diff --git a/bin/concurrently.spec.ts b/bin/concurrently.spec.ts index d41c2889..27baf3a7 100644 --- a/bin/concurrently.spec.ts +++ b/bin/concurrently.spec.ts @@ -59,12 +59,10 @@ const run = (args: string, ctrlcWrapper?: boolean) => { const stdout = readline.createInterface({ input: child.stdout, - output: null, }); const stderr = readline.createInterface({ input: child.stderr, - output: null, }); const log = new Rx.Observable((observer) => { @@ -82,8 +80,8 @@ const run = (args: string, ctrlcWrapper?: boolean) => { }); const exit = Rx.firstValueFrom( - Rx.fromEvent(child, 'exit').pipe( - map((exit: [number | null, NodeJS.Signals | null]) => { + Rx.fromEvent<[number | null, NodeJS.Signals | null]>(child, 'exit').pipe( + map((exit) => { return { /** The exit code if the child exited on its own. */ code: exit[0], @@ -180,7 +178,7 @@ describe('exiting conditions', () => { // Instruct the wrapper to send CTRL+C to its child sendCtrlC(child.process); } else { - process.kill(child.pid, 'SIGINT'); + process.kill(Number(child.pid), 'SIGINT'); } } }); diff --git a/src/command-parser/expand-npm-shortcut.spec.ts b/src/command-parser/expand-npm-shortcut.spec.ts index 7d791c1a..286b100f 100644 --- a/src/command-parser/expand-npm-shortcut.spec.ts +++ b/src/command-parser/expand-npm-shortcut.spec.ts @@ -3,7 +3,7 @@ import { ExpandNpmShortcut } from './expand-npm-shortcut'; const parser = new ExpandNpmShortcut(); -const createCommandInfo = (command: string, name?: string): CommandInfo => ({ +const createCommandInfo = (command: string, name = ''): CommandInfo => ({ name, command, }); diff --git a/src/command-parser/expand-npm-wildcard.spec.ts b/src/command-parser/expand-npm-wildcard.spec.ts index b5ac271e..9c0a7954 100644 --- a/src/command-parser/expand-npm-wildcard.spec.ts +++ b/src/command-parser/expand-npm-wildcard.spec.ts @@ -30,7 +30,7 @@ describe('ExpandNpmWildcard#readPackage', () => { if (path === 'package.json') { return JSON.stringify(expectedPackage); } - return null; + return ''; }); const actualReadPackage = ExpandNpmWildcard.readPackage(); diff --git a/src/command.spec.ts b/src/command.spec.ts index 0a03579c..b6e25172 100644 --- a/src/command.spec.ts +++ b/src/command.spec.ts @@ -44,7 +44,7 @@ beforeEach(() => { killProcess = jest.fn(); }); -const createCommand = (overrides?: Partial, spawnOpts?: SpawnOptions) => { +const createCommand = (overrides?: Partial, spawnOpts: SpawnOptions = {}) => { const command = new Command( { index: 0, name: '', command: 'echo foo', ...overrides }, spawnOpts, @@ -198,7 +198,7 @@ describe('#start()', () => { const { command } = createCommand(); const stdout = Rx.firstValueFrom(command.stdout); command.start(); - process.stdout.emit('data', Buffer.from('hello')); + process.stdout?.emit('data', Buffer.from('hello')); expect((await stdout).toString()).toBe('hello'); }); @@ -207,7 +207,7 @@ describe('#start()', () => { const { command } = createCommand(); const stderr = Rx.firstValueFrom(command.stderr); command.start(); - process.stderr.emit('data', Buffer.from('dang')); + process.stderr?.emit('data', Buffer.from('dang')); expect((await stderr).toString()).toBe('dang'); }); @@ -252,3 +252,16 @@ describe('#kill()', () => { expect(close).toMatchObject({ exitCode: 1, killed: true }); }); }); + +describe('.canKill()', () => { + it('returns whether command has both PID and process', () => { + const { command } = createCommand(); + expect(Command.canKill(command)).toBe(false); + + command.pid = 1; + expect(Command.canKill(command)).toBe(false); + + command.process = process; + expect(Command.canKill(command)).toBe(true); + }); +}); diff --git a/src/command.ts b/src/command.ts index 77196b9c..17255f8b 100644 --- a/src/command.ts +++ b/src/command.ts @@ -92,7 +92,7 @@ export class Command implements CommandInfo { readonly command: string; /** @inheritdoc */ - readonly prefixColor: string; + readonly prefixColor?: string; /** @inheritdoc */ readonly env: Record; @@ -112,8 +112,9 @@ export class Command implements CommandInfo { killed = false; exited = false; + /** @deprecated */ get killable() { - return !!this.process; + return Command.canKill(this); } constructor( @@ -126,7 +127,7 @@ export class Command implements CommandInfo { this.name = name; this.command = command; this.prefixColor = prefixColor; - this.env = env; + this.env = env || {}; this.cwd = cwd; this.killProcess = killProcess; this.spawn = spawn; @@ -161,7 +162,7 @@ export class Command implements CommandInfo { this.close.next({ command: this, index: this.index, - exitCode: exitCode === null ? signal : exitCode, + exitCode: exitCode ?? String(signal), killed: this.killed, timings: { startDate, @@ -173,18 +174,27 @@ export class Command implements CommandInfo { ); child.stdout && pipeTo(Rx.fromEvent(child.stdout, 'data'), this.stdout); child.stderr && pipeTo(Rx.fromEvent(child.stderr, 'data'), this.stderr); - this.stdin = child.stdin; + this.stdin = child.stdin || undefined; } /** * Kills this command, optionally specifying a signal to send to it. */ kill(code?: string) { - if (this.killable) { + if (Command.canKill(this)) { this.killed = true; this.killProcess(this.pid, code); } } + + /** + * Detects whether a command can be killed. + * + * Also works as a type guard on the input `command`. + */ + static canKill(command: Command): command is Command & { pid: number; process: ChildProcess } { + return !!command.pid && !!command.process; + } } /** diff --git a/src/completion-listener.ts b/src/completion-listener.ts index a3aa2deb..26035f43 100644 --- a/src/completion-listener.ts +++ b/src/completion-listener.ts @@ -92,11 +92,15 @@ export class CompletionListener { bufferCount(closeStreams.length), switchMap((exitInfos) => this.isSuccess(exitInfos) - ? Rx.of(exitInfos, this.scheduler) - : Rx.throwError(exitInfos, this.scheduler) + ? this.emitWithScheduler(Rx.of(exitInfos)) + : this.emitWithScheduler(Rx.throwError(exitInfos)) ), take(1) ) ); } + + private emitWithScheduler(input: Rx.Observable): Rx.Observable { + return this.scheduler ? input.pipe(Rx.observeOn(this.scheduler)) : input; + } } diff --git a/src/concurrently.ts b/src/concurrently.ts index 98863bee..ba269e6f 100644 --- a/src/concurrently.ts +++ b/src/concurrently.ts @@ -31,7 +31,7 @@ const defaults: ConcurrentlyOptions = { * If value is a string, then that's the command's command line. * Fine grained options can be defined by using the object format. */ -export type ConcurrentlyCommandInput = string | Partial; +export type ConcurrentlyCommandInput = string | ({ command: string } & Partial); export type ConcurrentlyResult = { /** @@ -175,14 +175,17 @@ export function concurrently( onFinishCallbacks: _.concat(onFinishCallbacks, onFinish ? [onFinish] : []), }; }, - { commands, onFinishCallbacks: [] } + { commands, onFinishCallbacks: [] } as { + commands: Command[]; + onFinishCallbacks: (() => void)[]; + } ); commands = handleResult.commands; if (options.logger && options.outputStream) { const outputWriter = new OutputWriter({ outputStream: options.outputStream, - group: options.group, + group: !!options.group, commands, }); options.logger.output.subscribe(({ command, text }) => outputWriter.write(command, text)); @@ -213,14 +216,10 @@ export function concurrently( function mapToCommandInfo(command: ConcurrentlyCommandInput): CommandInfo { if (typeof command === 'string') { - return { - command, - name: '', - env: {}, - cwd: '', - }; + return mapToCommandInfo({ command }); } + assert.ok(command.command, '[concurrently] command cannot be empty'); return { command: command.command, name: command.name || '', diff --git a/src/flow-control/input-handler.spec.ts b/src/flow-control/input-handler.spec.ts index dc48f3ee..165e13a0 100644 --- a/src/flow-control/input-handler.spec.ts +++ b/src/flow-control/input-handler.spec.ts @@ -37,7 +37,7 @@ it('does nothing if called without input stream', () => { }).handle(commands); inputStream.write('something'); - expect(commands[0].stdin.write).not.toHaveBeenCalled(); + expect(commands[0].stdin?.write).not.toHaveBeenCalled(); }); it('forwards input stream to default target ID', () => { @@ -45,9 +45,9 @@ it('forwards input stream to default target ID', () => { inputStream.write('something'); - expect(commands[0].stdin.write).toHaveBeenCalledTimes(1); - expect(commands[0].stdin.write).toHaveBeenCalledWith('something'); - expect(commands[1].stdin.write).not.toHaveBeenCalled(); + expect(commands[0].stdin?.write).toHaveBeenCalledTimes(1); + expect(commands[0].stdin?.write).toHaveBeenCalledWith('something'); + expect(commands[1].stdin?.write).not.toHaveBeenCalled(); }); it('forwards input stream to target index specified in input', () => { @@ -55,9 +55,9 @@ it('forwards input stream to target index specified in input', () => { inputStream.write('1:something'); - expect(commands[0].stdin.write).not.toHaveBeenCalled(); - expect(commands[1].stdin.write).toHaveBeenCalledTimes(1); - expect(commands[1].stdin.write).toHaveBeenCalledWith('something'); + expect(commands[0].stdin?.write).not.toHaveBeenCalled(); + expect(commands[1].stdin?.write).toHaveBeenCalledTimes(1); + expect(commands[1].stdin?.write).toHaveBeenCalledWith('something'); }); it('forwards input stream to target index specified in input when input contains colon', () => { @@ -66,10 +66,10 @@ it('forwards input stream to target index specified in input when input contains inputStream.emit('data', Buffer.from('1::something')); inputStream.emit('data', Buffer.from('1:some:thing')); - expect(commands[0].stdin.write).not.toHaveBeenCalled(); - expect(commands[1].stdin.write).toHaveBeenCalledTimes(2); - expect(commands[1].stdin.write).toHaveBeenCalledWith(':something'); - expect(commands[1].stdin.write).toHaveBeenCalledWith('some:thing'); + expect(commands[0].stdin?.write).not.toHaveBeenCalled(); + expect(commands[1].stdin?.write).toHaveBeenCalledTimes(2); + expect(commands[1].stdin?.write).toHaveBeenCalledWith(':something'); + expect(commands[1].stdin?.write).toHaveBeenCalledWith('some:thing'); }); it('forwards input stream to target name specified in input', () => { @@ -77,18 +77,18 @@ it('forwards input stream to target name specified in input', () => { inputStream.write('bar:something'); - expect(commands[0].stdin.write).not.toHaveBeenCalled(); - expect(commands[1].stdin.write).toHaveBeenCalledTimes(1); - expect(commands[1].stdin.write).toHaveBeenCalledWith('something'); + expect(commands[0].stdin?.write).not.toHaveBeenCalled(); + expect(commands[1].stdin?.write).toHaveBeenCalledTimes(1); + expect(commands[1].stdin?.write).toHaveBeenCalledWith('something'); }); it('logs error if command has no stdin open', () => { - commands[0].stdin = null; + commands[0].stdin = undefined; controller.handle(commands); inputStream.write('something'); - expect(commands[1].stdin.write).not.toHaveBeenCalled(); + expect(commands[1].stdin?.write).not.toHaveBeenCalled(); expect(logger.logGlobalEvent).toHaveBeenCalledWith( 'Unable to find command 0, or it has no stdin open\n' ); @@ -99,8 +99,8 @@ it('logs error if command is not found', () => { inputStream.write('foobar:something'); - expect(commands[0].stdin.write).not.toHaveBeenCalled(); - expect(commands[1].stdin.write).not.toHaveBeenCalled(); + expect(commands[0].stdin?.write).not.toHaveBeenCalled(); + expect(commands[1].stdin?.write).not.toHaveBeenCalled(); expect(logger.logGlobalEvent).toHaveBeenCalledWith( 'Unable to find command foobar, or it has no stdin open\n' ); @@ -112,7 +112,8 @@ it('pauses input stream when finished', () => { const { onFinish } = controller.handle(commands); expect(inputStream.readableFlowing).toBe(true); - onFinish(); + expect(onFinish).toBeDefined(); + onFinish?.(); expect(inputStream.readableFlowing).toBe(false); }); @@ -124,6 +125,7 @@ it('does not pause input stream when pauseInputStreamOnFinish is set to false', const { onFinish } = controller.handle(commands); expect(inputStream.readableFlowing).toBe(true); - onFinish(); + expect(onFinish).toBeDefined(); + onFinish?.(); expect(inputStream.readableFlowing).toBe(true); }); diff --git a/src/flow-control/input-handler.ts b/src/flow-control/input-handler.ts index 18fb8d82..f537c8fc 100644 --- a/src/flow-control/input-handler.ts +++ b/src/flow-control/input-handler.ts @@ -19,7 +19,7 @@ import { FlowController } from './flow-controller'; export class InputHandler implements FlowController { private readonly logger: Logger; private readonly defaultInputTarget: CommandIdentifier; - private readonly inputStream: Readable; + private readonly inputStream?: Readable; private readonly pauseInputStreamOnFinish: boolean; constructor({ @@ -28,7 +28,7 @@ export class InputHandler implements FlowController { pauseInputStreamOnFinish, logger, }: { - inputStream: Readable; + inputStream?: Readable; logger: Logger; defaultInputTarget?: CommandIdentifier; pauseInputStreamOnFinish?: boolean; @@ -43,12 +43,13 @@ export class InputHandler implements FlowController { commands: Command[]; onFinish?: () => void | undefined; } { - if (!this.inputStream) { + const { inputStream } = this; + if (!inputStream) { return { commands }; } - Rx.fromEvent(this.inputStream, 'data') - .pipe(map((data) => data.toString())) + Rx.fromEvent(inputStream, 'data') + .pipe(map((data) => String(data))) .subscribe((data) => { const dataParts = data.split(/:(.+)/); const targetId = dataParts.length > 1 ? dataParts[0] : this.defaultInputTarget; @@ -74,7 +75,7 @@ export class InputHandler implements FlowController { onFinish: () => { if (this.pauseInputStreamOnFinish) { // https://github.com/kimmobrunfeldt/concurrently/issues/252 - this.inputStream.pause(); + inputStream.pause(); } }, }; diff --git a/src/flow-control/kill-others.ts b/src/flow-control/kill-others.ts index 7178c48f..39f9d095 100644 --- a/src/flow-control/kill-others.ts +++ b/src/flow-control/kill-others.ts @@ -8,7 +8,7 @@ import { FlowController } from './flow-controller'; export type ProcessCloseCondition = 'failure' | 'success'; /** - * Sends a SIGTERM signal to all commands when one of the exits with a matching condition. + * Sends a SIGTERM signal to all commands when one of the commands exits with a matching condition. */ export class KillOthers implements FlowController { private readonly logger: Logger; diff --git a/src/flow-control/log-timings.ts b/src/flow-control/log-timings.ts index 244d149f..8bcd4277 100644 --- a/src/flow-control/log-timings.ts +++ b/src/flow-control/log-timings.ts @@ -1,3 +1,4 @@ +import * as assert from 'assert'; import formatDate from 'date-fns/format'; import _ from 'lodash'; import * as Rx from 'rxjs'; @@ -53,6 +54,8 @@ export class LogTimings implements FlowController { } private printExitInfoTimingTable(exitInfos: CloseEvent[]) { + assert.ok(this.logger); + const exitInfoTable = _(exitInfos) .sortBy(({ timings }) => timings.durationSeconds) .reverse() @@ -65,7 +68,8 @@ export class LogTimings implements FlowController { } handle(commands: Command[]) { - if (!this.logger) { + const { logger } = this; + if (!logger) { return { commands }; } @@ -74,14 +78,14 @@ export class LogTimings implements FlowController { command.timer.subscribe(({ startDate, endDate }) => { if (!endDate) { const formattedStartDate = formatDate(startDate, this.timestampFormat); - this.logger.logCommandEvent( + logger.logCommandEvent( `${command.command} started at ${formattedStartDate}`, command ); } else { const durationMs = endDate.getTime() - startDate.getTime(); const formattedEndDate = formatDate(endDate, this.timestampFormat); - this.logger.logCommandEvent( + logger.logCommandEvent( `${ command.command } stopped at ${formattedEndDate} after ${durationMs.toLocaleString()}ms`, diff --git a/src/index.ts b/src/index.ts index 4773e701..43c22224 100644 --- a/src/index.ts +++ b/src/index.ts @@ -118,7 +118,8 @@ export default ( new InputHandler({ logger, defaultInputTarget: options.defaultInputTarget, - inputStream: options.inputStream || (options.handleInput && process.stdin), + inputStream: + options.inputStream || (options.handleInput ? process.stdin : undefined), pauseInputStreamOnFinish: options.pauseInputStreamOnFinish, }), new KillOnSignal({ process }), @@ -129,10 +130,10 @@ export default ( }), new KillOthers({ logger, - conditions: options.killOthers, + conditions: options.killOthers || [], }), new LogTimings({ - logger: options.timings ? logger : null, + logger: options.timings ? logger : undefined, timestampFormat: options.timestampFormat, }), ], diff --git a/src/logger.spec.ts b/src/logger.spec.ts index b9156c9e..4eab2d68 100644 --- a/src/logger.spec.ts +++ b/src/logger.spec.ts @@ -261,7 +261,7 @@ describe('#logTable()', () => { it('does not log anything if value is not an array', () => { const logger = createLogger({}); logger.logTable({} as never); - logger.logTable(null); + logger.logTable(null as never); logger.logTable(0 as never); logger.logTable('' as never); diff --git a/src/logger.ts b/src/logger.ts index 8bd800ed..1bdd5e87 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -124,7 +124,7 @@ export class Logger { color = chalk.hex(command.prefixColor); } else { const defaultColor = _.get(chalk, defaults.prefixColors, chalk.reset); - color = _.get(chalk, command.prefixColor, defaultColor); + color = _.get(chalk, command.prefixColor ?? '', defaultColor); } return color(text); } diff --git a/src/prefix-color-selector.ts b/src/prefix-color-selector.ts index 94338ec5..95c17580 100644 --- a/src/prefix-color-selector.ts +++ b/src/prefix-color-selector.ts @@ -13,7 +13,7 @@ function getConsoleColorsWithoutCustomColors(customColors: string[]): string[] { function* createColorGenerator(customColors: string[]): Generator { // Custom colors should be used as is, except for "auto" const nextAutoColors: string[] = getConsoleColorsWithoutCustomColors(customColors); - let lastColor: string; + let lastColor: string | undefined; for (const customColor of customColors) { let currentColor = customColor; if (currentColor !== 'auto') { @@ -25,7 +25,7 @@ function* createColorGenerator(customColors: string[]): Generator