meteor/meteor

View on GitHub
tools/isobuild/meteor-npm.js

Summary

Maintainability
F
5 days
Test Coverage
/// Implements the process of managing a package's .npm directory,
/// in which we call `npm install` to install npm dependencies,
/// and a variety of related commands. Notably, we use `npm shrinkwrap`
/// to ensure we get consistent versions of npm sub-dependencies.

var assert = require('assert');
var cleanup = require('../tool-env/cleanup.js');
var fs = require('fs');
var files = require('../fs/files');
var _ = require('underscore');
var buildmessage = require('../utils/buildmessage.js');
var utils = require('../utils/utils.js');
var runLog = require('../runners/run-log.js');
var Profile = require('../tool-env/profile').Profile;
import { parse } from "semver";
import { version as npmVersion } from 'npm';
import {
  get as getRebuildArgs
} from "../static-assets/server/npm-rebuild-args.js";
import {
  convert as convertColonsInPath
} from "../utils/colon-converter.js";

import { wrap as wrapOptimistic } from "optimism";
import {
  dirtyNodeModulesDirectory,
  optimisticLStat,
  optimisticStatOrNull,
  optimisticReadJsonOrNull,
  optimisticReaddir,
} from "../fs/optimistic";

var meteorNpm = exports;

// Expose the version of npm in use from the dev bundle.
meteorNpm.npmVersion = npmVersion;

// if a user exits meteor while we're trying to create a .npm
// directory, we will have temporary directories that we clean up
var tmpDirs = [];
cleanup.onExit(function () {
  _.each(tmpDirs, function (dir) {
    if (files.exists(dir)) {
      files.rm_recursive(dir);
    }
  });
});

// Exception used internally to gracefully bail out of a npm run if
// something goes wrong
var NpmFailure = function () {};

// Creates a temporary directory in which the new contents of the
// package's .npm directory will be assembled. If all is successful,
// renames that directory back to .npm. Returns true if there are NPM
// dependencies and they are installed without error.
//
// @param npmDependencies {Object} dependencies that should be
//     installed, eg {tar: '0.1.6', gcd: '0.0.0'}. If falsey or empty,
//     will remove the .npm directory instead.
meteorNpm.updateDependencies = function (packageName,
                                         packageNpmDir,
                                         npmDependencies,
                                         quiet) {
  // we make sure to put it beside the original package dir so that
  // we can then atomically rename it. we also make sure to
  // randomize the name, in case we're bundling this package
  // multiple times in parallel.
  var newPackageNpmDir =
    convertColonsInPath(packageNpmDir) + '-new-' + utils.randomToken();

  if (! npmDependencies || _.isEmpty(npmDependencies)) {
    // No NPM dependencies? Delete the .npm directory if it exists (because,
    // eg, we used to have NPM dependencies but don't any more).  We'd like to
    // do this in as atomic a way as possible in case multiple meteor
    // instances are trying to make this update in parallel, so we rename the
    // directory to something before doing the rm -rf.
    try {
      files.rename(packageNpmDir, newPackageNpmDir);
    } catch (e) {
      if (e.code !== 'ENOENT') {
        throw e;
      }
      // It didn't exist, which is exactly what we wanted.
      return false;
    }
    files.rm_recursive(newPackageNpmDir);
    return false;
  }

  try {
    // v0.6.0 had a bug that could cause .npm directories to be
    // created without npm-shrinkwrap.json
    // (https://github.com/meteor/meteor/pull/927). Running your app
    // in that state causes consistent "Corrupted .npm directory"
    // errors.
    //
    // If you've reached that state, delete the empty directory and
    // proceed.
    if (files.exists(packageNpmDir) &&
        ! files.exists(files.pathJoin(packageNpmDir, 'npm-shrinkwrap.json'))) {
      files.rm_recursive(packageNpmDir);
    }

    if (files.exists(packageNpmDir)) {
      // we already nave a .npm directory. update it appropriately with some
      // ceremony involving:
      // `npm install`, `npm install name@version`, `npm shrinkwrap`
      updateExistingNpmDirectory(
        packageName, newPackageNpmDir, packageNpmDir, npmDependencies, quiet);
    } else {
      // create a fresh .npm directory with `npm install
      // name@version` and `npm shrinkwrap`
      createFreshNpmDirectory(
        packageName, newPackageNpmDir, packageNpmDir, npmDependencies, quiet);
    }
  } catch (e) {
    if (e instanceof NpmFailure) {
      // Something happened that was out of our control, but wasn't
      // exactly unexpected (eg, no such npm package, no internet
      // connection). Handle it gracefully.
      return false;
    }

    // Some other exception -- let it propagate.
    throw e;
  } finally {
    if (files.exists(newPackageNpmDir)) {
      files.rm_recursive(newPackageNpmDir);
    }
    tmpDirs = _.without(tmpDirs, newPackageNpmDir);
  }

  return true;
};

// Returns a flattened dictionary of npm package names used in production,
// or false if there is no package.json file in the parent directory.
export const getProdPackageNames = wrapOptimistic(nodeModulesDir => {
  const names = Object.create(null);
  const dirs = Object.create(null);
  const nodeModulesDirStack = [];

  // Returns true iff dir is a package directory.
  function walk(dir) {
    const packageJsonPath = files.pathJoin(dir, "package.json");
    const packageJsonStat = optimisticStatOrNull(packageJsonPath);

    if (packageJsonStat &&
        packageJsonStat.isFile()) {
      const pkg = optimisticReadJsonOrNull(packageJsonPath);
      const nodeModulesDir = files.pathJoin(dir, "node_modules");
      nodeModulesDirStack.push(nodeModulesDir);

      // Scan all dependencies except pkg.devDependencies.
      scanDeps(pkg.dependencies);
      scanDeps(pkg.peerDependencies);
      scanDeps(pkg.optionalDependencies);
      scanDeps(pkg.bundledDependencies);
      // This typo is also honored.
      scanDeps(pkg.bundleDependencies);

      assert.strictEqual(
        nodeModulesDirStack.pop(),
        nodeModulesDir
      );

      return true;
    }

    return false;
  }

  function scanDeps(deps) {
    if (! deps) {
      return;
    }

    Object.keys(deps).forEach(name => {
      const resDir = resolve(name);
      if (! resDir || _.has(dirs, resDir)) {
        return;
      }

      // Record that we've seen this directory so that we don't try to
      // walk it again.
      dirs[resDir] = name;

      if (walk(resDir)) {
        // If resDir is indeed a package directory, record the package
        // name in the set of production names.
        names[name] = true;
      }
    });
  }

  function resolve(name) {
    for (let i = nodeModulesDirStack.length - 1; i >= 0; --i) {
      const nodeModulesDir = nodeModulesDirStack[i];
      const candidate = files.pathJoin(nodeModulesDir, name);
      const stat = optimisticStatOrNull(candidate);
      if (stat && stat.isDirectory()) {
        return candidate;
      }
    }
  }

  // If the top-level nodeModulesDir is not contained by a package
  // directory with a package.json file, then we return false to indicate
  // that we don't know or care which packages are production-specific.
  // Concretely, this means your app needs to have a package.json file if
  // you want any npm packages to be excluded in production.
  return walk(files.pathDirname(nodeModulesDir)) && names;
});

const lastRebuildJSONFilename = ".meteor-last-rebuild-version.json";

const currentVersions = {
  platform: process.platform,
  arch: process.arch,
  versions: {...process.versions},
};

const currentVersionsJSON =
  JSON.stringify(currentVersions, null, 2) + "\n";

function recordLastRebuildVersions(pkgDir) {
  // Record the current process.{platform,arch,versions} so that we can
  // avoid copying/rebuilding/renaming next time.
  files.writeFile(
    files.pathJoin(pkgDir, lastRebuildJSONFilename),
    currentVersionsJSON,
    "utf8"
  );
}

// Returns true iff isSubtreeOf(currentVersions, versions), allowing
// valid semantic versions to differ in their patch versions.
function versionsAreCompatible(versions) {
  return isSubtreeOf(currentVersions, versions, (a, b) => {
    // Technically already handled by isSubtreeOf, but doesn't hurt.
    if (a === b) {
      return true;
    }

    if (! a || ! b) {
      return false;
    }

    const aType = typeof a;
    const bType = typeof b;

    if (aType !== bType) {
      return false;
    }

    if (aType === "string") {
      const aVer = parse(a);
      const bVer = parse(b);
      return aVer && bVer &&
        aVer.major === bVer.major &&
        aVer.minor === bVer.minor;
    }
  });
}

function rebuildVersionsAreCompatible(pkgPath) {
  const versionFile =
    files.pathJoin(pkgPath, lastRebuildJSONFilename);

  return versionsAreCompatible(
    optimisticReadJsonOrNull(versionFile));
}

function isDirectory(path) {
  const stat = optimisticStatOrNull(path);
  return !! (stat && stat.isDirectory());
}

// Rebuilds any binary dependencies in the given node_modules directory,
// and returns true iff anything was rebuilt.
meteorNpm.rebuildIfNonPortable =
Profile("meteorNpm.rebuildIfNonPortable", function (nodeModulesDir) {
  const dirsToRebuild = [];

  function scan(dir, scoped) {
    if (! isDirectory(dir)) {
      return;
    }

    files.readdir(dir).forEach(item => {
      if (item.startsWith(".")) {
        // Ignore "hidden" files, such as node_modules/.bin directories.
        return;
      }

      const path = files.pathJoin(dir, item);

      if (! scoped &&
          item.startsWith("@")) {
        return scan(path, true);
      }

      if (! isDirectory(path)) {
        return;
      }

      if (isPortable(path)) {
        return;
      }

      if (rebuildVersionsAreCompatible(path)) {
        return;
      }

      dirsToRebuild.push(path);
    });
  }

  scan(nodeModulesDir);

  if (dirsToRebuild.length === 0) {
    return false;
  }

  const tempDir = files.pathJoin(
    nodeModulesDir,
    ".temp-" + utils.randomToken()
  );

  // There's a chance the basename of the original nodeModulesDir isn't
  // actually "node_modules", which will confuse the `npm rebuild`
  // command, but fortunately we can ensure this temporary directory has
  // exactly that basename.
  const tempNodeModules = files.pathJoin(tempDir, "node_modules");
  files.mkdir_p(tempNodeModules);

  // Map from original package directory paths to temporary package
  // directory paths.
  const tempPkgDirs = {};

  dirsToRebuild.splice(0).forEach(pkgPath => {
    const tempPkgDir = tempPkgDirs[pkgPath] = files.pathJoin(
      tempNodeModules,
      files.pathRelative(nodeModulesDir, pkgPath)
    );

    // It's possible the pkgPath directory may have been deleted since we
    // did the scan above: https://circleci.com/gh/meteor/meteor/31330
    if (isDirectory(pkgPath)) {
      // Copy the package directory instead of renaming it, so that the
      // original package will be left untouched if the rebuild fails. We
      // could just run files.cp_r(pkgPath, tempPkgDir) here, except that we
      // want to handle nested node_modules directories specially.
      copyNpmPackageWithSymlinkedNodeModules(pkgPath, tempPkgDir);

      // Record the current process.versions so that we can avoid
      // copying/rebuilding/renaming next time.
      recordLastRebuildVersions(tempPkgDir);

      dirsToRebuild.push(pkgPath);
    }
  });

  // The `npm rebuild` command must be run in the parent directory of the
  // relevant node_modules directory, which in this case is tempDir.
  const rebuildResult = runNpmCommand(getRebuildArgs(), tempDir);
  if (! rebuildResult.success) {
    buildmessage.error(rebuildResult.error);
    files.rm_recursive(tempDir);
    return false;
  }

  dirtyNodeModulesDirectory(nodeModulesDir);

  // If the `npm rebuild` command succeeded, overwrite the original
  // package directories with the rebuilt package directories.
  dirsToRebuild.forEach(function (pkgPath) {
    const actualNodeModulesDir =
      files.pathJoin(pkgPath, "node_modules");

    const actualNodeModulesStat =
      files.statOrNull(actualNodeModulesDir);

    if (actualNodeModulesStat &&
        actualNodeModulesStat.isDirectory()) {
      // If the original package had a node_modules directory, move it
      // into the temporary package directory, overwriting the one created
      // by copyNpmPackageWithSymlinkedNodeModules (which contains only
      // symlinks), so that when we rename the temporary directory back to
      // the original directory below, we'll end up with a node_modules
      // directory that contains real packages rather than symlinks.

      const symlinkNodeModulesDir =
        files.pathJoin(tempPkgDirs[pkgPath], "node_modules");

      files.renameDirAlmostAtomically(
        actualNodeModulesDir,
        symlinkNodeModulesDir
      );
    }

    files.renameDirAlmostAtomically(tempPkgDirs[pkgPath], pkgPath);
  });

  files.rm_recursive(tempDir);

  return true;
});

// Copy an npm package directory to another location, but attempt to
// symlink all of its node_modules rather than recursively copying them,
// which potentially saves a lot of time.
function copyNpmPackageWithSymlinkedNodeModules(fromPkgDir, toPkgDir) {
  files.mkdir_p(toPkgDir);

  let needToHandleNodeModules = false;

  files.readdir(fromPkgDir).forEach(item => {
    if (item === "node_modules") {
      // We'll link or copy node_modules in a follow-up step.
      needToHandleNodeModules = true;
      return;
    }

    files.cp_r(
      files.pathJoin(fromPkgDir, item),
      files.pathJoin(toPkgDir, item)
    );
  });

  if (! needToHandleNodeModules) {
    return;
  }

  const nodeModulesFromPath = files.pathJoin(fromPkgDir, "node_modules");
  const nodeModulesToPath = files.pathJoin(toPkgDir, "node_modules");

  files.mkdir(nodeModulesToPath);

  files.readdir(nodeModulesFromPath).forEach(depPath => {
    if (depPath === ".bin") {
      // Avoid copying node_modules/.bin because commands like
      // .bin/node-gyp and .bin/node-pre-gyp tend to cause problems.
      return;
    }

    const absDepFromPath = files.pathJoin(nodeModulesFromPath, depPath);

    if (! files.stat(absDepFromPath).isDirectory()) {
      // Only copy package directories, even though there might be other
      // kinds of files in node_modules.
      return;
    }

    const absDepToPath = files.pathJoin(nodeModulesToPath, depPath);

    // Try to symlink node_modules dependencies if possible (faster),
    // and fall back to a recursive copy otherwise.
    try {
      files.symlink(absDepFromPath, absDepToPath, "junction");
    } catch (e) {
      files.cp_r(absDepFromPath, absDepToPath);
    }
  });
}

const portableCache = Object.create(null);

// Increment this version to trigger the full portability check again.
const portableVersion = 2;

const isPortable = Profile("meteorNpm.isPortable", dir => {
  const lstat = optimisticLStat(dir);
  if (! lstat.isDirectory()) {
    // Non-directory files are portable unless they end with .node.
    return ! dir.endsWith(".node");
  }

  const pkgJsonPath = files.pathJoin(dir, "package.json");
  const pkgJsonStat = optimisticStatOrNull(pkgJsonPath);
  const canCache = pkgJsonStat && pkgJsonStat.isFile();
  const portableFile = files.convertToOSPath(
    files.pathJoin(dir, ".meteor-portable-" + portableVersion + ".json")
  );

  if (canCache) {
    // Cache previous results by writing a boolean value to a hidden file
    // called .meteor-portable. Although it's tempting to write this file
    // once for the whole node_modules directory, it's important that we
    // put .meteor-portable files only in the individual top-level package
    // directories, so that they will get cleared away the next time those
    // packages are (re)installed.
    const result = _.has(portableCache, portableFile)
      ? portableCache[portableFile]
      : optimisticReadJsonOrNull(portableFile, {
          // Make optimisticReadJsonOrNull return null if there's a
          // SyntaxError when parsing the .meteor-portable file.
          allowSyntaxError: true
        });

    if (typeof result === "boolean") {
      return result;
    }

  } else {
    // Clean up any .meteor-portable files we mistakenly wrote in
    // directories that do not contain package.json files. #7296
    fs.unlink(portableFile, error => {});
  }

  const pkgJson = canCache && optimisticReadJsonOrNull(pkgJsonPath, {
    // A syntactically incorrect `package.json` isn't likely to have other
    // effects since the npm itself likely won't install but the developer has
    // no control over that happening so we should allow this.
    allowSyntaxError: true
  });

  const hasBuildScript =
    pkgJson &&
    pkgJson.scripts &&
    (pkgJson.scripts.preinstall ||
     pkgJson.scripts.install ||
     pkgJson.scripts.postinstall);

  const result = hasBuildScript
    ? false // Build scripts may not be portable.
    : optimisticReaddir(dir).every(
      // Ignore files that start with a ".", such as .bin directories.
      itemName => itemName.startsWith(".") ||
        isPortable(files.pathJoin(dir, itemName)));

  if (canCache) {
    // Write the .meteor-portable file asynchronously, and don't worry
    // if it fails, e.g. because the file system is read-only (#6591).
    // Failing to write the file only means more work next time.
    fs.writeFile(
      portableFile,
      JSON.stringify(result) + "\n",
      error => {
        // Once the asynchronous write finishes (successful or not), we no
        // longer need to cache the written value in memory.
        delete portableCache[portableFile];
      },
    );

    // Cache the result immediately in memory so we don't have to wait for
    // file change notifications to invalidate optimisticReadJsonOrNull.
    portableCache[portableFile] = result;
  }

  return result;
});

// Return true if all of a package's npm dependencies are portable
// (that is, if the node_modules can be copied anywhere and we'd
// expect it to work, rather than containing native extensions that
// were built just for our architecture), else
// false. updateDependencies should first be used to bring
// nodeModulesDir up to date.
meteorNpm.dependenciesArePortable = function (nodeModulesDir) {
  // We use a simple heuristic: we check to see if a package (or any
  // of its transitive dependencies) contains any *.node files. .node
  // is the extension that signals to Node that it should load a file
  // as a shared object rather than as JavaScript, so this should work
  // in the vast majority of cases.

  assert.ok(
    files.pathBasename(nodeModulesDir).startsWith("node_modules"),
    "Bad node_modules directory: " + nodeModulesDir,
  );

  // Only check/write .meteor-portable files in each of the top-level
  // package directories.
  return isPortable(nodeModulesDir);
};

var makeNewPackageNpmDir = function (newPackageNpmDir) {
  // keep track so that we can remove them on process exit
  tmpDirs.push(newPackageNpmDir);
  files.mkdir_p(newPackageNpmDir);

  // create node_modules -- prevent npm install from installing
  // to an existing node_modules dir higher up in the filesystem
  files.mkdir(files.pathJoin(newPackageNpmDir, 'node_modules'));

  // create .gitignore -- node_modules shouldn't be in git since we
  // recreate it as needed by using `npm install`. since we use `npm
  // shrinkwrap` we're guaranteed to have the same version installed
  // each time.
  files.writeFile(
    files.pathJoin(newPackageNpmDir, '.gitignore'),
    ['node_modules',
     ''/*git diff complains without trailing newline*/].join('\n'));
};

var updateExistingNpmDirectory = function (packageName, newPackageNpmDir,
                                           packageNpmDir, npmDependencies,
                                           quiet) {
  // sanity check on contents of .npm directory
  if (!files.stat(packageNpmDir).isDirectory()) {
    throw new Error("Corrupted .npm directory -- should be a directory: " +
                    packageNpmDir);
  }
  if (!files.exists(files.pathJoin(packageNpmDir, 'npm-shrinkwrap.json'))) {
    throw new Error(
      "Corrupted .npm directory -- can't find npm-shrinkwrap.json in " +
        packageNpmDir);
  }

  // We need to rebuild all node modules when the Node version
  // changes, in case there are some binary ones. Technically this is
  // racey, but it shouldn't fail very often.
  var nodeModulesDir = files.pathJoin(packageNpmDir, 'node_modules');
  if (files.exists(nodeModulesDir)) {
    var oldNodeVersion;
    try {
      oldNodeVersion = files.readFile(
        files.pathJoin(packageNpmDir, 'node_modules', '.node_version'), 'utf8');
    } catch (e) {
      if (e.code !== 'ENOENT') {
        throw e;
      }
      // Use the Node version from the last release where we didn't
      // drop this file.
      oldNodeVersion = 'v0.8.24';
    }

    if (oldNodeVersion !== currentNodeCompatibilityVersion()) {
      files.rm_recursive(nodeModulesDir);
    }
  }

  // Make sure node_modules is present (fix for #1761). Prevents npm install
  // from installing to an existing node_modules dir higher up in the
  // filesystem.  node_modules may be absent due to a change in Node version or
  // when `meteor add`ing a cloned package for the first time (node_modules is
  // excluded by .gitignore)
  if (! files.exists(nodeModulesDir)) {
    files.mkdir(nodeModulesDir);
  }

  var installedDependenciesTree = getInstalledDependenciesTree(packageNpmDir);
  var shrinkwrappedDependenciesTree =
    getShrinkwrappedDependenciesTree(packageNpmDir);

  const npmTree = { dependencies: {} };
  _.each(npmDependencies, (version, name) => {
    npmTree.dependencies[name] = { version };
  });

  const minInstalledTree =
    minimizeDependencyTree(installedDependenciesTree);
  const minShrinkwrapTree =
    minimizeDependencyTree(shrinkwrappedDependenciesTree);

  if (isSubtreeOf(npmTree, minInstalledTree) &&
      isSubtreeOf(minShrinkwrapTree, minInstalledTree)) {
    return;
  }

  if (! quiet) {
    logUpdateDependencies(packageName, npmDependencies);
  }

  makeNewPackageNpmDir(newPackageNpmDir);

  let preservedShrinkwrap;

  if (_.isEmpty(npmDependencies)) {
    // If there are no npmDependencies, make sure nothing is installed.
    preservedShrinkwrap = { dependencies: {} };

  } else if (isSubtreeOf(npmTree, minShrinkwrapTree)) {
    // If the top-level npm dependencies are already encompassed by the
    // npm-shrinkwrap.json file, then reuse that file.
    preservedShrinkwrap = shrinkwrappedDependenciesTree;

  } else {
    // Otherwise install npmTree.dependencies as if we were creating a new
    // .npm/package directory, and leave preservedShrinkwrap empty.
    installNpmDependencies(npmDependencies, newPackageNpmDir);

    // Note: as of npm@4.0.0, npm-shrinkwrap.json files are regarded as
    // "canonical," meaning `npm install` (without a package argument)
    // will only install dependencies mentioned in npm-shrinkwrap.json.
    // That's why we can't just update installedDependenciesTree to
    // include npmTree.dependencies and hope for the best, because if the
    // new versions of the required top-level packages have any additional
    // transitive dependencies, those dependencies will not be installed
    // unless previously mentioned in npm-shrinkwrap.json. Reference:
    // https://github.com/npm/npm/blob/latest/CHANGELOG.md#no-more-partial-shrinkwraps-breaking
  }

  if (! _.isEmpty(preservedShrinkwrap &&
                  preservedShrinkwrap.dependencies)) {
    const newShrinkwrapFile = files.pathJoin(
      newPackageNpmDir,
      'npm-shrinkwrap.json'
    );

    // There are some unchanged packages here. Install from shrinkwrap.
    files.writeFile(
      newShrinkwrapFile,
      JSON.stringify(preservedShrinkwrap, null, 2)
    );

    const newPackageJsonFile = files.pathJoin(
      newPackageNpmDir,
      "package.json"
    );

    // We have to write out a minimal package.json file, else the results
    // of installFromShrinkwrap may be incomplete in npm@5.
    files.writeFile(
      newPackageJsonFile,
      JSON.stringify({
        dependencies: npmDependencies
      }, null, 2)
    );

    // `npm install`
    installFromShrinkwrap(newPackageNpmDir);

    files.unlink(newShrinkwrapFile);
    files.unlink(newPackageJsonFile);
  }

  completeNpmDirectory(packageName, newPackageNpmDir, packageNpmDir,
                       npmDependencies);
};

function isSubtreeOf(subsetTree, supersetTree, predicate) {
  if (subsetTree === supersetTree) {
    return true;
  }

  if (_.isObject(subsetTree)) {
    return _.isObject(supersetTree) &&
      _.every(subsetTree, (value, key) => {
        return isSubtreeOf(value, supersetTree[key], predicate);
      });
  }

  if (_.isFunction(predicate)) {
    const result = predicate(subsetTree, supersetTree);
    if (typeof result === "boolean") {
      return result;
    }
  }

  return false;
}

var createFreshNpmDirectory = function (packageName, newPackageNpmDir,
                                        packageNpmDir, npmDependencies, quiet) {
  if (! quiet) {
    logUpdateDependencies(packageName, npmDependencies);
  }

  makeNewPackageNpmDir(newPackageNpmDir);

  installNpmDependencies(npmDependencies, newPackageNpmDir);

  completeNpmDirectory(packageName, newPackageNpmDir, packageNpmDir,
                       npmDependencies);
};

function installNpmDependencies(dependencies, dir) {
  const packageJsonPath = files.pathJoin(dir, "package.json");
  const packageJsonExisted = files.exists(packageJsonPath);

  files.writeFile(
    packageJsonPath,
    JSON.stringify({ dependencies }, null, 2)
  );

  try {
    Object.keys(dependencies).forEach(name => {
      const version = dependencies[name];
      installNpmModule(name, version, dir);
    });
  } finally {
    if (! packageJsonExisted) {
      files.unlink(packageJsonPath);
    }
  }
}

// Shared code for updateExistingNpmDirectory and createFreshNpmDirectory.
function completeNpmDirectory(
  packageName,
  newPackageNpmDir,
  packageNpmDir,
  npmDependencies,
) {
  // Create a shrinkwrap file.
  shrinkwrap(newPackageNpmDir);

  // And stow a copy of npm-shrinkwrap too.
  files.copyFile(
    files.pathJoin(newPackageNpmDir, 'npm-shrinkwrap.json'),
    files.pathJoin(newPackageNpmDir, 'node_modules', '.npm-shrinkwrap.json')
  );

  createReadme(newPackageNpmDir);
  createNodeVersion(newPackageNpmDir);
  files.renameDirAlmostAtomically(newPackageNpmDir, packageNpmDir);

  dirtyNodeModulesDirectory(files.pathJoin(packageNpmDir, "node_modules"));
}

var createReadme = function (newPackageNpmDir) {
  // This file gets checked in to version control by users, so resist the
  // temptation to make unnecessary tweaks to it.
  files.writeFile(
    files.pathJoin(newPackageNpmDir, 'README'),
"This directory and the files immediately inside it are automatically generated\n" +
"when you change this package's NPM dependencies. Commit the files in this\n" +
"directory (npm-shrinkwrap.json, .gitignore, and this README) to source control\n" +
"so that others run the same versions of sub-dependencies.\n" +
"\n" +
"You should NOT check in the node_modules directory that Meteor automatically\n" +
"creates; if you are using git, the .gitignore file tells git to ignore it.\n"
  );
};

var createNodeVersion = function (newPackageNpmDir) {
  files.writeFile(
    files.pathJoin(newPackageNpmDir, 'node_modules', '.node_version'),
    currentNodeCompatibilityVersion());
};

// This value should change whenever we think that the Node C ABI has changed
// (ie, when we need to be sure to reinstall npm packages because they might
// have native components that need to be rebuilt). It does not need to change
// for every patch release of Node! Notably, it needed to change between 0.8.*
// and 0.10.*.  If Node does make a patch release of 0.10 that breaks
// compatibility, you can just change this from "0.10.*" to "0.10.35" or
// whatever.
var currentNodeCompatibilityVersion = function () {
  var version = process.version;
  version = version.replace(/\.(\d+)$/, '.*');
  return version + '\n';
};

const npmUserConfigFile = files.pathJoin(
  __dirname,
  "meteor-npm-userconfig"
);

var runNpmCommand = meteorNpm.runNpmCommand =
Profile("meteorNpm.runNpmCommand", function (args, cwd) {
  import { getEnv } from "../cli/dev-bundle-bin-helpers.js";

  const devBundleDir = files.getDevBundle();
  const isWindows = process.platform === "win32";
  const npmPath = files.convertToOSPath(files.pathJoin(
    devBundleDir, "bin",
    isWindows ? "npm.cmd" : "npm"
  ));

  // On Windows, `.cmd` and `.bat` files must be launched in a shell per:
  // http://nodejs.org/api/child_process.html#child_process_spawning_bat_and_cmd_files_on_windows
  //
  // Additionally, the COMSPEC environment variable is meant to have the path to
  // cmd.exe, but we'll use 'cmd.exe' if it's not set, in the same spirit as
  // http://nodejs.org/api/child_process.html#child_process_shell_requirements.

  let commandToRun = npmPath;
  if (isWindows) {
    args = ['/c', npmPath, ...args];
    commandToRun = process.env.ComSpec || "cmd.exe";
  }

  if (meteorNpm._printNpmCalls) {
    // only used by test-bundler.js
    process.stdout.write('cd ' + cwd + ' && ' + commandToRun + ' ' +
                         args.join(' ') + ' ...\n');
  }

  return getEnv({
    devBundle: devBundleDir
  }).then(env => {
    const opts = {
      env: env,
      maxBuffer: 10 * 1024 * 1024
    };

    if (cwd) {
      opts.cwd = files.convertToOSPath(cwd);
    }

    // Make sure we don't honor any user-provided configuration files.
    env.npm_config_userconfig = npmUserConfigFile;

    return new Promise(function (resolve) {
      require('child_process').execFile(
        commandToRun, args, opts, function (err, stdout, stderr) {
          if (meteorNpm._printNpmCalls) {
            process.stdout.write(err ? 'failed\n' : 'done\n');
          }

          resolve({
            success: ! err,
            error: (err ? `${err.message}${stderr}` : stderr),
            stdout: stdout,
            stderr: stderr
          });
        }
      );
    }).await();

  }).await();
});

// Gets a JSON object from `npm ls --json` (getInstalledDependenciesTree) or
// `npm-shrinkwrap.json` (getShrinkwrappedDependenciesTree).
//
// @returns {Object} eg {
//   "name": "packages",
//   "version": "0.0.0",
//   "dependencies": {
//     "sockjs": {
//       "version": "0.3.4",
//       "dependencies": {
//         "node-uuid": {
//           "version": "1.3.3"
//         }
//       }
//     }
//   }
// }
function getInstalledDependenciesTree(dir) {
  function ls(nodeModulesDir) {
    let contents;
    try {
      contents = files.readdir(nodeModulesDir).sort();
    } finally {
      if (! contents) return;
    }

    const result = {};

    contents.forEach(item => {
      if (item.startsWith(".")) {
        return;
      }

      const pkgDir = files.pathJoin(nodeModulesDir, item);
      const pkgJsonPath = files.pathJoin(pkgDir, "package.json");

      if (item.startsWith("@")) {
        Object.assign(result, ls(pkgDir));
        return;
      }

      let pkg;
      try {
        pkg = JSON.parse(files.readFile(pkgJsonPath));
      } finally {
        if (! pkg) return;
      }

      const name = pkg.name || item;

      const info = result[name] = {
        version: pkg.version
      };

      const from = pkg._from || pkg.from;
      if (from) {
        // Fix for https://github.com/meteor/meteor/issues/9477:
        const prefix = name + "@";
        let fromUrl = from;
        if (fromUrl.startsWith(prefix)) {
          fromUrl = fromUrl.slice(prefix.length);
        }

        if (utils.isNpmUrl(fromUrl) &&
            ! utils.isNpmUrl(info.version)) {
          info.version = fromUrl;
        }
      }

      const resolved = pkg._resolved || pkg.resolved;
      if (resolved && resolved !== info.version) {
        info.resolved = resolved;
      }

      const integrity = pkg._integrity || pkg.integrity;
      if (integrity) {
        info.integrity = integrity;
      }

      const deps = ls(files.pathJoin(pkgDir, "node_modules"));
      if (deps && ! _.isEmpty(deps)) {
        info.dependencies = deps;
      }
    });

    return result;
  }

  return {
    lockfileVersion: 1,
    dependencies: ls(files.pathJoin(dir, "node_modules"))
  };
}

function getShrinkwrappedDependenciesTree(dir) {
  const shrinkwrap = JSON.parse(files.readFile(
    files.pathJoin(dir, 'npm-shrinkwrap.json')
  ));
  shrinkwrap.lockfileVersion = 1;
  return shrinkwrap;
};

// Maps a "dependency object" (a thing you find in `npm ls --json` or
// npm-shrinkwrap.json with keys like "version" and "from") to the
// canonical version that matches what users put in the `Npm.depends`
// clause.  ie, either the version or the tarball URL.
//
// If more logic is added here, it should probably go in minimizeModule too.
var canonicalVersion = function (depObj) {
  if (utils.isNpmUrl(depObj.from)) {
    return depObj.from;
  } else {
    return depObj.version;
  }
};

// map the structure returned from `npm ls` or shrinkwrap.json into
// the structure of npmDependencies (e.g. {gcd: '0.0.0'}), so that
// they can be diffed. This only returns top-level dependencies.
var treeToDependencies = function (tree) {
  return _.object(
    _.map(
      tree.dependencies, function (properties, name) {
        return [name, canonicalVersion(properties)];
      }));
};

var getInstalledDependencies = function (dir) {
  return treeToDependencies(getInstalledDependenciesTree(dir));
};

// (appears to not be called)
var getShrinkwrappedDependencies = function (dir) {
  return treeToDependencies(getShrinkwrappedDependenciesTree(dir));
};

const installNpmModule = meteorNpm.installNpmModule = (name, version, dir) => {
  const installArg = utils.isNpmUrl(version)
    ? version
    : `${name}@${version}`;

  // We don't use npm.commands.install since we couldn't figure out
  // how to silence all output (specifically the installed tree which
  // is printed out with `console.log`)
  const result = runNpmCommand(["install", installArg], dir);

  if (! result.success) {
    const pkgNotFound =
      `404 Not Found - GET ${utils.quotemeta("https://registry.npmjs.org/"+name)}`;

    const versionNotFound =
      "No matching version found for " +
      `${utils.quotemeta(name)}@${utils.quotemeta(version)}`;

    if (result.stderr.match(new RegExp(pkgNotFound))) {
      buildmessage.error(
        `there is no npm package named '${name}' in the npm registry`);
    } else if (result.stderr.match(new RegExp(versionNotFound))) {
      buildmessage.error(
        `${name} version ${version} is not available in the npm registry`);
    } else {
      buildmessage.error(
        `couldn\'t install npm package ${name}@${version}: ${result.error}`);
    }

    // Recover by returning false from updateDependencies
    throw new NpmFailure;
  }

  const pkgDir = files.pathJoin(dir, "node_modules", name);
  if (! isPortable(pkgDir)) {
    recordLastRebuildVersions(pkgDir);
  }

  if (process.platform !== "win32") {
    // If we are on a unixy file system, we should not build a package that
    // can't be used on Windows.

    var pathsWithColons = files.findPathsWithRegex(".", new RegExp(":"),
      { cwd: files.pathJoin(dir, "node_modules") });

    if (pathsWithColons.length) {
      var firstTen = pathsWithColons.slice(0, 10);
      if (pathsWithColons.length > 10) {
        firstTen.push("... " + (pathsWithColons.length - 10) +
          " paths omitted.");
      }

      buildmessage.error(
"Some filenames in your package have invalid characters.\n" +
"The following file paths in the NPM module '" + name + "' have colons, ':', which won't work on Windows:\n" +
firstTen.join("\n"));

      throw new NpmFailure;
    }
  }
};

var installFromShrinkwrap = function (dir) {
  if (! files.exists(files.pathJoin(dir, "npm-shrinkwrap.json"))) {
    throw new Error(
      "Can't call `npm install` without a npm-shrinkwrap.json file present");
  }

  // `npm install`, which reads npm-shrinkwrap.json.
  var result = runNpmCommand(["install"], dir);

  if (! result.success) {
    buildmessage.error(
      "couldn't install npm packages from npm-shrinkwrap: " +
        result.error
    );

    // Recover by returning false from updateDependencies
    throw new NpmFailure;
  }

  const nodeModulesDir = files.pathJoin(dir, "node_modules");
  files.readdir(nodeModulesDir).forEach(function (name) {
    const pkgDir = files.pathJoin(nodeModulesDir, name);
    if (! isPortable(pkgDir, true)) {
      recordLastRebuildVersions(pkgDir);
    }
  });
};

// `npm shrinkwrap`
function shrinkwrap(dir) {
  const tree = getInstalledDependenciesTree(dir);

  files.writeFile(
    files.pathJoin(dir, "npm-shrinkwrap.json"),
    JSON.stringify(tree, null, 2) + "\n"
  );

  const packageLockJsonPath =
    files.pathJoin(dir, "package-lock.json");

  // The normal `npm shrinkwrap` commands renames any package-lock.json
  // file to npm-shrinkwrap.json, so this function should have the same
  // side effect (i.e., removing package-lock.json if it exists).
  if (files.exists(packageLockJsonPath)) {
    files.unlink(packageLockJsonPath);
  }
}

// Reduces a dependency tree (as read from a just-made npm-shrinkwrap.json or
// from npm ls --json) to just the versions we want. Returns an object that does
// not share state with its input
function minimizeDependencyTree(tree) {
  function minimizeModule(module) {
    var version;
    if (module.resolved && ! isUrlFromRegistry(module.resolved)) {
      version = module.resolved;
    } else if (utils.isNpmUrl(module.from)) {
      version = module.from;
    } else {
      version = module.version;
    }
    var minimized = {version: version};

    if (module.dependencies) {
      minimized.dependencies = {};
      _.each(module.dependencies, function (subModule, name) {
        minimized.dependencies[name] = minimizeModule(subModule);
      });
    }
    return minimized;
  }

  var newTopLevelDependencies = {};
  _.each(tree.dependencies, function (module, name) {
    newTopLevelDependencies[name] = minimizeModule(module);
  });
  return {dependencies: newTopLevelDependencies};
}

function isUrlFromRegistry(url) {
  if (url.match(/^https?:\/\/registry.npmjs.org\//)) {
    return true;
  }
  const NCR = process.env.NPM_CONFIG_REGISTRY;
  return NCR && url.startsWith(NCR);
}

var logUpdateDependencies = function (packageName, npmDependencies) {
  runLog.log(packageName + ': updating npm dependencies -- ' +
             Object.keys(npmDependencies).join(', ') + '...');
};