From 95d4193e65bff445b9a27325d668e44160f44fc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 21:55:16 +0000 Subject: [PATCH 01/20] fix: Correct logout, memoize config store, remove dead code - logout now deletes the per-server token key it stores tokens under, along with the legacy un-namespaced token and the current workspace. - getConfigStore() is memoized so reads no longer construct three Configstores and re-run the legacy migration on every call. - Remove unused get-current-workspace-id.ts. - Move the INSIDE_WEB_BROWSER read into env.ts so every environment variable is read in one place. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- src/bin/cli.ts | 9 ++++---- src/lib/config/config-store.ts | 16 +++++++++++-- src/lib/config/index.ts | 6 ++++- src/lib/env.ts | 7 ++++++ src/lib/get-current-workspace-id.ts | 9 -------- src/lib/interact-for-login.ts | 2 +- test/cli.test.ts | 36 +++++++++++++++++++++++++++++ 7 files changed, 68 insertions(+), 17 deletions(-) delete mode 100644 src/lib/get-current-workspace-id.ts diff --git a/src/bin/cli.ts b/src/bin/cli.ts index dd890394..862d82e1 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -19,6 +19,7 @@ import { getEndpointFromEnv, getTokenFromEnv, getWorkspaceIdFromEnv, + isInsideWebBrowser, tokenEnvVar, workspaceIdEnvVar, } from 'lib/env.js' @@ -242,6 +243,9 @@ async function cli(args: ParsedArgs) { } else if (isEqual(selectedCommand, ['logout'])) { assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log out') config.delete(`${getServer()}.pat`) + // Configs written before tokens were stored per server may still hold an + // un-namespaced token, so drop that too. + config.delete('pat') config.delete('current_workspace_id') output.info('Logged out!') return @@ -407,10 +411,7 @@ const handleConnectWebviewResponse = async ( ) => { const url = connectWebview.url - if ( - interactivity !== 'non-interactive' && - process.env['INSIDE_WEB_BROWSER'] !== '1' - ) { + if (interactivity !== 'non-interactive' && !isInsideWebBrowser()) { const action = await promptConfirm({ message: 'Would you like to open the webview in your browser?', initialValue: false, diff --git a/src/lib/config/config-store.ts b/src/lib/config/config-store.ts index 4223dc62..f0fbc8ac 100644 --- a/src/lib/config/config-store.ts +++ b/src/lib/config/config-store.ts @@ -11,7 +11,19 @@ const currentWorkspaceIdKey = 'current_workspace_id' const patKey = 'pat' const paths = envPaths('seam', { suffix: '' }) -export const getConfigStore = () => { +let configStore: SeamConfigStore | null = null + +export const getConfigStore = (): SeamConfigStore => { + configStore ??= createConfigStore() + return configStore +} + +/** Drop the memoized store so a test may read a fresh one. */ +export const resetConfigStore = (): void => { + configStore = null +} + +const createConfigStore = (): SeamConfigStore => { const settingsStore = new Configstore(legacyConfigStoreId, undefined, { configPath: getConfigPath(), }) @@ -79,7 +91,7 @@ export const splitConfig = ( return { settings, state } } -class SeamConfigStore { +export class SeamConfigStore { readonly path: string constructor( diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index b47a2819..173ec7a9 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -1 +1,5 @@ -export { getConfigStore } from './config-store.js' +export { + getConfigStore, + resetConfigStore, + type SeamConfigStore, +} from './config-store.js' diff --git a/src/lib/env.ts b/src/lib/env.ts index a109ec1b..1b48f926 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -52,6 +52,13 @@ export const assertEnvVarUnset = ( ) } +/** + * Whether the CLI runs inside a hosted web terminal, where it cannot open + * anything in a browser of its own. + */ +export const isInsideWebBrowser = (): boolean => + process.env['INSIDE_WEB_BROWSER'] === '1' + const readEnvVar = (envVar: SeamCliEnvVar): string | null => { const value = process.env[envVar] diff --git a/src/lib/get-current-workspace-id.ts b/src/lib/get-current-workspace-id.ts deleted file mode 100644 index 1ec2113d..00000000 --- a/src/lib/get-current-workspace-id.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getWorkspaceId } from './get-credentials.js' -import { interactForWorkspaceId } from './interact-for-workspace-id.js' - -export const getCurrentWorkspaceId = async (): Promise => { - const currentWorkspaceId = getWorkspaceId() - if (currentWorkspaceId != null) return currentWorkspaceId - - return await interactForWorkspaceId() -} diff --git a/src/lib/interact-for-login.ts b/src/lib/interact-for-login.ts index 90775a7d..1c007c9c 100644 --- a/src/lib/interact-for-login.ts +++ b/src/lib/interact-for-login.ts @@ -11,7 +11,7 @@ import { withLoading } from './util/with-loading.js' import { validateToken } from './validate-token.js' export const interactForLogin = async () => { - const config = await getConfigStore() + const config = getConfigStore() const output = getOutput() assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in') diff --git a/test/cli.test.ts b/test/cli.test.ts index 2702aa8f..637a6ef4 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -474,6 +474,42 @@ test('cli: refuses to select a server while SEAM_CLI_ENDPOINT is set', async () ) }) +test('cli: logout removes the stored token and workspace', async () => { + // A dedicated state home: logging out of the shared one would break + // every test that runs after this one. + const logoutStateHome = join(await mkdtemp(join(tmpdir(), 'seam-cli-test-'))) + await mkdir(join(logoutStateHome, 'seam'), { recursive: true }) + const stateFile = join(logoutStateHome, 'seam', 'cli.json') + await writeFile( + stateFile, + JSON.stringify({ + [endpoint]: { pat: 'seam_apikey1_token' }, + // A token stored before tokens were kept per server. + pat: 'seam_apikey1_legacy', + current_workspace_id: 'workspace1', + }), + ) + + // Info messages only print in text format, so ask for it explicitly. + const { stderr, exitCode } = await runCli(['logout', '--no-json'], { + stateHome: logoutStateHome, + }) + + expect(exitCode).toBe(0) + expect(stderr).toContain('Logged out!') + + const state = JSON.parse(await readFile(stateFile, 'utf8')) + expect(state[endpoint]?.pat).toBeUndefined() + expect(state.pat).toBeUndefined() + expect(state.current_workspace_id).toBeUndefined() + + const next = await runCli(['devices', 'list'], { + stateHome: logoutStateHome, + }) + expect(next.exitCode).toBe(1) + expect(next.stderr).toContain('Not logged in') +}) + test('cli: refuses to log out while SEAM_CLI_TOKEN is set', async () => { const { stderr, exitCode } = await runCli(['logout'], { env: { SEAM_CLI_TOKEN: 'seam_apikey1_from_env' }, From 702098cafecf2aa1b599c06ac34c8336a6a348c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 22:07:59 +0000 Subject: [PATCH 02/20] refactor: Move modules into single-responsibility layer directories Pure moves with import updates only, no logic changes: - util/cli-args.ts -> args/parse.ts (CLI argument definition + parsing) - interact-for-*.ts -> interact/ (interactive prompting UX) - render-help.ts -> render/help.ts, completion/ -> render/completion/ (presentation renderers over the command spec) - validate-token.ts -> auth/ - util/read-stdin-json.ts -> output/ (stdin/stdout/stderr layer) - get-seam.ts -> seam/client.ts, util/request-seam-api.ts -> seam/request.ts (SDK init + request layer) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- prepack.ts | 2 +- src/bin/cli.ts | 52 +++++++++---------- .../cli-args.test.ts => args/parse.test.ts} | 2 +- src/lib/{util/cli-args.ts => args/parse.ts} | 0 src/lib/{ => auth}/validate-token.ts | 2 +- .../interact-for-access-code.ts | 2 +- .../interact-for-acs-entrance.ts | 2 +- .../{ => interact}/interact-for-acs-system.ts | 2 +- .../{ => interact}/interact-for-acs-user.ts | 2 +- .../interact-for-action-attempt-poll.ts | 8 +-- src/lib/{ => interact}/interact-for-array.ts | 4 +- .../interact-for-blueprint-object.test.ts | 12 ++--- .../interact-for-blueprint-object.ts | 10 ++-- .../interact-for-command-params.ts | 4 +- .../interact-for-command-selection.test.ts | 8 +-- .../interact-for-command-selection.ts | 6 +-- .../interact-for-connected-account.ts | 2 +- .../interact-for-custom-metadata.test.ts | 10 ++-- .../interact-for-custom-metadata.ts | 4 +- src/lib/{ => interact}/interact-for-device.ts | 2 +- src/lib/{ => interact}/interact-for-login.ts | 14 ++--- .../{ => interact}/interact-for-resource.ts | 4 +- .../interact-for-server-selection.ts | 10 ++-- .../{ => interact}/interact-for-timestamp.ts | 2 +- .../interact-for-use-remote-api-defs.ts | 6 +-- .../interact-for-user-identity.ts | 2 +- .../interact-for-workspace-id.ts | 12 ++--- .../{util => output}/read-stdin-json.test.ts | 0 src/lib/{util => output}/read-stdin-json.ts | 0 .../completion/completion.test.ts | 2 +- src/lib/{ => render}/completion/describe.ts | 4 +- src/lib/{ => render}/completion/index.ts | 2 +- .../{ => render}/completion/render-bash.ts | 2 +- .../{ => render}/completion/render-fish.ts | 2 +- src/lib/{ => render}/completion/render-zsh.ts | 2 +- .../help.test.ts} | 6 +-- src/lib/{render-help.ts => render/help.ts} | 2 +- src/lib/{get-seam.ts => seam/client.ts} | 6 +-- .../request-seam-api.ts => seam/request.ts} | 4 +- src/lib/types.ts | 2 +- src/lib/util/prompt.ts | 2 +- 41 files changed, 111 insertions(+), 111 deletions(-) rename src/lib/{util/cli-args.test.ts => args/parse.test.ts} (99%) rename src/lib/{util/cli-args.ts => args/parse.ts} (100%) rename src/lib/{ => auth}/validate-token.ts (94%) rename src/lib/{ => interact}/interact-for-access-code.ts (94%) rename src/lib/{ => interact}/interact-for-acs-entrance.ts (91%) rename src/lib/{ => interact}/interact-for-acs-system.ts (91%) rename src/lib/{ => interact}/interact-for-acs-user.ts (93%) rename src/lib/{ => interact}/interact-for-action-attempt-poll.ts (82%) rename src/lib/{ => interact}/interact-for-array.ts (95%) rename src/lib/{ => interact}/interact-for-blueprint-object.test.ts (96%) rename src/lib/{ => interact}/interact-for-blueprint-object.ts (97%) rename src/lib/{ => interact}/interact-for-command-params.ts (79%) rename src/lib/{ => interact}/interact-for-command-selection.test.ts (90%) rename src/lib/{ => interact}/interact-for-command-selection.ts (96%) rename src/lib/{ => interact}/interact-for-connected-account.ts (94%) rename src/lib/{ => interact}/interact-for-custom-metadata.test.ts (88%) rename src/lib/{ => interact}/interact-for-custom-metadata.ts (97%) rename src/lib/{ => interact}/interact-for-device.ts (90%) rename src/lib/{ => interact}/interact-for-login.ts (82%) rename src/lib/{ => interact}/interact-for-resource.ts (87%) rename src/lib/{ => interact}/interact-for-server-selection.ts (85%) rename src/lib/{ => interact}/interact-for-timestamp.ts (89%) rename src/lib/{ => interact}/interact-for-use-remote-api-defs.ts (75%) rename src/lib/{ => interact}/interact-for-user-identity.ts (92%) rename src/lib/{ => interact}/interact-for-workspace-id.ts (79%) rename src/lib/{util => output}/read-stdin-json.test.ts (100%) rename src/lib/{util => output}/read-stdin-json.ts (100%) rename src/lib/{ => render}/completion/completion.test.ts (98%) rename src/lib/{ => render}/completion/describe.ts (85%) rename src/lib/{ => render}/completion/index.ts (98%) rename src/lib/{ => render}/completion/render-bash.ts (99%) rename src/lib/{ => render}/completion/render-fish.ts (97%) rename src/lib/{ => render}/completion/render-zsh.ts (99%) rename src/lib/{render-help.test.ts => render/help.test.ts} (95%) rename src/lib/{render-help.ts => render/help.ts} (99%) rename src/lib/{get-seam.ts => seam/client.ts} (89%) rename src/lib/{util/request-seam-api.ts => seam/request.ts} (92%) diff --git a/prepack.ts b/prepack.ts index b5a9bc84..71a9a5ca 100644 --- a/prepack.ts +++ b/prepack.ts @@ -8,7 +8,7 @@ import { completionFileNames, completionShells, renderCompletionStub, -} from './src/lib/completion/index.js' +} from './src/lib/render/completion/index.js' const versionFile = './src/lib/version.ts' const completionsDirectory = './completions' diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 862d82e1..7d307709 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -5,12 +5,18 @@ import { isDeepStrictEqual as isEqual } from 'node:util' import chalk from 'chalk' import type { ParsedArgs } from 'minimist' -import { findLocalCommand, getCommandSpec } from 'lib/command-spec.js' import { - completionShells, - isCompletionShell, - renderCompletion, -} from 'lib/completion/index.js' + cliFlags, + getInteractivity, + type Interactivity, + NonInteractiveError, + parseCliArgs, + toGivenArgName, + toParameterName, + UsageError, +} from 'lib/args/parse.js' +import { validateToken } from 'lib/auth/validate-token.js' +import { findLocalCommand, getCommandSpec } from 'lib/command-spec.js' import { getConfigStore } from 'lib/config/index.js' import { assertEnvVarUnset, @@ -28,36 +34,30 @@ import { getCommandBlueprintDef } from 'lib/get-command-blueprint-def.js' import { getToken } from 'lib/get-credentials.js' import { getResponseKey } from 'lib/get-response-key.js' import { getServer } from 'lib/get-server.js' -import { interactForActionAttemptPoll } from 'lib/interact-for-action-attempt-poll.js' -import { interactForCommandParams } from 'lib/interact-for-command-params.js' -import { interactForCommandSelection } from 'lib/interact-for-command-selection.js' -import { interactForLogin } from 'lib/interact-for-login.js' -import { interactForServerSelection } from 'lib/interact-for-server-selection.js' -import { interactForUseRemoteApiDefs } from 'lib/interact-for-use-remote-api-defs.js' -import { interactForWorkspaceId } from 'lib/interact-for-workspace-id.js' +import { interactForActionAttemptPoll } from 'lib/interact/interact-for-action-attempt-poll.js' +import { interactForCommandParams } from 'lib/interact/interact-for-command-params.js' +import { interactForCommandSelection } from 'lib/interact/interact-for-command-selection.js' +import { interactForLogin } from 'lib/interact/interact-for-login.js' +import { interactForServerSelection } from 'lib/interact/interact-for-server-selection.js' +import { interactForUseRemoteApiDefs } from 'lib/interact/interact-for-use-remote-api-defs.js' +import { interactForWorkspaceId } from 'lib/interact/interact-for-workspace-id.js' import { createOutput } from 'lib/output/create-output.js' import { getOutput, setOutput } from 'lib/output/get-output.js' +import { readStdinJson } from 'lib/output/read-stdin-json.js' import { resolveOutputFormat } from 'lib/output/resolve-output-format.js' -import { renderHelp } from 'lib/render-help.js' -import type { ContextHelpers } from 'lib/types.js' import { - cliFlags, - getInteractivity, - type Interactivity, - NonInteractiveError, - parseCliArgs, - toGivenArgName, - toParameterName, - UsageError, -} from 'lib/util/cli-args.js' + completionShells, + isCompletionShell, + renderCompletion, +} from 'lib/render/completion/index.js' +import { renderHelp } from 'lib/render/help.js' +import { RequestSeamApi } from 'lib/seam/request.js' +import type { ContextHelpers } from 'lib/types.js' import { canPrompt, PromptCancelledError, promptConfirm, } from 'lib/util/prompt.js' -import { readStdinJson } from 'lib/util/read-stdin-json.js' -import { RequestSeamApi } from 'lib/util/request-seam-api.js' -import { validateToken } from 'lib/validate-token.js' import seamapiCliVersion from 'lib/version.js' async function cli(args: ParsedArgs) { diff --git a/src/lib/util/cli-args.test.ts b/src/lib/args/parse.test.ts similarity index 99% rename from src/lib/util/cli-args.test.ts rename to src/lib/args/parse.test.ts index 8c6ca1a5..17007b5f 100644 --- a/src/lib/util/cli-args.test.ts +++ b/src/lib/args/parse.test.ts @@ -7,7 +7,7 @@ import { toArgName, toGivenArgName, toParameterName, -} from './cli-args.js' +} from './parse.js' // The CLI normalizes argument keys before checking them. const parse = (argv: string[]): ParsedArgs => { diff --git a/src/lib/util/cli-args.ts b/src/lib/args/parse.ts similarity index 100% rename from src/lib/util/cli-args.ts rename to src/lib/args/parse.ts diff --git a/src/lib/validate-token.ts b/src/lib/auth/validate-token.ts similarity index 94% rename from src/lib/validate-token.ts rename to src/lib/auth/validate-token.ts index df8e4177..de1d2cd0 100644 --- a/src/lib/validate-token.ts +++ b/src/lib/auth/validate-token.ts @@ -5,7 +5,7 @@ import { SeamHttpWithoutWorkspace, } from '@seamapi/http/connect' -import { getServer } from './get-server.js' +import { getServer } from '../get-server.js' export const validateToken = async (token: string, workspaceId?: string) => { const options = { endpoint: getServer() } diff --git a/src/lib/interact-for-access-code.ts b/src/lib/interact/interact-for-access-code.ts similarity index 94% rename from src/lib/interact-for-access-code.ts rename to src/lib/interact/interact-for-access-code.ts index caa707ba..63427b9b 100644 --- a/src/lib/interact-for-access-code.ts +++ b/src/lib/interact/interact-for-access-code.ts @@ -1,4 +1,4 @@ -import { getSeam } from './get-seam.js' +import { getSeam } from '../seam/client.js' import { interactForDevice } from './interact-for-device.js' import { interactForResource } from './interact-for-resource.js' diff --git a/src/lib/interact-for-acs-entrance.ts b/src/lib/interact/interact-for-acs-entrance.ts similarity index 91% rename from src/lib/interact-for-acs-entrance.ts rename to src/lib/interact/interact-for-acs-entrance.ts index 9f22970a..d086d68a 100644 --- a/src/lib/interact-for-acs-entrance.ts +++ b/src/lib/interact/interact-for-acs-entrance.ts @@ -1,4 +1,4 @@ -import { getSeam } from './get-seam.js' +import { getSeam } from '../seam/client.js' import { interactForResource } from './interact-for-resource.js' export const interactForAcsEntrance = async () => { diff --git a/src/lib/interact-for-acs-system.ts b/src/lib/interact/interact-for-acs-system.ts similarity index 91% rename from src/lib/interact-for-acs-system.ts rename to src/lib/interact/interact-for-acs-system.ts index 87d25537..be1929c9 100644 --- a/src/lib/interact-for-acs-system.ts +++ b/src/lib/interact/interact-for-acs-system.ts @@ -1,4 +1,4 @@ -import { getSeam } from './get-seam.js' +import { getSeam } from '../seam/client.js' import { interactForResource } from './interact-for-resource.js' export const interactForAcsSystem = async (message?: string) => { diff --git a/src/lib/interact-for-acs-user.ts b/src/lib/interact/interact-for-acs-user.ts similarity index 93% rename from src/lib/interact-for-acs-user.ts rename to src/lib/interact/interact-for-acs-user.ts index bfb49d15..ea6b9b62 100644 --- a/src/lib/interact-for-acs-user.ts +++ b/src/lib/interact/interact-for-acs-user.ts @@ -1,4 +1,4 @@ -import { getSeam } from './get-seam.js' +import { getSeam } from '../seam/client.js' import { interactForAcsSystem } from './interact-for-acs-system.js' import { interactForResource } from './interact-for-resource.js' diff --git a/src/lib/interact-for-action-attempt-poll.ts b/src/lib/interact/interact-for-action-attempt-poll.ts similarity index 82% rename from src/lib/interact-for-action-attempt-poll.ts rename to src/lib/interact/interact-for-action-attempt-poll.ts index 1903a6d0..d4dc8764 100644 --- a/src/lib/interact-for-action-attempt-poll.ts +++ b/src/lib/interact/interact-for-action-attempt-poll.ts @@ -1,9 +1,9 @@ import type { ActionAttemptsGetResponse } from '@seamapi/http/connect' -import { getSeam } from './get-seam.js' -import { getOutput } from './output/get-output.js' -import { promptConfirm } from './util/prompt.js' -import { withLoading } from './util/with-loading.js' +import { getSeam } from '../seam/client.js' +import { getOutput } from '../output/get-output.js' +import { promptConfirm } from '../util/prompt.js' +import { withLoading } from '../util/with-loading.js' export const interactForActionAttemptPoll = async ( actionAttempt: ActionAttemptsGetResponse['action_attempt'], diff --git a/src/lib/interact-for-array.ts b/src/lib/interact/interact-for-array.ts similarity index 95% rename from src/lib/interact-for-array.ts rename to src/lib/interact/interact-for-array.ts index 34a5b8f8..2d2e4879 100644 --- a/src/lib/interact-for-array.ts +++ b/src/lib/interact/interact-for-array.ts @@ -1,11 +1,11 @@ -import { getOutput } from './output/get-output.js' +import { getOutput } from '../output/get-output.js' import { PromptCancelledError, promptNumber, promptSelect, promptText, withBackHint, -} from './util/prompt.js' +} from '../util/prompt.js' export const interactForArray = async ( array: string[], diff --git a/src/lib/interact-for-blueprint-object.test.ts b/src/lib/interact/interact-for-blueprint-object.test.ts similarity index 96% rename from src/lib/interact-for-blueprint-object.test.ts rename to src/lib/interact/interact-for-blueprint-object.test.ts index baa949d7..a498dbc5 100644 --- a/src/lib/interact-for-blueprint-object.test.ts +++ b/src/lib/interact/interact-for-blueprint-object.test.ts @@ -2,21 +2,21 @@ import type { Parameter } from '@seamapi/blueprint' import { beforeEach, expect, test, vi } from 'vitest' import { interactForBlueprintObject } from './interact-for-blueprint-object.js' -import { createMemoryOutput } from './output/create-memory-output.js' -import { setOutput } from './output/get-output.js' -import type { ContextHelpers } from './types.js' -import type * as PromptModule from './util/prompt.js' +import { createMemoryOutput } from '../output/create-memory-output.js' +import { setOutput } from '../output/get-output.js' +import type { ContextHelpers } from '../types.js' +import type * as PromptModule from '../util/prompt.js' import { promptAutocomplete, PromptCancelledError, promptSelect, promptText, withBackHint, -} from './util/prompt.js' +} from '../util/prompt.js' // Only the prompts themselves are replaced, so the real PromptCancelledError // and withBackHint are used, as they are in production. -vi.mock('./util/prompt.js', async (importOriginal) => ({ +vi.mock('../util/prompt.js', async (importOriginal) => ({ ...(await importOriginal()), promptText: vi.fn(), promptNumber: vi.fn(), diff --git a/src/lib/interact-for-blueprint-object.ts b/src/lib/interact/interact-for-blueprint-object.ts similarity index 97% rename from src/lib/interact-for-blueprint-object.ts rename to src/lib/interact/interact-for-blueprint-object.ts index 5f4e173f..6a15a39a 100644 --- a/src/lib/interact-for-blueprint-object.ts +++ b/src/lib/interact/interact-for-blueprint-object.ts @@ -1,5 +1,9 @@ import type { Parameter } from '@seamapi/blueprint' +import { NonInteractiveError, toArgName } from '../args/parse.js' +import { getOutput } from '../output/get-output.js' +import type { ContextHelpers } from '../types.js' +import { ellipsis } from '../util/ellipsis.js' import { interactForAccessCode } from './interact-for-access-code.js' import { interactForAcsEntrance } from './interact-for-acs-entrance.js' import { interactForAcsSystem } from './interact-for-acs-system.js' @@ -10,10 +14,6 @@ import { interactForCustomMetadata } from './interact-for-custom-metadata.js' import { interactForDevice } from './interact-for-device.js' import { interactForTimestamp } from './interact-for-timestamp.js' import { interactForUserIdentity } from './interact-for-user-identity.js' -import { getOutput } from './output/get-output.js' -import type { ContextHelpers } from './types.js' -import { NonInteractiveError, toArgName } from './util/cli-args.js' -import { ellipsis } from './util/ellipsis.js' import { promptAutocomplete, promptAutocompleteMultiselect, @@ -23,7 +23,7 @@ import { promptSelect, promptText, withBackHint, -} from './util/prompt.js' +} from '../util/prompt.js' const ergonomicPropOrder = [ 'name', diff --git a/src/lib/interact-for-command-params.ts b/src/lib/interact/interact-for-command-params.ts similarity index 79% rename from src/lib/interact-for-command-params.ts rename to src/lib/interact/interact-for-command-params.ts index 4923496e..4c1f6f3e 100644 --- a/src/lib/interact-for-command-params.ts +++ b/src/lib/interact/interact-for-command-params.ts @@ -1,6 +1,6 @@ -import { getCommandBlueprintDef } from './get-command-blueprint-def.js' +import { getCommandBlueprintDef } from '../get-command-blueprint-def.js' +import type { ContextHelpers } from '../types.js' import { interactForBlueprintObject } from './interact-for-blueprint-object.js' -import type { ContextHelpers } from './types.js' export const interactForCommandParams = async ( args: { diff --git a/src/lib/interact-for-command-selection.test.ts b/src/lib/interact/interact-for-command-selection.test.ts similarity index 90% rename from src/lib/interact-for-command-selection.test.ts rename to src/lib/interact/interact-for-command-selection.test.ts index c6c40de9..d8ff8e28 100644 --- a/src/lib/interact-for-command-selection.test.ts +++ b/src/lib/interact/interact-for-command-selection.test.ts @@ -1,11 +1,11 @@ import { beforeEach, expect, test, vi } from 'vitest' import { interactForCommandSelection } from './interact-for-command-selection.js' -import type { ContextHelpers } from './types.js' -import type * as PromptModule from './util/prompt.js' -import { promptAutocomplete, withBackHint } from './util/prompt.js' +import type { ContextHelpers } from '../types.js' +import type * as PromptModule from '../util/prompt.js' +import { promptAutocomplete, withBackHint } from '../util/prompt.js' -vi.mock('./util/prompt.js', async (importOriginal) => ({ +vi.mock('../util/prompt.js', async (importOriginal) => ({ ...(await importOriginal()), promptAutocomplete: vi.fn(), })) diff --git a/src/lib/interact-for-command-selection.ts b/src/lib/interact/interact-for-command-selection.ts similarity index 96% rename from src/lib/interact-for-command-selection.ts rename to src/lib/interact/interact-for-command-selection.ts index ed030b12..42130b27 100644 --- a/src/lib/interact-for-command-selection.ts +++ b/src/lib/interact/interact-for-command-selection.ts @@ -1,12 +1,12 @@ import { isDeepStrictEqual as isEqual } from 'node:util' -import type { ContextHelpers } from './types.js' -import { NonInteractiveError } from './util/cli-args.js' +import type { ContextHelpers } from '../types.js' +import { NonInteractiveError } from '../args/parse.js' import { promptAutocomplete, PromptCancelledError, withBackHint, -} from './util/prompt.js' +} from '../util/prompt.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() diff --git a/src/lib/interact-for-connected-account.ts b/src/lib/interact/interact-for-connected-account.ts similarity index 94% rename from src/lib/interact-for-connected-account.ts rename to src/lib/interact/interact-for-connected-account.ts index 7673be54..c54fab08 100644 --- a/src/lib/interact-for-connected-account.ts +++ b/src/lib/interact/interact-for-connected-account.ts @@ -1,4 +1,4 @@ -import { getSeam } from './get-seam.js' +import { getSeam } from '../seam/client.js' import { interactForResource } from './interact-for-resource.js' export const interactForConnectedAccount = async () => { const seam = await getSeam() diff --git a/src/lib/interact-for-custom-metadata.test.ts b/src/lib/interact/interact-for-custom-metadata.test.ts similarity index 88% rename from src/lib/interact-for-custom-metadata.test.ts rename to src/lib/interact/interact-for-custom-metadata.test.ts index ec8cbd52..ef211b4b 100644 --- a/src/lib/interact-for-custom-metadata.test.ts +++ b/src/lib/interact/interact-for-custom-metadata.test.ts @@ -1,14 +1,14 @@ import { beforeEach, expect, test, vi } from 'vitest' import { interactForCustomMetadata } from './interact-for-custom-metadata.js' -import { createMemoryOutput } from './output/create-memory-output.js' -import { setOutput } from './output/get-output.js' -import type * as PromptModule from './util/prompt.js' -import { promptSelect, promptText } from './util/prompt.js' +import { createMemoryOutput } from '../output/create-memory-output.js' +import { setOutput } from '../output/get-output.js' +import type * as PromptModule from '../util/prompt.js' +import { promptSelect, promptText } from '../util/prompt.js' // Only the prompts themselves are replaced, so the real PromptCancelledError // and withBackHint are used, as they are in production. -vi.mock('./util/prompt.js', async (importOriginal) => ({ +vi.mock('../util/prompt.js', async (importOriginal) => ({ ...(await importOriginal()), promptText: vi.fn(), promptNumber: vi.fn(), diff --git a/src/lib/interact-for-custom-metadata.ts b/src/lib/interact/interact-for-custom-metadata.ts similarity index 97% rename from src/lib/interact-for-custom-metadata.ts rename to src/lib/interact/interact-for-custom-metadata.ts index dde3181c..a347d480 100644 --- a/src/lib/interact-for-custom-metadata.ts +++ b/src/lib/interact/interact-for-custom-metadata.ts @@ -1,10 +1,10 @@ -import { getOutput } from './output/get-output.js' +import { getOutput } from '../output/get-output.js' import { PromptCancelledError, promptSelect, promptText, withBackHint, -} from './util/prompt.js' +} from '../util/prompt.js' // Structurally the CustomMetadata of @seamapi/types, spelled out here so the // published declarations do not depend on a development-only package. diff --git a/src/lib/interact-for-device.ts b/src/lib/interact/interact-for-device.ts similarity index 90% rename from src/lib/interact-for-device.ts rename to src/lib/interact/interact-for-device.ts index 33ed6cee..8ee4b84f 100644 --- a/src/lib/interact-for-device.ts +++ b/src/lib/interact/interact-for-device.ts @@ -1,4 +1,4 @@ -import { getSeam } from './get-seam.js' +import { getSeam } from '../seam/client.js' import { interactForResource } from './interact-for-resource.js' export const interactForDevice = async () => { const seam = await getSeam() diff --git a/src/lib/interact-for-login.ts b/src/lib/interact/interact-for-login.ts similarity index 82% rename from src/lib/interact-for-login.ts rename to src/lib/interact/interact-for-login.ts index 1c007c9c..b14e6995 100644 --- a/src/lib/interact-for-login.ts +++ b/src/lib/interact/interact-for-login.ts @@ -1,14 +1,14 @@ import { isApiKey, isPersonalAccessToken } from '@seamapi/http/connect' import chalk from 'chalk' -import { getConfigStore } from './config/index.js' -import { assertEnvVarUnset, getTokenFromEnv, tokenEnvVar } from './env.js' -import { getServer } from './get-server.js' +import { validateToken } from '../auth/validate-token.js' +import { getConfigStore } from '../config/index.js' +import { assertEnvVarUnset, getTokenFromEnv, tokenEnvVar } from '../env.js' +import { getServer } from '../get-server.js' +import { getOutput } from '../output/get-output.js' +import { promptText } from '../util/prompt.js' +import { withLoading } from '../util/with-loading.js' import { interactForWorkspaceId } from './interact-for-workspace-id.js' -import { getOutput } from './output/get-output.js' -import { promptText } from './util/prompt.js' -import { withLoading } from './util/with-loading.js' -import { validateToken } from './validate-token.js' export const interactForLogin = async () => { const config = getConfigStore() diff --git a/src/lib/interact-for-resource.ts b/src/lib/interact/interact-for-resource.ts similarity index 87% rename from src/lib/interact-for-resource.ts rename to src/lib/interact/interact-for-resource.ts index 7438c11e..80552564 100644 --- a/src/lib/interact-for-resource.ts +++ b/src/lib/interact/interact-for-resource.ts @@ -1,5 +1,5 @@ -import { promptAutocomplete, withBackHint } from './util/prompt.js' -import { withLoading } from './util/with-loading.js' +import { promptAutocomplete, withBackHint } from '../util/prompt.js' +import { withLoading } from '../util/with-loading.js' export interface ResourceChoice { title: string diff --git a/src/lib/interact-for-server-selection.ts b/src/lib/interact/interact-for-server-selection.ts similarity index 85% rename from src/lib/interact-for-server-selection.ts rename to src/lib/interact/interact-for-server-selection.ts index 3859fcb1..360d2f78 100644 --- a/src/lib/interact-for-server-selection.ts +++ b/src/lib/interact/interact-for-server-selection.ts @@ -1,16 +1,16 @@ import { randomBytes } from 'node:crypto' -import { getConfigStore } from './config/index.js' +import { getConfigStore } from '../config/index.js' import { assertEnvVarUnset, endpointEnvVar, getEndpointFromEnv, getTokenFromEnv, tokenEnvVar, -} from './env.js' -import { getServer } from './get-server.js' -import { getOutput } from './output/get-output.js' -import { promptAutocomplete, promptText } from './util/prompt.js' +} from '../env.js' +import { getServer } from '../get-server.js' +import { getOutput } from '../output/get-output.js' +import { promptAutocomplete, promptText } from '../util/prompt.js' export async function interactForServerSelection() { assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server') diff --git a/src/lib/interact-for-timestamp.ts b/src/lib/interact/interact-for-timestamp.ts similarity index 89% rename from src/lib/interact-for-timestamp.ts rename to src/lib/interact/interact-for-timestamp.ts index 6ecf1164..573c9b44 100644 --- a/src/lib/interact-for-timestamp.ts +++ b/src/lib/interact/interact-for-timestamp.ts @@ -1,4 +1,4 @@ -import { promptText, withBackHint } from './util/prompt.js' +import { promptText, withBackHint } from '../util/prompt.js' export const interactForTimestamp = async () => { const now = new Date().toISOString() diff --git a/src/lib/interact-for-use-remote-api-defs.ts b/src/lib/interact/interact-for-use-remote-api-defs.ts similarity index 75% rename from src/lib/interact-for-use-remote-api-defs.ts rename to src/lib/interact/interact-for-use-remote-api-defs.ts index cd51a69e..0ed55df3 100644 --- a/src/lib/interact-for-use-remote-api-defs.ts +++ b/src/lib/interact/interact-for-use-remote-api-defs.ts @@ -1,6 +1,6 @@ -import { getConfigStore } from './config/index.js' -import { getOutput } from './output/get-output.js' -import { promptSelect } from './util/prompt.js' +import { getConfigStore } from '../config/index.js' +import { getOutput } from '../output/get-output.js' +import { promptSelect } from '../util/prompt.js' export async function interactForUseRemoteApiDefs() { const useRemoteApiDefs = await promptSelect({ diff --git a/src/lib/interact-for-user-identity.ts b/src/lib/interact/interact-for-user-identity.ts similarity index 92% rename from src/lib/interact-for-user-identity.ts rename to src/lib/interact/interact-for-user-identity.ts index 2403ffba..16b0a7b0 100644 --- a/src/lib/interact-for-user-identity.ts +++ b/src/lib/interact/interact-for-user-identity.ts @@ -1,4 +1,4 @@ -import { getSeam } from './get-seam.js' +import { getSeam } from '../seam/client.js' import { interactForResource } from './interact-for-resource.js' export const interactForUserIdentity = async () => { diff --git a/src/lib/interact-for-workspace-id.ts b/src/lib/interact/interact-for-workspace-id.ts similarity index 79% rename from src/lib/interact-for-workspace-id.ts rename to src/lib/interact/interact-for-workspace-id.ts index aeb42181..6239f6e4 100644 --- a/src/lib/interact-for-workspace-id.ts +++ b/src/lib/interact/interact-for-workspace-id.ts @@ -1,15 +1,15 @@ import { SeamHttpWithoutWorkspace } from '@seamapi/http/connect' -import { getConfigStore } from './config/index.js' +import { getConfigStore } from '../config/index.js' import { assertEnvVarUnset, getWorkspaceIdFromEnv, workspaceIdEnvVar, -} from './env.js' -import { getSeamMultiWorkspace } from './get-seam.js' -import { getServer } from './get-server.js' -import { promptAutocomplete } from './util/prompt.js' -import { withLoading } from './util/with-loading.js' +} from '../env.js' +import { getSeamMultiWorkspace } from '../seam/client.js' +import { getServer } from '../get-server.js' +import { promptAutocomplete } from '../util/prompt.js' +import { withLoading } from '../util/with-loading.js' export const interactForWorkspaceId = async (personalAccessToken?: string) => { const config = getConfigStore() diff --git a/src/lib/util/read-stdin-json.test.ts b/src/lib/output/read-stdin-json.test.ts similarity index 100% rename from src/lib/util/read-stdin-json.test.ts rename to src/lib/output/read-stdin-json.test.ts diff --git a/src/lib/util/read-stdin-json.ts b/src/lib/output/read-stdin-json.ts similarity index 100% rename from src/lib/util/read-stdin-json.ts rename to src/lib/output/read-stdin-json.ts diff --git a/src/lib/completion/completion.test.ts b/src/lib/render/completion/completion.test.ts similarity index 98% rename from src/lib/completion/completion.test.ts rename to src/lib/render/completion/completion.test.ts index f35cd478..657651b5 100644 --- a/src/lib/completion/completion.test.ts +++ b/src/lib/render/completion/completion.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest' -import { testBlueprint } from '../../../test/fixtures/blueprint.js' +import { testBlueprint } from '../../../../test/fixtures/blueprint.js' import { describeForShell } from './describe.js' import { completionScriptSentinels, diff --git a/src/lib/completion/describe.ts b/src/lib/render/completion/describe.ts similarity index 85% rename from src/lib/completion/describe.ts rename to src/lib/render/completion/describe.ts index 5eb038cb..f3112c0c 100644 --- a/src/lib/completion/describe.ts +++ b/src/lib/render/completion/describe.ts @@ -1,5 +1,5 @@ -import { firstSentence } from '../command-spec.js' -import { ellipsis } from '../util/ellipsis.js' +import { firstSentence } from '../../command-spec.js' +import { ellipsis } from '../../util/ellipsis.js' const maxDescriptionLength = 72 diff --git a/src/lib/completion/index.ts b/src/lib/render/completion/index.ts similarity index 98% rename from src/lib/completion/index.ts rename to src/lib/render/completion/index.ts index 171a49fd..3487c46b 100644 --- a/src/lib/completion/index.ts +++ b/src/lib/render/completion/index.ts @@ -1,6 +1,6 @@ import type { Blueprint } from '@seamapi/blueprint' -import { type CommandSpec, getCommandSpec } from '../command-spec.js' +import { type CommandSpec, getCommandSpec } from '../../command-spec.js' import { renderBashCompletion } from './render-bash.js' import { renderFishCompletion } from './render-fish.js' import { renderZshCompletion } from './render-zsh.js' diff --git a/src/lib/completion/render-bash.ts b/src/lib/render/completion/render-bash.ts similarity index 99% rename from src/lib/completion/render-bash.ts rename to src/lib/render/completion/render-bash.ts index 13278c0f..b56063c3 100644 --- a/src/lib/completion/render-bash.ts +++ b/src/lib/render/completion/render-bash.ts @@ -2,7 +2,7 @@ import { type CommandFlag, type CommandSpec, flagTokens, -} from '../command-spec.js' +} from '../../command-spec.js' export const renderBashCompletion = (spec: CommandSpec): string => { const globalTokens = spec.globalFlags.flatMap(flagTokens).sort() diff --git a/src/lib/completion/render-fish.ts b/src/lib/render/completion/render-fish.ts similarity index 97% rename from src/lib/completion/render-fish.ts rename to src/lib/render/completion/render-fish.ts index 937651ec..3dcd5751 100644 --- a/src/lib/completion/render-fish.ts +++ b/src/lib/render/completion/render-fish.ts @@ -1,4 +1,4 @@ -import type { CommandFlag, CommandSpec } from '../command-spec.js' +import type { CommandFlag, CommandSpec } from '../../command-spec.js' import { describeForShell } from './describe.js' export const renderFishCompletion = (spec: CommandSpec): string => diff --git a/src/lib/completion/render-zsh.ts b/src/lib/render/completion/render-zsh.ts similarity index 99% rename from src/lib/completion/render-zsh.ts rename to src/lib/render/completion/render-zsh.ts index e057c629..ef3bbd55 100644 --- a/src/lib/completion/render-zsh.ts +++ b/src/lib/render/completion/render-zsh.ts @@ -2,7 +2,7 @@ import { type CommandFlag, type CommandSpec, flagTokens, -} from '../command-spec.js' +} from '../../command-spec.js' import { describeForShell } from './describe.js' export const renderZshCompletion = (spec: CommandSpec): string => { diff --git a/src/lib/render-help.test.ts b/src/lib/render/help.test.ts similarity index 95% rename from src/lib/render-help.test.ts rename to src/lib/render/help.test.ts index 12142731..f11eb8cc 100644 --- a/src/lib/render-help.test.ts +++ b/src/lib/render/help.test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest' -import { testBlueprint } from '../../test/fixtures/blueprint.js' -import { getCommandSpec } from './command-spec.js' -import { renderHelp } from './render-help.js' +import { testBlueprint } from '../../../test/fixtures/blueprint.js' +import { getCommandSpec } from '../command-spec.js' +import { renderHelp } from '../render/help.js' const spec = getCommandSpec(testBlueprint) diff --git a/src/lib/render-help.ts b/src/lib/render/help.ts similarity index 99% rename from src/lib/render-help.ts rename to src/lib/render/help.ts index 5a54b01c..2b51a9a9 100644 --- a/src/lib/render-help.ts +++ b/src/lib/render/help.ts @@ -7,7 +7,7 @@ import { type CommandSpec, findCommand, findGroup, -} from './command-spec.js' +} from '../command-spec.js' /** * Render the help guide for a command path, or `null` when no command or diff --git a/src/lib/get-seam.ts b/src/lib/seam/client.ts similarity index 89% rename from src/lib/get-seam.ts rename to src/lib/seam/client.ts index 7d252433..0afacd13 100644 --- a/src/lib/get-seam.ts +++ b/src/lib/seam/client.ts @@ -5,9 +5,9 @@ import { SeamHttpWithoutWorkspace, } from '@seamapi/http/connect' -import { tokenEnvVar, workspaceIdEnvVar } from './env.js' -import { getToken, getWorkspaceId } from './get-credentials.js' -import { getServer } from './get-server.js' +import { tokenEnvVar, workspaceIdEnvVar } from '../env.js' +import { getToken, getWorkspaceId } from '../get-credentials.js' +import { getServer } from '../get-server.js' export const getSeam = async (): Promise => { const token = getRequiredToken() diff --git a/src/lib/util/request-seam-api.ts b/src/lib/seam/request.ts similarity index 92% rename from src/lib/util/request-seam-api.ts rename to src/lib/seam/request.ts index 54a73409..762e624e 100644 --- a/src/lib/util/request-seam-api.ts +++ b/src/lib/seam/request.ts @@ -1,10 +1,10 @@ import chalk from 'chalk' -import { getSeam } from 'lib/get-seam.js' import { getOutput } from 'lib/output/get-output.js' import { selectResponsePayload } from 'lib/output/select-response-payload.js' +import { getSeam } from 'lib/seam/client.js' -import { withLoading } from './with-loading.js' +import { withLoading } from '../util/with-loading.js' export interface RequestSeamApiOptions { path: string diff --git a/src/lib/types.ts b/src/lib/types.ts index 64a8d95c..d3a7e02e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,5 +1,5 @@ +import type { Interactivity } from './args/parse.js' import type { ApiBlueprint } from './get-api-blueprint.js' -import type { Interactivity } from './util/cli-args.js' export interface ContextHelpers { blueprint: ApiBlueprint diff --git a/src/lib/util/prompt.ts b/src/lib/util/prompt.ts index 1c84691e..b62d7478 100644 --- a/src/lib/util/prompt.ts +++ b/src/lib/util/prompt.ts @@ -12,7 +12,7 @@ import { } from '@clack/prompts' import chalk from 'chalk' -import { NonInteractiveError } from './cli-args.js' +import { NonInteractiveError } from '../args/parse.js' /** * Whether the CLI can ask the user a question. From b7e3506a55755a47e4018e6b7d7a639c948bce3c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 22:11:30 +0000 Subject: [PATCH 03/20] refactor: Split blueprint acquisition into source and cache modules - blueprint/source-npm.ts: fetch @seamapi/types from the npm registry, extract the OpenAPI module, and build a blueprint from it. - blueprint/cache.ts: the on-disk blueprint cache with TTL and version invalidation, atomic writes, and blueprint-version discovery. - blueprint/source-remote.ts: build a blueprint from the OpenAPI document served by the configured server. - blueprint/index.ts: getApiBlueprint source selector (was get-api-blueprint.ts). - blueprint/endpoint.ts: command path -> endpoint lookup and response key (was get-command-blueprint-def.ts + get-response-key.ts). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- src/bin/cli.ts | 8 +- src/lib/blueprint/cache.ts | 94 +++++++++++++++ .../endpoint.ts} | 18 ++- .../index.ts} | 14 +-- .../source-npm.test.ts} | 4 +- .../{blueprint.ts => blueprint/source-npm.ts} | 113 +++--------------- src/lib/blueprint/source-remote.ts | 17 +++ src/lib/get-command-blueprint-def.ts | 15 --- .../interact/interact-for-command-params.ts | 2 +- src/lib/types.ts | 2 +- 10 files changed, 156 insertions(+), 131 deletions(-) create mode 100644 src/lib/blueprint/cache.ts rename src/lib/{get-response-key.ts => blueprint/endpoint.ts} (56%) rename src/lib/{get-api-blueprint.ts => blueprint/index.ts} (55%) rename src/lib/{blueprint.test.ts => blueprint/source-npm.test.ts} (98%) rename src/lib/{blueprint.ts => blueprint/source-npm.ts} (62%) create mode 100644 src/lib/blueprint/source-remote.ts delete mode 100644 src/lib/get-command-blueprint-def.ts diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 7d307709..691b9a88 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -16,6 +16,11 @@ import { UsageError, } from 'lib/args/parse.js' import { validateToken } from 'lib/auth/validate-token.js' +import { + getCommandBlueprintDef, + getResponseKey, +} from 'lib/blueprint/endpoint.js' +import { getApiBlueprint } from 'lib/blueprint/index.js' import { findLocalCommand, getCommandSpec } from 'lib/command-spec.js' import { getConfigStore } from 'lib/config/index.js' import { @@ -29,10 +34,7 @@ import { tokenEnvVar, workspaceIdEnvVar, } from 'lib/env.js' -import { getApiBlueprint } from 'lib/get-api-blueprint.js' -import { getCommandBlueprintDef } from 'lib/get-command-blueprint-def.js' import { getToken } from 'lib/get-credentials.js' -import { getResponseKey } from 'lib/get-response-key.js' import { getServer } from 'lib/get-server.js' import { interactForActionAttemptPoll } from 'lib/interact/interact-for-action-attempt-poll.js' import { interactForCommandParams } from 'lib/interact/interact-for-command-params.js' diff --git a/src/lib/blueprint/cache.ts b/src/lib/blueprint/cache.ts new file mode 100644 index 00000000..68893963 --- /dev/null +++ b/src/lib/blueprint/cache.ts @@ -0,0 +1,94 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { Blueprint } from '@seamapi/blueprint' + +import { seamapiBlueprintVersion } from '../version.js' + +const cacheFileName = 'blueprint.json' +const updateCheckInterval = 24 * 60 * 60 * 1000 + +export interface BlueprintCache { + blueprintVersion: string + typesVersion: string + checkedAt: string + blueprint: Blueprint +} + +export const getCacheFile = (cacheDirectory: string): string => + join(cacheDirectory, cacheFileName) + +export const readCache = async ( + file: string, +): Promise => { + try { + const cache = JSON.parse(await readFile(file, 'utf8')) as unknown + if (!isBlueprintCache(cache)) return null + return cache + } catch { + return null + } +} + +export const writeCache = async ( + file: string, + cache: BlueprintCache, +): Promise => { + const temporaryFile = `${file}.tmp` + await mkdir(dirname(file), { recursive: true }) + await writeFile(temporaryFile, `${JSON.stringify(cache)}\n`, 'utf8') + await rename(temporaryFile, file) +} + +export const isUpdateCheckDue = (checkedAt: string): boolean => { + const checkedAtTime = Date.parse(checkedAt) + if (Number.isNaN(checkedAtTime)) return true + return Date.now() - checkedAtTime > updateCheckInterval +} + +/** + * The blueprint version the cache is keyed on: a cached blueprint built by a + * different @seamapi/blueprint version is stale even for the same types. + */ +export const getBlueprintVersion = async (): Promise => { + if (seamapiBlueprintVersion !== '0.0.0') return seamapiBlueprintVersion + + // The version is only injected when the package is packed, so a + // development checkout reads the pinned version from package.json + // to keep invalidating the cache on version changes as expected. + const pkg = await findOwnPackageJson() + return pkg?.dependencies?.['@seamapi/blueprint'] ?? seamapiBlueprintVersion +} + +const findOwnPackageJson = async (): Promise<{ + dependencies?: Record +} | null> => { + let directory = dirname(fileURLToPath(import.meta.url)) + while (true) { + try { + const pkg = JSON.parse( + await readFile(join(directory, 'package.json'), 'utf8'), + ) as { name?: string; dependencies?: Record } + if (pkg.name === '@seamapi/cli') return pkg + } catch { + // Keep walking up until a package.json for this package is found. + } + const parent = dirname(directory) + if (parent === directory) return null + directory = parent + } +} + +const isBlueprintCache = (cache: unknown): cache is BlueprintCache => { + if (cache == null || typeof cache !== 'object') return false + const { blueprintVersion, typesVersion, checkedAt, blueprint } = + cache as Record + return ( + typeof blueprintVersion === 'string' && + typeof typesVersion === 'string' && + typeof checkedAt === 'string' && + blueprint != null && + typeof blueprint === 'object' + ) +} diff --git a/src/lib/get-response-key.ts b/src/lib/blueprint/endpoint.ts similarity index 56% rename from src/lib/get-response-key.ts rename to src/lib/blueprint/endpoint.ts index 7f0cb1ef..25514beb 100644 --- a/src/lib/get-response-key.ts +++ b/src/lib/blueprint/endpoint.ts @@ -1,5 +1,19 @@ -import { getCommandBlueprintDef } from './get-command-blueprint-def.js' -import type { ContextHelpers } from './types.js' +import type { ContextHelpers } from '../types.js' + +export const getCommandBlueprintDef = ( + cmd: string[], + helpers: ContextHelpers, +) => { + const path = `/${cmd.join('/').replace(/-/g, '_')}` + const def = helpers.blueprint.routes + .flatMap((route) => route.endpoints) + .find((endpoint) => endpoint.path === path) + if (!def) { + throw new Error(`No definition for path ${path}`) + } + + return def +} /** * The top level response key documented for a command, diff --git a/src/lib/get-api-blueprint.ts b/src/lib/blueprint/index.ts similarity index 55% rename from src/lib/get-api-blueprint.ts rename to src/lib/blueprint/index.ts index 1568bf23..bc936636 100644 --- a/src/lib/get-api-blueprint.ts +++ b/src/lib/blueprint/index.ts @@ -1,7 +1,7 @@ import type { Blueprint } from '@seamapi/blueprint' -import getBlueprint from './blueprint.js' -import { getServer } from './get-server.js' +import { getBlueprint } from './source-npm.js' +import { createRemoteBlueprint } from './source-remote.js' export type ApiBlueprint = Blueprint @@ -19,13 +19,3 @@ export const getApiBlueprint = async ( return await getBlueprint(options) } - -const createRemoteBlueprint = async (): Promise => { - const [{ createBlueprint }, { getOpenapiSchema }] = await Promise.all([ - import('@seamapi/blueprint'), - import('@seamapi/http/connect'), - ]) - const openapi = await getOpenapiSchema(getServer()) - - return await createBlueprint({ openapi }, { omitUndocumented: true }) -} diff --git a/src/lib/blueprint.test.ts b/src/lib/blueprint/source-npm.test.ts similarity index 98% rename from src/lib/blueprint.test.ts rename to src/lib/blueprint/source-npm.test.ts index 1bda1454..7b60c968 100644 --- a/src/lib/blueprint.test.ts +++ b/src/lib/blueprint/source-npm.test.ts @@ -16,7 +16,7 @@ import { vi, } from 'vitest' -import getBlueprint from './blueprint.js' +import { getBlueprint } from './source-npm.js' const typesVersion = '1.985.0' const manifestUrl = 'https://registry.npmjs.org/@seamapi/types/latest' @@ -80,7 +80,7 @@ const hoursAgo = (hours: number): string => beforeAll(async () => { const pkg = JSON.parse( - await readFile(new URL('../../package.json', import.meta.url), 'utf8'), + await readFile(new URL('../../../package.json', import.meta.url), 'utf8'), ) as { dependencies: Record } pinnedBlueprintVersion = pkg.dependencies['@seamapi/blueprint'] ?? '' diff --git a/src/lib/blueprint.ts b/src/lib/blueprint/source-npm.ts similarity index 62% rename from src/lib/blueprint.ts rename to src/lib/blueprint/source-npm.ts index cb68a8de..8f92c798 100644 --- a/src/lib/blueprint.ts +++ b/src/lib/blueprint/source-npm.ts @@ -1,52 +1,45 @@ -import { - access, - mkdir, - readFile, - rename, - rm, - writeFile, -} from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { access, mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import type { Blueprint, TypesModuleInput } from '@seamapi/blueprint' import envPaths from 'env-paths' import { extract } from 'tar' -import { withLoading } from './util/with-loading.js' -import { seamapiBlueprintVersion } from './version.js' +import { withLoading } from '../util/with-loading.js' +import { + getBlueprintVersion, + getCacheFile, + isUpdateCheckDue, + readCache, + writeCache, +} from './cache.js' const typesPackageName = '@seamapi/types' const openapiTarEntryName = 'package/lib/seam/connect/openapi.js' const registryUrl = 'https://registry.npmjs.org' -const updateCheckInterval = 24 * 60 * 60 * 1000 - -const cacheFileName = 'blueprint.json' - -interface BlueprintCache { - blueprintVersion: string - typesVersion: string - checkedAt: string - blueprint: Blueprint -} interface TypesPackageManifest { version: string dist: { tarball: string } } -interface GetBlueprintOptions { +export interface GetBlueprintOptions { update?: boolean cacheDirectory?: string } -const getBlueprint = async ( +/** + * Build a blueprint from the latest published Seam API types on npm, + * using the on-disk cache unless it is stale or an update is forced. + */ +export const getBlueprint = async ( options: GetBlueprintOptions = {}, ): Promise => { const update = options.update ?? false const cacheDirectory = options.cacheDirectory ?? envPaths('seam', { suffix: '' }).cache - const cacheFile = join(cacheDirectory, cacheFileName) + const cacheFile = getCacheFile(cacheDirectory) const blueprintVersion = await getBlueprintVersion() const cache = await readCache(cacheFile) @@ -100,41 +93,6 @@ const getBlueprint = async ( return blueprint } -const getBlueprintVersion = async (): Promise => { - if (seamapiBlueprintVersion !== '0.0.0') return seamapiBlueprintVersion - - // The version is only injected when the package is packed, so a - // development checkout reads the pinned version from package.json - // to keep invalidating the cache on version changes as expected. - const pkg = await findOwnPackageJson() - return pkg?.dependencies?.['@seamapi/blueprint'] ?? seamapiBlueprintVersion -} - -const findOwnPackageJson = async (): Promise<{ - dependencies?: Record -} | null> => { - let directory = dirname(fileURLToPath(import.meta.url)) - while (true) { - try { - const pkg = JSON.parse( - await readFile(join(directory, 'package.json'), 'utf8'), - ) as { name?: string; dependencies?: Record } - if (pkg.name === '@seamapi/cli') return pkg - } catch { - // Keep walking up until a package.json for this package is found. - } - const parent = dirname(directory) - if (parent === directory) return null - directory = parent - } -} - -const isUpdateCheckDue = (checkedAt: string): boolean => { - const checkedAtTime = Date.parse(checkedAt) - if (Number.isNaN(checkedAtTime)) return true - return Date.now() - checkedAtTime > updateCheckInterval -} - const fetchLatestTypesPackageManifest = async (): Promise => { const res = await fetch(`${registryUrl}/${typesPackageName}/latest`, { @@ -214,40 +172,5 @@ const exists = async (file: string): Promise => { } } -const readCache = async (file: string): Promise => { - try { - const cache = JSON.parse(await readFile(file, 'utf8')) as unknown - if (!isBlueprintCache(cache)) return null - return cache - } catch { - return null - } -} - -const writeCache = async ( - file: string, - cache: BlueprintCache, -): Promise => { - const temporaryFile = `${file}.tmp` - await mkdir(dirname(file), { recursive: true }) - await writeFile(temporaryFile, `${JSON.stringify(cache)}\n`, 'utf8') - await rename(temporaryFile, file) -} - -const isBlueprintCache = (cache: unknown): cache is BlueprintCache => { - if (cache == null || typeof cache !== 'object') return false - const { blueprintVersion, typesVersion, checkedAt, blueprint } = - cache as Record - return ( - typeof blueprintVersion === 'string' && - typeof typesVersion === 'string' && - typeof checkedAt === 'string' && - blueprint != null && - typeof blueprint === 'object' - ) -} - const toErrorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error) - -export default getBlueprint diff --git a/src/lib/blueprint/source-remote.ts b/src/lib/blueprint/source-remote.ts new file mode 100644 index 00000000..4bc76ee7 --- /dev/null +++ b/src/lib/blueprint/source-remote.ts @@ -0,0 +1,17 @@ +import type { Blueprint } from '@seamapi/blueprint' + +import { getServer } from '../get-server.js' + +/** + * Build a blueprint from the OpenAPI document the current server is running, + * describing exactly what that server accepts rather than what is published. + */ +export const createRemoteBlueprint = async (): Promise => { + const [{ createBlueprint }, { getOpenapiSchema }] = await Promise.all([ + import('@seamapi/blueprint'), + import('@seamapi/http/connect'), + ]) + const openapi = await getOpenapiSchema(getServer()) + + return await createBlueprint({ openapi }, { omitUndocumented: true }) +} diff --git a/src/lib/get-command-blueprint-def.ts b/src/lib/get-command-blueprint-def.ts deleted file mode 100644 index 5d3178e4..00000000 --- a/src/lib/get-command-blueprint-def.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ContextHelpers } from './types.js' -export const getCommandBlueprintDef = ( - cmd: string[], - helpers: ContextHelpers, -) => { - const path = `/${cmd.join('/').replace(/-/g, '_')}` - const def = helpers.blueprint.routes - .flatMap((route) => route.endpoints) - .find((endpoint) => endpoint.path === path) - if (!def) { - throw new Error(`No definition for path ${path}`) - } - - return def -} diff --git a/src/lib/interact/interact-for-command-params.ts b/src/lib/interact/interact-for-command-params.ts index 4c1f6f3e..f1b0fd1f 100644 --- a/src/lib/interact/interact-for-command-params.ts +++ b/src/lib/interact/interact-for-command-params.ts @@ -1,4 +1,4 @@ -import { getCommandBlueprintDef } from '../get-command-blueprint-def.js' +import { getCommandBlueprintDef } from '../blueprint/endpoint.js' import type { ContextHelpers } from '../types.js' import { interactForBlueprintObject } from './interact-for-blueprint-object.js' diff --git a/src/lib/types.ts b/src/lib/types.ts index d3a7e02e..7c055f38 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,5 +1,5 @@ import type { Interactivity } from './args/parse.js' -import type { ApiBlueprint } from './get-api-blueprint.js' +import type { ApiBlueprint } from './blueprint/index.js' export interface ContextHelpers { blueprint: ApiBlueprint From a8de008ec77a31d62d96f17554ea20cd29d58a2b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 23:07:26 +0000 Subject: [PATCH 04/20] refactor: Dissolve util/ into the layers that own each module Types and helpers live with something real, not in grab-bag modules: - util/prompt.ts -> interact/prompt.ts: the prompting primitive is the foundation of the interaction layer. - util/with-loading.ts -> output/with-loading.ts: a stderr spinner gated on the output format is an output concern. - util/ellipsis.ts -> render/text.ts: text truncation for display belongs to the presentation layer (markdown helpers join it later). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- src/bin/cli.ts | 10 +++++----- src/lib/blueprint/source-npm.ts | 2 +- src/lib/interact/interact-for-action-attempt-poll.ts | 4 ++-- src/lib/interact/interact-for-array.ts | 2 +- src/lib/interact/interact-for-blueprint-object.test.ts | 6 +++--- src/lib/interact/interact-for-blueprint-object.ts | 4 ++-- .../interact/interact-for-command-selection.test.ts | 6 +++--- src/lib/interact/interact-for-command-selection.ts | 2 +- src/lib/interact/interact-for-custom-metadata.test.ts | 6 +++--- src/lib/interact/interact-for-custom-metadata.ts | 2 +- src/lib/interact/interact-for-login.ts | 4 ++-- src/lib/interact/interact-for-resource.ts | 4 ++-- src/lib/interact/interact-for-server-selection.ts | 2 +- src/lib/interact/interact-for-timestamp.ts | 2 +- src/lib/interact/interact-for-use-remote-api-defs.ts | 2 +- src/lib/interact/interact-for-workspace-id.ts | 4 ++-- src/lib/{util => interact}/prompt.test.ts | 0 src/lib/{util => interact}/prompt.ts | 0 src/lib/{util => output}/with-loading.ts | 2 +- src/lib/render/completion/describe.ts | 2 +- src/lib/{util/ellipsis.test.ts => render/text.test.ts} | 2 +- src/lib/{util/ellipsis.ts => render/text.ts} | 0 src/lib/seam/request.ts | 2 +- 23 files changed, 35 insertions(+), 35 deletions(-) rename src/lib/{util => interact}/prompt.test.ts (100%) rename src/lib/{util => interact}/prompt.ts (100%) rename src/lib/{util => output}/with-loading.ts (91%) rename src/lib/{util/ellipsis.test.ts => render/text.test.ts} (82%) rename src/lib/{util/ellipsis.ts => render/text.ts} (100%) diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 691b9a88..e6087ea1 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -43,6 +43,11 @@ import { interactForLogin } from 'lib/interact/interact-for-login.js' import { interactForServerSelection } from 'lib/interact/interact-for-server-selection.js' import { interactForUseRemoteApiDefs } from 'lib/interact/interact-for-use-remote-api-defs.js' import { interactForWorkspaceId } from 'lib/interact/interact-for-workspace-id.js' +import { + canPrompt, + PromptCancelledError, + promptConfirm, +} from 'lib/interact/prompt.js' import { createOutput } from 'lib/output/create-output.js' import { getOutput, setOutput } from 'lib/output/get-output.js' import { readStdinJson } from 'lib/output/read-stdin-json.js' @@ -55,11 +60,6 @@ import { import { renderHelp } from 'lib/render/help.js' import { RequestSeamApi } from 'lib/seam/request.js' import type { ContextHelpers } from 'lib/types.js' -import { - canPrompt, - PromptCancelledError, - promptConfirm, -} from 'lib/util/prompt.js' import seamapiCliVersion from 'lib/version.js' async function cli(args: ParsedArgs) { diff --git a/src/lib/blueprint/source-npm.ts b/src/lib/blueprint/source-npm.ts index 8f92c798..7cef431d 100644 --- a/src/lib/blueprint/source-npm.ts +++ b/src/lib/blueprint/source-npm.ts @@ -6,7 +6,7 @@ import type { Blueprint, TypesModuleInput } from '@seamapi/blueprint' import envPaths from 'env-paths' import { extract } from 'tar' -import { withLoading } from '../util/with-loading.js' +import { withLoading } from '../output/with-loading.js' import { getBlueprintVersion, getCacheFile, diff --git a/src/lib/interact/interact-for-action-attempt-poll.ts b/src/lib/interact/interact-for-action-attempt-poll.ts index d4dc8764..472531ac 100644 --- a/src/lib/interact/interact-for-action-attempt-poll.ts +++ b/src/lib/interact/interact-for-action-attempt-poll.ts @@ -2,8 +2,8 @@ import type { ActionAttemptsGetResponse } from '@seamapi/http/connect' import { getSeam } from '../seam/client.js' import { getOutput } from '../output/get-output.js' -import { promptConfirm } from '../util/prompt.js' -import { withLoading } from '../util/with-loading.js' +import { promptConfirm } from './prompt.js' +import { withLoading } from '../output/with-loading.js' export const interactForActionAttemptPoll = async ( actionAttempt: ActionAttemptsGetResponse['action_attempt'], diff --git a/src/lib/interact/interact-for-array.ts b/src/lib/interact/interact-for-array.ts index 2d2e4879..4c8ae342 100644 --- a/src/lib/interact/interact-for-array.ts +++ b/src/lib/interact/interact-for-array.ts @@ -5,7 +5,7 @@ import { promptSelect, promptText, withBackHint, -} from '../util/prompt.js' +} from './prompt.js' export const interactForArray = async ( array: string[], diff --git a/src/lib/interact/interact-for-blueprint-object.test.ts b/src/lib/interact/interact-for-blueprint-object.test.ts index a498dbc5..099aefe9 100644 --- a/src/lib/interact/interact-for-blueprint-object.test.ts +++ b/src/lib/interact/interact-for-blueprint-object.test.ts @@ -5,18 +5,18 @@ import { interactForBlueprintObject } from './interact-for-blueprint-object.js' import { createMemoryOutput } from '../output/create-memory-output.js' import { setOutput } from '../output/get-output.js' import type { ContextHelpers } from '../types.js' -import type * as PromptModule from '../util/prompt.js' +import type * as PromptModule from './prompt.js' import { promptAutocomplete, PromptCancelledError, promptSelect, promptText, withBackHint, -} from '../util/prompt.js' +} from './prompt.js' // Only the prompts themselves are replaced, so the real PromptCancelledError // and withBackHint are used, as they are in production. -vi.mock('../util/prompt.js', async (importOriginal) => ({ +vi.mock('./prompt.js', async (importOriginal) => ({ ...(await importOriginal()), promptText: vi.fn(), promptNumber: vi.fn(), diff --git a/src/lib/interact/interact-for-blueprint-object.ts b/src/lib/interact/interact-for-blueprint-object.ts index 6a15a39a..6f9367a6 100644 --- a/src/lib/interact/interact-for-blueprint-object.ts +++ b/src/lib/interact/interact-for-blueprint-object.ts @@ -2,8 +2,8 @@ import type { Parameter } from '@seamapi/blueprint' import { NonInteractiveError, toArgName } from '../args/parse.js' import { getOutput } from '../output/get-output.js' +import { ellipsis } from '../render/text.js' import type { ContextHelpers } from '../types.js' -import { ellipsis } from '../util/ellipsis.js' import { interactForAccessCode } from './interact-for-access-code.js' import { interactForAcsEntrance } from './interact-for-acs-entrance.js' import { interactForAcsSystem } from './interact-for-acs-system.js' @@ -23,7 +23,7 @@ import { promptSelect, promptText, withBackHint, -} from '../util/prompt.js' +} from './prompt.js' const ergonomicPropOrder = [ 'name', diff --git a/src/lib/interact/interact-for-command-selection.test.ts b/src/lib/interact/interact-for-command-selection.test.ts index d8ff8e28..9d174cce 100644 --- a/src/lib/interact/interact-for-command-selection.test.ts +++ b/src/lib/interact/interact-for-command-selection.test.ts @@ -2,10 +2,10 @@ import { beforeEach, expect, test, vi } from 'vitest' import { interactForCommandSelection } from './interact-for-command-selection.js' import type { ContextHelpers } from '../types.js' -import type * as PromptModule from '../util/prompt.js' -import { promptAutocomplete, withBackHint } from '../util/prompt.js' +import type * as PromptModule from './prompt.js' +import { promptAutocomplete, withBackHint } from './prompt.js' -vi.mock('../util/prompt.js', async (importOriginal) => ({ +vi.mock('./prompt.js', async (importOriginal) => ({ ...(await importOriginal()), promptAutocomplete: vi.fn(), })) diff --git a/src/lib/interact/interact-for-command-selection.ts b/src/lib/interact/interact-for-command-selection.ts index 42130b27..666ba4df 100644 --- a/src/lib/interact/interact-for-command-selection.ts +++ b/src/lib/interact/interact-for-command-selection.ts @@ -6,7 +6,7 @@ import { promptAutocomplete, PromptCancelledError, withBackHint, -} from '../util/prompt.js' +} from './prompt.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() diff --git a/src/lib/interact/interact-for-custom-metadata.test.ts b/src/lib/interact/interact-for-custom-metadata.test.ts index ef211b4b..1e78ff14 100644 --- a/src/lib/interact/interact-for-custom-metadata.test.ts +++ b/src/lib/interact/interact-for-custom-metadata.test.ts @@ -3,12 +3,12 @@ import { beforeEach, expect, test, vi } from 'vitest' import { interactForCustomMetadata } from './interact-for-custom-metadata.js' import { createMemoryOutput } from '../output/create-memory-output.js' import { setOutput } from '../output/get-output.js' -import type * as PromptModule from '../util/prompt.js' -import { promptSelect, promptText } from '../util/prompt.js' +import type * as PromptModule from './prompt.js' +import { promptSelect, promptText } from './prompt.js' // Only the prompts themselves are replaced, so the real PromptCancelledError // and withBackHint are used, as they are in production. -vi.mock('../util/prompt.js', async (importOriginal) => ({ +vi.mock('./prompt.js', async (importOriginal) => ({ ...(await importOriginal()), promptText: vi.fn(), promptNumber: vi.fn(), diff --git a/src/lib/interact/interact-for-custom-metadata.ts b/src/lib/interact/interact-for-custom-metadata.ts index a347d480..cb34ebf7 100644 --- a/src/lib/interact/interact-for-custom-metadata.ts +++ b/src/lib/interact/interact-for-custom-metadata.ts @@ -4,7 +4,7 @@ import { promptSelect, promptText, withBackHint, -} from '../util/prompt.js' +} from './prompt.js' // Structurally the CustomMetadata of @seamapi/types, spelled out here so the // published declarations do not depend on a development-only package. diff --git a/src/lib/interact/interact-for-login.ts b/src/lib/interact/interact-for-login.ts index b14e6995..e7ca8be3 100644 --- a/src/lib/interact/interact-for-login.ts +++ b/src/lib/interact/interact-for-login.ts @@ -6,8 +6,8 @@ import { getConfigStore } from '../config/index.js' import { assertEnvVarUnset, getTokenFromEnv, tokenEnvVar } from '../env.js' import { getServer } from '../get-server.js' import { getOutput } from '../output/get-output.js' -import { promptText } from '../util/prompt.js' -import { withLoading } from '../util/with-loading.js' +import { promptText } from './prompt.js' +import { withLoading } from '../output/with-loading.js' import { interactForWorkspaceId } from './interact-for-workspace-id.js' export const interactForLogin = async () => { diff --git a/src/lib/interact/interact-for-resource.ts b/src/lib/interact/interact-for-resource.ts index 80552564..d14a1ba6 100644 --- a/src/lib/interact/interact-for-resource.ts +++ b/src/lib/interact/interact-for-resource.ts @@ -1,5 +1,5 @@ -import { promptAutocomplete, withBackHint } from '../util/prompt.js' -import { withLoading } from '../util/with-loading.js' +import { promptAutocomplete, withBackHint } from './prompt.js' +import { withLoading } from '../output/with-loading.js' export interface ResourceChoice { title: string diff --git a/src/lib/interact/interact-for-server-selection.ts b/src/lib/interact/interact-for-server-selection.ts index 360d2f78..218c26b9 100644 --- a/src/lib/interact/interact-for-server-selection.ts +++ b/src/lib/interact/interact-for-server-selection.ts @@ -10,7 +10,7 @@ import { } from '../env.js' import { getServer } from '../get-server.js' import { getOutput } from '../output/get-output.js' -import { promptAutocomplete, promptText } from '../util/prompt.js' +import { promptAutocomplete, promptText } from './prompt.js' export async function interactForServerSelection() { assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server') diff --git a/src/lib/interact/interact-for-timestamp.ts b/src/lib/interact/interact-for-timestamp.ts index 573c9b44..2e7d35a3 100644 --- a/src/lib/interact/interact-for-timestamp.ts +++ b/src/lib/interact/interact-for-timestamp.ts @@ -1,4 +1,4 @@ -import { promptText, withBackHint } from '../util/prompt.js' +import { promptText, withBackHint } from './prompt.js' export const interactForTimestamp = async () => { const now = new Date().toISOString() diff --git a/src/lib/interact/interact-for-use-remote-api-defs.ts b/src/lib/interact/interact-for-use-remote-api-defs.ts index 0ed55df3..1b3818d0 100644 --- a/src/lib/interact/interact-for-use-remote-api-defs.ts +++ b/src/lib/interact/interact-for-use-remote-api-defs.ts @@ -1,6 +1,6 @@ import { getConfigStore } from '../config/index.js' import { getOutput } from '../output/get-output.js' -import { promptSelect } from '../util/prompt.js' +import { promptSelect } from './prompt.js' export async function interactForUseRemoteApiDefs() { const useRemoteApiDefs = await promptSelect({ diff --git a/src/lib/interact/interact-for-workspace-id.ts b/src/lib/interact/interact-for-workspace-id.ts index 6239f6e4..5599aade 100644 --- a/src/lib/interact/interact-for-workspace-id.ts +++ b/src/lib/interact/interact-for-workspace-id.ts @@ -8,8 +8,8 @@ import { } from '../env.js' import { getSeamMultiWorkspace } from '../seam/client.js' import { getServer } from '../get-server.js' -import { promptAutocomplete } from '../util/prompt.js' -import { withLoading } from '../util/with-loading.js' +import { promptAutocomplete } from './prompt.js' +import { withLoading } from '../output/with-loading.js' export const interactForWorkspaceId = async (personalAccessToken?: string) => { const config = getConfigStore() diff --git a/src/lib/util/prompt.test.ts b/src/lib/interact/prompt.test.ts similarity index 100% rename from src/lib/util/prompt.test.ts rename to src/lib/interact/prompt.test.ts diff --git a/src/lib/util/prompt.ts b/src/lib/interact/prompt.ts similarity index 100% rename from src/lib/util/prompt.ts rename to src/lib/interact/prompt.ts diff --git a/src/lib/util/with-loading.ts b/src/lib/output/with-loading.ts similarity index 91% rename from src/lib/util/with-loading.ts rename to src/lib/output/with-loading.ts index 515fbb9e..09d9d7bd 100644 --- a/src/lib/util/with-loading.ts +++ b/src/lib/output/with-loading.ts @@ -1,6 +1,6 @@ import { createSpinner } from 'nanospinner' -import { getOutput } from 'lib/output/get-output.js' +import { getOutput } from './get-output.js' export const withLoading = async ( message: string, diff --git a/src/lib/render/completion/describe.ts b/src/lib/render/completion/describe.ts index f3112c0c..1c937225 100644 --- a/src/lib/render/completion/describe.ts +++ b/src/lib/render/completion/describe.ts @@ -1,5 +1,5 @@ import { firstSentence } from '../../command-spec.js' -import { ellipsis } from '../../util/ellipsis.js' +import { ellipsis } from '../text.js' const maxDescriptionLength = 72 diff --git a/src/lib/util/ellipsis.test.ts b/src/lib/render/text.test.ts similarity index 82% rename from src/lib/util/ellipsis.test.ts rename to src/lib/render/text.test.ts index 7a9661b9..6688b327 100644 --- a/src/lib/util/ellipsis.test.ts +++ b/src/lib/render/text.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest' -import { ellipsis } from './ellipsis.js' +import { ellipsis } from './text.js' test('ellipsis: truncates only when over the limit', () => { expect(ellipsis('seam', 10)).toBe('seam') diff --git a/src/lib/util/ellipsis.ts b/src/lib/render/text.ts similarity index 100% rename from src/lib/util/ellipsis.ts rename to src/lib/render/text.ts diff --git a/src/lib/seam/request.ts b/src/lib/seam/request.ts index 762e624e..eba35f1d 100644 --- a/src/lib/seam/request.ts +++ b/src/lib/seam/request.ts @@ -4,7 +4,7 @@ import { getOutput } from 'lib/output/get-output.js' import { selectResponsePayload } from 'lib/output/select-response-payload.js' import { getSeam } from 'lib/seam/client.js' -import { withLoading } from '../util/with-loading.js' +import { withLoading } from '../output/with-loading.js' export interface RequestSeamApiOptions { path: string From c7f064321aa64ba61bc43927ad0db6d9a02a72de Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 23:10:56 +0000 Subject: [PATCH 05/20] refactor: Resolve auth context in one place Add context.ts with resolveAuth(): the server, token, and workspace are resolved once with env-over-config precedence and tagged with their source, replacing the per-value re-implementations in get-server.ts and get-credentials.ts (both deleted, tests ported to context.test.ts as a precedence table). CliContext replaces ContextHelpers (types.ts deleted) and now carries the config store and resolved auth alongside the blueprint and interactivity. seam/client.ts takes an AuthContext instead of re-resolving internally; blueprint/endpoint.ts narrows its dependency to just the blueprint. Config-mutating command bodies re-resolve after writes to keep today's ordering semantics (login --server stores the token under the new server's key). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- src/bin/cli.ts | 20 ++- src/lib/auth/validate-token.ts | 4 +- src/lib/blueprint/endpoint.ts | 6 +- src/lib/blueprint/source-remote.ts | 4 +- src/lib/context.test.ts | 166 ++++++++++++++++++ src/lib/context.ts | 79 +++++++++ src/lib/get-credentials.test.ts | 105 ----------- src/lib/get-credentials.ts | 38 ---- src/lib/get-server.test.ts | 55 ------ src/lib/get-server.ts | 20 --- .../interact-for-blueprint-object.test.ts | 8 +- .../interact/interact-for-blueprint-object.ts | 4 +- .../interact/interact-for-command-params.ts | 4 +- .../interact-for-command-selection.test.ts | 6 +- .../interact-for-command-selection.ts | 4 +- src/lib/interact/interact-for-login.ts | 9 +- .../interact/interact-for-server-selection.ts | 6 +- src/lib/interact/interact-for-workspace-id.ts | 6 +- src/lib/seam/client.ts | 35 ++-- src/lib/types.ts | 7 - 20 files changed, 305 insertions(+), 281 deletions(-) create mode 100644 src/lib/context.test.ts create mode 100644 src/lib/context.ts delete mode 100644 src/lib/get-credentials.test.ts delete mode 100644 src/lib/get-credentials.ts delete mode 100644 src/lib/get-server.test.ts delete mode 100644 src/lib/get-server.ts delete mode 100644 src/lib/types.ts diff --git a/src/bin/cli.ts b/src/bin/cli.ts index e6087ea1..0e55ab16 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -23,6 +23,7 @@ import { import { getApiBlueprint } from 'lib/blueprint/index.js' import { findLocalCommand, getCommandSpec } from 'lib/command-spec.js' import { getConfigStore } from 'lib/config/index.js' +import { type CliContext, resolveAuth } from 'lib/context.js' import { assertEnvVarUnset, endpointEnvVar, @@ -34,8 +35,6 @@ import { tokenEnvVar, workspaceIdEnvVar, } from 'lib/env.js' -import { getToken } from 'lib/get-credentials.js' -import { getServer } from 'lib/get-server.js' import { interactForActionAttemptPoll } from 'lib/interact/interact-for-action-attempt-poll.js' import { interactForCommandParams } from 'lib/interact/interact-for-command-params.js' import { interactForCommandSelection } from 'lib/interact/interact-for-command-selection.js' @@ -59,7 +58,6 @@ import { } from 'lib/render/completion/index.js' import { renderHelp } from 'lib/render/help.js' import { RequestSeamApi } from 'lib/seam/request.js' -import type { ContextHelpers } from 'lib/types.js' import seamapiCliVersion from 'lib/version.js' async function cli(args: ParsedArgs) { @@ -156,13 +154,13 @@ async function cli(args: ParsedArgs) { config.set('server', fakeApiUrl) output.info(`Server URL set to ${fakeApiUrl}`) - config.set(`${getServer()}.pat`, `seam_apikey1_token`) + config.set(`${fakeApiUrl}.pat`, `seam_apikey1_token`) output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) return } if ( - getToken() == null && + resolveAuth(config).token == null && args._[0] !== 'login' && !isEqual(args._, ['select', 'server']) ) { @@ -182,7 +180,9 @@ async function cli(args: ParsedArgs) { // Params given as arguments take precedence over these. const commandParams: Record = { ...(await readStdinJson()) } - const ctx: ContextHelpers = { + const ctx: CliContext = { + config, + auth: resolveAuth(config), blueprint, interactivity: getInteractivity(args, { canPrompt: canPrompt() }), } @@ -226,7 +226,9 @@ async function cli(args: ParsedArgs) { if (args['token']) { const token = String(args['token']).trim() await validateToken(token, args['workspace_id']) - config.set(`${getServer()}.pat`, token) + // Resolve after any --server write above so the token is stored + // under the server it was validated against. + config.set(`${resolveAuth(config).server}.pat`, token) config.delete('current_workspace_id') } if (args['workspace_id']) { @@ -244,7 +246,7 @@ async function cli(args: ParsedArgs) { return } else if (isEqual(selectedCommand, ['logout'])) { assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log out') - config.delete(`${getServer()}.pat`) + config.delete(`${resolveAuth(config).server}.pat`) // Configs written before tokens were stored per server may still hold an // un-namespaced token, so drop that too. config.delete('pat') @@ -366,7 +368,7 @@ const toCommandWord = (arg: string): string => const assertKnownArgs = ( argParams: Record, command: string[], - ctx?: ContextHelpers, + ctx?: CliContext, ): void => { const local = findLocalCommand(command) diff --git a/src/lib/auth/validate-token.ts b/src/lib/auth/validate-token.ts index de1d2cd0..33db7b1c 100644 --- a/src/lib/auth/validate-token.ts +++ b/src/lib/auth/validate-token.ts @@ -5,10 +5,10 @@ import { SeamHttpWithoutWorkspace, } from '@seamapi/http/connect' -import { getServer } from '../get-server.js' +import { resolveAuth } from '../context.js' export const validateToken = async (token: string, workspaceId?: string) => { - const options = { endpoint: getServer() } + const options = { endpoint: resolveAuth().server } if (isPersonalAccessToken(token)) { const seam = workspaceId diff --git a/src/lib/blueprint/endpoint.ts b/src/lib/blueprint/endpoint.ts index 25514beb..afb34d0a 100644 --- a/src/lib/blueprint/endpoint.ts +++ b/src/lib/blueprint/endpoint.ts @@ -1,8 +1,8 @@ -import type { ContextHelpers } from '../types.js' +import type { ApiBlueprint } from './index.js' export const getCommandBlueprintDef = ( cmd: string[], - helpers: ContextHelpers, + helpers: { blueprint: ApiBlueprint }, ) => { const path = `/${cmd.join('/').replace(/-/g, '_')}` const def = helpers.blueprint.routes @@ -24,7 +24,7 @@ export const getCommandBlueprintDef = ( */ export const getResponseKey = ( command: string[], - ctx: ContextHelpers, + ctx: { blueprint: ApiBlueprint }, ): string | null => { let endpoint try { diff --git a/src/lib/blueprint/source-remote.ts b/src/lib/blueprint/source-remote.ts index 4bc76ee7..4acf998b 100644 --- a/src/lib/blueprint/source-remote.ts +++ b/src/lib/blueprint/source-remote.ts @@ -1,6 +1,6 @@ import type { Blueprint } from '@seamapi/blueprint' -import { getServer } from '../get-server.js' +import { resolveAuth } from '../context.js' /** * Build a blueprint from the OpenAPI document the current server is running, @@ -11,7 +11,7 @@ export const createRemoteBlueprint = async (): Promise => { import('@seamapi/blueprint'), import('@seamapi/http/connect'), ]) - const openapi = await getOpenapiSchema(getServer()) + const openapi = await getOpenapiSchema(resolveAuth().server) return await createBlueprint({ openapi }, { omitUndocumented: true }) } diff --git a/src/lib/context.test.ts b/src/lib/context.test.ts new file mode 100644 index 00000000..420818ca --- /dev/null +++ b/src/lib/context.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, expect, test } from 'vitest' + +import type { SeamConfigStore } from './config/index.js' +import { resolveAuth } from './context.js' +import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from './env.js' + +const server = 'https://connect.example.com' + +const store = (values: Record = {}): SeamConfigStore => + ({ get: (key: string) => values[key] }) as unknown as SeamConfigStore + +const clearEnv = (): void => { + delete process.env[endpointEnvVar] + delete process.env[tokenEnvVar] + delete process.env[workspaceIdEnvVar] +} + +beforeEach(clearEnv) +afterEach(clearEnv) + +test('resolveAuth: reads the stored server', () => { + const auth = resolveAuth(store({ server })) + + expect(auth.server).toBe(server) + expect(auth.serverSource).toBe('config') +}) + +test('resolveAuth: defaults the server to Seam', () => { + const auth = resolveAuth(store()) + + expect(auth.server).toBe('https://connect.getseam.com') + expect(auth.serverSource).toBe('default') +}) + +test(`resolveAuth: ${endpointEnvVar} wins over the stored server`, () => { + process.env[endpointEnvVar] = 'http://localhost:3020' + + const auth = resolveAuth(store({ server })) + + expect(auth.server).toBe('http://localhost:3020') + expect(auth.serverSource).toBe('env') +}) + +test(`resolveAuth: ${endpointEnvVar} is used without a stored server`, () => { + process.env[endpointEnvVar] = 'http://localhost:3020' + + expect(resolveAuth(store()).server).toBe('http://localhost:3020') +}) + +test(`resolveAuth: ignores an empty ${endpointEnvVar}`, () => { + process.env[endpointEnvVar] = '' + + const auth = resolveAuth(store({ server })) + + expect(auth.server).toBe(server) + expect(auth.serverSource).toBe('config') +}) + +test('resolveAuth: reads the token stored for the current server', () => { + const auth = resolveAuth( + store({ server, [`${server}.pat`]: 'seam_apikey1_stored' }), + ) + + expect(auth.token).toBe('seam_apikey1_stored') + expect(auth.tokenSource).toBe('config') +}) + +test(`resolveAuth: the token stored for ${endpointEnvVar} wins over the stored server's`, () => { + process.env[endpointEnvVar] = 'http://localhost:3020' + + const auth = resolveAuth( + store({ + server, + [`${server}.pat`]: 'seam_apikey1_stored', + 'http://localhost:3020.pat': 'seam_apikey1_local', + }), + ) + + expect(auth.token).toBe('seam_apikey1_local') +}) + +test(`resolveAuth: ${tokenEnvVar} wins over the stored token`, () => { + process.env[tokenEnvVar] = 'seam_apikey1_env' + + const auth = resolveAuth( + store({ server, [`${server}.pat`]: 'seam_apikey1_stored' }), + ) + + expect(auth.token).toBe('seam_apikey1_env') + expect(auth.tokenSource).toBe('env') +}) + +test(`resolveAuth: ${tokenEnvVar} is used without a stored token`, () => { + process.env[tokenEnvVar] = 'seam_apikey1_env' + + expect(resolveAuth(store()).token).toBe('seam_apikey1_env') +}) + +test(`resolveAuth: ignores an empty ${tokenEnvVar}`, () => { + process.env[tokenEnvVar] = ' ' + + const auth = resolveAuth( + store({ server, [`${server}.pat`]: 'seam_apikey1_stored' }), + ) + + expect(auth.token).toBe('seam_apikey1_stored') +}) + +test('resolveAuth: token is null when nothing is set', () => { + const auth = resolveAuth(store()) + + expect(auth.token).toBe(null) + expect(auth.tokenSource).toBe(null) +}) + +test('resolveAuth: reads the stored workspace selection', () => { + const auth = resolveAuth(store({ current_workspace_id: 'workspace1' })) + + expect(auth.workspaceId).toBe('workspace1') + expect(auth.workspaceIdSource).toBe('config') +}) + +test(`resolveAuth: ${workspaceIdEnvVar} wins over the stored selection`, () => { + process.env[workspaceIdEnvVar] = 'workspace2' + + const auth = resolveAuth(store({ current_workspace_id: 'workspace1' })) + + expect(auth.workspaceId).toBe('workspace2') + expect(auth.workspaceIdSource).toBe('env') +}) + +test(`resolveAuth: ${workspaceIdEnvVar} is used without a stored selection`, () => { + process.env[workspaceIdEnvVar] = 'workspace2' + + expect(resolveAuth(store()).workspaceId).toBe('workspace2') +}) + +test(`resolveAuth: ignores an empty ${workspaceIdEnvVar}`, () => { + process.env[workspaceIdEnvVar] = '' + + expect( + resolveAuth(store({ current_workspace_id: 'workspace1' })).workspaceId, + ).toBe('workspace1') +}) + +test('resolveAuth: workspace is null when nothing is set', () => { + const auth = resolveAuth(store()) + + expect(auth.workspaceId).toBe(null) + expect(auth.workspaceIdSource).toBe(null) +}) + +test('resolveAuth: each value resolves on its own', () => { + process.env[workspaceIdEnvVar] = 'workspace2' + + const auth = resolveAuth( + store({ + server, + [`${server}.pat`]: 'seam_apikey1_stored', + current_workspace_id: 'workspace1', + }), + ) + + expect(auth.token).toBe('seam_apikey1_stored') + expect(auth.workspaceId).toBe('workspace2') +}) diff --git a/src/lib/context.ts b/src/lib/context.ts new file mode 100644 index 00000000..bc97466c --- /dev/null +++ b/src/lib/context.ts @@ -0,0 +1,79 @@ +import type { Interactivity } from './args/parse.js' +import type { ApiBlueprint } from './blueprint/index.js' +import { getConfigStore, type SeamConfigStore } from './config/index.js' +import { + getEndpointFromEnv, + getTokenFromEnv, + getWorkspaceIdFromEnv, +} from './env.js' + +export const defaultServer = 'https://connect.getseam.com' + +/** Where a resolved value came from, e.g., to refuse writes the env shadows. */ +export type ValueSource = 'env' | 'config' | 'default' + +/** + * The server, token, and workspace requests are made with. + * + * Resolved in one place so the precedence rule exists once: an environment + * variable wins over the stored value, and the server falls back to Seam. + * The source tags say where each value came from. + */ +export interface AuthContext { + server: string + serverSource: ValueSource + token: string | null + tokenSource: Exclude | null + workspaceId: string | null + workspaceIdSource: Exclude | null +} + +export const resolveAuth = ( + config: SeamConfigStore = getConfigStore(), +): AuthContext => { + const envServer = getEndpointFromEnv() + const storedServer = config.get('server') + const server = + envServer ?? (typeof storedServer === 'string' ? storedServer : null) + + const envToken = getTokenFromEnv() + const storedToken = readString(config.get(`${server ?? defaultServer}.pat`)) + + const envWorkspaceId = getWorkspaceIdFromEnv() + const storedWorkspaceId = readString(config.get('current_workspace_id')) + + return { + server: server ?? defaultServer, + serverSource: + envServer != null ? 'env' : server != null ? 'config' : 'default', + token: envToken ?? storedToken, + tokenSource: + envToken != null ? 'env' : storedToken != null ? 'config' : null, + workspaceId: envWorkspaceId ?? storedWorkspaceId, + workspaceIdSource: + envWorkspaceId != null + ? 'env' + : storedWorkspaceId != null + ? 'config' + : null, + } +} + +/** + * Everything a command runs with: the stores and auth it reads, the API + * shape it acts on, and how it may interact with the user. + */ +export interface CliContext { + config: SeamConfigStore + auth: AuthContext + blueprint: ApiBlueprint + interactivity: Interactivity +} + +const readString = (value: unknown): string | null => { + if (typeof value !== 'string') return null + + const trimmedValue = value.trim() + + return trimmedValue === '' ? null : trimmedValue +} diff --git a/src/lib/get-credentials.test.ts b/src/lib/get-credentials.test.ts deleted file mode 100644 index 9062fc03..00000000 --- a/src/lib/get-credentials.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { afterEach, beforeEach, expect, test, vi } from 'vitest' - -import { getConfigStore } from './config/index.js' -import { tokenEnvVar, workspaceIdEnvVar } from './env.js' -import { getToken, getWorkspaceId } from './get-credentials.js' - -const server = 'https://connect.example.com' - -const storedConfig: Record = {} - -vi.mock('./config/index.js', () => ({ - getConfigStore: vi.fn(() => ({ - get: (key: string) => storedConfig[key], - })), -})) - -vi.mock('./get-server.js', () => ({ - getServer: vi.fn(() => server), -})) - -const clearEnv = (): void => { - delete process.env[tokenEnvVar] - delete process.env[workspaceIdEnvVar] -} - -beforeEach(() => { - for (const key of Object.keys(storedConfig)) { - delete storedConfig[key] - } - clearEnv() -}) - -afterEach(() => { - clearEnv() - vi.mocked(getConfigStore).mockClear() -}) - -test('getToken: reads the token stored for the current server', () => { - storedConfig[`${server}.pat`] = 'seam_apikey1_stored' - - expect(getToken()).toBe('seam_apikey1_stored') -}) - -test(`getToken: ${tokenEnvVar} wins over the stored token`, () => { - storedConfig[`${server}.pat`] = 'seam_apikey1_stored' - process.env[tokenEnvVar] = 'seam_apikey1_env' - - expect(getToken()).toBe('seam_apikey1_env') -}) - -test(`getToken: ${tokenEnvVar} is used without a stored token`, () => { - process.env[tokenEnvVar] = 'seam_apikey1_env' - - expect(getToken()).toBe('seam_apikey1_env') -}) - -test(`getToken: ignores an empty ${tokenEnvVar}`, () => { - storedConfig[`${server}.pat`] = 'seam_apikey1_stored' - process.env[tokenEnvVar] = ' ' - - expect(getToken()).toBe('seam_apikey1_stored') -}) - -test('getToken: returns null when nothing is set', () => { - expect(getToken()).toBe(null) -}) - -test('getWorkspaceId: reads the stored workspace selection', () => { - storedConfig['current_workspace_id'] = 'workspace1' - - expect(getWorkspaceId()).toBe('workspace1') -}) - -test(`getWorkspaceId: ${workspaceIdEnvVar} wins over the stored selection`, () => { - storedConfig['current_workspace_id'] = 'workspace1' - process.env[workspaceIdEnvVar] = 'workspace2' - - expect(getWorkspaceId()).toBe('workspace2') -}) - -test(`getWorkspaceId: ${workspaceIdEnvVar} is used without a stored selection`, () => { - process.env[workspaceIdEnvVar] = 'workspace2' - - expect(getWorkspaceId()).toBe('workspace2') -}) - -test(`getWorkspaceId: ignores an empty ${workspaceIdEnvVar}`, () => { - storedConfig['current_workspace_id'] = 'workspace1' - process.env[workspaceIdEnvVar] = '' - - expect(getWorkspaceId()).toBe('workspace1') -}) - -test('getWorkspaceId: returns null when nothing is set', () => { - expect(getWorkspaceId()).toBe(null) -}) - -test('getToken and getWorkspaceId: either may be set on its own', () => { - storedConfig[`${server}.pat`] = 'seam_apikey1_stored' - storedConfig['current_workspace_id'] = 'workspace1' - process.env[workspaceIdEnvVar] = 'workspace2' - - expect(getToken()).toBe('seam_apikey1_stored') - expect(getWorkspaceId()).toBe('workspace2') -}) diff --git a/src/lib/get-credentials.ts b/src/lib/get-credentials.ts deleted file mode 100644 index 5e573654..00000000 --- a/src/lib/get-credentials.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { getConfigStore } from './config/index.js' -import { getTokenFromEnv, getWorkspaceIdFromEnv } from './env.js' -import { getServer } from './get-server.js' - -/** - * The token used to authenticate requests. - * - * `SEAM_CLI_TOKEN` wins over the token stored by `seam login`, - * so a token may be given per command or per shell without logging in. - */ -export const getToken = (): string | null => { - const token = getTokenFromEnv() - if (token != null) return token - - return readString(getConfigStore().get(`${getServer()}.pat`)) -} - -/** - * The workspace requests are made against. - * - * `SEAM_CLI_WORKSPACE_ID` wins over the workspace stored by - * `seam select workspace`. Returns `null` when neither is set: a token - * scoped to a single workspace does not need one. - */ -export const getWorkspaceId = (): string | null => { - const workspaceId = getWorkspaceIdFromEnv() - if (workspaceId != null) return workspaceId - - return readString(getConfigStore().get('current_workspace_id')) -} - -const readString = (value: unknown): string | null => { - if (typeof value !== 'string') return null - - const trimmedValue = value.trim() - - return trimmedValue === '' ? null : trimmedValue -} diff --git a/src/lib/get-server.test.ts b/src/lib/get-server.test.ts deleted file mode 100644 index f876bce3..00000000 --- a/src/lib/get-server.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { afterEach, beforeEach, expect, test, vi } from 'vitest' - -import { getConfigStore } from './config/index.js' -import { endpointEnvVar } from './env.js' -import { getServer } from './get-server.js' - -const storedConfig: Record = {} - -vi.mock('./config/index.js', () => ({ - getConfigStore: vi.fn(() => ({ - get: (key: string) => storedConfig[key], - })), -})) - -beforeEach(() => { - for (const key of Object.keys(storedConfig)) { - delete storedConfig[key] - } - delete process.env[endpointEnvVar] -}) - -afterEach(() => { - delete process.env[endpointEnvVar] - vi.mocked(getConfigStore).mockClear() -}) - -test('getServer: reads the stored server', () => { - storedConfig['server'] = 'https://connect.example.com' - - expect(getServer()).toBe('https://connect.example.com') -}) - -test('getServer: defaults to Seam', () => { - expect(getServer()).toBe('https://connect.getseam.com') -}) - -test(`getServer: ${endpointEnvVar} wins over the stored server`, () => { - storedConfig['server'] = 'https://connect.example.com' - process.env[endpointEnvVar] = 'http://localhost:3020' - - expect(getServer()).toBe('http://localhost:3020') -}) - -test(`getServer: ${endpointEnvVar} is used without a stored server`, () => { - process.env[endpointEnvVar] = 'http://localhost:3020' - - expect(getServer()).toBe('http://localhost:3020') -}) - -test(`getServer: ignores an empty ${endpointEnvVar}`, () => { - storedConfig['server'] = 'https://connect.example.com' - process.env[endpointEnvVar] = '' - - expect(getServer()).toBe('https://connect.example.com') -}) diff --git a/src/lib/get-server.ts b/src/lib/get-server.ts deleted file mode 100644 index 2c4521c7..00000000 --- a/src/lib/get-server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { getConfigStore } from './config/index.js' -import { getEndpointFromEnv } from './env.js' - -const defaultServer = 'https://connect.getseam.com' - -/** - * The Seam API server requests are made against. - * - * `SEAM_CLI_ENDPOINT` wins over the server stored by `seam select server`. - */ -export const getServer = (): string => { - const endpoint = getEndpointFromEnv() - if (endpoint != null) return endpoint - - const config = getConfigStore() - - const server = config.get('server') - - return typeof server === 'string' ? server : defaultServer -} diff --git a/src/lib/interact/interact-for-blueprint-object.test.ts b/src/lib/interact/interact-for-blueprint-object.test.ts index 099aefe9..2ce2c011 100644 --- a/src/lib/interact/interact-for-blueprint-object.test.ts +++ b/src/lib/interact/interact-for-blueprint-object.test.ts @@ -1,10 +1,10 @@ import type { Parameter } from '@seamapi/blueprint' import { beforeEach, expect, test, vi } from 'vitest' -import { interactForBlueprintObject } from './interact-for-blueprint-object.js' +import type { CliContext } from '../context.js' import { createMemoryOutput } from '../output/create-memory-output.js' import { setOutput } from '../output/get-output.js' -import type { ContextHelpers } from '../types.js' +import { interactForBlueprintObject } from './interact-for-blueprint-object.js' import type * as PromptModule from './prompt.js' import { promptAutocomplete, @@ -39,8 +39,8 @@ const parameters = [ { name: 'name', isRequired: false, format: 'string' }, ] as unknown as Parameter[] -const ctx = (interactivity: ContextHelpers['interactivity']): ContextHelpers => - ({ interactivity, blueprint: {} }) as unknown as ContextHelpers +const ctx = (interactivity: CliContext['interactivity']): CliContext => + ({ interactivity, blueprint: {} }) as unknown as CliContext const args = (params: Record) => ({ command: ['devices', 'get'], diff --git a/src/lib/interact/interact-for-blueprint-object.ts b/src/lib/interact/interact-for-blueprint-object.ts index 6f9367a6..fbd0dcf0 100644 --- a/src/lib/interact/interact-for-blueprint-object.ts +++ b/src/lib/interact/interact-for-blueprint-object.ts @@ -1,9 +1,9 @@ import type { Parameter } from '@seamapi/blueprint' import { NonInteractiveError, toArgName } from '../args/parse.js' +import type { CliContext } from '../context.js' import { getOutput } from '../output/get-output.js' import { ellipsis } from '../render/text.js' -import type { ContextHelpers } from '../types.js' import { interactForAccessCode } from './interact-for-access-code.js' import { interactForAcsEntrance } from './interact-for-acs-entrance.js' import { interactForAcsSystem } from './interact-for-acs-system.js' @@ -44,7 +44,7 @@ export const interactForBlueprintObject = async ( isSubProperty?: boolean subPropertyPath?: string }, - ctx: ContextHelpers, + ctx: CliContext, ): Promise => { // Clone args and args params so that we can mutate it args = { ...args, params: { ...args.params } } diff --git a/src/lib/interact/interact-for-command-params.ts b/src/lib/interact/interact-for-command-params.ts index f1b0fd1f..382336fc 100644 --- a/src/lib/interact/interact-for-command-params.ts +++ b/src/lib/interact/interact-for-command-params.ts @@ -1,5 +1,5 @@ import { getCommandBlueprintDef } from '../blueprint/endpoint.js' -import type { ContextHelpers } from '../types.js' +import type { CliContext } from '../context.js' import { interactForBlueprintObject } from './interact-for-blueprint-object.js' export const interactForCommandParams = async ( @@ -7,7 +7,7 @@ export const interactForCommandParams = async ( command: string[] params: Record }, - ctx: ContextHelpers, + ctx: CliContext, ): Promise => { const endpoint = getCommandBlueprintDef(args.command, ctx) diff --git a/src/lib/interact/interact-for-command-selection.test.ts b/src/lib/interact/interact-for-command-selection.test.ts index 9d174cce..3b7e21c8 100644 --- a/src/lib/interact/interact-for-command-selection.test.ts +++ b/src/lib/interact/interact-for-command-selection.test.ts @@ -1,7 +1,7 @@ import { beforeEach, expect, test, vi } from 'vitest' +import type { CliContext } from '../context.js' import { interactForCommandSelection } from './interact-for-command-selection.js' -import type { ContextHelpers } from '../types.js' import type * as PromptModule from './prompt.js' import { promptAutocomplete, withBackHint } from './prompt.js' @@ -27,7 +27,7 @@ const ctx = { }, ], }, -} as unknown as ContextHelpers +} as unknown as CliContext test('interactForCommandSelection: resolves a complete command', async () => { await expect( @@ -52,7 +52,7 @@ test('interactForCommandSelection: rejects a missing command when non-interactiv const interactiveCtx = { ...ctx, interactivity: 'interactive', -} as unknown as ContextHelpers +} as unknown as CliContext test('interactForCommandSelection: tells the user a sub-command menu can be left', async () => { vi.mocked(promptAutocomplete).mockImplementationOnce(async () => 'list') diff --git a/src/lib/interact/interact-for-command-selection.ts b/src/lib/interact/interact-for-command-selection.ts index 666ba4df..95bff427 100644 --- a/src/lib/interact/interact-for-command-selection.ts +++ b/src/lib/interact/interact-for-command-selection.ts @@ -1,7 +1,7 @@ import { isDeepStrictEqual as isEqual } from 'node:util' -import type { ContextHelpers } from '../types.js' import { NonInteractiveError } from '../args/parse.js' +import type { CliContext } from '../context.js' import { promptAutocomplete, PromptCancelledError, @@ -31,7 +31,7 @@ function ergonomicSort(aStr: string, bStr: string) { export async function interactForCommandSelection( commandPath: string[], - helpers: ContextHelpers, + helpers: CliContext, ) { const commands = helpers.blueprint.routes .flatMap((route) => route.endpoints) diff --git a/src/lib/interact/interact-for-login.ts b/src/lib/interact/interact-for-login.ts index e7ca8be3..507b198d 100644 --- a/src/lib/interact/interact-for-login.ts +++ b/src/lib/interact/interact-for-login.ts @@ -3,8 +3,8 @@ import chalk from 'chalk' import { validateToken } from '../auth/validate-token.js' import { getConfigStore } from '../config/index.js' +import { resolveAuth } from '../context.js' import { assertEnvVarUnset, getTokenFromEnv, tokenEnvVar } from '../env.js' -import { getServer } from '../get-server.js' import { getOutput } from '../output/get-output.js' import { promptText } from './prompt.js' import { withLoading } from '../output/with-loading.js' @@ -13,12 +13,13 @@ import { interactForWorkspaceId } from './interact-for-workspace-id.js' export const interactForLogin = async () => { const config = getConfigStore() const output = getOutput() + const { server } = resolveAuth(config) assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in') - if (getServer().includes('localhost')) { + if (server.includes('localhost')) { output.info( - `You're using a local Seam Connect instance, you can enter the API Key to your local user, you can create a new user from:\n\n${getServer()}/admin/create_user_with_api_key`, + `You're using a local Seam Connect instance, you can enter the API Key to your local user, you can create a new user from:\n\n${server}/admin/create_user_with_api_key`, ) } else { output.info( @@ -51,6 +52,6 @@ export const interactForLogin = async () => { ) } - config.set(`${getServer()}.pat`, token) + config.set(`${server}.pat`, token) output.info(`Token saved! You may begin using the CLI!`) } diff --git a/src/lib/interact/interact-for-server-selection.ts b/src/lib/interact/interact-for-server-selection.ts index 218c26b9..cb038a63 100644 --- a/src/lib/interact/interact-for-server-selection.ts +++ b/src/lib/interact/interact-for-server-selection.ts @@ -8,7 +8,6 @@ import { getTokenFromEnv, tokenEnvVar, } from '../env.js' -import { getServer } from '../get-server.js' import { getOutput } from '../output/get-output.js' import { promptAutocomplete, promptText } from './prompt.js' @@ -39,8 +38,9 @@ export async function interactForServerSelection() { userUrlSeed = randomBytes(5).toString('hex') } assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in') - config.set('server', `https://${userUrlSeed}.fakeseamconnect.seam.vc`) - config.set(`${getServer()}.pat`, `seam_apikey1_token`) + const fakeServerUrl = `https://${userUrlSeed}.fakeseamconnect.seam.vc` + config.set('server', fakeServerUrl) + config.set(`${fakeServerUrl}.pat`, `seam_apikey1_token`) output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) } else { config.set('server', server) diff --git a/src/lib/interact/interact-for-workspace-id.ts b/src/lib/interact/interact-for-workspace-id.ts index 5599aade..2484e843 100644 --- a/src/lib/interact/interact-for-workspace-id.ts +++ b/src/lib/interact/interact-for-workspace-id.ts @@ -1,15 +1,15 @@ import { SeamHttpWithoutWorkspace } from '@seamapi/http/connect' import { getConfigStore } from '../config/index.js' +import { resolveAuth } from '../context.js' import { assertEnvVarUnset, getWorkspaceIdFromEnv, workspaceIdEnvVar, } from '../env.js' +import { withLoading } from '../output/with-loading.js' import { getSeamMultiWorkspace } from '../seam/client.js' -import { getServer } from '../get-server.js' import { promptAutocomplete } from './prompt.js' -import { withLoading } from '../output/with-loading.js' export const interactForWorkspaceId = async (personalAccessToken?: string) => { const config = getConfigStore() @@ -22,7 +22,7 @@ export const interactForWorkspaceId = async (personalAccessToken?: string) => { const seam = personalAccessToken ? SeamHttpWithoutWorkspace.fromPersonalAccessToken(personalAccessToken, { - endpoint: getServer(), + endpoint: resolveAuth(config).server, }) : await getSeamMultiWorkspace() diff --git a/src/lib/seam/client.ts b/src/lib/seam/client.ts index 0afacd13..d644015e 100644 --- a/src/lib/seam/client.ts +++ b/src/lib/seam/client.ts @@ -5,19 +5,20 @@ import { SeamHttpWithoutWorkspace, } from '@seamapi/http/connect' +import { type AuthContext, resolveAuth } from '../context.js' import { tokenEnvVar, workspaceIdEnvVar } from '../env.js' -import { getToken, getWorkspaceId } from '../get-credentials.js' -import { getServer } from '../get-server.js' -export const getSeam = async (): Promise => { - const token = getRequiredToken() +export const getSeam = async ( + auth: AuthContext = resolveAuth(), +): Promise => { + const token = getRequiredToken(auth) - const options = { endpoint: getServer() } + const options = { endpoint: auth.server } if (isPersonalAccessToken(token)) { return SeamHttp.fromPersonalAccessToken( token, - getRequiredWorkspaceId(), + getRequiredWorkspaceId(auth), options, ) } @@ -25,7 +26,7 @@ export const getSeam = async (): Promise => { if (isConsoleSessionToken(token)) { return SeamHttp.fromConsoleSessionToken( token, - getRequiredWorkspaceId(), + getRequiredWorkspaceId(auth), options, ) } @@ -33,21 +34,21 @@ export const getSeam = async (): Promise => { return SeamHttp.fromApiKey(token, options) } -export const getSeamMultiWorkspace = async (): Promise< - SeamHttpWithoutWorkspace | SeamHttp -> => { - const token = getRequiredToken() - const options = { endpoint: getServer() } +export const getSeamMultiWorkspace = async ( + auth: AuthContext = resolveAuth(), +): Promise => { + const token = getRequiredToken(auth) + const options = { endpoint: auth.server } if (isPersonalAccessToken(token)) { return SeamHttpWithoutWorkspace.fromPersonalAccessToken(token, options) } - return await getSeam() + return await getSeam(auth) } -const getRequiredToken = (): string => { - const token = getToken() +const getRequiredToken = (auth: AuthContext): string => { + const { token } = auth if (token == null) { throw new Error( @@ -58,8 +59,8 @@ const getRequiredToken = (): string => { return token } -const getRequiredWorkspaceId = (): string => { - const workspaceId = getWorkspaceId() +const getRequiredWorkspaceId = (auth: AuthContext): string => { + const { workspaceId } = auth if (workspaceId == null) { throw new Error( diff --git a/src/lib/types.ts b/src/lib/types.ts deleted file mode 100644 index 7c055f38..00000000 --- a/src/lib/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Interactivity } from './args/parse.js' -import type { ApiBlueprint } from './blueprint/index.js' - -export interface ContextHelpers { - blueprint: ApiBlueprint - interactivity: Interactivity -} From d85a65a04b9133b040ada43a884d2eea759c1e14 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 23:16:44 +0000 Subject: [PATCH 06/20] refactor: Extract auth mutations into an operations service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth/operations.ts owns every auth/settings write: login, storeToken, logout, selectServer, selectWorkspace, selectFakeServer, and setUseRemoteApiDefs. The env-override policy now has one implementation, assertMutable, driven by the AuthContext source tags — the guards previously duplicated between the dispatcher and the interact modules are gone. The interact modules keep only prompting plus a call into operations; the dispatcher branches shrink to selection and messaging. login() stores the server before deriving the token key, covered by a unit test on that ordering. Behavior note: `config set fake-server` now clears the stored workspace selection, matching `select server` — a workspace from the previous server is not valid on the new one. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- src/bin/cli.ts | 80 ++----- src/lib/auth/operations.test.ts | 212 ++++++++++++++++++ src/lib/auth/operations.ts | 162 +++++++++++++ src/lib/interact/interact-for-login.ts | 13 +- .../interact/interact-for-server-selection.ts | 25 +-- .../interact-for-use-remote-api-defs.ts | 5 +- src/lib/interact/interact-for-workspace-id.ts | 15 +- 7 files changed, 418 insertions(+), 94 deletions(-) create mode 100644 src/lib/auth/operations.test.ts create mode 100644 src/lib/auth/operations.ts diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 0e55ab16..9e55181f 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -1,5 +1,4 @@ #!/usr/bin/env node -import { randomBytes } from 'node:crypto' import { isDeepStrictEqual as isEqual } from 'node:util' import chalk from 'chalk' @@ -15,7 +14,13 @@ import { toParameterName, UsageError, } from 'lib/args/parse.js' -import { validateToken } from 'lib/auth/validate-token.js' +import { + assertMutable, + login, + logout, + selectFakeServer, + selectServer, +} from 'lib/auth/operations.js' import { getCommandBlueprintDef, getResponseKey, @@ -25,15 +30,9 @@ import { findLocalCommand, getCommandSpec } from 'lib/command-spec.js' import { getConfigStore } from 'lib/config/index.js' import { type CliContext, resolveAuth } from 'lib/context.js' import { - assertEnvVarUnset, - endpointEnvVar, EnvVarOverrideError, - getEndpointFromEnv, - getTokenFromEnv, - getWorkspaceIdFromEnv, isInsideWebBrowser, tokenEnvVar, - workspaceIdEnvVar, } from 'lib/env.js' import { interactForActionAttemptPoll } from 'lib/interact/interact-for-action-attempt-poll.js' import { interactForCommandParams } from 'lib/interact/interact-for-command-params.js' @@ -145,16 +144,8 @@ async function cli(args: ParsedArgs) { args._[1] === 'set' && args._[2] === 'fake-server' ) { - assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server') - assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in') - - const randomstring = randomBytes(5).toString('hex') - const fakeApiUrl = `https://${randomstring}.fakeseamconnect.seam.vc` - - config.set('server', fakeApiUrl) + const { server: fakeApiUrl } = selectFakeServer(undefined, config) output.info(`Server URL set to ${fakeApiUrl}`) - - config.set(`${fakeApiUrl}.pat`, `seam_apikey1_token`) output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) return } @@ -206,37 +197,18 @@ async function cli(args: ParsedArgs) { assertKnownArgs(argParams, selectedCommand, ctx) if (isEqual(selectedCommand, ['login'])) { - // Nothing is stored while the environment overrides it, so refuse before - // storing anything rather than part way through. - assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in') - if (args['server']) { - assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server') - } - if (args['workspace_id']) { - assertEnvVarUnset( - workspaceIdEnvVar, - getWorkspaceIdFromEnv(), - 'select a workspace', - ) - } - if (args['server']) { - config.set('server', args['server']) - config.delete('current_workspace_id') - } - if (args['token']) { - const token = String(args['token']).trim() - await validateToken(token, args['workspace_id']) - // Resolve after any --server write above so the token is stored - // under the server it was validated against. - config.set(`${resolveAuth(config).server}.pat`, token) - config.delete('current_workspace_id') - } - if (args['workspace_id']) { - config.set(`current_workspace_id`, args['workspace_id']) - } if (args['token'] || args['workspace_id'] || args['server']) { + await login( + { + server: args['server'] ? args['server'] : undefined, + token: args['token'] ? String(args['token']).trim() : undefined, + workspaceId: args['workspace_id'] ? args['workspace_id'] : undefined, + }, + config, + ) return } + assertMutable(ctx.auth, 'token', 'log in') if (isNonInteractive) { throw new NonInteractiveError( 'Missing required parameter for login: --token', @@ -245,12 +217,7 @@ async function cli(args: ParsedArgs) { await interactForLogin() return } else if (isEqual(selectedCommand, ['logout'])) { - assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log out') - config.delete(`${resolveAuth(config).server}.pat`) - // Configs written before tokens were stored per server may still hold an - // un-namespaced token, so drop that too. - config.delete('pat') - config.delete('current_workspace_id') + logout(config) output.info('Logged out!') return } else if (isEqual(selectedCommand, ['config', 'reveal-location'])) { @@ -265,11 +232,7 @@ async function cli(args: ParsedArgs) { await interactForUseRemoteApiDefs() return } else if (isEqual(selectedCommand, ['select', 'workspace'])) { - assertEnvVarUnset( - workspaceIdEnvVar, - getWorkspaceIdFromEnv(), - 'select a workspace', - ) + assertMutable(ctx.auth, 'workspaceId', 'select a workspace') if (isNonInteractive) { throw new NonInteractiveError( 'Cannot select a workspace in non-interactive mode: pass --workspace-id to "seam login"', @@ -284,10 +247,9 @@ async function cli(args: ParsedArgs) { commandParams['since'] = date.toISOString() } } else if (isEqual(selectedCommand, ['select', 'server'])) { - assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server') + assertMutable(ctx.auth, 'server', 'select a server') if (args['server']) { - config.set('server', args['server']) - config.delete('current_workspace_id') + selectServer(args['server'], config) return } if (isNonInteractive) { diff --git a/src/lib/auth/operations.test.ts b/src/lib/auth/operations.test.ts new file mode 100644 index 00000000..8dea2113 --- /dev/null +++ b/src/lib/auth/operations.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import type { SeamConfigStore } from '../config/index.js' +import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from '../env.js' +import { + login, + logout, + selectFakeServer, + selectServer, + selectWorkspace, + storeToken, +} from './operations.js' +import { validateToken } from './validate-token.js' + +vi.mock('./validate-token.js', () => ({ + validateToken: vi.fn(async () => {}), +})) + +const server = 'https://connect.example.com' + +const createStore = ( + values: Record = {}, +): { values: Record; store: SeamConfigStore } => ({ + values, + store: { + get: (key: string) => values[key], + set: (key: string, value: unknown) => { + values[key] = value + }, + delete: (key: string) => { + delete values[key] + }, + } as unknown as SeamConfigStore, +}) + +const clearEnv = (): void => { + delete process.env[endpointEnvVar] + delete process.env[tokenEnvVar] + delete process.env[workspaceIdEnvVar] +} + +beforeEach(() => { + clearEnv() + vi.mocked(validateToken).mockClear() +}) + +afterEach(clearEnv) + +test('login: stores a validated token under the current server', async () => { + const { values, store } = createStore({ server }) + + await login({ token: 'seam_apikey1_stored' }, store) + + expect(validateToken).toHaveBeenCalledWith('seam_apikey1_stored', undefined) + expect(values[`${server}.pat`]).toBe('seam_apikey1_stored') +}) + +test('login: stores the token under a server given alongside it', async () => { + const { values, store } = createStore({ server }) + + await login( + { server: 'https://other.example.com', token: 'seam_apikey1_stored' }, + store, + ) + + expect(values['server']).toBe('https://other.example.com') + expect(values['https://other.example.com.pat']).toBe('seam_apikey1_stored') + expect(values[`${server}.pat`]).toBeUndefined() +}) + +test('login: a new login clears the previous workspace selection', async () => { + const { values, store } = createStore({ + server, + current_workspace_id: 'workspace1', + }) + + await login({ token: 'seam_apikey1_stored' }, store) + + expect(values['current_workspace_id']).toBeUndefined() +}) + +test('login: stores a workspace given with the token', async () => { + const { values, store } = createStore({ server }) + + await login({ token: 'seam_at1_stored', workspaceId: 'workspace1' }, store) + + expect(validateToken).toHaveBeenCalledWith('seam_at1_stored', 'workspace1') + expect(values['current_workspace_id']).toBe('workspace1') +}) + +test(`login: refuses while ${tokenEnvVar} is set, before storing anything`, async () => { + process.env[tokenEnvVar] = 'seam_apikey1_env' + const { values, store } = createStore({ server }) + + await expect(login({ token: 'seam_apikey1_stored' }, store)).rejects.toThrow( + `Cannot log in while ${tokenEnvVar} is set`, + ) + expect(values[`${server}.pat`]).toBeUndefined() + expect(validateToken).not.toHaveBeenCalled() +}) + +test(`login: refuses a server while ${endpointEnvVar} is set`, async () => { + process.env[endpointEnvVar] = server + const { store } = createStore() + + await expect( + login({ server: 'https://other.example.com' }, store), + ).rejects.toThrow(`Cannot select a server while ${endpointEnvVar} is set`) +}) + +test(`login: refuses a workspace while ${workspaceIdEnvVar} is set`, async () => { + process.env[workspaceIdEnvVar] = 'workspace_env' + const { store } = createStore({ server }) + + await expect( + login({ token: 'seam_at1_stored', workspaceId: 'workspace1' }, store), + ).rejects.toThrow( + `Cannot select a workspace while ${workspaceIdEnvVar} is set`, + ) +}) + +test('storeToken: stores under the current server without validating', () => { + const { values, store } = createStore({ server }) + + storeToken('seam_apikey1_stored', store) + + expect(values[`${server}.pat`]).toBe('seam_apikey1_stored') + expect(validateToken).not.toHaveBeenCalled() +}) + +test('logout: removes the stored token, legacy token, and workspace', () => { + const { values, store } = createStore({ + server, + [`${server}.pat`]: 'seam_apikey1_stored', + pat: 'seam_apikey1_legacy', + current_workspace_id: 'workspace1', + }) + + logout(store) + + expect(values[`${server}.pat`]).toBeUndefined() + expect(values['pat']).toBeUndefined() + expect(values['current_workspace_id']).toBeUndefined() +}) + +test(`logout: refuses while ${tokenEnvVar} is set`, () => { + process.env[tokenEnvVar] = 'seam_apikey1_env' + const { values, store } = createStore({ + server, + [`${server}.pat`]: 'seam_apikey1_stored', + }) + + expect(() => { + logout(store) + }).toThrow(`Cannot log out while ${tokenEnvVar} is set`) + expect(values[`${server}.pat`]).toBe('seam_apikey1_stored') +}) + +test('selectServer: stores the server and clears the workspace', () => { + const { values, store } = createStore({ current_workspace_id: 'workspace1' }) + + selectServer(server, store) + + expect(values['server']).toBe(server) + expect(values['current_workspace_id']).toBeUndefined() +}) + +test(`selectServer: refuses while ${endpointEnvVar} is set`, () => { + process.env[endpointEnvVar] = 'http://localhost:3020' + const { store } = createStore() + + expect(() => { + selectServer(server, store) + }).toThrow(`Cannot select a server while ${endpointEnvVar} is set`) +}) + +test('selectWorkspace: stores the workspace selection', () => { + const { values, store } = createStore() + + selectWorkspace('workspace1', store) + + expect(values['current_workspace_id']).toBe('workspace1') +}) + +test(`selectWorkspace: refuses while ${workspaceIdEnvVar} is set`, () => { + process.env[workspaceIdEnvVar] = 'workspace_env' + const { store } = createStore() + + expect(() => { + selectWorkspace('workspace1', store) + }).toThrow(`Cannot select a workspace while ${workspaceIdEnvVar} is set`) +}) + +test('selectFakeServer: stores the server and its well-known token', () => { + const { values, store } = createStore({ current_workspace_id: 'workspace1' }) + + const { server: fakeServer } = selectFakeServer('abc123', store) + + expect(fakeServer).toBe('https://abc123.fakeseamconnect.seam.vc') + expect(values['server']).toBe(fakeServer) + expect(values[`${fakeServer}.pat`]).toBe('seam_apikey1_token') + expect(values['current_workspace_id']).toBeUndefined() +}) + +test(`selectFakeServer: refuses while ${endpointEnvVar} is set`, () => { + process.env[endpointEnvVar] = server + const { store } = createStore() + + expect(() => selectFakeServer('abc123', store)).toThrow( + `Cannot select a server while ${endpointEnvVar} is set`, + ) +}) diff --git a/src/lib/auth/operations.ts b/src/lib/auth/operations.ts new file mode 100644 index 00000000..5df8a796 --- /dev/null +++ b/src/lib/auth/operations.ts @@ -0,0 +1,162 @@ +import { randomBytes } from 'node:crypto' + +import { getConfigStore, type SeamConfigStore } from '../config/index.js' +import { type AuthContext, resolveAuth } from '../context.js' +import { + assertEnvVarUnset, + endpointEnvVar, + tokenEnvVar, + workspaceIdEnvVar, +} from '../env.js' +import { validateToken } from './validate-token.js' + +/** A stored auth setting an environment variable may override. */ +export type AuthSetting = 'server' | 'token' | 'workspaceId' + +/** + * Refuse to store a setting the environment overrides. + * + * The env-override policy lives here alone: every auth mutation asserts + * through this before writing, so a command that appears to succeed cannot + * leave the CLI using something else. + * + * @param action What the command does, e.g., `log in`. + */ +export const assertMutable = ( + auth: AuthContext, + setting: AuthSetting, + action: string, +): void => { + const { envVar, source, value } = { + server: { + envVar: endpointEnvVar, + source: auth.serverSource, + value: auth.server, + }, + token: { envVar: tokenEnvVar, source: auth.tokenSource, value: auth.token }, + workspaceId: { + envVar: workspaceIdEnvVar, + source: auth.workspaceIdSource, + value: auth.workspaceId, + }, + }[setting] + + if (source !== 'env') return + assertEnvVarUnset(envVar, value, action) +} + +export interface LoginOptions { + server?: string | undefined + token?: string | undefined + workspaceId?: string | undefined +} + +/** + * Store the given credentials, validating the token first. + * + * The token is stored under the server it will be used with, so a given + * server is stored and re-resolved before the token key is derived. + */ +export const login = async ( + { server, token, workspaceId }: LoginOptions, + config: SeamConfigStore = getConfigStore(), +): Promise => { + let auth = resolveAuth(config) + + // Nothing is stored while the environment overrides it, so refuse before + // storing anything rather than part way through. + assertMutable(auth, 'token', 'log in') + if (server != null) assertMutable(auth, 'server', 'select a server') + if (workspaceId != null) { + assertMutable(auth, 'workspaceId', 'select a workspace') + } + + if (server != null) { + config.set('server', server) + config.delete('current_workspace_id') + auth = resolveAuth(config) + } + + if (token != null) { + await validateToken(token, workspaceId) + config.set(`${auth.server}.pat`, token) + config.delete('current_workspace_id') + } + + if (workspaceId != null) { + config.set('current_workspace_id', workspaceId) + } +} + +/** Store the token for the current server, e.g., one just prompted for. */ +export const storeToken = ( + token: string, + config: SeamConfigStore = getConfigStore(), +): void => { + const auth = resolveAuth(config) + assertMutable(auth, 'token', 'log in') + config.set(`${auth.server}.pat`, token) +} + +/** Remove the stored token and workspace selection. */ +export const logout = (config: SeamConfigStore = getConfigStore()): void => { + const auth = resolveAuth(config) + assertMutable(auth, 'token', 'log out') + config.delete(`${auth.server}.pat`) + // Configs written before tokens were stored per server may still hold an + // un-namespaced token, so drop that too. + config.delete('pat') + config.delete('current_workspace_id') +} + +/** + * Store the server to make requests against. + * + * The workspace selection belongs to the previous server, so it is cleared. + */ +export const selectServer = ( + server: string, + config: SeamConfigStore = getConfigStore(), +): void => { + assertMutable(resolveAuth(config), 'server', 'select a server') + config.set('server', server) + config.delete('current_workspace_id') +} + +/** Store the workspace requests are made against. */ +export const selectWorkspace = ( + workspaceId: string, + config: SeamConfigStore = getConfigStore(), +): void => { + assertMutable(resolveAuth(config), 'workspaceId', 'select a workspace') + config.set('current_workspace_id', workspaceId) +} + +/** + * Point the CLI at a fake Seam Connect server and store the well-known + * token it accepts. Returns the generated server URL for reporting. + */ +export const selectFakeServer = ( + urlSeed: string = randomBytes(5).toString('hex'), + config: SeamConfigStore = getConfigStore(), +): { server: string; token: string } => { + const auth = resolveAuth(config) + assertMutable(auth, 'server', 'select a server') + assertMutable(auth, 'token', 'log in') + + const server = `https://${urlSeed}.fakeseamconnect.seam.vc` + const token = 'seam_apikey1_token' + config.set('server', server) + config.set(`${server}.pat`, token) + config.delete('current_workspace_id') + + return { server, token } +} + +/** Store whether API definitions come from the server instead of npm. */ +export const setUseRemoteApiDefs = ( + useRemoteApiDefs: boolean, + config: SeamConfigStore = getConfigStore(), +): void => { + config.set('use_remote_api_defs', useRemoteApiDefs) +} diff --git a/src/lib/interact/interact-for-login.ts b/src/lib/interact/interact-for-login.ts index 507b198d..e4ac4ce7 100644 --- a/src/lib/interact/interact-for-login.ts +++ b/src/lib/interact/interact-for-login.ts @@ -1,10 +1,10 @@ import { isApiKey, isPersonalAccessToken } from '@seamapi/http/connect' import chalk from 'chalk' +import { assertMutable, storeToken } from '../auth/operations.js' import { validateToken } from '../auth/validate-token.js' import { getConfigStore } from '../config/index.js' import { resolveAuth } from '../context.js' -import { assertEnvVarUnset, getTokenFromEnv, tokenEnvVar } from '../env.js' import { getOutput } from '../output/get-output.js' import { promptText } from './prompt.js' import { withLoading } from '../output/with-loading.js' @@ -13,13 +13,14 @@ import { interactForWorkspaceId } from './interact-for-workspace-id.js' export const interactForLogin = async () => { const config = getConfigStore() const output = getOutput() - const { server } = resolveAuth(config) + const auth = resolveAuth(config) - assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in') + // Refuse before prompting: nothing typed here could be stored. + assertMutable(auth, 'token', 'log in') - if (server.includes('localhost')) { + if (auth.server.includes('localhost')) { output.info( - `You're using a local Seam Connect instance, you can enter the API Key to your local user, you can create a new user from:\n\n${server}/admin/create_user_with_api_key`, + `You're using a local Seam Connect instance, you can enter the API Key to your local user, you can create a new user from:\n\n${auth.server}/admin/create_user_with_api_key`, ) } else { output.info( @@ -52,6 +53,6 @@ export const interactForLogin = async () => { ) } - config.set(`${server}.pat`, token) + storeToken(token, config) output.info(`Token saved! You may begin using the CLI!`) } diff --git a/src/lib/interact/interact-for-server-selection.ts b/src/lib/interact/interact-for-server-selection.ts index cb038a63..ae2fa8cb 100644 --- a/src/lib/interact/interact-for-server-selection.ts +++ b/src/lib/interact/interact-for-server-selection.ts @@ -1,18 +1,18 @@ import { randomBytes } from 'node:crypto' -import { getConfigStore } from '../config/index.js' import { - assertEnvVarUnset, - endpointEnvVar, - getEndpointFromEnv, - getTokenFromEnv, - tokenEnvVar, -} from '../env.js' + assertMutable, + selectFakeServer, + selectServer, +} from '../auth/operations.js' +import { getConfigStore } from '../config/index.js' +import { resolveAuth } from '../context.js' import { getOutput } from '../output/get-output.js' import { promptAutocomplete, promptText } from './prompt.js' export async function interactForServerSelection() { - assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server') + const config = getConfigStore() + assertMutable(resolveAuth(config), 'server', 'select a server') const servers = [ 'http://localhost:3020', @@ -26,7 +26,6 @@ export async function interactForServerSelection() { choices: servers.map((server) => ({ label: server, value: server })), }) - const config = getConfigStore() const output = getOutput() if (server === servers[2]) { let userUrlSeed = await promptText({ @@ -37,14 +36,10 @@ export async function interactForServerSelection() { if (userUrlSeed.trim().length === 0) { userUrlSeed = randomBytes(5).toString('hex') } - assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in') - const fakeServerUrl = `https://${userUrlSeed}.fakeseamconnect.seam.vc` - config.set('server', fakeServerUrl) - config.set(`${fakeServerUrl}.pat`, `seam_apikey1_token`) + selectFakeServer(userUrlSeed, config) output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) } else { - config.set('server', server) + selectServer(server, config) } - config.delete('current_workspace_id') output.info(`Server set to ${server}`) } diff --git a/src/lib/interact/interact-for-use-remote-api-defs.ts b/src/lib/interact/interact-for-use-remote-api-defs.ts index 1b3818d0..85d9817f 100644 --- a/src/lib/interact/interact-for-use-remote-api-defs.ts +++ b/src/lib/interact/interact-for-use-remote-api-defs.ts @@ -1,4 +1,4 @@ -import { getConfigStore } from '../config/index.js' +import { setUseRemoteApiDefs } from '../auth/operations.js' import { getOutput } from '../output/get-output.js' import { promptSelect } from './prompt.js' @@ -17,7 +17,6 @@ export async function interactForUseRemoteApiDefs() { ], }) - const config = getConfigStore() - config.set('use_remote_api_defs', useRemoteApiDefs) + setUseRemoteApiDefs(useRemoteApiDefs) getOutput().info(`Use remote API Definitions: ${useRemoteApiDefs}`) } diff --git a/src/lib/interact/interact-for-workspace-id.ts b/src/lib/interact/interact-for-workspace-id.ts index 2484e843..f3da1bc1 100644 --- a/src/lib/interact/interact-for-workspace-id.ts +++ b/src/lib/interact/interact-for-workspace-id.ts @@ -1,12 +1,8 @@ import { SeamHttpWithoutWorkspace } from '@seamapi/http/connect' +import { assertMutable, selectWorkspace } from '../auth/operations.js' import { getConfigStore } from '../config/index.js' import { resolveAuth } from '../context.js' -import { - assertEnvVarUnset, - getWorkspaceIdFromEnv, - workspaceIdEnvVar, -} from '../env.js' import { withLoading } from '../output/with-loading.js' import { getSeamMultiWorkspace } from '../seam/client.js' import { promptAutocomplete } from './prompt.js' @@ -14,11 +10,8 @@ import { promptAutocomplete } from './prompt.js' export const interactForWorkspaceId = async (personalAccessToken?: string) => { const config = getConfigStore() - assertEnvVarUnset( - workspaceIdEnvVar, - getWorkspaceIdFromEnv(), - 'select a workspace', - ) + // Refuse before prompting: nothing selected here could be stored. + assertMutable(resolveAuth(config), 'workspaceId', 'select a workspace') const seam = personalAccessToken ? SeamHttpWithoutWorkspace.fromPersonalAccessToken(personalAccessToken, { @@ -40,6 +33,6 @@ export const interactForWorkspaceId = async (personalAccessToken?: string) => { })), }) - config.set('current_workspace_id', workspaceId) + selectWorkspace(workspaceId, config) return workspaceId } From 5bdacdeb8a33ba8d2b5a2656138368d6cf241a43 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 02:39:03 +0000 Subject: [PATCH 07/20] test: Deliver fakes by injection, never by module-path mock Adds TESTING.md and conforms the tests to it: ConfigStore interface + createMemoryConfigStore + setConfigStore slot; PromptClient set/reset slot + createMemoryPrompt; injected validate dependency on login(). The three tests using vi.mock module-path fakes are rewritten against injected fakes; no vi.mock substitution remains. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- TESTING.md | 203 ++++++++++++++ src/lib/auth/operations.test.ts | 155 ++++++----- src/lib/auth/operations.ts | 21 +- src/lib/config/config-store.ts | 31 ++- src/lib/config/create-memory-config-store.ts | 46 ++++ src/lib/config/index.ts | 3 + src/lib/context.test.ts | 5 +- src/lib/context.ts | 6 +- src/lib/interact/create-memory-prompt.ts | 76 ++++++ .../interact-for-blueprint-object.test.ts | 101 +++---- .../interact-for-command-selection.test.ts | 27 +- .../interact-for-custom-metadata.test.ts | 50 ++-- src/lib/interact/prompt.ts | 254 ++++++++++++------ 13 files changed, 690 insertions(+), 288 deletions(-) create mode 100644 TESTING.md create mode 100644 src/lib/config/create-memory-config-store.ts create mode 100644 src/lib/interact/create-memory-prompt.ts diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 00000000..7af8cd8b --- /dev/null +++ b/TESTING.md @@ -0,0 +1,203 @@ +# Testing the Seam CLI + +How to decide, for any module in this repo, what kind of test it gets and where +the fake goes. + +## Principles + +1. **Classical by default.** Assert on returned values and on data captured at + a process edge. A test that asserts "function A called function B" is + testing the implementation unless B is the outside world. +2. **Fake only where data leaves the process** — the terminal, the wire, the + disk location, the environment. Everything on our side of those edges stays + real in every test, including sibling modules in `src/lib`. +3. **Fakes are injected values, never module-path substitution.** + `vi.mock('./config/index.js')` couples the test to file layout and to the + accidental shape of the import; a rename or internal refactor breaks tests + while behavior is unchanged. A fake is a real implementation of a narrow + interface, handed to the code under test. +4. **`createMemoryOutput()` + `setOutput()` is the house pattern** + (`src/lib/output/`): a tiny interface, a real in-memory implementation, a + capture you assert on. Config (`createMemoryConfigStore()` + + `setConfigStore()`), the prompt layer (`createMemoryPrompt()` + + `setPromptClient()`), and the Seam API get the same treatment; nothing else + needs it. +5. **The e2e suite proves wiring once; module tests prove behavior + everywhere.** Don't re-prove auth headers in a unit test, and don't push + branching logic into `test/cli.test.ts`. + +## Taxonomy + +| Module kind | The tell | Default test | Gets faked | Never faked | +| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------- | +| **Pure transform** — `render/help`, `render/completion/render-*`, `output/select-response-payload`, `args/parse` | Value in → value out; no I/O imports | Classical unit, real values | Nothing | Anything | +| **Decision over injected data** — `interact-for-command-selection` (non-interactive), `blueprint/endpoint`, `context.ts` | Takes `CliContext` / blueprint / config store as a parameter | Classical with a literal ctx object (`interact-for-command-selection.test.ts` is the model) | Nothing — a hand-built blueprint literal is a fixture, not a fake | The traversal/decision logic | +| **Prompt flow** — `interact/interact-for-*` importing `interact/prompt.js` | Imports `interact/prompt.js` | Classical on the returned value, memory output, scripted prompt fake; assert the choice list _offered_ where the prompt is the UX | The prompt layer (the whole `prompts` edge), output | The module's own branching and param assembly | +| **Config & state** — `config/config-store`, `config/migrate` | Touches `Configstore` / `env-paths` | Classical against a real store in a temp directory — it's a JSON file, and split/merge/migration _is_ the behavior | The directory; env vars (`vi.stubEnv`) | `Configstore` or fs behavior | +| **Network** — `seam/request`, `auth/validate-token`, `blueprint/source-npm` | Constructs `SeamHttp` or calls `fetch` | Classical against a fake port (or a stubbed global `fetch` with captured requests, as `blueprint/source-npm.test.ts` does); assert the payload sent _and_ the value returned | The `SeamApi` port / global `fetch` | Status handling, payload selection, formatting — that's the unit | +| **Orchestration** — `bin/cli.ts` | Reads argv/env, wires everything | E2e: spawn via `execa`, `node:http` fake server, XDG temp dirs (`test/cli.test.ts`) | The far end of the wire; the home directories | Anything in-process | + +## The mocking boundary + +Legitimate fakes in this repo, exhaustively: the **terminal** (prompt layer + +output streams), the **wire** (fake `node:http` server for the spawned e2e; the +`SeamApi` port in-process; `fetch` stub for the npm registry), the **disk +location** (temp dirs — never a fake fs), and **env vars**. Everything else — +`Configstore`, blueprint traversal, response formatting, `command-spec`, any +sibling in `src/lib` — must stay real, because faking it removes exactly the +thing the test exists to prove. + +## London vs. classical: the rule + +A mock-verification assertion is legitimate **only when the interaction is +itself the user-observable contract** — when the message crosses a process +boundary. "We sent this request body to `/devices/list`" is behavior: the +request is the product. "The prompt offered these choices with these hints" is +behavior: the choices are what the user sees +(`interact-for-blueprint-object.test.ts` asserting on the recorded `choices` +is the good in-repo example). "`resolveAuth` called `getConfigStore`" is +implementation: the contract is _what server comes back_, not how it was +looked up. + +Even at a real boundary, prefer **capture-then-assert** over +`toHaveBeenCalledWith`: have the fake record what it received (like the e2e +server's `requests` array, or `createMemoryPrompt()`'s `questions`) and make +classical assertions on the capture. A good London test asserts on the content +of one outbound message; a bad one asserts call counts and ordering of +internal helpers. + +## The real-HTTP line + +A test earns a real HTTP server only if it proves wiring that exists solely in +the real transport stack: `SeamHttp` auth-header construction, token-type +dispatch, endpoint resolution, `validateStatus`, and the exit code of the +actual spawned process. That is `test/cli.test.ts` and nothing else. Everything +in-process fakes at the port. Today the e2e file is ~20% of tests; hold it +there — each user-visible flow once end-to-end, while new module tests grow the +HTTP-free share. + +## The Seam SDK boundary + +**Wrap it behind our own narrow port.** Not `vi.mock('./seam/client.js')`, and +not dependency-injecting `SeamHttp`: both force the fake to imitate an +axios-shaped SDK surface (`client.post` returning an `AxiosResponse`), so +tests end up re-verifying the SDK's shape instead of our behavior. The CLI is +blueprint-driven and has one chokepoint — +`seam.client.post(path, params, { validateStatus: () => true })` in +`seam/request.ts` — so the port is one method: + +```ts +// src/lib/seam/api.ts +export interface SeamApiResponse { + status: number + data: unknown +} + +export interface SeamApi { + post: ( + path: string, + params: Record, + ) => Promise +} + +export const createSeamApi = async (): Promise => { + const seam = await getSeam() // the only place SeamHttp appears + return { + post: async (path, params) => { + const { status, data } = await seam.client.post(path, params, { + validateStatus: () => true, + }) + return { status, data } + }, + } +} +``` + +The fake is the in-process mirror of the e2e server — a routes table plus a +capture: + +```ts +// src/lib/seam/create-memory-seam-api.ts +export const createMemorySeamApi = ( + routes: Record, +) => { + const requests: Array<{ path: string; params: Record }> = [] + const api: SeamApi = { + post: async (path, params) => { + requests.push({ path, params }) + return ( + routes[path] ?? { status: 404, data: { error: { type: 'not_found' } } } + ) + }, + } + return { api, requests } +} +``` + +This split also separates transport from presentation in `seam/request.ts` +(which historically also formatted output and set `process.exitCode`), so the +error-status → exit-code behavior becomes a classical test with zero HTTP: + +```ts +const { api, requests } = createMemorySeamApi({ + '/devices/list': { status: 400, data: { error: { type: 'invalid_input' } } }, +}) +const memory = createMemoryOutput() + +await requestSeamApi( + { path: '/devices/list', params: { limit: 5 } }, + { api, output: memory.output }, +) + +// Boundary interaction: the outbound message IS the behavior. +expect(requests).toEqual([{ path: '/devices/list', params: { limit: 5 } }]) +expect(memory.stdout()).toContain('invalid_input') +expect(process.exitCode).toBe(1) +``` + +`auth/validate-token.ts` and the resource pickers (`interact/interact-for-device.ts` +and friends) use typed SDK methods and stay on the real SDK, covered by e2e — +don't invent a second port for them. + +## Singletons: `getConfigStore`, `getOutput`, the prompt client + +Target shape: `CliContext = { config, auth, output, blueprint, interactivity, api }` +threaded as a parameter, with port boundaries exactly the ones above — config +store interface, `Output`, the prompt client, `SeamApi`. That makes every fake +an ordinary argument. + +The rule: a singleton getter is tolerable only when it has (a) a setter + +reset and (b) an in-memory fake of the same narrow interface. In this repo +that is `getOutput`/`setOutput`/`resetOutput` + `createMemoryOutput()`, +`getConfigStore`/`setConfigStore`/`resetConfigStore` + +`createMemoryConfigStore()`, and `setPromptClient`/`resetPromptClient` + +`createMemoryPrompt()`. `vi.mock` on a module path is never the delivery +mechanism for a fake. Direct env reads (`env.ts`, `resolveAuth`) are a genuine +ambient edge — setting env vars in the test is fine. + +## Anti-pattern + +The shape the (since deleted) `get-server.test.ts` used: + +```ts +const storedConfig: Record = {} +vi.mock('./config/index.js', () => ({ + getConfigStore: vi.fn(() => ({ get: (key: string) => storedConfig[key] })), +})) +afterEach(() => { + vi.mocked(getConfigStore).mockClear() +}) +``` + +Three things wrong: the fake is delivered by file path, so renaming +`config/index.js` breaks the test; the fake's shape is whatever the test author +remembered (`{ get }`) rather than the store's interface, so it drifts +silently; and the `mockClear` bookkeeping exists only because the mock is +module-global state. The same tests written against an injected +`createMemoryConfigStore()` keep every assertion and lose all three problems. + +## Rule of thumb + +> **Assert on what leaves the process — stdout, the config file, the request +> payload, the choices offered, the exit code. Fake only the edge it leaves +> through, and keep everything on our side of that edge real.** diff --git a/src/lib/auth/operations.test.ts b/src/lib/auth/operations.test.ts index 8dea2113..d0d56967 100644 --- a/src/lib/auth/operations.test.ts +++ b/src/lib/auth/operations.test.ts @@ -1,6 +1,6 @@ -import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, expect, test } from 'vitest' -import type { SeamConfigStore } from '../config/index.js' +import { createMemoryConfigStore } from '../config/create-memory-config-store.js' import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from '../env.js' import { login, @@ -10,28 +10,26 @@ import { selectWorkspace, storeToken, } from './operations.js' -import { validateToken } from './validate-token.js' - -vi.mock('./validate-token.js', () => ({ - validateToken: vi.fn(async () => {}), -})) const server = 'https://connect.example.com' -const createStore = ( - values: Record = {}, -): { values: Record; store: SeamConfigStore } => ({ - values, - store: { - get: (key: string) => values[key], - set: (key: string, value: unknown) => { - values[key] = value - }, - delete: (key: string) => { - delete values[key] +/** + * Validation is a network call, so it is faked at that edge: a capture of + * what would have been validated, asserted on like any outbound message. + */ +const createValidate = (): { + validate: (token: string, workspaceId?: string) => Promise + validated: Array<{ token: string; workspaceId: string | undefined }> +} => { + const validated: Array<{ token: string; workspaceId: string | undefined }> = + [] + return { + validated, + validate: async (token, workspaceId) => { + validated.push({ token, workspaceId }) }, - } as unknown as SeamConfigStore, -}) + } +} const clearEnv = (): void => { delete process.env[endpointEnvVar] @@ -39,97 +37,112 @@ const clearEnv = (): void => { delete process.env[workspaceIdEnvVar] } -beforeEach(() => { - clearEnv() - vi.mocked(validateToken).mockClear() -}) - +beforeEach(clearEnv) afterEach(clearEnv) test('login: stores a validated token under the current server', async () => { - const { values, store } = createStore({ server }) + const store = createMemoryConfigStore({ server }) + const { validate, validated } = createValidate() - await login({ token: 'seam_apikey1_stored' }, store) + await login({ token: 'seam_apikey1_stored' }, store, validate) - expect(validateToken).toHaveBeenCalledWith('seam_apikey1_stored', undefined) - expect(values[`${server}.pat`]).toBe('seam_apikey1_stored') + expect(validated).toEqual([ + { token: 'seam_apikey1_stored', workspaceId: undefined }, + ]) + expect(store.get(`${server}.pat`)).toBe('seam_apikey1_stored') }) test('login: stores the token under a server given alongside it', async () => { - const { values, store } = createStore({ server }) + const store = createMemoryConfigStore({ server }) + const { validate } = createValidate() await login( { server: 'https://other.example.com', token: 'seam_apikey1_stored' }, store, + validate, ) - expect(values['server']).toBe('https://other.example.com') - expect(values['https://other.example.com.pat']).toBe('seam_apikey1_stored') - expect(values[`${server}.pat`]).toBeUndefined() + expect(store.get('server')).toBe('https://other.example.com') + expect(store.get('https://other.example.com.pat')).toBe('seam_apikey1_stored') + expect(store.has(`${server}.pat`)).toBe(false) }) test('login: a new login clears the previous workspace selection', async () => { - const { values, store } = createStore({ + const store = createMemoryConfigStore({ server, current_workspace_id: 'workspace1', }) + const { validate } = createValidate() - await login({ token: 'seam_apikey1_stored' }, store) + await login({ token: 'seam_apikey1_stored' }, store, validate) - expect(values['current_workspace_id']).toBeUndefined() + expect(store.has('current_workspace_id')).toBe(false) }) test('login: stores a workspace given with the token', async () => { - const { values, store } = createStore({ server }) + const store = createMemoryConfigStore({ server }) + const { validate, validated } = createValidate() - await login({ token: 'seam_at1_stored', workspaceId: 'workspace1' }, store) + await login( + { token: 'seam_at1_stored', workspaceId: 'workspace1' }, + store, + validate, + ) - expect(validateToken).toHaveBeenCalledWith('seam_at1_stored', 'workspace1') - expect(values['current_workspace_id']).toBe('workspace1') + expect(validated).toEqual([ + { token: 'seam_at1_stored', workspaceId: 'workspace1' }, + ]) + expect(store.get('current_workspace_id')).toBe('workspace1') }) test(`login: refuses while ${tokenEnvVar} is set, before storing anything`, async () => { process.env[tokenEnvVar] = 'seam_apikey1_env' - const { values, store } = createStore({ server }) + const store = createMemoryConfigStore({ server }) + const { validate, validated } = createValidate() - await expect(login({ token: 'seam_apikey1_stored' }, store)).rejects.toThrow( - `Cannot log in while ${tokenEnvVar} is set`, - ) - expect(values[`${server}.pat`]).toBeUndefined() - expect(validateToken).not.toHaveBeenCalled() + await expect( + login({ token: 'seam_apikey1_stored' }, store, validate), + ).rejects.toThrow(`Cannot log in while ${tokenEnvVar} is set`) + expect(store.has(`${server}.pat`)).toBe(false) + expect(validated).toEqual([]) }) test(`login: refuses a server while ${endpointEnvVar} is set`, async () => { process.env[endpointEnvVar] = server - const { store } = createStore() + const store = createMemoryConfigStore() + const { validate } = createValidate() await expect( - login({ server: 'https://other.example.com' }, store), + login({ server: 'https://other.example.com' }, store, validate), ).rejects.toThrow(`Cannot select a server while ${endpointEnvVar} is set`) }) test(`login: refuses a workspace while ${workspaceIdEnvVar} is set`, async () => { process.env[workspaceIdEnvVar] = 'workspace_env' - const { store } = createStore({ server }) + const store = createMemoryConfigStore({ server }) + const { validate } = createValidate() await expect( - login({ token: 'seam_at1_stored', workspaceId: 'workspace1' }, store), + login( + { token: 'seam_at1_stored', workspaceId: 'workspace1' }, + store, + validate, + ), ).rejects.toThrow( `Cannot select a workspace while ${workspaceIdEnvVar} is set`, ) }) test('storeToken: stores under the current server without validating', () => { - const { values, store } = createStore({ server }) + const store = createMemoryConfigStore({ server }) storeToken('seam_apikey1_stored', store) - expect(values[`${server}.pat`]).toBe('seam_apikey1_stored') - expect(validateToken).not.toHaveBeenCalled() + expect(store.get(`${server}.pat`)).toBe('seam_apikey1_stored') }) test('logout: removes the stored token, legacy token, and workspace', () => { - const { values, store } = createStore({ + const store = createMemoryConfigStore({ server, [`${server}.pat`]: 'seam_apikey1_stored', pat: 'seam_apikey1_legacy', @@ -138,14 +151,14 @@ test('logout: removes the stored token, legacy token, and workspace', () => { logout(store) - expect(values[`${server}.pat`]).toBeUndefined() - expect(values['pat']).toBeUndefined() - expect(values['current_workspace_id']).toBeUndefined() + expect(store.has(`${server}.pat`)).toBe(false) + expect(store.has('pat')).toBe(false) + expect(store.has('current_workspace_id')).toBe(false) }) test(`logout: refuses while ${tokenEnvVar} is set`, () => { process.env[tokenEnvVar] = 'seam_apikey1_env' - const { values, store } = createStore({ + const store = createMemoryConfigStore({ server, [`${server}.pat`]: 'seam_apikey1_stored', }) @@ -153,21 +166,21 @@ test(`logout: refuses while ${tokenEnvVar} is set`, () => { expect(() => { logout(store) }).toThrow(`Cannot log out while ${tokenEnvVar} is set`) - expect(values[`${server}.pat`]).toBe('seam_apikey1_stored') + expect(store.get(`${server}.pat`)).toBe('seam_apikey1_stored') }) test('selectServer: stores the server and clears the workspace', () => { - const { values, store } = createStore({ current_workspace_id: 'workspace1' }) + const store = createMemoryConfigStore({ current_workspace_id: 'workspace1' }) selectServer(server, store) - expect(values['server']).toBe(server) - expect(values['current_workspace_id']).toBeUndefined() + expect(store.get('server')).toBe(server) + expect(store.has('current_workspace_id')).toBe(false) }) test(`selectServer: refuses while ${endpointEnvVar} is set`, () => { process.env[endpointEnvVar] = 'http://localhost:3020' - const { store } = createStore() + const store = createMemoryConfigStore() expect(() => { selectServer(server, store) @@ -175,16 +188,16 @@ test(`selectServer: refuses while ${endpointEnvVar} is set`, () => { }) test('selectWorkspace: stores the workspace selection', () => { - const { values, store } = createStore() + const store = createMemoryConfigStore() selectWorkspace('workspace1', store) - expect(values['current_workspace_id']).toBe('workspace1') + expect(store.get('current_workspace_id')).toBe('workspace1') }) test(`selectWorkspace: refuses while ${workspaceIdEnvVar} is set`, () => { process.env[workspaceIdEnvVar] = 'workspace_env' - const { store } = createStore() + const store = createMemoryConfigStore() expect(() => { selectWorkspace('workspace1', store) @@ -192,19 +205,19 @@ test(`selectWorkspace: refuses while ${workspaceIdEnvVar} is set`, () => { }) test('selectFakeServer: stores the server and its well-known token', () => { - const { values, store } = createStore({ current_workspace_id: 'workspace1' }) + const store = createMemoryConfigStore({ current_workspace_id: 'workspace1' }) const { server: fakeServer } = selectFakeServer('abc123', store) expect(fakeServer).toBe('https://abc123.fakeseamconnect.seam.vc') - expect(values['server']).toBe(fakeServer) - expect(values[`${fakeServer}.pat`]).toBe('seam_apikey1_token') - expect(values['current_workspace_id']).toBeUndefined() + expect(store.get('server')).toBe(fakeServer) + expect(store.get(`${fakeServer}.pat`)).toBe('seam_apikey1_token') + expect(store.has('current_workspace_id')).toBe(false) }) test(`selectFakeServer: refuses while ${endpointEnvVar} is set`, () => { process.env[endpointEnvVar] = server - const { store } = createStore() + const store = createMemoryConfigStore() expect(() => selectFakeServer('abc123', store)).toThrow( `Cannot select a server while ${endpointEnvVar} is set`, diff --git a/src/lib/auth/operations.ts b/src/lib/auth/operations.ts index 5df8a796..35fcf67e 100644 --- a/src/lib/auth/operations.ts +++ b/src/lib/auth/operations.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto' -import { getConfigStore, type SeamConfigStore } from '../config/index.js' +import { type ConfigStore, getConfigStore } from '../config/index.js' import { type AuthContext, resolveAuth } from '../context.js' import { assertEnvVarUnset, @@ -56,10 +56,13 @@ export interface LoginOptions { * * The token is stored under the server it will be used with, so a given * server is stored and re-resolved before the token key is derived. + * + * Validation reaches the network, so a test may inject its own `validate`. */ export const login = async ( { server, token, workspaceId }: LoginOptions, - config: SeamConfigStore = getConfigStore(), + config: ConfigStore = getConfigStore(), + validate: typeof validateToken = validateToken, ): Promise => { let auth = resolveAuth(config) @@ -78,7 +81,7 @@ export const login = async ( } if (token != null) { - await validateToken(token, workspaceId) + await validate(token, workspaceId) config.set(`${auth.server}.pat`, token) config.delete('current_workspace_id') } @@ -91,7 +94,7 @@ export const login = async ( /** Store the token for the current server, e.g., one just prompted for. */ export const storeToken = ( token: string, - config: SeamConfigStore = getConfigStore(), + config: ConfigStore = getConfigStore(), ): void => { const auth = resolveAuth(config) assertMutable(auth, 'token', 'log in') @@ -99,7 +102,7 @@ export const storeToken = ( } /** Remove the stored token and workspace selection. */ -export const logout = (config: SeamConfigStore = getConfigStore()): void => { +export const logout = (config: ConfigStore = getConfigStore()): void => { const auth = resolveAuth(config) assertMutable(auth, 'token', 'log out') config.delete(`${auth.server}.pat`) @@ -116,7 +119,7 @@ export const logout = (config: SeamConfigStore = getConfigStore()): void => { */ export const selectServer = ( server: string, - config: SeamConfigStore = getConfigStore(), + config: ConfigStore = getConfigStore(), ): void => { assertMutable(resolveAuth(config), 'server', 'select a server') config.set('server', server) @@ -126,7 +129,7 @@ export const selectServer = ( /** Store the workspace requests are made against. */ export const selectWorkspace = ( workspaceId: string, - config: SeamConfigStore = getConfigStore(), + config: ConfigStore = getConfigStore(), ): void => { assertMutable(resolveAuth(config), 'workspaceId', 'select a workspace') config.set('current_workspace_id', workspaceId) @@ -138,7 +141,7 @@ export const selectWorkspace = ( */ export const selectFakeServer = ( urlSeed: string = randomBytes(5).toString('hex'), - config: SeamConfigStore = getConfigStore(), + config: ConfigStore = getConfigStore(), ): { server: string; token: string } => { const auth = resolveAuth(config) assertMutable(auth, 'server', 'select a server') @@ -156,7 +159,7 @@ export const selectFakeServer = ( /** Store whether API definitions come from the server instead of npm. */ export const setUseRemoteApiDefs = ( useRemoteApiDefs: boolean, - config: SeamConfigStore = getConfigStore(), + config: ConfigStore = getConfigStore(), ): void => { config.set('use_remote_api_defs', useRemoteApiDefs) } diff --git a/src/lib/config/config-store.ts b/src/lib/config/config-store.ts index f0fbc8ac..c3cedcaa 100644 --- a/src/lib/config/config-store.ts +++ b/src/lib/config/config-store.ts @@ -11,14 +11,37 @@ const currentWorkspaceIdKey = 'current_workspace_id' const patKey = 'pat' const paths = envPaths('seam', { suffix: '' }) -let configStore: SeamConfigStore | null = null +/** + * What a config store can do, regardless of where it keeps the values. + * + * The CLI reads and writes through this interface so a test may hand code an + * in-memory store (see `create-memory-config-store.ts`) instead of the real + * file-backed one. + */ +export interface ConfigStore { + readonly path: string + all: Record + readonly size: number + get: (key: string) => unknown + set: (key: string | Record, value?: unknown) => void + has: (key: string) => boolean + delete: (key: string) => void + clear: () => void +} + +let configStore: ConfigStore | null = null -export const getConfigStore = (): SeamConfigStore => { +export const getConfigStore = (): ConfigStore => { configStore ??= createConfigStore() return configStore } -/** Drop the memoized store so a test may read a fresh one. */ +/** Replace the store, e.g., with an in-memory one for a test. */ +export const setConfigStore = (store: ConfigStore): void => { + configStore = store +} + +/** Drop the current store so the next read builds the real one. */ export const resetConfigStore = (): void => { configStore = null } @@ -91,7 +114,7 @@ export const splitConfig = ( return { settings, state } } -export class SeamConfigStore { +export class SeamConfigStore implements ConfigStore { readonly path: string constructor( diff --git a/src/lib/config/create-memory-config-store.ts b/src/lib/config/create-memory-config-store.ts new file mode 100644 index 00000000..0d56e6b2 --- /dev/null +++ b/src/lib/config/create-memory-config-store.ts @@ -0,0 +1,46 @@ +import type { ConfigStore } from './config-store.js' + +/** + * A real {@link ConfigStore} held in memory, for tests. + * + * Keys are flat: the file-backed store nests dotted keys, but nothing reads + * a value back by a different spelling than it was written with. + */ +export const createMemoryConfigStore = ( + initialValues: Record = {}, +): ConfigStore => { + const values = new Map(Object.entries(initialValues)) + + return { + path: '/memory/cli.json', + get all() { + return Object.fromEntries(values) + }, + set all(newValues: Record) { + values.clear() + for (const [key, value] of Object.entries(newValues)) { + values.set(key, value) + } + }, + get size() { + return values.size + }, + get: (key) => values.get(key), + set: (key, value) => { + if (typeof key === 'string') { + values.set(key, value) + return + } + for (const [configKey, configValue] of Object.entries(key)) { + values.set(configKey, configValue) + } + }, + has: (key) => values.has(key), + delete: (key) => { + values.delete(key) + }, + clear: () => { + values.clear() + }, + } +} diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index 173ec7a9..1d0f058e 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -1,5 +1,8 @@ export { + type ConfigStore, getConfigStore, resetConfigStore, type SeamConfigStore, + setConfigStore, } from './config-store.js' +export { createMemoryConfigStore } from './create-memory-config-store.js' diff --git a/src/lib/context.test.ts b/src/lib/context.test.ts index 420818ca..fb5fafcd 100644 --- a/src/lib/context.test.ts +++ b/src/lib/context.test.ts @@ -1,13 +1,12 @@ import { afterEach, beforeEach, expect, test } from 'vitest' -import type { SeamConfigStore } from './config/index.js' +import { createMemoryConfigStore } from './config/create-memory-config-store.js' import { resolveAuth } from './context.js' import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from './env.js' const server = 'https://connect.example.com' -const store = (values: Record = {}): SeamConfigStore => - ({ get: (key: string) => values[key] }) as unknown as SeamConfigStore +const store = createMemoryConfigStore const clearEnv = (): void => { delete process.env[endpointEnvVar] diff --git a/src/lib/context.ts b/src/lib/context.ts index bc97466c..bce4286b 100644 --- a/src/lib/context.ts +++ b/src/lib/context.ts @@ -1,6 +1,6 @@ import type { Interactivity } from './args/parse.js' import type { ApiBlueprint } from './blueprint/index.js' -import { getConfigStore, type SeamConfigStore } from './config/index.js' +import { type ConfigStore, getConfigStore } from './config/index.js' import { getEndpointFromEnv, getTokenFromEnv, @@ -29,7 +29,7 @@ export interface AuthContext { } export const resolveAuth = ( - config: SeamConfigStore = getConfigStore(), + config: ConfigStore = getConfigStore(), ): AuthContext => { const envServer = getEndpointFromEnv() const storedServer = config.get('server') @@ -64,7 +64,7 @@ export const resolveAuth = ( * shape it acts on, and how it may interact with the user. */ export interface CliContext { - config: SeamConfigStore + config: ConfigStore auth: AuthContext blueprint: ApiBlueprint interactivity: Interactivity diff --git a/src/lib/interact/create-memory-prompt.ts b/src/lib/interact/create-memory-prompt.ts new file mode 100644 index 00000000..78328dd9 --- /dev/null +++ b/src/lib/interact/create-memory-prompt.ts @@ -0,0 +1,76 @@ +import { + PromptCancelledError, + type PromptChoice, + type PromptClient, + type PromptSelectOptions, +} from './prompt.js' + +/** A question a {@link PromptClient} was asked, as a test sees it. */ +export interface PromptQuestion { + kind: + | 'text' + | 'number' + | 'confirm' + | 'select' + | 'autocomplete' + | 'autocompleteMultiselect' + message: string + choices?: Array> +} + +/** Scripted in place of an answer to dismiss that prompt. */ +export const cancelPrompt = Symbol('cancel-prompt') + +export interface MemoryPrompt { + client: PromptClient + /** Every question asked, in order — assert on what the user was offered. */ + questions: PromptQuestion[] +} + +/** + * A real {@link PromptClient} that answers from a script instead of a + * terminal, and records every question it was asked. + * + * Each ask consumes the next scripted answer in turn. Scripting + * {@link cancelPrompt} dismisses that prompt, and an exhausted script + * dismisses every prompt after it, exactly as a user cancelling would. + */ +export const createMemoryPrompt = (script: unknown[] = []): MemoryPrompt => { + const questions: PromptQuestion[] = [] + const answers = [...script] + + const answer = (question: PromptQuestion): unknown => { + questions.push(question) + if (answers.length === 0) throw new PromptCancelledError() + const value = answers.shift() + if (value === cancelPrompt) throw new PromptCancelledError() + return value + } + + const client: PromptClient = { + canPrompt: () => true, + text: async ({ message }) => answer({ kind: 'text', message }) as string, + number: async ({ message }) => + answer({ kind: 'number', message }) as number, + confirm: async ({ message }) => + answer({ kind: 'confirm', message }) as boolean, + select: async ({ message, choices }: PromptSelectOptions) => + answer({ kind: 'select', message, choices }) as Value, + autocomplete: async ({ + message, + choices, + }: PromptSelectOptions) => + answer({ kind: 'autocomplete', message, choices }) as Value, + autocompleteMultiselect: async ({ + message, + choices, + }: PromptSelectOptions) => + answer({ + kind: 'autocompleteMultiselect', + message, + choices, + }) as Value[], + } + + return { client, questions } +} diff --git a/src/lib/interact/interact-for-blueprint-object.test.ts b/src/lib/interact/interact-for-blueprint-object.test.ts index 2ce2c011..cc5c9ee3 100644 --- a/src/lib/interact/interact-for-blueprint-object.test.ts +++ b/src/lib/interact/interact-for-blueprint-object.test.ts @@ -1,39 +1,35 @@ import type { Parameter } from '@seamapi/blueprint' -import { beforeEach, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, expect, test } from 'vitest' import type { CliContext } from '../context.js' import { createMemoryOutput } from '../output/create-memory-output.js' import { setOutput } from '../output/get-output.js' -import { interactForBlueprintObject } from './interact-for-blueprint-object.js' -import type * as PromptModule from './prompt.js' import { - promptAutocomplete, - PromptCancelledError, - promptSelect, - promptText, - withBackHint, -} from './prompt.js' - -// Only the prompts themselves are replaced, so the real PromptCancelledError -// and withBackHint are used, as they are in production. -vi.mock('./prompt.js', async (importOriginal) => ({ - ...(await importOriginal()), - promptText: vi.fn(), - promptNumber: vi.fn(), - promptConfirm: vi.fn(), - promptSelect: vi.fn(), - promptAutocomplete: vi.fn(async () => 'done'), - promptAutocompleteMultiselect: vi.fn(), -})) + cancelPrompt, + createMemoryPrompt, + type MemoryPrompt, +} from './create-memory-prompt.js' +import { interactForBlueprintObject } from './interact-for-blueprint-object.js' +import { resetPromptClient, setPromptClient, withBackHint } from './prompt.js' + +let memoryPrompt: MemoryPrompt + +/** Replace the prompt client, scripting an answer for each ask in turn. */ +const scriptPrompt = (script: unknown[]): MemoryPrompt => { + memoryPrompt = createMemoryPrompt(script) + setPromptClient(memoryPrompt.client) + return memoryPrompt +} beforeEach(() => { - vi.mocked(promptAutocomplete).mockClear() - vi.mocked(promptAutocomplete).mockImplementation(async () => 'done') - vi.mocked(promptText).mockReset() + // Any unscripted review prompt submits immediately. + scriptPrompt(['done']) // Keep the interactive chrome out of the test output. setOutput(createMemoryOutput().output) }) +afterEach(resetPromptClient) + const parameters = [ { name: 'device_id', isRequired: true, format: 'id' }, { name: 'name', isRequired: false, format: 'string' }, @@ -52,7 +48,7 @@ test('interactForBlueprintObject: submits without prompting once every required await expect( interactForBlueprintObject(args({ device_id: 'device1' }), ctx('auto')), ).resolves.toEqual({ device_id: 'device1' }) - expect(promptAutocomplete).not.toHaveBeenCalled() + expect(memoryPrompt.questions).toHaveLength(0) }) test('interactForBlueprintObject: prompts to review given parameters when interactive', async () => { @@ -62,7 +58,7 @@ test('interactForBlueprintObject: prompts to review given parameters when intera ctx('interactive'), ), ).resolves.toEqual({ device_id: 'device1' }) - expect(promptAutocomplete).toHaveBeenCalledTimes(1) + expect(memoryPrompt.questions).toHaveLength(1) }) test('interactForBlueprintObject: prefills the prompt with the given parameters', async () => { @@ -71,7 +67,7 @@ test('interactForBlueprintObject: prefills the prompt with the given parameters' ctx('interactive'), ) - const { choices } = vi.mocked(promptAutocomplete).mock.calls[0]?.[0] as { + const { choices } = memoryPrompt.questions[0] as unknown as { choices: Array<{ value: string; hint?: string }> } expect(choices.find(({ value }) => value === 'device_id')).toMatchObject({ @@ -86,7 +82,7 @@ test('interactForBlueprintObject: submits without prompting when non-interactive ctx('non-interactive'), ), ).resolves.toEqual({ device_id: 'device1' }) - expect(promptAutocomplete).not.toHaveBeenCalled() + expect(memoryPrompt.questions).toHaveLength(0) }) test('interactForBlueprintObject: rejects missing required parameters when non-interactive', async () => { @@ -98,7 +94,7 @@ test('interactForBlueprintObject: rejects missing required parameters when non-i ).rejects.toThrowError( 'Missing required parameter for /devices/get: --device-id', ) - expect(promptAutocomplete).not.toHaveBeenCalled() + expect(memoryPrompt.questions).toHaveLength(0) }) const falsyParameters = [ @@ -123,7 +119,7 @@ test.for([ await expect( interactForBlueprintObject(falsyArgs({ enabled: value }), ctx('auto')), ).resolves.toEqual({ enabled: value }) - expect(promptAutocomplete).not.toHaveBeenCalled() + expect(memoryPrompt.questions).toHaveLength(0) }, ) @@ -154,21 +150,19 @@ test('interactForBlueprintObject: offers the submit choice when a required value ctx('interactive'), ) - const { choices } = vi.mocked(promptAutocomplete).mock.calls[0]?.[0] as { + const { choices } = memoryPrompt.questions[0] as unknown as { choices: Array<{ value: string; label: string }> } expect(choices.map(({ value }) => value)).toContain('done') }) -// `custom_metadata` and `custom_metadata_has` are both records, a format with no -// branch of its own, so each has to be routed by name. +// `custom_metadata` and `custom_metadata_has` are both records, a format with +// no branch of its own, so each has to be routed by name. test.for(['custom_metadata', 'custom_metadata_has'] as const)( 'interactForBlueprintObject: edits %s with the metadata editor', async (name) => { - vi.mocked(promptAutocomplete) - .mockImplementationOnce(async () => name as never) - .mockImplementationOnce(async () => 'done' as never) - vi.mocked(promptSelect).mockImplementation(async () => 'done' as never) + // Pick the parameter, finish the metadata editor, then submit. + scriptPrompt([name, 'done', 'done']) await expect( interactForBlueprintObject( @@ -186,9 +180,7 @@ test.for(['custom_metadata', 'custom_metadata_has'] as const)( ) test('interactForBlueprintObject: dismissing the parameter menu leaves the command', async () => { - vi.mocked(promptAutocomplete).mockRejectedValueOnce( - new PromptCancelledError(), - ) + scriptPrompt([cancelPrompt]) await expect( interactForBlueprintObject( @@ -199,10 +191,7 @@ test('interactForBlueprintObject: dismissing the parameter menu leaves the comma }) test('interactForBlueprintObject: dismissing a value prompt returns to the menu', async () => { - vi.mocked(promptAutocomplete) - .mockImplementationOnce(async () => 'name') - .mockImplementationOnce(async () => 'done') - vi.mocked(promptText).mockRejectedValueOnce(new PromptCancelledError()) + scriptPrompt(['name', cancelPrompt, 'done']) // The parameter is left unset and the command still runs, rather than the // dismissal ending the whole command. @@ -212,14 +201,13 @@ test('interactForBlueprintObject: dismissing a value prompt returns to the menu' ctx('interactive'), ), ).resolves.toEqual({ device_id: 'device1' }) - expect(promptAutocomplete).toHaveBeenCalledTimes(2) + expect( + memoryPrompt.questions.filter(({ kind }) => kind === 'autocomplete'), + ).toHaveLength(2) }) test('interactForBlueprintObject: dismissing a value prompt keeps an earlier value', async () => { - vi.mocked(promptAutocomplete) - .mockImplementationOnce(async () => 'name') - .mockImplementationOnce(async () => 'done') - vi.mocked(promptText).mockRejectedValueOnce(new PromptCancelledError()) + scriptPrompt(['name', cancelPrompt, 'done']) await expect( interactForBlueprintObject( @@ -235,24 +223,21 @@ test('interactForBlueprintObject: tells the user the parameter menu can be left' ctx('interactive'), ) - const { message } = vi.mocked(promptAutocomplete).mock.calls[0]?.[0] as { - message: string - } - expect(message).toBe(withBackHint('[/devices/get] Parameters')) + expect(memoryPrompt.questions[0]).toMatchObject({ + message: withBackHint('[/devices/get] Parameters'), + }) }) test('interactForBlueprintObject: tells the user a value prompt can be left', async () => { - vi.mocked(promptAutocomplete) - .mockImplementationOnce(async () => 'name') - .mockImplementationOnce(async () => 'done') - vi.mocked(promptText).mockImplementationOnce(async () => 'Front Door') + scriptPrompt(['name', 'Front Door', 'done']) await interactForBlueprintObject( args({ device_id: 'device1' }), ctx('interactive'), ) - expect(vi.mocked(promptText).mock.calls[0]?.[0]).toMatchObject({ + expect(memoryPrompt.questions[1]).toMatchObject({ + kind: 'text', message: withBackHint('name:'), }) }) diff --git a/src/lib/interact/interact-for-command-selection.test.ts b/src/lib/interact/interact-for-command-selection.test.ts index 3b7e21c8..35f67299 100644 --- a/src/lib/interact/interact-for-command-selection.test.ts +++ b/src/lib/interact/interact-for-command-selection.test.ts @@ -1,18 +1,11 @@ -import { beforeEach, expect, test, vi } from 'vitest' +import { afterEach, expect, test } from 'vitest' import type { CliContext } from '../context.js' +import { createMemoryPrompt } from './create-memory-prompt.js' import { interactForCommandSelection } from './interact-for-command-selection.js' -import type * as PromptModule from './prompt.js' -import { promptAutocomplete, withBackHint } from './prompt.js' +import { resetPromptClient, setPromptClient, withBackHint } from './prompt.js' -vi.mock('./prompt.js', async (importOriginal) => ({ - ...(await importOriginal()), - promptAutocomplete: vi.fn(), -})) - -beforeEach(() => { - vi.mocked(promptAutocomplete).mockReset() -}) +afterEach(resetPromptClient) const ctx = { interactivity: 'non-interactive', @@ -55,24 +48,24 @@ const interactiveCtx = { } as unknown as CliContext test('interactForCommandSelection: tells the user a sub-command menu can be left', async () => { - vi.mocked(promptAutocomplete).mockImplementationOnce(async () => 'list') + const memoryPrompt = createMemoryPrompt(['list']) + setPromptClient(memoryPrompt.client) await interactForCommandSelection(['devices'], interactiveCtx) - expect(vi.mocked(promptAutocomplete).mock.calls[0]?.[0]).toMatchObject({ + expect(memoryPrompt.questions[0]).toMatchObject({ message: withBackHint('Select a command: /devices'), }) }) // Escape stops the CLI at the top level, so promising a way back would lie. test('interactForCommandSelection: says nothing about going back at the top level', async () => { - vi.mocked(promptAutocomplete) - .mockImplementationOnce(async () => 'devices') - .mockImplementationOnce(async () => 'list') + const memoryPrompt = createMemoryPrompt(['devices', 'list']) + setPromptClient(memoryPrompt.client) await interactForCommandSelection([], interactiveCtx) - expect(vi.mocked(promptAutocomplete).mock.calls[0]?.[0]).toMatchObject({ + expect(memoryPrompt.questions[0]).toMatchObject({ message: 'Select a command: /', }) }) diff --git a/src/lib/interact/interact-for-custom-metadata.test.ts b/src/lib/interact/interact-for-custom-metadata.test.ts index 1e78ff14..2a714a99 100644 --- a/src/lib/interact/interact-for-custom-metadata.test.ts +++ b/src/lib/interact/interact-for-custom-metadata.test.ts @@ -1,49 +1,31 @@ -import { beforeEach, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, expect, test } from 'vitest' -import { interactForCustomMetadata } from './interact-for-custom-metadata.js' import { createMemoryOutput } from '../output/create-memory-output.js' import { setOutput } from '../output/get-output.js' -import type * as PromptModule from './prompt.js' -import { promptSelect, promptText } from './prompt.js' - -// Only the prompts themselves are replaced, so the real PromptCancelledError -// and withBackHint are used, as they are in production. -vi.mock('./prompt.js', async (importOriginal) => ({ - ...(await importOriginal()), - promptText: vi.fn(), - promptNumber: vi.fn(), - promptConfirm: vi.fn(), - promptSelect: vi.fn(), - promptAutocomplete: vi.fn(), - promptAutocompleteMultiselect: vi.fn(), -})) - -/** Queues answers in the order the editor asks for them. */ -const answerSelects = (...values: string[]): void => { - const queue = [...values] - vi.mocked(promptSelect).mockImplementation(async () => queue.shift() as never) -} +import { createMemoryPrompt } from './create-memory-prompt.js' +import { interactForCustomMetadata } from './interact-for-custom-metadata.js' +import { resetPromptClient, setPromptClient } from './prompt.js' -const answerTexts = (...values: string[]): void => { - const queue = [...values] - vi.mocked(promptText).mockImplementation(async () => queue.shift() as never) +/** Scripts an answer for each ask, in the order the editor asks. */ +const scriptPrompt = (script: unknown[]): void => { + setPromptClient(createMemoryPrompt(script).client) } beforeEach(() => { - vi.mocked(promptSelect).mockReset() - vi.mocked(promptText).mockReset() + // Keep the interactive chrome out of the test output. setOutput(createMemoryOutput().output) }) +afterEach(resetPromptClient) + test('interactForCustomMetadata: adds a key and value', async () => { - answerSelects('add', 'done') - answerTexts('floor', '3') + scriptPrompt(['add', 'floor', '3', 'done']) await expect(interactForCustomMetadata({})).resolves.toEqual({ floor: '3' }) }) test('interactForCustomMetadata: removes a key from the result', async () => { - answerSelects('remove', 'floor', 'done') + scriptPrompt(['remove', 'floor', 'done']) await expect( interactForCustomMetadata({ floor: '3', wing: 'east' }), @@ -51,7 +33,7 @@ test('interactForCustomMetadata: removes a key from the result', async () => { }) test('interactForCustomMetadata: leaves the given metadata unmodified', async () => { - answerSelects('remove', 'floor', 'done') + scriptPrompt(['remove', 'floor', 'done']) const customMetadata = { floor: '3', wing: 'east' } await interactForCustomMetadata(customMetadata) @@ -62,8 +44,7 @@ test('interactForCustomMetadata: leaves the given metadata unmodified', async () test.for([['true', true] as const, ['false', false] as const])( 'interactForCustomMetadata: stores %s as a boolean', async ([given, stored]) => { - answerSelects('add', 'done') - answerTexts('enabled', given) + scriptPrompt(['add', 'enabled', given, 'done']) await expect(interactForCustomMetadata({})).resolves.toEqual({ enabled: stored, @@ -72,8 +53,7 @@ test.for([['true', true] as const, ['false', false] as const])( ) test('interactForCustomMetadata: stores null for the null keyword', async () => { - answerSelects('add', 'done') - answerTexts('note', 'null') + scriptPrompt(['add', 'note', 'null', 'done']) await expect(interactForCustomMetadata({})).resolves.toEqual({ note: null }) }) diff --git a/src/lib/interact/prompt.ts b/src/lib/interact/prompt.ts index b62d7478..f07d1708 100644 --- a/src/lib/interact/prompt.ts +++ b/src/lib/interact/prompt.ts @@ -14,17 +14,6 @@ import chalk from 'chalk' import { NonInteractiveError } from '../args/parse.js' -/** - * Whether the CLI can ask the user a question. - * - * Prompts read raw keypresses and render an interface, so they need a - * terminal on both ends: when stdin is a pipe or a file it holds request - * params, not answers, and when stderr is redirected nobody sees the - * question. - */ -export const canPrompt = (): boolean => - process.stdin.isTTY === true && process.stderr.isTTY === true - /** The user dismissed a prompt with ctrl-c or escape instead of answering. */ export class PromptCancelledError extends Error { constructor() { @@ -38,6 +27,50 @@ export interface PromptChoice { hint?: string | undefined } +export interface PromptTextOptions { + message: string + placeholder?: string + defaultValue?: string + validate?: (value: string | undefined) => string | undefined +} + +export interface PromptNumberOptions { + message: string + validate?: (value: number) => string | undefined +} + +export interface PromptConfirmOptions { + message: string + initialValue?: boolean + active?: string + inactive?: string +} + +export interface PromptSelectOptions { + message: string + choices: Array> +} + +/** + * The terminal edge behind the prompt functions: whether questions can be + * asked, and how to ask each kind. + * + * A test replaces this with an in-memory client (see + * `create-memory-prompt.ts`) via {@link setPromptClient} — the code under + * test keeps calling `promptText` and friends as usual. + */ +export interface PromptClient { + canPrompt: () => boolean + text: (options: PromptTextOptions) => Promise + number: (options: PromptNumberOptions) => Promise + confirm: (options: PromptConfirmOptions) => Promise + select: (options: PromptSelectOptions) => Promise + autocomplete: (options: PromptSelectOptions) => Promise + autocompleteMultiselect: ( + options: PromptSelectOptions, + ) => Promise +} + /** * Note on a prompt message that dismissing it returns to the previous step. * @@ -49,15 +82,6 @@ export interface PromptChoice { export const withBackHint = (message: string): string => `${message} ${chalk.dim('· Esc: go back')}` -const ensureInteractive = (): void => { - if (!canPrompt()) { - throw new NonInteractiveError( - 'Cannot prompt without a terminal: pass the missing arguments, or pipe them in as JSON', - ) - } - installArrowKeyAliases() -} - /** * The arrow keypress an Emacs-style control keypress stands for, or * undefined for any other key: ctrl-p is up and ctrl-n is down. @@ -116,90 +140,144 @@ const toOptions = ( : { label, value, hint }) as Option, ) -export const promptText = async (options: { - message: string - placeholder?: string - defaultValue?: string - validate?: (value: string | undefined) => string | undefined -}): Promise => { +const terminalPromptClient: PromptClient = { + /** + * Prompts read raw keypresses and render an interface, so they need a + * terminal on both ends: when stdin is a pipe or a file it holds request + * params, not answers, and when stderr is redirected nobody sees the + * question. + */ + canPrompt: () => + process.stdin.isTTY === true && process.stderr.isTTY === true, + + text: async (options) => { + installArrowKeyAliases() + return unwrap(await text({ ...options, output })) + }, + + number: async (options) => { + installArrowKeyAliases() + const value = unwrap( + await text({ + message: options.message, + validate: (value) => { + if (value == null || value.trim() === '') return 'Enter a number' + const parsed = Number(value) + if (Number.isNaN(parsed)) return 'Enter a number' + return options.validate?.(parsed) + }, + output, + }), + ) + return Number(value) + }, + + confirm: async (options) => { + installArrowKeyAliases() + return unwrap(await confirm({ ...options, output })) + }, + + select: async (options: PromptSelectOptions) => { + installArrowKeyAliases() + return unwrap( + await select({ + message: options.message, + options: toOptions(options.choices), + output, + }), + ) + }, + + autocomplete: async (options: PromptSelectOptions) => { + installArrowKeyAliases() + return unwrap( + await autocomplete({ + message: options.message, + options: toOptions(options.choices), + // Search a list by any part of a name or hint, rather than only by + // the label, which is all clack matches for itself. + filter: searchChoices, + output, + }), + ) + }, + + autocompleteMultiselect: async ( + options: PromptSelectOptions, + ) => { + installArrowKeyAliases() + return unwrap( + await autocompleteMultiselect({ + message: options.message, + options: toOptions(options.choices), + filter: searchChoices, + output, + }), + ) + }, +} + +let client: PromptClient = terminalPromptClient + +export const setPromptClient = (promptClient: PromptClient): void => { + client = promptClient +} + +export const resetPromptClient = (): void => { + client = terminalPromptClient +} + +/** Whether the CLI can ask the user a question. */ +export const canPrompt = (): boolean => client.canPrompt() + +const ensureInteractive = (): void => { + if (!client.canPrompt()) { + throw new NonInteractiveError( + 'Cannot prompt without a terminal: pass the missing arguments, or pipe them in as JSON', + ) + } +} + +export const promptText = async ( + options: PromptTextOptions, +): Promise => { ensureInteractive() - return unwrap(await text({ ...options, output })) + return await client.text(options) } -export const promptNumber = async (options: { - message: string - validate?: (value: number) => string | undefined -}): Promise => { +export const promptNumber = async ( + options: PromptNumberOptions, +): Promise => { ensureInteractive() - const value = unwrap( - await text({ - message: options.message, - validate: (value) => { - if (value == null || value.trim() === '') return 'Enter a number' - const parsed = Number(value) - if (Number.isNaN(parsed)) return 'Enter a number' - return options.validate?.(parsed) - }, - output, - }), - ) - return Number(value) + return await client.number(options) } -export const promptConfirm = async (options: { - message: string - initialValue?: boolean - active?: string - inactive?: string -}): Promise => { +export const promptConfirm = async ( + options: PromptConfirmOptions, +): Promise => { ensureInteractive() - return unwrap(await confirm({ ...options, output })) + return await client.confirm(options) } -export const promptSelect = async (options: { - message: string - choices: Array> -}): Promise => { +export const promptSelect = async ( + options: PromptSelectOptions, +): Promise => { ensureInteractive() - return unwrap( - await select({ - message: options.message, - options: toOptions(options.choices), - output, - }), - ) + return await client.select(options) } -export const promptAutocomplete = async (options: { - message: string - choices: Array> -}): Promise => { +export const promptAutocomplete = async ( + options: PromptSelectOptions, +): Promise => { ensureInteractive() - return unwrap( - await autocomplete({ - message: options.message, - options: toOptions(options.choices), - // Search a list by any part of a name or hint, rather than only by - // the label, which is all clack matches for itself. - filter: searchChoices, - output, - }), - ) + return await client.autocomplete(options) } -export const promptAutocompleteMultiselect = async (options: { - message: string - choices: Array> -}): Promise => { +export const promptAutocompleteMultiselect = async ( + options: PromptSelectOptions, +): Promise => { ensureInteractive() - return unwrap( - await autocompleteMultiselect({ - message: options.message, - options: toOptions(options.choices), - filter: searchChoices, - output, - }), - ) + return await client.autocompleteMultiselect(options) } export interface SearchableChoice { From 6da8f9334aa66abf8063a88d270dd5e990716037 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 02:40:45 +0000 Subject: [PATCH 08/20] refactor: Rename seam/ to http/ "Seam" names the whole product, not a layer. This layer is the SDK/HTTP edge, so it takes the SDK package's name (@seamapi/http): http/client.ts constructs SeamHttp, http/request.ts makes the request. TESTING.md references updated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- TESTING.md | 12 ++++++------ src/bin/cli.ts | 2 +- src/lib/{seam => http}/client.ts | 0 src/lib/{seam => http}/request.ts | 2 +- src/lib/interact/interact-for-access-code.ts | 2 +- src/lib/interact/interact-for-acs-entrance.ts | 2 +- src/lib/interact/interact-for-acs-system.ts | 2 +- src/lib/interact/interact-for-acs-user.ts | 2 +- src/lib/interact/interact-for-action-attempt-poll.ts | 4 ++-- src/lib/interact/interact-for-connected-account.ts | 2 +- src/lib/interact/interact-for-device.ts | 2 +- src/lib/interact/interact-for-user-identity.ts | 2 +- src/lib/interact/interact-for-workspace-id.ts | 2 +- 13 files changed, 18 insertions(+), 18 deletions(-) rename src/lib/{seam => http}/client.ts (100%) rename src/lib/{seam => http}/request.ts (96%) diff --git a/TESTING.md b/TESTING.md index 7af8cd8b..1b1eba54 100644 --- a/TESTING.md +++ b/TESTING.md @@ -34,7 +34,7 @@ the fake goes. | **Decision over injected data** — `interact-for-command-selection` (non-interactive), `blueprint/endpoint`, `context.ts` | Takes `CliContext` / blueprint / config store as a parameter | Classical with a literal ctx object (`interact-for-command-selection.test.ts` is the model) | Nothing — a hand-built blueprint literal is a fixture, not a fake | The traversal/decision logic | | **Prompt flow** — `interact/interact-for-*` importing `interact/prompt.js` | Imports `interact/prompt.js` | Classical on the returned value, memory output, scripted prompt fake; assert the choice list _offered_ where the prompt is the UX | The prompt layer (the whole `prompts` edge), output | The module's own branching and param assembly | | **Config & state** — `config/config-store`, `config/migrate` | Touches `Configstore` / `env-paths` | Classical against a real store in a temp directory — it's a JSON file, and split/merge/migration _is_ the behavior | The directory; env vars (`vi.stubEnv`) | `Configstore` or fs behavior | -| **Network** — `seam/request`, `auth/validate-token`, `blueprint/source-npm` | Constructs `SeamHttp` or calls `fetch` | Classical against a fake port (or a stubbed global `fetch` with captured requests, as `blueprint/source-npm.test.ts` does); assert the payload sent _and_ the value returned | The `SeamApi` port / global `fetch` | Status handling, payload selection, formatting — that's the unit | +| **Network** — `http/request`, `auth/validate-token`, `blueprint/source-npm` | Constructs `SeamHttp` or calls `fetch` | Classical against a fake port (or a stubbed global `fetch` with captured requests, as `blueprint/source-npm.test.ts` does); assert the payload sent _and_ the value returned | The `SeamApi` port / global `fetch` | Status handling, payload selection, formatting — that's the unit | | **Orchestration** — `bin/cli.ts` | Reads argv/env, wires everything | E2e: spawn via `execa`, `node:http` fake server, XDG temp dirs (`test/cli.test.ts`) | The far end of the wire; the home directories | Anything in-process | ## The mocking boundary @@ -78,16 +78,16 @@ HTTP-free share. ## The Seam SDK boundary -**Wrap it behind our own narrow port.** Not `vi.mock('./seam/client.js')`, and +**Wrap it behind our own narrow port.** Not `vi.mock('./http/client.js')`, and not dependency-injecting `SeamHttp`: both force the fake to imitate an axios-shaped SDK surface (`client.post` returning an `AxiosResponse`), so tests end up re-verifying the SDK's shape instead of our behavior. The CLI is blueprint-driven and has one chokepoint — `seam.client.post(path, params, { validateStatus: () => true })` in -`seam/request.ts` — so the port is one method: +`http/request.ts` — so the port is one method: ```ts -// src/lib/seam/api.ts +// src/lib/http/api.ts export interface SeamApiResponse { status: number data: unknown @@ -117,7 +117,7 @@ The fake is the in-process mirror of the e2e server — a routes table plus a capture: ```ts -// src/lib/seam/create-memory-seam-api.ts +// src/lib/http/create-memory-seam-api.ts export const createMemorySeamApi = ( routes: Record, ) => { @@ -134,7 +134,7 @@ export const createMemorySeamApi = ( } ``` -This split also separates transport from presentation in `seam/request.ts` +This split also separates transport from presentation in `http/request.ts` (which historically also formatted output and set `process.exitCode`), so the error-status → exit-code behavior becomes a classical test with zero HTTP: diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 9e55181f..9e5aafea 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -34,6 +34,7 @@ import { isInsideWebBrowser, tokenEnvVar, } from 'lib/env.js' +import { RequestSeamApi } from 'lib/http/request.js' import { interactForActionAttemptPoll } from 'lib/interact/interact-for-action-attempt-poll.js' import { interactForCommandParams } from 'lib/interact/interact-for-command-params.js' import { interactForCommandSelection } from 'lib/interact/interact-for-command-selection.js' @@ -56,7 +57,6 @@ import { renderCompletion, } from 'lib/render/completion/index.js' import { renderHelp } from 'lib/render/help.js' -import { RequestSeamApi } from 'lib/seam/request.js' import seamapiCliVersion from 'lib/version.js' async function cli(args: ParsedArgs) { diff --git a/src/lib/seam/client.ts b/src/lib/http/client.ts similarity index 100% rename from src/lib/seam/client.ts rename to src/lib/http/client.ts diff --git a/src/lib/seam/request.ts b/src/lib/http/request.ts similarity index 96% rename from src/lib/seam/request.ts rename to src/lib/http/request.ts index eba35f1d..7d9497b1 100644 --- a/src/lib/seam/request.ts +++ b/src/lib/http/request.ts @@ -1,8 +1,8 @@ import chalk from 'chalk' +import { getSeam } from 'lib/http/client.js' import { getOutput } from 'lib/output/get-output.js' import { selectResponsePayload } from 'lib/output/select-response-payload.js' -import { getSeam } from 'lib/seam/client.js' import { withLoading } from '../output/with-loading.js' diff --git a/src/lib/interact/interact-for-access-code.ts b/src/lib/interact/interact-for-access-code.ts index 63427b9b..26be2b68 100644 --- a/src/lib/interact/interact-for-access-code.ts +++ b/src/lib/interact/interact-for-access-code.ts @@ -1,4 +1,4 @@ -import { getSeam } from '../seam/client.js' +import { getSeam } from '../http/client.js' import { interactForDevice } from './interact-for-device.js' import { interactForResource } from './interact-for-resource.js' diff --git a/src/lib/interact/interact-for-acs-entrance.ts b/src/lib/interact/interact-for-acs-entrance.ts index d086d68a..988fbcbc 100644 --- a/src/lib/interact/interact-for-acs-entrance.ts +++ b/src/lib/interact/interact-for-acs-entrance.ts @@ -1,4 +1,4 @@ -import { getSeam } from '../seam/client.js' +import { getSeam } from '../http/client.js' import { interactForResource } from './interact-for-resource.js' export const interactForAcsEntrance = async () => { diff --git a/src/lib/interact/interact-for-acs-system.ts b/src/lib/interact/interact-for-acs-system.ts index be1929c9..6110f337 100644 --- a/src/lib/interact/interact-for-acs-system.ts +++ b/src/lib/interact/interact-for-acs-system.ts @@ -1,4 +1,4 @@ -import { getSeam } from '../seam/client.js' +import { getSeam } from '../http/client.js' import { interactForResource } from './interact-for-resource.js' export const interactForAcsSystem = async (message?: string) => { diff --git a/src/lib/interact/interact-for-acs-user.ts b/src/lib/interact/interact-for-acs-user.ts index ea6b9b62..49192f7e 100644 --- a/src/lib/interact/interact-for-acs-user.ts +++ b/src/lib/interact/interact-for-acs-user.ts @@ -1,4 +1,4 @@ -import { getSeam } from '../seam/client.js' +import { getSeam } from '../http/client.js' import { interactForAcsSystem } from './interact-for-acs-system.js' import { interactForResource } from './interact-for-resource.js' diff --git a/src/lib/interact/interact-for-action-attempt-poll.ts b/src/lib/interact/interact-for-action-attempt-poll.ts index 472531ac..23b58aa9 100644 --- a/src/lib/interact/interact-for-action-attempt-poll.ts +++ b/src/lib/interact/interact-for-action-attempt-poll.ts @@ -1,9 +1,9 @@ import type { ActionAttemptsGetResponse } from '@seamapi/http/connect' -import { getSeam } from '../seam/client.js' +import { getSeam } from '../http/client.js' import { getOutput } from '../output/get-output.js' -import { promptConfirm } from './prompt.js' import { withLoading } from '../output/with-loading.js' +import { promptConfirm } from './prompt.js' export const interactForActionAttemptPoll = async ( actionAttempt: ActionAttemptsGetResponse['action_attempt'], diff --git a/src/lib/interact/interact-for-connected-account.ts b/src/lib/interact/interact-for-connected-account.ts index c54fab08..ca0c4172 100644 --- a/src/lib/interact/interact-for-connected-account.ts +++ b/src/lib/interact/interact-for-connected-account.ts @@ -1,4 +1,4 @@ -import { getSeam } from '../seam/client.js' +import { getSeam } from '../http/client.js' import { interactForResource } from './interact-for-resource.js' export const interactForConnectedAccount = async () => { const seam = await getSeam() diff --git a/src/lib/interact/interact-for-device.ts b/src/lib/interact/interact-for-device.ts index 8ee4b84f..8e9fe23f 100644 --- a/src/lib/interact/interact-for-device.ts +++ b/src/lib/interact/interact-for-device.ts @@ -1,4 +1,4 @@ -import { getSeam } from '../seam/client.js' +import { getSeam } from '../http/client.js' import { interactForResource } from './interact-for-resource.js' export const interactForDevice = async () => { const seam = await getSeam() diff --git a/src/lib/interact/interact-for-user-identity.ts b/src/lib/interact/interact-for-user-identity.ts index 16b0a7b0..af11b6ed 100644 --- a/src/lib/interact/interact-for-user-identity.ts +++ b/src/lib/interact/interact-for-user-identity.ts @@ -1,4 +1,4 @@ -import { getSeam } from '../seam/client.js' +import { getSeam } from '../http/client.js' import { interactForResource } from './interact-for-resource.js' export const interactForUserIdentity = async () => { diff --git a/src/lib/interact/interact-for-workspace-id.ts b/src/lib/interact/interact-for-workspace-id.ts index f3da1bc1..92e2882d 100644 --- a/src/lib/interact/interact-for-workspace-id.ts +++ b/src/lib/interact/interact-for-workspace-id.ts @@ -3,8 +3,8 @@ import { SeamHttpWithoutWorkspace } from '@seamapi/http/connect' import { assertMutable, selectWorkspace } from '../auth/operations.js' import { getConfigStore } from '../config/index.js' import { resolveAuth } from '../context.js' +import { getSeamMultiWorkspace } from '../http/client.js' import { withLoading } from '../output/with-loading.js' -import { getSeamMultiWorkspace } from '../seam/client.js' import { promptAutocomplete } from './prompt.js' export const interactForWorkspaceId = async (personalAccessToken?: string) => { From 757c75841f5cc20dc807998ce05e716bf0aeb71a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 02:54:49 +0000 Subject: [PATCH 09/20] refactor: Unify the three command registries into one Command definitions previously lived in three places that had to agree by hand: the localCommands array (help and completion), the if/else dispatch chain in bin/cli.ts (execution), and a hardcoded list in the interactive picker. They had already drifted: config set fake-server existed only in the executor, wizard only in the spec. commands/registry.ts is now the single source of truth. A Command declares its definition, whether it needs a login, whether it is hidden, and how to execute, so the spec, the picker, and the dispatcher cannot disagree. Blueprint endpoints run through the generic commands/api-command.ts executor, which also owns the per-endpoint parameter policy and post-response follow-ups. bin/cli.ts shrinks to parsing, the login gate, and a dispatch loop whose 'back' navigation replaces the old self-recursion. assertKnownArgs moves to args/validate.ts; toPlainText/firstSentence move to render/text.ts; CliContext carries the output. Visible changes: the interactive picker now offers wizard (it was in help but missing from the picker), and config set fake-server loads the cached API definitions like every other dispatched command. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- src/bin/cli.ts | 319 ++++-------------- src/lib/args/validate.ts | 42 +++ src/lib/commands/api-command.ts | 106 ++++++ src/lib/commands/local/completion.ts | 49 +++ .../commands/local/config-reveal-location.ts | 16 + .../commands/local/config-set-fake-server.ts | 21 ++ .../local/config-use-remote-api-defs.ts | 23 ++ src/lib/commands/local/health.ts | 21 ++ src/lib/commands/local/login.ts | 42 +++ src/lib/commands/local/logout.ts | 18 + src/lib/commands/local/select-server.ts | 30 ++ src/lib/commands/local/select-workspace.ts | 25 ++ src/lib/commands/local/wizard.ts | 31 ++ src/lib/commands/registry.test.ts | 73 ++++ src/lib/commands/registry.ts | 120 +++++++ .../spec.test.ts} | 30 +- src/lib/{command-spec.ts => commands/spec.ts} | 139 +------- src/lib/context.ts | 2 + .../interact-for-command-selection.test.ts | 53 +-- .../interact-for-command-selection.ts | 27 +- src/lib/render/completion/completion.test.ts | 17 +- src/lib/render/completion/describe.ts | 3 +- src/lib/render/completion/index.ts | 8 +- src/lib/render/completion/render-bash.ts | 2 +- src/lib/render/completion/render-fish.ts | 2 +- src/lib/render/completion/render-zsh.ts | 2 +- src/lib/render/help.test.ts | 4 +- src/lib/render/help.ts | 2 +- src/lib/render/text.test.ts | 19 +- src/lib/render/text.ts | 13 + test/cli.test.ts | 12 + 31 files changed, 802 insertions(+), 469 deletions(-) create mode 100644 src/lib/args/validate.ts create mode 100644 src/lib/commands/api-command.ts create mode 100644 src/lib/commands/local/completion.ts create mode 100644 src/lib/commands/local/config-reveal-location.ts create mode 100644 src/lib/commands/local/config-set-fake-server.ts create mode 100644 src/lib/commands/local/config-use-remote-api-defs.ts create mode 100644 src/lib/commands/local/health.ts create mode 100644 src/lib/commands/local/login.ts create mode 100644 src/lib/commands/local/logout.ts create mode 100644 src/lib/commands/local/select-server.ts create mode 100644 src/lib/commands/local/select-workspace.ts create mode 100644 src/lib/commands/local/wizard.ts create mode 100644 src/lib/commands/registry.test.ts create mode 100644 src/lib/commands/registry.ts rename src/lib/{command-spec.test.ts => commands/spec.test.ts} (85%) rename src/lib/{command-spec.ts => commands/spec.ts} (71%) diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 9e5aafea..2b0af077 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -7,46 +7,25 @@ import type { ParsedArgs } from 'minimist' import { cliFlags, getInteractivity, - type Interactivity, NonInteractiveError, parseCliArgs, - toGivenArgName, toParameterName, UsageError, } from 'lib/args/parse.js' -import { - assertMutable, - login, - logout, - selectFakeServer, - selectServer, -} from 'lib/auth/operations.js' -import { - getCommandBlueprintDef, - getResponseKey, -} from 'lib/blueprint/endpoint.js' +import { assertKnownArgs } from 'lib/args/validate.js' import { getApiBlueprint } from 'lib/blueprint/index.js' -import { findLocalCommand, getCommandSpec } from 'lib/command-spec.js' +import { printCompletion } from 'lib/commands/local/completion.js' +import { runWizard } from 'lib/commands/local/wizard.js' +import { + acceptedParamsOf, + buildRegistry, + findLocalCommand, +} from 'lib/commands/registry.js' import { getConfigStore } from 'lib/config/index.js' import { type CliContext, resolveAuth } from 'lib/context.js' -import { - EnvVarOverrideError, - isInsideWebBrowser, - tokenEnvVar, -} from 'lib/env.js' -import { RequestSeamApi } from 'lib/http/request.js' -import { interactForActionAttemptPoll } from 'lib/interact/interact-for-action-attempt-poll.js' -import { interactForCommandParams } from 'lib/interact/interact-for-command-params.js' +import { EnvVarOverrideError, tokenEnvVar } from 'lib/env.js' import { interactForCommandSelection } from 'lib/interact/interact-for-command-selection.js' -import { interactForLogin } from 'lib/interact/interact-for-login.js' -import { interactForServerSelection } from 'lib/interact/interact-for-server-selection.js' -import { interactForUseRemoteApiDefs } from 'lib/interact/interact-for-use-remote-api-defs.js' -import { interactForWorkspaceId } from 'lib/interact/interact-for-workspace-id.js' -import { - canPrompt, - PromptCancelledError, - promptConfirm, -} from 'lib/interact/prompt.js' +import { canPrompt, PromptCancelledError } from 'lib/interact/prompt.js' import { createOutput } from 'lib/output/create-output.js' import { getOutput, setOutput } from 'lib/output/get-output.js' import { readStdinJson } from 'lib/output/read-stdin-json.js' @@ -54,7 +33,6 @@ import { resolveOutputFormat } from 'lib/output/resolve-output-format.js' import { completionShells, isCompletionShell, - renderCompletion, } from 'lib/render/completion/index.js' import { renderHelp } from 'lib/render/help.js' import seamapiCliVersion from 'lib/version.js' @@ -69,7 +47,7 @@ async function cli(args: ParsedArgs) { if (helpFlag != null) { // Help comes from the cached API definitions so that it works without // logging in, and offline once the cache is warm. - const spec = getCommandSpec(await getApiBlueprint(false, { update })) + const { spec } = buildRegistry(await getApiBlueprint(false, { update })) // minimist reads the word after --help as its value, so 'seam --help // devices' asks about devices just as 'seam devices --help' does. @@ -128,33 +106,28 @@ async function cli(args: ParsedArgs) { return } - assertKnownArgs(argParams, ['completion', shell]) + const command = findLocalCommand(['completion', shell]) + assertKnownArgs(argParams, ['completion', shell], { + accepted: + command == null ? new Set() : acceptedParamsOf(command.definition), + isLocal: true, + }) - // Completions always come from the cached API definitions so that they - // can be generated without logging in. They may lag the definitions - // served by Seam when config use-remote-api-defs is enabled. - output.text( - renderCompletion(shell, await getApiBlueprint(false, { update })), - ) + await printCompletion(shell, { update }) return } - if ( - args._[0] === 'config' && - args._[1] === 'set' && - args._[2] === 'fake-server' - ) { - const { server: fakeApiUrl } = selectFakeServer(undefined, config) - output.info(`Server URL set to ${fakeApiUrl}`) - output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) - return - } + const localCommand = findLocalCommand(args._) + + // Commands declared not to need a token bypass the login gate. A partial + // path keeps the historical rule: only login and select server may be + // reached logged out. + const requiresAuth = + localCommand != null + ? localCommand.requiresAuth + : !(args._[0] === 'login' || isEqual(args._, ['select', 'server'])) - if ( - resolveAuth(config).token == null && - args._[0] !== 'login' && - !isEqual(args._, ['select', 'server']) - ) { + if (requiresAuth && resolveAuth(config).token == null) { output.error(`Not logged in. Please run "seam login" or set ${tokenEnvVar}`) process.exitCode = 1 return @@ -167,236 +140,68 @@ async function cli(args: ParsedArgs) { update, }) + const registry = buildRegistry(blueprint) + // Params piped or redirected in, e.g., `seam devices list < params.json`. - // Params given as arguments take precedence over these. - const commandParams: Record = { ...(await readStdinJson()) } + const stdinParams: Record = { ...(await readStdinJson()) } const ctx: CliContext = { config, auth: resolveAuth(config), + output, blueprint, interactivity: getInteractivity(args, { canPrompt: canPrompt() }), } - const isNonInteractive = ctx.interactivity === 'non-interactive' - - Object.assign(commandParams, argParams) + const selectableCommands = registry.spec.commands.map(({ path }) => path) - const selectedCommand = await interactForCommandSelection(args._, ctx) - - // Hit 'back' on a top-level command path, so we start again - if (selectedCommand.slice(-1)[0] === '[Back]') { - return await cli({ - ...args, - _: [], + let commandPath = args._ + while (true) { + const selectedCommand = await interactForCommandSelection(commandPath, { + commands: selectableCommands, + interactivity: ctx.interactivity, }) - } - // Check the arguments before the command acts on any of them, so a mistake - // is reported rather than half applied. - assertKnownArgs(argParams, selectedCommand, ctx) - - if (isEqual(selectedCommand, ['login'])) { - if (args['token'] || args['workspace_id'] || args['server']) { - await login( - { - server: args['server'] ? args['server'] : undefined, - token: args['token'] ? String(args['token']).trim() : undefined, - workspaceId: args['workspace_id'] ? args['workspace_id'] : undefined, - }, - config, - ) - return + // Hit 'back' on a top-level command path, so we start again + if (selectedCommand.at(-1) === '[Back]') { + commandPath = [] + continue } - assertMutable(ctx.auth, 'token', 'log in') - if (isNonInteractive) { - throw new NonInteractiveError( - 'Missing required parameter for login: --token', - ) - } - await interactForLogin() - return - } else if (isEqual(selectedCommand, ['logout'])) { - logout(config) - output.info('Logged out!') - return - } else if (isEqual(selectedCommand, ['config', 'reveal-location'])) { - output.text(config.path) - return - } else if (isEqual(selectedCommand, ['config', 'use-remote-api-defs'])) { - if (isNonInteractive) { - throw new NonInteractiveError( - 'Cannot select whether to use remote API definitions in non-interactive mode', - ) - } - await interactForUseRemoteApiDefs() - return - } else if (isEqual(selectedCommand, ['select', 'workspace'])) { - assertMutable(ctx.auth, 'workspaceId', 'select a workspace') - if (isNonInteractive) { - throw new NonInteractiveError( - 'Cannot select a workspace in non-interactive mode: pass --workspace-id to "seam login"', - ) - } - await interactForWorkspaceId() - return - } else if (isEqual(selectedCommand, ['events', 'list'])) { - if (!commandParams['since']) { - const date = new Date() - date.setMonth(date.getMonth() - 1) - commandParams['since'] = date.toISOString() - } - } else if (isEqual(selectedCommand, ['select', 'server'])) { - assertMutable(ctx.auth, 'server', 'select a server') - if (args['server']) { - selectServer(args['server'], config) - return - } - if (isNonInteractive) { - throw new NonInteractiveError( - 'Missing required parameter for select server: --server', + + const command = registry.find(selectedCommand) + if (command == null) { + throw new Error( + `No definition for command seam ${selectedCommand.join(' ')}`, ) } - await interactForServerSelection() - return - } else if (isEqual(selectedCommand, ['health', 'get-health'])) { - await RequestSeamApi({ - path: '/health/get_health', - params: {}, - }) - - return - } - // TODO - do this using the OpenAPI spec for the command rather than - // explicitly encoding the property names - if (commandParams['accepted_providers']) { - commandParams['accepted_providers'] = - commandParams['accepted_providers'].split(',') - } - - const apiPath = `/${selectedCommand.join('/').replace(/-/g, '_')}` - - const params = await interactForCommandParams( - { command: selectedCommand, params: commandParams }, - ctx, - ) - if (params === '[Back]') { - const previousCommands = [...selectedCommand] - previousCommands.pop() - return await cli({ - ...args, - _: previousCommands, + // Check the arguments before the command acts on any of them, so a + // mistake is reported rather than half applied. + assertKnownArgs(argParams, selectedCommand, { + accepted: acceptedParamsOf(command.definition), + isLocal: findLocalCommand(selectedCommand) != null, }) - } - - if (apiPath.includes('/events/list') && params.between) { - delete params.since - } - - const response = await RequestSeamApi({ - path: apiPath, - params, - responseKey: getResponseKey(selectedCommand, ctx), - }) - if (response.data?.connect_webview) { - await handleConnectWebviewResponse( - response.data.connect_webview, - ctx.interactivity, + const result = await command.execute( + { path: selectedCommand, argParams, stdinParams, args }, + ctx, ) - } - if (response.data?.action_attempt && !isNonInteractive) { - await interactForActionAttemptPoll(response.data.action_attempt) + if (result.kind === 'back') { + commandPath = result.toPath + continue + } + + return } } const toCommandWord = (arg: string): string => arg.toLowerCase().replace(/_/g, '-') -/** - * Report any argument the command does not accept, rather than acting on it. - * An unrecognized argument is a mistake: forwarded to the API it would fail - * somewhere less obvious or be quietly ignored, and on a command the CLI - * handles itself it would go nowhere at all. - * - * Only arguments are checked. Params read from stdin are passed through as - * given, so a caller may send whatever the API itself accepts. - * - * `ctx` is only needed to look up an endpoint's parameters, so commands the - * CLI declares itself can be checked before any blueprint is loaded. - */ -const assertKnownArgs = ( - argParams: Record, - command: string[], - ctx?: CliContext, -): void => { - const local = findLocalCommand(command) - - let accepted: Set - if (local != null) { - accepted = new Set( - local.flags.flatMap(({ long }) => - long == null ? [] : [toParameterName(long)], - ), - ) - } else if (ctx != null) { - accepted = new Set( - getCommandBlueprintDef(command, ctx).request.parameters.map( - ({ name }) => name, - ), - ) - } else { - throw new Error(`No definition for command seam ${command.join(' ')}`) - } - - const unknown = Object.keys(argParams).filter((key) => !accepted.has(key)) - if (unknown.length === 0) return - - // Name an endpoint command by its path, as missing params are named, and a - // command the CLI handles itself by the words that run it. - const target = - local == null - ? `/${command.join('/').replace(/-/g, '_')}` - : command.join(' ') - - throw new UsageError( - `Unknown ${ - unknown.length === 1 ? 'parameter' : 'parameters' - } for ${target}: ${unknown.map(toGivenArgName).join(' ')}`, - { - hint: `Run 'seam ${command.join(' ')} --help' to see what it accepts.`, - }, - ) -} - -const handleConnectWebviewResponse = async ( - connectWebview: any, - interactivity: Interactivity, -) => { - const url = connectWebview.url - - if (interactivity !== 'non-interactive' && !isInsideWebBrowser()) { - const action = await promptConfirm({ - message: 'Would you like to open the webview in your browser?', - initialValue: false, - }) - - if (action) { - const { default: open } = await import('open') - await open(url) - } - } -} - const run = async (argv: string[]) => { if (argv[0] === 'wizard') { - const { default: wizard } = await import('@seamapi/wizard') - await wizard({ - argv: argv.slice(1), - commandName: 'seam wizard', - }) + await runWizard(argv.slice(1)) return } diff --git a/src/lib/args/validate.ts b/src/lib/args/validate.ts new file mode 100644 index 00000000..951f1ea2 --- /dev/null +++ b/src/lib/args/validate.ts @@ -0,0 +1,42 @@ +import { toGivenArgName, UsageError } from './parse.js' + +/** + * Report any argument the command does not accept, rather than acting on it. + * An unrecognized argument is a mistake: forwarded to the API it would fail + * somewhere less obvious or be quietly ignored, and on a command the CLI + * handles itself it would go nowhere at all. + * + * Only arguments are checked. Params read from stdin are passed through as + * given, so a caller may send whatever the API itself accepts. + */ +export const assertKnownArgs = ( + argParams: Record, + command: string[], + { + accepted, + isLocal, + }: { + /** Parameter names the command accepts. */ + accepted: Set + /** Whether the CLI handles the command itself. */ + isLocal: boolean + }, +): void => { + const unknown = Object.keys(argParams).filter((key) => !accepted.has(key)) + if (unknown.length === 0) return + + // Name an endpoint command by its path, as missing params are named, and a + // command the CLI handles itself by the words that run it. + const target = isLocal + ? command.join(' ') + : `/${command.join('/').replace(/-/g, '_')}` + + throw new UsageError( + `Unknown ${ + unknown.length === 1 ? 'parameter' : 'parameters' + } for ${target}: ${unknown.map(toGivenArgName).join(' ')}`, + { + hint: `Run 'seam ${command.join(' ')} --help' to see what it accepts.`, + }, + ) +} diff --git a/src/lib/commands/api-command.ts b/src/lib/commands/api-command.ts new file mode 100644 index 00000000..ec01f751 --- /dev/null +++ b/src/lib/commands/api-command.ts @@ -0,0 +1,106 @@ +import { isDeepStrictEqual as isEqual } from 'node:util' + +import type { Interactivity } from '../args/parse.js' +import { getResponseKey } from '../blueprint/endpoint.js' +import type { CliContext } from '../context.js' +import { isInsideWebBrowser } from '../env.js' +import { RequestSeamApi } from '../http/request.js' +import { interactForActionAttemptPoll } from '../interact/interact-for-action-attempt-poll.js' +import { interactForCommandParams } from '../interact/interact-for-command-params.js' +import { promptConfirm } from '../interact/prompt.js' +import type { CommandResult, Invocation } from './registry.js' + +/** + * Run a command that calls a Seam API endpoint: assemble the params, + * prompt for what is missing, make the request, and run any follow-ups + * the response calls for. + */ +export const executeApiCommand = async ( + invocation: Invocation, + ctx: CliContext, +): Promise => { + const { path } = invocation + const isNonInteractive = ctx.interactivity === 'non-interactive' + + // Params given as arguments win over params piped in. + const commandParams: Record = { ...invocation.stdinParams } + Object.assign(commandParams, invocation.argParams) + + applyEndpointDefaults(path, commandParams) + + // TODO - do this using the OpenAPI spec for the command rather than + // explicitly encoding the property names + if (commandParams['accepted_providers']) { + commandParams['accepted_providers'] = + commandParams['accepted_providers'].split(',') + } + + const apiPath = `/${path.join('/').replace(/-/g, '_')}` + + const params = await interactForCommandParams( + { command: path, params: commandParams }, + ctx, + ) + + if (params === '[Back]') { + return { kind: 'back', toPath: path.slice(0, -1) } + } + + if (apiPath.includes('/events/list') && params.between) { + delete params.since + } + + const response = await RequestSeamApi({ + path: apiPath, + params, + responseKey: getResponseKey(path, ctx), + }) + + if (response.data?.connect_webview) { + await handleConnectWebviewResponse( + response.data.connect_webview, + ctx.interactivity, + ) + } + + if (response.data?.action_attempt && !isNonInteractive) { + await interactForActionAttemptPoll(response.data.action_attempt) + } + + return { kind: 'done' } +} + +/** + * Per-endpoint request policy that is not derivable from the API + * definitions. Keep this table small and explicit. + */ +const applyEndpointDefaults = ( + path: string[], + params: Record, +): void => { + // Unbounded event lists are never wanted, so default to the last month. + if (isEqual(path, ['events', 'list']) && !params['since']) { + const date = new Date() + date.setMonth(date.getMonth() - 1) + params['since'] = date.toISOString() + } +} + +const handleConnectWebviewResponse = async ( + connectWebview: any, + interactivity: Interactivity, +) => { + const url = connectWebview.url + + if (interactivity !== 'non-interactive' && !isInsideWebBrowser()) { + const action = await promptConfirm({ + message: 'Would you like to open the webview in your browser?', + initialValue: false, + }) + + if (action) { + const { default: open } = await import('open') + await open(url) + } + } +} diff --git a/src/lib/commands/local/completion.ts b/src/lib/commands/local/completion.ts new file mode 100644 index 00000000..12ce1a5d --- /dev/null +++ b/src/lib/commands/local/completion.ts @@ -0,0 +1,49 @@ +import { getApiBlueprint } from '../../blueprint/index.js' +import { getOutput } from '../../output/get-output.js' +import { + type CompletionShell, + renderCompletion, +} from '../../render/completion/index.js' +import type { Command } from '../registry.js' + +/** + * Print the completion script for a shell. + * + * Completions always come from the cached API definitions so that they can + * be generated without logging in. They may lag the definitions served by + * Seam when config use-remote-api-defs is enabled. + * + * Called by the entry before any auth or blueprint context exists, and by + * the registered command's executor — one implementation for both. + */ +export const printCompletion = async ( + shell: CompletionShell, + { update = false }: { update?: boolean } = {}, +): Promise => { + // Deferred import: the registry lists this module's commands, so a static + // import back into it would be a cycle. + const { buildRegistry } = await import('../registry.js') + const { spec } = buildRegistry(await getApiBlueprint(false, { update })) + getOutput().text(renderCompletion(shell, spec)) +} + +const completionCommand = (shell: CompletionShell): Command => ({ + definition: { + path: ['completion', shell], + kind: 'cli', + title: `Print the ${shell} completion script.`, + description: '', + flags: [], + }, + requiresAuth: false, + execute: async ({ args }) => { + await printCompletion(shell, { update: args['update'] === true }) + return { kind: 'done' } + }, +}) + +export const completionCommands: Command[] = [ + completionCommand('bash'), + completionCommand('fish'), + completionCommand('zsh'), +] diff --git a/src/lib/commands/local/config-reveal-location.ts b/src/lib/commands/local/config-reveal-location.ts new file mode 100644 index 00000000..5ba79049 --- /dev/null +++ b/src/lib/commands/local/config-reveal-location.ts @@ -0,0 +1,16 @@ +import type { Command } from '../registry.js' + +export const configRevealLocationCommand: Command = { + definition: { + path: ['config', 'reveal-location'], + kind: 'cli', + title: 'Print the path to the CLI configuration file.', + description: '', + flags: [], + }, + requiresAuth: true, + execute: async (_invocation, ctx) => { + ctx.output.text(ctx.config.path) + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/config-set-fake-server.ts b/src/lib/commands/local/config-set-fake-server.ts new file mode 100644 index 00000000..789c5806 --- /dev/null +++ b/src/lib/commands/local/config-set-fake-server.ts @@ -0,0 +1,21 @@ +import { selectFakeServer } from '../../auth/operations.js' +import type { Command } from '../registry.js' + +/** Hidden: a development shortcut, kept out of help and completion. */ +export const configSetFakeServerCommand: Command = { + definition: { + path: ['config', 'set', 'fake-server'], + kind: 'cli', + title: 'Point the CLI at a fake Seam Connect server.', + description: '', + flags: [], + }, + requiresAuth: false, + hidden: true, + execute: async (_invocation, ctx) => { + const { server } = selectFakeServer(undefined, ctx.config) + ctx.output.info(`Server URL set to ${server}`) + ctx.output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/config-use-remote-api-defs.ts b/src/lib/commands/local/config-use-remote-api-defs.ts new file mode 100644 index 00000000..25727e0b --- /dev/null +++ b/src/lib/commands/local/config-use-remote-api-defs.ts @@ -0,0 +1,23 @@ +import { NonInteractiveError } from '../../args/parse.js' +import { interactForUseRemoteApiDefs } from '../../interact/interact-for-use-remote-api-defs.js' +import type { Command } from '../registry.js' + +export const configUseRemoteApiDefsCommand: Command = { + definition: { + path: ['config', 'use-remote-api-defs'], + kind: 'cli', + title: 'Choose whether to use the API definitions served by Seam.', + description: '', + flags: [], + }, + requiresAuth: true, + execute: async (_invocation, ctx) => { + if (ctx.interactivity === 'non-interactive') { + throw new NonInteractiveError( + 'Cannot select whether to use remote API definitions in non-interactive mode', + ) + } + await interactForUseRemoteApiDefs() + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/health.ts b/src/lib/commands/local/health.ts new file mode 100644 index 00000000..556d2ca6 --- /dev/null +++ b/src/lib/commands/local/health.ts @@ -0,0 +1,21 @@ +import { RequestSeamApi } from '../../http/request.js' +import type { Command } from '../registry.js' + +export const healthCommand: Command = { + definition: { + path: ['health', 'get-health'], + // Handled by the CLI itself, but calls the Seam API. + kind: 'api', + title: 'Report the health of the Seam API.', + description: '', + flags: [], + }, + requiresAuth: true, + execute: async () => { + await RequestSeamApi({ + path: '/health/get_health', + params: {}, + }) + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/login.ts b/src/lib/commands/local/login.ts new file mode 100644 index 00000000..171a7ea5 --- /dev/null +++ b/src/lib/commands/local/login.ts @@ -0,0 +1,42 @@ +import { NonInteractiveError } from '../../args/parse.js' +import { assertMutable, login } from '../../auth/operations.js' +import { interactForLogin } from '../../interact/interact-for-login.js' +import type { Command } from '../registry.js' +import { stringFlag } from '../spec.js' + +export const loginCommand: Command = { + definition: { + path: ['login'], + kind: 'cli', + title: 'Log in to Seam.', + description: + 'Prompts for a personal access token unless one is passed with --token.', + flags: [ + stringFlag('server', 'Seam API server to log in to.'), + stringFlag('token', 'Personal access token to log in with.'), + stringFlag('workspace-id', 'Workspace to select after logging in.'), + ], + }, + requiresAuth: false, + execute: async ({ args }, ctx) => { + if (args['token'] || args['workspace_id'] || args['server']) { + await login( + { + server: args['server'] ? args['server'] : undefined, + token: args['token'] ? String(args['token']).trim() : undefined, + workspaceId: args['workspace_id'] ? args['workspace_id'] : undefined, + }, + ctx.config, + ) + return { kind: 'done' } + } + assertMutable(ctx.auth, 'token', 'log in') + if (ctx.interactivity === 'non-interactive') { + throw new NonInteractiveError( + 'Missing required parameter for login: --token', + ) + } + await interactForLogin() + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/logout.ts b/src/lib/commands/local/logout.ts new file mode 100644 index 00000000..ce74d010 --- /dev/null +++ b/src/lib/commands/local/logout.ts @@ -0,0 +1,18 @@ +import { logout } from '../../auth/operations.js' +import type { Command } from '../registry.js' + +export const logoutCommand: Command = { + definition: { + path: ['logout'], + kind: 'cli', + title: 'Log out of Seam.', + description: '', + flags: [], + }, + requiresAuth: true, + execute: async (_invocation, ctx) => { + logout(ctx.config) + ctx.output.info('Logged out!') + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/select-server.ts b/src/lib/commands/local/select-server.ts new file mode 100644 index 00000000..562bffc3 --- /dev/null +++ b/src/lib/commands/local/select-server.ts @@ -0,0 +1,30 @@ +import { NonInteractiveError } from '../../args/parse.js' +import { assertMutable, selectServer } from '../../auth/operations.js' +import { interactForServerSelection } from '../../interact/interact-for-server-selection.js' +import type { Command } from '../registry.js' +import { stringFlag } from '../spec.js' + +export const selectServerCommand: Command = { + definition: { + path: ['select', 'server'], + kind: 'cli', + title: 'Select the Seam API server.', + description: '', + flags: [stringFlag('server', 'Seam API server to select.')], + }, + requiresAuth: false, + execute: async ({ args }, ctx) => { + assertMutable(ctx.auth, 'server', 'select a server') + if (args['server']) { + selectServer(args['server'], ctx.config) + return { kind: 'done' } + } + if (ctx.interactivity === 'non-interactive') { + throw new NonInteractiveError( + 'Missing required parameter for select server: --server', + ) + } + await interactForServerSelection() + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/select-workspace.ts b/src/lib/commands/local/select-workspace.ts new file mode 100644 index 00000000..b8fdff4a --- /dev/null +++ b/src/lib/commands/local/select-workspace.ts @@ -0,0 +1,25 @@ +import { NonInteractiveError } from '../../args/parse.js' +import { assertMutable } from '../../auth/operations.js' +import { interactForWorkspaceId } from '../../interact/interact-for-workspace-id.js' +import type { Command } from '../registry.js' + +export const selectWorkspaceCommand: Command = { + definition: { + path: ['select', 'workspace'], + kind: 'cli', + title: 'Select the current workspace.', + description: '', + flags: [], + }, + requiresAuth: true, + execute: async (_invocation, ctx) => { + assertMutable(ctx.auth, 'workspaceId', 'select a workspace') + if (ctx.interactivity === 'non-interactive') { + throw new NonInteractiveError( + 'Cannot select a workspace in non-interactive mode: pass --workspace-id to "seam login"', + ) + } + await interactForWorkspaceId() + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/wizard.ts b/src/lib/commands/local/wizard.ts new file mode 100644 index 00000000..dd7d08da --- /dev/null +++ b/src/lib/commands/local/wizard.ts @@ -0,0 +1,31 @@ +import type { Command } from '../registry.js' + +/** + * Run the Seam setup wizard. + * + * Intercepted by the entry before argument parsing so the wizard owns its + * own argv; the registered executor covers selecting it interactively. + */ +export const runWizard = async (argv: string[]): Promise => { + const { default: wizard } = await import('@seamapi/wizard') + await wizard({ + argv, + commandName: 'seam wizard', + }) +} + +export const wizardCommand: Command = { + definition: { + path: ['wizard'], + kind: 'cli', + title: 'Set up Seam in the current project.', + description: + 'Takes a project from zero to a working Seam integration. Run seam wizard --help for its own options.', + flags: [], + }, + requiresAuth: false, + execute: async () => { + await runWizard([]) + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/registry.test.ts b/src/lib/commands/registry.test.ts new file mode 100644 index 00000000..31e1019b --- /dev/null +++ b/src/lib/commands/registry.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from 'vitest' + +import { testBlueprint } from '../../../test/fixtures/blueprint.js' +import { + acceptedParamsOf, + buildRegistry, + findLocalCommand, + localCommands, +} from './registry.js' + +const registry = buildRegistry(testBlueprint) + +test('registry: every spec command resolves to an executable command', () => { + for (const { path } of registry.spec.commands) { + const command = registry.find(path) + expect(command, `no executor for seam ${path.join(' ')}`).toBeDefined() + expect(command?.execute).toBeTypeOf('function') + } +}) + +test('registry: every visible local command is in the spec', () => { + for (const { definition, hidden } of localCommands) { + const inSpec = registry.spec.commands.some( + ({ path }) => path.join(' ') === definition.path.join(' '), + ) + expect( + inSpec, + `seam ${definition.path.join(' ')} should${hidden === true ? ' not' : ''} be in the spec`, + ).toBe(hidden !== true) + } +}) + +test('registry: hidden commands are findable without being offered', () => { + const fakeServer = registry.find(['config', 'set', 'fake-server']) + expect(fakeServer?.hidden).toBe(true) + expect(fakeServer?.requiresAuth).toBe(false) +}) + +test('registry: only commands for logging in and selecting a server skip auth', () => { + const noAuth = localCommands + .filter(({ requiresAuth }) => !requiresAuth) + .map(({ definition }) => definition.path.join(' ')) + .sort() + expect(noAuth).toEqual([ + 'completion bash', + 'completion fish', + 'completion zsh', + 'config set fake-server', + 'login', + 'select server', + 'wizard', + ]) +}) + +test('registry: api commands come from the blueprint and require auth', () => { + const devicesList = registry.find(['devices', 'list']) + expect(devicesList?.requiresAuth).toBe(true) + expect(devicesList?.definition.kind).toBe('api') +}) + +test('findLocalCommand: knows nothing of blueprint endpoints', () => { + expect(findLocalCommand(['login'])?.definition.path).toEqual(['login']) + expect(findLocalCommand(['devices', 'list'])).toBeUndefined() +}) + +test('acceptedParamsOf: names the parameters behind the flags', () => { + const login = findLocalCommand(['login']) + expect(login).toBeDefined() + if (login == null) return + expect(acceptedParamsOf(login.definition)).toEqual( + new Set(['server', 'token', 'workspace_id']), + ) +}) diff --git a/src/lib/commands/registry.ts b/src/lib/commands/registry.ts new file mode 100644 index 00000000..5b415946 --- /dev/null +++ b/src/lib/commands/registry.ts @@ -0,0 +1,120 @@ +import type { ParsedArgs } from 'minimist' + +import { toParameterName } from '../args/parse.js' +import type { ApiBlueprint } from '../blueprint/index.js' +import type { CliContext } from '../context.js' +import { executeApiCommand } from './api-command.js' +import { completionCommands } from './local/completion.js' +import { configRevealLocationCommand } from './local/config-reveal-location.js' +import { configSetFakeServerCommand } from './local/config-set-fake-server.js' +import { configUseRemoteApiDefsCommand } from './local/config-use-remote-api-defs.js' +import { healthCommand } from './local/health.js' +import { loginCommand } from './local/login.js' +import { logoutCommand } from './local/logout.js' +import { selectServerCommand } from './local/select-server.js' +import { selectWorkspaceCommand } from './local/select-workspace.js' +import { wizardCommand } from './local/wizard.js' +import { + type CommandDefinition, + type CommandSpec, + getCommandSpec, + isSamePath, +} from './spec.js' + +/** + * One invocable command: what it looks like to help, completion, and the + * interactive picker, whether it needs a login, and how to run it. Declaring + * the metadata and the executor together is what keeps them from drifting. + */ +export interface Command { + definition: CommandDefinition + /** Whether the command needs a token before it can do anything. */ + requiresAuth: boolean + /** Kept out of the spec, so out of help, completion, and the picker. */ + hidden?: boolean + execute: (invocation: Invocation, ctx: CliContext) => Promise +} + +/** Everything a single run of a command was given. */ +export interface Invocation { + path: string[] + /** Params given as arguments, held to what the command accepts. */ + argParams: Record + /** Params piped in as JSON, passed through as given. */ + stdinParams: Record + /** The full parsed arguments, for commands that read their own flags. */ + args: ParsedArgs +} + +export type CommandResult = + | { kind: 'done' } + /** Navigate back to selecting a command under `toPath`. */ + | { kind: 'back'; toPath: string[] } + +export interface CommandRegistry { + /** The spec help, completion, and the interactive picker render. */ + spec: CommandSpec + find: (path: string[]) => Command | undefined +} + +/** + * Commands handled by the CLI itself, which have no endpoint in the + * blueprint. The single source of truth: the spec, the picker, and the + * dispatcher all consume this list. + */ +export const localCommands: Command[] = [ + ...completionCommands, + configRevealLocationCommand, + configSetFakeServerCommand, + configUseRemoteApiDefsCommand, + healthCommand, + loginCommand, + logoutCommand, + selectServerCommand, + selectWorkspaceCommand, + wizardCommand, +] + +/** Definitions shown in help, completion, and the picker. */ +export const localCommandDefinitions: CommandDefinition[] = localCommands + .filter((command) => command.hidden !== true) + .map((command) => command.definition) + +/** + * The local command going by a path, or `undefined` when the path is an + * endpoint or no command at all. Needs no blueprint, so the entry may check + * commands before any definitions are loaded. + */ +export const findLocalCommand = (path: string[]): Command | undefined => + localCommands.find((command) => isSamePath(command.definition.path, path)) + +/** Parameter names a command accepts as arguments. */ +export const acceptedParamsOf = (definition: CommandDefinition): Set => + new Set( + definition.flags.flatMap(({ long }) => + long == null ? [] : [toParameterName(long)], + ), + ) + +export const buildRegistry = (blueprint: ApiBlueprint): CommandRegistry => { + const spec = getCommandSpec(blueprint, localCommandDefinitions) + + const commands = new Map() + for (const definition of spec.commands) { + commands.set(definition.path.join(' '), { + definition, + requiresAuth: true, + execute: executeApiCommand, + }) + } + // Local commands win over a same-named endpoint, as the spec's dedupe does, + // and hidden ones are findable without being in the spec. + for (const command of localCommands) { + commands.set(command.definition.path.join(' '), command) + } + + return { + spec, + find: (path) => commands.get(path.join(' ')), + } +} diff --git a/src/lib/command-spec.test.ts b/src/lib/commands/spec.test.ts similarity index 85% rename from src/lib/command-spec.test.ts rename to src/lib/commands/spec.test.ts index 2a8e0af1..66abf19b 100644 --- a/src/lib/command-spec.test.ts +++ b/src/lib/commands/spec.test.ts @@ -1,15 +1,10 @@ import { expect, test } from 'vitest' -import { testBlueprint } from '../../test/fixtures/blueprint.js' -import { - findCommand, - findGroup, - firstSentence, - getCommandSpec, - toPlainText, -} from './command-spec.js' +import { testBlueprint } from '../../../test/fixtures/blueprint.js' +import { localCommandDefinitions } from './registry.js' +import { findCommand, findGroup, getCommandSpec } from './spec.js' -const spec = getCommandSpec(testBlueprint) +const spec = getCommandSpec(testBlueprint, localCommandDefinitions) test('command spec: derives commands from endpoint paths', () => { expect(findCommand(spec, ['devices', 'list'])?.title).toBe('List Devices') @@ -155,20 +150,3 @@ test('command spec: a command path is either a command or a group', () => { expect(findCommand(spec, ['nope'])).toBeUndefined() expect(findGroup(spec, ['nope'])).toBeUndefined() }) - -test('toPlainText: reduces markdown to one line', () => { - expect(toPlainText('Returns all [devices](https://docs.seam.co).')).toBe( - 'Returns all devices.', - ) - expect(toPlainText('Uses `code`\nand **bold**.')).toBe('Uses code and bold.') - expect(toPlainText("Keeps the device's colon: intact.")).toBe( - "Keeps the device's colon: intact.", - ) -}) - -test('firstSentence: stops at the first sentence break', () => { - expect(firstSentence('First sentence. Second sentence.')).toBe( - 'First sentence.', - ) - expect(firstSentence('No break here')).toBe('No break here') -}) diff --git a/src/lib/command-spec.ts b/src/lib/commands/spec.ts similarity index 71% rename from src/lib/command-spec.ts rename to src/lib/commands/spec.ts index f0ef0487..7da87808 100644 --- a/src/lib/command-spec.ts +++ b/src/lib/commands/spec.ts @@ -1,5 +1,7 @@ import type { Blueprint } from '@seamapi/blueprint' +import { firstSentence, toPlainText } from '../render/text.js' + type Endpoint = Blueprint['routes'][number]['endpoints'][number] type Parameter = Endpoint['request']['parameters'][number] @@ -122,7 +124,15 @@ export const flagTokens = (flag: CommandFlag): string[] => { return tokens } -export const getCommandSpec = (blueprint: Blueprint): CommandSpec => { +/** + * Derive the command spec from the API definitions, merged with the commands + * the CLI declares itself (see `commands/registry.ts`, the single source of + * those declarations). + */ +export const getCommandSpec = ( + blueprint: Blueprint, + localCommands: CommandDefinition[] = [], +): CommandSpec => { const commands = sortByPath( dedupeByPath([ ...blueprint.routes @@ -151,22 +161,10 @@ export const findGroup = ( ): CommandGroup | undefined => spec.groups.find((group) => isSamePath(group.path, path)) -/** - * The definition of a command the CLI handles itself, or `undefined` when the - * path is an endpoint or no command at all. - * - * Unlike {@link findCommand} this needs no blueprint, since these commands are - * declared by the CLI rather than derived from the API definitions. - */ -export const findLocalCommand = ( - path: string[], -): CommandDefinition | undefined => - localCommands.find((command) => isSamePath(command.path, path)) - -const isSamePath = (a: string[], b: string[]): boolean => +export const isSamePath = (a: string[], b: string[]): boolean => a.length === b.length && a.every((word, index) => word === b[index]) -const stringFlag = (long: string, description: string): CommandFlag => ({ +export const stringFlag = (long: string, description: string): CommandFlag => ({ long, short: null, description, @@ -175,98 +173,6 @@ const stringFlag = (long: string, description: string): CommandFlag => ({ isRequired: false, }) -/** - * Commands handled by the CLI itself, which have no endpoint in the blueprint. - * - * Keep in sync with the command handling in `src/bin/cli.ts` and the extra - * commands offered by `interactForCommandSelection`. - */ -const localCommands: CommandDefinition[] = [ - { - path: ['completion', 'bash'], - kind: 'cli', - title: 'Print the bash completion script.', - description: '', - flags: [], - }, - { - path: ['completion', 'fish'], - kind: 'cli', - title: 'Print the fish completion script.', - description: '', - flags: [], - }, - { - path: ['completion', 'zsh'], - kind: 'cli', - title: 'Print the zsh completion script.', - description: '', - flags: [], - }, - { - path: ['config', 'reveal-location'], - kind: 'cli', - title: 'Print the path to the CLI configuration file.', - description: '', - flags: [], - }, - { - path: ['config', 'use-remote-api-defs'], - kind: 'cli', - title: 'Choose whether to use the API definitions served by Seam.', - description: '', - flags: [], - }, - { - path: ['health', 'get-health'], - kind: 'api', - title: 'Report the health of the Seam API.', - description: '', - flags: [], - }, - { - path: ['login'], - kind: 'cli', - title: 'Log in to Seam.', - description: - 'Prompts for a personal access token unless one is passed with --token.', - flags: [ - stringFlag('server', 'Seam API server to log in to.'), - stringFlag('token', 'Personal access token to log in with.'), - stringFlag('workspace-id', 'Workspace to select after logging in.'), - ], - }, - { - path: ['logout'], - kind: 'cli', - title: 'Log out of Seam.', - description: '', - flags: [], - }, - { - path: ['select', 'server'], - kind: 'cli', - title: 'Select the Seam API server.', - description: '', - flags: [stringFlag('server', 'Seam API server to select.')], - }, - { - path: ['select', 'workspace'], - kind: 'cli', - title: 'Select the current workspace.', - description: '', - flags: [], - }, - { - path: ['wizard'], - kind: 'cli', - title: 'Set up Seam in the current project.', - description: - 'Takes a project from zero to a working Seam integration. Run seam wizard --help for its own options.', - flags: [], - }, -] - const toCommandDefinition = (endpoint: Endpoint): CommandDefinition => { const description = toPlainText(endpoint.description) @@ -342,7 +248,11 @@ const toCommandGroups = (commands: CommandDefinition[]): CommandGroup[] => { // `seam devices` alongside `seam devices list`. Prefer the command // title, since it describes what running the name does. if (depth === command.path.length - 1) { - entries.set(name, { isCommand: true, kind, description: command.title }) + entries.set(name, { + isCommand: true, + kind, + description: command.title, + }) continue } @@ -397,16 +307,3 @@ const toCommandPath = (path: string): string[] => path.replace(/^\//, '').split('/').map(toFlagName) const toFlagName = (name: string): string => name.replace(/_/g, '-') - -/** Reduce documentation markdown to a single line of prose. */ -export const toPlainText = (markdown: string): string => - markdown - .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') - .replace(/[`*]/g, '') - .replace(/\s+/g, ' ') - .trim() - -export const firstSentence = (text: string): string => { - const [sentence] = text.split(/(?<=\.)\s/) - return sentence ?? text -} diff --git a/src/lib/context.ts b/src/lib/context.ts index bce4286b..19c668fe 100644 --- a/src/lib/context.ts +++ b/src/lib/context.ts @@ -6,6 +6,7 @@ import { getTokenFromEnv, getWorkspaceIdFromEnv, } from './env.js' +import type { Output } from './output/create-output.js' export const defaultServer = 'https://connect.getseam.com' @@ -66,6 +67,7 @@ export const resolveAuth = ( export interface CliContext { config: ConfigStore auth: AuthContext + output: Output blueprint: ApiBlueprint interactivity: Interactivity } diff --git a/src/lib/interact/interact-for-command-selection.test.ts b/src/lib/interact/interact-for-command-selection.test.ts index 35f67299..7c7edeba 100644 --- a/src/lib/interact/interact-for-command-selection.test.ts +++ b/src/lib/interact/interact-for-command-selection.test.ts @@ -1,57 +1,60 @@ import { afterEach, expect, test } from 'vitest' -import type { CliContext } from '../context.js' import { createMemoryPrompt } from './create-memory-prompt.js' import { interactForCommandSelection } from './interact-for-command-selection.js' import { resetPromptClient, setPromptClient, withBackHint } from './prompt.js' afterEach(resetPromptClient) -const ctx = { +const helpers = { interactivity: 'non-interactive', - blueprint: { - routes: [ - { - endpoints: [ - { path: '/devices/get' }, - { path: '/devices/list' }, - { path: '/devices/unmanaged/list' }, - ], - }, - ], - }, -} as unknown as CliContext + commands: [ + ['devices', 'get'], + ['devices', 'list'], + ['devices', 'unmanaged', 'list'], + ], +} as const test('interactForCommandSelection: resolves a complete command', async () => { await expect( - interactForCommandSelection(['devices', 'list'], ctx), + interactForCommandSelection(['devices', 'list'], { + ...helpers, + commands: [...helpers.commands.map((path) => [...path])], + }), ).resolves.toEqual(['devices', 'list']) }) test('interactForCommandSelection: rejects an incomplete command when non-interactive', async () => { await expect( - interactForCommandSelection(['devices'], ctx), + interactForCommandSelection(['devices'], { + ...helpers, + commands: [...helpers.commands.map((path) => [...path])], + }), ).rejects.toThrowError( 'Incomplete command "seam devices": expected one of list, get, unmanaged', ) }) test('interactForCommandSelection: rejects a missing command when non-interactive', async () => { - await expect(interactForCommandSelection([], ctx)).rejects.toThrowError( - /^Missing command: expected one of /, - ) + await expect( + interactForCommandSelection([], { + ...helpers, + commands: [...helpers.commands.map((path) => [...path])], + }), + ).rejects.toThrowError(/^Missing command: expected one of /) }) -const interactiveCtx = { - ...ctx, - interactivity: 'interactive', -} as unknown as CliContext +const interactiveHelpers = () => ({ + ...helpers, + interactivity: 'interactive' as const, + commands: [...helpers.commands.map((path) => [...path])], +}) test('interactForCommandSelection: tells the user a sub-command menu can be left', async () => { const memoryPrompt = createMemoryPrompt(['list']) setPromptClient(memoryPrompt.client) - await interactForCommandSelection(['devices'], interactiveCtx) + await interactForCommandSelection(['devices'], interactiveHelpers()) expect(memoryPrompt.questions[0]).toMatchObject({ message: withBackHint('Select a command: /devices'), @@ -63,7 +66,7 @@ test('interactForCommandSelection: says nothing about going back at the top leve const memoryPrompt = createMemoryPrompt(['devices', 'list']) setPromptClient(memoryPrompt.client) - await interactForCommandSelection([], interactiveCtx) + await interactForCommandSelection([], interactiveHelpers()) expect(memoryPrompt.questions[0]).toMatchObject({ message: 'Select a command: /', diff --git a/src/lib/interact/interact-for-command-selection.ts b/src/lib/interact/interact-for-command-selection.ts index 95bff427..89be9256 100644 --- a/src/lib/interact/interact-for-command-selection.ts +++ b/src/lib/interact/interact-for-command-selection.ts @@ -1,7 +1,6 @@ import { isDeepStrictEqual as isEqual } from 'node:util' -import { NonInteractiveError } from '../args/parse.js' -import type { CliContext } from '../context.js' +import { type Interactivity, NonInteractiveError } from '../args/parse.js' import { promptAutocomplete, PromptCancelledError, @@ -29,24 +28,16 @@ function ergonomicSort(aStr: string, bStr: string) { return a > b ? 1 : a < b ? -1 : 0 } +/** + * Resolve a command path to a full command, prompting to complete it when + * interactive. `commands` is every selectable command path, from the + * registry's spec. + */ export async function interactForCommandSelection( commandPath: string[], - helpers: CliContext, -) { - const commands = helpers.blueprint.routes - .flatMap((route) => route.endpoints) - .map((endpoint) => - endpoint.path.replace(/_/g, '-').replace(/^\//, '').split('/'), - ) - .concat([ - ['login'], - ['logout'], - ['config', 'reveal-location'], - ['config', 'use-remote-api-defs'], - ['select', 'workspace'], - ['select', 'server'], - ['health', 'get-health'], - ]) + helpers: { commands: string[][]; interactivity: Interactivity }, +): Promise { + const commands = helpers.commands const possibleCommands = uniqBy( commandPath.length === 0 diff --git a/src/lib/render/completion/completion.test.ts b/src/lib/render/completion/completion.test.ts index 657651b5..69c6d696 100644 --- a/src/lib/render/completion/completion.test.ts +++ b/src/lib/render/completion/completion.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'vitest' import { testBlueprint } from '../../../../test/fixtures/blueprint.js' +import { buildRegistry } from '../../commands/registry.js' import { describeForShell } from './describe.js' import { completionScriptSentinels, @@ -10,6 +11,8 @@ import { renderCompletionStub, } from './index.js' +const { spec } = buildRegistry(testBlueprint) + test('isCompletionShell: accepts only supported shells', () => { expect(completionShells.every(isCompletionShell)).toBe(true) expect(isCompletionShell('nushell')).toBe(false) @@ -28,7 +31,7 @@ test('describeForShell: drops characters that would end a quoted string', () => }) test('bash completion: dispatches on the command path', () => { - const script = renderCompletion('bash', testBlueprint) + const script = renderCompletion('bash', spec) expect(script).toContain('complete -F _seam_completion seam') expect(script).toContain("'devices') echo 'list unmanaged' ;;") expect(script).toContain( @@ -40,7 +43,7 @@ test('bash completion: dispatches on the command path', () => { }) test('zsh completion: describes every candidate', () => { - const script = renderCompletion('zsh', testBlueprint) + const script = renderCompletion('zsh', spec) expect(script.startsWith('#compdef seam\n')).toBe(true) expect(script).toContain("('devices') _seam_reply+=('list:List Devices'") expect(script).toContain("'--limit:Number of devices to return.'") @@ -50,7 +53,7 @@ test('zsh completion: describes every candidate', () => { }) test('fish completion: guards each candidate with its command path', () => { - const script = renderCompletion('fish', testBlueprint) + const script = renderCompletion('fish', spec) expect(script).toContain('complete -c seam -f') expect(script).toContain( `complete -c seam -n '__seam_using "devices"' -a 'list' -d 'List Devices'`, @@ -62,7 +65,7 @@ test('fish completion: guards each candidate with its command path', () => { }) test.each(completionShells)('%s completion: quotes safely', (shell) => { - const script = renderCompletion(shell, testBlueprint) + const script = renderCompletion(shell, spec) // Descriptions are embedded in single-quoted shell strings. expect(script).not.toContain("device's") expect(script.endsWith('\n')).toBe(true) @@ -84,9 +87,7 @@ test.each(completionShells)( // The stub requires the sentinel, and the generated script provides it // as its exact first line, so the two cannot drift apart. expect(renderCompletionStub(shell)).toContain(sentinel) - expect( - renderCompletion(shell, testBlueprint).startsWith(`${sentinel}\n`), - ).toBe(true) + expect(renderCompletion(shell, spec).startsWith(`${sentinel}\n`)).toBe(true) }, ) @@ -97,7 +98,7 @@ test('zsh completion stub: is an autoloadable completion function', () => { test('zsh completion: completes the in-flight request when evaluated by the stub', () => { // eval pushes '(eval)' onto funcstack, so the dispatch must search the // whole stack for _seam, not only the top. - expect(renderCompletion('zsh', testBlueprint)).toContain( + expect(renderCompletion('zsh', spec)).toContain( // eslint-disable-next-line no-template-curly-in-string 'if (( ${funcstack[(I)_seam]} )); then', ) diff --git a/src/lib/render/completion/describe.ts b/src/lib/render/completion/describe.ts index 1c937225..18bea988 100644 --- a/src/lib/render/completion/describe.ts +++ b/src/lib/render/completion/describe.ts @@ -1,5 +1,4 @@ -import { firstSentence } from '../../command-spec.js' -import { ellipsis } from '../text.js' +import { ellipsis, firstSentence } from '../text.js' const maxDescriptionLength = 72 diff --git a/src/lib/render/completion/index.ts b/src/lib/render/completion/index.ts index 3487c46b..0c0cf744 100644 --- a/src/lib/render/completion/index.ts +++ b/src/lib/render/completion/index.ts @@ -1,6 +1,4 @@ -import type { Blueprint } from '@seamapi/blueprint' - -import { type CommandSpec, getCommandSpec } from '../../command-spec.js' +import type { CommandSpec } from '../../commands/spec.js' import { renderBashCompletion } from './render-bash.js' import { renderFishCompletion } from './render-fish.js' import { renderZshCompletion } from './render-zsh.js' @@ -27,8 +25,8 @@ const renderers: Record string> = { export const renderCompletion = ( shell: CompletionShell, - blueprint: Blueprint, -): string => renderers[shell](getCommandSpec(blueprint)) + spec: CommandSpec, +): string => renderers[shell](spec) /** * Render the completion loader installed by system packages. diff --git a/src/lib/render/completion/render-bash.ts b/src/lib/render/completion/render-bash.ts index b56063c3..e00ce8ab 100644 --- a/src/lib/render/completion/render-bash.ts +++ b/src/lib/render/completion/render-bash.ts @@ -2,7 +2,7 @@ import { type CommandFlag, type CommandSpec, flagTokens, -} from '../../command-spec.js' +} from '../../commands/spec.js' export const renderBashCompletion = (spec: CommandSpec): string => { const globalTokens = spec.globalFlags.flatMap(flagTokens).sort() diff --git a/src/lib/render/completion/render-fish.ts b/src/lib/render/completion/render-fish.ts index 3dcd5751..b7591fbb 100644 --- a/src/lib/render/completion/render-fish.ts +++ b/src/lib/render/completion/render-fish.ts @@ -1,4 +1,4 @@ -import type { CommandFlag, CommandSpec } from '../../command-spec.js' +import type { CommandFlag, CommandSpec } from '../../commands/spec.js' import { describeForShell } from './describe.js' export const renderFishCompletion = (spec: CommandSpec): string => diff --git a/src/lib/render/completion/render-zsh.ts b/src/lib/render/completion/render-zsh.ts index ef3bbd55..8ece3bd4 100644 --- a/src/lib/render/completion/render-zsh.ts +++ b/src/lib/render/completion/render-zsh.ts @@ -2,7 +2,7 @@ import { type CommandFlag, type CommandSpec, flagTokens, -} from '../../command-spec.js' +} from '../../commands/spec.js' import { describeForShell } from './describe.js' export const renderZshCompletion = (spec: CommandSpec): string => { diff --git a/src/lib/render/help.test.ts b/src/lib/render/help.test.ts index f11eb8cc..3010fac3 100644 --- a/src/lib/render/help.test.ts +++ b/src/lib/render/help.test.ts @@ -1,10 +1,10 @@ import { expect, test } from 'vitest' import { testBlueprint } from '../../../test/fixtures/blueprint.js' -import { getCommandSpec } from '../command-spec.js' +import { buildRegistry } from '../commands/registry.js' import { renderHelp } from '../render/help.js' -const spec = getCommandSpec(testBlueprint) +const { spec } = buildRegistry(testBlueprint) const help = (...path: string[]): string => { const rendered = renderHelp(path, spec) diff --git a/src/lib/render/help.ts b/src/lib/render/help.ts index 2b51a9a9..0f6207dd 100644 --- a/src/lib/render/help.ts +++ b/src/lib/render/help.ts @@ -7,7 +7,7 @@ import { type CommandSpec, findCommand, findGroup, -} from '../command-spec.js' +} from '../commands/spec.js' /** * Render the help guide for a command path, or `null` when no command or diff --git a/src/lib/render/text.test.ts b/src/lib/render/text.test.ts index 6688b327..f59fe3b8 100644 --- a/src/lib/render/text.test.ts +++ b/src/lib/render/text.test.ts @@ -1,8 +1,25 @@ import { expect, test } from 'vitest' -import { ellipsis } from './text.js' +import { ellipsis, firstSentence, toPlainText } from './text.js' test('ellipsis: truncates only when over the limit', () => { expect(ellipsis('seam', 10)).toBe('seam') expect(ellipsis('seam-cli', 6)).toBe('sea...') }) + +test('toPlainText: reduces markdown to one line', () => { + expect(toPlainText('Returns all [devices](https://docs.seam.co).')).toBe( + 'Returns all devices.', + ) + expect(toPlainText('Uses `code`\nand **bold**.')).toBe('Uses code and bold.') + expect(toPlainText("Keeps the device's colon: intact.")).toBe( + "Keeps the device's colon: intact.", + ) +}) + +test('firstSentence: stops at the first sentence break', () => { + expect(firstSentence('First sentence. Second sentence.')).toBe( + 'First sentence.', + ) + expect(firstSentence('No break here')).toBe('No break here') +}) diff --git a/src/lib/render/text.ts b/src/lib/render/text.ts index 7b10f971..59c56422 100644 --- a/src/lib/render/text.ts +++ b/src/lib/render/text.ts @@ -2,3 +2,16 @@ export const ellipsis = (str: string, len: number) => { if (str.length <= len) return str return str.slice(0, len - 3) + '...' } + +/** Reduce documentation markdown to a single line of prose. */ +export const toPlainText = (markdown: string): string => + markdown + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[`*]/g, '') + .replace(/\s+/g, ' ') + .trim() + +export const firstSentence = (text: string): string => { + const [sentence] = text.split(/(?<=\.)\s/) + return sentence ?? text +} diff --git a/test/cli.test.ts b/test/cli.test.ts index 637a6ef4..03efef05 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -379,6 +379,18 @@ test('cli: SEAM_CLI_TOKEN authenticates without logging in', async () => { ) }) +test('cli: help and completion work without being logged in', async () => { + const help = await runCli(['--help'], { stateHome: loggedOutStateHome }) + expect(help.exitCode).toBe(0) + expect(help.stdout).toContain('Seam CLI') + + const completion = await runCli(['completion', 'bash'], { + stateHome: loggedOutStateHome, + }) + expect(completion.exitCode).toBe(0) + expect(completion.stdout).toContain('complete -F _seam_completion seam') +}) + test('cli: reports not being logged in without SEAM_CLI_TOKEN', async () => { const { stdout, stderr, exitCode } = await runCli(['devices', 'list'], { stateHome: loggedOutStateHome, From f72dcc5d942ca90b782b0b7a2bc1d375fac986c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 02:58:15 +0000 Subject: [PATCH 10/20] refactor: Extract required-parameter validation from the prompt layer The only place required params were checked was inside the interactive parameter editor, which is why validation lived in a UX module. args/validate.ts now owns assertRequiredParams; the api-command executor validates and sends directly on non-interactive runs, and the editor delegates to the same function for its nested-object flows. Error strings are unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- src/lib/args/validate.test.ts | 78 +++++++++++++++++++ src/lib/args/validate.ts | 36 ++++++++- src/lib/commands/api-command.ts | 34 +++++--- .../interact/interact-for-blueprint-object.ts | 11 +-- 4 files changed, 142 insertions(+), 17 deletions(-) create mode 100644 src/lib/args/validate.test.ts diff --git a/src/lib/args/validate.test.ts b/src/lib/args/validate.test.ts new file mode 100644 index 00000000..21a11aa4 --- /dev/null +++ b/src/lib/args/validate.test.ts @@ -0,0 +1,78 @@ +import type { Parameter } from '@seamapi/blueprint' +import { expect, test } from 'vitest' + +import { assertKnownArgs, assertRequiredParams } from './validate.js' + +const parameters = [ + { name: 'device_id', isRequired: true, format: 'id' }, + { name: 'code', isRequired: true, format: 'string' }, + { name: 'name', isRequired: false, format: 'string' }, +] as unknown as Parameter[] + +test('assertRequiredParams: passes when every required parameter is given', () => { + expect(() => { + assertRequiredParams( + parameters, + { device_id: 'device1', code: '1234' }, + '/access_codes/create', + ) + }).not.toThrow() +}) + +test('assertRequiredParams: names one missing parameter as its argument', () => { + expect(() => { + assertRequiredParams(parameters, { code: '1234' }, '/access_codes/create') + }).toThrow('Missing required parameter for /access_codes/create: --device-id') +}) + +test('assertRequiredParams: names every missing parameter at once', () => { + expect(() => { + assertRequiredParams(parameters, {}, '/access_codes/create') + }).toThrow( + 'Missing required parameters for /access_codes/create: --device-id --code', + ) +}) + +test('assertKnownArgs: passes when every argument is accepted', () => { + expect(() => { + assertKnownArgs({ limit: 5 }, ['devices', 'list'], { + accepted: new Set(['limit']), + isLocal: false, + }) + }).not.toThrow() +}) + +test('assertKnownArgs: names an endpoint command by its path', () => { + expect(() => { + assertKnownArgs({ limitt: 5 }, ['devices', 'list'], { + accepted: new Set(['limit']), + isLocal: false, + }) + }).toThrow('Unknown parameter for /devices/list: --limitt') +}) + +test('assertKnownArgs: names a CLI command by its words', () => { + expect(() => { + assertKnownArgs({ serverr: 'https://example.com' }, ['select', 'server'], { + accepted: new Set(['server']), + isLocal: true, + }) + }).toThrow('Unknown parameter for select server: --serverr') +}) + +test('assertKnownArgs: names every unknown argument at once, with a hint', () => { + try { + assertKnownArgs({ limitt: 5, n: true }, ['devices', 'list'], { + accepted: new Set(['limit']), + isLocal: false, + }) + expect.unreachable() + } catch (error: any) { + expect(error.message).toBe( + 'Unknown parameters for /devices/list: --limitt -n', + ) + expect(error.hint).toBe( + "Run 'seam devices list --help' to see what it accepts.", + ) + } +}) diff --git a/src/lib/args/validate.ts b/src/lib/args/validate.ts index 951f1ea2..9c3f6e74 100644 --- a/src/lib/args/validate.ts +++ b/src/lib/args/validate.ts @@ -1,4 +1,38 @@ -import { toGivenArgName, UsageError } from './parse.js' +import type { Parameter } from '@seamapi/blueprint' + +import { + NonInteractiveError, + toArgName, + toGivenArgName, + UsageError, +} from './parse.js' + +/** + * Report every required parameter still missing from the params, rather + * than prompting for it. + * + * @param target What the params are for, e.g., `/devices/list`. + */ +export const assertRequiredParams = ( + parameters: Parameter[], + params: Record, + target: string, +): void => { + // A required parameter is satisfied by being present, not by being + // truthy: `false`, `0` and `''` are values a caller can supply. + const missing = parameters + .filter((parameter) => parameter.isRequired) + .map((parameter) => parameter.name) + .filter((name) => params[name] === undefined) + + if (missing.length === 0) return + + throw new NonInteractiveError( + `Missing required ${ + missing.length === 1 ? 'parameter' : 'parameters' + } for ${target}: ${missing.map(toArgName).join(' ')}`, + ) +} /** * Report any argument the command does not accept, rather than acting on it. diff --git a/src/lib/commands/api-command.ts b/src/lib/commands/api-command.ts index ec01f751..f7318ca0 100644 --- a/src/lib/commands/api-command.ts +++ b/src/lib/commands/api-command.ts @@ -1,7 +1,11 @@ import { isDeepStrictEqual as isEqual } from 'node:util' import type { Interactivity } from '../args/parse.js' -import { getResponseKey } from '../blueprint/endpoint.js' +import { assertRequiredParams } from '../args/validate.js' +import { + getCommandBlueprintDef, + getResponseKey, +} from '../blueprint/endpoint.js' import type { CliContext } from '../context.js' import { isInsideWebBrowser } from '../env.js' import { RequestSeamApi } from '../http/request.js' @@ -37,17 +41,29 @@ export const executeApiCommand = async ( const apiPath = `/${path.join('/').replace(/-/g, '_')}` - const params = await interactForCommandParams( - { command: path, params: commandParams }, - ctx, - ) + // Non-interactive runs never prompt: validate and send what was given. + let params: Record + if (isNonInteractive) { + assertRequiredParams( + getCommandBlueprintDef(path, ctx).request.parameters, + commandParams, + apiPath, + ) + params = commandParams + } else { + const edited = await interactForCommandParams( + { command: path, params: commandParams }, + ctx, + ) - if (params === '[Back]') { - return { kind: 'back', toPath: path.slice(0, -1) } + if (edited === '[Back]') { + return { kind: 'back', toPath: path.slice(0, -1) } + } + params = edited } - if (apiPath.includes('/events/list') && params.between) { - delete params.since + if (apiPath.includes('/events/list') && params['between']) { + delete params['since'] } const response = await RequestSeamApi({ diff --git a/src/lib/interact/interact-for-blueprint-object.ts b/src/lib/interact/interact-for-blueprint-object.ts index fbd0dcf0..9480b4ce 100644 --- a/src/lib/interact/interact-for-blueprint-object.ts +++ b/src/lib/interact/interact-for-blueprint-object.ts @@ -1,6 +1,7 @@ import type { Parameter } from '@seamapi/blueprint' -import { NonInteractiveError, toArgName } from '../args/parse.js' +import { NonInteractiveError } from '../args/parse.js' +import { assertRequiredParams } from '../args/validate.js' import type { CliContext } from '../context.js' import { getOutput } from '../output/get-output.js' import { ellipsis } from '../render/text.js' @@ -71,14 +72,10 @@ export const interactForBlueprintObject = async ( } if (ctx.interactivity === 'non-interactive') { - const missing = required.filter((k) => !isSupplied(k)) const target = args.isSubProperty ? `"${args.subPropertyPath}"` : cmdPath + assertRequiredParams(args.parameters, args.params, target) throw new NonInteractiveError( - missing.length > 0 - ? `Missing required ${ - missing.length === 1 ? 'parameter' : 'parameters' - } for ${target}: ${missing.map(toArgName).join(' ')}` - : `Cannot prompt for ${target} in non-interactive mode`, + `Cannot prompt for ${target} in non-interactive mode`, ) } From 01c019c66930ef2cca300717350b2447813a7dbb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 03:04:50 +0000 Subject: [PATCH 11/20] feat!: Parse arguments into the JSON types their parameters document Argument values previously arrived typed by minimist's guessing: --is-managed true reached the API as the string "true" while the same parameter entered interactively was a real boolean, opaque strings were mangled into numbers unless hand-listed, and one list parameter was comma-split by a hardcoded hack. The api-command executor now re-reads argv with the endpoint's own parameter types (string-listing everything that is not a number or boolean) and args/coerce.ts turns each value into the JSON type its parameter documents: real booleans and numbers, comma-split lists typed per item, JSON-parsed objects, enum membership checked. A value that does not fit fails with a UsageError naming what the parameter expects instead of being sent for the API to reject. BREAKING CHANGE: request bodies for arguments change type. Booleans and lists that previously arrived as strings are now JSON booleans and arrays; values outside a documented enum are rejected client-side. Params piped in over stdin are passed through as given and are no longer comma-split. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- src/bin/cli.ts | 6 +- src/lib/args/coerce.test.ts | 112 ++++++++++++++++++++++ src/lib/args/coerce.ts | 158 ++++++++++++++++++++++++++++++++ src/lib/args/parse.ts | 37 +++++++- src/lib/commands/api-command.ts | 53 +++++++---- src/lib/commands/registry.ts | 2 + test/cli.test.ts | 109 ++++++++++++++++++++++ test/fixtures/blueprint.ts | 31 +++++++ 8 files changed, 484 insertions(+), 24 deletions(-) create mode 100644 src/lib/args/coerce.test.ts create mode 100644 src/lib/args/coerce.ts diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 2b0af077..ad96d9b2 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -37,7 +37,7 @@ import { import { renderHelp } from 'lib/render/help.js' import seamapiCliVersion from 'lib/version.js' -async function cli(args: ParsedArgs) { +async function cli(args: ParsedArgs, argv: string[]) { const config = getConfigStore() const output = getOutput() @@ -183,7 +183,7 @@ async function cli(args: ParsedArgs) { }) const result = await command.execute( - { path: selectedCommand, argParams, stdinParams, args }, + { path: selectedCommand, argParams, stdinParams, args, argv }, ctx, ) @@ -216,7 +216,7 @@ const run = async (argv: string[]) => { }), ) - await cli(args) + await cli(args, argv) } run(process.argv.slice(2)).catch((e: unknown) => { diff --git a/src/lib/args/coerce.test.ts b/src/lib/args/coerce.test.ts new file mode 100644 index 00000000..4c9fe0b9 --- /dev/null +++ b/src/lib/args/coerce.test.ts @@ -0,0 +1,112 @@ +import type { Parameter } from '@seamapi/blueprint' +import { expect, test } from 'vitest' + +import { coerceArgParams, coerceParam } from './coerce.js' + +const parameter = (shape: Record): Parameter => + shape as unknown as Parameter + +const boolean = parameter({ name: 'is_managed', format: 'boolean' }) +const number = parameter({ name: 'limit', format: 'number' }) +const string = parameter({ name: 'code', format: 'string' }) +const id = parameter({ name: 'device_id', format: 'id' }) +const datetime = parameter({ name: 'since', format: 'datetime' }) +const enumParam = parameter({ + name: 'device_type', + format: 'enum', + values: [{ name: 'august_lock' }, { name: 'schlage_lock' }], +}) +const list = parameter({ + name: 'accepted_providers', + format: 'list', + itemFormat: 'string', +}) +const numberList = parameter({ + name: 'limits', + format: 'list', + itemFormat: 'number', +}) +const enumList = parameter({ + name: 'device_types', + format: 'list', + itemFormat: 'enum', + itemEnumValues: [{ name: 'august_lock' }, { name: 'schlage_lock' }], +}) +const object = parameter({ name: 'custom_metadata', format: 'object' }) + +test.each([ + [boolean, 'true', true], + [boolean, 'false', false], + [boolean, true, true], + [boolean, '1', true], + [boolean, 0, false], + [number, 5, 5], + [number, '5', 5], + [number, '0.5', 0.5], + [string, '0123', '0123'], + [string, 'a,b', 'a,b'], + [id, 'device1', 'device1'], + [datetime, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'], + [enumParam, 'august_lock', 'august_lock'], + [list, 'a,b', ['a', 'b']], + [list, 'a', ['a']], + [list, ['a', 'b'], ['a', 'b']], + [list, 5, ['5']], + [numberList, '1,2', [1, 2]], + [enumList, 'august_lock,schlage_lock', ['august_lock', 'schlage_lock']], + [object, '{"floor":2}', { floor: 2 }], +] as Array<[Parameter, unknown, unknown]>)( + 'coerceParam: %o given %o becomes %o', + (param, given, value) => { + expect(coerceParam(param, given)).toEqual({ value }) + }, +) + +test.each([ + [boolean, 'maybe', 'true or false'], + [boolean, 2, 'true or false'], + [number, 'five', 'a number'], + [number, '', 'a number'], + [number, true, 'a number'], + [enumParam, 'bogus', 'one of august_lock, schlage_lock'], + [numberList, '1,two', 'a list of numbers'], + [enumList, 'august_lock,bogus', 'a list of august_lock, schlage_lock'], + [object, 'not json', 'a JSON object'], + [object, '[1]', 'a JSON object'], + [string, ['a', 'b'], 'a single value'], +] as Array<[Parameter, unknown, string]>)( + 'coerceParam: %o rejects %o expecting %s', + (param, given, issue) => { + expect(coerceParam(param, given)).toEqual({ issue }) + }, +) + +test('coerceArgParams: coerces each argument by its own parameter', () => { + const { params, issues } = coerceArgParams([boolean, number, string], { + is_managed: 'true', + limit: '5', + code: '0123', + }) + + expect(issues).toEqual([]) + expect(params).toEqual({ is_managed: true, limit: 5, code: '0123' }) +}) + +test('coerceArgParams: passes unknown arguments through unchanged', () => { + const { params, issues } = coerceArgParams([number], { nope: 'x' }) + + expect(issues).toEqual([]) + expect(params).toEqual({ nope: 'x' }) +}) + +test('coerceArgParams: collects every issue at once', () => { + const { issues } = coerceArgParams([boolean, number], { + is_managed: 'maybe', + limit: 'five', + }) + + expect(issues).toEqual([ + { name: 'is_managed', given: 'maybe', expected: 'true or false' }, + { name: 'limit', given: 'five', expected: 'a number' }, + ]) +}) diff --git a/src/lib/args/coerce.ts b/src/lib/args/coerce.ts new file mode 100644 index 00000000..06e955eb --- /dev/null +++ b/src/lib/args/coerce.ts @@ -0,0 +1,158 @@ +import type { Parameter } from '@seamapi/blueprint' + +/** An argument whose value does not fit the parameter's documented format. */ +export interface CoercionIssue { + name: string + given: unknown + /** What the parameter takes, e.g., `a number`. */ + expected: string +} + +type Coerced = { value: unknown } | { issue: string } + +/** + * Read each argument as the JSON value its parameter documents: booleans and + * numbers become real booleans and numbers, lists split on commas, objects + * parse as JSON. The request body is then the same whether a value arrived + * as an argument, over stdin, or interactively. + * + * An argument naming no parameter passes through unchanged: unknown + * arguments are reported by `assertKnownArgs`, not silently dropped here. + */ +export const coerceArgParams = ( + parameters: Parameter[], + argParams: Record, +): { params: Record; issues: CoercionIssue[] } => { + const byName = new Map( + parameters.map((parameter) => [parameter.name, parameter]), + ) + + const params: Record = {} + const issues: CoercionIssue[] = [] + + for (const [name, given] of Object.entries(argParams)) { + const parameter = byName.get(name) + if (parameter == null) { + params[name] = given + continue + } + + const coerced = coerceParam(parameter, given) + if ('issue' in coerced) { + issues.push({ name, given, expected: coerced.issue }) + continue + } + params[name] = coerced.value + } + + return { params, issues } +} + +export const coerceParam = (parameter: Parameter, given: unknown): Coerced => { + if (parameter.format === 'list') return coerceList(parameter, given) + + // A repeated argument parses as an array, which only a list accepts. + if (Array.isArray(given)) return { issue: 'a single value' } + + switch (parameter.format) { + case 'boolean': + return coerceBoolean(given) + case 'number': + return coerceNumber(given) + case 'enum': + return coerceEnum(parameter, given) + case 'object': + return coerceObject(given) + default: + return { value: String(given) } + } +} + +const coerceBoolean = (given: unknown): Coerced => { + if (given === true || given === 'true' || given === '1' || given === 1) { + return { value: true } + } + if (given === false || given === 'false' || given === '0' || given === 0) { + return { value: false } + } + return { issue: 'true or false' } +} + +const coerceNumber = (given: unknown): Coerced => { + if (typeof given === 'number') return { value: given } + if (typeof given === 'string' && given.trim() !== '') { + const value = Number(given) + if (!Number.isNaN(value)) return { value } + } + return { issue: 'a number' } +} + +const coerceEnum = ( + parameter: Parameter & { format: 'enum' }, + given: unknown, +): Coerced => { + const value = String(given) + const names = parameter.values.map(({ name }) => name) + if (!names.includes(value)) return { issue: `one of ${names.join(', ')}` } + return { value } +} + +const coerceObject = (given: unknown): Coerced => { + if (typeof given !== 'string') return { issue: 'a JSON object' } + try { + const value: unknown = JSON.parse(given) + if (value == null || typeof value !== 'object' || Array.isArray(value)) { + return { issue: 'a JSON object' } + } + return { value } + } catch { + return { issue: 'a JSON object' } + } +} + +const coerceList = ( + parameter: Parameter & { format: 'list' }, + given: unknown, +): Coerced => { + const items = Array.isArray(given) + ? given + : typeof given === 'string' + ? given.split(',') + : [given] + + const values: unknown[] = [] + for (const item of items) { + const coerced = coerceListItem(parameter, item) + if ('issue' in coerced) return coerced + values.push(coerced.value) + } + return { value: values } +} + +const coerceListItem = ( + parameter: Parameter & { format: 'list' }, + item: unknown, +): Coerced => { + if (parameter.itemFormat === 'number') { + const coerced = coerceNumber(item) + if ('issue' in coerced) return { issue: 'a list of numbers' } + return coerced + } + + if (parameter.itemFormat === 'boolean') { + const coerced = coerceBoolean(item) + if ('issue' in coerced) return { issue: 'a list of true or false' } + return coerced + } + + if (parameter.itemFormat === 'enum') { + const value = String(item) + const names = parameter.itemEnumValues.map(({ name }) => name) + if (!names.includes(value)) { + return { issue: `a list of ${names.join(', ')}` } + } + return { value } + } + + return { value: String(item) } +} diff --git a/src/lib/args/parse.ts b/src/lib/args/parse.ts index b5b54e47..7d726445 100644 --- a/src/lib/args/parse.ts +++ b/src/lib/args/parse.ts @@ -59,18 +59,45 @@ export class UsageError extends Error { } } -export const parseCliArgs = (argv: string[]): ParsedArgs => +export interface ParseCliArgsOptions { + /** + * Argument keys read exactly as given rather than by guessing at a type, + * e.g., every parameter of an endpoint that does not take a number or a + * boolean. Read as a number, an opaque value like an access code would + * lose leading zeroes or turn exponent notation into a digit string. + */ + stringKeys?: string[] +} + +export const parseCliArgs = ( + argv: string[], + { stringKeys = [] }: ParseCliArgsOptions = {}, +): ParsedArgs => parseArgs(argv, { - // A page cursor is opaque, so keep it exactly as given: read as a number - // it would lose leading zeroes and turn exponent notation into a digit - // string, naming a page the API never issued. - string: ['code', 'page-cursor', 'page_cursor'], + // A page cursor and a code are opaque even before the endpoint's own + // parameter types are known, so always keep them exactly as given. + string: ['code', 'page-cursor', 'page_cursor', ...stringKeys], boolean: ['non-interactive', 'interactive', 'json'], // Deliberately not aliased to -n, which is reserved for a future // --dry-run flag. alias: { 'non-interactive': 'y', interactive: 'i' }, }) +/** + * The request params among the parsed arguments: every key normalized to + * the parameter it names, minus the flags that configure the CLI itself. + */ +export const toArgParams = (args: ParsedArgs): Record => { + const argParams: Record = {} + for (const [key, value] of Object.entries(args)) { + if (key === '_') continue + const name = toParameterName(key) + if (cliFlags.includes(name)) continue + argParams[name] = value + } + return argParams +} + export interface GetInteractivityOptions { /** * Whether there is a terminal to prompt on. diff --git a/src/lib/commands/api-command.ts b/src/lib/commands/api-command.ts index f7318ca0..f95dad60 100644 --- a/src/lib/commands/api-command.ts +++ b/src/lib/commands/api-command.ts @@ -1,6 +1,13 @@ import { isDeepStrictEqual as isEqual } from 'node:util' -import type { Interactivity } from '../args/parse.js' +import { coerceArgParams } from '../args/coerce.js' +import { + type Interactivity, + parseCliArgs, + toArgName, + toArgParams, + UsageError, +} from '../args/parse.js' import { assertRequiredParams } from '../args/validate.js' import { getCommandBlueprintDef, @@ -25,30 +32,44 @@ export const executeApiCommand = async ( ): Promise => { const { path } = invocation const isNonInteractive = ctx.interactivity === 'non-interactive' + const apiPath = `/${path.join('/').replace(/-/g, '_')}` + + const parameters = getCommandBlueprintDef(path, ctx).request.parameters + + // Re-read the arguments knowing the endpoint's own parameter types — the + // generic first parse guessed, mangling opaque values like access codes — + // then coerce each value to the JSON type its parameter documents. + const stringKeys = parameters + .filter(({ format }) => format !== 'boolean' && format !== 'number') + .flatMap(({ name }) => [name, name.replace(/_/g, '-')]) + const { params: argParams, issues } = coerceArgParams( + parameters, + toArgParams(parseCliArgs(invocation.argv, { stringKeys })), + ) + + if (issues.length > 0) { + throw new UsageError( + `Invalid ${ + issues.length === 1 ? 'value' : 'values' + } for ${apiPath}: ${issues + .map(({ name, expected }) => `${toArgName(name)} expects ${expected}`) + .join('; ')}`, + { + hint: `Run 'seam ${path.join(' ')} --help' to see what it accepts.`, + }, + ) + } // Params given as arguments win over params piped in. const commandParams: Record = { ...invocation.stdinParams } - Object.assign(commandParams, invocation.argParams) + Object.assign(commandParams, argParams) applyEndpointDefaults(path, commandParams) - // TODO - do this using the OpenAPI spec for the command rather than - // explicitly encoding the property names - if (commandParams['accepted_providers']) { - commandParams['accepted_providers'] = - commandParams['accepted_providers'].split(',') - } - - const apiPath = `/${path.join('/').replace(/-/g, '_')}` - // Non-interactive runs never prompt: validate and send what was given. let params: Record if (isNonInteractive) { - assertRequiredParams( - getCommandBlueprintDef(path, ctx).request.parameters, - commandParams, - apiPath, - ) + assertRequiredParams(parameters, commandParams, apiPath) params = commandParams } else { const edited = await interactForCommandParams( diff --git a/src/lib/commands/registry.ts b/src/lib/commands/registry.ts index 5b415946..257b53cb 100644 --- a/src/lib/commands/registry.ts +++ b/src/lib/commands/registry.ts @@ -44,6 +44,8 @@ export interface Invocation { stdinParams: Record /** The full parsed arguments, for commands that read their own flags. */ args: ParsedArgs + /** The raw argv, for commands that re-read arguments with their own types. */ + argv: string[] } export type CommandResult = diff --git a/test/cli.test.ts b/test/cli.test.ts index 03efef05..67f47275 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -7,6 +7,8 @@ import { fileURLToPath } from 'node:url' import { execa } from 'execa' import { afterAll, beforeAll, expect, test } from 'vitest' +import { testBlueprint } from './fixtures/blueprint.js' + const projectRoot = fileURLToPath(new URL('..', import.meta.url)) const entrypoint = join(projectRoot, 'src', 'bin', 'cli.ts') @@ -25,6 +27,7 @@ let server: Server let endpoint: string let stateHome: string let configHome: string +let cacheHome: string let loggedOutStateHome: string let otherServerConfigHome: string let requests: Array<{ @@ -92,6 +95,24 @@ beforeAll(async () => { join(otherServerConfigHome, 'seam', 'cli.json'), JSON.stringify({ server: 'http://localhost:1' }), ) + + // A pre-seeded blueprint cache holding the fixture blueprint, so tests + // that pin parameter handling run against known API definitions and + // never touch the npm registry. + const pkg = JSON.parse( + await readFile(join(projectRoot, 'package.json'), 'utf8'), + ) as { dependencies: Record } + cacheHome = join(home, 'cache') + await mkdir(join(cacheHome, 'seam'), { recursive: true }) + await writeFile( + join(cacheHome, 'seam', 'blueprint.json'), + JSON.stringify({ + blueprintVersion: pkg.dependencies['@seamapi/blueprint'], + typesVersion: '0.0.0-e2e', + checkedAt: new Date().toISOString(), + blueprint: testBlueprint, + }), + ) }) afterAll(async () => { @@ -111,11 +132,13 @@ const runCli = async ( env, configHome: configHomeOverride, stateHome: stateHomeOverride, + cacheHome: cacheHomeOverride, }: { input?: string env?: Record configHome?: string stateHome?: string + cacheHome?: string } = {}, ): Promise => { const { stdout, stderr, exitCode } = await execa( @@ -126,6 +149,9 @@ const runCli = async ( env: { XDG_CONFIG_HOME: configHomeOverride ?? configHome, XDG_STATE_HOME: stateHomeOverride ?? stateHome, + ...(cacheHomeOverride == null + ? {} + : { XDG_CACHE_HOME: cacheHomeOverride }), FORCE_COLOR: '0', // Never inherit credentials from the environment running the tests. SEAM_CLI_TOKEN: undefined, @@ -379,6 +405,89 @@ test('cli: SEAM_CLI_TOKEN authenticates without logging in', async () => { ) }) +test('cli: sends a boolean parameter as a JSON boolean', async () => { + requests = [] + const { exitCode } = await runCli( + ['devices', 'list', '--is-managed', 'true'], + { cacheHome }, + ) + + expect(exitCode).toBe(0) + expect(requests[0]?.body).toEqual({ is_managed: true }) + + await runCli(['devices', 'list', '--is-managed', 'false'], { cacheHome }) + expect(requests[1]?.body).toEqual({ is_managed: false }) +}) + +test('cli: sends a number parameter as a JSON number', async () => { + requests = [] + const { exitCode } = await runCli(['devices', 'list', '--limit', '5'], { + cacheHome, + }) + + expect(exitCode).toBe(0) + expect(requests[0]?.body).toEqual({ limit: 5 }) +}) + +test('cli: keeps an opaque string parameter exactly as given', async () => { + requests = [] + const { exitCode } = await runCli( + ['access-codes', 'create', '--device-id', 'device1', '--code', '0123'], + { cacheHome }, + ) + + expect(exitCode).toBe(0) + expect(requests[0]?.body).toEqual({ device_id: 'device1', code: '0123' }) +}) + +test('cli: splits a list parameter on commas', async () => { + requests = [] + const { exitCode } = await runCli( + [ + 'access-codes', + 'create', + '--device-id', + 'device1', + '--accepted-providers', + 'august,schlage', + ], + { cacheHome }, + ) + + expect(exitCode).toBe(0) + expect(requests[0]?.body).toEqual({ + device_id: 'device1', + accepted_providers: ['august', 'schlage'], + }) +}) + +test('cli: rejects a value outside the documented enum', async () => { + requests = [] + const { stdout, stderr, exitCode } = await runCli( + ['devices', 'list', '--device-type', 'bogus'], + { cacheHome }, + ) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain( + '--device-type expects one of august_lock, schlage_lock', + ) + expect(requests).toHaveLength(0) +}) + +test('cli: rejects a value that is not the documented boolean', async () => { + requests = [] + const { stderr, exitCode } = await runCli( + ['devices', 'list', '--is-managed', 'maybe'], + { cacheHome }, + ) + + expect(exitCode).toBe(1) + expect(stderr).toContain('--is-managed expects true or false') + expect(requests).toHaveLength(0) +}) + test('cli: help and completion work without being logged in', async () => { const help = await runCli(['--help'], { stateHome: loggedOutStateHome }) expect(help.exitCode).toBe(0) diff --git a/test/fixtures/blueprint.ts b/test/fixtures/blueprint.ts index 802c6095..0439520e 100644 --- a/test/fixtures/blueprint.ts +++ b/test/fixtures/blueprint.ts @@ -36,6 +36,7 @@ export const testBlueprint = { }, ], }, + response: { responseType: 'resource_list', responseKey: 'devices' }, }, { path: '/devices/unmanaged/get', @@ -51,6 +52,36 @@ export const testBlueprint = { }, ], }, + response: { responseType: 'resource', responseKey: 'device' }, + }, + { + path: '/access_codes/create', + title: 'Create an Access Code', + description: 'Creates an access code on a device.', + request: { + parameters: [ + { + name: 'device_id', + description: 'ID of the device.', + format: 'id', + isRequired: true, + }, + { + name: 'code', + description: 'Code to program, e.g., with leading zeroes.', + format: 'string', + isRequired: false, + }, + { + name: 'accepted_providers', + description: 'Providers to accept.', + format: 'list', + itemFormat: 'string', + isRequired: false, + }, + ], + }, + response: { responseType: 'resource', responseKey: 'access_code' }, }, ], }, From fc508b637204dbefc5355c5f07d27d95e6f6cd34 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 03:10:37 +0000 Subject: [PATCH 12/20] refactor: Put the Seam SDK behind a one-method port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http/api.ts declares SeamApi — post params to a path, read back status and data — with createSeamApi() as the only place SeamHttp appears for raw requests, and createMemorySeamApi() as its in-memory test fake: a routes table plus a request capture, the in-process mirror of the e2e suite's HTTP server. requestSeamApi now takes its api and output as arguments, so the error-status-to-exit-code behavior is covered by a classical test with zero HTTP. Post-response follow-ups (connect webview open, action-attempt poll) move to http/follow-ups.ts. CliContext gains a lazy, per-run SeamApi accessor. UsageError and NonInteractiveError move to errors.ts alongside reportErrorAndExit, the top-level error-to-exit mapping formerly inlined in the entry's catch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- src/bin/cli.ts | 37 +++------ src/lib/args/parse.ts | 22 ------ src/lib/args/validate.ts | 8 +- src/lib/commands/api-command.ts | 54 +++---------- .../local/config-use-remote-api-defs.ts | 2 +- src/lib/commands/local/health.ts | 12 +-- src/lib/commands/local/login.ts | 2 +- src/lib/commands/local/select-server.ts | 2 +- src/lib/commands/local/select-workspace.ts | 2 +- src/lib/context.ts | 3 + src/lib/errors.ts | 67 ++++++++++++++++ src/lib/http/api.ts | 35 ++++++++ src/lib/http/create-memory-seam-api.ts | 28 +++++++ src/lib/http/follow-ups.ts | 42 ++++++++++ src/lib/http/request.test.ts | 79 +++++++++++++++++++ src/lib/http/request.ts | 33 ++++---- src/lib/interact/create-memory-prompt.ts | 2 +- src/lib/interact/interact-for-array.ts | 2 +- .../interact/interact-for-blueprint-object.ts | 3 +- .../interact-for-command-selection.ts | 9 +-- .../interact/interact-for-custom-metadata.ts | 8 +- src/lib/interact/prompt.ts | 9 +-- 22 files changed, 312 insertions(+), 149 deletions(-) create mode 100644 src/lib/errors.ts create mode 100644 src/lib/http/api.ts create mode 100644 src/lib/http/create-memory-seam-api.ts create mode 100644 src/lib/http/follow-ups.ts create mode 100644 src/lib/http/request.test.ts diff --git a/src/bin/cli.ts b/src/bin/cli.ts index ad96d9b2..c3c6bd6e 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -7,10 +7,8 @@ import type { ParsedArgs } from 'minimist' import { cliFlags, getInteractivity, - NonInteractiveError, parseCliArgs, toParameterName, - UsageError, } from 'lib/args/parse.js' import { assertKnownArgs } from 'lib/args/validate.js' import { getApiBlueprint } from 'lib/blueprint/index.js' @@ -23,9 +21,11 @@ import { } from 'lib/commands/registry.js' import { getConfigStore } from 'lib/config/index.js' import { type CliContext, resolveAuth } from 'lib/context.js' -import { EnvVarOverrideError, tokenEnvVar } from 'lib/env.js' +import { tokenEnvVar } from 'lib/env.js' +import { reportErrorAndExit } from 'lib/errors.js' +import { createSeamApi, type SeamApi } from 'lib/http/api.js' import { interactForCommandSelection } from 'lib/interact/interact-for-command-selection.js' -import { canPrompt, PromptCancelledError } from 'lib/interact/prompt.js' +import { canPrompt } from 'lib/interact/prompt.js' import { createOutput } from 'lib/output/create-output.js' import { getOutput, setOutput } from 'lib/output/get-output.js' import { readStdinJson } from 'lib/output/read-stdin-json.js' @@ -145,12 +145,16 @@ async function cli(args: ParsedArgs, argv: string[]) { // Params piped or redirected in, e.g., `seam devices list < params.json`. const stdinParams: Record = { ...(await readStdinJson()) } + const auth = resolveAuth(config) + let seamApi: Promise | null = null + const ctx: CliContext = { config, - auth: resolveAuth(config), + auth, output, blueprint, interactivity: getInteractivity(args, { canPrompt: canPrompt() }), + api: async () => await (seamApi ??= createSeamApi(auth)), } const selectableCommands = registry.spec.commands.map(({ path }) => path) @@ -220,26 +224,5 @@ const run = async (argv: string[]) => { } run(process.argv.slice(2)).catch((e: unknown) => { - const output = getOutput() - process.exitCode = 1 - - if (e instanceof UsageError) { - output.error(chalk.red(e.message)) - if (e.hint !== '') output.error(e.hint) - return - } - - if (e instanceof NonInteractiveError || e instanceof EnvVarOverrideError) { - output.error(chalk.red(e.message)) - return - } - - if (e instanceof PromptCancelledError) { - output.error(chalk.gray(e.message)) - return - } - - const error = e instanceof Error ? e : new Error(String(e)) - output.error(chalk.red(`CLI Error: ${error.message}`)) - if (error.stack != null) output.error(chalk.gray(error.stack)) + reportErrorAndExit(e, getOutput()) }) diff --git a/src/lib/args/parse.ts b/src/lib/args/parse.ts index 7d726445..5fc85dfa 100644 --- a/src/lib/args/parse.ts +++ b/src/lib/args/parse.ts @@ -37,28 +37,6 @@ export const cliFlags: string[] = [ 'version', ] -/** - * Thrown when the CLI needs input it cannot prompt for. - */ -export class NonInteractiveError extends Error { - override name = 'NonInteractiveError' -} - -/** - * Thrown when the arguments do not name something the CLI can run. - */ -export class UsageError extends Error { - override name = 'UsageError' - - /** What to run instead, reported after the message. */ - readonly hint: string - - constructor(message: string, { hint = '' }: { hint?: string } = {}) { - super(message) - this.hint = hint - } -} - export interface ParseCliArgsOptions { /** * Argument keys read exactly as given rather than by guessing at a type, diff --git a/src/lib/args/validate.ts b/src/lib/args/validate.ts index 9c3f6e74..2ba8689b 100644 --- a/src/lib/args/validate.ts +++ b/src/lib/args/validate.ts @@ -1,11 +1,7 @@ import type { Parameter } from '@seamapi/blueprint' -import { - NonInteractiveError, - toArgName, - toGivenArgName, - UsageError, -} from './parse.js' +import { NonInteractiveError, UsageError } from '../errors.js' +import { toArgName, toGivenArgName } from './parse.js' /** * Report every required parameter still missing from the params, rather diff --git a/src/lib/commands/api-command.ts b/src/lib/commands/api-command.ts index f95dad60..fa43a353 100644 --- a/src/lib/commands/api-command.ts +++ b/src/lib/commands/api-command.ts @@ -1,24 +1,17 @@ import { isDeepStrictEqual as isEqual } from 'node:util' import { coerceArgParams } from '../args/coerce.js' -import { - type Interactivity, - parseCliArgs, - toArgName, - toArgParams, - UsageError, -} from '../args/parse.js' +import { parseCliArgs, toArgName, toArgParams } from '../args/parse.js' import { assertRequiredParams } from '../args/validate.js' import { getCommandBlueprintDef, getResponseKey, } from '../blueprint/endpoint.js' import type { CliContext } from '../context.js' -import { isInsideWebBrowser } from '../env.js' -import { RequestSeamApi } from '../http/request.js' -import { interactForActionAttemptPoll } from '../interact/interact-for-action-attempt-poll.js' +import { UsageError } from '../errors.js' +import { runResponseFollowUps } from '../http/follow-ups.js' +import { requestSeamApi } from '../http/request.js' import { interactForCommandParams } from '../interact/interact-for-command-params.js' -import { promptConfirm } from '../interact/prompt.js' import type { CommandResult, Invocation } from './registry.js' /** @@ -87,22 +80,12 @@ export const executeApiCommand = async ( delete params['since'] } - const response = await RequestSeamApi({ - path: apiPath, - params, - responseKey: getResponseKey(path, ctx), - }) - - if (response.data?.connect_webview) { - await handleConnectWebviewResponse( - response.data.connect_webview, - ctx.interactivity, - ) - } + const response = await requestSeamApi( + { path: apiPath, params, responseKey: getResponseKey(path, ctx) }, + { api: await ctx.api(), output: ctx.output }, + ) - if (response.data?.action_attempt && !isNonInteractive) { - await interactForActionAttemptPoll(response.data.action_attempt) - } + await runResponseFollowUps(response.data, ctx) return { kind: 'done' } } @@ -122,22 +105,3 @@ const applyEndpointDefaults = ( params['since'] = date.toISOString() } } - -const handleConnectWebviewResponse = async ( - connectWebview: any, - interactivity: Interactivity, -) => { - const url = connectWebview.url - - if (interactivity !== 'non-interactive' && !isInsideWebBrowser()) { - const action = await promptConfirm({ - message: 'Would you like to open the webview in your browser?', - initialValue: false, - }) - - if (action) { - const { default: open } = await import('open') - await open(url) - } - } -} diff --git a/src/lib/commands/local/config-use-remote-api-defs.ts b/src/lib/commands/local/config-use-remote-api-defs.ts index 25727e0b..e81d98a7 100644 --- a/src/lib/commands/local/config-use-remote-api-defs.ts +++ b/src/lib/commands/local/config-use-remote-api-defs.ts @@ -1,4 +1,4 @@ -import { NonInteractiveError } from '../../args/parse.js' +import { NonInteractiveError } from '../../errors.js' import { interactForUseRemoteApiDefs } from '../../interact/interact-for-use-remote-api-defs.js' import type { Command } from '../registry.js' diff --git a/src/lib/commands/local/health.ts b/src/lib/commands/local/health.ts index 556d2ca6..eb1e38c5 100644 --- a/src/lib/commands/local/health.ts +++ b/src/lib/commands/local/health.ts @@ -1,4 +1,4 @@ -import { RequestSeamApi } from '../../http/request.js' +import { requestSeamApi } from '../../http/request.js' import type { Command } from '../registry.js' export const healthCommand: Command = { @@ -11,11 +11,11 @@ export const healthCommand: Command = { flags: [], }, requiresAuth: true, - execute: async () => { - await RequestSeamApi({ - path: '/health/get_health', - params: {}, - }) + execute: async (_invocation, ctx) => { + await requestSeamApi( + { path: '/health/get_health', params: {} }, + { api: await ctx.api(), output: ctx.output }, + ) return { kind: 'done' } }, } diff --git a/src/lib/commands/local/login.ts b/src/lib/commands/local/login.ts index 171a7ea5..83ac5fb9 100644 --- a/src/lib/commands/local/login.ts +++ b/src/lib/commands/local/login.ts @@ -1,5 +1,5 @@ -import { NonInteractiveError } from '../../args/parse.js' import { assertMutable, login } from '../../auth/operations.js' +import { NonInteractiveError } from '../../errors.js' import { interactForLogin } from '../../interact/interact-for-login.js' import type { Command } from '../registry.js' import { stringFlag } from '../spec.js' diff --git a/src/lib/commands/local/select-server.ts b/src/lib/commands/local/select-server.ts index 562bffc3..8be34245 100644 --- a/src/lib/commands/local/select-server.ts +++ b/src/lib/commands/local/select-server.ts @@ -1,5 +1,5 @@ -import { NonInteractiveError } from '../../args/parse.js' import { assertMutable, selectServer } from '../../auth/operations.js' +import { NonInteractiveError } from '../../errors.js' import { interactForServerSelection } from '../../interact/interact-for-server-selection.js' import type { Command } from '../registry.js' import { stringFlag } from '../spec.js' diff --git a/src/lib/commands/local/select-workspace.ts b/src/lib/commands/local/select-workspace.ts index b8fdff4a..b9db611d 100644 --- a/src/lib/commands/local/select-workspace.ts +++ b/src/lib/commands/local/select-workspace.ts @@ -1,5 +1,5 @@ -import { NonInteractiveError } from '../../args/parse.js' import { assertMutable } from '../../auth/operations.js' +import { NonInteractiveError } from '../../errors.js' import { interactForWorkspaceId } from '../../interact/interact-for-workspace-id.js' import type { Command } from '../registry.js' diff --git a/src/lib/context.ts b/src/lib/context.ts index 19c668fe..fd68ce20 100644 --- a/src/lib/context.ts +++ b/src/lib/context.ts @@ -6,6 +6,7 @@ import { getTokenFromEnv, getWorkspaceIdFromEnv, } from './env.js' +import type { SeamApi } from './http/api.js' import type { Output } from './output/create-output.js' export const defaultServer = 'https://connect.getseam.com' @@ -70,6 +71,8 @@ export interface CliContext { output: Output blueprint: ApiBlueprint interactivity: Interactivity + /** The Seam API, constructed on first use and shared for the run. */ + api: () => Promise } const readString = (value: unknown): string | null => { diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 00000000..54bc22d5 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,67 @@ +import chalk from 'chalk' + +import { EnvVarOverrideError } from './env.js' +import type { Output } from './output/create-output.js' + +/** + * Thrown when the CLI needs input it cannot prompt for. + */ +export class NonInteractiveError extends Error { + override name = 'NonInteractiveError' +} + +/** + * Thrown when the user dismisses a prompt with ctrl-c or escape instead of + * answering it. + */ +export class PromptCancelledError extends Error { + constructor() { + super('Cancelled') + } +} + +/** + * Thrown when the arguments do not name something the CLI can run. + */ +export class UsageError extends Error { + override name = 'UsageError' + + /** What to run instead, reported after the message. */ + readonly hint: string + + constructor(message: string, { hint = '' }: { hint?: string } = {}) { + super(message) + this.hint = hint + } +} + +/** + * Report a failure and set the exit code: usage mistakes read as one line + * with a hint, environment overrides without a stack trace, and anything + * else as an unexpected CLI error. + */ +export const reportErrorAndExit = (e: unknown, output: Output): void => { + process.exitCode = 1 + + if (e instanceof UsageError) { + output.error(chalk.red(e.message)) + if (e.hint !== '') output.error(e.hint) + return + } + + if (e instanceof NonInteractiveError || e instanceof EnvVarOverrideError) { + output.error(chalk.red(e.message)) + return + } + + // Dismissing a prompt is the user stopping the CLI, not the CLI failing: + // note it quietly, without the alarm of an error. + if (e instanceof PromptCancelledError) { + output.error(chalk.gray(e.message)) + return + } + + const error = e instanceof Error ? e : new Error(String(e)) + output.error(chalk.red(`CLI Error: ${error.message}`)) + if (error.stack != null) output.error(chalk.gray(error.stack)) +} diff --git a/src/lib/http/api.ts b/src/lib/http/api.ts new file mode 100644 index 00000000..56007a60 --- /dev/null +++ b/src/lib/http/api.ts @@ -0,0 +1,35 @@ +import type { AuthContext } from '../context.js' +import { getSeam } from './client.js' + +export interface SeamApiResponse { + status: number + data: unknown +} + +/** + * The one method the blueprint-driven CLI needs from the Seam API: post + * params to an endpoint path and read back the status and body. + * + * Tests fake at this port with `createMemorySeamApi()` — the in-process + * mirror of the e2e suite's HTTP server — so nothing in-process ever + * imitates the SDK's own surface. + */ +export interface SeamApi { + post: ( + path: string, + params: Record, + ) => Promise +} + +/** The only place `SeamHttp` appears for raw requests. */ +export const createSeamApi = async (auth?: AuthContext): Promise => { + const seam = await getSeam(auth) + return { + post: async (path, params) => { + const { status, data } = await seam.client.post(path, params, { + validateStatus: () => true, + }) + return { status, data } + }, + } +} diff --git a/src/lib/http/create-memory-seam-api.ts b/src/lib/http/create-memory-seam-api.ts new file mode 100644 index 00000000..6f7106db --- /dev/null +++ b/src/lib/http/create-memory-seam-api.ts @@ -0,0 +1,28 @@ +import type { SeamApi, SeamApiResponse } from './api.js' + +export interface MemorySeamApi { + api: SeamApi + /** Every request made, in order — assert on the outbound messages. */ + requests: Array<{ path: string; params: Record }> +} + +/** + * A real {@link SeamApi} answering from a routes table and recording every + * request, for tests: the in-process mirror of the e2e suite's HTTP server. + */ +export const createMemorySeamApi = ( + routes: Record, +): MemorySeamApi => { + const requests: Array<{ path: string; params: Record }> = [] + + const api: SeamApi = { + post: async (path, params) => { + requests.push({ path, params }) + return ( + routes[path] ?? { status: 404, data: { error: { type: 'not_found' } } } + ) + }, + } + + return { api, requests } +} diff --git a/src/lib/http/follow-ups.ts b/src/lib/http/follow-ups.ts new file mode 100644 index 00000000..3428329a --- /dev/null +++ b/src/lib/http/follow-ups.ts @@ -0,0 +1,42 @@ +import type { CliContext } from '../context.js' +import { isInsideWebBrowser } from '../env.js' +import { interactForActionAttemptPoll } from '../interact/interact-for-action-attempt-poll.js' +import { promptConfirm } from '../interact/prompt.js' + +/** + * Follow-ups a response may call for: opening a connect webview in the + * browser, and offering to poll a pending action attempt. + */ +export const runResponseFollowUps = async ( + data: any, + ctx: CliContext, +): Promise => { + const isNonInteractive = ctx.interactivity === 'non-interactive' + + if (data?.connect_webview) { + await handleConnectWebview(data.connect_webview, isNonInteractive) + } + + if (data?.action_attempt && !isNonInteractive) { + await interactForActionAttemptPoll(data.action_attempt) + } +} + +const handleConnectWebview = async ( + connectWebview: any, + isNonInteractive: boolean, +): Promise => { + const url = connectWebview.url + + if (!isNonInteractive && !isInsideWebBrowser()) { + const action = await promptConfirm({ + message: 'Would you like to open the webview in your browser?', + initialValue: false, + }) + + if (action) { + const { default: open } = await import('open') + await open(url) + } + } +} diff --git a/src/lib/http/request.test.ts b/src/lib/http/request.test.ts new file mode 100644 index 00000000..ac14c7a0 --- /dev/null +++ b/src/lib/http/request.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, expect, test } from 'vitest' + +import { createMemoryOutput } from '../output/create-memory-output.js' +import { createMemorySeamApi } from './create-memory-seam-api.js' +import { requestSeamApi } from './request.js' + +let exitCodeBefore: number | string | undefined + +beforeEach(() => { + exitCodeBefore = process.exitCode ?? undefined +}) + +afterEach(() => { + process.exitCode = exitCodeBefore +}) + +test('requestSeamApi: sends the params and reports the trimmed payload', async () => { + const { api, requests } = createMemorySeamApi({ + '/devices/list': { + status: 200, + data: { + devices: [{ device_id: 'device1' }], + pagination: { has_next_page: false }, + ok: true, + }, + }, + }) + const memory = createMemoryOutput({ format: 'json' }) + + const response = await requestSeamApi( + { path: '/devices/list', params: { limit: 5 }, responseKey: 'devices' }, + { api, output: memory.output }, + ) + + // Boundary interaction: the outbound message IS the behavior. + expect(requests).toEqual([{ path: '/devices/list', params: { limit: 5 } }]) + expect(response.status).toBe(200) + expect(JSON.parse(memory.stdout())).toEqual({ + devices: [{ device_id: 'device1' }], + pagination: { has_next_page: false }, + }) + expect(process.exitCode).toBe(exitCodeBefore) +}) + +test('requestSeamApi: reports an error payload and sets the exit code', async () => { + const { api, requests } = createMemorySeamApi({ + '/devices/list': { + status: 400, + data: { error: { type: 'invalid_input' }, ok: false }, + }, + }) + const memory = createMemoryOutput({ format: 'json' }) + + await requestSeamApi( + { path: '/devices/list', params: { limit: 5 } }, + { api, output: memory.output }, + ) + + expect(requests).toEqual([{ path: '/devices/list', params: { limit: 5 } }]) + expect(memory.stdout()).toContain('invalid_input') + expect(memory.stderr()).toContain('[400]') + expect(process.exitCode).toBe(1) +}) + +test('requestSeamApi: keeps the request banner out of stdout', async () => { + const { api } = createMemorySeamApi({ + '/devices/list': { status: 200, data: { devices: [], ok: true } }, + }) + const memory = createMemoryOutput({ format: 'text' }) + + await requestSeamApi( + { path: '/devices/list', params: {}, responseKey: 'devices' }, + { api, output: memory.output }, + ) + + expect(memory.stderr()).toContain('/devices/list') + expect(memory.stderr()).toContain('Request Params:') + expect(memory.stdout()).not.toContain('Request Params:') +}) diff --git a/src/lib/http/request.ts b/src/lib/http/request.ts index 7d9497b1..2bda3fe3 100644 --- a/src/lib/http/request.ts +++ b/src/lib/http/request.ts @@ -1,10 +1,9 @@ import chalk from 'chalk' -import { getSeam } from 'lib/http/client.js' -import { getOutput } from 'lib/output/get-output.js' -import { selectResponsePayload } from 'lib/output/select-response-payload.js' - +import type { Output } from '../output/create-output.js' +import { selectResponsePayload } from '../output/select-response-payload.js' import { withLoading } from '../output/with-loading.js' +import type { SeamApi, SeamApiResponse } from './api.js' export interface RequestSeamApiOptions { path: string @@ -13,22 +12,26 @@ export interface RequestSeamApiOptions { responseKey?: string | null | undefined } -export const RequestSeamApi = async ({ - path, - params, - responseKey, -}: RequestSeamApiOptions) => { - const seam = await getSeam() - const output = getOutput() +export interface RequestSeamApiDependencies { + api: SeamApi + output: Output +} +/** + * Make a request and report the result: the request banner and status go to + * stderr, the trimmed payload to stdout, and an error status sets the exit + * code. The transport itself is behind the injected {@link SeamApi}. + */ +export const requestSeamApi = async ( + { path, params, responseKey }: RequestSeamApiOptions, + { api, output }: RequestSeamApiDependencies, +): Promise => { output.info(`\n${chalk.green(path)}`) output.info(`Request Params:`) output.info(formatParams(params)) - const response = await withLoading('Making request...', () => - seam.client.post(path, params, { - validateStatus: () => true, - }), + const response = await withLoading('Making request...', async () => + api.post(path, params), ) if (response.status >= 400) { diff --git a/src/lib/interact/create-memory-prompt.ts b/src/lib/interact/create-memory-prompt.ts index 78328dd9..c314a25e 100644 --- a/src/lib/interact/create-memory-prompt.ts +++ b/src/lib/interact/create-memory-prompt.ts @@ -1,5 +1,5 @@ +import { PromptCancelledError } from '../errors.js' import { - PromptCancelledError, type PromptChoice, type PromptClient, type PromptSelectOptions, diff --git a/src/lib/interact/interact-for-array.ts b/src/lib/interact/interact-for-array.ts index 4c8ae342..2acac41b 100644 --- a/src/lib/interact/interact-for-array.ts +++ b/src/lib/interact/interact-for-array.ts @@ -1,6 +1,6 @@ import { getOutput } from '../output/get-output.js' +import { PromptCancelledError } from '../errors.js' import { - PromptCancelledError, promptNumber, promptSelect, promptText, diff --git a/src/lib/interact/interact-for-blueprint-object.ts b/src/lib/interact/interact-for-blueprint-object.ts index 9480b4ce..39093df6 100644 --- a/src/lib/interact/interact-for-blueprint-object.ts +++ b/src/lib/interact/interact-for-blueprint-object.ts @@ -1,8 +1,8 @@ import type { Parameter } from '@seamapi/blueprint' -import { NonInteractiveError } from '../args/parse.js' import { assertRequiredParams } from '../args/validate.js' import type { CliContext } from '../context.js' +import { NonInteractiveError, PromptCancelledError } from '../errors.js' import { getOutput } from '../output/get-output.js' import { ellipsis } from '../render/text.js' import { interactForAccessCode } from './interact-for-access-code.js' @@ -18,7 +18,6 @@ import { interactForUserIdentity } from './interact-for-user-identity.js' import { promptAutocomplete, promptAutocompleteMultiselect, - PromptCancelledError, promptConfirm, promptNumber, promptSelect, diff --git a/src/lib/interact/interact-for-command-selection.ts b/src/lib/interact/interact-for-command-selection.ts index 89be9256..e068a910 100644 --- a/src/lib/interact/interact-for-command-selection.ts +++ b/src/lib/interact/interact-for-command-selection.ts @@ -1,11 +1,8 @@ import { isDeepStrictEqual as isEqual } from 'node:util' -import { type Interactivity, NonInteractiveError } from '../args/parse.js' -import { - promptAutocomplete, - PromptCancelledError, - withBackHint, -} from './prompt.js' +import type { Interactivity } from '../args/parse.js' +import { NonInteractiveError, PromptCancelledError } from '../errors.js' +import { promptAutocomplete, withBackHint } from './prompt.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() diff --git a/src/lib/interact/interact-for-custom-metadata.ts b/src/lib/interact/interact-for-custom-metadata.ts index cb34ebf7..bf62b6ed 100644 --- a/src/lib/interact/interact-for-custom-metadata.ts +++ b/src/lib/interact/interact-for-custom-metadata.ts @@ -1,10 +1,6 @@ import { getOutput } from '../output/get-output.js' -import { - PromptCancelledError, - promptSelect, - promptText, - withBackHint, -} from './prompt.js' +import { PromptCancelledError } from '../errors.js' +import { promptSelect, promptText, withBackHint } from './prompt.js' // Structurally the CustomMetadata of @seamapi/types, spelled out here so the // published declarations do not depend on a development-only package. diff --git a/src/lib/interact/prompt.ts b/src/lib/interact/prompt.ts index f07d1708..c6b5bd4d 100644 --- a/src/lib/interact/prompt.ts +++ b/src/lib/interact/prompt.ts @@ -12,14 +12,7 @@ import { } from '@clack/prompts' import chalk from 'chalk' -import { NonInteractiveError } from '../args/parse.js' - -/** The user dismissed a prompt with ctrl-c or escape instead of answering. */ -export class PromptCancelledError extends Error { - constructor() { - super('Cancelled') - } -} +import { NonInteractiveError, PromptCancelledError } from '../errors.js' export interface PromptChoice { label: string From 84dd8d90b794d5105c0ca30484abe851d71520c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 03:55:17 +0000 Subject: [PATCH 13/20] test: Separate module tests from fixture-driven tests Fixtures live only in test/fixtures, and a test that consumes one is not a unit test: the blueprint-driven suites (commands/spec, commands/registry, render/help, render/completion) move under test/, mirroring the source layout, along with every test that reaches into another module for its fakes (context, auth/operations, http/request, interact-for-blueprint-object). A test now sits beside its module in src only when it tests the module of the same name and imports nothing beyond it, external packages and type-only imports excepted. TESTING.md records the rule. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- TESTING.md | 14 ++++++++++++++ {src/lib => test}/auth/operations.test.ts | 6 +++--- {src/lib => test}/commands/registry.test.ts | 5 +++-- {src/lib => test}/commands/spec.test.ts | 7 ++++--- {src/lib => test}/context.test.ts | 6 +++--- {src/lib => test}/http/request.test.ts | 6 +++--- .../interact/interact-for-blueprint-object.test.ts | 12 ++++++------ .../interact-for-command-selection.test.ts | 6 +++--- .../interact/interact-for-custom-metadata.test.ts | 10 +++++----- .../completion => test/render}/completion.test.ts | 9 +++++---- {src/lib => test}/render/help.test.ts | 7 ++++--- 11 files changed, 53 insertions(+), 35 deletions(-) rename {src/lib => test}/auth/operations.test.ts (98%) rename {src/lib => test}/commands/registry.test.ts (96%) rename {src/lib => test}/commands/spec.test.ts (96%) rename {src/lib => test}/context.test.ts (97%) rename {src/lib => test}/http/request.test.ts (92%) rename {src/lib => test}/interact/interact-for-blueprint-object.test.ts (95%) rename {src/lib => test}/interact/interact-for-command-selection.test.ts (92%) rename {src/lib => test}/interact/interact-for-custom-metadata.test.ts (82%) rename {src/lib/render/completion => test/render}/completion.test.ts (94%) rename {src/lib => test}/render/help.test.ts (95%) diff --git a/TESTING.md b/TESTING.md index 1b1eba54..ac6a6544 100644 --- a/TESTING.md +++ b/TESTING.md @@ -26,6 +26,20 @@ the fake goes. everywhere.** Don't re-prove auth headers in a unit test, and don't push branching logic into `test/cli.test.ts`. +## Where tests live + +- **Test fixtures live in `test/fixtures` and nowhere else.** Anything that + exists for a test — a hand-built blueprint, seeded config files — never + sits beside normal code. +- **A test that uses such a fixture is not a unit test.** It goes under + `test/`, mirroring the source layout (`test/commands/registry.test.ts` + tests `src/lib/commands/registry.ts`). +- **A test may sit beside its module in `src` only when it tests the module + of the same name and imports nothing beyond it** — external packages and + type-only imports excepted. The moment it needs another module's code (a + memory fake from elsewhere, a sibling's helpers, a fixture), it moves + under `test/`. + ## Taxonomy | Module kind | The tell | Default test | Gets faked | Never faked | diff --git a/src/lib/auth/operations.test.ts b/test/auth/operations.test.ts similarity index 98% rename from src/lib/auth/operations.test.ts rename to test/auth/operations.test.ts index d0d56967..0a1ece05 100644 --- a/src/lib/auth/operations.test.ts +++ b/test/auth/operations.test.ts @@ -1,7 +1,5 @@ import { afterEach, beforeEach, expect, test } from 'vitest' -import { createMemoryConfigStore } from '../config/create-memory-config-store.js' -import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from '../env.js' import { login, logout, @@ -9,7 +7,9 @@ import { selectServer, selectWorkspace, storeToken, -} from './operations.js' +} from 'lib/auth/operations.js' +import { createMemoryConfigStore } from 'lib/config/create-memory-config-store.js' +import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from 'lib/env.js' const server = 'https://connect.example.com' diff --git a/src/lib/commands/registry.test.ts b/test/commands/registry.test.ts similarity index 96% rename from src/lib/commands/registry.test.ts rename to test/commands/registry.test.ts index 31e1019b..f2db4b92 100644 --- a/src/lib/commands/registry.test.ts +++ b/test/commands/registry.test.ts @@ -1,12 +1,13 @@ import { expect, test } from 'vitest' -import { testBlueprint } from '../../../test/fixtures/blueprint.js' import { acceptedParamsOf, buildRegistry, findLocalCommand, localCommands, -} from './registry.js' +} from 'lib/commands/registry.js' + +import { testBlueprint } from '../fixtures/blueprint.js' const registry = buildRegistry(testBlueprint) diff --git a/src/lib/commands/spec.test.ts b/test/commands/spec.test.ts similarity index 96% rename from src/lib/commands/spec.test.ts rename to test/commands/spec.test.ts index 66abf19b..97998a8b 100644 --- a/src/lib/commands/spec.test.ts +++ b/test/commands/spec.test.ts @@ -1,8 +1,9 @@ import { expect, test } from 'vitest' -import { testBlueprint } from '../../../test/fixtures/blueprint.js' -import { localCommandDefinitions } from './registry.js' -import { findCommand, findGroup, getCommandSpec } from './spec.js' +import { localCommandDefinitions } from 'lib/commands/registry.js' +import { findCommand, findGroup, getCommandSpec } from 'lib/commands/spec.js' + +import { testBlueprint } from '../fixtures/blueprint.js' const spec = getCommandSpec(testBlueprint, localCommandDefinitions) diff --git a/src/lib/context.test.ts b/test/context.test.ts similarity index 97% rename from src/lib/context.test.ts rename to test/context.test.ts index fb5fafcd..2d6673ff 100644 --- a/src/lib/context.test.ts +++ b/test/context.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, expect, test } from 'vitest' -import { createMemoryConfigStore } from './config/create-memory-config-store.js' -import { resolveAuth } from './context.js' -import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from './env.js' +import { createMemoryConfigStore } from 'lib/config/create-memory-config-store.js' +import { resolveAuth } from 'lib/context.js' +import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from 'lib/env.js' const server = 'https://connect.example.com' diff --git a/src/lib/http/request.test.ts b/test/http/request.test.ts similarity index 92% rename from src/lib/http/request.test.ts rename to test/http/request.test.ts index ac14c7a0..7416d411 100644 --- a/src/lib/http/request.test.ts +++ b/test/http/request.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, expect, test } from 'vitest' -import { createMemoryOutput } from '../output/create-memory-output.js' -import { createMemorySeamApi } from './create-memory-seam-api.js' -import { requestSeamApi } from './request.js' +import { createMemorySeamApi } from 'lib/http/create-memory-seam-api.js' +import { requestSeamApi } from 'lib/http/request.js' +import { createMemoryOutput } from 'lib/output/create-memory-output.js' let exitCodeBefore: number | string | undefined diff --git a/src/lib/interact/interact-for-blueprint-object.test.ts b/test/interact/interact-for-blueprint-object.test.ts similarity index 95% rename from src/lib/interact/interact-for-blueprint-object.test.ts rename to test/interact/interact-for-blueprint-object.test.ts index cc5c9ee3..6da4df3d 100644 --- a/src/lib/interact/interact-for-blueprint-object.test.ts +++ b/test/interact/interact-for-blueprint-object.test.ts @@ -1,16 +1,16 @@ import type { Parameter } from '@seamapi/blueprint' import { afterEach, beforeEach, expect, test } from 'vitest' -import type { CliContext } from '../context.js' -import { createMemoryOutput } from '../output/create-memory-output.js' -import { setOutput } from '../output/get-output.js' +import type { CliContext } from 'lib/context.js' +import { createMemoryOutput } from 'lib/output/create-memory-output.js' +import { setOutput } from 'lib/output/get-output.js' import { cancelPrompt, createMemoryPrompt, type MemoryPrompt, -} from './create-memory-prompt.js' -import { interactForBlueprintObject } from './interact-for-blueprint-object.js' -import { resetPromptClient, setPromptClient, withBackHint } from './prompt.js' +} from 'lib/interact/create-memory-prompt.js' +import { interactForBlueprintObject } from 'lib/interact/interact-for-blueprint-object.js' +import { resetPromptClient, setPromptClient, withBackHint } from 'lib/interact/prompt.js' let memoryPrompt: MemoryPrompt diff --git a/src/lib/interact/interact-for-command-selection.test.ts b/test/interact/interact-for-command-selection.test.ts similarity index 92% rename from src/lib/interact/interact-for-command-selection.test.ts rename to test/interact/interact-for-command-selection.test.ts index 7c7edeba..4184a747 100644 --- a/src/lib/interact/interact-for-command-selection.test.ts +++ b/test/interact/interact-for-command-selection.test.ts @@ -1,8 +1,8 @@ import { afterEach, expect, test } from 'vitest' -import { createMemoryPrompt } from './create-memory-prompt.js' -import { interactForCommandSelection } from './interact-for-command-selection.js' -import { resetPromptClient, setPromptClient, withBackHint } from './prompt.js' +import { createMemoryPrompt } from 'lib/interact/create-memory-prompt.js' +import { interactForCommandSelection } from 'lib/interact/interact-for-command-selection.js' +import { resetPromptClient, setPromptClient, withBackHint } from 'lib/interact/prompt.js' afterEach(resetPromptClient) diff --git a/src/lib/interact/interact-for-custom-metadata.test.ts b/test/interact/interact-for-custom-metadata.test.ts similarity index 82% rename from src/lib/interact/interact-for-custom-metadata.test.ts rename to test/interact/interact-for-custom-metadata.test.ts index 2a714a99..725f5ad4 100644 --- a/src/lib/interact/interact-for-custom-metadata.test.ts +++ b/test/interact/interact-for-custom-metadata.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, expect, test } from 'vitest' -import { createMemoryOutput } from '../output/create-memory-output.js' -import { setOutput } from '../output/get-output.js' -import { createMemoryPrompt } from './create-memory-prompt.js' -import { interactForCustomMetadata } from './interact-for-custom-metadata.js' -import { resetPromptClient, setPromptClient } from './prompt.js' +import { createMemoryOutput } from 'lib/output/create-memory-output.js' +import { setOutput } from 'lib/output/get-output.js' +import { createMemoryPrompt } from 'lib/interact/create-memory-prompt.js' +import { interactForCustomMetadata } from 'lib/interact/interact-for-custom-metadata.js' +import { resetPromptClient, setPromptClient } from 'lib/interact/prompt.js' /** Scripts an answer for each ask, in the order the editor asks. */ const scriptPrompt = (script: unknown[]): void => { diff --git a/src/lib/render/completion/completion.test.ts b/test/render/completion.test.ts similarity index 94% rename from src/lib/render/completion/completion.test.ts rename to test/render/completion.test.ts index 69c6d696..6ab1fc0c 100644 --- a/src/lib/render/completion/completion.test.ts +++ b/test/render/completion.test.ts @@ -1,15 +1,16 @@ import { expect, test } from 'vitest' -import { testBlueprint } from '../../../../test/fixtures/blueprint.js' -import { buildRegistry } from '../../commands/registry.js' -import { describeForShell } from './describe.js' +import { buildRegistry } from 'lib/commands/registry.js' +import { describeForShell } from 'lib/render/completion/describe.js' import { completionScriptSentinels, completionShells, isCompletionShell, renderCompletion, renderCompletionStub, -} from './index.js' +} from 'lib/render/completion/index.js' + +import { testBlueprint } from '../fixtures/blueprint.js' const { spec } = buildRegistry(testBlueprint) diff --git a/src/lib/render/help.test.ts b/test/render/help.test.ts similarity index 95% rename from src/lib/render/help.test.ts rename to test/render/help.test.ts index 3010fac3..90f13c9c 100644 --- a/src/lib/render/help.test.ts +++ b/test/render/help.test.ts @@ -1,8 +1,9 @@ import { expect, test } from 'vitest' -import { testBlueprint } from '../../../test/fixtures/blueprint.js' -import { buildRegistry } from '../commands/registry.js' -import { renderHelp } from '../render/help.js' +import { buildRegistry } from 'lib/commands/registry.js' +import { renderHelp } from 'lib/render/help.js' + +import { testBlueprint } from '../fixtures/blueprint.js' const { spec } = buildRegistry(testBlueprint) From 859f04a764e25809b4cad9108907ddaae6b609a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 03:59:50 +0000 Subject: [PATCH 14/20] refactor: Fulfill injected interfaces with classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where an interface has more than one implementation — the real edge and its memory fake — each implementation is now a class, matching the existing SeamConfigStore: SeamHttpApi and MemorySeamApi behind SeamApi, TerminalPromptClient and MemoryPromptClient behind PromptClient, MemoryConfigStore behind ConfigStore, and StreamOutput as the one stream-parameterized Output. The createFoo factories remain as the convenient constructors; call sites are unchanged apart from the memory fakes now being the capture themselves (api.requests, memoryPrompt.questions). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- TESTING.md | 60 +++++++----- src/lib/config/create-memory-config-store.ts | 88 ++++++++++------- src/lib/http/api.ts | 25 +++-- src/lib/http/create-memory-seam-api.ts | 40 ++++---- src/lib/interact/create-memory-prompt.ts | 96 +++++++++++-------- src/lib/interact/prompt.ts | 36 +++---- src/lib/output/create-output.ts | 79 ++++++++------- test/http/request.test.ts | 14 ++- .../interact-for-blueprint-object.test.ts | 8 +- .../interact-for-command-selection.test.ts | 4 +- .../interact-for-custom-metadata.test.ts | 2 +- 11 files changed, 262 insertions(+), 190 deletions(-) diff --git a/TESTING.md b/TESTING.md index ac6a6544..a750e677 100644 --- a/TESTING.md +++ b/TESTING.md @@ -21,7 +21,11 @@ the fake goes. capture you assert on. Config (`createMemoryConfigStore()` + `setConfigStore()`), the prompt layer (`createMemoryPrompt()` + `setPromptClient()`), and the Seam API get the same treatment; nothing else - needs it. + needs it. An interface with more than one implementation — the real edge + and its memory fake — is fulfilled by **classes** (`SeamHttpApi` / + `MemorySeamApi`, `TerminalPromptClient` / `MemoryPromptClient`, + `SeamConfigStore` / `MemoryConfigStore`), with `createFoo` factories kept + as the convenient way to construct them. 5. **The e2e suite proves wiring once; module tests prove behavior everywhere.** Don't re-prove auth headers in a unit test, and don't push branching logic into `test/cli.test.ts`. @@ -114,17 +118,19 @@ export interface SeamApi { ) => Promise } -export const createSeamApi = async (): Promise => { - const seam = await getSeam() // the only place SeamHttp appears - return { - post: async (path, params) => { - const { status, data } = await seam.client.post(path, params, { - validateStatus: () => true, - }) - return { status, data } - }, +export class SeamHttpApi implements SeamApi { + constructor(private readonly seam: SeamHttp) {} // the only place SeamHttp appears + + post = async (path: string, params: Record) => { + const { status, data } = await this.seam.client.post(path, params, { + validateStatus: () => true, + }) + return { status, data } } } + +export const createSeamApi = async (): Promise => + new SeamHttpApi(await getSeam()) ``` The fake is the in-process mirror of the e2e server — a routes table plus a @@ -132,20 +138,26 @@ capture: ```ts // src/lib/http/create-memory-seam-api.ts -export const createMemorySeamApi = ( - routes: Record, -) => { - const requests: Array<{ path: string; params: Record }> = [] - const api: SeamApi = { - post: async (path, params) => { - requests.push({ path, params }) - return ( - routes[path] ?? { status: 404, data: { error: { type: 'not_found' } } } - ) - }, +export class MemorySeamApi implements SeamApi { + readonly requests: Array<{ path: string; params: Record }> = + [] + + constructor(private readonly routes: Record) {} + + post = async (path: string, params: Record) => { + this.requests.push({ path, params }) + return ( + this.routes[path] ?? { + status: 404, + data: { error: { type: 'not_found' } }, + } + ) } - return { api, requests } } + +export const createMemorySeamApi = ( + routes: Record, +): MemorySeamApi => new MemorySeamApi(routes) ``` This split also separates transport from presentation in `http/request.ts` @@ -153,7 +165,7 @@ This split also separates transport from presentation in `http/request.ts` error-status → exit-code behavior becomes a classical test with zero HTTP: ```ts -const { api, requests } = createMemorySeamApi({ +const api = createMemorySeamApi({ '/devices/list': { status: 400, data: { error: { type: 'invalid_input' } } }, }) const memory = createMemoryOutput() @@ -164,7 +176,7 @@ await requestSeamApi( ) // Boundary interaction: the outbound message IS the behavior. -expect(requests).toEqual([{ path: '/devices/list', params: { limit: 5 } }]) +expect(api.requests).toEqual([{ path: '/devices/list', params: { limit: 5 } }]) expect(memory.stdout()).toContain('invalid_input') expect(process.exitCode).toBe(1) ``` diff --git a/src/lib/config/create-memory-config-store.ts b/src/lib/config/create-memory-config-store.ts index 0d56e6b2..372d08df 100644 --- a/src/lib/config/create-memory-config-store.ts +++ b/src/lib/config/create-memory-config-store.ts @@ -6,41 +6,57 @@ import type { ConfigStore } from './config-store.js' * Keys are flat: the file-backed store nests dotted keys, but nothing reads * a value back by a different spelling than it was written with. */ -export const createMemoryConfigStore = ( - initialValues: Record = {}, -): ConfigStore => { - const values = new Map(Object.entries(initialValues)) - - return { - path: '/memory/cli.json', - get all() { - return Object.fromEntries(values) - }, - set all(newValues: Record) { - values.clear() - for (const [key, value] of Object.entries(newValues)) { - values.set(key, value) - } - }, - get size() { - return values.size - }, - get: (key) => values.get(key), - set: (key, value) => { - if (typeof key === 'string') { - values.set(key, value) - return - } - for (const [configKey, configValue] of Object.entries(key)) { - values.set(configKey, configValue) - } - }, - has: (key) => values.has(key), - delete: (key) => { - values.delete(key) - }, - clear: () => { - values.clear() - }, +export class MemoryConfigStore implements ConfigStore { + readonly path = '/memory/cli.json' + + private readonly values: Map + + constructor(initialValues: Record = {}) { + this.values = new Map(Object.entries(initialValues)) + } + + get all(): Record { + return Object.fromEntries(this.values) + } + + set all(newValues: Record) { + this.values.clear() + for (const [key, value] of Object.entries(newValues)) { + this.values.set(key, value) + } + } + + get size(): number { + return this.values.size + } + + get(key: string): unknown { + return this.values.get(key) + } + + set(key: string | Record, value?: unknown): void { + if (typeof key === 'string') { + this.values.set(key, value) + return + } + for (const [configKey, configValue] of Object.entries(key)) { + this.values.set(configKey, configValue) + } + } + + has(key: string): boolean { + return this.values.has(key) + } + + delete(key: string): void { + this.values.delete(key) + } + + clear(): void { + this.values.clear() } } + +export const createMemoryConfigStore = ( + initialValues: Record = {}, +): ConfigStore => new MemoryConfigStore(initialValues) diff --git a/src/lib/http/api.ts b/src/lib/http/api.ts index 56007a60..42c364f9 100644 --- a/src/lib/http/api.ts +++ b/src/lib/http/api.ts @@ -1,3 +1,5 @@ +import type { SeamHttp } from '@seamapi/http/connect' + import type { AuthContext } from '../context.js' import { getSeam } from './client.js' @@ -22,14 +24,19 @@ export interface SeamApi { } /** The only place `SeamHttp` appears for raw requests. */ -export const createSeamApi = async (auth?: AuthContext): Promise => { - const seam = await getSeam(auth) - return { - post: async (path, params) => { - const { status, data } = await seam.client.post(path, params, { - validateStatus: () => true, - }) - return { status, data } - }, +export class SeamHttpApi implements SeamApi { + constructor(private readonly seam: SeamHttp) {} + + post = async ( + path: string, + params: Record, + ): Promise => { + const { status, data } = await this.seam.client.post(path, params, { + validateStatus: () => true, + }) + return { status, data } } } + +export const createSeamApi = async (auth?: AuthContext): Promise => + new SeamHttpApi(await getSeam(auth)) diff --git a/src/lib/http/create-memory-seam-api.ts b/src/lib/http/create-memory-seam-api.ts index 6f7106db..3af24663 100644 --- a/src/lib/http/create-memory-seam-api.ts +++ b/src/lib/http/create-memory-seam-api.ts @@ -1,28 +1,30 @@ import type { SeamApi, SeamApiResponse } from './api.js' -export interface MemorySeamApi { - api: SeamApi - /** Every request made, in order — assert on the outbound messages. */ - requests: Array<{ path: string; params: Record }> -} - /** * A real {@link SeamApi} answering from a routes table and recording every * request, for tests: the in-process mirror of the e2e suite's HTTP server. */ -export const createMemorySeamApi = ( - routes: Record, -): MemorySeamApi => { - const requests: Array<{ path: string; params: Record }> = [] +export class MemorySeamApi implements SeamApi { + /** Every request made, in order — assert on the outbound messages. */ + readonly requests: Array<{ path: string; params: Record }> = + [] - const api: SeamApi = { - post: async (path, params) => { - requests.push({ path, params }) - return ( - routes[path] ?? { status: 404, data: { error: { type: 'not_found' } } } - ) - }, - } + constructor(private readonly routes: Record) {} - return { api, requests } + post = async ( + path: string, + params: Record, + ): Promise => { + this.requests.push({ path, params }) + return ( + this.routes[path] ?? { + status: 404, + data: { error: { type: 'not_found' } }, + } + ) + } } + +export const createMemorySeamApi = ( + routes: Record, +): MemorySeamApi => new MemorySeamApi(routes) diff --git a/src/lib/interact/create-memory-prompt.ts b/src/lib/interact/create-memory-prompt.ts index c314a25e..697a4bc5 100644 --- a/src/lib/interact/create-memory-prompt.ts +++ b/src/lib/interact/create-memory-prompt.ts @@ -1,8 +1,11 @@ import { PromptCancelledError } from '../errors.js' -import { - type PromptChoice, - type PromptClient, - type PromptSelectOptions, +import type { + PromptChoice, + PromptClient, + PromptConfirmOptions, + PromptNumberOptions, + PromptSelectOptions, + PromptTextOptions, } from './prompt.js' /** A question a {@link PromptClient} was asked, as a test sees it. */ @@ -21,12 +24,6 @@ export interface PromptQuestion { /** Scripted in place of an answer to dismiss that prompt. */ export const cancelPrompt = Symbol('cancel-prompt') -export interface MemoryPrompt { - client: PromptClient - /** Every question asked, in order — assert on what the user was offered. */ - questions: PromptQuestion[] -} - /** * A real {@link PromptClient} that answers from a script instead of a * terminal, and records every question it was asked. @@ -35,42 +32,57 @@ export interface MemoryPrompt { * {@link cancelPrompt} dismisses that prompt, and an exhausted script * dismisses every prompt after it, exactly as a user cancelling would. */ -export const createMemoryPrompt = (script: unknown[] = []): MemoryPrompt => { - const questions: PromptQuestion[] = [] - const answers = [...script] +export class MemoryPromptClient implements PromptClient { + /** Every question asked, in order — assert on what the user was offered. */ + readonly questions: PromptQuestion[] = [] - const answer = (question: PromptQuestion): unknown => { - questions.push(question) - if (answers.length === 0) throw new PromptCancelledError() - const value = answers.shift() - if (value === cancelPrompt) throw new PromptCancelledError() - return value + private readonly answers: unknown[] + + constructor(script: unknown[] = []) { + this.answers = [...script] } - const client: PromptClient = { - canPrompt: () => true, - text: async ({ message }) => answer({ kind: 'text', message }) as string, - number: async ({ message }) => - answer({ kind: 'number', message }) as number, - confirm: async ({ message }) => - answer({ kind: 'confirm', message }) as boolean, - select: async ({ message, choices }: PromptSelectOptions) => - answer({ kind: 'select', message, choices }) as Value, - autocomplete: async ({ - message, - choices, - }: PromptSelectOptions) => - answer({ kind: 'autocomplete', message, choices }) as Value, - autocompleteMultiselect: async ({ + canPrompt = (): boolean => true + + text = async ({ message }: PromptTextOptions): Promise => + this.answer({ kind: 'text', message }) as string + + number = async ({ message }: PromptNumberOptions): Promise => + this.answer({ kind: 'number', message }) as number + + confirm = async ({ message }: PromptConfirmOptions): Promise => + this.answer({ kind: 'confirm', message }) as boolean + + select = async ({ + message, + choices, + }: PromptSelectOptions): Promise => + this.answer({ kind: 'select', message, choices }) as Value + + autocomplete = async ({ + message, + choices, + }: PromptSelectOptions): Promise => + this.answer({ kind: 'autocomplete', message, choices }) as Value + + autocompleteMultiselect = async ({ + message, + choices, + }: PromptSelectOptions): Promise => + this.answer({ + kind: 'autocompleteMultiselect', message, choices, - }: PromptSelectOptions) => - answer({ - kind: 'autocompleteMultiselect', - message, - choices, - }) as Value[], - } + }) as Value[] - return { client, questions } + private answer(question: PromptQuestion): unknown { + this.questions.push(question) + if (this.answers.length === 0) throw new PromptCancelledError() + const value = this.answers.shift() + if (value === cancelPrompt) throw new PromptCancelledError() + return value + } } + +export const createMemoryPrompt = (script: unknown[] = []): MemoryPromptClient => + new MemoryPromptClient(script) diff --git a/src/lib/interact/prompt.ts b/src/lib/interact/prompt.ts index c6b5bd4d..09bed47c 100644 --- a/src/lib/interact/prompt.ts +++ b/src/lib/interact/prompt.ts @@ -133,22 +133,22 @@ const toOptions = ( : { label, value, hint }) as Option, ) -const terminalPromptClient: PromptClient = { +export class TerminalPromptClient implements PromptClient { /** * Prompts read raw keypresses and render an interface, so they need a * terminal on both ends: when stdin is a pipe or a file it holds request * params, not answers, and when stderr is redirected nobody sees the * question. */ - canPrompt: () => - process.stdin.isTTY === true && process.stderr.isTTY === true, + canPrompt = (): boolean => + process.stdin.isTTY === true && process.stderr.isTTY === true - text: async (options) => { + text = async (options: PromptTextOptions): Promise => { installArrowKeyAliases() return unwrap(await text({ ...options, output })) - }, + } - number: async (options) => { + number = async (options: PromptNumberOptions): Promise => { installArrowKeyAliases() const value = unwrap( await text({ @@ -163,14 +163,14 @@ const terminalPromptClient: PromptClient = { }), ) return Number(value) - }, + } - confirm: async (options) => { + confirm = async (options: PromptConfirmOptions): Promise => { installArrowKeyAliases() return unwrap(await confirm({ ...options, output })) - }, + } - select: async (options: PromptSelectOptions) => { + select = async (options: PromptSelectOptions): Promise => { installArrowKeyAliases() return unwrap( await select({ @@ -179,9 +179,11 @@ const terminalPromptClient: PromptClient = { output, }), ) - }, + } - autocomplete: async (options: PromptSelectOptions) => { + autocomplete = async ( + options: PromptSelectOptions, + ): Promise => { installArrowKeyAliases() return unwrap( await autocomplete({ @@ -193,11 +195,11 @@ const terminalPromptClient: PromptClient = { output, }), ) - }, + } - autocompleteMultiselect: async ( + autocompleteMultiselect = async ( options: PromptSelectOptions, - ) => { + ): Promise => { installArrowKeyAliases() return unwrap( await autocompleteMultiselect({ @@ -207,9 +209,11 @@ const terminalPromptClient: PromptClient = { output, }), ) - }, + } } +const terminalPromptClient = new TerminalPromptClient() + let client: PromptClient = terminalPromptClient export const setPromptClient = (promptClient: PromptClient): void => { diff --git a/src/lib/output/create-output.ts b/src/lib/output/create-output.ts index 21239485..064ea9af 100644 --- a/src/lib/output/create-output.ts +++ b/src/lib/output/create-output.ts @@ -58,41 +58,56 @@ export interface CreateOutputOptions { colors?: boolean } -export const createOutput = ({ - format = 'text', - stdout = process.stdout, - stderr = process.stderr, - colors = false, -}: CreateOutputOptions = {}): Output => { - const isJson = format === 'json' - - return { - format, - - data: (value: unknown): void => { - if (value === undefined) return - stdout.write(`${formatData(value, format, colors)}\n`) - }, - - text: (value: string): void => { - stdout.write(`${value}\n`) - }, - - info: (message = ''): void => { - if (isJson) return - stderr.write(`${message}\n`) - }, - - warn: (message: string): void => { - stderr.write(`${message}\n`) - }, - - error: (message: string): void => { - stderr.write(`${message}\n`) - }, +/** + * The one {@link Output} implementation: writes to a pair of streams. The + * process streams make it the real output; in-memory streams make it the + * test capture (see `create-memory-output.ts`). + */ +export class StreamOutput implements Output { + readonly format: OutputFormat + + private readonly stdout: OutputStream + private readonly stderr: OutputStream + private readonly colors: boolean + + constructor({ + format = 'text', + stdout = process.stdout, + stderr = process.stderr, + colors = false, + }: CreateOutputOptions = {}) { + this.format = format + this.stdout = stdout + this.stderr = stderr + this.colors = colors + } + + data = (value: unknown): void => { + if (value === undefined) return + this.stdout.write(`${formatData(value, this.format, this.colors)}\n`) + } + + text = (value: string): void => { + this.stdout.write(`${value}\n`) + } + + info = (message = ''): void => { + if (this.format === 'json') return + this.stderr.write(`${message}\n`) + } + + warn = (message: string): void => { + this.stderr.write(`${message}\n`) + } + + error = (message: string): void => { + this.stderr.write(`${message}\n`) } } +export const createOutput = (options: CreateOutputOptions = {}): Output => + new StreamOutput(options) + const formatData = ( value: unknown, format: OutputFormat, diff --git a/test/http/request.test.ts b/test/http/request.test.ts index 7416d411..2d634a51 100644 --- a/test/http/request.test.ts +++ b/test/http/request.test.ts @@ -15,7 +15,7 @@ afterEach(() => { }) test('requestSeamApi: sends the params and reports the trimmed payload', async () => { - const { api, requests } = createMemorySeamApi({ + const api = createMemorySeamApi({ '/devices/list': { status: 200, data: { @@ -33,7 +33,9 @@ test('requestSeamApi: sends the params and reports the trimmed payload', async ( ) // Boundary interaction: the outbound message IS the behavior. - expect(requests).toEqual([{ path: '/devices/list', params: { limit: 5 } }]) + expect(api.requests).toEqual([ + { path: '/devices/list', params: { limit: 5 } }, + ]) expect(response.status).toBe(200) expect(JSON.parse(memory.stdout())).toEqual({ devices: [{ device_id: 'device1' }], @@ -43,7 +45,7 @@ test('requestSeamApi: sends the params and reports the trimmed payload', async ( }) test('requestSeamApi: reports an error payload and sets the exit code', async () => { - const { api, requests } = createMemorySeamApi({ + const api = createMemorySeamApi({ '/devices/list': { status: 400, data: { error: { type: 'invalid_input' }, ok: false }, @@ -56,14 +58,16 @@ test('requestSeamApi: reports an error payload and sets the exit code', async () { api, output: memory.output }, ) - expect(requests).toEqual([{ path: '/devices/list', params: { limit: 5 } }]) + expect(api.requests).toEqual([ + { path: '/devices/list', params: { limit: 5 } }, + ]) expect(memory.stdout()).toContain('invalid_input') expect(memory.stderr()).toContain('[400]') expect(process.exitCode).toBe(1) }) test('requestSeamApi: keeps the request banner out of stdout', async () => { - const { api } = createMemorySeamApi({ + const api = createMemorySeamApi({ '/devices/list': { status: 200, data: { devices: [], ok: true } }, }) const memory = createMemoryOutput({ format: 'text' }) diff --git a/test/interact/interact-for-blueprint-object.test.ts b/test/interact/interact-for-blueprint-object.test.ts index 6da4df3d..d23ca69d 100644 --- a/test/interact/interact-for-blueprint-object.test.ts +++ b/test/interact/interact-for-blueprint-object.test.ts @@ -7,17 +7,17 @@ import { setOutput } from 'lib/output/get-output.js' import { cancelPrompt, createMemoryPrompt, - type MemoryPrompt, + type MemoryPromptClient, } from 'lib/interact/create-memory-prompt.js' import { interactForBlueprintObject } from 'lib/interact/interact-for-blueprint-object.js' import { resetPromptClient, setPromptClient, withBackHint } from 'lib/interact/prompt.js' -let memoryPrompt: MemoryPrompt +let memoryPrompt: MemoryPromptClient /** Replace the prompt client, scripting an answer for each ask in turn. */ -const scriptPrompt = (script: unknown[]): MemoryPrompt => { +const scriptPrompt = (script: unknown[]): MemoryPromptClient => { memoryPrompt = createMemoryPrompt(script) - setPromptClient(memoryPrompt.client) + setPromptClient(memoryPrompt) return memoryPrompt } diff --git a/test/interact/interact-for-command-selection.test.ts b/test/interact/interact-for-command-selection.test.ts index 4184a747..f205b1de 100644 --- a/test/interact/interact-for-command-selection.test.ts +++ b/test/interact/interact-for-command-selection.test.ts @@ -52,7 +52,7 @@ const interactiveHelpers = () => ({ test('interactForCommandSelection: tells the user a sub-command menu can be left', async () => { const memoryPrompt = createMemoryPrompt(['list']) - setPromptClient(memoryPrompt.client) + setPromptClient(memoryPrompt) await interactForCommandSelection(['devices'], interactiveHelpers()) @@ -64,7 +64,7 @@ test('interactForCommandSelection: tells the user a sub-command menu can be left // Escape stops the CLI at the top level, so promising a way back would lie. test('interactForCommandSelection: says nothing about going back at the top level', async () => { const memoryPrompt = createMemoryPrompt(['devices', 'list']) - setPromptClient(memoryPrompt.client) + setPromptClient(memoryPrompt) await interactForCommandSelection([], interactiveHelpers()) diff --git a/test/interact/interact-for-custom-metadata.test.ts b/test/interact/interact-for-custom-metadata.test.ts index 725f5ad4..3a16f396 100644 --- a/test/interact/interact-for-custom-metadata.test.ts +++ b/test/interact/interact-for-custom-metadata.test.ts @@ -8,7 +8,7 @@ import { resetPromptClient, setPromptClient } from 'lib/interact/prompt.js' /** Scripts an answer for each ask, in the order the editor asks. */ const scriptPrompt = (script: unknown[]): void => { - setPromptClient(createMemoryPrompt(script).client) + setPromptClient(createMemoryPrompt(script)) } beforeEach(() => { From 8795ecfcb1ba49e9b2703bd3196ed2aa22515ebe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 04:05:56 +0000 Subject: [PATCH 15/20] style: Unnest awaits and name the blueprint source selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Awaited expressions no longer hide inside call arguments, casts, or object literals — each await lands on its own line in a const, so a rejection's stack trace points at a named step instead of an expression soup. getApiBlueprint(false, { update }) told a reader nothing about what false meant; the selector now takes a single options object, getApiBlueprint({ useRemoteDefinitions, update }). selectFakeServer likewise takes { urlSeed, config } instead of a positional seed that call sites passed as undefined. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- src/bin/cli.ts | 9 ++++-- src/lib/auth/operations.ts | 11 ++++--- src/lib/blueprint/cache.ts | 11 ++++--- src/lib/blueprint/index.ts | 16 ++++++---- src/lib/blueprint/source-npm.test.ts | 29 ++++++++++++------- src/lib/blueprint/source-npm.ts | 12 +++++--- src/lib/commands/api-command.ts | 3 +- src/lib/commands/local/completion.ts | 3 +- .../commands/local/config-set-fake-server.ts | 2 +- src/lib/commands/local/health.ts | 3 +- src/lib/http/api.ts | 6 ++-- .../interact/interact-for-server-selection.ts | 2 +- test/auth/operations.test.ts | 7 +++-- test/cli.test.ts | 10 ++++--- 14 files changed, 81 insertions(+), 43 deletions(-) diff --git a/src/bin/cli.ts b/src/bin/cli.ts index c3c6bd6e..de0f8235 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -47,7 +47,8 @@ async function cli(args: ParsedArgs, argv: string[]) { if (helpFlag != null) { // Help comes from the cached API definitions so that it works without // logging in, and offline once the cache is warm. - const { spec } = buildRegistry(await getApiBlueprint(false, { update })) + const cachedBlueprint = await getApiBlueprint({ update }) + const { spec } = buildRegistry(cachedBlueprint) // minimist reads the word after --help as its value, so 'seam --help // devices' asks about devices just as 'seam devices --help' does. @@ -136,14 +137,16 @@ async function cli(args: ParsedArgs, argv: string[]) { const useRemoteApiDefs = args['remote_api_defs'] ?? config.get('use_remote_api_defs') - const blueprint = await getApiBlueprint(useRemoteApiDefs ?? false, { + const blueprint = await getApiBlueprint({ + useRemoteDefinitions: useRemoteApiDefs ?? false, update, }) const registry = buildRegistry(blueprint) // Params piped or redirected in, e.g., `seam devices list < params.json`. - const stdinParams: Record = { ...(await readStdinJson()) } + const pipedParams = await readStdinJson() + const stdinParams: Record = { ...pipedParams } const auth = resolveAuth(config) let seamApi: Promise | null = null diff --git a/src/lib/auth/operations.ts b/src/lib/auth/operations.ts index 35fcf67e..776a4dd7 100644 --- a/src/lib/auth/operations.ts +++ b/src/lib/auth/operations.ts @@ -139,10 +139,13 @@ export const selectWorkspace = ( * Point the CLI at a fake Seam Connect server and store the well-known * token it accepts. Returns the generated server URL for reporting. */ -export const selectFakeServer = ( - urlSeed: string = randomBytes(5).toString('hex'), - config: ConfigStore = getConfigStore(), -): { server: string; token: string } => { +export const selectFakeServer = ({ + urlSeed = randomBytes(5).toString('hex'), + config = getConfigStore(), +}: { + urlSeed?: string + config?: ConfigStore +} = {}): { server: string; token: string } => { const auth = resolveAuth(config) assertMutable(auth, 'server', 'select a server') assertMutable(auth, 'token', 'log in') diff --git a/src/lib/blueprint/cache.ts b/src/lib/blueprint/cache.ts index 68893963..fb501acf 100644 --- a/src/lib/blueprint/cache.ts +++ b/src/lib/blueprint/cache.ts @@ -23,7 +23,8 @@ export const readCache = async ( file: string, ): Promise => { try { - const cache = JSON.parse(await readFile(file, 'utf8')) as unknown + const contents = await readFile(file, 'utf8') + const cache = JSON.parse(contents) as unknown if (!isBlueprintCache(cache)) return null return cache } catch { @@ -67,9 +68,11 @@ const findOwnPackageJson = async (): Promise<{ let directory = dirname(fileURLToPath(import.meta.url)) while (true) { try { - const pkg = JSON.parse( - await readFile(join(directory, 'package.json'), 'utf8'), - ) as { name?: string; dependencies?: Record } + const contents = await readFile(join(directory, 'package.json'), 'utf8') + const pkg = JSON.parse(contents) as { + name?: string + dependencies?: Record + } if (pkg.name === '@seamapi/cli') return pkg } catch { // Keep walking up until a package.json for this package is found. diff --git a/src/lib/blueprint/index.ts b/src/lib/blueprint/index.ts index bc936636..7d050869 100644 --- a/src/lib/blueprint/index.ts +++ b/src/lib/blueprint/index.ts @@ -6,16 +6,22 @@ import { createRemoteBlueprint } from './source-remote.js' export type ApiBlueprint = Blueprint export interface GetApiBlueprintOptions { + /** + * Build from the OpenAPI document the configured server is currently + * running, instead of the published npm types. + */ + useRemoteDefinitions?: boolean + /** Force an update of the cached Seam API definitions. */ update?: boolean } -export const getApiBlueprint = async ( - useRemoteDefinitions: boolean, - options: GetApiBlueprintOptions = {}, -): Promise => { +export const getApiBlueprint = async ({ + useRemoteDefinitions = false, + update = false, +}: GetApiBlueprintOptions = {}): Promise => { // Remote definitions describe whatever the server is currently running, so // build them directly from the server's OpenAPI document. if (useRemoteDefinitions) return await createRemoteBlueprint() - return await getBlueprint(options) + return await getBlueprint({ update }) } diff --git a/src/lib/blueprint/source-npm.test.ts b/src/lib/blueprint/source-npm.test.ts index 7b60c968..0eec3421 100644 --- a/src/lib/blueprint/source-npm.test.ts +++ b/src/lib/blueprint/source-npm.test.ts @@ -70,18 +70,25 @@ const seedCache = async ( ) } -const readCache = async (): Promise => - JSON.parse( - await readFile(join(cacheDirectory, 'blueprint.json'), 'utf8'), - ) as typeof seedCacheState +const readCache = async (): Promise => { + const contents = await readFile( + join(cacheDirectory, 'blueprint.json'), + 'utf8', + ) + return JSON.parse(contents) as typeof seedCacheState +} const hoursAgo = (hours: number): string => new Date(Date.now() - hours * 60 * 60 * 1000).toISOString() beforeAll(async () => { - const pkg = JSON.parse( - await readFile(new URL('../../../package.json', import.meta.url), 'utf8'), - ) as { dependencies: Record } + const packageJson = await readFile( + new URL('../../../package.json', import.meta.url), + 'utf8', + ) + const pkg = JSON.parse(packageJson) as { + dependencies: Record + } pinnedBlueprintVersion = pkg.dependencies['@seamapi/blueprint'] ?? '' // Build a @seamapi/types package tarball from the locally installed @@ -106,9 +113,11 @@ beforeAll(async () => { try { stubRegistry() await getBlueprint({ cacheDirectory: seedDirectory }) - seedCacheState = JSON.parse( - await readFile(join(seedDirectory, 'blueprint.json'), 'utf8'), - ) as typeof seedCacheState + const seedContents = await readFile( + join(seedDirectory, 'blueprint.json'), + 'utf8', + ) + seedCacheState = JSON.parse(seedContents) as typeof seedCacheState } finally { vi.unstubAllGlobals() await rm(seedDirectory, { recursive: true, force: true }) diff --git a/src/lib/blueprint/source-npm.ts b/src/lib/blueprint/source-npm.ts index 7cef431d..2ef7e8db 100644 --- a/src/lib/blueprint/source-npm.ts +++ b/src/lib/blueprint/source-npm.ts @@ -102,7 +102,8 @@ const fetchLatestTypesPackageManifest = if (!res.ok) { throw new Error(`npm registry responded with status ${res.status}`) } - const manifest = (await res.json()) as Partial + const body = await res.json() + const manifest = body as Partial if ( typeof manifest.version !== 'string' || typeof manifest.dist?.tarball !== 'string' @@ -139,21 +140,24 @@ const downloadOpenapi = async ( await rm(extractDirectory, { recursive: true, force: true }) await mkdir(extractDirectory, { recursive: true }) try { - await writeFile(tarballFile, Buffer.from(await res.arrayBuffer())) + const tarball = await res.arrayBuffer() + await writeFile(tarballFile, Buffer.from(tarball)) await extract({ file: tarballFile, cwd: extractDirectory }, [ openapiTarEntryName, ]) const moduleFile = join(extractDirectory, openapiTarEntryName) - if (!(await exists(moduleFile))) { + const moduleFileExists = await exists(moduleFile) + if (!moduleFileExists) { throw new Error(`Missing ${openapiTarEntryName} in package tarball`) } // The OpenAPI document is published as a JavaScript module, so import it. const openapiModuleUrl = pathToFileURL(moduleFile).href - const { default: openapi } = (await import(openapiModuleUrl)) as { + const openapiModule = (await import(openapiModuleUrl)) as { default: unknown } + const { default: openapi } = openapiModule if (openapi == null) { throw new Error(`Missing default export in ${openapiTarEntryName}`) } diff --git a/src/lib/commands/api-command.ts b/src/lib/commands/api-command.ts index fa43a353..2a2515a4 100644 --- a/src/lib/commands/api-command.ts +++ b/src/lib/commands/api-command.ts @@ -80,9 +80,10 @@ export const executeApiCommand = async ( delete params['since'] } + const api = await ctx.api() const response = await requestSeamApi( { path: apiPath, params, responseKey: getResponseKey(path, ctx) }, - { api: await ctx.api(), output: ctx.output }, + { api, output: ctx.output }, ) await runResponseFollowUps(response.data, ctx) diff --git a/src/lib/commands/local/completion.ts b/src/lib/commands/local/completion.ts index 12ce1a5d..c0054def 100644 --- a/src/lib/commands/local/completion.ts +++ b/src/lib/commands/local/completion.ts @@ -23,7 +23,8 @@ export const printCompletion = async ( // Deferred import: the registry lists this module's commands, so a static // import back into it would be a cycle. const { buildRegistry } = await import('../registry.js') - const { spec } = buildRegistry(await getApiBlueprint(false, { update })) + const blueprint = await getApiBlueprint({ update }) + const { spec } = buildRegistry(blueprint) getOutput().text(renderCompletion(shell, spec)) } diff --git a/src/lib/commands/local/config-set-fake-server.ts b/src/lib/commands/local/config-set-fake-server.ts index 789c5806..94f92ebf 100644 --- a/src/lib/commands/local/config-set-fake-server.ts +++ b/src/lib/commands/local/config-set-fake-server.ts @@ -13,7 +13,7 @@ export const configSetFakeServerCommand: Command = { requiresAuth: false, hidden: true, execute: async (_invocation, ctx) => { - const { server } = selectFakeServer(undefined, ctx.config) + const { server } = selectFakeServer({ config: ctx.config }) ctx.output.info(`Server URL set to ${server}`) ctx.output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) return { kind: 'done' } diff --git a/src/lib/commands/local/health.ts b/src/lib/commands/local/health.ts index eb1e38c5..c4f43400 100644 --- a/src/lib/commands/local/health.ts +++ b/src/lib/commands/local/health.ts @@ -12,9 +12,10 @@ export const healthCommand: Command = { }, requiresAuth: true, execute: async (_invocation, ctx) => { + const api = await ctx.api() await requestSeamApi( { path: '/health/get_health', params: {} }, - { api: await ctx.api(), output: ctx.output }, + { api, output: ctx.output }, ) return { kind: 'done' } }, diff --git a/src/lib/http/api.ts b/src/lib/http/api.ts index 42c364f9..19759b82 100644 --- a/src/lib/http/api.ts +++ b/src/lib/http/api.ts @@ -38,5 +38,7 @@ export class SeamHttpApi implements SeamApi { } } -export const createSeamApi = async (auth?: AuthContext): Promise => - new SeamHttpApi(await getSeam(auth)) +export const createSeamApi = async (auth?: AuthContext): Promise => { + const seam = await getSeam(auth) + return new SeamHttpApi(seam) +} diff --git a/src/lib/interact/interact-for-server-selection.ts b/src/lib/interact/interact-for-server-selection.ts index ae2fa8cb..72017685 100644 --- a/src/lib/interact/interact-for-server-selection.ts +++ b/src/lib/interact/interact-for-server-selection.ts @@ -36,7 +36,7 @@ export async function interactForServerSelection() { if (userUrlSeed.trim().length === 0) { userUrlSeed = randomBytes(5).toString('hex') } - selectFakeServer(userUrlSeed, config) + selectFakeServer({ urlSeed: userUrlSeed, config }) output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) } else { selectServer(server, config) diff --git a/test/auth/operations.test.ts b/test/auth/operations.test.ts index 0a1ece05..5bc53eb8 100644 --- a/test/auth/operations.test.ts +++ b/test/auth/operations.test.ts @@ -207,7 +207,10 @@ test(`selectWorkspace: refuses while ${workspaceIdEnvVar} is set`, () => { test('selectFakeServer: stores the server and its well-known token', () => { const store = createMemoryConfigStore({ current_workspace_id: 'workspace1' }) - const { server: fakeServer } = selectFakeServer('abc123', store) + const { server: fakeServer } = selectFakeServer({ + urlSeed: 'abc123', + config: store, + }) expect(fakeServer).toBe('https://abc123.fakeseamconnect.seam.vc') expect(store.get('server')).toBe(fakeServer) @@ -219,7 +222,7 @@ test(`selectFakeServer: refuses while ${endpointEnvVar} is set`, () => { process.env[endpointEnvVar] = server const store = createMemoryConfigStore() - expect(() => selectFakeServer('abc123', store)).toThrow( + expect(() => selectFakeServer({ urlSeed: 'abc123', config: store })).toThrow( `Cannot select a server while ${endpointEnvVar} is set`, ) }) diff --git a/test/cli.test.ts b/test/cli.test.ts index 67f47275..5142338c 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -99,9 +99,10 @@ beforeAll(async () => { // A pre-seeded blueprint cache holding the fixture blueprint, so tests // that pin parameter handling run against known API definitions and // never touch the npm registry. - const pkg = JSON.parse( - await readFile(join(projectRoot, 'package.json'), 'utf8'), - ) as { dependencies: Record } + const packageJson = await readFile(join(projectRoot, 'package.json'), 'utf8') + const pkg = JSON.parse(packageJson) as { + dependencies: Record + } cacheHome = join(home, 'cache') await mkdir(join(cacheHome, 'seam'), { recursive: true }) await writeFile( @@ -619,7 +620,8 @@ test('cli: logout removes the stored token and workspace', async () => { expect(exitCode).toBe(0) expect(stderr).toContain('Logged out!') - const state = JSON.parse(await readFile(stateFile, 'utf8')) + const stateJson = await readFile(stateFile, 'utf8') + const state = JSON.parse(stateJson) expect(state[endpoint]?.pat).toBeUndefined() expect(state.pat).toBeUndefined() expect(state.current_workspace_id).toBeUndefined() From 79cafab91c5184104be1aaf5e51ef9d6c486ea23 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 04:12:18 +0000 Subject: [PATCH 16/20] style: Ban relative parent imports and drop the Seam prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parent-relative imports are now an error: the configured import/no-relative-parent-imports rule was silently inert in this flat config, so the core no-restricted-imports rule enforces it instead. Every ../ import becomes a path alias — lib/* for source and a new test/* alias for fixtures — resolved by tsconfig paths, vitest, and tsc-alias in the build. Seam is the default context, so it earns no place in names: SeamConfigStore is really the PersistentConfigStore. The SeamApi port keeps its name as the one thing genuinely named after the API, with HttpSeamApi and MemorySeamApi as its implementations. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- TESTING.md | 8 ++++---- eslint.config.ts | 17 +++++++++++++++-- src/lib/args/validate.ts | 3 ++- src/lib/auth/operations.ts | 7 ++++--- src/lib/auth/validate-token.ts | 2 +- src/lib/blueprint/cache.ts | 2 +- src/lib/blueprint/source-npm.ts | 3 ++- src/lib/blueprint/source-remote.ts | 2 +- src/lib/commands/api-command.ts | 19 ++++++++++--------- src/lib/commands/local/completion.ts | 10 +++++----- .../commands/local/config-reveal-location.ts | 2 +- .../commands/local/config-set-fake-server.ts | 4 ++-- .../local/config-use-remote-api-defs.ts | 6 +++--- src/lib/commands/local/health.ts | 4 ++-- src/lib/commands/local/login.ts | 10 +++++----- src/lib/commands/local/logout.ts | 4 ++-- src/lib/commands/local/select-server.ts | 10 +++++----- src/lib/commands/local/select-workspace.ts | 8 ++++---- src/lib/commands/local/wizard.ts | 2 +- src/lib/commands/registry.ts | 7 ++++--- src/lib/commands/spec.ts | 2 +- src/lib/config/config-store.ts | 6 +++--- src/lib/config/index.ts | 2 +- src/lib/env.ts | 4 ++-- src/lib/http/api.ts | 7 ++++--- src/lib/http/client.ts | 4 ++-- src/lib/http/follow-ups.ts | 8 ++++---- src/lib/http/request.ts | 7 ++++--- src/lib/interact/interact-for-access-code.ts | 3 ++- src/lib/interact/interact-for-acs-entrance.ts | 3 ++- src/lib/interact/interact-for-acs-system.ts | 3 ++- src/lib/interact/interact-for-acs-user.ts | 3 ++- .../interact-for-action-attempt-poll.ts | 6 +++--- src/lib/interact/interact-for-array.ts | 4 ++-- .../interact/interact-for-blueprint-object.ts | 10 +++++----- .../interact/interact-for-command-params.ts | 5 +++-- .../interact-for-command-selection.ts | 4 ++-- .../interact-for-connected-account.ts | 3 ++- .../interact/interact-for-custom-metadata.ts | 4 ++-- src/lib/interact/interact-for-device.ts | 3 ++- src/lib/interact/interact-for-login.ts | 12 ++++++------ src/lib/interact/interact-for-resource.ts | 2 +- .../interact/interact-for-server-selection.ts | 8 ++++---- .../interact-for-use-remote-api-defs.ts | 4 ++-- .../interact/interact-for-user-identity.ts | 3 ++- src/lib/interact/interact-for-workspace-id.ts | 10 +++++----- src/lib/interact/prompt.ts | 2 +- src/lib/render/completion/describe.ts | 2 +- src/lib/render/completion/index.ts | 3 ++- src/lib/render/completion/render-bash.ts | 2 +- src/lib/render/completion/render-fish.ts | 3 ++- src/lib/render/completion/render-zsh.ts | 3 ++- src/lib/render/help.ts | 2 +- test/commands/registry.test.ts | 3 +-- test/commands/spec.test.ts | 3 +-- test/render/completion.test.ts | 3 +-- test/render/help.test.ts | 3 +-- tsconfig.json | 3 ++- vitest.config.ts | 1 + 59 files changed, 161 insertions(+), 132 deletions(-) diff --git a/TESTING.md b/TESTING.md index a750e677..31c6fa07 100644 --- a/TESTING.md +++ b/TESTING.md @@ -22,9 +22,9 @@ the fake goes. `setConfigStore()`), the prompt layer (`createMemoryPrompt()` + `setPromptClient()`), and the Seam API get the same treatment; nothing else needs it. An interface with more than one implementation — the real edge - and its memory fake — is fulfilled by **classes** (`SeamHttpApi` / + and its memory fake — is fulfilled by **classes** (`HttpSeamApi` / `MemorySeamApi`, `TerminalPromptClient` / `MemoryPromptClient`, - `SeamConfigStore` / `MemoryConfigStore`), with `createFoo` factories kept + `PersistentConfigStore` / `MemoryConfigStore`), with `createFoo` factories kept as the convenient way to construct them. 5. **The e2e suite proves wiring once; module tests prove behavior everywhere.** Don't re-prove auth headers in a unit test, and don't push @@ -118,7 +118,7 @@ export interface SeamApi { ) => Promise } -export class SeamHttpApi implements SeamApi { +export class HttpSeamApi implements SeamApi { constructor(private readonly seam: SeamHttp) {} // the only place SeamHttp appears post = async (path: string, params: Record) => { @@ -130,7 +130,7 @@ export class SeamHttpApi implements SeamApi { } export const createSeamApi = async (): Promise => - new SeamHttpApi(await getSeam()) + new HttpSeamApi(await getSeam()) ``` The fake is the in-process mirror of the e2e server — a routes table plus a diff --git a/eslint.config.ts b/eslint.config.ts index 5c765c9e..e67e153d 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -33,7 +33,20 @@ export default [ ], 'import/extensions': ['error', 'ignorePackages'], 'import/no-duplicates': ['error', { 'prefer-inline': true }], - 'import/no-relative-parent-imports': 'error', + // The import/no-relative-parent-imports rule is silently inert in this + // flat config, so ban parent traversal with the core rule instead. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['..', '../**'], + message: + 'Import by path alias instead, e.g., lib/foo/bar.js or test/fixtures/blueprint.js.', + }, + ], + }, + ], 'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-vars': [ 'error', @@ -61,7 +74,7 @@ export default [ ['^node:'], ['^@?\\w'], ['@seamapi/cli'], - ['^lib/'], + ['^lib/', '^test/'], ['^'], ['^\\.'], ], diff --git a/src/lib/args/validate.ts b/src/lib/args/validate.ts index 2ba8689b..9221d670 100644 --- a/src/lib/args/validate.ts +++ b/src/lib/args/validate.ts @@ -1,6 +1,7 @@ import type { Parameter } from '@seamapi/blueprint' -import { NonInteractiveError, UsageError } from '../errors.js' +import { NonInteractiveError, UsageError } from 'lib/errors.js' + import { toArgName, toGivenArgName } from './parse.js' /** diff --git a/src/lib/auth/operations.ts b/src/lib/auth/operations.ts index 776a4dd7..1b968fe2 100644 --- a/src/lib/auth/operations.ts +++ b/src/lib/auth/operations.ts @@ -1,13 +1,14 @@ import { randomBytes } from 'node:crypto' -import { type ConfigStore, getConfigStore } from '../config/index.js' -import { type AuthContext, resolveAuth } from '../context.js' +import { type ConfigStore, getConfigStore } from 'lib/config/index.js' +import { type AuthContext, resolveAuth } from 'lib/context.js' import { assertEnvVarUnset, endpointEnvVar, tokenEnvVar, workspaceIdEnvVar, -} from '../env.js' +} from 'lib/env.js' + import { validateToken } from './validate-token.js' /** A stored auth setting an environment variable may override. */ diff --git a/src/lib/auth/validate-token.ts b/src/lib/auth/validate-token.ts index 33db7b1c..394af062 100644 --- a/src/lib/auth/validate-token.ts +++ b/src/lib/auth/validate-token.ts @@ -5,7 +5,7 @@ import { SeamHttpWithoutWorkspace, } from '@seamapi/http/connect' -import { resolveAuth } from '../context.js' +import { resolveAuth } from 'lib/context.js' export const validateToken = async (token: string, workspaceId?: string) => { const options = { endpoint: resolveAuth().server } diff --git a/src/lib/blueprint/cache.ts b/src/lib/blueprint/cache.ts index fb501acf..ec2d7b70 100644 --- a/src/lib/blueprint/cache.ts +++ b/src/lib/blueprint/cache.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url' import type { Blueprint } from '@seamapi/blueprint' -import { seamapiBlueprintVersion } from '../version.js' +import { seamapiBlueprintVersion } from 'lib/version.js' const cacheFileName = 'blueprint.json' const updateCheckInterval = 24 * 60 * 60 * 1000 diff --git a/src/lib/blueprint/source-npm.ts b/src/lib/blueprint/source-npm.ts index 2ef7e8db..9ca623d3 100644 --- a/src/lib/blueprint/source-npm.ts +++ b/src/lib/blueprint/source-npm.ts @@ -6,7 +6,8 @@ import type { Blueprint, TypesModuleInput } from '@seamapi/blueprint' import envPaths from 'env-paths' import { extract } from 'tar' -import { withLoading } from '../output/with-loading.js' +import { withLoading } from 'lib/output/with-loading.js' + import { getBlueprintVersion, getCacheFile, diff --git a/src/lib/blueprint/source-remote.ts b/src/lib/blueprint/source-remote.ts index 4acf998b..6989a1cf 100644 --- a/src/lib/blueprint/source-remote.ts +++ b/src/lib/blueprint/source-remote.ts @@ -1,6 +1,6 @@ import type { Blueprint } from '@seamapi/blueprint' -import { resolveAuth } from '../context.js' +import { resolveAuth } from 'lib/context.js' /** * Build a blueprint from the OpenAPI document the current server is running, diff --git a/src/lib/commands/api-command.ts b/src/lib/commands/api-command.ts index 2a2515a4..77fb32ed 100644 --- a/src/lib/commands/api-command.ts +++ b/src/lib/commands/api-command.ts @@ -1,17 +1,18 @@ import { isDeepStrictEqual as isEqual } from 'node:util' -import { coerceArgParams } from '../args/coerce.js' -import { parseCliArgs, toArgName, toArgParams } from '../args/parse.js' -import { assertRequiredParams } from '../args/validate.js' +import { coerceArgParams } from 'lib/args/coerce.js' +import { parseCliArgs, toArgName, toArgParams } from 'lib/args/parse.js' +import { assertRequiredParams } from 'lib/args/validate.js' import { getCommandBlueprintDef, getResponseKey, -} from '../blueprint/endpoint.js' -import type { CliContext } from '../context.js' -import { UsageError } from '../errors.js' -import { runResponseFollowUps } from '../http/follow-ups.js' -import { requestSeamApi } from '../http/request.js' -import { interactForCommandParams } from '../interact/interact-for-command-params.js' +} from 'lib/blueprint/endpoint.js' +import type { CliContext } from 'lib/context.js' +import { UsageError } from 'lib/errors.js' +import { runResponseFollowUps } from 'lib/http/follow-ups.js' +import { requestSeamApi } from 'lib/http/request.js' +import { interactForCommandParams } from 'lib/interact/interact-for-command-params.js' + import type { CommandResult, Invocation } from './registry.js' /** diff --git a/src/lib/commands/local/completion.ts b/src/lib/commands/local/completion.ts index c0054def..729f3a28 100644 --- a/src/lib/commands/local/completion.ts +++ b/src/lib/commands/local/completion.ts @@ -1,10 +1,10 @@ -import { getApiBlueprint } from '../../blueprint/index.js' -import { getOutput } from '../../output/get-output.js' +import { getApiBlueprint } from 'lib/blueprint/index.js' +import type { Command } from 'lib/commands/registry.js' +import { getOutput } from 'lib/output/get-output.js' import { type CompletionShell, renderCompletion, -} from '../../render/completion/index.js' -import type { Command } from '../registry.js' +} from 'lib/render/completion/index.js' /** * Print the completion script for a shell. @@ -22,7 +22,7 @@ export const printCompletion = async ( ): Promise => { // Deferred import: the registry lists this module's commands, so a static // import back into it would be a cycle. - const { buildRegistry } = await import('../registry.js') + const { buildRegistry } = await import('lib/commands/registry.js') const blueprint = await getApiBlueprint({ update }) const { spec } = buildRegistry(blueprint) getOutput().text(renderCompletion(shell, spec)) diff --git a/src/lib/commands/local/config-reveal-location.ts b/src/lib/commands/local/config-reveal-location.ts index 5ba79049..f7ae4ebf 100644 --- a/src/lib/commands/local/config-reveal-location.ts +++ b/src/lib/commands/local/config-reveal-location.ts @@ -1,4 +1,4 @@ -import type { Command } from '../registry.js' +import type { Command } from 'lib/commands/registry.js' export const configRevealLocationCommand: Command = { definition: { diff --git a/src/lib/commands/local/config-set-fake-server.ts b/src/lib/commands/local/config-set-fake-server.ts index 94f92ebf..d91cdc09 100644 --- a/src/lib/commands/local/config-set-fake-server.ts +++ b/src/lib/commands/local/config-set-fake-server.ts @@ -1,5 +1,5 @@ -import { selectFakeServer } from '../../auth/operations.js' -import type { Command } from '../registry.js' +import { selectFakeServer } from 'lib/auth/operations.js' +import type { Command } from 'lib/commands/registry.js' /** Hidden: a development shortcut, kept out of help and completion. */ export const configSetFakeServerCommand: Command = { diff --git a/src/lib/commands/local/config-use-remote-api-defs.ts b/src/lib/commands/local/config-use-remote-api-defs.ts index e81d98a7..74119fcf 100644 --- a/src/lib/commands/local/config-use-remote-api-defs.ts +++ b/src/lib/commands/local/config-use-remote-api-defs.ts @@ -1,6 +1,6 @@ -import { NonInteractiveError } from '../../errors.js' -import { interactForUseRemoteApiDefs } from '../../interact/interact-for-use-remote-api-defs.js' -import type { Command } from '../registry.js' +import type { Command } from 'lib/commands/registry.js' +import { NonInteractiveError } from 'lib/errors.js' +import { interactForUseRemoteApiDefs } from 'lib/interact/interact-for-use-remote-api-defs.js' export const configUseRemoteApiDefsCommand: Command = { definition: { diff --git a/src/lib/commands/local/health.ts b/src/lib/commands/local/health.ts index c4f43400..6ca942a6 100644 --- a/src/lib/commands/local/health.ts +++ b/src/lib/commands/local/health.ts @@ -1,5 +1,5 @@ -import { requestSeamApi } from '../../http/request.js' -import type { Command } from '../registry.js' +import type { Command } from 'lib/commands/registry.js' +import { requestSeamApi } from 'lib/http/request.js' export const healthCommand: Command = { definition: { diff --git a/src/lib/commands/local/login.ts b/src/lib/commands/local/login.ts index 83ac5fb9..44e51425 100644 --- a/src/lib/commands/local/login.ts +++ b/src/lib/commands/local/login.ts @@ -1,8 +1,8 @@ -import { assertMutable, login } from '../../auth/operations.js' -import { NonInteractiveError } from '../../errors.js' -import { interactForLogin } from '../../interact/interact-for-login.js' -import type { Command } from '../registry.js' -import { stringFlag } from '../spec.js' +import { assertMutable, login } from 'lib/auth/operations.js' +import type { Command } from 'lib/commands/registry.js' +import { stringFlag } from 'lib/commands/spec.js' +import { NonInteractiveError } from 'lib/errors.js' +import { interactForLogin } from 'lib/interact/interact-for-login.js' export const loginCommand: Command = { definition: { diff --git a/src/lib/commands/local/logout.ts b/src/lib/commands/local/logout.ts index ce74d010..5a4cae45 100644 --- a/src/lib/commands/local/logout.ts +++ b/src/lib/commands/local/logout.ts @@ -1,5 +1,5 @@ -import { logout } from '../../auth/operations.js' -import type { Command } from '../registry.js' +import { logout } from 'lib/auth/operations.js' +import type { Command } from 'lib/commands/registry.js' export const logoutCommand: Command = { definition: { diff --git a/src/lib/commands/local/select-server.ts b/src/lib/commands/local/select-server.ts index 8be34245..795eec73 100644 --- a/src/lib/commands/local/select-server.ts +++ b/src/lib/commands/local/select-server.ts @@ -1,8 +1,8 @@ -import { assertMutable, selectServer } from '../../auth/operations.js' -import { NonInteractiveError } from '../../errors.js' -import { interactForServerSelection } from '../../interact/interact-for-server-selection.js' -import type { Command } from '../registry.js' -import { stringFlag } from '../spec.js' +import { assertMutable, selectServer } from 'lib/auth/operations.js' +import type { Command } from 'lib/commands/registry.js' +import { stringFlag } from 'lib/commands/spec.js' +import { NonInteractiveError } from 'lib/errors.js' +import { interactForServerSelection } from 'lib/interact/interact-for-server-selection.js' export const selectServerCommand: Command = { definition: { diff --git a/src/lib/commands/local/select-workspace.ts b/src/lib/commands/local/select-workspace.ts index b9db611d..798217c2 100644 --- a/src/lib/commands/local/select-workspace.ts +++ b/src/lib/commands/local/select-workspace.ts @@ -1,7 +1,7 @@ -import { assertMutable } from '../../auth/operations.js' -import { NonInteractiveError } from '../../errors.js' -import { interactForWorkspaceId } from '../../interact/interact-for-workspace-id.js' -import type { Command } from '../registry.js' +import { assertMutable } from 'lib/auth/operations.js' +import type { Command } from 'lib/commands/registry.js' +import { NonInteractiveError } from 'lib/errors.js' +import { interactForWorkspaceId } from 'lib/interact/interact-for-workspace-id.js' export const selectWorkspaceCommand: Command = { definition: { diff --git a/src/lib/commands/local/wizard.ts b/src/lib/commands/local/wizard.ts index dd7d08da..2c9caac0 100644 --- a/src/lib/commands/local/wizard.ts +++ b/src/lib/commands/local/wizard.ts @@ -1,4 +1,4 @@ -import type { Command } from '../registry.js' +import type { Command } from 'lib/commands/registry.js' /** * Run the Seam setup wizard. diff --git a/src/lib/commands/registry.ts b/src/lib/commands/registry.ts index 257b53cb..19e77a6a 100644 --- a/src/lib/commands/registry.ts +++ b/src/lib/commands/registry.ts @@ -1,8 +1,9 @@ import type { ParsedArgs } from 'minimist' -import { toParameterName } from '../args/parse.js' -import type { ApiBlueprint } from '../blueprint/index.js' -import type { CliContext } from '../context.js' +import { toParameterName } from 'lib/args/parse.js' +import type { ApiBlueprint } from 'lib/blueprint/index.js' +import type { CliContext } from 'lib/context.js' + import { executeApiCommand } from './api-command.js' import { completionCommands } from './local/completion.js' import { configRevealLocationCommand } from './local/config-reveal-location.js' diff --git a/src/lib/commands/spec.ts b/src/lib/commands/spec.ts index 7da87808..502a9215 100644 --- a/src/lib/commands/spec.ts +++ b/src/lib/commands/spec.ts @@ -1,6 +1,6 @@ import type { Blueprint } from '@seamapi/blueprint' -import { firstSentence, toPlainText } from '../render/text.js' +import { firstSentence, toPlainText } from 'lib/render/text.js' type Endpoint = Blueprint['routes'][number]['endpoints'][number] type Parameter = Endpoint['request']['parameters'][number] diff --git a/src/lib/config/config-store.ts b/src/lib/config/config-store.ts index c3cedcaa..bd3cf955 100644 --- a/src/lib/config/config-store.ts +++ b/src/lib/config/config-store.ts @@ -46,7 +46,7 @@ export const resetConfigStore = (): void => { configStore = null } -const createConfigStore = (): SeamConfigStore => { +const createConfigStore = (): PersistentConfigStore => { const settingsStore = new Configstore(legacyConfigStoreId, undefined, { configPath: getConfigPath(), }) @@ -60,7 +60,7 @@ const createConfigStore = (): SeamConfigStore => { new Configstore(legacyConfigStoreId), ) - return new SeamConfigStore(settingsStore, stateStore) + return new PersistentConfigStore(settingsStore, stateStore) } export const mergeConfig = ( @@ -114,7 +114,7 @@ export const splitConfig = ( return { settings, state } } -export class SeamConfigStore implements ConfigStore { +export class PersistentConfigStore implements ConfigStore { readonly path: string constructor( diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index 1d0f058e..a197928c 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -1,8 +1,8 @@ export { type ConfigStore, getConfigStore, + type PersistentConfigStore, resetConfigStore, - type SeamConfigStore, setConfigStore, } from './config-store.js' export { createMemoryConfigStore } from './create-memory-config-store.js' diff --git a/src/lib/env.ts b/src/lib/env.ts index 1b48f926..a3e114aa 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -16,7 +16,7 @@ export const workspaceIdEnvVar = 'SEAM_CLI_WORKSPACE_ID' export const endpointEnvVar = 'SEAM_CLI_ENDPOINT' /** Every variable read here is declared on `ProcessEnv` in `env.d.ts`. */ -type SeamCliEnvVar = +type CliEnvVar = typeof endpointEnvVar | typeof tokenEnvVar | typeof workspaceIdEnvVar export const getTokenFromEnv = (): string | null => readEnvVar(tokenEnvVar) @@ -59,7 +59,7 @@ export const assertEnvVarUnset = ( export const isInsideWebBrowser = (): boolean => process.env['INSIDE_WEB_BROWSER'] === '1' -const readEnvVar = (envVar: SeamCliEnvVar): string | null => { +const readEnvVar = (envVar: CliEnvVar): string | null => { const value = process.env[envVar] if (value == null) return null diff --git a/src/lib/http/api.ts b/src/lib/http/api.ts index 19759b82..b355d458 100644 --- a/src/lib/http/api.ts +++ b/src/lib/http/api.ts @@ -1,6 +1,7 @@ import type { SeamHttp } from '@seamapi/http/connect' -import type { AuthContext } from '../context.js' +import type { AuthContext } from 'lib/context.js' + import { getSeam } from './client.js' export interface SeamApiResponse { @@ -24,7 +25,7 @@ export interface SeamApi { } /** The only place `SeamHttp` appears for raw requests. */ -export class SeamHttpApi implements SeamApi { +export class HttpSeamApi implements SeamApi { constructor(private readonly seam: SeamHttp) {} post = async ( @@ -40,5 +41,5 @@ export class SeamHttpApi implements SeamApi { export const createSeamApi = async (auth?: AuthContext): Promise => { const seam = await getSeam(auth) - return new SeamHttpApi(seam) + return new HttpSeamApi(seam) } diff --git a/src/lib/http/client.ts b/src/lib/http/client.ts index d644015e..3e8d2efb 100644 --- a/src/lib/http/client.ts +++ b/src/lib/http/client.ts @@ -5,8 +5,8 @@ import { SeamHttpWithoutWorkspace, } from '@seamapi/http/connect' -import { type AuthContext, resolveAuth } from '../context.js' -import { tokenEnvVar, workspaceIdEnvVar } from '../env.js' +import { type AuthContext, resolveAuth } from 'lib/context.js' +import { tokenEnvVar, workspaceIdEnvVar } from 'lib/env.js' export const getSeam = async ( auth: AuthContext = resolveAuth(), diff --git a/src/lib/http/follow-ups.ts b/src/lib/http/follow-ups.ts index 3428329a..8e4608bf 100644 --- a/src/lib/http/follow-ups.ts +++ b/src/lib/http/follow-ups.ts @@ -1,7 +1,7 @@ -import type { CliContext } from '../context.js' -import { isInsideWebBrowser } from '../env.js' -import { interactForActionAttemptPoll } from '../interact/interact-for-action-attempt-poll.js' -import { promptConfirm } from '../interact/prompt.js' +import type { CliContext } from 'lib/context.js' +import { isInsideWebBrowser } from 'lib/env.js' +import { interactForActionAttemptPoll } from 'lib/interact/interact-for-action-attempt-poll.js' +import { promptConfirm } from 'lib/interact/prompt.js' /** * Follow-ups a response may call for: opening a connect webview in the diff --git a/src/lib/http/request.ts b/src/lib/http/request.ts index 2bda3fe3..6d709f4c 100644 --- a/src/lib/http/request.ts +++ b/src/lib/http/request.ts @@ -1,8 +1,9 @@ import chalk from 'chalk' -import type { Output } from '../output/create-output.js' -import { selectResponsePayload } from '../output/select-response-payload.js' -import { withLoading } from '../output/with-loading.js' +import type { Output } from 'lib/output/create-output.js' +import { selectResponsePayload } from 'lib/output/select-response-payload.js' +import { withLoading } from 'lib/output/with-loading.js' + import type { SeamApi, SeamApiResponse } from './api.js' export interface RequestSeamApiOptions { diff --git a/src/lib/interact/interact-for-access-code.ts b/src/lib/interact/interact-for-access-code.ts index 26be2b68..8fc2a212 100644 --- a/src/lib/interact/interact-for-access-code.ts +++ b/src/lib/interact/interact-for-access-code.ts @@ -1,4 +1,5 @@ -import { getSeam } from '../http/client.js' +import { getSeam } from 'lib/http/client.js' + import { interactForDevice } from './interact-for-device.js' import { interactForResource } from './interact-for-resource.js' diff --git a/src/lib/interact/interact-for-acs-entrance.ts b/src/lib/interact/interact-for-acs-entrance.ts index 988fbcbc..9a524fba 100644 --- a/src/lib/interact/interact-for-acs-entrance.ts +++ b/src/lib/interact/interact-for-acs-entrance.ts @@ -1,4 +1,5 @@ -import { getSeam } from '../http/client.js' +import { getSeam } from 'lib/http/client.js' + import { interactForResource } from './interact-for-resource.js' export const interactForAcsEntrance = async () => { diff --git a/src/lib/interact/interact-for-acs-system.ts b/src/lib/interact/interact-for-acs-system.ts index 6110f337..610e4a4e 100644 --- a/src/lib/interact/interact-for-acs-system.ts +++ b/src/lib/interact/interact-for-acs-system.ts @@ -1,4 +1,5 @@ -import { getSeam } from '../http/client.js' +import { getSeam } from 'lib/http/client.js' + import { interactForResource } from './interact-for-resource.js' export const interactForAcsSystem = async (message?: string) => { diff --git a/src/lib/interact/interact-for-acs-user.ts b/src/lib/interact/interact-for-acs-user.ts index 49192f7e..82a41b73 100644 --- a/src/lib/interact/interact-for-acs-user.ts +++ b/src/lib/interact/interact-for-acs-user.ts @@ -1,4 +1,5 @@ -import { getSeam } from '../http/client.js' +import { getSeam } from 'lib/http/client.js' + import { interactForAcsSystem } from './interact-for-acs-system.js' import { interactForResource } from './interact-for-resource.js' diff --git a/src/lib/interact/interact-for-action-attempt-poll.ts b/src/lib/interact/interact-for-action-attempt-poll.ts index 23b58aa9..2d437b09 100644 --- a/src/lib/interact/interact-for-action-attempt-poll.ts +++ b/src/lib/interact/interact-for-action-attempt-poll.ts @@ -1,8 +1,8 @@ import type { ActionAttemptsGetResponse } from '@seamapi/http/connect' -import { getSeam } from '../http/client.js' -import { getOutput } from '../output/get-output.js' -import { withLoading } from '../output/with-loading.js' +import { getSeam } from 'lib/http/client.js' +import { getOutput } from 'lib/output/get-output.js' +import { withLoading } from 'lib/output/with-loading.js' import { promptConfirm } from './prompt.js' export const interactForActionAttemptPoll = async ( diff --git a/src/lib/interact/interact-for-array.ts b/src/lib/interact/interact-for-array.ts index 2acac41b..397d4149 100644 --- a/src/lib/interact/interact-for-array.ts +++ b/src/lib/interact/interact-for-array.ts @@ -1,5 +1,5 @@ -import { getOutput } from '../output/get-output.js' -import { PromptCancelledError } from '../errors.js' +import { getOutput } from 'lib/output/get-output.js' +import { PromptCancelledError } from 'lib/errors.js' import { promptNumber, promptSelect, diff --git a/src/lib/interact/interact-for-blueprint-object.ts b/src/lib/interact/interact-for-blueprint-object.ts index 39093df6..c8eed416 100644 --- a/src/lib/interact/interact-for-blueprint-object.ts +++ b/src/lib/interact/interact-for-blueprint-object.ts @@ -1,10 +1,10 @@ import type { Parameter } from '@seamapi/blueprint' -import { assertRequiredParams } from '../args/validate.js' -import type { CliContext } from '../context.js' -import { NonInteractiveError, PromptCancelledError } from '../errors.js' -import { getOutput } from '../output/get-output.js' -import { ellipsis } from '../render/text.js' +import { assertRequiredParams } from 'lib/args/validate.js' +import type { CliContext } from 'lib/context.js' +import { NonInteractiveError, PromptCancelledError } from 'lib/errors.js' +import { getOutput } from 'lib/output/get-output.js' +import { ellipsis } from 'lib/render/text.js' import { interactForAccessCode } from './interact-for-access-code.js' import { interactForAcsEntrance } from './interact-for-acs-entrance.js' import { interactForAcsSystem } from './interact-for-acs-system.js' diff --git a/src/lib/interact/interact-for-command-params.ts b/src/lib/interact/interact-for-command-params.ts index 382336fc..2220141c 100644 --- a/src/lib/interact/interact-for-command-params.ts +++ b/src/lib/interact/interact-for-command-params.ts @@ -1,5 +1,6 @@ -import { getCommandBlueprintDef } from '../blueprint/endpoint.js' -import type { CliContext } from '../context.js' +import { getCommandBlueprintDef } from 'lib/blueprint/endpoint.js' +import type { CliContext } from 'lib/context.js' + import { interactForBlueprintObject } from './interact-for-blueprint-object.js' export const interactForCommandParams = async ( diff --git a/src/lib/interact/interact-for-command-selection.ts b/src/lib/interact/interact-for-command-selection.ts index e068a910..e350a23e 100644 --- a/src/lib/interact/interact-for-command-selection.ts +++ b/src/lib/interact/interact-for-command-selection.ts @@ -1,7 +1,7 @@ import { isDeepStrictEqual as isEqual } from 'node:util' -import type { Interactivity } from '../args/parse.js' -import { NonInteractiveError, PromptCancelledError } from '../errors.js' +import type { Interactivity } from 'lib/args/parse.js' +import { NonInteractiveError, PromptCancelledError } from 'lib/errors.js' import { promptAutocomplete, withBackHint } from './prompt.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { diff --git a/src/lib/interact/interact-for-connected-account.ts b/src/lib/interact/interact-for-connected-account.ts index ca0c4172..9d047f8b 100644 --- a/src/lib/interact/interact-for-connected-account.ts +++ b/src/lib/interact/interact-for-connected-account.ts @@ -1,4 +1,5 @@ -import { getSeam } from '../http/client.js' +import { getSeam } from 'lib/http/client.js' + import { interactForResource } from './interact-for-resource.js' export const interactForConnectedAccount = async () => { const seam = await getSeam() diff --git a/src/lib/interact/interact-for-custom-metadata.ts b/src/lib/interact/interact-for-custom-metadata.ts index bf62b6ed..ab6bc00a 100644 --- a/src/lib/interact/interact-for-custom-metadata.ts +++ b/src/lib/interact/interact-for-custom-metadata.ts @@ -1,5 +1,5 @@ -import { getOutput } from '../output/get-output.js' -import { PromptCancelledError } from '../errors.js' +import { getOutput } from 'lib/output/get-output.js' +import { PromptCancelledError } from 'lib/errors.js' import { promptSelect, promptText, withBackHint } from './prompt.js' // Structurally the CustomMetadata of @seamapi/types, spelled out here so the diff --git a/src/lib/interact/interact-for-device.ts b/src/lib/interact/interact-for-device.ts index 8e9fe23f..098da755 100644 --- a/src/lib/interact/interact-for-device.ts +++ b/src/lib/interact/interact-for-device.ts @@ -1,4 +1,5 @@ -import { getSeam } from '../http/client.js' +import { getSeam } from 'lib/http/client.js' + import { interactForResource } from './interact-for-resource.js' export const interactForDevice = async () => { const seam = await getSeam() diff --git a/src/lib/interact/interact-for-login.ts b/src/lib/interact/interact-for-login.ts index e4ac4ce7..69bc5d42 100644 --- a/src/lib/interact/interact-for-login.ts +++ b/src/lib/interact/interact-for-login.ts @@ -1,13 +1,13 @@ import { isApiKey, isPersonalAccessToken } from '@seamapi/http/connect' import chalk from 'chalk' -import { assertMutable, storeToken } from '../auth/operations.js' -import { validateToken } from '../auth/validate-token.js' -import { getConfigStore } from '../config/index.js' -import { resolveAuth } from '../context.js' -import { getOutput } from '../output/get-output.js' +import { assertMutable, storeToken } from 'lib/auth/operations.js' +import { validateToken } from 'lib/auth/validate-token.js' +import { getConfigStore } from 'lib/config/index.js' +import { resolveAuth } from 'lib/context.js' +import { getOutput } from 'lib/output/get-output.js' import { promptText } from './prompt.js' -import { withLoading } from '../output/with-loading.js' +import { withLoading } from 'lib/output/with-loading.js' import { interactForWorkspaceId } from './interact-for-workspace-id.js' export const interactForLogin = async () => { diff --git a/src/lib/interact/interact-for-resource.ts b/src/lib/interact/interact-for-resource.ts index d14a1ba6..65bd4c6b 100644 --- a/src/lib/interact/interact-for-resource.ts +++ b/src/lib/interact/interact-for-resource.ts @@ -1,5 +1,5 @@ import { promptAutocomplete, withBackHint } from './prompt.js' -import { withLoading } from '../output/with-loading.js' +import { withLoading } from 'lib/output/with-loading.js' export interface ResourceChoice { title: string diff --git a/src/lib/interact/interact-for-server-selection.ts b/src/lib/interact/interact-for-server-selection.ts index 72017685..c169068b 100644 --- a/src/lib/interact/interact-for-server-selection.ts +++ b/src/lib/interact/interact-for-server-selection.ts @@ -4,10 +4,10 @@ import { assertMutable, selectFakeServer, selectServer, -} from '../auth/operations.js' -import { getConfigStore } from '../config/index.js' -import { resolveAuth } from '../context.js' -import { getOutput } from '../output/get-output.js' +} from 'lib/auth/operations.js' +import { getConfigStore } from 'lib/config/index.js' +import { resolveAuth } from 'lib/context.js' +import { getOutput } from 'lib/output/get-output.js' import { promptAutocomplete, promptText } from './prompt.js' export async function interactForServerSelection() { diff --git a/src/lib/interact/interact-for-use-remote-api-defs.ts b/src/lib/interact/interact-for-use-remote-api-defs.ts index 85d9817f..ca0817f0 100644 --- a/src/lib/interact/interact-for-use-remote-api-defs.ts +++ b/src/lib/interact/interact-for-use-remote-api-defs.ts @@ -1,5 +1,5 @@ -import { setUseRemoteApiDefs } from '../auth/operations.js' -import { getOutput } from '../output/get-output.js' +import { setUseRemoteApiDefs } from 'lib/auth/operations.js' +import { getOutput } from 'lib/output/get-output.js' import { promptSelect } from './prompt.js' export async function interactForUseRemoteApiDefs() { diff --git a/src/lib/interact/interact-for-user-identity.ts b/src/lib/interact/interact-for-user-identity.ts index af11b6ed..6767c5e4 100644 --- a/src/lib/interact/interact-for-user-identity.ts +++ b/src/lib/interact/interact-for-user-identity.ts @@ -1,4 +1,5 @@ -import { getSeam } from '../http/client.js' +import { getSeam } from 'lib/http/client.js' + import { interactForResource } from './interact-for-resource.js' export const interactForUserIdentity = async () => { diff --git a/src/lib/interact/interact-for-workspace-id.ts b/src/lib/interact/interact-for-workspace-id.ts index 92e2882d..f210a993 100644 --- a/src/lib/interact/interact-for-workspace-id.ts +++ b/src/lib/interact/interact-for-workspace-id.ts @@ -1,10 +1,10 @@ import { SeamHttpWithoutWorkspace } from '@seamapi/http/connect' -import { assertMutable, selectWorkspace } from '../auth/operations.js' -import { getConfigStore } from '../config/index.js' -import { resolveAuth } from '../context.js' -import { getSeamMultiWorkspace } from '../http/client.js' -import { withLoading } from '../output/with-loading.js' +import { assertMutable, selectWorkspace } from 'lib/auth/operations.js' +import { getConfigStore } from 'lib/config/index.js' +import { resolveAuth } from 'lib/context.js' +import { getSeamMultiWorkspace } from 'lib/http/client.js' +import { withLoading } from 'lib/output/with-loading.js' import { promptAutocomplete } from './prompt.js' export const interactForWorkspaceId = async (personalAccessToken?: string) => { diff --git a/src/lib/interact/prompt.ts b/src/lib/interact/prompt.ts index 09bed47c..48af2e9a 100644 --- a/src/lib/interact/prompt.ts +++ b/src/lib/interact/prompt.ts @@ -12,7 +12,7 @@ import { } from '@clack/prompts' import chalk from 'chalk' -import { NonInteractiveError, PromptCancelledError } from '../errors.js' +import { NonInteractiveError, PromptCancelledError } from 'lib/errors.js' export interface PromptChoice { label: string diff --git a/src/lib/render/completion/describe.ts b/src/lib/render/completion/describe.ts index 18bea988..f66482d7 100644 --- a/src/lib/render/completion/describe.ts +++ b/src/lib/render/completion/describe.ts @@ -1,4 +1,4 @@ -import { ellipsis, firstSentence } from '../text.js' +import { ellipsis, firstSentence } from 'lib/render/text.js' const maxDescriptionLength = 72 diff --git a/src/lib/render/completion/index.ts b/src/lib/render/completion/index.ts index 0c0cf744..c032f6d7 100644 --- a/src/lib/render/completion/index.ts +++ b/src/lib/render/completion/index.ts @@ -1,4 +1,5 @@ -import type { CommandSpec } from '../../commands/spec.js' +import type { CommandSpec } from 'lib/commands/spec.js' + import { renderBashCompletion } from './render-bash.js' import { renderFishCompletion } from './render-fish.js' import { renderZshCompletion } from './render-zsh.js' diff --git a/src/lib/render/completion/render-bash.ts b/src/lib/render/completion/render-bash.ts index e00ce8ab..fcabd6e7 100644 --- a/src/lib/render/completion/render-bash.ts +++ b/src/lib/render/completion/render-bash.ts @@ -2,7 +2,7 @@ import { type CommandFlag, type CommandSpec, flagTokens, -} from '../../commands/spec.js' +} from 'lib/commands/spec.js' export const renderBashCompletion = (spec: CommandSpec): string => { const globalTokens = spec.globalFlags.flatMap(flagTokens).sort() diff --git a/src/lib/render/completion/render-fish.ts b/src/lib/render/completion/render-fish.ts index b7591fbb..a76ab079 100644 --- a/src/lib/render/completion/render-fish.ts +++ b/src/lib/render/completion/render-fish.ts @@ -1,4 +1,5 @@ -import type { CommandFlag, CommandSpec } from '../../commands/spec.js' +import type { CommandFlag, CommandSpec } from 'lib/commands/spec.js' + import { describeForShell } from './describe.js' export const renderFishCompletion = (spec: CommandSpec): string => diff --git a/src/lib/render/completion/render-zsh.ts b/src/lib/render/completion/render-zsh.ts index 8ece3bd4..f7474ff9 100644 --- a/src/lib/render/completion/render-zsh.ts +++ b/src/lib/render/completion/render-zsh.ts @@ -2,7 +2,8 @@ import { type CommandFlag, type CommandSpec, flagTokens, -} from '../../commands/spec.js' +} from 'lib/commands/spec.js' + import { describeForShell } from './describe.js' export const renderZshCompletion = (spec: CommandSpec): string => { diff --git a/src/lib/render/help.ts b/src/lib/render/help.ts index 0f6207dd..7228f58d 100644 --- a/src/lib/render/help.ts +++ b/src/lib/render/help.ts @@ -7,7 +7,7 @@ import { type CommandSpec, findCommand, findGroup, -} from '../commands/spec.js' +} from 'lib/commands/spec.js' /** * Render the help guide for a command path, or `null` when no command or diff --git a/test/commands/registry.test.ts b/test/commands/registry.test.ts index f2db4b92..2d5e84c4 100644 --- a/test/commands/registry.test.ts +++ b/test/commands/registry.test.ts @@ -6,8 +6,7 @@ import { findLocalCommand, localCommands, } from 'lib/commands/registry.js' - -import { testBlueprint } from '../fixtures/blueprint.js' +import { testBlueprint } from 'test/fixtures/blueprint.js' const registry = buildRegistry(testBlueprint) diff --git a/test/commands/spec.test.ts b/test/commands/spec.test.ts index 97998a8b..35cb802f 100644 --- a/test/commands/spec.test.ts +++ b/test/commands/spec.test.ts @@ -2,8 +2,7 @@ import { expect, test } from 'vitest' import { localCommandDefinitions } from 'lib/commands/registry.js' import { findCommand, findGroup, getCommandSpec } from 'lib/commands/spec.js' - -import { testBlueprint } from '../fixtures/blueprint.js' +import { testBlueprint } from 'test/fixtures/blueprint.js' const spec = getCommandSpec(testBlueprint, localCommandDefinitions) diff --git a/test/render/completion.test.ts b/test/render/completion.test.ts index 6ab1fc0c..3b494fb6 100644 --- a/test/render/completion.test.ts +++ b/test/render/completion.test.ts @@ -9,8 +9,7 @@ import { renderCompletion, renderCompletionStub, } from 'lib/render/completion/index.js' - -import { testBlueprint } from '../fixtures/blueprint.js' +import { testBlueprint } from 'test/fixtures/blueprint.js' const { spec } = buildRegistry(testBlueprint) diff --git a/test/render/help.test.ts b/test/render/help.test.ts index 90f13c9c..3525ec5d 100644 --- a/test/render/help.test.ts +++ b/test/render/help.test.ts @@ -2,8 +2,7 @@ import { expect, test } from 'vitest' import { buildRegistry } from 'lib/commands/registry.js' import { renderHelp } from 'lib/render/help.js' - -import { testBlueprint } from '../fixtures/blueprint.js' +import { testBlueprint } from 'test/fixtures/blueprint.js' const { spec } = buildRegistry(testBlueprint) diff --git a/tsconfig.json b/tsconfig.json index ac990992..695324a3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,8 @@ "types": ["node"], "paths": { "@seamapi/cli": ["./src/index.ts"], - "lib/*": ["./src/lib/*"] + "lib/*": ["./src/lib/*"], + "test/*": ["./test/*"] } }, "files": ["src/index.ts", "src/bin/cli.ts"], diff --git a/vitest.config.ts b/vitest.config.ts index 06a056bc..1b7bfd5e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ alias: { '@seamapi/cli': new URL('./src/index.ts', import.meta.url).pathname, lib: new URL('./src/lib', import.meta.url).pathname, + test: new URL('./test', import.meta.url).pathname, }, }, test: { From a897f18720ce27cdb4f9c2ec83db76df9309394f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 05:58:39 +0000 Subject: [PATCH 17/20] style: Name files after what they export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interact/interact-for-device.ts said interact three times before the noun; the directory is now interactions/ and each file is just the noun: interactions/device.ts, interactions/command-selection.ts, and so on. The interactForFoo function names stay — they read as verbs at the call site. Likewise the create- prefix comes off module names: memory-output.ts, memory-config-store.ts, memory-prompt.ts, and memory-seam-api.ts each export their interface or class and its createFoo factory, and create-output.ts is simply output.ts, home of the Output interface and StreamOutput. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- TESTING.md | 10 +++++----- src/bin/cli.ts | 6 +++--- src/lib/commands/api-command.ts | 2 +- .../local/config-use-remote-api-defs.ts | 2 +- src/lib/commands/local/login.ts | 2 +- src/lib/commands/local/select-server.ts | 2 +- src/lib/commands/local/select-workspace.ts | 2 +- src/lib/config/config-store.ts | 2 +- src/lib/config/index.ts | 2 +- ...config-store.ts => memory-config-store.ts} | 0 src/lib/context.ts | 2 +- src/lib/errors.ts | 2 +- src/lib/http/follow-ups.ts | 4 ++-- ...-memory-seam-api.ts => memory-seam-api.ts} | 0 src/lib/http/request.ts | 2 +- .../access-code.ts} | 4 ++-- .../acs-entrance.ts} | 2 +- .../acs-system.ts} | 2 +- .../acs-user.ts} | 4 ++-- .../action-attempt-poll.ts} | 0 .../array.ts} | 0 .../blueprint-object.ts} | 20 +++++++++---------- .../command-params.ts} | 2 +- .../command-selection.ts} | 0 .../connected-account.ts} | 2 +- .../custom-metadata.ts} | 0 .../device.ts} | 2 +- .../login.ts} | 2 +- .../memory-prompt.ts} | 0 .../{interact => interactions}/prompt.test.ts | 0 src/lib/{interact => interactions}/prompt.ts | 2 +- .../resource.ts} | 0 .../server-selection.ts} | 0 .../timestamp.ts} | 0 .../use-remote-api-defs.ts} | 0 .../user-identity.ts} | 2 +- .../workspace-id.ts} | 0 src/lib/output/get-output.ts | 2 +- ...eate-memory-output.ts => memory-output.ts} | 2 +- .../{create-output.test.ts => output.test.ts} | 2 +- .../output/{create-output.ts => output.ts} | 0 src/lib/output/resolve-output-format.ts | 2 +- test/auth/operations.test.ts | 2 +- test/context.test.ts | 2 +- test/http/request.test.ts | 4 ++-- .../blueprint-object.test.ts} | 8 ++++---- .../command-selection.test.ts} | 6 +++--- .../custom-metadata.test.ts} | 8 ++++---- 48 files changed, 61 insertions(+), 61 deletions(-) rename src/lib/config/{create-memory-config-store.ts => memory-config-store.ts} (100%) rename src/lib/http/{create-memory-seam-api.ts => memory-seam-api.ts} (100%) rename src/lib/{interact/interact-for-access-code.ts => interactions/access-code.ts} (85%) rename src/lib/{interact/interact-for-acs-entrance.ts => interactions/acs-entrance.ts} (86%) rename src/lib/{interact/interact-for-acs-system.ts => interactions/acs-system.ts} (87%) rename src/lib/{interact/interact-for-acs-user.ts => interactions/acs-user.ts} (80%) rename src/lib/{interact/interact-for-action-attempt-poll.ts => interactions/action-attempt-poll.ts} (100%) rename src/lib/{interact/interact-for-array.ts => interactions/array.ts} (100%) rename src/lib/{interact/interact-for-blueprint-object.ts => interactions/blueprint-object.ts} (92%) rename src/lib/{interact/interact-for-command-params.ts => interactions/command-params.ts} (86%) rename src/lib/{interact/interact-for-command-selection.ts => interactions/command-selection.ts} (100%) rename src/lib/{interact/interact-for-connected-account.ts => interactions/connected-account.ts} (92%) rename src/lib/{interact/interact-for-custom-metadata.ts => interactions/custom-metadata.ts} (100%) rename src/lib/{interact/interact-for-device.ts => interactions/device.ts} (86%) rename src/lib/{interact/interact-for-login.ts => interactions/login.ts} (96%) rename src/lib/{interact/create-memory-prompt.ts => interactions/memory-prompt.ts} (100%) rename src/lib/{interact => interactions}/prompt.test.ts (100%) rename src/lib/{interact => interactions}/prompt.ts (99%) rename src/lib/{interact/interact-for-resource.ts => interactions/resource.ts} (100%) rename src/lib/{interact/interact-for-server-selection.ts => interactions/server-selection.ts} (100%) rename src/lib/{interact/interact-for-timestamp.ts => interactions/timestamp.ts} (100%) rename src/lib/{interact/interact-for-use-remote-api-defs.ts => interactions/use-remote-api-defs.ts} (100%) rename src/lib/{interact/interact-for-user-identity.ts => interactions/user-identity.ts} (88%) rename src/lib/{interact/interact-for-workspace-id.ts => interactions/workspace-id.ts} (100%) rename src/lib/output/{create-memory-output.ts => memory-output.ts} (97%) rename src/lib/output/{create-output.test.ts => output.test.ts} (96%) rename src/lib/output/{create-output.ts => output.ts} (100%) rename test/{interact/interact-for-blueprint-object.test.ts => interactions/blueprint-object.test.ts} (96%) rename test/{interact/interact-for-command-selection.test.ts => interactions/command-selection.test.ts} (92%) rename test/{interact/interact-for-custom-metadata.test.ts => interactions/custom-metadata.test.ts} (87%) diff --git a/TESTING.md b/TESTING.md index 31c6fa07..54d99c97 100644 --- a/TESTING.md +++ b/TESTING.md @@ -49,8 +49,8 @@ the fake goes. | Module kind | The tell | Default test | Gets faked | Never faked | | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------- | | **Pure transform** — `render/help`, `render/completion/render-*`, `output/select-response-payload`, `args/parse` | Value in → value out; no I/O imports | Classical unit, real values | Nothing | Anything | -| **Decision over injected data** — `interact-for-command-selection` (non-interactive), `blueprint/endpoint`, `context.ts` | Takes `CliContext` / blueprint / config store as a parameter | Classical with a literal ctx object (`interact-for-command-selection.test.ts` is the model) | Nothing — a hand-built blueprint literal is a fixture, not a fake | The traversal/decision logic | -| **Prompt flow** — `interact/interact-for-*` importing `interact/prompt.js` | Imports `interact/prompt.js` | Classical on the returned value, memory output, scripted prompt fake; assert the choice list _offered_ where the prompt is the UX | The prompt layer (the whole `prompts` edge), output | The module's own branching and param assembly | +| **Decision over injected data** — `interact-for-command-selection` (non-interactive), `blueprint/endpoint`, `context.ts` | Takes `CliContext` / blueprint / config store as a parameter | Classical with a literal ctx object (`command-selection.test.ts` is the model) | Nothing — a hand-built blueprint literal is a fixture, not a fake | The traversal/decision logic | +| **Prompt flow** — `interactions/*` importing `interactions/prompt.js` | Imports `interactions/prompt.js` | Classical on the returned value, memory output, scripted prompt fake; assert the choice list _offered_ where the prompt is the UX | The prompt layer (the whole `prompts` edge), output | The module's own branching and param assembly | | **Config & state** — `config/config-store`, `config/migrate` | Touches `Configstore` / `env-paths` | Classical against a real store in a temp directory — it's a JSON file, and split/merge/migration _is_ the behavior | The directory; env vars (`vi.stubEnv`) | `Configstore` or fs behavior | | **Network** — `http/request`, `auth/validate-token`, `blueprint/source-npm` | Constructs `SeamHttp` or calls `fetch` | Classical against a fake port (or a stubbed global `fetch` with captured requests, as `blueprint/source-npm.test.ts` does); assert the payload sent _and_ the value returned | The `SeamApi` port / global `fetch` | Status handling, payload selection, formatting — that's the unit | | **Orchestration** — `bin/cli.ts` | Reads argv/env, wires everything | E2e: spawn via `execa`, `node:http` fake server, XDG temp dirs (`test/cli.test.ts`) | The far end of the wire; the home directories | Anything in-process | @@ -72,7 +72,7 @@ itself the user-observable contract** — when the message crosses a process boundary. "We sent this request body to `/devices/list`" is behavior: the request is the product. "The prompt offered these choices with these hints" is behavior: the choices are what the user sees -(`interact-for-blueprint-object.test.ts` asserting on the recorded `choices` +(`blueprint-object.test.ts` asserting on the recorded `choices` is the good in-repo example). "`resolveAuth` called `getConfigStore`" is implementation: the contract is _what server comes back_, not how it was looked up. @@ -137,7 +137,7 @@ The fake is the in-process mirror of the e2e server — a routes table plus a capture: ```ts -// src/lib/http/create-memory-seam-api.ts +// src/lib/http/memory-seam-api.ts export class MemorySeamApi implements SeamApi { readonly requests: Array<{ path: string; params: Record }> = [] @@ -181,7 +181,7 @@ expect(memory.stdout()).toContain('invalid_input') expect(process.exitCode).toBe(1) ``` -`auth/validate-token.ts` and the resource pickers (`interact/interact-for-device.ts` +`auth/validate-token.ts` and the resource pickers (`interactions/device.ts` and friends) use typed SDK methods and stay on the real SDK, covered by e2e — don't invent a second port for them. diff --git a/src/bin/cli.ts b/src/bin/cli.ts index de0f8235..567b74b7 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -24,10 +24,10 @@ import { type CliContext, resolveAuth } from 'lib/context.js' import { tokenEnvVar } from 'lib/env.js' import { reportErrorAndExit } from 'lib/errors.js' import { createSeamApi, type SeamApi } from 'lib/http/api.js' -import { interactForCommandSelection } from 'lib/interact/interact-for-command-selection.js' -import { canPrompt } from 'lib/interact/prompt.js' -import { createOutput } from 'lib/output/create-output.js' +import { interactForCommandSelection } from 'lib/interactions/command-selection.js' +import { canPrompt } from 'lib/interactions/prompt.js' import { getOutput, setOutput } from 'lib/output/get-output.js' +import { createOutput } from 'lib/output/output.js' import { readStdinJson } from 'lib/output/read-stdin-json.js' import { resolveOutputFormat } from 'lib/output/resolve-output-format.js' import { diff --git a/src/lib/commands/api-command.ts b/src/lib/commands/api-command.ts index 77fb32ed..0123f7d5 100644 --- a/src/lib/commands/api-command.ts +++ b/src/lib/commands/api-command.ts @@ -11,7 +11,7 @@ import type { CliContext } from 'lib/context.js' import { UsageError } from 'lib/errors.js' import { runResponseFollowUps } from 'lib/http/follow-ups.js' import { requestSeamApi } from 'lib/http/request.js' -import { interactForCommandParams } from 'lib/interact/interact-for-command-params.js' +import { interactForCommandParams } from 'lib/interactions/command-params.js' import type { CommandResult, Invocation } from './registry.js' diff --git a/src/lib/commands/local/config-use-remote-api-defs.ts b/src/lib/commands/local/config-use-remote-api-defs.ts index 74119fcf..07670a5a 100644 --- a/src/lib/commands/local/config-use-remote-api-defs.ts +++ b/src/lib/commands/local/config-use-remote-api-defs.ts @@ -1,6 +1,6 @@ import type { Command } from 'lib/commands/registry.js' import { NonInteractiveError } from 'lib/errors.js' -import { interactForUseRemoteApiDefs } from 'lib/interact/interact-for-use-remote-api-defs.js' +import { interactForUseRemoteApiDefs } from 'lib/interactions/use-remote-api-defs.js' export const configUseRemoteApiDefsCommand: Command = { definition: { diff --git a/src/lib/commands/local/login.ts b/src/lib/commands/local/login.ts index 44e51425..23287030 100644 --- a/src/lib/commands/local/login.ts +++ b/src/lib/commands/local/login.ts @@ -2,7 +2,7 @@ import { assertMutable, login } from 'lib/auth/operations.js' import type { Command } from 'lib/commands/registry.js' import { stringFlag } from 'lib/commands/spec.js' import { NonInteractiveError } from 'lib/errors.js' -import { interactForLogin } from 'lib/interact/interact-for-login.js' +import { interactForLogin } from 'lib/interactions/login.js' export const loginCommand: Command = { definition: { diff --git a/src/lib/commands/local/select-server.ts b/src/lib/commands/local/select-server.ts index 795eec73..ee8f3c41 100644 --- a/src/lib/commands/local/select-server.ts +++ b/src/lib/commands/local/select-server.ts @@ -2,7 +2,7 @@ import { assertMutable, selectServer } from 'lib/auth/operations.js' import type { Command } from 'lib/commands/registry.js' import { stringFlag } from 'lib/commands/spec.js' import { NonInteractiveError } from 'lib/errors.js' -import { interactForServerSelection } from 'lib/interact/interact-for-server-selection.js' +import { interactForServerSelection } from 'lib/interactions/server-selection.js' export const selectServerCommand: Command = { definition: { diff --git a/src/lib/commands/local/select-workspace.ts b/src/lib/commands/local/select-workspace.ts index 798217c2..45cb2ad1 100644 --- a/src/lib/commands/local/select-workspace.ts +++ b/src/lib/commands/local/select-workspace.ts @@ -1,7 +1,7 @@ import { assertMutable } from 'lib/auth/operations.js' import type { Command } from 'lib/commands/registry.js' import { NonInteractiveError } from 'lib/errors.js' -import { interactForWorkspaceId } from 'lib/interact/interact-for-workspace-id.js' +import { interactForWorkspaceId } from 'lib/interactions/workspace-id.js' export const selectWorkspaceCommand: Command = { definition: { diff --git a/src/lib/config/config-store.ts b/src/lib/config/config-store.ts index bd3cf955..d53ff2a6 100644 --- a/src/lib/config/config-store.ts +++ b/src/lib/config/config-store.ts @@ -15,7 +15,7 @@ const paths = envPaths('seam', { suffix: '' }) * What a config store can do, regardless of where it keeps the values. * * The CLI reads and writes through this interface so a test may hand code an - * in-memory store (see `create-memory-config-store.ts`) instead of the real + * in-memory store (see `memory-config-store.ts`) instead of the real * file-backed one. */ export interface ConfigStore { diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index a197928c..380110c5 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -5,4 +5,4 @@ export { resetConfigStore, setConfigStore, } from './config-store.js' -export { createMemoryConfigStore } from './create-memory-config-store.js' +export { createMemoryConfigStore } from './memory-config-store.js' diff --git a/src/lib/config/create-memory-config-store.ts b/src/lib/config/memory-config-store.ts similarity index 100% rename from src/lib/config/create-memory-config-store.ts rename to src/lib/config/memory-config-store.ts diff --git a/src/lib/context.ts b/src/lib/context.ts index fd68ce20..5bef8eb6 100644 --- a/src/lib/context.ts +++ b/src/lib/context.ts @@ -7,7 +7,7 @@ import { getWorkspaceIdFromEnv, } from './env.js' import type { SeamApi } from './http/api.js' -import type { Output } from './output/create-output.js' +import type { Output } from './output/output.js' export const defaultServer = 'https://connect.getseam.com' diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 54bc22d5..f9d930fc 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,7 +1,7 @@ import chalk from 'chalk' import { EnvVarOverrideError } from './env.js' -import type { Output } from './output/create-output.js' +import type { Output } from './output/output.js' /** * Thrown when the CLI needs input it cannot prompt for. diff --git a/src/lib/http/follow-ups.ts b/src/lib/http/follow-ups.ts index 8e4608bf..d2e7bade 100644 --- a/src/lib/http/follow-ups.ts +++ b/src/lib/http/follow-ups.ts @@ -1,7 +1,7 @@ import type { CliContext } from 'lib/context.js' import { isInsideWebBrowser } from 'lib/env.js' -import { interactForActionAttemptPoll } from 'lib/interact/interact-for-action-attempt-poll.js' -import { promptConfirm } from 'lib/interact/prompt.js' +import { interactForActionAttemptPoll } from 'lib/interactions/action-attempt-poll.js' +import { promptConfirm } from 'lib/interactions/prompt.js' /** * Follow-ups a response may call for: opening a connect webview in the diff --git a/src/lib/http/create-memory-seam-api.ts b/src/lib/http/memory-seam-api.ts similarity index 100% rename from src/lib/http/create-memory-seam-api.ts rename to src/lib/http/memory-seam-api.ts diff --git a/src/lib/http/request.ts b/src/lib/http/request.ts index 6d709f4c..f0b4d7d8 100644 --- a/src/lib/http/request.ts +++ b/src/lib/http/request.ts @@ -1,6 +1,6 @@ import chalk from 'chalk' -import type { Output } from 'lib/output/create-output.js' +import type { Output } from 'lib/output/output.js' import { selectResponsePayload } from 'lib/output/select-response-payload.js' import { withLoading } from 'lib/output/with-loading.js' diff --git a/src/lib/interact/interact-for-access-code.ts b/src/lib/interactions/access-code.ts similarity index 85% rename from src/lib/interact/interact-for-access-code.ts rename to src/lib/interactions/access-code.ts index 8fc2a212..198df95d 100644 --- a/src/lib/interact/interact-for-access-code.ts +++ b/src/lib/interactions/access-code.ts @@ -1,7 +1,7 @@ import { getSeam } from 'lib/http/client.js' -import { interactForDevice } from './interact-for-device.js' -import { interactForResource } from './interact-for-resource.js' +import { interactForDevice } from './device.js' +import { interactForResource } from './resource.js' export const interactForAccessCode = async ({ // The key is a Seam API parameter name: callers pass the blueprint params diff --git a/src/lib/interact/interact-for-acs-entrance.ts b/src/lib/interactions/acs-entrance.ts similarity index 86% rename from src/lib/interact/interact-for-acs-entrance.ts rename to src/lib/interactions/acs-entrance.ts index 9a524fba..0e08533b 100644 --- a/src/lib/interact/interact-for-acs-entrance.ts +++ b/src/lib/interactions/acs-entrance.ts @@ -1,6 +1,6 @@ import { getSeam } from 'lib/http/client.js' -import { interactForResource } from './interact-for-resource.js' +import { interactForResource } from './resource.js' export const interactForAcsEntrance = async () => { const seam = await getSeam() diff --git a/src/lib/interact/interact-for-acs-system.ts b/src/lib/interactions/acs-system.ts similarity index 87% rename from src/lib/interact/interact-for-acs-system.ts rename to src/lib/interactions/acs-system.ts index 610e4a4e..ef4cff7f 100644 --- a/src/lib/interact/interact-for-acs-system.ts +++ b/src/lib/interactions/acs-system.ts @@ -1,6 +1,6 @@ import { getSeam } from 'lib/http/client.js' -import { interactForResource } from './interact-for-resource.js' +import { interactForResource } from './resource.js' export const interactForAcsSystem = async (message?: string) => { const seam = await getSeam() diff --git a/src/lib/interact/interact-for-acs-user.ts b/src/lib/interactions/acs-user.ts similarity index 80% rename from src/lib/interact/interact-for-acs-user.ts rename to src/lib/interactions/acs-user.ts index 82a41b73..c74409db 100644 --- a/src/lib/interact/interact-for-acs-user.ts +++ b/src/lib/interactions/acs-user.ts @@ -1,7 +1,7 @@ import { getSeam } from 'lib/http/client.js' -import { interactForAcsSystem } from './interact-for-acs-system.js' -import { interactForResource } from './interact-for-resource.js' +import { interactForAcsSystem } from './acs-system.js' +import { interactForResource } from './resource.js' export const interactForAcsUser = async () => { const seam = await getSeam() diff --git a/src/lib/interact/interact-for-action-attempt-poll.ts b/src/lib/interactions/action-attempt-poll.ts similarity index 100% rename from src/lib/interact/interact-for-action-attempt-poll.ts rename to src/lib/interactions/action-attempt-poll.ts diff --git a/src/lib/interact/interact-for-array.ts b/src/lib/interactions/array.ts similarity index 100% rename from src/lib/interact/interact-for-array.ts rename to src/lib/interactions/array.ts diff --git a/src/lib/interact/interact-for-blueprint-object.ts b/src/lib/interactions/blueprint-object.ts similarity index 92% rename from src/lib/interact/interact-for-blueprint-object.ts rename to src/lib/interactions/blueprint-object.ts index c8eed416..27a761ba 100644 --- a/src/lib/interact/interact-for-blueprint-object.ts +++ b/src/lib/interactions/blueprint-object.ts @@ -5,16 +5,16 @@ import type { CliContext } from 'lib/context.js' import { NonInteractiveError, PromptCancelledError } from 'lib/errors.js' import { getOutput } from 'lib/output/get-output.js' import { ellipsis } from 'lib/render/text.js' -import { interactForAccessCode } from './interact-for-access-code.js' -import { interactForAcsEntrance } from './interact-for-acs-entrance.js' -import { interactForAcsSystem } from './interact-for-acs-system.js' -import { interactForAcsUser } from './interact-for-acs-user.js' -import { interactForArray } from './interact-for-array.js' -import { interactForConnectedAccount } from './interact-for-connected-account.js' -import { interactForCustomMetadata } from './interact-for-custom-metadata.js' -import { interactForDevice } from './interact-for-device.js' -import { interactForTimestamp } from './interact-for-timestamp.js' -import { interactForUserIdentity } from './interact-for-user-identity.js' +import { interactForAccessCode } from './access-code.js' +import { interactForAcsEntrance } from './acs-entrance.js' +import { interactForAcsSystem } from './acs-system.js' +import { interactForAcsUser } from './acs-user.js' +import { interactForArray } from './array.js' +import { interactForConnectedAccount } from './connected-account.js' +import { interactForCustomMetadata } from './custom-metadata.js' +import { interactForDevice } from './device.js' +import { interactForTimestamp } from './timestamp.js' +import { interactForUserIdentity } from './user-identity.js' import { promptAutocomplete, promptAutocompleteMultiselect, diff --git a/src/lib/interact/interact-for-command-params.ts b/src/lib/interactions/command-params.ts similarity index 86% rename from src/lib/interact/interact-for-command-params.ts rename to src/lib/interactions/command-params.ts index 2220141c..ff978a62 100644 --- a/src/lib/interact/interact-for-command-params.ts +++ b/src/lib/interactions/command-params.ts @@ -1,7 +1,7 @@ import { getCommandBlueprintDef } from 'lib/blueprint/endpoint.js' import type { CliContext } from 'lib/context.js' -import { interactForBlueprintObject } from './interact-for-blueprint-object.js' +import { interactForBlueprintObject } from './blueprint-object.js' export const interactForCommandParams = async ( args: { diff --git a/src/lib/interact/interact-for-command-selection.ts b/src/lib/interactions/command-selection.ts similarity index 100% rename from src/lib/interact/interact-for-command-selection.ts rename to src/lib/interactions/command-selection.ts diff --git a/src/lib/interact/interact-for-connected-account.ts b/src/lib/interactions/connected-account.ts similarity index 92% rename from src/lib/interact/interact-for-connected-account.ts rename to src/lib/interactions/connected-account.ts index 9d047f8b..7892bfd1 100644 --- a/src/lib/interact/interact-for-connected-account.ts +++ b/src/lib/interactions/connected-account.ts @@ -1,6 +1,6 @@ import { getSeam } from 'lib/http/client.js' -import { interactForResource } from './interact-for-resource.js' +import { interactForResource } from './resource.js' export const interactForConnectedAccount = async () => { const seam = await getSeam() diff --git a/src/lib/interact/interact-for-custom-metadata.ts b/src/lib/interactions/custom-metadata.ts similarity index 100% rename from src/lib/interact/interact-for-custom-metadata.ts rename to src/lib/interactions/custom-metadata.ts diff --git a/src/lib/interact/interact-for-device.ts b/src/lib/interactions/device.ts similarity index 86% rename from src/lib/interact/interact-for-device.ts rename to src/lib/interactions/device.ts index 098da755..72043e70 100644 --- a/src/lib/interact/interact-for-device.ts +++ b/src/lib/interactions/device.ts @@ -1,6 +1,6 @@ import { getSeam } from 'lib/http/client.js' -import { interactForResource } from './interact-for-resource.js' +import { interactForResource } from './resource.js' export const interactForDevice = async () => { const seam = await getSeam() diff --git a/src/lib/interact/interact-for-login.ts b/src/lib/interactions/login.ts similarity index 96% rename from src/lib/interact/interact-for-login.ts rename to src/lib/interactions/login.ts index 69bc5d42..93a02d1d 100644 --- a/src/lib/interact/interact-for-login.ts +++ b/src/lib/interactions/login.ts @@ -8,7 +8,7 @@ import { resolveAuth } from 'lib/context.js' import { getOutput } from 'lib/output/get-output.js' import { promptText } from './prompt.js' import { withLoading } from 'lib/output/with-loading.js' -import { interactForWorkspaceId } from './interact-for-workspace-id.js' +import { interactForWorkspaceId } from './workspace-id.js' export const interactForLogin = async () => { const config = getConfigStore() diff --git a/src/lib/interact/create-memory-prompt.ts b/src/lib/interactions/memory-prompt.ts similarity index 100% rename from src/lib/interact/create-memory-prompt.ts rename to src/lib/interactions/memory-prompt.ts diff --git a/src/lib/interact/prompt.test.ts b/src/lib/interactions/prompt.test.ts similarity index 100% rename from src/lib/interact/prompt.test.ts rename to src/lib/interactions/prompt.test.ts diff --git a/src/lib/interact/prompt.ts b/src/lib/interactions/prompt.ts similarity index 99% rename from src/lib/interact/prompt.ts rename to src/lib/interactions/prompt.ts index 48af2e9a..010b8cab 100644 --- a/src/lib/interact/prompt.ts +++ b/src/lib/interactions/prompt.ts @@ -49,7 +49,7 @@ export interface PromptSelectOptions { * asked, and how to ask each kind. * * A test replaces this with an in-memory client (see - * `create-memory-prompt.ts`) via {@link setPromptClient} — the code under + * `memory-prompt.ts`) via {@link setPromptClient} — the code under * test keeps calling `promptText` and friends as usual. */ export interface PromptClient { diff --git a/src/lib/interact/interact-for-resource.ts b/src/lib/interactions/resource.ts similarity index 100% rename from src/lib/interact/interact-for-resource.ts rename to src/lib/interactions/resource.ts diff --git a/src/lib/interact/interact-for-server-selection.ts b/src/lib/interactions/server-selection.ts similarity index 100% rename from src/lib/interact/interact-for-server-selection.ts rename to src/lib/interactions/server-selection.ts diff --git a/src/lib/interact/interact-for-timestamp.ts b/src/lib/interactions/timestamp.ts similarity index 100% rename from src/lib/interact/interact-for-timestamp.ts rename to src/lib/interactions/timestamp.ts diff --git a/src/lib/interact/interact-for-use-remote-api-defs.ts b/src/lib/interactions/use-remote-api-defs.ts similarity index 100% rename from src/lib/interact/interact-for-use-remote-api-defs.ts rename to src/lib/interactions/use-remote-api-defs.ts diff --git a/src/lib/interact/interact-for-user-identity.ts b/src/lib/interactions/user-identity.ts similarity index 88% rename from src/lib/interact/interact-for-user-identity.ts rename to src/lib/interactions/user-identity.ts index 6767c5e4..be153306 100644 --- a/src/lib/interact/interact-for-user-identity.ts +++ b/src/lib/interactions/user-identity.ts @@ -1,6 +1,6 @@ import { getSeam } from 'lib/http/client.js' -import { interactForResource } from './interact-for-resource.js' +import { interactForResource } from './resource.js' export const interactForUserIdentity = async () => { const seam = await getSeam() diff --git a/src/lib/interact/interact-for-workspace-id.ts b/src/lib/interactions/workspace-id.ts similarity index 100% rename from src/lib/interact/interact-for-workspace-id.ts rename to src/lib/interactions/workspace-id.ts diff --git a/src/lib/output/get-output.ts b/src/lib/output/get-output.ts index 93ee6fac..5be5c45e 100644 --- a/src/lib/output/get-output.ts +++ b/src/lib/output/get-output.ts @@ -1,4 +1,4 @@ -import { createOutput, type Output } from './create-output.js' +import { createOutput, type Output } from './output.js' let output: Output | null = null diff --git a/src/lib/output/create-memory-output.ts b/src/lib/output/memory-output.ts similarity index 97% rename from src/lib/output/create-memory-output.ts rename to src/lib/output/memory-output.ts index 91e4ef29..33822a90 100644 --- a/src/lib/output/create-memory-output.ts +++ b/src/lib/output/memory-output.ts @@ -3,7 +3,7 @@ import { type CreateOutputOptions, type Output, type OutputStream, -} from './create-output.js' +} from './output.js' export interface MemoryOutput { output: Output diff --git a/src/lib/output/create-output.test.ts b/src/lib/output/output.test.ts similarity index 96% rename from src/lib/output/create-output.test.ts rename to src/lib/output/output.test.ts index ce39d3f5..14c65052 100644 --- a/src/lib/output/create-output.test.ts +++ b/src/lib/output/output.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest' -import { createMemoryOutput } from './create-memory-output.js' +import { createMemoryOutput } from './memory-output.js' test('createOutput: writes data to stdout as json', () => { const { output, stdout, stderr } = createMemoryOutput({ format: 'json' }) diff --git a/src/lib/output/create-output.ts b/src/lib/output/output.ts similarity index 100% rename from src/lib/output/create-output.ts rename to src/lib/output/output.ts diff --git a/src/lib/output/resolve-output-format.ts b/src/lib/output/resolve-output-format.ts index de451de7..be8b7f95 100644 --- a/src/lib/output/resolve-output-format.ts +++ b/src/lib/output/resolve-output-format.ts @@ -1,4 +1,4 @@ -import type { OutputFormat } from './create-output.js' +import type { OutputFormat } from './output.js' export interface ResolveOutputFormatOptions { /** Whether stdout is a terminal. */ diff --git a/test/auth/operations.test.ts b/test/auth/operations.test.ts index 5bc53eb8..20c35811 100644 --- a/test/auth/operations.test.ts +++ b/test/auth/operations.test.ts @@ -8,7 +8,7 @@ import { selectWorkspace, storeToken, } from 'lib/auth/operations.js' -import { createMemoryConfigStore } from 'lib/config/create-memory-config-store.js' +import { createMemoryConfigStore } from 'lib/config/memory-config-store.js' import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from 'lib/env.js' const server = 'https://connect.example.com' diff --git a/test/context.test.ts b/test/context.test.ts index 2d6673ff..33a9e86a 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, expect, test } from 'vitest' -import { createMemoryConfigStore } from 'lib/config/create-memory-config-store.js' +import { createMemoryConfigStore } from 'lib/config/memory-config-store.js' import { resolveAuth } from 'lib/context.js' import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from 'lib/env.js' diff --git a/test/http/request.test.ts b/test/http/request.test.ts index 2d634a51..097e6807 100644 --- a/test/http/request.test.ts +++ b/test/http/request.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, expect, test } from 'vitest' -import { createMemorySeamApi } from 'lib/http/create-memory-seam-api.js' +import { createMemorySeamApi } from 'lib/http/memory-seam-api.js' import { requestSeamApi } from 'lib/http/request.js' -import { createMemoryOutput } from 'lib/output/create-memory-output.js' +import { createMemoryOutput } from 'lib/output/memory-output.js' let exitCodeBefore: number | string | undefined diff --git a/test/interact/interact-for-blueprint-object.test.ts b/test/interactions/blueprint-object.test.ts similarity index 96% rename from test/interact/interact-for-blueprint-object.test.ts rename to test/interactions/blueprint-object.test.ts index d23ca69d..2bd96873 100644 --- a/test/interact/interact-for-blueprint-object.test.ts +++ b/test/interactions/blueprint-object.test.ts @@ -2,15 +2,15 @@ import type { Parameter } from '@seamapi/blueprint' import { afterEach, beforeEach, expect, test } from 'vitest' import type { CliContext } from 'lib/context.js' -import { createMemoryOutput } from 'lib/output/create-memory-output.js' +import { createMemoryOutput } from 'lib/output/memory-output.js' import { setOutput } from 'lib/output/get-output.js' import { cancelPrompt, createMemoryPrompt, type MemoryPromptClient, -} from 'lib/interact/create-memory-prompt.js' -import { interactForBlueprintObject } from 'lib/interact/interact-for-blueprint-object.js' -import { resetPromptClient, setPromptClient, withBackHint } from 'lib/interact/prompt.js' +} from 'lib/interactions/memory-prompt.js' +import { interactForBlueprintObject } from 'lib/interactions/blueprint-object.js' +import { resetPromptClient, setPromptClient, withBackHint } from 'lib/interactions/prompt.js' let memoryPrompt: MemoryPromptClient diff --git a/test/interact/interact-for-command-selection.test.ts b/test/interactions/command-selection.test.ts similarity index 92% rename from test/interact/interact-for-command-selection.test.ts rename to test/interactions/command-selection.test.ts index f205b1de..8c436332 100644 --- a/test/interact/interact-for-command-selection.test.ts +++ b/test/interactions/command-selection.test.ts @@ -1,8 +1,8 @@ import { afterEach, expect, test } from 'vitest' -import { createMemoryPrompt } from 'lib/interact/create-memory-prompt.js' -import { interactForCommandSelection } from 'lib/interact/interact-for-command-selection.js' -import { resetPromptClient, setPromptClient, withBackHint } from 'lib/interact/prompt.js' +import { createMemoryPrompt } from 'lib/interactions/memory-prompt.js' +import { interactForCommandSelection } from 'lib/interactions/command-selection.js' +import { resetPromptClient, setPromptClient, withBackHint } from 'lib/interactions/prompt.js' afterEach(resetPromptClient) diff --git a/test/interact/interact-for-custom-metadata.test.ts b/test/interactions/custom-metadata.test.ts similarity index 87% rename from test/interact/interact-for-custom-metadata.test.ts rename to test/interactions/custom-metadata.test.ts index 3a16f396..670eaa21 100644 --- a/test/interact/interact-for-custom-metadata.test.ts +++ b/test/interactions/custom-metadata.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, expect, test } from 'vitest' -import { createMemoryOutput } from 'lib/output/create-memory-output.js' +import { createMemoryOutput } from 'lib/output/memory-output.js' import { setOutput } from 'lib/output/get-output.js' -import { createMemoryPrompt } from 'lib/interact/create-memory-prompt.js' -import { interactForCustomMetadata } from 'lib/interact/interact-for-custom-metadata.js' -import { resetPromptClient, setPromptClient } from 'lib/interact/prompt.js' +import { createMemoryPrompt } from 'lib/interactions/memory-prompt.js' +import { interactForCustomMetadata } from 'lib/interactions/custom-metadata.js' +import { resetPromptClient, setPromptClient } from 'lib/interactions/prompt.js' /** Scripts an answer for each ask, in the order the editor asks. */ const scriptPrompt = (script: unknown[]): void => { From 29b8ea24ce1ae32b23123b985b5b4d6f41208591 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:03:07 +0000 Subject: [PATCH 18/20] refactor: Keep interactions/ to interactions only, behind a barrel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interactions/ now holds nothing but the interactFor functions, re-exported through index.ts, so consumers import from lib/interactions/index.js without knowing the file-per-interaction layout. The prompt framework powering them — PromptClient, the terminal and memory clients, and the set/reset slot — moves out to sit beside the directory as lib/prompt.ts and lib/memory-prompt.ts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- TESTING.md | 2 +- src/bin/cli.ts | 4 ++-- src/lib/commands/api-command.ts | 2 +- .../local/config-use-remote-api-defs.ts | 2 +- src/lib/commands/local/login.ts | 2 +- src/lib/commands/local/select-server.ts | 2 +- src/lib/commands/local/select-workspace.ts | 2 +- src/lib/http/follow-ups.ts | 4 ++-- src/lib/interactions/action-attempt-poll.ts | 2 +- src/lib/interactions/array.ts | 4 ++-- src/lib/interactions/blueprint-object.ts | 19 ++++++++++--------- src/lib/interactions/command-selection.ts | 2 +- src/lib/interactions/custom-metadata.ts | 4 ++-- src/lib/interactions/index.ts | 19 +++++++++++++++++++ src/lib/interactions/login.ts | 3 ++- src/lib/interactions/resource.ts | 2 +- src/lib/interactions/server-selection.ts | 2 +- src/lib/interactions/timestamp.ts | 2 +- src/lib/interactions/use-remote-api-defs.ts | 2 +- src/lib/interactions/workspace-id.ts | 2 +- src/lib/{interactions => }/memory-prompt.ts | 9 +++++---- src/lib/{interactions => }/prompt.test.ts | 2 +- src/lib/{interactions => }/prompt.ts | 4 +++- test/interactions/blueprint-object.test.ts | 10 +++++----- test/interactions/command-selection.test.ts | 6 +++--- test/interactions/custom-metadata.test.ts | 8 ++++---- 26 files changed, 73 insertions(+), 49 deletions(-) create mode 100644 src/lib/interactions/index.ts rename src/lib/{interactions => }/memory-prompt.ts (92%) rename src/lib/{interactions => }/prompt.test.ts (99%) rename src/lib/{interactions => }/prompt.ts (98%) diff --git a/TESTING.md b/TESTING.md index 54d99c97..1a43c918 100644 --- a/TESTING.md +++ b/TESTING.md @@ -50,7 +50,7 @@ the fake goes. | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------- | | **Pure transform** — `render/help`, `render/completion/render-*`, `output/select-response-payload`, `args/parse` | Value in → value out; no I/O imports | Classical unit, real values | Nothing | Anything | | **Decision over injected data** — `interact-for-command-selection` (non-interactive), `blueprint/endpoint`, `context.ts` | Takes `CliContext` / blueprint / config store as a parameter | Classical with a literal ctx object (`command-selection.test.ts` is the model) | Nothing — a hand-built blueprint literal is a fixture, not a fake | The traversal/decision logic | -| **Prompt flow** — `interactions/*` importing `interactions/prompt.js` | Imports `interactions/prompt.js` | Classical on the returned value, memory output, scripted prompt fake; assert the choice list _offered_ where the prompt is the UX | The prompt layer (the whole `prompts` edge), output | The module's own branching and param assembly | +| **Prompt flow** — `interactions/*` importing `lib/prompt.js` | Imports `lib/prompt.js` | Classical on the returned value, memory output, scripted prompt fake; assert the choice list _offered_ where the prompt is the UX | The prompt layer (the whole `prompts` edge), output | The module's own branching and param assembly | | **Config & state** — `config/config-store`, `config/migrate` | Touches `Configstore` / `env-paths` | Classical against a real store in a temp directory — it's a JSON file, and split/merge/migration _is_ the behavior | The directory; env vars (`vi.stubEnv`) | `Configstore` or fs behavior | | **Network** — `http/request`, `auth/validate-token`, `blueprint/source-npm` | Constructs `SeamHttp` or calls `fetch` | Classical against a fake port (or a stubbed global `fetch` with captured requests, as `blueprint/source-npm.test.ts` does); assert the payload sent _and_ the value returned | The `SeamApi` port / global `fetch` | Status handling, payload selection, formatting — that's the unit | | **Orchestration** — `bin/cli.ts` | Reads argv/env, wires everything | E2e: spawn via `execa`, `node:http` fake server, XDG temp dirs (`test/cli.test.ts`) | The far end of the wire; the home directories | Anything in-process | diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 567b74b7..dffced76 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -24,12 +24,12 @@ import { type CliContext, resolveAuth } from 'lib/context.js' import { tokenEnvVar } from 'lib/env.js' import { reportErrorAndExit } from 'lib/errors.js' import { createSeamApi, type SeamApi } from 'lib/http/api.js' -import { interactForCommandSelection } from 'lib/interactions/command-selection.js' -import { canPrompt } from 'lib/interactions/prompt.js' +import { interactForCommandSelection } from 'lib/interactions/index.js' import { getOutput, setOutput } from 'lib/output/get-output.js' import { createOutput } from 'lib/output/output.js' import { readStdinJson } from 'lib/output/read-stdin-json.js' import { resolveOutputFormat } from 'lib/output/resolve-output-format.js' +import { canPrompt } from 'lib/prompt.js' import { completionShells, isCompletionShell, diff --git a/src/lib/commands/api-command.ts b/src/lib/commands/api-command.ts index 0123f7d5..92551473 100644 --- a/src/lib/commands/api-command.ts +++ b/src/lib/commands/api-command.ts @@ -11,7 +11,7 @@ import type { CliContext } from 'lib/context.js' import { UsageError } from 'lib/errors.js' import { runResponseFollowUps } from 'lib/http/follow-ups.js' import { requestSeamApi } from 'lib/http/request.js' -import { interactForCommandParams } from 'lib/interactions/command-params.js' +import { interactForCommandParams } from 'lib/interactions/index.js' import type { CommandResult, Invocation } from './registry.js' diff --git a/src/lib/commands/local/config-use-remote-api-defs.ts b/src/lib/commands/local/config-use-remote-api-defs.ts index 07670a5a..1c401a5b 100644 --- a/src/lib/commands/local/config-use-remote-api-defs.ts +++ b/src/lib/commands/local/config-use-remote-api-defs.ts @@ -1,6 +1,6 @@ import type { Command } from 'lib/commands/registry.js' import { NonInteractiveError } from 'lib/errors.js' -import { interactForUseRemoteApiDefs } from 'lib/interactions/use-remote-api-defs.js' +import { interactForUseRemoteApiDefs } from 'lib/interactions/index.js' export const configUseRemoteApiDefsCommand: Command = { definition: { diff --git a/src/lib/commands/local/login.ts b/src/lib/commands/local/login.ts index 23287030..fda343f0 100644 --- a/src/lib/commands/local/login.ts +++ b/src/lib/commands/local/login.ts @@ -2,7 +2,7 @@ import { assertMutable, login } from 'lib/auth/operations.js' import type { Command } from 'lib/commands/registry.js' import { stringFlag } from 'lib/commands/spec.js' import { NonInteractiveError } from 'lib/errors.js' -import { interactForLogin } from 'lib/interactions/login.js' +import { interactForLogin } from 'lib/interactions/index.js' export const loginCommand: Command = { definition: { diff --git a/src/lib/commands/local/select-server.ts b/src/lib/commands/local/select-server.ts index ee8f3c41..f98c9658 100644 --- a/src/lib/commands/local/select-server.ts +++ b/src/lib/commands/local/select-server.ts @@ -2,7 +2,7 @@ import { assertMutable, selectServer } from 'lib/auth/operations.js' import type { Command } from 'lib/commands/registry.js' import { stringFlag } from 'lib/commands/spec.js' import { NonInteractiveError } from 'lib/errors.js' -import { interactForServerSelection } from 'lib/interactions/server-selection.js' +import { interactForServerSelection } from 'lib/interactions/index.js' export const selectServerCommand: Command = { definition: { diff --git a/src/lib/commands/local/select-workspace.ts b/src/lib/commands/local/select-workspace.ts index 45cb2ad1..237ceb64 100644 --- a/src/lib/commands/local/select-workspace.ts +++ b/src/lib/commands/local/select-workspace.ts @@ -1,7 +1,7 @@ import { assertMutable } from 'lib/auth/operations.js' import type { Command } from 'lib/commands/registry.js' import { NonInteractiveError } from 'lib/errors.js' -import { interactForWorkspaceId } from 'lib/interactions/workspace-id.js' +import { interactForWorkspaceId } from 'lib/interactions/index.js' export const selectWorkspaceCommand: Command = { definition: { diff --git a/src/lib/http/follow-ups.ts b/src/lib/http/follow-ups.ts index d2e7bade..a262a98f 100644 --- a/src/lib/http/follow-ups.ts +++ b/src/lib/http/follow-ups.ts @@ -1,7 +1,7 @@ import type { CliContext } from 'lib/context.js' import { isInsideWebBrowser } from 'lib/env.js' -import { interactForActionAttemptPoll } from 'lib/interactions/action-attempt-poll.js' -import { promptConfirm } from 'lib/interactions/prompt.js' +import { interactForActionAttemptPoll } from 'lib/interactions/index.js' +import { promptConfirm } from 'lib/prompt.js' /** * Follow-ups a response may call for: opening a connect webview in the diff --git a/src/lib/interactions/action-attempt-poll.ts b/src/lib/interactions/action-attempt-poll.ts index 2d437b09..c09d1997 100644 --- a/src/lib/interactions/action-attempt-poll.ts +++ b/src/lib/interactions/action-attempt-poll.ts @@ -3,7 +3,7 @@ import type { ActionAttemptsGetResponse } from '@seamapi/http/connect' import { getSeam } from 'lib/http/client.js' import { getOutput } from 'lib/output/get-output.js' import { withLoading } from 'lib/output/with-loading.js' -import { promptConfirm } from './prompt.js' +import { promptConfirm } from 'lib/prompt.js' export const interactForActionAttemptPoll = async ( actionAttempt: ActionAttemptsGetResponse['action_attempt'], diff --git a/src/lib/interactions/array.ts b/src/lib/interactions/array.ts index 397d4149..1711b3a4 100644 --- a/src/lib/interactions/array.ts +++ b/src/lib/interactions/array.ts @@ -1,11 +1,11 @@ -import { getOutput } from 'lib/output/get-output.js' import { PromptCancelledError } from 'lib/errors.js' +import { getOutput } from 'lib/output/get-output.js' import { promptNumber, promptSelect, promptText, withBackHint, -} from './prompt.js' +} from 'lib/prompt.js' export const interactForArray = async ( array: string[], diff --git a/src/lib/interactions/blueprint-object.ts b/src/lib/interactions/blueprint-object.ts index 27a761ba..19fe4911 100644 --- a/src/lib/interactions/blueprint-object.ts +++ b/src/lib/interactions/blueprint-object.ts @@ -4,7 +4,17 @@ import { assertRequiredParams } from 'lib/args/validate.js' import type { CliContext } from 'lib/context.js' import { NonInteractiveError, PromptCancelledError } from 'lib/errors.js' import { getOutput } from 'lib/output/get-output.js' +import { + promptAutocomplete, + promptAutocompleteMultiselect, + promptConfirm, + promptNumber, + promptSelect, + promptText, + withBackHint, +} from 'lib/prompt.js' import { ellipsis } from 'lib/render/text.js' + import { interactForAccessCode } from './access-code.js' import { interactForAcsEntrance } from './acs-entrance.js' import { interactForAcsSystem } from './acs-system.js' @@ -15,15 +25,6 @@ import { interactForCustomMetadata } from './custom-metadata.js' import { interactForDevice } from './device.js' import { interactForTimestamp } from './timestamp.js' import { interactForUserIdentity } from './user-identity.js' -import { - promptAutocomplete, - promptAutocompleteMultiselect, - promptConfirm, - promptNumber, - promptSelect, - promptText, - withBackHint, -} from './prompt.js' const ergonomicPropOrder = [ 'name', diff --git a/src/lib/interactions/command-selection.ts b/src/lib/interactions/command-selection.ts index e350a23e..b74e81b1 100644 --- a/src/lib/interactions/command-selection.ts +++ b/src/lib/interactions/command-selection.ts @@ -2,7 +2,7 @@ import { isDeepStrictEqual as isEqual } from 'node:util' import type { Interactivity } from 'lib/args/parse.js' import { NonInteractiveError, PromptCancelledError } from 'lib/errors.js' -import { promptAutocomplete, withBackHint } from './prompt.js' +import { promptAutocomplete, withBackHint } from 'lib/prompt.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() diff --git a/src/lib/interactions/custom-metadata.ts b/src/lib/interactions/custom-metadata.ts index ab6bc00a..72ef462e 100644 --- a/src/lib/interactions/custom-metadata.ts +++ b/src/lib/interactions/custom-metadata.ts @@ -1,6 +1,6 @@ -import { getOutput } from 'lib/output/get-output.js' import { PromptCancelledError } from 'lib/errors.js' -import { promptSelect, promptText, withBackHint } from './prompt.js' +import { getOutput } from 'lib/output/get-output.js' +import { promptSelect, promptText, withBackHint } from 'lib/prompt.js' // Structurally the CustomMetadata of @seamapi/types, spelled out here so the // published declarations do not depend on a development-only package. diff --git a/src/lib/interactions/index.ts b/src/lib/interactions/index.ts new file mode 100644 index 00000000..6fc0b67b --- /dev/null +++ b/src/lib/interactions/index.ts @@ -0,0 +1,19 @@ +export * from './access-code.js' +export * from './acs-entrance.js' +export * from './acs-system.js' +export * from './acs-user.js' +export * from './action-attempt-poll.js' +export * from './array.js' +export * from './blueprint-object.js' +export * from './command-params.js' +export * from './command-selection.js' +export * from './connected-account.js' +export * from './custom-metadata.js' +export * from './device.js' +export * from './login.js' +export * from './resource.js' +export * from './server-selection.js' +export * from './timestamp.js' +export * from './use-remote-api-defs.js' +export * from './user-identity.js' +export * from './workspace-id.js' diff --git a/src/lib/interactions/login.ts b/src/lib/interactions/login.ts index 93a02d1d..683b8533 100644 --- a/src/lib/interactions/login.ts +++ b/src/lib/interactions/login.ts @@ -6,8 +6,9 @@ import { validateToken } from 'lib/auth/validate-token.js' import { getConfigStore } from 'lib/config/index.js' import { resolveAuth } from 'lib/context.js' import { getOutput } from 'lib/output/get-output.js' -import { promptText } from './prompt.js' import { withLoading } from 'lib/output/with-loading.js' +import { promptText } from 'lib/prompt.js' + import { interactForWorkspaceId } from './workspace-id.js' export const interactForLogin = async () => { diff --git a/src/lib/interactions/resource.ts b/src/lib/interactions/resource.ts index 65bd4c6b..10b2cfa0 100644 --- a/src/lib/interactions/resource.ts +++ b/src/lib/interactions/resource.ts @@ -1,5 +1,5 @@ -import { promptAutocomplete, withBackHint } from './prompt.js' import { withLoading } from 'lib/output/with-loading.js' +import { promptAutocomplete, withBackHint } from 'lib/prompt.js' export interface ResourceChoice { title: string diff --git a/src/lib/interactions/server-selection.ts b/src/lib/interactions/server-selection.ts index c169068b..26aba76b 100644 --- a/src/lib/interactions/server-selection.ts +++ b/src/lib/interactions/server-selection.ts @@ -8,7 +8,7 @@ import { import { getConfigStore } from 'lib/config/index.js' import { resolveAuth } from 'lib/context.js' import { getOutput } from 'lib/output/get-output.js' -import { promptAutocomplete, promptText } from './prompt.js' +import { promptAutocomplete, promptText } from 'lib/prompt.js' export async function interactForServerSelection() { const config = getConfigStore() diff --git a/src/lib/interactions/timestamp.ts b/src/lib/interactions/timestamp.ts index 2e7d35a3..dda6467e 100644 --- a/src/lib/interactions/timestamp.ts +++ b/src/lib/interactions/timestamp.ts @@ -1,4 +1,4 @@ -import { promptText, withBackHint } from './prompt.js' +import { promptText, withBackHint } from 'lib/prompt.js' export const interactForTimestamp = async () => { const now = new Date().toISOString() diff --git a/src/lib/interactions/use-remote-api-defs.ts b/src/lib/interactions/use-remote-api-defs.ts index ca0817f0..59f006b6 100644 --- a/src/lib/interactions/use-remote-api-defs.ts +++ b/src/lib/interactions/use-remote-api-defs.ts @@ -1,6 +1,6 @@ import { setUseRemoteApiDefs } from 'lib/auth/operations.js' import { getOutput } from 'lib/output/get-output.js' -import { promptSelect } from './prompt.js' +import { promptSelect } from 'lib/prompt.js' export async function interactForUseRemoteApiDefs() { const useRemoteApiDefs = await promptSelect({ diff --git a/src/lib/interactions/workspace-id.ts b/src/lib/interactions/workspace-id.ts index f210a993..4380878c 100644 --- a/src/lib/interactions/workspace-id.ts +++ b/src/lib/interactions/workspace-id.ts @@ -5,7 +5,7 @@ import { getConfigStore } from 'lib/config/index.js' import { resolveAuth } from 'lib/context.js' import { getSeamMultiWorkspace } from 'lib/http/client.js' import { withLoading } from 'lib/output/with-loading.js' -import { promptAutocomplete } from './prompt.js' +import { promptAutocomplete } from 'lib/prompt.js' export const interactForWorkspaceId = async (personalAccessToken?: string) => { const config = getConfigStore() diff --git a/src/lib/interactions/memory-prompt.ts b/src/lib/memory-prompt.ts similarity index 92% rename from src/lib/interactions/memory-prompt.ts rename to src/lib/memory-prompt.ts index 697a4bc5..448c2482 100644 --- a/src/lib/interactions/memory-prompt.ts +++ b/src/lib/memory-prompt.ts @@ -1,4 +1,4 @@ -import { PromptCancelledError } from '../errors.js' +import { PromptCancelledError } from 'lib/errors.js' import type { PromptChoice, PromptClient, @@ -6,7 +6,7 @@ import type { PromptNumberOptions, PromptSelectOptions, PromptTextOptions, -} from './prompt.js' +} from 'lib/prompt.js' /** A question a {@link PromptClient} was asked, as a test sees it. */ export interface PromptQuestion { @@ -84,5 +84,6 @@ export class MemoryPromptClient implements PromptClient { } } -export const createMemoryPrompt = (script: unknown[] = []): MemoryPromptClient => - new MemoryPromptClient(script) +export const createMemoryPrompt = ( + script: unknown[] = [], +): MemoryPromptClient => new MemoryPromptClient(script) diff --git a/src/lib/interactions/prompt.test.ts b/src/lib/prompt.test.ts similarity index 99% rename from src/lib/interactions/prompt.test.ts rename to src/lib/prompt.test.ts index 2a73dbcb..ce2715de 100644 --- a/src/lib/interactions/prompt.test.ts +++ b/src/lib/prompt.test.ts @@ -8,7 +8,7 @@ import { emitArrowKeyAliases, type SearchableChoice, searchChoices, -} from './prompt.js' +} from 'lib/prompt.js' const workspaces = [ { label: 'Sandbox', hint: 'ws_1' }, diff --git a/src/lib/interactions/prompt.ts b/src/lib/prompt.ts similarity index 98% rename from src/lib/interactions/prompt.ts rename to src/lib/prompt.ts index 010b8cab..45528e0d 100644 --- a/src/lib/interactions/prompt.ts +++ b/src/lib/prompt.ts @@ -170,7 +170,9 @@ export class TerminalPromptClient implements PromptClient { return unwrap(await confirm({ ...options, output })) } - select = async (options: PromptSelectOptions): Promise => { + select = async ( + options: PromptSelectOptions, + ): Promise => { installArrowKeyAliases() return unwrap( await select({ diff --git a/test/interactions/blueprint-object.test.ts b/test/interactions/blueprint-object.test.ts index 2bd96873..4a3065af 100644 --- a/test/interactions/blueprint-object.test.ts +++ b/test/interactions/blueprint-object.test.ts @@ -2,15 +2,15 @@ import type { Parameter } from '@seamapi/blueprint' import { afterEach, beforeEach, expect, test } from 'vitest' import type { CliContext } from 'lib/context.js' -import { createMemoryOutput } from 'lib/output/memory-output.js' -import { setOutput } from 'lib/output/get-output.js' +import { interactForBlueprintObject } from 'lib/interactions/index.js' import { cancelPrompt, createMemoryPrompt, type MemoryPromptClient, -} from 'lib/interactions/memory-prompt.js' -import { interactForBlueprintObject } from 'lib/interactions/blueprint-object.js' -import { resetPromptClient, setPromptClient, withBackHint } from 'lib/interactions/prompt.js' +} from 'lib/memory-prompt.js' +import { setOutput } from 'lib/output/get-output.js' +import { createMemoryOutput } from 'lib/output/memory-output.js' +import { resetPromptClient, setPromptClient, withBackHint } from 'lib/prompt.js' let memoryPrompt: MemoryPromptClient diff --git a/test/interactions/command-selection.test.ts b/test/interactions/command-selection.test.ts index 8c436332..f8fd1dc0 100644 --- a/test/interactions/command-selection.test.ts +++ b/test/interactions/command-selection.test.ts @@ -1,8 +1,8 @@ import { afterEach, expect, test } from 'vitest' -import { createMemoryPrompt } from 'lib/interactions/memory-prompt.js' -import { interactForCommandSelection } from 'lib/interactions/command-selection.js' -import { resetPromptClient, setPromptClient, withBackHint } from 'lib/interactions/prompt.js' +import { interactForCommandSelection } from 'lib/interactions/index.js' +import { createMemoryPrompt } from 'lib/memory-prompt.js' +import { resetPromptClient, setPromptClient, withBackHint } from 'lib/prompt.js' afterEach(resetPromptClient) diff --git a/test/interactions/custom-metadata.test.ts b/test/interactions/custom-metadata.test.ts index 670eaa21..8339925f 100644 --- a/test/interactions/custom-metadata.test.ts +++ b/test/interactions/custom-metadata.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, expect, test } from 'vitest' -import { createMemoryOutput } from 'lib/output/memory-output.js' +import { interactForCustomMetadata } from 'lib/interactions/index.js' +import { createMemoryPrompt } from 'lib/memory-prompt.js' import { setOutput } from 'lib/output/get-output.js' -import { createMemoryPrompt } from 'lib/interactions/memory-prompt.js' -import { interactForCustomMetadata } from 'lib/interactions/custom-metadata.js' -import { resetPromptClient, setPromptClient } from 'lib/interactions/prompt.js' +import { createMemoryOutput } from 'lib/output/memory-output.js' +import { resetPromptClient, setPromptClient } from 'lib/prompt.js' /** Scripts an answer for each ask, in the order the editor asks. */ const scriptPrompt = (script: unknown[]): void => { From 8b89b44ec40ab864b7e57cf4e744f8e7cc47589a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 07:55:30 +0000 Subject: [PATCH 19/20] lint: Detect import cycles, with the resolver the plugin needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds eslint-import-resolver-typescript plus the import/parsers setting so eslint-plugin-import can resolve .js-suffixed TypeScript imports and parse the imported files — without both, its graph-based rules see nothing and pass silently. import/no-cycle is now on (verified against a deliberate cycle) and immediately caught a real one: config-store and migrate imported each other, broken by extracting the shared pure transforms into config/values.ts. Two rules stay off, documented in place: import/extensions demands .ts extensions once the resolver maps imports to their .ts files (nodenext already fails the build on a bad extension), and import/no-relative-parent-imports turns out to ban depending on parent directories however the import is spelled — alias imports included — which is not the specifier rule this repo wants; the core no-restricted-imports pattern keeps doing that job. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- eslint.config.ts | 39 ++- package-lock.json | 495 +++++++++++++++++++++++++++++++++ package.json | 1 + src/lib/config/config-store.ts | 62 +---- src/lib/config/migrate.ts | 2 +- src/lib/config/values.ts | 72 +++++ 6 files changed, 606 insertions(+), 65 deletions(-) create mode 100644 src/lib/config/values.ts diff --git a/eslint.config.ts b/eslint.config.ts index e67e153d..c99f0aeb 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -22,6 +22,23 @@ export default [ 'unused-imports': unusedImports, import: importPlugin, }, + settings: { + // no-cycle builds the import graph by parsing the imported files, and + // its default parser cannot read TypeScript: without this setting it + // sees no edges and silently reports nothing. + 'import/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'], + }, + // Resolves the .js-suffixed TypeScript imports and the tsconfig path + // aliases. Without a resolver, every import in this ESM TypeScript + // codebase is unresolvable, which silently disables the import rules + // that resolve before reporting, e.g., no-relative-parent-imports. + 'import/resolver': { + typescript: { + project: './tsconfig.json', + }, + }, + }, rules: { '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-import-type-side-effects': 'error', @@ -31,10 +48,26 @@ export default [ fixStyle: 'inline-type-imports', }, ], - 'import/extensions': ['error', 'ignorePackages'], + // Not import/extensions: with the resolver active it resolves the + // .js-suffixed import to the .ts file and demands a .ts extension. + // TypeScript's nodenext resolution already fails the build on a + // missing or wrong extension, so the rule adds nothing here. + // + // Not import/no-relative-parent-imports: with a resolver it bans + // depending on anything in a parent directory however the import is + // written, path aliases included. The core rule below bans the ../ + // spelling, which is the actual mistake. 'import/no-duplicates': ['error', { 'prefer-inline': true }], - // The import/no-relative-parent-imports rule is silently inert in this - // flat config, so ban parent traversal with the core rule instead. + 'import/no-cycle': [ + 'error', + { + ignoreExternal: true, + // A cycle broken by a deferred import() is intentional, e.g., the + // command registry lists the completion command while the + // completion command builds a spec from the registry. + allowUnsafeDynamicCyclicDependency: true, + }, + ], 'no-restricted-imports': [ 'error', { diff --git a/package-lock.json b/package-lock.json index 7d168202..7a1bda83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "concurrently": "^10.0.4", "del-cli": "^7.0.0", "eslint": "^9.31.0", + "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-import": "^2.32.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", @@ -2028,6 +2029,353 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", @@ -3963,6 +4311,31 @@ "node": ">=10" } }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", @@ -3985,6 +4358,41 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", + "integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, "node_modules/eslint-module-utils": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", @@ -5609,6 +6017,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -6917,6 +7348,22 @@ "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", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -8435,6 +8882,16 @@ "node": ">=0.10.0" } }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -9286,6 +9743,44 @@ "node": ">= 0.8" } }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 7ec8db3e..73366715 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "concurrently": "^10.0.4", "del-cli": "^7.0.0", "eslint": "^9.31.0", + "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-import": "^2.32.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", diff --git a/src/lib/config/config-store.ts b/src/lib/config/config-store.ts index d53ff2a6..07c6fd7a 100644 --- a/src/lib/config/config-store.ts +++ b/src/lib/config/config-store.ts @@ -4,11 +4,10 @@ import Configstore from 'configstore' import envPaths from 'env-paths' import { migrateConfigStore } from './migrate.js' +import { isStateKey, mergeConfig, splitConfig } from './values.js' const configFileName = 'cli.json' const legacyConfigStoreId = 'seam-cli' -const currentWorkspaceIdKey = 'current_workspace_id' -const patKey = 'pat' const paths = envPaths('seam', { suffix: '' }) /** @@ -63,57 +62,6 @@ const createConfigStore = (): PersistentConfigStore => { return new PersistentConfigStore(settingsStore, stateStore) } -export const mergeConfig = ( - baseConfig: Record, - overrideConfig: Record, -): Record => { - const mergedConfig = { ...baseConfig } - - for (const [key, value] of Object.entries(overrideConfig)) { - const baseValue = mergedConfig[key] - mergedConfig[key] = - isRecord(baseValue) && isRecord(value) - ? mergeConfig(baseValue, value) - : value - } - - return mergedConfig -} - -export const splitConfig = ( - config: Record, -): { - settings: Record - state: Record -} => { - const settings: Record = {} - const state: Record = {} - - for (const [key, value] of Object.entries(config)) { - if (isStateKey(key)) { - state[key] = value - continue - } - - if (isRecord(value)) { - const splitValue = splitConfig(value) - if (Object.keys(splitValue.settings).length > 0) { - settings[key] = splitValue.settings - } - - if (Object.keys(splitValue.state).length > 0) { - state[key] = splitValue.state - } - - continue - } - - settings[key] = value - } - - return { settings, state } -} - export class PersistentConfigStore implements ConfigStore { readonly path: string @@ -180,14 +128,6 @@ const getStateConfigPath = (): string => { return join(paths.log, configFileName) } -const isStateKey = (key: string): boolean => { - return ( - key === currentWorkspaceIdKey || - key === patKey || - key.endsWith(`.${patKey}`) - ) -} - const isRecord = (value: unknown): value is Record => { return value != null && typeof value === 'object' && !Array.isArray(value) } diff --git a/src/lib/config/migrate.ts b/src/lib/config/migrate.ts index d7c50ea1..09088fc3 100644 --- a/src/lib/config/migrate.ts +++ b/src/lib/config/migrate.ts @@ -2,7 +2,7 @@ import { existsSync, rmSync } from 'node:fs' import type Configstore from 'configstore' -import { mergeConfig, splitConfig } from './config-store.js' +import { mergeConfig, splitConfig } from './values.js' export const migrateConfigStore = ( settingsStore: Configstore, diff --git a/src/lib/config/values.ts b/src/lib/config/values.ts new file mode 100644 index 00000000..89931261 --- /dev/null +++ b/src/lib/config/values.ts @@ -0,0 +1,72 @@ +/** + * Config values as whole trees: merging two trees into one view, and + * splitting one tree into the settings file and the state file by key. + * Pure transforms shared by the persistent store and the legacy migration. + */ + +const currentWorkspaceIdKey = 'current_workspace_id' +const patKey = 'pat' + +/** Whether a key holds auth state rather than a setting. */ +export const isStateKey = (key: string): boolean => { + return ( + key === currentWorkspaceIdKey || + key === patKey || + key.endsWith(`.${patKey}`) + ) +} + +export const mergeConfig = ( + baseConfig: Record, + overrideConfig: Record, +): Record => { + const mergedConfig = { ...baseConfig } + + for (const [key, value] of Object.entries(overrideConfig)) { + const baseValue = mergedConfig[key] + mergedConfig[key] = + isRecord(baseValue) && isRecord(value) + ? mergeConfig(baseValue, value) + : value + } + + return mergedConfig +} + +export const splitConfig = ( + config: Record, +): { + settings: Record + state: Record +} => { + const settings: Record = {} + const state: Record = {} + + for (const [key, value] of Object.entries(config)) { + if (isStateKey(key)) { + state[key] = value + continue + } + + if (isRecord(value)) { + const splitValue = splitConfig(value) + if (Object.keys(splitValue.settings).length > 0) { + settings[key] = splitValue.settings + } + + if (Object.keys(splitValue.state).length > 0) { + state[key] = splitValue.state + } + + continue + } + + settings[key] = value + } + + return { settings, state } +} + +const isRecord = (value: unknown): value is Record => { + return value != null && typeof value === 'object' && !Array.isArray(value) +} From 9ae18817e26add1a9f0538138e03961e6a65fda6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 08:21:04 +0000 Subject: [PATCH 20/20] refactor: Prepare requests with the SDK's SeamHttpRequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port no longer imitates a transport with its own post method — it prepares requests the way the SDK itself models them. SeamApi.createRequest returns a SeamApiRequest (url, method, body, fetchResponse), and the real implementation hands back an actual SeamHttpRequest, so the reported request banner now shows the full resolved URL and error statuses arrive as the SDK's typed SeamHttpApiError instead of hand-rolled status checks. The memory fake rejects with those same SDK error classes, never an imitation. requestSeamApi returns the response body (null on an API error) and renders the error payload from the typed error: type, message, and data. Visible changes: the banner prints the full request URL rather than the bare path, the informational [200] status line on success is gone, and a non-Seam-shaped error response (e.g. proxy HTML) now reports as a CLI error instead of being printed as a payload. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KfGRqRqcApmECbDUkFuKfS --- TESTING.md | 80 ++++++++++++++++----------------- src/lib/commands/api-command.ts | 4 +- src/lib/http/api.ts | 60 ++++++++++++++++--------- src/lib/http/memory-seam-api.ts | 53 ++++++++++++++++------ src/lib/http/request.ts | 52 ++++++++++++++------- test/http/request.test.ts | 20 ++++++--- 6 files changed, 166 insertions(+), 103 deletions(-) diff --git a/TESTING.md b/TESTING.md index 1a43c918..3e789769 100644 --- a/TESTING.md +++ b/TESTING.md @@ -50,7 +50,7 @@ the fake goes. | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------- | | **Pure transform** — `render/help`, `render/completion/render-*`, `output/select-response-payload`, `args/parse` | Value in → value out; no I/O imports | Classical unit, real values | Nothing | Anything | | **Decision over injected data** — `interact-for-command-selection` (non-interactive), `blueprint/endpoint`, `context.ts` | Takes `CliContext` / blueprint / config store as a parameter | Classical with a literal ctx object (`command-selection.test.ts` is the model) | Nothing — a hand-built blueprint literal is a fixture, not a fake | The traversal/decision logic | -| **Prompt flow** — `interactions/*` importing `lib/prompt.js` | Imports `lib/prompt.js` | Classical on the returned value, memory output, scripted prompt fake; assert the choice list _offered_ where the prompt is the UX | The prompt layer (the whole `prompts` edge), output | The module's own branching and param assembly | +| **Prompt flow** — `interactions/*` importing `lib/prompt.js` | Imports `lib/prompt.js` | Classical on the returned value, memory output, scripted prompt fake; assert the choice list _offered_ where the prompt is the UX | The prompt layer (the whole `@clack/prompts` edge), output | The module's own branching and param assembly | | **Config & state** — `config/config-store`, `config/migrate` | Touches `Configstore` / `env-paths` | Classical against a real store in a temp directory — it's a JSON file, and split/merge/migration _is_ the behavior | The directory; env vars (`vi.stubEnv`) | `Configstore` or fs behavior | | **Network** — `http/request`, `auth/validate-token`, `blueprint/source-npm` | Constructs `SeamHttp` or calls `fetch` | Classical against a fake port (or a stubbed global `fetch` with captured requests, as `blueprint/source-npm.test.ts` does); assert the payload sent _and_ the value returned | The `SeamApi` port / global `fetch` | Status handling, payload selection, formatting — that's the unit | | **Orchestration** — `bin/cli.ts` | Reads argv/env, wires everything | E2e: spawn via `execa`, `node:http` fake server, XDG temp dirs (`test/cli.test.ts`) | The far end of the wire; the home directories | Anything in-process | @@ -97,44 +97,43 @@ HTTP-free share. ## The Seam SDK boundary **Wrap it behind our own narrow port.** Not `vi.mock('./http/client.js')`, and -not dependency-injecting `SeamHttp`: both force the fake to imitate an -axios-shaped SDK surface (`client.post` returning an `AxiosResponse`), so -tests end up re-verifying the SDK's shape instead of our behavior. The CLI is -blueprint-driven and has one chokepoint — -`seam.client.post(path, params, { validateStatus: () => true })` in -`http/request.ts` — so the port is one method: +not dependency-injecting `SeamHttp`: both force the fake to imitate the SDK's +whole surface, so tests end up re-verifying the SDK's shape instead of our +behavior. The CLI is blueprint-driven and has one chokepoint: preparing a +`SeamHttpRequest` for an endpoint path. The port mirrors that — prepare a +request, inspect it, send it: ```ts // src/lib/http/api.ts -export interface SeamApiResponse { - status: number - data: unknown +export interface SeamApiRequest { + readonly url: URL + readonly method: string + readonly body: unknown + fetchResponse: () => Promise } export interface SeamApi { - post: ( - path: string, - params: Record, - ) => Promise + createRequest: (options: ApiRequestOptions) => SeamApiRequest } export class HttpSeamApi implements SeamApi { constructor(private readonly seam: SeamHttp) {} // the only place SeamHttp appears - post = async (path: string, params: Record) => { - const { status, data } = await this.seam.client.post(path, params, { - validateStatus: () => true, + createRequest = ({ path, params, responseKey }: ApiRequestOptions) => + new SeamHttpRequest(this.seam, { + pathname: path, + method: 'POST', + body: params, + responseKey: responseKey ?? undefined, }) - return { status, data } - } } - -export const createSeamApi = async (): Promise => - new HttpSeamApi(await getSeam()) ``` -The fake is the in-process mirror of the e2e server — a routes table plus a -capture: +The real request object is the SDK's own `SeamHttpRequest`, so the URL is +inspectable before sending and an error status rejects with the SDK's typed +`SeamHttpApiError`. The fake is the in-process mirror of the e2e server — a +routes table plus a capture — and it rejects with those same SDK error +classes, never an imitation: ```ts // src/lib/http/memory-seam-api.ts @@ -142,27 +141,24 @@ export class MemorySeamApi implements SeamApi { readonly requests: Array<{ path: string; params: Record }> = [] - constructor(private readonly routes: Record) {} - - post = async (path: string, params: Record) => { - this.requests.push({ path, params }) - return ( - this.routes[path] ?? { - status: 404, - data: { error: { type: 'not_found' } }, - } - ) - } + constructor(private readonly routes: Record) {} + + createRequest = ({ path, params }: ApiRequestOptions): SeamApiRequest => ({ + url: new URL(`https://memory.seam.example${path}`), + method: 'POST', + body: params, + fetchResponse: async () => { + this.requests.push({ path, params }) + const route = this.routes[path] + if (route == null || route.status >= 400) throw toSeamHttpError(route) + return route.data + }, + }) } - -export const createMemorySeamApi = ( - routes: Record, -): MemorySeamApi => new MemorySeamApi(routes) ``` -This split also separates transport from presentation in `http/request.ts` -(which historically also formatted output and set `process.exitCode`), so the -error-status → exit-code behavior becomes a classical test with zero HTTP: +This keeps transport separate from presentation: the error-status → exit-code +behavior is a classical test with zero HTTP: ```ts const api = createMemorySeamApi({ diff --git a/src/lib/commands/api-command.ts b/src/lib/commands/api-command.ts index 92551473..d87913e6 100644 --- a/src/lib/commands/api-command.ts +++ b/src/lib/commands/api-command.ts @@ -82,12 +82,12 @@ export const executeApiCommand = async ( } const api = await ctx.api() - const response = await requestSeamApi( + const body = await requestSeamApi( { path: apiPath, params, responseKey: getResponseKey(path, ctx) }, { api, output: ctx.output }, ) - await runResponseFollowUps(response.data, ctx) + await runResponseFollowUps(body, ctx) return { kind: 'done' } } diff --git a/src/lib/http/api.ts b/src/lib/http/api.ts index b355d458..7bfd5da4 100644 --- a/src/lib/http/api.ts +++ b/src/lib/http/api.ts @@ -1,42 +1,58 @@ -import type { SeamHttp } from '@seamapi/http/connect' +import { type SeamHttp, SeamHttpRequest } from '@seamapi/http/connect' import type { AuthContext } from 'lib/context.js' import { getSeam } from './client.js' -export interface SeamApiResponse { - status: number - data: unknown +export interface ApiRequestOptions { + path: string + params: Record + /** Response key documented for the endpoint, e.g., `devices`. */ + responseKey?: string | null | undefined } /** - * The one method the blueprint-driven CLI needs from the Seam API: post - * params to an endpoint path and read back the status and body. + * A prepared call to the Seam API: inspectable before it is sent, e.g., to + * report the URL, then sent with {@link SeamApiRequest.fetchResponse}. * - * Tests fake at this port with `createMemorySeamApi()` — the in-process - * mirror of the e2e suite's HTTP server — so nothing in-process ever - * imitates the SDK's own surface. + * The real implementation is the SDK's own `SeamHttpRequest`; sending one + * rejects with a `SeamHttpApiError` when the API reports an error. + */ +export interface SeamApiRequest { + readonly url: URL + readonly method: string + readonly body: unknown + /** Send the request and return the full response body. */ + fetchResponse: () => Promise +} + +/** + * How the blueprint-driven CLI reaches the Seam API: prepare a request for + * an endpoint path. Tests fake at this port with `createMemorySeamApi()` — + * the in-process mirror of the e2e suite's HTTP server. */ export interface SeamApi { - post: ( - path: string, - params: Record, - ) => Promise + createRequest: (options: ApiRequestOptions) => SeamApiRequest } /** The only place `SeamHttp` appears for raw requests. */ export class HttpSeamApi implements SeamApi { constructor(private readonly seam: SeamHttp) {} - post = async ( - path: string, - params: Record, - ): Promise => { - const { status, data } = await this.seam.client.post(path, params, { - validateStatus: () => true, - }) - return { status, data } - } + createRequest = ({ + path, + params, + responseKey, + }: ApiRequestOptions): SeamApiRequest => + new SeamHttpRequest, string | undefined>( + this.seam, + { + pathname: path, + method: 'POST', + body: params, + responseKey: responseKey ?? undefined, + }, + ) } export const createSeamApi = async (auth?: AuthContext): Promise => { diff --git a/src/lib/http/memory-seam-api.ts b/src/lib/http/memory-seam-api.ts index 3af24663..03cae5f9 100644 --- a/src/lib/http/memory-seam-api.ts +++ b/src/lib/http/memory-seam-api.ts @@ -1,30 +1,57 @@ -import type { SeamApi, SeamApiResponse } from './api.js' +import { + SeamHttpApiError, + SeamHttpInvalidInputError, +} from '@seamapi/http/connect' + +import type { ApiRequestOptions, SeamApi, SeamApiRequest } from './api.js' + +export interface MemorySeamApiResponse { + status: number + data: unknown +} /** * A real {@link SeamApi} answering from a routes table and recording every * request, for tests: the in-process mirror of the e2e suite's HTTP server. + * Error statuses reject with the SDK's own error classes, exactly as the + * real transport does. */ export class MemorySeamApi implements SeamApi { - /** Every request made, in order — assert on the outbound messages. */ + /** Every request sent, in order — assert on the outbound messages. */ readonly requests: Array<{ path: string; params: Record }> = [] - constructor(private readonly routes: Record) {} + constructor(private readonly routes: Record) {} + + createRequest = ({ path, params }: ApiRequestOptions): SeamApiRequest => ({ + url: new URL(`https://memory.seam.example${path}`), + method: 'POST', + body: params, + fetchResponse: async () => { + this.requests.push({ path, params }) - post = async ( - path: string, - params: Record, - ): Promise => { - this.requests.push({ path, params }) - return ( - this.routes[path] ?? { + const route = this.routes[path] ?? { status: 404, - data: { error: { type: 'not_found' } }, + data: { error: { type: 'not_found', message: 'Not Found' } }, } - ) + + if (route.status >= 400) { + throw toSeamHttpError(route) + } + return route.data + }, + }) +} + +const toSeamHttpError = (route: MemorySeamApiResponse): SeamHttpApiError => { + const error = (route.data as { error: { type: string; message: string } }) + .error + if (error.type === 'invalid_input') { + return new SeamHttpInvalidInputError(error, route.status, 'request_memory') } + return new SeamHttpApiError(error, route.status, 'request_memory') } export const createMemorySeamApi = ( - routes: Record, + routes: Record, ): MemorySeamApi => new MemorySeamApi(routes) diff --git a/src/lib/http/request.ts b/src/lib/http/request.ts index f0b4d7d8..1b7a6496 100644 --- a/src/lib/http/request.ts +++ b/src/lib/http/request.ts @@ -1,10 +1,14 @@ +import { + isSeamHttpApiError, + type SeamHttpApiError, +} from '@seamapi/http/connect' import chalk from 'chalk' import type { Output } from 'lib/output/output.js' import { selectResponsePayload } from 'lib/output/select-response-payload.js' import { withLoading } from 'lib/output/with-loading.js' -import type { SeamApi, SeamApiResponse } from './api.js' +import type { SeamApi } from './api.js' export interface RequestSeamApiOptions { path: string @@ -19,33 +23,47 @@ export interface RequestSeamApiDependencies { } /** - * Make a request and report the result: the request banner and status go to - * stderr, the trimmed payload to stdout, and an error status sets the exit - * code. The transport itself is behind the injected {@link SeamApi}. + * Make a request and report the result: the request URL and params go to + * stderr, the trimmed payload to stdout. An API error reports its status + * and payload and sets the exit code. Returns the response body, or `null` + * when the API reported an error. */ export const requestSeamApi = async ( - { path, params, responseKey }: RequestSeamApiOptions, + options: RequestSeamApiOptions, { api, output }: RequestSeamApiDependencies, -): Promise => { - output.info(`\n${chalk.green(path)}`) +): Promise => { + const request = api.createRequest(options) + + output.info(`\n${chalk.green(request.url.toString())}`) output.info(`Request Params:`) - output.info(formatParams(params)) + output.info(formatParams(options.params)) - const response = await withLoading('Making request...', async () => - api.post(path, params), - ) + let body: unknown + try { + body = await withLoading('Making request...', async () => { + return await request.fetchResponse() + }) + } catch (error) { + if (!isSeamHttpApiError(error)) throw error - if (response.status >= 400) { - output.warn(chalk.red(`[${response.status}]`)) + output.warn(chalk.red(`[${error.statusCode}]`)) process.exitCode = 1 - } else { - output.info(chalk.green(`[${response.status}]`)) + output.data({ error: toErrorPayload(error) }) + return null } - output.data(selectResponsePayload(response.data, { responseKey })) + output.data(selectResponsePayload(body, { responseKey: options.responseKey })) - return response + return body } +const toErrorPayload = ( + error: SeamHttpApiError, +): { type: string; message: string; data?: unknown } => ({ + type: error.code, + message: error.message, + ...(error.data === undefined ? {} : { data: error.data }), +}) + const formatParams = (params: Record): string => JSON.stringify(params, null, 2) diff --git a/test/http/request.test.ts b/test/http/request.test.ts index 097e6807..e74ac2bd 100644 --- a/test/http/request.test.ts +++ b/test/http/request.test.ts @@ -27,7 +27,7 @@ test('requestSeamApi: sends the params and reports the trimmed payload', async ( }) const memory = createMemoryOutput({ format: 'json' }) - const response = await requestSeamApi( + const body = await requestSeamApi( { path: '/devices/list', params: { limit: 5 }, responseKey: 'devices' }, { api, output: memory.output }, ) @@ -36,7 +36,7 @@ test('requestSeamApi: sends the params and reports the trimmed payload', async ( expect(api.requests).toEqual([ { path: '/devices/list', params: { limit: 5 } }, ]) - expect(response.status).toBe(200) + expect(body).toMatchObject({ devices: [{ device_id: 'device1' }] }) expect(JSON.parse(memory.stdout())).toEqual({ devices: [{ device_id: 'device1' }], pagination: { has_next_page: false }, @@ -44,16 +44,19 @@ test('requestSeamApi: sends the params and reports the trimmed payload', async ( expect(process.exitCode).toBe(exitCodeBefore) }) -test('requestSeamApi: reports an error payload and sets the exit code', async () => { +test('requestSeamApi: reports an API error and sets the exit code', async () => { const api = createMemorySeamApi({ '/devices/list': { status: 400, - data: { error: { type: 'invalid_input' }, ok: false }, + data: { + error: { type: 'invalid_input', message: 'Bad request' }, + ok: false, + }, }, }) const memory = createMemoryOutput({ format: 'json' }) - await requestSeamApi( + const body = await requestSeamApi( { path: '/devices/list', params: { limit: 5 } }, { api, output: memory.output }, ) @@ -61,12 +64,15 @@ test('requestSeamApi: reports an error payload and sets the exit code', async () expect(api.requests).toEqual([ { path: '/devices/list', params: { limit: 5 } }, ]) - expect(memory.stdout()).toContain('invalid_input') + expect(body).toBe(null) + expect(JSON.parse(memory.stdout())).toEqual({ + error: { type: 'invalid_input', message: 'Bad request' }, + }) expect(memory.stderr()).toContain('[400]') expect(process.exitCode).toBe(1) }) -test('requestSeamApi: keeps the request banner out of stdout', async () => { +test('requestSeamApi: reports the request URL on stderr, never stdout', async () => { const api = createMemorySeamApi({ '/devices/list': { status: 200, data: { devices: [], ok: true } }, })