@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
@@ -51,6 +51,12 @@ import { homedir } from "node:os";
51
51
  import { dirname, join } from "node:path";
52
52
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
53
53
  import { HUB_PACKAGE, HUB_SVC } from "../hub-control.ts";
54
+ import {
55
+ type HubUnitDeps,
56
+ type HubUnitManagerOpResult,
57
+ defaultHubUnitDeps,
58
+ restartHubUnit as restartHubUnitImpl,
59
+ } from "../hub-unit.ts";
54
60
  import { ModuleManifestError } from "../module-manifest.ts";
55
61
  import {
56
62
  type ServiceSpec,
@@ -201,6 +207,25 @@ export interface UpgradeOpts {
201
207
  * flaky probe never blocks a legitimate upgrade.
202
208
  */
203
209
  resolveChannelVersion?: (pkg: string, channel: string) => Promise<string | null>;
210
+ /**
211
+ * Supervisor-path seams (design §5) — the ONLY runtime as of Phase 5b.
212
+ * `upgrade hub` rewrites the binary as usual then RESTARTS THE UNIT via the
213
+ * platform manager (`restartHubUnit` — systemctl restart / launchctl kickstart
214
+ * -k): the manager tears down the old hub (children die), starts the new
215
+ * binary, which re-boots every module from services.json. NEVER a PID-signal
216
+ * restart (launchd KeepAlive / systemd Restart=always would fight). Module-
217
+ * target restarts drive the running Supervisor (lifecycle's own dispatch, fed
218
+ * a `supervisor` block here). The detached restart arm was retired in Phase 5b.
219
+ *
220
+ * Production CLI dispatch passes `supervisor: {}`; tests inject the seams they
221
+ * want to assert.
222
+ */
223
+ supervisor?: {
224
+ /** Deps for the `restartHubUnit` manager op. */
225
+ hubUnitDeps?: HubUnitDeps;
226
+ /** Restart the hub unit via the platform manager (never a PID signal, §5). */
227
+ restartHubUnit?: (deps: HubUnitDeps) => HubUnitManagerOpResult;
228
+ };
204
229
  }
205
230
 
206
231
  interface ResolvedTarget {
@@ -226,6 +251,8 @@ interface Resolved {
226
251
  channelOverride: "rc" | "latest" | undefined;
227
252
  allowDowngrade: boolean;
228
253
  resolveChannelVersion: (pkg: string, channel: string) => Promise<string | null>;
254
+ hubUnitDeps: HubUnitDeps;
255
+ restartHubUnit: (deps: HubUnitDeps) => HubUnitManagerOpResult;
229
256
  }
230
257
 
231
258
  function bunGlobalPrefixes(): string[] {
@@ -258,6 +285,20 @@ function resolve(opts: UpgradeOpts): Resolved {
258
285
  allowDowngrade: opts.allowDowngrade ?? false,
259
286
  resolveChannelVersion:
260
287
  opts.resolveChannelVersion ?? ((pkg, channel) => npmViewVersion(pkg, channel, runner)),
288
+ // Supervisor seams (the only runtime as of Phase 5b). Production passes
289
+ // `supervisor: {}`; tests inject the seams they want to assert.
290
+ ...resolveUpgradeSupervisor(opts.supervisor),
291
+ };
292
+ }
293
+
294
+ /** Resolve the supervisor seams for the upgrade path. */
295
+ function resolveUpgradeSupervisor(opts: UpgradeOpts["supervisor"]): {
296
+ hubUnitDeps: HubUnitDeps;
297
+ restartHubUnit: (deps: HubUnitDeps) => HubUnitManagerOpResult;
298
+ } {
299
+ return {
300
+ hubUnitDeps: opts?.hubUnitDeps ?? defaultHubUnitDeps,
301
+ restartHubUnit: opts?.restartHubUnit ?? restartHubUnitImpl,
261
302
  };
262
303
  }
263
304
 
@@ -422,6 +463,13 @@ async function resolveTargets(
422
463
  // Sweep mode: hub first, then everything in services.json. Hub-first means a
423
464
  // dispatcher upgrade can't be undermined mid-sweep by a service upgrade that
424
465
  // restarts hub for reasons unrelated to its own code change.
466
+ //
467
+ // Phase 4 note (design §5 item 4): on a unit-managed box, restarting the hub
468
+ // unit re-boots ALL modules from services.json. So the hub-first sweep already
469
+ // boots every module onto current code when the hub binary upgrades; each
470
+ // module target then upgrades its package + `supervisor.restart`s it
471
+ // individually (idempotent — a no-op restart if its code didn't change). The
472
+ // hub-first invariant still holds.
425
473
  const targets: ResolvedTarget[] = [hubTarget()];
426
474
  for (const entry of manifest.services) {
427
475
  const short = shortNameForManifest(entry.name);
@@ -515,6 +563,56 @@ function readPackageVersion(pkgJsonPath: string): string | null {
515
563
  }
516
564
  }
517
565
 
566
+ /**
567
+ * Restart an upgraded target after its binary/package was rewritten (design §5).
568
+ * Supervised path only (Phase 5b — the detached restart arm is retired):
569
+ * - HUB target → restart the hub UNIT via the platform manager
570
+ * (`restartHubUnit` — systemctl restart / launchctl kickstart -k). The
571
+ * manager tears down the old hub (children die), starts the new binary,
572
+ * which re-boots every module from services.json. NEVER a PID-signal restart
573
+ * (launchd KeepAlive / systemd Restart=always would fight). The command
574
+ * returns once the restart is dispatched; it does not need to outlive the
575
+ * old hub.
576
+ * - MODULE target → drive the running Supervisor by handing `lifecycle.restart`
577
+ * the SAME opts a bare `parachute restart <svc>` threads: `supervisor: {}`
578
+ * (so the real `isHubUnitInstalled` probe — not a forced override — decides)
579
+ * plus `migrateOffer: { enabled: true }`. Its dispatch then routes to
580
+ * `supervisor.restart` with the 404-fallthrough. The hub unit was already
581
+ * restarted hub-first in the sweep, so it's up to answer.
582
+ *
583
+ * A box with no hub unit takes the actionable migrate path: the hub-target
584
+ * `restartHubUnit` returns `no-unit` (messages surfaced, non-zero), and the
585
+ * module-target `lifecycle.restart` — driven with `supervisor: {}` +
586
+ * `migrateOffer` — runs `requireSupervisedOrOffer`'s real probe, then the §7.5
587
+ * auto-offer / actionable "run `parachute migrate --to-supervised`" error,
588
+ * rather than a bare connection-refused from `driveModuleOp`.
589
+ */
590
+ async function restartTarget(target: ResolvedTarget, r: Resolved): Promise<number> {
591
+ if (target.short === HUB_SVC) {
592
+ const res = r.restartHubUnit(r.hubUnitDeps);
593
+ for (const m of res.messages) r.log(m);
594
+ if (res.outcome === "ok") {
595
+ r.log(`${target.short}: restarted the hub unit (all modules re-booted).`);
596
+ return 0;
597
+ }
598
+ return 1;
599
+ }
600
+ // Module target: route through lifecycle's supervisor arm with the SAME opts a
601
+ // bare `parachute restart <svc>` threads — `supervisor: {}` (let the real
602
+ // `isHubUnitInstalled` probe decide; do NOT force `unitInstalled: true` and
603
+ // bypass it) plus `migrateOffer: { enabled: true }`. On a supervised box this
604
+ // drives `supervisor.restart` over the loopback module-ops API; on a no-unit
605
+ // box it gets the §7.5 auto-offer / actionable migrate error instead of a bare
606
+ // connection-refused. `hubUnitDeps` threads through so the real probe + manager
607
+ // ops use the resolved deps (production defaults; tests inject the seams).
608
+ return await r.restartFn(target.short, {
609
+ manifestPath: r.manifestPath,
610
+ configDir: r.configDir,
611
+ supervisor: { hubUnitDeps: r.hubUnitDeps },
612
+ migrateOffer: { enabled: true },
613
+ });
614
+ }
615
+
518
616
  async function upgradeLinked(
519
617
  target: ResolvedTarget,
520
618
  sourceDir: string,
@@ -573,7 +671,7 @@ async function upgradeLinked(
573
671
  }
574
672
 
575
673
  r.log(`${target.short}: ${before.sha.slice(0, 7)} → ${after.sha.slice(0, 7)}; restarting…`);
576
- return await r.restartFn(target.short, { manifestPath: r.manifestPath, configDir: r.configDir });
674
+ return await restartTarget(target, r);
577
675
  }
578
676
 
579
677
  /**
@@ -642,7 +740,7 @@ async function upgradeNpm(target: ResolvedTarget, sourceDir: string, r: Resolved
642
740
  }
643
741
 
644
742
  r.log(`${target.short}: ${beforeVersion ?? "?"} → ${afterVersion ?? "?"}; restarting…`);
645
- return await r.restartFn(target.short, { manifestPath: r.manifestPath, configDir: r.configDir });
743
+ return await restartTarget(target, r);
646
744
  }
647
745
 
648
746
  async function upgradeOne(target: ResolvedTarget, r: Resolved): Promise<number> {
package/src/help.ts CHANGED
@@ -16,15 +16,16 @@ Usage:
16
16
  parachute setup interactive walk-through: install services + configure
17
17
  parachute install <service> install and register a service
18
18
  services: ${services}
19
- parachute status show installed services, process state, health
20
- parachute start [service] start all services (or one) in the background
21
- parachute stop [service] stop all services (or one) SIGTERM then SIGKILL
22
- parachute restart [service] stop + start
19
+ parachute status show installed services, run state, health
20
+ parachute start [service] start a module via the supervisor (or ensure the hub is up)
21
+ parachute stop [service] stop a module via the supervisor (or stop the hub unit)
22
+ parachute restart [service] restart a module via the supervisor (or restart the hub unit)
23
23
  parachute upgrade [service] pull / re-install + restart (skips if no changes)
24
24
  parachute logs <service> [-f] print service logs; -f to tail
25
25
  parachute expose tailnet [off] HTTPS across your tailnet (supported)
26
26
  parachute expose public [off] HTTPS on the public internet (exploratory)
27
- parachute serve run hub HTTP server foregrounded (for containers)
27
+ parachute serve run the hub + supervisor foregrounded (the runtime)
28
+ parachute migrate --to-supervised move a legacy detached install to the managed hub
28
29
  parachute migrate [--dry-run] archive legacy files at ecosystem root
29
30
  parachute auth <cmd> identity (set password, manage 2FA)
30
31
  parachute vault <args...> vault-specific ops (tokens, 2fa, config, init,
@@ -123,7 +124,9 @@ What it does:
123
124
  get you to that wizard.
124
125
 
125
126
  Idempotent — every re-run is safe:
126
- 1. If the hub isn't running, start it.
127
+ 1. Install + start the hub as a managed unit (launchd on a Mac,
128
+ systemd on a Linux VM) so it survives reboots; no-op if it's
129
+ already up.
127
130
  2. If the hub isn't already exposed, in a terminal, offer to set up
128
131
  exposure (Tailscale Funnel, Cloudflare Tunnel, or stay loopback).
129
132
  The default highlights "no thanks" on laptops and Cloudflare on
@@ -273,11 +276,17 @@ Usage:
273
276
  parachute status
274
277
 
275
278
  What it does:
276
- Reads ~/.parachute/services.json. For each registered service:
277
- - checks PID file at ~/.parachute/<svc>/run/<svc>.pid
278
- - probes http://localhost:<port><health> (skipped for known-stopped processes)
279
+ Reads ~/.parachute/services.json. For each registered module:
280
+ - reads its run state from the running hub's supervisor (supervisor.list())
281
+ - probes http://localhost:<port><health> (skipped for known-stopped modules)
279
282
  - classifies the install source as bun-linked (local checkout) or npm
280
283
 
284
+ The hub gets its own row, derived from the platform process manager
285
+ (launchd \`launchctl print\` / systemd \`systemctl is-active\`, or
286
+ "container runtime (managed)" on Render / Fly) — the supervisor runs the
287
+ modules, but the manager runs the hub. The hub row appears even with zero
288
+ modules installed.
289
+
281
290
  The STATE column rolls process state + probe result into one of four
282
291
  canonical labels (per parachute-patterns/patterns/design-system.md §6):
283
292
  active supervised, running, last probe ok
@@ -293,10 +302,10 @@ What it does:
293
302
  HEALTH (ok / down / http <code>). Workstream F collapsed them onto the
294
303
  single STATE column the SPA + well-known doc also speak.
295
304
 
296
- Stopped services render as STATE=inactive and don't count toward the
305
+ Stopped modules render as STATE=inactive and don't count toward the
297
306
  exit code — they're an expected state after fresh install before
298
- \`parachute start\`. Running or externally-managed services that fail
299
- health checks render as STATE=failing and exit 1.
307
+ \`parachute start\`. Supervised modules that fail health checks render
308
+ as STATE=failing and exit 1.
300
309
 
301
310
  A "STALE: services.json cached … live package.json …" continuation line
302
311
  appears under a row when a bun-linked service has been rebuilt but the
@@ -409,39 +418,41 @@ Cloudflare tunnel requirements (--cloudflare):
409
418
  }
410
419
 
411
420
  export function startHelp(): string {
412
- return `parachute start — spawn services in the background
421
+ return `parachute start — start a module via the running hub's supervisor
413
422
 
414
423
  Usage:
415
- parachute start start every installed service
416
- parachute start <service> start just that one
417
- parachute start hub start the internal hub (port 1939)
424
+ parachute start ensure the hub is up (boots every module)
425
+ parachute start <service> start just that one module
426
+ parachute start hub ensure the hub unit is up
418
427
 
419
428
  What it does:
420
- For each target service, spawns its start command detached, redirects
421
- stdout+stderr to ~/.parachute/<service>/logs/<service>.log, and records
422
- the child PID at ~/.parachute/<service>/run/<service>.pid.
423
-
424
- Idempotent: if the service is already running, no-op.
425
- If a stale PID file exists (process died without cleanup), it's cleared
426
- and the service starts fresh.
427
-
428
- \`parachute start hub\` brings up the internal hub directly (normally
429
- spawned implicitly by \`parachute expose\`). Useful when restarting a
430
- hub that crashed without an active expose layer.
429
+ \`parachute serve\` is the one runtime the hub foreground with an
430
+ in-process supervisor that runs each module as an attached child. These
431
+ verbs are clients of that running hub:
432
+
433
+ - \`parachute start <service>\` ensures the hub unit is up, then asks its
434
+ supervisor to start that one module (over the loopback module-ops API,
435
+ authenticated with your operator token). No per-module daemon is
436
+ spawned — the supervisor owns the module process.
437
+ - \`parachute start\` (no service) ensures the hub unit is up. The hub
438
+ boots every installed module on start, so this brings the whole stack
439
+ up. On a box with no hub unit yet, it offers to run
440
+ \`parachute migrate --to-supervised\` (which installs + starts the unit).
441
+ - \`parachute start hub\` is the same "ensure the hub unit is up."
442
+
443
+ Idempotent: starting an already-running module is a no-op.
431
444
 
432
445
  Flags:
433
- --hub-origin <url> override PARACHUTE_HUB_ORIGIN passed to services
446
+ --hub-origin <url> override the hub origin used as the operator token's
447
+ \`iss\` validator on the loopback module-ops call
434
448
  (default: current expose-state hub origin, else loopback).
435
- For \`start hub\`, also doubles as the hub's --issuer.
436
449
 
437
450
  Examples:
438
451
  parachute start bring everything up
439
- parachute start vault just vault
440
- parachute start hub just the internal hub
452
+ parachute start vault just vault, via the supervisor
441
453
  parachute logs vault watch what just started
442
454
 
443
- Start commands by service:
444
- hub bun <cli>/hub-server.ts --port <picked> ...
455
+ Module start commands (run by the supervisor under \`serve\`):
445
456
  vault parachute-vault serve
446
457
  scribe parachute-scribe serve
447
458
  app parachute-app serve
@@ -451,39 +462,45 @@ Start commands by service:
451
462
  }
452
463
 
453
464
  export function stopHelp(): string {
454
- return `parachute stop — stop running services cleanly
465
+ return `parachute stop — stop a module (or the hub) cleanly
455
466
 
456
467
  Usage:
457
- parachute stop stop every installed service
458
- parachute stop <service> stop just that one
459
- parachute stop hub stop the internal hub
468
+ parachute stop stop the hub unit (modules stop with it)
469
+ parachute stop <service> stop just that one module
470
+ parachute stop hub stop the hub unit
460
471
 
461
472
  What it does:
462
- Sends SIGTERM, waits up to 10s for a clean exit, then escalates to
463
- SIGKILL if the process is still alive. Removes the PID file on success.
464
-
465
- No-op if the service wasn't running.
466
-
467
- Bare \`parachute stop\` (no service) does NOT stop the hub — that's
468
- managed by the active expose layer (or \`parachute stop hub\` directly).
473
+ - \`parachute stop <service>\` asks the running hub's supervisor to stop
474
+ that one module (SIGTERM to the module's process group, then SIGKILL if
475
+ it doesn't exit). No-op if it wasn't running.
476
+ - \`parachute stop\` (no service) and \`parachute stop hub\` stop the hub
477
+ UNIT through the platform process manager (\`launchctl bootout\` /
478
+ \`systemctl stop\`) never a raw PID signal, which launchd's KeepAlive
479
+ would just undo. Modules are attached children and stop with the hub.
469
480
 
470
481
  Examples:
471
- parachute stop stop everything before sleep
472
- parachute stop vault just vault
473
- parachute stop hub just the internal hub
482
+ parachute stop vault just vault, via the supervisor
483
+ parachute stop stop the whole stack (the hub unit)
474
484
  `;
475
485
  }
476
486
 
477
487
  export function restartHelp(): string {
478
- return `parachute restart — stop then start
488
+ return `parachute restart — restart a module (or the hub)
479
489
 
480
490
  Usage:
481
- parachute restart restart every installed service
482
- parachute restart <service> restart just that one
483
- parachute restart hub restart the internal hub
491
+ parachute restart restart the hub unit (re-boots every module)
492
+ parachute restart <service> restart just that one module
493
+ parachute restart hub restart the hub unit
484
494
 
485
495
  What it does:
486
- Equivalent to \`parachute stop <svc> && parachute start <svc>\`.
496
+ - \`parachute restart <service>\` asks the running hub's supervisor to
497
+ restart that one module. If the module isn't currently supervised
498
+ (e.g. it crashed out of its restart budget), this falls through to a
499
+ fresh \`start\` so the verb is total over module state.
500
+ - \`parachute restart\` (no service) and \`parachute restart hub\` restart
501
+ the hub UNIT via the platform manager (\`launchctl kickstart -k\` /
502
+ \`systemctl restart\`), which re-boots every module — it is NOT a
503
+ fan-out of per-module restarts.
487
504
  `;
488
505
  }
489
506
 
@@ -523,6 +540,13 @@ What it does:
523
540
  package.json version unchanged after bun add -g), the restart is skipped.
524
541
  Re-running on an up-to-date install is a fast no-op.
525
542
 
543
+ The "restart" step matches the supervised model: upgrading a MODULE
544
+ restarts it via the running hub's supervisor; \`parachute upgrade hub\`
545
+ rewrites the binary on disk, then restarts the hub UNIT through the
546
+ platform manager (\`launchctl kickstart -k\` / \`systemctl restart\`), which
547
+ re-boots every module onto the new code. From the admin SPA (the no-CLI
548
+ Render / Fly path) the same hub-upgrade runs via POST /api/hub/upgrade.
549
+
526
550
  Channel detection (hub#332):
527
551
  Pre-1.0 governance ships two channels — \`@rc\` (the development chain) and
528
552
  \`@latest\` (explicitly-promoted stable). \`parachute upgrade\` reads the
@@ -556,16 +580,23 @@ If no log file exists yet, prints a hint to \`parachute start <service>\`.
556
580
  }
557
581
 
558
582
  export function serveHelp(): string {
559
- return `parachute serve — run the hub HTTP server foregrounded
583
+ return `parachute serve — run the hub + supervisor foregrounded (the runtime)
560
584
 
561
585
  Usage:
562
586
  parachute serve
563
587
 
564
- The container shape. The on-box CLI flow (\`parachute expose\`) spawns the
565
- hub-server detached and tracks it via pidfile; \`parachute serve\` is the
566
- inverse the hub IS the foreground process, lives as long as its
567
- supervisor wants it to, and exits on signal. Built for Docker / Render /
568
- systemd, but works fine for a foregrounded local debug too.
588
+ This is the one runtime everywhere. The hub IS the foreground process: it
589
+ runs the HTTP server on port 1939 AND an in-process supervisor that spawns
590
+ every installed module as an attached child, multiplexes their logs into
591
+ its own stdout, and crash-restarts them on a budget. It runs until it gets
592
+ a signal, then SIGTERMs its children and exits.
593
+
594
+ You don't normally invoke \`serve\` by hand — \`parachute init\` (or
595
+ \`parachute migrate --to-supervised\`) installs it as a managed unit so your
596
+ platform's process manager keeps it alive across crashes and reboots:
597
+ launchd on a Mac, systemd on a Linux VM, the container runtime's CMD on
598
+ Render / Fly. Run it directly only for a foregrounded local debug, or on an
599
+ init-less host that can't host a unit.
569
600
 
570
601
  Environment:
571
602
  PORT bind port (default 1939). Render injects
@@ -598,12 +629,14 @@ Examples:
598
629
  }
599
630
 
600
631
  export function migrateHelp(): string {
601
- return `parachute migrate — archive known-legacy files at the ecosystem root
632
+ return `parachute migrate — archive legacy root files, or cut over to the supervised model
602
633
 
603
634
  Usage:
604
635
  parachute migrate [--list] [--dry-run] [--yes]
636
+ parachute migrate --to-supervised
637
+ parachute migrate --teardown
605
638
 
606
- What it does:
639
+ What it does (the default archive sweep):
607
640
  Scans ~/.parachute/ for files and directories that match the
608
641
  known-legacy allowlist (daily.db*, server.yaml, channel.log/err,
609
642
  channel.start.sh, top-level logs/, tokens.db*, and the legacy lens/
@@ -619,9 +652,11 @@ What it does:
619
652
  Dotfiles at the root (.env, .DS_Store, prior .archive-* dirs) are
620
653
  never touched.
621
654
 
622
- Safety:
655
+ Archive-sweep safety:
623
656
  - Refuses to sweep while any service is running — stop them first
624
- (\`parachute stop\`) or preview with \`--list\`.
657
+ (\`parachute stop\`) or preview with \`--list\`. A hub running under a
658
+ process-manager unit (the supervised model) is detected as running
659
+ via the platform manager too, not just its pidfile.
625
660
  - SQLite-shape files (\`*.db\`, \`*.db-wal\`, \`*.db-shm\`) get a
626
661
  \`[live-db]\` label and pull an extra confirmation; wal/shm
627
662
  consistency depends on all three moving together.
@@ -629,14 +664,39 @@ Safety:
629
664
  \`[unknown — skipping]\`, with skipped items printed last.
630
665
  - In a non-TTY shell (CI / piped), refuses without \`--yes\`.
631
666
 
667
+ --to-supervised (detached → supervised cutover):
668
+ Migrate a legacy detached install (independent \`parachute start\`-spawned
669
+ daemons) to the supervised model: the hub runs as \`parachute serve\`
670
+ under your platform's process manager (launchd on macOS, systemd on
671
+ Linux), survives reboots, and supervises modules as children. The
672
+ cutover is idempotent + re-runnable, and ordered so it never races the
673
+ canonical hub port: it writes the unit file WITHOUT starting it, stops
674
+ the detached hub + modules, sweeps any process still bound to a declared
675
+ port, verifies the ports are free, THEN starts the unit and verifies the
676
+ hub is healthy. If anything fails partway it leaves the box recoverable
677
+ (unit written but not started) and you can simply re-run it. A box with
678
+ no service manager (a container / init-less host) can't host a unit —
679
+ run \`parachute serve\` in the foreground there instead.
680
+
681
+ --teardown (cutover rollback):
682
+ Remove the hub process-manager unit. Idempotent + best-effort. Use it to
683
+ roll back a cutover: the unit is removed and you fall back to running the
684
+ hub with \`parachute serve\` (or re-run \`--to-supervised\` to reinstall it).
685
+ Run this BEFORE \`bun remove -g @openparachute/hub\` so a removed package
686
+ doesn't leave a unit pointing at a deleted binary.
687
+
632
688
  Flags:
633
- --list print the plan; make no changes (friendly preview)
634
- --dry-run synonym for --list (kept for back-compat)
635
- --yes, -y skip the confirmation prompt; required in non-TTY shells
689
+ --list print the plan; make no changes (friendly preview)
690
+ --dry-run synonym for --list (kept for back-compat)
691
+ --yes, -y skip the confirmation prompt; required in non-TTY shells
692
+ --to-supervised cut over a detached install to the supervised model
693
+ --teardown remove the hub unit (cutover rollback)
636
694
 
637
695
  Examples:
638
- parachute migrate --list see what would move, without touching anything
639
- parachute migrate interactive sweep (prompts before acting)
640
- parachute migrate --yes sweep without prompting
696
+ parachute migrate --list see what would move, without touching anything
697
+ parachute migrate interactive sweep (prompts before acting)
698
+ parachute migrate --yes sweep without prompting
699
+ parachute migrate --to-supervised move to the supervised (serve-under-manager) model
700
+ parachute migrate --teardown remove the hub unit (roll back the cutover)
641
701
  `;
642
702
  }