Skip to content

Commit

Permalink
Allow appending to files (#1166)
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky authored Oct 27, 2024
1 parent 091ed74 commit 94a75df
Show file tree
Hide file tree
Showing 10 changed files with 160 additions and 6 deletions.
21 changes: 21 additions & 0 deletions docs/bash.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions lib/io/output-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion lib/stdio/handle-async.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion lib/stdio/handle-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion lib/stdio/type.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
44 changes: 44 additions & 0 deletions test-d/stdio/option/file-append-invalid.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<StdinOption>(invalidFileAppend);
expectNotAssignable<StdinSyncOption>(invalidFileAppend);
expectNotAssignable<StdinOption>([invalidFileAppend]);
expectNotAssignable<StdinSyncOption>([invalidFileAppend]);

expectNotAssignable<StdoutStderrOption>(invalidFileAppend);
expectNotAssignable<StdoutStderrSyncOption>(invalidFileAppend);
expectNotAssignable<StdoutStderrOption>([invalidFileAppend]);
expectNotAssignable<StdoutStderrSyncOption>([invalidFileAppend]);
44 changes: 44 additions & 0 deletions test-d/stdio/option/file-append.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<StdinOption>(fileAppend);
expectAssignable<StdinSyncOption>(fileAppend);
expectAssignable<StdinOption>([fileAppend]);
expectAssignable<StdinSyncOption>([fileAppend]);

expectAssignable<StdoutStderrOption>(fileAppend);
expectAssignable<StdoutStderrSyncOption>(fileAppend);
expectAssignable<StdoutStderrOption>([fileAppend]);
expectAssignable<StdoutStderrSyncOption>([fileAppend]);
36 changes: 36 additions & 0 deletions test/stdio/file-path-main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
2 changes: 1 addition & 1 deletion types/stdio/type.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ type CommonStdioOption<
> =
| SimpleStdioOption<IsSync, IsExtra, IsArray>
| URL
| {readonly file: string}
| {readonly file: string; readonly append?: boolean}
| GeneratorTransform<IsSync>
| GeneratorTransformFull<IsSync>
| Unless<And<Not<IsSync>, IsArray>, 3 | 4 | 5 | 6 | 7 | 8 | 9>
Expand Down

0 comments on commit 94a75df

Please sign in to comment.