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

Add .env method to Option for consulting environment variable for option value #1587

Merged
merged 19 commits into from
Aug 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions Readme.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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);
});
});
Loading