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() +})