npm/cli

[BUG] Installing a package globally also installs its peerDependencies even if they are already available globally

ericmorand opened this issue · 8 comments

Is there an existing issue for this?

  • I have searched the existing issues

This issue exists in the latest npm version

  • I am using the latest npm

Current Behavior

When installing a package globally, its peer dependencies are also always installed in its own node_modules directory even when these peer dependencies are already installed globally.

Expected Behavior

I expect that the peer dependencies available globally are not installed globally twice when they are the same version, just like npm behaves when installing locally.

Having multiple versions of the same global dependency creates some issues with typedoc (probably among some other tools) where typedoc ends up installed twice:

  • globally
  • under each theme node_modules directory

Obviously, when executing the typedoc binary to build a documentation, the executed instance of typedoc is not the one that is required by the theme, leading to the infamous error:

$ typedoc src/lib.ts --out public --plugin typedoc-material-theme
[info] Loaded plugin typedoc-material-theme
[warning] TypeDoc has been loaded multiple times. This is commonly caused by plugins which have their own installation of TypeDoc. The loaded paths are:
        /home/ericmorand/.nvm/versions/node/v18.19.0/lib/node_modules/typedoc
        /home/ericmorand/.nvm/versions/node/v18.19.0/lib/node_modules/typedoc-material-theme/node_modules/typedoc

To confirm that there is no need to install "locally" peer dependencies that are already installed globally, I deleted the /home/ericmorand/.nvm/versions/node/v18.19.0/lib/node_modules/typedoc-material-theme/node_modules/typedoc directory, rerun the command and everything went fine.

Which is to be expected: the theme eventually requires typedoc which is then resolved to the package installed one level above the theme, as per the resolution rule of node:

https://nodejs.org/api/modules.html#loading-from-node_modules-folders

If the module identifier passed to require() is not a core module, and does not begin with '/', '../', or './', then Node.js starts at the directory of the current module, and adds /node_modules, and attempts to load the module from that location. Node.js will not append node_modules to a path already ending in node_modules.

If it is not found there, then it moves to the parent directory, and so on, until the root of the file system is reached.

Workarounds:

  • Removing the theme node_modules/typedoc directory which may be challenging in a build system where we don't necessarily control where global packages are located
  • Installing typedoc and the themes locally which makes npm respect the rule of not installing twice the same dependency - but it means all our projects need to be reworked because none of them include typedoc or its themes: our build system is responsible for the documentation building and it is where we enforce some common typedoc themes and settings for all our products, for consistency and convenience
  • Installing the theme using the --legacy-peer-deps flag which is the best solution we found - it is simple and it works :party

Steps To Reproduce

  1. Install npm 10
  2. Install typedoc globally npm i -g typedoc
  3. Install a typedoc theme globally npm i -g typedoc-material-theme
  4. Look into the global node_modules directory: you'll find typedoc installed under both the global node_modules directory and under node_modules/typedoc-material-theme/node_modules
  5. Also note how they are exactly the same version

Environment

  • npm: 10.2.4
  • Node.js: 18.19.0
  • OS Name: Ubuntu
  • npm config:
; "user" config from /home/ericmorand/.npmrc

//registry.npmjs.org/:_authToken = (protected) 

; node bin location = /home/ericmorand/.nvm/versions/node/v18.19.0/bin/node
; node version = v18.19.0
; npm local prefix = /home/ericmorand/Projects/twing
; npm version = 10.2.3
; cwd = /home/ericmorand/Projects/twing
; HOME = /home/ericmorand
; Run `npm config ls -l` to show all defaults.
ljharb commented

This is expected since global modules can’t be required.

@ljharb they can and they are. The proof in the description of the issue: typedoc themes require typedoc successfully when both are installed globally.

You are confusing non-global modules - that can't require global modules - and global modules that can.


I want to make clear that node.js has no specification at all that prevents global modules from being required. The specification of the require algorithm organically makes global modules likely to not be requirable, but it is because they are likely to not be located in a directory that is among the parents of the module that calls require. That's why the NODE_PATH variable exists: to add some arbitrary locations to be considered by the require algorithm.

So, it is totally wrong to say "global modules can’t be required".

That said, to keep it simple, the rest of this comment assumes that NODE_PATH was not configured to include the global modules folder and that by "non global" module we mean "a module that is not installed in a directory that is a child of the global modules one".


Node.js walks the filesystem up to the root when looking for a module to load. So, yes, you are right, global modules are not considered when a module is required from a non global package.

/home/me/my-local-package/node_modules
/home/me/node_modules
/home/node_modules
/node_modules

In this case global modules are not considered by the resolution algorithm.

But global modules are installed in the global modules directory. So walking up the filesystem eventually ends up in the root of the global module directory, allowing Node.js to actually find the required module.

/home/me/global-modules/a-global-package/node_modules
/home/me/global-modules/node_modules
/home/me/node_modules
/home/node_modules
/node_modules

Here the global modules are considered by Node.js. So:

Global modules can require global modules

ljharb commented

If true that’s still surprising to me, but fair enough.

This is intentional. There is no overarching package.json that manages the global package space. It is not a package in and of itself. Notice how there is NOTHING in this directory except a node_modules folder

$ ls (npm prefix -g)/lib
node_modules/

Therefore npm can't treat it like a package, specifically it can't know about other packages that are installed alongside each other. Each package installed globally is done as a discrete unit. Allowing the fact that global package could require each other to be relied on would mean that npm could remove a peer of an existing package if asked.

TLDR the global package namespace is not a single package namespace. Each globally installed package is treated as its own discrete installation and packages that happened to rely on cross-requiring across the global namespace may experience unexpected results.

There is also the fact that when one is installing into the global namespace there is no way to know what other packages may be requiring into it. The current npm contract is that npm install will always leave your current project's tree in a fully reified state. Consider for instance a fresh clone of a project repo with nothing installed. If I typed something like npm install semver where semver was a package that wasn't already installed, when npm was done every dependency would also be installed in node_modules.

There is no way to guarantee that a global install could keep this project with non-global packages that require into the global namespace

@wraithgar thanks a lot. Just to be sure that I understood perfectly: peer dependencies are automatically installed only when a package is installed globally, and must be installed manually when a package is installed locally, right? Ot is it automatic in both cases?

If it is automatic in both cases, what is the point of having npm install emit a warning if a peer dependency is not already installed? If npm install installs it anyway, surely the warning is useless, what do you think?

https://docs.npmjs.com/cli/v7/configuring-npm/package-json#peerdependenciesmeta

When a user installs your package, npm will emit warnings if packages specified in peerDependencies are not already installed.

About the issue with typedoc I discussed with the team and we will have our pipeline install typedoc and its themes locally (instead of globally) after npm install. This is a trivial change and it will solve the issue for good. 🥳

ljharb commented

It's not always fully solvable by a computer (think P=NP hard); it's always best to have peer deps explicitly installed.

Certainly in general, installing things locally is always better than globally, so that's the best outcome.

The docs for peerDependencies clarify further:

In npm versions 3 through 6, peerDependencies were not automatically installed, and would raise a warning if an invalid version of the peer dependency was found in the tree. As of npm v7, peerDependencies are installed by default.

Trying to install another plugin with a conflicting requirement may cause an error if the tree cannot be resolved correctly. For this reason, make sure your plugin requirement is as broad as possible, and not to lock it down to specific patch versions.

It looks like the language in peerDependenciesMeta is describing the npm 6 behavior. A PR updating that section would be helpful.