mainflow 0.28.0 → 0.29.0-alpha.1

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.
@@ -0,0 +1,30 @@
1
+ //#region ../schema/dist/retro-formats.js
2
+ /** Roles a delegation label names: `{item} {role} r{round}`. */
3
+ const LABEL_ROLES = [
4
+ "impl",
5
+ "review",
6
+ "check",
7
+ "survey",
8
+ "explore"
9
+ ];
10
+ /** The fence of the block a reviewer appends, one JSON object per finding and line. */
11
+ const FINDINGS_FENCE = "review-findings";
12
+ const FINDING_SEVERITIES = [
13
+ "high",
14
+ "mid",
15
+ "low"
16
+ ];
17
+ const FINDING_CLASSES = [
18
+ "preexisting",
19
+ "spec-gap",
20
+ "impl-bug"
21
+ ];
22
+ /** Fields every finding line carries. */
23
+ const FINDING_FIELDS = [
24
+ "id",
25
+ "sev",
26
+ "class",
27
+ "src"
28
+ ];
29
+ //#endregion
30
+ export { LABEL_ROLES as a, FINDING_SEVERITIES as i, FINDING_CLASSES as n, FINDING_FIELDS as r, FINDINGS_FENCE as t };
@@ -0,0 +1,658 @@
1
+ import { A as attributeTrace, C as transcriptPath, G as loadHub, J as linksInto, K as dependencyGraph, M as transcriptTitle, S as readSession, Y as refIndex, a as openInApp, at as ConflictError, et as readYaml, h as setArchivedMany, j as traceTranscript, m as ARCHIVE_KINDS, ot as DATA_DIR, q as mainlineEdges, st as WriteError, x as loadSessions } from "./host-BJnrv8xF.js";
2
+ import { j as configSchema } from "./dist-BbamrIPW.js";
3
+ import { t as listProgress } from "./progress-1BfPg2zN.js";
4
+ import { a as remoteMode, b as isSynced, g as readDaemon, m as callDaemon, n as hubWriter, p as DaemonUnavailable, r as writtenLines, x as syncPaths, y as SYNC_DIR } from "./operation-Bp4U2bx3.js";
5
+ import { l as CLI_VERSION } from "./launch-DefacJEL.js";
6
+ import { createRequire } from "node:module";
7
+ import { closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, realpathSync, statSync, watch } from "node:fs";
8
+ import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { spawnSync } from "node:child_process";
11
+ import { homedir } from "node:os";
12
+ import { serve } from "@hono/node-server";
13
+ import { Hono } from "hono";
14
+ import { streamSSE } from "hono/streaming";
15
+ //#region src/server.ts
16
+ /**
17
+ * The header a write's response carries when the write did more than change the hub: in remote mode it waits in the
18
+ * outbox for the sync server, maybe with no sync client to send it, or the operation noted something. Its value is
19
+ * this shape as URI-encoded JSON.
20
+ */
21
+ const WRITE_HEADER = "x-mainflow-write";
22
+ /** Every reference with a hub prefix in the documents of `hub`: links, origins and replaced items. */
23
+ function prefixedRefs(hub) {
24
+ const refs = [];
25
+ const doc = (d) => refs.push(d.origin, ...(d.links ?? []).map((l) => l.to));
26
+ for (const m of hub.mainlines) {
27
+ doc(m);
28
+ refs.push(...m.criteria.map((c) => c.supersededBy));
29
+ for (const d of m.decisions) doc(d);
30
+ for (const t of m.tasks) {
31
+ doc(t);
32
+ refs.push(...t.checklist.map((c) => c.supersededBy));
33
+ }
34
+ }
35
+ for (const d of [...hub.issues, ...hub.findings]) doc(d);
36
+ return [...new Set(refs.filter((r) => !!r && /^[a-z0-9-]+:/.test(r)))];
37
+ }
38
+ function relationsView(hub) {
39
+ const own = refIndex(hub);
40
+ const indexes = new Map(hub.remotes.flatMap((r) => r.hub ? [[r.name, refIndex(r.hub)]] : []));
41
+ const targets = {};
42
+ const graph = dependencyGraph(hub);
43
+ const depRefs = graph.edges.flatMap((e) => [e.from, e.to]);
44
+ for (const ref of /* @__PURE__ */ new Set([...prefixedRefs(hub), ...depRefs.filter((r) => r.includes(":"))])) {
45
+ const colon = ref.indexOf(":");
46
+ const name = ref.slice(0, colon);
47
+ const found = name === hub.config.hub ? own.get(ref.slice(colon + 1)) : indexes.get(name)?.get(ref.slice(colon + 1));
48
+ if (found) targets[ref] = found;
49
+ }
50
+ const backlinks = {};
51
+ const add = (to, link) => (backlinks[to] ??= []).push(link);
52
+ for (const { to, link } of linksInto(hub, void 0)) add(to, {
53
+ ...link,
54
+ ...pick(own.get(link.from))
55
+ });
56
+ for (const remote of hub.remotes) {
57
+ if (!remote.hub) continue;
58
+ const index = indexes.get(remote.name);
59
+ for (const { to, link } of linksInto(remote.hub, hub.config.hub)) add(to, {
60
+ ...link,
61
+ from: `${remote.name}:${link.from}`,
62
+ ...pick(index?.get(link.from))
63
+ });
64
+ }
65
+ return {
66
+ hubs: hub.remotes.map((r) => ({
67
+ name: r.name,
68
+ path: r.path,
69
+ available: !!r.hub,
70
+ ...r.unavailable ? { reason: r.unavailable } : {}
71
+ })),
72
+ targets,
73
+ backlinks,
74
+ deps: graph.edges,
75
+ mainlineDeps: mainlineEdges(graph),
76
+ cycles: graph.cycles
77
+ };
78
+ }
79
+ const pick = (t) => t ? {
80
+ title: t.title,
81
+ status: t.status
82
+ } : {};
83
+ const CONTENT_TYPES = {
84
+ ".html": "text/html; charset=utf-8",
85
+ ".md": "text/markdown; charset=utf-8",
86
+ ".js": "text/javascript; charset=utf-8",
87
+ ".mjs": "text/javascript; charset=utf-8",
88
+ ".vue": "text/plain; charset=utf-8",
89
+ ".css": "text/css; charset=utf-8",
90
+ ".json": "application/json; charset=utf-8",
91
+ ".svg": "image/svg+xml",
92
+ ".png": "image/png",
93
+ ".jpg": "image/jpeg",
94
+ ".txt": "text/plain; charset=utf-8"
95
+ };
96
+ function summarize(m) {
97
+ return {
98
+ id: m.id,
99
+ title: m.title,
100
+ status: m.status,
101
+ created: m.created,
102
+ archivedAt: m.archivedAt ?? null,
103
+ category: m.category ?? null,
104
+ priority: m.priority ?? null,
105
+ labels: m.labels ?? [],
106
+ due: m.due ?? null,
107
+ updated: [
108
+ m,
109
+ ...m.tasks,
110
+ ...m.decisions
111
+ ].map((d) => d.updated).reduce((a, b) => b > a ? b : a),
112
+ tasks: {
113
+ total: m.tasks.length,
114
+ done: m.tasks.filter((t) => t.status === "done").length
115
+ },
116
+ items: listProgress(m.tasks.flatMap((t) => t.checklist)),
117
+ criteria: listProgress(m.criteria),
118
+ decisions: m.decisions.length
119
+ };
120
+ }
121
+ /**
122
+ * HTTP API over one hub. The CLI `ui` command and the web dev server both mount it, through createUiApp. Writes go
123
+ * through the hub's writer (src/remote/operation.ts), so in remote mode they are operations the sync server applies;
124
+ * `env` gives the sync server and token as the environment variables do, `{}` for a hub other than the process's own.
125
+ */
126
+ function createApp(root, options = {}) {
127
+ const app = new Hono();
128
+ const mainlinesRoot = join(root, DATA_DIR, "mainlines");
129
+ const writer = hubWriter(root, options.env);
130
+ const notes = /* @__PURE__ */ new WeakMap();
131
+ const write = async (c, op, input) => {
132
+ const written = await writer(op, input);
133
+ for (const line of writtenLines(written)) if (line.startsWith("!")) console.log(`mainflow ui: ${line}`);
134
+ if (written.queued || written.notices.length) {
135
+ const before = notes.get(c) ?? {
136
+ queued: false,
137
+ notices: []
138
+ };
139
+ const note = {
140
+ queued: before.queued || written.queued,
141
+ ...written.stopped ?? before.stopped ? { stopped: written.stopped ?? before.stopped } : {},
142
+ ...written.restarted ?? before.restarted ? { restarted: written.restarted ?? before.restarted } : {},
143
+ notices: [...before.notices, ...written.notices]
144
+ };
145
+ notes.set(c, note);
146
+ c.header(WRITE_HEADER, encodeURIComponent(JSON.stringify(note)));
147
+ }
148
+ return written;
149
+ };
150
+ app.onError((error, c) => {
151
+ const message = error.message.replaceAll(`${root}${sep}`, "");
152
+ if (error instanceof ConflictError) return c.json({ error: message }, 409);
153
+ if (error instanceof WriteError) return c.json({ error: message }, 400);
154
+ throw error;
155
+ });
156
+ app.get("/api/hub", (c) => {
157
+ const hub = loadHub(root);
158
+ const body = {
159
+ hub: hub.config.hub,
160
+ version: CLI_VERSION,
161
+ repos: hub.config.repos,
162
+ mainlines: hub.mainlines.map(summarize),
163
+ openIssues: hub.issues.filter((i) => i.status !== "closed").length,
164
+ validFindings: hub.findings.filter((f) => f.status === "valid").length,
165
+ problems: hub.problems
166
+ };
167
+ return c.json(body);
168
+ });
169
+ app.get("/api/relations", (c) => c.json(relationsView(loadHub(root))));
170
+ const local = () => loadHub(root, { remotes: false });
171
+ app.get("/api/issues", (c) => c.json(local().issues));
172
+ app.get("/api/findings", (c) => c.json(local().findings));
173
+ app.get("/api/ops", (c) => c.json(local().ops));
174
+ app.post("/api/reveal", async (c) => {
175
+ const { path } = await c.req.json();
176
+ const known = loadSessions(root).sessions.some((s) => s.transcript === path);
177
+ const file = transcriptPath(root, path);
178
+ if (!known || !existsSync(file)) return c.json({ error: "only a transcript recorded on a session can be revealed" }, 400);
179
+ const opened = spawnSync("open", ["-R", file]);
180
+ return opened.status === 0 ? c.json({ revealed: file }) : c.json({ error: `open -R failed: ${opened.stderr}` }, 500);
181
+ });
182
+ app.get("/api/mainlines", (c) => {
183
+ const ids = c.req.query("ids")?.split(",").filter(Boolean);
184
+ const all = local().mainlines;
185
+ return c.json(ids ? all.filter((m) => ids.includes(m.id)) : all.filter((m) => !m.archivedAt));
186
+ });
187
+ app.get("/api/mainlines/:m", (c) => {
188
+ const mainline = local().mainlines.find((m) => m.id === c.req.param("m"));
189
+ return mainline ? c.json(mainline) : c.json({ error: `mainline ${c.req.param("m")} not found` }, 404);
190
+ });
191
+ const titles = /* @__PURE__ */ new Map();
192
+ const titleOf = (recorded) => {
193
+ const transcript = recorded && transcriptPath(root, recorded);
194
+ if (!transcript || !existsSync(transcript)) return void 0;
195
+ const { size, mtimeMs } = statSync(transcript);
196
+ const stamp = `${size}:${mtimeMs}`;
197
+ const cached = titles.get(transcript);
198
+ if (cached?.stamp === stamp) return cached.title;
199
+ const title = transcriptTitle(transcript);
200
+ titles.set(transcript, {
201
+ stamp,
202
+ title
203
+ });
204
+ return title;
205
+ };
206
+ app.get("/api/sessions", (c) => {
207
+ const { sessions, problems } = loadSessions(root);
208
+ const views = sessions.map((s) => {
209
+ const title = titleOf(s.transcript);
210
+ return {
211
+ ...s,
212
+ ...title ? { title } : {},
213
+ ...runsElsewhere(root, s) ? { elsewhere: true } : {}
214
+ };
215
+ });
216
+ return c.json({
217
+ sessions: views,
218
+ problems
219
+ });
220
+ });
221
+ app.post("/api/archive", async (c) => {
222
+ const { kind, ids, archived } = await c.req.json();
223
+ if (!ARCHIVE_KINDS.includes(kind) || !Array.isArray(ids)) return c.json({ error: `kind must be one of ${ARCHIVE_KINDS.join(", ")} and ids a list` }, 400);
224
+ return c.json(await setArchivedMany(kind, ids, archived, (op, input) => write(c, op, input)));
225
+ });
226
+ app.post("/api/sessions/:id/archive", async (c) => {
227
+ const { archived } = await c.req.json();
228
+ return c.json((await write(c, "session.archive", {
229
+ id: c.req.param("id"),
230
+ archived
231
+ })).value);
232
+ });
233
+ app.post("/api/issues/:id/archive", async (c) => {
234
+ const { archived, version } = await c.req.json();
235
+ return c.json((await write(c, "issue.archive", {
236
+ id: c.req.param("id"),
237
+ archived,
238
+ expectedVersion: version
239
+ })).value);
240
+ });
241
+ app.post("/api/findings/:id/archive", async (c) => {
242
+ const { archived, version } = await c.req.json();
243
+ return c.json((await write(c, "finding.archive", {
244
+ id: c.req.param("id"),
245
+ archived,
246
+ expectedVersion: version
247
+ })).value);
248
+ });
249
+ app.get("/api/sessions/:id/trace", (c) => {
250
+ const session = readSession(root, c.req.param("id"));
251
+ if (!session) return c.json({ error: `session ${c.req.param("id")} not found` }, 404);
252
+ const transcript = session.transcript && transcriptPath(root, session.transcript);
253
+ if (!transcript || !existsSync(transcript)) return c.json({
254
+ available: false,
255
+ byAction: {},
256
+ unattributed: []
257
+ });
258
+ const trace = traceTranscript(transcript, { home: homedir() });
259
+ return c.json({
260
+ available: true,
261
+ model: trace.model ?? session.model,
262
+ ...attributeTrace(trace, session.actions)
263
+ });
264
+ });
265
+ app.get("/api/files/*", (c) => {
266
+ const path = normalize(decodeURIComponent(c.req.path.slice(11)));
267
+ const file = join(root, path);
268
+ if (!file.startsWith(mainlinesRoot + sep) || !existsSync(file) || !statSync(file).isFile()) return c.json({ error: `file ${path} not found under ${relative(root, mainlinesRoot)}` }, 404);
269
+ return c.body(readFileSync(file), 200, { "content-type": CONTENT_TYPES[extname(file)] ?? "application/octet-stream" });
270
+ });
271
+ app.post("/api/mainlines/:m/archive", async (c) => {
272
+ const { archived, version } = await c.req.json();
273
+ return c.json((await write(c, "mainline.archive", {
274
+ id: c.req.param("m"),
275
+ archived,
276
+ expectedVersion: version
277
+ })).value);
278
+ });
279
+ app.post("/api/mainlines/:m/title", async (c) => {
280
+ const { title, version } = await c.req.json();
281
+ return c.json((await write(c, "mainline.title", {
282
+ id: c.req.param("m"),
283
+ title,
284
+ expectedVersion: version
285
+ })).value);
286
+ });
287
+ app.post("/api/mainlines/:m/status", async (c) => {
288
+ const { status, version } = await c.req.json();
289
+ return c.json((await write(c, "mainline.set", {
290
+ id: c.req.param("m"),
291
+ status,
292
+ expectedVersion: version
293
+ })).value);
294
+ });
295
+ app.post("/api/tasks/:m/:t/status", async (c) => {
296
+ const { status, version } = await c.req.json();
297
+ return c.json((await write(c, "task.set", {
298
+ ref: `${c.req.param("m")}/${c.req.param("t")}`,
299
+ patch: { status },
300
+ expectedVersion: version
301
+ })).value);
302
+ });
303
+ app.post("/api/check", async (c) => {
304
+ const { ref, done, evidence, version } = await c.req.json();
305
+ const input = {
306
+ ref,
307
+ done,
308
+ evidence,
309
+ expectedVersion: version
310
+ };
311
+ return c.json((ref.includes("/") ? await write(c, "item.check", input) : await write(c, "criterion.check", input)).value);
312
+ });
313
+ app.get("/api/sync", (c) => c.json({
314
+ remote: remoteMode(root, options.env),
315
+ running: readDaemon(root) !== null,
316
+ notices: readNotices(root)
317
+ }));
318
+ app.get("/api/sync/conflicts/:name", async (c) => {
319
+ const name = c.req.param("name");
320
+ const saved = `${SYNC_DIR}/conflicts/${name}`;
321
+ const file = join(syncPaths(root).conflicts, name);
322
+ const notice = readNotices(root).find((n) => n.saved === saved);
323
+ if (name.includes("/") || name.startsWith(".") || !existsSync(file) || !notice) return c.json({ error: `no saved text ${saved} named by a notice` }, 404);
324
+ if (lstatSync(file).isSymbolicLink() || !insideHub(root, file)) return c.json({ error: `${saved} is not a file the sync client saved in the hub` }, 400);
325
+ const target = isSynced(notice.path) ? join(root, DATA_DIR, notice.path) : null;
326
+ if (!target || !insideHub(root, target)) return c.json({ error: `the notice for ${saved} names ${JSON.stringify(notice.path)}, not a synced file of the hub` }, 400);
327
+ return c.json({
328
+ saved,
329
+ path: notice.path,
330
+ mine: readFileSync(file, "utf8"),
331
+ theirs: await serverVersion(root, notice.path, target)
332
+ });
333
+ });
334
+ app.get("/api/events", (c) => streamSSE(c, async (stream) => {
335
+ const stop = watchHub(root, (event) => void stream.writeSSE({
336
+ event: event.kind,
337
+ data: event.name
338
+ }));
339
+ stream.onAbort(stop);
340
+ while (!stream.aborted) await stream.sleep(3e4);
341
+ }));
342
+ return app;
343
+ }
344
+ /**
345
+ * Whether a session runs in another environment: its working directory is not on this machine, and neither is a
346
+ * transcript it recorded. A session of this machine whose worktree was removed still has its transcript here.
347
+ */
348
+ function runsElsewhere(root, session) {
349
+ if (existsSync(session.cwd)) return false;
350
+ return !(session.transcript && existsSync(transcriptPath(root, session.transcript)));
351
+ }
352
+ /** Every warn-level line of .mainflow/local/sync/notices.jsonl, newest first; unreadable lines are skipped. */
353
+ function readNotices(root) {
354
+ const file = syncPaths(root).notices;
355
+ if (!existsSync(file)) return [];
356
+ const notices = [];
357
+ for (const line of readFileSync(file, "utf8").split("\n")) {
358
+ const notice = parseWarn(line);
359
+ if (notice) notices.push(notice);
360
+ }
361
+ return notices.reverse();
362
+ }
363
+ function parseWarn(line) {
364
+ if (!line.trim()) return null;
365
+ try {
366
+ const { at, level, kind, path, message, saved } = JSON.parse(line);
367
+ if (level !== "warn" || typeof at !== "string" || typeof kind !== "string" || typeof message !== "string") return null;
368
+ return {
369
+ at,
370
+ kind,
371
+ path: typeof path === "string" ? path : "",
372
+ message,
373
+ ...typeof saved === "string" ? { saved } : {}
374
+ };
375
+ } catch {
376
+ return null;
377
+ }
378
+ }
379
+ /**
380
+ * Whether `file` really is inside the hub's .mainflow/ once every symbolic link on the way is followed. A file that
381
+ * does not exist yet is judged by the deepest directory above it that does.
382
+ */
383
+ function insideHub(root, file) {
384
+ let data;
385
+ try {
386
+ data = realpathSync(join(root, DATA_DIR));
387
+ } catch {
388
+ return false;
389
+ }
390
+ let existing = resolve(file);
391
+ const rest = [];
392
+ for (;;) try {
393
+ return join(realpathSync(existing), ...rest).startsWith(`${data}${sep}`);
394
+ } catch {
395
+ if (lstatSync(existing, { throwIfNoEntry: false })) return false;
396
+ const parent = dirname(existing);
397
+ if (parent === existing) return false;
398
+ rest.unshift(basename(existing));
399
+ existing = parent;
400
+ }
401
+ }
402
+ /**
403
+ * The file at `path` as the hub has it: from the running sync client's copy of the server's version, else this
404
+ * machine's file at `file`, already checked to be inside the hub.
405
+ */
406
+ async function serverVersion(root, path, file) {
407
+ const daemon = readDaemon(root);
408
+ if (daemon) try {
409
+ const { text } = await callDaemon(daemon, "/file", { path }, 5e3);
410
+ if (text !== void 0) return {
411
+ text,
412
+ source: "server"
413
+ };
414
+ } catch (error) {
415
+ if (!(error instanceof DaemonUnavailable)) throw error;
416
+ }
417
+ return {
418
+ text: existsSync(file) ? readFileSync(file, "utf8") : null,
419
+ source: "local"
420
+ };
421
+ }
422
+ /**
423
+ * What the interface does about a change at `name`, relative to .mainflow/: reload (`change`), check the sync
424
+ * client's notices (`notices`), or nothing. The sync client's own state under local/sync/ (known.json, queue.json,
425
+ * the conflicts it saves, its tmp/) is not the hub's content, so writing it reloads nothing; notices.jsonl only
426
+ * matters when a warn-level line arrives. Lock and temporary files are left out as well.
427
+ */
428
+ function eventOf(name) {
429
+ const path = name.split(sep).join("/");
430
+ if (path === `local/sync/notices.jsonl`) return "notices";
431
+ if (path === "local/sync" || path.startsWith(`local/sync/`)) return null;
432
+ if (path.endsWith("write.lock") || path.endsWith(".tmp")) return null;
433
+ return "change";
434
+ }
435
+ /** One file watcher per hub, shared by every listener of that hub. */
436
+ var HubWatch = class {
437
+ root;
438
+ listeners = /* @__PURE__ */ new Set();
439
+ watcher;
440
+ /** How far notices.jsonl has been read */
441
+ offset;
442
+ constructor(root) {
443
+ this.root = root;
444
+ this.offset = sizeOf(syncPaths(root).notices);
445
+ this.watcher = watch(join(root, DATA_DIR), { recursive: true }, (_event, name) => {
446
+ const kind = name ? eventOf(String(name)) : null;
447
+ if (kind === null || kind === "notices" && !this.newWarnings()) return;
448
+ const event = {
449
+ kind: kind === "change" ? "change" : "notice",
450
+ name: String(name)
451
+ };
452
+ for (const notify of this.listeners) notify(event);
453
+ });
454
+ this.watcher.on("error", () => void 0);
455
+ }
456
+ close() {
457
+ this.watcher.close();
458
+ }
459
+ /** Whether the lines added to notices.jsonl since the last read hold a warn-level notice. */
460
+ newWarnings() {
461
+ const file = syncPaths(this.root).notices;
462
+ const size = sizeOf(file);
463
+ if (size < this.offset) this.offset = 0;
464
+ if (size === this.offset) return false;
465
+ const buffer = Buffer.alloc(size - this.offset);
466
+ let fd;
467
+ try {
468
+ fd = openSync(file, "r");
469
+ } catch {
470
+ this.offset = 0;
471
+ return false;
472
+ }
473
+ let read;
474
+ try {
475
+ read = readSync(fd, buffer, 0, buffer.length, this.offset);
476
+ } finally {
477
+ closeSync(fd);
478
+ }
479
+ const complete = buffer.subarray(0, read).lastIndexOf(10) + 1;
480
+ this.offset += complete;
481
+ return buffer.subarray(0, complete).toString("utf8").split("\n").some((line) => parseWarn(line) !== null);
482
+ }
483
+ };
484
+ const watches = /* @__PURE__ */ new Map();
485
+ /** The size of a file, 0 when it does not exist; it may be removed between any two calls, so it is read in one. */
486
+ function sizeOf(file) {
487
+ try {
488
+ return statSync(file).size;
489
+ } catch {
490
+ return 0;
491
+ }
492
+ }
493
+ /**
494
+ * Call `listener` for every change the interface reloads on, and once for each batch of new warn-level notices.
495
+ * Every listener of a hub shares one file watcher, which closes with the last of them. Returns the unsubscribe.
496
+ */
497
+ function watchHub(root, listener) {
498
+ const key = resolve(root);
499
+ const hub = watches.get(key) ?? new HubWatch(key);
500
+ watches.set(key, hub);
501
+ hub.listeners.add(listener);
502
+ return () => {
503
+ hub.listeners.delete(listener);
504
+ if (hub.listeners.size > 0) return;
505
+ hub.close();
506
+ watches.delete(key);
507
+ };
508
+ }
509
+ /** The hubs this interface serves: its own, then every hub registered in its config.yaml, with each one's root when it is here. */
510
+ function servedHubs(root) {
511
+ const config = readYaml(join(root, DATA_DIR, "config.yaml"), configSchema).data;
512
+ const others = Object.entries(config.hubs ?? {}).map(([name, path]) => {
513
+ const base = `/h/${name}/`;
514
+ const other = resolve(root, path);
515
+ const file = join(other, DATA_DIR, "config.yaml");
516
+ if (name === config.hub) return {
517
+ name,
518
+ base,
519
+ available: false,
520
+ reason: "this hub"
521
+ };
522
+ if (!existsSync(file)) return {
523
+ name,
524
+ base,
525
+ available: false,
526
+ reason: `not found at ${path} on this machine`
527
+ };
528
+ try {
529
+ const found = readYaml(file, configSchema).data;
530
+ if (found.hub !== name) return {
531
+ name,
532
+ base,
533
+ available: false,
534
+ reason: `${path} holds hub "${found.hub}"`
535
+ };
536
+ if (found.formatVersion !== 2) return {
537
+ name,
538
+ base,
539
+ available: false,
540
+ reason: `format ${found.formatVersion}; this mainflow reads format 2`
541
+ };
542
+ } catch (error) {
543
+ return {
544
+ name,
545
+ base,
546
+ available: false,
547
+ reason: error.message
548
+ };
549
+ }
550
+ return {
551
+ name,
552
+ base,
553
+ available: true,
554
+ root: other
555
+ };
556
+ });
557
+ return [{
558
+ name: config.hub,
559
+ base: "/",
560
+ available: true,
561
+ root
562
+ }, ...others];
563
+ }
564
+ /**
565
+ * The interface's API: this hub at /api, and every registered hub that is on this machine at /h/{name}/api, so one
566
+ * interface follows several hubs and switches between them. /api/hubs lists them, and /api/hubs/events sends every
567
+ * hub's changes and notices tagged with its name, so the switcher shows where something happened. Another hub is
568
+ * written through its own sync settings only, never through this process's environment variables.
569
+ */
570
+ function createUiApp(root) {
571
+ const app = new Hono();
572
+ const own = createApp(root);
573
+ const others = /* @__PURE__ */ new Map();
574
+ const appOf = (name) => {
575
+ const hub = servedHubs(root).find((h) => h.name === name && h.base !== "/");
576
+ if (!hub?.available || !hub.root) return null;
577
+ let sub = others.get(hub.root);
578
+ if (!sub) {
579
+ sub = createApp(hub.root, { env: {} });
580
+ others.set(hub.root, sub);
581
+ }
582
+ return sub;
583
+ };
584
+ app.get("/api/hubs", (c) => {
585
+ const hubs = servedHubs(root);
586
+ return c.json({
587
+ primary: hubs[0].name,
588
+ hubs: hubs.map(({ root: _root, ...link }) => link)
589
+ });
590
+ });
591
+ app.get("/api/hubs/events", (c) => streamSSE(c, async (stream) => {
592
+ const stops = servedHubs(root).flatMap((hub) => hub.available && hub.root ? [watchHub(hub.root, (event) => void stream.writeSSE({
593
+ event: event.kind,
594
+ data: JSON.stringify({
595
+ hub: hub.name,
596
+ name: event.name
597
+ })
598
+ }))] : []);
599
+ stream.onAbort(() => stops.forEach((stop) => stop()));
600
+ while (!stream.aborted) await stream.sleep(3e4);
601
+ }));
602
+ app.all("/h/:name/api/*", (c) => {
603
+ const name = c.req.param("name");
604
+ const sub = appOf(name);
605
+ if (!sub) return c.json({ error: `hub ${name} is not registered in this hub's config.yaml or is not on this machine` }, 404);
606
+ const url = new URL(c.req.url);
607
+ url.pathname = url.pathname.slice(`/h/${name}`.length);
608
+ return sub.fetch(new Request(url, c.req.raw));
609
+ });
610
+ app.route("/", own);
611
+ return app;
612
+ }
613
+ //#endregion
614
+ //#region src/ui.ts
615
+ /**
616
+ * Where the built web client lives: `web/` beside the bundled CLI in the published package, or the
617
+ * workspace's web package when running from source before a bundle exists.
618
+ */
619
+ function webDir() {
620
+ const bundled = join(dirname(fileURLToPath(import.meta.url)), "web");
621
+ if (existsSync(join(bundled, "index.html"))) return bundled;
622
+ return join(dirname(createRequire(import.meta.url).resolve("@carlos-space/mainflow-web/package.json")), "dist");
623
+ }
624
+ /**
625
+ * Serve the API plus the built web client; unknown paths fall back to index.html for client-side routing.
626
+ * Resolves once the port is listening. A port taken between the free-port probe and here rejects with a
627
+ * WriteError instead of Node's unhandled 'error' event and stack trace.
628
+ */
629
+ function serveUi(root, port, open = openInApp) {
630
+ const app = createUiApp(root);
631
+ const dist = webDir();
632
+ const hub = loadHub(root, { remotes: false }).config.hub;
633
+ app.get("/open/*", (c) => {
634
+ const url = new URL(c.req.url);
635
+ const target = `${url.pathname.slice(5) || "/"}${url.search}`;
636
+ const opened = open(hub, target);
637
+ return c.html(openPage(target, opened));
638
+ });
639
+ app.get("*", (c) => {
640
+ let file = join(dist, normalize(decodeURIComponent(c.req.path)));
641
+ if (!file.startsWith(dist + sep) || !existsSync(file) || !statSync(file).isFile()) file = join(dist, "index.html");
642
+ return c.body(readFileSync(file), 200, { "content-type": CONTENT_TYPES[extname(file)] ?? "application/octet-stream" });
643
+ });
644
+ return new Promise((done, fail) => {
645
+ const server = serve({
646
+ fetch: app.fetch,
647
+ port
648
+ }, () => done(server));
649
+ server.once("error", (error) => fail(error.code === "EADDRINUSE" ? new WriteError(`port ${port} is already in use; run mainflow ui again to pick another`) : error));
650
+ });
651
+ }
652
+ /** The page an /open link leaves in the browser: blank in the interface's colours, or a jump to the address. */
653
+ function openPage(target, opened) {
654
+ const escaped = target.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
655
+ return `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark">${opened ? "" : `<meta http-equiv="refresh" content="0;url=${escaped}">`}<title>mainflow</title><style>html{background:#fff}@media (prefers-color-scheme: dark){html{background:#111113}}</style>`;
656
+ }
657
+ //#endregion
658
+ export { serveUi };