diff --git a/README.md b/README.md index 2a057850..22f26c07 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,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 147a0f07..f08d1c5c 100644 --- a/src/lib/interact-for-custom-metadata.ts +++ b/src/lib/interact-for-custom-metadata.ts @@ -1,4 +1,5 @@ -import prompts from 'prompts' +import { getOutput } from './output/get-output.js' +import { prompt } 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. @@ -12,15 +13,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.') } } @@ -29,7 +31,7 @@ export const interactForCustomMetadata = async ( do { displayCurrentCustomMetadata() - const response = await prompts({ + const response = await prompt({ type: 'select', name: 'action', message: 'Choose an action:', @@ -43,13 +45,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):', @@ -65,7 +67,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, }, })