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