@osdlc/command-seam 0.4.1 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2f3fc7c: `CommandFailed` now names the program that failed, and the sync runner flags output it stopped reading early.
8
+
9
+ - `CommandFailed.message` includes the binary: `command failed (exit 1): git nope` instead of `command failed (exit 1): nope`. The new `binary` field holds it, and the constructor takes it as an optional third argument. `runChecked` and `runCheckedAsync` read it from the runner's new optional `binary` property. `spawnRunner`, `spawnRunnerAsync`, `asAsync` and `withDefaults` runners have it. A fake runner without `binary` keeps the old argv-only message. ([#231](https://github.com/osdlc/osdlc/issues/231))
10
+ - `CommandResult` has a new `outputIncomplete` flag. The sync runner sets it when the child exited but a process it started still held stdout or stderr open at `timeoutMs`. That run used to look like a success with a stray `ETIMEDOUT` in `error`. A child that passes the output cap but exits before the runner's kill lands gets the flag too, on Node or Bun. The `bun test` capture path never sets it. `exitCode` stays the child's own and `reason` stays absent, so `ran` and `runChecked` still count the run as a success. Check `outputIncomplete` when the output must be whole. ([#232](https://github.com/osdlc/osdlc/issues/232))
11
+ - **Breaking:** `CommandFailed.message` now starts with the binary, so code that matches on the message text sees a different string. `CommandRunner` and `AsyncCommandRunner` gain an optional public `readonly binary?: string` member, so a class that implements one of them with a private or non-string `binary` field no longer type-checks. Make the field public and a string, or rename it.
12
+ - 2f3fc7c: Add `which(binary, opts?)`, experimental: the absolute path a spawn of `binary` would run, or `undefined`, without running it. It uses the runners' lookup rules: a name with a `/` resolves against `cwd`, a bare name is searched on `PATH`, and a match must be an executable file. `opts` takes `cwd` and `env` (merged over the parent's, as the runners take it), typed `WhichOptions`. ([#236](https://github.com/osdlc/osdlc/issues/236))
13
+
3
14
  ## 0.4.1
4
15
 
5
16
  ### Patch Changes
package/README.md CHANGED
@@ -93,7 +93,7 @@ try {
93
93
  of arguments, without the binary name. The binary is fixed when you create
94
94
  the runner.
95
95
  - **Result**: what a capture runner returns: `exitCode`, `stdout`, `stderr`,
96
- `neverExited`, `reason`, `signal` and `error`.
96
+ `neverExited`, `reason`, `signal`, `outputIncomplete` and `error`.
97
97
  - **`neverExited`**: `true` when the command never reported an exit status.
98
98
  The spawn failed, a timeout or abort killed it, a signal ended it, or the
99
99
  sync runner killed it at the output cap.
@@ -106,6 +106,10 @@ try {
106
106
  runner's 256 MB output cap). `signal` names the signal when the runner
107
107
  knows it. A timeout may pass on a retry; a spawn failure or the output cap
108
108
  will not.
109
+ - **`outputIncomplete`**: `true` when the child exited but the runner stopped
110
+ reading its output early, so `stdout` and `stderr` may be cut short. The
111
+ exit code is real and `reason` is absent. See "`error` can come with a real
112
+ exit code" under Behaviour.
109
113
  - **`error`**: the runner's own message, such as a spawn error or
110
114
  `timed out after 500ms`. It is never copied into `stderr`, which holds only
111
115
  what the child wrote. `failureText` and `CommandFailed` read `error` when
@@ -345,6 +349,27 @@ const user = runJson<{ login: string }>(gh, ['api', 'user'], { env: { GH_HOST: '
345
349
  console.log(head, user.ok && user.value.login)
346
350
  ```
347
351
 
352
+ ## Finding a binary
353
+
354
+ `which(binary, opts?)` returns the absolute path a spawn of `binary` would
355
+ run, or `undefined`, without running anything. Use it to pick a tool by what
356
+ is installed, where probing with `--version` is slow or has side effects.
357
+
358
+ ```ts
359
+ import { which } from '@osdlc/command-seam'
360
+
361
+ const harnesses = ['codex', 'claude', 'cursor-agent'].filter((b) => which(b) !== undefined)
362
+ ```
363
+
364
+ It looks a binary up the way the runners' spawn does: a name with a `/` is
365
+ resolved against `cwd`, a bare name is searched on `PATH`, and a match must
366
+ be an executable file. `opts` takes `cwd` (default `process.cwd()`) and
367
+ `env`, merged over the parent's as the runners take it, so
368
+ `which('tool', { env: { PATH: binDir } })` checks a test's own `PATH`.
369
+ An empty `PATH` entry means `cwd`, as it does for Node's spawn. Bun's spawn
370
+ skips empty entries, so under Bun `which` can find a binary there that a
371
+ spawn would not.
372
+
348
373
  ## Reading results
349
374
 
350
375
  Small helpers that never throw for a failed command (a programmer error,
@@ -411,14 +436,19 @@ else if (checked.reason === 'invalid') console.error(checked.message, checked.ca
411
436
 
412
437
  ## Reporting failures
413
438
 
414
- `CommandFailed` carries `argv` and the full `result`. Its `message` is
415
- `command failed (exit N): <argv>`, with argv quoted by `shellJoin`, then the
439
+ `CommandFailed` carries `binary`, `argv` and the full `result`. Its `message`
440
+ is `command failed (exit N): <binary> <argv>`, quoted by `shellJoin`, then the
416
441
  stderr, or the runner's `error` when the child wrote no stderr. It never
417
442
  includes stdout. Some commands explain a failure on stdout instead, such as a
418
443
  git hook that rejects a commit. `e.detail` joins both
419
444
  streams, trimmed, and drops empty ones: it is
420
445
  `failureText(e.result, { both: true })`. Show `detail` to a person.
421
446
 
447
+ `runChecked` and `runCheckedAsync` take the binary from the runner's
448
+ `binary` property, which `spawnRunner`, `spawnRunnerAsync`, `asAsync` and
449
+ `withDefaults` runners have. A fake runner may leave it out; the message then
450
+ shows the argv alone and `e.binary` is `undefined`.
451
+
422
452
  The message carries the full argv, so it reaches error logs and crash
423
453
  reports. Do not pass a secret, such as a token in a URL, as an argument:
424
454
  pass it through `env` or `input`.
@@ -572,6 +602,17 @@ console.log(await server.exited) // { exitCode: 0, signal: null }
572
602
  the output captured so far. The sync runner also sets `signal: 'SIGKILL'`.
573
603
  Both runners set `error`: the async runner's is `timed out after <N>ms`,
574
604
  the sync runner's is spawnSync's `ETIMEDOUT` message.
605
+ - The sync capture runners wait for the output pipes to close, not only for
606
+ the child to exit. If the child exits but a process it started still holds
607
+ stdout or stderr, the run waits until `timeoutMs` (or, without one, until
608
+ that process exits). The result then has the child's own `exitCode`,
609
+ `outputIncomplete: true`, no `reason`, and spawnSync's `ETIMEDOUT` message
610
+ in `error`. Output written after the deadline is lost, and the process
611
+ keeps running. A run that may start background work belongs on an async
612
+ runner with `processGroup: true`. Under `bun test` the sync runner captures
613
+ through temp files instead of pipes (see Known issue: `bun test`), so it
614
+ returns as soon as the child exits: later output is lost without
615
+ `outputIncomplete` or `error`, and a test there never sees the flag.
575
616
  - The sync runners (`spawnRunner`, `runShell`, `shellRunner`) and
576
617
  `streamRunner` kill only the direct child on timeout. Processes it started
577
618
  keep running, and these runners have no option to change that. The async
@@ -614,13 +655,21 @@ console.log(await server.exited) // { exitCode: 0, signal: null }
614
655
  written, the result keeps the child's exit code. The async runner and the
615
656
  sync runner on Node put the write error in `error`. The sync runner under
616
657
  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.
658
+ - **`error` can come with a real exit code.** `exitCode` is then the child's
659
+ own and `reason` is absent. Three cases:
660
+ - a failed stdin write: only `error` says what went wrong;
661
+ - a sync run whose child exited while a process it started held the output
662
+ pipes past `timeoutMs` (see Timeouts and cancellation): `outputIncomplete: true`;
663
+ - a child that passes the output cap but exits before the runner's kill
664
+ lands, on Node or Bun: `outputIncomplete: true`.
665
+
666
+ `ran` and `runChecked` go by the exit code, so they treat the last two as
667
+ success. Check `outputIncomplete` when the output must be whole.
620
668
  - **Output is text.** Captured output is decoded as UTF-8. The sync pipe
621
669
  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
670
+ with `reason: 'output'`. A child that passes the cap but exits before the
671
+ kill lands keeps its exit code and gets `outputIncomplete: true` and
672
+ `error`. The `bun test` capture path and
624
673
  the async runner have no cap.
625
674
 
626
675
  ## Known issue: `bun test`
@@ -657,6 +706,7 @@ The experimental exports:
657
706
  - `firstLine`, `stderrOrError`
658
707
  - `runJson`, `runJsonAsync`, `parseJsonResult`, `JsonRun`, `RunJsonOptions`
659
708
  - `withDefaults`, `DefaultedRunner`
709
+ - `which`, `WhichOptions`
660
710
  - `stopProcess`
661
711
  - `installGroupSignalForwarding`, `GroupSignalForwardingOptions`
662
712
  - `liveProcessGroups`, `forwardedSignal`, `forwardedExitCode`
@@ -678,9 +728,9 @@ are settled, and Windows support has landed, if that is feasible.
678
728
  | `mapFailed`, `mapFailedAsync` | function | Rethrow a `CommandFailed` as another error |
679
729
  | `catchFailed`, `catchFailedAsync` | function | Turn a `CommandFailed` into a value |
680
730
  | `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` |
731
+ | `CommandFailed` | class | Error with `binary`, `argv`, `result` and `detail`; `message` shows the command |
732
+ | `CommandRunner`, `AsyncCommandRunner` | interface | Capture runner contracts; `binary` names the program |
733
+ | `CommandResult` | interface | `exitCode`, `stdout`, `stderr`, `neverExited`, `reason`, `signal`, `outputIncomplete`, `error` |
684
734
  | `NeverExitedReason` | type | `'spawn' \| 'timeout' \| 'abort' \| 'signal' \| 'output'` |
685
735
  | `CommandRunOptions`, `AsyncCommandRunOptions` | interface | `cwd`, `timeoutMs`, `input`, `env`; async adds `signal`, `onSpawn`, `processGroup` |
686
736
  | `EnvOverrides` | type | `Record<string, string \| undefined>` |
@@ -703,6 +753,8 @@ are settled, and Windows support has landed, if that is feasible.
703
753
  | `GroupSignalForwardingOptions` | interface | `signals`, `graceMs`, `exit` |
704
754
  | `withDefaults` | function | Bind default options to any runner |
705
755
  | `DefaultedRunner` | interface | The runner `withDefaults` returns |
756
+ | `which` | function | The path a spawn of a binary would run, without running it |
757
+ | `WhichOptions` | interface | `cwd`, `env` |
706
758
  | `fakeRunner` | function | `/testing`: a `CommandRunner` that answers from a table |
707
759
  | `fakeNeverExited` | function | `/testing`: a fake answer for a run that never exited |
708
760
  | `fakeProcess` | function | `/testing`: a `ManagedProcessHandle` the test ends by hand |
package/async.ts CHANGED
@@ -67,6 +67,8 @@ export interface AsyncCommandRunOptions extends CommandRunOptions {
67
67
 
68
68
  /** The async form of {@link CommandRunner}: `run` returns a promise. */
69
69
  export interface AsyncCommandRunner {
70
+ /** The binary this runner runs, when it has one. See `CommandRunner.binary`. */
71
+ readonly binary?: string | undefined
70
72
  /**
71
73
  * Run the binary with `argv` (without the binary name). The promise never
72
74
  * rejects for a failed command: a non-zero exit, a missing binary, a
@@ -141,6 +143,7 @@ const ABORT_KILL_GRACE_MS = 1000
141
143
  */
142
144
  export function spawnRunnerAsync(binary: string): AsyncCommandRunner {
143
145
  return {
146
+ binary,
144
147
  run(argv: string[], opts: AsyncCommandRunOptions): Promise<CommandResult> {
145
148
  return new Promise((resolve) => {
146
149
  // Inside the executor, so an invalid timeoutMs (or a throw from spawn)
@@ -391,17 +394,23 @@ export async function runCheckedAsync(
391
394
  opts: AsyncCommandRunOptions,
392
395
  ): Promise<CommandResult>
393
396
  export async function runCheckedAsync<P extends unknown[]>(
394
- runner: { run(argv: string[], ...opts: P): Promise<CommandResult> },
397
+ runner: {
398
+ readonly binary?: string | undefined
399
+ run(argv: string[], ...opts: P): Promise<CommandResult>
400
+ },
395
401
  argv: string[],
396
402
  ...opts: NoInfer<P>
397
403
  ): Promise<CommandResult>
398
404
  export async function runCheckedAsync(
399
- runner: { run(argv: string[], ...opts: unknown[]): Promise<CommandResult> },
405
+ runner: {
406
+ readonly binary?: string | undefined
407
+ run(argv: string[], ...opts: unknown[]): Promise<CommandResult>
408
+ },
400
409
  argv: string[],
401
410
  ...opts: unknown[]
402
411
  ): Promise<CommandResult> {
403
412
  const result = await runner.run(argv, ...opts)
404
- if (result.exitCode !== 0) throw new CommandFailed(argv, result)
413
+ if (result.exitCode !== 0) throw new CommandFailed(argv, result, runner.binary)
405
414
  return result
406
415
  }
407
416
 
@@ -416,6 +425,7 @@ export async function runCheckedAsync(
416
425
  */
417
426
  export function asAsync(runner: CommandRunner): AsyncCommandRunner {
418
427
  return {
428
+ ...(runner.binary === undefined ? {} : { binary: runner.binary }),
419
429
  run: async (argv, opts) => runner.run(argv, opts),
420
430
  }
421
431
  }
package/defaults.ts CHANGED
@@ -24,6 +24,8 @@ export type DefaultedOptions<O, K extends keyof O> = Omit<O, K> & Partial<Pick<O
24
24
  * @experimental
25
25
  */
26
26
  export interface DefaultedRunner<Rn extends AnyRunner, K extends keyof RunOptions<Rn> = never> {
27
+ /** The wrapped runner's `binary`, when it has one. */
28
+ readonly binary?: string | undefined
27
29
  run(
28
30
  arg: Parameters<Rn['run']>[0],
29
31
  ...opts: {} extends DefaultedOptions<RunOptions<Rn>, K>
@@ -62,8 +64,12 @@ export function withDefaults<Rn extends AnyRunner, K extends keyof RunOptions<Rn
62
64
  defaults: Pick<RunOptions<Rn>, K> & Partial<RunOptions<Rn>>,
63
65
  ): DefaultedRunner<Rn, K> {
64
66
  const bound: Record<string, unknown> = { ...defaults }
65
- const target = runner as unknown as { run(arg: unknown, opts: unknown): ReturnType<Rn['run']> }
67
+ const target = runner as unknown as {
68
+ binary?: unknown
69
+ run(arg: unknown, opts: unknown): ReturnType<Rn['run']>
70
+ }
66
71
  return {
72
+ ...(typeof target.binary === 'string' ? { binary: target.binary } : {}),
67
73
  run(arg: unknown, opts?: object) {
68
74
  const merged = { ...bound }
69
75
  for (const [key, value] of Object.entries(opts ?? {})) {
package/dist/async.d.ts CHANGED
@@ -45,6 +45,8 @@ export interface AsyncCommandRunOptions extends CommandRunOptions {
45
45
  }
46
46
  /** The async form of {@link CommandRunner}: `run` returns a promise. */
47
47
  export interface AsyncCommandRunner {
48
+ /** The binary this runner runs, when it has one. See `CommandRunner.binary`. */
49
+ readonly binary?: string | undefined;
48
50
  /**
49
51
  * Run the binary with `argv` (without the binary name). The promise never
50
52
  * rejects for a failed command: a non-zero exit, a missing binary, a
@@ -108,6 +110,7 @@ export declare function spawnRunnerAsync(binary: string): AsyncCommandRunner;
108
110
  */
109
111
  export declare function runCheckedAsync(runner: AsyncCommandRunner, argv: string[], opts: AsyncCommandRunOptions): Promise<CommandResult>;
110
112
  export declare function runCheckedAsync<P extends unknown[]>(runner: {
113
+ readonly binary?: string | undefined;
111
114
  run(argv: string[], ...opts: P): Promise<CommandResult>;
112
115
  }, argv: string[], ...opts: NoInfer<P>): Promise<CommandResult>;
113
116
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"async.d.ts","sourceRoot":"","sources":["../async.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAYH,OAAO,EACL,aAAa,EAKb,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACvB,MAAM,WAAW,CAAA;AAElB,wFAAwF;AACxF,MAAM,WAAW,sBAAuB,SAAQ,iBAAiB;IAC/D;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAA;IAChC;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC,GAAG,SAAS,CAAA;IAC7C;;;;;;;;;;;OAWG;IACH,YAAY,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;CACnC;AAED,wEAAwE;AACxE,MAAM,WAAW,kBAAkB;IACjC;;;;;;OAMG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,sBAAsB,GAAG,OAAO,CAAC,aAAa,CAAC,CAAA;CAC1E;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,kBAAkB,GAAG,SAAS,GACxC,kBAAkB,CAEpB;AAgBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,kBAAkB,CA2OnE;AAED;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,kBAAkB,EAC1B,IAAI,EAAE,MAAM,EAAE,EACd,IAAI,EAAE,sBAAsB,GAC3B,OAAO,CAAC,aAAa,CAAC,CAAA;AACzB,wBAAsB,eAAe,CAAC,CAAC,SAAS,OAAO,EAAE,EACvD,MAAM,EAAE;IAAE,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,GAAG,IAAI,EAAE,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,CAAA;CAAE,EACnE,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,GAClB,OAAO,CAAC,aAAa,CAAC,CAAA;AAWzB;;;;;;;;GAQG;AACH,wBAAgB,OAAO,CAAC,MAAM,EAAE,aAAa,GAAG,kBAAkB,CAIjE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,cAAc,CAAC,CAAC,EACpC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,MAAM,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,GACxD,OAAO,CAAC,CAAC,CAAC,CAOZ;AAED;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CAAC,CAAC,EAAE,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,QAAQ,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GAClD,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,CAOhB"}
1
+ {"version":3,"file":"async.d.ts","sourceRoot":"","sources":["../async.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAYH,OAAO,EACL,aAAa,EAKb,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACvB,MAAM,WAAW,CAAA;AAElB,wFAAwF;AACxF,MAAM,WAAW,sBAAuB,SAAQ,iBAAiB;IAC/D;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAA;IAChC;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC,GAAG,SAAS,CAAA;IAC7C;;;;;;;;;;;OAWG;IACH,YAAY,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;CACnC;AAED,wEAAwE;AACxE,MAAM,WAAW,kBAAkB;IACjC,gFAAgF;IAChF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACpC;;;;;;OAMG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,sBAAsB,GAAG,OAAO,CAAC,aAAa,CAAC,CAAA;CAC1E;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,kBAAkB,GAAG,SAAS,GACxC,kBAAkB,CAEpB;AAgBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,kBAAkB,CA4OnE;AAED;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,kBAAkB,EAC1B,IAAI,EAAE,MAAM,EAAE,EACd,IAAI,EAAE,sBAAsB,GAC3B,OAAO,CAAC,aAAa,CAAC,CAAA;AACzB,wBAAsB,eAAe,CAAC,CAAC,SAAS,OAAO,EAAE,EACvD,MAAM,EAAE;IACN,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACpC,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,GAAG,IAAI,EAAE,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,CAAA;CACxD,EACD,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,GAClB,OAAO,CAAC,aAAa,CAAC,CAAA;AAczB;;;;;;;;GAQG;AACH,wBAAgB,OAAO,CAAC,MAAM,EAAE,aAAa,GAAG,kBAAkB,CAKjE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,cAAc,CAAC,CAAC,EACpC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,MAAM,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,GACxD,OAAO,CAAC,CAAC,CAAC,CAOZ;AAED;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CAAC,CAAC,EAAE,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,QAAQ,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GAClD,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,CAOhB"}
package/dist/async.js CHANGED
@@ -69,6 +69,7 @@ const ABORT_KILL_GRACE_MS = 1000;
69
69
  */
70
70
  export function spawnRunnerAsync(binary) {
71
71
  return {
72
+ binary,
72
73
  run(argv, opts) {
73
74
  return new Promise((resolve) => {
74
75
  // Inside the executor, so an invalid timeoutMs (or a throw from spawn)
@@ -308,7 +309,7 @@ export function spawnRunnerAsync(binary) {
308
309
  export async function runCheckedAsync(runner, argv, ...opts) {
309
310
  const result = await runner.run(argv, ...opts);
310
311
  if (result.exitCode !== 0)
311
- throw new CommandFailed(argv, result);
312
+ throw new CommandFailed(argv, result, runner.binary);
312
313
  return result;
313
314
  }
314
315
  /**
@@ -322,6 +323,7 @@ export async function runCheckedAsync(runner, argv, ...opts) {
322
323
  */
323
324
  export function asAsync(runner) {
324
325
  return {
326
+ ...(runner.binary === undefined ? {} : { binary: runner.binary }),
325
327
  run: async (argv, opts) => runner.run(argv, opts),
326
328
  };
327
329
  }
@@ -20,6 +20,8 @@ export type DefaultedOptions<O, K extends keyof O> = Omit<O, K> & Partial<Pick<O
20
20
  * @experimental
21
21
  */
22
22
  export interface DefaultedRunner<Rn extends AnyRunner, K extends keyof RunOptions<Rn> = never> {
23
+ /** The wrapped runner's `binary`, when it has one. */
24
+ readonly binary?: string | undefined;
23
25
  run(arg: Parameters<Rn['run']>[0], ...opts: {} extends DefaultedOptions<RunOptions<Rn>, K> ? [opts?: DefaultedOptions<RunOptions<Rn>, K>] : [opts: DefaultedOptions<RunOptions<Rn>, K>]): ReturnType<Rn['run']>;
24
26
  }
25
27
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../defaults.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,kFAAkF;AAClF,MAAM,WAAW,SAAS;IACxB,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,GAAG,OAAO,CAAA;CACtC;AAED,uCAAuC;AACvC,KAAK,UAAU,CAAC,EAAE,SAAS,SAAS,IAAI,UAAU,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AAEhE,oFAAoF;AACpF,MAAM,MAAM,gBAAgB,CAAC,CAAC,EAAE,CAAC,SAAS,MAAM,CAAC,IAAI,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;AAErF;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC,SAAS,MAAM,UAAU,CAAC,EAAE,CAAC,GAAG,KAAK;IAC3F,GAAG,CACD,GAAG,EAAE,UAAU,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAC7B,GAAG,IAAI,EAAE,EAAE,SAAS,gBAAgB,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,GACnD,CAAC,IAAI,CAAC,EAAE,gBAAgB,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,GAC5C,CAAC,IAAI,EAAE,gBAAgB,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,GAC9C,UAAU,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAA;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,YAAY,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC,SAAS,MAAM,UAAU,CAAC,EAAE,CAAC,GAAG,KAAK,EACvF,MAAM,EAAE,EAAE,EACV,QAAQ,EAAE,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,GAC1D,eAAe,CAAC,EAAE,EAAE,CAAC,CAAC,CAgBxB"}
1
+ {"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../defaults.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,kFAAkF;AAClF,MAAM,WAAW,SAAS;IACxB,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,GAAG,OAAO,CAAA;CACtC;AAED,uCAAuC;AACvC,KAAK,UAAU,CAAC,EAAE,SAAS,SAAS,IAAI,UAAU,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AAEhE,oFAAoF;AACpF,MAAM,MAAM,gBAAgB,CAAC,CAAC,EAAE,CAAC,SAAS,MAAM,CAAC,IAAI,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;AAErF;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC,SAAS,MAAM,UAAU,CAAC,EAAE,CAAC,GAAG,KAAK;IAC3F,sDAAsD;IACtD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACpC,GAAG,CACD,GAAG,EAAE,UAAU,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAC7B,GAAG,IAAI,EAAE,EAAE,SAAS,gBAAgB,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,GACnD,CAAC,IAAI,CAAC,EAAE,gBAAgB,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,GAC5C,CAAC,IAAI,EAAE,gBAAgB,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,GAC9C,UAAU,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAA;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,YAAY,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC,SAAS,MAAM,UAAU,CAAC,EAAE,CAAC,GAAG,KAAK,EACvF,MAAM,EAAE,EAAE,EACV,QAAQ,EAAE,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,GAC1D,eAAe,CAAC,EAAE,EAAE,CAAC,CAAC,CAoBxB"}
package/dist/defaults.js CHANGED
@@ -32,6 +32,7 @@ export function withDefaults(runner, defaults) {
32
32
  const bound = { ...defaults };
33
33
  const target = runner;
34
34
  return {
35
+ ...(typeof target.binary === 'string' ? { binary: target.binary } : {}),
35
36
  run(arg, opts) {
36
37
  const merged = { ...bound };
37
38
  for (const [key, value] of Object.entries(opts ?? {})) {
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * without waiting for it to finish. `stopProcess` stops it with a grace
14
14
  * period.
15
15
  * - `withDefaults` binds default options to any of them.
16
+ * - `which` finds a binary on `PATH` without running it.
16
17
  *
17
18
  * The capture runners share {@link CommandResult}, {@link CommandRunOptions}
18
19
  * and {@link CommandFailed}, so one `instanceof CommandFailed` check covers
@@ -27,4 +28,5 @@ export { streamRunner, type StreamRunner, type StreamRunOptions, type StreamResu
27
28
  export { managedProcessRunner, stopProcess, type ManagedProcessRunner, type ManagedProcessHandle, type ManagedProcessRunOptions, type ManagedProcessExit, } from './process.ts';
28
29
  export { forwardedExitCode, forwardedSignal, installGroupSignalForwarding, liveProcessGroups, type GroupSignalForwardingOptions, } from './groups.ts';
29
30
  export { withDefaults, type DefaultedRunner } from './defaults.ts';
31
+ export { which, type WhichOptions } from './which.ts';
30
32
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EACL,WAAW,EACX,aAAa,EACb,SAAS,EACT,aAAa,EACb,UAAU,EACV,WAAW,EACX,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,iBAAiB,GACvB,MAAM,WAAW,CAAA;AAElB,OAAO,EACL,OAAO,EACP,gBAAgB,EAChB,cAAc,EACd,kBAAkB,EAClB,eAAe,EACf,gBAAgB,EAChB,KAAK,kBAAkB,EACvB,KAAK,sBAAsB,GAC5B,MAAM,YAAY,CAAA;AAEnB,OAAO,EACL,WAAW,EACX,SAAS,EACT,eAAe,EACf,GAAG,EACH,OAAO,EACP,YAAY,EACZ,aAAa,EACb,KAAK,kBAAkB,EACvB,KAAK,OAAO,EACZ,KAAK,cAAc,GACpB,MAAM,aAAa,CAAA;AAEpB,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAElD,OAAO,EACL,QAAQ,EACR,aAAa,EACb,WAAW,EACX,gBAAgB,EAChB,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EACzB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,eAAe,GACrB,MAAM,YAAY,CAAA;AAEnB,OAAO,EACL,YAAY,EACZ,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,YAAY,GAClB,MAAM,aAAa,CAAA;AAEpB,OAAO,EACL,oBAAoB,EACpB,WAAW,EACX,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,wBAAwB,EAC7B,KAAK,kBAAkB,GACxB,MAAM,cAAc,CAAA;AAErB,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,4BAA4B,EAC5B,iBAAiB,EACjB,KAAK,4BAA4B,GAClC,MAAM,aAAa,CAAA;AAEpB,OAAO,EAAE,YAAY,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EACL,WAAW,EACX,aAAa,EACb,SAAS,EACT,aAAa,EACb,UAAU,EACV,WAAW,EACX,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,iBAAiB,GACvB,MAAM,WAAW,CAAA;AAElB,OAAO,EACL,OAAO,EACP,gBAAgB,EAChB,cAAc,EACd,kBAAkB,EAClB,eAAe,EACf,gBAAgB,EAChB,KAAK,kBAAkB,EACvB,KAAK,sBAAsB,GAC5B,MAAM,YAAY,CAAA;AAEnB,OAAO,EACL,WAAW,EACX,SAAS,EACT,eAAe,EACf,GAAG,EACH,OAAO,EACP,YAAY,EACZ,aAAa,EACb,KAAK,kBAAkB,EACvB,KAAK,OAAO,EACZ,KAAK,cAAc,GACpB,MAAM,aAAa,CAAA;AAEpB,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAElD,OAAO,EACL,QAAQ,EACR,aAAa,EACb,WAAW,EACX,gBAAgB,EAChB,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EACzB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,eAAe,GACrB,MAAM,YAAY,CAAA;AAEnB,OAAO,EACL,YAAY,EACZ,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,YAAY,GAClB,MAAM,aAAa,CAAA;AAEpB,OAAO,EACL,oBAAoB,EACpB,WAAW,EACX,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,wBAAwB,EAC7B,KAAK,kBAAkB,GACxB,MAAM,cAAc,CAAA;AAErB,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,4BAA4B,EAC5B,iBAAiB,EACjB,KAAK,4BAA4B,GAClC,MAAM,aAAa,CAAA;AAEpB,OAAO,EAAE,YAAY,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAElE,OAAO,EAAE,KAAK,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAA"}
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * without waiting for it to finish. `stopProcess` stops it with a grace
14
14
  * period.
15
15
  * - `withDefaults` binds default options to any of them.
16
+ * - `which` finds a binary on `PATH` without running it.
16
17
  *
17
18
  * The capture runners share {@link CommandResult}, {@link CommandRunOptions}
18
19
  * and {@link CommandFailed}, so one `instanceof CommandFailed` check covers
@@ -27,3 +28,4 @@ export { streamRunner, } from "./stream.js";
27
28
  export { managedProcessRunner, stopProcess, } from "./process.js";
28
29
  export { forwardedExitCode, forwardedSignal, installGroupSignalForwarding, liveProcessGroups, } from "./groups.js";
29
30
  export { withDefaults } from "./defaults.js";
31
+ export { which } from "./which.js";
package/dist/sync.d.ts CHANGED
@@ -46,6 +46,26 @@ export interface CommandResult {
46
46
  * it leaves this unset there.
47
47
  */
48
48
  signal?: NodeJS.Signals | undefined;
49
+ /**
50
+ * `true` when the child exited but the runner stopped reading its output
51
+ * early, so `stdout` and `stderr` may be cut short. `exitCode` is the
52
+ * child's own, `neverExited` and `reason` are absent, and `error` says why.
53
+ * Absent otherwise. Two cases, both on the sync capture runner:
54
+ *
55
+ * - The child exited, but a process it started (`cmd &`, a daemon, a
56
+ * postinstall script) still held stdout or stderr open when `timeoutMs`
57
+ * ran out. Anything that process wrote after the deadline is lost, and it
58
+ * keeps running. Without `timeoutMs` the run waits for it to exit.
59
+ * - The child passed the 256 MB output cap but exited before the runner's
60
+ * kill landed, on Node or Bun.
61
+ *
62
+ * The `bun test` capture path writes output to files and never waits on a
63
+ * held pipe, so it never sets this flag.
64
+ *
65
+ * `ran` and `runChecked` go by the exit code alone, so they still count
66
+ * such a run as a success. Check this flag when the output must be whole.
67
+ */
68
+ outputIncomplete?: boolean | undefined;
49
69
  /**
50
70
  * The runner's own message, when it has one: a failed spawn, a timeout, an
51
71
  * abort, an `onSpawn` that threw, a refused `processGroup` run, the output
@@ -53,9 +73,8 @@ export interface CommandResult {
53
73
  * `stderr`. Absent otherwise.
54
74
  *
55
75
  * It can also be set when the child did exit: a failed stdin write (Bun's
56
- * `spawnSync` does not report one), or, under Bun, a child that exits right
57
- * at the output cap. `exitCode` is then the child's own and `reason` is
58
- * absent.
76
+ * `spawnSync` does not report one), or a run with `outputIncomplete` set.
77
+ * `exitCode` is then the child's own and `reason` is absent.
59
78
  */
60
79
  error?: string | undefined;
61
80
  }
@@ -121,6 +140,12 @@ export type EnvOverrides = Record<string, string | undefined>;
121
140
  * ```
122
141
  */
123
142
  export interface CommandRunner {
143
+ /**
144
+ * The binary this runner runs, when it has one. `runChecked` puts it in
145
+ * `CommandFailed`, so the message names the program. `spawnRunner` sets
146
+ * it; a fake may leave it out.
147
+ */
148
+ readonly binary?: string | undefined;
124
149
  /**
125
150
  * Run the binary with `argv` (the arguments, without the binary name) and
126
151
  * wait for it to exit. Never throws for a failed command: a non-zero exit,
@@ -196,17 +221,20 @@ export declare function syncStop(proc: {
196
221
  };
197
222
  /**
198
223
  * Thrown by {@link runChecked} and `runCheckedAsync` on a non-zero exit.
199
- * Carries the argv and the full {@link CommandResult}, so a caller can report
200
- * the output without running the command again.
224
+ * Carries the binary, the argv and the full {@link CommandResult}, so a
225
+ * caller can report the output without running the command again.
201
226
  *
202
- * `message` is `command failed (exit N): <argv>`, with argv quoted by
227
+ * `message` is `command failed (exit N): <binary> <argv>`, quoted by
203
228
  * `shellJoin`, followed on a new line by the trimmed stderr, or the trimmed
204
229
  * `error` when stderr is empty. Nothing follows when both are empty. The argv
205
230
  * appears in full, so error logs and reports carry it: pass secrets through
206
231
  * `env` or `input`, not argv. Use `detail` to show a person
207
- * why it failed.
232
+ * why it failed. The binary is left out when it is not known, as for a fake
233
+ * runner without `binary`.
208
234
  */
209
235
  export declare class CommandFailed extends Error {
236
+ /** The binary that ran, when the runner reports one (see `CommandRunner.binary`). */
237
+ readonly binary: string | undefined;
210
238
  /** The arguments the command ran with, without the binary name. */
211
239
  readonly argv: string[];
212
240
  /** The full result of the failed run. */
@@ -231,8 +259,9 @@ export declare class CommandFailed extends Error {
231
259
  /**
232
260
  * @param argv - The arguments the command ran with.
233
261
  * @param result - The result of the failed run.
262
+ * @param binary - The binary that ran, when known.
234
263
  */
235
- constructor(argv: string[], result: CommandResult);
264
+ constructor(argv: string[], result: CommandResult, binary?: string | undefined);
236
265
  }
237
266
  /**
238
267
  * Run a command and throw if it fails. Use it where the only sensible
@@ -258,6 +287,7 @@ export declare class CommandFailed extends Error {
258
287
  */
259
288
  export declare function runChecked(runner: CommandRunner, argv: string[], opts: CommandRunOptions): CommandResult;
260
289
  export declare function runChecked<P extends unknown[]>(runner: {
290
+ readonly binary?: string | undefined;
261
291
  run(argv: string[], ...opts: P): CommandResult;
262
292
  }, argv: string[], ...opts: NoInfer<P>): CommandResult;
263
293
  /**
@@ -283,6 +313,15 @@ export declare function mapFailed<T>(fn: () => T, mapper: (failed: CommandFailed
283
313
  * @returns What `fn` returns, or what `onFailed` returns.
284
314
  */
285
315
  export declare function catchFailed<T, R>(fn: () => T, onFailed: (failed: CommandFailed) => R): T | R;
316
+ /**
317
+ * Folds a finished `spawnSync` into a {@link CommandResult}. Exported for
318
+ * tests.
319
+ */
320
+ export declare function fromSpawnSync(proc: {
321
+ status: number | null;
322
+ signal: NodeJS.Signals | null;
323
+ error?: Error | undefined;
324
+ }, stdout: string, stderr: string): CommandResult;
286
325
  /**
287
326
  * The injected runner when there is one, else `spawnRunner(binary)`.
288
327
  *
@@ -1 +1 @@
1
- {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../sync.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../sync.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAWH,uEAAuE;AACvE,MAAM,WAAW,aAAa;IAC5B,oFAAoF;IACpF,QAAQ,EAAE,MAAM,CAAA;IAChB,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAA;IACd;;;;OAIG;IACH,MAAM,EAAE,MAAM,CAAA;IACd;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;IACjC;;;OAGG;IACH,MAAM,CAAC,EAAE,iBAAiB,GAAG,SAAS,CAAA;IACtC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC,OAAO,GAAG,SAAS,CAAA;IACnC;;;;;;;;;;;;;;;;;;OAkBG;IACH,gBAAgB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;IACtC;;;;;;;;;OASG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAC3B;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,iBAAiB,GAAG,OAAO,GAAG,SAAS,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAEnF,oDAAoD;AACpD,MAAM,WAAW,iBAAiB;IAChC,4EAA4E;IAC5E,GAAG,EAAE,MAAM,CAAA;IAKX;;;;;;;;;;;;;;;;;OAiBG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC9B,4FAA4F;IAC5F,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC1B;;;OAGG;IACH,GAAG,CAAC,EAAE,YAAY,GAAG,SAAS,CAAA;CAC/B;AAED,kGAAkG;AAClG,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;AAE7D;;;;;;;;;;GAUG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACpC;;;;;;OAMG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,iBAAiB,GAAG,aAAa,CAAA;CAC5D;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,iBAAiB,EAAE,KAAK,CAAC,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC,UAAU,CAAA;CAAE,CAI1F;AAKD;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAGlF;AAED;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAQ9F;AAED;;;;;;;;;GASG;AACH,wBAAgB,QAAQ,CACtB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,IAAI,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,YAAY,GAAG,SAAS,CAAA;CAAE,GACpD,MAAM,GAAG,SAAS,CAYpB;AAwBD,0DAA0D;AAC1D,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,aAAa,CAS3D;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE;IAAE,KAAK,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAA;CAAE,GAAG;IAC5F,MAAM,EAAE,OAAO,CAAC,iBAAiB,EAAE,OAAO,CAAC,CAAA;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAC,OAAO,CAAA;CACxB,CASA;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,aAAc,SAAQ,KAAK;IACtC,qFAAqF;IACrF,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;IACnC,mEAAmE;IACnE,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAA;IACvB,yCAAyC;IACzC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAA;IAE9B;;;;;;;;;;;;;;;OAeG;IACH,IAAI,MAAM,IAAI,MAAM,CAEnB;IAED;;;;OAIG;gBACS,IAAI,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS;CAY/E;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,aAAa,EACrB,IAAI,EAAE,MAAM,EAAE,EACd,IAAI,EAAE,iBAAiB,GACtB,aAAa,CAAA;AAChB,wBAAgB,UAAU,CAAC,CAAC,SAAS,OAAO,EAAE,EAC5C,MAAM,EAAE;IAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,GAAG,IAAI,EAAE,CAAC,GAAG,aAAa,CAAA;CAAE,EAChG,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,GAClB,aAAa,CAAA;AAchB;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,KAAK,GAAG,CAAC,CAOrF;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAO5F;AAeD;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;IAAC,KAAK,CAAC,EAAE,KAAK,GAAG,SAAS,CAAA;CAAE,EACzF,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,GACb,aAAa,CAoBf;AA2ED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,aAAa,GAAG,SAAS,GAAG,aAAa,CAEjG;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,CAoBzD"}
package/dist/sync.js CHANGED
@@ -11,11 +11,12 @@
11
11
  * exit, for callers whose only sensible response to failure is to stop.
12
12
  */
13
13
  import { spawnSync } from 'node:child_process';
14
- import { accessSync, constants, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
14
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
15
  import { tmpdir } from 'node:os';
16
- import { delimiter, join, resolve } from 'node:path';
16
+ import { join } from 'node:path';
17
17
  import { shellJoin } from "./quote.js";
18
18
  import { failureText, stderrOrError } from "./result.js";
19
+ import { which } from "./which.js";
19
20
  /**
20
21
  * `{ env }`: `process.env` merged with `opts.env`, minus any key whose
21
22
  * override is `undefined`. Always an explicit object built from the current
@@ -147,17 +148,20 @@ export function syncStop(proc) {
147
148
  }
148
149
  /**
149
150
  * Thrown by {@link runChecked} and `runCheckedAsync` on a non-zero exit.
150
- * Carries the argv and the full {@link CommandResult}, so a caller can report
151
- * the output without running the command again.
151
+ * Carries the binary, the argv and the full {@link CommandResult}, so a
152
+ * caller can report the output without running the command again.
152
153
  *
153
- * `message` is `command failed (exit N): <argv>`, with argv quoted by
154
+ * `message` is `command failed (exit N): <binary> <argv>`, quoted by
154
155
  * `shellJoin`, followed on a new line by the trimmed stderr, or the trimmed
155
156
  * `error` when stderr is empty. Nothing follows when both are empty. The argv
156
157
  * appears in full, so error logs and reports carry it: pass secrets through
157
158
  * `env` or `input`, not argv. Use `detail` to show a person
158
- * why it failed.
159
+ * why it failed. The binary is left out when it is not known, as for a fake
160
+ * runner without `binary`.
159
161
  */
160
162
  export class CommandFailed extends Error {
163
+ /** The binary that ran, when the runner reports one (see `CommandRunner.binary`). */
164
+ binary;
161
165
  /** The arguments the command ran with, without the binary name. */
162
166
  argv;
163
167
  /** The full result of the failed run. */
@@ -184,12 +188,15 @@ export class CommandFailed extends Error {
184
188
  /**
185
189
  * @param argv - The arguments the command ran with.
186
190
  * @param result - The result of the failed run.
191
+ * @param binary - The binary that ran, when known.
187
192
  */
188
- constructor(argv, result) {
193
+ constructor(argv, result, binary) {
189
194
  const stderr = stderrOrError(result);
190
- super(`command failed (exit ${result.exitCode}): ${shellJoin(argv)}` +
195
+ const command = binary === undefined ? argv : [binary, ...argv];
196
+ super(`command failed (exit ${result.exitCode}): ${shellJoin(command)}` +
191
197
  (stderr ? `\n${stderr}` : ''));
192
198
  this.name = 'CommandFailed';
199
+ this.binary = binary;
193
200
  this.argv = argv;
194
201
  this.result = result;
195
202
  }
@@ -197,7 +204,7 @@ export class CommandFailed extends Error {
197
204
  export function runChecked(runner, argv, ...opts) {
198
205
  const result = runner.run(argv, ...opts);
199
206
  if (result.exitCode !== 0)
200
- throw new CommandFailed(argv, result);
207
+ throw new CommandFailed(argv, result, runner.binary);
201
208
  return result;
202
209
  }
203
210
  /**
@@ -253,39 +260,26 @@ export function catchFailed(fn, onFailed) {
253
260
  // set `NODE_ENV=test`, but they run on Node and keep the pipe path below.
254
261
  const CHILD_STDIO_CORRUPTED = process.versions['bun'] !== undefined && process.env['NODE_ENV'] === 'test';
255
262
  /**
256
- * Whether `binary` resolves to an executable file the way a spawn would find
257
- * it: a name with a `/` is taken relative to `cwd`; a bare name is looked up
258
- * on the child's `PATH`.
263
+ * Folds a finished `spawnSync` into a {@link CommandResult}. Exported for
264
+ * tests.
259
265
  */
260
- function findsExecutable(binary, cwd, path) {
261
- const isExecutable = (p) => {
262
- try {
263
- accessSync(p, constants.X_OK);
264
- return statSync(p).isFile();
265
- }
266
- catch {
267
- return false;
268
- }
269
- };
270
- if (binary === '')
271
- return false;
272
- if (binary.includes('/'))
273
- return isExecutable(resolve(cwd, binary));
274
- return (path ?? '/usr/bin:/bin')
275
- .split(delimiter)
276
- .some((dir) => isExecutable(resolve(cwd, dir, binary)));
277
- }
278
- /** Folds a finished `spawnSync` into a {@link CommandResult}. */
279
- function fromSpawnSync(proc, stdout, stderr) {
266
+ export function fromSpawnSync(proc, stdout, stderr) {
267
+ const code = proc.error?.code;
280
268
  return {
281
269
  exitCode: proc.status ?? 1,
282
270
  // Bun reports a failed spawn with `status` undefined, not null.
283
271
  ...(proc.status == null ? { neverExited: true, ...syncStop(proc) } : {}),
272
+ // A real status with a timeout: the child exited, but a process it started
273
+ // held the pipes until the deadline. With the cap: the child passed it and
274
+ // exited before the kill landed.
275
+ ...(proc.status != null && (code === 'ETIMEDOUT' || code === 'ENOBUFS')
276
+ ? { outputIncomplete: true }
277
+ : {}),
284
278
  stdout,
285
279
  stderr,
286
280
  // `proc.error` is set for a failed spawn, a timeout, the output cap, or a
287
- // failed stdin write; only the first three leave `status` null (and under
288
- // Bun the cap can come with a real status too). A stdin write error can
281
+ // failed stdin write. The first three usually leave `status` null; when
282
+ // they do not, `outputIncomplete` is set above. A stdin write error can
289
283
  // come with a child that did exit, so it is kept either way.
290
284
  ...(proc.error ? { error: proc.error.message } : {}),
291
285
  };
@@ -305,7 +299,7 @@ function runViaChildRedirect(binary, argv, opts, timeoutMs) {
305
299
  // the same stand-in the pipe path reports for a failed spawn. This also
306
300
  // keeps a binary named like an option (`-a`) from reaching `exec` as one,
307
301
  // unless an executable named `-a` is on PATH.
308
- if (!findsExecutable(binary, opts.cwd, env['PATH'])) {
302
+ if (which(binary, { cwd: opts.cwd, env: { PATH: env['PATH'] } }) === undefined) {
309
303
  return spawnFailure(`spawnSync ${binary} ENOENT`);
310
304
  }
311
305
  const dir = mkdtempSync(join(tmpdir(), 'command-seam-'));
@@ -384,6 +378,7 @@ export function resolveRunner(binary, injected) {
384
378
  */
385
379
  export function spawnRunner(binary) {
386
380
  return {
381
+ binary,
387
382
  run(argv, opts) {
388
383
  const timeoutMs = normalizeTimeout(opts.timeoutMs);
389
384
  const cwdMessage = cwdError(`spawnSync ${binary}`, binary, argv, opts);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `which`: find a binary on `PATH` without running it, the way the runners'
3
+ * spawn would find it. For code that picks a tool by what is installed;
4
+ * running `--version` to probe is slow for some CLIs and has side effects
5
+ * (update checks, telemetry) for others.
6
+ */
7
+ import type { EnvOverrides } from './sync.ts';
8
+ /**
9
+ * Options for {@link which}.
10
+ *
11
+ * @experimental
12
+ */
13
+ export interface WhichOptions {
14
+ /**
15
+ * The folder a relative path, or a relative `PATH` entry, is resolved
16
+ * against, as a spawn with this `cwd` would. Default `process.cwd()`.
17
+ */
18
+ cwd?: string | undefined;
19
+ /**
20
+ * Variables merged over the parent's environment, as the runners take
21
+ * them. Only `PATH` is read; an `undefined` value unsets it.
22
+ */
23
+ env?: EnvOverrides | undefined;
24
+ }
25
+ /**
26
+ * The absolute path of the file a spawn of `binary` would run, or
27
+ * `undefined` when there is none. A name with a `/` is taken relative to
28
+ * `cwd`; a bare name is looked up on `PATH`, first match wins. A match must be
29
+ * an executable file. With `PATH` unset, it searches `/usr/bin:/bin`. An
30
+ * empty `PATH` entry (`PATH=''`, `a::b`, a trailing `:`) means `cwd`, as it
31
+ * does for Node's spawn; Bun's spawn skips empty entries, so under Bun such
32
+ * a match may not start. POSIX only: `PATHEXT` is not read.
33
+ *
34
+ * Never throws for a missing binary, an unreadable folder or a bad `cwd`.
35
+ *
36
+ * @param binary - A name looked up on `PATH`, or a path.
37
+ * @param opts - `cwd` and `env`, as the runners take them.
38
+ * @returns The absolute path, or `undefined`.
39
+ * @experimental
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * const harnesses = ['codex', 'claude', 'cursor-agent'].filter((b) => which(b) !== undefined)
44
+ * ```
45
+ */
46
+ export declare function which(binary: string, opts?: WhichOptions): string | undefined;
47
+ //# sourceMappingURL=which.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"which.d.ts","sourceRoot":"","sources":["../which.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAA;AAE7C;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACxB;;;OAGG;IACH,GAAG,CAAC,EAAE,YAAY,GAAG,SAAS,CAAA;CAC/B;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,GAAE,YAAiB,GAAG,MAAM,GAAG,SAAS,CAUjF"}
package/dist/which.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `which`: find a binary on `PATH` without running it, the way the runners'
3
+ * spawn would find it. For code that picks a tool by what is installed;
4
+ * running `--version` to probe is slow for some CLIs and has side effects
5
+ * (update checks, telemetry) for others.
6
+ */
7
+ import { accessSync, constants, statSync } from 'node:fs';
8
+ import { delimiter, resolve } from 'node:path';
9
+ /**
10
+ * The absolute path of the file a spawn of `binary` would run, or
11
+ * `undefined` when there is none. A name with a `/` is taken relative to
12
+ * `cwd`; a bare name is looked up on `PATH`, first match wins. A match must be
13
+ * an executable file. With `PATH` unset, it searches `/usr/bin:/bin`. An
14
+ * empty `PATH` entry (`PATH=''`, `a::b`, a trailing `:`) means `cwd`, as it
15
+ * does for Node's spawn; Bun's spawn skips empty entries, so under Bun such
16
+ * a match may not start. POSIX only: `PATHEXT` is not read.
17
+ *
18
+ * Never throws for a missing binary, an unreadable folder or a bad `cwd`.
19
+ *
20
+ * @param binary - A name looked up on `PATH`, or a path.
21
+ * @param opts - `cwd` and `env`, as the runners take them.
22
+ * @returns The absolute path, or `undefined`.
23
+ * @experimental
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * const harnesses = ['codex', 'claude', 'cursor-agent'].filter((b) => which(b) !== undefined)
28
+ * ```
29
+ */
30
+ export function which(binary, opts = {}) {
31
+ const cwd = opts.cwd ?? process.cwd();
32
+ if (binary === '')
33
+ return undefined;
34
+ if (binary.includes('/'))
35
+ return executable(resolve(cwd, binary));
36
+ const path = opts.env !== undefined && 'PATH' in opts.env ? opts.env['PATH'] : process.env['PATH'];
37
+ for (const dir of (path ?? '/usr/bin:/bin').split(delimiter)) {
38
+ const found = executable(resolve(cwd, dir, binary));
39
+ if (found !== undefined)
40
+ return found;
41
+ }
42
+ return undefined;
43
+ }
44
+ /** `p` when it is an executable file, else `undefined`. */
45
+ function executable(p) {
46
+ try {
47
+ accessSync(p, constants.X_OK);
48
+ return statSync(p).isFile() ? p : undefined;
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
package/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * without waiting for it to finish. `stopProcess` stops it with a grace
14
14
  * period.
15
15
  * - `withDefaults` binds default options to any of them.
16
+ * - `which` finds a binary on `PATH` without running it.
16
17
  *
17
18
  * The capture runners share {@link CommandResult}, {@link CommandRunOptions}
18
19
  * and {@link CommandFailed}, so one `instanceof CommandFailed` check covers
@@ -96,3 +97,5 @@ export {
96
97
  } from './groups.ts'
97
98
 
98
99
  export { withDefaults, type DefaultedRunner } from './defaults.ts'
100
+
101
+ export { which, type WhichOptions } from './which.ts'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osdlc/command-seam",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Injectable subprocess runner for Node: capture, stream and managed-process runners, hard timeouts, failures as results.",
5
5
  "license": "MIT",
6
6
  "author": "SK Sizer",
@@ -59,6 +59,7 @@
59
59
  "quote.ts",
60
60
  "shell.ts",
61
61
  "defaults.ts",
62
+ "which.ts",
62
63
  "testing.ts",
63
64
  "README.md",
64
65
  "CHANGELOG.md",
package/sync.ts CHANGED
@@ -12,20 +12,13 @@
12
12
  */
13
13
 
14
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'
15
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
24
16
  import { tmpdir } from 'node:os'
25
- import { delimiter, join, resolve } from 'node:path'
17
+ import { join } from 'node:path'
26
18
 
27
19
  import { shellJoin } from './quote.ts'
28
20
  import { failureText, stderrOrError } from './result.ts'
21
+ import { which } from './which.ts'
29
22
 
30
23
  /** Result of running an external command through a `CommandRunner`. */
31
24
  export interface CommandResult {
@@ -63,6 +56,26 @@ export interface CommandResult {
63
56
  * it leaves this unset there.
64
57
  */
65
58
  signal?: NodeJS.Signals | undefined
59
+ /**
60
+ * `true` when the child exited but the runner stopped reading its output
61
+ * early, so `stdout` and `stderr` may be cut short. `exitCode` is the
62
+ * child's own, `neverExited` and `reason` are absent, and `error` says why.
63
+ * Absent otherwise. Two cases, both on the sync capture runner:
64
+ *
65
+ * - The child exited, but a process it started (`cmd &`, a daemon, a
66
+ * postinstall script) still held stdout or stderr open when `timeoutMs`
67
+ * ran out. Anything that process wrote after the deadline is lost, and it
68
+ * keeps running. Without `timeoutMs` the run waits for it to exit.
69
+ * - The child passed the 256 MB output cap but exited before the runner's
70
+ * kill landed, on Node or Bun.
71
+ *
72
+ * The `bun test` capture path writes output to files and never waits on a
73
+ * held pipe, so it never sets this flag.
74
+ *
75
+ * `ran` and `runChecked` go by the exit code alone, so they still count
76
+ * such a run as a success. Check this flag when the output must be whole.
77
+ */
78
+ outputIncomplete?: boolean | undefined
66
79
  /**
67
80
  * The runner's own message, when it has one: a failed spawn, a timeout, an
68
81
  * abort, an `onSpawn` that threw, a refused `processGroup` run, the output
@@ -70,9 +83,8 @@ export interface CommandResult {
70
83
  * `stderr`. Absent otherwise.
71
84
  *
72
85
  * 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.
86
+ * `spawnSync` does not report one), or a run with `outputIncomplete` set.
87
+ * `exitCode` is then the child's own and `reason` is absent.
76
88
  */
77
89
  error?: string | undefined
78
90
  }
@@ -146,6 +158,12 @@ export type EnvOverrides = Record<string, string | undefined>
146
158
  * ```
147
159
  */
148
160
  export interface CommandRunner {
161
+ /**
162
+ * The binary this runner runs, when it has one. `runChecked` puts it in
163
+ * `CommandFailed`, so the message names the program. `spawnRunner` sets
164
+ * it; a fake may leave it out.
165
+ */
166
+ readonly binary?: string | undefined
149
167
  /**
150
168
  * Run the binary with `argv` (the arguments, without the binary name) and
151
169
  * wait for it to exit. Never throws for a failed command: a non-zero exit,
@@ -295,17 +313,20 @@ export function syncStop(proc: { error?: Error | undefined; signal: NodeJS.Signa
295
313
 
296
314
  /**
297
315
  * 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.
316
+ * Carries the binary, the argv and the full {@link CommandResult}, so a
317
+ * caller can report the output without running the command again.
300
318
  *
301
- * `message` is `command failed (exit N): <argv>`, with argv quoted by
319
+ * `message` is `command failed (exit N): <binary> <argv>`, quoted by
302
320
  * `shellJoin`, followed on a new line by the trimmed stderr, or the trimmed
303
321
  * `error` when stderr is empty. Nothing follows when both are empty. The argv
304
322
  * appears in full, so error logs and reports carry it: pass secrets through
305
323
  * `env` or `input`, not argv. Use `detail` to show a person
306
- * why it failed.
324
+ * why it failed. The binary is left out when it is not known, as for a fake
325
+ * runner without `binary`.
307
326
  */
308
327
  export class CommandFailed extends Error {
328
+ /** The binary that ran, when the runner reports one (see `CommandRunner.binary`). */
329
+ readonly binary: string | undefined
309
330
  /** The arguments the command ran with, without the binary name. */
310
331
  readonly argv: string[]
311
332
  /** The full result of the failed run. */
@@ -334,14 +355,17 @@ export class CommandFailed extends Error {
334
355
  /**
335
356
  * @param argv - The arguments the command ran with.
336
357
  * @param result - The result of the failed run.
358
+ * @param binary - The binary that ran, when known.
337
359
  */
338
- constructor(argv: string[], result: CommandResult) {
360
+ constructor(argv: string[], result: CommandResult, binary?: string | undefined) {
339
361
  const stderr = stderrOrError(result)
362
+ const command = binary === undefined ? argv : [binary, ...argv]
340
363
  super(
341
- `command failed (exit ${result.exitCode}): ${shellJoin(argv)}` +
364
+ `command failed (exit ${result.exitCode}): ${shellJoin(command)}` +
342
365
  (stderr ? `\n${stderr}` : ''),
343
366
  )
344
367
  this.name = 'CommandFailed'
368
+ this.binary = binary
345
369
  this.argv = argv
346
370
  this.result = result
347
371
  }
@@ -375,17 +399,20 @@ export function runChecked(
375
399
  opts: CommandRunOptions,
376
400
  ): CommandResult
377
401
  export function runChecked<P extends unknown[]>(
378
- runner: { run(argv: string[], ...opts: P): CommandResult },
402
+ runner: { readonly binary?: string | undefined; run(argv: string[], ...opts: P): CommandResult },
379
403
  argv: string[],
380
404
  ...opts: NoInfer<P>
381
405
  ): CommandResult
382
406
  export function runChecked(
383
- runner: { run(argv: string[], ...opts: unknown[]): CommandResult },
407
+ runner: {
408
+ readonly binary?: string | undefined
409
+ run(argv: string[], ...opts: unknown[]): CommandResult
410
+ },
384
411
  argv: string[],
385
412
  ...opts: unknown[]
386
413
  ): CommandResult {
387
414
  const result = runner.run(argv, ...opts)
388
- if (result.exitCode !== 0) throw new CommandFailed(argv, result)
415
+ if (result.exitCode !== 0) throw new CommandFailed(argv, result, runner.binary)
389
416
  return result
390
417
  }
391
418
 
@@ -442,41 +469,30 @@ const CHILD_STDIO_CORRUPTED =
442
469
  process.versions['bun'] !== undefined && process.env['NODE_ENV'] === 'test'
443
470
 
444
471
  /**
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`.
472
+ * Folds a finished `spawnSync` into a {@link CommandResult}. Exported for
473
+ * tests.
448
474
  */
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(
475
+ export function fromSpawnSync(
467
476
  proc: { status: number | null; signal: NodeJS.Signals | null; error?: Error | undefined },
468
477
  stdout: string,
469
478
  stderr: string,
470
479
  ): CommandResult {
480
+ const code = (proc.error as NodeJS.ErrnoException | undefined)?.code
471
481
  return {
472
482
  exitCode: proc.status ?? 1,
473
483
  // Bun reports a failed spawn with `status` undefined, not null.
474
484
  ...(proc.status == null ? { neverExited: true as const, ...syncStop(proc) } : {}),
485
+ // A real status with a timeout: the child exited, but a process it started
486
+ // held the pipes until the deadline. With the cap: the child passed it and
487
+ // exited before the kill landed.
488
+ ...(proc.status != null && (code === 'ETIMEDOUT' || code === 'ENOBUFS')
489
+ ? { outputIncomplete: true as const }
490
+ : {}),
475
491
  stdout,
476
492
  stderr,
477
493
  // `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
494
+ // failed stdin write. The first three usually leave `status` null; when
495
+ // they do not, `outputIncomplete` is set above. A stdin write error can
480
496
  // come with a child that did exit, so it is kept either way.
481
497
  ...(proc.error ? { error: proc.error.message } : {}),
482
498
  }
@@ -502,7 +518,7 @@ function runViaChildRedirect(
502
518
  // the same stand-in the pipe path reports for a failed spawn. This also
503
519
  // keeps a binary named like an option (`-a`) from reaching `exec` as one,
504
520
  // unless an executable named `-a` is on PATH.
505
- if (!findsExecutable(binary, opts.cwd, env['PATH'])) {
521
+ if (which(binary, { cwd: opts.cwd, env: { PATH: env['PATH'] } }) === undefined) {
506
522
  return spawnFailure(`spawnSync ${binary} ENOENT`)
507
523
  }
508
524
  const dir = mkdtempSync(join(tmpdir(), 'command-seam-'))
@@ -584,6 +600,7 @@ export function resolveRunner(binary: string, injected?: CommandRunner | undefin
584
600
  */
585
601
  export function spawnRunner(binary: string): CommandRunner {
586
602
  return {
603
+ binary,
587
604
  run(argv: string[], opts: CommandRunOptions): CommandResult {
588
605
  const timeoutMs = normalizeTimeout(opts.timeoutMs)
589
606
  const cwdMessage = cwdError(`spawnSync ${binary}`, binary, argv, opts)
package/which.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `which`: find a binary on `PATH` without running it, the way the runners'
3
+ * spawn would find it. For code that picks a tool by what is installed;
4
+ * running `--version` to probe is slow for some CLIs and has side effects
5
+ * (update checks, telemetry) for others.
6
+ */
7
+
8
+ import { accessSync, constants, statSync } from 'node:fs'
9
+ import { delimiter, resolve } from 'node:path'
10
+
11
+ import type { EnvOverrides } from './sync.ts'
12
+
13
+ /**
14
+ * Options for {@link which}.
15
+ *
16
+ * @experimental
17
+ */
18
+ export interface WhichOptions {
19
+ /**
20
+ * The folder a relative path, or a relative `PATH` entry, is resolved
21
+ * against, as a spawn with this `cwd` would. Default `process.cwd()`.
22
+ */
23
+ cwd?: string | undefined
24
+ /**
25
+ * Variables merged over the parent's environment, as the runners take
26
+ * them. Only `PATH` is read; an `undefined` value unsets it.
27
+ */
28
+ env?: EnvOverrides | undefined
29
+ }
30
+
31
+ /**
32
+ * The absolute path of the file a spawn of `binary` would run, or
33
+ * `undefined` when there is none. A name with a `/` is taken relative to
34
+ * `cwd`; a bare name is looked up on `PATH`, first match wins. A match must be
35
+ * an executable file. With `PATH` unset, it searches `/usr/bin:/bin`. An
36
+ * empty `PATH` entry (`PATH=''`, `a::b`, a trailing `:`) means `cwd`, as it
37
+ * does for Node's spawn; Bun's spawn skips empty entries, so under Bun such
38
+ * a match may not start. POSIX only: `PATHEXT` is not read.
39
+ *
40
+ * Never throws for a missing binary, an unreadable folder or a bad `cwd`.
41
+ *
42
+ * @param binary - A name looked up on `PATH`, or a path.
43
+ * @param opts - `cwd` and `env`, as the runners take them.
44
+ * @returns The absolute path, or `undefined`.
45
+ * @experimental
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * const harnesses = ['codex', 'claude', 'cursor-agent'].filter((b) => which(b) !== undefined)
50
+ * ```
51
+ */
52
+ export function which(binary: string, opts: WhichOptions = {}): string | undefined {
53
+ const cwd = opts.cwd ?? process.cwd()
54
+ if (binary === '') return undefined
55
+ if (binary.includes('/')) return executable(resolve(cwd, binary))
56
+ const path = opts.env !== undefined && 'PATH' in opts.env ? opts.env['PATH'] : process.env['PATH']
57
+ for (const dir of (path ?? '/usr/bin:/bin').split(delimiter)) {
58
+ const found = executable(resolve(cwd, dir, binary))
59
+ if (found !== undefined) return found
60
+ }
61
+ return undefined
62
+ }
63
+
64
+ /** `p` when it is an executable file, else `undefined`. */
65
+ function executable(p: string): string | undefined {
66
+ try {
67
+ accessSync(p, constants.X_OK)
68
+ return statSync(p).isFile() ? p : undefined
69
+ } catch {
70
+ return undefined
71
+ }
72
+ }