diff --git a/.changeset/good-seals-fix.md b/.changeset/good-seals-fix.md new file mode 100644 index 00000000000..17e4e4e80bd --- /dev/null +++ b/.changeset/good-seals-fix.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/backend-cli': patch +--- + +gracefully handle errors that occur during a process event handler diff --git a/packages/cli/src/amplify.ts b/packages/cli/src/amplify.ts index 79b47c49160..179aa4f4a80 100755 --- a/packages/cli/src/amplify.ts +++ b/packages/cli/src/amplify.ts @@ -1,8 +1,10 @@ #!/usr/bin/env node import { hideBin } from 'yargs/helpers'; -import * as process from 'process'; import { createMainParser } from './main_parser_factory.js'; +import { attachUnhandledExceptionListeners } from './error_handler.js'; + +attachUnhandledExceptionListeners(); const parser = createMainParser(); diff --git a/packages/cli/src/command_failure_handler.test.ts b/packages/cli/src/command_failure_handler.test.ts deleted file mode 100644 index d5ca4317505..00000000000 --- a/packages/cli/src/command_failure_handler.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it, mock } from 'node:test'; -import { handleCommandFailure } from './command_failure_handler.js'; -import { Argv } from 'yargs'; -import { COLOR, Printer } from '@aws-amplify/cli-core'; -import assert from 'node:assert'; -import { InvalidCredentialError } from './error/credential_error.js'; - -void describe('handleCommandFailure', () => { - void it('prints a message', (contextual) => { - const mockPrint = contextual.mock.method(Printer, 'print'); - const mockShowHelp = mock.fn(); - const args = { - showHelp: mockShowHelp, - }; - const someMsg = 'some msg'; - // undefined error is encountered with --help option. - handleCommandFailure( - someMsg, - undefined as unknown as Error, - args as unknown as Argv - ); - assert.equal(mockPrint.mock.callCount(), 1); - assert.equal(mockShowHelp.mock.callCount(), 1); - assert.deepStrictEqual(mockPrint.mock.calls[0].arguments, [ - someMsg, - COLOR.RED, - ]); - }); - - void it('prints a random error', (contextual) => { - const mockPrint = contextual.mock.method(Printer, 'print'); - const mockShowHelp = mock.fn(); - const args = { - showHelp: mockShowHelp, - }; - const errMsg = 'some random error msg'; - handleCommandFailure( - '', - new Error(errMsg), - args as unknown as Argv - ); - assert.equal(mockPrint.mock.callCount(), 1); - assert.equal(mockShowHelp.mock.callCount(), 1); - assert.match(mockPrint.mock.calls[0].arguments[0], new RegExp(errMsg)); - assert.equal(mockPrint.mock.calls[0].arguments[1], COLOR.RED); - }); - - void it('handles a prompt force close error', (contextual) => { - const mockPrint = contextual.mock.method(Printer, 'print'); - handleCommandFailure( - '', - new Error('User force closed the prompt'), - {} as unknown as Argv - ); - assert.equal(mockPrint.mock.callCount(), 0); - }); - - void it('handles a profile error', (contextual) => { - const errMsg = 'some profile error'; - const mockPrint = contextual.mock.method(Printer, 'print'); - handleCommandFailure( - '', - new InvalidCredentialError(errMsg), - {} as unknown as Argv - ); - assert.equal(mockPrint.mock.callCount(), 1); - assert.match(mockPrint.mock.calls[0].arguments[0], new RegExp(errMsg)); - assert.equal(mockPrint.mock.calls[0].arguments[1], COLOR.RED); - }); -}); diff --git a/packages/cli/src/command_failure_handler.ts b/packages/cli/src/command_failure_handler.ts deleted file mode 100644 index eead1f6f13c..00000000000 --- a/packages/cli/src/command_failure_handler.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Argv } from 'yargs'; -import { COLOR, Printer } from '@aws-amplify/cli-core'; -import { InvalidCredentialError } from './error/credential_error.js'; -import { EOL } from 'os'; - -/** - * Format error output when a command fails by displaying the error message in - * red color and displaying the message before the help as well. - * @param msg error message set in the yargs:check validations - * @param err errors thrown by yargs handlers - * @param yargs instance of yargs as made available in the builder - */ -export const handleCommandFailure = (msg: string, err: Error, yargs: Argv) => { - if (isUserForceClosePromptError(err)) { - return; - } - - if (err instanceof InvalidCredentialError) { - Printer.print(`${err.message}${EOL}`, COLOR.RED); - return; - } - - Printer.printNewLine(); - yargs.showHelp(); - - Printer.printNewLine(); - Printer.print(msg || String(err), COLOR.RED); - Printer.printNewLine(); -}; - -const isUserForceClosePromptError = (err: Error): boolean => { - return err?.message.includes('User force closed the prompt'); -}; diff --git a/packages/cli/src/commands/configure/configure_command.ts b/packages/cli/src/commands/configure/configure_command.ts index 8abdb9550d0..cd5515384a7 100644 --- a/packages/cli/src/commands/configure/configure_command.ts +++ b/packages/cli/src/commands/configure/configure_command.ts @@ -1,5 +1,4 @@ import { Argv, CommandModule } from 'yargs'; -import { handleCommandFailure } from '../../command_failure_handler.js'; /** * Root command to configure Amplify. @@ -35,13 +34,6 @@ export class ConfigureCommand implements CommandModule { * @inheritDoc */ builder = (yargs: Argv): Argv => { - return yargs - .version(false) - .command(this.configureSubCommands) - .help() - .fail((msg, err) => { - handleCommandFailure(msg, err, yargs); - yargs.exit(1, err); - }); + return yargs.version(false).command(this.configureSubCommands).help(); }; } diff --git a/packages/cli/src/commands/configure/telemetry/configure_telemetry_command.ts b/packages/cli/src/commands/configure/telemetry/configure_telemetry_command.ts index 2c35df77502..7011d59d580 100644 --- a/packages/cli/src/commands/configure/telemetry/configure_telemetry_command.ts +++ b/packages/cli/src/commands/configure/telemetry/configure_telemetry_command.ts @@ -4,7 +4,6 @@ import { USAGE_DATA_TRACKING_ENABLED, } from '@aws-amplify/platform-core'; import { Argv, CommandModule } from 'yargs'; -import { handleCommandFailure } from '../../../command_failure_handler.js'; /** * Command to configure AWS Amplify profile. */ @@ -54,10 +53,6 @@ export class ConfigureTelemetryCommand implements CommandModule { }) .demandCommand() .strictCommands() - .recommendCommands() - .fail((msg, err) => { - handleCommandFailure(msg, err, yargs); - yargs.exit(1, err); - }); + .recommendCommands(); }; } diff --git a/packages/cli/src/commands/generate/config/generate_config_command.ts b/packages/cli/src/commands/generate/config/generate_config_command.ts index 1e89e965147..0d7a686bbe0 100644 --- a/packages/cli/src/commands/generate/config/generate_config_command.ts +++ b/packages/cli/src/commands/generate/config/generate_config_command.ts @@ -3,7 +3,6 @@ import { ClientConfigFormat } from '@aws-amplify/client-config'; import { BackendIdentifierResolver } from '../../../backend-identifier/backend_identifier_resolver.js'; import { ClientConfigGeneratorAdapter } from '../../../client-config/client_config_generator_adapter.js'; import { ArgumentsKebabCase } from '../../../kebab_case.js'; -import { handleCommandFailure } from '../../../command_failure_handler.js'; export type GenerateConfigCommandOptions = ArgumentsKebabCase; @@ -100,10 +99,6 @@ export class GenerateConfigCommand 'A path to directory where config is written. If not provided defaults to current process working directory.', type: 'string', array: false, - }) - .fail((msg, err) => { - handleCommandFailure(msg, err, yargs); - yargs.exit(1, err); }); }; } diff --git a/packages/cli/src/commands/generate/forms/generate_forms_command.ts b/packages/cli/src/commands/generate/forms/generate_forms_command.ts index 218ce4e558f..ebbf3d00b9b 100644 --- a/packages/cli/src/commands/generate/forms/generate_forms_command.ts +++ b/packages/cli/src/commands/generate/forms/generate_forms_command.ts @@ -6,7 +6,6 @@ import { BackendIdentifierResolver } from '../../../backend-identifier/backend_i import { DEFAULT_UI_PATH } from '../../../form-generation/default_form_generation_output_paths.js'; import { FormGenerationHandler } from '../../../form-generation/form_generation_handler.js'; import { ArgumentsKebabCase } from '../../../kebab_case.js'; -import { handleCommandFailure } from '../../../command_failure_handler.js'; export type GenerateFormsCommandOptions = ArgumentsKebabCase; @@ -128,10 +127,6 @@ export class GenerateFormsCommand type: 'string', array: true, group: 'Form Generation', - }) - .fail((msg, err) => { - handleCommandFailure(msg, err, yargs); - yargs.exit(1, err); }); }; } diff --git a/packages/cli/src/commands/generate/generate_command.ts b/packages/cli/src/commands/generate/generate_command.ts index 5b710f56419..ab61c445c1a 100644 --- a/packages/cli/src/commands/generate/generate_command.ts +++ b/packages/cli/src/commands/generate/generate_command.ts @@ -2,7 +2,6 @@ import { Argv, CommandModule } from 'yargs'; import { GenerateConfigCommand } from './config/generate_config_command.js'; import { GenerateFormsCommand } from './forms/generate_forms_command.js'; import { GenerateGraphqlClientCodeCommand } from './graphql-client-code/generate_graphql_client_code_command.js'; -import { handleCommandFailure } from '../../command_failure_handler.js'; import { CommandMiddleware } from '../../command_middleware.js'; /** @@ -61,10 +60,6 @@ export class GenerateCommand implements CommandModule { array: false, }) .middleware([this.commandMiddleware.ensureAwsCredentialAndRegion]) - .fail((msg, err) => { - handleCommandFailure(msg, err, yargs); - yargs.exit(1, err); - }) ); }; } diff --git a/packages/cli/src/commands/generate/graphql-client-code/generate_graphql_client_code_command.ts b/packages/cli/src/commands/generate/graphql-client-code/generate_graphql_client_code_command.ts index c37b2c97d7a..1c4ba08a814 100644 --- a/packages/cli/src/commands/generate/graphql-client-code/generate_graphql_client_code_command.ts +++ b/packages/cli/src/commands/generate/graphql-client-code/generate_graphql_client_code_command.ts @@ -15,7 +15,6 @@ import { GenerateModelsOptions, } from '@aws-amplify/model-generator'; import { ArgumentsKebabCase } from '../../../kebab_case.js'; -import { handleCommandFailure } from '../../../command_failure_handler.js'; type GenerateOptions = | GenerateGraphqlCodegenOptions @@ -321,10 +320,6 @@ export class GenerateGraphqlClientCodeCommand array: false, hidden: true, }) - .showHidden('all') - .fail((msg, err) => { - handleCommandFailure(msg, err, yargs); - yargs.exit(1, err); - }); + .showHidden('all'); }; } diff --git a/packages/cli/src/commands/pipeline-deploy/pipeline_deploy_command.ts b/packages/cli/src/commands/pipeline-deploy/pipeline_deploy_command.ts index 9665796e02c..ebdf00f22f0 100644 --- a/packages/cli/src/commands/pipeline-deploy/pipeline_deploy_command.ts +++ b/packages/cli/src/commands/pipeline-deploy/pipeline_deploy_command.ts @@ -3,7 +3,6 @@ import { Argv, CommandModule } from 'yargs'; import { BackendDeployer } from '@aws-amplify/backend-deployer'; import { ClientConfigGeneratorAdapter } from '../../client-config/client_config_generator_adapter.js'; import { ArgumentsKebabCase } from '../../kebab_case.js'; -import { handleCommandFailure } from '../../command_failure_handler.js'; import { BackendIdentifier } from '@aws-amplify/plugin-types'; export type PipelineDeployCommandOptions = @@ -88,10 +87,6 @@ export class PipelineDeployCommand 'A path to directory where config is written. If not provided defaults to current process working directory.', type: 'string', array: false, - }) - .fail((msg, err) => { - handleCommandFailure(msg, err, yargs); - yargs.exit(1, err); }); }; } diff --git a/packages/cli/src/commands/sandbox/sandbox-delete/sandbox_delete_command.ts b/packages/cli/src/commands/sandbox/sandbox-delete/sandbox_delete_command.ts index 09593fb8004..fb5ea612670 100644 --- a/packages/cli/src/commands/sandbox/sandbox-delete/sandbox_delete_command.ts +++ b/packages/cli/src/commands/sandbox/sandbox-delete/sandbox_delete_command.ts @@ -1,7 +1,6 @@ import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; import { SandboxSingletonFactory } from '@aws-amplify/sandbox'; import { AmplifyPrompter } from '@aws-amplify/cli-core'; -import { handleCommandFailure } from '../../../command_failure_handler.js'; /** * Command that deletes the sandbox environment. @@ -76,10 +75,6 @@ export class SandboxDeleteCommand } } return true; - }) - .fail((msg, err) => { - handleCommandFailure(msg, err, yargs); - yargs.exit(1, err); }); }; } diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command.ts index 5c72657448c..98e8f9964b6 100644 --- a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command.ts +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command.ts @@ -1,5 +1,4 @@ import { Argv, CommandModule } from 'yargs'; -import { handleCommandFailure } from '../../../command_failure_handler.js'; /** * Root command to manage sandbox secret. @@ -35,12 +34,6 @@ export class SandboxSecretCommand implements CommandModule { * @inheritDoc */ builder = (yargs: Argv): Argv => { - return yargs - .command(this.secretSubCommands) - .help() - .fail((msg, err) => { - handleCommandFailure(msg, err, yargs); - yargs.exit(1, err); - }); + return yargs.command(this.secretSubCommands).help(); }; } diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_get_command.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_get_command.ts index 8da4f9306cc..d1753831cbe 100644 --- a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_get_command.ts +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_get_command.ts @@ -3,7 +3,6 @@ import { SecretClient } from '@aws-amplify/backend-secret'; import { SandboxBackendIdResolver } from '../sandbox_id_resolver.js'; import { Printer } from '@aws-amplify/cli-core'; import { ArgumentsKebabCase } from '../../../kebab_case.js'; -import { handleCommandFailure } from '../../../command_failure_handler.js'; /** * Command to get sandbox secret. @@ -53,11 +52,7 @@ export class SandboxSecretGetCommand type: 'string', demandOption: true, }) - .help() - .fail((msg, err) => { - handleCommandFailure(msg, err, yargs); - yargs.exit(1, err); - }); + .help(); }; } diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_remove_command.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_remove_command.ts index 3f5ddce6f6e..fc387830293 100644 --- a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_remove_command.ts +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_remove_command.ts @@ -2,7 +2,6 @@ import { Argv, CommandModule } from 'yargs'; import { SecretClient } from '@aws-amplify/backend-secret'; import { SandboxBackendIdResolver } from '../sandbox_id_resolver.js'; import { ArgumentsKebabCase } from '../../../kebab_case.js'; -import { handleCommandFailure } from '../../../command_failure_handler.js'; /** * Command to remove sandbox secret. @@ -46,16 +45,11 @@ export class SandboxSecretRemoveCommand * @inheritDoc */ builder = (yargs: Argv): Argv => { - return yargs - .positional('secret-name', { - describe: 'Name of the secret to remove', - type: 'string', - demandOption: true, - }) - .fail((msg, err) => { - handleCommandFailure(msg, err, yargs); - yargs.exit(1, err); - }); + return yargs.positional('secret-name', { + describe: 'Name of the secret to remove', + type: 'string', + demandOption: true, + }); }; } diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_set_command.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_set_command.ts index 70f47b6ef84..239526857ac 100644 --- a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_set_command.ts +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_set_command.ts @@ -4,7 +4,6 @@ import { SandboxBackendIdResolver } from '../sandbox_id_resolver.js'; import { AmplifyPrompter } from '@aws-amplify/cli-core'; import { ArgumentsKebabCase } from '../../../kebab_case.js'; -import { handleCommandFailure } from '../../../command_failure_handler.js'; /** * Command to set sandbox secret. @@ -49,16 +48,11 @@ export class SandboxSecretSetCommand * @inheritDoc */ builder = (yargs: Argv): Argv => { - return yargs - .positional('secret-name', { - describe: 'Name of the secret to set', - type: 'string', - demandOption: true, - }) - .fail((msg, err) => { - handleCommandFailure(msg, err, yargs); - yargs.exit(1, err); - }); + return yargs.positional('secret-name', { + describe: 'Name of the secret to set', + type: 'string', + demandOption: true, + }); }; } diff --git a/packages/cli/src/commands/sandbox/sandbox_command.ts b/packages/cli/src/commands/sandbox/sandbox_command.ts index d615df17791..ca22f3b3593 100644 --- a/packages/cli/src/commands/sandbox/sandbox_command.ts +++ b/packages/cli/src/commands/sandbox/sandbox_command.ts @@ -7,7 +7,6 @@ import { getClientConfigPath, } from '@aws-amplify/client-config'; import { ArgumentsKebabCase } from '../../kebab_case.js'; -import { handleCommandFailure } from '../../command_failure_handler.js'; import { ClientConfigLifecycleHandler } from '../../client-config/client_config_lifecycle_handler.js'; import { ClientConfigGeneratorAdapter } from '../../client-config/client_config_generator_adapter.js'; import { CommandMiddleware } from '../../command_middleware.js'; @@ -180,10 +179,6 @@ export class SandboxCommand return true; }) .middleware([this.commandMiddleware.ensureAwsCredentialAndRegion]) - .fail((msg, err) => { - handleCommandFailure(msg, err, yargs); - yargs.exit(1, err); - }) ); }; diff --git a/packages/cli/src/error_handler.test.ts b/packages/cli/src/error_handler.test.ts new file mode 100644 index 00000000000..a035d50294a --- /dev/null +++ b/packages/cli/src/error_handler.test.ts @@ -0,0 +1,171 @@ +import { after, before, beforeEach, describe, it, mock } from 'node:test'; +import { + attachUnhandledExceptionListeners, + generateCommandFailureHandler, +} from './error_handler.js'; +import { Argv } from 'yargs'; +import { COLOR, Printer } from '@aws-amplify/cli-core'; +import assert from 'node:assert'; +import { InvalidCredentialError } from './error/credential_error.js'; + +void describe('generateCommandFailureHandler', () => { + const mockPrint = mock.method(Printer, 'print'); + + const mockShowHelp = mock.fn(); + const mockExit = mock.fn(); + + const parser = { + showHelp: mockShowHelp, + exit: mockExit, + } as unknown as Argv; + + beforeEach(() => { + mockPrint.mock.resetCalls(); + mockShowHelp.mock.resetCalls(); + mockExit.mock.resetCalls(); + }); + + void it('prints specified message with undefined error', () => { + const someMsg = 'some msg'; + // undefined error is encountered with --help option. + generateCommandFailureHandler(parser)( + someMsg, + undefined as unknown as Error + ); + assert.equal(mockPrint.mock.callCount(), 1); + assert.equal(mockShowHelp.mock.callCount(), 1); + assert.equal(mockExit.mock.callCount(), 1); + assert.deepStrictEqual(mockPrint.mock.calls[0].arguments, [ + someMsg, + COLOR.RED, + ]); + }); + + void it('prints message from error object', () => { + const errMsg = 'some error msg'; + generateCommandFailureHandler(parser)('', new Error(errMsg)); + assert.equal(mockPrint.mock.callCount(), 1); + assert.equal(mockShowHelp.mock.callCount(), 1); + assert.equal(mockExit.mock.callCount(), 1); + assert.match(mockPrint.mock.calls[0].arguments[0], new RegExp(errMsg)); + assert.equal(mockPrint.mock.calls[0].arguments[1], COLOR.RED); + }); + + void it('handles a prompt force close error', () => { + generateCommandFailureHandler(parser)( + '', + new Error('User force closed the prompt') + ); + assert.equal(mockExit.mock.callCount(), 1); + assert.equal(mockPrint.mock.callCount(), 0); + }); + + void it('handles a profile error', () => { + const errMsg = 'some profile error'; + generateCommandFailureHandler(parser)( + '', + new InvalidCredentialError(errMsg) + ); + assert.equal(mockExit.mock.callCount(), 1); + assert.equal(mockPrint.mock.callCount(), 1); + assert.match(mockPrint.mock.calls[0].arguments[0], new RegExp(errMsg)); + assert.equal(mockPrint.mock.calls[0].arguments[1], COLOR.RED); + }); +}); + +void describe('attachUnhandledExceptionListeners', { concurrency: 1 }, () => { + const mockPrint = mock.method(Printer, 'print'); + + before(() => { + attachUnhandledExceptionListeners(); + }); + + beforeEach(() => { + mockPrint.mock.resetCalls(); + }); + + after(() => { + // remove the exception listeners that were added during setup + process.listeners('unhandledRejection').pop(); + process.listeners('uncaughtException').pop(); + }); + void it('handles rejected errors', () => { + process.listeners('unhandledRejection').at(-1)?.( + new Error('test error'), + Promise.resolve() + ); + assert.ok( + mockPrint.mock.calls.findIndex((call) => + call.arguments[0].includes('test error') + ) >= 0 + ); + expectProcessExitCode1AndReset(); + }); + + void it('handles rejected strings', () => { + process.listeners('unhandledRejection').at(-1)?.( + 'test error', + Promise.resolve() + ); + assert.ok( + mockPrint.mock.calls.findIndex((call) => + call.arguments[0].includes('test error') + ) >= 0 + ); + expectProcessExitCode1AndReset(); + }); + + void it('handles rejected symbols of other types', () => { + process.listeners('unhandledRejection').at(-1)?.( + { something: 'weird' }, + Promise.resolve() + ); + assert.ok( + mockPrint.mock.calls.findIndex((call) => + call.arguments[0].includes( + 'Error: Unhandled rejection of type [object]' + ) + ) >= 0 + ); + expectProcessExitCode1AndReset(); + }); + + void it('handles uncaught errors', () => { + process.listeners('uncaughtException').at(-1)?.( + new Error('test error'), + 'uncaughtException' + ); + assert.ok( + mockPrint.mock.calls.findIndex((call) => + call.arguments[0].includes('test error') + ) >= 0 + ); + expectProcessExitCode1AndReset(); + }); + + void it('does nothing when called multiple times', () => { + // note the first call happened in the before() setup + + const unhandledRejectionListenerCount = + process.listenerCount('unhandledRejection'); + const uncaughtExceptionListenerCount = + process.listenerCount('uncaughtException'); + + attachUnhandledExceptionListeners(); + attachUnhandledExceptionListeners(); + + assert.equal( + process.listenerCount('unhandledRejection'), + unhandledRejectionListenerCount + ); + assert.equal( + process.listenerCount('uncaughtException'), + uncaughtExceptionListenerCount + ); + }); +}); + +const expectProcessExitCode1AndReset = () => { + assert.equal(process.exitCode, 1); + process.exitCode = 0; +}; diff --git a/packages/cli/src/error_handler.ts b/packages/cli/src/error_handler.ts new file mode 100644 index 00000000000..4bff366f25c --- /dev/null +++ b/packages/cli/src/error_handler.ts @@ -0,0 +1,95 @@ +import { COLOR, Printer } from '@aws-amplify/cli-core'; +import { InvalidCredentialError } from './error/credential_error.js'; +import { EOL } from 'os'; +import { Argv } from 'yargs'; + +let hasAttachUnhandledExceptionListenersBeenCalled = false; + +/** + * Attaches process listeners to handle unhandled exceptions and rejections + */ +export const attachUnhandledExceptionListeners = (): void => { + if (hasAttachUnhandledExceptionListenersBeenCalled) { + return; + } + process.on('unhandledRejection', (reason) => { + process.exitCode = 1; + if (reason instanceof Error) { + handleError(reason); + } else if (typeof reason === 'string') { + handleError(new Error(reason)); + } else { + handleError( + new Error(`Unhandled rejection of type [${typeof reason}]`, { + cause: reason, + }) + ); + } + }); + + process.on('uncaughtException', (error) => { + process.exitCode = 1; + handleError(error); + }); + hasAttachUnhandledExceptionListenersBeenCalled = true; +}; + +/** + * Generates a function that is intended to be used as a callback to yargs.fail() + * All logic for actually handling errors should be delegated to handleError. + * + * For some reason the yargs object that is injected into the fail callback does not include all methods on the Argv type + * This generator allows us to inject the yargs parser into the callback so that we can call parser.exit() from the failure handler + * This prevents our top-level error handler from being invoked after the yargs error handler has already been invoked + */ +export const generateCommandFailureHandler = ( + parser: Argv +): ((message: string, error: Error) => void) => { + /** + * Format error output when a command fails + * @param message error message set by the yargs:check validations + * @param error error thrown by yargs handler + */ + const handleCommandFailure = (message: string, error: Error) => { + const printHelp = () => { + Printer.printNewLine(); + parser.showHelp(); + Printer.printNewLine(); + }; + + handleError(error, printHelp, message); + parser.exit(1, error); + }; + return handleCommandFailure; +}; + +/** + * Error handling for uncaught errors during CLI command execution. + * + * This should be the one and only place where we handle unexpected errors. + * This includes console logging, debug logging, metrics recording, etc. + * (Note that we don't do all of those things yet, but this is where they should go) + */ +const handleError = ( + error: Error, + printMessagePreamble?: () => void, + message?: string +) => { + // If yargs threw an error because the customer force-closed a prompt (ie Ctrl+C during a prompt) then the intent to exit the process is clear + if (isUserForceClosePromptError(error)) { + return; + } + + if (error instanceof InvalidCredentialError) { + Printer.print(`${error.message}${EOL}`, COLOR.RED); + return; + } + + printMessagePreamble?.(); + Printer.print(message || String(error), COLOR.RED); + Printer.printNewLine(); +}; + +const isUserForceClosePromptError = (err: Error): boolean => { + return err?.message.includes('User force closed the prompt'); +}; diff --git a/packages/cli/src/main_parser_factory.test.ts b/packages/cli/src/main_parser_factory.test.ts index 9e613d983a7..9dc4ae5b6b2 100644 --- a/packages/cli/src/main_parser_factory.test.ts +++ b/packages/cli/src/main_parser_factory.test.ts @@ -1,9 +1,6 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; -import { - TestCommandError, - TestCommandRunner, -} from './test-utils/command_runner.js'; +import { TestCommandRunner } from './test-utils/command_runner.js'; import { createMainParser } from './main_parser_factory.js'; import { version } from '#package.json'; @@ -22,16 +19,9 @@ void describe('main parser', { concurrency: false }, () => { assert.equal(output, `${version}\n`); }); - void it('fails if command is not provided', async () => { - await assert.rejects( - () => commandRunner.runCommand(''), - (err: TestCommandError) => { - assert.equal(err.error.name, 'YError'); - assert.match(err.error.message, /Not enough non-option arguments:/); - assert.match(err.output, /Commands:/); - assert.match(err.output, /Not enough non-option arguments:/); - return true; - } - ); + void it('prints help if command is not provided', async () => { + const output = await commandRunner.runCommand(''); + assert.match(output, /Commands:/); + assert.match(output, /Not enough non-option arguments:/); }); }); diff --git a/packages/cli/src/main_parser_factory.ts b/packages/cli/src/main_parser_factory.ts index 6f9a4c92fbc..01ca16d6823 100644 --- a/packages/cli/src/main_parser_factory.ts +++ b/packages/cli/src/main_parser_factory.ts @@ -5,6 +5,7 @@ import { createGenerateCommand } from './commands/generate/generate_command_fact import { createSandboxCommand } from './commands/sandbox/sandbox_command_factory.js'; import { createPipelineDeployCommand } from './commands/pipeline-deploy/pipeline_deploy_command_factory.js'; import { createConfigureCommand } from './commands/configure/configure_command_factory.js'; +import { generateCommandFailureHandler } from './error_handler.js'; /** * Creates main parser. @@ -13,7 +14,7 @@ export const createMainParser = (): Argv => { const packageJson = new PackageJsonReader().read( fileURLToPath(new URL('../package.json', import.meta.url)) ); - return yargs() + const parser = yargs() .version(packageJson.version ?? '') .command(createGenerateCommand()) .command(createSandboxCommand()) @@ -23,4 +24,6 @@ export const createMainParser = (): Argv => { .demandCommand() .strictCommands() .recommendCommands(); + parser.fail(generateCommandFailureHandler(parser)); + return parser; }; diff --git a/packages/cli/src/test-utils/command_runner.ts b/packages/cli/src/test-utils/command_runner.ts index a3a196dca6a..b32fbd1aa69 100644 --- a/packages/cli/src/test-utils/command_runner.ts +++ b/packages/cli/src/test-utils/command_runner.ts @@ -1,5 +1,6 @@ import { Argv } from 'yargs'; import { AsyncLocalStorage } from 'node:async_hooks'; +import { generateCommandFailureHandler } from '../error_handler.js'; class OutputInterceptor { private output = ''; @@ -60,7 +61,11 @@ export class TestCommandRunner { // Override script name to avoid long test file names .scriptName('amplify') // Make sure we don't exit process on error or --help - .exitProcess(false); + .exitProcess(false) + // attach the failure handler + // this is necessary because we may be testing a subcommand that doesn't have the top-level failure handler attached + // eventually we may want to have a separate "testFailureHandler" if we need additional tooling here + .fail(generateCommandFailureHandler(parser)); } /**