Repository: sindresorhus/execa (JavaScript)

Issue:
TypeScript: pipeStdout (and other pipeXxx) is optional property 
What is the reason to keep it optional?

```ts
pipeStdout?<Target extends ExecaChildPromise<StdoutStderrAll>>(target: Target): Target;
pipeStdout?(target: WritableStream | string): ExecaChildProcess<StdoutStderrType>;
```

It can't be user without extra check or '!`:
<img width="667" alt="image" src="https://github.com/sindresorhus/execa/assets/20106607/f52eb6b3-43ed-4631-b69f-896ae0097bcc">

Improve process piping API
## Proposal

Changing the following:

```js
await execa(...).pipeStdout(execa(...));
await execa(...).pipeStderr(execa(...));
await execa(..., {all: true}).pipeAll(execa(...));
```

And:

```js
const childProcess = execa(...)
const secondChildProcess = execa(...)
childProcess.stdio[3].pipe(secondChildProcess.stdin)
await Promise.all([childProcess, secondChildProcess])
```

To this instead:

```js
await execa(...).pipe(execa(...));
await execa(...).pipe(execa(...), 'stderr');
await execa(..., {all: true}).pipe(execa(...), 'all');
await execa(...).pipe(execa(...), 3);
```

## Details

The method's second argument can be `'stdout'` (default value), `'stderr'`, `'all'` or any file descriptor number. 
Passing an input file descriptor (e.g. `0`) throws.

## Pros

1. The pipe methods currently only allow stdout/stderr/all, not other file descriptors. This adds support for it.
2. Having `stdout` as the default value encourages the most common case. In general, `stdout` is the recommended stream to pipe multiple processes. This is why Bash uses a single character `|` for it, while piping `stderr` is more verbose `2>&1 >/dev/null |`, and piping `all` is available as `|&` but less commonly seen because introduced in Bash 4. 
3. Reduce the number of methods, simplifying the API.

---

What do you think?


Discussion: Hi @koshic,

[`pipeStdout()`](https://github.com/sindresorhus/execa#pipestdouttarget) is `undefined` if the [`stdout` option](https://github.com/sindresorhus/execa#stdout-1) is `'ignore'`, `'ipc'`, a file descriptor integer, or a stream. Default value is `'pipe'`.
The same applies to [`pipeStderr()`](https://github.com/sindresorhus/execa#pipestderrtarget), but using the [`stderr` option](https://github.com/sindresorhus/execa#stderr-1).

[`pipeAll()`](https://github.com/sindresorhus/execa#pipealltarget) is `undefined` if either: 
  - Both the `stdout` and `stderr` options are `'ignore'`, `'ipc'`, a file descriptor integer, or a stream.
  - The [`all` option](https://github.com/sindresorhus/execa#all-2) is not `true` (default value is `false`).

https://github.com/sindresorhus/execa/blob/834e3726f0c7b7f23827b6e09b69b66dedbddd47/lib/pipe.js#L31-L33

Ideally the types would take this into account. Would you be interested in submitting a PR to fix them?
@ehmicky yeah, let me experiment a bit )
As you can see, if we will use current strategy - modify options via generic parameter, and provide all possible combinations as execa definitions, we have to multiply all definitions by 3 (new modifiers: stdout + stdin + all) which makes code unmaintainable:

<img width="606" alt="image" src="https://github.com/sindresorhus/execa/assets/20106607/86723f46-3aa1-49ba-9d13-b6e6f78d0a68">

I see only one way - keep modifiers for options, but produce exact ExecaChildProcess type by additional conditional type wrapper which will work like switch-case statement:
```ts
O extends { stdin: 'pipe' /* + default value check */} ? { pipeStdout: () => {} } : { }
```

@ehmicky what do you think about it?
Yes, I think what you're highlighting was the reason I did not implement stricter typing in the first place when adding those pipe methods. 

I agree with you. I'm not sure if this is what you are describing, but instead of method overloading, type inference could be used. I.e. only define `execa()` once and let `Options<EncodingType>` be inferred from the parameters passed as input. Something like:

```ts
export function execa<EncodingType extends EncodingOption = DefaultEncodingOption>(
	file: string,
	arguments?: readonly string[],
    // EncodingType is now inferred based on the `options` passed as input
	options?: Options<EncodingType>
// Which is used to guess whether `result.stdout|stderr|all` is a `string` or a `Buffer`
): ExecaChildProcess<EncodingType extends BufferEncodingOption ? Buffer : string>; 
```

What do you think?
Yes, I mean the same pattern - define function signature once, and use smart generic types. So, I propose to split that task into 2:

First PR - to replace overloading with type inference. Not a breaking change, I believe.
Second PR - to extend generics with PipeXxx options. May be a breaking change due to pipeStdout will be not present in resulting type in some cases. 
This sounds like a good plan! I do like breaking it down like this, it's going to be easier to review.

I actually believe that this might not be a breaking change, because the type will go from `Function | undefined` to either `Function` or `undefined`, depending on the option being passed, so it is actually stricter. That being said, I am planning on making an unrelated PR which will require a new major release, so we can always bundle it with it, to be safe.

Thanks for your help!
@koshic Would you be interested in doing the second PR discussed above?
@ehmicky yeah, I need a few days to recover from my illness.
Thanks @koshic. Take care! :woman_health_worker: 

👍



Fix (patch):
diff --git a/docs/scripts.md b/docs/scripts.md
index 762429e39c..d45be60619 100644
--- a/docs/scripts.md
+++ b/docs/scripts.md
@@ -6,7 +6,7 @@ With Execa, you can write scripts with Node.js instead of a shell language. It i
 import {$} from 'execa';
 
 const {stdout: name} = await $`cat package.json`
-  .pipeStdout($({stdin: 'pipe'})`grep name`);
+  .pipe($({stdin: 'pipe'})`grep name`);
 console.log(name);
 
 const branch = await $`git branch --show-current`;
@@ -598,7 +598,7 @@ await $`echo example | cat`;
 
 ```js
 // Execa
-await $`echo example`.pipeStdout($({stdin: 'pipe'})`cat`);
+await $`echo example`.pipe($({stdin: 'pipe'})`cat`);
 ```
 
 ### Piping stdout and stderr to another command
@@ -619,7 +619,7 @@ await Promise.all([echo, cat]);
 
 ```js
 // Execa
-await $({all: true})`echo example`.pipeAll($({stdin: 'pipe'})`cat`);
+await $({all: true})`echo example`.pipe($({stdin: 'pipe'})`cat`, 'all');
 ```
 
 ### Piping stdout to a file
diff --git a/index.d.ts b/index.d.ts
index ea673731d5..6780ba3b0e 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -812,25 +812,13 @@ export type ExecaChildPromise<OptionsType extends Options = Options> = {
 	/**
 	[Pipe](https://nodejs.org/api/stream.html#readablepipedestination-options) the child process' `stdout` to another Execa child process' `stdin`.
 
-	Returns `execaChildProcess`, which allows chaining `pipeStdout()` then `await`ing the final result.
+	A `streamName` can be passed to pipe `"stderr"`, `"all"` (both `stdout` and `stderr`) or any another file descriptor instead of `stdout`.
 
-	`childProcess.stdout` must not be `undefined`.
-	*/
-	pipeStdout?<Target extends ExecaChildProcess>(target: Target): Target;
-
-	/**
-	Like `pipeStdout()` but piping the child process's `stderr` instead.
-
-	`childProcess.stderr` must not be `undefined`.
-	*/
-	pipeStderr?<Target extends ExecaChildProcess>(target: Target): Target;
-
-	/**
-	Combines both `pipeStdout()` and `pipeStderr()`.
+	`childProcess.stdout` (and/or `childProcess.stderr` depending on `streamName`) must not be `undefined`. When `streamName` is `"all"`, the `all` option must be set to `true`.
 
-	The `all` option must be set to `true`.
+	Returns `execaChildProcess`, which allows chaining `.pipe()` then `await`ing the final result.
 	*/
-	pipeAll?<Target extends ExecaChildProcess>(target: Target): Target;
+	pipe<Target extends ExecaChildProcess>(target: Target, streamName?: 'stdout' | 'stderr' | 'all' | number): Target;
 };
 
 export type ExecaChildProcess<OptionsType extends Options = Options> = ChildProcess &
@@ -865,13 +853,13 @@ console.log(stdout);
 import {execa} from 'execa';
 
 // Similar to `echo unicorns > stdout.txt` in Bash
-await execa('echo', ['unicorns']).pipeStdout('stdout.txt');
+await execa('echo', ['unicorns'], {stdout: {file: 'stdout.txt'}});
 
 // Similar to `echo unicorns 2> stdout.txt` in Bash
-await execa('echo', ['unicorns']).pipeStderr('stderr.txt');
+await execa('echo', ['unicorns'], {stderr: {file: 'stderr.txt'}});
 
 // Similar to `echo unicorns &> stdout.txt` in Bash
-await execa('echo', ['unicorns'], {all: true}).pipeAll('all.txt');
+await execa('echo', ['unicorns'], {stdout: {file: 'all.txt'}, stderr: {file: 'all.txt'}});
 ```
 
 @example <caption>Redirect input from a file</caption>
@@ -888,7 +876,7 @@ console.log(stdout);
 ```
 import {execa} from 'execa';
 
-const {stdout} = await execa('echo', ['unicorns']).pipeStdout(process.stdout);
+const {stdout} = await execa('echo', ['unicorns'], {stdout: ['pipe', 'inherit']});
 // Prints `unicorns`
 console.log(stdout);
 // Also returns 'unicorns'
@@ -899,7 +887,7 @@ console.log(stdout);
 import {execa} from 'execa';
 
 // Similar to `echo unicorns | cat` in Bash
-const {stdout} = await execa('echo', ['unicorns']).pipeStdout(execa('cat'));
+const {stdout} = await execa('echo', ['unicorns']).pipe(execa('cat'));
 console.log(stdout);
 //=> 'unicorns'
 ```
diff --git a/index.js b/index.js
index 9570ba5fff..e03083f752 100644
--- a/index.js
+++ b/index.js
@@ -11,7 +11,7 @@ import {handleInputAsync, pipeOutputAsync} from './lib/stdio/async.js';
 import {handleInputSync, pipeOutputSync} from './lib/stdio/sync.js';
 import {normalizeStdioNode} from './lib/stdio/normalize.js';
 import {spawnedKill, validateTimeout, normalizeForceKillAfterDelay} from './lib/kill.js';
-import {addPipeMethods} from './lib/pipe.js';
+import {pipeToProcess} from './lib/pipe.js';
 import {getSpawnedResult, makeAllStream} from './lib/stream.js';
 import {mergePromise} from './lib/promise.js';
 import {joinCommand, parseCommand, parseTemplates, getEscapedCommand} from './lib/command.js';
@@ -154,10 +154,9 @@ export function execa(rawFile, rawArgs, rawOptions) {
 
 	pipeOutputAsync(spawned, stdioStreamsGroups);
 
-	spawned.kill = spawnedKill.bind(null, spawned.kill.bind(spawned), options, controller);
+	spawned.kill = spawnedKill.bind(undefined, spawned.kill.bind(spawned), options, controller);
 	spawned.all = makeAllStream(spawned, options);
-
-	addPipeMethods(spawned);
+	spawned.pipe = pipeToProcess.bind(undefined, {spawned, stdioStreamsGroups, options});
 
 	const promise = handlePromise({spawned, options, stdioStreamsGroups, command, escapedCommand, controller});
 	mergePromise(spawned, promise);
diff --git a/lib/pipe.js b/lib/pipe.js
index 3326f3915a..07c5634f16 100644
--- a/lib/pipe.js
+++ b/lib/pipe.js
@@ -1,31 +1,79 @@
 import {ChildProcess} from 'node:child_process';
 import {isWritableStream} from 'is-stream';
 
-const isExecaChildProcess = target => target instanceof ChildProcess && typeof target.then === 'function';
+export const pipeToProcess = ({spawned, stdioStreamsGroups, options}, targetProcess, streamName = 'stdout') => {
+	validateTargetProcess(targetProcess);
+
+	const inputStream = getInputStream(spawned, streamName, stdioStreamsGroups);
+	validateStdioOption(inputStream, spawned, streamName, options);
+
+	inputStream.pipe(targetProcess.stdin);
+	return targetProcess;
+};
 
-const pipeToTarget = (spawned, streamName, target) => {
-	if (!isExecaChildProcess(target)) {
-		throw new TypeError('The second argument must be an Execa child process.');
+const validateTargetProcess = targetProcess => {
+	if (!isExecaChildProcess(targetProcess)) {
+		throw new TypeError('The first argument must be an Execa child process.');
 	}
 
-	if (!isWritableStream(target.stdin)) {
+	if (!isWritableStream(targetProcess.stdin)) {
 		throw new TypeError('The target child process\'s stdin must be available.');
 	}
+};
+
+const isExecaChildProcess = target => target instanceof ChildProcess && typeof target.then === 'function';
+
+const getInputStream = (spawned, streamName, stdioStreamsGroups) => {
+	if (VALID_STREAM_NAMES.has(streamName)) {
+		return spawned[streamName];
+	}
 
-	spawned[streamName].pipe(target.stdin);
-	return target;
+	if (streamName === 'stdin') {
+		throw new TypeError('The second argument must not be "stdin".');
+	}
+
+	if (!Number.isInteger(streamName) || streamName < 0) {
+		throw new TypeError(`The second argument must not be "${streamName}".
+It must be "stdout", "stderr", "all" or a file descriptor integer.
+It is optional and defaults to "stdout".`);
+	}
+
+	const stdioStreams = stdioStreamsGroups[streamName];
+	if (stdioStreams === undefined) {
+		throw new TypeError(`The second argument must not be ${streamName}: that file descriptor does not exist.
+Please set the "stdio" option to ensure that file descriptor exists.`);
+	}
+
+	if (stdioStreams[0].direction === 'input') {
+		throw new TypeError(`The second argument must not be ${streamName}: it must be a readable stream, not writable.`);
+	}
+
+	return spawned.stdio[streamName];
 };
 
-export const addPipeMethods = spawned => {
-	if (spawned.stdout !== null) {
-		spawned.pipeStdout = pipeToTarget.bind(undefined, spawned, 'stdout');
+const VALID_STREAM_NAMES = new Set(['stdout', 'stderr', 'all']);
+
+const validateStdioOption = (inputStream, spawned, streamName, options) => {
+	if (inputStream !== null && inputStream !== undefined) {
+		return;
 	}
 
-	if (spawned.stderr !== null) {
-		spawned.pipeStderr = pipeToTarget.bind(undefined, spawned, 'stderr');
+	if (streamName === 'all' && !options.all) {
+		throw new TypeError('The "all" option must be true to use `childProcess.pipe(targetProcess, "all")`.');
 	}
 
-	if (spawned.all !== undefined) {
-		spawned.pipeAll = pipeToTarget.bind(undefined, spawned, 'all');
+	throw new TypeError(`The "${getInvalidStdioOption(inputStream, spawned, options)}" option's value is incompatible with using \`childProcess.pipe(targetProcess)\`.
+Please set this option with "pipe" instead.`);
+};
+
+const getInvalidStdioOption = (inputStream, spawned, options) => {
+	if (inputStream === spawned.stdout && options.stdout !== undefined) {
+		return 'stdout';
 	}
+
+	if (inputStream === spawned.stderr && options.stderr !== undefined) {
+		return 'stderr';
+	}
+
+	return 'stdio';
 };
diff --git a/readme.md b/readme.md
index b8a5ab42d8..aacde8df3a 100644
--- a/readme.md
+++ b/readme.md
@@ -183,7 +183,7 @@ console.log(stdout);
 import {execa} from 'execa';
 
 // Similar to `echo unicorns | cat` in Bash
-const {stdout} = await execa('echo', ['unicorns']).pipeStdout(execa('cat'));
+const {stdout} = await execa('echo', ['unicorns']).pipe(execa('cat'));
 console.log(stdout);
 //=> 'unicorns'
 ```
@@ -319,31 +319,18 @@ This is `undefined` if either:
 - the [`all` option](#all-2) is `false` (the default value)
 - both [`stdout`](#stdout-1) and [`stderr`](#stderr-1) options are set to [`'inherit'`, `'ipc'`, `'ignore'`, `Stream` or `integer`](https://nodejs.org/api/child_process.html#child_process_options_stdio)
 
-#### pipeStdout(execaChildProcess)
+#### pipe(execaChildProcess, streamName?)
 
 `execaChildProcess`: [`execa()` return value](#pipe-multiple-processes)
+`streamName`: `"stdout"` (default), `"stderr"`, `"all"` or file descriptor index
 
 [Pipe](https://nodejs.org/api/stream.html#readablepipedestination-options) the child process' `stdout` to another Execa child process' `stdin`.
 
-Returns `execaChildProcess`, which allows chaining `pipeStdout()` then `await`ing the [final result](#childprocessresult).
+A `streamName` can be passed to pipe `"stderr"`, `"all"` (both `stdout` and `stderr`) or any another file descriptor instead of `stdout`.
 
-[`childProcess.stdout`](#stdout) must not be `undefined`.
+[`childProcess.stdout`](#stdout) (and/or [`childProcess.stderr`](#stderr) depending on `streamName`) must not be `undefined`. When `streamName` is `"all"`, the [`all` option](#all-2) must be set to `true`.
 
-#### pipeStderr(execaChildProcess)
-
-`execaChildProcess`: [`execa()` return value](#pipe-multiple-processes)
-
-Like [`pipeStdout()`](#pipestdoutexecachildprocess) but piping the child process's `stderr` instead.
-
-[`childProcess.stderr`](#stderr) must not be `undefined`.
-
-#### pipeAll(execaChildProcess)
-
-`execaChildProcess`: [`execa()` return value](#pipe-multiple-processes)
-
-Combines both [`pipeStdout()`](#pipestdoutexecachildprocess) and [`pipeStderr()`](#pipestderrexecachildprocess).
-
-The [`all` option](#all-2) must be set to `true`.
+Returns `execaChildProcess`, which allows chaining `.pipe()` then `await`ing the [final result](#childprocessresult).
 
 ### childProcessResult