Skip to content

Commit 94a75df

Browse files
authored
Allow appending to files (#1166)
1 parent 091ed74 commit 94a75df

File tree

10 files changed

+160
-6
lines changed

10 files changed

+160
-6
lines changed

docs/bash.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,27 @@ await $({stdout: {file: 'output.txt'}})`npm run build`;
605605

606606
[More info.](output.md#file-output)
607607

608+
### Append stdout to a file
609+
610+
```sh
611+
# Bash
612+
npm run build >> output.txt
613+
```
614+
615+
```js
616+
// zx
617+
import {createWriteStream} from 'node:fs';
618+
619+
await $`npm run build`.pipe(createWriteStream('output.txt', {flags: 'a'}));
620+
```
621+
622+
```js
623+
// Execa
624+
await $({stdout: {file: 'output.txt', append: true}})`npm run build`;
625+
```
626+
627+
[More info.](output.md#file-output)
628+
608629
### Piping interleaved stdout and stderr to a file
609630

610631
```sh

docs/output.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,19 @@ console.log(stderr); // string with errors
3232
await execa({stdout: {file: 'output.txt'}})`npm run build`;
3333
// Or:
3434
await execa({stdout: new URL('file:///path/to/output.txt')})`npm run build`;
35+
```
3536

37+
```js
3638
// Redirect interleaved stdout and stderr to same file
3739
const output = {file: 'output.txt'};
3840
await execa({stdout: output, stderr: output})`npm run build`;
3941
```
4042

43+
```js
44+
// Append instead of overwriting
45+
await execa({stdout: {file: 'output.txt', append: true}})`npm run build`;
46+
```
47+
4148
## Terminal output
4249

4350
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.

lib/io/output-sync.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,9 @@ const logOutputSync = ({serializedResult, fdNumber, state, verboseInfo, encoding
123123

124124
// When the `std*` target is a file path/URL or a file descriptor
125125
const writeToFiles = (serializedResult, stdioItems, outputFiles) => {
126-
for (const {path} of stdioItems.filter(({type}) => FILE_TYPES.has(type))) {
126+
for (const {path, append} of stdioItems.filter(({type}) => FILE_TYPES.has(type))) {
127127
const pathString = typeof path === 'string' ? path : path.toString();
128-
if (outputFiles.has(pathString)) {
128+
if (append || outputFiles.has(pathString)) {
129129
appendFileSync(path, serializedResult);
130130
} else {
131131
outputFiles.add(pathString);

lib/stdio/handle-async.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const addPropertiesAsync = {
4242
output: {
4343
...addProperties,
4444
fileUrl: ({value}) => ({stream: createWriteStream(value)}),
45-
filePath: ({value: {file}}) => ({stream: createWriteStream(file)}),
45+
filePath: ({value: {file, append}}) => ({stream: createWriteStream(file, append ? {flags: 'a'} : {})}),
4646
webStream: ({value}) => ({stream: Writable.fromWeb(value)}),
4747
iterable: forbiddenIfAsync,
4848
asyncIterable: forbiddenIfAsync,

lib/stdio/handle-sync.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const addPropertiesSync = {
4848
output: {
4949
...addProperties,
5050
fileUrl: ({value}) => ({path: value}),
51-
filePath: ({value: {file}}) => ({path: file}),
51+
filePath: ({value: {file, append}}) => ({path: file, append}),
5252
fileNumber: ({value}) => ({path: value}),
5353
iterable: forbiddenIfSync,
5454
string: forbiddenIfSync,

lib/stdio/type.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,10 @@ export const isUrl = value => Object.prototype.toString.call(value) === '[object
124124
export const isRegularUrl = value => isUrl(value) && value.protocol !== 'file:';
125125

126126
const isFilePathObject = value => isPlainObj(value)
127-
&& Object.keys(value).length === 1
127+
&& Object.keys(value).length > 0
128+
&& Object.keys(value).every(key => FILE_PATH_KEYS.has(key))
128129
&& isFilePathString(value.file);
130+
const FILE_PATH_KEYS = new Set(['file', 'append']);
129131
export const isFilePathString = file => typeof file === 'string';
130132

131133
export const isUnknownStdioString = (type, value) => type === 'native'
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {expectError, expectNotAssignable} from 'tsd';
2+
import {
3+
execa,
4+
execaSync,
5+
type StdinOption,
6+
type StdinSyncOption,
7+
type StdoutStderrOption,
8+
type StdoutStderrSyncOption,
9+
} from '../../../index.js';
10+
11+
const invalidFileAppend = {file: './test', append: 'true'} as const;
12+
13+
expectError(await execa('unicorns', {stdin: invalidFileAppend}));
14+
expectError(execaSync('unicorns', {stdin: invalidFileAppend}));
15+
expectError(await execa('unicorns', {stdin: [invalidFileAppend]}));
16+
expectError(execaSync('unicorns', {stdin: [invalidFileAppend]}));
17+
18+
expectError(await execa('unicorns', {stdout: invalidFileAppend}));
19+
expectError(execaSync('unicorns', {stdout: invalidFileAppend}));
20+
expectError(await execa('unicorns', {stdout: [invalidFileAppend]}));
21+
expectError(execaSync('unicorns', {stdout: [invalidFileAppend]}));
22+
23+
expectError(await execa('unicorns', {stderr: invalidFileAppend}));
24+
expectError(execaSync('unicorns', {stderr: invalidFileAppend}));
25+
expectError(await execa('unicorns', {stderr: [invalidFileAppend]}));
26+
expectError(execaSync('unicorns', {stderr: [invalidFileAppend]}));
27+
28+
expectError(await execa('unicorns', {stdio: invalidFileAppend}));
29+
expectError(execaSync('unicorns', {stdio: invalidFileAppend}));
30+
31+
expectError(await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', invalidFileAppend]}));
32+
expectError(execaSync('unicorns', {stdio: ['pipe', 'pipe', 'pipe', invalidFileAppend]}));
33+
expectError(await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', [invalidFileAppend]]}));
34+
expectError(execaSync('unicorns', {stdio: ['pipe', 'pipe', 'pipe', [invalidFileAppend]]}));
35+
36+
expectNotAssignable<StdinOption>(invalidFileAppend);
37+
expectNotAssignable<StdinSyncOption>(invalidFileAppend);
38+
expectNotAssignable<StdinOption>([invalidFileAppend]);
39+
expectNotAssignable<StdinSyncOption>([invalidFileAppend]);
40+
41+
expectNotAssignable<StdoutStderrOption>(invalidFileAppend);
42+
expectNotAssignable<StdoutStderrSyncOption>(invalidFileAppend);
43+
expectNotAssignable<StdoutStderrOption>([invalidFileAppend]);
44+
expectNotAssignable<StdoutStderrSyncOption>([invalidFileAppend]);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {expectError, expectAssignable} from 'tsd';
2+
import {
3+
execa,
4+
execaSync,
5+
type StdinOption,
6+
type StdinSyncOption,
7+
type StdoutStderrOption,
8+
type StdoutStderrSyncOption,
9+
} from '../../../index.js';
10+
11+
const fileAppend = {file: './test', append: true} as const;
12+
13+
await execa('unicorns', {stdin: fileAppend});
14+
execaSync('unicorns', {stdin: fileAppend});
15+
await execa('unicorns', {stdin: [fileAppend]});
16+
execaSync('unicorns', {stdin: [fileAppend]});
17+
18+
await execa('unicorns', {stdout: fileAppend});
19+
execaSync('unicorns', {stdout: fileAppend});
20+
await execa('unicorns', {stdout: [fileAppend]});
21+
execaSync('unicorns', {stdout: [fileAppend]});
22+
23+
await execa('unicorns', {stderr: fileAppend});
24+
execaSync('unicorns', {stderr: fileAppend});
25+
await execa('unicorns', {stderr: [fileAppend]});
26+
execaSync('unicorns', {stderr: [fileAppend]});
27+
28+
expectError(await execa('unicorns', {stdio: fileAppend}));
29+
expectError(execaSync('unicorns', {stdio: fileAppend}));
30+
31+
await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', fileAppend]});
32+
execaSync('unicorns', {stdio: ['pipe', 'pipe', 'pipe', fileAppend]});
33+
await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', [fileAppend]]});
34+
execaSync('unicorns', {stdio: ['pipe', 'pipe', 'pipe', [fileAppend]]});
35+
36+
expectAssignable<StdinOption>(fileAppend);
37+
expectAssignable<StdinSyncOption>(fileAppend);
38+
expectAssignable<StdinOption>([fileAppend]);
39+
expectAssignable<StdinSyncOption>([fileAppend]);
40+
41+
expectAssignable<StdoutStderrOption>(fileAppend);
42+
expectAssignable<StdoutStderrSyncOption>(fileAppend);
43+
expectAssignable<StdoutStderrOption>([fileAppend]);
44+
expectAssignable<StdoutStderrSyncOption>([fileAppend]);

test/stdio/file-path-main.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,39 @@ const testInputFileHanging = async (t, mapFilePath) => {
158158

159159
test('Passing an input file path when subprocess exits does not make promise hang', testInputFileHanging, getAbsolutePath);
160160
test('Passing an input file URL when subprocess exits does not make promise hang', testInputFileHanging, pathToFileURL);
161+
162+
const testOverwriteFile = async (t, fdNumber, execaMethod, append) => {
163+
const filePath = tempfile();
164+
await writeFile(filePath, '.');
165+
await execaMethod('noop-fd.js', [`${fdNumber}`, foobarString], getStdio(fdNumber, {file: filePath, append}));
166+
t.is(await readFile(filePath, 'utf8'), foobarString);
167+
await rm(filePath);
168+
};
169+
170+
test('Overwrite by default to stdout', testOverwriteFile, 1, execa, undefined);
171+
test('Overwrite by default to stderr', testOverwriteFile, 2, execa, undefined);
172+
test('Overwrite by default to stdio[*]', testOverwriteFile, 3, execa, undefined);
173+
test('Overwrite by default to stdout - sync', testOverwriteFile, 1, execaSync, undefined);
174+
test('Overwrite by default to stderr - sync', testOverwriteFile, 2, execaSync, undefined);
175+
test('Overwrite by default to stdio[*] - sync', testOverwriteFile, 3, execaSync, undefined);
176+
test('Overwrite with append false to stdout', testOverwriteFile, 1, execa, false);
177+
test('Overwrite with append false to stderr', testOverwriteFile, 2, execa, false);
178+
test('Overwrite with append false to stdio[*]', testOverwriteFile, 3, execa, false);
179+
test('Overwrite with append false to stdout - sync', testOverwriteFile, 1, execaSync, false);
180+
test('Overwrite with append false to stderr - sync', testOverwriteFile, 2, execaSync, false);
181+
test('Overwrite with append false to stdio[*] - sync', testOverwriteFile, 3, execaSync, false);
182+
183+
const testAppendFile = async (t, fdNumber, execaMethod) => {
184+
const filePath = tempfile();
185+
await writeFile(filePath, '.');
186+
await execaMethod('noop-fd.js', [`${fdNumber}`, foobarString], getStdio(fdNumber, {file: filePath, append: true}));
187+
t.is(await readFile(filePath, 'utf8'), `.${foobarString}`);
188+
await rm(filePath);
189+
};
190+
191+
test('Can append to stdout', testAppendFile, 1, execa);
192+
test('Can append to stderr', testAppendFile, 2, execa);
193+
test('Can append to stdio[*]', testAppendFile, 3, execa);
194+
test('Can append to stdout - sync', testAppendFile, 1, execaSync);
195+
test('Can append to stderr - sync', testAppendFile, 2, execaSync);
196+
test('Can append to stdio[*] - sync', testAppendFile, 3, execaSync);

types/stdio/type.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ type CommonStdioOption<
4949
> =
5050
| SimpleStdioOption<IsSync, IsExtra, IsArray>
5151
| URL
52-
| {readonly file: string}
52+
| {readonly file: string; readonly append?: boolean}
5353
| GeneratorTransform<IsSync>
5454
| GeneratorTransformFull<IsSync>
5555
| Unless<And<Not<IsSync>, IsArray>, 3 | 4 | 5 | 6 | 7 | 8 | 9>

0 commit comments

Comments
 (0)