Files
renovate/lib/util/exec/common.ts
T
renovate[bot] 1909c2ef22 build(deps): update dependency execa to v10 (main) (#45834)
* build(deps): update dependency execa to v10

| datasource | package | from  | to     |
| ---------- | ------- | ----- | ------ |
| npm        | execa   | 8.0.1 | 10.0.1 |

* fix(exec): adapt to execa v10 types and subprocess API

* fix(tools): type execa v10 results in hook exec and mkdocs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: secustor <sebastian@poxhofer.at>
2026-09-25 09:00:52 +00:00

263 lines
7.7 KiB
TypeScript

import type { ChildProcess } from 'node:child_process';
import type { Readable } from 'node:stream';
import { isNullOrUndefined } from '@sindresorhus/is';
import { execa } from 'execa';
import { join, split } from 'shlex';
import { instrument } from '../../instrumentation/index.ts';
import { logger } from '../../logger/index.ts';
import { getEnv } from '../env.ts';
import { sanitize } from '../sanitize.ts';
import type { ExecErrorData } from './exec-error.ts';
import { ExecError } from './exec-error.ts';
import type {
CommandWithOptions,
DataListener,
ExecResult,
OutputWriter,
RawExecOptions,
} from './types.ts';
import { asRawCommand, isCommandWithOptions } from './utils.ts';
// https://man7.org/linux/man-pages/man7/signal.7.html#NAME
// Non TERM/CORE signals
// The following is step 3. in https://github.com/renovatebot/renovate/issues/16197#issuecomment-1171423890
const NONTERM = [
'SIGCHLD',
'SIGCLD',
'SIGCONT',
'SIGSTOP',
'SIGTSTP',
'SIGTTIN',
'SIGTTOU',
'SIGURG',
'SIGWINCH',
];
const encoding = 'utf8';
function stringify(list: Buffer[], writer: OutputWriter | undefined): string {
return writer?.toString() ?? Buffer.concat(list).toString(encoding);
}
function initStreamListeners(
cp: ChildProcess,
opts: RawExecOptions & { maxBuffer: number },
): [Buffer[], Buffer[]] {
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let stdoutLen = 0;
let stderrLen = 0;
registerDataListeners(cp.stdout, opts.outputListeners?.stdout);
registerDataListeners(cp.stderr, opts.outputListeners?.stderr);
cp.stdout?.on('data', (chunk: Buffer) => {
// process.stdout.write(data.toString());
if (opts.outputWriters?.stdout) {
opts.outputWriters.stdout.write(chunk);
return;
}
const len = Buffer.byteLength(chunk, encoding);
stdoutLen += len;
if (stdoutLen > opts.maxBuffer) {
cp.emit('error', new Error('stdout maxBuffer exceeded'));
} else {
stdout.push(chunk);
}
});
cp.stderr?.on('data', (chunk: Buffer) => {
// process.stderr.write(data.toString());
if (opts.outputWriters?.stderr) {
opts.outputWriters.stderr.write(chunk);
return;
}
const len = Buffer.byteLength(chunk, encoding);
stderrLen += len;
if (stderrLen > opts.maxBuffer) {
cp.emit('error', new Error('stderr maxBuffer exceeded'));
} else {
stderr.push(chunk);
}
});
return [stdout, stderr];
}
function registerDataListeners(
readable: Readable | null,
dataListeners: DataListener[] | undefined,
): void {
if (isNullOrUndefined(readable) || isNullOrUndefined(dataListeners)) {
return;
}
for (const listener of dataListeners) {
readable.on('data', listener);
}
}
export function exec(
commandArgument: string | CommandWithOptions,
opts: RawExecOptions,
): Promise<ExecResult> {
let theCmd = commandArgument;
let ignoreFailure = false;
if (isCommandWithOptions(commandArgument)) {
theCmd = join(commandArgument.command);
if (commandArgument.ignoreFailure !== undefined) {
ignoreFailure = commandArgument.ignoreFailure;
}
}
return new Promise((resolve, reject) => {
let cmd = asRawCommand(theCmd);
let args: string[] = [];
const maxBuffer = opts.maxBuffer ?? 10 * 1024 * 1024; // Set default max buffer size to 10MB
// don't use shell by default, as it leads to potential security issues
let shell = opts.shell ?? false;
if (
isCommandWithOptions(commandArgument) &&
commandArgument.shell !== undefined
) {
shell = commandArgument.shell;
}
// if we're not in shell mode, we need to provide the command and arguments
if (shell === false) {
const parts = split(cmd);
// v8 ignore else -- TODO: add test #40625
if (parts) {
cmd = parts[0];
args = parts.slice(1);
}
}
const subprocess = execa(cmd, args, {
...opts,
// force detached on non WIN platforms
// https://github.com/nodejs/node/issues/21825#issuecomment-611328888
detached: process.platform !== 'win32',
shell,
extendEnv: false,
// Suppress execa's internal promise rejection (e.g., from timeout).
// We handle all exit scenarios via 'exit' and 'error' event listeners below,
// so the promise rejection would otherwise surface as an unhandledRejection.
// TODO: Refactor to await execa result (#45650)
reject: false,
});
const cp = subprocess.nodeChildProcess;
// handle streams
const [stdout, stderr] = initStreamListeners(cp, {
...opts,
maxBuffer,
});
// handle process events
void cp.on('error', (error) => {
kill(cp, 'SIGTERM');
// rethrowing, use originally emitted error message
reject(new ExecError(error.message, rejectInfo(), error));
});
void cp.on('exit', (code: number, signal: NodeJS.Signals) => {
if (NONTERM.includes(signal)) {
return;
}
if (signal) {
kill(cp, signal);
reject(
new ExecError(
`Command failed: ${cp.spawnargs.join(' ')}\nInterrupted by ${signal}`,
{
...rejectInfo(),
signal,
},
),
);
return;
}
if (code !== 0) {
if (ignoreFailure === undefined || ignoreFailure === false) {
reject(
new ExecError(
`Command failed: ${cp.spawnargs.join(' ')}\n${stringify(stderr, opts.outputWriters?.stderr)}`,
{
...rejectInfo(),
exitCode: code,
},
),
);
return;
}
logger.once.debug(
{
command: cp.spawnargs.join(' '),
stdout: stringify(stdout, opts.outputWriters?.stdout),
stderr: stringify(stderr, opts.outputWriters?.stderr),
exitCode: code,
},
`Ignoring failure to execute comamnd \`${cp.spawnargs.join(' ')}\`, as ignoreFailure=true is set`,
);
resolve({
stderr: stringify(stderr, opts.outputWriters?.stderr),
stdout: stringify(stdout, opts.outputWriters?.stdout),
exitCode: code,
});
return;
}
resolve({
stderr: stringify(stderr, opts.outputWriters?.stderr),
stdout: stringify(stdout, opts.outputWriters?.stdout),
});
});
function rejectInfo(): ExecErrorData {
return {
cmd: cp.spawnargs.join(' '),
options: opts,
stdout: stringify(stdout, opts.outputWriters?.stdout),
stderr: stringify(stderr, opts.outputWriters?.stderr),
};
}
});
}
function kill(cp: ChildProcess, signal: NodeJS.Signals): boolean {
try {
if (cp.pid && getEnv().RENOVATE_X_EXEC_GPID_HANDLE) {
/**
* If `pid` is negative, but not `-1`, signal shall be sent to all processes
* (excluding an unspecified set of system processes),
* whose process group ID (pgid) is equal to the absolute value of pid,
* and for which the process has permission to send a signal.
*/
return process.kill(-cp.pid, signal);
}
// destroying stdio is needed for unref to work
// https://nodejs.org/api/child_process.html#subprocessunref
// https://github.com/nodejs/node/blob/4d5ff25a813fd18939c9f76b17e36291e3ea15c3/lib/child_process.js#L412-L426
cp.stderr?.destroy();
cp.stdout?.destroy();
cp.unref();
return cp.kill(signal);
} catch {
// cp is a single node tree, therefore -pid is invalid as there is no such pgid,
return false;
}
}
export function rawExec(
cmd: string | CommandWithOptions,
opts: RawExecOptions,
): Promise<ExecResult> {
return instrument(`rawExec: ${sanitize(asRawCommand(cmd))}`, () =>
exec(cmd, opts),
);
}