@openparachute/hub 0.6.2 → 0.6.3-rc.2

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 (58) hide show
  1. package/README.md +87 -35
  2. package/package.json +1 -1
  3. package/src/__tests__/api-hub-upgrade.test.ts +690 -0
  4. package/src/__tests__/api-modules-ops.test.ts +359 -3
  5. package/src/__tests__/api-modules.test.ts +54 -0
  6. package/src/__tests__/expose-cloudflare.test.ts +163 -72
  7. package/src/__tests__/expose-off-auto.test.ts +26 -1
  8. package/src/__tests__/expose.test.ts +260 -240
  9. package/src/__tests__/hub-control.test.ts +1 -242
  10. package/src/__tests__/hub-server.test.ts +64 -0
  11. package/src/__tests__/hub-unit.test.ts +574 -0
  12. package/src/__tests__/init.test.ts +219 -2
  13. package/src/__tests__/lifecycle.test.ts +416 -1448
  14. package/src/__tests__/managed-unit.test.ts +575 -0
  15. package/src/__tests__/migrate-cutover.test.ts +840 -0
  16. package/src/__tests__/migrate-offer.test.ts +240 -0
  17. package/src/__tests__/migrate.test.ts +132 -0
  18. package/src/__tests__/module-ops-client.test.ts +556 -0
  19. package/src/__tests__/port-probe.test.ts +23 -0
  20. package/src/__tests__/setup-wizard.test.ts +130 -0
  21. package/src/__tests__/status-supervisor.test.ts +504 -0
  22. package/src/__tests__/status.test.ts +157 -708
  23. package/src/__tests__/supervisor.test.ts +471 -6
  24. package/src/__tests__/upgrade.test.ts +351 -5
  25. package/src/api-hub-upgrade.ts +384 -0
  26. package/src/api-hub.ts +2 -1
  27. package/src/api-modules-ops.ts +221 -0
  28. package/src/api-modules.ts +18 -2
  29. package/src/cli.ts +97 -12
  30. package/src/cloudflare/connector-service.ts +117 -322
  31. package/src/commands/expose-cloudflare.ts +63 -71
  32. package/src/commands/expose-supervisor.ts +247 -0
  33. package/src/commands/expose.ts +59 -48
  34. package/src/commands/init.ts +225 -12
  35. package/src/commands/lifecycle.ts +455 -816
  36. package/src/commands/migrate-cutover.ts +837 -0
  37. package/src/commands/migrate.ts +71 -2
  38. package/src/commands/serve-boot.ts +71 -25
  39. package/src/commands/status.ts +535 -235
  40. package/src/commands/upgrade.ts +100 -2
  41. package/src/help.ts +128 -68
  42. package/src/hub-control.ts +23 -162
  43. package/src/hub-server.ts +39 -0
  44. package/src/hub-unit.ts +735 -0
  45. package/src/hub-upgrade-helper.ts +306 -0
  46. package/src/hub-upgrade-mode.ts +209 -0
  47. package/src/hub-upgrade-status.ts +150 -0
  48. package/src/managed-unit.ts +692 -0
  49. package/src/migrate-offer.ts +186 -0
  50. package/src/module-ops-client.ts +457 -0
  51. package/src/port-probe.ts +50 -0
  52. package/src/process-state.ts +19 -3
  53. package/src/setup-wizard.ts +80 -1
  54. package/src/supervisor.ts +389 -38
  55. package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
  56. package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
@@ -27,6 +27,12 @@ import { type ExposeState, readExposeState, writeExposeState } from "../expose-s
27
27
  import { hubDbPath, openHubDb } from "../hub-db.ts";
28
28
  import { hubFetch } from "../hub-server.ts";
29
29
  import { getSetting, setSetting } from "../hub-settings.ts";
30
+ import { validateAccessToken } from "../jwt-sign.ts";
31
+ import {
32
+ OPERATOR_TOKEN_SCOPE_SET_CLAIM,
33
+ readOperatorTokenFile,
34
+ writeOperatorTokenFile,
35
+ } from "../operator-token.ts";
30
36
  import { writeManifest } from "../services-manifest.ts";
31
37
  import { SESSION_COOKIE_NAME } from "../sessions.ts";
32
38
  import {
@@ -39,6 +45,7 @@ import {
39
45
  handleSetupVaultPost,
40
46
  postVaultImportImpl,
41
47
  } from "../setup-wizard.ts";
48
+ import { rotateSigningKey } from "../signing-keys.ts";
42
49
  import { Supervisor } from "../supervisor.ts";
43
50
  import { createUser, getUserByUsername, userCount } from "../users.ts";
44
51
 
@@ -1007,6 +1014,129 @@ describe("handleSetupAccountPost", () => {
1007
1014
  });
1008
1015
  });
1009
1016
 
1017
+ // --- Phase 3b Deliverable A: fresh-box operator-token closure (§3.1) ------
1018
+ //
1019
+ // After the wizard creates the first admin, it persists ~/.parachute/operator.token
1020
+ // so the box has a CLI operator credential immediately — otherwise the Phase 3b
1021
+ // per-module verbs (start/stop/restart <svc> over the module-ops API) would 401.
1022
+
1023
+ describe("handleSetupAccountPost — operator-token closure (Phase 3b §3.1)", () => {
1024
+ let h: Harness;
1025
+ beforeEach(() => {
1026
+ h = makeHarness();
1027
+ _resetOperationsRegistryForTests();
1028
+ });
1029
+ afterEach(() => h.cleanup());
1030
+
1031
+ /** Drive a valid account-creation POST against the given deps. */
1032
+ async function createFirstAdmin(
1033
+ db: ReturnType<typeof openHubDb>,
1034
+ deps: Partial<Parameters<typeof handleSetupAccountPost>[1]> = {},
1035
+ username = "ops",
1036
+ ): Promise<Response> {
1037
+ const baseDeps = {
1038
+ db,
1039
+ manifestPath: h.manifestPath,
1040
+ configDir: h.dir,
1041
+ readExposeStateFn: h.readExposeStateFn,
1042
+ issuer: "https://hub.example",
1043
+ registry: getDefaultOperationsRegistry(),
1044
+ };
1045
+ const get = handleSetupGet(req("/admin/setup"), baseDeps);
1046
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
1047
+ const form = formBody({
1048
+ username,
1049
+ password: "correct horse battery",
1050
+ password_confirm: "correct horse battery",
1051
+ [CSRF_FIELD_NAME]: csrf,
1052
+ });
1053
+ return handleSetupAccountPost(
1054
+ req("/admin/setup/account", {
1055
+ method: "POST",
1056
+ body: form.body,
1057
+ headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
1058
+ }),
1059
+ { ...baseDeps, ...deps },
1060
+ );
1061
+ }
1062
+
1063
+ test("persists operator.token (admin scope-set, carries parachute:host:admin)", async () => {
1064
+ const db = openHubDb(hubDbPath(h.dir));
1065
+ try {
1066
+ rotateSigningKey(db); // real issuance needs a signing key
1067
+ const post = await createFirstAdmin(db);
1068
+ expect(post.status).toBe(303);
1069
+ // The token file now exists on disk…
1070
+ const token = await readOperatorTokenFile(h.dir);
1071
+ expect(token).not.toBeNull();
1072
+ // …and decodes with the admin scope (the scope module-ops gates on).
1073
+ // The JWT carries the OAuth `scope` claim as a space-delimited string.
1074
+ const { payload } = await validateAccessToken(db, token ?? "", "https://hub.example");
1075
+ const scopes = String(payload.scope ?? "").split(" ");
1076
+ expect(scopes).toContain("parachute:host:admin");
1077
+ expect((payload as Record<string, unknown>)[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("admin");
1078
+ } finally {
1079
+ db.close();
1080
+ }
1081
+ });
1082
+
1083
+ test("does NOT clobber an existing operator.token", async () => {
1084
+ const db = openHubDb(hubDbPath(h.dir));
1085
+ try {
1086
+ rotateSigningKey(db);
1087
+ // Plant a sentinel token before the wizard runs.
1088
+ await writeOperatorTokenFile("sentinel.preexisting.token", h.dir);
1089
+ // Use a stub issuer that fails the test if it's ever called.
1090
+ const post = await createFirstAdmin(db, {
1091
+ issueOperatorToken: async () => {
1092
+ throw new Error("issueOperatorToken must NOT run when a token already exists");
1093
+ },
1094
+ });
1095
+ expect(post.status).toBe(303);
1096
+ // The pre-existing token is untouched.
1097
+ expect(await readOperatorTokenFile(h.dir)).toBe("sentinel.preexisting.token");
1098
+ } finally {
1099
+ db.close();
1100
+ }
1101
+ });
1102
+
1103
+ test("no admin created (already-bootstrapped guard) → no token written", async () => {
1104
+ const db = openHubDb(hubDbPath(h.dir));
1105
+ try {
1106
+ rotateSigningKey(db);
1107
+ // An admin already exists, so the wizard's already-bootstrapped guard
1108
+ // returns early (303 to /admin/setup) WITHOUT reaching createUser — and
1109
+ // therefore WITHOUT minting a token. The closure only fires for a
1110
+ // genuinely-created first admin.
1111
+ await createUser(db, "owner", "pw");
1112
+ const post = await createFirstAdmin(db, {}, "interloper");
1113
+ expect(post.status).toBe(303);
1114
+ expect(await readOperatorTokenFile(h.dir)).toBeNull();
1115
+ } finally {
1116
+ db.close();
1117
+ }
1118
+ });
1119
+
1120
+ test("token-write failure is non-fatal — account creation still succeeds", async () => {
1121
+ const db = openHubDb(hubDbPath(h.dir));
1122
+ try {
1123
+ const post = await createFirstAdmin(db, {
1124
+ issueOperatorToken: async () => {
1125
+ throw new Error("disk full");
1126
+ },
1127
+ });
1128
+ // The admin + session were committed despite the token-write failure.
1129
+ expect(post.status).toBe(303);
1130
+ expect(setCookie(post, SESSION_COOKIE_NAME)).toBeDefined();
1131
+ expect(userCount(db)).toBe(1);
1132
+ // No token landed (the issuer threw), but that didn't fail the request.
1133
+ expect(await readOperatorTokenFile(h.dir)).toBeNull();
1134
+ } finally {
1135
+ db.close();
1136
+ }
1137
+ });
1138
+ });
1139
+
1010
1140
  // --- POST /admin/setup/vault ---------------------------------------------
1011
1141
 
1012
1142
  describe("handleSetupVaultPost", () => {
@@ -0,0 +1,504 @@
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 5b (design §6.4): `status` reads the hub row from the platform manager +
16
+ * `/health` and the module rows from the running supervisor (`GET /api/modules`)
17
+ * — the ONLY runtime now that the detached pidfile arm is retired. Everything
18
+ * below is driven through the `supervisor` seams — no real launchd/systemd/
19
+ * socket/HTTP/db call. The manifest-derived rendering (URLs, version, persisted
20
+ * start-error) is covered in `status.test.ts` (also supervised-arm now).
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
+ hubUnitDeps: FAKE_HUB_UNIT_DEPS,
94
+ queryHubUnitState: () => o.managerState,
95
+ probeHubHealth: async () => o.hubHealthy,
96
+ fetchModuleStates:
97
+ o.fetchModuleStatesImpl ??
98
+ (async () => o.moduleStates ?? { supervisorAvailable: true, modules: [] }),
99
+ openDb: fakeOpenDb as unknown as (configDir: string) => import("bun:sqlite").Database,
100
+ },
101
+ };
102
+ }
103
+
104
+ describe("status — Phase 3c supervisor arm: hub row", () => {
105
+ test("manager active + /health OK → running (active) with port", async () => {
106
+ const { path, configDir, cleanup } = makeTempPath();
107
+ try {
108
+ const lines: string[] = [];
109
+ const code = await status({
110
+ ...supervisorOpts(configDir, path, {
111
+ managerState: { state: "active" },
112
+ hubHealthy: true,
113
+ moduleStates: { supervisorAvailable: true, modules: [] },
114
+ }),
115
+ print: (l) => lines.push(l),
116
+ });
117
+ // With no modules + the hub active, status exits 0.
118
+ expect(code).toBe(0);
119
+ const out = lines.join("\n");
120
+ expect(out).toMatch(/parachute-hub \(internal\)/);
121
+ // Hub row is `active` and shows the canonical port (no manifest entry).
122
+ const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
123
+ expect(hubLine).toBeDefined();
124
+ expect(hubLine).toMatch(/\bactive\b/);
125
+ expect(hubLine).toMatch(/1939/);
126
+ } finally {
127
+ cleanup();
128
+ }
129
+ });
130
+
131
+ test("manager failed → failing + surfaces last exit code", async () => {
132
+ const { path, configDir, cleanup } = makeTempPath();
133
+ try {
134
+ const lines: string[] = [];
135
+ const code = await status({
136
+ ...supervisorOpts(configDir, path, {
137
+ managerState: { state: "failed", lastExitCode: 7 },
138
+ hubHealthy: false,
139
+ }),
140
+ print: (l) => lines.push(l),
141
+ });
142
+ expect(code).toBe(1);
143
+ const out = lines.join("\n");
144
+ expect(out).toMatch(/\bfailing\b/);
145
+ expect(out).toMatch(/the hub unit failed/);
146
+ expect(out).toMatch(/last exit code 7/);
147
+ } finally {
148
+ cleanup();
149
+ }
150
+ });
151
+
152
+ test("manager active but /health down → failing with starting/unhealthy nuance", async () => {
153
+ const { path, configDir, cleanup } = makeTempPath();
154
+ try {
155
+ const lines: string[] = [];
156
+ const code = await status({
157
+ ...supervisorOpts(configDir, path, {
158
+ managerState: { state: "active" },
159
+ hubHealthy: false,
160
+ }),
161
+ print: (l) => lines.push(l),
162
+ });
163
+ expect(code).toBe(1);
164
+ const out = lines.join("\n");
165
+ const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
166
+ expect(hubLine).toMatch(/\bfailing\b/);
167
+ expect(out).toMatch(/\/health not answering yet \(starting or unhealthy\)/);
168
+ } finally {
169
+ cleanup();
170
+ }
171
+ });
172
+
173
+ test("no on-box manager (container) → /health is liveness, 'container runtime (managed)' note", async () => {
174
+ const { path, configDir, cleanup } = makeTempPath();
175
+ try {
176
+ const lines: string[] = [];
177
+ const code = await status({
178
+ ...supervisorOpts(configDir, path, {
179
+ managerState: { state: "no-manager" },
180
+ hubHealthy: true,
181
+ moduleStates: { supervisorAvailable: true, modules: [] },
182
+ }),
183
+ print: (l) => lines.push(l),
184
+ });
185
+ expect(code).toBe(0);
186
+ const out = lines.join("\n");
187
+ const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
188
+ expect(hubLine).toMatch(/\bactive\b/);
189
+ expect(out).toMatch(/container runtime \(managed\)/);
190
+ } finally {
191
+ cleanup();
192
+ }
193
+ });
194
+
195
+ test("container with /health down → hub row failing", async () => {
196
+ const { path, configDir, cleanup } = makeTempPath();
197
+ try {
198
+ const lines: string[] = [];
199
+ const code = await status({
200
+ ...supervisorOpts(configDir, path, {
201
+ managerState: { state: "no-manager" },
202
+ hubHealthy: false,
203
+ }),
204
+ print: (l) => lines.push(l),
205
+ });
206
+ expect(code).toBe(1);
207
+ const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
208
+ expect(hubLine).toMatch(/\bfailing\b/);
209
+ } finally {
210
+ cleanup();
211
+ }
212
+ });
213
+
214
+ test("a thrown manager query never crashes status — degrades to /health verdict", async () => {
215
+ const { path, configDir, cleanup } = makeTempPath();
216
+ try {
217
+ const lines: string[] = [];
218
+ const code = await status({
219
+ ...supervisorOpts(configDir, path, {
220
+ managerState: { state: "active" }, // overridden by the throwing stub below
221
+ hubHealthy: true,
222
+ moduleStates: { supervisorAvailable: true, modules: [] },
223
+ }),
224
+ // Replace the query with one that throws — status must not crash.
225
+ supervisor: {
226
+ hubUnitDeps: FAKE_HUB_UNIT_DEPS,
227
+ queryHubUnitState: () => {
228
+ throw new Error("systemctl exploded");
229
+ },
230
+ probeHubHealth: async () => true,
231
+ fetchModuleStates: async () => ({ supervisorAvailable: true, modules: [] }),
232
+ openDb: fakeOpenDb as unknown as (configDir: string) => import("bun:sqlite").Database,
233
+ },
234
+ print: (l) => lines.push(l),
235
+ });
236
+ // /health answered → unknown manager state falls back to active.
237
+ expect(code).toBe(0);
238
+ const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
239
+ expect(hubLine).toMatch(/\bactive\b/);
240
+ } finally {
241
+ cleanup();
242
+ }
243
+ });
244
+ });
245
+
246
+ describe("status — Phase 3c supervisor arm: module rows", () => {
247
+ test("hub up → module states come from the stubbed GET /api/modules", async () => {
248
+ const { path, configDir, cleanup } = makeTempPath();
249
+ try {
250
+ upsertService(
251
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
252
+ path,
253
+ );
254
+ upsertService(
255
+ {
256
+ name: "parachute-scribe",
257
+ port: 3200,
258
+ paths: ["/scribe"],
259
+ health: "/scribe/health",
260
+ version: "0.6.2",
261
+ },
262
+ path,
263
+ );
264
+ const lines: string[] = [];
265
+ const code = await status({
266
+ ...supervisorOpts(configDir, path, {
267
+ managerState: { state: "active" },
268
+ hubHealthy: true,
269
+ moduleStates: {
270
+ supervisorAvailable: true,
271
+ modules: [
272
+ {
273
+ short: "vault",
274
+ installed: true,
275
+ installed_version: "0.6.2",
276
+ supervisor_status: "running",
277
+ pid: 5151,
278
+ supervisor_start_error: null,
279
+ },
280
+ {
281
+ short: "scribe",
282
+ installed: true,
283
+ installed_version: "0.6.2",
284
+ supervisor_status: "crashed",
285
+ pid: null,
286
+ supervisor_start_error: null,
287
+ },
288
+ ],
289
+ },
290
+ }),
291
+ print: (l) => lines.push(l),
292
+ });
293
+ // scribe crashed → failing → overall exit 1.
294
+ expect(code).toBe(1);
295
+ const vaultLine = lines.find((l) => l.includes("parachute-vault"));
296
+ const scribeLine = lines.find((l) => l.includes("parachute-scribe"));
297
+ expect(vaultLine).toMatch(/\bactive\b/);
298
+ expect(vaultLine).toMatch(/5151/); // pid from the supervisor snapshot
299
+ expect(scribeLine).toMatch(/\bfailing\b/);
300
+ // The failing row surfaces the supervisor status on a continuation line.
301
+ expect(lines.some((l) => l.includes("supervisor: crashed"))).toBe(true);
302
+ } finally {
303
+ cleanup();
304
+ }
305
+ });
306
+
307
+ test("module with a structured startError surfaces the missing-dependency note", async () => {
308
+ const { path, configDir, cleanup } = makeTempPath();
309
+ try {
310
+ upsertService(
311
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
312
+ path,
313
+ );
314
+ const lines: string[] = [];
315
+ await status({
316
+ ...supervisorOpts(configDir, path, {
317
+ managerState: { state: "active" },
318
+ hubHealthy: true,
319
+ moduleStates: {
320
+ supervisorAvailable: true,
321
+ modules: [
322
+ {
323
+ short: "vault",
324
+ installed: true,
325
+ installed_version: "0.6.2",
326
+ supervisor_status: "crashed",
327
+ pid: null,
328
+ supervisor_start_error: {
329
+ error_type: "missing_dependency",
330
+ error_description: "parachute-vault is required",
331
+ binary: "parachute-vault",
332
+ at: "2026-06-01T00:00:00Z",
333
+ },
334
+ },
335
+ ],
336
+ },
337
+ }),
338
+ print: (l) => lines.push(l),
339
+ });
340
+ const out = lines.join("\n");
341
+ expect(out).toMatch(/failed to start: parachute-vault not installed/);
342
+ } finally {
343
+ cleanup();
344
+ }
345
+ });
346
+
347
+ test("hub DOWN → modules degrade to inactive + 'hub is down' note, no hang/crash, exit 0", async () => {
348
+ const { path, configDir, cleanup } = makeTempPath();
349
+ try {
350
+ upsertService(
351
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
352
+ path,
353
+ );
354
+ let fetched = false;
355
+ const lines: string[] = [];
356
+ const code = await status({
357
+ ...supervisorOpts(configDir, path, {
358
+ managerState: { state: "inactive" },
359
+ hubHealthy: false,
360
+ fetchModuleStatesImpl: async () => {
361
+ fetched = true;
362
+ return { supervisorAvailable: true, modules: [] };
363
+ },
364
+ }),
365
+ print: (l) => lines.push(l),
366
+ });
367
+ // Hub down → modules are `inactive` (expected, not a failure) → exit 0.
368
+ expect(code).toBe(0);
369
+ // We must NOT call the module-states API when the hub is down (children
370
+ // die with the hub; the call would just connection-refuse).
371
+ expect(fetched).toBe(false);
372
+ const vaultLine = lines.find((l) => l.includes("parachute-vault"));
373
+ expect(vaultLine).toMatch(/\binactive\b/);
374
+ expect(lines.some((l) => l.includes("hub is down — its modules are stopped"))).toBe(true);
375
+ } finally {
376
+ cleanup();
377
+ }
378
+ });
379
+
380
+ test("no operator token → graceful degrade (manifest rows + actionable hint), no 401 crash", async () => {
381
+ const { path, configDir, cleanup } = makeTempPath();
382
+ try {
383
+ upsertService(
384
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
385
+ path,
386
+ );
387
+ const lines: string[] = [];
388
+ const code = await status({
389
+ ...supervisorOpts(configDir, path, {
390
+ managerState: { state: "active" },
391
+ hubHealthy: true,
392
+ fetchModuleStatesImpl: async () => {
393
+ throw new NoOperatorTokenError();
394
+ },
395
+ }),
396
+ print: (l) => lines.push(l),
397
+ });
398
+ // We could not read run-state, but didn't crash. The module row falls back
399
+ // to `inactive` (no supervisor snapshot) — a stopped row is exit 0.
400
+ expect(code).toBe(0);
401
+ const out = lines.join("\n");
402
+ expect(out).toMatch(/parachute-vault/);
403
+ expect(out).toMatch(/run `parachute auth rotate-operator`/);
404
+ } finally {
405
+ cleanup();
406
+ }
407
+ });
408
+
409
+ test("expired operator token → graceful degrade, no crash", async () => {
410
+ const { path, configDir, cleanup } = makeTempPath();
411
+ try {
412
+ upsertService(
413
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
414
+ path,
415
+ );
416
+ const lines: string[] = [];
417
+ const code = await status({
418
+ ...supervisorOpts(configDir, path, {
419
+ managerState: { state: "active" },
420
+ hubHealthy: true,
421
+ fetchModuleStatesImpl: async () => {
422
+ throw new OperatorTokenExpiredError(
423
+ "token expired — run `parachute auth rotate-operator`",
424
+ );
425
+ },
426
+ }),
427
+ print: (l) => lines.push(l),
428
+ });
429
+ expect(code).toBe(0);
430
+ expect(lines.some((l) => l.includes("rotate-operator"))).toBe(true);
431
+ } finally {
432
+ cleanup();
433
+ }
434
+ });
435
+
436
+ test("API error reading module states → degrade with the message, no crash", async () => {
437
+ const { path, configDir, cleanup } = makeTempPath();
438
+ try {
439
+ upsertService(
440
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
441
+ path,
442
+ );
443
+ const lines: string[] = [];
444
+ const code = await status({
445
+ ...supervisorOpts(configDir, path, {
446
+ managerState: { state: "active" },
447
+ hubHealthy: true,
448
+ fetchModuleStatesImpl: async () => {
449
+ throw new Error("the api blew up");
450
+ },
451
+ }),
452
+ print: (l) => lines.push(l),
453
+ });
454
+ expect(code).toBe(0);
455
+ expect(lines.some((l) => l.includes("couldn't read live module state"))).toBe(true);
456
+ expect(lines.some((l) => l.includes("the api blew up"))).toBe(true);
457
+ } finally {
458
+ cleanup();
459
+ }
460
+ });
461
+
462
+ test("starting/restarting supervisor status → pending, not a failure (exit 0)", async () => {
463
+ const { path, configDir, cleanup } = makeTempPath();
464
+ try {
465
+ upsertService(
466
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
467
+ path,
468
+ );
469
+ const lines: string[] = [];
470
+ const code = await status({
471
+ ...supervisorOpts(configDir, path, {
472
+ managerState: { state: "active" },
473
+ hubHealthy: true,
474
+ moduleStates: {
475
+ supervisorAvailable: true,
476
+ modules: [
477
+ {
478
+ short: "vault",
479
+ installed: true,
480
+ installed_version: "0.6.2",
481
+ supervisor_status: "starting",
482
+ pid: 9090,
483
+ supervisor_start_error: null,
484
+ },
485
+ ],
486
+ },
487
+ }),
488
+ print: (l) => lines.push(l),
489
+ });
490
+ expect(code).toBe(0);
491
+ const vaultLine = lines.find((l) => l.includes("parachute-vault"));
492
+ expect(vaultLine).toMatch(/\bpending\b/);
493
+ } finally {
494
+ cleanup();
495
+ }
496
+ });
497
+ });
498
+
499
+ // The "Phase 3c discriminant" block (no-supervisor / unitInstalled:false →
500
+ // detached pidfile-probe arm) was removed in Phase 5b: the detached arm is
501
+ // retired, so there is no discriminant — `status` always reads the platform
502
+ // manager + supervisor. The supervisor-path readout is exercised throughout the
503
+ // suites above; a box with no hub unit degrades gracefully (manager `no-unit` /
504
+ // `/health` down → inactive rows), which the hub-row + module-row suites cover.