From 60b27fc2cf0480f3cea0a108f402a93185f7f4be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 04:59:25 +0000 Subject: [PATCH] Adapt the wizard: answer for the login and keep its files The CLI mounts the wizard, owns the login, and owns where files go, so it answers for all three. createWizardAdapter hands over the endpoint and workspace resolveAuth resolved, and an API key or nothing: the wizard only uses an API key, and only a workspace-scoped one may go in a project's .env, so a personal access token is not passed at all. Its two files are named where the wizard is mounted, off the config's rootPaths: wizard.json beside cli.json in the config and state directories. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QCJ1v1NFc6b43GooAhij2c --- package-lock.json | 8 +- package.json | 2 +- src/lib/commands/local/wizard.ts | 48 +++++++++++ test/commands/wizard.test.ts | 140 +++++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 test/commands/wizard.test.ts diff --git a/package-lock.json b/package-lock.json index d8b871e9..586cca08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@clack/prompts": "^1.7.0", "@seamapi/blueprint": "1.5.0", "@seamapi/http": "2.2.0", - "@seamapi/wizard": "0.5.2", + "@seamapi/wizard": "0.7.0", "chalk": "^6.0.0", "command-line-usage": "^7.0.4", "configstore": "^8.0.0", @@ -1501,9 +1501,9 @@ } }, "node_modules/@seamapi/wizard": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@seamapi/wizard/-/wizard-0.5.2.tgz", - "integrity": "sha512-zqZjGxN7Np24J9USS6BeGewMAVdDNYwOseOVqMzHSXwbvxLO29yCRv+sfT9RwQr+Y+uI3APK61Qgp7XTkBADvA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@seamapi/wizard/-/wizard-0.7.0.tgz", + "integrity": "sha512-oYNzvdZyDn39OPF4j77BdbJTcFc0vIno4TlSqKxpNGv278/d+eyiTBiXla/n52koxMJMuLSTEFIiBuXcomom0Q==", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.216", diff --git a/package.json b/package.json index 3c51d2fd..f5416de0 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "@clack/prompts": "^1.7.0", "@seamapi/blueprint": "1.5.0", "@seamapi/http": "2.2.0", - "@seamapi/wizard": "0.5.2", + "@seamapi/wizard": "0.7.0", "chalk": "^6.0.0", "command-line-usage": "^7.0.4", "configstore": "^8.0.0", diff --git a/src/lib/commands/local/wizard.ts b/src/lib/commands/local/wizard.ts index 2c9caac0..c10430cf 100644 --- a/src/lib/commands/local/wizard.ts +++ b/src/lib/commands/local/wizard.ts @@ -1,4 +1,12 @@ +import { join } from 'node:path' + +import { isApiKey } from '@seamapi/http/connect' +import type { StorageAdapter, WizardAdapter, WizardAuth } from '@seamapi/wizard' +import Configstore from 'configstore' + import type { Command } from 'lib/commands/registry.js' +import { type CliConfig, getConfig, rootPaths } from 'lib/config/index.js' +import { resolveAuth } from 'lib/context.js' /** * Run the Seam setup wizard. @@ -11,6 +19,7 @@ export const runWizard = async (argv: string[]): Promise => { await wizard({ argv, commandName: 'seam wizard', + adapter: createWizardAdapter(), }) } @@ -29,3 +38,42 @@ export const wizardCommand: Command = { return { kind: 'done' } }, } + +const wizardFileName = 'wizard.json' + +export const createWizardAdapter = ({ + cliConfig = getConfig(), + configPath = join(rootPaths.config, wizardFileName), + statePath = join(rootPaths.log, wizardFileName), +}: { + cliConfig?: CliConfig + configPath?: string + statePath?: string +} = {}): WizardAdapter => ({ + getAuth: async () => toWizardAuth(cliConfig), + config: createStorage(configPath), + state: createStorage(statePath), +}) + +// Only a workspace-scoped key may go in a project, so a personal access +// token is not handed over at all. +const toWizardAuth = (cliConfig: CliConfig): WizardAuth => { + const { endpoint, token, workspaceId } = resolveAuth(cliConfig) + + return { + endpoint, + apiKey: token != null && isApiKey(token) ? token : null, + workspaceId, + } +} + +const createStorage = (path: string): StorageAdapter => { + const store = new Configstore('seam-cli', undefined, { configPath: path }) + + return { + get: async (key) => store.get(key), + set: async (key, value) => { + store.set(key, value) + }, + } +} diff --git a/test/commands/wizard.test.ts b/test/commands/wizard.test.ts new file mode 100644 index 00000000..03768d98 --- /dev/null +++ b/test/commands/wizard.test.ts @@ -0,0 +1,140 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, expect, test } from 'vitest' + +import { createWizardAdapter } from 'lib/commands/local/wizard.js' +import { createMemoryConfig } from 'lib/config/index.js' +import { defaultEndpoint } from 'lib/context.js' +import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from 'lib/env.js' + +let directory = '' + +const clearEnv = (): void => { + delete process.env[endpointEnvVar] + delete process.env[tokenEnvVar] + delete process.env[workspaceIdEnvVar] +} + +beforeEach(() => { + clearEnv() + directory = mkdtempSync(join(tmpdir(), 'seam-wizard-adapter-')) +}) + +afterEach(() => { + clearEnv() + rmSync(directory, { recursive: true, force: true }) +}) + +const createAdapter = ({ + endpoint, + token, + workspaceId, +}: { + endpoint?: string + token?: string + workspaceId?: string +} = {}) => { + const cliConfig = createMemoryConfig() + if (endpoint != null) cliConfig.setEndpoint(endpoint) + if (token != null) cliConfig.setToken(endpoint ?? defaultEndpoint, token) + if (workspaceId != null) cliConfig.setWorkspace(workspaceId) + + return createWizardAdapter({ + cliConfig, + configPath: join(directory, 'config', 'wizard.json'), + statePath: join(directory, 'state', 'wizard.json'), + }) +} + +test('wizard adapter: hands over an API key for the project to use', async () => { + const adapter = createAdapter({ token: 'seam_apikey1_token' }) + + expect(await adapter.getAuth()).toEqual({ + endpoint: defaultEndpoint, + apiKey: 'seam_apikey1_token', + workspaceId: null, + }) +}) + +test('wizard adapter: hands over no key for a personal access token', async () => { + const adapter = createAdapter({ + token: 'seam_at1_token', + workspaceId: 'workspace-1', + }) + + expect(await adapter.getAuth()).toEqual({ + endpoint: defaultEndpoint, + apiKey: null, + workspaceId: 'workspace-1', + }) +}) + +test('wizard adapter: reports no login when nothing is stored', async () => { + expect(await createAdapter().getAuth()).toEqual({ + endpoint: defaultEndpoint, + apiKey: null, + workspaceId: null, + }) +}) + +test('wizard adapter: uses the endpoint the CLI is pointed at', async () => { + const adapter = createAdapter({ + endpoint: 'https://connect.example.com', + token: 'seam_apikey1_token', + }) + + expect(await adapter.getAuth()).toMatchObject({ + endpoint: 'https://connect.example.com', + apiKey: 'seam_apikey1_token', + }) +}) + +test('wizard adapter: the environment wins over what the CLI stored', async () => { + process.env[tokenEnvVar] = 'seam_apikey1_from_env' + process.env[endpointEnvVar] = 'https://connect.env.example.com' + + const adapter = createAdapter({ + endpoint: 'https://connect.example.com', + token: 'seam_apikey1_stored', + }) + + expect(await adapter.getAuth()).toMatchObject({ + endpoint: 'https://connect.env.example.com', + apiKey: 'seam_apikey1_from_env', + }) +}) + +test('wizard adapter: keeps config and state in separate files', async () => { + const adapter = createAdapter() + + await adapter.config.set('sdk', 'python') + await adapter.state.set('projects.app-1234567890', { goal: 'Set up Seam.' }) + + expect(await adapter.config.get('sdk')).toBe('python') + expect(await adapter.state.get('projects.app-1234567890')).toEqual({ + goal: 'Set up Seam.', + }) + + const configFile = join(directory, 'config', 'wizard.json') + const stateFile = join(directory, 'state', 'wizard.json') + expect(JSON.parse(readFileSync(configFile, 'utf8'))).toEqual({ + sdk: 'python', + }) + expect(readFileSync(stateFile, 'utf8')).not.toContain('sdk') +}) + +test('wizard adapter: keeps what it was given between adapters', async () => { + await createAdapter().config.set('sdk', 'javascript') + + expect(await createAdapter().config.get('sdk')).toBe('javascript') +}) + +test('wizard adapter: writes nothing until the wizard saves something', () => { + createAdapter() + + expect(() => + readFileSync(join(directory, 'config', 'wizard.json'), 'utf8'), + ).toThrow() +})