@openparachute/hub 0.6.1 → 0.6.3-rc.1

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.
@@ -0,0 +1,569 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { status } from "../commands/status.ts";
6
+ import type { HubUnitDeps, HubUnitStateResult } from "../hub-unit.ts";
7
+ import {
8
+ type ModuleStatesResult,
9
+ NoOperatorTokenError,
10
+ OperatorTokenExpiredError,
11
+ } from "../module-ops-client.ts";
12
+ import { upsertService } from "../services-manifest.ts";
13
+
14
+ /**
15
+ * Phase 3c (design §6.4): on a UNIT-MANAGED box `status` reads the hub row from
16
+ * the platform manager + `/health` and the module rows from the running
17
+ * supervisor (`GET /api/modules`). Everything below is driven through the
18
+ * `supervisor` seams — no real launchd/systemd/socket/HTTP/db call. The detached
19
+ * arm is exercised separately in `status.test.ts` (those tests omit the
20
+ * `supervisor` block entirely, so they keep the pidfile readout unchanged).
21
+ */
22
+
23
+ function makeTempPath(): { path: string; cleanup: () => void; configDir: string } {
24
+ const dir = mkdtempSync(join(tmpdir(), "pcli-status-sup-"));
25
+ return {
26
+ path: join(dir, "services.json"),
27
+ configDir: dir,
28
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Install-source deps that never touch the real filesystem (so the hub row's +
34
+ * module rows' source classification is deterministic in the test runner).
35
+ */
36
+ const STUB_INSTALL_SOURCE = {
37
+ bunGlobalPrefixes: () => [] as string[],
38
+ resolveBunGlobal: () => null,
39
+ readJson: () => ({ version: "0.6.2" }),
40
+ readGitHead: () => undefined,
41
+ };
42
+
43
+ /**
44
+ * A throwaway db handle exposing ONLY `{ close }`. This is intentionally minimal:
45
+ * on the supervisor status path the db is never READ — module states come from
46
+ * the API (`fetchModuleStates`, stubbed here), and `buildSupervisorRows` only
47
+ * opens the handle to pass it through + `close()` it in `finally`. The
48
+ * `as unknown as Database` cast at the call site widens this to the full type;
49
+ * if a future change adds a real db read on this path, it will fail at RUNTIME
50
+ * (missing method) rather than typecheck — so wire the needed method in here.
51
+ */
52
+ function fakeOpenDb(): { close: () => void } {
53
+ return { close: () => {} };
54
+ }
55
+
56
+ /**
57
+ * Minimal `HubUnitDeps` — only the fields the seams that ARE wired through deps
58
+ * touch. `queryHubUnitState` / `probeHubHealth` / `fetchModuleStates` are
59
+ * injected directly as the `supervisor` seams, so the deps here only need to be
60
+ * a well-typed placeholder.
61
+ */
62
+ const FAKE_HUB_UNIT_DEPS = {
63
+ platform: "linux",
64
+ getuid: () => 1000,
65
+ homeDir: () => "/home/op",
66
+ userName: () => "op",
67
+ which: () => "/usr/bin/systemctl",
68
+ run: () => ({ code: 0, stdout: "", stderr: "" }),
69
+ writeFile: () => {},
70
+ removeFile: () => {},
71
+ readFile: () => undefined,
72
+ exists: () => false,
73
+ probeHealth: async () => true,
74
+ portListening: async () => true,
75
+ sleep: async () => {},
76
+ } as unknown as HubUnitDeps;
77
+
78
+ interface SupervisorArmOpts {
79
+ managerState: HubUnitStateResult;
80
+ hubHealthy: boolean;
81
+ moduleStates?: ModuleStatesResult;
82
+ fetchModuleStatesImpl?: () => Promise<ModuleStatesResult>;
83
+ }
84
+
85
+ /** Drive `status` through the supervisor arm with fully stubbed seams. */
86
+ function supervisorOpts(configDir: string, path: string, o: SupervisorArmOpts) {
87
+ return {
88
+ manifestPath: path,
89
+ configDir,
90
+ installSourceDeps: STUB_INSTALL_SOURCE,
91
+ hubSrcDir: "/nonexistent/hub/src",
92
+ supervisor: {
93
+ unitInstalled: true,
94
+ hubUnitDeps: FAKE_HUB_UNIT_DEPS,
95
+ queryHubUnitState: () => o.managerState,
96
+ probeHubHealth: async () => o.hubHealthy,
97
+ fetchModuleStates:
98
+ o.fetchModuleStatesImpl ??
99
+ (async () => o.moduleStates ?? { supervisorAvailable: true, modules: [] }),
100
+ openDb: fakeOpenDb as unknown as (configDir: string) => import("bun:sqlite").Database,
101
+ },
102
+ };
103
+ }
104
+
105
+ describe("status — Phase 3c supervisor arm: hub row", () => {
106
+ test("manager active + /health OK → running (active) with port", async () => {
107
+ const { path, configDir, cleanup } = makeTempPath();
108
+ try {
109
+ const lines: string[] = [];
110
+ const code = await status({
111
+ ...supervisorOpts(configDir, path, {
112
+ managerState: { state: "active" },
113
+ hubHealthy: true,
114
+ moduleStates: { supervisorAvailable: true, modules: [] },
115
+ }),
116
+ print: (l) => lines.push(l),
117
+ });
118
+ // With no modules + the hub active, status exits 0.
119
+ expect(code).toBe(0);
120
+ const out = lines.join("\n");
121
+ expect(out).toMatch(/parachute-hub \(internal\)/);
122
+ // Hub row is `active` and shows the canonical port (no manifest entry).
123
+ const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
124
+ expect(hubLine).toBeDefined();
125
+ expect(hubLine).toMatch(/\bactive\b/);
126
+ expect(hubLine).toMatch(/1939/);
127
+ } finally {
128
+ cleanup();
129
+ }
130
+ });
131
+
132
+ test("manager failed → failing + surfaces last exit code", async () => {
133
+ const { path, configDir, cleanup } = makeTempPath();
134
+ try {
135
+ const lines: string[] = [];
136
+ const code = await status({
137
+ ...supervisorOpts(configDir, path, {
138
+ managerState: { state: "failed", lastExitCode: 7 },
139
+ hubHealthy: false,
140
+ }),
141
+ print: (l) => lines.push(l),
142
+ });
143
+ expect(code).toBe(1);
144
+ const out = lines.join("\n");
145
+ expect(out).toMatch(/\bfailing\b/);
146
+ expect(out).toMatch(/the hub unit failed/);
147
+ expect(out).toMatch(/last exit code 7/);
148
+ } finally {
149
+ cleanup();
150
+ }
151
+ });
152
+
153
+ test("manager active but /health down → failing with starting/unhealthy nuance", async () => {
154
+ const { path, configDir, cleanup } = makeTempPath();
155
+ try {
156
+ const lines: string[] = [];
157
+ const code = await status({
158
+ ...supervisorOpts(configDir, path, {
159
+ managerState: { state: "active" },
160
+ hubHealthy: false,
161
+ }),
162
+ print: (l) => lines.push(l),
163
+ });
164
+ expect(code).toBe(1);
165
+ const out = lines.join("\n");
166
+ const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
167
+ expect(hubLine).toMatch(/\bfailing\b/);
168
+ expect(out).toMatch(/\/health not answering yet \(starting or unhealthy\)/);
169
+ } finally {
170
+ cleanup();
171
+ }
172
+ });
173
+
174
+ test("no on-box manager (container) → /health is liveness, 'container runtime (managed)' note", async () => {
175
+ const { path, configDir, cleanup } = makeTempPath();
176
+ try {
177
+ const lines: string[] = [];
178
+ const code = await status({
179
+ ...supervisorOpts(configDir, path, {
180
+ managerState: { state: "no-manager" },
181
+ hubHealthy: true,
182
+ moduleStates: { supervisorAvailable: true, modules: [] },
183
+ }),
184
+ print: (l) => lines.push(l),
185
+ });
186
+ expect(code).toBe(0);
187
+ const out = lines.join("\n");
188
+ const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
189
+ expect(hubLine).toMatch(/\bactive\b/);
190
+ expect(out).toMatch(/container runtime \(managed\)/);
191
+ } finally {
192
+ cleanup();
193
+ }
194
+ });
195
+
196
+ test("container with /health down → hub row failing", async () => {
197
+ const { path, configDir, cleanup } = makeTempPath();
198
+ try {
199
+ const lines: string[] = [];
200
+ const code = await status({
201
+ ...supervisorOpts(configDir, path, {
202
+ managerState: { state: "no-manager" },
203
+ hubHealthy: false,
204
+ }),
205
+ print: (l) => lines.push(l),
206
+ });
207
+ expect(code).toBe(1);
208
+ const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
209
+ expect(hubLine).toMatch(/\bfailing\b/);
210
+ } finally {
211
+ cleanup();
212
+ }
213
+ });
214
+
215
+ test("a thrown manager query never crashes status — degrades to /health verdict", async () => {
216
+ const { path, configDir, cleanup } = makeTempPath();
217
+ try {
218
+ const lines: string[] = [];
219
+ const code = await status({
220
+ ...supervisorOpts(configDir, path, {
221
+ managerState: { state: "active" }, // overridden by the throwing stub below
222
+ hubHealthy: true,
223
+ moduleStates: { supervisorAvailable: true, modules: [] },
224
+ }),
225
+ // Replace the query with one that throws — status must not crash.
226
+ supervisor: {
227
+ unitInstalled: true,
228
+ hubUnitDeps: FAKE_HUB_UNIT_DEPS,
229
+ queryHubUnitState: () => {
230
+ throw new Error("systemctl exploded");
231
+ },
232
+ probeHubHealth: async () => true,
233
+ fetchModuleStates: async () => ({ supervisorAvailable: true, modules: [] }),
234
+ openDb: fakeOpenDb as unknown as (configDir: string) => import("bun:sqlite").Database,
235
+ },
236
+ print: (l) => lines.push(l),
237
+ });
238
+ // /health answered → unknown manager state falls back to active.
239
+ expect(code).toBe(0);
240
+ const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
241
+ expect(hubLine).toMatch(/\bactive\b/);
242
+ } finally {
243
+ cleanup();
244
+ }
245
+ });
246
+ });
247
+
248
+ describe("status — Phase 3c supervisor arm: module rows", () => {
249
+ test("hub up → module states come from the stubbed GET /api/modules", async () => {
250
+ const { path, configDir, cleanup } = makeTempPath();
251
+ try {
252
+ upsertService(
253
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
254
+ path,
255
+ );
256
+ upsertService(
257
+ {
258
+ name: "parachute-scribe",
259
+ port: 3200,
260
+ paths: ["/scribe"],
261
+ health: "/scribe/health",
262
+ version: "0.6.2",
263
+ },
264
+ path,
265
+ );
266
+ const lines: string[] = [];
267
+ const code = await status({
268
+ ...supervisorOpts(configDir, path, {
269
+ managerState: { state: "active" },
270
+ hubHealthy: true,
271
+ moduleStates: {
272
+ supervisorAvailable: true,
273
+ modules: [
274
+ {
275
+ short: "vault",
276
+ installed: true,
277
+ installed_version: "0.6.2",
278
+ supervisor_status: "running",
279
+ pid: 5151,
280
+ supervisor_start_error: null,
281
+ },
282
+ {
283
+ short: "scribe",
284
+ installed: true,
285
+ installed_version: "0.6.2",
286
+ supervisor_status: "crashed",
287
+ pid: null,
288
+ supervisor_start_error: null,
289
+ },
290
+ ],
291
+ },
292
+ }),
293
+ print: (l) => lines.push(l),
294
+ });
295
+ // scribe crashed → failing → overall exit 1.
296
+ expect(code).toBe(1);
297
+ const vaultLine = lines.find((l) => l.includes("parachute-vault"));
298
+ const scribeLine = lines.find((l) => l.includes("parachute-scribe"));
299
+ expect(vaultLine).toMatch(/\bactive\b/);
300
+ expect(vaultLine).toMatch(/5151/); // pid from the supervisor snapshot
301
+ expect(scribeLine).toMatch(/\bfailing\b/);
302
+ // The failing row surfaces the supervisor status on a continuation line.
303
+ expect(lines.some((l) => l.includes("supervisor: crashed"))).toBe(true);
304
+ } finally {
305
+ cleanup();
306
+ }
307
+ });
308
+
309
+ test("module with a structured startError surfaces the missing-dependency note", async () => {
310
+ const { path, configDir, cleanup } = makeTempPath();
311
+ try {
312
+ upsertService(
313
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
314
+ path,
315
+ );
316
+ const lines: string[] = [];
317
+ await status({
318
+ ...supervisorOpts(configDir, path, {
319
+ managerState: { state: "active" },
320
+ hubHealthy: true,
321
+ moduleStates: {
322
+ supervisorAvailable: true,
323
+ modules: [
324
+ {
325
+ short: "vault",
326
+ installed: true,
327
+ installed_version: "0.6.2",
328
+ supervisor_status: "crashed",
329
+ pid: null,
330
+ supervisor_start_error: {
331
+ error_type: "missing_dependency",
332
+ error_description: "parachute-vault is required",
333
+ binary: "parachute-vault",
334
+ at: "2026-06-01T00:00:00Z",
335
+ },
336
+ },
337
+ ],
338
+ },
339
+ }),
340
+ print: (l) => lines.push(l),
341
+ });
342
+ const out = lines.join("\n");
343
+ expect(out).toMatch(/failed to start: parachute-vault not installed/);
344
+ } finally {
345
+ cleanup();
346
+ }
347
+ });
348
+
349
+ test("hub DOWN → modules degrade to inactive + 'hub is down' note, no hang/crash, exit 0", async () => {
350
+ const { path, configDir, cleanup } = makeTempPath();
351
+ try {
352
+ upsertService(
353
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
354
+ path,
355
+ );
356
+ let fetched = false;
357
+ const lines: string[] = [];
358
+ const code = await status({
359
+ ...supervisorOpts(configDir, path, {
360
+ managerState: { state: "inactive" },
361
+ hubHealthy: false,
362
+ fetchModuleStatesImpl: async () => {
363
+ fetched = true;
364
+ return { supervisorAvailable: true, modules: [] };
365
+ },
366
+ }),
367
+ print: (l) => lines.push(l),
368
+ });
369
+ // Hub down → modules are `inactive` (expected, not a failure) → exit 0.
370
+ expect(code).toBe(0);
371
+ // We must NOT call the module-states API when the hub is down (children
372
+ // die with the hub; the call would just connection-refuse).
373
+ expect(fetched).toBe(false);
374
+ const vaultLine = lines.find((l) => l.includes("parachute-vault"));
375
+ expect(vaultLine).toMatch(/\binactive\b/);
376
+ expect(lines.some((l) => l.includes("hub is down — its modules are stopped"))).toBe(true);
377
+ } finally {
378
+ cleanup();
379
+ }
380
+ });
381
+
382
+ test("no operator token → graceful degrade (manifest rows + actionable hint), no 401 crash", async () => {
383
+ const { path, configDir, cleanup } = makeTempPath();
384
+ try {
385
+ upsertService(
386
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
387
+ path,
388
+ );
389
+ const lines: string[] = [];
390
+ const code = await status({
391
+ ...supervisorOpts(configDir, path, {
392
+ managerState: { state: "active" },
393
+ hubHealthy: true,
394
+ fetchModuleStatesImpl: async () => {
395
+ throw new NoOperatorTokenError();
396
+ },
397
+ }),
398
+ print: (l) => lines.push(l),
399
+ });
400
+ // We could not read run-state, but didn't crash. The module row falls back
401
+ // to `inactive` (no supervisor snapshot) — a stopped row is exit 0.
402
+ expect(code).toBe(0);
403
+ const out = lines.join("\n");
404
+ expect(out).toMatch(/parachute-vault/);
405
+ expect(out).toMatch(/run `parachute auth rotate-operator`/);
406
+ } finally {
407
+ cleanup();
408
+ }
409
+ });
410
+
411
+ test("expired operator token → graceful degrade, no crash", async () => {
412
+ const { path, configDir, cleanup } = makeTempPath();
413
+ try {
414
+ upsertService(
415
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
416
+ path,
417
+ );
418
+ const lines: string[] = [];
419
+ const code = await status({
420
+ ...supervisorOpts(configDir, path, {
421
+ managerState: { state: "active" },
422
+ hubHealthy: true,
423
+ fetchModuleStatesImpl: async () => {
424
+ throw new OperatorTokenExpiredError(
425
+ "token expired — run `parachute auth rotate-operator`",
426
+ );
427
+ },
428
+ }),
429
+ print: (l) => lines.push(l),
430
+ });
431
+ expect(code).toBe(0);
432
+ expect(lines.some((l) => l.includes("rotate-operator"))).toBe(true);
433
+ } finally {
434
+ cleanup();
435
+ }
436
+ });
437
+
438
+ test("API error reading module states → degrade with the message, no crash", async () => {
439
+ const { path, configDir, cleanup } = makeTempPath();
440
+ try {
441
+ upsertService(
442
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
443
+ path,
444
+ );
445
+ const lines: string[] = [];
446
+ const code = await status({
447
+ ...supervisorOpts(configDir, path, {
448
+ managerState: { state: "active" },
449
+ hubHealthy: true,
450
+ fetchModuleStatesImpl: async () => {
451
+ throw new Error("the api blew up");
452
+ },
453
+ }),
454
+ print: (l) => lines.push(l),
455
+ });
456
+ expect(code).toBe(0);
457
+ expect(lines.some((l) => l.includes("couldn't read live module state"))).toBe(true);
458
+ expect(lines.some((l) => l.includes("the api blew up"))).toBe(true);
459
+ } finally {
460
+ cleanup();
461
+ }
462
+ });
463
+
464
+ test("starting/restarting supervisor status → pending, not a failure (exit 0)", async () => {
465
+ const { path, configDir, cleanup } = makeTempPath();
466
+ try {
467
+ upsertService(
468
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
469
+ path,
470
+ );
471
+ const lines: string[] = [];
472
+ const code = await status({
473
+ ...supervisorOpts(configDir, path, {
474
+ managerState: { state: "active" },
475
+ hubHealthy: true,
476
+ moduleStates: {
477
+ supervisorAvailable: true,
478
+ modules: [
479
+ {
480
+ short: "vault",
481
+ installed: true,
482
+ installed_version: "0.6.2",
483
+ supervisor_status: "starting",
484
+ pid: 9090,
485
+ supervisor_start_error: null,
486
+ },
487
+ ],
488
+ },
489
+ }),
490
+ print: (l) => lines.push(l),
491
+ });
492
+ expect(code).toBe(0);
493
+ const vaultLine = lines.find((l) => l.includes("parachute-vault"));
494
+ expect(vaultLine).toMatch(/\bpending\b/);
495
+ } finally {
496
+ cleanup();
497
+ }
498
+ });
499
+ });
500
+
501
+ describe("status — Phase 3c discriminant", () => {
502
+ test("no supervisor block → detached arm (pidfile readout), supervisor seams untouched", async () => {
503
+ const { path, configDir, cleanup } = makeTempPath();
504
+ try {
505
+ upsertService(
506
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
507
+ path,
508
+ );
509
+ // No `supervisor` block at all → resolveStatusSupervisor returns
510
+ // unitInstalled:false → the detached arm. The probe (fetchImpl) runs,
511
+ // proving we took the pidfile/probe path, not the supervisor path.
512
+ let probed = false;
513
+ const lines: string[] = [];
514
+ const code = await status({
515
+ manifestPath: path,
516
+ configDir,
517
+ installSourceDeps: STUB_INSTALL_SOURCE,
518
+ hubSrcDir: "/nonexistent/hub/src",
519
+ fetchImpl: async () => {
520
+ probed = true;
521
+ return new Response(null, { status: 200 });
522
+ },
523
+ print: (l) => lines.push(l),
524
+ });
525
+ expect(code).toBe(0);
526
+ // The detached arm probes the service's /health endpoint.
527
+ expect(probed).toBe(true);
528
+ expect(lines.some((l) => l.includes("parachute-vault"))).toBe(true);
529
+ } finally {
530
+ cleanup();
531
+ }
532
+ });
533
+
534
+ test("supervisor block with unitInstalled:false → detached arm (probe runs)", async () => {
535
+ const { path, configDir, cleanup } = makeTempPath();
536
+ try {
537
+ upsertService(
538
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
539
+ path,
540
+ );
541
+ let probed = false;
542
+ let queried = false;
543
+ const code = await status({
544
+ manifestPath: path,
545
+ configDir,
546
+ installSourceDeps: STUB_INSTALL_SOURCE,
547
+ hubSrcDir: "/nonexistent/hub/src",
548
+ fetchImpl: async () => {
549
+ probed = true;
550
+ return new Response(null, { status: 200 });
551
+ },
552
+ supervisor: {
553
+ unitInstalled: false,
554
+ queryHubUnitState: () => {
555
+ queried = true;
556
+ return { state: "active" };
557
+ },
558
+ },
559
+ print: () => {},
560
+ });
561
+ expect(code).toBe(0);
562
+ // Detached arm: the per-service probe runs and the manager is NOT queried.
563
+ expect(probed).toBe(true);
564
+ expect(queried).toBe(false);
565
+ } finally {
566
+ cleanup();
567
+ }
568
+ });
569
+ });