From ade29b864abecaa8c4f8dcc3493f5eb24fb73d84 Mon Sep 17 00:00:00 2001 From: "Benjamin E. Coe" Date: Fri, 26 Mar 2021 15:34:53 -0700 Subject: [PATCH] feat: adds support for async builder (#1888) Fixes: #1042 BREAKING CHANGE: providing an async builder will now cause yargs to return async result BREAKING CHANGE: .positional() now allowed at root level of yargs. --- docs/advanced.md | 2 +- docs/api.md | 7 +- lib/command.ts | 656 ++++++++++++++++++++++------------------- lib/completion.ts | 2 +- lib/yargs-factory.ts | 160 +++++----- test/command.cjs | 81 +++++ test/helpers/utils.cjs | 11 +- test/middleware.cjs | 2 +- test/usage.cjs | 46 ++- test/yargs.cjs | 55 ++-- 10 files changed, 613 insertions(+), 409 deletions(-) diff --git a/docs/advanced.md b/docs/advanced.md index e32f475c9..a2cce2518 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -557,7 +557,7 @@ var argv = require('yargs/yargs')(process.argv.slice(2)) ## Using Yargs with Async/await -If you use async middleware or async handlers for commands, `yargs.parse` and +If you use async middleware or async builders/handlers for commands, `yargs.parse` and `yargs.argv` will return a `Promise`. When you `await` this promise the parsed arguments object will be returned after the handler completes: diff --git a/docs/api.md b/docs/api.md index 72231584e..9bad6fcac 100644 --- a/docs/api.md +++ b/docs/api.md @@ -293,11 +293,14 @@ yargs ``` `builder` can also be a function. This function is executed -with a `yargs` instance, and can be used to provide _advanced_ command specific help: +with a `yargs` instance, which can be used to provide command specific +configuration, and the boolean `helpOrVersionSet`, which indicates whether or +not the `--help` or `--version` flag was set prior to calling the +builder. ```js yargs - .command('get', 'make a get HTTP request', function (yargs) { + .command('get', 'make a get HTTP request', function (yargs, helpOrVersionSet) { return yargs.option('url', { alias: 'u', default: 'http://yargs.js.org/' diff --git a/lib/command.ts b/lib/command.ts index 5434c1149..6dde6c6bc 100644 --- a/lib/command.ts +++ b/lib/command.ts @@ -30,29 +30,67 @@ 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 -// arguments. -export function command( - yargs: YargsInstance, - usage: UsageInstance, - validation: ValidationInstance, - globalMiddleware: GlobalMiddleware, - shim: PlatformShim -) { - const self: CommandInstance = {} as CommandInstance; - let handlers: Dictionary = {}; - let aliasMap: Dictionary = {}; - let defaultCommand: CommandHandler | undefined; - - self.addHandler = function addHandler( - cmd: DefinitionOrCommandName | [DefinitionOrCommandName, ...string[]], - description?: string | false, - builder?: CommandBuilder, +export class CommandInstance { + shim: PlatformShim; + requireCache: Set = new Set(); + handlers: Dictionary = {}; + aliasMap: Dictionary = {}; + defaultCommand?: CommandHandler; + usage: UsageInstance; + globalMiddleware: GlobalMiddleware; + validation: ValidationInstance; + // Used to cache state from prior invocations of commands. + // This allows the parser to push and pop state when running + // a nested command: + frozens: FrozenCommandInstance[] = []; + constructor( + usage: UsageInstance, + validation: ValidationInstance, + globalMiddleware: GlobalMiddleware, + shim: PlatformShim + ) { + this.shim = shim; + this.usage = usage; + this.globalMiddleware = globalMiddleware; + this.validation = validation; + } + addDirectory( + dir: string, + req: Function, + callerFile: string, + opts?: RequireDirectoryOptions + ): void { + opts = opts || {}; + + // disable recursion to support nested directories of subcommands + if (typeof opts.recurse !== 'boolean') opts.recurse = false; + // exclude 'json', 'coffee' from require-directory defaults + if (!Array.isArray(opts.extensions)) opts.extensions = ['js']; + // allow consumer to define their own visitor function + const parentVisit = + typeof opts.visit === 'function' ? opts.visit : (o: any) => o; + // call addHandler via visitor function + opts.visit = (obj, joined, filename) => { + const visited = parentVisit(obj, joined, filename); + // allow consumer to skip modules with their own visitor + if (visited) { + // check for cyclic reference: + if (this.requireCache.has(joined)) return visited; + else this.requireCache.add(joined); + this.addHandler(visited); + } + return visited; + }; + this.shim.requireDirectory({require: req, filename: callerFile}, dir, opts); + } + addHandler( + cmd: string | CommandHandlerDefinition | DefinitionOrCommandName[], + description?: CommandHandler['description'], + builder?: CommandBuilderDefinition | CommandBuilder, handler?: CommandHandlerCallback, commandMiddleware?: Middleware[], deprecated?: boolean - ) { + ): void { let aliases: string[] = []; const middlewares = commandMiddlewareFactory(commandMiddleware); handler = handler || (() => {}); @@ -64,19 +102,19 @@ export function command( [cmd, ...aliases] = cmd; } else { for (const command of cmd) { - self.addHandler(command); + this.addHandler(command); } } } else if (isCommandHandlerDefinition(cmd)) { let command = Array.isArray(cmd.command) || typeof cmd.command === 'string' ? cmd.command - : moduleName(cmd); + : this.moduleName(cmd); if (cmd.aliases) command = ([] as string[]).concat(command).concat(cmd.aliases); - self.addHandler( + this.addHandler( command, - extractDesc(cmd), + this.extractDesc(cmd), cmd.builder, cmd.handler, cmd.middlewares, @@ -85,7 +123,7 @@ export function command( return; } else if (isCommandBuilderDefinition(builder)) { // Allow a module to be provided as builder, rather than function: - self.addHandler( + this.addHandler( [cmd].concat(aliases), description, builder.builder, @@ -127,188 +165,214 @@ export function command( // populate aliasMap aliases.forEach(alias => { - aliasMap[alias] = parsedCommand.cmd; + this.aliasMap[alias] = parsedCommand.cmd; }); if (description !== false) { - usage.command(cmd, description, isDefault, aliases, deprecated); + this.usage.command(cmd, description, isDefault, aliases, deprecated); } - handlers[parsedCommand.cmd] = { + this.handlers[parsedCommand.cmd] = { original: cmd, description, handler, - builder: builder || {}, + builder: (builder as CommandBuilder) || {}, middlewares, deprecated, demanded: parsedCommand.demanded, optional: parsedCommand.optional, }; - if (isDefault) defaultCommand = handlers[parsedCommand.cmd]; + if (isDefault) this.defaultCommand = this.handlers[parsedCommand.cmd]; } - }; - - self.addDirectory = function addDirectory( - dir, - context, - req, - callerFile, - opts - ) { - opts = opts || {}; - // disable recursion to support nested directories of subcommands - if (typeof opts.recurse !== 'boolean') opts.recurse = false; - // exclude 'json', 'coffee' from require-directory defaults - if (!Array.isArray(opts.extensions)) opts.extensions = ['js']; - // allow consumer to define their own visitor function - const parentVisit = - typeof opts.visit === 'function' ? opts.visit : (o: any) => o; - // call addHandler via visitor function - opts.visit = function visit(obj, joined, filename) { - const visited = parentVisit(obj, joined, filename); - // allow consumer to skip modules with their own visitor - if (visited) { - // check for cyclic reference - // each command file path should only be seen once per execution - if (~context.files.indexOf(joined)) return visited; - // keep track of visited files in context.files - context.files.push(joined); - self.addHandler(visited); - } - return visited; - }; - shim.requireDirectory({require: req, filename: callerFile}, dir, opts); - }; - - // lookup module object from require()d command and derive name - // if module was not require()d and no name given, throw error - function moduleName(obj: CommandHandlerDefinition) { - const mod = whichModule(obj); - if (!mod) - throw new Error(`No command name given for module: ${shim.inspect(obj)}`); - return commandFromFilename(mod.filename); } - - // derive command name from filename - function commandFromFilename(filename: string) { - return shim.path.basename(filename, shim.path.extname(filename)); + getCommandHandlers(): Dictionary { + return this.handlers; } - - function extractDesc({ - describe, - description, - desc, - }: CommandHandlerDefinition) { - for (const test of [describe, description, desc]) { - if (typeof test === 'string' || test === false) return test; - assertNotStrictEqual(test, true as const, shim); - } - return false; + getCommands(): string[] { + return Object.keys(this.handlers).concat(Object.keys(this.aliasMap)); } - - self.getCommands = () => Object.keys(handlers).concat(Object.keys(aliasMap)); - - self.getCommandHandlers = () => handlers; - - self.hasDefaultCommand = () => !!defaultCommand; - - self.runCommand = function runCommand( - command, - yargs, - parsed, - commandIndex = 0, - helpOnly = false - ) { - let aliases = parsed.aliases; + hasDefaultCommand(): boolean { + return !!this.defaultCommand; + } + runCommand( + command: string | null, + yargs: YargsInstance, + parsed: DetailedArguments, + commandIndex: number, + helpOnly: boolean, + helpOrVersionSet: boolean + ): Arguments | Promise { const commandHandler = - handlers[command!] || handlers[aliasMap[command!]] || defaultCommand; + this.handlers[command!] || + this.handlers[this.aliasMap[command!]] || + this.defaultCommand; const currentContext = yargs.getContext(); - let numFiles = currentContext.files.length; const parentCommands = currentContext.commands.slice(); - - // what does yargs look like after the builder is run? - let innerArgv: Arguments | Promise = parsed.argv; - let positionalMap: Dictionary = {}; if (command) { currentContext.commands.push(command); currentContext.fullCommands.push(commandHandler.original); } + const builderResult = this.applyBuilderUpdateUsageAndParse( + command, + commandHandler, + yargs, + parsed.aliases, + parentCommands, + commandIndex, + helpOnly, + helpOrVersionSet + ); + if (isPromise(builderResult)) { + return builderResult.then(result => { + return this.applyMiddlewareAndGetResult( + command, + commandHandler, + result.innerArgv, + currentContext, + helpOnly, + result.aliases, + yargs + ); + }); + } else { + return this.applyMiddlewareAndGetResult( + command, + commandHandler, + builderResult.innerArgv, + currentContext, + helpOnly, + builderResult.aliases, + yargs + ); + } + } + private applyBuilderUpdateUsageAndParse( + command: string | null, + commandHandler: CommandHandler, + yargs: YargsInstance, + aliases: Dictionary, + parentCommands: string[], + commandIndex: number, + helpOnly: boolean, + helpOrVersionSet: boolean + ): + | {aliases: Dictionary; innerArgv: Arguments} + | Promise<{aliases: Dictionary; innerArgv: Arguments}> { const builder = commandHandler.builder; + let innerYargs: YargsInstance = yargs; if (isCommandBuilderCallback(builder)) { - // a function can be provided, which builds + // A function can be provided, which builds // up a yargs chain and possibly returns it. - const builderOutput = builder(yargs.reset(parsed.aliases)); - const innerYargs = isYargsInstance(builderOutput) ? builderOutput : yargs; - // A null command indicates we are running the default command, - // if this is the case, we should show the root usage instructions - // rather than the usage instructions for the nested default command: - if (!command) innerYargs.getUsageInstance().unfreeze(); - if (shouldUpdateUsage(innerYargs)) { - innerYargs - .getUsageInstance() - .usage( - usageFromParentCommandsCommandHandler( - parentCommands, - commandHandler - ), - commandHandler.description + const builderOutput = builder(yargs.reset(aliases), helpOrVersionSet); + // Support the use-case of async builders: + if (isPromise(builderOutput)) { + return builderOutput.then(output => { + innerYargs = isYargsInstance(output) ? output : yargs; + return this.parseAndUpdateUsage( + command, + commandHandler, + innerYargs, + parentCommands, + commandIndex, + helpOnly ); + }); } - innerArgv = innerYargs._parseArgs( - null, - undefined, - true, - commandIndex, - helpOnly - ); - aliases = (innerYargs.parsed as DetailedArguments).aliases; } else if (isCommandBuilderOptionDefinitions(builder)) { // as a short hand, an object can instead be provided, specifying // the options that a command takes. - const innerYargs = yargs.reset(parsed.aliases); - // A null command indicates we are running the default command, - // if this is the case, we should show the root usage instructions - // rather than the usage instructions for the nested default command: - if (!command) innerYargs.getUsageInstance().unfreeze(); - if (shouldUpdateUsage(innerYargs)) { - innerYargs - .getUsageInstance() - .usage( - usageFromParentCommandsCommandHandler( - parentCommands, - commandHandler - ), - commandHandler.description - ); - } + innerYargs = yargs.reset(aliases); Object.keys(commandHandler.builder).forEach(key => { innerYargs.option(key, builder[key]); }); - innerArgv = innerYargs._parseArgs( - null, - undefined, - true, - commandIndex, - helpOnly - ); - aliases = (innerYargs.parsed as DetailedArguments).aliases; } - - if (!yargs._hasOutput()) { - positionalMap = populatePositionals( - commandHandler, - innerArgv as Arguments, - currentContext - ); + return this.parseAndUpdateUsage( + command, + commandHandler, + innerYargs, + parentCommands, + commandIndex, + helpOnly + ); + } + private parseAndUpdateUsage( + command: string | null, + commandHandler: CommandHandler, + innerYargs: YargsInstance, + parentCommands: string[], + commandIndex: number, + helpOnly: boolean + ): {aliases: Dictionary; innerArgv: Arguments} { + // A null command indicates we are running the default command, + // if this is the case, we should show the root usage instructions + // rather than the usage instructions for the nested default command: + if (!command) innerYargs.getUsageInstance().unfreeze(); + if (this.shouldUpdateUsage(innerYargs)) { + innerYargs + .getUsageInstance() + .usage( + this.usageFromParentCommandsCommandHandler( + parentCommands, + commandHandler + ), + commandHandler.description + ); } - + const innerArgv = innerYargs._parseArgs( + null, + undefined, + true, + commandIndex, + helpOnly + ); + return { + aliases: (innerYargs.parsed as DetailedArguments).aliases, + innerArgv: innerArgv as Arguments, + }; + } + private shouldUpdateUsage(yargs: YargsInstance) { + return ( + !yargs.getUsageInstance().getUsageDisabled() && + yargs.getUsageInstance().getUsage().length === 0 + ); + } + private usageFromParentCommandsCommandHandler( + parentCommands: string[], + commandHandler: CommandHandler + ) { + const c = DEFAULT_MARKER.test(commandHandler.original) + ? commandHandler.original.replace(DEFAULT_MARKER, '').trim() + : commandHandler.original; + const pc = parentCommands.filter(c => { + return !DEFAULT_MARKER.test(c); + }); + pc.push(c); + return `$0 ${pc.join(' ')}`; + } + private applyMiddlewareAndGetResult( + command: string | null, + commandHandler: CommandHandler, + innerArgv: Arguments | Promise, + currentContext: Context, + helpOnly: boolean, + aliases: Dictionary, + yargs: YargsInstance + ): Arguments | Promise { + let positionalMap: Dictionary = {}; // If showHelp() or getHelp() is being run, we should not // execute middleware or handlers (these may perform expensive operations // like creating a DB connection). if (helpOnly) return innerArgv; - - const middlewares = globalMiddleware + if (!yargs._hasOutput()) { + positionalMap = this.populatePositionals( + commandHandler, + innerArgv as Arguments, + currentContext, + yargs + ); + } + const middlewares = this.globalMiddleware .getMiddleware() .slice(0) .concat(commandHandler.middlewares); @@ -365,88 +429,47 @@ export function command( currentContext.commands.pop(); currentContext.fullCommands.pop(); } - numFiles = currentContext.files.length - numFiles; - if (numFiles > 0) currentContext.files.splice(numFiles * -1, numFiles); return innerArgv; - }; - - function shouldUpdateUsage(yargs: YargsInstance) { - return ( - !yargs.getUsageInstance().getUsageDisabled() && - yargs.getUsageInstance().getUsage().length === 0 - ); - } - - function usageFromParentCommandsCommandHandler( - parentCommands: string[], - commandHandler: CommandHandler - ) { - const c = DEFAULT_MARKER.test(commandHandler.original) - ? commandHandler.original.replace(DEFAULT_MARKER, '').trim() - : commandHandler.original; - const pc = parentCommands.filter(c => { - return !DEFAULT_MARKER.test(c); - }); - pc.push(c); - return `$0 ${pc.join(' ')}`; } - - self.runDefaultBuilderOn = function (yargs) { - assertNotStrictEqual(defaultCommand, undefined, shim); - if (shouldUpdateUsage(yargs)) { - // build the root-level command string from the default string. - const commandString = DEFAULT_MARKER.test(defaultCommand.original) - ? defaultCommand.original - : defaultCommand.original.replace(/^[^[\]<>]*/, '$0 '); - yargs.getUsageInstance().usage(commandString, defaultCommand.description); - } - const builder = defaultCommand.builder; - if (isCommandBuilderCallback(builder)) { - builder(yargs); - } else if (!isCommandBuilderDefinition(builder)) { - Object.keys(builder).forEach(key => { - yargs.option(key, builder[key]); - }); - } - }; - // transcribe all positional arguments "command [apple]" // onto argv. - function populatePositionals( + private populatePositionals( commandHandler: CommandHandler, argv: Arguments, - context: Context + context: Context, + yargs: YargsInstance ) { argv._ = argv._.slice(context.commands.length); // nuke the current commands const demanded = commandHandler.demanded.slice(0); const optional = commandHandler.optional.slice(0); const positionalMap: Dictionary = {}; - validation.positionalCount(demanded.length, argv._.length); + this.validation.positionalCount(demanded.length, argv._.length); while (demanded.length) { const demand = demanded.shift()!; - populatePositional(demand, argv, positionalMap); + this.populatePositional(demand, argv, positionalMap); } while (optional.length) { const maybe = optional.shift()!; - populatePositional(maybe, argv, positionalMap); + this.populatePositional(maybe, argv, positionalMap); } argv._ = context.commands.concat(argv._.map(a => '' + a)); - postProcessPositionals( + this.postProcessPositionals( argv, positionalMap, - self.cmdToParseOptions(commandHandler.original) + this.cmdToParseOptions(commandHandler.original), + yargs ); return positionalMap; } - function populatePositional( + private populatePositional( positional: Positional, argv: Arguments, positionalMap: Dictionary @@ -459,12 +482,46 @@ export function command( } } + // Based on parsing variadic markers '...', demand syntax '', etc., + // populate parser hints: + public cmdToParseOptions(cmdString: string): Positionals { + const parseOptions: Positionals = { + array: [], + default: {}, + alias: {}, + demand: {}, + }; + + const parsed = parseCommand(cmdString); + parsed.demanded.forEach(d => { + const [cmd, ...aliases] = d.cmd; + if (d.variadic) { + parseOptions.array.push(cmd); + parseOptions.default[cmd] = []; + } + parseOptions.alias[cmd] = aliases; + parseOptions.demand[cmd] = true; + }); + + parsed.optional.forEach(o => { + const [cmd, ...aliases] = o.cmd; + if (o.variadic) { + parseOptions.array.push(cmd); + parseOptions.default[cmd] = []; + } + parseOptions.alias[cmd] = aliases; + }); + + return parseOptions; + } + // we run yargs-parser against the positional arguments // applying the same parsing logic used for flags. - function postProcessPositionals( + private postProcessPositionals( argv: Arguments, positionalMap: Dictionary, - parseOptions: Positionals + parseOptions: Positionals, + yargs: YargsInstance ) { // combine the parsing hints we've inferred from the command // string with explicitly configured parsing hints. @@ -495,7 +552,7 @@ export function command( 'populate--': false, }); - const parsed = shim.Parser.detailed( + const parsed = this.shim.Parser.detailed( unparsed, Object.assign({}, options, { configuration: config, @@ -522,98 +579,83 @@ export function command( }); } } + runDefaultBuilderOn(yargs: YargsInstance): void { + if (!this.defaultCommand) return; + if (this.shouldUpdateUsage(yargs)) { + // build the root-level command string from the default string. + const commandString = DEFAULT_MARKER.test(this.defaultCommand.original) + ? this.defaultCommand.original + : this.defaultCommand.original.replace(/^[^[\]<>]*/, '$0 '); + yargs + .getUsageInstance() + .usage(commandString, this.defaultCommand.description); + } + const builder = this.defaultCommand.builder; + if (isCommandBuilderCallback(builder)) { + builder(yargs, true); + } else if (!isCommandBuilderDefinition(builder)) { + Object.keys(builder).forEach(key => { + yargs.option(key, builder[key]); + }); + } + } + // lookup module object from require()d command and derive name + // if module was not require()d and no name given, throw error + private moduleName(obj: CommandHandlerDefinition) { + const mod = whichModule(obj); + if (!mod) + throw new Error( + `No command name given for module: ${this.shim.inspect(obj)}` + ); + return this.commandFromFilename(mod.filename); + } - self.cmdToParseOptions = function (cmdString) { - const parseOptions: Positionals = { - array: [], - default: {}, - alias: {}, - demand: {}, - }; - - const parsed = parseCommand(cmdString); - parsed.demanded.forEach(d => { - const [cmd, ...aliases] = d.cmd; - if (d.variadic) { - parseOptions.array.push(cmd); - parseOptions.default[cmd] = []; - } - parseOptions.alias[cmd] = aliases; - parseOptions.demand[cmd] = true; - }); - - parsed.optional.forEach(o => { - const [cmd, ...aliases] = o.cmd; - if (o.variadic) { - parseOptions.array.push(cmd); - parseOptions.default[cmd] = []; - } - parseOptions.alias[cmd] = aliases; - }); + private commandFromFilename(filename: string) { + return this.shim.path.basename(filename, this.shim.path.extname(filename)); + } - return parseOptions; - }; - - self.reset = () => { - handlers = {}; - aliasMap = {}; - defaultCommand = undefined; - return self; - }; - - // used by yargs.parse() to freeze - // the state of commands such that - // we can apply .parse() multiple times - // with the same yargs instance. - const frozens: FrozenCommandInstance[] = []; - self.freeze = () => { - frozens.push({ - handlers, - aliasMap, - defaultCommand, + private extractDesc({describe, description, desc}: CommandHandlerDefinition) { + for (const test of [describe, description, desc]) { + if (typeof test === 'string' || test === false) return test; + assertNotStrictEqual(test, true as const, this.shim); + } + return false; + } + // Push/pop the current command configuration: + freeze() { + this.frozens.push({ + handlers: this.handlers, + aliasMap: this.aliasMap, + defaultCommand: this.defaultCommand, }); - }; - self.unfreeze = () => { - const frozen = frozens.pop(); - assertNotStrictEqual(frozen, undefined, shim); - ({handlers, aliasMap, defaultCommand} = frozen); - }; - - return self; + } + unfreeze() { + const frozen = this.frozens.pop(); + assertNotStrictEqual(frozen, undefined, this.shim); + ({ + handlers: this.handlers, + aliasMap: this.aliasMap, + defaultCommand: this.defaultCommand, + } = frozen); + } + // Revert to initial state: + reset(): CommandInstance { + this.handlers = {}; + this.aliasMap = {}; + this.defaultCommand = undefined; + this.requireCache = new Set(); + return this; + } } -/** Instance of the command module. */ -export interface CommandInstance { - addDirectory( - dir: string, - context: Context, - req: Function, - callerFile: string, - opts?: RequireDirectoryOptions - ): void; - addHandler( - cmd: string | CommandHandlerDefinition | DefinitionOrCommandName[], - description?: CommandHandler['description'], - builder?: CommandBuilderDefinition | CommandBuilder, - handler?: CommandHandlerCallback, - commandMiddleware?: Middleware[], - deprecated?: boolean - ): void; - cmdToParseOptions(cmdString: string): Positionals; - freeze(): void; - getCommandHandlers(): Dictionary; - getCommands(): string[]; - hasDefaultCommand(): boolean; - reset(): CommandInstance; - runCommand( - command: string | null, - yargs: YargsInstance, - parsed: DetailedArguments, - commandIndex: number, - helpOnly: boolean - ): Arguments | Promise; - runDefaultBuilderOn(yargs: YargsInstance): void; - unfreeze(): void; +// Adds support to yargs for lazy loading a hierarchy of commands: +export function command( + usage: UsageInstance, + validation: ValidationInstance, + globalMiddleware: GlobalMiddleware, + shim: PlatformShim +) { + return new CommandInstance(usage, validation, globalMiddleware, shim); } export interface CommandHandlerDefinition @@ -668,7 +710,7 @@ export type CommandBuilder = | Dictionary; interface CommandBuilderCallback { - (y: YargsInstance): YargsInstance | void; + (y: YargsInstance, helpOrVersionSet: boolean): YargsInstance | void; } function isCommandAndAliases( diff --git a/lib/completion.ts b/lib/completion.ts index 19e7ab7c4..1f9cbeff6 100644 --- a/lib/completion.ts +++ b/lib/completion.ts @@ -58,7 +58,7 @@ export class Completion implements CompletionInstance { const builder = handlers[args[i]].builder; if (isCommandBuilderCallback(builder)) { const y = this.yargs.reset(); - builder(y); + builder(y, true); return y.argv; } } diff --git a/lib/yargs-factory.ts b/lib/yargs-factory.ts index 1c12740a8..50f310560 100644 --- a/lib/yargs-factory.ts +++ b/lib/yargs-factory.ts @@ -113,9 +113,9 @@ function Yargs( .replace(`${shim.path.dirname(shim.process.execPath())}/`, ''); } - // use context object to keep track of resets, subcommand execution, etc - // submodules should modify and check the state of context as necessary - const context = {resets: -1, commands: [], fullCommands: [], files: []}; + // use context object to keep track of resets, subcommand execution, etc., + // submodules should modify and check the state of context as necessary: + const context = {commands: [], fullCommands: []}; self.getContext = () => context; let hasOutput = false; @@ -166,7 +166,6 @@ function Yargs( // by this action. let options: Options; self.resetOptions = self.reset = function resetOptions(aliases = {}) { - context.resets++; options = options || {}; // put yargs back into an initial state, this // logic is used to build a nested command @@ -244,7 +243,7 @@ function Yargs( : Validation(self, usage, y18n, shim); command = command ? command.reset() - : Command(self, usage, validation, globalMiddleware, shim); + : Command(usage, validation, globalMiddleware, shim); if (!completion) completion = Completion(self, usage, command, shim); globalMiddleware.reset(); @@ -729,13 +728,7 @@ function Yargs( self.commandDir = function (dir, opts) { argsert(' [object]', [dir, opts], arguments.length); const req = parentRequire || shim.require; - command.addDirectory( - dir, - self.getContext(), - req, - shim.getCallerFile(), - opts - ); + command.addDirectory(dir, req, shim.getCallerFile(), opts); return self; }; @@ -1195,14 +1188,8 @@ function Yargs( self.positional = function (key, opts) { argsert(' ', [key, opts], arguments.length); - if (context.resets === 0) { - throw new YError( - ".positional() can only be called in a command's builder function" - ); - } - // .positional() only supports a subset of the configuration - // options available to .option(). + // options available to .option(): const supportedOpts: (keyof PositionalDefinition)[] = [ 'default', 'defaultDescription', @@ -1318,11 +1305,18 @@ function Yargs( if (!self.parsed) { // Run the parser as if --help was passed to it (this is what // the last parameter `true` indicates). - self._parseArgs(processArgs, undefined, undefined, 0, true); - } - if (command.hasDefaultCommand()) { - context.resets++; // override the restriction on top-level positoinals. - command.runDefaultBuilderOn(self); + const parse = self._parseArgs( + processArgs, + undefined, + undefined, + 0, + true + ); + if (isPromise(parse)) { + return parse.then(() => { + return usage.help(); + }); + } } } return usage.help(); @@ -1335,11 +1329,19 @@ function Yargs( if (!self.parsed) { // Run the parser as if --help was passed to it (this is what // the last parameter `true` indicates). - self._parseArgs(processArgs, undefined, undefined, 0, true); - } - if (command.hasDefaultCommand()) { - context.resets++; // override the restriction on top-level positoinals. - command.runDefaultBuilderOn(self); + const parse = self._parseArgs( + processArgs, + undefined, + undefined, + 0, + true + ); + if (isPromise(parse)) { + parse.then(() => { + usage.showHelp(level); + }); + return self; + } } } usage.showHelp(level); @@ -1600,12 +1602,23 @@ function Yargs( }) ) as DetailedArguments; - let argv: Arguments = parsed.argv as Arguments; + const argv: Arguments = Object.assign( + parsed.argv, + parseContext + ) as Arguments; let argvPromise: Arguments | Promise | undefined = undefined; - // Used rather than argv if middleware introduces an async step: - if (parseContext) argv = Object.assign({}, argv, parseContext); const aliases = parsed.aliases; + let helpOptSet = false; + let versionOptSet = false; + Object.keys(argv).forEach(key => { + if (key === helpOpt && argv[key]) { + helpOptSet = true; + } else if (key === versionOpt && argv[key]) { + versionOptSet = true; + } + }); + argv.$0 = self.$0; self.parsed = parsed; @@ -1644,14 +1657,13 @@ function Yargs( // check if help should trigger and strip it from _. if (~helpCmds.indexOf('' + argv._[argv._.length - 1])) { argv._.pop(); - argv[helpOpt] = true; + helpOptSet = true; } } const handlerKeys = command.getCommands(); const requestCompletions = completion!.completionKey in argv; - const skipRecommendation = - argv[helpOpt!] || requestCompletions || helpOnly; + const skipRecommendation = helpOptSet || requestCompletions || helpOnly; if (argv._.length) { if (handlerKeys.length) { @@ -1667,7 +1679,10 @@ function Yargs( self, parsed, i + 1, - helpOnly // Don't run a handler, just figure out the help string. + // Don't run a handler, just figure out the help string: + helpOnly, + // Passed to builder so that expensive commands can be deferred: + helpOptSet || versionOptSet || helpOnly ); return self._postProcess( innerArgv, @@ -1680,27 +1695,14 @@ function Yargs( break; } } - - // run the default command, if defined - if (command.hasDefaultCommand() && !skipRecommendation) { - const innerArgv = command.runCommand( - null, - self, - parsed, - 0, - helpOnly - ); - return self._postProcess( - innerArgv, - populateDoubleDash, - !!calledFromCommand, - false - ); - } - // recommend a command if recommendCommands() has // been enabled, and no commands were found to execute - if (recommendCommands && firstUnknownCommand && !skipRecommendation) { + if ( + !command.hasDefaultCommand() && + recommendCommands && + firstUnknownCommand && + !skipRecommendation + ) { validation.recommendCommands(firstUnknownCommand, handlerKeys); } } @@ -1715,14 +1717,27 @@ function Yargs( self.showCompletionScript(); self.exit(0); } - } else if (command.hasDefaultCommand() && !skipRecommendation) { - const innerArgv = command.runCommand(null, self, parsed, 0, helpOnly); + } + + if (command.hasDefaultCommand() && !skipRecommendation) { + const innerArgv = command.runCommand( + null, + self, + parsed, + 0, + helpOnly, + helpOptSet || versionOptSet || helpOnly + ); return self._postProcess( innerArgv, populateDoubleDash, !!calledFromCommand, false ); + } else if (!calledFromCommand && helpOnly) { + // TODO: what if the default builder is async? + // TODO: add better comments. + command.runDefaultBuilderOn(self); } // we must run completions first, a user might @@ -1754,21 +1769,20 @@ function Yargs( // Handle 'help' and 'version' options // if we haven't already output help! if (!hasOutput) { - Object.keys(argv).forEach(key => { - if (key === helpOpt && argv[key]) { - if (exitProcess) setBlocking(true); - - skipValidation = true; - self.showHelp('log'); - self.exit(0); - } else if (key === versionOpt && argv[key]) { - if (exitProcess) setBlocking(true); - - skipValidation = true; - usage.showVersion('log'); - self.exit(0); - } - }); + if (helpOptSet) { + if (exitProcess) setBlocking(true); + skipValidation = true; + // TODO: add appropriate comment. + if (!calledFromCommand) command.runDefaultBuilderOn(self); + self.showHelp('log'); + self.exit(0); + } else if (versionOptSet) { + if (exitProcess) setBlocking(true); + + skipValidation = true; + usage.showVersion('log'); + self.exit(0); + } } // Check if any of the options to skip validation were provided @@ -1812,6 +1826,7 @@ function Yargs( if (err instanceof YError) usage.fail(err.message, err); else throw err; } + return self._postProcess( argvPromise ?? argv, populateDoubleDash, @@ -2221,7 +2236,6 @@ export function isYargsInstance(y: YargsInstance | void): y is YargsInstance { /** Yargs' context. */ export interface Context { commands: string[]; - files: string[]; fullCommands: string[]; } diff --git a/test/command.cjs b/test/command.cjs index 59f05ada4..642b715cd 100644 --- a/test/command.cjs +++ b/test/command.cjs @@ -8,6 +8,11 @@ const checkOutput = require('./helpers/utils.cjs').checkOutput; require('chai').should(); const noop = () => {}; +async function wait() { + return new Promise(resolve => { + setTimeout(resolve, 10); + }); +} describe('Command', () => { beforeEach(() => { @@ -1942,4 +1947,80 @@ describe('Command', () => { err.message.should.match(/Not enough arguments/); } }); + + describe('async builder', async () => { + it('allows positionals to be configured asynchronously', async () => { + const argvPromise = yargs(['cmd', '999']) + .command('cmd ', 'a test command', async yargs => { + await wait(); + yargs.positional('foo', { + type: 'string', + }); + }) + .parse(); + (typeof argvPromise.then).should.equal('function'); + const argv = await argvPromise; + (typeof argv.foo).should.equal('string'); + }); + describe('helpOrVersionSet', () => { + it('--help', async () => { + let set = false; + await yargs() + .command('cmd ', 'a test command', (yargs, helpOrVersionSet) => { + set = helpOrVersionSet; + if (!helpOrVersionSet) { + return wait(); + } + }) + .parse('cmd --help', () => {}); + assert.strictEqual(set, true); + }); + }); + // TODO: investigate why .parse('cmd --help', () => {}); does not + // work properly with an async builder. We should test the same + // with handler. + }); + + describe('builder', () => { + // Refs: https://github.com/yargs/yargs/issues/1042 + describe('helpOrVersionSet', () => { + it('--version', () => { + let set = false; + yargs() + .command('cmd ', 'a test command', (yargs, helpOrVersionSet) => { + set = helpOrVersionSet; + }) + .parse('cmd --version', () => {}); + assert.strictEqual(set, true); + }); + it('--help', () => { + let set = false; + yargs() + .command('cmd ', 'a test command', (yargs, helpOrVersionSet) => { + set = helpOrVersionSet; + }) + .parse('cmd --help', () => {}); + assert.strictEqual(set, true); + }); + it('help', () => { + let set = false; + yargs() + .command('cmd ', 'a test command', (yargs, helpOrVersionSet) => { + set = helpOrVersionSet; + }) + .parse('cmd help', () => {}); + assert.strictEqual(set, true); + }); + it('cmd', () => { + let set = false; + const argv = yargs() + .command('cmd ', 'a test command', (yargs, helpOrVersionSet) => { + set = helpOrVersionSet; + }) + .parse('cmd bar', () => {}); + assert.strictEqual(set, false); + assert.strictEqual(argv.foo, 'bar'); + }); + }); + }); }); diff --git a/test/helpers/utils.cjs b/test/helpers/utils.cjs index 7709fda12..ec2ef8e5f 100644 --- a/test/helpers/utils.cjs +++ b/test/helpers/utils.cjs @@ -56,10 +56,17 @@ exports.checkOutput = function checkOutput(f, argv, cb) { } else { try { result = f(); - } finally { + if (typeof result.then === 'function') { + return result.then((r) => { + reset(); + return done(); + }); + } else { + reset(); + } + } catch (_err) { reset(); } - return done(); } diff --git a/test/middleware.cjs b/test/middleware.cjs index 625be7d3d..c607cfcec 100644 --- a/test/middleware.cjs +++ b/test/middleware.cjs @@ -13,7 +13,7 @@ function clearRequireCache() { } async function wait() { - return Promise(resolve => { + return new Promise(resolve => { setTimeout(resolve, 10); }); } diff --git a/test/usage.cjs b/test/usage.cjs index d0345f255..a2aaca950 100644 --- a/test/usage.cjs +++ b/test/usage.cjs @@ -11,6 +11,11 @@ const {rebase, YError} = require('../build/index.cjs'); const should = require('chai').should(); const noop = () => {}; +async function wait(n = 10) { + return new Promise(resolve => { + setTimeout(resolve, n); + }); +} describe('usage tests', () => { beforeEach(() => { @@ -4319,7 +4324,6 @@ describe('usage tests', () => { .command('*', 'Default command description') .parse() ); - r.logs[0].split('\n').should.deep.equal(expected); }); @@ -4475,4 +4479,44 @@ describe('usage tests', () => { }); }); }); + + describe('async builder', async () => { + it('shows appropriate usage instructions for nested command', async () => { + // With --help flag: + { + const r = await checkUsage(() => { + return yargs(['cmd', '--help']) + .command('cmd ', 'a test command', async yargs => { + await wait(); + yargs.positional('foo', { + type: 'string', + default: 'hello', + }); + }) + .parse(); + }); + const logs = r.logs.join('\n'); + logs.should.match(/default: "hello"/); + logs.should.match(/a test command/); + } + // Using showHelp(): + { + const r = await checkUsage(() => { + yargs(['cmd']) + .command('cmd ', 'a test command', async yargs => { + await wait(); + yargs.positional('foo', { + type: 'string', + default: 'hello', + }); + }) + .showHelp('log'); + return wait(20); + }); + const logs = r.logs.join('\n'); + logs.should.match(/default: "hello"/); + logs.should.match(/a test command/); + } + }); + }); }); diff --git a/test/yargs.cjs b/test/yargs.cjs index d8e016280..b682d078a 100644 --- a/test/yargs.cjs +++ b/test/yargs.cjs @@ -12,6 +12,11 @@ let yargs; require('chai').should(); const noop = () => {}; +async function wait() { + return new Promise(resolve => { + setTimeout(resolve, 10); + }); +} const implicationsFailedPattern = new RegExp(english['Implications failed:']); function clearRequireCache() { @@ -2166,21 +2171,6 @@ describe('yargs dsl tests', () => { yargs = require('../index.cjs'); }); - it('should begin with initial state', () => { - const context = yargs.getContext(); - context.resets.should.equal(0); - context.commands.should.deep.equal([]); - }); - - it('should track number of resets', () => { - const context = yargs.getContext(); - yargs.reset(); - context.resets.should.equal(1); - yargs.reset(); - yargs.reset(); - context.resets.should.equal(3); - }); - it('should track commands being executed', () => { let context; yargs('one two') @@ -2408,12 +2398,16 @@ describe('yargs dsl tests', () => { argv.str.should.equal('33'); }); - it("can only be used as part of a command's builder function", () => { - expect(() => { - yargs('foo').positional('foo', { - describe: 'I should not work', - }); - }).to.throw(/\.positional\(\) can only be called/); + it('allows positionals to be defined for default command', async () => { + const help = await yargs() + .command('* [foo]', 'default command') + .positional('foo', { + default: 33, + type: 'number', + }) + .getHelp(); + help.should.include('default: 33'); + help.should.include('default command'); }); // see: https://github.com/yargs/yargs-parser/pull/110 @@ -3086,5 +3080,24 @@ describe('yargs dsl tests', () => { help.should.match(/node object get/); argv._.should.eql(['object']); }); + it('should return appropriate help message when async builder used', async () => { + const help = await yargs('foo') + .command( + 'foo [bar]', + 'foo command', + async yargs => { + wait(); + return yargs.positional('foo', { + demand: true, + default: 'hello', + type: 'string', + }); + }, + async argv => {} + ) + .getHelp(); + help.should.match(/default: "hello"/); + help.should.match(/foo command/); + }); }); });