From b6822b5c18b2fa74b4271b4ca5d0a9f019f97929 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 21:38:18 +0000 Subject: [PATCH] feat: Replace prompts with @clack/prompts The prompts package is unmaintained (last published October 2021). Replace it with @clack/prompts, which covers every prompt type the CLI uses, including autocomplete, autocomplete multiselect, and toggle-style confirm, and ships its own TypeScript types. The prompt wrapper now exposes one typed helper per prompt kind instead of passing through prompts question objects. Each helper keeps the TTY guard and renders to stderr, and normalizes clack's cancel symbol into a PromptCancelledError that the CLI entrypoint reports as a quiet 'Cancelled' instead of cascading undefined answers. Timestamps are now collected with a validated text prompt defaulting to the current time, since the clack date picker has no time-of-day segments and API timestamps need them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N2Ch14ybwrTTFwM7sHFWr6 --- package-lock.json | 88 +++++++----- package.json | 3 +- src/bin/cli.ts | 16 ++- src/lib/interact-for-action-attempt-poll.ts | 8 +- src/lib/interact-for-array.ts | 30 ++-- src/lib/interact-for-blueprint-object.test.ts | 26 ++-- src/lib/interact-for-blueprint-object.ts | 81 +++++------ src/lib/interact-for-command-selection.ts | 18 +-- src/lib/interact-for-custom-metadata.ts | 32 ++--- src/lib/interact-for-login.ts | 8 +- src/lib/interact-for-resource.ts | 13 +- src/lib/interact-for-server-selection.ts | 26 ++-- src/lib/interact-for-timestamp.ts | 19 ++- src/lib/interact-for-use-remote-api-defs.ts | 32 ++--- src/lib/interact-for-workspace-id.ts | 18 +-- src/lib/util/prompt.ts | 136 ++++++++++++++++-- 16 files changed, 320 insertions(+), 234 deletions(-) diff --git a/package-lock.json b/package-lock.json index f81e73fc..6585480f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.14.0", "license": "MIT", "dependencies": { + "@clack/prompts": "^1.7.0", "@seamapi/blueprint": "1.2.0", "@seamapi/http": "2.2.0", "@seamapi/wizard": "0.5.2", @@ -19,7 +20,6 @@ "minimist": "^1.2.8", "nanospinner": "^1.2.2", "open": "^11.0.0", - "prompts": "^2.4.2", "tar": "^7.5.22" }, "bin": { @@ -30,7 +30,6 @@ "@types/command-line-usage": "^5.0.4", "@types/minimist": "^1.2.5", "@types/node": "^24.10.9", - "@types/prompts": "^2.4.9", "@vitest/coverage-v8": "^4.1.10", "concurrently": "^10.0.4", "del-cli": "^7.0.0", @@ -263,6 +262,34 @@ "node": ">=18" } }, + "node_modules/@clack/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", + "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", + "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.3", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, "node_modules/@emnapi/core": { "version": "2.0.0-alpha.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", @@ -1706,17 +1733,6 @@ "undici-types": "~7.18.0" } }, - "node_modules/@types/prompts": { - "version": "2.4.9", - "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz", - "integrity": "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "kleur": "^3.0.3" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", @@ -4573,6 +4589,21 @@ "license": "Unlicense", "peer": true }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", @@ -4590,6 +4621,15 @@ "license": "BSD-3-Clause", "peer": true }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -6307,15 +6347,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7618,19 +7649,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/package.json b/package.json index eb263092..7d2ab969 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ } }, "dependencies": { + "@clack/prompts": "^1.7.0", "@seamapi/blueprint": "1.2.0", "@seamapi/http": "2.2.0", "@seamapi/wizard": "0.5.2", @@ -102,7 +103,6 @@ "minimist": "^1.2.8", "nanospinner": "^1.2.2", "open": "^11.0.0", - "prompts": "^2.4.2", "tar": "^7.5.22" }, "devDependencies": { @@ -110,7 +110,6 @@ "@types/command-line-usage": "^5.0.4", "@types/minimist": "^1.2.5", "@types/node": "^24.10.9", - "@types/prompts": "^2.4.9", "@vitest/coverage-v8": "^4.1.10", "concurrently": "^10.0.4", "del-cli": "^7.0.0", diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 62a3e300..def0c32e 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -38,7 +38,11 @@ import { toParameterName, UsageError, } from 'lib/util/cli-args.js' -import { canPrompt, prompt } from 'lib/util/prompt.js' +import { + canPrompt, + PromptCancelledError, + promptConfirm, +} from 'lib/util/prompt.js' import { readStdinJson } from 'lib/util/read-stdin-json.js' import { RequestSeamApi } from 'lib/util/request-seam-api.js' import { validateToken } from 'lib/validate-token.js' @@ -372,10 +376,9 @@ const handleConnectWebviewResponse = async ( interactivity !== 'non-interactive' && process.env['INSIDE_WEB_BROWSER'] !== '1' ) { - const { action } = await prompt({ - type: 'confirm', - name: 'action', + const action = await promptConfirm({ message: 'Would you like to open the webview in your browser?', + initialValue: false, }) if (action) { @@ -424,6 +427,11 @@ run(process.argv.slice(2)).catch((e: unknown) => { return } + if (e instanceof PromptCancelledError) { + output.error(chalk.gray(e.message)) + return + } + const error = e instanceof Error ? e : new Error(String(e)) output.error(chalk.red(`CLI Error: ${error.message}`)) if (error.stack != null) output.error(chalk.gray(error.stack)) diff --git a/src/lib/interact-for-action-attempt-poll.ts b/src/lib/interact-for-action-attempt-poll.ts index 7f3c5ad1..1903a6d0 100644 --- a/src/lib/interact-for-action-attempt-poll.ts +++ b/src/lib/interact-for-action-attempt-poll.ts @@ -2,18 +2,16 @@ import type { ActionAttemptsGetResponse } from '@seamapi/http/connect' import { getSeam } from './get-seam.js' import { getOutput } from './output/get-output.js' -import { prompt } from './util/prompt.js' +import { promptConfirm } from './util/prompt.js' import { withLoading } from './util/with-loading.js' export const interactForActionAttemptPoll = async ( actionAttempt: ActionAttemptsGetResponse['action_attempt'], ) => { if (actionAttempt.status === 'pending') { - const { pollForActionAttempt } = await prompt({ - name: 'pollForActionAttempt', + const pollForActionAttempt = await promptConfirm({ message: "Would you like to poll the action attempt until it's ready?", - type: 'toggle', - initial: true, + initialValue: true, active: 'yes', inactive: 'no', }) diff --git a/src/lib/interact-for-array.ts b/src/lib/interact-for-array.ts index 75bdebf5..6ab01f71 100644 --- a/src/lib/interact-for-array.ts +++ b/src/lib/interact-for-array.ts @@ -1,5 +1,5 @@ import { getOutput } from './output/get-output.js' -import { prompt } from './util/prompt.js' +import { promptNumber, promptSelect, promptText } from './util/prompt.js' export const interactForArray = async ( array: string[], @@ -23,39 +23,31 @@ export const interactForArray = async ( do { displayList() - const response = await prompt({ - type: 'select', - name: 'action', + action = await promptSelect({ message: 'Choose an action:', choices: [ - { title: 'Add an item', value: 'add' }, - { title: 'Remove an item', value: 'remove' }, - { title: 'Finish editing', value: 'done' }, + { label: 'Add an item', value: 'add' }, + { label: 'Remove an item', value: 'remove' }, + { label: 'Finish editing', value: 'done' }, ], }) - action = response.action - if (action === 'add') { - const { newItem } = await prompt({ - type: 'text', - name: 'newItem', + const newItem = await promptText({ message: 'Enter the new item:', }) if (newItem) { updatedArray.push(newItem) } } else if (action === 'remove') { - const { index } = await prompt({ - type: 'number', - name: 'index', + const index = await promptNumber({ message: 'Enter the index of the item to remove:', validate: (value) => - value > 0 && value <= updatedArray.length ? true : 'Invalid index', + value > 0 && value <= updatedArray.length + ? undefined + : 'Invalid index', }) - if (index) { - updatedArray.splice(index - 1, 1) - } + updatedArray.splice(index - 1, 1) } } while (action !== 'done') diff --git a/src/lib/interact-for-blueprint-object.test.ts b/src/lib/interact-for-blueprint-object.test.ts index eda0b253..dcb308de 100644 --- a/src/lib/interact-for-blueprint-object.test.ts +++ b/src/lib/interact-for-blueprint-object.test.ts @@ -5,15 +5,21 @@ 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' +import { promptAutocomplete } from './util/prompt.js' vi.mock('./util/prompt.js', () => ({ canPrompt: vi.fn(() => true), - prompt: vi.fn(async () => ({ paramToEdit: 'done' })), + PromptCancelledError: class extends Error {}, + promptText: vi.fn(), + promptNumber: vi.fn(), + promptConfirm: vi.fn(), + promptSelect: vi.fn(), + promptAutocomplete: vi.fn(async () => 'done'), + promptAutocompleteMultiselect: vi.fn(), })) beforeEach(() => { - vi.mocked(prompt).mockClear() + vi.mocked(promptAutocomplete).mockClear() // Keep the interactive chrome out of the test output. setOutput(createMemoryOutput().output) }) @@ -36,7 +42,7 @@ test('interactForBlueprintObject: submits without prompting once every required await expect( interactForBlueprintObject(args({ device_id: 'device1' }), ctx('auto')), ).resolves.toEqual({ device_id: 'device1' }) - expect(prompt).not.toHaveBeenCalled() + expect(promptAutocomplete).not.toHaveBeenCalled() }) test('interactForBlueprintObject: prompts to review given parameters when interactive', async () => { @@ -46,7 +52,7 @@ test('interactForBlueprintObject: prompts to review given parameters when intera ctx('interactive'), ), ).resolves.toEqual({ device_id: 'device1' }) - expect(prompt).toHaveBeenCalledTimes(1) + expect(promptAutocomplete).toHaveBeenCalledTimes(1) }) test('interactForBlueprintObject: prefills the prompt with the given parameters', async () => { @@ -55,11 +61,11 @@ test('interactForBlueprintObject: prefills the prompt with the given parameters' ctx('interactive'), ) - const { choices } = vi.mocked(prompt).mock.calls[0]?.[0] as { - choices: Array<{ value: string; description?: string }> + const { choices } = vi.mocked(promptAutocomplete).mock.calls[0]?.[0] as { + choices: Array<{ value: string; hint?: string }> } expect(choices.find(({ value }) => value === 'device_id')).toMatchObject({ - description: '[device1]', + hint: '[device1]', }) }) @@ -70,7 +76,7 @@ test('interactForBlueprintObject: submits without prompting when non-interactive ctx('non-interactive'), ), ).resolves.toEqual({ device_id: 'device1' }) - expect(prompt).not.toHaveBeenCalled() + expect(promptAutocomplete).not.toHaveBeenCalled() }) test('interactForBlueprintObject: rejects missing required parameters when non-interactive', async () => { @@ -82,5 +88,5 @@ test('interactForBlueprintObject: rejects missing required parameters when non-i ).rejects.toThrowError( 'Missing required parameter for /devices/get: --device-id', ) - expect(prompt).not.toHaveBeenCalled() + expect(promptAutocomplete).not.toHaveBeenCalled() }) diff --git a/src/lib/interact-for-blueprint-object.ts b/src/lib/interact-for-blueprint-object.ts index 84429ea7..01af4d3d 100644 --- a/src/lib/interact-for-blueprint-object.ts +++ b/src/lib/interact-for-blueprint-object.ts @@ -14,7 +14,14 @@ 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' +import { + promptAutocomplete, + promptAutocompleteMultiselect, + promptConfirm, + promptNumber, + promptSelect, + promptText, +} from './util/prompt.js' const ergonomicPropOrder = [ 'name', @@ -84,23 +91,21 @@ export const interactForBlueprintObject = async ( : `[${cmdPath}] Parameters` getOutput().info() - const { paramToEdit } = await prompt({ - name: 'paramToEdit', + const paramToEdit = await promptAutocomplete({ message: parameterSelectionMessage, - type: 'autocomplete', choices: [ ...(haveAllRequiredParams && !args.isSubProperty ? [ { value: 'done', - title: `[Make API Call] ${cmdPath}`, + label: `[Make API Call] ${cmdPath}`, }, ] : []), ...(haveAllRequiredParams && args.isSubProperty ? [ { - title: `[Save]`, + label: `[Save]`, value: 'done', }, ] @@ -108,9 +113,9 @@ export const interactForBlueprintObject = async ( ...Object.keys(properties) .map((k) => { return { - title: k + (required.includes(k) ? '*' : ''), + label: k + (required.includes(k) ? '*' : ''), value: k, - description: + hint: args.params[k] !== undefined ? typeof args.params[k] === 'object' ? ellipsis(JSON.stringify(args.params[k]), 60) @@ -122,13 +127,13 @@ export const interactForBlueprintObject = async ( ...(args.isSubProperty ? [ { - title: `[Leave Empty]`, + label: `[Leave Empty]`, value: 'empty', }, ] : []), { - title: `[Back]`, + label: `[Back]`, value: 'back', }, ], @@ -201,36 +206,26 @@ export const interactForBlueprintObject = async ( if (prop.format === 'datetime') { value = await interactForTimestamp() } else { - value = ( - await prompt({ - name: 'value', - message: `${paramToEdit}:`, - type: 'text', - }) - ).value + value = await promptText({ + message: `${paramToEdit}:`, + }) } args.params[paramToEdit] = value return interactForBlueprintObject(args, ctx) } else if (prop.format === 'enum') { - const value = ( - await prompt({ - name: 'value', - message: `${paramToEdit}:`, - type: 'select', - choices: prop.values.map((v) => ({ - title: v.name, - value: v.name, - })), - }) - ).value + const value = await promptSelect({ + message: `${paramToEdit}:`, + choices: prop.values.map((v) => ({ + label: v.name, + value: v.name, + })), + }) args.params[paramToEdit] = value return interactForBlueprintObject(args, ctx) } else if (prop.format === 'boolean') { - const { value } = await prompt({ - name: 'value', + const value = await promptConfirm({ message: `${paramToEdit}:`, - type: 'toggle', - initial: true, + initialValue: true, active: 'true', inactive: 'false', }) @@ -239,17 +234,13 @@ export const interactForBlueprintObject = async ( return interactForBlueprintObject(args, ctx) } else if (prop.format === 'list' && prop.itemFormat === 'enum') { - const value = ( - await prompt({ - name: 'value', - message: `${paramToEdit}:`, - type: 'autocompleteMultiselect', - choices: prop.itemEnumValues.map((v) => ({ - title: v.name, - value: v.name, - })), - }) - ).value + const value = await promptAutocompleteMultiselect({ + message: `${paramToEdit}:`, + choices: prop.itemEnumValues.map((v) => ({ + label: v.name, + value: v.name, + })), + }) args.params[paramToEdit] = value return interactForBlueprintObject(args, ctx) } else if (prop.format === 'list') { @@ -271,10 +262,8 @@ export const interactForBlueprintObject = async ( ) return interactForBlueprintObject(args, ctx) } else if (prop.format === 'number') { - const { value } = await prompt({ - name: 'value', + const value = await promptNumber({ message: `${paramToEdit}:`, - type: 'number', }) args.params[paramToEdit] = value diff --git a/src/lib/interact-for-command-selection.ts b/src/lib/interact-for-command-selection.ts index c9a02f27..c9f9cf0c 100644 --- a/src/lib/interact-for-command-selection.ts +++ b/src/lib/interact-for-command-selection.ts @@ -2,7 +2,7 @@ import { isDeepStrictEqual as isEqual } from 'node:util' import type { ContextHelpers } from './types.js' import { NonInteractiveError } from './util/cli-args.js' -import { prompt } from './util/prompt.js' +import { promptAutocomplete } from './util/prompt.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() @@ -92,28 +92,22 @@ export async function interactForCommandSelection( const commandPathStr = commandPath.join('/').replace(/-/g, '_') - const res = await prompt({ - name: 'Command', - type: 'autocomplete', + const selectedCommand = await promptAutocomplete({ + message: `Select a command: /${commandPathStr}`, choices: [ ...possibleCommands.map((cmd) => ({ - title: + label: cmd?.[commandPath.length] ?? `[Call /${commandPathStr} Directly]`, value: cmd?.[commandPath.length] ?? '', })), ].sort((a, b) => ergonomicSort(a.value, b.value)), - message: `Select a command: /${commandPathStr}`, }) - if (res?.Command === undefined) { - throw new Error('Bailed') - } - - if (res?.Command === '') { + if (selectedCommand === '') { return commandPath } - const newCommandPath = [...commandPath, res.Command] + const newCommandPath = [...commandPath, selectedCommand] const fullCommand = possibleCommands.find((cmd) => isEqual(newCommandPath, cmd), diff --git a/src/lib/interact-for-custom-metadata.ts b/src/lib/interact-for-custom-metadata.ts index eb823c2f..41805293 100644 --- a/src/lib/interact-for-custom-metadata.ts +++ b/src/lib/interact-for-custom-metadata.ts @@ -1,5 +1,5 @@ import { getOutput } from './output/get-output.js' -import { prompt } from './util/prompt.js' +import { promptSelect, promptText } 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. @@ -31,29 +31,21 @@ export const interactForCustomMetadata = async ( do { displayCurrentCustomMetadata() - const response = await prompt({ - type: 'select', - name: 'action', + action = await promptSelect({ message: 'Choose an action:', choices: [ - { title: 'Add an item to params', value: 'add' }, - { title: 'Remove an item from params', value: 'remove' }, - { title: 'Finish editing params', value: 'done' }, + { label: 'Add an item to params', value: 'add' }, + { label: 'Remove an item from params', value: 'remove' }, + { label: 'Finish editing params', value: 'done' }, ], }) - action = response.action - if (action === 'add') { - const { newKey } = await prompt({ - type: 'text', - name: 'newKey', + const newKey = await promptText({ message: 'Enter a key to add or edit:', }) - let { newValue } = await prompt({ - type: 'text', - name: 'newValue', + let newValue: string | boolean = await promptText({ message: 'Enter the new value to add or edit (or null to delete):', }) if (newKey) { @@ -67,21 +59,17 @@ export const interactForCustomMetadata = async ( } } } else if (action === 'remove') { - const { customKeyToRemove } = await prompt({ - type: 'select', - name: 'customKeyToRemove', + const customKeyToRemove = await promptSelect({ message: 'Choose a key-value pair to remove from params:', choices: Object.keys(updatedCustomMetadata).map((customMetadataKey) => { return { - title: `${customMetadataKey}: ${updatedCustomMetadata[customMetadataKey]}`, + label: `${customMetadataKey}: ${updatedCustomMetadata[customMetadataKey]}`, value: customMetadataKey, } }), }) - if (customKeyToRemove) { - delete customMetadata[customKeyToRemove] - } + delete customMetadata[customKeyToRemove] } } while (action !== 'done') diff --git a/src/lib/interact-for-login.ts b/src/lib/interact-for-login.ts index 94cec49e..d5748e98 100644 --- a/src/lib/interact-for-login.ts +++ b/src/lib/interact-for-login.ts @@ -5,7 +5,7 @@ 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 { promptText } from './util/prompt.js' import { withLoading } from './util/with-loading.js' import { validateToken } from './validate-token.js' @@ -29,12 +29,10 @@ export const interactForLogin = async () => { ), ) - const { pat } = await prompt({ - name: 'pat', - type: 'text', + const pat = await promptText({ message: 'Personal Access Token:', }) - const token = pat?.trim() + const token = pat.trim() if (!token) { throw new Error('No token provided') diff --git a/src/lib/interact-for-resource.ts b/src/lib/interact-for-resource.ts index 6cbcc22b..08a2af5b 100644 --- a/src/lib/interact-for-resource.ts +++ b/src/lib/interact-for-resource.ts @@ -1,4 +1,4 @@ -import { prompt } from './util/prompt.js' +import { promptAutocomplete } from './util/prompt.js' import { withLoading } from './util/with-loading.js' export interface ResourceChoice { @@ -22,12 +22,11 @@ export const interactForResource = async ({ `Fetching ${resourceName.replace(/_/g, ' ')}s...`, fetchResources, ) - const { resourceId } = await prompt({ - name: 'resourceId', - type: 'autocomplete', + return await promptAutocomplete({ message, - choices: resources.map(toChoice), + choices: resources.map((resource) => { + const { title, value, description } = toChoice(resource) + return { label: title, value, hint: description } + }), }) - - return resourceId as string } diff --git a/src/lib/interact-for-server-selection.ts b/src/lib/interact-for-server-selection.ts index 2958fc34..f69187da 100644 --- a/src/lib/interact-for-server-selection.ts +++ b/src/lib/interact-for-server-selection.ts @@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto' 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' +import { promptSelect, promptText } from './util/prompt.js' export async function interactForServerSelection() { const servers = [ @@ -12,26 +12,18 @@ export async function interactForServerSelection() { 'https://fakeseamconnect.seam.vc', ] - const { server } = await prompt([ - { - type: 'select', - name: 'server', - message: 'Select a server:', - choices: servers.map((server) => ({ title: server, value: server })), - }, - ]) + const server = await promptSelect({ + message: 'Select a server:', + choices: servers.map((server) => ({ label: server, value: server })), + }) const config = getConfigStore() const output = getOutput() if (server === servers[2]) { - let { userUrlSeed } = await prompt([ - { - type: 'text', - name: 'userUrlSeed', - message: - 'You can input a custom server URL or leave this field empty to use a new fakeserver.', - }, - ]) + let userUrlSeed = await promptText({ + message: + 'You can input a custom server URL or leave this field empty to use a new fakeserver.', + }) if (userUrlSeed.trim().length === 0) { userUrlSeed = randomBytes(5).toString('hex') diff --git a/src/lib/interact-for-timestamp.ts b/src/lib/interact-for-timestamp.ts index 58229911..55e78f65 100644 --- a/src/lib/interact-for-timestamp.ts +++ b/src/lib/interact-for-timestamp.ts @@ -1,10 +1,19 @@ -import { prompt } from './util/prompt.js' +import { promptText } from './util/prompt.js' + export const interactForTimestamp = async () => { - const { timestamp } = await prompt({ - name: 'timestamp', - type: 'date', + const now = new Date().toISOString() + const timestamp = await promptText({ message: 'Enter a timestamp:', + placeholder: now, + defaultValue: now, + validate: (value) => { + if (value == null || value === '') return undefined + if (Number.isNaN(new Date(value).getTime())) { + return `Enter a valid timestamp, e.g. ${now}` + } + return undefined + }, }) - return timestamp.toISOString() + return new Date(timestamp).toISOString() } diff --git a/src/lib/interact-for-use-remote-api-defs.ts b/src/lib/interact-for-use-remote-api-defs.ts index 678d67a2..cd51a69e 100644 --- a/src/lib/interact-for-use-remote-api-defs.ts +++ b/src/lib/interact-for-use-remote-api-defs.ts @@ -1,25 +1,21 @@ import { getConfigStore } from './config/index.js' import { getOutput } from './output/get-output.js' -import { prompt } from './util/prompt.js' +import { promptSelect } from './util/prompt.js' export async function interactForUseRemoteApiDefs() { - const { useRemoteApiDefs } = await prompt([ - { - type: 'select', - name: 'useRemoteApiDefs', - message: 'Always use remote API Definitions?', - choices: [ - { - title: 'Yes', - value: true, - }, - { - title: 'No', - value: false, - }, - ], - }, - ]) + const useRemoteApiDefs = await promptSelect({ + message: 'Always use remote API Definitions?', + choices: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + }) const config = getConfigStore() config.set('use_remote_api_defs', useRemoteApiDefs) diff --git a/src/lib/interact-for-workspace-id.ts b/src/lib/interact-for-workspace-id.ts index 04f58c02..d13de76c 100644 --- a/src/lib/interact-for-workspace-id.ts +++ b/src/lib/interact-for-workspace-id.ts @@ -3,7 +3,7 @@ import { SeamHttpWithoutWorkspace } from '@seamapi/http/connect' 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 { promptSelect } from './util/prompt.js' import { withLoading } from './util/with-loading.js' export const interactForWorkspaceId = async (personalAccessToken?: string) => { @@ -17,21 +17,15 @@ export const interactForWorkspaceId = async (personalAccessToken?: string) => { const workspaces = await withLoading('Fetching workspaces...', () => seam.workspaces.list(), ) - const { workspaceId } = await prompt({ - name: 'workspaceId', - type: 'select', + const workspaceId = await promptSelect({ message: 'Select a workspace:', choices: workspaces.map((workspace: any) => ({ - title: workspace.name, + label: workspace.name, value: workspace.workspace_id, - description: workspace.workspace_id, + hint: workspace.workspace_id, })), }) - if (workspaceId) { - config.set('current_workspace_id', workspaceId) - return workspaceId - } - - throw new Error('Bailed') + config.set('current_workspace_id', workspaceId) + return workspaceId } diff --git a/src/lib/util/prompt.ts b/src/lib/util/prompt.ts index b4e1ae4b..3be09ffe 100644 --- a/src/lib/util/prompt.ts +++ b/src/lib/util/prompt.ts @@ -1,4 +1,12 @@ -import prompts, { type Answers, type Options, type PromptObject } from 'prompts' +import { + autocomplete, + autocompleteMultiselect, + confirm, + isCancel, + type Option, + select, + text, +} from '@clack/prompts' import { NonInteractiveError } from './cli-args.js' @@ -13,26 +21,124 @@ import { NonInteractiveError } from './cli-args.js' 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> => { +/** The user dismissed a prompt with ctrl-c or escape instead of answering. */ +export class PromptCancelledError extends Error { + constructor() { + super('Cancelled') + } +} + +export interface PromptChoice { + label: string + value: Value + hint?: string | undefined +} + +const ensureInteractive = (): void => { if (!canPrompt()) { throw new NonInteractiveError( 'Cannot prompt without a terminal: pass the missing arguments, or pipe them in as JSON', ) } +} + +const unwrap = (value: Value | symbol): Value => { + if (isCancel(value)) throw new PromptCancelledError() + return value as Value +} + +// 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. +const output = process.stderr + +const toOptions = ( + choices: Array>, +): Array> => + choices.map( + ({ label, value, hint }) => + (hint === undefined + ? { label, value } + : { label, value, hint }) as Option, + ) + +export const promptText = async (options: { + message: string + placeholder?: string + defaultValue?: string + validate?: (value: string | undefined) => string | undefined +}): Promise => { + ensureInteractive() + return unwrap(await text({ ...options, output })) +} - const questionList = Array.isArray(questions) ? questions : [questions] +export const promptNumber = async (options: { + message: string + validate?: (value: number) => string | undefined +}): Promise => { + ensureInteractive() + const value = unwrap( + await text({ + message: options.message, + validate: (value) => { + if (value == null || value.trim() === '') return 'Enter a number' + const parsed = Number(value) + if (Number.isNaN(parsed)) return 'Enter a number' + return options.validate?.(parsed) + }, + output, + }), + ) + return Number(value) +} + +export const promptConfirm = async (options: { + message: string + initialValue?: boolean + active?: string + inactive?: string +}): Promise => { + ensureInteractive() + return unwrap(await confirm({ ...options, output })) +} + +export const promptSelect = async (options: { + message: string + choices: Array> +}): Promise => { + ensureInteractive() + return unwrap( + await select({ + message: options.message, + options: toOptions(options.choices), + output, + }), + ) +} + +export const promptAutocomplete = async (options: { + message: string + choices: Array> +}): Promise => { + ensureInteractive() + return unwrap( + await autocomplete({ + message: options.message, + options: toOptions(options.choices), + output, + }), + ) +} - return await prompts( - questionList.map((question) => ({ ...question, stdout: process.stderr })), - options, +export const promptAutocompleteMultiselect = async (options: { + message: string + choices: Array> +}): Promise => { + ensureInteractive() + return unwrap( + await autocompleteMultiselect({ + message: options.message, + options: toOptions(options.choices), + output, + }), ) }