diff --git a/src/index.ts b/src/index.ts index ce7280b..0c26a52 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,17 @@ function generateShShim (src: string, to: string, opts: InternalOptions): string let shLongProg shTarget = shTarget.split('\\').join('/') const quotedPathToTarget = path.isAbsolute(shTarget) ? `"${shTarget}"` : `"$basedir/${shTarget}"` + // 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 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' + ) + }) +}) 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 `;