feat: isolated global packages (#10697)

**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 <pkg>`
- `pnpm link <pkg-name>` 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
This commit is contained in:
Zoltan Kochan
2026-03-01 15:49:18 +01:00
committed by GitHub
parent aefd54879f
commit fd511e4676
56 changed files with 1630 additions and 328 deletions

View File

@@ -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 <pkg>` creates an isolated installation in `{pnpmHomeDir}/global/v11/{hash}/`
- `pnpm remove -g <pkg>` 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 <pkg>` instead

View File

@@ -0,0 +1,10 @@
---
"pnpm": major
"@pnpm/plugin-commands-installation": major
---
Breaking changes to `pnpm link`:
- `pnpm link <pkg-name>` 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 <dir>` with an explicit path instead.

View File

@@ -4,7 +4,7 @@ import os from 'os'
import { isCI } from 'ci-info' import { isCI } from 'ci-info'
import { omit } from 'ramda' import { omit } from 'ramda'
import { getCatalogsFromWorkspaceManifest } from '@pnpm/catalogs.config' 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 { PnpmError } from '@pnpm/error'
import { isCamelCase } from '@pnpm/naming-cases' import { isCamelCase } from '@pnpm/naming-cases'
import loadNpmConf from '@pnpm/npm-conf' import loadNpmConf from '@pnpm/npm-conf'
@@ -333,7 +333,7 @@ export async function getConfig (opts: {
} else { } else {
globalDirRoot = path.join(pnpmConfig.pnpmHomeDir, 'global') 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']) { if (cliOptions['global']) {
delete pnpmConfig.workspaceDir delete pnpmConfig.workspaceDir
pnpmConfig.dir = pnpmConfig.globalPkgDir pnpmConfig.dir = pnpmConfig.globalPkgDir

View File

@@ -112,7 +112,7 @@ function parseTokenHelper (source: string): TokenHelper {
for (const char of source) { for (const char of source) {
// We'll only support a simple syntax for now. // We'll only support a simple syntax for now.
// In the future, we may add quotations and environment variable interpolations. // 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) throw new TokenHelperUnsupportedCharacterError(char)
} }
} }

View File

@@ -88,6 +88,7 @@
"fnumber", "fnumber",
"foobarqar", "foobarqar",
"foofoo", "foofoo",
"footgun",
"forgejo", "forgejo",
"fsevents", "fsevents",
"gabor", "gabor",

View File

@@ -35,6 +35,7 @@
"@pnpm/config": "workspace:*", "@pnpm/config": "workspace:*",
"@pnpm/config.config-writer": "workspace:*", "@pnpm/config.config-writer": "workspace:*",
"@pnpm/dependency-path": "workspace:*", "@pnpm/dependency-path": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/modules-yaml": "workspace:*", "@pnpm/modules-yaml": "workspace:*",
"@pnpm/plugin-commands-rebuild": "workspace:*", "@pnpm/plugin-commands-rebuild": "workspace:*",
"@pnpm/prepare-temp-dir": "workspace:*", "@pnpm/prepare-temp-dir": "workspace:*",
@@ -49,11 +50,11 @@
"devDependencies": { "devDependencies": {
"@jest/globals": "catalog:", "@jest/globals": "catalog:",
"@pnpm/exec.build-commands": "workspace:*", "@pnpm/exec.build-commands": "workspace:*",
"@pnpm/plugin-commands-installation": "workspace:*",
"@pnpm/prepare": "workspace:*", "@pnpm/prepare": "workspace:*",
"@pnpm/registry-mock": "catalog:", "@pnpm/registry-mock": "catalog:",
"@pnpm/types": "workspace:*", "@pnpm/types": "workspace:*",
"@types/ramda": "catalog:", "@types/ramda": "catalog:",
"execa": "catalog:",
"load-json-file": "catalog:", "load-json-file": "catalog:",
"ramda": "catalog:", "ramda": "catalog:",
"read-yaml-file": "catalog:", "read-yaml-file": "catalog:",

View File

@@ -1,4 +1,5 @@
import { type Config } from '@pnpm/config' import { type Config } from '@pnpm/config'
import { PnpmError } from '@pnpm/error'
import { globalInfo } from '@pnpm/logger' import { globalInfo } from '@pnpm/logger'
import { type StrictModules, writeModulesManifest } from '@pnpm/modules-yaml' import { type StrictModules, writeModulesManifest } from '@pnpm/modules-yaml'
import { lexCompare } from '@pnpm/util.lex-comparator' 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 { writeSettings } from '@pnpm/config.config-writer'
import { getAutomaticallyIgnoredBuilds } from './getAutomaticallyIgnoredBuilds.js' import { getAutomaticallyIgnoredBuilds } from './getAutomaticallyIgnoredBuilds.js'
export type ApproveBuildsCommandOpts = Pick<Config, 'modulesDir' | 'dir' | 'rootProjectManifest' | 'rootProjectManifestDir' | 'allowBuilds'> & { all?: boolean } export type ApproveBuildsCommandOpts = Pick<Config, 'modulesDir' | 'dir' | 'rootProjectManifest' | 'rootProjectManifestDir' | 'allowBuilds'> & { all?: boolean, global?: boolean }
export const commandNames = ['approve-builds'] export const commandNames = ['approve-builds']
@@ -26,11 +27,6 @@ export function help (): string {
description: 'Approve all pending dependencies without interactive prompts', description: 'Approve all pending dependencies without interactive prompts',
name: '--all', name: '--all',
}, },
{
description: 'Approve dependencies of global packages',
name: '--global',
shortAlias: '-g',
},
], ],
}, },
], ],
@@ -49,6 +45,16 @@ export function rcOptionsTypes (): Record<string, unknown> {
} }
export async function handler (opts: ApproveBuildsCommandOpts & RebuildCommandOpts): Promise<void> { export async function handler (opts: ApproveBuildsCommandOpts & RebuildCommandOpts): Promise<void> {
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=<pkg> <pkg>". ' +
'pnpm will also prompt to allow builds interactively during global install.',
}
)
}
const { const {
automaticallyIgnoredBuilds, automaticallyIgnoredBuilds,
modulesDir, modulesDir,

View File

@@ -1,6 +1,5 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { install } from '@pnpm/plugin-commands-installation'
import type { ApproveBuildsCommandOpts } from '@pnpm/exec.build-commands' import type { ApproveBuildsCommandOpts } from '@pnpm/exec.build-commands'
import type { RebuildCommandOpts } from '@pnpm/plugin-commands-rebuild' import type { RebuildCommandOpts } from '@pnpm/plugin-commands-rebuild'
import { prepare } from '@pnpm/prepare' import { prepare } from '@pnpm/prepare'
@@ -13,6 +12,7 @@ import { tempDir } from '@pnpm/prepare-temp-dir'
import { writePackageSync } from 'write-package' import { writePackageSync } from 'write-package'
import { sync as readYamlFile } from 'read-yaml-file' import { sync as readYamlFile } from 'read-yaml-file'
import { sync as writeYamlFile } from 'write-yaml-file' import { sync as writeYamlFile } from 'write-yaml-file'
import execa from 'execa'
jest.unstable_mockModule('enquirer', () => ({ default: { prompt: jest.fn() } })) jest.unstable_mockModule('enquirer', () => ({ default: { prompt: jest.fn() } }))
const { default: enquirer } = await import('enquirer') const { default: enquirer } = await import('enquirer')
@@ -20,15 +20,28 @@ const { approveBuilds } = await import('@pnpm/exec.build-commands')
const prompt = jest.mocked(enquirer.prompt) const prompt = jest.mocked(enquirer.prompt)
type ApproveBuildsOptions = Partial<ApproveBuildsCommandOpts & RebuildCommandOpts> 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<void> {
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 = { const cliOptions = {
argv: [], argv: [],
dir: process.cwd(), dir: process.cwd(),
registry: `http://localhost:${REGISTRY_MOCK_PORT}`, registry: `http://localhost:${REGISTRY_MOCK_PORT}`,
} }
const config = { return {
...omit(['reporter'], (await getConfig({ ...omit(['reporter'], (await getConfig({
cliOptions, cliOptions,
packageManager: { name: 'pnpm', version: '' }, packageManager: { name: 'pnpm', version: '' },
@@ -37,9 +50,14 @@ async function approveSomeBuilds (opts?: ApproveBuildsOptions) {
cacheDir: path.resolve('cache'), cacheDir: path.resolve('cache'),
pnpmfile: [], // this is only needed because the pnpmfile returned by getConfig is string | string[] pnpmfile: [], // this is only needed because the pnpmfile returned by getConfig is string | string[]
enableGlobalVirtualStore: false, enableGlobalVirtualStore: false,
strictDepBuilds: false,
} }
await install.handler({ ...config, argv: { original: [] } }) }
type ApproveBuildsOptions = Partial<ApproveBuildsCommandOpts & RebuildCommandOpts>
async function approveSomeBuilds (opts?: ApproveBuildsOptions) {
await execPnpmInstall()
const config = await getApproveBuildsConfig()
prompt.mockResolvedValueOnce({ prompt.mockResolvedValueOnce({
result: [ result: [
@@ -56,22 +74,8 @@ async function approveSomeBuilds (opts?: ApproveBuildsOptions) {
} }
async function approveNoBuilds (opts?: ApproveBuildsOptions) { async function approveNoBuilds (opts?: ApproveBuildsOptions) {
const cliOptions = { await execPnpmInstall()
argv: [], const config = await getApproveBuildsConfig()
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: [] } })
prompt.mockResolvedValueOnce({ prompt.mockResolvedValueOnce({
result: [], 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') const workspaceManifestFile = path.join(workspaceDir, 'pnpm-workspace.yaml')
writeYamlFile(workspaceManifestFile, { packages: ['packages/*'] }) 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({ expect(readYamlFile(workspaceManifestFile)).toStrictEqual({
packages: ['packages/*'], packages: ['packages/*'],
@@ -184,23 +199,8 @@ test('approve all builds with --all flag', async () => {
}, },
}) })
const cliOptions = { await execPnpmInstall()
argv: [], const config = await getApproveBuildsConfig()
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: [] } })
prompt.mockClear() prompt.mockClear()
await approveBuilds.handler({ ...config, all: true }) await approveBuilds.handler({ ...config, all: true })
@@ -230,6 +230,11 @@ test('should retain existing allowBuilds entries when approving builds', async (
tempDir: temp, 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') const workspaceManifestFile = path.join(temp, 'pnpm-workspace.yaml')
writeYamlFile(workspaceManifestFile, { writeYamlFile(workspaceManifestFile, {
packages: ['packages/*'], packages: ['packages/*'],
@@ -238,15 +243,21 @@ test('should retain existing allowBuilds entries when approving builds', async (
'@pnpm.e2e/install-script-example': true, '@pnpm.e2e/install-script-example': true,
}, },
}) })
await approveSomeBuilds(
{ const config = await getApproveBuildsConfig()
workspaceDir: temp, prompt.mockResolvedValueOnce({
rootProjectManifestDir: temp, result: [{ value: '@pnpm.e2e/pre-and-postinstall-scripts-example' }],
allowBuilds: { })
'@pnpm.e2e/test': false, prompt.mockResolvedValueOnce({ build: true })
'@pnpm.e2e/install-script-example': true, await approveBuilds.handler({
}, ...config,
}) workspaceDir: temp,
rootProjectManifestDir: temp,
allowBuilds: {
'@pnpm.e2e/test': false,
'@pnpm.e2e/install-script-example': true,
},
})
expect(readYamlFile(workspaceManifestFile)).toStrictEqual({ expect(readYamlFile(workspaceManifestFile)).toStrictEqual({
packages: ['packages/*'], packages: ['packages/*'],

View File

@@ -24,15 +24,15 @@
{ {
"path": "../../packages/dependency-path" "path": "../../packages/dependency-path"
}, },
{
"path": "../../packages/error"
},
{ {
"path": "../../packages/types" "path": "../../packages/types"
}, },
{ {
"path": "../../pkg-manager/modules-yaml" "path": "../../pkg-manager/modules-yaml"
}, },
{
"path": "../../pkg-manager/plugin-commands-installation"
},
{ {
"path": "../plugin-commands-rebuild" "path": "../plugin-commands-rebuild"
} }

View File

@@ -275,8 +275,8 @@ export function createCacheKey (opts: {
allowBuild?: string[] allowBuild?: string[]
supportedArchitectures?: SupportedArchitectures supportedArchitectures?: SupportedArchitectures
}): string { }): string {
const sortedPkgs = [...opts.packages].sort((a, b) => a.localeCompare(b)) const sortedPkgs = [...opts.packages].sort(lexCompare)
const sortedRegistries = Object.entries(opts.registries).sort(([k1], [k2]) => k1.localeCompare(k2)) const sortedRegistries = Object.entries(opts.registries).sort(([k1], [k2]) => lexCompare(k1, k2))
const args: unknown[] = [sortedPkgs, sortedRegistries] const args: unknown[] = [sortedPkgs, sortedRegistries]
if (opts.allowBuild?.length) { if (opts.allowBuild?.length) {
args.push({ allowBuild: opts.allowBuild.sort(lexCompare) }) args.push({ allowBuild: opts.allowBuild.sort(lexCompare) })

151
global/commands/README.md Normal file
View File

@@ -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 <pkg> [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 <pkg> [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)
}
```

View File

@@ -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"
}
}

View File

@@ -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<void> {
const newBinNames = new Set<string>()
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}`,
}
)
}
}
}
}
}

View File

@@ -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<typeof approveBuilds.handler>[0]
import { checkGlobalBinConflicts } from './checkGlobalBinConflicts.js'
import { readInstalledPackages } from './readInstalledPackages.js'
export type GlobalAddOptions = CreateStoreControllerOptions & {
bin?: string
globalPkgDir?: string
registries: Record<string, string>
allowBuild?: string[]
allowBuilds?: Record<string, string | boolean>
saveExact?: boolean
savePrefix?: string
supportedArchitectures?: { libc?: string[] }
rootProjectManifest?: { pnpm?: { supportedArchitectures?: { libc?: string[] } } }
}
export async function handleGlobalAdd (
opts: GlobalAddOptions,
params: string[]
): Promise<void> {
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<string, string | boolean>) => ({
...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<void> {
// Collect unique groups to remove (dedup by hash)
const groupsToRemove = new Map<string, ReturnType<typeof getInstalledBinNames>>()
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 })
}
})
)
}

View File

@@ -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<void> {
const globalDir = opts.globalPkgDir!
const globalBinDir = opts.bin!
// Find all groups that contain the packages to remove (dedup by hash)
const groupsToRemove = new Map<string, GlobalPackageInfo>()
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 })
}
})
)
}

View File

@@ -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<typeof approveBuilds.handler>[0]
import { checkGlobalBinConflicts } from './checkGlobalBinConflicts.js'
import { readInstalledPackages } from './readInstalledPackages.js'
export type GlobalUpdateOptions = CreateStoreControllerOptions & {
bin?: string
globalPkgDir?: string
latest?: boolean
allowBuilds?: Record<string, string | boolean>
saveExact?: boolean
savePrefix?: string
supportedArchitectures?: { libc?: string[] }
rootProjectManifest?: { pnpm?: { supportedArchitectures?: { libc?: string[] } } }
}
export async function handleGlobalUpdate (
opts: GlobalUpdateOptions,
params: string[]
): Promise<string | undefined> {
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<void> {
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)
}

View File

@@ -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'

View File

@@ -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<string, string | boolean>
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<IgnoredBuilds | undefined> {
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
}

View File

@@ -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<string> {
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')
}

View File

@@ -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<Array<{ manifest: DependencyManifest, location: string }>> {
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),
}))
}

View File

@@ -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"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}

View File

@@ -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"
}
}

View File

@@ -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, string>
}): 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)
}

View File

@@ -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
}

View File

@@ -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'

View File

@@ -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<string, string>
}
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<InstalledGlobalPackage[]> {
const aliases = Object.keys(info.dependencies)
const installedPackages = await Promise.all(
aliases.map(async (alias): Promise<InstalledGlobalPackage | null> => {
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<string>()
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<string[]> {
const bins = new Set<string>()
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]
}

View File

@@ -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"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}

View File

@@ -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 ENGINE_NAME = `${process.platform};${process.arch};node${process.version.split('.')[0].substring(1)}`
export const LAYOUT_VERSION = 5 export const LAYOUT_VERSION = 5
export const STORE_VERSION = 'v11' export const STORE_VERSION = 'v11'
export const GLOBAL_LAYOUT_VERSION = 'v11'
export const GLOBAL_CONFIG_YAML_FILENAME = 'config.yaml' export const GLOBAL_CONFIG_YAML_FILENAME = 'config.yaml'
export const WORKSPACE_MANIFEST_FILENAME = 'pnpm-workspace.yaml' export const WORKSPACE_MANIFEST_FILENAME = 'pnpm-workspace.yaml'

View File

@@ -48,6 +48,7 @@
"@pnpm/filter-workspace-packages": "workspace:*", "@pnpm/filter-workspace-packages": "workspace:*",
"@pnpm/find-workspace-dir": "workspace:*", "@pnpm/find-workspace-dir": "workspace:*",
"@pnpm/get-context": "workspace:*", "@pnpm/get-context": "workspace:*",
"@pnpm/global.commands": "workspace:*",
"@pnpm/graceful-fs": "workspace:*", "@pnpm/graceful-fs": "workspace:*",
"@pnpm/lockfile.types": "workspace:*", "@pnpm/lockfile.types": "workspace:*",
"@pnpm/manifest-utils": "workspace:*", "@pnpm/manifest-utils": "workspace:*",

View File

@@ -3,6 +3,7 @@ import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-
import { types as allTypes } from '@pnpm/config' import { types as allTypes } from '@pnpm/config'
import { resolveConfigDeps } from '@pnpm/config.deps-installer' import { resolveConfigDeps } from '@pnpm/config.deps-installer'
import { PnpmError } from '@pnpm/error' import { PnpmError } from '@pnpm/error'
import { handleGlobalAdd } from '@pnpm/global.commands'
import { createStoreController } from '@pnpm/store-connection-manager' import { createStoreController } from '@pnpm/store-connection-manager'
import { pick } from 'ramda' import { pick } from 'ramda'
import renderHelp from 'render-help' import renderHelp from 'render-help'
@@ -250,6 +251,7 @@ export async function handler (
if (params.includes('pnpm') || params.includes('@pnpm/exe')) { 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') throw new PnpmError('GLOBAL_PNPM_INSTALL', 'Use the "pnpm self-update" command to install or update pnpm')
} }
return handleGlobalAdd(opts, params)
} }
const include = { const include = {

View File

@@ -2,6 +2,7 @@ import { docsUrl } from '@pnpm/cli-utils'
import { FILTERING, OPTIONS, OUTPUT_OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help' import { FILTERING, OPTIONS, OUTPUT_OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help'
import { type Config, types as allTypes } from '@pnpm/config' import { type Config, types as allTypes } from '@pnpm/config'
import { WANTED_LOCKFILE } from '@pnpm/constants' import { WANTED_LOCKFILE } from '@pnpm/constants'
import { PnpmError } from '@pnpm/error'
import { type CreateStoreControllerOptions } from '@pnpm/store-connection-manager' import { type CreateStoreControllerOptions } from '@pnpm/store-connection-manager'
import { pick } from 'ramda' import { pick } from 'ramda'
import renderHelp from 'render-help' import renderHelp from 'render-help'
@@ -328,7 +329,7 @@ export type InstallCommandOptions = Pick<Config,
| 'overrides' | 'overrides'
| 'supportedArchitectures' | 'supportedArchitectures'
| 'packageConfigs' | 'packageConfigs'
> & CreateStoreControllerOptions & { > & CreateStoreControllerOptions & Partial<Pick<Config, 'globalPkgDir'>> & {
argv: { argv: {
original: string[] original: string[]
} }
@@ -347,7 +348,11 @@ export type InstallCommandOptions = Pick<Config,
pnpmfile: string[] pnpmfile: string[]
} & Partial<Pick<Config, 'ci' | 'modulesCacheMaxAge' | 'pnpmHomeDir' | 'preferWorkspacePackages' | 'useLockfile' | 'symlink'>> } & Partial<Pick<Config, 'ci' | 'modulesCacheMaxAge' | 'pnpmHomeDir' | 'preferWorkspacePackages' | 'useLockfile' | 'symlink'>>
export async function handler (opts: InstallCommandOptions): Promise<void> { export async function handler (opts: InstallCommandOptions & { _calledFromLink?: boolean }): Promise<void> {
if (opts.global && !opts._calledFromLink) {
throw new PnpmError('GLOBAL_INSTALL_NOT_SUPPORTED',
'"pnpm install -g" is not supported. Use "pnpm add -g <pkg>" to install global packages.')
}
const include = { const include = {
dependencies: opts.production !== false, dependencies: opts.production !== false,
devDependencies: opts.dev !== false, devDependencies: opts.dev !== false,

View File

@@ -38,7 +38,6 @@ type LinkOpts = Pick<Config,
| 'workspaceDir' | 'workspaceDir'
| 'workspacePackagePatterns' | 'workspacePackagePatterns'
| 'sharedWorkspaceLockfile' | 'sharedWorkspaceLockfile'
| 'globalPkgDir'
> & Partial<Pick<Config, 'linkWorkspacePackages'>> & install.InstallCommandOptions > & Partial<Pick<Config, 'linkWorkspacePackages'>> & install.InstallCommandOptions
export const rcOptionsTypes = cliOptionsTypes export const rcOptionsTypes = cliOptionsTypes
@@ -74,8 +73,7 @@ export function help (): string {
], ],
url: docsUrl('link'), url: docsUrl('link'),
usages: [ usages: [
'pnpm link <dir|pkg name>', 'pnpm link <dir>',
'pnpm link',
], ],
}) })
} }
@@ -123,37 +121,18 @@ export async function handler (
binsDir: opts.bin, 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) const writeProjectManifest = await createProjectManifestWriter(opts.rootProjectManifestDir)
// pnpm link
if ((params == null) || (params.length === 0)) { if ((params == null) || (params.length === 0)) {
const cwd = process.cwd() throw new PnpmError('LINK_BAD_PARAMS', 'You must provide a parameter. Usage: pnpm link <dir>')
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
} }
const [pkgPaths, pkgNames] = partition((inp) => isFilespec.test(inp), params) 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 ?? {} const newManifest = opts.rootProjectManifest ?? {}
await Promise.all( await Promise.all(
@@ -166,6 +145,7 @@ export async function handler (
await writeProjectManifest(newManifest) await writeProjectManifest(newManifest)
await install.handler({ await install.handler({
...linkOpts, ...linkOpts,
_calledFromLink: true,
frozenLockfileIfExists: false, frozenLockfileIfExists: false,
rootProjectManifest: newManifest, rootProjectManifest: newManifest,
}) })

View File

@@ -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 { type Config, getOptionsFromRootManifest, types as allTypes } from '@pnpm/config'
import { PnpmError } from '@pnpm/error' import { PnpmError } from '@pnpm/error'
import { arrayOfWorkspacePackagesToMap } from '@pnpm/get-context' import { arrayOfWorkspacePackagesToMap } from '@pnpm/get-context'
import { handleGlobalRemove } from '@pnpm/global.commands'
import { findWorkspacePackages } from '@pnpm/workspace.find-packages' import { findWorkspacePackages } from '@pnpm/workspace.find-packages'
import { updateWorkspaceManifest } from '@pnpm/workspace.manifest-writer' import { updateWorkspaceManifest } from '@pnpm/workspace.manifest-writer'
import { getAllDependenciesFromManifest } from '@pnpm/manifest-utils' import { getAllDependenciesFromManifest } from '@pnpm/manifest-utils'
@@ -154,10 +155,18 @@ export async function handler (
> & { > & {
recursive?: boolean recursive?: boolean
pnpmfile: string[] pnpmfile: string[]
}, } & Partial<Pick<Config, 'global' | 'globalPkgDir'>>,
params: string[] params: string[]
): Promise<void> { ): Promise<void> {
if (params.length === 0) throw new PnpmError('MUST_REMOVE_SOMETHING', 'At least one dependency name should be specified for removal') 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 = { const include = {
dependencies: opts.production !== false, dependencies: opts.production !== false,
devDependencies: opts.dev !== false, devDependencies: opts.dev !== false,

View File

@@ -6,6 +6,7 @@ import {
import { type CompletionFunc } from '@pnpm/command' import { type CompletionFunc } from '@pnpm/command'
import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help' import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help'
import { types as allTypes } from '@pnpm/config' import { types as allTypes } from '@pnpm/config'
import { handleGlobalUpdate } from '@pnpm/global.commands'
import { globalInfo } from '@pnpm/logger' import { globalInfo } from '@pnpm/logger'
import { createMatcher } from '@pnpm/matcher' import { createMatcher } from '@pnpm/matcher'
import { outdatedDepsOfProjects } from '@pnpm/outdated' import { outdatedDepsOfProjects } from '@pnpm/outdated'
@@ -168,8 +169,13 @@ export async function handler (
opts: UpdateCommandOptions, opts: UpdateCommandOptions,
params: string[] = [] params: string[] = []
): Promise<string | undefined> { ): Promise<string | undefined> {
if (opts.global && opts.rootProjectManifest == null) { if (opts.global) {
return 'No global packages found' 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) { if (opts.interactive) {
return interactiveUpdate(params, opts) return interactiveUpdate(params, opts)

View File

@@ -3,11 +3,9 @@ import path from 'path'
import { prepare, preparePackages, prepareEmpty } from '@pnpm/prepare' import { prepare, preparePackages, prepareEmpty } from '@pnpm/prepare'
import { isExecutable, assertProject } from '@pnpm/assert-project' import { isExecutable, assertProject } from '@pnpm/assert-project'
import { fixtures } from '@pnpm/test-fixtures' import { fixtures } from '@pnpm/test-fixtures'
import { loadJsonFileSync } from 'load-json-file'
import PATH from 'path-name' import PATH from 'path-name'
import { sync as readYamlFile } from 'read-yaml-file' import { sync as readYamlFile } from 'read-yaml-file'
import { writePackageSync } from 'write-package' import { writePackageSync } from 'write-package'
import { type PnpmError } from '@pnpm/error'
import { jest } from '@jest/globals' import { jest } from '@jest/globals'
import { sync as writeYamlFile } from 'write-yaml-file' import { sync as writeYamlFile } from 'write-yaml-file'
import { DEFAULT_OPTS } from './utils/index.js' import { DEFAULT_OPTS } from './utils/index.js'
@@ -33,32 +31,18 @@ test('linking multiple packages', async () => {
const project = prepare() const project = prepare()
process.chdir('..') process.chdir('..')
const globalDir = path.resolve('global')
writePackageSync('linked-foo', { name: 'linked-foo', version: '1.0.0' }) 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' } }) writePackageSync('linked-bar', { name: 'linked-bar', version: '1.0.0', dependencies: { 'is-positive': '1.0.0' } })
fs.writeFileSync('linked-bar/.npmrc', 'shamefully-hoist = true') 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') process.chdir('project')
await link.handler({ await link.handler({
...DEFAULT_OPTS, ...DEFAULT_OPTS,
dir: process.cwd(), dir: process.cwd(),
globalPkgDir: globalDir,
rootProjectManifestDir: process.cwd(), rootProjectManifestDir: process.cwd(),
}, ['linked-foo', '../linked-bar']) }, ['../linked-foo', '../linked-bar'])
project.has('linked-foo') project.has('linked-foo')
project.has('linked-bar') 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' }) 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') 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({ await link.handler({
...DEFAULT_OPTS, ...DEFAULT_OPTS,
@@ -85,7 +69,7 @@ test('link global bin', async function () {
dir: globalDir, dir: globalDir,
globalPkgDir: globalDir, globalPkgDir: globalDir,
rootProjectManifestDir: globalDir, rootProjectManifestDir: globalDir,
}) }, [pkgWithBinDir])
process.env[PATH] = oldPath process.env[PATH] = oldPath
isExecutable((value) => { isExecutable((value) => {
@@ -93,51 +77,6 @@ test('link global bin', async function () {
}, path.join(globalBin, 'package-with-bin')) }, 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<any>(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 () => { test('relative link', async () => {
const project = prepare({ const project = prepare({
dependencies: { dependencies: {
@@ -274,7 +213,6 @@ test('logger warns about peer dependencies when linking', async () => {
prepare() prepare()
process.chdir('..') process.chdir('..')
const globalDir = path.resolve('global')
writePackageSync('linked-with-peer-deps', { writePackageSync('linked-with-peer-deps', {
name: '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') process.chdir('project')
await link.handler({ await link.handler({
...DEFAULT_OPTS, ...DEFAULT_OPTS,
dir: process.cwd(), dir: process.cwd(),
globalPkgDir: globalDir, rootProjectManifestDir: process.cwd(),
rootProjectManifestDir: globalDir, }, ['../linked-with-peer-deps'])
}, ['linked-with-peer-deps'])
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('has the following peerDependencies specified in its package.json'), 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() prepare()
process.chdir('..') process.chdir('..')
const globalDir = path.resolve('global')
writePackageSync('linked-with-empty-peer-deps', { writePackageSync('linked-with-empty-peer-deps', {
name: '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: {}, 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') process.chdir('project')
await link.handler({ await link.handler({
...DEFAULT_OPTS, ...DEFAULT_OPTS,
globalPkgDir: globalDir,
dir: process.cwd(), dir: process.cwd(),
rootProjectManifestDir: process.cwd(), rootProjectManifestDir: process.cwd(),
}, ['linked-with-empty-peer-deps']) }, ['../linked-with-empty-peer-deps'])
expect(logger.warn).not.toHaveBeenCalledWith(expect.objectContaining({ expect(logger.warn).not.toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('has the following peerDependencies specified in its package.json'), 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() 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 () => { test('relative link from workspace package', async () => {
prepareEmpty() prepareEmpty()

View File

@@ -63,6 +63,9 @@
{ {
"path": "../../fs/read-modules-dir" "path": "../../fs/read-modules-dir"
}, },
{
"path": "../../global/commands"
},
{ {
"path": "../../hooks/pnpmfile" "path": "../../hooks/pnpmfile"
}, },

View File

@@ -1,4 +1,5 @@
import path from 'path' import path from 'path'
import util from 'util'
import { PnpmError } from '@pnpm/error' import { PnpmError } from '@pnpm/error'
import { type PackageManifest } from '@pnpm/types' import { type PackageManifest } from '@pnpm/types'
import { loadJsonFile, loadJsonFileSync } from 'load-json-file' import { loadJsonFile, loadJsonFileSync } from 'load-json-file'
@@ -46,3 +47,12 @@ export async function safeReadPackageJson (pkgPath: string): Promise<PackageMani
export async function safeReadPackageJsonFromDir (pkgPath: string): Promise<PackageManifest | null> { export async function safeReadPackageJsonFromDir (pkgPath: string): Promise<PackageManifest | null> {
return safeReadPackageJson(path.join(pkgPath, 'package.json')) return safeReadPackageJson(path.join(pkgPath, 'package.json'))
} }
export function readPackageJsonFromDirRawSync (pkgPath: string): PackageManifest {
try {
return loadJsonFileSync<PackageManifest>(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)}`)
}
}

101
pnpm-lock.yaml generated
View File

@@ -2419,6 +2419,9 @@ importers:
'@pnpm/dependency-path': '@pnpm/dependency-path':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/dependency-path version: link:../../packages/dependency-path
'@pnpm/error':
specifier: workspace:*
version: link:../../packages/error
'@pnpm/logger': '@pnpm/logger':
specifier: 'catalog:' specifier: 'catalog:'
version: 1001.0.1 version: 1001.0.1
@@ -2450,9 +2453,6 @@ importers:
'@pnpm/exec.build-commands': '@pnpm/exec.build-commands':
specifier: workspace:* specifier: workspace:*
version: 'link:' version: 'link:'
'@pnpm/plugin-commands-installation':
specifier: workspace:*
version: link:../../pkg-manager/plugin-commands-installation
'@pnpm/prepare': '@pnpm/prepare':
specifier: workspace:* specifier: workspace:*
version: link:../../__utils__/prepare version: link:../../__utils__/prepare
@@ -2465,6 +2465,9 @@ importers:
'@types/ramda': '@types/ramda':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.29.12 version: 0.29.12
execa:
specifier: 'catalog:'
version: safe-execa@0.2.0
load-json-file: load-json-file:
specifier: 'catalog:' specifier: 'catalog:'
version: 7.0.1 version: 7.0.1
@@ -3478,6 +3481,83 @@ importers:
specifier: workspace:* specifier: workspace:*
version: 'link:' 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: hooks/pnpmfile:
dependencies: dependencies:
'@pnpm/core-loggers': '@pnpm/core-loggers':
@@ -5834,6 +5914,9 @@ importers:
'@pnpm/get-context': '@pnpm/get-context':
specifier: workspace:* specifier: workspace:*
version: link:../get-context version: link:../get-context
'@pnpm/global.commands':
specifier: workspace:*
version: link:../../global/commands
'@pnpm/graceful-fs': '@pnpm/graceful-fs':
specifier: workspace:* specifier: workspace:*
version: link:../../fs/graceful-fs version: link:../../fs/graceful-fs
@@ -7945,6 +8028,9 @@ importers:
'@pnpm/error': '@pnpm/error':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/error version: link:../../packages/error
'@pnpm/global.commands':
specifier: workspace:*
version: link:../../global/commands
'@pnpm/list': '@pnpm/list':
specifier: workspace:* specifier: workspace:*
version: link:../list version: link:../list
@@ -8018,6 +8104,9 @@ importers:
'@pnpm/error': '@pnpm/error':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/error version: link:../../packages/error
'@pnpm/global.packages':
specifier: workspace:*
version: link:../../global/packages
'@pnpm/lockfile.fs': '@pnpm/lockfile.fs':
specifier: workspace:* specifier: workspace:*
version: link:../../lockfile/fs version: link:../../lockfile/fs
@@ -8079,6 +8168,9 @@ importers:
'@types/zkochan__table': '@types/zkochan__table':
specifier: 'catalog:' specifier: 'catalog:'
version: '@types/table@6.0.0' version: '@types/table@6.0.0'
symlink-dir:
specifier: 'catalog:'
version: 7.1.0
reviewing/plugin-commands-sbom: reviewing/plugin-commands-sbom:
dependencies: dependencies:
@@ -8443,6 +8535,9 @@ importers:
'@pnpm/get-context': '@pnpm/get-context':
specifier: workspace:* specifier: workspace:*
version: link:../../pkg-manager/get-context version: link:../../pkg-manager/get-context
'@pnpm/global.packages':
specifier: workspace:*
version: link:../../global/packages
'@pnpm/lockfile.utils': '@pnpm/lockfile.utils':
specifier: workspace:* specifier: workspace:*
version: link:../../lockfile/utils version: link:../../lockfile/utils

View File

@@ -17,6 +17,7 @@ packages:
- exec/* - exec/*
- fetching/* - fetching/*
- fs/* - fs/*
- global/*
- hooks/* - hooks/*
- lockfile/* - lockfile/*
- network/* - network/*

View File

@@ -23,7 +23,7 @@ export function help (): string {
list: [ list: [
{ {
description: 'Print the global `node_modules` directory', description: 'Print the global packages directory',
name: '--global', name: '--global',
shortAlias: '-g', shortAlias: '-g',
}, },
@@ -38,7 +38,11 @@ export function help (): string {
export async function handler ( export async function handler (
opts: { opts: {
dir: string dir: string
global?: boolean
} }
): Promise<string> { ): Promise<string> {
if (opts.global) {
return `${opts.dir}\n`
}
return `${path.join(opts.dir, 'node_modules')}\n` return `${path.join(opts.dir, 'node_modules')}\n`
} }

View File

@@ -1,17 +1,53 @@
import path from 'path' import path from 'path'
import PATH_NAME from 'path-name' import PATH_NAME from 'path-name'
import fs from 'fs' import fs from 'fs'
import { LAYOUT_VERSION } from '@pnpm/constants'
import { prepare } from '@pnpm/prepare' import { prepare } from '@pnpm/prepare'
import { type ProjectManifest } from '@pnpm/types' import { type ProjectManifest } from '@pnpm/types'
import isWindows from 'is-windows' import isWindows from 'is-windows'
import writeYamlFile from 'write-yaml-file' import { GLOBAL_LAYOUT_VERSION } from '@pnpm/constants'
import { import {
addDistTag, addDistTag,
execPnpm, execPnpm,
execPnpmSync, execPnpmSync,
} from '../utils/index.js' } 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<string, string> }
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 () => { test('global installation', async () => {
prepare() prepare()
const global = path.resolve('..', 'global') 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 } 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 // there was an issue when subsequent installations were removing everything installed prior
// https://github.com/pnpm/pnpm/issues/808 // 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 isPositivePath = findGlobalPkg(globalPkgDir(pnpmHome), 'is-positive')
expect(isPositivePath).toBeTruthy()
const { default: isPositive } = await import(path.join(globalPrefix, 'node_modules', 'is-positive')) const { default: isPositive } = await import(isPositivePath!)
expect(typeof isPositive).toBe('function') 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') 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 env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
const { status } = execPnpmSync([ const { status } = execPnpmSync([
'install', 'add',
'--global', '--global',
'is-positive', 'is-positive',
'--config.package-manager-strict=true', '--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 }) 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') 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 } const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
await execPnpm(['install', '-g', '@pnpm.e2e/peer-c@1'], { env }) await execPnpm(['add', '-g', '@pnpm.e2e/peer-c@1'], { env })
await execPnpm(['install', '-g', '@pnpm.e2e/peer-c'], { env }) await execPnpm(['add', '-g', '@pnpm.e2e/peer-c'], { env })
const globalPrefix = path.join(global, `pnpm/global/${LAYOUT_VERSION}`) const peerCPath = findGlobalPkg(globalPkgDir(pnpmHome), '@pnpm.e2e/peer-c')
expect(peerCPath).toBeTruthy()
process.chdir(globalPrefix) expect((await import(path.join(peerCPath!, 'package.json'))).default.version).toBe('2.0.0')
expect((await import(path.resolve('node_modules', '@pnpm.e2e/peer-c', 'package.json'))).default.version).toBe('2.0.0')
}) })
test('run lifecycle events of global packages in correct working directory', async () => { 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() prepare()
const global = path.resolve('..', 'global') const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm') const pnpmHome = path.join(global, 'pnpm')
const globalPkgDir = path.join(pnpmHome, 'global', String(LAYOUT_VERSION)) fs.mkdirSync(pnpmHome, { recursive: true })
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,
},
})
const env = { const env = {
[PATH_NAME]: `${pnpmHome}${path.delimiter}${process.env[PATH_NAME]!}`, [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, 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 // 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 pnpmRcFile = path.join(pnpmCfgDir, 'rc')
const global = path.resolve('..', 'global') const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm') 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.mkdirSync(pnpmCfgDir, { recursive: true })
fs.writeFileSync(pnpmRcFile, [ fs.writeFileSync(pnpmRcFile, [
'reporter=append-only', 'reporter=append-only',
@@ -158,8 +191,8 @@ test.skip('dangerously-allow-all-builds=true in global config', async () => {
} }
// global install should run scripts // global install should run scripts
await execPnpm(['install', '-g', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env }) await execPnpm(['add', '-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') expect(fs.readdirSync(path.join(globalDir, 'node_modules/@pnpm.e2e/postinstall-calls-pnpm'))).toContain('created-by-postinstall')
// local config should override global config // local config should override global config
await execPnpm(['add', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env }) 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 pnpmRcFile = path.join(pnpmCfgDir, 'rc')
const global = path.resolve('..', 'global') const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm') 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.mkdirSync(pnpmCfgDir, { recursive: true })
fs.writeFileSync(pnpmRcFile, [ fs.writeFileSync(pnpmRcFile, [
'reporter=append-only', 'reporter=append-only',
@@ -213,8 +246,8 @@ test.skip('dangerously-allow-all-builds=false in global config', async () => {
} }
// global install should run scripts // global install should run scripts
await execPnpm(['install', '-g', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env }) await execPnpm(['add', '-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') expect(fs.readdirSync(path.join(globalDir, 'node_modules/@pnpm.e2e/postinstall-calls-pnpm'))).not.toContain('created-by-postinstall')
// local config should override global config // local config should override global config
await execPnpm(['add', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env }) 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 } 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 }) await execPnpm(['update', '--global', '--latest'], { env })
const globalPrefix = path.join(global, `pnpm/global/${LAYOUT_VERSION}`) const isPositivePath = findGlobalPkg(globalPkgDir(pnpmHome), 'is-positive')
expect(isPositivePath).toBeTruthy()
const { default: isPositive } = await import(path.join(globalPrefix, 'node_modules/is-positive/package.json')) const pkgJson = JSON.parse(fs.readFileSync(path.join(isPositivePath!, 'package.json'), 'utf-8'))
expect(isPositive.version).toBe('3.1.0') expect(pkgJson.version).toBe('3.1.0')
}) })
test('global update should not crash if there are no global packages', async () => { 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) 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()
})

View File

@@ -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()
})

View File

@@ -1,7 +1,7 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import PATH_NAME from 'path-name' 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 { tempDir } from '@pnpm/prepare'
import { execPnpmSync } from './utils/index.js' import { execPnpmSync } from './utils/index.js'
@@ -28,5 +28,5 @@ test('pnpm root -g', async () => {
const result = execPnpmSync(['root', '-g'], { env }) const result = execPnpmSync(['root', '-g'], { env })
expect(result.status).toBe(0) 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')
}) })

View File

@@ -35,6 +35,7 @@
"@pnpm/common-cli-options-help": "workspace:*", "@pnpm/common-cli-options-help": "workspace:*",
"@pnpm/config": "workspace:*", "@pnpm/config": "workspace:*",
"@pnpm/error": "workspace:*", "@pnpm/error": "workspace:*",
"@pnpm/global.commands": "workspace:*",
"@pnpm/list": "workspace:*", "@pnpm/list": "workspace:*",
"@pnpm/types": "workspace:*", "@pnpm/types": "workspace:*",
"ramda": "catalog:", "ramda": "catalog:",

View File

@@ -1,6 +1,7 @@
import { docsUrl } from '@pnpm/cli-utils' import { docsUrl } from '@pnpm/cli-utils'
import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help' import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help'
import { type Config, types as allTypes } from '@pnpm/config' import { type Config, types as allTypes } from '@pnpm/config'
import { listGlobalPackages } from '@pnpm/global.commands'
import { list, listForPackages } from '@pnpm/list' import { list, listForPackages } from '@pnpm/list'
import { type Finder, type IncludedDependencies } from '@pnpm/types' import { type Finder, type IncludedDependencies } from '@pnpm/types'
import { pick } from 'ramda' import { pick } from 'ramda'
@@ -101,12 +102,15 @@ export type ListCommandOptions = Pick<Config,
onlyProjects?: boolean onlyProjects?: boolean
recursive?: boolean recursive?: boolean
findBy?: string[] findBy?: string[]
} } & Partial<Pick<Config, 'global' | 'globalPkgDir'>>
export async function handler ( export async function handler (
opts: ListCommandOptions, opts: ListCommandOptions,
params: string[] params: string[]
): Promise<string> { ): Promise<string> {
if (opts.global && opts.globalPkgDir) {
return listGlobalPackages(opts.globalPkgDir, params)
}
const include = computeInclude(opts) const include = computeInclude(opts)
const depth = opts.cliOptions?.['depth'] ?? 0 const depth = opts.cliOptions?.['depth'] ?? 0
if (opts.recursive && (opts.selectedProjectsGraph != null)) { if (opts.recursive && (opts.selectedProjectsGraph != null)) {

View File

@@ -21,6 +21,9 @@
{ {
"path": "../../config/config" "path": "../../config/config"
}, },
{
"path": "../../global/commands"
},
{ {
"path": "../../packages/constants" "path": "../../packages/constants"
}, },

View File

@@ -39,6 +39,7 @@
"@pnpm/config": "workspace:*", "@pnpm/config": "workspace:*",
"@pnpm/default-resolver": "workspace:*", "@pnpm/default-resolver": "workspace:*",
"@pnpm/error": "workspace:*", "@pnpm/error": "workspace:*",
"@pnpm/global.packages": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*", "@pnpm/lockfile.fs": "workspace:*",
"@pnpm/matcher": "workspace:*", "@pnpm/matcher": "workspace:*",
"@pnpm/modules-yaml": "workspace:*", "@pnpm/modules-yaml": "workspace:*",
@@ -60,7 +61,8 @@
"@pnpm/test-fixtures": "workspace:*", "@pnpm/test-fixtures": "workspace:*",
"@pnpm/workspace.filter-packages-from-dir": "workspace:*", "@pnpm/workspace.filter-packages-from-dir": "workspace:*",
"@types/ramda": "catalog:", "@types/ramda": "catalog:",
"@types/zkochan__table": "catalog:" "@types/zkochan__table": "catalog:",
"symlink-dir": "catalog:"
}, },
"engines": { "engines": {
"node": ">=22.13" "node": ">=22.13"

View File

@@ -9,12 +9,13 @@ import { type CompletionFunc } from '@pnpm/command'
import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help' import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help'
import { type Config, types as allTypes } from '@pnpm/config' import { type Config, types as allTypes } from '@pnpm/config'
import { PnpmError } from '@pnpm/error' import { PnpmError } from '@pnpm/error'
import { scanGlobalPackages } from '@pnpm/global.packages'
import { import {
outdatedDepsOfProjects, outdatedDepsOfProjects,
type OutdatedPackage, type OutdatedPackage,
} from '@pnpm/outdated' } from '@pnpm/outdated'
import semverDiff from '@pnpm/semver-diff' 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 { table } from '@zkochan/table'
import chalk from 'chalk' import chalk from 'chalk'
import { pick, sortWith } from 'ramda' import { pick, sortWith } from 'ramda'
@@ -169,7 +170,7 @@ export type OutdatedCommandOptions = {
| 'tag' | 'tag'
| 'userAgent' | 'userAgent'
| 'updateConfig' | 'updateConfig'
> & Partial<Pick<Config, 'userConfig'>> > & Partial<Pick<Config, 'globalPkgDir' | 'userConfig'>>
export async function handler ( export async function handler (
opts: OutdatedCommandOptions, opts: OutdatedCommandOptions,
@@ -184,14 +185,25 @@ export async function handler (
const pkgs = Object.values(opts.selectedProjectsGraph).map((wsPkg) => wsPkg.package) const pkgs = Object.values(opts.selectedProjectsGraph).map((wsPkg) => wsPkg.package)
return outdatedRecursive(pkgs, params, { ...opts, include }) return outdatedRecursive(pkgs, params, { ...opts, include })
} }
const manifest = await readProjectManifestOnly(opts.dir, opts) let packages: Array<{ rootDir: ProjectRootDir, manifest: ProjectManifest }>
const packages = [ if (opts.global && opts.globalPkgDir) {
{ const globalPackages = scanGlobalPackages(opts.globalPkgDir)
rootDir: opts.dir as ProjectRootDir, packages = await Promise.all(
manifest, globalPackages.map(async (pkg) => ({
}, rootDir: pkg.installDir as ProjectRootDir,
] manifest: await readProjectManifestOnly(pkg.installDir, opts),
const [outdatedPackages] = await outdatedDepsOfProjects(packages, params, { }))
)
} else {
const manifest = await readProjectManifestOnly(opts.dir, opts)
packages = [
{
rootDir: opts.dir as ProjectRootDir,
manifest,
},
]
}
const outdatedPerProject = await outdatedDepsOfProjects(packages, params, {
...opts, ...opts,
fullMetadata: opts.long, fullMetadata: opts.long,
ignoreDependencies: opts.updateConfig?.ignoreDependencies, ignoreDependencies: opts.updateConfig?.ignoreDependencies,
@@ -206,6 +218,7 @@ export async function handler (
}, },
timeout: opts.fetchTimeout, timeout: opts.fetchTimeout,
}) })
const outdatedPackages = outdatedPerProject.flat()
let output!: string let output!: string
switch (opts.format ?? 'table') { switch (opts.format ?? 'table') {

View File

@@ -8,6 +8,7 @@ import { prepare, tempDir } from '@pnpm/prepare'
import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock' import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
import { fixtures } from '@pnpm/test-fixtures' import { fixtures } from '@pnpm/test-fixtures'
import { stripVTControlCharacters as stripAnsi } from 'util' import { stripVTControlCharacters as stripAnsi } from 'util'
import symlinkDir from 'symlink-dir'
const f = fixtures(import.meta.dirname) const f = fixtures(import.meta.dirname)
const hasOutdatedDepsFixture = f.find('has-outdated-deps') 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 () => { test('pnpm outdated --long with only deprecated packages', async () => {
tempDir() tempDir()

View File

@@ -30,6 +30,9 @@
{ {
"path": "../../config/matcher" "path": "../../config/matcher"
}, },
{
"path": "../../global/packages"
},
{ {
"path": "../../lockfile/fs" "path": "../../lockfile/fs"
}, },

View File

@@ -39,6 +39,7 @@
"@pnpm/error": "workspace:*", "@pnpm/error": "workspace:*",
"@pnpm/fs.msgpack-file": "workspace:*", "@pnpm/fs.msgpack-file": "workspace:*",
"@pnpm/get-context": "workspace:*", "@pnpm/get-context": "workspace:*",
"@pnpm/global.packages": "workspace:*",
"@pnpm/lockfile.utils": "workspace:*", "@pnpm/lockfile.utils": "workspace:*",
"@pnpm/normalize-registries": "workspace:*", "@pnpm/normalize-registries": "workspace:*",
"@pnpm/parse-wanted-dependency": "workspace:*", "@pnpm/parse-wanted-dependency": "workspace:*",

View File

@@ -74,7 +74,7 @@ class StoreStatusError extends PnpmError {
} }
} }
export type StoreCommandOptions = Pick<Config, 'dir' | 'lockfileDir' | 'registries' | 'tag' | 'storeDir' | 'force' | 'dlxCacheMaxAge'> & CreateStoreControllerOptions & { export type StoreCommandOptions = Pick<Config, 'dir' | 'lockfileDir' | 'registries' | 'tag' | 'storeDir' | 'force' | 'dlxCacheMaxAge'> & Partial<Pick<Config, 'globalPkgDir'>> & CreateStoreControllerOptions & {
reporter?: (logObj: LogBase) => void reporter?: (logObj: LogBase) => void
} }

View File

@@ -1,3 +1,4 @@
import { cleanOrphanedInstallDirs } from '@pnpm/global.packages'
import { streamParser } from '@pnpm/logger' import { streamParser } from '@pnpm/logger'
import { type StoreController } from '@pnpm/store-controller-types' import { type StoreController } from '@pnpm/store-controller-types'
import { type ReporterFunction } from './types.js' import { type ReporterFunction } from './types.js'
@@ -10,6 +11,7 @@ export async function storePrune (
removeAlienFiles?: boolean removeAlienFiles?: boolean
cacheDir: string cacheDir: string
dlxCacheMaxAge: number dlxCacheMaxAge: number
globalPkgDir?: string
} }
): Promise<void> { ): Promise<void> {
const reporter = opts?.reporter const reporter = opts?.reporter
@@ -25,6 +27,10 @@ export async function storePrune (
now: new Date(), now: new Date(),
}) })
if (opts.globalPkgDir) {
cleanOrphanedInstallDirs(opts.globalPkgDir)
}
if ((reporter != null) && typeof reporter === 'function') { if ((reporter != null) && typeof reporter === 'function') {
streamParser.removeListener('data', reporter) streamParser.removeListener('data', reporter)
} }

View File

@@ -33,6 +33,9 @@
{ {
"path": "../../fs/msgpack-file" "path": "../../fs/msgpack-file"
}, },
{
"path": "../../global/packages"
},
{ {
"path": "../../lockfile/fs" "path": "../../lockfile/fs"
}, },