mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-21 06:04:29 +00:00
Compare commits
59 Commits
kit/effect
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80de9afe9e | ||
|
|
a27bee24aa | ||
|
|
115c9db8f8 | ||
|
|
0738546943 | ||
|
|
8c0fe3273b | ||
|
|
850c54abf8 | ||
|
|
1e266a8860 | ||
|
|
d3228c3678 | ||
|
|
825c1912a4 | ||
|
|
2e0d5d2308 | ||
|
|
38e0dc9ccd | ||
|
|
40aeaa120d | ||
|
|
6a64177589 | ||
|
|
5dc47905a9 | ||
|
|
dc0044882c | ||
|
|
45ae7dc653 | ||
|
|
129fe1e350 | ||
|
|
214a6c6cf1 | ||
|
|
3f249aba6d | ||
|
|
5c6ec1caac | ||
|
|
5792a80a8c | ||
|
|
df44b41aaa | ||
|
|
db039db7f5 | ||
|
|
c1a3936b61 | ||
|
|
b1d537b49a | ||
|
|
9f5713e802 | ||
|
|
bc43bf378d | ||
|
|
3b669e7f2b | ||
|
|
e48da96886 | ||
|
|
20249bb723 | ||
|
|
205affa0eb | ||
|
|
7409ce8aec | ||
|
|
c075a5d694 | ||
|
|
34c676ba7a | ||
|
|
816a5f793f | ||
|
|
ca2099e69d | ||
|
|
79318eb96a | ||
|
|
eb32c7150e | ||
|
|
c7b93a885c | ||
|
|
df5f873cb3 | ||
|
|
1392d868b1 | ||
|
|
e993acec31 | ||
|
|
611e616010 | ||
|
|
b286c0ae3f | ||
|
|
81a61f8dbd | ||
|
|
752e449e38 | ||
|
|
5d419a0211 | ||
|
|
8b168981aa | ||
|
|
724dd665ec | ||
|
|
fc258ea74f | ||
|
|
abd9e195ac | ||
|
|
9d78b69cd3 | ||
|
|
a531f3f36d | ||
|
|
bb3382311d | ||
|
|
ad545d0cc9 | ||
|
|
ac244b1458 | ||
|
|
f202536b65 | ||
|
|
405cc3f610 | ||
|
|
878c1b8c2d |
4
.github/VOUCHED.td
vendored
4
.github/VOUCHED.td
vendored
@@ -10,6 +10,7 @@
|
||||
adamdotdevin
|
||||
-agusbasari29 AI PR slop
|
||||
ariane-emory
|
||||
-danieljoshuanazareth
|
||||
edemaine
|
||||
-florianleibert
|
||||
fwang
|
||||
@@ -17,8 +18,9 @@ iamdavidhill
|
||||
jayair
|
||||
kitlangton
|
||||
kommander
|
||||
-opencode2026
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-OpenCode2026
|
||||
-danieljoshuanazareth
|
||||
|
||||
290
bun.lock
290
bun.lock
@@ -128,7 +128,7 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.0",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
@@ -328,6 +328,7 @@
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
@@ -336,8 +337,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.87",
|
||||
"@opentui/solid": "0.1.87",
|
||||
"@opentui/core": "0.1.88",
|
||||
"@opentui/solid": "0.1.88",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -404,6 +405,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
@@ -610,7 +612,7 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.9",
|
||||
"@types/bun": "1.3.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
@@ -1110,6 +1112,11 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||
|
||||
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
|
||||
|
||||
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
|
||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||
@@ -1184,6 +1191,8 @@
|
||||
|
||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||
|
||||
"@isaacs/string-locale-compare": ["@isaacs/string-locale-compare@1.1.0", "", {}, "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ=="],
|
||||
|
||||
"@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="],
|
||||
|
||||
"@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="],
|
||||
@@ -1356,9 +1365,35 @@
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="],
|
||||
"@npm/types": ["@npm/types@1.0.2", "", {}, "sha512-KXZccTDEnWqNrrx6JjpJKU/wJvNeg9BDgjS0XhmlZab7br921HtyVbsYzJr4L+xIvjdJ20Wh9dgxgCI2a5CEQw=="],
|
||||
|
||||
"@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="],
|
||||
"@npmcli/agent": ["@npmcli/agent@4.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA=="],
|
||||
|
||||
"@npmcli/arborist": ["@npmcli/arborist@9.4.0", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^4.0.0", "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^5.0.0", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", "nopt": "^9.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" } }, "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA=="],
|
||||
|
||||
"@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="],
|
||||
|
||||
"@npmcli/git": ["@npmcli/git@7.0.2", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", "semver": "^7.3.5", "which": "^6.0.0" } }, "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg=="],
|
||||
|
||||
"@npmcli/installed-package-contents": ["@npmcli/installed-package-contents@4.0.0", "", { "dependencies": { "npm-bundled": "^5.0.0", "npm-normalize-package-bin": "^5.0.0" }, "bin": { "installed-package-contents": "bin/index.js" } }, "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA=="],
|
||||
|
||||
"@npmcli/map-workspaces": ["@npmcli/map-workspaces@5.0.3", "", { "dependencies": { "@npmcli/name-from-folder": "^4.0.0", "@npmcli/package-json": "^7.0.0", "glob": "^13.0.0", "minimatch": "^10.0.3" } }, "sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw=="],
|
||||
|
||||
"@npmcli/metavuln-calculator": ["@npmcli/metavuln-calculator@9.0.3", "", { "dependencies": { "cacache": "^20.0.0", "json-parse-even-better-errors": "^5.0.0", "pacote": "^21.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5" } }, "sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg=="],
|
||||
|
||||
"@npmcli/name-from-folder": ["@npmcli/name-from-folder@4.0.0", "", {}, "sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg=="],
|
||||
|
||||
"@npmcli/node-gyp": ["@npmcli/node-gyp@5.0.0", "", {}, "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ=="],
|
||||
|
||||
"@npmcli/package-json": ["@npmcli/package-json@7.0.5", "", { "dependencies": { "@npmcli/git": "^7.0.0", "glob": "^13.0.0", "hosted-git-info": "^9.0.0", "json-parse-even-better-errors": "^5.0.0", "proc-log": "^6.0.0", "semver": "^7.5.3", "spdx-expression-parse": "^4.0.0" } }, "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ=="],
|
||||
|
||||
"@npmcli/promise-spawn": ["@npmcli/promise-spawn@9.0.1", "", { "dependencies": { "which": "^6.0.0" } }, "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q=="],
|
||||
|
||||
"@npmcli/query": ["@npmcli/query@5.0.0", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ=="],
|
||||
|
||||
"@npmcli/redact": ["@npmcli/redact@4.0.0", "", {}, "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q=="],
|
||||
|
||||
"@npmcli/run-script": ["@npmcli/run-script@10.0.4", "", { "dependencies": { "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "node-gyp": "^12.1.0", "proc-log": "^6.0.0" } }, "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg=="],
|
||||
|
||||
"@octokit/auth-app": ["@octokit/auth-app@8.0.1", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.1", "@octokit/auth-oauth-user": "^6.0.0", "@octokit/request": "^10.0.2", "@octokit/request-error": "^7.0.0", "@octokit/types": "^14.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-P2J5pB3pjiGwtJX4WqJVYCtNkcZ+j5T2Wm14aJAEIC3WJOrv12jvBley3G1U/XI8q9o1A7QMG54LiFED2BiFlg=="],
|
||||
|
||||
@@ -1446,21 +1481,21 @@
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.87", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.87", "@opentui/core-darwin-x64": "0.1.87", "@opentui/core-linux-arm64": "0.1.87", "@opentui/core-linux-x64": "0.1.87", "@opentui/core-win32-arm64": "0.1.87", "@opentui/core-win32-x64": "0.1.87", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dhsmMv0IqKftwG7J/pBrLBj2armsYIg5R3LBvciRQI/6X89GufP4l1u0+QTACAx6iR4SYJJNVNQ2tdX8LM9rMw=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.88", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.88", "@opentui/core-darwin-x64": "0.1.88", "@opentui/core-linux-arm64": "0.1.88", "@opentui/core-linux-x64": "0.1.88", "@opentui/core-win32-arm64": "0.1.88", "@opentui/core-win32-x64": "0.1.88", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-eaDVZfAzZraddOIkgWSHMVkyaY0O20foYnPWKPQx1TY4t7G1oatIoan2zkytx67epW+4BZQ9vGib+61/uNM1MA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.87", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G8oq85diOfkU6n0T1CxCle7oDmpKxwhcdhZ9khBMU5IrfLx9ZDuCM3F6MsiRQWdvPPCq2oomNbd64bYkPamYgw=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.88", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oGRexWwZFeQJymOK5ORrLrwJUbPHMYaFa0EcLnlhvPnymm1xyMcRKm39ez0WSIdtiCCi/PmMHX95CfyyJB5VMA=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.87", "", { "os": "darwin", "cpu": "x64" }, "sha512-MYTFQfOHm6qO7YaY4GHK9u/oJlXY6djaaxl5I+k4p2mk3vvuFIl/AP1ypITwBFjyV5gyp7PRWFp4nGfY9oN8bw=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.88", "", { "os": "darwin", "cpu": "x64" }, "sha512-ddnruYpXt7gXsAqZoQzNrHtZ50niYQfESVT3rhE5qgsz7zoWBdKe/RxLKcb6zQmHMZML6SjSh0NrMG86lsH4dQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.87", "", { "os": "linux", "cpu": "arm64" }, "sha512-he8o1h5M6oskRJ7wE+xKJgmWnv5ZwN6gB3M/Z+SeHtOMPa5cZmi3TefTjG54llEgFfx0F9RcqHof7TJ/GNxRkw=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.88", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfcU/Sw8re3aWWb9cQ4OXmVNp/pchu6lgDRqvfy0EKTpzd7CNIu6a0xm+rcUKiPO7BrTrwtumT5/jZWWgCdHlg=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.87", "", { "os": "linux", "cpu": "x64" }, "sha512-aiUwjPlH4yDcB8/6YDKSmMkaoGAAltL0Xo0AzXyAtJXWK5tkCSaYjEVwzJ/rYRkr4Magnad+Mjth4AQUWdR2AA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.88", "", { "os": "linux", "cpu": "x64" }, "sha512-nyfilOYLu6XWRlPl1R0Y6WzdL+jVdIFnwShBWcZL+QC5HiJnQc6LKy5yX8uv0fVbY5xs1wBvlHVeUj1UwFQyFQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.87", "", { "os": "win32", "cpu": "arm64" }, "sha512-cmP0pOyREjWGniHqbDmaMY7U+1AyagrD8VseJbU0cGpNgVpG2/gbrJUGdfdLB0SNb+mzLdx6SOjdxtrElwRCQA=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.88", "", { "os": "win32", "cpu": "arm64" }, "sha512-jv/dQwcku7YZ4lNnYjivVvjPwTfDfzGfcplUqHxmirnv1Q1pZL1qS5wH1PV6RhAKN779vHTvnYMD4OgHWzqVaA=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.87", "", { "os": "win32", "cpu": "x64" }, "sha512-N2GErAAP8iODf2RPp86pilPaVKiD6G4pkpZL5nLGbKsl0bndrVTpSqZcn8+/nQwFZDPD/AsiRTYNOfWOblhzOw=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.88", "", { "os": "win32", "cpu": "x64" }, "sha512-saGvsQqwL8H7B0VBCQ+szMCKh9WIfTebOR8cwPa2+DR+1FnrEG2I4kiikoj4hfYfRMX18A0A11vQxSh3vvy8Ig=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.87", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.87", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-lRT9t30l8+FtgOjjWJcdb2MT6hP8/RKqwGgYwTI7fXrOqdhxxwdP2SM+rH2l3suHeASheiTdlvPAo230iUcsvg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.88", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.88", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-hAqMBk3u/MnUapOmRPdMZinXPOFC+5ccmW1rEQRf9HpShRlZfyg9/u+wUI5rUavyeNFtka92Mtjf/N4AKQpwuA=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -1736,6 +1771,18 @@
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@sigstore/bundle": ["@sigstore/bundle@4.0.0", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A=="],
|
||||
|
||||
"@sigstore/core": ["@sigstore/core@3.2.0", "", {}, "sha512-kxHrDQ9YgfrWUSXU0cjsQGv8JykOFZQ9ErNKbFPWzk3Hgpwu8x2hHrQ9IdA8yl+j9RTLTC3sAF3Tdq1IQCP4oA=="],
|
||||
|
||||
"@sigstore/protobuf-specs": ["@sigstore/protobuf-specs@0.5.0", "", {}, "sha512-MM8XIwUjN2bwvCg1QvrMtbBmpcSHrkhFSCu1D11NyPvDQ25HEc4oG5/OcQfd/Tlf/OxmKWERDj0zGE23jQaMwA=="],
|
||||
|
||||
"@sigstore/sign": ["@sigstore/sign@4.1.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.2", "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.0", "@sigstore/protobuf-specs": "^0.5.0", "make-fetch-happen": "^15.0.4", "proc-log": "^6.1.0" } }, "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ=="],
|
||||
|
||||
"@sigstore/tuf": ["@sigstore/tuf@4.0.2", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0", "tuf-js": "^4.1.0" } }, "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ=="],
|
||||
|
||||
"@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||
|
||||
"@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="],
|
||||
@@ -2036,6 +2083,10 @@
|
||||
|
||||
"@tsconfig/node22": ["@tsconfig/node22@22.0.2", "", {}, "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA=="],
|
||||
|
||||
"@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="],
|
||||
|
||||
"@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
@@ -2052,7 +2103,9 @@
|
||||
|
||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
|
||||
"@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="],
|
||||
|
||||
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
|
||||
|
||||
@@ -2118,6 +2171,18 @@
|
||||
|
||||
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
|
||||
|
||||
"@types/npm-package-arg": ["@types/npm-package-arg@6.1.4", "", {}, "sha512-vDgdbMy2QXHnAruzlv68pUtXCjmqUk3WrBAsRboRovsOmxbfn/WiYCjmecyKjGztnMps5dWp4Uq2prp+Ilo17Q=="],
|
||||
|
||||
"@types/npm-registry-fetch": ["@types/npm-registry-fetch@8.0.9", "", { "dependencies": { "@types/node": "*", "@types/node-fetch": "*", "@types/npm-package-arg": "*", "@types/npmlog": "*", "@types/ssri": "*" } }, "sha512-7NxvodR5Yrop3pb6+n8jhJNyzwOX0+6F+iagNEoi9u1CGxruYAwZD8pvGc9prIkL0+FdX5Xp0p80J9QPrGUp/g=="],
|
||||
|
||||
"@types/npmcli__arborist": ["@types/npmcli__arborist@6.3.3", "", { "dependencies": { "@npm/types": "^1", "@types/cacache": "*", "@types/node": "*", "@types/npmcli__package-json": "*", "@types/pacote": "*" } }, "sha512-kyrX932Qr+/Y4OB47Jamgc2YWa/HlXTCN0KVJsq04XDHUGkfbprJA8rd66zZXHmHmvnz1LR4X17zsE/H8Mklew=="],
|
||||
|
||||
"@types/npmcli__package-json": ["@types/npmcli__package-json@4.0.4", "", {}, "sha512-6QjlFUSHBmZJWuC08bz1ZCx6tm4t+7+OJXAdvM6tL2pI7n6Bh5SIp/YxQvnOLFf8MzCXs2ijyFgrzaiu1UFBGA=="],
|
||||
|
||||
"@types/npmlog": ["@types/npmlog@7.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-hJWbrKFvxKyWwSUXjZMYTINsSOY6IclhvGOZ97M8ac2tmR9hMwmTnYaMdpGhvju9ctWLTPhCS+eLfQNluiEjQQ=="],
|
||||
|
||||
"@types/pacote": ["@types/pacote@11.1.8", "", { "dependencies": { "@types/node": "*", "@types/npm-registry-fetch": "*", "@types/npmlog": "*", "@types/ssri": "*" } }, "sha512-/XLR0VoTh2JEO0jJg1q/e6Rh9bxjBq9vorJuQmtT7rRrXSiWz7e7NsvXVYJQ0i8JxMlBMPPYDTnrRe7MZRFA8Q=="],
|
||||
|
||||
"@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
|
||||
|
||||
"@types/promise.allsettled": ["@types/promise.allsettled@1.0.6", "", {}, "sha512-wA0UT0HeT2fGHzIFV9kWpYz5mdoyLxKrTgMdZQM++5h6pYAFH73HXcQhefg24nD1yivUFEn5KU+EF4b+CXJ4Wg=="],
|
||||
@@ -2146,6 +2211,8 @@
|
||||
|
||||
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
|
||||
|
||||
"@types/ssri": ["@types/ssri@7.1.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-odD/56S3B51liILSk5aXJlnYt99S6Rt9EFDDqGtJM26rKHApHcwyU/UoYHrzKkdkHMAIquGWCuHtQTbes+FRQw=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/tsscmp": ["@types/tsscmp@1.0.2", "", {}, "sha512-cy7BRSU8GYYgxjcx0Py+8lo5MthuDhlyu076KUcYzVNXL23luYgRHkMG2fIFEc6neckeh/ntP82mw+U4QjZq+g=="],
|
||||
@@ -2232,7 +2299,7 @@
|
||||
|
||||
"@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="],
|
||||
|
||||
"abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
|
||||
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
@@ -2396,6 +2463,8 @@
|
||||
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
|
||||
"bin-links": ["bin-links@6.0.0", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w=="],
|
||||
|
||||
"binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
@@ -2448,7 +2517,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
|
||||
|
||||
@@ -2468,7 +2537,7 @@
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="],
|
||||
"cacache": ["cacache@20.0.4", "", { "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0" } }, "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA=="],
|
||||
|
||||
"cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
|
||||
|
||||
@@ -2548,6 +2617,8 @@
|
||||
|
||||
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
||||
|
||||
"cmd-shim": ["cmd-shim@8.0.0", "", {}, "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="],
|
||||
|
||||
"collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
|
||||
|
||||
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
@@ -3128,7 +3199,7 @@
|
||||
|
||||
"hono-openapi": ["hono-openapi@1.1.2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="],
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
"hosted-git-info": ["hosted-git-info@9.0.2", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg=="],
|
||||
|
||||
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||
|
||||
@@ -3172,6 +3243,8 @@
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="],
|
||||
|
||||
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
|
||||
|
||||
"import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="],
|
||||
@@ -3344,6 +3417,8 @@
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@5.0.0", "", {}, "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ=="],
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
|
||||
@@ -3354,6 +3429,8 @@
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"json-stringify-nice": ["json-stringify-nice@1.1.4", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="],
|
||||
|
||||
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
||||
|
||||
"json-with-bigint": ["json-with-bigint@3.5.7", "", {}, "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw=="],
|
||||
@@ -3364,8 +3441,14 @@
|
||||
|
||||
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
"jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="],
|
||||
|
||||
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
|
||||
|
||||
"just-diff": ["just-diff@6.0.2", "", {}, "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA=="],
|
||||
|
||||
"just-diff-apply": ["just-diff-apply@5.5.0", "", {}, "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||
@@ -3478,7 +3561,7 @@
|
||||
|
||||
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
|
||||
|
||||
"make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="],
|
||||
"make-fetch-happen": ["make-fetch-happen@15.0.5", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg=="],
|
||||
|
||||
"markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="],
|
||||
|
||||
@@ -3644,13 +3727,13 @@
|
||||
|
||||
"minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="],
|
||||
|
||||
"minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="],
|
||||
"minipass-fetch": ["minipass-fetch@5.0.2", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "optionalDependencies": { "iconv-lite": "^0.7.2" } }, "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ=="],
|
||||
|
||||
"minipass-flush": ["minipass-flush@1.0.5", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw=="],
|
||||
|
||||
"minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="],
|
||||
|
||||
"minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="],
|
||||
"minipass-sized": ["minipass-sized@2.0.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA=="],
|
||||
|
||||
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
||||
|
||||
@@ -3718,7 +3801,7 @@
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="],
|
||||
"node-gyp": ["node-gyp@12.2.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^15.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -3730,12 +3813,26 @@
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
|
||||
"nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
|
||||
"nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
|
||||
|
||||
"npm-bundled": ["npm-bundled@5.0.0", "", { "dependencies": { "npm-normalize-package-bin": "^5.0.0" } }, "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw=="],
|
||||
|
||||
"npm-install-checks": ["npm-install-checks@8.0.0", "", { "dependencies": { "semver": "^7.1.1" } }, "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA=="],
|
||||
|
||||
"npm-normalize-package-bin": ["npm-normalize-package-bin@5.0.0", "", {}, "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag=="],
|
||||
|
||||
"npm-package-arg": ["npm-package-arg@13.0.2", "", { "dependencies": { "hosted-git-info": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^7.0.0" } }, "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA=="],
|
||||
|
||||
"npm-packlist": ["npm-packlist@10.0.4", "", { "dependencies": { "ignore-walk": "^8.0.0", "proc-log": "^6.0.0" } }, "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng=="],
|
||||
|
||||
"npm-pick-manifest": ["npm-pick-manifest@11.0.3", "", { "dependencies": { "npm-install-checks": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "npm-package-arg": "^13.0.0", "semver": "^7.3.5" } }, "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ=="],
|
||||
|
||||
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
@@ -3822,6 +3919,8 @@
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||
|
||||
"pacote": ["pacote@21.5.0", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-VtZ0SB8mb5Tzw3dXDfVAIjhyVKUHZkS/ZH9/5mpKenwC9sFOXNI0JI7kEF7IMkwOnsWMFrvAZHzx1T5fmrp9FQ=="],
|
||||
|
||||
"pagefind": ["pagefind@1.4.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.4.0", "@pagefind/darwin-x64": "1.4.0", "@pagefind/freebsd-x64": "1.4.0", "@pagefind/linux-arm64": "1.4.0", "@pagefind/linux-x64": "1.4.0", "@pagefind/windows-x64": "1.4.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g=="],
|
||||
|
||||
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||
@@ -3834,6 +3933,8 @@
|
||||
|
||||
"parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="],
|
||||
|
||||
"parse-conflict-json": ["parse-conflict-json@5.0.1", "", { "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="],
|
||||
@@ -3948,7 +4049,7 @@
|
||||
|
||||
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
||||
|
||||
"proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="],
|
||||
"proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="],
|
||||
|
||||
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
|
||||
|
||||
@@ -3956,8 +4057,14 @@
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"proggy": ["proggy@4.0.0", "", {}, "sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ=="],
|
||||
|
||||
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
|
||||
|
||||
"promise-all-reject-late": ["promise-all-reject-late@1.0.1", "", {}, "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw=="],
|
||||
|
||||
"promise-call-limit": ["promise-call-limit@3.0.2", "", {}, "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw=="],
|
||||
|
||||
"promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="],
|
||||
|
||||
"promise.allsettled": ["promise.allsettled@1.0.7", "", { "dependencies": { "array.prototype.map": "^1.0.5", "call-bind": "^1.0.2", "define-properties": "^1.2.0", "es-abstract": "^1.22.1", "get-intrinsic": "^1.2.1", "iterate-value": "^1.0.2" } }, "sha512-hezvKvQQmsFkOdrZfYxUxkyxl8mgFQeT259Ajj9PXdbg9VzBCWrItOev72JyWxkCD5VSSqAeHmlN3tWx4DlmsA=="],
|
||||
@@ -4024,6 +4131,8 @@
|
||||
|
||||
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||
|
||||
"read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="],
|
||||
|
||||
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
|
||||
|
||||
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
|
||||
@@ -4226,6 +4335,8 @@
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"sigstore": ["sigstore@4.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0", "@sigstore/sign": "^4.1.0", "@sigstore/tuf": "^4.0.1", "@sigstore/verify": "^3.1.0" } }, "sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA=="],
|
||||
|
||||
"simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
|
||||
|
||||
"simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
|
||||
@@ -4276,6 +4387,12 @@
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="],
|
||||
|
||||
"spdx-expression-parse": ["spdx-expression-parse@4.0.0", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="],
|
||||
|
||||
"spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
|
||||
@@ -4284,7 +4401,7 @@
|
||||
|
||||
"srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="],
|
||||
|
||||
"ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="],
|
||||
"ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="],
|
||||
|
||||
"sst": ["sst@3.18.10", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.18.10", "sst-darwin-x64": "3.18.10", "sst-linux-arm64": "3.18.10", "sst-linux-x64": "3.18.10", "sst-linux-x86": "3.18.10", "sst-win32-arm64": "3.18.10", "sst-win32-x64": "3.18.10", "sst-win32-x86": "3.18.10" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-SY+ldeJ9K5E9q+DhjXA3e2W3BEOzBwkE3IyLSD71uA3/5nRhUAST31iOWEpW36LbIvSQ9uOVDFcebztoLJ8s7w=="],
|
||||
|
||||
@@ -4460,6 +4577,8 @@
|
||||
|
||||
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
|
||||
|
||||
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
@@ -4478,6 +4597,8 @@
|
||||
|
||||
"tsscmp": ["tsscmp@1.0.6", "", {}, "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA=="],
|
||||
|
||||
"tuf-js": ["tuf-js@4.1.0", "", { "dependencies": { "@tufjs/models": "4.1.0", "debug": "^4.4.3", "make-fetch-happen": "^15.0.1" } }, "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ=="],
|
||||
|
||||
"tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="],
|
||||
|
||||
"turbo": ["turbo@2.8.13", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.13", "turbo-darwin-arm64": "2.8.13", "turbo-linux-64": "2.8.13", "turbo-linux-arm64": "2.8.13", "turbo-windows-64": "2.8.13", "turbo-windows-arm64": "2.8.13" }, "bin": { "turbo": "bin/turbo" } }, "sha512-nyM99hwFB9/DHaFyKEqatdayGjsMNYsQ/XBNO6MITc7roncZetKb97MpHxWf3uiU+LB9c9HUlU3Jp2Ixei2k1A=="],
|
||||
@@ -4604,6 +4725,8 @@
|
||||
|
||||
"uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
|
||||
|
||||
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
|
||||
@@ -4662,6 +4785,8 @@
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
|
||||
"walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
|
||||
|
||||
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
@@ -4706,6 +4831,8 @@
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
@@ -5050,6 +5177,8 @@
|
||||
|
||||
"@electron/rebuild/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"@electron/rebuild/node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="],
|
||||
|
||||
"@electron/rebuild/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"@electron/universal/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
|
||||
@@ -5122,7 +5251,13 @@
|
||||
|
||||
"@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
"@npmcli/arborist/common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="],
|
||||
|
||||
"@npmcli/arborist/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"@npmcli/map-workspaces/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.8", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw=="],
|
||||
|
||||
@@ -5198,8 +5333,6 @@
|
||||
|
||||
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||
|
||||
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
|
||||
|
||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||
|
||||
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
@@ -5290,6 +5423,8 @@
|
||||
|
||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"@tufjs/models/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
|
||||
"@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||
@@ -5322,6 +5457,8 @@
|
||||
|
||||
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||
|
||||
"app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
"app-builder-lib/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
@@ -5366,10 +5503,6 @@
|
||||
|
||||
"c12/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||
|
||||
"cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"cli-truncate/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
|
||||
@@ -5468,8 +5601,6 @@
|
||||
|
||||
"happy-dom/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||
|
||||
"hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"html-minifier-terser/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
@@ -5478,8 +5609,12 @@
|
||||
|
||||
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
||||
|
||||
"ignore-walk/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
|
||||
|
||||
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
@@ -5510,18 +5645,12 @@
|
||||
|
||||
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
|
||||
"minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
|
||||
"motion/framer-motion": ["framer-motion@12.35.2", "", { "dependencies": { "motion-dom": "^12.35.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA=="],
|
||||
|
||||
"mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
||||
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
|
||||
|
||||
"node-gyp/nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
|
||||
|
||||
"node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
|
||||
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
@@ -5960,6 +6089,14 @@
|
||||
|
||||
"@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
|
||||
"@electron/rebuild/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"@electron/rebuild/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
@@ -6206,6 +6343,8 @@
|
||||
|
||||
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
@@ -6232,12 +6371,6 @@
|
||||
|
||||
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"cacache/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"cacache/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
"cacache/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"cli-truncate/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"cli-truncate/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
@@ -6278,6 +6411,8 @@
|
||||
|
||||
"js-beautify/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"js-beautify/nopt/abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
|
||||
|
||||
"lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
@@ -6289,7 +6424,6 @@
|
||||
"node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
@@ -6460,6 +6594,20 @@
|
||||
|
||||
"@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/nopt/abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"@electron/rebuild/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@electron/rebuild/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
@@ -6576,10 +6724,6 @@
|
||||
|
||||
"babel-plugin-module-resolver/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"cacache/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
@@ -6684,6 +6828,16 @@
|
||||
|
||||
"@aws-sdk/token-providers/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/cacache/@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/minipass-fetch/minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="],
|
||||
|
||||
"@electron/rebuild/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"@electron/rebuild/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
@@ -6702,12 +6856,6 @@
|
||||
|
||||
"babel-plugin-module-resolver/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"cacache/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"cacache/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"cacache/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"electron-builder/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"electron-builder/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
@@ -6732,16 +6880,34 @@
|
||||
|
||||
"@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/minipass-fetch/minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
|
||||
"archiver-utils/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"archiver-utils/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"cacache/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"cacache/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"js-beautify/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"js-beautify/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-P0RJfQF8APTYVGP6hLJRrOkRSl5nVDNxdcGcZECPPJE=",
|
||||
"aarch64-linux": "sha256-ZtMjTcd35X3JhJIdn3DilFsp7i/IZIcNaKZFnSzW/nk=",
|
||||
"aarch64-darwin": "sha256-Uw/okFDRxxKQMfEsj8MXuHyhpugxZGgIKtu89Getlz8=",
|
||||
"x86_64-darwin": "sha256-ZySIgT1HbWZWnaQ0W0eURKC43BTupRmmply92JDFPWA="
|
||||
"x86_64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
|
||||
"aarch64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
|
||||
"aarch64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V",
|
||||
"x86_64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.10",
|
||||
"packageManager": "bun@1.3.11",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||
@@ -26,7 +26,7 @@
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/platform-node": "4.0.0-beta.35",
|
||||
"@types/bun": "1.3.9",
|
||||
"@types/bun": "1.3.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
|
||||
|
||||
function user(id: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
sessionID: "session-1",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
function assistant(id: string, parentID: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
sessionID: "session-1",
|
||||
parentID,
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("findAssistantMessages", () => {
|
||||
test("normal ordering: assistant after user in array → found via forward scan", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("clock skew: assistant before user in array → found via backward scan", () => {
|
||||
// When client clock is ahead, user ID sorts after assistant ID,
|
||||
// so assistant appears earlier in the ID-sorted message array
|
||||
const messages = [assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 1, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("no assistant messages → returns empty array", () => {
|
||||
const messages = [user("u1"), user("u2")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("multiple assistant messages with matching parentID → all found", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe("a1")
|
||||
expect(result[1].id).toBe("a2")
|
||||
})
|
||||
|
||||
test("does not return assistant messages with different parentID", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops forward scan at next user message", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops backward scan at previous user message", () => {
|
||||
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 3, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("invalid index returns empty array", () => {
|
||||
const messages = [user("u1")]
|
||||
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
|
||||
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -287,6 +287,9 @@ export function Titlebar() {
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
|
||||
BETA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -25,6 +26,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
sending: false,
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
@@ -35,6 +37,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
@@ -43,6 +46,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
|
||||
const fold = () => setStore("collapsed", (value) => !value)
|
||||
|
||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||
const prev = input().trim()
|
||||
const next = value.trim()
|
||||
@@ -257,9 +262,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
header={
|
||||
<>
|
||||
<div
|
||||
data-action="session-question-toggle"
|
||||
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
|
||||
onClick={fold}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
fold()
|
||||
}}
|
||||
>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<div data-slot="question-progress">
|
||||
<div data-slot="question-progress" class="ml-auto mr-1">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
@@ -271,13 +288,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||
}
|
||||
disabled={store.sending}
|
||||
onClick={() => jump(i())}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
jump(i())
|
||||
}}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<IconButton
|
||||
data-action="session-question-toggle-button"
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
classList={{ "rotate-180": store.collapsed }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
fold()
|
||||
}}
|
||||
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
@@ -297,56 +339,121 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div data-slot="question-text">{question()?.question}</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
<div
|
||||
data-slot="question-text"
|
||||
class="cursor-default"
|
||||
classList={{
|
||||
"mb-6": store.collapsed && picked() === 0,
|
||||
}}
|
||||
role={store.collapsed ? "button" : undefined}
|
||||
tabIndex={store.collapsed ? 0 : undefined}
|
||||
onClick={fold}
|
||||
onKeyDown={(event) => {
|
||||
if (!store.collapsed) return
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
fold()
|
||||
}}
|
||||
>
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={store.collapsed && picked() > 0}>
|
||||
<div data-slot="question-hint" class="cursor-default mb-6">
|
||||
{picked()} answer{picked() === 1 ? "" : "s"} selected
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(i())}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
aria-checked={on()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(i())}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={store.sending}
|
||||
onClick={customOpen}
|
||||
onMouseDown={(e) => {
|
||||
if (store.sending) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
@@ -365,80 +472,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (store.sending) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={store.sending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={store.sending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
)
|
||||
|
||||
@@ -24,7 +24,13 @@ import {
|
||||
FreeUsageLimitError,
|
||||
SubscriptionUsageLimitError,
|
||||
} from "./error"
|
||||
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
|
||||
import {
|
||||
buildCostChunk,
|
||||
createBodyConverter,
|
||||
createStreamPartConverter,
|
||||
createResponseConverter,
|
||||
UsageInfo,
|
||||
} from "./provider/provider"
|
||||
import { anthropicHelper } from "./provider/anthropic"
|
||||
import { googleHelper } from "./provider/google"
|
||||
import { openaiHelper } from "./provider/openai"
|
||||
@@ -90,7 +96,7 @@ export async function handler(
|
||||
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
||||
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
|
||||
logger.metric({
|
||||
is_tream: isStream,
|
||||
is_stream: isStream,
|
||||
session: sessionId,
|
||||
request: requestId,
|
||||
client: ocClient,
|
||||
@@ -230,7 +236,7 @@ export async function handler(
|
||||
const body = JSON.stringify(
|
||||
responseConverter({
|
||||
...json,
|
||||
cost: calculateOccuredCost(billingSource, costInfo),
|
||||
cost: calculateOccurredCost(billingSource, costInfo),
|
||||
}),
|
||||
)
|
||||
logger.metric({ response_length: body.length })
|
||||
@@ -274,8 +280,8 @@ export async function handler(
|
||||
await trialLimiter?.track(usageInfo)
|
||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||
await reload(billingSource, authInfo, costInfo)
|
||||
const cost = calculateOccuredCost(billingSource, costInfo)
|
||||
c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost)))
|
||||
const cost = calculateOccurredCost(billingSource, costInfo)
|
||||
c.enqueue(encoder.encode(buildCostChunk(opts.format, cost)))
|
||||
}
|
||||
c.close()
|
||||
return
|
||||
@@ -818,7 +824,7 @@ export async function handler(
|
||||
}
|
||||
}
|
||||
|
||||
function calculateOccuredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
||||
function calculateOccurredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
||||
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
||||
}
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => ({
|
||||
|
||||
@@ -56,7 +56,6 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
|
||||
usage = json.usageMetadata
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
|
||||
@@ -54,7 +54,6 @@ export const oaCompatHelper: ProviderHelper = () => ({
|
||||
usage = json.usage
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ choices: [], cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
|
||||
@@ -44,7 +44,6 @@ export const openaiHelper: ProviderHelper = () => ({
|
||||
usage = json.response.usage
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
|
||||
@@ -43,7 +43,6 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
|
||||
createUsageParser: () => {
|
||||
parse: (chunk: string) => void
|
||||
retrieve: () => any
|
||||
buidlCostChunk: (cost: string) => string
|
||||
}
|
||||
normalizeUsage: (usage: any) => UsageInfo
|
||||
}
|
||||
@@ -162,6 +161,19 @@ export interface CommonChunk {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCostChunk(format: ZenData.Format, cost: string): string {
|
||||
switch (format) {
|
||||
case "anthropic":
|
||||
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||
case "openai":
|
||||
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||
case "oa-compat":
|
||||
return `data: ${JSON.stringify({ choices: [], cost })}\n\n`
|
||||
default:
|
||||
return `data: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||
}
|
||||
}
|
||||
|
||||
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
|
||||
return (body: any): any => {
|
||||
if (from === to) return body
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.0",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"mysql2": "3.14.4",
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
|
||||
SHELL ["/bin/bash", "-lc"]
|
||||
|
||||
ARG NODE_VERSION=24.4.0
|
||||
ARG BUN_VERSION=1.3.5
|
||||
ARG BUN_VERSION=1.3.11
|
||||
|
||||
ENV BUN_INSTALL=/opt/bun
|
||||
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
@@ -89,12 +90,11 @@
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"gitlab-ai-provider": "5.2.2",
|
||||
"opencode-gitlab-auth": "2.0.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
@@ -103,8 +103,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.87",
|
||||
"@opentui/solid": "0.1.87",
|
||||
"@opentui/core": "0.1.88",
|
||||
"@opentui/solid": "0.1.88",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -123,6 +123,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "5.2.2",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -133,6 +134,7 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.0",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
|
||||
@@ -199,6 +199,19 @@ for (const item of targets) {
|
||||
},
|
||||
})
|
||||
|
||||
// Smoke test: only run if binary is for current platform
|
||||
if (item.os === process.platform && item.arch === process.arch && !item.abi) {
|
||||
const binaryPath = `dist/${name}/bin/opencode`
|
||||
console.log(`Running smoke test: ${binaryPath} --version`)
|
||||
try {
|
||||
const versionOutput = await $`${binaryPath} --version`.text()
|
||||
console.log(`Smoke test passed: ${versionOutput.trim()}`)
|
||||
} catch (e) {
|
||||
console.error(`Smoke test failed for ${name}:`, e)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
await $`rm -rf ./dist/${name}/bin/tui`
|
||||
await Bun.file(`dist/${name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
|
||||
@@ -11,7 +11,6 @@ const seed = async () => {
|
||||
const { Instance } = await import("../src/project/instance")
|
||||
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
||||
const { Config } = await import("../src/config/config")
|
||||
const { disposeRuntime } = await import("../src/effect/runtime")
|
||||
const { Session } = await import("../src/session")
|
||||
const { MessageID, PartID } = await import("../src/session/schema")
|
||||
const { Project } = await import("../src/project/project")
|
||||
@@ -55,7 +54,6 @@ const seed = async () => {
|
||||
})
|
||||
} finally {
|
||||
await Instance.disposeAll().catch(() => {})
|
||||
await disposeRuntime().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,18 +4,18 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
|
||||
|
||||
## Choose scope
|
||||
|
||||
Use the shared runtime for process-wide services with one lifecycle for the whole app.
|
||||
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
|
||||
|
||||
Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
|
||||
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
|
||||
|
||||
- Shared runtime: config readers, stateless helpers, global clients
|
||||
- Instance-scoped: watchers, per-project caches, session state, project-bound background work
|
||||
- Global services (no per-directory state): Account, Auth, Installation, Truncate
|
||||
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
|
||||
|
||||
Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
|
||||
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||
|
||||
## Service shape
|
||||
|
||||
For a fully migrated module, use the public namespace directly:
|
||||
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
|
||||
|
||||
```ts
|
||||
export namespace Foo {
|
||||
@@ -28,53 +28,52 @@ export namespace Foo {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
return Service.of({
|
||||
get: Effect.fn("Foo.get")(function* (id) {
|
||||
return yield* ...
|
||||
}),
|
||||
// For instance-scoped services:
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Foo.get")(function* (id: FooID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
// ...
|
||||
})
|
||||
|
||||
return Service.of({ get })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
|
||||
// Optional: wire dependencies
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
||||
|
||||
// Per-service runtime (inside the namespace)
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
// Async facade functions
|
||||
export async function get(id: FooID) {
|
||||
return runPromise((svc) => svc.get(id))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
|
||||
- Export `defaultLayer` only when wiring dependencies is useful
|
||||
- Use the direct namespace form once the module is fully migrated
|
||||
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
|
||||
- `runPromise` goes inside the namespace (not exported unless tests need it)
|
||||
- Facade functions are plain `async function` — no `fn()` wrappers
|
||||
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
|
||||
- No `Layer.fresh` — InstanceState handles per-directory isolation
|
||||
|
||||
## Temporary mixed-mode pattern
|
||||
## Schema → Zod interop
|
||||
|
||||
Prefer a single namespace whenever possible.
|
||||
|
||||
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
|
||||
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
|
||||
|
||||
```ts
|
||||
export namespace FooEffect {
|
||||
export interface Interface {
|
||||
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
|
||||
}
|
||||
import { zod } from "@/util/effect-zod"
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
|
||||
export const layer = Layer.effect(...)
|
||||
}
|
||||
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
|
||||
```
|
||||
|
||||
Then keep the old boundary thin:
|
||||
|
||||
```ts
|
||||
export namespace Foo {
|
||||
export function get(id: FooID) {
|
||||
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Remove the `Effect` suffix when the boundary split is gone.
|
||||
See `Auth.ZodInfo` for the canonical example.
|
||||
|
||||
## Scheduled Tasks
|
||||
|
||||
@@ -107,23 +106,23 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
|
||||
|
||||
## Migration checklist
|
||||
|
||||
Done now:
|
||||
Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||
|
||||
- [x] `AccountEffect` (mixed-mode)
|
||||
- [x] `AuthEffect` (mixed-mode)
|
||||
- [x] `TruncateEffect` (mixed-mode)
|
||||
- [x] `Question`
|
||||
- [x] `PermissionNext`
|
||||
- [x] `ProviderAuth`
|
||||
- [x] `FileWatcher`
|
||||
- [x] `FileTime`
|
||||
- [x] `Format`
|
||||
- [x] `Vcs`
|
||||
- [x] `Skill`
|
||||
- [x] `Discovery`
|
||||
- [x] `File`
|
||||
- [x] `Snapshot`
|
||||
- [x] `SessionStatus`
|
||||
- [x] `Account` — `account/index.ts`
|
||||
- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
|
||||
- [x] `File` — `file/index.ts`
|
||||
- [x] `FileTime` — `file/time.ts`
|
||||
- [x] `FileWatcher` — `file/watcher.ts`
|
||||
- [x] `Format` — `format/index.ts`
|
||||
- [x] `Installation` — `installation/index.ts`
|
||||
- [x] `Permission` — `permission/index.ts`
|
||||
- [x] `ProviderAuth` — `provider/auth.ts`
|
||||
- [x] `Question` — `question/index.ts`
|
||||
- [x] `Skill` — `skill/index.ts`
|
||||
- [x] `Snapshot` — `snapshot/index.ts`
|
||||
- [x] `Truncate` — `tool/truncate.ts`
|
||||
- [x] `Vcs` — `project/vcs.ts`
|
||||
- [x] `Discovery` — `skill/discovery.ts`
|
||||
|
||||
Still open and likely worth migrating:
|
||||
|
||||
@@ -131,7 +130,6 @@ Still open and likely worth migrating:
|
||||
- [ ] `ToolRegistry`
|
||||
- [ ] `Pty`
|
||||
- [ ] `Worktree`
|
||||
- [ ] `Installation`
|
||||
- [ ] `Bus`
|
||||
- [ ] `Command`
|
||||
- [ ] `Config`
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { AccountRepo, type AccountRow } from "./repo"
|
||||
import {
|
||||
type AccountError,
|
||||
AccessToken,
|
||||
AccountID,
|
||||
DeviceCode,
|
||||
Info,
|
||||
RefreshToken,
|
||||
AccountServiceError,
|
||||
Login,
|
||||
Org,
|
||||
OrgID,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollExpired,
|
||||
PollPending,
|
||||
type PollResult,
|
||||
PollSlow,
|
||||
PollSuccess,
|
||||
UserCode,
|
||||
} from "./schema"
|
||||
|
||||
export {
|
||||
AccountID,
|
||||
type AccountError,
|
||||
AccountRepoError,
|
||||
AccountServiceError,
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
DeviceCode,
|
||||
UserCode,
|
||||
Info,
|
||||
Org,
|
||||
OrgID,
|
||||
Login,
|
||||
PollSuccess,
|
||||
PollPending,
|
||||
PollSlow,
|
||||
PollExpired,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollResult,
|
||||
} from "./schema"
|
||||
|
||||
export type AccountOrgs = {
|
||||
account: Info
|
||||
orgs: readonly Org[]
|
||||
}
|
||||
|
||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
config: Schema.Record(Schema.String, Schema.Json),
|
||||
}) {}
|
||||
|
||||
const DurationFromSeconds = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Duration, {
|
||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||
}),
|
||||
)
|
||||
|
||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
device_code: DeviceCode,
|
||||
user_code: UserCode,
|
||||
verification_uri_complete: Schema.String,
|
||||
expires_in: DurationFromSeconds,
|
||||
interval: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
token_type: Schema.Literal("Bearer"),
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
error: Schema.String,
|
||||
error_description: Schema.String,
|
||||
}) {
|
||||
toPollResult(): PollResult {
|
||||
if (this.error === "authorization_pending") return new PollPending()
|
||||
if (this.error === "slow_down") return new PollSlow()
|
||||
if (this.error === "expired_token") return new PollExpired()
|
||||
if (this.error === "access_denied") return new PollDenied()
|
||||
return new PollError({ cause: this.error })
|
||||
}
|
||||
}
|
||||
|
||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
|
||||
class User extends Schema.Class<User>("User")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
|
||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
grant_type: Schema.String,
|
||||
device_code: DeviceCode,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
grant_type: Schema.String,
|
||||
refresh_token: RefreshToken,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
const clientId = "opencode-cli"
|
||||
|
||||
const mapAccountServiceError =
|
||||
(message = "Account service operation failed") =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
|
||||
effect.pipe(
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
|
||||
),
|
||||
)
|
||||
|
||||
export namespace Account {
|
||||
export interface Interface {
|
||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
|
||||
readonly list: () => Effect.Effect<Info[], AccountError>
|
||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
||||
readonly config: (
|
||||
accountID: AccountID,
|
||||
orgID: OrgID,
|
||||
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
||||
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const repo = yield* AccountRepo
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const httpRead = withTransientReadRetry(http)
|
||||
const httpOk = HttpClient.filterStatusOk(http)
|
||||
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
||||
|
||||
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
|
||||
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
|
||||
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => httpOk.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => http.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
||||
new TokenRefreshRequest({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: row.refresh_token,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
|
||||
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
||||
|
||||
yield* repo.persistToken({
|
||||
accountID: row.id,
|
||||
accessToken: parsed.access_token,
|
||||
refreshToken: parsed.refresh_token,
|
||||
expiry,
|
||||
})
|
||||
|
||||
return parsed.access_token
|
||||
})
|
||||
|
||||
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) return Option.none()
|
||||
|
||||
const account = maybeAccount.value
|
||||
const accessToken = yield* resolveToken(account)
|
||||
return Option.some({ account, accessToken })
|
||||
})
|
||||
|
||||
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/user`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||
)
|
||||
|
||||
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
||||
const accounts = yield* repo.list()
|
||||
const [errors, results] = yield* Effect.partition(
|
||||
accounts,
|
||||
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
for (const error of errors) {
|
||||
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
|
||||
Effect.annotateLogs({ error: String(error) }),
|
||||
)
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return []
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
return yield* fetchOrgs(account.url, accessToken)
|
||||
})
|
||||
|
||||
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return Option.none()
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
const response = yield* executeRead(
|
||||
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
||||
),
|
||||
)
|
||||
|
||||
if (response.status === 404) return Option.none()
|
||||
|
||||
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return Option.some(parsed.config)
|
||||
})
|
||||
|
||||
const login = Effect.fn("Account.login")(function* (server: string) {
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return new Login({
|
||||
code: parsed.device_code,
|
||||
user: parsed.user_code,
|
||||
url: `${server}${parsed.verification_uri_complete}`,
|
||||
server,
|
||||
expiry: parsed.expires_in,
|
||||
interval: parsed.interval,
|
||||
})
|
||||
})
|
||||
|
||||
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
||||
const response = yield* executeEffect(
|
||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
||||
new DeviceTokenRequest({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: input.code,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
|
||||
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
||||
const accessToken = parsed.access_token
|
||||
|
||||
const user = fetchUser(input.server, accessToken)
|
||||
const orgs = fetchOrgs(input.server, accessToken)
|
||||
|
||||
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
||||
|
||||
// TODO: When there are multiple orgs, let the user choose
|
||||
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
||||
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const expiry = now + Duration.toMillis(parsed.expires_in)
|
||||
const refreshToken = parsed.refresh_token
|
||||
|
||||
yield* repo.persistAccount({
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
url: input.server,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiry,
|
||||
orgID: firstOrgID,
|
||||
})
|
||||
|
||||
return new PollSuccess({ email: account.email })
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
active: repo.active,
|
||||
list: repo.list,
|
||||
orgsByAccount,
|
||||
remove: repo.remove,
|
||||
use: repo.use,
|
||||
orgs,
|
||||
config,
|
||||
token,
|
||||
login,
|
||||
poll,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||
}
|
||||
@@ -1,34 +1,397 @@
|
||||
import { Effect, Option } from "effect"
|
||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { AccountRepo, type AccountRow } from "./repo"
|
||||
import {
|
||||
type AccountError,
|
||||
AccessToken,
|
||||
AccountID,
|
||||
DeviceCode,
|
||||
Info,
|
||||
RefreshToken,
|
||||
AccountServiceError,
|
||||
Login,
|
||||
Org,
|
||||
OrgID,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollExpired,
|
||||
PollPending,
|
||||
type PollResult,
|
||||
PollSlow,
|
||||
PollSuccess,
|
||||
UserCode,
|
||||
} from "./schema"
|
||||
|
||||
export { AccessToken, AccountID, OrgID } from "./effect"
|
||||
export {
|
||||
AccountID,
|
||||
type AccountError,
|
||||
AccountRepoError,
|
||||
AccountServiceError,
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
DeviceCode,
|
||||
UserCode,
|
||||
Info,
|
||||
Org,
|
||||
OrgID,
|
||||
Login,
|
||||
PollSuccess,
|
||||
PollPending,
|
||||
PollSlow,
|
||||
PollExpired,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollResult,
|
||||
} from "./schema"
|
||||
|
||||
import { runtime } from "@/effect/runtime"
|
||||
|
||||
function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runSync(S.Service.use(f))
|
||||
export type AccountOrgs = {
|
||||
account: Info
|
||||
orgs: readonly Org[]
|
||||
}
|
||||
|
||||
function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runPromise(S.Service.use(f))
|
||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
config: Schema.Record(Schema.String, Schema.Json),
|
||||
}) {}
|
||||
|
||||
const DurationFromSeconds = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Duration, {
|
||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||
}),
|
||||
)
|
||||
|
||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
device_code: DeviceCode,
|
||||
user_code: UserCode,
|
||||
verification_uri_complete: Schema.String,
|
||||
expires_in: DurationFromSeconds,
|
||||
interval: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
token_type: Schema.Literal("Bearer"),
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
error: Schema.String,
|
||||
error_description: Schema.String,
|
||||
}) {
|
||||
toPollResult(): PollResult {
|
||||
if (this.error === "authorization_pending") return new PollPending()
|
||||
if (this.error === "slow_down") return new PollSlow()
|
||||
if (this.error === "expired_token") return new PollExpired()
|
||||
if (this.error === "access_denied") return new PollDenied()
|
||||
return new PollError({ cause: this.error })
|
||||
}
|
||||
}
|
||||
|
||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
|
||||
class User extends Schema.Class<User>("User")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
|
||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
grant_type: Schema.String,
|
||||
device_code: DeviceCode,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
grant_type: Schema.String,
|
||||
refresh_token: RefreshToken,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
const clientId = "opencode-cli"
|
||||
|
||||
const mapAccountServiceError =
|
||||
(message = "Account service operation failed") =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
|
||||
effect.pipe(
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
|
||||
),
|
||||
)
|
||||
|
||||
export namespace Account {
|
||||
export const Info = Model
|
||||
export type Info = Model
|
||||
export interface Interface {
|
||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
|
||||
readonly list: () => Effect.Effect<Info[], AccountError>
|
||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
||||
readonly config: (
|
||||
accountID: AccountID,
|
||||
orgID: OrgID,
|
||||
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
||||
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
|
||||
export function active(): Info | undefined {
|
||||
return Option.getOrUndefined(runSync((service) => service.active()))
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const repo = yield* AccountRepo
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const httpRead = withTransientReadRetry(http)
|
||||
const httpOk = HttpClient.filterStatusOk(http)
|
||||
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
||||
|
||||
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
|
||||
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
|
||||
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => httpOk.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => http.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
||||
new TokenRefreshRequest({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: row.refresh_token,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
|
||||
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
||||
|
||||
yield* repo.persistToken({
|
||||
accountID: row.id,
|
||||
accessToken: parsed.access_token,
|
||||
refreshToken: parsed.refresh_token,
|
||||
expiry,
|
||||
})
|
||||
|
||||
return parsed.access_token
|
||||
})
|
||||
|
||||
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) return Option.none()
|
||||
|
||||
const account = maybeAccount.value
|
||||
const accessToken = yield* resolveToken(account)
|
||||
return Option.some({ account, accessToken })
|
||||
})
|
||||
|
||||
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/user`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||
)
|
||||
|
||||
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
||||
const accounts = yield* repo.list()
|
||||
const [errors, results] = yield* Effect.partition(
|
||||
accounts,
|
||||
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
for (const error of errors) {
|
||||
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
|
||||
Effect.annotateLogs({ error: String(error) }),
|
||||
)
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return []
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
return yield* fetchOrgs(account.url, accessToken)
|
||||
})
|
||||
|
||||
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return Option.none()
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
const response = yield* executeRead(
|
||||
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
||||
),
|
||||
)
|
||||
|
||||
if (response.status === 404) return Option.none()
|
||||
|
||||
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return Option.some(parsed.config)
|
||||
})
|
||||
|
||||
const login = Effect.fn("Account.login")(function* (server: string) {
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return new Login({
|
||||
code: parsed.device_code,
|
||||
user: parsed.user_code,
|
||||
url: `${server}${parsed.verification_uri_complete}`,
|
||||
server,
|
||||
expiry: parsed.expires_in,
|
||||
interval: parsed.interval,
|
||||
})
|
||||
})
|
||||
|
||||
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
||||
const response = yield* executeEffect(
|
||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
||||
new DeviceTokenRequest({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: input.code,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
|
||||
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
||||
const accessToken = parsed.access_token
|
||||
|
||||
const user = fetchUser(input.server, accessToken)
|
||||
const orgs = fetchOrgs(input.server, accessToken)
|
||||
|
||||
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
||||
|
||||
// TODO: When there are multiple orgs, let the user choose
|
||||
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
||||
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const expiry = now + Duration.toMillis(parsed.expires_in)
|
||||
const refreshToken = parsed.refresh_token
|
||||
|
||||
yield* repo.persistAccount({
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
url: input.server,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiry,
|
||||
orgID: firstOrgID,
|
||||
})
|
||||
|
||||
return new PollSuccess({ email: account.email })
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
active: repo.active,
|
||||
list: repo.list,
|
||||
orgsByAccount,
|
||||
remove: repo.remove,
|
||||
use: repo.use,
|
||||
orgs,
|
||||
config,
|
||||
token,
|
||||
login,
|
||||
poll,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
export const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export async function active(): Promise<Info | undefined> {
|
||||
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
||||
}
|
||||
|
||||
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
|
||||
const config = await runPromise((service) => service.config(accountID, orgID))
|
||||
return Option.getOrUndefined(config)
|
||||
const cfg = await runPromise((service) => service.config(accountID, orgID))
|
||||
return Option.getOrUndefined(cfg)
|
||||
}
|
||||
|
||||
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
|
||||
const token = await runPromise((service) => service.token(accountID))
|
||||
return Option.getOrUndefined(token)
|
||||
const t = await runPromise((service) => service.token(accountID))
|
||||
return Option.getOrUndefined(t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||
import { Global } from "@/global"
|
||||
import path from "path"
|
||||
@@ -32,7 +32,7 @@ export namespace Agent {
|
||||
topP: z.number().optional(),
|
||||
temperature: z.number().optional(),
|
||||
color: z.string().optional(),
|
||||
permission: PermissionNext.Ruleset,
|
||||
permission: Permission.Ruleset,
|
||||
model: z
|
||||
.object({
|
||||
modelID: ModelID.zod,
|
||||
@@ -54,7 +54,7 @@ export namespace Agent {
|
||||
|
||||
const skillDirs = await Skill.dirs()
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
const defaults = PermissionNext.fromConfig({
|
||||
const defaults = Permission.fromConfig({
|
||||
"*": "allow",
|
||||
doom_loop: "ask",
|
||||
external_directory: {
|
||||
@@ -64,6 +64,7 @@ export namespace Agent {
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
edit: "ask",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
@@ -72,16 +73,16 @@ export namespace Agent {
|
||||
"*.env.example": "allow",
|
||||
},
|
||||
})
|
||||
const user = PermissionNext.fromConfig(cfg.permission ?? {})
|
||||
const user = Permission.fromConfig(cfg.permission ?? {})
|
||||
|
||||
const result: Record<string, Info> = {
|
||||
build: {
|
||||
name: "build",
|
||||
description: "The default agent. Executes tools based on configured permissions.",
|
||||
options: {},
|
||||
permission: PermissionNext.merge(
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
Permission.fromConfig({
|
||||
question: "allow",
|
||||
plan_enter: "allow",
|
||||
}),
|
||||
@@ -94,9 +95,9 @@ export namespace Agent {
|
||||
name: "plan",
|
||||
description: "Plan mode. Disallows all edit tools.",
|
||||
options: {},
|
||||
permission: PermissionNext.merge(
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
Permission.fromConfig({
|
||||
question: "allow",
|
||||
plan_exit: "allow",
|
||||
external_directory: {
|
||||
@@ -116,9 +117,9 @@ export namespace Agent {
|
||||
general: {
|
||||
name: "general",
|
||||
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
|
||||
permission: PermissionNext.merge(
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
Permission.fromConfig({
|
||||
todoread: "deny",
|
||||
todowrite: "deny",
|
||||
}),
|
||||
@@ -130,9 +131,9 @@ export namespace Agent {
|
||||
},
|
||||
explore: {
|
||||
name: "explore",
|
||||
permission: PermissionNext.merge(
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
grep: "allow",
|
||||
glob: "allow",
|
||||
@@ -161,9 +162,9 @@ export namespace Agent {
|
||||
native: true,
|
||||
hidden: true,
|
||||
prompt: PROMPT_COMPACTION,
|
||||
permission: PermissionNext.merge(
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
@@ -177,9 +178,9 @@ export namespace Agent {
|
||||
native: true,
|
||||
hidden: true,
|
||||
temperature: 0.5,
|
||||
permission: PermissionNext.merge(
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
@@ -192,9 +193,9 @@ export namespace Agent {
|
||||
options: {},
|
||||
native: true,
|
||||
hidden: true,
|
||||
permission: PermissionNext.merge(
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
@@ -213,7 +214,7 @@ export namespace Agent {
|
||||
item = result[key] = {
|
||||
name: key,
|
||||
mode: "all",
|
||||
permission: PermissionNext.merge(defaults, user),
|
||||
permission: Permission.merge(defaults, user),
|
||||
options: {},
|
||||
native: false,
|
||||
}
|
||||
@@ -229,7 +230,7 @@ export namespace Agent {
|
||||
item.name = value.name ?? item.name
|
||||
item.steps = value.steps ?? item.steps
|
||||
item.options = mergeDeep(item.options, value.options ?? {})
|
||||
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
|
||||
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
||||
}
|
||||
|
||||
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
||||
@@ -242,9 +243,9 @@ export namespace Agent {
|
||||
})
|
||||
if (explicit) continue
|
||||
|
||||
result[name].permission = PermissionNext.merge(
|
||||
result[name].permission = Permission.merge(
|
||||
result[name].permission,
|
||||
PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
||||
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||
|
||||
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
||||
type: Schema.Literal("oauth"),
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: Schema.Number,
|
||||
accountId: Schema.optional(Schema.String),
|
||||
enterpriseUrl: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||
type: Schema.Literal("api"),
|
||||
key: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||
type: Schema.Literal("wellknown"),
|
||||
key: Schema.String,
|
||||
token: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([Oauth, Api, WellKnown])
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
const file = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
||||
|
||||
export namespace Auth {
|
||||
export interface Interface {
|
||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
||||
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
|
||||
const all = Effect.fn("Auth.all")(() =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||
},
|
||||
catch: fail("Failed to read auth data"),
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||
return (yield* all())[providerID]
|
||||
})
|
||||
|
||||
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
if (norm !== key) delete data[key]
|
||||
delete data[norm + "/"]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
delete data[key]
|
||||
delete data[norm]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, data, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ get, all, set, remove })
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,43 +1,101 @@
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import * as S from "./effect"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export { OAUTH_DUMMY_KEY } from "./effect"
|
||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||
|
||||
function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
|
||||
return runtime.runPromise(S.Auth.Service.use(f))
|
||||
}
|
||||
const file = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause })
|
||||
|
||||
export namespace Auth {
|
||||
export const Oauth = z
|
||||
.object({
|
||||
type: z.literal("oauth"),
|
||||
refresh: z.string(),
|
||||
access: z.string(),
|
||||
expires: z.number(),
|
||||
accountId: z.string().optional(),
|
||||
enterpriseUrl: z.string().optional(),
|
||||
})
|
||||
.meta({ ref: "OAuth" })
|
||||
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
||||
type: Schema.Literal("oauth"),
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: Schema.Number,
|
||||
accountId: Schema.optional(Schema.String),
|
||||
enterpriseUrl: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export const Api = z
|
||||
.object({
|
||||
type: z.literal("api"),
|
||||
key: z.string(),
|
||||
})
|
||||
.meta({ ref: "ApiAuth" })
|
||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||
type: Schema.Literal("api"),
|
||||
key: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const WellKnown = z
|
||||
.object({
|
||||
type: z.literal("wellknown"),
|
||||
key: z.string(),
|
||||
token: z.string(),
|
||||
})
|
||||
.meta({ ref: "WellKnownAuth" })
|
||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||
type: Schema.Literal("wellknown"),
|
||||
key: Schema.String,
|
||||
token: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
|
||||
export type Info = z.infer<typeof Info>
|
||||
const _Info = Schema.Union([Oauth, Api, WellKnown])
|
||||
export const Info = Object.assign(_Info, { zod: zod(_Info) })
|
||||
export type Info = Schema.Schema.Type<typeof _Info>
|
||||
|
||||
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
||||
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
|
||||
const all = Effect.fn("Auth.all")(() =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||
},
|
||||
catch: fail("Failed to read auth data"),
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||
return (yield* all())[providerID]
|
||||
})
|
||||
|
||||
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
if (norm !== key) delete data[key]
|
||||
delete data[norm + "/"]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
delete data[key]
|
||||
delete data[norm]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, data, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ get, all, set, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export async function get(providerID: string) {
|
||||
return runPromise((service) => service.get(providerID))
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Lock } from "../util/lock"
|
||||
import { PackageRegistry } from "./registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace BunProc {
|
||||
const log = Log.create({ service: "bun" })
|
||||
|
||||
export async function run(cmd: string[], options?: Process.RunOptions) {
|
||||
const full = [which(), ...cmd]
|
||||
log.info("running", {
|
||||
cmd: full,
|
||||
...options,
|
||||
})
|
||||
const result = await Process.run(full, {
|
||||
cwd: options?.cwd,
|
||||
abort: options?.abort,
|
||||
kill: options?.kill,
|
||||
timeout: options?.timeout,
|
||||
nothrow: options?.nothrow,
|
||||
env: {
|
||||
...process.env,
|
||||
...options?.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
log.info("done", {
|
||||
code: result.code,
|
||||
stdout: result.stdout.toString(),
|
||||
stderr: result.stderr.toString(),
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export function which() {
|
||||
return process.execPath
|
||||
}
|
||||
|
||||
export const InstallFailedError = NamedError.create(
|
||||
"BunInstallFailedError",
|
||||
z.object({
|
||||
pkg: z.string(),
|
||||
version: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export async function install(pkg: string, version = "latest") {
|
||||
// Use lock to ensure only one install at a time
|
||||
using _ = await Lock.write("bun-install")
|
||||
|
||||
const mod = path.join(Global.Path.cache, "node_modules", pkg)
|
||||
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
|
||||
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
|
||||
const result = { dependencies: {} as Record<string, string> }
|
||||
await Filesystem.writeJson(pkgjsonPath, result)
|
||||
return result
|
||||
})
|
||||
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
|
||||
const dependencies = parsed.dependencies
|
||||
const modExists = await Filesystem.exists(mod)
|
||||
const cachedVersion = dependencies[pkg]
|
||||
|
||||
if (!modExists || !cachedVersion) {
|
||||
// continue to install
|
||||
} else if (version !== "latest" && cachedVersion === version) {
|
||||
return mod
|
||||
} else if (version === "latest") {
|
||||
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
|
||||
if (!isOutdated) return mod
|
||||
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
||||
}
|
||||
|
||||
// Build command arguments
|
||||
const args = [
|
||||
"add",
|
||||
"--force",
|
||||
"--exact",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
"--cwd",
|
||||
Global.Path.cache,
|
||||
pkg + "@" + version,
|
||||
]
|
||||
|
||||
// Let Bun handle registry resolution:
|
||||
// - If .npmrc files exist, Bun will use them automatically
|
||||
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
|
||||
// - No need to pass --registry flag
|
||||
log.info("installing package using Bun's default registry resolution", {
|
||||
pkg,
|
||||
version,
|
||||
})
|
||||
|
||||
await BunProc.run(args, {
|
||||
cwd: Global.Path.cache,
|
||||
}).catch((e) => {
|
||||
throw new InstallFailedError(
|
||||
{ pkg, version },
|
||||
{
|
||||
cause: e,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// Resolve actual version from installed package when using "latest"
|
||||
// This ensures subsequent starts use the cached version until explicitly updated
|
||||
let resolvedVersion = version
|
||||
if (version === "latest") {
|
||||
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
|
||||
() => null,
|
||||
)
|
||||
if (installedPkg?.version) {
|
||||
resolvedVersion = installedPkg.version
|
||||
}
|
||||
}
|
||||
|
||||
parsed.dependencies[pkg] = resolvedVersion
|
||||
await Filesystem.writeJson(pkgjsonPath, parsed)
|
||||
return mod
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import semver from "semver"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace PackageRegistry {
|
||||
const log = Log.create({ service: "bun" })
|
||||
|
||||
function which() {
|
||||
return process.execPath
|
||||
}
|
||||
|
||||
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
|
||||
const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
nothrow: true,
|
||||
})
|
||||
|
||||
if (code !== 0) {
|
||||
log.warn("bun info failed", { pkg, field, code, stderr: stderr.toString() })
|
||||
return null
|
||||
}
|
||||
|
||||
const value = stdout.toString().trim()
|
||||
if (!value) return null
|
||||
return value
|
||||
}
|
||||
|
||||
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
|
||||
const latestVersion = await info(pkg, "version", cwd)
|
||||
if (!latestVersion) {
|
||||
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
||||
return false
|
||||
}
|
||||
|
||||
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
|
||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
||||
import { type AccountError } from "@/account/schema"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
@@ -160,7 +159,7 @@ export const LoginCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
await runtime.runPromise(loginEffect(args.url))
|
||||
await Account.runPromise((_svc) => loginEffect(args.url))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -174,7 +173,7 @@ export const LogoutCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
await runtime.runPromise(logoutEffect(args.email))
|
||||
await Account.runPromise((_svc) => logoutEffect(args.email))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -183,7 +182,7 @@ export const SwitchCommand = cmd({
|
||||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await runtime.runPromise(switchEffect())
|
||||
await Account.runPromise((_svc) => switchEffect())
|
||||
},
|
||||
})
|
||||
|
||||
@@ -192,7 +191,7 @@ export const OrgsCommand = cmd({
|
||||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await runtime.runPromise(orgsEffect())
|
||||
await Account.runPromise((_svc) => orgsEffect())
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
|
||||
import { MessageID, PartID } from "../../../session/schema"
|
||||
import { ToolRegistry } from "../../../tool/registry"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { PermissionNext } from "../../../permission"
|
||||
import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
@@ -75,7 +75,7 @@ async function getAvailableTools(agent: Agent.Info) {
|
||||
}
|
||||
|
||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||
const disabled = PermissionNext.disabled(
|
||||
const disabled = Permission.disabled(
|
||||
availableTools.map((tool) => tool.id),
|
||||
agent.permission,
|
||||
)
|
||||
@@ -145,7 +145,7 @@ async function createToolContext(agent: Agent.Info) {
|
||||
}
|
||||
await Session.updateMessage(message)
|
||||
|
||||
const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
|
||||
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
||||
|
||||
return {
|
||||
sessionID: session.id,
|
||||
@@ -155,11 +155,11 @@ async function createToolContext(agent: Agent.Info) {
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
|
||||
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
|
||||
const rule = Permission.evaluate(req.permission, pattern, ruleset)
|
||||
if (rule.action === "deny") {
|
||||
throw new PermissionNext.DeniedError({ ruleset })
|
||||
throw new Permission.DeniedError({ ruleset })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
|
||||
import { Server } from "../../server/server"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { PermissionNext } from "../../permission"
|
||||
import { Permission } from "../../permission"
|
||||
import { Tool } from "../../tool/tool"
|
||||
import { GlobTool } from "../../tool/glob"
|
||||
import { GrepTool } from "../../tool/grep"
|
||||
@@ -354,7 +354,7 @@ export const RunCommand = cmd({
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const rules: PermissionNext.Ruleset = [
|
||||
const rules: Permission.Ruleset = [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
@@ -370,6 +370,11 @@ export const RunCommand = cmd({
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "edit",
|
||||
action: "allow",
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
|
||||
function title() {
|
||||
|
||||
@@ -480,6 +480,7 @@ function App() {
|
||||
{
|
||||
title: "Toggle MCPs",
|
||||
value: "mcp.list",
|
||||
search: "toggle mcps",
|
||||
category: "Agent",
|
||||
slash: {
|
||||
name: "mcps",
|
||||
@@ -555,8 +556,9 @@ function App() {
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Toggle appearance",
|
||||
title: mode() === "dark" ? "Light mode" : "Dark mode",
|
||||
value: "theme.switch_mode",
|
||||
search: "toggle appearance",
|
||||
onSelect: (dialog) => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
dialog.clear()
|
||||
@@ -595,6 +597,7 @@ function App() {
|
||||
},
|
||||
{
|
||||
title: "Toggle debug panel",
|
||||
search: "toggle debug",
|
||||
category: "System",
|
||||
value: "app.debug",
|
||||
onSelect: (dialog) => {
|
||||
@@ -604,6 +607,7 @@ function App() {
|
||||
},
|
||||
{
|
||||
title: "Toggle console",
|
||||
search: "toggle console",
|
||||
category: "System",
|
||||
value: "app.console",
|
||||
onSelect: (dialog) => {
|
||||
@@ -644,6 +648,7 @@ function App() {
|
||||
{
|
||||
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
|
||||
value: "terminal.title.toggle",
|
||||
search: "toggle terminal title",
|
||||
keybind: "terminal_title_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
@@ -659,6 +664,7 @@ function App() {
|
||||
{
|
||||
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
|
||||
value: "app.toggle.animations",
|
||||
search: "toggle animations",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("animations_enabled", !kv.get("animations_enabled", true))
|
||||
@@ -668,6 +674,7 @@ function App() {
|
||||
{
|
||||
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
value: "app.toggle.diffwrap",
|
||||
search: "toggle diff wrapping",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
const current = kv.get("diff_wrap_mode", "word")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
|
||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
|
||||
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import path from "path"
|
||||
@@ -79,6 +79,7 @@ export function Prompt(props: PromptProps) {
|
||||
const renderer = useRenderer()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
function promptModelWarning() {
|
||||
toast.show({
|
||||
@@ -172,6 +173,17 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
{
|
||||
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
|
||||
value: "permission.auto_accept.toggle",
|
||||
search: "toggle permissions",
|
||||
keybind: "permission_auto_accept_toggle",
|
||||
category: "Agent",
|
||||
onSelect: (dialog) => {
|
||||
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
@@ -934,7 +946,7 @@ export function Prompt(props: PromptProps) {
|
||||
// Normalize line endings at the boundary
|
||||
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
||||
// Replace CRLF first, then any remaining CR
|
||||
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
if (!pastedContent) {
|
||||
command.trigger("prompt.paste")
|
||||
@@ -1010,23 +1022,30 @@ export function Prompt(props: PromptProps) {
|
||||
cursorColor={theme.text}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={autoaccept() === "edit"}>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning }}>autoedit</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { createSimpleContext } from "./helper"
|
||||
import type { Snapshot } from "@/snapshot"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { useKV } from "./kv"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { Log } from "@/util/log"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
@@ -106,6 +107,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
async function syncWorkspaces() {
|
||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
@@ -136,6 +139,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
if (autoaccept() === "edit" && request.permission === "edit") {
|
||||
sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: request.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
const requests = store.permission[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("permission", request.sessionID, [request])
|
||||
@@ -451,6 +461,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
get ready() {
|
||||
return store.status !== "loading"
|
||||
},
|
||||
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
|
||||
@@ -47,6 +47,7 @@ export function Home() {
|
||||
{
|
||||
title: tipsHidden() ? "Show tips" : "Hide tips",
|
||||
value: "tips.toggle",
|
||||
search: "toggle tips",
|
||||
keybind: "tips_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
|
||||
@@ -568,6 +568,7 @@ export function Session() {
|
||||
{
|
||||
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
|
||||
value: "session.sidebar.toggle",
|
||||
search: "toggle sidebar",
|
||||
keybind: "sidebar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -582,6 +583,7 @@ export function Session() {
|
||||
{
|
||||
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
||||
value: "session.toggle.conceal",
|
||||
search: "toggle code concealment",
|
||||
keybind: "messages_toggle_conceal" as any,
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -592,6 +594,7 @@ export function Session() {
|
||||
{
|
||||
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
|
||||
value: "session.toggle.timestamps",
|
||||
search: "toggle timestamps",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "timestamps",
|
||||
@@ -605,6 +608,7 @@ export function Session() {
|
||||
{
|
||||
title: showThinking() ? "Hide thinking" : "Show thinking",
|
||||
value: "session.toggle.thinking",
|
||||
search: "toggle thinking",
|
||||
keybind: "display_thinking",
|
||||
category: "Session",
|
||||
slash: {
|
||||
@@ -619,6 +623,7 @@ export function Session() {
|
||||
{
|
||||
title: showDetails() ? "Hide tool details" : "Show tool details",
|
||||
value: "session.toggle.actions",
|
||||
search: "toggle tool details",
|
||||
keybind: "tool_details",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -627,8 +632,9 @@ export function Session() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Toggle session scrollbar",
|
||||
title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar",
|
||||
value: "session.toggle.scrollbar",
|
||||
search: "toggle session scrollbar",
|
||||
keybind: "scrollbar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -1667,6 +1673,7 @@ function InlineTool(props: {
|
||||
|
||||
const denied = createMemo(
|
||||
() =>
|
||||
error()?.includes("QuestionRejectedError") ||
|
||||
error()?.includes("rejected permission") ||
|
||||
error()?.includes("specified a rule") ||
|
||||
error()?.includes("user dismissed"),
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
search?: string
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
disabled?: boolean
|
||||
@@ -85,8 +86,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
// users typically search by the item name, and not its category.
|
||||
const result = fuzzysort
|
||||
.go(needle, options, {
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
keys: ["title", "category", "search"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { pathToFileURL } from "url"
|
||||
import { createRequire } from "module"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from "jsonc-parser"
|
||||
import { Instance } from "../project/instance"
|
||||
import { LSPServer } from "../lsp/server"
|
||||
import { BunProc } from "@/bun"
|
||||
import { Installation } from "@/installation"
|
||||
import { ConfigMarkdown } from "./markdown"
|
||||
import { constants, existsSync } from "fs"
|
||||
@@ -30,14 +29,11 @@ import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
import { Glob } from "../util/glob"
|
||||
import { PackageRegistry } from "@/bun/registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Account } from "@/account"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { Lock } from "@/util/lock"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export namespace Config {
|
||||
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||
@@ -154,8 +150,7 @@ export namespace Config {
|
||||
|
||||
deps.push(
|
||||
iife(async () => {
|
||||
const shouldInstall = await needsInstall(dir)
|
||||
if (shouldInstall) await installDependencies(dir)
|
||||
await installDependencies(dir)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -177,7 +172,7 @@ export namespace Config {
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
const active = Account.active()
|
||||
const active = await Account.active()
|
||||
if (active?.active_org_id) {
|
||||
try {
|
||||
const [config, token] = await Promise.all([
|
||||
@@ -271,6 +266,10 @@ export namespace Config {
|
||||
}
|
||||
|
||||
export async function installDependencies(dir: string) {
|
||||
if (!(await isWritable(dir))) {
|
||||
log.info("config dir is not writable, skipping dependency install", { dir })
|
||||
return
|
||||
}
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
|
||||
|
||||
@@ -284,43 +283,15 @@ export namespace Config {
|
||||
await Filesystem.writeJson(pkg, json)
|
||||
|
||||
const gitignore = path.join(dir, ".gitignore")
|
||||
const hasGitIgnore = await Filesystem.exists(gitignore)
|
||||
if (!hasGitIgnore)
|
||||
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||
if (!(await Filesystem.exists(gitignore)))
|
||||
await Filesystem.write(
|
||||
gitignore,
|
||||
["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"),
|
||||
)
|
||||
|
||||
// Install any additional dependencies defined in the package.json
|
||||
// This allows local plugins and custom tools to use external packages
|
||||
using _ = await Lock.write("bun-install")
|
||||
await BunProc.run(
|
||||
[
|
||||
"install",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
],
|
||||
{ cwd: dir },
|
||||
).catch((err) => {
|
||||
if (err instanceof Process.RunFailedError) {
|
||||
const detail = {
|
||||
dir,
|
||||
cmd: err.cmd,
|
||||
code: err.code,
|
||||
stdout: err.stdout.toString(),
|
||||
stderr: err.stderr.toString(),
|
||||
}
|
||||
if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
|
||||
log.error("failed to install dependencies", detail)
|
||||
throw err
|
||||
}
|
||||
log.warn("failed to install dependencies", detail)
|
||||
return
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
|
||||
log.error("failed to install dependencies", { dir, error: err })
|
||||
throw err
|
||||
}
|
||||
log.warn("failed to install dependencies", { dir, error: err })
|
||||
})
|
||||
await Npm.install(dir)
|
||||
}
|
||||
|
||||
async function isWritable(dir: string) {
|
||||
@@ -332,41 +303,6 @@ export namespace Config {
|
||||
}
|
||||
}
|
||||
|
||||
export async function needsInstall(dir: string) {
|
||||
// Some config dirs may be read-only.
|
||||
// Installing deps there will fail; skip installation in that case.
|
||||
const writable = await isWritable(dir)
|
||||
if (!writable) {
|
||||
log.debug("config dir is not writable, skipping dependency install", { dir })
|
||||
return false
|
||||
}
|
||||
|
||||
const nodeModules = path.join(dir, "node_modules")
|
||||
if (!existsSync(nodeModules)) return true
|
||||
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const pkgExists = await Filesystem.exists(pkg)
|
||||
if (!pkgExists) return true
|
||||
|
||||
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
|
||||
const dependencies = parsed?.dependencies ?? {}
|
||||
const depVersion = dependencies["@opencode-ai/plugin"]
|
||||
if (!depVersion) return true
|
||||
|
||||
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
|
||||
if (targetVersion === "latest") {
|
||||
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
|
||||
if (!isOutdated) return false
|
||||
log.info("Cached version is outdated, proceeding with install", {
|
||||
pkg: "@opencode-ai/plugin",
|
||||
cachedVersion: depVersion,
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (depVersion === targetVersion) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function rel(item: string, patterns: string[]) {
|
||||
const normalizedItem = item.replaceAll("\\", "/")
|
||||
for (const pattern of patterns) {
|
||||
@@ -858,7 +794,12 @@ export namespace Config {
|
||||
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
|
||||
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
|
||||
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"),
|
||||
permission_auto_accept_toggle: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("shift+tab")
|
||||
.describe("Toggle auto-accept mode for permissions"),
|
||||
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
|
||||
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
|
||||
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
|
||||
|
||||
47
packages/opencode/src/effect/instance-state.ts
Normal file
47
packages/opencode/src/effect/instance-state.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Effect, ScopedCache, Scope } from "effect"
|
||||
import { Instance, type Shape } from "@/project/instance"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
|
||||
const TypeId = "~opencode/InstanceState"
|
||||
|
||||
export interface InstanceState<A, E = never, R = never> {
|
||||
readonly [TypeId]: typeof TypeId
|
||||
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
|
||||
}
|
||||
|
||||
export namespace InstanceState {
|
||||
export const make = <A, E = never, R = never>(
|
||||
init: (ctx: Shape) => Effect.Effect<A, E, R | Scope.Scope>,
|
||||
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ScopedCache.make<string, A, E, R>({
|
||||
capacity: Number.POSITIVE_INFINITY,
|
||||
lookup: () => init(Instance.current),
|
||||
})
|
||||
|
||||
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||
|
||||
return {
|
||||
[TypeId]: TypeId,
|
||||
cache,
|
||||
}
|
||||
})
|
||||
|
||||
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
|
||||
|
||||
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
|
||||
Effect.map(get(self), select)
|
||||
|
||||
export const useEffect = <A, E, R, B, E2, R2>(
|
||||
self: InstanceState<A, E, R>,
|
||||
select: (value: A) => Effect.Effect<B, E2, R2>,
|
||||
) => Effect.flatMap(get(self), select)
|
||||
|
||||
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
|
||||
|
||||
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||
import { File } from "@/file/service"
|
||||
import { FileTime } from "@/file/time-service"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Format } from "@/format/service"
|
||||
import { Permission } from "@/permission/service"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { ProviderAuth } from "@/provider/auth-service"
|
||||
import { Question } from "@/question/service"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { Skill } from "@/skill/service"
|
||||
import { Snapshot } from "@/snapshot/service"
|
||||
import { InstanceContext } from "./instance-context"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
|
||||
export { InstanceContext } from "./instance-context"
|
||||
|
||||
export type InstanceServices =
|
||||
| Question.Service
|
||||
| Permission.Service
|
||||
| ProviderAuth.Service
|
||||
| FileWatcher.Service
|
||||
| Vcs.Service
|
||||
| FileTime.Service
|
||||
| Format.Service
|
||||
| File.Service
|
||||
| SessionStatus.Service
|
||||
| Skill.Service
|
||||
| Snapshot.Service
|
||||
|
||||
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
|
||||
// the full instance context (directory, worktree, project). We read from the
|
||||
// legacy Instance ALS here, which is safe because lookup is only triggered via
|
||||
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
|
||||
// This should go away once the old Instance type is removed and lookup can load
|
||||
// the full context directly.
|
||||
function lookup(_key: string) {
|
||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
||||
return Layer.mergeAll(
|
||||
Question.layer,
|
||||
Permission.layer,
|
||||
ProviderAuth.defaultLayer,
|
||||
FileWatcher.layer,
|
||||
Vcs.layer,
|
||||
FileTime.layer,
|
||||
Format.layer,
|
||||
File.layer,
|
||||
SessionStatus.layer,
|
||||
Skill.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
).pipe(Layer.provide(ctx))
|
||||
}
|
||||
|
||||
export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
|
||||
"opencode/Instances",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
Instances,
|
||||
Effect.gen(function* () {
|
||||
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
||||
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
||||
return Instances.of(layerMap)
|
||||
}),
|
||||
)
|
||||
|
||||
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
|
||||
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
|
||||
}
|
||||
}
|
||||
13
packages/opencode/src/effect/run-service.ts
Normal file
13
packages/opencode/src/effect/run-service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import * as ServiceMap from "effect/ServiceMap"
|
||||
|
||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||
|
||||
export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||
|
||||
return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
|
||||
rt ??= ManagedRuntime.make(layer, { memoMap })
|
||||
return rt.runPromise(service.use(fn), options)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { Account } from "@/account/effect"
|
||||
import { Auth } from "@/auth/effect"
|
||||
import { Instances } from "@/effect/instances"
|
||||
import type { InstanceServices } from "@/effect/instances"
|
||||
import { Installation } from "@/installation"
|
||||
import { Truncate } from "@/tool/truncate-effect"
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
export const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(
|
||||
Account.defaultLayer, //
|
||||
Installation.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
Instances.layer,
|
||||
).pipe(Layer.provideMerge(Auth.layer)),
|
||||
)
|
||||
|
||||
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
||||
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
||||
}
|
||||
|
||||
export function runSyncInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
||||
return runtime.runSync(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
||||
}
|
||||
|
||||
export function disposeRuntime() {
|
||||
return runtime.dispose()
|
||||
}
|
||||
@@ -1,40 +1,712 @@
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { File as S } from "./service"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { git } from "@/util/git"
|
||||
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import fs from "fs"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import ignore from "ignore"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
import { Protected } from "./protected"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
|
||||
export namespace File {
|
||||
export const Info = S.Info
|
||||
export type Info = S.Info
|
||||
export const Info = z
|
||||
.object({
|
||||
path: z.string(),
|
||||
added: z.number().int(),
|
||||
removed: z.number().int(),
|
||||
status: z.enum(["added", "deleted", "modified"]),
|
||||
})
|
||||
.meta({
|
||||
ref: "File",
|
||||
})
|
||||
|
||||
export const Node = S.Node
|
||||
export type Node = S.Node
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Content = S.Content
|
||||
export type Content = S.Content
|
||||
export const Node = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
absolute: z.string(),
|
||||
type: z.enum(["file", "directory"]),
|
||||
ignored: z.boolean(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FileNode",
|
||||
})
|
||||
export type Node = z.infer<typeof Node>
|
||||
|
||||
export const Event = S.Event
|
||||
export const Content = z
|
||||
.object({
|
||||
type: z.enum(["text", "binary"]),
|
||||
content: z.string(),
|
||||
diff: z.string().optional(),
|
||||
patch: z
|
||||
.object({
|
||||
oldFileName: z.string(),
|
||||
newFileName: z.string(),
|
||||
oldHeader: z.string().optional(),
|
||||
newHeader: z.string().optional(),
|
||||
hunks: z.array(
|
||||
z.object({
|
||||
oldStart: z.number(),
|
||||
oldLines: z.number(),
|
||||
newStart: z.number(),
|
||||
newLines: z.number(),
|
||||
lines: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
index: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
encoding: z.literal("base64").optional(),
|
||||
mimeType: z.string().optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FileContent",
|
||||
})
|
||||
export type Content = z.infer<typeof Content>
|
||||
|
||||
export type Interface = S.Interface
|
||||
export const Event = {
|
||||
Edited: BusEvent.define(
|
||||
"file.edited",
|
||||
z.object({
|
||||
file: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export const Service = S.Service
|
||||
export const layer = S.layer
|
||||
const log = Log.create({ service: "file" })
|
||||
|
||||
const binary = new Set([
|
||||
"exe",
|
||||
"dll",
|
||||
"pdb",
|
||||
"bin",
|
||||
"so",
|
||||
"dylib",
|
||||
"o",
|
||||
"a",
|
||||
"lib",
|
||||
"wav",
|
||||
"mp3",
|
||||
"ogg",
|
||||
"oga",
|
||||
"ogv",
|
||||
"ogx",
|
||||
"flac",
|
||||
"aac",
|
||||
"wma",
|
||||
"m4a",
|
||||
"weba",
|
||||
"mp4",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"webm",
|
||||
"mkv",
|
||||
"zip",
|
||||
"tar",
|
||||
"gz",
|
||||
"gzip",
|
||||
"bz",
|
||||
"bz2",
|
||||
"bzip",
|
||||
"bzip2",
|
||||
"7z",
|
||||
"rar",
|
||||
"xz",
|
||||
"lz",
|
||||
"z",
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"dmg",
|
||||
"iso",
|
||||
"img",
|
||||
"vmdk",
|
||||
"ttf",
|
||||
"otf",
|
||||
"woff",
|
||||
"woff2",
|
||||
"eot",
|
||||
"sqlite",
|
||||
"db",
|
||||
"mdb",
|
||||
"apk",
|
||||
"ipa",
|
||||
"aab",
|
||||
"xapk",
|
||||
"app",
|
||||
"pkg",
|
||||
"deb",
|
||||
"rpm",
|
||||
"snap",
|
||||
"flatpak",
|
||||
"appimage",
|
||||
"msi",
|
||||
"msp",
|
||||
"jar",
|
||||
"war",
|
||||
"ear",
|
||||
"class",
|
||||
"kotlin_module",
|
||||
"dex",
|
||||
"vdex",
|
||||
"odex",
|
||||
"oat",
|
||||
"art",
|
||||
"wasm",
|
||||
"wat",
|
||||
"bc",
|
||||
"ll",
|
||||
"s",
|
||||
"ko",
|
||||
"sys",
|
||||
"drv",
|
||||
"efi",
|
||||
"rom",
|
||||
"com",
|
||||
])
|
||||
|
||||
const image = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"bmp",
|
||||
"webp",
|
||||
"ico",
|
||||
"tif",
|
||||
"tiff",
|
||||
"svg",
|
||||
"svgz",
|
||||
"avif",
|
||||
"apng",
|
||||
"jxl",
|
||||
"heic",
|
||||
"heif",
|
||||
"raw",
|
||||
"cr2",
|
||||
"nef",
|
||||
"arw",
|
||||
"dng",
|
||||
"orf",
|
||||
"raf",
|
||||
"pef",
|
||||
"x3f",
|
||||
])
|
||||
|
||||
const text = new Set([
|
||||
"ts",
|
||||
"tsx",
|
||||
"mts",
|
||||
"cts",
|
||||
"mtsx",
|
||||
"ctsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
"ps1",
|
||||
"psm1",
|
||||
"cmd",
|
||||
"bat",
|
||||
"json",
|
||||
"jsonc",
|
||||
"json5",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"md",
|
||||
"mdx",
|
||||
"txt",
|
||||
"xml",
|
||||
"html",
|
||||
"htm",
|
||||
"css",
|
||||
"scss",
|
||||
"sass",
|
||||
"less",
|
||||
"graphql",
|
||||
"gql",
|
||||
"sql",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
"env",
|
||||
])
|
||||
|
||||
const textName = new Set([
|
||||
"dockerfile",
|
||||
"makefile",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
".editorconfig",
|
||||
".npmrc",
|
||||
".nvmrc",
|
||||
".prettierrc",
|
||||
".eslintrc",
|
||||
])
|
||||
|
||||
const mime: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
bmp: "image/bmp",
|
||||
webp: "image/webp",
|
||||
ico: "image/x-icon",
|
||||
tif: "image/tiff",
|
||||
tiff: "image/tiff",
|
||||
svg: "image/svg+xml",
|
||||
svgz: "image/svg+xml",
|
||||
avif: "image/avif",
|
||||
apng: "image/apng",
|
||||
jxl: "image/jxl",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
}
|
||||
|
||||
type Entry = { files: string[]; dirs: string[] }
|
||||
|
||||
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
||||
const name = (file: string) => path.basename(file).toLowerCase()
|
||||
const isImageByExtension = (file: string) => image.has(ext(file))
|
||||
const isTextByExtension = (file: string) => text.has(ext(file))
|
||||
const isTextByName = (file: string) => textName.has(name(file))
|
||||
const isBinaryByExtension = (file: string) => binary.has(ext(file))
|
||||
const isImage = (mimeType: string) => mimeType.startsWith("image/")
|
||||
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
|
||||
|
||||
function shouldEncode(mimeType: string) {
|
||||
const type = mimeType.toLowerCase()
|
||||
log.debug("shouldEncode", { type })
|
||||
if (!type) return false
|
||||
if (type.startsWith("text/")) return false
|
||||
if (type.includes("charset=")) return false
|
||||
const top = type.split("/", 2)[0]
|
||||
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
|
||||
}
|
||||
|
||||
const hidden = (item: string) => {
|
||||
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
|
||||
}
|
||||
|
||||
const sortHiddenLast = (items: string[], prefer: boolean) => {
|
||||
if (prefer) return items
|
||||
const visible: string[] = []
|
||||
const hiddenItems: string[] = []
|
||||
for (const item of items) {
|
||||
if (hidden(item)) hiddenItems.push(item)
|
||||
else visible.push(item)
|
||||
}
|
||||
return [...visible, ...hiddenItems]
|
||||
}
|
||||
|
||||
interface State {
|
||||
cache: Entry
|
||||
fiber: Fiber.Fiber<void> | undefined
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<File.Info[]>
|
||||
readonly read: (file: string) => Effect.Effect<File.Content>
|
||||
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
|
||||
readonly search: (input: {
|
||||
query: string
|
||||
limit?: number
|
||||
dirs?: boolean
|
||||
type?: "file" | "directory"
|
||||
}) => Effect.Effect<string[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("File.state")(() =>
|
||||
Effect.succeed({
|
||||
cache: { files: [], dirs: [] } as Entry,
|
||||
fiber: undefined as Fiber.Fiber<void> | undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const scan = Effect.fn("File.scan")(function* () {
|
||||
if (Instance.directory === path.parse(Instance.directory).root) return
|
||||
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
|
||||
const next: Entry = { files: [], dirs: [] }
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
if (isGlobalHome) {
|
||||
const dirs = new Set<string>()
|
||||
const protectedNames = Protected.names()
|
||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||
const top = await fs.promises
|
||||
.readdir(Instance.directory, { withFileTypes: true })
|
||||
.catch(() => [] as fs.Dirent[])
|
||||
|
||||
for (const entry of top) {
|
||||
if (!entry.isDirectory()) continue
|
||||
if (shouldIgnoreName(entry.name)) continue
|
||||
dirs.add(entry.name + "/")
|
||||
|
||||
const base = path.join(Instance.directory, entry.name)
|
||||
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
||||
for (const child of children) {
|
||||
if (!child.isDirectory()) continue
|
||||
if (shouldIgnoreNested(child.name)) continue
|
||||
dirs.add(entry.name + "/" + child.name + "/")
|
||||
}
|
||||
}
|
||||
|
||||
next.dirs = Array.from(dirs).toSorted()
|
||||
} else {
|
||||
const seen = new Set<string>()
|
||||
for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
|
||||
next.files.push(file)
|
||||
let current = file
|
||||
while (true) {
|
||||
const dir = path.dirname(current)
|
||||
if (dir === ".") break
|
||||
if (dir === current) break
|
||||
current = dir
|
||||
if (seen.has(dir)) continue
|
||||
seen.add(dir)
|
||||
next.dirs.push(dir + "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.cache = next
|
||||
})
|
||||
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const ensure = Effect.fn("File.ensure")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
if (!s.fiber)
|
||||
s.fiber = yield* scan().pipe(
|
||||
Effect.catchCause(() => Effect.void),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
s.fiber = undefined
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
yield* Fiber.join(s.fiber)
|
||||
})
|
||||
|
||||
const init = Effect.fn("File.init")(function* () {
|
||||
yield* ensure()
|
||||
})
|
||||
|
||||
const status = Effect.fn("File.status")(function* () {
|
||||
if (Instance.project.vcs !== "git") return []
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const diffOutput = (
|
||||
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
|
||||
const changed: File.Info[] = []
|
||||
|
||||
if (diffOutput.trim()) {
|
||||
for (const line of diffOutput.trim().split("\n")) {
|
||||
const [added, removed, file] = line.split("\t")
|
||||
changed.push({
|
||||
path: file,
|
||||
added: added === "-" ? 0 : parseInt(added, 10),
|
||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||
status: "modified",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const untrackedOutput = (
|
||||
await git(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
|
||||
if (untrackedOutput.trim()) {
|
||||
for (const file of untrackedOutput.trim().split("\n")) {
|
||||
try {
|
||||
const content = await Filesystem.readText(path.join(Instance.directory, file))
|
||||
changed.push({
|
||||
path: file,
|
||||
added: content.split("\n").length,
|
||||
removed: 0,
|
||||
status: "added",
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deletedOutput = (
|
||||
await git(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=D",
|
||||
"HEAD",
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
|
||||
if (deletedOutput.trim()) {
|
||||
for (const file of deletedOutput.trim().split("\n")) {
|
||||
changed.push({
|
||||
path: file,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
status: "deleted",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changed.map((item) => {
|
||||
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
|
||||
return {
|
||||
...item,
|
||||
path: path.relative(Instance.directory, full),
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const read = Effect.fn("File.read")(function* (file: string) {
|
||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||
using _ = log.time("read", { file })
|
||||
const full = path.join(Instance.directory, file)
|
||||
|
||||
if (!Instance.containsPath(full)) {
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
if (isImageByExtension(file)) {
|
||||
if (await Filesystem.exists(full)) {
|
||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||
return {
|
||||
type: "text",
|
||||
content: buffer.toString("base64"),
|
||||
mimeType: getImageMimeType(file),
|
||||
encoding: "base64",
|
||||
}
|
||||
}
|
||||
return { type: "text", content: "" }
|
||||
}
|
||||
|
||||
const knownText = isTextByExtension(file) || isTextByName(file)
|
||||
|
||||
if (isBinaryByExtension(file) && !knownText) {
|
||||
return { type: "binary", content: "" }
|
||||
}
|
||||
|
||||
if (!(await Filesystem.exists(full))) {
|
||||
return { type: "text", content: "" }
|
||||
}
|
||||
|
||||
const mimeType = Filesystem.mimeType(full)
|
||||
const encode = knownText ? false : shouldEncode(mimeType)
|
||||
|
||||
if (encode && !isImage(mimeType)) {
|
||||
return { type: "binary", content: "", mimeType }
|
||||
}
|
||||
|
||||
if (encode) {
|
||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||
return {
|
||||
type: "text",
|
||||
content: buffer.toString("base64"),
|
||||
mimeType,
|
||||
encoding: "base64",
|
||||
}
|
||||
}
|
||||
|
||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
let diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||
).text()
|
||||
if (!diff.trim()) {
|
||||
diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
})
|
||||
return {
|
||||
type: "text",
|
||||
content,
|
||||
patch,
|
||||
diff: formatPatch(patch),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "text", content }
|
||||
})
|
||||
})
|
||||
|
||||
const list = Effect.fn("File.list")(function* (dir?: string) {
|
||||
return yield* Effect.promise(async () => {
|
||||
const exclude = [".git", ".DS_Store"]
|
||||
let ignored = (_: string) => false
|
||||
if (Instance.project.vcs === "git") {
|
||||
const ig = ignore()
|
||||
const gitignore = path.join(Instance.project.worktree, ".gitignore")
|
||||
if (await Filesystem.exists(gitignore)) {
|
||||
ig.add(await Filesystem.readText(gitignore))
|
||||
}
|
||||
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
|
||||
if (await Filesystem.exists(ignoreFile)) {
|
||||
ig.add(await Filesystem.readText(ignoreFile))
|
||||
}
|
||||
ignored = ig.ignores.bind(ig)
|
||||
}
|
||||
|
||||
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
|
||||
if (!Instance.containsPath(resolved)) {
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
const nodes: File.Node[] = []
|
||||
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
|
||||
if (exclude.includes(entry.name)) continue
|
||||
const absolute = path.join(resolved, entry.name)
|
||||
const file = path.relative(Instance.directory, absolute)
|
||||
const type = entry.isDirectory() ? "directory" : "file"
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: file,
|
||||
absolute,
|
||||
type,
|
||||
ignored: ignored(type === "directory" ? file + "/" : file),
|
||||
})
|
||||
}
|
||||
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const search = Effect.fn("File.search")(function* (input: {
|
||||
query: string
|
||||
limit?: number
|
||||
dirs?: boolean
|
||||
type?: "file" | "directory"
|
||||
}) {
|
||||
yield* ensure()
|
||||
const { cache } = yield* InstanceState.get(state)
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const query = input.query.trim()
|
||||
const limit = input.limit ?? 100
|
||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||
log.info("search", { query, kind })
|
||||
|
||||
const result = cache
|
||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||
|
||||
if (!query) {
|
||||
if (kind === "file") return result.files.slice(0, limit)
|
||||
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||
}
|
||||
|
||||
const items =
|
||||
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
||||
|
||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||
|
||||
log.info("search", { query, kind, results: output.length })
|
||||
return output
|
||||
})
|
||||
})
|
||||
|
||||
log.info("init")
|
||||
return Service.of({ init, status, read, list, search })
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export function init() {
|
||||
return runPromiseInstance(S.Service.use((svc) => svc.init()))
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return runPromiseInstance(S.Service.use((svc) => svc.status()))
|
||||
return runPromise((svc) => svc.status())
|
||||
}
|
||||
|
||||
export async function read(file: string): Promise<Content> {
|
||||
return runPromiseInstance(S.Service.use((svc) => svc.read(file)))
|
||||
return runPromise((svc) => svc.read(file))
|
||||
}
|
||||
|
||||
export async function list(dir?: string) {
|
||||
return runPromiseInstance(S.Service.use((svc) => svc.list(dir)))
|
||||
return runPromise((svc) => svc.list(dir))
|
||||
}
|
||||
|
||||
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
||||
return runPromiseInstance(S.Service.use((svc) => svc.search(input)))
|
||||
return runPromise((svc) => svc.search(input))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,674 +0,0 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { git } from "@/util/git"
|
||||
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import fs from "fs"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import ignore from "ignore"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
import { Protected } from "./protected"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
|
||||
export namespace File {
|
||||
export const Info = z
|
||||
.object({
|
||||
path: z.string(),
|
||||
added: z.number().int(),
|
||||
removed: z.number().int(),
|
||||
status: z.enum(["added", "deleted", "modified"]),
|
||||
})
|
||||
.meta({
|
||||
ref: "File",
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Node = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
absolute: z.string(),
|
||||
type: z.enum(["file", "directory"]),
|
||||
ignored: z.boolean(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FileNode",
|
||||
})
|
||||
export type Node = z.infer<typeof Node>
|
||||
|
||||
export const Content = z
|
||||
.object({
|
||||
type: z.enum(["text", "binary"]),
|
||||
content: z.string(),
|
||||
diff: z.string().optional(),
|
||||
patch: z
|
||||
.object({
|
||||
oldFileName: z.string(),
|
||||
newFileName: z.string(),
|
||||
oldHeader: z.string().optional(),
|
||||
newHeader: z.string().optional(),
|
||||
hunks: z.array(
|
||||
z.object({
|
||||
oldStart: z.number(),
|
||||
oldLines: z.number(),
|
||||
newStart: z.number(),
|
||||
newLines: z.number(),
|
||||
lines: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
index: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
encoding: z.literal("base64").optional(),
|
||||
mimeType: z.string().optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FileContent",
|
||||
})
|
||||
export type Content = z.infer<typeof Content>
|
||||
|
||||
export const Event = {
|
||||
Edited: BusEvent.define(
|
||||
"file.edited",
|
||||
z.object({
|
||||
file: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "file" })
|
||||
|
||||
const binary = new Set([
|
||||
"exe",
|
||||
"dll",
|
||||
"pdb",
|
||||
"bin",
|
||||
"so",
|
||||
"dylib",
|
||||
"o",
|
||||
"a",
|
||||
"lib",
|
||||
"wav",
|
||||
"mp3",
|
||||
"ogg",
|
||||
"oga",
|
||||
"ogv",
|
||||
"ogx",
|
||||
"flac",
|
||||
"aac",
|
||||
"wma",
|
||||
"m4a",
|
||||
"weba",
|
||||
"mp4",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"webm",
|
||||
"mkv",
|
||||
"zip",
|
||||
"tar",
|
||||
"gz",
|
||||
"gzip",
|
||||
"bz",
|
||||
"bz2",
|
||||
"bzip",
|
||||
"bzip2",
|
||||
"7z",
|
||||
"rar",
|
||||
"xz",
|
||||
"lz",
|
||||
"z",
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"dmg",
|
||||
"iso",
|
||||
"img",
|
||||
"vmdk",
|
||||
"ttf",
|
||||
"otf",
|
||||
"woff",
|
||||
"woff2",
|
||||
"eot",
|
||||
"sqlite",
|
||||
"db",
|
||||
"mdb",
|
||||
"apk",
|
||||
"ipa",
|
||||
"aab",
|
||||
"xapk",
|
||||
"app",
|
||||
"pkg",
|
||||
"deb",
|
||||
"rpm",
|
||||
"snap",
|
||||
"flatpak",
|
||||
"appimage",
|
||||
"msi",
|
||||
"msp",
|
||||
"jar",
|
||||
"war",
|
||||
"ear",
|
||||
"class",
|
||||
"kotlin_module",
|
||||
"dex",
|
||||
"vdex",
|
||||
"odex",
|
||||
"oat",
|
||||
"art",
|
||||
"wasm",
|
||||
"wat",
|
||||
"bc",
|
||||
"ll",
|
||||
"s",
|
||||
"ko",
|
||||
"sys",
|
||||
"drv",
|
||||
"efi",
|
||||
"rom",
|
||||
"com",
|
||||
"cmd",
|
||||
"ps1",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
])
|
||||
|
||||
const image = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"bmp",
|
||||
"webp",
|
||||
"ico",
|
||||
"tif",
|
||||
"tiff",
|
||||
"svg",
|
||||
"svgz",
|
||||
"avif",
|
||||
"apng",
|
||||
"jxl",
|
||||
"heic",
|
||||
"heif",
|
||||
"raw",
|
||||
"cr2",
|
||||
"nef",
|
||||
"arw",
|
||||
"dng",
|
||||
"orf",
|
||||
"raf",
|
||||
"pef",
|
||||
"x3f",
|
||||
])
|
||||
|
||||
const text = new Set([
|
||||
"ts",
|
||||
"tsx",
|
||||
"mts",
|
||||
"cts",
|
||||
"mtsx",
|
||||
"ctsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
"ps1",
|
||||
"psm1",
|
||||
"cmd",
|
||||
"bat",
|
||||
"json",
|
||||
"jsonc",
|
||||
"json5",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"md",
|
||||
"mdx",
|
||||
"txt",
|
||||
"xml",
|
||||
"html",
|
||||
"htm",
|
||||
"css",
|
||||
"scss",
|
||||
"sass",
|
||||
"less",
|
||||
"graphql",
|
||||
"gql",
|
||||
"sql",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
"env",
|
||||
])
|
||||
|
||||
const textName = new Set([
|
||||
"dockerfile",
|
||||
"makefile",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
".editorconfig",
|
||||
".npmrc",
|
||||
".nvmrc",
|
||||
".prettierrc",
|
||||
".eslintrc",
|
||||
])
|
||||
|
||||
const mime: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
bmp: "image/bmp",
|
||||
webp: "image/webp",
|
||||
ico: "image/x-icon",
|
||||
tif: "image/tiff",
|
||||
tiff: "image/tiff",
|
||||
svg: "image/svg+xml",
|
||||
svgz: "image/svg+xml",
|
||||
avif: "image/avif",
|
||||
apng: "image/apng",
|
||||
jxl: "image/jxl",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
}
|
||||
|
||||
type Entry = { files: string[]; dirs: string[] }
|
||||
|
||||
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
||||
const name = (file: string) => path.basename(file).toLowerCase()
|
||||
const isImageByExtension = (file: string) => image.has(ext(file))
|
||||
const isTextByExtension = (file: string) => text.has(ext(file))
|
||||
const isTextByName = (file: string) => textName.has(name(file))
|
||||
const isBinaryByExtension = (file: string) => binary.has(ext(file))
|
||||
const isImage = (mimeType: string) => mimeType.startsWith("image/")
|
||||
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
|
||||
|
||||
function shouldEncode(mimeType: string) {
|
||||
const type = mimeType.toLowerCase()
|
||||
log.info("shouldEncode", { type })
|
||||
if (!type) return false
|
||||
if (type.startsWith("text/")) return false
|
||||
if (type.includes("charset=")) return false
|
||||
const top = type.split("/", 2)[0]
|
||||
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
|
||||
}
|
||||
|
||||
const hidden = (item: string) => {
|
||||
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
|
||||
}
|
||||
|
||||
const sortHiddenLast = (items: string[], prefer: boolean) => {
|
||||
if (prefer) return items
|
||||
const visible: string[] = []
|
||||
const hiddenItems: string[] = []
|
||||
for (const item of items) {
|
||||
if (hidden(item)) hiddenItems.push(item)
|
||||
else visible.push(item)
|
||||
}
|
||||
return [...visible, ...hiddenItems]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<File.Info[]>
|
||||
readonly read: (file: string) => Effect.Effect<File.Content>
|
||||
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
|
||||
readonly search: (input: {
|
||||
query: string
|
||||
limit?: number
|
||||
dirs?: boolean
|
||||
type?: "file" | "directory"
|
||||
}) => Effect.Effect<string[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
let cache: Entry = { files: [], dirs: [] }
|
||||
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
|
||||
|
||||
const scan = Effect.fn("File.scan")(function* () {
|
||||
if (instance.directory === path.parse(instance.directory).root) return
|
||||
const next: Entry = { files: [], dirs: [] }
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
if (isGlobalHome) {
|
||||
const dirs = new Set<string>()
|
||||
const protectedNames = Protected.names()
|
||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||
const top = await fs.promises
|
||||
.readdir(instance.directory, { withFileTypes: true })
|
||||
.catch(() => [] as fs.Dirent[])
|
||||
|
||||
for (const entry of top) {
|
||||
if (!entry.isDirectory()) continue
|
||||
if (shouldIgnoreName(entry.name)) continue
|
||||
dirs.add(entry.name + "/")
|
||||
|
||||
const base = path.join(instance.directory, entry.name)
|
||||
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
||||
for (const child of children) {
|
||||
if (!child.isDirectory()) continue
|
||||
if (shouldIgnoreNested(child.name)) continue
|
||||
dirs.add(entry.name + "/" + child.name + "/")
|
||||
}
|
||||
}
|
||||
|
||||
next.dirs = Array.from(dirs).toSorted()
|
||||
} else {
|
||||
const seen = new Set<string>()
|
||||
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
|
||||
next.files.push(file)
|
||||
let current = file
|
||||
while (true) {
|
||||
const dir = path.dirname(current)
|
||||
if (dir === ".") break
|
||||
if (dir === current) break
|
||||
current = dir
|
||||
if (seen.has(dir)) continue
|
||||
seen.add(dir)
|
||||
next.dirs.push(dir + "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
cache = next
|
||||
})
|
||||
|
||||
const getFiles = () => cache
|
||||
|
||||
const scope = yield* Scope.Scope
|
||||
let fiber: Fiber.Fiber<void> | undefined
|
||||
|
||||
const init = Effect.fn("File.init")(function* () {
|
||||
if (!fiber) {
|
||||
fiber = yield* scan().pipe(
|
||||
Effect.catchCause(() => Effect.void),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
}
|
||||
yield* Fiber.join(fiber)
|
||||
})
|
||||
|
||||
const status = Effect.fn("File.status")(function* () {
|
||||
if (instance.project.vcs !== "git") return []
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const diffOutput = (
|
||||
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||
cwd: instance.directory,
|
||||
})
|
||||
).text()
|
||||
|
||||
const changed: File.Info[] = []
|
||||
|
||||
if (diffOutput.trim()) {
|
||||
for (const line of diffOutput.trim().split("\n")) {
|
||||
const [added, removed, file] = line.split("\t")
|
||||
changed.push({
|
||||
path: file,
|
||||
added: added === "-" ? 0 : parseInt(added, 10),
|
||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||
status: "modified",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const untrackedOutput = (
|
||||
await git(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
],
|
||||
{
|
||||
cwd: instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
|
||||
if (untrackedOutput.trim()) {
|
||||
for (const file of untrackedOutput.trim().split("\n")) {
|
||||
try {
|
||||
const content = await Filesystem.readText(path.join(instance.directory, file))
|
||||
changed.push({
|
||||
path: file,
|
||||
added: content.split("\n").length,
|
||||
removed: 0,
|
||||
status: "added",
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deletedOutput = (
|
||||
await git(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=D",
|
||||
"HEAD",
|
||||
],
|
||||
{
|
||||
cwd: instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
|
||||
if (deletedOutput.trim()) {
|
||||
for (const file of deletedOutput.trim().split("\n")) {
|
||||
changed.push({
|
||||
path: file,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
status: "deleted",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changed.map((item) => {
|
||||
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
|
||||
return {
|
||||
...item,
|
||||
path: path.relative(instance.directory, full),
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const read = Effect.fn("File.read")(function* (file: string) {
|
||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||
using _ = log.time("read", { file })
|
||||
const full = path.join(instance.directory, file)
|
||||
|
||||
if (!Instance.containsPath(full)) {
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
if (isImageByExtension(file)) {
|
||||
if (await Filesystem.exists(full)) {
|
||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||
return {
|
||||
type: "text",
|
||||
content: buffer.toString("base64"),
|
||||
mimeType: getImageMimeType(file),
|
||||
encoding: "base64",
|
||||
}
|
||||
}
|
||||
return { type: "text", content: "" }
|
||||
}
|
||||
|
||||
const knownText = isTextByExtension(file) || isTextByName(file)
|
||||
|
||||
if (isBinaryByExtension(file) && !knownText) {
|
||||
return { type: "binary", content: "" }
|
||||
}
|
||||
|
||||
if (!(await Filesystem.exists(full))) {
|
||||
return { type: "text", content: "" }
|
||||
}
|
||||
|
||||
const mimeType = Filesystem.mimeType(full)
|
||||
const encode = knownText ? false : shouldEncode(mimeType)
|
||||
|
||||
if (encode && !isImage(mimeType)) {
|
||||
return { type: "binary", content: "", mimeType }
|
||||
}
|
||||
|
||||
if (encode) {
|
||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||
return {
|
||||
type: "text",
|
||||
content: buffer.toString("base64"),
|
||||
mimeType,
|
||||
encoding: "base64",
|
||||
}
|
||||
}
|
||||
|
||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||
|
||||
if (instance.project.vcs === "git") {
|
||||
let diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
|
||||
).text()
|
||||
if (!diff.trim()) {
|
||||
diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||
cwd: instance.directory,
|
||||
})
|
||||
).text()
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
})
|
||||
return {
|
||||
type: "text",
|
||||
content,
|
||||
patch,
|
||||
diff: formatPatch(patch),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "text", content }
|
||||
})
|
||||
})
|
||||
|
||||
const list = Effect.fn("File.list")(function* (dir?: string) {
|
||||
return yield* Effect.promise(async () => {
|
||||
const exclude = [".git", ".DS_Store"]
|
||||
let ignored = (_: string) => false
|
||||
if (instance.project.vcs === "git") {
|
||||
const ig = ignore()
|
||||
const gitignore = path.join(instance.project.worktree, ".gitignore")
|
||||
if (await Filesystem.exists(gitignore)) {
|
||||
ig.add(await Filesystem.readText(gitignore))
|
||||
}
|
||||
const ignoreFile = path.join(instance.project.worktree, ".ignore")
|
||||
if (await Filesystem.exists(ignoreFile)) {
|
||||
ig.add(await Filesystem.readText(ignoreFile))
|
||||
}
|
||||
ignored = ig.ignores.bind(ig)
|
||||
}
|
||||
|
||||
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
|
||||
if (!Instance.containsPath(resolved)) {
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
const nodes: File.Node[] = []
|
||||
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
|
||||
if (exclude.includes(entry.name)) continue
|
||||
const absolute = path.join(resolved, entry.name)
|
||||
const file = path.relative(instance.directory, absolute)
|
||||
const type = entry.isDirectory() ? "directory" : "file"
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: file,
|
||||
absolute,
|
||||
type,
|
||||
ignored: ignored(type === "directory" ? file + "/" : file),
|
||||
})
|
||||
}
|
||||
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const search = Effect.fn("File.search")(function* (input: {
|
||||
query: string
|
||||
limit?: number
|
||||
dirs?: boolean
|
||||
type?: "file" | "directory"
|
||||
}) {
|
||||
return yield* Effect.promise(async () => {
|
||||
const query = input.query.trim()
|
||||
const limit = input.limit ?? 100
|
||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||
log.info("search", { query, kind })
|
||||
|
||||
const result = getFiles()
|
||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||
|
||||
if (!query) {
|
||||
if (kind === "file") return result.files.slice(0, limit)
|
||||
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||
}
|
||||
|
||||
const items =
|
||||
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
||||
|
||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||
|
||||
log.info("search", { query, kind, results: output.length })
|
||||
return output
|
||||
})
|
||||
})
|
||||
|
||||
log.info("init")
|
||||
return Service.of({ init, status, read, list, search })
|
||||
}),
|
||||
).pipe(Layer.fresh)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace FileTime {
|
||||
const log = Log.create({ service: "file.time" })
|
||||
|
||||
export type Stamp = {
|
||||
readonly read: Date
|
||||
readonly mtime: number | undefined
|
||||
readonly ctime: number | undefined
|
||||
readonly size: number | undefined
|
||||
}
|
||||
|
||||
const stamp = Effect.fnUntraced(function* (file: string) {
|
||||
const stat = Filesystem.stat(file)
|
||||
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
||||
return {
|
||||
read: yield* DateTime.nowAsDate,
|
||||
mtime: stat?.mtime?.getTime(),
|
||||
ctime: stat?.ctime?.getTime(),
|
||||
size,
|
||||
}
|
||||
})
|
||||
|
||||
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
||||
const value = reads.get(sessionID)
|
||||
if (value) return value
|
||||
|
||||
const next = new Map<string, Stamp>()
|
||||
reads.set(sessionID, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
||||
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||
const reads = new Map<SessionID, Map<string, Stamp>>()
|
||||
const locks = new Map<string, Semaphore.Semaphore>()
|
||||
|
||||
const getLock = (filepath: string) => {
|
||||
const lock = locks.get(filepath)
|
||||
if (lock) return lock
|
||||
|
||||
const next = Semaphore.makeUnsafe(1)
|
||||
locks.set(filepath, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
||||
log.info("read", { sessionID, file })
|
||||
session(reads, sessionID).set(file, yield* stamp(file))
|
||||
})
|
||||
|
||||
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
||||
return reads.get(sessionID)?.get(file)?.read
|
||||
})
|
||||
|
||||
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||
if (disableCheck) return
|
||||
|
||||
const time = reads.get(sessionID)?.get(filepath)
|
||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||
|
||||
const next = yield* stamp(filepath)
|
||||
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
||||
if (!changed) return
|
||||
|
||||
throw new Error(
|
||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||
)
|
||||
})
|
||||
|
||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
|
||||
})
|
||||
|
||||
return Service.of({ read, get, assert, withLock })
|
||||
}),
|
||||
).pipe(Layer.orDie, Layer.fresh)
|
||||
}
|
||||
@@ -1,28 +1,128 @@
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { FileTime as S } from "./time-service"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace FileTime {
|
||||
export type Stamp = S.Stamp
|
||||
const log = Log.create({ service: "file.time" })
|
||||
|
||||
export type Interface = S.Interface
|
||||
export type Stamp = {
|
||||
readonly read: Date
|
||||
readonly mtime: number | undefined
|
||||
readonly ctime: number | undefined
|
||||
readonly size: number | undefined
|
||||
}
|
||||
|
||||
export const Service = S.Service
|
||||
export const layer = S.layer
|
||||
const stamp = Effect.fnUntraced(function* (file: string) {
|
||||
const stat = Filesystem.stat(file)
|
||||
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
||||
return {
|
||||
read: yield* DateTime.nowAsDate,
|
||||
mtime: stat?.mtime?.getTime(),
|
||||
ctime: stat?.ctime?.getTime(),
|
||||
size,
|
||||
}
|
||||
})
|
||||
|
||||
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
||||
const value = reads.get(sessionID)
|
||||
if (value) return value
|
||||
|
||||
const next = new Map<string, Stamp>()
|
||||
reads.set(sessionID, next)
|
||||
return next
|
||||
}
|
||||
|
||||
interface State {
|
||||
reads: Map<SessionID, Map<string, Stamp>>
|
||||
locks: Map<string, Semaphore.Semaphore>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
||||
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("FileTime.state")(() =>
|
||||
Effect.succeed({
|
||||
reads: new Map<SessionID, Map<string, Stamp>>(),
|
||||
locks: new Map<string, Semaphore.Semaphore>(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
|
||||
const locks = (yield* InstanceState.get(state)).locks
|
||||
const lock = locks.get(filepath)
|
||||
if (lock) return lock
|
||||
|
||||
const next = Semaphore.makeUnsafe(1)
|
||||
locks.set(filepath, next)
|
||||
return next
|
||||
})
|
||||
|
||||
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
||||
const reads = (yield* InstanceState.get(state)).reads
|
||||
log.info("read", { sessionID, file })
|
||||
session(reads, sessionID).set(file, yield* stamp(file))
|
||||
})
|
||||
|
||||
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
||||
const reads = (yield* InstanceState.get(state)).reads
|
||||
return reads.get(sessionID)?.get(file)?.read
|
||||
})
|
||||
|
||||
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||
if (disableCheck) return
|
||||
|
||||
const reads = (yield* InstanceState.get(state)).reads
|
||||
const time = reads.get(sessionID)?.get(filepath)
|
||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||
|
||||
const next = yield* stamp(filepath)
|
||||
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
||||
if (!changed) return
|
||||
|
||||
throw new Error(
|
||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||
)
|
||||
})
|
||||
|
||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||
return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
|
||||
})
|
||||
|
||||
return Service.of({ read, get, assert, withLock })
|
||||
}),
|
||||
).pipe(Layer.orDie)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export function read(sessionID: SessionID, file: string) {
|
||||
return runPromiseInstance(S.Service.use((s) => s.read(sessionID, file)))
|
||||
return runPromise((s) => s.read(sessionID, file))
|
||||
}
|
||||
|
||||
export function get(sessionID: SessionID, file: string) {
|
||||
return runPromiseInstance(S.Service.use((s) => s.get(sessionID, file)))
|
||||
return runPromise((s) => s.get(sessionID, file))
|
||||
}
|
||||
|
||||
export async function assert(sessionID: SessionID, filepath: string) {
|
||||
return runPromiseInstance(S.Service.use((s) => s.assert(sessionID, filepath)))
|
||||
return runPromise((s) => s.assert(sessionID, filepath))
|
||||
}
|
||||
|
||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
||||
return runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn)))
|
||||
return runPromise((s) => s.withLock(filepath, fn))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
||||
import { Cause, Effect, Layer, Scope, ServiceMap } from "effect"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
@@ -7,7 +7,8 @@ import path from "path"
|
||||
import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { git } from "@/util/git"
|
||||
@@ -60,82 +61,107 @@ export namespace FileWatcher {
|
||||
|
||||
export const hasNativeBinding = () => !!watcher()
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("FileWatcher.state")(
|
||||
function* () {
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
|
||||
|
||||
log.info("init", { directory: instance.directory })
|
||||
log.info("init", { directory: Instance.directory })
|
||||
|
||||
const backend = getBackend()
|
||||
if (!backend) {
|
||||
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
|
||||
return Service.of({})
|
||||
}
|
||||
const backend = getBackend()
|
||||
if (!backend) {
|
||||
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
|
||||
return
|
||||
}
|
||||
|
||||
const w = watcher()
|
||||
if (!w) return Service.of({})
|
||||
const w = watcher()
|
||||
if (!w) return
|
||||
|
||||
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
|
||||
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
|
||||
|
||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))))
|
||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
|
||||
)
|
||||
|
||||
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||
}
|
||||
})
|
||||
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||
}
|
||||
})
|
||||
|
||||
const subscribe = (dir: string, ignore: string[]) => {
|
||||
const pending = w.subscribe(dir, cb, { ignore, backend })
|
||||
return Effect.gen(function* () {
|
||||
const sub = yield* Effect.promise(() => pending)
|
||||
subs.push(sub)
|
||||
}).pipe(
|
||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||
const subscribe = (dir: string, ignore: string[]) => {
|
||||
const pending = w.subscribe(dir, cb, { ignore, backend })
|
||||
return Effect.gen(function* () {
|
||||
const sub = yield* Effect.promise(() => pending)
|
||||
subs.push(sub)
|
||||
}).pipe(
|
||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
yield* subscribe(Instance.directory, [
|
||||
...FileIgnore.PATTERNS,
|
||||
...cfgIgnores,
|
||||
...protecteds(Instance.directory),
|
||||
])
|
||||
}
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
const result = yield* Effect.promise(() =>
|
||||
git(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.project.worktree,
|
||||
}),
|
||||
)
|
||||
const vcsDir =
|
||||
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||
(entry) => entry !== "HEAD",
|
||||
)
|
||||
yield* subscribe(vcsDir, ignore)
|
||||
}
|
||||
}
|
||||
},
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
return Service.of({
|
||||
init: Effect.fn("FileWatcher.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)])
|
||||
}
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
if (instance.project.vcs === "git") {
|
||||
const result = yield* Effect.promise(() =>
|
||||
git(["rev-parse", "--git-dir"], {
|
||||
cwd: instance.project.worktree,
|
||||
}),
|
||||
)
|
||||
const vcsDir = result.exitCode === 0 ? path.resolve(instance.project.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||
(entry) => entry !== "HEAD",
|
||||
)
|
||||
yield* subscribe(vcsDir, ignore)
|
||||
}
|
||||
}
|
||||
|
||||
return Service.of({})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||
return Effect.succeed(Service.of({}))
|
||||
}),
|
||||
),
|
||||
).pipe(Layer.orDie, Layer.fresh)
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||
export const OPENCODE_DB = process.env["OPENCODE_DB"]
|
||||
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
|
||||
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
|
||||
export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS")
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
import { text } from "node:stream/consumers"
|
||||
import { BunProc } from "../bun"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export interface Info {
|
||||
name: string
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
extensions: string[]
|
||||
enabled(): Promise<boolean>
|
||||
enabled(): Promise<string[] | false>
|
||||
}
|
||||
|
||||
export const gofmt: Info = {
|
||||
name: "gofmt",
|
||||
command: ["gofmt", "-w", "$FILE"],
|
||||
extensions: [".go"],
|
||||
async enabled() {
|
||||
return which("gofmt") !== null
|
||||
const p = which("gofmt")
|
||||
if (p === null) return false
|
||||
return [p, "-w", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const mix: Info = {
|
||||
name: "mix",
|
||||
command: ["mix", "format", "$FILE"],
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
async enabled() {
|
||||
return which("mix") !== null
|
||||
const p = which("mix")
|
||||
if (p === null) return false
|
||||
return [p, "format", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const prettier: Info = {
|
||||
name: "prettier",
|
||||
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
extensions: [
|
||||
".js",
|
||||
".jsx",
|
||||
@@ -73,8 +70,11 @@ export const prettier: Info = {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.dependencies?.prettier) return true
|
||||
if (json.devDependencies?.prettier) return true
|
||||
if (json.dependencies?.prettier || json.devDependencies?.prettier) {
|
||||
const bin = await Npm.which("prettier").catch(() => null)
|
||||
if (!bin) return false
|
||||
return [bin, "--write", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -82,10 +82,6 @@ export const prettier: Info = {
|
||||
|
||||
export const oxfmt: Info = {
|
||||
name: "oxfmt",
|
||||
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
|
||||
async enabled() {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_OXFMT) return false
|
||||
@@ -95,8 +91,11 @@ export const oxfmt: Info = {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.dependencies?.oxfmt) return true
|
||||
if (json.devDependencies?.oxfmt) return true
|
||||
if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) {
|
||||
const bin = await Npm.which("oxfmt")
|
||||
if (!bin) return false
|
||||
return [bin, "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -104,10 +103,6 @@ export const oxfmt: Info = {
|
||||
|
||||
export const biome: Info = {
|
||||
name: "biome",
|
||||
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
extensions: [
|
||||
".js",
|
||||
".jsx",
|
||||
@@ -141,7 +136,9 @@ export const biome: Info = {
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
return true
|
||||
const bin = await Npm.which("@biomejs/biome")
|
||||
if (!bin) return false
|
||||
return [bin, "check", "--write", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -150,47 +147,49 @@ export const biome: Info = {
|
||||
|
||||
export const zig: Info = {
|
||||
name: "zig",
|
||||
command: ["zig", "fmt", "$FILE"],
|
||||
extensions: [".zig", ".zon"],
|
||||
async enabled() {
|
||||
return which("zig") !== null
|
||||
const p = which("zig")
|
||||
if (p === null) return false
|
||||
return [p, "fmt", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const clang: Info = {
|
||||
name: "clang-format",
|
||||
command: ["clang-format", "-i", "$FILE"],
|
||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||
async enabled() {
|
||||
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
|
||||
return items.length > 0
|
||||
if (items.length === 0) return false
|
||||
return ["clang-format", "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const ktlint: Info = {
|
||||
name: "ktlint",
|
||||
command: ["ktlint", "-F", "$FILE"],
|
||||
extensions: [".kt", ".kts"],
|
||||
async enabled() {
|
||||
return which("ktlint") !== null
|
||||
const p = which("ktlint")
|
||||
if (p === null) return false
|
||||
return [p, "-F", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const ruff: Info = {
|
||||
name: "ruff",
|
||||
command: ["ruff", "format", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (!which("ruff")) return false
|
||||
const p = which("ruff")
|
||||
if (p === null) return false
|
||||
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
if (config === "pyproject.toml") {
|
||||
const content = await Filesystem.readText(found[0])
|
||||
if (content.includes("[tool.ruff]")) return true
|
||||
if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"]
|
||||
} else {
|
||||
return true
|
||||
return [p, "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,7 +198,7 @@ export const ruff: Info = {
|
||||
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
const content = await Filesystem.readText(found[0])
|
||||
if (content.includes("ruff")) return true
|
||||
if (content.includes("ruff")) return [p, "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -208,14 +207,13 @@ export const ruff: Info = {
|
||||
|
||||
export const rlang: Info = {
|
||||
name: "air",
|
||||
command: ["air", "format", "$FILE"],
|
||||
extensions: [".R"],
|
||||
async enabled() {
|
||||
const airPath = which("air")
|
||||
if (airPath == null) return false
|
||||
|
||||
try {
|
||||
const proc = Process.spawn(["air", "--help"], {
|
||||
const proc = Process.spawn([airPath, "--help"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
@@ -227,7 +225,10 @@ export const rlang: Info = {
|
||||
const firstLine = output.split("\n")[0]
|
||||
const hasR = firstLine.includes("R language")
|
||||
const hasFormatter = firstLine.includes("formatter")
|
||||
return hasR && hasFormatter
|
||||
if (hasR && hasFormatter) {
|
||||
return [airPath, "format", "$FILE"]
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
@@ -236,14 +237,14 @@ export const rlang: Info = {
|
||||
|
||||
export const uvformat: Info = {
|
||||
name: "uv",
|
||||
command: ["uv", "format", "--", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (await ruff.enabled()) return false
|
||||
if (which("uv") !== null) {
|
||||
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
const uvPath = which("uv")
|
||||
if (uvPath !== null) {
|
||||
const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
const code = await proc.exited
|
||||
return code === 0
|
||||
if (code === 0) return [uvPath, "format", "--", "$FILE"]
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -251,108 +252,118 @@ export const uvformat: Info = {
|
||||
|
||||
export const rubocop: Info = {
|
||||
name: "rubocop",
|
||||
command: ["rubocop", "--autocorrect", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
return which("rubocop") !== null
|
||||
const path = which("rubocop")
|
||||
if (path === null) return false
|
||||
return [path, "--autocorrect", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const standardrb: Info = {
|
||||
name: "standardrb",
|
||||
command: ["standardrb", "--fix", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
return which("standardrb") !== null
|
||||
const path = which("standardrb")
|
||||
if (path === null) return false
|
||||
return [path, "--fix", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const htmlbeautifier: Info = {
|
||||
name: "htmlbeautifier",
|
||||
command: ["htmlbeautifier", "$FILE"],
|
||||
extensions: [".erb", ".html.erb"],
|
||||
async enabled() {
|
||||
return which("htmlbeautifier") !== null
|
||||
const path = which("htmlbeautifier")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const dart: Info = {
|
||||
name: "dart",
|
||||
command: ["dart", "format", "$FILE"],
|
||||
extensions: [".dart"],
|
||||
async enabled() {
|
||||
return which("dart") !== null
|
||||
const path = which("dart")
|
||||
if (path === null) return false
|
||||
return [path, "format", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const ocamlformat: Info = {
|
||||
name: "ocamlformat",
|
||||
command: ["ocamlformat", "-i", "$FILE"],
|
||||
extensions: [".ml", ".mli"],
|
||||
async enabled() {
|
||||
if (!which("ocamlformat")) return false
|
||||
const path = which("ocamlformat")
|
||||
if (!path) return false
|
||||
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
|
||||
return items.length > 0
|
||||
if (items.length === 0) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const terraform: Info = {
|
||||
name: "terraform",
|
||||
command: ["terraform", "fmt", "$FILE"],
|
||||
extensions: [".tf", ".tfvars"],
|
||||
async enabled() {
|
||||
return which("terraform") !== null
|
||||
const path = which("terraform")
|
||||
if (path === null) return false
|
||||
return [path, "fmt", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const latexindent: Info = {
|
||||
name: "latexindent",
|
||||
command: ["latexindent", "-w", "-s", "$FILE"],
|
||||
extensions: [".tex"],
|
||||
async enabled() {
|
||||
return which("latexindent") !== null
|
||||
const path = which("latexindent")
|
||||
if (path === null) return false
|
||||
return [path, "-w", "-s", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const gleam: Info = {
|
||||
name: "gleam",
|
||||
command: ["gleam", "format", "$FILE"],
|
||||
extensions: [".gleam"],
|
||||
async enabled() {
|
||||
return which("gleam") !== null
|
||||
const path = which("gleam")
|
||||
if (path === null) return false
|
||||
return [path, "format", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const shfmt: Info = {
|
||||
name: "shfmt",
|
||||
command: ["shfmt", "-w", "$FILE"],
|
||||
extensions: [".sh", ".bash"],
|
||||
async enabled() {
|
||||
return which("shfmt") !== null
|
||||
const path = which("shfmt")
|
||||
if (path === null) return false
|
||||
return [path, "-w", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const nixfmt: Info = {
|
||||
name: "nixfmt",
|
||||
command: ["nixfmt", "$FILE"],
|
||||
extensions: [".nix"],
|
||||
async enabled() {
|
||||
return which("nixfmt") !== null
|
||||
const path = which("nixfmt")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const rustfmt: Info = {
|
||||
name: "rustfmt",
|
||||
command: ["rustfmt", "$FILE"],
|
||||
extensions: [".rs"],
|
||||
async enabled() {
|
||||
return which("rustfmt") !== null
|
||||
const path = which("rustfmt")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const pint: Info = {
|
||||
name: "pint",
|
||||
command: ["./vendor/bin/pint", "$FILE"],
|
||||
extensions: [".php"],
|
||||
async enabled() {
|
||||
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
|
||||
@@ -361,8 +372,9 @@ export const pint: Info = {
|
||||
require?: Record<string, string>
|
||||
"require-dev"?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.require?.["laravel/pint"]) return true
|
||||
if (json["require-dev"]?.["laravel/pint"]) return true
|
||||
if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) {
|
||||
return ["./vendor/bin/pint", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -370,27 +382,30 @@ export const pint: Info = {
|
||||
|
||||
export const ormolu: Info = {
|
||||
name: "ormolu",
|
||||
command: ["ormolu", "-i", "$FILE"],
|
||||
extensions: [".hs"],
|
||||
async enabled() {
|
||||
return which("ormolu") !== null
|
||||
const path = which("ormolu")
|
||||
if (path === null) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const cljfmt: Info = {
|
||||
name: "cljfmt",
|
||||
command: ["cljfmt", "fix", "--quiet", "$FILE"],
|
||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||
async enabled() {
|
||||
return which("cljfmt") !== null
|
||||
const path = which("cljfmt")
|
||||
if (path === null) return false
|
||||
return [path, "fix", "--quiet", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const dfmt: Info = {
|
||||
name: "dfmt",
|
||||
command: ["dfmt", "-i", "$FILE"],
|
||||
extensions: [".d"],
|
||||
async enabled() {
|
||||
return which("dfmt") !== null
|
||||
const path = which("dfmt")
|
||||
if (path === null) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,16 +1,185 @@
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Format as S } from "./service"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import path from "path"
|
||||
import { mergeDeep } from "remeda"
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Config } from "../config/config"
|
||||
import { File } from "../file"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Process } from "../util/process"
|
||||
import { Log } from "../util/log"
|
||||
import * as Formatter from "./formatter"
|
||||
|
||||
export namespace Format {
|
||||
export const Status = S.Status
|
||||
export type Status = S.Status
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
export type Interface = S.Interface
|
||||
export const Status = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
extensions: z.string().array(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FormatterStatus",
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
export const Service = S.Service
|
||||
export const layer = S.layer
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Format.state")(function* (_ctx) {
|
||||
const enabled: Record<string, string[] | false> = {}
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
|
||||
if (cfg.formatter !== false) {
|
||||
for (const item of Object.values(Formatter)) {
|
||||
formatters[item.name] = item
|
||||
}
|
||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||
if (item.disabled) {
|
||||
delete formatters[name]
|
||||
continue
|
||||
}
|
||||
const info = mergeDeep(formatters[name] ?? {}, {
|
||||
command: [],
|
||||
extensions: [],
|
||||
...item,
|
||||
})
|
||||
|
||||
if (info.command.length === 0) continue
|
||||
|
||||
formatters[name] = {
|
||||
...info,
|
||||
name,
|
||||
enabled: async () => info.command,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.info("all formatters are disabled")
|
||||
}
|
||||
|
||||
async function isEnabled(item: Formatter.Info) {
|
||||
let status = enabled[item.name]
|
||||
if (status === undefined) {
|
||||
status = await item.enabled()
|
||||
enabled[item.name] = status
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const result: Array<{
|
||||
name: string
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
}> = []
|
||||
for (const item of Object.values(formatters)) {
|
||||
log.info("checking", { name: item.name, ext })
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
const cmd = await isEnabled(item)
|
||||
if (!cmd) continue
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
result.push({
|
||||
name: item.name,
|
||||
command: cmd,
|
||||
environment: item.environment,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
File.Event.Edited,
|
||||
Instance.bind(async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
)
|
||||
log.info("init")
|
||||
|
||||
return {
|
||||
formatters,
|
||||
isEnabled,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const init = Effect.fn("Format.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
})
|
||||
|
||||
const status = Effect.fn("Format.status")(function* () {
|
||||
const { formatters, isEnabled } = yield* InstanceState.get(state)
|
||||
const result: Status[] = []
|
||||
for (const formatter of Object.values(formatters)) {
|
||||
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
||||
result.push({
|
||||
name: formatter.name,
|
||||
extensions: formatter.extensions,
|
||||
enabled: !!isOn,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return Service.of({ init, status })
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((s) => s.init())
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return runPromiseInstance(S.Service.use((s) => s.status()))
|
||||
return runPromise((s) => s.status())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import path from "path"
|
||||
import { mergeDeep } from "remeda"
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Config } from "../config/config"
|
||||
import { File } from "../file/service"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Process } from "../util/process"
|
||||
import { Log } from "../util/log"
|
||||
import * as Formatter from "./formatter"
|
||||
|
||||
export namespace Format {
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
export const Status = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
extensions: z.string().array(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FormatterStatus",
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
export interface Interface {
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
|
||||
const enabled: Record<string, boolean> = {}
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
|
||||
if (cfg.formatter !== false) {
|
||||
for (const item of Object.values(Formatter)) {
|
||||
formatters[item.name] = item
|
||||
}
|
||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||
if (item.disabled) {
|
||||
delete formatters[name]
|
||||
continue
|
||||
}
|
||||
const info = mergeDeep(formatters[name] ?? {}, {
|
||||
command: [],
|
||||
extensions: [],
|
||||
...item,
|
||||
})
|
||||
|
||||
if (info.command.length === 0) continue
|
||||
|
||||
formatters[name] = {
|
||||
...info,
|
||||
name,
|
||||
enabled: async () => true,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.info("all formatters are disabled")
|
||||
}
|
||||
|
||||
async function isEnabled(item: Formatter.Info) {
|
||||
let status = enabled[item.name]
|
||||
if (status === undefined) {
|
||||
status = await item.enabled()
|
||||
enabled[item.name] = status
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const result = []
|
||||
for (const item of Object.values(formatters)) {
|
||||
log.info("checking", { name: item.name, ext })
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
if (!(await isEnabled(item))) continue
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
result.push(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
File.Event.Edited,
|
||||
Instance.bind(async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
{
|
||||
cwd: instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
)
|
||||
log.info("init")
|
||||
|
||||
const status = Effect.fn("Format.status")(function* () {
|
||||
const result: Status[] = []
|
||||
for (const formatter of Object.values(formatters)) {
|
||||
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
||||
result.push({
|
||||
name: formatter.name,
|
||||
extensions: formatter.extensions,
|
||||
enabled: isOn,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return Service.of({ status })
|
||||
}),
|
||||
).pipe(Layer.fresh)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
@@ -293,7 +294,7 @@ export namespace Installation {
|
||||
result = yield* run(["scoop", "install", `opencode@${target}`])
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown method: ${m}`)
|
||||
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
|
||||
}
|
||||
if (!result || result.code !== 0) {
|
||||
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
|
||||
@@ -329,27 +330,21 @@ export namespace Installation {
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
// Legacy adapters — dynamic import avoids circular dependency since
|
||||
// foundational modules (db.ts, provider/models.ts) import Installation
|
||||
// at load time, and runtime transitively loads those same modules.
|
||||
async function runPromise<A>(f: (service: Interface) => Effect.Effect<A, any>) {
|
||||
const { runtime } = await import("@/effect/runtime")
|
||||
return runtime.runPromise(Service.use(f))
|
||||
}
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export function info(): Promise<Info> {
|
||||
export async function info(): Promise<Info> {
|
||||
return runPromise((svc) => svc.info())
|
||||
}
|
||||
|
||||
export function method(): Promise<Method> {
|
||||
export async function method(): Promise<Method> {
|
||||
return runPromise((svc) => svc.method())
|
||||
}
|
||||
|
||||
export function latest(installMethod?: Method): Promise<string> {
|
||||
export async function latest(installMethod?: Method): Promise<string> {
|
||||
return runPromise((svc) => svc.latest(installMethod))
|
||||
}
|
||||
|
||||
export function upgrade(m: Method, target: string): Promise<void> {
|
||||
export async function upgrade(m: Method, target: string): Promise<void> {
|
||||
return runPromise((svc) => svc.upgrade(m, target))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "path"
|
||||
import os from "os"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { text } from "node:stream/consumers"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -14,6 +13,7 @@ import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Module } from "@opencode-ai/util/module"
|
||||
import { spawn } from "./launch"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
@@ -103,11 +103,12 @@ export namespace LSPServer {
|
||||
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
|
||||
log.info("typescript server", { tsserver })
|
||||
if (!tsserver) return
|
||||
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
|
||||
const bin = await Npm.which("typescript-language-server")
|
||||
if (!bin) return
|
||||
const proc = spawn(bin, ["--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -129,36 +130,16 @@ export namespace LSPServer {
|
||||
let binary = which("vue-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(
|
||||
Global.Path.bin,
|
||||
"node_modules",
|
||||
"@vue",
|
||||
"language-server",
|
||||
"bin",
|
||||
"vue-language-server.js",
|
||||
)
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("@vue/language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -214,11 +195,10 @@ export namespace LSPServer {
|
||||
log.info("installed VS Code ESLint server", { serverPath })
|
||||
}
|
||||
|
||||
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
|
||||
const proc = spawn("node", [serverPath, "--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -345,15 +325,15 @@ export namespace LSPServer {
|
||||
if (!bin) {
|
||||
const resolved = Module.resolve("biome", root)
|
||||
if (!resolved) return
|
||||
bin = BunProc.which()
|
||||
args = ["x", "biome", "lsp-proxy", "--stdio"]
|
||||
bin = await Npm.which("biome")
|
||||
if (!bin) return
|
||||
args = ["lsp-proxy", "--stdio"]
|
||||
}
|
||||
|
||||
const proc = spawn(bin, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -372,9 +352,7 @@ export namespace LSPServer {
|
||||
},
|
||||
extensions: [".go"],
|
||||
async spawn(root) {
|
||||
let bin = which("gopls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("gopls")
|
||||
if (!bin) {
|
||||
if (!which("go")) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -409,9 +387,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot(["Gemfile"]),
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async spawn(root) {
|
||||
let bin = which("rubocop", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("rubocop")
|
||||
if (!bin) {
|
||||
const ruby = which("ruby")
|
||||
const gem = which("gem")
|
||||
@@ -516,19 +492,10 @@ export namespace LSPServer {
|
||||
let binary = which("pyright-langserver")
|
||||
const args = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "pyright"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push(...["run", js])
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("pyright")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("--stdio")
|
||||
|
||||
@@ -552,7 +519,6 @@ export namespace LSPServer {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -630,9 +596,7 @@ export namespace LSPServer {
|
||||
extensions: [".zig", ".zon"],
|
||||
root: NearestRoot(["build.zig"]),
|
||||
async spawn(root) {
|
||||
let bin = which("zls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("zls")
|
||||
|
||||
if (!bin) {
|
||||
const zig = which("zig")
|
||||
@@ -742,9 +706,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".cs"],
|
||||
async spawn(root) {
|
||||
let bin = which("csharp-ls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("csharp-ls")
|
||||
if (!bin) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install csharp-ls")
|
||||
@@ -781,9 +743,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
||||
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
||||
async spawn(root) {
|
||||
let bin = which("fsautocomplete", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("fsautocomplete")
|
||||
if (!bin) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install fsautocomplete")
|
||||
@@ -1049,29 +1009,16 @@ export namespace LSPServer {
|
||||
let binary = which("svelteserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("svelte-language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1096,29 +1043,16 @@ export namespace LSPServer {
|
||||
let binary = which("astro-ls")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("@astrojs/language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1360,38 +1294,16 @@ export namespace LSPServer {
|
||||
let binary = which("yaml-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(
|
||||
Global.Path.bin,
|
||||
"node_modules",
|
||||
"yaml-language-server",
|
||||
"out",
|
||||
"server",
|
||||
"src",
|
||||
"server.js",
|
||||
)
|
||||
const exists = await Filesystem.exists(js)
|
||||
if (!exists) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("yaml-language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1413,9 +1325,7 @@ export namespace LSPServer {
|
||||
]),
|
||||
extensions: [".lua"],
|
||||
async spawn(root) {
|
||||
let bin = which("lua-language-server", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("lua-language-server")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1551,29 +1461,16 @@ export namespace LSPServer {
|
||||
let binary = which("intelephense")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "intelephense"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("intelephense")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1648,29 +1545,16 @@ export namespace LSPServer {
|
||||
let binary = which("bash-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("bash-language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("start")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1684,9 +1568,7 @@ export namespace LSPServer {
|
||||
extensions: [".tf", ".tfvars"],
|
||||
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
|
||||
async spawn(root) {
|
||||
let bin = which("terraform-ls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("terraform-ls")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1767,9 +1649,7 @@ export namespace LSPServer {
|
||||
extensions: [".tex", ".bib"],
|
||||
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
|
||||
async spawn(root) {
|
||||
let bin = which("texlab", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("texlab")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1860,29 +1740,16 @@ export namespace LSPServer {
|
||||
let binary = which("docker-langserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("dockerfile-language-server-nodejs")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1966,9 +1833,7 @@ export namespace LSPServer {
|
||||
extensions: [".typ", ".typc"],
|
||||
root: NearestRoot(["typst.toml"]),
|
||||
async spawn(root) {
|
||||
let bin = which("tinymist", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("tinymist")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
|
||||
@@ -455,9 +455,8 @@ export namespace MCP {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||
...mcp.environment,
|
||||
},
|
||||
} as any,
|
||||
})
|
||||
transport.stderr?.on("data", (chunk: Buffer) => {
|
||||
log.info(`mcp stderr: ${chunk.toString()}`, { key })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createConnection } from "net"
|
||||
import { createServer } from "http"
|
||||
import { Log } from "../util/log"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
|
||||
@@ -52,11 +53,74 @@ interface PendingAuth {
|
||||
}
|
||||
|
||||
export namespace McpOAuthCallback {
|
||||
let server: ReturnType<typeof Bun.serve> | undefined
|
||||
let server: ReturnType<typeof createServer> | undefined
|
||||
const pendingAuths = new Map<string, PendingAuth>()
|
||||
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
return
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
log.info("received oauth callback", { hasCode: !!code, state, error })
|
||||
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR("No authorization code provided"))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)!
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.resolve(code)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
}
|
||||
|
||||
export async function ensureRunning(): Promise<void> {
|
||||
if (server) return
|
||||
|
||||
@@ -66,75 +130,14 @@ export namespace McpOAuthCallback {
|
||||
return
|
||||
}
|
||||
|
||||
server = Bun.serve({
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
log.info("received oauth callback", { hasCode: !!code, state, error })
|
||||
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return new Response(HTML_ERROR("No authorization code provided"), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)!
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.resolve(code)
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
},
|
||||
server = createServer(handleRequest)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server!.listen(OAUTH_CALLBACK_PORT, () => {
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
resolve()
|
||||
})
|
||||
server!.on("error", reject)
|
||||
})
|
||||
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
}
|
||||
|
||||
export function waitForCallback(oauthState: string): Promise<string> {
|
||||
@@ -174,7 +177,7 @@ export namespace McpOAuthCallback {
|
||||
|
||||
export async function stop(): Promise<void> {
|
||||
if (server) {
|
||||
server.stop()
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()))
|
||||
server = undefined
|
||||
log.info("oauth callback server stopped")
|
||||
}
|
||||
|
||||
181
packages/opencode/src/npm/index.ts
Normal file
181
packages/opencode/src/npm/index.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// Workaround: Bun on Windows does not support the UV_FS_O_FILEMAP flag that
|
||||
// the `tar` package uses for files < 512KB (fs.open returns EINVAL).
|
||||
// tar silently swallows the error and skips writing files, leaving only empty
|
||||
// directories. Setting __FAKE_PLATFORM__ makes tar fall back to the plain 'w'
|
||||
// flag. See tar's get-write-flag.js.
|
||||
// Must be set before @npmcli/arborist is imported since tar caches the flag
|
||||
// at module evaluation time — so we use a dynamic import() below.
|
||||
if (process.platform === "win32") {
|
||||
process.env.__FAKE_PLATFORM__ = "linux"
|
||||
}
|
||||
|
||||
import semver from "semver"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Global } from "../global"
|
||||
import { Lock } from "../util/lock"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { readdir, rm } from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
const { Arborist } = await import("@npmcli/arborist")
|
||||
|
||||
export namespace Npm {
|
||||
const log = Log.create({ service: "npm" })
|
||||
|
||||
export const InstallFailedError = NamedError.create(
|
||||
"NpmInstallFailedError",
|
||||
z.object({
|
||||
pkg: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
function directory(pkg: string) {
|
||||
return path.join(Global.Path.cache, "packages", pkg)
|
||||
}
|
||||
|
||||
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
|
||||
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
|
||||
if (!response.ok) {
|
||||
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
||||
return false
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
|
||||
const latestVersion = data?.["dist-tags"]?.latest
|
||||
if (!latestVersion) {
|
||||
log.warn("No latest version found, using cached", { pkg, cachedVersion })
|
||||
return false
|
||||
}
|
||||
|
||||
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (range) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
}
|
||||
|
||||
export async function add(pkg: string) {
|
||||
using _ = await Lock.write(`npm-install:${pkg}`)
|
||||
log.info("installing package", {
|
||||
pkg,
|
||||
})
|
||||
const dir = directory(pkg)
|
||||
|
||||
const arborist = new Arborist({
|
||||
path: dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
savePrefix: "",
|
||||
})
|
||||
const tree = await arborist.loadVirtual().catch(() => {})
|
||||
if (tree) {
|
||||
const first = tree.edgesOut.values().next().value?.to
|
||||
if (first) {
|
||||
log.info("package already installed", { pkg })
|
||||
return first.path
|
||||
}
|
||||
}
|
||||
|
||||
const result = await arborist
|
||||
.reify({
|
||||
add: [pkg],
|
||||
save: true,
|
||||
saveType: "prod",
|
||||
})
|
||||
.catch((cause) => {
|
||||
throw new InstallFailedError(
|
||||
{ pkg },
|
||||
{
|
||||
cause,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const first = result.edgesOut.values().next().value?.to
|
||||
if (!first) throw new InstallFailedError({ pkg })
|
||||
return first.path
|
||||
}
|
||||
|
||||
export async function install(dir: string) {
|
||||
using _ = await Lock.write(`npm-install:${dir}`)
|
||||
log.info("checking dependencies", { dir })
|
||||
|
||||
const reify = async () => {
|
||||
const arb = new Arborist({
|
||||
path: dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
savePrefix: "",
|
||||
})
|
||||
await arb.reify().catch(() => {})
|
||||
}
|
||||
|
||||
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
|
||||
log.info("node_modules missing, reifying")
|
||||
await reify()
|
||||
return
|
||||
}
|
||||
|
||||
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
|
||||
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
|
||||
|
||||
const declared = new Set([
|
||||
...Object.keys(pkg.dependencies || {}),
|
||||
...Object.keys(pkg.devDependencies || {}),
|
||||
...Object.keys(pkg.peerDependencies || {}),
|
||||
...Object.keys(pkg.optionalDependencies || {}),
|
||||
])
|
||||
|
||||
const root = lock.packages?.[""] || {}
|
||||
const locked = new Set([
|
||||
...Object.keys(root.dependencies || {}),
|
||||
...Object.keys(root.devDependencies || {}),
|
||||
...Object.keys(root.peerDependencies || {}),
|
||||
...Object.keys(root.optionalDependencies || {}),
|
||||
])
|
||||
|
||||
for (const name of declared) {
|
||||
if (!locked.has(name)) {
|
||||
log.info("dependency not in lock file, reifying", { name })
|
||||
await reify()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.info("dependencies in sync")
|
||||
}
|
||||
|
||||
export async function which(pkg: string) {
|
||||
const dir = directory(pkg)
|
||||
const binDir = path.join(dir, "node_modules", ".bin")
|
||||
|
||||
const pick = async () => {
|
||||
const files = await readdir(binDir).catch(() => [])
|
||||
if (files.length === 0) return undefined
|
||||
if (files.length === 1) return files[0]
|
||||
// Multiple binaries — resolve from package.json bin field like npx does
|
||||
const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
|
||||
path.join(dir, "node_modules", pkg, "package.json"),
|
||||
).catch(() => undefined)
|
||||
if (pkgJson?.bin) {
|
||||
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
|
||||
const bin = pkgJson.bin
|
||||
if (typeof bin === "string") return unscoped
|
||||
const keys = Object.keys(bin)
|
||||
if (keys.length === 1) return keys[0]
|
||||
return bin[unscoped] ? unscoped : keys[0]
|
||||
}
|
||||
return files[0]
|
||||
}
|
||||
|
||||
const bin = await pick()
|
||||
if (bin) return path.join(binDir, bin)
|
||||
|
||||
await rm(path.join(dir, "package-lock.json"), { force: true })
|
||||
await add(pkg)
|
||||
const resolved = await pick()
|
||||
if (!resolved) return
|
||||
return path.join(binDir, resolved)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,322 @@
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { Log } from "@/util/log"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import { Permission as S } from "./service"
|
||||
import { evaluate as evalRule } from "./evaluate"
|
||||
import { PermissionID } from "./schema"
|
||||
|
||||
export namespace PermissionNext {
|
||||
export const Action = S.Action
|
||||
export type Action = S.Action
|
||||
export namespace Permission {
|
||||
const log = Log.create({ service: "permission" })
|
||||
|
||||
export const Rule = S.Rule
|
||||
export type Rule = S.Rule
|
||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||
ref: "PermissionAction",
|
||||
})
|
||||
export type Action = z.infer<typeof Action>
|
||||
|
||||
export const Ruleset = S.Ruleset
|
||||
export type Ruleset = S.Ruleset
|
||||
export const Rule = z
|
||||
.object({
|
||||
permission: z.string(),
|
||||
pattern: z.string(),
|
||||
action: Action,
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRule",
|
||||
})
|
||||
export type Rule = z.infer<typeof Rule>
|
||||
|
||||
export const Request = S.Request
|
||||
export type Request = S.Request
|
||||
export const Ruleset = Rule.array().meta({
|
||||
ref: "PermissionRuleset",
|
||||
})
|
||||
export type Ruleset = z.infer<typeof Ruleset>
|
||||
|
||||
export const Reply = S.Reply
|
||||
export type Reply = S.Reply
|
||||
export const Request = z
|
||||
.object({
|
||||
id: PermissionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
permission: z.string(),
|
||||
patterns: z.string().array(),
|
||||
metadata: z.record(z.string(), z.any()),
|
||||
always: z.string().array(),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRequest",
|
||||
})
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Approval = S.Approval
|
||||
export type Approval = z.infer<typeof S.Approval>
|
||||
export const Reply = z.enum(["once", "always", "reject"])
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Event = S.Event
|
||||
export const Approval = z.object({
|
||||
projectID: ProjectID.zod,
|
||||
patterns: z.string().array(),
|
||||
})
|
||||
|
||||
export const RejectedError = S.RejectedError
|
||||
export const CorrectedError = S.CorrectedError
|
||||
export const DeniedError = S.DeniedError
|
||||
export type Error = S.Error
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("permission.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"permission.replied",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export const AskInput = S.AskInput
|
||||
export const ReplyInput = S.ReplyInput
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user rejected permission to use this specific tool call."
|
||||
}
|
||||
}
|
||||
|
||||
export type Interface = S.Interface
|
||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||
feedback: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||
}
|
||||
}
|
||||
|
||||
export const Service = S.Service
|
||||
export const layer = S.layer
|
||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||
ruleset: Schema.Any,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||
}
|
||||
}
|
||||
|
||||
export const evaluate = S.evaluate
|
||||
export const fromConfig = S.fromConfig
|
||||
export const merge = S.merge
|
||||
export const disabled = S.disabled
|
||||
export type Error = DeniedError | RejectedError | CorrectedError
|
||||
|
||||
export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((s) => s.ask(input))))
|
||||
export const AskInput = Request.partial({ id: true }).extend({
|
||||
ruleset: Ruleset,
|
||||
})
|
||||
|
||||
export const reply = fn(S.ReplyInput, async (input) => runPromiseInstance(S.Service.use((s) => s.reply(input))))
|
||||
export const ReplyInput = z.object({
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
message: z.string().optional(),
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
||||
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Request[]>
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||
}
|
||||
|
||||
interface State {
|
||||
pending: Map<PermissionID, PendingEntry>
|
||||
approved: Ruleset
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
||||
return evalRule(permission, pattern, ...rulesets)
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Permission") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Permission.state")(function* (ctx) {
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
|
||||
)
|
||||
const state = {
|
||||
pending: new Map<PermissionID, PendingEntry>(),
|
||||
approved: row?.data ?? [],
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
for (const item of state.pending.values()) {
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
}
|
||||
state.pending.clear()
|
||||
}),
|
||||
)
|
||||
|
||||
return state
|
||||
}),
|
||||
)
|
||||
|
||||
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||
const { approved, pending } = yield* InstanceState.get(state)
|
||||
const { ruleset, ...request } = input
|
||||
let needsAsk = false
|
||||
|
||||
for (const pattern of request.patterns) {
|
||||
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||
if (rule.action === "deny") {
|
||||
return yield* new DeniedError({
|
||||
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
||||
})
|
||||
}
|
||||
if (rule.action === "allow") continue
|
||||
needsAsk = true
|
||||
}
|
||||
|
||||
if (!needsAsk) return
|
||||
|
||||
const id = request.id ?? PermissionID.ascending()
|
||||
const info: Request = {
|
||||
id,
|
||||
...request,
|
||||
}
|
||||
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||
|
||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||
pending.set(id, { info, deferred })
|
||||
void Bus.publish(Event.Asked, info)
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||
const { approved, pending } = yield* InstanceState.get(state)
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return
|
||||
|
||||
pending.delete(input.requestID)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
reply: input.reply,
|
||||
})
|
||||
|
||||
if (input.reply === "reject") {
|
||||
yield* Deferred.fail(
|
||||
existing.deferred,
|
||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||
)
|
||||
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
pending.delete(id)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
reply: "reject",
|
||||
})
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
yield* Deferred.succeed(existing.deferred, undefined)
|
||||
if (input.reply === "once") return
|
||||
|
||||
for (const pattern of existing.info.always) {
|
||||
approved.push({
|
||||
permission: existing.info.permission,
|
||||
pattern,
|
||||
action: "allow",
|
||||
})
|
||||
}
|
||||
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
const ok = item.info.patterns.every(
|
||||
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
||||
)
|
||||
if (!ok) continue
|
||||
pending.delete(id)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
reply: "always",
|
||||
})
|
||||
yield* Deferred.succeed(item.deferred, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
const list = Effect.fn("Permission.list")(function* () {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
return Array.from(pending.values(), (item) => item.info)
|
||||
})
|
||||
|
||||
return Service.of({ ask, reply, list })
|
||||
}),
|
||||
)
|
||||
|
||||
function expand(pattern: string): string {
|
||||
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
||||
if (pattern === "~") return os.homedir()
|
||||
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
|
||||
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
|
||||
return pattern
|
||||
}
|
||||
|
||||
export function fromConfig(permission: Config.Permission) {
|
||||
const ruleset: Ruleset = []
|
||||
for (const [key, value] of Object.entries(permission)) {
|
||||
if (typeof value === "string") {
|
||||
ruleset.push({ permission: key, action: value, pattern: "*" })
|
||||
continue
|
||||
}
|
||||
ruleset.push(
|
||||
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
||||
)
|
||||
}
|
||||
return ruleset
|
||||
}
|
||||
|
||||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||
return rulesets.flat()
|
||||
}
|
||||
|
||||
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
||||
|
||||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||
const result = new Set<string>()
|
||||
for (const tool of tools) {
|
||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
||||
if (!rule) continue
|
||||
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export async function ask(input: z.infer<typeof AskInput>) {
|
||||
return runPromise((s) => s.ask(input))
|
||||
}
|
||||
|
||||
export async function reply(input: z.infer<typeof ReplyInput>) {
|
||||
return runPromise((s) => s.reply(input))
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return runPromiseInstance(S.Service.use((s) => s.list()))
|
||||
return runPromise((s) => s.list())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { Log } from "@/util/log"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import { evaluate as evalRule } from "./evaluate"
|
||||
import { PermissionID } from "./schema"
|
||||
|
||||
export namespace Permission {
|
||||
const log = Log.create({ service: "permission" })
|
||||
|
||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||
ref: "PermissionAction",
|
||||
})
|
||||
export type Action = z.infer<typeof Action>
|
||||
|
||||
export const Rule = z
|
||||
.object({
|
||||
permission: z.string(),
|
||||
pattern: z.string(),
|
||||
action: Action,
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRule",
|
||||
})
|
||||
export type Rule = z.infer<typeof Rule>
|
||||
|
||||
export const Ruleset = Rule.array().meta({
|
||||
ref: "PermissionRuleset",
|
||||
})
|
||||
export type Ruleset = z.infer<typeof Ruleset>
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: PermissionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
permission: z.string(),
|
||||
patterns: z.string().array(),
|
||||
metadata: z.record(z.string(), z.any()),
|
||||
always: z.string().array(),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRequest",
|
||||
})
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Reply = z.enum(["once", "always", "reject"])
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Approval = z.object({
|
||||
projectID: ProjectID.zod,
|
||||
patterns: z.string().array(),
|
||||
})
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("permission.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"permission.replied",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user rejected permission to use this specific tool call."
|
||||
}
|
||||
}
|
||||
|
||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||
feedback: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||
}
|
||||
}
|
||||
|
||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||
ruleset: Schema.Any,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||
}
|
||||
}
|
||||
|
||||
export type Error = DeniedError | RejectedError | CorrectedError
|
||||
|
||||
export const AskInput = Request.partial({ id: true }).extend({
|
||||
ruleset: Ruleset,
|
||||
})
|
||||
|
||||
export const ReplyInput = z.object({
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
message: z.string().optional(),
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
||||
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Request[]>
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
||||
return evalRule(permission, pattern, ...rulesets)
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { project } = yield* InstanceContext
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
|
||||
)
|
||||
const pending = new Map<PermissionID, PendingEntry>()
|
||||
const approved: Ruleset = row?.data ?? []
|
||||
|
||||
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||
const { ruleset, ...request } = input
|
||||
let needsAsk = false
|
||||
|
||||
for (const pattern of request.patterns) {
|
||||
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||
if (rule.action === "deny") {
|
||||
return yield* new DeniedError({
|
||||
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
||||
})
|
||||
}
|
||||
if (rule.action === "allow") continue
|
||||
needsAsk = true
|
||||
}
|
||||
|
||||
if (!needsAsk) return
|
||||
|
||||
const id = request.id ?? PermissionID.ascending()
|
||||
const info: Request = {
|
||||
id,
|
||||
...request,
|
||||
}
|
||||
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||
|
||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||
pending.set(id, { info, deferred })
|
||||
void Bus.publish(Event.Asked, info)
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return
|
||||
|
||||
pending.delete(input.requestID)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
reply: input.reply,
|
||||
})
|
||||
|
||||
if (input.reply === "reject") {
|
||||
yield* Deferred.fail(
|
||||
existing.deferred,
|
||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||
)
|
||||
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
pending.delete(id)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
reply: "reject",
|
||||
})
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
yield* Deferred.succeed(existing.deferred, undefined)
|
||||
if (input.reply === "once") return
|
||||
|
||||
for (const pattern of existing.info.always) {
|
||||
approved.push({
|
||||
permission: existing.info.permission,
|
||||
pattern,
|
||||
action: "allow",
|
||||
})
|
||||
}
|
||||
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
const ok = item.info.patterns.every(
|
||||
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
||||
)
|
||||
if (!ok) continue
|
||||
pending.delete(id)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
reply: "always",
|
||||
})
|
||||
yield* Deferred.succeed(item.deferred, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
const list = Effect.fn("Permission.list")(function* () {
|
||||
return Array.from(pending.values(), (item) => item.info)
|
||||
})
|
||||
|
||||
return Service.of({ ask, reply, list })
|
||||
}),
|
||||
).pipe(Layer.fresh)
|
||||
|
||||
function expand(pattern: string): string {
|
||||
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
||||
if (pattern === "~") return os.homedir()
|
||||
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
|
||||
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
|
||||
return pattern
|
||||
}
|
||||
|
||||
export function fromConfig(permission: Config.Permission) {
|
||||
const ruleset: Ruleset = []
|
||||
for (const [key, value] of Object.entries(permission)) {
|
||||
if (typeof value === "string") {
|
||||
ruleset.push({ permission: key, action: value, pattern: "*" })
|
||||
continue
|
||||
}
|
||||
ruleset.push(
|
||||
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
||||
)
|
||||
}
|
||||
return ruleset
|
||||
}
|
||||
|
||||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||
return rulesets.flat()
|
||||
}
|
||||
|
||||
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
||||
|
||||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||
const result = new Set<string>()
|
||||
for (const tool of tools) {
|
||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
||||
if (!rule) continue
|
||||
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import os from "os"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createServer } from "http"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -241,7 +242,7 @@ interface PendingOAuth {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof Bun.serve> | undefined
|
||||
let oauthServer: ReturnType<typeof createServer> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
|
||||
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||
@@ -249,77 +250,83 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
oauthServer = Bun.serve({
|
||||
port: OAUTH_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
oauthServer = createServer((req, res) => {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
|
||||
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response("Login cancelled", { status: 200 })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200)
|
||||
res.end("Login cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
oauthServer!.listen(OAUTH_PORT, () => {
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
resolve()
|
||||
})
|
||||
oauthServer!.on("error", reject)
|
||||
})
|
||||
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (oauthServer) {
|
||||
oauthServer.stop()
|
||||
oauthServer.close(() => {
|
||||
log.info("codex oauth server stopped")
|
||||
})
|
||||
oauthServer = undefined
|
||||
log.info("codex oauth server stopped")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Server } from "../server/server"
|
||||
import { BunProc } from "../bun"
|
||||
import { Npm } from "../npm"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
@@ -30,7 +30,9 @@ export namespace Plugin {
|
||||
: undefined,
|
||||
fetch: async (...args) => Server.Default().fetch(...args),
|
||||
})
|
||||
log.info("loading config")
|
||||
const config = await Config.get()
|
||||
log.info("config loaded")
|
||||
const hooks: Hooks[] = []
|
||||
const input: PluginInput = {
|
||||
client,
|
||||
@@ -40,7 +42,8 @@ export namespace Plugin {
|
||||
get serverUrl(): URL {
|
||||
return Server.url ?? new URL("http://localhost:4096")
|
||||
},
|
||||
$: Bun.$,
|
||||
// @ts-expect-error
|
||||
$: typeof Bun === "undefined" ? undefined : Bun.$,
|
||||
}
|
||||
|
||||
for (const plugin of INTERNAL_PLUGINS) {
|
||||
@@ -59,16 +62,13 @@ export namespace Plugin {
|
||||
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
|
||||
log.info("loading plugin", { path: plugin })
|
||||
if (!plugin.startsWith("file://")) {
|
||||
const lastAtIndex = plugin.lastIndexOf("@")
|
||||
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
|
||||
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
|
||||
plugin = await BunProc.install(pkg, version).catch((err) => {
|
||||
plugin = await Npm.add(plugin).catch((err) => {
|
||||
const cause = err instanceof Error ? err.cause : err
|
||||
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
||||
log.error("failed to install plugin", { pkg, version, error: detail })
|
||||
log.error("failed to install plugin", { plugin, error: detail })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
|
||||
message: `Failed to install plugin ${plugin}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return ""
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Plugin } from "../plugin"
|
||||
import { Format } from "../format"
|
||||
import { LSP } from "../lsp"
|
||||
import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Project } from "./project"
|
||||
import { Vcs } from "./vcs"
|
||||
import { Bus } from "../bus"
|
||||
import { Command } from "../command"
|
||||
import { Instance } from "./instance"
|
||||
@@ -12,8 +16,12 @@ export async function InstanceBootstrap() {
|
||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||
await Plugin.init()
|
||||
ShareNext.init()
|
||||
Format.init()
|
||||
await LSP.init()
|
||||
File.init()
|
||||
FileWatcher.init()
|
||||
Vcs.init()
|
||||
Snapshot.init()
|
||||
|
||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
|
||||
@@ -7,13 +7,13 @@ import { Context } from "../util/context"
|
||||
import { Project } from "./project"
|
||||
import { State } from "./state"
|
||||
|
||||
interface Context {
|
||||
export interface Shape {
|
||||
directory: string
|
||||
worktree: string
|
||||
project: Project.Info
|
||||
}
|
||||
const context = Context.create<Context>("instance")
|
||||
const cache = new Map<string, Promise<Context>>()
|
||||
const context = Context.create<Shape>("instance")
|
||||
const cache = new Map<string, Promise<Shape>>()
|
||||
|
||||
const disposal = {
|
||||
all: undefined as Promise<void> | undefined,
|
||||
@@ -52,7 +52,7 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
|
||||
})
|
||||
}
|
||||
|
||||
function track(directory: string, next: Promise<Context>) {
|
||||
function track(directory: string, next: Promise<Shape>) {
|
||||
const task = next.catch((error) => {
|
||||
if (cache.get(directory) === task) cache.delete(directory)
|
||||
throw error
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Log } from "@/util/log"
|
||||
import { git } from "@/util/git"
|
||||
@@ -30,54 +31,81 @@ export namespace Vcs {
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly branch: () => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
interface State {
|
||||
current: string | undefined
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
let currentBranch: string | undefined
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Vcs.state")((ctx) =>
|
||||
Effect.gen(function* () {
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return { current: undefined }
|
||||
}
|
||||
|
||||
if (instance.project.vcs === "git") {
|
||||
const getCurrentBranch = async () => {
|
||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: instance.project.worktree,
|
||||
})
|
||||
if (result.exitCode !== 0) return undefined
|
||||
const text = result.text().trim()
|
||||
return text || undefined
|
||||
}
|
||||
const getCurrentBranch = async () => {
|
||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: ctx.worktree,
|
||||
})
|
||||
if (result.exitCode !== 0) return undefined
|
||||
const text = result.text().trim()
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
currentBranch = yield* Effect.promise(() => getCurrentBranch())
|
||||
log.info("initialized", { branch: currentBranch })
|
||||
const value = {
|
||||
current: yield* Effect.promise(() => getCurrentBranch()),
|
||||
}
|
||||
log.info("initialized", { branch: value.current })
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
FileWatcher.Event.Updated,
|
||||
Instance.bind(async (evt) => {
|
||||
if (!evt.properties.file.endsWith("HEAD")) return
|
||||
const next = await getCurrentBranch()
|
||||
if (next !== currentBranch) {
|
||||
log.info("branch changed", { from: currentBranch, to: next })
|
||||
currentBranch = next
|
||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
)
|
||||
}
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
FileWatcher.Event.Updated,
|
||||
Instance.bind(async (evt) => {
|
||||
if (!evt.properties.file.endsWith("HEAD")) return
|
||||
const next = await getCurrentBranch()
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
)
|
||||
|
||||
return value
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("Vcs.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
}),
|
||||
branch: Effect.fn("Vcs.branch")(function* () {
|
||||
return currentBranch
|
||||
return yield* InstanceState.use(state, (x) => x.current)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.fresh)
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
|
||||
export function branch() {
|
||||
return runPromise((svc) => svc.branch())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import * as Auth from "@/auth/effect"
|
||||
import { ProviderID } from "./schema"
|
||||
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
export namespace ProviderAuth {
|
||||
export const Method = z
|
||||
.object({
|
||||
type: z.union([z.literal("oauth"), z.literal("api")]),
|
||||
label: z.string(),
|
||||
prompts: z
|
||||
.array(
|
||||
z.union([
|
||||
z.object({
|
||||
type: z.literal("text"),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
placeholder: z.string().optional(),
|
||||
when: z
|
||||
.object({
|
||||
key: z.string(),
|
||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||
value: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("select"),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
hint: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
when: z
|
||||
.object({
|
||||
key: z.string(),
|
||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||
value: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthMethod",
|
||||
})
|
||||
export type Method = z.infer<typeof Method>
|
||||
|
||||
export const Authorization = z
|
||||
.object({
|
||||
url: z.string(),
|
||||
method: z.union([z.literal("auto"), z.literal("code")]),
|
||||
instructions: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthAuthorization",
|
||||
})
|
||||
export type Authorization = z.infer<typeof Authorization>
|
||||
|
||||
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
|
||||
|
||||
export const OauthCodeMissing = NamedError.create(
|
||||
"ProviderAuthOauthCodeMissing",
|
||||
z.object({ providerID: ProviderID.zod }),
|
||||
)
|
||||
|
||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||
|
||||
export const ValidationFailed = NamedError.create(
|
||||
"ProviderAuthValidationFailed",
|
||||
z.object({
|
||||
field: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export type Error =
|
||||
| Auth.AuthError
|
||||
| InstanceType<typeof OauthMissing>
|
||||
| InstanceType<typeof OauthCodeMissing>
|
||||
| InstanceType<typeof OauthCallbackFailed>
|
||||
| InstanceType<typeof ValidationFailed>
|
||||
|
||||
export interface Interface {
|
||||
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
|
||||
readonly authorize: (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
inputs?: Record<string, string>
|
||||
}) => Effect.Effect<Authorization | undefined, Error>
|
||||
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Auth.Service
|
||||
const hooks = yield* Effect.promise(async () => {
|
||||
const mod = await import("../plugin")
|
||||
const plugins = await mod.Plugin.list()
|
||||
return Record.fromEntries(
|
||||
Arr.filterMap(plugins, (x) =>
|
||||
x.auth?.provider !== undefined
|
||||
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
||||
: Result.failVoid,
|
||||
),
|
||||
)
|
||||
})
|
||||
const pending = new Map<ProviderID, AuthOuathResult>()
|
||||
|
||||
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
||||
return Record.map(hooks, (item) =>
|
||||
item.methods.map(
|
||||
(method): Method => ({
|
||||
type: method.type,
|
||||
label: method.label,
|
||||
prompts: method.prompts?.map((prompt) => {
|
||||
if (prompt.type === "select") {
|
||||
return {
|
||||
type: "select" as const,
|
||||
key: prompt.key,
|
||||
message: prompt.message,
|
||||
options: prompt.options,
|
||||
when: prompt.when,
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "text" as const,
|
||||
key: prompt.key,
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder,
|
||||
when: prompt.when,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
inputs?: Record<string, string>
|
||||
}) {
|
||||
const method = hooks[input.providerID].methods[input.method]
|
||||
if (method.type !== "oauth") return
|
||||
|
||||
if (method.prompts && input.inputs) {
|
||||
for (const prompt of method.prompts) {
|
||||
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
|
||||
const error = prompt.validate(input.inputs[prompt.key])
|
||||
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = yield* Effect.promise(() => method.authorize(input.inputs))
|
||||
pending.set(input.providerID, result)
|
||||
return {
|
||||
url: result.url,
|
||||
method: result.method,
|
||||
instructions: result.instructions,
|
||||
}
|
||||
})
|
||||
|
||||
const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
code?: string
|
||||
}) {
|
||||
const match = pending.get(input.providerID)
|
||||
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
||||
if (match.method === "code" && !input.code) {
|
||||
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
||||
}
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
||||
)
|
||||
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
||||
|
||||
if ("key" in result) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
}
|
||||
|
||||
if ("refresh" in result) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "oauth",
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
...(result.accountId ? { accountId: result.accountId } : {}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ methods, authorize, callback })
|
||||
}),
|
||||
).pipe(Layer.fresh)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer))
|
||||
}
|
||||
@@ -1,48 +1,251 @@
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { fn } from "@/util/fn"
|
||||
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Auth } from "@/auth"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { Plugin } from "../plugin"
|
||||
import { ProviderID } from "./schema"
|
||||
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
|
||||
import z from "zod"
|
||||
import { ProviderAuth as S } from "./auth-service"
|
||||
|
||||
export namespace ProviderAuth {
|
||||
export const Method = S.Method
|
||||
export type Method = S.Method
|
||||
export const Method = z
|
||||
.object({
|
||||
type: z.union([z.literal("oauth"), z.literal("api")]),
|
||||
label: z.string(),
|
||||
prompts: z
|
||||
.array(
|
||||
z.union([
|
||||
z.object({
|
||||
type: z.literal("text"),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
placeholder: z.string().optional(),
|
||||
when: z
|
||||
.object({
|
||||
key: z.string(),
|
||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||
value: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("select"),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
hint: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
when: z
|
||||
.object({
|
||||
key: z.string(),
|
||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||
value: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthMethod",
|
||||
})
|
||||
export type Method = z.infer<typeof Method>
|
||||
|
||||
export const Authorization = S.Authorization
|
||||
export type Authorization = S.Authorization
|
||||
export const Authorization = z
|
||||
.object({
|
||||
url: z.string(),
|
||||
method: z.union([z.literal("auto"), z.literal("code")]),
|
||||
instructions: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthAuthorization",
|
||||
})
|
||||
export type Authorization = z.infer<typeof Authorization>
|
||||
|
||||
export const OauthMissing = S.OauthMissing
|
||||
export const OauthCodeMissing = S.OauthCodeMissing
|
||||
export const OauthCallbackFailed = S.OauthCallbackFailed
|
||||
export const ValidationFailed = S.ValidationFailed
|
||||
export type Error = S.Error
|
||||
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
|
||||
|
||||
export type Interface = S.Interface
|
||||
export const OauthCodeMissing = NamedError.create(
|
||||
"ProviderAuthOauthCodeMissing",
|
||||
z.object({ providerID: ProviderID.zod }),
|
||||
)
|
||||
|
||||
export const Service = S.Service
|
||||
export const layer = S.layer
|
||||
export const defaultLayer = S.defaultLayer
|
||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||
|
||||
export async function methods() {
|
||||
return runPromiseInstance(S.Service.use((svc) => svc.methods()))
|
||||
export const ValidationFailed = NamedError.create(
|
||||
"ProviderAuthValidationFailed",
|
||||
z.object({
|
||||
field: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export type Error =
|
||||
| Auth.AuthError
|
||||
| InstanceType<typeof OauthMissing>
|
||||
| InstanceType<typeof OauthCodeMissing>
|
||||
| InstanceType<typeof OauthCallbackFailed>
|
||||
| InstanceType<typeof ValidationFailed>
|
||||
|
||||
type Hook = NonNullable<Hooks["auth"]>
|
||||
|
||||
export interface Interface {
|
||||
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
|
||||
readonly authorize: (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
inputs?: Record<string, string>
|
||||
}) => Effect.Effect<Authorization | undefined, Error>
|
||||
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export const authorize = fn(
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
method: z.number(),
|
||||
inputs: z.record(z.string(), z.string()).optional(),
|
||||
interface State {
|
||||
hooks: Record<ProviderID, Hook>
|
||||
pending: Map<ProviderID, AuthOuathResult>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ProviderAuth.state")(() =>
|
||||
Effect.promise(async () => {
|
||||
const plugins = await Plugin.list()
|
||||
return {
|
||||
hooks: Record.fromEntries(
|
||||
Arr.filterMap(plugins, (x) =>
|
||||
x.auth?.provider !== undefined
|
||||
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
||||
: Result.failVoid,
|
||||
),
|
||||
),
|
||||
pending: new Map<ProviderID, AuthOuathResult>(),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
||||
const hooks = (yield* InstanceState.get(state)).hooks
|
||||
return Record.map(hooks, (item) =>
|
||||
item.methods.map(
|
||||
(method): Method => ({
|
||||
type: method.type,
|
||||
label: method.label,
|
||||
prompts: method.prompts?.map((prompt) => {
|
||||
if (prompt.type === "select") {
|
||||
return {
|
||||
type: "select" as const,
|
||||
key: prompt.key,
|
||||
message: prompt.message,
|
||||
options: prompt.options,
|
||||
when: prompt.when,
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "text" as const,
|
||||
key: prompt.key,
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder,
|
||||
when: prompt.when,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
inputs?: Record<string, string>
|
||||
}) {
|
||||
const { hooks, pending } = yield* InstanceState.get(state)
|
||||
const method = hooks[input.providerID].methods[input.method]
|
||||
if (method.type !== "oauth") return
|
||||
|
||||
if (method.prompts && input.inputs) {
|
||||
for (const prompt of method.prompts) {
|
||||
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
|
||||
const error = prompt.validate(input.inputs[prompt.key])
|
||||
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = yield* Effect.promise(() => method.authorize(input.inputs))
|
||||
pending.set(input.providerID, result)
|
||||
return {
|
||||
url: result.url,
|
||||
method: result.method,
|
||||
instructions: result.instructions,
|
||||
}
|
||||
})
|
||||
|
||||
const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
code?: string
|
||||
}) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const match = pending.get(input.providerID)
|
||||
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
||||
if (match.method === "code" && !input.code) {
|
||||
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
||||
}
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
||||
)
|
||||
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
||||
|
||||
if ("key" in result) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
}
|
||||
|
||||
if ("refresh" in result) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "oauth",
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
...(result.accountId ? { accountId: result.accountId } : {}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ methods, authorize, callback })
|
||||
}),
|
||||
async (input): Promise<Authorization | undefined> =>
|
||||
runPromiseInstance(S.Service.use((svc) => svc.authorize(input))),
|
||||
)
|
||||
|
||||
export const callback = fn(
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
method: z.number(),
|
||||
code: z.string().optional(),
|
||||
}),
|
||||
async (input) => runPromiseInstance(S.Service.use((svc) => svc.callback(input))),
|
||||
)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export async function methods() {
|
||||
return runPromise((svc) => svc.methods())
|
||||
}
|
||||
|
||||
export async function authorize(input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
inputs?: Record<string, string>
|
||||
}): Promise<Authorization | undefined> {
|
||||
return runPromise((svc) => svc.authorize(input))
|
||||
}
|
||||
|
||||
export async function callback(input: { providerID: ProviderID; method: number; code?: string }) {
|
||||
return runPromise((svc) => svc.callback(input))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Config } from "../config/config"
|
||||
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
|
||||
import { NoSuchModelError, type Provider as SDK } from "ai"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { Npm } from "../npm"
|
||||
import { Hash } from "../util/hash"
|
||||
import { Plugin } from "../plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
@@ -1296,7 +1296,7 @@ export namespace Provider {
|
||||
|
||||
let installedPath: string
|
||||
if (!model.api.npm.startsWith("file://")) {
|
||||
installedPath = await BunProc.install(model.api.npm, "latest")
|
||||
installedPath = await Npm.add(model.api.npm)
|
||||
} else {
|
||||
log.info("loading local provider", { pkg: model.api.npm })
|
||||
installedPath = model.api.npm
|
||||
|
||||
@@ -1,49 +1,221 @@
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import type { MessageID, SessionID } from "@/session/schema"
|
||||
import type { QuestionID } from "./schema"
|
||||
import { Question as S } from "./service"
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Log } from "@/util/log"
|
||||
import z from "zod"
|
||||
import { QuestionID } from "./schema"
|
||||
|
||||
export namespace Question {
|
||||
export const Option = S.Option
|
||||
export type Option = S.Option
|
||||
const log = Log.create({ service: "question" })
|
||||
|
||||
export const Info = S.Info
|
||||
export type Info = S.Info
|
||||
// Schemas
|
||||
|
||||
export const Request = S.Request
|
||||
export type Request = S.Request
|
||||
export const Option = z
|
||||
.object({
|
||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||
description: z.string().describe("Explanation of choice"),
|
||||
})
|
||||
.meta({ ref: "QuestionOption" })
|
||||
export type Option = z.infer<typeof Option>
|
||||
|
||||
export const Answer = S.Answer
|
||||
export type Answer = S.Answer
|
||||
export const Info = z
|
||||
.object({
|
||||
question: z.string().describe("Complete question"),
|
||||
header: z.string().describe("Very short label (max 30 chars)"),
|
||||
options: z.array(Option).describe("Available choices"),
|
||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||
})
|
||||
.meta({ ref: "QuestionInfo" })
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Reply = S.Reply
|
||||
export type Reply = S.Reply
|
||||
export const Request = z
|
||||
.object({
|
||||
id: QuestionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
questions: z.array(Info).describe("Questions to ask"),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({ ref: "QuestionRequest" })
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Event = S.Event
|
||||
export const RejectedError = S.RejectedError
|
||||
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
||||
export type Answer = z.infer<typeof Answer>
|
||||
|
||||
export type Interface = S.Interface
|
||||
export const Reply = z.object({
|
||||
answers: z
|
||||
.array(Answer)
|
||||
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||
})
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Service = S.Service
|
||||
export const layer = S.layer
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("question.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"question.replied",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: QuestionID.zod,
|
||||
answers: z.array(Answer),
|
||||
}),
|
||||
),
|
||||
Rejected: BusEvent.define(
|
||||
"question.rejected",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: QuestionID.zod,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user dismissed this question"
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<Answer[], RejectedError>
|
||||
}
|
||||
|
||||
interface State {
|
||||
pending: Map<QuestionID, PendingEntry>
|
||||
}
|
||||
|
||||
// Service
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) => Effect.Effect<Answer[], RejectedError>
|
||||
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Request[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Question.state")(function* () {
|
||||
const state = {
|
||||
pending: new Map<QuestionID, PendingEntry>(),
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
for (const item of state.pending.values()) {
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
}
|
||||
state.pending.clear()
|
||||
}),
|
||||
)
|
||||
|
||||
return state
|
||||
}),
|
||||
)
|
||||
|
||||
const ask = Effect.fn("Question.ask")(function* (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const id = QuestionID.ascending()
|
||||
log.info("asking", { id, questions: input.questions.length })
|
||||
|
||||
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
||||
const info: Request = {
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
questions: input.questions,
|
||||
tool: input.tool,
|
||||
}
|
||||
pending.set(id, { info, deferred })
|
||||
Bus.publish(Event.Asked, info)
|
||||
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) {
|
||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||
return
|
||||
}
|
||||
pending.delete(input.requestID)
|
||||
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
||||
Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
answers: input.answers,
|
||||
})
|
||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||
})
|
||||
|
||||
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const existing = pending.get(requestID)
|
||||
if (!existing) {
|
||||
log.warn("reject for unknown request", { requestID })
|
||||
return
|
||||
}
|
||||
pending.delete(requestID)
|
||||
log.info("rejected", { requestID })
|
||||
Bus.publish(Event.Rejected, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
})
|
||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
||||
})
|
||||
|
||||
const list = Effect.fn("Question.list")(function* () {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
return Array.from(pending.values(), (x) => x.info)
|
||||
})
|
||||
|
||||
return Service.of({ ask, reply, reject, list })
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export async function ask(input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}): Promise<Answer[]> {
|
||||
return runPromiseInstance(S.Service.use((s) => s.ask(input)))
|
||||
return runPromise((s) => s.ask(input))
|
||||
}
|
||||
|
||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
|
||||
return runPromiseInstance(S.Service.use((s) => s.reply(input)))
|
||||
return runPromise((s) => s.reply(input))
|
||||
}
|
||||
|
||||
export async function reject(requestID: QuestionID) {
|
||||
return runPromiseInstance(S.Service.use((s) => s.reject(requestID)))
|
||||
return runPromise((s) => s.reject(requestID))
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return runPromiseInstance(S.Service.use((s) => s.list()))
|
||||
return runPromise((s) => s.list())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Log } from "@/util/log"
|
||||
import z from "zod"
|
||||
import { QuestionID } from "./schema"
|
||||
|
||||
const log = Log.create({ service: "question" })
|
||||
|
||||
export namespace Question {
|
||||
// Schemas
|
||||
|
||||
export const Option = z
|
||||
.object({
|
||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||
description: z.string().describe("Explanation of choice"),
|
||||
})
|
||||
.meta({ ref: "QuestionOption" })
|
||||
export type Option = z.infer<typeof Option>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
question: z.string().describe("Complete question"),
|
||||
header: z.string().describe("Very short label (max 30 chars)"),
|
||||
options: z.array(Option).describe("Available choices"),
|
||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||
})
|
||||
.meta({ ref: "QuestionInfo" })
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: QuestionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
questions: z.array(Info).describe("Questions to ask"),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({ ref: "QuestionRequest" })
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
||||
export type Answer = z.infer<typeof Answer>
|
||||
|
||||
export const Reply = z.object({
|
||||
answers: z
|
||||
.array(Answer)
|
||||
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||
})
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("question.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"question.replied",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: QuestionID.zod,
|
||||
answers: z.array(Answer),
|
||||
}),
|
||||
),
|
||||
Rejected: BusEvent.define(
|
||||
"question.rejected",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: QuestionID.zod,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user dismissed this question"
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<Answer[], RejectedError>
|
||||
}
|
||||
|
||||
// Service
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) => Effect.Effect<Answer[], RejectedError>
|
||||
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Request[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const pending = new Map<QuestionID, PendingEntry>()
|
||||
|
||||
const ask = Effect.fn("Question.ask")(function* (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) {
|
||||
const id = QuestionID.ascending()
|
||||
log.info("asking", { id, questions: input.questions.length })
|
||||
|
||||
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
||||
const info: Request = {
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
questions: input.questions,
|
||||
tool: input.tool,
|
||||
}
|
||||
pending.set(id, { info, deferred })
|
||||
Bus.publish(Event.Asked, info)
|
||||
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) {
|
||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||
return
|
||||
}
|
||||
pending.delete(input.requestID)
|
||||
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
||||
Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
answers: input.answers,
|
||||
})
|
||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||
})
|
||||
|
||||
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
|
||||
const existing = pending.get(requestID)
|
||||
if (!existing) {
|
||||
log.warn("reject for unknown request", { requestID })
|
||||
return
|
||||
}
|
||||
pending.delete(requestID)
|
||||
log.info("rejected", { requestID })
|
||||
Bus.publish(Event.Rejected, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
})
|
||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
||||
})
|
||||
|
||||
const list = Effect.fn("Question.list")(function* () {
|
||||
return Array.from(pending.values(), (x) => x.info)
|
||||
})
|
||||
|
||||
return Service.of({ ask, reply, reject, list })
|
||||
}),
|
||||
).pipe(Layer.fresh)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
@@ -32,11 +32,11 @@ export const PermissionRoutes = lazy(() =>
|
||||
requestID: PermissionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
|
||||
validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: params.requestID,
|
||||
reply: json.reply,
|
||||
message: json.message,
|
||||
@@ -55,14 +55,14 @@ export const PermissionRoutes = lazy(() =>
|
||||
description: "List of pending permissions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(PermissionNext.Request.array()),
|
||||
schema: resolver(Permission.Request.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const permissions = await PermissionNext.list()
|
||||
const permissions = await Permission.list()
|
||||
return c.json(permissions)
|
||||
},
|
||||
),
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Todo } from "../../session/todo"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Log } from "../../util/log"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { errors } from "../error"
|
||||
@@ -1010,10 +1010,10 @@ export const SessionRoutes = lazy(() =>
|
||||
permissionID: PermissionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ response: PermissionNext.Reply })),
|
||||
validator("json", z.object({ response: Permission.Reply })),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
PermissionNext.reply({
|
||||
Permission.reply({
|
||||
requestID: params.permissionID,
|
||||
reply: c.req.valid("json").response,
|
||||
})
|
||||
|
||||
@@ -12,9 +12,8 @@ import { Format } from "../format"
|
||||
import { TuiRoutes } from "./routes/tui"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Vcs } from "../project/vcs"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Skill } from "../skill/skill"
|
||||
import { Skill } from "../skill"
|
||||
import { Auth } from "../auth"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Command } from "../command"
|
||||
@@ -152,7 +151,7 @@ export namespace Server {
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Auth.Info),
|
||||
validator("json", Auth.Info.zod),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const info = c.req.valid("json")
|
||||
@@ -331,7 +330,7 @@ export namespace Server {
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
|
||||
const branch = await Vcs.branch()
|
||||
return c.json({
|
||||
branch,
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
|
||||
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { Global } from "@/global"
|
||||
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
||||
import { iife } from "@/util/iife"
|
||||
@@ -148,7 +148,7 @@ export namespace Session {
|
||||
compacting: z.number().optional(),
|
||||
archived: z.number().optional(),
|
||||
}),
|
||||
permission: PermissionNext.Ruleset.optional(),
|
||||
permission: Permission.Ruleset.optional(),
|
||||
revert: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
@@ -300,7 +300,7 @@ export namespace Session {
|
||||
parentID?: SessionID
|
||||
workspaceID?: WorkspaceID
|
||||
directory: string
|
||||
permission?: PermissionNext.Ruleset
|
||||
permission?: Permission.Ruleset
|
||||
}) {
|
||||
const result: Info = {
|
||||
id: SessionID.descending(input.id),
|
||||
@@ -423,7 +423,7 @@ export namespace Session {
|
||||
export const setPermission = fn(
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
permission: PermissionNext.Ruleset,
|
||||
permission: Permission.Ruleset,
|
||||
}),
|
||||
async (input) => {
|
||||
return Database.use((db) => {
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { MessageV2 } from "./message-v2"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { Auth } from "@/auth"
|
||||
|
||||
export namespace LLM {
|
||||
@@ -33,7 +33,7 @@ export namespace LLM {
|
||||
sessionID: string
|
||||
model: Provider.Model
|
||||
agent: Agent.Info
|
||||
permission?: PermissionNext.Ruleset
|
||||
permission?: Permission.Ruleset
|
||||
system: string[]
|
||||
abort: AbortSignal
|
||||
messages: ModelMessage[]
|
||||
@@ -286,9 +286,9 @@ export namespace LLM {
|
||||
}
|
||||
|
||||
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
|
||||
const disabled = PermissionNext.disabled(
|
||||
const disabled = Permission.disabled(
|
||||
Object.keys(input.tools),
|
||||
PermissionNext.merge(input.agent.permission, input.permission ?? []),
|
||||
Permission.merge(input.agent.permission, input.permission ?? []),
|
||||
)
|
||||
for (const tool of Object.keys(input.tools)) {
|
||||
if (input.user.tools?.[tool] === false || disabled.has(tool)) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { Provider } from "@/provider/provider"
|
||||
import { LLM } from "./llm"
|
||||
import { Config } from "@/config/config"
|
||||
import { SessionCompaction } from "./compaction"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { Question } from "@/question"
|
||||
import { PartID } from "./schema"
|
||||
import type { SessionID, MessageID } from "./schema"
|
||||
@@ -163,7 +163,7 @@ export namespace SessionProcessor {
|
||||
)
|
||||
) {
|
||||
const agent = await Agent.get(input.assistantMessage.agent)
|
||||
await PermissionNext.ask({
|
||||
await Permission.ask({
|
||||
permission: "doom_loop",
|
||||
patterns: [value.toolName],
|
||||
sessionID: input.assistantMessage.sessionID,
|
||||
@@ -219,7 +219,7 @@ export namespace SessionProcessor {
|
||||
})
|
||||
|
||||
if (
|
||||
value.error instanceof PermissionNext.RejectedError ||
|
||||
value.error instanceof Permission.RejectedError ||
|
||||
value.error instanceof Question.RejectedError
|
||||
) {
|
||||
blocked = shouldBreak
|
||||
|
||||
@@ -41,7 +41,7 @@ import { fn } from "@/util/fn"
|
||||
import { SessionProcessor } from "./processor"
|
||||
import { TaskTool } from "@/tool/task"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { SessionStatus } from "./status"
|
||||
import { LLM } from "./llm"
|
||||
import { iife } from "@/util/iife"
|
||||
@@ -168,7 +168,7 @@ export namespace SessionPrompt {
|
||||
|
||||
// this is backwards compatibility for allowing `tools` to be specified when
|
||||
// prompting
|
||||
const permissions: PermissionNext.Ruleset = []
|
||||
const permissions: Permission.Ruleset = []
|
||||
for (const [tool, enabled] of Object.entries(input.tools ?? {})) {
|
||||
permissions.push({
|
||||
permission: tool,
|
||||
@@ -319,11 +319,7 @@ export namespace SessionPrompt {
|
||||
}
|
||||
|
||||
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
|
||||
if (
|
||||
lastAssistant?.finish &&
|
||||
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
|
||||
lastUser.id < lastAssistant.id
|
||||
) {
|
||||
if (shouldExitLoop(lastUser, lastAssistant)) {
|
||||
log.info("exiting loop", { sessionID })
|
||||
break
|
||||
}
|
||||
@@ -437,10 +433,10 @@ export namespace SessionPrompt {
|
||||
} satisfies MessageV2.ToolPart)) as MessageV2.ToolPart
|
||||
},
|
||||
async ask(req) {
|
||||
await PermissionNext.ask({
|
||||
await Permission.ask({
|
||||
...req,
|
||||
sessionID: sessionID,
|
||||
ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []),
|
||||
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -781,11 +777,11 @@ export namespace SessionPrompt {
|
||||
}
|
||||
},
|
||||
async ask(req) {
|
||||
await PermissionNext.ask({
|
||||
await Permission.ask({
|
||||
...req,
|
||||
sessionID: input.session.id,
|
||||
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
||||
ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []),
|
||||
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -1271,7 +1267,7 @@ export namespace SessionPrompt {
|
||||
|
||||
if (part.type === "agent") {
|
||||
// Check if this agent would be denied by task permission
|
||||
const perm = PermissionNext.evaluate("task", part.name, agent.permission)
|
||||
const perm = Permission.evaluate("task", part.name, agent.permission)
|
||||
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
|
||||
return [
|
||||
{
|
||||
@@ -1995,4 +1991,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Exported for testing — determines whether the prompt loop should exit */
|
||||
export function shouldExitLoop(
|
||||
lastUser: MessageV2.User | undefined,
|
||||
lastAssistant: MessageV2.Assistant | undefined,
|
||||
): boolean {
|
||||
if (!lastUser) return false
|
||||
if (!lastAssistant?.finish) return false
|
||||
if (["tool-calls", "unknown"].includes(lastAssistant.finish)) return false
|
||||
return lastAssistant.parentID === lastUser.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlit
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
import type { PermissionNext } from "../permission"
|
||||
import type { Permission } from "../permission"
|
||||
import type { ProjectID } from "../project/schema"
|
||||
import type { SessionID, MessageID, PartID } from "./schema"
|
||||
import type { WorkspaceID } from "../control-plane/schema"
|
||||
@@ -31,7 +31,7 @@ export const SessionTable = sqliteTable(
|
||||
summary_files: integer(),
|
||||
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
|
||||
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
|
||||
permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(),
|
||||
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
|
||||
...Timestamps,
|
||||
time_compacting: integer(),
|
||||
time_archived: integer(),
|
||||
@@ -99,5 +99,5 @@ export const PermissionTable = sqliteTable("permission", {
|
||||
.primaryKey()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<PermissionNext.Ruleset>(),
|
||||
data: text({ mode: "json" }).notNull().$type<Permission.Ruleset>(),
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { runSyncInstance } from "@/effect/runtime"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { SessionID } from "./schema"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
export namespace SessionStatus {
|
||||
@@ -43,51 +42,36 @@ export namespace SessionStatus {
|
||||
),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (sessionID: SessionID) => Effect.Effect<Info>
|
||||
readonly list: () => Effect.Effect<Record<string, Info>>
|
||||
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
|
||||
const state = Instance.state(() => {
|
||||
const data: Record<string, Info> = {}
|
||||
return data
|
||||
})
|
||||
|
||||
export function get(sessionID: SessionID) {
|
||||
return (
|
||||
state()[sessionID] ?? {
|
||||
type: "idle",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionStatus") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const data = new Map<SessionID, Info>()
|
||||
|
||||
const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) {
|
||||
return data.get(sessionID) ?? { type: "idle" as const }
|
||||
})
|
||||
|
||||
const list = Effect.fn("SessionStatus.list")(function* () {
|
||||
return Object.fromEntries(data)
|
||||
})
|
||||
|
||||
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
|
||||
Bus.publish(Event.Status, { sessionID, status })
|
||||
if (status.type === "idle") {
|
||||
// deprecated
|
||||
Bus.publish(Event.Idle, { sessionID })
|
||||
data.delete(sessionID)
|
||||
return
|
||||
}
|
||||
data.set(sessionID, status)
|
||||
})
|
||||
|
||||
return Service.of({ get, list, set })
|
||||
}),
|
||||
)
|
||||
|
||||
export function get(sessionID: SessionID): Info {
|
||||
return runSyncInstance(Service.use((svc) => svc.get(sessionID)))
|
||||
}
|
||||
|
||||
export function list(): Record<string, Info> {
|
||||
return runSyncInstance(Service.use((svc) => svc.list()))
|
||||
export function list() {
|
||||
return state()
|
||||
}
|
||||
|
||||
export function set(sessionID: SessionID, status: Info) {
|
||||
runSyncInstance(Service.use((svc) => svc.set(sessionID, status)))
|
||||
Bus.publish(Event.Status, {
|
||||
sessionID,
|
||||
status,
|
||||
})
|
||||
if (status.type === "idle") {
|
||||
// deprecated
|
||||
Bus.publish(Event.Idle, {
|
||||
sessionID,
|
||||
})
|
||||
delete state()[sessionID]
|
||||
return
|
||||
}
|
||||
state()[sessionID] = status
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex.txt"
|
||||
import PROMPT_TRINITY from "./prompt/trinity.txt"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { Skill } from "@/skill"
|
||||
|
||||
export namespace SystemPrompt {
|
||||
@@ -53,7 +53,7 @@ export namespace SystemPrompt {
|
||||
}
|
||||
|
||||
export async function skills(agent: Agent.Info) {
|
||||
if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return
|
||||
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
|
||||
|
||||
const list = await Skill.available(agent)
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export namespace ShareNext {
|
||||
}> {
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
const active = Account.active()
|
||||
const active = await Account.active()
|
||||
if (!active?.active_org_id) {
|
||||
const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
|
||||
return { headers, api: legacyApi, baseUrl }
|
||||
|
||||
@@ -1 +1,262 @@
|
||||
export * from "./skill"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Global } from "@/global"
|
||||
import { Permission } from "@/permission"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { Glob } from "../util/glob"
|
||||
import { Log } from "../util/log"
|
||||
import { Discovery } from "./discovery"
|
||||
|
||||
export namespace Skill {
|
||||
const log = Log.create({ service: "skill" })
|
||||
const EXTERNAL_DIRS = [".claude", ".agents"]
|
||||
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||
const SKILL_PATTERN = "**/SKILL.md"
|
||||
|
||||
export const Info = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
location: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const InvalidError = NamedError.create(
|
||||
"SkillInvalidError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
message: z.string().optional(),
|
||||
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const NameMismatchError = NamedError.create(
|
||||
"SkillNameMismatchError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
expected: z.string(),
|
||||
actual: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
type State = {
|
||||
skills: Record<string, Info>
|
||||
dirs: Set<string>
|
||||
task?: Promise<void>
|
||||
}
|
||||
|
||||
type Cache = State & {
|
||||
ensure: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
readonly dirs: () => Effect.Effect<string[]>
|
||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
const add = async (state: State, match: string) => {
|
||||
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
const { Session } = await import("@/session")
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!md) return
|
||||
|
||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||
if (!parsed.success) return
|
||||
|
||||
if (state.skills[parsed.data.name]) {
|
||||
log.warn("duplicate skill name", {
|
||||
name: parsed.data.name,
|
||||
existing: state.skills[parsed.data.name].location,
|
||||
duplicate: match,
|
||||
})
|
||||
}
|
||||
|
||||
state.dirs.add(path.dirname(match))
|
||||
state.skills[parsed.data.name] = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
location: match,
|
||||
content: md.content,
|
||||
}
|
||||
}
|
||||
|
||||
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
|
||||
return Glob.scan(pattern, {
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
dot: opts?.dot,
|
||||
})
|
||||
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
|
||||
.catch((error) => {
|
||||
if (!opts?.scope) throw error
|
||||
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Migrate to Effect
|
||||
const create = (discovery: Discovery.Interface, directory: string, worktree: string): Cache => {
|
||||
const state: State = {
|
||||
skills: {},
|
||||
dirs: new Set<string>(),
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(await Filesystem.isDir(root))) continue
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||
}
|
||||
|
||||
for await (const root of Filesystem.up({
|
||||
targets: EXTERNAL_DIRS,
|
||||
start: directory,
|
||||
stop: worktree,
|
||||
})) {
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of await Config.directories()) {
|
||||
await scan(state, dir, OPENCODE_SKILL_PATTERN)
|
||||
}
|
||||
|
||||
const cfg = await Config.get()
|
||||
for (const item of cfg.skills?.paths ?? []) {
|
||||
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
|
||||
if (!(await Filesystem.isDir(dir))) {
|
||||
log.warn("skill path not found", { path: dir })
|
||||
continue
|
||||
}
|
||||
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
|
||||
for (const url of cfg.skills?.urls ?? []) {
|
||||
for (const dir of await Effect.runPromise(discovery.pull(url))) {
|
||||
state.dirs.add(dir)
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
}
|
||||
|
||||
log.info("init", { count: Object.keys(state.skills).length })
|
||||
}
|
||||
|
||||
const ensure = () => {
|
||||
if (state.task) return state.task
|
||||
state.task = load().catch((err) => {
|
||||
state.task = undefined
|
||||
throw err
|
||||
})
|
||||
return state.task
|
||||
}
|
||||
|
||||
return { ...state, ensure }
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, Discovery.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* Discovery.Service
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Skill.state")((ctx) => Effect.sync(() => create(discovery, ctx.directory, ctx.worktree))),
|
||||
)
|
||||
|
||||
const ensure = Effect.fn("Skill.ensure")(function* () {
|
||||
const cache = yield* InstanceState.get(state)
|
||||
yield* Effect.promise(() => cache.ensure())
|
||||
return cache
|
||||
})
|
||||
|
||||
const get = Effect.fn("Skill.get")(function* (name: string) {
|
||||
const cache = yield* ensure()
|
||||
return cache.skills[name]
|
||||
})
|
||||
|
||||
const all = Effect.fn("Skill.all")(function* () {
|
||||
const cache = yield* ensure()
|
||||
return Object.values(cache.skills)
|
||||
})
|
||||
|
||||
const dirs = Effect.fn("Skill.dirs")(function* () {
|
||||
const cache = yield* ensure()
|
||||
return Array.from(cache.dirs)
|
||||
})
|
||||
|
||||
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
||||
const cache = yield* ensure()
|
||||
const list = Object.values(cache.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
if (!agent) return list
|
||||
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||
})
|
||||
|
||||
return Service.of({ get, all, dirs, available })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Discovery.defaultLayer))
|
||||
|
||||
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||
if (list.length === 0) return "No skills are currently available."
|
||||
|
||||
if (opts.verbose) {
|
||||
return [
|
||||
"<available_skills>",
|
||||
...list.flatMap((skill) => [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||
" </skill>",
|
||||
]),
|
||||
"</available_skills>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
||||
}
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export async function get(name: string) {
|
||||
return runPromise((skill) => skill.get(name))
|
||||
}
|
||||
|
||||
export async function all() {
|
||||
return runPromise((skill) => skill.all())
|
||||
}
|
||||
|
||||
export async function dirs() {
|
||||
return runPromise((skill) => skill.dirs())
|
||||
}
|
||||
|
||||
export async function available(agent?: Agent.Info) {
|
||||
return runPromise((skill) => skill.available(agent))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Global } from "@/global"
|
||||
import { Permission } from "@/permission/service"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { Glob } from "../util/glob"
|
||||
import { Log } from "../util/log"
|
||||
import { Discovery } from "./discovery"
|
||||
|
||||
export namespace Skill {
|
||||
const log = Log.create({ service: "skill" })
|
||||
const EXTERNAL_DIRS = [".claude", ".agents"]
|
||||
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||
const SKILL_PATTERN = "**/SKILL.md"
|
||||
|
||||
export const Info = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
location: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const InvalidError = NamedError.create(
|
||||
"SkillInvalidError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
message: z.string().optional(),
|
||||
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const NameMismatchError = NamedError.create(
|
||||
"SkillNameMismatchError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
expected: z.string(),
|
||||
actual: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
type State = {
|
||||
skills: Record<string, Info>
|
||||
dirs: Set<string>
|
||||
task?: Promise<void>
|
||||
}
|
||||
|
||||
type Cache = State & {
|
||||
ensure: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
readonly dirs: () => Effect.Effect<string[]>
|
||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
const add = async (state: State, match: string) => {
|
||||
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
const { Session } = await import("@/session")
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!md) return
|
||||
|
||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||
if (!parsed.success) return
|
||||
|
||||
if (state.skills[parsed.data.name]) {
|
||||
log.warn("duplicate skill name", {
|
||||
name: parsed.data.name,
|
||||
existing: state.skills[parsed.data.name].location,
|
||||
duplicate: match,
|
||||
})
|
||||
}
|
||||
|
||||
state.dirs.add(path.dirname(match))
|
||||
state.skills[parsed.data.name] = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
location: match,
|
||||
content: md.content,
|
||||
}
|
||||
}
|
||||
|
||||
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
|
||||
return Glob.scan(pattern, {
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
dot: opts?.dot,
|
||||
})
|
||||
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
|
||||
.catch((error) => {
|
||||
if (!opts?.scope) throw error
|
||||
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Migrate to Effect
|
||||
const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
|
||||
const state: State = {
|
||||
skills: {},
|
||||
dirs: new Set<string>(),
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(await Filesystem.isDir(root))) continue
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||
}
|
||||
|
||||
for await (const root of Filesystem.up({
|
||||
targets: EXTERNAL_DIRS,
|
||||
start: instance.directory,
|
||||
stop: instance.project.worktree,
|
||||
})) {
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of await Config.directories()) {
|
||||
await scan(state, dir, OPENCODE_SKILL_PATTERN)
|
||||
}
|
||||
|
||||
const cfg = await Config.get()
|
||||
for (const item of cfg.skills?.paths ?? []) {
|
||||
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
|
||||
if (!(await Filesystem.isDir(dir))) {
|
||||
log.warn("skill path not found", { path: dir })
|
||||
continue
|
||||
}
|
||||
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
|
||||
for (const url of cfg.skills?.urls ?? []) {
|
||||
for (const dir of await Effect.runPromise(discovery.pull(url))) {
|
||||
state.dirs.add(dir)
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
}
|
||||
|
||||
log.info("init", { count: Object.keys(state.skills).length })
|
||||
}
|
||||
|
||||
const ensure = () => {
|
||||
if (state.task) return state.task
|
||||
state.task = load().catch((err) => {
|
||||
state.task = undefined
|
||||
throw err
|
||||
})
|
||||
return state.task
|
||||
}
|
||||
|
||||
return { ...state, ensure }
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
const discovery = yield* Discovery.Service
|
||||
const state = create(instance, discovery)
|
||||
|
||||
const get = Effect.fn("Skill.get")(function* (name: string) {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
return state.skills[name]
|
||||
})
|
||||
|
||||
const all = Effect.fn("Skill.all")(function* () {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
return Object.values(state.skills)
|
||||
})
|
||||
|
||||
const dirs = Effect.fn("Skill.dirs")(function* () {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
return Array.from(state.dirs)
|
||||
})
|
||||
|
||||
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
if (!agent) return list
|
||||
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||
})
|
||||
|
||||
return Service.of({ get, all, dirs, available })
|
||||
}),
|
||||
).pipe(Layer.fresh)
|
||||
|
||||
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
|
||||
Layer.provide(Discovery.defaultLayer),
|
||||
)
|
||||
|
||||
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||
if (list.length === 0) return "No skills are currently available."
|
||||
|
||||
if (opts.verbose) {
|
||||
return [
|
||||
"<available_skills>",
|
||||
...list.flatMap((skill) => [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||
" </skill>",
|
||||
]),
|
||||
"</available_skills>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Skill as S } from "./service"
|
||||
|
||||
export namespace Skill {
|
||||
export const Info = S.Info
|
||||
export type Info = S.Info
|
||||
|
||||
export const InvalidError = S.InvalidError
|
||||
export const NameMismatchError = S.NameMismatchError
|
||||
|
||||
export type Interface = S.Interface
|
||||
|
||||
export const Service = S.Service
|
||||
export const layer = S.layer
|
||||
export const defaultLayer = S.defaultLayer
|
||||
|
||||
export const fmt = S.fmt
|
||||
|
||||
export async function get(name: string) {
|
||||
return runPromiseInstance(S.Service.use((skill) => skill.get(name)))
|
||||
}
|
||||
|
||||
export async function all() {
|
||||
return runPromiseInstance(S.Service.use((skill) => skill.all()))
|
||||
}
|
||||
|
||||
export async function dirs() {
|
||||
return runPromiseInstance(S.Service.use((skill) => skill.dirs()))
|
||||
}
|
||||
|
||||
export async function available(agent?: Agent.Info) {
|
||||
return runPromiseInstance(S.Service.use((skill) => skill.available(agent)))
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,396 @@
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Snapshot as S } from "./service"
|
||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace Snapshot {
|
||||
export const Patch = S.Patch
|
||||
export type Patch = S.Patch
|
||||
export const Patch = z.object({
|
||||
hash: z.string(),
|
||||
files: z.string().array(),
|
||||
})
|
||||
export type Patch = z.infer<typeof Patch>
|
||||
|
||||
export const FileDiff = S.FileDiff
|
||||
export type FileDiff = S.FileDiff
|
||||
export const FileDiff = z
|
||||
.object({
|
||||
file: z.string(),
|
||||
before: z.string(),
|
||||
after: z.string(),
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
status: z.enum(["added", "deleted", "modified"]).optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FileDiff",
|
||||
})
|
||||
export type FileDiff = z.infer<typeof FileDiff>
|
||||
|
||||
export type Interface = S.Interface
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const prune = "7.days"
|
||||
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
||||
const cfg = ["-c", "core.autocrlf=false", ...core]
|
||||
const quote = [...cfg, "-c", "core.quotepath=false"]
|
||||
|
||||
export const Service = S.Service
|
||||
export const layer = S.layer
|
||||
export const defaultLayer = S.defaultLayer
|
||||
interface GitResult {
|
||||
readonly code: ChildProcessSpawner.ExitCode
|
||||
readonly text: string
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
type State = Omit<Interface, "init">
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
readonly track: () => Effect.Effect<string | undefined>
|
||||
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
|
||||
readonly restore: (snapshot: string) => Effect.Effect<void>
|
||||
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
|
||||
readonly diff: (hash: string) => Effect.Effect<string>
|
||||
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Snapshot.state")(function* (ctx) {
|
||||
const state = {
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id),
|
||||
vcs: ctx.project.vcs,
|
||||
}
|
||||
|
||||
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||
const proc = ChildProcess.make("git", cmd, {
|
||||
cwd: opts?.cwd,
|
||||
env: opts?.env,
|
||||
extendEnv: true,
|
||||
})
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[
|
||||
Stream.mkString(Stream.decodeText(handle.stdout)),
|
||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
text: "",
|
||||
stderr: String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||
|
||||
const enabled = Effect.fnUntraced(function* () {
|
||||
if (state.vcs !== "git") return false
|
||||
return (yield* Effect.promise(() => Config.get())).snapshot !== false
|
||||
})
|
||||
|
||||
const excludes = Effect.fnUntraced(function* () {
|
||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
const file = result.text.trim()
|
||||
if (!file) return
|
||||
if (!(yield* exists(file))) return
|
||||
return file
|
||||
})
|
||||
|
||||
const sync = Effect.fnUntraced(function* () {
|
||||
const file = yield* excludes()
|
||||
const target = path.join(state.gitdir, "info", "exclude")
|
||||
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
|
||||
if (!file) {
|
||||
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
|
||||
return
|
||||
}
|
||||
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const add = Effect.fnUntraced(function* () {
|
||||
yield* sync()
|
||||
yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory })
|
||||
})
|
||||
|
||||
const cleanup = Effect.fnUntraced(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
if (!(yield* exists(state.gitdir))) return
|
||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
const existed = yield* exists(state.gitdir)
|
||||
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
||||
})
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add()
|
||||
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
||||
return hash
|
||||
})
|
||||
|
||||
const patch = Effect.fnUntraced(function* (hash: string) {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
})
|
||||
|
||||
const restore = Effect.fnUntraced(function* (snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: state.worktree })
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
})
|
||||
|
||||
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
const rel = path.relative(state.worktree, file)
|
||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (tree.code === 0 && tree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
yield* remove(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const diff = Effect.fnUntraced(function* (hash: string) {
|
||||
yield* add()
|
||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return result.text.trim()
|
||||
})
|
||||
|
||||
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
|
||||
const result: Snapshot.FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||
{ cwd: state.directory },
|
||||
)
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [adds, dels, file] = line.split("\t")
|
||||
if (!file) continue
|
||||
const binary = adds === "-" && dels === "-"
|
||||
const [before, after] = binary
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const additions = binary ? 0 : parseInt(adds)
|
||||
const deletions = binary ? 0 : parseInt(dels)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.delay(Duration.minutes(1)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return { cleanup, track, patch, restore, revert, diff, diffFull }
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("Snapshot.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
}),
|
||||
cleanup: Effect.fn("Snapshot.cleanup")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.cleanup())
|
||||
}),
|
||||
track: Effect.fn("Snapshot.track")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.track())
|
||||
}),
|
||||
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
|
||||
}),
|
||||
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
|
||||
}),
|
||||
revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
|
||||
}),
|
||||
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
|
||||
}),
|
||||
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(NodeChildProcessSpawner.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
return runPromiseInstance(S.Service.use((svc) => svc.cleanup()))
|
||||
return runPromise((svc) => svc.cleanup())
|
||||
}
|
||||
|
||||
export async function track() {
|
||||
return runPromiseInstance(S.Service.use((svc) => svc.track()))
|
||||
return runPromise((svc) => svc.track())
|
||||
}
|
||||
|
||||
export async function patch(hash: string) {
|
||||
return runPromiseInstance(S.Service.use((svc) => svc.patch(hash)))
|
||||
return runPromise((svc) => svc.patch(hash))
|
||||
}
|
||||
|
||||
export async function restore(snapshot: string) {
|
||||
return runPromiseInstance(S.Service.use((svc) => svc.restore(snapshot)))
|
||||
return runPromise((svc) => svc.restore(snapshot))
|
||||
}
|
||||
|
||||
export async function revert(patches: Patch[]) {
|
||||
return runPromiseInstance(S.Service.use((svc) => svc.revert(patches)))
|
||||
return runPromise((svc) => svc.revert(patches))
|
||||
}
|
||||
|
||||
export async function diff(hash: string) {
|
||||
return runPromiseInstance(S.Service.use((svc) => svc.diff(hash)))
|
||||
return runPromise((svc) => svc.diff(hash))
|
||||
}
|
||||
|
||||
export async function diffFull(from: string, to: string) {
|
||||
return runPromiseInstance(S.Service.use((svc) => svc.diffFull(from, to)))
|
||||
return runPromise((svc) => svc.diffFull(from, to))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace Snapshot {
|
||||
export const Patch = z.object({
|
||||
hash: z.string(),
|
||||
files: z.string().array(),
|
||||
})
|
||||
export type Patch = z.infer<typeof Patch>
|
||||
|
||||
export const FileDiff = z
|
||||
.object({
|
||||
file: z.string(),
|
||||
before: z.string(),
|
||||
after: z.string(),
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
status: z.enum(["added", "deleted", "modified"]).optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FileDiff",
|
||||
})
|
||||
export type FileDiff = z.infer<typeof FileDiff>
|
||||
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const prune = "7.days"
|
||||
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
||||
const cfg = ["-c", "core.autocrlf=false", ...core]
|
||||
const quote = [...cfg, "-c", "core.quotepath=false"]
|
||||
|
||||
interface GitResult {
|
||||
readonly code: ChildProcessSpawner.ExitCode
|
||||
readonly text: string
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
readonly track: () => Effect.Effect<string | undefined>
|
||||
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
|
||||
readonly restore: (snapshot: string) => Effect.Effect<void>
|
||||
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
|
||||
readonly diff: (hash: string) => Effect.Effect<string>
|
||||
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* InstanceContext
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const directory = ctx.directory
|
||||
const worktree = ctx.worktree
|
||||
const project = ctx.project
|
||||
const gitdir = path.join(Global.Path.data, "snapshot", project.id)
|
||||
|
||||
const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||
const proc = ChildProcess.make("git", cmd, {
|
||||
cwd: opts?.cwd,
|
||||
env: opts?.env,
|
||||
extendEnv: true,
|
||||
})
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
text: "",
|
||||
stderr: String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Snapshot-specific error handling on top of AppFileSystem
|
||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||
|
||||
const enabled = Effect.fnUntraced(function* () {
|
||||
if (project.vcs !== "git") return false
|
||||
return (yield* Effect.promise(() => Config.get())).snapshot !== false
|
||||
})
|
||||
|
||||
const excludes = Effect.fnUntraced(function* () {
|
||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: worktree,
|
||||
})
|
||||
const file = result.text.trim()
|
||||
if (!file) return
|
||||
if (!(yield* exists(file))) return
|
||||
return file
|
||||
})
|
||||
|
||||
const sync = Effect.fnUntraced(function* () {
|
||||
const file = yield* excludes()
|
||||
const target = path.join(gitdir, "info", "exclude")
|
||||
yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie)
|
||||
if (!file) {
|
||||
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
|
||||
return
|
||||
}
|
||||
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const add = Effect.fnUntraced(function* () {
|
||||
yield* sync()
|
||||
yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
|
||||
})
|
||||
|
||||
const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
if (!(yield* exists(gitdir))) return
|
||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
})
|
||||
|
||||
const track = Effect.fn("Snapshot.track")(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
const existed = yield* exists(gitdir)
|
||||
yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
|
||||
})
|
||||
yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add()
|
||||
const result = yield* git(args(["write-tree"]), { cwd: directory })
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: directory, git: gitdir })
|
||||
return hash
|
||||
})
|
||||
|
||||
const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||
yield* add()
|
||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
|
||||
cwd: directory,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
})
|
||||
|
||||
const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
})
|
||||
|
||||
const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree })
|
||||
if (result.code !== 0) {
|
||||
const rel = path.relative(worktree, file)
|
||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree })
|
||||
if (tree.code === 0 && tree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
yield* remove(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
|
||||
yield* add()
|
||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
|
||||
cwd: worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return result.text.trim()
|
||||
})
|
||||
|
||||
const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
||||
const result: Snapshot.FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||
{ cwd: directory },
|
||||
)
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{
|
||||
cwd: directory,
|
||||
},
|
||||
)
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [adds, dels, file] = line.split("\t")
|
||||
if (!file) continue
|
||||
const binary = adds === "-" && dels === "-"
|
||||
const [before, after] = binary
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const additions = binary ? 0 : parseInt(adds)
|
||||
const deletions = binary ? 0 : parseInt(dels)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.delay(Duration.minutes(1)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
|
||||
}),
|
||||
).pipe(Layer.fresh)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(NodeChildProcessSpawner.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,10 @@ const log = Log.create({ service: "db" })
|
||||
|
||||
export namespace Database {
|
||||
export const Path = iife(() => {
|
||||
if (Flag.OPENCODE_DB) {
|
||||
if (path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
|
||||
return path.join(Global.Path.data, Flag.OPENCODE_DB)
|
||||
}
|
||||
const channel = Installation.CHANNEL
|
||||
if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
|
||||
return path.join(Global.Path.data, "opencode.db")
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt"
|
||||
import { iife } from "@/util/iife"
|
||||
import { defer } from "@/util/defer"
|
||||
import { Config } from "../config/config"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
|
||||
const parameters = z.object({
|
||||
description: z.string().describe("A short (3-5 words) description of the task"),
|
||||
@@ -31,7 +31,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
// Filter agents by permissions if agent provided
|
||||
const caller = ctx?.agent
|
||||
const accessibleAgents = caller
|
||||
? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
|
||||
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
|
||||
: agents
|
||||
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import z from "zod"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import type { PermissionNext } from "../permission"
|
||||
import type { Permission } from "../permission"
|
||||
import type { SessionID, MessageID } from "../session/schema"
|
||||
import { Truncate } from "./truncate"
|
||||
|
||||
@@ -23,7 +23,7 @@ export namespace Tool {
|
||||
extra?: { [key: string]: any }
|
||||
messages: MessageV2.WithParts[]
|
||||
metadata(input: { title?: string; metadata?: M }): void
|
||||
ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
|
||||
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
|
||||
}
|
||||
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
||||
id: string
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
|
||||
import path from "path"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { evaluate } from "@/permission/evaluate"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Log } from "../util/log"
|
||||
import { ToolID } from "./schema"
|
||||
import { TRUNCATION_DIR } from "./truncation-dir"
|
||||
|
||||
export namespace Truncate {
|
||||
const log = Log.create({ service: "truncation" })
|
||||
const RETENTION = Duration.days(7)
|
||||
|
||||
export const MAX_LINES = 2000
|
||||
export const MAX_BYTES = 50 * 1024
|
||||
export const DIR = TRUNCATION_DIR
|
||||
export const GLOB = path.join(TRUNCATION_DIR, "*")
|
||||
|
||||
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
||||
|
||||
export interface Options {
|
||||
maxLines?: number
|
||||
maxBytes?: number
|
||||
direction?: "head" | "tail"
|
||||
}
|
||||
|
||||
function hasTaskTool(agent?: Agent.Info) {
|
||||
if (!agent?.permission) return false
|
||||
return evaluate("task", "*", agent.permission).action !== "deny"
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
/**
|
||||
* Returns output unchanged when it fits within the limits, otherwise writes the full text
|
||||
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
|
||||
*/
|
||||
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
||||
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
|
||||
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
|
||||
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
)
|
||||
for (const entry of entries) {
|
||||
if (Identifier.timestamp(entry) >= cutoff) continue
|
||||
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
})
|
||||
|
||||
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
|
||||
const maxLines = options.maxLines ?? MAX_LINES
|
||||
const maxBytes = options.maxBytes ?? MAX_BYTES
|
||||
const direction = options.direction ?? "head"
|
||||
const lines = text.split("\n")
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
|
||||
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
||||
return { content: text, truncated: false } as const
|
||||
}
|
||||
|
||||
const out: string[] = []
|
||||
let i = 0
|
||||
let bytes = 0
|
||||
let hitBytes = false
|
||||
|
||||
if (direction === "head") {
|
||||
for (i = 0; i < lines.length && i < maxLines; i++) {
|
||||
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
out.push(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
} else {
|
||||
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
|
||||
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
out.unshift(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
}
|
||||
|
||||
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
||||
const unit = hitBytes ? "bytes" : "lines"
|
||||
const preview = out.join("\n")
|
||||
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
|
||||
|
||||
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
||||
|
||||
const hint = hasTaskTool(agent)
|
||||
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
|
||||
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
|
||||
|
||||
return {
|
||||
content:
|
||||
direction === "head"
|
||||
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
|
||||
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
|
||||
truncated: true,
|
||||
outputPath: file,
|
||||
} as const
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.delay(Duration.minutes(1)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({ cleanup, output })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
||||
}
|
||||
@@ -1,18 +1,144 @@
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
|
||||
import path from "path"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import { Truncate as S } from "./truncate-effect"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { evaluate } from "@/permission/evaluate"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Log } from "../util/log"
|
||||
import { ToolID } from "./schema"
|
||||
import { TRUNCATION_DIR } from "./truncation-dir"
|
||||
|
||||
export namespace Truncate {
|
||||
export const MAX_LINES = S.MAX_LINES
|
||||
export const MAX_BYTES = S.MAX_BYTES
|
||||
export const DIR = S.DIR
|
||||
export const GLOB = S.GLOB
|
||||
const log = Log.create({ service: "truncation" })
|
||||
const RETENTION = Duration.days(7)
|
||||
|
||||
export type Result = S.Result
|
||||
export const MAX_LINES = 2000
|
||||
export const MAX_BYTES = 50 * 1024
|
||||
export const DIR = TRUNCATION_DIR
|
||||
export const GLOB = path.join(TRUNCATION_DIR, "*")
|
||||
|
||||
export type Options = S.Options
|
||||
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
||||
|
||||
export interface Options {
|
||||
maxLines?: number
|
||||
maxBytes?: number
|
||||
direction?: "head" | "tail"
|
||||
}
|
||||
|
||||
function hasTaskTool(agent?: Agent.Info) {
|
||||
if (!agent?.permission) return false
|
||||
return evaluate("task", "*", agent.permission).action !== "deny"
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
/**
|
||||
* Returns output unchanged when it fits within the limits, otherwise writes the full text
|
||||
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
|
||||
*/
|
||||
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
||||
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
|
||||
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
|
||||
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
)
|
||||
for (const entry of entries) {
|
||||
if (Identifier.timestamp(entry) >= cutoff) continue
|
||||
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
})
|
||||
|
||||
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
|
||||
const maxLines = options.maxLines ?? MAX_LINES
|
||||
const maxBytes = options.maxBytes ?? MAX_BYTES
|
||||
const direction = options.direction ?? "head"
|
||||
const lines = text.split("\n")
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
|
||||
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
||||
return { content: text, truncated: false } as const
|
||||
}
|
||||
|
||||
const out: string[] = []
|
||||
let i = 0
|
||||
let bytes = 0
|
||||
let hitBytes = false
|
||||
|
||||
if (direction === "head") {
|
||||
for (i = 0; i < lines.length && i < maxLines; i++) {
|
||||
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
out.push(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
} else {
|
||||
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
|
||||
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
out.unshift(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
}
|
||||
|
||||
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
||||
const unit = hitBytes ? "bytes" : "lines"
|
||||
const preview = out.join("\n")
|
||||
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
|
||||
|
||||
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
||||
|
||||
const hint = hasTaskTool(agent)
|
||||
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
|
||||
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
|
||||
|
||||
return {
|
||||
content:
|
||||
direction === "head"
|
||||
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
|
||||
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
|
||||
truncated: true,
|
||||
outputPath: file,
|
||||
} as const
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.delay(Duration.minutes(1)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({ cleanup, output })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
|
||||
return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent)))
|
||||
return runPromise((s) => s.output(text, options, agent))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { AccountRepo } from "../../src/account/repo"
|
||||
import { Account } from "../../src/account/effect"
|
||||
import { Account } from "../../src/account"
|
||||
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
|
||||
import { Database } from "../../src/storage/db"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { afterEach, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { PermissionNext } from "../../src/permission"
|
||||
import { Permission } from "../../src/permission"
|
||||
|
||||
// Helper to evaluate permission for a tool with wildcard pattern
|
||||
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
|
||||
function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
|
||||
if (!agent) return undefined
|
||||
return PermissionNext.evaluate(permission, "*", agent.permission).action
|
||||
return Permission.evaluate(permission, "*", agent.permission).action
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("returns default native agents when no config", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
@@ -38,7 +42,7 @@ test("build agent has correct default properties", async () => {
|
||||
expect(build).toBeDefined()
|
||||
expect(build?.mode).toBe("primary")
|
||||
expect(build?.native).toBe(true)
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
expect(evalPerm(build, "edit")).toBe("ask")
|
||||
expect(evalPerm(build, "bash")).toBe("allow")
|
||||
},
|
||||
})
|
||||
@@ -54,7 +58,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => {
|
||||
// Wildcard is denied
|
||||
expect(evalPerm(plan, "edit")).toBe("deny")
|
||||
// But specific path is allowed
|
||||
expect(PermissionNext.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
|
||||
expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -83,8 +87,8 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy
|
||||
fn: async () => {
|
||||
const explore = await Agent.get("explore")
|
||||
expect(explore).toBeDefined()
|
||||
expect(PermissionNext.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
|
||||
expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -216,9 +220,9 @@ test("agent permission config merges with defaults", async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build).toBeDefined()
|
||||
// Specific pattern is denied
|
||||
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
|
||||
// Edit still allowed
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
|
||||
// Edit still asks (default behavior)
|
||||
expect(evalPerm(build, "edit")).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -501,9 +505,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -525,9 +529,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -548,8 +552,8 @@ test("explicit Truncate.GLOB deny is respected", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -582,7 +586,7 @@ description: Permission skill.
|
||||
const build = await Agent.get("build")
|
||||
const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
|
||||
const target = path.join(skillDir, "reference", "notes.md")
|
||||
expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
describe("BunProc registry configuration", () => {
|
||||
test("should not contain hardcoded registry parameters", async () => {
|
||||
// Read the bun/index.ts file
|
||||
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
|
||||
const content = await fs.readFile(bunIndexPath, "utf-8")
|
||||
|
||||
// Verify that no hardcoded registry is present
|
||||
expect(content).not.toContain("--registry=")
|
||||
expect(content).not.toContain("hasNpmRcConfig")
|
||||
expect(content).not.toContain("NpmRc")
|
||||
})
|
||||
|
||||
test("should use Bun's default registry resolution", async () => {
|
||||
// Read the bun/index.ts file
|
||||
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
|
||||
const content = await fs.readFile(bunIndexPath, "utf-8")
|
||||
|
||||
// Verify that it uses Bun's default resolution
|
||||
expect(content).toContain("Bun's default registry resolution")
|
||||
expect(content).toContain("Bun will use them automatically")
|
||||
expect(content).toContain("No need to pass --registry flag")
|
||||
})
|
||||
|
||||
test("should have correct command structure without registry", async () => {
|
||||
// Read the bun/index.ts file
|
||||
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
|
||||
const content = await fs.readFile(bunIndexPath, "utf-8")
|
||||
|
||||
// Extract the install function
|
||||
const installFunctionMatch = content.match(/export async function install[\s\S]*?^ }/m)
|
||||
expect(installFunctionMatch).toBeTruthy()
|
||||
|
||||
if (installFunctionMatch) {
|
||||
const installFunction = installFunctionMatch[0]
|
||||
|
||||
// Verify expected arguments are present
|
||||
expect(installFunction).toContain('"add"')
|
||||
expect(installFunction).toContain('"--force"')
|
||||
expect(installFunction).toContain('"--exact"')
|
||||
expect(installFunction).toContain('"--cwd"')
|
||||
expect(installFunction).toContain("Global.Path.cache")
|
||||
expect(installFunction).toContain('pkg + "@" + version')
|
||||
|
||||
// Verify no registry argument is added
|
||||
expect(installFunction).not.toContain('"--registry"')
|
||||
expect(installFunction).not.toContain('args.push("--registry')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, describe, mock, afterEach, spyOn } from "bun:test"
|
||||
import { test, expect, describe, mock, afterEach } from "bun:test"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Auth } from "../../src/auth"
|
||||
@@ -10,7 +10,6 @@ import { pathToFileURL } from "url"
|
||||
import { Global } from "../../src/global"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { BunProc } from "../../src/bun"
|
||||
|
||||
// Get managed config directory from environment (set in preload.ts)
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
@@ -251,7 +250,7 @@ test("resolves env templates in account config with account token", async () =>
|
||||
const originalToken = Account.token
|
||||
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
|
||||
|
||||
Account.active = mock(() => ({
|
||||
Account.active = mock(async () => ({
|
||||
id: AccountID.make("account-1"),
|
||||
email: "user@example.com",
|
||||
url: "https://control.example.com",
|
||||
@@ -764,39 +763,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("serializes concurrent config dependency installs", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dirs = [path.join(tmp.path, "a"), path.join(tmp.path, "b")]
|
||||
await Promise.all(dirs.map((dir) => fs.mkdir(dir, { recursive: true })))
|
||||
|
||||
const seen: string[] = []
|
||||
let active = 0
|
||||
let max = 0
|
||||
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
seen.push(opts?.cwd ?? "")
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
active--
|
||||
return {
|
||||
code: 0,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(dirs.map((dir) => Config.installDependencies(dir)))
|
||||
} finally {
|
||||
run.mockRestore()
|
||||
}
|
||||
|
||||
expect(max).toBe(1)
|
||||
expect(seen.toSorted()).toEqual(dirs.toSorted())
|
||||
expect(await Filesystem.exists(path.join(dirs[0], "package.json"))).toBe(true)
|
||||
expect(await Filesystem.exists(path.join(dirs[1], "package.json"))).toBe(true)
|
||||
})
|
||||
|
||||
test("resolves scoped npm plugins in config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
384
packages/opencode/test/effect/instance-state.test.ts
Normal file
384
packages/opencode/test/effect/instance-state.test.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect"
|
||||
import { InstanceState } from "../../src/effect/instance-state"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
async function access<A, E>(state: InstanceState<A, E>, dir: string) {
|
||||
return Instance.provide({
|
||||
directory: dir,
|
||||
fn: () => Effect.runPromise(InstanceState.get(state)),
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("InstanceState caches values per directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
|
||||
|
||||
const a = yield* Effect.promise(() => access(state, tmp.path))
|
||||
const b = yield* Effect.promise(() => access(state, tmp.path))
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(n).toBe(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState isolates directories", async () => {
|
||||
await using one = await tmpdir()
|
||||
await using two = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
|
||||
|
||||
const a = yield* Effect.promise(() => access(state, one.path))
|
||||
const b = yield* Effect.promise(() => access(state, two.path))
|
||||
const c = yield* Effect.promise(() => access(state, one.path))
|
||||
|
||||
expect(a).toBe(c)
|
||||
expect(a).not.toBe(b)
|
||||
expect(n).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState invalidates on reload", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const seen: string[] = []
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make(() =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => ({ n: ++n })),
|
||||
(value) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(String(value.n))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const a = yield* Effect.promise(() => access(state, tmp.path))
|
||||
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
|
||||
const b = yield* Effect.promise(() => access(state, tmp.path))
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
expect(seen).toEqual(["1"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState invalidates on disposeAll", async () => {
|
||||
await using one = await tmpdir()
|
||||
await using two = await tmpdir()
|
||||
const seen: string[] = []
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((ctx) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => ({ dir: ctx.directory })),
|
||||
(value) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(value.dir)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => access(state, one.path))
|
||||
yield* Effect.promise(() => access(state, two.path))
|
||||
yield* Effect.promise(() => Instance.disposeAll())
|
||||
|
||||
expect(seen.sort()).toEqual([one.path, two.path].sort())
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState.get reads the current directory lazily", async () => {
|
||||
await using one = await tmpdir()
|
||||
await using two = await tmpdir()
|
||||
|
||||
interface Api {
|
||||
readonly get: () => Effect.Effect<string>
|
||||
}
|
||||
|
||||
class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateLazy") {
|
||||
static readonly layer = Layer.effect(
|
||||
Test,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
|
||||
const get = InstanceState.get(state)
|
||||
|
||||
return Test.of({
|
||||
get: Effect.fn("Test.get")(function* () {
|
||||
return yield* get
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const rt = ManagedRuntime.make(Test.layer)
|
||||
|
||||
try {
|
||||
const a = await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
})
|
||||
const b = await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
})
|
||||
|
||||
expect(a).toBe(one.path)
|
||||
expect(b).toBe(two.path)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("InstanceState preserves directory across async boundaries", async () => {
|
||||
await using one = await tmpdir({ git: true })
|
||||
await using two = await tmpdir({ git: true })
|
||||
await using three = await tmpdir({ git: true })
|
||||
|
||||
interface Api {
|
||||
readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
|
||||
}
|
||||
|
||||
class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateAsync") {
|
||||
static readonly layer = Layer.effect(
|
||||
Test,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((ctx) =>
|
||||
Effect.sync(() => ({
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
project: ctx.project.id,
|
||||
})),
|
||||
)
|
||||
|
||||
return Test.of({
|
||||
get: Effect.fn("Test.get")(function* () {
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
yield* Effect.sleep(Duration.millis(1))
|
||||
for (let i = 0; i < 100; i++) {
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
for (let i = 0; i < 100; i++) {
|
||||
yield* Effect.promise(() => Promise.resolve())
|
||||
}
|
||||
yield* Effect.sleep(Duration.millis(2))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* InstanceState.get(state)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const rt = ManagedRuntime.make(Test.layer)
|
||||
|
||||
try {
|
||||
const [a, b, c] = await Promise.all([
|
||||
Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
}),
|
||||
Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
}),
|
||||
Instance.provide({
|
||||
directory: three.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
}),
|
||||
])
|
||||
|
||||
expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project })
|
||||
expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project })
|
||||
expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project })
|
||||
expect(a.project).not.toBe(b.project)
|
||||
expect(a.project).not.toBe(c.project)
|
||||
expect(b.project).not.toBe(c.project)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("InstanceState survives high-contention concurrent access", async () => {
|
||||
const N = 20
|
||||
const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir()))
|
||||
|
||||
interface Api {
|
||||
readonly get: () => Effect.Effect<string>
|
||||
}
|
||||
|
||||
class Test extends ServiceMap.Service<Test, Api>()("@test/HighContention") {
|
||||
static readonly layer = Layer.effect(
|
||||
Test,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
|
||||
|
||||
return Test.of({
|
||||
get: Effect.fn("Test.get")(function* () {
|
||||
// Interleave many async hops to maximize chance of ALS corruption
|
||||
for (let i = 0; i < 10; i++) {
|
||||
yield* Effect.promise(() => Bun.sleep(Math.random() * 3))
|
||||
yield* Effect.yieldNow
|
||||
yield* Effect.promise(() => Promise.resolve())
|
||||
}
|
||||
return yield* InstanceState.get(state)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const rt = ManagedRuntime.make(Test.layer)
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
dirs.map((d) =>
|
||||
Instance.provide({
|
||||
directory: d.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
expect(results[i]).toBe(dirs[i].path)
|
||||
}
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
for (const d of dirs) await d[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
|
||||
test("InstanceState correct after interleaved init and dispose", async () => {
|
||||
await using one = await tmpdir()
|
||||
await using two = await tmpdir()
|
||||
|
||||
interface Api {
|
||||
readonly get: () => Effect.Effect<string>
|
||||
}
|
||||
|
||||
class Test extends ServiceMap.Service<Test, Api>()("@test/InterleavedDispose") {
|
||||
static readonly layer = Layer.effect(
|
||||
Test,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((ctx) =>
|
||||
Effect.promise(async () => {
|
||||
await Bun.sleep(5) // slow init
|
||||
return ctx.directory
|
||||
}),
|
||||
)
|
||||
|
||||
return Test.of({
|
||||
get: Effect.fn("Test.get")(function* () {
|
||||
return yield* InstanceState.get(state)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const rt = ManagedRuntime.make(Test.layer)
|
||||
|
||||
try {
|
||||
// Init both directories
|
||||
const a = await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
})
|
||||
expect(a).toBe(one.path)
|
||||
|
||||
// Dispose one directory, access the other concurrently
|
||||
const [, b] = await Promise.all([
|
||||
Instance.reload({ directory: one.path }),
|
||||
Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
}),
|
||||
])
|
||||
expect(b).toBe(two.path)
|
||||
|
||||
// Re-access disposed directory - should get fresh state
|
||||
const c = await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
})
|
||||
expect(c).toBe(one.path)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("InstanceState mutation in one directory does not leak to another", async () => {
|
||||
await using one = await tmpdir()
|
||||
await using two = await tmpdir()
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
|
||||
|
||||
// Mutate state in directory one
|
||||
const s1 = yield* Effect.promise(() => access(state, one.path))
|
||||
s1.count = 42
|
||||
|
||||
// Access directory two — should be independent
|
||||
const s2 = yield* Effect.promise(() => access(state, two.path))
|
||||
expect(s2.count).toBe(0)
|
||||
|
||||
// Confirm directory one still has the mutation
|
||||
const s1again = yield* Effect.promise(() => access(state, one.path))
|
||||
expect(s1again.count).toBe(42)
|
||||
expect(s1again).toBe(s1) // same reference
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState dedupes concurrent lookups", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make(() =>
|
||||
Effect.promise(async () => {
|
||||
n += 1
|
||||
await Bun.sleep(10)
|
||||
return { n }
|
||||
}),
|
||||
)
|
||||
|
||||
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
|
||||
expect(a).toBe(b)
|
||||
expect(n).toBe(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
46
packages/opencode/test/effect/run-service.test.ts
Normal file
46
packages/opencode/test/effect/run-service.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { makeRunPromise } from "../../src/effect/run-service"
|
||||
|
||||
class Shared extends ServiceMap.Service<Shared, { readonly id: number }>()("@test/Shared") {}
|
||||
|
||||
test("makeRunPromise shares dependent layers through the shared memo map", async () => {
|
||||
let n = 0
|
||||
|
||||
const shared = Layer.effect(
|
||||
Shared,
|
||||
Effect.sync(() => {
|
||||
n += 1
|
||||
return Shared.of({ id: n })
|
||||
}),
|
||||
)
|
||||
|
||||
class One extends ServiceMap.Service<One, { readonly get: () => Effect.Effect<number> }>()("@test/One") {}
|
||||
const one = Layer.effect(
|
||||
One,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Shared
|
||||
return One.of({
|
||||
get: Effect.fn("One.get")(() => Effect.succeed(svc.id)),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(shared))
|
||||
|
||||
class Two extends ServiceMap.Service<Two, { readonly get: () => Effect.Effect<number> }>()("@test/Two") {}
|
||||
const two = Layer.effect(
|
||||
Two,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Shared
|
||||
return Two.of({
|
||||
get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(shared))
|
||||
|
||||
const runOne = makeRunPromise(One, one)
|
||||
const runTwo = makeRunPromise(Two, two)
|
||||
|
||||
expect(await runOne((svc) => svc.get())).toBe(1)
|
||||
expect(await runTwo((svc) => svc.get())).toBe(1)
|
||||
expect(n).toBe(1)
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { runtime, runPromiseInstance } from "../../src/effect/runtime"
|
||||
import { Auth } from "../../src/auth/effect"
|
||||
import { Instances } from "../../src/effect/instances"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ProviderAuth } from "../../src/provider/auth"
|
||||
import { Vcs } from "../../src/project/vcs"
|
||||
import { Question } from "../../src/question"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
/**
|
||||
* Integration tests for the Effect runtime and LayerMap-based instance system.
|
||||
*
|
||||
* Each instance service layer has `.pipe(Layer.fresh)` at its definition site
|
||||
* so it is always rebuilt per directory, while shared dependencies are provided
|
||||
* outside the fresh boundary and remain memoizable.
|
||||
*
|
||||
* These tests verify the invariants using object identity (===) on the real
|
||||
* production services — not mock services or return-value checks.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const grabInstance = (service: any) => runPromiseInstance(service.use(Effect.succeed))
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const grabGlobal = (service: any) => runtime.runPromise(service.use(Effect.succeed))
|
||||
|
||||
describe("effect/runtime", () => {
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("global services are shared across directories", async () => {
|
||||
await using one = await tmpdir({ git: true })
|
||||
await using two = await tmpdir({ git: true })
|
||||
|
||||
// Auth is a global service — it should be the exact same object
|
||||
// regardless of which directory we're in.
|
||||
const authOne = await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => grabGlobal(Auth.Service),
|
||||
})
|
||||
|
||||
const authTwo = await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => grabGlobal(Auth.Service),
|
||||
})
|
||||
|
||||
expect(authOne).toBe(authTwo)
|
||||
})
|
||||
|
||||
test("instance services with global deps share the global (ProviderAuth → Auth)", async () => {
|
||||
await using one = await tmpdir({ git: true })
|
||||
await using two = await tmpdir({ git: true })
|
||||
|
||||
// ProviderAuth depends on Auth via defaultLayer.
|
||||
// The instance service itself should be different per directory,
|
||||
// but the underlying Auth should be shared.
|
||||
const paOne = await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => grabInstance(ProviderAuth.Service),
|
||||
})
|
||||
|
||||
const paTwo = await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => grabInstance(ProviderAuth.Service),
|
||||
})
|
||||
|
||||
// Different directories → different ProviderAuth instances.
|
||||
expect(paOne).not.toBe(paTwo)
|
||||
|
||||
// But the global Auth is the same object in both.
|
||||
const authOne = await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => grabGlobal(Auth.Service),
|
||||
})
|
||||
const authTwo = await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => grabGlobal(Auth.Service),
|
||||
})
|
||||
expect(authOne).toBe(authTwo)
|
||||
})
|
||||
|
||||
test("instance services are shared within the same directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
expect(await grabInstance(Vcs.Service)).toBe(await grabInstance(Vcs.Service))
|
||||
expect(await grabInstance(Question.Service)).toBe(await grabInstance(Question.Service))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("different directories get different service instances", async () => {
|
||||
await using one = await tmpdir({ git: true })
|
||||
await using two = await tmpdir({ git: true })
|
||||
|
||||
const vcsOne = await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => grabInstance(Vcs.Service),
|
||||
})
|
||||
|
||||
const vcsTwo = await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => grabInstance(Vcs.Service),
|
||||
})
|
||||
|
||||
expect(vcsOne).not.toBe(vcsTwo)
|
||||
})
|
||||
|
||||
test("disposal rebuilds services with a new instance", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await grabInstance(Question.Service)
|
||||
|
||||
await runtime.runPromise(Instances.use((map) => map.invalidate(Instance.directory)))
|
||||
|
||||
const after = await grabInstance(Question.Service)
|
||||
expect(after).not.toBe(before)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { afterEach, describe, test, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
@@ -7,6 +7,10 @@ import { Instance } from "../../src/project/instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
describe("file/index Filesystem patterns", () => {
|
||||
describe("File.read() - text content", () => {
|
||||
test("reads text file via Filesystem.readText()", async () => {
|
||||
@@ -689,6 +693,18 @@ describe("file/index Filesystem patterns", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("search works before explicit init", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.search({ query: "main", type: "file" })
|
||||
expect(result.some((f) => f.includes("main"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("empty query returns dirs sorted with hidden last", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
@@ -785,6 +801,23 @@ describe("file/index Filesystem patterns", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("search refreshes after init when files change", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
|
||||
|
||||
await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8")
|
||||
|
||||
const result = await File.search({ query: "fresh", type: "file" })
|
||||
expect(result).toContain("fresh.ts")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.read() - diff/patch", () => {
|
||||
@@ -849,4 +882,65 @@ describe("file/index Filesystem patterns", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("InstanceState isolation", () => {
|
||||
test("two directories get independent file caches", async () => {
|
||||
await using one = await tmpdir({ git: true })
|
||||
await using two = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8")
|
||||
await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
const results = await File.search({ query: "a.ts", type: "file" })
|
||||
expect(results).toContain("a.ts")
|
||||
const results2 = await File.search({ query: "b.ts", type: "file" })
|
||||
expect(results2).not.toContain("b.ts")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
const results = await File.search({ query: "b.ts", type: "file" })
|
||||
expect(results).toContain("b.ts")
|
||||
const results2 = await File.search({ query: "a.ts", type: "file" })
|
||||
expect(results2).not.toContain("a.ts")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("disposal gives fresh state on next access", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
const results = await File.search({ query: "before", type: "file" })
|
||||
expect(results).toContain("before.ts")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.disposeAll()
|
||||
|
||||
await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8")
|
||||
await fs.rm(path.join(tmp.path, "before.ts"))
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
const results = await File.search({ query: "after", type: "file" })
|
||||
expect(results).toContain("after.ts")
|
||||
const stale = await File.search({ query: "before", type: "file" })
|
||||
expect(stale).not.toContain("before.ts")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,9 @@ import { SessionID } from "../../src/session/schema"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(() => Instance.disposeAll())
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
async function touch(file: string, time: number) {
|
||||
const date = new Date(time)
|
||||
@@ -84,6 +86,28 @@ describe("file/time", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("isolates reads by directory", async () => {
|
||||
await using one = await tmpdir()
|
||||
await using two = await tmpdir()
|
||||
await using shared = await tmpdir()
|
||||
const filepath = path.join(shared.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: async () => {
|
||||
expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("assert()", () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
||||
directory,
|
||||
FileWatcher.layer,
|
||||
async (rt) => {
|
||||
await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
|
||||
await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
|
||||
await Effect.runPromise(ready(directory))
|
||||
await Effect.runPromise(body)
|
||||
},
|
||||
@@ -136,7 +136,9 @@ function ready(directory: string) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeWatcher("FileWatcher", () => {
|
||||
afterEach(() => Instance.disposeAll())
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("publishes root create, update, and delete events", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
@@ -2,11 +2,16 @@ import { Effect } from "effect"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { withServices } from "../fixture/instance"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { File } from "../../src/file"
|
||||
import { Format } from "../../src/format"
|
||||
import * as Formatter from "../../src/format/formatter"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
||||
describe("Format", () => {
|
||||
afterEach(() => Instance.disposeAll())
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("status() returns built-in formatters when no config overrides", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
@@ -62,4 +67,106 @@ describe("Format", () => {
|
||||
await rt.runPromise(Format.Service.use(() => Effect.void))
|
||||
})
|
||||
})
|
||||
|
||||
test("status() initializes formatter state per directory", async () => {
|
||||
await using off = await tmpdir({
|
||||
config: { formatter: false },
|
||||
})
|
||||
await using on = await tmpdir()
|
||||
|
||||
const a = await Instance.provide({
|
||||
directory: off.path,
|
||||
fn: () => Format.status(),
|
||||
})
|
||||
const b = await Instance.provide({
|
||||
directory: on.path,
|
||||
fn: () => Format.status(),
|
||||
})
|
||||
|
||||
expect(a).toEqual([])
|
||||
expect(b.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("runs enabled checks for matching formatters in parallel", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
const file = `${tmp.path}/test.parallel`
|
||||
await Bun.write(file, "x")
|
||||
|
||||
const one = {
|
||||
extensions: Formatter.gofmt.extensions,
|
||||
enabled: Formatter.gofmt.enabled,
|
||||
command: Formatter.gofmt.command,
|
||||
}
|
||||
const two = {
|
||||
extensions: Formatter.mix.extensions,
|
||||
enabled: Formatter.mix.enabled,
|
||||
command: Formatter.mix.command,
|
||||
}
|
||||
|
||||
let active = 0
|
||||
let max = 0
|
||||
|
||||
Formatter.gofmt.extensions = [".parallel"]
|
||||
Formatter.mix.extensions = [".parallel"]
|
||||
Formatter.gofmt.command = ["sh", "-c", "true"]
|
||||
Formatter.mix.command = ["sh", "-c", "true"]
|
||||
Formatter.gofmt.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Bun.sleep(20)
|
||||
active--
|
||||
return true
|
||||
}
|
||||
Formatter.mix.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Bun.sleep(20)
|
||||
active--
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
await withServices(tmp.path, Format.layer, async (rt) => {
|
||||
await rt.runPromise(Format.Service.use((s) => s.init()))
|
||||
await Bus.publish(File.Event.Edited, { file })
|
||||
})
|
||||
} finally {
|
||||
Formatter.gofmt.extensions = one.extensions
|
||||
Formatter.gofmt.enabled = one.enabled
|
||||
Formatter.gofmt.command = one.command
|
||||
Formatter.mix.extensions = two.extensions
|
||||
Formatter.mix.enabled = two.enabled
|
||||
Formatter.mix.command = two.command
|
||||
}
|
||||
|
||||
expect(max).toBe(2)
|
||||
})
|
||||
|
||||
test("runs matching formatters sequentially for the same file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
formatter: {
|
||||
first: {
|
||||
command: ["sh", "-c", 'sleep 0.05; v=$(cat "$1"); printf \'%sA\' "$v" > "$1"', "sh", "$FILE"],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
second: {
|
||||
command: ["sh", "-c", 'v=$(cat "$1"); printf \'%sB\' "$v" > "$1"', "sh", "$FILE"],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const file = `${tmp.path}/test.seq`
|
||||
await Bun.write(file, "x")
|
||||
|
||||
await withServices(tmp.path, Format.layer, async (rt) => {
|
||||
await rt.runPromise(Format.Service.use((s) => s.init()))
|
||||
await Bus.publish(File.Event.Edited, { file })
|
||||
})
|
||||
|
||||
expect(await Bun.file(file).text()).toBe("xAB")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { PermissionNext } from "../src/permission"
|
||||
import { afterEach, describe, test, expect } from "bun:test"
|
||||
import { Permission } from "../src/permission"
|
||||
import { Config } from "../src/config/config"
|
||||
import { Instance } from "../src/project/instance"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
describe("PermissionNext.evaluate for permission.task", () => {
|
||||
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
describe("Permission.evaluate for permission.task", () => {
|
||||
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
|
||||
Object.entries(rules).map(([pattern, action]) => ({
|
||||
permission: "task",
|
||||
pattern,
|
||||
@@ -13,42 +17,42 @@ describe("PermissionNext.evaluate for permission.task", () => {
|
||||
}))
|
||||
|
||||
test("returns ask when no match (default)", () => {
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", []).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "code-reviewer", []).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("returns deny for explicit deny", () => {
|
||||
const ruleset = createRuleset({ "code-reviewer": "deny" })
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
})
|
||||
|
||||
test("returns allow for explicit allow", () => {
|
||||
const ruleset = createRuleset({ "code-reviewer": "allow" })
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
|
||||
})
|
||||
|
||||
test("returns ask for explicit ask", () => {
|
||||
const ruleset = createRuleset({ "code-reviewer": "ask" })
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("matches wildcard patterns with deny", () => {
|
||||
const ruleset = createRuleset({ "orchestrator-*": "deny" })
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("matches wildcard patterns with allow", () => {
|
||||
const ruleset = createRuleset({ "orchestrator-*": "allow" })
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
|
||||
})
|
||||
|
||||
test("matches wildcard patterns with ask", () => {
|
||||
const ruleset = createRuleset({ "orchestrator-*": "ask" })
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
|
||||
const globalRuleset = createRuleset({ "*": "ask" })
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("later rules take precedence (last match wins)", () => {
|
||||
@@ -56,22 +60,22 @@ describe("PermissionNext.evaluate for permission.task", () => {
|
||||
"orchestrator-*": "deny",
|
||||
"orchestrator-fast": "allow",
|
||||
})
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
|
||||
})
|
||||
|
||||
test("matches global wildcard", () => {
|
||||
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
|
||||
})
|
||||
})
|
||||
|
||||
describe("PermissionNext.disabled for task tool", () => {
|
||||
describe("Permission.disabled for task tool", () => {
|
||||
// Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
|
||||
// It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
|
||||
// It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
|
||||
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
|
||||
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
|
||||
Object.entries(rules).map(([pattern, action]) => ({
|
||||
permission: "task",
|
||||
pattern,
|
||||
@@ -85,7 +89,7 @@ describe("PermissionNext.disabled for task tool", () => {
|
||||
"orchestrator-*": "allow",
|
||||
"*": "deny",
|
||||
})
|
||||
const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset)
|
||||
const disabled = Permission.disabled(["task", "bash", "read"], ruleset)
|
||||
// The task tool IS disabled because there's a pattern: "*" with action: "deny"
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
})
|
||||
@@ -95,14 +99,14 @@ describe("PermissionNext.disabled for task tool", () => {
|
||||
"orchestrator-*": "ask",
|
||||
"*": "deny",
|
||||
})
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
// The task tool IS disabled because there's a pattern: "*" with action: "deny"
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
})
|
||||
|
||||
test("task tool is disabled when global deny pattern exists", () => {
|
||||
const ruleset = createRuleset({ "*": "deny" })
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
})
|
||||
|
||||
@@ -113,13 +117,13 @@ describe("PermissionNext.disabled for task tool", () => {
|
||||
"orchestrator-*": "deny",
|
||||
general: "deny",
|
||||
})
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
// The task tool is NOT disabled because no rule has pattern: "*" with action: "deny"
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
})
|
||||
|
||||
test("task tool is enabled when no task rules exist (default ask)", () => {
|
||||
const disabled = PermissionNext.disabled(["task"], [])
|
||||
const disabled = Permission.disabled(["task"], [])
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
})
|
||||
|
||||
@@ -129,7 +133,7 @@ describe("PermissionNext.disabled for task tool", () => {
|
||||
"*": "deny",
|
||||
"orchestrator-coder": "allow",
|
||||
})
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
// The disabled() function uses findLast and checks if the last matching rule
|
||||
// has pattern: "*" and action: "deny". In this case, the last rule matching
|
||||
// "task" permission has pattern "orchestrator-coder", not "*", so not disabled
|
||||
@@ -155,11 +159,11 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
// general and orchestrator-fast should be allowed, code-reviewer denied
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -180,11 +184,11 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
// general and code-reviewer should be ask, orchestrator-* denied
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -205,11 +209,11 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
// Unspecified agents default to "ask"
|
||||
expect(PermissionNext.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -232,18 +236,18 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
|
||||
// Verify task permissions
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
|
||||
// Verify other tool permissions
|
||||
expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("bash", "*", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("edit", "*", ruleset).action).toBe("ask")
|
||||
|
||||
// Verify disabled tools
|
||||
const disabled = PermissionNext.disabled(["bash", "edit", "task"], ruleset)
|
||||
const disabled = Permission.disabled(["bash", "edit", "task"], ruleset)
|
||||
expect(disabled.has("bash")).toBe(false)
|
||||
expect(disabled.has("edit")).toBe(false)
|
||||
// task is NOT disabled because disabled() uses findLast, and the last rule
|
||||
@@ -270,16 +274,16 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
|
||||
// Last matching rule wins - "*" deny is last, so all agents are denied
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "unknown", ruleset).action).toBe("deny")
|
||||
|
||||
// Since "*": "deny" is the last rule, disabled() finds it with findLast
|
||||
// and sees pattern: "*" with action: "deny", so task is disabled
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
},
|
||||
})
|
||||
@@ -301,17 +305,17 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
|
||||
// Evaluate uses findLast - "general" allow comes after "*" deny
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
// Other agents still denied by the earlier "*" deny
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
|
||||
// disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny"
|
||||
// In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*"
|
||||
// So the task tool is NOT disabled (even though most subagents are denied)
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { afterEach, test, expect } from "bun:test"
|
||||
import os from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { runtime } from "../../src/effect/runtime"
|
||||
import { Instances } from "../../src/effect/instances"
|
||||
import { PermissionNext } from "../../src/permission"
|
||||
import { PermissionNext as S } from "../../src/permission"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -16,8 +12,8 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
async function rejectAll(message?: string) {
|
||||
for (const req of await PermissionNext.list()) {
|
||||
await PermissionNext.reply({
|
||||
for (const req of await Permission.list()) {
|
||||
await Permission.reply({
|
||||
requestID: req.id,
|
||||
reply: "reject",
|
||||
message,
|
||||
@@ -27,22 +23,22 @@ async function rejectAll(message?: string) {
|
||||
|
||||
async function waitForPending(count: number) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const list = await PermissionNext.list()
|
||||
const list = await Permission.list()
|
||||
if (list.length === count) return list
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
return PermissionNext.list()
|
||||
return Permission.list()
|
||||
}
|
||||
|
||||
// fromConfig tests
|
||||
|
||||
test("fromConfig - string value becomes wildcard rule", () => {
|
||||
const result = PermissionNext.fromConfig({ bash: "allow" })
|
||||
const result = Permission.fromConfig({ bash: "allow" })
|
||||
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
|
||||
})
|
||||
|
||||
test("fromConfig - object value converts to rules array", () => {
|
||||
const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
|
||||
const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" } })
|
||||
expect(result).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "rm", action: "deny" },
|
||||
@@ -50,7 +46,7 @@ test("fromConfig - object value converts to rules array", () => {
|
||||
})
|
||||
|
||||
test("fromConfig - mixed string and object values", () => {
|
||||
const result = PermissionNext.fromConfig({
|
||||
const result = Permission.fromConfig({
|
||||
bash: { "*": "allow", rm: "deny" },
|
||||
edit: "allow",
|
||||
webfetch: "ask",
|
||||
@@ -64,51 +60,51 @@ test("fromConfig - mixed string and object values", () => {
|
||||
})
|
||||
|
||||
test("fromConfig - empty object", () => {
|
||||
const result = PermissionNext.fromConfig({})
|
||||
const result = Permission.fromConfig({})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("fromConfig - expands tilde to home directory", () => {
|
||||
const result = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
|
||||
const result = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
|
||||
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
|
||||
})
|
||||
|
||||
test("fromConfig - expands $HOME to home directory", () => {
|
||||
const result = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
|
||||
const result = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
|
||||
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
|
||||
})
|
||||
|
||||
test("fromConfig - expands $HOME without trailing slash", () => {
|
||||
const result = PermissionNext.fromConfig({ external_directory: { $HOME: "allow" } })
|
||||
const result = Permission.fromConfig({ external_directory: { $HOME: "allow" } })
|
||||
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
|
||||
})
|
||||
|
||||
test("fromConfig - does not expand tilde in middle of path", () => {
|
||||
const result = PermissionNext.fromConfig({ external_directory: { "/some/~/path": "allow" } })
|
||||
const result = Permission.fromConfig({ external_directory: { "/some/~/path": "allow" } })
|
||||
expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
|
||||
})
|
||||
|
||||
test("fromConfig - expands exact tilde to home directory", () => {
|
||||
const result = PermissionNext.fromConfig({ external_directory: { "~": "allow" } })
|
||||
const result = Permission.fromConfig({ external_directory: { "~": "allow" } })
|
||||
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
|
||||
})
|
||||
|
||||
test("evaluate - matches expanded tilde pattern", () => {
|
||||
const ruleset = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
|
||||
const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
|
||||
const ruleset = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
|
||||
const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - matches expanded $HOME pattern", () => {
|
||||
const ruleset = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
|
||||
const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
|
||||
const ruleset = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
|
||||
const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
// merge tests
|
||||
|
||||
test("merge - simple concatenation", () => {
|
||||
const result = PermissionNext.merge(
|
||||
const result = Permission.merge(
|
||||
[{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
[{ permission: "bash", pattern: "*", action: "deny" }],
|
||||
)
|
||||
@@ -119,7 +115,7 @@ test("merge - simple concatenation", () => {
|
||||
})
|
||||
|
||||
test("merge - adds new permission", () => {
|
||||
const result = PermissionNext.merge(
|
||||
const result = Permission.merge(
|
||||
[{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
[{ permission: "edit", pattern: "*", action: "deny" }],
|
||||
)
|
||||
@@ -130,7 +126,7 @@ test("merge - adds new permission", () => {
|
||||
})
|
||||
|
||||
test("merge - concatenates rules for same permission", () => {
|
||||
const result = PermissionNext.merge(
|
||||
const result = Permission.merge(
|
||||
[{ permission: "bash", pattern: "foo", action: "ask" }],
|
||||
[{ permission: "bash", pattern: "*", action: "deny" }],
|
||||
)
|
||||
@@ -141,7 +137,7 @@ test("merge - concatenates rules for same permission", () => {
|
||||
})
|
||||
|
||||
test("merge - multiple rulesets", () => {
|
||||
const result = PermissionNext.merge(
|
||||
const result = Permission.merge(
|
||||
[{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
[{ permission: "bash", pattern: "rm", action: "ask" }],
|
||||
[{ permission: "edit", pattern: "*", action: "allow" }],
|
||||
@@ -154,12 +150,12 @@ test("merge - multiple rulesets", () => {
|
||||
})
|
||||
|
||||
test("merge - empty ruleset does nothing", () => {
|
||||
const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
|
||||
const result = Permission.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
|
||||
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
|
||||
})
|
||||
|
||||
test("merge - preserves rule order", () => {
|
||||
const result = PermissionNext.merge(
|
||||
const result = Permission.merge(
|
||||
[
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
{ permission: "edit", pattern: "src/secret/*", action: "deny" },
|
||||
@@ -175,40 +171,40 @@ test("merge - preserves rule order", () => {
|
||||
|
||||
test("merge - config permission overrides default ask", () => {
|
||||
// Simulates: defaults have "*": "ask", config sets bash: "allow"
|
||||
const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
|
||||
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const merged = PermissionNext.merge(defaults, config)
|
||||
const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
|
||||
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const merged = Permission.merge(defaults, config)
|
||||
|
||||
// Config's bash allow should override default ask
|
||||
expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("allow")
|
||||
expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow")
|
||||
// Other permissions should still be ask (from defaults)
|
||||
expect(PermissionNext.evaluate("edit", "foo.ts", merged).action).toBe("ask")
|
||||
expect(Permission.evaluate("edit", "foo.ts", merged).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("merge - config ask overrides default allow", () => {
|
||||
// Simulates: defaults have bash: "allow", config sets bash: "ask"
|
||||
const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
|
||||
const merged = PermissionNext.merge(defaults, config)
|
||||
const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
|
||||
const merged = Permission.merge(defaults, config)
|
||||
|
||||
// Config's ask should override default allow
|
||||
expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("ask")
|
||||
expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask")
|
||||
})
|
||||
|
||||
// evaluate tests
|
||||
|
||||
test("evaluate - exact pattern match", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
|
||||
const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
|
||||
expect(result.action).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - wildcard pattern match", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
|
||||
const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - last matching rule wins", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [
|
||||
const result = Permission.evaluate("bash", "rm", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "rm", action: "deny" },
|
||||
])
|
||||
@@ -216,7 +212,7 @@ test("evaluate - last matching rule wins", () => {
|
||||
})
|
||||
|
||||
test("evaluate - last matching rule wins (wildcard after specific)", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [
|
||||
const result = Permission.evaluate("bash", "rm", [
|
||||
{ permission: "bash", pattern: "rm", action: "deny" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
@@ -224,14 +220,12 @@ test("evaluate - last matching rule wins (wildcard after specific)", () => {
|
||||
})
|
||||
|
||||
test("evaluate - glob pattern match", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
])
|
||||
const result = Permission.evaluate("edit", "src/foo.ts", [{ permission: "edit", pattern: "src/*", action: "allow" }])
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - last matching glob wins", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
|
||||
const result = Permission.evaluate("edit", "src/components/Button.tsx", [
|
||||
{ permission: "edit", pattern: "src/*", action: "deny" },
|
||||
{ permission: "edit", pattern: "src/components/*", action: "allow" },
|
||||
])
|
||||
@@ -240,7 +234,7 @@ test("evaluate - last matching glob wins", () => {
|
||||
|
||||
test("evaluate - order matters for specificity", () => {
|
||||
// If more specific rule comes first, later wildcard overrides it
|
||||
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
|
||||
const result = Permission.evaluate("edit", "src/components/Button.tsx", [
|
||||
{ permission: "edit", pattern: "src/components/*", action: "allow" },
|
||||
{ permission: "edit", pattern: "src/*", action: "deny" },
|
||||
])
|
||||
@@ -248,31 +242,29 @@ test("evaluate - order matters for specificity", () => {
|
||||
})
|
||||
|
||||
test("evaluate - unknown permission returns ask", () => {
|
||||
const result = PermissionNext.evaluate("unknown_tool", "anything", [
|
||||
const result = Permission.evaluate("unknown_tool", "anything", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
expect(result.action).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - empty ruleset returns ask", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [])
|
||||
const result = Permission.evaluate("bash", "rm", [])
|
||||
expect(result.action).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - no matching pattern returns ask", () => {
|
||||
const result = PermissionNext.evaluate("edit", "etc/passwd", [
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
])
|
||||
const result = Permission.evaluate("edit", "etc/passwd", [{ permission: "edit", pattern: "src/*", action: "allow" }])
|
||||
expect(result.action).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - empty rules array returns ask", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [])
|
||||
const result = Permission.evaluate("bash", "rm", [])
|
||||
expect(result.action).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - multiple matching patterns, last wins", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/secret.ts", [
|
||||
const result = Permission.evaluate("edit", "src/secret.ts", [
|
||||
{ permission: "edit", pattern: "*", action: "ask" },
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
{ permission: "edit", pattern: "src/secret.ts", action: "deny" },
|
||||
@@ -281,7 +273,7 @@ test("evaluate - multiple matching patterns, last wins", () => {
|
||||
})
|
||||
|
||||
test("evaluate - non-matching patterns are skipped", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
|
||||
const result = Permission.evaluate("edit", "src/foo.ts", [
|
||||
{ permission: "edit", pattern: "*", action: "ask" },
|
||||
{ permission: "edit", pattern: "test/*", action: "deny" },
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
@@ -290,7 +282,7 @@ test("evaluate - non-matching patterns are skipped", () => {
|
||||
})
|
||||
|
||||
test("evaluate - exact match at end wins over earlier wildcard", () => {
|
||||
const result = PermissionNext.evaluate("bash", "/bin/rm", [
|
||||
const result = Permission.evaluate("bash", "/bin/rm", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
|
||||
])
|
||||
@@ -298,7 +290,7 @@ test("evaluate - exact match at end wins over earlier wildcard", () => {
|
||||
})
|
||||
|
||||
test("evaluate - wildcard at end overrides earlier exact match", () => {
|
||||
const result = PermissionNext.evaluate("bash", "/bin/rm", [
|
||||
const result = Permission.evaluate("bash", "/bin/rm", [
|
||||
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
@@ -308,24 +300,24 @@ test("evaluate - wildcard at end overrides earlier exact match", () => {
|
||||
// wildcard permission tests
|
||||
|
||||
test("evaluate - wildcard permission matches any permission", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
|
||||
const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
|
||||
expect(result.action).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - wildcard permission with specific pattern", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
|
||||
const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
|
||||
expect(result.action).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - glob permission pattern", () => {
|
||||
const result = PermissionNext.evaluate("mcp_server_tool", "anything", [
|
||||
const result = Permission.evaluate("mcp_server_tool", "anything", [
|
||||
{ permission: "mcp_*", pattern: "*", action: "allow" },
|
||||
])
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - specific permission and wildcard permission combined", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [
|
||||
const result = Permission.evaluate("bash", "rm", [
|
||||
{ permission: "*", pattern: "*", action: "deny" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
@@ -333,7 +325,7 @@ test("evaluate - specific permission and wildcard permission combined", () => {
|
||||
})
|
||||
|
||||
test("evaluate - wildcard permission does not match when specific exists", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
|
||||
const result = Permission.evaluate("edit", "src/foo.ts", [
|
||||
{ permission: "*", pattern: "*", action: "deny" },
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
])
|
||||
@@ -341,7 +333,7 @@ test("evaluate - wildcard permission does not match when specific exists", () =>
|
||||
})
|
||||
|
||||
test("evaluate - multiple matching permission patterns combine rules", () => {
|
||||
const result = PermissionNext.evaluate("mcp_dangerous", "anything", [
|
||||
const result = Permission.evaluate("mcp_dangerous", "anything", [
|
||||
{ permission: "*", pattern: "*", action: "ask" },
|
||||
{ permission: "mcp_*", pattern: "*", action: "allow" },
|
||||
{ permission: "mcp_dangerous", pattern: "*", action: "deny" },
|
||||
@@ -350,7 +342,7 @@ test("evaluate - multiple matching permission patterns combine rules", () => {
|
||||
})
|
||||
|
||||
test("evaluate - wildcard permission fallback for unknown tool", () => {
|
||||
const result = PermissionNext.evaluate("unknown_tool", "anything", [
|
||||
const result = Permission.evaluate("unknown_tool", "anything", [
|
||||
{ permission: "*", pattern: "*", action: "ask" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
@@ -359,7 +351,7 @@ test("evaluate - wildcard permission fallback for unknown tool", () => {
|
||||
|
||||
test("evaluate - permission patterns sorted by length regardless of object order", () => {
|
||||
// specific permission listed before wildcard, but specific should still win
|
||||
const result = PermissionNext.evaluate("bash", "rm", [
|
||||
const result = Permission.evaluate("bash", "rm", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "*", pattern: "*", action: "deny" },
|
||||
])
|
||||
@@ -368,22 +360,22 @@ test("evaluate - permission patterns sorted by length regardless of object order
|
||||
})
|
||||
|
||||
test("evaluate - merges multiple rulesets", () => {
|
||||
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
|
||||
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
|
||||
// approved comes after config, so rm should be denied
|
||||
const result = PermissionNext.evaluate("bash", "rm", config, approved)
|
||||
const result = Permission.evaluate("bash", "rm", config, approved)
|
||||
expect(result.action).toBe("deny")
|
||||
})
|
||||
|
||||
// disabled tests
|
||||
|
||||
test("disabled - returns empty set when all tools allowed", () => {
|
||||
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
|
||||
const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
test("disabled - disables tool when denied", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
const result = Permission.disabled(
|
||||
["bash", "edit", "read"],
|
||||
[
|
||||
{ permission: "*", pattern: "*", action: "allow" },
|
||||
@@ -396,7 +388,7 @@ test("disabled - disables tool when denied", () => {
|
||||
})
|
||||
|
||||
test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
const result = Permission.disabled(
|
||||
["edit", "write", "apply_patch", "multiedit", "bash"],
|
||||
[
|
||||
{ permission: "*", pattern: "*", action: "allow" },
|
||||
@@ -411,7 +403,7 @@ test("disabled - disables edit/write/apply_patch/multiedit when edit denied", ()
|
||||
})
|
||||
|
||||
test("disabled - does not disable when partially denied", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
const result = Permission.disabled(
|
||||
["bash"],
|
||||
[
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
@@ -422,14 +414,14 @@ test("disabled - does not disable when partially denied", () => {
|
||||
})
|
||||
|
||||
test("disabled - does not disable when action is ask", () => {
|
||||
const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
|
||||
const result = Permission.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
test("disabled - does not disable when specific allow after wildcard deny", () => {
|
||||
// Tool is NOT disabled because a specific allow after wildcard deny means
|
||||
// there's at least some usage allowed
|
||||
const result = PermissionNext.disabled(
|
||||
const result = Permission.disabled(
|
||||
["bash"],
|
||||
[
|
||||
{ permission: "bash", pattern: "*", action: "deny" },
|
||||
@@ -440,7 +432,7 @@ test("disabled - does not disable when specific allow after wildcard deny", () =
|
||||
})
|
||||
|
||||
test("disabled - does not disable when wildcard allow after deny", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
const result = Permission.disabled(
|
||||
["bash"],
|
||||
[
|
||||
{ permission: "bash", pattern: "rm *", action: "deny" },
|
||||
@@ -451,7 +443,7 @@ test("disabled - does not disable when wildcard allow after deny", () => {
|
||||
})
|
||||
|
||||
test("disabled - disables multiple tools", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
const result = Permission.disabled(
|
||||
["bash", "edit", "webfetch"],
|
||||
[
|
||||
{ permission: "bash", pattern: "*", action: "deny" },
|
||||
@@ -465,14 +457,14 @@ test("disabled - disables multiple tools", () => {
|
||||
})
|
||||
|
||||
test("disabled - wildcard permission denies all tools", () => {
|
||||
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
|
||||
const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
|
||||
expect(result.has("bash")).toBe(true)
|
||||
expect(result.has("edit")).toBe(true)
|
||||
expect(result.has("read")).toBe(true)
|
||||
})
|
||||
|
||||
test("disabled - specific allow overrides wildcard deny", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
const result = Permission.disabled(
|
||||
["bash", "edit", "read"],
|
||||
[
|
||||
{ permission: "*", pattern: "*", action: "deny" },
|
||||
@@ -491,7 +483,7 @@ test("ask - resolves immediately when action is allow", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await PermissionNext.ask({
|
||||
const result = await Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
@@ -510,7 +502,7 @@ test("ask - throws RejectedError when action is deny", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(
|
||||
PermissionNext.ask({
|
||||
Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["rm -rf /"],
|
||||
@@ -518,7 +510,7 @@ test("ask - throws RejectedError when action is deny", async () => {
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PermissionNext.DeniedError)
|
||||
).rejects.toBeInstanceOf(Permission.DeniedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -528,7 +520,7 @@ test("ask - returns pending promise when action is ask", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const promise = PermissionNext.ask({
|
||||
const promise = Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
@@ -550,7 +542,7 @@ test("ask - adds request to pending list", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ask = PermissionNext.ask({
|
||||
const ask = Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
@@ -563,7 +555,7 @@ test("ask - adds request to pending list", async () => {
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
const list = await PermissionNext.list()
|
||||
const list = await Permission.list()
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0]).toMatchObject({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
@@ -588,12 +580,12 @@ test("ask - publishes asked event", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
let seen: PermissionNext.Request | undefined
|
||||
const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => {
|
||||
let seen: Permission.Request | undefined
|
||||
const unsub = Bus.subscribe(Permission.Event.Asked, (event) => {
|
||||
seen = event.properties
|
||||
})
|
||||
|
||||
const ask = PermissionNext.ask({
|
||||
const ask = Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
@@ -606,7 +598,7 @@ test("ask - publishes asked event", async () => {
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
expect(await PermissionNext.list()).toHaveLength(1)
|
||||
expect(await Permission.list()).toHaveLength(1)
|
||||
expect(seen).toBeDefined()
|
||||
expect(seen).toMatchObject({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
@@ -628,7 +620,7 @@ test("reply - once resolves the pending ask", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise = PermissionNext.ask({
|
||||
const askPromise = Permission.ask({
|
||||
id: PermissionID.make("per_test1"),
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
@@ -640,7 +632,7 @@ test("reply - once resolves the pending ask", async () => {
|
||||
|
||||
await waitForPending(1)
|
||||
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test1"),
|
||||
reply: "once",
|
||||
})
|
||||
@@ -655,7 +647,7 @@ test("reply - reject throws RejectedError", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise = PermissionNext.ask({
|
||||
const askPromise = Permission.ask({
|
||||
id: PermissionID.make("per_test2"),
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
@@ -667,12 +659,12 @@ test("reply - reject throws RejectedError", async () => {
|
||||
|
||||
await waitForPending(1)
|
||||
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test2"),
|
||||
reply: "reject",
|
||||
})
|
||||
|
||||
await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
|
||||
await expect(askPromise).rejects.toBeInstanceOf(Permission.RejectedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -682,7 +674,7 @@ test("reply - reject with message throws CorrectedError", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ask = PermissionNext.ask({
|
||||
const ask = Permission.ask({
|
||||
id: PermissionID.make("per_test2b"),
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
@@ -694,14 +686,14 @@ test("reply - reject with message throws CorrectedError", async () => {
|
||||
|
||||
await waitForPending(1)
|
||||
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test2b"),
|
||||
reply: "reject",
|
||||
message: "Use a safer command",
|
||||
})
|
||||
|
||||
const err = await ask.catch((err) => err)
|
||||
expect(err).toBeInstanceOf(PermissionNext.CorrectedError)
|
||||
expect(err).toBeInstanceOf(Permission.CorrectedError)
|
||||
expect(err.message).toContain("Use a safer command")
|
||||
},
|
||||
})
|
||||
@@ -712,7 +704,7 @@ test("reply - always persists approval and resolves", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise = PermissionNext.ask({
|
||||
const askPromise = Permission.ask({
|
||||
id: PermissionID.make("per_test3"),
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
@@ -724,7 +716,7 @@ test("reply - always persists approval and resolves", async () => {
|
||||
|
||||
await waitForPending(1)
|
||||
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test3"),
|
||||
reply: "always",
|
||||
})
|
||||
@@ -737,7 +729,7 @@ test("reply - always persists approval and resolves", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Stored approval should allow without asking
|
||||
const result = await PermissionNext.ask({
|
||||
const result = await Permission.ask({
|
||||
sessionID: SessionID.make("session_test2"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
@@ -755,7 +747,7 @@ test("reply - reject cancels all pending for same session", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise1 = PermissionNext.ask({
|
||||
const askPromise1 = Permission.ask({
|
||||
id: PermissionID.make("per_test4a"),
|
||||
sessionID: SessionID.make("session_same"),
|
||||
permission: "bash",
|
||||
@@ -765,7 +757,7 @@ test("reply - reject cancels all pending for same session", async () => {
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
const askPromise2 = PermissionNext.ask({
|
||||
const askPromise2 = Permission.ask({
|
||||
id: PermissionID.make("per_test4b"),
|
||||
sessionID: SessionID.make("session_same"),
|
||||
permission: "edit",
|
||||
@@ -782,14 +774,14 @@ test("reply - reject cancels all pending for same session", async () => {
|
||||
const result2 = askPromise2.catch((e) => e)
|
||||
|
||||
// Reject the first one
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test4a"),
|
||||
reply: "reject",
|
||||
})
|
||||
|
||||
// Both should be rejected
|
||||
expect(await result1).toBeInstanceOf(PermissionNext.RejectedError)
|
||||
expect(await result2).toBeInstanceOf(PermissionNext.RejectedError)
|
||||
expect(await result1).toBeInstanceOf(Permission.RejectedError)
|
||||
expect(await result2).toBeInstanceOf(Permission.RejectedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -799,7 +791,7 @@ test("reply - always resolves matching pending requests in same session", async
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const a = PermissionNext.ask({
|
||||
const a = Permission.ask({
|
||||
id: PermissionID.make("per_test5a"),
|
||||
sessionID: SessionID.make("session_same"),
|
||||
permission: "bash",
|
||||
@@ -809,7 +801,7 @@ test("reply - always resolves matching pending requests in same session", async
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
const b = PermissionNext.ask({
|
||||
const b = Permission.ask({
|
||||
id: PermissionID.make("per_test5b"),
|
||||
sessionID: SessionID.make("session_same"),
|
||||
permission: "bash",
|
||||
@@ -821,14 +813,14 @@ test("reply - always resolves matching pending requests in same session", async
|
||||
|
||||
await waitForPending(2)
|
||||
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test5a"),
|
||||
reply: "always",
|
||||
})
|
||||
|
||||
await expect(a).resolves.toBeUndefined()
|
||||
await expect(b).resolves.toBeUndefined()
|
||||
expect(await PermissionNext.list()).toHaveLength(0)
|
||||
expect(await Permission.list()).toHaveLength(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -838,7 +830,7 @@ test("reply - always keeps other session pending", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const a = PermissionNext.ask({
|
||||
const a = Permission.ask({
|
||||
id: PermissionID.make("per_test6a"),
|
||||
sessionID: SessionID.make("session_a"),
|
||||
permission: "bash",
|
||||
@@ -848,7 +840,7 @@ test("reply - always keeps other session pending", async () => {
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
const b = PermissionNext.ask({
|
||||
const b = Permission.ask({
|
||||
id: PermissionID.make("per_test6b"),
|
||||
sessionID: SessionID.make("session_b"),
|
||||
permission: "bash",
|
||||
@@ -860,13 +852,13 @@ test("reply - always keeps other session pending", async () => {
|
||||
|
||||
await waitForPending(2)
|
||||
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test6a"),
|
||||
reply: "always",
|
||||
})
|
||||
|
||||
await expect(a).resolves.toBeUndefined()
|
||||
expect((await PermissionNext.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
|
||||
expect((await Permission.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
|
||||
|
||||
await rejectAll()
|
||||
await b.catch(() => {})
|
||||
@@ -879,7 +871,7 @@ test("reply - publishes replied event", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ask = PermissionNext.ask({
|
||||
const ask = Permission.ask({
|
||||
id: PermissionID.make("per_test7"),
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
@@ -895,14 +887,14 @@ test("reply - publishes replied event", async () => {
|
||||
| {
|
||||
sessionID: SessionID
|
||||
requestID: PermissionID
|
||||
reply: PermissionNext.Reply
|
||||
reply: Permission.Reply
|
||||
}
|
||||
| undefined
|
||||
const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => {
|
||||
const unsub = Bus.subscribe(Permission.Event.Replied, (event) => {
|
||||
seen = event.properties
|
||||
})
|
||||
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test7"),
|
||||
reply: "once",
|
||||
})
|
||||
@@ -918,16 +910,141 @@ test("reply - publishes replied event", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("permission requests stay isolated by directory", async () => {
|
||||
await using one = await tmpdir({ git: true })
|
||||
await using two = await tmpdir({ git: true })
|
||||
|
||||
const a = Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () =>
|
||||
Permission.ask({
|
||||
id: PermissionID.make("per_dir_a"),
|
||||
sessionID: SessionID.make("session_dir_a"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}),
|
||||
})
|
||||
|
||||
const b = Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () =>
|
||||
Permission.ask({
|
||||
id: PermissionID.make("per_dir_b"),
|
||||
sessionID: SessionID.make("session_dir_b"),
|
||||
permission: "bash",
|
||||
patterns: ["pwd"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}),
|
||||
})
|
||||
|
||||
const onePending = await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => waitForPending(1),
|
||||
})
|
||||
const twoPending = await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => waitForPending(1),
|
||||
})
|
||||
|
||||
expect(onePending).toHaveLength(1)
|
||||
expect(twoPending).toHaveLength(1)
|
||||
expect(onePending[0].id).toBe(PermissionID.make("per_dir_a"))
|
||||
expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b"))
|
||||
|
||||
await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => Permission.reply({ requestID: onePending[0].id, reply: "reject" }),
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => Permission.reply({ requestID: twoPending[0].id, reply: "reject" }),
|
||||
})
|
||||
|
||||
await a.catch(() => {})
|
||||
await b.catch(() => {})
|
||||
})
|
||||
|
||||
test("pending permission rejects on instance dispose", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const ask = Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
Permission.ask({
|
||||
id: PermissionID.make("per_dispose"),
|
||||
sessionID: SessionID.make("session_dispose"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}),
|
||||
})
|
||||
const result = ask.then(
|
||||
() => "resolved" as const,
|
||||
(err) => err,
|
||||
)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const pending = await waitForPending(1)
|
||||
expect(pending).toHaveLength(1)
|
||||
await Instance.dispose()
|
||||
},
|
||||
})
|
||||
|
||||
expect(await result).toBeInstanceOf(Permission.RejectedError)
|
||||
})
|
||||
|
||||
test("pending permission rejects on instance reload", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const ask = Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
Permission.ask({
|
||||
id: PermissionID.make("per_reload"),
|
||||
sessionID: SessionID.make("session_reload"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}),
|
||||
})
|
||||
const result = ask.then(
|
||||
() => "resolved" as const,
|
||||
(err) => err,
|
||||
)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const pending = await waitForPending(1)
|
||||
expect(pending).toHaveLength(1)
|
||||
await Instance.reload({ directory: tmp.path })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await result).toBeInstanceOf(Permission.RejectedError)
|
||||
})
|
||||
|
||||
test("reply - does nothing for unknown requestID", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_unknown"),
|
||||
reply: "once",
|
||||
})
|
||||
expect(await PermissionNext.list()).toHaveLength(0)
|
||||
expect(await Permission.list()).toHaveLength(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -938,7 +1055,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(
|
||||
PermissionNext.ask({
|
||||
Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["echo hello", "rm -rf /"],
|
||||
@@ -949,7 +1066,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
|
||||
{ permission: "bash", pattern: "rm *", action: "deny" },
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PermissionNext.DeniedError)
|
||||
).rejects.toBeInstanceOf(Permission.DeniedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -959,7 +1076,7 @@ test("ask - allows all patterns when all match allow rules", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await PermissionNext.ask({
|
||||
const result = await Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["echo hello", "ls -la", "pwd"],
|
||||
@@ -977,7 +1094,7 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const err = await PermissionNext.ask({
|
||||
const err = await Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["echo hello", "rm -rf /"],
|
||||
@@ -992,8 +1109,8 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
|
||||
(err) => err,
|
||||
)
|
||||
|
||||
expect(err).toBeInstanceOf(PermissionNext.DeniedError)
|
||||
expect(await PermissionNext.list()).toHaveLength(0)
|
||||
expect(err).toBeInstanceOf(Permission.DeniedError)
|
||||
expect(await Permission.list()).toHaveLength(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1004,8 +1121,8 @@ test("ask - abort should clear pending request", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ctl = new AbortController()
|
||||
const ask = runtime.runPromise(
|
||||
S.Service.use((svc) =>
|
||||
const ask = Permission.runPromise(
|
||||
(svc) =>
|
||||
svc.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
@@ -1014,7 +1131,6 @@ test("ask - abort should clear pending request", async () => {
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
|
||||
}),
|
||||
).pipe(Effect.provide(Instances.get(Instance.directory))),
|
||||
{ signal: ctl.signal },
|
||||
)
|
||||
|
||||
@@ -1023,7 +1139,7 @@ test("ask - abort should clear pending request", async () => {
|
||||
await ask.catch(() => {})
|
||||
|
||||
try {
|
||||
expect(await PermissionNext.list()).toHaveLength(0)
|
||||
expect(await Permission.list()).toHaveLength(0)
|
||||
} finally {
|
||||
await rejectAll()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user