fix(self-update): refresh legacy v10 bootstrap shim at PNPM_HOME (#11467)

pnpm v10 setup added PNPM_HOME (not PNPM_HOME/bin) to PATH and wrote
a pnpm bootstrap shim there. After upgrading to v11, that shim still
points into the old .tools/<version> install, so PATH continues to
resolve `pnpm` to the pre-update version even though the new version
was installed under global/v11.

Detect that layout during self-update, refresh the shims at PNPM_HOME
so the upgrade actually takes effect, and warn the user to run
`pnpm setup` for a clean migration to the v11 PATH layout.

Closes #11464.
This commit is contained in:
Zoltan Kochan
2026-05-05 13:48:55 +02:00
parent 817b1b4c3e
commit 0219ab2484
3 changed files with 78 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/engine.pm.commands": patch
"pnpm": patch
---
Fixed `pnpm self-update` on installations originally set up by pnpm v10. v10 added `PNPM_HOME` directly to PATH and wrote a `pnpm` bootstrap shim there. v11 setup writes shims under `PNPM_HOME/bin` instead, so when a v10 user upgrades to v11 the legacy shim at `PNPM_HOME` keeps pointing into the old `.tools/<version>` install — `pnpm --version` continues to report the pre-update version even though the new version was installed under `global/v11`. Self-update now detects this layout, refreshes the legacy shims so the upgrade actually takes effect, and prints a hint suggesting `pnpm setup` to migrate PATH to the v11 layout. [#11464](https://github.com/pnpm/pnpm/issues/11464).

View File

@@ -1,3 +1,4 @@
import fs from 'node:fs'
import path from 'node:path'
import { linkBins } from '@pnpm/bins.linker'
@@ -206,12 +207,39 @@ export async function handler (
// Link bins to pnpmHomeDir/bin so the updated pnpm is the active global binary
await linkBins(path.join(baseDir, 'node_modules'), path.join(opts.pnpmHomeDir, 'bin'), { warn: globalWarn })
// pnpm v10 setup linked bins directly into pnpmHomeDir and added that
// directory to PATH (instead of pnpmHomeDir/bin as v11 does). When a v10
// user upgrades to v11 the legacy shims at pnpmHomeDir keep pointing into
// the old `.tools/<version>` install — so PATH still resolves `pnpm` to the
// pre-update version. Detect that case and refresh the legacy shims so the
// upgrade actually takes effect, then warn the user to run `pnpm setup`
// for a clean migration to the v11 layout. See pnpm/pnpm#11464.
if (hasLegacyHomeDirShim(opts.pnpmHomeDir)) {
await linkBins(path.join(baseDir, 'node_modules'), opts.pnpmHomeDir, { warn: globalWarn })
globalWarn(
'Detected a pnpm v10 installation layout at PNPM_HOME. The pnpm shims ' +
'at PNPM_HOME have been refreshed so the new version is active, but ' +
'pnpm v11 expects bins in PNPM_HOME/bin. Run "pnpm setup" to migrate ' +
'your PATH to the v11 layout.'
)
}
if (alreadyExisted) {
return `The ${bareSpecifier} version, v${resolution.manifest.version}, is already present on the system. It was activated by linking it from ${baseDir}.`
}
return `Successfully updated pnpm to v${resolution.manifest.version}`
}
// A fresh v11 setup never writes a `pnpm` shim at pnpmHomeDir itself — only
// under pnpmHomeDir/bin. The presence of a `pnpm` (or `pnpm.cmd`) file
// directly at pnpmHomeDir is therefore a reliable v10-layout marker.
function hasLegacyHomeDirShim (pnpmHomeDir: string): boolean {
for (const name of ['pnpm', 'pnpm.cmd']) {
if (fs.existsSync(path.join(pnpmHomeDir, name))) return true
}
return false
}
/**
* Returns the updated version constraint for devEngines.packageManager.
* - Exact versions and simple ranges (^, ~) are updated to the new version,

View File

@@ -169,6 +169,50 @@ test('self-update', async () => {
expect(stdout.toString().trim()).toBe('9.1.0')
})
test('self-update refreshes legacy v10 bootstrap shim at pnpmHomeDir', async () => {
// pnpm v10 setup added pnpmHomeDir (not pnpmHomeDir/bin) to PATH and wrote
// a `pnpm` bootstrap shim there. After upgrading to v11, that shim still
// points into the old `.tools/<version>` install, so PATH continues to
// resolve to the pre-update pnpm. Self-update on v11 must refresh the
// legacy shim so the upgrade actually takes effect for users still on the
// v10 PATH layout. See pnpm/pnpm#11464.
const opts = prepare()
// Simulate a leftover v10 bootstrap shim. Content is irrelevant — the
// detector only cares about file presence, and linkBins will overwrite it.
fs.writeFileSync(path.join(opts.pnpmHomeDir, 'pnpm'), '#!/bin/sh\necho stale\n', { mode: 0o755 })
if (process.platform === 'win32') {
fs.writeFileSync(path.join(opts.pnpmHomeDir, 'pnpm.cmd'), '@echo stale\n')
}
mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default))
await selfUpdate.handler(opts, [])
// Invoking pnpm via pnpmHomeDir (the v10 PATH layout, NOT pnpmHomeDir/bin)
// must now resolve to the freshly installed version.
const pnpmEnv = prependDirsToPath([opts.pnpmHomeDir])
const { status, stdout } = spawn.sync('pnpm', ['-v'], {
env: {
...process.env,
[pnpmEnv.name]: pnpmEnv.value,
},
})
expect(status).toBe(0)
expect(stdout.toString().trim()).toBe('9.1.0')
})
test('self-update does not write shims to pnpmHomeDir on a clean v11 layout', async () => {
// Mirror image of the previous test: when there is no v10-style shim at
// pnpmHomeDir, self-update must NOT start writing bins there. Otherwise we
// would clutter pnpmHomeDir on every fresh-v11 self-update.
const opts = prepare()
mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default))
await selfUpdate.handler(opts, [])
expect(fs.existsSync(path.join(opts.pnpmHomeDir, 'pnpm'))).toBe(false)
expect(fs.existsSync(path.join(opts.pnpmHomeDir, 'pnpm.cmd'))).toBe(false)
})
test('self-update by exact version', async () => {
const opts = prepare()
const metadata = createMetadata('9.2.0', opts.registries.default, ['9.1.0'])