diff --git a/lib/command.js b/lib/command.js index 590a271dd..f34ccd5c9 100644 --- a/lib/command.js +++ b/lib/command.js @@ -12,7 +12,71 @@ const { suggestSimilar } = require('./suggestSimilar'); // @ts-check -class Command extends EventEmitter { +class CommandBase extends EventEmitter { + constructor() { + super(); + + // The proxy only treats keys not present in the instance and its prototype chain as keys for _optionValues when _storeOptionsAsProperties is set to true. + // Setting option values for keys present in the instance and its prototype chain is still possible by calling .setOptionValue() or .setOptionValueWithSource(), + // but such values will not be accessible as instance properties because the instance and its prototype chain have precedence. + // However, they will be accessible via .getOptionValue(), .opts() and .optsWithGlobals(). + return new Proxy(this, { + get(target, key, receiver) { + if (target._storeOptionsAsProperties && !(key in target)) { + target = receiver = receiver._optionValues; + } + return Reflect.get(target, key, receiver); + }, + set(target, key, value, receiver) { + if (target._storeOptionsAsProperties && !(key in target)) { + target = receiver = receiver._optionValues; + } + return Reflect.set(target, key, value, receiver); + }, + has(target, key) { + if (target._storeOptionsAsProperties && !(key in target)) { + target = target._optionValues; + } + return Reflect.has(target, key); + }, + deleteProperty(target, key) { + if (target._storeOptionsAsProperties && !(key in target)) { + target = target._optionValues; + } + return Reflect.deleteProperty(target, key); + }, + defineProperty(target, key, descriptor) { + if (target._storeOptionsAsProperties && !(key in target)) { + target = target._optionValues; + } + return Reflect.defineProperty(target, key, descriptor); + }, + getOwnPropertyDescriptor(target, key) { + if (target._storeOptionsAsProperties && !(key in target)) { + target = target._optionValues; + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + ownKeys(target) { + const result = Reflect.ownKeys(target); + if (target._storeOptionsAsProperties) { + result.push(...Reflect.ownKeys(target._optionValues).filter( + key => !(result.includes(key)) // remove duplicates + )); + } + return result; + }, + preventExtensions(target) { + if (target._storeOptionsAsProperties) { + Reflect.preventExtensions(target._optionValues); + } + return Reflect.preventExtensions(target); + } + }); + } +} + +class Command extends CommandBase { /** * Initialize a new `Command`. * @@ -30,14 +94,6 @@ class Command extends EventEmitter { this._allowExcessArguments = true; /** @type {Argument[]} */ this._args = []; - /** @type {string[]} */ - this.args = []; // cli args with options removed - this.rawArgs = []; - this.processedArgs = []; // like .args but after custom processing and collecting variadic - this._scriptPath = null; - this._name = name || ''; - this._optionValues = {}; - this._optionValueSources = {}; // default, env, cli etc this._storeOptionsAsProperties = false; this._actionHandler = null; this._executableHandler = false; @@ -77,6 +133,34 @@ class Command extends EventEmitter { this._helpCommandnameAndArgs = 'help [command]'; this._helpCommandDescription = 'display help for command'; this._helpConfiguration = {}; + + // Because of how the proxy returned from the CommandBase constructor works in order to support options-as-properties, + // all instance properties have to be defined when _storeOptionsAsProperties is set to false. + // Ideally, that should happen as soon as in the constructor, even if it seems unnecessary because the initial values are undefined like here. + this._version = undefined; + this._versionOptionName = undefined; + + this._unprocessedName = name || ''; + this._persistentOptionValues = {}; + this._persistentOptionValueSources = {}; + this.resetParseState(); + + /** @type {boolean | undefined} */ + this._asyncParsing = undefined; + } + + resetParseState() { + /** @type {string[]} */ + this.args = []; // cli args with options removed + this.rawArgs = []; + this.processedArgs = []; // like .args but after custom processing and collecting variadic + this._scriptPath = null; + + this._name = this._unprocessedName; + this._optionValues = Object.assign({}, this._persistentOptionValues); + this._optionValueSources = Object.assign( + {}, this._persistentOptionValueSources + ); // default, env, cli etc } /** @@ -515,10 +599,10 @@ Expecting one of '${allowedValues.join("', '")}'`); // --no-foo is special and defaults foo to true, unless a --foo option is already defined const positiveLongFlag = option.long.replace(/^--no-/, '--'); if (!this._findOption(positiveLongFlag)) { - this.setOptionValueWithSource(name, option.defaultValue === undefined ? true : option.defaultValue, 'default'); + this._setPersistentOptionValueWithSource(name, option.defaultValue === undefined ? true : option.defaultValue, 'default'); } } else if (option.defaultValue !== undefined) { - this.setOptionValueWithSource(name, option.defaultValue, 'default'); + this._setPersistentOptionValueWithSource(name, option.defaultValue, 'default'); } // register the option @@ -558,7 +642,7 @@ Expecting one of '${allowedValues.join("', '")}'`); val = ''; // not normal, parseArg might have failed or be a mock function for testing } } - this.setOptionValueWithSource(name, val, valueSource); + this._setNonPersistentOptionValueWithSource(name, val, valueSource); }; this.on('option:' + oname, (val) => { @@ -765,9 +849,6 @@ Expecting one of '${allowedValues.join("', '")}'`); */ getOptionValue(key) { - if (this._storeOptionsAsProperties) { - return this[key]; - } return this._optionValues[key]; } @@ -780,7 +861,7 @@ Expecting one of '${allowedValues.join("', '")}'`); */ setOptionValue(key, value) { - return this.setOptionValueWithSource(key, value, undefined); + return this.setOptionValueWithSource(key, value); } /** @@ -788,26 +869,49 @@ Expecting one of '${allowedValues.join("', '")}'`); * * @param {string} key * @param {Object} value - * @param {string} source - expected values are default/config/env/cli/implied + * @param {string} [source] * @return {Command} `this` command for chaining */ setOptionValueWithSource(key, value, source) { - if (this._storeOptionsAsProperties) { - this[key] = value; - } else { - this._optionValues[key] = value; - } - this._optionValueSources[key] = source; + const set = this._asyncParsing === undefined + ? this._setPersistentOptionValueWithSource + : this._setNonPersistentOptionValueWithSource; + set.call(this, key, value, source); return this; } + /** + * @param {string} key + * @param {Object} value + * @param {string} [source] + * @api private + */ + + _setPersistentOptionValueWithSource(key, value, source) { + this._setNonPersistentOptionValueWithSource(key, value, source); + this._persistentOptionValues[key] = value; + this._persistentOptionValueSources[key] = source; + } + + /** + * @param {string} key + * @param {Object} value + * @param {string} [source] + * @api private + */ + + _setNonPersistentOptionValueWithSource(key, value, source) { + this._optionValues[key] = value; + this._optionValueSources[key] = source; + } + /** * Get source of option value. * Expected values are default | config | env | cli | implied * * @param {string} key - * @return {string} + * @return {string | undefined} */ getOptionValueSource(key) { @@ -819,7 +923,7 @@ Expecting one of '${allowedValues.join("', '")}'`); * Expected values are default | config | env | cli | implied * * @param {string} key - * @return {string} + * @return {string | undefined} */ getOptionValueSourceWithGlobals(key) { @@ -887,6 +991,22 @@ Expecting one of '${allowedValues.join("', '")}'`); return userArgs; } + /** + * @param {boolean} async + * @param {Function} userArgsCallback + * @param {string[]} [argv] + * @param {Object} [parseOptions] + * @param {string} [parseOptions.from] + * @return {Command|Promise} + * @api private + */ + + _parseSubroutine(async, userArgsCallback, argv, parseOptions) { + this.resetParseState(); + const userArgs = this._prepareUserArgs(argv, parseOptions); + return userArgsCallback(userArgs); + } + /** * Parse `argv`, setting options and invoking commands when defined. * @@ -905,10 +1025,10 @@ Expecting one of '${allowedValues.join("', '")}'`); */ parse(argv, parseOptions) { - const userArgs = this._prepareUserArgs(argv, parseOptions); - this._parseCommand([], userArgs); - - return this; + return this._parseSubroutine(false, (userArgs) => { + this._parseCommand([], userArgs); + return this; + }, argv, parseOptions); } /** @@ -931,10 +1051,10 @@ Expecting one of '${allowedValues.join("', '")}'`); */ async parseAsync(argv, parseOptions) { - const userArgs = this._prepareUserArgs(argv, parseOptions); - await this._parseCommand([], userArgs); - - return this; + return this._parseSubroutine(true, async(userArgs) => { + await this._parseCommand([], userArgs); + return this; + }, argv, parseOptions); } /** @@ -1071,6 +1191,7 @@ Expecting one of '${allowedValues.join("', '")}'`); _dispatchSubcommand(commandName, operands, unknown) { const subCommand = this._findCommand(commandName); if (!subCommand) this.help({ error: true }); + subCommand.resetParseState(); let hookResult; hookResult = this._chainOrCallSubCommandHook(hookResult, subCommand, 'preSubcommand'); @@ -1078,7 +1199,7 @@ Expecting one of '${allowedValues.join("', '")}'`); if (subCommand._executableHandler) { this._executeSubCommand(subCommand, operands.concat(unknown)); } else { - return subCommand._parseCommand(operands, unknown); + return subCommand._parseCommand(operands, unknown, this._asyncParsing); } }); return hookResult; @@ -1256,81 +1377,87 @@ Expecting one of '${allowedValues.join("', '")}'`); * @api private */ - _parseCommand(operands, unknown) { - const parsed = this.parseOptions(unknown); - this._parseOptionsEnv(); // after cli, so parseArg not called on both cli and env - this._parseOptionsImplied(); - operands = operands.concat(parsed.operands); - unknown = parsed.unknown; - this.args = operands.concat(unknown); + _parseCommand(operands, unknown, async) { + this._asyncParsing = async; - if (operands && this._findCommand(operands[0])) { - return this._dispatchSubcommand(operands[0], operands.slice(1), unknown); - } - if (this._hasImplicitHelpCommand() && operands[0] === this._helpCommandName) { - return this._dispatchHelpCommand(operands[1]); - } - if (this._defaultCommandName) { - outputHelpIfRequested(this, unknown); // Run the help for default command from parent rather than passing to default command - return this._dispatchSubcommand(this._defaultCommandName, operands, unknown); - } - if (this.commands.length && this.args.length === 0 && !this._actionHandler && !this._defaultCommandName) { - // probably missing subcommand and no handler, user needs help (and exit) - this.help({ error: true }); - } + try { + const parsed = this.parseOptions(unknown); + this._parseOptionsEnv(); // after cli, so parseArg not called on both cli and env + this._parseOptionsImplied(); + operands = operands.concat(parsed.operands); + unknown = parsed.unknown; + this.args = operands.concat(unknown); - outputHelpIfRequested(this, parsed.unknown); - this._checkForMissingMandatoryOptions(); - this._checkForConflictingOptions(); - - // We do not always call this check to avoid masking a "better" error, like unknown command. - const checkForUnknownOptions = () => { - if (parsed.unknown.length > 0) { - this.unknownOption(parsed.unknown[0]); + if (operands && this._findCommand(operands[0])) { + return this._dispatchSubcommand(operands[0], operands.slice(1), unknown); } - }; - - const commandEvent = `command:${this.name()}`; - if (this._actionHandler) { - checkForUnknownOptions(); - this._processArguments(); - - let actionResult; - actionResult = this._chainOrCallHooks(actionResult, 'preAction'); - actionResult = this._chainOrCall(actionResult, () => this._actionHandler(this.processedArgs)); - if (this.parent) { - actionResult = this._chainOrCall(actionResult, () => { - this.parent.emit(commandEvent, operands, unknown); // legacy - }); + if (this._hasImplicitHelpCommand() && operands[0] === this._helpCommandName) { + return this._dispatchHelpCommand(operands[1]); } - actionResult = this._chainOrCallHooks(actionResult, 'postAction'); - return actionResult; - } - if (this.parent && this.parent.listenerCount(commandEvent)) { - checkForUnknownOptions(); - this._processArguments(); - this.parent.emit(commandEvent, operands, unknown); // legacy - } else if (operands.length) { - if (this._findCommand('*')) { // legacy default command - return this._dispatchSubcommand('*', operands, unknown); + if (this._defaultCommandName) { + outputHelpIfRequested(this, unknown); // Run the help for default command from parent rather than passing to default command + return this._dispatchSubcommand(this._defaultCommandName, operands, unknown); + } + if (this.commands.length && this.args.length === 0 && !this._actionHandler && !this._defaultCommandName) { + // probably missing subcommand and no handler, user needs help (and exit) + this.help({ error: true }); + } + + outputHelpIfRequested(this, parsed.unknown); + this._checkForMissingMandatoryOptions(); + this._checkForConflictingOptions(); + + // We do not always call this check to avoid masking a "better" error, like unknown command. + const checkForUnknownOptions = () => { + if (parsed.unknown.length > 0) { + this.unknownOption(parsed.unknown[0]); + } + }; + + const commandEvent = `command:${this.name()}`; + if (this._actionHandler) { + checkForUnknownOptions(); + this._processArguments(); + + let actionResult; + actionResult = this._chainOrCallHooks(actionResult, 'preAction'); + actionResult = this._chainOrCall(actionResult, () => this._actionHandler(this.processedArgs)); + if (this.parent) { + actionResult = this._chainOrCall(actionResult, () => { + this.parent.emit(commandEvent, operands, unknown); // legacy + }); + } + actionResult = this._chainOrCallHooks(actionResult, 'postAction'); + return actionResult; } - if (this.listenerCount('command:*')) { - // skip option check, emit event for possible misspelling suggestion - this.emit('command:*', operands, unknown); + if (this.parent && this.parent.listenerCount(commandEvent)) { + checkForUnknownOptions(); + this._processArguments(); + this.parent.emit(commandEvent, operands, unknown); // legacy + } else if (operands.length) { + if (this._findCommand('*')) { // legacy default command + return this._dispatchSubcommand('*', operands, unknown); + } + if (this.listenerCount('command:*')) { + // skip option check, emit event for possible misspelling suggestion + this.emit('command:*', operands, unknown); + } else if (this.commands.length) { + this.unknownCommand(); + } else { + checkForUnknownOptions(); + this._processArguments(); + } } else if (this.commands.length) { - this.unknownCommand(); + checkForUnknownOptions(); + // This command has subcommands and nothing hooked up at this level, so display help (and exit). + this.help({ error: true }); } else { checkForUnknownOptions(); this._processArguments(); + // fall through for caller to handle after calling .parse() } - } else if (this.commands.length) { - checkForUnknownOptions(); - // This command has subcommands and nothing hooked up at this level, so display help (and exit). - this.help({ error: true }); - } else { - checkForUnknownOptions(); - this._processArguments(); - // fall through for caller to handle after calling .parse() + } finally { + this._asyncParsing = undefined; } } @@ -1562,7 +1689,9 @@ Expecting one of '${allowedValues.join("', '")}'`); for (let i = 0; i < len; i++) { const key = this.options[i].attributeName(); - result[key] = key === this._versionOptionName ? this._version : this[key]; + result[key] = key === this._versionOptionName + ? this._version + : this._optionValues[key]; } return result; } @@ -1650,7 +1779,9 @@ Expecting one of '${allowedValues.join("', '")}'`); Object.keys(option.implied) .filter(impliedKey => !hasCustomOptionValue(impliedKey)) .forEach(impliedKey => { - this.setOptionValueWithSource(impliedKey, option.implied[impliedKey], 'implied'); + this._setNonPersistentOptionValueWithSource( + impliedKey, option.implied[impliedKey], 'implied' + ); }); }); } @@ -1932,7 +2063,7 @@ Expecting one of '${allowedValues.join("', '")}'`); name(str) { if (str === undefined) return this._name; - this._name = str; + this._name = this._unprocessedName = str; return this; } diff --git a/typings/index.d.ts b/typings/index.d.ts index 695c3bd25..6aa1dff1b 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -272,7 +272,6 @@ export interface OutputConfiguration { export type AddHelpTextPosition = 'beforeAll' | 'before' | 'after' | 'afterAll'; export type HookEvent = 'preSubcommand' | 'preAction' | 'postAction'; -export type OptionValueSource = 'default' | 'config' | 'env' | 'cli' | 'implied'; export type OptionValues = Record; @@ -595,17 +594,17 @@ export class Command { /** * Store option value and where the value came from. */ - setOptionValueWithSource(key: string, value: unknown, source: OptionValueSource): this; + setOptionValueWithSource(key: string, value: unknown, source?: string | undefined): this; /** * Get source of option value. */ - getOptionValueSource(key: string): OptionValueSource | undefined; + getOptionValueSource(key: string): string | undefined; /** * Get source of option value. See also .optsWithGlobals(). */ - getOptionValueSourceWithGlobals(key: string): OptionValueSource | undefined; + getOptionValueSourceWithGlobals(key: string): string | undefined; /** * Alter parsing of short flags with optional values. diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts index 734036fad..035418b4a 100644 --- a/typings/index.test-d.ts +++ b/typings/index.test-d.ts @@ -170,13 +170,13 @@ expectType(program.setOptionValue('example', 'value')); expectType(program.setOptionValue('example', true)); // setOptionValueWithSource -expectType(program.setOptionValueWithSource('example', [], 'cli')); +expectType(program.setOptionValueWithSource('example', [], 'config')); // getOptionValueSource -expectType(program.getOptionValueSource('example')); +expectType(program.getOptionValueSource('example')); // getOptionValueSourceWithGlobals -expectType(program.getOptionValueSourceWithGlobals('example')); +expectType(program.getOptionValueSourceWithGlobals('example')); // combineFlagAndOptionalValue expectType(program.combineFlagAndOptionalValue());