diff --git a/package-lock.json b/package-lock.json index cbe92c78..e345487d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.16.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 88272031..404e3a77 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 84bd4962..c687d039 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -49,7 +49,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' @@ -406,10 +410,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) { @@ -458,6 +461,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 4a9cf036..90775a7d 100644 --- a/src/lib/interact-for-login.ts +++ b/src/lib/interact-for-login.ts @@ -6,7 +6,7 @@ import { assertEnvVarUnset, getTokenFromEnv, tokenEnvVar } from './env.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' @@ -32,12 +32,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 4fed1860..3859fcb1 100644 --- a/src/lib/interact-for-server-selection.ts +++ b/src/lib/interact-for-server-selection.ts @@ -10,7 +10,7 @@ import { } from './env.js' import { getServer } from './get-server.js' import { getOutput } from './output/get-output.js' -import { prompt } from './util/prompt.js' +import { promptAutocomplete, promptText } from './util/prompt.js' export async function interactForServerSelection() { assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server') @@ -21,27 +21,19 @@ export async function interactForServerSelection() { 'https://fakeseamconnect.seam.vc', ] - const { server } = await prompt([ - { - // Searchable, as selecting a device or a command is. - type: 'autocomplete', - name: 'server', - message: 'Select a server:', - choices: servers.map((server) => ({ title: server, value: server })), - }, - ]) + // Searchable, as selecting a device or a command is. + const server = await promptAutocomplete({ + 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 a8eb4500..aeb42181 100644 --- a/src/lib/interact-for-workspace-id.ts +++ b/src/lib/interact-for-workspace-id.ts @@ -8,7 +8,7 @@ import { } from './env.js' import { getSeamMultiWorkspace } from './get-seam.js' import { getServer } from './get-server.js' -import { prompt } from './util/prompt.js' +import { promptAutocomplete } from './util/prompt.js' import { withLoading } from './util/with-loading.js' export const interactForWorkspaceId = async (personalAccessToken?: string) => { @@ -29,23 +29,17 @@ export const interactForWorkspaceId = async (personalAccessToken?: string) => { const workspaces = await withLoading('Fetching workspaces...', () => seam.workspaces.list(), ) - const { workspaceId } = await prompt({ - name: 'workspaceId', - // Searchable, as selecting a device or a command is: an account may have - // more workspaces than fit on a screen. - type: 'autocomplete', + // Searchable, as selecting a device or a command is: an account may have + // more workspaces than fit on a screen. + const workspaceId = await promptAutocomplete({ 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.test.ts b/src/lib/util/prompt.test.ts index 28265d73..910867b9 100644 --- a/src/lib/util/prompt.test.ts +++ b/src/lib/util/prompt.test.ts @@ -1,34 +1,35 @@ import { expect, test } from 'vitest' -import { searchChoices } from './prompt.js' +import { type SearchableChoice, searchChoices } from './prompt.js' const workspaces = [ - { title: 'Sandbox', description: 'ws_1' }, - { title: 'Production Europe', description: 'ws_2' }, - { title: 'Production US', description: 'ws_3' }, + { label: 'Sandbox', hint: 'ws_1' }, + { label: 'Production Europe', hint: 'ws_2' }, + { label: 'Production US', hint: 'ws_3' }, ] -test('searchChoices: matches every term against the title and description', async () => { - await expect(searchChoices('prod us', workspaces)).resolves.toEqual([ - workspaces[2], - ]) - await expect(searchChoices('WS_1', workspaces)).resolves.toEqual([ - workspaces[0], - ]) - await expect(searchChoices('nope', workspaces)).resolves.toEqual([]) +const search = ( + input: string, + choices: Choice[], +) => choices.filter((choice) => searchChoices(input, choice)) + +test('searchChoices: matches every term against the label and hint', () => { + expect(search('prod us', workspaces)).toEqual([workspaces[2]]) + expect(search('WS_1', workspaces)).toEqual([workspaces[0]]) + expect(search('nope', workspaces)).toEqual([]) }) -test('searchChoices: matches any part of a name, not only its start', async () => { +test('searchChoices: matches any part of a name, not only its start', () => { const servers = [ - { title: 'http://localhost:3020' }, - { title: 'https://connect.getseam.com' }, - { title: 'https://fakeseamconnect.seam.vc' }, + { label: 'http://localhost:3020' }, + { label: 'https://connect.getseam.com' }, + { label: 'https://fakeseamconnect.seam.vc' }, ] - await expect(searchChoices('fake', servers)).resolves.toEqual([servers[2]]) + expect(search('fake', servers)).toEqual([servers[2]]) }) -test('searchChoices: offers every choice until something is typed', async () => { - await expect(searchChoices('', workspaces)).resolves.toEqual(workspaces) - await expect(searchChoices(' ', workspaces)).resolves.toEqual(workspaces) +test('searchChoices: offers every choice until something is typed', () => { + expect(search('', workspaces)).toEqual(workspaces) + expect(search(' ', workspaces)).toEqual(workspaces) }) diff --git a/src/lib/util/prompt.ts b/src/lib/util/prompt.ts index adfab8a8..491ddd26 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,62 +21,154 @@ 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, + ) - const questionList = Array.isArray(questions) ? questions : [questions] - - return await prompts( - questionList.map((question) => ({ - ...question, - // Search a list by any part of a name, rather than only by what it - // starts with, which is all prompts does for itself. - ...(question.type === 'autocomplete' && question.suggest == null - ? { suggest: searchChoices } - : {}), - stdout: process.stderr, - })), - options, +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 })) +} + +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), + // Search a list by any part of a name or hint, rather than only by + // the label, which is all clack matches for itself. + filter: searchChoices, + output, + }), + ) +} + +export const promptAutocompleteMultiselect = async (options: { + message: string + choices: Array> +}): Promise => { + ensureInteractive() + return unwrap( + await autocompleteMultiselect({ + message: options.message, + options: toOptions(options.choices), + filter: searchChoices, + output, + }), ) } export interface SearchableChoice { - title?: string | undefined - description?: string | undefined + label?: string | undefined + hint?: string | undefined } /** - * Filter choices by every whitespace separated term of the input, matched - * case insensitively against the title and the description. + * Match a choice by every whitespace separated term of the input, matched + * case insensitively against the label and the hint. */ -export const searchChoices = async ( +export const searchChoices = ( input: string, - choices: Choice[], -): Promise => { + choice: SearchableChoice, +): boolean => { const terms = input .toLowerCase() .split(/\s+/) .filter((term) => term.length > 0) - if (terms.length === 0) return choices + if (terms.length === 0) return true - return choices.filter((choice) => { - const searchable = `${choice.title ?? ''} ${choice.description ?? ''}` - .toLowerCase() - .trim() - return terms.every((term) => searchable.includes(term)) - }) + const searchable = `${choice.label ?? ''} ${choice.hint ?? ''}` + .toLowerCase() + .trim() + return terms.every((term) => searchable.includes(term)) }