diff --git a/README.md b/README.md index 2a057850..78407a99 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ A command line interface (CLI) for interacting with the Seam API. ## Description -Every command is interactive: the CLI prompts for any missing required -parameter with suggestions pulled from your workspace. Pass `-y` to take the -first suggestion instead of being asked. +Commands run as soon as every required parameter is given. Anything missing is +prompted for, with suggestions pulled from your workspace. Pass +`--non-interactive` (or `-y`) to never be prompted: the command fails instead. ## Installation @@ -32,9 +32,19 @@ $ paru -S seam-bin ## Usage -Every `seam` command is interactive and will prompt you for any missing -required properties with helpful suggestions. To avoid automatic behavior, -pass `-y` +Every `seam` command makes its request as soon as every required property is +given. When something is missing, the CLI prompts you for it with helpful +suggestions. + +Pass `--interactive` (or `-i`) to always be prompted to review and edit +properties before the request is made. The prompt is prefilled with whatever +you passed as arguments, so this is the way to add optional properties, or to +check a request before making it. + +For scripts and CI, pass `--non-interactive` (or `-y`) to never be prompted. +The command must then be complete: if the command itself is ambiguous, or any +required property is missing, the CLI exits with an error naming what is +missing instead of asking for it. ```bash # Login to Seam @@ -52,6 +62,15 @@ seam connect-webviews create # List devices in your workspace seam devices list +# Review and edit filters before listing devices +seam devices list --interactive + +# List devices, failing instead of prompting +seam devices list --non-interactive + +# Fails with: Missing required parameter for /locks/unlock_door: --device-id +seam locks unlock-door --non-interactive + MY_DOOR=$(seam devices get --name "Front Door" --id-only) # Unlock a lock diff --git a/src/bin/cli.ts b/src/bin/cli.ts index ec58146a..e1e9d341 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -4,7 +4,7 @@ import { isDeepStrictEqual as isEqual } from 'node:util' import chalk from 'chalk' import commandLineUsage from 'command-line-usage' -import parseArgs, { type ParsedArgs } from 'minimist' +import type { ParsedArgs } from 'minimist' import prompts from 'prompts' import { getApiBlueprint } from 'lib/get-api-blueprint.js' @@ -18,6 +18,13 @@ 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 type { ContextHelpers } from 'lib/types.js' +import { + getInteractivity, + type Interactivity, + interactivityFlags, + NonInteractiveError, + parseCliArgs, +} from 'lib/util/cli-args.js' import { RequestSeamApi } from 'lib/util/request-seam-api.js' import { validateToken } from 'lib/validate-token.js' import seamapiCliVersion from 'lib/version.js' @@ -26,7 +33,7 @@ const sections = [ { header: 'Seam CLI', content: - 'Every seam command is interactive and will prompt you for any missing required properties with helpful suggestions. To avoid automatic behavior, pass -y ', + 'Every seam command runs as soon as every required property is given, and otherwise prompts you for what is missing with helpful suggestions. Pass -i to always review properties first, or -y to never be prompted. ', }, { header: 'Options', @@ -37,6 +44,20 @@ const sections = [ alias: 'h', type: Boolean, }, + { + name: 'interactive', + description: + 'Always prompt to review and edit properties, prefilled with the given arguments.', + alias: 'i', + type: Boolean, + }, + { + name: 'non-interactive', + description: + 'Never prompt: exit with an error if the command or any required property is missing.', + alias: 'y', + type: Boolean, + }, ], }, { @@ -50,6 +71,14 @@ const sections = [ summary: 'Create a connect webview to connect devices.', }, { name: 'seam devices list', summary: 'List devices in your workspace.' }, + { + name: 'seam devices list {bold --interactive}', + summary: 'Review and edit filters before listing devices.', + }, + { + name: 'seam devices list {bold --non-interactive}', + summary: 'List devices, failing instead of prompting.', + }, { name: 'seam locks unlock-door {bold --device-id} $MY_DOOR', summary: 'Unlock a lock.', @@ -117,22 +146,21 @@ async function cli(args: ParsedArgs) { const commandParams: Record = {} - /** - * Whether or not to auto-select first option - */ - const is_interactive = args['y'] !== true - const ctx: ContextHelpers = { blueprint, - is_interactive, + interactivity: getInteractivity(args), } + const isNonInteractive = ctx.interactivity === 'non-interactive' + for (const k in args) { 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 (interactivityFlags.includes(key)) continue + commandParams[key] = v } const selectedCommand = await interactForCommandSelection(args._, ctx) @@ -153,6 +181,11 @@ async function cli(args: ParsedArgs) { if (args['token'] || args['workspace_id'] || args['server']) { return } + if (isNonInteractive) { + throw new NonInteractiveError( + 'Missing required parameter for login: --token', + ) + } await interactForLogin() return } else if (isEqual(selectedCommand, ['logout'])) { @@ -163,9 +196,19 @@ async function cli(args: ParsedArgs) { console.log(config.path) return } else if (isEqual(selectedCommand, ['config', 'use-remote-api-defs'])) { + if (isNonInteractive) { + throw new NonInteractiveError( + 'Cannot select whether to use remote API definitions in non-interactive mode', + ) + } await interactForUseRemoteApiDefs() return } else if (isEqual(selectedCommand, ['select', 'workspace'])) { + if (isNonInteractive) { + throw new NonInteractiveError( + 'Cannot select a workspace in non-interactive mode: pass --workspace-id to "seam login"', + ) + } await interactForWorkspaceId() return } else if (isEqual(selectedCommand, ['events', 'list'])) { @@ -180,6 +223,11 @@ async function cli(args: ParsedArgs) { config.delete('current_workspace_id') return } + if (isNonInteractive) { + throw new NonInteractiveError( + 'Missing required parameter for select server: --server', + ) + } await interactForServerSelection() return } else if (isEqual(selectedCommand, ['health', 'get-health'])) { @@ -232,18 +280,27 @@ async function cli(args: ParsedArgs) { }) if (response.data?.connect_webview) { - await handleConnectWebviewResponse(response.data.connect_webview) + await handleConnectWebviewResponse( + response.data.connect_webview, + ctx.interactivity, + ) } - if (response.data?.action_attempt) { + if (response.data?.action_attempt && !isNonInteractive) { interactForActionAttemptPoll(response.data.action_attempt) } } -const handleConnectWebviewResponse = async (connect_webview: any) => { +const handleConnectWebviewResponse = async ( + connect_webview: any, + interactivity: Interactivity, +) => { const url = connect_webview.url - if (process.env['INSIDE_WEB_BROWSER'] !== '1') { + if ( + interactivity !== 'non-interactive' && + process.env['INSIDE_WEB_BROWSER'] !== '1' + ) { const { action } = await prompts({ type: 'confirm', name: 'action', @@ -257,7 +314,12 @@ const handleConnectWebviewResponse = async (connect_webview: any) => { } } -cli(parseArgs(process.argv.slice(2), { string: ['code'] })).catch((e) => { +cli(parseCliArgs(process.argv.slice(2))).catch((e) => { + if (e instanceof NonInteractiveError) { + console.log(chalk.red(e.message)) + process.exit(1) + } + console.log(chalk.red(`CLI Error: ${e.toString()}\n${e.stack}`)) if (e.toString().includes('object Object')) { console.log(e) diff --git a/src/lib/interact-for-blueprint-object.test.ts b/src/lib/interact-for-blueprint-object.test.ts new file mode 100644 index 00000000..fcbae014 --- /dev/null +++ b/src/lib/interact-for-blueprint-object.test.ts @@ -0,0 +1,81 @@ +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 type { ContextHelpers } from './types.js' + +vi.mock('prompts', () => ({ + default: vi.fn(async () => ({ paramToEdit: 'done' })), +})) + +beforeEach(() => { + vi.mocked(prompts).mockClear() +}) + +const parameters = [ + { name: 'device_id', isRequired: true, format: 'id' }, + { name: 'name', isRequired: false, format: 'string' }, +] as unknown as Parameter[] + +const ctx = (interactivity: ContextHelpers['interactivity']): ContextHelpers => + ({ interactivity, blueprint: {} }) as unknown as ContextHelpers + +const args = (params: Record) => ({ + command: ['devices', 'get'], + parameters, + params, +}) + +test('interactForBlueprintObject: submits without prompting once every required parameter is given', async () => { + await expect( + interactForBlueprintObject(args({ device_id: 'device1' }), ctx('auto')), + ).resolves.toEqual({ device_id: 'device1' }) + expect(prompts).not.toHaveBeenCalled() +}) + +test('interactForBlueprintObject: prompts to review given parameters when interactive', async () => { + await expect( + interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ), + ).resolves.toEqual({ device_id: 'device1' }) + expect(prompts).toHaveBeenCalledTimes(1) +}) + +test('interactForBlueprintObject: prefills the prompt with the given parameters', async () => { + await interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ) + + const { choices } = vi.mocked(prompts).mock.calls[0]?.[0] as { + choices: Array<{ value: string; description?: string }> + } + expect(choices.find(({ value }) => value === 'device_id')).toMatchObject({ + description: '[device1]', + }) +}) + +test('interactForBlueprintObject: submits without prompting when non-interactive', async () => { + await expect( + interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('non-interactive'), + ), + ).resolves.toEqual({ device_id: 'device1' }) + expect(prompts).not.toHaveBeenCalled() +}) + +test('interactForBlueprintObject: rejects missing required parameters when non-interactive', async () => { + await expect( + interactForBlueprintObject( + args({ name: 'Front Door' }), + ctx('non-interactive'), + ), + ).rejects.toThrowError( + 'Missing required parameter for /devices/get: --device-id', + ) + expect(prompts).not.toHaveBeenCalled() +}) diff --git a/src/lib/interact-for-blueprint-object.ts b/src/lib/interact-for-blueprint-object.ts index e9f5ddd6..1d198af7 100644 --- a/src/lib/interact-for-blueprint-object.ts +++ b/src/lib/interact-for-blueprint-object.ts @@ -12,6 +12,7 @@ import { interactForDevice } from './interact-for-device.js' import { interactForTimestamp } from './interact-for-timestamp.js' import { interactForUserIdentity } from './interact-for-user-identity.js' import type { ContextHelpers } from './types.js' +import { NonInteractiveError, toArgName } from './util/cli-args.js' import { ellipsis } from './util/ellipsis.js' const ergonomicPropOrder = [ @@ -47,12 +48,28 @@ export const interactForBlueprintObject = async ( const haveAllRequiredParams = required.every((k) => args.params[k]) + const cmdPath = `/${args.command.join('/').replace(/-/g, '_')}` + const should_auto_submit = - !ctx.is_interactive && haveAllRequiredParams && !args.isSubProperty + ctx.interactivity !== 'interactive' && + haveAllRequiredParams && + !args.isSubProperty if (should_auto_submit) { return args.params } + if (ctx.interactivity === 'non-interactive') { + const missing = required.filter((k) => !args.params[k]) + const target = args.isSubProperty ? `"${args.subPropertyPath}"` : cmdPath + throw new NonInteractiveError( + missing.length > 0 + ? `Missing required ${ + missing.length === 1 ? 'parameter' : 'parameters' + } for ${target}: ${missing.map(toArgName).join(' ')}` + : `Cannot prompt for ${target} in non-interactive mode`, + ) + } + const propSortScore = (prop: string) => { if (required.includes(prop)) return 100 - ergonomicPropOrder.indexOf(prop) if (args.params[prop] !== undefined) { @@ -61,7 +78,6 @@ export const interactForBlueprintObject = async ( return ergonomicPropOrder.indexOf(prop) } - const cmdPath = `/${args.command.join('/').replace(/-/g, '_')}` const parameterSelectionMessage = args.isSubProperty ? `Editing "${args.subPropertyPath}"` : `[${cmdPath}] Parameters` diff --git a/src/lib/interact-for-command-selection.test.ts b/src/lib/interact-for-command-selection.test.ts new file mode 100644 index 00000000..479339c1 --- /dev/null +++ b/src/lib/interact-for-command-selection.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from 'vitest' + +import { interactForCommandSelection } from './interact-for-command-selection.js' +import type { ContextHelpers } from './types.js' + +const ctx = { + interactivity: 'non-interactive', + blueprint: { + routes: [ + { + endpoints: [ + { path: '/devices/get' }, + { path: '/devices/list' }, + { path: '/devices/unmanaged/list' }, + ], + }, + ], + }, +} as unknown as ContextHelpers + +test('interactForCommandSelection: resolves a complete command', async () => { + await expect( + interactForCommandSelection(['devices', 'list'], ctx), + ).resolves.toEqual(['devices', 'list']) +}) + +test('interactForCommandSelection: rejects an incomplete command when non-interactive', async () => { + await expect( + interactForCommandSelection(['devices'], ctx), + ).rejects.toThrowError( + 'Incomplete command "seam devices": expected one of list, get, unmanaged', + ) +}) + +test('interactForCommandSelection: rejects a missing command when non-interactive', async () => { + await expect(interactForCommandSelection([], ctx)).rejects.toThrowError( + /^Missing command: expected one of /, + ) +}) diff --git a/src/lib/interact-for-command-selection.ts b/src/lib/interact-for-command-selection.ts index 3eef904a..a21baa70 100644 --- a/src/lib/interact-for-command-selection.ts +++ b/src/lib/interact-for-command-selection.ts @@ -3,6 +3,7 @@ import { isDeepStrictEqual as isEqual } from 'node:util' import prompts from 'prompts' import type { ContextHelpers } from './types.js' +import { NonInteractiveError } from './util/cli-args.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() @@ -64,6 +65,26 @@ export async function interactForCommandSelection( return commandPath } + if (helpers.interactivity === 'non-interactive') { + // The command path is itself a command, so call it directly rather than + // prompting to select one of its sub-commands. + if (possibleCommands.some((cmd) => cmd.length === commandPath.length)) { + return commandPath + } + + const subcommands = possibleCommands + .map((cmd) => cmd[commandPath.length]) + .filter((subcommand) => subcommand != null) + .sort(ergonomicSort) + throw new NonInteractiveError( + `${ + commandPath.length === 0 + ? 'Missing command' + : `Incomplete command "seam ${commandPath.join(' ')}"` + }: expected one of ${subcommands.join(', ')}`, + ) + } + // Add dynamic 'back' command for sub-commands to allow returning // to previous level. if (commandPath.length > 0) { diff --git a/src/lib/types.ts b/src/lib/types.ts index 62b53774..64a8d95c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,7 @@ import type { ApiBlueprint } from './get-api-blueprint.js' +import type { Interactivity } from './util/cli-args.js' export interface ContextHelpers { blueprint: ApiBlueprint - is_interactive: boolean + interactivity: Interactivity } diff --git a/src/lib/util/cli-args.test.ts b/src/lib/util/cli-args.test.ts new file mode 100644 index 00000000..462af8b3 --- /dev/null +++ b/src/lib/util/cli-args.test.ts @@ -0,0 +1,55 @@ +import type { ParsedArgs } from 'minimist' +import { expect, test } from 'vitest' + +import { getInteractivity, parseCliArgs, toArgName } from './cli-args.js' + +// The CLI normalizes argument keys before checking them. +const parse = (argv: string[]): ParsedArgs => { + const args = parseCliArgs(argv) + for (const k in args) { + if (k === '_') continue + args[k.toLowerCase().replace(/-/g, '_')] = args[k] + } + return args +} + +test('getInteractivity: prompts only for what is missing by default', () => { + expect(getInteractivity(parse(['devices', 'list']))).toBe('auto') +}) + +test('getInteractivity: --interactive and -i always prompt', () => { + expect(getInteractivity(parse(['devices', 'list', '--interactive']))).toBe( + 'interactive', + ) + expect(getInteractivity(parse(['devices', 'list', '-i']))).toBe('interactive') +}) + +test('getInteractivity: --non-interactive and -y never prompt', () => { + expect( + getInteractivity(parse(['devices', 'list', '--non-interactive'])), + ).toBe('non-interactive') + expect(getInteractivity(parse(['devices', 'list', '-y']))).toBe( + 'non-interactive', + ) +}) + +test('getInteractivity: --non-interactive wins over --interactive', () => { + expect(getInteractivity(parse(['devices', 'list', '-i', '-y']))).toBe( + 'non-interactive', + ) +}) + +test('getInteractivity: -n is reserved and does not affect interactivity', () => { + expect(getInteractivity(parse(['devices', 'list', '-n']))).toBe('auto') +}) + +test('parseCliArgs: interactivity flags do not consume the next argument', () => { + const args = parse(['devices', 'get', '-y', '-i', '--device-id', 'foo']) + expect(args['device_id']).toBe('foo') + expect(args._).toEqual(['devices', 'get']) +}) + +test('toArgName: renders a parameter as its argument', () => { + expect(toArgName('device_id')).toBe('--device-id') + expect(toArgName('code')).toBe('--code') +}) diff --git a/src/lib/util/cli-args.ts b/src/lib/util/cli-args.ts new file mode 100644 index 00000000..0b79e200 --- /dev/null +++ b/src/lib/util/cli-args.ts @@ -0,0 +1,56 @@ +import parseArgs, { type ParsedArgs } from 'minimist' + +/** + * How the CLI should behave when properties are not given as arguments. + * + * - `auto`: make the request as soon as every required property is given, + * otherwise prompt for what is missing. This is the default. + * - `interactive`: always prompt to review and edit properties, prefilled + * with the given arguments. Selected with `--interactive` or `-i`. + * - `non-interactive`: never prompt: anything missing is an error. + * Selected with `--non-interactive` or `-y`. + */ +export type Interactivity = 'auto' | 'interactive' | 'non-interactive' + +/** + * Argument keys that select the interactivity + * and are therefore not command parameters. + */ +export const interactivityFlags: string[] = [ + 'non_interactive', + 'y', + 'interactive', + 'i', +] + +/** + * Thrown when the CLI needs input it cannot prompt for. + */ +export class NonInteractiveError extends Error { + override name = 'NonInteractiveError' +} + +export const parseCliArgs = (argv: string[]): ParsedArgs => + parseArgs(argv, { + string: ['code'], + boolean: ['non-interactive', 'interactive'], + // 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 => { + // Prefer the flag that cannot hang when both are given. + if (args['non_interactive'] === true || args['y'] === true) { + return 'non-interactive' + } + if (args['interactive'] === true || args['i'] === true) return 'interactive' + return 'auto' +} + +/** + * Render a parameter name as the argument used to set it, + * e.g., `device_id` as `--device-id`. + */ +export const toArgName = (parameterName: string): string => + `--${parameterName.replace(/_/g, '-')}`