From d7af66a6bf0d5bc829790d6e46c4924677c4325b Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Wed, 18 Mar 2026 06:59:47 +0700 Subject: [PATCH 01/23] refactor: add parse.ts --- src/parse.ts | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/parse.ts diff --git a/src/parse.ts b/src/parse.ts new file mode 100644 index 0000000..92e4fc3 --- /dev/null +++ b/src/parse.ts @@ -0,0 +1,149 @@ +import {type SpawnOptions} from 'node:child_process'; +import {closeSync, openSync, readSync, statSync} from 'node:fs'; +import {delimiter, normalize, resolve, sep} from 'node:path'; + +const isExecutableRegExp = /\.(?:com|exe)$/i; +const isCmdShimRegExp = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i; +// From https://github.com/sindresorhus/shebang-regex (MIT) +const shebangRegex = /^#!(.*)/; +// See http://www.robvanderwoude.com/escapechars.php +const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; + +export interface CrossParseResult { + command: string; + args: string[]; + options: SpawnOptions +} + +// From https://github.com/moxystudio/node-cross-spawn (MIT) +export function parse(command: string, args: string[] = [], options: SpawnOptions = {}): CrossParseResult { + // Build our parsed object + const parsed: CrossParseResult = { + command, + args: [...args], + options: { ...options }, + }; + + // Early return if use `shell` option or not on Windows. + if (parsed.options.shell === true || process.platform !== 'win32') { + return parsed; + } + + parsed.options.env ??= process.env; + parsed.options.cwd ??= process.cwd(); + + // Detect & add support for shebangs + let file = resolveCommand(parsed.command, parsed.options); + let shebang: string | null = null; + + if (file !== null) { + // Read the first 150 bytes from the file + const size = 150; + const buffer = Buffer.alloc(size); + + try { + const fd = openSync(file, 'r'); + readSync(fd, buffer, 0, size, 0); + closeSync(fd); + } catch {} + + // From https://github.com/kevva/shebang-command (MIT) + const match = buffer.toString().match(shebangRegex); + + if (match !== null) { + const [path, argument] = match[0].replace(/#! ?/, '').split(' '); + const binary = path.split('/').pop(); + + shebang = binary === 'env' ? argument : binary; + } + } + + if (shebang !== null) { + parsed.args.unshift(file); + parsed.command = shebang; + + file = resolveCommand(parsed.command, parsed.options); + } + + // We don't need a shell if the command filename is an executable + if (!isExecutableRegExp.test(file)) { + // Need to double escape meta chars if the command is a cmd-shim located in `node_modules/.bin/` + // The cmd-shim simply calls execute the package bin file with NodeJS, proxying any argument + // Because the escape of metachars with ^ gets interpreted when the cmd.exe is first called, + // we need to double escape them + const needsDoubleEscapeMetaChars = isCmdShimRegExp.test(file); + + // Normalize posix paths into OS compatible paths (e.g.: foo/bar -> foo\bar) + // This is necessary otherwise it will always fail with ENOENT in those cases + parsed.command = normalize(parsed.command); + + // Escape command & arguments + parsed.command = parsed.command.replace(metaCharsRegExp, '^$1'); + parsed.args = parsed.args.map((arg) => { + // Algorithm below is based on https://qntm.org/cmd + // It's slightly altered to disable JS backtracking to avoid hanging on specially crafted input + // Please see https://github.com/moxystudio/node-cross-spawn/pull/160 for more information + + // Sequence of backslashes followed by a double quote: + // double up all the backslashes and escape the double quote + arg = arg.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"'); + + // Sequence of backslashes followed by the end of the string + // (which will become a double quote later): + // double up all the backslashes + arg = arg.replace(/(?=(\\+?)?)\1$/, '$1$1'); + + // All other backslashes occur literally + + // Quote the whole thing: + arg = `"${arg}"`; + + // Escape meta chars + arg = arg.replace(metaCharsRegExp, '^$1'); + + // Double escape meta chars if necessary + if (needsDoubleEscapeMetaChars) { + arg = arg.replace(metaCharsRegExp, '^$1'); + } + + return arg; + }); + + parsed.args = ['/d', '/s', '/c', `"${[parsed.command, ...parsed.args].join(' ')}"`]; + parsed.command = parsed.options.env.comspec ?? 'cmd.exe'; + parsed.options.windowsVerbatimArguments = true; // Tell node's spawn that the arguments are already escaped + } + + return parsed; +}; + +// From https://github.com/npm/node-which (ISC), Windows part only. +function resolveCommand(command: string, options: SpawnOptions): string | null { + const { cwd, env } = options; + + const PATH = env.Path ?? env.PATH; + const PATHEXT = env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM'; + + const pathEnv = command.includes(sep) ? [''] : [cwd, ...PATH.split(delimiter)]; + const pathExt = PATHEXT.split(delimiter); + + if (command.includes('.') && pathExt[0] !== '') { + pathExt.unshift(''); + } + + for (const pe of pathEnv) { + const dest = resolve(pe, command); + + for (const ext of pathExt) { + const destWithExt = dest + ext; + + try { + if (statSync(destWithExt).isFile()) { + return resolve(cwd, destWithExt); + } + } catch {} + } + } + + return null; +}; From a0a0a33fb1206e8fa5a9c0f2556107305aa68e21 Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Wed, 18 Mar 2026 09:44:45 +0700 Subject: [PATCH 02/23] chore: inline some regexes --- src/parse.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index 92e4fc3..bc4d403 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -2,10 +2,6 @@ import {type SpawnOptions} from 'node:child_process'; import {closeSync, openSync, readSync, statSync} from 'node:fs'; import {delimiter, normalize, resolve, sep} from 'node:path'; -const isExecutableRegExp = /\.(?:com|exe)$/i; -const isCmdShimRegExp = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i; -// From https://github.com/sindresorhus/shebang-regex (MIT) -const shebangRegex = /^#!(.*)/; // See http://www.robvanderwoude.com/escapechars.php const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; @@ -48,7 +44,7 @@ export function parse(command: string, args: string[] = [], options: SpawnOption } catch {} // From https://github.com/kevva/shebang-command (MIT) - const match = buffer.toString().match(shebangRegex); + const match = buffer.toString().match(/^#!(.*)/); if (match !== null) { const [path, argument] = match[0].replace(/#! ?/, '').split(' '); @@ -66,12 +62,12 @@ export function parse(command: string, args: string[] = [], options: SpawnOption } // We don't need a shell if the command filename is an executable - if (!isExecutableRegExp.test(file)) { + if (!/\.(?:com|exe)$/i.test(file)) { // Need to double escape meta chars if the command is a cmd-shim located in `node_modules/.bin/` // The cmd-shim simply calls execute the package bin file with NodeJS, proxying any argument // Because the escape of metachars with ^ gets interpreted when the cmd.exe is first called, // we need to double escape them - const needsDoubleEscapeMetaChars = isCmdShimRegExp.test(file); + const needsDoubleEscapeMetaChars = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(file); // Normalize posix paths into OS compatible paths (e.g.: foo/bar -> foo\bar) // This is necessary otherwise it will always fail with ENOENT in those cases From e6d189ce2037b5a56e431f9f5b0e1a25cd519167 Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Wed, 18 Mar 2026 10:58:57 +0700 Subject: [PATCH 03/23] chore: don't use destruction --- src/parse.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index bc4d403..31bb97e 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -115,20 +115,18 @@ export function parse(command: string, args: string[] = [], options: SpawnOption // From https://github.com/npm/node-which (ISC), Windows part only. function resolveCommand(command: string, options: SpawnOptions): string | null { - const { cwd, env } = options; + const PATH = options.env.Path ?? options.env.PATH; + const PATHEXT = options.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM'; - const PATH = env.Path ?? env.PATH; - const PATHEXT = env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM'; - - const pathEnv = command.includes(sep) ? [''] : [cwd, ...PATH.split(delimiter)]; + const pathEnv = command.includes(sep) ? [''] : [options.cwd, ...PATH.split(delimiter)]; const pathExt = PATHEXT.split(delimiter); if (command.includes('.') && pathExt[0] !== '') { pathExt.unshift(''); } - for (const pe of pathEnv) { - const dest = resolve(pe, command); + for (const path of pathEnv) { + const dest = resolve(path, command); for (const ext of pathExt) { const destWithExt = dest + ext; From ad782b32e0e8cc6b9c6d6d0abed6f679d5a50ab7 Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Wed, 18 Mar 2026 11:04:32 +0700 Subject: [PATCH 04/23] chore: use better named imports --- src/parse.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index 31bb97e..c096ad7 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -1,6 +1,6 @@ import {type SpawnOptions} from 'node:child_process'; import {closeSync, openSync, readSync, statSync} from 'node:fs'; -import {delimiter, normalize, resolve, sep} from 'node:path'; +import {delimiter as pathDelimiter, normalize as normalizePath, resolve as resolvePath, sep as pathSeparator} from 'node:path'; // See http://www.robvanderwoude.com/escapechars.php const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; @@ -71,7 +71,7 @@ export function parse(command: string, args: string[] = [], options: SpawnOption // Normalize posix paths into OS compatible paths (e.g.: foo/bar -> foo\bar) // This is necessary otherwise it will always fail with ENOENT in those cases - parsed.command = normalize(parsed.command); + parsed.command = normalizePath(parsed.command); // Escape command & arguments parsed.command = parsed.command.replace(metaCharsRegExp, '^$1'); @@ -118,22 +118,22 @@ function resolveCommand(command: string, options: SpawnOptions): string | null { const PATH = options.env.Path ?? options.env.PATH; const PATHEXT = options.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM'; - const pathEnv = command.includes(sep) ? [''] : [options.cwd, ...PATH.split(delimiter)]; - const pathExt = PATHEXT.split(delimiter); + const pathEnv = command.includes(pathSeparator) ? [''] : [options.cwd, ...PATH.split(pathDelimiter)]; + const pathExt = PATHEXT.split(pathDelimiter); if (command.includes('.') && pathExt[0] !== '') { pathExt.unshift(''); } for (const path of pathEnv) { - const dest = resolve(path, command); + const dest = resolvePath(path, command); for (const ext of pathExt) { const destWithExt = dest + ext; try { if (statSync(destWithExt).isFile()) { - return resolve(cwd, destWithExt); + return resolvePath(cwd, destWithExt); } } catch {} } From 62771d3fb624b34ae340fe1ec3664a746d5d70f3 Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Wed, 18 Mar 2026 13:20:52 +0700 Subject: [PATCH 05/23] docs: say if using sync version --- src/parse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parse.ts b/src/parse.ts index c096ad7..6e166db 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -113,7 +113,7 @@ export function parse(command: string, args: string[] = [], options: SpawnOption return parsed; }; -// From https://github.com/npm/node-which (ISC), Windows part only. +// From https://github.com/npm/node-which (ISC), Windows part only and sync version. function resolveCommand(command: string, options: SpawnOptions): string | null { const PATH = options.env.Path ?? options.env.PATH; const PATHEXT = options.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM'; From f3e60a409ca75c866ee1e6e19d67032eddea11b1 Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Thu, 19 Mar 2026 05:45:43 +0700 Subject: [PATCH 06/23] chore: pass parsed object --- src/parse.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index 6e166db..c263ab2 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -29,7 +29,7 @@ export function parse(command: string, args: string[] = [], options: SpawnOption parsed.options.cwd ??= process.cwd(); // Detect & add support for shebangs - let file = resolveCommand(parsed.command, parsed.options); + let file = resolveCommand(parsed); let shebang: string | null = null; if (file !== null) { @@ -58,7 +58,7 @@ export function parse(command: string, args: string[] = [], options: SpawnOption parsed.args.unshift(file); parsed.command = shebang; - file = resolveCommand(parsed.command, parsed.options); + file = resolveCommand(parsed); } // We don't need a shell if the command filename is an executable @@ -114,7 +114,8 @@ export function parse(command: string, args: string[] = [], options: SpawnOption }; // From https://github.com/npm/node-which (ISC), Windows part only and sync version. -function resolveCommand(command: string, options: SpawnOptions): string | null { +function resolveCommand(parsed: CrossParseResult): string | null { + const { command, options } = parsed; const PATH = options.env.Path ?? options.env.PATH; const PATHEXT = options.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM'; From 694b8905bedb5d247e97f478b14712364a8eec2d Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Thu, 19 Mar 2026 15:40:48 +0700 Subject: [PATCH 07/23] style: format --- src/parse.ts | 253 +++++++++++++++++++++++++++------------------------ 1 file changed, 135 insertions(+), 118 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index c263ab2..4785f8c 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -1,6 +1,11 @@ import {type SpawnOptions} from 'node:child_process'; import {closeSync, openSync, readSync, statSync} from 'node:fs'; -import {delimiter as pathDelimiter, normalize as normalizePath, resolve as resolvePath, sep as pathSeparator} from 'node:path'; +import { + delimiter as pathDelimiter, + normalize as normalizePath, + resolve as resolvePath, + sep as pathSeparator +} from 'node:path'; // See http://www.robvanderwoude.com/escapechars.php const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; @@ -8,137 +13,149 @@ const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; export interface CrossParseResult { command: string; args: string[]; - options: SpawnOptions + options: SpawnOptions; } // From https://github.com/moxystudio/node-cross-spawn (MIT) -export function parse(command: string, args: string[] = [], options: SpawnOptions = {}): CrossParseResult { - // Build our parsed object - const parsed: CrossParseResult = { - command, - args: [...args], - options: { ...options }, - }; - - // Early return if use `shell` option or not on Windows. - if (parsed.options.shell === true || process.platform !== 'win32') { - return parsed; - } - - parsed.options.env ??= process.env; - parsed.options.cwd ??= process.cwd(); +export function parse( + command: string, + args: string[] = [], + options: SpawnOptions = {} +): CrossParseResult { + // Build our parsed object + const parsed: CrossParseResult = { + command, + args: [...args], + options: {...options} + }; + + // Early return if use `shell` option or not on Windows. + if (parsed.options.shell === true || process.platform !== 'win32') { + return parsed; + } + + parsed.options.env ??= process.env; + parsed.options.cwd ??= process.cwd(); // Detect & add support for shebangs - let file = resolveCommand(parsed); - let shebang: string | null = null; + let file = resolveCommand(parsed); + let shebang: string | null = null; - if (file !== null) { - // Read the first 150 bytes from the file - const size = 150; - const buffer = Buffer.alloc(size); + if (file !== null) { + // Read the first 150 bytes from the file + const size = 150; + const buffer = Buffer.alloc(size); - try { - const fd = openSync(file, 'r'); - readSync(fd, buffer, 0, size, 0); - closeSync(fd); - } catch {} + try { + const fd = openSync(file, 'r'); + readSync(fd, buffer, 0, size, 0); + closeSync(fd); + } catch {} - // From https://github.com/kevva/shebang-command (MIT) - const match = buffer.toString().match(/^#!(.*)/); + // From https://github.com/kevva/shebang-command (MIT) + const match = buffer.toString().match(/^#!(.*)/); - if (match !== null) { - const [path, argument] = match[0].replace(/#! ?/, '').split(' '); - const binary = path.split('/').pop(); + if (match !== null) { + const [path, argument] = match[0].replace(/#! ?/, '').split(' '); + const binary = path.split('/').pop(); - shebang = binary === 'env' ? argument : binary; - } - } + shebang = binary === 'env' ? argument : binary; + } + } - if (shebang !== null) { - parsed.args.unshift(file); - parsed.command = shebang; + if (shebang !== null) { + parsed.args.unshift(file); + parsed.command = shebang; - file = resolveCommand(parsed); - } + file = resolveCommand(parsed); + } - // We don't need a shell if the command filename is an executable + // We don't need a shell if the command filename is an executable if (!/\.(?:com|exe)$/i.test(file)) { - // Need to double escape meta chars if the command is a cmd-shim located in `node_modules/.bin/` - // The cmd-shim simply calls execute the package bin file with NodeJS, proxying any argument - // Because the escape of metachars with ^ gets interpreted when the cmd.exe is first called, - // we need to double escape them - const needsDoubleEscapeMetaChars = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(file); - - // Normalize posix paths into OS compatible paths (e.g.: foo/bar -> foo\bar) - // This is necessary otherwise it will always fail with ENOENT in those cases - parsed.command = normalizePath(parsed.command); - - // Escape command & arguments - parsed.command = parsed.command.replace(metaCharsRegExp, '^$1'); - parsed.args = parsed.args.map((arg) => { - // Algorithm below is based on https://qntm.org/cmd - // It's slightly altered to disable JS backtracking to avoid hanging on specially crafted input - // Please see https://github.com/moxystudio/node-cross-spawn/pull/160 for more information - - // Sequence of backslashes followed by a double quote: - // double up all the backslashes and escape the double quote - arg = arg.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"'); - - // Sequence of backslashes followed by the end of the string - // (which will become a double quote later): - // double up all the backslashes - arg = arg.replace(/(?=(\\+?)?)\1$/, '$1$1'); - - // All other backslashes occur literally - - // Quote the whole thing: - arg = `"${arg}"`; - - // Escape meta chars - arg = arg.replace(metaCharsRegExp, '^$1'); - - // Double escape meta chars if necessary - if (needsDoubleEscapeMetaChars) { - arg = arg.replace(metaCharsRegExp, '^$1'); - } - - return arg; - }); - - parsed.args = ['/d', '/s', '/c', `"${[parsed.command, ...parsed.args].join(' ')}"`]; - parsed.command = parsed.options.env.comspec ?? 'cmd.exe'; - parsed.options.windowsVerbatimArguments = true; // Tell node's spawn that the arguments are already escaped - } - - return parsed; -}; + // Need to double escape meta chars if the command is a cmd-shim located in `node_modules/.bin/` + // The cmd-shim simply calls execute the package bin file with NodeJS, proxying any argument + // Because the escape of metachars with ^ gets interpreted when the cmd.exe is first called, + // we need to double escape them + const needsDoubleEscapeMetaChars = + /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(file); + + // Normalize posix paths into OS compatible paths (e.g.: foo/bar -> foo\bar) + // This is necessary otherwise it will always fail with ENOENT in those cases + parsed.command = normalizePath(parsed.command); + + // Escape command & arguments + parsed.command = parsed.command.replace(metaCharsRegExp, '^$1'); + parsed.args = parsed.args.map((arg) => { + // Algorithm below is based on https://qntm.org/cmd + // It's slightly altered to disable JS backtracking to avoid hanging on specially crafted input + // Please see https://github.com/moxystudio/node-cross-spawn/pull/160 for more information + + // Sequence of backslashes followed by a double quote: + // double up all the backslashes and escape the double quote + arg = arg.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"'); + + // Sequence of backslashes followed by the end of the string + // (which will become a double quote later): + // double up all the backslashes + arg = arg.replace(/(?=(\\+?)?)\1$/, '$1$1'); + + // All other backslashes occur literally + + // Quote the whole thing: + arg = `"${arg}"`; + + // Escape meta chars + arg = arg.replace(metaCharsRegExp, '^$1'); + + // Double escape meta chars if necessary + if (needsDoubleEscapeMetaChars) { + arg = arg.replace(metaCharsRegExp, '^$1'); + } + + return arg; + }); + + parsed.args = [ + '/d', + '/s', + '/c', + `"${[parsed.command, ...parsed.args].join(' ')}"` + ]; + parsed.command = parsed.options.env.comspec ?? 'cmd.exe'; + parsed.options.windowsVerbatimArguments = true; // Tell node's spawn that the arguments are already escaped + } + + return parsed; +} // From https://github.com/npm/node-which (ISC), Windows part only and sync version. function resolveCommand(parsed: CrossParseResult): string | null { - const { command, options } = parsed; - const PATH = options.env.Path ?? options.env.PATH; - const PATHEXT = options.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM'; - - const pathEnv = command.includes(pathSeparator) ? [''] : [options.cwd, ...PATH.split(pathDelimiter)]; - const pathExt = PATHEXT.split(pathDelimiter); - - if (command.includes('.') && pathExt[0] !== '') { - pathExt.unshift(''); - } - - for (const path of pathEnv) { - const dest = resolvePath(path, command); - - for (const ext of pathExt) { - const destWithExt = dest + ext; - - try { - if (statSync(destWithExt).isFile()) { - return resolvePath(cwd, destWithExt); - } - } catch {} - } - } - - return null; -}; + const {command, options} = parsed; + const PATH = options.env.Path ?? options.env.PATH; + const PATHEXT = options.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM'; + + const pathEnv = command.includes(pathSeparator) + ? [''] + : [options.cwd, ...PATH.split(pathDelimiter)]; + const pathExt = PATHEXT.split(pathDelimiter); + + if (command.includes('.') && pathExt[0] !== '') { + pathExt.unshift(''); + } + + for (const path of pathEnv) { + const dest = resolvePath(path, command); + + for (const ext of pathExt) { + const destWithExt = dest + ext; + + try { + if (statSync(destWithExt).isFile()) { + return resolvePath(cwd, destWithExt); + } + } catch {} + } + } + + return null; +} From 9d5be90cd20876b84deece50b0453359b80b82f8 Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Thu, 19 Mar 2026 16:03:41 +0700 Subject: [PATCH 08/23] chore: handle quoted env paths --- src/parse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parse.ts b/src/parse.ts index 4785f8c..36e51d0 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -144,7 +144,7 @@ function resolveCommand(parsed: CrossParseResult): string | null { } for (const path of pathEnv) { - const dest = resolvePath(path, command); + const dest = resolvePath(path.replace(/^"(.*)"$/, "$1"), command); for (const ext of pathExt) { const destWithExt = dest + ext; From 4c5a8b0370d96a3f90244bae6f55b49a5ed5c7a6 Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Thu, 26 Mar 2026 17:11:44 +0700 Subject: [PATCH 09/23] style: format --- src/parse.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index 36e51d0..3443c27 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -50,7 +50,7 @@ export function parse( const fd = openSync(file, 'r'); readSync(fd, buffer, 0, size, 0); closeSync(fd); - } catch {} + } catch {} // eslint-disable-line no-empty // From https://github.com/kevva/shebang-command (MIT) const match = buffer.toString().match(/^#!(.*)/); @@ -144,7 +144,7 @@ function resolveCommand(parsed: CrossParseResult): string | null { } for (const path of pathEnv) { - const dest = resolvePath(path.replace(/^"(.*)"$/, "$1"), command); + const dest = resolvePath(path.replace(/^"(.*)"$/, '$1'), command); for (const ext of pathExt) { const destWithExt = dest + ext; @@ -153,7 +153,7 @@ function resolveCommand(parsed: CrossParseResult): string | null { if (statSync(destWithExt).isFile()) { return resolvePath(cwd, destWithExt); } - } catch {} + } catch {} // eslint-disable-line no-empty } } From 808ce030ccb75386e5869ee24280ad5dcacb123e Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Thu, 26 Mar 2026 17:58:07 +0700 Subject: [PATCH 10/23] chore: fix and change --- src/parse.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index 3443c27..19af5e7 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -34,7 +34,6 @@ export function parse( return parsed; } - parsed.options.env ??= process.env; parsed.options.cwd ??= process.cwd(); // Detect & add support for shebangs @@ -151,7 +150,7 @@ function resolveCommand(parsed: CrossParseResult): string | null { try { if (statSync(destWithExt).isFile()) { - return resolvePath(cwd, destWithExt); + return resolvePath(options.cwd, destWithExt); } } catch {} // eslint-disable-line no-empty } From 6b981a7df0e3622386d67354068781c5089a3d6c Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Thu, 26 Mar 2026 21:30:18 +0700 Subject: [PATCH 11/23] chore: use our parser --- src/main.ts | 6 +++--- src/parse.ts | 22 +++++++++++++--------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/main.ts b/src/main.ts index cee5246..7277a3b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,7 +11,7 @@ import {cwd as getCwd} from 'node:process'; import {computeEnv} from './env.js'; import {combineStreams} from './stream.js'; import readline from 'node:readline'; -import {_parse} from 'cross-spawn'; +import {parse} from './parse.js'; import {NonZeroExitError} from './non-zero-exit-error.js'; export {NonZeroExitError}; @@ -320,7 +320,7 @@ export class ExecProcess implements Result { const {command: normalisedCommand, args: normalisedArgs} = normaliseCommandAndArgs(this._command, this._args); - const crossResult = _parse(normalisedCommand, normalisedArgs, nodeOptions); + const crossResult = parse(normalisedCommand, normalisedArgs, nodeOptions); const handle = spawn( crossResult.command, @@ -394,7 +394,7 @@ export function xSync( const {command: normalisedCommand, args: normalisedArgs} = normaliseCommandAndArgs(command, args); - const crossResult = _parse(normalisedCommand, normalisedArgs, nodeOptions); + const crossResult = parse(normalisedCommand, normalisedArgs, nodeOptions); const spawnResult = spawnSync( crossResult.command, diff --git a/src/parse.ts b/src/parse.ts index 19af5e7..3cad2fe 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -10,7 +10,7 @@ import { // See http://www.robvanderwoude.com/escapechars.php const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; -export interface CrossParseResult { +interface CrossParseResult { command: string; args: string[]; options: SpawnOptions; @@ -58,11 +58,11 @@ export function parse( const [path, argument] = match[0].replace(/#! ?/, '').split(' '); const binary = path.split('/').pop(); - shebang = binary === 'env' ? argument : binary; + shebang = (binary === 'env' ? argument : binary) as string; } } - if (shebang !== null) { + if (shebang !== null && file !== null) { parsed.args.unshift(file); parsed.command = shebang; @@ -70,7 +70,7 @@ export function parse( } // We don't need a shell if the command filename is an executable - if (!/\.(?:com|exe)$/i.test(file)) { + if (file !== null && !/\.(?:com|exe)$/i.test(file)) { // Need to double escape meta chars if the command is a cmd-shim located in `node_modules/.bin/` // The cmd-shim simply calls execute the package bin file with NodeJS, proxying any argument // Because the escape of metachars with ^ gets interpreted when the cmd.exe is first called, @@ -120,7 +120,7 @@ export function parse( '/c', `"${[parsed.command, ...parsed.args].join(' ')}"` ]; - parsed.command = parsed.options.env.comspec ?? 'cmd.exe'; + parsed.command = parsed.options.env?.comspec ?? 'cmd.exe'; parsed.options.windowsVerbatimArguments = true; // Tell node's spawn that the arguments are already escaped } @@ -129,13 +129,17 @@ export function parse( // From https://github.com/npm/node-which (ISC), Windows part only and sync version. function resolveCommand(parsed: CrossParseResult): string | null { - const {command, options} = parsed; - const PATH = options.env.Path ?? options.env.PATH; + const {command, options} = parsed as { + command: string; + options: Required; + }; + const cwd = options.cwd.toString() as string; + const PATH = (options.env.Path ?? options.env.PATH) as string; const PATHEXT = options.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM'; const pathEnv = command.includes(pathSeparator) ? [''] - : [options.cwd, ...PATH.split(pathDelimiter)]; + : [cwd, ...PATH.split(pathDelimiter)]; const pathExt = PATHEXT.split(pathDelimiter); if (command.includes('.') && pathExt[0] !== '') { @@ -150,7 +154,7 @@ function resolveCommand(parsed: CrossParseResult): string | null { try { if (statSync(destWithExt).isFile()) { - return resolvePath(options.cwd, destWithExt); + return resolvePath(cwd, destWithExt); } } catch {} // eslint-disable-line no-empty } From 7f6f8942eb9ea7c80716f447320c8873b21b0ffd Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Fri, 27 Mar 2026 07:55:54 +0700 Subject: [PATCH 12/23] refactor: some type annotation & code vhange --- src/parse.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index 3cad2fe..5220978 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -6,6 +6,8 @@ import { resolve as resolvePath, sep as pathSeparator } from 'node:path'; +import {cwd as getCwd} from 'node:process'; +import {type EnvLike} from './env.js'; // See http://www.robvanderwoude.com/escapechars.php const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; @@ -34,8 +36,6 @@ export function parse( return parsed; } - parsed.options.cwd ??= process.cwd(); - // Detect & add support for shebangs let file = resolveCommand(parsed); let shebang: string | null = null; @@ -129,13 +129,12 @@ export function parse( // From https://github.com/npm/node-which (ISC), Windows part only and sync version. function resolveCommand(parsed: CrossParseResult): string | null { - const {command, options} = parsed as { - command: string; - options: Required; - }; - const cwd = options.cwd.toString() as string; - const PATH = (options.env.Path ?? options.env.PATH) as string; - const PATHEXT = options.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM'; + const {command, options} = parsed; + const cwd = (options.cwd ?? getCwd()).toString() as string; + const env = options.env as EnvLike; + + const PATH = (env.Path ?? env.PATH) as string; + const PATHEXT = env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM'; const pathEnv = command.includes(pathSeparator) ? [''] From 7d8f7dcb5a89a4cdb87764632bace35d59a66c27 Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Fri, 27 Mar 2026 08:11:05 +0700 Subject: [PATCH 13/23] test: add parse test --- src/test/parse_test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/test/parse_test.ts diff --git a/src/test/parse_test.ts b/src/test/parse_test.ts new file mode 100644 index 0000000..77003eb --- /dev/null +++ b/src/test/parse_test.ts @@ -0,0 +1,27 @@ +import {parse} from '../parse.js'; +import {describe, test, expect} from 'vitest'; +import os from 'node:os'; + +const isWindows = os.platform() === 'win32'; + +describe('parse', () => { + test('return from arguments if `shell` option is `true`', () => { + expect(parse('node', ['-v'], {shell: true})).toEqual({ + command: 'node', + args: ['-v'], + options: {shell: true} + }); + }); + + if (isWindows) { + // Add Windows tests later + } else { + test('return from arguments', () => { + expect(parse('node', ['-v'])).toEqual({ + command: 'node', + args: ['-v'], + options: {} + }); + }); + } +}); From 728e1dc3ffcd8ed9d4f868fc96fcadc9019dbcbb Mon Sep 17 00:00:00 2001 From: Hyper-Z11 Date: Sat, 28 Mar 2026 14:28:28 +0700 Subject: [PATCH 14/23] fix: use shell if command is not resolved too --- src/parse.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index 5220978..82347d3 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -69,13 +69,13 @@ export function parse( file = resolveCommand(parsed); } - // We don't need a shell if the command filename is an executable - if (file !== null && !/\.(?:com|exe)$/i.test(file)) { + // We don't need a shell if the command filename is an executable or not resolved + if (file === null || !/\.(?:com|exe)$/i.test(file)) { // Need to double escape meta chars if the command is a cmd-shim located in `node_modules/.bin/` // The cmd-shim simply calls execute the package bin file with NodeJS, proxying any argument // Because the escape of metachars with ^ gets interpreted when the cmd.exe is first called, // we need to double escape them - const needsDoubleEscapeMetaChars = + const needsDoubleEscapeMetaChars = file !== null && /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(file); // Normalize posix paths into OS compatible paths (e.g.: foo/bar -> foo\bar) From 9003fbe8bd4d9f1985bcb6158e371eb83df36bdb Mon Sep 17 00:00:00 2001 From: Hyper-Z11 Date: Sat, 28 Mar 2026 14:29:32 +0700 Subject: [PATCH 15/23] style: format --- src/parse.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index 82347d3..ef31338 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -75,8 +75,8 @@ export function parse( // The cmd-shim simply calls execute the package bin file with NodeJS, proxying any argument // Because the escape of metachars with ^ gets interpreted when the cmd.exe is first called, // we need to double escape them - const needsDoubleEscapeMetaChars = file !== null && - /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(file); + const needsDoubleEscapeMetaChars = + file !== null && /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(file); // Normalize posix paths into OS compatible paths (e.g.: foo/bar -> foo\bar) // This is necessary otherwise it will always fail with ENOENT in those cases From c7a7f89426b80a3e315418441932899e6fd5ae7f Mon Sep 17 00:00:00 2001 From: Hyper-Z11 Date: Sat, 28 Mar 2026 14:42:48 +0700 Subject: [PATCH 16/23] test: add Windows tests --- src/test/parse_test.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/test/parse_test.ts b/src/test/parse_test.ts index 77003eb..96120ad 100644 --- a/src/test/parse_test.ts +++ b/src/test/parse_test.ts @@ -14,7 +14,25 @@ describe('parse', () => { }); if (isWindows) { - // Add Windows tests later + // TODO: add more tests + const baseOptions = { + env: process.env + }; + + test('just return the same input if resolved', () => { + const parsed = parse('node', ['-v'], baseOptions); + + expect(parsed.command).toBe('node'); + expect(parsed.args).toEqual(['-v']); + }); + + test('use shell if command are not resolved/available', () => { + const parsed = parse('notexist', ['hi'], baseOptions); + + expect(parsed.command.endsWith('cmd.exe')).ok; + expect(parsed.args).toEqual(['/d', '/s', '/c', '"notexist ^"hi^""']); + expect(parsed.options.windowsVerbatimArguments).toBe(true); + }); } else { test('return from arguments', () => { expect(parse('node', ['-v'])).toEqual({ From d3e8e548d085f99f635f98606d67441e20d8a58e Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 31 Mar 2026 04:49:20 +0100 Subject: [PATCH 17/23] perf: slight optimisation in shebang splitting --- src/parse.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index ef31338..6dcf54e 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -11,6 +11,7 @@ import {type EnvLike} from './env.js'; // See http://www.robvanderwoude.com/escapechars.php const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; +const shebangRegExp = /^#!\s*(.+)$/; interface CrossParseResult { command: string; @@ -49,16 +50,25 @@ export function parse( const fd = openSync(file, 'r'); readSync(fd, buffer, 0, size, 0); closeSync(fd); - } catch {} // eslint-disable-line no-empty + } catch { + // do nothing, we'll just assume it's not a shebang + } - // From https://github.com/kevva/shebang-command (MIT) - const match = buffer.toString().match(/^#!(.*)/); + const match = buffer.toString().match(shebangRegExp); if (match !== null) { - const [path, argument] = match[0].replace(/#! ?/, '').split(' '); - const binary = path.split('/').pop(); - - shebang = (binary === 'env' ? argument : binary) as string; + const separatorIndex = match[1].indexOf(' '); + if (separatorIndex !== -1) { + const path = match[1].slice(0, separatorIndex); + const argument = match[1].slice(separatorIndex + 1); + const binarySeparatorIndex = path.lastIndexOf('/'); + const binary = + binarySeparatorIndex !== -1 + ? path.slice(binarySeparatorIndex + 1) + : path; + + shebang = binary === 'env' ? argument : binary; + } } } From e40004287b20eb56e9d50d189a61ec2489cad9a6 Mon Sep 17 00:00:00 2001 From: Hyper-Z11 <114817308+hyperz111@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:53:17 +0700 Subject: [PATCH 18/23] chore: fix typos --- src/parse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parse.ts b/src/parse.ts index 6dcf54e..af1d639 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -79,7 +79,7 @@ export function parse( file = resolveCommand(parsed); } - // We don't need a shell if the command filename is an executable or not resolved + // We don't need a shell if the command filename is resolved and an executable if (file === null || !/\.(?:com|exe)$/i.test(file)) { // Need to double escape meta chars if the command is a cmd-shim located in `node_modules/.bin/` // The cmd-shim simply calls execute the package bin file with NodeJS, proxying any argument From 153a956de714a8052ec422a255f41ed73cf6a7d5 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:35:06 +0100 Subject: [PATCH 19/23] chore: fix up --- src/main.ts | 18 ++---------------- src/test/parse_test.ts | 43 +++++++++++++++++++++--------------------- 2 files changed, 23 insertions(+), 38 deletions(-) diff --git a/src/main.ts b/src/main.ts index 08aeb5d..71d8baf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -312,14 +312,7 @@ export class ExecProcess implements Result { nodeOptions.env = computeEnv(cwd, nodeOptions.env); -<<<<<<< HEAD - const {command: normalisedCommand, args: normalisedArgs} = - normaliseCommandAndArgs(this._command, this._args); - - const crossResult = parse(normalisedCommand, normalisedArgs, nodeOptions); -======= - const crossResult = _parse(this._command, this._args, nodeOptions); ->>>>>>> main + const crossResult = parse(this._command, this._args, nodeOptions); const handle = spawn( crossResult.command, @@ -393,14 +386,7 @@ export function xSync( nodeOptions.env = computeEnv(cwd, nodeOptions.env); -<<<<<<< HEAD - const {command: normalisedCommand, args: normalisedArgs} = - normaliseCommandAndArgs(command, args); - - const crossResult = parse(normalisedCommand, normalisedArgs, nodeOptions); -======= - const crossResult = _parse(command, args ?? [], nodeOptions); ->>>>>>> main + const crossResult = parse(command, args ?? [], nodeOptions); const spawnResult = spawnSync( crossResult.command, diff --git a/src/test/parse_test.ts b/src/test/parse_test.ts index 96120ad..e2f72a9 100644 --- a/src/test/parse_test.ts +++ b/src/test/parse_test.ts @@ -3,6 +3,9 @@ import {describe, test, expect} from 'vitest'; import os from 'node:os'; const isWindows = os.platform() === 'win32'; +const baseWindowsOptions = { + env: process.env +}; describe('parse', () => { test('return from arguments if `shell` option is `true`', () => { @@ -13,33 +16,29 @@ describe('parse', () => { }); }); - if (isWindows) { - // TODO: add more tests - const baseOptions = { - env: process.env - }; + test.runIf(isWindows)('just return the same input if resolved', () => { + const parsed = parse('node', ['-v'], baseWindowsOptions); - test('just return the same input if resolved', () => { - const parsed = parse('node', ['-v'], baseOptions); - - expect(parsed.command).toBe('node'); - expect(parsed.args).toEqual(['-v']); - }); + expect(parsed.command).toBe('node'); + expect(parsed.args).toEqual(['-v']); + }); - test('use shell if command are not resolved/available', () => { - const parsed = parse('notexist', ['hi'], baseOptions); + test.runIf(isWindows)( + 'use shell if command are not resolved/available', + () => { + const parsed = parse('notexist', ['hi'], baseWindowsOptions); expect(parsed.command.endsWith('cmd.exe')).ok; expect(parsed.args).toEqual(['/d', '/s', '/c', '"notexist ^"hi^""']); expect(parsed.options.windowsVerbatimArguments).toBe(true); + } + ); + + test.runIf(!isWindows)('return from arguments', () => { + expect(parse('node', ['-v'])).toEqual({ + command: 'node', + args: ['-v'], + options: {} }); - } else { - test('return from arguments', () => { - expect(parse('node', ['-v'])).toEqual({ - command: 'node', - args: ['-v'], - options: {} - }); - }); - } + }); }); From 784b9d04692e85c9fdf0b2be7f87b4ae14b81f00 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:37:06 +0100 Subject: [PATCH 20/23] test: use describe block per os --- src/test/parse_test.ts | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/test/parse_test.ts b/src/test/parse_test.ts index e2f72a9..c5abf42 100644 --- a/src/test/parse_test.ts +++ b/src/test/parse_test.ts @@ -16,29 +16,30 @@ describe('parse', () => { }); }); - test.runIf(isWindows)('just return the same input if resolved', () => { - const parsed = parse('node', ['-v'], baseWindowsOptions); + describe.runIf(isWindows)('windows only', () => { + test('just return the same input if resolved', () => { + const parsed = parse('node', ['-v'], baseWindowsOptions); - expect(parsed.command).toBe('node'); - expect(parsed.args).toEqual(['-v']); - }); + expect(parsed.command).toBe('node'); + expect(parsed.args).toEqual(['-v']); + }); - test.runIf(isWindows)( - 'use shell if command are not resolved/available', - () => { + test('use shell if command are not resolved/available', () => { const parsed = parse('notexist', ['hi'], baseWindowsOptions); expect(parsed.command.endsWith('cmd.exe')).ok; expect(parsed.args).toEqual(['/d', '/s', '/c', '"notexist ^"hi^""']); expect(parsed.options.windowsVerbatimArguments).toBe(true); - } - ); + }); + }); - test.runIf(!isWindows)('return from arguments', () => { - expect(parse('node', ['-v'])).toEqual({ - command: 'node', - args: ['-v'], - options: {} + describe.runIf(!isWindows)('unix only', () => { + test('return from arguments', () => { + expect(parse('node', ['-v'])).toEqual({ + command: 'node', + args: ['-v'], + options: {} + }); }); }); }); From 636ef540c564505891bdcfe6bd5ece0c3387b7a1 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:03:13 +0100 Subject: [PATCH 21/23] fix: a few things Use cwd when resolving relative commands, move regexps to module scope, and handle spaceless shebangs. --- src/parse.ts | 69 +++++++++++++++++++++++++++++----------------------- 1 file changed, 39 insertions(+), 30 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index 7dd6967..c9cc9d3 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -3,19 +3,21 @@ import {closeSync, openSync, readSync, statSync} from 'node:fs'; import { delimiter as pathDelimiter, normalize as normalizePath, - resolve as resolvePath, - sep as pathSeparator + resolve as resolvePath } from 'node:path'; import {cwd as getCwd} from 'node:process'; -import {type EnvLike} from './env.js'; +import {getPathFromEnv, type EnvLike} from './env.js'; // See http://www.robvanderwoude.com/escapechars.php const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; const shebangRegExp = /^#!\s*(.+)$/; +const isWindowsExecutableRegExp = /\.(?:com|exe)$/i; +const isNodeModulesCmdRegExp = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i; +const isWindows = process.platform === 'win32'; interface CrossParseResult { command: string; - args: string[]; + args: readonly string[]; options: SpawnOptions; } @@ -28,12 +30,12 @@ export function parse( // Build our parsed object const parsed: CrossParseResult = { command, - args: [...args], + args, options: {...options} }; // Early return if use `shell` option or not on Windows. - if (parsed.options.shell === true || process.platform !== 'win32') { + if (parsed.options.shell === true || !isWindows) { return parsed; } @@ -57,36 +59,36 @@ export function parse( const match = buffer.toString().match(shebangRegExp); if (match !== null) { - const separatorIndex = match[1].indexOf(' '); - if (separatorIndex !== -1) { - const path = match[1].slice(0, separatorIndex); - const argument = match[1].slice(separatorIndex + 1); - const binarySeparatorIndex = path.lastIndexOf('/'); - const binary = - binarySeparatorIndex !== -1 - ? path.slice(binarySeparatorIndex + 1) - : path; - - shebang = binary === 'env' ? argument : binary; - } + const line = match[1].trim(); + const separatorIndex = line.indexOf(' '); + const path = separatorIndex !== -1 ? line.slice(0, separatorIndex) : line; + const argument = + separatorIndex !== -1 ? line.slice(separatorIndex + 1) : ''; + const binarySeparatorIndex = path.lastIndexOf('/'); + const binary = + binarySeparatorIndex !== -1 + ? path.slice(binarySeparatorIndex + 1) + : path; + + shebang = binary === 'env' ? argument || null : binary; } } if (shebang !== null && file !== null) { - parsed.args.unshift(file); + parsed.args = [file, ...parsed.args]; parsed.command = shebang; file = resolveCommand(parsed); } // We don't need a shell if the command filename is resolved and an executable - if (file === null || !/\.(?:com|exe)$/i.test(file)) { + if (file === null || !isWindowsExecutableRegExp.test(file)) { // Need to double escape meta chars if the command is a cmd-shim located in `node_modules/.bin/` // The cmd-shim simply calls execute the package bin file with NodeJS, proxying any argument // Because the escape of metachars with ^ gets interpreted when the cmd.exe is first called, // we need to double escape them const needsDoubleEscapeMetaChars = - file !== null && /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(file); + file !== null && isNodeModulesCmdRegExp.test(file); // Normalize posix paths into OS compatible paths (e.g.: foo/bar -> foo\bar) // This is necessary otherwise it will always fail with ENOENT in those cases @@ -140,15 +142,16 @@ export function parse( // From https://github.com/npm/node-which (ISC), Windows part only and sync version. function resolveCommand(parsed: CrossParseResult): string | null { const {command, options} = parsed; - const cwd = (options.cwd ?? getCwd()).toString() as string; - const env = options.env as EnvLike; + const cwd = (options.cwd ?? getCwd()).toString(); + const env = options.env ?? process.env; - const PATH = (env.Path ?? env.PATH) as string; + const PATH = getPathFromEnv(env).value; const PATHEXT = env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM'; - const pathEnv = command.includes(pathSeparator) - ? [''] - : [cwd, ...PATH.split(pathDelimiter)]; + const pathEnv = + command.includes('/') || command.includes('\\') + ? [''] + : [cwd, ...PATH.split(pathDelimiter)]; const pathExt = PATHEXT.split(pathDelimiter); if (command.includes('.') && pathExt[0] !== '') { @@ -156,16 +159,22 @@ function resolveCommand(parsed: CrossParseResult): string | null { } for (const path of pathEnv) { - const dest = resolvePath(path.replace(/^"(.*)"$/, '$1'), command); + const unquoted = + path.startsWith('"') && path.endsWith('"') && path.length > 1 + ? path.slice(1, -1) + : path; + const dest = resolvePath(cwd, unquoted, command); for (const ext of pathExt) { const destWithExt = dest + ext; try { if (statSync(destWithExt).isFile()) { - return resolvePath(cwd, destWithExt); + return destWithExt; } - } catch {} // eslint-disable-line no-empty + } catch { + // do nothing, it didn't exist + } } } From ad556764e3c971790a1e45436cec7ea6a7535cda Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sat, 2 May 2026 22:25:21 +0100 Subject: [PATCH 22/23] chore: remove unused import --- src/parse.ts | 2 +- tsconfig.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/parse.ts b/src/parse.ts index c9cc9d3..eb276fb 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -6,7 +6,7 @@ import { resolve as resolvePath } from 'node:path'; import {cwd as getCwd} from 'node:process'; -import {getPathFromEnv, type EnvLike} from './env.js'; +import {getPathFromEnv} from './env.js'; // See http://www.robvanderwoude.com/escapechars.php const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; diff --git a/tsconfig.json b/tsconfig.json index af1dc68..f906ede 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "types": ["node"], "declaration": true, "outDir": "./lib", + "rootDir": "./src", "importHelpers": false, "isolatedModules": true, "forceConsistentCasingInFileNames": true, From e77cc1891b8aac21ac63b899a8b1a394e67ce185 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sat, 2 May 2026 23:10:00 +0100 Subject: [PATCH 23/23] chore: rework and simplify some things --- THIRD_PARTY_LICENSES.txt | 23 ++++++++++ package.json | 3 +- src/main.ts | 12 +++-- src/{parse.ts => normalize.ts} | 84 +++++++++++++++++----------------- src/test/normalize_test.ts | 53 +++++++++++++++++++++ src/test/parse_test.ts | 45 ------------------ 6 files changed, 127 insertions(+), 93 deletions(-) create mode 100644 THIRD_PARTY_LICENSES.txt rename src/{parse.ts => normalize.ts} (72%) create mode 100644 src/test/normalize_test.ts delete mode 100644 src/test/parse_test.ts diff --git a/THIRD_PARTY_LICENSES.txt b/THIRD_PARTY_LICENSES.txt new file mode 100644 index 0000000..be276a3 --- /dev/null +++ b/THIRD_PARTY_LICENSES.txt @@ -0,0 +1,23 @@ +# cross-spawn + +The MIT License (MIT) + +Copyright (c) 2018 Made With MOXY Lda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/package.json b/package.json index b3cd83a..8df52d7 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "node": ">=18" }, "files": [ - "dist" + "dist", + "THIRD-PARTY-LICENSES.txt" ], "scripts": { "build": "tsdown", diff --git a/src/main.ts b/src/main.ts index 71d8baf..e4d1903 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,10 +10,10 @@ import {cwd as getCwd} from 'node:process'; import {computeEnv} from './env.js'; import {combineStreams} from './stream.js'; import readline from 'node:readline'; -import {parse} from './parse.js'; +import {normalizeSpawnCommand} from './normalize.js'; import {NonZeroExitError} from './non-zero-exit-error.js'; -export {NonZeroExitError}; +export {NonZeroExitError, normalizeSpawnCommand}; const LINE_SEPARATOR_REGEX = /\r?\n/; @@ -312,7 +312,11 @@ export class ExecProcess implements Result { nodeOptions.env = computeEnv(cwd, nodeOptions.env); - const crossResult = parse(this._command, this._args, nodeOptions); + const crossResult = normalizeSpawnCommand( + this._command, + this._args, + nodeOptions + ); const handle = spawn( crossResult.command, @@ -386,7 +390,7 @@ export function xSync( nodeOptions.env = computeEnv(cwd, nodeOptions.env); - const crossResult = parse(command, args ?? [], nodeOptions); + const crossResult = normalizeSpawnCommand(command, args ?? [], nodeOptions); const spawnResult = spawnSync( crossResult.command, diff --git a/src/parse.ts b/src/normalize.ts similarity index 72% rename from src/parse.ts rename to src/normalize.ts index eb276fb..619eb99 100644 --- a/src/parse.ts +++ b/src/normalize.ts @@ -3,7 +3,8 @@ import {closeSync, openSync, readSync, statSync} from 'node:fs'; import { delimiter as pathDelimiter, normalize as normalizePath, - resolve as resolvePath + resolve as resolvePath, + basename } from 'node:path'; import {cwd as getCwd} from 'node:process'; import {getPathFromEnv} from './env.js'; @@ -14,33 +15,32 @@ const shebangRegExp = /^#!\s*(.+)$/; const isWindowsExecutableRegExp = /\.(?:com|exe)$/i; const isNodeModulesCmdRegExp = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i; const isWindows = process.platform === 'win32'; +const defaultPathExt = ['.EXE', '.CMD', '.BAT', '.COM']; -interface CrossParseResult { +interface NormalizedSpawnCommand { command: string; args: readonly string[]; options: SpawnOptions; } -// From https://github.com/moxystudio/node-cross-spawn (MIT) -export function parse( +/** + * Normalizes the command and arguments to work cross-platform. + * On Windows, this basically handles things like shebangs, calling + * `node_modules/.bin` commands, and escaping meta characters. + * On other platforms, it just returns the command and arguments as-is. + */ +export function normalizeSpawnCommand( command: string, args: readonly string[] = [], options: SpawnOptions = {} -): CrossParseResult { - // Build our parsed object - const parsed: CrossParseResult = { - command, - args, - options: {...options} - }; - +): NormalizedSpawnCommand { // Early return if use `shell` option or not on Windows. - if (parsed.options.shell === true || !isWindows) { - return parsed; + if (options.shell === true || !isWindows) { + return {command, args, options}; } // Detect & add support for shebangs - let file = resolveCommand(parsed); + let file = resolveCommand(command, options); let shebang: string | null = null; if (file !== null) { @@ -48,12 +48,16 @@ export function parse( const size = 150; const buffer = Buffer.alloc(size); + let fd: number | null = null; try { - const fd = openSync(file, 'r'); + fd = openSync(file, 'r'); readSync(fd, buffer, 0, size, 0); - closeSync(fd); } catch { // do nothing, we'll just assume it's not a shebang + } finally { + if (fd !== null) { + closeSync(fd); + } } const match = buffer.toString().match(shebangRegExp); @@ -64,21 +68,17 @@ export function parse( const path = separatorIndex !== -1 ? line.slice(0, separatorIndex) : line; const argument = separatorIndex !== -1 ? line.slice(separatorIndex + 1) : ''; - const binarySeparatorIndex = path.lastIndexOf('/'); - const binary = - binarySeparatorIndex !== -1 - ? path.slice(binarySeparatorIndex + 1) - : path; + const binary = basename(path); shebang = binary === 'env' ? argument || null : binary; } } if (shebang !== null && file !== null) { - parsed.args = [file, ...parsed.args]; - parsed.command = shebang; + args = [file, ...args]; + command = shebang; - file = resolveCommand(parsed); + file = resolveCommand(command, options); } // We don't need a shell if the command filename is resolved and an executable @@ -92,11 +92,11 @@ export function parse( // Normalize posix paths into OS compatible paths (e.g.: foo/bar -> foo\bar) // This is necessary otherwise it will always fail with ENOENT in those cases - parsed.command = normalizePath(parsed.command); + command = normalizePath(command); // Escape command & arguments - parsed.command = parsed.command.replace(metaCharsRegExp, '^$1'); - parsed.args = parsed.args.map((arg) => { + command = command.replace(metaCharsRegExp, '^$1'); + args = args.map((arg) => { // Algorithm below is based on https://qntm.org/cmd // It's slightly altered to disable JS backtracking to avoid hanging on specially crafted input // Please see https://github.com/moxystudio/node-cross-spawn/pull/160 for more information @@ -126,33 +126,31 @@ export function parse( return arg; }); - parsed.args = [ - '/d', - '/s', - '/c', - `"${[parsed.command, ...parsed.args].join(' ')}"` - ]; - parsed.command = parsed.options.env?.comspec ?? 'cmd.exe'; - parsed.options.windowsVerbatimArguments = true; // Tell node's spawn that the arguments are already escaped + args = ['/d', '/s', '/c', `"${[command, ...args].join(' ')}"`]; + command = options.env?.comspec ?? 'cmd.exe'; + // Tell node's spawn that the arguments are already escaped + options = {...options, windowsVerbatimArguments: true}; } - return parsed; + return {command, args, options}; } -// From https://github.com/npm/node-which (ISC), Windows part only and sync version. -function resolveCommand(parsed: CrossParseResult): string | null { - const {command, options} = parsed; +/** + * Resolves the command to an absolute path if possible. + * Handles things like traversing PATH and adding extensions from PATHEXT + */ +function resolveCommand(command: string, options: SpawnOptions): string | null { const cwd = (options.cwd ?? getCwd()).toString(); const env = options.env ?? process.env; - const PATH = getPathFromEnv(env).value; - const PATHEXT = env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM'; const pathEnv = command.includes('/') || command.includes('\\') ? [''] : [cwd, ...PATH.split(pathDelimiter)]; - const pathExt = PATHEXT.split(pathDelimiter); + const pathExt = env.PATHEXT + ? env.PATHEXT.split(pathDelimiter) + : defaultPathExt; if (command.includes('.') && pathExt[0] !== '') { pathExt.unshift(''); diff --git a/src/test/normalize_test.ts b/src/test/normalize_test.ts new file mode 100644 index 0000000..93b4c3a --- /dev/null +++ b/src/test/normalize_test.ts @@ -0,0 +1,53 @@ +import {normalizeSpawnCommand} from '../normalize.js'; +import {describe, test, expect} from 'vitest'; +import os from 'node:os'; + +const isWindows = os.platform() === 'win32'; +const baseWindowsOptions = { + env: process.env +}; + +describe('normalizeSpawnCommand', () => { + test('return from arguments if `shell` option is `true`', () => { + expect(normalizeSpawnCommand('node', ['-v'], {shell: true})).toEqual({ + command: 'node', + args: ['-v'], + options: {shell: true} + }); + }); + + describe.runIf(isWindows)('windows only', () => { + test('just return the same input if resolved', () => { + const normalized = normalizeSpawnCommand( + 'node', + ['-v'], + baseWindowsOptions + ); + + expect(normalized.command).toBe('node'); + expect(normalized.args).toEqual(['-v']); + }); + + test('use shell if command are not resolved/available', () => { + const normalized = normalizeSpawnCommand( + 'notexist', + ['hi'], + baseWindowsOptions + ); + + expect(normalized.command.endsWith('cmd.exe')).ok; + expect(normalized.args).toEqual(['/d', '/s', '/c', '"notexist ^"hi^""']); + expect(normalized.options.windowsVerbatimArguments).toBe(true); + }); + }); + + describe.runIf(!isWindows)('unix only', () => { + test('return from arguments', () => { + expect(normalizeSpawnCommand('node', ['-v'])).toEqual({ + command: 'node', + args: ['-v'], + options: {} + }); + }); + }); +}); diff --git a/src/test/parse_test.ts b/src/test/parse_test.ts deleted file mode 100644 index c5abf42..0000000 --- a/src/test/parse_test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import {parse} from '../parse.js'; -import {describe, test, expect} from 'vitest'; -import os from 'node:os'; - -const isWindows = os.platform() === 'win32'; -const baseWindowsOptions = { - env: process.env -}; - -describe('parse', () => { - test('return from arguments if `shell` option is `true`', () => { - expect(parse('node', ['-v'], {shell: true})).toEqual({ - command: 'node', - args: ['-v'], - options: {shell: true} - }); - }); - - describe.runIf(isWindows)('windows only', () => { - test('just return the same input if resolved', () => { - const parsed = parse('node', ['-v'], baseWindowsOptions); - - expect(parsed.command).toBe('node'); - expect(parsed.args).toEqual(['-v']); - }); - - test('use shell if command are not resolved/available', () => { - const parsed = parse('notexist', ['hi'], baseWindowsOptions); - - expect(parsed.command.endsWith('cmd.exe')).ok; - expect(parsed.args).toEqual(['/d', '/s', '/c', '"notexist ^"hi^""']); - expect(parsed.options.windowsVerbatimArguments).toBe(true); - }); - }); - - describe.runIf(!isWindows)('unix only', () => { - test('return from arguments', () => { - expect(parse('node', ['-v'])).toEqual({ - command: 'node', - args: ['-v'], - options: {} - }); - }); - }); -});