@beignet/cli 0.0.40 → 0.0.42

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 (111) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +139 -17
  3. package/dist/analysis/source-index.d.ts +38 -0
  4. package/dist/analysis/source-index.d.ts.map +1 -0
  5. package/dist/analysis/source-index.js +271 -0
  6. package/dist/analysis/source-index.js.map +1 -0
  7. package/dist/analysis/workspace.d.ts +16 -0
  8. package/dist/analysis/workspace.d.ts.map +1 -0
  9. package/dist/analysis/workspace.js +134 -0
  10. package/dist/analysis/workspace.js.map +1 -0
  11. package/dist/app-map-schema.d.ts +5 -0
  12. package/dist/app-map-schema.d.ts.map +1 -0
  13. package/dist/app-map-schema.js +26 -0
  14. package/dist/app-map-schema.js.map +1 -0
  15. package/dist/app-map.d.ts +96 -0
  16. package/dist/app-map.d.ts.map +1 -0
  17. package/dist/app-map.js +1141 -0
  18. package/dist/app-map.js.map +1 -0
  19. package/dist/check.d.ts +7 -0
  20. package/dist/check.d.ts.map +1 -1
  21. package/dist/check.js +20 -5
  22. package/dist/check.js.map +1 -1
  23. package/dist/choices.d.ts +18 -0
  24. package/dist/choices.d.ts.map +1 -1
  25. package/dist/choices.js +35 -0
  26. package/dist/choices.js.map +1 -1
  27. package/dist/db.d.ts +49 -13
  28. package/dist/db.d.ts.map +1 -1
  29. package/dist/db.js +202 -21
  30. package/dist/db.js.map +1 -1
  31. package/dist/doctor-fixes.d.ts +64 -0
  32. package/dist/doctor-fixes.d.ts.map +1 -0
  33. package/dist/doctor-fixes.js +142 -0
  34. package/dist/doctor-fixes.js.map +1 -0
  35. package/dist/explain.d.ts +100 -0
  36. package/dist/explain.d.ts.map +1 -0
  37. package/dist/explain.js +606 -0
  38. package/dist/explain.js.map +1 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +165 -21
  41. package/dist/index.js.map +1 -1
  42. package/dist/inspect.d.ts +33 -9
  43. package/dist/inspect.d.ts.map +1 -1
  44. package/dist/inspect.js +406 -119
  45. package/dist/inspect.js.map +1 -1
  46. package/dist/lib.d.ts +10 -2
  47. package/dist/lib.d.ts.map +1 -1
  48. package/dist/lib.js +5 -2
  49. package/dist/lib.js.map +1 -1
  50. package/dist/make/shared.js +3 -3
  51. package/dist/make/shared.js.map +1 -1
  52. package/dist/make.d.ts.map +1 -1
  53. package/dist/make.js +63 -2
  54. package/dist/make.js.map +1 -1
  55. package/dist/mcp.d.ts +1 -1
  56. package/dist/mcp.d.ts.map +1 -1
  57. package/dist/mcp.js +188 -12
  58. package/dist/mcp.js.map +1 -1
  59. package/dist/preflight.d.ts.map +1 -1
  60. package/dist/preflight.js +10 -0
  61. package/dist/preflight.js.map +1 -1
  62. package/dist/provider-add.d.ts.map +1 -1
  63. package/dist/provider-add.js +167 -13
  64. package/dist/provider-add.js.map +1 -1
  65. package/dist/provider-audit.d.ts +6 -0
  66. package/dist/provider-audit.d.ts.map +1 -1
  67. package/dist/provider-audit.js +63 -15
  68. package/dist/provider-audit.js.map +1 -1
  69. package/dist/templates/agents.d.ts.map +1 -1
  70. package/dist/templates/agents.js +35 -6
  71. package/dist/templates/agents.js.map +1 -1
  72. package/dist/templates/base.d.ts.map +1 -1
  73. package/dist/templates/base.js +13 -7
  74. package/dist/templates/base.js.map +1 -1
  75. package/dist/templates/index.d.ts +1 -1
  76. package/dist/templates/index.d.ts.map +1 -1
  77. package/dist/templates/index.js +8 -1
  78. package/dist/templates/index.js.map +1 -1
  79. package/dist/templates/server.d.ts +3 -0
  80. package/dist/templates/server.d.ts.map +1 -1
  81. package/dist/templates/server.js +55 -1
  82. package/dist/templates/server.js.map +1 -1
  83. package/dist/templates/shared.d.ts +2 -1
  84. package/dist/templates/shared.d.ts.map +1 -1
  85. package/dist/templates/shared.js +8 -5
  86. package/dist/templates/shared.js.map +1 -1
  87. package/package.json +3 -2
  88. package/skills/app-structure/SKILL.md +48 -6
  89. package/src/analysis/source-index.ts +395 -0
  90. package/src/analysis/workspace.ts +180 -0
  91. package/src/app-map-schema.ts +28 -0
  92. package/src/app-map.ts +1705 -0
  93. package/src/check.ts +27 -4
  94. package/src/choices.ts +57 -0
  95. package/src/db.ts +276 -28
  96. package/src/doctor-fixes.ts +252 -0
  97. package/src/explain.ts +894 -0
  98. package/src/index.ts +237 -31
  99. package/src/inspect.ts +567 -147
  100. package/src/lib.ts +64 -1
  101. package/src/make/shared.ts +3 -3
  102. package/src/make.ts +90 -2
  103. package/src/mcp.ts +292 -12
  104. package/src/preflight.ts +14 -0
  105. package/src/provider-add.ts +211 -12
  106. package/src/provider-audit.ts +127 -22
  107. package/src/templates/agents.ts +35 -6
  108. package/src/templates/base.ts +25 -6
  109. package/src/templates/index.ts +18 -1
  110. package/src/templates/server.ts +58 -1
  111. package/src/templates/shared.ts +15 -7
package/src/check.ts CHANGED
@@ -22,6 +22,7 @@ export type CheckStep = {
22
22
  status: "passed" | "failed" | "skipped";
23
23
  detail?: string;
24
24
  output?: string;
25
+ outputTruncated?: boolean;
25
26
  durationMs: number;
26
27
  };
27
28
 
@@ -40,11 +41,18 @@ export type CheckAppOptions = {
40
41
  color?: boolean;
41
42
  /** Append a `beignet preflight` step after the doctor step. */
42
43
  preflight?: boolean;
44
+ /** Keep only the last N bytes from each package-script output stream. */
45
+ maxOutputBytes?: number;
46
+ /** Cancel the active package script with the owning request. */
47
+ signal?: AbortSignal;
48
+ /** Fail each package script after this many milliseconds. */
49
+ scriptTimeoutMs?: number;
43
50
  /** Called after each step completes, for incremental progress output. */
44
51
  onStep?: (step: CheckStep) => void;
45
52
  };
46
53
 
47
54
  const scriptStepNames = ["lint", "typecheck", "test"] as const;
55
+ const defaultMaxOutputBytes = 64 * 1024;
48
56
 
49
57
  /**
50
58
  * Run the whole app validation loop as one command: `beignet lint`,
@@ -67,17 +75,21 @@ export async function checkApp(
67
75
  options.onStep?.(step);
68
76
  };
69
77
 
78
+ options.signal?.throwIfAborted();
70
79
  record(await lintStep(targetDir, options));
80
+ options.signal?.throwIfAborted();
71
81
  const { step: doctorStep, fixes } = await runDoctorStep(targetDir, options);
72
82
  record(doctorStep);
73
83
 
74
84
  if (options.preflight) {
85
+ options.signal?.throwIfAborted();
75
86
  record(await preflightStep(targetDir));
76
87
  }
77
88
 
78
89
  const scripts = await readPackageScripts(targetDir);
79
90
  for (const script of scriptStepNames) {
80
- record(await scriptStep(targetDir, runner, script, scripts));
91
+ options.signal?.throwIfAborted();
92
+ record(await scriptStep(targetDir, runner, script, scripts, options));
81
93
  }
82
94
 
83
95
  return {
@@ -114,7 +126,10 @@ export function formatCheckStep(
114
126
  .split("\n")
115
127
  .map((outputLine) => ` ${outputLine}`)
116
128
  .join("\n");
117
- return `${line}\n${indented}`;
129
+ const truncation = step.outputTruncated
130
+ ? "\n … output truncated; showing the retained tail"
131
+ : "";
132
+ return `${line}\n${indented}${truncation}`;
118
133
  }
119
134
 
120
135
  /**
@@ -203,6 +218,7 @@ async function scriptStep(
203
218
  runner: string,
204
219
  script: (typeof scriptStepNames)[number],
205
220
  scripts: Record<string, string>,
221
+ options: CheckAppOptions,
206
222
  ): Promise<CheckStep> {
207
223
  const command = `${runner} run ${script}`;
208
224
  if (!scripts[script]) {
@@ -218,17 +234,24 @@ async function scriptStep(
218
234
  const startedAt = Date.now();
219
235
  const result = await spawnCommand(runner, ["run", script], targetDir, {
220
236
  captureOutput: true,
237
+ maxOutputBytes: options.maxOutputBytes ?? defaultMaxOutputBytes,
238
+ signal: options.signal,
239
+ timeoutMs: options.scriptTimeoutMs,
221
240
  });
222
- const failed = result.exitCode !== 0;
241
+ const failed = result.timedOut === true || result.exitCode !== 0;
242
+ const detail = result.timedOut
243
+ ? `${command}; timed out after ${options.scriptTimeoutMs}ms`
244
+ : command;
223
245
 
224
246
  return {
225
247
  name: script,
226
248
  command,
227
249
  status: failed ? "failed" : "passed",
228
- detail: command,
250
+ detail,
229
251
  ...(failed
230
252
  ? { output: [result.stdout, result.stderr].filter(Boolean).join("\n") }
231
253
  : {}),
254
+ ...(failed && result.outputTruncated ? { outputTruncated: true } : {}),
232
255
  durationMs: Date.now() - startedAt,
233
256
  };
234
257
  }
package/src/choices.ts CHANGED
@@ -5,6 +5,12 @@
5
5
  * templates or generators at startup.
6
6
  */
7
7
 
8
+ function defineExhaustiveChoices<Choice extends string>() {
9
+ return <const Choices extends readonly Choice[]>(
10
+ choices: Exclude<Choice, Choices[number]> extends never ? Choices : never,
11
+ ): Choices => choices;
12
+ }
13
+
8
14
  /**
9
15
  * Package managers supported by the app template generator.
10
16
  */
@@ -13,6 +19,23 @@ export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
13
19
  * Application templates supported by the app template generator.
14
20
  */
15
21
  export type TemplateName = "next";
22
+
23
+ /**
24
+ * Stable repair operations supported by doctor fix planning.
25
+ */
26
+ export const doctorFixOperationIds = [
27
+ "package.repair-generated-support",
28
+ "routes.register-missing",
29
+ "schedules.register-missing",
30
+ "tasks.register-missing",
31
+ "workflows.register-missing",
32
+ "outbox.register-missing",
33
+ "listeners.register-missing",
34
+ "openapi.register-missing",
35
+ ] as const;
36
+
37
+ /** Stable identifier for one selectable doctor repair operation. */
38
+ export type DoctorFixOperationId = (typeof doctorFixOperationIds)[number];
16
39
  /**
17
40
  * Optional providers rendered directly by the starter template.
18
41
  *
@@ -27,6 +50,40 @@ export type StarterProviderName =
27
50
  * Databases supported by the starter's Drizzle persistence layer.
28
51
  */
29
52
  export type DatabaseName = "sqlite" | "postgres" | "mysql";
53
+
54
+ /** Database lifecycle command supported by Beignet. */
55
+ export type DatabaseCommand = "generate" | "migrate" | "seed" | "reset";
56
+
57
+ /** Database lifecycle commands shared by the CLI, library, and MCP tool. */
58
+ export const databaseCommandChoices =
59
+ defineExhaustiveChoices<DatabaseCommand>()([
60
+ "generate",
61
+ "migrate",
62
+ "seed",
63
+ "reset",
64
+ ]);
65
+
66
+ /** Drizzle dialect supported by provider-table schema sync. */
67
+ export type DatabaseSchemaDialect = "sqlite" | "postgres" | "mysql";
68
+
69
+ /** Drizzle dialects supported by provider-table schema sync. */
70
+ export const databaseSchemaDialectChoices =
71
+ defineExhaustiveChoices<DatabaseSchemaDialect>()([
72
+ "sqlite",
73
+ "postgres",
74
+ "mysql",
75
+ ]);
76
+
77
+ /** Beignet provider table supported by database schema sync. */
78
+ export type DatabaseSchemaTable = "audit" | "idempotency" | "outbox";
79
+
80
+ /** Beignet provider tables supported by database schema sync. */
81
+ export const databaseSchemaTableChoices =
82
+ defineExhaustiveChoices<DatabaseSchemaTable>()([
83
+ "audit",
84
+ "idempotency",
85
+ "outbox",
86
+ ]);
30
87
  /**
31
88
  * Provider setup presets supported by `beignet provider add`.
32
89
  */
package/src/db.ts CHANGED
@@ -1,11 +1,23 @@
1
- import { spawn } from "node:child_process";
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
2
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import {
5
+ type DatabaseCommand,
6
+ type DatabaseSchemaDialect,
7
+ type DatabaseSchemaTable,
8
+ databaseSchemaTableChoices,
9
+ } from "./choices.js";
10
+
11
+ export type {
12
+ DatabaseCommand,
13
+ DatabaseSchemaDialect,
14
+ DatabaseSchemaTable,
15
+ } from "./choices.js";
4
16
 
5
- /**
6
- * Database lifecycle operations supported by the Beignet CLI.
7
- */
8
- export type DatabaseCommand = "generate" | "migrate" | "seed" | "reset";
17
+ /** Default output retained per stream by structured database command callers. */
18
+ export const defaultDatabaseCommandMaxOutputBytes = 64 * 1024;
19
+ /** Default timeout used by request-controlled database command callers. */
20
+ export const defaultDatabaseCommandTimeoutMs = 10 * 60 * 1_000;
9
21
 
10
22
  /**
11
23
  * Options for running a database lifecycle command.
@@ -14,6 +26,12 @@ export type RunDatabaseCommandOptions = {
14
26
  command: DatabaseCommand;
15
27
  cwd?: string;
16
28
  captureOutput?: boolean;
29
+ /** Keep only the last N bytes from each output stream. */
30
+ maxOutputBytes?: number;
31
+ /** Cancel the app-owned database script when the owning request aborts. */
32
+ signal?: AbortSignal;
33
+ /** Stop the app-owned database script after this many milliseconds. */
34
+ timeoutMs?: number;
17
35
  dryRun?: boolean;
18
36
  };
19
37
 
@@ -29,21 +47,47 @@ export type RunDatabaseCommandResult = {
29
47
  args: string[];
30
48
  stdout?: string;
31
49
  stderr?: string;
50
+ outputTruncated?: boolean;
51
+ timedOut?: boolean;
32
52
  dryRun: boolean;
33
53
  exitCode: number;
34
54
  };
35
55
 
36
- export type DatabaseSchemaDialect = "sqlite" | "postgres" | "mysql";
37
- export type DatabaseSchemaTable = "audit" | "idempotency" | "outbox";
56
+ export type SpawnCommandOptions = {
57
+ captureOutput: boolean;
58
+ /** Keep only the last N bytes from each output stream. Unbounded when omitted. */
59
+ maxOutputBytes?: number;
60
+ /** Cancel the child process when the owning request is aborted. */
61
+ signal?: AbortSignal;
62
+ /** Stop the child process after this many milliseconds. Unbounded when omitted. */
63
+ timeoutMs?: number;
64
+ };
65
+
66
+ export type SpawnCommandResult = {
67
+ exitCode: number;
68
+ stdout?: string;
69
+ stderr?: string;
70
+ outputTruncated?: boolean;
71
+ timedOut?: boolean;
72
+ };
73
+
74
+ type ProcessTerminationPlan =
75
+ | { kind: "child"; signal: NodeJS.Signals }
76
+ | { kind: "process-group"; pid: number; signal: NodeJS.Signals }
77
+ | { kind: "windows-process-tree"; command: string; args: string[] };
38
78
 
79
+ /** Options for syncing app-owned Beignet provider-table schema re-exports. */
39
80
  export type SyncDatabaseSchemaOptions = {
40
81
  cwd?: string;
41
82
  dialect?: DatabaseSchemaDialect;
42
83
  tables?: readonly DatabaseSchemaTable[];
43
84
  output?: string;
44
85
  dryRun?: boolean;
86
+ /** Cancel before applying pending schema-file writes. */
87
+ signal?: AbortSignal;
45
88
  };
46
89
 
90
+ /** Versioned report returned by `syncDatabaseSchema`. */
47
91
  export type SyncDatabaseSchemaResult = {
48
92
  schemaVersion: 1;
49
93
  command: "schema:sync";
@@ -69,12 +113,6 @@ const databaseScripts: Record<DatabaseCommand, string> = {
69
113
  reset: "db:reset",
70
114
  };
71
115
 
72
- const allDatabaseSchemaTables = [
73
- "audit",
74
- "idempotency",
75
- "outbox",
76
- ] as const satisfies readonly DatabaseSchemaTable[];
77
-
78
116
  /**
79
117
  * Run an app-owned database lifecycle script.
80
118
  *
@@ -85,6 +123,7 @@ const allDatabaseSchemaTables = [
85
123
  export async function runDatabaseCommand(
86
124
  options: RunDatabaseCommandOptions,
87
125
  ): Promise<RunDatabaseCommandResult> {
126
+ options.signal?.throwIfAborted();
88
127
  const cwd = path.resolve(options.cwd ?? process.cwd());
89
128
  const script = databaseScripts[options.command];
90
129
  const packageJson = await readPackageJson(cwd);
@@ -95,6 +134,7 @@ export async function runDatabaseCommand(
95
134
  }
96
135
 
97
136
  await assertDatabaseCommandPreflight(cwd, options.command, scriptCommand);
137
+ options.signal?.throwIfAborted();
98
138
 
99
139
  const runner = await detectPackageManager(cwd);
100
140
  const args = ["run", script];
@@ -114,6 +154,9 @@ export async function runDatabaseCommand(
114
154
 
115
155
  const commandResult = await spawnCommand(runner, args, cwd, {
116
156
  captureOutput: Boolean(options.captureOutput),
157
+ maxOutputBytes: options.maxOutputBytes,
158
+ signal: options.signal,
159
+ timeoutMs: options.timeoutMs,
117
160
  });
118
161
 
119
162
  return {
@@ -128,6 +171,8 @@ export async function runDatabaseCommand(
128
171
  ...(options.captureOutput
129
172
  ? { stdout: commandResult.stdout, stderr: commandResult.stderr }
130
173
  : {}),
174
+ ...(commandResult.outputTruncated ? { outputTruncated: true } : {}),
175
+ ...(commandResult.timedOut ? { timedOut: true } : {}),
131
176
  };
132
177
  }
133
178
 
@@ -141,9 +186,12 @@ export async function runDatabaseCommand(
141
186
  export async function syncDatabaseSchema(
142
187
  options: SyncDatabaseSchemaOptions = {},
143
188
  ): Promise<SyncDatabaseSchemaResult> {
189
+ options.signal?.throwIfAborted();
144
190
  const cwd = path.resolve(options.cwd ?? process.cwd());
145
191
  const dialect = await resolveDatabaseSchemaDialect(cwd, options.dialect);
146
- const tables = uniqueSchemaTables(options.tables ?? allDatabaseSchemaTables);
192
+ const tables = uniqueSchemaTables(
193
+ options.tables ?? databaseSchemaTableChoices,
194
+ );
147
195
  const output = normalizeAppRelativePath(
148
196
  cwd,
149
197
  options.output ?? "infra/db/schema/beignet.ts",
@@ -173,6 +221,7 @@ export async function syncDatabaseSchema(
173
221
  dryRun: Boolean(options.dryRun),
174
222
  };
175
223
 
224
+ options.signal?.throwIfAborted();
176
225
  const schemaStatus = await writeProjectFile(
177
226
  cwd,
178
227
  output,
@@ -370,7 +419,7 @@ const schemaTableDefinitions: Record<
370
419
  function uniqueSchemaTables(
371
420
  tables: readonly DatabaseSchemaTable[],
372
421
  ): DatabaseSchemaTable[] {
373
- const unique = allDatabaseSchemaTables.filter((table) =>
422
+ const unique = databaseSchemaTableChoices.filter((table) =>
374
423
  tables.includes(table),
375
424
  );
376
425
  if (unique.length === 0) {
@@ -504,34 +553,233 @@ export function spawnCommand(
504
553
  command: string,
505
554
  args: readonly string[],
506
555
  cwd: string,
507
- options: { captureOutput: boolean },
508
- ): Promise<{ exitCode: number; stdout?: string; stderr?: string }> {
556
+ options: SpawnCommandOptions,
557
+ ): Promise<SpawnCommandResult> {
558
+ if (options.signal?.aborted) {
559
+ return Promise.reject(commandAbortError(options.signal));
560
+ }
561
+ const timeoutMs =
562
+ options.timeoutMs === undefined
563
+ ? undefined
564
+ : normalizePositiveInteger(options.timeoutMs, "Command timeout");
565
+ const maxOutputBytes =
566
+ options.maxOutputBytes === undefined
567
+ ? undefined
568
+ : normalizeNonNegativeInteger(
569
+ options.maxOutputBytes,
570
+ "Maximum output bytes",
571
+ );
572
+
509
573
  return new Promise((resolve, reject) => {
574
+ let settled = false;
575
+ let timedOut = false;
576
+ let timeout: ReturnType<typeof setTimeout> | undefined;
577
+ let forceKillTimeout: ReturnType<typeof setTimeout> | undefined;
578
+ const ownsProcessGroup = commandOwnsProcessGroup(process.platform, {
579
+ signal: options.signal,
580
+ timeoutMs,
581
+ });
510
582
  const child = spawn(command, args, {
511
583
  cwd,
584
+ detached: ownsProcessGroup,
512
585
  env: process.env,
513
586
  stdio: options.captureOutput ? ["ignore", "pipe", "pipe"] : "inherit",
514
587
  });
515
588
 
516
- const stdout: Buffer[] = [];
517
- const stderr: Buffer[] = [];
589
+ const stdout = createOutputCapture(maxOutputBytes);
590
+ const stderr = createOutputCapture(maxOutputBytes);
591
+
592
+ const cleanup = () => {
593
+ if (timeout) clearTimeout(timeout);
594
+ if (forceKillTimeout) clearTimeout(forceKillTimeout);
595
+ options.signal?.removeEventListener("abort", abort);
596
+ };
597
+ const abort = () => {
598
+ if (settled) return;
599
+ settled = true;
600
+ terminateChild(child, "SIGKILL", ownsProcessGroup);
601
+ cleanup();
602
+ reject(commandAbortError(options.signal));
603
+ };
518
604
 
519
605
  if (options.captureOutput) {
520
- child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk));
521
- child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk));
606
+ child.stdout?.on("data", (chunk: Buffer) => stdout.append(chunk));
607
+ child.stderr?.on("data", (chunk: Buffer) => stderr.append(chunk));
522
608
  }
523
609
 
524
- child.on("error", reject);
525
- child.on("close", (code) =>
610
+ child.on("error", (error) => {
611
+ if (settled) return;
612
+ settled = true;
613
+ cleanup();
614
+ reject(error);
615
+ });
616
+ child.on("close", (code) => {
617
+ if (settled) return;
618
+ if (timedOut && ownsProcessGroup && forceKillTimeout) {
619
+ terminateChild(child, "SIGKILL", ownsProcessGroup);
620
+ }
621
+ settled = true;
622
+ cleanup();
526
623
  resolve({
527
- exitCode: code ?? 1,
624
+ exitCode: timedOut ? 1 : (code ?? 1),
528
625
  ...(options.captureOutput
529
626
  ? {
530
- stdout: Buffer.concat(stdout).toString("utf8"),
531
- stderr: Buffer.concat(stderr).toString("utf8"),
627
+ stdout: stdout.text(),
628
+ stderr: stderr.text(),
629
+ ...(stdout.truncated() || stderr.truncated()
630
+ ? { outputTruncated: true }
631
+ : {}),
532
632
  }
533
633
  : {}),
534
- }),
535
- );
634
+ ...(timedOut ? { timedOut: true } : {}),
635
+ });
636
+ });
637
+
638
+ options.signal?.addEventListener("abort", abort, { once: true });
639
+ if (options.signal?.aborted) {
640
+ abort();
641
+ return;
642
+ }
643
+ if (timeoutMs !== undefined) {
644
+ timeout = setTimeout(() => {
645
+ if (settled) return;
646
+ timedOut = true;
647
+ terminateChild(child, "SIGTERM", ownsProcessGroup);
648
+ forceKillTimeout = setTimeout(() => {
649
+ forceKillTimeout = undefined;
650
+ if (!settled) terminateChild(child, "SIGKILL", ownsProcessGroup);
651
+ }, 1_000);
652
+ forceKillTimeout.unref?.();
653
+ }, timeoutMs);
654
+ timeout.unref?.();
655
+ }
536
656
  });
537
657
  }
658
+
659
+ export function commandOwnsProcessGroup(
660
+ platform: NodeJS.Platform,
661
+ options: Pick<SpawnCommandOptions, "signal" | "timeoutMs">,
662
+ ): boolean {
663
+ return (
664
+ platform !== "win32" &&
665
+ (options.signal !== undefined || options.timeoutMs !== undefined)
666
+ );
667
+ }
668
+
669
+ export function commandTerminationPlan(
670
+ platform: NodeJS.Platform,
671
+ pid: number | undefined,
672
+ signal: NodeJS.Signals,
673
+ ownsProcessGroup: boolean,
674
+ ): ProcessTerminationPlan {
675
+ if (platform === "win32" && pid !== undefined) {
676
+ return {
677
+ kind: "windows-process-tree",
678
+ command: "taskkill",
679
+ args: ["/PID", String(pid), "/T", "/F"],
680
+ };
681
+ }
682
+ if (ownsProcessGroup && pid !== undefined) {
683
+ return { kind: "process-group", pid: -pid, signal };
684
+ }
685
+ return { kind: "child", signal };
686
+ }
687
+
688
+ function terminateChild(
689
+ child: ChildProcess,
690
+ signal: NodeJS.Signals,
691
+ ownsProcessGroup: boolean,
692
+ ): void {
693
+ const plan = commandTerminationPlan(
694
+ process.platform,
695
+ child.pid,
696
+ signal,
697
+ ownsProcessGroup,
698
+ );
699
+ if (plan.kind === "windows-process-tree") {
700
+ const killer = spawn(plan.command, plan.args, {
701
+ stdio: "ignore",
702
+ windowsHide: true,
703
+ });
704
+ const fallback = () => {
705
+ if (child.exitCode === null) child.kill(signal);
706
+ };
707
+ killer.once("error", fallback);
708
+ killer.once("close", (code) => {
709
+ if (code !== 0) fallback();
710
+ });
711
+ killer.unref();
712
+ return;
713
+ }
714
+ if (plan.kind === "process-group") {
715
+ try {
716
+ process.kill(plan.pid, plan.signal);
717
+ return;
718
+ } catch (error) {
719
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") return;
720
+ }
721
+ }
722
+ child.kill(plan.signal);
723
+ }
724
+
725
+ function createOutputCapture(maxOutputBytes?: number): {
726
+ append(chunk: Buffer): void;
727
+ text(): string;
728
+ truncated(): boolean;
729
+ } {
730
+ const limit = maxOutputBytes;
731
+ const unboundedChunks: Buffer[] = [];
732
+ let output: Buffer = Buffer.alloc(0);
733
+ let wasTruncated = false;
734
+
735
+ return {
736
+ append(chunk) {
737
+ if (limit === undefined) {
738
+ unboundedChunks.push(chunk);
739
+ return;
740
+ }
741
+ if (limit === 0) {
742
+ if (chunk.length > 0) wasTruncated = true;
743
+ return;
744
+ }
745
+ if (chunk.length >= limit) {
746
+ wasTruncated ||= output.length > 0 || chunk.length > limit;
747
+ output = Buffer.from(chunk.subarray(chunk.length - limit));
748
+ return;
749
+ }
750
+ const overflow = output.length + chunk.length - limit;
751
+ if (overflow > 0) {
752
+ wasTruncated = true;
753
+ output = Buffer.concat([output.subarray(overflow), chunk]);
754
+ return;
755
+ }
756
+ output = Buffer.concat([output, chunk]);
757
+ },
758
+ text: () =>
759
+ (limit === undefined ? Buffer.concat(unboundedChunks) : output).toString(
760
+ "utf8",
761
+ ),
762
+ truncated: () => wasTruncated,
763
+ };
764
+ }
765
+
766
+ function commandAbortError(signal?: AbortSignal): Error {
767
+ if (signal?.reason instanceof Error) return signal.reason;
768
+ const error = new Error("Command cancelled.");
769
+ error.name = "AbortError";
770
+ return error;
771
+ }
772
+
773
+ function normalizePositiveInteger(value: number, label: string): number {
774
+ if (!Number.isSafeInteger(value) || value <= 0) {
775
+ throw new Error(`${label} must be a positive integer.`);
776
+ }
777
+ return value;
778
+ }
779
+
780
+ function normalizeNonNegativeInteger(value: number, label: string): number {
781
+ if (!Number.isSafeInteger(value) || value < 0) {
782
+ throw new Error(`${label} must be a non-negative integer.`);
783
+ }
784
+ return value;
785
+ }