diff --git a/src/lib/adapter.test.ts b/src/lib/adapter.test.ts new file mode 100644 index 0000000..92afd27 --- /dev/null +++ b/src/lib/adapter.test.ts @@ -0,0 +1,89 @@ +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', + apiKey: 'seam_apikey1_token', + workspaceId: 'workspace-1', +} + +test('adapter: runs logged out against an in-memory adapter by default', async () => { + expect(await loadAuth()).toEqual({ + endpoint: defaultEndpoint, + apiKey: null, + workspaceId: null, + }) +}) + +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().apiKey).toBeNull() + 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({ apiKey: null }) +}) + +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/adapter.ts b/src/lib/adapter.ts new file mode 100644 index 0000000..c2acc0e --- /dev/null +++ b/src/lib/adapter.ts @@ -0,0 +1,72 @@ +export interface WizardAuth { + endpoint: string + apiKey: string | null + workspaceId: string | null +} + +export interface StorageAdapter { + get: (key: string) => Promise + set: (key: string, value: unknown) => Promise +} + +export interface WizardAdapter { + getAuth: () => Promise + config: StorageAdapter + state: StorageAdapter +} + +export const defaultEndpoint = 'https://connect.getseam.com' + +const loggedOut: WizardAuth = { + endpoint: defaultEndpoint, + apiKey: null, + workspaceId: null, +} + +export const createMemoryStorage = ( + initialValues: Record = {}, +): StorageAdapter => { + const values = new Map(Object.entries(initialValues)) + return { + get: async (key) => values.get(key), + set: async (key, value) => { + values.set(key, value) + }, + } +} + +export const createMemoryAdapter = ({ + auth = loggedOut, + config = {}, + state = {}, +}: { + auth?: WizardAuth + config?: Record + state?: Record +} = {}): WizardAdapter => ({ + getAuth: async () => auth, + config: createMemoryStorage(config), + state: createMemoryStorage(state), +}) + +let adapter: WizardAdapter = createMemoryAdapter() +let auth: WizardAuth | null = null + +export const getAdapter = (): WizardAdapter => adapter + +export const setAdapter = (nextAdapter: WizardAdapter): void => { + adapter = nextAdapter + auth = null +} + +export const resetAdapter = (): void => { + adapter = createMemoryAdapter() + auth = null +} + +export const loadAuth = async (): Promise => { + auth ??= await adapter.getAuth() + return auth +} + +export const getAuth = (): WizardAuth => auth ?? loggedOut diff --git a/src/lib/index.ts b/src/lib/index.ts index 3069cf6..256f713 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1 +1,6 @@ +export { + type StorageAdapter, + type WizardAdapter, + type WizardAuth, +} 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 ba8ae09..7f2a9ed 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 { createMemoryAdapter, getAuth, resetAdapter } from './adapter.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(resetAdapter) + const captureOutput = async ( options: Parameters[0], ): Promise => { @@ -70,3 +73,38 @@ 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 adapter it is given', async () => { + const adapter = createMemoryAdapter({ + auth: { + endpoint: 'https://connect.example.com', + apiKey: 'seam_apikey1_token', + workspaceId: 'workspace-1', + }, + }) + + await wizard({ argv: [], adapter }) + + expect(getAuth()).toMatchObject({ endpoint: 'https://connect.example.com' }) +}) + +test('wizard: runs logged out when it is given no adapter', async () => { + await wizard({ argv: [] }) + + expect(getAuth().apiKey).toBeNull() +}) + +test('wizard: asks no adapter anything to display usage', async () => { + const getAuthSpy = vi.fn(async () => ({ + endpoint: 'https://connect.example.com', + apiKey: null, + workspaceId: null, + })) + + await captureOutput({ + argv: ['--help'], + adapter: { ...createMemoryAdapter(), getAuth: getAuthSpy }, + }) + + expect(getAuthSpy).not.toHaveBeenCalled() +}) diff --git a/src/lib/wizard.ts b/src/lib/wizard.ts index c1914df..e68ecf3 100644 --- a/src/lib/wizard.ts +++ b/src/lib/wizard.ts @@ -1,5 +1,6 @@ import parseArgs from 'minimist' +import { loadAuth, setAdapter, type WizardAdapter } from './adapter.js' import { renderApp } from './render.js' import seamapiWizardVersion from './version.js' @@ -29,6 +30,8 @@ export interface WizardOptions { * `.seam/onboarding.json`. */ cwd?: string + + adapter?: WizardAdapter } /** @@ -43,6 +46,7 @@ export interface WizardOptions { */ const wizard = async (options: WizardOptions = {}): Promise => { const { argv = [], commandName = 'wizard', cwd = process.cwd() } = options + if (options.adapter != null) setAdapter(options.adapter) const args = parseArgs([...argv], { boolean: ['help', 'version'], @@ -59,6 +63,8 @@ const wizard = async (options: WizardOptions = {}): Promise => { return } + await loadAuth() + await renderApp({ root: cwd }) }