From ae1c082d05a858a5b532caebc9a40d8cb197828b Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 12 May 2026 00:50:50 +0200 Subject: [PATCH 1/3] fix(sh): escape `/C` so MSYS doesn't drop the cmd switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sh shim generated for a `.cmd` / `.bat` target runs the script via `exec cmd /C "" "$@"`. Under Git Bash (MSYS), the path-conversion layer that runs when bash launches a native Win32 process rewrites arguments matching POSIX-path heuristics — a bare `/C` is treated as a path and rewritten to `C:\`. cmd.exe then never sees the `/C` flag, starts interactively, and reads the rest of the calling script as input until it hits EOF. Prefixing with `//` is the MSYS escape: `//C` survives the translation and reaches cmd.exe as `/C`. The cmd shim is unaffected (`%*` argument passing in cmd.exe doesn't get this treatment), so the change is scoped to `generateShShim`. Bug introduced in #46; manifests as cmd.exe banner output when invoking any cmd-shim-wrapped `.cmd` from Git Bash. --- Written by an agent (Claude Code, claude-opus-4-7). --- src/index.ts | 17 ++++++++++++++++- test/test.js.snapshot | 4 ++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index ce7280b..f50f200 100644 --- a/src/index.ts +++ b/src/index.ts @@ -324,6 +324,15 @@ function getExeExtension (): string { return cmdExtension || '.exe' } +/** + * Prefix bare `/C` / `/K` switches with an extra `/` so MSYS / Git Bash + * passes them through to cmd.exe unchanged instead of converting them to + * `C:\` / `K:\` paths. See {@link generateShShim} for context. + */ +function escapeMsysCmdSwitches (args: string): string { + return args.replace(/(^|\s)\/([CcKk])(\s|$)/g, '$1//$2$3') +} + /** * Write shim to the file system while executing the pre- and post-processes * defined in `WriteShimPre` and `WriteShimPost`. @@ -433,7 +442,13 @@ function generateShShim (src: string, to: string, opts: InternalOptions): string let shLongProg shTarget = shTarget.split('\\').join('/') const quotedPathToTarget = path.isAbsolute(shTarget) ? `"${shTarget}"` : `"$basedir/${shTarget}"` - let args = opts.args || '' + // Escape leading `/` on single-letter switches like `/C` (passed to cmd.exe + // for `.cmd`/`.bat` targets). When Git Bash / MSYS launches a native Win32 + // process, arguments that look like POSIX paths are translated — a bare + // `/C` becomes `C:\`, which drops the switch and leaves cmd.exe running + // interactively. Prefixing with `//` is the MSYS escape: it survives the + // translation and reaches cmd.exe as `/C`. + let args = escapeMsysCmdSwitches(opts.args || '') const shNodePath = normalizePathEnvVar(opts.nodePath).posix if (!shProg) { shProg = quotedPathToTarget diff --git a/test/test.js.snapshot b/test/test.js.snapshot index 65626d7..12d1f82 100644 --- a/test/test.js.snapshot +++ b/test/test.js.snapshot @@ -12,9 +12,9 @@ case \`uname\` in esac if [ -x "$basedir/cmd" ]; then - exec "$basedir/cmd" /C "$basedir/src.bat" "$@" + exec "$basedir/cmd" //C "$basedir/src.bat" "$@" else - exec cmd /C "$basedir/src.bat" "$@" + exec cmd //C "$basedir/src.bat" "$@" fi `; From 47c17a8571ce032b46fff0da678fd50347229b19 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 12 May 2026 00:55:24 +0200 Subject: [PATCH 2/3] test(e2e): regression test for MSYS /C escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generates an sh shim wrapping a `.cmd` target and invokes it via Git Bash. Asserts the script's output appears (proving the `/C` switch reached cmd.exe) and that cmd.exe's interactive banner did not (proving the bug — where MSYS rewrote `/C` to `C:\` — is not present). Windows-only; the suite is skipped elsewhere. --- Written by an agent (Claude Code, claude-opus-4-7). --- test/e2e.test.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/e2e.test.js b/test/e2e.test.js index 6733717..e05ba33 100644 --- a/test/e2e.test.js +++ b/test/e2e.test.js @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import { describe, test, snapshot } from 'node:test' @@ -22,3 +23,37 @@ describeOnWindows('create a command shim for a .exe file', () => { t.assert.snapshot(fs.readFileSync(path.join(tempDir, 'dest.ps1'), 'utf-8')) }) }) + +describeOnWindows('sh shim wrapping a .cmd target invoked from Git Bash', () => { + // Regression for the MSYS path-translation bug: Git Bash rewrites a bare + // `/C` argument to `C:\` before launching cmd.exe, dropping the switch + // and leaving cmd.exe interactive. The fix escapes it as `//C` so MSYS + // passes it through. + test('runs the wrapped batch script and captures its output', async () => { + const tempDir = tempy.directory() + const target = path.join(tempDir, 'src.cmd') + fs.writeFileSync(target, '@echo HELLO_FROM_CMD\r\n', 'utf8') + const shim = path.join(tempDir, 'shim') + await cmdShim(target, shim) + + // Invoke the sh shim via Git Bash. If `/C` survives MSYS translation, + // cmd.exe runs src.cmd and prints HELLO_FROM_CMD; if it gets rewritten + // to a drive path, cmd.exe starts interactively and dumps its banner + // (`Microsoft Windows [Version ...]`) instead. + const bash = process.env.PROGRAMFILES + ? path.join(process.env.PROGRAMFILES, 'Git', 'bin', 'bash.exe') + : 'bash' + const r = spawnSync(bash, ['--noprofile', '--norc', shim], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + + assert.equal(r.status, 0, `bash exited ${r.status}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`) + assert.match(r.stdout, /HELLO_FROM_CMD/, `expected script output, got:\n${r.stdout}`) + assert.doesNotMatch( + r.stdout, + /Microsoft Windows \[Version/, + 'cmd.exe started interactively — the /C switch was dropped' + ) + }) +}) From 51031bb3de974d9519a02bf3a2fd826be805e8d7 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 12 May 2026 00:58:51 +0200 Subject: [PATCH 3/3] fix(sh): scope MSYS escape to the cmd runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the /C → //C rewrite only when opts.prog is 'cmd' (the runtime inferred for .cmd/.bat targets). Avoids mangling legitimate /C-like args from shebang-derived configurations on non-MSYS systems, where the program would see //C verbatim. Addresses review feedback on #55. --- src/index.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index f50f200..0c26a52 100644 --- a/src/index.ts +++ b/src/index.ts @@ -442,13 +442,17 @@ function generateShShim (src: string, to: string, opts: InternalOptions): string let shLongProg shTarget = shTarget.split('\\').join('/') const quotedPathToTarget = path.isAbsolute(shTarget) ? `"${shTarget}"` : `"$basedir/${shTarget}"` - // Escape leading `/` on single-letter switches like `/C` (passed to cmd.exe - // for `.cmd`/`.bat` targets). When Git Bash / MSYS launches a native Win32 - // process, arguments that look like POSIX paths are translated — a bare - // `/C` becomes `C:\`, which drops the switch and leaves cmd.exe running - // interactively. Prefixing with `//` is the MSYS escape: it survives the - // translation and reaches cmd.exe as `/C`. - let args = escapeMsysCmdSwitches(opts.args || '') + // For `.cmd`/`.bat` targets the runtime is `cmd` and args is `/C`. When + // Git Bash / MSYS launches a native Win32 process, arguments that look + // like POSIX paths are translated — a bare `/C` becomes `C:\`, which + // drops the switch and leaves cmd.exe running interactively. Prefixing + // with `//` is the MSYS escape: it survives the translation and reaches + // cmd.exe as `/C`. Scoped to the cmd runtime so shebang-derived `/C` + // args on other shims are passed through unchanged. + let args = opts.args || '' + if (opts.prog === 'cmd' || opts.prog === 'cmd.exe') { + args = escapeMsysCmdSwitches(args) + } const shNodePath = normalizePathEnvVar(opts.nodePath).posix if (!shProg) { shProg = quotedPathToTarget