@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,186 @@
1
+ /**
2
+ * §7.5 auto-detect-and-offer — the safety net that keeps a box that landed the
3
+ * cutover code (via `bun add -g @openparachute/hub@<new>` / auto-upgrade) WITHOUT
4
+ * running `parachute migrate --to-supervised` from silently going dead.
5
+ *
6
+ * Design `parachute.computer/design/2026-06-01-hub-as-supervisor-unification.md`
7
+ * §7.5: the first time a lifecycle verb (start/stop/restart) runs and finds
8
+ * (a) NO hub unit installed, AND
9
+ * (b) evidence of a prior detached install (pidfiles / services.json),
10
+ * it OFFERS to run the cutover (interactive prompt) or, in a non-interactive
11
+ * context, PRINTS the exact command. It does NOT silently auto-migrate —
12
+ * archiving / stopping services is destructive-adjacent, so this is
13
+ * detect-and-offer only.
14
+ *
15
+ * This is the bridge's companion: Phase 5a keeps the detached spawners intact
16
+ * (un-migrated boxes still work), and keeps the pidfile READERS so this detector
17
+ * can see the old state. Phase 5b retires the spawners; the readers stay one
18
+ * release longer precisely so this detector keeps working.
19
+ *
20
+ * EVERYTHING is behind injectable seams so tests drive the offer without a real
21
+ * prompt, a real cutover, or touching the operator's `~/.parachute`.
22
+ */
23
+
24
+ import { existsSync } from "node:fs";
25
+ import {
26
+ type CutoverOpts,
27
+ type CutoverResult,
28
+ cutoverToSupervised,
29
+ } from "./commands/migrate-cutover.ts";
30
+ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "./config.ts";
31
+ import { HUB_SVC } from "./hub-control.ts";
32
+ import { type HubUnitDeps, defaultHubUnitDeps, isHubUnitInstalled } from "./hub-unit.ts";
33
+ import { readPid } from "./process-state.ts";
34
+ import { shortNameForManifest } from "./service-spec.ts";
35
+ import { readManifestLenient } from "./services-manifest.ts";
36
+
37
+ /**
38
+ * Detect evidence of a prior DETACHED install — the §7.5 (b) condition. True
39
+ * when EITHER:
40
+ * - a hub pidfile exists (`~/.parachute/hub/run/hub.pid`) — the clearest
41
+ * detached-era fingerprint, written only by the detached `ensureHubRunning`
42
+ * spawn; OR
43
+ * - any services.json module has a pidfile (a module was `parachute start`-ed
44
+ * the detached way).
45
+ *
46
+ * services.json EXISTING alone is NOT enough — a freshly `init`-ed supervised
47
+ * box also has a services.json. The discriminant is a PIDFILE, which only the
48
+ * detached path writes (the supervised path tracks children in-process, no
49
+ * pidfile). So this detects "a box that ran the detached daemons," not merely
50
+ * "a box that has been configured."
51
+ *
52
+ * Pure read; never mutates. Uses `readPid` (a reader the bridge keeps).
53
+ */
54
+ export function hasPriorDetachedInstall(
55
+ configDir: string = CONFIG_DIR,
56
+ manifestPath: string = SERVICES_MANIFEST_PATH,
57
+ ): boolean {
58
+ // Hub pidfile — the detached-era fingerprint.
59
+ if (readPid(HUB_SVC, configDir) !== undefined) return true;
60
+ // Any module pidfile.
61
+ if (!existsSync(manifestPath)) return false;
62
+ let services: ReturnType<typeof readManifestLenient>["services"];
63
+ try {
64
+ services = readManifestLenient(manifestPath).services;
65
+ } catch {
66
+ return false;
67
+ }
68
+ for (const entry of services) {
69
+ const short = shortNameForManifest(entry.name) ?? entry.name;
70
+ if (readPid(short, configDir) !== undefined) return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * The interactive-prompt seam (mirrors `migrate.ts`'s `defaultPrompt`). Tests
77
+ * inject a stub; production reads a line from stdin.
78
+ */
79
+ export type OfferPrompt = (question: string) => Promise<string>;
80
+
81
+ export async function defaultOfferPrompt(question: string): Promise<string> {
82
+ const { createInterface } = await import("node:readline/promises");
83
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
84
+ const answer = await rl.question(question);
85
+ rl.close();
86
+ return answer;
87
+ }
88
+
89
+ export interface MigrateOfferOpts {
90
+ configDir?: string;
91
+ manifestPath?: string;
92
+ log?: (line: string) => void;
93
+ /** Injectable: is a hub unit installed? (default real probe). */
94
+ isHubUnitInstalled?: (deps: HubUnitDeps) => boolean;
95
+ hubUnitDeps?: HubUnitDeps;
96
+ /** Injectable: prior-detached-install detector (default `hasPriorDetachedInstall`). */
97
+ hasPriorDetached?: (configDir: string, manifestPath: string) => boolean;
98
+ /** Injectable: the cutover itself (default `cutoverToSupervised`). */
99
+ cutover?: (opts: CutoverOpts) => Promise<CutoverResult>;
100
+ /** Injectable interactive prompt (default reads stdin). */
101
+ prompt?: OfferPrompt;
102
+ /**
103
+ * TTY override. Production reads `process.stdin.isTTY`; tests pass true/false
104
+ * to drive the interactive-vs-print branch without manipulating real fds.
105
+ */
106
+ isTty?: boolean;
107
+ }
108
+
109
+ export type MigrateOfferOutcome =
110
+ /** No offer was made (a unit is installed, or no prior-detached evidence). */
111
+ | "no-offer"
112
+ /** Interactive: the operator declined the offer. */
113
+ | "declined"
114
+ /** Non-interactive: we printed the exact command (didn't run it). */
115
+ | "printed"
116
+ /** Interactive: the operator accepted and the cutover succeeded. */
117
+ | "migrated"
118
+ /** Interactive: the operator accepted but the cutover failed (recoverable). */
119
+ | "migrate-failed";
120
+
121
+ export interface MigrateOfferResult {
122
+ outcome: MigrateOfferOutcome;
123
+ /** The cutover result, when one ran. */
124
+ cutover?: CutoverResult;
125
+ }
126
+
127
+ /**
128
+ * §7.5 detect-and-offer. Call this from a lifecycle verb's detached arm (before
129
+ * doing the detached work). Returns a structured outcome:
130
+ *
131
+ * - `no-offer` — a hub unit IS installed (the supervised box; nothing to
132
+ * offer), or there's no prior-detached evidence (a clean box). The caller
133
+ * proceeds with whatever it was going to do.
134
+ * - interactive (TTY) — prompt; on yes, RUN the cutover and return `migrated`
135
+ * / `migrate-failed`; on no, return `declined`.
136
+ * - non-interactive (no TTY) — PRINT the exact command and return `printed`.
137
+ * NEVER auto-run in a non-TTY context (a cron/CI pipe must not stop services
138
+ * unprompted).
139
+ *
140
+ * It NEVER silently migrates: the only path that runs the cutover is an explicit
141
+ * interactive "yes."
142
+ */
143
+ export async function offerMigrateToSupervised(
144
+ opts: MigrateOfferOpts = {},
145
+ ): Promise<MigrateOfferResult> {
146
+ const configDir = opts.configDir ?? CONFIG_DIR;
147
+ const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
148
+ const log = opts.log ?? ((line) => console.log(line));
149
+ const unitInstalledFn = opts.isHubUnitInstalled ?? isHubUnitInstalled;
150
+ const hubUnitDeps = opts.hubUnitDeps ?? defaultHubUnitDeps;
151
+ const hasPriorDetached = opts.hasPriorDetached ?? hasPriorDetachedInstall;
152
+ const cutover = opts.cutover ?? cutoverToSupervised;
153
+ const prompt = opts.prompt ?? defaultOfferPrompt;
154
+ const isTty = opts.isTty ?? Boolean(process.stdin.isTTY);
155
+
156
+ // (a) no hub unit installed — only offer on the detached box.
157
+ if (unitInstalledFn(hubUnitDeps)) return { outcome: "no-offer" };
158
+ // (b) prior-detached evidence — don't pester a clean box.
159
+ if (!hasPriorDetached(configDir, manifestPath)) return { outcome: "no-offer" };
160
+
161
+ log("");
162
+ log("This box is running the legacy detached model (independent daemons, no");
163
+ log("process manager). The current Parachute hub runs supervised — `parachute");
164
+ log("serve` under launchd/systemd, with reboot survival + UI module management.");
165
+
166
+ if (!isTty) {
167
+ // Non-interactive: print the exact command, never run it.
168
+ log("");
169
+ log("To migrate, run:");
170
+ log(" parachute migrate --to-supervised");
171
+ log("");
172
+ return { outcome: "printed" };
173
+ }
174
+
175
+ // Interactive: offer to run it now.
176
+ const answer = (await prompt("Migrate to the supervised model now? [y/N] ")).trim().toLowerCase();
177
+ if (answer !== "y" && answer !== "yes") {
178
+ log("Skipped. Run `parachute migrate --to-supervised` when you're ready.");
179
+ return { outcome: "declined" };
180
+ }
181
+
182
+ const result = await cutover({ configDir, manifestPath, log });
183
+ for (const line of result.messages) log(line);
184
+ const ok = result.outcome === "migrated" || result.outcome === "already-migrated";
185
+ return { outcome: ok ? "migrated" : "migrate-failed", cutover: result };
186
+ }
@@ -0,0 +1,457 @@
1
+ /**
2
+ * CLI client for the module-ops HTTP API (`POST /api/modules/:short/<op>`).
3
+ *
4
+ * This is the credential + transport seam that Phase 3 of the
5
+ * hub-as-supervisor unification (design 2026-06-01 §3.1) will repoint
6
+ * `parachute start/stop/restart <svc>` onto: instead of touching pidfiles
7
+ * directly (`commands/lifecycle.ts`), those verbs become authenticated calls
8
+ * to the running hub's in-process Supervisor over loopback.
9
+ *
10
+ * **Phase 1 is additive.** This file ADDS the client + its tests; it does NOT
11
+ * repoint any existing CLI command. `parachute start/stop/restart/install/
12
+ * upgrade` stay on the detached `lifecycle.ts` path until the Phase 3 cutover.
13
+ *
14
+ * ## The credential (§3.1)
15
+ *
16
+ * The on-box caller's proof of operator authority is `~/.parachute/operator.token`
17
+ * — a hub-issued JWT carrying `parachute:host:admin` under the default `admin`
18
+ * scope-set, which is exactly the scope `api-modules-ops.ts` gates on. We READ
19
+ * it via `useOperatorTokenWithAutoRotate` (which validates against the hub DB +
20
+ * issuer and opportunistically re-mints a within-7d-of-expiry token in place);
21
+ * we never mint a parallel token, so there is no second SQLite writer racing
22
+ * the running hub. The token is presented as `Authorization: Bearer` to the
23
+ * loopback hub.
24
+ *
25
+ * No operator token on disk → an actionable error ("no operator token — run
26
+ * `parachute auth rotate-operator`"), never a raw 401.
27
+ *
28
+ * ## Sync vs async ops
29
+ *
30
+ * `start` / `stop` / `restart` / `uninstall` are synchronous: the handler does
31
+ * the work inline and returns the new state in the body. `install` / `upgrade`
32
+ * return `202 { operation_id }` and the client polls
33
+ * `GET /api/modules/operations/:id` to a terminal state. This client handles
34
+ * both: a 202-with-operation_id response is polled to completion; any other
35
+ * 2xx body is returned as-is.
36
+ */
37
+
38
+ import type { Database } from "bun:sqlite";
39
+ import { OperatorTokenExpiredError, useOperatorTokenWithAutoRotate } from "./operator-token.ts";
40
+
41
+ /** Loopback hub base URL when none is injected. The hub pins 1939 (canonical-ports). */
42
+ export const DEFAULT_HUB_BASE_URL = "http://127.0.0.1:1939";
43
+
44
+ /** Default poll interval + ceiling for async operations. */
45
+ const DEFAULT_POLL_INTERVAL_MS = 1_000;
46
+ const DEFAULT_POLL_TIMEOUT_MS = 120_000;
47
+
48
+ /**
49
+ * Default ceiling for the single `GET /api/modules` read in {@link fetchModuleStates}.
50
+ * `status` is a read-only health snapshot — a hub whose request handler is wedged
51
+ * (accepts the TCP connection but never answers) must not hang it. Bounded so the
52
+ * caller degrades to a "couldn't read live module state" note instead of stalling.
53
+ */
54
+ const DEFAULT_STATES_FETCH_TIMEOUT_MS = 3_000;
55
+
56
+ /** Module-op verbs the client can drive against `POST /api/modules/:short/<op>`. */
57
+ export type ModuleOp = "start" | "stop" | "restart" | "install" | "upgrade" | "uninstall";
58
+
59
+ /**
60
+ * Thrown when no `operator.token` exists on disk. The CLI surfaces
61
+ * `.message` directly — it's already actionable. Distinct error class so a
62
+ * caller can branch on "needs bootstrap" vs "hub said no."
63
+ */
64
+ export class NoOperatorTokenError extends Error {
65
+ override name = "NoOperatorTokenError";
66
+ constructor() {
67
+ super(
68
+ "no operator token — run `parachute auth rotate-operator` to mint one (looked for ~/.parachute/operator.token)",
69
+ );
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Thrown when the hub answers a module-op with a non-2xx status. Carries the
75
+ * HTTP status + the parsed `{ error, error_description }` body so the CLI can
76
+ * render the hub's own message (e.g. `not_installed`, `insufficient_scope`).
77
+ */
78
+ export class ModuleOpHttpError extends Error {
79
+ override name = "ModuleOpHttpError";
80
+ readonly status: number;
81
+ readonly code: string;
82
+ constructor(status: number, code: string, description: string) {
83
+ super(`${code}: ${description}`);
84
+ this.status = status;
85
+ this.code = code;
86
+ }
87
+ }
88
+
89
+ /** Thrown when an async operation reaches `failed`, or polling times out. */
90
+ export class ModuleOpFailedError extends Error {
91
+ override name = "ModuleOpFailedError";
92
+ }
93
+
94
+ /** Terminal shape returned to the caller — the hub's response body. */
95
+ export interface ModuleOpResult {
96
+ /** HTTP status of the initiating POST (200 sync, 202 async). */
97
+ readonly status: number;
98
+ /** Parsed JSON body. For async ops, the terminal operation record. */
99
+ readonly body: unknown;
100
+ /** Operation id when the op was async (202); undefined for sync ops. */
101
+ readonly operationId?: string;
102
+ }
103
+
104
+ export interface DriveModuleOpDeps {
105
+ /** Open hub DB handle — used to validate / auto-rotate the operator token. */
106
+ readonly db: Database;
107
+ /** Hub issuer (origin) the operator token's `iss` is validated against. */
108
+ readonly issuer: string;
109
+ /** Loopback hub base URL. Defaults to {@link DEFAULT_HUB_BASE_URL}. */
110
+ readonly baseUrl?: string;
111
+ /** configDir override (where operator.token lives). Defaults to `configDir()`. */
112
+ readonly configDir?: string;
113
+ /** Optional JSON body for the POST (e.g. `{ channel }` on install/upgrade). */
114
+ readonly body?: unknown;
115
+ /**
116
+ * fetch seam. Production passes the global `fetch`; tests inject a fake that
117
+ * asserts the Authorization header + returns canned responses without a
118
+ * real socket.
119
+ */
120
+ readonly fetch?: typeof fetch;
121
+ /** Clock seam for the operator-token rotation check. */
122
+ readonly now?: () => Date;
123
+ /** Sleep seam for the async-op poll loop. Tests stub to advance instantly. */
124
+ readonly sleep?: (ms: number) => Promise<void>;
125
+ /** Poll interval for async ops, ms. Default 1000. */
126
+ readonly pollIntervalMs?: number;
127
+ /** Poll ceiling for async ops, ms. Default 120000. */
128
+ readonly pollTimeoutMs?: number;
129
+ /**
130
+ * Per-request ceiling for the {@link fetchModuleStates} `GET /api/modules` read,
131
+ * ms. Default {@link DEFAULT_STATES_FETCH_TIMEOUT_MS} (3000). Bounds `status`
132
+ * against a wedged hub handler; tests inject a short value to exercise the
133
+ * timeout-degrade path without a real wall-clock wait.
134
+ */
135
+ readonly statesFetchTimeoutMs?: number;
136
+ }
137
+
138
+ /**
139
+ * Read the operator token (auto-rotating if near expiry) and return the bearer
140
+ * to present onward. Throws {@link NoOperatorTokenError} when none is on disk,
141
+ * and re-throws {@link OperatorTokenExpiredError} unchanged (its message is
142
+ * already the actionable "run rotate-operator" shape).
143
+ */
144
+ export async function resolveOperatorBearer(deps: DriveModuleOpDeps): Promise<string> {
145
+ const used = await useOperatorTokenWithAutoRotate(deps.db, {
146
+ issuer: deps.issuer,
147
+ ...(deps.configDir !== undefined ? { configDir: deps.configDir } : {}),
148
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
149
+ });
150
+ if (!used) throw new NoOperatorTokenError();
151
+ return used.token;
152
+ }
153
+
154
+ /**
155
+ * Drive a module-op end-to-end: read the operator token, POST it as Bearer to
156
+ * the loopback hub, and (for async ops that return 202 + operation_id) poll
157
+ * `GET /api/modules/operations/:id` to a terminal state.
158
+ *
159
+ * Throws:
160
+ * - {@link NoOperatorTokenError} — no operator.token on disk.
161
+ * - {@link OperatorTokenExpiredError} — token fully expired (actionable msg).
162
+ * - {@link ModuleOpHttpError} — hub answered non-2xx (carries status + code).
163
+ * - {@link ModuleOpFailedError} — async op reached `failed`, or poll timed out.
164
+ */
165
+ export async function driveModuleOp(
166
+ short: string,
167
+ op: ModuleOp,
168
+ deps: DriveModuleOpDeps,
169
+ ): Promise<ModuleOpResult> {
170
+ const doFetch = deps.fetch ?? fetch;
171
+ const baseUrl = (deps.baseUrl ?? DEFAULT_HUB_BASE_URL).replace(/\/+$/, "");
172
+
173
+ const bearer = await resolveOperatorBearer(deps);
174
+
175
+ const headers: Record<string, string> = { authorization: `Bearer ${bearer}` };
176
+ const init: RequestInit = { method: "POST", headers };
177
+ if (deps.body !== undefined) {
178
+ headers["content-type"] = "application/json";
179
+ init.body = JSON.stringify(deps.body);
180
+ }
181
+
182
+ const res = await doFetch(`${baseUrl}/api/modules/${short}/${op}`, init);
183
+ const body = await parseJsonSafe(res);
184
+
185
+ if (res.status < 200 || res.status >= 300) {
186
+ const { error, error_description } = asErrorBody(body);
187
+ throw new ModuleOpHttpError(res.status, error, error_description);
188
+ }
189
+
190
+ // Async op (install / upgrade): 202 + { operation_id } → poll to terminal.
191
+ if (res.status === 202) {
192
+ const operationId = extractOperationId(body);
193
+ if (!operationId) {
194
+ // 202 means "accepted, poll for completion" — but with no operation_id
195
+ // there's nothing to poll. Silently returning the 202 would strand the
196
+ // caller on an incomplete op; surface it as a hard failure instead.
197
+ throw new ModuleOpFailedError("hub returned 202 but no operation_id in body");
198
+ }
199
+ const terminal = await pollOperation(operationId, bearer, baseUrl, doFetch, deps);
200
+ return { status: res.status, body: terminal, operationId };
201
+ }
202
+
203
+ // Sync op (start / stop / restart / uninstall) — body is the final state.
204
+ return { status: res.status, body };
205
+ }
206
+
207
+ /**
208
+ * Poll `GET /api/modules/operations/:id` until `succeeded` / `failed` or the
209
+ * timeout elapses. Returns the terminal operation record on success; throws
210
+ * {@link ModuleOpFailedError} on `failed` or timeout, {@link ModuleOpHttpError}
211
+ * on a non-2xx poll response.
212
+ */
213
+ async function pollOperation(
214
+ operationId: string,
215
+ bearer: string,
216
+ baseUrl: string,
217
+ doFetch: typeof fetch,
218
+ deps: DriveModuleOpDeps,
219
+ ): Promise<unknown> {
220
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
221
+ const intervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
222
+ const timeoutMs = deps.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
223
+ const now = deps.now ?? (() => new Date());
224
+ const deadline = now().getTime() + timeoutMs;
225
+ const url = `${baseUrl}/api/modules/operations/${operationId}`;
226
+
227
+ while (true) {
228
+ const res = await doFetch(url, {
229
+ method: "GET",
230
+ headers: { authorization: `Bearer ${bearer}` },
231
+ });
232
+ const body = await parseJsonSafe(res);
233
+ if (res.status < 200 || res.status >= 300) {
234
+ const { error, error_description } = asErrorBody(body);
235
+ throw new ModuleOpHttpError(res.status, error, error_description);
236
+ }
237
+ const status = extractOpStatus(body);
238
+ if (status === "succeeded") return body;
239
+ if (status === "failed") {
240
+ const errMsg = extractOpError(body) ?? "operation failed";
241
+ throw new ModuleOpFailedError(errMsg);
242
+ }
243
+ if (now().getTime() >= deadline) {
244
+ throw new ModuleOpFailedError(
245
+ `operation ${operationId} did not complete within ${timeoutMs}ms (last status: ${status ?? "unknown"})`,
246
+ );
247
+ }
248
+ await sleep(intervalMs);
249
+ }
250
+ }
251
+
252
+ /** Terminal shape returned by {@link fetchModuleLogs}. */
253
+ export interface ModuleLogsResult {
254
+ /** The short name the logs belong to. */
255
+ readonly short: string;
256
+ /** Buffered lines, oldest-first (each includes its trailing newline). */
257
+ readonly lines: string[];
258
+ /** The same lines joined — the tail-blob shape `parachute logs` will print. */
259
+ readonly text: string;
260
+ }
261
+
262
+ /**
263
+ * Read a supervised module's recent output from the hub's per-module ring
264
+ * buffer (`GET /api/modules/:short/logs`, §6.5). Additive — this does NOT wire
265
+ * into the `parachute logs` CLI command (that cutover is Phase 3); it's the
266
+ * transport+credential seam Phase 3 will call.
267
+ *
268
+ * Reuses the same operator.token→Bearer path as {@link driveModuleOp} (read,
269
+ * never mint). The buffer replay includes the boot/crash lines that preceded
270
+ * the call — the must-have that a connect-time stream would miss.
271
+ *
272
+ * Throws:
273
+ * - {@link NoOperatorTokenError} — no operator.token on disk.
274
+ * - {@link OperatorTokenExpiredError} — token fully expired (actionable msg).
275
+ * - {@link ModuleOpHttpError} — hub answered non-2xx (e.g. `not_supervised`).
276
+ */
277
+ export async function fetchModuleLogs(
278
+ short: string,
279
+ deps: DriveModuleOpDeps,
280
+ ): Promise<ModuleLogsResult> {
281
+ const doFetch = deps.fetch ?? fetch;
282
+ const baseUrl = (deps.baseUrl ?? DEFAULT_HUB_BASE_URL).replace(/\/+$/, "");
283
+ const bearer = await resolveOperatorBearer(deps);
284
+
285
+ const res = await doFetch(`${baseUrl}/api/modules/${short}/logs`, {
286
+ method: "GET",
287
+ headers: { authorization: `Bearer ${bearer}` },
288
+ });
289
+ const body = await parseJsonSafe(res);
290
+ if (res.status < 200 || res.status >= 300) {
291
+ const { error, error_description } = asErrorBody(body);
292
+ throw new ModuleOpHttpError(res.status, error, error_description);
293
+ }
294
+ const b = (body ?? {}) as { lines?: unknown; text?: unknown };
295
+ const lines = Array.isArray(b.lines)
296
+ ? b.lines.filter((l): l is string => typeof l === "string")
297
+ : [];
298
+ const text = typeof b.text === "string" ? b.text : lines.join("");
299
+ return { short, lines, text };
300
+ }
301
+
302
+ /**
303
+ * One module's run-state as read from `GET /api/modules` — the subset
304
+ * `parachute status` needs to render a module row from the RUNNING supervisor
305
+ * (design §6.4 module rows). Snake-case mirrors the wire shape (`api-modules.ts`
306
+ * `ModuleWireShape`); we keep only the supervisor-derived fields here.
307
+ */
308
+ export interface ModuleStateSnapshot {
309
+ readonly short: string;
310
+ readonly installed: boolean;
311
+ readonly installed_version: string | null;
312
+ /**
313
+ * Supervisor run-status (`running` / `stopped` / `crashed` / `starting` /
314
+ * `restarting`), or null when the module isn't tracked by the supervisor
315
+ * (e.g. never booted, skipped at boot, or no supervisor on this hub).
316
+ */
317
+ readonly supervisor_status: string | null;
318
+ readonly pid: number | null;
319
+ /**
320
+ * Structured start-error the supervisor recorded (missing-dependency /
321
+ * started-but-unbound). Passed through verbatim so `status` can render the
322
+ * SAME friendly missing-dependency note the detached path shows (#188).
323
+ */
324
+ readonly supervisor_start_error: unknown | null;
325
+ }
326
+
327
+ /** Terminal shape returned by {@link fetchModuleStates}. */
328
+ export interface ModuleStatesResult {
329
+ /** Whether the running hub has a supervisor wired in (`supervisor_available`). */
330
+ readonly supervisorAvailable: boolean;
331
+ /** Per-module supervisor snapshots, keyed by short name in array order. */
332
+ readonly modules: ModuleStateSnapshot[];
333
+ }
334
+
335
+ /**
336
+ * Read the RUNNING hub's per-module supervisor states via `GET /api/modules`
337
+ * (design §6.4 module rows). The operator token's `admin` scope-set carries
338
+ * `parachute:host:auth` (the scope `/api/modules` gates on), so the same
339
+ * read-never-mint operator-token→Bearer path {@link driveModuleOp} uses
340
+ * authenticates this read.
341
+ *
342
+ * Used by `parachute status` on a UNIT-MANAGED box to source module rows from
343
+ * the live supervisor instead of pidfiles. It is read-only and BOUNDED: the
344
+ * single `GET /api/modules` carries an `AbortSignal.timeout` (default
345
+ * {@link DEFAULT_STATES_FETCH_TIMEOUT_MS}) so a hub that accepts the TCP
346
+ * connection but never answers (wedged request handler) can't hang `status` —
347
+ * the preceding `/health` probe is bounded too, but this read needs its own
348
+ * ceiling. The CALLER is responsible for degrading gracefully (hub down → don't
349
+ * call this; no token → catch {@link NoOperatorTokenError}) so `status` never
350
+ * hangs/crashes.
351
+ *
352
+ * Throws (all caught + degraded to a "couldn't read live module state" note by
353
+ * the `status` caller — see `buildSupervisorRows`):
354
+ * - {@link NoOperatorTokenError} — no operator.token on disk.
355
+ * - {@link OperatorTokenExpiredError} — token fully expired (actionable msg).
356
+ * - {@link ModuleOpHttpError} — hub answered non-2xx, OR the bounded fetch
357
+ * timed out / aborted (surfaced as a synthetic `request_timeout` so the
358
+ * caller degrades with a message via its existing non-2xx catch, exactly as
359
+ * it does for a real HTTP error — never a hang).
360
+ */
361
+ export async function fetchModuleStates(deps: DriveModuleOpDeps): Promise<ModuleStatesResult> {
362
+ const doFetch = deps.fetch ?? fetch;
363
+ const baseUrl = (deps.baseUrl ?? DEFAULT_HUB_BASE_URL).replace(/\/+$/, "");
364
+ const timeoutMs = deps.statesFetchTimeoutMs ?? DEFAULT_STATES_FETCH_TIMEOUT_MS;
365
+ const bearer = await resolveOperatorBearer(deps);
366
+
367
+ let res: Response;
368
+ try {
369
+ res = await doFetch(`${baseUrl}/api/modules`, {
370
+ method: "GET",
371
+ // `/api/modules` parses the scheme-cased `Bearer ` prefix; match it exactly.
372
+ headers: { authorization: `Bearer ${bearer}` },
373
+ // Bound the read so a wedged hub handler degrades `status` rather than
374
+ // hanging it. AbortSignal.timeout fires `AbortError` once the ceiling
375
+ // elapses (or the fetch errors for another transport reason).
376
+ signal: AbortSignal.timeout(timeoutMs),
377
+ });
378
+ } catch (err) {
379
+ // Timeout/abort or transport failure → re-shape as a ModuleOpHttpError so the
380
+ // `status` caller degrades through the SAME non-2xx catch it uses for an HTTP
381
+ // error (a "couldn't read live module state" note + exit 0), never hangs.
382
+ const aborted =
383
+ err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
384
+ const description = aborted
385
+ ? `module-states read timed out after ${timeoutMs}ms`
386
+ : `module-states read failed (${err instanceof Error ? err.message : String(err)})`;
387
+ throw new ModuleOpHttpError(0, "request_timeout", description);
388
+ }
389
+ const body = await parseJsonSafe(res);
390
+ if (res.status < 200 || res.status >= 300) {
391
+ const { error, error_description } = asErrorBody(body);
392
+ throw new ModuleOpHttpError(res.status, error, error_description);
393
+ }
394
+ const b = (body ?? {}) as { modules?: unknown; supervisor_available?: unknown };
395
+ const supervisorAvailable = b.supervisor_available === true;
396
+ const modules: ModuleStateSnapshot[] = Array.isArray(b.modules)
397
+ ? b.modules
398
+ .filter((m): m is Record<string, unknown> => !!m && typeof m === "object")
399
+ .map((m) => ({
400
+ short: typeof m.short === "string" ? m.short : "",
401
+ installed: m.installed === true,
402
+ installed_version: typeof m.installed_version === "string" ? m.installed_version : null,
403
+ supervisor_status: typeof m.supervisor_status === "string" ? m.supervisor_status : null,
404
+ pid: typeof m.pid === "number" ? m.pid : null,
405
+ supervisor_start_error:
406
+ m.supervisor_start_error !== undefined ? (m.supervisor_start_error ?? null) : null,
407
+ }))
408
+ : [];
409
+ return { supervisorAvailable, modules };
410
+ }
411
+
412
+ async function parseJsonSafe(res: Response): Promise<unknown> {
413
+ try {
414
+ return await res.json();
415
+ } catch {
416
+ return undefined;
417
+ }
418
+ }
419
+
420
+ function asErrorBody(body: unknown): { error: string; error_description: string } {
421
+ if (body && typeof body === "object") {
422
+ const b = body as Record<string, unknown>;
423
+ const error = typeof b.error === "string" ? b.error : "error";
424
+ const error_description =
425
+ typeof b.error_description === "string" ? b.error_description : "request failed";
426
+ return { error, error_description };
427
+ }
428
+ return { error: "error", error_description: "request failed" };
429
+ }
430
+
431
+ function extractOperationId(body: unknown): string | undefined {
432
+ if (body && typeof body === "object") {
433
+ const id = (body as Record<string, unknown>).operation_id;
434
+ if (typeof id === "string" && id.length > 0) return id;
435
+ }
436
+ return undefined;
437
+ }
438
+
439
+ function extractOpStatus(body: unknown): string | undefined {
440
+ if (body && typeof body === "object") {
441
+ const s = (body as Record<string, unknown>).status;
442
+ if (typeof s === "string") return s;
443
+ }
444
+ return undefined;
445
+ }
446
+
447
+ function extractOpError(body: unknown): string | undefined {
448
+ if (body && typeof body === "object") {
449
+ const e = (body as Record<string, unknown>).error;
450
+ if (typeof e === "string" && e.length > 0) return e;
451
+ }
452
+ return undefined;
453
+ }
454
+
455
+ // Re-export so CLI callers can catch the expired-token case without a second
456
+ // import from operator-token.ts.
457
+ export { OperatorTokenExpiredError };