Home Manual Reference Source Test

src/AtSCMCli.js

import { realpathSync } from 'fs';
import { join } from 'path';
import { EOL } from 'os';
import Liftoff from 'liftoff';
import yargs from 'yargs';
import gulplog from 'gulplog';
import { jsVariants } from 'interpret';
import { yellow } from 'chalk';
import pkg from '../package.json';
import Logger from './lib/util/Logger';
import Options, { GlobalOptions } from './cli/Options';
import Commands from './cli/Commands';
import UsageError from './lib/error/UsageError';
import { readJson } from './lib/util/fs';

/**
 * The main class. Handles arguments and runs commands.
 * @extends {Liftoff}
 */
export default class AtSCMCli extends Liftoff {
  /**
   * The name under which the module is available from the command line.
   * @type {string}
   */
  static get BinName() {
    return Object.keys(pkg.bin)[0];
  }

  /**
   * The filename used for configuration files.
   * @type {string}
   */
  static get ConfigName() {
    return 'Atviseproject';
  }

  /**
   * Reports an error and exits the process with return code `1`.
   * @param {Error} err The error that occurred.
   */
  _reportCliError(err) {
    Logger.error(Logger.colors.red(err.message));

    if (err instanceof UsageError) {
      Logger.info(err.help);
    } else {
      Logger.debug(err.stack);

      if (err instanceof SyntaxError && this._failedRequires.length) {
        Logger.info(yellow(`You may have to install the '${this._failedRequires[0]}' module.`));
        Logger.info(['Other failed requires:', ...this._failedRequires].join(`${EOL} - `));
      }
    }

    process.exitCode = 1;
  }

  /**
   * Creates a new {@link AtSCMCli} object based on command line arguments.
   * @param {string[]} argv The command line arguments to use. If no command is provided and neither
   * `--help` nor `--version` are used, the command `run` is added.
   * @throws {UsageError} Throws an error if option parsing fails.
   */
  constructor(argv = []) {
    super({
      name: AtSCMCli.BinName,
      configName: AtSCMCli.ConfigName,
      extensions: jsVariants,
    });

    this.on('require', function (name) {
      Logger.debug('Requiring external module', Logger.colors.magenta(name));
    });

    /** If requiring an external module failed.
     * @type {string[]} */
    this._failedRequires = [];

    this.on('requireFail', function (name) {
      this._failedRequires.push(name);

      Logger.debug(
        Logger.colors.red('Failed to load external module'),
        Logger.colors.magenta(name)
      );
    });

    /**
     * `true` if the instance was created by running the binaries, `false` if used programmatically.
     * @type {Boolean}
     */
    this.runViaCli = realpathSync(process.argv[1]) === require.resolve('./bin/atscm');

    /**
     * The raw, unparsed command line arguments the Cli was created with.
     * @type {String[]}
     */
    this._argv = argv;

    // If no command is given, default to "run"
    const commandNames = Commands.map((c) => c.name);

    /**
     * The options parsed from {@link AtSCMCli#_argv}. Note that **these options are not complete**
     * until {@link AtSCMCli#launch} was called.
     * @type {Object}
     */
    this.options = yargs(argv)
      .version(false)
      .help(false)
      .env('ATSCM')
      .option(GlobalOptions)
      .fail((msg, e, y) => {
        const err = new UsageError(msg, y.help());

        if (this.runViaCli) {
          gulplog.on('error', () => {}); // Prevent logger to throw an error

          this._reportCliError(err);
        } else {
          throw err;
        }
      }).argv;

    if (!this.options.help && !this.options.version) {
      if (this.options._.filter((e) => commandNames.includes(e)).length === 0) {
        this._argv.unshift('run');
      }
    }

    // Initialize logger
    Logger.applyOptions(this.options);

    const globalOptionNames = Object.keys(GlobalOptions);

    /**
     * An instance of {@link yargs} responible for parsing options.
     * @type {yargs}
     */
    this.argumentsParser = Commands.reduce(
      (parser, command) =>
        parser.command(
          command.usage,
          command.description,
          (y) => {
            y.usage(`Usage: $0 ${command.usage}`);
            y.option(command.options);

            y.group(Object.keys(command.options), 'Command specific options:');
            y.group(globalOptionNames, 'Global options:');

            y.strict(command.strict);
            y.help('help', Options.help.desc).alias('help', 'h');
            y.demandCommand(...command.demandCommand);
          },
          () => (this.command = command)
        ),
      yargs()
        .env('ATSCM')
        .usage('Usage: $0 [cmd=run]')
        .version(false)
        .options(GlobalOptions)
        .global(globalOptionNames)
        .strict()
        .help('help', Options.help.desc)
        .alias('help', 'h')
    );
  }

  /**
   * Used to expose project config overrides via environment variables. All project options are
   * exposed as `ATSCM_PROJECT__{KEY}={VALUE}`.
   * @param {Object} config The object to expose.
   * @param {string} key The key currently handled.
   * @param {string} [base=ATSCM_PROJECT__] The parent key.
   */
  _exposeOverride(config, key, base = 'ATSCM_PROJECT__') {
    const currentKey = `${base}${key.toUpperCase()}`;

    if (typeof config[key] === 'object') {
      const c = config[key];

      Object.keys(c).forEach((k) => this._exposeOverride(c, k, `${currentKey}__`));
    } else {
      process.env[currentKey] = config[key];
      Logger.debug(`Setting ${currentKey}:`, Logger.format.value(config[key]));
    }
  }

  /**
   * Parses arguments and exposes the project options as environment variables.
   * @return {Promise<Object, UsageError>} Rejected with a {@link UsageError} if parsing failed,
   * otherwise fulfilled with the parsed arguments.
   */
  parseArguments() {
    return new Promise((resolve, reject) => {
      this.options = this.argumentsParser
        .fail((msg, err, y) => reject(new UsageError(msg, y.help())))
        .parse(this._argv);

      Object.keys(this.options.project).forEach((key) =>
        this._exposeOverride(this.options.project, key)
      );

      resolve(this.options);
    });
  }

  /**
   * Returns a {@link Liftoff.Environment} for the Cli.
   * @param {boolean} [findUp=false] If the environment should be searched for in parent
   * directories.
   * @return {Promise<Object>} Fulfilled with a {@link Liftoff} environment.
   */
  getEnvironment(findUp = true) {
    return new Promise((resolve) => {
      super.launch(
        {
          cwd: this.options.cwd,
          configPath: findUp
            ? this.options.projectfile
            : join(this.options.cwd || process.cwd(), `${this.constructor.ConfigName}.js`),
          require: this.options.require,
        },
        (env) => resolve((this.environment = env))
      );
    });
  }

  /**
   * Gets a {@link Liftoff.Environment} and validates a config file and a local module was found.
   * @return {Promise<Object, Error>} Resolved with the {@link Liftoff environment}, rejected if the
   * config file or the local module cannot be found.
   */
  requireEnvironment() {
    return this.getEnvironment().then((env) => {
      if (!env.modulePath) {
        throw new Error(`Local ${AtSCMCli.BinName} not found`);
      }

      if (!env.configPath) {
        throw new Error('No config file found');
      }

      return env;
    });
  }

  /**
   * Returns the CLI version and, if a local module could be found, the local version.
   * @return {Promise<{cli: string, local: ?string}>} Fulfilled with the found cli and local
   * version.
   */
  async getVersion() {
    const env = await this.getEnvironment();

    const projectPackage =
      env.modulePath && (await readJson(join(env.cwd, 'package.json')).catch(() => undefined));

    return {
      cli: pkg.version,
      local: env.modulePath ? env.modulePackage.version : undefined,
      server: projectPackage && projectPackage.engines && projectPackage.engines.atserver,
    };
  }

  /**
   * Gets and prints the CLI version and, if a local module could be found, the local version.
   * @return {Promise<{cli: string, local: ?string}>} Fulfilled with the found cli and local
   * version.
   */
  async printVersion() {
    const { cli, local, server } = await this.getVersion();

    const versions = [
      ['atscm-cli', cli],
      local && ['atscm', local],
      server && ['atvise server', server],
    ].filter((v) => v);

    const maxLength = versions.reduce((length, [label]) => Math.max(length, label.length), 0);

    versions.forEach(([label, version]) =>
      Logger.info(label.padEnd(maxLength + 1), Logger.format.number(version))
    );
  }

  /**
   * Runs the command specified in the command line arguments ({@link AtSCMCli#_argv}). **Note that
   * this will only work if {@link AtSCMCli#parseArguments} was called before.**.
   * @return {Promise<*, Error>} Fulfilled if the command succeeded.
   */
  runCommand() {
    if (this.options.version) {
      return this.printVersion();
    }

    if (this.command) {
      return (this.command.requiresEnvironment(this)
        ? this.requireEnvironment()
        : Promise.resolve()
      ).then(() => this.command.run(this));
    }

    Logger.warn('No command specified');

    return Promise.resolve(this);
  }

  /**
   * Parses arguments and runs the specified command.
   * @return {Promise<*, Error>} Fulfilled if the command succeeded. Note that, if the instance is
   * run through the binary all rejections will be handled.
   */
  launch() {
    const app = this.parseArguments()
      .then(() => {
        if (process.env.ATSCM_DEBUG || this.options.debug) {
          process.env.ATSCM_DEBUG = process.env.ATSCM_DEBUG || 'true';
          require('source-map-support').install(); // eslint-disable-line global-require
        }
      })
      .then(() => this.runCommand());

    if (this.runViaCli) {
      return app.catch((err) => this._reportCliError(err));
    }

    return app;
  }
}