@osdlc/command-seam 0.0.0-stage → 0.0.1-stage
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +328 -0
- package/CONTRIBUTING.md +58 -0
- package/LICENSE +21 -0
- package/README.md +741 -2
- package/SECURITY.md +23 -0
- package/async.ts +466 -0
- package/defaults.ts +83 -0
- package/dist/async.d.ts +146 -0
- package/dist/async.d.ts.map +1 -0
- package/dist/async.js +368 -0
- package/dist/defaults.d.ts +52 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +51 -0
- package/dist/groups.d.ts +155 -0
- package/dist/groups.d.ts.map +1 -0
- package/dist/groups.js +331 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/process.d.ts +146 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +175 -0
- package/dist/process.test.d.ts +1 -0
- package/dist/process.test.d.ts.map +1 -0
- package/dist/process.test.js +1 -0
- package/dist/quote.d.ts +18 -0
- package/dist/quote.d.ts.map +1 -0
- package/dist/quote.js +28 -0
- package/dist/result.d.ts +211 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +219 -0
- package/dist/shell.d.ts +84 -0
- package/dist/shell.d.ts.map +1 -0
- package/dist/shell.js +83 -0
- package/dist/stream.d.ts +90 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +73 -0
- package/dist/sync.d.ts +311 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +406 -0
- package/dist/testing.d.ts +158 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +173 -0
- package/groups.ts +360 -0
- package/index.ts +98 -0
- package/package.json +94 -3
- package/process.ts +270 -0
- package/quote.ts +31 -0
- package/result.ts +403 -0
- package/shell.ts +131 -0
- package/smoke.mjs +61 -0
- package/stream.ts +134 -0
- package/sync.ts +604 -0
- package/testing.ts +263 -0
package/README.md
CHANGED
|
@@ -1,3 +1,742 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @osdlc/command-seam
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run external commands through a small interface you can replace in tests.
|
|
4
|
+
Timeouts kill the process. Failures come back as values, not exceptions.
|
|
5
|
+
|
|
6
|
+
## Why
|
|
7
|
+
|
|
8
|
+
Code that calls `child_process` directly has three common problems:
|
|
9
|
+
|
|
10
|
+
- **It is hard to test.** You have to mock a Node module, or run the real
|
|
11
|
+
binary in every test.
|
|
12
|
+
- **Timeouts do not always stop the command.** Node's `timeout` option sends
|
|
13
|
+
`SIGTERM`, which a process can ignore. A forked grandchild can also keep the
|
|
14
|
+
output pipes open, so the call waits long after the timeout.
|
|
15
|
+
- **Failures arrive in several shapes.** A missing binary, a timeout and a
|
|
16
|
+
non-zero exit each show up differently.
|
|
17
|
+
|
|
18
|
+
This package gives you a _runner_: an object with one `run` method. Code that
|
|
19
|
+
needs to run `git` takes a runner as a parameter. In production you pass the
|
|
20
|
+
real one, `spawnRunner('git')`. In tests you pass a fake that returns canned
|
|
21
|
+
results. Every runner reports failures the same way, and a timeout always
|
|
22
|
+
kills the child with `SIGKILL`.
|
|
23
|
+
|
|
24
|
+
The name comes from the testing term "seam": a place where you can swap
|
|
25
|
+
behaviour without changing the code around it. Here the seam is the runner
|
|
26
|
+
parameter.
|
|
27
|
+
|
|
28
|
+
## When not to use it
|
|
29
|
+
|
|
30
|
+
- **You need Windows.** This package is tested only on Linux and macOS. See
|
|
31
|
+
Requirements below.
|
|
32
|
+
- **You want shell-script ergonomics** (template strings, piping, globbing).
|
|
33
|
+
Use [`zx`](https://www.npmjs.com/package/zx) or
|
|
34
|
+
[`execa`](https://www.npmjs.com/package/execa).
|
|
35
|
+
- **You need to process output as a stream while the command runs**, with
|
|
36
|
+
backpressure or line-by-line iteration. Use
|
|
37
|
+
[`execa`](https://www.npmjs.com/package/execa),
|
|
38
|
+
[`tinyexec`](https://www.npmjs.com/package/tinyexec) or `child_process`
|
|
39
|
+
directly. The capture runners here buffer all output until the command
|
|
40
|
+
ends.
|
|
41
|
+
- **You need a full process manager** (restarts, logs, health checks).
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
npm install @osdlc/command-seam
|
|
47
|
+
# or
|
|
48
|
+
pnpm add @osdlc/command-seam
|
|
49
|
+
# or
|
|
50
|
+
bun add @osdlc/command-seam
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
These are the facts today, not a support promise.
|
|
56
|
+
|
|
57
|
+
- **Node.js 22 or later** (`engines.node` is `>=22`). Node is the primary
|
|
58
|
+
runtime.
|
|
59
|
+
- **Bun** also works. Under Bun the package resolves to its TypeScript source
|
|
60
|
+
through the `bun` export condition.
|
|
61
|
+
- **ESM only.** There is no CommonJS build.
|
|
62
|
+
- **POSIX only (Linux, macOS).** `runShell` needs `sh` on `PATH`. The
|
|
63
|
+
`processGroup` options use POSIX process groups. Signals
|
|
64
|
+
follow POSIX rules. Windows is untested and not supported for now.
|
|
65
|
+
- **TypeScript 5.4 or later.** The types use the `NodeJS` namespace, so
|
|
66
|
+
install `@types/node` (an optional peer dependency).
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { CommandFailed, failureText, runChecked, spawnRunner } from '@osdlc/command-seam'
|
|
72
|
+
|
|
73
|
+
const git = spawnRunner('git')
|
|
74
|
+
const cwd = process.cwd()
|
|
75
|
+
|
|
76
|
+
// run() never throws for a failed command. Read exitCode yourself.
|
|
77
|
+
const status = git.run(['status', '--porcelain'], { cwd, timeoutMs: 10_000 })
|
|
78
|
+
if (status.exitCode === 0) console.log(status.stdout)
|
|
79
|
+
else console.error(failureText(status))
|
|
80
|
+
|
|
81
|
+
// runChecked() throws CommandFailed on a non-zero exit.
|
|
82
|
+
try {
|
|
83
|
+
runChecked(git, ['push', 'origin', 'main'], { cwd, timeoutMs: 30_000 })
|
|
84
|
+
} catch (e) {
|
|
85
|
+
if (e instanceof CommandFailed) console.error(e.detail)
|
|
86
|
+
else throw e
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Concepts
|
|
91
|
+
|
|
92
|
+
- **Runner**: an object with a `run(argv, options)` method. `argv` is the list
|
|
93
|
+
of arguments, without the binary name. The binary is fixed when you create
|
|
94
|
+
the runner.
|
|
95
|
+
- **Result**: what a capture runner returns: `exitCode`, `stdout`, `stderr`,
|
|
96
|
+
`neverExited`, `reason`, `signal` and `error`.
|
|
97
|
+
- **`neverExited`**: `true` when the command never reported an exit status.
|
|
98
|
+
The spawn failed, a timeout or abort killed it, a signal ended it, or the
|
|
99
|
+
sync runner killed it at the output cap.
|
|
100
|
+
`exitCode` is then a stand-in `1`. Check it before you treat an exit code as
|
|
101
|
+
an answer: `grep` exits 1 for "no matches", and a missing `grep` must not
|
|
102
|
+
look the same.
|
|
103
|
+
- **`reason`**: which of those causes it was, set whenever `neverExited` is:
|
|
104
|
+
`'spawn'` (it never started), `'timeout'`, `'abort'` (the caller stopped
|
|
105
|
+
it), `'signal'` (a signal from elsewhere ended it) or `'output'` (the sync
|
|
106
|
+
runner's 256 MB output cap). `signal` names the signal when the runner
|
|
107
|
+
knows it. A timeout may pass on a retry; a spawn failure or the output cap
|
|
108
|
+
will not.
|
|
109
|
+
- **`error`**: the runner's own message, such as a spawn error or
|
|
110
|
+
`timed out after 500ms`. It is never copied into `stderr`, which holds only
|
|
111
|
+
what the child wrote. `failureText` and `CommandFailed` read `error` when
|
|
112
|
+
`stderr` is empty.
|
|
113
|
+
- **Checked call**: `runChecked` and `runCheckedAsync` throw `CommandFailed`
|
|
114
|
+
when `exitCode` is not 0. Plain `run` calls never throw for a failed
|
|
115
|
+
command; programmer errors throw (see Behaviour).
|
|
116
|
+
|
|
117
|
+
## Choosing a runner
|
|
118
|
+
|
|
119
|
+
| Need | Use | Returns |
|
|
120
|
+
| -------------------------------------------- | ---------------------------------------- | ---------------------------- |
|
|
121
|
+
| Run a command and read its output | `spawnRunner(binary)` | `CommandResult` |
|
|
122
|
+
| The same, without blocking the event loop | `spawnRunnerAsync(binary)` | `Promise<CommandResult>` |
|
|
123
|
+
| Run a shell string (`a && b`, pipes) | `runShell(verb, opts)` / `shellRunner()` | `CommandResult` |
|
|
124
|
+
| Show output live (builds, interactive tools) | `streamRunner(binary)` | `StreamResult` (exit only) |
|
|
125
|
+
| Start a long-running process and keep going | `managedProcessRunner(binary)` | `ManagedProcessHandle` |
|
|
126
|
+
|
|
127
|
+
Use the sync runner for short commands run one after another. Blocking costs
|
|
128
|
+
them nothing and keeps your code synchronous. Use the async runner for
|
|
129
|
+
long-running commands, or to run several commands at once.
|
|
130
|
+
|
|
131
|
+
### Capture, sync
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { spawnRunner } from '@osdlc/command-seam'
|
|
135
|
+
|
|
136
|
+
const git = spawnRunner('git')
|
|
137
|
+
const r = git.run(['rev-parse', 'HEAD'], { cwd: process.cwd() })
|
|
138
|
+
if (r.exitCode === 0) console.log(r.stdout.trim())
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Capture, async
|
|
142
|
+
|
|
143
|
+
The async runner adds three options: `signal` (an `AbortSignal`), `onSpawn`
|
|
144
|
+
(called with the child's pid once it starts) and `processGroup` (see
|
|
145
|
+
Timeouts and cancellation below).
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { spawnRunnerAsync } from '@osdlc/command-seam'
|
|
149
|
+
|
|
150
|
+
const git = spawnRunnerAsync('git')
|
|
151
|
+
const controller = new AbortController()
|
|
152
|
+
const [log, branch] = await Promise.all([
|
|
153
|
+
git.run(['log', '-1', '--format=%s'], { cwd: process.cwd(), signal: controller.signal }),
|
|
154
|
+
git.run(['branch', '--show-current'], { cwd: process.cwd(), timeoutMs: 5_000 }),
|
|
155
|
+
])
|
|
156
|
+
console.log(log.stdout.trim(), branch.stdout.trim())
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Shell strings
|
|
160
|
+
|
|
161
|
+
Every other runner passes `argv` straight to the binary, so nothing is parsed
|
|
162
|
+
by a shell. `runShell` is the one function that runs a shell string, as
|
|
163
|
+
`sh -c -- <verb>`. The `--` keeps a verb that starts with `-` or `+` from
|
|
164
|
+
being read as an option to `sh`. Pass data as positional parameters (`$1`, `$2`, …) through
|
|
165
|
+
`args`, or quote it with `shellQuote`.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
import { runShell, runShellAsync, shellQuote, shellRunner, spawnRunner } from '@osdlc/command-seam'
|
|
169
|
+
|
|
170
|
+
const cwd = process.cwd()
|
|
171
|
+
const file = 'notes with spaces.txt'
|
|
172
|
+
|
|
173
|
+
// Positional parameters are passed as separate arguments, never parsed.
|
|
174
|
+
const lines = runShell('wc -l < "$1"', { cwd, args: [file] })
|
|
175
|
+
|
|
176
|
+
// Or quote the value into the string.
|
|
177
|
+
const same = runShell(`wc -l < ${shellQuote(file)}`, { cwd })
|
|
178
|
+
|
|
179
|
+
const later = await runShellAsync('sleep 1 && echo done', { cwd, timeoutMs: 5_000 })
|
|
180
|
+
|
|
181
|
+
// Like every runner, it can run through a fake.
|
|
182
|
+
const sh = shellRunner(spawnRunner('sh'))
|
|
183
|
+
console.log(lines.stdout, same.stdout, later.stdout, sh.run('echo hi', { cwd }).stdout)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`runShell` takes the capture options (`timeoutMs`, `env`, `input`) and returns a `CommandResult`. A
|
|
187
|
+
fake passed to `shellRunner` receives `['-c', '--', verb]`, then `scriptName` and `args` when given.
|
|
188
|
+
`shellJoin(argv)` quotes a whole command line, for printing one a person can paste.
|
|
189
|
+
|
|
190
|
+
### Streaming
|
|
191
|
+
|
|
192
|
+
`streamRunner` inherits stdin, stdout and stderr, so the child's output
|
|
193
|
+
appears as it runs. The result has no output, only how the child ended:
|
|
194
|
+
`exitCode`, `neverExited`, `reason` (`'spawn'`, `'timeout'` or `'signal'`),
|
|
195
|
+
`signal`, and `error`, the runner's message when the spawn failed or the
|
|
196
|
+
timeout fired. That message, or `<binary> was killed by <signal>`, is also
|
|
197
|
+
written to the parent's stderr.
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import { ran, streamRunner } from '@osdlc/command-seam'
|
|
201
|
+
|
|
202
|
+
const npm = streamRunner('npm')
|
|
203
|
+
const r = npm.run(['run', 'build'], { cwd: process.cwd(), timeoutMs: 600_000 })
|
|
204
|
+
// The runner has already printed why; exit 124 on a timeout, like `timeout(1)`.
|
|
205
|
+
process.exitCode = r.reason === 'timeout' ? 124 : ran(r) ? 0 : 1
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`run` blocks until the child exits. There is no async version.
|
|
209
|
+
|
|
210
|
+
### Managed process
|
|
211
|
+
|
|
212
|
+
`managedProcessRunner` starts a process and returns a handle at once. It does
|
|
213
|
+
not wait for the process to finish, capture output or apply a timeout. Its
|
|
214
|
+
options are `ManagedProcessRunOptions`: `cwd` (required), `env` and
|
|
215
|
+
`processGroup`.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
import { managedProcessRunner } from '@osdlc/command-seam'
|
|
219
|
+
|
|
220
|
+
const server = managedProcessRunner('node').run(['server.js'], {
|
|
221
|
+
cwd: process.cwd(),
|
|
222
|
+
processGroup: true, // kill() then signals the whole process group
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
console.log(server.pid) // -1 if the spawn failed
|
|
226
|
+
setTimeout(() => server.kill(), 5_000) // SIGTERM by default
|
|
227
|
+
const exit = await server.exited // never rejects
|
|
228
|
+
if (exit.reason === 'abort') console.log('stopped by kill()')
|
|
229
|
+
else if (exit.reason === 'spawn') console.error(`could not start: ${exit.error}`)
|
|
230
|
+
else console.log(exit.exitCode, exit.signal)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`processGroup: true` makes the child the leader of its own process group, so
|
|
234
|
+
`kill()` also stops any processes it started.
|
|
235
|
+
|
|
236
|
+
A group leader no longer gets the terminal's Ctrl-C, and a host killed by a
|
|
237
|
+
signal would leave the group behind. Call `installGroupSignalForwarding()` once
|
|
238
|
+
at startup to prevent that:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { installGroupSignalForwarding } from '@osdlc/command-seam'
|
|
242
|
+
|
|
243
|
+
installGroupSignalForwarding() // SIGINT, SIGTERM, SIGHUP; graceMs defaults to 1000
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
While a group is live, one of those signals sends `SIGTERM` to every live
|
|
247
|
+
group (async and managed runners with `processGroup: true`), then `SIGKILL` to
|
|
248
|
+
those still registered after `graceMs`, and the process exits with
|
|
249
|
+
`128 + signal number`. A second signal during the grace skips to `SIGKILL`. An
|
|
250
|
+
`exit` handler also `SIGKILL`s every group whose run is still in flight, so a
|
|
251
|
+
host that exits first leaves none behind. The listeners are attached just before the first
|
|
252
|
+
group starts and removed when the last one is gone, so a host with no live group
|
|
253
|
+
keeps each signal's default action. If the host has its own listener for the
|
|
254
|
+
signal (`process.once` counts too), the groups still get `SIGTERM` at once
|
|
255
|
+
and `SIGKILL` after `graceMs` (or on exit, if sooner and the run is in
|
|
256
|
+
flight), but that listener keeps the exit. By default the exit
|
|
257
|
+
sets `process.exitCode` and lets output drain, then calls `process.exit` after
|
|
258
|
+
500 ms, so piped stdout is not cut. A group stays registered until no member is
|
|
259
|
+
left, also after its async run settles or its managed leader exits. A
|
|
260
|
+
forwarded signal kills what such a group left behind, such as a daemon. The
|
|
261
|
+
host's normal exit leaves it running, unless the runner was stopping the group
|
|
262
|
+
(an abort, a timeout or `kill()`). `SIGKILL`
|
|
263
|
+
of the host cannot be caught. POSIX only; a no-op on Windows.
|
|
264
|
+
`liveProcessGroups()` lists the registered group pids.
|
|
265
|
+
|
|
266
|
+
When forwarding owns the exit (the host has no listener of its own for the
|
|
267
|
+
signal), the first forwarded signal puts the host in a stopping state. A new
|
|
268
|
+
run with `processGroup: true` is refused: it starts nothing and settles with
|
|
269
|
+
`reason: 'abort'`. `forwardedSignal()` returns the signal, so a host that runs
|
|
270
|
+
one command after another can stop its loop. `forwardedExitCode()` returns
|
|
271
|
+
`128 + signal number` for it. Only the uninstall function
|
|
272
|
+
resets this. A host with its own listener, one that may keep running, gets no
|
|
273
|
+
stopping state: its groups are killed, and later runs start as usual.
|
|
274
|
+
|
|
275
|
+
A host that sets `process.exitCode` itself after its work returns should keep
|
|
276
|
+
the `128 + signal number` code. The default exit still forces `process.exit`
|
|
277
|
+
after 500 ms:
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
installGroupSignalForwarding()
|
|
281
|
+
const code = await main()
|
|
282
|
+
process.exitCode = forwardedExitCode() ?? code
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`exited` reports `reason: 'abort'` when a signal ends the process after your
|
|
286
|
+
own `kill()`, `'signal'` when a signal from elsewhere ends it, and `'spawn'`
|
|
287
|
+
with the error message in `error` when it never started.
|
|
288
|
+
|
|
289
|
+
`kill()` sends one signal. A process that handles `SIGTERM` and keeps
|
|
290
|
+
running leaves `await server.exited` waiting forever.
|
|
291
|
+
`stopProcess(handle, { graceMs })` sends `SIGTERM`, waits up to `graceMs`
|
|
292
|
+
(default 5 s), then sends `SIGKILL`, and resolves with the exit. It works
|
|
293
|
+
with any handle, fakes included. With `processGroup`, it waits for the
|
|
294
|
+
leader only: a group member that outlives the leader gets no `SIGKILL`.
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
import { managedProcessRunner, stopProcess } from '@osdlc/command-seam'
|
|
298
|
+
|
|
299
|
+
const server = managedProcessRunner('node').run(['server.js'], { cwd: process.cwd() })
|
|
300
|
+
const exit = await stopProcess(server, { graceMs: 2_000 })
|
|
301
|
+
// null if it exited on SIGTERM, 'SIGTERM' if SIGTERM killed it, 'SIGKILL' if it ignored SIGTERM
|
|
302
|
+
console.log(exit.signal)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
The runner cannot tell you when the process is ready from its output: stdio
|
|
306
|
+
is inherited, not piped. Poll from outside instead: try the port or an HTTP
|
|
307
|
+
endpoint, or wait for a file the process writes, in a loop with a deadline.
|
|
308
|
+
Check `exited` after each try, so a process that died early is reported
|
|
309
|
+
rather than waited on.
|
|
310
|
+
|
|
311
|
+
## Default options
|
|
312
|
+
|
|
313
|
+
`withDefaults(runner, opts)` returns a runner that fills in `opts` on every
|
|
314
|
+
call. Code that runs many commands in one folder binds `cwd` (and often
|
|
315
|
+
`env` or `timeoutMs`) once. It works with every runner type.
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
import { spawnRunner, withDefaults } from '@osdlc/command-seam'
|
|
319
|
+
|
|
320
|
+
const git = withDefaults(spawnRunner('git'), { cwd: process.cwd(), timeoutMs: 30_000 })
|
|
321
|
+
git.run(['status'])
|
|
322
|
+
git.run(['fetch'], { timeoutMs: 120_000 }) // a per-call option wins
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
A per-call option replaces the default of the same name, except `env`: a
|
|
326
|
+
per-call `env` is merged over the bound one, key by key, so a client that
|
|
327
|
+
binds `LC_ALL=C` keeps it when one call adds a variable. A per-call `env`
|
|
328
|
+
value of `undefined` removes a bound variable. A per-call option set to
|
|
329
|
+
`undefined` keeps the default. The defaults are shallow-copied when bound,
|
|
330
|
+
so a bound `env` object is still shared.
|
|
331
|
+
|
|
332
|
+
`runChecked`, `runCheckedAsync`, `runJson` and `runJsonAsync` take the
|
|
333
|
+
runner's own options, so a bound runner needs only what it did not bind. The
|
|
334
|
+
result is also still a runner of the original type, so code that takes a
|
|
335
|
+
`CommandRunner` accepts it, and passes its own full options, which replace
|
|
336
|
+
the bound ones (a per-call `env` is merged, as above).
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { runChecked, runJson, spawnRunner, withDefaults } from '@osdlc/command-seam'
|
|
340
|
+
|
|
341
|
+
const gh = withDefaults(spawnRunner('gh'), { cwd: process.cwd(), env: { GH_PROMPT_DISABLED: '1' } })
|
|
342
|
+
const head = runChecked(gh, ['repo', 'view', '--json', 'name']).stdout
|
|
343
|
+
// This call's env is merged over the bound one: both variables reach gh.
|
|
344
|
+
const user = runJson<{ login: string }>(gh, ['api', 'user'], { env: { GH_HOST: 'github.com' } })
|
|
345
|
+
console.log(head, user.ok && user.value.login)
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## Reading results
|
|
349
|
+
|
|
350
|
+
Small helpers that never throw for a failed command (a programmer error,
|
|
351
|
+
such as an invalid `timeoutMs`, still throws or rejects):
|
|
352
|
+
|
|
353
|
+
- `ran(result)`: the process started and exited 0. Takes any runner's
|
|
354
|
+
result, including `StreamResult` and `ManagedProcessExit`.
|
|
355
|
+
- `failureText(result)`: trimmed stderr, else the trimmed `error`, else
|
|
356
|
+
trimmed stdout, else `exit N`. `failureText(result, { both: true })` joins
|
|
357
|
+
both trimmed streams instead, stderr (or `error`) first, and is empty when
|
|
358
|
+
there is no text.
|
|
359
|
+
- `firstLine(text, fallback?)`: the first non-blank line, trimmed.
|
|
360
|
+
- `stderrOrError(result)`: trimmed stderr, else the trimmed `error`, else
|
|
361
|
+
`''`. Use it where a message reads stderr, so a command that never ran
|
|
362
|
+
still says why.
|
|
363
|
+
- `runJson(runner, argv, opts)` and `runJsonAsync`: run, check the exit code
|
|
364
|
+
(`0` unless `okExitCodes` says otherwise), then `JSON.parse` stdout.
|
|
365
|
+
`parseJsonResult(result)` is the parse step alone.
|
|
366
|
+
|
|
367
|
+
For "the answer, or a fallback when the command failed", read one command's
|
|
368
|
+
result with `ran`: `ran(r) ? r.stdout : null`. When several steps can throw
|
|
369
|
+
(a run of `runChecked` calls, or a helper that calls `runChecked` itself),
|
|
370
|
+
wrap them in `catchFailed` (see Reporting failures) instead of checking each
|
|
371
|
+
result.
|
|
372
|
+
|
|
373
|
+
`runJson` returns `{ ok: true, value, result }` or
|
|
374
|
+
`{ ok: false, reason, message, result }`. `reason` is `'failed'` (the command
|
|
375
|
+
did not run, or exited with a code not in `okExitCodes`), `'unparseable'`
|
|
376
|
+
(stdout is not JSON) or `'invalid'` (the `parse` option threw).
|
|
377
|
+
`tolerateLeadingLog: true` skips log lines printed before the JSON.
|
|
378
|
+
|
|
379
|
+
Some tools print JSON and exit non-zero on purpose: `npm outdated --json`
|
|
380
|
+
exits 1 when anything is outdated. `okExitCodes: [0, 1]` accepts those
|
|
381
|
+
codes. A run that never exited is still `'failed'`. An accepted non-zero exit
|
|
382
|
+
still needs output that parses, and the tool's own errors often use the same
|
|
383
|
+
code (npm prints `{ "error": … }` and exits 1), so pass a `parse` that
|
|
384
|
+
rejects the error output.
|
|
385
|
+
|
|
386
|
+
Without `parse`, the type parameter is not checked at runtime. Pass `parse`,
|
|
387
|
+
such as a schema's `parse` method, when you do not trust the producer. Its
|
|
388
|
+
return type becomes the value's type. When it throws, the outcome is
|
|
389
|
+
`invalid` and `cause` holds what it threw. `parse` must be sync: pass
|
|
390
|
+
`schema.parse`, not `schema.parseAsync`. A `parse` that returns a promise
|
|
391
|
+
gives `invalid` too.
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
import { runJson, spawnRunner } from '@osdlc/command-seam'
|
|
395
|
+
|
|
396
|
+
const gh = spawnRunner('gh')
|
|
397
|
+
const r = runJson<{ login: string }>(gh, ['api', 'user'], { cwd: process.cwd() })
|
|
398
|
+
if (r.ok) console.log(r.value.login)
|
|
399
|
+
else console.error(`${r.reason}: ${r.message}`)
|
|
400
|
+
|
|
401
|
+
// With a check: `parse` throws on a bad shape, and its return type is the value's.
|
|
402
|
+
const login = (v: unknown): string => {
|
|
403
|
+
const name = (v as { login?: unknown } | null)?.login
|
|
404
|
+
if (typeof name !== 'string') throw new Error('no login')
|
|
405
|
+
return name
|
|
406
|
+
}
|
|
407
|
+
const checked = runJson(gh, ['api', 'user'], { cwd: process.cwd(), parse: login })
|
|
408
|
+
if (checked.ok) console.log(checked.value.toUpperCase())
|
|
409
|
+
else if (checked.reason === 'invalid') console.error(checked.message, checked.cause)
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
## Reporting failures
|
|
413
|
+
|
|
414
|
+
`CommandFailed` carries `argv` and the full `result`. Its `message` is
|
|
415
|
+
`command failed (exit N): <argv>`, with argv quoted by `shellJoin`, then the
|
|
416
|
+
stderr, or the runner's `error` when the child wrote no stderr. It never
|
|
417
|
+
includes stdout. Some commands explain a failure on stdout instead, such as a
|
|
418
|
+
git hook that rejects a commit. `e.detail` joins both
|
|
419
|
+
streams, trimmed, and drops empty ones: it is
|
|
420
|
+
`failureText(e.result, { both: true })`. Show `detail` to a person.
|
|
421
|
+
|
|
422
|
+
The message carries the full argv, so it reaches error logs and crash
|
|
423
|
+
reports. Do not pass a secret, such as a token in a URL, as an argument:
|
|
424
|
+
pass it through `env` or `input`.
|
|
425
|
+
|
|
426
|
+
`mapFailed(fn, mapper)` runs `fn` and, if it throws `CommandFailed`, throws
|
|
427
|
+
`mapper(e)` instead. `catchFailed(fn, onFailed)` returns `onFailed(e)`
|
|
428
|
+
instead. Other errors pass through unchanged. `mapFailedAsync` and
|
|
429
|
+
`catchFailedAsync` do the same for async functions, and their mapper or
|
|
430
|
+
handler may be async.
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
import { catchFailed, mapFailed, runChecked, spawnRunner } from '@osdlc/command-seam'
|
|
434
|
+
|
|
435
|
+
class DiffError extends Error {}
|
|
436
|
+
|
|
437
|
+
const git = spawnRunner('git')
|
|
438
|
+
const cwd = process.cwd()
|
|
439
|
+
|
|
440
|
+
const diff = mapFailed(
|
|
441
|
+
() => runChecked(git, ['diff', '-U0'], { cwd }).stdout,
|
|
442
|
+
(e) => new DiffError(`git diff failed: ${e.detail}`),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
// Two steps that can fail: one catchFailed covers both.
|
|
446
|
+
const ahead = catchFailed(
|
|
447
|
+
() => {
|
|
448
|
+
const upstream = runChecked(git, ['rev-parse', '--abbrev-ref', '@{upstream}'], { cwd })
|
|
449
|
+
const range = `${upstream.stdout.trim()}..HEAD`
|
|
450
|
+
return Number(runChecked(git, ['rev-list', '--count', range], { cwd }).stdout)
|
|
451
|
+
},
|
|
452
|
+
() => undefined,
|
|
453
|
+
)
|
|
454
|
+
console.log(diff.length, ahead)
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
## Testing with fakes
|
|
458
|
+
|
|
459
|
+
Write code against the runner interface, and pass a fake in tests. A fake is
|
|
460
|
+
any object with a `run` method.
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
import { asAsync, spawnRunner, type CommandResult, type CommandRunner } from '@osdlc/command-seam'
|
|
464
|
+
|
|
465
|
+
// Code under test: takes a runner, defaults to the real one.
|
|
466
|
+
function currentBranch(cwd: string, git: CommandRunner = spawnRunner('git')): string {
|
|
467
|
+
return git.run(['branch', '--show-current'], { cwd }).stdout.trim()
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// A recording fake.
|
|
471
|
+
const calls: string[][] = []
|
|
472
|
+
const fake: CommandRunner = {
|
|
473
|
+
run(argv): CommandResult {
|
|
474
|
+
calls.push(argv)
|
|
475
|
+
return { exitCode: 0, stdout: 'main\n', stderr: '' }
|
|
476
|
+
},
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
console.log(currentBranch('/repo', fake)) // 'main'
|
|
480
|
+
|
|
481
|
+
// The same fake, for code that takes an AsyncCommandRunner.
|
|
482
|
+
const asyncFake = asAsync(fake)
|
|
483
|
+
console.log((await asyncFake.run(['status'], { cwd: '/repo' })).stdout)
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
A default parameter, or `opts.git ?? spawnRunner('git')` for a runner in an
|
|
487
|
+
options object, is all the injection needs.
|
|
488
|
+
`resolveRunner(binary, injected?)` and `resolveRunnerAsync` do the same as a
|
|
489
|
+
function call: the injected runner when there is one, else the real default.
|
|
490
|
+
`StreamRunner`, `ShellRunner` and `ManagedProcessRunner` are interfaces too,
|
|
491
|
+
so they can be faked the same way. `shellRunner(fake)` runs shell strings
|
|
492
|
+
through a fake `CommandRunner`.
|
|
493
|
+
|
|
494
|
+
### Ready-made fakes
|
|
495
|
+
|
|
496
|
+
`@osdlc/command-seam/testing` has the fakes, in their own entry point so
|
|
497
|
+
production code does not import them. It also exports their types:
|
|
498
|
+
`FakeRunner` (what `fakeRunner` returns), `FakeCall` (one recorded call),
|
|
499
|
+
`FakeProcess` and `FakeProcessOptions`.
|
|
500
|
+
|
|
501
|
+
- `fakeRunner(answers)` returns `{ runner, asyncRunner, calls }`. `runner`
|
|
502
|
+
is a `CommandRunner` that answers from a table keyed by the argv, each
|
|
503
|
+
argument quoted with `shellQuote` and joined with spaces: a plain argument
|
|
504
|
+
stays as it is, and one with a space or a shell character is single-quoted,
|
|
505
|
+
so `['-m', 'a b']` is `-m 'a b'`. Each answer lists only the fields that
|
|
506
|
+
differ from `{ exitCode: 0, stdout: '', stderr: '' }`. `answers` can also be
|
|
507
|
+
a function of `(argv, opts)`. A call with no answer reports a failed spawn,
|
|
508
|
+
as a missing binary would: `neverExited: true`, `reason: 'spawn'`, and
|
|
509
|
+
`fakeRunner: no answer for: <key>` in `error`, with the key you would add to
|
|
510
|
+
the table. Code that reads a failed run as an answer can still go on, so
|
|
511
|
+
assert on `calls`, which records each call's argv and options. `asyncRunner`
|
|
512
|
+
is the same fake as an `AsyncCommandRunner`, for async code; the async
|
|
513
|
+
options are recorded, and `onSpawn` is never called.
|
|
514
|
+
- `fakeNeverExited(reason, fields?)` builds an answer for a run that never
|
|
515
|
+
exited: `exitCode: 1`, `neverExited: true`, `reason`,
|
|
516
|
+
`error: 'fake: <reason>'` (none for `'signal'`), and `signal` where the
|
|
517
|
+
sync runner sets one (`SIGKILL` for `'timeout'` and `'output'`, `SIGTERM`
|
|
518
|
+
for `'signal'`). `fields` override any of them.
|
|
519
|
+
- `fakeProcess(opts?)` returns a `ManagedProcessHandle` that starts nothing.
|
|
520
|
+
`exit(fields?)` settles `exited` with `{ exitCode: 0, signal: null }` and
|
|
521
|
+
your fields. To fake a death by a signal from elsewhere, pass
|
|
522
|
+
`{ exitCode: 1, signal, neverExited: true, reason: 'signal' }`, as
|
|
523
|
+
`managedProcessRunner` reports it. `kill()` records the signal in `signals` and ends the process
|
|
524
|
+
with `reason: 'abort'`. `exitOnKill: false` makes it survive every signal,
|
|
525
|
+
and a list such as `exitOnKill: ['SIGKILL']` makes it die only of those,
|
|
526
|
+
as a process that traps `SIGTERM` would. `runner` is a
|
|
527
|
+
`ManagedProcessRunner` that returns the handle, and `calls` records what it
|
|
528
|
+
was given. `pid` defaults to `4242`.
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
import { runChecked, stopProcess } from '@osdlc/command-seam'
|
|
532
|
+
import { fakeNeverExited, fakeProcess, fakeRunner } from '@osdlc/command-seam/testing'
|
|
533
|
+
|
|
534
|
+
const { runner, calls } = fakeRunner({
|
|
535
|
+
'rev-parse --short HEAD': { stdout: 'abc\n' },
|
|
536
|
+
"commit -m 'Fix login'": {},
|
|
537
|
+
'fetch origin': fakeNeverExited('timeout'),
|
|
538
|
+
})
|
|
539
|
+
console.log(runChecked(runner, ['rev-parse', '--short', 'HEAD'], { cwd: '/repo' }).stdout)
|
|
540
|
+
console.log(calls[0]?.opts.cwd) // '/repo'
|
|
541
|
+
|
|
542
|
+
const stubborn = fakeProcess({ exitOnKill: ['SIGKILL'] })
|
|
543
|
+
console.log((await stopProcess(stubborn, { graceMs: 10 })).signal) // 'SIGKILL'
|
|
544
|
+
|
|
545
|
+
const proc = fakeProcess()
|
|
546
|
+
const server = proc.runner.run(['server.js'], { cwd: '/app' })
|
|
547
|
+
proc.exit({ exitCode: 0 })
|
|
548
|
+
console.log(await server.exited) // { exitCode: 0, signal: null }
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
## Timeouts and cancellation
|
|
552
|
+
|
|
553
|
+
- `timeoutMs` follows one rule on every runner that has it. `undefined` and
|
|
554
|
+
`Infinity` mean no cap. A fraction rounds up, so `1.2` is `2`. A value
|
|
555
|
+
above 2^31 - 1 (about 24.8 days), the largest timer delay, is clamped to
|
|
556
|
+
it. `0`, a negative value and `NaN` are a caller error: `RangeError`,
|
|
557
|
+
thrown by the sync runners and rejected by the async ones. A value that is
|
|
558
|
+
not a number is a `TypeError`, the same way. Leave `timeoutMs` out, or
|
|
559
|
+
pass `Infinity`, for no cap.
|
|
560
|
+
- `timeoutMs` kills the child with `SIGKILL`, not `SIGTERM`, so a child that
|
|
561
|
+
ignores `SIGTERM` still stops. The child gets no chance to clean up. For a
|
|
562
|
+
graceful stop on the async runners, pass `signal: AbortSignal.timeout(ms)`
|
|
563
|
+
instead: an abort sends `SIGTERM` first.
|
|
564
|
+
- The async runner settles 250 ms after the child exits, even if a background
|
|
565
|
+
process the child started still holds the output pipes. The pipes are then
|
|
566
|
+
closed: that process loses any later output and may die of `SIGPIPE` on its
|
|
567
|
+
next write. With `processGroup: true` the group is killed at that point.
|
|
568
|
+
Redirect a daemon's output, or use `managedProcessRunner`, to keep it.
|
|
569
|
+
- The async runner resolves at the deadline. It does not wait for the output
|
|
570
|
+
pipes to close, which a surviving grandchild could keep open.
|
|
571
|
+
- The result of a timeout has `neverExited: true`, `reason: 'timeout'` and
|
|
572
|
+
the output captured so far. The sync runner also sets `signal: 'SIGKILL'`.
|
|
573
|
+
Both runners set `error`: the async runner's is `timed out after <N>ms`,
|
|
574
|
+
the sync runner's is spawnSync's `ETIMEDOUT` message.
|
|
575
|
+
- The sync runners (`spawnRunner`, `runShell`, `shellRunner`) and
|
|
576
|
+
`streamRunner` kill only the direct child on timeout. Processes it started
|
|
577
|
+
keep running, and these runners have no option to change that. The async
|
|
578
|
+
runners (`spawnRunnerAsync`, `runShellAsync`, `shellRunnerAsync`) take
|
|
579
|
+
`processGroup: true` to kill the child's whole process group on timeout or
|
|
580
|
+
abort. `managedProcessRunner` takes it too, so `kill()` signals the group.
|
|
581
|
+
- A process group does not receive the terminal's Ctrl-C. To stop it on
|
|
582
|
+
Ctrl-C, call `installGroupSignalForwarding()` once at startup, or listen
|
|
583
|
+
for `SIGINT` and abort through `signal`.
|
|
584
|
+
- `signal` (async only) sends `SIGTERM`, then `SIGKILL` 1 s later if the child is still running. The
|
|
585
|
+
`SIGTERM` lets a child such as git remove its lock files. The promise resolves at once, with
|
|
586
|
+
`neverExited: true`, `reason: 'abort'` and `The operation was aborted` in `error`. An abort that
|
|
587
|
+
lands after the child exited keeps the child's exit code. While a child ignores `SIGTERM`, the
|
|
588
|
+
pending `SIGKILL` keeps the host alive for up to 1 s.
|
|
589
|
+
|
|
590
|
+
## Behaviour
|
|
591
|
+
|
|
592
|
+
- **`run` never throws for a failed command; programmer errors throw.** A
|
|
593
|
+
missing binary, a timeout or an abort becomes a result with a non-zero
|
|
594
|
+
`exitCode`, `neverExited: true` and a `reason`. An invalid `timeoutMs`
|
|
595
|
+
(`RangeError`, or `TypeError` for a non-number) or a NUL byte in argv or
|
|
596
|
+
env throws from a sync runner and rejects from an async one.
|
|
597
|
+
- **Capture runners always report something.** When the runner has its own
|
|
598
|
+
message (spawn failure, timeout, abort, output cap), it is in `error`.
|
|
599
|
+
`stderr` holds only what the child wrote.
|
|
600
|
+
- **The streaming and managed-process runners capture no output.** On a
|
|
601
|
+
failed spawn they return `exitCode: 1`, `neverExited: true`,
|
|
602
|
+
`reason: 'spawn'` and the message in `error`. The streaming runner also
|
|
603
|
+
prints the error to the parent's stderr, and prints a line when a signal or
|
|
604
|
+
the timeout ends the child.
|
|
605
|
+
- **`cwd` is required** on every call, on every runner. There is no
|
|
606
|
+
default. Bind it once with `withDefaults`. A `cwd` that is missing or not a
|
|
607
|
+
directory is a failed spawn (`reason: 'spawn'`) whose message names the
|
|
608
|
+
`cwd`.
|
|
609
|
+
- **`argv` is never parsed by a shell.** Spaces, quotes and `$()` in an
|
|
610
|
+
argument reach the binary unchanged. Only `runShell` runs a shell string.
|
|
611
|
+
- **`env` merges over `process.env`**, read at call time. A value of
|
|
612
|
+
`undefined` removes that variable from the child's environment.
|
|
613
|
+
- **Early stdin close.** If the child closes stdin before `input` is fully
|
|
614
|
+
written, the result keeps the child's exit code. The async runner and the
|
|
615
|
+
sync runner on Node put the write error in `error`. The sync runner under
|
|
616
|
+
Bun does not report it.
|
|
617
|
+
- **`error` can come with a real exit code.** After a failed stdin write, or
|
|
618
|
+
a child that exits right at the output cap under Bun, `exitCode` is the
|
|
619
|
+
child's own, `reason` is absent and only `error` says what went wrong.
|
|
620
|
+
- **Output is text.** Captured output is decoded as UTF-8. The sync pipe
|
|
621
|
+
path caps each stream at 256 MB and usually kills a child that passes it,
|
|
622
|
+
with `reason: 'output'`. Under Bun, a child that exits right at the cap
|
|
623
|
+
keeps its exit code and gets only `error`. The `bun test` capture path and
|
|
624
|
+
the async runner have no cap.
|
|
625
|
+
|
|
626
|
+
## Known issue: `bun test`
|
|
627
|
+
|
|
628
|
+
Under `bun test`, once a large module graph is loaded, Bun can hand child
|
|
629
|
+
processes stdio that the parent reads back as empty. The upstream report is
|
|
630
|
+
[oven-sh/bun#24690](https://github.com/oven-sh/bun/issues/24690). To work
|
|
631
|
+
around this, `spawnRunner` checks for the Bun runtime with `NODE_ENV=test`.
|
|
632
|
+
When both are true, it runs the command through `/bin/sh` with stdin, stdout
|
|
633
|
+
and stderr redirected to temp files, then reads the files back. Arguments are
|
|
634
|
+
still not parsed by the shell.
|
|
635
|
+
|
|
636
|
+
This applies only under Bun. Jest and Vitest on Node use the normal pipe
|
|
637
|
+
path. The async runner does not have this workaround. If the test process is
|
|
638
|
+
killed during a run, its temp dir (`command-seam-*` in the OS temp dir) is
|
|
639
|
+
left behind.
|
|
640
|
+
|
|
641
|
+
## Stability
|
|
642
|
+
|
|
643
|
+
The package is at 0.x. Each export is stable or experimental.
|
|
644
|
+
|
|
645
|
+
- **Stable** exports are the runner contract: the runners, their options and
|
|
646
|
+
results, `CommandFailed`, and the helpers around them. The runners and
|
|
647
|
+
helpers are in use by other packages; a type that names their options or
|
|
648
|
+
results is stable with them. A breaking change to one is listed under
|
|
649
|
+
"Breaking" in the CHANGELOG.
|
|
650
|
+
- **Experimental** exports are newer or have no user yet. Any release may
|
|
651
|
+
change or remove one. Each has an `@experimental` tag in its TSDoc.
|
|
652
|
+
|
|
653
|
+
The experimental exports:
|
|
654
|
+
|
|
655
|
+
- `shellRunnerAsync`, `AsyncShellRunner`
|
|
656
|
+
- `catchFailedAsync`, `mapFailedAsync`
|
|
657
|
+
- `firstLine`, `stderrOrError`
|
|
658
|
+
- `runJson`, `runJsonAsync`, `parseJsonResult`, `JsonRun`, `RunJsonOptions`
|
|
659
|
+
- `withDefaults`, `DefaultedRunner`
|
|
660
|
+
- `stopProcess`
|
|
661
|
+
- `installGroupSignalForwarding`, `GroupSignalForwardingOptions`
|
|
662
|
+
- `liveProcessGroups`, `forwardedSignal`, `forwardedExitCode`
|
|
663
|
+
- `fakeRunner`, `fakeNeverExited`, `fakeProcess` (from `@osdlc/command-seam/testing`)
|
|
664
|
+
- `FakeRunner`, `FakeCall`, `FakeProcess`, `FakeProcessOptions` (from `/testing`)
|
|
665
|
+
|
|
666
|
+
Every other export is stable. 1.0 comes once the experimental exports have
|
|
667
|
+
gone 60 to 90 days without a breaking change, the open API-change questions
|
|
668
|
+
are settled, and Windows support has landed, if that is feasible.
|
|
669
|
+
|
|
670
|
+
## API
|
|
671
|
+
|
|
672
|
+
| Export | Kind | Summary |
|
|
673
|
+
| --------------------------------------------------------------- | --------- | -------------------------------------------------------------- |
|
|
674
|
+
| `spawnRunner` | function | Default sync capture runner (`spawnSync`) |
|
|
675
|
+
| `spawnRunnerAsync` | function | Default async capture runner (`spawn`) |
|
|
676
|
+
| `resolveRunner`, `resolveRunnerAsync` | function | An injected runner, else the default |
|
|
677
|
+
| `runChecked`, `runCheckedAsync` | function | Run and throw `CommandFailed` on a non-zero exit |
|
|
678
|
+
| `mapFailed`, `mapFailedAsync` | function | Rethrow a `CommandFailed` as another error |
|
|
679
|
+
| `catchFailed`, `catchFailedAsync` | function | Turn a `CommandFailed` into a value |
|
|
680
|
+
| `asAsync` | function | Wrap a sync runner as an async one |
|
|
681
|
+
| `CommandFailed` | class | Error with `argv`, `result` and `detail`; `message` shows argv |
|
|
682
|
+
| `CommandRunner`, `AsyncCommandRunner` | interface | Capture runner contracts |
|
|
683
|
+
| `CommandResult` | interface | `exitCode`, `stdout`, `stderr`, `neverExited`, `reason`, `signal`, `error` |
|
|
684
|
+
| `NeverExitedReason` | type | `'spawn' \| 'timeout' \| 'abort' \| 'signal' \| 'output'` |
|
|
685
|
+
| `CommandRunOptions`, `AsyncCommandRunOptions` | interface | `cwd`, `timeoutMs`, `input`, `env`; async adds `signal`, `onSpawn`, `processGroup` |
|
|
686
|
+
| `EnvOverrides` | type | `Record<string, string \| undefined>` |
|
|
687
|
+
| `ran`, `failureText`, `firstLine`, `stderrOrError` | function | Read a result |
|
|
688
|
+
| `FailureTextOptions` | interface | `both`: join both streams in `failureText` |
|
|
689
|
+
| `runJson`, `runJsonAsync`, `parseJsonResult` | function | Run and parse stdout as JSON |
|
|
690
|
+
| `JsonRun`, `RunJsonOptions` | type | `runJson` result and options |
|
|
691
|
+
| `runShell`, `runShellAsync` | function | Run a shell string with `sh -c --` |
|
|
692
|
+
| `shellRunner`, `shellRunnerAsync` | function | Shell runner over a given `CommandRunner` |
|
|
693
|
+
| `ShellRunner`, `AsyncShellRunner` | interface | Shell runner contracts |
|
|
694
|
+
| `ShellRunOptions`, `AsyncShellRunOptions`, `ShellPositionals` | interface | Capture options plus `scriptName` (`$0`) and `args` (`$1`…) |
|
|
695
|
+
| `shellQuote`, `shellJoin` | function | POSIX quoting for one word or a command line |
|
|
696
|
+
| `streamRunner` | function | Default streaming runner (inherited stdio) |
|
|
697
|
+
| `StreamRunner`, `StreamRunOptions`, `StreamResult` | type | Streaming runner contract; the result adds `reason`, `signal`, `error` |
|
|
698
|
+
| `managedProcessRunner` | function | Default managed-process runner |
|
|
699
|
+
| `stopProcess` | function | `SIGTERM`, then `SIGKILL` after a grace period |
|
|
700
|
+
| `ManagedProcessRunner`, `ManagedProcessRunOptions` | interface | Managed-process runner contract and options (`cwd` required) |
|
|
701
|
+
| `ManagedProcessHandle`, `ManagedProcessExit` | interface | `pid`, `kill()`, `exited`; how the process ended, with `reason` and `error` |
|
|
702
|
+
| `installGroupSignalForwarding`, `liveProcessGroups`, `forwardedSignal`, `forwardedExitCode` | function | Kill live process groups on `SIGINT`/`SIGTERM`/`SIGHUP`; list them; the signal forwarded and its exit code |
|
|
703
|
+
| `GroupSignalForwardingOptions` | interface | `signals`, `graceMs`, `exit` |
|
|
704
|
+
| `withDefaults` | function | Bind default options to any runner |
|
|
705
|
+
| `DefaultedRunner` | interface | The runner `withDefaults` returns |
|
|
706
|
+
| `fakeRunner` | function | `/testing`: a `CommandRunner` that answers from a table |
|
|
707
|
+
| `fakeNeverExited` | function | `/testing`: a fake answer for a run that never exited |
|
|
708
|
+
| `fakeProcess` | function | `/testing`: a `ManagedProcessHandle` the test ends by hand |
|
|
709
|
+
| `FakeRunner`, `FakeCall`, `FakeProcess`, `FakeProcessOptions` | interface | `/testing`: the fakes' return, call and option types |
|
|
710
|
+
|
|
711
|
+
`/testing` marks an export of `@osdlc/command-seam/testing`; every other
|
|
712
|
+
export is from `@osdlc/command-seam`. Every export has TSDoc in the source.
|
|
713
|
+
Runnable examples for each export are in the
|
|
714
|
+
[`examples` directory](https://github.com/osdlc/osdlc/tree/main/packages/ts/command-seam/examples).
|
|
715
|
+
|
|
716
|
+
## Alternatives
|
|
717
|
+
|
|
718
|
+
- [`execa`](https://www.npmjs.com/package/execa): full-featured process
|
|
719
|
+
execution with strong Windows support and rich streaming. Choose it for
|
|
720
|
+
most general use.
|
|
721
|
+
- [`tinyexec`](https://www.npmjs.com/package/tinyexec) and
|
|
722
|
+
[`nano-spawn`](https://www.npmjs.com/package/nano-spawn): small,
|
|
723
|
+
dependency-free wrappers over `spawn`. tinyexec also has a sync API.
|
|
724
|
+
- [`zx`](https://www.npmjs.com/package/zx): shell scripting in JavaScript.
|
|
725
|
+
- [`cross-spawn`](https://www.npmjs.com/package/cross-spawn): a drop-in
|
|
726
|
+
`spawn` that handles Windows quirks.
|
|
727
|
+
- `node:child_process`: no dependency at all.
|
|
728
|
+
|
|
729
|
+
Choose this package when you want the runner as an injected dependency, a
|
|
730
|
+
timeout that kills with `SIGKILL`, and one result shape for every failure, on
|
|
731
|
+
Linux or macOS.
|
|
732
|
+
|
|
733
|
+
## Contributing
|
|
734
|
+
|
|
735
|
+
See
|
|
736
|
+
[CONTRIBUTING.md](https://github.com/osdlc/osdlc/blob/main/packages/ts/command-seam/CONTRIBUTING.md).
|
|
737
|
+
Report security issues as described in
|
|
738
|
+
[SECURITY.md](https://github.com/osdlc/osdlc/blob/main/packages/ts/command-seam/SECURITY.md).
|
|
739
|
+
|
|
740
|
+
## License
|
|
741
|
+
|
|
742
|
+
[MIT](https://github.com/osdlc/osdlc/blob/main/packages/ts/command-seam/LICENSE)
|