@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
@@ -3,6 +3,12 @@ import { join } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
5
5
  import { HUB_SVC } from "../hub-control.ts";
6
+ import {
7
+ type HubUnitDeps,
8
+ type HubUnitState,
9
+ defaultHubUnitDeps,
10
+ queryHubUnitState,
11
+ } from "../hub-unit.ts";
6
12
  import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
7
13
  import { knownServices, shortNameForManifest } from "../service-spec.ts";
8
14
  import { readManifestLenient } from "../services-manifest.ts";
@@ -335,6 +341,31 @@ export async function defaultPrompt(question: string): Promise<string> {
335
341
  return answer;
336
342
  }
337
343
 
344
+ /**
345
+ * Hub-unit-state query seam for the archive guard (§7.3). Production uses the
346
+ * real `queryHubUnitState` over `defaultHubUnitDeps`; tests inject a stub so the
347
+ * unit-managed-hub-detected-as-running path is exercised without a live
348
+ * systemctl/launchctl. Returns the platform manager's view of the hub unit.
349
+ */
350
+ export type HubUnitStateQuery = (deps: HubUnitDeps) => { state: HubUnitState };
351
+
352
+ /**
353
+ * The §7.3 archive-guard fix: a hub unit is "running, leave it alone" when the
354
+ * platform manager reports it `active` or `activating`. `failed` / `inactive` /
355
+ * `no-unit` / `no-manager` / `unknown` are NOT treated as running — those mean
356
+ * the unit isn't actively holding state at this moment.
357
+ *
358
+ * Deliberately conservative on `unknown`: an unparseable manager response is
359
+ * NOT a license to archive (that would re-introduce the silent fail-open), but
360
+ * it's also not a clear "running" signal — so we fall through to the pidfile
361
+ * check (which catches a detached-era hub) rather than blanket-refusing on a
362
+ * transient manager hiccup. `active`/`activating` is the unambiguous unit-up
363
+ * signal that the pidfile-only guard missed.
364
+ */
365
+ function hubUnitReportsRunning(state: HubUnitState): boolean {
366
+ return state === "active" || state === "activating";
367
+ }
368
+
338
369
  /**
339
370
  * Probe whether any managed service (or the hub) is currently running.
340
371
  * Returns the list of short names that are live; an empty list means the
@@ -343,15 +374,42 @@ export async function defaultPrompt(question: string): Promise<string> {
343
374
  * Reads services.json leniently so a malformed entry doesn't block the
344
375
  * pre-flight (better to surface "running: vault" + a corrupt-entry warning
345
376
  * elsewhere than to refuse migration on an unrelated parsing problem).
377
+ *
378
+ * §7.3 archive-guard fix: the hub liveness check is BOTH the pidfile
379
+ * (`processState(HUB_SVC)`, the detached-era signal) AND the platform manager
380
+ * (`queryHubUnitState`, the unit-era signal). A unit-managed hub writes NO
381
+ * pidfile, so `processState(HUB_SVC)` reports it not-running and the
382
+ * refuse-while-running guard would silently FAIL OPEN — `migrate` could archive
383
+ * `~/.parachute` out from under a live unit-managed hub. Querying the manager
384
+ * too closes that hole: an `active`/`activating` hub unit is correctly detected
385
+ * as running and the guard holds.
346
386
  */
347
387
  export function listRunningServices(
348
388
  configDir: string,
349
389
  manifestPath: string,
350
390
  alive: AliveFn,
391
+ hubUnitState: HubUnitStateQuery = queryHubUnitState,
392
+ hubUnitDeps: HubUnitDeps = defaultHubUnitDeps,
351
393
  ): string[] {
352
394
  const running: string[] = [];
395
+ // Detached-era signal: the hub pidfile.
353
396
  const hubState = processState(HUB_SVC, configDir, alive);
354
- if (hubState.status === "running") running.push(HUB_SVC);
397
+ let hubRunning = hubState.status === "running";
398
+ // Unit-era signal (§7.3): the platform manager. A unit-managed hub has no
399
+ // pidfile, so without this it would fail open. Never throws — `queryHubUnitState`
400
+ // degrades to `unknown` on a manager hiccup; we only escalate to "running" on
401
+ // an unambiguous active/activating.
402
+ if (!hubRunning) {
403
+ try {
404
+ const unit = hubUnitState(hubUnitDeps);
405
+ if (hubUnitReportsRunning(unit.state)) hubRunning = true;
406
+ } catch {
407
+ // A manager-query failure must not crash the guard — fall through. The
408
+ // pidfile check already ran; treat an unqueryable manager as "no extra
409
+ // signal," neither forcing-running nor opening the gate.
410
+ }
411
+ }
412
+ if (hubRunning) running.push(HUB_SVC);
355
413
  let manifest: ReturnType<typeof readManifestLenient>;
356
414
  try {
357
415
  manifest = readManifestLenient(manifestPath);
@@ -385,6 +443,15 @@ export interface MigrateOpts {
385
443
  * non-interactive guard without manipulating real fds.
386
444
  */
387
445
  isTty?: boolean;
446
+ /**
447
+ * Test seam: the §7.3 platform-manager hub-unit-state query used by the
448
+ * archive guard. Production uses `queryHubUnitState`; tests inject a stub to
449
+ * exercise the unit-managed-hub-detected-as-running path without a live
450
+ * systemctl/launchctl.
451
+ */
452
+ hubUnitState?: HubUnitStateQuery;
453
+ /** Test seam: the hub-unit deps passed to `hubUnitState` (default production). */
454
+ hubUnitDeps?: HubUnitDeps;
388
455
  }
389
456
 
390
457
  export async function migrate(opts: MigrateOpts = {}): Promise<number> {
@@ -397,6 +464,8 @@ export async function migrate(opts: MigrateOpts = {}): Promise<number> {
397
464
  const yes = opts.yes ?? false;
398
465
  const alive = opts.alive ?? defaultAlive;
399
466
  const isTty = opts.isTty ?? Boolean(process.stdin.isTTY);
467
+ const hubUnitState = opts.hubUnitState ?? queryHubUnitState;
468
+ const hubUnitDeps = opts.hubUnitDeps ?? defaultHubUnitDeps;
400
469
 
401
470
  // Refuse-while-running: archiving a path a live daemon owns can corrupt
402
471
  // its state. Print the runners and bail before we read the directory.
@@ -404,7 +473,7 @@ export async function migrate(opts: MigrateOpts = {}): Promise<number> {
404
473
  // operator may explicitly want to see what would move while things are
405
474
  // up.
406
475
  if (!dryRun) {
407
- const running = listRunningServices(configDir, manifestPath, alive);
476
+ const running = listRunningServices(configDir, manifestPath, alive, hubUnitState, hubUnitDeps);
408
477
  if (running.length > 0) {
409
478
  log("parachute migrate: services are currently running — refusing to sweep:");
410
479
  for (const short of running) log(` - ${short}`);
@@ -47,6 +47,62 @@ export interface BootedModule {
47
47
  readonly reason?: string;
48
48
  }
49
49
 
50
+ export interface SpawnReqShape {
51
+ short: string;
52
+ cmd: readonly string[];
53
+ cwd?: string;
54
+ env?: Record<string, string>;
55
+ }
56
+
57
+ export interface BuildSpawnRequestOpts {
58
+ /** Config dir ($PARACHUTE_HOME). Used to read the module's per-service `.env`. */
59
+ readonly configDir: string;
60
+ /** Canonical hub origin → child env `PARACHUTE_HUB_ORIGIN`. Skipped when absent. */
61
+ readonly hubOrigin?: string;
62
+ /**
63
+ * Extra env merged on top of the derived env (PORT / .env / HUB_ORIGIN).
64
+ * Wins over all of them. Used by the API `start` handler's test seam +
65
+ * first-boot vault-name pass-through (`spawnEnv`). Empty/absent on the
66
+ * boot path.
67
+ */
68
+ readonly extraEnv?: Record<string, string>;
69
+ }
70
+
71
+ /**
72
+ * Build the `Supervisor.start` request for a single module, identically on
73
+ * both the serve-boot path and the `POST /api/modules/:short/start` handler.
74
+ *
75
+ * Env layering (later wins):
76
+ * 1. `PORT` from the services.json `entry.port` — overrides hub's own PORT
77
+ * so supervised children honor their canonical port assignment
78
+ * (hub#356/#357).
79
+ * 2. per-service `.env` at `<configDir>/<short>/.env` — operator-configured
80
+ * values (e.g. scribe provider keys) override the bare PORT.
81
+ * 3. `PARACHUTE_HUB_ORIGIN` = `opts.hubOrigin` — anchors the child's `iss`
82
+ * expectation to the value hub mints with (hub#365).
83
+ * 4. `opts.extraEnv` — test seam / first-boot pass-through; wins last.
84
+ *
85
+ * `cwd` is set to `entry.installDir` when present (third-party modules ship
86
+ * relative startCmds that need it; first-party fallbacks use absolute / PATH
87
+ * binaries so cwd is a no-op there).
88
+ */
89
+ export function buildModuleSpawnRequest(
90
+ short: string,
91
+ entry: ServiceEntry,
92
+ cmd: readonly string[],
93
+ opts: BuildSpawnRequestOpts,
94
+ ): SpawnReqShape {
95
+ const fileEnv = readEnvFileValues(join(opts.configDir, short, ".env"));
96
+ const env: Record<string, string> = { PORT: String(entry.port), ...fileEnv };
97
+ if (opts.hubOrigin) env[HUB_ORIGIN_ENV] = opts.hubOrigin;
98
+ if (opts.extraEnv) Object.assign(env, opts.extraEnv);
99
+
100
+ const req: SpawnReqShape = { short, cmd };
101
+ if (entry.installDir) req.cwd = entry.installDir;
102
+ if (Object.keys(env).length > 0) req.env = env;
103
+ return req;
104
+ }
105
+
50
106
  /**
51
107
  * Walk services.json, spawn every manageable module via the
52
108
  * supervisor. Returns a per-module decision log so the caller can
@@ -92,32 +148,22 @@ export async function bootSupervisedModules(
92
148
  continue;
93
149
  }
94
150
 
95
- // PORT override (hub#357 — third spawn site missed by hub#356).
96
- // Without this, modules that read process.env.PORT (vault, scribe)
97
- // inherit hub's PORT from Bun.spawn's env: process.env default and
98
- // crash EADDRINUSE on hub's port. Container deploy was still broken
99
- // after #356 because this BOOT path runs on hub startup before the
100
- // supervisor's other spawn paths see any traffic. fileEnv wins on
101
- // collision so per-service .env can still override.
102
- const fileEnv = readEnvFileValues(join(opts.configDir, short, ".env"));
103
- const env: Record<string, string> = { PORT: String(entry.port), ...fileEnv };
104
- if (opts.hubOrigin) env[HUB_ORIGIN_ENV] = opts.hubOrigin;
105
-
106
- const req: {
107
- short: string;
108
- cmd: readonly string[];
109
- cwd?: string;
110
- env?: Record<string, string>;
111
- } = {
112
- short,
113
- cmd,
114
- };
115
- // Third-party modules ship clean relative startCmds — cwd:
116
- // installDir makes them resolve. First-party fallbacks use
117
- // absolute / PATH binaries so cwd is a no-op there.
118
- if (entry.installDir) req.cwd = entry.installDir;
119
- if (Object.keys(env).length > 0) req.env = env;
151
+ // PORT override (hub#357 — third spawn site missed by hub#356), per-service
152
+ // .env merge, and PARACHUTE_HUB_ORIGIN propagation (hub#365) all live in the
153
+ // shared `buildModuleSpawnRequest` so the `POST /api/modules/:short/start`
154
+ // handler builds an identical request (design 2026-06-01 §3.3).
155
+ const req = buildModuleSpawnRequest(short, entry, cmd, {
156
+ configDir: opts.configDir,
157
+ ...(opts.hubOrigin !== undefined ? { hubOrigin: opts.hubOrigin } : {}),
158
+ });
120
159
 
160
+ // Serial await, not Promise.all: `supervisor.start` now carries a bounded
161
+ // post-spawn port-readiness gate (DEFAULT_START_READY_MS), so boot latency
162
+ // is the SUM of each slow-binding module's gate wait before `Bun.serve`
163
+ // comes up. Intentional — sequential boot keeps the start-error/install-card
164
+ // surface ordered and avoids a thundering-herd of port probes. Don't switch
165
+ // to `Promise.all` without accounting for the gate (it'd overlap the waits
166
+ // but also fire N concurrent readiness probes mid-boot).
121
167
  await supervisor.start(req);
122
168
  log(`[supervisor] ${short}: started (cmd=${cmd.join(" ")}).`);
123
169
  results.push({ short, entryName: entry.name, status: "started" });