diff --git a/.changeset/fix-exe-version-switch-no-nodejs.md b/.changeset/fix-exe-version-switch-no-nodejs.md new file mode 100644 index 0000000000..a73f843da7 --- /dev/null +++ b/.changeset/fix-exe-version-switch-no-nodejs.md @@ -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). diff --git a/tools/plugin-commands-self-updater/src/installPnpmToTools.ts b/tools/plugin-commands-self-updater/src/installPnpmToTools.ts index 4d6d136cf5..8262637bae 100644 --- a/tools/plugin-commands-self-updater/src/installPnpmToTools.ts +++ b/tools/plugin-commands-self-updater/src/installPnpmToTools.ts @@ -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)) + } +}