fix(pacquet): generate executable PowerShell shim (#14369)
fix(pacquet): generate executable PowerShell shim Publish the native CLI wrapper with pnpm.exe as its cross-platform bin target so npm-generated PowerShell shims invoke an executable file. Keep package-manager delegation, self-update validation, and dispatcher refresh compatible with both the new target and existing extensionless wrappers, and cover the npm global-install flow with an integration test.
This commit is contained in:
1 parent
de2167d55e
commit
470c15502b
11 files changed
+139
-17
No files matched your search
@@ -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).
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
File renamed without changes.
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user