@osdlc/command-seam 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/CONTRIBUTING.md +61 -0
  3. package/LICENSE +21 -0
  4. package/README.md +741 -2
  5. package/SECURITY.md +23 -0
  6. package/async.ts +466 -0
  7. package/defaults.ts +83 -0
  8. package/dist/async.d.ts +146 -0
  9. package/dist/async.d.ts.map +1 -0
  10. package/dist/async.js +368 -0
  11. package/dist/defaults.d.ts +52 -0
  12. package/dist/defaults.d.ts.map +1 -0
  13. package/dist/defaults.js +51 -0
  14. package/dist/groups.d.ts +155 -0
  15. package/dist/groups.d.ts.map +1 -0
  16. package/dist/groups.js +331 -0
  17. package/dist/index.d.ts +30 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +29 -0
  20. package/dist/process.d.ts +146 -0
  21. package/dist/process.d.ts.map +1 -0
  22. package/dist/process.js +175 -0
  23. package/dist/process.test.d.ts +1 -0
  24. package/dist/process.test.d.ts.map +1 -0
  25. package/dist/process.test.js +1 -0
  26. package/dist/quote.d.ts +18 -0
  27. package/dist/quote.d.ts.map +1 -0
  28. package/dist/quote.js +28 -0
  29. package/dist/result.d.ts +211 -0
  30. package/dist/result.d.ts.map +1 -0
  31. package/dist/result.js +219 -0
  32. package/dist/shell.d.ts +84 -0
  33. package/dist/shell.d.ts.map +1 -0
  34. package/dist/shell.js +83 -0
  35. package/dist/stream.d.ts +90 -0
  36. package/dist/stream.d.ts.map +1 -0
  37. package/dist/stream.js +73 -0
  38. package/dist/sync.d.ts +311 -0
  39. package/dist/sync.d.ts.map +1 -0
  40. package/dist/sync.js +406 -0
  41. package/dist/testing.d.ts +158 -0
  42. package/dist/testing.d.ts.map +1 -0
  43. package/dist/testing.js +173 -0
  44. package/groups.ts +360 -0
  45. package/index.ts +98 -0
  46. package/package.json +94 -3
  47. package/process.ts +270 -0
  48. package/quote.ts +31 -0
  49. package/result.ts +403 -0
  50. package/shell.ts +131 -0
  51. package/smoke.mjs +61 -0
  52. package/stream.ts +134 -0
  53. package/sync.ts +604 -0
  54. package/testing.ts +263 -0
package/result.ts ADDED
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Helpers for reading a {@link CommandResult}: did it run and succeed, what
3
+ * to tell a person, and its stdout as JSON.
4
+ *
5
+ * None of them throw for a failed command: a failure stays a value the
6
+ * caller reads. Use `runChecked` when stopping on failure is the only
7
+ * sensible response.
8
+ */
9
+
10
+ import type { AsyncCommandRunner, AsyncCommandRunOptions } from './async.ts'
11
+ import type { CommandResult, CommandRunner, CommandRunOptions } from './sync.ts'
12
+
13
+ /**
14
+ * Whether the process started and exited 0. `exitCode` alone is not enough:
15
+ * a spawn failure, timeout or abort reports a stand-in `1`, and a caller that
16
+ * reads exit codes as answers must rule that out first.
17
+ *
18
+ * Takes any runner's result: a `CommandResult`, a `StreamResult` or a
19
+ * `ManagedProcessExit`.
20
+ *
21
+ * @returns `true` when `neverExited` is not set and `exitCode` is 0.
22
+ */
23
+ export function ran(result: { exitCode: number; neverExited?: boolean | undefined }): boolean {
24
+ return result.neverExited !== true && result.exitCode === 0
25
+ }
26
+
27
+ /**
28
+ * The child's trimmed stderr, else the runner's trimmed `error` (a failed
29
+ * spawn, a timeout, an abort), else `''`. Use it where a failure message reads
30
+ * stderr: since the runner's message is only in `error`, reading `stderr`
31
+ * alone shows nothing when the command never ran.
32
+ *
33
+ * @param result - A run's result.
34
+ * @returns The text, trimmed; `''` when both are empty.
35
+ * @experimental
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * const r = spawnRunner('git').run(['fetch'], { cwd: '.' })
40
+ * if (!ran(r)) console.error(`fetch failed: ${stderrOrError(r)}`)
41
+ * ```
42
+ */
43
+ export function stderrOrError(result: Pick<CommandResult, 'stderr' | 'error'>): string {
44
+ return result.stderr.trim() || (result.error?.trim() ?? '')
45
+ }
46
+
47
+ /** Options for {@link failureText}. */
48
+ export interface FailureTextOptions {
49
+ /**
50
+ * Return both streams instead of one: trimmed stderr (or the runner's
51
+ * `error`, when the child wrote no stderr) and trimmed stdout,
52
+ * newline-joined, empty ones dropped. Empty when stderr, `error` and stdout
53
+ * are all empty; there is no `exit N` fallback. This is what `CommandFailed.detail`
54
+ * returns. Use it when both halves matter, such as a git hook that rejects
55
+ * a commit: git's own error is on stderr, the hook's reason on stdout.
56
+ */
57
+ both?: boolean | undefined
58
+ }
59
+
60
+ /**
61
+ * The failure text to show a person. By default it picks one stream, which
62
+ * suits a one-line message: trimmed stderr, else the runner's trimmed
63
+ * `error` (a failed spawn, a timeout), else trimmed stdout (some commands
64
+ * explain a failure there), else `exit N`. With `both: true` it
65
+ * joins both streams (see {@link FailureTextOptions.both}).
66
+ *
67
+ * @param result - The result of a failed run.
68
+ * @param opts - `both: true` to join both streams.
69
+ * @returns The message. Non-empty unless `both` is set and there is no text
70
+ * at all.
71
+ */
72
+ export function failureText(
73
+ result: Pick<CommandResult, 'exitCode' | 'stdout' | 'stderr' | 'error'>,
74
+ opts: FailureTextOptions = {},
75
+ ): string {
76
+ const stderr = stderrOrError(result)
77
+ const stdout = result.stdout.trim()
78
+ if (opts.both === true) return [stderr, stdout].filter(Boolean).join('\n')
79
+ return stderr || stdout || `exit ${result.exitCode}`
80
+ }
81
+
82
+ /**
83
+ * The first non-blank line of `text`, trimmed; `fallback` when there is
84
+ * none. For short diagnostics from multi-line tool output.
85
+ *
86
+ * @param text - Usually `stdout` or `stderr`.
87
+ * @param fallback - Returned when every line is blank. Defaults to `''`.
88
+ * @experimental
89
+ */
90
+ export function firstLine(text: string, fallback = ''): string {
91
+ return (
92
+ text
93
+ .split('\n')
94
+ .map((l) => l.trim())
95
+ .find((l) => l !== '') ?? fallback
96
+ )
97
+ }
98
+
99
+ /**
100
+ * Outcome of {@link runJson}: a parsed value, or why there is none. Two
101
+ * fields are named `reason`: `j.reason` says why there is no value (`failed`,
102
+ * `unparseable` or `invalid`), and `j.result.reason` says why the command
103
+ * never finished, when it did not (see `NeverExitedReason`).
104
+ *
105
+ * @experimental
106
+ */
107
+ export type JsonRun<T> =
108
+ | { ok: true; value: T; result: CommandResult }
109
+ | {
110
+ ok: false
111
+ /**
112
+ * `failed`: did not run, or exited with a code not in `okExitCodes`.
113
+ * `unparseable`: exited with an accepted code, and stdout was not JSON.
114
+ */
115
+ reason: 'failed' | 'unparseable'
116
+ /** A human-readable explanation (the failure text, or the parse error). */
117
+ message: string
118
+ result: CommandResult
119
+ }
120
+ | {
121
+ ok: false
122
+ /** `invalid`: stdout was JSON, and the `parse` option threw for it. */
123
+ reason: 'invalid'
124
+ /** `stdout JSON was rejected: ` and the thrown error's message. */
125
+ message: string
126
+ /** What `parse` threw, such as a schema library's error with its issue list. */
127
+ cause: unknown
128
+ result: CommandResult
129
+ }
130
+
131
+ /**
132
+ * Options for {@link runJson}, apart from `parse`, which is typed by the
133
+ * value it returns. A value typed `RunJsonOptions` leaves `T` to the caller.
134
+ *
135
+ * @experimental
136
+ */
137
+ export interface RunJsonOptions {
138
+ /**
139
+ * Skip leading non-JSON output (log lines, banners). The JSON is the rest of
140
+ * stdout from the first line (after `\n` or `\r`) that starts with `{` or
141
+ * `[` and parses to the end, so a `[INFO] ...` prefix is skipped too.
142
+ * Trailing output is not handled. Off by default: strict `JSON.parse` of
143
+ * the whole stdout.
144
+ */
145
+ tolerateLeadingLog?: boolean | undefined
146
+ /**
147
+ * Exit codes that count as an answer, for a tool that prints JSON and
148
+ * exits non-zero on purpose (`npm outdated --json` exits 1 when anything is
149
+ * outdated). Default `[0]`. List `0` too if it still counts. A run that
150
+ * never exited is `failed` whatever its stand-in code. Output from an
151
+ * accepted non-zero exit must still parse, so pass a `parse` that rejects
152
+ * the tool's own error output.
153
+ */
154
+ okExitCodes?: readonly number[] | undefined
155
+ }
156
+
157
+ /** The `parse` option of `runJson` and its siblings. `T` is what it returns. */
158
+ interface ParseOption<T> {
159
+ /**
160
+ * Check the parsed value and return it typed, such as a schema's `parse`.
161
+ * A throw makes the outcome `invalid`. It must be sync: a returned promise
162
+ * is also `invalid`. Without it, `T` is the caller's claim and is not
163
+ * checked.
164
+ */
165
+ parse?: ((value: unknown) => T) | undefined
166
+ }
167
+
168
+ /** Options with an optional `parse`. */
169
+ type JsonOptions<T> = RunJsonOptions & ParseOption<T>
170
+
171
+ /**
172
+ * Parse a finished run's stdout as JSON, if it ran and exited with a code in
173
+ * `okExitCodes` (default `[0]`). Without the `parse` option, `T` is the
174
+ * caller's claim about the shape and is not checked; pass `parse` when the
175
+ * producer is not trusted. Never throws.
176
+ *
177
+ * @param result - A finished run.
178
+ * @param opts - Parse options.
179
+ * @returns `{ ok: true, value }`, or `{ ok: false, reason, message }` with
180
+ * `reason` `failed` (did not run, or exited with a code not accepted),
181
+ * `unparseable`, or `invalid` (`parse` threw; `cause` holds what it threw).
182
+ * @experimental
183
+ */
184
+ export function parseJsonResult<T = unknown>(
185
+ result: CommandResult,
186
+ opts: JsonOptions<T> = {},
187
+ ): JsonRun<T> {
188
+ const okExitCodes = opts.okExitCodes ?? [0]
189
+ if (result.neverExited === true || !okExitCodes.includes(result.exitCode)) {
190
+ return { ok: false, reason: 'failed', message: failureText(result), result }
191
+ }
192
+ const parsed = tryParse(result.stdout, opts.tolerateLeadingLog === true)
193
+ if (!parsed.ok) {
194
+ return {
195
+ ok: false,
196
+ reason: 'unparseable',
197
+ message: `stdout is not JSON: ${parsed.error}`,
198
+ result,
199
+ }
200
+ }
201
+ if (opts.parse === undefined) return { ok: true, value: parsed.value as T, result }
202
+ let value: T
203
+ try {
204
+ value = opts.parse(parsed.value)
205
+ } catch (e) {
206
+ const why = e instanceof Error ? e.message : String(e)
207
+ return {
208
+ ok: false,
209
+ reason: 'invalid',
210
+ message: `stdout JSON was rejected: ${why}`,
211
+ cause: e,
212
+ result,
213
+ }
214
+ }
215
+ if (isThenable(value)) {
216
+ // An async `parse` (such as `parseAsync`): its verdict would arrive too
217
+ // late, so say so, and keep its rejection from going unhandled.
218
+ value.then(undefined, () => {})
219
+ return {
220
+ ok: false,
221
+ reason: 'invalid',
222
+ message: 'stdout JSON was rejected: parse returned a promise; it must be sync',
223
+ cause: value,
224
+ result,
225
+ }
226
+ }
227
+ return { ok: true, value, result }
228
+ }
229
+
230
+ function isThenable(v: unknown): v is PromiseLike<unknown> {
231
+ return (
232
+ (typeof v === 'object' || typeof v === 'function') &&
233
+ v !== null &&
234
+ typeof (v as { then?: unknown }).then === 'function'
235
+ )
236
+ }
237
+
238
+ function tryParse(
239
+ stdout: string,
240
+ tolerateLeadingLog: boolean,
241
+ ): { ok: true; value: unknown } | { ok: false; error: string } {
242
+ try {
243
+ return { ok: true, value: JSON.parse(stdout) }
244
+ } catch (e) {
245
+ const start = tolerateLeadingLog ? jsonStart(stdout) : -1
246
+ if (start > 0) {
247
+ try {
248
+ return { ok: true, value: JSON.parse(stdout.slice(start)) }
249
+ } catch {
250
+ // Report the whole-stdout error below.
251
+ }
252
+ }
253
+ return { ok: false, error: (e as Error).message }
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Where the JSON starts for `tolerateLeadingLog`: the line starting with `{` or
259
+ * `[` whose brackets stay open until the last bracket in stdout closes them.
260
+ * Only that line's suffix can parse, so stdout is parsed once more at most.
261
+ * A JSON string holds no raw line break, so string state resets at each one.
262
+ * `-1` when no line qualifies.
263
+ */
264
+ function jsonStart(stdout: string): number {
265
+ // The bracket depth after each bracket outside a string, in order.
266
+ const depths: number[] = []
267
+ // Each candidate line: where it starts, its first bracket's index in
268
+ // `depths`, and the depth before that bracket.
269
+ const lines: { at: number; first: number; before: number }[] = []
270
+ let depth = 0
271
+ let inString = false
272
+ for (let i = 0; i < stdout.length; i++) {
273
+ const c = stdout[i]
274
+ if (c === '\n' || c === '\r') {
275
+ inString = false
276
+ } else if (inString) {
277
+ if (c === '"') inString = false
278
+ else if (c === '\\' && stdout[i + 1] !== '\n' && stdout[i + 1] !== '\r') i++
279
+ } else if (c === '"') {
280
+ inString = true
281
+ } else if (c === '{' || c === '[') {
282
+ const prev = stdout[i - 1]
283
+ if (prev === '\n' || prev === '\r') lines.push({ at: i, first: depths.length, before: depth })
284
+ depths.push(++depth)
285
+ } else if (c === '}' || c === ']') {
286
+ depths.push(--depth)
287
+ }
288
+ }
289
+ const end = depths.at(-1)
290
+ // The lowest depth from each bracket on, leaving out the last bracket.
291
+ let low = Infinity
292
+ let next = depths.length - 2
293
+ for (let k = lines.length - 1; k >= 0; k--) {
294
+ const { at, first, before } = lines[k]!
295
+ for (; next >= first; next--) low = Math.min(low, depths[next]!)
296
+ if (before === end && low > before) return at
297
+ }
298
+ return -1
299
+ }
300
+
301
+ /**
302
+ * The runner's options tuple, `[opts]` or `[opts?]`, with the `runJson`
303
+ * options added. A conditional rather than a mapped type, so `T` can be inferred
304
+ * from `parse`; `NoInfer` keeps `P` inferred from the runner alone.
305
+ */
306
+ type WithJsonOptions<P extends unknown[], T> = P extends [opts: infer O]
307
+ ? [opts: NoInfer<O> & JsonOptions<T>]
308
+ : P extends [opts?: infer O]
309
+ ? [opts?: NoInfer<O> & JsonOptions<T>]
310
+ : never
311
+
312
+ /**
313
+ * Run a command with a sync runner and parse its stdout as JSON
314
+ * ({@link parseJsonResult}). Never throws for a failed command; a programmer
315
+ * error, such as an invalid `timeoutMs`, throws. With the `parse` option,
316
+ * `T` is what `parse` returns.
317
+ *
318
+ * Takes the runner's own options, so a `withDefaults` runner needs only the
319
+ * options it did not bind.
320
+ *
321
+ * @param runner - Runs the command; pass a fake in tests.
322
+ * @param argv - Arguments, without the binary name.
323
+ * @param opts - Per-call options and {@link RunJsonOptions}.
324
+ * @returns A {@link JsonRun}: the parsed value, or why there is none.
325
+ *
326
+ * @example
327
+ * ```ts
328
+ * const gh = spawnRunner('gh')
329
+ * const r = runJson<{ login: string }>(gh, ['api', 'user'], { cwd: '.' })
330
+ * if (r.ok) console.log(r.value.login)
331
+ * else console.error(r.message)
332
+ * ```
333
+ *
334
+ * @experimental
335
+ */
336
+ export function runJson<T = unknown>(
337
+ runner: CommandRunner,
338
+ argv: string[],
339
+ opts: CommandRunOptions & JsonOptions<T>,
340
+ ): JsonRun<T>
341
+ // A runner that needs no options, such as one with `cwd` bound. Its only type
342
+ // parameter is `T`, so `runJson<T>(bound, argv)` compiles: naming `T` on the
343
+ // generic overload below would also fix `P` to its default.
344
+ export function runJson<T = unknown>(
345
+ runner: { run(argv: string[]): CommandResult },
346
+ argv: string[],
347
+ opts?: Partial<CommandRunOptions> & JsonOptions<T>,
348
+ ): JsonRun<T>
349
+ export function runJson<T = unknown, P extends unknown[] = [opts: CommandRunOptions]>(
350
+ runner: { run(argv: string[], ...opts: P): CommandResult },
351
+ argv: string[],
352
+ ...opts: WithJsonOptions<P, T>
353
+ ): JsonRun<T>
354
+ export function runJson<T>(
355
+ runner: { run(argv: string[], ...opts: unknown[]): CommandResult },
356
+ argv: string[],
357
+ ...opts: unknown[]
358
+ ): JsonRun<T> {
359
+ const { run, json } = splitJsonOptions<T>(opts)
360
+ return parseJsonResult<T>(runner.run(argv, ...run), json)
361
+ }
362
+
363
+ /**
364
+ * The async version of {@link runJson}. Never rejects for a failed command;
365
+ * a programmer error, such as an invalid `timeoutMs`, rejects. `parse` is
366
+ * sync, as in `runJson`.
367
+ *
368
+ * @experimental
369
+ */
370
+ export async function runJsonAsync<T = unknown>(
371
+ runner: AsyncCommandRunner,
372
+ argv: string[],
373
+ opts: AsyncCommandRunOptions & JsonOptions<T>,
374
+ ): Promise<JsonRun<T>>
375
+ // A runner that needs no options; see the same overload of `runJson`.
376
+ export async function runJsonAsync<T = unknown>(
377
+ runner: { run(argv: string[]): Promise<CommandResult> },
378
+ argv: string[],
379
+ opts?: Partial<AsyncCommandRunOptions> & JsonOptions<T>,
380
+ ): Promise<JsonRun<T>>
381
+ export async function runJsonAsync<
382
+ T = unknown,
383
+ P extends unknown[] = [opts: AsyncCommandRunOptions],
384
+ >(
385
+ runner: { run(argv: string[], ...opts: P): Promise<CommandResult> },
386
+ argv: string[],
387
+ ...opts: WithJsonOptions<P, T>
388
+ ): Promise<JsonRun<T>>
389
+ export async function runJsonAsync<T>(
390
+ runner: { run(argv: string[], ...opts: unknown[]): Promise<CommandResult> },
391
+ argv: string[],
392
+ ...opts: unknown[]
393
+ ): Promise<JsonRun<T>> {
394
+ const { run, json } = splitJsonOptions<T>(opts)
395
+ return parseJsonResult<T>(await runner.run(argv, ...run), json)
396
+ }
397
+
398
+ /** Split `runJson`'s options into the runner's and the parse step's. */
399
+ function splitJsonOptions<T>(opts: unknown[]): { run: unknown[]; json: JsonOptions<T> } {
400
+ if (opts[0] === undefined) return { run: [], json: {} }
401
+ const { tolerateLeadingLog, parse, okExitCodes, ...run } = opts[0] as JsonOptions<T>
402
+ return { run: [run], json: { tolerateLeadingLog, parse, okExitCodes } }
403
+ }
package/shell.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The shell runner: the one place in this package that runs a shell string.
3
+ *
4
+ * Every other runner passes argv straight to a binary, so nothing is parsed
5
+ * as shell. A configured command such as `bun install --filter '@x/y'` or
6
+ * `a && b` is a shell string by nature, and it runs as `sh -c -- <verb>`.
7
+ * Keeping that call in one place makes shell use easy to find and audit, and
8
+ * keeps the invocation the same everywhere.
9
+ *
10
+ * Built on the capture runners, so it uses their types
11
+ * ({@link CommandResult}, {@link CommandRunOptions}, `CommandFailed`) and
12
+ * their timeout, env and stdin handling.
13
+ */
14
+
15
+ import { spawnRunnerAsync, type AsyncCommandRunner, type AsyncCommandRunOptions } from './async.ts'
16
+ import {
17
+ normalizeTimeout,
18
+ spawnRunner,
19
+ type CommandResult,
20
+ type CommandRunner,
21
+ type CommandRunOptions,
22
+ } from './sync.ts'
23
+
24
+ /**
25
+ * Positional parameters for the verb. They ride as separate argv entries,
26
+ * never spliced into the string, so they are inert data.
27
+ */
28
+ export interface ShellPositionals {
29
+ /** Sets `$0`, the name the script sees for itself, used in its error messages. Default `sh`. */
30
+ scriptName?: string | undefined
31
+ /** Visible to the script as `$1`, `$2`, … */
32
+ args?: readonly string[] | undefined
33
+ }
34
+
35
+ /** {@link CommandRunOptions} plus {@link ShellPositionals}. */
36
+ export interface ShellRunOptions extends CommandRunOptions, ShellPositionals {}
37
+
38
+ /** {@link ShellPositionals} plus the async runner's options. */
39
+ export interface AsyncShellRunOptions extends AsyncCommandRunOptions, ShellPositionals {}
40
+
41
+ /** Runs a shell string. `verb` is interpreted by `sh`, so quote data with `shellQuote`. */
42
+ export interface ShellRunner {
43
+ run(verb: string, opts: ShellRunOptions): CommandResult
44
+ }
45
+
46
+ /**
47
+ * The async version of {@link ShellRunner}.
48
+ *
49
+ * @experimental
50
+ */
51
+ export interface AsyncShellRunner {
52
+ run(verb: string, opts: AsyncShellRunOptions): Promise<CommandResult>
53
+ }
54
+
55
+ /**
56
+ * The `sh -c -- <verb>` argv; `$0`/`$1…` are appended only when given. The
57
+ * `--` keeps a verb that starts with `-` or `+` from being read as an option.
58
+ */
59
+ function shellArgv(
60
+ verb: string,
61
+ scriptName: string | undefined,
62
+ args: readonly string[] | undefined,
63
+ ): string[] {
64
+ const rest = args ?? []
65
+ if (scriptName === undefined && rest.length === 0) return ['-c', '--', verb]
66
+ return ['-c', '--', verb, scriptName ?? 'sh', ...rest]
67
+ }
68
+
69
+ /**
70
+ * A {@link ShellRunner} over `sh`. Pass `sh` to run through a different
71
+ * {@link CommandRunner}, such as a test fake. An invalid `timeoutMs` throws
72
+ * before `sh` is called, as it does on the capture runners.
73
+ *
74
+ * @param sh - The runner that receives `['-c', '--', verb]`, then
75
+ * `scriptName` and `args` when given. Defaults to `spawnRunner('sh')`.
76
+ */
77
+ export function shellRunner(sh: CommandRunner = spawnRunner('sh')): ShellRunner {
78
+ return {
79
+ run: (verb, { scriptName, args, ...opts }) => {
80
+ normalizeTimeout(opts.timeoutMs)
81
+ return sh.run(shellArgv(verb, scriptName, args), opts)
82
+ },
83
+ }
84
+ }
85
+
86
+ /**
87
+ * The async version of {@link shellRunner}. Wrap a sync fake with `asAsync`
88
+ * to pass it in. An invalid `timeoutMs` rejects before `sh` is called.
89
+ *
90
+ * @param sh - The runner that receives `['-c', '--', verb]`, then
91
+ * `scriptName` and `args` when given. Defaults to `spawnRunnerAsync('sh')`.
92
+ * @experimental
93
+ */
94
+ export function shellRunnerAsync(
95
+ sh: AsyncCommandRunner = spawnRunnerAsync('sh'),
96
+ ): AsyncShellRunner {
97
+ return {
98
+ run: async (verb, { scriptName, args, ...opts }) => {
99
+ normalizeTimeout(opts.timeoutMs)
100
+ return sh.run(shellArgv(verb, scriptName, args), opts)
101
+ },
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Run `verb` once through `sh -c --` and capture the result. Needs `sh` on
107
+ * `PATH`. Never throws for a failed command, like `spawnRunner`; programmer
108
+ * errors such as an invalid `timeoutMs` throw.
109
+ *
110
+ * @param verb - A shell string. Quote any data in it with `shellQuote`, or
111
+ * pass it through `args`.
112
+ * @param opts - The capture options plus `scriptName` (`$0`) and `args` (`$1`…).
113
+ * @returns The captured result.
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * const r = runShell('ls "$1" | wc -l', { cwd: '.', args: ['my dir'] })
118
+ * console.log(r.stdout.trim())
119
+ * ```
120
+ */
121
+ export function runShell(verb: string, opts: ShellRunOptions): CommandResult {
122
+ return shellRunner().run(verb, opts)
123
+ }
124
+
125
+ /**
126
+ * The async version of {@link runShell}. Never rejects for a failed command;
127
+ * rejects for programmer errors such as an invalid `timeoutMs`.
128
+ */
129
+ export function runShellAsync(verb: string, opts: AsyncShellRunOptions): Promise<CommandResult> {
130
+ return shellRunnerAsync().run(verb, opts)
131
+ }
package/smoke.mjs ADDED
@@ -0,0 +1,61 @@
1
+ // Runs the BUILT package under Node, which no other check does: `bun test`
2
+ // resolves the `bun` export condition to the TypeScript sources, so dist/ is
3
+ // compiled and never executed. A broken emit — an unrewritten `.ts` import, a
4
+ // dependency with no dist/ of its own — passes every other gate and fails on
5
+ // the consumer's machine.
6
+ //
7
+ // Deliberately not a test-runner suite: it must run from the extracted
8
+ // snapshot with only `dist/` and production dependencies present.
9
+
10
+ import { strict as assert } from 'node:assert'
11
+ import { spawnRunner, runChecked, CommandFailed, asAsync, withDefaults } from './dist/index.js'
12
+ import { fakeProcess, fakeRunner } from './dist/testing.js'
13
+
14
+ const runner = spawnRunner('node')
15
+ const ok = runner.run(['-e', "process.stdout.write('hi')"], { cwd: process.cwd() })
16
+ assert.equal(ok.exitCode, 0)
17
+ assert.equal(ok.stdout, 'hi')
18
+
19
+ const bad = runner.run(['-e', "process.stderr.write('boom'); process.exit(3)"], {
20
+ cwd: process.cwd(),
21
+ })
22
+ assert.equal(bad.exitCode, 3)
23
+ assert.match(bad.stderr, /boom/)
24
+
25
+ // `run` never throws; `runChecked` does.
26
+ assert.throws(
27
+ () => runChecked(runner, ['-e', 'process.exit(1)'], { cwd: process.cwd() }),
28
+ (e) => e instanceof CommandFailed && e.argv.length === 2,
29
+ )
30
+
31
+ // detail joins both streams, empty ones dropped — the reason it exists is a
32
+ // command that explains itself on stdout while git reports failure on stderr.
33
+ const both = new CommandFailed(['x'], { exitCode: 1, stdout: 'why\n', stderr: 'what\n' })
34
+ assert.equal(both.detail, 'what\nwhy')
35
+ assert.equal(new CommandFailed(['x'], { exitCode: 1, stdout: 'only\n', stderr: '' }).detail, 'only')
36
+
37
+ // env merges over the parent environment.
38
+ const withEnv = runner.run(['-e', "process.stdout.write(process.env.SMOKE_VAR ?? '')"], {
39
+ cwd: process.cwd(),
40
+ env: { SMOKE_VAR: 'set' },
41
+ })
42
+ assert.equal(withEnv.stdout, 'set')
43
+
44
+ const async = asAsync(runner)
45
+ const viaAsync = await async.run(['-e', "process.stdout.write('async')"], { cwd: process.cwd() })
46
+ assert.equal(viaAsync.stdout, 'async')
47
+
48
+ // An invalid timeoutMs is a caller error.
49
+ assert.throws(() => runner.run(['-e', ''], { cwd: process.cwd(), timeoutMs: 0 }), RangeError)
50
+
51
+ const bound = withDefaults(runner, { cwd: process.cwd() })
52
+ assert.equal(bound.run(['-e', "process.stdout.write('bound')"]).stdout, 'bound')
53
+
54
+ // The /testing entry point, built.
55
+ const fake = fakeRunner({ 'rev-parse HEAD': { stdout: 'abc\n' } })
56
+ assert.equal(runChecked(fake.runner, ['rev-parse', 'HEAD'], { cwd: '/' }).stdout, 'abc\n')
57
+ const proc = fakeProcess()
58
+ proc.kill()
59
+ assert.equal((await proc.exited).reason, 'abort')
60
+
61
+ console.log('command-seam smoke: OK')