"use strict"; const dedent = require("dedent"); const getPort = require("get-port"); const npa = require("npm-package-arg"); const path = require("path"); const pMap = require("p-map"); const pMapSeries = require("p-map-series"); const pWaterfall = require("p-waterfall"); const { Command } = require("@lerna/command"); const { rimrafDir } = require("@lerna/rimraf-dir"); const { hasNpmVersion } = require("@lerna/has-npm-version"); const { npmInstall, npmInstallDependencies } = require("@lerna/npm-install"); const { createRunner } = require("@lerna/run-lifecycle"); const { runTopologically } = require("@lerna/run-topologically"); const { symlinkBinary } = require("@lerna/symlink-binary"); const { symlinkDependencies } = require("@lerna/symlink-dependencies"); const { ValidationError } = require("@lerna/validation-error"); const { getFilteredPackages } = require("@lerna/filter-options"); const { PackageGraph } = require("@lerna/package-graph"); const { pulseTillDone } = require("@lerna/pulse-till-done"); const { hasDependencyInstalled } = require("./lib/has-dependency-installed"); const { isHoistedPackage } = require("./lib/is-hoisted-package"); module.exports = factory; function factory(argv) { return new BootstrapCommand(argv); } class BootstrapCommand extends Command { get requiresGit() { return false; } initialize() { const { registry, npmClient = "npm", npmClientArgs = [], mutex, hoist, nohoist } = this.options; if (npmClient === "yarn" && hoist) { throw new ValidationError( "EWORKSPACES", dedent` --hoist is not supported with --npm-client=yarn, use yarn workspaces instead A guide is available at https://yarnpkg.com/blog/2017/08/02/introducing-workspaces/ ` ); } if ( npmClient === "yarn" && this.project.manifest.get("workspaces") && this.options.useWorkspaces !== true ) { throw new ValidationError( "EWORKSPACES", dedent` Yarn workspaces are configured in package.json, but not enabled in lerna.json! Please choose one: useWorkspaces = true in lerna.json, or remove package.json workspaces config ` ); } // postinstall and prepare are commonly used to call `lerna bootstrap`, // but we need to avoid recursive execution when `--hoist` is enabled const { LERNA_EXEC_PATH = "leaf", LERNA_ROOT_PATH = "root" } = process.env; if (LERNA_EXEC_PATH === LERNA_ROOT_PATH) { this.logger.warn("bootstrap", "Skipping recursive execution"); return false; } if (hoist) { let hoisting; if (hoist === true) { // lerna.json `hoist: true` hoisting = ["**"]; } else { // `--hoist ...` or lerna.json `hoist: [...]` hoisting = [].concat(hoist); } if (nohoist) { if (!Array.isArray(nohoist)) { // `--nohoist` single hoisting = hoisting.concat(`!${nohoist}`); } else { // `--nohoist` multiple or lerna.json `nohoist: [...]` hoisting = hoisting.concat(nohoist.map((str) => `!${str}`)); } } this.logger.verbose("hoist", "using globs %j", hoisting); this.hoisting = hoisting; } this.runPackageLifecycle = createRunner({ registry }); this.npmConfig = { registry, npmClient, npmClientArgs, mutex, }; if (npmClient === "npm" && this.options.ci && hasNpmVersion(">=5.7.0")) { // never `npm ci` when hoisting this.npmConfig.subCommand = this.hoisting ? "install" : "ci"; if (this.hoisting) { // don't mutate lockfiles in CI this.npmConfig.npmClientArgs.unshift("--no-save"); } } // lerna bootstrap ... -- const doubleDashArgs = this.options["--"] || []; if (doubleDashArgs.length) { this.npmConfig.npmClientArgs = [...npmClientArgs, ...doubleDashArgs]; } // do not run any lifecycle scripts (if configured) if (this.options.ignoreScripts) { this.npmConfig.npmClientArgs.unshift("--ignore-scripts"); } this.targetGraph = this.options.forceLocal ? new PackageGraph(this.packageGraph.rawPackageList, "allDependencies", "forceLocal") : this.packageGraph; let chain = Promise.resolve(); chain = chain.then(() => { return getFilteredPackages(this.targetGraph, this.execOpts, this.options); }); chain = chain.then((filteredPackages) => { this.filteredPackages = filteredPackages; if (this.options.contents) { // globally override directory to link for (const pkg of filteredPackages) { pkg.contents = this.options.contents; } } if (filteredPackages.length !== this.targetGraph.size && !this.options.forceLocal) { this.logger.warn("bootstrap", "Installing local packages that do not match filters from registry"); // an explicit --scope, --ignore, or --since should only symlink the targeted packages, no others this.targetGraph = new PackageGraph(filteredPackages, "allDependencies", this.options.forceLocal); // never automatically --save or modify lockfiles this.npmConfig.npmClientArgs.unshift(npmClient === "yarn" ? "--pure-lockfile" : "--no-save"); // never attempt `npm ci`, it would always fail if (this.npmConfig.subCommand === "ci") { this.npmConfig.subCommand = "install"; } } }); chain = chain.then(() => { if (npmClient === "yarn" && !mutex) { return getPort({ port: 42424, host: "0.0.0.0" }).then((port) => { this.npmConfig.mutex = `network:${port}`; this.logger.silly("npmConfig", this.npmConfig); }); } this.logger.silly("npmConfig", this.npmConfig); }); return chain; } execute() { if (this.options.useWorkspaces || this.rootHasLocalFileDependencies()) { return this.installRootPackageOnly(); } const filteredLength = this.filteredPackages.length; const packageCountLabel = `${filteredLength} package${filteredLength > 1 ? "s" : ""}`; const scriptsEnabled = this.options.ignoreScripts !== true; // root install does not need progress bar this.enableProgressBar(); this.logger.info("", `Bootstrapping ${packageCountLabel}`); // conditional task queue const tasks = []; if (scriptsEnabled) { tasks.push(() => this.runLifecycleInPackages("preinstall")); } tasks.push( () => this.getDependenciesToInstall(), (result) => this.installExternalDependencies(result), () => this.symlinkPackages() ); if (scriptsEnabled) { tasks.push( () => this.runLifecycleInPackages("install"), () => this.runLifecycleInPackages("postinstall") ); if (!this.options.ignorePrepublish) { tasks.push(() => this.runLifecycleInPackages("prepublish")); } // "run on local npm install without any arguments", AFTER prepublish tasks.push(() => this.runLifecycleInPackages("prepare")); } return pWaterfall(tasks).then(() => { this.logger.success("", `Bootstrapped ${packageCountLabel}`); }); } installRootPackageOnly() { this.logger.info("bootstrap", "root only"); // don't hide yarn or npm output this.npmConfig.stdio = "inherit"; return npmInstall(this.project.manifest, this.npmConfig); } /** * If the root manifest has local dependencies with `file:` specifiers, * all the complicated bootstrap logic should be skipped in favor of * npm5's package-locked auto-hoisting. * @returns {Boolean} */ rootHasLocalFileDependencies() { const rootDependencies = Object.assign({}, this.project.manifest.dependencies); return Object.keys(rootDependencies).some( (name) => this.targetGraph.has(name) && npa.resolve(name, rootDependencies[name], this.project.rootPath).type === "directory" ); } runLifecycleInPackages(stage) { this.logger.verbose("lifecycle", stage); if (!this.filteredPackages.length) { return; } const tracker = this.logger.newItem(stage); const mapPackageWithScript = (pkg) => this.runPackageLifecycle(pkg, stage).then(() => { tracker.completeWork(1); }); tracker.addWork(this.filteredPackages.length); const runner = this.toposort ? runTopologically(this.filteredPackages, mapPackageWithScript, { concurrency: this.concurrency, rejectCycles: this.options.rejectCycles, }) : pMap(this.filteredPackages, mapPackageWithScript, { concurrency: this.concurrency }); return runner.finally(() => tracker.finish()); } hoistedDirectory(dependency) { return path.join(this.project.rootPath, "node_modules", dependency); } hoistedPackageJson(dependency) { try { // eslint-disable-next-line import/no-dynamic-require, global-require return require(path.join(this.hoistedDirectory(dependency), "package.json")); } catch (e) { // Pass. return {}; } } /** * Return a object of root and leaf dependencies to install * @returns {Object} */ getDependenciesToInstall() { // Configuration for what packages to hoist may be in lerna.json or it may // come in as command line options. const rootPkg = this.project.manifest; // This will contain entries for each hoistable dependency. const rootSet = new Set(); // This will map packages to lists of unhoistable dependencies const leaves = new Map(); /** * Map of dependencies to install * * Map { * "": Map { * "": Set { "", "", ... } * } * } * * Example: * * Map { * "react": Map { * "15.x": Set { "my-component1", "my-component2", "my-component3" }, * "^0.14.0": Set { "my-component4" }, * } * } */ const depsToInstall = new Map(); const filteredNodes = new Map( this.filteredPackages.map((pkg) => [pkg.name, this.targetGraph.get(pkg.name)]) ); // collect root dependency versions const mergedRootDeps = Object.assign( {}, rootPkg.devDependencies, rootPkg.optionalDependencies, rootPkg.dependencies ); const rootExternalVersions = new Map( Object.keys(mergedRootDeps).map((externalName) => [externalName, mergedRootDeps[externalName]]) ); // seed the root dependencies rootExternalVersions.forEach((version, externalName) => { const externalDependents = new Set(); const record = new Map(); record.set(version, externalDependents); depsToInstall.set(externalName, record); }); // build a map of external dependencies to install for (const [leafName, leafNode] of filteredNodes) { for (const [externalName, resolved] of leafNode.externalDependencies) { // rawSpec is something like "^1.2.3" const version = resolved.rawSpec; const record = depsToInstall.get(externalName) || depsToInstall.set(externalName, new Map()).get(externalName); const externalDependents = record.get(version) || record.set(version, new Set()).get(version); externalDependents.add(leafName); } } const rootActions = []; const leafActions = []; // We don't want to exit on the first hoist issue, but log them all and then exit let strictExitOnWarning = false; // determine where each dependency will be installed for (const [externalName, externalDependents] of depsToInstall) { let rootVersion; if (this.hoisting && isHoistedPackage(externalName, this.hoisting)) { const commonVersion = Array.from(externalDependents.keys()).reduce((a, b) => externalDependents.get(a).size > externalDependents.get(b).size ? a : b ); // Get the version required by the repo root (if any). // If the root doesn't have a dependency on this package then we'll // install the most common dependency there. rootVersion = rootExternalVersions.get(externalName) || commonVersion; if (rootVersion !== commonVersion) { this.logger.warn( "EHOIST_ROOT_VERSION", `The repository root depends on ${externalName}@${rootVersion}, ` + `which differs from the more common ${externalName}@${commonVersion}.` ); if (this.options.strict) { strictExitOnWarning = true; } } const dependents = Array.from(externalDependents.get(rootVersion)).map( (leafName) => this.targetGraph.get(leafName).pkg ); // remove collection so leaves don't repeat it externalDependents.delete(rootVersion); // Install the best version we can in the repo root. // Even if it's already installed there we still need to make sure any // binaries are linked to the packages that depend on them. rootActions.push(() => hasDependencyInstalled(rootPkg, externalName, rootVersion).then((isSatisfied) => { rootSet.add({ name: externalName, dependents, dependency: `${externalName}@${rootVersion}`, isSatisfied, }); }) ); } // Add less common versions to package installs. for (const [leafVersion, leafDependents] of externalDependents) { for (const leafName of leafDependents) { if (rootVersion) { this.logger.warn( "EHOIST_PKG_VERSION", `"${leafName}" package depends on ${externalName}@${leafVersion}, ` + `which differs from the hoisted ${externalName}@${rootVersion}.` ); if (this.options.strict) { strictExitOnWarning = true; } } const leafNode = this.targetGraph.get(leafName); const leafRecord = leaves.get(leafNode) || leaves.set(leafNode, new Set()).get(leafNode); // only install dependency if it's not already installed leafActions.push(() => hasDependencyInstalled(leafNode.pkg, externalName, leafVersion).then((isSatisfied) => { leafRecord.add({ dependency: `${externalName}@${leafVersion}`, isSatisfied, }); }) ); } } } if (this.options.strict && strictExitOnWarning) { throw new ValidationError( "EHOISTSTRICT", "Package version inconsistencies found while hoisting. Fix the above warnings and retry." ); } return pMapSeries([...rootActions, ...leafActions], (el) => el()).then(() => { this.logger.silly("root dependencies", JSON.stringify(rootSet, null, 2)); this.logger.silly("leaf dependencies", JSON.stringify(leaves, null, 2)); return { rootSet, leaves }; }); } /** * Install external dependencies for all packages * @returns {Promise} */ installExternalDependencies({ leaves, rootSet }) { const tracker = this.logger.newItem("install dependencies"); const rootPkg = this.project.manifest; const actions = []; // Start root install first, if any, since it's likely to take the longest. if (rootSet.size) { // If we have anything to install in the root then we'll install // _everything_ that needs to go there. This is important for // consistent behavior across npm clients. const root = Array.from(rootSet); actions.push(() => { const depsToInstallInRoot = root.some(({ isSatisfied }) => !isSatisfied) ? root.map(({ dependency }) => dependency) : []; if (depsToInstallInRoot.length) { tracker.info("hoist", "Installing hoisted dependencies into root"); } const promise = npmInstallDependencies(rootPkg, depsToInstallInRoot, this.npmConfig); return pulseTillDone(promise) .then(() => // Link binaries into dependent packages so npm scripts will // have access to them. pMapSeries(root, ({ name, dependents }) => { const { bin } = this.hoistedPackageJson(name); if (bin) { return pMap(dependents, (pkg) => { const src = this.hoistedDirectory(name); return symlinkBinary(src, pkg); }); } }) ) .then(() => { tracker.info("hoist", "Finished bootstrapping root"); tracker.completeWork(1); }); }); // Remove any hoisted dependencies that may have previously been // installed in package directories. actions.push(() => { // Compute the list of candidate directories synchronously const candidates = root .filter((dep) => dep.dependents.length) .reduce((list, { name, dependents }) => { const dirs = dependents .filter((pkg) => pkg.nodeModulesLocation !== rootPkg.nodeModulesLocation) .map((pkg) => path.join(pkg.nodeModulesLocation, name)); return list.concat(dirs); }, []); if (!candidates.length) { tracker.verbose("hoist", "nothing to prune"); tracker.completeWork(1); // the action "work" return; } tracker.info("hoist", "Pruning hoisted dependencies"); tracker.silly("prune", candidates); tracker.addWork(candidates.length); return pMap( candidates, (dirPath) => pulseTillDone(rimrafDir(dirPath)).then(() => { tracker.verbose("prune", dirPath); tracker.completeWork(1); }), // these are mostly no-ops in the vast majority of cases { concurrency: this.concurrency } ).then(() => { tracker.info("hoist", "Finished pruning hoisted dependencies"); tracker.completeWork(1); // the action "work" }); }); } const leafNpmConfig = Object.assign({}, this.npmConfig, { // Use `npm install --global-style` for leaves when hoisting is enabled npmGlobalStyle: !!this.options.hoist, }); // Install anything that needs to go into the leaves. leaves.forEach((leafRecord, leafNode) => { const deps = Array.from(leafRecord); // If we have any unsatisfied deps then we need to install everything. // This is important for consistent behavior across npm clients. if (deps.some(({ isSatisfied }) => !isSatisfied)) { actions.push(() => { const dependencies = deps.map(({ dependency }) => dependency); const promise = npmInstallDependencies(leafNode.pkg, dependencies, leafNpmConfig); return pulseTillDone(promise).then(() => { tracker.verbose("installed leaf", leafNode.name); tracker.completeWork(1); }); }); } }); if (actions.length) { tracker.info("", "Installing external dependencies"); tracker.verbose("actions", "%d actions, concurrency %d", actions.length, this.concurrency); tracker.addWork(actions.length); } return pMap(actions, (act) => act(), { concurrency: this.concurrency }).finally(() => tracker.finish()); } /** * Symlink all packages to the packages/node_modules directory * Symlink package binaries to dependent packages' node_modules/.bin directory * @returns {Promise} */ symlinkPackages() { return symlinkDependencies( this.filteredPackages, this.targetGraph, this.logger.newItem("bootstrap dependencies") ); } } module.exports.BootstrapCommand = BootstrapCommand;