From 58779a38faefe0b8281256e6489ddd5162f04476 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 3 Sep 2026 19:52:13 +0200 Subject: [PATCH] fix: preserve pnpm 11 compatibility with native pnpm 12 (#14506) pnpm 11 installs package-manager versions with lifecycle scripts disabled. Starting with pnpm 12.3.0, the published placeholder has a Node.js shebang. The bin linker records Node.js in its launcher before replacing the target with the native executable, then retains that launcher because the target path is unchanged. Restore the shebangless placeholder in the pnpm 12 npm wrapper so existing pnpm 11 releases can install future pnpm 12 versions through their version store. Keep the npm global Windows postinstall relink, which regenerates npm's shims against pnpm.exe after lifecycle scripts install the native binary. Script-blocked wrapper installs must enable lifecycle scripts. Corepack keeps its separate JavaScript entry point. Closes pnpm/pnpm#14502. --- .changeset/repair-native-pnpm-shims.md | 5 + pnpm/npm/pnpm/bin/pnpm.mjs | 9 +- pnpm/npm/pnpm/install.js | 15 +- pnpm/npm/pnpm/pnpm | 25 +--- pnpm/npm/pnpm/test/install.test.mjs | 67 +++------ pnpm/npm/pnpm/test/placeholder.test.mjs | 136 +----------------- .../test/self-updater/selfUpdate.test.ts | 28 ++-- 7 files changed, 60 insertions(+), 225 deletions(-) create mode 100644 .changeset/repair-native-pnpm-shims.md diff --git a/.changeset/repair-native-pnpm-shims.md b/.changeset/repair-native-pnpm-shims.md new file mode 100644 index 0000000000..0ae461d574 --- /dev/null +++ b/.changeset/repair-native-pnpm-shims.md @@ -0,0 +1,5 @@ +--- +"pacquet": patch +--- + +The pnpm npm wrapper keeps its placeholder shebang-less so pnpm 11 can install pnpm 12 through the version store. Wrapper installs must allow lifecycle scripts to install the native binary [#14502](https://github.com/pnpm/pnpm/issues/14502). diff --git a/pnpm/npm/pnpm/bin/pnpm.mjs b/pnpm/npm/pnpm/bin/pnpm.mjs index 864e1f0766..b7a8c0acad 100644 --- a/pnpm/npm/pnpm/bin/pnpm.mjs +++ b/pnpm/npm/pnpm/bin/pnpm.mjs @@ -3,17 +3,14 @@ // `./bin/pnpx.mjs` for every pnpm >=11 (see its `config.json`) and loads them // into its own Node.js process, which a native executable cannot be loaded into. // -// The `pnpm` placeholder bin runs it too, when the install script that -// replaces it with the native binary did not run (build scripts blocked). An -// ordinary `npm install -g pnpm` never pays for a Node.js startup: -// `package.json#bin` points at the native binary. +// Only Corepack reaches this file: `package.json#bin` still points at the native +// binary, so an ordinary `npm install -g pnpm` never pays for a Node.js startup. // // Corepack installs no dependencies and runs no lifecycle scripts, so the // `@pnpm/exe.` package that carries the binary is absent and // `install.js` never ran. The binary is therefore downloaded on first use and // kept next to this wrapper — where the native binary also finds the `dist/` -// payload it ships node-gyp in. The placeholder's installs usually do carry -// that package, and the binary is taken from there. +// payload it ships node-gyp in. // // The download itself is `get-pnpm`, the package behind https://get.pnpm.io, // which already knows how to verify one; it travels in that same `dist/` diff --git a/pnpm/npm/pnpm/install.js b/pnpm/npm/pnpm/install.js index 09ad7b06b0..87db583574 100644 --- a/pnpm/npm/pnpm/install.js +++ b/pnpm/npm/pnpm/install.js @@ -1,12 +1,13 @@ #!/usr/bin/env node // Preinstall for the pnpm v12 wrapper (shared verbatim by `pnpm` and -// `@pnpm/exe`): replace the placeholder bins with the host's native binary so -// `pnpm` runs directly, no Node startup per call. Package managers write bin -// shims after preinstall, so the shims are made from the binary; where build -// scripts are blocked (`--ignore-scripts`, the pnpm/Bun default) the -// placeholder stays and runs pnpm through Node.js (see the `pnpm` file). npm -// does not re-read the rewritten `bin` during the same install pass, so on a -// global Windows install, postinstall asks it to relink. +// `@pnpm/exe`): replace the shebang-less placeholder bins with the host's native +// binary so `pnpm` runs directly, no Node startup per call. The placeholder must +// stay shebang-less because pnpm 11 records its interpreter before installing +// the native binary at the same path. npm's global Windows shims still target +// the extensionless path after the `bin` rewrite, so postinstall asks npm to +// regenerate them against `pnpm.exe`. When lifecycle scripts are blocked +// (`--ignore-scripts`, pnpm/Bun default), the placeholder remains and pnpm +// cannot run. // // `pn`/`pnpx`/`pnx` are committed `#!/bin/sh` scripts on Unix (so only `pnpm` is // relinked); on Windows the native binary is hardlinked onto each and diff --git a/pnpm/npm/pnpm/pnpm b/pnpm/npm/pnpm/pnpm index f8d22169b2..11637685c1 100644 --- a/pnpm/npm/pnpm/pnpm +++ b/pnpm/npm/pnpm/pnpm @@ -1,21 +1,4 @@ -#!/usr/bin/env node -// pnpm's native binary replaces this file during installation (see -// ./install.js). If this is running, that install script did not — build -// scripts were blocked (pnpm and Bun block them by default) or skipped -// (`--ignore-scripts`) — so pnpm runs through Node.js instead: `bin/pnpm.mjs` -// finds the installed binary, or downloads one, and spawns it. -// -// Package managers write bin shims after preinstall has had its chance to run, -// so a shim made from this file's shebang exists only where the binary never -// arrived. That is also why this file is left in place once it is running: a -// binary put here under such a shim would be run through `node`. -import process from 'node:process' - -if (process.stderr.isTTY) { - process.stderr.write( - 'pnpm is running through Node.js because the script that installs its native binary was skipped. ' + - 'Reinstall pnpm with its build scripts allowed to run the binary directly.\n' - ) -} - -await import('./bin/pnpm.mjs') +This is a placeholder. pnpm's native binary replaces this file during +installation (see ./install.js). If you are reading this, the install/build +script did not run — reinstall with build scripts enabled (e.g. allow-list +pnpm's build under pnpm or Bun, or drop --ignore-scripts). diff --git a/pnpm/npm/pnpm/test/install.test.mjs b/pnpm/npm/pnpm/test/install.test.mjs index 9ec0085356..231493eed4 100644 --- a/pnpm/npm/pnpm/test/install.test.mjs +++ b/pnpm/npm/pnpm/test/install.test.mjs @@ -15,52 +15,6 @@ const wrapperManifest = JSON.parse(fs.readFileSync(path.join(wrapperDir, 'packag test('npm installs a shim that runs the native pnpm binary', (t) => { assert.equal(wrapperManifest.bin.pnpm, 'pnpm') - const { prefix } = installFixtureWithNpm(t, ['--dangerously-allow-all-scripts']) - - if (process.platform === 'win32') { - const cmdShim = path.join(prefix, 'pnpm.cmd') - assert.match(fs.readFileSync(cmdShim, 'utf8'), /pnpm\.exe/) - assert.match(execFileSync('cmd.exe', ['/d', '/s', '/c', 'call', cmdShim, '--version'], { encoding: 'utf8' }), /^v\d+/) - - const powershellShim = path.join(prefix, 'pnpm.ps1') - assert.match(fs.readFileSync(powershellShim, 'utf8'), /pnpm\.exe/) - assert.match(execFileSync('pwsh', ['-NoProfile', '-File', powershellShim, '--version'], { encoding: 'utf8' }), /^v\d+/) - } else { - assert.equal(execFileSync(path.join(prefix, 'bin', 'pnpm'), ['works'], { encoding: 'utf8' }), 'fixture:works\n') - } -}) - -test('the shim runs pnpm through Node.js when npm skipped the install scripts', (t) => { - const { prefix, fixtureDir } = installFixtureWithNpm(t, ['--ignore-scripts']) - const placeholder = fs.readFileSync(path.join(fixtureDir, 'pnpm'), 'utf8') - const installedPlaceholder = process.platform === 'win32' - ? path.join(prefix, 'node_modules', 'pnpm-install-fixture', 'pnpm') - : path.join(prefix, 'lib', 'node_modules', 'pnpm-install-fixture', 'pnpm') - assert.equal(fs.readFileSync(installedPlaceholder, 'utf8'), placeholder) - - if (process.platform === 'win32') { - const cmdShim = path.join(prefix, 'pnpm.cmd') - assert.match(execFileSync('cmd.exe', ['/d', '/s', '/c', 'call', cmdShim, '--version'], { encoding: 'utf8' }), /^v\d+/) - const powershellShim = path.join(prefix, 'pnpm.ps1') - assert.match(execFileSync('pwsh', ['-NoProfile', '-File', powershellShim, '--version'], { encoding: 'utf8' }), /^v\d+/) - } else { - assert.equal(execFileSync(path.join(prefix, 'bin', 'pnpm'), ['works'], { encoding: 'utf8' }), 'fixture:works\n') - } - // The shim was made from the placeholder's shebang, so the placeholder stays. - assert.equal(fs.readFileSync(installedPlaceholder, 'utf8'), placeholder) -}) - -/** - * Install the wrapper fixture globally with npm into a prefix of its own, with - * the host's platform package as a `file:` optional dependency. Throws when npm - * fails; the temp tree is removed when `t` ends. - * - * @param {import('node:test').TestContext} t The test, for cleanup. - * @param {string[]} npmFlags Extra `npm install` flags, e.g. `--ignore-scripts`. - * @returns {{ prefix: string, fixtureDir: string }} The npm prefix the shims - * landed in, and the fixture wrapper it was installed from. - */ -function installFixtureWithNpm (t, npmFlags) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm wrapper install-')) t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) @@ -76,8 +30,8 @@ function installFixtureWithNpm (t, npmFlags) { writeNativeFixture(path.join(nativePackageDir, binFile)) const fixtureDir = path.join(tempDir, 'wrapper') - fs.mkdirSync(path.join(fixtureDir, 'bin'), { recursive: true }) - for (const file of ['install.js', 'native-binary.mjs', 'bin/pnpm.mjs', wrapperManifest.bin.pnpm]) { + fs.mkdirSync(fixtureDir) + for (const file of ['install.js', 'native-binary.mjs', wrapperManifest.bin.pnpm]) { fs.copyFileSync(path.join(wrapperDir, file), path.join(fixtureDir, file)) } fs.writeFileSync(path.join(fixtureDir, 'package.json'), JSON.stringify({ @@ -97,13 +51,24 @@ function installFixtureWithNpm (t, npmFlags) { 'install', '--global', '--install-links=true', - ...npmFlags, + '--dangerously-allow-all-scripts', '--prefix', prefix, fixtureDir, ], tempDir) - return { prefix, fixtureDir } -} + + if (process.platform === 'win32') { + const cmdShim = path.join(prefix, 'pnpm.cmd') + assert.match(fs.readFileSync(cmdShim, 'utf8'), /pnpm\.exe/) + assert.match(execFileSync('cmd.exe', ['/d', '/s', '/c', 'call', cmdShim, '--version'], { encoding: 'utf8' }), /^v\d+/) + + const powershellShim = path.join(prefix, 'pnpm.ps1') + assert.match(fs.readFileSync(powershellShim, 'utf8'), /pnpm\.exe/) + assert.match(execFileSync('pwsh', ['-NoProfile', '-File', powershellShim, '--version'], { encoding: 'utf8' }), /^v\d+/) + } else { + assert.equal(execFileSync(path.join(prefix, 'bin', 'pnpm'), ['works'], { encoding: 'utf8' }), 'fixture:works\n') + } +}) function runNpm (args, cwd) { if (process.platform === 'win32') { diff --git a/pnpm/npm/pnpm/test/placeholder.test.mjs b/pnpm/npm/pnpm/test/placeholder.test.mjs index ccca8110a4..84327b2f01 100644 --- a/pnpm/npm/pnpm/test/placeholder.test.mjs +++ b/pnpm/npm/pnpm/test/placeholder.test.mjs @@ -1,139 +1,11 @@ -// Exercises the `pnpm` placeholder bin the way a script-less install leaves it: -// the install script never replaced it with the native binary, and it is what -// the package manager's bin shim runs — through Node.js, since its shebang -// names it. import assert from 'node:assert/strict' -import { spawn } from 'node:child_process' import fs from 'node:fs' -import os from 'node:os' import path from 'node:path' -import process from 'node:process' -import { after, describe, it } from 'node:test' +import { test } from 'node:test' import { fileURLToPath } from 'node:url' -import { getBinCandidates, splitBinSpecifier } from '../native-binary.mjs' +const wrapperDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const WRAPPER_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const WRAPPER_FILES = ['pnpm', 'native-binary.mjs', 'bin/pnpm.mjs'] -// A stand-in for the native binary. On Windows it is a copy of node itself, -// which is why the tests ask for `--version`; elsewhere a script that echoes. -const FAKE_BINARY_OUTPUT = process.platform === 'win32' ? /^v\d+/ : /^installed: --version\n$/ - -const IS_UNIX = process.platform !== 'win32' - -describe('placeholder bin', () => { - // What every bin shim made from this file resolves it to. - it('names node in its shebang', () => { - assert.match(fs.readFileSync(path.join(WRAPPER_DIR, 'pnpm'), 'utf8'), /^#!\/usr\/bin\/env node\n/) - }) - - it('runs the installed native binary through Node.js', async () => { - const fixture = createFixture() - - const result = await run(process.execPath, [fixture.placeholder, '--version']) - assert.equal(result.status, 0, result.stderr) - assert.match(result.stdout, FAKE_BINARY_OUTPUT) - // Not a terminal, so no notice. - assert.equal(result.stderr, '') - assert.equal(fs.readFileSync(fixture.placeholder, 'utf8'), fs.readFileSync(path.join(WRAPPER_DIR, 'pnpm'), 'utf8')) - }) - - // npm links `node_modules/.bin/pnpm` straight to the file and the kernel - // reads the shebang; Windows has neither, so this is Unix-only. - it('runs from a symlink to itself', { skip: !IS_UNIX && 'Windows bins are shims, not symlinks' }, async () => { - const fixture = createFixture() - const binDir = path.join(fixture.dir, 'node_modules', '.bin') - fs.mkdirSync(binDir, { recursive: true }) - const link = path.join(binDir, 'pnpm') - fs.symlinkSync(path.relative(binDir, fixture.placeholder), link) - - const result = await run(link, ['--version']) - assert.equal(result.status, 0, result.stderr) - assert.match(result.stdout, FAKE_BINARY_OUTPUT) - }) - - it('hands over to the entry point when no platform package is installed', async () => { - const fixture = createFixture({ installPlatformPackage: false }) - - const result = await run(process.execPath, [fixture.placeholder, '--version'], { COREPACK_ENABLE_NETWORK: '0' }) - assert.notEqual(result.status, 0) - assert.match(result.stderr, /Network access is disabled/) - }) - - // A project the wrapper sits under can hold anything under the platform - // package's name; only what was installed with the wrapper is its binary. - it('does not run a platform package from an ancestor node_modules', async () => { - const fixture = createFixture({ installPlatformPackage: false, nestedUnder: ['node_modules', 'tool', 'node_modules'] }) - writePlatformPackage(path.join(fixture.dir, 'node_modules')) - - const result = await run(process.execPath, [fixture.placeholder, '--version'], { COREPACK_ENABLE_NETWORK: '0' }) - assert.notEqual(result.status, 0) - assert.match(result.stderr, /Network access is disabled/) - assert.doesNotMatch(result.stdout, FAKE_BINARY_OUTPUT) - }) +test('the placeholder is not a Node.js script', () => { + assert.doesNotMatch(fs.readFileSync(path.join(wrapperDir, 'pnpm'), 'utf8'), /^#!/) }) - -/** - * Spawn `command` with `args`, `env` overriding the inherited environment. - * Resolves once the child has exited, with its exit status and decoded output; - * rejects only if it could not be spawned. - * - * @returns {Promise<{status: number | null, stdout: string, stderr: string}>} - */ -function run (command, args, env) { - const child = spawn(command, args, { env: { ...process.env, ...env } }) - - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8').on('data', (chunk) => { stdout += chunk }) - child.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk }) - - return new Promise((resolve, reject) => { - child.on('error', reject) - child.on('close', (status) => { resolve({ status, stdout, stderr }) }) - }) -} - -/** - * A wrapper directory as a script-less install leaves it: the placeholder still - * in place, and — unless told otherwise — the platform package that carries the - * binary installed in the wrapper's own `node_modules`, since only the scripts - * were skipped. `nestedUnder` places the wrapper that many directories below - * the fixture root, which then stands for a project the wrapper sits under. - */ -function createFixture ({ installPlatformPackage = true, nestedUnder = [] } = {}) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-placeholder-')) - after(() => fs.rmSync(dir, { force: true, recursive: true })) - const wrapperDir = path.join(dir, ...nestedUnder, nestedUnder.length > 0 ? 'pnpm' : '') - - for (const file of WRAPPER_FILES) { - fs.mkdirSync(path.dirname(path.join(wrapperDir, file)), { recursive: true }) - fs.copyFileSync(path.join(WRAPPER_DIR, file), path.join(wrapperDir, file)) - } - fs.chmodSync(path.join(wrapperDir, 'pnpm'), 0o755) - // `type` is what makes Node.js read the extensionless placeholder as ESM. - fs.writeFileSync(path.join(wrapperDir, 'package.json'), JSON.stringify({ name: 'pnpm', version: '99.0.0', type: 'module' })) - - if (installPlatformPackage) { - writePlatformPackage(path.join(wrapperDir, 'node_modules')) - } - - return { dir, placeholder: path.join(wrapperDir, 'pnpm') } -} - -/** - * Create the host's `@pnpm/exe.` package under `modulesDir` (created if - * missing): a manifest and the stand-in binary — an executable `sh` script on - * Unix, a copy of the running node on Windows. Filesystem errors propagate. - */ -function writePlatformPackage (modulesDir) { - const { packageName, binFile } = splitBinSpecifier(getBinCandidates()[0]) - const packageDir = path.join(modulesDir, packageName) - fs.mkdirSync(packageDir, { recursive: true }) - fs.writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify({ name: packageName, version: '99.0.0' })) - if (IS_UNIX) { - fs.writeFileSync(path.join(packageDir, binFile), '#!/bin/sh\necho "installed: $*"\n', { mode: 0o755 }) - } else { - fs.copyFileSync(process.execPath, path.join(packageDir, binFile)) - } -} diff --git a/pnpm11/engine/pm/commands/test/self-updater/selfUpdate.test.ts b/pnpm11/engine/pm/commands/test/self-updater/selfUpdate.test.ts index a41d643f7e..6cc0f73a66 100644 --- a/pnpm11/engine/pm/commands/test/self-updater/selfUpdate.test.ts +++ b/pnpm11/engine/pm/commands/test/self-updater/selfUpdate.test.ts @@ -1418,11 +1418,9 @@ describe('linkExePlatformBinary', () => { expect(result).toBe(fakeBinaryContent) }) - test('links the pnpm v12 wrapper from its @pnpm/exe. dependency', () => { + test('pnpm 11 version-store linking runs the pnpm v12 wrapper after installing its native binary', async () => { const dir = tempDir(false) - // pnpm v12 (the Rust port) is published as the unscoped `pnpm` wrapper that - // depends on `@pnpm/exe.-[-musl]` — the `exe.<...>` scheme. const nextPkgName = exePlatformPkgDirNameNext(platform, arch, libcFamily) const wrapperDir = path.join(dir, 'node_modules', 'pnpm') const platformDir = path.join(dir, 'node_modules', '@pnpm', nextPkgName) @@ -1430,18 +1428,32 @@ describe('linkExePlatformBinary', () => { fs.mkdirSync(wrapperDir, { recursive: true }) fs.mkdirSync(platformDir, { recursive: true }) - fs.writeFileSync(path.join(wrapperDir, executable), 'This is a placeholder.') + fs.copyFileSync( + path.resolve(import.meta.dirname, '../../../../../..', 'pnpm/npm/pnpm/pnpm'), + path.join(wrapperDir, 'pnpm') + ) fs.writeFileSync(path.join(wrapperDir, 'package.json'), JSON.stringify({ + name: 'pnpm', + version: '12.99.0', bin: { pnpm: 'pnpm', pn: 'pn', pnpx: 'pnpx', pnx: 'pnx' }, })) - const fakeBinaryContent = '#!/bin/sh\necho "fake pnpm v12 binary"' - fs.writeFileSync(path.join(platformDir, executable), fakeBinaryContent) + const nativeBinary = path.join(platformDir, executable) + if (platform === 'win32') { + fs.copyFileSync(process.execPath, nativeBinary) + } else { + fs.writeFileSync(nativeBinary, '#!/bin/sh\necho "fake pnpm v12 binary"\n', { mode: 0o755 }) + } + + const binDir = path.join(dir, 'bin') + await linkBins(path.join(dir, 'node_modules'), binDir, { warn: () => {} }) linkExePlatformBinary(dir, 'pnpm') + await linkBins(path.join(dir, 'node_modules'), binDir, { warn: () => {} }) - const result = fs.readFileSync(path.join(wrapperDir, executable), 'utf8') - expect(result).toBe(fakeBinaryContent) + const result = spawn.sync(path.join(binDir, 'pnpm'), ['--version'], { encoding: 'utf8' }) + expect(result.status).toBe(0) + expect(result.stdout.trim()).toBe(platform === 'win32' ? process.version : 'fake pnpm v12 binary') }) test.each([