fix(pacquet): support WSL Windows binaries in sh shims (#12409)

- Port the shell shim behavior from pnpm/cmd-shim#56 to pacquet.
- Generate `basedir_win` with Cygwin/MSYS/WSL2 handling and use it only when invoking `.exe` runtime branches.
- Preserve POSIX target paths for non-`.exe` runtime branches and add the `.cmd`/`.bat` `/C` runtime fallback.
- Gate MSYS-specific cmd switch escaping behind an `$msys` runtime flag, so MSYS gets `//C` while WSL2 and other shells keep `/C`.
- Bump `@zkochan/cmd-shim` to 9.0.6.
This commit is contained in:
Zoltan Kochan
2026-06-15 07:21:16 +02:00
committed by GitHub
parent a6d485abca
commit cd8348c6e9
5 changed files with 237 additions and 40 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/bins.linker": patch
"@pnpm/exe": patch
"pnpm": patch
---
Updated `@zkochan/cmd-shim` to v9.0.6.

View File

@@ -8,7 +8,7 @@ use std::{
/// Detected runtime for a target script. /// Detected runtime for a target script.
/// ///
/// Mirrors the return shape of `searchScriptRuntime` in /// Mirrors the return shape of `searchScriptRuntime` in
/// <https://github.com/pnpm/cmd-shim/blob/0d79ca9534/src/index.ts>. /// <https://github.com/pnpm/cmd-shim/blob/e8560a8405/src/index.ts>.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScriptRuntime { pub struct ScriptRuntime {
/// The interpreter to invoke. `None` means "exec the file directly". /// The interpreter to invoke. `None` means "exec the file directly".
@@ -54,7 +54,8 @@ pub fn search_script_runtime<Sys: FsReadHead>(path: &Path) -> io::Result<Option<
} }
if let Some(prog) = extension_program(extension) { if let Some(prog) = extension_program(extension) {
return Ok(Some(ScriptRuntime { prog: Some(prog.to_string()), args: String::new() })); let args = if prog == "cmd" { "/C" } else { "" };
return Ok(Some(ScriptRuntime { prog: Some(prog.to_string()), args: args.to_string() }));
} }
Ok(None) Ok(None)
@@ -164,11 +165,12 @@ fn strip_env_prefix(input: &str) -> (&str, bool) {
/// ///
/// The shim is a pure `/bin/sh` script that: /// The shim is a pure `/bin/sh` script that:
/// ///
/// 1. Resolves `basedir` to its own directory (with a `cygpath` fixup for /// 1. Resolves `basedir` to its own directory and keeps `basedir_win` for
/// MSYS-style POSIX shells on Windows). /// native Windows binaries reached from Cygwin/MSYS/WSL2.
/// 2. If the runtime program is colocated at `$basedir/<prog>` (a rare case, /// 2. If the runtime program is colocated at `$basedir/<prog>.exe` or
/// only true when the runtime was bundled alongside the shim), prefer that /// `$basedir/<prog>` (a rare case, only true when the runtime was bundled
/// binary; otherwise fall through to the system PATH. /// alongside the shim), prefer that binary; otherwise fall through to the
/// system PATH.
/// 3. Forwards `"$@"` to the resolved interpreter, with the target script as /// 3. Forwards `"$@"` to the resolved interpreter, with the target script as
/// the first positional argument. /// the first positional argument.
/// ///
@@ -189,18 +191,52 @@ pub fn generate_sh_shim(
} else { } else {
format!("\"$basedir/{sh_target}\"") format!("\"$basedir/{sh_target}\"")
}; };
let quoted_target_win = if Path::new(&sh_target).is_absolute() {
format!("\"{sh_target}\"")
} else {
format!("\"$basedir_win/{sh_target}\"")
};
match runtime { match runtime {
Some(ScriptRuntime { prog: Some(prog), args }) => { Some(ScriptRuntime { prog: Some(prog), args }) => {
// `sh_long_prog` is the `"$basedir/<prog>"` form upstream uses. let prog_base = strip_exe_suffix(prog).unwrap_or(prog);
// It always carries the leading `$basedir/` and quotes; never let prog_has_exe = prog_base.len() != prog.len();
// just the program name on its own. let prog_exe = if prog_has_exe { prog.clone() } else { format!("{prog}.exe") };
let sh_long_prog = format!("\"$basedir/{prog}\""); let sh_long_prog_exe = format!("\"$basedir/{prog_exe}\"");
writeln!( let exec_block = |exec_args: &str| {
sh, let mut block = String::new();
"if [ -x {sh_long_prog} ]; then\n exec {sh_long_prog} {args} {quoted_target} \"$@\"\nelse\n exec {prog} {args} {quoted_target} \"$@\"\nfi", if prog_has_exe {
) writeln!(
.unwrap(); block,
"if [ -x {sh_long_prog_exe} ]; then\n exec {sh_long_prog_exe} {exec_args} {quoted_target_win} \"$@\"\nelse\n exec {prog_exe} {exec_args} {quoted_target_win} \"$@\"\nfi",
)
.unwrap();
} else {
let sh_long_prog = format!("\"$basedir/{prog}\"");
writeln!(
block,
"if [ -n \"$exe\" ] && [ -x {sh_long_prog_exe} ]; then\n exec {sh_long_prog_exe} {exec_args} {quoted_target_win} \"$@\"\nelif [ -x {sh_long_prog} ]; then\n exec {sh_long_prog} {exec_args} {quoted_target} \"$@\"\nelif command -v {prog} >/dev/null 2>&1; then\n exec {prog} {exec_args} {quoted_target} \"$@\"\nelif [ -n \"$exe\" ] && command -v {prog_exe} >/dev/null 2>&1; then\n exec {prog_exe} {exec_args} {quoted_target_win} \"$@\"\nelse\n exec {prog} {exec_args} {quoted_target} \"$@\"\nfi",
)
.unwrap();
}
block
};
let msys_args = prog_base
.eq_ignore_ascii_case("cmd")
.then(|| escape_msys_cmd_switches(args))
.filter(|escaped_args| escaped_args != args);
if let Some(msys_args) = msys_args {
writeln!(
sh,
"if [ -n \"$msys\" ]; then\n{}else\n{}fi",
indent_shell_block(&exec_block(&msys_args)),
indent_shell_block(&exec_block(args)),
)
.unwrap();
} else {
sh.push_str(&exec_block(args));
}
} }
// No runtime detected, so exec the target directly. Upstream still // No runtime detected, so exec the target directly. Upstream still
// emits `exit $?` on this branch for parity with non-execve POSIX // emits `exit $?` on this branch for parity with non-execve POSIX
@@ -343,17 +379,72 @@ fn relative_target_windows(target_path: &Path, shim_path: &Path) -> String {
const SH_SHIM_HEADER: &str = r#"#!/bin/sh const SH_SHIM_HEADER: &str = r#"#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
basedir_win="$basedir"
exe=""
msys=""
case `uname` in case `uname -a` in
*CYGWIN*|*MINGW*|*MSYS*) *CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"` basedir_win=`cygpath -w "$basedir"`
fi fi
;; exe=".exe"
msys="true"
;;
*WSL2*)
if command -v wslpath > /dev/null 2>&1; then
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
basedir_win="$basedir"
else
exe=".exe"
fi
fi
;;
esac esac
"#; "#;
fn indent_shell_block(script: &str) -> String {
script
.split('\n')
.map(|line| if line.is_empty() { String::new() } else { format!(" {line}") })
.collect::<Vec<_>>()
.join("\n")
}
fn escape_msys_cmd_switches(args: &str) -> String {
let mut escaped = String::with_capacity(args.len());
let mut chars = args.char_indices();
let mut at_boundary = true;
while let Some((_, ch)) = chars.next() {
if ch == '/' && at_boundary {
let mut lookahead = chars.clone();
if let Some((_, switch @ ('C' | 'c' | 'K' | 'k'))) = lookahead.next()
&& lookahead.next().is_none_or(|(_, next)| next.is_whitespace())
{
escaped.push('/');
escaped.push('/');
escaped.push(switch);
chars.next();
at_boundary = false;
continue;
}
}
escaped.push(ch);
at_boundary = ch.is_whitespace();
}
escaped
}
fn strip_exe_suffix(prog: &str) -> Option<&str> {
let suffix_start = prog.len().checked_sub(4)?;
prog.as_bytes()[suffix_start..].eq_ignore_ascii_case(b".exe").then(|| &prog[..suffix_start])
}
/// Trailing `# cmd-shim-target=<rel>` marker. Upstream uses it to detect /// Trailing `# cmd-shim-target=<rel>` marker. Upstream uses it to detect
/// whether an existing shim already targets the same source without /// whether an existing shim already targets the same source without
/// re-parsing its body. Pacquet uses [`is_shim_pointing_at`] for the same /// re-parsing its body. Pacquet uses [`is_shim_pointing_at`] for the same

View File

@@ -1,7 +1,8 @@
use super::{ use super::{
ScriptRuntime, extension_program, generate_cmd_shim, generate_pwsh_shim, generate_sh_shim, ScriptRuntime, escape_msys_cmd_switches, extension_program, generate_cmd_shim,
is_shim_pointing_at, parse_shebang, parse_shebang_from_bytes, read_head_filled, generate_pwsh_shim, generate_sh_shim, is_shim_pointing_at, parse_shebang,
relative_target, search_script_runtime, parse_shebang_from_bytes, read_head_filled, relative_target, search_script_runtime,
strip_exe_suffix,
}; };
use crate::{ use crate::{
capabilities::{FsReadHead, Host}, capabilities::{FsReadHead, Host},
@@ -59,10 +60,9 @@ fn relative_target_traverses_into_sibling_package() {
assert_eq!(relative_target(target, shim), "../foo/bin/cli.js"); assert_eq!(relative_target(target, shim), "../foo/bin/cli.js");
} }
/// Shim body for the typical `#!/usr/bin/env node` case must match the /// Shim body for the typical `#!/usr/bin/env node` case must preserve
/// exec template upstream produces verbatim, including the double space /// the generated exec block shape, including the double space between
/// between `$basedir/node` and the quoted target path (upstream's /// `$basedir/node` and the quoted target path when `args` is empty.
/// `${args}` interpolates to empty between two literal spaces).
#[test] #[test]
fn generate_sh_shim_matches_pnpm_typical_case() { fn generate_sh_shim_matches_pnpm_typical_case() {
let target = Path::new("/proj/node_modules/typescript/bin/tsc"); let target = Path::new("/proj/node_modules/typescript/bin/tsc");
@@ -72,8 +72,34 @@ fn generate_sh_shim_matches_pnpm_typical_case() {
assert!(body.starts_with("#!/bin/sh\n"), "shebang must come first"); assert!(body.starts_with("#!/bin/sh\n"), "shebang must come first");
assert!( assert!(
body.contains("if [ -x \"$basedir/node\" ]; then\n exec \"$basedir/node\" \"$basedir/../typescript/bin/tsc\" \"$@\"\nelse\n exec node \"$basedir/../typescript/bin/tsc\" \"$@\"\nfi\n"), body.contains(
"exec block must match pnpm's generateShShim template, body was:\n{body}", r#"basedir_win="$basedir"
exe=""
msys=""
case `uname -a` in"#
),
"header must track a Windows-form basedir for WSL2/Cygwin, body was:\n{body}",
);
assert!(
body.contains(r#"basedir_win="$(wslpath -w "$basedir" 2> /dev/null)""#),
"header must convert WSL2 basedir with wslpath, body was:\n{body}",
);
assert!(
body.contains(r#"basedir_win=`cygpath -w "$basedir"`"#),
"MSYS branch must only update the Windows-form basedir, body was:\n{body}",
);
assert!(
!body.contains("basedir=`cygpath"),
"MSYS branch must keep the POSIX basedir unchanged, body was:\n{body}",
);
assert!(
body.contains("else\n exe=\".exe\"\n fi"),
"WSL2 branch must enable .exe fallback only after wslpath succeeds, body was:\n{body}",
);
assert!(
body.contains("if [ -n \"$exe\" ] && [ -x \"$basedir/node.exe\" ]; then\n exec \"$basedir/node.exe\" \"$basedir_win/../typescript/bin/tsc\" \"$@\"\nelif [ -x \"$basedir/node\" ]; then\n exec \"$basedir/node\" \"$basedir/../typescript/bin/tsc\" \"$@\"\nelif command -v node >/dev/null 2>&1; then\n exec node \"$basedir/../typescript/bin/tsc\" \"$@\"\nelif [ -n \"$exe\" ] && command -v node.exe >/dev/null 2>&1; then\n exec node.exe \"$basedir_win/../typescript/bin/tsc\" \"$@\"\nelse\n exec node \"$basedir/../typescript/bin/tsc\" \"$@\"\nfi\n"),
"exec block must preserve the generated sh shim fallback order, body was:\n{body}",
); );
assert!( assert!(
body.ends_with("# cmd-shim-target=/proj/node_modules/typescript/bin/tsc\n"), body.ends_with("# cmd-shim-target=/proj/node_modules/typescript/bin/tsc\n"),
@@ -277,6 +303,80 @@ fn search_script_runtime_falls_back_to_extension() {
assert_eq!(rt.prog.as_deref(), Some("node")); assert_eq!(rt.prog.as_deref(), Some("node"));
} }
#[test]
fn search_script_runtime_falls_back_to_cmd_with_c_switch() {
use tempfile::tempdir;
let tmp = tempdir().unwrap();
for filename in ["script.cmd", "script.bat"] {
let path = tmp.path().join(filename);
std::fs::write(&path, "echo off\r\n").unwrap();
let rt = search_script_runtime::<Host>(&path).unwrap().expect("extension fallback");
assert_eq!(rt.prog.as_deref(), Some("cmd"));
assert_eq!(rt.args, "/C");
}
}
#[test]
fn escape_msys_cmd_switches_escapes_only_standalone_cmd_switches() {
assert_eq!(escape_msys_cmd_switches("/C"), "//C");
assert_eq!(escape_msys_cmd_switches(" /c\t/K "), " //c\t//K ");
assert_eq!(
escape_msys_cmd_switches("--flag /Config path/C /C:bad"),
"--flag /Config path/C /C:bad",
);
}
#[test]
fn strip_exe_suffix_is_case_insensitive() {
assert_eq!(strip_exe_suffix("cmd.exe"), Some("cmd"));
assert_eq!(strip_exe_suffix("cmd.EXE"), Some("cmd"));
assert_eq!(strip_exe_suffix("\u{e5}.exe"), Some("\u{e5}"));
assert_eq!(strip_exe_suffix("node"), None);
assert_eq!(strip_exe_suffix("\u{e5}\u{e5}x"), None);
}
#[test]
fn generate_sh_shim_uses_windows_target_only_for_exe_branches() {
let target = Path::new("/proj/node_modules/foo/src.bat");
let shim = Path::new("/proj/node_modules/.bin/foo");
let runtime = ScriptRuntime { prog: Some("cmd".into()), args: "/C".into() };
let body = generate_sh_shim(target, shim, Some(&runtime));
assert!(
body.contains("if [ -n \"$msys\" ]; then\n if [ -n \"$exe\" ] && [ -x \"$basedir/cmd.exe\" ]; then\n exec \"$basedir/cmd.exe\" //C \"$basedir_win/../foo/src.bat\" \"$@\"\n elif [ -x \"$basedir/cmd\" ]; then\n exec \"$basedir/cmd\" //C \"$basedir/../foo/src.bat\" \"$@\"\n elif command -v cmd >/dev/null 2>&1; then\n exec cmd //C \"$basedir/../foo/src.bat\" \"$@\"\n elif [ -n \"$exe\" ] && command -v cmd.exe >/dev/null 2>&1; then\n exec cmd.exe //C \"$basedir_win/../foo/src.bat\" \"$@\"\n else\n exec cmd //C \"$basedir/../foo/src.bat\" \"$@\"\n fi\nelse\n if [ -n \"$exe\" ] && [ -x \"$basedir/cmd.exe\" ]; then\n exec \"$basedir/cmd.exe\" /C \"$basedir_win/../foo/src.bat\" \"$@\"\n elif [ -x \"$basedir/cmd\" ]; then\n exec \"$basedir/cmd\" /C \"$basedir/../foo/src.bat\" \"$@\"\n elif command -v cmd >/dev/null 2>&1; then\n exec cmd /C \"$basedir/../foo/src.bat\" \"$@\"\n elif [ -n \"$exe\" ] && command -v cmd.exe >/dev/null 2>&1; then\n exec cmd.exe /C \"$basedir_win/../foo/src.bat\" \"$@\"\n else\n exec cmd /C \"$basedir/../foo/src.bat\" \"$@\"\n fi\nfi\n"),
"cmd sh shim must escape switches only for MSYS and use Windows-form targets only for .exe execution branches, body was:\n{body}",
);
}
#[test]
fn generate_sh_shim_checks_path_before_exe_fallback() {
let target = Path::new("/proj/node_modules/foo/src.sh");
let shim = Path::new("/proj/node_modules/.bin/foo");
let runtime = ScriptRuntime { prog: Some("sh".into()), args: String::new() };
let body = generate_sh_shim(target, shim, Some(&runtime));
assert!(
body.contains("elif command -v sh >/dev/null 2>&1; then\n exec sh \"$basedir/../foo/src.sh\" \"$@\"\nelif [ -n \"$exe\" ] && command -v sh.exe >/dev/null 2>&1; then\n exec sh.exe \"$basedir_win/../foo/src.sh\" \"$@\"\nelse\n exec sh \"$basedir/../foo/src.sh\" \"$@\"\nfi\n"),
"PATH fallback must prefer POSIX runtimes and gate .exe fallback, body was:\n{body}",
);
}
#[test]
fn generate_sh_shim_does_not_append_exe_twice() {
let target = Path::new("/proj/node_modules/foo/src.bat");
let shim = Path::new("/proj/node_modules/.bin/foo");
let runtime = ScriptRuntime { prog: Some("cmd.exe".into()), args: "/C".into() };
let body = generate_sh_shim(target, shim, Some(&runtime));
assert!(!body.contains("cmd.exe.exe"), "explicit .exe runtime must not double suffix:\n{body}");
assert!(
body.contains("if [ -n \"$msys\" ]; then\n if [ -x \"$basedir/cmd.exe\" ]; then\n exec \"$basedir/cmd.exe\" //C \"$basedir_win/../foo/src.bat\" \"$@\"\n else\n exec cmd.exe //C \"$basedir_win/../foo/src.bat\" \"$@\"\n fi\nelse\n if [ -x \"$basedir/cmd.exe\" ]; then\n exec \"$basedir/cmd.exe\" /C \"$basedir_win/../foo/src.bat\" \"$@\"\n else\n exec cmd.exe /C \"$basedir_win/../foo/src.bat\" \"$@\"\n fi\nfi\n"),
"explicit .exe runtime must use Windows-form targets and escape switches only for MSYS, body was:\n{body}",
);
}
/// [`search_script_runtime`] returns `Ok(None)` when neither shebang nor /// [`search_script_runtime`] returns `Ok(None)` when neither shebang nor
/// extension yields a runtime. Pure no-runtime path. /// extension yields a runtime. Pure no-runtime path.
#[test] #[test]

14
pnpm-lock.yaml generated
View File

@@ -522,8 +522,8 @@ catalogs:
specifier: ^4.1.6 specifier: ^4.1.6
version: 4.1.7 version: 4.1.7
'@zkochan/cmd-shim': '@zkochan/cmd-shim':
specifier: ^9.0.3 specifier: ^9.0.6
version: 9.0.3 version: 9.0.6
'@zkochan/retry': '@zkochan/retry':
specifier: ^0.2.0 specifier: ^0.2.0
version: 0.2.0 version: 0.2.0
@@ -1608,7 +1608,7 @@ importers:
version: link:../../workspace/project-manifest-reader version: link:../../workspace/project-manifest-reader
'@zkochan/cmd-shim': '@zkochan/cmd-shim':
specifier: 'catalog:' specifier: 'catalog:'
version: 9.0.3 version: 9.0.6
'@zkochan/rimraf': '@zkochan/rimraf':
specifier: 'catalog:' specifier: 'catalog:'
version: 4.0.0 version: 4.0.0
@@ -8043,7 +8043,7 @@ importers:
version: link:../../../__utils__/jest-config version: link:../../../__utils__/jest-config
'@zkochan/cmd-shim': '@zkochan/cmd-shim':
specifier: 'catalog:' specifier: 'catalog:'
version: 9.0.3 version: 9.0.6
execa: execa:
specifier: 'catalog:' specifier: 'catalog:'
version: safe-execa@0.3.0 version: safe-execa@0.3.0
@@ -12582,8 +12582,8 @@ packages:
resolution: {integrity: sha512-E5mgrRS8Kk80n19Xxmrx5qO9UG03FyZd8Me5gxYi++VPZsOv8+OsclA+0Fth4KTDCrQ/FkJryNFKJ6/642lo4g==} resolution: {integrity: sha512-E5mgrRS8Kk80n19Xxmrx5qO9UG03FyZd8Me5gxYi++VPZsOv8+OsclA+0Fth4KTDCrQ/FkJryNFKJ6/642lo4g==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
'@zkochan/cmd-shim@9.0.3': '@zkochan/cmd-shim@9.0.6':
resolution: {integrity: sha512-u2kKE7N/UapZ0RyAQTNcnSQ9SYbvtcgwzfXl3WHcoJvja69T15Dou//ZU6Bsk3p0M6fFWD5ip+uwkJy61TvaMA==} resolution: {integrity: sha512-Anjtn1GHeHMpG8jndikUt/eMb1BVHS8RnYPrXxcvP7FZjKXS1JoQilD7Ympyz7WI/p1gTQzXME4z6CKpY5Jghg==}
engines: {node: '>=22.13'} engines: {node: '>=22.13'}
'@zkochan/diable@1.0.2': '@zkochan/diable@1.0.2':
@@ -20463,7 +20463,7 @@ snapshots:
graceful-fs: 4.2.11(patch_hash=68ebc232025360cb3dcd3081f4067f4e9fc022ab6b6f71a3230e86c7a5b337d1) graceful-fs: 4.2.11(patch_hash=68ebc232025360cb3dcd3081f4067f4e9fc022ab6b6f71a3230e86c7a5b337d1)
is-windows: 1.0.2 is-windows: 1.0.2
'@zkochan/cmd-shim@9.0.3': '@zkochan/cmd-shim@9.0.6':
dependencies: dependencies:
cmd-extension: 1.0.2 cmd-extension: 1.0.2
graceful-fs: 4.2.11(patch_hash=68ebc232025360cb3dcd3081f4067f4e9fc022ab6b6f71a3230e86c7a5b337d1) graceful-fs: 4.2.11(patch_hash=68ebc232025360cb3dcd3081f4067f4e9fc022ab6b6f71a3230e86c7a5b337d1)

View File

@@ -149,7 +149,7 @@ catalog:
'@yarnpkg/nm': 4.0.7 '@yarnpkg/nm': 4.0.7
'@yarnpkg/parsers': 3.0.3 '@yarnpkg/parsers': 3.0.3
'@yarnpkg/pnp': ^4.1.6 '@yarnpkg/pnp': ^4.1.6
'@zkochan/cmd-shim': ^9.0.3 '@zkochan/cmd-shim': ^9.0.6
'@zkochan/retry': ^0.2.0 '@zkochan/retry': ^0.2.0
'@zkochan/rimraf': ^4.0.0 '@zkochan/rimraf': ^4.0.0
'@zkochan/table': ^2.0.1 '@zkochan/table': ^2.0.1
@@ -369,7 +369,6 @@ minimumReleaseAgeExclude:
- '@pnpm/*' - '@pnpm/*'
- '@rushstack/worker-pool@0.7.18' - '@rushstack/worker-pool@0.7.18'
- '@zkochan/*' - '@zkochan/*'
- '@zkochan/cmd-shim@9.0.3'
- better-path-resolve - better-path-resolve
- body-parser@2.2.1 - body-parser@2.2.1
- can-link - can-link