Skip to content

Commit

Permalink
chore(toolkit): default IoHost supports prompting (#33177)
Browse files Browse the repository at this point in the history
### 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*
  • Loading branch information
mrgrain authored Jan 30, 2025
1 parent 3b2846e commit 62b3b60
Show file tree
Hide file tree
Showing 3 changed files with 251 additions and 9 deletions.
90 changes: 87 additions & 3 deletions packages/aws-cdk/lib/toolkit/cli-io-host.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<T, U>(msg: IoRequest<T, U>): Promise<U> {
public async requestResponse<DataType, ResponseType>(msg: IoRequest<DataType, ResponseType>): Promise<ResponseType> {
// 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<string | number | true> => {
// 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;
}

/**
Expand All @@ -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<any, any>): msg is IoRequest<any, string | number | boolean> {
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<any, any>): msg is IoRequest<any, boolean> {
return typeof msg.defaultResponse === 'boolean';
}

/**
* Helper to extract information for promptly from the request
*/
function extractPromptInfo(msg: IoRequest<any, any>): {
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<IoMessageLevel, (str: string) => string> = {
error: chalk.red,
warn: chalk.yellow,
Expand Down
17 changes: 17 additions & 0 deletions packages/aws-cdk/test/_helpers/prompts.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
153 changes: 147 additions & 6 deletions packages/aws-cdk/test/toolkit/cli-io-host.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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]);
});
});
});
});

0 comments on commit 62b3b60

Please sign in to comment.