Home Intro Source Mebo GitHub

src/Handlers/Cli.js

const stream = require('stream');
const path = require('path');
const assert = require('assert');
const ejs = require('ejs');
const TypeCheck = require('js-typecheck');
const Settings = require('../Settings');
const Input = require('../Input');
const Metadata = require('../Metadata');
const Handler = require('../Handler');
const MeboErrors = require('../MeboErrors');

// symbols used for private members to avoid any potential clashing
// caused by re-implementations
const _args = Symbol('args');
const _stdout = Symbol('stdout');
const _stderr = Symbol('stderr');
const _commands = Symbol('commands');

// handler name (used for registration)
const _handlerName = 'cli';


/**
* Handles the command-line integration based on docopt specification.
*
* It enables the execution of actions triggered as command-line
* applications. The args are parsed using the reader {@link CliArgs} and
* the output is provided by writer {@link CliOutput}.
*
* Using cli handler:
*
* **Creating an action that can be executed through command-line**
* ```
* @Mebo.grant('cli')
* @Mebo.register('myAction')
* class MyAction extends Mebo.Action{
*   constructor(){
*     super();
*     this.setMeta('description', 'Welcome!');
*     this.createInput('myArgument: text', {elementType: 'argument', description: 'my argument'});
*     this.createInput('myOption: bool', {description: 'my option'});
*   }
*
*   async _perform(data){
*     const result = {
*       myArgument: data.myArgument,
*       myOption: data.myOption,
*     };
*     return result;
*   }
* }
*
* ```
*
* The command-line support can be invoked in two ways:
*
* **1) Multiple commands (recommended):** Used to provide multiple granted actions through command-line
* ```
* if (require.main === module) {
*   const cli = Mebo.Handler.get('cli');
*   if (cli.isSupported()){
*     cli.init();
*   }
* }
* ```
* When this method is used the command-line help (`-h` or `--help`)
* provides a list of commands:
*
* ```
* node mycli.js --help
*  __  __      _
* |  \/  | ___| |__   ___
* | |\/| |/ _ \ '_ \ / _ \_
* | |  | |  __/ |_) | (_) |
* |_|  |_|\___|_.__/ \___/
*
* Available commands:
*     myAction
* ```
*
* In order to access the help for each command you need to provide
* the command name before the help flag (`-h` or `--help`)
* ```
* node . myAction --help
* Welcome.
*
* Usage: node mycli.js myAction [options] <my-argument>
*
* Arguments:
*   my-argument     my argument (text type).
*
* Options:
*   --my-option     my option (bool type).
* ```
*
* A complete example about providing multiple commands through command-line
* can be found at: https://github.com/meboHQ/example-cli
*
* **2) Single command:** Used to provide just a single granted action
* through command-line
*
* ```
* if (require.main === module) {
*   // creating an cli handler which is used to load the arguments
*   // arguments to the action and to output the result back to the stream
*   const cli = Mebo.Handler.create('cli');
*
*   // loading the parsed information to the action
*   cli.runAction('myAction').then((result) => {
*
*   // success output
*   cli.output(result);
*
*   // error output
*   }).catch((err) => {
*     cli.output(err);
*   });
* }
* ```
*
* When using the single command the help flag (`-h` or `--help`)
* provides the help about the command directly:
* ```
* node mycli.js --help
* Welcome.
*
* Usage: node mycli.js [options] <my-argument>
*
* Arguments:
*   my-argument     my argument (text type).
*
* Options:
*   --my-option     my option (bool type).
* ```
*
* @see http://docopt.org
*/
class Cli extends Handler{

  /**
   * Creates an cli handler
   *
   * @param {Array<string>} argv - argument list
   * @param {stream} stdout - stream used as stdout
   * @param {stream} stderr - stream used as stderr
   */
  constructor(argv=process.argv, stdout=process.stdout, stderr=process.stderr){
    super();

    this.setArgs(argv);
    this.setStdout(stdout);
    this.setStderr(stderr);
  }

  /**
   * Sets a list of argument values used by the reader. It must follow
   * the same pattern found at `process.argv`
   *
   * @param {Array<string>} value - argument list
   */
  setArgs(value){
    assert(TypeCheck.isList(value), 'value needs to be a list');
    assert(value.length >= 2, 'missing first argument process.execPath and second argument javaScript file being executed');

    this[_args] = value.slice(0);
  }

  /**
   * Returns a list of argument values used by the reader, by default it uses
   * `process.argv`.
   *
   * @return {Array<string>}
   */
  args(){
    return this[_args];
  }

  /**
   * Sets the stdout stream
   *
   * @param {stream} value - stream used as stdout
   */
  setStdout(value){
    assert(value instanceof stream, 'Invalid stream type');

    this[_stdout] = value;
  }

  /**
   * Returns the stream used as stdout
   *
   * @return {stream}
   */
  stdout(){
    return this[_stdout];
  }

  /**
   * Sets the stderr stream
   *
   * @param {stream} value - stream used as stderr
   */
  setStderr(value){
    assert(value instanceof stream, 'Invalid stream type');

    this[_stderr] = value;
  }

  /**
   * Returns the stream used as stderr
   *
   * @return {stream}
   */
  stderr(){
    return this[_stderr];
  }

  /**
   * Returns a boolean telling if cli support can be initialized based on the input argv.
   *
   * @param {Array<string>} [argv] - custom list of arguments, if not specified
   * it uses the `process.argv` (this information is passed to the creation of cli handler)
   * @return {boolean}
   */
  static isSupported(argv=process.argv){
    // checks if there is a command being passed and if the file being
    // executed by node is not inside of a dependency (for instance, mocha
    // contains its own set of command-line args)
    // Also, when argv is passed using process.argv (default) we don't need to
    // worry about filtering out specific args related with node itself. Since,
    // those args are handled through process.execArgv
    return (argv.length >= 3 && !path.normalize(argv[1]).split(path.sep).includes('node_modules'));
  }

  /**
   * Initializes a registered action as cli.
   *
   * *`CliActions/Default.js`: defining an action as cli:*
   * ```
   * const Mebo = require('mebo');
   *
   * @Mebo.grant('cli', 'cli.default', {command: 'default'})
   * @Mebo.register('cli.default')
   * class Default extends Mebo.Action{
   *
   *  constructor(){
   *   super();
   *     this.createInput('name: text');
   *     this.createInput('myOtherInput?: numeric');
   *  }
   *  async _perform(data){
   *    // ...
   *  }
   * }
   *
   * ```
   *
   * *`index.js`:*
   * ```
   * const Mebo = require('mebo');
   * require('Clis/Default.js');
   *
   * // ...
   * const cli = Mebo.Handler.get('cli');
   * if (cli.isSupported()){
   *    cli.init();
   * }
   * ```
   *
   * *Listing available actions:*
   * ```
   * node myFile.js --help
   * ```
   *
   * *Showing help from the app:*
   * ```
   * node myFile.js myCli --help
   * ```
   *
   * *Executing an cli by specifying custom args:*
   * ```
   * node myFile.js myCli --arg-a=1 --arg-b=2
   * ```
   * @param {Object} options - plain object containing custom options
   * @param {Array<string>} [options.argv] - custom list of arguments, if not specified
   * it uses the `process.argv` (this information is passed to the creation of cli handler)
   * @param {stream} [options.stdout] - custom writable stream, if not specified it uses
   * `process.stdout` (this information is passed to the creation of cli handler)
   * @param {stream} [options.stderr] - custom writable stream, if not specified it uses
   * `process.stderr` (this information is passed to the creation of cli handler)
   * @param {function} [options.finalizeCallback] - callback executed after the
   * output. (The value the output value is passed as argument)
   */
  static init(
    {
      argv=process.argv,
      stdout=process.stdout,
      stderr=process.stderr,
      showBanner=true,
      description='',
      finalizeCallback=null,
    }={},
  ){
    assert(this.isSupported(argv), 'cli support cannot be initialized with the current with input argv');
    assert(TypeCheck.isString(description), 'description needs to be defined as string');

    const useCommand = argv[2];
    const parsedArgs = argv.slice(0);
    // removing the command name from parsed args
    parsedArgs.splice(2, 1);

    const handler = Handler.create(_handlerName, '*', parsedArgs, stdout, stderr);

    const _handlerOutput = (output) => {
      try{
        handler.output(output);
      }
      finally{
        if (finalizeCallback){
          finalizeCallback(output);
        }
      }
    };

    // list the available action names grated for cli
    const availableCommands = this[_commands][_handlerName];
    if (['-h', '--help'].includes(useCommand)){

      ejs.renderFile(
        Settings.get('handler/cli/commandsHelpTemplate'),
        {
          commands: Object.keys(availableCommands),
          showBanner,
          description,
        },
      ).then((result) => {
        _handlerOutput(new MeboErrors.Help(result));
      }).catch(/* istanbul ignore next */ (error) => {
        _handlerOutput(error);
      });
    }
    // command not found
    else if (!(useCommand in availableCommands)){
      _handlerOutput(new MeboErrors.Help(`Could not initialize '${useCommand}', command not found!`));
    }
    // found cli, initializing from it
    else{
      handler.runAction(availableCommands[useCommand].actionName).then((result) => {
        _handlerOutput(result);
      }).catch(/* istanbul ignore next */ (err) => {
        _handlerOutput(err);
      });
    }
  }

  /**
   * Return a list of commands mapped to the action name.
   *
   * @param {string} actionName - registered action name
   * @return {Array<string>}
   * @protected
   */
  static actionCommands(actionName){
    assert(TypeCheck.isString(actionName), 'actionName needs to be defined as string');

    const result = [];

    if (_handlerName in this[_commands]){
      for (const name in this[_commands][_handlerName]){
        if (this[_commands][_handlerName][name].actionName === actionName){
          result.push(name);
        }
      }
    }

    return result;
  }

  /**
   * This method can be re-implemented by derived classes to hook when an {@link Action}
   * is granted for a handler ({@link Handler.grantAction})
   *
   * @param {string} handlerName - registered handler name
   * @param {string} actionName - registered action name
   * @param {object} options - custom options passed during {@link Handler.grantAction}
   * @param {string} [options.command] - command name used to initialize the cli, otherwise
   * if not defined the actionName is used instead
   * @protected
   */
  static _grantingAction(handlerName, actionName, {command=null}={}){

    const useCommand = (command || actionName);
    assert(TypeCheck.isString(useCommand), 'name needs to be defined as string');

    if (!(handlerName in this[_commands])){
      this[_commands][handlerName] = {};
    }

    const options = {};
    options.actionName = actionName;

    this[_commands][handlerName][useCommand] = options;
  }

  /**
   * Creates an instance of a reader for the current handler.
   * This passes the {@link Cli.args} to the reader.
   *
   * @param {Action} action - action instance used by the reader to parse the values
   * @param {Object} options - plain object containing the options passed to the reader
   * @return {Reader}
   * @protected
   */
  _createReader(action, options){

    return super._createReader(
      action,
      options,
      this.args(),
    );
  }

  /**
   * Creates an instance of a writer for the current handler
   *
   * This passes the {@link Cli.stdout} and {@link Cli.stderr}
   * to the writer.
   *
   * @param {*} value - arbitrary value passed to the writer
   * @param {Object} options - plain object containing the options passed to the writer
   * @return {Writer}
   * @protected
   */
  _createWriter(value, options){

    return super._createWriter(
      value,
      options,
      this.stdout(),
      this.stderr(),
    );
  }
}

Cli[_commands] = {};

// registering properties
Input.registerProperty(Input, 'elementType', 'option');
Input.registerProperty(Input, 'shortOption');

// registering option vars
Metadata.registerOptionVar('$cli', `handler.${_handlerName}`);
Metadata.registerOptionVar('$cliResult', '$cli.writeOptions.result');

// default settings
Settings.set(
  'handler/cli/commandsHelpTemplate',
  path.join(path.dirname(path.dirname(path.dirname(__filename))), 'data', 'handlers', 'cli', 'commandsHelp.ejs'),
);

// registering handler
Handler.register(Cli, _handlerName);

module.exports = Cli;