Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

configure output #1387

Merged
merged 23 commits into from Nov 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ebf5334
Add output configuration and use for version and errors
shadowspawn Oct 27, 2020
849bbbe
Tests passing using write/writeErr
shadowspawn Oct 30, 2020
c295eda
Suppress test output on stderr using spyon
shadowspawn Oct 30, 2020
0d82720
Accurately set help columns for stdout/stderr
shadowspawn Oct 31, 2020
ab1e0ce
Remove bogus file
shadowspawn Oct 31, 2020
95c3153
Tidy comments
shadowspawn Oct 31, 2020
9f3efe2
Only using single argument to write, simplify declaration to match
shadowspawn Oct 31, 2020
6a3eadf
Add tests for configureOutput write and writeError
shadowspawn Oct 31, 2020
b191033
Add tests for configureOutput getColumns and getErrorColumns
shadowspawn Oct 31, 2020
627f310
Add error case too
shadowspawn Oct 31, 2020
02f5b44
Use configureOutput instead of jest.spyon for some tests
shadowspawn Oct 31, 2020
d89c918
Add configureOutput to chain tests
shadowspawn Oct 31, 2020
3477e7e
Add set/get test for configureOutput
shadowspawn Oct 31, 2020
a0b1db7
Rename routines with symmetrical out/err
shadowspawn Nov 7, 2020
015895e
Add outputError simple code
shadowspawn Nov 7, 2020
2727d13
Add tests for outputError
shadowspawn Nov 10, 2020
bab81d0
Add JSDoc
shadowspawn Nov 10, 2020
8a68060
Tweak wording
shadowspawn Nov 10, 2020
2a8d1cb
First cut at TypeScript
shadowspawn Nov 10, 2020
1100c47
Add TypeScript sanity check for configureOutput
shadowspawn Nov 10, 2020
7c882f6
Add example for configureOutput
shadowspawn Nov 11, 2020
cc42309
Add configureOutput to README
shadowspawn Nov 14, 2020
0ae16fb
Make example in README a little clearer
shadowspawn Nov 16, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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);
});
});