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.
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +817 -213
- package/dist/facts.d.ts +64 -2
- package/dist/facts.js +64 -3
- package/dist/grant-commands.d.ts +1 -1
- package/dist/hooks.d.ts +514 -3
- package/dist/hooks.js +66 -35
- package/dist/host-BJnrv8xF.js +7300 -0
- package/dist/host.js +1 -1
- package/dist/index-DV6Gqu8K.d.ts +423 -0
- package/dist/{launch-BKKoCyTj.js → launch-DefacJEL.js} +2 -2
- package/dist/launch.js +1 -1
- package/dist/native/mainflow-swap-linux-x64 +0 -0
- package/dist/native/mainflow-swap.c +67 -0
- package/dist/operation-Bp4U2bx3.js +2862 -0
- package/dist/retro-formats-ECy0gGOL.js +30 -0
- package/dist/ui-CiapFoik.js +658 -0
- package/dist/web/assets/index-CyQML0b0.css +1 -0
- package/dist/web/assets/index-tAb99n75.js +82 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -1
- package/dist/host-CeN3dvXg.js +0 -3268
- package/dist/index-bIFT1ORw.d.ts +0 -46
- package/dist/ui-D_9x6XNs.js +0 -501
- package/dist/web/assets/index-BUxmYhLT.css +0 -1
- package/dist/web/assets/index-ypKt0hMg.js +0 -80
|
@@ -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, "&").replace(/"/g, """).replace(/</g, "<");
|
|
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 };
|