@magnaboy/cli-core
v0.0.7
Published
Reusable Node.js primitives for building robust repository scripts and CLIs.
Readme
@magnaboy/cli-core
Reusable, shell-free Node.js primitives for repository scripts and command-line tools.
Install
npm i @magnaboy/cli-coreRequires Node 25+ and ESM.
Run commands
import { NativeProcessRunner } from '@magnaboy/cli-core';
const runner = new NativeProcessRunner();
const result = await runner.run({
command: process.execPath,
args: ['--version'],
output: 'tee',
timeoutMs: 30_000
});
if (!result.ok) throw result.spawnError ?? new Error(`Command exited with ${result.exitCode}`);Commands are executed without a shell. The process runner supports captured or teed output, combined log files, stdin, timeouts, cancellation, injected streams and environments, and descendant-process termination.
Input and bounded output
const result = await runner.run({
command: 'build-tool',
args: ['build'],
stdinMode: 'inherit',
output: 'tee',
captureLimitBytes: 2 * 1024 * 1024,
logPath: 'logs/build.log',
logRotation: { maxBytes: 4 * 1024 * 1024, maxFiles: 3 }
});stdin accepts literal strings, byte arrays, and Node readable streams. Streams are consumed by the command;
do not reuse them afterward. Set stdinMode: 'inherit' to inherit the caller's stdin descriptor, or 'ignore'
to provide no input. stdin and stdinMode are mutually exclusive. The string stdin: 'inherit' remains
literal input. Inherited stdin does not enable a PTY or change stdout/stderr into terminal descriptors.
When output is retained, captureLimitBytes keeps only the final bytes of stdout, stderr, and their combined
output. Each has an independent ring buffer of that size. The child continues running when a buffer fills;
live output and log files are unaffected. The returned UTF-8 strings fit the limit and drop a character cut
at the start of a tail. outputTruncated reports truncation separately for all three fields. Combined output
follows observed stream delivery, decoding partial UTF-8 characters independently per stream. It cannot
establish a total ordering between simultaneous stdout and stderr writes.
The limit must be a positive safe integer. It does not enable retention: use retainOutput: true with
start if you need tails. Omitting the limit preserves the existing capture behavior and Execa buffer limit.
logStream selects 'stdout', 'stderr', or 'combined' (default). logRotation requires logPath.
maxBytes and maxFiles are positive safe integers; maxFiles includes the active log and defaults to two.
Logs rotate as build.log, build.log.1, through build.log.N; .1 is the newest backup. Large chunks
are split to keep each newly written file within maxBytes. Rotation boundaries are bytes, not lines or
UTF-8 characters. An append run rejects an active log already larger than the limit.
Log files must be regular files, and the caller must own the basename and configured backup names exclusively. Rotation replaces those backup files; it does not prune names outside the configured range. Changing a file count does not delete older backups outside that range. The runner applies backpressure and waits for file writes before resolving completion. File failures prevent a successful result.
await runner.run({
command: 'analyzer',
args: ['input.bin'],
stdoutFile: 'reports/analysis.json',
logPath: 'reports/diagnostics.log',
logStream: 'stderr',
retainOutput: false
});stdoutFile opens with exclusive creation before spawning. An existing file or symlink causes rejection
without running the command. It receives raw stdout bytes only, and retains partial output if the command
fails. It can coexist with capture, tee output, and a separate log file. Output paths resolve against the
command's cwd, their parent directories are created, and stdoutFile and logPath must differ.
Linux process groups
const gate = await runner.run({
command: 'test-gate',
args: ['--check'],
cleanupProcessGroup: true,
killSignal: 'SIGINT',
killGraceMs: 3_000,
timeoutMs: 600_000
});killSignal selects the first signal for cancellation and timeouts (default SIGTERM). Windows uses
Execa's process-tree termination and cannot provide POSIX signal semantics.
cleanupProcessGroup: true requires Linux with readable /proc; other platforms reject it before spawning.
After the direct child exits, including a successful exit, the runner checks its owned process group for
live members. It sends killSignal, then SIGTERM if different, then SIGKILL. Each graceful stage waits
up to killGraceMs; after SIGKILL it allows five seconds to verify termination. Zombies are not running
processes; the runner cannot reap grandchildren owned by another parent. Completion waits for this cleanup,
even when all output pipes have closed. Failed verification sets cleanupError, ok: false, and a nonzero
exit code. runChecked always rejects cleanup failures, regardless of allowedExitCodes.
This option does not contain descendants that create another process group/session. It also does not provide parent-death signaling after a hard kill of the runner, an OS advisory lock, or a security boundary. Those require native supervision. Without this option, existing cancellation and descendant handling remain.
Script entry points
import { isMainModule, NativeProcessRunner, runScript } from '@magnaboy/cli-core';
if (isMainModule(import.meta.url)) {
await runScript(async signal => {
const runner = new NativeProcessRunner({ signal });
const result = await runner.run({ command: 'node', args: ['scripts/check.mjs'], output: 'tee' });
return result.ok ? 0 : 1;
});
}The package also exports bounded parallel workflows, typed CLI errors, safe path helpers, timeout-aware fetch helpers, terminal prompting, and Windows/WSL host interoperability helpers. It contains no repository-specific root discovery, commands, or configuration.
Verified file downloads
import { downloadFile } from '@magnaboy/cli-core';
const file = await downloadFile(sourceUrl, 'downloads/tool.tar.gz', {
sha256: expectedSha256,
maxBytes: 100 * 1024 * 1024,
timeoutMs: 60_000,
signal
});
console.log(file.path, file.bytes, file.sha256);downloadFile streams a response into a private temporary sibling directory and checks its SHA-256 before
publishing it. sha256 is required and accepts either hex case. maxBytes is a required non-negative safe
integer; zero allows an empty file. The helper checks both the declared Content-Length and the actual streamed
bytes. The timeout defaults to 30 seconds and covers fetching and consuming the body; it checks cancellation
again before publication. headers and an injectable fetch are supported. An injected fetch must honor
the supplied signal, including while reading its response body. Failed HTTP statuses are rejected, not retried.
The destination is exclusive: it never replaces an existing path, even if another caller creates that path during the download. An atomic hard link publishes verified bytes without a partially written destination. The destination filesystem must support hard links; no non-atomic copy fallback is used. The parent directory is created as needed. Failures remove temporary output and preserve existing destinations. A hard process kill may leave a temporary sibling directory. The returned result contains the absolute path, byte count, and lowercase SHA-256. This provides atomic visibility, not a durability guarantee across host power loss.
Checked commands and background processes
import { commandOutput, NativeProcessRunner, runChecked } from '@magnaboy/cli-core';
const runner = new NativeProcessRunner({ cwd: process.cwd() });
const commit = await commandOutput(runner, { command: 'git', args: ['rev-parse', 'HEAD'] });
const diff = await runChecked(runner, {
command: 'git', args: ['diff', '--quiet'], allowedExitCodes: [0, 1]
});
const worker = await runner.start({
command: process.execPath,
args: ['worker.mjs'],
logPath: 'logs/worker.log',
killGraceMs: 5_000
});
try {
// Run the work that needs this process. worker.exited and worker.completion expose its state.
} finally {
await worker.stop();
}runChecked returns CommandResult or throws CommandError, which keeps the result on .result.
Only normal exits can match allowedExitCodes (default [0]). Cancellation, timeouts, signals, and spawn
errors always fail. Error messages omit arguments, environment values, and captured output; inspect the result
explicitly when safe. commandOutput captures and trims stdout.
start returns a ManagedProcess before completion. stop() cancels the process tree and waits for exit,
with forced termination after killGraceMs (default 30 seconds). Repeated stops share the completion result.
Spawn failures resolve completion with spawnError; invalid launch options can reject start itself.
This API supervises processes tied to the caller's lifetime, not detached GUI applications.
start defaults to no retained output; run keeps its existing capture default. Both support log files and
injected cancellation signals. Native results include cancelled; the optional field preserves compatibility
with existing injected ProcessRunner implementations. ManagedProcessRunner adds the typed start method.
runScript also accepts an async cleanup callback and awaits it. Cleanup failures are reported, preserve an
existing nonzero exit code, and otherwise set exit code 3. Signal handlers are removed even if cleanup fails.
Environment files and file hashes
import { readEnvFile, sha256File } from '@magnaboy/cli-core';
const environment = { ...readEnvFile('build.env'), ...process.env };
const digest = await sha256File('build/archive.zip');readEnvFile is synchronous and uses Node's util.parseEnv format: comments, optional export, quoted values,
and multiline values. It does not mutate process.env. Missing files return {}; other read errors propagate.
It does not JSON-decode quoted values or expand variables. Callers migrating a custom parser must check those
differences. Environment precedence is the caller's choice.
sha256File streams bytes with bounded memory and returns a lowercase hex digest. An optional { signal }
cancels the read. Missing or unreadable files fail.
Temporary directories and ZIP archives
import { compressDirectory, createStagingDirectory } from '@magnaboy/cli-core';
const staging = createStagingDirectory('build-');
try {
await compressDirectory('build/output', `${staging.path}/output.zip`, {
onProgress: ({ completedFiles, totalFiles }) => console.log(`${completedFiles}/${totalFiles}`)
});
} finally {
staging.cleanup();
}createStagingDirectory(prefix, options?) creates a unique directory under the OS temp directory or an existing
parentDirectory. Prefixes cannot contain path separators. Cleanup removes only this owned directory, is
idempotent, retries locked Windows files, and returns false rather than throwing on failure. A later cleanup or
process exit retries failed removal. onCleanupError(error, directory) overrides the default warning.
A shared exit handler removes active directories. By default, shared SIGINT, SIGTERM, SIGHUP, and Windows
SIGBREAK handlers clean up and exit with the signal's conventional code. Set cleanupOnSignals: false when
runScript or another caller owns signal handling; perform cleanup in that caller's finally/cleanup callback.
The synchronous process-exit backstop remains enabled. Hard kills cannot run cleanup.
compressDirectory(source, destination, options?) is async and uses yazl to stream one input file at a time.
It fixes entry timestamps and modes, sorts paths without locale dependence, and supports ZIP64 automatically.
compressionLevel is 0 through 9, default 1. Progress starts at 0 and advances as each file is read. Archive
completion is the returned promise, not the last progress event. signal cancels archive creation.
The destination must be outside the source directory, including directory aliases. Symbolic links and special files in the source are rejected; empty directories are omitted. Inputs must remain unchanged during creation. Output is written to a temporary sibling directory and replaces the destination only after success. A failure or cancellation removes temporary output and preserves the previous archive. The helper creates destination parent directories as needed. Stable metadata yields repeatable archives for the same inputs and toolchain; compression-library updates may change bytes. Archives are not byte-compatible with other ZIP writers.
Retry, polling, and heartbeat
import { pollUntil, retry, retryTransfer, withHeartbeat } from '@magnaboy/cli-core';
const result = await retry(async (attempt, signal) => performOperation(signal), {
attempts: 3,
delayMs: attempt => attempt * 500,
shouldRetry: error => isTransient(error)
});
await pollUntil(async signal => fetch('http://localhost:8080/healthz', { signal }).then(r => r.ok), {
accept: healthy => healthy,
timeoutMs: 30_000,
intervalMs: 500
});
await withHeartbeat(async signal => performOperation(signal), {
onHeartbeat: elapsedMs => console.log(`Working: ${Math.round(elapsedMs / 1000)}s`)
});retry uses one-based attempts, three attempts by default, and exponential delays starting at 500 ms and capped
at 30 seconds. The final error is preserved. Use shouldRetry to exclude permanent failures; cancellation and
AbortError are never retried. All three helpers accept an external signal.
pollUntil polls values until accept succeeds. Probe errors propagate. Its total timeout covers pending probes
and intervals and rejects with TimeoutError; timers and listeners are removed on completion. Actions must
honor their signal to stop underlying work after cancellation.
stableForMs requires accept to hold continuously for that long before returning, and a single rejected
probe restarts the window. Use it when readiness must be stable rather than momentary, such as a service that
answers once and then restarts.
withHeartbeat reports immediately and every 10 seconds by default (intervalMs overrides this). It returns
the action result and always clears its timer. A reporter failure rejects the operation and signals cancellation.
retryTransfer takes a fresh request factory (attempt, signal) => Promise<Response> plus RetryOptions.
It retries HTTP 408, 429, 500, 502, 503, and 504, disposing each discarded response body. It returns the final
response even if that response has an error status. Other HTTP statuses return immediately. shouldRetry
controls thrown errors; retryable HTTP statuses use the fixed policy. Create a new stream body inside the factory
for each upload attempt. Choose a per-request timeout and use this only for operations that can safely repeat.
File locks, generated files, and Git fingerprints
acquireFileLock(path, metadata?) creates an exclusive local filesystem lock and returns
{ path, owner, release() }. Use release() in a finally block. Release is idempotent and
checks the ownership token. Existing locks throw FileLockError, including locks with stale or
incomplete metadata. Recovery is explicit: verify no owner or contender is active before removing
a leftover lock. There is no PID-based automatic recovery, signal handler, lease, or network
filesystem guarantee. Do not remove or replace an active lock outside this API.
writeOrCheckFile(path, content, { check }) returns written, current, or stale.
Check mode compares exact bytes and performs no writes or directory creation. Missing files are
stale; other I/O errors propagate. Write mode creates parents and writes the supplied string or
bytes. Callers own logging and exit codes. Writes are not transactional.
gitSourceFingerprint({ cwd, paths, marker, signal, runner }) hashes selected Git filenames
and streamed content. Paths must be nonempty and are Git pathspecs. It includes tracked and
untracked non-ignored files, skips deleted files, deduplicates and sorts names, and uses NUL-delimited
Git output. The digest includes the marker, filename and binary SHA-256 of each file. Symlinks
follow Node file-reading behavior; keep inputs stable while hashing. The helper does not infer
artifact policy, toolchain markers or secret exclusions. Select input paths explicitly.
Command-line parsing
Argument parsing itself is Commander, which is an optional
peer dependency: install it only if you import @magnaboy/cli-core/commander. Pair it with
@commander-js/extra-typings for inferred option types.
import { Command, Option } from '@commander-js/extra-typings';
import { envFlag, integerParser, runCommand } from '@magnaboy/cli-core/commander';
const command = new Command('capture')
.argument('[seconds]', 'measurement window', integerParser(1, 3_600), 30)
.addOption(new Option('--freq <hz>').env('FREQ').default(4_000).argParser(integerParser(1, 100_000)))
.addOption(envFlag('heap', 'HEAP', { defaultValue: true, description: 'java heap snapshot' }))
.action(async (seconds, options) => capture(seconds, options));
await runScript(() => runCommand(command));runCommand exists because Commander calls process.exit by default, which skips runScript's
cleanup callback and leaves its signal handlers installed. It applies exitOverride across the whole
command tree, returns 0 when Commander has already printed help or a version, and turns every other
parse failure into a CliError. Commander's own error output is suppressed so the message is printed
once, by runScript, together with any usageHint. exitOverride is inherited by subcommands but
output configuration is not, so runCommand walks them.
envFlag(name, environmentVariable) is a boolean option that reads its environment variable
correctly. Commander treats a plain boolean option's variable as set-by-presence, so HEAP=0 turns
the flag on; this declares an optional-value option instead, where --heap alone is true and both
--heap 0 and HEAP=0 are false. Precedence is command line, then environment, then default, and
getOptionValueSource reports which applied.
integerParser, numberParser and listParser build argParser functions that report failures as
Commander usage errors. listParser splits, trims, and rejects blanks and repeats.
Generated text, sections and tables
import { formatKeyValues, parseKeyValues, renderTable, splitSections, toLf } from '@magnaboy/cli-core';
const device = parseKeyValues(await readFile('device.txt', 'utf8'), { allowedKeys: ['serial', 'label'] });
const sections = splitSections(await readFile('before.txt', 'utf8'));
console.log(renderTable(threads, [
{ header: 'thread', value: row => row.comm },
{ header: 'cpu%', align: 'right', value: row => row.corePercent.toFixed(1) }
]));toLf drops carriage returns so Windows tool output compares equal to the POSIX form.
splitSections reads ### name blocks into a Map in file order. Content before the first marker is
rejected rather than dropped, because a tool that printed a warning ahead of the first section would
otherwise lose it silently. Duplicate and unnamed sections are errors.
parseKeyValues reads key=value lines and returns a KeyValues with accessors that name the key
that failed: require, number, integer, boolean, optionalNumber. Only the first = splits,
so a value may contain one. allowedKeys rejects anything unexpected and duplicates are rejected
unless allowDuplicates is set. formatKeyValues writes what parseKeyValues reads back, and
rejects keys or values that would not survive the round trip. Failures throw TextParseError.
renderTable sizes columns from the widest cell, or to an explicit width that truncates. Cells are
formatted by the caller, so numeric precision stays with the code that knows the units; null and
undefined render as the column's empty placeholder. With no rows it returns emptyMessage rather
than a bare header.
formatBytes, formatDuration, formatPercent and formatCount cover the usual report units.
formatPercent takes a ratio in 0..1, not a percentage; formatCount groups digits without
depending on the host locale.
Run directories
import { createRunDirectory, timestampSlug, writeNewFile } from '@magnaboy/cli-core';
const run = await createRunDirectory({ root: 'out', tag: 'full', utc: true });
writeNewFile(`${run.path}/summary.json`, JSON.stringify(summary));timestampSlug returns a sortable 20260917-143005, or 20260917T143005Z with utc.
createRunDirectory names a directory tag-stamp and is exclusive by default: reusing a directory
silently mixes two runs' artifacts and the reader cannot tell afterwards which file came from which
window. writeNewFile applies the same rule to a single file, creating parent directories and
failing with EEXIST rather than replacing.
