@openparachute/hub 0.5.7 → 0.5.10-rc.10

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 (85) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-modules-ops.test.ts +658 -0
  8. package/src/__tests__/api-modules.test.ts +426 -0
  9. package/src/__tests__/api-revocation-list.test.ts +198 -0
  10. package/src/__tests__/api-revoke-token.test.ts +320 -0
  11. package/src/__tests__/api-tokens.test.ts +629 -0
  12. package/src/__tests__/auth.test.ts +680 -16
  13. package/src/__tests__/csrf.test.ts +40 -1
  14. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  15. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  16. package/src/__tests__/expose.test.ts +2 -2
  17. package/src/__tests__/hub-server.test.ts +584 -67
  18. package/src/__tests__/hub-settings.test.ts +377 -0
  19. package/src/__tests__/hub.test.ts +123 -53
  20. package/src/__tests__/install-source.test.ts +249 -0
  21. package/src/__tests__/jwt-sign.test.ts +205 -0
  22. package/src/__tests__/module-manifest.test.ts +48 -0
  23. package/src/__tests__/oauth-handlers.test.ts +522 -5
  24. package/src/__tests__/operator-token.test.ts +427 -3
  25. package/src/__tests__/origin-check.test.ts +220 -0
  26. package/src/__tests__/request-protocol.test.ts +54 -0
  27. package/src/__tests__/serve-boot.test.ts +193 -0
  28. package/src/__tests__/serve.test.ts +100 -0
  29. package/src/__tests__/sessions.test.ts +25 -2
  30. package/src/__tests__/setup-gate.test.ts +222 -0
  31. package/src/__tests__/setup-wizard.test.ts +2089 -0
  32. package/src/__tests__/status.test.ts +199 -0
  33. package/src/__tests__/supervisor.test.ts +482 -0
  34. package/src/__tests__/upgrade.test.ts +247 -4
  35. package/src/__tests__/vault-name.test.ts +79 -0
  36. package/src/__tests__/well-known.test.ts +69 -0
  37. package/src/admin-clients.ts +139 -0
  38. package/src/admin-handlers.ts +37 -254
  39. package/src/admin-host-admin-token.ts +25 -10
  40. package/src/admin-login-ui.ts +256 -0
  41. package/src/admin-vault-admin-token.ts +1 -1
  42. package/src/api-me.ts +124 -0
  43. package/src/api-mint-token.ts +239 -0
  44. package/src/api-modules-ops.ts +585 -0
  45. package/src/api-modules.ts +367 -0
  46. package/src/api-revocation-list.ts +59 -0
  47. package/src/api-revoke-token.ts +153 -0
  48. package/src/api-tokens.ts +224 -0
  49. package/src/cli.ts +28 -0
  50. package/src/commands/auth.ts +408 -51
  51. package/src/commands/expose-2fa-warning.ts +6 -6
  52. package/src/commands/serve-boot.ts +133 -0
  53. package/src/commands/serve.ts +214 -0
  54. package/src/commands/status.ts +74 -10
  55. package/src/commands/upgrade.ts +33 -6
  56. package/src/csrf.ts +34 -13
  57. package/src/help.ts +55 -5
  58. package/src/hub-control.ts +1 -0
  59. package/src/hub-db.ts +87 -0
  60. package/src/hub-server.ts +767 -136
  61. package/src/hub-settings.ts +259 -0
  62. package/src/hub.ts +298 -150
  63. package/src/install-source.ts +291 -0
  64. package/src/jwt-sign.ts +265 -5
  65. package/src/module-manifest.ts +48 -10
  66. package/src/oauth-handlers.ts +262 -56
  67. package/src/oauth-ui.ts +23 -2
  68. package/src/operator-token.ts +349 -18
  69. package/src/origin-check.ts +127 -0
  70. package/src/rate-limit.ts +5 -2
  71. package/src/request-protocol.ts +48 -0
  72. package/src/scope-explanations.ts +33 -2
  73. package/src/sessions.ts +30 -18
  74. package/src/setup-wizard.ts +2009 -0
  75. package/src/supervisor.ts +411 -0
  76. package/src/vault-name.ts +71 -0
  77. package/src/well-known.ts +54 -1
  78. package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
  79. package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
  80. package/web/ui/dist/index.html +2 -2
  81. package/src/__tests__/admin-config.test.ts +0 -281
  82. package/src/admin-config-ui.ts +0 -534
  83. package/src/admin-config.ts +0 -226
  84. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  85. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -517,4 +517,203 @@ describe("status", () => {
517
517
  cleanup();
518
518
  }
519
519
  });
520
+
521
+ describe("install-source surface (hub#243)", () => {
522
+ test("renders SOURCE column header + per-row label", async () => {
523
+ const { path, cleanup } = makeTempPath();
524
+ try {
525
+ upsertService(
526
+ {
527
+ name: "parachute-vault",
528
+ port: 1940,
529
+ paths: ["/vault/default"],
530
+ health: "/vault/default/health",
531
+ version: "0.4.4-rc.3",
532
+ installDir: "/Users/me/code/parachute-vault",
533
+ },
534
+ path,
535
+ );
536
+ const lines: string[] = [];
537
+ await status({
538
+ manifestPath: path,
539
+ fetchImpl: async () => new Response(null, { status: 200 }),
540
+ print: (l) => lines.push(l),
541
+ installSourceDeps: {
542
+ bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
543
+ resolveBunGlobal: () => null,
544
+ readJson: (p) =>
545
+ p === "/Users/me/code/parachute-vault/package.json"
546
+ ? { name: "@openparachute/vault", version: "0.4.4-rc.3" }
547
+ : (() => {
548
+ throw new Error("nope");
549
+ })(),
550
+ readGitHead: () => "8aa167b",
551
+ },
552
+ });
553
+ expect(lines[0]).toMatch(/SOURCE/);
554
+ expect(lines.some((l) => l.includes("bun-linked → parachute-vault @ 8aa167b"))).toBe(true);
555
+ } finally {
556
+ cleanup();
557
+ }
558
+ });
559
+
560
+ test("STALE continuation line fires when bun-linked live version != cached version", async () => {
561
+ // Reproduces hub#243's motivating case: services.json says 0.3.11-rc.1
562
+ // but the live source has been rebuilt to 0.3.15-rc.1. Operator should
563
+ // see STALE in one glance from `parachute status` output.
564
+ const { path, cleanup } = makeTempPath();
565
+ try {
566
+ upsertService(
567
+ {
568
+ name: "parachute-notes",
569
+ port: 1942,
570
+ paths: ["/notes"],
571
+ health: "/notes/health",
572
+ version: "0.3.11-rc.1",
573
+ installDir: "/Users/me/code/parachute-notes",
574
+ },
575
+ path,
576
+ );
577
+ const lines: string[] = [];
578
+ await status({
579
+ manifestPath: path,
580
+ fetchImpl: async () => new Response(null, { status: 200 }),
581
+ print: (l) => lines.push(l),
582
+ installSourceDeps: {
583
+ bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
584
+ resolveBunGlobal: () => null,
585
+ readJson: (p) =>
586
+ p === "/Users/me/code/parachute-notes/package.json"
587
+ ? { name: "@openparachute/notes", version: "0.3.15-rc.1" }
588
+ : (() => {
589
+ throw new Error("nope");
590
+ })(),
591
+ readGitHead: () => "051c404",
592
+ },
593
+ });
594
+ expect(
595
+ lines.some((l) =>
596
+ l.includes("STALE: services.json cached 0.3.11-rc.1; live package.json 0.3.15-rc.1"),
597
+ ),
598
+ ).toBe(true);
599
+ } finally {
600
+ cleanup();
601
+ }
602
+ });
603
+
604
+ test("npm-installed services render as `npm (<version>)` and never STALE", async () => {
605
+ const { path, cleanup } = makeTempPath();
606
+ try {
607
+ upsertService(
608
+ {
609
+ name: "parachute-scribe",
610
+ port: 1943,
611
+ paths: ["/scribe"],
612
+ health: "/scribe/health",
613
+ version: "0.4.2-rc.1",
614
+ installDir: "/home/test/.bun/install/global/node_modules/@openparachute/scribe",
615
+ },
616
+ path,
617
+ );
618
+ const lines: string[] = [];
619
+ await status({
620
+ manifestPath: path,
621
+ fetchImpl: async () => new Response(null, { status: 200 }),
622
+ print: (l) => lines.push(l),
623
+ installSourceDeps: {
624
+ bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
625
+ resolveBunGlobal: () => null,
626
+ readJson: (p) =>
627
+ p === "/home/test/.bun/install/global/node_modules/@openparachute/scribe/package.json"
628
+ ? { name: "@openparachute/scribe", version: "0.4.2-rc.1" }
629
+ : (() => {
630
+ throw new Error("nope");
631
+ })(),
632
+ readGitHead: () => undefined,
633
+ },
634
+ });
635
+ expect(lines.some((l) => l.includes("npm (0.4.2-rc.1)"))).toBe(true);
636
+ expect(lines.some((l) => l.includes("STALE:"))).toBe(false);
637
+ } finally {
638
+ cleanup();
639
+ }
640
+ });
641
+
642
+ test("entries without installDir fall back to bun-global symlink lookup", async () => {
643
+ // Some services.json entries (older first-party rows, or rows written
644
+ // by a service that doesn't echo installDir) leave the field absent.
645
+ // detectInstallSource maps the entry name → first-party package and
646
+ // probes bun globals for the symlink. Pins that fallback path.
647
+ const { path, cleanup } = makeTempPath();
648
+ try {
649
+ upsertService(
650
+ {
651
+ name: "parachute-vault",
652
+ port: 1940,
653
+ paths: ["/vault/default"],
654
+ health: "/vault/default/health",
655
+ version: "0.4.4-rc.3",
656
+ // No installDir.
657
+ },
658
+ path,
659
+ );
660
+ const lines: string[] = [];
661
+ await status({
662
+ manifestPath: path,
663
+ fetchImpl: async () => new Response(null, { status: 200 }),
664
+ print: (l) => lines.push(l),
665
+ installSourceDeps: {
666
+ bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
667
+ resolveBunGlobal: (pkg) =>
668
+ pkg === "@openparachute/vault" ? "/Users/me/code/parachute-vault" : null,
669
+ readJson: (p) =>
670
+ p === "/Users/me/code/parachute-vault/package.json"
671
+ ? { name: "@openparachute/vault", version: "0.4.4-rc.3" }
672
+ : (() => {
673
+ throw new Error("nope");
674
+ })(),
675
+ readGitHead: () => "8aa167b",
676
+ },
677
+ });
678
+ expect(lines.some((l) => l.includes("bun-linked → parachute-vault @ 8aa167b"))).toBe(true);
679
+ } finally {
680
+ cleanup();
681
+ }
682
+ });
683
+
684
+ test("third-party row without installDir + no mapping renders as 'unknown'", async () => {
685
+ const { path, cleanup } = makeTempPath();
686
+ try {
687
+ upsertService(
688
+ {
689
+ name: "agent",
690
+ port: 1946,
691
+ paths: ["/agent"],
692
+ health: "/agent/health",
693
+ version: "0.1.4-rc.1",
694
+ // No installDir; agent isn't in FIRST_PARTY_FALLBACKS by short name,
695
+ // and the fallback bun-global lookup needs a known package name.
696
+ },
697
+ path,
698
+ );
699
+ const lines: string[] = [];
700
+ await status({
701
+ manifestPath: path,
702
+ fetchImpl: async () => new Response(null, { status: 200 }),
703
+ print: (l) => lines.push(l),
704
+ installSourceDeps: {
705
+ bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
706
+ resolveBunGlobal: () => null,
707
+ readJson: () => {
708
+ throw new Error("not reached");
709
+ },
710
+ readGitHead: () => undefined,
711
+ },
712
+ });
713
+ expect(lines.some((l) => l.includes("unknown"))).toBe(true);
714
+ } finally {
715
+ cleanup();
716
+ }
717
+ });
718
+ });
520
719
  });
@@ -0,0 +1,482 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { type SpawnRequest, type SupervisedProc, Supervisor } from "../supervisor.ts";
3
+
4
+ /**
5
+ * Fake subprocess with controllable exited promise + injectable stdout
6
+ * / stderr. The test drives `resolveExit(code)` to simulate the child
7
+ * exiting (clean or crash) and `emit(stream, bytes)` to push log
8
+ * output through the line-buffered tap.
9
+ */
10
+ interface FakeProc extends SupervisedProc {
11
+ resolveExit(code: number | null): void;
12
+ emitStdout(chunk: string): void;
13
+ emitStderr(chunk: string): void;
14
+ closeStreams(): void;
15
+ killed: boolean;
16
+ killSignal?: NodeJS.Signals | number;
17
+ }
18
+
19
+ function makeFakeProc(pid: number): FakeProc {
20
+ let resolveExit!: (code: number | null) => void;
21
+ const exited = new Promise<number | null>((resolve) => {
22
+ resolveExit = resolve;
23
+ });
24
+
25
+ const stdoutController = makeStreamController();
26
+ const stderrController = makeStreamController();
27
+
28
+ return {
29
+ pid,
30
+ exited,
31
+ stdout: stdoutController.stream,
32
+ stderr: stderrController.stream,
33
+ kill(signal) {
34
+ this.killed = true;
35
+ this.killSignal = signal;
36
+ },
37
+ killed: false,
38
+ resolveExit: (code) => resolveExit(code),
39
+ emitStdout: (chunk) => stdoutController.push(chunk),
40
+ emitStderr: (chunk) => stderrController.push(chunk),
41
+ closeStreams: () => {
42
+ stdoutController.close();
43
+ stderrController.close();
44
+ },
45
+ };
46
+ }
47
+
48
+ function makeStreamController(): {
49
+ stream: ReadableStream<Uint8Array>;
50
+ push: (s: string) => void;
51
+ close: () => void;
52
+ } {
53
+ let controller: ReadableStreamDefaultController<Uint8Array>;
54
+ const stream = new ReadableStream<Uint8Array>({
55
+ start(c) {
56
+ controller = c;
57
+ },
58
+ });
59
+ const enc = new TextEncoder();
60
+ return {
61
+ stream,
62
+ push: (s) => controller.enqueue(enc.encode(s)),
63
+ close: () => {
64
+ try {
65
+ controller.close();
66
+ } catch {
67
+ // already closed
68
+ }
69
+ },
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Programmable spawner. Hand it a queue of FakeProc instances, one per
75
+ * expected spawn call (or one shared if every spawn uses the same
76
+ * fake). Tests drive lifecycle by calling resolveExit on the returned
77
+ * proc.
78
+ */
79
+ function makeQueueSpawner(): {
80
+ spawn: (req: SpawnRequest) => SupervisedProc;
81
+ enqueue: (proc: FakeProc) => void;
82
+ calls: SpawnRequest[];
83
+ } {
84
+ const queue: FakeProc[] = [];
85
+ const calls: SpawnRequest[] = [];
86
+ return {
87
+ enqueue: (proc) => queue.push(proc),
88
+ calls,
89
+ spawn: (req) => {
90
+ calls.push(req);
91
+ const next = queue.shift();
92
+ if (!next) throw new Error(`unexpected spawn for ${req.short}`);
93
+ return next;
94
+ },
95
+ };
96
+ }
97
+
98
+ function tick(ms = 10): Promise<void> {
99
+ return new Promise((r) => setTimeout(r, ms));
100
+ }
101
+
102
+ describe("Supervisor.start + status transitions", () => {
103
+ test("transitions starting → running after spawn", async () => {
104
+ const proc = makeFakeProc(123);
105
+ const spawner = makeQueueSpawner();
106
+ spawner.enqueue(proc);
107
+ const sup = new Supervisor({ spawnFn: spawner.spawn });
108
+
109
+ const state = await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
110
+ expect(state.status).toBe("running");
111
+ expect(state.pid).toBe(123);
112
+ expect(state.startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
113
+ expect(spawner.calls).toHaveLength(1);
114
+ expect(spawner.calls[0]?.short).toBe("vault");
115
+
116
+ // Cleanup: resolve the exited promise so the watcher doesn't dangle.
117
+ proc.closeStreams();
118
+ sup.stop("vault");
119
+ proc.resolveExit(0);
120
+ });
121
+
122
+ test("is idempotent on a running module — second start returns existing state", async () => {
123
+ const proc = makeFakeProc(123);
124
+ const spawner = makeQueueSpawner();
125
+ spawner.enqueue(proc);
126
+ const sup = new Supervisor({ spawnFn: spawner.spawn });
127
+
128
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
129
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
130
+ // Only one spawn — the second start short-circuited.
131
+ expect(spawner.calls).toHaveLength(1);
132
+
133
+ proc.closeStreams();
134
+ sup.stop("vault");
135
+ proc.resolveExit(0);
136
+ });
137
+ });
138
+
139
+ describe("Supervisor restart-on-crash", () => {
140
+ test("restarts a crashed module within the budget", async () => {
141
+ const first = makeFakeProc(101);
142
+ const second = makeFakeProc(102);
143
+ const spawner = makeQueueSpawner();
144
+ spawner.enqueue(first);
145
+ spawner.enqueue(second);
146
+
147
+ const sup = new Supervisor({
148
+ spawnFn: spawner.spawn,
149
+ restartDelayMs: 0,
150
+ sleep: () => Promise.resolve(),
151
+ });
152
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
153
+
154
+ // First child crashes.
155
+ first.closeStreams();
156
+ first.resolveExit(1);
157
+ // Let the handleExit microtask run + the spawn happen.
158
+ await tick();
159
+
160
+ expect(spawner.calls).toHaveLength(2);
161
+ const state = sup.get("vault");
162
+ expect(state?.status).toBe("running");
163
+ expect(state?.pid).toBe(102);
164
+ expect(state?.restartsInWindow).toBe(1);
165
+ expect(state?.lastExitCode).toBe(1);
166
+
167
+ second.closeStreams();
168
+ sup.stop("vault");
169
+ second.resolveExit(0);
170
+ });
171
+
172
+ test("gives up after maxRestarts crashes in window, marks crashed", async () => {
173
+ const procs = Array.from({ length: 3 }, (_, i) => makeFakeProc(200 + i));
174
+ const spawner = makeQueueSpawner();
175
+ for (const p of procs) spawner.enqueue(p);
176
+
177
+ const outputs: string[] = [];
178
+ const sup = new Supervisor({
179
+ spawnFn: spawner.spawn,
180
+ maxRestarts: 3,
181
+ restartDelayMs: 0,
182
+ sleep: () => Promise.resolve(),
183
+ output: (line) => outputs.push(line),
184
+ });
185
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
186
+
187
+ // Crash 3 times in quick succession.
188
+ for (const p of procs) {
189
+ p.closeStreams();
190
+ p.resolveExit(2);
191
+ await tick();
192
+ }
193
+
194
+ const state = sup.get("vault");
195
+ expect(state?.status).toBe("crashed");
196
+ expect(state?.restartsInWindow).toBe(3);
197
+ expect(spawner.calls).toHaveLength(3); // initial + 2 restarts; 3rd crash trips the budget.
198
+ expect(outputs.some((l) => l.includes("giving up"))).toBe(true);
199
+ });
200
+
201
+ test("crashes outside the window drop off and budget resets", async () => {
202
+ const procs = Array.from({ length: 3 }, (_, i) => makeFakeProc(300 + i));
203
+ const spawner = makeQueueSpawner();
204
+ for (const p of procs) spawner.enqueue(p);
205
+
206
+ let nowVal = 1_000_000;
207
+ const sup = new Supervisor({
208
+ spawnFn: spawner.spawn,
209
+ maxRestarts: 2,
210
+ restartWindowMs: 5_000,
211
+ restartDelayMs: 0,
212
+ sleep: () => Promise.resolve(),
213
+ now: () => nowVal,
214
+ });
215
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
216
+
217
+ // First crash at t=0.
218
+ procs[0]?.closeStreams();
219
+ procs[0]?.resolveExit(1);
220
+ await tick();
221
+ expect(sup.get("vault")?.restartsInWindow).toBe(1);
222
+
223
+ // Advance past the window; the first crash falls out before the
224
+ // second is counted.
225
+ nowVal += 10_000;
226
+ procs[1]?.closeStreams();
227
+ procs[1]?.resolveExit(1);
228
+ await tick();
229
+ // Budget reset — this crash is alone in its window.
230
+ expect(sup.get("vault")?.restartsInWindow).toBe(1);
231
+ expect(sup.get("vault")?.status).toBe("running");
232
+
233
+ procs[2]?.closeStreams();
234
+ sup.stop("vault");
235
+ procs[2]?.resolveExit(0);
236
+ });
237
+ });
238
+
239
+ describe("Supervisor.stop", () => {
240
+ test("operator stop is not a crash — does not restart", async () => {
241
+ const proc = makeFakeProc(101);
242
+ const spawner = makeQueueSpawner();
243
+ spawner.enqueue(proc);
244
+ const sup = new Supervisor({
245
+ spawnFn: spawner.spawn,
246
+ restartDelayMs: 0,
247
+ sleep: () => Promise.resolve(),
248
+ });
249
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
250
+
251
+ // stop() now awaits proc.exited (with SIGKILL escalation on
252
+ // timeout) — kick it off, observe the SIGTERM landed, then
253
+ // resolve exited so the await completes.
254
+ const stopPromise = sup.stop("vault");
255
+ expect(proc.killed).toBe(true);
256
+ expect(proc.killSignal).toBe("SIGTERM");
257
+
258
+ proc.closeStreams();
259
+ proc.resolveExit(0);
260
+ await stopPromise;
261
+
262
+ // No second spawn — stop is an intentional exit.
263
+ expect(spawner.calls).toHaveLength(1);
264
+ expect(sup.get("vault")?.status).toBe("stopped");
265
+ });
266
+
267
+ test("escalates to SIGKILL when child ignores SIGTERM past killTimeoutMs", async () => {
268
+ // Child that refuses to exit on SIGTERM. The fake records every
269
+ // signal it receives; the supervisor should send SIGTERM,
270
+ // observe no exit, then send SIGKILL after the timeout.
271
+ const proc = makeFakeProc(101);
272
+ const signals: (NodeJS.Signals | number | undefined)[] = [];
273
+ proc.kill = (signal) => {
274
+ signals.push(signal);
275
+ // Only SIGKILL actually terminates this fake child — SIGTERM
276
+ // gets logged and ignored, simulating the wedged-module shape.
277
+ if (signal === "SIGKILL") proc.resolveExit(null);
278
+ };
279
+ const spawner = makeQueueSpawner();
280
+ spawner.enqueue(proc);
281
+ const outputs: string[] = [];
282
+ const sup = new Supervisor({
283
+ spawnFn: spawner.spawn,
284
+ restartDelayMs: 0,
285
+ sleep: () => Promise.resolve(),
286
+ killTimeoutMs: 5, // Short timeout so the test doesn't pause for 5s.
287
+ output: (line) => outputs.push(line),
288
+ });
289
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
290
+
291
+ proc.closeStreams();
292
+ await sup.stop("vault");
293
+
294
+ // SIGTERM first, then SIGKILL after the timeout.
295
+ expect(signals).toEqual(["SIGTERM", "SIGKILL"]);
296
+ expect(outputs.some((l) => l.includes("escalating to SIGKILL"))).toBe(true);
297
+ expect(sup.get("vault")?.status).toBe("stopped");
298
+ });
299
+
300
+ test("stop awaits child exit before returning (no SIGKILL needed)", async () => {
301
+ // Well-behaved child: exits ~10ms after SIGTERM. stop() should
302
+ // return only after the exit promise resolves, not immediately
303
+ // post-SIGTERM. This is the log-flush guarantee that motivated
304
+ // the await in the first place (hub#263).
305
+ const proc = makeFakeProc(101);
306
+ proc.kill = (signal) => {
307
+ signals.push(signal);
308
+ // Simulate the child taking a few ms to flush + exit.
309
+ setTimeout(() => proc.resolveExit(0), 5);
310
+ };
311
+ const signals: (NodeJS.Signals | number | undefined)[] = [];
312
+ const spawner = makeQueueSpawner();
313
+ spawner.enqueue(proc);
314
+ const sup = new Supervisor({
315
+ spawnFn: spawner.spawn,
316
+ restartDelayMs: 0,
317
+ sleep: () => Promise.resolve(),
318
+ killTimeoutMs: 1000, // Plenty of headroom for the 5ms simulated exit.
319
+ });
320
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
321
+
322
+ proc.closeStreams();
323
+ let exitObservedBeforeReturn = false;
324
+ void proc.exited.then(() => {
325
+ exitObservedBeforeReturn = true;
326
+ });
327
+ await sup.stop("vault");
328
+
329
+ // The exited-resolver awaited the same promise stop() did; if
330
+ // stop returned without awaiting, this flag could still be false.
331
+ // (Both promise chains fire from the same resolveExit call.
332
+ // Microtask ordering guarantees they both run before await returns.)
333
+ expect(exitObservedBeforeReturn).toBe(true);
334
+ expect(signals).toEqual(["SIGTERM"]);
335
+ expect(sup.get("vault")?.status).toBe("stopped");
336
+ });
337
+ });
338
+
339
+ describe("Supervisor.restart", () => {
340
+ test("stops the current process and spawns fresh", async () => {
341
+ const first = makeFakeProc(101);
342
+ const second = makeFakeProc(102);
343
+ const spawner = makeQueueSpawner();
344
+ spawner.enqueue(first);
345
+ spawner.enqueue(second);
346
+
347
+ const sup = new Supervisor({
348
+ spawnFn: spawner.spawn,
349
+ restartDelayMs: 0,
350
+ sleep: () => Promise.resolve(),
351
+ });
352
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
353
+
354
+ // Kick off restart; we need to resolve the first proc's exited so
355
+ // the awaitable in restart() doesn't hang.
356
+ const restartPromise = sup.restart("vault");
357
+ first.closeStreams();
358
+ first.resolveExit(0);
359
+ const state = await restartPromise;
360
+
361
+ expect(state?.status).toBe("running");
362
+ expect(state?.pid).toBe(102);
363
+ expect(spawner.calls).toHaveLength(2);
364
+
365
+ second.closeStreams();
366
+ sup.stop("vault");
367
+ second.resolveExit(0);
368
+ });
369
+ });
370
+
371
+ describe("Supervisor output multiplexing", () => {
372
+ test("prefixes child stdout lines with [short]", async () => {
373
+ const proc = makeFakeProc(101);
374
+ const spawner = makeQueueSpawner();
375
+ spawner.enqueue(proc);
376
+ const outputs: string[] = [];
377
+ const sup = new Supervisor({
378
+ spawnFn: spawner.spawn,
379
+ output: (line) => outputs.push(line),
380
+ });
381
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
382
+
383
+ proc.emitStdout("listening on 1940\n");
384
+ proc.emitStdout("ready\n");
385
+ // Allow the async stream reader to flush.
386
+ await tick(20);
387
+
388
+ expect(outputs).toContain("[vault] listening on 1940\n");
389
+ expect(outputs).toContain("[vault] ready\n");
390
+
391
+ proc.closeStreams();
392
+ sup.stop("vault");
393
+ proc.resolveExit(0);
394
+ });
395
+
396
+ test("line-buffers split chunks so partial lines don't break the prefix", async () => {
397
+ const proc = makeFakeProc(101);
398
+ const spawner = makeQueueSpawner();
399
+ spawner.enqueue(proc);
400
+ const outputs: string[] = [];
401
+ const sup = new Supervisor({
402
+ spawnFn: spawner.spawn,
403
+ output: (line) => outputs.push(line),
404
+ });
405
+ await sup.start({ short: "scribe", cmd: ["bun", "scribe.ts"] });
406
+
407
+ // Single line arriving in two chunks should still be one prefixed
408
+ // line — not "[scribe] listening" + "[scribe] on 3200\n".
409
+ proc.emitStdout("listening");
410
+ await tick(10);
411
+ proc.emitStdout(" on 3200\n");
412
+ await tick(20);
413
+
414
+ expect(outputs).toContain("[scribe] listening on 3200\n");
415
+
416
+ proc.closeStreams();
417
+ sup.stop("scribe");
418
+ proc.resolveExit(0);
419
+ });
420
+
421
+ test("multiple children interleave without prefix collisions", async () => {
422
+ const vault = makeFakeProc(101);
423
+ const scribe = makeFakeProc(102);
424
+ const spawner = makeQueueSpawner();
425
+ spawner.enqueue(vault);
426
+ spawner.enqueue(scribe);
427
+ const outputs: string[] = [];
428
+ const sup = new Supervisor({
429
+ spawnFn: spawner.spawn,
430
+ output: (line) => outputs.push(line),
431
+ });
432
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
433
+ await sup.start({ short: "scribe", cmd: ["bun", "scribe.ts"] });
434
+
435
+ vault.emitStdout("vault-line-1\n");
436
+ scribe.emitStdout("scribe-line-1\n");
437
+ vault.emitStderr("vault-err\n");
438
+ await tick(20);
439
+
440
+ expect(outputs).toContain("[vault] vault-line-1\n");
441
+ expect(outputs).toContain("[scribe] scribe-line-1\n");
442
+ expect(outputs).toContain("[vault] vault-err\n");
443
+
444
+ vault.closeStreams();
445
+ scribe.closeStreams();
446
+ sup.stop("vault");
447
+ sup.stop("scribe");
448
+ vault.resolveExit(0);
449
+ scribe.resolveExit(0);
450
+ });
451
+ });
452
+
453
+ describe("Supervisor.list + get", () => {
454
+ test("list returns snapshot of all supervised modules", async () => {
455
+ const vault = makeFakeProc(101);
456
+ const scribe = makeFakeProc(102);
457
+ const spawner = makeQueueSpawner();
458
+ spawner.enqueue(vault);
459
+ spawner.enqueue(scribe);
460
+ const sup = new Supervisor({ spawnFn: spawner.spawn });
461
+
462
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
463
+ await sup.start({ short: "scribe", cmd: ["bun", "scribe.ts"] });
464
+
465
+ const states = sup.list();
466
+ expect(states).toHaveLength(2);
467
+ const shorts = states.map((s) => s.short).sort();
468
+ expect(shorts).toEqual(["scribe", "vault"]);
469
+
470
+ vault.closeStreams();
471
+ scribe.closeStreams();
472
+ sup.stop("vault");
473
+ sup.stop("scribe");
474
+ vault.resolveExit(0);
475
+ scribe.resolveExit(0);
476
+ });
477
+
478
+ test("get returns undefined for an unknown module", () => {
479
+ const sup = new Supervisor({ spawnFn: () => makeFakeProc(0) });
480
+ expect(sup.get("nothing")).toBeUndefined();
481
+ });
482
+ });