diff --git a/bun.lock b/bun.lock index e9f71f33884d..5b858750dd07 100644 --- a/bun.lock +++ b/bun.lock @@ -276,6 +276,7 @@ "gitlab-ai-provider": "6.8.0", "glob": "13.0.5", "google-auth-library": "10.5.0", + "gray-matter": "4.0.3", "immer": "11.1.4", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", @@ -2712,7 +2713,7 @@ "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -3764,7 +3765,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], @@ -5308,6 +5309,8 @@ "@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], + "@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -5318,6 +5321,8 @@ "@astrojs/solid-js/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], + "@astrojs/starlight/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.7", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg=="], "@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.973.7", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg=="], @@ -5584,6 +5589,8 @@ "@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -5844,18 +5851,24 @@ "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/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "archiver-utils/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=="], "archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], "astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], "astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], + "astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], @@ -5874,6 +5887,8 @@ "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "c12/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], @@ -5904,6 +5919,8 @@ "dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "dmg-builder/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "dmg-license/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -5924,6 +5941,8 @@ "electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + "electron-updater/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -5966,8 +5985,6 @@ "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], - "happy-dom/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], @@ -6250,12 +6267,18 @@ "@astrojs/check/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=="], + "@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="], "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], + "@astrojs/mdx/@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@astrojs/mdx/@astrojs/markdown-remark/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], + "@astrojs/starlight/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], @@ -6514,6 +6537,8 @@ "@gitlab/opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "@hey-api/json-schema-ref-parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@jsx-email/cli/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], "@jsx-email/cli/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], @@ -6758,6 +6783,8 @@ "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/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "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=="], @@ -6766,6 +6793,8 @@ "archiver-utils/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=="], + "astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "astro/unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "astro/unstorage/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], @@ -6780,6 +6809,8 @@ "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -6788,6 +6819,8 @@ "dir-compare/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "dmg-builder/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], @@ -6796,6 +6829,8 @@ "electron-builder/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=="], + "electron-updater/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -6808,8 +6843,6 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], "iconv-corefoundation/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=="], @@ -6954,6 +6987,8 @@ "@astrojs/check/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@astrojs/mdx/@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], "@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], @@ -7170,8 +7205,6 @@ "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "gray-matter/js-yaml/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "iconv-corefoundation/cli-truncate/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "iconv-corefoundation/cli-truncate/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/packages/core/package.json b/packages/core/package.json index 1c3f2a3f0024..651ea49e2ade 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -73,6 +73,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "gitlab-ai-provider": "6.8.0", + "gray-matter": "4.0.3", "glob": "13.0.5", "google-auth-library": "10.5.0", "immer": "11.1.4", diff --git a/packages/core/src/account.ts b/packages/core/src/account.ts index 4de8176e4bc8..23fe10e2ed82 100644 --- a/packages/core/src/account.ts +++ b/packages/core/src/account.ts @@ -1,7 +1,17 @@ export * as AccountV2 from "./account" -import { Schema } from "effect" -import type * as HttpClientError from "effect/unstable/http/HttpClientError" +import { Cache, Clock, Context, Duration, Effect, Layer, Option, Schedule, Schema, SchemaGetter } from "effect" +import { eq } from "drizzle-orm" +import { + FetchHttpClient, + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http" +import { AccountStateTable, AccountTable } from "./account/sql" +import { Database } from "./database/database" +import { serviceUse } from "./effect/service-use" export const ID = Schema.String.pipe(Schema.brand("AccountID")) export type ID = Schema.Schema.Type @@ -99,3 +109,441 @@ export class PollError extends Schema.TaggedClass()("PollError", { export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError]) export type PollResult = Schema.Schema.Type + +export type AccountOrgs = { + account: Info + orgs: readonly Org[] +} + +export type ActiveOrg = { + account: Info + org: Org +} + +class RemoteConfig extends Schema.Class("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")({ + access_token: AccessToken, + refresh_token: RefreshToken, + expires_in: DurationFromSeconds, +}) {} + +class DeviceAuth extends Schema.Class("DeviceAuth")({ + device_code: DeviceCode, + user_code: UserCode, + verification_uri_complete: Schema.String, + expires_in: DurationFromSeconds, + interval: DurationFromSeconds, +}) {} + +class DeviceTokenSuccess extends Schema.Class("DeviceTokenSuccess")({ + access_token: AccessToken, + refresh_token: RefreshToken, + token_type: Schema.Literal("Bearer"), + expires_in: DurationFromSeconds, +}) {} + +class DeviceTokenError extends Schema.Class("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")({ + id: ID, + email: Schema.String, +}) {} + +class ClientId extends Schema.Class("ClientId")({ client_id: Schema.String }) {} + +class DeviceTokenRequest extends Schema.Class("DeviceTokenRequest")({ + grant_type: Schema.String, + device_code: DeviceCode, + client_id: Schema.String, +}) {} + +class TokenRefreshRequest extends Schema.Class("TokenRefreshRequest")({ + grant_type: Schema.String, + refresh_token: RefreshToken, + client_id: Schema.String, +}) {} + +type AccountRow = typeof AccountTable.$inferSelect +const ACCOUNT_STATE_ID = 1 +const clientId = "opencode-cli" +const eagerRefreshThresholdMs = Duration.toMillis(Duration.minutes(5)) + +function normalizeServerUrl(input: string) { + const url = new URL(input) + url.search = "" + url.hash = "" + const pathname = url.pathname.replace(/\/+$/, "") + return pathname.length === 0 ? url.origin : `${url.origin}${pathname}` +} + +const isTokenFresh = (tokenExpiry: number | null, now: number) => + tokenExpiry != null && tokenExpiry > now + eagerRefreshThresholdMs + +const mapAccountServiceError = + (message = "Account service operation failed") => + (effect: Effect.Effect): Effect.Effect => + effect.pipe(Effect.mapError((cause) => accountErrorFromCause(cause, message))) + +function accountErrorFromCause(cause: unknown, message: string): AccountError { + if (cause instanceof AccountServiceError || cause instanceof AccountTransportError) return cause + if (HttpClientError.isHttpClientError(cause)) { + if (cause.reason._tag === "TransportError") return AccountTransportError.fromHttpClientError(cause.reason) + return new AccountServiceError({ message, cause }) + } + return new AccountServiceError({ message, cause }) +} + +export interface Interface { + readonly active: () => Effect.Effect, AccountError> + readonly activeOrg: () => Effect.Effect, AccountError> + readonly list: () => Effect.Effect + readonly orgsByAccount: () => Effect.Effect + readonly remove: (accountID: ID) => Effect.Effect + readonly use: (accountID: ID, orgID: Option.Option) => Effect.Effect + readonly orgs: (accountID: ID) => Effect.Effect + readonly config: (accountID: ID, orgID: OrgID) => Effect.Effect>, AccountError> + readonly token: (accountID: ID) => Effect.Effect, AccountError> + readonly login: (url: string) => Effect.Effect + readonly poll: (input: Login) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Account") {} + +export const use = serviceUse(Service) + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const { db } = yield* Database.Service + const http = yield* HttpClient.HttpClient + const httpRead = http.pipe( + HttpClient.retryTransient({ + retryOn: "errors-and-responses", + times: 2, + schedule: Schedule.exponential(200).pipe(Schedule.jittered), + }), + ) + const httpOk = HttpClient.filterStatusOk(http) + const httpReadOk = HttpClient.filterStatusOk(httpRead) + const decode = Schema.decodeUnknownSync(Info) + + const query = (effect: Effect.Effect) => + effect.pipe(Effect.mapError((cause) => new AccountRepoError({ message: "Database operation failed", cause }))) + + const current = Effect.fnUntraced(function* () { + const state = yield* db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get() + if (!state?.active_account_id) return + const account = yield* db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get() + if (!account) return + return { ...account, active_org_id: state.active_org_id ?? null } + }) + + const state = (accountID: ID, orgID: Option.Option) => + db + .insert(AccountStateTable) + .values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: Option.getOrNull(orgID) }) + .onConflictDoUpdate({ + target: AccountStateTable.id, + set: { active_account_id: accountID, active_org_id: Option.getOrNull(orgID) }, + }) + .run() + + const active = Effect.fn("AccountV2.active")(() => + query(current()).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))), + ) + const list = Effect.fn("AccountV2.list")(() => + query( + db + .select() + .from(AccountTable) + .all() + .pipe(Effect.map((rows) => rows.map((row) => decode({ ...row, active_org_id: null })))), + ), + ) + const remove = Effect.fn("AccountV2.remove")((accountID: ID) => + query( + db.transaction((tx) => + Effect.gen(function* () { + yield* tx + .update(AccountStateTable) + .set({ active_account_id: null, active_org_id: null }) + .where(eq(AccountStateTable.active_account_id, accountID)) + .run() + yield* tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run() + }), + ), + ).pipe(Effect.asVoid), + ) + const select = Effect.fn("AccountV2.use")((accountID: ID, orgID: Option.Option) => + query(state(accountID, orgID)).pipe(Effect.asVoid), + ) + const getRow = (accountID: ID) => + query(db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe( + Effect.map(Option.fromNullishOr), + ) + + const persistToken = Effect.fnUntraced(function* (input: { + accountID: ID + accessToken: AccessToken + refreshToken: RefreshToken + expiry: Option.Option + }) { + yield* query( + db + .update(AccountTable) + .set({ + access_token: input.accessToken, + refresh_token: input.refreshToken, + token_expiry: Option.getOrNull(input.expiry), + }) + .where(eq(AccountTable.id, input.accountID)) + .run(), + ) + }) + const persistAccount = Effect.fnUntraced(function* (input: { + id: ID + email: string + url: string + accessToken: AccessToken + refreshToken: RefreshToken + expiry: number + orgID: Option.Option + }) { + const url = normalizeServerUrl(input.url) + yield* query( + db.transaction((tx) => + Effect.gen(function* () { + yield* tx + .insert(AccountTable) + .values({ + id: input.id, + email: input.email, + url, + access_token: input.accessToken, + refresh_token: input.refreshToken, + token_expiry: input.expiry, + }) + .onConflictDoUpdate({ + target: AccountTable.id, + set: { + email: input.email, + url, + access_token: input.accessToken, + refresh_token: input.refreshToken, + token_expiry: input.expiry, + }, + }) + .run() + yield* state(input.id, input.orgID) + }), + ), + ) + }) + 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 = (request: Effect.Effect) => + request.pipe( + Effect.flatMap((req) => httpOk.execute(req)), + mapAccountServiceError("HTTP request failed"), + ) + const executeEffect = (request: Effect.Effect) => + request.pipe( + Effect.flatMap((req) => http.execute(req)), + mapAccountServiceError("HTTP request failed"), + ) + + const refreshToken = Effect.fnUntraced(function* (row: AccountRow) { + const now = yield* Clock.currentTimeMillis + 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"), + ) + yield* persistToken({ + accountID: row.id, + accessToken: parsed.access_token, + refreshToken: parsed.refresh_token, + expiry: Option.some(now + Duration.toMillis(parsed.expires_in)), + }) + return parsed.access_token + }) + const refreshTokenCache = yield* Cache.make({ + capacity: Number.POSITIVE_INFINITY, + timeToLive: Duration.zero, + lookup: Effect.fnUntraced(function* (accountID) { + const maybeAccount = yield* getRow(accountID) + if (Option.isNone(maybeAccount)) + return yield* new AccountServiceError({ message: "Account not found during token refresh" }) + const now = yield* Clock.currentTimeMillis + if (isTokenFresh(maybeAccount.value.token_expiry, now)) return maybeAccount.value.access_token + return yield* refreshToken(maybeAccount.value) + }), + }) + const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { + const now = yield* Clock.currentTimeMillis + if (isTokenFresh(row.token_expiry, now)) return row.access_token + return yield* Cache.get(refreshTokenCache, row.id) + }) + const resolveAccess = Effect.fnUntraced(function* (accountID: ID) { + const maybeAccount = yield* getRow(accountID) + if (Option.isNone(maybeAccount)) return Option.none() + return Option.some({ account: maybeAccount.value, accessToken: yield* resolveToken(maybeAccount.value) }) + }) + 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("AccountV2.token")((accountID: ID) => + resolveAccess(accountID).pipe(Effect.map(Option.map((item) => item.accessToken))), + ) + const orgs = Effect.fn("AccountV2.orgs")(function* (accountID: ID) { + const resolved = yield* resolveAccess(accountID) + if (Option.isNone(resolved)) return [] + return yield* fetchOrgs(resolved.value.account.url, resolved.value.accessToken) + }) + const activeOrg = Effect.fn("AccountV2.activeOrg")(function* () { + const value = yield* active() + if (Option.isNone(value) || !value.value.active_org_id) return Option.none() + const org = (yield* orgs(value.value.id)).find((item) => item.id === value.value.active_org_id) + return org ? Option.some({ account: value.value, org }) : Option.none() + }) + const orgsByAccount = Effect.fn("AccountV2.orgsByAccount")(function* () { + return yield* Effect.forEach( + yield* list(), + (account) => + orgs(account.id).pipe( + Effect.catch(() => Effect.succeed([] as readonly Org[])), + Effect.map((orgs) => ({ account, orgs })), + ), + { concurrency: 3 }, + ) + }) + const config = Effect.fn("AccountV2.config")(function* (accountID: ID, orgID: OrgID) { + const resolved = yield* resolveAccess(accountID) + if (Option.isNone(resolved)) return Option.none() + const response = yield* executeRead( + HttpClientRequest.get(`${resolved.value.account.url}/api/config`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(resolved.value.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("AccountV2.login")(function* (server: string) { + const normalizedServer = normalizeServerUrl(server) + const response = yield* executeEffectOk( + HttpClientRequest.post(`${normalizedServer}/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: `${normalizedServer}${parsed.verification_uri_complete}`, + server: normalizedServer, + expiry: parsed.expires_in, + interval: parsed.interval, + }) + }) + const poll = Effect.fn("AccountV2.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 [account, remoteOrgs] = yield* Effect.all( + [fetchUser(input.server, parsed.access_token), fetchOrgs(input.server, parsed.access_token)], + { concurrency: 2 }, + ) + const now = yield* Clock.currentTimeMillis + yield* persistAccount({ + id: account.id, + email: account.email, + url: input.server, + accessToken: parsed.access_token, + refreshToken: parsed.refresh_token, + expiry: now + Duration.toMillis(parsed.expires_in), + orgID: remoteOrgs.length ? Option.some(remoteOrgs[0].id) : Option.none(), + }) + return new PollSuccess({ email: account.email }) + }) + + return Service.of({ active, activeOrg, list, orgsByAccount, remove, use: select, orgs, config, token, login, poll }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(FetchHttpClient.layer)) diff --git a/packages/core/src/account/sql.ts b/packages/core/src/account/sql.ts index 4f45651d78ec..f884151003f3 100644 --- a/packages/core/src/account/sql.ts +++ b/packages/core/src/account/sql.ts @@ -1,6 +1,6 @@ import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core" -import { AccountV2 } from "../account" +import type { AccountV2 } from "../account" import { Timestamps } from "../database/schema.sql" export const AccountTable = sqliteTable("account", { diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index c05b0a578f0c..0c9cd4f118b2 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -1,8 +1,12 @@ export * as ConfigAgentPlugin from "./agent" -import { Effect } from "effect" +import path from "path" +import matter from "gray-matter" +import { Effect, Option, Schema } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" +import { ConfigAgent } from "../agent" +import { AppFileSystem } from "../../filesystem" import { ModelV2 } from "../../model" import { PermissionV2 } from "../../permission" import { PluginV2 } from "../../plugin" @@ -12,49 +16,83 @@ export const Plugin = PluginV2.define({ effect: Effect.gen(function* () { const agent = yield* AgentV2.Service const config = yield* Config.Service - const files = yield* config.get() + const fs = yield* AppFileSystem.Service + const documents = yield* config.get() + const loadFile = Effect.fnUntraced(function* (directory: string, filepath: string) { + const text = yield* fs.readFileString(filepath).pipe(Effect.orDie) + const document = yield* Effect.try({ try: () => matter(text), catch: () => undefined }).pipe( + Effect.catch(() => Effect.void), + ) + if (!document) return + + const info = Option.getOrUndefined( + Schema.decodeUnknownOption(ConfigAgent.Info)( + { ...document.data, system: document.content.trim() }, + { errors: "all", onExcessProperty: "ignore" }, + ), + ) + if (!info) return + + const relative = path.relative(directory, filepath).split(path.sep).slice(1).join("/") + return { + id: AgentV2.ID.make(relative.slice(0, -path.extname(relative).length)), + info, + } + }) + const files = (yield* Effect.forEach(yield* config.directories(), (directory) => + fs.glob("{agent,agents}/**/*.md", { cwd: directory, absolute: true, dot: true, symlink: true }).pipe( + Effect.orDie, + Effect.flatMap((items) => Effect.forEach(items, (item) => loadFile(directory, item))), + Effect.map((items) => items.filter((item): item is NonNullable => item !== undefined)), + ), + )).flat() yield* agent.update((editor) => { const permissions = new Map() - for (const file of files) { - for (const [id, item] of Object.entries(file.info.agents ?? {})) { - const agentID = AgentV2.ID.make(id) - if (item.disabled) { - editor.remove(agentID) - permissions.delete(agentID) - continue + function update(agentID: AgentV2.ID, item: ConfigAgent.Info, disabled: boolean, rules: PermissionV2.Ruleset) { + if (disabled) { + editor.remove(agentID) + permissions.delete(agentID) + return + } + + editor.update(agentID, (agent) => { + if (item.model !== undefined) { + const model = ModelV2.parse(item.model) + agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } + } + if (item.variant !== undefined && agent.model !== undefined) { + agent.model.variant = ModelV2.VariantID.make(item.variant) } + if (item.options !== undefined) { + Object.assign(agent.options.headers, item.options.headers ?? {}) + Object.assign(agent.options.body, item.options.body ?? {}) + Object.assign(agent.options.aisdk.provider, item.options.aisdk?.provider ?? {}) + Object.assign(agent.options.aisdk.request, item.options.aisdk?.request ?? {}) + } + if (item.system !== undefined) agent.system = item.system + if (item.description !== undefined) agent.description = item.description + if (item.mode !== undefined) agent.mode = item.mode + if (item.hidden !== undefined) agent.hidden = item.hidden + if (item.color !== undefined) agent.color = item.color + if (item.steps !== undefined) agent.steps = item.steps + }) - editor.update(agentID, (agent) => { - if (item.model !== undefined) { - const model = ModelV2.parse(item.model) - agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } - } - if (item.variant !== undefined && agent.model !== undefined) { - agent.model.variant = ModelV2.VariantID.make(item.variant) - } - if (item.options !== undefined) { - Object.assign(agent.options.headers, item.options.headers ?? {}) - Object.assign(agent.options.body, item.options.body ?? {}) - Object.assign(agent.options.aisdk.provider, item.options.aisdk?.provider ?? {}) - Object.assign(agent.options.aisdk.request, item.options.aisdk?.request ?? {}) - } - if (item.system !== undefined) agent.system = item.system - if (item.description !== undefined) agent.description = item.description - if (item.mode !== undefined) agent.mode = item.mode - if (item.hidden !== undefined) agent.hidden = item.hidden - if (item.color !== undefined) agent.color = item.color - if (item.steps !== undefined) agent.steps = item.steps - }) + if (rules.length) permissions.set(agentID, [...(permissions.get(agentID) ?? []), ...rules]) + } - if (item.permissions !== undefined) { - permissions.set(agentID, [...(permissions.get(agentID) ?? []), ...item.permissions]) - } + for (const file of documents) { + for (const [id, item] of Object.entries(file.info.agents ?? {})) { + update(AgentV2.ID.make(id), item, item.disabled ?? false, item.permissions ?? []) } } - const global = files.flatMap((file) => file.info.permissions ?? []) + for (const file of files) { + update(file.id, file.info, file.info.disabled ?? false, file.info.permissions ?? []) + } + + const global = documents.flatMap((file) => file.info.permissions ?? []) for (const current of editor.list()) { editor.update(current.id, (agent) => { agent.permissions.push(...global, ...(permissions.get(current.id) ?? [])) diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 98004600e6e1..be2cf5b72e38 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -7,6 +7,7 @@ import { Catalog } from "../catalog" import { Config } from "../config" import { ConfigAgentPlugin } from "../config/plugin/agent" import { EventV2 } from "../event" +import { AppFileSystem } from "../filesystem" import { Location } from "../location" import { ModelsDev } from "../models-dev" import { Npm } from "../npm" @@ -26,6 +27,7 @@ type Plugin = { | AgentV2.Service | Npm.Service | EventV2.Service + | AppFileSystem.Service | Location.Service | PluginV2.Service | Config.Service @@ -51,6 +53,7 @@ export const layer = Layer.effect( const modelsDev = yield* ModelsDev.Service const npm = yield* Npm.Service const events = yield* EventV2.Service + const fs = yield* AppFileSystem.Service const done = yield* Deferred.make() const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { @@ -65,6 +68,7 @@ export const layer = Layer.effect( Effect.provideService(ModelsDev.Service, modelsDev), Effect.provideService(Npm.Service, npm), Effect.provideService(EventV2.Service, events), + Effect.provideService(AppFileSystem.Service, fs), Effect.provideService(PluginV2.Service, plugin), ), }) diff --git a/packages/core/test/account.test.ts b/packages/core/test/account.test.ts index 4e69df25d31e..3eec6e95f71d 100644 --- a/packages/core/test/account.test.ts +++ b/packages/core/test/account.test.ts @@ -1,287 +1,489 @@ -import path from "path" -import { describe, expect } from "bun:test" -import { produce } from "immer" -import { Effect, Fiber, Layer, Option, Stream } from "effect" -import { Auth } from "@opencode-ai/core/auth" -import { Catalog } from "@opencode-ai/core/catalog" -import { EventV2 } from "@opencode-ai/core/event" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Global } from "@opencode-ai/core/global" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { AccountPlugin } from "@opencode-ai/core/plugin/account" -import { ModelV2 } from "@opencode-ai/core/model" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { tmpdir } from "./fixture/tmpdir" +import { expect } from "bun:test" +import { Duration, Effect, Layer, Option, Schema } from "effect" +import { eq, sql } from "drizzle-orm" +import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http" + +import { AccountV2 } from "@opencode-ai/core/account" +import { AccountStateTable, AccountTable } from "@opencode-ai/core/account/sql" +import { Database } from "@opencode-ai/core/database/database" import { testEffect } from "./lib/effect" -const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))) - -function context( - records: { provider: ProviderV2.Info; models: Map }[], - updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>, -): Catalog.Editor { - return { - provider: { - list: () => records, - get: (providerID) => records.find((item) => item.provider.id === providerID), - update: (providerID, fn) => { - const record = records.find((item) => item.provider.id === providerID) - const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn) - if (record) record.provider = provider - else records.push({ provider, models: new Map() }) - updates.push({ - id: providerID, - enabled: provider.enabled, - apiKey: - typeof provider.options.aisdk.provider.apiKey === "string" - ? provider.options.aisdk.provider.apiKey - : undefined, - }) - }, - remove: (providerID) => { - const index = records.findIndex((item) => item.provider.id === providerID) - if (index !== -1) records.splice(index, 1) - }, - }, - model: { - get: () => undefined, - update: () => {}, - remove: () => {}, - default: { - get: () => undefined, - set: () => {}, - }, - }, - } -} +const database = Database.layerFromPath(":memory:") -function testLayer(dir: string) { - return Auth.layer.pipe( - Layer.provide(AppFileSystem.defaultLayer), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provide( - Global.layerWith({ - data: dir, - cache: path.join(dir, "cache"), - config: path.join(dir, "config"), - state: path.join(dir, "state"), - tmp: path.join(dir, "tmp"), - bin: path.join(dir, "bin"), - log: path.join(dir, "log"), - repos: path.join(dir, "repos"), - }), - ), +const truncate = Layer.effectDiscard( + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.run(sql`DELETE FROM account_state`) + yield* db.run(sql`DELETE FROM account`) + }), +).pipe(Layer.provide(database)) + +const it = testEffect(Layer.mergeAll(database, truncate)) + +const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1)) +const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10)) + +const live = (client: HttpClient.HttpClient) => + AccountV2.layer.pipe(Layer.provide(database), Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) + +const persist = (input: { + id: AccountV2.ID + email: string + url: string + accessToken: AccountV2.AccessToken + refreshToken: AccountV2.RefreshToken + expiry: number + orgID: Option.Option +}) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(AccountTable) + .values({ + id: input.id, + email: input.email, + url: input.url, + access_token: input.accessToken, + refresh_token: input.refreshToken, + token_expiry: input.expiry, + }) + .onConflictDoUpdate({ + target: AccountTable.id, + set: { access_token: input.accessToken, refresh_token: input.refreshToken, token_expiry: input.expiry }, + }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(AccountStateTable) + .values({ id: 1, active_account_id: input.id, active_org_id: Option.getOrNull(input.orgID) }) + .onConflictDoUpdate({ + target: AccountStateTable.id, + set: { active_account_id: input.id, active_org_id: Option.getOrNull(input.orgID) }, + }) + .run() + .pipe(Effect.orDie) + }) + +const row = (id: AccountV2.ID) => + Effect.gen(function* () { + const { db } = yield* Database.Service + return yield* db.select().from(AccountTable).where(eq(AccountTable.id, id)).get().pipe(Effect.orDie) + }) + +const active = Effect.gen(function* () { + const { db } = yield* Database.Service + const state = yield* db.select().from(AccountStateTable).where(eq(AccountStateTable.id, 1)).get().pipe(Effect.orDie) + if (!state?.active_account_id) return undefined + const account = yield* row(state.active_account_id) + return account ? { ...account, active_org_id: state.active_org_id } : undefined +}) + +const json = (req: Parameters[0], body: unknown, status = 200) => + HttpClientResponse.fromWeb( + req, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), ) -} -describe("Auth", () => { - it.live("emits account lifecycle events", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - const accounts = yield* Auth.Service - const eventSvc = yield* EventV2.Service - const addedFiber = yield* eventSvc - .subscribe(Auth.Event.Added) - .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) - const switchedFiber = yield* eventSvc - .subscribe(Auth.Event.Switched) - .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) - const removedFiber = yield* eventSvc - .subscribe(Auth.Event.Removed) - .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) - - yield* Effect.yieldNow - - const first = yield* accounts.create({ - serviceID: Auth.ServiceID.make("provider"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "raw-key" }), - }) - expect(first).toBeDefined() - if (!first) return - expect(first.description).toBe("default") - expect(first.credential.type).toBe("api") - if (first.credential.type === "api") expect(first.credential.key).toBe("raw-key") - - yield* accounts.update(first.id, { description: "keep" }) - const updated = yield* accounts.get(first.id) - expect(updated?.description).toBe("keep") - expect(updated?.credential.type).toBe("api") - if (updated?.credential.type === "api") expect(updated.credential.key).toBe("raw-key") - - const second = yield* accounts.create({ - serviceID: Auth.ServiceID.make("provider"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }), - }) - expect(second).toBeDefined() - if (!second) return - - yield* accounts.remove(second.id) - const added = Array.from(yield* Fiber.join(addedFiber)) - const switched = Array.from(yield* Fiber.join(switchedFiber)) - const removed = Array.from(yield* Fiber.join(removedFiber)) - expect(added.map((event) => event.data.account.id)).toEqual([first.id, second.id]) - expect(switched.map((event) => event.data)).toEqual([ - { serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id }, - { serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id }, - { serviceID: Auth.ServiceID.make("provider"), from: second.id, to: first.id }, - ]) - expect(removed[0]?.data.account.id).toBe(second.id) - }).pipe(Effect.provide(testLayer(tmp.path))), - ), +const encodeOrg = Schema.encodeSync(AccountV2.Org) + +const org = (id: string, name: string) => encodeOrg(new AccountV2.Org({ id: AccountV2.OrgID.make(id), name })) + +const login = () => + new AccountV2.Login({ + code: AccountV2.DeviceCode.make("device-code"), + user: AccountV2.UserCode.make("user-code"), + url: "https://one.example.com/verify", + server: "https://one.example.com", + expiry: Duration.seconds(600), + interval: Duration.seconds(5), + }) + +const deviceTokenClient = (body: unknown, status = 400) => + HttpClient.make((req) => + Effect.succeed( + req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404), ), ) - it.live("always switches to newly created accounts", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - const accounts = yield* Auth.Service - const eventSvc = yield* EventV2.Service - const switchedFiber = yield* eventSvc - .subscribe(Auth.Event.Switched) - .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) - - yield* Effect.yieldNow - - const first = yield* accounts.create({ - serviceID: Auth.ServiceID.make("provider"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }), - }) - const second = yield* accounts.create({ - serviceID: Auth.ServiceID.make("provider"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }), - }) - const third = yield* accounts.create({ - serviceID: Auth.ServiceID.make("provider"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "third-key" }), +const poll = (body: unknown, status = 400) => + AccountV2.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status)))) + +it.live("login normalizes trailing slashes in the provided server URL", () => + Effect.gen(function* () { + const seen: Array = [] + const client = HttpClient.make((req) => + Effect.gen(function* () { + seen.push(`${req.method} ${req.url}`) + + if (req.url === "https://one.example.com/auth/device/code") { + return json(req, { + device_code: "device-code", + user_code: "user-code", + verification_uri_complete: "/device?user_code=user-code", + expires_in: 600, + interval: 5, }) + } - expect(first).toBeDefined() - expect(second).toBeDefined() - expect(third).toBeDefined() - if (!first || !second || !third) return - - expect((yield* accounts.active(Auth.ServiceID.make("provider")))?.id).toBe(third.id) - expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([ - { serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id }, - { serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id }, - { serviceID: Auth.ServiceID.make("provider"), from: second.id, to: third.id }, - ]) - }).pipe(Effect.provide(testLayer(tmp.path))), + return json(req, {}, 404) + }), + ) + + const result = yield* AccountV2.use.login("https://one.example.com/").pipe(Effect.provide(live(client))) + + expect(seen).toEqual(["POST https://one.example.com/auth/device/code"]) + expect(result.server).toBe("https://one.example.com") + expect(result.url).toBe("https://one.example.com/device?user_code=user-code") + }), +) + +it.live("login maps transport failures to account transport errors", () => + Effect.gen(function* () { + const client = HttpClient.make((req) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request: req }), + }), ), - ), - ) + ) - it.live("account plugin refreshes providers on account lifecycle events", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - const accounts = yield* Auth.Service - const plugin = yield* PluginV2.Service - const records = [ - { - provider: ProviderV2.Info.empty(ProviderV2.ID.make("provider")), - models: new Map(), - }, - ] - const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = [] - const catalog = Catalog.Service.of({ - transform: () => Effect.die("unexpected catalog.transform"), - provider: { - get: () => Effect.die("unexpected provider.get"), - all: () => Effect.succeed([]), - available: () => Effect.succeed([]), - }, - model: { - get: () => Effect.die("unexpected model.get"), - all: () => Effect.succeed([]), - available: () => Effect.succeed([]), - default: () => Effect.succeed(Option.none()), - small: () => Effect.succeed(Option.none()), - }, - }) + const error = yield* Effect.flip(AccountV2.use.login("https://one.example.com").pipe(Effect.provide(live(client)))) - const eventSvc = yield* EventV2.Service - yield* plugin.add({ - ...AccountPlugin, - effect: AccountPlugin.effect.pipe( - Effect.provideService(Auth.Service, accounts), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(EventV2.Service, eventSvc), - Effect.provideService(PluginV2.Service, plugin), - ), - }) - yield* Effect.yieldNow + expect(error).toBeInstanceOf(AccountV2.AccountTransportError) + if (error instanceof AccountV2.AccountTransportError) { + expect(error.method).toBe("POST") + expect(error.url).toBe("https://one.example.com/auth/device/code") + } + }), +) - const first = yield* accounts.create({ - serviceID: Auth.ServiceID.make("provider"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }), - }) - expect(first).toBeDefined() - if (!first) return - yield* plugin.trigger("catalog.transform", context(records, updates), {}) - expect(updates).toEqual([ - { - id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: Auth.ServiceID.make("provider") }, - apiKey: "first-key", - }, - ]) +it.live("orgsByAccount groups orgs per account", () => + Effect.gen(function* () { + yield* persist({ + id: AccountV2.ID.make("user-1"), + email: "one@example.com", + url: "https://one.example.com", + accessToken: AccountV2.AccessToken.make("at_1"), + refreshToken: AccountV2.RefreshToken.make("rt_1"), + expiry: Date.now() + outsideEagerRefreshWindow, + orgID: Option.none(), + }) + + yield* persist({ + id: AccountV2.ID.make("user-2"), + email: "two@example.com", + url: "https://two.example.com", + accessToken: AccountV2.AccessToken.make("at_2"), + refreshToken: AccountV2.RefreshToken.make("rt_2"), + expiry: Date.now() + outsideEagerRefreshWindow, + orgID: Option.none(), + }) + + const seen: Array = [] + const client = HttpClient.make((req) => + Effect.gen(function* () { + seen.push(`${req.method} ${req.url}`) + + if (req.url === "https://one.example.com/api/orgs") { + return json(req, [org("org-1", "One")]) + } + + if (req.url === "https://two.example.com/api/orgs") { + return json(req, [org("org-2", "Two A"), org("org-3", "Two B")]) + } + + return json(req, [], 404) + }), + ) + + const rows = yield* AccountV2.use.orgsByAccount().pipe(Effect.provide(live(client))) + + expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([ + [AccountV2.ID.make("user-1"), [AccountV2.OrgID.make("org-1")]], + [AccountV2.ID.make("user-2"), [AccountV2.OrgID.make("org-2"), AccountV2.OrgID.make("org-3")]], + ]) + expect(seen).toEqual(["GET https://one.example.com/api/orgs", "GET https://two.example.com/api/orgs"]) + }), +) + +it.live("token refresh persists the new token", () => + Effect.gen(function* () { + const id = AccountV2.ID.make("user-1") - updates.length = 0 - const second = yield* accounts.create({ - serviceID: Auth.ServiceID.make("provider"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }), + yield* persist({ + id, + email: "user@example.com", + url: "https://one.example.com", + accessToken: AccountV2.AccessToken.make("at_old"), + refreshToken: AccountV2.RefreshToken.make("rt_old"), + expiry: Date.now() - 1_000, + orgID: Option.none(), + }) + + const client = HttpClient.make((req) => + Effect.succeed( + req.url === "https://one.example.com/auth/device/token" + ? json(req, { + access_token: "at_new", + refresh_token: "rt_new", + expires_in: 60, + }) + : json(req, {}, 404), + ), + ) + + const token = yield* AccountV2.use.token(id).pipe(Effect.provide(live(client))) + + expect(Option.getOrThrow(token)).toBeDefined() + expect(String(Option.getOrThrow(token))).toBe("at_new") + + const value = yield* row(id) + expect(value).toBeDefined() + if (!value) return + expect(value.access_token).toBe(AccountV2.AccessToken.make("at_new")) + expect(value.refresh_token).toBe(AccountV2.RefreshToken.make("rt_new")) + expect(value.token_expiry).toBeGreaterThan(Date.now()) + }), +) + +it.live("token refreshes before expiry when inside the eager refresh window", () => + Effect.gen(function* () { + const id = AccountV2.ID.make("user-1") + + yield* persist({ + id, + email: "user@example.com", + url: "https://one.example.com", + accessToken: AccountV2.AccessToken.make("at_old"), + refreshToken: AccountV2.RefreshToken.make("rt_old"), + expiry: Date.now() + insideEagerRefreshWindow, + orgID: Option.none(), + }) + + let refreshCalls = 0 + const client = HttpClient.make((req) => + Effect.promise(async () => { + if (req.url === "https://one.example.com/auth/device/token") { + refreshCalls += 1 + return json(req, { + access_token: "at_new", + refresh_token: "rt_new", + expires_in: 60, }) - expect(second).toBeDefined() - if (!second) return - yield* plugin.trigger("catalog.transform", context(records, updates), {}) - expect(updates).toEqual([ - { - id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: Auth.ServiceID.make("provider") }, - apiKey: "second-key", - }, - ]) + } - updates.length = 0 - yield* accounts.activate(first.id) - yield* plugin.trigger("catalog.transform", context(records, updates), {}) - expect(updates).toEqual([ - { - id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: Auth.ServiceID.make("provider") }, - apiKey: "first-key", - }, - ]) + return json(req, {}, 404) + }), + ) + + const token = yield* AccountV2.use.token(id).pipe(Effect.provide(live(client))) + + expect(String(Option.getOrThrow(token))).toBe("at_new") + expect(refreshCalls).toBe(1) + + const value = yield* row(id) + expect(value).toBeDefined() + if (!value) return + expect(value.access_token).toBe(AccountV2.AccessToken.make("at_new")) + expect(value.refresh_token).toBe(AccountV2.RefreshToken.make("rt_new")) + }), +) + +it.live("concurrent config and token requests coalesce token refresh", () => + Effect.gen(function* () { + const id = AccountV2.ID.make("user-1") + + yield* persist({ + id, + email: "user@example.com", + url: "https://one.example.com", + accessToken: AccountV2.AccessToken.make("at_old"), + refreshToken: AccountV2.RefreshToken.make("rt_old"), + expiry: Date.now() - 1_000, + orgID: Option.some(AccountV2.OrgID.make("org-9")), + }) - updates.length = 0 - yield* accounts.remove(first.id) - yield* plugin.trigger("catalog.transform", context(records, updates), {}) - expect(updates).toEqual([ + let refreshCalls = 0 + const client = HttpClient.make((req) => + Effect.promise(async () => { + if (req.url === "https://one.example.com/auth/device/token") { + refreshCalls += 1 + + if (refreshCalls === 1) { + await new Promise((resolve) => setTimeout(resolve, 25)) + return json(req, { + access_token: "at_new", + refresh_token: "rt_new", + expires_in: 60, + }) + } + + return json( + req, { - id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: Auth.ServiceID.make("provider") }, - apiKey: "second-key", + error: "invalid_grant", + error_description: "refresh token already used", }, - ]) + 400, + ) + } + + if (req.url === "https://one.example.com/api/config") { + return json(req, { config: { theme: "light", seats: 5 } }) + } + + return json(req, {}, 404) + }), + ) + + const [cfg, token] = yield* AccountV2.Service.use((s) => + Effect.all([s.config(id, AccountV2.OrgID.make("org-9")), s.token(id)], { concurrency: 2 }), + ).pipe(Effect.provide(live(client))) - updates.length = 0 - yield* accounts.remove(second.id) - yield* plugin.trigger("catalog.transform", context(records, updates), {}) - expect(updates).toEqual([]) - }).pipe(Effect.provide(testLayer(tmp.path))), + expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 }) + expect(String(Option.getOrThrow(token))).toBe("at_new") + expect(refreshCalls).toBe(1) + + const value = yield* row(id) + expect(value).toBeDefined() + if (!value) return + expect(value.access_token).toBe(AccountV2.AccessToken.make("at_new")) + expect(value.refresh_token).toBe(AccountV2.RefreshToken.make("rt_new")) + }), +) + +it.live("config sends the selected org header", () => + Effect.gen(function* () { + const id = AccountV2.ID.make("user-1") + + yield* persist({ + id, + email: "user@example.com", + url: "https://one.example.com", + accessToken: AccountV2.AccessToken.make("at_1"), + refreshToken: AccountV2.RefreshToken.make("rt_1"), + expiry: Date.now() + outsideEagerRefreshWindow, + orgID: Option.none(), + }) + + const seen: { auth?: string; org?: string } = {} + const client = HttpClient.make((req) => + Effect.gen(function* () { + seen.auth = req.headers.authorization + seen.org = req.headers["x-org-id"] + + if (req.url === "https://one.example.com/api/config") { + return json(req, { config: { theme: "light", seats: 5 } }) + } + + return json(req, {}, 404) + }), + ) + + const cfg = yield* AccountV2.Service.use((s) => s.config(id, AccountV2.OrgID.make("org-9"))).pipe( + Effect.provide(live(client)), + ) + + expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 }) + expect(seen).toEqual({ + auth: "Bearer at_1", + org: "org-9", + }) + }), +) + +it.live("poll stores the account and first org on success", () => + Effect.gen(function* () { + const client = HttpClient.make((req) => + Effect.succeed( + req.url === "https://one.example.com/auth/device/token" + ? json(req, { + access_token: "at_1", + refresh_token: "rt_1", + token_type: "Bearer", + expires_in: 60, + }) + : req.url === "https://one.example.com/api/user" + ? json(req, { id: "user-1", email: "user@example.com" }) + : req.url === "https://one.example.com/api/orgs" + ? json(req, [org("org-1", "One")]) + : json(req, {}, 404), ), - ), + ) + + const res = yield* AccountV2.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client))) + + expect(res._tag).toBe("PollSuccess") + if (res._tag === "PollSuccess") { + expect(res.email).toBe("user@example.com") + } + + const current = yield* active + expect(current).toEqual( + expect.objectContaining({ + id: "user-1", + email: "user@example.com", + active_org_id: "org-1", + }), + ) + }), +) + +for (const [name, body, expectedTag] of [ + [ + "pending", + { + error: "authorization_pending", + error_description: "The authorization request is still pending", + }, + "PollPending", + ], + [ + "slow", + { + error: "slow_down", + error_description: "Polling too frequently, please slow down", + }, + "PollSlow", + ], + [ + "denied", + { + error: "access_denied", + error_description: "The authorization request was denied", + }, + "PollDenied", + ], + [ + "expired", + { + error: "expired_token", + error_description: "The device code has expired", + }, + "PollExpired", + ], +] as const) { + it.live(`poll returns ${name} for ${body.error}`, () => + Effect.gen(function* () { + const result = yield* poll(body) + expect(result._tag).toBe(expectedTag) + }), ) -}) +} + +it.live("poll returns poll error for other OAuth errors", () => + Effect.gen(function* () { + const result = yield* poll({ + error: "server_error", + error_description: "An unexpected error occurred", + }) + + expect(result._tag).toBe("PollError") + if (result._tag === "PollError") { + expect(String(result.cause)).toContain("server_error") + } + }), +) diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index dac2933cc1f7..27dc79a3ae95 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -1,12 +1,17 @@ +import fs from "fs/promises" +import path from "path" import { describe, expect } from "bun:test" -import { Effect, Schema } from "effect" +import { Effect, Layer, Schema } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { PermissionV2 } from "@opencode-ai/core/permission" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" -const it = testEffect(AgentV2.locationLayer) +const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, AppFileSystem.defaultLayer)) const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigAgentPlugin.Plugin", () => { @@ -183,4 +188,73 @@ describe("ConfigAgentPlugin.Plugin", () => { expect(yield* agents.get(build)).toBeUndefined() }), ) + + it.live("loads markdown agents from config directories in priority order", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + const local = path.join(tmp.path, ".opencode") + return Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(path.join(global, "agent"), { recursive: true }) + await fs.mkdir(path.join(local, "agents", "team"), { recursive: true }) + await fs.writeFile( + path.join(global, "agent", "reviewer.md"), + `--- +description: Global reviewer +mode: subagent +permissions: + - action: edit + resource: "*" + effect: deny +--- +Review globally.`, + ) + await fs.writeFile( + path.join(local, "agents", "reviewer.md"), + `--- +description: Local reviewer +model: anthropic/claude-sonnet +--- +Review locally.`, + ) + await fs.writeFile( + path.join(local, "agents", "team", "research.md"), + `--- +mode: subagent +--- +Research the issue.`, + ) + await fs.writeFile(path.join(local, "agents", "build.md"), "---\ndisabled: true\n---\n") + }) + + const agents = yield* AgentV2.Service + yield* agents.update((editor) => editor.update(AgentV2.ID.make("build"), () => {})) + const config = Config.Service.of({ + directories: () => Effect.succeed([AbsolutePath.make(global), AbsolutePath.make(local)]), + get: () => Effect.succeed([]), + }) + + yield* ConfigAgentPlugin.Plugin.effect.pipe(Effect.provideService(Config.Service, config)) + + const reviewer = yield* agents.get(AgentV2.ID.make("reviewer")) + expect(reviewer).toMatchObject({ + system: "Review locally.", + description: "Local reviewer", + mode: "subagent", + model: { providerID: "anthropic", id: "claude-sonnet" }, + }) + expect(PermissionV2.evaluate("edit", "src/index.ts", reviewer?.permissions ?? []).effect).toBe("deny") + expect(yield* agents.get(AgentV2.ID.make("team/research"))).toMatchObject({ + system: "Research the issue.", + mode: "subagent", + }) + expect(yield* agents.get(AgentV2.ID.make("build"))).toBeUndefined() + }) + }), + ), + ) }) diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts deleted file mode 100644 index 9d9f7e4a2882..000000000000 --- a/packages/opencode/src/account/account.ts +++ /dev/null @@ -1,459 +0,0 @@ -import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect" -import { serviceUse } from "@opencode-ai/core/effect/service-use" -import { - FetchHttpClient, - HttpClient, - HttpClientError, - HttpClientRequest, - HttpClientResponse, -} from "effect/unstable/http" - -import { withTransientReadRetry } from "@/util/effect-http-client" -import { AccountRepo, type AccountRow } from "./repo" -import { normalizeServerUrl } from "./url" -import { - type AccountError, - AccessToken, - AccountID, - DeviceCode, - Info, - RefreshToken, - AccountServiceError, - AccountTransportError, - Login, - Org, - OrgID, - PollDenied, - PollError, - PollExpired, - PollPending, - type PollResult, - PollSlow, - PollSuccess, - UserCode, -} from "./schema" - -export { - AccountID, - type AccountError, - AccountRepoError, - AccountServiceError, - AccountTransportError, - 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[] -} - -export type ActiveOrg = { - account: Info - org: Org -} - -class RemoteConfig extends Schema.Class("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")({ - access_token: AccessToken, - refresh_token: RefreshToken, - expires_in: DurationFromSeconds, -}) {} - -class DeviceAuth extends Schema.Class("DeviceAuth")({ - device_code: DeviceCode, - user_code: UserCode, - verification_uri_complete: Schema.String, - expires_in: DurationFromSeconds, - interval: DurationFromSeconds, -}) {} - -class DeviceTokenSuccess extends Schema.Class("DeviceTokenSuccess")({ - access_token: AccessToken, - refresh_token: RefreshToken, - token_type: Schema.Literal("Bearer"), - expires_in: DurationFromSeconds, -}) {} - -class DeviceTokenError extends Schema.Class("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")({ - id: AccountID, - email: Schema.String, -}) {} - -class ClientId extends Schema.Class("ClientId")({ client_id: Schema.String }) {} - -class DeviceTokenRequest extends Schema.Class("DeviceTokenRequest")({ - grant_type: Schema.String, - device_code: DeviceCode, - client_id: Schema.String, -}) {} - -class TokenRefreshRequest extends Schema.Class("TokenRefreshRequest")({ - grant_type: Schema.String, - refresh_token: RefreshToken, - client_id: Schema.String, -}) {} - -const clientId = "opencode-cli" -const eagerRefreshThreshold = Duration.minutes(5) -const eagerRefreshThresholdMs = Duration.toMillis(eagerRefreshThreshold) - -const isTokenFresh = (tokenExpiry: number | null, now: number) => - tokenExpiry != null && tokenExpiry > now + eagerRefreshThresholdMs - -const mapAccountServiceError = - (message = "Account service operation failed") => - (effect: Effect.Effect): Effect.Effect => - effect.pipe(Effect.mapError((cause) => accountErrorFromCause(cause, message))) - -const accountErrorFromCause = (cause: unknown, message: string): AccountError => { - if (cause instanceof AccountServiceError || cause instanceof AccountTransportError) { - return cause - } - - if (HttpClientError.isHttpClientError(cause)) { - switch (cause.reason._tag) { - case "TransportError": { - return AccountTransportError.fromHttpClientError(cause.reason) - } - default: { - return new AccountServiceError({ message, cause }) - } - } - } - - return new AccountServiceError({ message, cause }) -} - -export interface Interface { - readonly active: () => Effect.Effect, AccountError> - readonly activeOrg: () => Effect.Effect, AccountError> - readonly list: () => Effect.Effect - readonly orgsByAccount: () => Effect.Effect - readonly remove: (accountID: AccountID) => Effect.Effect - readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect - readonly orgs: (accountID: AccountID) => Effect.Effect - readonly config: ( - accountID: AccountID, - orgID: OrgID, - ) => Effect.Effect>, AccountError> - readonly token: (accountID: AccountID) => Effect.Effect, AccountError> - readonly login: (url: string) => Effect.Effect - readonly poll: (input: Login) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/Account") {} - -export const use = serviceUse(Service) - -export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const repo = yield* AccountRepo.Service - 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 = (request: Effect.Effect) => - request.pipe( - Effect.flatMap((req) => httpOk.execute(req)), - mapAccountServiceError("HTTP request failed"), - ) - - const executeEffect = (request: Effect.Effect) => - request.pipe( - Effect.flatMap((req) => http.execute(req)), - mapAccountServiceError("HTTP request failed"), - ) - - const refreshToken = Effect.fnUntraced(function* (row: AccountRow) { - const now = yield* Clock.currentTimeMillis - - 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 refreshTokenCache = yield* Cache.make({ - capacity: Number.POSITIVE_INFINITY, - timeToLive: Duration.zero, - lookup: Effect.fnUntraced(function* (accountID) { - const maybeAccount = yield* repo.getRow(accountID) - if (Option.isNone(maybeAccount)) { - return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" })) - } - - const account = maybeAccount.value - const now = yield* Clock.currentTimeMillis - if (isTokenFresh(account.token_expiry, now)) { - return account.access_token - } - - return yield* refreshToken(account) - }), - }) - - const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { - const now = yield* Clock.currentTimeMillis - if (isTokenFresh(row.token_expiry, now)) { - return row.access_token - } - - return yield* Cache.get(refreshTokenCache, row.id) - }) - - 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 activeOrg = Effect.fn("Account.activeOrg")(function* () { - const activeAccount = yield* repo.active() - if (Option.isNone(activeAccount)) return Option.none() - - const account = activeAccount.value - if (!account.active_org_id) return Option.none() - - const accountOrgs = yield* orgs(account.id) - const org = accountOrgs.find((item) => item.id === account.active_org_id) - if (!org) return Option.none() - - return Option.some({ account, org }) - }) - - const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () { - const accounts = yield* repo.list() - return yield* Effect.forEach( - accounts, - (account) => - orgs(account.id).pipe( - Effect.catch(() => Effect.succeed([] as readonly Org[])), - Effect.map((orgs) => ({ account, orgs })), - ), - { concurrency: 3 }, - ) - }) - - 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 normalizedServer = normalizeServerUrl(server) - const response = yield* executeEffectOk( - HttpClientRequest.post(`${normalizedServer}/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: `${normalizedServer}${parsed.verification_uri_complete}`, - server: normalizedServer, - 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() - - 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, - activeOrg, - list: repo.list, - orgsByAccount, - remove: repo.remove, - use: repo.use, - orgs, - config, - token, - login, - poll, - }) - }), -) - -export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(FetchHttpClient.layer)) - -export * as Account from "./account" diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts deleted file mode 100644 index 8b091d4fff1d..000000000000 --- a/packages/opencode/src/account/repo.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { eq } from "drizzle-orm" -import { serviceUse } from "@opencode-ai/core/effect/service-use" -import { Effect, Layer, Option, Schema, Context } from "effect" - -import { Database } from "@opencode-ai/core/database/database" -import { AccountStateTable, AccountTable } from "@opencode-ai/core/account/sql" -import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema" -import { normalizeServerUrl } from "./url" - -export type AccountRow = (typeof AccountTable)["$inferSelect"] - -const ACCOUNT_STATE_ID = 1 - -export interface Interface { - readonly active: () => Effect.Effect, AccountRepoError> - readonly list: () => Effect.Effect - readonly remove: (accountID: AccountID) => Effect.Effect - readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect - readonly getRow: (accountID: AccountID) => Effect.Effect, AccountRepoError> - readonly persistToken: (input: { - accountID: AccountID - accessToken: AccessToken - refreshToken: RefreshToken - expiry: Option.Option - }) => Effect.Effect - readonly persistAccount: (input: { - id: AccountID - email: string - url: string - accessToken: AccessToken - refreshToken: RefreshToken - expiry: number - orgID: Option.Option - }) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/AccountRepo") {} - -export const use = serviceUse(Service) - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const { db } = yield* Database.Service - const decode = Schema.decodeUnknownSync(Info) - - const query = (effect: Effect.Effect) => - effect.pipe(Effect.mapError((cause) => new AccountRepoError({ message: "Database operation failed", cause }))) - - const current = Effect.fnUntraced(function* () { - const state = yield* db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get() - if (!state?.active_account_id) return - const account = yield* db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get() - if (!account) return - return { ...account, active_org_id: state.active_org_id ?? null } - }) - - const state = (accountID: AccountID, orgID: Option.Option) => { - const id = Option.getOrNull(orgID) - return db - .insert(AccountStateTable) - .values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id }) - .onConflictDoUpdate({ - target: AccountStateTable.id, - set: { active_account_id: accountID, active_org_id: id }, - }) - .run() - } - - const active = Effect.fn("AccountRepo.active")(() => - query(current()).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))), - ) - - const list = Effect.fn("AccountRepo.list")(() => - query( - db - .select() - .from(AccountTable) - .all() - .pipe(Effect.map((rows) => rows.map((row: AccountRow) => decode({ ...row, active_org_id: null })))), - ), - ) - - const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) => - query( - db.transaction((tx) => - Effect.gen(function* () { - yield* tx - .update(AccountStateTable) - .set({ active_account_id: null, active_org_id: null }) - .where(eq(AccountStateTable.active_account_id, accountID)) - .run() - yield* tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run() - }), - ), - ).pipe(Effect.asVoid), - ) - - const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option) => - query(state(accountID, orgID)).pipe(Effect.asVoid), - ) - - const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) => - query(db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe( - Effect.map(Option.fromNullishOr), - ), - ) - - const persistToken = Effect.fn("AccountRepo.persistToken")((input) => - query( - db - .update(AccountTable) - .set({ - access_token: input.accessToken, - refresh_token: input.refreshToken, - token_expiry: Option.getOrNull(input.expiry), - }) - .where(eq(AccountTable.id, input.accountID)) - .run(), - ).pipe(Effect.asVoid), - ) - - const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) => - query( - db.transaction((tx) => - Effect.gen(function* () { - const url = normalizeServerUrl(input.url) - - yield* tx - .insert(AccountTable) - .values({ - id: input.id, - email: input.email, - url, - access_token: input.accessToken, - refresh_token: input.refreshToken, - token_expiry: input.expiry, - }) - .onConflictDoUpdate({ - target: AccountTable.id, - set: { - email: input.email, - url, - access_token: input.accessToken, - refresh_token: input.refreshToken, - token_expiry: input.expiry, - }, - }) - .run() - yield* state(input.id, input.orgID) - }), - ), - ).pipe(Effect.asVoid), - ) - - return Service.of({ - active, - list, - remove, - use, - getRow, - persistToken, - persistAccount, - }) - }), -) - -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) - -export * as AccountRepo from "./repo" diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts deleted file mode 100644 index 222296ff1bc3..000000000000 --- a/packages/opencode/src/account/schema.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Schema } from "effect" -import type * as HttpClientError from "effect/unstable/http/HttpClientError" - -export const AccountID = Schema.String.pipe(Schema.brand("AccountID")) -export type AccountID = Schema.Schema.Type - -export const OrgID = Schema.String.pipe(Schema.brand("OrgID")) -export type OrgID = Schema.Schema.Type - -export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken")) -export type AccessToken = Schema.Schema.Type - -export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken")) -export type RefreshToken = Schema.Schema.Type - -export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode")) -export type DeviceCode = Schema.Schema.Type - -export const UserCode = Schema.String.pipe(Schema.brand("UserCode")) -export type UserCode = Schema.Schema.Type - -export class Info extends Schema.Class("Account")({ - id: AccountID, - email: Schema.String, - url: Schema.String, - active_org_id: Schema.NullOr(OrgID), -}) {} - -export class Org extends Schema.Class("Org")({ - id: OrgID, - name: Schema.String, -}) {} - -export class AccountRepoError extends Schema.TaggedErrorClass()("AccountRepoError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} - -export class AccountServiceError extends Schema.TaggedErrorClass()("AccountServiceError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} - -export class AccountTransportError extends Schema.TaggedErrorClass()("AccountTransportError", { - method: Schema.String, - url: Schema.String, - description: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect), -}) { - static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError { - return new AccountTransportError({ - method: error.request.method, - url: error.request.url, - description: error.description, - cause: error.cause, - }) - } - - override get message(): string { - return [ - `Could not reach ${this.method} ${this.url}.`, - `This failed before the server returned an HTTP response.`, - this.description, - `Check your network, proxy, or VPN configuration and try again.`, - ] - .filter(Boolean) - .join("\n") - } -} - -export type AccountError = AccountRepoError | AccountServiceError | AccountTransportError - -export class Login extends Schema.Class("Login")({ - code: DeviceCode, - user: UserCode, - url: Schema.String, - server: Schema.String, - expiry: Schema.Duration, - interval: Schema.Duration, -}) {} - -export class PollSuccess extends Schema.TaggedClass()("PollSuccess", { - email: Schema.String, -}) {} - -export class PollPending extends Schema.TaggedClass()("PollPending", {}) {} - -export class PollSlow extends Schema.TaggedClass()("PollSlow", {}) {} - -export class PollExpired extends Schema.TaggedClass()("PollExpired", {}) {} - -export class PollDenied extends Schema.TaggedClass()("PollDenied", {}) {} - -export class PollError extends Schema.TaggedClass()("PollError", { - cause: Schema.Defect, -}) {} - -export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError]) -export type PollResult = Schema.Schema.Type diff --git a/packages/opencode/src/account/url.ts b/packages/opencode/src/account/url.ts deleted file mode 100644 index 36afd6d8e99b..000000000000 --- a/packages/opencode/src/account/url.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const normalizeServerUrl = (input: string): string => { - const url = new URL(input) - url.search = "" - url.hash = "" - - const pathname = url.pathname.replace(/\/+$/, "") - return pathname.length === 0 ? url.origin : `${url.origin}${pathname}` -} diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index b9cbf5569cf2..48c48abdaacd 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -1,8 +1,7 @@ import { cmd } from "./cmd" import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" -import { Account } from "@/account/account" -import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema" +import { AccountV2 } from "@opencode-ai/core/account" import { effectCmd } from "../effect-cmd" import * as Prompt from "../effect/prompt" import open from "open" @@ -34,12 +33,12 @@ export const formatOrgLine = ( } const isActiveOrgChoice = ( - active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>, - choice: { accountID: AccountID; orgID: OrgID }, + active: Option.Option<{ id: AccountV2.ID; active_org_id: AccountV2.OrgID | null }>, + choice: { accountID: AccountV2.ID; orgID: AccountV2.OrgID }, ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID const loginEffect = Effect.fn("login")(function* (url: string) { - const service = yield* Account.Service + const service = yield* AccountV2.Service yield* Prompt.intro("Log in") const login = yield* service.login(url) @@ -51,7 +50,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) { const s = Prompt.spinner() yield* s.start("Waiting for authorization...") - const poll = (wait: Duration.Duration): Effect.Effect => + const poll = (wait: Duration.Duration): Effect.Effect => Effect.gen(function* () { yield* Effect.sleep(wait) const result = yield* service.poll(login) @@ -62,7 +61,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) { const result = yield* poll(login.interval).pipe( Effect.timeout(login.expiry), - Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())), + Effect.catchTag("TimeoutError", () => Effect.succeed(new AccountV2.PollExpired())), ) yield* Match.valueTags(result, { @@ -80,7 +79,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) { }) const logoutEffect = Effect.fn("logout")(function* (email?: string) { - const service = yield* Account.Service + const service = yield* AccountV2.Service const accounts = yield* service.list() if (accounts.length === 0) return yield* println("Not logged in") @@ -113,13 +112,13 @@ const logoutEffect = Effect.fn("logout")(function* (email?: string) { }) interface OrgChoice { - orgID: OrgID - accountID: AccountID + orgID: AccountV2.OrgID + accountID: AccountV2.ID label: string } const switchEffect = Effect.fn("switch")(function* () { - const service = yield* Account.Service + const service = yield* AccountV2.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("Not logged in") @@ -148,7 +147,7 @@ const switchEffect = Effect.fn("switch")(function* () { }) const orgsEffect = Effect.fn("orgs")(function* () { - const service = yield* Account.Service + const service = yield* AccountV2.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("No accounts found") @@ -165,7 +164,7 @@ const orgsEffect = Effect.fn("orgs")(function* () { }) const openEffect = Effect.fn("open")(function* () { - const service = yield* Account.Service + const service = yield* AccountV2.Service const active = yield* service.active() if (Option.isNone(active)) return yield* println("No active account") diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 8dc8c6ee54a8..8f958271c30d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -13,7 +13,7 @@ import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { existsSync } from "fs" -import { Account } from "@/account/account" +import { AccountV2 } from "@opencode-ai/core/account" import { isRecord } from "@/util/record" import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -387,7 +387,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const fs = yield* AppFileSystem.Service const authSvc = yield* Auth.Service - const accountSvc = yield* Account.Service + const accountSvc = yield* AccountV2.Service const env = yield* Env.Service const npmSvc = yield* Npm.Service const http = yield* HttpClient.HttpClient @@ -880,7 +880,7 @@ export const defaultLayer = layer.pipe( Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(Auth.defaultLayer), - Layer.provide(Account.defaultLayer), + Layer.provide(AccountV2.defaultLayer), Layer.provide(Npm.defaultLayer), Layer.provide(FetchHttpClient.layer), ) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 5434bb713f55..6c4d11149d94 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -5,7 +5,7 @@ import * as Observability from "@opencode-ai/core/effect/observability" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Database } from "@opencode-ai/core/database/database" import { Auth } from "@/auth" -import { Account } from "@/account/account" +import { AccountV2 } from "@opencode-ai/core/account" import { Config } from "@/config/config" import { Git } from "@/git" import { Ripgrep } from "@/file/ripgrep" @@ -62,7 +62,7 @@ export const AppLayer = Layer.mergeAll( AppFileSystem.defaultLayer, Database.defaultLayer, Auth.defaultLayer, - Account.defaultLayer, + AccountV2.defaultLayer, Config.defaultLayer, Git.defaultLayer, Ripgrep.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index c40a3bf00615..ed02d7c447b7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -1,4 +1,4 @@ -import { AccountID, OrgID } from "@/account/schema" +import { AccountV2 } from "@opencode-ai/core/account" import { MCP } from "@/mcp" import { Session } from "@/session/session" @@ -37,8 +37,8 @@ const ConsoleOrgList = Schema.Struct({ }) export const ConsoleSwitchPayload = Schema.Struct({ - accountID: AccountID, - orgID: OrgID, + accountID: AccountV2.ID, + orgID: AccountV2.OrgID, }) const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index e995c21602a3..a612344d95d7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -1,4 +1,4 @@ -import { Account } from "@/account/account" +import { AccountV2 } from "@opencode-ai/core/account" import { Agent } from "@/agent/agent" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" @@ -22,7 +22,7 @@ function mapWorktreeError(self: Effect.Effect) { export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "experimental", (handlers) => Effect.gen(function* () { - const account = yield* Account.Service + const account = yield* AccountV2.Service const agents = yield* Agent.Service const config = yield* Config.Service const mcp = yield* MCP.Service diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 12761ab7ff39..4bcb1cba8e24 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -10,7 +10,7 @@ import { } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Account } from "@/account/account" +import { AccountV2 } from "@opencode-ai/core/account" import { Agent } from "@/agent/agent" import { Auth } from "@/auth" import { Config } from "@/config/config" @@ -193,7 +193,7 @@ export function createRoutes( fenceLayer.pipe(Layer.provide(Database.defaultLayer)), cors(corsOptions), Database.defaultLayer, - Account.defaultLayer, + AccountV2.defaultLayer, Agent.defaultLayer, Auth.defaultLayer, Command.defaultLayer, diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 665b62898b8d..ac3537ff2d27 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -2,7 +2,7 @@ import type * as SDK from "@opencode-ai/sdk/v2" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { Account } from "@/account/account" +import { AccountV2 } from "@opencode-ai/core/account" import { EventV2Bridge } from "@/event-v2-bridge" import { InstanceState } from "@/effect/instance-state" import { Provider } from "@/provider/provider" @@ -111,7 +111,7 @@ function key(item: Data) { export const layer = Layer.effect( Service, Effect.gen(function* () { - const account = yield* Account.Service + const account = yield* AccountV2.Service const events = yield* EventV2Bridge.Service const cfg = yield* Config.Service const { db } = yield* Database.Service @@ -367,7 +367,7 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe( Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Account.defaultLayer), + Layer.provide(AccountV2.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(FetchHttpClient.layer), diff --git a/packages/opencode/test/account/repo.test.ts b/packages/opencode/test/account/repo.test.ts deleted file mode 100644 index 42851fc19d46..000000000000 --- a/packages/opencode/test/account/repo.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { expect } from "bun:test" -import { Effect, Layer, Option } from "effect" -import { sql } from "drizzle-orm" - -import { AccountRepo } from "../../src/account/repo" -import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema" -import { Database } from "@opencode-ai/core/database/database" -import { testEffect } from "../lib/effect" - -const truncate = Layer.effectDiscard( - Effect.gen(function* () { - const { db } = yield* Database.Service - yield* db.run(sql`DELETE FROM account_state`) - yield* db.run(sql`DELETE FROM account`) - }), -).pipe(Layer.provide(Database.defaultLayer)) - -const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate)) - -it.live("list returns empty when no accounts exist", () => - Effect.gen(function* () { - const accounts = yield* AccountRepo.use.list() - expect(accounts).toEqual([]) - }), -) - -it.live("active returns none when no accounts exist", () => - Effect.gen(function* () { - const active = yield* AccountRepo.use.active() - expect(Option.isNone(active)).toBe(true) - }), -) - -it.live("persistAccount inserts and getRow retrieves", () => - Effect.gen(function* () { - const id = AccountID.make("user-1") - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id, - email: "test@example.com", - url: "https://control.example.com", - accessToken: AccessToken.make("at_123"), - refreshToken: RefreshToken.make("rt_456"), - expiry: Date.now() + 3600_000, - orgID: Option.some(OrgID.make("org-1")), - }), - ) - - const row = yield* AccountRepo.use.getRow(id) - expect(Option.isSome(row)).toBe(true) - const value = Option.getOrThrow(row) - expect(value.id).toBe(AccountID.make("user-1")) - expect(value.email).toBe("test@example.com") - - const active = yield* AccountRepo.use.active() - expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1")) - }), -) - -it.live("persistAccount normalizes trailing slashes in stored server URLs", () => - Effect.gen(function* () { - const id = AccountID.make("user-1") - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id, - email: "test@example.com", - url: "https://control.example.com/", - accessToken: AccessToken.make("at_123"), - refreshToken: RefreshToken.make("rt_456"), - expiry: Date.now() + 3600_000, - orgID: Option.none(), - }), - ) - - const row = yield* AccountRepo.use.getRow(id) - const active = yield* AccountRepo.use.active() - const list = yield* AccountRepo.use.list() - - expect(Option.getOrThrow(row).url).toBe("https://control.example.com") - expect(Option.getOrThrow(active).url).toBe("https://control.example.com") - expect(list[0]?.url).toBe("https://control.example.com") - }), -) - -it.live("persistAccount sets the active account and org", () => - Effect.gen(function* () { - const id1 = AccountID.make("user-1") - const id2 = AccountID.make("user-2") - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id: id1, - email: "first@example.com", - url: "https://control.example.com", - accessToken: AccessToken.make("at_1"), - refreshToken: RefreshToken.make("rt_1"), - expiry: Date.now() + 3600_000, - orgID: Option.some(OrgID.make("org-1")), - }), - ) - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id: id2, - email: "second@example.com", - url: "https://control.example.com", - accessToken: AccessToken.make("at_2"), - refreshToken: RefreshToken.make("rt_2"), - expiry: Date.now() + 3600_000, - orgID: Option.some(OrgID.make("org-2")), - }), - ) - - // Last persisted account is active with its org - const active = yield* AccountRepo.use.active() - expect(Option.isSome(active)).toBe(true) - expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2")) - expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2")) - }), -) - -it.live("list returns all accounts", () => - Effect.gen(function* () { - const id1 = AccountID.make("user-1") - const id2 = AccountID.make("user-2") - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id: id1, - email: "a@example.com", - url: "https://control.example.com", - accessToken: AccessToken.make("at_1"), - refreshToken: RefreshToken.make("rt_1"), - expiry: Date.now() + 3600_000, - orgID: Option.none(), - }), - ) - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id: id2, - email: "b@example.com", - url: "https://control.example.com", - accessToken: AccessToken.make("at_2"), - refreshToken: RefreshToken.make("rt_2"), - expiry: Date.now() + 3600_000, - orgID: Option.some(OrgID.make("org-1")), - }), - ) - - const accounts = yield* AccountRepo.use.list() - expect(accounts.length).toBe(2) - expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"]) - }), -) - -it.live("remove deletes an account", () => - Effect.gen(function* () { - const id = AccountID.make("user-1") - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id, - email: "test@example.com", - url: "https://control.example.com", - accessToken: AccessToken.make("at_1"), - refreshToken: RefreshToken.make("rt_1"), - expiry: Date.now() + 3600_000, - orgID: Option.none(), - }), - ) - - yield* AccountRepo.use.remove(id) - - const row = yield* AccountRepo.use.getRow(id) - expect(Option.isNone(row)).toBe(true) - }), -) - -it.live("use stores the selected org and marks the account active", () => - Effect.gen(function* () { - const id1 = AccountID.make("user-1") - const id2 = AccountID.make("user-2") - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id: id1, - email: "first@example.com", - url: "https://control.example.com", - accessToken: AccessToken.make("at_1"), - refreshToken: RefreshToken.make("rt_1"), - expiry: Date.now() + 3600_000, - orgID: Option.none(), - }), - ) - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id: id2, - email: "second@example.com", - url: "https://control.example.com", - accessToken: AccessToken.make("at_2"), - refreshToken: RefreshToken.make("rt_2"), - expiry: Date.now() + 3600_000, - orgID: Option.none(), - }), - ) - - yield* AccountRepo.Service.use((r) => r.use(id1, Option.some(OrgID.make("org-99")))) - const active1 = yield* AccountRepo.use.active() - expect(Option.getOrThrow(active1).id).toBe(id1) - expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99")) - - yield* AccountRepo.Service.use((r) => r.use(id1, Option.none())) - const active2 = yield* AccountRepo.use.active() - expect(Option.getOrThrow(active2).active_org_id).toBeNull() - }), -) - -it.live("persistToken updates token fields", () => - Effect.gen(function* () { - const id = AccountID.make("user-1") - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id, - email: "test@example.com", - url: "https://control.example.com", - accessToken: AccessToken.make("old_token"), - refreshToken: RefreshToken.make("old_refresh"), - expiry: 1000, - orgID: Option.none(), - }), - ) - - const expiry = Date.now() + 7200_000 - yield* AccountRepo.Service.use((r) => - r.persistToken({ - accountID: id, - accessToken: AccessToken.make("new_token"), - refreshToken: RefreshToken.make("new_refresh"), - expiry: Option.some(expiry), - }), - ) - - const row = yield* AccountRepo.use.getRow(id) - const value = Option.getOrThrow(row) - expect(value.access_token).toBe(AccessToken.make("new_token")) - expect(value.refresh_token).toBe(RefreshToken.make("new_refresh")) - expect(value.token_expiry).toBe(expiry) - }), -) - -it.live("persistToken with no expiry sets token_expiry to null", () => - Effect.gen(function* () { - const id = AccountID.make("user-1") - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id, - email: "test@example.com", - url: "https://control.example.com", - accessToken: AccessToken.make("old_token"), - refreshToken: RefreshToken.make("old_refresh"), - expiry: 1000, - orgID: Option.none(), - }), - ) - - yield* AccountRepo.Service.use((r) => - r.persistToken({ - accountID: id, - accessToken: AccessToken.make("new_token"), - refreshToken: RefreshToken.make("new_refresh"), - expiry: Option.none(), - }), - ) - - const row = yield* AccountRepo.use.getRow(id) - expect(Option.getOrThrow(row).token_expiry).toBeNull() - }), -) - -it.live("persistAccount upserts on conflict", () => - Effect.gen(function* () { - const id = AccountID.make("user-1") - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id, - email: "test@example.com", - url: "https://control.example.com", - accessToken: AccessToken.make("at_v1"), - refreshToken: RefreshToken.make("rt_v1"), - expiry: 1000, - orgID: Option.some(OrgID.make("org-1")), - }), - ) - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id, - email: "test@example.com", - url: "https://control.example.com", - accessToken: AccessToken.make("at_v2"), - refreshToken: RefreshToken.make("rt_v2"), - expiry: 2000, - orgID: Option.some(OrgID.make("org-2")), - }), - ) - - const accounts = yield* AccountRepo.use.list() - expect(accounts.length).toBe(1) - - const row = yield* AccountRepo.use.getRow(id) - const value = Option.getOrThrow(row) - expect(value.access_token).toBe(AccessToken.make("at_v2")) - - const active = yield* AccountRepo.use.active() - expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2")) - }), -) - -it.live("remove clears active state when deleting the active account", () => - Effect.gen(function* () { - const id = AccountID.make("user-1") - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id, - email: "test@example.com", - url: "https://control.example.com", - accessToken: AccessToken.make("at_1"), - refreshToken: RefreshToken.make("rt_1"), - expiry: Date.now() + 3600_000, - orgID: Option.some(OrgID.make("org-1")), - }), - ) - - yield* AccountRepo.use.remove(id) - - const active = yield* AccountRepo.use.active() - expect(Option.isNone(active)).toBe(true) - }), -) - -it.live("getRow returns none for nonexistent account", () => - Effect.gen(function* () { - const row = yield* AccountRepo.Service.use((r) => r.getRow(AccountID.make("nope"))) - expect(Option.isNone(row)).toBe(true) - }), -) diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts deleted file mode 100644 index 04d425e2c465..000000000000 --- a/packages/opencode/test/account/service.test.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { expect } from "bun:test" -import { Duration, Effect, Layer, Option, Schema } from "effect" -import { sql } from "drizzle-orm" -import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http" - -import { AccountRepo } from "../../src/account/repo" -import { Account } from "../../src/account/account" -import { - AccessToken, - AccountID, - AccountTransportError, - DeviceCode, - Login, - Org, - OrgID, - RefreshToken, - UserCode, -} from "../../src/account/schema" -import { Database } from "@opencode-ai/core/database/database" -import { testEffect } from "../lib/effect" - -const truncate = Layer.effectDiscard( - Effect.gen(function* () { - const { db } = yield* Database.Service - yield* db.run(sql`DELETE FROM account_state`) - yield* db.run(sql`DELETE FROM account`) - }), -).pipe(Layer.provide(Database.defaultLayer)) - -const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate)) - -const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1)) -const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10)) - -const live = (client: HttpClient.HttpClient) => - Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) - -const json = (req: Parameters[0], body: unknown, status = 200) => - HttpClientResponse.fromWeb( - req, - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }), - ) - -const encodeOrg = Schema.encodeSync(Org) - -const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name })) - -const login = () => - new Login({ - code: DeviceCode.make("device-code"), - user: UserCode.make("user-code"), - url: "https://one.example.com/verify", - server: "https://one.example.com", - expiry: Duration.seconds(600), - interval: Duration.seconds(5), - }) - -const deviceTokenClient = (body: unknown, status = 400) => - HttpClient.make((req) => - Effect.succeed( - req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404), - ), - ) - -const poll = (body: unknown, status = 400) => - Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status)))) - -it.live("login normalizes trailing slashes in the provided server URL", () => - Effect.gen(function* () { - const seen: Array = [] - const client = HttpClient.make((req) => - Effect.gen(function* () { - seen.push(`${req.method} ${req.url}`) - - if (req.url === "https://one.example.com/auth/device/code") { - return json(req, { - device_code: "device-code", - user_code: "user-code", - verification_uri_complete: "/device?user_code=user-code", - expires_in: 600, - interval: 5, - }) - } - - return json(req, {}, 404) - }), - ) - - const result = yield* Account.use.login("https://one.example.com/").pipe(Effect.provide(live(client))) - - expect(seen).toEqual(["POST https://one.example.com/auth/device/code"]) - expect(result.server).toBe("https://one.example.com") - expect(result.url).toBe("https://one.example.com/device?user_code=user-code") - }), -) - -it.live("login maps transport failures to account transport errors", () => - Effect.gen(function* () { - const client = HttpClient.make((req) => - Effect.fail( - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ request: req }), - }), - ), - ) - - const error = yield* Effect.flip(Account.use.login("https://one.example.com").pipe(Effect.provide(live(client)))) - - expect(error).toBeInstanceOf(AccountTransportError) - if (error instanceof AccountTransportError) { - expect(error.method).toBe("POST") - expect(error.url).toBe("https://one.example.com/auth/device/code") - } - }), -) - -it.live("orgsByAccount groups orgs per account", () => - Effect.gen(function* () { - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id: AccountID.make("user-1"), - email: "one@example.com", - url: "https://one.example.com", - accessToken: AccessToken.make("at_1"), - refreshToken: RefreshToken.make("rt_1"), - expiry: Date.now() + outsideEagerRefreshWindow, - orgID: Option.none(), - }), - ) - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id: AccountID.make("user-2"), - email: "two@example.com", - url: "https://two.example.com", - accessToken: AccessToken.make("at_2"), - refreshToken: RefreshToken.make("rt_2"), - expiry: Date.now() + outsideEagerRefreshWindow, - orgID: Option.none(), - }), - ) - - const seen: Array = [] - const client = HttpClient.make((req) => - Effect.gen(function* () { - seen.push(`${req.method} ${req.url}`) - - if (req.url === "https://one.example.com/api/orgs") { - return json(req, [org("org-1", "One")]) - } - - if (req.url === "https://two.example.com/api/orgs") { - return json(req, [org("org-2", "Two A"), org("org-3", "Two B")]) - } - - return json(req, [], 404) - }), - ) - - const rows = yield* Account.use.orgsByAccount().pipe(Effect.provide(live(client))) - - expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([ - [AccountID.make("user-1"), [OrgID.make("org-1")]], - [AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]], - ]) - expect(seen).toEqual(["GET https://one.example.com/api/orgs", "GET https://two.example.com/api/orgs"]) - }), -) - -it.live("token refresh persists the new token", () => - Effect.gen(function* () { - const id = AccountID.make("user-1") - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id, - email: "user@example.com", - url: "https://one.example.com", - accessToken: AccessToken.make("at_old"), - refreshToken: RefreshToken.make("rt_old"), - expiry: Date.now() - 1_000, - orgID: Option.none(), - }), - ) - - const client = HttpClient.make((req) => - Effect.succeed( - req.url === "https://one.example.com/auth/device/token" - ? json(req, { - access_token: "at_new", - refresh_token: "rt_new", - expires_in: 60, - }) - : json(req, {}, 404), - ), - ) - - const token = yield* Account.use.token(id).pipe(Effect.provide(live(client))) - - expect(Option.getOrThrow(token)).toBeDefined() - expect(String(Option.getOrThrow(token))).toBe("at_new") - - const row = yield* AccountRepo.use.getRow(id) - const value = Option.getOrThrow(row) - expect(value.access_token).toBe(AccessToken.make("at_new")) - expect(value.refresh_token).toBe(RefreshToken.make("rt_new")) - expect(value.token_expiry).toBeGreaterThan(Date.now()) - }), -) - -it.live("token refreshes before expiry when inside the eager refresh window", () => - Effect.gen(function* () { - const id = AccountID.make("user-1") - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id, - email: "user@example.com", - url: "https://one.example.com", - accessToken: AccessToken.make("at_old"), - refreshToken: RefreshToken.make("rt_old"), - expiry: Date.now() + insideEagerRefreshWindow, - orgID: Option.none(), - }), - ) - - let refreshCalls = 0 - const client = HttpClient.make((req) => - Effect.promise(async () => { - if (req.url === "https://one.example.com/auth/device/token") { - refreshCalls += 1 - return json(req, { - access_token: "at_new", - refresh_token: "rt_new", - expires_in: 60, - }) - } - - return json(req, {}, 404) - }), - ) - - const token = yield* Account.use.token(id).pipe(Effect.provide(live(client))) - - expect(String(Option.getOrThrow(token))).toBe("at_new") - expect(refreshCalls).toBe(1) - - const row = yield* AccountRepo.use.getRow(id) - const value = Option.getOrThrow(row) - expect(value.access_token).toBe(AccessToken.make("at_new")) - expect(value.refresh_token).toBe(RefreshToken.make("rt_new")) - }), -) - -it.live("concurrent config and token requests coalesce token refresh", () => - Effect.gen(function* () { - const id = AccountID.make("user-1") - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id, - email: "user@example.com", - url: "https://one.example.com", - accessToken: AccessToken.make("at_old"), - refreshToken: RefreshToken.make("rt_old"), - expiry: Date.now() - 1_000, - orgID: Option.some(OrgID.make("org-9")), - }), - ) - - let refreshCalls = 0 - const client = HttpClient.make((req) => - Effect.promise(async () => { - if (req.url === "https://one.example.com/auth/device/token") { - refreshCalls += 1 - - if (refreshCalls === 1) { - await new Promise((resolve) => setTimeout(resolve, 25)) - return json(req, { - access_token: "at_new", - refresh_token: "rt_new", - expires_in: 60, - }) - } - - return json( - req, - { - error: "invalid_grant", - error_description: "refresh token already used", - }, - 400, - ) - } - - if (req.url === "https://one.example.com/api/config") { - return json(req, { config: { theme: "light", seats: 5 } }) - } - - return json(req, {}, 404) - }), - ) - - const [cfg, token] = yield* Account.Service.use((s) => - Effect.all([s.config(id, OrgID.make("org-9")), s.token(id)], { concurrency: 2 }), - ).pipe(Effect.provide(live(client))) - - expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 }) - expect(String(Option.getOrThrow(token))).toBe("at_new") - expect(refreshCalls).toBe(1) - - const row = yield* AccountRepo.use.getRow(id) - const value = Option.getOrThrow(row) - expect(value.access_token).toBe(AccessToken.make("at_new")) - expect(value.refresh_token).toBe(RefreshToken.make("rt_new")) - }), -) - -it.live("config sends the selected org header", () => - Effect.gen(function* () { - const id = AccountID.make("user-1") - - yield* AccountRepo.Service.use((r) => - r.persistAccount({ - id, - email: "user@example.com", - url: "https://one.example.com", - accessToken: AccessToken.make("at_1"), - refreshToken: RefreshToken.make("rt_1"), - expiry: Date.now() + outsideEagerRefreshWindow, - orgID: Option.none(), - }), - ) - - const seen: { auth?: string; org?: string } = {} - const client = HttpClient.make((req) => - Effect.gen(function* () { - seen.auth = req.headers.authorization - seen.org = req.headers["x-org-id"] - - if (req.url === "https://one.example.com/api/config") { - return json(req, { config: { theme: "light", seats: 5 } }) - } - - return json(req, {}, 404) - }), - ) - - const cfg = yield* Account.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client))) - - expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 }) - expect(seen).toEqual({ - auth: "Bearer at_1", - org: "org-9", - }) - }), -) - -it.live("poll stores the account and first org on success", () => - Effect.gen(function* () { - const client = HttpClient.make((req) => - Effect.succeed( - req.url === "https://one.example.com/auth/device/token" - ? json(req, { - access_token: "at_1", - refresh_token: "rt_1", - token_type: "Bearer", - expires_in: 60, - }) - : req.url === "https://one.example.com/api/user" - ? json(req, { id: "user-1", email: "user@example.com" }) - : req.url === "https://one.example.com/api/orgs" - ? json(req, [org("org-1", "One")]) - : json(req, {}, 404), - ), - ) - - const res = yield* Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client))) - - expect(res._tag).toBe("PollSuccess") - if (res._tag === "PollSuccess") { - expect(res.email).toBe("user@example.com") - } - - const active = yield* AccountRepo.use.active() - expect(Option.getOrThrow(active)).toEqual( - expect.objectContaining({ - id: "user-1", - email: "user@example.com", - active_org_id: "org-1", - }), - ) - }), -) - -for (const [name, body, expectedTag] of [ - [ - "pending", - { - error: "authorization_pending", - error_description: "The authorization request is still pending", - }, - "PollPending", - ], - [ - "slow", - { - error: "slow_down", - error_description: "Polling too frequently, please slow down", - }, - "PollSlow", - ], - [ - "denied", - { - error: "access_denied", - error_description: "The authorization request was denied", - }, - "PollDenied", - ], - [ - "expired", - { - error: "expired_token", - error_description: "The device code has expired", - }, - "PollExpired", - ], -] as const) { - it.live(`poll returns ${name} for ${body.error}`, () => - Effect.gen(function* () { - const result = yield* poll(body) - expect(result._tag).toBe(expectedTag) - }), - ) -} - -it.live("poll returns poll error for other OAuth errors", () => - Effect.gen(function* () { - const result = yield* poll({ - error: "server_error", - error_description: "An unexpected error occurred", - }) - - expect(result._tag).toBe("PollError") - if (result._tag === "PollError") { - expect(String(result.cause)).toContain("server_error") - } - }), -) diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts index b29ca2b3bae1..5b98c8cff5f5 100644 --- a/packages/opencode/test/cli/error.test.ts +++ b/packages/opencode/test/cli/error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { AccountTransportError } from "../../src/account/schema" +import { AccountV2 } from "@opencode-ai/core/account" import { FormatError } from "../../src/cli/error" import { UI } from "../../src/cli/ui" @@ -52,7 +52,7 @@ describe("cli.error", () => { }) test("formats account transport errors clearly", () => { - const error = new AccountTransportError({ + const error = new AccountV2.AccountTransportError({ method: "POST", url: "https://console.opencode.ai/auth/device/code", }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 85cb78a329c6..6095acd94835 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -10,8 +10,7 @@ import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { InstanceRef } from "../../src/effect/instance-ref" import type { InstanceContext } from "../../src/project/instance-context" import { Auth } from "../../src/auth" -import { Account } from "../../src/account/account" -import { AccessToken, AccountID, OrgID } from "../../src/account/schema" +import { AccountV2 } from "@opencode-ai/core/account" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Env } from "../../src/env" import { @@ -88,7 +87,7 @@ function remoteConfigClient(input: { const configLayer = ( options: { auth?: Layer.Layer - account?: Layer.Layer + account?: Layer.Layer client?: HttpClient.HttpClient } = {}, ) => @@ -553,27 +552,27 @@ it.instance("handles file inclusion with replacement tokens", () => ) const accountTokenIt = configIt({ - account: Layer.mock(Account.Service)({ + account: Layer.mock(AccountV2.Service)({ active: () => Effect.succeed( Option.some({ - id: AccountID.make("account-1"), + id: AccountV2.ID.make("account-1"), email: "user@example.com", url: "https://control.example.com", - active_org_id: OrgID.make("org-1"), + active_org_id: AccountV2.OrgID.make("org-1"), }), ), activeOrg: () => Effect.succeed( Option.some({ account: { - id: AccountID.make("account-1"), + id: AccountV2.ID.make("account-1"), email: "user@example.com", url: "https://control.example.com", - active_org_id: OrgID.make("org-1"), + active_org_id: AccountV2.OrgID.make("org-1"), }, org: { - id: OrgID.make("org-1"), + id: AccountV2.OrgID.make("org-1"), name: "Example Org", }, }), @@ -584,7 +583,7 @@ const accountTokenIt = configIt({ provider: { opencode: { options: { apiKey: "{env:OPENCODE_CONSOLE_TOKEN}" } } }, }), ), - token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))), + token: () => Effect.succeed(Option.some(AccountV2.AccessToken.make("st_test_token"))), }), }) diff --git a/packages/opencode/test/fake/account.ts b/packages/opencode/test/fake/account.ts index aeaa0735bc28..cd138fb6e970 100644 --- a/packages/opencode/test/fake/account.ts +++ b/packages/opencode/test/fake/account.ts @@ -1,7 +1,7 @@ import { Effect, Layer, Option } from "effect" -import { Account } from "../../src/account/account" +import { AccountV2 } from "@opencode-ai/core/account" -export const empty = Layer.mock(Account.Service)({ +export const empty = Layer.mock(AccountV2.Service)({ active: () => Effect.succeed(Option.none()), activeOrg: () => Effect.succeed(Option.none()), }) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index feb070a09cb0..f114b90f2e67 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -1,11 +1,10 @@ import { NodeFileSystem } from "@effect/platform-node" import { beforeEach, describe, expect } from "bun:test" -import { Effect, Exit, Layer, Option } from "effect" +import { Effect, Exit, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema" -import { Account } from "../../src/account/account" -import { AccountRepo } from "../../src/account/repo" +import { AccountV2 } from "@opencode-ai/core/account" +import { AccountStateTable, AccountTable } from "@opencode-ai/core/account/sql" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "@/config/config" @@ -22,7 +21,6 @@ import { testEffect } from "../lib/effect" const env = Layer.mergeAll( Session.defaultLayer, - AccountRepo.defaultLayer, Database.defaultLayer, NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer, @@ -44,7 +42,7 @@ function live(client: HttpClient.HttpClient) { const http = Layer.succeed(HttpClient.HttpClient, client) return ShareNext.layer.pipe( Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(http))), + Layer.provide(AccountV2.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(http), @@ -59,13 +57,12 @@ function wired(client: HttpClient.HttpClient) { EventV2Bridge.defaultLayer, ShareNext.layer, Session.defaultLayer, - AccountRepo.defaultLayer, Database.defaultLayer, NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer, ).pipe( Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(http))), + Layer.provide(AccountV2.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(http), Layer.provide(Provider.defaultLayer), @@ -83,18 +80,28 @@ const share = (id: SessionID) => .pipe(Effect.orDie) }) -const seed = (url: string, org?: string) => - AccountRepo.Service.use((repo) => - repo.persistAccount({ - id: AccountID.make("account-1"), - email: "user@example.com", - url, - accessToken: AccessToken.make("st_test_token"), - refreshToken: RefreshToken.make("rt_test_token"), - expiry: Date.now() + 10 * 60_000, - orgID: org ? Option.some(OrgID.make(org)) : Option.none(), - }), - ) +const seed = (url: string, org?: AccountV2.OrgID) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(AccountTable) + .values({ + id: AccountV2.ID.make("account-1"), + email: "user@example.com", + url, + access_token: AccountV2.AccessToken.make("st_test_token"), + refresh_token: AccountV2.RefreshToken.make("rt_test_token"), + token_expiry: Date.now() + 10 * 60_000, + }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(AccountStateTable) + .values({ id: 1, active_account_id: AccountV2.ID.make("account-1"), active_org_id: org ?? null }) + .onConflictDoUpdate({ target: AccountStateTable.id, set: { active_org_id: org ?? null } }) + .run() + .pipe(Effect.orDie) + }) beforeEach(async () => { await resetDatabase() @@ -137,7 +144,7 @@ describe("ShareNext", () => { it.live("request uses org share API with auth headers when account is active", () => provideTmpdirInstance(() => Effect.gen(function* () { - yield* seed("https://control.example.com", "org-1") + yield* seed("https://control.example.com", AccountV2.OrgID.make("org-1")) const req = yield* ShareNext.use.request().pipe(Effect.provide(live(none)))