From fd511e46763b7e0a3ba03bbe44d630786d826e18 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Sun, 1 Mar 2026 15:49:18 +0100 Subject: [PATCH] feat: isolated global packages (#10697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 - Add `@pnpm/global-packages` shared utilities package for scanning, hashing, and managing isolated global installs - `pnpm add -g` creates isolated installs in `{pnpmHomeDir}/global/v11/{hash}/` - `pnpm remove -g` removes the entire installation group containing the package - `pnpm update -g` re-installs into new isolated directories and swaps symlinks - `pnpm list -g` scans isolated directories to show installed global packages - `pnpm outdated -g` checks each isolated installation for outdated dependencies - `pnpm store prune` cleans up orphaned global installation directories ## Breaking changes - `pnpm install -g` (no args) is no longer supported — use `pnpm add -g ` - `pnpm link ` no longer resolves packages from the global store — only relative or absolute paths are accepted - `pnpm link --global` is removed — use `pnpm add -g .` to register a local package's bins globally --- .changeset/isolated-global-packages.md | 17 ++ .changeset/link-breaking-changes.md | 10 + config/config/src/index.ts | 4 +- config/config/src/parseAuthInfo.ts | 2 +- cspell.json | 1 + exec/build-commands/package.json | 3 +- exec/build-commands/src/approveBuilds.ts | 18 +- .../build-commands/test/approveBuilds.test.ts | 113 +++++---- exec/build-commands/tsconfig.json | 6 +- .../plugin-commands-script-runners/src/dlx.ts | 4 +- global/commands/README.md | 151 ++++++++++++ global/commands/package.json | 60 +++++ .../commands/src/checkGlobalBinConflicts.ts | 59 +++++ global/commands/src/globalAdd.ts | 170 +++++++++++++ global/commands/src/globalRemove.ts | 44 ++++ global/commands/src/globalUpdate.ts | 156 ++++++++++++ global/commands/src/index.ts | 6 + global/commands/src/installGlobalPackages.ts | 63 +++++ global/commands/src/listGlobalPackages.ts | 24 ++ global/commands/src/readInstalledPackages.ts | 15 ++ global/commands/tsconfig.json | 52 ++++ global/commands/tsconfig.lint.json | 8 + global/packages/package.json | 49 ++++ global/packages/src/cacheKey.ts | 12 + global/packages/src/globalPackageDir.ts | 28 +++ global/packages/src/index.ts | 15 ++ global/packages/src/scanGlobalPackages.ts | 127 ++++++++++ global/packages/tsconfig.json | 25 ++ global/packages/tsconfig.lint.json | 8 + packages/constants/src/index.ts | 1 + .../plugin-commands-installation/package.json | 1 + .../plugin-commands-installation/src/add.ts | 2 + .../src/install.ts | 9 +- .../plugin-commands-installation/src/link.ts | 34 +-- .../src/remove.ts | 11 +- .../src/update/index.ts | 10 +- .../plugin-commands-installation/test/link.ts | 121 +-------- .../tsconfig.json | 3 + pkg-manifest/read-package-json/src/index.ts | 10 + pnpm-lock.yaml | 101 +++++++- pnpm-workspace.yaml | 1 + pnpm/src/cmd/root.ts | 6 +- pnpm/test/install/global.ts | 229 +++++++++++++++--- pnpm/test/link.ts | 58 ----- pnpm/test/root.ts | 4 +- .../plugin-commands-listing/package.json | 1 + reviewing/plugin-commands-listing/src/list.ts | 6 +- .../plugin-commands-listing/tsconfig.json | 3 + .../plugin-commands-outdated/package.json | 4 +- .../plugin-commands-outdated/src/outdated.ts | 33 ++- .../plugin-commands-outdated/test/index.ts | 45 ++++ .../plugin-commands-outdated/tsconfig.json | 3 + store/plugin-commands-store/package.json | 1 + store/plugin-commands-store/src/store.ts | 2 +- store/plugin-commands-store/src/storePrune.ts | 6 + store/plugin-commands-store/tsconfig.json | 3 + 56 files changed, 1630 insertions(+), 328 deletions(-) create mode 100644 .changeset/isolated-global-packages.md create mode 100644 .changeset/link-breaking-changes.md create mode 100644 global/commands/README.md create mode 100644 global/commands/package.json create mode 100644 global/commands/src/checkGlobalBinConflicts.ts create mode 100644 global/commands/src/globalAdd.ts create mode 100644 global/commands/src/globalRemove.ts create mode 100644 global/commands/src/globalUpdate.ts create mode 100644 global/commands/src/index.ts create mode 100644 global/commands/src/installGlobalPackages.ts create mode 100644 global/commands/src/listGlobalPackages.ts create mode 100644 global/commands/src/readInstalledPackages.ts create mode 100644 global/commands/tsconfig.json create mode 100644 global/commands/tsconfig.lint.json create mode 100644 global/packages/package.json create mode 100644 global/packages/src/cacheKey.ts create mode 100644 global/packages/src/globalPackageDir.ts create mode 100644 global/packages/src/index.ts create mode 100644 global/packages/src/scanGlobalPackages.ts create mode 100644 global/packages/tsconfig.json create mode 100644 global/packages/tsconfig.lint.json delete mode 100644 pnpm/test/link.ts diff --git a/.changeset/isolated-global-packages.md b/.changeset/isolated-global-packages.md new file mode 100644 index 0000000000..781e8ee5f1 --- /dev/null +++ b/.changeset/isolated-global-packages.md @@ -0,0 +1,17 @@ +--- +"@pnpm/global.packages": minor +"@pnpm/global.commands": minor +"@pnpm/plugin-commands-installation": major +"@pnpm/plugin-commands-listing": minor +"@pnpm/plugin-commands-script-runners": minor +"pnpm": major +--- + +Isolated global packages. 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, hoisting changes, or version resolution shifts. + +Key changes: +- `pnpm add -g ` creates an isolated installation in `{pnpmHomeDir}/global/v11/{hash}/` +- `pnpm remove -g ` removes the entire installation group containing the package +- `pnpm update -g [pkg]` re-installs packages in new isolated directories +- `pnpm list -g` scans isolated directories to show all installed global packages +- `pnpm install -g` (no args) is no longer supported; use `pnpm add -g ` instead diff --git a/.changeset/link-breaking-changes.md b/.changeset/link-breaking-changes.md new file mode 100644 index 0000000000..845b471368 --- /dev/null +++ b/.changeset/link-breaking-changes.md @@ -0,0 +1,10 @@ +--- +"pnpm": major +"@pnpm/plugin-commands-installation": major +--- + +Breaking changes to `pnpm link`: + +- `pnpm link ` no longer resolves packages from the global store. Only relative or absolute paths are accepted. For example, use `pnpm link ./foo` instead of `pnpm link foo`. +- `pnpm link --global` is removed. Use `pnpm add -g .` to register a local package's bins globally. +- `pnpm link` (no arguments) is removed. Use `pnpm link ` with an explicit path instead. diff --git a/config/config/src/index.ts b/config/config/src/index.ts index af501915ee..147267f208 100644 --- a/config/config/src/index.ts +++ b/config/config/src/index.ts @@ -4,7 +4,7 @@ import os from 'os' import { isCI } from 'ci-info' import { omit } from 'ramda' import { getCatalogsFromWorkspaceManifest } from '@pnpm/catalogs.config' -import { GLOBAL_CONFIG_YAML_FILENAME, LAYOUT_VERSION } from '@pnpm/constants' +import { GLOBAL_CONFIG_YAML_FILENAME, GLOBAL_LAYOUT_VERSION } from '@pnpm/constants' import { PnpmError } from '@pnpm/error' import { isCamelCase } from '@pnpm/naming-cases' import loadNpmConf from '@pnpm/npm-conf' @@ -333,7 +333,7 @@ export async function getConfig (opts: { } else { globalDirRoot = path.join(pnpmConfig.pnpmHomeDir, 'global') } - pnpmConfig.globalPkgDir = path.join(globalDirRoot, LAYOUT_VERSION.toString()) + pnpmConfig.globalPkgDir = path.join(globalDirRoot, GLOBAL_LAYOUT_VERSION) if (cliOptions['global']) { delete pnpmConfig.workspaceDir pnpmConfig.dir = pnpmConfig.globalPkgDir diff --git a/config/config/src/parseAuthInfo.ts b/config/config/src/parseAuthInfo.ts index a2fc61e06b..7c46997c55 100644 --- a/config/config/src/parseAuthInfo.ts +++ b/config/config/src/parseAuthInfo.ts @@ -112,7 +112,7 @@ function parseTokenHelper (source: string): TokenHelper { for (const char of source) { // We'll only support a simple syntax for now. // In the future, we may add quotations and environment variable interpolations. - if (RESERVED_CHARACTERS.has(char)) { // eslint-disable-line + if (RESERVED_CHARACTERS.has(char)) { throw new TokenHelperUnsupportedCharacterError(char) } } diff --git a/cspell.json b/cspell.json index 2133ac70c5..348320cdbd 100644 --- a/cspell.json +++ b/cspell.json @@ -88,6 +88,7 @@ "fnumber", "foobarqar", "foofoo", + "footgun", "forgejo", "fsevents", "gabor", diff --git a/exec/build-commands/package.json b/exec/build-commands/package.json index d6f5579e53..fc35a337f3 100644 --- a/exec/build-commands/package.json +++ b/exec/build-commands/package.json @@ -35,6 +35,7 @@ "@pnpm/config": "workspace:*", "@pnpm/config.config-writer": "workspace:*", "@pnpm/dependency-path": "workspace:*", + "@pnpm/error": "workspace:*", "@pnpm/modules-yaml": "workspace:*", "@pnpm/plugin-commands-rebuild": "workspace:*", "@pnpm/prepare-temp-dir": "workspace:*", @@ -49,11 +50,11 @@ "devDependencies": { "@jest/globals": "catalog:", "@pnpm/exec.build-commands": "workspace:*", - "@pnpm/plugin-commands-installation": "workspace:*", "@pnpm/prepare": "workspace:*", "@pnpm/registry-mock": "catalog:", "@pnpm/types": "workspace:*", "@types/ramda": "catalog:", + "execa": "catalog:", "load-json-file": "catalog:", "ramda": "catalog:", "read-yaml-file": "catalog:", diff --git a/exec/build-commands/src/approveBuilds.ts b/exec/build-commands/src/approveBuilds.ts index 6c73bb14f3..6087632ecd 100644 --- a/exec/build-commands/src/approveBuilds.ts +++ b/exec/build-commands/src/approveBuilds.ts @@ -1,4 +1,5 @@ import { type Config } from '@pnpm/config' +import { PnpmError } from '@pnpm/error' import { globalInfo } from '@pnpm/logger' import { type StrictModules, writeModulesManifest } from '@pnpm/modules-yaml' import { lexCompare } from '@pnpm/util.lex-comparator' @@ -9,7 +10,7 @@ import { rebuild, type RebuildCommandOpts } from '@pnpm/plugin-commands-rebuild' import { writeSettings } from '@pnpm/config.config-writer' import { getAutomaticallyIgnoredBuilds } from './getAutomaticallyIgnoredBuilds.js' -export type ApproveBuildsCommandOpts = Pick & { all?: boolean } +export type ApproveBuildsCommandOpts = Pick & { all?: boolean, global?: boolean } export const commandNames = ['approve-builds'] @@ -26,11 +27,6 @@ export function help (): string { description: 'Approve all pending dependencies without interactive prompts', name: '--all', }, - { - description: 'Approve dependencies of global packages', - name: '--global', - shortAlias: '-g', - }, ], }, ], @@ -49,6 +45,16 @@ export function rcOptionsTypes (): Record { } export async function handler (opts: ApproveBuildsCommandOpts & RebuildCommandOpts): Promise { + if (opts.global) { + throw new PnpmError( + 'APPROVE_BUILDS_NOT_SUPPORTED_WITH_GLOBAL', + '"approve-builds" is not supported with global packages', + { + hint: 'Use --allow-build when installing globally, e.g. "pnpm add -g --allow-build= ". ' + + 'pnpm will also prompt to allow builds interactively during global install.', + } + ) + } const { automaticallyIgnoredBuilds, modulesDir, diff --git a/exec/build-commands/test/approveBuilds.test.ts b/exec/build-commands/test/approveBuilds.test.ts index da69b92de1..0ae4c14780 100644 --- a/exec/build-commands/test/approveBuilds.test.ts +++ b/exec/build-commands/test/approveBuilds.test.ts @@ -1,6 +1,5 @@ import fs from 'fs' import path from 'path' -import { install } from '@pnpm/plugin-commands-installation' import type { ApproveBuildsCommandOpts } from '@pnpm/exec.build-commands' import type { RebuildCommandOpts } from '@pnpm/plugin-commands-rebuild' import { prepare } from '@pnpm/prepare' @@ -13,6 +12,7 @@ import { tempDir } from '@pnpm/prepare-temp-dir' import { writePackageSync } from 'write-package' import { sync as readYamlFile } from 'read-yaml-file' import { sync as writeYamlFile } from 'write-yaml-file' +import execa from 'execa' jest.unstable_mockModule('enquirer', () => ({ default: { prompt: jest.fn() } })) const { default: enquirer } = await import('enquirer') @@ -20,15 +20,28 @@ const { approveBuilds } = await import('@pnpm/exec.build-commands') const prompt = jest.mocked(enquirer.prompt) -type ApproveBuildsOptions = Partial +const REGISTRY = `http://localhost:${REGISTRY_MOCK_PORT}/` +const pnpmBin = path.join(import.meta.dirname, '../../../pnpm/bin/pnpm.mjs') -async function approveSomeBuilds (opts?: ApproveBuildsOptions) { +async function execPnpmInstall (): Promise { + await execa('node', [ + pnpmBin, + 'install', + `--store-dir=${path.resolve('store')}`, + `--cache-dir=${path.resolve('cache')}`, + `--registry=${REGISTRY}`, + '--config.strict-dep-builds=false', + '--config.enable-global-virtual-store=false', + ]) +} + +async function getApproveBuildsConfig () { const cliOptions = { argv: [], dir: process.cwd(), registry: `http://localhost:${REGISTRY_MOCK_PORT}`, } - const config = { + return { ...omit(['reporter'], (await getConfig({ cliOptions, packageManager: { name: 'pnpm', version: '' }, @@ -37,9 +50,14 @@ async function approveSomeBuilds (opts?: ApproveBuildsOptions) { cacheDir: path.resolve('cache'), pnpmfile: [], // this is only needed because the pnpmfile returned by getConfig is string | string[] enableGlobalVirtualStore: false, - strictDepBuilds: false, } - await install.handler({ ...config, argv: { original: [] } }) +} + +type ApproveBuildsOptions = Partial + +async function approveSomeBuilds (opts?: ApproveBuildsOptions) { + await execPnpmInstall() + const config = await getApproveBuildsConfig() prompt.mockResolvedValueOnce({ result: [ @@ -56,22 +74,8 @@ async function approveSomeBuilds (opts?: ApproveBuildsOptions) { } async function approveNoBuilds (opts?: ApproveBuildsOptions) { - const cliOptions = { - argv: [], - dir: process.cwd(), - registry: `http://localhost:${REGISTRY_MOCK_PORT}`, - } - const config = { - ...omit(['reporter'], (await getConfig({ - cliOptions, - packageManager: { name: 'pnpm', version: '' }, - })).config), - storeDir: path.resolve('store'), - cacheDir: path.resolve('cache'), - pnpmfile: [], // this is only needed because the pnpmfile returned by getConfig is string | string[] - strictDepBuilds: false, - } - await install.handler({ ...config, argv: { original: [] } }) + await execPnpmInstall() + const config = await getApproveBuildsConfig() prompt.mockResolvedValueOnce({ result: [], @@ -136,11 +140,22 @@ test("works when root project manifest doesn't exist in a workspace", async () = }, }) - const workspaceDir = path.resolve('workspace') + // Install before writing the workspace manifest so the CLI doesn't + // detect a workspace (matching the old install.handler() behaviour + // where getConfig() didn't read allowBuilds from the manifest). + process.chdir('workspace/packages/project') + await execPnpmInstall() + + const workspaceDir = path.resolve('../..') const workspaceManifestFile = path.join(workspaceDir, 'pnpm-workspace.yaml') writeYamlFile(workspaceManifestFile, { packages: ['packages/*'] }) - process.chdir('workspace/packages/project') - await approveSomeBuilds({ workspaceDir, rootProjectManifestDir: workspaceDir }) + + const config = await getApproveBuildsConfig() + prompt.mockResolvedValueOnce({ + result: [{ value: '@pnpm.e2e/pre-and-postinstall-scripts-example' }], + }) + prompt.mockResolvedValueOnce({ build: true }) + await approveBuilds.handler({ ...config, workspaceDir, rootProjectManifestDir: workspaceDir }) expect(readYamlFile(workspaceManifestFile)).toStrictEqual({ packages: ['packages/*'], @@ -184,23 +199,8 @@ test('approve all builds with --all flag', async () => { }, }) - const cliOptions = { - argv: [], - dir: process.cwd(), - registry: `http://localhost:${REGISTRY_MOCK_PORT}`, - } - const config = { - ...omit(['reporter'], (await getConfig({ - cliOptions, - packageManager: { name: 'pnpm', version: '' }, - })).config), - storeDir: path.resolve('store'), - cacheDir: path.resolve('cache'), - pnpmfile: [], - enableGlobalVirtualStore: false, - strictDepBuilds: false, - } - await install.handler({ ...config, argv: { original: [] } }) + await execPnpmInstall() + const config = await getApproveBuildsConfig() prompt.mockClear() await approveBuilds.handler({ ...config, all: true }) @@ -230,6 +230,11 @@ test('should retain existing allowBuilds entries when approving builds', async ( tempDir: temp, }) + // Install before writing the workspace manifest with allowBuilds so the + // CLI ignores all builds (matching the old install.handler() behaviour + // where getConfig() didn't read allowBuilds from the manifest). + await execPnpmInstall() + const workspaceManifestFile = path.join(temp, 'pnpm-workspace.yaml') writeYamlFile(workspaceManifestFile, { packages: ['packages/*'], @@ -238,15 +243,21 @@ test('should retain existing allowBuilds entries when approving builds', async ( '@pnpm.e2e/install-script-example': true, }, }) - await approveSomeBuilds( - { - workspaceDir: temp, - rootProjectManifestDir: temp, - allowBuilds: { - '@pnpm.e2e/test': false, - '@pnpm.e2e/install-script-example': true, - }, - }) + + const config = await getApproveBuildsConfig() + prompt.mockResolvedValueOnce({ + result: [{ value: '@pnpm.e2e/pre-and-postinstall-scripts-example' }], + }) + prompt.mockResolvedValueOnce({ build: true }) + await approveBuilds.handler({ + ...config, + workspaceDir: temp, + rootProjectManifestDir: temp, + allowBuilds: { + '@pnpm.e2e/test': false, + '@pnpm.e2e/install-script-example': true, + }, + }) expect(readYamlFile(workspaceManifestFile)).toStrictEqual({ packages: ['packages/*'], diff --git a/exec/build-commands/tsconfig.json b/exec/build-commands/tsconfig.json index 5731bbc60f..ac27649a65 100644 --- a/exec/build-commands/tsconfig.json +++ b/exec/build-commands/tsconfig.json @@ -24,15 +24,15 @@ { "path": "../../packages/dependency-path" }, + { + "path": "../../packages/error" + }, { "path": "../../packages/types" }, { "path": "../../pkg-manager/modules-yaml" }, - { - "path": "../../pkg-manager/plugin-commands-installation" - }, { "path": "../plugin-commands-rebuild" } diff --git a/exec/plugin-commands-script-runners/src/dlx.ts b/exec/plugin-commands-script-runners/src/dlx.ts index 6412e63149..5c21007c68 100644 --- a/exec/plugin-commands-script-runners/src/dlx.ts +++ b/exec/plugin-commands-script-runners/src/dlx.ts @@ -275,8 +275,8 @@ export function createCacheKey (opts: { allowBuild?: string[] supportedArchitectures?: SupportedArchitectures }): string { - const sortedPkgs = [...opts.packages].sort((a, b) => a.localeCompare(b)) - const sortedRegistries = Object.entries(opts.registries).sort(([k1], [k2]) => k1.localeCompare(k2)) + const sortedPkgs = [...opts.packages].sort(lexCompare) + const sortedRegistries = Object.entries(opts.registries).sort(([k1], [k2]) => lexCompare(k1, k2)) const args: unknown[] = [sortedPkgs, sortedRegistries] if (opts.allowBuild?.length) { args.push({ allowBuild: opts.allowBuild.sort(lexCompare) }) diff --git a/global/commands/README.md b/global/commands/README.md new file mode 100644 index 0000000000..973ce80847 --- /dev/null +++ b/global/commands/README.md @@ -0,0 +1,151 @@ +# @pnpm/global.commands + +Command handlers for pnpm's global package management (`pnpm add -g`, `pnpm remove -g`, `pnpm update -g`, `pnpm list -g`). + +## Architecture: Isolated Global Packages + +Unlike npm, where all global packages share a single `node_modules/` directory, pnpm installs each global package (or group of packages installed together) into its own **isolated installation directory**. This prevents global packages from interfering with each other through peer dependency conflicts, hoisting changes, or version resolution shifts. + +### Directory layout + +All global package data lives under `{pnpmHomeDir}/global/v11/` (the "global package directory", referred to as `globalPkgDir` in the code). The layout is: + +``` +{pnpmHomeDir}/global/v11/ + {hash-1} -> symlink to {pid}-{timestamp-1}/ (hash symlink) + {pid}-{timestamp-1}/ (install dir) + package.json + node_modules/ + .pnpm/ + {pkg-a}/ + {pkg-b}/ + pnpm-lock.yaml + {hash-2} -> symlink to {pid}-{timestamp-2}/ + {pid}-{timestamp-2}/ + package.json + node_modules/ + .pnpm/ + {pkg-c}/ + pnpm-lock.yaml +``` + +Each install group has two entries: + +1. **Install directory** (`{pid}-{timestamp}`): A regular directory containing a complete pnpm project with its own `package.json`, `node_modules/`, and lockfile. Named with the process ID and timestamp at creation time to avoid collisions. + +2. **Hash symlink** (`{hash}`): A symbolic link named with a deterministic hash of the package aliases and registries. Points to the install directory. This serves as the lookup key for finding where a set of packages is installed. + +The hash is computed from the sorted list of package aliases and sorted registry URLs, making it deterministic for a given set of packages. + +### How each command works + +#### `pnpm add -g [pkg2 ...]` + +Handled by `handleGlobalAdd()`: + +1. Clean up orphaned install directories (those not referenced by any hash symlink). +2. Create a new install directory `{pid}-{timestamp}`. +3. Run `installGlobalPackages()` to install the requested packages into this directory using `@pnpm/core`'s `mutateModulesInSingleProject()`. +4. Read the resolved aliases from the resulting `package.json` (this is more reliable than parsing aliases from CLI params, which may be tarballs, git URLs, etc.). +5. Check for bin name conflicts with existing global packages via `checkGlobalBinConflicts()`. +6. Remove any existing global installations of the same aliases. +7. Create a hash symlink pointing to the new install directory. +8. Link bins from the installed packages into the global bin directory. + +#### `pnpm remove -g [pkg2 ...]` + +Handled by `handleGlobalRemove()`: + +1. Look up each requested package alias to find its install group (via `findGlobalPackage()`). +2. For each affected group: remove all bin shims, delete the hash symlink, and delete the install directory. + +#### `pnpm update -g [pkg ...]` + +Handled by `handleGlobalUpdate()`: + +1. Scan all existing global packages. +2. Filter to groups containing the requested packages (or all groups if no args). +3. For each group: + - Create a new install directory. + - Re-install the same packages (at `--latest` versions if the `--latest` flag is set, or within existing ranges otherwise). + - Check for bin conflicts, remove old bins, swap the hash symlink to point to the new directory, clean up the old directory, and link new bins. + +#### `pnpm list -g [pattern ...]` + +Handled by `listGlobalPackages()`: + +1. Scan all global packages. +2. Read package details (alias, version) from each install group's `node_modules/`. +3. Filter by glob patterns if provided (via `@pnpm/matcher`). +4. Sort and display. + +### `installGlobalPackages()` — the core install function + +This is a focused ~30-line function that replaces the 450-line `installDeps()` from `plugin-commands-installation`. Global installs don't need workspace logic, recursive installs, update matching, rebuild orchestration, or any of the other complexity in `installDeps()`. The function does exactly: + +1. Create a store controller. +2. Read (or create) a manifest for the install directory. +3. Call `mutateModulesInSingleProject()` with `mutation: 'installSome'`. +4. Write the updated manifest. + +### Bin conflict detection + +`checkGlobalBinConflicts()` prevents a common footgun: installing a global package whose binaries would shadow binaries from a different globally-installed package. Before any bin linking happens, it: + +1. Collects bin names from the packages about to be installed. +2. Checks if any of those bin names already exist in the global bin directory. +3. If they do, verifies whether they belong to a package being replaced (ok) or to a different package (error). + +### Orphan cleanup + +`cleanOrphanedInstallDirs()` (from `@pnpm/global.packages`) runs at the start of `add` and `update` to remove install directories that are no longer referenced by any hash symlink. This handles cases where a previous install was interrupted or crashed. A 5-minute safety window prevents cleaning up directories from concurrent installs that haven't created their hash symlink yet. + +## Package structure + +``` +global/ + commands/ @pnpm/global.commands (this package) + src/ + globalAdd.ts handleGlobalAdd() + globalRemove.ts handleGlobalRemove() + globalUpdate.ts handleGlobalUpdate() + listGlobalPackages.ts listGlobalPackages() + installGlobalPackages.ts core install function + checkGlobalBinConflicts.ts bin conflict detection + readInstalledPackages.ts shared helper + index.ts + packages/ @pnpm/global.packages + src/ + scanGlobalPackages.ts directory scanning, package lookup + globalPackageDir.ts install dir / hash link management + cacheKey.ts deterministic hash computation + index.ts +``` + +`@pnpm/global.packages` provides the low-level utilities for reading and managing the directory structure. `@pnpm/global.commands` provides the high-level command handlers that orchestrate installs, removals, updates, and listing. + +## Integration points + +The CLI command handlers in `plugin-commands-installation` and `plugin-commands-listing` delegate to this package with a simple early return: + +```typescript +// In add.ts handler: +if (opts.global) { + return handleGlobalAdd(opts, params) +} + +// In remove.ts handler: +if (opts.global) { + return handleGlobalRemove(opts, params) +} + +// In update/index.ts handler: +if (opts.global) { + return handleGlobalUpdate(opts, params) +} + +// In list.ts handler: +if (opts.global && opts.globalPkgDir) { + return listGlobalPackages(opts.globalPkgDir, params) +} +``` diff --git a/global/commands/package.json b/global/commands/package.json new file mode 100644 index 0000000000..99eb62cd9a --- /dev/null +++ b/global/commands/package.json @@ -0,0 +1,60 @@ +{ + "name": "@pnpm/global.commands", + "version": "1000.0.0-0", + "description": "Global package command handlers for pnpm", + "keywords": [ + "pnpm", + "pnpm11", + "global" + ], + "license": "MIT", + "funding": "https://opencollective.com/pnpm", + "repository": "https://github.com/pnpm/pnpm/tree/main/global/commands", + "homepage": "https://github.com/pnpm/pnpm/tree/main/global/commands#readme", + "bugs": { + "url": "https://github.com/pnpm/pnpm/issues" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": "./lib/index.js" + }, + "files": [ + "lib", + "!*.map" + ], + "scripts": { + "lint": "eslint \"src/**/*.ts\"", + "prepublishOnly": "pnpm run compile", + "compile": "tsgo --build && pnpm run lint --fix", + "test": "pnpm run compile" + }, + "dependencies": { + "@pnpm/cli-utils": "workspace:*", + "@pnpm/config": "workspace:*", + "@pnpm/core": "workspace:*", + "@pnpm/error": "workspace:*", + "@pnpm/exec.build-commands": "workspace:*", + "@pnpm/global.packages": "workspace:*", + "@pnpm/link-bins": "workspace:*", + "@pnpm/matcher": "workspace:*", + "@pnpm/package-bins": "workspace:*", + "@pnpm/read-package-json": "workspace:*", + "@pnpm/remove-bins": "workspace:*", + "@pnpm/store-connection-manager": "workspace:*", + "@pnpm/types": "workspace:*", + "@pnpm/util.lex-comparator": "catalog:", + "is-subdir": "catalog:", + "symlink-dir": "catalog:" + }, + "devDependencies": { + "@pnpm/global.commands": "workspace:*" + }, + "engines": { + "node": ">=22.13" + }, + "jest": { + "preset": "@pnpm/jest-config" + } +} diff --git a/global/commands/src/checkGlobalBinConflicts.ts b/global/commands/src/checkGlobalBinConflicts.ts new file mode 100644 index 0000000000..a792a6d0e3 --- /dev/null +++ b/global/commands/src/checkGlobalBinConflicts.ts @@ -0,0 +1,59 @@ +import fs from 'fs' +import path from 'path' +import { + scanGlobalPackages, + type GlobalPackageInfo, +} from '@pnpm/global.packages' +import { PnpmError } from '@pnpm/error' +import { getBinsFromPackageManifest } from '@pnpm/package-bins' +import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json' +import { type DependencyManifest } from '@pnpm/types' + +export async function checkGlobalBinConflicts (opts: { + globalDir: string + globalBinDir: string + newPkgs: Array<{ manifest: DependencyManifest, location: string }> + shouldSkip: (pkg: GlobalPackageInfo) => boolean +}): Promise { + const newBinNames = new Set() + await Promise.all( + opts.newPkgs.map(async (pkg) => { + const bins = await getBinsFromPackageManifest(pkg.manifest, pkg.location) + for (const bin of bins) { + newBinNames.add(bin.name) + } + }) + ) + if (newBinNames.size === 0) return + + // Quick check: only investigate if a bin with the same name already exists + const conflicting = [...newBinNames].filter( + (name) => fs.existsSync(path.join(opts.globalBinDir, name)) + ) + if (conflicting.length === 0) return + + // Some bins already exist — find out if they belong to packages being replaced + // (in which case it's fine) or to other packages (conflict). + const existingPackages = scanGlobalPackages(opts.globalDir) + for (const existingPkg of existingPackages) { + if (opts.shouldSkip(existingPkg)) continue + const modulesDir = path.join(existingPkg.installDir, 'node_modules') + for (const alias of Object.keys(existingPkg.dependencies)) { + const depDir = path.join(modulesDir, alias) + const manifest = await safeReadPackageJsonFromDir(depDir) // eslint-disable-line no-await-in-loop + if (!manifest) continue + const bins = await getBinsFromPackageManifest(manifest as DependencyManifest, depDir) // eslint-disable-line no-await-in-loop + for (const bin of bins) { + if (conflicting.includes(bin.name)) { + throw new PnpmError( + 'GLOBAL_BIN_CONFLICT', + `Cannot install: binary "${bin.name}" would conflict with package "${alias}" that is already installed globally`, + { + hint: `Remove the conflicting package first: pnpm remove -g ${alias}`, + } + ) + } + } + } + } +} diff --git a/global/commands/src/globalAdd.ts b/global/commands/src/globalAdd.ts new file mode 100644 index 0000000000..c1070bf967 --- /dev/null +++ b/global/commands/src/globalAdd.ts @@ -0,0 +1,170 @@ +import fs from 'fs' +import path from 'path' +import { + cleanOrphanedInstallDirs, + createGlobalCacheKey, + createInstallDir, + findGlobalPackage, + getHashLink, + getInstalledBinNames, +} from '@pnpm/global.packages' +import { linkBinsOfPackages } from '@pnpm/link-bins' +import { removeBin } from '@pnpm/remove-bins' +import { readPackageJsonFromDirRawSync } from '@pnpm/read-package-json' +import isSubdir from 'is-subdir' +import symlinkDir from 'symlink-dir' +import { type CreateStoreControllerOptions } from '@pnpm/store-connection-manager' +import { approveBuilds } from '@pnpm/exec.build-commands' +import { installGlobalPackages } from './installGlobalPackages.js' + +type ApproveBuildsHandlerOpts = Parameters[0] +import { checkGlobalBinConflicts } from './checkGlobalBinConflicts.js' +import { readInstalledPackages } from './readInstalledPackages.js' + +export type GlobalAddOptions = CreateStoreControllerOptions & { + bin?: string + globalPkgDir?: string + registries: Record + allowBuild?: string[] + allowBuilds?: Record + saveExact?: boolean + savePrefix?: string + supportedArchitectures?: { libc?: string[] } + rootProjectManifest?: { pnpm?: { supportedArchitectures?: { libc?: string[] } } } +} + +export async function handleGlobalAdd ( + opts: GlobalAddOptions, + params: string[] +): Promise { + const globalDir = opts.globalPkgDir! + const globalBinDir = opts.bin! + cleanOrphanedInstallDirs(globalDir) + + // Install into a new directory first, then read the resolved aliases + // from the resulting package.json. This is more reliable than parsing + // aliases from CLI params (which may be tarballs, git URLs, etc.). + const installDir = createInstallDir(globalDir) + + // Convert allowBuild array to allowBuilds Record (same conversion as add.handler) + let allowBuilds = opts.allowBuilds ?? {} + if (opts.allowBuild?.length) { + allowBuilds = { ...allowBuilds } + for (const pkg of opts.allowBuild) { + allowBuilds[pkg] = true + } + } + + const include = { + dependencies: true, + devDependencies: false, + optionalDependencies: true, + } + const fetchFullMetadata = (opts.supportedArchitectures?.libc ?? opts.rootProjectManifest?.pnpm?.supportedArchitectures?.libc) && true + + const makeInstallOpts = (dir: string, builds: Record) => ({ + ...opts, + global: false, + bin: path.join(dir, 'node_modules/.bin'), + dir, + lockfileDir: dir, + rootProjectManifestDir: dir, + rootProjectManifest: undefined, + saveProd: true, + saveDev: false, + saveOptional: false, + savePeer: false, + workspaceDir: undefined, + sharedWorkspaceLockfile: false, + lockfileOnly: false, + fetchFullMetadata, + include, + includeDirect: include, + allowBuilds: builds, + }) + + const ignoredBuilds = await installGlobalPackages(makeInstallOpts(installDir, allowBuilds), params) + + // If any packages had their builds skipped, prompt the user to approve them + // (reuses the same interactive flow as `pnpm approve-builds`) + if (ignoredBuilds?.size && process.stdin.isTTY) { + await approveBuilds.handler({ + ...opts, + modulesDir: path.join(installDir, 'node_modules'), + dir: installDir, + lockfileDir: installDir, + rootProjectManifest: undefined, + rootProjectManifestDir: installDir, + workspaceDir: opts.globalPkgDir!, + global: false, + pending: false, + allowBuilds, + } as ApproveBuildsHandlerOpts) + } + + // Read resolved aliases from the installed package.json + const pkgJson = readPackageJsonFromDirRawSync(installDir) + const aliases = Object.keys(pkgJson.dependencies ?? {}) + + // Check for bin name conflicts with other global packages + // (must happen before removeExistingGlobalInstalls so we don't lose existing packages on failure) + const pkgs = await readInstalledPackages(installDir) + try { + await checkGlobalBinConflicts({ + globalDir, + globalBinDir, + newPkgs: pkgs, + shouldSkip: (pkg) => aliases.some((alias) => alias in pkg.dependencies), + }) + } catch (err) { + await fs.promises.rm(installDir, { recursive: true, force: true }) + throw err + } + + // Remove any existing global installations of these aliases + await removeExistingGlobalInstalls(globalDir, globalBinDir, aliases) + + // Compute cache key and create hash symlink pointing to install dir + const cacheHash = createGlobalCacheKey({ + aliases, + registries: opts.registries, + }) + const hashLink = getHashLink(globalDir, cacheHash) + await symlinkDir(installDir, hashLink, { overwrite: true }) + + // Link bins from installed packages into global bin dir + await linkBinsOfPackages(pkgs, globalBinDir) +} + +async function removeExistingGlobalInstalls ( + globalDir: string, + globalBinDir: string, + aliases: string[] +): Promise { + // Collect unique groups to remove (dedup by hash) + const groupsToRemove = new Map>() + for (const alias of aliases) { + const existing = findGlobalPackage(globalDir, alias) + if (existing && !groupsToRemove.has(existing.hash)) { + groupsToRemove.set(existing.hash, getInstalledBinNames(existing)) + } + } + + // Remove all groups in parallel + await Promise.all( + [...groupsToRemove.entries()].map(async ([hash, binNamesPromise]) => { + const binNames = await binNamesPromise + await Promise.all(binNames.map((binName) => removeBin(path.join(globalBinDir, binName)))) + // Remove both the hash symlink and the install dir it points to + const hashLink = getHashLink(globalDir, hash) + let installDir: string | null = null + try { + installDir = fs.realpathSync(hashLink) + } catch {} + await fs.promises.rm(hashLink, { force: true }) + if (installDir && isSubdir(globalDir, installDir)) { + await fs.promises.rm(installDir, { recursive: true, force: true }) + } + }) + ) +} diff --git a/global/commands/src/globalRemove.ts b/global/commands/src/globalRemove.ts new file mode 100644 index 0000000000..b3a9155b9a --- /dev/null +++ b/global/commands/src/globalRemove.ts @@ -0,0 +1,44 @@ +import fs from 'fs' +import path from 'path' +import { PnpmError } from '@pnpm/error' +import { + findGlobalPackage, + getHashLink, + getInstalledBinNames, + type GlobalPackageInfo, +} from '@pnpm/global.packages' +import { removeBin } from '@pnpm/remove-bins' +import isSubdir from 'is-subdir' + +export async function handleGlobalRemove ( + opts: { + globalPkgDir?: string + bin?: string + }, + params: string[] +): Promise { + const globalDir = opts.globalPkgDir! + const globalBinDir = opts.bin! + + // Find all groups that contain the packages to remove (dedup by hash) + const groupsToRemove = new Map() + for (const param of params) { + const pkg = findGlobalPackage(globalDir, param) + if (!pkg) { + throw new PnpmError('GLOBAL_PKG_NOT_FOUND', `Cannot remove '${param}': not found in global packages`) + } + groupsToRemove.set(pkg.hash, pkg) + } + + // Remove bins, hash symlinks, and install dirs for all affected groups in parallel + await Promise.all( + [...groupsToRemove.entries()].map(async ([hash, pkg]) => { + const binNames = await getInstalledBinNames(pkg) + await Promise.all(binNames.map((binName) => removeBin(path.join(globalBinDir, binName)))) + await fs.promises.rm(getHashLink(globalDir, hash), { force: true }) + if (isSubdir(globalDir, pkg.installDir)) { + await fs.promises.rm(pkg.installDir, { recursive: true, force: true }) + } + }) + ) +} diff --git a/global/commands/src/globalUpdate.ts b/global/commands/src/globalUpdate.ts new file mode 100644 index 0000000000..d408ca466c --- /dev/null +++ b/global/commands/src/globalUpdate.ts @@ -0,0 +1,156 @@ +import fs from 'fs' +import path from 'path' +import { + cleanOrphanedInstallDirs, + createInstallDir, + getHashLink, + getInstalledBinNames, + scanGlobalPackages, + type GlobalPackageInfo, +} from '@pnpm/global.packages' +import { linkBinsOfPackages } from '@pnpm/link-bins' +import { removeBin } from '@pnpm/remove-bins' +import isSubdir from 'is-subdir' +import symlinkDir from 'symlink-dir' +import { type CreateStoreControllerOptions } from '@pnpm/store-connection-manager' +import { approveBuilds } from '@pnpm/exec.build-commands' +import { installGlobalPackages } from './installGlobalPackages.js' + +type ApproveBuildsHandlerOpts = Parameters[0] +import { checkGlobalBinConflicts } from './checkGlobalBinConflicts.js' +import { readInstalledPackages } from './readInstalledPackages.js' + +export type GlobalUpdateOptions = CreateStoreControllerOptions & { + bin?: string + globalPkgDir?: string + latest?: boolean + allowBuilds?: Record + saveExact?: boolean + savePrefix?: string + supportedArchitectures?: { libc?: string[] } + rootProjectManifest?: { pnpm?: { supportedArchitectures?: { libc?: string[] } } } +} + +export async function handleGlobalUpdate ( + opts: GlobalUpdateOptions, + params: string[] +): Promise { + const globalDir = opts.globalPkgDir! + const globalBinDir = opts.bin! + cleanOrphanedInstallDirs(globalDir) + const allPackages = scanGlobalPackages(globalDir) + + if (allPackages.length === 0) { + return 'No global packages found' + } + + // If specific packages are requested, filter to only groups containing them + let packagesToUpdate: GlobalPackageInfo[] + if (params.length > 0) { + packagesToUpdate = allPackages.filter((pkg) => + params.some((p) => p in pkg.dependencies) + ) + if (packagesToUpdate.length === 0) { + return 'No matching global packages found' + } + } else { + packagesToUpdate = allPackages + } + + // Update each package group sequentially to avoid overwhelming the system + + for (const pkg of packagesToUpdate) { + await updateGlobalPackageGroup(opts, globalDir, globalBinDir, pkg) // eslint-disable-line no-await-in-loop + } + return undefined +} + +async function updateGlobalPackageGroup ( + opts: GlobalUpdateOptions, + globalDir: string, + globalBinDir: string, + pkg: GlobalPackageInfo +): Promise { + const installDir = createInstallDir(globalDir) + + // When --latest, just pass alias names to get the latest version. + // Otherwise, pass alias@spec to update within the existing range. + const depSpecs = Object.entries(pkg.dependencies).map( + ([alias, spec]) => opts.latest ? alias : `${alias}@${spec}` + ) + + const include = { + dependencies: true, + devDependencies: false, + optionalDependencies: true, + } + const fetchFullMetadata = (opts.supportedArchitectures?.libc ?? opts.rootProjectManifest?.pnpm?.supportedArchitectures?.libc) && true + const allowBuilds = opts.allowBuilds ?? {} + + const ignoredBuilds = await installGlobalPackages({ + ...opts, + global: false, + bin: path.join(installDir, 'node_modules/.bin'), + dir: installDir, + lockfileDir: installDir, + rootProjectManifestDir: installDir, + rootProjectManifest: undefined, + saveProd: true, + saveDev: false, + saveOptional: false, + savePeer: false, + workspaceDir: undefined, + sharedWorkspaceLockfile: false, + lockfileOnly: false, + fetchFullMetadata, + include, + includeDirect: include, + allowBuilds, + }, depSpecs) + + // If any packages had their builds skipped, prompt the user to approve them + // (reuses the same interactive flow as `pnpm approve-builds`) + if (ignoredBuilds?.size && process.stdin.isTTY) { + await approveBuilds.handler({ + ...opts, + modulesDir: path.join(installDir, 'node_modules'), + dir: installDir, + lockfileDir: installDir, + rootProjectManifest: undefined, + rootProjectManifestDir: installDir, + workspaceDir: opts.globalPkgDir!, + global: false, + pending: false, + allowBuilds, + } as ApproveBuildsHandlerOpts) + } + + // Check for bin name conflicts with other global packages + const pkgs = await readInstalledPackages(installDir) + try { + await checkGlobalBinConflicts({ + globalDir, + globalBinDir, + newPkgs: pkgs, + shouldSkip: (existingPkg) => existingPkg.hash === pkg.hash, + }) + } catch (err) { + await fs.promises.rm(installDir, { recursive: true, force: true }) + throw err + } + + // Remove stale bins from old installation before swapping + const oldBinNames = await getInstalledBinNames(pkg) + await Promise.all(oldBinNames.map((binName) => removeBin(path.join(globalBinDir, binName)))) + + // Swap hash symlink to new install dir, then clean up old one + const hashLink = getHashLink(globalDir, pkg.hash) + const oldInstallDir = pkg.installDir + await symlinkDir(installDir, hashLink, { overwrite: true }) + if (isSubdir(globalDir, oldInstallDir)) { + await fs.promises.rm(oldInstallDir, { recursive: true, force: true }) + } + + // Link bins from new installation + await linkBinsOfPackages(pkgs, globalBinDir) +} diff --git a/global/commands/src/index.ts b/global/commands/src/index.ts new file mode 100644 index 0000000000..cd9a669522 --- /dev/null +++ b/global/commands/src/index.ts @@ -0,0 +1,6 @@ +export { handleGlobalAdd, type GlobalAddOptions } from './globalAdd.js' +export { handleGlobalRemove } from './globalRemove.js' +export { handleGlobalUpdate, type GlobalUpdateOptions } from './globalUpdate.js' +export { checkGlobalBinConflicts } from './checkGlobalBinConflicts.js' +export { listGlobalPackages } from './listGlobalPackages.js' +export { installGlobalPackages, type InstallGlobalPackagesOptions } from './installGlobalPackages.js' diff --git a/global/commands/src/installGlobalPackages.ts b/global/commands/src/installGlobalPackages.ts new file mode 100644 index 0000000000..f26e82cafb --- /dev/null +++ b/global/commands/src/installGlobalPackages.ts @@ -0,0 +1,63 @@ +import { tryReadProjectManifest } from '@pnpm/cli-utils' +import { getOptionsFromRootManifest } from '@pnpm/config' +import { mutateModulesInSingleProject } from '@pnpm/core' +import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store-connection-manager' +import { type IgnoredBuilds, type IncludedDependencies, type ProjectRootDir } from '@pnpm/types' + +export interface InstallGlobalPackagesOptions extends CreateStoreControllerOptions { + bin: string + dir: string + global?: boolean + lockfileDir: string + lockfileOnly?: boolean + allowBuilds?: Record + include: IncludedDependencies + includeDirect?: IncludedDependencies + fetchFullMetadata?: boolean + rootProjectManifest?: unknown + rootProjectManifestDir?: string + saveDev?: boolean + saveExact?: boolean + saveOptional?: boolean + savePeer?: boolean + savePrefix?: string + saveProd?: boolean + sharedWorkspaceLockfile?: boolean + workspaceDir?: string +} + +export async function installGlobalPackages ( + opts: InstallGlobalPackagesOptions, + params: string[] +): Promise { + const store = await createStoreController(opts) + let { manifest, writeProjectManifest } = await tryReadProjectManifest(opts.dir, opts) + if (manifest == null) { + manifest = {} + } + const rootManifestOpts = getOptionsFromRootManifest(opts.dir, manifest) + const installOpts = { + ...opts, + ...rootManifestOpts, + allowBuilds: { ...rootManifestOpts.allowBuilds, ...opts.allowBuilds }, + storeController: store.ctrl, + storeDir: store.dir, + } + const pinnedVersion = opts.saveExact ? 'patch' : (opts.savePrefix === '~' ? 'minor' : 'major') + const { updatedProject, ignoredBuilds } = await mutateModulesInSingleProject( + { + allowNew: true, + binsDir: opts.bin, + dependencySelectors: params, + manifest, + mutation: 'installSome' as const, + peer: false, + pinnedVersion, + rootDir: opts.dir as ProjectRootDir, + targetDependenciesField: 'dependencies' as const, + }, + installOpts + ) + await writeProjectManifest(updatedProject.manifest) + return ignoredBuilds +} diff --git a/global/commands/src/listGlobalPackages.ts b/global/commands/src/listGlobalPackages.ts new file mode 100644 index 0000000000..dcc545b5b7 --- /dev/null +++ b/global/commands/src/listGlobalPackages.ts @@ -0,0 +1,24 @@ +import { + scanGlobalPackages, + getGlobalPackageDetails, +} from '@pnpm/global.packages' +import { createMatcher } from '@pnpm/matcher' +import { lexCompare } from '@pnpm/util.lex-comparator' + +export async function listGlobalPackages (globalPkgDir: string, params: string[]): Promise { + const packages = scanGlobalPackages(globalPkgDir) + const allDetails = await Promise.all(packages.map((pkg) => getGlobalPackageDetails(pkg))) + const matches = params.length > 0 ? createMatcher(params) : () => true + const lines: string[] = [] + for (const installed of allDetails.flat()) { + if (!matches(installed.alias)) continue + lines.push(`${installed.alias}@${installed.version}`) + } + if (lines.length === 0) { + return params.length > 0 + ? 'No matching global packages found' + : 'No global packages found' + } + lines.sort(lexCompare) + return lines.join('\n') +} diff --git a/global/commands/src/readInstalledPackages.ts b/global/commands/src/readInstalledPackages.ts new file mode 100644 index 0000000000..ae471639df --- /dev/null +++ b/global/commands/src/readInstalledPackages.ts @@ -0,0 +1,15 @@ +import path from 'path' +import { readPackageJsonFromDir, readPackageJsonFromDirRawSync } from '@pnpm/read-package-json' +import { type DependencyManifest } from '@pnpm/types' + +export async function readInstalledPackages (installDir: string): Promise> { + const pkgJson = readPackageJsonFromDirRawSync(installDir) + const depNames = Object.keys(pkgJson.dependencies ?? {}) + const manifests = await Promise.all( + depNames.map((depName) => readPackageJsonFromDir(path.join(installDir, 'node_modules', depName))) + ) + return depNames.map((depName, i) => ({ + manifest: manifests[i] as DependencyManifest, + location: path.join(installDir, 'node_modules', depName), + })) +} diff --git a/global/commands/tsconfig.json b/global/commands/tsconfig.json new file mode 100644 index 0000000000..a595a29405 --- /dev/null +++ b/global/commands/tsconfig.json @@ -0,0 +1,52 @@ +{ + "extends": "@pnpm/tsconfig", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts", + "../../__typings__/**/*.d.ts" + ], + "references": [ + { + "path": "../../cli/cli-utils" + }, + { + "path": "../../config/config" + }, + { + "path": "../../config/matcher" + }, + { + "path": "../../exec/build-commands" + }, + { + "path": "../../packages/error" + }, + { + "path": "../../packages/types" + }, + { + "path": "../../pkg-manager/core" + }, + { + "path": "../../pkg-manager/link-bins" + }, + { + "path": "../../pkg-manager/package-bins" + }, + { + "path": "../../pkg-manager/remove-bins" + }, + { + "path": "../../pkg-manifest/read-package-json" + }, + { + "path": "../../store/store-connection-manager" + }, + { + "path": "../packages" + } + ] +} diff --git a/global/commands/tsconfig.lint.json b/global/commands/tsconfig.lint.json new file mode 100644 index 0000000000..1bbe711971 --- /dev/null +++ b/global/commands/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../../__typings__/**/*.d.ts" + ] +} diff --git a/global/packages/package.json b/global/packages/package.json new file mode 100644 index 0000000000..2ab94b6c8d --- /dev/null +++ b/global/packages/package.json @@ -0,0 +1,49 @@ +{ + "name": "@pnpm/global.packages", + "version": "1000.0.0-0", + "description": "Utilities for managing isolated global packages", + "keywords": [ + "pnpm", + "pnpm11", + "global" + ], + "license": "MIT", + "funding": "https://opencollective.com/pnpm", + "repository": "https://github.com/pnpm/pnpm/tree/main/global/packages", + "homepage": "https://github.com/pnpm/pnpm/tree/main/global/packages#readme", + "bugs": { + "url": "https://github.com/pnpm/pnpm/issues" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": "./lib/index.js" + }, + "files": [ + "lib", + "!*.map" + ], + "scripts": { + "lint": "eslint \"src/**/*.ts\"", + "prepublishOnly": "pnpm run compile", + "compile": "tsgo --build && pnpm run lint --fix", + "test": "pnpm run compile" + }, + "dependencies": { + "@pnpm/crypto.hash": "workspace:*", + "@pnpm/package-bins": "workspace:*", + "@pnpm/read-package-json": "workspace:*", + "@pnpm/types": "workspace:*", + "@pnpm/util.lex-comparator": "catalog:" + }, + "devDependencies": { + "@pnpm/global.packages": "workspace:*" + }, + "engines": { + "node": ">=22.13" + }, + "jest": { + "preset": "@pnpm/jest-config" + } +} diff --git a/global/packages/src/cacheKey.ts b/global/packages/src/cacheKey.ts new file mode 100644 index 0000000000..e0bc1d7221 --- /dev/null +++ b/global/packages/src/cacheKey.ts @@ -0,0 +1,12 @@ +import { createHexHash } from '@pnpm/crypto.hash' +import { lexCompare } from '@pnpm/util.lex-comparator' + +export function createGlobalCacheKey (opts: { + aliases: string[] + registries: Record +}): string { + const sortedAliases = [...opts.aliases].sort(lexCompare) + const sortedRegistries = Object.entries(opts.registries).sort(([k1], [k2]) => lexCompare(k1, k2)) + const hashStr = JSON.stringify([sortedAliases, sortedRegistries]) + return createHexHash(hashStr) +} diff --git a/global/packages/src/globalPackageDir.ts b/global/packages/src/globalPackageDir.ts new file mode 100644 index 0000000000..bf4174e518 --- /dev/null +++ b/global/packages/src/globalPackageDir.ts @@ -0,0 +1,28 @@ +import fs from 'fs' +import path from 'path' +import util from 'util' + +export function getHashLink (globalDir: string, hash: string): string { + return path.join(globalDir, hash) +} + +export function resolveInstallDir (globalDir: string, hash: string): string | null { + const linkPath = getHashLink(globalDir, hash) + try { + const stats = fs.lstatSync(linkPath) + if (!stats.isSymbolicLink()) return null + return fs.realpathSync(linkPath) + } catch (err) { + if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') { + return null + } + throw err + } +} + +export function createInstallDir (globalDir: string): string { + const name = `${process.pid.toString(16)}-${Date.now().toString(16)}` + const dir = path.join(globalDir, name) + fs.mkdirSync(dir, { recursive: true }) + return dir +} diff --git a/global/packages/src/index.ts b/global/packages/src/index.ts new file mode 100644 index 0000000000..2867ec8565 --- /dev/null +++ b/global/packages/src/index.ts @@ -0,0 +1,15 @@ +export { createGlobalCacheKey } from './cacheKey.js' +export { + createInstallDir, + getHashLink, + resolveInstallDir, +} from './globalPackageDir.js' +export { + cleanOrphanedInstallDirs, + findGlobalPackage, + getGlobalPackageDetails, + getInstalledBinNames, + scanGlobalPackages, + type GlobalPackageInfo, + type InstalledGlobalPackage, +} from './scanGlobalPackages.js' diff --git a/global/packages/src/scanGlobalPackages.ts b/global/packages/src/scanGlobalPackages.ts new file mode 100644 index 0000000000..90885755c6 --- /dev/null +++ b/global/packages/src/scanGlobalPackages.ts @@ -0,0 +1,127 @@ +import fs from 'fs' +import path from 'path' +import util from 'util' +import { getBinsFromPackageManifest } from '@pnpm/package-bins' +import { readPackageJsonFromDirRawSync, safeReadPackageJsonFromDir } from '@pnpm/read-package-json' +import { type PackageManifest } from '@pnpm/types' + +export interface GlobalPackageInfo { + hash: string + installDir: string + dependencies: Record +} + +export interface InstalledGlobalPackage { + alias: string + version: string + manifest: PackageManifest +} + +export function scanGlobalPackages (globalDir: string): GlobalPackageInfo[] { + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(globalDir, { withFileTypes: true }) + } catch (err) { + if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') { + return [] + } + throw err + } + const result: GlobalPackageInfo[] = [] + for (const entry of entries) { + // Hash entries are symlinks pointing to install dirs + if (!entry.isSymbolicLink()) continue + const linkPath = path.join(globalDir, entry.name) + let installDir: string + try { + installDir = fs.realpathSync(linkPath) + } catch { + continue + } + let pkgJson: PackageManifest + try { + pkgJson = readPackageJsonFromDirRawSync(installDir) + } catch { + continue + } + if (!pkgJson.dependencies || Object.keys(pkgJson.dependencies).length === 0) continue + result.push({ + hash: entry.name, + installDir, + dependencies: pkgJson.dependencies, + }) + } + return result +} + +export function findGlobalPackage (globalDir: string, alias: string): GlobalPackageInfo | null { + const packages = scanGlobalPackages(globalDir) + return packages.find((pkg) => alias in pkg.dependencies) ?? null +} + +export async function getGlobalPackageDetails (info: GlobalPackageInfo): Promise { + const aliases = Object.keys(info.dependencies) + const installedPackages = await Promise.all( + aliases.map(async (alias): Promise => { + const manifest = await safeReadPackageJsonFromDir(path.join(info.installDir, 'node_modules', alias)) + if (!manifest) return null + return { alias, version: manifest.version, manifest } + }) + ) + return installedPackages.filter((pkg): pkg is InstalledGlobalPackage => pkg !== null) +} + +export function cleanOrphanedInstallDirs (globalDir: string): void { + globalDir = path.resolve(globalDir) + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(globalDir, { withFileTypes: true }) + } catch { + return + } + + // Collect real paths of all symlink targets + const referenced = new Set() + for (const entry of entries) { + if (!entry.isSymbolicLink()) continue + try { + referenced.add(fs.realpathSync(path.join(globalDir, entry.name))) + } catch {} + } + + // Remove directories that no symlink points to. + // Skip recently-created dirs to avoid racing with a concurrent install + // that hasn't created its hash symlink yet. + const now = Date.now() + const SAFETY_WINDOW_MS = 5 * 60 * 1000 + for (const entry of entries) { + if (!entry.isDirectory()) continue + const dirPath = path.join(globalDir, entry.name) + if (referenced.has(dirPath)) continue + try { + const stat = fs.statSync(dirPath) + if (now - Math.max(stat.birthtimeMs, stat.ctimeMs) < SAFETY_WINDOW_MS) continue + } catch { + continue + } + fs.rmSync(dirPath, { recursive: true, force: true }) + } +} + +export async function getInstalledBinNames (info: GlobalPackageInfo): Promise { + const bins = new Set() + const aliases = Object.keys(info.dependencies) + const modulesDir = path.join(info.installDir, 'node_modules') + await Promise.all( + aliases.map(async (alias) => { + const depDir = path.join(modulesDir, alias) + const manifest = await safeReadPackageJsonFromDir(depDir) + if (!manifest) return + const binsOfPkg = await getBinsFromPackageManifest(manifest, depDir) + for (const bin of binsOfPkg) { + bins.add(bin.name) + } + }) + ) + return [...bins] +} diff --git a/global/packages/tsconfig.json b/global/packages/tsconfig.json new file mode 100644 index 0000000000..b69e84a093 --- /dev/null +++ b/global/packages/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "@pnpm/tsconfig", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts", + "../../__typings__/**/*.d.ts" + ], + "references": [ + { + "path": "../../crypto/hash" + }, + { + "path": "../../packages/types" + }, + { + "path": "../../pkg-manager/package-bins" + }, + { + "path": "../../pkg-manifest/read-package-json" + } + ] +} diff --git a/global/packages/tsconfig.lint.json b/global/packages/tsconfig.lint.json new file mode 100644 index 0000000000..1bbe711971 --- /dev/null +++ b/global/packages/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../../__typings__/**/*.d.ts" + ] +} diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 4d1afd3704..d96112117b 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -7,6 +7,7 @@ export const MANIFEST_BASE_NAMES = ['package.json', 'package.json5', 'package.ya export const ENGINE_NAME = `${process.platform};${process.arch};node${process.version.split('.')[0].substring(1)}` export const LAYOUT_VERSION = 5 export const STORE_VERSION = 'v11' +export const GLOBAL_LAYOUT_VERSION = 'v11' export const GLOBAL_CONFIG_YAML_FILENAME = 'config.yaml' export const WORKSPACE_MANIFEST_FILENAME = 'pnpm-workspace.yaml' diff --git a/pkg-manager/plugin-commands-installation/package.json b/pkg-manager/plugin-commands-installation/package.json index 01b762daee..ce8108b057 100644 --- a/pkg-manager/plugin-commands-installation/package.json +++ b/pkg-manager/plugin-commands-installation/package.json @@ -48,6 +48,7 @@ "@pnpm/filter-workspace-packages": "workspace:*", "@pnpm/find-workspace-dir": "workspace:*", "@pnpm/get-context": "workspace:*", + "@pnpm/global.commands": "workspace:*", "@pnpm/graceful-fs": "workspace:*", "@pnpm/lockfile.types": "workspace:*", "@pnpm/manifest-utils": "workspace:*", diff --git a/pkg-manager/plugin-commands-installation/src/add.ts b/pkg-manager/plugin-commands-installation/src/add.ts index 97269c1de8..14172f7113 100644 --- a/pkg-manager/plugin-commands-installation/src/add.ts +++ b/pkg-manager/plugin-commands-installation/src/add.ts @@ -3,6 +3,7 @@ import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options- import { types as allTypes } from '@pnpm/config' import { resolveConfigDeps } from '@pnpm/config.deps-installer' import { PnpmError } from '@pnpm/error' +import { handleGlobalAdd } from '@pnpm/global.commands' import { createStoreController } from '@pnpm/store-connection-manager' import { pick } from 'ramda' import renderHelp from 'render-help' @@ -250,6 +251,7 @@ export async function handler ( if (params.includes('pnpm') || params.includes('@pnpm/exe')) { throw new PnpmError('GLOBAL_PNPM_INSTALL', 'Use the "pnpm self-update" command to install or update pnpm') } + return handleGlobalAdd(opts, params) } const include = { diff --git a/pkg-manager/plugin-commands-installation/src/install.ts b/pkg-manager/plugin-commands-installation/src/install.ts index ecaad60ae4..97c54954f4 100644 --- a/pkg-manager/plugin-commands-installation/src/install.ts +++ b/pkg-manager/plugin-commands-installation/src/install.ts @@ -2,6 +2,7 @@ import { docsUrl } from '@pnpm/cli-utils' import { FILTERING, OPTIONS, OUTPUT_OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help' import { type Config, types as allTypes } from '@pnpm/config' import { WANTED_LOCKFILE } from '@pnpm/constants' +import { PnpmError } from '@pnpm/error' import { type CreateStoreControllerOptions } from '@pnpm/store-connection-manager' import { pick } from 'ramda' import renderHelp from 'render-help' @@ -328,7 +329,7 @@ export type InstallCommandOptions = Pick & CreateStoreControllerOptions & { +> & CreateStoreControllerOptions & Partial> & { argv: { original: string[] } @@ -347,7 +348,11 @@ export type InstallCommandOptions = Pick> -export async function handler (opts: InstallCommandOptions): Promise { +export async function handler (opts: InstallCommandOptions & { _calledFromLink?: boolean }): Promise { + if (opts.global && !opts._calledFromLink) { + throw new PnpmError('GLOBAL_INSTALL_NOT_SUPPORTED', + '"pnpm install -g" is not supported. Use "pnpm add -g " to install global packages.') + } const include = { dependencies: opts.production !== false, devDependencies: opts.dev !== false, diff --git a/pkg-manager/plugin-commands-installation/src/link.ts b/pkg-manager/plugin-commands-installation/src/link.ts index 4dc1145f7b..4fc9b0f8d8 100644 --- a/pkg-manager/plugin-commands-installation/src/link.ts +++ b/pkg-manager/plugin-commands-installation/src/link.ts @@ -38,7 +38,6 @@ type LinkOpts = Pick & Partial> & install.InstallCommandOptions export const rcOptionsTypes = cliOptionsTypes @@ -74,8 +73,7 @@ export function help (): string { ], url: docsUrl('link'), usages: [ - 'pnpm link ', - 'pnpm link', + 'pnpm link ', ], }) } @@ -123,37 +121,18 @@ export async function handler ( binsDir: opts.bin, }) - if (opts.cliOptions?.global && !opts.bin) { - throw new PnpmError('NO_GLOBAL_BIN_DIR', 'Unable to find the global bin directory', { - hint: 'Run "pnpm setup" to create it automatically, or set the global-bin-dir setting, or the PNPM_HOME env variable. The global bin directory should be in the PATH.', - }) - } - const writeProjectManifest = await createProjectManifestWriter(opts.rootProjectManifestDir) - // pnpm link 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') - } - - await checkPeerDeps(cwd, opts) - - const newManifest = opts.rootProjectManifest ?? {} - await addLinkToManifest(opts, newManifest, cwd, opts.rootProjectManifestDir) - await writeProjectManifest(newManifest) - await install.handler({ - ...linkOpts, - frozenLockfileIfExists: false, - rootProjectManifest: newManifest, - }) - return + throw new PnpmError('LINK_BAD_PARAMS', 'You must provide a parameter. Usage: pnpm link ') } 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]}"`) + } const newManifest = opts.rootProjectManifest ?? {} await Promise.all( @@ -166,6 +145,7 @@ export async function handler ( await writeProjectManifest(newManifest) await install.handler({ ...linkOpts, + _calledFromLink: true, frozenLockfileIfExists: false, rootProjectManifest: newManifest, }) diff --git a/pkg-manager/plugin-commands-installation/src/remove.ts b/pkg-manager/plugin-commands-installation/src/remove.ts index f5f42557e0..98807c21a0 100644 --- a/pkg-manager/plugin-commands-installation/src/remove.ts +++ b/pkg-manager/plugin-commands-installation/src/remove.ts @@ -8,6 +8,7 @@ import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options- import { type Config, getOptionsFromRootManifest, types as allTypes } from '@pnpm/config' import { PnpmError } from '@pnpm/error' import { arrayOfWorkspacePackagesToMap } from '@pnpm/get-context' +import { handleGlobalRemove } from '@pnpm/global.commands' import { findWorkspacePackages } from '@pnpm/workspace.find-packages' import { updateWorkspaceManifest } from '@pnpm/workspace.manifest-writer' import { getAllDependenciesFromManifest } from '@pnpm/manifest-utils' @@ -154,10 +155,18 @@ export async function handler ( > & { recursive?: boolean pnpmfile: string[] - }, + } & Partial>, params: string[] ): Promise { if (params.length === 0) throw new PnpmError('MUST_REMOVE_SOMETHING', 'At least one dependency name should be specified for removal') + if (opts.global) { + if (!opts.bin) { + throw new PnpmError('NO_GLOBAL_BIN_DIR', 'Unable to find the global bin directory', { + hint: 'Run "pnpm setup" to create it automatically, or set the global-bin-dir setting, or the PNPM_HOME env variable. The global bin directory should be in the PATH.', + }) + } + return handleGlobalRemove(opts, params) + } const include = { dependencies: opts.production !== false, devDependencies: opts.dev !== false, diff --git a/pkg-manager/plugin-commands-installation/src/update/index.ts b/pkg-manager/plugin-commands-installation/src/update/index.ts index 436ae3b838..e460e0a195 100644 --- a/pkg-manager/plugin-commands-installation/src/update/index.ts +++ b/pkg-manager/plugin-commands-installation/src/update/index.ts @@ -6,6 +6,7 @@ import { import { type CompletionFunc } from '@pnpm/command' import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help' import { types as allTypes } from '@pnpm/config' +import { handleGlobalUpdate } from '@pnpm/global.commands' import { globalInfo } from '@pnpm/logger' import { createMatcher } from '@pnpm/matcher' import { outdatedDepsOfProjects } from '@pnpm/outdated' @@ -168,8 +169,13 @@ export async function handler ( opts: UpdateCommandOptions, params: string[] = [] ): Promise { - if (opts.global && opts.rootProjectManifest == null) { - return 'No global packages found' + if (opts.global) { + if (!opts.bin) { + throw new PnpmError('NO_GLOBAL_BIN_DIR', 'Unable to find the global bin directory', { + hint: 'Run "pnpm setup" to create it automatically, or set the global-bin-dir setting, or the PNPM_HOME env variable. The global bin directory should be in the PATH.', + }) + } + return handleGlobalUpdate(opts, params) } if (opts.interactive) { return interactiveUpdate(params, opts) diff --git a/pkg-manager/plugin-commands-installation/test/link.ts b/pkg-manager/plugin-commands-installation/test/link.ts index e6c8668e9e..cd2e1a7534 100644 --- a/pkg-manager/plugin-commands-installation/test/link.ts +++ b/pkg-manager/plugin-commands-installation/test/link.ts @@ -3,11 +3,9 @@ import path from 'path' import { prepare, preparePackages, prepareEmpty } from '@pnpm/prepare' import { isExecutable, assertProject } from '@pnpm/assert-project' import { fixtures } from '@pnpm/test-fixtures' -import { loadJsonFileSync } from 'load-json-file' import PATH from 'path-name' import { sync as readYamlFile } from 'read-yaml-file' import { writePackageSync } from 'write-package' -import { type PnpmError } from '@pnpm/error' import { jest } from '@jest/globals' import { sync as writeYamlFile } from 'write-yaml-file' import { DEFAULT_OPTS } from './utils/index.js' @@ -33,32 +31,18 @@ test('linking multiple packages', async () => { const project = prepare() process.chdir('..') - const globalDir = path.resolve('global') writePackageSync('linked-foo', { name: 'linked-foo', version: '1.0.0' }) writePackageSync('linked-bar', { name: 'linked-bar', version: '1.0.0', dependencies: { 'is-positive': '1.0.0' } }) fs.writeFileSync('linked-bar/.npmrc', 'shamefully-hoist = true') - process.chdir('linked-foo') - - // linking linked-foo to global package - await link.handler({ - ...DEFAULT_OPTS, - bin: path.join(globalDir, 'bin'), - dir: globalDir, - globalPkgDir: globalDir, - rootProjectManifestDir: globalDir, - }) - - process.chdir('..') process.chdir('project') await link.handler({ ...DEFAULT_OPTS, dir: process.cwd(), - globalPkgDir: globalDir, rootProjectManifestDir: process.cwd(), - }, ['linked-foo', '../linked-bar']) + }, ['../linked-foo', '../linked-bar']) project.has('linked-foo') project.has('linked-bar') @@ -77,7 +61,7 @@ test('link global bin', async function () { writePackageSync('package-with-bin', { name: 'package-with-bin', version: '1.0.0', bin: 'bin.js' }) fs.writeFileSync('package-with-bin/bin.js', '#!/usr/bin/env node\nconsole.log(/hi/)\n', 'utf8') - process.chdir('package-with-bin') + const pkgWithBinDir = path.resolve('package-with-bin') await link.handler({ ...DEFAULT_OPTS, @@ -85,7 +69,7 @@ test('link global bin', async function () { dir: globalDir, globalPkgDir: globalDir, rootProjectManifestDir: globalDir, - }) + }, [pkgWithBinDir]) process.env[PATH] = oldPath isExecutable((value) => { @@ -93,51 +77,6 @@ test('link global bin', async function () { }, path.join(globalBin, 'package-with-bin')) }) -test('link a global package to the specified directory', async function () { - const project = prepare({ dependencies: { 'global-package-with-bin': '0.0.0' } }) - process.chdir('..') - - const globalDir = path.resolve('global') - const globalBin = path.join(globalDir, 'bin') - const oldPath = process.env[PATH] - process.env[PATH] = `${globalBin}${path.delimiter}${oldPath ?? ''}` - fs.mkdirSync(globalBin, { recursive: true }) - - writePackageSync('global-package-with-bin', { name: 'global-package-with-bin', version: '1.0.0', bin: 'bin.js' }) - fs.writeFileSync('global-package-with-bin/bin.js', '#!/usr/bin/env node\nconsole.log(/hi/)\n', 'utf8') - - process.chdir('global-package-with-bin') - - // link to global - await link.handler({ - ...DEFAULT_OPTS, - bin: globalBin, - dir: globalDir, - globalPkgDir: globalDir, - rootProjectManifestDir: globalDir, - }) - - process.chdir('..') - const projectDir = path.resolve('./project') - - // link from global - await link.handler({ - ...DEFAULT_OPTS, - // bin: globalBin, - dir: projectDir, - saveProd: true, // @pnpm/config sets this setting to true when global is true. This should probably be changed. - globalPkgDir: globalDir, - rootProjectManifest: { dependencies: { 'global-package-with-bin': '0.0.0' } }, - rootProjectManifestDir: projectDir, - }, ['global-package-with-bin']) - - process.env[PATH] = oldPath - - const manifest = loadJsonFileSync(path.join(projectDir, 'package.json')) // eslint-disable-line @typescript-eslint/no-explicit-any - expect(manifest.dependencies).toStrictEqual({ 'global-package-with-bin': '0.0.0' }) - project.has('global-package-with-bin') -}) - test('relative link', async () => { const project = prepare({ dependencies: { @@ -274,7 +213,6 @@ test('logger warns about peer dependencies when linking', async () => { prepare() process.chdir('..') - const globalDir = path.resolve('global') writePackageSync('linked-with-peer-deps', { name: 'linked-with-peer-deps', @@ -284,25 +222,13 @@ test('logger warns about peer dependencies when linking', async () => { }, }) - process.chdir('linked-with-peer-deps') - - await link.handler({ - ...DEFAULT_OPTS, - bin: path.join(globalDir, 'bin'), - dir: globalDir, - globalPkgDir: globalDir, - rootProjectManifestDir: globalDir, - }) - - process.chdir('..') process.chdir('project') await link.handler({ ...DEFAULT_OPTS, dir: process.cwd(), - globalPkgDir: globalDir, - rootProjectManifestDir: globalDir, - }, ['linked-with-peer-deps']) + rootProjectManifestDir: process.cwd(), + }, ['../linked-with-peer-deps']) expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('has the following peerDependencies specified in its package.json'), @@ -315,7 +241,6 @@ test('logger should not warn about peer dependencies when it is an empty object' prepare() process.chdir('..') - const globalDir = path.resolve('global') writePackageSync('linked-with-empty-peer-deps', { name: 'linked-with-empty-peer-deps', @@ -323,25 +248,13 @@ test('logger should not warn about peer dependencies when it is an empty object' peerDependencies: {}, }) - process.chdir('linked-with-empty-peer-deps') - - await link.handler({ - ...DEFAULT_OPTS, - globalPkgDir: '', - bin: path.join(globalDir, 'bin'), - dir: globalDir, - rootProjectManifestDir: globalDir, - }) - - process.chdir('..') process.chdir('project') await link.handler({ ...DEFAULT_OPTS, - globalPkgDir: globalDir, dir: process.cwd(), rootProjectManifestDir: process.cwd(), - }, ['linked-with-empty-peer-deps']) + }, ['../linked-with-empty-peer-deps']) expect(logger.warn).not.toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('has the following peerDependencies specified in its package.json'), @@ -350,28 +263,6 @@ test('logger should not warn about peer dependencies when it is an empty object' jest.mocked(logger.warn).mockRestore() }) -test('link: fail when global bin directory is not found', async () => { - prepare() - - const globalDir = path.resolve('global') - - let err!: PnpmError - try { - await link.handler({ - ...DEFAULT_OPTS, - bin: undefined as any, // eslint-disable-line @typescript-eslint/no-explicit-any - dir: globalDir, - globalPkgDir: globalDir, - cliOptions: { - global: true, - }, - }) - } catch (_err: any) { // eslint-disable-line @typescript-eslint/no-explicit-any - err = _err - } - expect(err.code).toBe('ERR_PNPM_NO_GLOBAL_BIN_DIR') -}) - test('relative link from workspace package', async () => { prepareEmpty() diff --git a/pkg-manager/plugin-commands-installation/tsconfig.json b/pkg-manager/plugin-commands-installation/tsconfig.json index bd34855eeb..ecc2806c52 100644 --- a/pkg-manager/plugin-commands-installation/tsconfig.json +++ b/pkg-manager/plugin-commands-installation/tsconfig.json @@ -63,6 +63,9 @@ { "path": "../../fs/read-modules-dir" }, + { + "path": "../../global/commands" + }, { "path": "../../hooks/pnpmfile" }, diff --git a/pkg-manifest/read-package-json/src/index.ts b/pkg-manifest/read-package-json/src/index.ts index 7edc6c65d3..44652c2f75 100644 --- a/pkg-manifest/read-package-json/src/index.ts +++ b/pkg-manifest/read-package-json/src/index.ts @@ -1,4 +1,5 @@ import path from 'path' +import util from 'util' import { PnpmError } from '@pnpm/error' import { type PackageManifest } from '@pnpm/types' import { loadJsonFile, loadJsonFileSync } from 'load-json-file' @@ -46,3 +47,12 @@ export async function safeReadPackageJson (pkgPath: string): Promise { return safeReadPackageJson(path.join(pkgPath, 'package.json')) } + +export function readPackageJsonFromDirRawSync (pkgPath: string): PackageManifest { + try { + return loadJsonFileSync(path.join(pkgPath, 'package.json')) + } catch (err: unknown) { + if (util.types.isNativeError(err) && 'code' in err) throw err + throw new PnpmError('BAD_PACKAGE_JSON', `${pkgPath}: ${err instanceof Error ? err.message : String(err)}`) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22c79e6e24..67c4cb4bd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2419,6 +2419,9 @@ importers: '@pnpm/dependency-path': specifier: workspace:* version: link:../../packages/dependency-path + '@pnpm/error': + specifier: workspace:* + version: link:../../packages/error '@pnpm/logger': specifier: 'catalog:' version: 1001.0.1 @@ -2450,9 +2453,6 @@ importers: '@pnpm/exec.build-commands': specifier: workspace:* version: 'link:' - '@pnpm/plugin-commands-installation': - specifier: workspace:* - version: link:../../pkg-manager/plugin-commands-installation '@pnpm/prepare': specifier: workspace:* version: link:../../__utils__/prepare @@ -2465,6 +2465,9 @@ importers: '@types/ramda': specifier: 'catalog:' version: 0.29.12 + execa: + specifier: 'catalog:' + version: safe-execa@0.2.0 load-json-file: specifier: 'catalog:' version: 7.0.1 @@ -3478,6 +3481,83 @@ importers: specifier: workspace:* version: 'link:' + global/commands: + dependencies: + '@pnpm/cli-utils': + specifier: workspace:* + version: link:../../cli/cli-utils + '@pnpm/config': + specifier: workspace:* + version: link:../../config/config + '@pnpm/core': + specifier: workspace:* + version: link:../../pkg-manager/core + '@pnpm/error': + specifier: workspace:* + version: link:../../packages/error + '@pnpm/exec.build-commands': + specifier: workspace:* + version: link:../../exec/build-commands + '@pnpm/global.packages': + specifier: workspace:* + version: link:../packages + '@pnpm/link-bins': + specifier: workspace:* + version: link:../../pkg-manager/link-bins + '@pnpm/matcher': + specifier: workspace:* + version: link:../../config/matcher + '@pnpm/package-bins': + specifier: workspace:* + version: link:../../pkg-manager/package-bins + '@pnpm/read-package-json': + specifier: workspace:* + version: link:../../pkg-manifest/read-package-json + '@pnpm/remove-bins': + specifier: workspace:* + version: link:../../pkg-manager/remove-bins + '@pnpm/store-connection-manager': + specifier: workspace:* + version: link:../../store/store-connection-manager + '@pnpm/types': + specifier: workspace:* + version: link:../../packages/types + '@pnpm/util.lex-comparator': + specifier: 'catalog:' + version: 3.0.2 + is-subdir: + specifier: 'catalog:' + version: 1.2.0 + symlink-dir: + specifier: 'catalog:' + version: 7.1.0 + devDependencies: + '@pnpm/global.commands': + specifier: workspace:* + version: 'link:' + + global/packages: + dependencies: + '@pnpm/crypto.hash': + specifier: workspace:* + version: link:../../crypto/hash + '@pnpm/package-bins': + specifier: workspace:* + version: link:../../pkg-manager/package-bins + '@pnpm/read-package-json': + specifier: workspace:* + version: link:../../pkg-manifest/read-package-json + '@pnpm/types': + specifier: workspace:* + version: link:../../packages/types + '@pnpm/util.lex-comparator': + specifier: 'catalog:' + version: 3.0.2 + devDependencies: + '@pnpm/global.packages': + specifier: workspace:* + version: 'link:' + hooks/pnpmfile: dependencies: '@pnpm/core-loggers': @@ -5834,6 +5914,9 @@ importers: '@pnpm/get-context': specifier: workspace:* version: link:../get-context + '@pnpm/global.commands': + specifier: workspace:* + version: link:../../global/commands '@pnpm/graceful-fs': specifier: workspace:* version: link:../../fs/graceful-fs @@ -7945,6 +8028,9 @@ importers: '@pnpm/error': specifier: workspace:* version: link:../../packages/error + '@pnpm/global.commands': + specifier: workspace:* + version: link:../../global/commands '@pnpm/list': specifier: workspace:* version: link:../list @@ -8018,6 +8104,9 @@ importers: '@pnpm/error': specifier: workspace:* version: link:../../packages/error + '@pnpm/global.packages': + specifier: workspace:* + version: link:../../global/packages '@pnpm/lockfile.fs': specifier: workspace:* version: link:../../lockfile/fs @@ -8079,6 +8168,9 @@ importers: '@types/zkochan__table': specifier: 'catalog:' version: '@types/table@6.0.0' + symlink-dir: + specifier: 'catalog:' + version: 7.1.0 reviewing/plugin-commands-sbom: dependencies: @@ -8443,6 +8535,9 @@ importers: '@pnpm/get-context': specifier: workspace:* version: link:../../pkg-manager/get-context + '@pnpm/global.packages': + specifier: workspace:* + version: link:../../global/packages '@pnpm/lockfile.utils': specifier: workspace:* version: link:../../lockfile/utils diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4d286ace99..d9c94733b5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,6 +17,7 @@ packages: - exec/* - fetching/* - fs/* + - global/* - hooks/* - lockfile/* - network/* diff --git a/pnpm/src/cmd/root.ts b/pnpm/src/cmd/root.ts index 51208f9c5f..ee037cd40f 100644 --- a/pnpm/src/cmd/root.ts +++ b/pnpm/src/cmd/root.ts @@ -23,7 +23,7 @@ export function help (): string { list: [ { - description: 'Print the global `node_modules` directory', + description: 'Print the global packages directory', name: '--global', shortAlias: '-g', }, @@ -38,7 +38,11 @@ export function help (): string { export async function handler ( opts: { dir: string + global?: boolean } ): Promise { + if (opts.global) { + return `${opts.dir}\n` + } return `${path.join(opts.dir, 'node_modules')}\n` } diff --git a/pnpm/test/install/global.ts b/pnpm/test/install/global.ts index ee4042bc92..1aeb2ba43f 100644 --- a/pnpm/test/install/global.ts +++ b/pnpm/test/install/global.ts @@ -1,17 +1,53 @@ import path from 'path' import PATH_NAME from 'path-name' import fs from 'fs' -import { LAYOUT_VERSION } from '@pnpm/constants' import { prepare } from '@pnpm/prepare' import { type ProjectManifest } from '@pnpm/types' import isWindows from 'is-windows' -import writeYamlFile from 'write-yaml-file' +import { GLOBAL_LAYOUT_VERSION } from '@pnpm/constants' import { addDistTag, execPnpm, execPnpmSync, } from '../utils/index.js' +function globalPkgDir (pnpmHome: string): string { + return path.join(pnpmHome, 'global', GLOBAL_LAYOUT_VERSION) +} + +/** + * Find an installed global package in the flat isolated directory structure. + * Scans globalDir for hash symlinks, resolves them, + * and returns the path to the package's node_modules entry. + */ +function findGlobalPkg (globalDir: string, pkgName: string): string | null { + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(globalDir, { withFileTypes: true }) + } catch { + return null + } + for (const entry of entries) { + if (!entry.isSymbolicLink()) continue + let installDir: string + try { + installDir = fs.realpathSync(path.join(globalDir, entry.name)) + } catch { + continue + } + let pkgJson: { dependencies?: Record } + try { + pkgJson = JSON.parse(fs.readFileSync(path.join(installDir, 'package.json'), 'utf-8')) + } catch { + continue + } + if (pkgJson.dependencies?.[pkgName]) { + return path.join(installDir, 'node_modules', pkgName) + } + } + return null +} + test('global installation', async () => { prepare() const global = path.resolve('..', 'global') @@ -20,18 +56,20 @@ test('global installation', async () => { const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global } - await execPnpm(['install', '--global', 'is-positive'], { env }) + await execPnpm(['add', '--global', 'is-positive'], { env }) // there was an issue when subsequent installations were removing everything installed prior // https://github.com/pnpm/pnpm/issues/808 - await execPnpm(['install', '--global', 'is-negative'], { env }) + await execPnpm(['add', '--global', 'is-negative'], { env }) - const globalPrefix = path.join(global, `pnpm/global/${LAYOUT_VERSION}`) - - const { default: isPositive } = await import(path.join(globalPrefix, 'node_modules', 'is-positive')) + const isPositivePath = findGlobalPkg(globalPkgDir(pnpmHome), 'is-positive') + expect(isPositivePath).toBeTruthy() + const { default: isPositive } = await import(isPositivePath!) expect(typeof isPositive).toBe('function') - const { default: isNegative } = await import(path.join(globalPrefix, 'node_modules', 'is-negative')) + const isNegativePath = findGlobalPkg(globalPkgDir(pnpmHome), 'is-negative') + expect(isNegativePath).toBeTruthy() + const { default: isNegative } = await import(isNegativePath!) expect(typeof isNegative).toBe('function') }) @@ -49,7 +87,7 @@ test('global install warns when project has packageManager configured', async () const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global } const { status } = execPnpmSync([ - 'install', + 'add', '--global', 'is-positive', '--config.package-manager-strict=true', @@ -66,7 +104,9 @@ test('global installation to custom directory with --global-dir', async () => { await execPnpm(['add', '--global', '--global-dir=../global', 'is-positive'], { env }) - const { default: isPositive } = await import(path.resolve(`../global/${LAYOUT_VERSION}/node_modules/is-positive`)) + const isPositivePath = findGlobalPkg(path.join(global, 'v11'), 'is-positive') + expect(isPositivePath).toBeTruthy() + const { default: isPositive } = await import(isPositivePath!) expect(typeof isPositive).toBe('function') }) @@ -80,14 +120,12 @@ test('always install latest when doing global installation without spec', async const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global } - await execPnpm(['install', '-g', '@pnpm.e2e/peer-c@1'], { env }) - await execPnpm(['install', '-g', '@pnpm.e2e/peer-c'], { env }) + await execPnpm(['add', '-g', '@pnpm.e2e/peer-c@1'], { env }) + await execPnpm(['add', '-g', '@pnpm.e2e/peer-c'], { env }) - const globalPrefix = path.join(global, `pnpm/global/${LAYOUT_VERSION}`) - - process.chdir(globalPrefix) - - expect((await import(path.resolve('node_modules', '@pnpm.e2e/peer-c', 'package.json'))).default.version).toBe('2.0.0') + const peerCPath = findGlobalPkg(globalPkgDir(pnpmHome), '@pnpm.e2e/peer-c') + expect(peerCPath).toBeTruthy() + expect((await import(path.join(peerCPath!, 'package.json'))).default.version).toBe('2.0.0') }) test('run lifecycle events of global packages in correct working directory', async () => { @@ -99,14 +137,7 @@ test('run lifecycle events of global packages in correct working directory', asy prepare() const global = path.resolve('..', 'global') const pnpmHome = path.join(global, 'pnpm') - const globalPkgDir = path.join(pnpmHome, 'global', String(LAYOUT_VERSION)) - fs.mkdirSync(globalPkgDir, { recursive: true }) - fs.writeFileSync(path.join(globalPkgDir, 'package.json'), JSON.stringify({})) - writeYamlFile.sync(path.join(globalPkgDir, 'pnpm-workspace.yaml'), { - allowBuilds: { - '@pnpm.e2e/postinstall-calls-pnpm': true, - }, - }) + fs.mkdirSync(pnpmHome, { recursive: true }) const env = { [PATH_NAME]: `${pnpmHome}${path.delimiter}${process.env[PATH_NAME]!}`, @@ -114,9 +145,11 @@ test('run lifecycle events of global packages in correct working directory', asy XDG_DATA_HOME: global, } - await execPnpm(['install', '-g', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env }) + await execPnpm(['add', '-g', '--allow-build=@pnpm.e2e/postinstall-calls-pnpm', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env }) - expect(fs.existsSync(path.join(globalPkgDir, 'node_modules/@pnpm.e2e/postinstall-calls-pnpm/created-by-postinstall'))).toBeTruthy() + const pkgPath = findGlobalPkg(globalPkgDir(pnpmHome), '@pnpm.e2e/postinstall-calls-pnpm') + expect(pkgPath).toBeTruthy() + expect(fs.existsSync(path.join(pkgPath!, 'created-by-postinstall'))).toBeTruthy() }) // CONTEXT: dangerously-allow-all-builds has been removed from rc files, as a result, this test no longer applies @@ -142,7 +175,7 @@ test.skip('dangerously-allow-all-builds=true in global config', async () => { const pnpmRcFile = path.join(pnpmCfgDir, 'rc') const global = path.resolve('..', 'global') const pnpmHome = path.join(global, 'pnpm') - const globalPkgDir = path.join(pnpmHome, 'global', String(LAYOUT_VERSION)) + const globalDir = globalPkgDir(pnpmHome) fs.mkdirSync(pnpmCfgDir, { recursive: true }) fs.writeFileSync(pnpmRcFile, [ 'reporter=append-only', @@ -158,8 +191,8 @@ test.skip('dangerously-allow-all-builds=true in global config', async () => { } // global install should run scripts - await execPnpm(['install', '-g', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env }) - expect(fs.readdirSync(path.join(globalPkgDir, 'node_modules/@pnpm.e2e/postinstall-calls-pnpm'))).toContain('created-by-postinstall') + await execPnpm(['add', '-g', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env }) + expect(fs.readdirSync(path.join(globalDir, 'node_modules/@pnpm.e2e/postinstall-calls-pnpm'))).toContain('created-by-postinstall') // local config should override global config await execPnpm(['add', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env }) @@ -197,7 +230,7 @@ test.skip('dangerously-allow-all-builds=false in global config', async () => { const pnpmRcFile = path.join(pnpmCfgDir, 'rc') const global = path.resolve('..', 'global') const pnpmHome = path.join(global, 'pnpm') - const globalPkgDir = path.join(pnpmHome, 'global', String(LAYOUT_VERSION)) + const globalDir = globalPkgDir(pnpmHome) fs.mkdirSync(pnpmCfgDir, { recursive: true }) fs.writeFileSync(pnpmRcFile, [ 'reporter=append-only', @@ -213,8 +246,8 @@ test.skip('dangerously-allow-all-builds=false in global config', async () => { } // global install should run scripts - await execPnpm(['install', '-g', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env }) - expect(fs.readdirSync(path.join(globalPkgDir, 'node_modules/@pnpm.e2e/postinstall-calls-pnpm'))).not.toContain('created-by-postinstall') + await execPnpm(['add', '-g', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env }) + expect(fs.readdirSync(path.join(globalDir, 'node_modules/@pnpm.e2e/postinstall-calls-pnpm'))).not.toContain('created-by-postinstall') // local config should override global config await execPnpm(['add', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env }) @@ -237,13 +270,13 @@ test('global update to latest', async () => { const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global } - await execPnpm(['install', '--global', 'is-positive@1'], { env }) + await execPnpm(['add', '--global', 'is-positive@1'], { env }) await execPnpm(['update', '--global', '--latest'], { env }) - const globalPrefix = path.join(global, `pnpm/global/${LAYOUT_VERSION}`) - - const { default: isPositive } = await import(path.join(globalPrefix, 'node_modules/is-positive/package.json')) - expect(isPositive.version).toBe('3.1.0') + const isPositivePath = findGlobalPkg(globalPkgDir(pnpmHome), 'is-positive') + expect(isPositivePath).toBeTruthy() + const pkgJson = JSON.parse(fs.readFileSync(path.join(isPositivePath!, 'package.json'), 'utf-8')) + expect(pkgJson.version).toBe('3.1.0') }) test('global update should not crash if there are no global packages', async () => { @@ -256,3 +289,123 @@ test('global update should not crash if there are no global packages', async () expect(execPnpmSync(['update', '--global'], { env }).status).toBe(0) }) + +test('global add cleans up stale bins when re-adding a package with different bins', async () => { + prepare() + const global = path.resolve('..', 'global') + const pnpmHome = path.join(global, 'pnpm') + fs.mkdirSync(pnpmHome, { recursive: true }) + + const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global } + + // Create v1 tarball with bin "old-bin" + const pkgDir = path.resolve('..', 'my-tool') + fs.mkdirSync(path.join(pkgDir, 'package'), { recursive: true }) + fs.writeFileSync(path.join(pkgDir, 'package', 'package.json'), JSON.stringify({ + name: 'my-tool', + version: '1.0.0', + bin: { 'old-bin': './index.js' }, + })) + fs.writeFileSync(path.join(pkgDir, 'package', 'index.js'), '#!/usr/bin/env node\nconsole.log("v1")\n') + const tarballV1 = path.join(pkgDir, 'my-tool-1.0.0.tgz') + execPnpmSync(['pack', '--pack-destination', pkgDir], { cwd: path.join(pkgDir, 'package') }) + + await execPnpm(['add', '-g', tarballV1], { env }) + expect(fs.existsSync(path.join(pnpmHome, 'old-bin'))).toBeTruthy() + + // Create v2 tarball with bin "new-bin" + fs.writeFileSync(path.join(pkgDir, 'package', 'package.json'), JSON.stringify({ + name: 'my-tool', + version: '2.0.0', + bin: { 'new-bin': './index.js' }, + })) + const tarballV2 = path.join(pkgDir, 'my-tool-2.0.0.tgz') + execPnpmSync(['pack', '--pack-destination', pkgDir], { cwd: path.join(pkgDir, 'package') }) + + // Re-add the same package from new tarball — old bins should be cleaned up + await execPnpm(['add', '-g', tarballV2], { env }) + + // old-bin should be gone, new-bin should exist + expect(fs.existsSync(path.join(pnpmHome, 'old-bin'))).toBeFalsy() + expect(fs.existsSync(path.join(pnpmHome, 'new-bin'))).toBeTruthy() +}) + +test('global add refuses to install when bin name conflicts with another global package', async () => { + prepare() + const global = path.resolve('..', 'global') + const pnpmHome = path.join(global, 'pnpm') + fs.mkdirSync(pnpmHome, { recursive: true }) + + const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global } + + // Create two local packages that both expose a bin called "my-bin" + const pkgA = path.resolve('..', 'pkg-a') + fs.mkdirSync(pkgA, { recursive: true }) + fs.writeFileSync(path.join(pkgA, 'package.json'), JSON.stringify({ + name: 'pkg-a', + version: '1.0.0', + bin: { 'my-bin': './index.js' }, + })) + fs.writeFileSync(path.join(pkgA, 'index.js'), '#!/usr/bin/env node\nconsole.log("a")\n') + + const pkgB = path.resolve('..', 'pkg-b') + fs.mkdirSync(pkgB, { recursive: true }) + fs.writeFileSync(path.join(pkgB, 'package.json'), JSON.stringify({ + name: 'pkg-b', + version: '1.0.0', + bin: { 'my-bin': './index.js' }, + })) + fs.writeFileSync(path.join(pkgB, 'index.js'), '#!/usr/bin/env node\nconsole.log("b")\n') + + // Install pkg-a globally — should succeed + await execPnpm(['add', '-g', pkgA], { env }) + expect(findGlobalPkg(globalPkgDir(pnpmHome), 'pkg-a')).toBeTruthy() + + // Install pkg-b globally — should fail due to bin conflict + const result = execPnpmSync(['add', '-g', pkgB], { env }) + expect(result.status).not.toBe(0) + expect(result.stdout.toString()).toContain('ERR_PNPM_GLOBAL_BIN_CONFLICT') + + // pkg-a should still be installed + expect(findGlobalPkg(globalPkgDir(pnpmHome), 'pkg-a')).toBeTruthy() +}) + +test('global remove deletes install group and bin shims', async () => { + prepare() + const global = path.resolve('..', 'global') + const pnpmHome = path.join(global, 'pnpm') + fs.mkdirSync(pnpmHome, { recursive: true }) + + const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global } + + // Create two packages with bins and install them together as a group + const pkgA = path.resolve('..', 'tool-a') + fs.mkdirSync(pkgA, { recursive: true }) + fs.writeFileSync(path.join(pkgA, 'package.json'), JSON.stringify({ + name: 'tool-a', + version: '1.0.0', + bin: { 'tool-a-bin': './index.js' }, + })) + fs.writeFileSync(path.join(pkgA, 'index.js'), '#!/usr/bin/env node\nconsole.log("a")\n') + + const pkgB = path.resolve('..', 'tool-b') + fs.mkdirSync(pkgB, { recursive: true }) + fs.writeFileSync(path.join(pkgB, 'package.json'), JSON.stringify({ + name: 'tool-b', + version: '1.0.0', + bin: { 'tool-b-bin': './index.js' }, + })) + fs.writeFileSync(path.join(pkgB, 'index.js'), '#!/usr/bin/env node\nconsole.log("b")\n') + + // Install as a group + await execPnpm(['add', '-g', pkgA, pkgB], { env }) + expect(fs.existsSync(path.join(pnpmHome, 'tool-a-bin'))).toBeTruthy() + expect(fs.existsSync(path.join(pnpmHome, 'tool-b-bin'))).toBeTruthy() + + // Remove one package — entire group (both bins) should be removed + await execPnpm(['remove', '-g', 'tool-a'], { env }) + expect(fs.existsSync(path.join(pnpmHome, 'tool-a-bin'))).toBeFalsy() + expect(fs.existsSync(path.join(pnpmHome, 'tool-b-bin'))).toBeFalsy() + expect(findGlobalPkg(globalPkgDir(pnpmHome), 'tool-a')).toBeNull() + expect(findGlobalPkg(globalPkgDir(pnpmHome), 'tool-b')).toBeNull() +}) diff --git a/pnpm/test/link.ts b/pnpm/test/link.ts deleted file mode 100644 index e3469bd049..0000000000 --- a/pnpm/test/link.ts +++ /dev/null @@ -1,58 +0,0 @@ -import path from 'path' -import PATH_NAME from 'path-name' -import fs from 'fs' -import { isExecutable } from '@pnpm/assert-project' -import { LAYOUT_VERSION } from '@pnpm/constants' -import { prepare, preparePackages } from '@pnpm/prepare' -import { sync as writeYamlFile } from 'write-yaml-file' -import { execPnpm } from './utils/index.js' - -const testLinkGlobal = (specifyGlobalOption: boolean) => async () => { - prepare() - fs.mkdirSync('cmd') - process.chdir('cmd') - fs.writeFileSync('package.json', JSON.stringify({ bin: { cmd: 'bin.js' } }), 'utf8') - fs.writeFileSync('bin.js', `#!/usr/bin/env node -console.log("hello world");`, 'utf8') - - const global = path.resolve('..', 'global') - const pnpmHome = path.join(global, 'pnpm') - fs.mkdirSync(global) - - const args = specifyGlobalOption ? ['link', '--global'] : ['link'] - const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global } - await execPnpm(args, { env }) - - const globalPrefix = path.join(global, `pnpm/global/${LAYOUT_VERSION}`) - expect(fs.existsSync(path.join(globalPrefix, 'node_modules/cmd'))).toBeTruthy() - const ok = (value: any) => { // eslint-disable-line - expect(value).toBeTruthy() - } - isExecutable(ok, path.join(pnpmHome, 'cmd')) -} - -test('link globally the command of a package that has no name in package.json', testLinkGlobal(true)) - -test('link a package globally without specifying the global option', testLinkGlobal(false)) - -test('link a package from a workspace to the global package', async () => { - preparePackages([ - { - name: 'project-1', - version: '1.0.0', - }, - ]) - const global = path.resolve('..', 'global') - const pnpmHome = path.join(global, 'pnpm') - fs.mkdirSync(global) - const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global } - - writeYamlFile('pnpm-workspace.yaml', { packages: ['**', '!store/**'] }) - - process.chdir('project-1') - - await execPnpm(['link'], { env }) - - const globalPrefix = path.join(global, `pnpm/global/${LAYOUT_VERSION}`) - expect(fs.existsSync(path.join(globalPrefix, 'node_modules/project-1'))).toBeTruthy() -}) diff --git a/pnpm/test/root.ts b/pnpm/test/root.ts index 830a7c469b..d555799c87 100644 --- a/pnpm/test/root.ts +++ b/pnpm/test/root.ts @@ -1,7 +1,7 @@ import fs from 'fs' import path from 'path' import PATH_NAME from 'path-name' -import { LAYOUT_VERSION } from '@pnpm/constants' +import { GLOBAL_LAYOUT_VERSION } from '@pnpm/constants' import { tempDir } from '@pnpm/prepare' import { execPnpmSync } from './utils/index.js' @@ -28,5 +28,5 @@ test('pnpm root -g', async () => { const result = execPnpmSync(['root', '-g'], { env }) expect(result.status).toBe(0) - expect(result.stdout.toString()).toBe(path.join(global, `pnpm/global/${LAYOUT_VERSION}/node_modules`) + '\n') + expect(result.stdout.toString()).toBe(path.join(global, `pnpm/global/${GLOBAL_LAYOUT_VERSION}`) + '\n') }) diff --git a/reviewing/plugin-commands-listing/package.json b/reviewing/plugin-commands-listing/package.json index e5b5763a02..16f15b0759 100644 --- a/reviewing/plugin-commands-listing/package.json +++ b/reviewing/plugin-commands-listing/package.json @@ -35,6 +35,7 @@ "@pnpm/common-cli-options-help": "workspace:*", "@pnpm/config": "workspace:*", "@pnpm/error": "workspace:*", + "@pnpm/global.commands": "workspace:*", "@pnpm/list": "workspace:*", "@pnpm/types": "workspace:*", "ramda": "catalog:", diff --git a/reviewing/plugin-commands-listing/src/list.ts b/reviewing/plugin-commands-listing/src/list.ts index 077adbe12b..41e7f6e5b1 100644 --- a/reviewing/plugin-commands-listing/src/list.ts +++ b/reviewing/plugin-commands-listing/src/list.ts @@ -1,6 +1,7 @@ import { docsUrl } from '@pnpm/cli-utils' import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help' import { type Config, types as allTypes } from '@pnpm/config' +import { listGlobalPackages } from '@pnpm/global.commands' import { list, listForPackages } from '@pnpm/list' import { type Finder, type IncludedDependencies } from '@pnpm/types' import { pick } from 'ramda' @@ -101,12 +102,15 @@ export type ListCommandOptions = Pick> export async function handler ( opts: ListCommandOptions, params: string[] ): Promise { + if (opts.global && opts.globalPkgDir) { + return listGlobalPackages(opts.globalPkgDir, params) + } const include = computeInclude(opts) const depth = opts.cliOptions?.['depth'] ?? 0 if (opts.recursive && (opts.selectedProjectsGraph != null)) { diff --git a/reviewing/plugin-commands-listing/tsconfig.json b/reviewing/plugin-commands-listing/tsconfig.json index 48da3e07ba..b6a5027d6b 100644 --- a/reviewing/plugin-commands-listing/tsconfig.json +++ b/reviewing/plugin-commands-listing/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../../config/config" }, + { + "path": "../../global/commands" + }, { "path": "../../packages/constants" }, diff --git a/reviewing/plugin-commands-outdated/package.json b/reviewing/plugin-commands-outdated/package.json index 7dde9a6cd8..e779cce338 100644 --- a/reviewing/plugin-commands-outdated/package.json +++ b/reviewing/plugin-commands-outdated/package.json @@ -39,6 +39,7 @@ "@pnpm/config": "workspace:*", "@pnpm/default-resolver": "workspace:*", "@pnpm/error": "workspace:*", + "@pnpm/global.packages": "workspace:*", "@pnpm/lockfile.fs": "workspace:*", "@pnpm/matcher": "workspace:*", "@pnpm/modules-yaml": "workspace:*", @@ -60,7 +61,8 @@ "@pnpm/test-fixtures": "workspace:*", "@pnpm/workspace.filter-packages-from-dir": "workspace:*", "@types/ramda": "catalog:", - "@types/zkochan__table": "catalog:" + "@types/zkochan__table": "catalog:", + "symlink-dir": "catalog:" }, "engines": { "node": ">=22.13" diff --git a/reviewing/plugin-commands-outdated/src/outdated.ts b/reviewing/plugin-commands-outdated/src/outdated.ts index cbef1050bb..b822d68287 100644 --- a/reviewing/plugin-commands-outdated/src/outdated.ts +++ b/reviewing/plugin-commands-outdated/src/outdated.ts @@ -9,12 +9,13 @@ import { type CompletionFunc } from '@pnpm/command' import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help' import { type Config, types as allTypes } from '@pnpm/config' import { PnpmError } from '@pnpm/error' +import { scanGlobalPackages } from '@pnpm/global.packages' import { outdatedDepsOfProjects, type OutdatedPackage, } from '@pnpm/outdated' import semverDiff from '@pnpm/semver-diff' -import { type DependenciesField, type PackageManifest, type ProjectRootDir } from '@pnpm/types' +import { type DependenciesField, type PackageManifest, type ProjectManifest, type ProjectRootDir } from '@pnpm/types' import { table } from '@zkochan/table' import chalk from 'chalk' import { pick, sortWith } from 'ramda' @@ -169,7 +170,7 @@ export type OutdatedCommandOptions = { | 'tag' | 'userAgent' | 'updateConfig' -> & Partial> +> & Partial> export async function handler ( opts: OutdatedCommandOptions, @@ -184,14 +185,25 @@ export async function handler ( const pkgs = Object.values(opts.selectedProjectsGraph).map((wsPkg) => wsPkg.package) return outdatedRecursive(pkgs, params, { ...opts, include }) } - const manifest = await readProjectManifestOnly(opts.dir, opts) - const packages = [ - { - rootDir: opts.dir as ProjectRootDir, - manifest, - }, - ] - const [outdatedPackages] = await outdatedDepsOfProjects(packages, params, { + let packages: Array<{ rootDir: ProjectRootDir, manifest: ProjectManifest }> + if (opts.global && opts.globalPkgDir) { + const globalPackages = scanGlobalPackages(opts.globalPkgDir) + packages = await Promise.all( + globalPackages.map(async (pkg) => ({ + rootDir: pkg.installDir as ProjectRootDir, + manifest: await readProjectManifestOnly(pkg.installDir, opts), + })) + ) + } else { + const manifest = await readProjectManifestOnly(opts.dir, opts) + packages = [ + { + rootDir: opts.dir as ProjectRootDir, + manifest, + }, + ] + } + const outdatedPerProject = await outdatedDepsOfProjects(packages, params, { ...opts, fullMetadata: opts.long, ignoreDependencies: opts.updateConfig?.ignoreDependencies, @@ -206,6 +218,7 @@ export async function handler ( }, timeout: opts.fetchTimeout, }) + const outdatedPackages = outdatedPerProject.flat() let output!: string switch (opts.format ?? 'table') { diff --git a/reviewing/plugin-commands-outdated/test/index.ts b/reviewing/plugin-commands-outdated/test/index.ts index cf17211944..782ee9790b 100644 --- a/reviewing/plugin-commands-outdated/test/index.ts +++ b/reviewing/plugin-commands-outdated/test/index.ts @@ -8,6 +8,7 @@ import { prepare, tempDir } from '@pnpm/prepare' import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock' import { fixtures } from '@pnpm/test-fixtures' import { stripVTControlCharacters as stripAnsi } from 'util' +import symlinkDir from 'symlink-dir' const f = fixtures(import.meta.dirname) const hasOutdatedDepsFixture = f.find('has-outdated-deps') @@ -461,6 +462,50 @@ test('pnpm outdated: support --sortField option', async () => { `) }) +test('pnpm outdated -g: shows outdated global packages', async () => { + tempDir() + + // Set up a simulated global dir with one isolated package group + const globalPkgDir = path.resolve('global') + const installDir = path.join(globalPkgDir, 'install-1') + fs.mkdirSync(path.join(installDir, 'node_modules/.pnpm'), { recursive: true }) + fs.copyFileSync(path.join(hasOutdatedDepsFixture, 'node_modules/.pnpm/lock.yaml'), path.join(installDir, 'node_modules/.pnpm/lock.yaml')) + fs.copyFileSync(path.join(hasOutdatedDepsFixture, 'package.json'), path.join(installDir, 'package.json')) + + // Create symlink from a hash entry to the install dir (this is how scanGlobalPackages discovers packages) + symlinkDir.sync(installDir, path.join(globalPkgDir, 'abc123')) + + const { output, exitCode } = await outdated.handler({ + ...OUTDATED_OPTIONS, + dir: globalPkgDir, + global: true, + globalPkgDir, + format: 'json', + }) + + expect(exitCode).toBe(1) + const result = JSON.parse(stripAnsi(output)) + expect(result['is-negative']).toBeDefined() + expect(result['@pnpm.e2e/deprecated']).toBeDefined() +}) + +test('pnpm outdated -g: no outdated packages when global dir is empty', async () => { + tempDir() + + const globalPkgDir = path.resolve('global') + fs.mkdirSync(globalPkgDir, { recursive: true }) + + const { output, exitCode } = await outdated.handler({ + ...OUTDATED_OPTIONS, + dir: globalPkgDir, + global: true, + globalPkgDir, + }) + + expect(output).toBe('') + expect(exitCode).toBe(0) +}) + test('pnpm outdated --long with only deprecated packages', async () => { tempDir() diff --git a/reviewing/plugin-commands-outdated/tsconfig.json b/reviewing/plugin-commands-outdated/tsconfig.json index 2ac5aaa730..c14abd55cf 100644 --- a/reviewing/plugin-commands-outdated/tsconfig.json +++ b/reviewing/plugin-commands-outdated/tsconfig.json @@ -30,6 +30,9 @@ { "path": "../../config/matcher" }, + { + "path": "../../global/packages" + }, { "path": "../../lockfile/fs" }, diff --git a/store/plugin-commands-store/package.json b/store/plugin-commands-store/package.json index 982c9827a5..139dd3773a 100644 --- a/store/plugin-commands-store/package.json +++ b/store/plugin-commands-store/package.json @@ -39,6 +39,7 @@ "@pnpm/error": "workspace:*", "@pnpm/fs.msgpack-file": "workspace:*", "@pnpm/get-context": "workspace:*", + "@pnpm/global.packages": "workspace:*", "@pnpm/lockfile.utils": "workspace:*", "@pnpm/normalize-registries": "workspace:*", "@pnpm/parse-wanted-dependency": "workspace:*", diff --git a/store/plugin-commands-store/src/store.ts b/store/plugin-commands-store/src/store.ts index 546bf230c2..c5754e2a26 100644 --- a/store/plugin-commands-store/src/store.ts +++ b/store/plugin-commands-store/src/store.ts @@ -74,7 +74,7 @@ class StoreStatusError extends PnpmError { } } -export type StoreCommandOptions = Pick & CreateStoreControllerOptions & { +export type StoreCommandOptions = Pick & Partial> & CreateStoreControllerOptions & { reporter?: (logObj: LogBase) => void } diff --git a/store/plugin-commands-store/src/storePrune.ts b/store/plugin-commands-store/src/storePrune.ts index bad4528e34..2477042615 100644 --- a/store/plugin-commands-store/src/storePrune.ts +++ b/store/plugin-commands-store/src/storePrune.ts @@ -1,3 +1,4 @@ +import { cleanOrphanedInstallDirs } from '@pnpm/global.packages' import { streamParser } from '@pnpm/logger' import { type StoreController } from '@pnpm/store-controller-types' import { type ReporterFunction } from './types.js' @@ -10,6 +11,7 @@ export async function storePrune ( removeAlienFiles?: boolean cacheDir: string dlxCacheMaxAge: number + globalPkgDir?: string } ): Promise { const reporter = opts?.reporter @@ -25,6 +27,10 @@ export async function storePrune ( now: new Date(), }) + if (opts.globalPkgDir) { + cleanOrphanedInstallDirs(opts.globalPkgDir) + } + if ((reporter != null) && typeof reporter === 'function') { streamParser.removeListener('data', reporter) } diff --git a/store/plugin-commands-store/tsconfig.json b/store/plugin-commands-store/tsconfig.json index f548594f64..fa7e450e30 100644 --- a/store/plugin-commands-store/tsconfig.json +++ b/store/plugin-commands-store/tsconfig.json @@ -33,6 +33,9 @@ { "path": "../../fs/msgpack-file" }, + { + "path": "../../global/packages" + }, { "path": "../../lockfile/fs" },