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

4 years ago
  1. /* eslint-disable class-methods-use-this */
  2. 'use strict';
  3. const
  4. UTIL = require('util'),
  5. PATH = require('path'),
  6. EOL = require('os').EOL,
  7. Q = require('q'),
  8. chalk = require('chalk'),
  9. CoaObject = require('./coaobject'),
  10. Opt = require('./opt'),
  11. Arg = require('./arg'),
  12. completion = require('./completion');
  13. /**
  14. * Command
  15. *
  16. * Top level entity. Commands may have options and arguments.
  17. *
  18. * @namespace
  19. * @class Cmd
  20. * @extends CoaObject
  21. */
  22. class Cmd extends CoaObject {
  23. /**
  24. * @constructs
  25. * @param {COA.Cmd} [cmd] parent command
  26. */
  27. constructor(cmd) {
  28. super(cmd);
  29. this._parent(cmd);
  30. this._cmds = [];
  31. this._cmdsByName = {};
  32. this._opts = [];
  33. this._optsByKey = {};
  34. this._args = [];
  35. this._api = null;
  36. this._ext = false;
  37. }
  38. static create(cmd) {
  39. return new Cmd(cmd);
  40. }
  41. /**
  42. * Returns object containing all its subcommands as methods
  43. * to use from other programs.
  44. *
  45. * @returns {Object}
  46. */
  47. get api() {
  48. // Need _this here because of passed arguments into _api
  49. const _this = this;
  50. this._api || (this._api = function () {
  51. return _this.invoke.apply(_this, arguments);
  52. });
  53. const cmds = this._cmdsByName;
  54. Object.keys(cmds).forEach(cmd => { this._api[cmd] = cmds[cmd].api; });
  55. return this._api;
  56. }
  57. _parent(cmd) {
  58. this._cmd = cmd || this;
  59. this.isRootCmd ||
  60. cmd._cmds.push(this) &&
  61. this._name &&
  62. (this._cmd._cmdsByName[this._name] = this);
  63. return this;
  64. }
  65. get isRootCmd() {
  66. return this._cmd === this;
  67. }
  68. /**
  69. * Set a canonical command identifier to be used anywhere in the API.
  70. *
  71. * @param {String} name - command name
  72. * @returns {COA.Cmd} - this instance (for chainability)
  73. */
  74. name(name) {
  75. super.name(name);
  76. this.isRootCmd ||
  77. (this._cmd._cmdsByName[name] = this);
  78. return this;
  79. }
  80. /**
  81. * Create new or add existing subcommand for current command.
  82. *
  83. * @param {COA.Cmd} [cmd] existing command instance
  84. * @returns {COA.Cmd} new subcommand instance
  85. */
  86. cmd(cmd) {
  87. return cmd?
  88. cmd._parent(this)
  89. : new Cmd(this);
  90. }
  91. /**
  92. * Create option for current command.
  93. *
  94. * @returns {COA.Opt} new option instance
  95. */
  96. opt() {
  97. return new Opt(this);
  98. }
  99. /**
  100. * Create argument for current command.
  101. *
  102. * @returns {COA.Opt} new argument instance
  103. */
  104. arg() {
  105. return new Arg(this);
  106. }
  107. /**
  108. * Add (or set) action for current command.
  109. *
  110. * @param {Function} act - action function,
  111. * invoked in the context of command instance
  112. * and has the parameters:
  113. * - {Object} opts - parsed options
  114. * - {String[]} args - parsed arguments
  115. * - {Object} res - actions result accumulator
  116. * It can return rejected promise by Cmd.reject (in case of error)
  117. * or any other value treated as result.
  118. * @param {Boolean} [force=false] flag for set action instead add to existings
  119. * @returns {COA.Cmd} - this instance (for chainability)
  120. */
  121. act(act, force) {
  122. if(!act) return this;
  123. (!this._act || force) && (this._act = []);
  124. this._act.push(act);
  125. return this;
  126. }
  127. /**
  128. * Make command "helpful", i.e. add -h --help flags for print usage.
  129. *
  130. * @returns {COA.Cmd} - this instance (for chainability)
  131. */
  132. helpful() {
  133. return this.opt()
  134. .name('help')
  135. .title('Help')
  136. .short('h')
  137. .long('help')
  138. .flag()
  139. .only()
  140. .act(function() {
  141. return this.usage();
  142. })
  143. .end();
  144. }
  145. /**
  146. * Adds shell completion to command, adds "completion" subcommand,
  147. * that makes all the magic.
  148. * Must be called only on root command.
  149. *
  150. * @returns {COA.Cmd} - this instance (for chainability)
  151. */
  152. completable() {
  153. return this.cmd()
  154. .name('completion')
  155. .apply(completion)
  156. .end();
  157. }
  158. /**
  159. * Allow command to be extendable by external node.js modules.
  160. *
  161. * @param {String} [pattern] Pattern of node.js module to find subcommands at.
  162. * @returns {COA.Cmd} - this instance (for chainability)
  163. */
  164. extendable(pattern) {
  165. this._ext = pattern || true;
  166. return this;
  167. }
  168. _exit(msg, code) {
  169. return process.once('exit', function(exitCode) {
  170. msg && console[code === 0 ? 'log' : 'error'](msg);
  171. process.exit(code || exitCode || 0);
  172. });
  173. }
  174. /**
  175. * Build full usage text for current command instance.
  176. *
  177. * @returns {String} usage text
  178. */
  179. usage() {
  180. const res = [];
  181. this._title && res.push(this._fullTitle());
  182. res.push('', 'Usage:');
  183. this._cmds.length
  184. && res.push([
  185. '', '', chalk.redBright(this._fullName()), chalk.blueBright('COMMAND'),
  186. chalk.greenBright('[OPTIONS]'), chalk.magentaBright('[ARGS]')
  187. ].join(' '));
  188. (this._opts.length + this._args.length)
  189. && res.push([
  190. '', '', chalk.redBright(this._fullName()),
  191. chalk.greenBright('[OPTIONS]'), chalk.magentaBright('[ARGS]')
  192. ].join(' '));
  193. res.push(
  194. this._usages(this._cmds, 'Commands'),
  195. this._usages(this._opts, 'Options'),
  196. this._usages(this._args, 'Arguments')
  197. );
  198. return res.join(EOL);
  199. }
  200. _usage() {
  201. return chalk.blueBright(this._name) + ' : ' + this._title;
  202. }
  203. _usages(os, title) {
  204. if(!os.length) return;
  205. return ['', title + ':']
  206. .concat(os.map(o => ` ${o._usage()}`))
  207. .join(EOL);
  208. }
  209. _fullTitle() {
  210. return `${this.isRootCmd? '' : this._cmd._fullTitle() + EOL}${this._title}`;
  211. }
  212. _fullName() {
  213. return `${this.isRootCmd? '' : this._cmd._fullName() + ' '}${PATH.basename(this._name)}`;
  214. }
  215. _ejectOpt(opts, opt) {
  216. const pos = opts.indexOf(opt);
  217. if(pos === -1) return;
  218. return opts[pos]._arr?
  219. opts[pos] :
  220. opts.splice(pos, 1)[0];
  221. }
  222. _checkRequired(opts, args) {
  223. if(this._opts.some(opt => opt._only && opts.hasOwnProperty(opt._name))) return;
  224. const all = this._opts.concat(this._args);
  225. let i;
  226. while(i = all.shift())
  227. if(i._req && i._checkParsed(opts, args))
  228. return this.reject(i._requiredText());
  229. }
  230. _parseCmd(argv, unparsed) {
  231. unparsed || (unparsed = []);
  232. let i,
  233. optSeen = false;
  234. while(i = argv.shift()) {
  235. i.indexOf('-') || (optSeen = true);
  236. if(optSeen || !/^\w[\w-_]*$/.test(i)) {
  237. unparsed.push(i);
  238. continue;
  239. }
  240. let pkg, cmd = this._cmdsByName[i];
  241. if(!cmd && this._ext) {
  242. if(this._ext === true) {
  243. pkg = i;
  244. let c = this;
  245. while(true) { // eslint-disable-line
  246. pkg = c._name + '-' + pkg;
  247. if(c.isRootCmd) break;
  248. c = c._cmd;
  249. }
  250. } else if(typeof this._ext === 'string')
  251. pkg = ~this._ext.indexOf('%s')?
  252. UTIL.format(this._ext, i) :
  253. this._ext + i;
  254. let cmdDesc;
  255. try {
  256. cmdDesc = require(pkg);
  257. } catch(e) {
  258. // Dummy
  259. }
  260. if(cmdDesc) {
  261. if(typeof cmdDesc === 'function') {
  262. this.cmd().name(i).apply(cmdDesc).end();
  263. } else if(typeof cmdDesc === 'object') {
  264. this.cmd(cmdDesc);
  265. cmdDesc.name(i);
  266. } else throw new Error('Error: Unsupported command declaration type, '
  267. + 'should be a function or COA.Cmd() object');
  268. cmd = this._cmdsByName[i];
  269. }
  270. }
  271. if(cmd) return cmd._parseCmd(argv, unparsed);
  272. unparsed.push(i);
  273. }
  274. return { cmd : this, argv : unparsed };
  275. }
  276. _parseOptsAndArgs(argv) {
  277. const opts = {},
  278. args = {},
  279. nonParsedOpts = this._opts.concat(),
  280. nonParsedArgs = this._args.concat();
  281. let res, i;
  282. while(i = argv.shift()) {
  283. if(i !== '--' && i[0] === '-') {
  284. const m = i.match(/^(--\w[\w-_]*)=(.*)$/);
  285. if(m) {
  286. i = m[1];
  287. this._optsByKey[i]._flag || argv.unshift(m[2]);
  288. }
  289. const opt = this._ejectOpt(nonParsedOpts, this._optsByKey[i]);
  290. if(!opt) return this.reject(`Unknown option: ${i}`);
  291. if(Q.isRejected(res = opt._parse(argv, opts))) return res;
  292. continue;
  293. }
  294. i === '--' && (i = argv.splice(0));
  295. Array.isArray(i) || (i = [i]);
  296. let a;
  297. while(a = i.shift()) {
  298. let arg = nonParsedArgs.shift();
  299. if(!arg) return this.reject(`Unknown argument: ${a}`);
  300. arg._arr && nonParsedArgs.unshift(arg);
  301. if(Q.isRejected(res = arg._parse(a, args))) return res;
  302. }
  303. }
  304. return {
  305. opts : this._setDefaults(opts, nonParsedOpts),
  306. args : this._setDefaults(args, nonParsedArgs)
  307. };
  308. }
  309. _setDefaults(params, desc) {
  310. for(const item of desc)
  311. item._def !== undefined &&
  312. !params.hasOwnProperty(item._name) &&
  313. item._saveVal(params, item._def);
  314. return params;
  315. }
  316. _processParams(params, desc) {
  317. const notExists = [];
  318. for(const item of desc) {
  319. const n = item._name;
  320. if(!params.hasOwnProperty(n)) {
  321. notExists.push(item);
  322. continue;
  323. }
  324. const vals = Array.isArray(params[n])? params[n] : [params[n]];
  325. delete params[n];
  326. let res;
  327. for(const v of vals)
  328. if(Q.isRejected(res = item._saveVal(params, v)))
  329. return res;
  330. }
  331. return this._setDefaults(params, notExists);
  332. }
  333. _parseArr(argv) {
  334. return Q.when(this._parseCmd(argv), p =>
  335. Q.when(p.cmd._parseOptsAndArgs(p.argv), r => ({
  336. cmd : p.cmd,
  337. opts : r.opts,
  338. args : r.args
  339. })));
  340. }
  341. _do(inputPromise) {
  342. return Q.when(inputPromise, input => {
  343. return [this._checkRequired]
  344. .concat(input.cmd._act || [])
  345. .reduce((res, act) =>
  346. Q.when(res, prev => act.call(input.cmd, input.opts, input.args, prev)),
  347. undefined);
  348. });
  349. }
  350. /**
  351. * Parse arguments from simple format like NodeJS process.argv
  352. * and run ahead current program, i.e. call process.exit when all actions done.
  353. *
  354. * @param {String[]} argv - arguments
  355. * @returns {COA.Cmd} - this instance (for chainability)
  356. */
  357. run(argv) {
  358. argv || (argv = process.argv.slice(2));
  359. const cb = code =>
  360. res => res?
  361. this._exit(res.stack || res.toString(), (res.hasOwnProperty('exitCode')? res.exitCode : code) || 0) :
  362. this._exit();
  363. Q.when(this.do(argv), cb(0), cb(1)).done();
  364. return this;
  365. }
  366. /**
  367. * Invoke specified (or current) command using provided
  368. * options and arguments.
  369. *
  370. * @param {String|String[]} [cmds] - subcommand to invoke (optional)
  371. * @param {Object} [opts] - command options (optional)
  372. * @param {Object} [args] - command arguments (optional)
  373. * @returns {Q.Promise}
  374. */
  375. invoke(cmds, opts, args) {
  376. cmds || (cmds = []);
  377. opts || (opts = {});
  378. args || (args = {});
  379. typeof cmds === 'string' && (cmds = cmds.split(' '));
  380. if(arguments.length < 3 && !Array.isArray(cmds)) {
  381. args = opts;
  382. opts = cmds;
  383. cmds = [];
  384. }
  385. return Q.when(this._parseCmd(cmds), p => {
  386. if(p.argv.length)
  387. return this.reject(`Unknown command: ${cmds.join(' ')}`);
  388. return Q.all([
  389. this._processParams(opts, this._opts),
  390. this._processParams(args, this._args)
  391. ]).spread((_opts, _args) =>
  392. this._do({
  393. cmd : p.cmd,
  394. opts : _opts,
  395. args : _args
  396. })
  397. .fail(res => (res && res.exitCode === 0)?
  398. res.toString() :
  399. this.reject(res)));
  400. });
  401. }
  402. }
  403. /**
  404. * Convenient function to run command from tests.
  405. *
  406. * @param {String[]} argv - arguments
  407. * @returns {Q.Promise}
  408. */
  409. Cmd.prototype.do = function(argv) {
  410. return this._do(this._parseArr(argv || []));
  411. };
  412. module.exports = Cmd;