From 94a75df951994e0627eb5fafb1acc174f2968f8b Mon Sep 17 00:00:00 2001 From: ehmicky Date: Sun, 27 Oct 2024 07:20:50 +0000 Subject: [PATCH] Allow appending to files (#1166) --- docs/bash.md | 21 +++++++++ docs/output.md | 7 +++ lib/io/output-sync.js | 4 +- lib/stdio/handle-async.js | 2 +- lib/stdio/handle-sync.js | 2 +- lib/stdio/type.js | 4 +- .../option/file-append-invalid.test-d.ts | 44 +++++++++++++++++++ test-d/stdio/option/file-append.test-d.ts | 44 +++++++++++++++++++ test/stdio/file-path-main.js | 36 +++++++++++++++ types/stdio/type.d.ts | 2 +- 10 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 test-d/stdio/option/file-append-invalid.test-d.ts create mode 100644 test-d/stdio/option/file-append.test-d.ts diff --git a/docs/bash.md b/docs/bash.md index 31c6a960f9..c5c38a939b 100644 --- a/docs/bash.md +++ b/docs/bash.md @@ -605,6 +605,27 @@ await $({stdout: {file: 'output.txt'}})`npm run build`; [More info.](output.md#file-output) +### Append stdout to a file + +```sh +# Bash +npm run build >> output.txt +``` + +```js +// zx +import {createWriteStream} from 'node:fs'; + +await $`npm run build`.pipe(createWriteStream('output.txt', {flags: 'a'})); +``` + +```js +// Execa +await $({stdout: {file: 'output.txt', append: true}})`npm run build`; +``` + +[More info.](output.md#file-output) + ### Piping interleaved stdout and stderr to a file ```sh diff --git a/docs/output.md b/docs/output.md index 68cf2e89ba..fac5942099 100644 --- a/docs/output.md +++ b/docs/output.md @@ -32,12 +32,19 @@ console.log(stderr); // string with errors await execa({stdout: {file: 'output.txt'}})`npm run build`; // Or: await execa({stdout: new URL('file:///path/to/output.txt')})`npm run build`; +``` +```js // Redirect interleaved stdout and stderr to same file const output = {file: 'output.txt'}; await execa({stdout: output, stderr: output})`npm run build`; ``` +```js +// Append instead of overwriting +await execa({stdout: {file: 'output.txt', append: true}})`npm run build`; +``` + ## Terminal output The parent process' output can be re-used in the subprocess by passing `'inherit'`. This is especially useful to print to the terminal in command line applications. diff --git a/lib/io/output-sync.js b/lib/io/output-sync.js index b29fe755eb..36c9a8af9f 100644 --- a/lib/io/output-sync.js +++ b/lib/io/output-sync.js @@ -123,9 +123,9 @@ 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} of stdioItems.filter(({type}) => FILE_TYPES.has(type))) { + for (const {path, append} of stdioItems.filter(({type}) => FILE_TYPES.has(type))) { const pathString = typeof path === 'string' ? path : path.toString(); - if (outputFiles.has(pathString)) { + if (append || outputFiles.has(pathString)) { appendFileSync(path, serializedResult); } else { outputFiles.add(pathString); diff --git a/lib/stdio/handle-async.js b/lib/stdio/handle-async.js index 56be39a238..32fb352845 100644 --- a/lib/stdio/handle-async.js +++ b/lib/stdio/handle-async.js @@ -42,7 +42,7 @@ const addPropertiesAsync = { output: { ...addProperties, fileUrl: ({value}) => ({stream: createWriteStream(value)}), - filePath: ({value: {file}}) => ({stream: createWriteStream(file)}), + filePath: ({value: {file, append}}) => ({stream: createWriteStream(file, append ? {flags: 'a'} : {})}), webStream: ({value}) => ({stream: Writable.fromWeb(value)}), iterable: forbiddenIfAsync, asyncIterable: forbiddenIfAsync, diff --git a/lib/stdio/handle-sync.js b/lib/stdio/handle-sync.js index 5f278afb82..07b8c2b752 100644 --- a/lib/stdio/handle-sync.js +++ b/lib/stdio/handle-sync.js @@ -48,7 +48,7 @@ const addPropertiesSync = { output: { ...addProperties, fileUrl: ({value}) => ({path: value}), - filePath: ({value: {file}}) => ({path: file}), + filePath: ({value: {file, append}}) => ({path: file, append}), fileNumber: ({value}) => ({path: value}), iterable: forbiddenIfSync, string: forbiddenIfSync, diff --git a/lib/stdio/type.js b/lib/stdio/type.js index f14545a7db..eb9dfcf146 100644 --- a/lib/stdio/type.js +++ b/lib/stdio/type.js @@ -124,8 +124,10 @@ export const isUrl = value => Object.prototype.toString.call(value) === '[object export const isRegularUrl = value => isUrl(value) && value.protocol !== 'file:'; const isFilePathObject = value => isPlainObj(value) - && Object.keys(value).length === 1 + && Object.keys(value).length > 0 + && Object.keys(value).every(key => FILE_PATH_KEYS.has(key)) && isFilePathString(value.file); +const FILE_PATH_KEYS = new Set(['file', 'append']); export const isFilePathString = file => typeof file === 'string'; export const isUnknownStdioString = (type, value) => type === 'native' diff --git a/test-d/stdio/option/file-append-invalid.test-d.ts b/test-d/stdio/option/file-append-invalid.test-d.ts new file mode 100644 index 0000000000..81f6c63a00 --- /dev/null +++ b/test-d/stdio/option/file-append-invalid.test-d.ts @@ -0,0 +1,44 @@ +import {expectError, expectNotAssignable} from 'tsd'; +import { + execa, + execaSync, + type StdinOption, + type StdinSyncOption, + type StdoutStderrOption, + type StdoutStderrSyncOption, +} from '../../../index.js'; + +const invalidFileAppend = {file: './test', append: 'true'} as const; + +expectError(await execa('unicorns', {stdin: invalidFileAppend})); +expectError(execaSync('unicorns', {stdin: invalidFileAppend})); +expectError(await execa('unicorns', {stdin: [invalidFileAppend]})); +expectError(execaSync('unicorns', {stdin: [invalidFileAppend]})); + +expectError(await execa('unicorns', {stdout: invalidFileAppend})); +expectError(execaSync('unicorns', {stdout: invalidFileAppend})); +expectError(await execa('unicorns', {stdout: [invalidFileAppend]})); +expectError(execaSync('unicorns', {stdout: [invalidFileAppend]})); + +expectError(await execa('unicorns', {stderr: invalidFileAppend})); +expectError(execaSync('unicorns', {stderr: invalidFileAppend})); +expectError(await execa('unicorns', {stderr: [invalidFileAppend]})); +expectError(execaSync('unicorns', {stderr: [invalidFileAppend]})); + +expectError(await execa('unicorns', {stdio: invalidFileAppend})); +expectError(execaSync('unicorns', {stdio: invalidFileAppend})); + +expectError(await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', invalidFileAppend]})); +expectError(execaSync('unicorns', {stdio: ['pipe', 'pipe', 'pipe', invalidFileAppend]})); +expectError(await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', [invalidFileAppend]]})); +expectError(execaSync('unicorns', {stdio: ['pipe', 'pipe', 'pipe', [invalidFileAppend]]})); + +expectNotAssignable(invalidFileAppend); +expectNotAssignable(invalidFileAppend); +expectNotAssignable([invalidFileAppend]); +expectNotAssignable([invalidFileAppend]); + +expectNotAssignable(invalidFileAppend); +expectNotAssignable(invalidFileAppend); +expectNotAssignable([invalidFileAppend]); +expectNotAssignable([invalidFileAppend]); diff --git a/test-d/stdio/option/file-append.test-d.ts b/test-d/stdio/option/file-append.test-d.ts new file mode 100644 index 0000000000..97162141a6 --- /dev/null +++ b/test-d/stdio/option/file-append.test-d.ts @@ -0,0 +1,44 @@ +import {expectError, expectAssignable} from 'tsd'; +import { + execa, + execaSync, + type StdinOption, + type StdinSyncOption, + type StdoutStderrOption, + type StdoutStderrSyncOption, +} from '../../../index.js'; + +const fileAppend = {file: './test', append: true} as const; + +await execa('unicorns', {stdin: fileAppend}); +execaSync('unicorns', {stdin: fileAppend}); +await execa('unicorns', {stdin: [fileAppend]}); +execaSync('unicorns', {stdin: [fileAppend]}); + +await execa('unicorns', {stdout: fileAppend}); +execaSync('unicorns', {stdout: fileAppend}); +await execa('unicorns', {stdout: [fileAppend]}); +execaSync('unicorns', {stdout: [fileAppend]}); + +await execa('unicorns', {stderr: fileAppend}); +execaSync('unicorns', {stderr: fileAppend}); +await execa('unicorns', {stderr: [fileAppend]}); +execaSync('unicorns', {stderr: [fileAppend]}); + +expectError(await execa('unicorns', {stdio: fileAppend})); +expectError(execaSync('unicorns', {stdio: fileAppend})); + +await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', fileAppend]}); +execaSync('unicorns', {stdio: ['pipe', 'pipe', 'pipe', fileAppend]}); +await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', [fileAppend]]}); +execaSync('unicorns', {stdio: ['pipe', 'pipe', 'pipe', [fileAppend]]}); + +expectAssignable(fileAppend); +expectAssignable(fileAppend); +expectAssignable([fileAppend]); +expectAssignable([fileAppend]); + +expectAssignable(fileAppend); +expectAssignable(fileAppend); +expectAssignable([fileAppend]); +expectAssignable([fileAppend]); diff --git a/test/stdio/file-path-main.js b/test/stdio/file-path-main.js index d6987893e2..58a40b1b90 100644 --- a/test/stdio/file-path-main.js +++ b/test/stdio/file-path-main.js @@ -158,3 +158,39 @@ const testInputFileHanging = async (t, mapFilePath) => { test('Passing an input file path when subprocess exits does not make promise hang', testInputFileHanging, getAbsolutePath); test('Passing an input file URL when subprocess exits does not make promise hang', testInputFileHanging, pathToFileURL); + +const testOverwriteFile = async (t, fdNumber, execaMethod, append) => { + const filePath = tempfile(); + await writeFile(filePath, '.'); + await execaMethod('noop-fd.js', [`${fdNumber}`, foobarString], getStdio(fdNumber, {file: filePath, append})); + t.is(await readFile(filePath, 'utf8'), foobarString); + await rm(filePath); +}; + +test('Overwrite by default to stdout', testOverwriteFile, 1, execa, undefined); +test('Overwrite by default to stderr', testOverwriteFile, 2, execa, undefined); +test('Overwrite by default to stdio[*]', testOverwriteFile, 3, execa, undefined); +test('Overwrite by default to stdout - sync', testOverwriteFile, 1, execaSync, undefined); +test('Overwrite by default to stderr - sync', testOverwriteFile, 2, execaSync, undefined); +test('Overwrite by default to stdio[*] - sync', testOverwriteFile, 3, execaSync, undefined); +test('Overwrite with append false to stdout', testOverwriteFile, 1, execa, false); +test('Overwrite with append false to stderr', testOverwriteFile, 2, execa, false); +test('Overwrite with append false to stdio[*]', testOverwriteFile, 3, execa, false); +test('Overwrite with append false to stdout - sync', testOverwriteFile, 1, execaSync, false); +test('Overwrite with append false to stderr - sync', testOverwriteFile, 2, execaSync, false); +test('Overwrite with append false to stdio[*] - sync', testOverwriteFile, 3, execaSync, false); + +const testAppendFile = async (t, fdNumber, execaMethod) => { + const filePath = tempfile(); + await writeFile(filePath, '.'); + await execaMethod('noop-fd.js', [`${fdNumber}`, foobarString], getStdio(fdNumber, {file: filePath, append: true})); + t.is(await readFile(filePath, 'utf8'), `.${foobarString}`); + await rm(filePath); +}; + +test('Can append to stdout', testAppendFile, 1, execa); +test('Can append to stderr', testAppendFile, 2, execa); +test('Can append to stdio[*]', testAppendFile, 3, execa); +test('Can append to stdout - sync', testAppendFile, 1, execaSync); +test('Can append to stderr - sync', testAppendFile, 2, execaSync); +test('Can append to stdio[*] - sync', testAppendFile, 3, execaSync); diff --git a/types/stdio/type.d.ts b/types/stdio/type.d.ts index 47cad30f46..c823dffd54 100644 --- a/types/stdio/type.d.ts +++ b/types/stdio/type.d.ts @@ -49,7 +49,7 @@ type CommonStdioOption< > = | SimpleStdioOption | URL - | {readonly file: string} + | {readonly file: string; readonly append?: boolean} | GeneratorTransform | GeneratorTransformFull | Unless, IsArray>, 3 | 4 | 5 | 6 | 7 | 8 | 9>