@osdlc/command-seam
v0.15.0
Published
Injectable subprocess runner for Node: capture, stream and managed-process runners, hard timeouts, failures as results.
Maintainers
Readme
@osdlc/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 the shell or process-group runners on Windows.
runShell,shellRunnerand theprocessGroupoptions are POSIX only. The argv runners start programs on Windows, but CI runs the Windows tests in a job that does not block a release. See Platforms. - 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 @osdlc/command-seam
# or
pnpm add @osdlc/command-seam
# or
bun add @osdlc/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, through the compiled
dist/. - ESM only. There is no CommonJS build.
- Linux and macOS, and spawning on Windows. See Platforms.
- 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 '@osdlc/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,signal,outputIncompleteanderror.stdoutis a string, or aBufferfrom the bytes runners (CommandResult<Buffer>). neverExited:truewhen the command never reported an exit status. The spawn failed, a timeout or abort killed it, a signal ended it, or the 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 output cap:maxOutputBytes, or the sync runner's default of 256 MB).signalnames the signal when the runner knows it. A timeout may pass on a retry. A spawn failure or the output cap will not.outputIncomplete:truewhen the child exited but the runner stopped reading its output early, sostdoutandstderrmay be cut short. The exit code is real andreasonis absent. See "errorcan come with a real exit code" under Behaviour.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> |
| Read stdout as raw bytes (images, archives) | spawnRunnerBytes(binary) / spawnRunnerBytesAsync(binary) | CommandResult<Buffer> |
| 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 |
| Launch a program and forget it (an editor) | managedProcessRunner(binary) with stdio: 'ignore', unref | 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.
On timeout, the sync and streaming runners kill only the direct child, and
anything it started keeps running. A command that may start background work,
such as an install whose postinstall scripts spawn processes, belongs on
spawnRunnerAsync (or managedProcessRunner) with processGroup: true,
which kills the whole group.
Capture, sync
import { spawnRunner } from '@osdlc/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 four options: signal (an AbortSignal), onSpawn
(called with the child's pid once it starts), processGroup and killTree
(stop the whole process tree when the run is stopped; see Timeouts and
cancellation below).
import { spawnRunnerAsync } from '@osdlc/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())Capture, as bytes
The capture runners decode stdout as UTF-8, and decoding replaces bytes that
are not UTF-8 with U+FFFD. For output that is not text, or that must not
change before you hash it, spawnRunnerBytes and spawnRunnerBytesAsync
return stdout as the Buffer the child wrote. Their result type is
CommandResult<Buffer>, so the compiler knows stdout is a Buffer. Stderr
is still decoded as UTF-8: it is diagnostics, and failureText,
runChecked and CommandFailed read it as they do for text. Each run takes
the same options as its text form, and the factories take the same
SpawnRunnerOptions. maxOutputBytes cuts stdout at exactly the
cap, since there is no character to keep whole.
import { createHash } from 'node:crypto'
import { runChecked, spawnRunnerBytes } from '@osdlc/command-seam'
const git = spawnRunnerBytes('git')
const blob = runChecked(git, ['cat-file', 'blob', 'HEAD:logo.png'], { cwd: process.cwd() }).stdout
console.log(createHash('sha256').update(blob).digest('hex'))runChecked and runCheckedAsync return CommandResult<Buffer> for a bytes
runner. A failure throws the same CommandFailed as any other run, with
stdout decoded into e.result.stdout. To keep the raw bytes of a failed run,
call run and check ran(result) yourself. Code takes a BytesCommandRunner
or an AsyncBytesCommandRunner, and tests pass fakeRunner's bytesRunner
(see Ready-made fakes). asAsync wraps a sync bytes runner as an async one.
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 '@osdlc/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,
maxOutputBytes) 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 a POSIX shell. For a line
a person pastes, use commandString (see
Command lines to paste), which also handles
Windows.
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. Pass quiet: true to turn that line off
when you report the failure yourself. The result's fields are set either way.
import { ran, streamRunner } from '@osdlc/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.
Where stdio goes
streamRunner and managedProcessRunner take a stdio option
(StdioOption):
'inherit', the default: the child uses the parent's stdin, stdout and stderr.'ignore': all three are/dev/null. The child cannot read from or write to the terminal.{ stdout, stderr }: file descriptors you opened, such as one fromfs.openSync. Stdin is/dev/null. Pass the same fd for both to get one log in the order the child wrote it, which joining two pipes afterwards cannot give.1and2are the parent's own stdout and stderr.
The child gets its own copy of each fd when it starts, so you can close yours
once run returns. The runner never closes it. An fd that is not open is a
failed spawn (reason: 'spawn', with EBADF in error). An fd that is not a
whole number, 0 or more, throws before anything starts. A pipe from one child
into another (git archive | tar -x) is out of scope. Use runShell or
child_process for that.
import { closeSync, openSync } from 'node:fs'
import { streamRunner } from '@osdlc/command-seam'
const log = openSync('build.log', 'w')
const r = streamRunner('npm').run(['run', 'build'], {
cwd: process.cwd(),
stdio: { stdout: log, stderr: log },
quiet: true,
})
closeSync(log)
if (r.reason === 'spawn') throw new Error(`npm failed to start: ${r.error}`)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,
processGroup, unref and stdio.
import { managedProcessRunner } from '@osdlc/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.
unref: true lets the parent exit while the child runs. With
processGroup: true and stdio: 'ignore' it starts a program and forgets
it, such as an editor or open <url>: the program outlives the parent, does
not keep it running, gets none of the terminal's signals, and does not write
to the terminal after the parent has gone.
import { managedProcessRunner } from '@osdlc/command-seam'
managedProcessRunner('code').run(['.'], {
cwd: process.cwd(),
processGroup: true,
unref: true,
stdio: 'ignore',
})exited still settles while the parent is alive. An unref'd group is not
tracked by installGroupSignalForwarding (below).
A group leader no longer gets the terminal's Ctrl-C, and a host killed by a
signal would leave the group behind. Call installGroupSignalForwarding() once
at startup to prevent that:
import { installGroupSignalForwarding } from '@osdlc/command-seam'
installGroupSignalForwarding() // SIGINT, SIGTERM, SIGHUP; graceMs defaults to 1000While a group is live, one of those signals sends SIGTERM to every live
group (async and managed runners with processGroup: true), then SIGKILL to
those still registered after graceMs, and the process exits with
128 + signal number. A second signal during the grace skips to SIGKILL. An
exit handler also SIGKILLs every group whose run is still in flight, so a
host that exits first leaves none behind.
The listeners are attached just before the first group starts and removed
when the last one is gone, so a host with no live group keeps each signal's
default action. If the host has its own listener for the signal
(process.once counts too), the groups still get SIGTERM at once and
SIGKILL after graceMs (or on exit, if sooner and the run is in flight).
But that listener keeps the exit. By default the exit sets process.exitCode
and lets output drain, then calls process.exit after 500 ms, so piped
stdout is not cut.
A group stays registered until no member is left, also after its async run
settles or its managed leader exits. A forwarded signal kills what such a
group left behind, such as a daemon. The host's normal exit leaves it
running, unless the runner was stopping the group (an abort, a timeout or
kill()). SIGKILL of the host cannot be caught. POSIX only. On Windows it
does nothing. liveProcessGroups() lists the registered group pids.
A host that spawns its own detached child instead of using a runner can
register it with spawnTrackedGroup(() => spawn(..., { detached: true })).
Check groupStartRefusal() first (it returns a reason while the host is
stopping, else undefined). When the run ends, call settleGroup(pid) unless
the host is itself stopping the group, then releaseWhenEmpty(pid, untrack)
with the untrack the spawn returned. These four are experimental.
When forwarding owns the exit (the host has no listener of its own for the
signal), the first forwarded signal puts the host in a stopping state. A new
run with processGroup: true is refused: it starts nothing and settles with
reason: 'abort'. forwardedSignal() returns the signal, so a host that runs
one command after another can stop its loop. forwardedExitCode() returns
128 + signal number for it. Only the uninstall function resets this. A
host with its own listener, one that may keep running, gets no stopping
state. Its groups are killed, and later runs start as usual.
A host that sets process.exitCode itself after its work returns should keep
the 128 + signal number code. The default exit still forces process.exit
after 500 ms:
installGroupSignalForwarding()
const code = await main()
process.exitCode = forwardedExitCode() ?? codeexited 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 '@osdlc/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 from the output when the process is ready,
because stdio is never piped. Check 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 '@osdlc/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 '@osdlc/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)Finding a binary
which(binary, opts?) returns the absolute path a spawn of binary would
run, or undefined, without running anything. Use it to pick a tool by what
is installed, where probing with --version is slow or has side effects.
import { which } from '@osdlc/command-seam'
const harnesses = ['codex', 'claude', 'cursor-agent'].filter((b) => which(b) !== undefined)It looks a binary up the way the runners' spawn does. opts takes cwd
(default process.cwd()), env, merged over the parent's as the runners
take it, so which('tool', { env: { PATH: binDir } }) checks a test's own
PATH, and platform (default process.platform), so a test can run the
Windows rules on any OS. absolutePathOnly limits the search to absolute
PATH entries (see Running a command inside an untrusted folder).
| Rule | POSIX | Windows (platform: 'win32') |
| ----------------- | ----------------------------------------- | --------------------------------------------------------------- |
| A path | a name with /, resolved against cwd | a name with \ or /, resolved against cwd |
| PATH separator | : | ; |
| Empty entry | means cwd, as for Node's spawn | skipped; cwd is searched only when PATH names it |
| PATH unset | /usr/bin:/bin | nothing is searched |
| Extensions | none | a name ending in a PATHEXT extension is tried as given; any other name with each one appended, in order. Default .COM;.EXE;.BAT;.CMD. Any case. |
| A match | an executable file | a file |
Bun's spawn skips empty PATH entries, so under Bun which can find a
binary there that a spawn would not.
Command lines to paste
commandString(argv, opts?) returns argv as one line a person can paste
into a terminal, quoted for the shell that will read it. opts.platform
(default process.platform) picks the shell: PowerShell on win32, a POSIX
shell elsewhere. opts.shell ('posix', 'powershell' or 'cmd') names
it directly. opts.cwd adds a change of folder first.
import { commandString } from '@osdlc/command-seam'
const argv = ['git', 'commit', '-m', "it's done"]
commandString(argv, { platform: 'linux', cwd: '/my repo' })
// cd '/my repo' && git commit -m 'it'\''s done'
commandString(argv, { platform: 'win32', cwd: 'C:\\my repo' })
// if (Set-Location -LiteralPath 'C:\my repo' -PassThru) { & git commit -m 'it''s done' }
commandString(argv, { platform: 'win32', shell: 'cmd' })
// git commit -m ^"it's^ done^"| Shell | Words | With cwd |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| posix | shellJoin: bare when only A-Za-z0-9_@%+=:,./-, else single-quoted with ' written '\'' and \ written '\\' | cd <dir> && <line> (cd -- for a folder starting with -, cd ./- for -) |
| powershell | & first, always. Bare when it starts with a letter or _-./\ and holds only letters, digits and _-./\:=; else single-quoted, each ' (and ‘ ’ ‚ ‛) doubled | if (Set-Location -LiteralPath <dir> -PassThru) { <line> } |
| cmd | Bare when only A-Za-z0-9_@+:./\-; else quoted and ^-escaped as in Starting a program on Windows. The program gets ^ escapes without quotes | cd /d <dir> && <line> |
In every shell the command runs only when the change of folder works.
PowerShell's ; does not stop on an error, so its form tests the result of
Set-Location -PassThru instead. Windows PowerShell 5.1, and PowerShell before
7.3, drop an empty argument and mangle one holding " when they call a
program. commandString throws RangeError for an empty argv, and for
cmd when a word or cwd holds a line break or a %, which cmd.exe cannot
quote. cmd.exe expands %NAME% in a pasted line before it reads any ^.
A POSIX line reads the same in fish, which treats \' and \\ inside single
quotes as escapes, so a backslash is always written outside the quotes.
@osdlc/command-seam/quote exports commandString, shellJoin and
shellQuote alone. It imports nothing from Node, so a browser bundle can use
it.
Platforms
Linux and macOS are supported in full. On Windows the argv runners
(spawnRunner, spawnRunnerAsync, the bytes runners, streamRunner and
managedProcessRunner) start programs, .cmd and .bat files included.
These parts stay POSIX only:
runShellandshellRunnerneedshonPATH.- The
processGroupoptions andinstallGroupSignalForwardinguse POSIX process groups. Signals follow POSIX rules. On Windows aprocessGrouprun's stop reaches the direct child only. To kill a process tree on Windows too, usekillTree. - The
bun testcapture path runs through/bin/sh.
Starting a program on Windows
On Windows each runner looks the binary up with which and its Windows
rules. An .exe or .com it finds is spawned by that path, with argv
passed directly. A binary it cannot find is not spawned at all: a Windows
spawn of a bare name searches the current folder first, which is often the
repository being worked on. The run fails as a missing binary does
(spawn <binary> ENOENT), and classifyFailure reports not-installed, as
on POSIX.
Node will not spawn a .cmd or .bat file directly. The runner starts
cmd.exe /d /v:off /s /c "<line>" instead (ComSpec when set), with
windowsVerbatimArguments. /v:off keeps delayed expansion (!VAR!) off
even when the registry turns it on. Each argument is quoted in two steps:
- Microsoft C runtime quoting: backslashes before a
"are doubled and the"becomes\", trailing backslashes are doubled, and the argument is wrapped in". cmd.exeescaping: each of( ) [ ] % ! ^ " ` < > & | ; , * ?and space gets a^. The quotes are escaped too, socmd.exenever enters a quoted section and every^applies.
Step 2 is applied twice for every batch file. A batch file passes its
arguments on with %* or %1, and cmd.exe parses that line a second
time. The npm, pnpm and yarn shims do this, in node_modules/.bin and in the
global %APPDATA%\npm, and so do gradlew.bat and mvnw.cmd. With one
escape, a " in an argument would end the quoted text on the second parse,
and a & or | after it would run as a command.
A % in an argument to a batch file, or in the batch file's path, is
refused, as a failed spawn with an EINVAL message. cmd.exe expands
%NAME%, %NAME:a=b% and %NAME:~n,m% before it reads any ^, even inside
quotes, so the escaping above cannot protect a %. Pass the value through an
environment variable or a file. A .exe gets its argv directly and takes any
%.
Two limits remain. A batch file that parses its arguments a third time (it
stores %* in a variable and expands that later, or passes it through
call) can still be broken by an argument holding ". A batch file that
uses an argument without parsing it again gets the extra carets in its
value.
An argument with a line break cannot be passed to a batch file: cmd.exe
ends the command there. The run is refused before anything starts, as a
failed spawn (reason: 'spawn', classifyFailure kind spawn). A NUL
byte throws, as on every platform.
Running a command inside an untrusted folder
A relative or empty PATH entry is looked up from the child's cwd. On
Windows, a spawn of a bare name searches cwd before PATH. So when cwd
is a folder someone else wrote, such as a cloned repository, a file there
named git can run in place of git. Pass absolutePathOnly: true to
any runner factory: spawnRunner, spawnRunnerBytes, their async forms,
streamRunner or managedProcessRunner. The runner then looks the bare name up
on absolute PATH entries only, using PATHEXT on Windows, and spawns the
absolute path it finds. When there is none, the run fails as a missing
binary does (ENOENT, kind not-installed). With PATH unset, POSIX
searches /usr/bin:/bin, as a spawn does, and Windows searches nothing. A
name with a separator, such as ./gradlew, is a path and runs as given.
import { spawnRunnerAsync } from '@osdlc/command-seam'
const git = spawnRunnerAsync('git', { absolutePathOnly: true })
const r = await git.run(['rev-parse', '--is-inside-work-tree'], { cwd: '/tmp/clone' })which(binary, { absolutePathOnly: true }) does the same lookup without
running anything. absolutePathCandidates(binary, { path, pathext, platform })
returns the paths that lookup checks, in order, without reading the disk.
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.runFailed(result, isAnswer?): the run must be read as a failure. It never exited, its output was cut short (outputIncomplete, even at exit 0), or it exited non-zero andisAnswer(result)is nottrue.isAnsweris asked only about a complete run that exited, so a missing binary's stand-in exit 1 never reads as "not there". See "Building a client over one program".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 a Result from @osdlc/result with the finished run
alongside it: { ok: true, value, result } or { ok: false, error, result }.
error is a JsonFailure. When the command did not run, or exited with a code
not in okExitCodes, it is the CommandFailure for that run (kind
'not-installed', 'exit', 'timeout' and so on, see "Classifying a
failure"). Otherwise its kind is 'unparseable' (stdout is not JSON) or
'invalid' (the parse option threw, and error.cause holds what it threw).
To tell the two apart, call isJsonParseFailure(error): true means the
command ran but its output was no use, false means the run itself failed
(and error narrows to CommandFailure). It stays right if a new run-failure
kind is added, which a hand-written list of kinds would not.
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 a failure. 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 error.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 '@osdlc/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.error.kind}: ${r.error.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.error.kind === 'invalid') console.error(checked.error.message, checked.error.cause)Classifying a failure
classifyFailure(result, { binary, argv, okExitCodes }) turns a finished run
into a CommandFailure, or null when the run succeeded (it exited with a
code in okExitCodes, default [0]). It reads any runner's result: a capture
runner's CommandResult, a StreamResult or a ManagedProcessExit. A
CommandFailure is a Failure from @osdlc/result with the run's binary,
argv, exitCode (null when the child never reported one) and signal.
| kind | When | message |
| ---------------- | -------------------------------------------------------------------- | -------------------------------------------------------------- |
| not-installed | spawn ENOENT on the binary | `gh` is not installed or not on PATH. |
| not-executable | spawn EACCES, EPERM or ENOEXEC | `gh` cannot be run: permission denied or not an executable. |
| bad-cwd | the working directory is missing, is not a directory, or cannot be entered | The working directory "/x" is missing or is not a directory., or The working directory "/x" cannot be entered: permission denied. |
| timeout | timeoutMs ran out | `gh` timed out and was stopped. |
| output-cap | the child passed maxOutputBytes | `gh` wrote more output than allowed and was stopped. |
| signal | a signal from elsewhere ended the child | `gh` was ended by SIGTERM. |
| aborted | an AbortSignal or kill() stopped it | `gh` was stopped before it finished. |
| spawn | any other failure to start (EBADF, E2BIG, a fake with no answer) | `gh` could not be started. |
| exit | it ran and exited with a code that is not accepted | the first line of the failure text (failureText) |
A working directory without search (execute) permission is bad-cwd, with
the cannot be entered message. Earlier releases reported it as
not-executable, which read as a binary that cannot be run.
The messages say The command when the binary is not known. There is no
hint at this layer. The layer that knows the tool knows how to install it.
detail is the runner's error text for the kinds where the child never
exited, and both streams (failureText(result, { both: true })) for exit.
import { classifyFailure, spawnRunner } from '@osdlc/command-seam'
const r = spawnRunner('definitely-not-installed').run(['--version'], { cwd: process.cwd() })
const failure = classifyFailure(r, { binary: 'definitely-not-installed', argv: ['--version'] })
if (failure?.kind === 'not-installed') console.error(failure.message)Reporting failures
CommandFailed carries failure, the classified CommandFailure for the run.
It extends FailureError from @osdlc/result, so a caller that handles
FailureError handles it too, and unwrap rethrows one the same way.
failure.message is the one line to show a person. message below is the
long form for logs.
CommandFailed also carries binary, argv and the full result. Its message
is command failed (exit N): <binary> <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.
runChecked and runCheckedAsync take the binary from the runner's
binary property, which spawnRunner, spawnRunnerAsync, asAsync and
withDefaults runners have. A fake runner may leave it out. The message then
shows the argv alone and e.binary is undefined.
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.
isCommandFailed(error) checks a caught value by its shape rather than with
instanceof, so it still recognises a CommandFailed thrown by another
installed copy of this package. The helpers above use it.
import { catchFailed, mapFailed, runChecked, spawnRunner } from '@osdlc/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)Building a client over one program
A typed client over one program (@osdlc/typed-git over git,
@osdlc/typed-gh over gh) needs the same few parts whatever the program.
They are here, so each client keeps only what is its own: its failure
kinds, its messages and its error classes.
runFailed(result, isAnswer?)is the gate between a result and a parse (see "Reading results"). A verb that reads exit 1 as "not there" passes(r) => r.exitCode === 1.classifyFailed(failed, { binary, installHint? })is the start of the client's own classifier. It returns theCommandFailureaCommandFailedcarries, with theCommandFailedas itscause. When the runner named no binary (a fake), the failure is classified again withbinary, so the message names the program. Anot-installedfailure getsinstallHintas itshint. Read the program's own stderr only whenkindisexit.tryMethods(client, recognize)andtryMethodsAsyncwrap a client so every method returns aResult(TriedMethods,TriedMethodsAsync) in place of throwing.recognize(error)returns the failure a thrown value carries, ornull, and then the value is thrown again, so aTypeErrorfrom a bug still throws.methodFacade(client, wrap)is the proxy under them. Every function property callswrap(call, name), which makes the call (or not) and returns the result. Use it for a formtryMethodsdoes not cover, such as one that falls back to a default value. Methods run on the client itself, so private fields still work.notAnOption(value, reject)returnsvalue, or throwsreject(value)when it starts with-, before anything runs. A program reads such a positional as an option, and some options run code.rejectbuilds the client's own error.
import {
classifyFailed,
CommandFailed,
isCommandFailed,
notAnOption,
runFailed,
spawnRunner,
tryMethods,
} from '@osdlc/command-seam'
const git = spawnRunner('git')
const client = {
/** A config value, or `null` when it is unset (`git config --get` exits 1). */
configGet(key: string): string | null {
const argv = ['config', '--get', notAnOption(key, (k) => new TypeError(`bad key ${k}`))]
const result = git.run(argv, { cwd: process.cwd() })
if (runFailed(result, (r) => r.exitCode === 1)) throw new CommandFailed(argv, result, git.binary)
return result.exitCode === 0 ? result.stdout.trim() : null
},
}
const tried = tryMethods(client, (e) =>
isCommandFailed(e) ? classifyFailed(e, { binary: 'git', installHint: 'Install git.' }) : null,
)
const name = tried.configGet('user.name')
if (!name.ok) console.error(name.error.message, name.error.hint ?? '')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 '@osdlc/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
@osdlc/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, bytesRunner, asyncBytesRunner, 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.bytesRunnerandasyncBytesRunnerare the same fake as bytes runners. An answer'sstdoutmay be a string or aBuffer: the bytes forms encode a string as UTF-8, and the text forms decode aBuffer.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,stdioandunrefincluded, so a test asserts on them there.piddefaults to4242.
import { runChecked, stopProcess } from '@osdlc/command-seam'
import { fakeNeverExited, fakeProcess, fakeRunner } from '@osdlc/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 capture runners wait for the output pipes to close, not only for
the child to exit. If the child exits but a process it started still holds
stdout or stderr, the run waits until
timeoutMs(or, without one, until that process exits). The result then has the child's ownexitCode,outputIncomplete: true, noreason, and spawnSync'sETIMEDOUTmessage inerror. Output written after the deadline is lost, and the process keeps running. A run that may start background work belongs on an async runner withprocessGroup: true. Underbun testthe sync runner captures through temp files instead of pipes (see Known issue:bun test), so it returns as soon as the child exits: later output is lost withoutoutputIncompleteorerror, and a test there never sees the flag. - The sync runners (
spawnRunner,spawnRunnerBytes,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,spawnRunnerBytesAsync,runShellAsync,shellRunnerAsync) takeprocessGroup: trueto kill the child's whole process group on timeout or abort.managedProcessRunnertakes it too, sokill()signals the group. killTree: trueon the async runners kills the child's process tree when the run is stopped, on every platform. On POSIX it isprocessGroup: true. On Windows the runner runstaskkill /pid <pid> /t /ffrom%SystemRoot%\System32, by absolute path, while the child is still running, then kills the child. Pass it for a command that forks, such as a build tool.- A
streamRunnertimeout prints its line to the parent's stderr, as a failed spawn or a signal does, unlessquietis set. Withstdioset to an fd, the line still goes to the parent's stderr, not to the fd. - A process group does not receive the terminal's Ctrl-C. To stop it on
Ctrl-C, call
installGroupSignalForwarding()once at startup, or listen forSIGINTand 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. Their stdio is inherited by default, or
/dev/nullor file descriptors you pass throughstdio. On a failed spawn they returnexitCode: 1,neverExited: true,reason: 'spawn'and the message inerror. An fd instdiothat is not open is a failed spawn too. 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, unlessquietis set.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. On Windows a.cmdor.batfile runs throughcmd.exe, with each argument quoted for it (see Platforms).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.exitCodeis then the child's own andreasonis absent. Three cases:- a failed stdin write: only
errorsays what went wrong; - a sync run whose child exited while a process it started held the output
pipes past
timeoutMs(see Timeouts and cancellation):outputIncomplete: true; - a child that passes the output cap but exits before the runner's kill
lands, on Node or Bun:
outputIncomplete: true.
ranandrunCheckedgo by the exit code, so they treat the last two as success. CheckoutputIncompletewhen the output must be whole.- a failed stdin write: only
Output is text, unless you ask for bytes. Captured output is decoded as UTF-8. The bytes runners (
spawnRunnerBytes,spawnRunnerBytesAsync) return stdout as theBufferthe child wrote and decode only stderr. A message built from a bytes result (failureText,CommandFailed) decodes stdout as UTF-8.Output is capped per stream.
maxOutputBytescaps each of stdout and stderr on the capture runners. The sync pipe path defaults to 256 MB. The async runner has no default cap. The cap counts the bytes the child wrote, not the decoded text: bytes that are not valid UTF-8 decode to U+FFFD, which is 3 bytes, so a stream can be longer than the cap once decoded. A child that passes the cap is killed withSIGKILL(its group, withprocessGroup), withreason: 'output', the runner's message inerror, and that stream cut to the cap on a character boundary (a bytes runner's stdout at exactly the cap). A child that passes the cap but exits before the kill lands keeps its exit code and getsoutputIncomplete: trueanderror. Thebun testcapture path writes to files and has no cap.@osdlc/agent-callhas the same option on its call spec, with the reasonoutput-limit. It stops the agent the way its timeout does (SIGTERM, thenSIGKILLafter 5 s), where this runner sendsSIGKILLat once.
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 (and spawnRunnerBytes) 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. Other packages use the runners and helpers. A type that names their options or results is stable with them. A breaking change to one is marked 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,AsyncShellRunnerspawnRunnerBytes,spawnRunnerBytesAsync,BytesCommandRunner,AsyncBytesCommandRunnercatchFailedAsync,mapFailedAsyncfirstLine,stderrOrErrorrunJson,runJsonAsync,parseJsonResult,isJsonParseFailure,JsonRun,JsonFailure,RunJsonOptionswithDefaults,DefaultedRunnerwhich,WhichOptions,absolutePathCandidates,PathCandidateOptionscommandString,CommandStringOptionsSpawnRunnerOptionsstopProcessinstallGroupSignalForwarding,GroupSignalForwardingOptionsliveProcessGroups,forwardedSignal,forwardedExitCodespawnTrackedGroup,settleGroup,releaseWhenEmpty,groupStartRefusal(register a detached child the caller spawned itself)fakeRunner,fakeNeverExited,fakeProcess(from@osdlc/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) |
| spawnRunnerBytes, spawnRunnerBytesAsync | function | Capture runners with stdout as raw bytes (CommandResult<Buffer>) |
| 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 (text or bytes) as an async one |
| CommandFailed | class | Error with binary, argv, result and detail; message shows the command |
| isCommandFailed | function | Recognise a CommandFailed, across copies of the package |
| SpawnRunnerOptions | interface | absolutePathOnly: look a bare name up on absolute PATH entries only |
| CommandRunner, AsyncCommandRunner | interface | Capture runner contracts; binary names the program |
| BytesCommandRunner, AsyncBytesCommandRunner | interface | The same, with stdout as a Buffer |
| CommandResult | interface | exitCode, stdout, stderr, neverExited, reason, signal, outputIncomplete, error; CommandResult<Buffer> for bytes |
| NeverExitedReason | type | 'spawn' \| 'timeout' \| 'abort' \| 'signal' \| 'output' |
| CommandRunOptions, AsyncCommandRunOptions | interface | cwd, timeoutMs, input, env, maxOutputBytes; async adds signal, onSpawn, processGroup, killTree |
| 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 |
| isJsonParseFailure | function | Whether a runJson failure is about the output, not the run |
| JsonFailure | type | A runJson failure: a CommandFailure, unparseable or invalid |
| classifyFailure | function | Classify a failed run (not-installed, timeout, exit, …) |
| classifyFailed | function | A CommandFailed's failure, named for the program, with cause and an install hint |
| ClassifyFailedOptions | interface | binary, installHint |
| runFailed | function | Whether a result is a failure: never exited, output cut short, or an exit that is not an answer |
| methodFacade | function | A proxy that wraps every method of a client |
| tryMethods, tryMethodsAsync | function | Every method of a client returning a Result instead of throwing |
| TriedMethods, TriedMethodsAsync | type | The types tryMethods and tryMethodsAsync return |
| notAnOption | function | Refuse a positional argument that starts with - |
| CommandFailure, CommandFailureKind | type | A classified failure with message and detail; its kinds |
| ClassifiableRun, ClassifyContext | interface | What classifyFailure reads; binary, argv, okExitCodes |
| 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 |
| commandString | function | A command line to paste, quoted for POSIX, PowerShell or cmd.exe |
| CommandStringOptions | interface | platform, shell, cwd |
| streamRunner | function | Default streaming runner (inherited stdio) |
| StreamRunner, StreamRunOptions, StreamResult | type | Streaming runner contract; options add stdio and quiet; the result adds reason, signal, error |
| StdioOption | type | 'inherit' \| 'ignore' \| { stdout, stderr } file descriptors |
| 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; env, processGroup, unref, stdio) |
| ManagedProcessHandle, ManagedProcessExit | interface | pid, kill(), exited; how the process ended, with reason and error |
| installGroupSignalForwarding, liveProcessGroups, forwardedSignal, forwardedExitCode | function | Kill live process groups on SIGINT/SIGTERM/SIGHUP; list them; the signal forwarded and its exit code |
| `Gro
