@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
package/sync.ts ADDED
@@ -0,0 +1,604 @@
1
+ /**
2
+ * The synchronous capture runner: the {@link CommandRunner} interface and its
3
+ * `spawnSync`-backed default, {@link spawnRunner}. Code that runs commands
4
+ * takes a `CommandRunner`; tests pass a fake. Live output and long-lived
5
+ * processes have their own runners (`stream.ts`, `process.ts`).
6
+ *
7
+ * Two ways to call it, like zod's `safeParse` and `parse`:
8
+ * - `runner.run(...)` never throws for a failed command; the caller reads
9
+ * `exitCode`.
10
+ * - `runChecked(runner, ...)` throws {@link CommandFailed} on a non-zero
11
+ * exit, for callers whose only sensible response to failure is to stop.
12
+ */
13
+
14
+ import { spawnSync } from 'node:child_process'
15
+ import {
16
+ accessSync,
17
+ constants,
18
+ mkdtempSync,
19
+ readFileSync,
20
+ rmSync,
21
+ statSync,
22
+ writeFileSync,
23
+ } from 'node:fs'
24
+ import { tmpdir } from 'node:os'
25
+ import { delimiter, join, resolve } from 'node:path'
26
+
27
+ import { shellJoin } from './quote.ts'
28
+ import { failureText, stderrOrError } from './result.ts'
29
+
30
+ /** Result of running an external command through a `CommandRunner`. */
31
+ export interface CommandResult {
32
+ /** The child's exit code, or `1` when it never reported one (see `neverExited`). */
33
+ exitCode: number
34
+ /** Everything the child wrote to stdout, decoded as UTF-8. */
35
+ stdout: string
36
+ /**
37
+ * Everything the child wrote to stderr, decoded as UTF-8. Only the child's
38
+ * output: the runner's own message, such as a failed spawn or a timeout, is
39
+ * in `error`. `failureText` reads both.
40
+ */
41
+ stderr: string
42
+ /**
43
+ * `true` when the child never reported an exit status: the spawn failed, a
44
+ * timeout or an abort killed it, a signal took it down, or the sync runner
45
+ * killed it at the output cap. `exitCode` is
46
+ * then a stand-in `1`, not the command's answer. Absent otherwise.
47
+ *
48
+ * Check this before reading an exit code as an answer. `grep` exits 1 for
49
+ * "no matches" and `git config --get` exits 1 for an unset key; without
50
+ * this check, a binary missing from `PATH` reads as a confident "no".
51
+ * `reason` says which of those causes it was.
52
+ */
53
+ neverExited?: boolean | undefined
54
+ /**
55
+ * Why the child never reported an exit status. Set whenever `neverExited`
56
+ * is, absent otherwise. See {@link NeverExitedReason}.
57
+ */
58
+ reason?: NeverExitedReason | undefined
59
+ /**
60
+ * The signal that ended the child, when the runner knows it: always for
61
+ * `reason: 'signal'`, and for a sync `'timeout'` or `'output'` (`SIGKILL`).
62
+ * The async runner settles a timeout or abort before the child is gone, so
63
+ * it leaves this unset there.
64
+ */
65
+ signal?: NodeJS.Signals | undefined
66
+ /**
67
+ * The runner's own message, when it has one: a failed spawn, a timeout, an
68
+ * abort, an `onSpawn` that threw, a refused `processGroup` run, the output
69
+ * cap, or a failed write to the child's stdin. It is never copied into
70
+ * `stderr`. Absent otherwise.
71
+ *
72
+ * It can also be set when the child did exit: a failed stdin write (Bun's
73
+ * `spawnSync` does not report one), or, under Bun, a child that exits right
74
+ * at the output cap. `exitCode` is then the child's own and `reason` is
75
+ * absent.
76
+ */
77
+ error?: string | undefined
78
+ }
79
+
80
+ /**
81
+ * Why a child never reported an exit status (`neverExited: true`):
82
+ *
83
+ * - `spawn`: it never started: a missing binary, no permission, a bad `cwd`.
84
+ * Retrying will not help.
85
+ * - `timeout`: `timeoutMs` ran out and the runner killed it.
86
+ * - `abort`: the caller stopped it: an `AbortSignal` (async runner), an
87
+ * `onSpawn` that threw, or `kill()` on a managed process. Or a
88
+ * `processGroup` run was refused because signal forwarding is stopping the
89
+ * host; it never started, and `error` carries the refusal message.
90
+ * - `signal`: a signal from elsewhere ended it.
91
+ * - `output`: the sync runner killed it for passing its 256 MB output cap
92
+ * (`spawnSync`'s `ENOBUFS`). The same run will fail the same way. The async
93
+ * runner has no cap, and the `bun test` capture path writes to files, so
94
+ * neither reports this.
95
+ */
96
+ export type NeverExitedReason = 'spawn' | 'timeout' | 'abort' | 'signal' | 'output'
97
+
98
+ /** Per-call options for a {@link CommandRunner}. */
99
+ export interface CommandRunOptions {
100
+ /** Working directory for the child. Required: the runner has no default. */
101
+ cwd: string
102
+ // Optional fields are `?: T | undefined` rather than `?: T`: options are
103
+ // normally built by spreading a caller's own optionals, and under
104
+ // `exactOptionalPropertyTypes` a present-but-undefined property is not the
105
+ // same as an absent one. Both mean "not given" here.
106
+ /**
107
+ * Kill the child with `SIGKILL` after this many milliseconds. The result
108
+ * then has a non-zero `exitCode`, `neverExited: true` and
109
+ * `reason: 'timeout'`.
110
+ *
111
+ * The same rule on every runner: `undefined` and `Infinity` mean no cap,
112
+ * and a fraction rounds up (`1.2` is `2`). A value above 2^31 - 1 (about
113
+ * 24.8 days), the largest timer delay, is clamped to it. `0`, a negative
114
+ * value and `NaN` are a caller error: `RangeError`, thrown by the sync
115
+ * runners and rejected by the async ones. A value that is not a number is
116
+ * a `TypeError`, the same way.
117
+ *
118
+ * The sync runners (`spawnRunner`, `runShell`, `shellRunner`) and
119
+ * `streamRunner` kill the direct child only. Processes it started keep
120
+ * running. Only the async runners (`spawnRunnerAsync`, `runShellAsync`,
121
+ * `shellRunnerAsync`) can kill the whole process group, with
122
+ * `processGroup`.
123
+ */
124
+ timeoutMs?: number | undefined
125
+ /** Text written to the child's stdin. Stdin is closed after it, or at once when omitted. */
126
+ input?: string | undefined
127
+ /**
128
+ * Variables merged over the parent's environment. An `undefined` value
129
+ * removes that variable from the child's environment.
130
+ */
131
+ env?: EnvOverrides | undefined
132
+ }
133
+
134
+ /** Env vars to set on a child, merged over the parent's; an `undefined` value unsets that var. */
135
+ export type EnvOverrides = Record<string, string | undefined>
136
+
137
+ /**
138
+ * Runs one external binary (`git`, `gh`, …) and captures its output. Code that
139
+ * shells out takes one of these; tests pass a fake.
140
+ *
141
+ * @example
142
+ * ```ts
143
+ * const fake: CommandRunner = {
144
+ * run: (argv) => ({ exitCode: 0, stdout: `ran ${argv.join(' ')}`, stderr: '' }),
145
+ * }
146
+ * ```
147
+ */
148
+ export interface CommandRunner {
149
+ /**
150
+ * Run the binary with `argv` (the arguments, without the binary name) and
151
+ * wait for it to exit. Never throws for a failed command: a non-zero exit,
152
+ * a missing binary or a timeout all come back as a {@link CommandResult}.
153
+ * Programmer errors throw: an invalid `timeoutMs` (see its doc) or a NUL
154
+ * byte in argv or env.
155
+ */
156
+ run(argv: string[], opts: CommandRunOptions): CommandResult
157
+ }
158
+
159
+ /**
160
+ * `{ env }`: `process.env` merged with `opts.env`, minus any key whose
161
+ * override is `undefined`. Always an explicit object built from the current
162
+ * `process.env` at call time — never `undefined`, never an omitted key, and
163
+ * never `process.env` handed through verbatim either.
164
+ *
165
+ * Node's `child_process.spawnSync` inherits live `process.env` whether
166
+ * `env` is omitted, `undefined`, or `process.env` itself. Bun's does not:
167
+ * all three instead take a snapshot — of exactly when is unclear, but
168
+ * empirically it misses anything set on `process.env` after the process
169
+ * started, even as the very first spawn — so a test that points an env var
170
+ * at a fresh temp dir right before spawning would silently see the real,
171
+ * unwritten default instead. Only spreading `process.env`'s current
172
+ * key/values into a brand-new plain object at the call site sidesteps
173
+ * whatever Bun is snapshotting.
174
+ */
175
+ export function mergedEnv(opts: Pick<CommandRunOptions, 'env'>): { env: NodeJS.ProcessEnv } {
176
+ const env: NodeJS.ProcessEnv = { ...process.env, ...opts.env }
177
+ for (const [key, value] of Object.entries(env)) if (value === undefined) delete env[key]
178
+ return { env }
179
+ }
180
+
181
+ /** The largest delay `setTimeout` honours; a longer one fires at once. */
182
+ const MAX_TIMER_MS = 2 ** 31 - 1
183
+
184
+ /**
185
+ * `timeoutMs` as every runner applies it: `undefined` for no cap, else a whole
186
+ * number of milliseconds, at most {@link MAX_TIMER_MS}. See
187
+ * {@link CommandRunOptions.timeoutMs}.
188
+ *
189
+ * @throws `TypeError` for a value that is not a number.
190
+ * @throws `RangeError` for `0`, a negative value or `NaN`.
191
+ */
192
+ export function normalizeTimeout(timeoutMs: number | undefined): number | undefined {
193
+ if (timeoutMs === undefined) return undefined
194
+ return timerMs('timeoutMs', timeoutMs, 'positive')
195
+ }
196
+
197
+ /**
198
+ * A delay option as a timer takes it: `undefined` for `Infinity`, else a whole
199
+ * number of milliseconds, at most {@link MAX_TIMER_MS}. `min` says whether `0`
200
+ * is allowed. `name` is the option's name, for the error message.
201
+ *
202
+ * @throws `TypeError` for a value that is not a number.
203
+ * @throws `RangeError` for a value below `min`, or `NaN`.
204
+ */
205
+ export function timerMs(name: string, ms: number, min: 'positive' | 'zero'): number | undefined {
206
+ if (ms === Infinity) return undefined
207
+ if (typeof ms !== 'number') throw new TypeError(`${name} must be a number, got ${typeof ms}`)
208
+ if (min === 'positive' ? !(ms > 0) : !(ms >= 0)) {
209
+ const allowed = min === 'positive' ? 'a positive number' : '0 or more'
210
+ throw new RangeError(`${name} must be ${allowed} or Infinity, got ${ms}`)
211
+ }
212
+ return Math.min(Math.ceil(ms), MAX_TIMER_MS)
213
+ }
214
+
215
+ /**
216
+ * A spawn error message when `cwd` is missing or not a directory, else
217
+ * `undefined`. Checked before spawning: a spawn reports a missing `cwd` as
218
+ * `spawn <binary> ENOENT`, which reads as a missing binary, and Node's async
219
+ * `spawn` throws for a `cwd` that is a file.
220
+ *
221
+ * @param spawnName - The message prefix: `spawn` or `spawnSync`, then the binary.
222
+ * @throws `TypeError` for a NUL byte in `binary`, `argv` or `opts.env`, as the
223
+ * spawn would, so a bad `cwd` does not hide it.
224
+ */
225
+ export function cwdError(
226
+ spawnName: string,
227
+ binary: string,
228
+ argv: readonly string[],
229
+ opts: { cwd: string; env?: EnvOverrides | undefined },
230
+ ): string | undefined {
231
+ let problem: string
232
+ try {
233
+ if (statSync(opts.cwd).isDirectory()) return undefined
234
+ problem = 'ENOTDIR: cwd %s is not a directory'
235
+ } catch (e) {
236
+ const code = (e as NodeJS.ErrnoException).code
237
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') return undefined // the spawn reports it
238
+ problem = 'ENOENT: cwd %s does not exist'
239
+ }
240
+ assertNoNulByte(binary, argv, opts.env)
241
+ return `${spawnName} ${problem.replace('%s', JSON.stringify(opts.cwd))}`
242
+ }
243
+
244
+ /** Throw the `TypeError` a spawn throws for a NUL byte in its arguments. */
245
+ function assertNoNulByte(
246
+ binary: string,
247
+ argv: readonly string[],
248
+ env: EnvOverrides | undefined,
249
+ ): void {
250
+ const values = [
251
+ ['binary', binary],
252
+ ...argv.map((a, i) => [`argv[${i}]`, a]),
253
+ ...Object.entries(env ?? {}).flatMap(([k, v]) => [
254
+ [`env key ${JSON.stringify(k)}`, k],
255
+ [`env.${k}`, v ?? ''],
256
+ ]),
257
+ ] as const
258
+ for (const [name, value] of values) {
259
+ if (typeof value !== 'string' || !value.includes('\0')) continue
260
+ const e: NodeJS.ErrnoException = new TypeError(`${name} must not contain a NUL byte`)
261
+ e.code = 'ERR_INVALID_ARG_VALUE'
262
+ throw e
263
+ }
264
+ }
265
+
266
+ /** The stand-in result for a child that never started. */
267
+ export function spawnFailure(message: string): CommandResult {
268
+ return {
269
+ exitCode: 1,
270
+ neverExited: true,
271
+ reason: 'spawn',
272
+ stdout: '',
273
+ stderr: '',
274
+ error: message,
275
+ }
276
+ }
277
+
278
+ /**
279
+ * How a `spawnSync` that reported no exit status ended. Shared by the sync
280
+ * capture and streaming runners.
281
+ */
282
+ export function syncStop(proc: { error?: Error | undefined; signal: NodeJS.Signals | null }): {
283
+ reason: Exclude<NeverExitedReason, 'abort'>
284
+ signal?: NodeJS.Signals
285
+ } {
286
+ const signal = proc.signal ?? undefined
287
+ const sig = signal === undefined ? {} : { signal }
288
+ const code = (proc.error as NodeJS.ErrnoException | undefined)?.code
289
+ if (code === 'ETIMEDOUT') return { reason: 'timeout', ...sig }
290
+ // On the cap, `spawnSync` kills the child with `killSignal`.
291
+ if (code === 'ENOBUFS') return { reason: 'output', ...sig }
292
+ if (signal !== undefined) return { reason: 'signal', signal }
293
+ return { reason: 'spawn' }
294
+ }
295
+
296
+ /**
297
+ * Thrown by {@link runChecked} and `runCheckedAsync` on a non-zero exit.
298
+ * Carries the argv and the full {@link CommandResult}, so a caller can report
299
+ * the output without running the command again.
300
+ *
301
+ * `message` is `command failed (exit N): <argv>`, with argv quoted by
302
+ * `shellJoin`, followed on a new line by the trimmed stderr, or the trimmed
303
+ * `error` when stderr is empty. Nothing follows when both are empty. The argv
304
+ * appears in full, so error logs and reports carry it: pass secrets through
305
+ * `env` or `input`, not argv. Use `detail` to show a person
306
+ * why it failed.
307
+ */
308
+ export class CommandFailed extends Error {
309
+ /** The arguments the command ran with, without the binary name. */
310
+ readonly argv: string[]
311
+ /** The full result of the failed run. */
312
+ readonly result: CommandResult
313
+
314
+ /**
315
+ * The failure text to show a human: both streams, trimmed, newline-joined,
316
+ * empty ones dropped, with the runner's `error` standing in for an empty
317
+ * stderr. Empty when there is no text at all. The same as
318
+ * `failureText(e.result, { both: true })`.
319
+ *
320
+ * `message` interpolates stderr only, and several commands write the
321
+ * sentence that explains the failure to stdout instead — a rejecting git
322
+ * hook, `git commit` on a clean tree, `git apply --check`. Read this rather
323
+ * than picking a stream at the call site.
324
+ *
325
+ * Both are joined rather than one preferred, because a rejecting hook is
326
+ * the case where both carry a distinct half: git's own "commit failed" on
327
+ * stderr, the hook's reason on stdout. Preferring either drops the half
328
+ * the reader needs.
329
+ */
330
+ get detail(): string {
331
+ return failureText(this.result, { both: true })
332
+ }
333
+
334
+ /**
335
+ * @param argv - The arguments the command ran with.
336
+ * @param result - The result of the failed run.
337
+ */
338
+ constructor(argv: string[], result: CommandResult) {
339
+ const stderr = stderrOrError(result)
340
+ super(
341
+ `command failed (exit ${result.exitCode}): ${shellJoin(argv)}` +
342
+ (stderr ? `\n${stderr}` : ''),
343
+ )
344
+ this.name = 'CommandFailed'
345
+ this.argv = argv
346
+ this.result = result
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Run a command and throw if it fails. Use it where the only sensible
352
+ * response to failure is to stop, instead of writing
353
+ * `if (r.exitCode !== 0) throw` after each `run`.
354
+ *
355
+ * Takes any runner whose `run` returns a {@link CommandResult}, with that
356
+ * runner's own options: a `withDefaults` runner needs only the options it
357
+ * did not bind.
358
+ *
359
+ * @param runner - Runs the command; pass a fake in tests.
360
+ * @param argv - Arguments, without the binary name.
361
+ * @param opts - Per-call options, as `runner.run` takes them.
362
+ * @returns The result, when `exitCode` is 0.
363
+ * @throws {@link CommandFailed} when `exitCode` is not 0, including a missing
364
+ * binary or a timeout.
365
+ *
366
+ * @example
367
+ * ```ts
368
+ * const git = spawnRunner('git')
369
+ * const head = runChecked(git, ['rev-parse', 'HEAD'], { cwd: '.' }).stdout.trim()
370
+ * ```
371
+ */
372
+ export function runChecked(
373
+ runner: CommandRunner,
374
+ argv: string[],
375
+ opts: CommandRunOptions,
376
+ ): CommandResult
377
+ export function runChecked<P extends unknown[]>(
378
+ runner: { run(argv: string[], ...opts: P): CommandResult },
379
+ argv: string[],
380
+ ...opts: NoInfer<P>
381
+ ): CommandResult
382
+ export function runChecked(
383
+ runner: { run(argv: string[], ...opts: unknown[]): CommandResult },
384
+ argv: string[],
385
+ ...opts: unknown[]
386
+ ): CommandResult {
387
+ const result = runner.run(argv, ...opts)
388
+ if (result.exitCode !== 0) throw new CommandFailed(argv, result)
389
+ return result
390
+ }
391
+
392
+ /**
393
+ * Run `fn`; if it throws {@link CommandFailed}, throw `mapper(e)` instead.
394
+ * Any other error propagates untouched, as does `fn`'s return value. Replaces
395
+ * `catch (e) { if (e instanceof CommandFailed) throw new X(...); throw e }`.
396
+ *
397
+ * `mapper` may have side effects (print the output, say) before returning the
398
+ * error to throw. Async version: `mapFailedAsync`.
399
+ *
400
+ * @param fn - The code to run, usually a `runChecked` call.
401
+ * @param mapper - Turns the failure into the error to throw.
402
+ * @returns What `fn` returns.
403
+ * @throws The error `mapper` returns, when `fn` throws `CommandFailed`.
404
+ */
405
+ export function mapFailed<T>(fn: () => T, mapper: (failed: CommandFailed) => Error): T {
406
+ try {
407
+ return fn()
408
+ } catch (e) {
409
+ if (e instanceof CommandFailed) throw mapper(e)
410
+ throw e
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Run `fn`; if it throws {@link CommandFailed}, return `onFailed(e)` instead.
416
+ * Any other error propagates untouched. Async version: `catchFailedAsync`.
417
+ *
418
+ * @param fn - The code to run, usually a `runChecked` call.
419
+ * @param onFailed - Turns the failure into a value.
420
+ * @returns What `fn` returns, or what `onFailed` returns.
421
+ */
422
+ export function catchFailed<T, R>(fn: () => T, onFailed: (failed: CommandFailed) => R): T | R {
423
+ try {
424
+ return fn()
425
+ } catch (e) {
426
+ if (e instanceof CommandFailed) return onFailed(e)
427
+ throw e
428
+ }
429
+ }
430
+
431
+ // Bun's `bun test` runtime mis-wires child-process stdio once a large module
432
+ // graph is loaded in the test process: the child runs — its own `<in >out`
433
+ // redirects work, filesystem side effects land — but the stdin/stdout/stderr
434
+ // the parent hands it come back empty. Confirmed for sync and async spawns,
435
+ // pipes and parent-opened fds, across every Bun 1.2.x-1.3.x; not reproducible
436
+ // under `bun run`. A suite that shells out after importing a heavy module
437
+ // graph hits it on every run.
438
+ // Only Bun's test runner is affected, so the workaround needs both the Bun
439
+ // runtime and `NODE_ENV=test` (which `bun test` sets). Jest and Vitest also
440
+ // set `NODE_ENV=test`, but they run on Node and keep the pipe path below.
441
+ const CHILD_STDIO_CORRUPTED =
442
+ process.versions['bun'] !== undefined && process.env['NODE_ENV'] === 'test'
443
+
444
+ /**
445
+ * Whether `binary` resolves to an executable file the way a spawn would find
446
+ * it: a name with a `/` is taken relative to `cwd`; a bare name is looked up
447
+ * on the child's `PATH`.
448
+ */
449
+ function findsExecutable(binary: string, cwd: string, path: string | undefined): boolean {
450
+ const isExecutable = (p: string): boolean => {
451
+ try {
452
+ accessSync(p, constants.X_OK)
453
+ return statSync(p).isFile()
454
+ } catch {
455
+ return false
456
+ }
457
+ }
458
+ if (binary === '') return false
459
+ if (binary.includes('/')) return isExecutable(resolve(cwd, binary))
460
+ return (path ?? '/usr/bin:/bin')
461
+ .split(delimiter)
462
+ .some((dir) => isExecutable(resolve(cwd, dir, binary)))
463
+ }
464
+
465
+ /** Folds a finished `spawnSync` into a {@link CommandResult}. */
466
+ function fromSpawnSync(
467
+ proc: { status: number | null; signal: NodeJS.Signals | null; error?: Error | undefined },
468
+ stdout: string,
469
+ stderr: string,
470
+ ): CommandResult {
471
+ return {
472
+ exitCode: proc.status ?? 1,
473
+ // Bun reports a failed spawn with `status` undefined, not null.
474
+ ...(proc.status == null ? { neverExited: true as const, ...syncStop(proc) } : {}),
475
+ stdout,
476
+ stderr,
477
+ // `proc.error` is set for a failed spawn, a timeout, the output cap, or a
478
+ // failed stdin write; only the first three leave `status` null (and under
479
+ // Bun the cap can come with a real status too). A stdin write error can
480
+ // come with a child that did exit, so it is kept either way.
481
+ ...(proc.error ? { error: proc.error.message } : {}),
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Capture output the one way Bun does not corrupt: let the child do its own
487
+ * `<in >out 2>err` redirects to temp files, then read the files back. `binary`
488
+ * and `argv` ride as separate `sh` positional args — never parsed as shell — so
489
+ * arguments containing spaces, metacharacters, or `$()` are inert. `exec`
490
+ * replaces `sh`, so the reported exit status (and the cwd/env/timeout) all
491
+ * belong to the child, matching the pipe path's contract.
492
+ */
493
+ function runViaChildRedirect(
494
+ binary: string,
495
+ argv: string[],
496
+ opts: CommandRunOptions,
497
+ timeoutMs: number | undefined,
498
+ ): CommandResult {
499
+ const { env } = mergedEnv(opts)
500
+ // A binary that cannot be found would make `exec` fail and `sh` exit 127,
501
+ // which reads as the command's own answer. Check first, so the result is
502
+ // the same stand-in the pipe path reports for a failed spawn. This also
503
+ // keeps a binary named like an option (`-a`) from reaching `exec` as one,
504
+ // unless an executable named `-a` is on PATH.
505
+ if (!findsExecutable(binary, opts.cwd, env['PATH'])) {
506
+ return spawnFailure(`spawnSync ${binary} ENOENT`)
507
+ }
508
+ const dir = mkdtempSync(join(tmpdir(), 'command-seam-'))
509
+ try {
510
+ const inFile = join(dir, 'in')
511
+ const outFile = join(dir, 'out')
512
+ const errFile = join(dir, 'err')
513
+ writeFileSync(inFile, opts.input ?? '')
514
+ const proc = spawnSync(
515
+ // An absolute path, so an `env.PATH` override without `/bin` still
516
+ // finds the shell.
517
+ '/bin/sh',
518
+ [
519
+ '-c',
520
+ 'i="$1"; o="$2"; e="$3"; shift 3; exec "$@" <"$i" >"$o" 2>"$e"',
521
+ 'sh', // $0
522
+ inFile,
523
+ outFile,
524
+ errFile,
525
+ binary,
526
+ ...argv,
527
+ ],
528
+ {
529
+ cwd: opts.cwd,
530
+ timeout: timeoutMs,
531
+ // SIGKILL, not spawnSync's default SIGTERM: the contract is a hard
532
+ // cap, and a child that ignores TERM is left to run to completion —
533
+ // spawnSync signals on expiry and then keeps waiting for an exit.
534
+ killSignal: 'SIGKILL',
535
+ env,
536
+ },
537
+ )
538
+ // The redirect files are created by the child's `>` / `2>`, so neither
539
+ // exists when `sh` itself never ran — a spawn that fails with EAGAIN under
540
+ // a saturated process table is the case that matters, and it must not turn
541
+ // into an ENOENT thrown from the capture layer. Missing reads as empty,
542
+ // matching what the pipe path reports for a child that produced nothing.
543
+ const readOrEmpty = (p: string): string => {
544
+ try {
545
+ return readFileSync(p, 'utf-8')
546
+ } catch {
547
+ return ''
548
+ }
549
+ }
550
+ // Output goes to a file, so there is no maxBuffer: it is bounded only by
551
+ // disk.
552
+ return fromSpawnSync(proc, readOrEmpty(outFile), readOrEmpty(errFile))
553
+ } finally {
554
+ rmSync(dir, { recursive: true, force: true })
555
+ }
556
+ }
557
+
558
+ /**
559
+ * The injected runner when there is one, else `spawnRunner(binary)`.
560
+ *
561
+ * @param binary - The binary for the default runner.
562
+ * @param injected - A runner to use instead, such as a test fake.
563
+ */
564
+ export function resolveRunner(binary: string, injected?: CommandRunner | undefined): CommandRunner {
565
+ return injected ?? spawnRunner(binary)
566
+ }
567
+
568
+ /**
569
+ * The default {@link CommandRunner}: runs `binary` with `spawnSync`, so each
570
+ * call blocks until the child exits. On timeout the child gets `SIGKILL`.
571
+ * Captured output is capped at 256 MB per stream. Under `bun test` (the Bun
572
+ * runtime with `NODE_ENV=test`) it captures through `/bin/sh` and temp files
573
+ * instead of pipes (see `runViaChildRedirect`).
574
+ *
575
+ * @param binary - A name looked up on `PATH`, or a path.
576
+ * @returns A runner for that binary.
577
+ *
578
+ * @example
579
+ * ```ts
580
+ * const git = spawnRunner('git')
581
+ * const r = git.run(['status', '--porcelain'], { cwd: '.', timeoutMs: 10_000 })
582
+ * if (r.exitCode === 0) console.log(r.stdout)
583
+ * ```
584
+ */
585
+ export function spawnRunner(binary: string): CommandRunner {
586
+ return {
587
+ run(argv: string[], opts: CommandRunOptions): CommandResult {
588
+ const timeoutMs = normalizeTimeout(opts.timeoutMs)
589
+ const cwdMessage = cwdError(`spawnSync ${binary}`, binary, argv, opts)
590
+ if (cwdMessage !== undefined) return spawnFailure(cwdMessage)
591
+ if (CHILD_STDIO_CORRUPTED) return runViaChildRedirect(binary, argv, opts, timeoutMs)
592
+ const proc = spawnSync(binary, argv, {
593
+ cwd: opts.cwd,
594
+ maxBuffer: 256 * 1024 * 1024,
595
+ timeout: timeoutMs,
596
+ // See `runViaChildRedirect`: the default SIGTERM is not a hard cap.
597
+ killSignal: 'SIGKILL',
598
+ input: opts.input,
599
+ ...mergedEnv(opts),
600
+ })
601
+ return fromSpawnSync(proc, proc.stdout?.toString() ?? '', proc.stderr?.toString() ?? '')
602
+ },
603
+ }
604
+ }