diff --git a/README.md b/README.md index 6c68c67..8500bb2 100644 --- a/README.md +++ b/README.md @@ -127,13 +127,23 @@ Run the wizard locally against a project with $ npm run wizard -- --cwd ``` -This runs the development CLI in `src/bin/cli.ts`, -which simply calls the wizard with the arguments given. +Run any other Seam CLI command with + +``` +$ npm run seam -- login +$ npm run seam -- devices list +``` + +Both run the Seam CLI from `devDependencies` through `src/bin/cli.ts`, +with its `@seamapi/wizard` import pointed at this checkout, +so the wizard runs as `seam wizard` does and with the adapter the CLI +gives it. That file exists for local development only: it is excluded from the build and from the published package. -`--cwd` is an option of that CLI, which becomes the wizard's `cwd`. -Every other argument is forwarded to the wizard untouched. +`--cwd ` is an option of that entry, for running against a scratch +project instead of this repository. +Every other argument is passed to the CLI untouched. ### Source layout diff --git a/package-lock.json b/package-lock.json index 85fff0d..c2a1ca1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "open": "^11.0.0" }, "devDependencies": { + "@seamapi/cli": "^0.23.0", "@types/minimist": "^1.2.5", "@types/node": "^24.10.9", "@types/react": "^19.2.17", @@ -293,6 +294,36 @@ "node": ">=18" } }, + "node_modules/@clack/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", + "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", + "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.3", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, "node_modules/@emnapi/core": { "version": "2.0.0-alpha.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", @@ -1030,6 +1061,19 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1474,6 +1518,122 @@ "dev": true, "license": "MIT" }, + "node_modules/@seamapi/blueprint": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@seamapi/blueprint/-/blueprint-1.5.0.tgz", + "integrity": "sha512-UhLlcfgxUbnxoi4GoL45Kwvz3d9kfu64enYmdGYHWDbya1lpvBF5E9NVP52OOR9OjSZpQFyIg9mUV1rjgjTyUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "change-case": "^5.4.4", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22.11.0", + "npm": ">=10.0.0" + } + }, + "node_modules/@seamapi/blueprint/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@seamapi/cli": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@seamapi/cli/-/cli-0.23.0.tgz", + "integrity": "sha512-G3fHna9ejmGgv83BtoqR7o9DnUnAdTD1D+CBZ2i3OKvtcvvvv8hA066ehkVIn73CPL1i4a7m5ZOKInSXBS9yzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "^1.7.0", + "@seamapi/blueprint": "1.5.0", + "@seamapi/http": "2.2.0", + "@seamapi/wizard": "0.7.0", + "chalk": "^6.0.0", + "command-line-usage": "^7.0.4", + "configstore": "^8.0.0", + "env-paths": "^4.0.0", + "minimist": "^1.2.8", + "nanospinner": "^1.2.2", + "open": "^11.0.0", + "tar": "^7.5.22" + }, + "bin": { + "seam": "bin/cli.js" + }, + "engines": { + "node": ">=22.11.0", + "npm": ">=10.0.0" + } + }, + "node_modules/@seamapi/cli/node_modules/chalk": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@seamapi/http": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@seamapi/http/-/http-2.2.0.tgz", + "integrity": "sha512-Uc4xGaR7Yi5nKLvYSn9YoZxrXuzwJ6AXi28RZGYSQ15VEzIYf9AdsakVJrLr1cznky0OHglgZKPzCK66h5JhyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@seamapi/url-search-params-serializer": "^3.0.0", + "axios": "^1.9.0", + "axios-retry": "^4.4.2" + }, + "engines": { + "node": ">=22.11.0", + "npm": ">=10.9.4" + } + }, + "node_modules/@seamapi/url-search-params-serializer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@seamapi/url-search-params-serializer/-/url-search-params-serializer-3.0.2.tgz", + "integrity": "sha512-0uDzO3U235FuPRQIi2RXoadfdTzwgLqqkwvnHwyrR4fu+ItmJr84fPrZ3AbGYyEf0FFFMs1fVxDPYUSoJpxIkA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.11.0", + "npm": ">=10.0.0" + } + }, + "node_modules/@seamapi/wizard": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@seamapi/wizard/-/wizard-0.7.0.tgz", + "integrity": "sha512-oYNzvdZyDn39OPF4j77BdbJTcFc0vIno4TlSqKxpNGv278/d+eyiTBiXla/n52koxMJMuLSTEFIiBuXcomom0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.216", + "ink-select-input": "^6.2.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", + "minimist": "^1.2.8", + "open": "^11.0.0" + }, + "engines": { + "node": ">=22.11.0", + "npm": ">=10.0.0" + }, + "peerDependencies": { + "ink": "^7.1.1", + "react": "^19.2.8" + } + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -2019,9 +2179,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2036,9 +2193,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2053,9 +2207,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2070,9 +2221,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2087,9 +2235,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2104,9 +2249,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2121,9 +2263,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2138,9 +2277,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2155,9 +2291,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2172,9 +2305,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2471,6 +2601,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -2570,6 +2713,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/array-back": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz", + "integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -2772,6 +2925,24 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, "node_modules/auto-bind": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", @@ -2800,6 +2971,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "is-retry-allowed": "^2.2.0" + }, + "peerDependencies": { + "axios": "0.x || 1.x" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2993,6 +3190,29 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3031,6 +3251,16 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/cli-boxes": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", @@ -3118,6 +3348,35 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/command-line-usage": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.4.tgz", + "integrity": "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.1", + "typical": "^7.3.0" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/commander": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", @@ -3135,6 +3394,26 @@ "dev": true, "license": "MIT" }, + "node_modules/configstore": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-8.0.0.tgz", + "integrity": "sha512-U51WPZg+o6FDFhBCV6yYFEspGMCoHvCgJSGFw9AwsW2u75gYBPoDlYca5XfDs4TdMUu3/y0R6FFdvvgyH31mKQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "atomically": "^2.1.0", + "dot-prop": "^10.1.0", + "graceful-fs": "^4.2.11", + "is-safe-filename": "^0.1.0", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -3432,6 +3711,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3488,6 +3777,22 @@ "node": ">=0.10.0" } }, + "node_modules/dot-prop": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.2.0.tgz", + "integrity": "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3533,6 +3838,22 @@ "node": ">=10.13.0" } }, + "node_modules/env-paths": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", + "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-safe-filename": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -4576,6 +4897,23 @@ "license": "Unlicense", "peer": true }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", @@ -4593,6 +4931,16 @@ "license": "BSD-3-Clause", "peer": true }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -4704,6 +5052,27 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -4720,6 +5089,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5135,6 +5544,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", @@ -5857,6 +6280,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-safe-filename": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz", + "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-set": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", @@ -6724,6 +7173,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6763,6 +7235,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanospinner": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/nanospinner/-/nanospinner-1.2.2.tgz", + "integrity": "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -7536,6 +8018,16 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8167,6 +8659,13 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -8464,6 +8963,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -8490,6 +9006,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" + }, + "engines": { + "node": ">=12.17" + } + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -8516,6 +9046,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/terminal-size": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", @@ -8964,6 +9511,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -9265,6 +9822,13 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9427,6 +9991,16 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrapjs": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", + "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/wrap-ansi": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", @@ -9500,6 +10074,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 19c85e1..7f709d7 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,9 @@ "postlint": "prettier --check --ignore-path .gitignore .", "prepack": "tsx ./prepack.ts", "postversion": "git push --follow-tags", - "wizard": "tsx src/bin/cli.ts", - "inspect": "tsx --inspect src/bin/cli.ts", + "seam": "tsx src/bin/cli.ts", + "wizard": "tsx src/bin/cli.ts wizard", + "inspect": "tsx --inspect src/bin/cli.ts wizard", "format": "prettier --write --ignore-path .gitignore .", "preformat": "eslint --fix .", "report": "vitest run --coverage" @@ -88,6 +89,7 @@ "open": "^11.0.0" }, "devDependencies": { + "@seamapi/cli": "^0.23.0", "@types/minimist": "^1.2.5", "@types/node": "^24.10.9", "@types/react": "^19.2.17", diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 4adb58d..704ae06 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -1,24 +1,28 @@ #!/usr/bin/env node -// This CLI exists for local development of the wizard only: -// it is not compiled or published, and the package exposes no bin. -// Consumers, e.g., the Seam CLI, mount the wizard with the default export. -// Run it with 'npm run wizard'. +// This entry exists for local development of the wizard only: it is not +// compiled or published, and the package exposes no bin. It runs the Seam +// CLI from devDependencies with its '@seamapi/wizard' import pointed at this +// checkout, so 'npm run seam' is the CLI with this wizard mounted in it. -import wizard from 'lib/wizard.js' +import { registerHooks } from 'node:module' -// The wizard sets up whichever project it is run in, which here is this -// repository. '--cwd ' points it at a scratch project instead. It is -// consumed here rather than forwarded, since it is an option of the wizard -// rather than one of its arguments. Every other argument is passed through -// untouched, so 'npm run wizard -- --help' reaches the wizard as '--help'. +const wizardEntry = new URL('../index.ts', import.meta.url).href + +registerHooks({ + resolve: (specifier, context, next) => + specifier === '@seamapi/wizard' + ? next(wizardEntry, context) + : next(specifier, context), +}) + +// '--cwd ' is an option of this entry, for running against a scratch +// project instead of this repository. Every other argument is passed through. const argv = process.argv.slice(2) const cwdIndex = argv.indexOf('--cwd') const cwd = cwdIndex === -1 ? undefined : argv.splice(cwdIndex, 2)[1] +if (cwd != null) process.chdir(cwd) -wizard({ argv, ...(cwd == null ? {} : { cwd }) }).catch((err: unknown) => { - const { message, stack } = err instanceof Error ? err : new Error(String(err)) - // eslint-disable-next-line no-console - console.error(`Wizard Error: ${message}\n${stack ?? ''}`) - process.exitCode = 1 -}) +process.argv = [process.argv[0] ?? 'node', 'seam', ...argv] + +await import('@seamapi/cli/cli') diff --git a/src/lib/app.test.tsx b/src/lib/app.test.tsx index 0697ecf..2f51651 100644 --- a/src/lib/app.test.tsx +++ b/src/lib/app.test.tsx @@ -1,6 +1,7 @@ import { render } from 'ink-testing-library' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { createMemoryAdapter, resetAdapter, setAdapter } from './adapter.js' import { App } from './app.js' // A project root that does not exist, with no key in the environment, keeps the @@ -9,7 +10,12 @@ import { App } from './app.js' // be exercised headlessly, so this covers the mount and the first frame only. const NONEXISTENT_ROOT = '/nonexistent/seam-wizard-test-project' +beforeEach(() => { + setAdapter(createMemoryAdapter()) +}) + afterEach(() => { + resetAdapter() vi.unstubAllEnvs() }) diff --git a/src/lib/app.tsx b/src/lib/app.tsx index 9962243..8ae26ba 100644 --- a/src/lib/app.tsx +++ b/src/lib/app.tsx @@ -10,13 +10,18 @@ import { useState, } from 'react' +import { getAuth } from './adapter.js' import { CheckboxList } from './components/checkbox-list.js' import { analyzeProject, type ProjectAnalysis, } from './steps/analyze-project.js' import { + type CliKeyResult, + type ExistingKeyResult, + findVerifiedCliKey, findVerifiedExistingKey, + saveVerifiedKey, verifyAndSaveKey, } from './steps/authenticate.js' import { @@ -24,10 +29,14 @@ import { COMMON_BLOCKS, composeGoal, CORE_BLOCKS, - type OnboardingRecord, - writeOnboardingRecord, } from './steps/build-plan.js' import { connectViaWeb } from './steps/connect-web.js' +import { + compareConnection, + type ConnectionChange, + describeChange, + saveConnection, +} from './steps/connection.js' import { detectProject, installSeamSdkCommand, @@ -40,13 +49,24 @@ import { SEAM_PLUGIN_NPX_COMMAND, } from './steps/install-seam-plugin.js' import { type IntegrateEvent, runIntegration } from './steps/integrate.js' -import { findExistingApiKey } from './util/env-file.js' +import { + type ProjectPlan, + readPreferredSdk, + readProjectRecord, + recordPlan, + recordResult, + writePreferredSdk, +} from './store/index.js' +import { + ensureProjectEnvConventions, + findExistingApiKey, +} from './util/env-file.js' import { runInstall } from './util/run-install.js' import { ApiKeyError, exchangeWizardInferenceToken, + getInferenceBaseUrl, looksLikeSeamApiKey, - SEAM_INFERENCE_BASE_URL, type SeamWorkspace, type WizardInferenceSession, } from './util/seam-api.js' @@ -65,6 +85,7 @@ type Phase = | { t: 'browser' } | { t: 'paste' } | { t: 'verify-paste'; api_key: string } + | { t: 'drift'; changes: ConnectionChange[] } | { t: 'sdk' } | { t: 'install-sdk' } | { t: 'install-plugin' } @@ -106,6 +127,9 @@ export function App({ url: null, received: false, }) + const [cliKey, setCliKey] = useState(null) + const [projectKey, setProjectKey] = useState(null) + const [preferredSdk, setPreferredSdk] = useState(null) const [pasteValue, setPasteValue] = useState('') const [pasteError, setPasteError] = useState(null) const [installLines, setInstallLines] = useState([]) @@ -116,9 +140,7 @@ export function App({ const [mode, setMode] = useState(null) const [selections, setSelections] = useState([]) - // The plan record (written to .seam/onboarding.json before the agent runs); - // the done handler rewrites it with the run result. - const planRef = useRef | null>(null) + const planRef = useRef(null) // Kept in sync with `messages` so the exit handler can hand the full // transcript to index.tsx to reprint after leaving the alt screen. @@ -135,8 +157,6 @@ export function App({ setPhase({ t: 'done' }) } - // Compose the goal from the chosen mode + building blocks + optional note, - // persist the plan to .seam/onboarding.json, then hand off to the agent. const startIntegration = (noteInput: string): void => { const note = noteInput.trim().length > 0 ? noteInput.trim() : null const effectiveMode: BuildMode = mode ?? 'full_api' @@ -148,8 +168,7 @@ export function App({ note, framework: analysis?.signals.framework ?? null, }) - const record: Omit = { - created_at: new Date().toISOString(), + const plan: ProjectPlan = { mode: effectiveMode, selections: effectiveSelections, note, @@ -163,16 +182,9 @@ export function App({ recommendation_source: analysis?.recommendation.source ?? 'heuristic', }, } - planRef.current = record - try { - writeOnboardingRecord(root, record) - addMessage({ - tone: 'info', - text: 'Saved your plan to .seam/onboarding.json', - }) - } catch { - // Writing the record is best-effort; proceed with the integration. - } + planRef.current = plan + recordPlan(root, plan).catch(() => {}) + addMessage({ tone: 'info', text: 'Saved your plan' }) setPhase({ t: 'integrate', goal }) } @@ -189,20 +201,11 @@ export function App({ ]) } else { setAgentLines([]) - if (planRef.current != null) { - try { - writeOnboardingRecord(root, { - ...planRef.current, - result: { - ok: event.ok, - files_summary: event.summary.trim().slice(0, 4000), - cost_usd: event.cost_usd, - }, - }) - } catch { - // Recording the result is best-effort; never block finishing. - } - } + recordResult(root, { + ok: event.ok, + files_summary: event.summary.trim().slice(0, 4000), + cost_usd: event.cost_usd, + }).catch(() => {}) addMessage( event.ok ? { @@ -228,6 +231,55 @@ export function App({ } } + const useCliKey = (found: CliKeyResult): void => { + try { + saveVerifiedKey(root, found.api_key) + addMessage({ + tone: 'ok', + text: `Using your Seam CLI login · workspace ${found.workspace.name} · saved to .env`, + }) + } catch { + addMessage({ + tone: 'warn', + text: "Couldn't write .env — set SEAM_API_KEY there yourself.", + }) + } + settleOn(found.workspace, found.api_key, 'cli', null) + } + + const useProjectKey = (found: ExistingKeyResult): void => { + try { + if (found.source === 'environment') { + saveVerifiedKey(root, found.api_key) + } else { + ensureProjectEnvConventions(root) + } + } catch { + // The project keeps working with the key it already has. + } + addMessage({ + tone: 'ok', + text: `Using the key from ${found.source} · workspace ${found.workspace.name}`, + }) + settleOn(found.workspace, found.api_key, 'project', found.source) + } + + const settleOn = ( + settledWorkspace: SeamWorkspace, + apiKey: string, + source: 'project' | 'cli' | 'browser' | 'pasted', + location: string | null, + ): void => { + setWorkspace(settledWorkspace) + saveConnection(root, { + workspace: settledWorkspace, + api_key: apiKey, + source, + location, + }).catch(() => {}) + advanceAfterAuth() + } + // Once a workspace is known, choose SDK (or skip if detected) then install. const advanceAfterAuth = (): void => { const detected = projectRef.current.detected_sdk @@ -240,23 +292,54 @@ export function App({ } } - // init: reuse an existing key if valid, else ask how to connect. useEffect(() => { if (phase.t !== 'init') return let cancelled = false const run = async (): Promise => { + const previous = await readProjectRecord(root) const existing = await findVerifiedExistingKey(root) if (cancelled) return - if (existing != null) { - setWorkspace(existing.workspace) + setProjectKey(existing) + + const auth = getAuth() + const cliResult = + existing?.api_key === auth.apiKey ? null : await findVerifiedCliKey() + if (cancelled) return + setCliKey(cliResult) + + const recorded = previous?.connection ?? null + + if (recorded != null && existing != null) { + const changes = compareConnection(recorded, { + endpoint: auth.endpoint, + workspace: existing.workspace, + api_key: existing.api_key, + }) + if (changes.length === 0) { + addMessage({ + tone: 'ok', + text: `Set up here before · workspace ${existing.workspace.name} · key from ${existing.source}`, + }) + settleOn( + existing.workspace, + existing.api_key, + 'project', + existing.source, + ) + return + } + setPhase({ t: 'drift', changes }) + return + } + + if (recorded != null) { addMessage({ - tone: 'ok', - text: `Using existing key from ${existing.source} · workspace ${existing.workspace.name}`, + tone: 'warn', + text: `Set up here on ${formatDate(previous?.created_at ?? '')}, but this project has no SEAM_API_KEY the wizard can verify now.`, }) - advanceAfterAuth() - } else { - setPhase({ t: 'method' }) } + + setPhase({ t: 'method' }) } run().catch((error: unknown) => { if (cancelled) return @@ -499,7 +582,7 @@ export function App({ project: projectRef.current, onboarding: currentSession.onboarding, inference: { - base_url: SEAM_INFERENCE_BASE_URL, + base_url: getInferenceBaseUrl(), token: currentSession.token, }, }) @@ -565,7 +648,7 @@ export function App({ workspace_name: workspace?.name ?? 'your workspace', goal, inference: { - base_url: SEAM_INFERENCE_BASE_URL, + base_url: getInferenceBaseUrl(), token: session.token, }, framework: analysis?.signals.framework ?? null, @@ -600,6 +683,18 @@ export function App({ return () => controller.abort() }, [phase.t]) + useEffect(() => { + let cancelled = false + readPreferredSdk() + .then((sdk) => { + if (!cancelled) setPreferredSdk(sdk) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, []) + // keep the full-screen layout sized to the terminal useEffect(() => { if (stdout == null) return @@ -651,6 +746,19 @@ export function App({ ) + function onConnectSelected(value: string): void { + if (value === 'project' && projectKey != null) { + useProjectKey(projectKey) + } else if (value === 'cli' && cliKey != null) { + useCliKey(cliKey) + } else if (value === 'paste') { + attemptRef.current = 0 + setPhase({ t: 'paste' }) + } else { + setPhase({ t: 'browser' }) + } + } + function renderActive(): ReactElement | null { switch (phase.t) { case 'init': @@ -659,24 +767,9 @@ export function App({ return ( { - if (item.value === 'paste') { - attemptRef.current = 0 - setPhase({ t: 'paste' }) - } else { - setPhase({ t: 'browser' }) - } + onConnectSelected(item.value) }} /> @@ -722,24 +815,71 @@ export function App({ ) case 'verify-paste': return - case 'sdk': + case 'drift': return ( - + + {phase.changes.map((change) => ( + + {` ${describeChange(change)}`} + + ))} { + if (item.value === 'project' && projectKey != null) { + useProjectKey(projectKey) + } else if (item.value === 'again') { + setPhase({ t: 'method' }) + } else { + addMessage({ + tone: 'plain', + text: 'Nothing changed. Run seam wizard again once your environment is how you want it.', + }) + setPhase({ t: 'done' }) + } + }} + /> + + ) + case 'sdk': { + const javascriptItem = { + label: 'JavaScript / TypeScript', + value: 'javascript', + } + const pythonItem = { label: 'Python', value: 'python' } + return ( + + { const chosen: Sdk = item.value === 'python' ? 'python' : 'javascript' setSdk(chosen) + writePreferredSdk(chosen).catch(() => {}) addMessage({ tone: 'info', text: `SDK: ${chosen}` }) setPhase({ t: 'install-sdk' }) }} /> ) + } case 'install-sdk': return ( @@ -880,6 +1020,51 @@ export function App({ } } +function formatDate(timestamp: string): string { + const date = new Date(timestamp) + return Number.isNaN(date.getTime()) + ? timestamp + : date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +function connectItems( + projectKey: ExistingKeyResult | null, + cliKey: CliKeyResult | null, +): Array<{ label: string; value: string }> { + return [ + ...(projectKey != null + ? [{ label: projectKeyLabel(projectKey, 'Use'), value: 'project' }] + : []), + ...(cliKey != null + ? [ + { + label: `Use your Seam CLI login (workspace ${cliKey.workspace.name})`, + value: 'cli', + }, + ] + : []), + { + label: 'Continue in your browser (create a key in the Console)', + value: 'browser', + }, + { label: 'Paste an API key (if you already have one)', value: 'paste' }, + ] +} + +function projectKeyLabel( + projectKey: ExistingKeyResult | null, + verb: string, +): string { + if (projectKey == null) return `${verb} what is in your environment` + return `${verb} the SEAM_API_KEY in ${projectKey.source} (workspace ${projectKey.workspace.name})` +} + +// Said only of what the login actually is: a token the wizard cannot hand to +// a project, or one it could not verify. function pushNextSteps( addMessage: (message: Msg) => void, sdk: Sdk | null, @@ -888,7 +1073,7 @@ function pushNextSteps( const envHint = sdk === 'python' ? "Make sure SEAM_API_KEY is exported (it's in .env)." - : 'Add .env to .gitignore — it holds your API key.' + : 'Your key is in .env (git ignored); .env.example tells the rest of your team what to set.' addMessage({ tone: 'plain', text: '' }) addMessage({ tone: 'ok', text: `You're set up in ${workspaceName}` }) addMessage({ tone: 'plain', text: 'Next steps:' }) diff --git a/src/lib/steps/authenticate.ts b/src/lib/steps/authenticate.ts index 9b08aa5..3f70bde 100644 --- a/src/lib/steps/authenticate.ts +++ b/src/lib/steps/authenticate.ts @@ -1,6 +1,5 @@ -import { join } from 'node:path' - -import { findExistingApiKey, upsertEnvVar } from 'lib/util/env-file.js' +import { getAuth } from 'lib/adapter.js' +import { findExistingApiKey, saveProjectApiKey } from 'lib/util/env-file.js' import { getWorkspaceForApiKey, type SeamWorkspace } from 'lib/util/seam-api.js' export interface AuthResult { @@ -9,9 +8,15 @@ export interface AuthResult { export interface ExistingKeyResult { workspace: SeamWorkspace + api_key: string source: string } +export interface CliKeyResult { + workspace: SeamWorkspace + api_key: string +} + // Pure auth logic (no UI). The Ink app drives the prompts and renders progress. // The wizard never handles credentials — keys are created in the browser or // pasted, never minted from a password here. @@ -25,19 +30,34 @@ export async function findVerifiedExistingKey( if (existing == null) return null try { const workspace = await getWorkspaceForApiKey(existing.api_key) - return { workspace, source: existing.source } + return { workspace, api_key: existing.api_key, source: existing.source } + } catch { + return null + } +} + +export async function findVerifiedCliKey(): Promise { + const { apiKey } = getAuth() + if (apiKey == null) return null + try { + const workspace = await getWorkspaceForApiKey(apiKey) + return { workspace, api_key: apiKey } } catch { return null } } -// Verify a pasted key and save it to .env. Throws (ApiKeyError) if invalid. +// Verify a pasted key and save it to the project. Throws (ApiKeyError) if invalid. export async function verifyAndSaveKey( root: string, apiKey: string, ): Promise { const trimmed = apiKey.trim() const workspace = await getWorkspaceForApiKey(trimmed) - upsertEnvVar(join(root, '.env'), 'SEAM_API_KEY', trimmed) + saveProjectApiKey(root, trimmed) return { workspace } } + +export function saveVerifiedKey(root: string, apiKey: string): void { + saveProjectApiKey(root, apiKey) +} diff --git a/src/lib/steps/build-plan.test.ts b/src/lib/steps/build-plan.test.ts index 139b7f5..781347d 100644 --- a/src/lib/steps/build-plan.test.ts +++ b/src/lib/steps/build-plan.test.ts @@ -1,25 +1,6 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { expect, test } from 'vitest' -import { afterEach, beforeEach, expect, test } from 'vitest' - -import { - blockById, - composeGoal, - type OnboardingRecord, - writeOnboardingRecord, -} from './build-plan.js' - -let dir = '' - -beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'seam-wizard-')) -}) - -afterEach(() => { - rmSync(dir, { recursive: true, force: true }) -}) +import { blockById, composeGoal } from './build-plan.js' const hintFor = (id: string): string => { const block = blockById(id) @@ -163,49 +144,3 @@ test('composeGoal: omits the note when it is only whitespace', () => { expect(goal).not.toContain('Additional context from the developer') }) - -const exampleRecord: Omit = { - created_at: '2026-07-29T00:00:00.000Z', - mode: 'full_api', - selections: ['access_grants'], - note: null, - goal: 'Set up a Seam integration.', - analysis: { - sdk: 'javascript', - framework: 'Next.js', - app_type_guess: 'property management', - seam_already_setup: false, - used_onboarding: true, - recommendation_source: 'llm', - }, -} - -test('writeOnboardingRecord: writes the record to .seam/onboarding.json', () => { - writeOnboardingRecord(dir, exampleRecord) - - const contents = readFileSync(join(dir, '.seam', 'onboarding.json'), 'utf8') - expect(JSON.parse(contents)).toEqual({ - schema_version: 1, - ...exampleRecord, - }) -}) - -test('writeOnboardingRecord: ends the file with a trailing newline', () => { - writeOnboardingRecord(dir, exampleRecord) - - const contents = readFileSync(join(dir, '.seam', 'onboarding.json'), 'utf8') - expect(contents.endsWith('}\n')).toBe(true) -}) - -test('writeOnboardingRecord: keeps an optional result in the record', () => { - writeOnboardingRecord(dir, { - ...exampleRecord, - result: { ok: true, files_summary: 'Added src/seam.ts', cost_usd: 0.42 }, - }) - - const contents = readFileSync(join(dir, '.seam', 'onboarding.json'), 'utf8') - expect(JSON.parse(contents)).toMatchObject({ - schema_version: 1, - result: { ok: true, files_summary: 'Added src/seam.ts', cost_usd: 0.42 }, - }) -}) diff --git a/src/lib/steps/build-plan.ts b/src/lib/steps/build-plan.ts index fe714bd..f45e95d 100644 --- a/src/lib/steps/build-plan.ts +++ b/src/lib/steps/build-plan.ts @@ -1,6 +1,3 @@ -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - // Top-level fork: let Seam host the UI (the app calls ~2 endpoints), or drive // everything through the API and build the UI yourself. export type BuildMode = 'customer_portal' | 'full_api' @@ -124,47 +121,3 @@ export function composeGoal(args: { noteSuffix ) } - -// Schema version for the on-disk record; bump on any breaking shape change. -const RECORD_SCHEMA_VERSION = 1 - -export interface OnboardingRecord { - schema_version: number - created_at: string - mode: BuildMode - selections: string[] - note: string | null - goal: string - analysis: { - sdk: string | null - framework: string | null - app_type_guess: string | null - seam_already_setup: boolean - used_onboarding: boolean - recommendation_source: 'llm' | 'heuristic' - } - result?: { - ok: boolean - files_summary: string - cost_usd: number | null - } -} - -// Write the wizard's run record to /.seam/onboarding.json. Holds no -// secrets (the API key stays in .env), so it is safe to commit as a record of -// what was set up, and the embedded agent / a later editor agent can read it. -export function writeOnboardingRecord( - root: string, - record: Omit, -): void { - const dir = join(root, '.seam') - mkdirSync(dir, { recursive: true }) - const full: OnboardingRecord = { - schema_version: RECORD_SCHEMA_VERSION, - ...record, - } - writeFileSync( - join(dir, 'onboarding.json'), - `${JSON.stringify(full, null, 2)}\n`, - ) -} diff --git a/src/lib/steps/connect-web.ts b/src/lib/steps/connect-web.ts index 0f5dc6a..2bc50f0 100644 --- a/src/lib/steps/connect-web.ts +++ b/src/lib/steps/connect-web.ts @@ -1,10 +1,9 @@ import { randomBytes } from 'node:crypto' import { createServer, type ServerResponse } from 'node:http' -import { join } from 'node:path' import open from 'open' -import { upsertEnvVar } from 'lib/util/env-file.js' +import { saveProjectApiKey } from 'lib/util/env-file.js' import { getWorkspaceForApiKey, type SeamWorkspace } from 'lib/util/seam-api.js' // The dashboard "wizard" page mints a key and posts it back to the local @@ -110,7 +109,7 @@ export async function connectViaWeb( }) const workspace = await getWorkspaceForApiKey(payload.api_key) - upsertEnvVar(join(root, '.env'), 'SEAM_API_KEY', payload.api_key) + saveProjectApiKey(root, payload.api_key) return { workspace } } diff --git a/src/lib/steps/connection.test.ts b/src/lib/steps/connection.test.ts new file mode 100644 index 0000000..eb2d555 --- /dev/null +++ b/src/lib/steps/connection.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, expect, test } from 'vitest' + +import { createMemoryAdapter, resetAdapter, setAdapter } from 'lib/adapter.js' +import { type ProjectConnection, readProjectRecord } from 'lib/store/index.js' +import { fingerprintApiKey } from 'lib/util/api-key.js' +import type { SeamWorkspace } from 'lib/util/seam-api.js' + +import { + compareConnection, + describeChange, + saveConnection, +} from './connection.js' + +const apiKey = 'seam_apikey1_first_key' +const workspace: SeamWorkspace = { + workspace_id: 'workspace-1', + name: 'Acme', + is_sandbox: false, +} + +const recorded: ProjectConnection = { + endpoint: 'https://connect.getseam.com', + workspace_id: workspace.workspace_id, + workspace_name: workspace.name, + api_key: fingerprintApiKey(apiKey), + api_key_source: 'project', + api_key_location: '.env', +} + +const current = { + endpoint: recorded.endpoint, + workspace, + api_key: apiKey, +} + +beforeEach(() => { + setAdapter(createMemoryAdapter()) +}) + +afterEach(resetAdapter) + +test('compareConnection: nothing changed is nothing to ask about', () => { + expect(compareConnection(recorded, current)).toEqual([]) +}) + +test('compareConnection: the same key read from another file has not changed', () => { + expect( + compareConnection({ ...recorded, api_key_location: '.env.local' }, current), + ).toEqual([]) +}) + +test('compareConnection: reports a different key by its last characters', () => { + const changes = compareConnection(recorded, { + ...current, + api_key: 'seam_apikey1_second_0f8f', + }) + + expect(changes).toEqual([{ what: 'api_key', from: '_key', to: '0f8f' }]) +}) + +test('compareConnection: reports a different workspace by name', () => { + const changes = compareConnection(recorded, { + ...current, + workspace: { ...workspace, workspace_id: 'workspace-2', name: 'Other' }, + }) + + expect(changes).toEqual([{ what: 'workspace', from: 'Acme', to: 'Other' }]) +}) + +test('compareConnection: reports a different server', () => { + const changes = compareConnection(recorded, { + ...current, + endpoint: 'https://connect.example.com', + }) + + expect(changes).toEqual([ + { + what: 'endpoint', + from: 'https://connect.getseam.com', + to: 'https://connect.example.com', + }, + ]) +}) + +test('compareConnection: reports everything that moved at once', () => { + const changes = compareConnection(recorded, { + endpoint: 'https://connect.example.com', + workspace: { ...workspace, workspace_id: 'workspace-2', name: 'Other' }, + api_key: 'seam_apikey1_second_0f8f', + }) + + expect(changes.map((change) => change.what)).toEqual([ + 'api_key', + 'workspace', + 'endpoint', + ]) +}) + +test('compareConnection: asks nothing about a record that kept no key', () => { + expect(compareConnection({ ...recorded, api_key: null }, current)).toEqual([]) +}) + +test('describeChange: reads as what moved, and where to', () => { + expect( + describeChange({ + what: 'endpoint', + from: 'https://connect.getseam.com', + to: 'https://connect.example.com', + }), + ).toBe('Endpoint: https://connect.getseam.com → https://connect.example.com') +}) + +test('saveConnection: records what the project talks to, never the key', async () => { + await saveConnection('/projects/app', { + workspace, + api_key: apiKey, + source: 'project', + location: '.env.local', + }) + + const record = await readProjectRecord('/projects/app') + expect(record?.connection).toEqual({ + endpoint: 'https://connect.getseam.com', + workspace_id: 'workspace-1', + workspace_name: 'Acme', + api_key: fingerprintApiKey(apiKey), + api_key_source: 'project', + api_key_location: '.env.local', + }) + expect(JSON.stringify(record)).not.toContain(apiKey) +}) + +test('saveConnection: what it records reads back as unchanged', async () => { + await saveConnection('/projects/app', { + workspace, + api_key: apiKey, + source: 'browser', + }) + + const record = await readProjectRecord('/projects/app') + expect(record?.connection).not.toBeNull() + expect( + compareConnection(record?.connection as ProjectConnection, current), + ).toEqual([]) +}) diff --git a/src/lib/steps/connection.ts b/src/lib/steps/connection.ts new file mode 100644 index 0000000..9623c31 --- /dev/null +++ b/src/lib/steps/connection.ts @@ -0,0 +1,86 @@ +import { getAuth } from 'lib/adapter.js' +import { + type ConnectionSource, + type ProjectConnection, + recordConnection, +} from 'lib/store/index.js' +import { fingerprintApiKey } from 'lib/util/api-key.js' +import type { SeamWorkspace } from 'lib/util/seam-api.js' + +export type ConnectionChange = + | { what: 'api_key'; from: string; to: string } + | { what: 'workspace'; from: string; to: string } + | { what: 'endpoint'; from: string; to: string } + +export interface CurrentConnection { + endpoint: string + workspace: SeamWorkspace + api_key: string +} + +export const compareConnection = ( + recorded: ProjectConnection, + current: CurrentConnection, +): ConnectionChange[] => { + const changes: ConnectionChange[] = [] + + const currentKey = fingerprintApiKey(current.api_key) + if ( + recorded.api_key != null && + recorded.api_key.digest !== currentKey.digest + ) { + changes.push({ + what: 'api_key', + from: recorded.api_key.hint, + to: currentKey.hint, + }) + } + + if ( + recorded.workspace_id != null && + recorded.workspace_id !== current.workspace.workspace_id + ) { + changes.push({ + what: 'workspace', + from: recorded.workspace_name ?? recorded.workspace_id, + to: current.workspace.name, + }) + } + + if (recorded.endpoint !== current.endpoint) { + changes.push({ + what: 'endpoint', + from: recorded.endpoint, + to: current.endpoint, + }) + } + + return changes +} + +export const describeChange = (change: ConnectionChange): string => { + if (change.what === 'api_key') { + return `API key: one ending ${change.from} → one ending ${change.to}` + } + const label = change.what === 'workspace' ? 'Workspace' : 'Endpoint' + return `${label}: ${change.from} → ${change.to}` +} + +export const saveConnection = async ( + root: string, + connection: { + workspace: SeamWorkspace + api_key: string + source: ConnectionSource + location?: string | null + }, +): Promise => { + await recordConnection(root, { + endpoint: getAuth().endpoint, + workspace_id: connection.workspace.workspace_id, + workspace_name: connection.workspace.name, + api_key: fingerprintApiKey(connection.api_key), + api_key_source: connection.source, + api_key_location: connection.location ?? null, + }) +} diff --git a/src/lib/store/config-store.test.ts b/src/lib/store/config-store.test.ts new file mode 100644 index 0000000..0a7df17 --- /dev/null +++ b/src/lib/store/config-store.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, expect, test } from 'vitest' + +import { + createMemoryAdapter, + getAdapter, + resetAdapter, + setAdapter, +} from 'lib/adapter.js' + +import { readPreferredSdk, writePreferredSdk } from './config-store.js' + +beforeEach(() => { + setAdapter(createMemoryAdapter()) +}) + +afterEach(resetAdapter) + +test('preferred SDK: is null until the developer has chosen one', async () => { + expect(await readPreferredSdk()).toBeNull() +}) + +test('preferred SDK: reads back the SDK the developer chose', async () => { + await writePreferredSdk('python') + + expect(await readPreferredSdk()).toBe('python') +}) + +test('preferred SDK: is kept in the settings the host holds', async () => { + await writePreferredSdk('javascript') + + expect(await getAdapter().config.get('sdk')).toBe('javascript') +}) + +test('preferred SDK: ignores an SDK the wizard no longer offers', async () => { + await getAdapter().config.set('sdk', 'ruby') + + expect(await readPreferredSdk()).toBeNull() +}) diff --git a/src/lib/store/config-store.ts b/src/lib/store/config-store.ts new file mode 100644 index 0000000..5ab376b --- /dev/null +++ b/src/lib/store/config-store.ts @@ -0,0 +1,13 @@ +import { getAdapter } from 'lib/adapter.js' +import type { Sdk } from 'lib/steps/detect-project.js' + +const sdkKey = 'sdk' + +export const readPreferredSdk = async (): Promise => { + const sdk = await getAdapter().config.get(sdkKey) + return sdk === 'javascript' || sdk === 'python' ? sdk : null +} + +export const writePreferredSdk = async (sdk: Sdk): Promise => { + await getAdapter().config.set(sdkKey, sdk) +} diff --git a/src/lib/store/index.ts b/src/lib/store/index.ts new file mode 100644 index 0000000..3dbcf0e --- /dev/null +++ b/src/lib/store/index.ts @@ -0,0 +1,13 @@ +export { readPreferredSdk, writePreferredSdk } from './config-store.js' +export { + type ConnectionSource, + getProjectKey, + type OnboardingRecord, + type ProjectConnection, + type ProjectPlan, + type ProjectResult, + readProjectRecord, + recordConnection, + recordPlan, + recordResult, +} from './project-store.js' diff --git a/src/lib/store/project-store.test.ts b/src/lib/store/project-store.test.ts new file mode 100644 index 0000000..c94ba00 --- /dev/null +++ b/src/lib/store/project-store.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, expect, test } from 'vitest' + +import { + createMemoryAdapter, + getAdapter, + resetAdapter, + setAdapter, +} from 'lib/adapter.js' + +import { + getProjectKey, + type ProjectConnection, + type ProjectPlan, + readProjectRecord, + recordConnection, + recordPlan, + recordResult, +} from './project-store.js' + +beforeEach(() => { + setAdapter(createMemoryAdapter()) +}) + +afterEach(resetAdapter) + +const exampleConnection: ProjectConnection = { + endpoint: 'https://connect.getseam.com', + workspace_id: 'workspace-1', + workspace_name: 'Acme', + api_key: { digest: 'abcdef0123456789', hint: '1234' }, + api_key_source: 'project', + api_key_location: '.env', +} + +const examplePlan: ProjectPlan = { + mode: 'full_api', + selections: ['access_grants'], + note: null, + goal: 'Set up a Seam integration.', + analysis: { + sdk: 'javascript', + framework: 'Next.js', + app_type_guess: 'property management', + seam_already_setup: false, + used_onboarding: true, + recommendation_source: 'llm', + }, +} + +test('recordConnection: keeps what the project was set up to talk to', async () => { + await recordConnection('/projects/app', exampleConnection) + + expect(await readProjectRecord('/projects/app')).toMatchObject({ + schema_version: 2, + connection: exampleConnection, + }) +}) + +test('recordConnection: never keeps the key itself', async () => { + await recordConnection('/projects/app', exampleConnection) + + const written = JSON.stringify( + await getAdapter().state.get(getProjectKey('/projects/app')), + ) + expect(written).not.toContain('seam_') + expect(written).toContain('1234') +}) + +test('recordPlan: adds to the record without dropping the connection', async () => { + await recordConnection('/projects/app', exampleConnection) + await recordPlan('/projects/app', examplePlan) + + expect(await readProjectRecord('/projects/app')).toMatchObject({ + connection: exampleConnection, + plan: examplePlan, + }) +}) + +test('recordResult: adds to the record without dropping the plan', async () => { + await recordPlan('/projects/app', examplePlan) + await recordResult('/projects/app', { + ok: true, + files_summary: 'Added src/seam.ts', + cost_usd: 0.42, + }) + + expect(await readProjectRecord('/projects/app')).toMatchObject({ + plan: examplePlan, + result: { ok: true, files_summary: 'Added src/seam.ts', cost_usd: 0.42 }, + }) +}) + +test('recordConnection: replaces what an earlier run recorded', async () => { + await recordConnection('/projects/app', exampleConnection) + await recordConnection('/projects/app', { + ...exampleConnection, + workspace_name: 'Other', + }) + + expect(await readProjectRecord('/projects/app')).toMatchObject({ + connection: { workspace_name: 'Other' }, + }) +}) + +test('recordConnection: keeps when the project was first set up', async () => { + await recordConnection('/projects/app', exampleConnection) + const first = await readProjectRecord('/projects/app') + + await recordPlan('/projects/app', examplePlan) + const second = await readProjectRecord('/projects/app') + + expect(second?.created_at).toBe(first?.created_at) + expect(second?.updated_at).not.toBeUndefined() +}) + +test('readProjectRecord: is null for a project with no record', async () => { + expect(await readProjectRecord('/projects/other')).toBeNull() +}) + +test('readProjectRecord: is null for a record the host cannot have written', async () => { + await getAdapter().state.set(getProjectKey('/projects/app'), 'not a record') + + expect(await readProjectRecord('/projects/app')).toBeNull() +}) + +test('readProjectRecord: is null for a record written by another schema', async () => { + await getAdapter().state.set(getProjectKey('/projects/app'), { + schema_version: 1, + created_at: '2026-07-29T00:00:00.000Z', + goal: 'Set up a Seam integration.', + }) + + expect(await readProjectRecord('/projects/app')).toBeNull() +}) + +test('getProjectKey: names the key after the project', () => { + expect(getProjectKey('/projects/My App')).toMatch( + /^projects\.my-app-[0-9a-f]{10}$/, + ) +}) + +test('getProjectKey: is the same key for the same project', () => { + expect(getProjectKey('/projects/app')).toBe(getProjectKey('/projects/app/')) +}) + +test('getProjectKey: separates projects with the same name', () => { + expect(getProjectKey('/one/app')).not.toBe(getProjectKey('/two/app')) +}) + +test('getProjectKey: names a key for a project at the root', () => { + expect(getProjectKey('/')).toMatch(/^projects\.project-[0-9a-f]{10}$/) +}) diff --git a/src/lib/store/project-store.ts b/src/lib/store/project-store.ts new file mode 100644 index 0000000..b41f2b7 --- /dev/null +++ b/src/lib/store/project-store.ts @@ -0,0 +1,124 @@ +import { createHash } from 'node:crypto' +import { basename, resolve } from 'node:path' + +import { getAdapter } from 'lib/adapter.js' +import type { BuildMode } from 'lib/steps/build-plan.js' +import type { ApiKeyFingerprint } from 'lib/util/api-key.js' + +export type ConnectionSource = 'project' | 'cli' | 'browser' | 'pasted' + +export interface ProjectConnection { + endpoint: string + workspace_id: string | null + workspace_name: string | null + api_key: ApiKeyFingerprint | null + api_key_source: ConnectionSource + api_key_location: string | null +} + +export interface ProjectPlan { + mode: BuildMode + selections: string[] + note: string | null + goal: string + analysis: { + sdk: string | null + framework: string | null + app_type_guess: string | null + seam_already_setup: boolean + used_onboarding: boolean + recommendation_source: 'llm' | 'heuristic' + } +} + +export interface ProjectResult { + ok: boolean + files_summary: string + cost_usd: number | null +} + +export interface OnboardingRecord { + schema_version: number + created_at: string + updated_at: string + connection: ProjectConnection | null + plan?: ProjectPlan + result?: ProjectResult +} + +const recordSchemaVersion = 2 + +export const getProjectKey = (root: string): string => { + const fullPath = resolve(root) + const digest = createHash('sha256') + .update(fullPath) + .digest('hex') + .slice(0, 10) + return `projects.${toSlug(basename(fullPath))}-${digest}` +} + +export const readProjectRecord = async ( + root: string, +): Promise => { + const record = await getAdapter().state.get(getProjectKey(root)) + return isOnboardingRecord(record) ? record : null +} + +export const recordConnection = async ( + root: string, + connection: ProjectConnection, +): Promise => { + await updateProjectRecord(root, (record) => ({ ...record, connection })) +} + +export const recordPlan = async ( + root: string, + plan: ProjectPlan, +): Promise => { + await updateProjectRecord(root, (record) => ({ ...record, plan })) +} + +export const recordResult = async ( + root: string, + result: ProjectResult, +): Promise => { + await updateProjectRecord(root, (record) => ({ ...record, result })) +} + +const updateProjectRecord = async ( + root: string, + update: (record: OnboardingRecord) => OnboardingRecord, +): Promise => { + const now = new Date().toISOString() + const existing = await readProjectRecord(root) + const record = update( + existing ?? { + schema_version: recordSchemaVersion, + created_at: now, + updated_at: now, + connection: null, + }, + ) + await getAdapter().state.set(getProjectKey(root), { + ...record, + schema_version: recordSchemaVersion, + updated_at: now, + }) +} + +const toSlug = (name: string): string => { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + return slug.length > 0 ? slug.slice(0, 40) : 'project' +} + +const isOnboardingRecord = (value: unknown): value is OnboardingRecord => { + if (value == null || typeof value !== 'object') return false + const record = value as Partial + return ( + record.schema_version === recordSchemaVersion && + typeof record.created_at === 'string' + ) +} diff --git a/src/lib/util/api-key.test.ts b/src/lib/util/api-key.test.ts new file mode 100644 index 0000000..e51ede1 --- /dev/null +++ b/src/lib/util/api-key.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from 'vitest' + +import { fingerprintApiKey, isSameApiKey } from './api-key.js' + +const apiKey = 'seam_apikey1_0f8fad5bd9cb469fa16570867728950e' + +test('fingerprintApiKey: holds none of the key it fingerprints', () => { + const fingerprint = fingerprintApiKey(apiKey) + + expect(JSON.stringify(fingerprint)).not.toContain('seam_') + expect(apiKey).not.toContain(fingerprint.digest) +}) + +test('fingerprintApiKey: shows enough to recognize which key is meant', () => { + expect(fingerprintApiKey(apiKey).hint).toBe('950e') +}) + +test('fingerprintApiKey: is the same for the same key', () => { + expect(fingerprintApiKey(apiKey)).toEqual(fingerprintApiKey(apiKey)) +}) + +test('fingerprintApiKey: ignores surrounding whitespace', () => { + expect(fingerprintApiKey(` ${apiKey}\n`)).toEqual(fingerprintApiKey(apiKey)) +}) + +test('fingerprintApiKey: differs for a different key', () => { + expect(fingerprintApiKey(apiKey).digest).not.toBe( + fingerprintApiKey('seam_apikey1_other').digest, + ) +}) + +test('isSameApiKey: tells the recorded key from another', () => { + const fingerprint = fingerprintApiKey(apiKey) + + expect(isSameApiKey(fingerprint, apiKey)).toBe(true) + expect(isSameApiKey(fingerprint, 'seam_apikey1_other')).toBe(false) +}) diff --git a/src/lib/util/api-key.ts b/src/lib/util/api-key.ts new file mode 100644 index 0000000..4ecfb6d --- /dev/null +++ b/src/lib/util/api-key.ts @@ -0,0 +1,19 @@ +import { createHash } from 'node:crypto' + +export interface ApiKeyFingerprint { + digest: string + hint: string +} + +export const fingerprintApiKey = (apiKey: string): ApiKeyFingerprint => { + const trimmed = apiKey.trim() + return { + digest: createHash('sha256').update(trimmed).digest('hex').slice(0, 16), + hint: trimmed.slice(-4), + } +} + +export const isSameApiKey = ( + fingerprint: ApiKeyFingerprint, + apiKey: string, +): boolean => fingerprintApiKey(apiKey).digest === fingerprint.digest diff --git a/src/lib/util/env-file.test.ts b/src/lib/util/env-file.test.ts index 6f35e05..ac3bd0f 100644 --- a/src/lib/util/env-file.test.ts +++ b/src/lib/util/env-file.test.ts @@ -1,10 +1,23 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, test, vi } from 'vitest' -import { findExistingApiKey, upsertEnvVar } from './env-file.js' +import { + ensureEnvExample, + ensureGitignored, + findExistingApiKey, + saveProjectApiKey, + upsertEnvVar, +} from './env-file.js' let dir = '' @@ -160,3 +173,90 @@ test('upsertEnvVar: writes into an existing empty file without a leading newline expect(upsertEnvVar(filePath, 'SEAM_API_KEY', 'seam_new')).toBe('added') expect(readFileSync(filePath, 'utf8')).toBe('SEAM_API_KEY=seam_new\n') }) + +test('saveProjectApiKey: writes the key, the example, and the ignore rule', () => { + mkdirSync(join(dir, '.git')) + + const result = saveProjectApiKey(dir, 'seam_new_key') + + expect(result).toEqual({ + env: 'created', + example: 'created', + gitignore: 'added', + }) + expect(readFileSync(join(dir, '.env'), 'utf8')).toBe( + 'SEAM_API_KEY=seam_new_key\n', + ) + expect(readFileSync(join(dir, '.env.example'), 'utf8')).toBe( + 'SEAM_API_KEY=\n', + ) + expect(readFileSync(join(dir, '.gitignore'), 'utf8')).toBe('.env\n') +}) + +test('saveProjectApiKey: never writes the key where it could be committed', () => { + mkdirSync(join(dir, '.git')) + + saveProjectApiKey(dir, 'seam_new_key') + + expect(readFileSync(join(dir, '.env.example'), 'utf8')).not.toContain( + 'seam_new_key', + ) +}) + +test('ensureEnvExample: leaves an example that already declares the key', () => { + writeEnvFile('.env.example', '# Seam\nSEAM_API_KEY=your-key-here\n') + + expect(ensureEnvExample(dir)).toBe('unchanged') + expect(readFileSync(join(dir, '.env.example'), 'utf8')).toContain( + 'your-key-here', + ) +}) + +test('ensureEnvExample: adds the key to an example that lacks it', () => { + writeEnvFile('.env.example', 'OTHER=1\n') + + expect(ensureEnvExample(dir)).toBe('added') + expect(readFileSync(join(dir, '.env.example'), 'utf8')).toBe( + 'OTHER=1\nSEAM_API_KEY=\n', + ) +}) + +test('ensureGitignored: adds the entry to an existing .gitignore', () => { + writeEnvFile('.gitignore', 'node_modules\n') + + expect(ensureGitignored(dir, '.env')).toBe('added') + expect(readFileSync(join(dir, '.gitignore'), 'utf8')).toBe( + 'node_modules\n.env\n', + ) +}) + +test('ensureGitignored: terminates a last line that has no newline', () => { + writeEnvFile('.gitignore', 'node_modules') + + ensureGitignored(dir, '.env') + + expect(readFileSync(join(dir, '.gitignore'), 'utf8')).toBe( + 'node_modules\n.env\n', + ) +}) + +test('ensureGitignored: leaves an entry that is already ignored', () => { + writeEnvFile('.gitignore', 'node_modules\n/.env/\n') + + expect(ensureGitignored(dir, '.env')).toBe('unchanged') + expect(readFileSync(join(dir, '.gitignore'), 'utf8')).toBe( + 'node_modules\n/.env/\n', + ) +}) + +test('ensureGitignored: starts a .gitignore for a repository without one', () => { + mkdirSync(join(dir, '.git')) + + expect(ensureGitignored(dir, '.env')).toBe('added') + expect(readFileSync(join(dir, '.gitignore'), 'utf8')).toBe('.env\n') +}) + +test('ensureGitignored: writes no git files outside a repository', () => { + expect(ensureGitignored(dir, '.env')).toBe('unchanged') + expect(existsSync(join(dir, '.gitignore'))).toBe(false) +}) diff --git a/src/lib/util/env-file.ts b/src/lib/util/env-file.ts index 874d4c5..bf048e3 100644 --- a/src/lib/util/env-file.ts +++ b/src/lib/util/env-file.ts @@ -39,6 +39,65 @@ export function findExistingApiKey(root: string): FoundApiKey | null { return null } +export interface ProjectEnvResult { + env: EnvWriteResult + example: EnvWriteResult | 'unchanged' + gitignore: 'added' | 'unchanged' +} + +export function saveProjectApiKey( + root: string, + apiKey: string, +): ProjectEnvResult { + return { + env: upsertEnvVar(join(root, '.env'), 'SEAM_API_KEY', apiKey), + ...ensureProjectEnvConventions(root), + } +} + +export function ensureProjectEnvConventions( + root: string, +): Omit { + return { + example: ensureEnvExample(root), + gitignore: ensureGitignored(root, '.env'), + } +} + +export function ensureEnvExample(root: string): EnvWriteResult | 'unchanged' { + const filePath = join(root, '.env.example') + if ( + existsSync(filePath) && + /^\s*SEAM_API_KEY\s*=/m.test(readFileSync(filePath, 'utf8')) + ) { + return 'unchanged' + } + return upsertEnvVar(filePath, 'SEAM_API_KEY', '') +} + +export function ensureGitignored( + root: string, + entry: string, +): 'added' | 'unchanged' { + const filePath = join(root, '.gitignore') + + if (!existsSync(filePath)) { + if (!existsSync(join(root, '.git'))) return 'unchanged' + writeFileSync(filePath, `${entry}\n`) + return 'added' + } + + const content = readFileSync(filePath, 'utf8') + const isIgnored = content + .split('\n') + .some((line) => line.trim().replace(/^\/+|\/+$/g, '') === entry) + if (isIgnored) return 'unchanged' + + const separator = content.length === 0 || content.endsWith('\n') ? '' : '\n' + writeFileSync(filePath, `${content}${separator}${entry}\n`) + return 'added' +} + // Upsert a KEY=value line into a dotenv file without disturbing other entries. // Returns what happened so the wizard can report it accurately. export function upsertEnvVar( diff --git a/src/lib/util/seam-api.ts b/src/lib/util/seam-api.ts index 0f55ed3..4a5f45e 100644 --- a/src/lib/util/seam-api.ts +++ b/src/lib/util/seam-api.ts @@ -2,7 +2,12 @@ // avoid pulling in the full SDK just for a health check — a single fetch keeps // the wizard's install footprint tiny. -const SEAM_API_BASE = 'https://connect.getseam.com' +import { getAuth } from 'lib/adapter.js' + +// Whichever server the host is pointed at. +export function getSeamApiBaseUrl(): string { + return getAuth().endpoint.replace(/\/+$/, '') +} export interface SeamWorkspace { workspace_id: string @@ -19,7 +24,7 @@ export async function getWorkspaceForApiKey( ): Promise { let response: Response try { - response = await fetch(`${SEAM_API_BASE}/workspaces/get`, { + response = await fetch(`${getSeamApiBaseUrl()}/workspaces/get`, { method: 'POST', headers: { authorization: `Bearer ${apiKey}`, @@ -57,7 +62,9 @@ export function looksLikeSeamApiKey(value: string): boolean { // Base URL for Seam-hosted inference. The embedded agent's SDK appends // /v1/messages; the exchange endpoint below lives at /session. -export const SEAM_INFERENCE_BASE_URL = `${SEAM_API_BASE}/internal/wizard_inference` +export function getInferenceBaseUrl(): string { + return `${getSeamApiBaseUrl()}/internal/wizard_inference` +} // The Console-collected onboarding answers Seam returns alongside the token, so // the wizard can pre-fill its plan instead of re-asking (null if none recorded). @@ -84,7 +91,7 @@ export async function exchangeWizardInferenceToken( ): Promise { let response: Response try { - response = await fetch(`${SEAM_INFERENCE_BASE_URL}/session`, { + response = await fetch(`${getInferenceBaseUrl()}/session`, { method: 'POST', headers: { authorization: `Bearer ${apiKey}`, diff --git a/src/lib/wizard.ts b/src/lib/wizard.ts index e68ecf3..cb0023e 100644 --- a/src/lib/wizard.ts +++ b/src/lib/wizard.ts @@ -26,8 +26,8 @@ export interface WizardOptions { * The project root the wizard sets up. * * Defaults to `process.cwd()`, which is the project the developer ran the - * command in. The wizard reads and writes files here, e.g., `.env` and - * `.seam/onboarding.json`. + * command in. The wizard reads and writes the project's own files here, + * e.g., `.env`, `.env.example`, and `.gitignore`. */ cwd?: string