1'use strict' 2 3const inspect = require('util').inspect 4const isPromise = require('./is-promise') 5const { applyMiddleware, commandMiddlewareFactory } = require('./middleware') 6const path = require('path') 7const Parser = require('yargs-parser') 8 9const DEFAULT_MARKER = /(^\*)|(^\$0)/ 10 11// handles parsing positional arguments, 12// and populating argv with said positional 13// arguments. 14module.exports = function command (yargs, usage, validation, globalMiddleware) { 15 const self = {} 16 let handlers = {} 17 let aliasMap = {} 18 let defaultCommand 19 globalMiddleware = globalMiddleware || [] 20 21 self.addHandler = function addHandler (cmd, description, builder, handler, commandMiddleware) { 22 let aliases = [] 23 const middlewares = commandMiddlewareFactory(commandMiddleware) 24 handler = handler || (() => {}) 25 26 if (Array.isArray(cmd)) { 27 aliases = cmd.slice(1) 28 cmd = cmd[0] 29 } else if (typeof cmd === 'object') { 30 let command = (Array.isArray(cmd.command) || typeof cmd.command === 'string') ? cmd.command : moduleName(cmd) 31 if (cmd.aliases) command = [].concat(command).concat(cmd.aliases) 32 self.addHandler(command, extractDesc(cmd), cmd.builder, cmd.handler, cmd.middlewares) 33 return 34 } 35 36 // allow a module to be provided instead of separate builder and handler 37 if (typeof builder === 'object' && builder.builder && typeof builder.handler === 'function') { 38 self.addHandler([cmd].concat(aliases), description, builder.builder, builder.handler, builder.middlewares) 39 return 40 } 41 42 // parse positionals out of cmd string 43 const parsedCommand = self.parseCommand(cmd) 44 45 // remove positional args from aliases only 46 aliases = aliases.map(alias => self.parseCommand(alias).cmd) 47 48 // check for default and filter out '*'' 49 let isDefault = false 50 const parsedAliases = [parsedCommand.cmd].concat(aliases).filter((c) => { 51 if (DEFAULT_MARKER.test(c)) { 52 isDefault = true 53 return false 54 } 55 return true 56 }) 57 58 // standardize on $0 for default command. 59 if (parsedAliases.length === 0 && isDefault) parsedAliases.push('$0') 60 61 // shift cmd and aliases after filtering out '*' 62 if (isDefault) { 63 parsedCommand.cmd = parsedAliases[0] 64 aliases = parsedAliases.slice(1) 65 cmd = cmd.replace(DEFAULT_MARKER, parsedCommand.cmd) 66 } 67 68 // populate aliasMap 69 aliases.forEach((alias) => { 70 aliasMap[alias] = parsedCommand.cmd 71 }) 72 73 if (description !== false) { 74 usage.command(cmd, description, isDefault, aliases) 75 } 76 77 handlers[parsedCommand.cmd] = { 78 original: cmd, 79 description: description, 80 handler, 81 builder: builder || {}, 82 middlewares: middlewares || [], 83 demanded: parsedCommand.demanded, 84 optional: parsedCommand.optional 85 } 86 87 if (isDefault) defaultCommand = handlers[parsedCommand.cmd] 88 } 89 90 self.addDirectory = function addDirectory (dir, context, req, callerFile, opts) { 91 opts = opts || {} 92 // disable recursion to support nested directories of subcommands 93 if (typeof opts.recurse !== 'boolean') opts.recurse = false 94 // exclude 'json', 'coffee' from require-directory defaults 95 if (!Array.isArray(opts.extensions)) opts.extensions = ['js'] 96 // allow consumer to define their own visitor function 97 const parentVisit = typeof opts.visit === 'function' ? opts.visit : o => o 98 // call addHandler via visitor function 99 opts.visit = function visit (obj, joined, filename) { 100 const visited = parentVisit(obj, joined, filename) 101 // allow consumer to skip modules with their own visitor 102 if (visited) { 103 // check for cyclic reference 104 // each command file path should only be seen once per execution 105 if (~context.files.indexOf(joined)) return visited 106 // keep track of visited files in context.files 107 context.files.push(joined) 108 self.addHandler(visited) 109 } 110 return visited 111 } 112 require('require-directory')({ require: req, filename: callerFile }, dir, opts) 113 } 114 115 // lookup module object from require()d command and derive name 116 // if module was not require()d and no name given, throw error 117 function moduleName (obj) { 118 const mod = require('which-module')(obj) 119 if (!mod) throw new Error(`No command name given for module: ${inspect(obj)}`) 120 return commandFromFilename(mod.filename) 121 } 122 123 // derive command name from filename 124 function commandFromFilename (filename) { 125 return path.basename(filename, path.extname(filename)) 126 } 127 128 function extractDesc (obj) { 129 for (let keys = ['describe', 'description', 'desc'], i = 0, l = keys.length, test; i < l; i++) { 130 test = obj[keys[i]] 131 if (typeof test === 'string' || typeof test === 'boolean') return test 132 } 133 return false 134 } 135 136 self.parseCommand = function parseCommand (cmd) { 137 const extraSpacesStrippedCommand = cmd.replace(/\s{2,}/g, ' ') 138 const splitCommand = extraSpacesStrippedCommand.split(/\s+(?![^[]*]|[^<]*>)/) 139 const bregex = /\.*[\][<>]/g 140 const parsedCommand = { 141 cmd: (splitCommand.shift()).replace(bregex, ''), 142 demanded: [], 143 optional: [] 144 } 145 splitCommand.forEach((cmd, i) => { 146 let variadic = false 147 cmd = cmd.replace(/\s/g, '') 148 if (/\.+[\]>]/.test(cmd) && i === splitCommand.length - 1) variadic = true 149 if (/^\[/.test(cmd)) { 150 parsedCommand.optional.push({ 151 cmd: cmd.replace(bregex, '').split('|'), 152 variadic 153 }) 154 } else { 155 parsedCommand.demanded.push({ 156 cmd: cmd.replace(bregex, '').split('|'), 157 variadic 158 }) 159 } 160 }) 161 return parsedCommand 162 } 163 164 self.getCommands = () => Object.keys(handlers).concat(Object.keys(aliasMap)) 165 166 self.getCommandHandlers = () => handlers 167 168 self.hasDefaultCommand = () => !!defaultCommand 169 170 self.runCommand = function runCommand (command, yargs, parsed, commandIndex) { 171 let aliases = parsed.aliases 172 const commandHandler = handlers[command] || handlers[aliasMap[command]] || defaultCommand 173 const currentContext = yargs.getContext() 174 let numFiles = currentContext.files.length 175 const parentCommands = currentContext.commands.slice() 176 177 // what does yargs look like after the builder is run? 178 let innerArgv = parsed.argv 179 let innerYargs = null 180 let positionalMap = {} 181 if (command) { 182 currentContext.commands.push(command) 183 currentContext.fullCommands.push(commandHandler.original) 184 } 185 if (typeof commandHandler.builder === 'function') { 186 // a function can be provided, which builds 187 // up a yargs chain and possibly returns it. 188 innerYargs = commandHandler.builder(yargs.reset(parsed.aliases)) 189 if (!innerYargs || (typeof innerYargs._parseArgs !== 'function')) { 190 innerYargs = yargs 191 } 192 if (shouldUpdateUsage(innerYargs)) { 193 innerYargs.getUsageInstance().usage( 194 usageFromParentCommandsCommandHandler(parentCommands, commandHandler), 195 commandHandler.description 196 ) 197 } 198 innerArgv = innerYargs._parseArgs(null, null, true, commandIndex) 199 aliases = innerYargs.parsed.aliases 200 } else if (typeof commandHandler.builder === 'object') { 201 // as a short hand, an object can instead be provided, specifying 202 // the options that a command takes. 203 innerYargs = yargs.reset(parsed.aliases) 204 if (shouldUpdateUsage(innerYargs)) { 205 innerYargs.getUsageInstance().usage( 206 usageFromParentCommandsCommandHandler(parentCommands, commandHandler), 207 commandHandler.description 208 ) 209 } 210 Object.keys(commandHandler.builder).forEach((key) => { 211 innerYargs.option(key, commandHandler.builder[key]) 212 }) 213 innerArgv = innerYargs._parseArgs(null, null, true, commandIndex) 214 aliases = innerYargs.parsed.aliases 215 } 216 217 if (!yargs._hasOutput()) { 218 positionalMap = populatePositionals(commandHandler, innerArgv, currentContext, yargs) 219 } 220 221 const middlewares = globalMiddleware.slice(0).concat(commandHandler.middlewares || []) 222 applyMiddleware(innerArgv, yargs, middlewares, true) 223 224 // we apply validation post-hoc, so that custom 225 // checks get passed populated positional arguments. 226 if (!yargs._hasOutput()) yargs._runValidation(innerArgv, aliases, positionalMap, yargs.parsed.error) 227 228 if (commandHandler.handler && !yargs._hasOutput()) { 229 yargs._setHasOutput() 230 // to simplify the parsing of positionals in commands, 231 // we temporarily populate '--' rather than _, with arguments 232 const populateDoubleDash = !!yargs.getOptions().configuration['populate--'] 233 if (!populateDoubleDash) yargs._copyDoubleDash(innerArgv) 234 235 innerArgv = applyMiddleware(innerArgv, yargs, middlewares, false) 236 let handlerResult 237 if (isPromise(innerArgv)) { 238 handlerResult = innerArgv.then(argv => commandHandler.handler(argv)) 239 } else { 240 handlerResult = commandHandler.handler(innerArgv) 241 } 242 243 if (isPromise(handlerResult)) { 244 yargs.getUsageInstance().cacheHelpMessage() 245 handlerResult.catch(error => { 246 try { 247 yargs.getUsageInstance().fail(null, error) 248 } catch (err) { 249 // fail's throwing would cause an unhandled rejection. 250 } 251 }) 252 } 253 } 254 255 if (command) { 256 currentContext.commands.pop() 257 currentContext.fullCommands.pop() 258 } 259 numFiles = currentContext.files.length - numFiles 260 if (numFiles > 0) currentContext.files.splice(numFiles * -1, numFiles) 261 262 return innerArgv 263 } 264 265 function shouldUpdateUsage (yargs) { 266 return !yargs.getUsageInstance().getUsageDisabled() && 267 yargs.getUsageInstance().getUsage().length === 0 268 } 269 270 function usageFromParentCommandsCommandHandler (parentCommands, commandHandler) { 271 const c = DEFAULT_MARKER.test(commandHandler.original) ? commandHandler.original.replace(DEFAULT_MARKER, '').trim() : commandHandler.original 272 const pc = parentCommands.filter((c) => { return !DEFAULT_MARKER.test(c) }) 273 pc.push(c) 274 return `$0 ${pc.join(' ')}` 275 } 276 277 self.runDefaultBuilderOn = function (yargs) { 278 if (shouldUpdateUsage(yargs)) { 279 // build the root-level command string from the default string. 280 const commandString = DEFAULT_MARKER.test(defaultCommand.original) 281 ? defaultCommand.original : defaultCommand.original.replace(/^[^[\]<>]*/, '$0 ') 282 yargs.getUsageInstance().usage( 283 commandString, 284 defaultCommand.description 285 ) 286 } 287 const builder = defaultCommand.builder 288 if (typeof builder === 'function') { 289 builder(yargs) 290 } else { 291 Object.keys(builder).forEach((key) => { 292 yargs.option(key, builder[key]) 293 }) 294 } 295 } 296 297 // transcribe all positional arguments "command <foo> <bar> [apple]" 298 // onto argv. 299 function populatePositionals (commandHandler, argv, context, yargs) { 300 argv._ = argv._.slice(context.commands.length) // nuke the current commands 301 const demanded = commandHandler.demanded.slice(0) 302 const optional = commandHandler.optional.slice(0) 303 const positionalMap = {} 304 305 validation.positionalCount(demanded.length, argv._.length) 306 307 while (demanded.length) { 308 const demand = demanded.shift() 309 populatePositional(demand, argv, positionalMap) 310 } 311 312 while (optional.length) { 313 const maybe = optional.shift() 314 populatePositional(maybe, argv, positionalMap) 315 } 316 317 argv._ = context.commands.concat(argv._) 318 319 postProcessPositionals(argv, positionalMap, self.cmdToParseOptions(commandHandler.original)) 320 321 return positionalMap 322 } 323 324 function populatePositional (positional, argv, positionalMap, parseOptions) { 325 const cmd = positional.cmd[0] 326 if (positional.variadic) { 327 positionalMap[cmd] = argv._.splice(0).map(String) 328 } else { 329 if (argv._.length) positionalMap[cmd] = [String(argv._.shift())] 330 } 331 } 332 333 // we run yargs-parser against the positional arguments 334 // applying the same parsing logic used for flags. 335 function postProcessPositionals (argv, positionalMap, parseOptions) { 336 // combine the parsing hints we've inferred from the command 337 // string with explicitly configured parsing hints. 338 const options = Object.assign({}, yargs.getOptions()) 339 options.default = Object.assign(parseOptions.default, options.default) 340 options.alias = Object.assign(parseOptions.alias, options.alias) 341 options.array = options.array.concat(parseOptions.array) 342 delete options.config // don't load config when processing positionals. 343 344 const unparsed = [] 345 Object.keys(positionalMap).forEach((key) => { 346 positionalMap[key].map((value) => { 347 unparsed.push(`--${key}`) 348 unparsed.push(value) 349 }) 350 }) 351 352 // short-circuit parse. 353 if (!unparsed.length) return 354 355 const config = Object.assign({}, options.configuration, { 356 'populate--': true 357 }) 358 const parsed = Parser.detailed(unparsed, Object.assign({}, options, { 359 configuration: config 360 })) 361 362 if (parsed.error) { 363 yargs.getUsageInstance().fail(parsed.error.message, parsed.error) 364 } else { 365 // only copy over positional keys (don't overwrite 366 // flag arguments that were already parsed). 367 const positionalKeys = Object.keys(positionalMap) 368 Object.keys(positionalMap).forEach((key) => { 369 [].push.apply(positionalKeys, parsed.aliases[key]) 370 }) 371 372 Object.keys(parsed.argv).forEach((key) => { 373 if (positionalKeys.indexOf(key) !== -1) { 374 // any new aliases need to be placed in positionalMap, which 375 // is used for validation. 376 if (!positionalMap[key]) positionalMap[key] = parsed.argv[key] 377 argv[key] = parsed.argv[key] 378 } 379 }) 380 } 381 } 382 383 self.cmdToParseOptions = function (cmdString) { 384 const parseOptions = { 385 array: [], 386 default: {}, 387 alias: {}, 388 demand: {} 389 } 390 391 const parsed = self.parseCommand(cmdString) 392 parsed.demanded.forEach((d) => { 393 const cmds = d.cmd.slice(0) 394 const cmd = cmds.shift() 395 if (d.variadic) { 396 parseOptions.array.push(cmd) 397 parseOptions.default[cmd] = [] 398 } 399 cmds.forEach((c) => { 400 parseOptions.alias[cmd] = c 401 }) 402 parseOptions.demand[cmd] = true 403 }) 404 405 parsed.optional.forEach((o) => { 406 const cmds = o.cmd.slice(0) 407 const cmd = cmds.shift() 408 if (o.variadic) { 409 parseOptions.array.push(cmd) 410 parseOptions.default[cmd] = [] 411 } 412 cmds.forEach((c) => { 413 parseOptions.alias[cmd] = c 414 }) 415 }) 416 417 return parseOptions 418 } 419 420 self.reset = () => { 421 handlers = {} 422 aliasMap = {} 423 defaultCommand = undefined 424 return self 425 } 426 427 // used by yargs.parse() to freeze 428 // the state of commands such that 429 // we can apply .parse() multiple times 430 // with the same yargs instance. 431 let frozens = [] 432 self.freeze = () => { 433 let frozen = {} 434 frozens.push(frozen) 435 frozen.handlers = handlers 436 frozen.aliasMap = aliasMap 437 frozen.defaultCommand = defaultCommand 438 } 439 self.unfreeze = () => { 440 let frozen = frozens.pop() 441 handlers = frozen.handlers 442 aliasMap = frozen.aliasMap 443 defaultCommand = frozen.defaultCommand 444 } 445 446 return self 447} 448