Skip to content
110 changes: 100 additions & 10 deletions lib/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
}

Expand Down Expand Up @@ -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);
}
};

Expand All @@ -580,6 +580,12 @@ Expecting one of '${allowedValues.join("', '")}'`);
});
}

// This pattern is to avoid changing the existing behaviour while adding new feature!
this.on('optionConfig:' + oname, (val) => {
const invalidValueMessage = `error: option '${option.flags}' value '${val}' from config is invalid.`;
handleOptionValue(val, invalidValueMessage, 'config');
});

return this;
}

Expand Down Expand Up @@ -793,13 +799,31 @@ Expecting one of '${allowedValues.join("', '")}'`);
};

/**
* @api private
* Set option value, and record source for later use.
*
* @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) {
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.
Expand Down Expand Up @@ -1112,6 +1136,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
* @param {Promise|undefined} promise
* @param {Function} fn
* @return {Promise|undefined}
* @api private
*/

_chainOrCall(promise, fn) {
Expand Down Expand Up @@ -1448,16 +1473,16 @@ Expecting one of '${allowedValues.join("', '")}'`);

/**
* Apply any option related environment variables, if option does
* not have a value from cli or client code.
* not already have a value from a higher priority source (cli).
*
* @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') {
// Priority check
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]);
Expand All @@ -1470,6 +1495,71 @@ Expecting one of '${allowedValues.join("', '")}'`);
});
}

/**
* Merge option values from config, if option does
* not already have a value from a higher priority source (env, cli).
*
* @param {object} config
* @param {string} [configDescription]
* @return {Command} `this` command for chaining
*/
mergeConfig(config, configDescription) {
const configError = (key, explanation) => {
// WIP
const extraDetail = configDescription ? ` in ${configDescription}` : '';
const message = `error: invalid value for config key '${key}'${extraDetail}\n${explanation}`;
this._displayError('commander.configError', 1, message);
};

Object.keys(config).forEach((configKey) => {
const option = this.options.find(option => option.attributeName() === configKey || option.is(configKey));
if (option) {
const optionKey = option.attributeName();
const optionValueSource = this.getOptionValueSource(optionKey) || 'unknown';
// Priority check
if (this.getOptionValue(optionKey) === undefined || ['default', 'config'].includes(optionValueSource)) {
const value = config[configKey];
if (value === false) {
if (configKey.startsWith('-')) {
configError(configKey, 'Config value false can only be used with a property name, not a flag.');
}
let negatedOption = option.negate ? option : null;
if (!option.negate && option.long) {
const negativeLongFlag = option.long.replace(/^--/, '--no-');
negatedOption = this._findOption(negativeLongFlag);
}
if (!negatedOption) {
configError(configKey, 'Config value is false and no matching negated option.');
}
this.emit(`optionConfig:${negatedOption.name()}`);
} else if (value === true) {
if (option.required) {
configError(configKey, 'Expecting string value.');
}
if (option.negate && !configKey.startsWith('-')) {
configError(configKey, 'Use value false with property or true with flag for negated option.');
}
this.emit(`optionConfig:${option.name()}`, option.optional ? null : undefined);
} else if (typeof value === 'string') {
if (!option.required && !option.optional) {
configError(configKey, 'Boolean and negated options require boolean values, not a string.');
}
this.emit(`optionConfig:${option.name()}`, value);
} else if (Array.isArray(value)) {
value.forEach((item) => {
const configItem = {};
configItem[configKey] = item;
this.mergeConfig(configItem);
});
} else {
configError(configKey, `Unsupported config value type '${typeof value}'`);
}
}
}
});
return this;
}

/**
* Argument `name` is missing.
*
Expand Down
6 changes: 6 additions & 0 deletions tests/command.chain.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,10 @@ describe('Command methods that should return this for chaining', () => {
const result = cmd.copyInheritedSettings(program);
expect(result).toBe(cmd);
});

test('when call .mergeConfig() then returns this', () => {
const program = new Command();
const result = program.mergeConfig({});
expect(result).toBe(program);
});
});
Loading