diff --git a/lib/command.js b/lib/command.js index e4b5d1699..a3c705050 100644 --- a/lib/command.js +++ b/lib/command.js @@ -21,10 +21,21 @@ class Command extends EventEmitter { constructor(name) { super(); + /** @type {boolean} */ + this._implicitlyCreated = false; + /** @type {Command[]} */ this.commands = []; /** @type {Option[]} */ this.options = []; + /** @type {Command[]} */ + this.parents = []; + /** + * Last added parent command, + * or parent command in last parse call. + * + * @type {Command | null} + */ this.parent = null; this._allowUnknownOption = false; this._allowExcessArguments = true; @@ -109,6 +120,19 @@ class Command extends EventEmitter { return this; } + /** + * @returns {Command[]} + * @api private + */ + + _getCommandAndAncestors() { + const result = []; + for (let command = this; command; command = command.parent) { + result.push(command); + } + return result; + } + /** * Define a command. * @@ -145,6 +169,7 @@ class Command extends EventEmitter { const [, name, args] = nameAndArgs.match(/([^ ]+) *(.*)/); const cmd = this.createCommand(name); + cmd._implicitlyCreated = true; if (desc) { cmd.description(desc); cmd._executableHandler = true; @@ -154,6 +179,7 @@ class Command extends EventEmitter { cmd._executableFile = opts.executableFile || null; // Custom name for executable file, set missing to null to match constructor if (args) cmd.arguments(args); this.commands.push(cmd); + cmd.parents.push(this); cmd.parent = this; cmd.copyInheritedSettings(this); @@ -271,6 +297,7 @@ class Command extends EventEmitter { if (opts.noHelp || opts.hidden) cmd._hidden = true; // modifying passed command due to existing implementation this.commands.push(cmd); + cmd.parents.push(this); cmd.parent = this; cmd._checkForBrokenPassThrough(); @@ -746,7 +773,9 @@ Expecting one of '${allowedValues.join("', '")}'`); */ _checkForBrokenPassThrough() { - if (this.parent && this._passThroughOptions && !this.parent._enablePositionalOptions) { + if (this._passThroughOptions && this.parents.some( + (parent) => !parent._enablePositionalOptions + )) { throw new Error(`passThroughOptions cannot be used for '${this._name}' without turning on enablePositionalOptions for parent command(s)`); } } @@ -835,7 +864,7 @@ Expecting one of '${allowedValues.join("', '")}'`); getOptionValueSourceWithGlobals(key) { // global overwrites local, like optsWithGlobals let source; - getCommandAndParents(this).forEach((cmd) => { + this._getCommandAndAncestors().forEach((cmd) => { if (cmd.getOptionValueSource(key) !== undefined) { source = cmd.getOptionValueSource(key); } @@ -897,6 +926,26 @@ 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) { + const methodName = async ? 'parseAsync' : 'parse'; + if (this._implicitlyCreated) { + console.warn(`Called .${methodName}() on subcommand '${this._name}' added with .command() +- meant to call on the top-level command?`); + } + const userArgs = this._prepareUserArgs(argv, parseOptions); + return userArgsCallback(userArgs); + } + /** * Parse `argv`, setting options and invoking commands when defined. * @@ -915,10 +964,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); } /** @@ -941,10 +990,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); } /** @@ -1088,7 +1137,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); } }); return hookResult; @@ -1218,7 +1267,7 @@ Expecting one of '${allowedValues.join("', '")}'`); _chainOrCallHooks(promise, event) { let result = promise; const hooks = []; - getCommandAndParents(this) + this._getCommandAndAncestors() .reverse() .filter(cmd => cmd._lifeCycleHooks[event] !== undefined) .forEach(hookedCommand => { @@ -1266,7 +1315,9 @@ Expecting one of '${allowedValues.join("', '")}'`); * @api private */ - _parseCommand(operands, unknown) { + _parseCommand(operands, unknown, parent) { + this.parent = parent ?? null; + const parsed = this.parseOptions(unknown); this._parseOptionsEnv(); // after cli, so parseArg not called on both cli and env this._parseOptionsImplied(); @@ -1308,18 +1359,18 @@ Expecting one of '${allowedValues.join("', '")}'`); let actionResult; actionResult = this._chainOrCallHooks(actionResult, 'preAction'); actionResult = this._chainOrCall(actionResult, () => this._actionHandler(this.processedArgs)); - if (this.parent) { + if (parent) { actionResult = this._chainOrCall(actionResult, () => { - this.parent.emit(commandEvent, operands, unknown); // legacy + parent.emit(commandEvent, operands, unknown); // legacy }); } actionResult = this._chainOrCallHooks(actionResult, 'postAction'); return actionResult; } - if (this.parent && this.parent.listenerCount(commandEvent)) { + if (parent?.listenerCount(commandEvent)) { checkForUnknownOptions(); this._processArguments(); - this.parent.emit(commandEvent, operands, unknown); // legacy + parent.emit(commandEvent, operands, unknown); // legacy } else if (operands.length) { if (this._findCommand('*')) { // legacy default command return this._dispatchSubcommand('*', operands, unknown); @@ -1375,13 +1426,13 @@ Expecting one of '${allowedValues.join("', '")}'`); _checkForMissingMandatoryOptions() { // Walk up hierarchy so can call in subcommand after checking for displaying help. - for (let cmd = this; cmd; cmd = cmd.parent) { + this._getCommandAndAncestors().forEach((cmd) => { cmd.options.forEach((anOption) => { if (anOption.mandatory && (cmd.getOptionValue(anOption.attributeName()) === undefined)) { cmd.missingMandatoryOptionValue(anOption); } }); - } + }); } /** @@ -1422,9 +1473,9 @@ Expecting one of '${allowedValues.join("', '")}'`); */ _checkForConflictingOptions() { // Walk up hierarchy so can call in subcommand after checking for displaying help. - for (let cmd = this; cmd; cmd = cmd.parent) { + this._getCommandAndAncestors().forEach((cmd) => { cmd._checkForConflictingLocalOptions(); - } + }); } /** @@ -1587,7 +1638,7 @@ Expecting one of '${allowedValues.join("', '")}'`); */ optsWithGlobals() { // globals overwrite locals - return getCommandAndParents(this).reduce( + return this._getCommandAndAncestors().reduce( (combinedOptions, cmd) => Object.assign(combinedOptions, cmd.opts()), {} ); @@ -2032,7 +2083,7 @@ Expecting one of '${allowedValues.join("', '")}'`); } const context = this._getHelpContext(contextOptions); - getCommandAndParents(this).reverse().forEach(command => command.emit('beforeAllHelp', context)); + this._getCommandAndAncestors().reverse().forEach(command => command.emit('beforeAllHelp', context)); this.emit('beforeHelp', context); let helpInformation = this.helpInformation(context); @@ -2046,7 +2097,7 @@ Expecting one of '${allowedValues.join("', '")}'`); this.emit(this._helpLongFlag); // deprecated this.emit('afterHelp', context); - getCommandAndParents(this).forEach(command => command.emit('afterAllHelp', context)); + this._getCommandAndAncestors().forEach(command => command.emit('afterAllHelp', context)); } /** @@ -2189,18 +2240,4 @@ function incrementNodeInspectorPort(args) { }); } -/** - * @param {Command} startCommand - * @returns {Command[]} - * @api private - */ - -function getCommandAndParents(startCommand) { - const result = []; - for (let command = startCommand; command; command = command.parent) { - result.push(command); - } - return result; -} - exports.Command = Command; diff --git a/lib/help.js b/lib/help.js index 14e0fb9f3..560801bd3 100644 --- a/lib/help.js +++ b/lib/help.js @@ -101,8 +101,8 @@ class Help { if (!this.showGlobalOptions) return []; const globalOptions = []; - for (let parentCmd = cmd.parent; parentCmd; parentCmd = parentCmd.parent) { - const visibleOptions = parentCmd.options.filter((option) => !option.hidden); + for (let ancestorCmd = cmd.parent; ancestorCmd; ancestorCmd = ancestorCmd.parent) { + const visibleOptions = ancestorCmd.options.filter((option) => !option.hidden); globalOptions.push(...visibleOptions); } if (this.sortOptions) { @@ -240,11 +240,11 @@ class Help { if (cmd._aliases[0]) { cmdName = cmdName + '|' + cmd._aliases[0]; } - let parentCmdNames = ''; - for (let parentCmd = cmd.parent; parentCmd; parentCmd = parentCmd.parent) { - parentCmdNames = parentCmd.name() + ' ' + parentCmdNames; + let ancestorCmdNames = ''; + for (let ancestorCmd = cmd.parent; ancestorCmd; ancestorCmd = ancestorCmd.parent) { + ancestorCmdNames = ancestorCmd.name() + ' ' + ancestorCmdNames; } - return parentCmdNames + cmdName + ' ' + cmd.usage(); + return ancestorCmdNames + cmdName + ' ' + cmd.usage(); } /** diff --git a/tests/command.addCommand.test.js b/tests/command.addCommand.test.js index f8f86b682..09e94c3c5 100644 --- a/tests/command.addCommand.test.js +++ b/tests/command.addCommand.test.js @@ -30,6 +30,7 @@ test('when commands added using .addCommand and .command then internals similar' expect(cmd2.parent).toBe(program2); for (const key of Object.keys(cmd1)) { + if (key === '_implicitlyCreated') continue; // expected to differ switch (typeof cmd1[key]) { case 'string': case 'boolean': diff --git a/typings/index.d.ts b/typings/index.d.ts index 695c3bd25..8faa66798 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -281,6 +281,11 @@ export class Command { processedArgs: any[]; readonly commands: readonly Command[]; readonly options: readonly Option[]; + parents: Command[]; + /** + * Last added parent command, + * or parent command in last parse call. + */ parent: Command | null; constructor(name?: string); diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts index 734036fad..d3639b530 100644 --- a/typings/index.test-d.ts +++ b/typings/index.test-d.ts @@ -30,6 +30,7 @@ expectType(program.args); expectType(program.processedArgs); expectType(program.commands); expectType(program.options); +expectType(program.parents); expectType(program.parent); // version