@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
package/package.json CHANGED
@@ -1,6 +1,97 @@
1
1
  {
2
2
  "name": "@osdlc/command-seam",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1-stage",
4
+ "description": "Injectable subprocess runner for Node: capture, stream and managed-process runners, hard timeouts, failures as results.",
5
+ "license": "MIT",
6
+ "author": "SK Sizer",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/osdlc/osdlc.git",
10
+ "directory": "packages/ts/command-seam"
11
+ },
12
+ "homepage": "https://github.com/osdlc/osdlc/tree/main/packages/ts/command-seam#readme",
13
+ "bugs": "https://github.com/osdlc/osdlc/issues",
14
+ "keywords": [
15
+ "subprocess",
16
+ "spawn",
17
+ "exec",
18
+ "child-process",
19
+ "timeout",
20
+ "dependency-injection",
21
+ "test-seam",
22
+ "testing",
23
+ "mock",
24
+ "cli",
25
+ "process-group",
26
+ "abort",
27
+ "stdio",
28
+ "typescript"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "type": "module",
34
+ "sideEffects": false,
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "exports": {
39
+ ".": {
40
+ "bun": "./index.ts",
41
+ "types": "./dist/index.d.ts",
42
+ "default": "./dist/index.js"
43
+ },
44
+ "./testing": {
45
+ "bun": "./testing.ts",
46
+ "types": "./dist/testing.d.ts",
47
+ "default": "./dist/testing.js"
48
+ }
49
+ },
50
+ "files": [
51
+ "dist/",
52
+ "smoke.mjs",
53
+ "index.ts",
54
+ "sync.ts",
55
+ "async.ts",
56
+ "result.ts",
57
+ "stream.ts",
58
+ "process.ts",
59
+ "groups.ts",
60
+ "quote.ts",
61
+ "shell.ts",
62
+ "defaults.ts",
63
+ "testing.ts",
64
+ "README.md",
65
+ "CHANGELOG.md",
66
+ "LICENSE",
67
+ "SECURITY.md",
68
+ "CONTRIBUTING.md"
69
+ ],
70
+ "peerDependencies": {
71
+ "@types/node": "*"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "@types/node": {
75
+ "optional": true
76
+ }
77
+ },
78
+ "devDependencies": {
79
+ "@types/bun": "^1.1.14",
80
+ "@types/node": "^26.1.0",
81
+ "oxlint": "^1.65.0",
82
+ "prettier": "3.9.6",
83
+ "typescript": "~6.0.3",
84
+ "@osdlc/prettier-config": "^0.2.3",
85
+ "@osdlc/tsconfig": "^0.3.3"
86
+ },
87
+ "scripts": {
88
+ "build": "tsc -p tsconfig.build.json",
89
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
90
+ "test": "bun test",
91
+ "smoke": "node smoke.mjs",
92
+ "format": "prettier --write .",
93
+ "format:check": "prettier --check .",
94
+ "lint": "oxlint .",
95
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.build.json --noEmit"
96
+ }
6
97
  }
package/process.ts ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The managed-process runner: start a long-running child and get a handle to
3
+ * it, for a process the caller watches and stops rather than waits on.
4
+ *
5
+ * Every other runner in this package waits for the child to exit
6
+ * (synchronously or through a promise) and reports how it went. This one
7
+ * returns as soon as the child exists, so the caller can do other work while
8
+ * it runs: enforce its own timeout, respond to signals, stop it later.
9
+ */
10
+
11
+ import { spawn } from 'node:child_process'
12
+ import { constants } from 'node:os'
13
+
14
+ import { groupStartRefusal, releaseWhenEmpty, settleGroup, spawnTrackedGroup } from './groups.ts'
15
+ import { cwdError, mergedEnv, timerMs, type EnvOverrides, type NeverExitedReason } from './sync.ts'
16
+
17
+ /** Per-call options for a {@link ManagedProcessRunner}. */
18
+ export interface ManagedProcessRunOptions {
19
+ /** Working directory for the child. */
20
+ cwd: string
21
+ /**
22
+ * Variables merged over the parent's environment. An `undefined` value
23
+ * removes that variable from the child's environment.
24
+ */
25
+ env?: EnvOverrides | undefined
26
+ /**
27
+ * Run the child as its own process-group leader. {@link
28
+ * ManagedProcessHandle.kill} then signals the whole group instead of just
29
+ * the direct child, so processes the child started die with it. The group
30
+ * no longer gets the terminal's Ctrl-C. POSIX only.
31
+ * `installGroupSignalForwarding` kills live groups when the host is
32
+ * signalled, and once it is stopping the host, a new group run is refused
33
+ * (`reason: 'abort'`, pid `-1`).
34
+ */
35
+ processGroup?: boolean | undefined
36
+ /**
37
+ * Let the parent exit while the child runs: the child's handle stops
38
+ * holding the parent's event loop open. Meant for a `processGroup` child
39
+ * that is supposed to outlive the call that started it (a loop tick that
40
+ * starts a run and returns); {@link ManagedProcessHandle.exited} then only
41
+ * settles while the parent is still alive. An `unref`'d group is not tracked
42
+ * by `installGroupSignalForwarding`: a signal to the parent does not reach it,
43
+ * and the parent's exit does not `SIGKILL` it.
44
+ */
45
+ unref?: boolean | undefined
46
+ }
47
+
48
+ /** How a managed process ended. */
49
+ export interface ManagedProcessExit {
50
+ /** The child's exit code, or `1` when it never reported one (see `neverExited`). */
51
+ exitCode: number
52
+ /** The signal that ended the child, or `null` when it exited on its own or never started. */
53
+ signal: NodeJS.Signals | null
54
+ /**
55
+ * No exit code was reported: the spawn failed (e.g. a missing binary) or a
56
+ * signal killed the process (see `signal`). Same meaning as
57
+ * `CommandResult.neverExited`.
58
+ */
59
+ neverExited?: boolean | undefined
60
+ /**
61
+ * Why no exit code was reported. Set whenever `neverExited` is, absent
62
+ * otherwise. `spawn`: the process never started (see `error`). `abort`: a
63
+ * signal ended it after the caller's own `kill()`, or a `processGroup` run
64
+ * was refused because signal forwarding is stopping the host (it never
65
+ * started; see `error`). `signal`: a signal from
66
+ * elsewhere ended it. There is no `timeout` or `output`: this runner has
67
+ * neither a timeout nor captured output.
68
+ */
69
+ reason?: Exclude<NeverExitedReason, 'timeout' | 'output'> | undefined
70
+ /**
71
+ * The spawn error's message, such as `spawn node ENOENT`, when `reason` is
72
+ * `spawn`, or the refusal message for a refused `processGroup` run.
73
+ */
74
+ error?: string | undefined
75
+ }
76
+
77
+ /** A running child: its pid, a way to kill it, and a promise settling once it's gone. */
78
+ export interface ManagedProcessHandle {
79
+ /** The child's pid, or `-1` when it could not be spawned at all. */
80
+ readonly pid: number
81
+ /**
82
+ * Send `signal` (default `SIGTERM`) to the child, or to its whole process
83
+ * group when started with `processGroup: true`. Does nothing when `pid` is
84
+ * `-1`. Without `processGroup`, it also does nothing once the child has
85
+ * exited, so a reused pid is never signalled. With `processGroup`, it still
86
+ * signals any member of the group that is left. If the process dies of a
87
+ * signal sent here, `exited` reports `reason: 'abort'`. A signal the child
88
+ * handles and survives does not count: a later death by another signal is
89
+ * `'signal'`.
90
+ */
91
+ kill(signal?: NodeJS.Signals | number): void
92
+ /** Settles once the child is gone, including when the spawn failed. Never rejects. */
93
+ readonly exited: Promise<ManagedProcessExit>
94
+ }
95
+
96
+ /** Starts a long-running process and returns a handle instead of waiting for it. */
97
+ export interface ManagedProcessRunner {
98
+ /**
99
+ * Start the binary with `argv` (without the binary name) and return at
100
+ * once. A failed spawn is reported through `exited`, not thrown. A NUL
101
+ * byte in argv or env is a programmer error and throws.
102
+ */
103
+ run(argv: string[], opts: ManagedProcessRunOptions): ManagedProcessHandle
104
+ }
105
+
106
+ /**
107
+ * The default {@link ManagedProcessRunner}: starts `binary` with `spawn`.
108
+ * Stdio is always inherited, so the child's output reaches the terminal. It
109
+ * does not capture output and has no timeout of its own.
110
+ *
111
+ * @param binary - A name looked up on `PATH`, or a path.
112
+ * @returns A runner for that binary.
113
+ *
114
+ * @example
115
+ * ```ts
116
+ * const server = managedProcessRunner('node').run(['server.js'], {
117
+ * cwd: '.',
118
+ * processGroup: true,
119
+ * })
120
+ * setTimeout(() => server.kill(), 5_000)
121
+ * const { exitCode, signal } = await server.exited
122
+ * ```
123
+ */
124
+ export function managedProcessRunner(binary: string): ManagedProcessRunner {
125
+ return {
126
+ run(argv: string[], opts: ManagedProcessRunOptions): ManagedProcessHandle {
127
+ const cwdMessage = cwdError(`spawn ${binary}`, binary, argv, opts)
128
+ if (cwdMessage !== undefined) {
129
+ return { pid: -1, kill: () => {}, exited: Promise.resolve(spawnExit(cwdMessage)) }
130
+ }
131
+ const group = opts.processGroup === true
132
+ const refusal = group ? groupStartRefusal() : undefined
133
+ if (refusal !== undefined) {
134
+ return { pid: -1, kill: () => {}, exited: Promise.resolve(refusedExit(refusal)) }
135
+ }
136
+ const start = () =>
137
+ spawn(binary, argv, {
138
+ cwd: opts.cwd,
139
+ detached: group,
140
+ stdio: 'inherit',
141
+ ...mergedEnv(opts),
142
+ })
143
+ // An `unref`'d group is meant to outlive the host, so forwarding must not reap it.
144
+ const { child, untrack: untrackGroup } =
145
+ group && opts.unref !== true
146
+ ? spawnTrackedGroup(start)
147
+ : { child: start(), untrack: undefined }
148
+ const pid = child.pid ?? -1
149
+ if (opts.unref === true) child.unref()
150
+ // The signals `kill()` has sent. A death by one of them is the caller's
151
+ // own stop; a death by any other signal came from elsewhere.
152
+ const sent = new Set<NodeJS.Signals>()
153
+
154
+ const exited = new Promise<ManagedProcessExit>((resolve) => {
155
+ let settled = false
156
+ const finish = (result: ManagedProcessExit): void => {
157
+ if (settled) return
158
+ settled = true
159
+ resolve(result)
160
+ }
161
+ // A spawn failure (e.g. ENOENT): no exit was ever reported.
162
+ child.on('error', (err) => {
163
+ untrackGroup?.()
164
+ finish(spawnExit(err.message))
165
+ })
166
+ child.on('exit', (code, signal) => {
167
+ // The leader is gone but the group may not be: stay registered until
168
+ // no member is left, so a signal to the host still reaches the rest.
169
+ // Unless `kill()` is stopping it, the host's exit leaves it running.
170
+ if (untrackGroup !== undefined) {
171
+ if (sent.size === 0) settleGroup(pid)
172
+ releaseWhenEmpty(pid, untrackGroup)
173
+ }
174
+ finish({
175
+ exitCode: code ?? 1,
176
+ signal,
177
+ ...(code === null
178
+ ? {
179
+ neverExited: true as const,
180
+ reason:
181
+ signal !== null && sent.has(signal) ? ('abort' as const) : ('signal' as const),
182
+ }
183
+ : {}),
184
+ })
185
+ })
186
+ })
187
+
188
+ return {
189
+ pid,
190
+ kill(signal: NodeJS.Signals | number = 'SIGTERM'): void {
191
+ if (pid === -1) return
192
+ try {
193
+ // A negative pid signals the whole process group — see
194
+ // `processGroup`'s doc above. The direct child goes through
195
+ // `child.kill`, which does nothing once the child has been
196
+ // reaped, so a reused pid is never signalled.
197
+ const delivered = group ? process.kill(-pid, signal) : child.kill(signal)
198
+ const name = typeof signal === 'number' ? signalName(signal) : signal
199
+ if (delivered && name !== undefined) sent.add(name)
200
+ } catch {
201
+ // Already gone — nothing to reap.
202
+ }
203
+ },
204
+ exited,
205
+ }
206
+ },
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Stop a managed process: send `SIGTERM`, wait up to `graceMs` for it to
212
+ * exit, then send `SIGKILL`. A plain `kill()` sends one signal, so a process
213
+ * that ignores `SIGTERM` would leave `await handle.exited` waiting forever.
214
+ *
215
+ * Works with any {@link ManagedProcessHandle}, fakes included. It waits for
216
+ * the handle's own process only: with `processGroup`, a group member that
217
+ * outlives the leader is not sent `SIGKILL`.
218
+ *
219
+ * @param handle - The process to stop.
220
+ * @param opts - `graceMs`: how long to wait after `SIGTERM`. Default `5_000`.
221
+ * `0` sends `SIGKILL` right after `SIGTERM`; `Infinity` never sends it. A
222
+ * value above 2^31 - 1, the largest timer delay, is clamped to it. A
223
+ * negative value or `NaN` rejects with `RangeError`, a non-number with
224
+ * `TypeError`.
225
+ * @returns How the process ended. `reason` is `'abort'` when one of the two
226
+ * signals ended it.
227
+ *
228
+ * @example
229
+ * ```ts
230
+ * const server = managedProcessRunner('node').run(['server.js'], { cwd: '.' })
231
+ * const exit = await stopProcess(server, { graceMs: 2_000 })
232
+ * ```
233
+ *
234
+ * @experimental
235
+ */
236
+ export async function stopProcess(
237
+ handle: ManagedProcessHandle,
238
+ opts: { graceMs?: number | undefined } = {},
239
+ ): Promise<ManagedProcessExit> {
240
+ const graceMs = timerMs('graceMs', opts.graceMs ?? 5_000, 'zero')
241
+ handle.kill()
242
+ if (graceMs === undefined) return handle.exited
243
+ let timer: ReturnType<typeof setTimeout> | undefined
244
+ const grace = new Promise<undefined>((resolve) => {
245
+ timer = setTimeout(resolve, graceMs, undefined)
246
+ })
247
+ const exit = await Promise.race([handle.exited, grace])
248
+ clearTimeout(timer)
249
+ if (exit !== undefined) return exit
250
+ handle.kill('SIGKILL')
251
+ return handle.exited
252
+ }
253
+
254
+ /** The exit of a `processGroup` run refused because signal forwarding is stopping the host. */
255
+ function refusedExit(error: string): ManagedProcessExit {
256
+ return { exitCode: 1, signal: null, neverExited: true, reason: 'abort', error }
257
+ }
258
+
259
+ /** The exit of a process that never started. */
260
+ function spawnExit(error: string): ManagedProcessExit {
261
+ return { exitCode: 1, signal: null, neverExited: true, reason: 'spawn', error }
262
+ }
263
+
264
+ /** The name of signal number `n`, or `undefined` for 0 (a probe) and unknown numbers. */
265
+ export function signalName(n: number): NodeJS.Signals | undefined {
266
+ for (const [name, value] of Object.entries(constants.signals)) {
267
+ if (value === n) return name as NodeJS.Signals
268
+ }
269
+ return undefined
270
+ }
package/quote.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * POSIX shell quoting, for a command line printed for a person to paste or
3
+ * passed to `sh -c`. The argv runners never need it: they pass argv straight
4
+ * to the binary.
5
+ */
6
+
7
+ /** Characters no POSIX shell treats specially. Same set as Python's `shlex.quote`. */
8
+ const INERT = /^[A-Za-z0-9_@%+=:,./-]+$/
9
+
10
+ /**
11
+ * `word` as one shell word: unquoted when it holds only characters no POSIX
12
+ * shell treats specially (letters, digits and `_@%+=:,./-`), single-quoted
13
+ * otherwise.
14
+ */
15
+ export function shellQuote(word: string): string {
16
+ if (INERT.test(word)) return word
17
+ return quoted(word)
18
+ }
19
+
20
+ /**
21
+ * `argv` as one shell command line. A first word containing `=` is always
22
+ * quoted: bare, the shell would read it as a variable assignment, not a
23
+ * command.
24
+ */
25
+ export function shellJoin(argv: readonly string[]): string {
26
+ return argv.map((w, i) => (i === 0 && w.includes('=') ? quoted(w) : shellQuote(w))).join(' ')
27
+ }
28
+
29
+ function quoted(word: string): string {
30
+ return `'${word.replaceAll("'", `'\\''`)}'`
31
+ }