@augment-vir/node
Version:
A collection of augments, helpers types, functions, and classes only for Node.js (backend) JavaScript environments.
355 lines (330 loc) • 12.2 kB
text/typescript
import {combineErrors, log, type Logger} from '@augment-vir/common';
import {
type MaybePromise,
type PartialWithUndefined,
type RequiredAndNotNull,
} from '@augment-vir/core';
import {type ChildProcess, type ExecException, spawn} from 'node:child_process';
import {defineTypedCustomEvent, ListenTarget} from 'typed-event-target';
/**
* All output from {@link runShellCommand}.
*
* @category Node : Terminal : Util
* @category Package : @augment-vir/node
* @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
*/
export type ShellOutput = {
error: undefined | Error;
stderr: string;
stdout: string;
exitCode: number | undefined;
exitSignal: NodeJS.Signals | undefined;
};
/**
* An event that indicates that the shell command just wrote to stdout.
*
* @category Node : Terminal : Util
* @category Package : @augment-vir/node
* @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
*/
export class ShellStdoutEvent extends defineTypedCustomEvent<string | Buffer>()('shell-stdout') {}
/**
* An event that indicates that the shell command just wrote to stderr.
*
* @category Node : Terminal : Util
* @category Package : @augment-vir/node
* @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
*/
export class ShellStderrEvent extends defineTypedCustomEvent<string | Buffer>()('shell-stderr') {}
/**
* An event that indicates that the shell command is finished. This contains an exit code or an exit
* signal. Based on the Node.js documentation, either one or the other is defined, never both at the
* same time.
*
* @category Node : Terminal : Util
* @category Package : @augment-vir/node
* @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
*/
export class ShellDoneEvent extends defineTypedCustomEvent<{
exitCode: number | undefined;
exitSignal: NodeJS.Signals | undefined;
}>()('shell-done') {}
/**
* An event that indicates that the shell command errored.
*
* @category Node : Terminal : Util
* @category Package : @augment-vir/node
* @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
*/
export class ShellErrorEvent extends defineTypedCustomEvent<Error>()('shell-error') {}
/**
* A shell command listen target that emits events.
*
* @category Node : Terminal : Util
* @category Package : @augment-vir/node
* @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
*/
export class ShellTarget extends ListenTarget<
ShellStdoutEvent | ShellStderrEvent | ShellDoneEvent | ShellErrorEvent
> {
constructor(public readonly childProcess: ChildProcess) {
super();
}
}
/**
* Options for {@link streamShellCommand}.
*
* @category Node : Terminal : Util
* @category Package : @augment-vir/node
* @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
*/
export type StreamShellCommandOptions = PartialWithUndefined<{
cwd: string;
env: NodeJS.ProcessEnv;
shell: string;
/** Automatically hook up stdout and stderr printing to the caller's console methods. */
hookUpToConsole: boolean;
}>;
/**
* Runs a shell command and returns a {@link ShellTarget} instance for directly hooking into shell
* events. This allows instant reactions to shell events but in a less convenient API compared to
* {@link runShellCommand}.
*
* @category Node : Terminal
* @category Package : @augment-vir/node
* @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
* @see
* - {@link runShellCommand}: a higher level and more succinct way of running a shell command.
*/
export function streamShellCommand(
command: string,
{
cwd,
env = process.env,
shell = 'bash',
hookUpToConsole = false,
}: StreamShellCommandOptions = {},
): ShellTarget {
const stdio = hookUpToConsole ? [process.stdin] : undefined;
const childProcess = spawn(command, {
shell,
cwd,
env,
stdio,
});
const shellTarget = new ShellTarget(childProcess);
/** Type guards. */
/* node:coverage ignore next 5 */
if (!childProcess.stdout) {
throw new Error('stdout emitter was not created by exec for some reason.');
} else if (!childProcess.stderr) {
throw new Error('stderr emitter was not created by exec for some reason.');
}
childProcess.stdout.on('data', (chunk) => {
shellTarget.dispatch(
new ShellStdoutEvent({
detail: chunk,
}),
);
});
childProcess.stderr.on('data', (chunk) => {
shellTarget.dispatch(
new ShellStderrEvent({
detail: chunk,
}),
);
});
/** Idk how to trigger the 'error' event. */
/* node:coverage ignore next 7 */
childProcess.on('error', (error) => {
shellTarget.dispatch(
new ShellErrorEvent({
detail: error,
}),
);
});
/**
* Based on the Node.js documentation, we should listen to "close" instead of "exit" because the
* io streams will be finished when "close" emits. Also "close" always emits after "exit"
* anyway.
*/
childProcess.on('close', (inputExitCode, inputExitSignal) => {
/** Idk how to control exitCode or exitSignal being null or not-null. */
/* node:coverage ignore next 2 */
const exitCode: number | undefined = inputExitCode ?? undefined;
const exitSignal: NodeJS.Signals | undefined = inputExitSignal ?? undefined;
if ((exitCode !== undefined && exitCode !== 0) || exitSignal !== undefined) {
const execException: ExecException & {cwd?: string | undefined} = Object.assign(
new Error(`Command failed: ${command}`),
{
cmd: command,
},
);
if (exitCode != undefined) {
execException.code = exitCode;
}
/* node:coverage ignore next 3: idk how to get this to trigger */
if (exitSignal != undefined) {
execException.signal = exitSignal;
}
execException.killed = childProcess.killed;
execException.cwd = cwd;
shellTarget.dispatch(
new ShellErrorEvent({
detail: execException,
}),
);
}
shellTarget.dispatch(
new ShellDoneEvent({
detail: {
exitCode,
exitSignal,
},
}),
);
});
return shellTarget;
}
/**
* Options for {@link runShellCommand}.
*
* @category Node : Terminal : Util
* @category Package : @augment-vir/node
* @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
*/
export type RunShellCommandOptions = StreamShellCommandOptions &
PartialWithUndefined<{
/** @default false */
rejectOnError: boolean;
/** Callback to call whenever the shell logs to stdout. */
stdoutCallback: (
stdout: string,
childProcess: ChildProcess,
) => MaybePromise<void> | undefined;
/** Callback to call whenever the shell logs to stderr. */
stderrCallback: (
stderr: string,
childProcess: ChildProcess,
) => MaybePromise<void> | undefined;
}>;
/**
* Runs a shell command and returns its output.
*
* @category Node : Terminal
* @category Package : @augment-vir/node
* @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
* @see
* - {@link streamShellCommand}: a lower level way of running a shell command that allows instant reactions to shell events.
*/
export async function runShellCommand(
command: string,
options: RunShellCommandOptions = {},
): Promise<ShellOutput> {
return new Promise<ShellOutput>((resolve, reject) => {
let stdout = '';
let stderr = '';
const errors: Error[] = [];
const shellTarget = streamShellCommand(command, {
cwd: options.cwd,
shell: options.shell,
env: options.env,
hookUpToConsole: options.hookUpToConsole,
});
shellTarget.listen(ShellStdoutEvent, ({detail: chunk}) => {
if (options.stdoutCallback) {
void options.stdoutCallback(chunk.toString(), shellTarget.childProcess);
}
if (options.hookUpToConsole) {
process.stdout.write(chunk.toString());
}
stdout += String(chunk);
});
shellTarget.listen(ShellStderrEvent, ({detail: chunk}) => {
if (options.stderrCallback) {
void options.stderrCallback(chunk.toString(), shellTarget.childProcess);
}
if (options.hookUpToConsole) {
process.stderr.write(chunk.toString());
}
stderr += String(chunk);
});
shellTarget.listen(ShellErrorEvent, ({detail: error}) => {
errors.push(error);
if (!options.rejectOnError) {
return;
}
/** Covering edge cases. */
/* node:coverage disable */
if (shellTarget.childProcess.connected) {
shellTarget.childProcess.disconnect();
}
if (
shellTarget.childProcess.exitCode == null &&
shellTarget.childProcess.signalCode == null &&
!shellTarget.childProcess.killed
) {
shellTarget.childProcess.kill();
}
/* node:coverage enable */
shellTarget.destroy();
const rejectionErrorMessage: Error = combineErrors([
new Error(stderr),
...errors,
]);
/** Reject now because the "done" listener won't get fired after killing the process. */
reject(rejectionErrorMessage);
});
shellTarget.listen(ShellDoneEvent, ({detail: {exitCode, exitSignal}}) => {
shellTarget.destroy();
resolve({
error: errors.length ? combineErrors(errors) : undefined,
stdout,
stderr,
exitCode,
exitSignal,
});
});
});
}
/**
* Options for {@link logShellOutput}.
*
* @category Node : Terminal : Util
* @category Package : @augment-vir/node
* @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
*/
export type LogShellOutputOptions = PartialWithUndefined<{
logger: Logger;
withLabels: boolean;
ignoreError: boolean;
}>;
const defaultLogShellOutputOptions: RequiredAndNotNull<LogShellOutputOptions> = {
ignoreError: false,
logger: log,
withLabels: false,
};
/**
* Log the output of running a shell command. This is useful for quick debugging of shell commands.
*
* @category Node : Terminal : Util
* @category Package : @augment-vir/node
* @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
*/
export function logShellOutput(
shellOutput: PartialWithUndefined<Omit<ShellOutput, 'exitSignal'>>,
{
ignoreError = defaultLogShellOutputOptions.ignoreError,
logger = defaultLogShellOutputOptions.logger,
withLabels = defaultLogShellOutputOptions.withLabels,
}: LogShellOutputOptions = defaultLogShellOutputOptions,
) {
logger.if(withLabels).info('exit code');
logger.if(shellOutput.exitCode != undefined || withLabels).plain(shellOutput.exitCode || 0);
logger.if(withLabels).info('stdout');
logger.if(!!shellOutput.stdout || withLabels).plain(shellOutput.stdout || '');
logger.if(withLabels).info('stderr');
logger.if(!!shellOutput.stderr || withLabels).error(shellOutput.stderr || '');
logger.if(withLabels && !ignoreError).info('error');
logger.if((!!shellOutput.error || withLabels) && !ignoreError).error(shellOutput.error);
}