Skip to content

Commit

Permalink
configure output (#1387)
Browse files Browse the repository at this point in the history
* Add output configuration and use for version and errors

* Tests passing using write/writeErr

* Suppress test output on stderr using spyon

* Accurately set help columns for stdout/stderr

* Remove bogus file

* Tidy comments

* Only using single argument to write, simplify declaration to match

* Add tests for configureOutput write and writeError

* Add tests for configureOutput getColumns and getErrorColumns

* Add error case too

* Use configureOutput instead of jest.spyon for some tests

* Add configureOutput to chain tests

* Add set/get test for configureOutput

* Rename routines with symmetrical out/err

* Add outputError simple code

* Add tests for outputError

* Add JSDoc

* Tweak wording

* First cut at TypeScript

* Add TypeScript sanity check for configureOutput

* Add example for configureOutput

* Add configureOutput to README

* Make example in README a little clearer
  • Loading branch information
shadowspawn committed Nov 17, 2020
1 parent 2f7aa33 commit ed7f13e
Show file tree
Hide file tree
Showing 19 changed files with 499 additions and 125 deletions.
26 changes: 24 additions & 2 deletions Readme.md
Expand Up @@ -41,7 +41,7 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md)
- [Import into ECMAScript Module](#import-into-ecmascript-module)
- [Node options such as `--harmony`](#node-options-such-as---harmony)
- [Debugging stand-alone executable subcommands](#debugging-stand-alone-executable-subcommands)
- [Override exit handling](#override-exit-handling)
- [Override exit and output handling](#override-exit-and-output-handling)
- [Additional documentation](#additional-documentation)
- [Examples](#examples)
- [Support](#support)
Expand Down Expand Up @@ -772,7 +772,7 @@ 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.
### Override exit handling
### 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
this behaviour and optionally supply a callback. The default override throws a `CommanderError`.
Expand All @@ -790,6 +790,28 @@ try {
}
```
By default Commander is configured for a command-line application and writes to stdout and stderr.
You can modify this behaviour for custom applications. In addition, you can modify the display of error messages.
Example file: [configure-output.js](./examples/configure-output.js)
```js
function errorColor(str) {
// Add ANSI escape codes to display text in red.
return `\x1b[31m${str}\x1b[0m`;
}
program
.configureOutput({
// Visibly override write routines as example!
writeOut: (str) => process.stdout.write(`[OUT] ${str}`),
writeErr: (str) => process.stdout.write(`[ERR] ${str}`),
// Highlight errors in color.
outputError: (str, write) => write(errorColor(str))
});
```
### Additional documentation
There is more information available about:
Expand Down
31 changes: 31 additions & 0 deletions examples/configure-output.js
@@ -0,0 +1,31 @@
// const commander = require('commander'); // (normal include)
const commander = require('../'); // include commander in git clone of commander repo

const program = new commander.Command();

function errorColor(str) {
// Add ANSI escape codes to display text in red.
return `\x1b[31m${str}\x1b[0m`;
}

program
.configureOutput({
// Visibly override write routines as example!
writeOut: (str) => process.stdout.write(`[OUT] ${str}`),
writeErr: (str) => process.stdout.write(`[ERR] ${str}`),
// Output errors in red.
outputError: (str, write) => write(errorColor(str))
});

program
.version('1.2.3')
.option('-c, --compress')
.command('sub-command');

program.parse();

// Try the following:
// node configure-output.js --version
// node configure-output.js --unknown
// node configure-output.js --help
// node configure-output.js
81 changes: 62 additions & 19 deletions index.js
Expand Up @@ -12,7 +12,7 @@ const fs = require('fs');
// Although this is a class, methods are static in style to allow override using subclass or just functions.
class Help {
constructor() {
this.columns = process.stdout.columns || 80;
this.columns = undefined;
this.sortSubcommands = false;
this.sortOptions = false;
}
Expand Down Expand Up @@ -243,7 +243,7 @@ class Help {

formatHelp(cmd, helper) {
const termWidth = helper.padWidth(cmd, helper);
const columns = helper.columns;
const columns = helper.columns || 80;
const itemIndentWidth = 2;
const itemSeparatorWidth = 2;
// itemIndent term itemSeparator description
Expand Down Expand Up @@ -543,6 +543,15 @@ class Command extends EventEmitter {
this._description = '';
this._argsDescription = undefined;

// see .configureOutput() for docs
this._outputConfiguration = {
writeOut: (str) => process.stdout.write(str),
writeErr: (str) => process.stderr.write(str),
getOutColumns: () => process.stdout.isTTY ? process.stdout.columns : undefined,
getErrColumns: () => process.stderr.isTTY ? process.stderr.columns : undefined,
outputError: (str, write) => write(str)
};

this._hidden = false;
this._hasHelpOption = true;
this._helpFlags = '-h, --help';
Expand Down Expand Up @@ -663,6 +672,32 @@ class Command extends EventEmitter {
return this;
}

/**
* The default output goes to stdout and stderr. You can customise this for special
* applications. You can also customise the display of errors by overriding outputError.
*
* The configuration properties are all functions:
*
* // functions to change where being written, stdout and stderr
* writeOut(str)
* writeErr(str)
* // matching functions to specify columns for wrapping help
* getOutColumns()
* getErrColumns()
* // functions based on what is being written out
* outputError(str, write) // used for displaying errors, and not used for displaying help
*
* @param {Object} [configuration] - configuration options
* @return {Command|Object} `this` command for chaining, or stored configuration
*/

configureOutput(configuration) {
if (configuration === undefined) return this._outputConfiguration;

Object.assign(this._outputConfiguration, configuration);
return this;
}

/**
* Add a prepared subcommand.
*
Expand Down Expand Up @@ -968,8 +1003,7 @@ Read more on https://git.io/JJc0W`);
val = option.parseArg(val, oldValue === undefined ? defaultValue : oldValue);
} catch (err) {
if (err.code === 'commander.optionArgumentRejected') {
console.error(err.message);
this._exit(err.exitCode, err.code, err.message);
this._displayError(err.exitCode, err.code, err.message);
}
throw err;
}
Expand Down Expand Up @@ -1639,6 +1673,16 @@ Read more on https://git.io/JJc0W`);
return this._optionValues;
};

/**
* Internal bottleneck for handling of parsing errors.
*
* @api private
*/
_displayError(exitCode, code, message) {
this._outputConfiguration.outputError(`${message}\n`, this._outputConfiguration.writeErr);
this._exit(exitCode, code, message);
}

/**
* Argument `name` is missing.
*
Expand All @@ -1648,8 +1692,7 @@ Read more on https://git.io/JJc0W`);

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

/**
Expand All @@ -1667,8 +1710,7 @@ Read more on https://git.io/JJc0W`);
} else {
message = `error: option '${option.flags}' argument missing`;
}
console.error(message);
this._exit(1, 'commander.optionMissingArgument', message);
this._displayError(1, 'commander.optionMissingArgument', message);
};

/**
Expand All @@ -1680,8 +1722,7 @@ Read more on https://git.io/JJc0W`);

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

/**
Expand All @@ -1694,8 +1735,7 @@ Read more on https://git.io/JJc0W`);
unknownOption(flag) {
if (this._allowUnknownOption) return;
const message = `error: unknown option '${flag}'`;
console.error(message);
this._exit(1, 'commander.unknownOption', message);
this._displayError(1, 'commander.unknownOption', message);
};

/**
Expand All @@ -1712,8 +1752,7 @@ Read more on https://git.io/JJc0W`);
const fullCommand = partCommands.join(' ');
const message = `error: unknown command '${this.args[0]}'.` +
(this._hasHelpOption ? ` See '${fullCommand} ${this._helpLongFlag}'.` : '');
console.error(message);
this._exit(1, 'commander.unknownCommand', message);
this._displayError(1, 'commander.unknownCommand', message);
};

/**
Expand All @@ -1739,7 +1778,7 @@ Read more on https://git.io/JJc0W`);
this._versionOptionName = versionOption.attributeName();
this.options.push(versionOption);
this.on('option:' + versionOption.name(), () => {
process.stdout.write(str + '\n');
this._outputConfiguration.writeOut(`${str}\n`);
this._exit(0, 'commander.version', str);
});
return this;
Expand Down Expand Up @@ -1841,11 +1880,15 @@ Read more on https://git.io/JJc0W`);
/**
* Return program help documentation.
*
* @param {{ error: boolean }} [contextOptions] - pass {error:true} to wrap for stderr instead of stdout
* @return {string}
*/

helpInformation() {
helpInformation(contextOptions) {
const helper = this.createHelp();
if (helper.columns === undefined) {
helper.columns = (contextOptions && contextOptions.error) ? this._outputConfiguration.getErrColumns() : this._outputConfiguration.getOutColumns();
}
return helper.formatHelp(this, helper);
};

Expand All @@ -1858,9 +1901,9 @@ Read more on https://git.io/JJc0W`);
const context = { error: !!contextOptions.error };
let write;
if (context.error) {
write = (arg, ...args) => process.stderr.write(arg, ...args);
write = (arg) => this._outputConfiguration.writeErr(arg);
} else {
write = (arg, ...args) => process.stdout.write(arg, ...args);
write = (arg) => this._outputConfiguration.writeOut(arg);
}
context.write = contextOptions.write || write;
context.command = this;
Expand Down Expand Up @@ -1893,7 +1936,7 @@ Read more on https://git.io/JJc0W`);
groupListeners.slice().reverse().forEach(command => command.emit('beforeAllHelp', context));
this.emit('beforeHelp', context);

let helpInformation = this.helpInformation();
let helpInformation = this.helpInformation(context);
if (deprecatedCallback) {
helpInformation = deprecatedCallback(helpInformation);
if (typeof helpInformation !== 'string' && !Buffer.isBuffer(helpInformation)) {
Expand Down
15 changes: 0 additions & 15 deletions tests/args.variadic.test.js
Expand Up @@ -3,21 +3,6 @@ const commander = require('../');
// Testing variadic arguments. Testing all the action arguments, but could test just variadicArg.

describe('variadic argument', () => {
// Optional. Use internal knowledge to suppress output to keep test output clean.
let consoleErrorSpy;

beforeAll(() => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
});

afterEach(() => {
consoleErrorSpy.mockClear();
});

afterAll(() => {
consoleErrorSpy.mockRestore();
});

test('when no extra arguments specified for program then variadic arg is empty array', () => {
const actionMock = jest.fn();
const program = new commander.Command();
Expand Down
4 changes: 1 addition & 3 deletions tests/command.action.test.js
Expand Up @@ -46,19 +46,17 @@ test('when .action on program with required argument and argument supplied then
});

test('when .action on program with required argument and argument not supplied then action not called', () => {
// Optional. Use internal knowledge to suppress output to keep test output clean.
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
const actionMock = jest.fn();
const program = new commander.Command();
program
.exitOverride()
.configureOutput({ writeErr: () => {} })
.arguments('<file>')
.action(actionMock);
expect(() => {
program.parse(['node', 'test']);
}).toThrow();
expect(actionMock).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});

// Changes made in #729 to call program action handler
Expand Down
8 changes: 4 additions & 4 deletions tests/command.allowUnknownOption.test.js
Expand Up @@ -4,18 +4,18 @@ const commander = require('../');

describe('allowUnknownOption', () => {
// Optional. Use internal knowledge to suppress output to keep test output clean.
let consoleErrorSpy;
let writeErrorSpy;

beforeAll(() => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
writeErrorSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => { });
});

afterEach(() => {
consoleErrorSpy.mockClear();
writeErrorSpy.mockClear();
});

afterAll(() => {
consoleErrorSpy.mockRestore();
writeErrorSpy.mockRestore();
});

test('when specify unknown program option then error', () => {
Expand Down
6 changes: 6 additions & 0 deletions tests/command.chain.test.js
Expand Up @@ -135,4 +135,10 @@ describe('Command methods that should return this for chaining', () => {
const result = program.configureHelp({ });
expect(result).toBe(program);
});

test('when call .configureOutput() then returns this', () => {
const program = new Command();
const result = program.configureOutput({ });
expect(result).toBe(program);
});
});

0 comments on commit ed7f13e

Please sign in to comment.