UNPKG

common-bin

Version:
371 lines (327 loc) 11.3 kB
'use strict'; const debug = require('debug')('common-bin'); const yargs = require('yargs'); const parser = require('yargs-parser'); const helper = require('./helper'); const assert = require('assert'); const fs = require('fs'); const path = require('path'); const semver = require('semver'); const { camelCase } = require('change-case'); const chalk = require('chalk'); const DISPATCH = Symbol('Command#dispatch'); const PARSE = Symbol('Command#parse'); const COMMANDS = Symbol('Command#commands'); const VERSION = Symbol('Command#version'); class CommonBin { constructor(rawArgv) { /** * original argument * @type {Array} */ this.rawArgv = rawArgv || process.argv.slice(2); debug('[%s] origin argument `%s`', this.constructor.name, this.rawArgv.join(' ')); /** * yargs * @type {Object} */ this.yargs = yargs(this.rawArgv); /** * helper function * @type {Object} */ this.helper = helper; /** * parserOptions * @type {Object} * @property {Boolean} execArgv - whether extract `execArgv` to `context.execArgv` * @property {Boolean} removeAlias - whether remove alias key from `argv` * @property {Boolean} removeCamelCase - whether remove camel case key from `argv` */ this.parserOptions = { execArgv: false, removeAlias: false, removeCamelCase: false, }; // <commandName, Command> this[COMMANDS] = new Map(); } /** * command handler, could be generator / async function / normal function which return promise * @param {Object} context - context object * @param {String} context.cwd - process.cwd() * @param {Object} context.argv - argv parse result by yargs, `{ _: [ 'start' ], '$0': '/usr/local/bin/common-bin', baseDir: 'simple'}` * @param {Array} context.rawArgv - the raw argv, `[ "--baseDir=simple" ]` * @protected */ run() { this.showHelp(); } /** * load sub commands * @param {String} fullPath - the command directory * @example `load(path.join(__dirname, 'command'))` */ load(fullPath) { assert(fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory(), `${fullPath} should exist and be a directory`); // load entire directory const files = fs.readdirSync(fullPath); const names = []; for (const file of files) { if (path.extname(file) === '.js') { const name = path.basename(file).replace(/\.js$/, ''); names.push(name); this.add(name, path.join(fullPath, file)); } } debug('[%s] loaded command `%s` from directory `%s`', this.constructor.name, names, fullPath); } /** * add sub command * @param {String} name - a command name * @param {String|Class} target - special file path (must contains ext) or Command Class * @example `add('test', path.join(__dirname, 'test_command.js'))` */ add(name, target) { assert(name, `${name} is required`); if (!(target.prototype instanceof CommonBin)) { assert(fs.existsSync(target) && fs.statSync(target).isFile(), `${target} is not a file.`); debug('[%s] add command `%s` from `%s`', this.constructor.name, name, target); target = require(target); // try to require es module if (target && target.__esModule && target.default) { target = target.default; } assert(target.prototype instanceof CommonBin, 'command class should be sub class of common-bin'); } this[COMMANDS].set(name, target); } /** * alias an existing command * @param {String} alias - alias command * @param {String} name - exist command */ alias(alias, name) { assert(alias, 'alias command name is required'); assert(this[COMMANDS].has(name), `${name} should be added first`); debug('[%s] set `%s` as alias of `%s`', this.constructor.name, alias, name); this[COMMANDS].set(alias, this[COMMANDS].get(name)); } /** * start point of bin process */ async start() { try { // replace `--get-yargs-completions` to our KEY, so yargs will not block our DISPATCH const index = this.rawArgv.indexOf('--get-yargs-completions'); if (index !== -1) { // bash will request as `--get-yargs-completions my-git remote add`, so need to remove 2 this.rawArgv.splice(index, 2, `--AUTO_COMPLETIONS=${this.rawArgv.join(',')}`); } await this[DISPATCH](); } catch (err) { this.errorHandler(err); } } /** * default error hander * @param {Error} err - error object * @protected */ errorHandler(err) { console.error(chalk.red(`⚠️ ${err.name}: ${err.message}`)); console.error(chalk.red('⚠️ Command Error, enable `DEBUG=common-bin` for detail')); debug('args %s', process.argv.slice(3)); debug(err.stack); process.exit(1); } /** * print help message to console * @param {String} [level=log] - console level */ showHelp(level = 'log') { this.yargs.showHelp(level); } /** * shortcut for yargs.options * @param {Object} opt - an object set to `yargs.options` */ set options(opt) { this.yargs.options(opt); } /** * shortcut for yargs.usage * @param {String} usage - usage info */ set usage(usage) { this.yargs.usage(usage); } set version(ver) { this[VERSION] = ver; } get version() { return this[VERSION]; } /** * instantiaze sub command * @param {CommonBin} Clz - sub command class * @param {Array} args - args * @return {CommonBin} sub command instance */ getSubCommandInstance(Clz, ...args) { return new Clz(...args); } /** * dispatch command, either `subCommand.exec` or `this.run` * @param {Object} context - context object * @param {String} context.cwd - process.cwd() * @param {Object} context.argv - argv parse result by yargs, `{ _: [ 'start' ], '$0': '/usr/local/bin/common-bin', baseDir: 'simple'}` * @param {Array} context.rawArgv - the raw argv, `[ "--baseDir=simple" ]` * @private */ async [DISPATCH]() { // define --help and --version by default this.yargs // .reset() .completion() .help() .version() .wrap(120) .alias('h', 'help') .alias('v', 'version') .group([ 'help', 'version' ], 'Global Options:'); // get parsed argument without handling helper and version const parsed = await this[PARSE](this.rawArgv); const commandName = parsed._[0]; if (parsed.version && this.version) { console.log(this.version); return; } // if sub command exist if (this[COMMANDS].has(commandName)) { const Command = this[COMMANDS].get(commandName); const rawArgv = this.rawArgv.slice(); rawArgv.splice(rawArgv.indexOf(commandName), 1); debug('[%s] dispatch to subcommand `%s` -> `%s` with %j', this.constructor.name, commandName, Command.name, rawArgv); const command = this.getSubCommandInstance(Command, rawArgv); await command[DISPATCH](); return; } // register command for printing for (const [ name, Command ] of this[COMMANDS].entries()) { this.yargs.command(name, Command.prototype.description || ''); } debug('[%s] exec run command', this.constructor.name); const context = this.context; // print completion for bash if (context.argv.AUTO_COMPLETIONS) { // slice to remove `--AUTO_COMPLETIONS=` which we append this.yargs.getCompletion(this.rawArgv.slice(1), completions => { completions.forEach(x => console.log(x)); }); } else { // handle by self await this.helper.callFn(this.run, [ context ], this); } } /** * getter of context, default behavior is remove `help` / `h` / `version` * @return {Object} context - { cwd, env, argv, rawArgv } * @protected */ get context() { const argv = this.yargs.argv; const context = { argv, cwd: process.cwd(), env: Object.assign({}, process.env), rawArgv: this.rawArgv, }; argv.help = undefined; argv.h = undefined; argv.version = undefined; argv.v = undefined; // remove alias result if (this.parserOptions.removeAlias) { const aliases = this.yargs.getOptions().alias; for (const key of Object.keys(aliases)) { aliases[key].forEach(item => { argv[item] = undefined; }); } } // remove camel case result if (this.parserOptions.removeCamelCase) { for (const key of Object.keys(argv)) { if (key.includes('-')) { argv[camelCase(key)] = undefined; } } } // extract execArgv if (this.parserOptions.execArgv) { // extract from command argv let { debugPort, debugOptions, execArgvObj } = this.helper.extractExecArgv(argv); // extract from WebStorm env `$NODE_DEBUG_OPTION` // Notice: WebStorm 2019 won't export the env, instead, use `env.NODE_OPTIONS="--require="`, but we can't extract it. if (context.env.NODE_DEBUG_OPTION) { console.log('Use $NODE_DEBUG_OPTION: %s', context.env.NODE_DEBUG_OPTION); const argvFromEnv = parser(context.env.NODE_DEBUG_OPTION); const obj = this.helper.extractExecArgv(argvFromEnv); debugPort = obj.debugPort || debugPort; Object.assign(debugOptions, obj.debugOptions); Object.assign(execArgvObj, obj.execArgvObj); } // `--expose_debug_as` is not supported by 7.x+ if (execArgvObj.expose_debug_as && semver.gte(process.version, '7.0.0')) { console.warn(chalk.yellow(`Node.js runtime is ${process.version}, and inspector protocol is not support --expose_debug_as`)); } // remove from origin argv for (const key of Object.keys(execArgvObj)) { argv[key] = undefined; argv[camelCase(key)] = undefined; } // exports execArgv const self = this; context.execArgvObj = execArgvObj; // convert execArgvObj to execArgv // `--require` should be `--require abc --require 123`, not allow `=` // `--debug` should be `--debug=9999`, only allow `=` Object.defineProperty(context, 'execArgv', { get() { const lazyExecArgvObj = context.execArgvObj; const execArgv = self.helper.unparseArgv(lazyExecArgvObj, { excludes: [ 'require' ] }); // convert require to execArgv let requireArgv = lazyExecArgvObj.require; if (requireArgv) { if (!Array.isArray(requireArgv)) requireArgv = [ requireArgv ]; requireArgv.forEach(item => { execArgv.push('--require'); execArgv.push(item.startsWith('./') || item.startsWith('.\\') ? path.resolve(context.cwd, item) : item); }); } return execArgv; }, }); // only exports debugPort when any match if (Object.keys(debugOptions).length) { context.debugPort = debugPort; context.debugOptions = debugOptions; } } return context; } [PARSE](rawArgv) { return new Promise((resolve, reject) => { this.yargs.parse(rawArgv, (err, argv) => { /* istanbul ignore next */ if (err) return reject(err); resolve(argv); }); }); } } module.exports = CommonBin;