Skip to content

Commit 52b894f

Browse files
committed
Improve spawn Windows shell mode documentation
Add comprehensive documentation explaining how Windows cmd.exe resolves commands using PATH and PATHEXT when shell: true is used with .cmd/.bat/.ps1 files. Clarifies the basename extraction approach and its consistency with industry standard tools like npm's promise-spawn and cross-spawn.
1 parent 1c348cc commit 52b894f

1 file changed

Lines changed: 39 additions & 25 deletions

File tree

registry/src/lib/spawn.ts

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -307,12 +307,30 @@ export function spawn(
307307
options?: SpawnOptions | undefined,
308308
extra?: SpawnExtra | undefined,
309309
): SpawnResult {
310-
// On Windows with shell: true, use just the command name, not the full path.
311-
// When shell: true is used, Windows cmd.exe has issues executing full paths to
312-
// .cmd/.bat files (e.g., 'C:\...\pnpm.cmd'), often resulting in ENOENT errors.
313-
// Using just the command name (e.g., 'pnpm') allows cmd.exe to find it via PATH.
310+
// Windows cmd.exe command resolution for .cmd/.bat/.ps1 files:
311+
//
312+
// When shell: true is used on Windows with script files (.cmd, .bat, .ps1),
313+
// cmd.exe can have issues executing full paths. The solution is to use just
314+
// the command basename without extension and let cmd.exe find it via PATH.
315+
//
316+
// How cmd.exe resolves commands:
317+
// 1. Searches current directory first
318+
// 2. Then searches each directory in PATH environment variable
319+
// 3. For each directory, tries extensions from PATHEXT (.COM, .EXE, .BAT, .CMD, etc.)
320+
// 4. Executes the first match found
321+
//
322+
// Example: Given 'C:\pnpm\pnpm.cmd' with shell: true
323+
// 1. Extract basename without extension: 'pnpm'
324+
// 2. cmd.exe searches PATH directories for 'pnpm'
325+
// 3. PATHEXT causes it to try 'pnpm.com', 'pnpm.exe', 'pnpm.bat', 'pnpm.cmd', etc.
326+
// 4. Finds and executes 'C:\pnpm\pnpm.cmd'
327+
//
328+
// This approach is consistent with how other tools handle Windows execution:
329+
// - npm's promise-spawn: uses which.sync() to find commands in PATH
330+
// - cross-spawn: spawns cmd.exe with escaped arguments
331+
// - execa: uses cross-spawn under the hood for Windows support
332+
//
314333
// See: https://github.com/nodejs/node/issues/3675
315-
// Check for .cmd, .bat, .ps1 extensions that indicate a Windows script.
316334
const shell = getOwn(options, 'shell')
317335
const WIN32 = /*@__PURE__*/ require('./constants/WIN32.js')
318336
if (WIN32 && shell && windowsScriptExtRegExp.test(cmd)) {
@@ -337,30 +355,30 @@ export function spawn(
337355
spinnerInstance.stop()
338356
}
339357
const npmCliPromiseSpawn = getNpmcliPromiseSpawn()
340-
const promiseSpawnOptions: PromiseSpawnOptions = {
341-
signal: abortSignal,
358+
// Use __proto__: null to prevent prototype pollution when passing to
359+
// third-party code, Node.js built-ins, or JavaScript built-in methods.
360+
// https://github.com/npm/promise-spawn
361+
// https://github.com/nodejs/node/blob/v24.0.1/lib/child_process.js#L674-L678
362+
const promiseSpawnOpts = {
363+
__proto__: null,
342364
cwd: typeof spawnOptions.cwd === 'string' ? spawnOptions.cwd : undefined,
365+
env: {
366+
__proto__: null,
367+
...process.env,
368+
...env,
369+
} as unknown as NodeJS.ProcessEnv,
370+
signal: abortSignal,
343371
stdio: spawnOptions.stdio,
344372
stdioString,
345373
shell: spawnOptions.shell,
346374
timeout: spawnOptions.timeout,
347375
uid: spawnOptions.uid,
348376
gid: spawnOptions.gid,
349-
// Node includes inherited properties of options.env when it normalizes
350-
// it due to backwards compatibility. However, this is a prototype sink and
351-
// undesired behavior so to prevent it we spread options.env onto a fresh
352-
// object with a null [[Prototype]].
353-
// https://github.com/nodejs/node/blob/v24.0.1/lib/child_process.js#L674-L678
354-
env: Object.assign(
355-
Object.create(null),
356-
process.env,
357-
env,
358-
) as NodeJS.ProcessEnv,
359-
}
377+
} as unknown as PromiseSpawnOptions
360378
const spawnPromise = npmCliPromiseSpawn(
361379
cmd,
362380
args ? [...args] : [],
363-
promiseSpawnOptions,
381+
promiseSpawnOpts,
364382
extra,
365383
)
366384
const oldSpawnPromise = spawnPromise
@@ -408,12 +426,8 @@ export function spawnSync(
408426
args?: string[] | readonly string[],
409427
options?: SpawnSyncOptions | undefined,
410428
): SpawnSyncReturns<string | Buffer> {
411-
// On Windows with shell: true, use just the command name, not the full path.
412-
// When shell: true is used, Windows cmd.exe has issues executing full paths to
413-
// .cmd/.bat files (e.g., 'C:\...\pnpm.cmd'), often resulting in ENOENT errors.
414-
// Using just the command name (e.g., 'pnpm') allows cmd.exe to find it via PATH.
415-
// See: https://github.com/nodejs/node/issues/3675
416-
// Check for .cmd, .bat, .ps1 extensions that indicate a Windows script.
429+
// Windows cmd.exe command resolution for .cmd/.bat/.ps1 files:
430+
// See spawn() function above for detailed explanation of this approach.
417431
const shell = getOwn(options, 'shell')
418432
const WIN32 = /*@__PURE__*/ require('./constants/WIN32.js')
419433
if (WIN32 && shell && windowsScriptExtRegExp.test(cmd)) {

0 commit comments

Comments
 (0)