mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-23 00:57:38 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f115f7e89 | ||
|
|
3a2203eaac | ||
|
|
38c320ea4c | ||
|
|
5c53cfc342 | ||
|
|
8683406690 | ||
|
|
126294a322 | ||
|
|
d2bbefbac8 | ||
|
|
ffe2284332 | ||
|
|
6345548b5f | ||
|
|
17a04f1f71 | ||
|
|
094a08b09c | ||
|
|
6775d13f59 |
@@ -53,8 +53,6 @@ runs:
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install setuptools for distutils compatibility
|
||||
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||
@@ -66,9 +64,9 @@ runs:
|
||||
# e.g. ./patches/ for standard-openapi
|
||||
# https://github.com/oven-sh/bun/issues/28147
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
bun install --linker hoisted ${{ inputs.install-flags }}
|
||||
bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }}
|
||||
else
|
||||
bun install ${{ inputs.install-flags }}
|
||||
bun install --frozen-lockfile ${{ inputs.install-flags }}
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
"effect": "4.0.0-rc.115",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -403,6 +403,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/which": "3.0.4",
|
||||
"drizzle-kit": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/desktop": {
|
||||
@@ -511,7 +512,7 @@
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.14",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.115",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -1075,6 +1076,7 @@
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
|
||||
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch",
|
||||
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
@@ -1098,10 +1100,10 @@
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
"@effect/opentelemetry": "4.0.0-rc.112",
|
||||
"@effect/platform-node": "4.0.0-rc.112",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
|
||||
"@effect/opentelemetry": "4.0.0-rc.115",
|
||||
"@effect/platform-node": "4.0.0-rc.115",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.115",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.115",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.13",
|
||||
@@ -1141,7 +1143,8 @@
|
||||
"dompurify": "3.4.14",
|
||||
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
||||
"drizzle-orm": "1.0.0-rc.5-169397b",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"effect": "4.0.0-rc.115",
|
||||
"fast-check": "4.9.0",
|
||||
"fuzzysort": "3.1.0",
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"hono": "4.10.7",
|
||||
@@ -1673,13 +1676,13 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="],
|
||||
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.112", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.112" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ=="],
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.115", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.115" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-ITOqqf/Z0mrs6XhuV8wb4YYFt0sdMhXZGPtytr+YG87YG6mFdYkc8IaJj8cGQFKRkGota/2CN+zjBKhQVI64OQ=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.112", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.112", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.115", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.115", "undici": "^8.10.2" }, "peerDependencies": { "effect": "^4.0.0-rc.115", "redis": ">=5.0.0 <7.0.0" } }, "sha512-mW03Wjq2FiTL5oi9yOkaKpCyUNBaduKgTk7qMHNELvC21Jq2qOAfdsfH+p+s2zmpB9w2RSa+ApODv4WjO9wQfw=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.112", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.115", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.115" } }, "sha512-rcBwhDIfb82akJoTv7Vwh8K5x9vC/XfuePS7JjOr+z+dORXzsmKqI1BUQF27ARCN9fTAbDh7I6YvBlWhLjnqNg=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.112", "", { "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-EqR8pWZo3VzedRvP6qo5HcfqNmFvSa+sQLWQl3oBMx2KY1FCc8NYjep79wYjNkW0qABnffrPUXCMTOncd1A+5g=="],
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.115", "", { "peerDependencies": { "effect": "^4.0.0-rc.115" } }, "sha512-CypwmCPAUSOOhopcnSI9ocgpKHfpgOdERpei3GuXZm5cCGP31pk2NUETqNaYhonhr2bWd+6Rc8atctnEbIYKYQ=="],
|
||||
|
||||
"@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.5", "", {}, "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA=="],
|
||||
|
||||
@@ -2013,18 +2016,6 @@
|
||||
|
||||
"@motionone/utils": ["@motionone/utils@10.18.0", "", { "dependencies": { "@motionone/types": "^10.17.1", "hey-listen": "^1.0.8", "tslib": "^2.3.1" } }, "sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
||||
|
||||
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
|
||||
@@ -3877,7 +3868,7 @@
|
||||
|
||||
"editorconfig": ["editorconfig@1.0.7", "", { "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", "minimatch": "^9.0.1", "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw=="],
|
||||
|
||||
"effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="],
|
||||
"effect": ["effect@4.0.0-rc.115", "", {}, "sha512-ogYulZ5ffeOzrJqQrG0XOkDO5lKn2s9KNHhoxJy/6wKUkF0i12dlVXWA9TgsgQ+zV/JzizyG0+XMPepZEQX6vw=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -4713,7 +4704,7 @@
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
|
||||
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
@@ -4761,10 +4752,6 @@
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="],
|
||||
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
|
||||
@@ -4807,8 +4794,6 @@
|
||||
|
||||
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
|
||||
|
||||
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
|
||||
|
||||
"node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="],
|
||||
|
||||
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
|
||||
@@ -5605,7 +5590,7 @@
|
||||
|
||||
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
|
||||
|
||||
"undici": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="],
|
||||
"undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
@@ -6059,8 +6044,6 @@
|
||||
|
||||
"@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="],
|
||||
|
||||
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@cloudflare/vite-plugin/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
@@ -6661,6 +6644,8 @@
|
||||
|
||||
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
||||
|
||||
"unifont/undici": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="],
|
||||
|
||||
"unplugin/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"unused-filename/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
+7
-6
@@ -42,10 +42,10 @@
|
||||
"packages/stats/*"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-rc.112",
|
||||
"@effect/platform-node": "4.0.0-rc.112",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
|
||||
"@effect/opentelemetry": "4.0.0-rc.115",
|
||||
"@effect/platform-node": "4.0.0-rc.115",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.115",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.115",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.4.0",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
@@ -79,7 +79,8 @@
|
||||
"dompurify": "3.4.14",
|
||||
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
||||
"drizzle-orm": "1.0.0-rc.5-169397b",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"effect": "4.0.0-rc.115",
|
||||
"fast-check": "4.9.0",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
@@ -173,7 +174,7 @@
|
||||
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
|
||||
@@ -26,7 +26,7 @@ import { OpenAI } from "@opencode/ai/providers"
|
||||
* hover imports and local values to see how the public API is typed.
|
||||
*/
|
||||
|
||||
const apiKey = Config.redacted("OPENAI_API_KEY")
|
||||
const apiKey = Config.Redacted("OPENAI_API_KEY")
|
||||
|
||||
// 1. Configure a provider. The configured facade records provider identity,
|
||||
// deployment options, authentication, and defaults. Per-modality selectors pick
|
||||
|
||||
@@ -278,7 +278,7 @@ const readEnvFile = Effect.fn("RecordingEnv.readFile")(function* () {
|
||||
})
|
||||
|
||||
const readConfigString = (provider: ConfigProvider.ConfigProvider, name: string) =>
|
||||
Config.string(name)
|
||||
Config.String(name)
|
||||
.parse(provider)
|
||||
.pipe(
|
||||
Effect.match({
|
||||
|
||||
@@ -89,7 +89,7 @@ export const optional = (secret: Secret | undefined, source = "optional value")
|
||||
? credential(Effect.fail(new MissingCredentialError(source)))
|
||||
: credentialFromSecret(secret, source)
|
||||
|
||||
export const config = (name: string) => credentialFromSecret(Config.redacted(name), name)
|
||||
export const config = (name: string) => credentialFromSecret(Config.Redacted(name), name)
|
||||
|
||||
export const effect = (load: Effect.Effect<Redacted.Redacted, CredentialError>) => credential(load)
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ export const toDefinitions = (tools: Tools): ReadonlyArray<ToolDefinitionClass>
|
||||
)
|
||||
|
||||
const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
|
||||
const document = Schema.toJsonSchemaDocument(schema)
|
||||
const document = Schema.toJsonSchemaDocument(schema, { onExcessProperty: "error" })
|
||||
if (Object.keys(document.definitions).length === 0) return document.schema
|
||||
return { ...document.schema, $defs: document.definitions }
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ type LanguageModel = {
|
||||
declare const auth: Auth.Definition
|
||||
declare const optionalAuthModel: LanguageModelFactory<BaseOptions, "optional", LanguageModel>
|
||||
declare const requiredAuthModel: LanguageModelFactory<BaseOptions, "required", LanguageModel>
|
||||
const configApiKey = Config.redacted("OPENAI_API_KEY")
|
||||
const configApiKey = Config.Redacted("OPENAI_API_KEY")
|
||||
|
||||
OpenAIChat.route.model({ id: "gpt-4.1-mini" })
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const Current = Persistence.struct({ session: SessionsSchema })
|
||||
|
||||
export const ModelSelectionSchema = Persistence.migrate(
|
||||
Current,
|
||||
Schema.Struct({
|
||||
Persistence.legacy({
|
||||
session: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
pick: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}).pipe(
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("persistence schemas", () => {
|
||||
|
||||
test("legacy migration observes missing fields before initial defaults are applied", () => {
|
||||
const current = Persistence.struct({ mode: Schema.Literals(["compact", "full"]), enabled: Schema.Boolean })
|
||||
const stored = Schema.Struct({
|
||||
const stored = Persistence.legacy({
|
||||
mode: Schema.optional(Schema.Unknown),
|
||||
expanded: Schema.optional(Schema.Boolean),
|
||||
}).pipe(
|
||||
@@ -53,6 +53,38 @@ describe("persistence schemas", () => {
|
||||
expect(Schema.encodeSync(schema)(decode({ expanded: true }))).toEqual({ mode: "full", enabled: true })
|
||||
})
|
||||
|
||||
test("legacy read shapes carry unknown nested fields through to the current schema", () => {
|
||||
const current = Persistence.struct({
|
||||
general: Persistence.struct({
|
||||
mode: Schema.Literals(["compact", "full"]),
|
||||
theme: Schema.String,
|
||||
nested: Persistence.struct({ keep: Schema.Boolean }),
|
||||
}),
|
||||
other: Persistence.struct({ flag: Schema.Boolean }),
|
||||
})
|
||||
const stored = Persistence.legacy({
|
||||
general: Persistence.optional(Persistence.legacy({ expanded: Persistence.optional(Schema.Boolean) })),
|
||||
}).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
const general = value.general
|
||||
if (!general || general.expanded === undefined) return value
|
||||
return { ...value, general: { ...general, mode: general.expanded ? "full" : "compact" } }
|
||||
}),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
const schema = Persistence.withInitial(Persistence.migrate(current, stored), {
|
||||
general: { mode: "compact", theme: "light", nested: { keep: false } },
|
||||
other: { flag: false },
|
||||
})
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
expect(decode({ general: { expanded: true, theme: "dark", nested: { keep: true } }, other: { flag: true } })).toEqual({
|
||||
general: { mode: "full", theme: "dark", nested: { keep: true } },
|
||||
other: { flag: true },
|
||||
})
|
||||
})
|
||||
|
||||
test("initial merging preserves field codecs and replaces arrays rather than merging indexes", () => {
|
||||
const current = Persistence.struct({
|
||||
amount: Schema.NumberFromString.check(Schema.isFinite()),
|
||||
|
||||
@@ -23,13 +23,11 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
initial: NoInfer<S["Type"]>,
|
||||
) {
|
||||
const schema = isMigrated(definition) ? definition.current : definition
|
||||
const read = isMigrated(definition)
|
||||
? SchemaParser.decodeUnknownResult(definition.read, { onExcessProperty: "preserve" })
|
||||
: Result.succeed<unknown>
|
||||
const read = isMigrated(definition) ? SchemaParser.decodeUnknownResult(definition.read) : Result.succeed<unknown>
|
||||
const encode = Schema.encodeUnknownSync(schema)
|
||||
return Schema.Unknown.pipe(
|
||||
Schema.decode<Schema.Unknown>({
|
||||
decode: SchemaGetter.transformOrFail((value) =>
|
||||
decode: SchemaGetter.transformEffect((value) =>
|
||||
Effect.fromResult(Result.map(read(value), (stored) => merge(initial, recover(schema.ast, stored, initial)))),
|
||||
),
|
||||
encode: SchemaGetter.transform((value) => encode(value)),
|
||||
@@ -38,6 +36,12 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
)
|
||||
}
|
||||
|
||||
// A legacy read shape declares only the fields it migrates. Decoding strips unknown keys, so every
|
||||
// struct level in a `migrate` read schema must stay open for the current fields it does not name.
|
||||
export function legacy<const Fields extends Schema.Struct.Fields>(fields: Fields) {
|
||||
return Schema.StructWithRest(Schema.Struct(fields), [Schema.Record(Schema.String, Schema.Unknown)])
|
||||
}
|
||||
|
||||
// Object-level codecs own their recovery. Plain structs can recover fields independently.
|
||||
function recover(ast: SchemaAST.AST, value: unknown, initial: unknown): unknown {
|
||||
if (value === undefined) return initial
|
||||
|
||||
@@ -53,7 +53,7 @@ const State = Persistence.struct({
|
||||
export function serverState(canonicalLocalServer: () => string | undefined = () => undefined) {
|
||||
return Persistence.migrate(
|
||||
State,
|
||||
Schema.Struct({ projects: Projects, lastProject: LastProject }).pipe(
|
||||
Persistence.legacy({ projects: Projects, lastProject: LastProject }).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
const canonical = canonicalLocalServer()
|
||||
|
||||
@@ -188,9 +188,9 @@ function legacyTimelineActivity(value: boolean | "hidden" | "compact" | "full" |
|
||||
|
||||
export const settingsPersistence = Persistence.migrate(
|
||||
settingsSchema,
|
||||
Schema.Struct({
|
||||
Persistence.legacy({
|
||||
general: Persistence.optional(
|
||||
Schema.Struct({
|
||||
Persistence.legacy({
|
||||
// Keep invalid explicit values distinct from absent values so legacy preferences cannot replace them.
|
||||
timelineDetail: Schema.optional(
|
||||
Schema.NullOr(
|
||||
|
||||
@@ -176,16 +176,16 @@ export const layoutSchema = Persistence.struct({
|
||||
|
||||
export const layoutPersistence = Persistence.migrate(
|
||||
layoutSchema,
|
||||
Schema.Struct({
|
||||
Persistence.legacy({
|
||||
sidebar: Persistence.optional(
|
||||
Schema.Struct({
|
||||
Persistence.legacy({
|
||||
workspaces: Persistence.optional(Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Schema.Boolean)])),
|
||||
workspacesDefault: Persistence.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
review: Persistence.optional(Schema.Struct({ panelOpened: Persistence.optional(Schema.Boolean) })),
|
||||
review: Persistence.optional(Persistence.legacy({ panelOpened: Persistence.optional(Schema.Boolean) })),
|
||||
fileTree: Persistence.optional(
|
||||
Schema.Struct({
|
||||
Persistence.legacy({
|
||||
opened: Persistence.optional(Schema.Boolean),
|
||||
width: Persistence.optional(Schema.Finite),
|
||||
tab: Persistence.optional(Schema.Literals(["changes", "all"])),
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
OpenCodeClient,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionStructuredError,
|
||||
} from "@opencode/client/promise"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { partsToContentChunks, type ReplayPart } from "./content"
|
||||
@@ -40,6 +41,13 @@ export type TurnStart =
|
||||
|
||||
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||
const RetryMeta = "opencode/retry"
|
||||
|
||||
type RetryStatus = {
|
||||
readonly attempt: number
|
||||
readonly nextRetryAt: string
|
||||
readonly error: SessionStructuredError
|
||||
}
|
||||
|
||||
type ChildSessionUpdateBase = {
|
||||
readonly rootSessionId: string
|
||||
@@ -97,6 +105,7 @@ export async function streamTurn(input: {
|
||||
let finish: SessionMessageAssistant["finish"]
|
||||
let executionError: { readonly type: string; readonly message: string } | undefined
|
||||
const tools = new Map<string, ToolState>()
|
||||
const retries = new Map<string, RetryStatus>()
|
||||
const children = new Map<string, ChildSession>()
|
||||
const openChildren = new Set<string>()
|
||||
let handedOff = false
|
||||
@@ -188,6 +197,18 @@ export async function streamTurn(input: {
|
||||
|
||||
if (event.type === "session.step.started") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
if (retries.delete(eventSessionID))
|
||||
await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: null } })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.retry.scheduled") {
|
||||
const retry = {
|
||||
attempt: event.data.attempt,
|
||||
nextRetryAt: new Date(event.data.at).toISOString(),
|
||||
error: event.data.error,
|
||||
}
|
||||
retries.set(eventSessionID, retry)
|
||||
await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: retry } })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
@@ -381,6 +402,7 @@ export async function streamTurn(input: {
|
||||
terminal,
|
||||
control.cancelled,
|
||||
finish,
|
||||
retries.get(input.sessionID),
|
||||
)
|
||||
} catch (error) {
|
||||
streamController.abort()
|
||||
@@ -560,6 +582,7 @@ function response(
|
||||
terminal: "succeeded" | "failed" | "interrupted",
|
||||
cancelled: boolean,
|
||||
finish: SessionMessageAssistant["finish"],
|
||||
retry?: RetryStatus,
|
||||
): PromptResponse {
|
||||
const error = assistant?.error ?? executionError
|
||||
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
|
||||
@@ -582,7 +605,8 @@ function response(
|
||||
}
|
||||
: undefined
|
||||
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: {} }
|
||||
// Only an interrupt during backoff leaves a retry pending. Interruption clears the projected retry, so report it here.
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
|
||||
}
|
||||
|
||||
function resolveStopReason(input: {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Schema } from "effect"
|
||||
import { Spec } from "../framework/spec"
|
||||
import { Updater } from "../services/updater"
|
||||
|
||||
export const PrintLogs = GlobalFlag.setting("print-logs")({
|
||||
flag: Flag.boolean("print-logs").pipe(
|
||||
export const PrintLogs = GlobalFlag.Setting("print-logs")({
|
||||
flag: Flag.Boolean("print-logs").pipe(
|
||||
Flag.withDescription("Print logs to stderr (server logs require --standalone)"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
@@ -13,23 +13,23 @@ export const PrintLogs = GlobalFlag.setting("print-logs")({
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
const ServerParams = {
|
||||
standalone: Flag.boolean("standalone").pipe(
|
||||
standalone: Flag.Boolean("standalone").pipe(
|
||||
Flag.withDescription("Run with a private server instead of the background service"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
server: Flag.string("server").pipe(
|
||||
server: Flag.String("server").pipe(
|
||||
Flag.withDescription("Connect to a server URL instead of the background service"),
|
||||
Flag.optional,
|
||||
),
|
||||
}
|
||||
|
||||
const PermissionParams = {
|
||||
auto: Flag.boolean("auto").pipe(
|
||||
auto: Flag.Boolean("auto").pipe(
|
||||
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe(
|
||||
yolo: Flag.Boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
dangerouslySkipPermissions: Flag.Boolean("dangerously-skip-permissions").pipe(
|
||||
Flag.withDefault(false),
|
||||
Flag.withHidden,
|
||||
),
|
||||
@@ -40,32 +40,32 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
params: {
|
||||
...ServerParams,
|
||||
...PermissionParams,
|
||||
directory: Argument.string("directory").pipe(
|
||||
directory: Argument.String("directory").pipe(
|
||||
Argument.withDescription("Directory to start OpenCode in"),
|
||||
Argument.optional,
|
||||
),
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
continue: Flag.Boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
session: Flag.string("session").pipe(
|
||||
session: Flag.String("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
prompt: Flag.String("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
},
|
||||
commands: [
|
||||
Spec.make("upgrade", {
|
||||
description: "Upgrade OpenCode to the latest or a specific version",
|
||||
aliases: ["update"],
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
target: Argument.String("target").pipe(
|
||||
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
|
||||
Argument.optional,
|
||||
),
|
||||
method: Flag.choice("method", Updater.methods).pipe(
|
||||
method: Flag.Literals("method", Updater.methods).pipe(
|
||||
Flag.withAlias("m"),
|
||||
Flag.withDescription("Installation method to use"),
|
||||
Flag.optional,
|
||||
@@ -75,21 +75,21 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("uninstall", {
|
||||
description: "Uninstall OpenCode and remove all related files",
|
||||
params: {
|
||||
keepConfig: Flag.boolean("keep-config").pipe(
|
||||
keepConfig: Flag.Boolean("keep-config").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Keep configuration files"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
keepData: Flag.boolean("keep-data").pipe(
|
||||
keepData: Flag.Boolean("keep-data").pipe(
|
||||
Flag.withAlias("d"),
|
||||
Flag.withDescription("Keep session data and snapshots"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
dryRun: Flag.boolean("dry-run").pipe(
|
||||
dryRun: Flag.Boolean("dry-run").pipe(
|
||||
Flag.withDescription("Show what would be removed without removing"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
force: Flag.boolean("force").pipe(
|
||||
force: Flag.Boolean("force").pipe(
|
||||
Flag.withAlias("f"),
|
||||
Flag.withDescription("Skip confirmation prompts"),
|
||||
Flag.withDefault(false),
|
||||
@@ -101,17 +101,17 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Make a request to the running server",
|
||||
params: {
|
||||
...ServerParams,
|
||||
request: Argument.string("operation | method path").pipe(
|
||||
request: Argument.String("operation | method path").pipe(
|
||||
Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"),
|
||||
Argument.variadic({ min: 1, max: 2 }),
|
||||
),
|
||||
data: Flag.string("data").pipe(Flag.withAlias("d"), Flag.withDescription("Request body"), Flag.optional),
|
||||
header: Flag.string("header").pipe(
|
||||
data: Flag.String("data").pipe(Flag.withAlias("d"), Flag.withDescription("Request body"), Flag.optional),
|
||||
header: Flag.String("header").pipe(
|
||||
Flag.withAlias("H"),
|
||||
Flag.withDescription("Request header in name:value form"),
|
||||
Flag.atMost(100),
|
||||
),
|
||||
param: Flag.keyValuePair("param").pipe(Flag.withDescription("OpenAPI path or query parameter"), Flag.optional),
|
||||
param: Flag.KeyValuePair("param").pipe(Flag.withDescription("OpenAPI path or query parameter"), Flag.optional),
|
||||
},
|
||||
}),
|
||||
Spec.make("debug", {
|
||||
@@ -122,7 +122,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("paths", {
|
||||
description: "Show global paths (data, config, cache, state)",
|
||||
params: {
|
||||
name: Argument.choice("name", [
|
||||
name: Argument.Literals("name", [
|
||||
"db",
|
||||
"home",
|
||||
"data",
|
||||
@@ -134,7 +134,9 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
"log",
|
||||
"repos",
|
||||
]).pipe(
|
||||
Argument.withDescription("Print only one path: db, home, data, config, cache, state, tmp, bin, log, repos"),
|
||||
Argument.withDescription(
|
||||
"Print only one path: db, home, data, config, cache, state, tmp, bin, log, repos",
|
||||
),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
@@ -148,7 +150,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "list providers and credentials",
|
||||
params: {
|
||||
...ServerParams,
|
||||
format: Flag.choice("format", ["default", "json"]).pipe(
|
||||
format: Flag.Literals("format", ["default", "json"]).pipe(
|
||||
Flag.withDescription("Output format"),
|
||||
Flag.withDefault("default"),
|
||||
),
|
||||
@@ -158,12 +160,12 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "log in to a provider",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
target: Argument.String("target").pipe(
|
||||
Argument.withDescription("Integration ID, name, or well-known provider URL"),
|
||||
Argument.optional,
|
||||
),
|
||||
method: Flag.string("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
|
||||
answer: Flag.string("answer").pipe(
|
||||
method: Flag.String("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
|
||||
answer: Flag.String("answer").pipe(
|
||||
Flag.withDescription("Provider form answer (key=value; repeat for multiple fields)"),
|
||||
Flag.atMost(100),
|
||||
),
|
||||
@@ -173,11 +175,11 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "log out of a saved account",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
target: Argument.String("target").pipe(
|
||||
Argument.withDescription("Integration ID or name"),
|
||||
Argument.optional,
|
||||
),
|
||||
credential: Argument.string("credential").pipe(
|
||||
credential: Argument.String("credential").pipe(
|
||||
Argument.withDescription("Credential ID or label (opens an account picker when omitted)"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -187,11 +189,11 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "switch the active account for an integration",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
target: Argument.String("target").pipe(
|
||||
Argument.withDescription("Integration ID or name"),
|
||||
Argument.optional,
|
||||
),
|
||||
credential: Argument.string("credential").pipe(
|
||||
credential: Argument.String("credential").pipe(
|
||||
Argument.withDescription("Credential ID or label (opens an account picker when omitted)"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -206,21 +208,21 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("add", {
|
||||
description: "Add an MCP server to your configuration",
|
||||
params: {
|
||||
name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")),
|
||||
command: Argument.string("command").pipe(
|
||||
name: Argument.String("name").pipe(Argument.withDescription("Name of the MCP server")),
|
||||
command: Argument.String("command").pipe(
|
||||
Argument.withDescription("Command and arguments for a local server, passed after --"),
|
||||
Argument.variadic({ min: 0 }),
|
||||
),
|
||||
url: Flag.string("url").pipe(Flag.withDescription("URL for a remote MCP server"), Flag.optional),
|
||||
header: Flag.keyValuePair("header").pipe(
|
||||
url: Flag.String("url").pipe(Flag.withDescription("URL for a remote MCP server"), Flag.optional),
|
||||
header: Flag.KeyValuePair("header").pipe(
|
||||
Flag.withDescription("HTTP header for a remote server, as name=value"),
|
||||
Flag.optional,
|
||||
),
|
||||
env: Flag.keyValuePair("env").pipe(
|
||||
env: Flag.KeyValuePair("env").pipe(
|
||||
Flag.withDescription("Environment variable for a local server, as name=value"),
|
||||
Flag.optional,
|
||||
),
|
||||
global: Flag.boolean("global").pipe(
|
||||
global: Flag.Boolean("global").pipe(
|
||||
Flag.withDescription("Write to the global config instead of the project config"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
@@ -228,11 +230,11 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
}),
|
||||
Spec.make("auth", {
|
||||
description: "Authenticate with an OAuth-capable remote MCP server",
|
||||
params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
params: { name: Argument.String("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
}),
|
||||
Spec.make("logout", {
|
||||
description: "Remove stored OAuth credentials for an MCP server",
|
||||
params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
params: { name: Argument.String("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -242,7 +244,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("list", {
|
||||
description: "List plugins",
|
||||
params: {
|
||||
builtin: Flag.boolean("builtin").pipe(
|
||||
builtin: Flag.Boolean("builtin").pipe(
|
||||
Flag.withDescription("Include built-in server plugins"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
@@ -251,13 +253,13 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("add", {
|
||||
description: "Install a plugin and add it to the global configuration",
|
||||
params: {
|
||||
package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
|
||||
package: Argument.String("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
|
||||
},
|
||||
}),
|
||||
Spec.make("check", {
|
||||
description: "Check package plugins for updates",
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
target: Argument.String("target").pipe(
|
||||
Argument.withDescription("Configured package target"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -266,7 +268,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("update", {
|
||||
description: "Update package plugins",
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
target: Argument.String("target").pipe(
|
||||
Argument.withDescription("Configured package target; omit to update all outdated plugins"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -275,7 +277,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("remove", {
|
||||
description: "Remove a plugin from global configuration",
|
||||
params: {
|
||||
package: Argument.string("package").pipe(Argument.withDescription("configured package specifier")),
|
||||
package: Argument.String("package").pipe(Argument.withDescription("configured package specifier")),
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -288,108 +290,108 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Show shareable usage statistics",
|
||||
params: {
|
||||
...ServerParams,
|
||||
days: Flag.integer("days").pipe(
|
||||
days: Flag.Int("days").pipe(
|
||||
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
|
||||
Flag.withDescription("Show the last N days; 0 means today"),
|
||||
Flag.optional,
|
||||
),
|
||||
year: Flag.integer("year").pipe(
|
||||
year: Flag.Int("year").pipe(
|
||||
Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1970, maximum: 9_999 }))),
|
||||
Flag.withDescription("Show a calendar year"),
|
||||
Flag.optional,
|
||||
),
|
||||
all: Flag.boolean("all").pipe(Flag.withDescription("Show lifetime statistics"), Flag.withDefault(false)),
|
||||
project: Flag.string("project").pipe(
|
||||
all: Flag.Boolean("all").pipe(Flag.withDescription("Show lifetime statistics"), Flag.withDefault(false)),
|
||||
project: Flag.String("project").pipe(
|
||||
Flag.withDescription('Filter by project ID, or use "." for the current project'),
|
||||
Flag.optional,
|
||||
),
|
||||
models: Flag.boolean("models").pipe(Flag.withDescription("Show model usage"), Flag.withDefault(false)),
|
||||
tools: Flag.boolean("tools").pipe(Flag.withDescription("Show tool reliability"), Flag.withDefault(false)),
|
||||
cost: Flag.boolean("cost").pipe(Flag.withDescription("Show cost and token details"), Flag.withDefault(false)),
|
||||
full: Flag.boolean("full").pipe(Flag.withDescription("Show every detailed section"), Flag.withDefault(false)),
|
||||
limit: Flag.integer("limit").pipe(
|
||||
models: Flag.Boolean("models").pipe(Flag.withDescription("Show model usage"), Flag.withDefault(false)),
|
||||
tools: Flag.Boolean("tools").pipe(Flag.withDescription("Show tool reliability"), Flag.withDefault(false)),
|
||||
cost: Flag.Boolean("cost").pipe(Flag.withDescription("Show cost and token details"), Flag.withDefault(false)),
|
||||
full: Flag.Boolean("full").pipe(Flag.withDescription("Show every detailed section"), Flag.withDefault(false)),
|
||||
limit: Flag.Int("limit").pipe(
|
||||
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
||||
Flag.withDescription("Number of rows in detailed sections"),
|
||||
Flag.withDefault(5),
|
||||
),
|
||||
json: Flag.boolean("json").pipe(Flag.withDescription("Output statistics as JSON"), Flag.withDefault(false)),
|
||||
json: Flag.Boolean("json").pipe(Flag.withDescription("Output statistics as JSON"), Flag.withDefault(false)),
|
||||
},
|
||||
}),
|
||||
Spec.make("mini", {
|
||||
description: "Start the minimal interactive interface",
|
||||
params: {
|
||||
...ServerParams,
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
continue: Flag.Boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
session: Flag.string("session").pipe(
|
||||
session: Flag.String("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
fork: Flag.boolean("fork").pipe(
|
||||
fork: Flag.Boolean("fork").pipe(
|
||||
Flag.withDescription("Fork the session when continuing"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
replay: Flag.boolean("replay").pipe(
|
||||
replay: Flag.Boolean("replay").pipe(
|
||||
Flag.withDescription("Restore session history on resume and resize (disable with --no-replay)"),
|
||||
Flag.optional,
|
||||
),
|
||||
replayLimit: Flag.integer("replay-limit").pipe(
|
||||
replayLimit: Flag.Int("replay-limit").pipe(
|
||||
Flag.withDescription("Limit replay to the newest N messages (default: 200)"),
|
||||
Flag.optional,
|
||||
),
|
||||
model: Flag.string("model").pipe(
|
||||
model: Flag.String("model").pipe(
|
||||
Flag.withAlias("m"),
|
||||
Flag.withDescription("Model to use in the format provider/model"),
|
||||
Flag.optional,
|
||||
),
|
||||
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
demo: Flag.boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
agent: Flag.String("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
prompt: Flag.String("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
demo: Flag.Boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
},
|
||||
}),
|
||||
Spec.make("run", {
|
||||
description: "Run OpenCode with a message",
|
||||
params: {
|
||||
...ServerParams,
|
||||
message: Argument.string("message").pipe(
|
||||
message: Argument.String("message").pipe(
|
||||
Argument.withDescription("Message to send"),
|
||||
Argument.variadic({ min: 0 }),
|
||||
),
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
continue: Flag.Boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
session: Flag.string("session").pipe(
|
||||
session: Flag.String("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
fork: Flag.boolean("fork").pipe(
|
||||
fork: Flag.Boolean("fork").pipe(
|
||||
Flag.withDescription("Fork the session before continuing"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
model: Flag.string("model").pipe(
|
||||
model: Flag.String("model").pipe(
|
||||
Flag.withAlias("m"),
|
||||
Flag.withDescription("Model to use in the format provider/model#variant"),
|
||||
Flag.optional,
|
||||
),
|
||||
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
format: Flag.choice("format", ["default", "json"]).pipe(
|
||||
agent: Flag.String("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
format: Flag.Literals("format", ["default", "json"]).pipe(
|
||||
Flag.withDescription("Output format"),
|
||||
Flag.withDefault("default"),
|
||||
),
|
||||
file: Flag.string("file").pipe(
|
||||
file: Flag.String("file").pipe(
|
||||
Flag.withAlias("f"),
|
||||
Flag.withDescription("File to attach to the message"),
|
||||
Flag.atMost(100),
|
||||
),
|
||||
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
|
||||
thinking: Flag.boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)),
|
||||
title: Flag.String("title").pipe(Flag.withDescription("Session title"), Flag.optional),
|
||||
thinking: Flag.Boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)),
|
||||
...PermissionParams,
|
||||
},
|
||||
}),
|
||||
@@ -400,13 +402,13 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "List top-level sessions in the current project, newest first",
|
||||
params: {
|
||||
...ServerParams,
|
||||
maxCount: Flag.integer("max-count").pipe(
|
||||
maxCount: Flag.Int("max-count").pipe(
|
||||
Flag.withAlias("n"),
|
||||
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
||||
Flag.withDescription("Limit to N most recent sessions (default: 100)"),
|
||||
Flag.optional,
|
||||
),
|
||||
format: Flag.choice("format", ["table", "json"]).pipe(
|
||||
format: Flag.Literals("format", ["table", "json"]).pipe(
|
||||
Flag.withDescription("Output format"),
|
||||
Flag.withDefault("table"),
|
||||
),
|
||||
@@ -416,18 +418,18 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Delete a session and its child sessions",
|
||||
params: {
|
||||
...ServerParams,
|
||||
sessionID: Argument.string("sessionID").pipe(Argument.withDescription("Session ID to delete")),
|
||||
sessionID: Argument.String("sessionID").pipe(Argument.withDescription("Session ID to delete")),
|
||||
},
|
||||
}),
|
||||
Spec.make("export", {
|
||||
description: "Export session data as JSON",
|
||||
params: {
|
||||
...ServerParams,
|
||||
session: Argument.string("session").pipe(
|
||||
session: Argument.String("session").pipe(
|
||||
Argument.withDescription("Session ID to export"),
|
||||
Argument.optional,
|
||||
),
|
||||
sanitize: Flag.boolean("sanitize").pipe(
|
||||
sanitize: Flag.Boolean("sanitize").pipe(
|
||||
Flag.withDescription("Redact sensitive transcript and file data"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
@@ -437,8 +439,8 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Import session data from a JSON file or URL",
|
||||
params: {
|
||||
...ServerParams,
|
||||
file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
|
||||
directory: Flag.string("directory").pipe(
|
||||
file: Argument.String("file").pipe(Argument.withDescription("JSON file or URL to import")),
|
||||
directory: Flag.String("directory").pipe(
|
||||
Flag.withDescription("Directory in which to import the session"),
|
||||
Flag.optional,
|
||||
),
|
||||
@@ -456,8 +458,8 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("get", {
|
||||
description: "Get service configuration",
|
||||
params: {
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional),
|
||||
name: Argument.string("name").pipe(
|
||||
key: Argument.String("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional),
|
||||
name: Argument.String("name").pipe(
|
||||
Argument.withDescription("Environment variable name"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -466,11 +468,11 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("set", {
|
||||
description: "Set service configuration",
|
||||
params: {
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
value: Argument.string("value").pipe(
|
||||
key: Argument.String("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
value: Argument.String("value").pipe(
|
||||
Argument.withDescription("Setting value or environment variable name"),
|
||||
),
|
||||
nestedValue: Argument.string("env-value").pipe(
|
||||
nestedValue: Argument.String("env-value").pipe(
|
||||
Argument.withDescription("Environment variable value"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -479,8 +481,8 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("unset", {
|
||||
description: "Unset service configuration",
|
||||
params: {
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
name: Argument.string("name").pipe(
|
||||
key: Argument.String("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
name: Argument.String("name").pipe(
|
||||
Argument.withDescription("Environment variable name"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -497,7 +499,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("pair", {
|
||||
description: "Show server pairing information",
|
||||
params: {
|
||||
url: Flag.string("url").pipe(
|
||||
url: Flag.String("url").pipe(
|
||||
Flag.withDescription("Advertise an external HTTP(S) server URL in the pairing QR code"),
|
||||
Flag.mapTryCatch(
|
||||
(value) => {
|
||||
@@ -515,15 +517,15 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("serve", {
|
||||
description: "Start the v2 API and web server",
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
cors: Flag.string("cors").pipe(
|
||||
hostname: Flag.String("hostname").pipe(Flag.optional),
|
||||
port: Flag.Int("port").pipe(Flag.optional),
|
||||
cors: Flag.String("cors").pipe(
|
||||
Flag.withSchema(Schema.NonEmptyString),
|
||||
Flag.withDescription("Additional allowed CORS origin (repeat for multiple origins)"),
|
||||
Flag.atLeast(0),
|
||||
),
|
||||
service: Flag.boolean("service").pipe(Flag.withDefault(false)),
|
||||
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
|
||||
service: Flag.Boolean("service").pipe(Flag.withDefault(false)),
|
||||
stdio: Flag.Boolean("stdio").pipe(Flag.withDefault(false)),
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -7,8 +7,8 @@ import { Config } from "effect"
|
||||
// The opencode server password: sent by clients connecting to an explicit
|
||||
// --server, and adopted by a manually run or standalone server. The legacy
|
||||
// name is still honored.
|
||||
export const password = Config.redacted("OPENCODE_PASSWORD").pipe(
|
||||
Config.orElse(() => Config.redacted("OPENCODE_SERVER_PASSWORD")),
|
||||
export const password = Config.Redacted("OPENCODE_PASSWORD").pipe(
|
||||
Config.orElse(() => Config.Redacted("OPENCODE_SERVER_PASSWORD")),
|
||||
Config.withDefault(undefined),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Service, type Info } from "@opencode/client/effect/service"
|
||||
import path from "node:path"
|
||||
import { Effect, FileSystem, Schedule, Schema } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { NetAddress } from "effect/unstable/net"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
|
||||
const infoJson = Schema.fromJsonString(Service.Info)
|
||||
@@ -11,7 +12,7 @@ const encodeInfo = Schema.encodeEffect(infoJson)
|
||||
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
|
||||
|
||||
export const register = Effect.fnUntraced(function* (options: {
|
||||
readonly address: HttpServer.Address
|
||||
readonly address: NetAddress.SocketAddress
|
||||
readonly password: string
|
||||
readonly id: string
|
||||
readonly file: string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NodeSocket } from "@effect/platform-node"
|
||||
import { Effect, Schema, Stdio, Stream } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
|
||||
const Response = Schema.fromJsonString(Schema.Struct({ value: Schema.NullOr(Schema.String) }))
|
||||
const Port = Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(65535))
|
||||
@@ -10,16 +11,15 @@ export const askpass = Effect.gen(function* () {
|
||||
const port = yield* Schema.decodeUnknownEffect(Port)(process.env.OPENCODE_SSH_ASKPASS_PORT)
|
||||
const stdio = yield* Stdio.Stdio
|
||||
const socket = yield* NodeSocket.makeNet({ host: "127.0.0.1", port })
|
||||
const write = yield* socket.writer
|
||||
const writer = yield* socket.writer
|
||||
const response = { text: "" }
|
||||
yield* Effect.all(
|
||||
[
|
||||
socket.runString((text) =>
|
||||
Effect.sync(() => {
|
||||
response.text += text
|
||||
}),
|
||||
),
|
||||
write(
|
||||
Effect.gen(function* () {
|
||||
const pull = yield* Socket.readerString(socket)
|
||||
while (true) response.text += (yield* pull).join("")
|
||||
}).pipe(Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void)),
|
||||
writer.write(
|
||||
JSON.stringify({
|
||||
token: process.env.OPENCODE_SSH_ASKPASS_TOKEN,
|
||||
text: process.argv.slice(2).join(" "),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PromptResponse, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
|
||||
|
||||
describe("acp retry subprocess", () => {
|
||||
test("reports provider retries during the turn and on cancellation", async () => {
|
||||
await using fixture = await createAcpFixture({
|
||||
respond: () =>
|
||||
Response.json(
|
||||
{ error: { message: "rate-limited upstream", type: "rate_limit_error" } },
|
||||
// A long backoff keeps the retry pending until the cancel lands.
|
||||
{ status: 429, headers: { "retry-after": "30" } },
|
||||
),
|
||||
})
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const prompt = acp.send<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
const scheduled = await acp.waitForNotification<SessionNotification>(
|
||||
"session/update",
|
||||
(params) => params.sessionId === session.sessionId && params.update.sessionUpdate === "session_info_update",
|
||||
)
|
||||
const retry = scheduled.params.update._meta?.["opencode/retry"]
|
||||
expect(retry).toMatchObject({ attempt: 2, error: { message: expect.stringContaining("rate-limited upstream") } })
|
||||
|
||||
await acp.notify("session/cancel", { sessionId: session.sessionId })
|
||||
const response = expectOk(await prompt.response)
|
||||
expect(response.stopReason).toBe("cancelled")
|
||||
expect(response._meta?.["opencode/retry"]).toEqual(retry)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -70,7 +70,10 @@ description: Verifier compatibility skill.
|
||||
`
|
||||
|
||||
export async function createAcpFixture(
|
||||
options: { readonly skill?: string; readonly respond?: (request: unknown) => string | Promise<string> } = {},
|
||||
options: {
|
||||
readonly skill?: string
|
||||
readonly respond?: (request: unknown) => string | Response | Promise<string | Response>
|
||||
} = {},
|
||||
) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
|
||||
const home = path.join(root, "workspace")
|
||||
@@ -93,7 +96,9 @@ export async function createAcpFixture(
|
||||
}
|
||||
const body: unknown = await request.json().catch(() => undefined)
|
||||
requests.push(body)
|
||||
return new Response(completion(await (options.respond?.(body) ?? "accepted")), {
|
||||
const result = await (options.respond?.(body) ?? "accepted")
|
||||
if (result instanceof Response) return result
|
||||
return new Response(completion(result), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Global } from "@opencode/util/global"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { NetAddress } from "effect/unstable/net"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
@@ -501,7 +502,7 @@ test("service registration replaces a stale owner with the bound address", async
|
||||
try {
|
||||
const cleanup = await Effect.runPromise(
|
||||
ServiceRegistration.register({
|
||||
address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 4321 },
|
||||
address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 4321),
|
||||
password: "secret",
|
||||
id: "owner",
|
||||
file: registration,
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@opencode/protocol": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
"effect": "4.0.0-rc.115",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -241,6 +241,7 @@ export type SessionSwitchModelOperation<E = never> = (
|
||||
export type SessionUpdateInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: Session.Metadata | undefined
|
||||
readonly permissions?: Permission.Ruleset | undefined
|
||||
}
|
||||
export type SessionUpdateOutput = void
|
||||
@@ -516,6 +517,20 @@ export type SessionLogOutput =
|
||||
| undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.metadata.updated"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?:
|
||||
| {
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: (string & Brand.Brand<"Workspace.ID">) | undefined
|
||||
}
|
||||
| undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly metadata: Session.Metadata }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
|
||||
@@ -453,7 +453,7 @@ const EndpointSessionUpdate = (raw: RawClient["server.session"]) => (input: Sess
|
||||
preserveEffect<SessionUpdateOutput>()(
|
||||
raw["session.update"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { title: input["title"], permissions: input["permissions"] },
|
||||
payload: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
|
||||
@@ -665,7 +665,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
|
||||
body: { title: input["title"], permissions: input["permissions"] },
|
||||
body: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: true,
|
||||
|
||||
@@ -1221,6 +1221,16 @@ export type SessionMoved = {
|
||||
|
||||
export type SessionInboxMovePayload1 = { location: LocationRef; projectID: string; subpath?: string }
|
||||
|
||||
export type SessionMetadataUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.metadata.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; metadata: SessionMetadata }
|
||||
}
|
||||
|
||||
export type SessionShellStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2308,6 +2318,7 @@ export type SessionEventDurable =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionMetadataUpdated
|
||||
| SessionPermissions
|
||||
| SessionViewed
|
||||
| SessionDeleted
|
||||
@@ -2370,6 +2381,7 @@ export type V2Event =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionMetadataUpdated
|
||||
| SessionPermissions
|
||||
| SessionViewed
|
||||
| SessionUsageUpdated
|
||||
@@ -3946,12 +3958,21 @@ export type SessionUpdateInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly title?: {
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
}["title"]
|
||||
readonly metadata?: {
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
}["metadata"]
|
||||
readonly permissions?: {
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { JsonPointer, Schema } from "effect"
|
||||
import { JsonPointer, Schema, SchemaAST } from "effect"
|
||||
import type { Tool, JsonSchema, SchemaType } from "./tool.js"
|
||||
|
||||
const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
|
||||
@@ -203,7 +203,7 @@ const renderSchema = (
|
||||
export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => {
|
||||
try {
|
||||
const visible = decoded ? Schema.toType(schema) : schema
|
||||
const document = Schema.toJsonSchemaDocument(visible) as {
|
||||
const document = Schema.toJsonSchemaDocument(visible, { onExcessProperty: "error" }) as {
|
||||
readonly schema: JsonSchema
|
||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||
}
|
||||
@@ -230,7 +230,7 @@ export type InputProperty = {
|
||||
export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
||||
try {
|
||||
const document = isEffectSchema(tool.input)
|
||||
? (Schema.toJsonSchemaDocument(tool.input) as {
|
||||
? (Schema.toJsonSchemaDocument(tool.input, { onExcessProperty: "error" }) as {
|
||||
readonly schema: JsonSchema
|
||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||
})
|
||||
@@ -261,10 +261,16 @@ export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
||||
export const inputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
||||
isEffectSchema(tool.input) ? toTypeScript(tool.input, false, pretty) : jsonSchemaToTypeScript(tool.input, pretty)
|
||||
|
||||
// Empty object schemas render as `{}` in compact form; anything with properties,
|
||||
// an index signature, or union members renders differently, so equality is a
|
||||
// conservative emptiness test for both Effect and JSON Schema inputs.
|
||||
export const isEmptyInput = <R>(tool: Tool<R>): boolean => inputTypeScript(tool) === "{}"
|
||||
// Effect 4.0 models an empty Struct as a non-nullish object, so inspect its AST.
|
||||
// Raw JSON Schema inputs retain the compact `{}` rendering check.
|
||||
export const isEmptyInput = <R>(tool: Tool<R>): boolean => {
|
||||
if (!isEffectSchema(tool.input)) return inputTypeScript(tool) === "{}"
|
||||
return (
|
||||
SchemaAST.isObjects(tool.input.ast) &&
|
||||
tool.input.ast.propertySignatures.length === 0 &&
|
||||
tool.input.ast.indexSignatures.length === 0
|
||||
)
|
||||
}
|
||||
|
||||
export const outputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
||||
tool.output === undefined
|
||||
|
||||
@@ -98,7 +98,8 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/which": "3.0.4",
|
||||
"drizzle-kit": "catalog:"
|
||||
"drizzle-kit": "catalog:",
|
||||
"fast-check": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
|
||||
@@ -99,7 +99,7 @@ export const layer = (options?: Options) =>
|
||||
const wellknown = yield* WellKnown.Service
|
||||
const reloadLock = Semaphore.makeUnsafe(1)
|
||||
const updateLock = Semaphore.makeUnsafe(1)
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
||||
const errors: ParseError[] = []
|
||||
@@ -330,7 +330,8 @@ export const layer = (options?: Options) =>
|
||||
function* (patch: Patch) {
|
||||
const directory = initial.global ?? AbsolutePath.make(globalService.config)
|
||||
const candidates = ConfigDiscovery.names.map((name) => path.join(directory, name))
|
||||
const filepath = (yield* Effect.filter(candidates, fs.isFile)).at(-1) ?? path.join(directory, "opencode.jsonc")
|
||||
const filepath =
|
||||
(yield* Effect.filter(candidates, fs.isFile)).at(-1) ?? path.join(directory, "opencode.jsonc")
|
||||
const text = (yield* fs.readFileStringSafe(filepath)) ?? "{}\n"
|
||||
const updated = yield* Effect.try({
|
||||
try: () =>
|
||||
|
||||
@@ -42,7 +42,7 @@ export type Result =
|
||||
}
|
||||
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
|
||||
|
||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const options = { errors: "all", onExcessProperty: "ignore" } as const
|
||||
const unsupportedTopLevel = ["logLevel", "server", "subagent_depth", "layout"] as const
|
||||
const unsupportedExperimental = [
|
||||
"disable_paste_summary",
|
||||
|
||||
@@ -196,11 +196,11 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
|
||||
const agent = legacy
|
||||
? Option.getOrUndefined(
|
||||
Option.map(
|
||||
decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all", propertyOrder: "original" }),
|
||||
decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all" }),
|
||||
ConfigMigrateV1.migrateAgent,
|
||||
),
|
||||
)
|
||||
: Option.getOrUndefined(decodeAgent({ ...data, system: body }, { errors: "all", propertyOrder: "original" }))
|
||||
: Option.getOrUndefined(decodeAgent({ ...data, system: body }, { errors: "all" }))
|
||||
if (!agent) return
|
||||
const info = Option.getOrUndefined(
|
||||
decodeConfig({
|
||||
|
||||
@@ -48,10 +48,9 @@ const proxy = (value: string, environment: Environment = process.env) => {
|
||||
)
|
||||
}
|
||||
|
||||
const constructorOptions = (input: string | Array<string> | undefined): WebSocketOptions => {
|
||||
const constructorOptions = (input: Socket.WebSocketConstructorOptions | undefined): WebSocketOptions => {
|
||||
if (typeof input === "string" || Array.isArray(input)) return { protocols: input }
|
||||
// AI routes pass handshake options through Effect's browser-shaped constructor.
|
||||
return (input ?? {}) as WebSocketOptions
|
||||
return { headers: input?.headers === undefined ? undefined : Headers.fromInput(input.headers) }
|
||||
}
|
||||
|
||||
const proxyAgent = (url: string, selectedProxy: string | undefined) => {
|
||||
|
||||
@@ -170,6 +170,10 @@ export interface Interface {
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly setMetadata: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
metadata: SessionSchema.Metadata
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly setPermissions: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
permissions: Permission.Ruleset
|
||||
@@ -417,6 +421,7 @@ const layer = Layer.effect(
|
||||
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
|
||||
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
|
||||
rename: (input) => sessions.forSession(input.sessionID).rename(input),
|
||||
setMetadata: (input) => sessions.forSession(input.sessionID).setMetadata(input),
|
||||
setPermissions: (input) => sessions.forSession(input.sessionID).setPermissions(input),
|
||||
move: moves.move,
|
||||
compact: (input) => sessions.forSession(input.sessionID).compact(input),
|
||||
|
||||
@@ -131,6 +131,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
)
|
||||
}),
|
||||
"session.renamed": () => Effect.void,
|
||||
"session.metadata.updated": () => Effect.void,
|
||||
"session.permissions": () => Effect.void,
|
||||
"session.deleted": () => Effect.void,
|
||||
"session.forked": () => Effect.void,
|
||||
|
||||
@@ -573,6 +573,14 @@ const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.MetadataUpdated, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ metadata: event.data.metadata, time_updated: event.created })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Permissions, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
|
||||
@@ -73,6 +73,13 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
yield* get(sessionID)
|
||||
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
|
||||
})
|
||||
const setMetadata = Effect.fn("Session.setMetadata")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { metadata: SessionSchema.Metadata },
|
||||
) {
|
||||
yield* get(sessionID)
|
||||
yield* bus.publish(SessionEvent.MetadataUpdated, { sessionID, metadata: input.metadata })
|
||||
})
|
||||
const setPermissions = Effect.fn("Session.setPermissions")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { permissions: Permission.Ruleset },
|
||||
@@ -342,6 +349,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
message,
|
||||
view,
|
||||
rename,
|
||||
setMetadata,
|
||||
setPermissions,
|
||||
switchAgent,
|
||||
switchModel,
|
||||
@@ -365,6 +373,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
const message = operations.message.bind(undefined, sessionID)
|
||||
const view = operations.view.bind(undefined, sessionID)
|
||||
const rename = operations.rename.bind(undefined, sessionID)
|
||||
const setMetadata = operations.setMetadata.bind(undefined, sessionID)
|
||||
const setPermissions = operations.setPermissions.bind(undefined, sessionID)
|
||||
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
|
||||
const switchModel = operations.switchModel.bind(undefined, sessionID)
|
||||
@@ -391,6 +400,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
message,
|
||||
view,
|
||||
rename,
|
||||
setMetadata,
|
||||
setPermissions,
|
||||
switchAgent,
|
||||
switchModel,
|
||||
|
||||
@@ -170,7 +170,7 @@ const standardJsonSchema = (schema: StandardSchemaV1<any, any>, io: "input" | "o
|
||||
}
|
||||
|
||||
const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
|
||||
const document = Schema.toJsonSchemaDocument(schema)
|
||||
const document = Schema.toJsonSchemaDocument(schema, { onExcessProperty: "error" })
|
||||
// Effect emits valid JSON Schema that some inference providers handle poorly. Simplify it
|
||||
// without changing validation: `{ type: "integer", allOf: [{ minimum: 0 }] }` becomes
|
||||
// `{ type: "integer", minimum: 0 }` only when no keyword would be overwritten. Named schemas
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ConfigProviderOptionsV1 } from "./provider-options.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { Model } from "../../model.js"
|
||||
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore" } as const
|
||||
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
|
||||
const encodeInfo = Schema.encodeSync(Info)
|
||||
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
|
||||
|
||||
@@ -11,9 +11,8 @@ export type Object = Schema.Schema.Type<typeof Object>
|
||||
export const Rule = Schema.Union([Action, Object]).annotate({ identifier: "PermissionRuleConfig" })
|
||||
export type Rule = Schema.Schema.Type<typeof Rule>
|
||||
|
||||
// Known permission keys get explicit types in the Effect schema for generated
|
||||
// docs/types. Runtime config parsing uses Effect's `propertyOrder: "original"`
|
||||
// parse option so user key order is preserved for permission precedence.
|
||||
// Known permission keys get explicit types in the Effect schema for generated docs/types.
|
||||
// StructWithRest also accepts custom permission names.
|
||||
const InputObject = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
read: Schema.optional(Rule),
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Logger, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Arbitrary } from "effect/unstable/arbitrary"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { Directory, Document, Event, Info } from "@opencode/schema/config"
|
||||
import { ConfigModel } from "@opencode/schema/config/model"
|
||||
@@ -632,17 +632,22 @@ describe("Config", () => {
|
||||
})
|
||||
|
||||
test("migrates arbitrary v1 configuration into valid v2 configuration", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
|
||||
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
|
||||
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
|
||||
),
|
||||
)
|
||||
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
const result = Effect.runSync(
|
||||
Arbitrary.checkEffect(
|
||||
Arbitrary.schema(ConfigV1.Info),
|
||||
(info) => {
|
||||
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
|
||||
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
|
||||
),
|
||||
)
|
||||
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
return true
|
||||
},
|
||||
{ runs: 100 },
|
||||
),
|
||||
)
|
||||
expect(Arbitrary.formatCheckFailure(result)).toBeUndefined()
|
||||
}, 30_000)
|
||||
|
||||
test("migrates the v1 experimental subagent depth", () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Duration, Schema } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { Arbitrary } from "effect/unstable/arbitrary"
|
||||
import { ConfigNormalize } from "@opencode/core/config/normalize"
|
||||
import { Info } from "@opencode/schema/config"
|
||||
|
||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const options = { errors: "all", onExcessProperty: "ignore" } as const
|
||||
|
||||
function normalized(input: unknown) {
|
||||
const result = ConfigNormalize.normalize(input)
|
||||
@@ -80,16 +80,21 @@ describe("ConfigNormalize", () => {
|
||||
})
|
||||
|
||||
test("preserves arbitrary JSON-round-tripped native configuration", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(Schema.toArbitrary(Info)(FastCheck), (info) => {
|
||||
const source = JSON.parse(JSON.stringify(Schema.encodeSync(Info)(info)))
|
||||
const result = normalized(source)
|
||||
expect(Schema.decodeUnknownSync(Info)(result.encoded)).toEqual(
|
||||
Schema.decodeUnknownSync(Info)(withoutEmptyCompatibilityContainers(source)),
|
||||
)
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
const result = Effect.runSync(
|
||||
Arbitrary.checkEffect(
|
||||
Arbitrary.schema(Info),
|
||||
(info) => {
|
||||
const source = JSON.parse(JSON.stringify(Schema.encodeSync(Info)(info)))
|
||||
const result = normalized(source)
|
||||
expect(Schema.decodeUnknownSync(Info)(result.encoded)).toEqual(
|
||||
Schema.decodeUnknownSync(Info)(withoutEmptyCompatibilityContainers(source)),
|
||||
)
|
||||
return true
|
||||
},
|
||||
{ runs: 100 },
|
||||
),
|
||||
)
|
||||
expect(Arbitrary.formatCheckFailure(result)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("merges named maps by entry and gives valid native entries precedence", () => {
|
||||
|
||||
@@ -248,20 +248,23 @@ describe("Npm.add", () => {
|
||||
expect(entries.added.directory).toContain("node_modules")
|
||||
})
|
||||
|
||||
test("installs a Git package from an npm ::path: subdirectory", async () => {
|
||||
test.each(["commit", "branch"])("installs a Git package from an npm ::path: subdirectory at a %s", async (ref) => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
const spec = `git+file://${fixture.repository}#${fixture.commit}::path:packages/subdirectory-plugin`
|
||||
const entry = await Effect.gen(function* () {
|
||||
const spec = `git+file://${fixture.repository}#${ref === "commit" ? fixture.commit : "fixture-branch"}::path:packages/subdirectory-plugin`
|
||||
const result = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
return { added: yield* npm.add(spec), outdated: yield* npm.check(spec) }
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
|
||||
expect(entry.name).toBe("fixture-subdirectory-plugin")
|
||||
expect(result.added.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
|
||||
expect(result.added.name).toBe("fixture-subdirectory-plugin")
|
||||
expect(result.added.version).toBe(fixture.commit)
|
||||
expect(await Bun.file(path.join(result.added.directory, "index.js")).text()).toContain("subdirectory: true")
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
|
||||
await fs.stat(path.join(path.dirname(result.added.directory), "fixture-subdirectory-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
expect(result.outdated).toBeFalse()
|
||||
})
|
||||
|
||||
// Several real Git installs and updates exceed Bun's default timeout on Windows.
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Money } from "@opencode/schema/money"
|
||||
import { Shell } from "@opencode/schema/shell"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
@@ -385,6 +385,26 @@ describe("Session.create", () => {
|
||||
|
||||
// Absent stays absent: no empty-object normalization.
|
||||
expect((yield* session.create({ location })).metadata).toBeUndefined()
|
||||
|
||||
const replacement = { thread: "updated", owner: "host" }
|
||||
yield* session.setMetadata({ sessionID: created.id, metadata: replacement })
|
||||
expect((yield* session.get(created.id)).metadata).toEqual(replacement)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ data: EventTable.data })
|
||||
.from(EventTable)
|
||||
.where(
|
||||
and(
|
||||
eq(EventTable.aggregate_id, created.id),
|
||||
eq(EventTable.type, Bus.versionedType(SessionEvent.MetadataUpdated.type, 1)),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
).toMatchObject({ data: { metadata: replacement } })
|
||||
expect(
|
||||
yield* session.setMetadata({ sessionID: Session.ID.create(), metadata: replacement }).pipe(Effect.flip),
|
||||
).toBeInstanceOf(Session.NotFoundError)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { State } from "@opencode/core/state"
|
||||
import { Effect } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import FastCheck from "fast-check"
|
||||
|
||||
type Operation = { multiply: number; add: number }
|
||||
type Value = { value: number; order: number[] }
|
||||
|
||||
@@ -1042,7 +1042,7 @@ describe("Tool", () => {
|
||||
value: Schema.Boolean.pipe(
|
||||
Schema.decodeTo(Schema.String, {
|
||||
decode: SchemaGetter.transform((value) => String(value)),
|
||||
encode: SchemaGetter.transformOrFail((value) =>
|
||||
encode: SchemaGetter.transformEffect((value) =>
|
||||
value === "valid"
|
||||
? Effect.succeed(true)
|
||||
: Effect.fail(new SchemaIssue.InvalidValue({ message: "invalid output" }, value)),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { ByteSize, Effect, FileSystem } from "effect"
|
||||
import { nativeT } from "../native/translations"
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
@@ -43,7 +43,7 @@ export function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const file = yield* fs.open(filePath, { flag: "r" })
|
||||
const info = yield* file.stat
|
||||
if (info.size > FileSystem.Size(maxBytes))
|
||||
if (info.size > ByteSize.bytes(maxBytes))
|
||||
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
|
||||
|
||||
const bytes = new Uint8Array(Number(info.size))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from "bun:test"
|
||||
import { NodeSocket } from "@effect/platform-node"
|
||||
import { Deferred, Effect, Fiber, Layer, Queue, Scope, Exit } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { testEffect } from "../../../../core/test/lib/effect"
|
||||
import { createAskpass } from "./askpass"
|
||||
|
||||
@@ -12,16 +13,15 @@ const request = Effect.fn("test.askpass.request")(function* (
|
||||
confirm = false,
|
||||
) {
|
||||
const socket = yield* NodeSocket.makeNet({ host: "127.0.0.1", port: Number(env.OPENCODE_SSH_ASKPASS_PORT) })
|
||||
const write = yield* socket.writer
|
||||
const writer = yield* socket.writer
|
||||
const result = { text: "" }
|
||||
yield* Effect.all(
|
||||
[
|
||||
socket
|
||||
.runString((text) => {
|
||||
result.text += text
|
||||
})
|
||||
.pipe(Effect.ignore),
|
||||
write(JSON.stringify({ token: env.OPENCODE_SSH_ASKPASS_TOKEN, text, confirm }) + "\n").pipe(Effect.ignore),
|
||||
Effect.gen(function* () {
|
||||
const pull = yield* Socket.readerString(socket)
|
||||
while (true) result.text += (yield* pull).join("")
|
||||
}).pipe(Effect.ignore),
|
||||
writer.write(JSON.stringify({ token: env.OPENCODE_SSH_ASKPASS_TOKEN, text, confirm }) + "\n").pipe(Effect.ignore),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NodeSocketServer } from "@effect/platform-node"
|
||||
import { Deferred, Effect, Fiber, Schema, Semaphore } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { SshFailure } from "./command"
|
||||
|
||||
@@ -16,23 +17,27 @@ export const createAskpass = Effect.fn("Ssh.askpass")(function* (input: {
|
||||
const pending = new Map<string, Deferred.Deferred<string>>()
|
||||
const prompts = yield* Semaphore.make(1)
|
||||
const server = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 }).pipe(Effect.mapError(SshFailure.from))
|
||||
if (server.address._tag !== "TcpAddress") return yield* Effect.fail(new SshFailure("connection"))
|
||||
if (server.address._tag === "UnixPathAddress") return yield* Effect.fail(new SshFailure("connection"))
|
||||
|
||||
const serving = yield* server
|
||||
.run((socket) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* Deferred.make<string, SshFailure>()
|
||||
const state = { buffer: "", received: false }
|
||||
const reader = yield* socket
|
||||
.runString((chunk) => {
|
||||
if (state.received) return Effect.fail(new SshFailure("connection"))
|
||||
state.buffer += chunk
|
||||
if (state.buffer.length > 16_384) return Effect.fail(new SshFailure("connection"))
|
||||
if (!state.buffer.includes("\n")) return Effect.void
|
||||
state.received = true
|
||||
return Deferred.succeed(request, state.buffer.trim())
|
||||
})
|
||||
.pipe(Effect.ensuring(Deferred.fail(request, new SshFailure("connection"))), Effect.forkScoped)
|
||||
const reader = yield* Effect.gen(function* () {
|
||||
const pull = yield* Socket.readerString(socket)
|
||||
while (true) {
|
||||
const chunks = yield* pull
|
||||
for (const chunk of chunks) {
|
||||
if (state.received) return yield* Effect.fail(new SshFailure("connection"))
|
||||
state.buffer += chunk
|
||||
if (state.buffer.length > 16_384) return yield* Effect.fail(new SshFailure("connection"))
|
||||
if (!state.buffer.includes("\n")) continue
|
||||
state.received = true
|
||||
yield* Deferred.succeed(request, state.buffer.trim())
|
||||
}
|
||||
}
|
||||
}).pipe(Effect.ensuring(Deferred.fail(request, new SshFailure("connection"))), Effect.forkScoped)
|
||||
const message = yield* Deferred.await(request).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Request)))
|
||||
if (message.token !== token) return
|
||||
|
||||
@@ -49,8 +54,8 @@ export const createAskpass = Effect.fn("Ssh.askpass")(function* (input: {
|
||||
)
|
||||
yield* input.prompt({ id, text: message.text, confirm: message.confirm })
|
||||
const value = yield* Deferred.await(response)
|
||||
const write = yield* socket.writer
|
||||
yield* write(JSON.stringify({ value }))
|
||||
const writer = yield* socket.writer
|
||||
yield* writer.write(JSON.stringify({ value }))
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
.pipe(Effect.raceFirst(Fiber.join(reader).pipe(Effect.andThen(Effect.fail(new SshFailure("connection"))))))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import { NodeServices, NodeSocketServer } from "@effect/platform-node"
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Deferred, Effect, Fiber, FileSystem, Layer, Path, Stream } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
@@ -228,17 +228,18 @@ it.live(
|
||||
const path = yield* Path.Path
|
||||
const config = path.join(yield* fs.makeTempDirectoryScoped({ prefix: "ssh-handshake-test-" }), "config")
|
||||
yield* fs.writeFileString(config, "")
|
||||
const connected = yield* Deferred.make<void>()
|
||||
const closed = yield* Deferred.make<void>()
|
||||
const server = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 })
|
||||
if (server.address._tag !== "TcpAddress") return yield* Effect.die("missing port")
|
||||
yield* server
|
||||
.run((socket) =>
|
||||
socket
|
||||
.run(() => Effect.void, { onOpen: Deferred.succeed(connected, undefined).pipe(Effect.asVoid) })
|
||||
.pipe(Effect.ensuring(Deferred.succeed(closed, undefined)), Effect.ignore),
|
||||
)
|
||||
.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
const connected = Promise.withResolvers<void>()
|
||||
const closed = Promise.withResolvers<void>()
|
||||
const server = Bun.listen({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
socket: {
|
||||
open: () => connected.resolve(),
|
||||
data: () => {},
|
||||
close: () => closed.resolve(),
|
||||
},
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.stop(true)))
|
||||
const controller = yield* createSshController({
|
||||
configs: [],
|
||||
binary: "unused",
|
||||
@@ -246,15 +247,16 @@ it.live(
|
||||
save: () => Effect.die("must not save"),
|
||||
})
|
||||
yield* controller.start(
|
||||
{ id: "fixture", target: `ssh -F ${quote(config)} -p ${server.address.port} 127.0.0.1`, name: "" },
|
||||
{ id: "fixture", target: `ssh -F ${quote(config)} -p ${server.port} 127.0.0.1`, name: "" },
|
||||
1,
|
||||
)
|
||||
yield* Deferred.await(connected)
|
||||
const waiting = yield* controller.resolve("fixture").pipe(Effect.forkScoped)
|
||||
yield* Effect.promise(() => connected.promise)
|
||||
const waiting = yield* controller.resolve("fixture").pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* controller.disconnect("fixture")
|
||||
yield* Deferred.await(closed)
|
||||
yield* Effect.promise(() => closed.promise)
|
||||
expect(yield* Fiber.join(waiting)).toBeNull()
|
||||
expect((yield* controller.state()).servers[0]?.stage).toBe("disconnected")
|
||||
yield* controller.close
|
||||
return undefined
|
||||
}).pipe(Effect.timeout("10 seconds")),
|
||||
)
|
||||
|
||||
@@ -337,7 +337,7 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input:
|
||||
|
||||
const freePort = Effect.gen(function* () {
|
||||
const server = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 })
|
||||
if (server.address._tag !== "TcpAddress") return yield* Effect.fail(new SshFailure("connection"))
|
||||
if (server.address._tag === "UnixPathAddress") return yield* Effect.fail(new SshFailure("connection"))
|
||||
return server.address.port
|
||||
}).pipe(Effect.scoped)
|
||||
|
||||
|
||||
@@ -111,26 +111,18 @@ Real applications often select WebSocket URLs inside domain services. Effect rep
|
||||
```ts
|
||||
import { NodeSocket } from "@effect/platform-node"
|
||||
import { it } from "@effect/vitest"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { HttpRecorder } from "@opencode/http-recorder"
|
||||
|
||||
const roundTrip = Effect.fn("Echo.roundTrip")(function* (url: string, message: string) {
|
||||
const socket = yield* Socket.makeWebSocket(url, { closeCodeIsError: () => false })
|
||||
const write = yield* socket.writer
|
||||
const echoed = yield* Deferred.make<string>()
|
||||
|
||||
yield* socket.runString(
|
||||
(response) => {
|
||||
return Deferred.succeed(echoed, response).pipe(
|
||||
Effect.andThen(write(new Socket.CloseEvent(1000, "done"))),
|
||||
Effect.orDie,
|
||||
)
|
||||
},
|
||||
{ onOpen: write(message).pipe(Effect.orDie) },
|
||||
)
|
||||
|
||||
return yield* Deferred.await(echoed)
|
||||
const socket = yield* Socket.makeWebSocket(url)
|
||||
const writer = yield* socket.writer
|
||||
const pull = yield* Socket.readerString(socket)
|
||||
yield* writer.write(message)
|
||||
const [echoed] = yield* pull
|
||||
yield* writer.write(new Socket.CloseEvent(1000, "done"))
|
||||
return echoed
|
||||
})
|
||||
|
||||
it.effect("round trips a message", () =>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112"
|
||||
"@effect/platform-node-shared": "4.0.0-rc.115"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "catalog:"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node-shared"
|
||||
import { Deferred, Effect, Exit, FiberSet, Layer, Option, Ref, Scope, Semaphore } from "effect"
|
||||
import { Deferred, Effect, Exit, Layer, Ref, Scope, Semaphore } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { fileSystem, type Interface, Service } from "../cassette/store.js"
|
||||
import type { SocketRecorderOptions } from "../options.js"
|
||||
@@ -28,7 +28,7 @@ interface ActiveRecording {
|
||||
interface PendingRecordings {
|
||||
readonly promises: Set<Promise<void>>
|
||||
readonly errors: Array<unknown>
|
||||
readonly sockets: Set<globalThis.WebSocket>
|
||||
readonly sockets: Set<Socket.WebSocketLike>
|
||||
}
|
||||
type Frame = string | Uint8Array
|
||||
|
||||
@@ -134,46 +134,6 @@ const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefine
|
||||
if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return
|
||||
throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`)
|
||||
})
|
||||
const runHandler = <A, E, R>(handler: (value: A) => Effect.Effect<unknown, E, R> | void, value: A) =>
|
||||
Effect.suspend(() => {
|
||||
const result = handler(value)
|
||||
return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void
|
||||
})
|
||||
const runReplay = <A, E, R>(
|
||||
state: ActiveReplay,
|
||||
handler: (value: A) => Effect.Effect<unknown, E, R> | void,
|
||||
decode: (event: WebSocketEvent) => A,
|
||||
onOpen: Effect.Effect<void> | undefined,
|
||||
) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handlers = yield* FiberSet.make<unknown, E>()
|
||||
const run = yield* FiberSet.runtime(handlers)<R>()
|
||||
if (onOpen) yield* onOpen
|
||||
const drive = Effect.gen(function* () {
|
||||
while (true) {
|
||||
const current = yield* Ref.get(state.progress)
|
||||
const event = state.interaction.events[current.position]
|
||||
if (!event) return
|
||||
if (yield* Ref.get(state.closed))
|
||||
return yield* Effect.die(
|
||||
new Error(
|
||||
`WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
|
||||
),
|
||||
)
|
||||
if (event.direction === "server") {
|
||||
yield* Ref.set(state.progress, { position: current.position + 1, changed: yield* Deferred.make<void>() })
|
||||
run(runHandler(handler, decode(event)))
|
||||
continue
|
||||
}
|
||||
yield* Deferred.await(current.changed)
|
||||
}
|
||||
})
|
||||
yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers)))
|
||||
yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers)))
|
||||
}),
|
||||
)
|
||||
|
||||
const makeRecordingSocket = (
|
||||
upstream: Socket.Socket,
|
||||
cassette: Interface,
|
||||
@@ -185,71 +145,95 @@ const makeRecordingSocket = (
|
||||
const active = yield* Ref.make<ActiveRecording | undefined>(undefined)
|
||||
const writeLock = yield* Semaphore.make(1)
|
||||
return Socket.make({
|
||||
runRaw: (handler, runOptions) =>
|
||||
Effect.gen(function* () {
|
||||
const state: ActiveRecording = {
|
||||
events: [],
|
||||
eventLock: yield* Semaphore.make(1),
|
||||
accepting: yield* Ref.make(true),
|
||||
opened: false,
|
||||
valid: true,
|
||||
}
|
||||
const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state])
|
||||
if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported")
|
||||
yield* upstream
|
||||
.runRaw(
|
||||
(message) => {
|
||||
if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing")
|
||||
state.events.push(redactEvent(encodeEvent("server", message), redactor))
|
||||
return handler(message)
|
||||
},
|
||||
{
|
||||
...runOptions,
|
||||
onOpen: Effect.gen(function* () {
|
||||
state.opened = true
|
||||
if (runOptions?.onOpen) yield* runOptions.onOpen
|
||||
reader: Effect.gen(function* () {
|
||||
const state: ActiveRecording = {
|
||||
events: [],
|
||||
eventLock: yield* Semaphore.make(1),
|
||||
accepting: yield* Ref.make(true),
|
||||
opened: false,
|
||||
valid: true,
|
||||
}
|
||||
const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state])
|
||||
if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported")
|
||||
yield* Effect.addFinalizer((exit) =>
|
||||
writeLock.withPermit(
|
||||
state.eventLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.set(state.accepting, false)
|
||||
yield* Ref.set(active, undefined)
|
||||
if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return
|
||||
yield* cassette
|
||||
.append(
|
||||
name,
|
||||
{
|
||||
transport: "websocket",
|
||||
events: [...state.events],
|
||||
},
|
||||
options.metadata,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const reader = yield* upstream.reader
|
||||
state.opened = true
|
||||
return {
|
||||
...reader,
|
||||
pull: reader.pull.pipe(
|
||||
Effect.tap((messages) =>
|
||||
state.eventLock.withPermit(
|
||||
Effect.sync(() => {
|
||||
if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing")
|
||||
state.events.push(...messages.map((message) => redactEvent(encodeEvent("server", message), redactor)))
|
||||
}),
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
Effect.onExit((exit) =>
|
||||
writeLock.withPermit(
|
||||
state.eventLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.set(state.accepting, false)
|
||||
yield* Ref.set(active, undefined)
|
||||
if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return
|
||||
yield* cassette
|
||||
.append(
|
||||
name,
|
||||
{
|
||||
transport: "websocket",
|
||||
events: [...state.events],
|
||||
},
|
||||
options.metadata,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
Effect.tapError((error) =>
|
||||
Effect.sync(() => {
|
||||
if (error.reason._tag !== "SocketCloseError") state.valid = false
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
writer: upstream.writer.pipe(
|
||||
Effect.map(
|
||||
(write) => (message) =>
|
||||
Effect.map((writer) => {
|
||||
const write: Socket.Writer["write"] = (message) =>
|
||||
writeLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (Socket.isCloseEvent(message)) return yield* write(message)
|
||||
if (Socket.isCloseEvent(message)) return yield* writer.write(message)
|
||||
const state = yield* Ref.get(active)
|
||||
if (!state || !(yield* Ref.get(state.accepting)))
|
||||
return yield* Effect.die("WebSocket writer used without an active socket run")
|
||||
const event = redactEvent(encodeEvent("client", message), redactor)
|
||||
yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event)))
|
||||
return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false))))
|
||||
yield* state.eventLock.withPermit(
|
||||
Effect.sync(() => state.events.push(redactEvent(encodeEvent("client", message), redactor))),
|
||||
)
|
||||
return yield* writer.write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false))))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return {
|
||||
write,
|
||||
writeAll: (messages) =>
|
||||
writeLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* Ref.get(active)
|
||||
if (!state || !(yield* Ref.get(state.accepting)))
|
||||
return yield* Effect.die("WebSocket writer used without an active socket run")
|
||||
yield* state.eventLock.withPermit(
|
||||
Effect.sync(() =>
|
||||
state.events.push(
|
||||
...messages.map((message) => redactEvent(encodeEvent("client", message), redactor)),
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* writer
|
||||
.writeAll(messages)
|
||||
.pipe(Effect.onError(() => Effect.sync(() => (state.valid = false))))
|
||||
}),
|
||||
),
|
||||
}
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
@@ -265,70 +249,109 @@ const makeReplaySocket = (
|
||||
const active = yield* Ref.make<ActiveReplay | undefined>(undefined)
|
||||
const runLock = yield* Semaphore.make(1)
|
||||
return Socket.make({
|
||||
runRaw: (handler, runOptions) =>
|
||||
runLock
|
||||
.withPermitsIfAvailable(1)(
|
||||
Effect.gen(function* () {
|
||||
const claimed = yield* replay
|
||||
.claim((interaction) =>
|
||||
interaction ? Effect.void : Effect.die("Missing recorded WebSocket interaction"),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const state = {
|
||||
interaction: claimed.interaction,
|
||||
progress: yield* Ref.make({ position: 0, changed: yield* Deferred.make<void>() }),
|
||||
writeLock: yield* Semaphore.make(1),
|
||||
closed: yield* Ref.make(false),
|
||||
}
|
||||
yield* Ref.set(active, state)
|
||||
yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe(
|
||||
Effect.ensuring(Ref.set(active, undefined)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.flatMap(
|
||||
Option.match({
|
||||
onNone: () => Effect.die("Concurrent runs of a replayed WebSocket are not supported"),
|
||||
onSome: () => Effect.void,
|
||||
}),
|
||||
reader: Effect.gen(function* () {
|
||||
if (!(yield* runLock.takeIfAvailable(1)))
|
||||
return yield* Effect.die("Concurrent runs of a replayed WebSocket are not supported")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.all([Ref.set(active, undefined), runLock.release(1)], { discard: true }),
|
||||
)
|
||||
const claimed = yield* replay
|
||||
.claim((interaction) => (interaction ? Effect.void : Effect.die("Missing recorded WebSocket interaction")))
|
||||
.pipe(Effect.orDie)
|
||||
const state = {
|
||||
interaction: claimed.interaction,
|
||||
progress: yield* Ref.make({ position: 0, changed: yield* Deferred.make<void>() }),
|
||||
writeLock: yield* Semaphore.make(1),
|
||||
closed: yield* Ref.make(false),
|
||||
}
|
||||
yield* Ref.set(active, state)
|
||||
const pull: Socket.Reader["pull"] = Effect.suspend(() =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(state.progress)
|
||||
if (yield* Ref.get(state.closed))
|
||||
return yield* Effect.fail(new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1000 }) }))
|
||||
const remaining = state.interaction.events.slice(current.position)
|
||||
const client = remaining.findIndex((event) => event.direction === "client")
|
||||
const messages = client === -1 ? remaining : remaining.slice(0, client)
|
||||
if (messages.length > 0) {
|
||||
yield* Ref.set(state.progress, {
|
||||
position: current.position + messages.length,
|
||||
changed: yield* Deferred.make<void>(),
|
||||
})
|
||||
return messages.map(decodeEvent) as [Frame, ...Array<Frame>]
|
||||
}
|
||||
if (current.position === state.interaction.events.length)
|
||||
return yield* Effect.fail(new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1000 }) }))
|
||||
yield* Deferred.await(current.changed)
|
||||
return yield* pull
|
||||
}),
|
||||
)
|
||||
return { pull, upgrade: Socket.SocketUpgradeError.unsupported }
|
||||
}),
|
||||
writer: Effect.succeed({
|
||||
write: (message) =>
|
||||
Ref.get(active).pipe(
|
||||
Effect.flatMap((state) =>
|
||||
state
|
||||
? state.writeLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(state.progress)
|
||||
if (Socket.isCloseEvent(message)) {
|
||||
yield* Ref.set(state.closed, true)
|
||||
yield* Deferred.succeed(current.changed, undefined)
|
||||
if (current.position === state.interaction.events.length) return
|
||||
return yield* Effect.die(
|
||||
new Error(
|
||||
`WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
const actual = redactEvent(encodeEvent("client", message), redactor)
|
||||
yield* assertEvent(
|
||||
actual,
|
||||
state.interaction.events[current.position],
|
||||
current.position,
|
||||
options.compareClientMessagesAsJson === true,
|
||||
)
|
||||
yield* Ref.set(state.progress, {
|
||||
position: current.position + 1,
|
||||
changed: yield* Deferred.make<void>(),
|
||||
})
|
||||
yield* Deferred.succeed(current.changed, undefined)
|
||||
}),
|
||||
)
|
||||
: Effect.die("WebSocket writer used without an active socket run"),
|
||||
),
|
||||
),
|
||||
writer: Effect.succeed((message) =>
|
||||
Ref.get(active).pipe(
|
||||
Effect.flatMap((state) =>
|
||||
state
|
||||
? state.writeLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(state.progress)
|
||||
if (Socket.isCloseEvent(message)) {
|
||||
yield* Ref.set(state.closed, true)
|
||||
yield* Deferred.succeed(current.changed, undefined)
|
||||
if (current.position === state.interaction.events.length) return
|
||||
return yield* Effect.die(
|
||||
new Error(
|
||||
`WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
|
||||
),
|
||||
writeAll: (messages) =>
|
||||
Effect.forEach(
|
||||
messages,
|
||||
(message) =>
|
||||
Ref.get(active).pipe(
|
||||
Effect.flatMap((state) =>
|
||||
state
|
||||
? state.writeLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(state.progress)
|
||||
yield* assertEvent(
|
||||
redactEvent(encodeEvent("client", message), redactor),
|
||||
state.interaction.events[current.position],
|
||||
current.position,
|
||||
options.compareClientMessagesAsJson === true,
|
||||
)
|
||||
yield* Ref.set(state.progress, {
|
||||
position: current.position + 1,
|
||||
changed: yield* Deferred.make<void>(),
|
||||
})
|
||||
yield* Deferred.succeed(current.changed, undefined)
|
||||
}),
|
||||
)
|
||||
}
|
||||
const actual = redactEvent(encodeEvent("client", message), redactor)
|
||||
yield* assertEvent(
|
||||
actual,
|
||||
state.interaction.events[current.position],
|
||||
current.position,
|
||||
options.compareClientMessagesAsJson === true,
|
||||
)
|
||||
yield* Ref.set(state.progress, {
|
||||
position: current.position + 1,
|
||||
changed: yield* Deferred.make<void>(),
|
||||
})
|
||||
yield* Deferred.succeed(current.changed, undefined)
|
||||
}),
|
||||
)
|
||||
: Effect.die("WebSocket writer used without an active socket run"),
|
||||
: Effect.die("WebSocket writer used without an active socket run"),
|
||||
),
|
||||
),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -399,13 +422,13 @@ const makeRecordingWebSocketConstructor = (
|
||||
const onOpen = () => {
|
||||
opened = true
|
||||
}
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const onMessage = (event: Socket.WebSocketEvent) => {
|
||||
appendEvent("server", event.data)
|
||||
}
|
||||
const onError = () => {
|
||||
failed = true
|
||||
}
|
||||
const onClose = (event: CloseEvent) => {
|
||||
const onClose = (event: Socket.WebSocketEvent) => {
|
||||
native.removeEventListener("open", onOpen)
|
||||
native.removeEventListener("message", onMessage)
|
||||
native.removeEventListener("error", onError)
|
||||
@@ -421,7 +444,7 @@ const makeRecordingWebSocketConstructor = (
|
||||
sequence,
|
||||
url: request.url,
|
||||
protocols: requestedProtocols,
|
||||
close: { code: event.code, reason: event.reason },
|
||||
close: { code: event.code ?? 1006, reason: event.reason ?? "" },
|
||||
},
|
||||
events: [...events],
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { Deferred, Effect, Exit, Fiber, FiberSet, Layer } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { existsSync } from "node:fs"
|
||||
import { HttpRecorder } from "../src"
|
||||
@@ -7,10 +7,58 @@ import { layerSocketWithMode } from "../src/websocket/recorder"
|
||||
import { failureText, readCassette, seedCassetteDirectory, tempDirectory, withEnvironment } from "./support"
|
||||
|
||||
const unavailableSocket = Socket.make({
|
||||
runRaw: () => Effect.die(new Error("unexpected live WebSocket run")),
|
||||
writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))),
|
||||
reader: Effect.die(new Error("unexpected live WebSocket run")),
|
||||
writer: Effect.succeed({
|
||||
write: () => Effect.die(new Error("unexpected live WebSocket write")),
|
||||
writeAll: () => Effect.die(new Error("unexpected live WebSocket write")),
|
||||
}),
|
||||
})
|
||||
|
||||
const runRaw = <E, R>(
|
||||
socket: Socket.Socket,
|
||||
handler: (message: string | Uint8Array) => Effect.Effect<unknown, E, R> | void,
|
||||
onOpen?: Effect.Effect<void, E, R>,
|
||||
) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handlers = yield* FiberSet.make<unknown, E>()
|
||||
const run = yield* FiberSet.runtime(handlers)<R>()
|
||||
const reader = yield* socket.reader
|
||||
if (onOpen) yield* onOpen
|
||||
yield* Effect.gen(function* () {
|
||||
while (true) {
|
||||
const messages = yield* reader.pull
|
||||
messages.forEach((message) =>
|
||||
run(
|
||||
Effect.suspend(() => {
|
||||
const result = handler(message)
|
||||
return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchIf(
|
||||
(error) => Socket.SocketError.is(error) && error.reason._tag === "SocketCloseError",
|
||||
() => Effect.void,
|
||||
),
|
||||
Effect.raceFirst(FiberSet.join(handlers)),
|
||||
)
|
||||
yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers)))
|
||||
}),
|
||||
)
|
||||
|
||||
const runString = <E, R>(
|
||||
socket: Socket.Socket,
|
||||
handler: (message: string) => Effect.Effect<unknown, E, R> | void,
|
||||
onOpen?: Effect.Effect<void, E, R>,
|
||||
) =>
|
||||
runRaw(
|
||||
socket,
|
||||
(message) => handler(typeof message === "string" ? message : new TextDecoder().decode(message)),
|
||||
onOpen,
|
||||
)
|
||||
|
||||
class EchoWebSocket extends EventTarget {
|
||||
readonly protocol = ""
|
||||
readonly extensions = ""
|
||||
@@ -53,12 +101,13 @@ describe("WebSocket", () => {
|
||||
Effect.gen(function* () {
|
||||
const socket = yield* Socket.makeWebSocket("wss://echo.example.test/one", {
|
||||
protocols: ["echo.v1"],
|
||||
closeCodeIsError: () => false,
|
||||
})
|
||||
const write = yield* socket.writer
|
||||
yield* socket.runString(() => write(new Socket.CloseEvent(1000, "complete")).pipe(Effect.orDie), {
|
||||
onOpen: write("hello").pipe(Effect.orDie),
|
||||
})
|
||||
const writer = yield* socket.writer
|
||||
yield* runString(
|
||||
socket,
|
||||
() => writer.write(new Socket.CloseEvent(1000, "complete")).pipe(Effect.orDie),
|
||||
writer.write("hello").pipe(Effect.orDie),
|
||||
)
|
||||
}).pipe(Effect.scoped, Effect.provide(recorder)),
|
||||
),
|
||||
)
|
||||
@@ -141,17 +190,15 @@ describe("WebSocket", () => {
|
||||
Effect.gen(function* () {
|
||||
const socket = yield* Socket.makeWebSocket("wss://events.example.test/workspaces/one", {
|
||||
protocols: ["events.v1"],
|
||||
closeCodeIsError: () => false,
|
||||
})
|
||||
const write = yield* socket.writer
|
||||
const writer = yield* socket.writer
|
||||
const received: string[] = []
|
||||
yield* socket.runString(
|
||||
yield* runString(
|
||||
socket,
|
||||
(message) => {
|
||||
received.push(message)
|
||||
},
|
||||
{
|
||||
onOpen: write('{"type":"subscribe"}').pipe(Effect.orDie),
|
||||
},
|
||||
writer.write('{"type":"subscribe"}').pipe(Effect.orDie),
|
||||
)
|
||||
return received
|
||||
}).pipe(Effect.scoped, Effect.provide(recorder)),
|
||||
@@ -187,7 +234,7 @@ describe("WebSocket", () => {
|
||||
const exit = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const socket = yield* Socket.makeWebSocket("wss://events.example.test/workspaces/two")
|
||||
yield* socket.runString(() => {})
|
||||
yield* runString<never, never>(socket, () => {})
|
||||
}).pipe(Effect.scoped, Effect.exit, Effect.provide(recorder)),
|
||||
)
|
||||
|
||||
@@ -200,23 +247,28 @@ describe("WebSocket", () => {
|
||||
type: "response.completed",
|
||||
token: "server-secret",
|
||||
})
|
||||
let sent = false
|
||||
const upstream = Socket.make({
|
||||
runRaw: (handler, options) =>
|
||||
Effect.gen(function* () {
|
||||
if (options?.onOpen) yield* options.onOpen
|
||||
const result = handler(response)
|
||||
if (Effect.isEffect(result)) yield* result
|
||||
reader: Effect.succeed({
|
||||
pull: Effect.suspend(() => {
|
||||
if (sent) return Effect.fail(new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1000 }) }))
|
||||
sent = true
|
||||
return Effect.succeed([response])
|
||||
}),
|
||||
writer: Effect.succeed(() => Effect.void),
|
||||
upgrade: Socket.SocketUpgradeError.unsupported,
|
||||
}),
|
||||
writer: Effect.succeed({ write: () => Effect.void, writeAll: () => Effect.void }),
|
||||
})
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const socket = yield* Socket.Socket
|
||||
const write = yield* socket.writer
|
||||
yield* socket.runRaw(() => {}, {
|
||||
onOpen: write(JSON.stringify({ type: "response.create", token: "client-secret" })).pipe(Effect.orDie),
|
||||
})
|
||||
const writer = yield* socket.writer
|
||||
yield* runRaw(
|
||||
socket,
|
||||
() => {},
|
||||
writer.write(JSON.stringify({ type: "response.create", token: "client-secret" })).pipe(Effect.orDie),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
@@ -279,14 +331,14 @@ describe("WebSocket", () => {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const socket = yield* Socket.Socket
|
||||
const write = yield* socket.writer
|
||||
yield* socket.runRaw((message) =>
|
||||
const writer = yield* socket.writer
|
||||
yield* runRaw(socket, (message) =>
|
||||
Effect.gen(function* () {
|
||||
if (typeof message !== "string") return
|
||||
received.push(message)
|
||||
const event: unknown = JSON.parse(message)
|
||||
if (typeof event !== "object" || event === null || !("type" in event)) return
|
||||
if (event.type === "session.created") yield* write('{"prompt":"hello","type":"response.create"}')
|
||||
if (event.type === "session.created") yield* writer.write('{"prompt":"hello","type":"response.create"}')
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
@@ -343,21 +395,21 @@ describe("WebSocket", () => {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const socket = yield* Socket.Socket
|
||||
const write = yield* socket.writer
|
||||
yield* socket.runString((message) =>
|
||||
const writer = yield* socket.writer
|
||||
yield* runString(socket, (message) =>
|
||||
Effect.gen(function* () {
|
||||
received.push(message)
|
||||
const event: unknown = JSON.parse(message)
|
||||
if (typeof event !== "object" || event === null) return
|
||||
if ("type" in event && event.type === "session.created") {
|
||||
yield* write('{"prompt":"first","type":"response.create"}')
|
||||
yield* writer.write('{"prompt":"first","type":"response.create"}')
|
||||
return
|
||||
}
|
||||
if ("id" in event && event.id === "first") {
|
||||
yield* write('{"prompt":"second","type":"response.create"}')
|
||||
yield* writer.write('{"prompt":"second","type":"response.create"}')
|
||||
return
|
||||
}
|
||||
yield* write(new Socket.CloseEvent(1000, "done"))
|
||||
yield* writer.write(new Socket.CloseEvent(1000, "done"))
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
@@ -393,7 +445,7 @@ describe("WebSocket", () => {
|
||||
Effect.gen(function* () {
|
||||
const socket = yield* Socket.Socket
|
||||
const second = yield* Deferred.make<void>()
|
||||
yield* socket.runString((message) =>
|
||||
yield* runString(socket, (message) =>
|
||||
message === "first" ? Deferred.await(second) : Deferred.succeed(second, undefined),
|
||||
)
|
||||
}).pipe(
|
||||
@@ -420,23 +472,21 @@ describe("WebSocket", () => {
|
||||
const socket = yield* Socket.Socket
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const first = yield* socket
|
||||
.runString((message) =>
|
||||
Effect.gen(function* () {
|
||||
received.push(message)
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.forkChild)
|
||||
const first = yield* runString(socket, (message) =>
|
||||
Effect.gen(function* () {
|
||||
received.push(message)
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
const concurrent = yield* Effect.exit(socket.runString(() => Effect.void))
|
||||
const concurrent = yield* Effect.exit(runString(socket, () => Effect.void))
|
||||
expect(failureText(concurrent)).toContain("Concurrent runs")
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* socket.runString((message) => Effect.sync(() => received.push(message)))
|
||||
yield* runString(socket, (message) => Effect.sync(() => received.push(message)))
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
@@ -462,11 +512,9 @@ describe("WebSocket", () => {
|
||||
const exit = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const socket = yield* Socket.Socket
|
||||
const write = yield* socket.writer
|
||||
const writer = yield* socket.writer
|
||||
return yield* Effect.exit(
|
||||
socket.runRaw(() => {}, {
|
||||
onOpen: write(new Socket.CloseEvent(1000)).pipe(Effect.orDie),
|
||||
}),
|
||||
runRaw(socket, () => {}, writer.write(new Socket.CloseEvent(1000)).pipe(Effect.orDie)),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
@@ -486,7 +534,7 @@ describe("WebSocket", () => {
|
||||
const exit = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const socket = yield* Socket.Socket
|
||||
return yield* Effect.exit(socket.runRaw(() => {}))
|
||||
return yield* Effect.exit(runRaw<never, never>(socket, () => {}))
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
@@ -495,8 +543,8 @@ describe("WebSocket", () => {
|
||||
Layer.succeed(
|
||||
Socket.Socket,
|
||||
Socket.make({
|
||||
runRaw: () => Effect.die(new Error("connection failed")),
|
||||
writer: Effect.succeed(() => Effect.void),
|
||||
reader: Effect.die(new Error("connection failed")),
|
||||
writer: Effect.succeed({ write: () => Effect.void, writeAll: () => Effect.void }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -534,13 +582,14 @@ describe("WebSocket", () => {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const socket = yield* Socket.Socket
|
||||
const write = yield* socket.writer
|
||||
const run = socket.runRaw(
|
||||
const writer = yield* socket.writer
|
||||
const run = runRaw(
|
||||
socket,
|
||||
(message) => {
|
||||
if (typeof message === "string") throw new Error("Expected a binary WebSocket frame")
|
||||
received.push([...message])
|
||||
},
|
||||
{ onOpen: write(new Uint8Array([1, 2])).pipe(Effect.orDie) },
|
||||
writer.write(new Uint8Array([1, 2])).pipe(Effect.orDie),
|
||||
)
|
||||
yield* run
|
||||
yield* run
|
||||
|
||||
@@ -1600,7 +1600,15 @@ function assertPortable(schema: Schema.Top, path: string, portable: Map<SchemaAS
|
||||
if (!annotationsPortable(ast.annotations)) return false
|
||||
if (!checksPortable(ast.checks) || ("encodingChecks" in ast && !checksPortable(ast.encodingChecks))) return false
|
||||
if (SchemaAST.isDeclaration(ast)) {
|
||||
return typeof ast.annotations?.toCode === "function" && ast.typeParameters.every(visit)
|
||||
const representation = ast.annotations?.representation
|
||||
const supported =
|
||||
typeof ast.annotations?.toCode === "function" ||
|
||||
(typeof representation === "object" &&
|
||||
representation !== null &&
|
||||
"id" in representation &&
|
||||
representation.id === "effect/schema/Json") ||
|
||||
(ast.annotations?.["~constructor"] !== undefined && ast.typeParameters[0] !== undefined)
|
||||
return supported && ast.typeParameters.every(visit)
|
||||
}
|
||||
if (ast.encoding !== undefined && ast.annotations?.toCode === undefined) return false
|
||||
if (SchemaAST.isSuspend(ast)) return visit(ast.thunk())
|
||||
@@ -1638,9 +1646,8 @@ function checksPortable(checks: SchemaAST.Checks | undefined): boolean {
|
||||
? !check.aborted &&
|
||||
check.annotations?.representation !== undefined &&
|
||||
serializable(check.annotations.representation) &&
|
||||
typeof check.annotations.arbitrary === "object" &&
|
||||
check.annotations.arbitrary !== null &&
|
||||
"constraint" in check.annotations.arbitrary
|
||||
typeof check.annotations.arbitraryConstraint === "object" &&
|
||||
check.annotations.arbitraryConstraint !== null
|
||||
: checksPortable(check.checks),
|
||||
)
|
||||
}
|
||||
@@ -1679,6 +1686,7 @@ function annotationsPortable(annotations: Schema.Annotations.Annotations | undef
|
||||
"toCodec",
|
||||
"toCodecJson",
|
||||
"toCodecStringTree",
|
||||
"toCodecArbitrary",
|
||||
"toArbitrary",
|
||||
"toFormatter",
|
||||
"toEquivalence",
|
||||
|
||||
@@ -1472,6 +1472,19 @@ describe("HttpApiCodegen.generate", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("emits schema classes with native arbitrary constraints structurally", () => {
|
||||
class Attempt extends Schema.Class<Attempt>("Attempt")({
|
||||
count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
}) {}
|
||||
const output = emitPromise(
|
||||
compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Attempt }))),
|
||||
)
|
||||
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||
|
||||
expect(types).toContain('export type Attempt = { readonly "count": number }')
|
||||
expect(types).toContain("export type SessionGetOutput = Attempt")
|
||||
})
|
||||
|
||||
test("rejects spoofed and aborted validation checks", () => {
|
||||
const Spoofed = Schema.Number.check(
|
||||
Schema.makeFilter(() => "always fails", { meta: { _tag: "isFinite" }, arbitrary: {} }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { join } from "node:path"
|
||||
import { Effect, FileSystem, Option } from "effect"
|
||||
import { ByteSize, Effect, FileSystem, Option } from "effect"
|
||||
import { write, type Output } from "../src"
|
||||
import { it } from "./effect"
|
||||
|
||||
@@ -150,7 +150,7 @@ describe("HttpApiCodegen.write", () => {
|
||||
uid: Option.none(),
|
||||
gid: Option.none(),
|
||||
rdev: Option.none(),
|
||||
size: FileSystem.Size(0),
|
||||
size: ByteSize.bytes(0),
|
||||
blksize: Option.none(),
|
||||
blocks: Option.none(),
|
||||
}),
|
||||
|
||||
@@ -359,6 +359,7 @@ export const makeSessionGroup = <
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({
|
||||
title: Schema.String.pipe(Schema.optional),
|
||||
metadata: Session.Metadata.pipe(Schema.optional),
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional),
|
||||
}),
|
||||
success: HttpApiSchema.NoContent,
|
||||
|
||||
@@ -111,6 +111,16 @@ export const Renamed = Event.durable({
|
||||
})
|
||||
export type Renamed = typeof Renamed.Type
|
||||
|
||||
export const MetadataUpdated = Event.durable({
|
||||
type: "session.metadata.updated",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
metadata: SessionMetadata,
|
||||
},
|
||||
})
|
||||
export type MetadataUpdated = typeof MetadataUpdated.Type
|
||||
|
||||
export const Permissions = Event.durable({
|
||||
type: "session.permissions",
|
||||
...options,
|
||||
@@ -648,6 +658,7 @@ export const Definitions = Event.inventory(
|
||||
ModelSelected,
|
||||
Moved,
|
||||
Renamed,
|
||||
MetadataUpdated,
|
||||
Permissions,
|
||||
Viewed,
|
||||
UsageUpdated,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
/**
|
||||
* Host-supplied session annotations, durable from creation and opaque to
|
||||
* core. Keys are arbitrary; values must be JSON-serializable. Children and
|
||||
* forks inherit the parent's metadata unless the creator supplies its own.
|
||||
* Host-supplied session annotations, durable and opaque to core. Keys are
|
||||
* arbitrary; values must be JSON-serializable. Children and forks inherit
|
||||
* the parent's current metadata unless the creator supplies its own.
|
||||
*/
|
||||
export const SessionMetadata = Schema.Record(Schema.String, Schema.Json).annotate({
|
||||
identifier: "Session.Metadata",
|
||||
|
||||
@@ -115,6 +115,7 @@ describe("public event manifest", () => {
|
||||
"session.model.selected.1",
|
||||
"session.moved.1",
|
||||
"session.renamed.1",
|
||||
"session.metadata.updated.1",
|
||||
"session.permissions.1",
|
||||
"session.viewed.1",
|
||||
"session.message.content.updated.1",
|
||||
|
||||
@@ -124,7 +124,7 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
|
||||
if (!Number.isSafeInteger(cursor) || cursor < 0) return HttpServerResponse.empty({ status: 400 })
|
||||
|
||||
const socket = yield* Effect.orDie(ctx.request.upgrade)
|
||||
const write = yield* socket.writer
|
||||
const writer = yield* socket.writer
|
||||
const outbox = yield* Queue.unbounded<string | Uint8Array | Socket.CloseEvent>()
|
||||
const input = yield* Semaphore.make(1)
|
||||
let attachment: PersistentPty.Attachment | undefined
|
||||
@@ -187,41 +187,52 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
|
||||
const drain = Effect.gen(function* () {
|
||||
while (true) {
|
||||
const item = yield* Queue.take(outbox)
|
||||
yield* write(item)
|
||||
yield* writer.write(item)
|
||||
if (item instanceof Socket.CloseEvent) return
|
||||
}
|
||||
})
|
||||
|
||||
yield* runPtySocket(
|
||||
drain,
|
||||
socket.runRaw(
|
||||
(message) =>
|
||||
input.withPermit(
|
||||
Effect.suspend(() => {
|
||||
if (!attachment) return Effect.void
|
||||
const data = typeof message === "string" ? Buffer.from(message) : message
|
||||
if (!framedInput)
|
||||
return pty
|
||||
.input(
|
||||
ctx.params.ptyID,
|
||||
attachmentID,
|
||||
attachment.info.size.cols,
|
||||
attachment.info.size.rows,
|
||||
data,
|
||||
)
|
||||
.pipe(Effect.ignore)
|
||||
if (data.byteLength < 5) return Effect.void
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
const type = data[0]
|
||||
const cols = view.getUint16(1)
|
||||
const rows = view.getUint16(3)
|
||||
if ((type !== 0 && type !== 1) || cols === 0 || rows === 0) return Effect.void
|
||||
if (type === 0) return pty.control(ctx.params.ptyID, attachmentID, cols, rows).pipe(Effect.ignore)
|
||||
return pty.input(ctx.params.ptyID, attachmentID, cols, rows, data.subarray(5)).pipe(Effect.ignore)
|
||||
}),
|
||||
),
|
||||
{ onOpen },
|
||||
),
|
||||
Effect.gen(function* () {
|
||||
const reader = yield* socket.reader
|
||||
yield* onOpen
|
||||
while (true) {
|
||||
const messages = yield* reader.pull
|
||||
yield* Effect.forEach(
|
||||
messages,
|
||||
(message) =>
|
||||
input.withPermit(
|
||||
Effect.suspend(() => {
|
||||
if (!attachment) return Effect.void
|
||||
const data = typeof message === "string" ? Buffer.from(message) : message
|
||||
if (!framedInput)
|
||||
return pty
|
||||
.input(
|
||||
ctx.params.ptyID,
|
||||
attachmentID,
|
||||
attachment.info.size.cols,
|
||||
attachment.info.size.rows,
|
||||
data,
|
||||
)
|
||||
.pipe(Effect.ignore)
|
||||
if (data.byteLength < 5) return Effect.void
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
const type = data[0]
|
||||
const cols = view.getUint16(1)
|
||||
const rows = view.getUint16(3)
|
||||
if ((type !== 0 && type !== 1) || cols === 0 || rows === 0) return Effect.void
|
||||
if (type === 0)
|
||||
return pty.control(ctx.params.ptyID, attachmentID, cols, rows).pipe(Effect.ignore)
|
||||
return pty
|
||||
.input(ctx.params.ptyID, attachmentID, cols, rows, data.subarray(5))
|
||||
.pipe(Effect.ignore)
|
||||
}),
|
||||
),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
}),
|
||||
() => attachment?.detach(),
|
||||
).pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
|
||||
@@ -165,15 +165,17 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
|
||||
: undefined
|
||||
|
||||
const socket = yield* Effect.orDie(ctx.request.upgrade)
|
||||
const write = yield* socket.writer
|
||||
const writer = yield* socket.writer
|
||||
const closeAccepted = (event: Socket.CloseEvent) =>
|
||||
socket
|
||||
.runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) })
|
||||
.pipe(
|
||||
Effect.timeout("1 second"),
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
Effect.gen(function* () {
|
||||
const reader = yield* socket.reader
|
||||
yield* writer.write(event).pipe(Effect.catch(() => Effect.void))
|
||||
while (true) yield* reader.pull
|
||||
}).pipe(
|
||||
Effect.timeout("1 second"),
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
|
||||
// Outbound frames flow through one queue drained by a single writer so replay, live
|
||||
// output, and the close frame keep their order.
|
||||
@@ -202,16 +204,22 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
|
||||
const drain = Effect.gen(function* () {
|
||||
while (true) {
|
||||
const item = yield* Queue.take(outbox)
|
||||
yield* write(item)
|
||||
yield* writer.write(item)
|
||||
if (item instanceof Socket.CloseEvent) return
|
||||
}
|
||||
})
|
||||
|
||||
yield* runPtySocket(
|
||||
drain,
|
||||
socket.runRaw((message) => {
|
||||
const decoded = PtyProtocol.decodeInput(message)
|
||||
if (decoded !== undefined) attachment.write(decoded)
|
||||
Effect.gen(function* () {
|
||||
const reader = yield* socket.reader
|
||||
while (true) {
|
||||
const messages = yield* reader.pull
|
||||
messages.forEach((message) => {
|
||||
const decoded = PtyProtocol.decodeInput(message)
|
||||
if (decoded !== undefined) attachment.write(decoded)
|
||||
})
|
||||
}
|
||||
}),
|
||||
attachment.detach,
|
||||
).pipe(
|
||||
|
||||
@@ -266,6 +266,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
yield* title.generate(ctx.params.sessionID)
|
||||
}
|
||||
}
|
||||
if (ctx.payload.metadata !== undefined)
|
||||
yield* session
|
||||
.setMetadata({ sessionID: ctx.params.sessionID, metadata: ctx.payload.metadata })
|
||||
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
|
||||
if (ctx.payload.permissions !== undefined)
|
||||
yield* session
|
||||
.setPermissions({ sessionID: ctx.params.sessionID, permissions: ctx.payload.permissions })
|
||||
|
||||
@@ -9,6 +9,7 @@ import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/pers
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
|
||||
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { NetAddress } from "effect/unstable/net"
|
||||
import { createServer } from "node:http"
|
||||
import { ServerAuth } from "./auth"
|
||||
import { isAllowedCorsOrigin } from "./cors"
|
||||
@@ -21,7 +22,7 @@ import type { ServerOptions } from "./options"
|
||||
|
||||
export interface Lifecycle<E = never, R = never> {
|
||||
readonly onListen: (
|
||||
address: HttpServer.Address,
|
||||
address: NetAddress.SocketAddress,
|
||||
shutdown: Effect.Effect<void>,
|
||||
) => Effect.Effect<Effect.Effect<void>, E, R>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
const SessionResponse = Schema.Struct({ data: Schema.toEncoded(Session.Info) })
|
||||
|
||||
it.live("updates session metadata through PATCH", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make({
|
||||
app: { version: "test" },
|
||||
database: { path: ":memory:" },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
})
|
||||
const request = (path: string, method: string, body?: unknown) =>
|
||||
Effect.promise(async () => {
|
||||
const response = await handler(
|
||||
new Request(`http://opencode.local${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(method === "PATCH" ? 204 : 200)
|
||||
return response.status === 204 ? undefined : response.json()
|
||||
})
|
||||
|
||||
const created = Schema.decodeUnknownSync(SessionResponse)(
|
||||
yield* request("/api/session", "POST", { metadata: { source: "create", stale: true } }),
|
||||
)
|
||||
yield* request(`/api/session/${created.data.id}`, "PATCH", { metadata: { source: "patch" } })
|
||||
const updated = Schema.decodeUnknownSync(SessionResponse)(yield* request(`/api/session/${created.data.id}`, "GET"))
|
||||
|
||||
expect(updated.data.metadata).toEqual({ source: "patch" })
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
@@ -28,7 +28,7 @@ export const simulationReplacements: (app: {
|
||||
// ModelsDev dies when its catalog fetch fails, so simulation answers it with
|
||||
// an empty catalog; providers come from seeded config instead.
|
||||
const models = SimulationNetwork.json("GET", "https://models.opencode.ai/api.json", {})
|
||||
const drive = yield* Config.string("OPENCODE_DRIVE").pipe(Config.withDefault(undefined))
|
||||
const drive = yield* Config.String("OPENCODE_DRIVE").pipe(Config.withDefault(undefined))
|
||||
if (!drive) return [httpClient.replace(SimulationNetwork.layer([models]))] satisfies LayerNode.Replacements
|
||||
|
||||
const manifest = yield* DriveManifest.resolve()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SimulationServer } from "./server"
|
||||
|
||||
/** Drive-mode renderer and control-server acquisition. */
|
||||
export const create = Effect.fn("Drive.create")(function* (options: CliRendererConfig, version: string) {
|
||||
const headless = (yield* Config.string("OPENCODE_DRIVE_RENDERER").pipe(Config.withDefault("visible"))) === "headless"
|
||||
const headless = (yield* Config.String("OPENCODE_DRIVE_RENDERER").pipe(Config.withDefault("visible"))) === "headless"
|
||||
const manifest = yield* DriveManifest.resolve()
|
||||
const renderer = headless
|
||||
? yield* SimulationRenderer.create(options, manifest.recording?.timeline, manifest.viewport)
|
||||
|
||||
@@ -70,11 +70,11 @@ export const resolve = Effect.fn("DriveManifest.resolve")(function* () {
|
||||
const name = yield* Config.schema(InstanceName, "OPENCODE_DRIVE").pipe(Effect.mapError(configError))
|
||||
if (name === "1") return defaults
|
||||
|
||||
const state = yield* Config.string("XDG_STATE_HOME").pipe(
|
||||
const state = yield* Config.String("XDG_STATE_HOME").pipe(
|
||||
Config.withDefault(join(homedir(), ".local", "state")),
|
||||
Effect.mapError(configError),
|
||||
)
|
||||
const directory = yield* Config.string("DRIVE_REGISTRY_DIR").pipe(
|
||||
const directory = yield* Config.String("DRIVE_REGISTRY_DIR").pipe(
|
||||
Config.withDefault(join(state, "opencode-drive", "instances")),
|
||||
Effect.mapError(configError),
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ const decodeAppConfigValue = Schema.decodeUnknownSync(AppConfigValue)
|
||||
|
||||
const config = Config.all({
|
||||
stage: Config.succeed(Resource.App.stage),
|
||||
publicUrl: Config.string("PUBLIC_URL").pipe(Config.withDefault("http://localhost:3000")),
|
||||
publicUrl: Config.String("PUBLIC_URL").pipe(Config.withDefault("http://localhost:3000")),
|
||||
}).pipe(Config.map(decodeAppConfigValue))
|
||||
|
||||
export class AppConfig extends Context.Service<AppConfig, AppConfigValue>()("@opencode/stats/AppConfig") {
|
||||
|
||||
@@ -16,8 +16,8 @@ export class DatabaseSettings extends Schema.Class<DatabaseSettings>("DatabaseSe
|
||||
const decodeDatabaseSettings = Schema.decodeUnknownSync(DatabaseSettings)
|
||||
|
||||
const config = Config.all({
|
||||
url: Config.nonEmptyString("DATABASE_URL").pipe(Config.withDefault(Resource.StatsDatabase.url)),
|
||||
migrationsDir: Config.nonEmptyString("DATABASE_MIGRATIONS_DIR").pipe(Config.withDefault("./migrations")),
|
||||
url: Config.NonEmptyString("DATABASE_URL").pipe(Config.withDefault(Resource.StatsDatabase.url)),
|
||||
migrationsDir: Config.NonEmptyString("DATABASE_MIGRATIONS_DIR").pipe(Config.withDefault("./migrations")),
|
||||
}).pipe(Config.map(decodeDatabaseSettings))
|
||||
|
||||
export class DatabaseConfig extends Context.Service<DatabaseConfig, DatabaseSettings>()(
|
||||
|
||||
@@ -12,8 +12,8 @@ registerShutdownSignalHandlers()
|
||||
const ServerLive = NodeHttpServer.layerConfig(
|
||||
() => createServer(),
|
||||
Config.all({
|
||||
port: Config.number("PORT").pipe(Config.withDefault(3000)),
|
||||
host: Config.string("HOST").pipe(Config.withDefault("0.0.0.0")),
|
||||
port: Config.Number("PORT").pipe(Config.withDefault(3000)),
|
||||
host: Config.String("HOST").pipe(Config.withDefault("0.0.0.0")),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import type { HueName } from "./schema.js"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = ["blue", "purple", "green", "orange", "red", "cyan"] as const satisfies readonly HueName[]
|
||||
@@ -2,13 +2,11 @@ export {
|
||||
ActionState,
|
||||
type ActionStateKey,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
BaseThemeDefinition,
|
||||
CategoricalDefinition,
|
||||
FeedbackKind,
|
||||
FormfieldState,
|
||||
type FormfieldStateKey,
|
||||
HueAlias,
|
||||
HueName,
|
||||
HueStep,
|
||||
MarkdownDefinition,
|
||||
@@ -24,6 +22,7 @@ export {
|
||||
type FormfieldColorDefinition,
|
||||
type HueDefinition,
|
||||
type Mode,
|
||||
type SemanticHue,
|
||||
type StatefulColorDefinition,
|
||||
type TextDefinition,
|
||||
type ThemeTokensDefinition,
|
||||
@@ -42,10 +41,9 @@ export type {
|
||||
ResolvedThemeTokens,
|
||||
StatefulColor,
|
||||
} from "./types.js"
|
||||
export { DEFAULT_CATEGORICAL } from "./categorical.js"
|
||||
export { rgbToOklch } from "./color.js"
|
||||
export { expandTheme } from "./expand.js"
|
||||
export { migrateV1 } from "./v1-migrate.js"
|
||||
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
|
||||
export { parseThemeDocument, resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
|
||||
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
|
||||
export { generateSyntax } from "./syntax.js"
|
||||
|
||||
@@ -4,8 +4,6 @@ import { expandTheme, mergeTheme } from "./expand.js"
|
||||
import {
|
||||
ActionState,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
HueAlias,
|
||||
HueStep,
|
||||
SurfaceName,
|
||||
ThemeDefinition,
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
import type {
|
||||
ActionStateKey,
|
||||
ActionStates,
|
||||
HueDefinition,
|
||||
HueScale,
|
||||
Mode,
|
||||
ResolvedActionState,
|
||||
@@ -24,9 +21,12 @@ import type {
|
||||
StatefulColorDefinition,
|
||||
ThemeTokensDefinition,
|
||||
} from "./index.js"
|
||||
import { selectThemeMode } from "./select.js"
|
||||
import { selectThemeMode, themeModes } from "./select.js"
|
||||
|
||||
type ResolvedHue = ResolvedThemeTokens["hue"] & Readonly<Record<string, HueScale>>
|
||||
|
||||
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition, { reportInput: true })
|
||||
const decodeThemeDocumentSchema = Schema.decodeUnknownSync(ThemeDocument, { reportInput: true })
|
||||
|
||||
function decodeThemeDefinition(input: unknown) {
|
||||
try {
|
||||
@@ -42,6 +42,16 @@ export function themeDecodeError(error: unknown, name: string) {
|
||||
return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error })
|
||||
}
|
||||
|
||||
export function parseThemeDocument(input: unknown, name = "theme") {
|
||||
try {
|
||||
const document = decodeThemeDocumentSchema(input)
|
||||
themeModes(document).forEach((mode) => resolveThemeDocument(document, mode))
|
||||
return document
|
||||
} catch (error) {
|
||||
throw themeDecodeError(error, name)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveThemeDocument(document: ThemeDocument, mode?: Mode) {
|
||||
const selected = selectThemeMode(document, mode)
|
||||
const definition = expandTheme(selected.theme)
|
||||
@@ -54,7 +64,11 @@ export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
|
||||
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
const hue = resolveHue(definition.hue)
|
||||
const categorical = definition.categorical.map((name) => hue[name])
|
||||
const categorical = definition.categorical.map((name) => {
|
||||
const scale = hue[name]
|
||||
if (!scale) throw new Error(`Categorical hue "${name}" was not found`)
|
||||
return scale
|
||||
})
|
||||
const hueSteps = compileHueSteps(hue)
|
||||
const base = tokens(definition)
|
||||
const views = {} as Record<SurfaceName, ResolvedTheme>
|
||||
@@ -156,10 +170,10 @@ function statefulColor(color: StatefulColor): StatefulColor {
|
||||
}
|
||||
|
||||
function compileHueSteps(
|
||||
hue: ResolvedThemeTokens["hue"],
|
||||
hue: ResolvedHue,
|
||||
): Pick<ResolvedThemeTokens, "source" | "increase" | "decrease"> {
|
||||
const index = new WeakMap<RGBA, { hue: keyof typeof hue; step: HueStep; position: number }>()
|
||||
for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) {
|
||||
const index = new WeakMap<RGBA, { hue: string; step: HueStep; position: number }>()
|
||||
for (const [name, scale] of Object.entries(hue)) {
|
||||
HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position }))
|
||||
}
|
||||
const shift = (color: RGBA, amount: number) => {
|
||||
@@ -179,13 +193,9 @@ function compileHueSteps(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHue(definition: HueDefinition) {
|
||||
function resolveHue(definition: ThemeDefinition["hue"]) {
|
||||
const source = definition as Record<string, unknown>
|
||||
const cache = new Map<string, HueScale>()
|
||||
const expected = new Set<string>([...BaseHue.literals, ...HueAlias.literals])
|
||||
for (const name of Object.keys(source)) {
|
||||
if (!expected.has(name)) throw new Error(`Unknown hue "${name}"`)
|
||||
}
|
||||
|
||||
function resolve(name: string, stack: string[]): HueScale {
|
||||
const hit = cache.get(name)
|
||||
@@ -193,8 +203,9 @@ function resolveHue(definition: HueDefinition) {
|
||||
if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`)
|
||||
const value = source[name]
|
||||
if (typeof value === "string") {
|
||||
const match = /^\$hue\.([^.]+)$/.exec(value)
|
||||
const match = /^\$hue\.(.+)$/.exec(value)
|
||||
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
|
||||
if (source[match[1]] === undefined) throw new Error(`Hue alias "${value}" references a missing hue`)
|
||||
const target = resolve(match[1], [...stack, name])
|
||||
const result = Object.fromEntries(HueStep.literals.map((step) => [step, RGBA.clone(target[step])])) as HueScale
|
||||
cache.set(name, result)
|
||||
@@ -216,9 +227,7 @@ function resolveHue(definition: HueDefinition) {
|
||||
return result
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
[...BaseHue.literals, ...HueAlias.literals].map((name) => [name, resolve(name, [])]),
|
||||
) as ResolvedThemeTokens["hue"]
|
||||
return Object.fromEntries(Object.keys(source).map((name) => [name, resolve(name, [])])) as ResolvedHue
|
||||
}
|
||||
|
||||
function createResolver(source: Record<string, unknown>) {
|
||||
@@ -242,7 +251,7 @@ function createResolver(source: Record<string, unknown>) {
|
||||
const hit = cache.get(target)
|
||||
if (hit) return hit
|
||||
if (stack.includes(target)) throw new Error(`Circular theme reference: ${[...stack, target].join(" -> ")}`)
|
||||
const result = resolve(read(source, target), target, [...stack, target])
|
||||
const result = resolve(readColor(source, target), target, [...stack, target])
|
||||
if (!(result instanceof RGBA)) throw new Error(`Theme reference "${value}" at "${path}" is not a color`)
|
||||
cache.set(target, result)
|
||||
return result
|
||||
@@ -251,6 +260,16 @@ function createResolver(source: Record<string, unknown>) {
|
||||
return (value: unknown, path: string) => resolve(value, path)
|
||||
}
|
||||
|
||||
function readColor(source: Record<string, unknown>, path: string) {
|
||||
const match = /^hue\.(.+)\.(100|200|300|400|500|600|700|800|900)$/.exec(path)
|
||||
if (!match?.[1] || !match[2]) return read(source, path)
|
||||
const scales = source.hue
|
||||
const scale = isRecord(scales) ? scales[match[1]] : undefined
|
||||
const color = isRecord(scale) ? scale[match[2]] : undefined
|
||||
if (color === undefined) throw new Error(`Theme reference "$${path}" was not found`)
|
||||
return color
|
||||
}
|
||||
|
||||
function resolvedKey(key: string) {
|
||||
if (!key.startsWith("$")) return key
|
||||
const state = key.slice(1)
|
||||
|
||||
@@ -2,12 +2,7 @@ import { Schema } from "effect"
|
||||
|
||||
export const HueStep = Schema.Literals([100, 200, 300, 400, 500, 600, 700, 800, 900])
|
||||
export type HueStep = Schema.Schema.Type<typeof HueStep>
|
||||
|
||||
export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
|
||||
export type BaseHue = Schema.Schema.Type<typeof BaseHue>
|
||||
|
||||
export const HueAlias = Schema.Literals(["accent", "interactive", "neutral"])
|
||||
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
|
||||
export type SemanticHue = "accent" | "interactive" | "neutral"
|
||||
|
||||
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
|
||||
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
|
||||
@@ -37,29 +32,32 @@ const ColorValue = Schema.Union([
|
||||
Schema.TemplateLiteral(["$", Schema.NonEmptyString]),
|
||||
])
|
||||
|
||||
export const HueName = Schema.Union([BaseHue, HueAlias])
|
||||
export const HueName = Schema.NonEmptyString
|
||||
export type HueName = Schema.Schema.Type<typeof HueName>
|
||||
export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLength(1))
|
||||
export type CategoricalDefinition = Schema.Schema.Type<typeof CategoricalDefinition>
|
||||
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
|
||||
const HueColorValue = Schema.Union([
|
||||
HexColor,
|
||||
Schema.String.check(Schema.isPattern(/^\$hue\..+\.(?:100|200|300|400|500|600|700|800|900)$/)),
|
||||
])
|
||||
|
||||
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
|
||||
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
|
||||
|
||||
const HueDefinition = Schema.Struct({
|
||||
gray: HueValueDefinition,
|
||||
red: HueValueDefinition,
|
||||
orange: HueValueDefinition,
|
||||
yellow: HueValueDefinition,
|
||||
green: HueValueDefinition,
|
||||
cyan: HueValueDefinition,
|
||||
blue: HueValueDefinition,
|
||||
purple: HueValueDefinition,
|
||||
accent: HueValueDefinition,
|
||||
interactive: HueValueDefinition,
|
||||
neutral: HueValueDefinition,
|
||||
})
|
||||
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
|
||||
const HueValueDefinition = Schema.Union([
|
||||
Schema.String.check(Schema.isPattern(/^\$hue\..+$/)),
|
||||
HueScaleDefinition,
|
||||
])
|
||||
type HueValueDefinition = Schema.Schema.Type<typeof HueValueDefinition>
|
||||
const HueRecord = Schema.Record(HueName, HueValueDefinition)
|
||||
const HueDefinition = HueRecord.check(
|
||||
Schema.makeFilter<Schema.Schema.Type<typeof HueRecord>>((hues) => {
|
||||
const missing = (["accent", "interactive", "neutral"] satisfies readonly SemanticHue[]).filter(
|
||||
(name) => hues[name] === undefined,
|
||||
)
|
||||
return missing.length ? `Missing required semantic hues: ${missing.join(", ")}` : undefined
|
||||
}),
|
||||
)
|
||||
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition> &
|
||||
Readonly<Record<SemanticHue, HueValueDefinition>>
|
||||
|
||||
const StatefulColorDefinition = Schema.Struct({
|
||||
base: Schema.optional(ColorValue),
|
||||
|
||||
@@ -2,11 +2,10 @@ import type { RGBA } from "@opentui/core"
|
||||
import type {
|
||||
ActionState,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
FeedbackKind,
|
||||
HueAlias,
|
||||
HueStep,
|
||||
MarkdownToken,
|
||||
SemanticHue,
|
||||
SurfaceName,
|
||||
SyntaxToken,
|
||||
} from "./schema.js"
|
||||
@@ -14,8 +13,8 @@ import type {
|
||||
export type ResolvedActionState = "base" | ActionState
|
||||
export type ResolvedFormfieldState = ResolvedActionState
|
||||
export type HueScale = Readonly<Record<HueStep, RGBA>>
|
||||
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
|
||||
export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }>
|
||||
export type Hue = Readonly<Record<SemanticHue, HueScale>>
|
||||
export type HueSource = Readonly<{ hue: string; step: HueStep }>
|
||||
export type Categorical = readonly HueScale[]
|
||||
export type ActionStates = Readonly<Partial<Record<ActionState, boolean>>>
|
||||
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>> & {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { oklchToHex, rgbToOklch } from "./color.js"
|
||||
import { DEFAULT_CATEGORICAL } from "./categorical.js"
|
||||
import type { BaseThemeDefinition, HueDefinition, Mode, ThemeDefinition, ThemeDocument } from "./index.js"
|
||||
import { HueStep } from "./schema.js"
|
||||
import type { Theme, ThemeV1Json } from "./v1.js"
|
||||
@@ -10,7 +9,15 @@ type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "p
|
||||
type V1HueToken = "secondary" | "accent" | "success" | "warning" | "primary" | "error" | "info"
|
||||
|
||||
const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "green", "cyan", "blue", "purple"]
|
||||
const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "success", "warning", "primary", "error"]
|
||||
const categoricalTokens: readonly V1HueToken[] = [
|
||||
"secondary",
|
||||
"accent",
|
||||
"success",
|
||||
"warning",
|
||||
"primary",
|
||||
"error",
|
||||
"info",
|
||||
]
|
||||
const minimumChroma = 0.03
|
||||
const lightThreshold = 0.6
|
||||
// Canonical swatches copied from the original default-theme classifier keep V1 migration self-contained.
|
||||
@@ -98,16 +105,16 @@ function migrateMode(theme: Theme, mode: Mode): ThemeDefinition {
|
||||
hue: {
|
||||
gray: neutralScale(theme),
|
||||
...Object.fromEntries(
|
||||
chromaticHues.map((name) => {
|
||||
chromaticHues.flatMap((name) => {
|
||||
const match = hues.byHue[name]
|
||||
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
|
||||
return match ? [[name, hueScale(match.color, mode)]] : []
|
||||
}),
|
||||
),
|
||||
accent: hues.byToken.accent ? `$hue.${hues.byToken.accent}` : "$hue.gray",
|
||||
interactive: hues.byToken.primary ? `$hue.${hues.byToken.primary}` : "$hue.gray",
|
||||
neutral: "$hue.gray",
|
||||
} as HueDefinition,
|
||||
categorical: uniqueCategorical.length ? uniqueCategorical : DEFAULT_CATEGORICAL,
|
||||
categorical: uniqueCategorical.length ? uniqueCategorical : ["neutral"],
|
||||
text: {
|
||||
base: text,
|
||||
muted: textMuted,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Schema } from "effect"
|
||||
import { migrateV1, resolveThemeDocument, ThemeDocument, themeDecodeError, type ModeDefinition } from "@opencode/theme/tui"
|
||||
import {
|
||||
migrateV1,
|
||||
parseThemeDocument,
|
||||
resolveThemeDocument,
|
||||
type ThemeDocument,
|
||||
type ModeDefinition,
|
||||
} from "@opencode/theme/tui"
|
||||
import { resolveThemeColors } from "./resolve"
|
||||
import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1"
|
||||
import opencode from "./assets/v2/opencode.json" with { type: "json" }
|
||||
@@ -14,7 +19,6 @@ let customThemes: Record<string, ThemeDocumentSource> = {}
|
||||
let systemTheme: ThemeDocumentSource | undefined
|
||||
const listeners = new Set<(themes: Record<string, ThemeDocumentSource>) => void>()
|
||||
const parsed = new WeakMap<object, ThemeDocument>()
|
||||
const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument, { reportInput: true })
|
||||
let opencodeTheme: (ThemeDocument & {
|
||||
readonly light: ModeDefinition
|
||||
readonly dark: ModeDefinition
|
||||
@@ -22,9 +26,9 @@ let opencodeTheme: (ThemeDocument & {
|
||||
|
||||
export function getOpenCodeTheme() {
|
||||
if (opencodeTheme) return opencodeTheme
|
||||
const decoded = decodeThemeDocument(opencode) as NonNullable<typeof opencodeTheme>
|
||||
opencodeTheme = decoded
|
||||
return decoded
|
||||
const document = parseThemeDocument(opencode, "opencode") as NonNullable<typeof opencodeTheme>
|
||||
opencodeTheme = document
|
||||
return document
|
||||
}
|
||||
|
||||
function listThemes(): Record<string, ThemeDocumentSource> {
|
||||
@@ -59,7 +63,7 @@ export function parseTheme(source: ThemeDocumentSource, name = "theme") {
|
||||
const cached = parsed.get(source)
|
||||
if (cached) return cached
|
||||
|
||||
const document = "theme" in source ? migrateV1(source as ThemeV1Json) : decodeV2Theme(source, name)
|
||||
const document = "theme" in source ? migrateV1(source as ThemeV1Json) : parseThemeDocument(source, name)
|
||||
|
||||
parsed.set(source, document)
|
||||
return document
|
||||
@@ -116,11 +120,3 @@ export function resolveTheme(theme: ThemeV1Json, mode: "dark" | "light"): Theme
|
||||
thinkingOpacity: resolved.thinkingOpacity,
|
||||
}
|
||||
}
|
||||
|
||||
function decodeV2Theme(source: ThemeDocumentSource, name: string) {
|
||||
try {
|
||||
return decodeThemeDocument(source)
|
||||
} catch (error) {
|
||||
throw themeDecodeError(error, name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { resolveThemeDocument } from "@opencode/theme/tui"
|
||||
import { resolveThemeDocument, type HueScale, type ResolvedTheme } from "@opencode/theme/tui"
|
||||
import { terminalPalette } from "../../src/component/terminal-pane"
|
||||
import { getOpenCodeTheme } from "../../src/theme"
|
||||
|
||||
test.each(["light", "dark"] as const)("builds the %s ANSI palette from semantic colors", (mode) => {
|
||||
const theme = resolveThemeDocument(getOpenCodeTheme(), mode)
|
||||
const hues = allHues(theme)
|
||||
const black = theme.hue.neutral[mode === "dark" ? 800 : 200]
|
||||
const white = theme.hue.neutral[mode === "dark" ? 200 : 800]
|
||||
const brightWhite = theme.hue.neutral[mode === "dark" ? 100 : 900]
|
||||
@@ -12,8 +13,8 @@ test.each(["light", "dark"] as const)("builds the %s ANSI palette from semantic
|
||||
theme.text.feedback.error.base,
|
||||
theme.text.feedback.success.base,
|
||||
theme.text.feedback.warning.base,
|
||||
theme.hue.blue[200],
|
||||
mode === "dark" ? theme.hue.purple[200] : theme.hue.accent[200],
|
||||
hues.blue[200],
|
||||
mode === "dark" ? hues.purple[200] : theme.hue.accent[200],
|
||||
theme.text.feedback.info.base,
|
||||
white,
|
||||
]
|
||||
@@ -38,9 +39,10 @@ test.each(["light", "dark"] as const)("builds the %s ANSI palette from semantic
|
||||
|
||||
test("falls back to accent when no hue is confidently blue or magenta", () => {
|
||||
const resolved = resolveThemeDocument(getOpenCodeTheme(), "dark")
|
||||
const hues = allHues(resolved)
|
||||
const hue = {
|
||||
...Object.fromEntries(Object.keys(resolved.hue).map((name) => [name, resolved.hue.red])),
|
||||
accent: resolved.hue.green,
|
||||
...Object.fromEntries(Object.keys(hues).map((name) => [name, hues.red])),
|
||||
accent: hues.green,
|
||||
} as typeof resolved.hue
|
||||
const theme = { ...resolved, hue }
|
||||
const palette = [
|
||||
@@ -60,3 +62,7 @@ function byte(value: number) {
|
||||
function hex(color: { toInts(): [number, number, number, number] }) {
|
||||
return `#${color.toInts().slice(0, 3).map(byte).join("")}`
|
||||
}
|
||||
|
||||
function allHues(theme: ResolvedTheme) {
|
||||
return theme.hue as typeof theme.hue & Readonly<Record<string, HueScale>>
|
||||
}
|
||||
|
||||
@@ -13,15 +13,15 @@ test("provides reactive properties, states, surfaces, and color operations", ()
|
||||
expect(theme.text.base).toBe(resolved().text.base)
|
||||
expect(theme.hue.accent[500]).toBe(resolved().hue.accent[500])
|
||||
expect(theme.hue.interactive[500]).toBe(resolved().hue.interactive[500])
|
||||
expect(theme.hue.gray[200]).toBe(resolved().hue.gray[200])
|
||||
expect(theme.hue.neutral[200]).toBe(resolved().hue.neutral[200])
|
||||
expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
|
||||
expect(theme.increase(theme.background.raised.base, 1)).toBe(resolved().hue.neutral[800])
|
||||
expect(theme.decrease(theme.background.raised.base)).toBe(resolved().hue.neutral[600])
|
||||
expect(theme.decrease(theme.hue.red[300], 2)).toBe(resolved().hue.red[100])
|
||||
expect(theme.increase(theme.hue.red[900], 3)).toBe(resolved().hue.red[900])
|
||||
expect(theme.decrease(theme.hue.red[100], 3)).toBe(resolved().hue.red[100])
|
||||
expect(theme.decrease(theme.hue.accent[300], 2)).toBe(resolved().hue.accent[100])
|
||||
expect(theme.increase(theme.hue.accent[900], 3)).toBe(resolved().hue.accent[900])
|
||||
expect(theme.decrease(theme.hue.accent[100], 3)).toBe(resolved().hue.accent[100])
|
||||
expect(theme.source(theme.background.raised.base)).toEqual({ hue: "neutral", step: 700 })
|
||||
const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts())
|
||||
const equivalent = RGBA.fromInts(...resolved().categorical[0][500].toInts())
|
||||
expect(theme.source(equivalent)).toBeUndefined()
|
||||
expect(theme.increase(equivalent, 1)).toBe(equivalent)
|
||||
const unmatched = RGBA.fromInts(1, 2, 3)
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import {
|
||||
BaseHue,
|
||||
generateSyntax,
|
||||
resolveTheme,
|
||||
resolveThemeDocument,
|
||||
selectTheme,
|
||||
type HueScale,
|
||||
type Mode,
|
||||
type ResolvedTheme,
|
||||
type ThemeDefinition,
|
||||
} from "@opencode/theme/tui"
|
||||
import { getOpenCodeTheme, parseTheme, type ThemeDocumentSource } from "../../../src/theme"
|
||||
|
||||
const opencodeLight = selectTheme(getOpenCodeTheme(), "light")
|
||||
const opencodeDark = selectTheme(getOpenCodeTheme(), "dark")
|
||||
const light = {
|
||||
const chromaticHues = ["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"] as const
|
||||
const light: ThemeDefinition = {
|
||||
...opencodeLight,
|
||||
categorical: ["blue", "purple"],
|
||||
hue: { ...opencodeLight.hue, accent: "$hue.blue", interactive: "$hue.blue", neutral: "$hue.gray" },
|
||||
} satisfies ThemeDefinition
|
||||
const dark = {
|
||||
}
|
||||
const dark: ThemeDefinition = {
|
||||
...opencodeDark,
|
||||
categorical: ["blue", "purple"],
|
||||
hue: { ...opencodeDark.hue, accent: "$hue.blue", interactive: "$hue.blue", neutral: "$hue.gray" },
|
||||
} satisfies ThemeDefinition
|
||||
}
|
||||
|
||||
test("orders light hues dark-to-light and dark hues light-to-dark", () => {
|
||||
const lightTheme = resolveTheme(light)
|
||||
const darkTheme = resolveTheme(dark)
|
||||
const lightHues = allHues(lightTheme)
|
||||
const darkHues = allHues(darkTheme)
|
||||
const luminance = (color: RGBA) => 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
|
||||
|
||||
BaseHue.literals.forEach((name) => {
|
||||
expect(luminance(lightTheme.hue[name][100])).toBeLessThan(luminance(lightTheme.hue[name][900]))
|
||||
expect(luminance(darkTheme.hue[name][100])).toBeGreaterThan(luminance(darkTheme.hue[name][900]))
|
||||
chromaticHues.forEach((name) => {
|
||||
expect(luminance(lightHues[name][100])).toBeLessThan(luminance(lightHues[name][900]))
|
||||
expect(luminance(darkHues[name][100])).toBeGreaterThan(luminance(darkHues[name][900]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,9 +65,10 @@ test("rejects theme documents without a mode", () => {
|
||||
|
||||
test("validates and resolves categorical hues in configured order", () => {
|
||||
const theme = resolveSource(complete("light", { categorical: ["accent", "red", "interactive"] }), "light")
|
||||
const hues = allHues(theme)
|
||||
|
||||
expect(theme.categorical[0]).toBe(theme.hue.accent)
|
||||
expect(theme.categorical[1]).toBe(theme.hue.red)
|
||||
expect(theme.categorical[1]).toBe(hues.red)
|
||||
expect(theme.categorical[2]).toBe(theme.hue.interactive)
|
||||
expect(theme.surface("dialog").categorical).toBe(theme.categorical)
|
||||
expect(() => resolveSource(complete("light", { categorical: [] }), "light")).toThrow("Invalid theme")
|
||||
@@ -72,6 +77,36 @@ test("validates and resolves categorical hues in configured order", () => {
|
||||
).toThrow("Invalid theme")
|
||||
})
|
||||
|
||||
test("resolves arbitrary hue names across aliases, categorical colors, and token references", () => {
|
||||
const ocean = light.hue.blue
|
||||
if (typeof ocean !== "object") throw new Error("Expected a concrete blue scale")
|
||||
const theme = resolveTheme({
|
||||
...light,
|
||||
hue: { ...light.hue, "brand.ocean": ocean, accent: "$hue.brand.ocean" },
|
||||
categorical: ["brand.ocean"],
|
||||
syntax: { ...light.syntax, keyword: "$hue.brand.ocean.200" },
|
||||
})
|
||||
const hues = allHues(theme)
|
||||
|
||||
expect(theme.hue.accent[200].equals(hues["brand.ocean"][200])).toBeTrue()
|
||||
expect(theme.categorical[0]).toBe(hues["brand.ocean"])
|
||||
expect(theme.syntax.keyword).toBe(hues["brand.ocean"][200])
|
||||
expect(theme.source(theme.syntax.keyword)).toEqual({ hue: "brand.ocean", step: 200 })
|
||||
})
|
||||
|
||||
test("validates hue relationships in every provided mode while parsing", () => {
|
||||
const source = structuredClone(getOpenCodeTheme()) as ThemeDocumentSource
|
||||
const darkMode = source.dark as Record<string, unknown>
|
||||
darkMode.hue = { ...(darkMode.hue as Record<string, unknown>), accent: "$hue.missing" }
|
||||
const missingSemantic = structuredClone(getOpenCodeTheme()) as ThemeDocumentSource
|
||||
const lightMode = missingSemantic.light as Record<string, unknown>
|
||||
const lightHues = lightMode.hue as Record<string, unknown>
|
||||
delete lightHues.neutral
|
||||
|
||||
expect(() => parseTheme(source, "broken-dark")).toThrow("Invalid theme: broken-dark")
|
||||
expect(() => parseTheme(missingSemantic, "missing-semantic")).toThrow("Invalid theme: missing-semantic")
|
||||
})
|
||||
|
||||
test("generates syntax with one categorical hue", () => {
|
||||
const theme = resolveSource(complete("light", { categorical: ["red"] }), "light")
|
||||
const syntax = generateSyntax(theme)
|
||||
@@ -90,22 +125,24 @@ test("rejects incomplete themes instead of merging defaults", () => {
|
||||
test("resolves independent definitions and hue aliases", () => {
|
||||
const lightTheme = resolveTheme(light)
|
||||
const darkTheme = resolveTheme(dark)
|
||||
const lightHues = allHues(lightTheme)
|
||||
const darkHues = allHues(darkTheme)
|
||||
|
||||
expect(lightTheme.hue.accent).not.toBe(lightTheme.hue.blue)
|
||||
expect(lightTheme.hue.accent[500].equals(lightTheme.hue.blue[500])).toBeTrue()
|
||||
expect(lightTheme.hue.interactive).not.toBe(lightTheme.hue.blue)
|
||||
expect(lightTheme.hue.interactive[500].equals(lightTheme.hue.blue[500])).toBeTrue()
|
||||
expect(lightTheme.hue.neutral).not.toBe(lightTheme.hue.gray)
|
||||
expect(lightTheme.hue.neutral[500].equals(lightTheme.hue.gray[500])).toBeTrue()
|
||||
expect(lightTheme.categorical[0]).toBe(lightTheme.hue.blue)
|
||||
expect(lightTheme.source(lightTheme.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
|
||||
expect(lightTheme.hue.accent).not.toBe(lightHues.blue)
|
||||
expect(lightTheme.hue.accent[500].equals(lightHues.blue[500])).toBeTrue()
|
||||
expect(lightTheme.hue.interactive).not.toBe(lightHues.blue)
|
||||
expect(lightTheme.hue.interactive[500].equals(lightHues.blue[500])).toBeTrue()
|
||||
expect(lightTheme.hue.neutral).not.toBe(lightHues.gray)
|
||||
expect(lightTheme.hue.neutral[500].equals(lightHues.gray[500])).toBeTrue()
|
||||
expect(lightTheme.categorical[0]).toBe(lightHues.blue)
|
||||
expect(lightTheme.source(lightHues.blue[500])).toEqual({ hue: "blue", step: 500 })
|
||||
expect(lightTheme.source(lightTheme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 })
|
||||
expect(lightTheme.source(lightTheme.background.raised.base)).toEqual({ hue: "neutral", step: 700 })
|
||||
expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
|
||||
expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
|
||||
expect(lightTheme.surface("dialog").increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
|
||||
expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
|
||||
expect(darkTheme.decrease(darkTheme.hue.red[200])).toBe(darkTheme.hue.red[100])
|
||||
expect(lightTheme.increase(lightHues.red[100])).toBe(lightHues.red[200])
|
||||
expect(lightTheme.decrease(lightHues.red[200])).toBe(lightHues.red[100])
|
||||
expect(lightTheme.surface("dialog").increase(lightHues.red[100])).toBe(lightHues.red[200])
|
||||
expect(lightTheme.decrease(lightHues.red[200])).toBe(lightHues.red[100])
|
||||
expect(darkTheme.decrease(darkHues.red[200])).toBe(darkHues.red[100])
|
||||
expect(lightTheme.text.base).toBeInstanceOf(RGBA)
|
||||
expect(darkTheme.background.base).toBeInstanceOf(RGBA)
|
||||
expect(lightTheme.background.raised.base).toBe(lightTheme.hue.neutral[700])
|
||||
@@ -136,16 +173,18 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
|
||||
complete("light", { hue: { ...light.hue, blue: "$hue.red" } }),
|
||||
"light",
|
||||
)
|
||||
const aliasedHues = allHues(aliased)
|
||||
const overriddenHues = allHues(overridden)
|
||||
|
||||
expect(aliased.hue.blue).not.toBe(aliased.hue.red)
|
||||
expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue()
|
||||
expect(aliased.hue.purple).not.toBe(aliased.hue.blue)
|
||||
expect(aliased.hue.purple[500].equals(aliased.hue.red[500])).toBeTrue()
|
||||
expect(overridden.hue.blue).not.toBe(overridden.hue.red)
|
||||
expect(overridden.hue.blue[500].equals(overridden.hue.red[500])).toBeTrue()
|
||||
expect(aliased.source(aliased.hue.red[500])).toEqual({ hue: "red", step: 500 })
|
||||
expect(aliased.source(aliased.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
|
||||
expect(aliased.source(aliased.hue.purple[500])).toEqual({ hue: "purple", step: 500 })
|
||||
expect(aliasedHues.blue).not.toBe(aliasedHues.red)
|
||||
expect(aliasedHues.blue[500].equals(aliasedHues.red[500])).toBeTrue()
|
||||
expect(aliasedHues.purple).not.toBe(aliasedHues.blue)
|
||||
expect(aliasedHues.purple[500].equals(aliasedHues.red[500])).toBeTrue()
|
||||
expect(overriddenHues.blue).not.toBe(overriddenHues.red)
|
||||
expect(overriddenHues.blue[500].equals(overriddenHues.red[500])).toBeTrue()
|
||||
expect(aliased.source(aliasedHues.red[500])).toEqual({ hue: "red", step: 500 })
|
||||
expect(aliased.source(aliasedHues.blue[500])).toEqual({ hue: "blue", step: 500 })
|
||||
expect(aliased.source(aliasedHues.purple[500])).toEqual({ hue: "purple", step: 500 })
|
||||
expect(() =>
|
||||
resolveTheme(
|
||||
{
|
||||
@@ -320,6 +359,10 @@ function override(base: ThemeDefinition, value: unknown) {
|
||||
return merge(base, value) as ThemeDefinition
|
||||
}
|
||||
|
||||
function allHues(theme: ResolvedTheme) {
|
||||
return theme.hue as typeof theme.hue & Readonly<Record<string, HueScale>>
|
||||
}
|
||||
|
||||
function merge(...values: unknown[]): Record<string, unknown> {
|
||||
return values.reduce<Record<string, unknown>>((result, value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return result
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
DEFAULT_CATEGORICAL,
|
||||
migrateV1,
|
||||
resolveThemeDocument,
|
||||
selectThemeMode,
|
||||
themeModes,
|
||||
type HueScale,
|
||||
type ResolvedTheme,
|
||||
} from "@opencode/theme/tui"
|
||||
import { DEFAULT_THEMES, getOpenCodeTheme, resolveTheme as resolveV1 } from "../../../src/theme"
|
||||
import opencodeSource from "../../../src/theme/assets/opencode.json" with { type: "json" }
|
||||
@@ -13,6 +14,7 @@ import type { ThemeV1Json } from "@opencode/theme/tui/v1"
|
||||
const opencodeV1 = opencodeSource as ThemeV1Json
|
||||
const opencodeLight = resolveThemeDocument(getOpenCodeTheme(), "light")
|
||||
const opencodeDark = resolveThemeDocument(getOpenCodeTheme(), "dark")
|
||||
const opencodeLightHues = allHues(opencodeLight)
|
||||
|
||||
test("migrates resolved V1 modes into V2 tokens", () => {
|
||||
const migrated = migrateV1(opencodeV1)
|
||||
@@ -72,7 +74,7 @@ test("references generated hues from matching token colors", () => {
|
||||
expect(migrated.base.markdown?.emphasis).toBe("#123456")
|
||||
})
|
||||
|
||||
test("infers chromatic hues, anchors light and dark colors, and aliases ambiguous hues to gray", () => {
|
||||
test("infers and emits only chromatic hues represented by V1 colors", () => {
|
||||
const source = structuredClone(opencodeV1)
|
||||
const ambiguous = { light: "#808080", dark: "#808080" }
|
||||
source.theme.accent = ambiguous
|
||||
@@ -93,12 +95,12 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
|
||||
expect(darkRed[200]).toBe("#450000")
|
||||
expect(lightRed[100]).not.toBe(lightRed[200])
|
||||
expect(darkRed[100]).not.toBe(darkRed[200])
|
||||
expect(migrated.light.hue?.orange).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.yellow).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.green).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.cyan).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.blue).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.purple).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.orange).toBeUndefined()
|
||||
expect(migrated.light.hue?.yellow).toBeUndefined()
|
||||
expect(migrated.light.hue?.green).toBeUndefined()
|
||||
expect(migrated.light.hue?.cyan).toBeUndefined()
|
||||
expect(migrated.light.hue?.blue).toBeUndefined()
|
||||
expect(migrated.light.hue?.purple).toBeUndefined()
|
||||
expect(migrated.light.hue?.accent).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.interactive).toBe("$hue.gray")
|
||||
expect(() => resolveThemeDocument(migrated, "light")).not.toThrow()
|
||||
@@ -135,21 +137,22 @@ test("orders categorical hues by V1 semantic color mapping", () => {
|
||||
source.theme.warning = mapped("yellow")
|
||||
source.theme.primary = mapped("blue")
|
||||
source.theme.error = mapped("red")
|
||||
source.theme.info = { light: "#67e8f9", dark: "#0e7490" }
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
expect(migrated.base.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
|
||||
expect(migrated.dark?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
|
||||
expect(migrated.base.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red", "cyan"])
|
||||
expect(migrated.dark?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red", "cyan"])
|
||||
|
||||
source.theme.accent = source.theme.secondary
|
||||
expect(migrateV1(source).base.categorical).toEqual(["purple", "green", "yellow", "blue", "red"])
|
||||
expect(migrateV1(source).base.categorical).toEqual(["purple", "green", "yellow", "blue", "red", "cyan"])
|
||||
})
|
||||
|
||||
test("gives accent and primary ownership of their inferred hues", () => {
|
||||
const source = structuredClone(opencodeV1)
|
||||
source.theme.success = hex(opencodeLight.hue.orange[700])
|
||||
source.theme.accent = hex(opencodeLight.hue.orange[600])
|
||||
source.theme.info = hex(opencodeLight.hue.blue[700])
|
||||
source.theme.primary = hex(opencodeLight.hue.blue[600])
|
||||
source.theme.success = hex(opencodeLightHues.orange[700])
|
||||
source.theme.accent = hex(opencodeLightHues.orange[600])
|
||||
source.theme.info = hex(opencodeLightHues.blue[700])
|
||||
source.theme.primary = hex(opencodeLightHues.blue[600])
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
if (!migrated.light) throw new Error("Expected light mode")
|
||||
@@ -162,7 +165,7 @@ test("gives accent and primary ownership of their inferred hues", () => {
|
||||
expect(migrated.light.hue?.accent).toBe("$hue.orange")
|
||||
expect(migrated.light.hue?.interactive).toBe("$hue.blue")
|
||||
|
||||
source.theme.primary = hex(opencodeLight.hue.orange[500])
|
||||
source.theme.primary = hex(opencodeLightHues.orange[500])
|
||||
const collisionMode = migrateV1(source).light
|
||||
const collision = collisionMode?.hue?.orange
|
||||
if (typeof collision !== "object") throw new Error("Expected concrete orange scale")
|
||||
@@ -171,7 +174,7 @@ test("gives accent and primary ownership of their inferred hues", () => {
|
||||
expect(collisionMode?.hue?.interactive).toBe("$hue.orange")
|
||||
})
|
||||
|
||||
test("uses default categorical hues when V1 semantic colors are ambiguous", () => {
|
||||
test("uses the semantic neutral hue when V1 categorical colors are ambiguous", () => {
|
||||
const source = structuredClone(opencodeV1)
|
||||
source.theme.secondary = "transparent"
|
||||
source.theme.accent = "transparent"
|
||||
@@ -179,10 +182,11 @@ test("uses default categorical hues when V1 semantic colors are ambiguous", () =
|
||||
source.theme.warning = "transparent"
|
||||
source.theme.primary = "transparent"
|
||||
source.theme.error = "transparent"
|
||||
source.theme.info = "transparent"
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
expect(migrated.base.categorical).toEqual(DEFAULT_CATEGORICAL)
|
||||
expect(migrated.dark?.categorical).toEqual(DEFAULT_CATEGORICAL)
|
||||
expect(migrated.base.categorical).toEqual(["neutral"])
|
||||
expect(migrated.dark?.categorical).toEqual(["neutral"])
|
||||
})
|
||||
|
||||
test("builds and extrapolates gray from V1 surfaces and text without using menus or borders", () => {
|
||||
@@ -282,6 +286,10 @@ test("keeps both modes when a shared background has different contrast", () => {
|
||||
expect(themeModes(migrated)).toEqual(["light", "dark"])
|
||||
})
|
||||
|
||||
function allHues(theme: ResolvedTheme) {
|
||||
return theme.hue as typeof theme.hue & Readonly<Record<string, HueScale>>
|
||||
}
|
||||
|
||||
function hex(color: { toInts(): [number, number, number, number] }) {
|
||||
const [r, g, b, a] = color.toInts()
|
||||
const byte = (value: number) => value.toString(16).padStart(2, "0")
|
||||
|
||||
@@ -111,7 +111,7 @@ export const trim = Effect.fn("Logging.trim")(function* (
|
||||
if (size <= max) return
|
||||
const handle = yield* fs.open(target, { flag: "r+" })
|
||||
const start = yield* lineStart(handle, size - keep)
|
||||
yield* handle.seek(0, "start")
|
||||
yield* handle.seek(0n, "start")
|
||||
// Reads run to the current EOF so lines appended since the stat survive; the write cursor always
|
||||
// trails the read cursor so the forward copy never overwrites unread bytes.
|
||||
const written = yield* fs.stream(target, { offset: start, chunkSize: LOG_TRIM_CHUNK }).pipe(
|
||||
@@ -146,7 +146,7 @@ function lineStart(handle: FileSystem.File, from: number) {
|
||||
return Effect.gen(function* () {
|
||||
let cursor = from
|
||||
while (true) {
|
||||
yield* handle.seek(cursor, "start")
|
||||
yield* handle.seek(BigInt(cursor), "start")
|
||||
const chunk = yield* handle.readAlloc(LOG_TRIM_CHUNK)
|
||||
if (Option.isNone(chunk)) return cursor
|
||||
const newline = chunk.value.indexOf(10)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
diff --git a/lib/git.js b/lib/git.js
|
||||
index 000ee9fc..a2a6cbb7 100644
|
||||
--- a/lib/git.js
|
||||
+++ b/lib/git.js
|
||||
@@ -254,8 +254,11 @@ class GitFetcher extends Fetcher {
|
||||
resolved: this.resolved,
|
||||
integrity: null, // it'll always be different, if we have one
|
||||
}).extract(tmp).then(() => handler(`${tmp}${this.spec.gitSubdir || ''}`), er => {
|
||||
- // fall back to ssh download if tarball fails
|
||||
- if (er.constructor.name.match(/^Http/)) {
|
||||
+ // fall back to clone if the tarball download fails due to an
|
||||
+ // HTTP error or if the response is not a valid tarball (e.g.
|
||||
+ // a hosted provider returning an HTML sign-in page with 200)
|
||||
+ if ((typeof er.statusCode === 'number' && er.statusCode >= 400) ||
|
||||
+ /^TAR_/.test(er.code)) {
|
||||
return this.#clone(handler, false)
|
||||
} else {
|
||||
throw er
|
||||
@@ -0,0 +1,58 @@
|
||||
diff --git a/node_modules/pacote/.bun-tag-eb0e1d32c8fdffa0 b/.bun-tag-eb0e1d32c8fdffa0
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/lib/git.js b/lib/git.js
|
||||
index 8faf125c6e5e572e980cfc8aaf4e6c9a21d740cf..b1ae3c8cdfa9cbdbd7573be7fa74197d158e6bcb 100644
|
||||
--- a/lib/git.js
|
||||
+++ b/lib/git.js
|
||||
@@ -247,15 +247,20 @@ class GitFetcher extends Fetcher {
|
||||
// if we're resolved, and have a tarball url, shell out to RemoteFetcher
|
||||
if (tarballOk) {
|
||||
const nameat = this.spec.name ? `${this.spec.name}@` : ''
|
||||
- return new RemoteFetcher(h.tarball({ noCommittish: false }), {
|
||||
+ // the hosted committish also carries ::path:, which is not a valid
|
||||
+ // archive ref; download the commit and select the subdirectory below
|
||||
+ return new RemoteFetcher(h.tarball({ committish: this.resolvedSha }), {
|
||||
...this.opts,
|
||||
allowGitIgnore: true,
|
||||
pkgid: `git:${nameat}${this.resolved}`,
|
||||
resolved: this.resolved,
|
||||
integrity: null, // it'll always be different, if we have one
|
||||
}).extract(tmp).then(() => handler(`${tmp}${this.spec.gitSubdir || ''}`), er => {
|
||||
- // fall back to ssh download if tarball fails
|
||||
- if (er.constructor.name.match(/^Http/)) {
|
||||
+ // fall back to clone if the tarball download fails due to an
|
||||
+ // HTTP error or if the response is not a valid tarball (e.g.
|
||||
+ // a hosted provider returning an HTML sign-in page with 200)
|
||||
+ if ((typeof er.statusCode === 'number' && er.statusCode >= 400) ||
|
||||
+ /^TAR_/.test(er.code)) {
|
||||
return this.#clone(handler, false)
|
||||
} else {
|
||||
throw er
|
||||
diff --git a/lib/util/add-git-sha.js b/lib/util/add-git-sha.js
|
||||
index 8518bff9e202a61cd4451d184b74015b43f24ad0..f46620d77c8ee336c20c1c93916307ec3be6e183 100644
|
||||
--- a/lib/util/add-git-sha.js
|
||||
+++ b/lib/util/add-git-sha.js
|
||||
@@ -1,17 +1,20 @@
|
||||
// add a sha to a git remote url spec
|
||||
const addGitSha = (spec, sha) => {
|
||||
+ // the sha replaces any committish or semver range, but the subdirectory
|
||||
+ // still selects the package within the resolved commit
|
||||
+ const subdir = spec.gitSubdir ? `::path:${spec.gitSubdir.slice(1)}` : ''
|
||||
if (spec.hosted) {
|
||||
const h = spec.hosted
|
||||
const opt = { noCommittish: true }
|
||||
const base = h.https && h.auth ? h.https(opt) : h.shortcut(opt)
|
||||
|
||||
- return `${base}#${sha}`
|
||||
+ return `${base}#${sha}${subdir}`
|
||||
} else {
|
||||
// don't use new URL for this, because it doesn't handle scp urls
|
||||
// strip the committish with indexOf/slice to avoid a regexp redos
|
||||
const hashIndex = spec.rawSpec.indexOf('#')
|
||||
const base = hashIndex === -1 ? spec.rawSpec : spec.rawSpec.slice(0, hashIndex)
|
||||
- return `${base}#${sha}`
|
||||
+ return `${base}#${sha}${subdir}`
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user