sails
Version: 
API-driven framework for building realtime apps, using MVC conventions (based on Express and Socket.io)
392 lines (335 loc) • 16.4 kB
JavaScript
/**
 * Module dependencies
 */
var path = require('path');
var fs = require('fs');
var _ = require('@sailshq/lodash');
var chalk = require('chalk');
var COMMON_JS_FILE_EXTENSIONS = require('common-js-file-extensions');
var flaverr = require('flaverr');
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Note that `whelk`, `machinepack-process`, and `../lib/app` are
// conditionally required below, only in the cases where they are actually used.
// (That way you don't have to wait for them to load if you're not using them.)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
/**
 * Module constants
 */
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Supported file extensions for imperative code files such as hooks:
//  • 'js' (.js)
//  • 'ts' (.ts)
//  • 'es6' (.es6)
//  • ...etc.
//
// > For full list, see:
// > https://github.com/luislobo/common-js-file-extensions/blob/210fd15d89690c7aaa35dba35478cb91c693dfa8/README.md#code-file-extensions
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
var BASIC_SUPPORTED_FILE_EXTENSIONS = COMMON_JS_FILE_EXTENSIONS.code;
/**
 * `sails run`
 *
 * Run a script for the Sails app in the current working directory.
 *
 * This matches either a JavaScript file in the `scripts/` directory, or one of the command-line scripts
 * declared within the `scripts: {}` dictionary in the package.json file.
 *
 * @see https://sailsjs.com/documentation/reference/command-line-interface/sails-run
 */
module.exports = function(scriptName) {
  // If there is only one argument, it means there is actually no scriptName at all.
  // (A detail of how commander works.)
  if (arguments.length === 1) {
    scriptName = undefined;
  }
  // Sanitize the script name, for comfort.
  if (!scriptName) {
    console.error('Which one?  (To run a script, provide its name.)');
    console.error('For example:');
    console.error('    sails run rebuild-cloud-sdk');
    console.error();
    console.error('^ runs `scripts/rebuild-cloud-sdk.js`.');
    console.error();
    console.error('(For more help, visit '+chalk.underline('https://sailsjs.com/support')+'.)');
    return process.exit(1);
  }
  // Remove `scripts/` prefix if it exists (allows users to do sails run scripts/foo, so they can use tab autocomplete).
  scriptName = _.trim(scriptName);
  scriptName = scriptName.replace(/^scripts\//, '');
  // Unless the script name is under a "scope" (as in `@sailshq/some-package`), don't allow slashes in the name.
  if (scriptName.match(/\//) && scriptName[0] !== '@') {
    // FUTURE: Do allow this so scripts can be nested in subdirectories  (doesn't work for package.json scripts obviously)
    console.error('Cannot run `'+scriptName+'`.  Script name should never contain any slashes.');
    return process.exit(1);
  }//-•
  // Examine the script name and determine if it has a file extension included.
  // If so, we'll rip it out of the script name, but keep a reference to it.
  // Otherwise, we'll always assume that we're looking for a normal `.js` file.
  var X_BASIC_SUPPORTED_FILE_EXTENSION = new RegExp('^([^.]+)\\.(' + BASIC_SUPPORTED_FILE_EXTENSIONS.join('|') + ')$');
  var matchedFileExtension = scriptName.match(X_BASIC_SUPPORTED_FILE_EXTENSION);
  var fileExtension;
  if (matchedFileExtension) {
    fileExtension = matchedFileExtension[2];
    scriptName = scriptName.replace(X_BASIC_SUPPORTED_FILE_EXTENSION, '$1');
  }
  else {
    fileExtension = 'js';
  }
  // First, we need to determine the appropriate script to run.
  // (either a terminal command or a specially-formatted Node.js/Sails.js module)
  // We begin by figuring out whether this is a script from the package.json
  // or a definition in the `scripts/` folder.
  var pjCommandToRun;
  // Check the package.json file.
  try {
    var pathToLocalPj = path.resolve(process.cwd(), 'package.json');
    var packageJson;
    try {
      packageJson = require(pathToLocalPj);
    } catch (e) {
      switch (e.code) {
        case 'MODULE_NOT_FOUND': throw flaverr('E_NO_PACKAGE_JSON', new Error('No package.json file.  Are you sure you\'re in the root directory of a Node.js/Sails.js app?'));
        default: throw e;
      }
    }
    if (!_.isUndefined(packageJson.scripts) && (!_.isObject(packageJson.scripts) || _.isArray(packageJson.scripts))) {
      throw flaverr('E_MALFORMED_PACKAGE_JSON', new Error('This package.json file has an invalid `scripts` property -- should be a dictionary (plain JS object).'));
    }
    pjCommandToRun = packageJson.scripts[scriptName];
  } catch (e) {
    switch (e.code) {
      case 'E_NO_PACKAGE_JSON':
      case 'E_MALFORMED_PACKAGE_JSON':
        console.error('--');
        console.error(chalk.red(e.message));
        return process.exit(1);
      default:
        console.error('--');
        console.error(chalk.bold('Oops, something unexpected happened:'));
        console.error(chalk.red(e.stack));
        console.error('--');
        console.error('Please read the error message above and troubleshoot accordingly.');
        console.error('(You can report suspected bugs at '+chalk.underline('http://sailsjs.com/bugs')+'.)');
        return process.exit(1);
    }
  }
  // Now check both the `scripts/` directory and node_modules to see if a matching script exists.
  var relativePathToAppScript = 'scripts/'+scriptName+'.'+fileExtension;
  var relativePathToInstalledScript = (function(){
    // Handle scripts organized under org subdirectories in node_modules.
    var installedScriptName = scriptName;
    var org = '';
    if (scriptName[0] === '@') {
      org = scriptName.split('/')[0] + '/';
      installedScriptName = scriptName.split('/')[1];
    }
    installedScriptName = installedScriptName.replace(/^sails-run-/,'');
    return 'node_modules/' + org + 'sails-run-'+installedScriptName;
  })();
  var installedScriptExists = fs.existsSync(path.resolve(relativePathToInstalledScript));
  var appScriptExists = fs.existsSync(path.resolve(relativePathToAppScript));
  var doesScriptFileExist = appScriptExists || installedScriptExists;
  // Ensure that this script is not defined in BOTH places.
  if (pjCommandToRun && doesScriptFileExist) {
    console.error('Cannot run `'+scriptName+'` because it is too ambiguous.');
    console.error('A script should only be defined once, but that script is defined in both the package.json file');
    console.error('AND as a file in the `scripts/` directory.');
    return process.exit(1);
  }
  // Ensure that this script exists one place or the other.
  if (!pjCommandToRun && !doesScriptFileExist) {
    console.error('Unknown script: `'+scriptName+'`');
    console.error('No matching script is defined at `'+relativePathToAppScript+'`.');
    console.error('(And there is no matching NPM script in the package.json file.)');
    return process.exit(1);
  }
  // If this is a Node.js/Sails.js script (machine def), then require the script file
  // to get the module definition, then run it using MaS.
  if (!pjCommandToRun) {
    try {
      var pathToScriptDef = path.resolve(process.cwd(), appScriptExists ? relativePathToAppScript : relativePathToInstalledScript);
      var scriptDef;
      try {
        scriptDef = require(pathToScriptDef);
      } catch (e) {
        switch (e.code) {
          case 'MODULE_NOT_FOUND': throw flaverr('E_FAILED_TO_REQUIRE_SCRIPT_DEF', new Error('Encountered an error while loading the script definition.  Are you sure this is a well-formed Node.js/Sails.js script definition?  Error details:\n'+e.stack));
          default: throw e;
        }
      }
      // Make sure the script is at least basically valid.
      // (MaS will check it more later -- this is just preliminary -- and also to make sure that it's not `{}`,
      // the special indicator that the script definition didn't export _ANYTHING_ at all.)
      if (!_.isObject(scriptDef) || _.isArray(scriptDef) || _.isEqual(scriptDef, {})) {
        console.error('');
        console.error('');
        console.error('Invalid script: `'+scriptName+'`');
        console.error('');
        console.error('A well-formed Node.js/Sails.js script should export a script definition.');
        console.error('In other words, it should be defined more or less like this:');
        console.error('');
        console.error('    ```````````````````````````````````````````````````````````');
        console.error('    module.exports = {');
        console.error('      description: \'Do a thing given some stuff.\',');
        console.error('      inputs: {');
        console.error('        someStuff: { type: \'string\', required: true }');
        console.error('      },');
        console.error('      fn: async function (inputs, exits) {');
        console.error('        // ...');
        console.error('        sails.log(\'Hello world!\');');
        console.error('        return exits.success();');
        console.error('      }');
        console.error('    };');
        console.error('    ```````````````````````````````````````````````````````````');
        console.error('');
        console.error(' [?] Visit https://sailsjs.com/support for assistance.');
        console.error('');
        return process.exit(1);
      }
      // Modify the script definition to add `sails: require('sails')` and `habitat: 'sails'`
      // (unless it explicitly disables this behavior with `sails: false` or by explicitly
      // declaring some other habitat)
      var isLifecycleMgmtExplicitlyDisabled = (
        scriptDef.sails === false ||
        (scriptDef.habitat !== undefined && scriptDef.habitat !== 'sails')
      );
      if (!isLifecycleMgmtExplicitlyDisabled) {
        // (Only require the rest of the Sails framework if it's needed.)
        var Sails = require('../lib/app');
        scriptDef.habitat = 'sails';
        scriptDef.sails = Sails();
      }
      // (Only require whelk if it's needed.)
      var whelk = require('whelk');
      // console.log('process.argv ->', require('util').inspect(process.argv,{depth:null}));
      // console.log('arguments ->', require('util').inspect(arguments,{depth:null}));
      // console.log('scriptName ->', scriptName);
      // console.log('Array.prototype.slice.call(arguments, 1, -1) ->', Array.prototype.slice.call(arguments, 1, -1));
      // Pass in override for runtime array of serial command-line arguments
      // (we rely on commander having parsed them for us so that we don't include `sails`, `run`, `node`, etc)
      scriptDef.rawSerialCommandLineArgs = Array.prototype.slice.call(arguments, 1, -1);
      // Now actually run the script.
      whelk(scriptDef);
    } catch (err) {
      console.error(err);
      return process.exit(1);
    }
  }
  // Otherwise, this is an NPM script of some kind, from the package.json file.
  else {
    // So execute the command like you would on the terminal.
    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    // FUTURE: Consider pulling this boilerplate setup code into spawnChildProcess() machine
    // as a way of leveraging a subshell to remove the need to pass in CLI args directly.
    // Maybe as an option at least.
    //
    // > Also, we should also consider adding a notifier function to optionally provide
    // > special instructions of what to do when the current (parent) process receives a SIGINT.
    // > (Otherwise, by default, the SIGINT behavior implemented below could be used instead.)
    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    //
    // -AND/OR-
    //
    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    // FUTURE: Consider exposing an optional `onData` input to the more basic `executeCommand()`
    // machine.  That way, you can just pass in a notifier function that handles the child's
    // writes to its stdout and stderr streams without having to dig into all of these
    // annoying complexities.  Also, it'd then be possible to add another input: a flag that
    // allows you to choose whether or not to store output and pass it to the callback
    // (e.g. `bufferOutput`).
    //
    // > Finally, we should also consider adding the same SIGINT notifier function mentioned above.
    //
    // Here's an example of how we might put it all together:
    // ```
    // Process.executeCommand({
    //   command: pjCommandToRun,
    //   bufferOutput: false,
    //   killOnParentSigint: false,
    //   onData: function (data, stdStreamName){
    //     process[stdStreamName].write(data);
    //   }
    // }).exec(function (err) {
    //   if (err) {
    //     console.error('Error occured running `'+ pjCommandToRun+ '`');
    //     console.error('Please resolve any issues and try `sails run '+scriptName+'` again.');
    //     console.error('Details:');
    //     console.error(err);
    //     return process.exit(1);
    //   }//-•
    //
    //   return process.exit(0);
    // });//< Process.executeCommand().exec() > _∏_
    // ```
    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    // (Only require machinepack-process if it's needed.)
    var Process = require('machinepack-process');
    // Determine an appropriate name for our shell.
    // > This is mainly just so we don't have to try and do any fancy parsing of the command,
    // > allowing for more platform-specific customization.  (Mirroring the NPM CLI here)
    var shProcName;
    var shFlag;
    if (process.platform === 'win32') {
      shProcName = process.env.comspec || 'cmd';
      shFlag = '/d /s /c';
    }
    else {
      shProcName = 'sh';
      shFlag = '-c';
    }
    var childProcess = Process.spawnChildProcess({
      command: shProcName,
      cliArgs: [ shFlag, pjCommandToRun ]
    }).now();
    // Pipe output from the child process to the current (parent) process.
    childProcess.stdout.pipe(process.stdout);
    childProcess.stderr.pipe(process.stderr);
    // Set up CTRL+C listener on the parent process that will force-kill this child process.
    // (Note that we define the event listener as a named function so we can unbind it below.)
    var onSigTerm = function (){
      Process.killChildProcess({ childProcess: childProcess, force: true }).exec(function (_forceKillErr){
        if (_forceKillErr) {
          console.error('There was a problem terminating this script:\n'+_forceKillErr.stack+'\nHere are some details which might be helpful:\n' + _forceKillErr.stack);
        }
      });
    };
    process.once('SIGTERM', onSigTerm);
    var spinlocked;
    (function (proceed){
      childProcess.on('error', function (err) { return proceed(err); });
      childProcess.stderr.on('error', function (err) { return proceed(err); });
      childProcess.stdout.on('error', function (err) { return proceed(err); });
      childProcess.on('close', function (code, signal) {
        // log.silly('lifecycle', logid(pkg, stage), 'Returned: code:', code, ' signal:', signal)
        // If a signal was received, terminate the current parent process (i.e. `sails run`).
        if (signal) {
          // Note that, in this case, `proceed()` is never called.
          // (But it doesn't actually matter, because we'll have killed the process.)
          return process.kill(process.pid, signal);
        }
        // Otherwise if we got a non-zero exit code, then consider this an error.
        if (code !== 0) {
          return proceed(new Error('Exit status '+code));
        }
        // Otherwise, consider it a success.
        return proceed();
      });
    })(function(err){
      if (err) {
        if (spinlocked) {
          console.error(err);
          return;
        }
        spinlocked = true;
        console.error('- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ');
        console.error('Error occured running `'+ pjCommandToRun+ '`');
        console.error('Please resolve any issues and try `sails run '+scriptName+'` again.');
        console.error('Details:');
        console.error(err);
        process.removeListener('SIGTERM', onSigTerm);
        return process.exit(1);
      }//-•
      return process.exit(0);
    });//_∏_  (†)
  }//</ else >
};