From a3de3b2aa5a6d2fdbc5cd855dde188a8f69cc1de Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 1 Sep 2026 02:28:15 +0200 Subject: [PATCH] fix(pacquet): restore pnpm executable without file extension (#14393) Restore the pnpm wrapper target without a file extension so pnpm 12.1 and earlier can replace it with the downloaded native executable on POSIX systems. The cross-platform pnpm.exe target left its placeholder script in place during upgrades from older pnpm releases, breaking automatic package-manager switching. The PowerShell shim issue remains tracked by npm/cmd-shim#51. --- .changeset/fix-pwsh-native-shim.md | 7 -- .changeset/restore-extensionless-pnpm-bin.md | 5 + .../src/cli_args/self_update/install_pnpm.rs | 9 +- .../self_update/install_pnpm/tests.rs | 17 +--- .../cli/src/cli_args/self_update/tests.rs | 5 +- pnpm/npm/pnpm/install.js | 11 +-- pnpm/npm/pnpm/package.json | 4 +- pnpm/npm/pnpm/{pnpm.exe => pnpm} | 0 pnpm/npm/pnpm/scripts/generate-packages.mjs | 2 +- pnpm/npm/pnpm/test/install.test.mjs | 91 ------------------- .../commands/src/self-updater/installPnpm.ts | 3 +- .../test/self-updater/selfUpdate.test.ts | 7 +- 12 files changed, 22 insertions(+), 139 deletions(-) delete mode 100644 .changeset/fix-pwsh-native-shim.md create mode 100644 .changeset/restore-extensionless-pnpm-bin.md rename pnpm/npm/pnpm/{pnpm.exe => pnpm} (100%) delete mode 100644 pnpm/npm/pnpm/test/install.test.mjs diff --git a/.changeset/fix-pwsh-native-shim.md b/.changeset/fix-pwsh-native-shim.md deleted file mode 100644 index f66aa15d16..0000000000 --- a/.changeset/fix-pwsh-native-shim.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"pacquet": patch -"@pnpm/engine.pm.commands": patch -"pnpm": patch ---- - -Fixed the PowerShell shim generated by `npm install -g pnpm` on Windows so it invokes the native `pnpm.exe` binary [pnpm/pnpm#14362](https://github.com/pnpm/pnpm/issues/14362). diff --git a/.changeset/restore-extensionless-pnpm-bin.md b/.changeset/restore-extensionless-pnpm-bin.md new file mode 100644 index 0000000000..3b4ae2df06 --- /dev/null +++ b/.changeset/restore-extensionless-pnpm-bin.md @@ -0,0 +1,5 @@ +--- +"pacquet": patch +--- + +Restored the `pnpm` executable target without a file extension so pnpm 12.1 and earlier can upgrade to newer pnpm 12 releases on POSIX systems. diff --git a/pnpm/crates/cli/src/cli_args/self_update/install_pnpm.rs b/pnpm/crates/cli/src/cli_args/self_update/install_pnpm.rs index ab27e35dc4..c3734033cb 100644 --- a/pnpm/crates/cli/src/cli_args/self_update/install_pnpm.rs +++ b/pnpm/crates/cli/src/cli_args/self_update/install_pnpm.rs @@ -148,12 +148,7 @@ pub(super) fn assert_pnpm_runs( /// The native pnpm executable linked into an installed engine wrapper. pub(super) fn pnpm_executable_path(install_dir: &Path, package_name: &str) -> PathBuf { - let wrapper_dir = package_dir(install_dir, package_name); - wrapper_executable_path(&wrapper_dir, host_platform()) -} - -fn wrapper_executable_path(wrapper_dir: &Path, platform: &str) -> PathBuf { - wrapper_dir.join(if wrapper_dir.join("pnpm.exe").exists() || platform == "win32" { + package_dir(install_dir, package_name).join(if host_platform() == "win32" { "pnpm.exe" } else { "pnpm" @@ -423,7 +418,7 @@ pub(crate) fn link_exe_platform_binary( })?; let native_source_root = native_source_trust_root(&install_real_dir, wrapper_pkg_name); let src = validate_native_binary_source(&src, &native_source_root)?; - let dest = wrapper_executable_path(&wrapper_real_dir, platform); + let dest = wrapper_real_dir.join(executable); replace_executable(&src, &dest) .into_diagnostic() .wrap_err("link the native pnpm binary into the wrapper")?; diff --git a/pnpm/crates/cli/src/cli_args/self_update/install_pnpm/tests.rs b/pnpm/crates/cli/src/cli_args/self_update/install_pnpm/tests.rs index 43e27d3423..bbde1d77be 100644 --- a/pnpm/crates/cli/src/cli_args/self_update/install_pnpm/tests.rs +++ b/pnpm/crates/cli/src/cli_args/self_update/install_pnpm/tests.rs @@ -1,5 +1,5 @@ use super::{ - PNPM_EXE_PACKAGE_NAME, PNPM_PACKAGE_NAME, assert_pnpm_runs, assert_release_is_installable, + PNPM_EXE_PACKAGE_NAME, PNPM_PACKAGE_NAME, assert_release_is_installable, exe_platform_pkg_dir_name, exe_platform_pkg_dir_name_next, link_exe_platform_binary, package_dir, pnpm_package_to_install, reuse_cached_engine, run_install, }; @@ -154,21 +154,6 @@ fn links_the_host_platform_binary_into_the_wrapper() { assert_eq!(mode & 0o777, 0o755, "the linked binary is executable"); } -#[cfg(unix)] -#[test] -fn links_the_host_platform_binary_over_an_extension_bearing_wrapper_target() { - let temp = tempfile::tempdir().expect("tempdir"); - fake_engine_install(temp.path(), true); - let wrapper_dir = package_dir(temp.path(), PNPM_PACKAGE_NAME); - fs::write(wrapper_dir.join("pnpm.exe"), b"placeholder").expect("write placeholder"); - - link_exe_platform_binary(temp.path(), PNPM_PACKAGE_NAME).expect("linking should succeed"); - - let dest = wrapper_dir.join("pnpm.exe"); - assert_eq!(fs::read(&dest).expect("read linked binary"), b"#!/bin/sh\necho pnpm\n"); - assert_pnpm_runs(temp.path(), PNPM_PACKAGE_NAME, "12.0.0").expect("linked pnpm should run"); -} - #[cfg(unix)] #[test] fn links_the_host_platform_binary_into_scoped_exe_wrapper() { diff --git a/pnpm/crates/cli/src/cli_args/self_update/tests.rs b/pnpm/crates/cli/src/cli_args/self_update/tests.rs index 220707523b..c16cd1e0be 100644 --- a/pnpm/crates/cli/src/cli_args/self_update/tests.rs +++ b/pnpm/crates/cli/src/cli_args/self_update/tests.rs @@ -89,9 +89,8 @@ fn self_update_refreshes_an_existing_v1_dispatcher_from_the_v12_engine() { let global_bin = root.path().join("bin"); let install_dir = root.path().join("engine"); fs::create_dir_all(&global_bin).unwrap(); - let wrapper_dir = install_pnpm::package_dir(&install_dir, "pnpm"); - fs::create_dir_all(&wrapper_dir).unwrap(); - let executable = wrapper_dir.join("pnpm.exe"); + let executable = install_pnpm::pnpm_executable_path(&install_dir, "pnpm"); + fs::create_dir_all(executable.parent().unwrap()).unwrap(); fs::write(&executable, b"new v12 engine").unwrap(); let dispatcher = global_shim_dispatcher_path(&global_bin); fs::write(&dispatcher, b"old v12 engine").unwrap(); diff --git a/pnpm/npm/pnpm/install.js b/pnpm/npm/pnpm/install.js index 16d5948126..28d13a2ec4 100644 --- a/pnpm/npm/pnpm/install.js +++ b/pnpm/npm/pnpm/install.js @@ -1,11 +1,10 @@ #!/usr/bin/env node // Preinstall for the pnpm v12 wrapper (shared verbatim by `pnpm` and // `@pnpm/exe`): replace the shebang-less placeholder bins with the host's native -// binary so `pnpm` runs directly, no Node startup per call. The `.exe` suffix is -// used on every platform because PowerShell cannot execute an extensionless -// native binary through an explicit path. The placeholder makes the bin target -// packable before preinstall replaces it; the tradeoff is no fallback when build -// scripts are blocked (`--ignore-scripts`, pnpm/Bun default). +// binary so `pnpm` runs directly, no Node startup per call. A placeholder (not a +// Node launcher) is required because the Windows shim is generated from the bin +// file and npm won't re-read package.json after preinstall; the tradeoff is no +// fallback when build scripts are blocked (`--ignore-scripts`, pnpm/Bun default). // // `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 @@ -64,7 +63,7 @@ function setup () { } rewriteBin(newBin) } else { - placeBinary(nativeBinary, path.join(wrapperDir, 'pnpm.exe'), 0o755) + placeBinary(nativeBinary, path.join(wrapperDir, 'pnpm'), 0o755) } } diff --git a/pnpm/npm/pnpm/package.json b/pnpm/npm/pnpm/package.json index 79220039eb..0d5ca3faee 100644 --- a/pnpm/npm/pnpm/package.json +++ b/pnpm/npm/pnpm/package.json @@ -27,7 +27,7 @@ "directory": "pnpm/npm/pnpm" }, "bin": { - "pnpm": "pnpm.exe", + "pnpm": "pnpm", "pn": "pn", "pnpx": "pnpx", "pnx": "pnx" @@ -41,7 +41,7 @@ "node": ">=18.*" }, "files": [ - "pnpm.exe", + "pnpm", "pn", "pnpx", "pnx", diff --git a/pnpm/npm/pnpm/pnpm.exe b/pnpm/npm/pnpm/pnpm similarity index 100% rename from pnpm/npm/pnpm/pnpm.exe rename to pnpm/npm/pnpm/pnpm diff --git a/pnpm/npm/pnpm/scripts/generate-packages.mjs b/pnpm/npm/pnpm/scripts/generate-packages.mjs index d39f96f7b2..b1e0854310 100644 --- a/pnpm/npm/pnpm/scripts/generate-packages.mjs +++ b/pnpm/npm/pnpm/scripts/generate-packages.mjs @@ -33,7 +33,7 @@ const NOTICES_FILE = "THIRD-PARTY-NOTICES.md"; // publish the same `files` list, so anything named there has to be copied here // too. const WRAPPER_FILES = [ - "pnpm.exe", + "pnpm", "pn", "pnpx", "pnx", diff --git a/pnpm/npm/pnpm/test/install.test.mjs b/pnpm/npm/pnpm/test/install.test.mjs deleted file mode 100644 index 5e0c481157..0000000000 --- a/pnpm/npm/pnpm/test/install.test.mjs +++ /dev/null @@ -1,91 +0,0 @@ -import assert from 'node:assert/strict' -import { execFileSync } 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 { test } from 'node:test' -import { fileURLToPath } from 'node:url' - -import { getBinCandidates, splitBinSpecifier } from '../native-binary.mjs' - -const wrapperDir = path.resolve(fileURLToPath(import.meta.url), '../..') -const wrapperManifest = JSON.parse(fs.readFileSync(path.join(wrapperDir, 'package.json'), 'utf8')) - -test('npm installs a shim that runs the native pnpm binary', (t) => { - assert.equal(wrapperManifest.bin.pnpm, 'pnpm.exe') - - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm wrapper install-')) - t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) - - const candidate = getBinCandidates()[0] - assert.ok(candidate) - const { packageName, binFile } = splitBinSpecifier(candidate) - const nativePackageDir = path.join(tempDir, 'native-package') - fs.mkdirSync(nativePackageDir) - fs.writeFileSync(path.join(nativePackageDir, 'package.json'), JSON.stringify({ - name: packageName, - version: '1.0.0', - })) - writeNativeFixture(path.join(nativePackageDir, binFile)) - - const fixtureDir = path.join(tempDir, 'wrapper') - 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({ - name: 'pnpm-install-fixture', - version: '1.0.0', - type: 'module', - bin: { pnpm: wrapperManifest.bin.pnpm }, - scripts: { preinstall: 'node install.js' }, - optionalDependencies: { [packageName]: `file:${nativePackageDir}` }, - })) - - const prefix = path.join(tempDir, 'prefix') - runNpm([ - 'install', - '--global', - '--install-links=true', - '--dangerously-allow-all-scripts', - '--prefix', - prefix, - fixtureDir, - ], tempDir) - - if (process.platform === 'win32') { - const shim = path.join(prefix, 'pnpm.ps1') - assert.match(fs.readFileSync(shim, 'utf8'), /pnpm\.exe/) - assert.match(execFileSync('pwsh', ['-NoProfile', '-File', shim, '--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') { - const npmCli = execFileSync('where.exe', ['npm.cmd'], { encoding: 'utf8' }) - .split(/\r?\n/) - .filter(Boolean) - .map(launcher => path.join(path.dirname(launcher), 'node_modules', 'npm', 'bin', 'npm-cli.js')) - .find(candidate => fs.existsSync(candidate)) - assert.ok(npmCli, 'Unable to find npm-cli.js next to an npm.cmd on PATH') - execFileSync(process.execPath, [npmCli, ...args], { cwd, stdio: 'pipe' }) - } else { - execFileSync('npm', args, { cwd, stdio: 'pipe' }) - } -} - -function writeNativeFixture (destPath) { - if (process.platform === 'win32') { - try { - fs.linkSync(process.execPath, destPath) - } catch (err) { - if (err.code !== 'EXDEV') throw err - fs.copyFileSync(process.execPath, destPath) - } - } else { - fs.writeFileSync(destPath, '#!/bin/sh\nprintf \'fixture:%s\\n\' "$1"\n', { mode: 0o755 }) - } -} diff --git a/pnpm11/engine/pm/commands/src/self-updater/installPnpm.ts b/pnpm11/engine/pm/commands/src/self-updater/installPnpm.ts index c4cd650ed2..01222de474 100644 --- a/pnpm11/engine/pm/commands/src/self-updater/installPnpm.ts +++ b/pnpm11/engine/pm/commands/src/self-updater/installPnpm.ts @@ -516,8 +516,7 @@ export function linkExePlatformBinary (installDir: string, wrapperPkgName: strin if (src != null) break } if (src == null) return - const wrapperExecutable = fs.existsSync(path.join(wrapperDir, 'pnpm.exe')) ? 'pnpm.exe' : executable - const dest = path.join(wrapperDir, wrapperExecutable) + const dest = path.join(wrapperDir, executable) forceLink(src, dest) if (platform === 'win32') { 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 6b0b38de95..a41d643f7e 100644 --- a/pnpm11/engine/pm/commands/test/self-updater/selfUpdate.test.ts +++ b/pnpm11/engine/pm/commands/test/self-updater/selfUpdate.test.ts @@ -1430,10 +1430,9 @@ describe('linkExePlatformBinary', () => { fs.mkdirSync(wrapperDir, { recursive: true }) fs.mkdirSync(platformDir, { recursive: true }) - const wrapperExecutable = 'pnpm.exe' - fs.writeFileSync(path.join(wrapperDir, wrapperExecutable), 'This is a placeholder.') + fs.writeFileSync(path.join(wrapperDir, executable), 'This is a placeholder.') fs.writeFileSync(path.join(wrapperDir, 'package.json'), JSON.stringify({ - bin: { pnpm: wrapperExecutable, pn: 'pn', pnpx: 'pnpx', pnx: 'pnx' }, + bin: { pnpm: 'pnpm', pn: 'pn', pnpx: 'pnpx', pnx: 'pnx' }, })) const fakeBinaryContent = '#!/bin/sh\necho "fake pnpm v12 binary"' @@ -1441,7 +1440,7 @@ describe('linkExePlatformBinary', () => { linkExePlatformBinary(dir, 'pnpm') - const result = fs.readFileSync(path.join(wrapperDir, wrapperExecutable), 'utf8') + const result = fs.readFileSync(path.join(wrapperDir, executable), 'utf8') expect(result).toBe(fakeBinaryContent) })