@osdlc/command-seam 0.0.0-stage → 0.0.1-stage

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/CONTRIBUTING.md +58 -0
  3. package/LICENSE +21 -0
  4. package/README.md +741 -2
  5. package/SECURITY.md +23 -0
  6. package/async.ts +466 -0
  7. package/defaults.ts +83 -0
  8. package/dist/async.d.ts +146 -0
  9. package/dist/async.d.ts.map +1 -0
  10. package/dist/async.js +368 -0
  11. package/dist/defaults.d.ts +52 -0
  12. package/dist/defaults.d.ts.map +1 -0
  13. package/dist/defaults.js +51 -0
  14. package/dist/groups.d.ts +155 -0
  15. package/dist/groups.d.ts.map +1 -0
  16. package/dist/groups.js +331 -0
  17. package/dist/index.d.ts +30 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +29 -0
  20. package/dist/process.d.ts +146 -0
  21. package/dist/process.d.ts.map +1 -0
  22. package/dist/process.js +175 -0
  23. package/dist/process.test.d.ts +1 -0
  24. package/dist/process.test.d.ts.map +1 -0
  25. package/dist/process.test.js +1 -0
  26. package/dist/quote.d.ts +18 -0
  27. package/dist/quote.d.ts.map +1 -0
  28. package/dist/quote.js +28 -0
  29. package/dist/result.d.ts +211 -0
  30. package/dist/result.d.ts.map +1 -0
  31. package/dist/result.js +219 -0
  32. package/dist/shell.d.ts +84 -0
  33. package/dist/shell.d.ts.map +1 -0
  34. package/dist/shell.js +83 -0
  35. package/dist/stream.d.ts +90 -0
  36. package/dist/stream.d.ts.map +1 -0
  37. package/dist/stream.js +73 -0
  38. package/dist/sync.d.ts +311 -0
  39. package/dist/sync.d.ts.map +1 -0
  40. package/dist/sync.js +406 -0
  41. package/dist/testing.d.ts +158 -0
  42. package/dist/testing.d.ts.map +1 -0
  43. package/dist/testing.js +173 -0
  44. package/groups.ts +360 -0
  45. package/index.ts +98 -0
  46. package/package.json +94 -3
  47. package/process.ts +270 -0
  48. package/quote.ts +31 -0
  49. package/result.ts +403 -0
  50. package/shell.ts +131 -0
  51. package/smoke.mjs +61 -0
  52. package/stream.ts +134 -0
  53. package/sync.ts +604 -0
  54. package/testing.ts +263 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The asynchronous capture runner: for long-running commands, where a
3
+ * blocking `spawnSync` would freeze the event loop, and for running several
4
+ * commands at once. It uses the same types as the sync runner:
5
+ * {@link CommandResult}, {@link CommandRunOptions}, `CommandFailed`.
6
+ *
7
+ * Short commands run one after another are simpler on the sync runner:
8
+ * blocking costs them nothing and keeps their callers synchronous.
9
+ */
10
+ import { CommandFailed, type CommandResult, type CommandRunner, type CommandRunOptions } from './sync.ts';
11
+ /** {@link CommandRunOptions} plus cancellation, a spawn hook and process-group kill. */
12
+ export interface AsyncCommandRunOptions extends CommandRunOptions {
13
+ /**
14
+ * Abort the run: the child gets `SIGTERM`, then `SIGKILL` if it is still
15
+ * running 1 s later. `SIGTERM` first lets a child such as git remove its
16
+ * lock files. The promise resolves at once with `neverExited: true`,
17
+ * `reason: 'abort'` and `The operation was aborted` in `error`.
18
+ * The `SIGKILL` follow-up runs after the promise resolves. While a child
19
+ * ignores `SIGTERM`, it keeps the host alive for up to 1 s. An
20
+ * already-aborted signal stops the child the same way.
21
+ */
22
+ signal?: AbortSignal | undefined;
23
+ /**
24
+ * Called once with the child's pid right after it spawns, before the
25
+ * command finishes. Use it to record the process while it is still running,
26
+ * for example in a pid file. Not called when the spawn fails (there is no
27
+ * pid). If it throws, the child is killed and the run resolves with
28
+ * `neverExited: true`, `reason: 'abort'` and `onSpawn threw: <message>` in
29
+ * `error`.
30
+ */
31
+ onSpawn?: ((pid: number) => void) | undefined;
32
+ /**
33
+ * Run the child as its own process-group leader, and send the timeout or
34
+ * abort signals to the whole group, so processes it started die with it.
35
+ * When the child exits but a group member still holds its output pipes,
36
+ * the group gets `SIGKILL` once the exit grace ends. A member that does not
37
+ * hold the pipes is left running after a normal exit.
38
+ * The group no longer gets the terminal's Ctrl-C. To stop it on Ctrl-C,
39
+ * listen for `SIGINT` and abort through `signal`, or call
40
+ * `installGroupSignalForwarding`, which kills live groups when the host is
41
+ * signalled; once it is stopping the host, a new group run is refused
42
+ * (`neverExited: true`, `reason: 'abort'`). POSIX only.
43
+ */
44
+ processGroup?: boolean | undefined;
45
+ }
46
+ /** The async form of {@link CommandRunner}: `run` returns a promise. */
47
+ export interface AsyncCommandRunner {
48
+ /**
49
+ * Run the binary with `argv` (without the binary name). The promise never
50
+ * rejects for a failed command: a non-zero exit, a missing binary, a
51
+ * timeout or an abort all resolve to a {@link CommandResult}. Programmer
52
+ * errors reject: an invalid `timeoutMs` (see its doc) or a NUL byte in
53
+ * argv or env.
54
+ */
55
+ run(argv: string[], opts: AsyncCommandRunOptions): Promise<CommandResult>;
56
+ }
57
+ /**
58
+ * The injected async runner when there is one, else `spawnRunnerAsync(binary)`.
59
+ * The async version of `resolveRunner`.
60
+ *
61
+ * @param binary - The binary for the default runner.
62
+ * @param injected - A runner to use instead, such as a test fake.
63
+ */
64
+ export declare function resolveRunnerAsync(binary: string, injected?: AsyncCommandRunner | undefined): AsyncCommandRunner;
65
+ /**
66
+ * The default {@link AsyncCommandRunner}: runs `binary` with `spawn`. The
67
+ * promise never rejects for a failed command. A missing binary, a timeout or
68
+ * an abort resolves with a non-zero `exitCode`, `neverExited: true` and a
69
+ * `reason`, and `error` carries the runner's message.
70
+ * Programmer errors (an invalid `timeoutMs`, a NUL byte in argv or env)
71
+ * reject, and nothing is spawned.
72
+ *
73
+ * The result settles when the child's stdio closes, or 250 ms after the
74
+ * child exits if a process it started still holds its pipes.
75
+ * Either way `exitCode` is the child's own. In the second case the pipes
76
+ * are closed: the background process loses any later output and may die of
77
+ * `SIGPIPE` on its next write. With `processGroup`, the group is killed
78
+ * instead. A timeout settles on the deadline, not when the pipes close.
79
+ *
80
+ * Output is captured through pipes with no size cap. Under `bun test` with a
81
+ * large module graph loaded, Bun can return empty output from child pipes.
82
+ * The sync `spawnRunner` works around this; this runner does not.
83
+ *
84
+ * @param binary - A name looked up on `PATH`, or a path.
85
+ * @returns A runner for that binary.
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * const git = spawnRunnerAsync('git')
90
+ * const controller = new AbortController()
91
+ * const r = await git.run(['fetch', 'origin'], {
92
+ * cwd: '.',
93
+ * timeoutMs: 60_000,
94
+ * signal: controller.signal,
95
+ * })
96
+ * if (r.neverExited) console.error(r.error)
97
+ * ```
98
+ */
99
+ export declare function spawnRunnerAsync(binary: string): AsyncCommandRunner;
100
+ /**
101
+ * The async version of `runChecked`: run a command and reject if it fails.
102
+ * Like `runChecked`, it takes the runner's own options, so a `withDefaults`
103
+ * runner needs only the options it did not bind.
104
+ *
105
+ * @param argv - Arguments, without the binary name.
106
+ * @returns The result, when `exitCode` is 0.
107
+ * @throws `CommandFailed` (as a rejection) when `exitCode` is not 0.
108
+ */
109
+ export declare function runCheckedAsync(runner: AsyncCommandRunner, argv: string[], opts: AsyncCommandRunOptions): Promise<CommandResult>;
110
+ export declare function runCheckedAsync<P extends unknown[]>(runner: {
111
+ run(argv: string[], ...opts: P): Promise<CommandResult>;
112
+ }, argv: string[], ...opts: NoInfer<P>): Promise<CommandResult>;
113
+ /**
114
+ * Wrap a sync runner as an async one, so one test fake serves both forms.
115
+ * `signal`, `onSpawn` and `processGroup` are ignored: a sync runner cannot be
116
+ * cancelled mid-flight and reports no pid. A throw from the sync runner
117
+ * becomes a rejection.
118
+ *
119
+ * @param runner - The sync runner to wrap.
120
+ * @returns An async runner that calls `runner`.
121
+ */
122
+ export declare function asAsync(runner: CommandRunner): AsyncCommandRunner;
123
+ /**
124
+ * The async version of `mapFailed`: awaits `fn`, so a rejected
125
+ * `CommandFailed` is mapped. `mapper` may be sync or async, so it can await
126
+ * more context, such as a log file, before building the error.
127
+ *
128
+ * @param fn - The code to run, usually a `runCheckedAsync` call.
129
+ * @param mapper - Turns the failure into the error to reject with.
130
+ * @returns What `fn` resolves to.
131
+ * @throws The error `mapper` returns or resolves to, when `fn` rejects with
132
+ * `CommandFailed`. A rejection from `mapper` itself passes through.
133
+ * @experimental
134
+ */
135
+ export declare function mapFailedAsync<T>(fn: () => Promise<T>, mapper: (failed: CommandFailed) => Error | Promise<Error>): Promise<T>;
136
+ /**
137
+ * The async version of `catchFailed`: awaits `fn`. `onFailed` may be sync or
138
+ * async.
139
+ *
140
+ * @param fn - The code to run, usually a `runCheckedAsync` call.
141
+ * @param onFailed - Turns the failure into a value.
142
+ * @returns What `fn` resolves to, or what `onFailed` returns.
143
+ * @experimental
144
+ */
145
+ export declare function catchFailedAsync<T, R>(fn: () => Promise<T>, onFailed: (failed: CommandFailed) => R | Promise<R>): Promise<T | R>;
146
+ //# sourceMappingURL=async.d.ts.map
@@ -0,0 +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"}
package/dist/async.js ADDED
@@ -0,0 +1,368 @@
1
+ /**
2
+ * The asynchronous capture runner: for long-running commands, where a
3
+ * blocking `spawnSync` would freeze the event loop, and for running several
4
+ * commands at once. It uses the same types as the sync runner:
5
+ * {@link CommandResult}, {@link CommandRunOptions}, `CommandFailed`.
6
+ *
7
+ * Short commands run one after another are simpler on the sync runner:
8
+ * blocking costs them nothing and keeps their callers synchronous.
9
+ */
10
+ import { spawn } from 'node:child_process';
11
+ import { StringDecoder } from 'node:string_decoder';
12
+ import { groupExists, groupStartRefusal, releaseWhenEmpty, settleGroup, spawnTrackedGroup, } from "./groups.js";
13
+ import { CommandFailed, cwdError, mergedEnv, normalizeTimeout, spawnFailure, } from "./sync.js";
14
+ /**
15
+ * The injected async runner when there is one, else `spawnRunnerAsync(binary)`.
16
+ * The async version of `resolveRunner`.
17
+ *
18
+ * @param binary - The binary for the default runner.
19
+ * @param injected - A runner to use instead, such as a test fake.
20
+ */
21
+ export function resolveRunnerAsync(binary, injected) {
22
+ return injected ?? spawnRunnerAsync(binary);
23
+ }
24
+ /**
25
+ * How long to wait for stdio to close after the child has exited. A child
26
+ * that leaves a background process holding its pipes would otherwise keep
27
+ * the call open until that process exits.
28
+ */
29
+ const EXIT_GRACE_MS = 250;
30
+ /**
31
+ * How long an aborted child has between `SIGTERM` and `SIGKILL`. Long enough
32
+ * for git to remove its `.lock` files; short enough that a child that ignores
33
+ * `SIGTERM` still stops soon.
34
+ */
35
+ const ABORT_KILL_GRACE_MS = 1000;
36
+ /**
37
+ * The default {@link AsyncCommandRunner}: runs `binary` with `spawn`. The
38
+ * promise never rejects for a failed command. A missing binary, a timeout or
39
+ * an abort resolves with a non-zero `exitCode`, `neverExited: true` and a
40
+ * `reason`, and `error` carries the runner's message.
41
+ * Programmer errors (an invalid `timeoutMs`, a NUL byte in argv or env)
42
+ * reject, and nothing is spawned.
43
+ *
44
+ * The result settles when the child's stdio closes, or 250 ms after the
45
+ * child exits if a process it started still holds its pipes.
46
+ * Either way `exitCode` is the child's own. In the second case the pipes
47
+ * are closed: the background process loses any later output and may die of
48
+ * `SIGPIPE` on its next write. With `processGroup`, the group is killed
49
+ * instead. A timeout settles on the deadline, not when the pipes close.
50
+ *
51
+ * Output is captured through pipes with no size cap. Under `bun test` with a
52
+ * large module graph loaded, Bun can return empty output from child pipes.
53
+ * The sync `spawnRunner` works around this; this runner does not.
54
+ *
55
+ * @param binary - A name looked up on `PATH`, or a path.
56
+ * @returns A runner for that binary.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * const git = spawnRunnerAsync('git')
61
+ * const controller = new AbortController()
62
+ * const r = await git.run(['fetch', 'origin'], {
63
+ * cwd: '.',
64
+ * timeoutMs: 60_000,
65
+ * signal: controller.signal,
66
+ * })
67
+ * if (r.neverExited) console.error(r.error)
68
+ * ```
69
+ */
70
+ export function spawnRunnerAsync(binary) {
71
+ return {
72
+ run(argv, opts) {
73
+ return new Promise((resolve) => {
74
+ // Inside the executor, so an invalid timeoutMs (or a throw from spawn)
75
+ // rejects and nothing is spawned.
76
+ const timeoutMs = normalizeTimeout(opts.timeoutMs);
77
+ const cwdMessage = cwdError(`spawn ${binary}`, binary, argv, opts);
78
+ if (cwdMessage !== undefined) {
79
+ resolve(spawnFailure(cwdMessage));
80
+ return;
81
+ }
82
+ const group = opts.processGroup === true;
83
+ const refusal = group ? groupStartRefusal() : undefined;
84
+ if (refusal !== undefined) {
85
+ resolve({
86
+ exitCode: 1,
87
+ neverExited: true,
88
+ reason: 'abort',
89
+ stdout: '',
90
+ stderr: '',
91
+ error: refusal,
92
+ });
93
+ return;
94
+ }
95
+ // Abort is handled below rather than through spawn's `signal` option,
96
+ // which sends one SIGTERM to the direct child only: no SIGKILL
97
+ // follow-up and no group.
98
+ const start = () => spawn(binary, argv, {
99
+ cwd: opts.cwd,
100
+ detached: group,
101
+ ...mergedEnv(opts),
102
+ });
103
+ const { child, untrack: untrackGroup } = group
104
+ ? spawnTrackedGroup(start)
105
+ : { child: start(), untrack: undefined };
106
+ // Set once this runner signals the child: a group it is stopping stays
107
+ // in flight for the host's exit handler.
108
+ let stopping = false;
109
+ const signalChild = (sig) => {
110
+ stopping = true;
111
+ if (group && child.pid !== undefined) {
112
+ try {
113
+ process.kill(-child.pid, sig);
114
+ return;
115
+ }
116
+ catch {
117
+ // The group is already gone; fall through to the direct child.
118
+ }
119
+ }
120
+ child.kill(sig);
121
+ };
122
+ const hardKill = () => signalChild('SIGKILL');
123
+ const groupGone = () => child.pid === undefined || !groupExists(child.pid);
124
+ let stdout = '';
125
+ let stderr = '';
126
+ let stdinError;
127
+ let settled = false;
128
+ let timer;
129
+ let graceTimer;
130
+ let abortKillTimer;
131
+ // Set once the child exits; a later timeout or abort reports this
132
+ // rather than a stand-in, since the command did answer.
133
+ let exited;
134
+ const onAbort = () => {
135
+ signalChild('SIGTERM');
136
+ // Outlives the settle on purpose. Not unref'd: an unref'd timer
137
+ // does not reliably fire on every Bun version. Cleared when the
138
+ // child exits, or in group mode when the group is already gone by
139
+ // then.
140
+ if (exited === undefined || (group && !groupGone())) {
141
+ abortKillTimer = setTimeout(hardKill, ABORT_KILL_GRACE_MS);
142
+ }
143
+ if (exited !== undefined)
144
+ finish(exited, stdinError?.message);
145
+ else
146
+ finish({ exitCode: 1, neverExited: true, reason: 'abort' }, 'The operation was aborted');
147
+ };
148
+ // Decoded through a StringDecoder rather than per-chunk `toString()`:
149
+ // a multi-byte character straddling a chunk boundary would otherwise
150
+ // be decoded as two partial sequences and each half replaced with
151
+ // U+FFFD. The decoder holds the partial sequence back until the rest
152
+ // of it arrives. (Both sync paths decode the whole buffer, or the
153
+ // whole file, at once, so they do not need this.)
154
+ const outDecoder = new StringDecoder('utf8');
155
+ const errDecoder = new StringDecoder('utf8');
156
+ /**
157
+ * Settle once, with the streams flushed. `end()` is what surfaces a
158
+ * sequence the decoder is still holding back when the output ends
159
+ * mid-character — truncated capture, or bytes that were never UTF-8:
160
+ * unflushed those bytes are dropped, so the result is shorter than
161
+ * what the child wrote and disagrees with the sync runner. Every settle
162
+ * path routes through here, so one flush covers every path.
163
+ */
164
+ const finish = (result, error) => {
165
+ if (settled)
166
+ return;
167
+ settled = true;
168
+ // The group can outlive the settle: a background member, or an aborted
169
+ // group waiting for its SIGKILL. It stays registered until it is empty,
170
+ // so a signal to the host still reaches the rest. Unless this runner
171
+ // is stopping it, the host's exit leaves it running.
172
+ if (untrackGroup !== undefined && child.pid !== undefined) {
173
+ if (!stopping)
174
+ settleGroup(child.pid);
175
+ releaseWhenEmpty(child.pid, untrackGroup);
176
+ }
177
+ if (timer !== undefined)
178
+ clearTimeout(timer);
179
+ if (graceTimer !== undefined)
180
+ clearTimeout(graceTimer);
181
+ opts.signal?.removeEventListener('abort', onAbort);
182
+ stdout += outDecoder.end();
183
+ stderr += errDecoder.end();
184
+ // Close the read ends. On `close` this is a no-op. On the other
185
+ // paths a surviving grandchild may still hold the write ends and
186
+ // keep writing. Handlers left installed would go on appending to
187
+ // `stdout` / `stderr` after this promise resolved: an unbounded
188
+ // heap leak in a long-lived host, which also keeps the grandchild
189
+ // alive by draining what it writes.
190
+ child.stdout?.destroy();
191
+ child.stderr?.destroy();
192
+ resolve({
193
+ ...result,
194
+ stdout,
195
+ stderr,
196
+ ...(error ? { error } : {}),
197
+ });
198
+ };
199
+ child.stdout?.on('data', (d) => {
200
+ stdout += outDecoder.write(d);
201
+ });
202
+ child.stderr?.on('data', (d) => {
203
+ stderr += errDecoder.write(d);
204
+ });
205
+ child.on('error', (err) => {
206
+ // A spawn failure: no exit code was ever reported, so the 1 is a
207
+ // stand-in and the result says so.
208
+ finish({ exitCode: 1, neverExited: true, reason: 'spawn' }, err.message);
209
+ });
210
+ const fromExit = (code, signal) => ({
211
+ exitCode: code ?? 1,
212
+ ...(code === null
213
+ ? {
214
+ neverExited: true,
215
+ reason: 'signal',
216
+ ...(signal === null ? {} : { signal }),
217
+ }
218
+ : {}),
219
+ });
220
+ child.on('exit', (code, signal) => {
221
+ exited = fromExit(code, signal);
222
+ // A group can outlive its leader; keep the follow-up while it does.
223
+ if (abortKillTimer !== undefined && (!group || groupGone())) {
224
+ clearTimeout(abortKillTimer);
225
+ }
226
+ if (settled)
227
+ return;
228
+ const result = exited;
229
+ // `close` normally follows at once. If it does not, a process the
230
+ // child started still holds the pipes. The immediate lets output
231
+ // already in the pipes be read before settling.
232
+ graceTimer = setTimeout(() => setImmediate(() => {
233
+ // A group member holds the pipes. Without a group there is no
234
+ // handle on it: the direct child is gone, and only `finish`
235
+ // closing the pipes reaches the background process.
236
+ if (group && !settled)
237
+ hardKill();
238
+ finish(result, stdinError?.message);
239
+ }), EXIT_GRACE_MS);
240
+ });
241
+ child.on('close', (code, signal) => {
242
+ finish(fromExit(code, signal), stdinError?.message);
243
+ });
244
+ // `child.stdin` is its own emitter — an `error` on it never reaches the
245
+ // `child.on("error")` handler above, and an unhandled `error` on an
246
+ // emitter is rethrown as an uncaught exception that takes the host
247
+ // process down. A child that closes its read end before `input` drains
248
+ // (it exits, or closes fd 0) fails the write (EPIPE, or ENOTCONN under
249
+ // some timings). That is not a command failure: the child ran, and its
250
+ // own exit code is the result.
251
+ // But it does mean the child read only part of `input`, so the message
252
+ // rides out in `error`, the way a spawn error already does. (A write
253
+ // error that lands after the promise settles is only swallowed —
254
+ // nothing left to report it on.)
255
+ child.stdin?.on('error', (err) => {
256
+ stdinError = err;
257
+ });
258
+ if (child.pid !== undefined && opts.onSpawn !== undefined) {
259
+ try {
260
+ opts.onSpawn(child.pid);
261
+ }
262
+ catch (err) {
263
+ // Called once every handler is installed, so a throw here can
264
+ // still kill the child and settle rather than reject and leave
265
+ // it running untracked.
266
+ hardKill();
267
+ const message = err instanceof Error ? err.message : String(err);
268
+ finish({ exitCode: 1, neverExited: true, reason: 'abort' }, `onSpawn threw: ${message}`);
269
+ }
270
+ }
271
+ if (!settled) {
272
+ if (opts.signal?.aborted === true)
273
+ onAbort();
274
+ else
275
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
276
+ }
277
+ if (!settled && timeoutMs !== undefined) {
278
+ // The timeout is enforced here rather than handed to spawn's own
279
+ // `timeout` option, which is a no-op on some Bun versions. Not
280
+ // unref'd: an unref'd timer does not reliably fire on every Bun
281
+ // version, and `finish` clears it, so it never outlives the call.
282
+ timer = setTimeout(() => {
283
+ // SIGKILL, not the default SIGTERM: the contract is a hard cap and
284
+ // the child is by assumption already hung, so a signal it may trap
285
+ // or ignore is no cap at all. This matches the sync and streaming
286
+ // runners, which cannot escalate. An abort, by contrast, is a
287
+ // request to stop and sends SIGTERM first.
288
+ hardKill();
289
+ if (exited !== undefined) {
290
+ finish(exited, stdinError?.message);
291
+ return;
292
+ }
293
+ // Settle here rather than leaving it to `close`. `close` waits for
294
+ // the stdio pipes to hit EOF, and a forked grandchild survives the
295
+ // kill still holding the write ends. So resolve on the deadline
296
+ // with the output captured so far.
297
+ finish({ exitCode: 1, neverExited: true, reason: 'timeout' }, `timed out after ${timeoutMs}ms`);
298
+ }, timeoutMs);
299
+ }
300
+ if (opts.input !== undefined)
301
+ child.stdin?.end(opts.input);
302
+ else
303
+ child.stdin?.end();
304
+ });
305
+ },
306
+ };
307
+ }
308
+ export async function runCheckedAsync(runner, argv, ...opts) {
309
+ const result = await runner.run(argv, ...opts);
310
+ if (result.exitCode !== 0)
311
+ throw new CommandFailed(argv, result);
312
+ return result;
313
+ }
314
+ /**
315
+ * Wrap a sync runner as an async one, so one test fake serves both forms.
316
+ * `signal`, `onSpawn` and `processGroup` are ignored: a sync runner cannot be
317
+ * cancelled mid-flight and reports no pid. A throw from the sync runner
318
+ * becomes a rejection.
319
+ *
320
+ * @param runner - The sync runner to wrap.
321
+ * @returns An async runner that calls `runner`.
322
+ */
323
+ export function asAsync(runner) {
324
+ return {
325
+ run: async (argv, opts) => runner.run(argv, opts),
326
+ };
327
+ }
328
+ /**
329
+ * The async version of `mapFailed`: awaits `fn`, so a rejected
330
+ * `CommandFailed` is mapped. `mapper` may be sync or async, so it can await
331
+ * more context, such as a log file, before building the error.
332
+ *
333
+ * @param fn - The code to run, usually a `runCheckedAsync` call.
334
+ * @param mapper - Turns the failure into the error to reject with.
335
+ * @returns What `fn` resolves to.
336
+ * @throws The error `mapper` returns or resolves to, when `fn` rejects with
337
+ * `CommandFailed`. A rejection from `mapper` itself passes through.
338
+ * @experimental
339
+ */
340
+ export async function mapFailedAsync(fn, mapper) {
341
+ try {
342
+ return await fn();
343
+ }
344
+ catch (e) {
345
+ if (e instanceof CommandFailed)
346
+ throw await mapper(e);
347
+ throw e;
348
+ }
349
+ }
350
+ /**
351
+ * The async version of `catchFailed`: awaits `fn`. `onFailed` may be sync or
352
+ * async.
353
+ *
354
+ * @param fn - The code to run, usually a `runCheckedAsync` call.
355
+ * @param onFailed - Turns the failure into a value.
356
+ * @returns What `fn` resolves to, or what `onFailed` returns.
357
+ * @experimental
358
+ */
359
+ export async function catchFailedAsync(fn, onFailed) {
360
+ try {
361
+ return await fn();
362
+ }
363
+ catch (e) {
364
+ if (e instanceof CommandFailed)
365
+ return onFailed(e);
366
+ throw e;
367
+ }
368
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `withDefaults`: bind default options to a runner, so code that runs many
3
+ * commands in one folder does not repeat `{ cwd, env, timeoutMs }` on every
4
+ * call.
5
+ */
6
+ /** Any runner: a `run(first, opts)` method. Every runner in this package fits. */
7
+ export interface AnyRunner {
8
+ run(arg: never, opts: never): unknown;
9
+ }
10
+ /** The options type of runner `Rn`. */
11
+ type RunOptions<Rn extends AnyRunner> = Parameters<Rn['run']>[1];
12
+ /** Per-call options once the keys `K` have defaults: those keys become optional. */
13
+ export type DefaultedOptions<O, K extends keyof O> = Omit<O, K> & Partial<Pick<O, K>>;
14
+ /**
15
+ * Runner `Rn` with defaults bound for the option keys `K`, as
16
+ * `withDefaults` returns it: `DefaultedRunner<CommandRunner, 'cwd'>`. `opts`
17
+ * may be left out when the defaults cover every required option. It is
18
+ * still assignable to `Rn`, so it can be passed to code that takes one.
19
+ *
20
+ * @experimental
21
+ */
22
+ export interface DefaultedRunner<Rn extends AnyRunner, K extends keyof RunOptions<Rn> = never> {
23
+ 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
+ }
25
+ /**
26
+ * Return a runner that fills in `defaults` on every call. A per-call option
27
+ * replaces the default of the same name, except `env`: a per-call `env` is
28
+ * merged over the bound one, key by key, and a per-call `undefined` value
29
+ * removes a bound variable. A per-call option set to `undefined` counts as
30
+ * not given, so the default stays.
31
+ *
32
+ * Works with every runner type: capture, async, shell, streaming and
33
+ * managed. `runChecked`, `runCheckedAsync`, `runJson` and `runJsonAsync`
34
+ * take the result too. The defaults are shallow-copied when bound.
35
+ *
36
+ * @param runner - The runner to wrap.
37
+ * @param defaults - Options applied to every call.
38
+ * @returns A runner with the same `run`, whose options are now optional
39
+ * where `defaults` covers them.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * const git = withDefaults(spawnRunner('git'), { cwd: repo, timeoutMs: 30_000 })
44
+ * git.run(['status'])
45
+ * git.run(['fetch'], { timeoutMs: 120_000 })
46
+ * ```
47
+ *
48
+ * @experimental
49
+ */
50
+ export declare function withDefaults<Rn extends AnyRunner, K extends keyof RunOptions<Rn> = never>(runner: Rn, defaults: Pick<RunOptions<Rn>, K> & Partial<RunOptions<Rn>>): DefaultedRunner<Rn, K>;
51
+ export {};
52
+ //# sourceMappingURL=defaults.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `withDefaults`: bind default options to a runner, so code that runs many
3
+ * commands in one folder does not repeat `{ cwd, env, timeoutMs }` on every
4
+ * call.
5
+ */
6
+ /**
7
+ * Return a runner that fills in `defaults` on every call. A per-call option
8
+ * replaces the default of the same name, except `env`: a per-call `env` is
9
+ * merged over the bound one, key by key, and a per-call `undefined` value
10
+ * removes a bound variable. A per-call option set to `undefined` counts as
11
+ * not given, so the default stays.
12
+ *
13
+ * Works with every runner type: capture, async, shell, streaming and
14
+ * managed. `runChecked`, `runCheckedAsync`, `runJson` and `runJsonAsync`
15
+ * take the result too. The defaults are shallow-copied when bound.
16
+ *
17
+ * @param runner - The runner to wrap.
18
+ * @param defaults - Options applied to every call.
19
+ * @returns A runner with the same `run`, whose options are now optional
20
+ * where `defaults` covers them.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * const git = withDefaults(spawnRunner('git'), { cwd: repo, timeoutMs: 30_000 })
25
+ * git.run(['status'])
26
+ * git.run(['fetch'], { timeoutMs: 120_000 })
27
+ * ```
28
+ *
29
+ * @experimental
30
+ */
31
+ export function withDefaults(runner, defaults) {
32
+ const bound = { ...defaults };
33
+ const target = runner;
34
+ return {
35
+ run(arg, opts) {
36
+ const merged = { ...bound };
37
+ for (const [key, value] of Object.entries(opts ?? {})) {
38
+ if (value === undefined)
39
+ continue;
40
+ merged[key] =
41
+ key === 'env' && isObject(value) && isObject(bound.env)
42
+ ? { ...bound.env, ...value }
43
+ : value;
44
+ }
45
+ return target.run(arg, merged);
46
+ },
47
+ };
48
+ }
49
+ function isObject(v) {
50
+ return typeof v === 'object' && v !== null;
51
+ }