123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370 |
- 'use strict';
- const debug = require('debug')('common-bin');
- const co = require('co');
- 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 changeCase = 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
- */
- start() {
- co(function* () {
- // 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(',')}`);
- }
- yield this[DISPATCH]();
- }.bind(this)).catch(this.errorHandler.bind(this));
- }
- /**
- * 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
- */
- * [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 = yield 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);
- yield 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 => {
- // console.log('%s', completions)
- completions.forEach(x => console.log(x));
- });
- } else {
- // handle by self
- yield 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[changeCase.camel(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[changeCase.camel(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;
|