diff --git a/src/lib/interact-for-array.ts b/src/lib/interact-for-array.ts index 6ab01f71..34a5b8f8 100644 --- a/src/lib/interact-for-array.ts +++ b/src/lib/interact-for-array.ts @@ -1,5 +1,11 @@ import { getOutput } from './output/get-output.js' -import { promptNumber, promptSelect, promptText } from './util/prompt.js' +import { + PromptCancelledError, + promptNumber, + promptSelect, + promptText, + withBackHint, +} from './util/prompt.js' export const interactForArray = async ( array: string[], @@ -23,31 +29,42 @@ export const interactForArray = async ( do { displayList() - action = await promptSelect({ - message: 'Choose an action:', - choices: [ - { label: 'Add an item', value: 'add' }, - { label: 'Remove an item', value: 'remove' }, - { label: 'Finish editing', value: 'done' }, - ], - }) - - if (action === 'add') { - const newItem = await promptText({ - message: 'Enter the new item:', + try { + action = await promptSelect({ + message: withBackHint('Choose an action:'), + choices: [ + { label: 'Add an item', value: 'add' }, + { label: 'Remove an item', value: 'remove' }, + { label: 'Finish editing', value: 'done' }, + ], }) - if (newItem) { - updatedArray.push(newItem) + } catch (error) { + if (!(error instanceof PromptCancelledError)) throw error + // Dismissing the action menu finishes editing, keeping the changes. + break + } + + try { + if (action === 'add') { + const newItem = await promptText({ + message: withBackHint('Enter the new item:'), + }) + if (newItem) { + updatedArray.push(newItem) + } + } else if (action === 'remove') { + const index = await promptNumber({ + message: withBackHint('Enter the index of the item to remove:'), + validate: (value) => + value > 0 && value <= updatedArray.length + ? undefined + : 'Invalid index', + }) + updatedArray.splice(index - 1, 1) } - } else if (action === 'remove') { - const index = await promptNumber({ - message: 'Enter the index of the item to remove:', - validate: (value) => - value > 0 && value <= updatedArray.length - ? undefined - : 'Invalid index', - }) - updatedArray.splice(index - 1, 1) + } catch (error) { + if (!(error instanceof PromptCancelledError)) throw error + // Dismissing an inner prompt returns to the action menu. } } while (action !== 'done') diff --git a/src/lib/interact-for-blueprint-object.test.ts b/src/lib/interact-for-blueprint-object.test.ts index 63498ff2..baa949d7 100644 --- a/src/lib/interact-for-blueprint-object.test.ts +++ b/src/lib/interact-for-blueprint-object.test.ts @@ -5,11 +5,19 @@ 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 { promptAutocomplete, promptSelect } from './util/prompt.js' +import type * as PromptModule from './util/prompt.js' +import { + promptAutocomplete, + PromptCancelledError, + promptSelect, + promptText, + withBackHint, +} from './util/prompt.js' -vi.mock('./util/prompt.js', () => ({ - canPrompt: vi.fn(() => true), - PromptCancelledError: class extends Error {}, +// Only the prompts themselves are replaced, so the real PromptCancelledError +// and withBackHint are used, as they are in production. +vi.mock('./util/prompt.js', async (importOriginal) => ({ + ...(await importOriginal()), promptText: vi.fn(), promptNumber: vi.fn(), promptConfirm: vi.fn(), @@ -20,6 +28,8 @@ vi.mock('./util/prompt.js', () => ({ beforeEach(() => { vi.mocked(promptAutocomplete).mockClear() + vi.mocked(promptAutocomplete).mockImplementation(async () => 'done') + vi.mocked(promptText).mockReset() // Keep the interactive chrome out of the test output. setOutput(createMemoryOutput().output) }) @@ -174,3 +184,75 @@ test.for(['custom_metadata', 'custom_metadata_has'] as const)( ).resolves.toEqual({ [name]: {} }) }, ) + +test('interactForBlueprintObject: dismissing the parameter menu leaves the command', async () => { + vi.mocked(promptAutocomplete).mockRejectedValueOnce( + new PromptCancelledError(), + ) + + await expect( + interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ), + ).resolves.toBe('[Back]') +}) + +test('interactForBlueprintObject: dismissing a value prompt returns to the menu', async () => { + vi.mocked(promptAutocomplete) + .mockImplementationOnce(async () => 'name') + .mockImplementationOnce(async () => 'done') + vi.mocked(promptText).mockRejectedValueOnce(new PromptCancelledError()) + + // The parameter is left unset and the command still runs, rather than the + // dismissal ending the whole command. + await expect( + interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ), + ).resolves.toEqual({ device_id: 'device1' }) + expect(promptAutocomplete).toHaveBeenCalledTimes(2) +}) + +test('interactForBlueprintObject: dismissing a value prompt keeps an earlier value', async () => { + vi.mocked(promptAutocomplete) + .mockImplementationOnce(async () => 'name') + .mockImplementationOnce(async () => 'done') + vi.mocked(promptText).mockRejectedValueOnce(new PromptCancelledError()) + + await expect( + interactForBlueprintObject( + args({ device_id: 'device1', name: 'Front Door' }), + ctx('interactive'), + ), + ).resolves.toEqual({ device_id: 'device1', name: 'Front Door' }) +}) + +test('interactForBlueprintObject: tells the user the parameter menu can be left', async () => { + await interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ) + + const { message } = vi.mocked(promptAutocomplete).mock.calls[0]?.[0] as { + message: string + } + expect(message).toBe(withBackHint('[/devices/get] Parameters')) +}) + +test('interactForBlueprintObject: tells the user a value prompt can be left', async () => { + vi.mocked(promptAutocomplete) + .mockImplementationOnce(async () => 'name') + .mockImplementationOnce(async () => 'done') + vi.mocked(promptText).mockImplementationOnce(async () => 'Front Door') + + await interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ) + + expect(vi.mocked(promptText).mock.calls[0]?.[0]).toMatchObject({ + message: withBackHint('name:'), + }) +}) diff --git a/src/lib/interact-for-blueprint-object.ts b/src/lib/interact-for-blueprint-object.ts index 3a268c1a..5f4e173f 100644 --- a/src/lib/interact-for-blueprint-object.ts +++ b/src/lib/interact-for-blueprint-object.ts @@ -17,10 +17,12 @@ import { ellipsis } from './util/ellipsis.js' import { promptAutocomplete, promptAutocompleteMultiselect, + PromptCancelledError, promptConfirm, promptNumber, promptSelect, promptText, + withBackHint, } from './util/prompt.js' const ergonomicPropOrder = [ @@ -88,58 +90,67 @@ export const interactForBlueprintObject = async ( return ergonomicPropOrder.indexOf(prop) } - const parameterSelectionMessage = args.isSubProperty - ? `Editing "${args.subPropertyPath}"` - : `[${cmdPath}] Parameters` + const parameterSelectionMessage = withBackHint( + args.isSubProperty + ? `Editing "${args.subPropertyPath}"` + : `[${cmdPath}] Parameters`, + ) getOutput().info() - const paramToEdit = await promptAutocomplete({ - message: parameterSelectionMessage, - choices: [ - ...(haveAllRequiredParams && !args.isSubProperty - ? [ - { - value: 'done', - label: `[Make API Call] ${cmdPath}`, - }, - ] - : []), - ...(haveAllRequiredParams && args.isSubProperty - ? [ - { - label: `[Save]`, - value: 'done', - }, - ] - : []), - ...Object.keys(properties) - .map((k) => { - return { - label: k + (required.includes(k) ? '*' : ''), - value: k, - hint: - args.params[k] !== undefined - ? typeof args.params[k] === 'object' - ? ellipsis(JSON.stringify(args.params[k]), 60) - : `[${args.params[k]}]` - : undefined, - } - }) - .sort((a, b) => propSortScore(b.value) - propSortScore(a.value)), - ...(args.isSubProperty - ? [ - { - label: `[Leave Empty]`, - value: 'empty', - }, - ] - : []), - { - label: `[Back]`, - value: 'back', - }, - ], - }) + let paramToEdit: string + try { + paramToEdit = await promptAutocomplete({ + message: parameterSelectionMessage, + choices: [ + ...(haveAllRequiredParams && !args.isSubProperty + ? [ + { + value: 'done', + label: `[Make API Call] ${cmdPath}`, + }, + ] + : []), + ...(haveAllRequiredParams && args.isSubProperty + ? [ + { + label: `[Save]`, + value: 'done', + }, + ] + : []), + ...Object.keys(properties) + .map((k) => { + return { + label: k + (required.includes(k) ? '*' : ''), + value: k, + hint: + args.params[k] !== undefined + ? typeof args.params[k] === 'object' + ? ellipsis(JSON.stringify(args.params[k]), 60) + : `[${args.params[k]}]` + : undefined, + } + }) + .sort((a, b) => propSortScore(b.value) - propSortScore(a.value)), + ...(args.isSubProperty + ? [ + { + label: `[Leave Empty]`, + value: 'empty', + }, + ] + : []), + { + label: `[Back]`, + value: 'back', + }, + ], + }) + } catch (error) { + // Dismissing the menu means the same as choosing to go back. + if (!(error instanceof PromptCancelledError)) throw error + paramToEdit = 'back' + } if (paramToEdit === 'empty') { return undefined @@ -160,121 +171,128 @@ export const interactForBlueprintObject = async ( const prop = properties[paramToEdit] - if (paramToEdit === 'device_id') { - args.params[paramToEdit] = await interactForDevice() - return interactForBlueprintObject(args, ctx) - } else if (paramToEdit === 'access_code_id') { - args.params[paramToEdit] = await interactForAccessCode(args.params as any) - return interactForBlueprintObject(args, ctx) - } else if (paramToEdit === 'connected_account_id') { - const connectedAccountId = await interactForConnectedAccount() - args.params[paramToEdit] = connectedAccountId - return interactForBlueprintObject(args, ctx) - } else if ( - paramToEdit === 'user_identity_id' || - paramToEdit === 'user_identity_ids' - ) { - const userIdentityId = await interactForUserIdentity() - args.params[paramToEdit] = - paramToEdit === 'user_identity_ids' ? [userIdentityId] : userIdentityId - return interactForBlueprintObject(args, ctx) - } else if (paramToEdit.endsWith('acs_system_id')) { - args.params[paramToEdit] = await interactForAcsSystem() - return interactForBlueprintObject(args, ctx) - } else if (paramToEdit.endsWith('acs_user_id')) { - args.params[paramToEdit] = await interactForAcsUser() - return interactForBlueprintObject(args, ctx) - } else if (paramToEdit.endsWith('acs_entrance_id')) { - args.params['acs_entrance_id'] = await interactForAcsEntrance() - return interactForBlueprintObject(args, ctx) - } else if ( - paramToEdit.endsWith('_at') || - paramToEdit === 'since' || - paramToEdit.endsWith('_before') || - paramToEdit.endsWith('_after') - ) { - args.params[paramToEdit] = await interactForTimestamp() - return interactForBlueprintObject(args, ctx) - } else if ( - paramToEdit === 'custom_metadata' || - paramToEdit === 'custom_metadata_has' - ) { - args.params[paramToEdit] = await interactForCustomMetadata( - args.params[paramToEdit] || {}, - ) - return interactForBlueprintObject(args, ctx) - } - - if (prop) { - if (['string', 'id', 'datetime'].includes(prop.format)) { - let value - if (prop.format === 'datetime') { - value = await interactForTimestamp() - } else { - value = await promptText({ - message: `${paramToEdit}:`, - }) - } - args.params[paramToEdit] = value + // Dismissing any prompt below returns to the parameter menu with the + // parameter left as it was, rather than ending the whole command. + try { + if (paramToEdit === 'device_id') { + args.params[paramToEdit] = await interactForDevice() return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'enum') { - const value = await promptSelect({ - message: `${paramToEdit}:`, - choices: prop.values.map((v) => ({ - label: v.name, - value: v.name, - })), - }) - args.params[paramToEdit] = value + } else if (paramToEdit === 'access_code_id') { + args.params[paramToEdit] = await interactForAccessCode(args.params as any) return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'boolean') { - const value = await promptConfirm({ - message: `${paramToEdit}:`, - initialValue: true, - active: 'true', - inactive: 'false', - }) - - args.params[paramToEdit] = value - + } else if (paramToEdit === 'connected_account_id') { + const connectedAccountId = await interactForConnectedAccount() + args.params[paramToEdit] = connectedAccountId return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'list' && prop.itemFormat === 'enum') { - const value = await promptAutocompleteMultiselect({ - message: `${paramToEdit}:`, - choices: prop.itemEnumValues.map((v) => ({ - label: v.name, - value: v.name, - })), - }) - args.params[paramToEdit] = value + } else if ( + paramToEdit === 'user_identity_id' || + paramToEdit === 'user_identity_ids' + ) { + const userIdentityId = await interactForUserIdentity() + args.params[paramToEdit] = + paramToEdit === 'user_identity_ids' ? [userIdentityId] : userIdentityId return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'list') { - args.params[paramToEdit] = await interactForArray( - args.params[paramToEdit] || [], - `Edit the list for ${paramToEdit}`, - ) + } else if (paramToEdit.endsWith('acs_system_id')) { + args.params[paramToEdit] = await interactForAcsSystem() return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'object') { - args.params[paramToEdit] = await interactForBlueprintObject( - { - command: args.command, - params: {}, - parameters: prop.parameters, - isSubProperty: true, - subPropertyPath: paramToEdit, - }, - ctx, + } else if (paramToEdit.endsWith('acs_user_id')) { + args.params[paramToEdit] = await interactForAcsUser() + return interactForBlueprintObject(args, ctx) + } else if (paramToEdit.endsWith('acs_entrance_id')) { + args.params['acs_entrance_id'] = await interactForAcsEntrance() + return interactForBlueprintObject(args, ctx) + } else if ( + paramToEdit.endsWith('_at') || + paramToEdit === 'since' || + paramToEdit.endsWith('_before') || + paramToEdit.endsWith('_after') + ) { + args.params[paramToEdit] = await interactForTimestamp() + return interactForBlueprintObject(args, ctx) + } else if ( + paramToEdit === 'custom_metadata' || + paramToEdit === 'custom_metadata_has' + ) { + args.params[paramToEdit] = await interactForCustomMetadata( + args.params[paramToEdit] || {}, ) return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'number') { - const value = await promptNumber({ - message: `${paramToEdit}:`, - }) + } - args.params[paramToEdit] = value + if (prop) { + if (['string', 'id', 'datetime'].includes(prop.format)) { + let value + if (prop.format === 'datetime') { + value = await interactForTimestamp() + } else { + value = await promptText({ + message: withBackHint(`${paramToEdit}:`), + }) + } + args.params[paramToEdit] = value + return interactForBlueprintObject(args, ctx) + } else if (prop.format === 'enum') { + const value = await promptSelect({ + message: withBackHint(`${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 promptConfirm({ + message: withBackHint(`${paramToEdit}:`), + initialValue: true, + active: 'true', + inactive: 'false', + }) - return interactForBlueprintObject(args, ctx) + args.params[paramToEdit] = value + + return interactForBlueprintObject(args, ctx) + } else if (prop.format === 'list' && prop.itemFormat === 'enum') { + const value = await promptAutocompleteMultiselect({ + message: withBackHint(`${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') { + args.params[paramToEdit] = await interactForArray( + args.params[paramToEdit] || [], + `Edit the list for ${paramToEdit}`, + ) + return interactForBlueprintObject(args, ctx) + } else if (prop.format === 'object') { + args.params[paramToEdit] = await interactForBlueprintObject( + { + command: args.command, + params: {}, + parameters: prop.parameters, + isSubProperty: true, + subPropertyPath: paramToEdit, + }, + ctx, + ) + return interactForBlueprintObject(args, ctx) + } else if (prop.format === 'number') { + const value = await promptNumber({ + message: withBackHint(`${paramToEdit}:`), + }) + + args.params[paramToEdit] = value + + return interactForBlueprintObject(args, ctx) + } } + } catch (error) { + if (!(error instanceof PromptCancelledError)) throw error + return interactForBlueprintObject(args, ctx) } throw new Error( diff --git a/src/lib/interact-for-command-selection.test.ts b/src/lib/interact-for-command-selection.test.ts index 479339c1..c6c40de9 100644 --- a/src/lib/interact-for-command-selection.test.ts +++ b/src/lib/interact-for-command-selection.test.ts @@ -1,7 +1,18 @@ -import { expect, test } from 'vitest' +import { beforeEach, expect, test, vi } from 'vitest' import { interactForCommandSelection } from './interact-for-command-selection.js' import type { ContextHelpers } from './types.js' +import type * as PromptModule from './util/prompt.js' +import { promptAutocomplete, withBackHint } from './util/prompt.js' + +vi.mock('./util/prompt.js', async (importOriginal) => ({ + ...(await importOriginal()), + promptAutocomplete: vi.fn(), +})) + +beforeEach(() => { + vi.mocked(promptAutocomplete).mockReset() +}) const ctx = { interactivity: 'non-interactive', @@ -37,3 +48,31 @@ test('interactForCommandSelection: rejects a missing command when non-interactiv /^Missing command: expected one of /, ) }) + +const interactiveCtx = { + ...ctx, + interactivity: 'interactive', +} as unknown as ContextHelpers + +test('interactForCommandSelection: tells the user a sub-command menu can be left', async () => { + vi.mocked(promptAutocomplete).mockImplementationOnce(async () => 'list') + + await interactForCommandSelection(['devices'], interactiveCtx) + + expect(vi.mocked(promptAutocomplete).mock.calls[0]?.[0]).toMatchObject({ + message: withBackHint('Select a command: /devices'), + }) +}) + +// Escape stops the CLI at the top level, so promising a way back would lie. +test('interactForCommandSelection: says nothing about going back at the top level', async () => { + vi.mocked(promptAutocomplete) + .mockImplementationOnce(async () => 'devices') + .mockImplementationOnce(async () => 'list') + + await interactForCommandSelection([], interactiveCtx) + + expect(vi.mocked(promptAutocomplete).mock.calls[0]?.[0]).toMatchObject({ + message: 'Select a command: /', + }) +}) diff --git a/src/lib/interact-for-command-selection.ts b/src/lib/interact-for-command-selection.ts index c9f9cf0c..ed030b12 100644 --- a/src/lib/interact-for-command-selection.ts +++ b/src/lib/interact-for-command-selection.ts @@ -2,7 +2,11 @@ import { isDeepStrictEqual as isEqual } from 'node:util' import type { ContextHelpers } from './types.js' import { NonInteractiveError } from './util/cli-args.js' -import { promptAutocomplete } from './util/prompt.js' +import { + promptAutocomplete, + PromptCancelledError, + withBackHint, +} from './util/prompt.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() @@ -92,16 +96,30 @@ export async function interactForCommandSelection( const commandPathStr = commandPath.join('/').replace(/-/g, '_') - const selectedCommand = await promptAutocomplete({ - message: `Select a command: /${commandPathStr}`, - choices: [ - ...possibleCommands.map((cmd) => ({ - label: - cmd?.[commandPath.length] ?? `[Call /${commandPathStr} Directly]`, - value: cmd?.[commandPath.length] ?? '', - })), - ].sort((a, b) => ergonomicSort(a.value, b.value)), - }) + // Only a sub-command menu has a level to go back to, so only it says so. + const selectMessage = `Select a command: /${commandPathStr}` + + let selectedCommand: string + try { + selectedCommand = await promptAutocomplete({ + message: + commandPath.length > 0 ? withBackHint(selectMessage) : selectMessage, + choices: [ + ...possibleCommands.map((cmd) => ({ + label: + cmd?.[commandPath.length] ?? `[Call /${commandPathStr} Directly]`, + value: cmd?.[commandPath.length] ?? '', + })), + ].sort((a, b) => ergonomicSort(a.value, b.value)), + }) + } catch (error) { + if (!(error instanceof PromptCancelledError)) throw error + // Dismissing the menu means the same as its [Back] entry, which is only + // offered when there is a level to go back to. At the top there is none, + // so dismissing it stops the CLI as it always has. + if (commandPath.length === 0) throw error + selectedCommand = '[Back]' + } if (selectedCommand === '') { return commandPath diff --git a/src/lib/interact-for-custom-metadata.test.ts b/src/lib/interact-for-custom-metadata.test.ts index 9244f0b1..ec8cbd52 100644 --- a/src/lib/interact-for-custom-metadata.test.ts +++ b/src/lib/interact-for-custom-metadata.test.ts @@ -3,11 +3,13 @@ import { beforeEach, expect, test, vi } from 'vitest' import { interactForCustomMetadata } from './interact-for-custom-metadata.js' import { createMemoryOutput } from './output/create-memory-output.js' import { setOutput } from './output/get-output.js' +import type * as PromptModule from './util/prompt.js' import { promptSelect, promptText } from './util/prompt.js' -vi.mock('./util/prompt.js', () => ({ - canPrompt: vi.fn(() => true), - PromptCancelledError: class extends Error {}, +// Only the prompts themselves are replaced, so the real PromptCancelledError +// and withBackHint are used, as they are in production. +vi.mock('./util/prompt.js', async (importOriginal) => ({ + ...(await importOriginal()), promptText: vi.fn(), promptNumber: vi.fn(), promptConfirm: vi.fn(), diff --git a/src/lib/interact-for-custom-metadata.ts b/src/lib/interact-for-custom-metadata.ts index be9a1944..dde3181c 100644 --- a/src/lib/interact-for-custom-metadata.ts +++ b/src/lib/interact-for-custom-metadata.ts @@ -1,5 +1,10 @@ import { getOutput } from './output/get-output.js' -import { promptSelect, promptText } from './util/prompt.js' +import { + PromptCancelledError, + promptSelect, + promptText, + withBackHint, +} 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,45 +36,62 @@ export const interactForCustomMetadata = async ( do { displayCurrentCustomMetadata() - action = await promptSelect({ - message: 'Choose an action:', - choices: [ - { label: 'Add an item to params', value: 'add' }, - { label: 'Remove an item from params', value: 'remove' }, - { label: 'Finish editing params', value: 'done' }, - ], - }) - - if (action === 'add') { - const newKey = await promptText({ - message: 'Enter a key to add or edit:', + try { + action = await promptSelect({ + message: withBackHint('Choose an action:'), + choices: [ + { label: 'Add an item to params', value: 'add' }, + { label: 'Remove an item from params', value: 'remove' }, + { label: 'Finish editing params', value: 'done' }, + ], }) + } catch (error) { + if (!(error instanceof PromptCancelledError)) throw error + // Dismissing the action menu finishes editing, keeping the changes. + break + } - let newValue: string | boolean = await promptText({ - message: 'Enter the new value to add or edit (or null to delete):', - }) - if (newKey) { - if (newValue === 'false' || newValue === 'true') { - newValue = newValue === 'true' - } - if (newValue === 'null') { - updatedCustomMetadata[newKey] = null - } else { - updatedCustomMetadata[newKey] = newValue - } - } - } else if (action === 'remove') { - const customKeyToRemove = await promptSelect({ - message: 'Choose a key-value pair to remove from params:', - choices: Object.keys(updatedCustomMetadata).map((customMetadataKey) => { - return { - label: `${customMetadataKey}: ${updatedCustomMetadata[customMetadataKey]}`, - value: customMetadataKey, + try { + if (action === 'add') { + const newKey = await promptText({ + message: withBackHint('Enter a key to add or edit:'), + }) + + let newValue: string | boolean = await promptText({ + message: withBackHint( + 'Enter the new value to add or edit (or null to delete):', + ), + }) + if (newKey) { + if (newValue === 'false' || newValue === 'true') { + newValue = newValue === 'true' } - }), - }) + if (newValue === 'null') { + updatedCustomMetadata[newKey] = null + } else { + updatedCustomMetadata[newKey] = newValue + } + } + } else if (action === 'remove') { + const customKeyToRemove = await promptSelect({ + message: withBackHint( + 'Choose a key-value pair to remove from params:', + ), + choices: Object.keys(updatedCustomMetadata).map( + (customMetadataKey) => { + return { + label: `${customMetadataKey}: ${updatedCustomMetadata[customMetadataKey]}`, + value: customMetadataKey, + } + }, + ), + }) - delete updatedCustomMetadata[customKeyToRemove] + delete updatedCustomMetadata[customKeyToRemove] + } + } catch (error) { + if (!(error instanceof PromptCancelledError)) throw error + // Dismissing an inner prompt returns to the action menu. } } while (action !== 'done') diff --git a/src/lib/interact-for-resource.ts b/src/lib/interact-for-resource.ts index 08a2af5b..7438c11e 100644 --- a/src/lib/interact-for-resource.ts +++ b/src/lib/interact-for-resource.ts @@ -1,4 +1,4 @@ -import { promptAutocomplete } from './util/prompt.js' +import { promptAutocomplete, withBackHint } from './util/prompt.js' import { withLoading } from './util/with-loading.js' export interface ResourceChoice { @@ -23,7 +23,9 @@ export const interactForResource = async ({ fetchResources, ) return await promptAutocomplete({ - message, + // Resource pickers are only reached from the parameter flow, which + // returns to its menu when one is dismissed. + message: withBackHint(message), choices: resources.map((resource) => { const { title, value, description } = toChoice(resource) return { label: title, value, hint: description } diff --git a/src/lib/interact-for-timestamp.ts b/src/lib/interact-for-timestamp.ts index 55e78f65..6ecf1164 100644 --- a/src/lib/interact-for-timestamp.ts +++ b/src/lib/interact-for-timestamp.ts @@ -1,9 +1,9 @@ -import { promptText } from './util/prompt.js' +import { promptText, withBackHint } from './util/prompt.js' export const interactForTimestamp = async () => { const now = new Date().toISOString() const timestamp = await promptText({ - message: 'Enter a timestamp:', + message: withBackHint('Enter a timestamp:'), placeholder: now, defaultValue: now, validate: (value) => { diff --git a/src/lib/util/prompt.ts b/src/lib/util/prompt.ts index 5eae9953..1c84691e 100644 --- a/src/lib/util/prompt.ts +++ b/src/lib/util/prompt.ts @@ -10,6 +10,7 @@ import { select, text, } from '@clack/prompts' +import chalk from 'chalk' import { NonInteractiveError } from './cli-args.js' @@ -37,6 +38,17 @@ export interface PromptChoice { hint?: string | undefined } +/** + * Note on a prompt message that dismissing it returns to the previous step. + * + * Only for prompts whose caller catches the dismissal: elsewhere it still + * stops the CLI, and saying otherwise would mislead. The note goes in the + * message because clack renders its own keyboard hints from a hardcoded list + * that a caller cannot add to. + */ +export const withBackHint = (message: string): string => + `${message} ${chalk.dim('ยท Esc: go back')}` + const ensureInteractive = (): void => { if (!canPrompt()) { throw new NonInteractiveError(