command.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. 'use strict';
  2. const debug = require('debug')('common-bin');
  3. const co = require('co');
  4. const yargs = require('yargs');
  5. const parser = require('yargs-parser');
  6. const helper = require('./helper');
  7. const assert = require('assert');
  8. const fs = require('fs');
  9. const path = require('path');
  10. const semver = require('semver');
  11. const changeCase = require('change-case');
  12. const chalk = require('chalk');
  13. const DISPATCH = Symbol('Command#dispatch');
  14. const PARSE = Symbol('Command#parse');
  15. const COMMANDS = Symbol('Command#commands');
  16. const VERSION = Symbol('Command#version');
  17. class CommonBin {
  18. constructor(rawArgv) {
  19. /**
  20. * original argument
  21. * @type {Array}
  22. */
  23. this.rawArgv = rawArgv || process.argv.slice(2);
  24. debug('[%s] origin argument `%s`', this.constructor.name, this.rawArgv.join(' '));
  25. /**
  26. * yargs
  27. * @type {Object}
  28. */
  29. this.yargs = yargs(this.rawArgv);
  30. /**
  31. * helper function
  32. * @type {Object}
  33. */
  34. this.helper = helper;
  35. /**
  36. * parserOptions
  37. * @type {Object}
  38. * @property {Boolean} execArgv - whether extract `execArgv` to `context.execArgv`
  39. * @property {Boolean} removeAlias - whether remove alias key from `argv`
  40. * @property {Boolean} removeCamelCase - whether remove camel case key from `argv`
  41. */
  42. this.parserOptions = {
  43. execArgv: false,
  44. removeAlias: false,
  45. removeCamelCase: false,
  46. };
  47. // <commandName, Command>
  48. this[COMMANDS] = new Map();
  49. }
  50. /**
  51. * command handler, could be generator / async function / normal function which return promise
  52. * @param {Object} context - context object
  53. * @param {String} context.cwd - process.cwd()
  54. * @param {Object} context.argv - argv parse result by yargs, `{ _: [ 'start' ], '$0': '/usr/local/bin/common-bin', baseDir: 'simple'}`
  55. * @param {Array} context.rawArgv - the raw argv, `[ "--baseDir=simple" ]`
  56. * @protected
  57. */
  58. run() {
  59. this.showHelp();
  60. }
  61. /**
  62. * load sub commands
  63. * @param {String} fullPath - the command directory
  64. * @example `load(path.join(__dirname, 'command'))`
  65. */
  66. load(fullPath) {
  67. assert(fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory(),
  68. `${fullPath} should exist and be a directory`);
  69. // load entire directory
  70. const files = fs.readdirSync(fullPath);
  71. const names = [];
  72. for (const file of files) {
  73. if (path.extname(file) === '.js') {
  74. const name = path.basename(file).replace(/\.js$/, '');
  75. names.push(name);
  76. this.add(name, path.join(fullPath, file));
  77. }
  78. }
  79. debug('[%s] loaded command `%s` from directory `%s`',
  80. this.constructor.name, names, fullPath);
  81. }
  82. /**
  83. * add sub command
  84. * @param {String} name - a command name
  85. * @param {String|Class} target - special file path (must contains ext) or Command Class
  86. * @example `add('test', path.join(__dirname, 'test_command.js'))`
  87. */
  88. add(name, target) {
  89. assert(name, `${name} is required`);
  90. if (!(target.prototype instanceof CommonBin)) {
  91. assert(fs.existsSync(target) && fs.statSync(target).isFile(), `${target} is not a file.`);
  92. debug('[%s] add command `%s` from `%s`', this.constructor.name, name, target);
  93. target = require(target);
  94. // try to require es module
  95. if (target && target.__esModule && target.default) {
  96. target = target.default;
  97. }
  98. assert(target.prototype instanceof CommonBin,
  99. 'command class should be sub class of common-bin');
  100. }
  101. this[COMMANDS].set(name, target);
  102. }
  103. /**
  104. * alias an existing command
  105. * @param {String} alias - alias command
  106. * @param {String} name - exist command
  107. */
  108. alias(alias, name) {
  109. assert(alias, 'alias command name is required');
  110. assert(this[COMMANDS].has(name), `${name} should be added first`);
  111. debug('[%s] set `%s` as alias of `%s`', this.constructor.name, alias, name);
  112. this[COMMANDS].set(alias, this[COMMANDS].get(name));
  113. }
  114. /**
  115. * start point of bin process
  116. */
  117. start() {
  118. co(function* () {
  119. // replace `--get-yargs-completions` to our KEY, so yargs will not block our DISPATCH
  120. const index = this.rawArgv.indexOf('--get-yargs-completions');
  121. if (index !== -1) {
  122. // bash will request as `--get-yargs-completions my-git remote add`, so need to remove 2
  123. this.rawArgv.splice(index, 2, `--AUTO_COMPLETIONS=${this.rawArgv.join(',')}`);
  124. }
  125. yield this[DISPATCH]();
  126. }.bind(this)).catch(this.errorHandler.bind(this));
  127. }
  128. /**
  129. * default error hander
  130. * @param {Error} err - error object
  131. * @protected
  132. */
  133. errorHandler(err) {
  134. console.error(chalk.red(`⚠️ ${err.name}: ${err.message}`));
  135. console.error(chalk.red('⚠️ Command Error, enable `DEBUG=common-bin` for detail'));
  136. debug('args %s', process.argv.slice(3));
  137. debug(err.stack);
  138. process.exit(1);
  139. }
  140. /**
  141. * print help message to console
  142. * @param {String} [level=log] - console level
  143. */
  144. showHelp(level = 'log') {
  145. this.yargs.showHelp(level);
  146. }
  147. /**
  148. * shortcut for yargs.options
  149. * @param {Object} opt - an object set to `yargs.options`
  150. */
  151. set options(opt) {
  152. this.yargs.options(opt);
  153. }
  154. /**
  155. * shortcut for yargs.usage
  156. * @param {String} usage - usage info
  157. */
  158. set usage(usage) {
  159. this.yargs.usage(usage);
  160. }
  161. set version(ver) {
  162. this[VERSION] = ver;
  163. }
  164. get version() {
  165. return this[VERSION];
  166. }
  167. /**
  168. * instantiaze sub command
  169. * @param {CommonBin} Clz - sub command class
  170. * @param {Array} args - args
  171. * @return {CommonBin} sub command instance
  172. */
  173. getSubCommandInstance(Clz, ...args) {
  174. return new Clz(...args);
  175. }
  176. /**
  177. * dispatch command, either `subCommand.exec` or `this.run`
  178. * @param {Object} context - context object
  179. * @param {String} context.cwd - process.cwd()
  180. * @param {Object} context.argv - argv parse result by yargs, `{ _: [ 'start' ], '$0': '/usr/local/bin/common-bin', baseDir: 'simple'}`
  181. * @param {Array} context.rawArgv - the raw argv, `[ "--baseDir=simple" ]`
  182. * @private
  183. */
  184. * [DISPATCH]() {
  185. // define --help and --version by default
  186. this.yargs
  187. // .reset()
  188. .completion()
  189. .help()
  190. .version()
  191. .wrap(120)
  192. .alias('h', 'help')
  193. .alias('v', 'version')
  194. .group([ 'help', 'version' ], 'Global Options:');
  195. // get parsed argument without handling helper and version
  196. const parsed = yield this[PARSE](this.rawArgv);
  197. const commandName = parsed._[0];
  198. if (parsed.version && this.version) {
  199. console.log(this.version);
  200. return;
  201. }
  202. // if sub command exist
  203. if (this[COMMANDS].has(commandName)) {
  204. const Command = this[COMMANDS].get(commandName);
  205. const rawArgv = this.rawArgv.slice();
  206. rawArgv.splice(rawArgv.indexOf(commandName), 1);
  207. debug('[%s] dispatch to subcommand `%s` -> `%s` with %j', this.constructor.name, commandName, Command.name, rawArgv);
  208. const command = this.getSubCommandInstance(Command, rawArgv);
  209. yield command[DISPATCH]();
  210. return;
  211. }
  212. // register command for printing
  213. for (const [ name, Command ] of this[COMMANDS].entries()) {
  214. this.yargs.command(name, Command.prototype.description || '');
  215. }
  216. debug('[%s] exec run command', this.constructor.name);
  217. const context = this.context;
  218. // print completion for bash
  219. if (context.argv.AUTO_COMPLETIONS) {
  220. // slice to remove `--AUTO_COMPLETIONS=` which we append
  221. this.yargs.getCompletion(this.rawArgv.slice(1), completions => {
  222. // console.log('%s', completions)
  223. completions.forEach(x => console.log(x));
  224. });
  225. } else {
  226. // handle by self
  227. yield this.helper.callFn(this.run, [ context ], this);
  228. }
  229. }
  230. /**
  231. * getter of context, default behavior is remove `help` / `h` / `version`
  232. * @return {Object} context - { cwd, env, argv, rawArgv }
  233. * @protected
  234. */
  235. get context() {
  236. const argv = this.yargs.argv;
  237. const context = {
  238. argv,
  239. cwd: process.cwd(),
  240. env: Object.assign({}, process.env),
  241. rawArgv: this.rawArgv,
  242. };
  243. argv.help = undefined;
  244. argv.h = undefined;
  245. argv.version = undefined;
  246. argv.v = undefined;
  247. // remove alias result
  248. if (this.parserOptions.removeAlias) {
  249. const aliases = this.yargs.getOptions().alias;
  250. for (const key of Object.keys(aliases)) {
  251. aliases[key].forEach(item => {
  252. argv[item] = undefined;
  253. });
  254. }
  255. }
  256. // remove camel case result
  257. if (this.parserOptions.removeCamelCase) {
  258. for (const key of Object.keys(argv)) {
  259. if (key.includes('-')) {
  260. argv[changeCase.camel(key)] = undefined;
  261. }
  262. }
  263. }
  264. // extract execArgv
  265. if (this.parserOptions.execArgv) {
  266. // extract from command argv
  267. let { debugPort, debugOptions, execArgvObj } = this.helper.extractExecArgv(argv);
  268. // extract from WebStorm env `$NODE_DEBUG_OPTION`
  269. // Notice: WebStorm 2019 won't export the env, instead, use `env.NODE_OPTIONS="--require="`, but we can't extract it.
  270. if (context.env.NODE_DEBUG_OPTION) {
  271. console.log('Use $NODE_DEBUG_OPTION: %s', context.env.NODE_DEBUG_OPTION);
  272. const argvFromEnv = parser(context.env.NODE_DEBUG_OPTION);
  273. const obj = this.helper.extractExecArgv(argvFromEnv);
  274. debugPort = obj.debugPort || debugPort;
  275. Object.assign(debugOptions, obj.debugOptions);
  276. Object.assign(execArgvObj, obj.execArgvObj);
  277. }
  278. // `--expose_debug_as` is not supported by 7.x+
  279. if (execArgvObj.expose_debug_as && semver.gte(process.version, '7.0.0')) {
  280. console.warn(chalk.yellow(`Node.js runtime is ${process.version}, and inspector protocol is not support --expose_debug_as`));
  281. }
  282. // remove from origin argv
  283. for (const key of Object.keys(execArgvObj)) {
  284. argv[key] = undefined;
  285. argv[changeCase.camel(key)] = undefined;
  286. }
  287. // exports execArgv
  288. const self = this;
  289. context.execArgvObj = execArgvObj;
  290. // convert execArgvObj to execArgv
  291. // `--require` should be `--require abc --require 123`, not allow `=`
  292. // `--debug` should be `--debug=9999`, only allow `=`
  293. Object.defineProperty(context, 'execArgv', {
  294. get() {
  295. const lazyExecArgvObj = context.execArgvObj;
  296. const execArgv = self.helper.unparseArgv(lazyExecArgvObj, { excludes: [ 'require' ] });
  297. // convert require to execArgv
  298. let requireArgv = lazyExecArgvObj.require;
  299. if (requireArgv) {
  300. if (!Array.isArray(requireArgv)) requireArgv = [ requireArgv ];
  301. requireArgv.forEach(item => {
  302. execArgv.push('--require');
  303. execArgv.push(item.startsWith('./') || item.startsWith('.\\') ? path.resolve(context.cwd, item) : item);
  304. });
  305. }
  306. return execArgv;
  307. },
  308. });
  309. // only exports debugPort when any match
  310. if (Object.keys(debugOptions).length) {
  311. context.debugPort = debugPort;
  312. context.debugOptions = debugOptions;
  313. }
  314. }
  315. return context;
  316. }
  317. [PARSE](rawArgv) {
  318. return new Promise((resolve, reject) => {
  319. this.yargs.parse(rawArgv, (err, argv) => {
  320. /* istanbul ignore next */
  321. if (err) return reject(err);
  322. resolve(argv);
  323. });
  324. });
  325. }
  326. }
  327. module.exports = CommonBin;