mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
refactor(core): migrate accounts and load file agents
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof ID>
|
||||
@@ -99,3 +109,441 @@ export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
|
||||
|
||||
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
|
||||
export type PollResult = Schema.Schema.Type<typeof PollResult>
|
||||
|
||||
export type AccountOrgs = {
|
||||
account: Info
|
||||
orgs: readonly Org[]
|
||||
}
|
||||
|
||||
export type ActiveOrg = {
|
||||
account: Info
|
||||
org: Org
|
||||
}
|
||||
|
||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
config: Schema.Record(Schema.String, Schema.Json),
|
||||
}) {}
|
||||
|
||||
const DurationFromSeconds = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Duration, {
|
||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||
}),
|
||||
)
|
||||
|
||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
device_code: DeviceCode,
|
||||
user_code: UserCode,
|
||||
verification_uri_complete: Schema.String,
|
||||
expires_in: DurationFromSeconds,
|
||||
interval: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
token_type: Schema.Literal("Bearer"),
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
error: Schema.String,
|
||||
error_description: Schema.String,
|
||||
}) {
|
||||
toPollResult(): PollResult {
|
||||
if (this.error === "authorization_pending") return new PollPending()
|
||||
if (this.error === "slow_down") return new PollSlow()
|
||||
if (this.error === "expired_token") return new PollExpired()
|
||||
if (this.error === "access_denied") return new PollDenied()
|
||||
return new PollError({ cause: this.error })
|
||||
}
|
||||
}
|
||||
|
||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
|
||||
class User extends Schema.Class<User>("User")({
|
||||
id: ID,
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
|
||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
grant_type: Schema.String,
|
||||
device_code: DeviceCode,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
grant_type: Schema.String,
|
||||
refresh_token: RefreshToken,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
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") =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountError, R> =>
|
||||
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<Option.Option<Info>, AccountError>
|
||||
readonly activeOrg: () => Effect.Effect<Option.Option<ActiveOrg>, AccountError>
|
||||
readonly list: () => Effect.Effect<Info[], AccountError>
|
||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||
readonly remove: (accountID: ID) => Effect.Effect<void, AccountError>
|
||||
readonly use: (accountID: ID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||
readonly orgs: (accountID: ID) => Effect.Effect<readonly Org[], AccountError>
|
||||
readonly config: (accountID: ID, orgID: OrgID) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
||||
readonly token: (accountID: ID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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 = <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
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<OrgID>) =>
|
||||
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<OrgID>) =>
|
||||
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<number>
|
||||
}) {
|
||||
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<OrgID>
|
||||
}) {
|
||||
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 = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => httpOk.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => http.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const 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<ID, AccessToken, AccountError>({
|
||||
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<ActiveOrg>()
|
||||
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<ActiveOrg>()
|
||||
})
|
||||
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))
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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<typeof item> => item !== undefined)),
|
||||
),
|
||||
)).flat()
|
||||
|
||||
yield* agent.update((editor) => {
|
||||
const permissions = new Map<AgentV2.ID, PermissionV2.Ruleset>()
|
||||
|
||||
for (const file of files) {
|
||||
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
|
||||
})
|
||||
|
||||
if (rules.length) permissions.set(agentID, [...(permissions.get(agentID) ?? []), ...rules])
|
||||
}
|
||||
|
||||
for (const file of documents) {
|
||||
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
|
||||
}
|
||||
|
||||
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 (item.permissions !== undefined) {
|
||||
permissions.set(agentID, [...(permissions.get(agentID) ?? []), ...item.permissions])
|
||||
}
|
||||
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) ?? []))
|
||||
|
||||
@@ -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<void>()
|
||||
|
||||
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),
|
||||
),
|
||||
})
|
||||
|
||||
+479
-277
@@ -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)))
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
|
||||
function context(
|
||||
records: { provider: ProviderV2.Info; models: Map<ModelV2.ID, ModelV2.Info> }[],
|
||||
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<ModelV2.ID, ModelV2.Info>() })
|
||||
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 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))
|
||||
|
||||
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 it = testEffect(Layer.mergeAll(database, truncate))
|
||||
|
||||
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)
|
||||
const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1))
|
||||
const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10))
|
||||
|
||||
yield* Effect.yieldNow
|
||||
const live = (client: HttpClient.HttpClient) =>
|
||||
AccountV2.layer.pipe(Layer.provide(database), Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
|
||||
|
||||
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")
|
||||
const persist = (input: {
|
||||
id: AccountV2.ID
|
||||
email: string
|
||||
url: string
|
||||
accessToken: AccountV2.AccessToken
|
||||
refreshToken: AccountV2.RefreshToken
|
||||
expiry: number
|
||||
orgID: Option.Option<AccountV2.OrgID>
|
||||
}) =>
|
||||
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)
|
||||
})
|
||||
|
||||
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 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 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))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
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" }),
|
||||
})
|
||||
|
||||
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))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
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<ModelV2.ID, ModelV2.Info>(),
|
||||
},
|
||||
]
|
||||
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<ModelV2.Info>()),
|
||||
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
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",
|
||||
},
|
||||
])
|
||||
|
||||
updates.length = 0
|
||||
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* 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",
|
||||
},
|
||||
])
|
||||
|
||||
updates.length = 0
|
||||
yield* accounts.remove(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: "second-key",
|
||||
},
|
||||
])
|
||||
|
||||
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))),
|
||||
),
|
||||
),
|
||||
)
|
||||
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<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
|
||||
HttpClientResponse.fromWeb(
|
||||
req,
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
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<string> = []
|
||||
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* 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 }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const error = yield* Effect.flip(AccountV2.use.login("https://one.example.com").pipe(Effect.provide(live(client))))
|
||||
|
||||
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")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
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<string> = []
|
||||
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")
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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")),
|
||||
})
|
||||
|
||||
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* AccountV2.Service.use((s) =>
|
||||
Effect.all([s.config(id, AccountV2.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 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")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>("RemoteConfig")({
|
||||
config: Schema.Record(Schema.String, Schema.Json),
|
||||
}) {}
|
||||
|
||||
const DurationFromSeconds = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Duration, {
|
||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||
}),
|
||||
)
|
||||
|
||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
device_code: DeviceCode,
|
||||
user_code: UserCode,
|
||||
verification_uri_complete: Schema.String,
|
||||
expires_in: DurationFromSeconds,
|
||||
interval: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
token_type: Schema.Literal("Bearer"),
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
error: Schema.String,
|
||||
error_description: Schema.String,
|
||||
}) {
|
||||
toPollResult(): PollResult {
|
||||
if (this.error === "authorization_pending") return new PollPending()
|
||||
if (this.error === "slow_down") return new PollSlow()
|
||||
if (this.error === "expired_token") return new PollExpired()
|
||||
if (this.error === "access_denied") return new PollDenied()
|
||||
return new PollError({ cause: this.error })
|
||||
}
|
||||
}
|
||||
|
||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
|
||||
class User extends Schema.Class<User>("User")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
|
||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
grant_type: Schema.String,
|
||||
device_code: DeviceCode,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
grant_type: Schema.String,
|
||||
refresh_token: RefreshToken,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
const clientId = "opencode-cli"
|
||||
const 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") =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountError, R> =>
|
||||
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<Option.Option<Info>, AccountError>
|
||||
readonly activeOrg: () => Effect.Effect<Option.Option<ActiveOrg>, AccountError>
|
||||
readonly list: () => Effect.Effect<Info[], AccountError>
|
||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
||||
readonly config: (
|
||||
accountID: AccountID,
|
||||
orgID: OrgID,
|
||||
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
||||
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpClient> = 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 = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => httpOk.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => http.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const 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<AccountID, AccessToken, AccountError>({
|
||||
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<ActiveOrg>()
|
||||
|
||||
const account = activeAccount.value
|
||||
if (!account.active_org_id) return Option.none<ActiveOrg>()
|
||||
|
||||
const accountOrgs = yield* orgs(account.id)
|
||||
const org = accountOrgs.find((item) => item.id === account.active_org_id)
|
||||
if (!org) return Option.none<ActiveOrg>()
|
||||
|
||||
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<OrgID>()
|
||||
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const expiry = now + Duration.toMillis(parsed.expires_in)
|
||||
const refreshToken = parsed.refresh_token
|
||||
|
||||
yield* repo.persistAccount({
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
url: input.server,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiry,
|
||||
orgID: firstOrgID,
|
||||
})
|
||||
|
||||
return new PollSuccess({ email: account.email })
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
active: repo.active,
|
||||
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"
|
||||
@@ -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<Option.Option<Info>, AccountRepoError>
|
||||
readonly list: () => Effect.Effect<Info[], AccountRepoError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
|
||||
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
|
||||
readonly persistToken: (input: {
|
||||
accountID: AccountID
|
||||
accessToken: AccessToken
|
||||
refreshToken: RefreshToken
|
||||
expiry: Option.Option<number>
|
||||
}) => Effect.Effect<void, AccountRepoError>
|
||||
readonly persistAccount: (input: {
|
||||
id: AccountID
|
||||
email: string
|
||||
url: string
|
||||
accessToken: AccessToken
|
||||
refreshToken: RefreshToken
|
||||
expiry: number
|
||||
orgID: Option.Option<OrgID>
|
||||
}) => Effect.Effect<void, AccountRepoError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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 = <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
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<OrgID>) => {
|
||||
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<OrgID>) =>
|
||||
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"
|
||||
@@ -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<typeof AccountID>
|
||||
|
||||
export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
|
||||
export type OrgID = Schema.Schema.Type<typeof OrgID>
|
||||
|
||||
export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
|
||||
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
|
||||
|
||||
export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
|
||||
export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
|
||||
|
||||
export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode"))
|
||||
export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
|
||||
|
||||
export const UserCode = Schema.String.pipe(Schema.brand("UserCode"))
|
||||
export type UserCode = Schema.Schema.Type<typeof UserCode>
|
||||
|
||||
export class Info extends Schema.Class<Info>("Account")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
url: Schema.String,
|
||||
active_org_id: Schema.NullOr(OrgID),
|
||||
}) {}
|
||||
|
||||
export class Org extends Schema.Class<Org>("Org")({
|
||||
id: OrgID,
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export class AccountTransportError extends Schema.TaggedErrorClass<AccountTransportError>()("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>("Login")({
|
||||
code: DeviceCode,
|
||||
user: UserCode,
|
||||
url: Schema.String,
|
||||
server: Schema.String,
|
||||
expiry: Schema.Duration,
|
||||
interval: Schema.Duration,
|
||||
}) {}
|
||||
|
||||
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
|
||||
|
||||
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
|
||||
|
||||
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
|
||||
|
||||
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
|
||||
|
||||
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
|
||||
export type PollResult = Schema.Schema.Type<typeof PollResult>
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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<PollResult, AccountError> =>
|
||||
const poll = (wait: Duration.Duration): Effect.Effect<AccountV2.PollResult, AccountV2.AccountError> =>
|
||||
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")
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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<A, R>(self: Effect.Effect<A, Worktree.Error, R>) {
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
@@ -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<typeof HttpClientResponse.fromWeb>[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<string> = []
|
||||
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<string> = []
|
||||
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")
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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<Auth.Service>
|
||||
account?: Layer.Layer<Account.Service>
|
||||
account?: Layer.Layer<AccountV2.Service>
|
||||
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"))),
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user