/* eslint-disable class-methods-use-this */ 'use strict'; const UTIL = require('util'), PATH = require('path'), EOL = require('os').EOL, Q = require('q'), chalk = require('chalk'), CoaObject = require('./coaobject'), Opt = require('./opt'), Arg = require('./arg'), completion = require('./completion'); /** * Command * * Top level entity. Commands may have options and arguments. * * @namespace * @class Cmd * @extends CoaObject */ class Cmd extends CoaObject { /** * @constructs * @param {COA.Cmd} [cmd] parent command */ constructor(cmd) { super(cmd); this._parent(cmd); this._cmds = []; this._cmdsByName = {}; this._opts = []; this._optsByKey = {}; this._args = []; this._api = null; this._ext = false; } static create(cmd) { return new Cmd(cmd); } /** * Returns object containing all its subcommands as methods * to use from other programs. * * @returns {Object} */ get api() { // Need _this here because of passed arguments into _api const _this = this; this._api || (this._api = function () { return _this.invoke.apply(_this, arguments); }); const cmds = this._cmdsByName; Object.keys(cmds).forEach(cmd => { this._api[cmd] = cmds[cmd].api; }); return this._api; } _parent(cmd) { this._cmd = cmd || this; this.isRootCmd || cmd._cmds.push(this) && this._name && (this._cmd._cmdsByName[this._name] = this); return this; } get isRootCmd() { return this._cmd === this; } /** * Set a canonical command identifier to be used anywhere in the API. * * @param {String} name - command name * @returns {COA.Cmd} - this instance (for chainability) */ name(name) { super.name(name); this.isRootCmd || (this._cmd._cmdsByName[name] = this); return this; } /** * Create new or add existing subcommand for current command. * * @param {COA.Cmd} [cmd] existing command instance * @returns {COA.Cmd} new subcommand instance */ cmd(cmd) { return cmd? cmd._parent(this) : new Cmd(this); } /** * Create option for current command. * * @returns {COA.Opt} new option instance */ opt() { return new Opt(this); } /** * Create argument for current command. * * @returns {COA.Opt} new argument instance */ arg() { return new Arg(this); } /** * Add (or set) action for current command. * * @param {Function} act - action function, * invoked in the context of command instance * and has the parameters: * - {Object} opts - parsed options * - {String[]} args - parsed arguments * - {Object} res - actions result accumulator * It can return rejected promise by Cmd.reject (in case of error) * or any other value treated as result. * @param {Boolean} [force=false] flag for set action instead add to existings * @returns {COA.Cmd} - this instance (for chainability) */ act(act, force) { if(!act) return this; (!this._act || force) && (this._act = []); this._act.push(act); return this; } /** * Make command "helpful", i.e. add -h --help flags for print usage. * * @returns {COA.Cmd} - this instance (for chainability) */ helpful() { return this.opt() .name('help') .title('Help') .short('h') .long('help') .flag() .only() .act(function() { return this.usage(); }) .end(); } /** * Adds shell completion to command, adds "completion" subcommand, * that makes all the magic. * Must be called only on root command. * * @returns {COA.Cmd} - this instance (for chainability) */ completable() { return this.cmd() .name('completion') .apply(completion) .end(); } /** * Allow command to be extendable by external node.js modules. * * @param {String} [pattern] Pattern of node.js module to find subcommands at. * @returns {COA.Cmd} - this instance (for chainability) */ extendable(pattern) { this._ext = pattern || true; return this; } _exit(msg, code) { return process.once('exit', function(exitCode) { msg && console[code === 0 ? 'log' : 'error'](msg); process.exit(code || exitCode || 0); }); } /** * Build full usage text for current command instance. * * @returns {String} usage text */ usage() { const res = []; this._title && res.push(this._fullTitle()); res.push('', 'Usage:'); this._cmds.length && res.push([ '', '', chalk.redBright(this._fullName()), chalk.blueBright('COMMAND'), chalk.greenBright('[OPTIONS]'), chalk.magentaBright('[ARGS]') ].join(' ')); (this._opts.length + this._args.length) && res.push([ '', '', chalk.redBright(this._fullName()), chalk.greenBright('[OPTIONS]'), chalk.magentaBright('[ARGS]') ].join(' ')); res.push( this._usages(this._cmds, 'Commands'), this._usages(this._opts, 'Options'), this._usages(this._args, 'Arguments') ); return res.join(EOL); } _usage() { return chalk.blueBright(this._name) + ' : ' + this._title; } _usages(os, title) { if(!os.length) return; return ['', title + ':'] .concat(os.map(o => ` ${o._usage()}`)) .join(EOL); } _fullTitle() { return `${this.isRootCmd? '' : this._cmd._fullTitle() + EOL}${this._title}`; } _fullName() { return `${this.isRootCmd? '' : this._cmd._fullName() + ' '}${PATH.basename(this._name)}`; } _ejectOpt(opts, opt) { const pos = opts.indexOf(opt); if(pos === -1) return; return opts[pos]._arr? opts[pos] : opts.splice(pos, 1)[0]; } _checkRequired(opts, args) { if(this._opts.some(opt => opt._only && opts.hasOwnProperty(opt._name))) return; const all = this._opts.concat(this._args); let i; while(i = all.shift()) if(i._req && i._checkParsed(opts, args)) return this.reject(i._requiredText()); } _parseCmd(argv, unparsed) { unparsed || (unparsed = []); let i, optSeen = false; while(i = argv.shift()) { i.indexOf('-') || (optSeen = true); if(optSeen || !/^\w[\w-_]*$/.test(i)) { unparsed.push(i); continue; } let pkg, cmd = this._cmdsByName[i]; if(!cmd && this._ext) { if(this._ext === true) { pkg = i; let c = this; while(true) { // eslint-disable-line pkg = c._name + '-' + pkg; if(c.isRootCmd) break; c = c._cmd; } } else if(typeof this._ext === 'string') pkg = ~this._ext.indexOf('%s')? UTIL.format(this._ext, i) : this._ext + i; let cmdDesc; try { cmdDesc = require(pkg); } catch(e) { // Dummy } if(cmdDesc) { if(typeof cmdDesc === 'function') { this.cmd().name(i).apply(cmdDesc).end(); } else if(typeof cmdDesc === 'object') { this.cmd(cmdDesc); cmdDesc.name(i); } else throw new Error('Error: Unsupported command declaration type, ' + 'should be a function or COA.Cmd() object'); cmd = this._cmdsByName[i]; } } if(cmd) return cmd._parseCmd(argv, unparsed); unparsed.push(i); } return { cmd : this, argv : unparsed }; } _parseOptsAndArgs(argv) { const opts = {}, args = {}, nonParsedOpts = this._opts.concat(), nonParsedArgs = this._args.concat(); let res, i; while(i = argv.shift()) { if(i !== '--' && i[0] === '-') { const m = i.match(/^(--\w[\w-_]*)=(.*)$/); if(m) { i = m[1]; this._optsByKey[i]._flag || argv.unshift(m[2]); } const opt = this._ejectOpt(nonParsedOpts, this._optsByKey[i]); if(!opt) return this.reject(`Unknown option: ${i}`); if(Q.isRejected(res = opt._parse(argv, opts))) return res; continue; } i === '--' && (i = argv.splice(0)); Array.isArray(i) || (i = [i]); let a; while(a = i.shift()) { let arg = nonParsedArgs.shift(); if(!arg) return this.reject(`Unknown argument: ${a}`); arg._arr && nonParsedArgs.unshift(arg); if(Q.isRejected(res = arg._parse(a, args))) return res; } } return { opts : this._setDefaults(opts, nonParsedOpts), args : this._setDefaults(args, nonParsedArgs) }; } _setDefaults(params, desc) { for(const item of desc) item._def !== undefined && !params.hasOwnProperty(item._name) && item._saveVal(params, item._def); return params; } _processParams(params, desc) { const notExists = []; for(const item of desc) { const n = item._name; if(!params.hasOwnProperty(n)) { notExists.push(item); continue; } const vals = Array.isArray(params[n])? params[n] : [params[n]]; delete params[n]; let res; for(const v of vals) if(Q.isRejected(res = item._saveVal(params, v))) return res; } return this._setDefaults(params, notExists); } _parseArr(argv) { return Q.when(this._parseCmd(argv), p => Q.when(p.cmd._parseOptsAndArgs(p.argv), r => ({ cmd : p.cmd, opts : r.opts, args : r.args }))); } _do(inputPromise) { return Q.when(inputPromise, input => { return [this._checkRequired] .concat(input.cmd._act || []) .reduce((res, act) => Q.when(res, prev => act.call(input.cmd, input.opts, input.args, prev)), undefined); }); } /** * Parse arguments from simple format like NodeJS process.argv * and run ahead current program, i.e. call process.exit when all actions done. * * @param {String[]} argv - arguments * @returns {COA.Cmd} - this instance (for chainability) */ run(argv) { argv || (argv = process.argv.slice(2)); const cb = code => res => res? this._exit(res.stack || res.toString(), (res.hasOwnProperty('exitCode')? res.exitCode : code) || 0) : this._exit(); Q.when(this.do(argv), cb(0), cb(1)).done(); return this; } /** * Invoke specified (or current) command using provided * options and arguments. * * @param {String|String[]} [cmds] - subcommand to invoke (optional) * @param {Object} [opts] - command options (optional) * @param {Object} [args] - command arguments (optional) * @returns {Q.Promise} */ invoke(cmds, opts, args) { cmds || (cmds = []); opts || (opts = {}); args || (args = {}); typeof cmds === 'string' && (cmds = cmds.split(' ')); if(arguments.length < 3 && !Array.isArray(cmds)) { args = opts; opts = cmds; cmds = []; } return Q.when(this._parseCmd(cmds), p => { if(p.argv.length) return this.reject(`Unknown command: ${cmds.join(' ')}`); return Q.all([ this._processParams(opts, this._opts), this._processParams(args, this._args) ]).spread((_opts, _args) => this._do({ cmd : p.cmd, opts : _opts, args : _args }) .fail(res => (res && res.exitCode === 0)? res.toString() : this.reject(res))); }); } } /** * Convenient function to run command from tests. * * @param {String[]} argv - arguments * @returns {Q.Promise} */ Cmd.prototype.do = function(argv) { return this._do(this._parseArr(argv || [])); }; module.exports = Cmd;