Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Forward subcommands (action-like subcommands) #1024

Closed
wants to merge 12 commits into from
50 changes: 50 additions & 0 deletions Readme.md
Expand Up @@ -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 <path>')
.action(listActionHandler);

subCommand
.command('delete <path>')
.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:
Expand Down
67 changes: 67 additions & 0 deletions 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 <path>')
.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 <path>')
.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 <name>')
.description('Greeting')
.action((name, cmdInstance) => {
console.log(`Hello ${name}!`);
});

program
.useSubcommand(journalSubCommand);

if (process.argv.length <= 2) {
program.help();
} else {
program.parse(process.argv);
}
63 changes: 63 additions & 0 deletions index.js
Expand Up @@ -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`
*
Expand Down
59 changes: 59 additions & 0 deletions 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']);
});
107 changes: 107 additions & 0 deletions 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 <param>')
.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();
});
18 changes: 18 additions & 0 deletions typings/index.d.ts
Expand Up @@ -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
*
Expand Down