Skip to content

Commit ed7f13e

Browse files
authored
configure output (#1387)
* 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
1 parent 2f7aa33 commit ed7f13e

19 files changed

+499
-125
lines changed

Readme.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md)
4141
- [Import into ECMAScript Module](#import-into-ecmascript-module)
4242
- [Node options such as `--harmony`](#node-options-such-as---harmony)
4343
- [Debugging stand-alone executable subcommands](#debugging-stand-alone-executable-subcommands)
44-
- [Override exit handling](#override-exit-handling)
44+
- [Override exit and output handling](#override-exit-and-output-handling)
4545
- [Additional documentation](#additional-documentation)
4646
- [Examples](#examples)
4747
- [Support](#support)
@@ -772,7 +772,7 @@ the inspector port is incremented by 1 for the spawned subcommand.
772772
773773
If you are using VSCode to debug executable subcommands you need to set the `"autoAttachChildProcesses": true` flag in your launch.json configuration.
774774
775-
### Override exit handling
775+
### Override exit and output handling
776776
777777
By default Commander calls `process.exit` when it detects errors, or after displaying the help or version. You can override
778778
this behaviour and optionally supply a callback. The default override throws a `CommanderError`.
@@ -790,6 +790,28 @@ try {
790790
}
791791
```
792792
793+
By default Commander is configured for a command-line application and writes to stdout and stderr.
794+
You can modify this behaviour for custom applications. In addition, you can modify the display of error messages.
795+
796+
Example file: [configure-output.js](./examples/configure-output.js)
797+
798+
799+
```js
800+
function errorColor(str) {
801+
// Add ANSI escape codes to display text in red.
802+
return `\x1b[31m${str}\x1b[0m`;
803+
}
804+
805+
program
806+
.configureOutput({
807+
// Visibly override write routines as example!
808+
writeOut: (str) => process.stdout.write(`[OUT] ${str}`),
809+
writeErr: (str) => process.stdout.write(`[ERR] ${str}`),
810+
// Highlight errors in color.
811+
outputError: (str, write) => write(errorColor(str))
812+
});
813+
```
814+
793815
### Additional documentation
794816
795817
There is more information available about:

examples/configure-output.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// const commander = require('commander'); // (normal include)
2+
const commander = require('../'); // include commander in git clone of commander repo
3+
4+
const program = new commander.Command();
5+
6+
function errorColor(str) {
7+
// Add ANSI escape codes to display text in red.
8+
return `\x1b[31m${str}\x1b[0m`;
9+
}
10+
11+
program
12+
.configureOutput({
13+
// Visibly override write routines as example!
14+
writeOut: (str) => process.stdout.write(`[OUT] ${str}`),
15+
writeErr: (str) => process.stdout.write(`[ERR] ${str}`),
16+
// Output errors in red.
17+
outputError: (str, write) => write(errorColor(str))
18+
});
19+
20+
program
21+
.version('1.2.3')
22+
.option('-c, --compress')
23+
.command('sub-command');
24+
25+
program.parse();
26+
27+
// Try the following:
28+
// node configure-output.js --version
29+
// node configure-output.js --unknown
30+
// node configure-output.js --help
31+
// node configure-output.js

index.js

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const fs = require('fs');
1212
// Although this is a class, methods are static in style to allow override using subclass or just functions.
1313
class Help {
1414
constructor() {
15-
this.columns = process.stdout.columns || 80;
15+
this.columns = undefined;
1616
this.sortSubcommands = false;
1717
this.sortOptions = false;
1818
}
@@ -243,7 +243,7 @@ class Help {
243243

244244
formatHelp(cmd, helper) {
245245
const termWidth = helper.padWidth(cmd, helper);
246-
const columns = helper.columns;
246+
const columns = helper.columns || 80;
247247
const itemIndentWidth = 2;
248248
const itemSeparatorWidth = 2;
249249
// itemIndent term itemSeparator description
@@ -543,6 +543,15 @@ class Command extends EventEmitter {
543543
this._description = '';
544544
this._argsDescription = undefined;
545545

546+
// see .configureOutput() for docs
547+
this._outputConfiguration = {
548+
writeOut: (str) => process.stdout.write(str),
549+
writeErr: (str) => process.stderr.write(str),
550+
getOutColumns: () => process.stdout.isTTY ? process.stdout.columns : undefined,
551+
getErrColumns: () => process.stderr.isTTY ? process.stderr.columns : undefined,
552+
outputError: (str, write) => write(str)
553+
};
554+
546555
this._hidden = false;
547556
this._hasHelpOption = true;
548557
this._helpFlags = '-h, --help';
@@ -663,6 +672,32 @@ class Command extends EventEmitter {
663672
return this;
664673
}
665674

675+
/**
676+
* The default output goes to stdout and stderr. You can customise this for special
677+
* applications. You can also customise the display of errors by overriding outputError.
678+
*
679+
* The configuration properties are all functions:
680+
*
681+
* // functions to change where being written, stdout and stderr
682+
* writeOut(str)
683+
* writeErr(str)
684+
* // matching functions to specify columns for wrapping help
685+
* getOutColumns()
686+
* getErrColumns()
687+
* // functions based on what is being written out
688+
* outputError(str, write) // used for displaying errors, and not used for displaying help
689+
*
690+
* @param {Object} [configuration] - configuration options
691+
* @return {Command|Object} `this` command for chaining, or stored configuration
692+
*/
693+
694+
configureOutput(configuration) {
695+
if (configuration === undefined) return this._outputConfiguration;
696+
697+
Object.assign(this._outputConfiguration, configuration);
698+
return this;
699+
}
700+
666701
/**
667702
* Add a prepared subcommand.
668703
*
@@ -968,8 +1003,7 @@ Read more on https://git.io/JJc0W`);
9681003
val = option.parseArg(val, oldValue === undefined ? defaultValue : oldValue);
9691004
} catch (err) {
9701005
if (err.code === 'commander.optionArgumentRejected') {
971-
console.error(err.message);
972-
this._exit(err.exitCode, err.code, err.message);
1006+
this._displayError(err.exitCode, err.code, err.message);
9731007
}
9741008
throw err;
9751009
}
@@ -1639,6 +1673,16 @@ Read more on https://git.io/JJc0W`);
16391673
return this._optionValues;
16401674
};
16411675

1676+
/**
1677+
* Internal bottleneck for handling of parsing errors.
1678+
*
1679+
* @api private
1680+
*/
1681+
_displayError(exitCode, code, message) {
1682+
this._outputConfiguration.outputError(`${message}\n`, this._outputConfiguration.writeErr);
1683+
this._exit(exitCode, code, message);
1684+
}
1685+
16421686
/**
16431687
* Argument `name` is missing.
16441688
*
@@ -1648,8 +1692,7 @@ Read more on https://git.io/JJc0W`);
16481692

16491693
missingArgument(name) {
16501694
const message = `error: missing required argument '${name}'`;
1651-
console.error(message);
1652-
this._exit(1, 'commander.missingArgument', message);
1695+
this._displayError(1, 'commander.missingArgument', message);
16531696
};
16541697

16551698
/**
@@ -1667,8 +1710,7 @@ Read more on https://git.io/JJc0W`);
16671710
} else {
16681711
message = `error: option '${option.flags}' argument missing`;
16691712
}
1670-
console.error(message);
1671-
this._exit(1, 'commander.optionMissingArgument', message);
1713+
this._displayError(1, 'commander.optionMissingArgument', message);
16721714
};
16731715

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

16811723
missingMandatoryOptionValue(option) {
16821724
const message = `error: required option '${option.flags}' not specified`;
1683-
console.error(message);
1684-
this._exit(1, 'commander.missingMandatoryOptionValue', message);
1725+
this._displayError(1, 'commander.missingMandatoryOptionValue', message);
16851726
};
16861727

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

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

17191758
/**
@@ -1739,7 +1778,7 @@ Read more on https://git.io/JJc0W`);
17391778
this._versionOptionName = versionOption.attributeName();
17401779
this.options.push(versionOption);
17411780
this.on('option:' + versionOption.name(), () => {
1742-
process.stdout.write(str + '\n');
1781+
this._outputConfiguration.writeOut(`${str}\n`);
17431782
this._exit(0, 'commander.version', str);
17441783
});
17451784
return this;
@@ -1841,11 +1880,15 @@ Read more on https://git.io/JJc0W`);
18411880
/**
18421881
* Return program help documentation.
18431882
*
1883+
* @param {{ error: boolean }} [contextOptions] - pass {error:true} to wrap for stderr instead of stdout
18441884
* @return {string}
18451885
*/
18461886

1847-
helpInformation() {
1887+
helpInformation(contextOptions) {
18481888
const helper = this.createHelp();
1889+
if (helper.columns === undefined) {
1890+
helper.columns = (contextOptions && contextOptions.error) ? this._outputConfiguration.getErrColumns() : this._outputConfiguration.getOutColumns();
1891+
}
18491892
return helper.formatHelp(this, helper);
18501893
};
18511894

@@ -1858,9 +1901,9 @@ Read more on https://git.io/JJc0W`);
18581901
const context = { error: !!contextOptions.error };
18591902
let write;
18601903
if (context.error) {
1861-
write = (arg, ...args) => process.stderr.write(arg, ...args);
1904+
write = (arg) => this._outputConfiguration.writeErr(arg);
18621905
} else {
1863-
write = (arg, ...args) => process.stdout.write(arg, ...args);
1906+
write = (arg) => this._outputConfiguration.writeOut(arg);
18641907
}
18651908
context.write = contextOptions.write || write;
18661909
context.command = this;
@@ -1893,7 +1936,7 @@ Read more on https://git.io/JJc0W`);
18931936
groupListeners.slice().reverse().forEach(command => command.emit('beforeAllHelp', context));
18941937
this.emit('beforeHelp', context);
18951938

1896-
let helpInformation = this.helpInformation();
1939+
let helpInformation = this.helpInformation(context);
18971940
if (deprecatedCallback) {
18981941
helpInformation = deprecatedCallback(helpInformation);
18991942
if (typeof helpInformation !== 'string' && !Buffer.isBuffer(helpInformation)) {

tests/args.variadic.test.js

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,6 @@ const commander = require('../');
33
// Testing variadic arguments. Testing all the action arguments, but could test just variadicArg.
44

55
describe('variadic argument', () => {
6-
// Optional. Use internal knowledge to suppress output to keep test output clean.
7-
let consoleErrorSpy;
8-
9-
beforeAll(() => {
10-
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
11-
});
12-
13-
afterEach(() => {
14-
consoleErrorSpy.mockClear();
15-
});
16-
17-
afterAll(() => {
18-
consoleErrorSpy.mockRestore();
19-
});
20-
216
test('when no extra arguments specified for program then variadic arg is empty array', () => {
227
const actionMock = jest.fn();
238
const program = new commander.Command();

tests/command.action.test.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,19 +46,17 @@ test('when .action on program with required argument and argument supplied then
4646
});
4747

4848
test('when .action on program with required argument and argument not supplied then action not called', () => {
49-
// Optional. Use internal knowledge to suppress output to keep test output clean.
50-
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
5149
const actionMock = jest.fn();
5250
const program = new commander.Command();
5351
program
5452
.exitOverride()
53+
.configureOutput({ writeErr: () => {} })
5554
.arguments('<file>')
5655
.action(actionMock);
5756
expect(() => {
5857
program.parse(['node', 'test']);
5958
}).toThrow();
6059
expect(actionMock).not.toHaveBeenCalled();
61-
consoleErrorSpy.mockRestore();
6260
});
6361

6462
// Changes made in #729 to call program action handler

tests/command.allowUnknownOption.test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,18 @@ const commander = require('../');
44

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

99
beforeAll(() => {
10-
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
10+
writeErrorSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => { });
1111
});
1212

1313
afterEach(() => {
14-
consoleErrorSpy.mockClear();
14+
writeErrorSpy.mockClear();
1515
});
1616

1717
afterAll(() => {
18-
consoleErrorSpy.mockRestore();
18+
writeErrorSpy.mockRestore();
1919
});
2020

2121
test('when specify unknown program option then error', () => {

tests/command.chain.test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,10 @@ describe('Command methods that should return this for chaining', () => {
135135
const result = program.configureHelp({ });
136136
expect(result).toBe(program);
137137
});
138+
139+
test('when call .configureOutput() then returns this', () => {
140+
const program = new Command();
141+
const result = program.configureOutput({ });
142+
expect(result).toBe(program);
143+
});
138144
});

0 commit comments

Comments
 (0)