Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow appending to files #1166

Merged
merged 1 commit into from
Oct 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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