Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,29 @@ jobs:
node-version:
- 26
- 24
- 18
- 22
os:
- ubuntu
- macos
- windows
steps:
- uses: actions/cache@v4
- uses: actions/cache@v6
with:
path: .lycheecache
key: cache-lychee-${{ github.sha }}
restore-keys: cache-lychee-
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- uses: lycheeverse/lychee-action@v1
- uses: lycheeverse/lychee-action@v2
with:
args: --cache --verbose --no-progress --include-fragments --exclude packagephobia --exclude /pull/ --exclude linkedin --exclude stackoverflow --exclude stackexchange --exclude github.com/nodejs/node --exclude file:///test --exclude invalid.com '*.md' 'docs/*.md' '.github/**/*.md' '*.json' '*.js' 'lib/**/*.js' 'test/**/*.js' '*.ts' 'test-d/**/*.ts'
args: --cache --verbose --no-progress --include-fragments --exclude packagephobia --exclude /pull/ --exclude linkedin --exclude stackoverflow --exclude stackexchange --exclude github.com/nodejs/node --exclude github.com/denoland/deno --exclude file:///test --exclude invalid.com '*.md' 'docs/*.md' '.github/**/*.md' '*.json' '*.js' 'lib/**/*.js' 'test/**/*.js' '*.ts' 'test-d/**/*.ts'
fail: true
if: ${{ matrix.os == 'ubuntu' && matrix.node-version == 26 }}
- run: npm run lint
if: ${{ matrix.os == 'ubuntu' && matrix.node-version == 26 }}
- run: npm run type
- run: npm run unit
- uses: codecov/codecov-action@v4
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules
yarn.lock
.nyc_output
coverage
lychee
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -945,7 +945,7 @@ If the subprocess outputs text, specifies its character encoding, either [`'utf8

If it outputs binary data instead, this should be either:
- `'buffer'`: returns the binary output as an [`Uint8Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array).
- [`'hex'`](https://en.wikipedia.org/wiki/Hexadecimal), [`'base64'`](https://en.wikipedia.org/wiki/Base64), [`'base64url'`](https://en.wikipedia.org/wiki/Base64#URL_applications), [`'latin1'`](https://nodejs.org/api/buffer.html#buffers-and-character-encodings) or [`'ascii'`](https://nodejs.org/api/buffer.html#buffers-and-character-encodings): encodes the binary output as a string.
- [`'hex'`](https://en.wikipedia.org/wiki/Hexadecimal), [`'base64'`](https://en.wikipedia.org/wiki/Base64), [`'base64url'`](https://en.wikipedia.org/wiki/Base64#RFC_4648), [`'latin1'`](https://nodejs.org/api/buffer.html#buffers-and-character-encodings) or [`'ascii'`](https://nodejs.org/api/buffer.html#buffers-and-character-encodings): encodes the binary output as a string.

The output is available with [`result.stdout`](#resultstdout), [`result.stderr`](#resultstderr) and [`result.stdio`](#resultstdio).

Expand Down
2 changes: 1 addition & 1 deletion docs/binary.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ console.log(stdout.byteLength);

## Encoding

When the output is binary, the [`encoding`](api.md#optionsencoding) option can also be set to [`'hex'`](https://en.wikipedia.org/wiki/Hexadecimal), [`'base64'`](https://en.wikipedia.org/wiki/Base64) or [`'base64url'`](https://en.wikipedia.org/wiki/Base64#URL_applications). The output will be a string then.
When the output is binary, the [`encoding`](api.md#optionsencoding) option can also be set to [`'hex'`](https://en.wikipedia.org/wiki/Hexadecimal), [`'base64'`](https://en.wikipedia.org/wiki/Base64) or [`'base64url'`](https://en.wikipedia.org/wiki/Base64#RFC_4648). The output will be a string then.

```js
const {stdout} = await execa({encoding: 'hex'})`zip -r - input.txt`;
Expand Down
2 changes: 1 addition & 1 deletion docs/termination.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ addAbortListener(cancelSignal, async () => {
});
```

However, if any operation is still ongoing, the subprocess will keep running. It can be forcefully ended using [`process.exit(exitCode)`](https://nodejs.org/api/process.html#processexitcode) instead of [`process.exitCode`](https://nodejs.org/api/process.html#processexitcode_1).
However, if any operation is still ongoing, the subprocess will keep running. It can be forcefully ended using [`process.exit(exitCode)`](https://nodejs.org/api/process.html#processexitcode) instead of [`process.exitCode`](https://nodejs.org/api/process.html#processexitcode-1).

If the subprocess is still alive after 5 seconds, it is forcefully terminated with [`SIGKILL`](#sigkill). This can be [configured or disabled](#forceful-termination) using the [`forceKillAfterDelay`](api.md#optionsforcekillafterdelay) option.

Expand Down
4 changes: 2 additions & 2 deletions lib/arguments/escape.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const getSpecialCharRegExp = () => {
// 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
// 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;
}
};
Expand Down Expand Up @@ -85,4 +85,4 @@ const quoteString = escapedArgument => {
: `'${escapedArgument.replaceAll('\'', '\'\\\'\'')}'`;
};

const NO_ESCAPE_REGEXP = /^[\w./-]+$/;
const NO_ESCAPE_REGEXP = /^[\w\-./]+$/;
4 changes: 2 additions & 2 deletions lib/arguments/specific.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,11 @@ export const parseFd = fdName => {

const regexpResult = FD_REGEXP.exec(fdName);
if (regexpResult !== null) {
return Number(regexpResult[1]);
return Number(regexpResult.groups.fdNumber);
}
};

const FD_REGEXP = /^fd(\d+)$/;
const FD_REGEXP = /^fd(?<fdNumber>\d+)$/;

const addDefaultValue = (optionArray, optionName) => optionArray.map(optionValue => optionValue === undefined
? DEFAULT_OPTIONS[optionName]
Expand Down
8 changes: 5 additions & 3 deletions lib/convert/readable.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,12 @@ export const onStdoutFinished = async ({subprocessStdout, onStdoutDataDone, read

// When `readable` aborts/errors, do the same on `subprocess.stdout`
export const onReadableDestroy = async ({subprocessStdout, subprocess, waitReadableDestroy}, error) => {
if (await waitForConcurrentStreams(waitReadableDestroy, subprocess)) {
destroyOtherReadable(subprocessStdout, error);
await waitForSubprocess(subprocess, error);
if (!await waitForConcurrentStreams(waitReadableDestroy, subprocess)) {
return;
}

destroyOtherReadable(subprocessStdout, error);
await waitForSubprocess(subprocess, error);
};

const destroyOtherReadable = (stream, error) => {
Expand Down
12 changes: 7 additions & 5 deletions lib/convert/writable.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,15 @@ const onWrite = (subprocessStdin, chunk, encoding, done) => {
// The user does not need to `await` the subprocess anymore, but now needs to await the stream completion or error.
// When multiple writables are targeting the same stream, they wait for each other, unless the subprocess ends first.
const onWritableFinal = async (subprocessStdin, subprocess, waitWritableFinal) => {
if (await waitForConcurrentStreams(waitWritableFinal, subprocess)) {
if (subprocessStdin.writable) {
subprocessStdin.end();
}
if (!await waitForConcurrentStreams(waitWritableFinal, subprocess)) {
return;
}

await subprocess;
if (subprocessStdin.writable) {
subprocessStdin.end();
}

await subprocess;
};

// When `subprocess.stdin` ends/aborts/errors, do the same on `writable`.
Expand Down
8 changes: 5 additions & 3 deletions lib/io/output-async.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ export const pipeOutputAsync = (subprocess, fileDescriptors, controller) => {
const pipeGroups = new Map();

for (const [fdNumber, {stdioItems, direction}] of Object.entries(fileDescriptors)) {
for (const {stream} of stdioItems.filter(({type}) => TRANSFORM_TYPES.has(type))) {
const transformItems = stdioItems.filter(({type}) => TRANSFORM_TYPES.has(type));
for (const {stream} of transformItems) {
pipeTransform(subprocess, stream, direction, fdNumber);
}

for (const {stream} of stdioItems.filter(({type}) => !TRANSFORM_TYPES.has(type))) {
const nonTransformItems = stdioItems.filter(({type}) => !TRANSFORM_TYPES.has(type));
for (const {stream} of nonTransformItems) {
pipeStdioItem({
subprocess,
stream,
Expand All @@ -26,7 +28,7 @@ export const pipeOutputAsync = (subprocess, fileDescriptors, controller) => {
}
}

for (const [outputStream, inputStreams] of pipeGroups.entries()) {
for (const [outputStream, inputStreams] of pipeGroups) {
const inputStream = inputStreams.length === 1 ? inputStreams[0] : mergeStreams(inputStreams);
pipeStreams(inputStream, outputStream);
}
Expand Down
5 changes: 3 additions & 2 deletions lib/io/output-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const transformOutputSync = ({fileDescriptors, syncResult: {output}, opti
}

const state = {};
const outputFiles = new Set([]);
const outputFiles = new Set();
const transformedOutput = output.map((result, fdNumber) =>
transformOutputResultSync({
result,
Expand Down Expand Up @@ -123,7 +123,8 @@ const logOutputSync = ({serializedResult, fdNumber, state, verboseInfo, encoding

// When the `std*` target is a file path/URL or a file descriptor
const writeToFiles = (serializedResult, stdioItems, outputFiles) => {
for (const {path, append} of stdioItems.filter(({type}) => FILE_TYPES.has(type))) {
const fileItems = stdioItems.filter(({type}) => FILE_TYPES.has(type));
for (const {path, append} of fileItems) {
const pathString = typeof path === 'string' ? path : path.toString();
if (append || outputFiles.has(pathString)) {
appendFileSync(path, serializedResult);
Expand Down
16 changes: 10 additions & 6 deletions lib/ipc/reference.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,20 @@ const removeReferenceCount = channel => {
// Those should not keep the subprocess alive, so we remove the automatic counting that Node.js is doing.
// See https://github.com/nodejs/node/blob/1b965270a9c273d4cf70e8808e9d28b9ada7844f/lib/child_process.js#L180
export const undoAddedReferences = (channel, isSubprocess) => {
if (isSubprocess) {
removeReferenceCount(channel);
removeReferenceCount(channel);
if (!isSubprocess) {
return;
}

removeReferenceCount(channel);
removeReferenceCount(channel);
};

// Reverse it during `disconnect`
export const redoAddedReferences = (channel, isSubprocess) => {
if (isSubprocess) {
addReferenceCount(channel);
addReferenceCount(channel);
if (!isSubprocess) {
return;
}

addReferenceCount(channel);
addReferenceCount(channel);
};
10 changes: 4 additions & 6 deletions lib/methods/bind.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@ import {FD_SPECIFIC_OPTIONS} from '../arguments/specific.js';
// Use spread (which only copies own properties) to safely read from boundOptions without prototype pollution
export const mergeOptions = (boundOptions, options) => {
const safeBoundOptions = {__proto__: null, ...boundOptions};
const mergedOptions = Object.fromEntries(
Object.entries(options).map(([optionName, optionValue]) => [
optionName,
mergeOption(optionName, safeBoundOptions[optionName], optionValue),
]),
);
const mergedOptions = Object.fromEntries(Object.entries(options).map(([optionName, optionValue]) => [
optionName,
mergeOption(optionName, safeBoundOptions[optionName], optionValue),
]));
return {...safeBoundOptions, ...mergedOptions};
};

Expand Down
2 changes: 1 addition & 1 deletion lib/methods/main-async.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const spawnSubprocessAsync = ({file, commandArguments, options, startTime, verbo
}

const controller = new AbortController();
setMaxListeners(Number.POSITIVE_INFINITY, controller.signal);
setMaxListeners(Infinity, controller.signal);

const originalStreams = [...subprocess.stdio];
pipeOutputAsync(subprocess, fileDescriptors, controller);
Expand Down
3 changes: 2 additions & 1 deletion lib/resolve/wait-stream.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export const waitForStream = async (stream, fdNumber, streamInfo, {isSameDirecti
// Therefore `.destroy()` might end before or after subprocess exit, based on OS speed and load.
// The only way to detect this is to spy on `subprocess.stdin._destroy()` by wrapping it.
// If `subprocess.exitCode` or `subprocess.signalCode` is set, it means `.destroy()` is being called by Node.js itself.
const handleStdinDestroy = (stream, {originalStreams: [originalStdin], subprocess}) => {
const handleStdinDestroy = (stream, {originalStreams, subprocess}) => {
const [originalStdin] = originalStreams;
const state = {stdinCleanedUp: false};
if (stream === originalStdin) {
spyOnStdinDestroy(stream, subprocess, state);
Expand Down
26 changes: 15 additions & 11 deletions lib/return/final-error.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,19 @@ export class DiscardedError extends Error {}

// Proper way to set `error.name`: it should be inherited and non-enumerable
const setErrorName = (ErrorClass, value) => {
Object.defineProperty(ErrorClass.prototype, 'name', {
value,
writable: true,
enumerable: false,
configurable: true,
});
Object.defineProperty(ErrorClass.prototype, execaErrorSymbol, {
value: true,
writable: false,
enumerable: false,
configurable: false,
Object.defineProperties(ErrorClass.prototype, {
name: {
value,
writable: true,
enumerable: false,
configurable: true,
},
[execaErrorSymbol]: {
value: true,
writable: false,
enumerable: false,
configurable: false,
},
});
};

Expand All @@ -34,7 +36,9 @@ export const isErrorInstance = value => Object.prototype.toString.call(value) ==

// We use two different Error classes for async/sync methods since they have slightly different shape and types
export class ExecaError extends Error {}
// eslint-disable-next-line unicorn/no-top-level-side-effects -- `error.name` must be set as soon as the class is defined
setErrorName(ExecaError, ExecaError.name);

export class ExecaSyncError extends Error {}
// eslint-disable-next-line unicorn/no-top-level-side-effects -- `error.name` must be set as soon as the class is defined
setErrorName(ExecaSyncError, ExecaSyncError.name);
22 changes: 13 additions & 9 deletions lib/stdio/input-option.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ export const handleInputOptions = ({input, inputFile}, fdNumber) => fdNumber ===
]
: [];

const handleInputOption = input => input === undefined ? [] : [{
type: getInputType(input),
value: input,
optionName: 'input',
}];
const handleInputOption = input => input === undefined
? []
: [{
type: getInputType(input),
value: input,
optionName: 'input',
}];

const getInputType = input => {
if (isReadableStream(input, {checkOpen: false})) {
Expand All @@ -32,10 +34,12 @@ const getInputType = input => {
throw new Error('The `input` option must be a string, a Uint8Array or a Node.js Readable stream.');
};

const handleInputFileOption = inputFile => inputFile === undefined ? [] : [{
...getInputFileType(inputFile),
optionName: 'inputFile',
}];
const handleInputFileOption = inputFile => inputFile === undefined
? []
: [{
...getInputFileType(inputFile),
optionName: 'inputFile',
}];

const getInputFileType = inputFile => {
if (isUrl(inputFile)) {
Expand Down
17 changes: 6 additions & 11 deletions lib/transform/normalize.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,12 @@ const normalizeTransform = ({stdioItem, stdioItem: {type}, index, newTransforms,
});
};

const normalizeDuplex = ({
stdioItem,
stdioItem: {
value: {
transform,
transform: {writableObjectMode, readableObjectMode},
objectMode = readableObjectMode,
},
},
optionName,
}) => {
const normalizeDuplex = ({stdioItem, optionName}) => {
const {value} = stdioItem;
const {transform} = value;
const {writableObjectMode, readableObjectMode} = transform;
const {objectMode = readableObjectMode} = value;

if (objectMode && !readableObjectMode) {
throw new TypeError(`The \`${optionName}.objectMode\` option can only be \`true\` if \`new Duplex({objectMode: true})\` is used.`);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/utils/max-listeners.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {addAbortListener} from 'node:events';
// Temporarily increase the maximum number of listeners on an eventEmitter
export const incrementMaxListeners = (eventEmitter, maxListenersIncrement, signal) => {
const maxListeners = eventEmitter.getMaxListeners();
if (maxListeners === 0 || maxListeners === Number.POSITIVE_INFINITY) {
if (maxListeners === 0 || maxListeners === Infinity) {
return;
}

Expand Down
28 changes: 14 additions & 14 deletions lib/verbose/log.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,19 @@ export const verboseLog = ({type, verboseMessage, fdNumber, verboseInfo, result}
}
};

const getVerboseObject = ({
type,
result,
verboseInfo: {escapedCommand, commandId, rawOptions: {piped = false, ...options}},
}) => ({
type,
escapedCommand,
commandId: `${commandId}`,
timestamp: new Date(),
piped,
result,
options,
});
const getVerboseObject = ({type, result, verboseInfo}) => {
const {escapedCommand, commandId, rawOptions} = verboseInfo;
const {piped = false, ...options} = rawOptions;
return {
type,
escapedCommand,
commandId: `${commandId}`,
timestamp: new Date(),
piped,
result,
options,
};
};

const getPrintedLines = (verboseMessage, verboseObject) => verboseMessage
.split('\n')
Expand All @@ -47,7 +47,7 @@ const getPrintedLine = verboseObject => {
export const serializeVerboseMessage = message => {
const messageString = typeof message === 'string' ? message : inspect(message);
const escapedMessage = escapeLines(messageString);
return escapedMessage.replaceAll('\t', ' '.repeat(TAB_SIZE));
return escapedMessage.replaceAll('\t', () => ' '.repeat(TAB_SIZE));
};

// Same as `util.inspect()`
Expand Down
Loading
Loading