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