mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-27 18:46:18 -04:00
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:
17
.changeset/isolated-global-packages.md
Normal file
17
.changeset/isolated-global-packages.md
Normal 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
|
||||
10
.changeset/link-breaking-changes.md
Normal file
10
.changeset/link-breaking-changes.md
Normal 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.
|
||||
@@ -4,7 +4,7 @@ import os from 'os'
|
||||
import { isCI } from 'ci-info'
|
||||
import { omit } from 'ramda'
|
||||
import { getCatalogsFromWorkspaceManifest } from '@pnpm/catalogs.config'
|
||||
import { GLOBAL_CONFIG_YAML_FILENAME, LAYOUT_VERSION } from '@pnpm/constants'
|
||||
import { GLOBAL_CONFIG_YAML_FILENAME, GLOBAL_LAYOUT_VERSION } from '@pnpm/constants'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { isCamelCase } from '@pnpm/naming-cases'
|
||||
import loadNpmConf from '@pnpm/npm-conf'
|
||||
@@ -333,7 +333,7 @@ export async function getConfig (opts: {
|
||||
} else {
|
||||
globalDirRoot = path.join(pnpmConfig.pnpmHomeDir, 'global')
|
||||
}
|
||||
pnpmConfig.globalPkgDir = path.join(globalDirRoot, LAYOUT_VERSION.toString())
|
||||
pnpmConfig.globalPkgDir = path.join(globalDirRoot, GLOBAL_LAYOUT_VERSION)
|
||||
if (cliOptions['global']) {
|
||||
delete pnpmConfig.workspaceDir
|
||||
pnpmConfig.dir = pnpmConfig.globalPkgDir
|
||||
|
||||
@@ -112,7 +112,7 @@ function parseTokenHelper (source: string): TokenHelper {
|
||||
for (const char of source) {
|
||||
// We'll only support a simple syntax for now.
|
||||
// In the future, we may add quotations and environment variable interpolations.
|
||||
if (RESERVED_CHARACTERS.has(char)) { // eslint-disable-line
|
||||
if (RESERVED_CHARACTERS.has(char)) {
|
||||
throw new TokenHelperUnsupportedCharacterError(char)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
"fnumber",
|
||||
"foobarqar",
|
||||
"foofoo",
|
||||
"footgun",
|
||||
"forgejo",
|
||||
"fsevents",
|
||||
"gabor",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@pnpm/config": "workspace:*",
|
||||
"@pnpm/config.config-writer": "workspace:*",
|
||||
"@pnpm/dependency-path": "workspace:*",
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/modules-yaml": "workspace:*",
|
||||
"@pnpm/plugin-commands-rebuild": "workspace:*",
|
||||
"@pnpm/prepare-temp-dir": "workspace:*",
|
||||
@@ -49,11 +50,11 @@
|
||||
"devDependencies": {
|
||||
"@jest/globals": "catalog:",
|
||||
"@pnpm/exec.build-commands": "workspace:*",
|
||||
"@pnpm/plugin-commands-installation": "workspace:*",
|
||||
"@pnpm/prepare": "workspace:*",
|
||||
"@pnpm/registry-mock": "catalog:",
|
||||
"@pnpm/types": "workspace:*",
|
||||
"@types/ramda": "catalog:",
|
||||
"execa": "catalog:",
|
||||
"load-json-file": "catalog:",
|
||||
"ramda": "catalog:",
|
||||
"read-yaml-file": "catalog:",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Config } from '@pnpm/config'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { globalInfo } from '@pnpm/logger'
|
||||
import { type StrictModules, writeModulesManifest } from '@pnpm/modules-yaml'
|
||||
import { lexCompare } from '@pnpm/util.lex-comparator'
|
||||
@@ -9,7 +10,7 @@ import { rebuild, type RebuildCommandOpts } from '@pnpm/plugin-commands-rebuild'
|
||||
import { writeSettings } from '@pnpm/config.config-writer'
|
||||
import { getAutomaticallyIgnoredBuilds } from './getAutomaticallyIgnoredBuilds.js'
|
||||
|
||||
export type ApproveBuildsCommandOpts = Pick<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']
|
||||
|
||||
@@ -26,11 +27,6 @@ export function help (): string {
|
||||
description: 'Approve all pending dependencies without interactive prompts',
|
||||
name: '--all',
|
||||
},
|
||||
{
|
||||
description: 'Approve dependencies of global packages',
|
||||
name: '--global',
|
||||
shortAlias: '-g',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -49,6 +45,16 @@ export function rcOptionsTypes (): Record<string, unknown> {
|
||||
}
|
||||
|
||||
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 {
|
||||
automaticallyIgnoredBuilds,
|
||||
modulesDir,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { install } from '@pnpm/plugin-commands-installation'
|
||||
import type { ApproveBuildsCommandOpts } from '@pnpm/exec.build-commands'
|
||||
import type { RebuildCommandOpts } from '@pnpm/plugin-commands-rebuild'
|
||||
import { prepare } from '@pnpm/prepare'
|
||||
@@ -13,6 +12,7 @@ import { tempDir } from '@pnpm/prepare-temp-dir'
|
||||
import { writePackageSync } from 'write-package'
|
||||
import { sync as readYamlFile } from 'read-yaml-file'
|
||||
import { sync as writeYamlFile } from 'write-yaml-file'
|
||||
import execa from 'execa'
|
||||
|
||||
jest.unstable_mockModule('enquirer', () => ({ default: { prompt: jest.fn() } }))
|
||||
const { default: enquirer } = await import('enquirer')
|
||||
@@ -20,15 +20,28 @@ const { approveBuilds } = await import('@pnpm/exec.build-commands')
|
||||
|
||||
const prompt = jest.mocked(enquirer.prompt)
|
||||
|
||||
type ApproveBuildsOptions = Partial<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 = {
|
||||
argv: [],
|
||||
dir: process.cwd(),
|
||||
registry: `http://localhost:${REGISTRY_MOCK_PORT}`,
|
||||
}
|
||||
const config = {
|
||||
return {
|
||||
...omit(['reporter'], (await getConfig({
|
||||
cliOptions,
|
||||
packageManager: { name: 'pnpm', version: '' },
|
||||
@@ -37,9 +50,14 @@ async function approveSomeBuilds (opts?: ApproveBuildsOptions) {
|
||||
cacheDir: path.resolve('cache'),
|
||||
pnpmfile: [], // this is only needed because the pnpmfile returned by getConfig is string | string[]
|
||||
enableGlobalVirtualStore: false,
|
||||
strictDepBuilds: false,
|
||||
}
|
||||
await install.handler({ ...config, argv: { original: [] } })
|
||||
}
|
||||
|
||||
type ApproveBuildsOptions = Partial<ApproveBuildsCommandOpts & RebuildCommandOpts>
|
||||
|
||||
async function approveSomeBuilds (opts?: ApproveBuildsOptions) {
|
||||
await execPnpmInstall()
|
||||
const config = await getApproveBuildsConfig()
|
||||
|
||||
prompt.mockResolvedValueOnce({
|
||||
result: [
|
||||
@@ -56,22 +74,8 @@ async function approveSomeBuilds (opts?: ApproveBuildsOptions) {
|
||||
}
|
||||
|
||||
async function approveNoBuilds (opts?: ApproveBuildsOptions) {
|
||||
const cliOptions = {
|
||||
argv: [],
|
||||
dir: process.cwd(),
|
||||
registry: `http://localhost:${REGISTRY_MOCK_PORT}`,
|
||||
}
|
||||
const config = {
|
||||
...omit(['reporter'], (await getConfig({
|
||||
cliOptions,
|
||||
packageManager: { name: 'pnpm', version: '' },
|
||||
})).config),
|
||||
storeDir: path.resolve('store'),
|
||||
cacheDir: path.resolve('cache'),
|
||||
pnpmfile: [], // this is only needed because the pnpmfile returned by getConfig is string | string[]
|
||||
strictDepBuilds: false,
|
||||
}
|
||||
await install.handler({ ...config, argv: { original: [] } })
|
||||
await execPnpmInstall()
|
||||
const config = await getApproveBuildsConfig()
|
||||
|
||||
prompt.mockResolvedValueOnce({
|
||||
result: [],
|
||||
@@ -136,11 +140,22 @@ test("works when root project manifest doesn't exist in a workspace", async () =
|
||||
},
|
||||
})
|
||||
|
||||
const workspaceDir = path.resolve('workspace')
|
||||
// Install before writing the workspace manifest so the CLI doesn't
|
||||
// detect a workspace (matching the old install.handler() behaviour
|
||||
// where getConfig() didn't read allowBuilds from the manifest).
|
||||
process.chdir('workspace/packages/project')
|
||||
await execPnpmInstall()
|
||||
|
||||
const workspaceDir = path.resolve('../..')
|
||||
const workspaceManifestFile = path.join(workspaceDir, 'pnpm-workspace.yaml')
|
||||
writeYamlFile(workspaceManifestFile, { packages: ['packages/*'] })
|
||||
process.chdir('workspace/packages/project')
|
||||
await approveSomeBuilds({ workspaceDir, rootProjectManifestDir: workspaceDir })
|
||||
|
||||
const config = await getApproveBuildsConfig()
|
||||
prompt.mockResolvedValueOnce({
|
||||
result: [{ value: '@pnpm.e2e/pre-and-postinstall-scripts-example' }],
|
||||
})
|
||||
prompt.mockResolvedValueOnce({ build: true })
|
||||
await approveBuilds.handler({ ...config, workspaceDir, rootProjectManifestDir: workspaceDir })
|
||||
|
||||
expect(readYamlFile(workspaceManifestFile)).toStrictEqual({
|
||||
packages: ['packages/*'],
|
||||
@@ -184,23 +199,8 @@ test('approve all builds with --all flag', async () => {
|
||||
},
|
||||
})
|
||||
|
||||
const cliOptions = {
|
||||
argv: [],
|
||||
dir: process.cwd(),
|
||||
registry: `http://localhost:${REGISTRY_MOCK_PORT}`,
|
||||
}
|
||||
const config = {
|
||||
...omit(['reporter'], (await getConfig({
|
||||
cliOptions,
|
||||
packageManager: { name: 'pnpm', version: '' },
|
||||
})).config),
|
||||
storeDir: path.resolve('store'),
|
||||
cacheDir: path.resolve('cache'),
|
||||
pnpmfile: [],
|
||||
enableGlobalVirtualStore: false,
|
||||
strictDepBuilds: false,
|
||||
}
|
||||
await install.handler({ ...config, argv: { original: [] } })
|
||||
await execPnpmInstall()
|
||||
const config = await getApproveBuildsConfig()
|
||||
|
||||
prompt.mockClear()
|
||||
await approveBuilds.handler({ ...config, all: true })
|
||||
@@ -230,6 +230,11 @@ test('should retain existing allowBuilds entries when approving builds', async (
|
||||
tempDir: temp,
|
||||
})
|
||||
|
||||
// Install before writing the workspace manifest with allowBuilds so the
|
||||
// CLI ignores all builds (matching the old install.handler() behaviour
|
||||
// where getConfig() didn't read allowBuilds from the manifest).
|
||||
await execPnpmInstall()
|
||||
|
||||
const workspaceManifestFile = path.join(temp, 'pnpm-workspace.yaml')
|
||||
writeYamlFile(workspaceManifestFile, {
|
||||
packages: ['packages/*'],
|
||||
@@ -238,15 +243,21 @@ test('should retain existing allowBuilds entries when approving builds', async (
|
||||
'@pnpm.e2e/install-script-example': true,
|
||||
},
|
||||
})
|
||||
await approveSomeBuilds(
|
||||
{
|
||||
workspaceDir: temp,
|
||||
rootProjectManifestDir: temp,
|
||||
allowBuilds: {
|
||||
'@pnpm.e2e/test': false,
|
||||
'@pnpm.e2e/install-script-example': true,
|
||||
},
|
||||
})
|
||||
|
||||
const config = await getApproveBuildsConfig()
|
||||
prompt.mockResolvedValueOnce({
|
||||
result: [{ value: '@pnpm.e2e/pre-and-postinstall-scripts-example' }],
|
||||
})
|
||||
prompt.mockResolvedValueOnce({ build: true })
|
||||
await approveBuilds.handler({
|
||||
...config,
|
||||
workspaceDir: temp,
|
||||
rootProjectManifestDir: temp,
|
||||
allowBuilds: {
|
||||
'@pnpm.e2e/test': false,
|
||||
'@pnpm.e2e/install-script-example': true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(readYamlFile(workspaceManifestFile)).toStrictEqual({
|
||||
packages: ['packages/*'],
|
||||
|
||||
@@ -24,15 +24,15 @@
|
||||
{
|
||||
"path": "../../packages/dependency-path"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/error"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/types"
|
||||
},
|
||||
{
|
||||
"path": "../../pkg-manager/modules-yaml"
|
||||
},
|
||||
{
|
||||
"path": "../../pkg-manager/plugin-commands-installation"
|
||||
},
|
||||
{
|
||||
"path": "../plugin-commands-rebuild"
|
||||
}
|
||||
|
||||
@@ -275,8 +275,8 @@ export function createCacheKey (opts: {
|
||||
allowBuild?: string[]
|
||||
supportedArchitectures?: SupportedArchitectures
|
||||
}): string {
|
||||
const sortedPkgs = [...opts.packages].sort((a, b) => a.localeCompare(b))
|
||||
const sortedRegistries = Object.entries(opts.registries).sort(([k1], [k2]) => k1.localeCompare(k2))
|
||||
const sortedPkgs = [...opts.packages].sort(lexCompare)
|
||||
const sortedRegistries = Object.entries(opts.registries).sort(([k1], [k2]) => lexCompare(k1, k2))
|
||||
const args: unknown[] = [sortedPkgs, sortedRegistries]
|
||||
if (opts.allowBuild?.length) {
|
||||
args.push({ allowBuild: opts.allowBuild.sort(lexCompare) })
|
||||
|
||||
151
global/commands/README.md
Normal file
151
global/commands/README.md
Normal 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)
|
||||
}
|
||||
```
|
||||
60
global/commands/package.json
Normal file
60
global/commands/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
59
global/commands/src/checkGlobalBinConflicts.ts
Normal file
59
global/commands/src/checkGlobalBinConflicts.ts
Normal 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}`,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
170
global/commands/src/globalAdd.ts
Normal file
170
global/commands/src/globalAdd.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
44
global/commands/src/globalRemove.ts
Normal file
44
global/commands/src/globalRemove.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
156
global/commands/src/globalUpdate.ts
Normal file
156
global/commands/src/globalUpdate.ts
Normal 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)
|
||||
}
|
||||
6
global/commands/src/index.ts
Normal file
6
global/commands/src/index.ts
Normal 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'
|
||||
63
global/commands/src/installGlobalPackages.ts
Normal file
63
global/commands/src/installGlobalPackages.ts
Normal 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
|
||||
}
|
||||
24
global/commands/src/listGlobalPackages.ts
Normal file
24
global/commands/src/listGlobalPackages.ts
Normal 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')
|
||||
}
|
||||
15
global/commands/src/readInstalledPackages.ts
Normal file
15
global/commands/src/readInstalledPackages.ts
Normal 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),
|
||||
}))
|
||||
}
|
||||
52
global/commands/tsconfig.json
Normal file
52
global/commands/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
global/commands/tsconfig.lint.json
Normal file
8
global/commands/tsconfig.lint.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
49
global/packages/package.json
Normal file
49
global/packages/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
12
global/packages/src/cacheKey.ts
Normal file
12
global/packages/src/cacheKey.ts
Normal 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)
|
||||
}
|
||||
28
global/packages/src/globalPackageDir.ts
Normal file
28
global/packages/src/globalPackageDir.ts
Normal 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
|
||||
}
|
||||
15
global/packages/src/index.ts
Normal file
15
global/packages/src/index.ts
Normal 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'
|
||||
127
global/packages/src/scanGlobalPackages.ts
Normal file
127
global/packages/src/scanGlobalPackages.ts
Normal 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]
|
||||
}
|
||||
25
global/packages/tsconfig.json
Normal file
25
global/packages/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
global/packages/tsconfig.lint.json
Normal file
8
global/packages/tsconfig.lint.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const MANIFEST_BASE_NAMES = ['package.json', 'package.json5', 'package.ya
|
||||
export const ENGINE_NAME = `${process.platform};${process.arch};node${process.version.split('.')[0].substring(1)}`
|
||||
export const LAYOUT_VERSION = 5
|
||||
export const STORE_VERSION = 'v11'
|
||||
export const GLOBAL_LAYOUT_VERSION = 'v11'
|
||||
|
||||
export const GLOBAL_CONFIG_YAML_FILENAME = 'config.yaml'
|
||||
export const WORKSPACE_MANIFEST_FILENAME = 'pnpm-workspace.yaml'
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"@pnpm/filter-workspace-packages": "workspace:*",
|
||||
"@pnpm/find-workspace-dir": "workspace:*",
|
||||
"@pnpm/get-context": "workspace:*",
|
||||
"@pnpm/global.commands": "workspace:*",
|
||||
"@pnpm/graceful-fs": "workspace:*",
|
||||
"@pnpm/lockfile.types": "workspace:*",
|
||||
"@pnpm/manifest-utils": "workspace:*",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-
|
||||
import { types as allTypes } from '@pnpm/config'
|
||||
import { resolveConfigDeps } from '@pnpm/config.deps-installer'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { handleGlobalAdd } from '@pnpm/global.commands'
|
||||
import { createStoreController } from '@pnpm/store-connection-manager'
|
||||
import { pick } from 'ramda'
|
||||
import renderHelp from 'render-help'
|
||||
@@ -250,6 +251,7 @@ export async function handler (
|
||||
if (params.includes('pnpm') || params.includes('@pnpm/exe')) {
|
||||
throw new PnpmError('GLOBAL_PNPM_INSTALL', 'Use the "pnpm self-update" command to install or update pnpm')
|
||||
}
|
||||
return handleGlobalAdd(opts, params)
|
||||
}
|
||||
|
||||
const include = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { docsUrl } from '@pnpm/cli-utils'
|
||||
import { FILTERING, OPTIONS, OUTPUT_OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help'
|
||||
import { type Config, types as allTypes } from '@pnpm/config'
|
||||
import { WANTED_LOCKFILE } from '@pnpm/constants'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { type CreateStoreControllerOptions } from '@pnpm/store-connection-manager'
|
||||
import { pick } from 'ramda'
|
||||
import renderHelp from 'render-help'
|
||||
@@ -328,7 +329,7 @@ export type InstallCommandOptions = Pick<Config,
|
||||
| 'overrides'
|
||||
| 'supportedArchitectures'
|
||||
| 'packageConfigs'
|
||||
> & CreateStoreControllerOptions & {
|
||||
> & CreateStoreControllerOptions & Partial<Pick<Config, 'globalPkgDir'>> & {
|
||||
argv: {
|
||||
original: string[]
|
||||
}
|
||||
@@ -347,7 +348,11 @@ export type InstallCommandOptions = Pick<Config,
|
||||
pnpmfile: string[]
|
||||
} & 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 = {
|
||||
dependencies: opts.production !== false,
|
||||
devDependencies: opts.dev !== false,
|
||||
|
||||
@@ -38,7 +38,6 @@ type LinkOpts = Pick<Config,
|
||||
| 'workspaceDir'
|
||||
| 'workspacePackagePatterns'
|
||||
| 'sharedWorkspaceLockfile'
|
||||
| 'globalPkgDir'
|
||||
> & Partial<Pick<Config, 'linkWorkspacePackages'>> & install.InstallCommandOptions
|
||||
|
||||
export const rcOptionsTypes = cliOptionsTypes
|
||||
@@ -74,8 +73,7 @@ export function help (): string {
|
||||
],
|
||||
url: docsUrl('link'),
|
||||
usages: [
|
||||
'pnpm link <dir|pkg name>',
|
||||
'pnpm link',
|
||||
'pnpm link <dir>',
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -123,37 +121,18 @@ export async function handler (
|
||||
binsDir: opts.bin,
|
||||
})
|
||||
|
||||
if (opts.cliOptions?.global && !opts.bin) {
|
||||
throw new PnpmError('NO_GLOBAL_BIN_DIR', 'Unable to find the global bin directory', {
|
||||
hint: 'Run "pnpm setup" to create it automatically, or set the global-bin-dir setting, or the PNPM_HOME env variable. The global bin directory should be in the PATH.',
|
||||
})
|
||||
}
|
||||
|
||||
const writeProjectManifest = await createProjectManifestWriter(opts.rootProjectManifestDir)
|
||||
|
||||
// pnpm link
|
||||
if ((params == null) || (params.length === 0)) {
|
||||
const cwd = process.cwd()
|
||||
if (path.relative(linkOpts.dir, cwd) === '') {
|
||||
throw new PnpmError('LINK_BAD_PARAMS', 'You must provide a parameter')
|
||||
}
|
||||
|
||||
await checkPeerDeps(cwd, opts)
|
||||
|
||||
const newManifest = opts.rootProjectManifest ?? {}
|
||||
await addLinkToManifest(opts, newManifest, cwd, opts.rootProjectManifestDir)
|
||||
await writeProjectManifest(newManifest)
|
||||
await install.handler({
|
||||
...linkOpts,
|
||||
frozenLockfileIfExists: false,
|
||||
rootProjectManifest: newManifest,
|
||||
})
|
||||
return
|
||||
throw new PnpmError('LINK_BAD_PARAMS', 'You must provide a parameter. Usage: pnpm link <dir>')
|
||||
}
|
||||
|
||||
const [pkgPaths, pkgNames] = partition((inp) => isFilespec.test(inp), params)
|
||||
|
||||
pkgNames.forEach((pkgName) => pkgPaths.push(path.join(opts.globalPkgDir, 'node_modules', pkgName)))
|
||||
if (pkgNames.length > 0) {
|
||||
throw new PnpmError('LINK_BAD_PARAMS',
|
||||
`Cannot link by package name. Use a relative or absolute path instead, e.g. "pnpm link ./${pkgNames[0]}"`)
|
||||
}
|
||||
|
||||
const newManifest = opts.rootProjectManifest ?? {}
|
||||
await Promise.all(
|
||||
@@ -166,6 +145,7 @@ export async function handler (
|
||||
await writeProjectManifest(newManifest)
|
||||
await install.handler({
|
||||
...linkOpts,
|
||||
_calledFromLink: true,
|
||||
frozenLockfileIfExists: false,
|
||||
rootProjectManifest: newManifest,
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-
|
||||
import { type Config, getOptionsFromRootManifest, types as allTypes } from '@pnpm/config'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { arrayOfWorkspacePackagesToMap } from '@pnpm/get-context'
|
||||
import { handleGlobalRemove } from '@pnpm/global.commands'
|
||||
import { findWorkspacePackages } from '@pnpm/workspace.find-packages'
|
||||
import { updateWorkspaceManifest } from '@pnpm/workspace.manifest-writer'
|
||||
import { getAllDependenciesFromManifest } from '@pnpm/manifest-utils'
|
||||
@@ -154,10 +155,18 @@ export async function handler (
|
||||
> & {
|
||||
recursive?: boolean
|
||||
pnpmfile: string[]
|
||||
},
|
||||
} & Partial<Pick<Config, 'global' | 'globalPkgDir'>>,
|
||||
params: string[]
|
||||
): Promise<void> {
|
||||
if (params.length === 0) throw new PnpmError('MUST_REMOVE_SOMETHING', 'At least one dependency name should be specified for removal')
|
||||
if (opts.global) {
|
||||
if (!opts.bin) {
|
||||
throw new PnpmError('NO_GLOBAL_BIN_DIR', 'Unable to find the global bin directory', {
|
||||
hint: 'Run "pnpm setup" to create it automatically, or set the global-bin-dir setting, or the PNPM_HOME env variable. The global bin directory should be in the PATH.',
|
||||
})
|
||||
}
|
||||
return handleGlobalRemove(opts, params)
|
||||
}
|
||||
const include = {
|
||||
dependencies: opts.production !== false,
|
||||
devDependencies: opts.dev !== false,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { type CompletionFunc } from '@pnpm/command'
|
||||
import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help'
|
||||
import { types as allTypes } from '@pnpm/config'
|
||||
import { handleGlobalUpdate } from '@pnpm/global.commands'
|
||||
import { globalInfo } from '@pnpm/logger'
|
||||
import { createMatcher } from '@pnpm/matcher'
|
||||
import { outdatedDepsOfProjects } from '@pnpm/outdated'
|
||||
@@ -168,8 +169,13 @@ export async function handler (
|
||||
opts: UpdateCommandOptions,
|
||||
params: string[] = []
|
||||
): Promise<string | undefined> {
|
||||
if (opts.global && opts.rootProjectManifest == null) {
|
||||
return 'No global packages found'
|
||||
if (opts.global) {
|
||||
if (!opts.bin) {
|
||||
throw new PnpmError('NO_GLOBAL_BIN_DIR', 'Unable to find the global bin directory', {
|
||||
hint: 'Run "pnpm setup" to create it automatically, or set the global-bin-dir setting, or the PNPM_HOME env variable. The global bin directory should be in the PATH.',
|
||||
})
|
||||
}
|
||||
return handleGlobalUpdate(opts, params)
|
||||
}
|
||||
if (opts.interactive) {
|
||||
return interactiveUpdate(params, opts)
|
||||
|
||||
@@ -3,11 +3,9 @@ import path from 'path'
|
||||
import { prepare, preparePackages, prepareEmpty } from '@pnpm/prepare'
|
||||
import { isExecutable, assertProject } from '@pnpm/assert-project'
|
||||
import { fixtures } from '@pnpm/test-fixtures'
|
||||
import { loadJsonFileSync } from 'load-json-file'
|
||||
import PATH from 'path-name'
|
||||
import { sync as readYamlFile } from 'read-yaml-file'
|
||||
import { writePackageSync } from 'write-package'
|
||||
import { type PnpmError } from '@pnpm/error'
|
||||
import { jest } from '@jest/globals'
|
||||
import { sync as writeYamlFile } from 'write-yaml-file'
|
||||
import { DEFAULT_OPTS } from './utils/index.js'
|
||||
@@ -33,32 +31,18 @@ test('linking multiple packages', async () => {
|
||||
const project = prepare()
|
||||
|
||||
process.chdir('..')
|
||||
const globalDir = path.resolve('global')
|
||||
|
||||
writePackageSync('linked-foo', { name: 'linked-foo', version: '1.0.0' })
|
||||
writePackageSync('linked-bar', { name: 'linked-bar', version: '1.0.0', dependencies: { 'is-positive': '1.0.0' } })
|
||||
fs.writeFileSync('linked-bar/.npmrc', 'shamefully-hoist = true')
|
||||
|
||||
process.chdir('linked-foo')
|
||||
|
||||
// linking linked-foo to global package
|
||||
await link.handler({
|
||||
...DEFAULT_OPTS,
|
||||
bin: path.join(globalDir, 'bin'),
|
||||
dir: globalDir,
|
||||
globalPkgDir: globalDir,
|
||||
rootProjectManifestDir: globalDir,
|
||||
})
|
||||
|
||||
process.chdir('..')
|
||||
process.chdir('project')
|
||||
|
||||
await link.handler({
|
||||
...DEFAULT_OPTS,
|
||||
dir: process.cwd(),
|
||||
globalPkgDir: globalDir,
|
||||
rootProjectManifestDir: process.cwd(),
|
||||
}, ['linked-foo', '../linked-bar'])
|
||||
}, ['../linked-foo', '../linked-bar'])
|
||||
|
||||
project.has('linked-foo')
|
||||
project.has('linked-bar')
|
||||
@@ -77,7 +61,7 @@ test('link global bin', async function () {
|
||||
writePackageSync('package-with-bin', { name: 'package-with-bin', version: '1.0.0', bin: 'bin.js' })
|
||||
fs.writeFileSync('package-with-bin/bin.js', '#!/usr/bin/env node\nconsole.log(/hi/)\n', 'utf8')
|
||||
|
||||
process.chdir('package-with-bin')
|
||||
const pkgWithBinDir = path.resolve('package-with-bin')
|
||||
|
||||
await link.handler({
|
||||
...DEFAULT_OPTS,
|
||||
@@ -85,7 +69,7 @@ test('link global bin', async function () {
|
||||
dir: globalDir,
|
||||
globalPkgDir: globalDir,
|
||||
rootProjectManifestDir: globalDir,
|
||||
})
|
||||
}, [pkgWithBinDir])
|
||||
process.env[PATH] = oldPath
|
||||
|
||||
isExecutable((value) => {
|
||||
@@ -93,51 +77,6 @@ test('link global bin', async function () {
|
||||
}, path.join(globalBin, 'package-with-bin'))
|
||||
})
|
||||
|
||||
test('link a global package to the specified directory', async function () {
|
||||
const project = prepare({ dependencies: { 'global-package-with-bin': '0.0.0' } })
|
||||
process.chdir('..')
|
||||
|
||||
const globalDir = path.resolve('global')
|
||||
const globalBin = path.join(globalDir, 'bin')
|
||||
const oldPath = process.env[PATH]
|
||||
process.env[PATH] = `${globalBin}${path.delimiter}${oldPath ?? ''}`
|
||||
fs.mkdirSync(globalBin, { recursive: true })
|
||||
|
||||
writePackageSync('global-package-with-bin', { name: 'global-package-with-bin', version: '1.0.0', bin: 'bin.js' })
|
||||
fs.writeFileSync('global-package-with-bin/bin.js', '#!/usr/bin/env node\nconsole.log(/hi/)\n', 'utf8')
|
||||
|
||||
process.chdir('global-package-with-bin')
|
||||
|
||||
// link to global
|
||||
await link.handler({
|
||||
...DEFAULT_OPTS,
|
||||
bin: globalBin,
|
||||
dir: globalDir,
|
||||
globalPkgDir: globalDir,
|
||||
rootProjectManifestDir: globalDir,
|
||||
})
|
||||
|
||||
process.chdir('..')
|
||||
const projectDir = path.resolve('./project')
|
||||
|
||||
// link from global
|
||||
await link.handler({
|
||||
...DEFAULT_OPTS,
|
||||
// bin: globalBin,
|
||||
dir: projectDir,
|
||||
saveProd: true, // @pnpm/config sets this setting to true when global is true. This should probably be changed.
|
||||
globalPkgDir: globalDir,
|
||||
rootProjectManifest: { dependencies: { 'global-package-with-bin': '0.0.0' } },
|
||||
rootProjectManifestDir: projectDir,
|
||||
}, ['global-package-with-bin'])
|
||||
|
||||
process.env[PATH] = oldPath
|
||||
|
||||
const manifest = loadJsonFileSync<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 () => {
|
||||
const project = prepare({
|
||||
dependencies: {
|
||||
@@ -274,7 +213,6 @@ test('logger warns about peer dependencies when linking', async () => {
|
||||
prepare()
|
||||
|
||||
process.chdir('..')
|
||||
const globalDir = path.resolve('global')
|
||||
|
||||
writePackageSync('linked-with-peer-deps', {
|
||||
name: 'linked-with-peer-deps',
|
||||
@@ -284,25 +222,13 @@ test('logger warns about peer dependencies when linking', async () => {
|
||||
},
|
||||
})
|
||||
|
||||
process.chdir('linked-with-peer-deps')
|
||||
|
||||
await link.handler({
|
||||
...DEFAULT_OPTS,
|
||||
bin: path.join(globalDir, 'bin'),
|
||||
dir: globalDir,
|
||||
globalPkgDir: globalDir,
|
||||
rootProjectManifestDir: globalDir,
|
||||
})
|
||||
|
||||
process.chdir('..')
|
||||
process.chdir('project')
|
||||
|
||||
await link.handler({
|
||||
...DEFAULT_OPTS,
|
||||
dir: process.cwd(),
|
||||
globalPkgDir: globalDir,
|
||||
rootProjectManifestDir: globalDir,
|
||||
}, ['linked-with-peer-deps'])
|
||||
rootProjectManifestDir: process.cwd(),
|
||||
}, ['../linked-with-peer-deps'])
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: expect.stringContaining('has the following peerDependencies specified in its package.json'),
|
||||
@@ -315,7 +241,6 @@ test('logger should not warn about peer dependencies when it is an empty object'
|
||||
prepare()
|
||||
|
||||
process.chdir('..')
|
||||
const globalDir = path.resolve('global')
|
||||
|
||||
writePackageSync('linked-with-empty-peer-deps', {
|
||||
name: 'linked-with-empty-peer-deps',
|
||||
@@ -323,25 +248,13 @@ test('logger should not warn about peer dependencies when it is an empty object'
|
||||
peerDependencies: {},
|
||||
})
|
||||
|
||||
process.chdir('linked-with-empty-peer-deps')
|
||||
|
||||
await link.handler({
|
||||
...DEFAULT_OPTS,
|
||||
globalPkgDir: '',
|
||||
bin: path.join(globalDir, 'bin'),
|
||||
dir: globalDir,
|
||||
rootProjectManifestDir: globalDir,
|
||||
})
|
||||
|
||||
process.chdir('..')
|
||||
process.chdir('project')
|
||||
|
||||
await link.handler({
|
||||
...DEFAULT_OPTS,
|
||||
globalPkgDir: globalDir,
|
||||
dir: process.cwd(),
|
||||
rootProjectManifestDir: process.cwd(),
|
||||
}, ['linked-with-empty-peer-deps'])
|
||||
}, ['../linked-with-empty-peer-deps'])
|
||||
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: expect.stringContaining('has the following peerDependencies specified in its package.json'),
|
||||
@@ -350,28 +263,6 @@ test('logger should not warn about peer dependencies when it is an empty object'
|
||||
jest.mocked(logger.warn).mockRestore()
|
||||
})
|
||||
|
||||
test('link: fail when global bin directory is not found', async () => {
|
||||
prepare()
|
||||
|
||||
const globalDir = path.resolve('global')
|
||||
|
||||
let err!: PnpmError
|
||||
try {
|
||||
await link.handler({
|
||||
...DEFAULT_OPTS,
|
||||
bin: undefined as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
dir: globalDir,
|
||||
globalPkgDir: globalDir,
|
||||
cliOptions: {
|
||||
global: true,
|
||||
},
|
||||
})
|
||||
} catch (_err: any) { // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
err = _err
|
||||
}
|
||||
expect(err.code).toBe('ERR_PNPM_NO_GLOBAL_BIN_DIR')
|
||||
})
|
||||
|
||||
test('relative link from workspace package', async () => {
|
||||
prepareEmpty()
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@
|
||||
{
|
||||
"path": "../../fs/read-modules-dir"
|
||||
},
|
||||
{
|
||||
"path": "../../global/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../hooks/pnpmfile"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from 'path'
|
||||
import util from 'util'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { type PackageManifest } from '@pnpm/types'
|
||||
import { loadJsonFile, loadJsonFileSync } from 'load-json-file'
|
||||
@@ -46,3 +47,12 @@ export async function safeReadPackageJson (pkgPath: string): Promise<PackageMani
|
||||
export async function safeReadPackageJsonFromDir (pkgPath: string): Promise<PackageManifest | null> {
|
||||
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
101
pnpm-lock.yaml
generated
@@ -2419,6 +2419,9 @@ importers:
|
||||
'@pnpm/dependency-path':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/dependency-path
|
||||
'@pnpm/error':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/error
|
||||
'@pnpm/logger':
|
||||
specifier: 'catalog:'
|
||||
version: 1001.0.1
|
||||
@@ -2450,9 +2453,6 @@ importers:
|
||||
'@pnpm/exec.build-commands':
|
||||
specifier: workspace:*
|
||||
version: 'link:'
|
||||
'@pnpm/plugin-commands-installation':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkg-manager/plugin-commands-installation
|
||||
'@pnpm/prepare':
|
||||
specifier: workspace:*
|
||||
version: link:../../__utils__/prepare
|
||||
@@ -2465,6 +2465,9 @@ importers:
|
||||
'@types/ramda':
|
||||
specifier: 'catalog:'
|
||||
version: 0.29.12
|
||||
execa:
|
||||
specifier: 'catalog:'
|
||||
version: safe-execa@0.2.0
|
||||
load-json-file:
|
||||
specifier: 'catalog:'
|
||||
version: 7.0.1
|
||||
@@ -3478,6 +3481,83 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: 'link:'
|
||||
|
||||
global/commands:
|
||||
dependencies:
|
||||
'@pnpm/cli-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../cli/cli-utils
|
||||
'@pnpm/config':
|
||||
specifier: workspace:*
|
||||
version: link:../../config/config
|
||||
'@pnpm/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkg-manager/core
|
||||
'@pnpm/error':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/error
|
||||
'@pnpm/exec.build-commands':
|
||||
specifier: workspace:*
|
||||
version: link:../../exec/build-commands
|
||||
'@pnpm/global.packages':
|
||||
specifier: workspace:*
|
||||
version: link:../packages
|
||||
'@pnpm/link-bins':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkg-manager/link-bins
|
||||
'@pnpm/matcher':
|
||||
specifier: workspace:*
|
||||
version: link:../../config/matcher
|
||||
'@pnpm/package-bins':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkg-manager/package-bins
|
||||
'@pnpm/read-package-json':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkg-manifest/read-package-json
|
||||
'@pnpm/remove-bins':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkg-manager/remove-bins
|
||||
'@pnpm/store-connection-manager':
|
||||
specifier: workspace:*
|
||||
version: link:../../store/store-connection-manager
|
||||
'@pnpm/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
'@pnpm/util.lex-comparator':
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.2
|
||||
is-subdir:
|
||||
specifier: 'catalog:'
|
||||
version: 1.2.0
|
||||
symlink-dir:
|
||||
specifier: 'catalog:'
|
||||
version: 7.1.0
|
||||
devDependencies:
|
||||
'@pnpm/global.commands':
|
||||
specifier: workspace:*
|
||||
version: 'link:'
|
||||
|
||||
global/packages:
|
||||
dependencies:
|
||||
'@pnpm/crypto.hash':
|
||||
specifier: workspace:*
|
||||
version: link:../../crypto/hash
|
||||
'@pnpm/package-bins':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkg-manager/package-bins
|
||||
'@pnpm/read-package-json':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkg-manifest/read-package-json
|
||||
'@pnpm/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
'@pnpm/util.lex-comparator':
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.2
|
||||
devDependencies:
|
||||
'@pnpm/global.packages':
|
||||
specifier: workspace:*
|
||||
version: 'link:'
|
||||
|
||||
hooks/pnpmfile:
|
||||
dependencies:
|
||||
'@pnpm/core-loggers':
|
||||
@@ -5834,6 +5914,9 @@ importers:
|
||||
'@pnpm/get-context':
|
||||
specifier: workspace:*
|
||||
version: link:../get-context
|
||||
'@pnpm/global.commands':
|
||||
specifier: workspace:*
|
||||
version: link:../../global/commands
|
||||
'@pnpm/graceful-fs':
|
||||
specifier: workspace:*
|
||||
version: link:../../fs/graceful-fs
|
||||
@@ -7945,6 +8028,9 @@ importers:
|
||||
'@pnpm/error':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/error
|
||||
'@pnpm/global.commands':
|
||||
specifier: workspace:*
|
||||
version: link:../../global/commands
|
||||
'@pnpm/list':
|
||||
specifier: workspace:*
|
||||
version: link:../list
|
||||
@@ -8018,6 +8104,9 @@ importers:
|
||||
'@pnpm/error':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/error
|
||||
'@pnpm/global.packages':
|
||||
specifier: workspace:*
|
||||
version: link:../../global/packages
|
||||
'@pnpm/lockfile.fs':
|
||||
specifier: workspace:*
|
||||
version: link:../../lockfile/fs
|
||||
@@ -8079,6 +8168,9 @@ importers:
|
||||
'@types/zkochan__table':
|
||||
specifier: 'catalog:'
|
||||
version: '@types/table@6.0.0'
|
||||
symlink-dir:
|
||||
specifier: 'catalog:'
|
||||
version: 7.1.0
|
||||
|
||||
reviewing/plugin-commands-sbom:
|
||||
dependencies:
|
||||
@@ -8443,6 +8535,9 @@ importers:
|
||||
'@pnpm/get-context':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkg-manager/get-context
|
||||
'@pnpm/global.packages':
|
||||
specifier: workspace:*
|
||||
version: link:../../global/packages
|
||||
'@pnpm/lockfile.utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../lockfile/utils
|
||||
|
||||
@@ -17,6 +17,7 @@ packages:
|
||||
- exec/*
|
||||
- fetching/*
|
||||
- fs/*
|
||||
- global/*
|
||||
- hooks/*
|
||||
- lockfile/*
|
||||
- network/*
|
||||
|
||||
@@ -23,7 +23,7 @@ export function help (): string {
|
||||
|
||||
list: [
|
||||
{
|
||||
description: 'Print the global `node_modules` directory',
|
||||
description: 'Print the global packages directory',
|
||||
name: '--global',
|
||||
shortAlias: '-g',
|
||||
},
|
||||
@@ -38,7 +38,11 @@ export function help (): string {
|
||||
export async function handler (
|
||||
opts: {
|
||||
dir: string
|
||||
global?: boolean
|
||||
}
|
||||
): Promise<string> {
|
||||
if (opts.global) {
|
||||
return `${opts.dir}\n`
|
||||
}
|
||||
return `${path.join(opts.dir, 'node_modules')}\n`
|
||||
}
|
||||
|
||||
@@ -1,17 +1,53 @@
|
||||
import path from 'path'
|
||||
import PATH_NAME from 'path-name'
|
||||
import fs from 'fs'
|
||||
import { LAYOUT_VERSION } from '@pnpm/constants'
|
||||
import { prepare } from '@pnpm/prepare'
|
||||
import { type ProjectManifest } from '@pnpm/types'
|
||||
import isWindows from 'is-windows'
|
||||
import writeYamlFile from 'write-yaml-file'
|
||||
import { GLOBAL_LAYOUT_VERSION } from '@pnpm/constants'
|
||||
import {
|
||||
addDistTag,
|
||||
execPnpm,
|
||||
execPnpmSync,
|
||||
} from '../utils/index.js'
|
||||
|
||||
function globalPkgDir (pnpmHome: string): string {
|
||||
return path.join(pnpmHome, 'global', GLOBAL_LAYOUT_VERSION)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an installed global package in the flat isolated directory structure.
|
||||
* Scans globalDir for hash symlinks, resolves them,
|
||||
* and returns the path to the package's node_modules entry.
|
||||
*/
|
||||
function findGlobalPkg (globalDir: string, pkgName: string): string | null {
|
||||
let entries: fs.Dirent[]
|
||||
try {
|
||||
entries = fs.readdirSync(globalDir, { withFileTypes: true })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isSymbolicLink()) continue
|
||||
let installDir: string
|
||||
try {
|
||||
installDir = fs.realpathSync(path.join(globalDir, entry.name))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
let pkgJson: { dependencies?: Record<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 () => {
|
||||
prepare()
|
||||
const global = path.resolve('..', 'global')
|
||||
@@ -20,18 +56,20 @@ test('global installation', async () => {
|
||||
|
||||
const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
|
||||
|
||||
await execPnpm(['install', '--global', 'is-positive'], { env })
|
||||
await execPnpm(['add', '--global', 'is-positive'], { env })
|
||||
|
||||
// there was an issue when subsequent installations were removing everything installed prior
|
||||
// https://github.com/pnpm/pnpm/issues/808
|
||||
await execPnpm(['install', '--global', 'is-negative'], { env })
|
||||
await execPnpm(['add', '--global', 'is-negative'], { env })
|
||||
|
||||
const globalPrefix = path.join(global, `pnpm/global/${LAYOUT_VERSION}`)
|
||||
|
||||
const { default: isPositive } = await import(path.join(globalPrefix, 'node_modules', 'is-positive'))
|
||||
const isPositivePath = findGlobalPkg(globalPkgDir(pnpmHome), 'is-positive')
|
||||
expect(isPositivePath).toBeTruthy()
|
||||
const { default: isPositive } = await import(isPositivePath!)
|
||||
expect(typeof isPositive).toBe('function')
|
||||
|
||||
const { default: isNegative } = await import(path.join(globalPrefix, 'node_modules', 'is-negative'))
|
||||
const isNegativePath = findGlobalPkg(globalPkgDir(pnpmHome), 'is-negative')
|
||||
expect(isNegativePath).toBeTruthy()
|
||||
const { default: isNegative } = await import(isNegativePath!)
|
||||
expect(typeof isNegative).toBe('function')
|
||||
})
|
||||
|
||||
@@ -49,7 +87,7 @@ test('global install warns when project has packageManager configured', async ()
|
||||
const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
|
||||
|
||||
const { status } = execPnpmSync([
|
||||
'install',
|
||||
'add',
|
||||
'--global',
|
||||
'is-positive',
|
||||
'--config.package-manager-strict=true',
|
||||
@@ -66,7 +104,9 @@ test('global installation to custom directory with --global-dir', async () => {
|
||||
|
||||
await execPnpm(['add', '--global', '--global-dir=../global', 'is-positive'], { env })
|
||||
|
||||
const { default: isPositive } = await import(path.resolve(`../global/${LAYOUT_VERSION}/node_modules/is-positive`))
|
||||
const isPositivePath = findGlobalPkg(path.join(global, 'v11'), 'is-positive')
|
||||
expect(isPositivePath).toBeTruthy()
|
||||
const { default: isPositive } = await import(isPositivePath!)
|
||||
expect(typeof isPositive).toBe('function')
|
||||
})
|
||||
|
||||
@@ -80,14 +120,12 @@ test('always install latest when doing global installation without spec', async
|
||||
|
||||
const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
|
||||
|
||||
await execPnpm(['install', '-g', '@pnpm.e2e/peer-c@1'], { env })
|
||||
await execPnpm(['install', '-g', '@pnpm.e2e/peer-c'], { env })
|
||||
await execPnpm(['add', '-g', '@pnpm.e2e/peer-c@1'], { env })
|
||||
await execPnpm(['add', '-g', '@pnpm.e2e/peer-c'], { env })
|
||||
|
||||
const globalPrefix = path.join(global, `pnpm/global/${LAYOUT_VERSION}`)
|
||||
|
||||
process.chdir(globalPrefix)
|
||||
|
||||
expect((await import(path.resolve('node_modules', '@pnpm.e2e/peer-c', 'package.json'))).default.version).toBe('2.0.0')
|
||||
const peerCPath = findGlobalPkg(globalPkgDir(pnpmHome), '@pnpm.e2e/peer-c')
|
||||
expect(peerCPath).toBeTruthy()
|
||||
expect((await import(path.join(peerCPath!, 'package.json'))).default.version).toBe('2.0.0')
|
||||
})
|
||||
|
||||
test('run lifecycle events of global packages in correct working directory', async () => {
|
||||
@@ -99,14 +137,7 @@ test('run lifecycle events of global packages in correct working directory', asy
|
||||
prepare()
|
||||
const global = path.resolve('..', 'global')
|
||||
const pnpmHome = path.join(global, 'pnpm')
|
||||
const globalPkgDir = path.join(pnpmHome, 'global', String(LAYOUT_VERSION))
|
||||
fs.mkdirSync(globalPkgDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(globalPkgDir, 'package.json'), JSON.stringify({}))
|
||||
writeYamlFile.sync(path.join(globalPkgDir, 'pnpm-workspace.yaml'), {
|
||||
allowBuilds: {
|
||||
'@pnpm.e2e/postinstall-calls-pnpm': true,
|
||||
},
|
||||
})
|
||||
fs.mkdirSync(pnpmHome, { recursive: true })
|
||||
|
||||
const env = {
|
||||
[PATH_NAME]: `${pnpmHome}${path.delimiter}${process.env[PATH_NAME]!}`,
|
||||
@@ -114,9 +145,11 @@ test('run lifecycle events of global packages in correct working directory', asy
|
||||
XDG_DATA_HOME: global,
|
||||
}
|
||||
|
||||
await execPnpm(['install', '-g', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env })
|
||||
await execPnpm(['add', '-g', '--allow-build=@pnpm.e2e/postinstall-calls-pnpm', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env })
|
||||
|
||||
expect(fs.existsSync(path.join(globalPkgDir, 'node_modules/@pnpm.e2e/postinstall-calls-pnpm/created-by-postinstall'))).toBeTruthy()
|
||||
const pkgPath = findGlobalPkg(globalPkgDir(pnpmHome), '@pnpm.e2e/postinstall-calls-pnpm')
|
||||
expect(pkgPath).toBeTruthy()
|
||||
expect(fs.existsSync(path.join(pkgPath!, 'created-by-postinstall'))).toBeTruthy()
|
||||
})
|
||||
|
||||
// CONTEXT: dangerously-allow-all-builds has been removed from rc files, as a result, this test no longer applies
|
||||
@@ -142,7 +175,7 @@ test.skip('dangerously-allow-all-builds=true in global config', async () => {
|
||||
const pnpmRcFile = path.join(pnpmCfgDir, 'rc')
|
||||
const global = path.resolve('..', 'global')
|
||||
const pnpmHome = path.join(global, 'pnpm')
|
||||
const globalPkgDir = path.join(pnpmHome, 'global', String(LAYOUT_VERSION))
|
||||
const globalDir = globalPkgDir(pnpmHome)
|
||||
fs.mkdirSync(pnpmCfgDir, { recursive: true })
|
||||
fs.writeFileSync(pnpmRcFile, [
|
||||
'reporter=append-only',
|
||||
@@ -158,8 +191,8 @@ test.skip('dangerously-allow-all-builds=true in global config', async () => {
|
||||
}
|
||||
|
||||
// global install should run scripts
|
||||
await execPnpm(['install', '-g', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env })
|
||||
expect(fs.readdirSync(path.join(globalPkgDir, 'node_modules/@pnpm.e2e/postinstall-calls-pnpm'))).toContain('created-by-postinstall')
|
||||
await execPnpm(['add', '-g', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env })
|
||||
expect(fs.readdirSync(path.join(globalDir, 'node_modules/@pnpm.e2e/postinstall-calls-pnpm'))).toContain('created-by-postinstall')
|
||||
|
||||
// local config should override global config
|
||||
await execPnpm(['add', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env })
|
||||
@@ -197,7 +230,7 @@ test.skip('dangerously-allow-all-builds=false in global config', async () => {
|
||||
const pnpmRcFile = path.join(pnpmCfgDir, 'rc')
|
||||
const global = path.resolve('..', 'global')
|
||||
const pnpmHome = path.join(global, 'pnpm')
|
||||
const globalPkgDir = path.join(pnpmHome, 'global', String(LAYOUT_VERSION))
|
||||
const globalDir = globalPkgDir(pnpmHome)
|
||||
fs.mkdirSync(pnpmCfgDir, { recursive: true })
|
||||
fs.writeFileSync(pnpmRcFile, [
|
||||
'reporter=append-only',
|
||||
@@ -213,8 +246,8 @@ test.skip('dangerously-allow-all-builds=false in global config', async () => {
|
||||
}
|
||||
|
||||
// global install should run scripts
|
||||
await execPnpm(['install', '-g', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env })
|
||||
expect(fs.readdirSync(path.join(globalPkgDir, 'node_modules/@pnpm.e2e/postinstall-calls-pnpm'))).not.toContain('created-by-postinstall')
|
||||
await execPnpm(['add', '-g', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env })
|
||||
expect(fs.readdirSync(path.join(globalDir, 'node_modules/@pnpm.e2e/postinstall-calls-pnpm'))).not.toContain('created-by-postinstall')
|
||||
|
||||
// local config should override global config
|
||||
await execPnpm(['add', '@pnpm.e2e/postinstall-calls-pnpm@1.0.0'], { env })
|
||||
@@ -237,13 +270,13 @@ test('global update to latest', async () => {
|
||||
|
||||
const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
|
||||
|
||||
await execPnpm(['install', '--global', 'is-positive@1'], { env })
|
||||
await execPnpm(['add', '--global', 'is-positive@1'], { env })
|
||||
await execPnpm(['update', '--global', '--latest'], { env })
|
||||
|
||||
const globalPrefix = path.join(global, `pnpm/global/${LAYOUT_VERSION}`)
|
||||
|
||||
const { default: isPositive } = await import(path.join(globalPrefix, 'node_modules/is-positive/package.json'))
|
||||
expect(isPositive.version).toBe('3.1.0')
|
||||
const isPositivePath = findGlobalPkg(globalPkgDir(pnpmHome), 'is-positive')
|
||||
expect(isPositivePath).toBeTruthy()
|
||||
const pkgJson = JSON.parse(fs.readFileSync(path.join(isPositivePath!, 'package.json'), 'utf-8'))
|
||||
expect(pkgJson.version).toBe('3.1.0')
|
||||
})
|
||||
|
||||
test('global update should not crash if there are no global packages', async () => {
|
||||
@@ -256,3 +289,123 @@ test('global update should not crash if there are no global packages', async ()
|
||||
|
||||
expect(execPnpmSync(['update', '--global'], { env }).status).toBe(0)
|
||||
})
|
||||
|
||||
test('global add cleans up stale bins when re-adding a package with different bins', async () => {
|
||||
prepare()
|
||||
const global = path.resolve('..', 'global')
|
||||
const pnpmHome = path.join(global, 'pnpm')
|
||||
fs.mkdirSync(pnpmHome, { recursive: true })
|
||||
|
||||
const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
|
||||
|
||||
// Create v1 tarball with bin "old-bin"
|
||||
const pkgDir = path.resolve('..', 'my-tool')
|
||||
fs.mkdirSync(path.join(pkgDir, 'package'), { recursive: true })
|
||||
fs.writeFileSync(path.join(pkgDir, 'package', 'package.json'), JSON.stringify({
|
||||
name: 'my-tool',
|
||||
version: '1.0.0',
|
||||
bin: { 'old-bin': './index.js' },
|
||||
}))
|
||||
fs.writeFileSync(path.join(pkgDir, 'package', 'index.js'), '#!/usr/bin/env node\nconsole.log("v1")\n')
|
||||
const tarballV1 = path.join(pkgDir, 'my-tool-1.0.0.tgz')
|
||||
execPnpmSync(['pack', '--pack-destination', pkgDir], { cwd: path.join(pkgDir, 'package') })
|
||||
|
||||
await execPnpm(['add', '-g', tarballV1], { env })
|
||||
expect(fs.existsSync(path.join(pnpmHome, 'old-bin'))).toBeTruthy()
|
||||
|
||||
// Create v2 tarball with bin "new-bin"
|
||||
fs.writeFileSync(path.join(pkgDir, 'package', 'package.json'), JSON.stringify({
|
||||
name: 'my-tool',
|
||||
version: '2.0.0',
|
||||
bin: { 'new-bin': './index.js' },
|
||||
}))
|
||||
const tarballV2 = path.join(pkgDir, 'my-tool-2.0.0.tgz')
|
||||
execPnpmSync(['pack', '--pack-destination', pkgDir], { cwd: path.join(pkgDir, 'package') })
|
||||
|
||||
// Re-add the same package from new tarball — old bins should be cleaned up
|
||||
await execPnpm(['add', '-g', tarballV2], { env })
|
||||
|
||||
// old-bin should be gone, new-bin should exist
|
||||
expect(fs.existsSync(path.join(pnpmHome, 'old-bin'))).toBeFalsy()
|
||||
expect(fs.existsSync(path.join(pnpmHome, 'new-bin'))).toBeTruthy()
|
||||
})
|
||||
|
||||
test('global add refuses to install when bin name conflicts with another global package', async () => {
|
||||
prepare()
|
||||
const global = path.resolve('..', 'global')
|
||||
const pnpmHome = path.join(global, 'pnpm')
|
||||
fs.mkdirSync(pnpmHome, { recursive: true })
|
||||
|
||||
const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
|
||||
|
||||
// Create two local packages that both expose a bin called "my-bin"
|
||||
const pkgA = path.resolve('..', 'pkg-a')
|
||||
fs.mkdirSync(pkgA, { recursive: true })
|
||||
fs.writeFileSync(path.join(pkgA, 'package.json'), JSON.stringify({
|
||||
name: 'pkg-a',
|
||||
version: '1.0.0',
|
||||
bin: { 'my-bin': './index.js' },
|
||||
}))
|
||||
fs.writeFileSync(path.join(pkgA, 'index.js'), '#!/usr/bin/env node\nconsole.log("a")\n')
|
||||
|
||||
const pkgB = path.resolve('..', 'pkg-b')
|
||||
fs.mkdirSync(pkgB, { recursive: true })
|
||||
fs.writeFileSync(path.join(pkgB, 'package.json'), JSON.stringify({
|
||||
name: 'pkg-b',
|
||||
version: '1.0.0',
|
||||
bin: { 'my-bin': './index.js' },
|
||||
}))
|
||||
fs.writeFileSync(path.join(pkgB, 'index.js'), '#!/usr/bin/env node\nconsole.log("b")\n')
|
||||
|
||||
// Install pkg-a globally — should succeed
|
||||
await execPnpm(['add', '-g', pkgA], { env })
|
||||
expect(findGlobalPkg(globalPkgDir(pnpmHome), 'pkg-a')).toBeTruthy()
|
||||
|
||||
// Install pkg-b globally — should fail due to bin conflict
|
||||
const result = execPnpmSync(['add', '-g', pkgB], { env })
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(result.stdout.toString()).toContain('ERR_PNPM_GLOBAL_BIN_CONFLICT')
|
||||
|
||||
// pkg-a should still be installed
|
||||
expect(findGlobalPkg(globalPkgDir(pnpmHome), 'pkg-a')).toBeTruthy()
|
||||
})
|
||||
|
||||
test('global remove deletes install group and bin shims', async () => {
|
||||
prepare()
|
||||
const global = path.resolve('..', 'global')
|
||||
const pnpmHome = path.join(global, 'pnpm')
|
||||
fs.mkdirSync(pnpmHome, { recursive: true })
|
||||
|
||||
const env = { [PATH_NAME]: pnpmHome, PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
|
||||
|
||||
// Create two packages with bins and install them together as a group
|
||||
const pkgA = path.resolve('..', 'tool-a')
|
||||
fs.mkdirSync(pkgA, { recursive: true })
|
||||
fs.writeFileSync(path.join(pkgA, 'package.json'), JSON.stringify({
|
||||
name: 'tool-a',
|
||||
version: '1.0.0',
|
||||
bin: { 'tool-a-bin': './index.js' },
|
||||
}))
|
||||
fs.writeFileSync(path.join(pkgA, 'index.js'), '#!/usr/bin/env node\nconsole.log("a")\n')
|
||||
|
||||
const pkgB = path.resolve('..', 'tool-b')
|
||||
fs.mkdirSync(pkgB, { recursive: true })
|
||||
fs.writeFileSync(path.join(pkgB, 'package.json'), JSON.stringify({
|
||||
name: 'tool-b',
|
||||
version: '1.0.0',
|
||||
bin: { 'tool-b-bin': './index.js' },
|
||||
}))
|
||||
fs.writeFileSync(path.join(pkgB, 'index.js'), '#!/usr/bin/env node\nconsole.log("b")\n')
|
||||
|
||||
// Install as a group
|
||||
await execPnpm(['add', '-g', pkgA, pkgB], { env })
|
||||
expect(fs.existsSync(path.join(pnpmHome, 'tool-a-bin'))).toBeTruthy()
|
||||
expect(fs.existsSync(path.join(pnpmHome, 'tool-b-bin'))).toBeTruthy()
|
||||
|
||||
// Remove one package — entire group (both bins) should be removed
|
||||
await execPnpm(['remove', '-g', 'tool-a'], { env })
|
||||
expect(fs.existsSync(path.join(pnpmHome, 'tool-a-bin'))).toBeFalsy()
|
||||
expect(fs.existsSync(path.join(pnpmHome, 'tool-b-bin'))).toBeFalsy()
|
||||
expect(findGlobalPkg(globalPkgDir(pnpmHome), 'tool-a')).toBeNull()
|
||||
expect(findGlobalPkg(globalPkgDir(pnpmHome), 'tool-b')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import PATH_NAME from 'path-name'
|
||||
import { LAYOUT_VERSION } from '@pnpm/constants'
|
||||
import { GLOBAL_LAYOUT_VERSION } from '@pnpm/constants'
|
||||
import { tempDir } from '@pnpm/prepare'
|
||||
import { execPnpmSync } from './utils/index.js'
|
||||
|
||||
@@ -28,5 +28,5 @@ test('pnpm root -g', async () => {
|
||||
const result = execPnpmSync(['root', '-g'], { env })
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout.toString()).toBe(path.join(global, `pnpm/global/${LAYOUT_VERSION}/node_modules`) + '\n')
|
||||
expect(result.stdout.toString()).toBe(path.join(global, `pnpm/global/${GLOBAL_LAYOUT_VERSION}`) + '\n')
|
||||
})
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@pnpm/common-cli-options-help": "workspace:*",
|
||||
"@pnpm/config": "workspace:*",
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/global.commands": "workspace:*",
|
||||
"@pnpm/list": "workspace:*",
|
||||
"@pnpm/types": "workspace:*",
|
||||
"ramda": "catalog:",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { docsUrl } from '@pnpm/cli-utils'
|
||||
import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help'
|
||||
import { type Config, types as allTypes } from '@pnpm/config'
|
||||
import { listGlobalPackages } from '@pnpm/global.commands'
|
||||
import { list, listForPackages } from '@pnpm/list'
|
||||
import { type Finder, type IncludedDependencies } from '@pnpm/types'
|
||||
import { pick } from 'ramda'
|
||||
@@ -101,12 +102,15 @@ export type ListCommandOptions = Pick<Config,
|
||||
onlyProjects?: boolean
|
||||
recursive?: boolean
|
||||
findBy?: string[]
|
||||
}
|
||||
} & Partial<Pick<Config, 'global' | 'globalPkgDir'>>
|
||||
|
||||
export async function handler (
|
||||
opts: ListCommandOptions,
|
||||
params: string[]
|
||||
): Promise<string> {
|
||||
if (opts.global && opts.globalPkgDir) {
|
||||
return listGlobalPackages(opts.globalPkgDir, params)
|
||||
}
|
||||
const include = computeInclude(opts)
|
||||
const depth = opts.cliOptions?.['depth'] ?? 0
|
||||
if (opts.recursive && (opts.selectedProjectsGraph != null)) {
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
{
|
||||
"path": "../../config/config"
|
||||
},
|
||||
{
|
||||
"path": "../../global/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/constants"
|
||||
},
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@pnpm/config": "workspace:*",
|
||||
"@pnpm/default-resolver": "workspace:*",
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/global.packages": "workspace:*",
|
||||
"@pnpm/lockfile.fs": "workspace:*",
|
||||
"@pnpm/matcher": "workspace:*",
|
||||
"@pnpm/modules-yaml": "workspace:*",
|
||||
@@ -60,7 +61,8 @@
|
||||
"@pnpm/test-fixtures": "workspace:*",
|
||||
"@pnpm/workspace.filter-packages-from-dir": "workspace:*",
|
||||
"@types/ramda": "catalog:",
|
||||
"@types/zkochan__table": "catalog:"
|
||||
"@types/zkochan__table": "catalog:",
|
||||
"symlink-dir": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13"
|
||||
|
||||
@@ -9,12 +9,13 @@ import { type CompletionFunc } from '@pnpm/command'
|
||||
import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help'
|
||||
import { type Config, types as allTypes } from '@pnpm/config'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { scanGlobalPackages } from '@pnpm/global.packages'
|
||||
import {
|
||||
outdatedDepsOfProjects,
|
||||
type OutdatedPackage,
|
||||
} from '@pnpm/outdated'
|
||||
import semverDiff from '@pnpm/semver-diff'
|
||||
import { type DependenciesField, type PackageManifest, type ProjectRootDir } from '@pnpm/types'
|
||||
import { type DependenciesField, type PackageManifest, type ProjectManifest, type ProjectRootDir } from '@pnpm/types'
|
||||
import { table } from '@zkochan/table'
|
||||
import chalk from 'chalk'
|
||||
import { pick, sortWith } from 'ramda'
|
||||
@@ -169,7 +170,7 @@ export type OutdatedCommandOptions = {
|
||||
| 'tag'
|
||||
| 'userAgent'
|
||||
| 'updateConfig'
|
||||
> & Partial<Pick<Config, 'userConfig'>>
|
||||
> & Partial<Pick<Config, 'globalPkgDir' | 'userConfig'>>
|
||||
|
||||
export async function handler (
|
||||
opts: OutdatedCommandOptions,
|
||||
@@ -184,14 +185,25 @@ export async function handler (
|
||||
const pkgs = Object.values(opts.selectedProjectsGraph).map((wsPkg) => wsPkg.package)
|
||||
return outdatedRecursive(pkgs, params, { ...opts, include })
|
||||
}
|
||||
const manifest = await readProjectManifestOnly(opts.dir, opts)
|
||||
const packages = [
|
||||
{
|
||||
rootDir: opts.dir as ProjectRootDir,
|
||||
manifest,
|
||||
},
|
||||
]
|
||||
const [outdatedPackages] = await outdatedDepsOfProjects(packages, params, {
|
||||
let packages: Array<{ rootDir: ProjectRootDir, manifest: ProjectManifest }>
|
||||
if (opts.global && opts.globalPkgDir) {
|
||||
const globalPackages = scanGlobalPackages(opts.globalPkgDir)
|
||||
packages = await Promise.all(
|
||||
globalPackages.map(async (pkg) => ({
|
||||
rootDir: pkg.installDir as ProjectRootDir,
|
||||
manifest: await readProjectManifestOnly(pkg.installDir, opts),
|
||||
}))
|
||||
)
|
||||
} else {
|
||||
const manifest = await readProjectManifestOnly(opts.dir, opts)
|
||||
packages = [
|
||||
{
|
||||
rootDir: opts.dir as ProjectRootDir,
|
||||
manifest,
|
||||
},
|
||||
]
|
||||
}
|
||||
const outdatedPerProject = await outdatedDepsOfProjects(packages, params, {
|
||||
...opts,
|
||||
fullMetadata: opts.long,
|
||||
ignoreDependencies: opts.updateConfig?.ignoreDependencies,
|
||||
@@ -206,6 +218,7 @@ export async function handler (
|
||||
},
|
||||
timeout: opts.fetchTimeout,
|
||||
})
|
||||
const outdatedPackages = outdatedPerProject.flat()
|
||||
|
||||
let output!: string
|
||||
switch (opts.format ?? 'table') {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { prepare, tempDir } from '@pnpm/prepare'
|
||||
import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
|
||||
import { fixtures } from '@pnpm/test-fixtures'
|
||||
import { stripVTControlCharacters as stripAnsi } from 'util'
|
||||
import symlinkDir from 'symlink-dir'
|
||||
|
||||
const f = fixtures(import.meta.dirname)
|
||||
const hasOutdatedDepsFixture = f.find('has-outdated-deps')
|
||||
@@ -461,6 +462,50 @@ test('pnpm outdated: support --sortField option', async () => {
|
||||
`)
|
||||
})
|
||||
|
||||
test('pnpm outdated -g: shows outdated global packages', async () => {
|
||||
tempDir()
|
||||
|
||||
// Set up a simulated global dir with one isolated package group
|
||||
const globalPkgDir = path.resolve('global')
|
||||
const installDir = path.join(globalPkgDir, 'install-1')
|
||||
fs.mkdirSync(path.join(installDir, 'node_modules/.pnpm'), { recursive: true })
|
||||
fs.copyFileSync(path.join(hasOutdatedDepsFixture, 'node_modules/.pnpm/lock.yaml'), path.join(installDir, 'node_modules/.pnpm/lock.yaml'))
|
||||
fs.copyFileSync(path.join(hasOutdatedDepsFixture, 'package.json'), path.join(installDir, 'package.json'))
|
||||
|
||||
// Create symlink from a hash entry to the install dir (this is how scanGlobalPackages discovers packages)
|
||||
symlinkDir.sync(installDir, path.join(globalPkgDir, 'abc123'))
|
||||
|
||||
const { output, exitCode } = await outdated.handler({
|
||||
...OUTDATED_OPTIONS,
|
||||
dir: globalPkgDir,
|
||||
global: true,
|
||||
globalPkgDir,
|
||||
format: 'json',
|
||||
})
|
||||
|
||||
expect(exitCode).toBe(1)
|
||||
const result = JSON.parse(stripAnsi(output))
|
||||
expect(result['is-negative']).toBeDefined()
|
||||
expect(result['@pnpm.e2e/deprecated']).toBeDefined()
|
||||
})
|
||||
|
||||
test('pnpm outdated -g: no outdated packages when global dir is empty', async () => {
|
||||
tempDir()
|
||||
|
||||
const globalPkgDir = path.resolve('global')
|
||||
fs.mkdirSync(globalPkgDir, { recursive: true })
|
||||
|
||||
const { output, exitCode } = await outdated.handler({
|
||||
...OUTDATED_OPTIONS,
|
||||
dir: globalPkgDir,
|
||||
global: true,
|
||||
globalPkgDir,
|
||||
})
|
||||
|
||||
expect(output).toBe('')
|
||||
expect(exitCode).toBe(0)
|
||||
})
|
||||
|
||||
test('pnpm outdated --long with only deprecated packages', async () => {
|
||||
tempDir()
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
{
|
||||
"path": "../../config/matcher"
|
||||
},
|
||||
{
|
||||
"path": "../../global/packages"
|
||||
},
|
||||
{
|
||||
"path": "../../lockfile/fs"
|
||||
},
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/fs.msgpack-file": "workspace:*",
|
||||
"@pnpm/get-context": "workspace:*",
|
||||
"@pnpm/global.packages": "workspace:*",
|
||||
"@pnpm/lockfile.utils": "workspace:*",
|
||||
"@pnpm/normalize-registries": "workspace:*",
|
||||
"@pnpm/parse-wanted-dependency": "workspace:*",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cleanOrphanedInstallDirs } from '@pnpm/global.packages'
|
||||
import { streamParser } from '@pnpm/logger'
|
||||
import { type StoreController } from '@pnpm/store-controller-types'
|
||||
import { type ReporterFunction } from './types.js'
|
||||
@@ -10,6 +11,7 @@ export async function storePrune (
|
||||
removeAlienFiles?: boolean
|
||||
cacheDir: string
|
||||
dlxCacheMaxAge: number
|
||||
globalPkgDir?: string
|
||||
}
|
||||
): Promise<void> {
|
||||
const reporter = opts?.reporter
|
||||
@@ -25,6 +27,10 @@ export async function storePrune (
|
||||
now: new Date(),
|
||||
})
|
||||
|
||||
if (opts.globalPkgDir) {
|
||||
cleanOrphanedInstallDirs(opts.globalPkgDir)
|
||||
}
|
||||
|
||||
if ((reporter != null) && typeof reporter === 'function') {
|
||||
streamParser.removeListener('data', reporter)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
{
|
||||
"path": "../../fs/msgpack-file"
|
||||
},
|
||||
{
|
||||
"path": "../../global/packages"
|
||||
},
|
||||
{
|
||||
"path": "../../lockfile/fs"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user