Skip to content

Commit

Permalink
Add error() for displaying errors from client code (#1675)
Browse files Browse the repository at this point in the history
* Refactor private _displayError() into public error()

* add TypeScript

* Add tests

* Add to README

* Tiny wording change
  • Loading branch information
shadowspawn committed Jan 14, 2022
1 parent 772eb53 commit 7a59df4
Show file tree
Hide file tree
Showing 6 changed files with 123 additions and 11 deletions.
13 changes: 13 additions & 0 deletions Readme.md
Expand Up @@ -47,6 +47,7 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md)
- [createCommand()](#createcommand)
- [Node options such as `--harmony`](#node-options-such-as---harmony)
- [Debugging stand-alone executable subcommands](#debugging-stand-alone-executable-subcommands)
- [Display error](#display-error)
- [Override exit and output handling](#override-exit-and-output-handling)
- [Additional documentation](#additional-documentation)
- [Support](#support)
Expand Down Expand Up @@ -1003,6 +1004,18 @@ the inspector port is incremented by 1 for the spawned subcommand.
If you are using VSCode to debug executable subcommands you need to set the `"autoAttachChildProcesses": true` flag in your launch.json configuration.
### Display error
This routine is available to invoke the Commander error handling for your own error conditions. (See also the next section about exit handling.)
As well as the error message, you can optionally specify the `exitCode` (used with `process.exit`)
and `code` (used with `CommanderError`).
```js
program.exit('Password must be longer than four characters');
program.exit('Custom processing has failed', { exitCode: 2, code: 'my.custom.error' });
```
### Override exit and output handling
By default Commander calls `process.exit` when it detects errors, or after displaying the help or version. You can override
Expand Down
31 changes: 20 additions & 11 deletions lib/command.js
Expand Up @@ -538,7 +538,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
} catch (err) {
if (err.code === 'commander.invalidArgument') {
const message = `${invalidValueMessage} ${err.message}`;
this._displayError(err.exitCode, err.code, message);
this.error(message, { exitCode: err.exitCode, code: err.code });
}
throw err;
}
Expand Down Expand Up @@ -1096,7 +1096,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
} catch (err) {
if (err.code === 'commander.invalidArgument') {
const message = `error: command-argument value '${value}' is invalid for argument '${argument.name()}'. ${err.message}`;
this._displayError(err.exitCode, err.code, message);
this.error(message, { exitCode: err.exitCode, code: err.code });
}
throw err;
}
Expand Down Expand Up @@ -1475,18 +1475,27 @@ Expecting one of '${allowedValues.join("', '")}'`);
}

/**
* Internal bottleneck for handling of parsing errors.
* Display error message and exit (or call exitOverride).
*
* @api private
* @param {string} message
* @param {Object} [errorOptions]
* @param {string} [errorOptions.code] - an id string representing the error
* @param {number} [errorOptions.exitCode] - used with process.exit
*/
_displayError(exitCode, code, message) {
error(message, errorOptions) {
// output handling
this._outputConfiguration.outputError(`${message}\n`, this._outputConfiguration.writeErr);
if (typeof this._showHelpAfterError === 'string') {
this._outputConfiguration.writeErr(`${this._showHelpAfterError}\n`);
} else if (this._showHelpAfterError) {
this._outputConfiguration.writeErr('\n');
this.outputHelp({ error: true });
}

// exit handling
const config = errorOptions || {};
const exitCode = config.exitCode || 1;
const code = config.code || 'commander.error';
this._exit(exitCode, code, message);
}

Expand Down Expand Up @@ -1523,7 +1532,7 @@ Expecting one of '${allowedValues.join("', '")}'`);

missingArgument(name) {
const message = `error: missing required argument '${name}'`;
this._displayError(1, 'commander.missingArgument', message);
this.error(message, { code: 'commander.missingArgument' });
}

/**
Expand All @@ -1535,7 +1544,7 @@ Expecting one of '${allowedValues.join("', '")}'`);

optionMissingArgument(option) {
const message = `error: option '${option.flags}' argument missing`;
this._displayError(1, 'commander.optionMissingArgument', message);
this.error(message, { code: 'commander.optionMissingArgument' });
}

/**
Expand All @@ -1547,7 +1556,7 @@ Expecting one of '${allowedValues.join("', '")}'`);

missingMandatoryOptionValue(option) {
const message = `error: required option '${option.flags}' not specified`;
this._displayError(1, 'commander.missingMandatoryOptionValue', message);
this.error(message, { code: 'commander.missingMandatoryOptionValue' });
}

/**
Expand Down Expand Up @@ -1576,7 +1585,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
}

const message = `error: unknown option '${flag}'${suggestion}`;
this._displayError(1, 'commander.unknownOption', message);
this.error(message, { code: 'commander.unknownOption' });
}

/**
Expand All @@ -1593,7 +1602,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
const s = (expected === 1) ? '' : 's';
const forSubcommand = this.parent ? ` for '${this.name()}'` : '';
const message = `error: too many arguments${forSubcommand}. Expected ${expected} argument${s} but got ${receivedArgs.length}.`;
this._displayError(1, 'commander.excessArguments', message);
this.error(message, { code: 'commander.excessArguments' });
}

/**
Expand All @@ -1617,7 +1626,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
}

const message = `error: unknown command '${unknownName}'${suggestion}`;
this._displayError(1, 'commander.unknownCommand', message);
this.error(message, { code: 'commander.unknownCommand' });
}

/**
Expand Down
57 changes: 57 additions & 0 deletions tests/command.error.test.js
@@ -0,0 +1,57 @@
const commander = require('../');

test('when error called with message then message displayed on stderr', () => {
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => { });
const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => { });

const program = new commander.Command();
const message = 'Goodbye';
program.error(message);

expect(stderrSpy).toHaveBeenCalledWith(`${message}\n`);
stderrSpy.mockRestore();
exitSpy.mockRestore();
});

test('when error called with no exitCode then process.exit(1)', () => {
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => { });

const program = new commander.Command();
program.configureOutput({
writeErr: () => {}
});

program.error('Goodbye');

expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});

test('when error called with exitCode 2 then process.exit(2)', () => {
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => { });

const program = new commander.Command();
program.configureOutput({
writeErr: () => {}
});
program.error('Goodbye', { exitCode: 2 });

expect(exitSpy).toHaveBeenCalledWith(2);
exitSpy.mockRestore();
});

test('when error called with code and exitOverride then throws with code', () => {
const program = new commander.Command();
let errorThrown;
program
.exitOverride((err) => { errorThrown = err; throw err; })
.configureOutput({
writeErr: () => {}
});

const code = 'commander.test';
expect(() => {
program.error('Goodbye', { code });
}).toThrow();
expect(errorThrown.code).toEqual(code);
});
15 changes: 15 additions & 0 deletions tests/command.exitOverride.test.js
Expand Up @@ -331,6 +331,21 @@ describe('.exitOverride and error details', () => {

expectCommanderError(caughtErr, 1, 'commander.invalidArgument', "error: command-argument value 'green' is invalid for argument 'n'. NO");
});

test('when call error() then throw CommanderError', () => {
const program = new commander.Command();
program
.exitOverride();

let caughtErr;
try {
program.error('message');
} catch (err) {
caughtErr = err;
}

expectCommanderError(caughtErr, 1, 'commander.error', 'message');
});
});

test('when no override and error then exit(1)', () => {
Expand Down
12 changes: 12 additions & 0 deletions typings/index.d.ts
Expand Up @@ -31,6 +31,13 @@ export class InvalidArgumentError extends CommanderError {
}
export { InvalidArgumentError as InvalidOptionArgumentError }; // deprecated old name

export interface ErrorOptions { // optional parameter for error()
/** an id string representing the error */
code?: string;
/** suggested exit code which could be used with process.exit */
exitCode?: number;
}

export class Argument {
description: string;
required: boolean;
Expand Down Expand Up @@ -387,6 +394,11 @@ export class Command {
*/
exitOverride(callback?: (err: CommanderError) => never|void): this;

/**
* Display error message and exit (or call exitOverride).
*/
error(message: string, errorOptions?: ErrorOptions): never;

/**
* You can customise the help with a subclass of Help by overriding createHelp,
* or by overriding Help properties using configureHelp().
Expand Down
6 changes: 6 additions & 0 deletions typings/index.test-d.ts
Expand Up @@ -75,6 +75,12 @@ expectType<commander.Command>(program.exitOverride((err): void => {
}
}));

// error
expectType<never>(program.error('Goodbye'));
expectType<never>(program.error('Goodbye', { code: 'my.error' }));
expectType<never>(program.error('Goodbye', { exitCode: 2 }));
expectType<never>(program.error('Goodbye', { code: 'my.error', exitCode: 2 }));

// hook
expectType<commander.Command>(program.hook('preAction', () => {}));
expectType<commander.Command>(program.hook('postAction', () => {}));
Expand Down

0 comments on commit 7a59df4

Please sign in to comment.