@sksizer/command-seam
v0.3.0
Published
Injectable subprocess runner for Node: capture, stream and managed-process runners, hard timeouts, failures as results.
Maintainers
Readme
@sksizer/command-seam
Run external commands through a small interface you can replace in tests. Timeouts kill the process. Failures come back as values, not exceptions.
Why
Code that calls child_process directly has three common problems:
- It is hard to test. You have to mock a Node module, or run the real binary in every test.
- Timeouts do not always stop the command. Node's
timeoutoption sendsSIGTERM, which a process can ignore. A forked grandchild can also keep the output pipes open, so the call waits long after the timeout. - Failures arrive in several shapes. A missing binary, a timeout and a non-zero exit each show up differently.
This package gives you a runner: an object with one run method. Code that
needs to run git takes a runner as a parameter. In production you pass the
real one, spawnRunner('git'). In tests you pass a fake that returns canned
results. Every runner reports failures the same way, and a timeout always
kills the child with SIGKILL.
The name comes from the testing term "seam": a place where you can swap behaviour without changing the code around it. Here the seam is the runner parameter.
When not to use it
- You need Windows. This package is tested only on Linux and macOS. See Requirements below.
- You want shell-script ergonomics (template strings, piping, globbing).
Use
zxorexeca. - You need to process output as a stream while the command runs, with
backpressure or line-by-line iteration. Use
execa,tinyexecorchild_processdirectly. The capture runners here buffer all output until the command ends. - You need a full process manager (restarts, logs, health checks).
Install
npm install @sksizer/command-seam
# or
pnpm add @sksizer/command-seam
# or
bun add @sksizer/command-seamRequirements
These are the facts today, not a support promise.
- Node.js 22 or later (
engines.nodeis>=22). Node is the primary runtime. - Bun also works. Under Bun the package resolves to its TypeScript source
through the
bunexport condition. - ESM only. There is no CommonJS build.
- POSIX only (Linux, macOS).
runShellneedsshonPATH. TheprocessGroupoptions use POSIX process groups. Signals follow POSIX rules. Windows is untested and not supported for now. - TypeScript 5.4 or later. The types use the
NodeJSnamespace, so install@types/node(an optional peer dependency).
Quick start
import { CommandFailed, failureText, runChecked, spawnRunner } from '@sksizer/command-seam'
const git = spawnRunner('git')
const cwd = process.cwd()
// run() never throws for a failed command. Read exitCode yourself.
const status = git.run(['status', '--porcelain'], { cwd, timeoutMs: 10_000 })
if (status.exitCode === 0) console.log(status.stdout)
else console.error(failureText(status))
// runChecked() throws CommandFailed on a non-zero exit.
try {
runChecked(git, ['push', 'origin', 'main'], { cwd, timeoutMs: 30_000 })
} catch (e) {
if (e instanceof CommandFailed) console.error(e.detail)
else throw e
}Concepts
- Runner: an object with a
run(argv, options)method.argvis the list of arguments, without the binary name. The binary is fixed when you create the runner. - Result: what a capture runner returns:
exitCode,stdout,stderr,neverExited,reason,signalanderror. neverExited:truewhen the command never reported an exit status. The spawn failed, a timeout or abort killed it, a signal ended it, or the sync runner killed it at the output cap.exitCodeis then a stand-in1. Check it before you treat an exit code as an answer:grepexits 1 for "no matches", and a missinggrepmust not look the same.reason: which of those causes it was, set wheneverneverExitedis:'spawn'(it never started),'timeout','abort'(the caller stopped it),'signal'(a signal from elsewhere ended it) or'output'(the sync runner's 256 MB output cap).signalnames the signal when the runner knows it. A timeout may pass on a retry; a spawn failure or the output cap will not.error: the runner's own message, such as a spawn error ortimed out after 500ms. It is never copied intostderr, which holds only what the child wrote.failureTextandCommandFailedreaderrorwhenstderris empty.- Checked call:
runCheckedandrunCheckedAsyncthrowCommandFailedwhenexitCodeis not 0. Plainruncalls never throw for a failed command; programmer errors throw (see Behaviour).
Choosing a runner
| Need | Use | Returns |
| -------------------------------------------- | ---------------------------------------- | ---------------------------- |
| Run a command and read its output | spawnRunner(binary) | CommandResult |
| The same, without blocking the event loop | spawnRunnerAsync(binary) | Promise<CommandResult> |
| Run a shell string (a && b, pipes) | runShell(verb, opts) / shellRunner() | CommandResult |
| Show output live (builds, interactive tools) | streamRunner(binary) | StreamResult (exit only) |
| Start a long-running process and keep going | managedProcessRunner(binary) | ManagedProcessHandle |
Use the sync runner for short commands run one after another. Blocking costs them nothing and keeps your code synchronous. Use the async runner for long-running commands, or to run several commands at once.
Capture, sync
import { spawnRunner } from '@sksizer/command-seam'
const git = spawnRunner('git')
const r = git.run(['rev-parse', 'HEAD'], { cwd: process.cwd() })
if (r.exitCode === 0) console.log(r.stdout.trim())Capture, async
The async runner adds three options: signal (an AbortSignal), onSpawn
(called with the child's pid once it starts) and processGroup (see
Timeouts and cancellation below).
import { spawnRunnerAsync } from '@sksizer/command-seam'
const git = spawnRunnerAsync('git')
const controller = new AbortController()
const [log, branch] = await Promise.all([
git.run(['log', '-1', '--format=%s'], { cwd: process.cwd(), signal: controller.signal }),
git.run(['branch', '--show-current'], { cwd: process.cwd(), timeoutMs: 5_000 }),
])
console.log(log.stdout.trim(), branch.stdout.trim())Shell strings
Every other runner passes argv straight to the binary, so nothing is parsed
by a shell. runShell is the one function that runs a shell string, as
sh -c -- <verb>. The -- keeps a verb that starts with - or + from
being read as an option to sh. Pass data as positional parameters ($1, $2, …) through
args, or quote it with shellQuote.
import { runShell, runShellAsync, shellQuote, shellRunner, spawnRunner } from '@sksizer/command-seam'
const cwd = process.cwd()
const file = 'notes with spaces.txt'
// Positional parameters are passed as separate arguments, never parsed.
const lines = runShell('wc -l < "$1"', { cwd, args: [file] })
// Or quote the value into the string.
const same = runShell(`wc -l < ${shellQuote(file)}`, { cwd })
const later = await runShellAsync('sleep 1 && echo done', { cwd, timeoutMs: 5_000 })
// Like every runner, it can run through a fake.
const sh = shellRunner(spawnRunner('sh'))
console.log(lines.stdout, same.stdout, later.stdout, sh.run('echo hi', { cwd }).stdout)runShell takes the capture options (timeoutMs, env, input) and returns a CommandResult. A
fake passed to shellRunner receives ['-c', '--', verb], then scriptName and args when given.
shellJoin(argv) quotes a whole command line, for printing one a person can paste.
Streaming
streamRunner inherits stdin, stdout and stderr, so the child's output
appears as it runs. The result has no output, only how the child ended:
exitCode, neverExited, reason ('spawn', 'timeout' or 'signal'),
signal, and error, the runner's message when the spawn failed or the
timeout fired. That message, or <binary> was killed by <signal>, is also
written to the parent's stderr.
import { ran, streamRunner } from '@sksizer/command-seam'
const npm = streamRunner('npm')
const r = npm.run(['run', 'build'], { cwd: process.cwd(), timeoutMs: 600_000 })
// The runner has already printed why; exit 124 on a timeout, like `timeout(1)`.
process.exitCode = r.reason === 'timeout' ? 124 : ran(r) ? 0 : 1run blocks until the child exits. There is no async version.
Managed process
managedProcessRunner starts a process and returns a handle at once. It does
not wait for the process to finish, capture output or apply a timeout. Its
options are ManagedProcessRunOptions: cwd (required), env and
processGroup.
import { managedProcessRunner } from '@sksizer/command-seam'
const server = managedProcessRunner('node').run(['server.js'], {
cwd: process.cwd(),
processGroup: true, // kill() then signals the whole process group
})
console.log(server.pid) // -1 if the spawn failed
setTimeout(() => server.kill(), 5_000) // SIGTERM by default
const exit = await server.exited // never rejects
if (exit.reason === 'abort') console.log('stopped by kill()')
else if (exit.reason === 'spawn') console.error(`could not start: ${exit.error}`)
else console.log(exit.exitCode, exit.signal)processGroup: true makes the child the leader of its own process group, so
kill() also stops any processes it started.
exited reports reason: 'abort' when a signal ends the process after your
own kill(), 'signal' when a signal from elsewhere ends it, and 'spawn'
with the error message in error when it never started.
kill() sends one signal. A process that handles SIGTERM and keeps
running leaves await server.exited waiting forever.
stopProcess(handle, { graceMs }) sends SIGTERM, waits up to graceMs
(default 5 s), then sends SIGKILL, and resolves with the exit. It works
with any handle, fakes included. With processGroup, it waits for the
leader only: a group member that outlives the leader gets no SIGKILL.
import { managedProcessRunner, stopProcess } from '@sksizer/command-seam'
const server = managedProcessRunner('node').run(['server.js'], { cwd: process.cwd() })
const exit = await stopProcess(server, { graceMs: 2_000 })
// null if it exited on SIGTERM, 'SIGTERM' if SIGTERM killed it, 'SIGKILL' if it ignored SIGTERM
console.log(exit.signal)The runner cannot tell you when the process is ready from its output: stdio
is inherited, not piped. Poll from outside instead: try the port or an HTTP
endpoint, or wait for a file the process writes, in a loop with a deadline.
Check exited after each try, so a process that died early is reported
rather than waited on.
Default options
withDefaults(runner, opts) returns a runner that fills in opts on every
call. Code that runs many commands in one folder binds cwd (and often
env or timeoutMs) once. It works with every runner type.
import { spawnRunner, withDefaults } from '@sksizer/command-seam'
const git = withDefaults(spawnRunner('git'), { cwd: process.cwd(), timeoutMs: 30_000 })
git.run(['status'])
git.run(['fetch'], { timeoutMs: 120_000 }) // a per-call option winsA per-call option replaces the default of the same name, except env: a
per-call env is merged over the bound one, key by key, so a client that
binds LC_ALL=C keeps it when one call adds a variable. A per-call env
value of undefined removes a bound variable. A per-call option set to
undefined keeps the default. The defaults are shallow-copied when bound,
so a bound env object is still shared.
runChecked, runCheckedAsync, runJson and runJsonAsync take the
runner's own options, so a bound runner needs only what it did not bind. The
result is also still a runner of the original type, so code that takes a
CommandRunner accepts it, and passes its own full options, which replace
the bound ones (a per-call env is merged, as above).
import { runChecked, runJson, spawnRunner, withDefaults } from '@sksizer/command-seam'
const gh = withDefaults(spawnRunner('gh'), { cwd: process.cwd(), env: { GH_PROMPT_DISABLED: '1' } })
const head = runChecked(gh, ['repo', 'view', '--json', 'name']).stdout
// This call's env is merged over the bound one: both variables reach gh.
const user = runJson<{ login: string }>(gh, ['api', 'user'], { env: { GH_HOST: 'github.com' } })
console.log(head, user.ok && user.value.login)Reading results
Small helpers that never throw for a failed command (a programmer error,
such as an invalid timeoutMs, still throws or rejects):
ran(result): the process started and exited 0. Takes any runner's result, includingStreamResultandManagedProcessExit.failureText(result): trimmed stderr, else the trimmederror, else trimmed stdout, elseexit N.failureText(result, { both: true })joins both trimmed streams instead, stderr (orerror) first, and is empty when there is no text.firstLine(text, fallback?): the first non-blank line, trimmed.stderrOrError(result): trimmed stderr, else the trimmederror, else''. Use it where a message reads stderr, so a command that never ran still says why.runJson(runner, argv, opts)andrunJsonAsync: run, check the exit code (0unlessokExitCodessays otherwise), thenJSON.parsestdout.parseJsonResult(result)is the parse step alone.
For "the answer, or a fallback when the command failed", read one command's
result with ran: ran(r) ? r.stdout : null. When several steps can throw
(a run of runChecked calls, or a helper that calls runChecked itself),
wrap them in catchFailed (see Reporting failures) instead of checking each
result.
runJson returns { ok: true, value, result } or
{ ok: false, reason, message, result }. reason is 'failed' (the command
did not run, or exited with a code not in okExitCodes), 'unparseable'
(stdout is not JSON) or 'invalid' (the parse option threw).
tolerateLeadingLog: true skips log lines printed before the JSON.
Some tools print JSON and exit non-zero on purpose: npm outdated --json
exits 1 when anything is outdated. okExitCodes: [0, 1] accepts those
codes. A run that never exited is still 'failed'. An accepted non-zero exit
still needs output that parses, and the tool's own errors often use the same
code (npm prints { "error": … } and exits 1), so pass a parse that
rejects the error output.
Without parse, the type parameter is not checked at runtime. Pass parse,
such as a schema's parse method, when you do not trust the producer. Its
return type becomes the value's type. When it throws, the outcome is
invalid and cause holds what it threw. parse must be sync: pass
schema.parse, not schema.parseAsync. A parse that returns a promise
gives invalid too.
import { runJson, spawnRunner } from '@sksizer/command-seam'
const gh = spawnRunner('gh')
const r = runJson<{ login: string }>(gh, ['api', 'user'], { cwd: process.cwd() })
if (r.ok) console.log(r.value.login)
else console.error(`${r.reason}: ${r.message}`)
// With a check: `parse` throws on a bad shape, and its return type is the value's.
const login = (v: unknown): string => {
const name = (v as { login?: unknown } | null)?.login
if (typeof name !== 'string') throw new Error('no login')
return name
}
const checked = runJson(gh, ['api', 'user'], { cwd: process.cwd(), parse: login })
if (checked.ok) console.log(checked.value.toUpperCase())
else if (checked.reason === 'invalid') console.error(checked.message, checked.cause)Reporting failures
CommandFailed carries argv and the full result. Its message is
command failed (exit N): <argv>, with argv quoted by shellJoin, then the
stderr, or the runner's error when the child wrote no stderr. It never
includes stdout. Some commands explain a failure on stdout instead, such as a
git hook that rejects a commit. e.detail joins both
streams, trimmed, and drops empty ones: it is
failureText(e.result, { both: true }). Show detail to a person.
The message carries the full argv, so it reaches error logs and crash
reports. Do not pass a secret, such as a token in a URL, as an argument:
pass it through env or input.
mapFailed(fn, mapper) runs fn and, if it throws CommandFailed, throws
mapper(e) instead. catchFailed(fn, onFailed) returns onFailed(e)
instead. Other errors pass through unchanged. mapFailedAsync and
catchFailedAsync do the same for async functions, and their mapper or
handler may be async.
import { catchFailed, mapFailed, runChecked, spawnRunner } from '@sksizer/command-seam'
class DiffError extends Error {}
const git = spawnRunner('git')
const cwd = process.cwd()
const diff = mapFailed(
() => runChecked(git, ['diff', '-U0'], { cwd }).stdout,
(e) => new DiffError(`git diff failed: ${e.detail}`),
)
// Two steps that can fail: one catchFailed covers both.
const ahead = catchFailed(
() => {
const upstream = runChecked(git, ['rev-parse', '--abbrev-ref', '@{upstream}'], { cwd })
const range = `${upstream.stdout.trim()}..HEAD`
return Number(runChecked(git, ['rev-list', '--count', range], { cwd }).stdout)
},
() => undefined,
)
console.log(diff.length, ahead)Testing with fakes
Write code against the runner interface, and pass a fake in tests. A fake is
any object with a run method.
import { asAsync, spawnRunner, type CommandResult, type CommandRunner } from '@sksizer/command-seam'
// Code under test: takes a runner, defaults to the real one.
function currentBranch(cwd: string, git: CommandRunner = spawnRunner('git')): string {
return git.run(['branch', '--show-current'], { cwd }).stdout.trim()
}
// A recording fake.
const calls: string[][] = []
const fake: CommandRunner = {
run(argv): CommandResult {
calls.push(argv)
return { exitCode: 0, stdout: 'main\n', stderr: '' }
},
}
console.log(currentBranch('/repo', fake)) // 'main'
// The same fake, for code that takes an AsyncCommandRunner.
const asyncFake = asAsync(fake)
console.log((await asyncFake.run(['status'], { cwd: '/repo' })).stdout)A default parameter, or opts.git ?? spawnRunner('git') for a runner in an
options object, is all the injection needs.
resolveRunner(binary, injected?) and resolveRunnerAsync do the same as a
function call: the injected runner when there is one, else the real default.
StreamRunner, ShellRunner and ManagedProcessRunner are interfaces too,
so they can be faked the same way. shellRunner(fake) runs shell strings
through a fake CommandRunner.
Ready-made fakes
@sksizer/command-seam/testing has the fakes, in their own entry point so
production code does not import them. It also exports their types:
FakeRunner (what fakeRunner returns), FakeCall (one recorded call),
FakeProcess and FakeProcessOptions.
fakeRunner(answers)returns{ runner, asyncRunner, calls }.runneris aCommandRunnerthat answers from a table keyed by the argv, each argument quoted withshellQuoteand joined with spaces: a plain argument stays as it is, and one with a space or a shell character is single-quoted, so['-m', 'a b']is-m 'a b'. Each answer lists only the fields that differ from{ exitCode: 0, stdout: '', stderr: '' }.answerscan also be a function of(argv, opts). A call with no answer reports a failed spawn, as a missing binary would:neverExited: true,reason: 'spawn', andfakeRunner: no answer for: <key>inerror, with the key you would add to the table. Code that reads a failed run as an answer can still go on, so assert oncalls, which records each call's argv and options.asyncRunneris the same fake as anAsyncCommandRunner, for async code; the async options are recorded, andonSpawnis never called.fakeNeverExited(reason, fields?)builds an answer for a run that never exited:exitCode: 1,neverExited: true,reason,error: 'fake: <reason>'(none for'signal'), andsignalwhere the sync runner sets one (SIGKILLfor'timeout'and'output',SIGTERMfor'signal').fieldsoverride any of them.fakeProcess(opts?)returns aManagedProcessHandlethat starts nothing.exit(fields?)settlesexitedwith{ exitCode: 0, signal: null }and your fields. To fake a death by a signal from elsewhere, pass{ exitCode: 1, signal, neverExited: true, reason: 'signal' }, asmanagedProcessRunnerreports it.kill()records the signal insignalsand ends the process withreason: 'abort'.exitOnKill: falsemakes it survive every signal, and a list such asexitOnKill: ['SIGKILL']makes it die only of those, as a process that trapsSIGTERMwould.runneris aManagedProcessRunnerthat returns the handle, andcallsrecords what it was given.piddefaults to4242.
import { runChecked, stopProcess } from '@sksizer/command-seam'
import { fakeNeverExited, fakeProcess, fakeRunner } from '@sksizer/command-seam/testing'
const { runner, calls } = fakeRunner({
'rev-parse --short HEAD': { stdout: 'abc\n' },
"commit -m 'Fix login'": {},
'fetch origin': fakeNeverExited('timeout'),
})
console.log(runChecked(runner, ['rev-parse', '--short', 'HEAD'], { cwd: '/repo' }).stdout)
console.log(calls[0]?.opts.cwd) // '/repo'
const stubborn = fakeProcess({ exitOnKill: ['SIGKILL'] })
console.log((await stopProcess(stubborn, { graceMs: 10 })).signal) // 'SIGKILL'
const proc = fakeProcess()
const server = proc.runner.run(['server.js'], { cwd: '/app' })
proc.exit({ exitCode: 0 })
console.log(await server.exited) // { exitCode: 0, signal: null }Timeouts and cancellation
timeoutMsfollows one rule on every runner that has it.undefinedandInfinitymean no cap. A fraction rounds up, so1.2is2. A value above 2^31 - 1 (about 24.8 days), the largest timer delay, is clamped to it.0, a negative value andNaNare a caller error:RangeError, thrown by the sync runners and rejected by the async ones. A value that is not a number is aTypeError, the same way. LeavetimeoutMsout, or passInfinity, for no cap.timeoutMskills the child withSIGKILL, notSIGTERM, so a child that ignoresSIGTERMstill stops. The child gets no chance to clean up. For a graceful stop on the async runners, passsignal: AbortSignal.timeout(ms)instead: an abort sendsSIGTERMfirst.- The async runner settles 250 ms after the child exits, even if a background
process the child started still holds the output pipes. The pipes are then
closed: that process loses any later output and may die of
SIGPIPEon its next write. WithprocessGroup: truethe group is killed at that point. Redirect a daemon's output, or usemanagedProcessRunner, to keep it. - The async runner resolves at the deadline. It does not wait for the output pipes to close, which a surviving grandchild could keep open.
- The result of a timeout has
neverExited: true,reason: 'timeout'and the output captured so far. The sync runner also setssignal: 'SIGKILL'. Both runners seterror: the async runner's istimed out after <N>ms, the sync runner's is spawnSync'sETIMEDOUTmessage. - The sync runners (
spawnRunner,runShell,shellRunner) andstreamRunnerkill only the direct child on timeout. Processes it started keep running, and these runners have no option to change that. The async runners (spawnRunnerAsync,runShellAsync,shellRunnerAsync) takeprocessGroup: trueto kill the child's whole process group on timeout or abort.managedProcessRunnertakes it too, sokill()signals the group. - A process group does not receive the terminal's Ctrl-C. To stop it on
Ctrl-C, listen for
SIGINTand abort throughsignal. signal(async only) sendsSIGTERM, thenSIGKILL1 s later if the child is still running. TheSIGTERMlets a child such as git remove its lock files. The promise resolves at once, withneverExited: true,reason: 'abort'andThe operation was abortedinerror. An abort that lands after the child exited keeps the child's exit code. While a child ignoresSIGTERM, the pendingSIGKILLkeeps the host alive for up to 1 s.
Behaviour
runnever throws for a failed command; programmer errors throw. A missing binary, a timeout or an abort becomes a result with a non-zeroexitCode,neverExited: trueand areason. An invalidtimeoutMs(RangeError, orTypeErrorfor a non-number) or a NUL byte in argv or env throws from a sync runner and rejects from an async one.- Capture runners always report something. When the runner has its own
message (spawn failure, timeout, abort, output cap), it is in
error.stderrholds only what the child wrote. - The streaming and managed-process runners capture no output. On a
failed spawn they return
exitCode: 1,neverExited: true,reason: 'spawn'and the message inerror. The streaming runner also prints the error to the parent's stderr, and prints a line when a signal or the timeout ends the child. cwdis required on every call, on every runner. There is no default. Bind it once withwithDefaults. Acwdthat is missing or not a directory is a failed spawn (reason: 'spawn') whose message names thecwd.argvis never parsed by a shell. Spaces, quotes and$()in an argument reach the binary unchanged. OnlyrunShellruns a shell string.envmerges overprocess.env, read at call time. A value ofundefinedremoves that variable from the child's environment.- Early stdin close. If the child closes stdin before
inputis fully written, the result keeps the child's exit code. The async runner and the sync runner on Node put the write error inerror. The sync runner under Bun does not report it. errorcan come with a real exit code. After a failed stdin write, or a child that exits right at the output cap under Bun,exitCodeis the child's own,reasonis absent and onlyerrorsays what went wrong.- Output is text. Captured output is decoded as UTF-8. The sync pipe
path caps each stream at 256 MB and usually kills a child that passes it,
with
reason: 'output'. Under Bun, a child that exits right at the cap keeps its exit code and gets onlyerror. Thebun testcapture path and the async runner have no cap.
Known issue: bun test
Under bun test, once a large module graph is loaded, Bun can hand child
processes stdio that the parent reads back as empty. The upstream report is
oven-sh/bun#24690. To work
around this, spawnRunner checks for the Bun runtime with NODE_ENV=test.
When both are true, it runs the command through /bin/sh with stdin, stdout
and stderr redirected to temp files, then reads the files back. Arguments are
still not parsed by the shell.
This applies only under Bun. Jest and Vitest on Node use the normal pipe
path. The async runner does not have this workaround. If the test process is
killed during a run, its temp dir (command-seam-* in the OS temp dir) is
left behind.
Stability
The package is at 0.x. Each export is stable or experimental.
- Stable exports are the runner contract: the runners, their options and
results,
CommandFailed, and the helpers around them. The runners and helpers are in use by other packages; a type that names their options or results is stable with them. A breaking change to one is listed under "Breaking" in the CHANGELOG. - Experimental exports are newer or have no user yet. Any release may
change or remove one. Each has an
@experimentaltag in its TSDoc.
The experimental exports:
shellRunnerAsync,AsyncShellRunnercatchFailedAsync,mapFailedAsyncfirstLine,stderrOrErrorrunJson,runJsonAsync,parseJsonResult,JsonRun,RunJsonOptionswithDefaults,DefaultedRunnerstopProcessfakeRunner,fakeNeverExited,fakeProcess(from@sksizer/command-seam/testing)FakeRunner,FakeCall,FakeProcess,FakeProcessOptions(from/testing)
Every other export is stable. 1.0 comes once the experimental exports have gone 60 to 90 days without a breaking change, the open API-change questions are settled, and Windows support has landed, if that is feasible.
API
| Export | Kind | Summary |
| --------------------------------------------------------------- | --------- | -------------------------------------------------------------- |
| spawnRunner | function | Default sync capture runner (spawnSync) |
| spawnRunnerAsync | function | Default async capture runner (spawn) |
| resolveRunner, resolveRunnerAsync | function | An injected runner, else the default |
| runChecked, runCheckedAsync | function | Run and throw CommandFailed on a non-zero exit |
| mapFailed, mapFailedAsync | function | Rethrow a CommandFailed as another error |
| catchFailed, catchFailedAsync | function | Turn a CommandFailed into a value |
| asAsync | function | Wrap a sync runner as an async one |
| CommandFailed | class | Error with argv, result and detail; message shows argv |
| CommandRunner, AsyncCommandRunner | interface | Capture runner contracts |
| CommandResult | interface | exitCode, stdout, stderr, neverExited, reason, signal, error |
| NeverExitedReason | type | 'spawn' \| 'timeout' \| 'abort' \| 'signal' \| 'output' |
| CommandRunOptions, AsyncCommandRunOptions | interface | cwd, timeoutMs, input, env; async adds signal, onSpawn, processGroup |
| EnvOverrides | type | Record<string, string \| undefined> |
| ran, failureText, firstLine, stderrOrError | function | Read a result |
| FailureTextOptions | interface | both: join both streams in failureText |
| runJson, runJsonAsync, parseJsonResult | function | Run and parse stdout as JSON |
| JsonRun, RunJsonOptions | type | runJson result and options |
| runShell, runShellAsync | function | Run a shell string with sh -c -- |
| shellRunner, shellRunnerAsync | function | Shell runner over a given CommandRunner |
| ShellRunner, AsyncShellRunner | interface | Shell runner contracts |
| ShellRunOptions, AsyncShellRunOptions, ShellPositionals | interface | Capture options plus scriptName ($0) and args ($1…) |
| shellQuote, shellJoin | function | POSIX quoting for one word or a command line |
| streamRunner | function | Default streaming runner (inherited stdio) |
| StreamRunner, StreamRunOptions, StreamResult | type | Streaming runner contract; the result adds reason, signal, error |
| managedProcessRunner | function | Default managed-process runner |
| stopProcess | function | SIGTERM, then SIGKILL after a grace period |
| ManagedProcessRunner, ManagedProcessRunOptions | interface | Managed-process runner contract and options (cwd required) |
| ManagedProcessHandle, ManagedProcessExit | interface | pid, kill(), exited; how the process ended, with reason and error |
| withDefaults | function | Bind default options to any runner |
| DefaultedRunner | interface | The runner withDefaults returns |
| fakeRunner | function | /testing: a CommandRunner that answers from a table |
| fakeNeverExited | function | /testing: a fake answer for a run that never exited |
| fakeProcess | function | /testing: a ManagedProcessHandle the test ends by hand |
| FakeRunner, FakeCall, FakeProcess, FakeProcessOptions | interface | /testing: the fakes' return, call and option types |
/testing marks an export of @sksizer/command-seam/testing; every other
export is from @sksizer/command-seam. Every export has TSDoc in the source.
Runnable examples for each export are in the
examples directory.
Alternatives
execa: full-featured process execution with strong Windows support and rich streaming. Choose it for most general use.tinyexecandnano-spawn: small, dependency-free wrappers overspawn. tinyexec also has a sync API.zx: shell scripting in JavaScript.cross-spawn: a drop-inspawnthat handles Windows quirks.node:child_process: no dependency at all.
Choose this package when you want the runner as an injected dependency, a
timeout that kills with SIGKILL, and one result shape for every failure, on
Linux or macOS.
Contributing
See CONTRIBUTING.md. Report security issues as described in SECURITY.md.
