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.
This commit is contained in:
Zoltan Kochan authored and GitHub committed 2026-09-01 02:28:15 +02:00
1 parent d754f7f413
commit a3de3b2aa5
12 files changed
+22 -139

No files matched your search

-7
View File
@@ -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).
@@ -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.
@@ -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")?;
@@ -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() {
@@ -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();
+5 -6
View File
@@ -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)
}
}
+2 -2
View File
@@ -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",
File renamed without changes.
+1 -1
View File
@@ -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",
-91
View File
@@ -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 })
}
}
@@ -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') {
@@ -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)
})