Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ Check out documentation and other usage examples in the [`docs` directory](./doc
- `prefixLength`: how many characters to show when prefixing with `command`. Default: `10`
- `raw`: whether raw mode should be used, meaning strictly process output will
be logged, without any prefixes, coloring or extra stuff. Can be overridden per command.
- `preventClear`: whether escape sequences that clear the terminal should be removed from the output of commands. Has no effect in raw mode. Default: `false`.
- `successCondition`: the condition to consider the run was successful.
If `first`, only the first process to exit will make up the success of the run; if `last`, the last process that exits will determine whether the run succeeds.
Anything else means all processes should exit successfully.
Expand Down
10 changes: 10 additions & 0 deletions bin/__fixtures__/clear-screen.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* Prints a line, clears the screen the way create-react-app and `tsc --watch` do,
* then prints another line, used to test preventing the terminal from being cleared
*/
import process from 'node:process';

process.stdout.write('foo\n');
process.stdout.write('\x1B[2J\x1B[3J\x1B[H');
process.stdout.write('\x1Bc');
process.stdout.write('bar\n');
18 changes: 18 additions & 0 deletions bin/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,24 @@ describe('does not log any extra output', () => {
});
});

describe('--prevent-clear', () => {
const command = 'node __fixtures__/clear-screen.js';

it('keeps clear sequences in the output by default', async () => {
const lines = await run(`"${command}"`).getLogLines();

expect(lines.join('\n')).toContain('\x1B[2J');
expect(lines.join('\n')).toContain('\x1Bc');
});

it('removes clear sequences from the output', async () => {
const lines = await run(`--prevent-clear "${command}"`).getLogLines();

expect(lines.slice(0, 2)).toEqual(['[0] foo', '[0] bar']);
expect(lines.join('\n')).not.toContain('\x1B');
});
});

describe('--hide', () => {
it('hides the output of a process by its index', async () => {
const lines = await run('--hide 1 "echo foo" "echo bar"').getLogLines();
Expand Down
22 changes: 21 additions & 1 deletion bin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ const program = yargs(hideBin(process.argv))
default: defaults.hide,
type: 'string',
},
'prevent-clear': {
describe:
'Remove escape sequences that clear the terminal from the output of processes, ' +
'so that one process clearing the screen does not hide the output of the others.',
type: 'boolean',
},
group: {
alias: 'g',
describe: 'Order the output as if the commands were run sequentially.',
Expand Down Expand Up @@ -209,7 +215,20 @@ const program = yargs(hideBin(process.argv))
},
})
.group(
['m', 'n', 's', 'r', 'no-color', 'hide', 'g', 'timings', 'shell', 'P', 'teardown'],
[
'm',
'n',
's',
'r',
'no-color',
'hide',
'prevent-clear',
'g',
'timings',
'shell',
'P',
'teardown',
],
'General',
)
.group(['p', 'c', 'l', 't', 'pad-prefix'], 'Prefix styling')
Expand Down Expand Up @@ -258,6 +277,7 @@ try {
maxProcesses: args.maxProcesses,
raw: args.raw,
hide: args.hide.split(','),
preventClear: args.preventClear,
group: args.group,
prefix: args.prefix,
prefixColors: splitOutsideParens(args.prefixColors, ','),
Expand Down
11 changes: 11 additions & 0 deletions docs/cli/output-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ $ concurrently --hide 0 'echo Hello there' 'echo General Kenobi!'
[1] echo 'General Kenobi!' exited with code 0
```

## Preventing Clearing

Some commands clear the terminal, for example when restarting in watch mode, which also wipes the output of every other command.<br/>
The escape sequences used to clear the terminal can be removed from the output of all commands by using the `--prevent-clear` flag.

```bash
$ concurrently --prevent-clear 'tsc --watch' 'node --watch server.js'
```

This flag has no effect in raw mode, as concurrently doesn't process the output of commands in that mode.

## Grouping

It might be useful at times to make sure that the commands outputs are grouped together, while running them in parallel.<br/>
Expand Down
58 changes: 57 additions & 1 deletion lib/flow-control/log-output.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Buffer } from 'node:buffer';

import { beforeEach, expect, it } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';

import { createMockInstance } from '../__fixtures__/create-mock-instance.js';
import { FakeCommand } from '../__fixtures__/fake-command.js';
Expand Down Expand Up @@ -42,3 +42,59 @@ it('logs the stderr of each command', () => {
expect(logger.logCommandText).toHaveBeenCalledWith('foo', commands[0]);
expect(logger.logCommandText).toHaveBeenCalledWith('bar', commands[1]);
});

describe('with preventClear', () => {
beforeEach(() => {
controller = new LogOutput({ logger, preventClear: true });
});

it('keeps clear sequences by default', () => {
controller = new LogOutput({ logger });
controller.handle(commands);

commands[0].stdout.next(Buffer.from('foo\x1Bcbar'));

expect(logger.logCommandText).toHaveBeenCalledWith('foo\x1Bcbar', commands[0]);
});

it.each(['stdout', 'stderr'] as const)('removes clear sequences from %s', (stream) => {
controller.handle(commands);

commands[0][stream].next(Buffer.from('foo\n\x1B[2J\x1B[3J\x1B[Hbar\n'));

expect(logger.logCommandText).toHaveBeenCalledWith('foo\nbar\n', commands[0]);
});

it.each([
['full reset', '\x1Bc'],
['erase display', '\x1B[J'],
['erase screen', '\x1B[2J'],
['erase scrollback', '\x1B[3J'],
['cursor home', '\x1B[H'],
['cursor to 1;1', '\x1B[1;1H'],
['cursor to 0;0 with f', '\x1B[0;0f'],
])('removes the %s sequence', (_, sequence) => {
controller.handle(commands);

commands[0].stdout.next(Buffer.from(`foo${sequence}bar`));

expect(logger.logCommandText).toHaveBeenCalledWith('foobar', commands[0]);
});

it('keeps other escape sequences, such as colors and cursor moves', () => {
controller.handle(commands);

const text = '\x1B[31mred\x1B[0m\x1B[2K\x1B[5;10H\x1B[1A';
commands[0].stdout.next(Buffer.from(text));

expect(logger.logCommandText).toHaveBeenCalledWith(text, commands[0]);
});

it('does not log chunks that only contain clear sequences', () => {
controller.handle(commands);

commands[0].stdout.next(Buffer.from('\x1B[2J\x1B[3J\x1B[H'));

expect(logger.logCommandText).not.toHaveBeenCalled();
});
});
36 changes: 29 additions & 7 deletions lib/flow-control/log-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,47 @@ import { Command } from '../command.js';
import { Logger } from '../logger.js';
import { FlowController } from './flow-controller.js';

/**
* Matches the escape sequences that commands use to clear the terminal:
* - `ESC c`: full reset, used by e.g. `tsc --watch`;
* - `ESC [ <n> J`: erase in display, e.g. `ESC [2J` (screen) and `ESC [3J` (scrollback);
* - `ESC [ H`/`ESC [ f` with no or 0/1 coordinates: move the cursor to the top-left corner,
* which would make the next output overwrite what other commands logged.
*/
// eslint-disable-next-line no-control-regex
const CLEAR_SEQUENCE = /\x1bc|\x1b\[[0-3]?J|\x1b\[(?:[01]?(?:;[01]?)?)?[Hf]/g;

/**
* Logs the stdout and stderr output of commands.
*/
export class LogOutput implements FlowController {
private readonly logger: Logger;
constructor({ logger }: { logger: Logger }) {
private readonly preventClear: boolean;

constructor({ logger, preventClear = false }: { logger: Logger; preventClear?: boolean }) {
this.logger = logger;
this.preventClear = preventClear;
}

handle(commands: Command[]) {
commands.forEach((command) => {
command.stdout.subscribe((text) =>
this.logger.logCommandText(text.toString(), command),
);
command.stderr.subscribe((text) =>
this.logger.logCommandText(text.toString(), command),
);
command.stdout.subscribe((text) => this.log(text.toString(), command));
command.stderr.subscribe((text) => this.log(text.toString(), command));
});

return { commands };
}

private log(text: string, command: Command) {
if (!this.preventClear) {
this.logger.logCommandText(text, command);
return;
}

const output = text.replace(CLEAR_SEQUENCE, '');
// Don't log chunks that only contained clear sequences, as that would output an empty prefix.
if (output) {
this.logger.logCommandText(output, command);
}
}
}
9 changes: 8 additions & 1 deletion lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ export type ConcurrentlyOptions = Omit<
*/
raw?: boolean;

/**
* Whether escape sequences that clear the terminal should be removed from the output of commands,
* so that a command clearing the screen doesn't hide the output of the other commands.
* Has no effect when `raw` is set, as the output is then not processed.
*/
preventClear?: boolean;

/**
* Date format used when logging date/time.
* @see https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table
Expand Down Expand Up @@ -178,7 +185,7 @@ export function concurrently(
// LoggerPadding needs to run before any other controllers that might output something
...(options.padPrefix ? [new LoggerPadding({ logger })] : []),
new LogError({ logger }),
new LogOutput({ logger }),
new LogOutput({ logger, preventClear: options.preventClear }),
new LogExit({ logger }),
new InputHandler({
logger,
Expand Down
Loading