diff --git a/Readme.md b/Readme.md index fb35819d5..9a2ab9500 100644 --- a/Readme.md +++ b/Readme.md @@ -98,7 +98,8 @@ const program = new Command(); 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 ('|'). The parsed options can be accessed by calling `.opts()` on a `Command` object, and are passed to the action handler. -You can also use `.getOptionValue()` and `.setOptionValue()` to work with a single option value. +(You can also use `.getOptionValue()` and `.setOptionValue()` to work with a single option value, +and `.getOptionValueSource()` and `.setOptionValueWithSource()` when it matters where the option value came from.) Multi-word options such as "--template-engine" are camel-cased, becoming `program.opts().templateEngine` etc. diff --git a/lib/command.js b/lib/command.js index 184bc0b79..f98de7229 100644 --- a/lib/command.js +++ b/lib/command.js @@ -36,7 +36,7 @@ class Command extends EventEmitter { this._scriptPath = null; this._name = name || ''; this._optionValues = {}; - this._optionValueSources = {}; // default < env < cli + this._optionValueSources = {}; // default < config < env < cli this._storeOptionsAsProperties = false; this._actionHandler = null; this._executableHandler = false; @@ -527,7 +527,7 @@ Expecting one of '${allowedValues.join("', '")}'`); } // preassign only if we have a default if (defaultValue !== undefined) { - this._setOptionValueWithSource(name, defaultValue, 'default'); + this.setOptionValueWithSource(name, defaultValue, 'default'); } } @@ -558,13 +558,13 @@ 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._setOptionValueWithSource(name, option.negate ? false : defaultValue || true, valueSource); + this.setOptionValueWithSource(name, option.negate ? false : defaultValue || true, valueSource); } else { - this._setOptionValueWithSource(name, val, valueSource); + this.setOptionValueWithSource(name, val, valueSource); } } else if (val !== null) { // reassign - this._setOptionValueWithSource(name, option.negate ? false : val, valueSource); + this.setOptionValueWithSource(name, option.negate ? false : val, valueSource); } }; @@ -793,13 +793,32 @@ Expecting one of '${allowedValues.join("', '")}'`); }; /** - * @api private - */ - _setOptionValueWithSource(key, value, source) { + * Store option value and where the value came from. + * + * @param {string} key + * @param {Object} value + * @param {string} source - expected values are default/config/env/cli + * @return {Command} `this` command for chaining + */ + + setOptionValueWithSource(key, value, source) { this.setOptionValue(key, value); this._optionValueSources[key] = source; + return this; } + /** + * Get source of option value. + * Expected values are default | config | env | cli + * + * @param {string} key + * @return {string} + */ + + getOptionValueSource(key) { + return this._optionValueSources[key]; + }; + /** * Get user arguments implied or explicit arguments. * Side-effects: set _scriptPath if args included application, and use that to set implicit command name. @@ -1112,6 +1131,7 @@ Expecting one of '${allowedValues.join("', '")}'`); * @param {Promise|undefined} promise * @param {Function} fn * @return {Promise|undefined} + * @api private */ _chainOrCall(promise, fn) { @@ -1456,8 +1476,8 @@ Expecting one of '${allowedValues.join("', '")}'`); 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') { + // Priority check. Do not overwrite cli or options from unknown source (client-code). + if (this.getOptionValue(optionKey) === undefined || ['default', 'config', 'env'].includes(this.getOptionValueSource(optionKey))) { 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]); diff --git a/tests/command.chain.test.js b/tests/command.chain.test.js index 09fc6e5a3..1ecc170f5 100644 --- a/tests/command.chain.test.js +++ b/tests/command.chain.test.js @@ -174,7 +174,13 @@ describe('Command methods that should return this for chaining', () => { test('when call .setOptionValue() then returns this', () => { const program = new Command(); - const result = program.setOptionValue(); + const result = program.setOptionValue('foo', 'bar'); + expect(result).toBe(program); + }); + + test('when call .setOptionValueWithSource() then returns this', () => { + const program = new Command(); + const result = program.setOptionValueWithSource('foo', 'bar', 'cli'); expect(result).toBe(program); }); diff --git a/tests/options.env.test.js b/tests/options.env.test.js index 5dc53cd37..593da1027 100644 --- a/tests/options.env.test.js +++ b/tests/options.env.test.js @@ -27,6 +27,26 @@ describe.each(['-f, --foo ', '-f, --foo [optional-arg]'])('option delete process.env.BAR; }); + test('when env defined and value source is config then option from env', () => { + const program = new commander.Command(); + process.env.BAR = 'env'; + program.addOption(new commander.Option(fooFlags).env('BAR')); + program.setOptionValueWithSource('foo', 'config', 'config'); + program.parse([], { from: 'user' }); + expect(program.opts().foo).toBe('env'); + delete process.env.BAR; + }); + + test('when env defined and value source is unspecified then option unchanged', () => { + const program = new commander.Command(); + process.env.BAR = 'env'; + program.addOption(new commander.Option(fooFlags).env('BAR')); + program.setOptionValue('foo', 'client'); + program.parse([], { from: 'user' }); + expect(program.opts().foo).toBe('client'); + delete process.env.BAR; + }); + test('when default and env undefined and no cli then option from default', () => { const program = new commander.Command(); program.addOption(new commander.Option(fooFlags).env('BAR').default('default')); diff --git a/tests/options.getset.test.js b/tests/options.getset.test.js index adb15e639..23927f31b 100644 --- a/tests/options.getset.test.js +++ b/tests/options.getset.test.js @@ -22,3 +22,38 @@ describe.each([true, false])('storeOptionsAsProperties is %s', (storeOptionsAsPr expect(program.opts().cheese).toBe(cheeseType); }); }); + +test('when setOptionValueWithSource then value returned by opts', () => { + const program = new commander.Command(); + const cheeseValue = 'blue'; + program + .option('--cheese [type]', 'cheese type') + .setOptionValue('cheese', cheeseValue); + expect(program.opts().cheese).toBe(cheeseValue); +}); + +test('when setOptionValueWithSource then source returned by getOptionValueSource', () => { + const program = new commander.Command(); + program + .option('--cheese [type]', 'cheese type') + .setOptionValueWithSource('cheese', 'blue', 'config'); + expect(program.getOptionValueSource('cheese')).toBe('config'); +}); + +test('when option value parsed from env then option source is env', () => { + const program = new commander.Command(); + process.env.BAR = 'env'; + program + .addOption(new commander.Option('-f, --foo').env('BAR')); + program.parse([], { from: 'user' }); + expect(program.getOptionValueSource('foo')).toBe('env'); + delete process.env.BAR; +}); + +test('when option value parsed from cli then option source is cli', () => { + const program = new commander.Command(); + program + .addOption(new commander.Option('-f, --foo').env('BAR')); + program.parse(['--foo'], { from: 'user' }); + expect(program.getOptionValueSource('foo')).toBe('cli'); +}); diff --git a/typings/index.d.ts b/typings/index.d.ts index cba74f681..7f02940ac 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -214,8 +214,9 @@ export interface OutputConfiguration { } -type AddHelpTextPosition = 'beforeAll' | 'before' | 'after' | 'afterAll'; -type HookEvent = 'preAction' | 'postAction'; +export type AddHelpTextPosition = 'beforeAll' | 'before' | 'after' | 'afterAll'; +export type HookEvent = 'preAction' | 'postAction'; +export type OptionValueSource = 'default' | 'env' | 'config' | 'cli'; export interface OptionValues { [key: string]: any; @@ -526,12 +527,22 @@ export class Command { */ getOptionValue(key: string): any; - /** + /** * Store option value. */ setOptionValue(key: string, value: unknown): this; /** + * Store option value and where the value came from. + */ + setOptionValueWithSource(key: string, value: unknown, source: OptionValueSource): this; + + /** + * Retrieve option value source. + */ + getOptionValueSource(key: string): OptionValueSource; + + /** * Alter parsing of short flags with optional values. * * @example diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts index 7ef3417ea..d83c198c1 100644 --- a/typings/index.test-d.ts +++ b/typings/index.test-d.ts @@ -155,6 +155,12 @@ void program.getOptionValue('example'); expectType(program.setOptionValue('example', 'value')); expectType(program.setOptionValue('example', true)); +// setOptionValueWithSource +expectType(program.setOptionValueWithSource('example', [], 'cli')); + +// getOptionValueSource +expectType(program.getOptionValueSource('example')); + // combineFlagAndOptionalValue expectType(program.combineFlagAndOptionalValue()); expectType(program.combineFlagAndOptionalValue(false));