meteor/meteor

View on GitHub
tools/utils/utils.js

Summary

Maintainability
F
3 days
Test Coverage
var _ = require('underscore');
var semver = require('semver');
var os = require('os');
var url = require('url');

var archinfo = require('./archinfo');
var buildmessage = require('./buildmessage.js');
var files = require('../fs/files');
var packageVersionParser = require('../packaging/package-version-parser.js');

var utils = exports;

// Parses <protocol>://<host>:<port> into an object { protocol: *, host:
// *, port: * }. The input can also be of the form <host>:<port> or just
// <port>. We're not simply using 'url.parse' because we want '3000' to
// parse as {host: undefined, protocol: undefined, port: '3000'}, whereas
// 'url.parse' would give us {protocol:' 3000', host: undefined, port:
// undefined} or something like that.
//
// 'defaults' is an optional object with 'hostname', 'port', and 'protocol' keys.
exports.parseUrl = function (str, defaults) {
  // XXX factor this out into a {type: host/port}?

  defaults = defaults || {};
  var defaultHostname = defaults.hostname || undefined;
  var defaultPort = defaults.port || undefined;
  var defaultProtocol = defaults.protocol || undefined;

  if (str.match(/^[0-9]+$/)) { // just a port
    return {
      port: str,
      hostname: defaultHostname,
      protocol: defaultProtocol };
  }

  var hasScheme = exports.hasScheme(str);
  if (! hasScheme) {
    str = "http://" + str;
  }

  var parsed = url.parse(str);

  // for consistency remove colon at the end of protocol
  parsed.protocol = parsed.protocol.replace(/\:$/, '');

  var ret = {
    protocol: hasScheme ? parsed.protocol : defaultProtocol,
    hostname: parsed.hostname || defaultHostname,
    port: parsed.port || defaultPort
  };
  if (parsed.pathname !== '/' && parsed.pathname) {
    ret.pathname = parsed.pathname;
  }
  return ret;
};

// 'options' is an object with 'hostname', 'port', and 'protocol' keys, such as
// the return value of parseUrl.
exports.formatUrl = function (options) {
  // For consistency with `Meteor.absoluteUrl`, add a trailing slash to make
  // this a valid URL
  if (!options.pathname)
    options.pathname = "/";

  return url.format(options);
};

exports.ipAddress = function () {
  const interfaces = os.networkInterfaces();

  // If we don't know the default route, we'll lookup all non-internal
  // IPv4 addresses and hope to find only one
  let addressEntries = _.chain(interfaces)
    .values()
    .flatten()
    .where({ family: "IPv4", internal: false })
    .value();

  if (! addressEntries.length) {
    throw new Error(`Could not find a network interface with a non-internal IPv4 address.`);
  }

  if (addressEntries.length > 1) {
    throw new Error(`Found multiple network interfaces with non-internal IPv4 addresses:
${addressEntries.map(entry => entry.address).join(', ')}`);
  }

  return addressEntries[0].address;
};

exports.hasScheme = function (str) {
  return !! str.match(/^[A-Za-z][A-Za-z0-9+-\.]*\:\/\//);
};


exports.hasScheme = function (str) {
  return !! str.match(/^[A-Za-z][A-Za-z0-9+-\.]*\:\/\//);
};

exports.isIPv4Address = function (str) {
  return str.match(/^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/);
}

// XXX: Move to e.g. formatters.js?
// Prints a package list in a nice format.
// Input is an array of objects with keys 'name' and 'description'.
exports.printPackageList = function (items, options) {
  options = options || {};

  var rows = _.map(items, function (item) {
    var name = item.name;
    var description = item.description || 'No description';
    return [name, description];
  });

  var alphaSort = function (row) {
    return row[0];
  };
  rows = _.sortBy(rows, alphaSort);

  var Console = require('../console/console.js').Console;
  return Console.printTwoColumns(rows, options);
};

// Determine a human-readable hostname for this computer. Prefer names
// that make sense to users (eg, the name they manually gave their
// computer on OS X, which might contain spaces) over names that have
// any particular technical significance (eg, might resolve in DNS).
exports.getHost = function (...args) {
  var ret;
  var attempt = function (...args) {
    var output = exports.execFileSync(args[0], args.slice(1)).stdout;
    if (output) {
      ret = output.trim();
    }
  };

  if (archinfo.matches(archinfo.host(), 'os.osx')) {
    // On OSX, to get the human-readable hostname that the user chose,
    // we call:
    //   scutil --get ComputerName
    // This can contain spaces. See
    // http://osxdaily.com/2012/10/24/set-the-hostname-computer-name-and-bonjour-name-separately-in-os-x/
    if (! ret) {
      attempt("scutil", "--get", "ComputerName");
    }
  }

  if (archinfo.matches(archinfo.host(), 'os.osx') ||
      archinfo.matches(archinfo.host(), 'os.linux')) {
    // On Unix-like platforms, try passing -s to hostname to strip off
    // the domain name, to reduce the extent to which the output
    // varies with DNS.
    if (! ret) {
      attempt("hostname", "-s");
    }
  }

  // Try "hostname" on any platform. It should work on
  // Windows. Unknown platforms that have a command called "hostname"
  // that deletes all of your files deserve what the get.
  if (! ret) {
    attempt("hostname");
  }

  // Otherwise, see what Node can come up with.
  return ret || os.hostname();
};

// Return standard info about this user-agent. Used when logging in to
// Meteor Accounts, mostly so that when the user is seeing a list of
// their open sessions in their profile on the web, they have a way to
// decide which ones they want to revoke.
exports.getAgentInfo = function () {
  var ret = {};

  var host = utils.getHost();
  if (host) {
    ret.host = host;
  }
  ret.agent = "Meteor";
  ret.agentVersion =
    files.inCheckout() ? "checkout" : files.getToolsVersion();
  ret.arch = archinfo.host();

  return ret;
};

// Wait for 'ms' milliseconds, and then return. Yields. (Must be
// called within a fiber, and blocks only the calling fiber, not the
// whole program.)
exports.sleepMs = function (ms) {
  if (ms <= 0) {
    return;
  }

  new Promise(function (resolve) {
    setTimeout(resolve, ms);
  }).await();
};

// Return a short, high entropy string without too many funny
// characters in it.
exports.randomToken = function () {
  return (Math.random() * 0x100000000 + 1).toString(36);
};

// Like utils.randomToken, except a legal variable name, i.e. the first
// character is guaranteed to be [a-z] and the rest [a-z0-9].
exports.randomIdentifier = function () {
  const firstLetter = String.fromCharCode(
    "a".charCodeAt(0) + Math.floor(Math.random() * 26));
  return firstLetter + Math.random().toString(36).slice(2);
};

// Returns a random non-privileged port number.
exports.randomPort = function () {
  return 20000 + Math.floor(Math.random() * 10000);
};

// Like packageVersionParser.parsePackageConstraint, but if called in a
// buildmessage context uses buildmessage to raise errors.
exports.parsePackageConstraint = function (constraintString, options) {
  try {
    return packageVersionParser.parsePackageConstraint(constraintString);
  } catch (e) {
    if (! (e.versionParserError && options && options.useBuildmessage)) {
      throw e;
    }
    buildmessage.error(e.message, { file: options.buildmessageFile });
    return null;
  }
};

exports.validatePackageName = function (name, options) {
  try {
    return packageVersionParser.validatePackageName(name, options);
  } catch (e) {
    if (! (e.versionParserError && options && options.useBuildmessage)) {
      throw e;
    }
    buildmessage.error(e.message, { file: options.buildmessageFile });
    return null;
  }
};

// Parse a string of the form `package + " " + version` into an object
// of the form {package, version}.  For backwards compatibility,
// an "@" separator instead of a space is also accepted.
//
// Lines of `.meteor/versions` are parsed using this function, among
// other uses.
exports.parsePackageAndVersion = function (packageAtVersionString, options) {
  var error = null;
  var separatorPos = Math.max(packageAtVersionString.lastIndexOf(' '),
                              packageAtVersionString.lastIndexOf('@'));
  if (separatorPos < 0) {
    error = new Error("Malformed package version: " +
                      JSON.stringify(packageAtVersionString));
  } else {
    var packageName = packageAtVersionString.slice(0, separatorPos);
    var version = packageAtVersionString.slice(separatorPos+1);
    try {
      packageVersionParser.validatePackageName(packageName);
      // validate the version, ignoring the parsed result:
      packageVersionParser.parse(version);
    } catch (e) {
      if (! e.versionParserError) {
        throw e;
      }
      error = e;
    }
    if (! error) {
      return { package: packageName, version: version };
    }
  }
  // `error` holds an Error
  if (! (options && options.useBuildmessage)) {
    throw error;
  }
  buildmessage.error(error.message, { file: options.buildmessageFile });
  return null;
};

// Check for invalid package names. Currently package names can only contain
// ASCII alphanumerics, dash, and dot, and must contain at least one letter. For
// safety reasons, package names may not start with a dot. Package names must be
// lowercase.
//
// These do not check that the package name is valid in terms of our naming
// scheme: ie, that it is prepended by a user's username. That check should
// happen at publication time.
//
// 3 variants: isValidPackageName just returns a bool.  validatePackageName
// throws an error marked with 'versionParserError'. validatePackageNameOrExit
// (which should only be used inside the implementation of a command, not
// eg package-client.js) prints and throws the "exit with code 1" exception
// on failure.

exports.isValidPackageName = function (packageName) {
  try {
    exports.validatePackageName(packageName);
    return true;
  } catch (e) {
    if (!e.versionParserError) {
      throw e;
    }
    return false;
  }
};

exports.validatePackageNameOrExit = function (packageName, options) {
  try {
    exports.validatePackageName(packageName, options);
  } catch (e) {
    if (!e.versionParserError) {
      throw e;
    }
    var Console = require('../console/console.js').Console;
    Console.error(e.message, Console.options({ bulletPoint: "Error: " }));
    // lazy-load main: old bundler tests fail if you add a circular require to
    // this file
    var main = require('../tests/apps/app-using-stylus/main.js');
    throw new main.ExitWithCode(1);
  }
};

// True if this looks like a valid email address. We deliberately
// don't support
// - quoted usernames (eg, "foo"@bar.com, " "@bar.com, "@"@bar.com)
// - IP addresses in domains (eg, foo@1.2.3.4 or the IPv6 equivalent)
// because they're weird and we don't want them in our database.
exports.validEmail = function (address) {
  return /^[^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*@([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}$/.test(address);
};

// Like Perl's quotemeta: quotes all regexp metacharacters. See
//   https://github.com/substack/quotemeta/blob/master/index.js
exports.quotemeta = function (str) {
    return String(str).replace(/(\W)/g, '\\$1');
};

// Allow a simple way to scale up all timeouts from the command line
var timeoutScaleFactor = 1.0;
if (process.env.TIMEOUT_SCALE_FACTOR) {
  timeoutScaleFactor = parseFloat(process.env.TIMEOUT_SCALE_FACTOR);
}
exports.timeoutScaleFactor = timeoutScaleFactor;

// If the given version matches a template (essentially, semver-style, but with
// a bounded number of digits per number part, and with no restriction on the
// amount of number parts, and some restrictions on legal prerelease labels),
// then return an orderKey for it. Otherwise return null.
//
// This conventional orderKey pads each part (with 0s for numbers, and ! for
// prerelease tags), and appends a $. (Because ! sorts before $, this means that
// the prerelease for a given release will sort before it. Because $ sorts
// before '.', this means that 1.2 will sort before 1.2.3.)
exports.defaultOrderKeyForReleaseVersion = function (v) {
  var m = v.match(/^(\d{1,4}(?:\.\d{1,4})*)(?:-([-A-Za-z.]{1,15})(\d{0,4}))?$/);
  if (!m) {
    return null;
  }
  var numberPart = m[1];
  var prereleaseTag = m[2];
  var prereleaseNumber = m[3];

  var hasRedundantLeadingZero = function (x) {
    return x.length > 1 && x[0] === '0';
  };
  var leftPad = function (chr, len, str) {
    if (str.length > len) {
      throw Error("too long to pad!");
    }
    var padding = new Array(len - str.length + 1).join(chr);
    return padding + str;
  };
  var rightPad = function (chr, len, str) {
    if (str.length > len) {
      throw Error("too long to pad!");
    }
    var padding = new Array(len - str.length + 1).join(chr);
    return str + padding;
  };

  // Versions must have no redundant leading zeroes, or else this encoding would
  // be ambiguous.
  var numbers = numberPart.split('.');
  if (_.any(numbers, hasRedundantLeadingZero)) {
    return null;
  }
  if (prereleaseNumber && hasRedundantLeadingZero(prereleaseNumber)) {
    return null;
  }

  // First, put together the non-prerelease part.
  var ret = _.map(numbers, _.partial(leftPad, '0', 4)).join('.');

  if (!prereleaseTag) {
    return ret + '$';
  }

  ret += '!' + rightPad('!', 15, prereleaseTag);
  if (prereleaseNumber) {
    ret += leftPad('0', 4, prereleaseNumber);
  }

  return ret + '$';
};

// XXX should be in files.js
exports.isDirectory = function (dir) {
  try {
    // use stat rather than lstat since symlink to dir is OK
    var stats = files.stat(dir);
  } catch (e) {
    return false;
  }
  return stats.isDirectory();
};

// Calls cb with each subset of the array "total", with non-decreasing size,
// until all subsets have been used or cb returns true. The array passed
// to cb may be safely mutated or retained by cb.
exports.generateSubsetsOfIncreasingSize = function (total, cb) {
  // We'll throw this if cb ever returns true, which is a simple way to pop us
  // out of our recursion.
  var Done = function () {};

  // Generates all subsets of size subsetSize which contain the indices already
  // in chosenIndices (and no indices that are "less than" any of them).
  var generateSubsetsOfFixedSize = function (goalSize, chosenIndices) {
    // If we've found a subset of the size we're looking for, output it.
    if (chosenIndices.length === goalSize) {
      // Change from indices into the actual elements. Note that 'elements' is
      // a newly allocated array which cb may mutate or retain.
      var elements = [];
      _.each(chosenIndices, function (index) {
        elements.push(total[index]);
      });
      if (cb(elements)) {
        throw new Done();  // unwind all the recursion
      }
      return;
    }

    // Otherwise try adding another index and call this recursively.  We're
    // trying to produce a sorted list of indices, so if there are already
    // indices, we start with the one after the biggest one we already have.
    var firstIndexToConsider = chosenIndices.length ?
          chosenIndices[chosenIndices.length - 1] + 1 : 0;
    for (var i = firstIndexToConsider; i < total.length; ++i) {
      var withThisChoice = _.clone(chosenIndices);
      withThisChoice.push(i);
      generateSubsetsOfFixedSize(goalSize, withThisChoice);
    }
  };

  try {
    for (var goalSize = 0; goalSize <= total.length; ++goalSize) {
      generateSubsetsOfFixedSize(goalSize, []);
    }
  } catch (e) {
    if (!(e instanceof Done)) {
      throw e;
    }
  }
};

exports.isUrlWithFileScheme = function (x) {
  return /^file:\/\/.+/.test(x);
};

exports.isUrlWithSha = function (x) {
  // Is a URL with a fixed SHA? We use this for Cordova -- although theoretically we could use
  // a URL like isNpmUrl(), there are a variety of problems with this,
  // see https://github.com/meteor/meteor/pull/5562
  return /^https?:\/\/.*[0-9a-f]{40}/.test(x);
}

exports.isNpmUrl = function (x) {
  // These are the various protocols that NPM supports, which we use to download NPM dependencies
  // See https://docs.npmjs.com/files/package.json#git-urls-as-dependencies
  return exports.isUrlWithSha(x) ||
    /^(git|git\+ssh|git\+http|git\+https|https|http)?:\/\//.test(x);
};

exports.isPathRelative = function (x) {
  return x.charAt(0) !== '/';
};

// If there is a version that isn't valid, throws an Error with a
// human-readable message that is suitable for showing to the user.
// dependencies may be falsey or empty.
//
// This is talking about NPM/Cordova versions specifically, not Meteor versions.
// It does not support the wrap number syntax.
exports.ensureOnlyValidVersions = function (dependencies, {forCordova}) {
  _.each(dependencies, function (version, name) {
    // We want a given version of a smart package (package.js +
    // .npm/npm-shrinkwrap.json) to pin down its dependencies precisely, so we
    // don't want anything too vague. For now, we support semvers and urls that
    // name a specific commit by SHA.
    if (! exports.isValidVersion(version, {forCordova})) {
      throw new Error(
        "Must declare valid version of dependency: " + name + '@' + version);
    }
  });
};
exports.isValidVersion = function (version, {forCordova}) {
  return semver.valid(version) || exports.isUrlWithFileScheme(version)
    || (forCordova ? exports.isUrlWithSha(version): exports.isNpmUrl(version));
};


exports.execFileSync = function (file, args, opts) {
  var child_process = require('child_process');
  var { eachline } = require('./eachline');

  opts = opts || {};
  if (! _.has(opts, 'maxBuffer')) {
    opts.maxBuffer = 1024 * 1024 * 10;
  }

  if (opts && opts.pipeOutput) {
    var p = child_process.spawn(file, args, opts);

    eachline(p.stdout, function (line) {
      process.stdout.write(line + '\n');
    });

    eachline(p.stderr, function (line) {
      process.stderr.write(line + '\n');
    });

    return {
      success: ! new Promise(function (resolve) {
        p.on('exit', resolve);
      }).await(),
      stdout: "",
      stderr: ""
    };
  }

  return new Promise(function (resolve) {
    child_process.execFile(file, args, opts, function (err, stdout, stderr) {
      resolve({
        success: ! err,
        stdout: stdout,
        stderr: stderr
      });
    });
  }).await();
};

exports.execFileAsync = function (file, args, opts) {
  opts = opts || {};
  var child_process = require('child_process');
  var { eachline } = require('./eachline');
  var p = child_process.spawn(file, args, opts);
  var mapper = opts.lineMapper || _.identity;

  function logOutput(line) {
    if (opts.verbose) {
      line = mapper(line);
      if (line) {
        console.log(line);
      }
    }
  }

  eachline(p.stdout, logOutput);
  eachline(p.stderr, logOutput);

  return p;
};


exports.runGitInCheckout = function (...args) {
  args.unshift(
    '--git-dir=' +
    files.convertToOSPath(files.pathJoin(files.getCurrentToolsDir(), '.git')));

  return exports.execFileSync('git', args).stdout;
};

exports.Throttled = function (options) {
  var self = this;

  options = Object.assign({ interval: 150 }, options || {});
  self.interval = options.interval;
  var now = +(new Date);

  self.next = now;
};

Object.assign(exports.Throttled.prototype, {
  isAllowed: function () {
    var self = this;
    var now = +(new Date);

    if (now < self.next) {
      return false;
    }

    self.next = now + self.interval;
    return true;
  }
});


// ThrottledYield just regulates the frequency of calling yield.
// It should behave similarly to calling yield on every iteration of a loop,
// except that it won't actually yield if there hasn't been a long enough time interval
//
// options:
//   interval: minimum interval of time between yield calls
//             (more frequent calls are simply dropped)
exports.ThrottledYield = function (options) {
  var self = this;

  self._throttle = new exports.Throttled(options);
};

Object.assign(exports.ThrottledYield.prototype, {
  yield: function () {
    var self = this;
    if (self._throttle.isAllowed()) {
      // setImmediate allows signals and IO to be processed but doesn't
      // otherwise add time-based delays. It is better for yielding than
      // process.nextTick (which doesn't allow signals or IO to be processed) or
      // setTimeout 1 (which adds a minimum of 1 ms and often more in delays).
      // XXX Actually, setImmediate is so fast that we might not even need
      // to use the throttler at all?
      new Promise(setImmediate).await();
    }
  }
});

// Use this to convert dates into our preferred human-readable format.
//
// Takes in either null, a raw date string (ex: 2014-12-09T18:37:48.977Z) or a
// date object and returns a long-form human-readable date (ex: December 9th,
// 2014) or unknown for null.
exports.longformDate = function (date) {
  if (! date) {
    return "Unknown";
  }
  var moment = require('moment');
  var pubDate = moment(date).format('MMMM Do, YYYY');
  return pubDate;
};

// Length of the longest possible string that could come out of longformDate
// (September is the longest month name, so "September 24th, 2014" would be an
// example).
exports.maxDateLength = "September 24th, 2014".length;

// Returns a sha256 hash of a given string.
exports.sha256 = function (contents) {
  var crypto = require('crypto');
  var hash = crypto.createHash('sha256');
  hash.update(contents);
  return hash.digest('base64');
};

exports.sourceMapLength = function (sm) {
  if (! sm) {
    return 0;
  }
  // sum the length of sources and the mappings, the size of
  // metadata is ignored, but it is not a big deal
  return sm.mappings.length
       + (sm.sourcesContent || []).reduce((soFar, current) => {
         return soFar + (current ? current.length : 0);
       }, 0);
};

// Find and return the current OS architecture, in "uname -m" format.
//
// For Linux and macOS (Darwin) this means first getting the current
// architecture reported by Node using "os.arch()" (e.g. ia32, x64), then
// converting it to a "uname -m" matching architecture label (e.g. i686,
// x86_64).
//
// For Windows things are handled differently. Node's "os.arch()" will return
// "ia32" for both 32-bit and 64-bit versions of Windows (since we're using
// a 32-bit version of Node on Windows). Instead we'll look for the presence
// of the PROCESSOR_ARCHITEW6432 environment variable to determine if the
// Windows architecture is 64-bit, then convert to a "uname -m" matching
// architecture label (e.g. i386, x86_64).
export function architecture() {
  const supportedArchitectures = {
    Darwin: {
      x64: 'x86_64',
    },
    Linux: {
      ia32: 'i686',
      x64: 'x86_64',
    },
    Windows_NT: {
      ia32: process.env.hasOwnProperty('PROCESSOR_ARCHITEW6432')
              ? 'x86_64'
              : 'i386',
      x64: 'x86_64'
    }
  };

  const osType = os.type();
  const osArch = os.arch();

  if (!supportedArchitectures[osType]) {
    throw new Error(`Unsupported OS ${osType}`);
  }

  if (!supportedArchitectures[osType][osArch]) {
    throw new Error(`Unsupported architecture ${osArch}`);
  }

  return supportedArchitectures[osType][osArch];
};

let emacsDetected;
export function isEmacs() {
  // Checking `process.env` is expensive, so only check once.
  if (typeof emacsDetected === "boolean") {
    return emacsDetected;
  }

  // Prior to v22, Emacs only set EMACS. After v27, it only sets INSIDE_EMACS.
  emacsDetected = !! (process.env.EMACS === "t" || process.env.INSIDE_EMACS);
  return emacsDetected;
}