Skip to content

Commit 62b3b60

Browse files
authored
chore(toolkit): default IoHost supports prompting (#33177)
### Reason for this change The `ClioIoHost` didn't implement `requestResponse` properly, it just returned the default value. However since this implementation is supposed to be exactly what the CLI does, we need to implement prompting. ### Description of changes Implements user prompting. This is not currently used yet by the CLI itself, we will enable this in follow-up ticket. ### Describe any new or updated permissions being added n/a ### Description of how you validated changes Added test cases. ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 3b2846e commit 62b3b60

File tree

3 files changed

+251
-9
lines changed

3 files changed

+251
-9
lines changed

packages/aws-cdk/lib/toolkit/cli-io-host.ts

+87-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import * as util from 'node:util';
12
import * as chalk from 'chalk';
3+
import * as promptly from 'promptly';
4+
import { ToolkitError } from './error';
25

36
export type IoMessageCodeCategory = 'TOOLKIT' | 'SDK' | 'ASSETS';
47
export type IoCodeLevel = 'E' | 'W' | 'I';
@@ -332,13 +335,61 @@ export class CliIoHost implements IIoHost {
332335
* If the host does not return a response the suggested
333336
* default response from the input message will be used.
334337
*/
335-
public async requestResponse<T, U>(msg: IoRequest<T, U>): Promise<U> {
338+
public async requestResponse<DataType, ResponseType>(msg: IoRequest<DataType, ResponseType>): Promise<ResponseType> {
339+
// First call out to a registered instance if we have one
336340
if (this._internalIoHost) {
337341
return this._internalIoHost.requestResponse(msg);
338342
}
339343

340-
await this.notify(msg);
341-
return msg.defaultResponse;
344+
// If the request cannot be prompted for by the CliIoHost, we just accept the default
345+
if (!isPromptableRequest(msg)) {
346+
await this.notify(msg);
347+
return msg.defaultResponse;
348+
}
349+
350+
const response = await this.withCorkedLogging(async (): Promise<string | number | true> => {
351+
// prepare prompt data
352+
// @todo this format is not defined anywhere, probably should be
353+
const data: {
354+
motivation?: string;
355+
concurrency?: number;
356+
} = msg.data ?? {};
357+
358+
const motivation = data.motivation ?? 'User input is needed';
359+
const concurrency = data.concurrency ?? 0;
360+
361+
// only talk to user if STDIN is a terminal (otherwise, fail)
362+
if (!this.isTTY) {
363+
throw new ToolkitError(`${motivation}, but terminal (TTY) is not attached so we are unable to get a confirmation from the user`);
364+
}
365+
366+
// only talk to user if concurrency is 1 (otherwise, fail)
367+
if (concurrency > 1) {
368+
throw new ToolkitError(`${motivation}, but concurrency is greater than 1 so we are unable to get a confirmation from the user`);
369+
}
370+
371+
// Basic confirmation prompt
372+
// We treat all requests with a boolean response as confirmation prompts
373+
if (isConfirmationPrompt(msg)) {
374+
const confirmed = await promptly.confirm(`${chalk.cyan(msg.message)} (y/n)`);
375+
if (!confirmed) {
376+
throw new ToolkitError('Aborted by user');
377+
}
378+
return confirmed;
379+
}
380+
381+
// Asking for a specific value
382+
const prompt = extractPromptInfo(msg);
383+
const answer = await promptly.prompt(`${chalk.cyan(msg.message)} (${prompt.default})`, {
384+
default: prompt.default,
385+
});
386+
return prompt.convertAnswer(answer);
387+
});
388+
389+
// We need to cast this because it is impossible to narrow the generic type
390+
// isPromptableRequest ensures that the response type is one we can prompt for
391+
// the remaining code ensure we are indeed returning the correct type
392+
return response as ResponseType;
342393
}
343394

344395
/**
@@ -365,6 +416,39 @@ export class CliIoHost implements IIoHost {
365416
}
366417
}
367418

419+
/**
420+
* This IoHost implementation considers a request promptable, if:
421+
* - it's a yes/no confirmation
422+
* - asking for a string or number value
423+
*/
424+
function isPromptableRequest(msg: IoRequest<any, any>): msg is IoRequest<any, string | number | boolean> {
425+
return isConfirmationPrompt(msg)
426+
|| typeof msg.defaultResponse === 'string'
427+
|| typeof msg.defaultResponse === 'number';
428+
}
429+
430+
/**
431+
* Check if the request is a confirmation prompt
432+
* We treat all requests with a boolean response as confirmation prompts
433+
*/
434+
function isConfirmationPrompt(msg: IoRequest<any, any>): msg is IoRequest<any, boolean> {
435+
return typeof msg.defaultResponse === 'boolean';
436+
}
437+
438+
/**
439+
* Helper to extract information for promptly from the request
440+
*/
441+
function extractPromptInfo(msg: IoRequest<any, any>): {
442+
default: string;
443+
convertAnswer: (input: string) => string | number;
444+
} {
445+
const isNumber = (typeof msg.defaultResponse === 'number');
446+
return {
447+
default: util.format(msg.defaultResponse),
448+
convertAnswer: isNumber ? (v) => Number(v) : (v) => String(v),
449+
};
450+
}
451+
368452
const styleMap: Record<IoMessageLevel, (str: string) => string> = {
369453
error: chalk.red,
370454
warn: chalk.yellow,
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Sends a response to a prompt to stdin
3+
* When using this in tests, call just before the prompt runs.
4+
*
5+
* @example
6+
* ```ts
7+
* sendResponse('y');
8+
* await prompt('Confirm (y/n)?');
9+
* ```
10+
*/
11+
export function sendResponse(res: string, delay = 0) {
12+
if (!delay) {
13+
setImmediate(() => process.stdin.emit('data', `${res}\n`));
14+
} else {
15+
setTimeout(() => process.stdin.emit('data', `${res}\n`), delay);
16+
}
17+
}

packages/aws-cdk/test/toolkit/cli-io-host.test.ts

+147-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as chalk from 'chalk';
22
import { CliIoHost, IoMessage, IoMessageLevel } from '../../lib/toolkit/cli-io-host';
3+
import { sendResponse } from '../_helpers/prompts';
34

45
const ioHost = CliIoHost.instance({
56
logLevel: 'trace',
@@ -221,19 +222,159 @@ describe('CliIoHost', () => {
221222
});
222223

223224
describe('requestResponse', () => {
224-
test('logs messages and returns default', async () => {
225+
beforeEach(() => {
225226
ioHost.isTTY = true;
226-
const response = await ioHost.requestResponse({
227+
ioHost.isCI = false;
228+
});
229+
230+
test('fail if concurrency is > 1', async () => {
231+
await expect(() => ioHost.requestResponse({
227232
time: new Date(),
228233
level: 'info',
229234
action: 'synth',
230235
code: 'CDK_TOOLKIT_I0001',
231-
message: 'test message',
232-
defaultResponse: 'default response',
236+
message: 'Continue?',
237+
defaultResponse: true,
238+
data: {
239+
concurrency: 3,
240+
},
241+
})).rejects.toThrow('but concurrency is greater than 1');
242+
});
243+
244+
describe('boolean', () => {
245+
test('respond "yes" to a confirmation prompt', async () => {
246+
sendResponse('y');
247+
const response = await ioHost.requestResponse({
248+
time: new Date(),
249+
level: 'info',
250+
action: 'synth',
251+
code: 'CDK_TOOLKIT_I0001',
252+
message: 'Continue?',
253+
defaultResponse: true,
254+
});
255+
256+
expect(mockStdout).toHaveBeenCalledWith(chalk.cyan('Continue?') + ' (y/n) ');
257+
expect(response).toBe(true);
233258
});
234259

235-
expect(mockStderr).toHaveBeenCalledWith(chalk.white('test message') + '\n');
236-
expect(response).toBe('default response');
260+
test('respond "no" to a confirmation prompt', async () => {
261+
sendResponse('n');
262+
await expect(() => ioHost.requestResponse({
263+
time: new Date(),
264+
level: 'info',
265+
action: 'synth',
266+
code: 'CDK_TOOLKIT_I0001',
267+
message: 'Continue?',
268+
defaultResponse: true,
269+
})).rejects.toThrow('Aborted by user');
270+
271+
expect(mockStdout).toHaveBeenCalledWith(chalk.cyan('Continue?') + ' (y/n) ');
272+
});
273+
});
274+
275+
describe('string', () => {
276+
test.each([
277+
['bear', 'bear'],
278+
['giraffe', 'giraffe'],
279+
// simulate the enter key
280+
['\x0A', 'cat'],
281+
])('receives %p and returns %p', async (input, expectedResponse) => {
282+
sendResponse(input);
283+
const response = await ioHost.requestResponse({
284+
time: new Date(),
285+
level: 'info',
286+
action: 'synth',
287+
code: 'CDK_TOOLKIT_I0001',
288+
message: 'Favorite animal',
289+
defaultResponse: 'cat',
290+
});
291+
292+
expect(mockStdout).toHaveBeenCalledWith(chalk.cyan('Favorite animal') + ' (cat) ');
293+
expect(response).toBe(expectedResponse);
294+
});
295+
});
296+
297+
describe('number', () => {
298+
test.each([
299+
['3', 3],
300+
// simulate the enter key
301+
['\x0A', 1],
302+
])('receives %p and return %p', async (input, expectedResponse) => {
303+
sendResponse(input);
304+
const response = await ioHost.requestResponse({
305+
time: new Date(),
306+
level: 'info',
307+
action: 'synth',
308+
code: 'CDK_TOOLKIT_I0001',
309+
message: 'How many would you like?',
310+
defaultResponse: 1,
311+
});
312+
313+
expect(mockStdout).toHaveBeenCalledWith(chalk.cyan('How many would you like?') + ' (1) ');
314+
expect(response).toBe(expectedResponse);
315+
});
316+
});
317+
318+
describe('non-promptable data', () => {
319+
test('logs messages and returns default unchanged', async () => {
320+
const response = await ioHost.requestResponse({
321+
time: new Date(),
322+
level: 'info',
323+
action: 'synth',
324+
code: 'CDK_TOOLKIT_I0001',
325+
message: 'test message',
326+
defaultResponse: [1, 2, 3],
327+
});
328+
329+
expect(mockStderr).toHaveBeenCalledWith(chalk.white('test message') + '\n');
330+
expect(response).toEqual([1, 2, 3]);
331+
});
332+
});
333+
334+
describe('non TTY environment', () => {
335+
beforeEach(() => {
336+
ioHost.isTTY = false;
337+
ioHost.isCI = false;
338+
});
339+
340+
test('fail for all prompts', async () => {
341+
await expect(() => ioHost.requestResponse({
342+
time: new Date(),
343+
level: 'info',
344+
action: 'synth',
345+
code: 'CDK_TOOLKIT_I0001',
346+
message: 'Continue?',
347+
defaultResponse: true,
348+
})).rejects.toThrow('User input is needed');
349+
});
350+
351+
test('fail with specific motivation', async () => {
352+
await expect(() => ioHost.requestResponse({
353+
time: new Date(),
354+
level: 'info',
355+
action: 'synth',
356+
code: 'CDK_TOOLKIT_I0001',
357+
message: 'Continue?',
358+
defaultResponse: true,
359+
data: {
360+
motivation: 'Bananas are yellow',
361+
},
362+
})).rejects.toThrow('Bananas are yellow');
363+
});
364+
365+
test('returns the default for non-promptable requests', async () => {
366+
const response = await ioHost.requestResponse({
367+
time: new Date(),
368+
level: 'info',
369+
action: 'synth',
370+
code: 'CDK_TOOLKIT_I0001',
371+
message: 'test message',
372+
defaultResponse: [1, 2, 3],
373+
});
374+
375+
expect(mockStderr).toHaveBeenCalledWith('test message\n');
376+
expect(response).toEqual([1, 2, 3]);
377+
});
237378
});
238379
});
239380
});

0 commit comments

Comments
 (0)