From c75e22e1b269850071adfa079ff45601c16819a5 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 2 Jul 2026 15:05:36 +0200 Subject: [PATCH] Add `{value: 'pipe', input: true}` to use an additional file descriptor as input For file descriptors beyond `stdin`/`stdout`/`stderr`, `'pipe'` is ambiguous: Execa cannot tell whether it is meant for input or output, so it defaults to output. This made it impossible to write into an extra file descriptor, e.g. `.duplex({to: 'fd3'})`, without the buggy `['pipe', []]` workaround. The new `{value: 'pipe', input: true}` object form explicitly marks such a file descriptor as input. Fixes #1214 --- .github/workflows/main.yml | 2 +- docs/api.md | 4 +- docs/input.md | 13 ++++++ docs/streams.md | 8 ++++ lib/arguments/fd-options.js | 3 +- lib/stdio/direction.js | 4 +- lib/stdio/duplicate.js | 5 ++- lib/stdio/handle-sync.js | 11 +++++ lib/stdio/handle.js | 30 ++++++++++--- lib/stdio/stdio-option.js | 17 +++++-- lib/stdio/type.js | 11 ++++- test-d/convert/writable.test-d.ts | 3 ++ test-d/return/result-stdio.test-d.ts | 26 +++++++++++ test-d/stdio/option/input-pipe.test-d.ts | 49 +++++++++++++++++++++ test-d/subprocess/stdio.test-d.ts | 26 +++++++++++ test/arguments/fd-options.js | 5 +++ test/convert/duplex.js | 8 +++- test/convert/writable.js | 4 +- test/io/max-buffer.js | 15 +++++++ test/stdio/direction.js | 56 ++++++++++++++++++++++++ types/return/ignore.d.ts | 10 +++-- types/stdio/direction.d.ts | 35 ++++++++++++++- types/stdio/type.d.ts | 10 ++++- 23 files changed, 332 insertions(+), 23 deletions(-) create mode 100644 test-d/stdio/option/input-pipe.test-d.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1495f96fa5..2e5a66d282 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,7 +30,7 @@ jobs: - run: npm install - 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 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' + args: --cache --verbose --no-progress --include-fragments --exclude packagephobia --exclude /pull/ --exclude depot.dev --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 diff --git a/docs/api.md b/docs/api.md index 0410c1831d..8fa6c8621c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -916,14 +916,14 @@ More info on [available values](output.md), [streaming](streams.md) and [transfo ### options.stdio _TypeScript:_ [`Options['stdio']`](typescript.md) or [`SyncOptions['stdio']`](typescript.md)\ -_Type:_ `string | Array | Iterable | Iterable | AsyncIterable | GeneratorFunction | AsyncGeneratorFunction | {transform: GeneratorFunction | AsyncGeneratorFunction | Duplex | TransformStream}>` (or a tuple of those types)\ +_Type:_ `string | Array | Iterable | Iterable | AsyncIterable | GeneratorFunction | AsyncGeneratorFunction | {transform: GeneratorFunction | AsyncGeneratorFunction | Duplex | TransformStream} | {value: 'pipe'; input?: boolean}>` (or a tuple of those types)\ _Default:_ `pipe` Like the [`stdin`](#optionsstdin), [`stdout`](#optionsstdout) and [`stderr`](#optionsstderr) options but for all [file descriptors](https://en.wikipedia.org/wiki/File_descriptor) at once. For example, `{stdio: ['ignore', 'pipe', 'pipe']}` is the same as `{stdin: 'ignore', stdout: 'pipe', stderr: 'pipe'}`. A single string can be used [as a shortcut](output.md#shortcut). -The array can have more than 3 items, to create [additional file descriptors](output.md#additional-file-descriptors) beyond [`stdin`](#optionsstdin)/[`stdout`](#optionsstdout)/[`stderr`](#optionsstderr). +The array can have more than 3 items, to create [additional file descriptors](output.md#additional-file-descriptors) beyond [`stdin`](#optionsstdin)/[`stdout`](#optionsstdout)/[`stderr`](#optionsstderr). Passing `'pipe'` to an additional file descriptor defaults it to output. To use it for [input](input.md#additional-file-descriptors) instead, pass `{value: 'pipe', input: true}`. More info on [available values](output.md), [streaming](streams.md) and [transforms](transform.md). diff --git a/docs/input.md b/docs/input.md index 6d6c7bb296..2cec45e2dc 100644 --- a/docs/input.md +++ b/docs/input.md @@ -135,6 +135,19 @@ await execa({ })`npm run build`; ``` +When passing `'pipe'` to an additional file descriptor, Execa cannot tell whether it is meant for input or output, so it defaults to output. To use it for input instead, such as when [streaming](streams.md#different-file-descriptor) to it, pass `{value: 'pipe', input: true}`. + +```js +import {pipeline} from 'node:stream/promises'; + +// Stream input to the file descriptor number 3 +const subprocess = execa({ + stdio: ['pipe', 'pipe', 'pipe', {value: 'pipe', input: true}], +})`npm run build`; + +await pipeline(inputStream, subprocess.writable({to: 'fd3'})); +``` +
[**Next**: 📢 Output](output.md)\ diff --git a/docs/streams.md b/docs/streams.md index a86a2bcf84..1faaf0e4dc 100644 --- a/docs/streams.md +++ b/docs/streams.md @@ -104,6 +104,14 @@ const writable = execa`npm run scaffold`.writable({to: 'fd3'}); const duplex = execa`npm run scaffold`.duplex({from: 'stderr', to: 'fd3'}); ``` +Writing to an additional file descriptor like `'fd3'` requires marking it as [input](input.md#additional-file-descriptors), since it otherwise defaults to output. + +```js +const subprocess = execa({stdio: ['pipe', 'pipe', 'pipe', {value: 'pipe', input: true}]})`npm run scaffold`; + +const writable = subprocess.writable({to: 'fd3'}); +``` + ### Error handling When using [`subprocess.readable()`](api.md#subprocessreadablereadableoptions), [`subprocess.writable()`](api.md#subprocesswritablewritableoptions) or [`subprocess.duplex()`](api.md#subprocessduplexduplexoptions), the stream waits for the subprocess to end, and emits an [`error`](https://nodejs.org/api/stream.html#event-error) event if the subprocess [fails](errors.md). This differs from [`subprocess.stdin`](api.md#subprocessstdin), [`subprocess.stdout`](api.md#subprocessstdout) and [`subprocess.stderr`](api.md#subprocessstderr)'s behavior. diff --git a/lib/arguments/fd-options.js b/lib/arguments/fd-options.js index cd0e49d7fa..6365e0b1e4 100644 --- a/lib/arguments/fd-options.js +++ b/lib/arguments/fd-options.js @@ -63,7 +63,8 @@ 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 writable stream, not readable.`); + 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}\`.`); } }; diff --git a/lib/stdio/direction.js b/lib/stdio/direction.js index 57c18c261d..236b15034b 100644 --- a/lib/stdio/direction.js +++ b/lib/stdio/direction.js @@ -20,7 +20,9 @@ export const getStreamDirection = (stdioItems, fdNumber, optionName) => { return directions.find(Boolean) ?? DEFAULT_DIRECTION; }; -const getStdioItemDirection = ({type, value}, fdNumber) => KNOWN_DIRECTIONS[fdNumber] ?? guessStreamDirection[type](value); +// `direction` is set when the user explicitly requests it, e.g. `{value: 'pipe', input: true}`. +// It takes precedence over guessing, but not over the fixed direction of `stdin`/`stdout`/`stderr`. +const getStdioItemDirection = ({type, value, direction}, fdNumber) => KNOWN_DIRECTIONS[fdNumber] ?? direction ?? guessStreamDirection[type](value); // `stdin`/`stdout`/`stderr` have a known direction const KNOWN_DIRECTIONS = ['input', 'output', 'output']; diff --git a/lib/stdio/duplicate.js b/lib/stdio/duplicate.js index 7f5b9a45bd..3ccfa667a3 100644 --- a/lib/stdio/duplicate.js +++ b/lib/stdio/duplicate.js @@ -8,11 +8,14 @@ import { // Duplicates in the same file descriptor is most likely an error. // However, this can be useful with generators. export const filterDuplicates = stdioItems => stdioItems.filter((stdioItemOne, indexOne) => - stdioItems.every((stdioItemTwo, indexTwo) => stdioItemOne.value !== stdioItemTwo.value + stdioItems.every((stdioItemTwo, indexTwo) => !hasSameValueAndDirection(stdioItemOne, stdioItemTwo) || indexOne >= indexTwo || stdioItemOne.type === 'generator' || stdioItemOne.type === 'asyncGenerator')); +const hasSameValueAndDirection = (stdioItemOne, stdioItemTwo) => stdioItemOne.value === stdioItemTwo.value + && stdioItemOne.direction === stdioItemTwo.direction; + // Check if two file descriptors are sharing the same target. // For example `{stdout: {file: './output.txt'}, stderr: {file: './output.txt'}}`. export const getDuplicateStream = ({stdioItem: {type, value, optionName}, direction, fileDescriptors, isSync}) => { diff --git a/lib/stdio/handle-sync.js b/lib/stdio/handle-sync.js index 57f62c4dff..ca32202812 100644 --- a/lib/stdio/handle-sync.js +++ b/lib/stdio/handle-sync.js @@ -18,6 +18,16 @@ const forbiddenNativeIfSync = ({optionName, value}) => { return {}; }; +// A native input pipe on an additional file descriptor cannot be fed in sync mode, since there is no way to write to it after spawning, so it would hang. +const forbiddenNativeInputIfSync = stdioItem => { + const {optionName, value} = stdioItem; + if (value === 'pipe' && optionName !== 'stdin') { + throw new TypeError(`Only the \`stdin\` option, not \`${optionName}\`, can be an input pipe with synchronous methods.`); + } + + return forbiddenNativeIfSync(stdioItem); +}; + const throwInvalidSyncValue = (optionName, value) => { throw new TypeError(`The \`${optionName}\` option cannot be ${value} with synchronous methods.`); }; @@ -38,6 +48,7 @@ const addProperties = { const addPropertiesSync = { input: { ...addProperties, + native: forbiddenNativeInputIfSync, fileUrl: ({value}) => ({contents: [bufferToUint8Array(readFileSync(value))]}), filePath: ({value: {file}}) => ({contents: [bufferToUint8Array(readFileSync(file))]}), fileNumber: forbiddenIfSync, diff --git a/lib/stdio/handle.js b/lib/stdio/handle.js index 21ae627ca0..dced4ea13b 100644 --- a/lib/stdio/handle.js +++ b/lib/stdio/handle.js @@ -5,6 +5,8 @@ import { getStdioItemType, isRegularUrl, isUnknownStdioString, + isPipeInputObject, + checkBooleanOption, FILE_TYPES, } from './type.js'; import {getStreamDirection} from './direction.js'; @@ -82,11 +84,29 @@ const isInheritedStdinOnly = stdioItems => stdioItems.length === 1 && stdioItems[0].type === 'native' && stdioItems[0].value === 'inherit'; -const initializeStdioItem = (value, optionName) => ({ - type: getStdioItemType(value, optionName), - value, - optionName, -}); +const initializeStdioItem = (value, optionName) => { + if (isPipeInputObject(value)) { + return initializePipeInputItem(value, optionName); + } + + return { + type: getStdioItemType(value, optionName), + value, + optionName, + }; +}; + +// The `{value: 'pipe', input: true}` object form is a native pipe, plus an explicit input direction. +const initializePipeInputItem = ({value, input}, optionName) => { + checkBooleanOption(input, `${optionName}.input`); + + return { + type: 'native', + value, + direction: input ? 'input' : undefined, + optionName, + }; +}; const validateStdioArray = (stdioItems, isStdioArray, optionName) => { if (stdioItems.length === 0) { diff --git a/lib/stdio/stdio-option.js b/lib/stdio/stdio-option.js index 3734f7b7e1..83da9be51d 100644 --- a/lib/stdio/stdio-option.js +++ b/lib/stdio/stdio-option.js @@ -1,6 +1,7 @@ import {STANDARD_STREAMS_ALIASES} from '../utils/standard-stream.js'; import {normalizeIpcStdioArray} from '../ipc/array.js'; import {isFullVerbose} from '../verbose/values.js'; +import {isPipeInputObject} from './type.js'; // Add support for `stdin`/`stdout`/`stderr` as an alias for `stdio`. // Also normalize the `stdio` option. @@ -60,9 +61,19 @@ const normalizeStdioSync = (stdioArray, buffer, verboseInfo) => stdioArray.map(( !buffer[fdNumber] && fdNumber !== 0 && !isFullVerbose(verboseInfo, fdNumber) - && isOutputPipeOnly(stdioOption) + && isOutputPipeOnly(stdioOption, fdNumber) ? 'ignore' : stdioOption); -const isOutputPipeOnly = stdioOption => stdioOption === 'pipe' - || (Array.isArray(stdioOption) && stdioOption.every(item => item === 'pipe')); +const isOutputPipeOnly = (stdioOption, fdNumber) => isOutputPipe(stdioOption, fdNumber) + || (Array.isArray(stdioOption) && stdioOption.every(item => isOutputPipe(item, fdNumber))); + +const isOutputPipe = (stdioOption, fdNumber) => stdioOption === 'pipe' + || isOutputPipeObject(stdioOption, fdNumber); + +const isOutputPipeObject = (stdioOption, fdNumber) => isPipeInputObject(stdioOption) + && stdioOption.value === 'pipe' + && (stdioOption.input === undefined || stdioOption.input === false || isFixedOutputPipe(fdNumber, stdioOption.input)); + +// On `stdout`/`stderr`, the direction is always output, so `input: true` is ignored and the pipe stays output. +const isFixedOutputPipe = (fdNumber, input) => input === true && (fdNumber === 1 || fdNumber === 2); diff --git a/lib/stdio/type.js b/lib/stdio/type.js index eb9dfcf146..defe84db5e 100644 --- a/lib/stdio/type.js +++ b/lib/stdio/type.js @@ -108,7 +108,7 @@ const getGeneratorObjectType = ({transform, final, binary, objectMode}, optionNa return isAsyncGenerator(transform) || isAsyncGenerator(final) ? 'asyncGenerator' : 'generator'; }; -const checkBooleanOption = (value, optionName) => { +export const checkBooleanOption = (value, optionName) => { if (value !== undefined && typeof value !== 'boolean') { throw new TypeError(`The \`${optionName}\` option must use a boolean.`); } @@ -130,6 +130,15 @@ const isFilePathObject = value => isPlainObj(value) const FILE_PATH_KEYS = new Set(['file', 'append']); export const isFilePathString = file => typeof file === 'string'; +// The `{value: 'pipe', input: true}` object form marks an additional file descriptor as input instead of output. +// This is only needed for `'pipe'`/`'overlapped'` on fd3+, whose direction is otherwise ambiguous and defaults to output. +export const isPipeInputObject = value => isPlainObj(value) + && Object.keys(value).length > 0 + && Object.keys(value).every(key => PIPE_INPUT_KEYS.has(key)) + && NATIVE_PIPE_VALUES.has(value.value); +const PIPE_INPUT_KEYS = new Set(['value', 'input']); +const NATIVE_PIPE_VALUES = new Set(['pipe', 'overlapped']); + export const isUnknownStdioString = (type, value) => type === 'native' && typeof value === 'string' && !KNOWN_STDIO_STRINGS.has(value); diff --git a/test-d/convert/writable.test-d.ts b/test-d/convert/writable.test-d.ts index e6c26bd052..631cee3711 100644 --- a/test-d/convert/writable.test-d.ts +++ b/test-d/convert/writable.test-d.ts @@ -20,3 +20,6 @@ expectError(subprocess.writable({preserveNewlines: false})); expectError(subprocess.writable('stdin')); expectError(subprocess.writable({other: 'stdin'})); + +const inputPipeSubprocess = execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 'pipe', input: true}]}); +inputPipeSubprocess.writable({to: 'fd3'}); diff --git a/test-d/return/result-stdio.test-d.ts b/test-d/return/result-stdio.test-d.ts index db5235ff1c..102f37a604 100644 --- a/test-d/return/result-stdio.test-d.ts +++ b/test-d/return/result-stdio.test-d.ts @@ -111,6 +111,32 @@ expectType(fd3Result.stdio[3]); const inputFd3Result = await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', ['pipe', new Readable()]]}); expectType(inputFd3Result.stdio[3]); +const inputPipeFd3Result = await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 'pipe', input: true}]}); +expectType(inputPipeFd3Result.stdio[3]); + +const outputPipeFd3Result = await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 'pipe'}]}); +expectType(outputPipeFd3Result.stdio[3]); + +const input = true as boolean; +const booleanInputPipeFd3Result = await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 'pipe', input}]}); +expectType(booleanInputPipeFd3Result.stdio[3]); + +const optionalInputPipe: {readonly value: 'pipe'; readonly input?: boolean} = {value: 'pipe', input}; +const optionalInputPipeFd3Result = await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', optionalInputPipe]}); +expectType(optionalInputPipeFd3Result.stdio[3]); + +const optionalTrueInputPipe: {readonly value: 'pipe'; readonly input?: true} = {value: 'pipe', input: true}; +const optionalTrueInputPipeFd3Result = await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', optionalTrueInputPipe]}); +expectType(optionalTrueInputPipeFd3Result.stdio[3]); + +const stdoutInputPipeResult = await execa('unicorns', {stdout: {value: 'pipe', input: true}}); +expectType(stdoutInputPipeResult.stdout); +expectType(stdoutInputPipeResult.stdio[1]); + +const stderrInputPipeResult = await execa('unicorns', {stderr: {value: 'pipe', input: true}}); +expectType(stderrInputPipeResult.stderr); +expectType(stderrInputPipeResult.stdio[2]); + const outputFd3Result = await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', ['pipe', new Writable()]]}); expectType(outputFd3Result.stdio[3]); diff --git a/test-d/stdio/option/input-pipe.test-d.ts b/test-d/stdio/option/input-pipe.test-d.ts new file mode 100644 index 0000000000..8e484abf21 --- /dev/null +++ b/test-d/stdio/option/input-pipe.test-d.ts @@ -0,0 +1,49 @@ +import {expectError, expectAssignable} from 'tsd'; +import { + execa, + execaSync, + type StdinOption, + type StdinSyncOption, + type StdoutStderrOption, + type StdoutStderrSyncOption, +} from '../../../index.js'; + +const inputPipe = {value: 'pipe', input: true} as const; +const input = true as boolean; +const booleanInputPipe = {value: 'pipe', input} as const; + +await execa('unicorns', {stdin: inputPipe}); +execaSync('unicorns', {stdin: inputPipe}); +await execa('unicorns', {stdout: inputPipe}); +execaSync('unicorns', {stdout: inputPipe}); +await execa('unicorns', {stderr: inputPipe}); +execaSync('unicorns', {stderr: inputPipe}); + +await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', inputPipe]}); +expectError(execaSync('unicorns', {stdio: ['pipe', 'pipe', 'pipe', inputPipe]})); +await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', booleanInputPipe]}); +expectError(execaSync('unicorns', {stdio: ['pipe', 'pipe', 'pipe', booleanInputPipe]})); + +await execa('unicorns', {stdin: {value: 'pipe'}}); +await execa('unicorns', {stdin: {value: 'overlapped', input: true}}); +expectError(execaSync('unicorns', {stdin: {value: 'overlapped', input: true}})); + +expectError(await execa('unicorns', {stdin: {value: 'other', input: true}})); +expectError(await execa('unicorns', {stdin: {value: 'pipe', input: 'true'}})); + +expectAssignable(inputPipe); +expectAssignable(booleanInputPipe); +expectAssignable(inputPipe); +expectAssignable(booleanInputPipe); +expectAssignable(inputPipe); +expectAssignable(booleanInputPipe); +expectAssignable(inputPipe); +expectAssignable(booleanInputPipe); +expectAssignable([inputPipe]); +expectAssignable([booleanInputPipe]); +expectAssignable([inputPipe]); +expectAssignable([booleanInputPipe]); +expectAssignable([inputPipe]); +expectAssignable([booleanInputPipe]); +expectAssignable([inputPipe]); +expectAssignable([booleanInputPipe]); diff --git a/test-d/subprocess/stdio.test-d.ts b/test-d/subprocess/stdio.test-d.ts index 0da8e1f5c7..f69e663e75 100644 --- a/test-d/subprocess/stdio.test-d.ts +++ b/test-d/subprocess/stdio.test-d.ts @@ -39,3 +39,29 @@ expectType(multipleStdoutSubprocess.stderr); expectType(multipleStdoutSubprocess.stdio[2]); expectType(multipleStdoutSubprocess.all); expectError(multipleStdoutSubprocess.stdio[3].destroy()); + +const inputPipeSubprocess = execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 'pipe', input: true}]}); +expectType(inputPipeSubprocess.stdio[3]); + +const outputPipeSubprocess = execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 'pipe'}]}); +expectType(outputPipeSubprocess.stdio[3]); + +const input = true as boolean; +const booleanInputPipeSubprocess = execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 'pipe', input}]}); +expectType(booleanInputPipeSubprocess.stdio[3]); + +const optionalInputPipe: {readonly value: 'pipe'; readonly input?: boolean} = {value: 'pipe', input}; +const optionalInputPipeSubprocess = execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', optionalInputPipe]}); +expectType(optionalInputPipeSubprocess.stdio[3]); + +const optionalTrueInputPipe: {readonly value: 'pipe'; readonly input?: true} = {value: 'pipe', input: true}; +const optionalTrueInputPipeSubprocess = execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', optionalTrueInputPipe]}); +expectType(optionalTrueInputPipeSubprocess.stdio[3]); + +const stdoutInputPipeSubprocess = execa('unicorns', {stdout: {value: 'pipe', input: true}}); +expectType(stdoutInputPipeSubprocess.stdout); +expectType(stdoutInputPipeSubprocess.stdio[1]); + +const stderrInputPipeSubprocess = execa('unicorns', {stderr: {value: 'pipe', input: true}}); +expectType(stderrInputPipeSubprocess.stderr); +expectType(stderrInputPipeSubprocess.stdio[2]); diff --git a/test/arguments/fd-options.js b/test/arguments/fd-options.js index 50bcfe0c34..8466c5c1fa 100644 --- a/test/arguments/fd-options.js +++ b/test/arguments/fd-options.js @@ -269,6 +269,11 @@ test('.duplex() "to" option cannot be an output file descriptor', testNodeStream to: 'fd3', message: 'must be a writable stream', }); +test('.duplex() "to" option on an output file descriptor suggests the input-pipe syntax', testNodeStream, { + sourceOptions: fullStdio, + to: 'fd3', + message: '{value: \'pipe\', input: true}', +}); test('.pipe() "to" option cannot be "all"', testPipeError, { destinationOptions: fullStdio, to: 'all', diff --git a/test/convert/duplex.js b/test/convert/duplex.js index d8ad6ef62e..23deeaf002 100644 --- a/test/convert/duplex.js +++ b/test/convert/duplex.js @@ -24,7 +24,12 @@ import { } from '../helpers/convert.js'; import {foobarString} from '../helpers/input.js'; import {majorNodeVersion} from '../helpers/node-version.js'; -import {prematureClose, fullStdio, fullReadableStdio} from '../helpers/stdio.js'; +import { + getStdio, + prematureClose, + fullStdio, + fullReadableStdio, +} from '../helpers/stdio.js'; import {defaultHighWaterMark} from '../helpers/stream.js'; setFixtureDirectory(); @@ -77,6 +82,7 @@ const testWritableDuplexDefault = async (t, fdNumber, to, options) => { test('.duplex() can use stdin', testWritableDuplexDefault, 0, 'stdin', {}); test('.duplex() can use input stdio[*]', testWritableDuplexDefault, 3, 'fd3', fullReadableStdio()); +test('.duplex() can use input stdio[*] with { value: "pipe", input: true }', testWritableDuplexDefault, 3, 'fd3', getStdio(3, {value: 'pipe', input: true})); test('.duplex() uses stdin by default', testWritableDuplexDefault, 0, undefined, {}); test('.duplex() abort -> subprocess fail', async t => { diff --git a/test/convert/writable.js b/test/convert/writable.js index 8df45fbeb3..3ea1847528 100644 --- a/test/convert/writable.js +++ b/test/convert/writable.js @@ -26,7 +26,7 @@ import { foobarObject, foobarObjectString, } from '../helpers/input.js'; -import {prematureClose, fullReadableStdio} from '../helpers/stdio.js'; +import {getStdio, prematureClose, fullReadableStdio} from '../helpers/stdio.js'; import { throwingGenerator, serializeGenerator, @@ -63,6 +63,8 @@ const testWritableDefault = async (t, fdNumber, to, options) => { test('.writable() can use stdin', testWritableDefault, 0, 'stdin', {}); test('.writable() can use stdio[*]', testWritableDefault, 3, 'fd3', fullReadableStdio()); +test('.writable() can use stdio[*] with { value: "pipe", input: true }', testWritableDefault, 3, 'fd3', getStdio(3, {value: 'pipe', input: true})); +test('.writable() can use stdio[*] with [{ value: "pipe", input: true }, "pipe"]', testWritableDefault, 3, 'fd3', getStdio(3, [{value: 'pipe', input: true}, 'pipe'])); test('.writable() uses stdin by default', testWritableDefault, 0, undefined, {}); test('.writable() hangs until ended', async t => { diff --git a/test/io/max-buffer.js b/test/io/max-buffer.js index 737881d3b9..47db8d0ed6 100644 --- a/test/io/max-buffer.js +++ b/test/io/max-buffer.js @@ -267,6 +267,21 @@ test('do not buffer stdout when `buffer` set to `false`, fd-specific, sync', tes test('do not buffer stderr when `buffer` set to `false`, sync', testNoMaxBufferSync, 2, false); test('do not buffer stderr when `buffer` set to `false`, fd-specific, sync', testNoMaxBufferSync, 2, {stderr: false}); +const testNoMaxBufferSyncObjectPipe = (t, fdNumber, fdName, stdioOption) => { + const {isMaxBuffer, stdio} = execaSync('max-buffer.js', [`${fdNumber}`, `${maxBuffer + 1}`], { + [fdName]: stdioOption, + buffer: false, + maxBuffer, + }); + t.false(isMaxBuffer); + t.is(stdio[fdNumber], undefined); +}; + +test('do not buffer stdout object pipe when `buffer` set to `false`, sync', testNoMaxBufferSyncObjectPipe, 1, 'stdout', {value: 'pipe'}); +test('do not buffer stdout object pipe with input false when `buffer` set to `false`, sync', testNoMaxBufferSyncObjectPipe, 1, 'stdout', {value: 'pipe', input: false}); +test('do not buffer stdout object pipe with input true when `buffer` set to `false`, sync', testNoMaxBufferSyncObjectPipe, 1, 'stdout', {value: 'pipe', input: true}); +test('do not buffer stderr object pipe with input true when `buffer` set to `false`, sync', testNoMaxBufferSyncObjectPipe, 2, 'stderr', {value: 'pipe', input: true}); + const testMaxBufferAbort = async (t, fdNumber) => { const subprocess = getMaxBufferSubprocess(execa, fdNumber); const [{isMaxBuffer, shortMessage}] = await Promise.all([ diff --git a/test/stdio/direction.js b/test/stdio/direction.js index 905bfb2eda..e686575ab3 100644 --- a/test/stdio/direction.js +++ b/test/stdio/direction.js @@ -49,3 +49,59 @@ const testAmbiguousMultiple = async (t, fdNumber) => { test('stdin ambiguous direction is influenced by other values', testAmbiguousMultiple, 0); test('stdio[*] ambiguous direction is influenced by other values', testAmbiguousMultiple, 3); + +const testDirectionInputPipe = async (t, stdioOption) => { + const subprocess = execa('stdin-fd.js', ['3'], getStdio(3, stdioOption)); + subprocess.stdio[3].end(foobarString); + const {stdout} = await subprocess; + t.is(stdout, foobarString); +}; + +test('stdio[*] { value: "pipe", input: true } sets the direction to input', testDirectionInputPipe, {value: 'pipe', input: true}); +test('stdio[*] { value: "overlapped", input: true } sets the direction to input', testDirectionInputPipe, {value: 'overlapped', input: true}); + +test('stdio[*] { value: "pipe", input: true } cannot be used in sync mode', t => { + t.throws(() => { + execaSync('empty.js', getStdio(3, {value: 'pipe', input: true})); + }, {message: /can be an input pipe with synchronous methods/}); +}); + +test('stdio[*] { value: "pipe", input: true } cannot be used in sync mode with buffer false', t => { + t.throws(() => { + execaSync('empty.js', {...getStdio(3, {value: 'pipe', input: true}), buffer: false}); + }, {message: /can be an input pipe with synchronous methods/}); +}); + +const testDirectionOutputPipe = async (t, stdioOption, execaMethod) => { + const {stdio} = await execaMethod('noop-fd.js', ['3', foobarString], getStdio(3, stdioOption)); + t.is(stdio[3], foobarString); +}; + +test('stdio[*] { value: "pipe" } keeps the default output direction', testDirectionOutputPipe, {value: 'pipe'}, execa); +test('stdio[*] { value: "pipe" } keeps the default output direction - sync', testDirectionOutputPipe, {value: 'pipe'}, execaSync); +test('stdio[*] { value: "pipe", input: false } keeps the default output direction', testDirectionOutputPipe, {value: 'pipe', input: false}, execa); +test('stdio[*] { value: "pipe", input: false } keeps the default output direction - sync', testDirectionOutputPipe, {value: 'pipe', input: false}, execaSync); + +const testDirectionConflict = (t, execaMethod) => { + t.throws(() => { + execaMethod('empty.js', getStdio(3, [{value: 'pipe', input: true}, 1])); + }, {message: /readable and writable/}); +}; + +test('Cannot pass { value: "pipe", input: true } with a writable value to stdio[*]', testDirectionConflict, execa); +test('Cannot pass { value: "pipe", input: true } with a writable value to stdio[*] - sync', testDirectionConflict, execaSync); + +const testInputBoolean = (t, execaMethod) => { + t.throws(() => { + execaMethod('empty.js', getStdio(3, {value: 'pipe', input: 'yes'})); + }, {message: /`stdio\[3\]\.input` option must use a boolean/}); +}; + +test('stdio[*].input must be a boolean', testInputBoolean, execa); +test('stdio[*].input must be a boolean - sync', testInputBoolean, execaSync); + +test('stdout.input must be a boolean with buffer false - sync', t => { + t.throws(() => { + execaSync('empty.js', {stdout: {value: 'pipe', input: 'yes'}, buffer: false}); + }, {message: /`stdout\.input` option must use a boolean/}); +}); diff --git a/types/return/ignore.d.ts b/types/return/ignore.d.ts index e423acb4bd..8a043651c0 100644 --- a/types/return/ignore.d.ts +++ b/types/return/ignore.d.ts @@ -10,9 +10,13 @@ export type IgnoresResultOutput< OptionsType extends CommonOptions, > = FdSpecificOption extends false ? true - : IsInputFd extends true - ? true - : IgnoresSubprocessOutput; + : IgnoresResultOutputDirection, FdNumber, OptionsType>; + +type IgnoresResultOutputDirection< + IsInput extends boolean, + FdNumber extends string, + OptionsType extends CommonOptions, +> = IsInput extends true ? true : IgnoresSubprocessOutput; // Whether `subprocess.stdout|stderr|all` is `undefined|null` export type IgnoresSubprocessOutput< diff --git a/types/stdio/direction.d.ts b/types/stdio/direction.d.ts index 3fb4110c36..427d1fbc49 100644 --- a/types/stdio/direction.d.ts +++ b/types/stdio/direction.d.ts @@ -1,14 +1,45 @@ import type {CommonOptions} from '../arguments/options.js'; import type {Intersects} from '../utils.js'; -import type {StdioSingleOptionItems, InputStdioOption} from './type.js'; +import type {StdioSingleOptionItems, InputStdioOption, NativePipeStdioOption} from './type.js'; import type {FdStdioArrayOption} from './option.js'; +type AnyNativePipeStdioOption = NativePipeStdioOption; + +type InputNativePipeStdioOption = AnyNativePipeStdioOption & { + readonly input: true; +}; + +type NativePipeInputValue = StdioOptionType extends AnyNativePipeStdioOption + ? NativePipeInputProperty + : never; + +type NativePipeInputProperty = 'input' extends keyof StdioOptionType + ? StdioOptionType extends {readonly input?: infer Input} ? Input : never + : never; + // Whether `result.stdio[FdNumber]` is an input stream export type IsInputFd< FdNumber extends string, OptionsType extends CommonOptions, > = FdNumber extends '0' ? true - : Intersects>, InputStdioOption>; + : IsInputFdOption< + FdNumber, + StdioSingleOptionItems> + >; + +type IsInputFdOption< + FdNumber extends string, + StdioOptionType, +> = Intersects< + StdioOptionType, + FdNumber extends '1' | '2' ? InputStdioOption : InputStdioOption | InputNativePipeStdioOption +> extends true + ? true + : FdNumber extends '1' | '2' + ? false + : true extends NativePipeInputValue + ? boolean + : false; export {}; diff --git a/types/stdio/type.d.ts b/types/stdio/type.d.ts index e85c7b97e4..a530e2fc8a 100644 --- a/types/stdio/type.d.ts +++ b/types/stdio/type.d.ts @@ -39,13 +39,21 @@ type SimpleStdioOption< | Unless | Unless; +export type NativePipeStdioOption< + IsSync extends boolean, + IsExtra extends boolean, +> = { + readonly value: 'pipe' | Unless; + readonly input?: Unless, boolean> | false; +}; + // Values available in both `options.stdin|stdio` and `options.stdout|stderr|stdio` type CommonStdioOption< IsSync extends boolean, IsExtra extends boolean, IsArray extends boolean, > = - SimpleStdioOption | URL | GeneratorTransform | GeneratorTransformFull | Unless, IsArray>, 3 | 4 | 5 | 6 | 7 | 8 | 9> | Unless | {readonly file: string; readonly append?: boolean}; + SimpleStdioOption | URL | GeneratorTransform | GeneratorTransformFull | Unless, IsArray>, 3 | 4 | 5 | 6 | 7 | 8 | 9> | Unless | NativePipeStdioOption | {readonly file: string; readonly append?: boolean}; // Synchronous iterables excluding strings, Uint8Arrays and Arrays type IterableObject = Iterable