diff --git a/Readme.md b/Readme.md index b23d01f2e..386802f48 100644 --- a/Readme.md +++ b/Readme.md @@ -404,6 +404,56 @@ Specifying a name with `executableFile` will override the default constructed na If the program is designed to be installed globally, make sure the executables have proper modes, like `755`. +### Sub commands (handlers) + +This approach does not spawn a new process in comparison to *'Git-style executable (sub)commands'* above. Instead it forwards arguments to a subcommand instance which can be initiated with own actions, options (and further subcommands). See also `use-subcommand.js` in `examples` dir. + +```js +const { Command } = require('commander'); + +// supposedly sub commands would be defined in a separate module +const subCommand = new Command(); + +subCommand + // Name is mandatory ! it will be the expected arg to trigger the sub command + .name('journal') + .description('Journal utils'); + +subCommand + .command('list ') + .action(listActionHandler); + +subCommand + .command('delete ') + .option('-f, --force') + .action(deleteActionHandler); + +// ... and this is supposedly in the main program ... +const program = new Command(); +program + .option('-q, --quiet'); + .useSubcommand(subCommand); // forward args, starting with "journal" (subCommand.name()) to this instance + +``` + +Invocation: +``` +$ node myapp journal list myjournal1 +$ node myapp -q journal delete myjournal1 -f +``` + +Be aware of option handling. In the example above `--force` option directly belongs to the command object passed to action handler (in last param). However, `--quiet` belongs to it's parent! Along with the explicit access you can use `collectAllOptions` - it collects option values from all levels and returns as an object. + +```js +// invoked with "journal --quiet delete xxx --force" +function deleteActionHandler(path, cmdInstance) { + console.log(cmdInstance.force); // true + console.log(cmdInstance.quiet); // undefined ! + console.log(cmdInstance.parent.quiet); // true + console.log(cmdInstance.collectAllOptions()); // { quiet: true, force: true } +} +``` + ## Automated --help The help information is auto-generated based on the information commander already knows about your program, so the following `--help` info is for free: diff --git a/examples/use-subcommand.js b/examples/use-subcommand.js new file mode 100755 index 000000000..a26e5b31e --- /dev/null +++ b/examples/use-subcommand.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +// This is an example of useSubcommand +// and collectAllOptions +// +// try +// $ use-subcommand journal list myjounal +// $ use-subcommand journal delete myjounal +// or with options +// $ use-subcommand journal -q delete -f myjounal + +// const { Command } = require('commander'); << would be in a real program +const { Command } = require('..'); + +function importSubCommand() { + const journalCmd = new Command() + .name('journal') + .description('Journal utils'); + + journalCmd + .command('list ') + .description('List journal') + .action((path, cmdInstance) => { + console.log('List journal'); + console.log('Path is', path); + console.log('Quiet =', Boolean(cmdInstance.parent.parent.quiet)); + // list is a child of journal, which is a child of main cmd + console.log('collectAllOptions:', cmdInstance.collectAllOptions()); + }); + + journalCmd + .command('delete ') + .description('Delete journal') + .option('-f, --force') + .action((path, cmdInstance) => { + console.log('List journal'); + console.log('Path is', path); + console.log('Quiet =', Boolean(cmdInstance.parent.parent.quiet)); + console.log('Force =', Boolean(cmdInstance.force)); + console.log('collectAllOptions:', cmdInstance.collectAllOptions()); + }); + + return journalCmd; +} + +// this is supposedly a module, so in real case this would be `require` +const journalSubCommand = importSubCommand(); + +const program = new Command(); +program + .option('-q, --quiet'); + +program + .command('hello ') + .description('Greeting') + .action((name, cmdInstance) => { + console.log(`Hello ${name}!`); + }); + +program + .useSubcommand(journalSubCommand); + +if (process.argv.length <= 2) { + program.help(); +} else { + program.parse(process.argv); +} diff --git a/index.js b/index.js index ce38da26b..4abf5bc90 100644 --- a/index.js +++ b/index.js @@ -1384,6 +1384,69 @@ Command.prototype.help = function(cb) { this._exit(process.exitCode || 0, 'commander.help', '(outputHelp)'); }; +/** + * Add action-like sub command + * command name is taken from name() property - must be defined + * + * @returns {Command} `this` instance + */ + +Command.prototype.useSubcommand = function(subCommand) { + if (this._args.length > 0) throw Error('useSubcommand cannot be applied to a command with explicit args'); + if (!subCommand._name) throw Error('subCommand name is not specified'); + + var listener = function(args, unknown) { + // Parse any so-far unknown options + args = args || []; + unknown = unknown || []; + + var parsed = subCommand.parseOptions(unknown); + if (parsed.args.length) args = parsed.args.concat(args); + unknown = parsed.unknown; + + // Output help if necessary + + const helpRequested = unknown.includes(subCommand._helpLongFlag) || unknown.includes(subCommand._helpShortFlag); + const noFutherValidCommands = args.length === 0 || !subCommand.listeners('command:' + args[0]); + const noFurtherCommandsButExpected = args.length === 0 && unknown.length === 0 && subCommand.commands.length > 0; + if ((helpRequested && noFutherValidCommands) || noFurtherCommandsButExpected) { + subCommand.outputHelp(); + subCommand._exit(0, 'commander.useSubcommand.listener', `outputHelp(${subCommand._name})`); + } + + subCommand.parseArgs(args, unknown); + }; + + for (const label of [subCommand._name, subCommand._alias]) { + if (label) this.on('command:' + label, listener); + } + this.commands.push(subCommand); + subCommand.parent = this; + return this; +}; + +/** + * Returns an object with all options values, including parent options values + * This makes it especially useful with useSubcommand as it collects + * options from the whole command chain, including parent levels. + * beware that subcommand opts enjoy the priority over the parent ones + * + * @returns {Object} dictionary of option values + */ + +Command.prototype.collectAllOptions = function() { + var allOpts = {}; + var node = this; + while (node) { + allOpts = node.options + .map(o => o.attributeName()) + .filter(o => typeof node[o] !== 'function') + .reduce((r, o) => ({ [o]: node[o], ...r }), allOpts); // deeper opts enjoy the priority + node = node.parent; + } + return allOpts; +}; + /** * Camel-case the given `flag` * diff --git a/tests/command.collectAllOptions.test.js b/tests/command.collectAllOptions.test.js new file mode 100644 index 000000000..74352e8ad --- /dev/null +++ b/tests/command.collectAllOptions.test.js @@ -0,0 +1,59 @@ +var { Command } = require('../'); + +function createCommanderInstance(mockFn) { + const subCmd = new Command() + .name('sub_cmd') + .option('-f, --force'); + subCmd + .command('sub_sub_cmd') + .option('-d, --delete') + .action(mockFn); + + const root = new Command(); + root + .option('-q, --quiet'); + root + .useSubcommand(subCmd); + + return root; +} + +// TESTS + +test('should collect options from all 3 levels when all passed', () => { + const actionMock = jest.fn(); + const program = createCommanderInstance(actionMock); + + program.parse(['node', 'test', 'sub_cmd', 'sub_sub_cmd', '-f', '-q', '-d']); + + expect(actionMock).toHaveBeenCalledTimes(1); + expect(actionMock.mock.calls[0].length).toBe(1); + expect(actionMock.mock.calls[0][0] instanceof Command).toBeTruthy(); + expect(actionMock.mock.calls[0][0].name()).toBe('sub_sub_cmd'); + expect(actionMock.mock.calls[0][0].collectAllOptions()).toEqual({ + quiet: true, + force: true, + delete: true + }); +}); + +test('should collect options from all 3 levels when just some passed', () => { + const actionMock = jest.fn(); + const program = createCommanderInstance(actionMock); + + program.parse(['node', 'test', 'sub_cmd', 'sub_sub_cmd', '-q']); + + expect(actionMock).toHaveBeenCalledTimes(1); + expect(actionMock.mock.calls[0].length).toBe(1); + expect(actionMock.mock.calls[0][0] instanceof Command).toBeTruthy(); + expect(actionMock.mock.calls[0][0].name()).toBe('sub_sub_cmd'); + + const allOpts = actionMock.mock.calls[0][0].collectAllOptions(); + expect(allOpts).toEqual({ + quiet: true, + force: undefined, + delete: undefined + }); + // The attrs are enumerable, just undefined ! + expect(Object.keys(allOpts).sort()).toEqual(['delete', 'force', 'quiet']); +}); diff --git a/tests/command.useSubcommand.test.js b/tests/command.useSubcommand.test.js new file mode 100644 index 000000000..ab560f659 --- /dev/null +++ b/tests/command.useSubcommand.test.js @@ -0,0 +1,107 @@ +var { Command } = require('../'); + +function createCommanderInstance(mockFn) { + const cmd2 = new Command() + .name('cmd2'); + cmd2 + .command('subCmd') + .action(mockFn); + + const cmd3 = new Command() + .name('cmd3') + .option('-q, --quiet'); + cmd3 + .command('subWithOpt') + .option('-f, --force') + .action(mockFn); + cmd3 + .command('subWithParam ') + .action(mockFn); + + const root = new Command(); + root + .command('cmd1') + .action(mockFn); + root + .useSubcommand(cmd2) + .useSubcommand(cmd3); + + return root; +} + +// TESTS + +test('should envoke 1 level command', () => { + const actionMock = jest.fn(); + const program = createCommanderInstance(actionMock); + + program.parse(['node', 'test', 'cmd1']); + + expect(actionMock).toHaveBeenCalledTimes(1); + expect(actionMock.mock.calls[0].length).toBe(1); + expect(actionMock.mock.calls[0][0] instanceof Command).toBeTruthy(); + expect(actionMock.mock.calls[0][0].name()).toBe('cmd1'); +}); + +test('should envoke 2 level sub command', () => { + const actionMock = jest.fn(); + const program = createCommanderInstance(actionMock); + + program.parse(['node', 'test', 'cmd2', 'subCmd']); + + expect(actionMock).toHaveBeenCalledTimes(1); + expect(actionMock.mock.calls[0].length).toBe(1); + expect(actionMock.mock.calls[0][0] instanceof Command).toBeTruthy(); + expect(actionMock.mock.calls[0][0].name()).toBe('subCmd'); +}); + +test('should envoke 2 level sub command with subcommand options', () => { + const actionMock = jest.fn(); + const program = createCommanderInstance(actionMock); + + program.parse(['node', 'test', 'cmd3', 'subWithOpt']); + + expect(actionMock).toHaveBeenCalledTimes(1); + expect(actionMock.mock.calls[0].length).toBe(1); + expect(actionMock.mock.calls[0][0] instanceof Command).toBeTruthy(); + expect(actionMock.mock.calls[0][0].name()).toBe('subWithOpt'); + expect(actionMock.mock.calls[0][0].force).toBeFalsy(); + + actionMock.mockReset(); + program.parse(['node', 'test', 'cmd3', 'subWithOpt', '-f']); + + expect(actionMock).toHaveBeenCalledTimes(1); + expect(actionMock.mock.calls[0].length).toBe(1); + expect(actionMock.mock.calls[0][0] instanceof Command).toBeTruthy(); + expect(actionMock.mock.calls[0][0].name()).toBe('subWithOpt'); + expect(actionMock.mock.calls[0][0].force).toBeTruthy(); +}); + +test('should envoke 2 level sub command with with subcommand param', () => { + const actionMock = jest.fn(); + const program = createCommanderInstance(actionMock); + + program.parse(['node', 'test', 'cmd3', 'subWithParam', 'theparam']); + + expect(actionMock).toHaveBeenCalledTimes(1); + expect(actionMock.mock.calls[0].length).toBe(2); + expect(actionMock.mock.calls[0][0]).toBe('theparam'); + expect(actionMock.mock.calls[0][1] instanceof Command).toBeTruthy(); + expect(actionMock.mock.calls[0][1].name()).toBe('subWithParam'); +}); + +test('should envoke 2 level sub command with options on several levels', () => { + const actionMock = jest.fn(); + const program = createCommanderInstance(actionMock); + + // -f belongs to subWithOpt, -q belongs to cmd3 + program.parse(['node', 'test', 'cmd3', 'subWithOpt', '-f', '-q']); + + expect(actionMock).toHaveBeenCalledTimes(1); + expect(actionMock.mock.calls[0].length).toBe(1); + expect(actionMock.mock.calls[0][0] instanceof Command).toBeTruthy(); + expect(actionMock.mock.calls[0][0].name()).toBe('subWithOpt'); + expect(actionMock.mock.calls[0][0].force).toBeTruthy(); + expect(actionMock.mock.calls[0][0].quiet).toBeUndefined(); + expect(actionMock.mock.calls[0][0].parent.quiet).toBeTruthy(); +}); diff --git a/typings/index.d.ts b/typings/index.d.ts index a2fc6a989..286b9bfd2 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -205,6 +205,24 @@ declare namespace commander { */ parseOptions(argv: string[]): commander.ParseOptionsResult; + /** + * Creates an instance of sub command + * + * @returns {Command} which is the subcommand instance + */ + + useSubcommand(subCommand : Command): Command; + + /** + * Returns an object with all options values, including parent options values + * This makes it especially useful with forwardSubcommands as it collects + * options from upper levels too + * + * @returns {Object} dictionary of option values + */ + + collectAllOptions(): Object; + /** * Return an object containing options as key-value pairs *