fix: prevent store prune from breaking globally installed pnpm (#11253)

When pnpm self-updates via the headless install path, the install
directory was not registered in the store's project registry. This
caused `pnpm store prune` to treat its global virtual store packages
as unreachable and remove them, breaking the global pnpm binary.

Register the install dir after headless install in installPnpmToGlobalDir
This commit is contained in:
Zoltan Kochan
2026-04-14 17:30:41 +02:00
committed by GitHub
parent 73fd47667a
commit b989a4a1f4
7 changed files with 50 additions and 16 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/store.commands": patch
"@pnpm/engine.pm.commands": patch
"pnpm": patch
---
Fixed `pnpm store prune` removing packages used by the globally installed pnpm, breaking it.

View File

@@ -61,6 +61,7 @@
},
"devDependencies": {
"@jest/globals": "catalog:",
"@pnpm/constants": "workspace:*",
"@pnpm/engine.pm.commands": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/logger": "workspace:*",

View File

@@ -20,7 +20,7 @@ import {
} from '@pnpm/global.packages'
import { headlessInstall } from '@pnpm/installing.deps-restorer'
import type { EnvLockfile, LockfileObject, PackageSnapshot } from '@pnpm/lockfile.types'
import type { StoreController } from '@pnpm/store.controller'
import { registerProject, type StoreController } from '@pnpm/store.controller'
import type { DepPath, ProjectId, ProjectRootDir, Registries } from '@pnpm/types'
import { symlinkDir } from 'symlink-dir'
@@ -196,6 +196,11 @@ async function installPnpmToGlobalDir (
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
packageManager: opts.packageManager,
})
// headlessInstall does not register the project, so we must do it
// explicitly. Without this, `pnpm store prune` would not know about
// this install directory and would remove its packages from the
// global virtual store.
await registerProject(opts.storeDir, installDir)
} else {
await installFromResolution(installDir, opts, [`${pkgName}@${version}`])
}

View File

@@ -3,8 +3,10 @@ import { createRequire } from 'node:module'
import path from 'node:path'
import { jest } from '@jest/globals'
import { STORE_VERSION } from '@pnpm/constants'
import { prepare as prepareWithPkg, tempDir } from '@pnpm/prepare'
import { prependDirsToPath } from '@pnpm/shell.path'
import { getRegisteredProjects } from '@pnpm/store.controller'
import { getMockAgent, setupMockAgent, teardownMockAgent } from '@pnpm/testing.mock-agent'
import spawn from 'cross-spawn'
@@ -138,14 +140,24 @@ test('self-update', async () => {
await selfUpdate.handler(opts, [])
// Verify the package was installed in the global dir
// Verify the package was installed in the global dir.
// The globalDir contains both the real install dir (a directory) and a
// hash symlink pointing to it. Use lstatSync to pick the real dir.
const globalDir = path.join(opts.pnpmHomeDir, 'global', 'v11')
const entries = fs.readdirSync(globalDir)
const installDirName = entries.find((e) => fs.statSync(path.join(globalDir, e)).isDirectory())
const installDirName = entries.find((e) => fs.lstatSync(path.join(globalDir, e)).isDirectory())
expect(installDirName).toBeDefined()
const pnpmPkgJson = JSON.parse(fs.readFileSync(path.join(globalDir, installDirName!, 'node_modules/pnpm/package.json'), 'utf8'))
const installDir = path.join(globalDir, installDirName!)
const pnpmPkgJson = JSON.parse(fs.readFileSync(path.join(installDir, 'node_modules/pnpm/package.json'), 'utf8'))
expect(pnpmPkgJson.version).toBe('9.1.0')
// Verify the install dir was registered in the store's project registry.
// Without this, `pnpm store prune` would remove the install's packages
// from the global virtual store.
const storeDir = path.join(opts.pnpmHomeDir, 'store', STORE_VERSION)
const registeredProjects = await getRegisteredProjects(storeDir)
expect(registeredProjects).toContain(installDir)
const pnpmEnv = prependDirsToPath([path.join(opts.pnpmHomeDir, 'bin')])
const { status, stdout } = spawn.sync('pnpm', ['-v'], {
env: {

View File

@@ -28,6 +28,9 @@
{
"path": "../../../config/reader"
},
{
"path": "../../../core/constants"
},
{
"path": "../../../core/error"
},

3
pnpm-lock.yaml generated
View File

@@ -3646,6 +3646,9 @@ importers:
'@jest/globals':
specifier: 'catalog:'
version: 30.3.0
'@pnpm/constants':
specifier: workspace:*
version: link:../../../core/constants
'@pnpm/engine.pm.commands':
specifier: workspace:*
version: 'link:'

View File

@@ -19,20 +19,23 @@ export async function storePrune (
if ((reporter != null) && typeof reporter === 'function') {
streamParser.on('data', reporter)
}
await opts.storeController.prune(opts.removeAlienFiles)
await opts.storeController.close()
await cleanExpiredDlxCache({
cacheDir: opts.cacheDir,
dlxCacheMaxAge: opts.dlxCacheMaxAge,
now: new Date(),
})
try {
await opts.storeController.prune(opts.removeAlienFiles)
if (opts.globalPkgDir) {
cleanOrphanedInstallDirs(opts.globalPkgDir)
}
await cleanExpiredDlxCache({
cacheDir: opts.cacheDir,
dlxCacheMaxAge: opts.dlxCacheMaxAge,
now: new Date(),
})
if ((reporter != null) && typeof reporter === 'function') {
streamParser.removeListener('data', reporter)
if (opts.globalPkgDir) {
cleanOrphanedInstallDirs(opts.globalPkgDir)
}
} finally {
await opts.storeController.close()
if ((reporter != null) && typeof reporter === 'function') {
streamParser.removeListener('data', reporter)
}
}
}