diff --git a/README.md b/README.md index 26d3e056..6f323562 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,64 @@ 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 + +Request params may be piped or redirected in as a JSON object. Params given as +arguments win over params read from stdin. + +```bash +# Read params from a file +seam locks unlock-door < params.json + +# Or from another program +echo '{"device_id": "'"$MY_DOOR"'"}' | seam locks unlock-door + +# --device-id wins over any device_id in params.json +seam devices list --limit 5 < params.json +``` + +Pass `--json` to write the response as JSON. It is enabled automatically +whenever stdout is not a terminal, so piping and redirecting produce JSON +without passing anything. Pass `--no-json` to opt out and get the pretty +format instead. + +```bash +# Both write JSON +seam devices list --json +seam devices list | jq + +# Pretty printed, even though it is piped +seam devices list --no-json | less +``` + +Without a terminal to prompt on, the CLI behaves as though +`--non-interactive` was given: rather than waiting for an answer nobody can +give, it exits with an error naming what is missing. + +```bash +$ echo '{}' | seam locks unlock-door +Missing required parameter for /locks/unlock_door: --device-id +``` + +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/package-lock.json b/package-lock.json index dcb5e143..42a22411 100644 --- a/package-lock.json +++ b/package-lock.json @@ -100,9 +100,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -116,9 +113,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -132,9 +126,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -148,9 +139,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1256,9 +1244,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1276,9 +1261,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1296,9 +1278,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1316,9 +1295,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1336,9 +1312,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1356,9 +1329,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6503,9 +6473,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6527,9 +6494,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6551,9 +6515,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6575,9 +6536,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 17d03314..a9f7abed 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 type { ParsedArgs } from 'minimist' -import prompts from 'prompts' import { getConfigStore } from 'lib/config/index.js' import { getApiBlueprint } from 'lib/get-api-blueprint.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,14 +17,19 @@ 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 } from 'lib/output/create-output.js' +import { getOutput, setOutput } from 'lib/output/get-output.js' +import { resolveOutputFormat } from 'lib/output/resolve-output-format.js' import type { ContextHelpers } from 'lib/types.js' import { + cliFlags, getInteractivity, type Interactivity, - interactivityFlags, NonInteractiveError, parseCliArgs, } from 'lib/util/cli-args.js' +import { canPrompt, prompt } from 'lib/util/prompt.js' +import { readStdinJson } from 'lib/util/read-stdin-json.js' import { RequestSeamApi } from 'lib/util/request-seam-api.js' import { validateToken } from 'lib/validate-token.js' import seamapiCliVersion from 'lib/version.js' @@ -58,6 +63,12 @@ const sections = [ alias: 'y', type: Boolean, }, + { + name: 'json', + description: + 'Write the response to stdout as JSON. Enabled automatically when stdout is not a terminal, disable with {bold --no-json}.', + type: Boolean, + }, { name: 'update', description: 'Force an update of the cached Seam API definitions.', @@ -65,6 +76,14 @@ const sections = [ }, ], }, + { + 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.', + 'Request params may be piped or redirected in as a JSON object. Params given as arguments win over params read from stdin.', + ], + }, { header: 'Command List Examples', content: [ @@ -100,22 +119,30 @@ const sections = [ name: 'seam access-codes list {bold --device-id} $MY_DOOR', summary: 'List you access codes.', }, + { + name: 'seam devices list > devices.json', + summary: 'Write the response to a file as JSON.', + }, + { + name: 'cat params.json | seam locks unlock-door', + summary: 'Pipe request params in as JSON.', + }, ], }, ] async function cli(args: ParsedArgs) { const config = getConfigStore() + const output = getOutput() if (args['help'] || args['h']) { - const usage = commandLineUsage(sections) - console.log(usage) + output.text(commandLineUsage(sections)) return } if (args['version']) { - console.log(seamapiCliVersion) - process.exit(0) + output.text(seamapiCliVersion) + return } if ( @@ -127,10 +154,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 } @@ -139,8 +166,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, '-')) @@ -158,11 +186,13 @@ async function cli(args: ParsedArgs) { update, }) - const commandParams: Record = {} + // Params piped or redirected in, e.g., `seam devices list < params.json`. + // Params given as arguments take precedence over these. + const commandParams: Record = { ...(await readStdinJson()) } const ctx: ContextHelpers = { blueprint, - interactivity: getInteractivity(args), + interactivity: getInteractivity(args, { canPrompt: canPrompt() }), } const isNonInteractive = ctx.interactivity === 'non-interactive' @@ -173,7 +203,7 @@ async function cli(args: ParsedArgs) { delete args[k] const key = k.replace(/-/g, '_') args[key] = v - if (interactivityFlags.includes(key)) continue + if (cliFlags.includes(key)) continue commandParams[key] = v } @@ -204,10 +234,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'])) { if (isNonInteractive) { @@ -291,6 +321,7 @@ async function cli(args: ParsedArgs) { const response = await RequestSeamApi({ path: apiPath, params, + responseKey: getResponseKey(selectedCommand, ctx), }) if (response.data?.connect_webview) { @@ -301,7 +332,7 @@ async function cli(args: ParsedArgs) { } if (response.data?.action_attempt && !isNonInteractive) { - interactForActionAttemptPoll(response.data.action_attempt) + await interactForActionAttemptPoll(response.data.action_attempt) } } @@ -315,7 +346,7 @@ const handleConnectWebviewResponse = async ( interactivity !== 'non-interactive' && process.env['INSIDE_WEB_BROWSER'] !== '1' ) { - const { action } = await prompts({ + const { action } = await prompt({ type: 'confirm', name: 'action', message: 'Would you like to open the webview in your browser?', @@ -338,17 +369,30 @@ const run = async (argv: string[]) => { return } - await cli(parseCliArgs(argv)) + const args = parseCliArgs(argv) + + const isTty = process.stdout.isTTY === true + + setOutput( + createOutput({ + format: resolveOutputFormat(argv, { isTty }), + colors: isTty, + }), + ) + + await cli(args) } -run(process.argv.slice(2)).catch((e) => { +run(process.argv.slice(2)).catch((e: unknown) => { + const output = getOutput() + process.exitCode = 1 + if (e instanceof NonInteractiveError) { - console.log(chalk.red(e.message)) - process.exit(1) + output.error(chalk.red(e.message)) + return } - console.log(chalk.red(`CLI Error: ${e.toString()}\n${e.stack}`)) - if (e.toString().includes('object Object')) { - console.log(e) - } + const error = e instanceof Error ? e : new Error(String(e)) + output.error(chalk.red(`CLI Error: ${error.message}`)) + if (error.stack != null) output.error(chalk.gray(error.stack)) }) diff --git a/src/lib/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.test.ts b/src/lib/interact-for-blueprint-object.test.ts index fcbae014..eda0b253 100644 --- a/src/lib/interact-for-blueprint-object.test.ts +++ b/src/lib/interact-for-blueprint-object.test.ts @@ -1,16 +1,21 @@ import type { Parameter } from '@seamapi/blueprint' -import prompts from 'prompts' import { beforeEach, expect, test, vi } from 'vitest' import { interactForBlueprintObject } from './interact-for-blueprint-object.js' +import { createMemoryOutput } from './output/create-memory-output.js' +import { setOutput } from './output/get-output.js' import type { ContextHelpers } from './types.js' +import { prompt } from './util/prompt.js' -vi.mock('prompts', () => ({ - default: vi.fn(async () => ({ paramToEdit: 'done' })), +vi.mock('./util/prompt.js', () => ({ + canPrompt: vi.fn(() => true), + prompt: vi.fn(async () => ({ paramToEdit: 'done' })), })) beforeEach(() => { - vi.mocked(prompts).mockClear() + vi.mocked(prompt).mockClear() + // Keep the interactive chrome out of the test output. + setOutput(createMemoryOutput().output) }) const parameters = [ @@ -31,7 +36,7 @@ test('interactForBlueprintObject: submits without prompting once every required await expect( interactForBlueprintObject(args({ device_id: 'device1' }), ctx('auto')), ).resolves.toEqual({ device_id: 'device1' }) - expect(prompts).not.toHaveBeenCalled() + expect(prompt).not.toHaveBeenCalled() }) test('interactForBlueprintObject: prompts to review given parameters when interactive', async () => { @@ -41,7 +46,7 @@ test('interactForBlueprintObject: prompts to review given parameters when intera ctx('interactive'), ), ).resolves.toEqual({ device_id: 'device1' }) - expect(prompts).toHaveBeenCalledTimes(1) + expect(prompt).toHaveBeenCalledTimes(1) }) test('interactForBlueprintObject: prefills the prompt with the given parameters', async () => { @@ -50,7 +55,7 @@ test('interactForBlueprintObject: prefills the prompt with the given parameters' ctx('interactive'), ) - const { choices } = vi.mocked(prompts).mock.calls[0]?.[0] as { + const { choices } = vi.mocked(prompt).mock.calls[0]?.[0] as { choices: Array<{ value: string; description?: string }> } expect(choices.find(({ value }) => value === 'device_id')).toMatchObject({ @@ -65,7 +70,7 @@ test('interactForBlueprintObject: submits without prompting when non-interactive ctx('non-interactive'), ), ).resolves.toEqual({ device_id: 'device1' }) - expect(prompts).not.toHaveBeenCalled() + expect(prompt).not.toHaveBeenCalled() }) test('interactForBlueprintObject: rejects missing required parameters when non-interactive', async () => { @@ -77,5 +82,5 @@ test('interactForBlueprintObject: rejects missing required parameters when non-i ).rejects.toThrowError( 'Missing required parameter for /devices/get: --device-id', ) - expect(prompts).not.toHaveBeenCalled() + expect(prompt).not.toHaveBeenCalled() }) diff --git a/src/lib/interact-for-blueprint-object.ts b/src/lib/interact-for-blueprint-object.ts index 1d198af7..63f363f8 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,9 +10,11 @@ import { interactForCustomMetadata } from './interact-for-custom-metadata.js' import { interactForDevice } from './interact-for-device.js' import { interactForTimestamp } from './interact-for-timestamp.js' import { interactForUserIdentity } from './interact-for-user-identity.js' +import { getOutput } from './output/get-output.js' import type { ContextHelpers } from './types.js' import { NonInteractiveError, toArgName } from './util/cli-args.js' import { ellipsis } from './util/ellipsis.js' +import { prompt } from './util/prompt.js' const ergonomicPropOrder = [ 'name', @@ -82,8 +83,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', @@ -203,7 +204,7 @@ export const interactForBlueprintObject = async ( value = await interactForTimestamp() } else { value = ( - await prompts({ + await prompt({ name: 'value', message: `${paramToEdit}:`, type: 'text', @@ -214,7 +215,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', @@ -227,7 +228,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', @@ -241,7 +242,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', @@ -272,7 +273,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 a21baa70..c9a02f27 100644 --- a/src/lib/interact-for-command-selection.ts +++ b/src/lib/interact-for-command-selection.ts @@ -1,9 +1,8 @@ import { isDeepStrictEqual as isEqual } from 'node:util' -import prompts from 'prompts' - import type { ContextHelpers } from './types.js' import { NonInteractiveError } from './util/cli-args.js' +import { prompt } from './util/prompt.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() @@ -93,7 +92,7 @@ export async function interactForCommandSelection( const commandPathStr = commandPath.join('/').replace(/-/g, '_') - const res = await prompts({ + 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 d0e80dfb..94cec49e 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 './config/index.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 0c6d8cd1..2958fc34 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 './config/index.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 695bc122..71009dd4 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 './config/index.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 4f0dc287..04f58c02 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 './config/index.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/resolve-output-format.test.ts b/src/lib/output/resolve-output-format.test.ts new file mode 100644 index 00000000..fef821d5 --- /dev/null +++ b/src/lib/output/resolve-output-format.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from 'vitest' + +import { resolveOutputFormat } from './resolve-output-format.js' + +test('resolveOutputFormat: writes json when piped or redirected', () => { + expect(resolveOutputFormat(['devices', 'list'], { isTty: false })).toBe( + 'json', + ) +}) + +test('resolveOutputFormat: pretty prints at a terminal', () => { + expect(resolveOutputFormat(['devices', 'list'], { isTty: true })).toBe('text') +}) + +test('resolveOutputFormat: --json wins at a terminal', () => { + expect( + resolveOutputFormat(['devices', 'list', '--json'], { isTty: true }), + ).toBe('json') +}) + +test('resolveOutputFormat: --no-json wins when piped', () => { + expect( + resolveOutputFormat(['devices', 'list', '--no-json'], { isTty: false }), + ).toBe('text') +}) + +test('resolveOutputFormat: the last flag wins', () => { + expect( + resolveOutputFormat(['devices', 'list', '--json', '--no-json'], { + isTty: true, + }), + ).toBe('text') +}) + +test('resolveOutputFormat: ignores a --json-like parameter value', () => { + expect( + resolveOutputFormat(['devices', 'list', '--name', '--no-json-x'], { + isTty: true, + }), + ).toBe('text') +}) diff --git a/src/lib/output/resolve-output-format.ts b/src/lib/output/resolve-output-format.ts new file mode 100644 index 00000000..de451de7 --- /dev/null +++ b/src/lib/output/resolve-output-format.ts @@ -0,0 +1,33 @@ +import type { OutputFormat } from './create-output.js' + +export interface ResolveOutputFormatOptions { + /** Whether stdout is a terminal. */ + isTty?: boolean +} + +/** + * Whether to write machine readable output. + * + * An explicit `--json` or `--no-json` wins, otherwise the CLI writes JSON + * whenever stdout is piped or redirected, and pretty output at a terminal. + * + * This reads the arguments rather than the parsed args because `--json` is + * declared as a boolean flag, so that it never consumes the argument after + * it, and a boolean cannot tell `--no-json` apart from not passing anything. + */ +export const resolveOutputFormat = ( + argv: string[], + { isTty = false }: ResolveOutputFormatOptions = {}, +): OutputFormat => { + const flag = argv.filter((arg) => arg === '--json' || arg === '--no-json') + + // The last one wins, matching how the argument parser resolves repeats. + switch (flag[flag.length - 1]) { + case '--json': + return 'json' + case '--no-json': + return 'text' + default: + return isTty ? 'text' : 'json' + } +} 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/cli-args.test.ts b/src/lib/util/cli-args.test.ts index 106ad1c8..cb91d3fa 100644 --- a/src/lib/util/cli-args.test.ts +++ b/src/lib/util/cli-args.test.ts @@ -55,3 +55,15 @@ test('toArgName: renders a parameter as its argument', () => { expect(toArgName('device_id')).toBe('--device-id') expect(toArgName('code')).toBe('--code') }) + +test('getInteractivity: never prompts without a terminal', () => { + expect( + getInteractivity(parse(['devices', 'list']), { canPrompt: false }), + ).toBe('non-interactive') +}) + +test('getInteractivity: an explicit --interactive still prompts', () => { + expect( + getInteractivity(parse(['devices', 'list', '-i']), { canPrompt: false }), + ).toBe('interactive') +}) diff --git a/src/lib/util/cli-args.ts b/src/lib/util/cli-args.ts index 78fbb2a1..2005da9c 100644 --- a/src/lib/util/cli-args.ts +++ b/src/lib/util/cli-args.ts @@ -23,6 +23,20 @@ export const interactivityFlags: string[] = [ 'i', ] +/** + * Argument keys that configure the CLI itself + * and are therefore never sent as command parameters. + */ +export const cliFlags: string[] = [ + ...interactivityFlags, + 'h', + 'help', + 'json', + 'remote_api_defs', + 'update', + 'version', +] + /** * Thrown when the CLI needs input it cannot prompt for. */ @@ -33,13 +47,27 @@ export class NonInteractiveError extends Error { export const parseCliArgs = (argv: string[]): ParsedArgs => parseArgs(argv, { string: ['code'], - boolean: ['non-interactive', 'interactive'], + boolean: ['non-interactive', 'interactive', 'json'], // Deliberately not aliased to -n, which is reserved for a future // --dry-run flag. alias: { 'non-interactive': 'y', interactive: 'i' }, }) -export const getInteractivity = (args: ParsedArgs): Interactivity => { +export interface GetInteractivityOptions { + /** + * Whether there is a terminal to prompt on. + * + * When there is not, the CLI cannot ask for anything, so it behaves as + * though `--non-interactive` was given rather than waiting on a prompt + * nobody can answer. + */ + canPrompt?: boolean +} + +export const getInteractivity = ( + args: ParsedArgs, + { canPrompt = true }: GetInteractivityOptions = {}, +): Interactivity => { const isNonInteractive = args['non_interactive'] === true || args['y'] === true const isInteractive = args['interactive'] === true || args['i'] === true @@ -50,7 +78,9 @@ export const getInteractivity = (args: ParsedArgs): Interactivity => { ) } if (isNonInteractive) return 'non-interactive' + // An explicit --interactive still asks, and fails loudly if it cannot. if (isInteractive) return 'interactive' + if (!canPrompt) return 'non-interactive' return 'auto' } diff --git a/src/lib/util/prompt.ts b/src/lib/util/prompt.ts new file mode 100644 index 00000000..b4e1ae4b --- /dev/null +++ b/src/lib/util/prompt.ts @@ -0,0 +1,38 @@ +import prompts, { type Answers, type Options, type PromptObject } from 'prompts' + +import { NonInteractiveError } from './cli-args.js' + +/** + * Whether the CLI can ask the user a question. + * + * Prompts read raw keypresses and render an interface, so they need a + * terminal on both ends: when stdin is a pipe or a file it holds request + * params, not answers, and when stderr is redirected nobody sees the + * question. + */ +export const canPrompt = (): boolean => + process.stdin.isTTY === true && process.stderr.isTTY === true + +/** + * 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 NonInteractiveError( + 'Cannot prompt without a terminal: pass the missing arguments, 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..923e174c --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,217 @@ +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 stateHome: string +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}` + + // Settings live under the config dir, auth state under the state dir. + const home = await mkdtemp(join(tmpdir(), 'seam-cli-test-')) + configHome = join(home, 'config') + stateHome = join(home, 'state') + await mkdir(join(configHome, 'seam'), { recursive: true }) + await mkdir(join(stateHome, 'seam'), { recursive: true }) + await writeFile( + join(configHome, 'seam', 'cli.json'), + JSON.stringify({ server: endpoint }), + ) + await writeFile( + join(stateHome, 'seam', 'cli.json'), + JSON.stringify({ [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, + XDG_STATE_HOME: stateHome, + 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 incomplete command without writing to stdout', async () => { + const { stdout, stderr, exitCode } = await runCli(['devices']) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain('Incomplete command "seam devices"') +}) + +test('cli: reports missing required params rather than prompting', async () => { + const { stdout, stderr, exitCode } = await runCli(['locks', 'unlock-door']) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain( + 'Missing required parameter for /locks/unlock_door: --device-id', + ) +}) + +test('cli: takes --json in any position without consuming an argument', async () => { + requests = [] + const { stdout, exitCode } = await runCli(['--json', 'devices', 'list']) + + expect(exitCode).toBe(0) + expect(requests[0]?.path).toBe('/devices/list') + expect(requests[0]?.body).toEqual({}) + expect(JSON.parse(stdout).devices).toHaveLength(2) +}) + +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, }, })