@osdlc/command-seam 0.0.0-stage → 0.4.0

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/CONTRIBUTING.md +61 -0
  3. package/LICENSE +21 -0
  4. package/README.md +741 -2
  5. package/SECURITY.md +23 -0
  6. package/async.ts +466 -0
  7. package/defaults.ts +83 -0
  8. package/dist/async.d.ts +146 -0
  9. package/dist/async.d.ts.map +1 -0
  10. package/dist/async.js +368 -0
  11. package/dist/defaults.d.ts +52 -0
  12. package/dist/defaults.d.ts.map +1 -0
  13. package/dist/defaults.js +51 -0
  14. package/dist/groups.d.ts +155 -0
  15. package/dist/groups.d.ts.map +1 -0
  16. package/dist/groups.js +331 -0
  17. package/dist/index.d.ts +30 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +29 -0
  20. package/dist/process.d.ts +146 -0
  21. package/dist/process.d.ts.map +1 -0
  22. package/dist/process.js +175 -0
  23. package/dist/process.test.d.ts +1 -0
  24. package/dist/process.test.d.ts.map +1 -0
  25. package/dist/process.test.js +1 -0
  26. package/dist/quote.d.ts +18 -0
  27. package/dist/quote.d.ts.map +1 -0
  28. package/dist/quote.js +28 -0
  29. package/dist/result.d.ts +211 -0
  30. package/dist/result.d.ts.map +1 -0
  31. package/dist/result.js +219 -0
  32. package/dist/shell.d.ts +84 -0
  33. package/dist/shell.d.ts.map +1 -0
  34. package/dist/shell.js +83 -0
  35. package/dist/stream.d.ts +90 -0
  36. package/dist/stream.d.ts.map +1 -0
  37. package/dist/stream.js +73 -0
  38. package/dist/sync.d.ts +311 -0
  39. package/dist/sync.d.ts.map +1 -0
  40. package/dist/sync.js +406 -0
  41. package/dist/testing.d.ts +158 -0
  42. package/dist/testing.d.ts.map +1 -0
  43. package/dist/testing.js +173 -0
  44. package/groups.ts +360 -0
  45. package/index.ts +98 -0
  46. package/package.json +94 -3
  47. package/process.ts +270 -0
  48. package/quote.ts +31 -0
  49. package/result.ts +403 -0
  50. package/shell.ts +131 -0
  51. package/smoke.mjs +61 -0
  52. package/stream.ts +134 -0
  53. package/sync.ts +604 -0
  54. package/testing.ts +263 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * `@osdlc/command-seam/testing`: fakes for tests, kept out of the main
3
+ * entry so production code never imports them.
4
+ *
5
+ * - `fakeRunner` answers capture-runner calls from a table and records them.
6
+ * - `fakeNeverExited` builds the result of a run that never exited.
7
+ * - `fakeProcess` is a managed-process handle the test ends by hand.
8
+ */
9
+ import { signalName, } from "./process.js";
10
+ import { asAsync } from "./async.js";
11
+ import { shellQuote } from "./quote.js";
12
+ /**
13
+ * A fake {@link CommandRunner} that answers from a table and records every
14
+ * call.
15
+ *
16
+ * `answers` maps an argv to the result fields that differ from a clean
17
+ * empty run: `{ exitCode: 0, stdout: '', stderr: '' }`. The key is each
18
+ * argument quoted with `shellQuote`, joined with single spaces: a plain
19
+ * argument stays as it is, and one with a space or a shell character is
20
+ * single-quoted, so `['-m', 'a b']` is `-m 'a b'` and cannot match
21
+ * `['-m', 'a', 'b']`. It can also be a function of the call; return
22
+ * `undefined` for no answer.
23
+ *
24
+ * A call with no answer reports a failed spawn, as a missing binary would:
25
+ * `exitCode: 1`, `neverExited: true`, `reason: 'spawn'`, and
26
+ * `fakeRunner: no answer for: <key>` in `error`, where `<key>` is the table
27
+ * key that would answer it. So `ran()` is false and `runChecked` throws.
28
+ * Code that reads a failed run as an answer may still go on, so assert on
29
+ * `calls` to be sure every call was expected.
30
+ *
31
+ * Through `asyncRunner`, `signal` and `processGroup` are recorded but do
32
+ * nothing, and `onSpawn` is never called.
33
+ *
34
+ * @param answers - Results by argv, or a function that picks one.
35
+ * @returns The runner, the same fake as an async runner, and the list of
36
+ * calls they received.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const { runner, calls } = fakeRunner({ 'rev-parse --short HEAD': { stdout: 'abc\n' } })
41
+ * runner.run(['rev-parse', '--short', 'HEAD'], { cwd: '/repo' }).stdout // 'abc\n'
42
+ * calls[0]?.opts.cwd // '/repo'
43
+ * ```
44
+ *
45
+ * @experimental
46
+ */
47
+ export function fakeRunner(answers) {
48
+ const calls = [];
49
+ const lookup = typeof answers === 'function'
50
+ ? answers
51
+ : (argv) => {
52
+ const key = keyOf(argv);
53
+ return Object.hasOwn(answers, key) ? answers[key] : undefined;
54
+ };
55
+ const runner = {
56
+ run(argv, opts) {
57
+ calls.push({ argv: [...argv], opts: { ...opts } });
58
+ const answer = lookup(argv, opts);
59
+ if (answer !== undefined)
60
+ return { exitCode: 0, stdout: '', stderr: '', ...answer };
61
+ const error = `fakeRunner: no answer for: ${keyOf(argv)}`;
62
+ return { exitCode: 1, stdout: '', stderr: '', neverExited: true, reason: 'spawn', error };
63
+ },
64
+ };
65
+ return { runner, asyncRunner: asAsync(runner), calls };
66
+ }
67
+ /**
68
+ * A `fakeRunner` table key. Not `shellJoin`: argv here has no binary name, so
69
+ * a first argument with `=` (such as `--git-dir=/r`) is not a command name
70
+ * and needs no quotes.
71
+ */
72
+ function keyOf(argv) {
73
+ return argv.map(shellQuote).join(' ');
74
+ }
75
+ /**
76
+ * A fake answer for a run that never exited, with the fields the real
77
+ * runners set together for `reason`: `exitCode: 1`, `neverExited: true`,
78
+ * `reason`, `error: 'fake: <reason>'`, and `signal` where the sync runner
79
+ * sets one (`SIGKILL` for `timeout` and `output`, `SIGTERM` for `signal`).
80
+ * A death by a signal from elsewhere has no `error`, as on the real runners.
81
+ * `fields` override any of these, such as the `signal` or the `error`.
82
+ *
83
+ * @param reason - Why the run never exited.
84
+ * @param fields - Fields to set over the defaults.
85
+ * @returns An answer for `fakeRunner`.
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * const { runner } = fakeRunner({ 'fetch origin': fakeNeverExited('timeout') })
90
+ * runner.run(['fetch', 'origin'], { cwd: '/repo' }).reason // 'timeout'
91
+ * ```
92
+ *
93
+ * @experimental
94
+ */
95
+ export function fakeNeverExited(reason, fields = {}) {
96
+ const signal = NEVER_EXITED_SIGNAL[reason];
97
+ return {
98
+ exitCode: 1,
99
+ neverExited: true,
100
+ reason,
101
+ ...(signal === undefined ? {} : { signal }),
102
+ ...(reason === 'signal' ? {} : { error: `fake: ${reason}` }),
103
+ ...fields,
104
+ };
105
+ }
106
+ /** The signal the sync runner reports for each reason, where it reports one. */
107
+ const NEVER_EXITED_SIGNAL = {
108
+ spawn: undefined,
109
+ timeout: 'SIGKILL',
110
+ abort: undefined,
111
+ signal: 'SIGTERM',
112
+ output: 'SIGKILL',
113
+ };
114
+ /**
115
+ * A fake {@link ManagedProcessHandle}: it starts nothing, and `exited`
116
+ * settles when the test calls `exit()`, or when the code under test calls
117
+ * `kill()` (see `exitOnKill`).
118
+ *
119
+ * @param opts - The pid, and which signals sent through `kill()` end the process.
120
+ * @returns The handle, with `exit()`, the signals sent and a runner that
121
+ * serves it.
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * const proc = fakeProcess()
126
+ * const server = proc.runner.run(['server.js'], { cwd: '.' })
127
+ * proc.exit({ exitCode: 0 })
128
+ * await server.exited // { exitCode: 0, signal: null }
129
+ * ```
130
+ *
131
+ * @experimental
132
+ */
133
+ export function fakeProcess(opts = {}) {
134
+ const pid = opts.pid ?? 4242;
135
+ const exitOnKill = opts.exitOnKill ?? true;
136
+ let settle = () => { };
137
+ let settled = false;
138
+ const exited = new Promise((resolve) => (settle = resolve));
139
+ const exit = (result = {}) => {
140
+ if (settled)
141
+ return;
142
+ settled = true;
143
+ settle({ exitCode: 0, signal: null, ...result });
144
+ };
145
+ const signals = [];
146
+ const calls = [];
147
+ const proc = {
148
+ pid,
149
+ exited,
150
+ kill(signal = 'SIGTERM') {
151
+ signals.push(signal);
152
+ const name = typeof signal === 'number' ? signalName(signal) : signal;
153
+ // Signal 0 (a probe) and unknown numbers end nothing.
154
+ if (name === undefined)
155
+ return;
156
+ if (exitOnKill === false)
157
+ return;
158
+ if (exitOnKill !== true && !exitOnKill.includes(name))
159
+ return;
160
+ exit({ exitCode: 1, signal: name, neverExited: true, reason: 'abort' });
161
+ },
162
+ exit,
163
+ signals,
164
+ calls,
165
+ runner: {
166
+ run(argv, runOpts) {
167
+ calls.push({ argv: [...argv], opts: { ...runOpts } });
168
+ return proc;
169
+ },
170
+ },
171
+ };
172
+ return proc;
173
+ }
package/groups.ts ADDED
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Live process groups, and forwarding of termination signals to them.
3
+ *
4
+ * A child started with `processGroup: true` leads its own group and no longer
5
+ * receives the terminal's Ctrl-C. When the host is then killed by `SIGINT`,
6
+ * `SIGTERM` or `SIGHUP`, the child and everything it started would be orphaned
7
+ * (ppid 1). The runners register each such group here, and
8
+ * {@link installGroupSignalForwarding} makes the host kill them when it is
9
+ * signalled. A group stays registered until it is empty. Once its run has
10
+ * settled, the host's normal exit leaves what is left of it running, such as a
11
+ * daemon; a forwarded signal still kills it.
12
+ *
13
+ * Once a signal has been forwarded and this module owns the exit, the host is
14
+ * stopping: the runners refuse to start a new group, and
15
+ * {@link forwardedSignal} tells the host's own code to stop.
16
+ *
17
+ * The signal listeners exist only while a group is starting or registered. A
18
+ * JS listener replaces the default "terminate" action and runs only when the
19
+ * event loop turns, so keeping one armed would make a host blocked in a sync
20
+ * spawn ignore a signal it would otherwise die from.
21
+ */
22
+
23
+ import { constants } from 'node:os'
24
+
25
+ const osSignals = constants.signals as Record<string, number>
26
+
27
+ /** Live group leaders, in start order, and whether each one's run has settled. */
28
+ const live = new Map<number, { settled: boolean }>()
29
+ /** Group spawns in progress: the listeners are armed before their pid is known. */
30
+ let spawning = 0
31
+
32
+ /**
33
+ * Options for {@link installGroupSignalForwarding}.
34
+ *
35
+ * @experimental
36
+ */
37
+ export interface GroupSignalForwardingOptions {
38
+ /** Signals to forward. Default `SIGINT`, `SIGTERM` and `SIGHUP`. */
39
+ signals?: readonly NodeJS.Signals[] | undefined
40
+ /**
41
+ * How long the groups have between `SIGTERM` and `SIGKILL`, in ms. Default
42
+ * 1000: long enough for git to remove its lock files.
43
+ */
44
+ graceMs?: number | undefined
45
+ /**
46
+ * Called to end the process, with `128 + signal number`. Default: set
47
+ * `process.exitCode` and let the event loop drain, so piped stdout is not
48
+ * truncated, then `process.exit` after 500 ms if handles still hold the loop.
49
+ * Tests replace it.
50
+ */
51
+ exit?: ((code: number) => void) | undefined
52
+ }
53
+
54
+ const DEFAULT_SIGNALS: readonly NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP']
55
+ const POLL_MS = 25
56
+
57
+ interface Config {
58
+ signals: readonly NodeJS.Signals[]
59
+ graceMs: number
60
+ exit: (code: number) => void
61
+ }
62
+
63
+ /** Set while forwarding is installed. */
64
+ let config: Config | undefined
65
+ /** Set while the listeners are attached: forwarding installed and a group starting or live. */
66
+ let armed: { off: () => void } | undefined
67
+ /**
68
+ * The first signal forwarded since install whose exit this module owns (the
69
+ * host has no listener of its own for it). Cleared only by uninstall.
70
+ */
71
+ let forwarded: NodeJS.Signals | undefined
72
+ /** Set while a graceful kill is waiting out its grace. */
73
+ let killing = false
74
+ /** A repeat signal already sent `SIGKILL`: the graceful kill stops waiting. */
75
+ let escalated = false
76
+
77
+ /**
78
+ * Register a group leader. The returned function unregisters it; call it once
79
+ * the group is gone so a reused pid is never signalled. Registering the first
80
+ * group arms the signal listeners (when forwarding is installed); removing the
81
+ * last disarms them.
82
+ *
83
+ * @internal
84
+ */
85
+ export function trackGroup(pid: number): () => void {
86
+ live.set(pid, { settled: false })
87
+ arm()
88
+ return () => {
89
+ live.delete(pid)
90
+ disarmIfIdle()
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Start a group leader with `start` and register it. The listeners are armed
96
+ * before `start` runs: the child can print, and the host be signalled, before
97
+ * `spawn` returns, and a signal that comes before the listeners would kill the
98
+ * host by default and orphan the group. `untrack` is `undefined` when the
99
+ * child got no pid.
100
+ *
101
+ * @internal
102
+ */
103
+ export function spawnTrackedGroup<C extends { readonly pid?: number | undefined }>(
104
+ start: () => C,
105
+ ): { child: C; untrack: (() => void) | undefined } {
106
+ spawning += 1
107
+ arm()
108
+ try {
109
+ const child = start()
110
+ return { child, untrack: child.pid === undefined ? undefined : trackGroup(child.pid) }
111
+ } finally {
112
+ spawning -= 1
113
+ disarmIfIdle()
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Mark the run of the group led by `pid` as settled: the host's exit no longer
119
+ * kills what is left of the group. A forwarded signal still does. A runner
120
+ * does not call this for a group it is stopping itself.
121
+ *
122
+ * @internal
123
+ */
124
+ export function settleGroup(pid: number): void {
125
+ const entry = live.get(pid)
126
+ if (entry !== undefined) entry.settled = true
127
+ }
128
+
129
+ /**
130
+ * Why a new group must not start, or `undefined` when it may. The runners call
131
+ * this before they spawn with `processGroup: true`.
132
+ *
133
+ * @internal
134
+ */
135
+ export function groupStartRefusal(): string | undefined {
136
+ if (forwarded === undefined) return undefined
137
+ return `not started: the host got ${forwarded} and is stopping`
138
+ }
139
+
140
+ /**
141
+ * Run `release` once the group led by `pid` has no member: now if it is
142
+ * already empty, else polled on an unref'd timer.
143
+ *
144
+ * Unref'd on purpose: a ref'd poll would keep the host alive for as long as a
145
+ * member the group left behind. On a Bun version where an unref'd timer does
146
+ * not fire (see `async.ts`), the entry stays registered. The listeners then
147
+ * stay armed and a signal still kills that member, which is the safe side.
148
+ *
149
+ * @internal
150
+ */
151
+ export function releaseWhenEmpty(pid: number, release: () => void): void {
152
+ if (!groupExists(pid)) {
153
+ release()
154
+ return
155
+ }
156
+ const timer = setInterval(() => {
157
+ if (groupExists(pid)) return
158
+ clearInterval(timer)
159
+ release()
160
+ }, 250)
161
+ timer.unref()
162
+ }
163
+
164
+ /**
165
+ * True while the group led by `pid` still has a member.
166
+ *
167
+ * @internal
168
+ */
169
+ export function groupExists(pid: number): boolean {
170
+ try {
171
+ process.kill(-pid, 0)
172
+ return true
173
+ } catch {
174
+ return false
175
+ }
176
+ }
177
+
178
+ /** Send `sig` to the group led by `pid`, only while it is registered. */
179
+ function signalGroup(pid: number, sig: NodeJS.Signals): void {
180
+ if (!live.has(pid)) return
181
+ try {
182
+ process.kill(-pid, sig)
183
+ } catch {
184
+ // Already gone.
185
+ }
186
+ }
187
+
188
+ /**
189
+ * The pids of the live process groups started with `processGroup: true`.
190
+ *
191
+ * @experimental
192
+ */
193
+ export function liveProcessGroups(): number[] {
194
+ return [...live.keys()]
195
+ }
196
+
197
+ /**
198
+ * The signal the host was sent and forwarded to its groups, or `undefined`
199
+ * while none has been. Set only when forwarding owns the exit, that is, the
200
+ * host has no listener of its own for that signal. Once set, the host is
201
+ * stopping: a loop that runs one command after another should check it and
202
+ * stop, and the runners refuse to start a new `processGroup` run. The
203
+ * uninstall function clears it.
204
+ *
205
+ * @experimental
206
+ */
207
+ export function forwardedSignal(): NodeJS.Signals | undefined {
208
+ return forwarded
209
+ }
210
+
211
+ /**
212
+ * The exit code for {@link forwardedSignal}: `128 + signal number`, or
213
+ * `undefined` while no signal has been forwarded. A host that sets its own
214
+ * exit code after its work returns keeps this one over it:
215
+ * `process.exitCode = forwardedExitCode() ?? code`.
216
+ *
217
+ * @experimental
218
+ */
219
+ export function forwardedExitCode(): number | undefined {
220
+ return forwarded === undefined ? undefined : 128 + signalNumber(forwarded)
221
+ }
222
+
223
+ function arm(): void {
224
+ if (config === undefined || armed !== undefined || (live.size === 0 && spawning === 0)) return
225
+ const { signals, graceMs, exit } = config
226
+ const onExit = () => {
227
+ // The host is going away: nothing async runs after this, so end every group
228
+ // whose run is still in flight now. A settled run's leftovers stay.
229
+ for (const [pid, { settled }] of live) if (!settled) signalGroup(pid, 'SIGKILL')
230
+ }
231
+ const handlers = signals.map((sig) => {
232
+ const handler = () => {
233
+ const code = 128 + signalNumber(sig)
234
+ if (forwarded !== undefined || killing) {
235
+ // A repeat signal: stop waiting.
236
+ escalated = true
237
+ for (const pid of live.keys()) signalGroup(pid, 'SIGKILL')
238
+ if (!othersListen(sig)) exit(code)
239
+ return
240
+ }
241
+ // A host listener owns the exit and may keep running (a REPL cancelling
242
+ // a job, a daemon reloading on SIGHUP): kill the groups, but do not
243
+ // refuse later runs.
244
+ const othersOwnExit = othersListen(sig)
245
+ if (!othersOwnExit) forwarded = sig
246
+ killing = true
247
+ escalated = false
248
+ void killAll(graceMs).then(() => {
249
+ killing = false
250
+ if (!escalated && !othersOwnExit) exit(code)
251
+ })
252
+ }
253
+ // First in line: Node and Bun drop a host's `process.once` wrapper before
254
+ // calling it, so a handler that ran later would not count that listener.
255
+ process.prependListener(sig, handler)
256
+ return [sig, handler] as const
257
+ })
258
+ process.on('exit', onExit)
259
+ armed = {
260
+ off: () => {
261
+ for (const [sig, handler] of handlers) process.off(sig, handler)
262
+ process.off('exit', onExit)
263
+ },
264
+ }
265
+ }
266
+
267
+ function disarm(): void {
268
+ armed?.off()
269
+ armed = undefined
270
+ }
271
+
272
+ function disarmIfIdle(): void {
273
+ if (live.size === 0 && spawning === 0) disarm()
274
+ }
275
+
276
+ /** Another listener than ours is attached for `sig`: it owns the exit. */
277
+ function othersListen(sig: NodeJS.Signals): boolean {
278
+ return process.listenerCount(sig) > 1
279
+ }
280
+
281
+ /**
282
+ * Enable forwarding of termination signals to live process groups.
283
+ *
284
+ * While at least one group started with `processGroup: true` is live, `SIGINT`,
285
+ * `SIGTERM` and `SIGHUP` send `SIGTERM` to every registered group, then
286
+ * `SIGKILL` to those still registered after `graceMs`, then end the process
287
+ * with `128 + signal number`, the shell convention. A second signal during the
288
+ * grace, or any later one, skips straight to `SIGKILL` and the exit. From the
289
+ * first signal on, {@link forwardedSignal} returns it and the runners refuse
290
+ * to start a new `processGroup` run, until the uninstall function resets
291
+ * that. A `process` `exit` handler, armed with the listeners, also `SIGKILL`s
292
+ * every group whose run is still in flight, so a host that exits first does
293
+ * not leave one behind. A member that a settled run left behind, such as a
294
+ * daemon, outlives the host's exit; a forwarded signal still kills it.
295
+ *
296
+ * The listeners are attached just before the first group starts and removed
297
+ * when the last one goes, so a host with no live group keeps each signal's default
298
+ * action. If the host has its own listener for the signal (`process.once`
299
+ * counts too), the groups still
300
+ * get `SIGTERM` at once and `SIGKILL` after `graceMs` (or on `exit`, if
301
+ * sooner and the run is in flight), but that listener keeps the exit, and later runs are not refused.
302
+ * Calling it again while installed is a no-op returning the same
303
+ * uninstall function. `SIGKILL` of the host cannot be caught and still orphans
304
+ * its groups. POSIX only; a no-op on Windows.
305
+ *
306
+ * @returns A function that removes the forwarding.
307
+ * @experimental
308
+ */
309
+ export function installGroupSignalForwarding(opts: GroupSignalForwardingOptions = {}): () => void {
310
+ if (process.platform === 'win32') return () => {}
311
+ if (config !== undefined) return uninstall
312
+ config = {
313
+ signals: opts.signals ?? DEFAULT_SIGNALS,
314
+ graceMs: opts.graceMs ?? 1000,
315
+ exit: opts.exit ?? defaultExit,
316
+ }
317
+ arm()
318
+ return uninstall
319
+ }
320
+
321
+ function uninstall(): void {
322
+ disarm()
323
+ config = undefined
324
+ forwarded = undefined
325
+ killing = false
326
+ escalated = false
327
+ }
328
+
329
+ /**
330
+ * Set the exit code and let output drain; force the exit after 500 ms. Not
331
+ * unref'd: an unref'd timer does not reliably fire on every Bun version, and
332
+ * holding the loop for 500 ms after a signal is cheap.
333
+ */
334
+ function defaultExit(code: number): void {
335
+ process.exitCode = code
336
+ setTimeout(() => process.exit(code), 500)
337
+ }
338
+
339
+ /**
340
+ * `SIGTERM` every registered group, wait up to `graceMs` for them to go, then
341
+ * `SIGKILL` those still registered. Stops early when `escalated` says a repeat
342
+ * signal already did the kill.
343
+ */
344
+ async function killAll(graceMs: number): Promise<void> {
345
+ // Drop entries whose group is already empty, so a stale pid is never signalled.
346
+ for (const pid of liveProcessGroups()) if (!groupExists(pid)) live.delete(pid)
347
+ for (const pid of liveProcessGroups()) signalGroup(pid, 'SIGTERM')
348
+ const deadline = performance.now() + graceMs
349
+ while (!escalated && performance.now() < deadline) {
350
+ if (!liveProcessGroups().some(groupExists)) return
351
+ await new Promise((res) => setTimeout(res, POLL_MS))
352
+ }
353
+ if (escalated) return
354
+ for (const pid of liveProcessGroups()) if (groupExists(pid)) signalGroup(pid, 'SIGKILL')
355
+ }
356
+
357
+ function signalNumber(sig: NodeJS.Signals): number {
358
+ const n = osSignals[sig]
359
+ return typeof n === 'number' ? n : 15
360
+ }
package/index.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `@osdlc/command-seam`: run external commands through a small interface
3
+ * that tests can replace.
4
+ *
5
+ * A runner is an object with a `run` method. Code that needs a command takes
6
+ * a runner as a parameter. Production passes a real one; tests pass a fake.
7
+ *
8
+ * - `spawnRunner` / `spawnRunnerAsync` capture a finished run's output
9
+ * ({@link CommandResult}).
10
+ * - `runShell` / `shellRunner` run a shell string through `sh -c --`.
11
+ * - `streamRunner` runs with inherited stdio, so output appears live.
12
+ * - `managedProcessRunner` starts a process and returns a handle to it
13
+ * without waiting for it to finish. `stopProcess` stops it with a grace
14
+ * period.
15
+ * - `withDefaults` binds default options to any of them.
16
+ *
17
+ * The capture runners share {@link CommandResult}, {@link CommandRunOptions}
18
+ * and {@link CommandFailed}, so one `instanceof CommandFailed` check covers
19
+ * the sync and async forms.
20
+ */
21
+
22
+ export {
23
+ catchFailed,
24
+ CommandFailed,
25
+ mapFailed,
26
+ resolveRunner,
27
+ runChecked,
28
+ spawnRunner,
29
+ type CommandResult,
30
+ type CommandRunner,
31
+ type CommandRunOptions,
32
+ type EnvOverrides,
33
+ type NeverExitedReason,
34
+ } from './sync.ts'
35
+
36
+ export {
37
+ asAsync,
38
+ catchFailedAsync,
39
+ mapFailedAsync,
40
+ resolveRunnerAsync,
41
+ runCheckedAsync,
42
+ spawnRunnerAsync,
43
+ type AsyncCommandRunner,
44
+ type AsyncCommandRunOptions,
45
+ } from './async.ts'
46
+
47
+ export {
48
+ failureText,
49
+ firstLine,
50
+ parseJsonResult,
51
+ ran,
52
+ runJson,
53
+ runJsonAsync,
54
+ stderrOrError,
55
+ type FailureTextOptions,
56
+ type JsonRun,
57
+ type RunJsonOptions,
58
+ } from './result.ts'
59
+
60
+ export { shellJoin, shellQuote } from './quote.ts'
61
+
62
+ export {
63
+ runShell,
64
+ runShellAsync,
65
+ shellRunner,
66
+ shellRunnerAsync,
67
+ type AsyncShellRunner,
68
+ type AsyncShellRunOptions,
69
+ type ShellRunner,
70
+ type ShellPositionals,
71
+ type ShellRunOptions,
72
+ } from './shell.ts'
73
+
74
+ export {
75
+ streamRunner,
76
+ type StreamRunner,
77
+ type StreamRunOptions,
78
+ type StreamResult,
79
+ } from './stream.ts'
80
+
81
+ export {
82
+ managedProcessRunner,
83
+ stopProcess,
84
+ type ManagedProcessRunner,
85
+ type ManagedProcessHandle,
86
+ type ManagedProcessRunOptions,
87
+ type ManagedProcessExit,
88
+ } from './process.ts'
89
+
90
+ export {
91
+ forwardedExitCode,
92
+ forwardedSignal,
93
+ installGroupSignalForwarding,
94
+ liveProcessGroups,
95
+ type GroupSignalForwardingOptions,
96
+ } from './groups.ts'
97
+
98
+ export { withDefaults, type DefaultedRunner } from './defaults.ts'