diff --git a/.changeset/fix-pwsh-native-shim.md b/.changeset/fix-pwsh-native-shim.md new file mode 100644 index 0000000000..f66aa15d16 --- /dev/null +++ b/.changeset/fix-pwsh-native-shim.md @@ -0,0 +1,7 @@ +--- +"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/pnpm/crates/cli/src/cli_args/self_update/install_pnpm.rs b/pnpm/crates/cli/src/cli_args/self_update/install_pnpm.rs index c3734033cb..ab27e35dc4 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,7 +148,12 @@ 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 { - package_dir(install_dir, package_name).join(if host_platform() == "win32" { + 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" { "pnpm.exe" } else { "pnpm" @@ -418,7 +423,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_real_dir.join(executable); + let dest = wrapper_executable_path(&wrapper_real_dir, platform); 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 bbde1d77be..43e27d3423 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_release_is_installable, + PNPM_EXE_PACKAGE_NAME, PNPM_PACKAGE_NAME, assert_pnpm_runs, 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,6 +154,21 @@ 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 c16cd1e0be..220707523b 100644 --- a/pnpm/crates/cli/src/cli_args/self_update/tests.rs +++ b/pnpm/crates/cli/src/cli_args/self_update/tests.rs @@ -89,8 +89,9 @@ 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 executable = install_pnpm::pnpm_executable_path(&install_dir, "pnpm"); - fs::create_dir_all(executable.parent().unwrap()).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"); 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 28d13a2ec4..16d5948126 100644 --- a/pnpm/npm/pnpm/install.js +++ b/pnpm/npm/pnpm/install.js @@ -1,10 +1,11 @@ #!/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. 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). +// 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). // // `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 @@ -63,7 +64,7 @@ function setup () { } rewriteBin(newBin) } else { - placeBinary(nativeBinary, path.join(wrapperDir, 'pnpm'), 0o755) + placeBinary(nativeBinary, path.join(wrapperDir, 'pnpm.exe'), 0o755) } } diff --git a/pnpm/npm/pnpm/package.json b/pnpm/npm/pnpm/package.json index 07afdfec39..73701f3d61 100644 --- a/pnpm/npm/pnpm/package.json +++ b/pnpm/npm/pnpm/package.json @@ -27,7 +27,7 @@ "directory": "pnpm/npm/pnpm" }, "bin": { - "pnpm": "pnpm", + "pnpm": "pnpm.exe", "pn": "pn", "pnpx": "pnpx", "pnx": "pnx" @@ -41,7 +41,7 @@ "node": ">=18.*" }, "files": [ - "pnpm", + "pnpm.exe", "pn", "pnpx", "pnx", diff --git a/pnpm/npm/pnpm/pnpm b/pnpm/npm/pnpm/pnpm.exe similarity index 100% rename from pnpm/npm/pnpm/pnpm rename to pnpm/npm/pnpm/pnpm.exe diff --git a/pnpm/npm/pnpm/scripts/generate-packages.mjs b/pnpm/npm/pnpm/scripts/generate-packages.mjs index b1e0854310..d39f96f7b2 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", + "pnpm.exe", "pn", "pnpx", "pnx", diff --git a/pnpm/npm/pnpm/test/install.test.mjs b/pnpm/npm/pnpm/test/install.test.mjs new file mode 100644 index 0000000000..5e0c481157 --- /dev/null +++ b/pnpm/npm/pnpm/test/install.test.mjs @@ -0,0 +1,91 @@ +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 01222de474..c4cd650ed2 100644 --- a/pnpm11/engine/pm/commands/src/self-updater/installPnpm.ts +++ b/pnpm11/engine/pm/commands/src/self-updater/installPnpm.ts @@ -516,7 +516,8 @@ export function linkExePlatformBinary (installDir: string, wrapperPkgName: strin if (src != null) break } if (src == null) return - const dest = path.join(wrapperDir, executable) + const wrapperExecutable = fs.existsSync(path.join(wrapperDir, 'pnpm.exe')) ? 'pnpm.exe' : executable + const dest = path.join(wrapperDir, wrapperExecutable) 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 a41d643f7e..6b0b38de95 100644 --- a/pnpm11/engine/pm/commands/test/self-updater/selfUpdate.test.ts +++ b/pnpm11/engine/pm/commands/test/self-updater/selfUpdate.test.ts @@ -1430,9 +1430,10 @@ describe('linkExePlatformBinary', () => { fs.mkdirSync(wrapperDir, { recursive: true }) fs.mkdirSync(platformDir, { recursive: true }) - fs.writeFileSync(path.join(wrapperDir, executable), 'This is a placeholder.') + const wrapperExecutable = 'pnpm.exe' + fs.writeFileSync(path.join(wrapperDir, wrapperExecutable), 'This is a placeholder.') fs.writeFileSync(path.join(wrapperDir, 'package.json'), JSON.stringify({ - bin: { pnpm: 'pnpm', pn: 'pn', pnpx: 'pnpx', pnx: 'pnx' }, + bin: { pnpm: wrapperExecutable, pn: 'pn', pnpx: 'pnpx', pnx: 'pnx' }, })) const fakeBinaryContent = '#!/bin/sh\necho "fake pnpm v12 binary"' @@ -1440,7 +1441,7 @@ describe('linkExePlatformBinary', () => { linkExePlatformBinary(dir, 'pnpm') - const result = fs.readFileSync(path.join(wrapperDir, executable), 'utf8') + const result = fs.readFileSync(path.join(wrapperDir, wrapperExecutable), 'utf8') expect(result).toBe(fakeBinaryContent) })