fix: run @pnpm/exe setup logic in-process to fix version switching without Node.js (#10696)

When pnpm is installed as a standalone executable in environments without
a system Node.js (e.g. Docker containers), the `@pnpm/exe` preinstall
script (`node setup.js`) fails because `node` is not on PATH. This broke
version switching via the `packageManager` field in package.json since
v10.30.2, which changed `getCurrentPackageName()` to return `@pnpm/exe`
instead of platform-specific package names like `@pnpm/linux-x64`.

Install with `--ignore-scripts` and link the platform-specific binary
in-process instead. The setup logic is inlined because setup.js can't
be loaded at runtime: `require()` fails on ESM (pnpm v11+) and
`import()` is intercepted by pkg's virtual filesystem in standalone
executables.

Closes #10687
This commit is contained in:
Zoltan Kochan
2026-02-26 10:42:44 +01:00
parent dcd16c7b36
commit 1ab0f7bb2a
2 changed files with 51 additions and 2 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/tools.plugin-commands-self-updater": patch
"pnpm": patch
---
Fixed version switching via `packageManager` field failing when pnpm is installed as a standalone executable in environments without a system Node.js [#10687](https://github.com/pnpm/pnpm/issues/10687).

View File

@@ -39,17 +39,23 @@ export async function installPnpmToTools (pnpmVersion: string, opts: SelfUpdateC
try {
// The reason we don't just run add.handler is that at this point we might have settings from local config files
// that we don't want to use while installing the pnpm CLI.
// We use --ignore-scripts because `@pnpm/exe` has a `preinstall` script that runs `node setup.js`,
// which fails in environments without a system Node.js (e.g. when pnpm is installed as a standalone executable).
// Instead, we link the platform-specific binary in-process after install.
runPnpmCli([
'add',
`${currentPkgName}@${pnpmVersion}`,
'--loglevel=error',
'--allow-build=@pnpm/exe',
'--no-dangerously-allow-all-builds',
'--ignore-scripts',
'--config.strict-dep-builds=false',
// We want to avoid symlinks because of the rename step,
// which breaks the junctions on Windows.
'--config.node-linker=hoisted',
'--config.bin=bin',
], { cwd: stage })
if (currentPkgName === '@pnpm/exe') {
linkExePlatformBinary(stage)
}
// We need the operation of installing pnpm to be atomic.
// However, we cannot use a rename as that breaks the command shim created for pnpm.
// Hence, we use a symlink.
@@ -67,3 +73,40 @@ export async function installPnpmToTools (pnpmVersion: string, opts: SelfUpdateC
binDir,
}
}
// This replicates the logic from @pnpm/exe's setup.js (pnpm/artifacts/exe/setup.js).
// We can't run setup.js via require() or import() because:
// - require() fails when setup.js is ESM (pnpm v11+)
// - import() is intercepted by pkg's virtual filesystem in standalone executables
// So we inline the logic: find the platform-specific binary and hard-link it
// into the @pnpm/exe package directory.
function linkExePlatformBinary (stageDir: string): void {
const platform = process.platform === 'win32'
? 'win'
: process.platform === 'darwin'
? 'macos'
: process.platform
const arch = platform === 'win' && process.arch === 'ia32' ? 'x86' : process.arch
const executable = platform === 'win' ? 'pnpm.exe' : 'pnpm'
const platformPkgDir = path.join(stageDir, 'node_modules', '@pnpm', `${platform}-${arch}`)
const src = path.join(platformPkgDir, executable)
if (!fs.existsSync(src)) return
const exePkgDir = path.join(stageDir, 'node_modules', '@pnpm', 'exe')
const dest = path.join(exePkgDir, executable)
try {
fs.unlinkSync(dest)
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err
}
}
fs.linkSync(src, dest)
fs.chmodSync(dest, 0o755)
if (platform === 'win') {
const exePkgJsonPath = path.join(exePkgDir, 'package.json')
const exePkg = JSON.parse(fs.readFileSync(exePkgJsonPath, 'utf8'))
fs.writeFileSync(path.join(exePkgDir, 'pnpm'), 'This file intentionally left blank')
exePkg.bin.pnpm = 'pnpm.exe'
fs.writeFileSync(exePkgJsonPath, JSON.stringify(exePkg, null, 2))
}
}