Skip to content

Commit d8eeea8

Browse files
authored
fix(cli): don't double up on plugin errors and don't show pointless stack trace (#262)
Improves the error output for plugin loading. We are loosing the green highlight of the plugin name because I doesn't feel right to have formatting inside the error. On the upside, we are loosing stack traces that are irrelevant to the user. --- Old: <img width="803" alt="image" src="https://github.com/user-attachments/assets/82acc6de-98a0-436e-a9bf-56c06fbfea14" /> New: <img width="421" alt="image" src="https://github.com/user-attachments/assets/b93858a7-49e1-4bf5-8c3b-9a2f4239e89d" /> Old: <img width="978" alt="image" src="https://github.com/user-attachments/assets/b2110990-9e5e-4fdb-8139-f1a992cd2c82" /> New: <img width="846" alt="image" src="https://github.com/user-attachments/assets/553c9106-5ed0-4b71-9c29-c1db87f28444" /> Also changes all of `cli.ts` to use the `IoHost` --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent d66e375 commit d8eeea8

File tree

11 files changed

+98
-40
lines changed

11 files changed

+98
-40
lines changed

.projenrc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,7 @@ const cli = configureProject(
805805
tsconfig: {
806806
compilerOptions: {
807807
...defaultTsOptions,
808+
lib: ['es2019', 'es2022.error'],
808809

809810
// Changes the meaning of 'import' for libraries whose top-level export is a function
810811
// 'aws-cdk' has been written against `false` for interop

packages/@aws-cdk/integ-runner/test/workers/integ-worker.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import { runIntegrationTestsInParallel, runIntegrationTests } from '../../lib/wo
88
let stderrMock: jest.SpyInstance;
99
let pool: workerpool.WorkerPool;
1010
let spawnSyncMock: jest.SpyInstance;
11+
12+
jest.setTimeout(20_000);
13+
1114
beforeAll(() => {
1215
pool = workerpool.pool(path.join(__dirname, 'mock-extract_worker.ts'), {
1316
workerType: 'thread',

packages/@aws-cdk/tmp-toolkit-helpers/src/api/toolkit-error.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ export class ToolkitError extends Error {
3737
return this.isToolkitError(x) && CONTEXT_PROVIDER_ERROR_SYMBOL in x;
3838
}
3939

40+
/**
41+
* An AssemblyError with an original error as cause
42+
*/
43+
public static withCause(message: string, error: unknown): ToolkitError {
44+
return new ToolkitError(message, 'toolkit', error);
45+
}
46+
4047
/**
4148
* The type of the error, defaults to "toolkit".
4249
*/
@@ -47,13 +54,19 @@ export class ToolkitError extends Error {
4754
*/
4855
public readonly source: 'toolkit' | 'user';
4956

50-
constructor(message: string, type: string = 'toolkit') {
57+
/**
58+
* The specific original cause of the error, if available
59+
*/
60+
public readonly cause?: unknown;
61+
62+
constructor(message: string, type: string = 'toolkit', cause?: unknown) {
5163
super(message);
5264
Object.setPrototypeOf(this, ToolkitError.prototype);
5365
Object.defineProperty(this, TOOLKIT_ERROR_SYMBOL, { value: true });
5466
this.name = new.target.name;
5567
this.type = type;
5668
this.source = 'toolkit';
69+
this.cause = cause;
5770
}
5871
}
5972

@@ -106,17 +119,11 @@ export class AssemblyError extends ToolkitError {
106119
*/
107120
public readonly stacks?: cxapi.CloudFormationStackArtifact[];
108121

109-
/**
110-
* The specific original cause of the error, if available
111-
*/
112-
public readonly cause?: unknown;
113-
114122
private constructor(message: string, stacks?: cxapi.CloudFormationStackArtifact[], cause?: unknown) {
115-
super(message, 'assembly');
123+
super(message, 'assembly', cause);
116124
Object.setPrototypeOf(this, AssemblyError.prototype);
117125
Object.defineProperty(this, ASSEMBLY_ERROR_SYMBOL, { value: true });
118126
this.stacks = stacks;
119-
this.cause = cause;
120127
}
121128
}
122129

packages/@aws-cdk/tmp-toolkit-helpers/test/api/toolkit-error.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AssemblyError, AuthenticationError, ContextProviderError, ToolkitError
22

33
describe('toolkit error', () => {
44
let toolkitError = new ToolkitError('Test toolkit error');
5+
let toolkitCauseError = ToolkitError.withCause('Test toolkit error', new Error('other error'));
56
let authError = new AuthenticationError('Test authentication error');
67
let contextProviderError = new ContextProviderError('Test context provider error');
78
let assemblyError = AssemblyError.withStacks('Test authentication error', []);
@@ -25,6 +26,11 @@ describe('toolkit error', () => {
2526
expect(ToolkitError.isToolkitError(contextProviderError)).toBe(true);
2627
});
2728

29+
test('ToolkitError.withCause', () => {
30+
expect((toolkitCauseError.cause as any)?.message).toBe('other error');
31+
expect(ToolkitError.isToolkitError(toolkitCauseError)).toBe(true);
32+
});
33+
2834
test('isAuthenticationError works', () => {
2935
expect(authError.source).toBe('user');
3036

@@ -42,7 +48,7 @@ describe('toolkit error', () => {
4248
expect(ToolkitError.isAssemblyError(authError)).toBe(false);
4349
});
4450

45-
test('AssemblyError.fromCause', () => {
51+
test('AssemblyError.withCause', () => {
4652
expect(assemblyCauseError.source).toBe('user');
4753
expect((assemblyCauseError.cause as any)?.message).toBe('other error');
4854

packages/aws-cdk/lib/api/plugin/plugin.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import { inspect } from 'util';
22
import type { CredentialProviderSource, IPluginHost, Plugin } from '@aws-cdk/cli-plugin-contract';
3-
4-
import * as chalk from 'chalk';
53
import { type ContextProviderPlugin, isContextProviderPlugin } from './context-provider-plugin';
64
import { ToolkitError } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api';
7-
import { error } from '../../logging';
85

96
export let TESTING = false;
107

@@ -44,15 +41,13 @@ export class PluginHost implements IPluginHost {
4441
const plugin = require(moduleSpec);
4542
/* eslint-enable */
4643
if (!isPlugin(plugin)) {
47-
error(`Module ${chalk.green(moduleSpec)} is not a valid plug-in, or has an unsupported version.`);
48-
throw new ToolkitError(`Module ${moduleSpec} does not define a valid plug-in.`);
44+
throw new ToolkitError(`Module ${moduleSpec} is not a valid plug-in, or has an unsupported version.`);
4945
}
5046
if (plugin.init) {
5147
plugin.init(this);
5248
}
5349
} catch (e: any) {
54-
error(`Unable to load ${chalk.green(moduleSpec)}: ${e.stack}`);
55-
throw new ToolkitError(`Unable to load plug-in: ${moduleSpec}: ${e}`);
50+
throw ToolkitError.withCause(`Unable to load plug-in '${moduleSpec}'`, e);
5651
}
5752

5853
function isPlugin(x: any): x is Plugin {

packages/aws-cdk/lib/cli/cli.ts

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { IoMessageLevel } from './io-host';
66
import { CliIoHost } from './io-host';
77
import { parseCommandLineArguments } from './parse-command-line-arguments';
88
import { checkForPlatformWarnings } from './platform-warnings';
9+
import { prettyPrintError } from './pretty-print-error';
910
import type { Command } from './user-configuration';
1011
import { Configuration } from './user-configuration';
1112
import * as version from './version';
@@ -32,7 +33,6 @@ import { docs } from '../commands/docs';
3233
import { doctor } from '../commands/doctor';
3334
import { cliInit, printAvailableTemplates } from '../commands/init';
3435
import { getMigrateScanType } from '../commands/migrate';
35-
import { result, debug, error, info } from '../logging';
3636
import { Notices } from '../notices';
3737
/* eslint-disable max-len */
3838
/* eslint-disable @typescript-eslint/no-shadow */ // yargs
@@ -80,11 +80,11 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
8080
try {
8181
await checkForPlatformWarnings();
8282
} catch (e) {
83-
debug(`Error while checking for platform warnings: ${e}`);
83+
ioHost.defaults.debug(`Error while checking for platform warnings: ${e}`);
8484
}
8585

86-
debug('CDK Toolkit CLI version:', version.displayVersion());
87-
debug('Command line arguments:', argv);
86+
ioHost.defaults.debug('CDK Toolkit CLI version:', version.displayVersion());
87+
ioHost.defaults.debug('Command line arguments:', argv);
8888

8989
const configuration = new Configuration({
9090
commandLineArguments: {
@@ -156,7 +156,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
156156
if (loaded.has(resolved)) {
157157
continue;
158158
}
159-
debug(`Loading plug-in: ${chalk.green(plugin)} from ${chalk.blue(resolved)}`);
159+
ioHost.defaults.debug(`Loading plug-in: ${chalk.green(plugin)} from ${chalk.blue(resolved)}`);
160160
PluginHost.instance.load(plugin);
161161
loaded.add(resolved);
162162
}
@@ -166,8 +166,11 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
166166
try {
167167
return require.resolve(plugin);
168168
} catch (e: any) {
169-
error(`Unable to resolve plugin ${chalk.green(plugin)}: ${e.stack}`);
170-
throw new ToolkitError(`Unable to resolve plug-in: ${plugin}`);
169+
// according to Node.js docs `MODULE_NOT_FOUND` is the only possible error here
170+
// @see https://nodejs.org/api/modules.html#requireresolverequest-options
171+
// Not using `withCause()` here, since the node error contains a "Require Stack"
172+
// as part of the error message that is inherently useless to our users.
173+
throw new ToolkitError(`Unable to resolve plug-in: Cannot find module '${plugin}'`);
171174
}
172175
}
173176
}
@@ -199,7 +202,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
199202
async function main(command: string, args: any): Promise<number | void> {
200203
ioHost.currentAction = command as any;
201204
const toolkitStackName: string = ToolkitInfo.determineName(configuration.settings.get(['toolkitStackName']));
202-
debug(`Toolkit stack: ${chalk.bold(toolkitStackName)}`);
205+
ioHost.defaults.debug(`Toolkit stack: ${chalk.bold(toolkitStackName)}`);
203206

204207
const cloudFormation = new Deployments({
205208
sdkProvider,
@@ -280,7 +283,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
280283

281284
case 'bootstrap':
282285
ioHost.currentAction = 'bootstrap';
283-
const source: BootstrapSource = determineBootstrapVersion(args);
286+
const source: BootstrapSource = determineBootstrapVersion(ioHost, args);
284287

285288
if (args.showTemplate) {
286289
const bootstrapper = new Bootstrapper(source, asIoHelper(ioHost, ioHost.currentAction));
@@ -521,7 +524,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
521524
});
522525
case 'version':
523526
ioHost.currentAction = 'version';
524-
return result(version.displayVersion());
527+
return ioHost.defaults.result(version.displayVersion());
525528

526529
default:
527530
throw new ToolkitError('Unknown command: ' + command);
@@ -532,13 +535,13 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
532535
/**
533536
* Determine which version of bootstrapping
534537
*/
535-
function determineBootstrapVersion(args: { template?: string }): BootstrapSource {
538+
function determineBootstrapVersion(ioHost: CliIoHost, args: { template?: string }): BootstrapSource {
536539
let source: BootstrapSource;
537540
if (args.template) {
538-
info(`Using bootstrapping template from ${args.template}`);
541+
ioHost.defaults.info(`Using bootstrapping template from ${args.template}`);
539542
source = { source: 'custom', templateFile: args.template };
540543
} else if (process.env.CDK_LEGACY_BOOTSTRAP) {
541-
info('CDK_LEGACY_BOOTSTRAP set, using legacy-style bootstrapping');
544+
ioHost.defaults.info('CDK_LEGACY_BOOTSTRAP set, using legacy-style bootstrapping');
542545
source = { source: 'legacy' };
543546
} else {
544547
// in V2, the "new" bootstrapping is the default
@@ -598,13 +601,9 @@ export function cli(args: string[] = process.argv.slice(2)) {
598601
}
599602
})
600603
.catch((err) => {
601-
error(err.message);
602-
603604
// Log the stack trace if we're on a developer workstation. Otherwise this will be into a minified
604605
// file and the printed code line and stack trace are huge and useless.
605-
if (err.stack && version.isDeveloperBuild()) {
606-
debug(err.stack);
607-
}
606+
prettyPrintError(err, version.isDeveloperBuild());
608607
process.exitCode = 1;
609608
});
610609
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as chalk from 'chalk';
44
import * as promptly from 'promptly';
55
import { ToolkitError } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api';
66
import type { IIoHost, IoMessage, IoMessageCode, IoMessageLevel, IoRequest, ToolkitAction } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api';
7-
import { IO, isMessageRelevantForLevel } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private';
7+
import { asIoHelper, IO, IoDefaultMessages, isMessageRelevantForLevel } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private';
88
import { StackActivityProgress } from '../../commands/deploy';
99
import { CurrentActivityPrinter, HistoryActivityPrinter } from '../activity-printer';
1010
import type { ActivityPrinterProps, IActivityPrinter } from '../activity-printer';
@@ -204,6 +204,11 @@ export class CliIoHost implements IIoHost {
204204
return this._progress;
205205
}
206206

207+
public get defaults() {
208+
const helper = asIoHelper(this, this.currentAction as any);
209+
return new IoDefaultMessages(helper);
210+
}
211+
207212
/**
208213
* Executes a block of code with corked logging. All log messages during execution
209214
* are buffered and only written when all nested cork blocks complete (when CORK_COUNTER reaches 0).
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/* eslint-disable no-console */
2+
import * as chalk from 'chalk';
3+
4+
export function prettyPrintError(error: unknown, debug = false) {
5+
const err = ensureError(error);
6+
console.error(chalk.red(err.message));
7+
8+
if (err.cause) {
9+
const cause = ensureError(err.cause);
10+
console.error(chalk.yellow(cause.message));
11+
printTrace(err, debug);
12+
}
13+
14+
printTrace(err, debug);
15+
}
16+
17+
function printTrace(err: Error, debug = false) {
18+
// Log the stack trace if we're on a developer workstation. Otherwise this will be into a minified
19+
// file and the printed code line and stack trace are huge and useless.
20+
if (err.stack && debug) {
21+
console.debug(chalk.gray(err.stack));
22+
}
23+
}
24+
25+
function ensureError(value: unknown): Error {
26+
if (value instanceof Error) return value;
27+
28+
let stringified = '[Unable to stringify the thrown value]';
29+
try {
30+
stringified = JSON.stringify(value);
31+
} catch {
32+
}
33+
34+
const error = new Error(`An unexpected error was thrown: ${stringified}`);
35+
return error;
36+
}

packages/aws-cdk/test/api/plugin/plugin-host.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,11 @@ test('plugin that registers an invalid Context Provider throws', () => {
9090
};
9191
}, { virtual: true });
9292

93-
expect(() => host.load(THE_PLUGIN)).toThrow(/does not look like a ContextProviderPlugin/);
93+
try {
94+
host.load(THE_PLUGIN);
95+
expect(true).toBe(false); // should not happen
96+
} catch(e) {
97+
expect(e).toHaveProperty('cause');
98+
expect(e.cause?.message).toMatch(/does not look like a ContextProviderPlugin/);
99+
}
94100
});

packages/aws-cdk/tsconfig.dev.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/aws-cdk/tsconfig.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)