@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
@@ -0,0 +1,574 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ type HubUnitDeps,
4
+ NO_MANAGER_MESSAGE,
5
+ NO_UNIT_MESSAGE,
6
+ ensureHubUnit,
7
+ installAndStartHubUnit,
8
+ queryHubUnitState,
9
+ restartHubUnit,
10
+ stopHubUnit,
11
+ } from "../hub-unit.ts";
12
+ import {
13
+ HUB_LAUNCHD_LABEL,
14
+ HUB_SYSTEMD_UNIT_NAME,
15
+ type ServiceCommandResult,
16
+ launchdPlistPathForLabel,
17
+ systemdUnitPathForName,
18
+ } from "../managed-unit.ts";
19
+
20
+ // Use the SHARED exported unit identifiers (not local re-declarations) so the
21
+ // assertions can't silently pass if the canonical label/unit name ever drifts.
22
+ const HUB_LABEL = HUB_LAUNCHD_LABEL;
23
+ const HUB_UNIT = HUB_SYSTEMD_UNIT_NAME;
24
+
25
+ interface FakeState {
26
+ deps: HubUnitDeps;
27
+ calls: string[][];
28
+ files: Map<string, string>;
29
+ }
30
+
31
+ /**
32
+ * Build a fully-stubbed {@link HubUnitDeps}. No launchctl/systemctl/socket/HTTP
33
+ * call touches the real OS — every side effect is recorded in `calls` / `files`.
34
+ * `probeHealth` / `portListening` accept arrays so a test can script the
35
+ * "down at first, up after start" readiness sequence.
36
+ */
37
+ function fakeDeps(
38
+ over: Partial<HubUnitDeps> & {
39
+ runResults?: ServiceCommandResult[];
40
+ healthSeq?: boolean[];
41
+ listeningSeq?: boolean[];
42
+ installedUnit?: boolean;
43
+ } = {},
44
+ ): FakeState {
45
+ const calls: string[][] = [];
46
+ const files = new Map<string, string>();
47
+ let runIdx = 0;
48
+ let healthIdx = 0;
49
+ let listeningIdx = 0;
50
+ const ok: ServiceCommandResult = { code: 0, stdout: "", stderr: "" };
51
+
52
+ const platform: NodeJS.Platform = over.platform ?? "linux";
53
+ const getuid = over.getuid ?? (() => 1000);
54
+ const homeDir = over.homeDir ?? (() => "/home/op");
55
+
56
+ // Optionally seed the unit file so isHubUnitInstalled() sees it.
57
+ if (over.installedUnit) {
58
+ const home = homeDir();
59
+ if (platform === "darwin") {
60
+ files.set(launchdPlistPathForLabel(HUB_LABEL, home), "<plist/>");
61
+ } else {
62
+ const root = (getuid() ?? 1000) === 0;
63
+ files.set(systemdUnitPathForName(HUB_UNIT, home, root), "[Unit]");
64
+ }
65
+ }
66
+
67
+ const deps: HubUnitDeps = {
68
+ platform,
69
+ getuid,
70
+ homeDir,
71
+ userName: over.userName ?? (() => "op"),
72
+ which:
73
+ over.which ??
74
+ ((b) => {
75
+ if (b === "bun") return "/home/op/.bun/bin/bun";
76
+ if (b === "launchctl" || b === "systemctl" || b === "loginctl" || b === "journalctl") {
77
+ return `/usr/bin/${b}`;
78
+ }
79
+ return null;
80
+ }),
81
+ run:
82
+ over.run ??
83
+ ((cmd) => {
84
+ calls.push([...cmd]);
85
+ const r = over.runResults?.[runIdx++];
86
+ return r ?? ok;
87
+ }),
88
+ writeFile: over.writeFile ?? ((p, c) => void files.set(p, c)),
89
+ removeFile: over.removeFile ?? ((p) => void files.delete(p)),
90
+ readFile: over.readFile ?? ((p) => files.get(p)),
91
+ exists: over.exists ?? ((p) => files.has(p)),
92
+ probeHealth:
93
+ over.probeHealth ??
94
+ (async () => {
95
+ const seq = over.healthSeq;
96
+ if (!seq) return false;
97
+ return seq[Math.min(healthIdx++, seq.length - 1)] ?? false;
98
+ }),
99
+ portListening:
100
+ over.portListening ??
101
+ (async () => {
102
+ const seq = over.listeningSeq;
103
+ if (!seq) return true;
104
+ return seq[Math.min(listeningIdx++, seq.length - 1)] ?? false;
105
+ }),
106
+ sleep: over.sleep ?? (async () => {}),
107
+ };
108
+ return { deps, calls, files };
109
+ }
110
+
111
+ describe("ensureHubUnit — §3.2 algorithm", () => {
112
+ test("hub already up: /health 200 → returns already-up, NO manager call", async () => {
113
+ const f = fakeDeps({ healthSeq: [true] });
114
+ const res = await ensureHubUnit({ port: 1939, deps: f.deps, readyPollMs: 0 });
115
+ expect(res.outcome).toBe("already-up");
116
+ expect(res.port).toBe(1939);
117
+ // No systemctl/launchctl invocation at all — the probe short-circuited.
118
+ expect(f.calls).toEqual([]);
119
+ });
120
+
121
+ test("hub down, unit present (systemd user): starts the unit + readiness poll succeeds", async () => {
122
+ const f = fakeDeps({
123
+ platform: "linux",
124
+ getuid: () => 1000,
125
+ installedUnit: true,
126
+ healthSeq: [false],
127
+ listeningSeq: [true],
128
+ });
129
+ const res = await ensureHubUnit({ port: 1939, deps: f.deps, readyPollMs: 0 });
130
+ expect(res.outcome).toBe("started");
131
+ // systemctl --user start parachute-hub.service was driven.
132
+ expect(f.calls).toContainEqual(["systemctl", "--user", "start", "parachute-hub.service"]);
133
+ });
134
+
135
+ test("hub down, unit present (systemd root): no --user scope", async () => {
136
+ const f = fakeDeps({
137
+ platform: "linux",
138
+ getuid: () => 0,
139
+ userName: () => "root",
140
+ installedUnit: true,
141
+ healthSeq: [false],
142
+ listeningSeq: [true],
143
+ });
144
+ const res = await ensureHubUnit({ port: 1939, deps: f.deps, readyPollMs: 0 });
145
+ expect(res.outcome).toBe("started");
146
+ expect(f.calls).toContainEqual(["systemctl", "start", "parachute-hub.service"]);
147
+ });
148
+
149
+ test("hub down, unit present (launchd): kickstart -k gui/<uid>/<label>", async () => {
150
+ const f = fakeDeps({
151
+ platform: "darwin",
152
+ getuid: () => 501,
153
+ installedUnit: true,
154
+ healthSeq: [false],
155
+ listeningSeq: [true],
156
+ });
157
+ const res = await ensureHubUnit({ port: 1939, deps: f.deps, readyPollMs: 0 });
158
+ expect(res.outcome).toBe("started");
159
+ expect(f.calls).toContainEqual([
160
+ "launchctl",
161
+ "kickstart",
162
+ "-k",
163
+ "gui/501/computer.parachute.hub",
164
+ ]);
165
+ });
166
+
167
+ test("no unit installed → actionable 'run parachute migrate' error, no manager call", async () => {
168
+ const f = fakeDeps({
169
+ platform: "linux",
170
+ installedUnit: false,
171
+ healthSeq: [false],
172
+ });
173
+ const res = await ensureHubUnit({ port: 1939, deps: f.deps, readyPollMs: 0 });
174
+ expect(res.outcome).toBe("no-unit");
175
+ expect(res.messages).toContain(NO_UNIT_MESSAGE);
176
+ // Did NOT try to start anything.
177
+ expect(f.calls).toEqual([]);
178
+ });
179
+
180
+ test("no manager at all (linux, no systemctl) → actionable foreground-serve message", async () => {
181
+ const f = fakeDeps({
182
+ platform: "linux",
183
+ installedUnit: true, // even with a unit file, no systemctl = no manager
184
+ healthSeq: [false],
185
+ which: () => null, // nothing resolvable
186
+ });
187
+ const res = await ensureHubUnit({ port: 1939, deps: f.deps, readyPollMs: 0 });
188
+ expect(res.outcome).toBe("no-manager");
189
+ expect(res.messages).toContain(NO_MANAGER_MESSAGE);
190
+ expect(f.calls).toEqual([]);
191
+ });
192
+
193
+ test("readiness timeout: surfaces the unit log (journald), does not hang", async () => {
194
+ const journalLog = "May 31 hub: boot failed: corrupt hub.db";
195
+ const f = fakeDeps({
196
+ platform: "linux",
197
+ getuid: () => 1000,
198
+ installedUnit: true,
199
+ healthSeq: [false],
200
+ listeningSeq: [false], // never binds
201
+ run: (cmd) => {
202
+ // journalctl tail returns the boot error; start returns ok.
203
+ if (cmd[0] === "journalctl") {
204
+ return { code: 0, stdout: journalLog, stderr: "" };
205
+ }
206
+ return { code: 0, stdout: "", stderr: "" };
207
+ },
208
+ });
209
+ const res = await ensureHubUnit({
210
+ port: 1939,
211
+ deps: f.deps,
212
+ readyTimeoutMs: 0, // immediate deadline → one poll then timeout
213
+ readyPollMs: 0,
214
+ });
215
+ expect(res.outcome).toBe("timeout");
216
+ const joined = res.messages.join("\n");
217
+ expect(joined).toContain("did not become ready");
218
+ expect(joined).toContain(journalLog);
219
+ });
220
+
221
+ test("manager start command fails → start-failed with stderr surfaced", async () => {
222
+ const f = fakeDeps({
223
+ platform: "linux",
224
+ getuid: () => 1000,
225
+ installedUnit: true,
226
+ healthSeq: [false],
227
+ run: (cmd) => {
228
+ if (cmd.includes("start")) {
229
+ return { code: 1, stdout: "", stderr: "Unit parachute-hub.service not found." };
230
+ }
231
+ return { code: 0, stdout: "", stderr: "" };
232
+ },
233
+ });
234
+ const res = await ensureHubUnit({ port: 1939, deps: f.deps, readyPollMs: 0 });
235
+ expect(res.outcome).toBe("start-failed");
236
+ expect(res.messages.join("\n")).toContain("Unit parachute-hub.service not found.");
237
+ });
238
+ });
239
+
240
+ describe("installAndStartHubUnit — init bringup (§3.3 / §4.2)", () => {
241
+ test("installs the hub unit (start:true) + waits readiness → started", async () => {
242
+ const f = fakeDeps({
243
+ platform: "linux",
244
+ getuid: () => 1000,
245
+ listeningSeq: [true],
246
+ });
247
+ const res = await installAndStartHubUnit({
248
+ parachuteHome: "/home/op/.parachute",
249
+ cliPath: "/home/op/parachute-hub/src/cli.ts",
250
+ port: 1939,
251
+ deps: f.deps,
252
+ readyPollMs: 0,
253
+ });
254
+ expect(res.outcome).toBe("started");
255
+ expect(res.install.outcome).toBe("installed");
256
+ // The systemd unit file was written, carrying the captured PARACHUTE_HOME
257
+ // and the serve ExecStart.
258
+ const unitPath = systemdUnitPathForName(HUB_UNIT, "/home/op", false);
259
+ const written = f.files.get(unitPath);
260
+ expect(written).toBeDefined();
261
+ expect(written).toContain("Environment=PARACHUTE_HOME=/home/op/.parachute");
262
+ expect(written).toContain("src/cli.ts serve");
263
+ // enable --now drove the start.
264
+ expect(f.calls).toContainEqual([
265
+ "systemctl",
266
+ "--user",
267
+ "enable",
268
+ "--now",
269
+ "parachute-hub.service",
270
+ ]);
271
+ });
272
+
273
+ test("launchd default on Mac (D2): writes the LaunchAgent plist + bootstraps", async () => {
274
+ const f = fakeDeps({
275
+ platform: "darwin",
276
+ getuid: () => 501,
277
+ homeDir: () => "/Users/op",
278
+ listeningSeq: [true],
279
+ });
280
+ const res = await installAndStartHubUnit({
281
+ parachuteHome: "/Users/op/.parachute",
282
+ cliPath: "/Users/op/parachute-hub/src/cli.ts",
283
+ port: 1939,
284
+ deps: f.deps,
285
+ readyPollMs: 0,
286
+ });
287
+ expect(res.outcome).toBe("started");
288
+ const plistPath = launchdPlistPathForLabel(HUB_LABEL, "/Users/op");
289
+ const plist = f.files.get(plistPath);
290
+ expect(plist).toBeDefined();
291
+ expect(plist).toContain("<string>serve</string>");
292
+ expect(plist).toContain("computer.parachute.hub");
293
+ });
294
+
295
+ test("captures a NON-default PARACHUTE_HOME (§4.2)", async () => {
296
+ const f = fakeDeps({ platform: "linux", getuid: () => 1000, listeningSeq: [true] });
297
+ await installAndStartHubUnit({
298
+ parachuteHome: "/custom/home/.parachute",
299
+ cliPath: "/home/op/parachute-hub/src/cli.ts",
300
+ deps: f.deps,
301
+ readyPollMs: 0,
302
+ });
303
+ const unitPath = systemdUnitPathForName(HUB_UNIT, "/home/op", false);
304
+ expect(f.files.get(unitPath)).toContain("Environment=PARACHUTE_HOME=/custom/home/.parachute");
305
+ });
306
+
307
+ test("no service manager → no-manager outcome, NOTHING installed or spawned", async () => {
308
+ const f = fakeDeps({
309
+ platform: "linux",
310
+ which: () => null, // no systemctl
311
+ listeningSeq: [true],
312
+ });
313
+ const res = await installAndStartHubUnit({
314
+ parachuteHome: "/home/op/.parachute",
315
+ cliPath: "/home/op/parachute-hub/src/cli.ts",
316
+ deps: f.deps,
317
+ readyPollMs: 0,
318
+ });
319
+ expect(res.outcome).toBe("no-manager");
320
+ expect(res.messages).toContain(NO_MANAGER_MESSAGE);
321
+ // No unit file written, no command run.
322
+ expect(f.files.size).toBe(0);
323
+ expect(f.calls).toEqual([]);
324
+ });
325
+
326
+ test("install succeeds but hub never binds → timeout with the unit log", async () => {
327
+ const f = fakeDeps({
328
+ platform: "linux",
329
+ getuid: () => 1000,
330
+ listeningSeq: [false], // never binds
331
+ run: (cmd) => {
332
+ if (cmd[0] === "journalctl") {
333
+ return { code: 0, stdout: "hub: EADDRINUSE 1939", stderr: "" };
334
+ }
335
+ return { code: 0, stdout: "", stderr: "" };
336
+ },
337
+ });
338
+ const res = await installAndStartHubUnit({
339
+ parachuteHome: "/home/op/.parachute",
340
+ cliPath: "/home/op/parachute-hub/src/cli.ts",
341
+ deps: f.deps,
342
+ readyTimeoutMs: 0,
343
+ readyPollMs: 0,
344
+ });
345
+ expect(res.outcome).toBe("timeout");
346
+ expect(res.messages.join("\n")).toContain("EADDRINUSE");
347
+ });
348
+ });
349
+
350
+ describe("stopHubUnit — manager-only hub stop (§3.3, R17)", () => {
351
+ test("systemd user: `systemctl --user stop` (NEVER a PID signal)", () => {
352
+ const f = fakeDeps({ platform: "linux", getuid: () => 1000, installedUnit: true });
353
+ const res = stopHubUnit(f.deps);
354
+ expect(res.outcome).toBe("ok");
355
+ expect(f.calls).toEqual([["systemctl", "--user", "stop", HUB_UNIT]]);
356
+ // No `kill`-shaped call — the manager is the only thing driven.
357
+ expect(f.calls.flat()).not.toContain("kill");
358
+ });
359
+
360
+ test("systemd root: `systemctl stop` (no --user scope)", () => {
361
+ const f = fakeDeps({
362
+ platform: "linux",
363
+ getuid: () => 0,
364
+ userName: () => "root",
365
+ installedUnit: true,
366
+ });
367
+ const res = stopHubUnit(f.deps);
368
+ expect(res.outcome).toBe("ok");
369
+ expect(f.calls).toEqual([["systemctl", "stop", HUB_UNIT]]);
370
+ });
371
+
372
+ test("launchd: `launchctl bootout gui/<uid>/<label>` (KeepAlive can't resurrect)", () => {
373
+ const f = fakeDeps({ platform: "darwin", getuid: () => 501, installedUnit: true });
374
+ const res = stopHubUnit(f.deps);
375
+ expect(res.outcome).toBe("ok");
376
+ expect(f.calls).toEqual([["launchctl", "bootout", `gui/501/${HUB_LABEL}`]]);
377
+ // Specifically NOT `launchctl kill` / a PID signal.
378
+ expect(f.calls.flat()).not.toContain("kill");
379
+ });
380
+
381
+ test("no unit installed → no-unit, no manager call", () => {
382
+ const f = fakeDeps({ platform: "linux", getuid: () => 1000 /* installedUnit: false */ });
383
+ const res = stopHubUnit(f.deps);
384
+ expect(res.outcome).toBe("no-unit");
385
+ expect(res.messages).toEqual([NO_UNIT_MESSAGE]);
386
+ expect(f.calls).toEqual([]);
387
+ });
388
+
389
+ test("no service manager → no-manager", () => {
390
+ const f = fakeDeps({ platform: "linux", which: () => null, installedUnit: true });
391
+ const res = stopHubUnit(f.deps);
392
+ expect(res.outcome).toBe("no-manager");
393
+ expect(res.messages).toEqual([NO_MANAGER_MESSAGE]);
394
+ });
395
+
396
+ test("manager rejects the command → failed, carries stderr", () => {
397
+ const f = fakeDeps({
398
+ platform: "linux",
399
+ getuid: () => 1000,
400
+ installedUnit: true,
401
+ runResults: [{ code: 1, stdout: "", stderr: "unit not loaded" }],
402
+ });
403
+ const res = stopHubUnit(f.deps);
404
+ expect(res.outcome).toBe("failed");
405
+ expect(res.messages.join("\n")).toContain("unit not loaded");
406
+ });
407
+ });
408
+
409
+ describe("restartHubUnit — manager-only hub restart (§3.3, R17)", () => {
410
+ test("systemd user: `systemctl --user restart` (NEVER a PID signal)", () => {
411
+ const f = fakeDeps({ platform: "linux", getuid: () => 1000, installedUnit: true });
412
+ const res = restartHubUnit(f.deps);
413
+ expect(res.outcome).toBe("ok");
414
+ expect(f.calls).toEqual([["systemctl", "--user", "restart", HUB_UNIT]]);
415
+ expect(f.calls.flat()).not.toContain("kill");
416
+ });
417
+
418
+ test("launchd: `launchctl kickstart -k gui/<uid>/<label>`", () => {
419
+ const f = fakeDeps({ platform: "darwin", getuid: () => 501, installedUnit: true });
420
+ const res = restartHubUnit(f.deps);
421
+ expect(res.outcome).toBe("ok");
422
+ expect(f.calls).toEqual([["launchctl", "kickstart", "-k", `gui/501/${HUB_LABEL}`]]);
423
+ expect(f.calls.flat()).not.toContain("kill");
424
+ });
425
+
426
+ test("no unit installed → no-unit", () => {
427
+ const f = fakeDeps({ platform: "linux", getuid: () => 1000 });
428
+ const res = restartHubUnit(f.deps);
429
+ expect(res.outcome).toBe("no-unit");
430
+ expect(f.calls).toEqual([]);
431
+ });
432
+
433
+ test("manager rejects → failed, carries stderr", () => {
434
+ const f = fakeDeps({
435
+ platform: "linux",
436
+ getuid: () => 1000,
437
+ installedUnit: true,
438
+ runResults: [{ code: 1, stdout: "", stderr: "job failed" }],
439
+ });
440
+ const res = restartHubUnit(f.deps);
441
+ expect(res.outcome).toBe("failed");
442
+ expect(res.messages.join("\n")).toContain("job failed");
443
+ });
444
+ });
445
+
446
+ describe("queryHubUnitState — §6.4 hub-row manager query", () => {
447
+ test("no service manager → no-manager (nothing to query)", () => {
448
+ const f = fakeDeps({ platform: "linux", which: () => null });
449
+ expect(queryHubUnitState(f.deps).state).toBe("no-manager");
450
+ });
451
+
452
+ test("manager present but no unit installed → no-unit", () => {
453
+ const f = fakeDeps({ platform: "linux", installedUnit: false });
454
+ expect(queryHubUnitState(f.deps).state).toBe("no-unit");
455
+ });
456
+
457
+ test("systemd is-active → active", () => {
458
+ const f = fakeDeps({
459
+ platform: "linux",
460
+ getuid: () => 1000,
461
+ installedUnit: true,
462
+ runResults: [{ code: 0, stdout: "active\n", stderr: "" }],
463
+ });
464
+ const r = queryHubUnitState(f.deps);
465
+ expect(r.state).toBe("active");
466
+ // user-scope is-active was driven.
467
+ expect(f.calls).toContainEqual(["systemctl", "--user", "is-active", HUB_UNIT]);
468
+ });
469
+
470
+ test("systemd is-active → failed (nonzero exit, stdout token classified)", () => {
471
+ const f = fakeDeps({
472
+ platform: "linux",
473
+ getuid: () => 1000,
474
+ installedUnit: true,
475
+ // is-active exits 3 for a failed unit; the state word is on stdout.
476
+ runResults: [{ code: 3, stdout: "failed\n", stderr: "" }],
477
+ });
478
+ expect(queryHubUnitState(f.deps).state).toBe("failed");
479
+ });
480
+
481
+ test("systemd is-active → inactive", () => {
482
+ const f = fakeDeps({
483
+ platform: "linux",
484
+ getuid: () => 1000,
485
+ installedUnit: true,
486
+ runResults: [{ code: 3, stdout: "inactive\n", stderr: "" }],
487
+ });
488
+ expect(queryHubUnitState(f.deps).state).toBe("inactive");
489
+ });
490
+
491
+ test("systemd is-active → activating maps to activating", () => {
492
+ const f = fakeDeps({
493
+ platform: "linux",
494
+ getuid: () => 1000,
495
+ installedUnit: true,
496
+ runResults: [{ code: 3, stdout: "activating\n", stderr: "" }],
497
+ });
498
+ expect(queryHubUnitState(f.deps).state).toBe("activating");
499
+ });
500
+
501
+ test("systemd root scope → no --user", () => {
502
+ const f = fakeDeps({
503
+ platform: "linux",
504
+ getuid: () => 0,
505
+ userName: () => "root",
506
+ installedUnit: true,
507
+ runResults: [{ code: 0, stdout: "active\n", stderr: "" }],
508
+ });
509
+ queryHubUnitState(f.deps);
510
+ expect(f.calls).toContainEqual(["systemctl", "is-active", HUB_UNIT]);
511
+ });
512
+
513
+ test("launchd print: state = running → active (+ last exit code parsed)", () => {
514
+ const f = fakeDeps({
515
+ platform: "darwin",
516
+ getuid: () => 501,
517
+ installedUnit: true,
518
+ runResults: [
519
+ {
520
+ code: 0,
521
+ stdout:
522
+ "computer.parachute.hub = {\n\tstate = running\n\tpid = 4242\n\tlast exit code = 0\n}",
523
+ stderr: "",
524
+ },
525
+ ],
526
+ });
527
+ const r = queryHubUnitState(f.deps);
528
+ expect(r.state).toBe("active");
529
+ expect(r.lastExitCode).toBe(0);
530
+ expect(f.calls).toContainEqual(["launchctl", "print", "gui/501/computer.parachute.hub"]);
531
+ });
532
+
533
+ test("launchd print: not running + nonzero last exit code → failed", () => {
534
+ const f = fakeDeps({
535
+ platform: "darwin",
536
+ getuid: () => 501,
537
+ installedUnit: true,
538
+ runResults: [
539
+ {
540
+ code: 0,
541
+ stdout: "computer.parachute.hub = {\n\tstate = not running\n\tlast exit code = 78\n}",
542
+ stderr: "",
543
+ },
544
+ ],
545
+ });
546
+ const r = queryHubUnitState(f.deps);
547
+ expect(r.state).toBe("failed");
548
+ expect(r.lastExitCode).toBe(78);
549
+ });
550
+
551
+ test("launchd print: label not loaded (empty stdout) → inactive, never throws", () => {
552
+ const f = fakeDeps({
553
+ platform: "darwin",
554
+ getuid: () => 501,
555
+ installedUnit: true,
556
+ runResults: [{ code: 1, stdout: "", stderr: "Could not find service" }],
557
+ });
558
+ expect(queryHubUnitState(f.deps).state).toBe("inactive");
559
+ });
560
+
561
+ test("a thrown manager run never escapes — degrades to unknown", () => {
562
+ const f = fakeDeps({
563
+ platform: "linux",
564
+ getuid: () => 1000,
565
+ installedUnit: true,
566
+ run: () => {
567
+ throw new Error("spawn EPERM");
568
+ },
569
+ });
570
+ const r = queryHubUnitState(f.deps);
571
+ expect(r.state).toBe("unknown");
572
+ expect(r.detail).toContain("spawn EPERM");
573
+ });
574
+ });