From 7e5ae8c177bfc1a4c6161c81f12c5f6cbf5bf253 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 21:33:57 +0000 Subject: [PATCH] feat: Add an output abstraction with stdout, stderr, and JSON support Replace ad hoc console.log calls with an injectable Output so the CLI has one place that decides what goes where, and so output can be asserted in tests with an in-memory implementation. - Only command results go to stdout: prompts, spinners, progress, and other information are written to stderr. - Add --json to read request params from stdin and write the response as JSON. The JSON format is used automatically when stdout is not a terminal, and --no-json opts out. - Support unix pipes end to end: params may be piped or redirected in, and the response piped or redirected out. - Trim responses to the response key and pagination, using the response key from the API blueprint. - Exit non-zero on a failed request or an error. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NKsb8oTieozDEGJtiwWqBo --- README.md | 42 ++++ src/bin/cli.ts | 165 +++++++++++--- src/lib/get-response-key.ts | 25 +++ src/lib/interact-for-action-attempt-poll.ts | 7 +- src/lib/interact-for-array.ts | 16 +- src/lib/interact-for-blueprint-object.ts | 26 ++- src/lib/interact-for-command-selection.ts | 11 +- src/lib/interact-for-custom-metadata.ts | 19 +- src/lib/interact-for-login.ts | 14 +- src/lib/interact-for-resource.ts | 5 +- src/lib/interact-for-server-selection.ts | 13 +- src/lib/interact-for-timestamp.ts | 4 +- src/lib/interact-for-use-remote-api-defs.ts | 8 +- src/lib/interact-for-workspace-id.ts | 4 +- src/lib/output/create-memory-output.ts | 39 ++++ src/lib/output/create-output.test.ts | 73 +++++++ src/lib/output/create-output.ts | 104 +++++++++ src/lib/output/get-output.ts | 24 +++ .../output/select-response-payload.test.ts | 67 ++++++ src/lib/output/select-response-payload.ts | 56 +++++ src/lib/util/prompt.ts | 33 +++ src/lib/util/read-stdin-json.test.ts | 46 ++++ src/lib/util/read-stdin-json.ts | 59 +++++ src/lib/util/request-seam-api.ts | 44 ++-- src/lib/util/with-loading.ts | 10 +- test/cli.test.ts | 201 ++++++++++++++++++ vitest.config.ts | 2 + 27 files changed, 1017 insertions(+), 100 deletions(-) create mode 100644 src/lib/get-response-key.ts create mode 100644 src/lib/output/create-memory-output.ts create mode 100644 src/lib/output/create-output.test.ts create mode 100644 src/lib/output/create-output.ts create mode 100644 src/lib/output/get-output.ts create mode 100644 src/lib/output/select-response-payload.test.ts create mode 100644 src/lib/output/select-response-payload.ts create mode 100644 src/lib/util/prompt.ts create mode 100644 src/lib/util/read-stdin-json.test.ts create mode 100644 src/lib/util/read-stdin-json.ts create mode 100644 test/cli.test.ts diff --git a/README.md b/README.md index db39e28a..1c57bcd1 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,48 @@ seam access-codes create --code "1234" --name "My Code" seam access-codes list --device-id $MY_DOOR ``` +### Output + +Only the response is written to stdout, so any command may be piped or +redirected. Prompts, progress, and other information are written to stderr. + +The response is trimmed to the response key and pagination: no other top level +fields are reported. + +```bash +# The response, and nothing else, ends up in the file +seam devices list > devices.json + +# Prompts and progress still show up in the terminal +seam devices list | jq '.devices[].device_id' +``` + +### JSON + +Pass `--json` to read request params from stdin as JSON and write the response +to stdout as JSON. + +```bash +# Read params from a file +seam locks unlock-door --json < params.json + +# Or from another program +echo '{"device_id": "'"$MY_DOOR"'"}' | seam locks unlock-door --json + +# Params given as flags win over params read from stdin +seam devices list --json --limit 5 < params.json +``` + +The JSON format is enabled automatically whenever stdout is not a terminal, so +piping and redirecting produce JSON without passing `--json`. Pass `--no-json` +to opt out and get the pretty format instead. + +Reading params from stdin also implies `-y`: there is nobody to prompt, so the +CLI reports the missing required params rather than waiting for an answer. + +An error exits non-zero. A request that fails reports its `error` on stdout, +so it can be inspected from a pipe; anything else is written to stderr only. + ## Development and Testing ### Quickstart diff --git a/src/bin/cli.ts b/src/bin/cli.ts index ec58146a..9a3bd854 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -5,10 +5,10 @@ import { isDeepStrictEqual as isEqual } from 'node:util' import chalk from 'chalk' import commandLineUsage from 'command-line-usage' import parseArgs, { type ParsedArgs } from 'minimist' -import prompts from 'prompts' import { getApiBlueprint } from 'lib/get-api-blueprint.js' import { getConfigStore } from 'lib/get-config-store.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' @@ -17,7 +17,11 @@ 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 { createOutput, type OutputFormat } from 'lib/output/create-output.js' +import { getOutput, setOutput } from 'lib/output/get-output.js' import type { ContextHelpers } from 'lib/types.js' +import { canPrompt, prompt } from 'lib/util/prompt.js' +import { parseJsonParams, 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' @@ -37,6 +41,25 @@ const sections = [ alias: 'h', type: Boolean, }, + { + name: 'json', + description: + 'Read request params as JSON from stdin and write the response to stdout as JSON. Enabled automatically when stdout is not a terminal, disable with {bold --no-json}.', + type: Boolean, + }, + { + name: 'y', + description: + 'Do not prompt: use the given params and take the first suggestion.', + type: Boolean, + }, + ], + }, + { + header: 'Output', + content: [ + 'Only the response is written to stdout, so it is safe to pipe. Prompts, progress, and other information are written to stderr.', + 'The response is trimmed to the response key and pagination.', ], }, { @@ -62,22 +85,48 @@ const sections = [ name: 'seam access-codes list {bold --device-id} $MY_DOOR', summary: 'List you access codes.', }, + { + name: 'seam devices list {bold --json} > devices.json', + summary: 'Write the response to a file as JSON.', + }, + { + name: 'seam locks unlock-door {bold --json} < params.json', + summary: 'Read request params from a JSON file.', + }, + { + name: 'cat params.json | seam locks unlock-door {bold --json}', + summary: 'Pipe request params in as JSON.', + }, ], }, ] +/** + * Flags that configure the CLI itself and are never sent as request params. + */ +const cliFlags = new Set([ + '_', + 'h', + 'help', + 'json', + 'remote-api-defs', + 'remote_api_defs', + 'version', + 'y', +]) + async function cli(args: ParsedArgs) { const config = getConfigStore() + const output = getOutput() - if (args['help'] || args['h']) { - const usage = commandLineUsage(sections) - console.log(usage) + if (args['help'] === true || args['h'] === true) { + output.text(commandLineUsage(sections)) return } - if (args['version']) { - console.log(seamapiCliVersion) - process.exit(0) + if (args['version'] === true) { + output.text(seamapiCliVersion) + return } if ( @@ -89,10 +138,10 @@ async function cli(args: ParsedArgs) { const fakeApiUrl = `https://${randomstring}.fakeseamconnect.seam.vc` config.set('server', fakeApiUrl) - console.log(`Server URL set to ${fakeApiUrl}`) + output.info(`Server URL set to ${fakeApiUrl}`) config.set(`${getServer()}.pat`, `seam_apikey1_token`) - console.log(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) + output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) return } @@ -101,8 +150,9 @@ async function cli(args: ParsedArgs) { args._[0] !== 'login' && !isEqual(args._, ['select', 'server']) ) { - console.log(`Not logged in. Please run "seam login"`) - process.exit(1) + output.error(`Not logged in. Please run "seam login"`) + process.exitCode = 1 + return } args._ = args._.map((arg) => arg.toLowerCase().replace(/_/g, '-')) @@ -115,12 +165,17 @@ async function cli(args: ParsedArgs) { const blueprint = await getApiBlueprint(use_remote_api_defs ?? false) - const commandParams: Record = {} + const commandParams: Record = { + ...(await readParamsFromStdin(args)), + } /** - * Whether or not to auto-select first option + * Whether or not to auto-select first option. + * + * Piped or redirected input holds request params, not answers, + * so there is nobody to ask. */ - const is_interactive = args['y'] !== true + const is_interactive = args['y'] !== true && canPrompt() const ctx: ContextHelpers = { blueprint, @@ -131,8 +186,11 @@ async function cli(args: ParsedArgs) { if (k === '_') continue const v = args[k] delete args[k] - args[k.replace(/-/g, '_')] = v - commandParams[k.replace(/-/g, '_')] = v + const key = k.replace(/-/g, '_') + args[key] = v + if (!cliFlags.has(k) && !cliFlags.has(key)) { + commandParams[key] = v + } } const selectedCommand = await interactForCommandSelection(args._, ctx) @@ -157,10 +215,10 @@ async function cli(args: ParsedArgs) { return } else if (isEqual(selectedCommand, ['logout'])) { config.delete('pat') - console.log('Logged out!') + output.info('Logged out!') return } else if (isEqual(selectedCommand, ['config', 'reveal-location'])) { - console.log(config.path) + output.text(config.path) return } else if (isEqual(selectedCommand, ['config', 'use-remote-api-defs'])) { await interactForUseRemoteApiDefs() @@ -229,22 +287,52 @@ async function cli(args: ParsedArgs) { const response = await RequestSeamApi({ path: apiPath, params, + responseKey: getResponseKey(selectedCommand, ctx), }) if (response.data?.connect_webview) { await handleConnectWebviewResponse(response.data.connect_webview) } - if (response.data?.action_attempt) { - interactForActionAttemptPoll(response.data.action_attempt) + if (response.data?.action_attempt && is_interactive) { + await interactForActionAttemptPoll(response.data.action_attempt) + } +} + +/** + * Request params piped or redirected into the CLI, e.g., + * `seam devices list --json < params.json`. + * + * Params given as flags take precedence over params read here. + */ +const readParamsFromStdin = async ( + args: ParsedArgs, +): Promise> => { + const json = getJsonFlag(args) + if (typeof json === 'string') { + return parseJsonParams(json, '--json') ?? {} } + + return (await readStdinJson()) ?? {} +} + +/** + * The `--json` flag: true, false for `--no-json`, or params given inline + * as `--json '{"limit": 2}'`. + */ +const getJsonFlag = (args: ParsedArgs): boolean | string | undefined => { + const json: unknown = args['json'] + if (json === 'true') return true + if (json === 'false') return false + if (typeof json === 'boolean' || typeof json === 'string') return json + return undefined } const handleConnectWebviewResponse = async (connect_webview: any) => { const url = connect_webview.url - if (process.env['INSIDE_WEB_BROWSER'] !== '1') { - const { action } = await prompts({ + if (process.env['INSIDE_WEB_BROWSER'] !== '1' && canPrompt()) { + const { action } = await prompt({ type: 'confirm', name: 'action', message: 'Would you like to open the webview in your browser?', @@ -257,9 +345,32 @@ const handleConnectWebviewResponse = async (connect_webview: any) => { } } -cli(parseArgs(process.argv.slice(2), { string: ['code'] })).catch((e) => { - console.log(chalk.red(`CLI Error: ${e.toString()}\n${e.stack}`)) - if (e.toString().includes('object Object')) { - console.log(e) - } +/** + * Whether to write machine readable output. + * + * Explicit `--json` or `--no-json` wins, otherwise the CLI writes JSON + * whenever stdout is piped or redirected, and pretty output at a terminal. + */ +const resolveOutputFormat = (args: ParsedArgs): OutputFormat => { + const json = getJsonFlag(args) + if (json === false) return 'text' + if (json !== undefined) return 'json' + return process.stdout.isTTY === true ? 'text' : 'json' +} + +const args = parseArgs(process.argv.slice(2), { string: ['code'] }) + +setOutput( + createOutput({ + format: resolveOutputFormat(args), + colors: process.stdout.isTTY === true, + }), +) + +cli(args).catch((e: unknown) => { + const output = getOutput() + 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)) + process.exitCode = 1 }) diff --git a/src/lib/get-response-key.ts b/src/lib/get-response-key.ts new file mode 100644 index 00000000..7f0cb1ef --- /dev/null +++ b/src/lib/get-response-key.ts @@ -0,0 +1,25 @@ +import { getCommandBlueprintDef } from './get-command-blueprint-def.js' +import type { ContextHelpers } from './types.js' + +/** + * The top level response key documented for a command, + * e.g., `devices` for `seam devices list`. + * + * Returns null when the command is not in the blueprint, + * or when it does not respond with a resource. + */ +export const getResponseKey = ( + command: string[], + ctx: ContextHelpers, +): string | null => { + let endpoint + try { + endpoint = getCommandBlueprintDef(command, ctx) + } catch { + return null + } + + if (endpoint.response.responseType === 'void') return null + + return endpoint.response.responseKey +} diff --git a/src/lib/interact-for-action-attempt-poll.ts b/src/lib/interact-for-action-attempt-poll.ts index 478348af..cfcaaa47 100644 --- a/src/lib/interact-for-action-attempt-poll.ts +++ b/src/lib/interact-for-action-attempt-poll.ts @@ -1,14 +1,15 @@ import type { ActionAttemptsGetResponse } from '@seamapi/http/connect' -import prompts from 'prompts' import { getSeam } from './get-seam.js' +import { getOutput } from './output/get-output.js' +import { prompt } from './util/prompt.js' import { withLoading } from './util/with-loading.js' export const interactForActionAttemptPoll = async ( action_attempt: ActionAttemptsGetResponse['action_attempt'], ) => { if (action_attempt.status === 'pending') { - const { poll_for_action_attempt } = await prompts({ + const { poll_for_action_attempt } = await prompt({ name: 'poll_for_action_attempt', message: "Would you like to poll the action attempt until it's ready?", type: 'toggle', @@ -29,7 +30,7 @@ export const interactForActionAttemptPoll = async ( { waitForActionAttempt: { pollingInterval: 240, timeout: 10_000 } }, ), ) - console.dir(updated_action_attempt, { depth: null }) + getOutput().data({ action_attempt: updated_action_attempt }) } } } diff --git a/src/lib/interact-for-array.ts b/src/lib/interact-for-array.ts index 6f30290b..75bdebf5 100644 --- a/src/lib/interact-for-array.ts +++ b/src/lib/interact-for-array.ts @@ -1,19 +1,21 @@ -import prompts from 'prompts' +import { getOutput } from './output/get-output.js' +import { prompt } from './util/prompt.js' export const interactForArray = async ( array: string[], message: string, ): Promise => { const updatedArray = [...array] + const output = getOutput() const displayList = () => { - console.log(`${message} Current list:`) + output.info(`${message} Current list:`) if (updatedArray.length > 0) { updatedArray.forEach((item, index) => { - console.log(`${index + 1}: ${item}`) + output.info(`${index + 1}: ${item}`) }) } else { - console.log('The list is currently empty.') + output.info('The list is currently empty.') } } @@ -21,7 +23,7 @@ export const interactForArray = async ( do { displayList() - const response = await prompts({ + const response = await prompt({ type: 'select', name: 'action', message: 'Choose an action:', @@ -35,7 +37,7 @@ export const interactForArray = async ( action = response.action if (action === 'add') { - const { newItem } = await prompts({ + const { newItem } = await prompt({ type: 'text', name: 'newItem', message: 'Enter the new item:', @@ -44,7 +46,7 @@ export const interactForArray = async ( updatedArray.push(newItem) } } else if (action === 'remove') { - const { index } = await prompts({ + const { index } = await prompt({ type: 'number', name: 'index', message: 'Enter the index of the item to remove:', diff --git a/src/lib/interact-for-blueprint-object.ts b/src/lib/interact-for-blueprint-object.ts index e9f5ddd6..0970c8c2 100644 --- a/src/lib/interact-for-blueprint-object.ts +++ b/src/lib/interact-for-blueprint-object.ts @@ -1,5 +1,4 @@ import type { Parameter } from '@seamapi/blueprint' -import prompts from 'prompts' import { interactForAccessCode } from './interact-for-access-code.js' import { interactForAcsEntrance } from './interact-for-acs-entrance.js' @@ -11,8 +10,10 @@ 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 { ellipsis } from './util/ellipsis.js' +import { canPrompt, prompt } from './util/prompt.js' const ergonomicPropOrder = [ 'name', @@ -53,6 +54,15 @@ export const interactForBlueprintObject = async ( return args.params } + if (!canPrompt()) { + if (haveAllRequiredParams) return args.params + + const missing = required.filter((k) => !args.params[k]) + throw new Error( + `Missing required param${missing.length === 1 ? '' : 's'} ${missing.join(', ')} for /${args.command.join('/').replace(/-/g, '_')}: pass as flags or pipe them in as JSON`, + ) + } + const propSortScore = (prop: string) => { if (required.includes(prop)) return 100 - ergonomicPropOrder.indexOf(prop) if (args.params[prop] !== undefined) { @@ -66,8 +76,8 @@ export const interactForBlueprintObject = async ( ? `Editing "${args.subPropertyPath}"` : `[${cmdPath}] Parameters` - console.log('') - const { paramToEdit } = await prompts({ + getOutput().info() + const { paramToEdit } = await prompt({ name: 'paramToEdit', message: parameterSelectionMessage, type: 'autocomplete', @@ -187,7 +197,7 @@ export const interactForBlueprintObject = async ( value = await interactForTimestamp() } else { value = ( - await prompts({ + await prompt({ name: 'value', message: `${paramToEdit}:`, type: 'text', @@ -198,7 +208,7 @@ export const interactForBlueprintObject = async ( return interactForBlueprintObject(args, ctx) } else if (prop.format === 'enum') { const value = ( - await prompts({ + await prompt({ name: 'value', message: `${paramToEdit}:`, type: 'select', @@ -211,7 +221,7 @@ export const interactForBlueprintObject = async ( args.params[paramToEdit] = value return interactForBlueprintObject(args, ctx) } else if (prop.format === 'boolean') { - const { value } = await prompts({ + const { value } = await prompt({ name: 'value', message: `${paramToEdit}:`, type: 'toggle', @@ -225,7 +235,7 @@ export const interactForBlueprintObject = async ( return interactForBlueprintObject(args, ctx) } else if (prop.format === 'list' && prop.itemFormat === 'enum') { const value = ( - await prompts({ + await prompt({ name: 'value', message: `${paramToEdit}:`, type: 'autocompleteMultiselect', @@ -256,7 +266,7 @@ export const interactForBlueprintObject = async ( ) return interactForBlueprintObject(args, ctx) } else if (prop.format === 'number') { - const { value } = await prompts({ + const { value } = await prompt({ name: 'value', message: `${paramToEdit}:`, type: 'number', diff --git a/src/lib/interact-for-command-selection.ts b/src/lib/interact-for-command-selection.ts index 3eef904a..427ed5a2 100644 --- a/src/lib/interact-for-command-selection.ts +++ b/src/lib/interact-for-command-selection.ts @@ -1,8 +1,7 @@ import { isDeepStrictEqual as isEqual } from 'node:util' -import prompts from 'prompts' - import type { ContextHelpers } from './types.js' +import { canPrompt, prompt } from './util/prompt.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() @@ -72,7 +71,13 @@ export async function interactForCommandSelection( const commandPathStr = commandPath.join('/').replace(/-/g, '_') - const res = await prompts({ + if (!canPrompt()) { + throw new Error( + `Ambiguous command "/${commandPathStr}": specify a full command, e.g., "seam devices list"`, + ) + } + + const res = await prompt({ name: 'Command', type: 'autocomplete', choices: [ diff --git a/src/lib/interact-for-custom-metadata.ts b/src/lib/interact-for-custom-metadata.ts index 4b356516..21f07317 100644 --- a/src/lib/interact-for-custom-metadata.ts +++ b/src/lib/interact-for-custom-metadata.ts @@ -1,5 +1,7 @@ import type { CustomMetadata } from '@seamapi/types/connect' -import prompts from 'prompts' + +import { getOutput } from './output/get-output.js' +import { prompt } from './util/prompt.js' type UpdatedCustomMetadata = { [x: string]: string | boolean | null @@ -9,15 +11,16 @@ export const interactForCustomMetadata = async ( custom_metadata: CustomMetadata, ) => { const updated_custom_metadata: UpdatedCustomMetadata = { ...custom_metadata } + const output = getOutput() const displayCurrentCustomMetadata = () => { - console.log('custom_metadata:') + output.info('custom_metadata:') if (Object.keys(updated_custom_metadata).length > 0) { Object.keys(updated_custom_metadata).forEach((key, index) => { - console.log(`${index + 1}: ${key}: ${updated_custom_metadata[key]}`) + output.info(`${index + 1}: ${key}: ${updated_custom_metadata[key]}`) }) } else { - console.log('The custom metadata param is empty.') + output.info('The custom metadata param is empty.') } } @@ -26,7 +29,7 @@ export const interactForCustomMetadata = async ( do { displayCurrentCustomMetadata() - const response = await prompts({ + const response = await prompt({ type: 'select', name: 'action', message: 'Choose an action:', @@ -40,13 +43,13 @@ export const interactForCustomMetadata = async ( action = response.action if (action === 'add') { - const { newKey } = await prompts({ + const { newKey } = await prompt({ type: 'text', name: 'newKey', message: 'Enter a key to add or edit:', }) - let { newValue } = await prompts({ + let { newValue } = await prompt({ type: 'text', name: 'newValue', message: 'Enter the new value to add or edit (or null to delete):', @@ -62,7 +65,7 @@ export const interactForCustomMetadata = async ( } } } else if (action === 'remove') { - const { custom_key_to_remove } = await prompts({ + const { custom_key_to_remove } = await prompt({ type: 'select', name: 'custom_key_to_remove', message: 'Choose a key-value pair to remove from params:', diff --git a/src/lib/interact-for-login.ts b/src/lib/interact-for-login.ts index da772f34..3e9afb29 100644 --- a/src/lib/interact-for-login.ts +++ b/src/lib/interact-for-login.ts @@ -1,33 +1,35 @@ import { isApiKey, isPersonalAccessToken } from '@seamapi/http/connect' import chalk from 'chalk' -import prompts from 'prompts' import { getConfigStore } from './get-config-store.js' import { getServer } from './get-server.js' import { interactForWorkspaceId } from './interact-for-workspace-id.js' +import { getOutput } from './output/get-output.js' +import { prompt } from './util/prompt.js' import { withLoading } from './util/with-loading.js' import { validateToken } from './validate-token.js' export const interactForLogin = async () => { const config = await getConfigStore() + const output = getOutput() if (getServer().includes('localhost')) { - console.log( + 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`, ) } else { - console.log( + output.info( `To login, navigate to the URL below and create a new Personal Access Token (PAT) and paste the PAT in the provided box:\n\nhttps://console.seam.co/settings/access-tokens\n\n`, ) } - console.log( + output.info( chalk.gray( '> Note: You can enter an API Key here for single-workspace access', ), ) - const { pat } = await prompts({ + const { pat } = await prompt({ name: 'pat', type: 'text', message: 'Personal Access Token:', @@ -49,5 +51,5 @@ export const interactForLogin = async () => { } config.set(`${getServer()}.pat`, token) - console.log(`Token saved! You may begin using the CLI!`) + output.info(`Token saved! You may begin using the CLI!`) } diff --git a/src/lib/interact-for-resource.ts b/src/lib/interact-for-resource.ts index 13f319aa..6cbcc22b 100644 --- a/src/lib/interact-for-resource.ts +++ b/src/lib/interact-for-resource.ts @@ -1,5 +1,4 @@ -import prompts from 'prompts' - +import { prompt } from './util/prompt.js' import { withLoading } from './util/with-loading.js' export interface ResourceChoice { @@ -23,7 +22,7 @@ export const interactForResource = async ({ `Fetching ${resourceName.replace(/_/g, ' ')}s...`, fetchResources, ) - const { resourceId } = await prompts({ + const { resourceId } = await prompt({ name: 'resourceId', type: 'autocomplete', message, diff --git a/src/lib/interact-for-server-selection.ts b/src/lib/interact-for-server-selection.ts index 29e9ee5a..2435f699 100644 --- a/src/lib/interact-for-server-selection.ts +++ b/src/lib/interact-for-server-selection.ts @@ -1,9 +1,9 @@ import { randomBytes } from 'node:crypto' -import prompts from 'prompts' - import { getConfigStore } from './get-config-store.js' import { getServer } from './get-server.js' +import { getOutput } from './output/get-output.js' +import { prompt } from './util/prompt.js' export async function interactForServerSelection() { const servers = [ @@ -12,7 +12,7 @@ export async function interactForServerSelection() { 'https://fakeseamconnect.seam.vc', ] - const { server } = await prompts([ + const { server } = await prompt([ { type: 'select', name: 'server', @@ -22,8 +22,9 @@ export async function interactForServerSelection() { ]) const config = getConfigStore() + const output = getOutput() if (server === servers[2]) { - let { userUrlSeed } = await prompts([ + let { userUrlSeed } = await prompt([ { type: 'text', name: 'userUrlSeed', @@ -37,10 +38,10 @@ export async function interactForServerSelection() { } config.set('server', `https://${userUrlSeed}.fakeseamconnect.seam.vc`) config.set(`${getServer()}.pat`, `seam_apikey1_token`) - console.log(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) + output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) } else { config.set('server', server) } config.delete('current_workspace_id') - console.log(`Server set to ${server}`) + output.info(`Server set to ${server}`) } diff --git a/src/lib/interact-for-timestamp.ts b/src/lib/interact-for-timestamp.ts index b6c5c5a1..58229911 100644 --- a/src/lib/interact-for-timestamp.ts +++ b/src/lib/interact-for-timestamp.ts @@ -1,6 +1,6 @@ -import prompts from 'prompts' +import { prompt } from './util/prompt.js' export const interactForTimestamp = async () => { - const { timestamp } = await prompts({ + const { timestamp } = await prompt({ name: 'timestamp', type: 'date', message: 'Enter a timestamp:', diff --git a/src/lib/interact-for-use-remote-api-defs.ts b/src/lib/interact-for-use-remote-api-defs.ts index 7580da5e..68d35d7d 100644 --- a/src/lib/interact-for-use-remote-api-defs.ts +++ b/src/lib/interact-for-use-remote-api-defs.ts @@ -1,9 +1,9 @@ -import prompts from 'prompts' - import { getConfigStore } from './get-config-store.js' +import { getOutput } from './output/get-output.js' +import { prompt } from './util/prompt.js' export async function interactForUseRemoteApiDefs() { - const { use_remote_api_defs } = await prompts([ + const { use_remote_api_defs } = await prompt([ { type: 'select', name: 'use_remote_api_defs', @@ -23,5 +23,5 @@ export async function interactForUseRemoteApiDefs() { const config = getConfigStore() config.set('use_remote_api_defs', use_remote_api_defs) - console.log(`Use remote API Definitions: ${use_remote_api_defs}`) + getOutput().info(`Use remote API Definitions: ${use_remote_api_defs}`) } diff --git a/src/lib/interact-for-workspace-id.ts b/src/lib/interact-for-workspace-id.ts index 69ec9fde..eebf7a74 100644 --- a/src/lib/interact-for-workspace-id.ts +++ b/src/lib/interact-for-workspace-id.ts @@ -1,9 +1,9 @@ import { SeamHttpWithoutWorkspace } from '@seamapi/http/connect' -import prompts from 'prompts' import { getConfigStore } from './get-config-store.js' import { getSeamMultiWorkspace } from './get-seam.js' import { getServer } from './get-server.js' +import { prompt } from './util/prompt.js' import { withLoading } from './util/with-loading.js' export const interactForWorkspaceId = async (personalAccessToken?: string) => { @@ -17,7 +17,7 @@ export const interactForWorkspaceId = async (personalAccessToken?: string) => { const workspaces = await withLoading('Fetching workspaces...', () => seam.workspaces.list(), ) - const { workspaceId } = await prompts({ + const { workspaceId } = await prompt({ name: 'workspaceId', type: 'select', message: 'Select a workspace:', diff --git a/src/lib/output/create-memory-output.ts b/src/lib/output/create-memory-output.ts new file mode 100644 index 00000000..91e4ef29 --- /dev/null +++ b/src/lib/output/create-memory-output.ts @@ -0,0 +1,39 @@ +import { + createOutput, + type CreateOutputOptions, + type Output, + type OutputStream, +} from './create-output.js' + +export interface MemoryOutput { + output: Output + /** Everything written to stdout so far. */ + stdout: () => string + /** Everything written to stderr so far. */ + stderr: () => string +} + +const createMemoryStream = (): OutputStream & { read: () => string } => { + const chunks: string[] = [] + return { + write: (chunk: string) => chunks.push(chunk), + read: () => chunks.join(''), + } +} + +/** + * An {@link Output} that captures writes in memory instead of + * touching the process streams, for asserting on CLI output. + */ +export const createMemoryOutput = ( + options: Omit = {}, +): MemoryOutput => { + const stdout = createMemoryStream() + const stderr = createMemoryStream() + + return { + output: createOutput({ ...options, stdout, stderr }), + stdout: stdout.read, + stderr: stderr.read, + } +} diff --git a/src/lib/output/create-output.test.ts b/src/lib/output/create-output.test.ts new file mode 100644 index 00000000..ce39d3f5 --- /dev/null +++ b/src/lib/output/create-output.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from 'vitest' + +import { createMemoryOutput } from './create-memory-output.js' + +test('createOutput: writes data to stdout as json', () => { + const { output, stdout, stderr } = createMemoryOutput({ format: 'json' }) + + output.data({ devices: [{ device_id: 'abc' }] }) + + expect(JSON.parse(stdout())).toEqual({ devices: [{ device_id: 'abc' }] }) + expect(stderr()).toBe('') +}) + +test('createOutput: pretty prints data as text', () => { + const { output, stdout } = createMemoryOutput({ format: 'text' }) + + output.data({ devices: [{ device_id: 'abc' }] }) + + expect(stdout()).toBe("{ devices: [ { device_id: 'abc' } ] }\n") +}) + +test('createOutput: writes plain text results to stdout verbatim', () => { + const json = createMemoryOutput({ format: 'json' }) + const text = createMemoryOutput({ format: 'text' }) + + json.output.text('1.2.3') + text.output.text('1.2.3') + + expect(json.stdout()).toBe('1.2.3\n') + expect(text.stdout()).toBe('1.2.3\n') +}) + +test('createOutput: keeps info off stdout', () => { + const { output, stdout, stderr } = createMemoryOutput({ format: 'text' }) + + output.info('Making request...') + + expect(stdout()).toBe('') + expect(stderr()).toBe('Making request...\n') +}) + +test('createOutput: suppresses info in the json format', () => { + const { output, stdout, stderr } = createMemoryOutput({ format: 'json' }) + + output.info('Making request...') + + expect(stdout()).toBe('') + expect(stderr()).toBe('') +}) + +test('createOutput: always reports warnings and errors on stderr', () => { + const { output, stdout, stderr } = createMemoryOutput({ format: 'json' }) + + output.warn('[400]') + output.error('CLI Error: Network Error') + + expect(stdout()).toBe('') + expect(stderr()).toBe('[400]\nCLI Error: Network Error\n') +}) + +test('createOutput: ignores undefined data', () => { + const { output, stdout } = createMemoryOutput({ format: 'json' }) + + output.data(undefined) + + expect(stdout()).toBe('') +}) + +test('createOutput: defaults to the text format', () => { + const { output } = createMemoryOutput() + + expect(output.format).toBe('text') +}) diff --git a/src/lib/output/create-output.ts b/src/lib/output/create-output.ts new file mode 100644 index 00000000..21239485 --- /dev/null +++ b/src/lib/output/create-output.ts @@ -0,0 +1,104 @@ +import { inspect } from 'node:util' + +/** + * Minimal writable surface needed by {@link Output}. + * + * Both `process.stdout` and an in-memory buffer satisfy this, + * which is what makes output testable. + */ +export interface OutputStream { + write: (chunk: string) => unknown +} + +/** + * `json` is machine readable: only {@link Output.data} produces output. + * `text` is human readable: data is pretty printed and may be colorized. + */ +export type OutputFormat = 'json' | 'text' + +export interface Output { + readonly format: OutputFormat + + /** + * The result of a command, e.g., an API response. + * + * This is the only thing ever written to stdout, + * so it is safe to pipe into another program. + */ + data: (value: unknown) => void + + /** + * A plain text command result, e.g., the version or the help guide. + * + * Written to stdout verbatim in every format: these results are already + * a single value, so encoding them as JSON would only make them harder + * to consume from a pipe. + */ + text: (value: string) => void + + /** + * Human facing progress, context, and confirmations. + * + * Written to stderr and suppressed entirely in the json format. + */ + info: (message?: string) => void + + /** Human facing warning. Written to stderr in every format. */ + warn: (message: string) => void + + /** Human facing error. Written to stderr in every format. */ + error: (message: string) => void +} + +export interface CreateOutputOptions { + format?: OutputFormat + stdout?: OutputStream + stderr?: OutputStream + /** Colorize pretty printed data. Never applied to the json format. */ + 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`) + }, + } +} + +const formatData = ( + value: unknown, + format: OutputFormat, + colors: boolean, +): string => { + if (format === 'json') return JSON.stringify(value, null, 2) + if (typeof value === 'string') return value + return inspect(value, { depth: null, colors }) +} diff --git a/src/lib/output/get-output.ts b/src/lib/output/get-output.ts new file mode 100644 index 00000000..93ee6fac --- /dev/null +++ b/src/lib/output/get-output.ts @@ -0,0 +1,24 @@ +import { createOutput, type Output } from './create-output.js' + +let output: Output | null = null + +/** + * The output used by the CLI. + * + * Defaults to a `text` output bound to the real process streams, + * so importing modules never write to stdout by accident. + */ +export const getOutput = (): Output => { + output ??= createOutput() + return output +} + +/** Replace the output, e.g., once flags are parsed, or from a test. */ +export const setOutput = (nextOutput: Output): void => { + output = nextOutput +} + +/** Restore the default output. Intended for tests. */ +export const resetOutput = (): void => { + output = null +} diff --git a/src/lib/output/select-response-payload.test.ts b/src/lib/output/select-response-payload.test.ts new file mode 100644 index 00000000..1c5e6827 --- /dev/null +++ b/src/lib/output/select-response-payload.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from 'vitest' + +import { selectResponsePayload } from './select-response-payload.js' + +test('selectResponsePayload: keeps the response key and pagination', () => { + const payload = selectResponsePayload( + { + devices: [{ device_id: 'abc' }], + pagination: { has_next_page: false }, + ok: true, + }, + { responseKey: 'devices' }, + ) + + expect(payload).toEqual({ + devices: [{ device_id: 'abc' }], + pagination: { has_next_page: false }, + }) +}) + +test('selectResponsePayload: drops top level fields outside the response key', () => { + const payload = selectResponsePayload( + { device: { device_id: 'abc' }, ok: true, warnings: [] }, + { responseKey: 'device' }, + ) + + expect(payload).toEqual({ device: { device_id: 'abc' } }) +}) + +test('selectResponsePayload: drops meta fields without a known response key', () => { + const payload = selectResponsePayload({ + device: { device_id: 'abc' }, + ok: true, + }) + + expect(payload).toEqual({ device: { device_id: 'abc' } }) +}) + +test('selectResponsePayload: falls back when the response key is absent', () => { + const payload = selectResponsePayload( + { health: { ok: true }, ok: true }, + { responseKey: 'devices' }, + ) + + expect(payload).toEqual({ health: { ok: true } }) +}) + +test('selectResponsePayload: reports only the error for a failed request', () => { + const payload = selectResponsePayload( + { + error: { type: 'invalid_input', message: 'Bad request' }, + ok: false, + request_id: 'req_1', + }, + { responseKey: 'devices' }, + ) + + expect(payload).toEqual({ + error: { type: 'invalid_input', message: 'Bad request' }, + }) +}) + +test('selectResponsePayload: passes through non object bodies', () => { + expect(selectResponsePayload('Bad Gateway')).toBe('Bad Gateway') + expect(selectResponsePayload(null)).toBe(null) + expect(selectResponsePayload([1, 2])).toEqual([1, 2]) +}) diff --git a/src/lib/output/select-response-payload.ts b/src/lib/output/select-response-payload.ts new file mode 100644 index 00000000..66ada190 --- /dev/null +++ b/src/lib/output/select-response-payload.ts @@ -0,0 +1,56 @@ +/** + * Top level response fields that are transport details, + * not part of the result the CLI reports. + */ +const metaKeys = new Set(['ok']) + +const paginationKey = 'pagination' +const errorKey = 'error' + +export interface SelectResponsePayloadOptions { + /** + * The response key for the endpoint, e.g., `devices` for `/devices/list`, + * usually taken from the API blueprint. + * + * When omitted, every top level field except {@link metaKeys} is kept. + */ + responseKey?: string | null | undefined +} + +/** + * Reduce an API response body to the response key and pagination. + * + * The CLI never reports other top level fields: they are details of the + * transport, so including them would leak into anything parsing stdout. + */ +export const selectResponsePayload = ( + data: unknown, + { responseKey }: SelectResponsePayloadOptions = {}, +): unknown => { + if (!isRecord(data)) return data + + if (errorKey in data) { + return { [errorKey]: data[errorKey] } + } + + const keys = + responseKey != null && responseKey in data + ? [responseKey] + : Object.keys(data).filter( + (key) => !metaKeys.has(key) && key !== paginationKey, + ) + + const payload: Record = {} + for (const key of keys) { + payload[key] = data[key] + } + + if (paginationKey in data) { + payload[paginationKey] = data[paginationKey] + } + + return payload +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) diff --git a/src/lib/util/prompt.ts b/src/lib/util/prompt.ts new file mode 100644 index 00000000..3ea2d45c --- /dev/null +++ b/src/lib/util/prompt.ts @@ -0,0 +1,33 @@ +import prompts, { type Answers, type Options, type PromptObject } from 'prompts' + +/** + * Whether the CLI can ask the user a question. + * + * Prompts read raw keypresses, so they need a terminal on stdin: + * when stdin is a pipe or a file it holds request params, not answers. + */ +export const canPrompt = (): boolean => process.stdin.isTTY === true + +/** + * Ask the user a question. + * + * Prompts are rendered to stderr: a selection is not a command result, + * so it must not end up in stdout when the CLI is piped. + */ +export const prompt = async ( + questions: PromptObject | Array>, + options?: Options, +): Promise> => { + if (!canPrompt()) { + throw new Error( + 'Cannot prompt because stdin is not a terminal. Pass params as flags or pipe them in as JSON.', + ) + } + + const questionList = Array.isArray(questions) ? questions : [questions] + + return await prompts( + questionList.map((question) => ({ ...question, stdout: process.stderr })), + options, + ) +} diff --git a/src/lib/util/read-stdin-json.test.ts b/src/lib/util/read-stdin-json.test.ts new file mode 100644 index 00000000..a9472c71 --- /dev/null +++ b/src/lib/util/read-stdin-json.test.ts @@ -0,0 +1,46 @@ +import { Readable } from 'node:stream' + +import { expect, test } from 'vitest' + +import { parseJsonParams, readStdinJson } from './read-stdin-json.js' + +const streamOf = (...chunks: string[]): Readable => Readable.from(chunks) + +test('readStdinJson: reads params piped in', async () => { + const params = await readStdinJson(streamOf('{"device_id":', '"abc"}')) + + expect(params).toEqual({ device_id: 'abc' }) +}) + +test('readStdinJson: returns null for a terminal', async () => { + const stdin = streamOf('{"device_id":"abc"}') as Readable & { + isTTY?: boolean + } + stdin.isTTY = true + + expect(await readStdinJson(stdin)).toBe(null) +}) + +test('readStdinJson: returns null when nothing is piped in', async () => { + expect(await readStdinJson(streamOf())).toBe(null) + expect(await readStdinJson(streamOf('\n '))).toBe(null) +}) + +test('readStdinJson: reports invalid json', async () => { + await expect(readStdinJson(streamOf('nope'))).rejects.toThrow( + /Could not parse JSON from stdin/, + ) +}) + +test('parseJsonParams: rejects json that is not an object of params', () => { + expect(() => parseJsonParams('[1, 2]', '--json')).toThrow( + /Expected a JSON object of request params from --json, got an array/, + ) + expect(() => parseJsonParams('42', 'stdin')).toThrow( + /Expected a JSON object of request params from stdin, got number/, + ) +}) + +test('parseJsonParams: returns null when there is nothing to parse', () => { + expect(parseJsonParams('', '--json')).toBe(null) +}) diff --git a/src/lib/util/read-stdin-json.ts b/src/lib/util/read-stdin-json.ts new file mode 100644 index 00000000..3a524193 --- /dev/null +++ b/src/lib/util/read-stdin-json.ts @@ -0,0 +1,59 @@ +import type { Readable } from 'node:stream' + +export interface StdinLike extends AsyncIterable { + isTTY?: boolean | undefined +} + +/** + * Read request params piped into the CLI, e.g., + * + * ``` + * $ echo '{"device_id": "..."}' | seam locks unlock-door --json + * $ seam locks unlock-door --json < params.json + * ``` + * + * Returns null when stdin is a terminal or is empty, + * so an interactive session is never blocked waiting for input. + */ +export const readStdinJson = async ( + stdin: StdinLike | Readable = process.stdin, +): Promise | null> => { + if ((stdin as StdinLike).isTTY ?? false) return null + + let raw = '' + for await (const chunk of stdin) { + raw += typeof chunk === 'string' ? chunk : chunk.toString('utf8') + } + + return parseJsonParams(raw, 'stdin') +} + +/** + * Parse JSON request params, e.g., from stdin or `--json '{"limit": 2}'`. + * + * Returns null when there is nothing to parse. + */ +export const parseJsonParams = ( + raw: string, + source: string, +): Record | null => { + const trimmed = raw.trim() + if (trimmed === '') return null + + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch (error) { + throw new Error( + `Could not parse JSON from ${source}: ${(error as Error).message}`, + ) + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error( + `Expected a JSON object of request params from ${source}, got ${Array.isArray(parsed) ? 'an array' : typeof parsed}`, + ) + } + + return parsed as Record +} diff --git a/src/lib/util/request-seam-api.ts b/src/lib/util/request-seam-api.ts index b8e71d7e..54a73409 100644 --- a/src/lib/util/request-seam-api.ts +++ b/src/lib/util/request-seam-api.ts @@ -1,19 +1,29 @@ 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 { withLoading } from './with-loading.js' +export interface RequestSeamApiOptions { + path: string + params: Record + /** Response key for the endpoint, used to trim the reported payload. */ + responseKey?: string | null | undefined +} + export const RequestSeamApi = async ({ path, params, -}: { - path: string - params: Record -}) => { + responseKey, +}: RequestSeamApiOptions) => { const seam = await getSeam() + const output = getOutput() - logRequest(path, params) + 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, { @@ -21,23 +31,17 @@ export const RequestSeamApi = async ({ }), ) - logResponse(response) - - return response -} - -const logResponse = (response: { status: number; data: unknown }) => { if (response.status >= 400) { - console.log(chalk.red(`\n\n[${response.status}]\n`)) + output.warn(chalk.red(`[${response.status}]`)) + process.exitCode = 1 } else { - console.log(chalk.green(`\n\n[${response.status}]`)) + output.info(chalk.green(`[${response.status}]`)) } - console.dir(response.data, { depth: null }) - console.log('\n') -} -const logRequest = (apiPath: string, params: Record) => { - console.log(`\n\n${chalk.green(apiPath)}`) - console.log(`Request Params:`) - console.log(params) + output.data(selectResponsePayload(response.data, { responseKey })) + + return response } + +const formatParams = (params: Record): string => + JSON.stringify(params, null, 2) diff --git a/src/lib/util/with-loading.ts b/src/lib/util/with-loading.ts index 5595d038..515fbb9e 100644 --- a/src/lib/util/with-loading.ts +++ b/src/lib/util/with-loading.ts @@ -1,10 +1,15 @@ import { createSpinner } from 'nanospinner' +import { getOutput } from 'lib/output/get-output.js' + export const withLoading = async ( message: string, fn: () => Promise, ): Promise => { - const spinner = createSpinner(message).start() + if (!shouldSpin()) return await fn() + + // Progress is not a command result, so it is rendered to stderr. + const spinner = createSpinner(message, { stream: process.stderr }).start() try { const result = await fn() spinner.success() @@ -14,3 +19,6 @@ export const withLoading = async ( throw error } } + +const shouldSpin = (): boolean => + getOutput().format === 'text' && process.stderr.isTTY === true diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 00000000..8c52852e --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,201 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { execa } from 'execa' +import { afterAll, beforeAll, expect, test } from 'vitest' + +const projectRoot = fileURLToPath(new URL('..', import.meta.url)) +const entrypoint = join(projectRoot, 'src', 'bin', 'cli.ts') + +const devicesListResponse = { + devices: [{ device_id: 'device1' }, { device_id: 'device2' }], + pagination: { has_next_page: false }, + ok: true, +} + +const errorResponse = { + error: { type: 'invalid_input', message: 'Bad request' }, + ok: false, +} + +let server: Server +let configHome: string +let requests: Array<{ path: string; body: unknown }> = [] +let failNextRequest = false + +beforeAll(async () => { + server = createServer((req, res) => { + let body = '' + req.on('data', (chunk) => (body += chunk)) + req.on('end', () => { + requests.push({ path: req.url ?? '', body: JSON.parse(body || '{}') }) + + if (failNextRequest) { + failNextRequest = false + res.writeHead(400, { 'content-type': 'application/json' }) + res.end(JSON.stringify(errorResponse)) + return + } + + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(devicesListResponse)) + }) + }) + + await new Promise((resolve) => server.listen(0, resolve)) + + const address = server.address() + if (address == null || typeof address === 'string') { + throw new Error('Could not determine the test server address') + } + // A host without dots: configstore reads nested keys by dot path. + const endpoint = `http://localhost:${address.port}` + + configHome = await mkdtemp(join(tmpdir(), 'seam-cli-test-')) + // Configstore keeps its config under $XDG_CONFIG_HOME/configstore. + await mkdir(join(configHome, 'configstore')) + await writeFile( + join(configHome, 'configstore', 'seam-cli.json'), + JSON.stringify({ + server: endpoint, + [endpoint]: { pat: 'seam_apikey1_token' }, + }), + ) +}) + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)) +}) + +interface CliResult { + stdout: string + stderr: string + exitCode: number | undefined +} + +const runCli = async ( + args: string[], + { input }: { input?: string } = {}, +): Promise => { + const { stdout, stderr, exitCode } = await execa( + 'node', + ['--import', 'tsx', entrypoint, ...args], + { + cwd: projectRoot, + env: { XDG_CONFIG_HOME: configHome, FORCE_COLOR: '0' }, + input: input ?? '', + reject: false, + }, + ) + + return { stdout: String(stdout), stderr: String(stderr), exitCode } +} + +test('cli: writes the version to stdout', async () => { + const { stdout, exitCode } = await runCli(['--version']) + + expect(exitCode).toBe(0) + expect(stdout).toMatch(/^\d+\.\d+\.\d+$/) +}) + +test('cli: writes the help guide to stdout', async () => { + const { stdout, exitCode } = await runCli(['--help']) + + expect(exitCode).toBe(0) + expect(stdout).toContain('Seam CLI') +}) + +test('cli: writes only the response to stdout as json', async () => { + requests = [] + const { stdout, stderr, exitCode } = await runCli(['devices', 'list']) + + expect(exitCode).toBe(0) + expect(JSON.parse(stdout)).toEqual({ + devices: devicesListResponse.devices, + pagination: devicesListResponse.pagination, + }) + expect(stdout).not.toContain('Making request') + expect(stderr).not.toContain('device1') +}) + +test('cli: reads request params piped in as json', async () => { + requests = [] + const { stdout, exitCode } = await runCli(['devices', 'list'], { + input: JSON.stringify({ limit: 2 }), + }) + + expect(exitCode).toBe(0) + expect(requests).toHaveLength(1) + expect(requests[0]?.path).toBe('/devices/list') + expect(requests[0]?.body).toEqual({ limit: 2 }) + expect(JSON.parse(stdout).devices).toHaveLength(2) +}) + +test('cli: params given as flags win over params piped in', async () => { + requests = [] + await runCli(['devices', 'list', '--limit', '5'], { + input: JSON.stringify({ limit: 2 }), + }) + + expect(requests[0]?.body).toEqual({ limit: 5 }) +}) + +test('cli: does not send cli flags as request params', async () => { + requests = [] + await runCli(['devices', 'list', '--json', '-y']) + + expect(requests[0]?.body).toEqual({}) +}) + +test('cli: reports invalid json params without writing to stdout', async () => { + const { stdout, stderr, exitCode } = await runCli(['devices', 'list'], { + input: 'not json', + }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain('Could not parse JSON from stdin') +}) + +test('cli: reports an ambiguous command without writing to stdout', async () => { + const { stdout, stderr, exitCode } = await runCli(['devices']) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain('Ambiguous command "/devices"') +}) + +test('cli: reads request params given inline as json', async () => { + requests = [] + const { exitCode } = await runCli([ + 'devices', + 'list', + '--json', + '{"limit": 3}', + ]) + + expect(exitCode).toBe(0) + expect(requests[0]?.body).toEqual({ limit: 3 }) +}) + +test('cli: pretty prints the response with --no-json', async () => { + const { stdout, exitCode } = await runCli(['devices', 'list', '--no-json']) + + expect(exitCode).toBe(0) + expect(stdout).toContain("device_id: 'device1'") + expect(stdout).not.toContain('"device_id"') +}) + +test('cli: reports a failed request on stdout and exits non-zero', async () => { + failNextRequest = true + const { stdout, stderr, exitCode } = await runCli(['devices', 'list']) + + expect(exitCode).toBe(1) + expect(JSON.parse(stdout)).toEqual({ + error: { type: 'invalid_input', message: 'Bad request' }, + }) + expect(stderr).toContain('[400]') +}) diff --git a/vitest.config.ts b/vitest.config.ts index 699940fe..06a056bc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,5 +19,7 @@ export default defineConfig({ reporter: ['html', 'lcov', 'text'], }, include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + // End to end tests spawn the CLI, which builds the API blueprint. + testTimeout: 60_000, }, })