diff --git a/.changeset/update-zkochan-cmd-shim.md b/.changeset/update-zkochan-cmd-shim.md new file mode 100644 index 0000000000..7accead532 --- /dev/null +++ b/.changeset/update-zkochan-cmd-shim.md @@ -0,0 +1,7 @@ +--- +"@pnpm/bins.linker": patch +"@pnpm/exe": patch +"pnpm": patch +--- + +Updated `@zkochan/cmd-shim` to v9.0.6. diff --git a/pacquet/crates/cmd-shim/src/shim.rs b/pacquet/crates/cmd-shim/src/shim.rs index 6606657d64..5c80abf7f2 100644 --- a/pacquet/crates/cmd-shim/src/shim.rs +++ b/pacquet/crates/cmd-shim/src/shim.rs @@ -8,7 +8,7 @@ use std::{ /// Detected runtime for a target script. /// /// Mirrors the return shape of `searchScriptRuntime` in -/// . +/// . #[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(path: &Path) -> io::Result (&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/` (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/.exe` or +/// `$basedir/` (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/"` 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::>() + .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=` 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 diff --git a/pacquet/crates/cmd-shim/src/shim/tests.rs b/pacquet/crates/cmd-shim/src/shim/tests.rs index 9110963a90..50e7905903 100644 --- a/pacquet/crates/cmd-shim/src/shim/tests.rs +++ b/pacquet/crates/cmd-shim/src/shim/tests.rs @@ -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::(&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] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f528c1c72..01afd389ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3c62d6b603..8bd2a53e94 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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