diff --git a/packages/aws-cdk/lib/toolkit/cli-io-host.ts b/packages/aws-cdk/lib/toolkit/cli-io-host.ts index 06b1ff1de7ee5..7fcce38c52d29 100644 --- a/packages/aws-cdk/lib/toolkit/cli-io-host.ts +++ b/packages/aws-cdk/lib/toolkit/cli-io-host.ts @@ -1,4 +1,7 @@ +import * as util from 'node:util'; import * as chalk from 'chalk'; +import * as promptly from 'promptly'; +import { ToolkitError } from './error'; export type IoMessageCodeCategory = 'TOOLKIT' | 'SDK' | 'ASSETS'; export type IoCodeLevel = 'E' | 'W' | 'I'; @@ -332,13 +335,61 @@ export class CliIoHost implements IIoHost { * If the host does not return a response the suggested * default response from the input message will be used. */ - public async requestResponse(msg: IoRequest): Promise { + public async requestResponse(msg: IoRequest): Promise { + // First call out to a registered instance if we have one if (this._internalIoHost) { return this._internalIoHost.requestResponse(msg); } - await this.notify(msg); - return msg.defaultResponse; + // If the request cannot be prompted for by the CliIoHost, we just accept the default + if (!isPromptableRequest(msg)) { + await this.notify(msg); + return msg.defaultResponse; + } + + const response = await this.withCorkedLogging(async (): Promise => { + // prepare prompt data + // @todo this format is not defined anywhere, probably should be + const data: { + motivation?: string; + concurrency?: number; + } = msg.data ?? {}; + + const motivation = data.motivation ?? 'User input is needed'; + const concurrency = data.concurrency ?? 0; + + // only talk to user if STDIN is a terminal (otherwise, fail) + if (!this.isTTY) { + throw new ToolkitError(`${motivation}, but terminal (TTY) is not attached so we are unable to get a confirmation from the user`); + } + + // only talk to user if concurrency is 1 (otherwise, fail) + if (concurrency > 1) { + throw new ToolkitError(`${motivation}, but concurrency is greater than 1 so we are unable to get a confirmation from the user`); + } + + // Basic confirmation prompt + // We treat all requests with a boolean response as confirmation prompts + if (isConfirmationPrompt(msg)) { + const confirmed = await promptly.confirm(`${chalk.cyan(msg.message)} (y/n)`); + if (!confirmed) { + throw new ToolkitError('Aborted by user'); + } + return confirmed; + } + + // Asking for a specific value + const prompt = extractPromptInfo(msg); + const answer = await promptly.prompt(`${chalk.cyan(msg.message)} (${prompt.default})`, { + default: prompt.default, + }); + return prompt.convertAnswer(answer); + }); + + // We need to cast this because it is impossible to narrow the generic type + // isPromptableRequest ensures that the response type is one we can prompt for + // the remaining code ensure we are indeed returning the correct type + return response as ResponseType; } /** @@ -365,6 +416,39 @@ export class CliIoHost implements IIoHost { } } +/** + * This IoHost implementation considers a request promptable, if: + * - it's a yes/no confirmation + * - asking for a string or number value + */ +function isPromptableRequest(msg: IoRequest): msg is IoRequest { + return isConfirmationPrompt(msg) + || typeof msg.defaultResponse === 'string' + || typeof msg.defaultResponse === 'number'; +} + +/** + * Check if the request is a confirmation prompt + * We treat all requests with a boolean response as confirmation prompts + */ +function isConfirmationPrompt(msg: IoRequest): msg is IoRequest { + return typeof msg.defaultResponse === 'boolean'; +} + +/** + * Helper to extract information for promptly from the request + */ +function extractPromptInfo(msg: IoRequest): { + default: string; + convertAnswer: (input: string) => string | number; +} { + const isNumber = (typeof msg.defaultResponse === 'number'); + return { + default: util.format(msg.defaultResponse), + convertAnswer: isNumber ? (v) => Number(v) : (v) => String(v), + }; +} + const styleMap: Record string> = { error: chalk.red, warn: chalk.yellow, diff --git a/packages/aws-cdk/test/_helpers/prompts.ts b/packages/aws-cdk/test/_helpers/prompts.ts new file mode 100644 index 0000000000000..548a8723212a7 --- /dev/null +++ b/packages/aws-cdk/test/_helpers/prompts.ts @@ -0,0 +1,17 @@ +/** + * Sends a response to a prompt to stdin + * When using this in tests, call just before the prompt runs. + * + * @example + * ```ts + * sendResponse('y'); + * await prompt('Confirm (y/n)?'); + * ``` + */ +export function sendResponse(res: string, delay = 0) { + if (!delay) { + setImmediate(() => process.stdin.emit('data', `${res}\n`)); + } else { + setTimeout(() => process.stdin.emit('data', `${res}\n`), delay); + } +} diff --git a/packages/aws-cdk/test/toolkit/cli-io-host.test.ts b/packages/aws-cdk/test/toolkit/cli-io-host.test.ts index 4d3b24d9d4d09..101c44bb9ad5a 100644 --- a/packages/aws-cdk/test/toolkit/cli-io-host.test.ts +++ b/packages/aws-cdk/test/toolkit/cli-io-host.test.ts @@ -1,5 +1,6 @@ import * as chalk from 'chalk'; import { CliIoHost, IoMessage, IoMessageLevel } from '../../lib/toolkit/cli-io-host'; +import { sendResponse } from '../_helpers/prompts'; const ioHost = CliIoHost.instance({ logLevel: 'trace', @@ -221,19 +222,159 @@ describe('CliIoHost', () => { }); describe('requestResponse', () => { - test('logs messages and returns default', async () => { + beforeEach(() => { ioHost.isTTY = true; - const response = await ioHost.requestResponse({ + ioHost.isCI = false; + }); + + test('fail if concurrency is > 1', async () => { + await expect(() => ioHost.requestResponse({ time: new Date(), level: 'info', action: 'synth', code: 'CDK_TOOLKIT_I0001', - message: 'test message', - defaultResponse: 'default response', + message: 'Continue?', + defaultResponse: true, + data: { + concurrency: 3, + }, + })).rejects.toThrow('but concurrency is greater than 1'); + }); + + describe('boolean', () => { + test('respond "yes" to a confirmation prompt', async () => { + sendResponse('y'); + const response = await ioHost.requestResponse({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'CDK_TOOLKIT_I0001', + message: 'Continue?', + defaultResponse: true, + }); + + expect(mockStdout).toHaveBeenCalledWith(chalk.cyan('Continue?') + ' (y/n) '); + expect(response).toBe(true); }); - expect(mockStderr).toHaveBeenCalledWith(chalk.white('test message') + '\n'); - expect(response).toBe('default response'); + test('respond "no" to a confirmation prompt', async () => { + sendResponse('n'); + await expect(() => ioHost.requestResponse({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'CDK_TOOLKIT_I0001', + message: 'Continue?', + defaultResponse: true, + })).rejects.toThrow('Aborted by user'); + + expect(mockStdout).toHaveBeenCalledWith(chalk.cyan('Continue?') + ' (y/n) '); + }); + }); + + describe('string', () => { + test.each([ + ['bear', 'bear'], + ['giraffe', 'giraffe'], + // simulate the enter key + ['\x0A', 'cat'], + ])('receives %p and returns %p', async (input, expectedResponse) => { + sendResponse(input); + const response = await ioHost.requestResponse({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'CDK_TOOLKIT_I0001', + message: 'Favorite animal', + defaultResponse: 'cat', + }); + + expect(mockStdout).toHaveBeenCalledWith(chalk.cyan('Favorite animal') + ' (cat) '); + expect(response).toBe(expectedResponse); + }); + }); + + describe('number', () => { + test.each([ + ['3', 3], + // simulate the enter key + ['\x0A', 1], + ])('receives %p and return %p', async (input, expectedResponse) => { + sendResponse(input); + const response = await ioHost.requestResponse({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'CDK_TOOLKIT_I0001', + message: 'How many would you like?', + defaultResponse: 1, + }); + + expect(mockStdout).toHaveBeenCalledWith(chalk.cyan('How many would you like?') + ' (1) '); + expect(response).toBe(expectedResponse); + }); + }); + + describe('non-promptable data', () => { + test('logs messages and returns default unchanged', async () => { + const response = await ioHost.requestResponse({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'CDK_TOOLKIT_I0001', + message: 'test message', + defaultResponse: [1, 2, 3], + }); + + expect(mockStderr).toHaveBeenCalledWith(chalk.white('test message') + '\n'); + expect(response).toEqual([1, 2, 3]); + }); + }); + + describe('non TTY environment', () => { + beforeEach(() => { + ioHost.isTTY = false; + ioHost.isCI = false; + }); + + test('fail for all prompts', async () => { + await expect(() => ioHost.requestResponse({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'CDK_TOOLKIT_I0001', + message: 'Continue?', + defaultResponse: true, + })).rejects.toThrow('User input is needed'); + }); + + test('fail with specific motivation', async () => { + await expect(() => ioHost.requestResponse({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'CDK_TOOLKIT_I0001', + message: 'Continue?', + defaultResponse: true, + data: { + motivation: 'Bananas are yellow', + }, + })).rejects.toThrow('Bananas are yellow'); + }); + + test('returns the default for non-promptable requests', async () => { + const response = await ioHost.requestResponse({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'CDK_TOOLKIT_I0001', + message: 'test message', + defaultResponse: [1, 2, 3], + }); + + expect(mockStderr).toHaveBeenCalledWith('test message\n'); + expect(response).toEqual([1, 2, 3]); + }); }); }); });