@naturalcycles/nodejs-lib
Version:
Standard library for Node.js
466 lines (429 loc) • 12.5 kB
text/typescript
import { execSync, spawn, spawnSync } from 'node:child_process'
import type { StdioOptions } from 'node:child_process'
import { _since } from '@naturalcycles/js-lib/datetime/time.util.js'
import { AppError } from '@naturalcycles/js-lib/error/error.util.js'
import { _substringAfterLast } from '@naturalcycles/js-lib/string/string.util.js'
import type {
AnyObject,
NumberOfMilliseconds,
UnixTimestampMillis,
} from '@naturalcycles/js-lib/types'
import { dimGrey, dimRed, hasColors, white } from '../colors/colors.js'
/**
* Set of utility functions to work with Spawn / Exec.
*
* How to decide between Spawn and Exec?
*
* Long-running job that prints output, and no need to return the output - use Spawn.
*
* Short-running job, no need to print the output, might want to return the output - use Exec.
*
* Need to both print and return the output - use SpawnAsyncAndReturn.
*
* ***
*
* Spawn is good for long-running large-output processes, that continuously output data.
* E.g running `jest`.
*
* Exec is the opposite - good for short-running processes that output small data.
* Exec allows to return the output as a string.
* Exec doesn't stream data during execution, so the output/error will only be printed
* at the end.
* Exec always uses the shell (there's no option to disable it).
*/
class Exec2 {
/**
* Reasons to use it:
* - Sync
* - Need to print output while running
*
* Limitations:
* - Cannot return stdout/stderr (use exec, execAsync or spawnAsyncAndReturn for that)
*
* Defaults:
*
* shell: true
* log: true
*/
spawn(cmd: string, opt: SpawnOptions = {}): void {
const {
shell = true,
cwd,
env,
passProcessEnv = true,
forceColor = hasColors,
stdio = 'inherit',
} = opt
opt.log ??= true // by default log should be true, as we are printing the output
opt.logStart ??= opt.log
opt.logFinish ??= opt.log
const started = Date.now() as UnixTimestampMillis
this.logStart(cmd, opt)
const r = spawnSync(cmd, opt.args, {
encoding: 'utf8',
stdio,
shell,
cwd,
env: {
...(passProcessEnv ? process.env : {}),
...(forceColor ? { FORCE_COLOR: '1' } : {}),
...env,
},
})
const isSuccessful = !r.error && !r.status
this.logFinish(cmd, opt, started, isSuccessful)
if (r.error) {
throw r.error
}
if (r.status) {
throw new Error(`spawn exited with code ${r.status}: ${cmd}`)
}
}
/**
* Reasons to use it:
*
* - Sync
* - Need to return output
*
* Limitations:
* - Cannot print while running (use spawn or spawnAsync for that)
*
* Defaults:
*
* shell: true
* log: false
*/
exec(cmd: string, opt: ExecOptions = {}): string {
const { cwd, env, passProcessEnv = true, timeout, stdio } = opt
opt.logStart ??= opt.log ?? false
opt.logFinish ??= opt.log ?? false
const started = Date.now() as UnixTimestampMillis
this.logStart(cmd, opt)
try {
const s = execSync(cmd, {
encoding: 'utf8',
stdio,
// shell: undefined,
cwd,
timeout,
env: {
...(passProcessEnv ? process.env : {}),
...env,
},
}).trim()
this.logFinish(cmd, opt, started, true)
return s
} catch (err) {
// Not logging stderr, as it's printed by execSync by default (somehow)
// stdout is not printed by execSync though, therefor we print it here
// if ((err as any).stderr) {
// process.stderr.write((err as any).stderr)
// }
if ((err as any).stdout) {
process.stdout.write((err as any).stdout)
}
this.logFinish(cmd, opt, started, false)
// oxlint-disable-next-line preserve-caught-error
throw new Error(`exec exited with code ${(err as any).status}: ${cmd}`)
}
}
/**
* Reasons to use it:
* - Async
* - Need to print output while running
*
* Limitations:
* - Cannot return stdout/stderr (use execAsync or spawnAsyncAndReturn for that)
*
* Defaults:
*
* shell: true
* log: true
*/
async spawnAsync(cmd: string, opt: SpawnOptions = {}): Promise<void> {
const {
shell = true,
cwd,
env,
passProcessEnv = true,
forceColor = hasColors,
stdio = 'inherit',
} = opt
opt.log ??= true // by default log should be true, as we are printing the output
opt.logStart ??= opt.log
opt.logFinish ??= opt.log
const started = Date.now() as UnixTimestampMillis
this.logStart(cmd, opt)
await new Promise<void>((resolve, reject) => {
const p = spawn(cmd, opt.args || [], {
shell,
cwd,
stdio,
env: {
...(passProcessEnv ? process.env : {}),
...(forceColor ? { FORCE_COLOR: '1' } : {}),
...env,
},
})
p.on('close', (code, signal) => {
const isSuccessful = code === 0
this.logFinish(cmd, opt, started, isSuccessful)
if (signal) {
return reject(new Error(`spawnAsync killed by signal ${signal}: ${cmd}`))
}
if (!isSuccessful) {
return reject(new Error(`spawnAsync exited with code ${code}: ${cmd}`))
}
resolve()
})
// Important to have this error listener.
// Without it - the process hangs, and `close` is never emitted
p.on('error', err => {
console.error(err)
})
})
}
/**
* Advanced/async version of Spawn.
* Consider simpler `spawn` or `exec` first, which are also sync.
*
* spawnAsyncAndReturn features:
*
* 1. Async
* 2. Allows to collect the output AND print it while running.
* 3. Returns SpawnOutput with stdout, stderr and exitCode.
* 4. Allows to not throw on error, but just return SpawnOutput for further inspection.
*
* Defaults:
*
* shell: true
* printWhileRunning: true
* collectOutputWhileRunning: true
* throwOnNonZeroCode: true
* log: true
*/
async spawnAsyncAndReturn(cmd: string, opt: SpawnAsyncOptions = {}): Promise<SpawnOutput> {
const {
shell = true,
printWhileRunning = true,
collectOutputWhileRunning = true,
throwOnNonZeroCode = true,
cwd,
env,
passProcessEnv = true,
forceColor = hasColors,
} = opt
opt.log ??= printWhileRunning // by default log should be true, as we are printing the output
opt.logStart ??= opt.log
opt.logFinish ??= opt.log
const started = Date.now() as UnixTimestampMillis
this.logStart(cmd, opt)
let stdout = ''
let stderr = ''
return await new Promise<SpawnOutput>((resolve, reject) => {
const p = spawn(cmd, opt.args || [], {
shell,
cwd,
env: {
...(passProcessEnv ? process.env : {}),
...(forceColor ? { FORCE_COLOR: '1' } : {}),
...env,
},
})
p.stdout.on('data', data => {
if (collectOutputWhileRunning) {
stdout += data.toString()
// console.log('stdout:', data.toString())
}
if (printWhileRunning) {
process.stdout.write(data)
// console.log('stderr:', data.toString())
}
})
p.stderr.on('data', data => {
if (collectOutputWhileRunning) {
stderr += data.toString()
}
if (printWhileRunning) {
process.stderr.write(data)
}
})
p.on('close', (code, signal) => {
const isSuccessful = code === 0
this.logFinish(cmd, opt, started, isSuccessful)
const exitCode = code ?? -1
const o: SpawnOutput = {
exitCode,
stdout: stdout.trim(),
stderr: stderr.trim(),
}
if (signal) {
return reject(new SpawnError(`spawnAsyncAndReturn killed by signal ${signal}: ${cmd}`, o))
}
if (throwOnNonZeroCode && !isSuccessful) {
return reject(new SpawnError(`spawnAsyncAndReturn exited with code ${code}: ${cmd}`, o))
}
resolve(o)
})
// Important to have this error listener.
// Without it - the process hangs, and `close` is never emitted
p.on('error', err => {
console.error(err)
})
})
}
private logStart(cmd: string, opt: SpawnOptions | ExecOptions): void {
if (!opt.logStart) return
const envString = Object.entries(opt.env || {})
.map(([k, v]) => [k, v].join('='))
.join(' ')
if (opt.name) {
console.log([' ', white(opt.name), dimGrey('started...')].filter(Boolean).join(' '))
} else {
console.log(
[
' ',
dimGrey(envString),
// todo: only before first space
white(_substringAfterLast(cmd, '/')),
...((opt as SpawnOptions).args || []),
]
.filter(Boolean)
.join(' '),
)
}
}
private logFinish(
cmd: string,
opt: SpawnOptions | ExecOptions,
started: UnixTimestampMillis,
isSuccessful: boolean,
): void {
if (isSuccessful && !opt.logFinish) return
console.log(
[
isSuccessful ? ' ✓' : ' ×',
white(opt.name || _substringAfterLast(cmd, '/')),
...((!opt.name && (opt as SpawnOptions).args) || []),
dimGrey('took ' + _since(started)),
!isSuccessful && dimGrey('and ') + dimRed('failed'),
]
.filter(Boolean)
.join(' '),
)
}
}
export const exec2 = new Exec2()
export class SpawnError extends AppError<SpawnErrorData> {
constructor(message: string, data: SpawnErrorData) {
super(message, data, { name: 'SpawnError' })
}
}
export interface SpawnErrorData extends SpawnOutput {}
export interface SpawnOutput {
/**
* Exit code of the spawned process.
* 0 means success, anything else means failure.
*/
exitCode: number
stdout: string
stderr: string
}
export interface SpawnAsyncOptions extends SpawnOptions {
/**
* Defaults to true.
* If true - prints both stdout and stderr to console while running,
* otherwise runs "silently".
* Returns SpawnOutput in the same way, regardless of `printWhileRunning` setting.
*/
printWhileRunning?: boolean
/**
* Defaults to true.
* If true - collects stdout and stderr while running, and return it in the end.
* stdout/stderr are collected and returned regardless if it returns with error or not.
* On success - stdout/stderr are available from `SpawnOutput`.
* On error - stdout/stderr are available from `SpawnError.data`.
*/
collectOutputWhileRunning?: boolean
/**
* Defaults to true.
* If true - throws SpawnError if non-zero code is returned.
* SpawnError conveniently contains .data.stdout and .data.strerr for inspection.
* If false - will not throw, but return SpawnOutput with stdout, stderr and exitCode.
*/
throwOnNonZeroCode?: boolean
}
export interface SpawnOptions {
args?: string[]
/**
* Defaults to true.
*/
logStart?: boolean
/**
* Defaults to true.
*/
logFinish?: boolean
/**
* Defaults to true.
* Controls/overrides both logStart and logFinish simultaneously.
*/
log?: boolean
/**
* Defaults to true.
*/
shell?: boolean
/**
* If specified - will be used as "command name" for logging purposes,
* instead of "cmd + args"
*/
name?: string
cwd?: string
env?: AnyObject
/**
* Defaults to true.
* Set to false to NOT pass `process.env` to the spawned process.
*/
passProcessEnv?: boolean
/**
* Defaults to "auto detect colors".
* Set to false or true to override.
*/
forceColor?: boolean
/**
* Defaults to "inherit"
*/
stdio?: StdioOptions
}
export interface ExecOptions {
/**
* Defaults to false.
*/
logStart?: boolean
/**
* Defaults to false.
*/
logFinish?: boolean
/**
* Defaults to false.
* Controls/overrides both logStart and logFinish simultaneously.
*/
log?: boolean
/**
* If specified - will be used as "command name" for logging purposes,
* instead of "cmd + args"
*/
name?: string
cwd?: string
timeout?: NumberOfMilliseconds
env?: AnyObject
/**
* Defaults to false for security reasons.
* Set to true to pass `process.env` to the spawned process.
*/
passProcessEnv?: boolean
/**
* Defaults to undefined.
* beware that stdio: 'inherit', means we don't get the output returned.
*/
stdio?: StdioOptions
}