diff --git a/index.js b/index.js index 7134a3865..f15bf23ee 100644 --- a/index.js +++ b/index.js @@ -115,6 +115,7 @@ class Command extends EventEmitter { this._actionHandler = null; this._executableHandler = false; this._executableFile = null; // custom name for executable + this._subCommandBuilder = null; // sub command builder this._defaultCommandName = null; this._exitCallback = null; this._aliases = []; @@ -187,6 +188,7 @@ class Command extends EventEmitter { cmd._passCommandToAction = this._passCommandToAction; cmd._executableFile = opts.executableFile || null; // Custom name for executable file, set missing to null to match constructor + cmd._subCommandBuilder = opts.subCommandBuilder || null; // A builder for sub commands. this.commands.push(cmd); cmd._parseExpectedArgs(args); cmd.parent = this; @@ -228,8 +230,8 @@ class Command extends EventEmitter { // Fail fast and detect when adding rather than later when parsing. function checkExplicitNames(commandArray) { commandArray.forEach((cmd) => { - if (cmd._executableHandler && !cmd._executableFile) { - throw new Error(`Must specify executableFile for deeply nested executable: ${cmd.name()}`); + if (cmd._executableHandler && !cmd._executableFile && !cmd._subCommandBuilder) { + throw new Error(`Must specify executableFile or subCommandBuilder for deeply nested executable: ${cmd.name()}`); } checkExplicitNames(cmd.commands); }); @@ -668,6 +670,11 @@ class Command extends EventEmitter { */ parse(argv, parseOptions) { + this._parseProgram(argv, parseOptions); + return this; + } + + _parseProgram(argv, parseOptions) { if (argv !== undefined && !Array.isArray(argv)) { throw new Error('first parameter to parse must be array or undefined'); } @@ -714,9 +721,7 @@ class Command extends EventEmitter { this._name = this._name || (this._scriptPath && path.basename(this._scriptPath, path.extname(this._scriptPath))); // Let's go! - this._parseCommand([], userArgs); - - return this; + return this._parseCommand([], userArgs); }; /** @@ -736,13 +741,14 @@ class Command extends EventEmitter { * @param {string[]} [argv] * @param {Object} [parseOptions] * @param {string} parseOptions.from - where the args are from: 'node', 'user', 'electron' - * @return {Promise} + * @return {Promise} promise `this` * @api public */ parseAsync(argv, parseOptions) { - this.parse(argv, parseOptions); - return Promise.all(this._actionResults).then(() => this); + return this._parseProgram(argv, parseOptions).then(() => { + return Promise.all(this._actionResults).then(() => this); + }); }; /** @@ -850,9 +856,11 @@ class Command extends EventEmitter { // Store the reference to the child process this.runningCommand = proc; + return Promise.resolve(); }; /** + * @return {Promise} * @api private */ _dispatchSubcommand(commandName, operands, unknown) { @@ -860,15 +868,24 @@ class Command extends EventEmitter { if (!subCommand) this._helpAndError(); if (subCommand._executableHandler) { - this._executeSubCommand(subCommand, operands.concat(unknown)); - } else { - subCommand._parseCommand(operands, unknown); + if (subCommand._subCommandBuilder) { + const handlerPromise = subCommand._subCommandBuilder(this, operands, unknown); + if (handlerPromise instanceof Promise) { + return handlerPromise.then(handler => { + return handler._parseCommand(operands, unknown); + }); + } + return handlerPromise._parseCommand(operands, unknown); + } + return this._executeSubCommand(subCommand, operands.concat(unknown)); } + return subCommand._parseCommand(operands, unknown); }; /** * Process arguments in context of this command. * + * @return {Promise} * @api private */ @@ -876,19 +893,21 @@ class Command extends EventEmitter { const parsed = this.parseOptions(unknown); operands = operands.concat(parsed.operands); unknown = parsed.unknown; + let commandPromise = Promise.resolve(); + this.args = operands.concat(unknown); if (operands && this._findCommand(operands[0])) { - this._dispatchSubcommand(operands[0], operands.slice(1), unknown); + commandPromise = this._dispatchSubcommand(operands[0], operands.slice(1), unknown); } else if (this._lazyHasImplicitHelpCommand() && operands[0] === this._helpCommandName) { if (operands.length === 1) { this.help(); } else { - this._dispatchSubcommand(operands[1], [], [this._helpLongFlag]); + commandPromise = this._dispatchSubcommand(operands[1], [], [this._helpLongFlag]); } } else if (this._defaultCommandName) { outputHelpIfRequested(this, unknown); // Run the help for default command from parent rather than passing to default command - this._dispatchSubcommand(this._defaultCommandName, operands, unknown); + commandPromise = this._dispatchSubcommand(this._defaultCommandName, operands, unknown); } else { if (this.commands.length && this.args.length === 0 && !this._actionHandler && !this._defaultCommandName) { // probably missing subcommand and no handler, user needs help @@ -928,6 +947,7 @@ class Command extends EventEmitter { // fall through for caller to handle after calling .parse() } } + return commandPromise; }; /** diff --git a/tests/command.subCommandPromise.action.test.js b/tests/command.subCommandPromise.action.test.js new file mode 100644 index 000000000..40d23fc8e --- /dev/null +++ b/tests/command.subCommandPromise.action.test.js @@ -0,0 +1,97 @@ +const commander = require('../'); + +// Test some behaviours of .action not covered in more specific tests. + +describe('with subCommandBuilder', () => { + test('when .action called then builder action is executed', () => { + const actionMock = jest.fn(); + const program = new commander.Command(); + program.command('run', 'run description', { + subCommandBuilder: parent => { + return parent + .command('run') + .action(actionMock); + } + }); + program.parse(['node', 'test', 'run']); + expect(actionMock).toHaveBeenCalled(); + }); + + test('when .action called then builder promised action is executed', () => { + const actionMock = jest.fn(); + const program = new commander.Command(); + program.command('run', 'run description', { + subCommandBuilder: parent => { + return Promise.resolve(parent + .command('run') + .action(actionMock)); + } + }); + return program.parseAsync(['node', 'test', 'run']).then(() => { + expect(actionMock).toHaveBeenCalled(); + }); + }); + + test('arguments is resolved and passed to action handler', () => { + const actionSpy = jest.fn(); + return new commander.Command() + .command('run ', 'run description', { + subCommandBuilder: parent => { + return Promise.resolve(parent + .command('run ') + .action((arg) => { + expect(arg).toEqual('foo'); + actionSpy(); + })); + } + }) + .parseAsync(['node', 'test', 'run', 'foo']).then(() => { + expect(actionSpy).toHaveBeenCalled(); + }); + }); + + test('options is resolved and passed to action handler', () => { + const actionSpy = jest.fn(); + return new commander.Command() + .command('run ', 'run description', { + subCommandBuilder: parent => { + return Promise.resolve(parent + .command('run') + .requiredOption('--test-option ', 'test value') + .action(command => { + expect(command.testOption).toEqual('bar'); + actionSpy(); + }) + ); + } + }) + .parseAsync(['node', 'test', 'run', '--test-option', 'bar']).then(() => { + expect(actionSpy).toHaveBeenCalled(); + }); + }); + + test('recursive sub command action handler', () => { + const infoSpy = jest.fn(); + const runSpy = jest.fn(); + const runCommandBuilder = parent => { + return parent.command('run ', 'run description', { + subCommandBuilder: (parent, _operands, _unknown) => { + if (_operands && _operands[0] === 'run') { + runSpy(); + return runCommandBuilder(parent); + } + return Promise.resolve(parent + .command('info') + .action(infoSpy) + ); + } + }); + }; + return runCommandBuilder(new commander.Command()) + .parseAsync(['node', 'test', 'run', 'run', 'run', 'run', 'info']).then(() => { + expect(infoSpy).toHaveBeenCalled(); + expect(runSpy).toHaveBeenCalledTimes(3); + }) + ; + }); +});