Skip to content

Commit

Permalink
feat: command() now accepts an array of modules
Browse files Browse the repository at this point in the history
  • Loading branch information
bcoe committed Dec 5, 2020
1 parent bc6f973 commit f415388
Show file tree
Hide file tree
Showing 12 changed files with 204 additions and 59 deletions.
34 changes: 34 additions & 0 deletions docs/advanced.md
Expand Up @@ -233,6 +233,8 @@ This example uses [jest](https://github.com/facebook/jest) as a test runner, but
.commandDir(directory, [opts])
------------------------------

_Note: `commandDir()` does not work with ESM or Deno, see [hierarchy using index.mjs](/docs/advanced.md#esm-hierarchy) for an example of building a complex nested CLI using ESM._

Apply command modules from a directory relative to the module calling this method.

This allows you to organize multiple commands into their own modules under a
Expand Down Expand Up @@ -360,6 +362,38 @@ exports.handler = function (argv) {
}
```

<a name="esm-hierarchy"></a>
### Example command hierarchy using index.mjs

To support creating a complex nested CLI when using ESM, the method
`.command()` was extended to accept an array of command modules.
Rather than using `.commandDir()`, create an `index.mjs` in each command
directory with a list of the commands:

cmds/index.mjs:

```js
import * as a from './init.mjs';
import * as b from './remote.mjs';
export const commands = [a, b];
```

This index will then be imported and registered with your CLI:

cli.js:

```js
#!/usr/bin/env node

import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { commands } from './cmds/index.mjs';

yargs(hideBin(process.argv))
.command(commands)
.argv;
```

<a name="configuration"></a>
## Building Configurable CLI Apps

Expand Down
132 changes: 76 additions & 56 deletions lib/command.ts
Expand Up @@ -26,6 +26,7 @@ import {
import whichModule from './utils/which-module.js';

const DEFAULT_MARKER = /(^\*)|(^\$0)/;
export type DefinitionOrCommandName = string | CommandHandlerDefinition;

// handles parsing positional arguments,
// and populating argv with said positional
Expand All @@ -43,9 +44,9 @@ export function command(
let defaultCommand: CommandHandler | undefined;

self.addHandler = function addHandler(
cmd: string | string[] | CommandHandlerDefinition,
cmd: DefinitionOrCommandName | [DefinitionOrCommandName, ...string[]],
description?: string | false,
builder?: CommandBuilder | CommandBuilderDefinition,
builder?: CommandBuilder,
handler?: CommandHandlerCallback,
commandMiddleware?: Middleware[],
deprecated?: boolean
Expand All @@ -54,9 +55,16 @@ export function command(
const middlewares = commandMiddlewareFactory(commandMiddleware);
handler = handler || (() => {});

// If an array is provided that is all CommandHandlerDefinitions, add
// each handler individually:
if (Array.isArray(cmd)) {
aliases = cmd.slice(1);
cmd = cmd[0];
if (isCommandAndAliases(cmd)) {
[cmd, ...aliases] = cmd;
} else {
for (const command of cmd) {
self.addHandler(command);
}
}
} else if (isCommandHandlerDefinition(cmd)) {
let command =
Array.isArray(cmd.command) || typeof cmd.command === 'string'
Expand All @@ -73,10 +81,8 @@ export function command(
cmd.deprecated
);
return;
}

// allow a module to be provided instead of separate builder and handler
if (isCommandBuilderDefinition(builder)) {
} else if (isCommandBuilderDefinition(builder)) {
// Allow a module to be provided as builder, rather than function:
self.addHandler(
[cmd].concat(aliases),
description,
Expand All @@ -88,53 +94,57 @@ export function command(
return;
}

// parse positionals out of cmd string
const parsedCommand = parseCommand(cmd);
// The 'cmd' provided was a string, we apply the command DSL:
// https://github.com/yargs/yargs/blob/master/docs/advanced.md#advanced-topics
if (typeof cmd === 'string') {
// parse positionals out of cmd string
const parsedCommand = parseCommand(cmd);

// remove positional args from aliases only
aliases = aliases.map(alias => parseCommand(alias).cmd);

// check for default and filter out '*'
let isDefault = false;
const parsedAliases = [parsedCommand.cmd].concat(aliases).filter(c => {
if (DEFAULT_MARKER.test(c)) {
isDefault = true;
return false;
}
return true;
});

// remove positional args from aliases only
aliases = aliases.map(alias => parseCommand(alias).cmd);
// standardize on $0 for default command.
if (parsedAliases.length === 0 && isDefault) parsedAliases.push('$0');

// check for default and filter out '*''
let isDefault = false;
const parsedAliases = [parsedCommand.cmd].concat(aliases).filter(c => {
if (DEFAULT_MARKER.test(c)) {
isDefault = true;
return false;
// shift cmd and aliases after filtering out '*'
if (isDefault) {
parsedCommand.cmd = parsedAliases[0];
aliases = parsedAliases.slice(1);
cmd = cmd.replace(DEFAULT_MARKER, parsedCommand.cmd);
}
return true;
});

// standardize on $0 for default command.
if (parsedAliases.length === 0 && isDefault) parsedAliases.push('$0');

// shift cmd and aliases after filtering out '*'
if (isDefault) {
parsedCommand.cmd = parsedAliases[0];
aliases = parsedAliases.slice(1);
cmd = cmd.replace(DEFAULT_MARKER, parsedCommand.cmd);
}
// populate aliasMap
aliases.forEach(alias => {
aliasMap[alias] = parsedCommand.cmd;
});

// populate aliasMap
aliases.forEach(alias => {
aliasMap[alias] = parsedCommand.cmd;
});
if (description !== false) {
usage.command(cmd, description, isDefault, aliases, deprecated);
}

if (description !== false) {
usage.command(cmd, description, isDefault, aliases, deprecated);
handlers[parsedCommand.cmd] = {
original: cmd,
description,
handler,
builder: builder || {},
middlewares,
deprecated,
demanded: parsedCommand.demanded,
optional: parsedCommand.optional,
};

if (isDefault) defaultCommand = handlers[parsedCommand.cmd];
}

handlers[parsedCommand.cmd] = {
original: cmd,
description,
handler,
builder: builder || {},
middlewares,
deprecated,
demanded: parsedCommand.demanded,
optional: parsedCommand.optional,
};

if (isDefault) defaultCommand = handlers[parsedCommand.cmd];
};

self.addDirectory = function addDirectory(
Expand Down Expand Up @@ -368,7 +378,7 @@ export function command(
const builder = defaultCommand.builder;
if (isCommandBuilderCallback(builder)) {
builder(yargs);
} else {
} else if (!isCommandBuilderDefinition(builder)) {
Object.keys(builder).forEach(key => {
yargs.option(key, builder[key]);
});
Expand Down Expand Up @@ -555,7 +565,7 @@ export interface CommandInstance {
opts?: RequireDirectoryOptions
): void;
addHandler(
cmd: string | string[] | CommandHandlerDefinition,
cmd: string | CommandHandlerDefinition | DefinitionOrCommandName[],
description?: CommandHandler['description'],
builder?: CommandBuilderDefinition | CommandBuilder,
handler?: CommandHandlerCallback,
Expand Down Expand Up @@ -592,12 +602,6 @@ export interface CommandHandlerDefinition
describe?: CommandHandler['description'];
}

export function isCommandHandlerDefinition(
cmd: string | string[] | CommandHandlerDefinition
): cmd is CommandHandlerDefinition {
return typeof cmd === 'object';
}

export interface CommandBuilderDefinition {
builder?: CommandBuilder;
deprecated?: boolean;
Expand Down Expand Up @@ -639,6 +643,16 @@ interface CommandBuilderCallback {
(y: YargsInstance): YargsInstance | void;
}

function isCommandAndAliases(
cmd: DefinitionOrCommandName[]
): cmd is [CommandHandlerDefinition, ...string[]] {
if (cmd.every(c => typeof c === 'string')) {
return true;
} else {
return false;
}
}

export function isCommandBuilderCallback(
builder: CommandBuilder
): builder is CommandBuilderCallback {
Expand All @@ -651,6 +665,12 @@ function isCommandBuilderOptionDefinitions(
return typeof builder === 'object';
}

export function isCommandHandlerDefinition(
cmd: DefinitionOrCommandName | [DefinitionOrCommandName, ...string[]]
): cmd is CommandHandlerDefinition {
return typeof cmd === 'object' && !Array.isArray(cmd);
}

interface Positionals extends Pick<Options, 'alias' | 'array' | 'default'> {
demand: Dictionary<boolean>;
}
Expand Down
7 changes: 4 additions & 3 deletions lib/yargs-factory.ts
Expand Up @@ -5,14 +5,15 @@
// Works by accepting a shim which shims methods that contain platform
// specific logic.
import {
command as Command,
CommandInstance,
CommandHandler,
CommandBuilderDefinition,
CommandBuilder,
CommandHandlerCallback,
FinishCommandHandler,
command as Command,
CommandHandlerDefinition,
FinishCommandHandler,
DefinitionOrCommandName,
} from './command.js';
import type {
Dictionary,
Expand Down Expand Up @@ -655,7 +656,7 @@ function Yargs(
};

self.command = function (
cmd: string | string[] | CommandHandlerDefinition,
cmd: string | CommandHandlerDefinition | DefinitionOrCommandName[],
description?: CommandHandler['description'],
builder?: CommandBuilderDefinition | CommandBuilder,
handler?: CommandHandlerCallback,
Expand Down
28 changes: 28 additions & 0 deletions test/command.cjs
Expand Up @@ -1832,4 +1832,32 @@ describe('Command', () => {
const argv = yargs.command('$0 <phone>', '', yargs => {}).parse('+5550100');
argv.phone.should.equal('+5550100');
});

it('allows an array of commands to be provided', () => {
const innerCommands = [
{
command: 'c <x> <y>',
describe: 'add x to y',
builder: () => {},
handler: argv => {
argv.output.value = argv.x + argv.y;
},
},
];
const cmds = [
{
command: 'a',
describe: 'numeric comamand',
builder: yargs => {
yargs.command(innerCommands);
},
handler: () => {},
},
];
const context = {
output: {},
};
yargs().command(cmds).parse('a c 10 5', context);
context.output.value.should.equal(15);
});
});
9 changes: 9 additions & 0 deletions test/deno/yargs.test.ts
Expand Up @@ -6,6 +6,7 @@ import {
} from 'https://deno.land/std/testing/asserts.ts'
import yargs from '../../deno.ts'
import { Arguments } from '../../deno-types.ts'
import { commands } from '../esm/fixtures/commands/index.mjs'

Deno.test('demandCommand(1) throw error if no command provided', () => {
let err: Error|null = null
Expand Down Expand Up @@ -38,3 +39,11 @@ Deno.test('does not drop .0 if positional is configured as string', async () =>
}).parse() as Arguments
assertEquals(argv.str, '33.0')
})

Deno.test('hierarchy of commands', async () => {
const context = {
output: {value: 0},
};
yargs().command(commands).parse('a c 10 5', context);
assertEquals(context.output.value, 15);
})
8 changes: 8 additions & 0 deletions test/esm/fixtures/commands/a.mjs
@@ -0,0 +1,8 @@
import {commands} from './subcommands/index.mjs';

export const command = 'a';
export const describe = 'numeric commands';
export const builder = yargs => {
yargs.command(commands);
};
export const handler = function (argv) {};
8 changes: 8 additions & 0 deletions test/esm/fixtures/commands/b.mjs
@@ -0,0 +1,8 @@
export const command = 'b <str1> <str2>';
export const describe = 'string commands';
export const builder = yargs => {
yargs.string(['str1', 'str2']);
};
export const handler = function (argv) {
argv.output.text = `${argv.str1} ${argv.str2}`;
};
3 changes: 3 additions & 0 deletions test/esm/fixtures/commands/index.mjs
@@ -0,0 +1,3 @@
import * as a from './a.mjs';
import * as b from './b.mjs';
export const commands = [a, b];
6 changes: 6 additions & 0 deletions test/esm/fixtures/commands/subcommands/c.mjs
@@ -0,0 +1,6 @@
export const command = 'c <x> <y>';
export const describe = 'add x to y';
export const builder = yargs => {};
export const handler = function (argv) {
argv.output.value = argv.x + argv.y;
};
6 changes: 6 additions & 0 deletions test/esm/fixtures/commands/subcommands/d.mjs
@@ -0,0 +1,6 @@
export const command = 'd <x> <y>';
export const describe = 'multiply x by y';
export const builder = yargs => {};
export const handler = function (argv) {
argv.output.value = argv.x * argv.y;
};
3 changes: 3 additions & 0 deletions test/esm/fixtures/commands/subcommands/index.mjs
@@ -0,0 +1,3 @@
import * as c from './c.mjs';
import * as d from './d.mjs';
export const commands = [c, d];

0 comments on commit f415388

Please sign in to comment.