auriga-cli 1.15.2 → 1.17.0

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/dist/server.js ADDED
@@ -0,0 +1,759 @@
1
+ // HTTP server for the Web UI (`auriga-cli web-ui`).
2
+ //
3
+ // Responsibilities: token + Origin auth, /api/state scanner endpoint,
4
+ // /api/apply (202 + SSE-progress), /api/progress (SSE with Last-Event-ID
5
+ // resume + 200-event ring buffer), /api/shutdown (graceful drain), and
6
+ // static asset serve from the extracted UI bundle dir.
7
+ //
8
+ // Public contract is anchored in docs/architecture/web-ui.md §4 (server
9
+ // surface), §6 (data flow + types), §7 (errors).
10
+ import { createServer } from "node:http";
11
+ import { Buffer } from "node:buffer";
12
+ import { randomBytes, timingSafeEqual } from "node:crypto";
13
+ import { readFile } from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { buildScanCatalog } from "./scan-catalog.js";
16
+ import { scanState } from "./state.js";
17
+ // Body parsing cap. /api/apply payloads are tiny (an array of item refs);
18
+ // 1 MiB is generously above the largest realistic batch and small enough that
19
+ // abusive clients can't pin memory.
20
+ const MAX_JSON_BODY = 1 * 1024 * 1024;
21
+ // SSE replay cache: keep at least 200 events per job for at least 5 minutes
22
+ // after the job's `all-done` event so reconnecting clients can resume
23
+ // (spec §6.5).
24
+ const SSE_BUFFER_CAP = 200;
25
+ const SSE_JOB_TTL_MS = 5 * 60 * 1000;
26
+ // ---------------------------------------------------------------------------
27
+ // Generic error helpers — bodies are byte-identical for matching status codes
28
+ // so probers can't distinguish *why* auth failed (spec §7 anti-probing).
29
+ // ---------------------------------------------------------------------------
30
+ const UNAUTHORIZED_BODY = JSON.stringify({ error: "unauthorized" });
31
+ const FORBIDDEN_BODY = JSON.stringify({ error: "forbidden" });
32
+ function sendJson(res, status, body) {
33
+ if (res.headersSent || res.writableEnded)
34
+ return;
35
+ const payload = typeof body === "string" ? body : JSON.stringify(body);
36
+ res.statusCode = status;
37
+ res.setHeader("content-type", "application/json; charset=utf-8");
38
+ res.setHeader("cache-control", "no-store");
39
+ res.end(payload);
40
+ }
41
+ function sendUnauthorized(res) {
42
+ sendJson(res, 401, UNAUTHORIZED_BODY);
43
+ }
44
+ function sendForbidden(res) {
45
+ sendJson(res, 403, FORBIDDEN_BODY);
46
+ }
47
+ function send404(res) {
48
+ // Generic 404 for unknown routes / missing static assets. Body is JSON for
49
+ // consistency with the other error surfaces; tests only assert the status.
50
+ sendJson(res, 404, { error: "not-found" });
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // Token: timing-safe constant-time compare on equal-length byte buffers.
54
+ // Returns false fast on length mismatch (length is not secret).
55
+ // ---------------------------------------------------------------------------
56
+ function tokensEqual(a, b) {
57
+ if (typeof a !== "string" || typeof b !== "string")
58
+ return false;
59
+ if (a.length !== b.length)
60
+ return false;
61
+ if (a.length === 0)
62
+ return false;
63
+ const ab = Buffer.from(a, "utf8");
64
+ const bb = Buffer.from(b, "utf8");
65
+ // Buffer.from on a string always returns a buffer of byte-length matching
66
+ // utf8 encoding; for our hex token shape, byte length == char length, but
67
+ // even with multibyte inputs the length check above + the equal-length
68
+ // guard below keep this safe.
69
+ if (ab.length !== bb.length)
70
+ return false;
71
+ return timingSafeEqual(ab, bb);
72
+ }
73
+ // Extracts token from Authorization header. Returns null if not a well-formed
74
+ // `Bearer <value>` (single space, non-empty value). Strict by design — spec
75
+ // A3 says the canonical shape is "Bearer <token>".
76
+ function parseBearer(authHeader) {
77
+ if (!authHeader)
78
+ return null;
79
+ // Reject any internal whitespace beyond the single delimiter between scheme
80
+ // and value. `Bearer token` (double space) → reject.
81
+ const m = /^Bearer ([^\s]+)$/.exec(authHeader);
82
+ if (!m)
83
+ return null;
84
+ return m[1] ?? null;
85
+ }
86
+ // Extracts ?token=... from the URL search string. URL is parsed against a
87
+ // dummy base so we can reuse the WHATWG parser without needing the real host.
88
+ function parseQueryToken(reqUrl) {
89
+ if (!reqUrl)
90
+ return null;
91
+ try {
92
+ const u = new URL(reqUrl, "http://localhost");
93
+ const t = u.searchParams.get("token");
94
+ return t === null || t === "" ? null : t;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // Host / Origin whitelist.
102
+ // ---------------------------------------------------------------------------
103
+ function buildAllowedHosts(port) {
104
+ // Stored lowercased; comparisons normalize the incoming header the same way.
105
+ return new Set([
106
+ `127.0.0.1:${port}`,
107
+ `localhost:${port}`,
108
+ `[::1]:${port}`,
109
+ ]);
110
+ }
111
+ function isHostAllowed(hostHeader, allowed) {
112
+ if (!hostHeader) {
113
+ // HTTP/1.1 requires Host; if absent, treat as bad. (HTTP/1.0 callers don't
114
+ // exist in this app's threat model.)
115
+ return false;
116
+ }
117
+ return allowed.has(hostHeader.toLowerCase());
118
+ }
119
+ function isOriginAllowed(originHeader, allowed) {
120
+ if (originHeader === undefined)
121
+ return true; // missing Origin = programmatic / file://
122
+ if (originHeader === "null")
123
+ return true; // file:// origin sends "null"
124
+ // Strip scheme. Only http://host:port is allowed (no https on loopback).
125
+ const m = /^https?:\/\/(.+)$/i.exec(originHeader);
126
+ if (!m)
127
+ return false;
128
+ return allowed.has(m[1].toLowerCase());
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Request URL: parse path + search without depending on host header (which
132
+ // may be hostile — Host is validated separately).
133
+ // ---------------------------------------------------------------------------
134
+ function parseRequestUrl(reqUrl) {
135
+ if (!reqUrl)
136
+ return { pathname: "/", searchParams: new URLSearchParams() };
137
+ const u = new URL(reqUrl, "http://localhost");
138
+ return { pathname: u.pathname, searchParams: u.searchParams };
139
+ }
140
+ // ---------------------------------------------------------------------------
141
+ // Body reader with a hard cap so malicious clients can't OOM the server by
142
+ // streaming forever. Resolves with the buffered string or rejects.
143
+ // ---------------------------------------------------------------------------
144
+ function readJsonBody(req) {
145
+ return new Promise((resolve, reject) => {
146
+ let total = 0;
147
+ const chunks = [];
148
+ req.on("data", (chunk) => {
149
+ total += chunk.length;
150
+ if (total > MAX_JSON_BODY) {
151
+ reject(new Error("body-too-large"));
152
+ req.destroy();
153
+ return;
154
+ }
155
+ chunks.push(chunk);
156
+ });
157
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
158
+ req.on("error", reject);
159
+ });
160
+ }
161
+ // ---------------------------------------------------------------------------
162
+ // ApplyRequest body validation. Returns null if invalid. Validates only the
163
+ // shape we promise to consumers — Slice C / the apply runner may add deeper
164
+ // checks (e.g. name ∈ catalog) later.
165
+ // ---------------------------------------------------------------------------
166
+ const VALID_CATEGORIES = new Set([
167
+ "workflow",
168
+ "skill",
169
+ "recommended-skill",
170
+ "plugin",
171
+ "hook",
172
+ ]);
173
+ const VALID_ACTIONS = new Set(["install", "update", "uninstall"]);
174
+ const VALID_SCOPES = new Set(["project", "user"]);
175
+ const VALID_LANGS = new Set(["en", "zh-CN"]);
176
+ function parseApplyRequest(raw) {
177
+ let parsed;
178
+ try {
179
+ parsed = JSON.parse(raw);
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ if (!parsed || typeof parsed !== "object")
185
+ return null;
186
+ const items = parsed.items;
187
+ if (!Array.isArray(items))
188
+ return null;
189
+ for (const it of items) {
190
+ if (!it || typeof it !== "object")
191
+ return null;
192
+ const { category, name, action, scope, lang } = it;
193
+ if (typeof category !== "string" || !VALID_CATEGORIES.has(category)) {
194
+ return null;
195
+ }
196
+ if (typeof name !== "string" || name.length === 0)
197
+ return null;
198
+ if (typeof action !== "string" || !VALID_ACTIONS.has(action))
199
+ return null;
200
+ // Scope is optional. When present it must be a known value AND must not
201
+ // be paired with `category === "workflow"` (workflow is a single root
202
+ // file with no scope concept).
203
+ if (scope !== undefined) {
204
+ if (typeof scope !== "string" || !VALID_SCOPES.has(scope))
205
+ return null;
206
+ if (category === "workflow")
207
+ return null;
208
+ }
209
+ // Lang is optional and only meaningful for category="workflow". Any
210
+ // other pairing is a client bug and we reject loudly.
211
+ if (lang !== undefined) {
212
+ if (typeof lang !== "string" || !VALID_LANGS.has(lang))
213
+ return null;
214
+ if (category !== "workflow")
215
+ return null;
216
+ }
217
+ }
218
+ return parsed;
219
+ }
220
+ function namesInCatalog(items, catalog) {
221
+ for (const it of items) {
222
+ const set = catalog[it.category];
223
+ if (!set || !set.has(it.name))
224
+ return false;
225
+ }
226
+ return true;
227
+ }
228
+ // ---------------------------------------------------------------------------
229
+ // Static asset handler. When `uiDir` is configured, we serve files from
230
+ // there with SPA fallback (any unknown path → index.html). When not
231
+ // configured (tests, no-bundle environments), every request returns 404.
232
+ // ---------------------------------------------------------------------------
233
+ const MIME_BY_EXT = {
234
+ ".html": "text/html; charset=utf-8",
235
+ ".js": "application/javascript; charset=utf-8",
236
+ ".mjs": "application/javascript; charset=utf-8",
237
+ ".css": "text/css; charset=utf-8",
238
+ ".svg": "image/svg+xml",
239
+ ".png": "image/png",
240
+ ".jpg": "image/jpeg",
241
+ ".jpeg": "image/jpeg",
242
+ ".webp": "image/webp",
243
+ ".ico": "image/x-icon",
244
+ ".json": "application/json; charset=utf-8",
245
+ ".woff": "font/woff",
246
+ ".woff2": "font/woff2",
247
+ ".ttf": "font/ttf",
248
+ ".map": "application/json; charset=utf-8",
249
+ };
250
+ async function handleStatic(pathname, uiDir, res) {
251
+ if (!uiDir) {
252
+ send404(res);
253
+ return;
254
+ }
255
+ // Strip leading `/`. Path traversal defense: resolve and verify the
256
+ // result stays inside uiDir.
257
+ const requested = pathname.replace(/^\/+/, "");
258
+ const target = requested === "" ? "index.html" : requested;
259
+ const resolved = path.resolve(uiDir, target);
260
+ const uiDirResolved = path.resolve(uiDir);
261
+ const isInside = resolved === uiDirResolved ||
262
+ resolved.startsWith(uiDirResolved + path.sep);
263
+ if (!isInside) {
264
+ send404(res);
265
+ return;
266
+ }
267
+ try {
268
+ const content = await readFile(resolved);
269
+ sendFile(res, resolved, content);
270
+ return;
271
+ }
272
+ catch {
273
+ // SPA fallback: serve index.html for unknown paths (excluding asset-like
274
+ // extensions). This keeps client-side routing usable without 404 noise.
275
+ if (!/\.[a-z0-9]+$/i.test(target)) {
276
+ try {
277
+ const index = await readFile(path.join(uiDirResolved, "index.html"));
278
+ sendFile(res, "index.html", index);
279
+ return;
280
+ }
281
+ catch {
282
+ /* fall through to 404 */
283
+ }
284
+ }
285
+ send404(res);
286
+ }
287
+ }
288
+ function sendFile(res, filePath, body) {
289
+ if (res.headersSent || res.writableEnded)
290
+ return;
291
+ const ext = path.extname(filePath).toLowerCase();
292
+ const type = MIME_BY_EXT[ext] ?? "application/octet-stream";
293
+ res.statusCode = 200;
294
+ res.setHeader("content-type", type);
295
+ res.setHeader("cache-control", "no-store");
296
+ res.end(body);
297
+ }
298
+ // ---------------------------------------------------------------------------
299
+ // startServer
300
+ // ---------------------------------------------------------------------------
301
+ export async function startServer(opts) {
302
+ // Lifecycle flags. Mutated only by the close path.
303
+ let closing = false;
304
+ // Track open sockets so close() can forcibly tear down idle keep-alive
305
+ // connections (Node's `server.close` only stops accepting; it waits for
306
+ // open sockets indefinitely otherwise).
307
+ const openSockets = new Set();
308
+ // Heartbeat state. `lastPingAt` is bumped on each POST /api/ping. The
309
+ // interval fires periodically and triggers shutdown if too much time
310
+ // has passed without a ping — implementing "closing the browser closes
311
+ // the server" UX.
312
+ let lastPingAt = Date.now();
313
+ let heartbeatTimer = null;
314
+ // Per-instance helpers that need the port (chosen after listen()).
315
+ let allowedHosts;
316
+ // ---- Apply / SSE state ----
317
+ // Job cache. Keyed by jobId; entries are deleted SSE_JOB_TTL_MS after the
318
+ // job finishes. Late subscribers and Last-Event-ID resume both read from
319
+ // here. `currentJobId` enforces serial execution: a second /api/apply that
320
+ // arrives while another job is in-flight returns 409 (spec §6.4 — installers
321
+ // contend on shared files like settings.json + skills-lock.json).
322
+ const jobs = new Map();
323
+ let currentJobId = null;
324
+ function emit(job, event) {
325
+ const id = String(job.nextId);
326
+ job.nextId++;
327
+ job.events.push({ id, event });
328
+ if (job.events.length > SSE_BUFFER_CAP)
329
+ job.events.shift();
330
+ const frame = `id: ${id}\nevent: progress\ndata: ${JSON.stringify(event)}\n\n`;
331
+ for (const sub of job.subscribers) {
332
+ try {
333
+ if (!sub.writableEnded)
334
+ sub.write(frame);
335
+ }
336
+ catch {
337
+ /* subscriber went away mid-write — close listener will remove it */
338
+ }
339
+ }
340
+ if (event.type === "all-done") {
341
+ job.finished = true;
342
+ // Close all live subscribers — they've received the terminal event.
343
+ for (const sub of job.subscribers) {
344
+ try {
345
+ if (!sub.writableEnded)
346
+ sub.end();
347
+ }
348
+ catch {
349
+ /* ignore */
350
+ }
351
+ }
352
+ job.subscribers.clear();
353
+ // Schedule cache eviction. .unref() so we don't keep the process alive.
354
+ job.cleanupTimer = setTimeout(() => {
355
+ jobs.delete(job.jobId);
356
+ }, SSE_JOB_TTL_MS);
357
+ job.cleanupTimer.unref?.();
358
+ }
359
+ }
360
+ async function runApplyJob(job, items, handlers) {
361
+ let failedCount = 0;
362
+ try {
363
+ for (let i = 0; i < items.length; i++) {
364
+ const item = items[i];
365
+ emit(job, {
366
+ type: "item:start",
367
+ index: i,
368
+ total: items.length,
369
+ item,
370
+ });
371
+ const handler = handlers[item.category];
372
+ try {
373
+ await handler(item.action, item.name, {
374
+ onLog: (line, level) => emit(job, { type: "item:log", index: i, line, level }),
375
+ scope: item.scope,
376
+ lang: item.lang,
377
+ });
378
+ emit(job, { type: "item:done", index: i, success: true });
379
+ }
380
+ catch (err) {
381
+ failedCount++;
382
+ const msg = err instanceof Error && err.message ? err.message : "handler-failed";
383
+ emit(job, {
384
+ type: "item:done",
385
+ index: i,
386
+ success: false,
387
+ error: msg,
388
+ });
389
+ }
390
+ }
391
+ }
392
+ finally {
393
+ // Clear the in-flight slot BEFORE emitting all-done so a client that
394
+ // reacts immediately to the terminal frame can submit a new apply
395
+ // without racing (test "after first job finishes, new apply succeeds").
396
+ if (currentJobId === job.jobId)
397
+ currentJobId = null;
398
+ emit(job, {
399
+ type: "all-done",
400
+ success: failedCount === 0,
401
+ failedCount,
402
+ });
403
+ }
404
+ }
405
+ async function handleApply(req, res) {
406
+ // 1. Concurrency: serial execution per spec §6.4.
407
+ if (currentJobId !== null) {
408
+ sendJson(res, 409, { error: "apply-in-flight" });
409
+ return;
410
+ }
411
+ // 2. Body cap + JSON parse + shape validation.
412
+ let raw;
413
+ try {
414
+ raw = await readJsonBody(req);
415
+ }
416
+ catch {
417
+ sendJson(res, 413, { error: "body-too-large" });
418
+ return;
419
+ }
420
+ const parsed = parseApplyRequest(raw);
421
+ if (parsed === null) {
422
+ sendJson(res, 400, { error: "bad-request" });
423
+ return;
424
+ }
425
+ // 3. Catalog membership (only when an applyCatalog was injected).
426
+ if (opts.applyCatalog) {
427
+ if (parsed.items.length === 0) {
428
+ sendJson(res, 400, { error: "items-empty" });
429
+ return;
430
+ }
431
+ if (!namesInCatalog(parsed.items, opts.applyCatalog)) {
432
+ sendJson(res, 400, { error: "unknown-name" });
433
+ return;
434
+ }
435
+ }
436
+ // 4. Allocate job. randomBytes(16) → 32 hex chars = 128 bits of entropy.
437
+ const jobId = randomBytes(16).toString("hex");
438
+ const job = {
439
+ jobId,
440
+ events: [],
441
+ nextId: 1,
442
+ finished: false,
443
+ subscribers: new Set(),
444
+ };
445
+ jobs.set(jobId, job);
446
+ currentJobId = jobId;
447
+ // 5. Accept fast — 202 returns BEFORE any handler runs.
448
+ sendJson(res, 202, { jobId });
449
+ // 6. Kick off the worker on the next tick so the response flushes first.
450
+ const handlers = opts.applyHandlers ?? defaultHandlersNotConfigured;
451
+ setImmediate(() => {
452
+ void runApplyJob(job, parsed.items, handlers);
453
+ });
454
+ }
455
+ function handleProgress(req, searchParams, res) {
456
+ const jobId = searchParams.get("jobId");
457
+ if (!jobId) {
458
+ sendJson(res, 400, { error: "missing-jobId" });
459
+ return;
460
+ }
461
+ const job = jobs.get(jobId);
462
+ if (!job) {
463
+ sendJson(res, 404, { error: "unknown-job" });
464
+ return;
465
+ }
466
+ // Parse Last-Event-ID. If valid numeric, replay buffered events with
467
+ // id strictly greater than the cursor. Otherwise replay everything in
468
+ // the buffer. EventSource spec sends the value verbatim from the last
469
+ // observed `id:` field.
470
+ const lastEventIdHeader = req.headers["last-event-id"];
471
+ let cursor = -1;
472
+ if (typeof lastEventIdHeader === "string" && lastEventIdHeader.length > 0) {
473
+ const parsedCursor = Number.parseInt(lastEventIdHeader, 10);
474
+ if (Number.isFinite(parsedCursor))
475
+ cursor = parsedCursor;
476
+ }
477
+ res.statusCode = 200;
478
+ res.setHeader("content-type", "text/event-stream; charset=utf-8");
479
+ res.setHeader("cache-control", "no-store");
480
+ res.setHeader("connection", "keep-alive");
481
+ res.flushHeaders?.();
482
+ // Replay strictly-newer cached events.
483
+ for (const buffered of job.events) {
484
+ const bid = Number.parseInt(buffered.id, 10);
485
+ if (Number.isFinite(bid) && bid <= cursor)
486
+ continue;
487
+ res.write(`id: ${buffered.id}\nevent: progress\ndata: ${JSON.stringify(buffered.event)}\n\n`);
488
+ }
489
+ if (job.finished) {
490
+ // No more events will arrive; close cleanly so late subscribers and
491
+ // resumers don't hold the socket forever.
492
+ res.end();
493
+ return;
494
+ }
495
+ // Subscribe for live events. Detach on client disconnect.
496
+ job.subscribers.add(res);
497
+ const detach = () => {
498
+ job.subscribers.delete(res);
499
+ };
500
+ req.once("close", detach);
501
+ res.once("close", detach);
502
+ }
503
+ const server = createServer(async (req, res) => {
504
+ try {
505
+ await handleRequest(req, res);
506
+ }
507
+ catch (err) {
508
+ // Last-resort safety net. Never echo error details — they may contain
509
+ // upstream library messages we don't control.
510
+ if (!res.headersSent && !res.writableEnded) {
511
+ sendJson(res, 500, { error: "internal" });
512
+ }
513
+ // Log to stderr without surfacing token-bearing context (req.url may
514
+ // include ?token=, so don't print it).
515
+ const safeMsg = err instanceof Error ? err.message.replace(/token=[^&\s]*/gi, "token=***") : String(err);
516
+ // eslint-disable-next-line no-console
517
+ console.error(`[server] handler error: ${safeMsg}`);
518
+ }
519
+ });
520
+ server.on("connection", (socket) => {
521
+ openSockets.add(socket);
522
+ socket.once("close", () => openSockets.delete(socket));
523
+ });
524
+ async function handleRequest(req, res) {
525
+ const { pathname, searchParams } = parseRequestUrl(req.url);
526
+ const hostHeader = req.headers.host;
527
+ const originHeader = req.headers.origin;
528
+ // 1. Host whitelist (DNS-rebinding defense) — applies to ALL paths, even
529
+ // public statics. Without this, an attacker could lure the user to a
530
+ // rebinding-controlled domain and pull JS off `/`.
531
+ if (!isHostAllowed(hostHeader, allowedHosts)) {
532
+ sendForbidden(res);
533
+ return;
534
+ }
535
+ // 2. Origin whitelist — also applies to all paths.
536
+ if (!isOriginAllowed(originHeader, allowedHosts)) {
537
+ sendForbidden(res);
538
+ return;
539
+ }
540
+ const isApiPath = pathname.startsWith("/api/");
541
+ // 3. Token (only on /api/*). Static paths are public per spec §6.1.
542
+ if (isApiPath) {
543
+ const headerToken = parseBearer(req.headers.authorization);
544
+ const queryToken = parseQueryToken(req.url);
545
+ // If BOTH sources present, they must agree (defense against smuggling
546
+ // ambiguity per A6).
547
+ if (headerToken !== null && queryToken !== null) {
548
+ if (!tokensEqual(headerToken, queryToken)) {
549
+ sendUnauthorized(res);
550
+ return;
551
+ }
552
+ }
553
+ const presented = headerToken ?? queryToken;
554
+ if (presented === null) {
555
+ sendUnauthorized(res);
556
+ return;
557
+ }
558
+ if (!tokensEqual(presented, opts.token)) {
559
+ sendUnauthorized(res);
560
+ return;
561
+ }
562
+ }
563
+ // 4. Routing.
564
+ const method = req.method ?? "GET";
565
+ if (!isApiPath) {
566
+ await handleStatic(pathname, opts.uiDir, res);
567
+ return;
568
+ }
569
+ if (closing && pathname !== "/api/shutdown") {
570
+ // After shutdown is initiated, refuse further work cleanly.
571
+ sendJson(res, 503, { error: "shutting-down" });
572
+ return;
573
+ }
574
+ if (pathname === "/api/catalog" && method === "GET") {
575
+ await routeCatalog(opts.cwd, res);
576
+ return;
577
+ }
578
+ if (pathname === "/api/state" && method === "GET") {
579
+ await routeState(opts.cwd, opts.packageRoot ?? opts.cwd, res);
580
+ return;
581
+ }
582
+ if (pathname === "/api/apply" && method === "POST") {
583
+ await handleApply(req, res);
584
+ return;
585
+ }
586
+ if (pathname === "/api/progress" && method === "GET") {
587
+ handleProgress(req, searchParams, res);
588
+ return;
589
+ }
590
+ if (pathname === "/api/ping" && method === "POST") {
591
+ lastPingAt = Date.now();
592
+ sendJson(res, 200, { ok: true });
593
+ return;
594
+ }
595
+ if (pathname === "/api/shutdown" && method === "POST") {
596
+ sendJson(res, 200, { ok: true });
597
+ // Defer the actual teardown so the response can flush.
598
+ closing = true;
599
+ setImmediate(() => {
600
+ void initiateShutdown();
601
+ });
602
+ return;
603
+ }
604
+ send404(res);
605
+ }
606
+ async function initiateShutdown() {
607
+ closing = true;
608
+ if (heartbeatTimer) {
609
+ clearInterval(heartbeatTimer);
610
+ heartbeatTimer = null;
611
+ }
612
+ // Spec §4.3 / §6.6 — graceful shutdown waits for the in-flight job up to
613
+ // `shutdownGraceMs` (default 30s). Items continue to drive SSE events
614
+ // through to all-done; new /api/apply is already blocked by `closing`.
615
+ const graceMs = opts.shutdownGraceMs ?? 30_000;
616
+ if (currentJobId !== null && graceMs > 0) {
617
+ await new Promise((resolve) => {
618
+ const start = Date.now();
619
+ const timer = setInterval(() => {
620
+ if (currentJobId === null || Date.now() - start >= graceMs) {
621
+ clearInterval(timer);
622
+ resolve();
623
+ }
624
+ }, 50);
625
+ timer.unref?.();
626
+ });
627
+ }
628
+ // Stop accepting new connections.
629
+ server.close();
630
+ // Force-close open keep-alive sockets so close() can resolve promptly.
631
+ for (const s of openSockets) {
632
+ s.destroy();
633
+ }
634
+ openSockets.clear();
635
+ }
636
+ // Listen.
637
+ await new Promise((resolve, reject) => {
638
+ const onError = (err) => {
639
+ server.off("listening", onListening);
640
+ reject(err);
641
+ };
642
+ const onListening = () => {
643
+ server.off("error", onError);
644
+ resolve();
645
+ };
646
+ server.once("error", onError);
647
+ server.once("listening", onListening);
648
+ server.listen(opts.port ?? 0, "127.0.0.1");
649
+ });
650
+ const address = server.address();
651
+ const port = address?.port ?? opts.port ?? 0;
652
+ allowedHosts = buildAllowedHosts(port);
653
+ // Start heartbeat if enabled. Interval is half the timeout so the worst-
654
+ // case detection latency is ≤ timeout + interval. `.unref()` keeps Node
655
+ // from hanging waiting on this timer if the user kills the process.
656
+ const heartbeatMs = opts.heartbeatTimeoutMs ?? 0;
657
+ if (heartbeatMs > 0) {
658
+ const interval = Math.max(1000, Math.floor(heartbeatMs / 3));
659
+ heartbeatTimer = setInterval(() => {
660
+ if (closing)
661
+ return;
662
+ if (Date.now() - lastPingAt > heartbeatMs) {
663
+ void initiateShutdown();
664
+ }
665
+ }, interval);
666
+ heartbeatTimer.unref();
667
+ }
668
+ // Tracks "fully stopped" — resolved by Node's http server `close` event,
669
+ // which fires on either the explicit close() path or the heartbeat-driven
670
+ // initiateShutdown() path.
671
+ const closed = new Promise((resolve) => {
672
+ server.once("close", () => resolve());
673
+ });
674
+ return {
675
+ port,
676
+ close: async () => {
677
+ closing = true;
678
+ if (heartbeatTimer) {
679
+ clearInterval(heartbeatTimer);
680
+ heartbeatTimer = null;
681
+ }
682
+ // Synchronously break all open sockets so close() resolves quickly
683
+ // (otherwise keep-alive idle conns would block until their timeout).
684
+ for (const s of openSockets) {
685
+ s.destroy();
686
+ }
687
+ openSockets.clear();
688
+ try {
689
+ await new Promise((resolve, reject) => {
690
+ server.close((err) => (err ? reject(err) : resolve()));
691
+ });
692
+ }
693
+ catch (err) {
694
+ // `Server.close()` rejects when the server isn't running — happens
695
+ // when the heartbeat path already shut things down. Treat as success.
696
+ const msg = err instanceof Error ? err.message : String(err);
697
+ if (!/not running|not listening/i.test(msg))
698
+ throw err;
699
+ }
700
+ await closed;
701
+ },
702
+ closed,
703
+ };
704
+ }
705
+ // ---------------------------------------------------------------------------
706
+ // Route: GET /api/state
707
+ // ---------------------------------------------------------------------------
708
+ async function routeState(cwd, packageRoot, res) {
709
+ try {
710
+ const catalog = await buildScanCatalog(packageRoot);
711
+ const report = await scanState(cwd, catalog);
712
+ sendJson(res, 200, report);
713
+ }
714
+ catch {
715
+ // Catalog or scan blew up — return a structured 500 so the UI can show
716
+ // a recovery banner rather than getting an HTML error page.
717
+ sendJson(res, 500, { error: "scan-failed" });
718
+ }
719
+ }
720
+ // ---------------------------------------------------------------------------
721
+ // Route: GET /api/catalog
722
+ // ---------------------------------------------------------------------------
723
+ async function routeCatalog(cwd, res) {
724
+ // Read `<cwd>/dist/catalog.json`. The spec (§6.1) says this endpoint
725
+ // returns the current catalog content; if the catalog is missing (e.g.,
726
+ // running from a checkout without `npm run build`), return an empty
727
+ // object so the UI can degrade gracefully rather than 500-ing.
728
+ const catalogPath = path.join(cwd, "dist", "catalog.json");
729
+ let body;
730
+ try {
731
+ body = await readFile(catalogPath, "utf8");
732
+ // Validate it parses as JSON before forwarding; if not, fall back to {}.
733
+ JSON.parse(body);
734
+ }
735
+ catch {
736
+ body = "{}";
737
+ }
738
+ res.statusCode = 200;
739
+ res.setHeader("content-type", "application/json; charset=utf-8");
740
+ res.setHeader("cache-control", "no-store");
741
+ res.end(body);
742
+ }
743
+ // ---------------------------------------------------------------------------
744
+ // Default apply handlers: returned when callers don't inject their own.
745
+ // CLI mode (M3 T3.6) replaces this with real installers wired via
746
+ // applyHandlers; tests always inject explicit mocks. The fallback throws so
747
+ // any forgotten wiring surfaces immediately as an item:done failure instead
748
+ // of silently no-op'ing.
749
+ // ---------------------------------------------------------------------------
750
+ const handlerNotConfigured = async () => {
751
+ throw new Error("apply handlers not configured");
752
+ };
753
+ const defaultHandlersNotConfigured = {
754
+ workflow: handlerNotConfigured,
755
+ skill: handlerNotConfigured,
756
+ "recommended-skill": handlerNotConfigured,
757
+ plugin: handlerNotConfigured,
758
+ hook: handlerNotConfigured,
759
+ };