Repository: sindresorhus/execa
Pull request #666 (2024-01-09 17:45:05)

Allow using the `input`, `inputFile` and `stdin` options together
This PR allows combining the `input`, `inputFile` and `stdin` options. For example `{ input: 'myInput', stdin: 'inherit' }`.


Patch:
diff --git a/index.d.ts b/index.d.ts
index 132f264..9175f5a 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -119,7 +119,7 @@ export type CommonOptions<EncodingType extends EncodingOption = DefaultEncodingO
 	- an integer: Re-use a specific file descriptor from the current process.
 	- a Node.js `Readable` stream.
 
-	Unless either the synchronous methods, the `input` option or the `inputFile` option is used, the value can also be a:
+	Unless the synchronous methods are used, the value can also be a:
 	- file path. If relative, it must start with `.`.
 	- file URL.
 	- web [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream).
@@ -141,7 +141,7 @@ export type CommonOptions<EncodingType extends EncodingOption = DefaultEncodingO
 	- an integer: Re-use a specific file descriptor from the current process.
 	- a Node.js `Writable` stream.
 
-	Unless either synchronous methods, the value can also be a:
+	Unless the synchronous methods are used, the value can also be a:
 	- file path. If relative, it must start with `.`.
 	- file URL.
 	- web [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream).
@@ -162,7 +162,7 @@ export type CommonOptions<EncodingType extends EncodingOption = DefaultEncodingO
 	- an integer: Re-use a specific file descriptor from the current process.
 	- a Node.js `Writable` stream.
 
-	Unless either synchronous methods, the value can also be a:
+	Unless the synchronous methods are used, the value can also be a:
 	- file path. If relative, it must start with `.`.
 	- file URL.
 	- web [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream).
diff --git a/lib/stdio/async.js b/lib/stdio/async.js
index 08b14ad..f09079b 100644
--- a/lib/stdio/async.js
+++ b/lib/stdio/async.js
@@ -1,4 +1,5 @@
 import {createReadStream, createWriteStream} from 'node:fs';
+import {Buffer} from 'node:buffer';
 import {Readable, Writable} from 'node:stream';
 import mergeStreams from '@sindresorhus/merge-streams';
 import {handleInput} from './handle.js';
@@ -11,6 +12,7 @@ const addPropertiesAsync = {
 		filePath: ({value}) => ({value: createReadStream(value)}),
 		webStream: ({value}) => ({value: Readable.fromWeb(value)}),
 		iterable: ({value}) => ({value: Readable.from(value)}),
+		stringOrBuffer: ({value}) => ({value: Readable.from(ArrayBuffer.isView(value) ? Buffer.from(value) : value)}),
 	},
 	output: {
 		filePath: ({value}) => ({value: createWriteStream(value)}),
@@ -43,13 +45,7 @@ const pipeStdioOption = (childStream, {type, value, direction, index}, inputStre
 
 	if (direction === 'output') {
 		childStream.pipe(value);
-		return;
+	} else {
+		inputStreamsGroups[index] = [...(inputStreamsGroups[index] ?? []), value];
 	}
-
-	if (type === 'stringOrBuffer') {
-		childStream.end(value);
-		return;
-	}
-
-	inputStreamsGroups[index] = [...(inputStreamsGroups[index] ?? []), value];
 };
diff --git a/lib/stdio/handle.js b/lib/stdio/handle.js
index ef7f210..194a438 100644
--- a/lib/stdio/handle.js
+++ b/lib/stdio/handle.js
@@ -1,14 +1,15 @@
 import {getStdioOptionType, isRegularUrl, isUnknownStdioString} from './type.js';
 import {addStreamDirection} from './direction.js';
 import {normalizeStdio} from './normalize.js';
-import {handleInputOption, handleInputFileOption} from './input.js';
 import {handleNativeStream} from './native.js';
+import {handleInputOptions} from './input.js';
 
 // Handle `input`, `inputFile`, `stdin`, `stdout` and `stderr` options, before spawning, in async/sync mode
 export const handleInput = (addProperties, options) => {
 	const stdio = normalizeStdio(options);
-	const stdioStreamsGroups = stdio
-		.map((stdioOption, index) => getStdioStreams(stdioOption, index, options))
+	const [stdinStreams, ...otherStreamsGroups] = stdio.map((stdioOption, index) => getStdioStreams(stdioOption, index));
+	const stdioStreamsGroups = [[...stdinStreams, ...handleInputOptions(options)], ...otherStreamsGroups]
+		.map(stdioStreams => validateStreams(stdioStreams))
 		.map(stdioStreams => addStreamDirection(stdioStreams))
 		.map(stdioStreams => addStreamsProperties(stdioStreams, addProperties));
 	options.stdio = transformStdio(stdioStreamsGroups);
@@ -18,12 +19,12 @@ export const handleInput = (addProperties, options) => {
 // We make sure passing an array with a single item behaves the same as passing that item without an array.
 // This is what users would expect.
 // For example, `stdout: ['ignore']` behaves the same as `stdout: 'ignore'`.
-const getStdioStreams = (stdioOption, index, options) => {
+const getStdioStreams = (stdioOption, index) => {
 	const optionName = getOptionName(index);
 	const stdioOptions = Array.isArray(stdioOption) ? [...new Set(stdioOption)] : [stdioOption];
 	const isStdioArray = stdioOptions.length > 1;
 	validateStdioArray(stdioOptions, isStdioArray, optionName);
-	return stdioOptions.map(stdioOption => getStdioStream({stdioOption, optionName, index, isStdioArray, options}));
+	return stdioOptions.map(stdioOption => getStdioStream({stdioOption, optionName, index, isStdioArray}));
 };
 
 const getOptionName = index => KNOWN_OPTION_NAMES[index] ?? `stdio[${index}]`;
@@ -49,17 +50,18 @@ const validateStdioArray = (stdioOptions, isStdioArray, optionName) => {
 // However, we do allow it if the array has a single item.
 const INVALID_STDIO_ARRAY_OPTIONS = ['ignore', 'ipc'];
 
-const getStdioStream = ({stdioOption, optionName, index, isStdioArray, options: {input, inputFile}}) => {
+const getStdioStream = ({stdioOption, optionName, index, isStdioArray}) => {
 	const type = getStdioOptionType(stdioOption);
-	let stdioStream = {type, value: stdioOption, optionName, index};
-
-	stdioStream = handleInputOption(stdioStream, input);
-	stdioStream = handleInputFileOption(stdioStream, inputFile, input);
-	stdioStream = handleNativeStream(stdioStream, isStdioArray);
+	const stdioStream = {type, value: stdioOption, optionName, index};
+	return handleNativeStream(stdioStream, isStdioArray);
+};
 
-	validateFileStdio(stdioStream);
+const validateStreams = stdioStreams => {
+	for (const stdioStream of stdioStreams) {
+		validateFileStdio(stdioStream);
+	}
 
-	return stdioStream;
+	return stdioStreams;
 };
 
 const validateFileStdio = ({type, value, optionName}) => {
diff --git a/lib/stdio/input.js b/lib/stdio/input.js
index a3aa944..b94ff6f 100644
--- a/lib/stdio/input.js
+++ b/lib/stdio/input.js
@@ -1,36 +1,21 @@
 import {isStream as isNodeStream} from 'is-stream';
 
-// Override the `stdin` option with the `input` option
-export const handleInputOption = (stdioStream, input) => {
-	if (input === undefined || stdioStream.index !== 0) {
-		return stdioStream;
-	}
+// Append the `stdin` option with the `input` and `inputFile` options
+export const handleInputOptions = ({input, inputFile}) => [
+	handleInputOption(input),
+	handleInputFileOption(inputFile),
+].filter(Boolean);
 
-	const optionName = 'input';
-	validateInputOption(stdioStream.value, optionName);
-	const type = isNodeStream(input) ? 'nodeStream' : 'stringOrBuffer';
-	return {...stdioStream, value: input, type, optionName};
+const handleInputOption = input => input && {
+	type: isNodeStream(input) ? 'nodeStream' : 'stringOrBuffer',
+	value: input,
+	optionName: 'input',
+	index: 0,
 };
 
-// Override the `stdin` option with the `inputFile` option
-export const handleInputFileOption = (stdioStream, inputFile, input) => {
-	if (inputFile === undefined || stdioStream.index !== 0) {
-		return stdioStream;
-	}
-
-	if (input !== undefined) {
-		throw new TypeError('The `input` and `inputFile` options cannot be both set.');
-	}
-
-	const optionName = 'inputFile';
-	validateInputOption(stdioStream.value, optionName);
-	return {...stdioStream, value: inputFile, type: 'filePath', optionName};
+const handleInputFileOption = inputFile => inputFile && {
+	type: 'filePath',
+	value: inputFile,
+	optionName: 'inputFile',
+	index: 0,
 };
-
-const validateInputOption = (value, optionName) => {
-	if (!CAN_USE_INPUT.has(value)) {
-		throw new TypeError(`The \`${optionName}\` and \`stdin\` options cannot be both set.`);
-	}
-};
-
-const CAN_USE_INPUT = new Set([undefined, null, 'overlapped', 'pipe']);
diff --git a/lib/stdio/sync.js b/lib/stdio/sync.js
index fa9d3ba..311c051 100644
--- a/lib/stdio/sync.js
+++ b/lib/stdio/sync.js
@@ -24,7 +24,7 @@ const forbiddenIfSync = ({type, optionName}) => {
 
 const addPropertiesSync = {
 	input: {
-		filePath: ({value}) => ({value: readFileSync(value), type: 'stringOrBuffer'}),
+		filePath: ({value}) => ({value: readFileSync(value, 'utf8'), type: 'stringOrBuffer'}),
 		webStream: forbiddenIfSync,
 		nodeStream: forbiddenIfSync,
 		iterable: forbiddenIfSync,
@@ -39,12 +39,18 @@ const addPropertiesSync = {
 };
 
 const addInputOptionSync = (stdioStreams, options) => {
-	const inputValue = stdioStreams.find(({type}) => type === 'stringOrBuffer')?.value;
-	if (inputValue !== undefined) {
-		options.input = inputValue;
+	const inputs = stdioStreams.filter(({type}) => type === 'stringOrBuffer');
+	if (inputs.length === 0) {
+		return;
 	}
+
+	options.input = inputs.length === 1
+		? inputs[0].value
+		: inputs.map(({value}) => serializeInput(value)).join('');
 };
 
+const serializeInput = value => typeof value === 'string' ? value : new TextDecoder().decode(value);
+
 // Handle `input`, `inputFile`, `stdin`, `stdout` and `stderr` options, after spawning, in sync mode
 export const pipeOutputSync = (stdioStreams, result) => {
 	if (result.output === null) {
diff --git a/lib/stream.js b/lib/stream.js
index 19d5a6e..62ec322 100644
--- a/lib/stream.js
+++ b/lib/stream.js
@@ -62,7 +62,7 @@ const applyEncoding = (contents, encoding) => {
 const isUtf8Encoding = encoding => encoding === 'utf8' || encoding === 'utf-8';
 
 // Retrieve streams created by the `std*` options
-const getCustomStreams = stdioStreams => stdioStreams.filter(({type}) => type !== 'native' && type !== 'stringOrBuffer');
+const getCustomStreams = stdioStreams => stdioStreams.filter(({type}) => type !== 'native');
 
 // Some `stdout`/`stderr` options create a stream, e.g. when passing a file path.
 // The `.pipe()` method automatically ends that stream when `childProcess.stdout|stderr` ends.
diff --git a/readme.md b/readme.md
index 4f6fea9..5a10fa0 100644
--- a/readme.md
+++ b/readme.md
@@ -581,7 +581,7 @@ Default: `inherit` with [`$`](#command), `pipe` otherwise
 - an integer: Re-use a specific file descriptor from the current process.
 - a [Node.js `Readable` stream](#redirect-a-nodejs-stream-fromto-stdinstdoutstderr).
 
-Unless either the [synchronous methods](#execasyncfile-arguments-options), the [`input` option](#input) or the [`inputFile` option](#inputfile) is used, the value can also be a:
+Unless the [synchronous methods](#execasyncfile-arguments-options) are used, the value can also be a:
 - file path. If relative, it must start with `.`.
 - file URL.
 - web [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream).
@@ -603,7 +603,7 @@ Default: `pipe`
 - an integer: Re-use a specific file descriptor from the current process.
 - a [Node.js `Writable` stream](#redirect-a-nodejs-stream-fromto-stdinstdoutstderr).
 
-Unless either [synchronous methods](#execasyncfile-arguments-options), the value can also be a:
+Unless the [synchronous methods](#execasyncfile-arguments-options) are used, the value can also be a:
 - file path. If relative, it must start with `.`.
 - file URL.
 - web [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream).
@@ -624,7 +624,7 @@ Default: `pipe`
 - an integer: Re-use a specific file descriptor from the current process.
 - a [Node.js `Writable` stream](#redirect-a-nodejs-stream-fromto-stdinstdoutstderr).
 
-Unless either [synchronous methods](#execasyncfile-arguments-options), the value can also be a:
+Unless the [synchronous methods](#execasyncfile-arguments-options) are used, the value can also be a:
 - file path. If relative, it must start with `.`.
 - file URL.
 - web [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream).


Test patch:
diff --git a/test/helpers/stdio.js b/test/helpers/stdio.js
index 3d75fb4..e8254a6 100644
--- a/test/helpers/stdio.js
+++ b/test/helpers/stdio.js
@@ -2,7 +2,6 @@ export const getStdinOption = stdioOption => ({stdin: stdioOption});
 export const getStdoutOption = stdioOption => ({stdout: stdioOption});
 export const getStderrOption = stdioOption => ({stderr: stdioOption});
 export const getStdioOption = stdioOption => ({stdio: ['pipe', 'pipe', 'pipe', stdioOption]});
-export const getPlainStdioOption = stdioOption => ({stdio: stdioOption});
 export const getInputOption = input => ({input});
 export const getInputFileOption = inputFile => ({inputFile});
 
diff --git a/test/stdio/file-path.js b/test/stdio/file-path.js
index d5a358d..f65737d 100644
--- a/test/stdio/file-path.js
+++ b/test/stdio/file-path.js
@@ -10,6 +10,8 @@ import {getStdinOption, getStdoutOption, getStderrOption, getStdioOption, getInp
 
 setFixtureDir();
 
+const textEncoder = new TextEncoder();
+const binaryFoobar = textEncoder.encode('foobar');
 const nonFileUrl = new URL('https://example.com');
 
 const getRelativePath = filePath => relative('.', filePath);
@@ -169,8 +171,29 @@ const testInputFileScript = async (t, getExecaMethod) => {
 test('inputFile can be set with $', testInputFileScript, identity);
 test('inputFile can be set with $.sync', testInputFileScript, getScriptSync);
 
-test('inputFile option cannot be set when stdin is set', t => {
-	t.throws(() => {
-		execa('stdin.js', {inputFile: '', stdin: 'ignore'});
-	}, {message: /`inputFile` and `stdin` options/});
-});
+const testMultipleInputs = async (t, allGetOptions, execaMethod) => {
+	const filePath = tempfile();
+	await writeFile(filePath, 'foobar');
+	const options = Object.assign({}, ...allGetOptions.map(getOptions => getOptions(filePath)));
+	const {stdout} = await execaMethod('stdin.js', options);
+	t.is(stdout, 'foobar'.repeat(allGetOptions.length));
+	await rm(filePath);
+};
+
+const getStringInput = () => ({input: 'foobar'});
+const getBinaryInput = () => ({input: binaryFoobar});
+
+test('input String and inputFile can be both set', testMultipleInputs, [getInputFileOption, getStringInput], execa);
+test('input String and stdin can be both set', testMultipleInputs, [getStdinOption, getStringInput], execa);
+test('input Uint8Array and inputFile can be both set', testMultipleInputs, [getInputFileOption, getBinaryInput], execa);
+test('input Uint8Array and stdin can be both set', testMultipleInputs, [getStdinOption, getBinaryInput], execa);
+test('stdin and inputFile can be both set', testMultipleInputs, [getStdinOption, getInputFileOption], execa);
+test('input String, stdin and inputFile can be all set', testMultipleInputs, [getInputFileOption, getStdinOption, getStringInput], execa);
+test('input Uint8Array, stdin and inputFile can be all set', testMultipleInputs, [getInputFileOption, getStdinOption, getBinaryInput], execa);
+test('input String and inputFile can be both set - sync', testMultipleInputs, [getInputFileOption, getStringInput], execaSync);
+test('input String and stdin can be both set - sync', testMultipleInputs, [getStdinOption, getStringInput], execaSync);
+test('input Uint8Array and inputFile can be both set - sync', testMultipleInputs, [getInputFileOption, getBinaryInput], execaSync);
+test('input Uint8Array and stdin can be both set - sync', testMultipleInputs, [getStdinOption, getBinaryInput], execaSync);
+test('stdin and inputFile can be both set - sync', testMultipleInputs, [getStdinOption, getInputFileOption], execaSync);
+test('input String, stdin and inputFile can be all set - sync', testMultipleInputs, [getInputFileOption, getStdinOption, getStringInput], execaSync);
+test('input Uint8Array, stdin and inputFile can be all set - sync', testMultipleInputs, [getInputFileOption, getStdinOption, getBinaryInput], execaSync);
diff --git a/test/stdio/input.js b/test/stdio/input.js
index 33b137b..4a53df4 100644
--- a/test/stdio/input.js
+++ b/test/stdio/input.js
@@ -1,31 +1,13 @@
-import {Readable} from 'node:stream';
-import {pathToFileURL} from 'node:url';
 import test from 'ava';
 import {execa, execaSync, $} from '../../index.js';
 import {setFixtureDir} from '../helpers/fixtures-dir.js';
-import {getStdinOption, getPlainStdioOption, getScriptSync, identity} from '../helpers/stdio.js';
-import {stringGenerator} from '../helpers/generator.js';
+import {getScriptSync, identity} from '../helpers/stdio.js';
 
 setFixtureDir();
 
 const textEncoder = new TextEncoder();
 const binaryFoobar = textEncoder.encode('foobar');
 
-const testInputOptionError = (t, stdin, inputName) => {
-	t.throws(() => {
-		execa('stdin.js', {stdin, [inputName]: 'foobar'});
-	}, {message: new RegExp(`\`${inputName}\` and \`stdin\` options`)});
-};
-
-test('stdin option cannot be an iterable when "input" is used', testInputOptionError, stringGenerator(), 'input');
-test('stdin option cannot be an iterable when "inputFile" is used', testInputOptionError, stringGenerator(), 'inputFile');
-test('stdin option cannot be a file URL when "input" is used', testInputOptionError, pathToFileURL('unknown'), 'input');
-test('stdin option cannot be a file URL when "inputFile" is used', testInputOptionError, pathToFileURL('unknown'), 'inputFile');
-test('stdin option cannot be a file path when "input" is used', testInputOptionError, './unknown', 'input');
-test('stdin option cannot be a file path when "inputFile" is used', testInputOptionError, './unknown', 'inputFile');
-test('stdin option cannot be a ReadableStream when "input" is used', testInputOptionError, new ReadableStream(), 'input');
-test('stdin option cannot be a ReadableStream when "inputFile" is used', testInputOptionError, new ReadableStream(), 'inputFile');
-
 const testInput = async (t, input, execaMethod) => {
 	const {stdout} = await execaMethod('stdin.js', {input});
 	t.is(stdout, 'foobar');
@@ -43,25 +25,3 @@ const testInputScript = async (t, getExecaMethod) => {
 
 test('input option can be used with $', testInputScript, identity);
 test('input option can be used with $.sync', testInputScript, getScriptSync);
-
-const testInputWithStdinError = (t, input, getOptions, execaMethod) => {
-	t.throws(() => {
-		execaMethod('stdin.js', {input, ...getOptions('ignore')});
-	}, {message: /`input` and `stdin` options/});
-};
-
-test('input option cannot be a String when stdin is set', testInputWithStdinError, 'foobar', getStdinOption, execa);
-test('input option cannot be a String when stdio is set', testInputWithStdinError, 'foobar', getPlainStdioOption, execa);
-test('input option cannot be a String when stdin is set - sync', testInputWithStdinError, 'foobar', getStdinOption, execaSync);
-test('input option cannot be a String when stdio is set - sync', testInputWithStdinError, 'foobar', getPlainStdioOption, execaSync);
-test('input option cannot be a Node.js Readable when stdin is set', testInputWithStdinError, new Readable(), getStdinOption, execa);
-test('input option cannot be a Node.js Readable when stdio is set', testInputWithStdinError, new Readable(), getPlainStdioOption, execa);
-
-const testInputAndInputFile = async (t, execaMethod) => {
-	t.throws(() => execaMethod('stdin.js', {inputFile: '', input: ''}), {
-		message: /cannot be both set/,
-	});
-};
-
-test('inputFile and input cannot be both set', testInputAndInputFile, execa);
-test('inputFile and input cannot be both set - sync', testInputAndInputFile, execaSync);