Skip to content

Commit

Permalink
Add .env method to Option for consulting environment variable for opt…
Browse files Browse the repository at this point in the history
…ion value (#1587)

* First cut at env support for options, including custom processing

* Basic tests

* Fill out special cases

* Skip test that picked up a problem!

* Add option source so can make decisions about priorities when processing env

* Rework processing to preserve existing behaviour and handle cli and env same way

* Remove _doParseOptionArg as no longer used, refactored in a more direct way

* Update JSDoc/TSDoc/tsd

* Add help test for env

* Add env event tests

* Add env to help for negated option

* Make env error message more explicit

* Add example file

* Add env to README

* name param for .env is not optional

* Add test that env counts for mandatory options

* Lint

* Remove stale parameter

* Minor lint
  • Loading branch information
shadowspawn committed Aug 24, 2021
1 parent 3a0ef21 commit 8571a75
Show file tree
Hide file tree
Showing 12 changed files with 482 additions and 34 deletions.
11 changes: 7 additions & 4 deletions Readme.md
Expand Up @@ -93,7 +93,6 @@ import { Command } from 'commander';
const program = new Command();
```


## Options

Options are defined with the `.option()` method, also serving as documentation for the options. Each option can have a short flag (single character) and a long name, separated by a comma or space or vertical bar ('|').
Expand Down Expand Up @@ -308,13 +307,14 @@ program.version('0.0.1', '-v, --vers', 'output the current version');
You can add most options using the `.option()` method, but there are some additional features available
by constructing an `Option` explicitly for less common cases.
Example file: [options-extra.js](./examples/options-extra.js)
Example files: [options-extra.js](./examples/options-extra.js), [options-env.js](./examples/options-env.js)
```js
program
.addOption(new Option('-s, --secret').hideHelp())
.addOption(new Option('-t, --timeout <delay>', 'timeout in seconds').default(60, 'one minute'))
.addOption(new Option('-d, --drink <size>', 'drink size').choices(['small', 'medium', 'large']));
.addOption(new Option('-d, --drink <size>', 'drink size').choices(['small', 'medium', 'large']))
.addOption(new Option('-p, --port <number>', 'port number').env('PORT'));
```
```bash
Expand All @@ -324,10 +324,14 @@ Usage: help [options]
Options:
-t, --timeout <delay> timeout in seconds (default: one minute)
-d, --drink <size> drink cup size (choices: "small", "medium", "large")
-p, --port <number> port number (env: PORT)
-h, --help display help for command
$ extra --drink huge
error: option '-d, --drink <size>' argument 'huge' is invalid. Allowed choices are small, medium, large.
$ PORT=80 extra
Options: { timeout: 60, port: '80' }
```
### Custom option processing
Expand Down Expand Up @@ -903,7 +907,6 @@ You can modify this behaviour for custom applications. In addition, you can modi
Example file: [configure-output.js](./examples/configure-output.js)
```js
function errorColor(str) {
// Add ANSI escape codes to display text in red.
Expand Down
38 changes: 38 additions & 0 deletions examples/options-env.js
@@ -0,0 +1,38 @@
#!/usr/bin/env node
// const { Command, Option } = require('commander'); // (normal include)
const { Command, Option } = require('../'); // include commander in git clone of commander repo
const program = new Command();

program.addOption(new Option('-p, --port <number>', 'specify port number')
.default(80)
.env('PORT')
);
program.addOption(new Option('-c, --colour', 'turn on colour output')
.env('COLOUR')
);
program.addOption(new Option('-C, --no-colour', 'turn off colour output')
.env('NO_COLOUR')
);
program.addOption(new Option('-s, --size <type>', 'specify size of drink')
.choices(['small', 'medium', 'large'])
.env('SIZE')
);

program.parse();
console.log(program.opts());

// Try the following:
// node options-env.js --help
//
// node options-env.js
// PORT=9001 node options-env.js
// PORT=9001 node options-env.js --port 123
//
// COLOUR= node options-env.js
// COLOUR= node options-env.js --no-colour
// NO_COLOUR= node options-env.js
// NO_COLOUR= node options-env.js --colour
//
// SIZE=small node options-env.js
// SIZE=enormous node options-env.js
// SIZE=enormous node options-env.js --size=large
19 changes: 11 additions & 8 deletions examples/options-extra.js
@@ -1,20 +1,23 @@
#!/usr/bin/env node

// This is used as an example in the README for extra option features.
// See also options-env.js for more extensive env examples.

// const commander = require('commander'); // (normal include)
const commander = require('../'); // include commander in git clone of commander repo
const program = new commander.Command();
// const { Command, Option } = require('commander'); // (normal include)
const { Command, Option } = require('../'); // include commander in git clone of commander repo
const program = new Command();

program
.addOption(new commander.Option('-s, --secret').hideHelp())
.addOption(new commander.Option('-t, --timeout <delay>', 'timeout in seconds').default(60, 'one minute'))
.addOption(new commander.Option('-d, --drink <size>', 'drink cup size').choices(['small', 'medium', 'large']));
.addOption(new Option('-s, --secret').hideHelp())
.addOption(new Option('-t, --timeout <delay>', 'timeout in seconds').default(60, 'one minute'))
.addOption(new Option('-d, --drink <size>', 'drink cup size').choices(['small', 'medium', 'large']))
.addOption(new Option('-p, --port <number>', 'port number').env('PORT'));

program.parse();

console.log('Options: ', program.opts());

// Try the following:
// node options-extra.js --help
// node options-extra.js --drink huge
// node options-extra.js --help
// node options-extra.js --drink huge
// PORT=80 node options-extra.js
64 changes: 54 additions & 10 deletions lib/command.js
Expand Up @@ -35,6 +35,7 @@ class Command extends EventEmitter {
this._scriptPath = null;
this._name = name || '';
this._optionValues = {};
this._optionValueSources = {}; // default < env < cli
this._storeOptionsAsProperties = false;
this._actionHandler = null;
this._executableHandler = false;
Expand Down Expand Up @@ -512,16 +513,16 @@ Expecting one of '${allowedValues.join("', '")}'`);
}
// preassign only if we have a default
if (defaultValue !== undefined) {
this.setOptionValue(name, defaultValue);
this._setOptionValueWithSource(name, defaultValue, 'default');
}
}

// register the option
this.options.push(option);

// when it's passed assign the value
// and conditionally invoke the callback
this.on('option:' + oname, (val) => {
// handler for cli and env supplied values
const handleOptionValue = (val, invalidValueMessage, valueSource) => {
// Note: using closure to access lots of lexical scoped variables.
const oldValue = this.getOptionValue(name);

// custom processing
Expand All @@ -530,7 +531,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
val = option.parseArg(val, oldValue === undefined ? defaultValue : oldValue);
} catch (err) {
if (err.code === 'commander.invalidArgument') {
const message = `error: option '${option.flags}' argument '${val}' is invalid. ${err.message}`;
const message = `${invalidValueMessage} ${err.message}`;
this._displayError(err.exitCode, err.code, message);
}
throw err;
Expand All @@ -543,18 +544,28 @@ Expecting one of '${allowedValues.join("', '")}'`);
if (typeof oldValue === 'boolean' || typeof oldValue === 'undefined') {
// if no value, negate false, and we have a default, then use it!
if (val == null) {
this.setOptionValue(name, option.negate
? false
: defaultValue || true);
this._setOptionValueWithSource(name, option.negate ? false : defaultValue || true, valueSource);
} else {
this.setOptionValue(name, val);
this._setOptionValueWithSource(name, val, valueSource);
}
} else if (val !== null) {
// reassign
this.setOptionValue(name, option.negate ? false : val);
this._setOptionValueWithSource(name, option.negate ? false : val, valueSource);
}
};

this.on('option:' + oname, (val) => {
const invalidValueMessage = `error: option '${option.flags}' argument '${val}' is invalid.`;
handleOptionValue(val, invalidValueMessage, 'cli');
});

if (option.envVar) {
this.on('optionEnv:' + oname, (val) => {
const invalidValueMessage = `error: option '${option.flags}' value '${val}' from env '${option.envVar}' is invalid.`;
handleOptionValue(val, invalidValueMessage, 'env');
});
}

return this;
}

Expand Down Expand Up @@ -767,6 +778,14 @@ Expecting one of '${allowedValues.join("', '")}'`);
return this;
};

/**
* @api private
*/
_setOptionValueWithSource(key, value, source) {
this.setOptionValue(key, value);
this._optionValueSources[key] = source;
}

/**
* Get user arguments implied or explicit arguments.
* Side-effects: set _scriptPath if args included application, and use that to set implicit command name.
Expand Down Expand Up @@ -1131,6 +1150,7 @@ Expecting one of '${allowedValues.join("', '")}'`);

_parseCommand(operands, unknown) {
const parsed = this.parseOptions(unknown);
this._parseOptionsEnv(); // after cli, so parseArg not called on both cli and env
operands = operands.concat(parsed.operands);
unknown = parsed.unknown;
this.args = operands.concat(unknown);
Expand Down Expand Up @@ -1411,6 +1431,30 @@ Expecting one of '${allowedValues.join("', '")}'`);
this._exit(exitCode, code, message);
}

/**
* Apply any option related environment variables, if option does
* not have a value from cli or client code.
*
* @api private
*/
_parseOptionsEnv() {
this.options.forEach((option) => {
if (option.envVar && option.envVar in process.env) {
const optionKey = option.attributeName();
// env is second lowest priority source, above default
if (this.getOptionValue(optionKey) === undefined || this._optionValueSources[optionKey] === 'default') {
if (option.required || option.optional) { // option can take a value
// keep very simple, optional always takes value
this.emit(`optionEnv:${option.name()}`, process.env[option.envVar]);
} else { // boolean
// keep very simple, only care that envVar defined and not the value
this.emit(`optionEnv:${option.name()}`);
}
}
}
});
}

/**
* Argument `name` is missing.
*
Expand Down
13 changes: 8 additions & 5 deletions lib/help.js
Expand Up @@ -234,21 +234,24 @@ class Help {
*/

optionDescription(option) {
if (option.negate) {
return option.description;
}
const extraInfo = [];
if (option.argChoices) {
// Some of these do not make sense for negated boolean and suppress for backwards compatibility.

if (option.argChoices && !option.negate) {
extraInfo.push(
// use stringify to match the display of the default value
`choices: ${option.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`);
}
if (option.defaultValue !== undefined) {
if (option.defaultValue !== undefined && !option.negate) {
extraInfo.push(`default: ${option.defaultValueDescription || JSON.stringify(option.defaultValue)}`);
}
if (option.envVar !== undefined) {
extraInfo.push(`env: ${option.envVar}`);
}
if (extraInfo.length > 0) {
return `${option.description} (${extraInfo.join(', ')})`;
}

return option.description;
};

Expand Down
14 changes: 14 additions & 0 deletions lib/option.js
Expand Up @@ -28,6 +28,7 @@ class Option {
}
this.defaultValue = undefined;
this.defaultValueDescription = undefined;
this.envVar = undefined;
this.parseArg = undefined;
this.hidden = false;
this.argChoices = undefined;
Expand All @@ -47,6 +48,19 @@ class Option {
return this;
};

/**
* Set environment variable to check for option value.
* Priority order of option values is default < env < cli
*
* @param {string} name
* @return {Option}
*/

env(name) {
this.envVar = name;
return this;
};

/**
* Set the custom handler for processing CLI option arguments into option values.
*
Expand Down
7 changes: 7 additions & 0 deletions tests/help.optionDescription.test.js
Expand Up @@ -24,6 +24,13 @@ describe('optionDescription', () => {
expect(helper.optionDescription(option)).toEqual('description (default: "default")');
});

test('when option has env then return description and env name', () => {
const description = 'description';
const option = new commander.Option('-a', description).env('ENV');
const helper = new commander.Help();
expect(helper.optionDescription(option)).toEqual('description (env: ENV)');
});

test('when option has default value description then return description and custom default description', () => {
const description = 'description';
const defaultValueDescription = 'custom';
Expand Down
6 changes: 6 additions & 0 deletions tests/option.chain.test.js
Expand Up @@ -30,4 +30,10 @@ describe('Option methods that should return this for chaining', () => {
const result = option.choices(['a']);
expect(result).toBe(option);
});

test('when call .env() then returns this', () => {
const option = new Option('-e,--example <value>');
const result = option.env('e');
expect(result).toBe(option);
});
});

0 comments on commit 8571a75

Please sign in to comment.