From 656aeaedfe20150f30da830160354c8504742021 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 05:09:00 +0000 Subject: [PATCH 1/3] Put the adapter to use, and ask what this project connects with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wizard reached into the Seam CLI's cli.json for the developer's login, re-derived the CLI's paths with its own copy of env-paths, and reimplemented its auth precedence. It also adopted whatever SEAM_API_KEY it found without recording anything, so it could not tell a project set up against one workspace from the same project now pointed at another. It now asks its adapter for both, which leaves three states. Nothing recorded, and the developer chooses, with what the environment already holds offered first. Recorded and still matching, and the run carries on without a question. Recorded and not matching, and what moved is named and the developer says which side is right. The key is never recorded, only a digest to compare it and the last four characters to recognize it by. .seam/onboarding.json is gone: a run is local to whoever ran it. What the team needs is written into the project instead — the key in .env, the variable in .env.example, and .env in .gitignore. @seamapi/cli is a devDependency, so npm run wizard runs this wizard as `seam wizard` through the real CLI, which hands it a real adapter. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QCJ1v1NFc6b43GooAhij2c --- package-lock.json | 657 ++++++++++++++++++++++++++-- package.json | 1 + src/bin/cli.ts | 37 +- src/lib/app.test.tsx | 8 +- src/lib/app.tsx | 319 +++++++++++--- src/lib/steps/authenticate.ts | 32 +- src/lib/steps/build-plan.test.ts | 69 +-- src/lib/steps/build-plan.ts | 47 -- src/lib/steps/connect-web.ts | 5 +- src/lib/steps/connection.test.ts | 145 ++++++ src/lib/steps/connection.ts | 86 ++++ src/lib/store/config-store.test.ts | 38 ++ src/lib/store/config-store.ts | 13 + src/lib/store/index.ts | 13 + src/lib/store/project-store.test.ts | 152 +++++++ src/lib/store/project-store.ts | 124 ++++++ src/lib/util/api-key.test.ts | 37 ++ src/lib/util/api-key.ts | 19 + src/lib/util/env-file.test.ts | 104 ++++- src/lib/util/env-file.ts | 59 +++ src/lib/util/seam-api.ts | 15 +- src/lib/wizard.ts | 4 +- 22 files changed, 1739 insertions(+), 245 deletions(-) create mode 100644 src/lib/steps/connection.test.ts create mode 100644 src/lib/steps/connection.ts create mode 100644 src/lib/store/config-store.test.ts create mode 100644 src/lib/store/config-store.ts create mode 100644 src/lib/store/index.ts create mode 100644 src/lib/store/project-store.test.ts create mode 100644 src/lib/store/project-store.ts create mode 100644 src/lib/util/api-key.test.ts create mode 100644 src/lib/util/api-key.ts 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..abf23c2 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,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..ec0fb06 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -1,24 +1,29 @@ #!/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 wizard' runs the wizard as 'seam wizard'. -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, pointing the wizard at a +// scratch project instead of this repository. Every other argument is passed +// through untouched. 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', 'wizard', ...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 From a3b3d6b39a3d6c6122f5354222537907f0f1ad95 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 05:16:24 +0000 Subject: [PATCH 2/3] Run the whole CLI in development, not only the wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev entry ran `seam wizard` and nothing else, so testing how the wizard sits alongside the rest of the CLI — logging in, selecting a workspace — meant leaving the repository. It now passes through whatever it is given: npm run seam -- login npm run seam -- devices list npm run wizard -- --cwd All of them the CLI from devDependencies, with @seamapi/wizard pointed at this checkout. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QCJ1v1NFc6b43GooAhij2c --- README.md | 18 ++++++++++++++---- package.json | 5 +++-- src/bin/{cli.ts => seam.ts} | 9 ++++----- tsconfig.json | 2 +- vitest.config.ts | 2 +- 5 files changed, 23 insertions(+), 13 deletions(-) rename src/bin/{cli.ts => seam.ts} (70%) diff --git a/README.md b/README.md index 6c68c67..243cd40 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/seam.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.json b/package.json index abf23c2..cdd8988 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/seam.ts", + "wizard": "tsx src/bin/seam.ts wizard", + "inspect": "tsx --inspect src/bin/seam.ts wizard", "format": "prettier --write --ignore-path .gitignore .", "preformat": "eslint --fix .", "report": "vitest run --coverage" diff --git a/src/bin/cli.ts b/src/bin/seam.ts similarity index 70% rename from src/bin/cli.ts rename to src/bin/seam.ts index ec0fb06..704ae06 100644 --- a/src/bin/cli.ts +++ b/src/bin/seam.ts @@ -3,7 +3,7 @@ // 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 wizard' runs the wizard as 'seam wizard'. +// checkout, so 'npm run seam' is the CLI with this wizard mounted in it. import { registerHooks } from 'node:module' @@ -16,14 +16,13 @@ registerHooks({ : next(specifier, context), }) -// '--cwd ' is an option of this entry, pointing the wizard at a -// scratch project instead of this repository. Every other argument is passed -// through untouched. +// '--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) -process.argv = [process.argv[0] ?? 'node', 'seam', 'wizard', ...argv] +process.argv = [process.argv[0] ?? 'node', 'seam', ...argv] await import('@seamapi/cli/cli') diff --git a/tsconfig.json b/tsconfig.json index 550188f..a4a9691 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,7 @@ "lib/*": ["./src/lib/*"] } }, - "files": ["src/index.ts", "src/bin/cli.ts"], + "files": ["src/index.ts", "src/bin/seam.ts"], "include": [ "src/**/*", "test/**/*", diff --git a/vitest.config.ts b/vitest.config.ts index ac14356..bed3d71 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ coverage: { exclude: [ '**/index.ts', - 'src/bin/cli.ts', + 'src/bin/seam.ts', 'package/**/*.ts', '**/*.test.ts', '**/*.test.tsx', From d1d4235a1320a09476405905f8af20f952fb1b8f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 05:22:37 +0000 Subject: [PATCH 3/3] Keep the dev entry at src/bin/cli.ts Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QCJ1v1NFc6b43GooAhij2c --- README.md | 2 +- package.json | 6 +++--- src/bin/{seam.ts => cli.ts} | 0 tsconfig.json | 2 +- vitest.config.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) rename src/bin/{seam.ts => cli.ts} (100%) diff --git a/README.md b/README.md index 243cd40..8500bb2 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ $ npm run seam -- login $ npm run seam -- devices list ``` -Both run the Seam CLI from `devDependencies` through `src/bin/seam.ts`, +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. diff --git a/package.json b/package.json index cdd8988..7f709d7 100644 --- a/package.json +++ b/package.json @@ -55,9 +55,9 @@ "postlint": "prettier --check --ignore-path .gitignore .", "prepack": "tsx ./prepack.ts", "postversion": "git push --follow-tags", - "seam": "tsx src/bin/seam.ts", - "wizard": "tsx src/bin/seam.ts wizard", - "inspect": "tsx --inspect src/bin/seam.ts wizard", + "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" diff --git a/src/bin/seam.ts b/src/bin/cli.ts similarity index 100% rename from src/bin/seam.ts rename to src/bin/cli.ts diff --git a/tsconfig.json b/tsconfig.json index a4a9691..550188f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,7 @@ "lib/*": ["./src/lib/*"] } }, - "files": ["src/index.ts", "src/bin/seam.ts"], + "files": ["src/index.ts", "src/bin/cli.ts"], "include": [ "src/**/*", "test/**/*", diff --git a/vitest.config.ts b/vitest.config.ts index bed3d71..ac14356 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ coverage: { exclude: [ '**/index.ts', - 'src/bin/seam.ts', + 'src/bin/cli.ts', 'package/**/*.ts', '**/*.test.ts', '**/*.test.tsx',