@openparachute/hub 0.5.7 → 0.5.10-rc.10
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.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/csrf.test.ts +40 -1
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +262 -56
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-module child supervisor for container-mode hub.
|
|
3
|
+
*
|
|
4
|
+
* The on-box flow (`parachute start <svc>`) spawns module daemons
|
|
5
|
+
* detached + unref'd, writes a pidfile, and walks away — process
|
|
6
|
+
* lifecycle becomes the operator's problem (launchd, systemd, or a
|
|
7
|
+
* follow-up `parachute restart`). That shape doesn't work in a
|
|
8
|
+
* container:
|
|
9
|
+
*
|
|
10
|
+
* - There's no external supervisor watching the children. If vault
|
|
11
|
+
* crashes, nothing brings it back.
|
|
12
|
+
* - Render's log viewer only surfaces hub's stdout. A detached child
|
|
13
|
+
* whose stdout goes to `~/.parachute/<svc>/logs/<svc>.log` is
|
|
14
|
+
* invisible to the operator clicking through the dashboard.
|
|
15
|
+
*
|
|
16
|
+
* This supervisor solves both. It spawns each module attached (no
|
|
17
|
+
* `detached: true`, no `unref()`), pipes their stdout/stderr through a
|
|
18
|
+
* line-prefixing tap into hub's own stdout (`[vault] …`,
|
|
19
|
+
* `[scribe] …`), watches `proc.exited`, and restarts crashed children
|
|
20
|
+
* up to a small budget before giving up + marking the module
|
|
21
|
+
* `crashed`. The budget keeps a wedged-on-boot module from chewing
|
|
22
|
+
* forever; once it's exhausted the operator sees the crash via /api/modules
|
|
23
|
+
* (or, post-1B, the per-module log view).
|
|
24
|
+
*
|
|
25
|
+
* Out of scope for this module: spawning the hub HTTP server itself
|
|
26
|
+
* (that's `Bun.serve` in the same process), driving the on-box
|
|
27
|
+
* `parachute start <svc>` path (still uses `commands/lifecycle.ts`),
|
|
28
|
+
* persisting child state to disk (transient — re-derived from
|
|
29
|
+
* services.json on boot).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type ModuleStatus = "starting" | "running" | "stopped" | "crashed" | "restarting";
|
|
33
|
+
|
|
34
|
+
export interface ModuleState {
|
|
35
|
+
/** Short name (vault / notes / scribe / …). */
|
|
36
|
+
readonly short: string;
|
|
37
|
+
/** Last-observed lifecycle phase. */
|
|
38
|
+
readonly status: ModuleStatus;
|
|
39
|
+
/** PID of the current Bun.spawn child, if any. */
|
|
40
|
+
readonly pid?: number;
|
|
41
|
+
/** ISO timestamp of the most recent spawn. */
|
|
42
|
+
readonly startedAt?: string;
|
|
43
|
+
/** Crash count within the current restart window. Resets after the window passes without a crash. */
|
|
44
|
+
readonly restartsInWindow: number;
|
|
45
|
+
/** ISO timestamp of the most recent crash, or undefined if never crashed. */
|
|
46
|
+
readonly lastCrashAt?: string;
|
|
47
|
+
/** Exit code of the most recent crash. */
|
|
48
|
+
readonly lastExitCode?: number | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SpawnRequest {
|
|
52
|
+
/** Short name — used as the log prefix and the supervisor map key. */
|
|
53
|
+
readonly short: string;
|
|
54
|
+
/** argv passed to `Bun.spawn`. */
|
|
55
|
+
readonly cmd: readonly string[];
|
|
56
|
+
/** Optional cwd for the child. */
|
|
57
|
+
readonly cwd?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Optional env merged on top of `process.env`. The supervisor doesn't
|
|
60
|
+
* mutate `process.env`; the merge happens at spawn time.
|
|
61
|
+
*/
|
|
62
|
+
readonly env?: Record<string, string>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SupervisorOpts {
|
|
66
|
+
/**
|
|
67
|
+
* Max crashes within `restartWindowMs` before the supervisor gives up
|
|
68
|
+
* and marks the module `crashed`. Default 3 — enough to ride out a
|
|
69
|
+
* transient race (DB lock, port still releasing), few enough to stop
|
|
70
|
+
* a wedged-on-boot module from looping forever.
|
|
71
|
+
*/
|
|
72
|
+
readonly maxRestarts?: number;
|
|
73
|
+
/**
|
|
74
|
+
* Sliding window for the restart budget, in ms. A crash older than
|
|
75
|
+
* this falls out of the count. Default 60_000 (1 minute).
|
|
76
|
+
*/
|
|
77
|
+
readonly restartWindowMs?: number;
|
|
78
|
+
/**
|
|
79
|
+
* Delay between a crash being observed and the restart spawn, in ms.
|
|
80
|
+
* Default 500 — gives sockets time to release on EADDRINUSE.
|
|
81
|
+
*/
|
|
82
|
+
readonly restartDelayMs?: number;
|
|
83
|
+
/**
|
|
84
|
+
* Max time to wait for a child to exit after SIGTERM before
|
|
85
|
+
* escalating to SIGKILL, in ms. Default 5000 — long enough for a
|
|
86
|
+
* well-behaved module to flush its log buffer + drop its listeners,
|
|
87
|
+
* short enough that a wedged child doesn't keep `stop()` (and the
|
|
88
|
+
* container shutdown path that calls it) hanging indefinitely.
|
|
89
|
+
*
|
|
90
|
+
* Tests pass a short timeout (1–10ms) to exercise the SIGKILL
|
|
91
|
+
* escalation path without real waiting.
|
|
92
|
+
*/
|
|
93
|
+
readonly killTimeoutMs?: number;
|
|
94
|
+
/**
|
|
95
|
+
* Where prefixed child output goes. Default `process.stdout.write`.
|
|
96
|
+
* Tests inject a collector so they can assert on the multiplexed
|
|
97
|
+
* stream without spelunking stdout.
|
|
98
|
+
*/
|
|
99
|
+
readonly output?: (line: string) => void;
|
|
100
|
+
/**
|
|
101
|
+
* Test seam over `Bun.spawn`. Returns a Subprocess-shaped handle.
|
|
102
|
+
*/
|
|
103
|
+
readonly spawnFn?: SpawnFn;
|
|
104
|
+
/**
|
|
105
|
+
* Test seam over wall-clock. Production passes `Date.now`.
|
|
106
|
+
*/
|
|
107
|
+
readonly now?: () => number;
|
|
108
|
+
/**
|
|
109
|
+
* Test seam over `setTimeout`. Production resolves a real Promise
|
|
110
|
+
* with `setTimeout`. Tests stub to advance time deterministically.
|
|
111
|
+
*/
|
|
112
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Subprocess-shaped seam. Production passes through to `Bun.spawn`;
|
|
117
|
+
* tests construct a fake that exposes a controllable `exited` Promise
|
|
118
|
+
* and pipe-able stdout/stderr.
|
|
119
|
+
*/
|
|
120
|
+
export type SpawnFn = (req: SpawnRequest) => SupervisedProc;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The minimal Subprocess shape the supervisor depends on. Bun's real
|
|
124
|
+
* `Subprocess` matches this; the test fake mirrors it.
|
|
125
|
+
*/
|
|
126
|
+
export interface SupervisedProc {
|
|
127
|
+
readonly pid: number;
|
|
128
|
+
readonly exited: Promise<number | null>;
|
|
129
|
+
readonly stdout: ReadableStream<Uint8Array> | null;
|
|
130
|
+
readonly stderr: ReadableStream<Uint8Array> | null;
|
|
131
|
+
kill(signal?: NodeJS.Signals | number): void;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const DEFAULT_MAX_RESTARTS = 3;
|
|
135
|
+
const DEFAULT_RESTART_WINDOW_MS = 60_000;
|
|
136
|
+
const DEFAULT_RESTART_DELAY_MS = 500;
|
|
137
|
+
const DEFAULT_KILL_TIMEOUT_MS = 5_000;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Per-module supervisor. Owns the spawn → watch → restart loop.
|
|
141
|
+
*
|
|
142
|
+
* Single-process semantics: instances of `Supervisor` aren't safe to
|
|
143
|
+
* share across processes (the underlying Subprocess handles are
|
|
144
|
+
* process-local). Hub creates one Supervisor per `parachute serve`
|
|
145
|
+
* boot and threads it into the API handlers.
|
|
146
|
+
*/
|
|
147
|
+
export class Supervisor {
|
|
148
|
+
private readonly opts: Required<Omit<SupervisorOpts, "spawnFn">> & {
|
|
149
|
+
readonly spawnFn: SpawnFn;
|
|
150
|
+
};
|
|
151
|
+
private readonly modules = new Map<string, ModuleEntry>();
|
|
152
|
+
|
|
153
|
+
constructor(opts: SupervisorOpts = {}) {
|
|
154
|
+
this.opts = {
|
|
155
|
+
maxRestarts: opts.maxRestarts ?? DEFAULT_MAX_RESTARTS,
|
|
156
|
+
restartWindowMs: opts.restartWindowMs ?? DEFAULT_RESTART_WINDOW_MS,
|
|
157
|
+
restartDelayMs: opts.restartDelayMs ?? DEFAULT_RESTART_DELAY_MS,
|
|
158
|
+
killTimeoutMs: opts.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS,
|
|
159
|
+
output: opts.output ?? ((line) => process.stdout.write(line)),
|
|
160
|
+
spawnFn: opts.spawnFn ?? defaultSpawnFn,
|
|
161
|
+
now: opts.now ?? Date.now,
|
|
162
|
+
sleep: opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Spawn a module under supervision. Idempotent: re-spawning an
|
|
168
|
+
* already-running module is a no-op (returns the existing state).
|
|
169
|
+
* Re-spawning a previously-crashed module clears the crash budget
|
|
170
|
+
* and starts fresh.
|
|
171
|
+
*/
|
|
172
|
+
async start(req: SpawnRequest): Promise<ModuleState> {
|
|
173
|
+
const existing = this.modules.get(req.short);
|
|
174
|
+
if (existing && (existing.state.status === "running" || existing.state.status === "starting")) {
|
|
175
|
+
return existing.state;
|
|
176
|
+
}
|
|
177
|
+
// Crashed → operator intent is "try again." Wipe the budget.
|
|
178
|
+
const entry: ModuleEntry = {
|
|
179
|
+
req,
|
|
180
|
+
state: {
|
|
181
|
+
short: req.short,
|
|
182
|
+
status: "starting",
|
|
183
|
+
restartsInWindow: 0,
|
|
184
|
+
},
|
|
185
|
+
crashStamps: [],
|
|
186
|
+
};
|
|
187
|
+
this.modules.set(req.short, entry);
|
|
188
|
+
this.spawnAndWatch(entry);
|
|
189
|
+
return entry.state;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Stop a supervised module. Sends SIGTERM, awaits the child's exit
|
|
194
|
+
* (so the log-pump drains the final flush before our stdout closes),
|
|
195
|
+
* and escalates to SIGKILL if the child doesn't exit within
|
|
196
|
+
* `killTimeoutMs`. Marks the state `stopped` and detaches the exit
|
|
197
|
+
* watcher so a normal termination isn't seen as a crash. Idempotent
|
|
198
|
+
* on already-stopped modules.
|
|
199
|
+
*
|
|
200
|
+
* The await matters in two places:
|
|
201
|
+
* - Container shutdown (hub PID 1 receiving SIGTERM from Render):
|
|
202
|
+
* without it, children's final log lines never make it through
|
|
203
|
+
* hub's stdout pipe before the platform reaps the pod.
|
|
204
|
+
* - `restart()`: a fresh spawn that races a still-listening prior
|
|
205
|
+
* PID will fail with EADDRINUSE.
|
|
206
|
+
*
|
|
207
|
+
* The SIGKILL escalation handles a wedged module (e.g. a broken
|
|
208
|
+
* native binding ignoring SIGTERM). Without it, `stop()` would hang
|
|
209
|
+
* forever and a re-deploy would leak the orphaned child until the
|
|
210
|
+
* container itself was recycled.
|
|
211
|
+
*/
|
|
212
|
+
async stop(short: string): Promise<ModuleState | undefined> {
|
|
213
|
+
const entry = this.modules.get(short);
|
|
214
|
+
if (!entry) return undefined;
|
|
215
|
+
entry.stopRequested = true;
|
|
216
|
+
const proc = entry.proc;
|
|
217
|
+
if (proc) {
|
|
218
|
+
try {
|
|
219
|
+
proc.kill("SIGTERM");
|
|
220
|
+
} catch {
|
|
221
|
+
// Process may already be dead — fall through.
|
|
222
|
+
}
|
|
223
|
+
// Race the child's exit against the kill timeout. If the timer
|
|
224
|
+
// wins, escalate to SIGKILL. Either way we end up awaiting the
|
|
225
|
+
// exit promise so the log pump drains.
|
|
226
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
227
|
+
const timeout = new Promise<"timeout">((resolve) => {
|
|
228
|
+
timer = setTimeout(() => resolve("timeout"), this.opts.killTimeoutMs);
|
|
229
|
+
});
|
|
230
|
+
try {
|
|
231
|
+
const winner = await Promise.race([proc.exited.then(() => "exited" as const), timeout]);
|
|
232
|
+
if (winner === "timeout") {
|
|
233
|
+
this.opts.output(
|
|
234
|
+
`[supervisor] ${entry.req.short} did not exit ${this.opts.killTimeoutMs}ms after SIGTERM — escalating to SIGKILL.\n`,
|
|
235
|
+
);
|
|
236
|
+
try {
|
|
237
|
+
proc.kill("SIGKILL");
|
|
238
|
+
} catch {
|
|
239
|
+
// Process may already be dead between the timeout firing
|
|
240
|
+
// and us reaching kill() — fall through to the await.
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
await proc.exited;
|
|
244
|
+
// SIGKILL cannot be caught; OS reaps the child promptly.
|
|
245
|
+
} catch {
|
|
246
|
+
// exited rejection is non-fatal — we're stopping anyway.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} finally {
|
|
250
|
+
clearTimeout(timer!);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
entry.state = { ...entry.state, status: "stopped" };
|
|
254
|
+
return entry.state;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Snapshot of every supervised module's current state. */
|
|
258
|
+
list(): ModuleState[] {
|
|
259
|
+
return Array.from(this.modules.values(), (e) => e.state);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Snapshot of a single module's state, or undefined if not supervised. */
|
|
263
|
+
get(short: string): ModuleState | undefined {
|
|
264
|
+
return this.modules.get(short)?.state;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Restart a supervised module: stop, wait for exit, start with the
|
|
269
|
+
* same SpawnRequest. Used by the `/api/modules/:name/restart`
|
|
270
|
+
* handler. The on-box `parachute restart <svc>` path stays on
|
|
271
|
+
* `commands/lifecycle.ts` — different surface, different ownership.
|
|
272
|
+
*/
|
|
273
|
+
async restart(short: string): Promise<ModuleState | undefined> {
|
|
274
|
+
const entry = this.modules.get(short);
|
|
275
|
+
if (!entry) return undefined;
|
|
276
|
+
const req = entry.req;
|
|
277
|
+
entry.state = { ...entry.state, status: "restarting" };
|
|
278
|
+
// stop() now awaits the prior process's exit (with SIGKILL
|
|
279
|
+
// escalation) before returning, so the fresh spawn below doesn't
|
|
280
|
+
// race on EADDRINUSE — no separate await needed here.
|
|
281
|
+
await this.stop(short);
|
|
282
|
+
// Drop the entry so `start` treats this as a clean spawn.
|
|
283
|
+
this.modules.delete(short);
|
|
284
|
+
return this.start(req);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private spawnAndWatch(entry: ModuleEntry): void {
|
|
288
|
+
const proc = this.opts.spawnFn(entry.req);
|
|
289
|
+
entry.proc = proc;
|
|
290
|
+
entry.state = {
|
|
291
|
+
...entry.state,
|
|
292
|
+
status: "running",
|
|
293
|
+
pid: proc.pid,
|
|
294
|
+
startedAt: new Date(this.opts.now()).toISOString(),
|
|
295
|
+
};
|
|
296
|
+
this.pipeOutput(entry.req.short, proc);
|
|
297
|
+
void proc.exited.then((exitCode) => this.handleExit(entry, exitCode));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private async handleExit(entry: ModuleEntry, exitCode: number | null): Promise<void> {
|
|
301
|
+
// Operator-driven stop: not a crash, don't restart.
|
|
302
|
+
if (entry.stopRequested) {
|
|
303
|
+
entry.state = {
|
|
304
|
+
...entry.state,
|
|
305
|
+
status: "stopped",
|
|
306
|
+
pid: undefined,
|
|
307
|
+
lastExitCode: exitCode,
|
|
308
|
+
};
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const now = this.opts.now();
|
|
313
|
+
// Drop crashes older than the window before counting.
|
|
314
|
+
const cutoff = now - this.opts.restartWindowMs;
|
|
315
|
+
entry.crashStamps = entry.crashStamps.filter((t) => t >= cutoff);
|
|
316
|
+
entry.crashStamps.push(now);
|
|
317
|
+
|
|
318
|
+
if (entry.crashStamps.length >= this.opts.maxRestarts) {
|
|
319
|
+
this.opts.output(
|
|
320
|
+
`[supervisor] ${entry.req.short} crashed ${entry.crashStamps.length}x within ${this.opts.restartWindowMs}ms — giving up.\n`,
|
|
321
|
+
);
|
|
322
|
+
entry.state = {
|
|
323
|
+
...entry.state,
|
|
324
|
+
status: "crashed",
|
|
325
|
+
pid: undefined,
|
|
326
|
+
lastCrashAt: new Date(now).toISOString(),
|
|
327
|
+
lastExitCode: exitCode,
|
|
328
|
+
restartsInWindow: entry.crashStamps.length,
|
|
329
|
+
};
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
entry.state = {
|
|
334
|
+
...entry.state,
|
|
335
|
+
status: "restarting",
|
|
336
|
+
pid: undefined,
|
|
337
|
+
lastCrashAt: new Date(now).toISOString(),
|
|
338
|
+
lastExitCode: exitCode,
|
|
339
|
+
restartsInWindow: entry.crashStamps.length,
|
|
340
|
+
};
|
|
341
|
+
this.opts.output(
|
|
342
|
+
`[supervisor] ${entry.req.short} exited (code=${exitCode ?? "?"}); restart ${entry.crashStamps.length}/${this.opts.maxRestarts} in window.\n`,
|
|
343
|
+
);
|
|
344
|
+
await this.opts.sleep(this.opts.restartDelayMs);
|
|
345
|
+
// Operator may have called stop() during the sleep — re-check.
|
|
346
|
+
if (entry.stopRequested) return;
|
|
347
|
+
this.spawnAndWatch(entry);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Tap a child's stdout + stderr into the supervisor's `output`
|
|
352
|
+
* callback (hub's stdout by default), prefixing each line with the
|
|
353
|
+
* module's short name. Line-buffered: partial chunks accumulate
|
|
354
|
+
* until a newline arrives so multi-byte log lines don't get
|
|
355
|
+
* scrambled across modules.
|
|
356
|
+
*/
|
|
357
|
+
private pipeOutput(short: string, proc: SupervisedProc): void {
|
|
358
|
+
const prefix = `[${short}] `;
|
|
359
|
+
if (proc.stdout) void pumpLines(proc.stdout, prefix, this.opts.output);
|
|
360
|
+
if (proc.stderr) void pumpLines(proc.stderr, prefix, this.opts.output);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
interface ModuleEntry {
|
|
365
|
+
req: SpawnRequest;
|
|
366
|
+
state: ModuleState;
|
|
367
|
+
proc?: SupervisedProc;
|
|
368
|
+
crashStamps: number[];
|
|
369
|
+
stopRequested?: boolean;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function pumpLines(
|
|
373
|
+
stream: ReadableStream<Uint8Array>,
|
|
374
|
+
prefix: string,
|
|
375
|
+
output: (line: string) => void,
|
|
376
|
+
): Promise<void> {
|
|
377
|
+
const reader = stream.getReader();
|
|
378
|
+
const decoder = new TextDecoder();
|
|
379
|
+
let buf = "";
|
|
380
|
+
try {
|
|
381
|
+
while (true) {
|
|
382
|
+
const { done, value } = await reader.read();
|
|
383
|
+
if (done) break;
|
|
384
|
+
buf += decoder.decode(value, { stream: true });
|
|
385
|
+
// Flush every complete line; keep the partial tail buffered.
|
|
386
|
+
let nl = buf.indexOf("\n");
|
|
387
|
+
while (nl !== -1) {
|
|
388
|
+
const line = buf.slice(0, nl + 1);
|
|
389
|
+
output(prefix + line);
|
|
390
|
+
buf = buf.slice(nl + 1);
|
|
391
|
+
nl = buf.indexOf("\n");
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// Flush any trailing partial line so we don't drop a module's
|
|
395
|
+
// last log message (it's likely the most important one — the
|
|
396
|
+
// exit cause).
|
|
397
|
+
if (buf.length > 0) output(`${prefix + buf}\n`);
|
|
398
|
+
} finally {
|
|
399
|
+
reader.releaseLock();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const defaultSpawnFn: SpawnFn = (req) => {
|
|
404
|
+
const spawnOpts: Parameters<typeof Bun.spawn>[1] = {
|
|
405
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
406
|
+
};
|
|
407
|
+
if (req.cwd) spawnOpts.cwd = req.cwd;
|
|
408
|
+
if (req.env) spawnOpts.env = { ...process.env, ...req.env };
|
|
409
|
+
const proc = Bun.spawn([...req.cmd], spawnOpts);
|
|
410
|
+
return proc as unknown as SupervisedProc;
|
|
411
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault-name validation, mirrored from `@openparachute/vault`'s
|
|
3
|
+
* `src/vault-name.ts`.
|
|
4
|
+
*
|
|
5
|
+
* The vault package owns the canonical validator (used by `init`, the
|
|
6
|
+
* `--vault-name` flag, and the `PARACHUTE_VAULT_NAME` env var on
|
|
7
|
+
* first-boot). Hub doesn't depend on vault at runtime, so we keep a
|
|
8
|
+
* byte-identical contract here and pin parity with a test that exercises
|
|
9
|
+
* the same rule set:
|
|
10
|
+
*
|
|
11
|
+
* * lowercase alphanumeric + hyphens or underscores
|
|
12
|
+
* * 2–32 chars
|
|
13
|
+
* * `list` is reserved
|
|
14
|
+
*
|
|
15
|
+
* If vault's validator changes (e.g. additional reserved name, length
|
|
16
|
+
* relaxation), the two must move in lockstep — hub passing the typed
|
|
17
|
+
* name through `PARACHUTE_VAULT_NAME` only works as long as vault accepts
|
|
18
|
+
* what hub validates. Cross-repo drift here would silently fall back to
|
|
19
|
+
* `default` at vault first-boot (vault's `resolveFirstBootVaultName`
|
|
20
|
+
* downgrades env-invalid values).
|
|
21
|
+
*
|
|
22
|
+
* Out of scope: collision against existing vaults on the same hub — the
|
|
23
|
+
* wizard only ever creates the first vault, so name reuse can't happen.
|
|
24
|
+
* Subsequent vaults go through the admin SPA, which talks to vault's own
|
|
25
|
+
* `/vault/list` endpoint.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const VAULT_NAME_RE = /^[a-z0-9_-]+$/;
|
|
29
|
+
const VAULT_NAME_MIN_LEN = 2;
|
|
30
|
+
const VAULT_NAME_MAX_LEN = 32;
|
|
31
|
+
|
|
32
|
+
const RESERVED_NAMES = new Set([
|
|
33
|
+
// Mirrors vault's reservation. Collides with the legacy `/vaults/list`
|
|
34
|
+
// discovery endpoint; the routes have moved under `/vault/<name>/` but
|
|
35
|
+
// vault's `cmdCreate` still rejects "list" and cross-repo consistency
|
|
36
|
+
// is cheap.
|
|
37
|
+
"list",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
export type VaultNameValidation = { ok: true; name: string } | { ok: false; error: string };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validate a vault name against vault's strict contract. Trims
|
|
44
|
+
* surrounding whitespace before checking. Returns the trimmed name on
|
|
45
|
+
* success so callers don't double-trim.
|
|
46
|
+
*/
|
|
47
|
+
export function validateVaultName(raw: string): VaultNameValidation {
|
|
48
|
+
const name = raw.trim();
|
|
49
|
+
if (!name) {
|
|
50
|
+
return { ok: false, error: "vault name cannot be empty." };
|
|
51
|
+
}
|
|
52
|
+
if (name.length < VAULT_NAME_MIN_LEN || name.length > VAULT_NAME_MAX_LEN) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
error: `vault names must be ${VAULT_NAME_MIN_LEN}–${VAULT_NAME_MAX_LEN} characters long.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (!VAULT_NAME_RE.test(name)) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: "vault names must be lowercase alphanumeric with hyphens or underscores.",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (RESERVED_NAMES.has(name)) {
|
|
65
|
+
return { ok: false, error: `"${name}" is a reserved vault name.` };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, name };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The default vault name when the operator leaves the field blank. */
|
|
71
|
+
export const DEFAULT_VAULT_NAME = "default";
|
package/src/well-known.ts
CHANGED
|
@@ -26,6 +26,14 @@ export interface WellKnownVaultEntry {
|
|
|
26
26
|
* to iterate without having to know every service's shortName ahead of time.
|
|
27
27
|
* `infoUrl` points at the service's `/.parachute/info` endpoint (relative to
|
|
28
28
|
* its mount path) which the hub fetches client-side for displayName/tagline.
|
|
29
|
+
*
|
|
30
|
+
* `displayName` and `uiUrl` are both optional — the discovery page renders
|
|
31
|
+
* a Services tile when `uiUrl` is present, falling back to the manifest
|
|
32
|
+
* short name when `displayName` is absent. Both are sourced via hub-server's
|
|
33
|
+
* `loadUiUrls`/`loadManagementUrls`-style readers from the module's
|
|
34
|
+
* `installDir/.parachute/module.json`, NOT from services.json (which gets
|
|
35
|
+
* overwritten on service boot per the "services own the write side"
|
|
36
|
+
* contract — see hub#238 commit message for the C-not-B trace).
|
|
29
37
|
*/
|
|
30
38
|
export interface WellKnownServicesEntry {
|
|
31
39
|
name: string;
|
|
@@ -33,6 +41,16 @@ export interface WellKnownServicesEntry {
|
|
|
33
41
|
path: string;
|
|
34
42
|
version: string;
|
|
35
43
|
infoUrl: string;
|
|
44
|
+
/**
|
|
45
|
+
* Human-readable label for the discovery page. Sourced from
|
|
46
|
+
* `module.json:displayName` when available; falls back to
|
|
47
|
+
* `services.json:displayName` written at install time.
|
|
48
|
+
*/
|
|
49
|
+
displayName?: string;
|
|
50
|
+
/** Where the service's primary user-facing UI lives, sourced from `module.json:uiUrl`. */
|
|
51
|
+
uiUrl?: string;
|
|
52
|
+
/** One-line subtitle for the discovery tile, sourced from `services.json:tagline`. */
|
|
53
|
+
tagline?: string;
|
|
36
54
|
}
|
|
37
55
|
|
|
38
56
|
/**
|
|
@@ -107,6 +125,19 @@ export interface BuildWellKnownOpts {
|
|
|
107
125
|
* in. Returning `undefined` means "no admin SPA" and hub renders no link.
|
|
108
126
|
*/
|
|
109
127
|
managementUrlFor?: (entry: ServiceEntry) => string | undefined;
|
|
128
|
+
/**
|
|
129
|
+
* Optional resolver mapping a `ServiceEntry` to its `module.json:uiUrl`,
|
|
130
|
+
* if any. Same shape as `managementUrlFor`. Returning `undefined` means
|
|
131
|
+
* "no user-facing UI" and discovery omits the Services tile (e.g. vault
|
|
132
|
+
* has no `uiUrl` — its content browses through Notes).
|
|
133
|
+
*/
|
|
134
|
+
uiUrlFor?: (entry: ServiceEntry) => string | undefined;
|
|
135
|
+
/**
|
|
136
|
+
* Optional resolver mapping a `ServiceEntry` to its `module.json:displayName`.
|
|
137
|
+
* Hub-server reads this at request time; falls back to the entry's own
|
|
138
|
+
* `displayName` (from services.json) when absent.
|
|
139
|
+
*/
|
|
140
|
+
displayNameFor?: (entry: ServiceEntry) => string | undefined;
|
|
110
141
|
}
|
|
111
142
|
|
|
112
143
|
/** Join a base origin and a path without double slashes — "/" stays "/". */
|
|
@@ -131,7 +162,29 @@ export function buildWellKnown(opts: BuildWellKnownOpts): WellKnownDocument {
|
|
|
131
162
|
for (const path of pathsToEmit) {
|
|
132
163
|
const url = new URL(path, `${base}/`).toString();
|
|
133
164
|
const infoUrl = new URL(joinInfoPath(path), `${base}/`).toString();
|
|
134
|
-
|
|
165
|
+
const entry: WellKnownServicesEntry = {
|
|
166
|
+
name: s.name,
|
|
167
|
+
url,
|
|
168
|
+
path,
|
|
169
|
+
version: s.version,
|
|
170
|
+
infoUrl,
|
|
171
|
+
};
|
|
172
|
+
const displayName = opts.displayNameFor?.(s) ?? s.displayName;
|
|
173
|
+
if (displayName !== undefined) entry.displayName = displayName;
|
|
174
|
+
// Tagline rides on services.json (set by service-spec at install or
|
|
175
|
+
// by the service's own boot-time upsert). Read directly from the
|
|
176
|
+
// entry — no installDir round-trip needed since it's already
|
|
177
|
+
// persisted server-side and reasonably stable across reboots.
|
|
178
|
+
if (s.tagline !== undefined) entry.tagline = s.tagline;
|
|
179
|
+
// Resolve uiUrl: relative path → absolute URL against `base`; full
|
|
180
|
+
// http(s) URL → verbatim. Same rule managementUrl uses.
|
|
181
|
+
const uiUrlRaw = opts.uiUrlFor?.(s);
|
|
182
|
+
if (uiUrlRaw !== undefined) {
|
|
183
|
+
entry.uiUrl = /^https?:\/\//i.test(uiUrlRaw)
|
|
184
|
+
? uiUrlRaw
|
|
185
|
+
: new URL(uiUrlRaw, `${base}/`).toString();
|
|
186
|
+
}
|
|
187
|
+
doc.services.push(entry);
|
|
135
188
|
if (isVault) {
|
|
136
189
|
const managementUrl = opts.managementUrlFor?.(s);
|
|
137
190
|
const entry: WellKnownVaultEntry = {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg: #faf8f4;--bg-soft: #f3f0ea;--fg: #2c2a26;--fg-muted: #6b6860;--fg-dim: #9a9690;--accent: #4a7c59;--accent-soft: rgba(74, 124, 89, .08);--accent-hover: #3d6849;--border: #e4e0d8;--border-light: #ece9e2;--card-bg: #ffffff;--error: #a3392b;--error-soft: rgba(163, 57, 43, .08);--warn: #b08023;--warn-soft: rgba(176, 128, 35, .08);--success: #3d6849;--success-soft: rgba(61, 104, 73, .08);--font-serif: Georgia, "Times New Roman", serif;--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace;font-family:var(--font-sans)}*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg);color:var(--fg)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button{font:inherit;background:var(--accent);color:#fff;border:0;border-radius:6px;padding:.55rem 1.1rem;cursor:pointer;transition:background .15s ease}button:hover{background:var(--accent-hover)}button:disabled{opacity:.5;cursor:not-allowed}button.secondary{background:#fff;color:var(--fg);border:1px solid var(--border)}button.secondary:hover{background:var(--bg-soft)}input,select,textarea{font:inherit;background:#fff;border:1px solid var(--border);border-radius:6px;padding:.55rem .75rem;color:var(--fg)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent)}code{font-family:var(--font-mono);font-size:.85em;background:var(--bg-soft);padding:.1em .3em;border-radius:3px}.page{max-width:880px;margin:0 auto;padding:1.5rem 1.5rem 6rem}.nav{display:flex;gap:1rem;align-items:center;padding-bottom:1rem;border-bottom:1px solid var(--border);margin-bottom:2rem}.nav .brand{font-weight:600;font-family:var(--font-serif);font-size:1.15rem;margin-right:auto}.nav .brand .sub{color:var(--fg-dim);font-size:.78rem;font-weight:400;margin-left:.4rem;font-family:var(--font-sans)}.nav a{color:var(--fg-muted);font-size:.95rem}.nav a:hover{text-decoration:none;color:var(--fg)}.nav .nav-divider{display:inline-block;width:1px;height:1.1em;background:var(--border);align-self:center}.nav .auth-spa{font-size:.85rem;color:var(--fg-muted)}.nav .auth-spa strong{font-weight:600;color:var(--fg)}.nav .auth-spa-signout{background:none;border:none;padding:0;color:var(--accent);font:inherit;cursor:pointer;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:2px}.nav .auth-spa-signout:hover:not(:disabled){color:var(--accent-hover)}.nav .auth-spa-signout:disabled{color:var(--fg-dim);cursor:not-allowed}h2{margin:0 0 1rem;font-size:1.4rem;font-weight:500}.muted{color:var(--fg-muted);font-size:.92rem}.dim{color:var(--fg-dim);font-size:.85rem}.error-banner{background:var(--error-soft);border:1px solid var(--error);color:var(--error);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.warn-banner{background:var(--warn-soft);border:1px solid var(--warn);color:var(--warn);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.empty{padding:3rem 1.5rem;text-align:center;color:var(--fg-muted);background:var(--bg-soft);border-radius:10px}.empty-rich{text-align:left;padding:2rem 1.75rem;background:#fff;border:1px solid var(--border)}.empty-rich .empty-headline{font-size:1.05rem;color:var(--fg);margin:0 0 .5rem;font-weight:500}.list-header{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;margin-bottom:1rem}.list-header h2{margin:0}.tag{display:inline-block;padding:.1em .55em;background:var(--accent-soft);color:var(--accent);border-radius:4px;font-size:.78rem;font-weight:500}.tag.muted{background:var(--bg-soft);color:var(--fg-muted)}.tag.source-oauth{background:#4a7cc61f;color:#3b6aa6}.tag.source-operator{background:#c6984a24;color:#8a5e1f}.tag.source-cli{background:#4a7c5924;color:#2f5a3f}.tag.source-unknown{background:var(--bg-soft);color:var(--fg-muted)}@media(prefers-color-scheme:dark){.tag.source-oauth{background:#7a9cdc24;color:#9bb6d8}.tag.source-operator{background:#dcb46e24;color:#d4b27a}.tag.source-cli{background:#7ab08a24;color:#8fc49e}.tag.source-unknown{background:#e8e4dc0f;color:#a8a49a}}.vault-row{display:flex;align-items:center;gap:1rem;padding:.85rem 1rem;background:#fff;border:1px solid var(--border);border-radius:8px;margin-bottom:.5rem;text-decoration:none;color:inherit;transition:border-color .15s ease}.vault-row:hover{border-color:var(--accent);text-decoration:none}.vault-row .body{flex:1;min-width:0}.vault-row .name{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.vault-row .name code{font-size:.95em}.vault-row .url{margin-top:.25rem;word-break:break-all}.vault-row .chev{color:var(--fg-dim);font-size:1.2rem}form .row{margin-bottom:1rem}form label{display:block;font-size:.9rem;color:var(--fg-muted);margin-bottom:.3rem;font-weight:500}form input[type=text]{width:100%}form .actions{display:flex;gap:.6rem;align-items:center;margin-top:1rem}form .field-hint{margin-top:.35rem;font-size:.82rem;color:var(--fg-dim)}form .field-error{margin-top:.35rem;font-size:.85rem;color:var(--error)}.section{background:#fff;border:1px solid var(--border);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:1.5rem}.mint-banner{background:var(--success-soft);border:1px solid var(--success);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:1.5rem}.mint-banner h3{margin:0 0 .5rem;font-size:1rem;color:var(--success)}.mint-banner .token-box{display:flex;align-items:center;gap:.5rem;margin:.85rem 0 .5rem}.mint-banner code{flex:1;font-size:.9rem;padding:.6rem .75rem;background:#fff;border:1px solid var(--border);word-break:break-all;-webkit-user-select:all;user-select:all}.mint-banner .warn{margin:.75rem 0 0;font-size:.85rem;color:var(--warn)}.mint-banner .actions{margin-top:1rem;display:flex;gap:.5rem}.kv{display:grid;grid-template-columns:8.5rem 1fr;gap:.5rem 1rem;font-size:.92rem}.kv>div:nth-child(odd){color:var(--fg-muted)}.kv code{word-break:break-all}.channel-toggle{margin:1.25rem 0 1.5rem;padding:.75rem 1rem;border:1px solid var(--border, #ddd);border-radius:6px;background:var(--bg-soft, #fafafa)}.channel-toggle legend{padding:0 .25rem;font-weight:600;font-size:.95rem}.channel-toggle label{display:inline-flex;align-items:center;gap:.4rem;margin-right:1.5rem;cursor:pointer;font-size:.95rem}.channel-toggle label input[type=radio]:disabled+*{opacity:.5}.channel-toggle code{font-size:.85em}.channel-toggle p.muted{margin:.4rem 0 0;font-size:.85rem}
|