From f415388cc454d02786c65c50dd6c7a0cf9d8b842 Mon Sep 17 00:00:00 2001 From: "Benjamin E. Coe" Date: Sat, 5 Dec 2020 18:01:08 -0500 Subject: [PATCH] feat: command() now accepts an array of modules --- docs/advanced.md | 34 +++++ lib/command.ts | 132 ++++++++++-------- lib/yargs-factory.ts | 7 +- test/command.cjs | 28 ++++ test/deno/yargs.test.ts | 9 ++ test/esm/fixtures/commands/a.mjs | 8 ++ test/esm/fixtures/commands/b.mjs | 8 ++ test/esm/fixtures/commands/index.mjs | 3 + test/esm/fixtures/commands/subcommands/c.mjs | 6 + test/esm/fixtures/commands/subcommands/d.mjs | 6 + .../fixtures/commands/subcommands/index.mjs | 3 + test/esm/yargs-test.mjs | 19 +++ 12 files changed, 204 insertions(+), 59 deletions(-) create mode 100644 test/esm/fixtures/commands/a.mjs create mode 100644 test/esm/fixtures/commands/b.mjs create mode 100644 test/esm/fixtures/commands/index.mjs create mode 100644 test/esm/fixtures/commands/subcommands/c.mjs create mode 100644 test/esm/fixtures/commands/subcommands/d.mjs create mode 100644 test/esm/fixtures/commands/subcommands/index.mjs diff --git a/docs/advanced.md b/docs/advanced.md index 84d3e3e9b..da5c1d9ca 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -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 @@ -360,6 +362,38 @@ exports.handler = function (argv) { } ``` + +### 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; +``` + ## Building Configurable CLI Apps diff --git a/lib/command.ts b/lib/command.ts index 33da0b220..781812268 100644 --- a/lib/command.ts +++ b/lib/command.ts @@ -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 @@ -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 @@ -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' @@ -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, @@ -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( @@ -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]); }); @@ -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, @@ -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; @@ -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 { @@ -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 { demand: Dictionary; } diff --git a/lib/yargs-factory.ts b/lib/yargs-factory.ts index 76f7f4e38..5622dd628 100644 --- a/lib/yargs-factory.ts +++ b/lib/yargs-factory.ts @@ -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, @@ -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, diff --git a/test/command.cjs b/test/command.cjs index efae6b039..ea89f3455 100644 --- a/test/command.cjs +++ b/test/command.cjs @@ -1832,4 +1832,32 @@ describe('Command', () => { const argv = yargs.command('$0 ', '', yargs => {}).parse('+5550100'); argv.phone.should.equal('+5550100'); }); + + it('allows an array of commands to be provided', () => { + const innerCommands = [ + { + command: 'c ', + 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); + }); }); diff --git a/test/deno/yargs.test.ts b/test/deno/yargs.test.ts index 25797a07b..895b6f011 100644 --- a/test/deno/yargs.test.ts +++ b/test/deno/yargs.test.ts @@ -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 @@ -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); +}) diff --git a/test/esm/fixtures/commands/a.mjs b/test/esm/fixtures/commands/a.mjs new file mode 100644 index 000000000..90fc41265 --- /dev/null +++ b/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) {}; diff --git a/test/esm/fixtures/commands/b.mjs b/test/esm/fixtures/commands/b.mjs new file mode 100644 index 000000000..c5fe2230d --- /dev/null +++ b/test/esm/fixtures/commands/b.mjs @@ -0,0 +1,8 @@ +export const command = 'b '; +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}`; +}; diff --git a/test/esm/fixtures/commands/index.mjs b/test/esm/fixtures/commands/index.mjs new file mode 100644 index 000000000..f5cdfcacb --- /dev/null +++ b/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]; diff --git a/test/esm/fixtures/commands/subcommands/c.mjs b/test/esm/fixtures/commands/subcommands/c.mjs new file mode 100644 index 000000000..8085aeb95 --- /dev/null +++ b/test/esm/fixtures/commands/subcommands/c.mjs @@ -0,0 +1,6 @@ +export const command = 'c '; +export const describe = 'add x to y'; +export const builder = yargs => {}; +export const handler = function (argv) { + argv.output.value = argv.x + argv.y; +}; diff --git a/test/esm/fixtures/commands/subcommands/d.mjs b/test/esm/fixtures/commands/subcommands/d.mjs new file mode 100644 index 000000000..62c937dfe --- /dev/null +++ b/test/esm/fixtures/commands/subcommands/d.mjs @@ -0,0 +1,6 @@ +export const command = 'd '; +export const describe = 'multiply x by y'; +export const builder = yargs => {}; +export const handler = function (argv) { + argv.output.value = argv.x * argv.y; +}; diff --git a/test/esm/fixtures/commands/subcommands/index.mjs b/test/esm/fixtures/commands/subcommands/index.mjs new file mode 100644 index 000000000..bf656652b --- /dev/null +++ b/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]; diff --git a/test/esm/yargs-test.mjs b/test/esm/yargs-test.mjs index 2d0331892..33baa9fd2 100644 --- a/test/esm/yargs-test.mjs +++ b/test/esm/yargs-test.mjs @@ -3,6 +3,9 @@ import * as assert from 'assert'; import yargs from '../../index.mjs'; +// Example of composing hierarchical commands when using ESM: +import {commands} from './fixtures/commands/index.mjs'; + describe('ESM', () => { describe('parser', () => { it('parses process.argv by default', () => { @@ -35,4 +38,20 @@ describe('ESM', () => { assert.strictEqual(argv.str, '33.0'); }); }); + describe('hierarchy of commands', () => { + it('allows array of commands to be registered', () => { + const context = { + output: {}, + }; + yargs().command(commands).parse('b hello world!', context); + assert.strictEqual(context.output.text, 'hello world!'); + }); + it('allows array of subcommands to be registered', () => { + const context = { + output: {}, + }; + yargs().command(commands).parse('a d 10 5', context); + assert.strictEqual(context.output.value, 50); + }); + }); });