@orkestrel/process 0.0.3 → 0.0.4

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.
@@ -3,6 +3,7 @@ let node_buffer = require("node:buffer");
3
3
  let node_child_process = require("node:child_process");
4
4
  let node_fs = require("node:fs");
5
5
  let node_path = require("node:path");
6
+ let node_process = require("node:process");
6
7
  let _orkestrel_contract = require("@orkestrel/contract");
7
8
  let _src_core = require("../core/index.cjs");
8
9
  let node_readline = require("node:readline");
@@ -66,10 +67,44 @@ function trimHead(bytes, limit) {
66
67
  return node_buffer.Buffer.from(buffer.subarray(0, end));
67
68
  }
68
69
  /**
70
+ * Takes one owned frozen snapshot of a caller's command.
71
+ *
72
+ * @remarks
73
+ * Every public entry point snapshots before it validates, so the object validated is the object
74
+ * spawned. Each property is read exactly once, because reading one runs the caller's own getter: a
75
+ * command whose `file` changes between reads would otherwise validate one executable and spawn
76
+ * another. The argument vector and the environment record are copied and frozen, so a caller
77
+ * mutating either after the call cannot reach the spawn. An absent optional stays absent rather than
78
+ * becoming an explicit `undefined`.
79
+ *
80
+ * @param command - The caller's command, whose properties may be getters
81
+ * @returns A frozen command carrying the values read at this instant
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * snapshotCommand({ file: 'git', arguments: ['status'] }) // { file: 'git', arguments: ['status'] }
86
+ * ```
87
+ */
88
+ function snapshotCommand(command) {
89
+ const file = command.file;
90
+ const argumentsList = Object.freeze([...command.arguments]);
91
+ const sourceEnvironment = command.environment;
92
+ const environment = sourceEnvironment === void 0 ? void 0 : Object.freeze({ ...sourceEnvironment });
93
+ const input = command.input;
94
+ const isolated = command.isolated;
95
+ return Object.freeze({
96
+ file,
97
+ arguments: argumentsList,
98
+ ...environment === void 0 ? {} : { environment },
99
+ ...input === void 0 ? {} : { input },
100
+ ...isolated === void 0 ? {} : { isolated }
101
+ });
102
+ }
103
+ /**
69
104
  * Renders one command into its diagnostic command line.
70
105
  *
71
106
  * @param command - The executable and its argument vector
72
- * @returns The space-joined command line, for a {@link RunResult} and error messages
107
+ * @returns The space-joined command line, for an {@link ExecuteResult} and error messages
73
108
  *
74
109
  * @example
75
110
  * ```ts
@@ -80,36 +115,54 @@ function formatCommand(command) {
80
115
  return [command.file, ...command.arguments].join(" ");
81
116
  }
82
117
  /**
83
- * Reads one environment variable the way the host resolves it.
118
+ * Reads one environment variable under an explicit platform's key rules.
84
119
  *
85
120
  * @remarks
86
121
  * Windows environment keys are case-insensitive, so a merged record can hold `Path` where the
87
- * caller asks for `PATH`. The exact key is tried first and a folded scan runs only on Windows.
122
+ * caller asks for `PATH`. The exact key is tried first and a folded scan runs only for `win32`.
88
123
  *
89
124
  * @param environment - The environment record to read
90
125
  * @param name - The variable name
126
+ * @param platform - The platform whose key rules apply
91
127
  * @returns The value, or `undefined` when the environment declares no such variable
92
128
  *
93
129
  * @example
94
130
  * ```ts
95
- * readVariable({ Path: 'C:\\Windows' }, 'PATH') // 'C:\\Windows' on Windows
131
+ * readPlatformVariable({ Path: 'C:\\Windows' }, 'PATH', 'win32') // 'C:\\Windows'
96
132
  * ```
97
133
  */
98
- function readVariable(environment, name) {
134
+ function readPlatformVariable(environment, name, platform) {
99
135
  const direct = environment[name];
100
136
  if (direct !== void 0) return direct;
101
- if (process.platform !== "win32") return void 0;
137
+ if (platform !== "win32") return void 0;
102
138
  const target = name.toUpperCase();
103
139
  for (const [key, value] of Object.entries(environment)) if (key.toUpperCase() === target) return value;
104
140
  }
105
141
  /**
106
- * Merges environment overrides into the environment one child receives.
142
+ * Reads one environment variable the way the current host resolves it.
143
+ *
144
+ * @param environment - The environment record to read
145
+ * @param name - The variable name
146
+ * @returns The value, or `undefined` when the environment declares no such variable
147
+ *
148
+ * @example
149
+ * ```ts
150
+ * readVariable({ PATH: '/usr/bin' }, 'PATH') // '/usr/bin'
151
+ * ```
152
+ */
153
+ function readVariable(environment, name) {
154
+ return readPlatformVariable(environment, name, node_process.platform);
155
+ }
156
+ /**
157
+ * Merges environment layers under an explicit platform's key rules.
107
158
  *
108
159
  * @remarks
109
- * Later maps override earlier ones and an `undefined` value unsets a key. On Windows the keys fold
160
+ * Later maps override earlier ones and an `undefined` value unsets a key. For `win32` the keys fold
110
161
  * case-insensitively and the last writer wins, so `PATH` followed by `Path` yields one variable
111
162
  * rather than two the host would resolve unpredictably.
112
163
  *
164
+ * @param platform - The platform whose key rules apply
165
+ * @param parent - The parent environment layer
113
166
  * @param isolated - If `true`, the parent environment is excluded; if `false`, the overrides layer over it
114
167
  * @param base - The command's own environment overrides
115
168
  * @param override - Per-invocation overrides applied last
@@ -117,13 +170,13 @@ function readVariable(environment, name) {
117
170
  *
118
171
  * @example
119
172
  * ```ts
120
- * mergeEnvironment(false, { TOKEN: 'a' }, { TOKEN: undefined }) // TOKEN unset
173
+ * mergePlatformEnvironment('linux', {}, false, { TOKEN: 'a' }, { TOKEN: undefined }) // TOKEN unset
121
174
  * ```
122
175
  */
123
- function mergeEnvironment(isolated, base, override) {
124
- const folded = process.platform === "win32";
176
+ function mergePlatformEnvironment(platform, parent, isolated, base, override) {
177
+ const folded = platform === "win32";
125
178
  const layers = [
126
- isolated ? {} : process.env,
179
+ isolated ? {} : parent,
127
180
  base ?? {},
128
181
  override ?? {}
129
182
  ];
@@ -141,6 +194,22 @@ function mergeEnvironment(isolated, base, override) {
141
194
  return merged;
142
195
  }
143
196
  /**
197
+ * Merges environment overrides into the environment one child receives on the current host.
198
+ *
199
+ * @param isolated - If `true`, the parent environment is excluded; if `false`, the overrides layer over it
200
+ * @param base - The command's own environment overrides
201
+ * @param override - Per-invocation overrides applied last
202
+ * @returns The environment for a spawn, carrying no unset key
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * mergeEnvironment(true, { TOKEN: 'a' }) // { TOKEN: 'a' }
207
+ * ```
208
+ */
209
+ function mergeEnvironment(isolated, base, override) {
210
+ return mergePlatformEnvironment(node_process.platform, process.env, isolated, base, override);
211
+ }
212
+ /**
144
213
  * Checks whether a path names a regular file.
145
214
  *
146
215
  * @remarks
@@ -159,18 +228,44 @@ function isFile(target) {
159
228
  return (0, _orkestrel_contract.holds)(() => (0, node_fs.statSync)(target, { throwIfNoEntry: false })?.isFile() === true);
160
229
  }
161
230
  /**
162
- * Resolves a command file to the executable path the host would launch.
231
+ * Builds the executable candidates an explicit platform would search.
163
232
  *
164
233
  * @remarks
165
- * Windows alone needs this: the host searches the working directory before `PATH` and applies
234
+ * Windows alone needs this list: the host searches the working directory before `PATH` and applies
166
235
  * `PATHEXT`, and Node reproduces neither for a direct spawn. Within each searched directory the
167
236
  * literal name is tried first and each `PATHEXT` candidate after it, whether or not the name already
168
237
  * carries an extension — so `report.txt` resolves to a `report.txt` file where one exists and to
169
238
  * `report.txt.cmd` where none does. The lookup reads the child's effective environment, so an
170
239
  * overridden `PATH` selects the executable the child would have found. A POSIX host resolves the
171
- * file itself, so the answer there is always `undefined`. An appended extension is spelled the way
240
+ * file itself, so the candidate list there is empty. An appended extension is spelled the way
172
241
  * `PATHEXT` spells it rather than the way the directory entry does, which the case-insensitive host
173
- * treats as the same file.
242
+ * treats as the same file. A non-Windows platform returns an empty list because `execvp` performs
243
+ * its own lookup.
244
+ *
245
+ * @param file - The command executable name or path
246
+ * @param workspace - The directory searched first
247
+ * @param environment - The child's effective environment
248
+ * @param platform - The platform whose lookup rules apply
249
+ * @returns The ordered absolute candidate paths
250
+ *
251
+ * @example
252
+ * ```ts
253
+ * buildExecutableCandidates('git', 'C:\\work', { PATH: 'C:\\bin' }, 'win32')
254
+ * ```
255
+ */
256
+ function buildExecutableCandidates(file, workspace, environment, platform) {
257
+ if (platform !== "win32") return Object.freeze([]);
258
+ const candidates = [file, ...(readPlatformVariable(environment, "PATHEXT", platform) ?? _src_core.PROCESS_PATHEXT).split(";").filter((extension) => extension.length > 0).map((extension) => `${file}${extension}`)];
259
+ const directories = file.includes("/") || file.includes("\\") ? [workspace] : [workspace, ...(readPlatformVariable(environment, "PATH", platform) ?? "").split(";")];
260
+ const targets = [];
261
+ for (const directory of directories) {
262
+ if (directory.length === 0) continue;
263
+ for (const candidate of candidates) targets.push(node_path.win32.resolve(directory, candidate));
264
+ }
265
+ return Object.freeze(targets);
266
+ }
267
+ /**
268
+ * Resolves a command file to the executable path the host would launch.
174
269
  *
175
270
  * @param file - The command executable name or path
176
271
  * @param options - The directory searched first and the child's effective environment
@@ -182,18 +277,9 @@ function isFile(target) {
182
277
  * ```
183
278
  */
184
279
  function resolveExecutable(file, options) {
185
- if (process.platform !== "win32") return void 0;
186
280
  const environment = options?.environment ?? process.env;
187
- const workspace = options?.workspace ?? process.cwd();
188
- const candidates = [file, ...(readVariable(environment, "PATHEXT") ?? _src_core.PROCESS_PATHEXT).split(";").filter((extension) => extension.length > 0).map((extension) => `${file}${extension}`)];
189
- const directories = file.includes("/") || file.includes("\\") ? [workspace] : [workspace, ...(readVariable(environment, "PATH") ?? "").split(node_path.delimiter)];
190
- for (const directory of directories) {
191
- if (directory.length === 0) continue;
192
- for (const candidate of candidates) {
193
- const target = (0, node_path.resolve)(directory, candidate);
194
- if (isFile(target)) return target;
195
- }
196
- }
281
+ const candidates = buildExecutableCandidates(file, options?.workspace ?? process.cwd(), environment, node_process.platform);
282
+ for (const target of candidates) if (isFile(target)) return target;
197
283
  }
198
284
  /**
199
285
  * Quotes one command-line token for a `cmd.exe` command line.
@@ -201,8 +287,9 @@ function resolveExecutable(file, options) {
201
287
  * @remarks
202
288
  * A token carrying whitespace or a shell metacharacter is wrapped in double quotes so `cmd.exe`
203
289
  * passes it as one literal argument; an embedded double quote is doubled, which is how `cmd.exe`
204
- * reads a literal quote inside a quoted token. Every other token is left exactly as written, so a
205
- * batch script still receives `%1` without added quotes.
290
+ * reads a literal quote inside a quoted token. The quoted set includes `%`, although quoting cannot
291
+ * stop `cmd.exe` expansion; {@link buildSpawn} therefore refuses `%` in an argument to a Windows
292
+ * batch target.
206
293
  *
207
294
  * @param value - The token to quote
208
295
  * @returns The token, quoted only when `cmd.exe` would otherwise split or interpret it
@@ -217,41 +304,41 @@ function quoteArgument(value) {
217
304
  return `"${value.replaceAll("\"", "\"\"")}"`;
218
305
  }
219
306
  /**
220
- * Builds the resolved spawn form of one command.
307
+ * Builds a spawn form from a resolved file and an explicit platform.
221
308
  *
222
309
  * @remarks
223
- * The executable is resolved against the child's effective environment. A resolved `.cmd` or `.bat`
224
- * script cannot be spawned directly **on Windows**, so it runs there through an explicitly quoted
225
- * `cmd.exe /d /s /c` command line with the argument vector passed verbatim. No path uses a shell,
226
- * so a metacharacter in an argument is never interpreted. `cmd.exe` expands `%NAME%` before it
227
- * parses quotes, so no quoting can carry a percent sign through to a Windows batch target: an
228
- * argument carrying one is refused there rather than silently rewritten. The whole batch path is
229
- * Windows-only. A POSIX host has no `cmd.exe` and no restriction on spawning a file directly, so a
230
- * target named `worker.cmd` spawns directly there and receives a percent sign as literal text.
310
+ * A resolved `.cmd` or `.bat` script cannot be spawned directly **on Windows**, so it runs there
311
+ * through an explicitly quoted `cmd.exe /d /s /c` command line with the argument vector passed
312
+ * verbatim. No path uses a shell, so a metacharacter in an argument is never interpreted. `cmd.exe`
313
+ * expands `%NAME%` before it parses quotes, so no quoting can carry a percent sign through to a
314
+ * Windows batch target: an argument carrying one is refused there rather than silently rewritten.
315
+ * The whole batch path is Windows-only. A POSIX host has no `cmd.exe` and no restriction on spawning
316
+ * a file directly, so a target named `worker.cmd` spawns directly there and receives a percent sign
317
+ * as literal text.
231
318
  *
232
319
  * @param command - The executable and its argument vector
233
- * @param options - The directory searched first and the child's effective environment
320
+ * @param file - The resolved executable file
321
+ * @param environment - The child's effective environment
322
+ * @param platform - The platform whose batch rules apply
234
323
  * @returns The file, argument vector, and verbatim flag to spawn with
235
- * @throws A {@link ProcessError} coded `invalid` when the host is Windows, the resolved target is a batch script, and an argument carries a percent sign
324
+ * @throws A {@link ProcessError} coded `invalid` when `platform` is `win32`, the resolved target is a batch script, and an argument carries a percent sign
236
325
  *
237
326
  * @example
238
327
  * ```ts
239
- * buildSpawn({ file: 'node', arguments: ['--version'] }).verbatim // false
328
+ * buildPlatformSpawn({ file: 'node', arguments: ['--version'] }, 'node', {}, 'linux').verbatim // false
240
329
  * ```
241
330
  */
242
- function buildSpawn(command, options) {
243
- const file = resolveExecutable(command.file, options) ?? command.file;
244
- const extension = (0, node_path.extname)(file).toLowerCase();
245
- if (!(process.platform === "win32" && (extension === ".cmd" || extension === ".bat"))) return Object.freeze({
331
+ function buildPlatformSpawn(command, file, environment, platform) {
332
+ const extension = (platform === "win32" ? node_path.win32.extname(file) : node_path.posix.extname(file)).toLowerCase();
333
+ if (!(platform === "win32" && (extension === ".cmd" || extension === ".bat"))) return Object.freeze({
246
334
  file,
247
335
  arguments: Object.freeze([...command.arguments]),
248
336
  verbatim: false
249
337
  });
250
338
  for (const argument of command.arguments) if (argument.includes("%")) throw (0, _src_core.createInvalidError)("command argument", argument);
251
339
  const line = [`"${file}"`, ...command.arguments.map(quoteArgument)].join(" ");
252
- const environment = options?.environment ?? process.env;
253
340
  return Object.freeze({
254
- file: readVariable(environment, "ComSpec") ?? "cmd.exe",
341
+ file: readPlatformVariable(environment, "ComSpec", platform) ?? "cmd.exe",
255
342
  arguments: Object.freeze([
256
343
  "/d",
257
344
  "/s",
@@ -262,6 +349,22 @@ function buildSpawn(command, options) {
262
349
  });
263
350
  }
264
351
  /**
352
+ * Builds the resolved spawn form of one command for the current host.
353
+ *
354
+ * @param command - The executable and its argument vector
355
+ * @param options - The directory searched first and the child's effective environment
356
+ * @returns The file, argument vector, and verbatim flag to spawn with
357
+ * @throws A {@link ProcessError} coded `invalid` when the host is Windows, the resolved target is a batch script, and an argument carries a percent sign
358
+ *
359
+ * @example
360
+ * ```ts
361
+ * buildSpawn({ file: 'node', arguments: ['--version'] }).verbatim // false
362
+ * ```
363
+ */
364
+ function buildSpawn(command, options) {
365
+ return buildPlatformSpawn(command, resolveExecutable(command.file, options) ?? command.file, options?.environment ?? process.env, node_process.platform);
366
+ }
367
+ /**
265
368
  * Validates one spawn-bound string.
266
369
  *
267
370
  * @remarks
@@ -379,40 +482,6 @@ function validateWorkspace(workspace) {
379
482
  validateText(workspace, "workspace", true);
380
483
  }
381
484
  /**
382
- * Retains one delivered chunk up to a byte limit and records what the stream delivered.
383
- *
384
- * @remarks
385
- * The retained head is kept as chunk slices and joined once, so a long stream never repeatedly
386
- * concatenates a growing buffer. `counts` is a two-slot tally: slot `0` counts every delivered byte
387
- * and slot `1` counts the retained bytes, so a caller compares slot `0` against `limit` to learn
388
- * whether the capture was truncated.
389
- *
390
- * @param chunk - The delivered chunk, ignored when it is not a buffer
391
- * @param chunks - The retained head, appended to in place
392
- * @param counts - The `[delivered, retained]` tally, updated in place
393
- * @param limit - The maximum retained byte count
394
- * @returns Nothing
395
- *
396
- * @example
397
- * ```ts
398
- * const chunks: Buffer[] = []
399
- * const counts = [0, 0]
400
- * retainChunk(Buffer.from('hello'), chunks, counts, 3)
401
- * counts[1] // 3
402
- * ```
403
- */
404
- function retainChunk(chunk, chunks, counts, limit) {
405
- if (!node_buffer.Buffer.isBuffer(chunk)) return;
406
- const delivered = counts[0] ?? 0;
407
- const retained = counts[1] ?? 0;
408
- counts[0] = delivered + chunk.byteLength;
409
- const room = limit - retained;
410
- if (room <= 0) return;
411
- const slice = chunk.byteLength <= room ? chunk : node_buffer.Buffer.from(chunk.subarray(0, room));
412
- chunks.push(slice);
413
- counts[1] = retained + slice.byteLength;
414
- }
415
- /**
416
485
  * Checks whether a child process has reached its native exit.
417
486
  *
418
487
  * @remarks
@@ -436,10 +505,11 @@ function isExited(child) {
436
505
  *
437
506
  * @remarks
438
507
  * On a POSIX host a child spawned `detached` leads its own process group, so signalling the
439
- * negated pid reaches the whole tree. On Windows, or when no pid is available, the signal goes to
440
- * the child directly. A throw during signalling is swallowed: the process can exit between the
441
- * caller's liveness check and this call, and the child's native exit remains the authoritative
442
- * terminal state.
508
+ * negated pid reaches the whole tree. When no group owns the pid and the host reports `ESRCH`, the
509
+ * signal falls back to the child directly. On Windows, or when no pid is available, the signal also
510
+ * goes to the child directly. Every other throw during signalling is swallowed: the process can
511
+ * exit between the caller's liveness check and this call, and the child's native exit remains the
512
+ * authoritative terminal state.
443
513
  *
444
514
  * @param child - The child boundary
445
515
  * @param signal - The signal to deliver
@@ -457,7 +527,12 @@ function killProcess(child, signal) {
457
527
  return;
458
528
  }
459
529
  process.kill(-child.pid, signal);
460
- } catch {}
530
+ } catch (error) {
531
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") {
532
+ (0, _orkestrel_contract.holds)(() => child.kill(signal));
533
+ return;
534
+ }
535
+ }
461
536
  }
462
537
  /**
463
538
  * Kills one Windows process tree through `taskkill`.
@@ -510,24 +585,25 @@ function killTree(pid, timeout) {
510
585
  function waitForExit(child, timeout) {
511
586
  if (isExited(child)) return Promise.resolve();
512
587
  const settled = Promise.withResolvers();
513
- const timer = setTimeout(settled.resolve, timeout);
514
- child.once("exit", () => {
515
- clearTimeout(timer);
588
+ const timer = setTimeout(() => {
589
+ child.off("exit", settled.resolve);
516
590
  settled.resolve();
517
- });
518
- return settled.promise;
591
+ }, timeout);
592
+ child.once("exit", settled.resolve);
593
+ return settled.promise.finally(() => clearTimeout(timer));
519
594
  }
520
595
  /**
521
596
  * Terminates one child process tree and reports whether its native exit was observed.
522
597
  *
523
598
  * @remarks
524
- * Windows ends the tree at once through {@link killTree}, falling back to a direct kill when the
525
- * utility fails, because the host has no cooperative termination to offer. A POSIX host signals the
526
- * process group `SIGTERM`, waits `grace`, then signals `SIGKILL`. No signal is initiated once the
527
- * native exit is observed; the window between initiating a signal and the host delivering it belongs
528
- * to the operating system. `confirm` bounds each awaited step rather than the call as a whole: on
529
- * Windows it bounds the `taskkill` call and then the final wait, and on a POSIX host `grace` bounds
530
- * the cooperative wait and `confirm` the final one. Never rejects.
599
+ * Windows ends the whole tree at once through {@link killTree}, falling back to a direct kill after
600
+ * the utility fails, because the host has no cooperative termination to offer. A POSIX host signals
601
+ * the process group `SIGTERM`, falls back to the direct child when no group owns its pid, waits
602
+ * `grace`, then sends `SIGKILL` through the same route. No signal is initiated after the native exit
603
+ * is observed; the window between initiating a signal and the host delivering it belongs to the
604
+ * operating system. `confirm` bounds each awaited step rather than the call as a whole: on Windows
605
+ * it bounds the `taskkill` call and then the final wait, and on a POSIX host `grace` bounds the
606
+ * cooperative wait and `confirm` the final one. Never rejects.
531
607
  *
532
608
  * @param child - The child boundary
533
609
  * @param grace - The cooperative POSIX window in milliseconds between `SIGTERM` and `SIGKILL`
@@ -553,12 +629,12 @@ async function stopChild(child, grace, confirm) {
553
629
  return isExited(child);
554
630
  }
555
631
  /**
556
- * Builds one settled {@link RunResult} from a completed run's captured bytes and terminal facts.
632
+ * Builds one settled {@link ExecuteResult} from a completed run's captured bytes and terminal facts.
557
633
  *
558
634
  * @remarks
559
635
  * `failed` is derived: a run failed when it timed out, was aborted, ended on a host fault, was ended
560
636
  * by a signal, or exited with a code other than `0` — a `null` code from a spawn fault is therefore
561
- * a failure. Both byte fields are bounded by `limit` on a code-point boundary here, which is the one
637
+ * a failure. Each byte field is bounded by `limit` on a code-point boundary here, which is the one
562
638
  * place the captured bytes are decoded.
563
639
  *
564
640
  * @param input - The captured bytes, terminal state, and capture limit
@@ -566,7 +642,7 @@ async function stopChild(child, grace, confirm) {
566
642
  *
567
643
  * @example
568
644
  * ```ts
569
- * buildRunResult({
645
+ * buildExecuteResult({
570
646
  * command: 'git status',
571
647
  * stdout: Buffer.from('ok'),
572
648
  * stderr: Buffer.alloc(0),
@@ -579,7 +655,7 @@ async function stopChild(child, grace, confirm) {
579
655
  * }).failed // false
580
656
  * ```
581
657
  */
582
- function buildRunResult(input) {
658
+ function buildExecuteResult(input) {
583
659
  return Object.freeze({
584
660
  command: input.command,
585
661
  stdout: trimHead(input.stdout, input.limit).toString("utf8"),
@@ -592,247 +668,6 @@ function buildRunResult(input) {
592
668
  truncated: input.truncated
593
669
  });
594
670
  }
595
- /**
596
- * Runs one command to completion, buffering its output, and settles with the outcome.
597
- *
598
- * @remarks
599
- * The executable is resolved through {@link buildSpawn}, so no run uses a shell. On a POSIX host the
600
- * child is detached, which is what lets its whole process group be terminated. Standard output and
601
- * error are each byte-bounded by `limit` (default {@link PROCESS_OUTPUT}) and `truncated` reports
602
- * whether either stream exceeded it. A positive `timeout` and an aborting `signal` both terminate the
603
- * child through the same bounded stop, and only the first of them is recorded: a timeout reports
604
- * `expired` and an abort reports `aborted`, never both. After termination the outcome is awaited for
605
- * a bounded window, so a descendant holding the child's stdio cannot keep the run pending. That bound
606
- * covers a terminated run alone: a run with no `timeout` and no `signal` settles on stdio completion
607
- * rather than on process exit, so a descendant that inherited the child's stdio holds the run open
608
- * after the child itself has gone. Give such a run a `timeout`. The child's `environment` merges over
609
- * the parent unless the command is `isolated`, then `options.environment` on top, and `options.input`
610
- * overrides `command.input`. Unless `strict` is `false`, a failed run rejects with a
611
- * {@link createRunError} carrying the {@link RunResult}. An invalid option or command string rejects
612
- * before the child is spawned, because an async function cannot throw synchronously.
613
- *
614
- * @param command - The executable, arguments, and optional environment and input
615
- * @param options - Working directory, timeout, grace, signal, capture limit, and failure delivery
616
- * @returns The settled run outcome
617
- * @throws A {@link ProcessError} coded `invalid` for a malformed option, command string, or batch-bound argument, or one carrying the {@link RunResult} when the run failed and `strict` is not `false`
618
- *
619
- * @example
620
- * ```ts
621
- * const result = await run({ file: 'git', arguments: ['status'] }, { workspace: process.cwd() })
622
- * ```
623
- */
624
- async function run(command, options) {
625
- validateCommand(command);
626
- validateEnvironment(options?.environment);
627
- validateWorkspace(options?.workspace);
628
- validateTimer(options?.timeout, "option 'timeout'");
629
- validateTimer(options?.grace, "option 'grace'");
630
- validateBytes(options?.limit, "option 'limit'", 0);
631
- const limit = options?.limit ?? _src_core.PROCESS_OUTPUT;
632
- const grace = options?.grace ?? _src_core.PROCESS_GRACE;
633
- const timeout = options?.timeout ?? 0;
634
- const strict = options?.strict ?? true;
635
- const text = options?.input ?? command.input;
636
- const line = formatCommand(command);
637
- const workspace = options?.workspace ?? process.cwd();
638
- const environment = mergeEnvironment(command.isolated === true, command.environment, options?.environment);
639
- const plan = buildSpawn(command, {
640
- workspace,
641
- environment
642
- });
643
- const settled = Promise.withResolvers();
644
- const terminate = new AbortController();
645
- const cleanup = new AbortController();
646
- const finish = new AbortController();
647
- const outChunks = [];
648
- const errChunks = [];
649
- const outCounts = [0, 0];
650
- const errCounts = [0, 0];
651
- let spawned = false;
652
- let expired = false;
653
- let aborted = false;
654
- let cause;
655
- let timeoutTimer;
656
- let confirmTimer;
657
- const child = (0, node_child_process.spawn)(plan.file, [...plan.arguments], {
658
- cwd: workspace,
659
- detached: process.platform !== "win32",
660
- env: environment,
661
- stdio: [
662
- "pipe",
663
- "pipe",
664
- "pipe"
665
- ],
666
- windowsHide: true,
667
- windowsVerbatimArguments: plan.verbatim
668
- });
669
- finish.signal.addEventListener("abort", () => {
670
- clearTimeout(timeoutTimer);
671
- clearTimeout(confirmTimer);
672
- cleanup.abort();
673
- settled.resolve(buildRunResult({
674
- command: line,
675
- stdout: node_buffer.Buffer.concat(outChunks),
676
- stderr: node_buffer.Buffer.concat(errChunks),
677
- code: child.exitCode,
678
- signal: child.signalCode,
679
- expired,
680
- aborted,
681
- truncated: (outCounts[0] ?? 0) > limit || (errCounts[0] ?? 0) > limit,
682
- limit,
683
- ...cause === void 0 ? {} : { cause }
684
- }));
685
- }, { once: true });
686
- terminate.signal.addEventListener("abort", () => {
687
- clearTimeout(timeoutTimer);
688
- cleanup.abort();
689
- stopChild(child, grace, _src_core.PROCESS_CONFIRMATION).then(() => {
690
- if (finish.signal.aborted) return;
691
- confirmTimer = setTimeout(() => finish.abort(), _src_core.PROCESS_CONFIRMATION);
692
- });
693
- }, { once: true });
694
- child.stdin.on("error", () => void 0);
695
- child.stdout.on("data", (chunk) => retainChunk(chunk, outChunks, outCounts, limit));
696
- child.stderr.on("data", (chunk) => retainChunk(chunk, errChunks, errCounts, limit));
697
- child.once("spawn", () => {
698
- spawned = true;
699
- });
700
- child.once("error", (error) => {
701
- if (spawned) return;
702
- cause = error;
703
- finish.abort();
704
- });
705
- child.once("close", () => finish.abort());
706
- if (text !== void 0) child.stdin.write(text);
707
- child.stdin.end();
708
- if (timeout > 0) timeoutTimer = setTimeout(() => {
709
- expired = true;
710
- terminate.abort();
711
- }, timeout);
712
- if (options?.signal !== void 0) {
713
- const signal = options.signal;
714
- signal.addEventListener("abort", () => {
715
- aborted = true;
716
- terminate.abort();
717
- }, {
718
- once: true,
719
- signal: cleanup.signal
720
- });
721
- if (signal.aborted) {
722
- aborted = true;
723
- terminate.abort();
724
- }
725
- }
726
- const result = await settled.promise;
727
- if (result.failed && strict) throw (0, _src_core.createRunError)(result, cause);
728
- return result;
729
- }
730
- /**
731
- * Runs one command to completion synchronously, buffering its output, and returns the outcome.
732
- *
733
- * @remarks
734
- * The synchronous counterpart of {@link run}, spawned through the same resolver and never through a
735
- * shell. The host offers no cooperative termination window and no in-flight cancellation, so this
736
- * contract carries neither. A positive `timeout` and an output overflow both end the child with
737
- * `SIGKILL`: an overflow reports `truncated` and `failed` together and trims the partial output to
738
- * `limit`, where {@link run} keeps reading and reports `truncated` without failing. The environment
739
- * and input follow the same merge as {@link run}. Unless `strict` is `false`, a failed run throws a
740
- * {@link createRunError} carrying the {@link RunResult}.
741
- *
742
- * @param command - The executable, arguments, and optional environment and input
743
- * @param options - Working directory, timeout, capture limit, and failure delivery
744
- * @returns The run outcome
745
- * @throws A {@link ProcessError} coded `invalid` for a malformed option, command string, or batch-bound argument, or one carrying the {@link RunResult} when the run failed and `strict` is not `false`
746
- *
747
- * @example
748
- * ```ts
749
- * const result = runSync({ file: 'git', arguments: ['--version'] }, { strict: false })
750
- * ```
751
- */
752
- function runSync(command, options) {
753
- validateCommand(command);
754
- validateEnvironment(options?.environment);
755
- validateWorkspace(options?.workspace);
756
- validateTimer(options?.timeout, "option 'timeout'");
757
- validateBytes(options?.limit, "option 'limit'", 0);
758
- const limit = options?.limit ?? _src_core.PROCESS_OUTPUT;
759
- const timeout = options?.timeout ?? 0;
760
- const strict = options?.strict ?? true;
761
- const text = options?.input ?? command.input;
762
- const line = formatCommand(command);
763
- const workspace = options?.workspace ?? process.cwd();
764
- const environment = mergeEnvironment(command.isolated === true, command.environment, options?.environment);
765
- const plan = buildSpawn(command, {
766
- workspace,
767
- environment
768
- });
769
- const outcome = (0, node_child_process.spawnSync)(plan.file, [...plan.arguments], {
770
- cwd: workspace,
771
- env: environment,
772
- encoding: "buffer",
773
- maxBuffer: limit,
774
- killSignal: "SIGKILL",
775
- windowsHide: true,
776
- windowsVerbatimArguments: plan.verbatim,
777
- ...text !== void 0 ? { input: text } : {},
778
- ...timeout > 0 ? { timeout } : {}
779
- });
780
- const error = outcome.error;
781
- const fault = error !== void 0 && "code" in error && (0, _orkestrel_contract.isString)(error.code) ? error.code : "";
782
- const result = buildRunResult({
783
- command: line,
784
- stdout: node_buffer.Buffer.isBuffer(outcome.stdout) ? outcome.stdout : node_buffer.Buffer.alloc(0),
785
- stderr: node_buffer.Buffer.isBuffer(outcome.stderr) ? outcome.stderr : node_buffer.Buffer.alloc(0),
786
- code: outcome.status,
787
- signal: outcome.signal,
788
- expired: fault === "ETIMEDOUT",
789
- aborted: false,
790
- truncated: fault === "ENOBUFS",
791
- limit,
792
- ...error === void 0 ? {} : { cause: error }
793
- });
794
- if (result.failed && strict) throw (0, _src_core.createRunError)(result, error);
795
- return result;
796
- }
797
- /**
798
- * Spawns one command as a detached process and returns without waiting for it.
799
- *
800
- * @remarks
801
- * The child owns no stdio and is unreferenced, so it outlives this process and nothing here observes
802
- * its outcome. The error listener is attached before the child is unreferenced, so a post-spawn host
803
- * fault is swallowed rather than crashing the caller; an invalid command is refused before anything
804
- * is spawned.
805
- *
806
- * @param command - The executable, arguments, and optional environment
807
- * @param options - The working directory the detached child starts in
808
- * @returns Nothing
809
- * @throws A {@link ProcessError} coded `invalid` when the working directory or a command string is malformed
810
- *
811
- * @example
812
- * ```ts
813
- * detach({ file: 'node', arguments: ['daemon.js'] }, { workspace: process.cwd() })
814
- * ```
815
- */
816
- function detach(command, options) {
817
- validateCommand(command);
818
- validateWorkspace(options?.workspace);
819
- const workspace = options?.workspace ?? process.cwd();
820
- const environment = mergeEnvironment(command.isolated === true, command.environment);
821
- const plan = buildSpawn(command, {
822
- workspace,
823
- environment
824
- });
825
- const child = (0, node_child_process.spawn)(plan.file, [...plan.arguments], {
826
- cwd: workspace,
827
- detached: true,
828
- env: environment,
829
- stdio: "ignore",
830
- windowsHide: true,
831
- windowsVerbatimArguments: plan.verbatim
832
- });
833
- child.once("error", () => void 0);
834
- child.unref();
835
- }
836
671
  //#endregion
837
672
  //#region src/server/Process.ts
838
673
  /**
@@ -840,20 +675,22 @@ function detach(command, options) {
840
675
  *
841
676
  * @remarks
842
677
  * Standard output is framed through `readline` into a single-consumer stream: each line goes to
843
- * exactly one waiting iterator, so two iterators split the output rather than each receiving all of
844
- * it. While no consumer has ever requested an iterator,
845
- * stdout keeps draining so `exit` still resolves, and retention stops at the `backlog` mark: a
846
- * consumer attaching later receives the retained head, a gap, then the live stream. Once an iterator
678
+ * exactly one waiting iterator, so concurrent iterators split the output rather than each receiving
679
+ * all of it. While no consumer has ever requested an iterator, stdout keeps draining so `exit` still
680
+ * resolves, and retention stops at the `backlog` mark: a
681
+ * consumer attaching later receives the retained head, a gap, then the live stream. After an iterator
847
682
  * has been requested, stdout pauses at the mark and resumes at half of it, so that consumer loses
848
- * nothing and the child feels real backpressure. The mark is soft — the host delivers whole chunks,
849
- * so the backlog overshoots it by up to one delivered chunk plus the drain that termination
850
- * releases. Standard error is decoded and forwarded live as the `stderr` event while a byte-bounded
851
- * raw tail is retained as `evidence`. The typed `emitter` also carries the child `error` cause on a
852
- * spawn fault and the terminal `exit`, alongside the `exit` promise. `stop` ends the whole tree and
853
- * reports whether the native exit was observed; `destroy` stops the child, destroys stdin so every
854
- * pending `send` settles, and destroys the emitter last. `destroy` resolving does not imply the
855
- * child's stdio has closed, so `exit` and `lines` can still be outstanding when a descendant holds
856
- * an inherited pipe.
683
+ * nothing before termination and the child feels real backpressure. The mark is soft — the ordinary backlog can
684
+ * overshoot it by the line that crossed it plus the rest of its delivered chunk. Termination never
685
+ * reapplies backpressure, and retained lines are capped at twice `backlog`; later lines are dropped
686
+ * and `truncated` reports the omission. Standard error is decoded and forwarded live as the `stderr`
687
+ * event while a byte-bounded raw tail is retained as `evidence`. The typed `emitter` also carries the
688
+ * child `error` cause on a spawn fault and the terminal `exit`, alongside the `exit` promise. `pid`,
689
+ * `code`, and `signal` read the spawned child directly, so the native exit is readable before the
690
+ * `exit` promise settles on stdio close. `stop` ends the whole tree and reports whether the native
691
+ * exit was observed; `destroy` stops the child, destroys stdin so every pending `send` settles, and
692
+ * destroys the emitter last. `destroy` resolving does not imply the child's stdio has closed, so
693
+ * `exit` and `lines` can still be outstanding when a descendant holds an inherited pipe.
857
694
  *
858
695
  * @example
859
696
  * ```ts
@@ -886,6 +723,7 @@ var Process = class {
886
723
  #pending = 0;
887
724
  #requested = false;
888
725
  #paused = false;
726
+ #truncated = false;
889
727
  #terminating = false;
890
728
  #closed = false;
891
729
  #ended = false;
@@ -898,7 +736,7 @@ var Process = class {
898
736
  * @throws A {@link ProcessError} coded `invalid` when an option or command string is malformed
899
737
  */
900
738
  constructor(options) {
901
- const command = options.command;
739
+ const source = options.command;
902
740
  const workspace = options.workspace;
903
741
  const grace = options.grace;
904
742
  const evidence = options.evidence;
@@ -907,8 +745,7 @@ var Process = class {
907
745
  const signal = options.signal;
908
746
  const on = options.on;
909
747
  const error = options.error;
910
- const isolated = command.isolated;
911
- const overrides = command.environment;
748
+ const command = snapshotCommand(source);
912
749
  const input = command.input;
913
750
  validateCommand(command);
914
751
  validateWorkspace(workspace);
@@ -924,15 +761,15 @@ var Process = class {
924
761
  this.#backlog = backlog ?? _src_core.PROCESS_BACKLOG;
925
762
  this.#signal = signal;
926
763
  this.#lines = Object.freeze({ [Symbol.asyncIterator]: this.#iterate.bind(this) });
927
- const environment = mergeEnvironment(isolated === true, overrides);
764
+ const childEnvironment = mergeEnvironment(command.isolated === true, command.environment);
928
765
  const plan = buildSpawn(command, {
929
766
  workspace,
930
- environment
767
+ environment: childEnvironment
931
768
  });
932
769
  this.#child = (0, node_child_process.spawn)(plan.file, [...plan.arguments], {
933
770
  cwd: workspace,
934
771
  detached: process.platform !== "win32",
935
- env: environment,
772
+ env: childEnvironment,
936
773
  stdio: [
937
774
  "pipe",
938
775
  "pipe",
@@ -959,6 +796,18 @@ var Process = class {
959
796
  if (signal.aborted) this.stop();
960
797
  }
961
798
  }
799
+ /** The host process id, fixed when construction returns, or `undefined` when the spawn produced none. */
800
+ get pid() {
801
+ return this.#child.pid;
802
+ }
803
+ /** The exit code the host recorded, or `null` while the child has not exited and when a signal ended it. */
804
+ get code() {
805
+ return this.#child.exitCode;
806
+ }
807
+ /** The terminating signal name the host recorded, or `null` while the child has not exited and when it exited on its own. */
808
+ get signal() {
809
+ return this.#child.signalCode;
810
+ }
962
811
  /** The typed lifecycle observation surface. */
963
812
  get emitter() {
964
813
  return this.#emitter;
@@ -971,6 +820,10 @@ var Process = class {
971
820
  get evidence() {
972
821
  return this.#tail.toString("utf8");
973
822
  }
823
+ /** True when the `lines` stream omitted output after a retention bound was reached. */
824
+ get truncated() {
825
+ return this.#truncated;
826
+ }
974
827
  /** The terminal child state, observed once from the close event. */
975
828
  get exit() {
976
829
  return this.#exit.promise;
@@ -1069,7 +922,11 @@ var Process = class {
1069
922
  return;
1070
923
  }
1071
924
  const bytes = node_buffer.Buffer.byteLength(line) + 1;
1072
- if (!this.#requested && this.#pending + bytes > this.#backlog) return;
925
+ const limit = this.#terminating ? this.#backlog * 2 : this.#backlog;
926
+ if ((!this.#requested || this.#terminating) && this.#pending + bytes > limit) {
927
+ this.#truncated = true;
928
+ return;
929
+ }
1073
930
  this.#queue.push(line);
1074
931
  this.#pending += bytes;
1075
932
  this.#restrain();
@@ -1107,7 +964,7 @@ var Process = class {
1107
964
  const suffix = this.#decoder.end();
1108
965
  if (suffix.length > 0) this.#emitter.emit("stderr", suffix);
1109
966
  this.#closed = true;
1110
- if (this.#signal !== void 0 && this.#abort !== void 0) this.#signal.removeEventListener("abort", this.#abort);
967
+ this.#removeAbortListener();
1111
968
  this.#reader.close();
1112
969
  const exit = Object.freeze({
1113
970
  code,
@@ -1119,14 +976,29 @@ var Process = class {
1119
976
  #terminate() {
1120
977
  this.stop();
1121
978
  }
979
+ #removeAbortListener() {
980
+ if (this.#signal === void 0 || this.#abort === void 0) return;
981
+ this.#signal.removeEventListener("abort", this.#abort);
982
+ }
983
+ #capBacklog() {
984
+ const limit = this.#backlog * 2;
985
+ while (this.#pending > limit && this.#queue.length > this.#head) {
986
+ const line = this.#queue.pop();
987
+ if (line === void 0) break;
988
+ this.#pending -= node_buffer.Buffer.byteLength(line) + 1;
989
+ this.#truncated = true;
990
+ }
991
+ }
1122
992
  async #kill() {
1123
993
  this.#terminating = true;
994
+ this.#capBacklog();
1124
995
  this.#relieve();
1125
996
  const confirmed = await stopChild(this.#child, this.#grace, _src_core.PROCESS_CONFIRMATION);
1126
997
  this.#child.stdin.destroy();
1127
998
  return confirmed;
1128
999
  }
1129
1000
  async #teardown() {
1001
+ this.#removeAbortListener();
1130
1002
  await this.stop();
1131
1003
  this.#emitter.destroy();
1132
1004
  }
@@ -1214,8 +1086,9 @@ var ProcessManager = class {
1214
1086
  * nothing has started and a throw from one strands no process. A getter that begins `destroy`
1215
1087
  * without throwing is the one remaining race, and it leaves a bounded residual: the child is
1216
1088
  * already spawned, so the launch is refused with a {@link createProtocolError} and that child is
1217
- * torn down asynchronously, bounded by `grace` plus the confirmation window. The `destroy` barrier
1218
- * settled before the refusal, so awaiting it does not cover that teardown.
1089
+ * torn down asynchronously, bounded by `grace` plus the confirmation window. The `protocol`
1090
+ * refusal throws synchronously before the `destroy` barrier settles. The barrier does not cover
1091
+ * that child's asynchronous teardown.
1219
1092
  *
1220
1093
  * @param id - The registry key, unique among live children
1221
1094
  * @param options - The child construction options
@@ -1329,25 +1202,342 @@ function createProcessManager(options) {
1329
1202
  return new ProcessManager(options);
1330
1203
  }
1331
1204
  //#endregion
1205
+ //#region src/server/Retention.ts
1206
+ /**
1207
+ * Accumulates byte totals while retaining a bounded stream head.
1208
+ *
1209
+ * @example
1210
+ * ```ts
1211
+ * const retention = new Retention()
1212
+ * retention.retain(Buffer.from('hello'), 3)?.toString('utf8') // 'hel'
1213
+ * retention.retained // 3
1214
+ * ```
1215
+ */
1216
+ var Retention = class {
1217
+ #delivered = 0;
1218
+ #retained = 0;
1219
+ /** The bytes delivered by the stream. */
1220
+ get delivered() {
1221
+ return this.#delivered;
1222
+ }
1223
+ /** The bytes retained from the stream head. */
1224
+ get retained() {
1225
+ return this.#retained;
1226
+ }
1227
+ /**
1228
+ * Retains a delivered chunk within a byte limit.
1229
+ *
1230
+ * @param chunk - The delivered chunk, ignored when it is not a buffer
1231
+ * @param limit - The maximum retained byte count
1232
+ * @returns The retained slice, or `undefined` when the chunk contributes no retained bytes
1233
+ */
1234
+ retain(chunk, limit) {
1235
+ if (!node_buffer.Buffer.isBuffer(chunk)) return void 0;
1236
+ this.#delivered += chunk.byteLength;
1237
+ const room = limit - this.#retained;
1238
+ if (room <= 0) return void 0;
1239
+ const slice = chunk.byteLength <= room ? chunk : node_buffer.Buffer.from(chunk.subarray(0, room));
1240
+ this.#retained += slice.byteLength;
1241
+ return slice;
1242
+ }
1243
+ };
1244
+ //#endregion
1245
+ //#region src/server/execution/execute.ts
1246
+ /**
1247
+ * Runs one command to completion, buffering its output, and settles with the outcome.
1248
+ *
1249
+ * @remarks
1250
+ * The executable is resolved through {@link buildSpawn}, so no run uses a shell. On a POSIX host the
1251
+ * child is detached, which is what lets its whole process group be terminated. Standard output and
1252
+ * error are each byte-bounded by `limit` (default {@link PROCESS_OUTPUT}) and `truncated` reports
1253
+ * whether either stream exceeded it. A positive `timeout` and an aborting `signal` both terminate the
1254
+ * child through the same bounded stop, and only the earliest is recorded: a timeout reports
1255
+ * `expired` and an abort reports `aborted`, never both. After termination the outcome is awaited for
1256
+ * a bounded window, so a descendant holding the child's stdio cannot keep the run pending. That bound
1257
+ * covers a terminated run alone: a run with no `timeout` and no `signal` settles on stdio completion
1258
+ * rather than on process exit, so a descendant that inherited the child's stdio holds the run open
1259
+ * after the child itself has gone. Give such a run a `timeout`. The child's `environment` merges over
1260
+ * the parent unless the command is `isolated`, then `options.environment` on top, and `options.input`
1261
+ * overrides `command.input`. Unless `strict` is `false`, a failed run rejects with a
1262
+ * {@link createExecuteError} carrying the {@link ExecuteResult}. An invalid option or command string rejects
1263
+ * before the child is spawned, because an async function cannot throw synchronously.
1264
+ *
1265
+ * @param command - The executable, arguments, and optional environment and input
1266
+ * @param options - Working directory, timeout, grace, signal, capture limit, and failure delivery
1267
+ * @returns The settled run outcome
1268
+ * @throws A {@link ProcessError} coded `invalid` for a malformed option, command string, or batch-bound argument, or one carrying the {@link ExecuteResult} when the run failed and `strict` is not `false`
1269
+ *
1270
+ * @example
1271
+ * ```ts
1272
+ * const result = await execute({ file: 'git', arguments: ['status'] }, { workspace: process.cwd() })
1273
+ * ```
1274
+ */
1275
+ async function execute(command, options) {
1276
+ const snapshot = snapshotCommand(command);
1277
+ const optionEnvironment = options?.environment;
1278
+ const optionWorkspace = options?.workspace;
1279
+ const optionInput = options?.input;
1280
+ const optionTimeout = options?.timeout;
1281
+ const optionGrace = options?.grace;
1282
+ const optionSignal = options?.signal;
1283
+ const optionStrict = options?.strict;
1284
+ const optionLimit = options?.limit;
1285
+ validateCommand(snapshot);
1286
+ validateEnvironment(optionEnvironment);
1287
+ validateWorkspace(optionWorkspace);
1288
+ validateTimer(optionTimeout, "option 'timeout'");
1289
+ validateTimer(optionGrace, "option 'grace'");
1290
+ validateBytes(optionLimit, "option 'limit'", 0);
1291
+ const limit = optionLimit ?? _src_core.PROCESS_OUTPUT;
1292
+ const grace = optionGrace ?? _src_core.PROCESS_GRACE;
1293
+ const timeout = optionTimeout ?? 0;
1294
+ const strict = optionStrict ?? true;
1295
+ const text = optionInput ?? snapshot.input;
1296
+ const line = formatCommand(snapshot);
1297
+ const workspace = optionWorkspace ?? process.cwd();
1298
+ const environment = mergeEnvironment(snapshot.isolated === true, snapshot.environment, optionEnvironment);
1299
+ const plan = buildSpawn(snapshot, {
1300
+ workspace,
1301
+ environment
1302
+ });
1303
+ const settled = Promise.withResolvers();
1304
+ const terminate = new AbortController();
1305
+ const cleanup = new AbortController();
1306
+ const finish = new AbortController();
1307
+ const outChunks = [];
1308
+ const errChunks = [];
1309
+ const outRetention = new Retention();
1310
+ const errRetention = new Retention();
1311
+ let spawned = false;
1312
+ let expired = false;
1313
+ let aborted = false;
1314
+ let cause;
1315
+ let timeoutTimer;
1316
+ let confirmTimer;
1317
+ const child = (0, node_child_process.spawn)(plan.file, [...plan.arguments], {
1318
+ cwd: workspace,
1319
+ detached: process.platform !== "win32",
1320
+ env: environment,
1321
+ stdio: [
1322
+ "pipe",
1323
+ "pipe",
1324
+ "pipe"
1325
+ ],
1326
+ windowsHide: true,
1327
+ windowsVerbatimArguments: plan.verbatim
1328
+ });
1329
+ finish.signal.addEventListener("abort", () => {
1330
+ clearTimeout(timeoutTimer);
1331
+ clearTimeout(confirmTimer);
1332
+ cleanup.abort();
1333
+ settled.resolve(buildExecuteResult({
1334
+ command: line,
1335
+ stdout: node_buffer.Buffer.concat(outChunks),
1336
+ stderr: node_buffer.Buffer.concat(errChunks),
1337
+ code: child.exitCode,
1338
+ signal: child.signalCode,
1339
+ expired,
1340
+ aborted,
1341
+ truncated: outRetention.delivered > limit || errRetention.delivered > limit,
1342
+ limit,
1343
+ ...cause === void 0 ? {} : { cause }
1344
+ }));
1345
+ }, { once: true });
1346
+ terminate.signal.addEventListener("abort", () => {
1347
+ clearTimeout(timeoutTimer);
1348
+ cleanup.abort();
1349
+ stopChild(child, grace, _src_core.PROCESS_CONFIRMATION).then(() => {
1350
+ if (finish.signal.aborted) return;
1351
+ confirmTimer = setTimeout(() => finish.abort(), _src_core.PROCESS_CONFIRMATION);
1352
+ });
1353
+ }, { once: true });
1354
+ child.stdin.on("error", () => void 0);
1355
+ child.stdout.on("data", (chunk) => {
1356
+ const retained = outRetention.retain(chunk, limit);
1357
+ if (retained !== void 0) outChunks.push(retained);
1358
+ });
1359
+ child.stderr.on("data", (chunk) => {
1360
+ const retained = errRetention.retain(chunk, limit);
1361
+ if (retained !== void 0) errChunks.push(retained);
1362
+ });
1363
+ child.once("spawn", () => {
1364
+ spawned = true;
1365
+ });
1366
+ child.once("error", (error) => {
1367
+ if (spawned) return;
1368
+ cause = error;
1369
+ finish.abort();
1370
+ });
1371
+ child.once("close", () => finish.abort());
1372
+ if (text !== void 0) child.stdin.write(text);
1373
+ child.stdin.end();
1374
+ if (timeout > 0) timeoutTimer = setTimeout(() => {
1375
+ expired = true;
1376
+ terminate.abort();
1377
+ }, timeout);
1378
+ if (optionSignal !== void 0) {
1379
+ optionSignal.addEventListener("abort", () => {
1380
+ aborted = true;
1381
+ terminate.abort();
1382
+ }, {
1383
+ once: true,
1384
+ signal: cleanup.signal
1385
+ });
1386
+ if (optionSignal.aborted) {
1387
+ aborted = true;
1388
+ terminate.abort();
1389
+ }
1390
+ }
1391
+ const result = await settled.promise;
1392
+ if (result.failed && strict) throw (0, _src_core.createExecuteError)(result, cause);
1393
+ return result;
1394
+ }
1395
+ //#endregion
1396
+ //#region src/server/execution/executeSync.ts
1397
+ /**
1398
+ * Runs one command to completion synchronously, buffering its output, and returns the outcome.
1399
+ *
1400
+ * @remarks
1401
+ * The synchronous counterpart of {@link execute}, spawned through the same resolver and never through a
1402
+ * shell. The host offers no cooperative termination window and no in-flight cancellation, so this
1403
+ * contract carries neither. A positive `timeout` ends only the root process and can leave
1404
+ * descendants running; use {@link execute} or {@link Process} when timeout must terminate the tree. A
1405
+ * timeout and an output overflow both end the root with `SIGKILL`: an overflow reports `truncated`
1406
+ * and `failed` together and trims the partial output to `limit`, where {@link execute} keeps reading and
1407
+ * reports `truncated` without failing. The environment and input follow the same merge as
1408
+ * {@link execute}. Unless `strict` is `false`, a failed run throws a {@link createExecuteError}
1409
+ * carrying the {@link ExecuteResult}.
1410
+ *
1411
+ * @param command - The executable, arguments, and optional environment and input
1412
+ * @param options - Working directory, timeout, capture limit, and failure delivery
1413
+ * @returns The run outcome
1414
+ * @throws A {@link ProcessError} coded `invalid` for a malformed option, command string, or batch-bound argument, or one carrying the {@link ExecuteResult} when the run failed and `strict` is not `false`
1415
+ *
1416
+ * @example
1417
+ * ```ts
1418
+ * const result = executeSync({ file: 'git', arguments: ['--version'] }, { strict: false })
1419
+ * ```
1420
+ */
1421
+ function executeSync(command, options) {
1422
+ const snapshot = snapshotCommand(command);
1423
+ const optionEnvironment = options?.environment;
1424
+ const optionWorkspace = options?.workspace;
1425
+ const optionInput = options?.input;
1426
+ const optionTimeout = options?.timeout;
1427
+ const optionStrict = options?.strict;
1428
+ const optionLimit = options?.limit;
1429
+ validateCommand(snapshot);
1430
+ validateEnvironment(optionEnvironment);
1431
+ validateWorkspace(optionWorkspace);
1432
+ validateTimer(optionTimeout, "option 'timeout'");
1433
+ validateBytes(optionLimit, "option 'limit'", 0);
1434
+ const limit = optionLimit ?? _src_core.PROCESS_OUTPUT;
1435
+ const timeout = optionTimeout ?? 0;
1436
+ const strict = optionStrict ?? true;
1437
+ const text = optionInput ?? snapshot.input;
1438
+ const line = formatCommand(snapshot);
1439
+ const workspace = optionWorkspace ?? process.cwd();
1440
+ const environment = mergeEnvironment(snapshot.isolated === true, snapshot.environment, optionEnvironment);
1441
+ const plan = buildSpawn(snapshot, {
1442
+ workspace,
1443
+ environment
1444
+ });
1445
+ const outcome = (0, node_child_process.spawnSync)(plan.file, [...plan.arguments], {
1446
+ cwd: workspace,
1447
+ env: environment,
1448
+ encoding: "buffer",
1449
+ maxBuffer: limit,
1450
+ killSignal: "SIGKILL",
1451
+ windowsHide: true,
1452
+ windowsVerbatimArguments: plan.verbatim,
1453
+ ...text !== void 0 ? { input: node_buffer.Buffer.from(text) } : {},
1454
+ ...timeout > 0 ? { timeout } : {}
1455
+ });
1456
+ const error = outcome.error;
1457
+ const fault = error !== void 0 && "code" in error && (0, _orkestrel_contract.isString)(error.code) ? error.code : "";
1458
+ const result = buildExecuteResult({
1459
+ command: line,
1460
+ stdout: node_buffer.Buffer.isBuffer(outcome.stdout) ? outcome.stdout : node_buffer.Buffer.alloc(0),
1461
+ stderr: node_buffer.Buffer.isBuffer(outcome.stderr) ? outcome.stderr : node_buffer.Buffer.alloc(0),
1462
+ code: outcome.status,
1463
+ signal: outcome.signal,
1464
+ expired: fault === "ETIMEDOUT",
1465
+ aborted: false,
1466
+ truncated: fault === "ENOBUFS",
1467
+ limit,
1468
+ ...error === void 0 ? {} : { cause: error }
1469
+ });
1470
+ if (result.failed && strict) throw (0, _src_core.createExecuteError)(result, error);
1471
+ return result;
1472
+ }
1473
+ //#endregion
1474
+ //#region src/server/execution/detach.ts
1475
+ /**
1476
+ * Spawns one command as a detached process and returns without waiting for it.
1477
+ *
1478
+ * @remarks
1479
+ * The child owns no stdio and is unreferenced, so it outlives this process and nothing here observes
1480
+ * its outcome. The error listener is attached before the child is unreferenced, so a post-spawn host
1481
+ * fault is swallowed rather than crashing the caller; an invalid command is refused before anything
1482
+ * is spawned.
1483
+ *
1484
+ * @param command - The executable, arguments, and optional environment
1485
+ * @param options - The working directory the detached child starts in
1486
+ * @returns Nothing
1487
+ * @throws A {@link ProcessError} coded `invalid` when the working directory or a command string is malformed
1488
+ *
1489
+ * @example
1490
+ * ```ts
1491
+ * detach({ file: 'node', arguments: ['daemon.js'] }, { workspace: process.cwd() })
1492
+ * ```
1493
+ */
1494
+ function detach(command, options) {
1495
+ const snapshot = snapshotCommand(command);
1496
+ const optionWorkspace = options?.workspace;
1497
+ validateCommand(snapshot);
1498
+ validateWorkspace(optionWorkspace);
1499
+ const workspace = optionWorkspace ?? process.cwd();
1500
+ const environment = mergeEnvironment(snapshot.isolated === true, snapshot.environment);
1501
+ const plan = buildSpawn(snapshot, {
1502
+ workspace,
1503
+ environment
1504
+ });
1505
+ const child = (0, node_child_process.spawn)(plan.file, [...plan.arguments], {
1506
+ cwd: workspace,
1507
+ detached: true,
1508
+ env: environment,
1509
+ stdio: "ignore",
1510
+ windowsHide: true,
1511
+ windowsVerbatimArguments: plan.verbatim
1512
+ });
1513
+ child.once("error", () => void 0);
1514
+ child.unref();
1515
+ }
1516
+ //#endregion
1332
1517
  exports.Process = Process;
1333
1518
  exports.ProcessManager = ProcessManager;
1334
- exports.buildRunResult = buildRunResult;
1519
+ exports.Retention = Retention;
1520
+ exports.buildExecutableCandidates = buildExecutableCandidates;
1521
+ exports.buildExecuteResult = buildExecuteResult;
1522
+ exports.buildPlatformSpawn = buildPlatformSpawn;
1335
1523
  exports.buildSpawn = buildSpawn;
1336
1524
  exports.createProcess = createProcess;
1337
1525
  exports.createProcessManager = createProcessManager;
1338
1526
  exports.detach = detach;
1527
+ exports.execute = execute;
1528
+ exports.executeSync = executeSync;
1339
1529
  exports.formatCommand = formatCommand;
1340
1530
  exports.isExited = isExited;
1341
1531
  exports.isFile = isFile;
1342
1532
  exports.killProcess = killProcess;
1343
1533
  exports.killTree = killTree;
1344
1534
  exports.mergeEnvironment = mergeEnvironment;
1535
+ exports.mergePlatformEnvironment = mergePlatformEnvironment;
1345
1536
  exports.quoteArgument = quoteArgument;
1537
+ exports.readPlatformVariable = readPlatformVariable;
1346
1538
  exports.readVariable = readVariable;
1347
1539
  exports.resolveExecutable = resolveExecutable;
1348
- exports.retainChunk = retainChunk;
1349
- exports.run = run;
1350
- exports.runSync = runSync;
1540
+ exports.snapshotCommand = snapshotCommand;
1351
1541
  exports.stopChild = stopChild;
1352
1542
  exports.trimHead = trimHead;
1353
1543
  exports.trimTail = trimTail;