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