You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
493 lines
13 KiB
493 lines
13 KiB
/* 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;
|
|
|