index.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  1. "use strict";
  2. const dedent = require("dedent");
  3. const getPort = require("get-port");
  4. const npa = require("npm-package-arg");
  5. const path = require("path");
  6. const pMap = require("p-map");
  7. const pMapSeries = require("p-map-series");
  8. const pWaterfall = require("p-waterfall");
  9. const { Command } = require("@lerna/command");
  10. const { rimrafDir } = require("@lerna/rimraf-dir");
  11. const { hasNpmVersion } = require("@lerna/has-npm-version");
  12. const { npmInstall, npmInstallDependencies } = require("@lerna/npm-install");
  13. const { createRunner } = require("@lerna/run-lifecycle");
  14. const { runTopologically } = require("@lerna/run-topologically");
  15. const { symlinkBinary } = require("@lerna/symlink-binary");
  16. const { symlinkDependencies } = require("@lerna/symlink-dependencies");
  17. const { ValidationError } = require("@lerna/validation-error");
  18. const { getFilteredPackages } = require("@lerna/filter-options");
  19. const { PackageGraph } = require("@lerna/package-graph");
  20. const { pulseTillDone } = require("@lerna/pulse-till-done");
  21. const { hasDependencyInstalled } = require("./lib/has-dependency-installed");
  22. const { isHoistedPackage } = require("./lib/is-hoisted-package");
  23. module.exports = factory;
  24. function factory(argv) {
  25. return new BootstrapCommand(argv);
  26. }
  27. class BootstrapCommand extends Command {
  28. get requiresGit() {
  29. return false;
  30. }
  31. initialize() {
  32. const { registry, npmClient = "npm", npmClientArgs = [], mutex, hoist, nohoist } = this.options;
  33. if (npmClient === "yarn" && hoist) {
  34. throw new ValidationError(
  35. "EWORKSPACES",
  36. dedent`
  37. --hoist is not supported with --npm-client=yarn, use yarn workspaces instead
  38. A guide is available at https://yarnpkg.com/blog/2017/08/02/introducing-workspaces/
  39. `
  40. );
  41. }
  42. if (
  43. npmClient === "yarn" &&
  44. this.project.manifest.get("workspaces") &&
  45. this.options.useWorkspaces !== true
  46. ) {
  47. throw new ValidationError(
  48. "EWORKSPACES",
  49. dedent`
  50. Yarn workspaces are configured in package.json, but not enabled in lerna.json!
  51. Please choose one: useWorkspaces = true in lerna.json, or remove package.json workspaces config
  52. `
  53. );
  54. }
  55. // postinstall and prepare are commonly used to call `lerna bootstrap`,
  56. // but we need to avoid recursive execution when `--hoist` is enabled
  57. const { LERNA_EXEC_PATH = "leaf", LERNA_ROOT_PATH = "root" } = process.env;
  58. if (LERNA_EXEC_PATH === LERNA_ROOT_PATH) {
  59. this.logger.warn("bootstrap", "Skipping recursive execution");
  60. return false;
  61. }
  62. if (hoist) {
  63. let hoisting;
  64. if (hoist === true) {
  65. // lerna.json `hoist: true`
  66. hoisting = ["**"];
  67. } else {
  68. // `--hoist ...` or lerna.json `hoist: [...]`
  69. hoisting = [].concat(hoist);
  70. }
  71. if (nohoist) {
  72. if (!Array.isArray(nohoist)) {
  73. // `--nohoist` single
  74. hoisting = hoisting.concat(`!${nohoist}`);
  75. } else {
  76. // `--nohoist` multiple or lerna.json `nohoist: [...]`
  77. hoisting = hoisting.concat(nohoist.map((str) => `!${str}`));
  78. }
  79. }
  80. this.logger.verbose("hoist", "using globs %j", hoisting);
  81. this.hoisting = hoisting;
  82. }
  83. this.runPackageLifecycle = createRunner({ registry });
  84. this.npmConfig = {
  85. registry,
  86. npmClient,
  87. npmClientArgs,
  88. mutex,
  89. };
  90. if (npmClient === "npm" && this.options.ci && hasNpmVersion(">=5.7.0")) {
  91. // never `npm ci` when hoisting
  92. this.npmConfig.subCommand = this.hoisting ? "install" : "ci";
  93. if (this.hoisting) {
  94. // don't mutate lockfiles in CI
  95. this.npmConfig.npmClientArgs.unshift("--no-save");
  96. }
  97. }
  98. // lerna bootstrap ... -- <input>
  99. const doubleDashArgs = this.options["--"] || [];
  100. if (doubleDashArgs.length) {
  101. this.npmConfig.npmClientArgs = [...npmClientArgs, ...doubleDashArgs];
  102. }
  103. // do not run any lifecycle scripts (if configured)
  104. if (this.options.ignoreScripts) {
  105. this.npmConfig.npmClientArgs.unshift("--ignore-scripts");
  106. }
  107. this.targetGraph = this.options.forceLocal
  108. ? new PackageGraph(this.packageGraph.rawPackageList, "allDependencies", "forceLocal")
  109. : this.packageGraph;
  110. let chain = Promise.resolve();
  111. chain = chain.then(() => {
  112. return getFilteredPackages(this.targetGraph, this.execOpts, this.options);
  113. });
  114. chain = chain.then((filteredPackages) => {
  115. this.filteredPackages = filteredPackages;
  116. if (this.options.contents) {
  117. // globally override directory to link
  118. for (const pkg of filteredPackages) {
  119. pkg.contents = this.options.contents;
  120. }
  121. }
  122. if (filteredPackages.length !== this.targetGraph.size && !this.options.forceLocal) {
  123. this.logger.warn("bootstrap", "Installing local packages that do not match filters from registry");
  124. // an explicit --scope, --ignore, or --since should only symlink the targeted packages, no others
  125. this.targetGraph = new PackageGraph(filteredPackages, "allDependencies", this.options.forceLocal);
  126. // never automatically --save or modify lockfiles
  127. this.npmConfig.npmClientArgs.unshift(npmClient === "yarn" ? "--pure-lockfile" : "--no-save");
  128. // never attempt `npm ci`, it would always fail
  129. if (this.npmConfig.subCommand === "ci") {
  130. this.npmConfig.subCommand = "install";
  131. }
  132. }
  133. });
  134. chain = chain.then(() => {
  135. if (npmClient === "yarn" && !mutex) {
  136. return getPort({ port: 42424, host: "0.0.0.0" }).then((port) => {
  137. this.npmConfig.mutex = `network:${port}`;
  138. this.logger.silly("npmConfig", this.npmConfig);
  139. });
  140. }
  141. this.logger.silly("npmConfig", this.npmConfig);
  142. });
  143. return chain;
  144. }
  145. execute() {
  146. if (this.options.useWorkspaces || this.rootHasLocalFileDependencies()) {
  147. return this.installRootPackageOnly();
  148. }
  149. const filteredLength = this.filteredPackages.length;
  150. const packageCountLabel = `${filteredLength} package${filteredLength > 1 ? "s" : ""}`;
  151. const scriptsEnabled = this.options.ignoreScripts !== true;
  152. // root install does not need progress bar
  153. this.enableProgressBar();
  154. this.logger.info("", `Bootstrapping ${packageCountLabel}`);
  155. // conditional task queue
  156. const tasks = [];
  157. if (scriptsEnabled) {
  158. tasks.push(() => this.runLifecycleInPackages("preinstall"));
  159. }
  160. tasks.push(
  161. () => this.getDependenciesToInstall(),
  162. (result) => this.installExternalDependencies(result),
  163. () => this.symlinkPackages()
  164. );
  165. if (scriptsEnabled) {
  166. tasks.push(
  167. () => this.runLifecycleInPackages("install"),
  168. () => this.runLifecycleInPackages("postinstall")
  169. );
  170. if (!this.options.ignorePrepublish) {
  171. tasks.push(() => this.runLifecycleInPackages("prepublish"));
  172. }
  173. // "run on local npm install without any arguments", AFTER prepublish
  174. tasks.push(() => this.runLifecycleInPackages("prepare"));
  175. }
  176. return pWaterfall(tasks).then(() => {
  177. this.logger.success("", `Bootstrapped ${packageCountLabel}`);
  178. });
  179. }
  180. installRootPackageOnly() {
  181. this.logger.info("bootstrap", "root only");
  182. // don't hide yarn or npm output
  183. this.npmConfig.stdio = "inherit";
  184. return npmInstall(this.project.manifest, this.npmConfig);
  185. }
  186. /**
  187. * If the root manifest has local dependencies with `file:` specifiers,
  188. * all the complicated bootstrap logic should be skipped in favor of
  189. * npm5's package-locked auto-hoisting.
  190. * @returns {Boolean}
  191. */
  192. rootHasLocalFileDependencies() {
  193. const rootDependencies = Object.assign({}, this.project.manifest.dependencies);
  194. return Object.keys(rootDependencies).some(
  195. (name) =>
  196. this.targetGraph.has(name) &&
  197. npa.resolve(name, rootDependencies[name], this.project.rootPath).type === "directory"
  198. );
  199. }
  200. runLifecycleInPackages(stage) {
  201. this.logger.verbose("lifecycle", stage);
  202. if (!this.filteredPackages.length) {
  203. return;
  204. }
  205. const tracker = this.logger.newItem(stage);
  206. const mapPackageWithScript = (pkg) =>
  207. this.runPackageLifecycle(pkg, stage).then(() => {
  208. tracker.completeWork(1);
  209. });
  210. tracker.addWork(this.filteredPackages.length);
  211. const runner = this.toposort
  212. ? runTopologically(this.filteredPackages, mapPackageWithScript, {
  213. concurrency: this.concurrency,
  214. rejectCycles: this.options.rejectCycles,
  215. })
  216. : pMap(this.filteredPackages, mapPackageWithScript, { concurrency: this.concurrency });
  217. return runner.finally(() => tracker.finish());
  218. }
  219. hoistedDirectory(dependency) {
  220. return path.join(this.project.rootPath, "node_modules", dependency);
  221. }
  222. hoistedPackageJson(dependency) {
  223. try {
  224. // eslint-disable-next-line import/no-dynamic-require, global-require
  225. return require(path.join(this.hoistedDirectory(dependency), "package.json"));
  226. } catch (e) {
  227. // Pass.
  228. return {};
  229. }
  230. }
  231. /**
  232. * Return a object of root and leaf dependencies to install
  233. * @returns {Object}
  234. */
  235. getDependenciesToInstall() {
  236. // Configuration for what packages to hoist may be in lerna.json or it may
  237. // come in as command line options.
  238. const rootPkg = this.project.manifest;
  239. // This will contain entries for each hoistable dependency.
  240. const rootSet = new Set();
  241. // This will map packages to lists of unhoistable dependencies
  242. const leaves = new Map();
  243. /**
  244. * Map of dependencies to install
  245. *
  246. * Map {
  247. * "<externalName>": Map {
  248. * "<versionRange>": Set { "<dependent1>", "<dependent2>", ... }
  249. * }
  250. * }
  251. *
  252. * Example:
  253. *
  254. * Map {
  255. * "react": Map {
  256. * "15.x": Set { "my-component1", "my-component2", "my-component3" },
  257. * "^0.14.0": Set { "my-component4" },
  258. * }
  259. * }
  260. */
  261. const depsToInstall = new Map();
  262. const filteredNodes = new Map(
  263. this.filteredPackages.map((pkg) => [pkg.name, this.targetGraph.get(pkg.name)])
  264. );
  265. // collect root dependency versions
  266. const mergedRootDeps = Object.assign(
  267. {},
  268. rootPkg.devDependencies,
  269. rootPkg.optionalDependencies,
  270. rootPkg.dependencies
  271. );
  272. const rootExternalVersions = new Map(
  273. Object.keys(mergedRootDeps).map((externalName) => [externalName, mergedRootDeps[externalName]])
  274. );
  275. // seed the root dependencies
  276. rootExternalVersions.forEach((version, externalName) => {
  277. const externalDependents = new Set();
  278. const record = new Map();
  279. record.set(version, externalDependents);
  280. depsToInstall.set(externalName, record);
  281. });
  282. // build a map of external dependencies to install
  283. for (const [leafName, leafNode] of filteredNodes) {
  284. for (const [externalName, resolved] of leafNode.externalDependencies) {
  285. // rawSpec is something like "^1.2.3"
  286. const version = resolved.rawSpec;
  287. const record =
  288. depsToInstall.get(externalName) || depsToInstall.set(externalName, new Map()).get(externalName);
  289. const externalDependents = record.get(version) || record.set(version, new Set()).get(version);
  290. externalDependents.add(leafName);
  291. }
  292. }
  293. const rootActions = [];
  294. const leafActions = [];
  295. // We don't want to exit on the first hoist issue, but log them all and then exit
  296. let strictExitOnWarning = false;
  297. // determine where each dependency will be installed
  298. for (const [externalName, externalDependents] of depsToInstall) {
  299. let rootVersion;
  300. if (this.hoisting && isHoistedPackage(externalName, this.hoisting)) {
  301. const commonVersion = Array.from(externalDependents.keys()).reduce((a, b) =>
  302. externalDependents.get(a).size > externalDependents.get(b).size ? a : b
  303. );
  304. // Get the version required by the repo root (if any).
  305. // If the root doesn't have a dependency on this package then we'll
  306. // install the most common dependency there.
  307. rootVersion = rootExternalVersions.get(externalName) || commonVersion;
  308. if (rootVersion !== commonVersion) {
  309. this.logger.warn(
  310. "EHOIST_ROOT_VERSION",
  311. `The repository root depends on ${externalName}@${rootVersion}, ` +
  312. `which differs from the more common ${externalName}@${commonVersion}.`
  313. );
  314. if (this.options.strict) {
  315. strictExitOnWarning = true;
  316. }
  317. }
  318. const dependents = Array.from(externalDependents.get(rootVersion)).map(
  319. (leafName) => this.targetGraph.get(leafName).pkg
  320. );
  321. // remove collection so leaves don't repeat it
  322. externalDependents.delete(rootVersion);
  323. // Install the best version we can in the repo root.
  324. // Even if it's already installed there we still need to make sure any
  325. // binaries are linked to the packages that depend on them.
  326. rootActions.push(() =>
  327. hasDependencyInstalled(rootPkg, externalName, rootVersion).then((isSatisfied) => {
  328. rootSet.add({
  329. name: externalName,
  330. dependents,
  331. dependency: `${externalName}@${rootVersion}`,
  332. isSatisfied,
  333. });
  334. })
  335. );
  336. }
  337. // Add less common versions to package installs.
  338. for (const [leafVersion, leafDependents] of externalDependents) {
  339. for (const leafName of leafDependents) {
  340. if (rootVersion) {
  341. this.logger.warn(
  342. "EHOIST_PKG_VERSION",
  343. `"${leafName}" package depends on ${externalName}@${leafVersion}, ` +
  344. `which differs from the hoisted ${externalName}@${rootVersion}.`
  345. );
  346. if (this.options.strict) {
  347. strictExitOnWarning = true;
  348. }
  349. }
  350. const leafNode = this.targetGraph.get(leafName);
  351. const leafRecord = leaves.get(leafNode) || leaves.set(leafNode, new Set()).get(leafNode);
  352. // only install dependency if it's not already installed
  353. leafActions.push(() =>
  354. hasDependencyInstalled(leafNode.pkg, externalName, leafVersion).then((isSatisfied) => {
  355. leafRecord.add({
  356. dependency: `${externalName}@${leafVersion}`,
  357. isSatisfied,
  358. });
  359. })
  360. );
  361. }
  362. }
  363. }
  364. if (this.options.strict && strictExitOnWarning) {
  365. throw new ValidationError(
  366. "EHOISTSTRICT",
  367. "Package version inconsistencies found while hoisting. Fix the above warnings and retry."
  368. );
  369. }
  370. return pMapSeries([...rootActions, ...leafActions], (el) => el()).then(() => {
  371. this.logger.silly("root dependencies", JSON.stringify(rootSet, null, 2));
  372. this.logger.silly("leaf dependencies", JSON.stringify(leaves, null, 2));
  373. return { rootSet, leaves };
  374. });
  375. }
  376. /**
  377. * Install external dependencies for all packages
  378. * @returns {Promise}
  379. */
  380. installExternalDependencies({ leaves, rootSet }) {
  381. const tracker = this.logger.newItem("install dependencies");
  382. const rootPkg = this.project.manifest;
  383. const actions = [];
  384. // Start root install first, if any, since it's likely to take the longest.
  385. if (rootSet.size) {
  386. // If we have anything to install in the root then we'll install
  387. // _everything_ that needs to go there. This is important for
  388. // consistent behavior across npm clients.
  389. const root = Array.from(rootSet);
  390. actions.push(() => {
  391. const depsToInstallInRoot = root.some(({ isSatisfied }) => !isSatisfied)
  392. ? root.map(({ dependency }) => dependency)
  393. : [];
  394. if (depsToInstallInRoot.length) {
  395. tracker.info("hoist", "Installing hoisted dependencies into root");
  396. }
  397. const promise = npmInstallDependencies(rootPkg, depsToInstallInRoot, this.npmConfig);
  398. return pulseTillDone(promise)
  399. .then(() =>
  400. // Link binaries into dependent packages so npm scripts will
  401. // have access to them.
  402. pMapSeries(root, ({ name, dependents }) => {
  403. const { bin } = this.hoistedPackageJson(name);
  404. if (bin) {
  405. return pMap(dependents, (pkg) => {
  406. const src = this.hoistedDirectory(name);
  407. return symlinkBinary(src, pkg);
  408. });
  409. }
  410. })
  411. )
  412. .then(() => {
  413. tracker.info("hoist", "Finished bootstrapping root");
  414. tracker.completeWork(1);
  415. });
  416. });
  417. // Remove any hoisted dependencies that may have previously been
  418. // installed in package directories.
  419. actions.push(() => {
  420. // Compute the list of candidate directories synchronously
  421. const candidates = root
  422. .filter((dep) => dep.dependents.length)
  423. .reduce((list, { name, dependents }) => {
  424. const dirs = dependents
  425. .filter((pkg) => pkg.nodeModulesLocation !== rootPkg.nodeModulesLocation)
  426. .map((pkg) => path.join(pkg.nodeModulesLocation, name));
  427. return list.concat(dirs);
  428. }, []);
  429. if (!candidates.length) {
  430. tracker.verbose("hoist", "nothing to prune");
  431. tracker.completeWork(1); // the action "work"
  432. return;
  433. }
  434. tracker.info("hoist", "Pruning hoisted dependencies");
  435. tracker.silly("prune", candidates);
  436. tracker.addWork(candidates.length);
  437. return pMap(
  438. candidates,
  439. (dirPath) =>
  440. pulseTillDone(rimrafDir(dirPath)).then(() => {
  441. tracker.verbose("prune", dirPath);
  442. tracker.completeWork(1);
  443. }),
  444. // these are mostly no-ops in the vast majority of cases
  445. { concurrency: this.concurrency }
  446. ).then(() => {
  447. tracker.info("hoist", "Finished pruning hoisted dependencies");
  448. tracker.completeWork(1); // the action "work"
  449. });
  450. });
  451. }
  452. const leafNpmConfig = Object.assign({}, this.npmConfig, {
  453. // Use `npm install --global-style` for leaves when hoisting is enabled
  454. npmGlobalStyle: !!this.options.hoist,
  455. });
  456. // Install anything that needs to go into the leaves.
  457. leaves.forEach((leafRecord, leafNode) => {
  458. const deps = Array.from(leafRecord);
  459. // If we have any unsatisfied deps then we need to install everything.
  460. // This is important for consistent behavior across npm clients.
  461. if (deps.some(({ isSatisfied }) => !isSatisfied)) {
  462. actions.push(() => {
  463. const dependencies = deps.map(({ dependency }) => dependency);
  464. const promise = npmInstallDependencies(leafNode.pkg, dependencies, leafNpmConfig);
  465. return pulseTillDone(promise).then(() => {
  466. tracker.verbose("installed leaf", leafNode.name);
  467. tracker.completeWork(1);
  468. });
  469. });
  470. }
  471. });
  472. if (actions.length) {
  473. tracker.info("", "Installing external dependencies");
  474. tracker.verbose("actions", "%d actions, concurrency %d", actions.length, this.concurrency);
  475. tracker.addWork(actions.length);
  476. }
  477. return pMap(actions, (act) => act(), { concurrency: this.concurrency }).finally(() => tracker.finish());
  478. }
  479. /**
  480. * Symlink all packages to the packages/node_modules directory
  481. * Symlink package binaries to dependent packages' node_modules/.bin directory
  482. * @returns {Promise}
  483. */
  484. symlinkPackages() {
  485. return symlinkDependencies(
  486. this.filteredPackages,
  487. this.targetGraph,
  488. this.logger.newItem("bootstrap dependencies")
  489. );
  490. }
  491. }
  492. module.exports.BootstrapCommand = BootstrapCommand;