BohemiaInteractive/bi-service

View on GitHub
bin/bi-service.js

Summary

Maintainability
A
3 hrs
Test Coverage
#!/usr/bin/env node

const argv    = process.argv.slice(2);
//TODO yargs API is too much cumbersome which causes some serious issues,
//consider migrating to eg.: commander.js in the next:major
const yargs   = require('yargs')(argv);
const path    = require('path');
const fs      = require('fs');
const logger  = require('bi-logger');//hooks up global uncaughtException listener
const _       = require('lodash');
const json5   = require('json5');
const config  = require('bi-config');
const Promise = require('bluebird');

const Service       = require('../index.js');
const utils         = require('../lib/utils.js');
const runCmd        = require('../lib/cli/runCmd.js');
const getConfigCmd  = require('../lib/cli/getConfigCmd.js');
const testConfigCmd = require('../lib/cli/testConfigCmd.js');

const VERSION       = require('../package.json').version;
const PROJECT_INDEX = path.resolve(process.cwd() + '/index.js');
const HELP_WIDTH    = Math.min(130, yargs.terminalWidth());

var _yargs = null; //yargs parser definition

// adds .json5 loader require.extension
require('json5/lib/require');

//run only if this module isn't required by other node module
if (module.parent === null) {

    _yargs = _initializeYargs(yargs);

    _yargs = _yargs.command('*', '', {
        'get-conf': {//TODO remove, deprecated (has been replated by get:config cmd)
            alias: 'g',
            describe: 'Prints resolved config value',
            type: 'string'
        },
        json5: {//TODO, remove deprecated (has been replated by get:config cmd)
            describe: 'if any json data are about to be printed they will be converted to json5 format',
            type: 'boolean',
            default: false
        },
        offset: {//TODO, remove , deprecated (has been replated by get:config cmd)
            describe: "A String or Number that's used to insert white space into the output JSON string for readability purposes.",
            default: 4
        }
    }, defaultCmd);


    const nativeCommands = [
        'init', 'run', 'get:config', 'test:config', '--version', 'init:seed',
        'init:schema', 'init:migration', 'init:mig', 'mig:status',
        'migration:status', 'migrate', 'seed', 'seed:all'
    ];

    //work around the yargs issue that makes it impossible to generate
    //--help output for commands & sub-commands
    //https://github.com/yargs/yargs/issues/1016
    //
    //as one of the known native commands has been matched, dispatch
    //the command and do NOT try to load additional user defined commands
    if (argv.length && nativeCommands.includes(argv[0])) {
        return _yargs.wrap(HELP_WIDTH).help().argv;
    }

    //no known registered cli command so far would be matched.
    //strict=false causes all unmatched commands to fallback to defaultCmd
    //function which tries to load user defined commands
    return _yargs.wrap(HELP_WIDTH).strict(false).argv;
}

module.exports.defaultCmd  = defaultCmd;

/**
 * @param {Object} ya - yargs
 */
function _initializeYargs(ya) {
    ya = ya
    .usage('$0 <command> [options]')
    .command(['run [options..]', 'start', 'serve'], 'Starts bi-service app - expects it to be located under cwd', {
        cluster: {
            alias: 'c',
            describe: '`<number>` is either a percentage amount (from number of available cpu threads) ' +
            'of childs/workers to be forked in the case of floating point value, or exlicit number of childs in the case of integer',
            default: 0,
            defaultDescription: 'cluster mode disabled',
            type: 'number'
        },
        'parse-pos-args': {
            describe: 'Whether to parse positional arguments',
            type: 'boolean',
            default: true
        }
    }, runCmd)
    .command(['get:config [key]'], 'Dumbs resolved service configuration', {
        json5: {
            describe: 'if any json data are about to be printed they will be converted to json5 format',
            type: 'boolean',
            default: false
        },
        offset: {
            describe: "A String or Number that's used to insert white space into the output JSON string for readability purposes.",
            default: 4
        }
    }, getConfigCmd)
    .command(['test:config'], 'Tries to load the configuration file. Validates configuration.', {
        schema: {
            describe: "File path of additional validation json-schema. Supported filetypes: json/js",
            type: 'string'
        }
    }, testConfigCmd)
    .option('help', {
        alias: 'h',
        describe: 'Show help',
        global: true,
        type: 'boolean'
    })
    .option('config', {
        describe: 'Custom config file destination',
        global: true,
        type: 'string'
    })
    .version('version', 'Prints bi-service version', VERSION);

    _loadExtension('bi-service-template', ya);
    _loadExtension('bi-db-migrations', ya);
    return ya;
}

/*
 * @param {String} name - npm package name
 * @param {Yargs} yargs
 */
function _loadExtension(name, yargs) {
    try{
        ya = require(name)(yargs);
    } catch(e) {
        if (e.code !== 'MODULE_NOT_FOUND') {
            throw e;
        }
    }
}

/**
 * @private
 * @param {Object} argv - shell arguments
 */
function defaultCmd(argv) {

    if (argv['get-conf'] !== undefined) {
        getConfigCmd(argv);
    //if no supported commands or options were matched so far,
    //we try to look for user defined shell commands:
    } else {
        config.initialize({fileConfigPath: argv.config});
        let ya = require('yargs/yargs')();

        ya.wrap(HELP_WIDTH);
        _initializeYargs(ya).help();

        if (fs.existsSync(PROJECT_INDEX)) {
            let service;

            let p = Promise.try(function() {
                service = require(PROJECT_INDEX);
                service.appManager.on('build-app', _onBuildApp);
                //give service enough time to register event listeners
                return _waitTillNextTick();
            }).then(function() {
                return service.$setup({
                    //inspect only resources with exclusive 'shell' tag
                    integrity: ['shell']
                });
            }).catch(function(err) {
                utils._stderr(
                    'Warning: Failure encountered (in user-space) while loading' +
                    ' additional shell commands.\n This is a problem' +
                    ' with service implementation, not with bi-service itself.\n'
                );
                p.cancel();

                //make sure exitCode is not changed by yargs
                Object.defineProperty(process, 'exitCode', {
                    get: function() {return 1;},
                    set: function() {}
                });

                return Promise.fromCallback(function(cb) {
                    logger.error(err, cb);
                }).then(function() {
                    return _setImmediate(_registerShellCommands, argv, ya, Service);
                });
            }).then(function() {
                return _setImmediate(_registerShellCommands, argv, ya, Service, service);
            }).catch(function(err) {
                utils._stderr(err);
                process.exit(1);
            });

            return p;
        } else {
            return _setImmediate(_registerShellCommands, argv, ya, Service);
        }
    }
}

/**
 * @return <Promise>
 */
function _waitTillNextTick(fn) {
    return new Promise(function(resolve, reject) {
        process.nextTick(resolve);
    });
}

/**
 * setImmediate which returns a Promise
 * @return <Promise>
 */
function _setImmediate(fn) {
    let args = Array.prototype.slice.call(arguments, 1);
    return new Promise(function(resolve, reject) {
        setImmediate(function() {
            try {
                fn.apply(this, args);
            } catch(e) {
                return reject(e);
            }
            resolve();
        });
    });
}

/**
 * `build-app` AppManager listener
 * @private
 */
function _onBuildApp(app) {
    let proto = Object.getPrototypeOf(app);
    if (proto.constructor && proto.constructor.name === 'ShellApp') {
        app.once('post-init', function() {
            this.build();
            this.listen();
        });
    }
}

/**
 * @private
 * @param {Object} argv
 * @param {Yargs} yargs
 * @param {Function} Service - Service constructor
 * @param {Service} service - instance of Service
 */
function _registerShellCommands(argv, yargs, Service, service) {

    let args = process.argv.slice(2);

    Service.emit('shell-cmd', yargs);
    if (service) {
        service.emit('shell-cmd', yargs);
    }

    if (!args.length) {
        yargs.showHelp();
        process.exit(1);
    }
    yargs.parse(args);
}