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([