From 19cb2aed578d45f4c0e55605dcfac9ed67ea840f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 00:37:03 +0000 Subject: [PATCH 1/6] Take a host, for whoever mounts the wizard to answer with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wizard needs two things it cannot decide for itself: the developer's Seam login, and somewhere to keep what it learns about a project. Both belong to whoever mounts it — the Seam CLI owns the login, and owns where files go — so this is the shape of the answer. Nothing reads the host yet. It is stated and accepted here on its own, so that the CLI can be built against it without waiting on the change that puts it to use. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QCJ1v1NFc6b43GooAhij2c --- README.md | 35 ++++++++++++++++ src/lib/host.test.ts | 93 +++++++++++++++++++++++++++++++++++++++++ src/lib/host.ts | 95 ++++++++++++++++++++++++++++++++++++++++++ src/lib/index.ts | 6 +++ src/lib/wizard.test.ts | 44 ++++++++++++++++++- src/lib/wizard.ts | 13 ++++++ 6 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 src/lib/host.test.ts create mode 100644 src/lib/host.ts diff --git a/README.md b/README.md index 6c68c67..f1daa1d 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,41 @@ await wizard({ }) ``` +### The host + +Pass `host` to answer for the two things the wizard cannot decide for +itself: the developer's Seam login, and where what it records is kept. + +```ts +import wizard, { type WizardHost } from '@seamapi/wizard' + +const host: WizardHost = { + // The developer's Seam login. `apiKey` is a workspace API key the project + // can use as SEAM_API_KEY, and is null unless the login has one. + getAuth: async () => ({ + server: 'https://connect.getseam.com', + serverSource: 'default', + apiKey: null, + workspaceId: null, + loginKind: 'none', + }), + // Preferences the developer chose, e.g., the SDK. + settings: { get: async (key) => ..., set: async (key, value) => ... }, + // What the wizard recorded about the projects it set up. + state: { get: async (key) => ..., set: async (key, value) => ... }, +} + +await wizard({ argv, commandName: 'seam wizard', host }) +``` + +The wizard picks the keys, the host picks the files. Values are plain JSON +and hold no secrets. `getAuth` is asked once per run, and `serverSource` +says whether the server came from the environment, so an override can be +mentioned rather than quietly used. + +Without a host the wizard runs against an in-memory one: every step works, +and nothing outlives the run. + ### Environment variables - `SEAM_API_KEY`: An existing Seam API key. diff --git a/src/lib/host.test.ts b/src/lib/host.test.ts new file mode 100644 index 0000000..1609856 --- /dev/null +++ b/src/lib/host.test.ts @@ -0,0 +1,93 @@ +import { afterEach, expect, test, vi } from 'vitest' + +import { + createMemoryHost, + defaultServer, + getAuth, + getHost, + loadAuth, + resetHost, + setHost, + type WizardAuth, +} from './host.js' + +afterEach(resetHost) + +const apiKeyLogin: WizardAuth = { + server: 'https://connect.example.com', + serverSource: 'cli', + apiKey: 'seam_apikey1_token', + workspaceId: 'workspace-1', + loginKind: 'api_key', +} + +test('host: runs logged out against an in-memory host by default', async () => { + expect(await loadAuth()).toEqual({ + server: defaultServer, + serverSource: 'default', + apiKey: null, + workspaceId: null, + loginKind: 'none', + }) +}) + +test('host: keeps values for the run and nothing beyond it', async () => { + await getHost().settings.set('sdk', 'python') + expect(await getHost().settings.get('sdk')).toBe('python') + + resetHost() + expect(await getHost().settings.get('sdk')).toBeUndefined() +}) + +test('host: keeps settings apart from state', async () => { + await getHost().settings.set('sdk', 'python') + + expect(await getHost().state.get('sdk')).toBeUndefined() +}) + +test('host: uses the auth the host answers with', async () => { + setHost(createMemoryHost({ auth: apiKeyLogin })) + + expect(await loadAuth()).toEqual(apiKeyLogin) + expect(getAuth()).toEqual(apiKeyLogin) +}) + +test('host: asks the host who the developer is once', async () => { + const getAuthSpy = vi.fn(async () => apiKeyLogin) + setHost({ ...createMemoryHost(), getAuth: getAuthSpy }) + + await loadAuth() + await loadAuth() + + expect(getAuthSpy).toHaveBeenCalledTimes(1) +}) + +test('host: is logged out until the auth has been loaded', () => { + setHost(createMemoryHost({ auth: apiKeyLogin })) + + expect(getAuth().loginKind).toBe('none') + expect(getAuth().server).toBe(defaultServer) +}) + +test('host: forgets the auth loaded for a previous host', async () => { + setHost(createMemoryHost({ auth: apiKeyLogin })) + await loadAuth() + + setHost(createMemoryHost()) + + expect(await loadAuth()).toMatchObject({ loginKind: 'none' }) +}) + +test('host: starts from the values it was created with', async () => { + setHost( + createMemoryHost({ + settings: { sdk: 'javascript' }, + state: { 'projects.app-1234567890': { goal: 'Set up Seam.' } }, + }), + ) + + expect(await getHost().settings.get('sdk')).toBe('javascript') + expect(await getHost().state.get('projects.app-1234567890')).toEqual({ + goal: 'Set up Seam.', + }) +}) diff --git a/src/lib/host.ts b/src/lib/host.ts new file mode 100644 index 0000000..4506f7c --- /dev/null +++ b/src/lib/host.ts @@ -0,0 +1,95 @@ +/** + * What the wizard cannot decide for itself, answered by whoever mounts it. + * + * The Seam CLI mounts the wizard and implements this: it owns the login and + * it owns where files go, so the wizard asks rather than reaching for either. + */ + +export interface WizardAuth { + /** The Seam API server to talk to. */ + server: string + /** Whether the server was overridden, chosen in the CLI, or the default. */ + serverSource: 'env' | 'cli' | 'default' + /** A workspace API key the project can use as SEAM_API_KEY, if there is one. */ + apiKey: string | null + /** The workspace the login is pointed at, when it names one. */ + workspaceId: string | null + loginKind: + 'api_key' | 'personal_access_token' | 'console_session_token' | 'none' +} + +/** Values the host keeps for the wizard. The keys are the wizard's own. */ +export interface WizardValues { + get: (key: string) => Promise + set: (key: string, value: unknown) => Promise +} + +export interface WizardHost { + getAuth: () => Promise + /** Preferences the developer chose, e.g., the SDK. */ + settings: WizardValues + /** What the wizard recorded about the projects it set up. */ + state: WizardValues +} + +export const defaultServer = 'https://connect.getseam.com' + +const loggedOut: WizardAuth = { + server: defaultServer, + serverSource: 'default', + apiKey: null, + workspaceId: null, + loginKind: 'none', +} + +export const createMemoryValues = ( + initialValues: Record = {}, +): WizardValues => { + const values = new Map(Object.entries(initialValues)) + return { + get: async (key) => values.get(key), + set: async (key, value) => { + values.set(key, value) + }, + } +} + +/** The default host: the run works, nothing outlives it. */ +export const createMemoryHost = ({ + auth = loggedOut, + settings = {}, + state = {}, +}: { + auth?: WizardAuth + settings?: Record + state?: Record +} = {}): WizardHost => ({ + getAuth: async () => auth, + settings: createMemoryValues(settings), + state: createMemoryValues(state), +}) + +let host: WizardHost = createMemoryHost() +let auth: WizardAuth | null = null + +export const getHost = (): WizardHost => host + +/** Mount the wizard on a host, e.g., the Seam CLI, or a fake from a test. */ +export const setHost = (nextHost: WizardHost): void => { + host = nextHost + auth = null +} + +export const resetHost = (): void => { + host = createMemoryHost() + auth = null +} + +/** Ask the host who the developer is, once for the run. */ +export const loadAuth = async (): Promise => { + auth ??= await host.getAuth() + return auth +} + +/** The auth loaded at startup. Logged out until {@link loadAuth} resolves. */ +export const getAuth = (): WizardAuth => auth ?? loggedOut diff --git a/src/lib/index.ts b/src/lib/index.ts index 3069cf6..aff9f38 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1 +1,7 @@ +export { + createMemoryHost, + type WizardAuth, + type WizardHost, + type WizardValues, +} from './host.js' export { default, type WizardOptions } from './wizard.js' diff --git a/src/lib/wizard.test.ts b/src/lib/wizard.test.ts index ba8ae09..b885145 100644 --- a/src/lib/wizard.test.ts +++ b/src/lib/wizard.test.ts @@ -1,5 +1,6 @@ -import { beforeEach, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { createMemoryHost, getAuth, resetHost } from './host.js' import { renderApp } from './render.js' import seamapiWizardVersion from './version.js' import wizard from './wizard.js' @@ -10,6 +11,8 @@ beforeEach(() => { vi.mocked(renderApp).mockClear() }) +afterEach(resetHost) + const captureOutput = async ( options: Parameters[0], ): Promise => { @@ -70,3 +73,42 @@ test('wizard: runs the app in the given directory', async () => { await wizard({ argv: [], cwd: '/tmp/example-project' }) expect(renderApp).toHaveBeenCalledWith({ root: '/tmp/example-project' }) }) + +test('wizard: runs on the host it is given', async () => { + const host = createMemoryHost({ + auth: { + server: 'https://connect.example.com', + serverSource: 'cli', + apiKey: 'seam_apikey1_token', + workspaceId: 'workspace-1', + loginKind: 'api_key', + }, + }) + + await wizard({ argv: [], host }) + + expect(getAuth()).toMatchObject({ server: 'https://connect.example.com' }) +}) + +test('wizard: runs logged out when it is given no host', async () => { + await wizard({ argv: [] }) + + expect(getAuth().loginKind).toBe('none') +}) + +test('wizard: asks no host anything to display usage', async () => { + const getAuthSpy = vi.fn(async () => ({ + server: 'https://connect.example.com', + serverSource: 'cli' as const, + apiKey: null, + workspaceId: null, + loginKind: 'none' as const, + })) + + await captureOutput({ + argv: ['--help'], + host: { ...createMemoryHost(), getAuth: getAuthSpy }, + }) + + expect(getAuthSpy).not.toHaveBeenCalled() +}) diff --git a/src/lib/wizard.ts b/src/lib/wizard.ts index c1914df..7826c2a 100644 --- a/src/lib/wizard.ts +++ b/src/lib/wizard.ts @@ -1,5 +1,6 @@ import parseArgs from 'minimist' +import { loadAuth, setHost, type WizardHost } from './host.js' import { renderApp } from './render.js' import seamapiWizardVersion from './version.js' @@ -29,6 +30,15 @@ export interface WizardOptions { * `.seam/onboarding.json`. */ cwd?: string + + /** + * Everything the wizard cannot decide for itself: the developer's Seam + * login, and where what it records is kept. + * + * The Seam CLI passes its own. Without one the wizard runs against an + * in-memory host: the run works, and nothing outlives it. + */ + host?: WizardHost } /** @@ -43,6 +53,7 @@ export interface WizardOptions { */ const wizard = async (options: WizardOptions = {}): Promise => { const { argv = [], commandName = 'wizard', cwd = process.cwd() } = options + if (options.host != null) setHost(options.host) const args = parseArgs([...argv], { boolean: ['help', 'version'], @@ -59,6 +70,8 @@ const wizard = async (options: WizardOptions = {}): Promise => { return } + await loadAuth() + await renderApp({ root: cwd }) } From 9c265588fb0e3b5b2fc7067fc10231a923d064d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 00:42:41 +0000 Subject: [PATCH 2/6] Say authMethod and config, as the CLI says them Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QCJ1v1NFc6b43GooAhij2c --- README.md | 4 ++-- src/lib/host.test.ts | 22 +++++++++++----------- src/lib/host.ts | 12 ++++++------ src/lib/wizard.test.ts | 6 +++--- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index f1daa1d..b73f172 100644 --- a/README.md +++ b/README.md @@ -114,10 +114,10 @@ const host: WizardHost = { serverSource: 'default', apiKey: null, workspaceId: null, - loginKind: 'none', + authMethod: 'none', }), // Preferences the developer chose, e.g., the SDK. - settings: { get: async (key) => ..., set: async (key, value) => ... }, + config: { get: async (key) => ..., set: async (key, value) => ... }, // What the wizard recorded about the projects it set up. state: { get: async (key) => ..., set: async (key, value) => ... }, } diff --git a/src/lib/host.test.ts b/src/lib/host.test.ts index 1609856..ff4247f 100644 --- a/src/lib/host.test.ts +++ b/src/lib/host.test.ts @@ -18,7 +18,7 @@ const apiKeyLogin: WizardAuth = { serverSource: 'cli', apiKey: 'seam_apikey1_token', workspaceId: 'workspace-1', - loginKind: 'api_key', + authMethod: 'api_key', } test('host: runs logged out against an in-memory host by default', async () => { @@ -27,20 +27,20 @@ test('host: runs logged out against an in-memory host by default', async () => { serverSource: 'default', apiKey: null, workspaceId: null, - loginKind: 'none', + authMethod: 'none', }) }) test('host: keeps values for the run and nothing beyond it', async () => { - await getHost().settings.set('sdk', 'python') - expect(await getHost().settings.get('sdk')).toBe('python') + await getHost().config.set('sdk', 'python') + expect(await getHost().config.get('sdk')).toBe('python') resetHost() - expect(await getHost().settings.get('sdk')).toBeUndefined() + expect(await getHost().config.get('sdk')).toBeUndefined() }) -test('host: keeps settings apart from state', async () => { - await getHost().settings.set('sdk', 'python') +test('host: keeps config apart from state', async () => { + await getHost().config.set('sdk', 'python') expect(await getHost().state.get('sdk')).toBeUndefined() }) @@ -65,7 +65,7 @@ test('host: asks the host who the developer is once', async () => { test('host: is logged out until the auth has been loaded', () => { setHost(createMemoryHost({ auth: apiKeyLogin })) - expect(getAuth().loginKind).toBe('none') + expect(getAuth().authMethod).toBe('none') expect(getAuth().server).toBe(defaultServer) }) @@ -75,18 +75,18 @@ test('host: forgets the auth loaded for a previous host', async () => { setHost(createMemoryHost()) - expect(await loadAuth()).toMatchObject({ loginKind: 'none' }) + expect(await loadAuth()).toMatchObject({ authMethod: 'none' }) }) test('host: starts from the values it was created with', async () => { setHost( createMemoryHost({ - settings: { sdk: 'javascript' }, + config: { sdk: 'javascript' }, state: { 'projects.app-1234567890': { goal: 'Set up Seam.' } }, }), ) - expect(await getHost().settings.get('sdk')).toBe('javascript') + expect(await getHost().config.get('sdk')).toBe('javascript') expect(await getHost().state.get('projects.app-1234567890')).toEqual({ goal: 'Set up Seam.', }) diff --git a/src/lib/host.ts b/src/lib/host.ts index 4506f7c..682f58a 100644 --- a/src/lib/host.ts +++ b/src/lib/host.ts @@ -14,7 +14,7 @@ export interface WizardAuth { apiKey: string | null /** The workspace the login is pointed at, when it names one. */ workspaceId: string | null - loginKind: + authMethod: 'api_key' | 'personal_access_token' | 'console_session_token' | 'none' } @@ -27,7 +27,7 @@ export interface WizardValues { export interface WizardHost { getAuth: () => Promise /** Preferences the developer chose, e.g., the SDK. */ - settings: WizardValues + config: WizardValues /** What the wizard recorded about the projects it set up. */ state: WizardValues } @@ -39,7 +39,7 @@ const loggedOut: WizardAuth = { serverSource: 'default', apiKey: null, workspaceId: null, - loginKind: 'none', + authMethod: 'none', } export const createMemoryValues = ( @@ -57,15 +57,15 @@ export const createMemoryValues = ( /** The default host: the run works, nothing outlives it. */ export const createMemoryHost = ({ auth = loggedOut, - settings = {}, + config = {}, state = {}, }: { auth?: WizardAuth - settings?: Record + config?: Record state?: Record } = {}): WizardHost => ({ getAuth: async () => auth, - settings: createMemoryValues(settings), + config: createMemoryValues(config), state: createMemoryValues(state), }) diff --git a/src/lib/wizard.test.ts b/src/lib/wizard.test.ts index b885145..0b9cf3e 100644 --- a/src/lib/wizard.test.ts +++ b/src/lib/wizard.test.ts @@ -81,7 +81,7 @@ test('wizard: runs on the host it is given', async () => { serverSource: 'cli', apiKey: 'seam_apikey1_token', workspaceId: 'workspace-1', - loginKind: 'api_key', + authMethod: 'api_key', }, }) @@ -93,7 +93,7 @@ test('wizard: runs on the host it is given', async () => { test('wizard: runs logged out when it is given no host', async () => { await wizard({ argv: [] }) - expect(getAuth().loginKind).toBe('none') + expect(getAuth().authMethod).toBe('none') }) test('wizard: asks no host anything to display usage', async () => { @@ -102,7 +102,7 @@ test('wizard: asks no host anything to display usage', async () => { serverSource: 'cli' as const, apiKey: null, workspaceId: null, - loginKind: 'none' as const, + authMethod: 'none' as const, })) await captureOutput({ From e9a8edfff829dfe7e647671c107b6790679e6058 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 00:50:39 +0000 Subject: [PATCH 3/6] Call it an adapter, and call the server an endpoint 'Host' reads as a URL, which is the one thing it is not: it is whoever mounts the wizard, answering for what the wizard cannot decide. And the Seam API address is an endpoint everywhere else, including the environment variable that overrides it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QCJ1v1NFc6b43GooAhij2c --- README.md | 26 ++++----- src/lib/adapter.test.ts | 93 +++++++++++++++++++++++++++++++++ src/lib/{host.ts => adapter.ts} | 42 +++++++-------- src/lib/host.test.ts | 93 --------------------------------- src/lib/index.ts | 6 +-- src/lib/wizard.test.ts | 26 ++++----- src/lib/wizard.ts | 8 +-- 7 files changed, 147 insertions(+), 147 deletions(-) create mode 100644 src/lib/adapter.test.ts rename src/lib/{host.ts => adapter.ts} (64%) delete mode 100644 src/lib/host.test.ts diff --git a/README.md b/README.md index b73f172..897afdf 100644 --- a/README.md +++ b/README.md @@ -98,20 +98,20 @@ await wizard({ }) ``` -### The host +### The adapter -Pass `host` to answer for the two things the wizard cannot decide for +Pass `adapter` to answer for the two things the wizard cannot decide for itself: the developer's Seam login, and where what it records is kept. ```ts -import wizard, { type WizardHost } from '@seamapi/wizard' +import wizard, { type WizardAdapter } from '@seamapi/wizard' -const host: WizardHost = { +const adapter: WizardAdapter = { // The developer's Seam login. `apiKey` is a workspace API key the project // can use as SEAM_API_KEY, and is null unless the login has one. getAuth: async () => ({ - server: 'https://connect.getseam.com', - serverSource: 'default', + endpoint: 'https://connect.getseam.com', + endpointSource: 'default', apiKey: null, workspaceId: null, authMethod: 'none', @@ -122,16 +122,16 @@ const host: WizardHost = { state: { get: async (key) => ..., set: async (key, value) => ... }, } -await wizard({ argv, commandName: 'seam wizard', host }) +await wizard({ argv, commandName: 'seam wizard', adapter }) ``` -The wizard picks the keys, the host picks the files. Values are plain JSON -and hold no secrets. `getAuth` is asked once per run, and `serverSource` -says whether the server came from the environment, so an override can be -mentioned rather than quietly used. +The wizard picks the keys, the adapter picks the files. Values are plain +JSON and hold no secrets. `getAuth` is asked once per run, and +`endpointSource` says whether the endpoint came from the environment, so an +override can be mentioned rather than quietly used. -Without a host the wizard runs against an in-memory one: every step works, -and nothing outlives the run. +Without an adapter the wizard runs against an in-memory one: every step +works, and nothing outlives the run. ### Environment variables diff --git a/src/lib/adapter.test.ts b/src/lib/adapter.test.ts new file mode 100644 index 0000000..9b8551a --- /dev/null +++ b/src/lib/adapter.test.ts @@ -0,0 +1,93 @@ +import { afterEach, expect, test, vi } from 'vitest' + +import { + createMemoryAdapter, + defaultEndpoint, + getAdapter, + getAuth, + loadAuth, + resetAdapter, + setAdapter, + type WizardAuth, +} from './adapter.js' + +afterEach(resetAdapter) + +const apiKeyLogin: WizardAuth = { + endpoint: 'https://connect.example.com', + endpointSource: 'cli', + apiKey: 'seam_apikey1_token', + workspaceId: 'workspace-1', + authMethod: 'api_key', +} + +test('adapter: runs logged out against an in-memory adapter by default', async () => { + expect(await loadAuth()).toEqual({ + endpoint: defaultEndpoint, + endpointSource: 'default', + apiKey: null, + workspaceId: null, + authMethod: 'none', + }) +}) + +test('adapter: keeps values for the run and nothing beyond it', async () => { + await getAdapter().config.set('sdk', 'python') + expect(await getAdapter().config.get('sdk')).toBe('python') + + resetAdapter() + expect(await getAdapter().config.get('sdk')).toBeUndefined() +}) + +test('adapter: keeps config apart from state', async () => { + await getAdapter().config.set('sdk', 'python') + + expect(await getAdapter().state.get('sdk')).toBeUndefined() +}) + +test('adapter: uses the auth the host answers with', async () => { + setAdapter(createMemoryAdapter({ auth: apiKeyLogin })) + + expect(await loadAuth()).toEqual(apiKeyLogin) + expect(getAuth()).toEqual(apiKeyLogin) +}) + +test('adapter: asks the adapter who the developer is once', async () => { + const getAuthSpy = vi.fn(async () => apiKeyLogin) + setAdapter({ ...createMemoryAdapter(), getAuth: getAuthSpy }) + + await loadAuth() + await loadAuth() + + expect(getAuthSpy).toHaveBeenCalledTimes(1) +}) + +test('adapter: is logged out until the auth has been loaded', () => { + setAdapter(createMemoryAdapter({ auth: apiKeyLogin })) + + expect(getAuth().authMethod).toBe('none') + expect(getAuth().endpoint).toBe(defaultEndpoint) +}) + +test('adapter: forgets the auth loaded for a previous adapter', async () => { + setAdapter(createMemoryAdapter({ auth: apiKeyLogin })) + await loadAuth() + + setAdapter(createMemoryAdapter()) + + expect(await loadAuth()).toMatchObject({ authMethod: 'none' }) +}) + +test('adapter: starts from the values it was created with', async () => { + setAdapter( + createMemoryAdapter({ + config: { sdk: 'javascript' }, + state: { 'projects.app-1234567890': { goal: 'Set up Seam.' } }, + }), + ) + + expect(await getAdapter().config.get('sdk')).toBe('javascript') + expect(await getAdapter().state.get('projects.app-1234567890')).toEqual({ + goal: 'Set up Seam.', + }) +}) diff --git a/src/lib/host.ts b/src/lib/adapter.ts similarity index 64% rename from src/lib/host.ts rename to src/lib/adapter.ts index 682f58a..ff42784 100644 --- a/src/lib/host.ts +++ b/src/lib/adapter.ts @@ -6,10 +6,10 @@ */ export interface WizardAuth { - /** The Seam API server to talk to. */ - server: string - /** Whether the server was overridden, chosen in the CLI, or the default. */ - serverSource: 'env' | 'cli' | 'default' + /** The Seam API endpoint to talk to. */ + endpoint: string + /** Whether the endpoint was overridden, chosen in the CLI, or the default. */ + endpointSource: 'env' | 'cli' | 'default' /** A workspace API key the project can use as SEAM_API_KEY, if there is one. */ apiKey: string | null /** The workspace the login is pointed at, when it names one. */ @@ -18,13 +18,13 @@ export interface WizardAuth { 'api_key' | 'personal_access_token' | 'console_session_token' | 'none' } -/** Values the host keeps for the wizard. The keys are the wizard's own. */ +/** Values the adapter keeps for the wizard. The keys are the wizard's own. */ export interface WizardValues { get: (key: string) => Promise set: (key: string, value: unknown) => Promise } -export interface WizardHost { +export interface WizardAdapter { getAuth: () => Promise /** Preferences the developer chose, e.g., the SDK. */ config: WizardValues @@ -32,11 +32,11 @@ export interface WizardHost { state: WizardValues } -export const defaultServer = 'https://connect.getseam.com' +export const defaultEndpoint = 'https://connect.getseam.com' const loggedOut: WizardAuth = { - server: defaultServer, - serverSource: 'default', + endpoint: defaultEndpoint, + endpointSource: 'default', apiKey: null, workspaceId: null, authMethod: 'none', @@ -54,8 +54,8 @@ export const createMemoryValues = ( } } -/** The default host: the run works, nothing outlives it. */ -export const createMemoryHost = ({ +/** The default adapter: the run works, nothing outlives it. */ +export const createMemoryAdapter = ({ auth = loggedOut, config = {}, state = {}, @@ -63,31 +63,31 @@ export const createMemoryHost = ({ auth?: WizardAuth config?: Record state?: Record -} = {}): WizardHost => ({ +} = {}): WizardAdapter => ({ getAuth: async () => auth, config: createMemoryValues(config), state: createMemoryValues(state), }) -let host: WizardHost = createMemoryHost() +let adapter: WizardAdapter = createMemoryAdapter() let auth: WizardAuth | null = null -export const getHost = (): WizardHost => host +export const getAdapter = (): WizardAdapter => adapter -/** Mount the wizard on a host, e.g., the Seam CLI, or a fake from a test. */ -export const setHost = (nextHost: WizardHost): void => { - host = nextHost +/** Mount the wizard on an adapter, e.g., the Seam CLI, or a test's own. */ +export const setAdapter = (nextAdapter: WizardAdapter): void => { + adapter = nextAdapter auth = null } -export const resetHost = (): void => { - host = createMemoryHost() +export const resetAdapter = (): void => { + adapter = createMemoryAdapter() auth = null } -/** Ask the host who the developer is, once for the run. */ +/** Ask the adapter who the developer is, once for the run. */ export const loadAuth = async (): Promise => { - auth ??= await host.getAuth() + auth ??= await adapter.getAuth() return auth } diff --git a/src/lib/host.test.ts b/src/lib/host.test.ts deleted file mode 100644 index ff4247f..0000000 --- a/src/lib/host.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { afterEach, expect, test, vi } from 'vitest' - -import { - createMemoryHost, - defaultServer, - getAuth, - getHost, - loadAuth, - resetHost, - setHost, - type WizardAuth, -} from './host.js' - -afterEach(resetHost) - -const apiKeyLogin: WizardAuth = { - server: 'https://connect.example.com', - serverSource: 'cli', - apiKey: 'seam_apikey1_token', - workspaceId: 'workspace-1', - authMethod: 'api_key', -} - -test('host: runs logged out against an in-memory host by default', async () => { - expect(await loadAuth()).toEqual({ - server: defaultServer, - serverSource: 'default', - apiKey: null, - workspaceId: null, - authMethod: 'none', - }) -}) - -test('host: keeps values for the run and nothing beyond it', async () => { - await getHost().config.set('sdk', 'python') - expect(await getHost().config.get('sdk')).toBe('python') - - resetHost() - expect(await getHost().config.get('sdk')).toBeUndefined() -}) - -test('host: keeps config apart from state', async () => { - await getHost().config.set('sdk', 'python') - - expect(await getHost().state.get('sdk')).toBeUndefined() -}) - -test('host: uses the auth the host answers with', async () => { - setHost(createMemoryHost({ auth: apiKeyLogin })) - - expect(await loadAuth()).toEqual(apiKeyLogin) - expect(getAuth()).toEqual(apiKeyLogin) -}) - -test('host: asks the host who the developer is once', async () => { - const getAuthSpy = vi.fn(async () => apiKeyLogin) - setHost({ ...createMemoryHost(), getAuth: getAuthSpy }) - - await loadAuth() - await loadAuth() - - expect(getAuthSpy).toHaveBeenCalledTimes(1) -}) - -test('host: is logged out until the auth has been loaded', () => { - setHost(createMemoryHost({ auth: apiKeyLogin })) - - expect(getAuth().authMethod).toBe('none') - expect(getAuth().server).toBe(defaultServer) -}) - -test('host: forgets the auth loaded for a previous host', async () => { - setHost(createMemoryHost({ auth: apiKeyLogin })) - await loadAuth() - - setHost(createMemoryHost()) - - expect(await loadAuth()).toMatchObject({ authMethod: 'none' }) -}) - -test('host: starts from the values it was created with', async () => { - setHost( - createMemoryHost({ - config: { sdk: 'javascript' }, - state: { 'projects.app-1234567890': { goal: 'Set up Seam.' } }, - }), - ) - - expect(await getHost().config.get('sdk')).toBe('javascript') - expect(await getHost().state.get('projects.app-1234567890')).toEqual({ - goal: 'Set up Seam.', - }) -}) diff --git a/src/lib/index.ts b/src/lib/index.ts index aff9f38..9ef6c2d 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,7 +1,7 @@ export { - createMemoryHost, + createMemoryAdapter, + type WizardAdapter, type WizardAuth, - type WizardHost, type WizardValues, -} from './host.js' +} from './adapter.js' export { default, type WizardOptions } from './wizard.js' diff --git a/src/lib/wizard.test.ts b/src/lib/wizard.test.ts index 0b9cf3e..f45ac7a 100644 --- a/src/lib/wizard.test.ts +++ b/src/lib/wizard.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest' -import { createMemoryHost, getAuth, resetHost } from './host.js' +import { createMemoryAdapter, getAuth, resetAdapter } from './adapter.js' import { renderApp } from './render.js' import seamapiWizardVersion from './version.js' import wizard from './wizard.js' @@ -11,7 +11,7 @@ beforeEach(() => { vi.mocked(renderApp).mockClear() }) -afterEach(resetHost) +afterEach(resetAdapter) const captureOutput = async ( options: Parameters[0], @@ -74,32 +74,32 @@ test('wizard: runs the app in the given directory', async () => { expect(renderApp).toHaveBeenCalledWith({ root: '/tmp/example-project' }) }) -test('wizard: runs on the host it is given', async () => { - const host = createMemoryHost({ +test('wizard: runs on the adapter it is given', async () => { + const adapter = createMemoryAdapter({ auth: { - server: 'https://connect.example.com', - serverSource: 'cli', + endpoint: 'https://connect.example.com', + endpointSource: 'cli', apiKey: 'seam_apikey1_token', workspaceId: 'workspace-1', authMethod: 'api_key', }, }) - await wizard({ argv: [], host }) + await wizard({ argv: [], adapter }) - expect(getAuth()).toMatchObject({ server: 'https://connect.example.com' }) + expect(getAuth()).toMatchObject({ endpoint: 'https://connect.example.com' }) }) -test('wizard: runs logged out when it is given no host', async () => { +test('wizard: runs logged out when it is given no adapter', async () => { await wizard({ argv: [] }) expect(getAuth().authMethod).toBe('none') }) -test('wizard: asks no host anything to display usage', async () => { +test('wizard: asks no adapter anything to display usage', async () => { const getAuthSpy = vi.fn(async () => ({ - server: 'https://connect.example.com', - serverSource: 'cli' as const, + endpoint: 'https://connect.example.com', + endpointSource: 'cli' as const, apiKey: null, workspaceId: null, authMethod: 'none' as const, @@ -107,7 +107,7 @@ test('wizard: asks no host anything to display usage', async () => { await captureOutput({ argv: ['--help'], - host: { ...createMemoryHost(), getAuth: getAuthSpy }, + adapter: { ...createMemoryAdapter(), getAuth: getAuthSpy }, }) expect(getAuthSpy).not.toHaveBeenCalled() diff --git a/src/lib/wizard.ts b/src/lib/wizard.ts index 7826c2a..d924d5f 100644 --- a/src/lib/wizard.ts +++ b/src/lib/wizard.ts @@ -1,6 +1,6 @@ import parseArgs from 'minimist' -import { loadAuth, setHost, type WizardHost } from './host.js' +import { loadAuth, setAdapter, type WizardAdapter } from './adapter.js' import { renderApp } from './render.js' import seamapiWizardVersion from './version.js' @@ -36,9 +36,9 @@ export interface WizardOptions { * login, and where what it records is kept. * * The Seam CLI passes its own. Without one the wizard runs against an - * in-memory host: the run works, and nothing outlives it. + * in-memory adapter: the run works, and nothing outlives it. */ - host?: WizardHost + adapter?: WizardAdapter } /** @@ -53,7 +53,7 @@ export interface WizardOptions { */ const wizard = async (options: WizardOptions = {}): Promise => { const { argv = [], commandName = 'wizard', cwd = process.cwd() } = options - if (options.host != null) setHost(options.host) + if (options.adapter != null) setAdapter(options.adapter) const args = parseArgs([...argv], { boolean: ['help', 'version'], From ff30c918475feab141cd497f47358255ef06471d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 04:04:54 +0000 Subject: [PATCH 4/6] Drop the added comments and README section Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QCJ1v1NFc6b43GooAhij2c --- README.md | 35 ----------------------------------- src/lib/adapter.ts | 18 ------------------ src/lib/wizard.ts | 7 ------- 3 files changed, 60 deletions(-) diff --git a/README.md b/README.md index 897afdf..6c68c67 100644 --- a/README.md +++ b/README.md @@ -98,41 +98,6 @@ await wizard({ }) ``` -### The adapter - -Pass `adapter` to answer for the two things the wizard cannot decide for -itself: the developer's Seam login, and where what it records is kept. - -```ts -import wizard, { type WizardAdapter } from '@seamapi/wizard' - -const adapter: WizardAdapter = { - // The developer's Seam login. `apiKey` is a workspace API key the project - // can use as SEAM_API_KEY, and is null unless the login has one. - getAuth: async () => ({ - endpoint: 'https://connect.getseam.com', - endpointSource: 'default', - apiKey: null, - workspaceId: null, - authMethod: 'none', - }), - // Preferences the developer chose, e.g., the SDK. - config: { get: async (key) => ..., set: async (key, value) => ... }, - // What the wizard recorded about the projects it set up. - state: { get: async (key) => ..., set: async (key, value) => ... }, -} - -await wizard({ argv, commandName: 'seam wizard', adapter }) -``` - -The wizard picks the keys, the adapter picks the files. Values are plain -JSON and hold no secrets. `getAuth` is asked once per run, and -`endpointSource` says whether the endpoint came from the environment, so an -override can be mentioned rather than quietly used. - -Without an adapter the wizard runs against an in-memory one: every step -works, and nothing outlives the run. - ### Environment variables - `SEAM_API_KEY`: An existing Seam API key. diff --git a/src/lib/adapter.ts b/src/lib/adapter.ts index ff42784..9bb37e3 100644 --- a/src/lib/adapter.ts +++ b/src/lib/adapter.ts @@ -1,24 +1,12 @@ -/** - * What the wizard cannot decide for itself, answered by whoever mounts it. - * - * The Seam CLI mounts the wizard and implements this: it owns the login and - * it owns where files go, so the wizard asks rather than reaching for either. - */ - export interface WizardAuth { - /** The Seam API endpoint to talk to. */ endpoint: string - /** Whether the endpoint was overridden, chosen in the CLI, or the default. */ endpointSource: 'env' | 'cli' | 'default' - /** A workspace API key the project can use as SEAM_API_KEY, if there is one. */ apiKey: string | null - /** The workspace the login is pointed at, when it names one. */ workspaceId: string | null authMethod: 'api_key' | 'personal_access_token' | 'console_session_token' | 'none' } -/** Values the adapter keeps for the wizard. The keys are the wizard's own. */ export interface WizardValues { get: (key: string) => Promise set: (key: string, value: unknown) => Promise @@ -26,9 +14,7 @@ export interface WizardValues { export interface WizardAdapter { getAuth: () => Promise - /** Preferences the developer chose, e.g., the SDK. */ config: WizardValues - /** What the wizard recorded about the projects it set up. */ state: WizardValues } @@ -54,7 +40,6 @@ export const createMemoryValues = ( } } -/** The default adapter: the run works, nothing outlives it. */ export const createMemoryAdapter = ({ auth = loggedOut, config = {}, @@ -74,7 +59,6 @@ let auth: WizardAuth | null = null export const getAdapter = (): WizardAdapter => adapter -/** Mount the wizard on an adapter, e.g., the Seam CLI, or a test's own. */ export const setAdapter = (nextAdapter: WizardAdapter): void => { adapter = nextAdapter auth = null @@ -85,11 +69,9 @@ export const resetAdapter = (): void => { auth = null } -/** Ask the adapter who the developer is, once for the run. */ export const loadAuth = async (): Promise => { auth ??= await adapter.getAuth() return auth } -/** The auth loaded at startup. Logged out until {@link loadAuth} resolves. */ export const getAuth = (): WizardAuth => auth ?? loggedOut diff --git a/src/lib/wizard.ts b/src/lib/wizard.ts index d924d5f..e68ecf3 100644 --- a/src/lib/wizard.ts +++ b/src/lib/wizard.ts @@ -31,13 +31,6 @@ export interface WizardOptions { */ cwd?: string - /** - * Everything the wizard cannot decide for itself: the developer's Seam - * login, and where what it records is kept. - * - * The Seam CLI passes its own. Without one the wizard runs against an - * in-memory adapter: the run works, and nothing outlives it. - */ adapter?: WizardAdapter } From 67bc55facc771a41d066b68b9c6b5e15ad65db67 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 04:08:13 +0000 Subject: [PATCH 5/6] Hand over an endpoint and a key, and nothing about how it was got The adapter reported which kind of credential the CLI held so the wizard could decide what to do with it. The wizard only ever uses an API key, so the CLI hands over one or hands over nothing, and the enum goes with it. endpointSource goes the same way, and WizardValues is named for what it is. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QCJ1v1NFc6b43GooAhij2c --- src/lib/adapter.test.ts | 8 ++------ src/lib/adapter.ts | 19 +++++++------------ src/lib/index.ts | 2 +- src/lib/wizard.test.ts | 6 +----- 4 files changed, 11 insertions(+), 24 deletions(-) diff --git a/src/lib/adapter.test.ts b/src/lib/adapter.test.ts index 9b8551a..92afd27 100644 --- a/src/lib/adapter.test.ts +++ b/src/lib/adapter.test.ts @@ -15,19 +15,15 @@ afterEach(resetAdapter) const apiKeyLogin: WizardAuth = { endpoint: 'https://connect.example.com', - endpointSource: 'cli', apiKey: 'seam_apikey1_token', workspaceId: 'workspace-1', - authMethod: 'api_key', } test('adapter: runs logged out against an in-memory adapter by default', async () => { expect(await loadAuth()).toEqual({ endpoint: defaultEndpoint, - endpointSource: 'default', apiKey: null, workspaceId: null, - authMethod: 'none', }) }) @@ -65,7 +61,7 @@ test('adapter: asks the adapter who the developer is once', async () => { test('adapter: is logged out until the auth has been loaded', () => { setAdapter(createMemoryAdapter({ auth: apiKeyLogin })) - expect(getAuth().authMethod).toBe('none') + expect(getAuth().apiKey).toBeNull() expect(getAuth().endpoint).toBe(defaultEndpoint) }) @@ -75,7 +71,7 @@ test('adapter: forgets the auth loaded for a previous adapter', async () => { setAdapter(createMemoryAdapter()) - expect(await loadAuth()).toMatchObject({ authMethod: 'none' }) + expect(await loadAuth()).toMatchObject({ apiKey: null }) }) test('adapter: starts from the values it was created with', async () => { diff --git a/src/lib/adapter.ts b/src/lib/adapter.ts index 9bb37e3..c2acc0e 100644 --- a/src/lib/adapter.ts +++ b/src/lib/adapter.ts @@ -1,36 +1,31 @@ export interface WizardAuth { endpoint: string - endpointSource: 'env' | 'cli' | 'default' apiKey: string | null workspaceId: string | null - authMethod: - 'api_key' | 'personal_access_token' | 'console_session_token' | 'none' } -export interface WizardValues { +export interface StorageAdapter { get: (key: string) => Promise set: (key: string, value: unknown) => Promise } export interface WizardAdapter { getAuth: () => Promise - config: WizardValues - state: WizardValues + config: StorageAdapter + state: StorageAdapter } export const defaultEndpoint = 'https://connect.getseam.com' const loggedOut: WizardAuth = { endpoint: defaultEndpoint, - endpointSource: 'default', apiKey: null, workspaceId: null, - authMethod: 'none', } -export const createMemoryValues = ( +export const createMemoryStorage = ( initialValues: Record = {}, -): WizardValues => { +): StorageAdapter => { const values = new Map(Object.entries(initialValues)) return { get: async (key) => values.get(key), @@ -50,8 +45,8 @@ export const createMemoryAdapter = ({ state?: Record } = {}): WizardAdapter => ({ getAuth: async () => auth, - config: createMemoryValues(config), - state: createMemoryValues(state), + config: createMemoryStorage(config), + state: createMemoryStorage(state), }) let adapter: WizardAdapter = createMemoryAdapter() diff --git a/src/lib/index.ts b/src/lib/index.ts index 9ef6c2d..f1cf7a7 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,7 +1,7 @@ export { createMemoryAdapter, + type StorageAdapter, type WizardAdapter, type WizardAuth, - type WizardValues, } from './adapter.js' export { default, type WizardOptions } from './wizard.js' diff --git a/src/lib/wizard.test.ts b/src/lib/wizard.test.ts index f45ac7a..7f2a9ed 100644 --- a/src/lib/wizard.test.ts +++ b/src/lib/wizard.test.ts @@ -78,10 +78,8 @@ test('wizard: runs on the adapter it is given', async () => { const adapter = createMemoryAdapter({ auth: { endpoint: 'https://connect.example.com', - endpointSource: 'cli', apiKey: 'seam_apikey1_token', workspaceId: 'workspace-1', - authMethod: 'api_key', }, }) @@ -93,16 +91,14 @@ test('wizard: runs on the adapter it is given', async () => { test('wizard: runs logged out when it is given no adapter', async () => { await wizard({ argv: [] }) - expect(getAuth().authMethod).toBe('none') + expect(getAuth().apiKey).toBeNull() }) test('wizard: asks no adapter anything to display usage', async () => { const getAuthSpy = vi.fn(async () => ({ endpoint: 'https://connect.example.com', - endpointSource: 'cli' as const, apiKey: null, workspaceId: null, - authMethod: 'none' as const, })) await captureOutput({ From f17967abd014a1c343d58b0b2435438ac176818c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 04:14:58 +0000 Subject: [PATCH 6/6] Keep createMemoryAdapter off the package surface It is the default the wizard falls back to and the one its own tests set. Nothing outside imports it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QCJ1v1NFc6b43GooAhij2c --- src/lib/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/index.ts b/src/lib/index.ts index f1cf7a7..256f713 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,5 +1,4 @@ export { - createMemoryAdapter, type StorageAdapter, type WizardAdapter, type WizardAuth,