@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,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/modules/:short/*` POST endpoints + `/api/modules/operations/:id`
|
|
3
|
+
* — module lifecycle operations driven from the admin SPA.
|
|
4
|
+
*
|
|
5
|
+
* Two operation classes:
|
|
6
|
+
*
|
|
7
|
+
* - **Synchronous** (restart, uninstall): handler runs the work
|
|
8
|
+
* inline and returns the new state in the response body. Fast
|
|
9
|
+
* enough that the UI just shows a spinner for the request
|
|
10
|
+
* round-trip — no operation_id needed.
|
|
11
|
+
*
|
|
12
|
+
* - **Asynchronous** (install, upgrade): handler kicks off work via
|
|
13
|
+
* `Bun.spawn` for `bun add` and returns 202 + `{operation_id}`
|
|
14
|
+
* immediately. The UI polls `GET /api/modules/operations/:id`
|
|
15
|
+
* every ~1s until the operation reaches a terminal state. This
|
|
16
|
+
* decouples the npm download (which can take 10-60s on a slow
|
|
17
|
+
* link) from the request timeout.
|
|
18
|
+
*
|
|
19
|
+
* Operation state lives in an in-memory registry — a singleton Map
|
|
20
|
+
* keyed by uuid. State is transient by design: a hub restart drops
|
|
21
|
+
* pending ops, which is the correct behavior because the underlying
|
|
22
|
+
* `bun add` is no longer running and the supervisor's own state is
|
|
23
|
+
* the source of truth post-restart. The UI re-polls /api/modules to
|
|
24
|
+
* re-derive what's actually installed.
|
|
25
|
+
*
|
|
26
|
+
* Bearer-gated on `parachute:host:admin` (destructive ops). Diverges
|
|
27
|
+
* from the read-only `/api/modules` GET which sits on the broader
|
|
28
|
+
* `:host:auth` scope: reading the catalog is part of the auth
|
|
29
|
+
* surface, mutating it is admin-only. A `:auth`-only automation token
|
|
30
|
+
* gets 403 here; the SPA's host-admin mint
|
|
31
|
+
* (`/admin/host-admin-token`) carries both scopes so the UI path is
|
|
32
|
+
* unaffected.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { Database } from "bun:sqlite";
|
|
36
|
+
import { randomUUID } from "node:crypto";
|
|
37
|
+
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
38
|
+
import { getModuleInstallChannel } from "./hub-settings.ts";
|
|
39
|
+
import { validateAccessToken } from "./jwt-sign.ts";
|
|
40
|
+
import { FIRST_PARTY_FALLBACKS, type ServiceSpec, composeServiceSpec } from "./service-spec.ts";
|
|
41
|
+
import { findService, readManifest, removeService } from "./services-manifest.ts";
|
|
42
|
+
import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Scope required for every POST + operation-poll endpoint here.
|
|
46
|
+
*
|
|
47
|
+
* `:host:admin` (not `:host:auth`) because install / upgrade /
|
|
48
|
+
* uninstall change the running set of system components — destructive
|
|
49
|
+
* by definition. The SPA mints both scopes through
|
|
50
|
+
* `/admin/host-admin-token` so its bearer carries this; an automation
|
|
51
|
+
* caller minted with `--scope-set auth` gets 403 from these endpoints,
|
|
52
|
+
* which is the intended security boundary.
|
|
53
|
+
*/
|
|
54
|
+
export const API_MODULES_OPS_REQUIRED_SCOPE = "parachute:host:admin";
|
|
55
|
+
|
|
56
|
+
export type OperationKind = "install" | "upgrade" | "restart" | "uninstall";
|
|
57
|
+
export type OperationStatus = "pending" | "running" | "succeeded" | "failed";
|
|
58
|
+
|
|
59
|
+
export interface Operation {
|
|
60
|
+
id: string;
|
|
61
|
+
kind: OperationKind;
|
|
62
|
+
short: string;
|
|
63
|
+
status: OperationStatus;
|
|
64
|
+
/** Sparse log of progress events surfaced to the UI ("running bun add…", etc). */
|
|
65
|
+
log: string[];
|
|
66
|
+
/** Error message when status is `failed`. Mirrored from the underlying throw. */
|
|
67
|
+
error?: string;
|
|
68
|
+
startedAt: string;
|
|
69
|
+
finishedAt?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface OperationsRegistry {
|
|
73
|
+
create(kind: OperationKind, short: string): Operation;
|
|
74
|
+
get(id: string): Operation | undefined;
|
|
75
|
+
/** Append a log line + (optionally) advance status. */
|
|
76
|
+
update(id: string, patch: Partial<Pick<Operation, "status" | "error">>, logLine?: string): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Process-local operations registry. One Map for the lifetime of
|
|
81
|
+
* `parachute serve`. Tests opt into a fresh registry per case via
|
|
82
|
+
* `_resetOperationsRegistryForTests`.
|
|
83
|
+
*/
|
|
84
|
+
class InMemoryOperationsRegistry implements OperationsRegistry {
|
|
85
|
+
private readonly ops = new Map<string, Operation>();
|
|
86
|
+
private readonly clock: () => Date;
|
|
87
|
+
|
|
88
|
+
constructor(clock: () => Date = () => new Date()) {
|
|
89
|
+
this.clock = clock;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
create(kind: OperationKind, short: string): Operation {
|
|
93
|
+
const op: Operation = {
|
|
94
|
+
id: randomUUID(),
|
|
95
|
+
kind,
|
|
96
|
+
short,
|
|
97
|
+
status: "pending",
|
|
98
|
+
log: [],
|
|
99
|
+
startedAt: this.clock().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
this.ops.set(op.id, op);
|
|
102
|
+
return op;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get(id: string): Operation | undefined {
|
|
106
|
+
return this.ops.get(id);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
update(id: string, patch: Partial<Pick<Operation, "status" | "error">>, logLine?: string): void {
|
|
110
|
+
const op = this.ops.get(id);
|
|
111
|
+
if (!op) return;
|
|
112
|
+
if (patch.status) op.status = patch.status;
|
|
113
|
+
if (patch.error !== undefined) op.error = patch.error;
|
|
114
|
+
if (logLine) op.log.push(logLine);
|
|
115
|
+
if (patch.status === "succeeded" || patch.status === "failed") {
|
|
116
|
+
op.finishedAt = this.clock().toISOString();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const defaultRegistry = new InMemoryOperationsRegistry();
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Access the process-singleton operations registry. Non-API callers
|
|
125
|
+
* (the first-boot wizard, hub#259) hand this to `runInstall` so the
|
|
126
|
+
* resulting op is poll-able through the same
|
|
127
|
+
* `/api/modules/operations/:id` surface the SPA uses — a stale tab
|
|
128
|
+
* watching the wizard's poll-cookie URL can still hand off mid-flight
|
|
129
|
+
* to the admin UI's module-management page after setup completes.
|
|
130
|
+
*/
|
|
131
|
+
export function getDefaultOperationsRegistry(): OperationsRegistry {
|
|
132
|
+
return defaultRegistry;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Reset the singleton operations registry — tests call between cases. */
|
|
136
|
+
export function _resetOperationsRegistryForTests(): void {
|
|
137
|
+
// The Map underneath is private; re-create the singleton by replacing
|
|
138
|
+
// every entry. Cheaper than re-exporting a mutable reference.
|
|
139
|
+
const r = defaultRegistry as unknown as { ops: Map<string, Operation> };
|
|
140
|
+
r.ops.clear();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface RunOpts {
|
|
144
|
+
/** stdio-inheriting Bun.spawn wrapper for `bun add` / `bun remove`. */
|
|
145
|
+
run?: (cmd: readonly string[]) => Promise<number>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface ApiModulesOpsDeps {
|
|
149
|
+
db: Database;
|
|
150
|
+
issuer: string;
|
|
151
|
+
manifestPath: string;
|
|
152
|
+
configDir: string;
|
|
153
|
+
supervisor: Supervisor;
|
|
154
|
+
/**
|
|
155
|
+
* Override the operations registry (test seam). Production uses the
|
|
156
|
+
* process-singleton; tests inject one with a deterministic clock so
|
|
157
|
+
* `startedAt`/`finishedAt` are stable.
|
|
158
|
+
*/
|
|
159
|
+
registry?: OperationsRegistry;
|
|
160
|
+
/**
|
|
161
|
+
* Override the shell runner (test seam). Production spawns `bun add`
|
|
162
|
+
* / `bun remove` for real; tests stub to a fast in-memory function
|
|
163
|
+
* that returns a chosen exit code without touching the filesystem.
|
|
164
|
+
*/
|
|
165
|
+
run?: (cmd: readonly string[]) => Promise<number>;
|
|
166
|
+
/** Override the cwd for the install dir lookup (BUN_INSTALL-aware). */
|
|
167
|
+
bunInstallDir?: string;
|
|
168
|
+
/**
|
|
169
|
+
* Override `findGlobalInstall`. Production probes bun's globals
|
|
170
|
+
* (BUN_INSTALL-aware via `${BUN_INSTALL}/install/global/...`); tests
|
|
171
|
+
* inject a fake. Returns the path to the installed package.json or
|
|
172
|
+
* null when not found.
|
|
173
|
+
*/
|
|
174
|
+
findGlobalInstall?: (pkg: string) => string | null;
|
|
175
|
+
/**
|
|
176
|
+
* Extra env vars merged onto the supervised child at spawn time (hub#267).
|
|
177
|
+
*
|
|
178
|
+
* The first-boot wizard uses this to pass `PARACHUTE_VAULT_NAME=<typed>`
|
|
179
|
+
* through to vault's first-boot path so the operator-typed name flows
|
|
180
|
+
* end-to-end (vault's `server.ts` reads the env var on its first-boot
|
|
181
|
+
* branch and creates the vault under that name instead of the hard-coded
|
|
182
|
+
* `default`). Generic enough that future env-driven config (e.g.
|
|
183
|
+
* `SCRIBE_MODEL`) can ride the same seam without growing a new field.
|
|
184
|
+
*
|
|
185
|
+
* Threaded to the supervisor's `SpawnRequest.env` — the merge happens
|
|
186
|
+
* inside `Bun.spawn` at child spawn time; we don't mutate `process.env`.
|
|
187
|
+
*/
|
|
188
|
+
spawnEnv?: Record<string, string>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface PathMatch {
|
|
192
|
+
short: CuratedModuleShort;
|
|
193
|
+
rest: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Parse `/api/modules/<short>/<rest>` into the canonical short name +
|
|
198
|
+
* the action suffix. Rejects unknown shorts to keep arbitrary
|
|
199
|
+
* services.json names from driving the install pathway (curated-only
|
|
200
|
+
* for v0.6).
|
|
201
|
+
*/
|
|
202
|
+
export function parseModulesPath(pathname: string): PathMatch | undefined {
|
|
203
|
+
const prefix = "/api/modules/";
|
|
204
|
+
if (!pathname.startsWith(prefix)) return undefined;
|
|
205
|
+
const tail = pathname.slice(prefix.length);
|
|
206
|
+
const slash = tail.indexOf("/");
|
|
207
|
+
if (slash <= 0) return undefined;
|
|
208
|
+
const short = tail.slice(0, slash);
|
|
209
|
+
const rest = tail.slice(slash + 1);
|
|
210
|
+
if (!CURATED_MODULES.includes(short as CuratedModuleShort)) return undefined;
|
|
211
|
+
return { short: short as CuratedModuleShort, rest };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function authorize(req: Request, deps: ApiModulesOpsDeps): Promise<Response | undefined> {
|
|
215
|
+
const auth = req.headers.get("authorization");
|
|
216
|
+
if (!auth || !auth.startsWith("Bearer ")) {
|
|
217
|
+
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
218
|
+
}
|
|
219
|
+
const bearer = auth.slice("Bearer ".length).trim();
|
|
220
|
+
if (!bearer) return jsonError(401, "unauthenticated", "empty bearer token");
|
|
221
|
+
try {
|
|
222
|
+
const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
|
|
223
|
+
if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
|
|
224
|
+
return jsonError(401, "unauthenticated", "bearer token has no sub claim");
|
|
225
|
+
}
|
|
226
|
+
const scopes =
|
|
227
|
+
typeof validated.payload.scope === "string"
|
|
228
|
+
? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
|
|
229
|
+
: [];
|
|
230
|
+
if (!scopes.includes(API_MODULES_OPS_REQUIRED_SCOPE)) {
|
|
231
|
+
return jsonError(
|
|
232
|
+
403,
|
|
233
|
+
"insufficient_scope",
|
|
234
|
+
`bearer token lacks ${API_MODULES_OPS_REQUIRED_SCOPE}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
} catch (err) {
|
|
238
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
239
|
+
return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
|
|
240
|
+
}
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Resolve the canonical `ServiceSpec` for a curated module short — the
|
|
246
|
+
* pair of (package, manifest) the supervisor + install runner act on.
|
|
247
|
+
* Exported so non-API callers (the first-boot wizard, hub#259) can
|
|
248
|
+
* reach the same spec the API handlers use without duplicating the
|
|
249
|
+
* FIRST_PARTY_FALLBACKS lookup.
|
|
250
|
+
*/
|
|
251
|
+
export function specFor(short: CuratedModuleShort): ServiceSpec {
|
|
252
|
+
const fb = FIRST_PARTY_FALLBACKS[short];
|
|
253
|
+
// Curated set is a const; every entry has a fallback. The non-null
|
|
254
|
+
// assertion is safe because CURATED_MODULES is a tuple-literal
|
|
255
|
+
// intersected with the FIRST_PARTY_FALLBACKS key set.
|
|
256
|
+
if (!fb) throw new Error(`internal: no fallback for curated ${short}`);
|
|
257
|
+
return composeServiceSpec({
|
|
258
|
+
packageName: fb.package,
|
|
259
|
+
manifest: fb.manifest,
|
|
260
|
+
extras: fb.extras,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function defaultRun(cmd: readonly string[]): Promise<number> {
|
|
265
|
+
const proc = Bun.spawn([...cmd], { stdio: ["ignore", "inherit", "inherit"] });
|
|
266
|
+
return proc.exited;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Spawn the supervised child for `short`, using the spec's startCmd
|
|
271
|
+
* and the current services.json entry (so notes' port-derived
|
|
272
|
+
* startCmd resolves correctly).
|
|
273
|
+
*/
|
|
274
|
+
async function spawnSupervised(
|
|
275
|
+
short: CuratedModuleShort,
|
|
276
|
+
spec: ServiceSpec,
|
|
277
|
+
deps: ApiModulesOpsDeps,
|
|
278
|
+
): Promise<ModuleState | undefined> {
|
|
279
|
+
const manifest = readManifest(deps.manifestPath);
|
|
280
|
+
const entry = manifest.services.find((s) => s.name === spec.manifestName);
|
|
281
|
+
if (!entry) return undefined;
|
|
282
|
+
const cmd = spec.startCmd?.(entry);
|
|
283
|
+
if (!cmd || cmd.length === 0) return undefined;
|
|
284
|
+
const req: SpawnRequest = {
|
|
285
|
+
short,
|
|
286
|
+
cmd,
|
|
287
|
+
...(entry.installDir ? { cwd: entry.installDir } : {}),
|
|
288
|
+
...(deps.spawnEnv && Object.keys(deps.spawnEnv).length > 0 ? { env: deps.spawnEnv } : {}),
|
|
289
|
+
};
|
|
290
|
+
return deps.supervisor.start(req);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* POST /api/modules/:short/install — async.
|
|
295
|
+
*
|
|
296
|
+
* Schedules a `bun add @openparachute/<svc>@latest` followed by
|
|
297
|
+
* services.json seed + supervisor spawn. Returns 202 + operation_id
|
|
298
|
+
* immediately; the UI polls /api/modules/operations/:id.
|
|
299
|
+
*
|
|
300
|
+
* Idempotent: if the module is already installed AND its supervisor
|
|
301
|
+
* state is running, the operation completes immediately with status
|
|
302
|
+
* `succeeded` and a "already running" log line. The UI doesn't have
|
|
303
|
+
* to special-case "this was a no-op."
|
|
304
|
+
*/
|
|
305
|
+
export async function handleInstall(
|
|
306
|
+
req: Request,
|
|
307
|
+
short: CuratedModuleShort,
|
|
308
|
+
deps: ApiModulesOpsDeps,
|
|
309
|
+
): Promise<Response> {
|
|
310
|
+
if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
|
|
311
|
+
const authFail = await authorize(req, deps);
|
|
312
|
+
if (authFail) return authFail;
|
|
313
|
+
|
|
314
|
+
const registry = deps.registry ?? defaultRegistry;
|
|
315
|
+
const op = registry.create("install", short);
|
|
316
|
+
|
|
317
|
+
// Idempotent short-circuit: already installed + running → mark
|
|
318
|
+
// succeeded synchronously so the UI's "operation finished"
|
|
319
|
+
// pathway works the same as a fresh install.
|
|
320
|
+
const spec = specFor(short);
|
|
321
|
+
const existing = findService(spec.manifestName, deps.manifestPath);
|
|
322
|
+
const state = deps.supervisor.get(short);
|
|
323
|
+
if (existing && state?.status === "running") {
|
|
324
|
+
registry.update(op.id, { status: "succeeded" }, `${short} already installed + running`);
|
|
325
|
+
return acceptedOp(op.id);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Kick off the async work. We DON'T await — the response goes back
|
|
329
|
+
// immediately + the work runs in the background. Errors get logged
|
|
330
|
+
// to the operation; nothing throws back to the request handler.
|
|
331
|
+
void runInstall(op.id, short, spec, deps).catch((err) => {
|
|
332
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
333
|
+
registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
return acceptedOp(op.id);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Internal install runner. Exported so non-API callers (the first-boot
|
|
341
|
+
* wizard at `/admin/setup`, hub#259) can drive the same install →
|
|
342
|
+
* services.json-seed → supervisor-spawn sequence without re-fabricating
|
|
343
|
+
* an HTTP request + bearer token just to hit `handleInstall`.
|
|
344
|
+
*
|
|
345
|
+
* The op-id + registry threading is identical to the API path; the
|
|
346
|
+
* wizard creates its own op, awaits this function, and surfaces the
|
|
347
|
+
* resulting state to the operator.
|
|
348
|
+
*/
|
|
349
|
+
export async function runInstall(
|
|
350
|
+
opId: string,
|
|
351
|
+
short: CuratedModuleShort,
|
|
352
|
+
spec: ServiceSpec,
|
|
353
|
+
deps: ApiModulesOpsDeps,
|
|
354
|
+
): Promise<void> {
|
|
355
|
+
const registry = deps.registry ?? defaultRegistry;
|
|
356
|
+
const run = deps.run ?? defaultRun;
|
|
357
|
+
// hub#275: operator-settable channel (`latest` | `rc`). Read on every
|
|
358
|
+
// op so a toggle change applies to the next install without a hub
|
|
359
|
+
// restart. The hub-settings layer seeds from PARACHUTE_MODULE_CHANNEL
|
|
360
|
+
// on first read; after that the row is source of truth.
|
|
361
|
+
const channel = getModuleInstallChannel(deps.db);
|
|
362
|
+
const spec_str = `${spec.package}@${channel}`;
|
|
363
|
+
registry.update(opId, { status: "running" }, `running bun add -g ${spec_str}`);
|
|
364
|
+
const code = await run(["bun", "add", "-g", spec_str]);
|
|
365
|
+
if (code !== 0) {
|
|
366
|
+
// Bun 1.2.x lockfile-recovery noise: probe the global prefix
|
|
367
|
+
// before treating non-zero as fatal. Mirrors the same defense in
|
|
368
|
+
// commands/install.ts.
|
|
369
|
+
const findGlobalInstall = deps.findGlobalInstall;
|
|
370
|
+
const probed = findGlobalInstall?.(spec.package) ?? null;
|
|
371
|
+
if (!probed) {
|
|
372
|
+
registry.update(
|
|
373
|
+
opId,
|
|
374
|
+
{ status: "failed", error: `bun add -g exited ${code}` },
|
|
375
|
+
`bun add -g ${spec_str} failed (exit ${code})`,
|
|
376
|
+
);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
registry.update(opId, {}, `bun add reported exit ${code} but package landed at ${probed}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Seed services.json if absent (the install flow does this for the
|
|
383
|
+
// CLI; we replicate the seed-only piece here so the supervisor's
|
|
384
|
+
// boot path can spawn next time).
|
|
385
|
+
if (spec.seedEntry) {
|
|
386
|
+
const existing = findService(spec.manifestName, deps.manifestPath);
|
|
387
|
+
if (!existing) {
|
|
388
|
+
const entry = spec.seedEntry();
|
|
389
|
+
const { upsertService } = await import("./services-manifest.ts");
|
|
390
|
+
upsertService(entry, deps.manifestPath);
|
|
391
|
+
registry.update(opId, {}, `seeded services.json entry for ${short}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Spawn the child via the supervisor. Boot-spawn semantics apply.
|
|
396
|
+
const state = await spawnSupervised(short, spec, deps);
|
|
397
|
+
if (!state) {
|
|
398
|
+
registry.update(
|
|
399
|
+
opId,
|
|
400
|
+
{ status: "failed", error: "module installed but spawn failed (no startCmd resolved)" },
|
|
401
|
+
`${short}: install succeeded but no startCmd resolvable from services.json`,
|
|
402
|
+
);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
registry.update(opId, { status: "succeeded" }, `${short} installed + spawned (pid ${state.pid})`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* POST /api/modules/:short/restart — synchronous.
|
|
410
|
+
*
|
|
411
|
+
* Routes through `supervisor.restart(short)` which does stop → await
|
|
412
|
+
* exit → start with the same SpawnRequest. Returns the new state in
|
|
413
|
+
* the body — the UI's spinner can clear as soon as the response
|
|
414
|
+
* arrives, no operation poll needed.
|
|
415
|
+
*/
|
|
416
|
+
export async function handleRestart(
|
|
417
|
+
req: Request,
|
|
418
|
+
short: CuratedModuleShort,
|
|
419
|
+
deps: ApiModulesOpsDeps,
|
|
420
|
+
): Promise<Response> {
|
|
421
|
+
if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
|
|
422
|
+
const authFail = await authorize(req, deps);
|
|
423
|
+
if (authFail) return authFail;
|
|
424
|
+
|
|
425
|
+
const state = await deps.supervisor.restart(short);
|
|
426
|
+
if (!state) {
|
|
427
|
+
return jsonError(
|
|
428
|
+
404,
|
|
429
|
+
"not_supervised",
|
|
430
|
+
`${short} is not currently supervised — install it first`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
return jsonOk({ short, state });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* POST /api/modules/:short/upgrade — async.
|
|
438
|
+
*
|
|
439
|
+
* Runs `bun add -g @openparachute/<svc>@latest` then restarts the
|
|
440
|
+
* supervised child. Same operation-poll pattern as install.
|
|
441
|
+
*/
|
|
442
|
+
export async function handleUpgrade(
|
|
443
|
+
req: Request,
|
|
444
|
+
short: CuratedModuleShort,
|
|
445
|
+
deps: ApiModulesOpsDeps,
|
|
446
|
+
): Promise<Response> {
|
|
447
|
+
if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
|
|
448
|
+
const authFail = await authorize(req, deps);
|
|
449
|
+
if (authFail) return authFail;
|
|
450
|
+
|
|
451
|
+
const registry = deps.registry ?? defaultRegistry;
|
|
452
|
+
const op = registry.create("upgrade", short);
|
|
453
|
+
const spec = specFor(short);
|
|
454
|
+
|
|
455
|
+
void runUpgrade(op.id, short, spec, deps).catch((err) => {
|
|
456
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
457
|
+
registry.update(op.id, { status: "failed", error: msg }, `upgrade failed: ${msg}`);
|
|
458
|
+
});
|
|
459
|
+
return acceptedOp(op.id);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function runUpgrade(
|
|
463
|
+
opId: string,
|
|
464
|
+
short: CuratedModuleShort,
|
|
465
|
+
spec: ServiceSpec,
|
|
466
|
+
deps: ApiModulesOpsDeps,
|
|
467
|
+
): Promise<void> {
|
|
468
|
+
const registry = deps.registry ?? defaultRegistry;
|
|
469
|
+
const run = deps.run ?? defaultRun;
|
|
470
|
+
const channel = getModuleInstallChannel(deps.db);
|
|
471
|
+
const spec_str = `${spec.package}@${channel}`;
|
|
472
|
+
registry.update(opId, { status: "running" }, `running bun add -g ${spec_str}`);
|
|
473
|
+
const code = await run(["bun", "add", "-g", spec_str]);
|
|
474
|
+
if (code !== 0) {
|
|
475
|
+
const findGlobalInstall = deps.findGlobalInstall;
|
|
476
|
+
const probed = findGlobalInstall?.(spec.package) ?? null;
|
|
477
|
+
if (!probed) {
|
|
478
|
+
registry.update(
|
|
479
|
+
opId,
|
|
480
|
+
{ status: "failed", error: `bun add -g exited ${code}` },
|
|
481
|
+
`bun add -g ${spec_str} failed (exit ${code})`,
|
|
482
|
+
);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
registry.update(opId, {}, `bun add reported exit ${code} but package landed at ${probed}`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const state = await deps.supervisor.restart(short);
|
|
489
|
+
if (!state) {
|
|
490
|
+
registry.update(
|
|
491
|
+
opId,
|
|
492
|
+
{ status: "failed", error: "upgrade installed but supervisor restart found no module" },
|
|
493
|
+
`${short}: upgraded but supervisor had no live entry — try install first`,
|
|
494
|
+
);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
registry.update(
|
|
498
|
+
opId,
|
|
499
|
+
{ status: "succeeded" },
|
|
500
|
+
`${short} upgraded + restarted (pid ${state.pid})`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* POST /api/modules/:short/uninstall — synchronous.
|
|
506
|
+
*
|
|
507
|
+
* Stops the supervised child, removes the services.json row, runs
|
|
508
|
+
* `bun remove -g <pkg>`. Returns the final state for UI confirmation.
|
|
509
|
+
* Idempotent: missing supervisor entry / missing services.json row /
|
|
510
|
+
* missing global install are all handled gracefully (the operation
|
|
511
|
+
* succeeds with a per-step "already gone" log).
|
|
512
|
+
*/
|
|
513
|
+
export async function handleUninstall(
|
|
514
|
+
req: Request,
|
|
515
|
+
short: CuratedModuleShort,
|
|
516
|
+
deps: ApiModulesOpsDeps,
|
|
517
|
+
): Promise<Response> {
|
|
518
|
+
if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
|
|
519
|
+
const authFail = await authorize(req, deps);
|
|
520
|
+
if (authFail) return authFail;
|
|
521
|
+
|
|
522
|
+
const spec = specFor(short);
|
|
523
|
+
const log: string[] = [];
|
|
524
|
+
|
|
525
|
+
// 1. Stop the supervised child (idempotent — null on missing).
|
|
526
|
+
const stopped = await deps.supervisor.stop(short);
|
|
527
|
+
log.push(stopped ? `${short} supervisor stopped` : `${short} not supervised`);
|
|
528
|
+
|
|
529
|
+
// 2. Drop the services.json row (idempotent — readManifest is empty if missing).
|
|
530
|
+
const before = readManifest(deps.manifestPath);
|
|
531
|
+
if (before.services.some((s) => s.name === spec.manifestName)) {
|
|
532
|
+
removeService(spec.manifestName, deps.manifestPath);
|
|
533
|
+
log.push(`removed ${spec.manifestName} from services.json`);
|
|
534
|
+
} else {
|
|
535
|
+
log.push(`${spec.manifestName} not in services.json`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 3. bun remove -g (idempotent on missing — bun returns 0).
|
|
539
|
+
const run = deps.run ?? defaultRun;
|
|
540
|
+
const code = await run(["bun", "remove", "-g", spec.package]);
|
|
541
|
+
log.push(`bun remove -g ${spec.package} exited ${code}`);
|
|
542
|
+
|
|
543
|
+
return jsonOk({ short, log });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* GET /api/modules/operations/:id — poll operation status.
|
|
548
|
+
*/
|
|
549
|
+
export async function handleOperationGet(
|
|
550
|
+
req: Request,
|
|
551
|
+
opId: string,
|
|
552
|
+
deps: ApiModulesOpsDeps,
|
|
553
|
+
): Promise<Response> {
|
|
554
|
+
if (req.method !== "GET") return jsonError(405, "method_not_allowed", "use GET");
|
|
555
|
+
const authFail = await authorize(req, deps);
|
|
556
|
+
if (authFail) return authFail;
|
|
557
|
+
|
|
558
|
+
const registry = deps.registry ?? defaultRegistry;
|
|
559
|
+
const op = registry.get(opId);
|
|
560
|
+
if (!op) {
|
|
561
|
+
return jsonError(404, "not_found", `no operation with id ${opId}`);
|
|
562
|
+
}
|
|
563
|
+
return jsonOk(op);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function jsonError(status: number, code: string, description: string): Response {
|
|
567
|
+
return new Response(JSON.stringify({ error: code, error_description: description }), {
|
|
568
|
+
status,
|
|
569
|
+
headers: { "content-type": "application/json" },
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function jsonOk(body: unknown): Response {
|
|
574
|
+
return new Response(JSON.stringify(body), {
|
|
575
|
+
status: 200,
|
|
576
|
+
headers: { "content-type": "application/json" },
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function acceptedOp(opId: string): Response {
|
|
581
|
+
return new Response(JSON.stringify({ operation_id: opId }), {
|
|
582
|
+
status: 202,
|
|
583
|
+
headers: { "content-type": "application/json" },
|
|
584
|
+
});
|
|
585
|
+
}
|