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.
///
/// 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)]
pub struct ScriptRuntime {
/// 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) {
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)
@@ -164,11 +165,12 @@ fn strip_env_prefix(input: &str) -> (&str, bool) {
///
/// The shim is a pure `/bin/sh` script that:
///
/// 1. Resolves `basedir` to its own directory (with a `cygpath` fixup for
/// MSYS-style POSIX shells on Windows).
/// 2. If the runtime program is colocated at `$basedir/<prog>` (a rare case,
/// only true when the runtime was bundled alongside the shim), prefer that
/// binary; otherwise fall through to the system PATH.
/// 1. Resolves `basedir` to its own directory and keeps `basedir_win` for
/// native Windows binaries reached from Cygwin/MSYS/WSL2.
/// 2. If the runtime program is colocated at `$basedir/<prog>.exe` or
/// `$basedir/<prog>` (a rare case, only true when the runtime was bundled
/// alongside the shim), prefer that binary; otherwise fall through to the
/// system PATH.
/// 3. Forwards `"$@"` to the resolved interpreter, with the target script as
/// the first positional argument.
///
@@ -189,18 +191,52 @@ pub fn generate_sh_shim(
} else {
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 {
Some(ScriptRuntime { prog: Some(prog), args }) => {
// `sh_long_prog` is the `"$basedir/<prog>"` form upstream uses.
// It always carries the leading `$basedir/` and quotes; never
// just the program name on its own.
let sh_long_prog = format!("\"$basedir/{prog}\"");
writeln!(
sh,
"if [ -x {sh_long_prog} ]; then\n exec {sh_long_prog} {args} {quoted_target} \"$@\"\nelse\n exec {prog} {args} {quoted_target} \"$@\"\nfi",
)
.unwrap();
let prog_base = strip_exe_suffix(prog).unwrap_or(prog);
let prog_has_exe = prog_base.len() != prog.len();
let prog_exe = if prog_has_exe { prog.clone() } else { format!("{prog}.exe") };
let sh_long_prog_exe = format!("\"$basedir/{prog_exe}\"");
let exec_block = |exec_args: &str| {
let mut block = String::new();
if prog_has_exe {
writeln!(
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
// 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
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
basedir_win="$basedir"
exe=""
msys=""
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
case `uname -a` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir_win=`cygpath -w "$basedir"`
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
"#;
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
/// whether an existing shim already targets the same source without
/// re-parsing its body. Pacquet uses [`is_shim_pointing_at`] for the same

View File

@@ -1,7 +1,8 @@
use super::{
ScriptRuntime, extension_program, generate_cmd_shim, generate_pwsh_shim, generate_sh_shim,
is_shim_pointing_at, parse_shebang, parse_shebang_from_bytes, read_head_filled,
relative_target, search_script_runtime,
ScriptRuntime, escape_msys_cmd_switches, extension_program, generate_cmd_shim,
generate_pwsh_shim, generate_sh_shim, is_shim_pointing_at, parse_shebang,
parse_shebang_from_bytes, read_head_filled, relative_target, search_script_runtime,
strip_exe_suffix,
};
use crate::{
capabilities::{FsReadHead, Host},
@@ -59,10 +60,9 @@ fn relative_target_traverses_into_sibling_package() {
assert_eq!(relative_target(target, shim), "../foo/bin/cli.js");
}
/// Shim body for the typical `#!/usr/bin/env node` case must match the
/// exec template upstream produces verbatim, including the double space
/// between `$basedir/node` and the quoted target path (upstream's
/// `${args}` interpolates to empty between two literal spaces).
/// Shim body for the typical `#!/usr/bin/env node` case must preserve
/// the generated exec block shape, including the double space between
/// `$basedir/node` and the quoted target path when `args` is empty.
#[test]
fn generate_sh_shim_matches_pnpm_typical_case() {
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.contains("if [ -x \"$basedir/node\" ]; then\n exec \"$basedir/node\" \"$basedir/../typescript/bin/tsc\" \"$@\"\nelse\n exec node \"$basedir/../typescript/bin/tsc\" \"$@\"\nfi\n"),
"exec block must match pnpm's generateShShim template, body was:\n{body}",
body.contains(
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!(
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"));
}
#[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
/// extension yields a runtime. Pure no-runtime path.
#[test]

14
pnpm-lock.yaml generated
View File

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

View File

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