Compare commits

...
87 changed files with 1177 additions and 712 deletions
+2 -4
View File
@@ -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
+19 -34
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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({
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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 }
}
+1 -1
View File
@@ -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()
+2 -2
View File
@@ -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(
+4 -4
View File
@@ -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"])),
+25 -1
View File
@@ -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: {
+95 -93
View File
@@ -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)),
},
}),
],
+2 -2
View File
@@ -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
+7 -7
View File
@@ -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)
})
+7 -2
View File
@@ -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" },
})
},
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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": {
+15
View File
@@ -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
+13 -7
View File
@@ -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
+2 -1
View File
@@ -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",
+3 -2
View File
@@ -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: () =>
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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) => {
+5
View File
@@ -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,
+8
View File
@@ -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)
+10
View File
@@ -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,
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+2 -3
View File
@@ -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),
+16 -11
View File
@@ -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", () => {
+17 -12
View File
@@ -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", () => {
+10 -7
View File
@@ -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.
+21 -1
View File
@@ -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 -1
View File
@@ -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[] }
+1 -1
View File
@@ -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" },
)
+18 -13
View File
@@ -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")),
)
+1 -1
View File
@@ -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)
+8 -16
View File
@@ -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", () =>
+1 -1
View File
@@ -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:"
+184 -161
View File
@@ -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],
}
+104 -55
View File
@@ -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
+12 -4
View File
@@ -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: {} }),
+2 -2
View File
@@ -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(),
}),
+1
View File
@@ -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,
+11
View File
@@ -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,
+3 -3
View File
@@ -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",
+41 -30
View File
@@ -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),
+20 -12
View File
@@ -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(
+4
View File
@@ -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 })
+2 -1
View File
@@ -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),
)
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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),
)
+1 -1
View File
@@ -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") {
+2 -2
View File
@@ -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>()(
+2 -2
View File
@@ -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")),
}),
)
-3
View File
@@ -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 -4
View File
@@ -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"
+37 -18
View File
@@ -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)
+22 -24
View File
@@ -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),
+3 -4
View File
@@ -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>> & {
+12 -5
View File
@@ -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,
+11 -15
View File
@@ -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>>
}
+5 -5
View File
@@ -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)
+74 -31
View File
@@ -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
+27 -19
View File
@@ -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")
+2 -2
View File
@@ -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)
-18
View File
@@ -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
+58
View File
@@ -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}`
}
}