diff --git a/index.d.ts b/index.d.ts index 83c172724c..6c45b6265c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,6 +1,6 @@ import {type Buffer} from 'node:buffer'; import {type ChildProcess} from 'node:child_process'; -import {type Stream, type Readable as ReadableStream} from 'node:stream'; +import {type Stream, type Readable as ReadableStream, type Writable as WritableStream} from 'node:stream'; export type StdioOption = | 'pipe' @@ -285,7 +285,9 @@ export type NodeOptions = { readonly nodeOptions?: string[]; } & Options; -export type ExecaReturnBase = { +type StdoutStderrAll = string | Buffer | undefined; + +export type ExecaReturnBase = { /** The file and arguments that were run, for logging purposes. @@ -346,7 +348,7 @@ export type ExecaReturnBase = { signalDescription?: string; }; -export type ExecaSyncReturnValue = { +export type ExecaSyncReturnValue = { } & ExecaReturnBase; /** @@ -359,7 +361,7 @@ The child process fails when: - being canceled - there's not enough memory or there are already too many child processes */ -export type ExecaReturnValue = { +export type ExecaReturnValue = { /** The output of the process with `stdout` and `stderr` interleaved. @@ -377,7 +379,7 @@ export type ExecaReturnValue = { isCanceled: boolean; } & ExecaSyncReturnValue; -export type ExecaSyncError = { +export type ExecaSyncError = { /** Error message when the child process failed to run. In addition to the underlying error message, it also contains some information related to why the child process errored. @@ -398,7 +400,7 @@ export type ExecaSyncError = { originalMessage?: string; } & Error & ExecaReturnBase; -export type ExecaError = { +export type ExecaError = { /** The output of the process with `stdout` and `stderr` interleaved. @@ -425,7 +427,7 @@ export type KillOptions = { forceKillAfterTimeout?: number | false; }; -export type ExecaChildPromise = { +export type ExecaChildPromise = { /** Stream combining/interleaving [`stdout`](https://nodejs.org/api/child_process.html#child_process_subprocess_stdout) and [`stderr`](https://nodejs.org/api/child_process.html#child_process_subprocess_stderr). @@ -448,9 +450,38 @@ export type ExecaChildPromise = { Similar to [`childProcess.kill()`](https://nodejs.org/api/child_process.html#child_process_subprocess_kill_signal). This used to be preferred when cancelling the child process execution as the error is more descriptive and [`childProcessResult.isCanceled`](#iscanceled) is set to `true`. But now this is deprecated and you should either use `.kill()` or the `signal` option when creating the child process. */ cancel(): void; + + /** + [Pipe](https://nodejs.org/api/stream.html#readablepipedestination-options) the child process's `stdout` to `target`, which can be: + - Another `execa()` return value + - A writable stream + - A file path string + + If the `target` is another `execa()` return value, it is returned. Otherwise, the original `execa()` return value is returned. This allows chaining `pipeStdout()` then `await`ing the final result. + + The `stdout` option] must be kept as `pipe`, its default value. + */ + pipeStdout?>(target: Target): Target; + pipeStdout?(target: WritableStream | string): ExecaChildProcess; + + /** + Like `pipeStdout()` but piping the child process's `stderr` instead. + + The `stderr` option must be kept as `pipe`, its default value. + */ + pipeStderr?>(target: Target): Target; + pipeStderr?(target: WritableStream | string): ExecaChildProcess; + + /** + Combines both `pipeStdout()` and `pipeStderr()`. + + Either the `stdout` option or the `stderr` option must be kept as `pipe`, their default value. Also, the `all` option must be set to `true`. + */ + pipeAll?>(target: Target): Target; + pipeAll?(target: WritableStream | string): ExecaChildProcess; }; -export type ExecaChildProcess = ChildProcess & +export type ExecaChildProcess = ChildProcess & ExecaChildPromise & Promise>; @@ -557,7 +588,7 @@ type TemplateExpression = | ExecaSyncReturnValue | Array | ExecaSyncReturnValue>; -type Execa$ = { +type Execa$ = { /** Same as `execa()` except both file and arguments are specified in a single tagged template string. For example, `` $`echo unicorns` `` is the same as `execa('echo', ['unicorns'])`. diff --git a/index.js b/index.js index 5c6ac50c71..e5525b9d4c 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,7 @@ import onetime from 'onetime'; import {makeError} from './lib/error.js'; import {normalizeStdio, normalizeStdioNode} from './lib/stdio.js'; import {spawnedKill, spawnedCancel, setupTimeout, validateTimeout, setExitHandler} from './lib/kill.js'; +import {addPipeMethods} from './lib/pipe.js'; import {handleInput, getSpawnedResult, makeAllStream, validateInputSync} from './lib/stream.js'; import {mergePromise, getSpawnedPromise} from './lib/promise.js'; import {joinCommand, parseCommand, parseTemplates, getEscapedCommand} from './lib/command.js'; @@ -100,7 +101,8 @@ export function execa(file, args, options) { isCanceled: false, killed: false, })); - return mergePromise(dummySpawned, errorPromise); + mergePromise(dummySpawned, errorPromise); + return dummySpawned; } const spawnedPromise = getSpawnedPromise(spawned); @@ -161,7 +163,9 @@ export function execa(file, args, options) { spawned.all = makeAllStream(spawned, parsed.options); - return mergePromise(spawned, handlePromiseOnce); + addPipeMethods(spawned); + mergePromise(spawned, handlePromiseOnce); + return spawned; } export function execaSync(file, args, options) { diff --git a/index.test-d.ts b/index.test-d.ts index 17b383f9ee..15208999d6 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -4,7 +4,8 @@ import {Buffer} from 'node:buffer'; // to get treated as `any` by `@typescript-eslint/no-unsafe-assignment`. import * as process from 'node:process'; import {type Readable as ReadableStream} from 'node:stream'; -import {expectType, expectError} from 'tsd'; +import {createWriteStream} from 'node:fs'; +import {expectType, expectError, expectAssignable} from 'tsd'; import { $, execa, @@ -24,6 +25,39 @@ try { execaPromise.cancel(); expectType(execaPromise.all); + const execaBufferPromise = execa('unicorns', {encoding: null}); + const writeStream = createWriteStream('output.txt'); + + expectAssignable(execaPromise.pipeStdout); + expectType(execaPromise.pipeStdout!('file.txt')); + expectType>(execaBufferPromise.pipeStdout!('file.txt')); + expectType(execaPromise.pipeStdout!(writeStream)); + expectType>(execaBufferPromise.pipeStdout!(writeStream)); + expectType(execaPromise.pipeStdout!(execaPromise)); + expectType>(execaPromise.pipeStdout!(execaBufferPromise)); + expectType(execaBufferPromise.pipeStdout!(execaPromise)); + expectType>(execaBufferPromise.pipeStdout!(execaBufferPromise)); + + expectAssignable(execaPromise.pipeStderr); + expectType(execaPromise.pipeStderr!('file.txt')); + expectType>(execaBufferPromise.pipeStderr!('file.txt')); + expectType(execaPromise.pipeStderr!(writeStream)); + expectType>(execaBufferPromise.pipeStderr!(writeStream)); + expectType(execaPromise.pipeStderr!(execaPromise)); + expectType>(execaPromise.pipeStderr!(execaBufferPromise)); + expectType(execaBufferPromise.pipeStderr!(execaPromise)); + expectType>(execaBufferPromise.pipeStderr!(execaBufferPromise)); + + expectAssignable(execaPromise.pipeAll); + expectType(execaPromise.pipeAll!('file.txt')); + expectType>(execaBufferPromise.pipeAll!('file.txt')); + expectType(execaPromise.pipeAll!(writeStream)); + expectType>(execaBufferPromise.pipeAll!(writeStream)); + expectType(execaPromise.pipeAll!(execaPromise)); + expectType>(execaPromise.pipeAll!(execaBufferPromise)); + expectType(execaBufferPromise.pipeAll!(execaPromise)); + expectType>(execaBufferPromise.pipeAll!(execaBufferPromise)); + const unicornsResult = await execaPromise; expectType(unicornsResult.command); expectType(unicornsResult.escapedCommand); @@ -63,6 +97,9 @@ try { expectType(unicornsResult.stdout); expectType(unicornsResult.stderr); expectError(unicornsResult.all); + expectError(unicornsResult.pipeStdout); + expectError(unicornsResult.pipeStderr); + expectError(unicornsResult.pipeAll); expectType(unicornsResult.failed); expectType(unicornsResult.timedOut); expectError(unicornsResult.isCanceled); diff --git a/lib/pipe.js b/lib/pipe.js new file mode 100644 index 0000000000..e73ffcc989 --- /dev/null +++ b/lib/pipe.js @@ -0,0 +1,42 @@ +import {createWriteStream} from 'node:fs'; +import {ChildProcess} from 'node:child_process'; +import {isWritableStream} from 'is-stream'; + +const isExecaChildProcess = target => target instanceof ChildProcess && typeof target.then === 'function'; + +const pipeToTarget = (spawned, streamName, target) => { + if (typeof target === 'string') { + spawned[streamName].pipe(createWriteStream(target)); + return spawned; + } + + if (isWritableStream(target)) { + spawned[streamName].pipe(target); + return spawned; + } + + if (!isExecaChildProcess(target)) { + throw new TypeError('The second argument must be a string, a stream or an Execa child process.'); + } + + if (!isWritableStream(target.stdin)) { + throw new TypeError('The target child process\'s stdin must be available.'); + } + + spawned[streamName].pipe(target.stdin); + return target; +}; + +export const addPipeMethods = spawned => { + if (spawned.stdout !== null) { + spawned.pipeStdout = pipeToTarget.bind(undefined, spawned, 'stdout'); + } + + if (spawned.stderr !== null) { + spawned.pipeStderr = pipeToTarget.bind(undefined, spawned, 'stderr'); + } + + if (spawned.all !== undefined) { + spawned.pipeAll = pipeToTarget.bind(undefined, spawned, 'all'); + } +}; diff --git a/lib/promise.js b/lib/promise.js index 975aee0cd6..a4773f30b0 100644 --- a/lib/promise.js +++ b/lib/promise.js @@ -16,8 +16,6 @@ export const mergePromise = (spawned, promise) => { Reflect.defineProperty(spawned, property, {...descriptor, value}); } - - return spawned; }; // Use promises instead of `child_process` events diff --git a/readme.md b/readme.md index 69aa9eaed8..c16583e6d5 100644 --- a/readme.md +++ b/readme.md @@ -20,6 +20,7 @@ This package improves [`child_process`](https://nodejs.org/api/child_process.htm - [Executes locally installed binaries by name.](#preferlocal) - [Cleans up spawned processes when the parent process dies.](#cleanup) - [Get interleaved output](#all) from `stdout` and `stderr` similar to what is printed on the terminal. [*(Async only)*](#execasyncfile-arguments-options) +- Convenience methods to [pipe processes' output](#redirect-output-to-a-file) - [Can specify file and arguments as a single string without a shell](#execacommandcommand-options) - More descriptive errors. @@ -115,12 +116,41 @@ unicorns rainbows ``` -### Pipe the child process stdout to the parent +### Redirect output to a file + +```js +import {execa} from 'execa'; + +// Similar to `echo unicorns > stdout.txt` in Bash +await execa('echo', ['unicorns']).pipeStdout('stdout.txt') + +// Similar to `echo unicorns 2> stdout.txt` in Bash +await execa('echo', ['unicorns']).pipeStderr('stderr.txt') + +// Similar to `echo unicorns &> stdout.txt` in Bash +await execa('echo', ['unicorns'], {all:true}).pipeAll('all.txt') +``` + +### Save and pipe output from a child process ```js import {execa} from 'execa'; -execa('echo', ['unicorns']).stdout.pipe(process.stdout); +const {stdout} = await execa('echo', ['unicorns']).pipeStdout(process.stdout); +// Prints `unicorns` +console.log(stdout); +// Also returns 'unicorns' +``` + +### Pipe multiple processes + +```js +import {execa} from 'execa'; + +// Similar to `echo unicorns | cat` in Bash +const {stdout} = await execa('echo', ['unicorns']).pipeStdout(execa('cat')); +console.log(stdout); +//=> 'unicorns' ``` ### Handling Errors @@ -261,6 +291,29 @@ 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'`, `Stream` or `integer`](https://nodejs.org/dist/latest-v6.x/docs/api/child_process.html#child_process_options_stdio) +#### pipeStdout(target) + +[Pipe](https://nodejs.org/api/stream.html#readablepipedestination-options) the child process's `stdout` to `target`, which can be: + - Another [`execa()` return value](#pipe-multiple-processes) + - A [writable stream](#save-and-pipe-output-from-a-child-process) + - A [file path string](#redirect-output-to-a-file) + +If the `target` is another [`execa()` return value](#execacommandcommand-options), it is returned. Otherwise, the original `execa()` return value is returned. This allows chaining `pipeStdout()` then `await`ing the [final result](#childprocessresult). + +The [`stdout` option](#stdout-1) must be kept as `pipe`, its default value. + +#### pipeStderr(target) + +Like [`pipeStdout()`](#pipestdouttarget) but piping the child process's `stderr` instead. + +The [`stderr` option](#stderr-1) must be kept as `pipe`, its default value. + +#### pipeAll(target) + +Combines both [`pipeStdout()`](#pipestdouttarget) and [`pipeStderr()`](#pipestderrtarget). + +Either the [`stdout` option](#stdout-1) or the [`stderr` option](#stderr-1) must be kept as `pipe`, their default value. Also, the [`all` option](#all-2) must be set to `true`. + ### execaSync(file, arguments?, options?) Execute a file synchronously. @@ -706,29 +759,6 @@ const run = async () => { console.log(await pRetry(run, {retries: 5})); ``` -### Save and pipe output from a child process - -Let's say you want to show the output of a child process in real-time while also saving it to a variable. - -```js -import {execa} from 'execa'; - -const subprocess = execa('echo', ['foo']); -subprocess.stdout.pipe(process.stdout); - -const {stdout} = await subprocess; -console.log('child output:', stdout); -``` - -### Redirect output to a file - -```js -import {execa} from 'execa'; - -const subprocess = execa('echo', ['foo']) -subprocess.stdout.pipe(fs.createWriteStream('stdout.txt')) -``` - ### Redirect input from a file ```js diff --git a/test/pipe.js b/test/pipe.js new file mode 100644 index 0000000000..b465440e10 --- /dev/null +++ b/test/pipe.js @@ -0,0 +1,78 @@ +import {PassThrough, Readable} from 'node:stream'; +import {spawn} from 'node:child_process'; +import {readFile} from 'node:fs/promises'; +import tempfile from 'tempfile'; +import test from 'ava'; +import getStream from 'get-stream'; +import {execa} from '../index.js'; +import {setFixtureDir} from './helpers/fixtures-dir.js'; + +setFixtureDir(); + +const pipeToProcess = async (t, fixtureName, funcName) => { + const {stdout} = await execa(fixtureName, ['test'], {all: true})[funcName](execa('stdin.js')); + t.is(stdout, 'test'); +}; + +test('pipeStdout() can pipe to Execa child processes', pipeToProcess, 'noop.js', 'pipeStdout'); +test('pipeStderr() can pipe to Execa child processes', pipeToProcess, 'noop-err.js', 'pipeStderr'); +test('pipeAll() can pipe stdout to Execa child processes', pipeToProcess, 'noop.js', 'pipeAll'); +test('pipeAll() can pipe stderr to Execa child processes', pipeToProcess, 'noop-err.js', 'pipeAll'); + +const pipeToStream = async (t, fixtureName, funcName, streamName) => { + const stream = new PassThrough(); + const result = await execa(fixtureName, ['test'], {all: true})[funcName](stream); + t.is(result[streamName], 'test'); + t.is(await getStream(stream), 'test\n'); +}; + +test('pipeStdout() can pipe to streams', pipeToStream, 'noop.js', 'pipeStdout', 'stdout'); +test('pipeStderr() can pipe to streams', pipeToStream, 'noop-err.js', 'pipeStderr', 'stderr'); +test('pipeAll() can pipe stdout to streams', pipeToStream, 'noop.js', 'pipeAll', 'stdout'); +test('pipeAll() can pipe stderr to streams', pipeToStream, 'noop-err.js', 'pipeAll', 'stderr'); + +const pipeToFile = async (t, fixtureName, funcName, streamName) => { + const file = tempfile('.txt'); + const result = await execa(fixtureName, ['test'], {all: true})[funcName](file); + t.is(result[streamName], 'test'); + t.is(await readFile(file, 'utf8'), 'test\n'); +}; + +test('pipeStdout() can pipe to files', pipeToFile, 'noop.js', 'pipeStdout', 'stdout'); +test('pipeStderr() can pipe to files', pipeToFile, 'noop-err.js', 'pipeStderr', 'stderr'); +test('pipeAll() can pipe stdout to files', pipeToFile, 'noop.js', 'pipeAll', 'stdout'); +test('pipeAll() can pipe stderr to files', pipeToFile, 'noop-err.js', 'pipeAll', 'stderr'); + +const invalidTarget = (t, funcName, getTarget) => { + t.throws(() => execa('noop.js', {all: true})[funcName](getTarget()), { + message: /a stream or an Execa child process/, + }); +}; + +test('pipeStdout() can only pipe to writable streams', invalidTarget, 'pipeStdout', () => new Readable()); +test('pipeStderr() can only pipe to writable streams', invalidTarget, 'pipeStderr', () => new Readable()); +test('pipeAll() can only pipe to writable streams', invalidTarget, 'pipeAll', () => new Readable()); +test('pipeStdout() cannot pipe to non-processes', invalidTarget, 'pipeStdout', () => ({stdin: new PassThrough()})); +test('pipeStderr() cannot pipe to non-processes', invalidTarget, 'pipeStderr', () => ({stdin: new PassThrough()})); +test('pipeAll() cannot pipe to non-processes', invalidTarget, 'pipeStderr', () => ({stdin: new PassThrough()})); +test('pipeStdout() cannot pipe to non-Execa processes', invalidTarget, 'pipeStdout', () => spawn('node', ['--version'])); +test('pipeStderr() cannot pipe to non-Execa processes', invalidTarget, 'pipeStderr', () => spawn('node', ['--version'])); +test('pipeAll() cannot pipe to non-Execa processes', invalidTarget, 'pipeStderr', () => spawn('node', ['--version'])); + +const invalidSource = (t, funcName) => { + t.false(funcName in execa('noop.js', {stdout: 'ignore', stderr: 'ignore'})); +}; + +test('Must set "stdout" option to "pipe" to use pipeStdout()', invalidSource, 'pipeStdout'); +test('Must set "stderr" option to "pipe" to use pipeStderr()', invalidSource, 'pipeStderr'); +test('Must set "stdout" or "stderr" option to "pipe" to use pipeAll()', invalidSource, 'pipeAll'); + +const invalidPipeToProcess = async (t, fixtureName, funcName) => { + t.throws(() => execa(fixtureName, ['test'], {all: true})[funcName](execa('stdin.js', {stdin: 'ignore'})), { + message: /stdin must be available/, + }); +}; + +test('Must set target "stdin" option to "pipe" to use pipeStdout()', invalidPipeToProcess, 'noop.js', 'pipeStdout'); +test('Must set target "stdin" option to "pipe" to use pipeStderr()', invalidPipeToProcess, 'noop-err.js', 'pipeStderr'); +test('Must set target "stdin" option to "pipe" to use pipeAll()', invalidPipeToProcess, 'noop.js', 'pipeAll');