@openparachute/hub 0.6.3-rc.1 → 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 (40) 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__/expose-cloudflare.test.ts +163 -72
  5. package/src/__tests__/expose-off-auto.test.ts +26 -1
  6. package/src/__tests__/expose.test.ts +260 -240
  7. package/src/__tests__/hub-control.test.ts +1 -242
  8. package/src/__tests__/hub-server.test.ts +64 -0
  9. package/src/__tests__/lifecycle.test.ts +431 -1886
  10. package/src/__tests__/migrate-cutover.test.ts +840 -0
  11. package/src/__tests__/migrate-offer.test.ts +240 -0
  12. package/src/__tests__/migrate.test.ts +132 -0
  13. package/src/__tests__/status-supervisor.test.ts +12 -77
  14. package/src/__tests__/status.test.ts +157 -708
  15. package/src/__tests__/upgrade.test.ts +351 -5
  16. package/src/api-hub-upgrade.ts +384 -0
  17. package/src/api-hub.ts +2 -1
  18. package/src/cli.ts +85 -10
  19. package/src/commands/expose-cloudflare.ts +63 -71
  20. package/src/commands/expose-supervisor.ts +247 -0
  21. package/src/commands/expose.ts +59 -48
  22. package/src/commands/lifecycle.ts +184 -873
  23. package/src/commands/migrate-cutover.ts +837 -0
  24. package/src/commands/migrate.ts +71 -2
  25. package/src/commands/status.ts +35 -282
  26. package/src/commands/upgrade.ts +100 -2
  27. package/src/help.ts +128 -68
  28. package/src/hub-control.ts +23 -162
  29. package/src/hub-server.ts +28 -0
  30. package/src/hub-upgrade-helper.ts +306 -0
  31. package/src/hub-upgrade-mode.ts +209 -0
  32. package/src/hub-upgrade-status.ts +150 -0
  33. package/src/managed-unit.ts +20 -2
  34. package/src/migrate-offer.ts +186 -0
  35. package/src/process-state.ts +19 -3
  36. package/src/supervisor.ts +29 -24
  37. package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
  38. package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
@@ -0,0 +1,240 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { CutoverResult } from "../commands/migrate-cutover.ts";
6
+ import { hasPriorDetachedInstall, offerMigrateToSupervised } from "../migrate-offer.ts";
7
+ import { writePid } from "../process-state.ts";
8
+
9
+ /**
10
+ * Sandboxed §7.5 auto-detect-and-offer tests. Every offer runs against a fresh
11
+ * tmp `PARACHUTE_HOME` with stubbed unit-detection / cutover / prompt / TTY —
12
+ * no real prompt, no real cutover, no touching `~/.parachute`. The only real fs
13
+ * is seeding pidfiles + services.json (via `writePid`) so the DETECTOR reads
14
+ * genuine on-disk detached-era state.
15
+ */
16
+ interface Harness {
17
+ configDir: string;
18
+ manifestPath: string;
19
+ cleanup: () => void;
20
+ }
21
+
22
+ function makeHarness(): Harness {
23
+ const dir = mkdtempSync(join(tmpdir(), "pcli-offer-"));
24
+ return {
25
+ configDir: dir,
26
+ manifestPath: join(dir, "services.json"),
27
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
28
+ };
29
+ }
30
+
31
+ function seedManifest(manifestPath: string, services: Array<{ name: string; port: number }>): void {
32
+ const full = services.map((s) => ({
33
+ name: s.name,
34
+ port: s.port,
35
+ paths: [`/${s.name}`],
36
+ health: "/health",
37
+ version: "1.0.0",
38
+ }));
39
+ writeFileSync(manifestPath, JSON.stringify({ services: full }));
40
+ }
41
+
42
+ function seedHubPidfile(configDir: string, pid = 12345): void {
43
+ mkdirSync(join(configDir, "hub", "run"), { recursive: true });
44
+ writePid("hub", pid, configDir);
45
+ }
46
+
47
+ const migratedResult: CutoverResult = { outcome: "migrated", port: 1939, messages: ["✓ migrated"] };
48
+
49
+ describe("hasPriorDetachedInstall (the §7.5 (b) detector)", () => {
50
+ test("true when a hub pidfile exists", () => {
51
+ const h = makeHarness();
52
+ try {
53
+ seedHubPidfile(h.configDir);
54
+ expect(hasPriorDetachedInstall(h.configDir, h.manifestPath)).toBe(true);
55
+ } finally {
56
+ h.cleanup();
57
+ }
58
+ });
59
+
60
+ test("true when a module pidfile exists", () => {
61
+ const h = makeHarness();
62
+ try {
63
+ seedManifest(h.manifestPath, [{ name: "parachute-vault", port: 1940 }]);
64
+ mkdirSync(join(h.configDir, "vault", "run"), { recursive: true });
65
+ writePid("vault", 999, h.configDir);
66
+ expect(hasPriorDetachedInstall(h.configDir, h.manifestPath)).toBe(true);
67
+ } finally {
68
+ h.cleanup();
69
+ }
70
+ });
71
+
72
+ test("FALSE on a clean/supervised box — services.json alone is NOT enough", () => {
73
+ const h = makeHarness();
74
+ try {
75
+ // A configured box with services.json but NO pidfiles (the supervised
76
+ // shape — children tracked in-process). Must not false-positive.
77
+ seedManifest(h.manifestPath, [{ name: "parachute-vault", port: 1940 }]);
78
+ expect(hasPriorDetachedInstall(h.configDir, h.manifestPath)).toBe(false);
79
+ } finally {
80
+ h.cleanup();
81
+ }
82
+ });
83
+
84
+ test("FALSE on a brand-new box (no services.json, no pidfiles)", () => {
85
+ const h = makeHarness();
86
+ try {
87
+ expect(hasPriorDetachedInstall(h.configDir, h.manifestPath)).toBe(false);
88
+ } finally {
89
+ h.cleanup();
90
+ }
91
+ });
92
+ });
93
+
94
+ describe("offerMigrateToSupervised — when NOT to offer", () => {
95
+ test("no offer when a hub unit IS installed (already supervised)", async () => {
96
+ const h = makeHarness();
97
+ try {
98
+ seedHubPidfile(h.configDir);
99
+ let cutoverCalled = false;
100
+ const result = await offerMigrateToSupervised({
101
+ configDir: h.configDir,
102
+ manifestPath: h.manifestPath,
103
+ log: () => {},
104
+ isHubUnitInstalled: () => true, // a unit exists
105
+ hasPriorDetached: () => true,
106
+ cutover: async () => {
107
+ cutoverCalled = true;
108
+ return migratedResult;
109
+ },
110
+ prompt: async () => "y",
111
+ isTty: true,
112
+ });
113
+ expect(result.outcome).toBe("no-offer");
114
+ expect(cutoverCalled).toBe(false);
115
+ } finally {
116
+ h.cleanup();
117
+ }
118
+ });
119
+
120
+ test("no offer when there is no prior-detached evidence (clean box)", async () => {
121
+ const h = makeHarness();
122
+ try {
123
+ const result = await offerMigrateToSupervised({
124
+ configDir: h.configDir,
125
+ manifestPath: h.manifestPath,
126
+ log: () => {},
127
+ isHubUnitInstalled: () => false,
128
+ hasPriorDetached: () => false, // clean box
129
+ prompt: async () => {
130
+ throw new Error("prompt must not be called on a clean box");
131
+ },
132
+ isTty: true,
133
+ });
134
+ expect(result.outcome).toBe("no-offer");
135
+ } finally {
136
+ h.cleanup();
137
+ }
138
+ });
139
+ });
140
+
141
+ describe("offerMigrateToSupervised — interactive (TTY)", () => {
142
+ test("accept → runs the cutover, reports migrated", async () => {
143
+ const h = makeHarness();
144
+ try {
145
+ let cutoverCalled = false;
146
+ const result = await offerMigrateToSupervised({
147
+ configDir: h.configDir,
148
+ manifestPath: h.manifestPath,
149
+ log: () => {},
150
+ isHubUnitInstalled: () => false,
151
+ hasPriorDetached: () => true,
152
+ cutover: async () => {
153
+ cutoverCalled = true;
154
+ return migratedResult;
155
+ },
156
+ prompt: async () => "y",
157
+ isTty: true,
158
+ });
159
+ expect(cutoverCalled).toBe(true);
160
+ expect(result.outcome).toBe("migrated");
161
+ } finally {
162
+ h.cleanup();
163
+ }
164
+ });
165
+
166
+ test("decline → does NOT run the cutover", async () => {
167
+ const h = makeHarness();
168
+ try {
169
+ let cutoverCalled = false;
170
+ const log: string[] = [];
171
+ const result = await offerMigrateToSupervised({
172
+ configDir: h.configDir,
173
+ manifestPath: h.manifestPath,
174
+ log: (l) => log.push(l),
175
+ isHubUnitInstalled: () => false,
176
+ hasPriorDetached: () => true,
177
+ cutover: async () => {
178
+ cutoverCalled = true;
179
+ return migratedResult;
180
+ },
181
+ prompt: async () => "n",
182
+ isTty: true,
183
+ });
184
+ expect(cutoverCalled).toBe(false);
185
+ expect(result.outcome).toBe("declined");
186
+ expect(log.join("\n")).toContain("parachute migrate --to-supervised");
187
+ } finally {
188
+ h.cleanup();
189
+ }
190
+ });
191
+
192
+ test("accept but cutover fails → migrate-failed (NOT migrated)", async () => {
193
+ const h = makeHarness();
194
+ try {
195
+ const result = await offerMigrateToSupervised({
196
+ configDir: h.configDir,
197
+ manifestPath: h.manifestPath,
198
+ log: () => {},
199
+ isHubUnitInstalled: () => false,
200
+ hasPriorDetached: () => true,
201
+ cutover: async () => ({ outcome: "port-stuck", port: 1939, messages: ["stuck"] }),
202
+ prompt: async () => "yes",
203
+ isTty: true,
204
+ });
205
+ expect(result.outcome).toBe("migrate-failed");
206
+ } finally {
207
+ h.cleanup();
208
+ }
209
+ });
210
+ });
211
+
212
+ describe("offerMigrateToSupervised — non-interactive (no TTY)", () => {
213
+ test("PRINTS the exact command and NEVER runs the cutover", async () => {
214
+ const h = makeHarness();
215
+ try {
216
+ let cutoverCalled = false;
217
+ const log: string[] = [];
218
+ const result = await offerMigrateToSupervised({
219
+ configDir: h.configDir,
220
+ manifestPath: h.manifestPath,
221
+ log: (l) => log.push(l),
222
+ isHubUnitInstalled: () => false,
223
+ hasPriorDetached: () => true,
224
+ cutover: async () => {
225
+ cutoverCalled = true;
226
+ return migratedResult;
227
+ },
228
+ prompt: async () => {
229
+ throw new Error("prompt must not be called in a non-TTY context");
230
+ },
231
+ isTty: false,
232
+ });
233
+ expect(result.outcome).toBe("printed");
234
+ expect(cutoverCalled).toBe(false);
235
+ expect(log.join("\n")).toContain("parachute migrate --to-supervised");
236
+ } finally {
237
+ h.cleanup();
238
+ }
239
+ });
240
+ });
@@ -439,6 +439,62 @@ describe("migrate — interactive + flag behavior", () => {
439
439
  }
440
440
  });
441
441
 
442
+ test("§7.3 refuses while a UNIT-MANAGED hub runs (no pidfile, detected via manager)", async () => {
443
+ const h = makeHarness();
444
+ try {
445
+ seedSafelist(h.configDir);
446
+ // NO hub pidfile — this is a supervised/unit-managed hub. Before the §7.3
447
+ // fix the refuse-while-running guard FAILED OPEN here and migrate would
448
+ // archive ~/.parachute out from under the live hub.
449
+ touch(join(h.configDir, "daily.db"), "X");
450
+
451
+ const logs: string[] = [];
452
+ const code = await migrate({
453
+ configDir: h.configDir,
454
+ now: () => APRIL_19,
455
+ log: (l) => logs.push(l),
456
+ prompt: async () => {
457
+ throw new Error("prompt must not be called");
458
+ },
459
+ yes: true,
460
+ isTty: true,
461
+ // No pidfile alive; the manager reports the hub unit active.
462
+ alive: () => false,
463
+ hubUnitState: () => ({ state: "active" }),
464
+ });
465
+ expect(code).toBe(1);
466
+ expect(logs.join("\n")).toMatch(/services are currently running/i);
467
+ // No archive happened — the guard held.
468
+ expect(existsSync(join(h.configDir, ".archive-2026-04-19"))).toBe(false);
469
+ expect(existsSync(join(h.configDir, "daily.db"))).toBe(true);
470
+ } finally {
471
+ h.cleanup();
472
+ }
473
+ });
474
+
475
+ test("§7.3 archive PROCEEDS when the manager reports the hub inactive (no false-positive)", async () => {
476
+ const h = makeHarness();
477
+ try {
478
+ seedSafelist(h.configDir);
479
+ touch(join(h.configDir, "daily.db"), "X");
480
+
481
+ const code = await migrate({
482
+ configDir: h.configDir,
483
+ now: () => APRIL_19,
484
+ log: () => {},
485
+ yes: true,
486
+ isTty: true,
487
+ alive: () => false,
488
+ hubUnitState: () => ({ state: "inactive" }),
489
+ });
490
+ expect(code).toBe(0);
491
+ // The sweep ran — daily.db is archived.
492
+ expect(existsSync(join(h.configDir, ".archive-2026-04-19", "daily.db"))).toBe(true);
493
+ } finally {
494
+ h.cleanup();
495
+ }
496
+ });
497
+
442
498
  test("refuses non-TTY without --yes (CI / pipe safety)", async () => {
443
499
  const h = makeHarness();
444
500
  try {
@@ -713,7 +769,83 @@ describe("listRunningServices", () => {
713
769
  h.configDir,
714
770
  join(h.configDir, "services.json"),
715
771
  () => false,
772
+ // Explicit no-unit stub so this stays deterministic even on a dev box
773
+ // that happens to have a hub unit installed (the §7.3 manager check).
774
+ () => ({ state: "no-unit" }),
775
+ );
776
+ expect(running).toEqual([]);
777
+ } finally {
778
+ h.cleanup();
779
+ }
780
+ });
781
+
782
+ test("§7.3 archive-guard: a unit-managed hub (no pidfile) is detected RUNNING via the manager", () => {
783
+ const h = makeHarness();
784
+ try {
785
+ seedSafelist(h.configDir);
786
+ // NO hub pidfile (unit-managed hubs don't write one) → the pidfile check
787
+ // (alive => false) reports the hub as not-running. Before the fix this
788
+ // FAILED OPEN. The platform-manager check sees `active` and holds.
789
+ const running = listRunningServices(
790
+ h.configDir,
791
+ join(h.configDir, "services.json"),
792
+ () => false,
793
+ () => ({ state: "active" }),
794
+ );
795
+ expect(running).toContain("hub");
796
+ } finally {
797
+ h.cleanup();
798
+ }
799
+ });
800
+
801
+ test("§7.3 archive-guard: `activating` also reads as running", () => {
802
+ const h = makeHarness();
803
+ try {
804
+ seedSafelist(h.configDir);
805
+ const running = listRunningServices(
806
+ h.configDir,
807
+ join(h.configDir, "services.json"),
808
+ () => false,
809
+ () => ({ state: "activating" }),
810
+ );
811
+ expect(running).toContain("hub");
812
+ } finally {
813
+ h.cleanup();
814
+ }
815
+ });
816
+
817
+ test("§7.3 archive-guard: `failed` / `inactive` / `no-manager` are NOT running", () => {
818
+ const h = makeHarness();
819
+ try {
820
+ seedSafelist(h.configDir);
821
+ for (const state of ["failed", "inactive", "no-manager", "no-unit", "unknown"] as const) {
822
+ const running = listRunningServices(
823
+ h.configDir,
824
+ join(h.configDir, "services.json"),
825
+ () => false,
826
+ () => ({ state }),
827
+ );
828
+ expect(running).not.toContain("hub");
829
+ }
830
+ } finally {
831
+ h.cleanup();
832
+ }
833
+ });
834
+
835
+ test("§7.3 archive-guard: a manager-query that throws never crashes the guard (fails closed-ish)", () => {
836
+ const h = makeHarness();
837
+ try {
838
+ seedSafelist(h.configDir);
839
+ const running = listRunningServices(
840
+ h.configDir,
841
+ join(h.configDir, "services.json"),
842
+ () => false,
843
+ () => {
844
+ throw new Error("systemctl exploded");
845
+ },
716
846
  );
847
+ // The throw is swallowed; the pidfile check (no pid) governs → not running.
848
+ // (The guard must not crash; it just gets no extra signal from the manager.)
717
849
  expect(running).toEqual([]);
718
850
  } finally {
719
851
  h.cleanup();
@@ -12,12 +12,12 @@ import {
12
12
  import { upsertService } from "../services-manifest.ts";
13
13
 
14
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).
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
21
  */
22
22
 
23
23
  function makeTempPath(): { path: string; cleanup: () => void; configDir: string } {
@@ -90,7 +90,6 @@ function supervisorOpts(configDir: string, path: string, o: SupervisorArmOpts) {
90
90
  installSourceDeps: STUB_INSTALL_SOURCE,
91
91
  hubSrcDir: "/nonexistent/hub/src",
92
92
  supervisor: {
93
- unitInstalled: true,
94
93
  hubUnitDeps: FAKE_HUB_UNIT_DEPS,
95
94
  queryHubUnitState: () => o.managerState,
96
95
  probeHubHealth: async () => o.hubHealthy,
@@ -224,7 +223,6 @@ describe("status — Phase 3c supervisor arm: hub row", () => {
224
223
  }),
225
224
  // Replace the query with one that throws — status must not crash.
226
225
  supervisor: {
227
- unitInstalled: true,
228
226
  hubUnitDeps: FAKE_HUB_UNIT_DEPS,
229
227
  queryHubUnitState: () => {
230
228
  throw new Error("systemctl exploded");
@@ -498,72 +496,9 @@ describe("status — Phase 3c supervisor arm: module rows", () => {
498
496
  });
499
497
  });
500
498
 
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
- });
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.