From baeb90afa254b0d8a3a80c95e5883da021d2f6d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 18:18:38 +0000 Subject: [PATCH] fix: preserve process.cwd() after SETLOCAL in cmd shims The goto-undefined launch line exits batch context so leftover SETLOCAL frames (including a parent setlocal + cd) restore the pre-cd directory before Node starts. Re-apply %CD% after that exit; it is expanded on the same line before endLocal runs. Fixes #69 Co-authored-by: David --- lib/index.js | 3 + tap-snapshots/test/basic.js.test.cjs | 14 +- test/setlocal-cwd.js | 193 +++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 test/setlocal-cwd.js diff --git a/lib/index.js b/lib/index.js index 1b6d1d8..97bd342 100644 --- a/lib/index.js +++ b/lib/index.js @@ -114,6 +114,9 @@ const writeShim_ = (from, to, prog, args, variables) => { // prevent "Terminate Batch Job? (Y/n)" message // https://github.com/npm/cli/issues/969#issuecomment-737496588 + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & ' + // Re-cd: SETLOCAL/batch-exit restore cwd, which undoes a parent `cd` + // after `setlocal` (https://github.com/npm/cmd-shim/issues/69) + + 'cd /d "%CD%" & ' + `set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" ${args} ${target} %*\r\n` } else { cmd = `${head}${prog} ${args} ${target} %*\r\n` diff --git a/tap-snapshots/test/basic.js.test.cjs b/tap-snapshots/test/basic.js.test.cjs index 901a363..bd6440e 100644 --- a/tap-snapshots/test/basic.js.test.cjs +++ b/tap-snapshots/test/basic.js.test.cjs @@ -21,7 +21,7 @@ IF EXIST "%dp0%\\node.exe" (\\r SET "_prog=node"\\r )\\r \\r -endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" "%dp0%\\from.env" %*\\r +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & cd /d "%CD%" & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" "%dp0%\\from.env" %*\\r ` @@ -110,7 +110,7 @@ IF EXIST "%dp0%\\node.exe" (\\r SET "_prog=node"\\r )\\r \\r -endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" --expose_gc "%dp0%\\from.env.args" %*\\r +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & cd /d "%CD%" & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" --expose_gc "%dp0%\\from.env.args" %*\\r ` @@ -200,7 +200,7 @@ IF EXIST "%dp0%\\node.exe" (\\r SET "_prog=node"\\r )\\r \\r -endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" "%dp0%\\from.env.variables" %*\\r +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & cd /d "%CD%" & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" "%dp0%\\from.env.variables" %*\\r ` @@ -289,7 +289,7 @@ IF EXIST "%dp0%\\/usr/bin/sh.exe" (\\r SET "_prog=/usr/bin/sh"\\r )\\r \\r -endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" "%dp0%\\from.sh" %*\\r +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & cd /d "%CD%" & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" "%dp0%\\from.sh" %*\\r ` @@ -378,7 +378,7 @@ IF EXIST "%dp0%\\/usr/bin/sh.exe" (\\r SET "_prog=/usr/bin/sh"\\r )\\r \\r -endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" -x "%dp0%\\from.sh.args" %*\\r +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & cd /d "%CD%" & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" -x "%dp0%\\from.sh.args" %*\\r ` @@ -587,7 +587,7 @@ IF EXIST "%dp0%\\node.exe" (\\r SET "_prog=node"\\r )\\r \\r -endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" --flag-one --flag-two "%dp0%\\from.env.multiple.variables" %*\\r +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & cd /d "%CD%" & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" --flag-one --flag-two "%dp0%\\from.env.multiple.variables" %*\\r ` @@ -735,7 +735,7 @@ IF EXIST "%dp0%\\node.exe" (\\r SET "_prog=node"\\r )\\r \\r -endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" --expose_gc "%dp0%\\from.env.S" %*\\r +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & cd /d "%CD%" & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" --expose_gc "%dp0%\\from.env.S" %*\\r ` diff --git a/test/setlocal-cwd.js b/test/setlocal-cwd.js new file mode 100644 index 0000000..3365bc9 --- /dev/null +++ b/test/setlocal-cwd.js @@ -0,0 +1,193 @@ +'use strict' + +// https://github.com/npm/cmd-shim/issues/69 +// +// On Windows, SETLOCAL saves cwd as well as the environment. The shebang +// shim then does `endLocal & goto #_undefined_#` so Node is not a child of +// the batch file (no "Terminate Batch Job?"). That goto exits batch context +// and implied-ENDLOCALs remaining frames — including a parent +// `setlocal` + `cd` — so process.cwd() is the pre-cd directory. +// +// cmd.exe expands %CD% for the whole line before endLocal runs, so +// `cd /d "%CD%"` after the goto re-applies the directory Node should see. + +const test = require('tap').test +const fs = require('fs') +const os = require('os') +const path = require('path') +const { spawnSync } = require('child_process') +const cmdShim = require('..') + +const fixtures = path.resolve(__dirname, 'fixtures') + +// Issue #69 reporter's launch line (no cwd restore). +const ISSUE69_LAUNCH = + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\aaaa.js" %*' + +const launchLine = (cmdText) => { + const line = cmdText.split(/\r?\n/).find((l) => /^endLocal\b/i.test(l)) + return line ? line.replace(/\r$/, '') : null +} + +// Percent-expand one cmd.exe line (parse-before-execute). +const expandPercents = (line, cwd) => + line.replace(/%([^%]+)%/g, (match, name) => { + if (name.toUpperCase() === 'CD') { + return cwd + } + return match + }) + +// Issue pattern: +// test.bat: setlocal / cd / test.cmd (no CALL) +// test.cmd: SETLOCAL / CALL :find_dp0 / +// goto #_undefined_# leaves batch context; leftover SETLOCAL frames pop. +const cwdAtNodeLaunch = (rawLaunchLine, initialCwd, desiredCwd) => { + const stack = [] + let cwd = initialCwd + + // parent test.bat + stack.push(cwd) + cwd = desiredCwd + + // shim SETLOCAL (find_dp0 does not cd) + stack.push(cwd) + + const expanded = expandPercents(rawLaunchLine, cwd) + + const commands = expanded + .split(/\s*(?:\|\||&)\s*/) + .map((c) => c.trim()) + .filter(Boolean) + + for (const command of commands) { + if (/^endLocal\b/i.test(command)) { + if (stack.length) { + cwd = stack.pop() + } + continue + } + if (/^goto\s+#/.test(command)) { + while (stack.length) { + cwd = stack.pop() + } + continue + } + const cd = command.match(/^cd\s+\/d\s+"([^"]*)"$/i) || + command.match(/^cd\s+\/d\s+(\S+)/i) + if (cd) { + cwd = cd[1] + } + } + + return cwd +} + +test('issue #69: endLocal+goto without re-cd loses a parent setlocal+cd', (t) => { + t.equal( + cwdAtNodeLaunch(ISSUE69_LAUNCH, 'C:\\Users\\reporter', 'C:\\abcdefg'), + 'C:\\Users\\reporter', + 'Node sees the directory from before the parent cd' + ) + t.end() +}) + +test('issue #69: re-cd after goto keeps the parent cd', (t) => { + const fixed = ISSUE69_LAUNCH.replace( + '|| title %COMSPEC% & ', + '|| title %COMSPEC% & cd /d "%CD%" & ' + ) + t.equal( + cwdAtNodeLaunch(fixed, 'C:\\Users\\reporter', 'C:\\abcdefg'), + 'C:\\abcdefg' + ) + t.end() +}) + +test('shebang cmd shim re-cds after endLocal+goto', async (t) => { + const from = path.resolve(fixtures, 'from.env') + const to = path.resolve(fixtures, 'setlocal-cwd.shim') + await cmdShim(from, to) + const cmd = fs.readFileSync(`${to}.cmd`, 'utf8') + const launch = launchLine(cmd) + + t.match( + cmd, + /SETLOCAL/, + 'still uses SETLOCAL (find_dp0 / _prog isolation)' + ) + t.match( + cmd, + /endLocal & goto #_undefined_# 2>NUL \|\| title %COMSPEC% & cd \/d "%CD%" & /, + 'restores cwd on the same line, after the batch-exit goto' + ) + t.ok(launch, 'has the endLocal launch line') + t.match(launch, /cd \/d "%CD%"/) + t.notMatch( + launch, + /cd \/d "%CD%" & goto /, + 'cd must run after goto, or implied ENDLOCAL undoes it' + ) + t.equal( + cwdAtNodeLaunch(launch, 'C:\\Users\\reporter', 'C:\\abcdefg'), + 'C:\\abcdefg', + 'generated launch line keeps the issue #69 parent cd' + ) +}) + +test('no-shebang cmd shim does not use the endLocal goto launch', async (t) => { + const from = path.resolve(fixtures, 'from.exe') + const to = path.resolve(fixtures, 'setlocal-cwd-exe.shim') + await cmdShim(from, to) + const cmd = fs.readFileSync(`${to}.cmd`, 'utf8') + t.equal(launchLine(cmd), null) + t.notMatch(cmd, /cd \/d "%CD%"/) +}) + +test('Windows: parent setlocal+cd is process.cwd() through the shim', { + skip: process.platform !== 'win32' ? 'cmd.exe reproduction is Windows-only' : false, +}, async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cmd-shim-cwd-')) + t.teardown(() => fs.rmSync(root, { recursive: true, force: true })) + + const desired = path.join(root, 'abcdefg') + fs.mkdirSync(desired) + + const cwdOut = path.join(root, 'cwd.txt') + const from = path.join(root, 'aaaa.js') + fs.writeFileSync( + from, + '#!/usr/bin/env node\n' + + 'require(\'fs\').writeFileSync(' + + JSON.stringify(cwdOut) + + ', process.cwd())\n' + ) + + const to = path.join(root, 'test') + await cmdShim(from, to) + + const parentBat = path.join(root, 'test.bat') + fs.writeFileSync( + parentBat, + [ + '@echo off', + 'setlocal', + `cd /d "${desired}"`, + `"${to}.cmd"`, + 'endlocal', + '', + ].join('\r\n') + ) + + const result = spawnSync(process.env.COMSPEC || 'cmd.exe', ['/c', parentBat], { + cwd: root, + encoding: 'utf8', + windowsHide: true, + }) + t.equal(result.status, 0, result.stderr || result.stdout) + t.ok(fs.existsSync(cwdOut), 'aaaa.js wrote process.cwd()') + t.equal( + path.resolve(fs.readFileSync(cwdOut, 'utf8')), + path.resolve(desired) + ) +})