cli.js

#!/usr/bin/env node
'use strict';

/**
 * ### Command Line Interface
 *
 * This module acts as the command line interface for the database builder and
 * parses the command line arguments and options to build the Database Build
 * Options ({@link Options}) that are used by the {@link module:run|run} module.
 *
 * See `node ./src/cli.js --usage` for the script's command line usage.
 * @module cli
 */



// Set process title
process.title = require('../package.json').name;


const fs = require('fs');
const path = require('path');
const props = require('../package.json');
const options = require('./helpers/options.js');
const log = require('./helpers/log.js');
const errors = require('./helpers/errors.js');
const run = require('./run.js');
const report = require('./report.js');



// COMMAND LINE ARGUMENTS
let ARGS = process.argv.slice(2);

// OPTIONS USED AS FLAGS (do not take arguments)
const OPTS_FLAGS = ["force", "f", "test", "t", "smtp-secure", "smtp-require-tls", "help", "h", "version", "v"];

// OPTIONS THAT TAKE ARGUMENTS
const OPTS_ARGS = ["post", "p", "email", "e", "smtp-host", "smtp-port", "smtp-user", "smtp-pass", "smtp-from", 
    "agency", "a", "config", "c", "notes", "n"];



// Start the CLI
init();




// ==== MAIN ENTRY POINT ==== //

/**
 * Start processing the CLI arguments and run the
 * DB build scripts
 */
function init() {

  // Check and Parse config file, if provided
  try {
    _parseConfig();
  }
  catch (error) {
    errors.error("Could not parse config file", error);
  }


  // Parse the CLI arguments
  try {
    if ( errors.getErrorCount() === 0 ) {
      _parseArgs();
    }
  }
  catch (error) {
    errors.error("Could not parse CLI arguments", error);
  }


  // Parse the passed agencies
  try {
    if ( errors.getErrorCount() === 0 ) {
      _parseAgencies();
    }
  }
  catch (error) {
    errors.error("Could not parse agencies", error);
  }


  // Start the Update Check & DB Compilation process
  try {
    if ( errors.getErrorCount() === 0 ) {
      run(function() {
        _report();
      });
    }
    else {
      _report();
    }
  }
  catch (error) {
    errors.error("Could not run update check and DB compilation", error);
    _report();
  }

}


// ==== HELPER FUNCTIONS ==== //


/**
 * Check if a config file path has been provided
 * If it has, parse the config file and merge it 
 * with the default configuration
 * @private
 */
function _parseConfig() {

  // Check for a config file path
  let config = _checkConfig();

  // Read the config file
  if ( config ) {
    _readConfig(config);
  }

}


/**
 * Check if a config file path has been provided
 * @return {string} path to possible config file
 * @private
 */
function _checkConfig() {

  // Get last 2 arguments
  let last_arg = undefined;
  let second_last_arg = undefined;
  if ( ARGS.length > 0 ) {
    last_arg = ARGS[ARGS.length-1];
  }
  if ( ARGS.length > 1 ) {
    second_last_arg = ARGS[ARGS.length-2];
  }

  // Check Args
  let check_last = _checkOption(last_arg);
  let check_second_last = _checkOption(second_last_arg);

  // Last arg should be parsed as config location...
  if ( last_arg && check_last === 0 && check_second_last !== 2 && last_arg.charAt(0) !== "-" ) {
    ARGS.pop();
    return last_arg;
  }

  // No config file found
  return undefined;

}


/**
 * Read the config file from the specified path
 * @param  {string} location Path to config file
 * @private
 */
function _readConfig(location) {

  // Check if file exists
  if ( !fs.existsSync(location) ) {
    return errors.error("Config file not found", "Make sure the path to the config file is correct [" + location + "]");
  }

  // Read the config file
  let config = require(fs.realpathSync(location));

  // Parse the config file
  if ( config.hasOwnProperty('test') ) {
    options.set().test = config.test;
  }
  if ( config.hasOwnProperty('force') ) {
    options.set().force = config.force;
  }
  if ( config.hasOwnProperty('post') ) {
    options.set().post = fs.realpathSync(config.post);
  }
  if ( config.hasOwnProperty('email') ) {
    options.set().email = config.email;
  }
  if ( config.hasOwnProperty('agencies') ) {
    if ( Array.isArray(config.agencies) ) {
      for ( let i = 0; i < config.agencies.length; i++ ) {
        let a = config.agencies[i];
        if ( typeof a === 'string' ) {
          options.addAgency(a);
        }
        else if ( a !== null && typeof a === 'object' ) {
          if ( a.hasOwnProperty('agency') ) {
            options.addAgency(a.agency);
            if ( a.hasOwnProperty('config') ) {
              options.addAgencyConfig(a.config);
            }
            if ( a.hasOwnProperty('notes') ) {
              options.addAgencyNotes(a.notes);
            }
          }
        }
      }
    }
  }
  if ( config.hasOwnProperty('smtp') ) {
    let smtp = config.smtp;
    if ( smtp.hasOwnProperty('host') ) {
      options.set().smtp.host = smtp.host;
    }
    if ( smtp.hasOwnProperty('port') ) {
      options.set().smtp.port = smtp.port;
    }
    if ( smtp.hasOwnProperty('secure') ) {
      options.set().smtp.secure = smtp.secure;
    }
    if ( smtp.hasOwnProperty('requireTLS') ) {
      options.set().smtp.requireTLS = smtp.requireTLS;
    }
    if ( smtp.hasOwnProperty('auth') ) {
      let auth = smtp.auth;
      if ( auth.hasOwnProperty('user') ) {
        options.set().smtp.auth.user = auth.user;
      }
      if ( auth.hasOwnProperty('pass') ) {
        options.set().smtp.auth.pass = auth.pass;
      }
    }
    if ( smtp.hasOwnProperty('from') ) {
      options.set().smtp.from = smtp.from;
    }
  }

}


/**
 * Parse the CLI arguments
 * @private
 */
function _parseArgs() {

  // Parse arguments
  for ( let i = 0; i < ARGS.length; i++ ) {
    let arg = ARGS[i];

    // Unsupported argument
    if ( _checkOption(arg) === 0 ) {
      return errors.error("Unrecognized option", "The option is not supported [" + arg + "]");
    }

    // --help / -h
    if ( arg === '--help' || arg === '-h' ) {
      _usage();
      process.exit(0);
    }

    // --version / -v
    else if ( arg === '--version' || arg === '-v' ) {
      log("Version: " + props.version);
      process.exit(0);
    }

    // --force / -f
    else if ( arg === '--force' || arg === '-f' ) {
      options.set().force = true;
    }

    // --test / -t
    else if ( arg === '--test' || arg === '-t' ) {
      options.set().test = !options.get().test;
    }

    // --agency / -a
    else if ( arg === '--agency' || arg === '-a' ) {
      i++;
      if ( ARGS[i] === undefined || ARGS[i].charAt(0) === '-' ) {
        return errors.error("Agency declaration is not defined");
      }
      else {
        options.addAgency(ARGS[i]);
      }
    }

    // --config / -c
    else if ( arg === '--config' || arg === '-c' ) {
      i++;
      if ( ARGS[i] === undefined || ARGS[i].charAt(0) === '-' ) {
        return errors.error("Config file for agency is not defined");
      }
      else if ( options.agencyCount() < 1 ) {
        return errors.error("The --config|-c argument must be preceded by an --agency <...> declaration")
      }
      else {
        if ( fs.existsSync(ARGS[i]) ) {
          options.addAgencyConfig(ARGS[i]);
        }
        else {
          return errors.error("Config file does not exist", "File not found [" + ARGS[i] + "]");
        }
      }
    }

    // --notes / -n
    else if ( arg === '--notes' || arg === '-n' ) {
      i++;
      if ( ARGS[i] === undefined || ARGS[i].charAt(0) === '-' ) {
        return errors.error("Notes for agency are not defined");
      }
      else if ( options.agencyCount() < 1 ) {
        return errors.error("The --notes|-n argument must be preceded by an --agency <...> declaration");
      }
      else {
        options.addAgencyNotes(ARGS[i]);
      }
    }

    // --post / -p
    else if ( arg === '--post' || arg === '-p' ) {
      i++;
      if ( ARGS[i] === undefined || ARGS[i].charAt(0) === '-' ) {
        return errors.error("The post-install script is not defined");
      }
      else {
        let post = ARGS[i];
        if ( !fs.existsSync(post) ) {
          return errors.error("The post-install script does not exist", "File not found [" + post + "]");
        }
        options.set().post = fs.realpathSync(post);
      }
    }

    // --email / -e
    else if ( arg === '--email' || arg === '-e' ) {
      i++;
      if ( ARGS[i] === undefined || ARGS[i].charAt(0) === '-' ) {
        return errors.error("Email address is not defined");
      }
      else {
        options.set().email = ARGS[i];
      }
    }

    // --smtp-host
    else if ( arg === '--smtp-host' ) {
      i++;
      if ( ARGS[i] === undefined || ARGS[i].charAt(0) === '-' ) {
        return errors.error("SMTP host is not defined");
      }
      else {
        options.set().smtp.host = ARGS[i];
      }
    }

    // --smtp-port
    else if ( arg === '--smtp-port' ) {
      i++;
      if ( ARGS[i] === undefined || ARGS[i].charAt(0) === '-' ) {
        return errors.error("SMTP port is not defined");
      }
      else {
        options.set().smtp.port = ARGS[i];
      }
    }

    // --smtp-secure
    else if ( arg === '--smtp-secure' ) {
      options.set().smtp.secure = !options.set().smtp.secure;
    }

    // --smtp-require-tls
    else if ( arg === '--smtp-require-tls' ) {
      options.set().smtp.requireTLS = !options.set().smtp.requireTLS;
    }

    // --smtp-user
    else if ( arg === '--smtp-user' ) {
      i++;
      if ( ARGS[i] === undefined || ARGS[i].charAt(0) === '-' ) {
        return errors.error("SMTP user is not defined");
      }
      else {
        options.set().smtp.auth.user = ARGS[i];
      }
    }

    // --smtp-pass
    else if ( arg === '--smtp-pass' ) {
      i++;
      if ( ARGS[i] === undefined || ARGS[i].charAt(0) === '-' ) {
        return errors.error("SMTP password is not defined");
      }
      else {
        options.set().smtp.auth.pass = ARGS[i];
      }
    }

    // --smtp-from
    else if ( arg === '--smtp-from' ) {
      i++;
      if ( ARGS[i] === undefined || ARGS[i].charAt(0) === '-' ) {
        return errors.error("SMTP From is not defined");
      }
      else {
        options.set().smtp.from = ARGS[i];
      }
    }

  }

  // Make sure at least one agency is provided
  if ( options.agencyCount() < 1 ) {
    return errors.error("No agencies defined", "At least one agency must be defined");
  }

  // Flag agencies for update when force is set
  if ( options.get().force ) {
    for ( let i = 0; i < options.agencyCount(); i++ ) {
      options.agency(i).update = true;
    }
  }

  // Print start info
  log.info("======== RIGHT TRACK DATABASE GENERATOR ========");
  log("Version: " + props.version);
  log("Started: " + new Date());

}


/**
 * Parse the passed agencies
 * @private
 */
function _parseAgencies() {

  // Print start information
  log("================================================");
  log.info("PARSING AGENCIES");

  // Parse each of the agencies
  for ( let i = 0; i < options.agencyCount(); i++ ) {
    let req = undefined;

    log("------------------------------------------------");
    log.raw([
      {
        "text": "AGENCY:"
      },
      {
        "text": " " + options.agency(i).require + " ",
        "chalk": "bgYellow.black"
      }
    ]);

    // Relative path
    if ( _isRelativePath(options.agency(i).require) ) {
      req = _makeAbsolutePath(options.agency(i).require);
      if ( !fs.existsSync(req) ) {
        return errors.error(
          "Could not load agency module", 
          "Agency module path not found [" + req + "]"
        );
      }
    }

    // Try finding the module by name
    else {
      req = _lookupModule(options.agency(i).require);
    }


    // Unknown agency
    if ( req === undefined ) {
      return errors.error(
        "Could not load agency module", 
        "Make sure the agency module is installed and properly referenced [" + options.agency(i).require + "]"
      );
    }

    // Found agency: load the agency
    options.agency(i).require = req;
    _loadAgency(i);

  }

  // Check for duplicate agency declarations
  let locations = [];
  for ( let i = 0; i < options.agencyCount(); i++ ) {
    let agency = options.agency(i);
    if ( locations.includes(agency.agency.moduleDirectory) ) {
      return errors.error(
        "Duplicate agency declaration",
        "The agency module has been declared more than once [" + agency.agency.moduleDirectory + "]"
      );
    }
    else {
      locations.push(agency.agency.moduleDirectory);
    }
  }

  // Output parsed info
  log("------------------------------------------------");
  log("Agencies Parsed: " + options.agencyCount());
  log("================================================");

}


/**
 * Load the specified agency and read the agency config, if specified
 * @param {int} i Index of agency to load
 * @private
 */
function _loadAgency(i) {

  log("==> LOADING MODULE: " + options.agency(i).require);
  log("    Location: " + require.resolve(options.agency(i).require));

  // Load agency & read agency config
  try {
    let agency = require(options.agency(i).require);
    if ( options.agency(i).config !== undefined ) {
      agency.readConfig(options.agency(i).config);
    }

    // Test the getConfig function
    agency.getConfig();

    // Add loaded agency to options
    options.agency(i).agency = agency;
  }
  catch(exception) {
    return errors.error(
      "Could not load agency module", 
      "The specified agency module could not be loaded as a Right Track Agency [" + options.agency(i).require + "]."
    );
  }

}


/**
 * Generate and send the summary report
 * @private
 */
function _report() {
  try {
    report();
  }
  catch (error) {
    log.error("ERROR: Could not send email report");
    log.error(error);
    process.exit(1);
  }
}


/**
 * Print the usage information
 * @private
 */
function _usage() {
  log(props.description);
  log("Module: " + props.name);
  log("Version: " + props.version);
  log("----------------------------");
  log("Usage:");
  log("  " + path.basename(process.argv[1]) + " [options] --agency <declaration> [agency options] ... [config file]");
  log("");
  log("config file:");
  log("  The path to a configuration file can be used to provide the configuration options for the db build script.");
  log("  The values in the configuration file will override the default values.  Values provided as CLI arguments");
  log("  will override the default values and those in the specified configuration file (if provided).");
  log("  See the README for more detailed information about available configuration variables.");
  log("");
  log("options:");
  log("  --force|-f             Force a GTFS update and database compilation");
  log("  --test|-t              Test the DB compilation (does not install)");
  log("  --post|-p <file>       Define a post-install script to run after update & compilation");
  log("  --email|-e <email>     Email address to send DB build results to");
  log("  --smtp-host <host>     SMTP server host");
  log("  --smtp-port <port>     SMTP server port");
  log("  --smtp-user <username> SMTP server username");
  log("  --smtp-pass <password> SMTP server password");
  log("  --smtp-from <name <email>>  SMTP server From address");
  log("  --smtp-secure          SMTP server use TLS");
  log("  --smtp-require-tls     SMTP server require TLS");
  log("  --help|-h              Display this usage information");
  log("  --version|-v           Display the DB Build script version");
  log("");
  log("agency declaration:");
  log("  Declare an agency to check for GTFS updates/compile database.  The agency");
  log("  can be declared by module name, agency id or file path.  For example:");
  log("  --agency right-track-agency-mnr");
  log("  --agency mnr");
  log("  --agency ./path/to/right-track-agency-mnr");
  log("");
  log("agency options:");
  log("  These options have to be proceeded by an agency declaration (--agency <...>)");
  log("  --config|-c <file>");
  log("     Specify the path to an optional agency configuration file");
  log("  --notes|-n <notes>");
  log("     Specify agency update notes to be included in the new database");
}






// ==== HELPER FUNCTIONS ==== //


/**
 * Check if the provided option is supported
 * @param {string} check The option to check
 * @return {int} 0 = not supported, 1 = flag, 2 = argument
 * @private
 */
function _checkOption(check) {
  for ( let i = 0; i < OPTS_FLAGS.length; i++ ) {
    let opt = OPTS_FLAGS[i];
    opt = opt.length === 1 ? "-" + opt : "--" + opt;
    if ( opt === check ) {
      return 1;
    }
  }
  for ( let i = 0; i < OPTS_ARGS.length; i++ ) {
    let opt = OPTS_ARGS[i];
    opt = opt.length === 1 ? "-" + opt : "--" + opt;
    if ( opt === check ) {
      return 2;
    }
  }
  return 0;
}


/**
 * Check if the directory is a relative path (begins with './' or '../')
 * @param {string} directory Path to directory
 * @return {boolean} True if the directory is a relative path
 * @private
 */
function _isRelativePath(directory) {
  if ( typeof directory === 'string' ) {
    if ( directory.charAt(0) === '.' ) {
      if ( directory.charAt(1) === '/' ) {
        return true;
      }
      if ( directory.charAt(1) === '.' ) {
        if ( directory.charAt(2) === '/' ) {
          return true;
        }
      }
    }
    return false;
  }
  else {
    return false;
  }
}

/**
 * Change a relative path to an absolute path (relative to the process cwd)
 * @param {string} relative The relative path
 * @returns {string} The absolute path
 * @private
 */
function _makeAbsolutePath(relative) {
  return path.normalize(
    path.join(process.cwd(), '/', relative)
  );
}


/**
 * Lookup the agency module name
 * @param {string} agency Module name or Agency ID
 * @returns {string|undefined} agency module name
 * @private
 */
function _lookupModule(agency) {

  // agency is module name
  if ( _resolve(agency) !== undefined ) {
    return agency;
  }

  // agency is agency code
  else if ( _resolve('right-track-agency-' + agency) !== undefined ) {
    return 'right-track-agency-' + agency;
  }

  // unknown module
  return undefined;

}


/**
 * Get the file path of the specified module
 * @param {string} name module name
 * @returns {string|undefined} module file path
 * @private
 */
function _resolve(name) {
  try {
    return require.resolve(name);
  }
  catch(exception) {
    return undefined;
  }
}