packages/aws-cdk/lib/cli/io-host/cli-io-host.ts (265 lines of code) (raw):
import * as util from 'node:util';
import { RequireApproval } from '@aws-cdk/cloud-assembly-schema';
import * as chalk from 'chalk';
import * as promptly from 'promptly';
import { ToolkitError } from '../../../../@aws-cdk/toolkit-lib/lib/api';
import type { IIoHost, IoMessage, IoMessageCode, IoMessageLevel, IoRequest, ToolkitAction } from '../../../../@aws-cdk/toolkit-lib/lib/api';
import type { IoHelper } from '../../../../@aws-cdk/toolkit-lib/lib/api/io/private';
import { asIoHelper, IO, IoDefaultMessages, isMessageRelevantForLevel } from '../../../../@aws-cdk/toolkit-lib/lib/api/io/private';
import { CurrentActivityPrinter, HistoryActivityPrinter } from '../../../../@aws-cdk/toolkit-lib/lib/private/activity-printer';
import type { ActivityPrinterProps, IActivityPrinter } from '../../../../@aws-cdk/toolkit-lib/lib/private/activity-printer';
import { StackActivityProgress } from '../../commands/deploy';
export type { IIoHost, IoMessage, IoMessageCode, IoMessageLevel, IoRequest };
type CliAction =
| ToolkitAction
| 'context'
| 'docs'
| 'notices'
| 'version'
| 'none';
export interface CliIoHostProps {
/**
* The initial Toolkit action the hosts starts with.
*
* @default 'none'
*/
readonly currentAction?: ToolkitAction;
/**
* Determines the verbosity of the output.
*
* The CliIoHost will still receive all messages and requests,
* but only the messages included in this level will be printed.
*
* @default 'info'
*/
readonly logLevel?: IoMessageLevel;
/**
* Overrides the automatic TTY detection.
*
* When TTY is disabled, the CLI will have no interactions or color.
*
* @default - determined from the current process
*/
readonly isTTY?: boolean;
/**
* Whether the CliIoHost is running in CI mode.
*
* In CI mode, all non-error output goes to stdout instead of stderr.
* Set to false in the CliIoHost constructor it will be overwritten if the CLI CI argument is passed
*
* @default - determined from the environment, specifically based on `process.env.CI`
*/
readonly isCI?: boolean;
/**
* In what scenarios should the CliIoHost ask for approval
*
* @default RequireApproval.BROADENING
*/
readonly requireDeployApproval?: RequireApproval;
/*
* The initial Toolkit action the hosts starts with.
*
* @default StackActivityProgress.BAR
*/
readonly stackProgress?: StackActivityProgress;
}
/**
* A type for configuring a target stream
*/
export type TargetStream = 'stdout' | 'stderr' | 'drop';
/**
* A simple IO host for the CLI that writes messages to the console.
*/
export class CliIoHost implements IIoHost {
/**
* Returns the singleton instance
*/
static instance(props: CliIoHostProps = {}, forceNew = false): CliIoHost {
if (forceNew || !CliIoHost._instance) {
CliIoHost._instance = new CliIoHost(props);
}
return CliIoHost._instance;
}
/**
* Singleton instance of the CliIoHost
*/
private static _instance: CliIoHost | undefined;
/**
* The current action being performed by the CLI.
*/
public currentAction: CliAction;
/**
* Whether the CliIoHost is running in CI mode.
*
* In CI mode, all non-error output goes to stdout instead of stderr.
*/
public isCI: boolean;
/**
* Whether the host can use interactions and message styling.
*/
public isTTY: boolean;
/**
* The current threshold.
*
* Messages with a lower priority level will be ignored.
*/
public logLevel: IoMessageLevel;
/**
* The conditions for requiring approval in this CliIoHost.
*/
public requireDeployApproval: RequireApproval;
/**
* Configure the target stream for notices
*
* (Not a setter because there's no need for additional logic when this value
* is changed yet)
*/
public noticesDestination: TargetStream = 'stderr';
private _internalIoHost?: IIoHost;
private _progress: StackActivityProgress = StackActivityProgress.BAR;
// Stack Activity Printer
private activityPrinter?: IActivityPrinter;
// Corked Logging
private corkedCounter = 0;
private readonly corkedLoggingBuffer: IoMessage<unknown>[] = [];
private constructor(props: CliIoHostProps = {}) {
this.currentAction = props.currentAction ?? 'none';
this.isTTY = props.isTTY ?? process.stdout.isTTY ?? false;
this.logLevel = props.logLevel ?? 'info';
this.isCI = props.isCI ?? isCI();
this.requireDeployApproval = props.requireDeployApproval ?? RequireApproval.BROADENING;
this.stackProgress = props.stackProgress ?? StackActivityProgress.BAR;
}
/**
* Returns the singleton instance
*/
public registerIoHost(ioHost: IIoHost) {
if (ioHost !== this) {
this._internalIoHost = ioHost;
}
}
/**
* Update the stackProgress preference.
*/
public set stackProgress(type: StackActivityProgress) {
this._progress = type;
}
/**
* Gets the stackProgress value.
*
* This takes into account other state of the ioHost,
* like if isTTY and isCI.
*/
public get stackProgress(): StackActivityProgress {
// We can always use EVENTS
if (this._progress === StackActivityProgress.EVENTS) {
return this._progress;
}
// if a debug message (and thus any more verbose messages) are relevant to the current log level, we have verbose logging
const verboseLogging = isMessageRelevantForLevel({ level: 'debug' }, this.logLevel);
if (verboseLogging) {
return StackActivityProgress.EVENTS;
}
// On Windows we cannot use fancy output
const isWindows = process.platform === 'win32';
if (isWindows) {
return StackActivityProgress.EVENTS;
}
// On some CI systems (such as CircleCI) output still reports as a TTY so we also
// need an individual check for whether we're running on CI.
// see: https://discuss.circleci.com/t/circleci-terminal-is-a-tty-but-term-is-not-set/9965
const fancyOutputAvailable = this.isTTY && !this.isCI;
if (!fancyOutputAvailable) {
return StackActivityProgress.EVENTS;
}
// Use the user preference
return this._progress;
}
public get defaults() {
return new IoDefaultMessages(this.asIoHelper());
}
public asIoHelper(): IoHelper {
return asIoHelper(this, this.currentAction as any);
}
/**
* Executes a block of code with corked logging. All log messages during execution
* are buffered and only written when all nested cork blocks complete (when CORK_COUNTER reaches 0).
* The corking is bound to the specific instance of the CliIoHost.
*
* @param block - Async function to execute with corked logging
* @returns Promise that resolves with the block's return value
*/
public async withCorkedLogging<T>(block: () => Promise<T>): Promise<T> {
this.corkedCounter++;
try {
return await block();
} finally {
this.corkedCounter--;
if (this.corkedCounter === 0) {
// Process each buffered message through notify
for (const ioMessage of this.corkedLoggingBuffer) {
await this.notify(ioMessage);
}
// remove all buffered messages in-place
this.corkedLoggingBuffer.splice(0);
}
}
}
/**
* Notifies the host of a message.
* The caller waits until the notification completes.
*/
public async notify(msg: IoMessage<unknown>): Promise<void> {
if (this._internalIoHost) {
return this._internalIoHost.notify(msg);
}
if (this.isStackActivity(msg)) {
if (!this.activityPrinter) {
this.activityPrinter = this.makeActivityPrinter();
}
await this.activityPrinter.notify(msg);
return;
}
if (!isMessageRelevantForLevel(msg, this.logLevel)) {
return;
}
if (this.corkedCounter > 0) {
this.corkedLoggingBuffer.push(msg);
return;
}
const output = this.formatMessage(msg);
const stream = this.selectStream(msg);
stream?.write(output);
}
/**
* Detect stack activity messages so they can be send to the printer.
*/
private isStackActivity(msg: IoMessage<unknown>) {
return [
'CDK_TOOLKIT_I5501',
'CDK_TOOLKIT_I5502',
'CDK_TOOLKIT_I5503',
].includes(msg.code);
}
/**
* Detect special messages encode information about whether or not
* they require approval
*/
private skipApprovalStep(msg: IoRequest<any, any>): boolean {
const approvalToolkitCodes = ['CDK_TOOLKIT_I5060'];
if (!approvalToolkitCodes.includes(msg.code)) {
false;
}
switch (this.requireDeployApproval) {
// Never require approval
case RequireApproval.NEVER:
return true;
// Always require approval
case RequireApproval.ANYCHANGE:
return false;
// Require approval if changes include broadening permissions
case RequireApproval.BROADENING:
return ['none', 'non-broadening'].includes(msg.data?.permissionChangeType);
}
}
/**
* Determines the output stream, based on message and configuration.
*/
private selectStream(msg: IoMessage<any>): NodeJS.WriteStream | undefined {
if (isNoticesMessage(msg)) {
return targetStreamObject(this.noticesDestination);
}
return this.selectStreamFromLevel(msg.level);
}
/**
* Determines the output stream, based on message level and configuration.
*/
private selectStreamFromLevel(level: IoMessageLevel): NodeJS.WriteStream {
// The stream selection policy for the CLI is the following:
//
// (1) Messages of level `result` always go to `stdout`
// (2) Messages of level `error` always go to `stderr`.
// (3a) All remaining messages go to `stderr`.
// (3b) If we are in CI mode, all remaining messages go to `stdout`.
//
switch (level) {
case 'error':
return process.stderr;
case 'result':
return process.stdout;
default:
return this.isCI ? process.stdout : process.stderr;
}
}
/**
* Notifies the host of a message that requires a response.
*
* If the host does not return a response the suggested
* default response from the input message will be used.
*/
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);
}
// 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`);
}
// Special approval prompt
// Determine if the message needs approval. If it does, continue (it is a basic confirmation prompt)
// If it does not, return success (true). We only check messages with codes that we are aware
// are requires approval codes.
if (this.skipApprovalStep(msg)) {
return true;
}
// 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;
}
/**
* Formats a message for console output with optional color support
*/
private formatMessage(msg: IoMessage<unknown>): string {
// apply provided style or a default style if we're in TTY mode
let message_text = this.isTTY
? styleMap[msg.level](msg.message)
: msg.message;
// prepend timestamp if IoMessageLevel is DEBUG or TRACE. Postpend a newline.
return ((msg.level === 'debug' || msg.level === 'trace')
? `[${this.formatTime(msg.time)}] ${message_text}`
: message_text) + '\n';
}
/**
* Formats date to HH:MM:SS
*/
private formatTime(d: Date): string {
const pad = (n: number): string => n.toString().padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/**
* Get an instance of the ActivityPrinter
*/
private makeActivityPrinter() {
const props: ActivityPrinterProps = {
stream: this.selectStreamFromLevel('info'),
};
switch (this.stackProgress) {
case StackActivityProgress.EVENTS:
return new HistoryActivityPrinter(props);
case StackActivityProgress.BAR:
return new CurrentActivityPrinter(props);
}
}
}
/**
* 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,
result: chalk.white,
info: chalk.white,
debug: chalk.gray,
trace: chalk.gray,
};
/**
* Returns true if the current process is running in a CI environment
* @returns true if the current process is running in a CI environment
*/
export function isCI(): boolean {
return process.env.CI !== undefined && process.env.CI !== 'false' && process.env.CI !== '0';
}
function targetStreamObject(x: TargetStream): NodeJS.WriteStream | undefined {
switch (x) {
case 'stderr':
return process.stderr;
case 'stdout':
return process.stdout;
case 'drop':
return undefined;
}
}
function isNoticesMessage(msg: IoMessage<unknown>) {
return IO.CDK_TOOLKIT_I0100.is(msg) || IO.CDK_TOOLKIT_W0101.is(msg) || IO.CDK_TOOLKIT_E0101.is(msg) || IO.CDK_TOOLKIT_I0101.is(msg);
}