feat: isolated global packages#10697
Conversation
Each globally installed package (or group of packages installed together)
now gets its own isolated installation directory with its own package.json,
node_modules, and lockfile. This prevents global packages from interfering
with each other through peer dependency conflicts or version resolution shifts.
- Add @pnpm/global-packages shared utilities package
- Extract createCacheKey from dlx to shared package
- pnpm add -g creates isolated installs in {pnpmHomeDir}/global/{hash}/
- pnpm remove -g removes entire installation group containing the package
- pnpm update -g re-installs in new isolated directories
- pnpm list -g scans isolated directories
- pnpm install -g (no args) is no longer supported
- pnpm link resolves packages from isolated global directories
…file - Rename global packages directory from `global` to `.global` to avoid collisions with bin shims in PNPM_HOME root - Read resolved aliases from installed package.json instead of parsing them from CLI params, removing the parseWantedDependency dependency - Replace JSON.parse(fs.readFileSync(...)) with loadJsonFileSync from load-json-file for consistency with the rest of the codebase - Use lexCompare instead of localeCompare in cacheKey.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…cleanup Hash entries in .global/ are now symlinks pointing directly to .tmp-* install dirs, removing the unnecessary intermediate directory + pkg symlink layer. Added cleanOrphanedInstallDirs() to remove .tmp-* dirs not referenced by any symlink, called from handleGlobalAdd, handleGlobalUpdate, and storePrune. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Install directories are permanent, not temporary. Renamed createTmpInstallDir to createInstallDir and removed the .tmp- prefix. Updated cleanOrphanedInstallDirs to clean any unreferenced directory. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Removed getGlobalDir from @pnpm/global-packages. The global packages directory is now resolved in the config package as global/v11 (respecting the user's --global-dir setting) and passed through as globalPkgDir to all command handlers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
createCacheKey is dlx-specific (hashes resolved package IDs), not related to global packages. Only createGlobalCacheKey (which hashes alias names) belongs in @pnpm/global-packages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
On Windows, os.devNull is '\\.\nul', which git cannot open as a config file path (fatal: unable to access '\\.\nul': Invalid argument). Git for Windows translates the literal '/dev/null' correctly via its MSYS2 layer, fixing patch-commit on Windows.
…mandOptions globalPkgDir is only needed for global operations, so it should not be required in option types used by non-global tests and callers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…kages Add a non-normalizing package.json reader to @pnpm/read-package-json and use it in scanGlobalPackages, globalAdd, and globalUpdate instead of importing load-json-file directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ackage[] Remove GlobalPackageDetail wrapper type. getGlobalPackageDetails now returns the installed packages array directly instead of spreading the info object the caller already has. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Minimize dlx.ts diff vs main (only localeCompare → lexCompare) - Remove unnecessary realpath in cleanOrphanedInstallDirs - Remove unused pnpmHomeDir from UpdateCommandOptions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pnpm link no longer resolves packages from the global store by name. Only relative or absolute paths are accepted. The --global flag is removed; use "pnpm add -g ." to register a local package's bins globally instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use scanGlobalPackages to discover all isolated global package groups and check each one for outdated dependencies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces an isolated global package installation layout, where each global install group lives in its own directory (with its own package.json, lockfile, and node_modules) to prevent cross-package dependency/peer resolution interference.
Changes:
- Adds new
@pnpm/global-packagesworkspace package to manage scanning/cleanup, hashing, and install-dir handling for global installs. - Updates global-related commands (
add -g,remove -g,update -g,list -g,outdated -g,store prune) to work with isolated global install directories. - Updates tests and changesets to reflect new global layout and breaking CLI behavior (notably
pnpm linkandpnpm install -g).
Reviewed changes
Copilot reviewed 39 out of 40 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| store/plugin-commands-store/tsconfig.json | Adds TS project reference to new global-packages utilities. |
| store/plugin-commands-store/src/storePrune.ts | Invokes orphaned global install-dir cleanup during store prune when configured. |
| store/plugin-commands-store/src/store.ts | Extends store command options to include globalPkgDir. |
| store/plugin-commands-store/package.json | Adds dependency on @pnpm/global-packages. |
| reviewing/plugin-commands-outdated/tsconfig.json | Adds TS project reference to global-packages. |
| reviewing/plugin-commands-outdated/test/index.ts | Adds tests for pnpm outdated -g using isolated global layout. |
| reviewing/plugin-commands-outdated/src/outdated.ts | Implements outdated -g by scanning isolated global install groups. |
| reviewing/plugin-commands-outdated/package.json | Adds dependency on @pnpm/global-packages. |
| reviewing/plugin-commands-listing/tsconfig.json | Adds TS project reference to global-packages. |
| reviewing/plugin-commands-listing/src/list.ts | Implements list -g by scanning isolated global install groups. |
| reviewing/plugin-commands-listing/package.json | Adds dependency on @pnpm/global-packages. |
| pnpm/test/root.ts | Updates pnpm root -g expectation to new layout path. |
| pnpm/test/link.ts | Updates link-related global prefix expectations to new layout path. |
| pnpm/test/install/global.ts | Refactors global install tests to work with isolated install directories. |
| pnpm-lock.yaml | Adds lock entries for new @pnpm/global-packages and related deps. |
| pkg-manifest/read-package-json/src/index.ts | Adds sync “raw” read helper for package.json with improved error wrapping. |
| pkg-manager/plugin-commands-installation/tsconfig.json | Adds TS refs for global-packages, link-bins, remove-bins. |
| pkg-manager/plugin-commands-installation/test/link.ts | Removes/adjusts tests tied to deprecated global link behaviors. |
| pkg-manager/plugin-commands-installation/src/update/index.ts | Routes pnpm update -g to new global update handler. |
| pkg-manager/plugin-commands-installation/src/remove.ts | Routes pnpm remove -g to new global remove handler. |
| pkg-manager/plugin-commands-installation/src/link.ts | Removes linking-by-package-name/global link mode; enforces path-only linking. |
| pkg-manager/plugin-commands-installation/src/install.ts | Rejects pnpm install -g (unless invoked internally from link). |
| pkg-manager/plugin-commands-installation/src/globalUpdate.ts | Implements global update by reinstalling into new isolated dirs and swapping hash links. |
| pkg-manager/plugin-commands-installation/src/globalRemove.ts | Implements global remove by deleting whole install groups + bins. |
| pkg-manager/plugin-commands-installation/src/globalAdd.ts | Implements global add into isolated dirs, de-duping existing installs and relinking bins. |
| pkg-manager/plugin-commands-installation/src/add.ts | Delegates global adds to handleGlobalAdd. |
| pkg-manager/plugin-commands-installation/package.json | Adds deps needed for global isolation (global-packages, link-bins, remove-bins, symlink-dir). |
| patching/plugin-commands-patching/src/patchCommit.ts | Makes git global config redirection Windows-safe by using /dev/null. |
| packages/global-packages/tsconfig.lint.json | Adds lint tsconfig for the new workspace package. |
| packages/global-packages/tsconfig.json | Adds build tsconfig and project references for the new workspace package. |
| packages/global-packages/src/scanGlobalPackages.ts | Adds scanning, lookup, orphan cleanup, and bin discovery for isolated global installs. |
| packages/global-packages/src/index.ts | Exports new global-packages utilities. |
| packages/global-packages/src/globalPackageDir.ts | Adds helpers for hash links, resolving install dirs, and creating install dirs. |
| packages/global-packages/src/cacheKey.ts | Adds cache key generator for global install groups. |
| packages/global-packages/package.json | Introduces the new @pnpm/global-packages workspace package. |
| exec/plugin-commands-script-runners/src/dlx.ts | Uses lexCompare for stable cache key sorting. |
| config/config/src/parseAuthInfo.ts | Removes an unnecessary eslint suppression. |
| config/config/src/index.ts | Sets globalPkgDir layout to .../global/v11 for isolated global installs. |
| .changeset/link-breaking-changes.md | Documents breaking changes to pnpm link. |
| .changeset/isolated-global-packages.md | Documents isolated global packages behavior and related breaking changes. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Add NO_GLOBAL_BIN_DIR validation to `pnpm remove -g` and `pnpm update -g` - Add GLOBAL_LAYOUT_VERSION constant, use it in config and tests - Use symlink-dir in outdated test for Windows compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 40 out of 41 changed files in this pull request and generated 10 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Fix shared mutation in getGlobalPackageDetails and getInstalledBinNames - Sort pnpm list -g output using lexCompare for deterministic results - Add isSubdir safety checks before deleting install dirs - Remove stale bins in globalUpdate before swapping symlink - Remove stale pnpm link bullet from changeset - Use is-subdir package instead of custom isSubdir function Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 40 out of 41 changed files in this pull request and generated 3 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)
pkg-manager/plugin-commands-installation/src/link.ts:131
- When
pnpm linkis run with no params, the error message just says "You must provide a parameter". Since this PR removes global-name linking and narrows link usage, consider making the error point to the supported forms (e.g.pnpm link <dir>) and/or the replacement for global registration (pnpm add -g .).
if ((params == null) || (params.length === 0)) {
const cwd = process.cwd()
if (path.relative(linkOpts.dir, cwd) === '') {
throw new PnpmError('LINK_BAD_PARAMS', 'You must provide a parameter')
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
When installing or updating a global package, check if its binaries would conflict with binaries from other globally installed packages. If a conflict is found, refuse the installation and suggest removing the conflicting package first. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a test verifying that old bins are removed when re-adding a global package whose bin names have changed. Also skip recently-created dirs in cleanOrphanedInstallDirs to avoid racing with concurrent installs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 41 out of 42 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)
pkg-manager/plugin-commands-installation/src/link.ts:130
- The PR declares
pnpm link --globalremoved, buthandler()doesn’t explicitly reject global mode (opts.global/opts.cliOptions?.global). As-is, runningpnpm link -gmay still proceed (and_calledFromLinkbypasses the newinstall -gguard). Add an explicit error for global link usage with guidance to usepnpm add -g ..
// pnpm link (no params, no --global)
if ((params == null) || (params.length === 0)) {
const cwd = process.cwd()
if (path.relative(linkOpts.dir, cwd) === '') {
throw new PnpmError('LINK_BAD_PARAMS', 'You must provide a parameter')
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Use safeReadPackageJsonFromDir for missing/corrupted package.json Use Math.max(birthtimeMs, ctimeMs) for orphaned install dir garbage collection
Remove `pnpm link` (no arguments), which required `-C` to specify the target project. Use `pnpm link <dir>` with an explicit path instead. Also resolve globalDir in cleanOrphanedInstallDirs to fix path comparison with realpaths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Verify that removing one package from a globally installed group deletes the entire group's install directory, hash symlink, and all bin shims. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 41 out of 42 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Use @pnpm/matcher for pnpm ls -g filtering so glob patterns like babel-* work consistently with non-global listing. Remove e2e tests for pnpm link --global and pnpm link (no args) which are no longer supported. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move global-related command handlers out of plugin-commands-installation into a new @pnpm/global.commands package under global/commands/. Also rename @pnpm/global-packages to @pnpm/global.packages and move it to global/packages/ to establish a global/ domain directory. The key architectural change is replacing the 450-line installDeps() dependency with a focused ~30-line installGlobalPackages() function that directly calls mutateModulesInSingleProject from @pnpm/core.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 51 out of 52 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (opts.global && opts.globalPkgDir) { | ||
| return listGlobalPackages(opts.globalPkgDir, params) | ||
| } |
There was a problem hiding this comment.
The global fast-path (if (opts.global && opts.globalPkgDir) return listGlobalPackages(...)) bypasses the existing list rendering logic and ignores supported list output options like --json, --parseable, --long, --depth, and --lockfile-only that are part of this command’s API.
Either (a) adapt global listing to reuse the existing list/listForPackages rendering (so flags behave consistently), or (b) explicitly reject/handle incompatible flags in global mode and document it as a breaking change.
| const [pkgPaths, pkgNames] = partition((inp) => isFilespec.test(inp), params) | ||
|
|
||
| pkgNames.forEach((pkgName) => pkgPaths.push(path.join(opts.globalPkgDir, 'node_modules', pkgName))) | ||
| if (pkgNames.length > 0) { | ||
| throw new PnpmError('LINK_BAD_PARAMS', | ||
| `Cannot link by package name. Use a relative or absolute path instead, e.g. "pnpm link ./${pkgNames[0]}"`) | ||
| } |
There was a problem hiding this comment.
partition((inp) => isFilespec.test(inp), params) is too strict for detecting directory paths. A relative directory like packages/foo (no leading ./) is a valid path but will be classified as a “package name” and rejected. This makes pnpm link packages/foo fail even though the command now requires linking by path.
Consider treating an argument as a path if it resolves to an existing filesystem entry (e.g. fs.existsSync(path.resolve(inp))), or at least if it contains a path separator, rather than requiring ./, ../, /, ~/, or a drive prefix.
- Make `pnpm approve-builds -g` throw an error since it doesn't work with isolated global packages (no single .modules.yaml to scan) - After `pnpm add -g` / `pnpm update -g`, if packages have ignored builds, reuse the `approve-builds` interactive flow (enquirer multiselect + rebuild) instead of re-installing from scratch - `installGlobalPackages` now returns `ignoredBuilds` from `mutateModulesInSingleProject` to avoid reading .modules.yaml
With isolated global packages, the global package directory itself is the root — there is no top-level node_modules directory. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…-commands The cycle was: global/commands → exec/build-commands → plugin-commands-installation → global/commands. The exec/build-commands → plugin-commands-installation reference was only needed for tests (import of install.handler). Replace the programmatic install.handler() calls with running the pnpm CLI via execa, matching the pattern used in plugin-commands-store tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 56 out of 57 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Restores `--json` / `--parseable` / `--long` support on `pnpm -g ls` and tightens `--depth>0` semantics around isolated global installs. Closes #11440. - **`--json` / `--parseable` (the regression):** aggregate global packages from all isolated install dirs into a single synthesized `PackageDependencyHierarchy` and dispatch to the existing `renderJson` / `renderParseable` / `renderTree`. Output shape matches pnpm 10 (`result[0].dependencies[name].version`), so tools like `npm-check-updates` work again. - **`--depth>0`:** the v11 architecture installs each global package into its own isolated dir with its own lockfile, so merging transitive trees across installs would be incoherent. New behavior: - One global install dir total → fast-path delegate to the regular `list` flow with `params` unchanged, so `listForPackages` can match top-level *or* transitive packages. - Multiple installs, params narrow to one install dir (top-level alias match) → drop the params and render that install dir's full tree. - Multiple installs, params don't narrow → throw `ERR_PNPM_GLOBAL_LS_DEPTH_NOT_SUPPORTED` with a message asking the user to filter to a single global package or omit `--depth`. The regression was introduced by the isolated global packages refactor (#10697), which added a custom `listGlobalPackages` shortcut that always returned plain text and ignored format flags.
Restores `--json` / `--parseable` / `--long` support on `pnpm -g ls` and tightens `--depth>0` semantics around isolated global installs. Closes #11440. - **`--json` / `--parseable` (the regression):** aggregate global packages from all isolated install dirs into a single synthesized `PackageDependencyHierarchy` and dispatch to the existing `renderJson` / `renderParseable` / `renderTree`. Output shape matches pnpm 10 (`result[0].dependencies[name].version`), so tools like `npm-check-updates` work again. - **`--depth>0`:** the v11 architecture installs each global package into its own isolated dir with its own lockfile, so merging transitive trees across installs would be incoherent. New behavior: - One global install dir total → fast-path delegate to the regular `list` flow with `params` unchanged, so `listForPackages` can match top-level *or* transitive packages. - Multiple installs, params narrow to one install dir (top-level alias match) → drop the params and render that install dir's full tree. - Multiple installs, params don't narrow → throw `ERR_PNPM_GLOBAL_LS_DEPTH_NOT_SUPPORTED` with a message asking the user to filter to a single global package or omit `--depth`. The regression was introduced by the isolated global packages refactor (#10697), which added a custom `listGlobalPackages` shortcut that always returned plain text and ignored format flags.
TLDR: Global packages in pnpm v10 are annoying and slow because they all are installed to a single global package. Instead, we will now use a system that is similar to the one used by "pnpm dlx" (aka "pnpx").
Each globally installed package (or group of packages installed together) now gets its own isolated installation directory with its own
package.json,node_modules, and lockfile. This prevents global packages from interfering with each other through peer dependency conflicts or version resolution shifts.Changes
@pnpm/global-packagesshared utilities package for scanning, hashing, and managing isolated global installspnpm add -gcreates isolated installs in{pnpmHomeDir}/global/v11/{hash}/pnpm remove -gremoves the entire installation group containing the packagepnpm update -gre-installs into new isolated directories and swaps symlinkspnpm list -gscans isolated directories to show installed global packagespnpm outdated -gchecks each isolated installation for outdated dependenciespnpm store prunecleans up orphaned global installation directoriesBreaking changes
pnpm install -g(no args) is no longer supported — usepnpm add -g <pkg>pnpm link <pkg-name>no longer resolves packages from the global store — only relative or absolute paths are acceptedpnpm link --globalis removed — usepnpm add -g .to register a local package's bins globally