init project

This commit is contained in:
root
2026-07-31 13:12:54 -04:00
parent 0da92d5e02
commit f3863f760c
7215 changed files with 1860260 additions and 1 deletions
+201
View File
@@ -0,0 +1,201 @@
import {openSync, readSync, closeSync} from 'node:fs';
import {Buffer} from 'node:buffer';
import path from 'node:path';
import process from 'node:process';
import {whichCommandSync} from 'which-command';
/*
On Windows, `node:child_process` cannot natively run many kinds of files (`.cmd`, `.bat`, shebang scripts, ...): without a shell it resolves neither `PATHEXT` nor shebangs, and it does not escape arguments. We resolve the command to its full file path and escape its arguments ourselves, so those work without an explicit shell, just like on Unix, where the OS handles all of this for us.
*/
export const parseCommandFile = (file, commandArguments, options) => {
// The arguments are cloned since a shebang interpreter might be prepended below, which must not mutate the caller's array
const parsed = {file, commandArguments: [...commandArguments], options};
// Under a shell, or on Unix, the OS resolves the file and escapes the arguments itself
if (options.shell || process.platform !== 'win32') {
return parsed;
}
return escapeWindowsCommand(parsed);
};
// Only `.exe` and `.com` files can be spawned directly; anything else needs `cmd.exe`
const directlyExecutableRegExp = /\.(?:com|exe)$/i;
// `.cmd` and `.bat` files re-expand their own arguments via `%*`/`%1`, so metacharacters must survive being interpreted by `cmd.exe` twice: once when the batch file is invoked and once when it forwards the arguments
const batchFileRegExp = /\.(?:bat|cmd)$/i;
const escapeWindowsCommand = parsed => {
// Resolve the file to an absolute path, following its shebang to the interpreter if any
const resolvedFile = resolveWithShebang(parsed);
// A directly executable file is spawned by its resolved path, bypassing `cmd.exe` and its escaping
if (resolvedFile !== undefined && directlyExecutableRegExp.test(resolvedFile)) {
if (parsed.options.argv0 === undefined) {
parsed.options.argv0 = parsed.file;
}
parsed.file = resolvedFile;
return parsed;
}
/*
`cmd.exe` treats CR and LF as command separators and offers no way to escape them, so allowing either would enable command injection.
Reject them instead.
*/
for (const value of [parsed.file, ...parsed.commandArguments]) {
assertNoLineBreak(value);
}
const isDoubleEscape = resolvedFile !== undefined && batchFileRegExp.test(resolvedFile);
// POSIX separators must become Windows ones (`foo/bar` -> `foo\bar`), otherwise resolution always fails with ENOENT
const escapedFile = escapeMetaChars(path.normalize(resolvedFile ?? parsed.file));
const escapedArguments = parsed.commandArguments.map(argument => escapeArgument(argument, isDoubleEscape));
const commandLine = `"${[escapedFile, ...escapedArguments].join(' ')}"`;
// Let `node:child_process` pass the already-escaped command line through untouched
parsed.options.windowsVerbatimArguments = true;
return {
file: process.env.comspec || 'cmd.exe',
commandArguments: ['/d', '/s', '/c', commandLine],
options: parsed.options,
};
};
// Resolve the command's absolute path, then, if it is a shebang script, resolve its interpreter instead, since Windows cannot run shebangs natively
const resolveWithShebang = parsed => {
const resolvedFile = resolvePath(parsed);
const interpreter = resolvedFile !== undefined && readShebang(resolvedFile);
if (!interpreter) {
return resolvedFile;
}
// Run the interpreter with the script as its first argument, then resolve the interpreter's own path
parsed.commandArguments.unshift(resolvedFile);
parsed.file = interpreter;
return resolvePath(parsed);
};
// Search `PATH` for the command, resolving its Windows executable extension via `PATHEXT`
const resolvePath = parsed => {
const environment = parsed.options.env || process.env;
const cwd = parsed.options.cwd ?? process.cwd();
const environmentPathExt = getWindowsEnvironmentValue(environment, 'PATHEXT');
const commandExtension = path.extname(parsed.file);
// Commands with an explicit extension must be tried verbatim so shebang scripts work even when their extension is excluded from PATHEXT.
const pathExt = commandExtension === '' ? environmentPathExt : `${commandExtension}${path.delimiter}${environmentPathExt ?? ''}`;
if (hasWindowsPathSeparator(parsed.file)) {
return whichCommandSync(path.resolve(cwd, parsed.file), {cwd, pathExt});
}
const searchPath = getWindowsEnvironmentValue(environment, 'PATH') ?? getWindowsEnvironmentValue(process.env, 'PATH') ?? '';
const resolveOptions = {
cwd,
path: searchPath,
pathExt,
};
return shouldSearchCurrentDirectory(environment) ? whichCommandSync(parsed.file, resolveOptions) : resolvePathDirectories(parsed.file, resolveOptions);
};
const hasWindowsPathSeparator = file => file.includes('/') || file.includes('\\') || file.includes(':');
const shouldSearchCurrentDirectory = environment => getWindowsEnvironmentValue(process.env, 'NODEFAULTCURRENTDIRECTORYINEXEPATH') === undefined && getWindowsEnvironmentValue(environment, 'NODEFAULTCURRENTDIRECTORYINEXEPATH') === undefined;
const resolvePathDirectories = (file, {cwd, path: searchPath, pathExt}) => {
for (const directory of searchPath.split(path.delimiter)) {
const unquotedDirectory = directory.length > 1 && directory.startsWith('"') && directory.endsWith('"') ? directory.slice(1, -1) : directory;
if (unquotedDirectory === '') {
continue;
}
const resolvedFile = whichCommandSync(path.resolve(cwd, unquotedDirectory, file), {cwd, pathExt});
if (resolvedFile !== undefined) {
return resolvedFile;
}
}
};
// Node sorts Windows environment keys and uses the first case-insensitive match when spawning.
const getWindowsEnvironmentValue = (environment, name) => {
const environmentKey = Object.keys(environment).sort().find(key => key.toUpperCase() === name);
return environmentKey === undefined ? undefined : environment[environmentKey];
};
const SHEBANG_BYTE_LENGTH = 150;
// Read the file's first bytes to find its shebang interpreter, if it has one
const readShebang = file => {
const buffer = Buffer.alloc(SHEBANG_BYTE_LENGTH);
try {
const fileDescriptor = openSync(file, 'r');
try {
readSync(fileDescriptor, buffer, 0, SHEBANG_BYTE_LENGTH, 0);
} finally {
closeSync(fileDescriptor);
}
} catch {
return undefined;
}
return parseShebang(buffer.toString());
};
const shebangRegExp = /^#!(?<line>.*)/;
/*
Extract the interpreter from a shebang line, e.g. `#!/usr/bin/env node` -> `node`.
*/
const parseShebang = contents => {
const shebangLine = contents.match(shebangRegExp)?.groups.line.trim();
if (!shebangLine) {
return undefined;
}
const [interpreterPath, argument] = shebangLine.split(' ');
const interpreter = interpreterPath.split('/').at(-1);
if (interpreter === 'env') {
return argument;
}
return argument ? `${interpreter} ${argument}` : interpreter;
};
const lineBreakRegExp = /[\n\r]/;
const assertNoLineBreak = value => {
if (lineBreakRegExp.test(value)) {
throw new TypeError(`The command and its arguments cannot contain a line break on Windows without a shell.\nThis would allow a command injection with \`cmd.exe\`.\nInvalid value: ${JSON.stringify(`${value}`)}`);
}
};
// See https://web.archive.org/web/20241220221102/https://www.robvanderwoude.com/escapechars.php
// eslint-disable-next-line regexp/sort-character-class-elements
const metaCharsRegExp = /[()\][%!^"`<>&|;, *?]/g;
// Prefix every `cmd.exe` metacharacter with a caret to neutralize it
const escapeMetaChars = value => value.replaceAll(metaCharsRegExp, '^$&');
const backslashRunRegExp = /\\+/g;
const escapeArgument = (rawArgument, doubleEscape) => {
/*
Escape backslashes and double quotes for `cmd.exe`, following the algorithm at https://web.archive.org/web/20240930203505/https://qntm.org/cmd.
A run of backslashes only needs doubling when it precedes a double quote, or the end of the argument since that becomes a double quote once the argument is wrapped below. Otherwise the backslashes would be taken as escaping that quote. Every double quote is then escaped in turn.
Each backslash run is matched exactly once and consumed, so a long run cannot trigger the quadratic backtracking a naive pattern would, which would be a denial-of-service risk.
*/
const argument = `${rawArgument}`
.replaceAll(backslashRunRegExp, (backslashes, offset, string) => {
const nextCharacter = string[offset + backslashes.length];
const isPrecedesDoubleQuote = nextCharacter === '"' || nextCharacter === undefined;
return isPrecedesDoubleQuote ? backslashes.repeat(2) : backslashes;
})
.replaceAll('"', '\\"');
// Wrap the whole argument in double quotes, then caret-escape the metacharacters, a second time when targeting a cmd-shim
const escapedArgument = escapeMetaChars(`"${argument}"`);
return doubleEscape ? escapeMetaChars(escapedArgument) : escapedArgument;
};
+20
View File
@@ -0,0 +1,20 @@
import {logCommand} from '../verbose/start.js';
import {getVerboseInfo} from '../verbose/info.js';
import {getStartTime} from '../return/duration.js';
import {joinCommand} from './escape.js';
import {normalizeFdSpecificOption} from './specific.js';
// Compute `result.command`, `result.escapedCommand` and `verbose`-related information
export const handleCommand = (filePath, rawArguments, rawOptions) => {
const startTime = getStartTime();
const {command, escapedCommand} = joinCommand(filePath, rawArguments);
const verbose = normalizeFdSpecificOption(rawOptions, 'verbose');
const verboseInfo = getVerboseInfo(verbose, escapedCommand, {...rawOptions});
logCommand(escapedCommand, verboseInfo);
return {
command,
escapedCommand,
startTime,
verboseInfo,
};
};
+39
View File
@@ -0,0 +1,39 @@
import {statSync} from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import {safeNormalizeFileUrl} from './file-url.js';
// Normalize `cwd` option
export const normalizeCwd = (cwd = getDefaultCwd()) => {
const cwdString = safeNormalizeFileUrl(cwd, 'The "cwd" option');
return path.resolve(cwdString);
};
const getDefaultCwd = () => {
try {
return process.cwd();
} catch (error) {
error.message = `The current directory does not exist.\n${error.message}`;
throw error;
}
};
// When `cwd` option has an invalid value, provide with a better error message
export const fixCwdError = (originalMessage, cwd) => {
if (cwd === getDefaultCwd()) {
return originalMessage;
}
let cwdStat;
try {
cwdStat = statSync(cwd);
} catch (error) {
return `The "cwd" option is invalid: ${cwd}.\n${error.message}\n${originalMessage}`;
}
if (!cwdStat.isDirectory()) {
return `The "cwd" option is not a directory: ${cwd}.\n${originalMessage}`;
}
return originalMessage;
};
+50
View File
@@ -0,0 +1,50 @@
// Validate `encoding` option
export const validateEncoding = ({encoding}) => {
if (ENCODINGS.has(encoding)) {
return;
}
const correctEncoding = getCorrectEncoding(encoding);
if (correctEncoding !== undefined) {
throw new TypeError(`Invalid option \`encoding: ${serializeEncoding(encoding)}\`.
Please rename it to ${serializeEncoding(correctEncoding)}.`);
}
const correctEncodings = [...ENCODINGS].map(correctEncoding => serializeEncoding(correctEncoding)).join(', ');
throw new TypeError(`Invalid option \`encoding: ${serializeEncoding(encoding)}\`.
Please rename it to one of: ${correctEncodings}.`);
};
const TEXT_ENCODINGS = new Set(['utf8', 'utf16le']);
export const BINARY_ENCODINGS = new Set(['buffer', 'hex', 'base64', 'base64url', 'latin1', 'ascii']);
const ENCODINGS = TEXT_ENCODINGS.union(BINARY_ENCODINGS);
const getCorrectEncoding = encoding => {
if (encoding === null) {
return 'buffer';
}
if (typeof encoding !== 'string') {
return;
}
const lowerEncoding = encoding.toLowerCase();
if (lowerEncoding in ENCODING_ALIASES) {
return ENCODING_ALIASES[lowerEncoding];
}
if (ENCODINGS.has(lowerEncoding)) {
return lowerEncoding;
}
};
const ENCODING_ALIASES = {
// eslint-disable-next-line unicorn/text-encoding-identifier-case
'utf-8': 'utf8',
'utf-16le': 'utf16le',
'ucs-2': 'utf16le',
ucs2: 'utf16le',
binary: 'latin1',
};
const serializeEncoding = encoding => typeof encoding === 'string' ? `"${encoding}"` : String(encoding);
+88
View File
@@ -0,0 +1,88 @@
import {platform} from 'node:process';
import {stripVTControlCharacters} from 'node:util';
// Compute `result.command` and `result.escapedCommand`
export const joinCommand = (filePath, rawArguments) => {
const fileAndArguments = [filePath, ...rawArguments];
const command = fileAndArguments.join(' ');
const escapedCommand = fileAndArguments
.map(fileAndArgument => quoteString(escapeControlCharacters(fileAndArgument)))
.join(' ');
return {command, escapedCommand};
};
// Remove ANSI sequences and escape control characters and newlines
export const escapeLines = lines => stripVTControlCharacters(lines)
.split('\n')
.map(line => escapeControlCharacters(line))
.join('\n');
const escapeControlCharacters = line => line.replaceAll(SPECIAL_CHAR_REGEXP, character => escapeControlCharacter(character));
const escapeControlCharacter = character => {
const commonEscape = COMMON_ESCAPES[character];
if (commonEscape !== undefined) {
return commonEscape;
}
const codepoint = character.codePointAt(0);
const codepointHex = codepoint.toString(16);
return codepoint <= ASTRAL_START
? `\\u${codepointHex.padStart(4, '0')}`
: `\\U${codepointHex}`;
};
// Characters that would create issues when printed are escaped using the \u or \U notation.
// Those include control characters and newlines.
// The \u and \U notation is Bash specific, but there is no way to do this in a shell-agnostic way.
// Some shells do not even have a way to print those characters in an escaped fashion.
// Therefore, we prioritize printing those safely, instead of allowing those to be copy-pasted.
// List of Unicode character categories: https://www.fileformat.info/info/unicode/category/index.htm
const getSpecialCharRegExp = () => {
try {
// This throws when using Node.js without ICU support.
// When using a RegExp literal, this would throw at parsing-time, instead of runtime.
// eslint-disable-next-line prefer-regex-literals
return new RegExp('\\p{Separator}|\\p{Other}', 'gu');
} catch {
// Similar to the above RegExp, but works even when Node.js has been built without ICU support.
// Unlike the above RegExp, it only covers whitespaces and C0/C1 control characters.
// It does not cover some edge cases, such as Unicode reserved characters.
// See https://github.com/sindresorhus/execa/issues/1143
// eslint-disable-next-line no-control-regex, regexp/no-control-character, unicorn/prefer-unicode-code-point-escapes
return /[\s\u0000-\u001F\u007F-\u009F\u00AD]/g;
}
};
const SPECIAL_CHAR_REGEXP = getSpecialCharRegExp();
// Accepted by $'...' in Bash.
// Exclude \a \e \v which are accepted in Bash but not in JavaScript (except \v) and JSON.
const COMMON_ESCAPES = {
' ': ' ',
'\b': '\\b',
'\f': '\\f',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
};
// Up until that codepoint, \u notation can be used instead of \U
const ASTRAL_START = 65_535;
// Some characters are shell-specific, i.e. need to be escaped when the command is copy-pasted then run.
// Escaping is shell-specific. We cannot know which shell is used: `process.platform` detection is not enough.
// For example, Windows users could be using `cmd.exe`, Powershell or Bash for Windows which all use different escaping.
// We use '...' on Unix, which is POSIX shell compliant and escape all characters but ' so this is fairly safe.
// On Windows, we assume cmd.exe is used and escape with "...", which also works with Powershell.
const quoteString = escapedArgument => {
if (NO_ESCAPE_REGEXP.test(escapedArgument)) {
return escapedArgument;
}
return platform === 'win32'
? `"${escapedArgument.replaceAll('"', '""')}"`
: `'${escapedArgument.replaceAll('\'', '\'\\\'\'')}'`;
};
const NO_ESCAPE_REGEXP = /^[\w\-./]+$/;
+109
View File
@@ -0,0 +1,109 @@
import {parseFd} from './specific.js';
// Retrieve stream targeted by the `to` option
export const getToStream = (destination, to = 'stdin') => {
const isWritable = true;
const {options, fileDescriptors} = SUBPROCESS_OPTIONS.get(destination);
const fdNumber = getFdNumber(fileDescriptors, to, isWritable);
const destinationStream = destination.stdio[fdNumber];
if (destinationStream === null) {
throw new TypeError(getInvalidStdioOptionMessage(fdNumber, to, options, isWritable));
}
return destinationStream;
};
// Retrieve stream targeted by the `from` option
export const getFromStream = (source, from = 'stdout') => {
const isWritable = false;
const {options, fileDescriptors} = SUBPROCESS_OPTIONS.get(source);
const fdNumber = getFdNumber(fileDescriptors, from, isWritable);
const sourceStream = fdNumber === 'all' ? source.all : source.stdio[fdNumber];
if (sourceStream === null || sourceStream === undefined) {
throw new TypeError(getInvalidStdioOptionMessage(fdNumber, from, options, isWritable));
}
return sourceStream;
};
// Keeps track of the options passed to each Execa call
export const SUBPROCESS_OPTIONS = new WeakMap();
const getFdNumber = (fileDescriptors, fdName, isWritable) => {
const fdNumber = parseFdNumber(fdName, isWritable);
validateFdNumber(fdNumber, fdName, isWritable, fileDescriptors);
return fdNumber;
};
const parseFdNumber = (fdName, isWritable) => {
const fdNumber = parseFd(fdName);
if (fdNumber !== undefined) {
return fdNumber;
}
const {validOptions, defaultValue} = isWritable
? {validOptions: '"stdin"', defaultValue: 'stdin'}
: {validOptions: '"stdout", "stderr", "all"', defaultValue: 'stdout'};
throw new TypeError(`"${getOptionName(isWritable)}" must not be "${fdName}".
It must be ${validOptions} or "fd3", "fd4" (and so on).
It is optional and defaults to "${defaultValue}".`);
};
const validateFdNumber = (fdNumber, fdName, isWritable, fileDescriptors) => {
const fileDescriptor = fileDescriptors[getUsedDescriptor(fdNumber)];
if (fileDescriptor === undefined) {
throw new TypeError(`"${getOptionName(isWritable)}" must not be ${fdName}. That file descriptor does not exist.
Please set the "stdio" option to ensure that file descriptor exists.`);
}
if (fileDescriptor.direction === 'input' && !isWritable) {
throw new TypeError(`"${getOptionName(isWritable)}" must not be ${fdName}. It must be a readable stream, not writable.`);
}
if (fileDescriptor.direction !== 'input' && isWritable) {
throw new TypeError(`"${getOptionName(isWritable)}" must not be ${fdName}. It must be a writable stream, not readable.
If you meant to use it as input, please set its "stdio" option to \`{value: 'pipe', input: true}\`.`);
}
};
const getInvalidStdioOptionMessage = (fdNumber, fdName, options, isWritable) => {
if (fdNumber === 'all' && !options.all) {
return 'The "all" option must be true to use "from: \'all\'".';
}
const {optionName, optionValue} = getInvalidStdioOption(fdNumber, options);
return `The "${optionName}: ${serializeOptionValue(optionValue)}" option is incompatible with using "${getOptionName(isWritable)}: ${serializeOptionValue(fdName)}".
Please set this option with "pipe" instead.`;
};
const getInvalidStdioOption = (fdNumber, {stdin, stdout, stderr, stdio}) => {
const usedDescriptor = getUsedDescriptor(fdNumber);
if (usedDescriptor === 0 && stdin !== undefined) {
return {optionName: 'stdin', optionValue: stdin};
}
if (usedDescriptor === 1 && stdout !== undefined) {
return {optionName: 'stdout', optionValue: stdout};
}
if (usedDescriptor === 2 && stderr !== undefined) {
return {optionName: 'stderr', optionValue: stderr};
}
return {optionName: `stdio[${usedDescriptor}]`, optionValue: stdio[usedDescriptor]};
};
const getUsedDescriptor = fdNumber => fdNumber === 'all' ? 1 : fdNumber;
const getOptionName = isWritable => isWritable ? 'to' : 'from';
export const serializeOptionValue = value => {
if (typeof value === 'string') {
return `'${value}'`;
}
return typeof value === 'number' ? `${value}` : 'Stream';
};
+25
View File
@@ -0,0 +1,25 @@
import {fileURLToPath} from 'node:url';
// Allow some arguments/options to be either a file path string or a file URL
export const safeNormalizeFileUrl = (file, name) => {
const fileString = normalizeFileUrl(normalizeDenoExecPath(file));
if (typeof fileString !== 'string') {
throw new TypeError(`${name} must be a string or a file URL: ${fileString}.`);
}
return fileString;
};
// In Deno node:process execPath is a special object, not just a string:
// https://github.com/denoland/deno/blob/f460188e583f00144000aa0d8ade08218d47c3c1/ext/node/polyfills/process.ts#L344
const normalizeDenoExecPath = file => isDenoExecPath(file)
? file.toString()
: file;
export const isDenoExecPath = file => typeof file !== 'string'
&& file
&& Object.getPrototypeOf(file) === String.prototype;
// Same but also allows other values, e.g. `boolean` for the `shell` option
export const normalizeFileUrl = file => file instanceof URL ? fileURLToPath(file) : file;
+105
View File
@@ -0,0 +1,105 @@
import path from 'node:path';
import process from 'node:process';
import {npmRunPathEnv} from 'npm-run-path';
import {normalizeForceKillAfterDelay} from '../terminate/kill.js';
import {normalizeKillSignal} from '../terminate/signal.js';
import {validateCancelSignal} from '../terminate/cancel.js';
import {validateGracefulCancel} from '../terminate/graceful.js';
import {validateTimeout} from '../terminate/timeout.js';
import {handleNodeOption} from '../methods/node.js';
import {validateIpcInputOption} from '../ipc/ipc-input.js';
import {validateEncoding, BINARY_ENCODINGS} from './encoding-option.js';
import {parseCommandFile} from './command-file.js';
import {normalizeCwd} from './cwd.js';
import {normalizeFileUrl} from './file-url.js';
import {normalizeFdSpecificOptions} from './specific.js';
const cmdExeRegExp = /^cmd(?:\.exe)?$/i;
// Normalize the options object, and sometimes also the file paths and arguments.
// Applies default values, validate allowed options, normalize them.
export const normalizeOptions = (filePath, rawArguments, rawOptions) => {
// Prevent prototype pollution by copying only own properties to a null-prototype object
const sanitizedOptions = {__proto__: null, ...rawOptions};
sanitizedOptions.cwd = normalizeCwd(sanitizedOptions.cwd);
const [processedFile, processedArguments, processedOptions] = handleNodeOption(filePath, rawArguments, sanitizedOptions);
const fdOptions = normalizeFdSpecificOptions(processedOptions);
const options = addDefaultOptions(fdOptions);
options.env = getEnv(options);
const {file, commandArguments} = parseCommandFile(processedFile, processedArguments, options);
validateTimeout(options);
validateEncoding(options);
validateIpcInputOption(options);
validateCancelSignal(options);
validateGracefulCancel(options);
options.shell = normalizeFileUrl(options.shell);
options.killSignal = normalizeKillSignal(options.killSignal);
options.forceKillAfterDelay = normalizeForceKillAfterDelay(options.forceKillAfterDelay);
options.lines = options.lines.map((lines, fdNumber) => lines && !BINARY_ENCODINGS.has(options.encoding) && options.buffer[fdNumber]);
// The file is now an absolute path resolved via `PATHEXT`, so its extension might be uppercase (`cmd.EXE`)
if (process.platform === 'win32' && cmdExeRegExp.test(path.basename(file))) {
// #116
commandArguments.unshift('/q');
}
return {file, commandArguments, options};
};
// Use null prototype to prevent prototype pollution from leaking through
const addDefaultOptions = ({
extendEnv = true,
preferLocal = false,
cwd,
localDir: localDirectory = cwd,
encoding = 'utf8',
reject = true,
cleanup = true,
killDescendants = false,
all = false,
windowsHide = true,
killSignal = 'SIGTERM',
forceKillAfterDelay = true,
gracefulCancel = false,
ipcInput,
ipc = ipcInput !== undefined || gracefulCancel,
serialization = 'advanced',
...options
}) => ({
__proto__: null,
...options,
extendEnv,
preferLocal,
cwd,
localDirectory,
encoding,
reject,
cleanup,
killDescendants,
all,
windowsHide,
killSignal,
forceKillAfterDelay,
gracefulCancel,
ipcInput,
ipc,
serialization,
});
const getEnv = ({env: envOption, extendEnv, preferLocal, node, localDirectory, nodePath}) => {
const env = extendEnv ? {...process.env, ...envOption} : envOption;
if (preferLocal || node) {
return npmRunPathEnv({
env,
cwd: localDirectory,
execPath: nodePath,
preferLocal,
addExecPath: node,
});
}
return env;
};
+11
View File
@@ -0,0 +1,11 @@
// When the `shell` option is set, any command argument is concatenated as a single string by Node.js:
// https://github.com/nodejs/node/blob/e38ce27f3ca0a65f68a31cedd984cddb927d4002/lib/child_process.js#L614-L624
// However, since Node 24, it also prints a deprecation warning.
// To avoid this warning, we perform that same operation before calling `node:child_process`.
// Shells only understand strings, which is why Node.js performs that concatenation.
// However, we rely on users splitting command arguments as an array.
// For example, this allows us to easily detect which arguments are passed.
// So we do want users to pass array of arguments even with `shell: true`, but we also want to avoid any warning.
export const concatenateShell = (file, commandArguments, options) => options.shell && commandArguments.length > 0
? [[file, ...commandArguments].join(' '), [], options]
: [file, commandArguments, options];
+112
View File
@@ -0,0 +1,112 @@
import {debuglog} from 'node:util';
import isPlainObject from 'is-plain-obj';
import {STANDARD_STREAMS_ALIASES} from '../utils/standard-stream.js';
// Some options can have different values for `stdout`/`stderr`/`fd3`.
// This normalizes those to array of values.
// For example, `{verbose: {stdout: 'none', stderr: 'full'}}` becomes `{verbose: ['none', 'none', 'full']}`
export const normalizeFdSpecificOptions = options => {
const optionsCopy = {...options};
for (const optionName of FD_SPECIFIC_OPTIONS) {
optionsCopy[optionName] = normalizeFdSpecificOption(options, optionName);
}
return optionsCopy;
};
export const normalizeFdSpecificOption = (options, optionName) => {
const stdioLength = getStdioLength(options);
const optionBaseArray = Array.from({length: stdioLength + 1});
const optionArray = normalizeFdSpecificValue(options[optionName], optionBaseArray, optionName, stdioLength);
return addDefaultValue(optionArray, optionName);
};
const getStdioLength = ({stdio}) => Array.isArray(stdio)
? Math.max(stdio.length, STANDARD_STREAMS_ALIASES.length)
: STANDARD_STREAMS_ALIASES.length;
const normalizeFdSpecificValue = (optionValue, optionArray, optionName, stdioLength) => isPlainObject(optionValue)
? normalizeOptionObject(optionValue, optionArray, optionName, stdioLength)
: optionArray.fill(optionValue);
const normalizeOptionObject = (optionValue, optionArray, optionName, stdioLength) => {
for (const fdName of Object.keys(optionValue).sort(compareFdName)) {
for (const fdNumber of parseFdName(fdName, optionName, stdioLength)) {
optionArray[fdNumber] = optionValue[fdName];
}
}
return optionArray;
};
// Ensure priority order when setting both `stdout`/`stderr`, `fd1`/`fd2`, and `all`
const compareFdName = (fdNameA, fdNameB) => getFdNameOrder(fdNameA) < getFdNameOrder(fdNameB) ? 1 : -1;
const getFdNameOrder = fdName => {
if (fdName === 'stdout' || fdName === 'stderr') {
return 0;
}
return fdName === 'all' ? 2 : 1;
};
const parseFdName = (fdName, optionName, stdioLength) => {
if (fdName === 'ipc') {
return [stdioLength];
}
const fdNumber = parseFd(fdName);
if (fdNumber === undefined || fdNumber === 0) {
throw new TypeError(`"${optionName}.${fdName}" is invalid.
It must be "${optionName}.stdout", "${optionName}.stderr", "${optionName}.all", "${optionName}.ipc", or "${optionName}.fd3", "${optionName}.fd4" (and so on).`);
}
if (fdNumber !== 'all' && fdNumber >= stdioLength) {
throw new TypeError(`"${optionName}.${fdName}" is invalid: that file descriptor does not exist.
Please set the "stdio" option to ensure that file descriptor exists.`);
}
return fdNumber === 'all' ? [1, 2] : [fdNumber];
};
// Use the same syntax for fd-specific options and the `from`/`to` options
export const parseFd = fdName => {
if (fdName === 'all') {
return fdName;
}
if (STANDARD_STREAMS_ALIASES.includes(fdName)) {
return STANDARD_STREAMS_ALIASES.indexOf(fdName);
}
const regexpResult = FD_REGEXP.exec(fdName);
if (regexpResult !== null) {
return Number(regexpResult.groups.fdNumber);
}
};
const FD_REGEXP = /^fd(?<fdNumber>\d+)$/;
const addDefaultValue = (optionArray, optionName) => optionArray.map(optionValue => optionValue === undefined
? DEFAULT_OPTIONS[optionName]
: optionValue);
// Default value for the `verbose` option
const verboseDefault = debuglog('execa').enabled ? 'full' : 'none';
const DEFAULT_OPTIONS = {
lines: false,
buffer: true,
maxBuffer: 1000 * 1000 * 100,
verbose: verboseDefault,
stripFinalNewline: true,
};
// List of options which can have different values for `stdout`/`stderr`
export const FD_SPECIFIC_OPTIONS = ['lines', 'buffer', 'maxBuffer', 'verbose', 'stripFinalNewline'];
// Retrieve fd-specific option
export const getFdSpecificValue = (optionArray, fdNumber) => fdNumber === 'ipc'
? optionArray.at(-1)
: optionArray[fdNumber];