@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
@@ -1,43 +1,34 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { createServer } from "node:net";
4
4
  import { dirname, join } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
5
  import { CONFIG_DIR } from "./config.ts";
7
- import { hubDbPath } from "./hub-db.ts";
8
- import {
9
- type AliveFn,
10
- clearPid,
11
- defaultAlive,
12
- ensureLogPath,
13
- readPid,
14
- runDir,
15
- writePid,
16
- } from "./process-state.ts";
17
- import { WELL_KNOWN_DIR } from "./well-known.ts";
6
+ import { type AliveFn, clearPid, defaultAlive, readPid, runDir } from "./process-state.ts";
18
7
 
19
8
  /**
20
- * Lifecycle for the internal hub HTTP server. The hub is *not* a user-facing
21
- * service (not in services.json) it's an implementation detail of
22
- * `parachute expose`, spawned implicitly on bringup and torn down on the
23
- * final teardown.
9
+ * Hub identity + port helpers + `stopHub` (the detached-stop path the migrate
10
+ * cutover still uses). The hub is *not* a user-facing service (not in
11
+ * services.json). Phase 5b retired the detached `ensureHubRunning` bringup the
12
+ * hub now runs under a platform unit (`parachute serve`, see `hub-unit.ts` /
13
+ * `managed-unit.ts`); `init` brings it up via `installAndStartHubUnit`. This
14
+ * file keeps `stopHub` (used by `migrate` to stop a legacy detached hub during
15
+ * the cutover) + the canonical-port readers/writers.
24
16
  *
25
17
  * The hub lives under `svc = "hub"` in the process-state world, so its PID,
26
- * logs, and runtime files land at `~/.parachute/hub/{run,logs}/…` alongside
27
- * every other managed service.
18
+ * logs, and runtime files land at `~/.parachute/hub/{run,logs}/…`.
28
19
  */
29
20
 
30
21
  export const HUB_SVC = "hub";
31
22
  export const HUB_PACKAGE = "@openparachute/hub";
32
23
  export const HUB_DEFAULT_PORT = 1939;
33
24
  /**
34
- * Default fallback range is 1 — the hub binds 1939 or fails. Walking up would
35
- * steal another Parachute service's slot from the canonical 1939–1949 range.
36
- * Tests and debug tooling can pass a larger `fallbackRange` explicitly.
25
+ * The container `PARACHUTE_HOME` — the Render Blueprint (and the shared Fly
26
+ * image) pins this exact path. `PARACHUTE_HOME === CONTAINER_HOME` is the most
27
+ * reliable container-mode signal the hub has. Single source of truth so the
28
+ * `/api/hub` status surface (`api-hub.ts`) and the in-place-vs-redeploy
29
+ * detection (`hub-upgrade-mode.ts`) can't drift on the magic path.
37
30
  */
38
- export const HUB_PORT_FALLBACK_RANGE = 1;
39
-
40
- const HUB_SERVER_PATH = fileURLToPath(new URL("./hub-server.ts", import.meta.url));
31
+ export const CONTAINER_HOME = "/parachute";
41
32
 
42
33
  export function hubPortPath(configDir: string = CONFIG_DIR): string {
43
34
  return join(runDir(HUB_SVC, configDir), `${HUB_SVC}.port`);
@@ -62,29 +53,6 @@ export function clearHubPort(configDir: string = CONFIG_DIR): void {
62
53
  if (existsSync(p)) rmSync(p, { force: true });
63
54
  }
64
55
 
65
- /**
66
- * Seam over `Bun.spawn`, mirroring the lifecycle Spawner — tests never want
67
- * to actually fork a process. The real implementation opens the log file,
68
- * pipes stdout+stderr into it, and detaches.
69
- */
70
- export interface HubSpawner {
71
- spawn(cmd: readonly string[], logFile: string): number;
72
- }
73
-
74
- export const defaultHubSpawner: HubSpawner = {
75
- spawn(cmd, logFile) {
76
- const fd = openSync(logFile, "a");
77
- // Inherit env so the hub child process sees PATH, HOME, PARACHUTE_HOME,
78
- // etc. Bun.spawn defaults to empty env — see api-modules-ops.ts.
79
- const proc = Bun.spawn([...cmd], {
80
- stdio: ["ignore", fd, fd],
81
- env: process.env,
82
- });
83
- proc.unref();
84
- return proc.pid;
85
- },
86
- };
87
-
88
56
  export type HubPortProbe = (port: number) => Promise<boolean>;
89
57
  export type KillFn = (pid: number, signal: NodeJS.Signals | number) => void;
90
58
  export type SleepFn = (ms: number) => Promise<void>;
@@ -151,126 +119,19 @@ export const defaultPortProbe: HubPortProbe = (port) =>
151
119
  server.listen(port, "127.0.0.1");
152
120
  });
153
121
 
122
+ /**
123
+ * Ensure-hub options shape. Phase 5b retired the detached `ensureHubRunning`
124
+ * spawn this used to drive; `init`'s `defaultEnsureHubViaUnit` (hub-unit-backed)
125
+ * reuses this opts shape for its parameter signature. Only `configDir` /
126
+ * `startPort` / `log` are read on that path.
127
+ */
154
128
  export interface EnsureHubOpts {
155
129
  configDir?: string;
156
- wellKnownDir?: string;
157
- spawner?: HubSpawner;
158
- alive?: AliveFn;
159
- probe?: HubPortProbe;
160
- sleep?: SleepFn;
161
- /**
162
- * Look up the PID listening on `port`. Production default uses `lsof`;
163
- * tests inject a stub. Used to report which orphan process is holding
164
- * the canonical hub port when the bind probe fails — so the operator
165
- * has a concrete PID to point `parachute restart hub` at, not just an
166
- * "unavailable" error. See hub#287.
167
- */
168
- pidOnPort?: PidOnPortFn;
169
- /** Starting port (default 1939). First port that probe()s true wins. */
130
+ /** Starting port (default 1939). */
170
131
  startPort?: number;
171
- /** How many ports to try before giving up (default 20). */
172
- fallbackRange?: number;
173
- /**
174
- * Ports to skip during fallback — typically service ports from services.json
175
- * so the hub doesn't steal a port a registered service will bind later.
176
- * Probed ports that happen to be listening still fail the probe on their own;
177
- * this guards the case where the service isn't running yet.
178
- */
179
- reservedPorts?: Iterable<number>;
180
- /** How long to wait after spawn before claiming readiness. Short — tests set to 0. */
181
- readyWaitMs?: number;
182
- /**
183
- * Public origin to use as the OAuth `iss` claim and as the base for the
184
- * authorization-server metadata document. Forwarded to the hub server as
185
- * `--issuer <url>`. When omitted, the hub falls back to the request's own
186
- * origin — fine for loopback testing, wrong under tailscale where the
187
- * request origin is `http://127.0.0.1:<port>`.
188
- */
189
- issuer?: string;
190
132
  log?: (line: string) => void;
191
133
  }
192
134
 
193
- export interface EnsureHubResult {
194
- pid: number;
195
- port: number;
196
- /** True when this call spawned the hub; false when it was already running. */
197
- started: boolean;
198
- }
199
-
200
- export async function ensureHubRunning(opts: EnsureHubOpts = {}): Promise<EnsureHubResult> {
201
- const configDir = opts.configDir ?? CONFIG_DIR;
202
- const wellKnownDir = opts.wellKnownDir ?? WELL_KNOWN_DIR;
203
- const spawner = opts.spawner ?? defaultHubSpawner;
204
- const alive = opts.alive ?? defaultAlive;
205
- const probe = opts.probe ?? defaultPortProbe;
206
- const sleep = opts.sleep ?? defaultSleep;
207
- const pidOnPort = opts.pidOnPort ?? defaultPidOnPort;
208
- const startPort = opts.startPort ?? HUB_DEFAULT_PORT;
209
- const fallbackRange = opts.fallbackRange ?? HUB_PORT_FALLBACK_RANGE;
210
- const reservedPorts = new Set(opts.reservedPorts ?? []);
211
- const readyWaitMs = opts.readyWaitMs ?? 150;
212
- const log = opts.log ?? (() => {});
213
-
214
- const existingPid = readPid(HUB_SVC, configDir);
215
- const existingPort = readHubPort(configDir);
216
- if (existingPid !== undefined && alive(existingPid) && existingPort !== undefined) {
217
- return { pid: existingPid, port: existingPort, started: false };
218
- }
219
- // Any stale state (pid without live process, port without pid) — wipe.
220
- if (existingPid !== undefined) clearPid(HUB_SVC, configDir);
221
- clearHubPort(configDir);
222
-
223
- let chosenPort: number | undefined;
224
- for (let i = 0; i < fallbackRange; i++) {
225
- const candidate = startPort + i;
226
- if (reservedPorts.has(candidate)) continue;
227
- if (await probe(candidate)) {
228
- chosenPort = candidate;
229
- break;
230
- }
231
- }
232
- if (chosenPort === undefined) {
233
- // Port is held by *something*. If we can name the PID (lsof on macOS /
234
- // Linux), point the operator at `parachute restart hub` — which now
235
- // detects and kills the orphan even when hub.port is missing or stale
236
- // (hub#287). Without a PID, fall back to the classic lsof hint.
237
- const range =
238
- fallbackRange === 1 ? `${startPort}` : `${startPort}..${startPort + fallbackRange - 1}`;
239
- const orphanPid = fallbackRange === 1 ? pidOnPort(startPort) : undefined;
240
- if (orphanPid !== undefined) {
241
- throw new Error(
242
- `hub: port ${range} unavailable — PID ${orphanPid} is already listening. Run \`parachute restart hub\` to clean up and restart, or \`kill ${orphanPid}\` then \`parachute start hub\`.`,
243
- );
244
- }
245
- throw new Error(
246
- `hub: port ${range} unavailable. Run \`lsof -iTCP:${startPort}\` to find what's using it, or pass --hub-port to override.`,
247
- );
248
- }
249
-
250
- const logFile = ensureLogPath(HUB_SVC, configDir);
251
- const cmd = [
252
- "bun",
253
- HUB_SERVER_PATH,
254
- "--port",
255
- String(chosenPort),
256
- "--well-known-dir",
257
- wellKnownDir,
258
- "--db",
259
- hubDbPath(configDir),
260
- ...(opts.issuer ? ["--issuer", opts.issuer] : []),
261
- ];
262
- const pid = spawner.spawn(cmd, logFile);
263
- writePid(HUB_SVC, pid, configDir);
264
- writeHubPort(chosenPort, configDir);
265
-
266
- // A tiny grace period so the subsequent `tailscale serve` proxy target
267
- // isn't pointed at a not-yet-listening socket.
268
- if (readyWaitMs > 0) await sleep(readyWaitMs);
269
-
270
- log(`hub listening on 127.0.0.1:${chosenPort} (pid ${pid}); logs: ${logFile}`);
271
- return { pid, port: chosenPort, started: true };
272
- }
273
-
274
135
  export interface StopHubOpts {
275
136
  configDir?: string;
276
137
  kill?: KillFn;
package/src/hub-server.ts CHANGED
@@ -47,9 +47,13 @@
47
47
  * /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
48
48
  * /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
49
49
  * /api/hub (GET) → hub version + uptime + install-source (host:admin)
50
+ * /api/hub/upgrade (POST) → SPA-driven hub self-upgrade → 202 + detached helper (host:admin, §5.3/D4)
51
+ * /api/hub/upgrade/status (GET) → poll the on-disk hub-upgrade status (host:admin)
50
52
  * /api/modules (GET) → curated + installed module catalog (host:auth)
51
53
  * /api/modules/channel (PUT) → operator channel toggle (host:admin)
52
54
  * /api/modules/:short/install (POST) → bun add + spawn (async op)
55
+ * /api/modules/:short/start (POST) → supervisor.start of an installed module (sync)
56
+ * /api/modules/:short/stop (POST) → supervisor.stop (sync)
53
57
  * /api/modules/:short/restart (POST) → supervisor restart (sync)
54
58
  * /api/modules/:short/upgrade (POST) → bun add @<channel> + restart (async op)
55
59
  * /api/modules/:short/uninstall (POST) → stop child + bun remove + drop row (sync)
@@ -134,6 +138,7 @@ import {
134
138
  handleAccountChangePasswordPost,
135
139
  handleAccountHomeGet,
136
140
  } from "./api-account.ts";
141
+ import { handleHubUpgrade, handleHubUpgradeStatus } from "./api-hub-upgrade.ts";
137
142
  import { handleApiHub } from "./api-hub.ts";
138
143
  import { handleApiMe } from "./api-me.ts";
139
144
  import { handleApiMintToken } from "./api-mint-token.ts";
@@ -141,8 +146,11 @@ import { handleApiModulesConfig, parseModulesConfigPath } from "./api-modules-co
141
146
  import {
142
147
  getDefaultOperationsRegistry,
143
148
  handleInstall,
149
+ handleLogs,
144
150
  handleOperationGet,
145
151
  handleRestart,
152
+ handleStart,
153
+ handleStop,
146
154
  handleUninstall,
147
155
  handleUpgrade,
148
156
  parseModulesPath,
@@ -1737,6 +1745,31 @@ export function hubFetch(
1737
1745
  return handleApiMe(req, { db: getDb() });
1738
1746
  }
1739
1747
 
1748
+ // SPA-driven hub self-upgrade (design 2026-06-01 §5.3 / D4). Dedicated
1749
+ // endpoint — the hub is NOT a supervised module (no /api/modules/hub/*),
1750
+ // so it gets its own route. Checked BEFORE the `/api/hub` exact match
1751
+ // below (and the `/api/modules/*` switch) so the more-specific path wins.
1752
+ // Does NOT require a supervisor: the hub upgrades itself via a detached
1753
+ // helper, not the supervisor. Host-admin gated inside the handler (reuses
1754
+ // the same validateAccessToken + scope check the module-ops API uses); the
1755
+ // channel param is a closed enum (rc|latest) — no injection surface.
1756
+ if (pathname === "/api/hub/upgrade") {
1757
+ if (!getDb) return dbNotConfigured();
1758
+ return handleHubUpgrade(req, {
1759
+ db: getDb(),
1760
+ issuer: oauthDeps(req).issuer,
1761
+ configDir: CONFIG_DIR,
1762
+ });
1763
+ }
1764
+ if (pathname === "/api/hub/upgrade/status") {
1765
+ if (!getDb) return dbNotConfigured();
1766
+ return handleHubUpgradeStatus(req, {
1767
+ db: getDb(),
1768
+ issuer: oauthDeps(req).issuer,
1769
+ configDir: CONFIG_DIR,
1770
+ });
1771
+ }
1772
+
1740
1773
  // Hub version + uptime + install-source — drives the admin SPA's
1741
1774
  // version badge (hub#348). Bearer-gated on `parachute:host:admin`
1742
1775
  // (same as the rest of the operator-only admin surface).
@@ -1865,8 +1898,14 @@ export function hubFetch(
1865
1898
  switch (match.rest) {
1866
1899
  case "install":
1867
1900
  return handleInstall(req, match.short, opsDeps);
1901
+ case "start":
1902
+ return handleStart(req, match.short, opsDeps);
1903
+ case "stop":
1904
+ return handleStop(req, match.short, opsDeps);
1868
1905
  case "restart":
1869
1906
  return handleRestart(req, match.short, opsDeps);
1907
+ case "logs":
1908
+ return handleLogs(req, match.short, opsDeps);
1870
1909
  case "upgrade":
1871
1910
  return handleUpgrade(req, match.short, opsDeps);
1872
1911
  case "uninstall":