From 1345f98c0fb0af61500aebc17006c520db6bdf04 Mon Sep 17 00:00:00 2001 From: John Gee Date: Fri, 31 Jan 2020 20:55:38 +1300 Subject: [PATCH] Parse rework for nested commands (#1149) * First cut at parse rework - skip asterisk tests - other tests runnning - nested commands untested - lots of details to check * Add check for requiredOption when calling executable subcommand * Set program name using supported approach * Add .addCommand, easy after previous work * Add support for default command using action handler - and remove stale _execs * Add implicitHelpCommand and change help flags description * Add implicit help command to help * Turn off implicit help command for most help tests * .addHelpCommand * Remove addHelpCommand from tests and make match more narrow * Use test of complete default help output * Add tests for whether implicit help appears in help * Add tests that help command dispatched to correct command * Add simple nested subcommand test * Add default command tests for action based subcommand * Remove mainModule, out of scope for current PR * Add legacy asterisk handling and tests * Add more initialisation so object in known state * Tests for addCommand * Add first cut at enhanced default error detection * Add test that addCommand requires name * Add block on automatic name generation for deeply nested executables * Add block on automatic name generation for deeply nested executables * Fix describe name for tests * Refine unknownCommand handling and add tests * Add suggestion to try help, when appropriate * Fix typo * Move common command configuration options in README, and add isDefault example program * Add isDefault and example to README * Add nested commands * Document .addHelpCommand, and tweaks * Remove old default command, and rework command:* example * Document .addCommand * Remove comment referring to removed code. * Revert the error tip "try --help", not happy with the wording * Say "unknown command", like "unknown option" * Set properties to null rather than undefined in constructor --- Readme.md | 85 ++-- Readme_zh-CN.md | 20 +- examples/custom-help | 4 +- examples/defaultCommand.js | 36 ++ examples/deploy | 6 - examples/nestedCommands.js | 47 ++ index.js | 468 ++++++++++-------- tests/args.variadic.test.js | 2 +- tests/command.action.test.js | 8 +- tests/command.addCommand.test.js | 69 +++ tests/command.allowUnknownOptions.test.js | 2 +- tests/command.asterisk.test.js | 18 +- tests/command.commandHelp.test.js | 4 +- tests/command.default.test.js | 61 +++ ...mmand.executableSubcommand.default.test.js | 27 - ...ommand.executableSubcommand.lookup.test.js | 4 +- tests/command.exitOverride.test.js | 20 +- tests/command.help.test.js | 7 +- tests/command.helpCommand.test.js | 100 ++++ tests/command.nested.test.js | 12 + tests/command.unknownCommand.test.js | 53 ++ tests/command.unknownOption.test.js | 2 +- tests/command.usage.test.js | 8 +- tests/helpwrap.test.js | 13 +- tests/options.mandatory.test.js | 4 +- typings/commander-tests.ts | 7 +- typings/index.d.ts | 14 +- 27 files changed, 763 insertions(+), 338 deletions(-) create mode 100644 examples/defaultCommand.js create mode 100644 examples/nestedCommands.js create mode 100644 tests/command.addCommand.test.js create mode 100644 tests/command.default.test.js delete mode 100644 tests/command.executableSubcommand.default.test.js create mode 100644 tests/command.helpCommand.test.js create mode 100644 tests/command.nested.test.js create mode 100644 tests/command.unknownCommand.test.js diff --git a/Readme.md b/Readme.md index e685f758e..0a9601108 100644 --- a/Readme.md +++ b/Readme.md @@ -11,7 +11,7 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md) - [Commander.js](#commanderjs) - [Installation](#installation) - - [Declaring program variable](#declaring-program-variable) + - [Declaring _program_ variable](#declaring-program-variable) - [Options](#options) - [Common option types, boolean and value](#common-option-types-boolean-and-value) - [Default option value](#default-option-value) @@ -23,17 +23,18 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md) - [Specify the argument syntax](#specify-the-argument-syntax) - [Action handler (sub)commands](#action-handler-subcommands) - [Git-style executable (sub)commands](#git-style-executable-subcommands) - - [Automated --help](#automated---help) + - [Automated help](#automated-help) - [Custom help](#custom-help) - [.usage and .name](#usage-and-name) - [.outputHelp(cb)](#outputhelpcb) - [.helpOption(flags, description)](#helpoptionflags-description) + - [.addHelpCommand()](#addhelpcommand) - [.help(cb)](#helpcb) - [Custom event listeners](#custom-event-listeners) - [Bits and pieces](#bits-and-pieces) - [Avoiding option name clashes](#avoiding-option-name-clashes) - [TypeScript](#typescript) - - [Node options such as --harmony](#node-options-such-as---harmony) + - [Node options such as `--harmony`](#node-options-such-as---harmony) - [Node debugging](#node-debugging) - [Override exit handling](#override-exit-handling) - [Examples](#examples) @@ -269,7 +270,7 @@ program program.parse(process.argv); ``` -``` +```bash $ pizza error: required option '-c, --cheese ' not specified ``` @@ -296,7 +297,11 @@ program.version('0.0.1', '-v, --vers', 'output the current version'); ## Commands -You can specify (sub)commands for your top-level command using `.command`. There are two ways these can be implemented: using an action handler attached to the command, or as a separate executable file (described in more detail later). In the first parameter to `.command` you specify the command name and any command arguments. The arguments may be `` or `[optional]`, and the last argument may also be `variadic...`. +You can specify (sub)commands for your top-level command using `.command()` or `.addCommand()`. There are two ways these can be implemented: using an action handler attached to the command, or as a separate executable file (described in more detail later). The subcommands may be nested ([example](./examples/nestedCommands.js)). + +In the first parameter to `.command()` you specify the command name and any command arguments. The arguments may be `` or `[optional]`, and the last argument may also be `variadic...`. + +You can use `.addCommand()` to add an already configured subcommand to the program. For example: @@ -315,8 +320,15 @@ program program .command('start ', 'start named service') .command('stop [service]', 'stop named service, or all if no name supplied'); + +// Command prepared separately. +// Returns top-level command for adding more commands. +program + .addCommand(build.makeBuildCommand()); ``` +Configuration options can be passed with the call to `.command()`. Specifying `true` for `opts.noHelp` will remove the command from the generated help output. Specifying `true` for `opts.isDefault` will run the subcommand if no other subcommand is specified ([example](./examples/defaultCommand.js)). + ### Specify the argument syntax You use `.arguments` to specify the arguments for the top-level command, and for subcommands they are included in the `.command` call. Angled brackets (e.g. ``) indicate required input. Square brackets (e.g. `[optional]`) indicate optional input. @@ -397,13 +409,11 @@ async function main() { } ``` -A command's options on the command line are validated when the command is used. Any unknown options will be reported as an error. However, if an action-based command does not define an action, then the options are not validated. - -Configuration options can be passed with the call to `.command()`. Specifying `true` for `opts.noHelp` will remove the command from the generated help output. +A command's options on the command line are validated when the command is used. Any unknown options will be reported as an error. ### Git-style executable (sub)commands -When `.command()` is invoked with a description argument, this tells commander that you're going to use separate executables for sub-commands, much like `git(1)` and other popular tools. +When `.command()` is invoked with a description argument, this tells commander that you're going to use separate executables for sub-commands, much like `git` and other popular tools. Commander will search the executables in the directory of the entry script (like `./examples/pm`) with the name `program-subcommand`, like `pm-install`, `pm-search`. You can specify a custom name with the `executableFile` configuration option. @@ -422,14 +432,12 @@ program .parse(process.argv); ``` -Configuration options can be passed with the call to `.command()`. Specifying `true` for `opts.noHelp` will remove the command from the generated help output. Specifying `true` for `opts.isDefault` will run the subcommand if no other subcommand is specified. -Specifying a name with `executableFile` will override the default constructed name. - If the program is designed to be installed globally, make sure the executables have proper modes, like `755`. -## Automated --help +## 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: + The help information is auto-generated based on the information commander already knows about your program. The default + help option is `-h,--help`. ```bash $ ./examples/pizza --help @@ -444,17 +452,25 @@ Options: -b, --bbq Add bbq sauce -c, --cheese Add the specified type of cheese (default: "marble") -C, --no-cheese You do not want any cheese - -h, --help output usage information + -h, --help display help for command +``` + +A `help` command is added by default if your command has subcommands. It can be used alone, or with a subcommand name to show +further help for the subcommand. These are effectively the same if the `shell` program has implicit help: + +```bash +shell help +shell --help + +shell help spawn +shell spawn --help ``` ### Custom help - You can display arbitrary `-h, --help` information + You can display extra `-h, --help` information by listening for "--help". Commander will automatically - exit once you are done so that the remainder of your program - does not execute causing undesired behaviors, for example - in the following executable "stuff" will not output when - `--help` is used. + exit after displaying the help. ```js #!/usr/bin/env node @@ -467,9 +483,7 @@ program .option('-b, --bar', 'enable some bar') .option('-B, --baz', 'enable some baz'); -// must be before .parse() since -// node's emit() is immediate - +// must be before .parse() program.on('--help', function(){ console.log('') console.log('Examples:'); @@ -488,11 +502,11 @@ Yields the following help output when `node script-name.js -h` or `node script-n Usage: custom-help [options] Options: - -h, --help output usage information -V, --version output the version number -f, --foo enable some foo -b, --bar enable some bar -B, --baz enable some baz + -h, --help display help for command Examples: $ custom-help --help @@ -550,6 +564,16 @@ program .helpOption('-e, --HELP', 'read more information'); ``` +### .addHelpCommand() + +You can explicitly turn on or off the implicit help command with `.addHelpCommand()` and `.addHelpCommand(false)`. + +You can both turn on and customise the help command by supplying the name and description: + +```js +program.addHelpCommand('assist [command]', 'show assistance'); +``` + ### .help(cb) Output help information and exit immediately. @@ -564,9 +588,10 @@ program.on('option:verbose', function () { process.env.VERBOSE = this.verbose; }); -// error on unknown commands -program.on('command:*', function () { - console.error('Invalid command: %s\nSee --help for a list of available commands.', program.args[0]]); +// custom error on unknown command +program.on('command:*', function (operands) { + console.error(`Invalid command '${operands[0]}'. Did you mean:`); + console.error(mySuggestions(operands[0])); process.exit(1); }); ``` @@ -686,12 +711,6 @@ program console.log(' $ deploy exec async'); }); -program - .command('*') - .action(function(env){ - console.log('deploying "%s"', env); - }); - program.parse(process.argv); ``` diff --git a/Readme_zh-CN.md b/Readme_zh-CN.md index ca2c8ace4..7aceda565 100644 --- a/Readme_zh-CN.md +++ b/Readme_zh-CN.md @@ -31,15 +31,15 @@ - [.help(cb)](#helpcb) - [自定义事件监听](#%e8%87%aa%e5%ae%9a%e4%b9%89%e4%ba%8b%e4%bb%b6%e7%9b%91%e5%90%ac) - [零碎知识](#%e9%9b%b6%e7%a2%8e%e7%9f%a5%e8%af%86) - - [避免选项命名冲突](#避免选项命名冲突) + - [避免选项命名冲突](#%e9%81%bf%e5%85%8d%e9%80%89%e9%a1%b9%e5%91%bd%e5%90%8d%e5%86%b2%e7%aa%81) - [TypeScript](#typescript) - - [Node 选项例如 --harmony](#node-%e9%80%89%e9%a1%b9%e4%be%8b%e5%a6%82---harmony) + - [Node 选项例如 `--harmony`](#node-%e9%80%89%e9%a1%b9%e4%be%8b%e5%a6%82---harmony) - [Node 调试](#node-%e8%b0%83%e8%af%95) - [重载退出(exit)处理](#%e9%87%8d%e8%bd%bd%e9%80%80%e5%87%baexit%e5%a4%84%e7%90%86) - [例子](#%e4%be%8b%e5%ad%90) - [许可证](#%e8%ae%b8%e5%8f%af%e8%af%81) - [支持](#%e6%94%af%e6%8c%81) - - [企业使用Commander](#企业使用Commander) + - [企业使用Commander](#%e4%bc%81%e4%b8%9a%e4%bd%bf%e7%94%a8commander) ## 安装 @@ -435,7 +435,7 @@ Options: -b, --bbq Add bbq sauce -c, --cheese Add the specified type of cheese (default: "marble") -C, --no-cheese You do not want any cheese - -h, --help output usage information + -h, --help display help for command ``` ### 自定义帮助 @@ -453,9 +453,7 @@ program .option('-b, --bar', 'enable some bar') .option('-B, --baz', 'enable some baz'); -// must be before .parse() since -// node's emit() is immediate - +// must be before .parse() program.on('--help', function(){ console.log(''); console.log('Examples:'); @@ -474,7 +472,7 @@ console.log('stuff'); Usage: custom-help [options] Options: - -h, --help output usage information + -h, --help display help for command -V, --version output the version number -f, --foo enable some foo -b, --bar enable some bar @@ -670,12 +668,6 @@ program console.log(' $ deploy exec async'); }); -program - .command('*') - .action(function(env){ - console.log('deploying "%s"', env); - }); - program.parse(process.argv); ``` diff --git a/examples/custom-help b/examples/custom-help index b4719ef50..668a48dfe 100755 --- a/examples/custom-help +++ b/examples/custom-help @@ -9,9 +9,7 @@ program .option('-b, --bar', 'enable some bar') .option('-B, --baz', 'enable some baz'); -// must be before .parse() since -// node's emit() is immediate - +// must be before .parse() program.on('--help', function() { console.log(''); console.log('Examples:'); diff --git a/examples/defaultCommand.js b/examples/defaultCommand.js new file mode 100644 index 000000000..c8afa771f --- /dev/null +++ b/examples/defaultCommand.js @@ -0,0 +1,36 @@ +// const commander = require('commander'); // (normal include) +const commander = require('../'); // include commander in git clone of commander repo +const program = new commander.Command(); + +// Example program using the command configuration option isDefault to specify the default command. +// +// $ node defaultCommand.js build +// build +// $ node defaultCommand.js serve -p 8080 +// server on port 8080 +// $ node defaultCommand.js -p 443 +// server on port 443 + +program + .command('build') + .description('build web site for deployment') + .action(() => { + console.log('build'); + }); + +program + .command('deploy') + .description('deploy web site to production') + .action(() => { + console.log('deploy'); + }); + +program + .command('serve', { isDefault: true }) + .description('launch web server') + .option('-p,--port ', 'web port') + .action((opts) => { + console.log(`server on port ${opts.port}`); + }); + +program.parse(process.argv); diff --git a/examples/deploy b/examples/deploy index d0821f86a..1ec4d2d26 100755 --- a/examples/deploy +++ b/examples/deploy @@ -34,10 +34,4 @@ program console.log(); }); -program - .command('*') - .action(function(env) { - console.log('deploying "%s"', env); - }); - program.parse(process.argv); diff --git a/examples/nestedCommands.js b/examples/nestedCommands.js new file mode 100644 index 000000000..3251ee5d8 --- /dev/null +++ b/examples/nestedCommands.js @@ -0,0 +1,47 @@ +// const commander = require('commander'); // (normal include) +const commander = require('../'); // include commander in git clone of commander repo +const program = new commander.Command(); + +// Commander supports nested subcommands. +// .command() can add a subcommand with an action handler or an executable. +// .addCommand() adds a prepared command with an actiomn handler. + +// Example output: +// +// $ node nestedCommands.js brew tea +// brew tea +// $ node nestedCommands.js heat jug +// heat jug + +// Add nested commands using `.command()`. +const brew = program.command('brew'); +brew + .command('tea') + .action(() => { + console.log('brew tea'); + }); +brew + .command('tea') + .action(() => { + console.log('brew tea'); + }); + +// Add nested commands using `.addCommand(). +// The command could be created separately in another module. +function makeHeatCommand() { + const heat = new commander.Command('heat'); + heat + .command('jug') + .action(() => { + console.log('heat jug'); + }); + heat + .command('pot') + .action(() => { + console.log('heat pot'); + }); + return heat; +} +program.addCommand(makeHeatCommand()); + +program.parse(process.argv); diff --git a/index.js b/index.js index be0ffbcf8..dedcf936f 100644 --- a/index.js +++ b/index.js @@ -5,8 +5,6 @@ var EventEmitter = require('events').EventEmitter; var spawn = require('child_process').spawn; var path = require('path'); -var dirname = path.dirname; -var basename = path.basename; var fs = require('fs'); /** @@ -124,7 +122,6 @@ exports.CommanderError = CommanderError; function Command(name) { this.commands = []; this.options = []; - this._execs = new Set(); this._allowUnknownOption = false; this._args = []; this._name = name || ''; @@ -132,11 +129,21 @@ function Command(name) { this._storeOptionsAsProperties = true; // backwards compatible by default this._passCommandToAction = true; // backwards compatible by default this._actionResults = []; + this._actionHandler = null; + this._executableHandler = false; + this._executableFile = null; // custom name for executable + this._defaultCommandName = null; + this._exitCallback = null; + this._noHelp = false; this._helpFlags = '-h, --help'; - this._helpDescription = 'output usage information'; + this._helpDescription = 'display help for command'; this._helpShortFlag = '-h'; this._helpLongFlag = '--help'; + this._hasImplicitHelpCommand = undefined; // Deliberately undefined, not decided whether true or false + this._helpCommandName = 'help'; + this._helpCommandnameAndArgs = 'help [command]'; + this._helpCommandDescription = 'display help for command'; } /** @@ -179,28 +186,59 @@ Command.prototype.command = function(nameAndArgs, actionOptsOrExecDesc, execOpts if (desc) { cmd.description(desc); - this.executables = true; - this._execs.add(cmd._name); - if (opts.isDefault) this.defaultExecutable = cmd._name; + cmd._executableHandler = true; } + if (opts.isDefault) this._defaultCommandName = cmd._name; + cmd._noHelp = !!opts.noHelp; cmd._helpFlags = this._helpFlags; cmd._helpDescription = this._helpDescription; cmd._helpShortFlag = this._helpShortFlag; cmd._helpLongFlag = this._helpLongFlag; + cmd._helpCommandName = this._helpCommandName; + cmd._helpCommandnameAndArgs = this._helpCommandnameAndArgs; + cmd._helpCommandDescription = this._helpCommandDescription; cmd._exitCallback = this._exitCallback; cmd._storeOptionsAsProperties = this._storeOptionsAsProperties; cmd._passCommandToAction = this._passCommandToAction; - cmd._executableFile = opts.executableFile; // Custom name for executable file + cmd._executableFile = opts.executableFile || null; // Custom name for executable file, set missing to null to match constructor this.commands.push(cmd); - cmd.parseExpectedArgs(args); + cmd._parseExpectedArgs(args); cmd.parent = this; if (desc) return this; return cmd; }; +/** + * Add a prepared subcommand. + * + * @param {Command} cmd - new subcommand + * @return {Command} parent command for chaining + * @api public + */ + +Command.prototype.addCommand = function(cmd) { + if (!cmd._name) throw new Error('Command passed to .AddCommand must have a name'); + + // To keep things simple, block automatic name generation for deeply nested executables. + // 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()}`); + } + checkExplicitNames(cmd.commands); + }); + } + checkExplicitNames(cmd.commands); + + this.commands.push(cmd); + cmd.parent = this; + return this; +}; + /** * Define argument syntax for the top-level command. * @@ -208,18 +246,44 @@ Command.prototype.command = function(nameAndArgs, actionOptsOrExecDesc, execOpts */ Command.prototype.arguments = function(desc) { - return this.parseExpectedArgs(desc.split(/ +/)); + return this._parseExpectedArgs(desc.split(/ +/)); }; /** - * Add an implicit `help [cmd]` subcommand - * which invokes `--help` for the given command. + * Override default decision whether to add implicit help command. * + * addHelpCommand() // force on + * addHelpCommand(false); // force off + * addHelpCommand('help [cmd]', 'display help for [cmd]'); // force on with custom detais + * + * @return {Command} for chaining + * @api public + */ + +Command.prototype.addHelpCommand = function(enableOrNameAndArgs, description) { + if (enableOrNameAndArgs === false) { + this._hasImplicitHelpCommand = false; + } else { + this._hasImplicitHelpCommand = true; + if (typeof enableOrNameAndArgs === 'string') { + this._helpCommandName = enableOrNameAndArgs.split(' ')[0]; + this._helpCommandnameAndArgs = enableOrNameAndArgs; + } + this._helpCommandDescription = description || this._helpCommandDescription; + } + return this; +}; + +/** + * @return {boolean} * @api private */ -Command.prototype.addImplicitHelpCommand = function() { - this.command('help [cmd]', 'display help for [cmd]'); +Command.prototype._lazyHasImplicitHelpCommand = function() { + if (this._hasImplicitHelpCommand === undefined) { + this._hasImplicitHelpCommand = this.commands.length && !this._actionHandler && !this._findCommand('help'); + } + return this._hasImplicitHelpCommand; }; /** @@ -229,10 +293,10 @@ Command.prototype.addImplicitHelpCommand = function() { * * @param {Array} args * @return {Command} for chaining - * @api public + * @api private */ -Command.prototype.parseExpectedArgs = function(args) { +Command.prototype._parseExpectedArgs = function(args) { if (!args.length) return; var self = this; args.forEach(function(arg) { @@ -323,37 +387,8 @@ Command.prototype._exit = function(exitCode, code, message) { Command.prototype.action = function(fn) { var self = this; - var listener = function(args, unknown) { - // Parse any so-far unknown options - args = args || []; - unknown = unknown || []; - - var parsed = self.parseOptions(unknown); - - // Output help if necessary - outputHelpIfRequested(self, parsed.unknown); - self._checkForMissingMandatoryOptions(); - - // If there are still any unknown options, then we simply die. - if (parsed.unknown.length > 0) { - self.unknownOption(parsed.unknown[0]); - } - - args = args.concat(parsed.operands, parsed.unknown); - - self._args.forEach(function(expectedArg, i) { - if (expectedArg.required && args[i] == null) { - self.missingArgument(expectedArg.name); - } else if (expectedArg.variadic) { - if (i !== self._args.length - 1) { - self.variadicArgNotLast(expectedArg.name); - } - - args[i] = args.splice(i); - } - }); - - // The .action callback takes an extra parameter which is the command itself. + var listener = function(args) { + // The .action callback takes an extra parameter which is the command or options. var expectedArgsCount = self._args.length; var actionArgs = args.slice(0, expectedArgsCount); if (self._passCommandToAction) { @@ -374,14 +409,7 @@ Command.prototype.action = function(fn) { } rootCommand._actionResults.push(actionResult); }; - var parent = this.parent || this; - if (parent === this) { - parent.on('program-command', listener); - } else { - parent.on('command:' + this._name, listener); - } - - if (this._alias) parent.on('command:' + this._alias, listener); + this._actionHandler = listener; return this; }; @@ -425,7 +453,7 @@ Command.prototype._optionEx = function(config, flags, description, fn, defaultVa // when --no-foo we make sure default is true, unless a --foo option is already defined if (option.negate) { const positiveLongFlag = option.long.replace(/^--no-/, '--'); - defaultValue = self.optionFor(positiveLongFlag) ? self._getOptionValue(name) : true; + defaultValue = self._findOption(positiveLongFlag) ? self._getOptionValue(name) : true; } // preassign only if we have a default if (defaultValue !== undefined) { @@ -622,80 +650,15 @@ Command.prototype._getOptionValue = function(key) { */ Command.prototype.parse = function(argv) { - // implicit help - if (this.executables) this.addImplicitHelpCommand(); - // store raw args this.rawArgs = argv; - // guess name - this._name = this._name || basename(argv[1], '.js'); - - // github-style sub-commands with no sub-command - if (this.executables && argv.length < 3 && !this.defaultExecutable) { - // this user needs help - argv.push(this._helpLongFlag); - } - - // process argv, leaving off first two args which are app and scriptname. - const parsed = this.parseOptions(argv.slice(2)); - const args = parsed.operands.concat(parsed.unknown); - this.args = args.slice(); + // Guess name, used in usage in help. + this._name = this._name || path.basename(argv[1], path.extname(argv[1])); - var result = this.parseArgs(parsed.operands, parsed.unknown); + this._parseCommand([], argv.slice(2)); - if (args[0] === 'help' && args.length === 1) this.help(); - - // Note for future: we could return early if we found an action handler in parseArgs, as none of following code needed? - - // --help - if (args[0] === 'help') { - args[0] = args[1]; - args[1] = this._helpLongFlag; - } else { - // If calling through to executable subcommand we could check for help flags before failing, - // but a somewhat unlikely case since program options not passed to executable subcommands. - // Wait for reports to see if check needed and what usage pattern is. - this._checkForMissingMandatoryOptions(); - } - - // executable sub-commands - // (Debugging note for future: args[0] is not right if an action has been called) - var name = result.args[0]; - var subCommand = null; - - // Look for subcommand - if (name) { - subCommand = this.commands.find(function(command) { - return command._name === name; - }); - } - - // Look for alias - if (!subCommand && name) { - subCommand = this.commands.find(function(command) { - return command.alias() === name; - }); - if (subCommand) { - name = subCommand._name; - args[0] = name; - } - } - - // Look for default subcommand - if (!subCommand && this.defaultExecutable) { - name = this.defaultExecutable; - args.unshift(name); - subCommand = this.commands.find(function(command) { - return command._name === name; - }); - } - - if (this._execs.has(name)) { - return this.executeSubCommand(argv, args, subCommand ? subCommand._executableFile : undefined); - } - - return result; + return this; }; /** @@ -707,6 +670,7 @@ Command.prototype.parse = function(argv) { * @return {Promise} * @api public */ + Command.prototype.parseAsync = function(argv) { this.parse(argv); return Promise.all(this._actionResults); @@ -715,59 +679,51 @@ Command.prototype.parseAsync = function(argv) { /** * Execute a sub-command executable. * - * @param {Array} argv - * @param {Array} args - * @param {Array} unknown - * @param {String} executableFile * @api private */ -Command.prototype.executeSubCommand = function(argv, args, executableFile) { - if (!args.length) this.help(); - - var isExplicitJS = false; // Whether to use node to launch "executable" +Command.prototype._executeSubCommand = function(subcommand, args) { + args = args.slice(); + let launchWithNode = false; // Use node for source targets so do not need to get permissions correct, and on Windows. + const sourceExt = ['.js', '.ts', '.mjs']; - // executable - var pm = argv[1]; - // name of the subcommand, like `pm-install` - var bin = basename(pm, path.extname(pm)) + '-' + args[0]; - if (executableFile != null) { - bin = executableFile; - // Check for same extensions as we scan for below so get consistent launch behaviour. - var executableExt = path.extname(executableFile); - isExplicitJS = executableExt === '.js' || executableExt === '.ts' || executableExt === '.mjs'; - } + // Not checking for help first. Unlikely to have mandatory and executable, and can't robustly test for help flags in external command. + this._checkForMissingMandatoryOptions(); - // In case of globally installed, get the base dir where executable - // subcommand file should be located at - var baseDir; + // Want the entry script as the reference for command name and directory for searching for other files. + const scriptPath = this.rawArgs[1]; - var resolvedLink = fs.realpathSync(pm); - - baseDir = dirname(resolvedLink); + let baseDir; + try { + const resolvedLink = fs.realpathSync(scriptPath); + baseDir = path.dirname(resolvedLink); + } catch (e) { + baseDir = '.'; // dummy, probably not going to find executable! + } - // prefer local `./` to bin in the $PATH - var localBin = path.join(baseDir, bin); + // name of the subcommand, like `pm-install` + let bin = path.basename(scriptPath, path.extname(scriptPath)) + '-' + subcommand._name; + if (subcommand._executableFile) { + bin = subcommand._executableFile; + } - // whether bin file is a js script with explicit `.js` or `.ts` extension - if (exists(localBin + '.js')) { - bin = localBin + '.js'; - isExplicitJS = true; - } else if (exists(localBin + '.ts')) { - bin = localBin + '.ts'; - isExplicitJS = true; - } else if (exists(localBin + '.mjs')) { - bin = localBin + '.mjs'; - isExplicitJS = true; - } else if (exists(localBin)) { + const localBin = path.join(baseDir, bin); + if (fs.existsSync(localBin)) { + // prefer local `./` to bin in the $PATH bin = localBin; + } else { + // Look for source files. + sourceExt.forEach((ext) => { + if (fs.existsSync(`${localBin}${ext}`)) { + bin = `${localBin}${ext}`; + } + }); } + launchWithNode = sourceExt.includes(path.extname(bin)); - args = args.slice(1); - - var proc; + let proc; if (process.platform !== 'win32') { - if (isExplicitJS) { + if (launchWithNode) { args.unshift(bin); // add executable arguments to spawn args = incrementNodeInspectorPort(process.execArgv).concat(args); @@ -783,7 +739,7 @@ Command.prototype.executeSubCommand = function(argv, args, executableFile) { proc = spawn(process.execPath, args, { stdio: 'inherit' }); } - var signals = ['SIGUSR1', 'SIGUSR2', 'SIGTERM', 'SIGINT', 'SIGHUP']; + const signals = ['SIGUSR1', 'SIGUSR2', 'SIGTERM', 'SIGINT', 'SIGHUP']; signals.forEach(function(signal) { process.on(signal, function() { if (proc.killed === false && proc.exitCode === null) { @@ -804,9 +760,9 @@ Command.prototype.executeSubCommand = function(argv, args, executableFile) { } proc.on('error', function(err) { if (err.code === 'ENOENT') { - console.error('error: %s(1) does not exist, try --help', bin); + console.error('error: %s(1) does not exist', bin); } else if (err.code === 'EACCES') { - console.error('error: %s(1) not executable. try chmod or run with root', bin); + console.error('error: %s(1) not executable', bin); } if (!exitCallback) { process.exit(1); @@ -822,41 +778,96 @@ Command.prototype.executeSubCommand = function(argv, args, executableFile) { }; /** - * Parse command `args`. - * - * When listener(s) are available those - * callbacks are invoked, otherwise the "*" - * event is emitted and those actions are invoked. + * @api private + */ +Command.prototype._dispatchSubcommand = function(commandName, operands, unknown) { + const subCommand = this._findCommand(commandName); + if (!subCommand) this._helpAndError(); + + if (subCommand._executableHandler) { + this._executeSubCommand(subCommand, operands.concat(unknown)); + } else { + subCommand._parseCommand(operands, unknown); + } +}; + +/** + * Process arguments in context of this command. * - * @param {Array} args - * @return {Command} for chaining * @api private */ -Command.prototype.parseArgs = function(operands, unknown) { - if (operands.length) { - const name = operands[0]; - if (this.listeners('command:' + name).length) { - this.emit('command:' + operands[0], operands.slice(1), unknown); +Command.prototype._parseCommand = function(operands, unknown) { + const parsed = this.parseOptions(unknown); + operands = operands.concat(parsed.operands); + unknown = parsed.unknown; + this.args = operands.concat(unknown); + + if (operands && this._findCommand(operands[0])) { + this._dispatchSubcommand(operands[0], operands.slice(1), unknown); + } else if (this._lazyHasImplicitHelpCommand() && operands[0] === this._helpCommandName) { + if (operands.length === 1) { + this.help(); } else { - this.emit('program-command', operands, unknown); - this.emit('command:*', operands, unknown); + 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); } else { - outputHelpIfRequested(this, unknown); + if (this.commands.length && this.args.length === 0 && !this._actionHandler && !this._defaultCommandName) { + // probaby missing subcommand and no handler, user needs help + this._helpAndError(); + } - // If there were no args and we have unknown options, - // then they are extraneous and we need to error. - if (unknown.length > 0 && !this.defaultExecutable) { - this.unknownOption(unknown[0]); + outputHelpIfRequested(this, parsed.unknown); + this._checkForMissingMandatoryOptions(); + if (parsed.unknown.length > 0) { + this.unknownOption(parsed.unknown[0]); } - // Call the program action handler, unless it has a (missing) required parameter and signature does not match. - if (this._args.filter(function(a) { return a.required; }).length === 0) { - this.emit('program-command'); + + if (this._actionHandler) { + const self = this; + const args = this.args.slice(); + this._args.forEach(function(arg, i) { + if (arg.required && args[i] == null) { + self.missingArgument(arg.name); + } else if (arg.variadic) { + if (i !== self._args.length - 1) { + self.variadicArgNotLast(arg.name); + } + + args[i] = args.splice(i); + } + }); + + this._actionHandler(args); + this.emit('command:' + this.name(), operands, unknown); + } else if (operands.length) { + if (this._findCommand('*')) { + this._dispatchSubcommand('*', operands, unknown); + } else if (this.listenerCount('command:*')) { + this.emit('command:*', operands, unknown); + } else if (this.commands.length) { + this.unknownCommand(); + } + } else if (this.commands.length) { + // This command has subcommands and nothing hooked up at this level, so display help. + this._helpAndError(); + } else { + // fall through for caller to handle after calling .parse() } } +}; - return this; +/** + * Find matching command. + * + * @api private + */ +Command.prototype._findCommand = function(name) { + if (!name) return undefined; + return this.commands.find(cmd => cmd._name === name || cmd._alias === name); }; /** @@ -867,18 +878,19 @@ Command.prototype.parseArgs = function(operands, unknown) { * @api private */ -Command.prototype.optionFor = function(arg) { +Command.prototype._findOption = function(arg) { return this.options.find(option => option.is(arg)); }; /** * Display an error message if a mandatory option does not have a value. + * Lazy calling after checking for help flags from leaf subcommand. * * @api private */ Command.prototype._checkForMissingMandatoryOptions = function() { - // Walk up hierarchy so can call from action handler after checking for displaying help. + // Walk up hierarchy so can call in subcommand after checking for displaying help. for (var cmd = this; cmd; cmd = cmd.parent) { cmd.options.forEach((anOption) => { if (anOption.mandatory && (cmd._getOptionValue(anOption.attributeName()) === undefined)) { @@ -927,7 +939,7 @@ Command.prototype.parseOptions = function(argv) { } if (maybeOption(arg)) { - const option = this.optionFor(arg); + const option = this._findOption(arg); // recognised option, call listener to assign value with possible custom processing if (option) { if (option.required) { @@ -950,7 +962,7 @@ Command.prototype.parseOptions = function(argv) { // Look for combo options following single dash, eat first one if known. if (arg.length > 2 && arg[0] === '-' && arg[1] !== '-') { - const option = this.optionFor(`-${arg[1]}`); + const option = this._findOption(`-${arg[1]}`); if (option) { if (option.required || option.optional) { // option with value following in same argument @@ -967,7 +979,7 @@ Command.prototype.parseOptions = function(argv) { // Look for known long flag with value, like --foo=bar if (/^--[^=]+=/.test(arg)) { const index = arg.indexOf('='); - const option = this.optionFor(arg.slice(0, index)); + const option = this._findOption(arg.slice(0, index)); if (option && (option.required || option.optional)) { this.emit(`option:${option.name()}`, arg.slice(index + 1)); continue; @@ -1067,6 +1079,19 @@ Command.prototype.unknownOption = function(flag) { this._exit(1, 'commander.unknownOption', message); }; +/** + * Unknown command. + * + * @param {String} flag + * @api private + */ + +Command.prototype.unknownCommand = function() { + const message = `error: unknown command '${this.args[0]}'`; + console.error(message); + this._exit(1, 'commander.unknownCommand', message); +}; + /** * Variadic argument with `name` is not the last argument as required. * @@ -1194,7 +1219,7 @@ Command.prototype.name = function(str) { */ Command.prototype.prepareCommands = function() { - return this.commands.filter(function(cmd) { + const commandDetails = this.commands.filter(function(cmd) { return !cmd._noHelp; }).map(function(cmd) { var args = cmd._args.map(function(arg) { @@ -1209,6 +1234,11 @@ Command.prototype.prepareCommands = function() { cmd._description ]; }); + + if (this._lazyHasImplicitHelpCommand()) { + commandDetails.push([this._helpCommandnameAndArgs, this._helpCommandDescription]); + } + return commandDetails; }; /** @@ -1310,7 +1340,7 @@ Command.prototype.optionHelp = function() { */ Command.prototype.commandHelp = function() { - if (!this.commands.length) return ''; + if (!this.commands.length && !this._lazyHasImplicitHelpCommand()) return ''; var commands = this.prepareCommands(); var width = this.padWidth(); @@ -1447,6 +1477,18 @@ Command.prototype.help = function(cb) { this._exit(process.exitCode || 0, 'commander.help', '(outputHelp)'); }; +/** + * Output help information and exit. Display for error situations. + * + * @api private + */ + +Command.prototype._helpAndError = function() { + this.outputHelp(); + // message: do not have all displayed text available so only passing placeholder. + this._exit(1, 'commander.help', '(outputHelp)'); +}; + /** * Camel-case the given `flag` * @@ -1522,19 +1564,16 @@ function optionalWrap(str, width, indent) { * Output help information if help flags specified * * @param {Command} cmd - command to output help for - * @param {Array} options - array of options to search for -h or --help + * @param {Array} args - array of options to search for help flags * @api private */ -function outputHelpIfRequested(cmd, options) { - options = options || []; - - for (var i = 0; i < options.length; i++) { - if (options[i] === cmd._helpLongFlag || options[i] === cmd._helpShortFlag) { - cmd.outputHelp(); - // (Do not have all displayed text available so only passing placeholder.) - cmd._exit(0, 'commander.helpDisplayed', '(outputHelp)'); - } +function outputHelpIfRequested(cmd, args) { + const helpOption = args.find(arg => arg === cmd._helpLongFlag || arg === cmd._helpShortFlag); + if (helpOption) { + cmd.outputHelp(); + // (Do not have all displayed text available so only passing placeholder.) + cmd._exit(0, 'commander.helpDisplayed', '(outputHelp)'); } } @@ -1554,17 +1593,6 @@ function humanReadableArgName(arg) { : '[' + nameOutput + ']'; } -// for versions before node v0.8 when there weren't `fs.existsSync` -function exists(file) { - try { - if (fs.statSync(file).isFile()) { - return true; - } - } catch (e) { - return false; - } -} - /** * Scan arguments and increment port number for inspect calls (to avoid conflicts when spawning new command). * diff --git a/tests/args.variadic.test.js b/tests/args.variadic.test.js index aed5e4365..8be9f7a7c 100644 --- a/tests/args.variadic.test.js +++ b/tests/args.variadic.test.js @@ -2,7 +2,7 @@ const commander = require('../'); // Testing variadic arguments. Testing all the action arguments, but could test just variadicArg. -describe('.version', () => { +describe('variadic argument', () => { // Optional. Use internal knowledge to suppress output to keep test output clean. let consoleErrorSpy; diff --git a/tests/command.action.test.js b/tests/command.action.test.js index 4253e0d4b..447028933 100644 --- a/tests/command.action.test.js +++ b/tests/command.action.test.js @@ -46,13 +46,19 @@ test('when .action on program with required argument and argument supplied then }); test('when .action on program with required argument and argument not supplied then action not called', () => { + // Optional. Use internal knowledge to suppress output to keep test output clean. + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { }); const actionMock = jest.fn(); const program = new commander.Command(); program + .exitOverride() .arguments('') .action(actionMock); - program.parse(['node', 'test']); + expect(() => { + program.parse(['node', 'test']); + }).toThrow(); expect(actionMock).not.toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); }); // Changes made in #729 to call program action handler diff --git a/tests/command.addCommand.test.js b/tests/command.addCommand.test.js new file mode 100644 index 000000000..d9773b17e --- /dev/null +++ b/tests/command.addCommand.test.js @@ -0,0 +1,69 @@ +const commander = require('../'); + +// simple sanity check subcommand works +test('when addCommand and specify subcommand then called', () => { + const program = new commander.Command(); + const leafAction = jest.fn(); + const sub = new commander.Command(); + sub + .name('sub') + .action(leafAction); + program + .addCommand(sub); + + program.parse('node test.js sub'.split(' ')); + expect(leafAction).toHaveBeenCalled(); +}); + +test('when commands added using .addCommand and .command then internals similar', () => { + const program1 = new commander.Command(); + program1.command('sub'); + const program2 = new commander.Command(); + program2.addCommand(new commander.Command('sub')); + + // This is a bit of a cheat to check using .addCommand() produces similar result to .command(), + // since .command() is well tested and understood. + + const cmd1 = program1.commands[0]; + const cmd2 = program2.commands[0]; + expect(cmd1.parent).toBe(program1); + expect(cmd2.parent).toBe(program2); + + for (const key of Object.keys(cmd1)) { + switch (typeof cmd1[key]) { + case 'string': + case 'boolean': + case 'number': + case 'undefined': + // Compare vaues in a way that will be readable in test failure message. + expect(`${key}:${cmd1[key]}`).toEqual(`${key}:${cmd2[key]}`); + break; + } + } +}); + +test('when command without name passed to .addCommand then throw', () => { + const program = new commander.Command(); + const cmd = new commander.Command(); + expect(() => { + program.addCommand(cmd); + }).toThrow(); +}); + +test('when executable command without custom executableFile passed to .addCommand then throw', () => { + const program = new commander.Command(); + const cmd = new commander.Command('sub'); + cmd.command('exec', 'exec description'); + expect(() => { + program.addCommand(cmd); + }).toThrow(); +}); + +test('when executable command with custom executableFile passed to .addCommand then ok', () => { + const program = new commander.Command(); + const cmd = new commander.Command('sub'); + cmd.command('exec', 'exec description', { executableFile: 'custom' }); + expect(() => { + program.addCommand(cmd); + }).not.toThrow(); +}); diff --git a/tests/command.allowUnknownOptions.test.js b/tests/command.allowUnknownOptions.test.js index f86ae9f75..da09df7a7 100644 --- a/tests/command.allowUnknownOptions.test.js +++ b/tests/command.allowUnknownOptions.test.js @@ -2,7 +2,7 @@ const commander = require('../'); // Not testing output, just testing whether an error is detected. -describe('.version', () => { +describe('allowUnknownOption', () => { // Optional. Use internal knowledge to suppress output to keep test output clean. let consoleErrorSpy; diff --git a/tests/command.asterisk.test.js b/tests/command.asterisk.test.js index 19d0257f9..cb815d835 100644 --- a/tests/command.asterisk.test.js +++ b/tests/command.asterisk.test.js @@ -11,13 +11,29 @@ const commander = require('../'); // Historical: the event 'command:*' used to also be shared by the action handler on the program. describe(".command('*')", () => { + // Use internal knowledge to suppress output to keep test output clean. + let writeMock; + + beforeAll(() => { + writeMock = jest.spyOn(process.stdout, 'write').mockImplementation(() => { }); + }); + + afterAll(() => { + writeMock.mockRestore(); + }); + test('when no arguments then asterisk action not called', () => { const mockAction = jest.fn(); const program = new commander.Command(); program + .exitOverride() // to catch help .command('*') .action(mockAction); - program.parse(['node', 'test']); + try { + program.parse(['node', 'test']); + } catch (err) { + ; + } expect(mockAction).not.toHaveBeenCalled(); }); diff --git a/tests/command.commandHelp.test.js b/tests/command.commandHelp.test.js index 57428ce53..4f6198c94 100644 --- a/tests/command.commandHelp.test.js +++ b/tests/command.commandHelp.test.js @@ -7,7 +7,7 @@ test('when program has command then appears in commandHelp', () => { program .command('bare'); const commandHelp = program.commandHelp(); - expect(commandHelp).toBe('Commands:\n bare\n'); + expect(commandHelp).toMatch(/Commands:\n +bare\n/); }); test('when program has command with optional arg then appears in commandHelp', () => { @@ -15,5 +15,5 @@ test('when program has command with optional arg then appears in commandHelp', ( program .command('bare [bare-arg]'); const commandHelp = program.commandHelp(); - expect(commandHelp).toEqual('Commands:\n bare [bare-arg]\n'); + expect(commandHelp).toMatch(/Commands:\n +bare \[bare-arg\]\n/); }); diff --git a/tests/command.default.test.js b/tests/command.default.test.js new file mode 100644 index 000000000..619f0f97a --- /dev/null +++ b/tests/command.default.test.js @@ -0,0 +1,61 @@ +const childProcess = require('child_process'); +const path = require('path'); +const commander = require('../'); + +describe('default executable command', () => { + // Calling node explicitly so pm works without file suffix cross-platform. + const pm = path.join(__dirname, './fixtures/pm'); + + test('when default subcommand and no command then call default', (done) => { + childProcess.exec(`node ${pm}`, function(_error, stdout, stderr) { + expect(stdout).toBe('default\n'); + done(); + }); + }); + + test('when default subcommand and unrecognised argument then call default with argument', (done) => { + childProcess.exec(`node ${pm} an-argument`, function(_error, stdout, stderr) { + expect(stdout).toBe("default\n[ 'an-argument' ]\n"); + done(); + }); + }); + + test('when default subcommand and unrecognised option then call default with option', (done) => { + childProcess.exec(`node ${pm} --an-option`, function(_error, stdout, stderr) { + expect(stdout).toBe("default\n[ '--an-option' ]\n"); + done(); + }); + }); +}); + +describe('default action command', () => { + function makeProgram() { + const program = new commander.Command(); + const actionMock = jest.fn(); + program + .command('other'); + program + .command('default', { isDefault: true }) + .allowUnknownOption() + .action(actionMock); + return { program, actionMock }; + } + + test('when default subcommand and no command then call default', () => { + const { program, actionMock } = makeProgram(); + program.parse('node test.js'.split(' ')); + expect(actionMock).toHaveBeenCalled(); + }); + + test('when default subcommand and unrecognised argument then call default with argument', () => { + const { program, actionMock } = makeProgram(); + program.parse('node test.js an-argument'.split(' ')); + expect(actionMock).toHaveBeenCalled(); + }); + + test('when default subcommand and unrecognised option then call default with option', () => { + const { program, actionMock } = makeProgram(); + program.parse('node test.js --an-option'.split(' ')); + expect(actionMock).toHaveBeenCalled(); + }); +}); diff --git a/tests/command.executableSubcommand.default.test.js b/tests/command.executableSubcommand.default.test.js deleted file mode 100644 index c3cf1fe74..000000000 --- a/tests/command.executableSubcommand.default.test.js +++ /dev/null @@ -1,27 +0,0 @@ -const childProcess = require('child_process'); -const path = require('path'); - -// Calling node explicitly so pm works without file suffix cross-platform. - -const pm = path.join(__dirname, './fixtures/pm'); - -test('when default subcommand and no command then call default', (done) => { - childProcess.exec(`node ${pm}`, function(_error, stdout, stderr) { - expect(stdout).toBe('default\n'); - done(); - }); -}); - -test('when default subcommand and unrecognised argument then call default with argument', (done) => { - childProcess.exec(`node ${pm} an-argument`, function(_error, stdout, stderr) { - expect(stdout).toBe("default\n[ 'an-argument' ]\n"); - done(); - }); -}); - -test('when default subcommand and unrecognised option then call default with option', (done) => { - childProcess.exec(`node ${pm} --an-option`, function(_error, stdout, stderr) { - expect(stdout).toBe("default\n[ '--an-option' ]\n"); - done(); - }); -}); diff --git a/tests/command.executableSubcommand.lookup.test.js b/tests/command.executableSubcommand.lookup.test.js index 6f28f8f5d..f18bfb694 100644 --- a/tests/command.executableSubcommand.lookup.test.js +++ b/tests/command.executableSubcommand.lookup.test.js @@ -12,7 +12,7 @@ test('when subcommand file missing then error', (done) => { // Get uncaught thrown error on Windows expect(stderr.length).toBeGreaterThan(0); } else { - expect(stderr).toBe('error: pm-list(1) does not exist, try --help\n'); + expect(stderr).toBe('error: pm-list(1) does not exist\n'); } done(); }); @@ -24,7 +24,7 @@ test('when alias subcommand file missing then error', (done) => { // Get uncaught thrown error on Windows expect(stderr.length).toBeGreaterThan(0); } else { - expect(stderr).toBe('error: pm-list(1) does not exist, try --help\n'); + expect(stderr).toBe('error: pm-list(1) does not exist\n'); } done(); }); diff --git a/tests/command.exitOverride.test.js b/tests/command.exitOverride.test.js index db5fd148c..fd75d5e3f 100644 --- a/tests/command.exitOverride.test.js +++ b/tests/command.exitOverride.test.js @@ -51,6 +51,23 @@ describe('.exitOverride and error details', () => { expectCommanderError(caughtErr, 1, 'commander.unknownOption', "error: unknown option '-m'"); }); + test('when specify unknown command then throw CommanderError', () => { + const program = new commander.Command(); + program + .exitOverride() + .command('sub'); + + let caughtErr; + try { + program.parse(['node', 'test', 'oops']); + } catch (err) { + caughtErr = err; + } + + expect(consoleErrorSpy).toHaveBeenCalled(); + expectCommanderError(caughtErr, 1, 'commander.unknownCommand', "error: unknown command 'oops'"); + }); + // Same error as above, but with custom handler. test('when supply custom handler then throw custom error', () => { const customError = new commander.CommanderError(123, 'custom-code', 'custom-message'); @@ -135,9 +152,8 @@ describe('.exitOverride and error details', () => { caughtErr = err; } - // This is effectively treated as a deliberate request for help, rather than an error. expect(writeSpy).toHaveBeenCalled(); - expectCommanderError(caughtErr, 0, 'commander.helpDisplayed', '(outputHelp)'); + expectCommanderError(caughtErr, 1, 'commander.help', '(outputHelp)'); }); test('when specify --version then throw CommanderError', () => { diff --git a/tests/command.help.test.js b/tests/command.help.test.js index b61fbc016..5ed1daa42 100644 --- a/tests/command.help.test.js +++ b/tests/command.help.test.js @@ -14,13 +14,14 @@ test('when call helpInformation for program then help format is as expected (usa `Usage: test [options] [command] Options: - -h, --help output usage information + -h, --help display help for command Commands: my-command + help [command] display help for command `; - program.parse(['node', 'test']); + program.name('test'); const helpInformation = program.helpInformation(); expect(helpInformation).toBe(expectedHelpInformation); }); @@ -30,7 +31,7 @@ test('when use .description for command then help incudes description', () => { program .command('simple-command') .description('custom-description'); - program.parse(['node', 'test']); + program._help = 'test'; const helpInformation = program.helpInformation(); expect(helpInformation).toMatch(/simple-command +custom-description/); }); diff --git a/tests/command.helpCommand.test.js b/tests/command.helpCommand.test.js new file mode 100644 index 000000000..be2d5a5b3 --- /dev/null +++ b/tests/command.helpCommand.test.js @@ -0,0 +1,100 @@ +const commander = require('../'); + +describe('help command listed in helpInformation', () => { + test('when program has no subcommands then no automatic help command', () => { + const program = new commander.Command(); + const helpInformation = program.helpInformation(); + expect(helpInformation).not.toMatch(/help \[command\]/); + }); + + test('when program has no subcommands and add help command then has help command', () => { + const program = new commander.Command(); + program.addHelpCommand(true); + const helpInformation = program.helpInformation(); + expect(helpInformation).toMatch(/help \[command\]/); + }); + + test('when program has subcommands then has automatic help command', () => { + const program = new commander.Command(); + program.command('foo'); + const helpInformation = program.helpInformation(); + expect(helpInformation).toMatch(/help \[command\]/); + }); + + test('when program has subcommands and suppress help command then no help command', () => { + const program = new commander.Command(); + program.addHelpCommand(false); + program.command('foo'); + const helpInformation = program.helpInformation(); + expect(helpInformation).not.toMatch(/help \[command\]/); + }); + + test('when add custom help command then custom help command', () => { + const program = new commander.Command(); + program.addHelpCommand('help command', 'help description'); + const helpInformation = program.helpInformation(); + expect(helpInformation).toMatch(/help command +help description/); + }); +}); + +describe('help command processed on correct command', () => { + // Use internal knowledge to suppress output to keep test output clean. + let consoleErrorSpy; + let writeSpy; + + beforeAll(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { }); + writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => { }); + }); + + afterEach(() => { + consoleErrorSpy.mockClear(); + writeSpy.mockClear(); + }); + + afterAll(() => { + consoleErrorSpy.mockRestore(); + writeSpy.mockRestore(); + }); + + test('when "program help" then program', () => { + const program = new commander.Command(); + program.exitOverride(); + program.command('sub1'); + program.exitOverride(() => { throw new Error('program'); }); + expect(() => { + program.parse('node test.js help'.split(' ')); + }).toThrow('program'); + }); + + test('when "program help sub1" then sub1', () => { + const program = new commander.Command(); + program.exitOverride(); + const sub1 = program.command('sub1'); + sub1.exitOverride(() => { throw new Error('sub1'); }); + expect(() => { + program.parse('node test.js help sub1'.split(' ')); + }).toThrow('sub1'); + }); + + test('when "program sub1 help sub2" then sub2', () => { + const program = new commander.Command(); + program.exitOverride(); + const sub1 = program.command('sub1'); + const sub2 = sub1.command('sub2'); + sub2.exitOverride(() => { throw new Error('sub2'); }); + expect(() => { + program.parse('node test.js sub1 help sub2'.split(' ')); + }).toThrow('sub2'); + }); + + test('when default command and "program help" then program', () => { + const program = new commander.Command(); + program.exitOverride(); + program.command('sub1', { isDefault: true }); + program.exitOverride(() => { throw new Error('program'); }); + expect(() => { + program.parse('node test.js help'.split(' ')); + }).toThrow('program'); + }); +}); diff --git a/tests/command.nested.test.js b/tests/command.nested.test.js new file mode 100644 index 000000000..9445061b2 --- /dev/null +++ b/tests/command.nested.test.js @@ -0,0 +1,12 @@ +const commander = require('../'); + +test('when call nested subcommand then runs', () => { + const program = new commander.Command(); + const leafAction = jest.fn(); + program + .command('sub1') + .command('sub2') + .action(leafAction); + program.parse('node test.js sub1 sub2'.split(' ')); + expect(leafAction).toHaveBeenCalled(); +}); diff --git a/tests/command.unknownCommand.test.js b/tests/command.unknownCommand.test.js new file mode 100644 index 000000000..65e32ee49 --- /dev/null +++ b/tests/command.unknownCommand.test.js @@ -0,0 +1,53 @@ +const commander = require('../'); + +describe('unknownOption', () => { + // Optional. Use internal knowledge to suppress output to keep test output clean. + let consoleErrorSpy; + + beforeAll(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { }); + }); + + afterEach(() => { + consoleErrorSpy.mockClear(); + }); + + afterAll(() => { + consoleErrorSpy.mockRestore(); + }); + + test('when unknown argument in simple program then no error', () => { + const program = new commander.Command(); + program.parse('node test.js unknown'.split(' ')); + }); + + test('when unknown command but action handler then no error', () => { + const program = new commander.Command(); + program.command('sub'); + program + .action(() => { }); + program.parse('node test.js unknown'.split(' ')); + }); + + test('when unknown command but listener then no error', () => { + const program = new commander.Command(); + program.command('sub'); + program + .on('command:*', () => { }); + program.parse('node test.js unknown'.split(' ')); + }); + + test('when unknown command then error', () => { + const program = new commander.Command(); + program + .exitOverride() + .command('sub'); + let caughtErr; + try { + program.parse('node test.js unknown'.split(' ')); + } catch (err) { + caughtErr = err; + } + expect(caughtErr.code).toBe('commander.unknownCommand'); + }); +}); diff --git a/tests/command.unknownOption.test.js b/tests/command.unknownOption.test.js index a4f571f68..18dbb6896 100644 --- a/tests/command.unknownOption.test.js +++ b/tests/command.unknownOption.test.js @@ -2,7 +2,7 @@ const commander = require('../'); // Checking for detection of unknown options, including regression tests for some past issues. -describe('.version', () => { +describe('unknownOption', () => { // Optional. Use internal knowledge to suppress output to keep test output clean. let consoleErrorSpy; diff --git a/tests/command.usage.test.js b/tests/command.usage.test.js index 7c3616813..6140ec93b 100644 --- a/tests/command.usage.test.js +++ b/tests/command.usage.test.js @@ -3,7 +3,7 @@ const commander = require('../'); test('when default usage and check program help then starts with default usage', () => { const program = new commander.Command(); - program.parse(['node', 'test']); + program.name('test'); const helpInformation = program.helpInformation(); expect(helpInformation).toMatch(new RegExp('^Usage: test \\[options\\]')); @@ -15,7 +15,7 @@ test('when custom usage and check program help then starts with custom usage', ( program .usage(myUsage); - program.parse(['node', 'test']); + program.name('test'); const helpInformation = program.helpInformation(); expect(helpInformation).toMatch(new RegExp(`^Usage: test ${myUsage}`)); @@ -26,7 +26,7 @@ test('when default usage and check subcommand help then starts with default usag const subCommand = program .command('info'); - program.parse(['node', 'test']); + program.name('test'); const helpInformation = subCommand.helpInformation(); expect(helpInformation).toMatch(new RegExp('^Usage: test info \\[options\\]')); @@ -39,7 +39,7 @@ test('when custom usage and check subcommand help then starts with custom usage .command('info') .usage(myUsage); - program.parse(['node', 'test']); + program.name('test'); const helpInformation = subCommand.helpInformation(); expect(helpInformation).toMatch(new RegExp(`^Usage: test info ${myUsage}`)); diff --git a/tests/helpwrap.test.js b/tests/helpwrap.test.js index 1cfbd55df..22f296693 100644 --- a/tests/helpwrap.test.js +++ b/tests/helpwrap.test.js @@ -1,6 +1,7 @@ const commander = require('../'); // Test auto wrap and indent with some manual strings. +// Fragile tests with complete help output. test('when long option description then wrap and indent', () => { const oldColumns = process.stdout.columns; @@ -16,7 +17,7 @@ Options: -x -extra-long-option-switch kjsahdkajshkahd kajhsd akhds kashd kajhs dkha dkh aksd ka dkha kdh kasd ka kahs dkh sdkh askdh aksd kashdk ahsd kahs dkha skdh - -h, --help output usage information + -h, --help display help for command `; expect(program.helpInformation()).toBe(expectedOutput); @@ -36,7 +37,7 @@ test('when long option description and default then wrap and indent', () => { Options: -x -extra-long-option kjsahdkajshkahd kajhsd akhds (default: "aaa bbb ccc ddd eee fff ggg") - -h, --help output usage information + -h, --help display help for command `; expect(program.helpInformation()).toBe(expectedOutput); @@ -56,11 +57,12 @@ test('when long command description then wrap and indent', () => { Options: -x -extra-long-option-switch x - -h, --help output usage information + -h, --help display help for command Commands: alpha Lorem mollit quis dolor ex do eu quis ad insa a commodo esse. + help [command] display help for command `; expect(program.helpInformation()).toBe(expectedOutput); @@ -80,10 +82,11 @@ test('when not enough room then help not wrapped', () => { `Usage: [options] [command] Options: - -h, --help output usage information + -h, --help display help for command Commands: 1234567801234567890x ${commandDescription} + help [command] display help for command `; expect(program.helpInformation()).toBe(expectedOutput); @@ -112,7 +115,7 @@ Options: Time can also be specified using special values: "dawn" - From night to sunrise. - -h, --help output usage information + -h, --help display help for command `; expect(program.helpInformation()).toBe(expectedOutput); diff --git a/tests/options.mandatory.test.js b/tests/options.mandatory.test.js index 87b725473..fb80d8d03 100644 --- a/tests/options.mandatory.test.js +++ b/tests/options.mandatory.test.js @@ -219,9 +219,11 @@ describe('required command option with mandatory value not specified', () => { .command('sub') .requiredOption('--subby ', 'description') .action((cmd) => {}); + program + .command('sub2'); expect(() => { - program.parse(['node', 'test']); + program.parse(['node', 'test', 'sub2']); }).not.toThrow(); }); }); diff --git a/typings/commander-tests.ts b/typings/commander-tests.ts index 7661bcf0e..a278c6c28 100644 --- a/typings/commander-tests.ts +++ b/typings/commander-tests.ts @@ -73,9 +73,7 @@ program .option('-b, --bar', 'enable some bar') .option('-B, --baz', 'enable some baz'); -// must be before .parse() since -// node's emit() is immediate - +// must be before .parse() program.on('--help', () => { console.log(' Examples:'); console.log(''); @@ -113,6 +111,9 @@ program .command("name1", "description") .command("name2", "description", { isDefault:true }) +const preparedCommand = new program.Command('prepared'); +program.addCommand(preparedCommand); + program .exitOverride(); diff --git a/typings/index.d.ts b/typings/index.d.ts index d700e8954..5ac15b8d6 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -80,20 +80,18 @@ declare namespace commander { command(nameAndArgs: string, description: string, opts?: commander.CommandOptions): Command; /** - * Define argument syntax for the top-level command. - * - * @returns Command for chaining + * Add a prepared subcommand. + * + * @returns parent command for chaining */ - arguments(desc: string): Command; + addCommand(cmd: Command): Command; /** - * Parse expected `args`. - * - * For example `["[type]"]` becomes `[{ required: false, name: 'type' }]`. + * Define argument syntax for the top-level command. * * @returns Command for chaining */ - parseExpectedArgs(args: string[]): Command; + arguments(desc: string): Command; /** * Register callback to use as replacement for calling process.exit.