@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,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();