@marver-design/marver 0.3.1 → 0.5.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/CHANGELOG.md +151 -0
- package/README.md +2 -0
- package/dist/auth-B36fMCM3.mjs +245 -0
- package/dist/{build-Ckmyci3O.mjs → build-BrCl9hJS.mjs} +56 -14
- package/dist/cli.mjs +15 -6
- package/dist/collab-CXy8gqoz.mjs +297 -0
- package/dist/comments-Ba8mU600.mjs +90 -0
- package/dist/comments-odHzYdO3.mjs +179 -0
- package/dist/dev-DdeU-Jst.mjs +162 -0
- package/dist/{init-DolP_Ld4.mjs → init-DsCUmlCW.mjs} +2 -1
- package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
- package/dist/{plugin-Crp4CAma.mjs → plugin-BtSGAm2h.mjs} +169 -19
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/serve-D_KBK7Oy.mjs +431 -0
- package/dist/sync-CkBk-tUk.mjs +248 -0
- package/package.json +3 -1
- package/src/client/content/diagram.tsx +46 -2
- package/src/client/content/index.tsx +6 -2
- package/src/client/content/md.ts +29 -0
- package/src/client/content/palette.ts +6 -0
- package/src/client/frame-host/bridge.js +269 -3
- package/src/client/frame-host/serialize.ts +195 -0
- package/src/client/shell/App.tsx +131 -17
- package/src/client/shell/Comments.tsx +464 -0
- package/src/client/shell/Play.tsx +24 -0
- package/src/client/shell/canvas/Canvas.tsx +66 -29
- package/src/client/shell/canvas/FrameNode.tsx +130 -10
- package/src/client/shell/canvas/frame-registry.ts +18 -0
- package/src/client/shell/canvas/snapshots.ts +233 -0
- package/src/client/shell/comments-store.ts +180 -0
- package/src/client/shell/cursor-arrow-dark.svg +1 -0
- package/src/client/shell/cursor-arrow.svg +1 -0
- package/src/client/shell/hash.ts +14 -3
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/labels.ts +10 -0
- package/src/client/shell/perf.ts +92 -0
- package/src/client/shell/store.ts +101 -6
- package/src/client/shell/styles.css +196 -7
- package/src/client/stage/main.tsx +2 -0
- package/src/shared/events.ts +103 -0
- package/templates/AGENTS-embedded.md +13 -1
- package/templates/AGENTS-studio.md +13 -1
- package/templates/instructions/boards.md +6 -3
- package/templates/instructions/configure.md +28 -0
- package/templates/instructions/iterate.md +38 -0
- package/templates/instructions/publish.md +129 -0
- package/templates/instructions/reference/color.md +22 -1
- package/templates/instructions/shape.md +23 -16
- package/dist/dev-C2oTKuXe.mjs +0 -97
- package/dist/serve-OtA9Nlow.mjs +0 -207
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { i as readLog, r as listBoards, t as appendEvents } from "./comments-Ba8mU600.mjs";
|
|
2
|
+
import { claimInvite, createInvite, inviteInfo, ownerName, publicUser, revokeUser, sessionUser, signIn, signOut, updateProfile } from "./auth-B36fMCM3.mjs";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
//#region src/server/collab.ts
|
|
7
|
+
const MONTH = 2592e3;
|
|
8
|
+
const MAX_BODY = 262144;
|
|
9
|
+
/** The permission seam (SPEC-M3 §3): v1 derives from publish policy + membership;
|
|
10
|
+
* a per-user ACL can replace the internals without touching a single route. */
|
|
11
|
+
function can(user, rights, board, action) {
|
|
12
|
+
if (action === "read") return board in rights;
|
|
13
|
+
if (action === "admin") return user?.role === "owner";
|
|
14
|
+
return !!user && rights[board] === "comment";
|
|
15
|
+
}
|
|
16
|
+
const EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
17
|
+
"create",
|
|
18
|
+
"reply",
|
|
19
|
+
"edit",
|
|
20
|
+
"resolve",
|
|
21
|
+
"reopen",
|
|
22
|
+
"react",
|
|
23
|
+
"profile"
|
|
24
|
+
]);
|
|
25
|
+
const ID_RE = /^[\w-]{8,64}$/;
|
|
26
|
+
const MAX_BODY_TEXT = 1e4;
|
|
27
|
+
/** An avatar the server will store: a raster data-URI whose ACTUAL decoded bytes
|
|
28
|
+
* match the declared type. The declared MIME alone is a lie an attacker controls
|
|
29
|
+
* (base64 SVG labelled image/png); the magic bytes are the truth. Capped small -
|
|
30
|
+
* the client downscales to a 128px jpeg (~15KB), 64KB is generous headroom. */
|
|
31
|
+
function validAvatar(s) {
|
|
32
|
+
if (typeof s !== "string" || s.length > 65536) return false;
|
|
33
|
+
const m = /^data:image\/(jpeg|png|webp|gif);base64,([A-Za-z0-9+/]+={0,2})$/.exec(s);
|
|
34
|
+
if (!m) return false;
|
|
35
|
+
let head;
|
|
36
|
+
try {
|
|
37
|
+
head = Buffer.from(m[2].slice(0, 24), "base64");
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
jpeg: (b) => b[0] === 255 && b[1] === 216 && b[2] === 255,
|
|
43
|
+
png: (b) => b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71,
|
|
44
|
+
gif: (b) => b.toString("latin1", 0, 4) === "GIF8",
|
|
45
|
+
webp: (b) => b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP"
|
|
46
|
+
}[m[1]]?.(head) ?? false;
|
|
47
|
+
}
|
|
48
|
+
/** Reject anything the log must not absorb. Returns an error string or null.
|
|
49
|
+
* Events are append-only and synced by id, so acceptance is forever - validate hard. */
|
|
50
|
+
function validateEvents(incoming, log, u, board) {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const creates = /* @__PURE__ */ new Map();
|
|
53
|
+
const authors = /* @__PURE__ */ new Map();
|
|
54
|
+
for (const ev of log) {
|
|
55
|
+
if (ev.type === "create" && ev.commentId && !creates.has(ev.commentId)) creates.set(ev.commentId, ev);
|
|
56
|
+
if ((ev.type === "create" || ev.type === "reply") && ev.commentId && ev.author?.email && !authors.has(ev.commentId)) authors.set(ev.commentId, ev.author.email.toLowerCase());
|
|
57
|
+
}
|
|
58
|
+
const me = u.email.toLowerCase();
|
|
59
|
+
for (const ev of incoming) {
|
|
60
|
+
if (!ev || typeof ev !== "object") return "malformed event";
|
|
61
|
+
if (typeof ev.id !== "string" || !ID_RE.test(ev.id)) return "event id must be an 8-64 char token";
|
|
62
|
+
if (!EVENT_TYPES.has(ev.type)) return `unknown event type "${ev.type}"`;
|
|
63
|
+
if (typeof ev.ts !== "number" || ev.ts < 15778368e5 || ev.ts > now + 6e4) return "event timestamp out of bounds";
|
|
64
|
+
if (ev.board !== void 0 && ev.board !== board) return "event board does not match the endpoint";
|
|
65
|
+
if (ev.body !== void 0 && (typeof ev.body !== "string" || ev.body.length > MAX_BODY_TEXT)) return "body too long";
|
|
66
|
+
if ((ev.type === "create" || ev.type === "reply" || ev.type === "react" || ev.type === "edit") && ev.author?.email?.toLowerCase() !== me) return "event author must be the signed-in account";
|
|
67
|
+
if (typeof ev.commentId !== "string" || !ID_RE.test(ev.commentId)) {
|
|
68
|
+
if (ev.type !== "profile") return "event needs a commentId";
|
|
69
|
+
}
|
|
70
|
+
switch (ev.type) {
|
|
71
|
+
case "create":
|
|
72
|
+
if (creates.has(ev.commentId)) return "a thread with that id already exists";
|
|
73
|
+
creates.set(ev.commentId, ev);
|
|
74
|
+
if (ev.author?.email) authors.set(ev.commentId, ev.author.email.toLowerCase());
|
|
75
|
+
break;
|
|
76
|
+
case "reply":
|
|
77
|
+
if (typeof ev.parentId !== "string" || !creates.has(ev.parentId) && !incoming.some((x) => x.type === "create" && x.commentId === ev.parentId)) return "reply parent does not exist";
|
|
78
|
+
if (ev.author?.email) authors.set(ev.commentId, ev.author.email.toLowerCase());
|
|
79
|
+
break;
|
|
80
|
+
case "edit": {
|
|
81
|
+
const owner = authors.get(ev.commentId);
|
|
82
|
+
if (!owner) return "cannot edit a comment that does not exist";
|
|
83
|
+
if (owner !== me) return "only the author can edit a comment";
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
case "resolve":
|
|
87
|
+
case "reopen": if (!creates.has(ev.commentId) && !incoming.some((x) => x.type === "create" && x.commentId === ev.commentId)) return "cannot resolve a thread that does not exist";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
function collabHandler(dataDir, distDir) {
|
|
93
|
+
let rights = {};
|
|
94
|
+
try {
|
|
95
|
+
rights = JSON.parse(readFileSync(join(distDir, "meta.json"), "utf8")).rights ?? {};
|
|
96
|
+
} catch {}
|
|
97
|
+
const commentsDir = join(dataDir, "comments");
|
|
98
|
+
const bootEpoch = randomBytes(4).toString("hex");
|
|
99
|
+
let seq = 0;
|
|
100
|
+
const ring = [];
|
|
101
|
+
const clients = /* @__PURE__ */ new Set();
|
|
102
|
+
const broadcast = (board, events) => {
|
|
103
|
+
for (const ev of events) {
|
|
104
|
+
const frame = `id: ${bootEpoch}-${++seq}\nevent: comment\ndata: ${JSON.stringify({
|
|
105
|
+
board,
|
|
106
|
+
ev
|
|
107
|
+
})}\n\n`;
|
|
108
|
+
ring.push({
|
|
109
|
+
seq,
|
|
110
|
+
data: frame
|
|
111
|
+
});
|
|
112
|
+
if (ring.length > 500) ring.shift();
|
|
113
|
+
for (const res of clients) res.write(frame);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
setInterval(() => {
|
|
117
|
+
for (const res of clients) res.write(": keepalive\n\n");
|
|
118
|
+
}, 24e4).unref();
|
|
119
|
+
const attempts = /* @__PURE__ */ new Map();
|
|
120
|
+
const limited = (...keys) => {
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
if (attempts.size > 1e4) {
|
|
123
|
+
for (const [k, a] of attempts) if (a.reset < now) attempts.delete(k);
|
|
124
|
+
}
|
|
125
|
+
if (attempts.size > 2e4) return true;
|
|
126
|
+
let hit = false;
|
|
127
|
+
for (const key of keys) {
|
|
128
|
+
const a = attempts.get(key);
|
|
129
|
+
if (!a || a.reset < now) attempts.set(key, {
|
|
130
|
+
n: 1,
|
|
131
|
+
reset: now + 6e4
|
|
132
|
+
});
|
|
133
|
+
else if (++a.n > 10) hit = true;
|
|
134
|
+
}
|
|
135
|
+
return hit;
|
|
136
|
+
};
|
|
137
|
+
const cookie = (req, name) => new RegExp(`(?:^|;\\s*)${name}=([\\w-]+)`).exec(String(req.headers.cookie ?? ""))?.[1];
|
|
138
|
+
const currentUser = (req) => {
|
|
139
|
+
const tok = /^Bearer ([\w-]+)$/.exec(String(req.headers.authorization ?? ""))?.[1] ?? cookie(req, "mv_s");
|
|
140
|
+
return tok ? sessionUser(dataDir, tok) : null;
|
|
141
|
+
};
|
|
142
|
+
const json = (res, code, body) => {
|
|
143
|
+
res.statusCode = code;
|
|
144
|
+
res.setHeader("content-type", "application/json");
|
|
145
|
+
res.setHeader("cache-control", "no-store");
|
|
146
|
+
res.end(JSON.stringify(body));
|
|
147
|
+
};
|
|
148
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
149
|
+
if (!/^application\/json\b/.test(String(req.headers["content-type"] ?? ""))) return reject(/* @__PURE__ */ new Error("content-type must be application/json"));
|
|
150
|
+
let body = "";
|
|
151
|
+
req.on("data", (c) => {
|
|
152
|
+
body += c;
|
|
153
|
+
if (body.length > MAX_BODY) {
|
|
154
|
+
reject(/* @__PURE__ */ new Error("body too large"));
|
|
155
|
+
req.destroy();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
req.on("end", () => {
|
|
159
|
+
try {
|
|
160
|
+
resolve(body ? JSON.parse(body) : {});
|
|
161
|
+
} catch {
|
|
162
|
+
reject(/* @__PURE__ */ new Error("bad json"));
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
const setSession = (req, res, token) => {
|
|
167
|
+
const secure = req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
|
|
168
|
+
res.setHeader("set-cookie", [`mv_s=${token}; Path=/; Max-Age=${MONTH}; HttpOnly; SameSite=Lax${secure}`, `mv_c=${randomBytes(16).toString("base64url")}; Path=/; Max-Age=${MONTH}; SameSite=Lax${secure}`]);
|
|
169
|
+
};
|
|
170
|
+
const csrfOk = (req) => !!/^Bearer /.test(String(req.headers.authorization ?? "")) || !cookie(req, "mv_s") || !!cookie(req, "mv_c") && req.headers["x-mv-c"] === cookie(req, "mv_c");
|
|
171
|
+
/** Returns true when the request was handled. Mounted behind the gate by serve. */
|
|
172
|
+
return async (req, res, url) => {
|
|
173
|
+
if (!url.pathname.startsWith("/__mv/api/")) return false;
|
|
174
|
+
const path = url.pathname.slice(10);
|
|
175
|
+
const ip = process.env.MARVER_TRUSTED_PROXY ? String(req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "?").split(",")[0].trim() : String(req.socket.remoteAddress ?? "?");
|
|
176
|
+
try {
|
|
177
|
+
if (req.method === "GET") {
|
|
178
|
+
if (path === "me") {
|
|
179
|
+
const u = currentUser(req);
|
|
180
|
+
return json(res, u ? 200 : 401, u ? {
|
|
181
|
+
user: publicUser(u),
|
|
182
|
+
role: u.role
|
|
183
|
+
} : { error: "signed out" }), true;
|
|
184
|
+
}
|
|
185
|
+
if (path === "boards") {
|
|
186
|
+
const name = ownerName(dataDir);
|
|
187
|
+
return json(res, 200, {
|
|
188
|
+
rights,
|
|
189
|
+
stored: listBoards(commentsDir),
|
|
190
|
+
...name ? { owner: { name } } : {}
|
|
191
|
+
}), true;
|
|
192
|
+
}
|
|
193
|
+
const m = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
|
|
194
|
+
if (m) {
|
|
195
|
+
if (!can(currentUser(req), rights, m[1], "read")) return json(res, 404, { error: "no such board" }), true;
|
|
196
|
+
return json(res, 200, { events: readLog(commentsDir, m[1]) }), true;
|
|
197
|
+
}
|
|
198
|
+
if (path === "invite-info") {
|
|
199
|
+
if (limited(`ip:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
200
|
+
const info = inviteInfo(dataDir, String(url.searchParams.get("token") ?? ""));
|
|
201
|
+
return json(res, info ? 200 : 404, info ?? { error: "this invite link is invalid, expired, or already used" }), true;
|
|
202
|
+
}
|
|
203
|
+
if (path === "events") {
|
|
204
|
+
res.statusCode = 200;
|
|
205
|
+
res.setHeader("content-type", "text/event-stream");
|
|
206
|
+
res.setHeader("cache-control", "no-store");
|
|
207
|
+
res.setHeader("x-accel-buffering", "no");
|
|
208
|
+
res.write(": connected\n\n");
|
|
209
|
+
const lastRaw = String(req.headers["last-event-id"] ?? "");
|
|
210
|
+
const lm = /^([0-9a-f]{8})-(\d+)$/.exec(lastRaw);
|
|
211
|
+
if (lastRaw) {
|
|
212
|
+
const last = lm && lm[1] === bootEpoch ? Number(lm[2]) : NaN;
|
|
213
|
+
if (!Number.isFinite(last)) res.write("event: resync\ndata: {}\n\n");
|
|
214
|
+
else {
|
|
215
|
+
const missed = ring.filter((r) => r.seq > last);
|
|
216
|
+
if (missed.length && missed[0].seq !== last + 1) res.write("event: resync\ndata: {}\n\n");
|
|
217
|
+
else for (const r of missed) res.write(r.data);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
clients.add(res);
|
|
221
|
+
req.on("close", () => clients.delete(res));
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
if (req.method !== "POST") return false;
|
|
227
|
+
if (path !== "auth/claim" && path !== "auth/signin" && !csrfOk(req)) return json(res, 403, { error: "missing or stale request token - reload the page" }), true;
|
|
228
|
+
if (path === "auth/claim") {
|
|
229
|
+
if (limited(`ip:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
230
|
+
const b = await readBody(req);
|
|
231
|
+
const { user, session } = claimInvite(dataDir, String(b.token ?? ""), {
|
|
232
|
+
password: String(b.password ?? ""),
|
|
233
|
+
name: String(b.name ?? ""),
|
|
234
|
+
avatar: validAvatar(b.avatar) ? b.avatar : void 0
|
|
235
|
+
});
|
|
236
|
+
setSession(req, res, session);
|
|
237
|
+
return json(res, 200, { user: publicUser(user) }), true;
|
|
238
|
+
}
|
|
239
|
+
if (path === "auth/signin") {
|
|
240
|
+
const b = await readBody(req);
|
|
241
|
+
const email = String(b.email ?? "").trim().toLowerCase();
|
|
242
|
+
if (limited(`ip:${ip}`, `em:${email}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
243
|
+
const hit = signIn(dataDir, email, String(b.password ?? ""));
|
|
244
|
+
if (!hit) return json(res, 401, { error: "wrong email or password" }), true;
|
|
245
|
+
setSession(req, res, hit.session);
|
|
246
|
+
return json(res, 200, { user: publicUser(hit.user) }), true;
|
|
247
|
+
}
|
|
248
|
+
if (path === "auth/signout") {
|
|
249
|
+
const tok = cookie(req, "mv_s");
|
|
250
|
+
if (tok) signOut(dataDir, tok);
|
|
251
|
+
res.setHeader("set-cookie", ["mv_s=; Path=/; Max-Age=0; HttpOnly", "mv_c=; Path=/; Max-Age=0"]);
|
|
252
|
+
return json(res, 200, {}), true;
|
|
253
|
+
}
|
|
254
|
+
if (path === "profile") {
|
|
255
|
+
const u = currentUser(req);
|
|
256
|
+
if (!u) return json(res, 401, { error: "sign in first" }), true;
|
|
257
|
+
const b = await readBody(req);
|
|
258
|
+
const next = updateProfile(dataDir, u.email, {
|
|
259
|
+
name: typeof b.name === "string" ? b.name : void 0,
|
|
260
|
+
avatar: b.avatar === "" ? "" : validAvatar(b.avatar) ? b.avatar : void 0
|
|
261
|
+
});
|
|
262
|
+
return json(res, 200, { user: publicUser(next) }), true;
|
|
263
|
+
}
|
|
264
|
+
if (path === "invite") {
|
|
265
|
+
if (!can(currentUser(req), rights, "", "admin")) return json(res, 403, { error: "owner only" }), true;
|
|
266
|
+
const b = await readBody(req);
|
|
267
|
+
const email = String(b.email ?? "");
|
|
268
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return json(res, 400, { error: "a valid email is required" }), true;
|
|
269
|
+
return json(res, 200, createInvite(dataDir, email)), true;
|
|
270
|
+
}
|
|
271
|
+
if (path === "revoke") {
|
|
272
|
+
if (!can(currentUser(req), rights, "", "admin")) return json(res, 403, { error: "owner only" }), true;
|
|
273
|
+
const b = await readBody(req);
|
|
274
|
+
revokeUser(dataDir, String(b.email ?? ""));
|
|
275
|
+
return json(res, 200, {}), true;
|
|
276
|
+
}
|
|
277
|
+
const m = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
|
|
278
|
+
if (m) {
|
|
279
|
+
const u = currentUser(req);
|
|
280
|
+
if (!can(u, rights, m[1], "comment")) return json(res, u ? 403 : 401, { error: u ? `this board is read-only - ask ${ownerName(dataDir) ?? "the canvas owner"} for commenting access` : "sign in to comment" }), true;
|
|
281
|
+
const b = await readBody(req);
|
|
282
|
+
const incoming = Array.isArray(b.events) ? b.events : [];
|
|
283
|
+
if (incoming.length > 100) return json(res, 400, { error: "too many events in one push" }), true;
|
|
284
|
+
const bad = validateEvents(incoming, readLog(commentsDir, m[1]), u, m[1]);
|
|
285
|
+
if (bad) return json(res, 400, { error: bad }), true;
|
|
286
|
+
const fresh = appendEvents(commentsDir, m[1], incoming);
|
|
287
|
+
broadcast(m[1], fresh);
|
|
288
|
+
return json(res, 200, { accepted: fresh.length }), true;
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
return json(res, 400, { error: err.message }), true;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
297
|
+
export { collabHandler };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, writeSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
//#region src/server/comments.ts
|
|
5
|
+
/**
|
|
6
|
+
* The comment event store (SPEC-M3 §1) - an append-only JSONL log, one file per board.
|
|
7
|
+
*
|
|
8
|
+
* Events are immutable and identified by client-generated UUIDs; two logs merge by SET
|
|
9
|
+
* UNION on id. That single rule is the entire sync protocol: idempotent, order-
|
|
10
|
+
* independent, retry-safe, tolerant of a deploy-overlap double-writer. Current thread
|
|
11
|
+
* state is derived by replaying events (ordered by ts then id, last writer wins per
|
|
12
|
+
* field). Files, not a database - tens to low hundreds of events per board.
|
|
13
|
+
*/
|
|
14
|
+
var comments_exports = /* @__PURE__ */ __exportAll({
|
|
15
|
+
appendEvents: () => appendEvents,
|
|
16
|
+
dataDir: () => dataDir,
|
|
17
|
+
listBoards: () => listBoards,
|
|
18
|
+
readLog: () => readLog
|
|
19
|
+
});
|
|
20
|
+
const BOARD_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
21
|
+
const boardFile = (dir, board) => {
|
|
22
|
+
if (!BOARD_RE.test(board)) throw new Error(`bad board name: ${board}`);
|
|
23
|
+
return join(dir, `${board}.jsonl`);
|
|
24
|
+
};
|
|
25
|
+
/** Read one board's log. Unparseable lines are skipped, never fatal - an interrupted
|
|
26
|
+
* append must not take the whole board down. Duplicate ids keep the first occurrence. */
|
|
27
|
+
function readLog(dir, board) {
|
|
28
|
+
const file = boardFile(dir, board);
|
|
29
|
+
if (!existsSync(file)) return [];
|
|
30
|
+
const seen = /* @__PURE__ */ new Set();
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
33
|
+
if (!line.trim()) continue;
|
|
34
|
+
let ev;
|
|
35
|
+
try {
|
|
36
|
+
ev = JSON.parse(line);
|
|
37
|
+
} catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (typeof ev?.id !== "string" || !ev.id || seen.has(ev.id)) continue;
|
|
41
|
+
seen.add(ev.id);
|
|
42
|
+
out.push(ev);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
/** Append events the log does not already hold; returns the ones actually written.
|
|
47
|
+
* This IS the merge rule - callers never need another. */
|
|
48
|
+
function appendEvents(dir, board, events) {
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
const have = new Set(readLog(dir, board).map((e) => e.id));
|
|
51
|
+
const fresh = events.filter((e) => typeof e?.id === "string" && e.id && !have.has(e.id) && (have.add(e.id), true));
|
|
52
|
+
if (fresh.length) {
|
|
53
|
+
const file = boardFile(dir, board);
|
|
54
|
+
let lead = "";
|
|
55
|
+
if (existsSync(file)) {
|
|
56
|
+
const size = statSync(file).size;
|
|
57
|
+
if (size > 0) {
|
|
58
|
+
const fd = openSync(file, "r");
|
|
59
|
+
const last = Buffer.alloc(1);
|
|
60
|
+
readSync(fd, last, 0, 1, size - 1);
|
|
61
|
+
closeSync(fd);
|
|
62
|
+
if (last[0] !== 10) lead = "\n";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const fd = openSync(file, "a");
|
|
66
|
+
try {
|
|
67
|
+
writeSync(fd, lead + fresh.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
68
|
+
fsyncSync(fd);
|
|
69
|
+
} finally {
|
|
70
|
+
closeSync(fd);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return fresh;
|
|
74
|
+
}
|
|
75
|
+
/** Board names present in a log directory. */
|
|
76
|
+
function listBoards(dir) {
|
|
77
|
+
if (!existsSync(dir)) return [];
|
|
78
|
+
return readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => f.slice(0, -6)).filter((n) => BOARD_RE.test(n)).sort();
|
|
79
|
+
}
|
|
80
|
+
/** Where serve keeps its live store: MARVER_DATA_DIR is REQUIRED when collaboration is
|
|
81
|
+
* on - failing loudly beats silently writing into an ephemeral working directory that a
|
|
82
|
+
* redeploy wipes (SPEC-M3 §2). */
|
|
83
|
+
function dataDir() {
|
|
84
|
+
const dir = process.env.MARVER_DATA_DIR;
|
|
85
|
+
if (!dir) throw new Error("collaboration needs a durable home: set MARVER_DATA_DIR to a directory on a persistent volume (comments written anywhere else would vanish on redeploy)");
|
|
86
|
+
mkdirSync(join(dir, "comments"), { recursive: true });
|
|
87
|
+
return dir;
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
90
|
+
export { readLog as i, comments_exports as n, listBoards as r, appendEvents as t };
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { n as NAME } from "./cli.mjs";
|
|
2
|
+
import { i as syncOnce, n as connectClaim, o as replay, r as loadCollab, t as connect } from "./sync-CkBk-tUk.mjs";
|
|
3
|
+
import { i as readLog, r as listBoards, t as appendEvents } from "./comments-Ba8mU600.mjs";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
//#region src/cli/comments.ts
|
|
9
|
+
/**
|
|
10
|
+
* `marver comments <action>` (SPEC-M3 §2, §9) - the collaboration CLI.
|
|
11
|
+
*
|
|
12
|
+
* connect <url> sign in (or --invite <token> to claim) against a published canvas;
|
|
13
|
+
* stores the device credential in design/.local/collab.json
|
|
14
|
+
* sync one full exchange with the publish target (agent/CI path)
|
|
15
|
+
* list threads from design/comments/ (--open, --board, --json)
|
|
16
|
+
* reply <thread> append a reply (--body) - the agent's voice in the loop
|
|
17
|
+
* resolve <thread> mark addressed (--addressed-in <frame> records WHICH variant)
|
|
18
|
+
* invite <email> mint a single-use invite link (owner only, needs connect first)
|
|
19
|
+
* revoke <email> revoke an account and its sessions (owner only)
|
|
20
|
+
*/
|
|
21
|
+
const ask = (q, hide = false) => new Promise((done) => {
|
|
22
|
+
const rl = createInterface({
|
|
23
|
+
input: process.stdin,
|
|
24
|
+
output: process.stdout
|
|
25
|
+
});
|
|
26
|
+
if (hide) {
|
|
27
|
+
const out = process.stdout;
|
|
28
|
+
const orig = rl._writeToOutput?.bind(rl);
|
|
29
|
+
rl._writeToOutput = (s) => {
|
|
30
|
+
if (s.includes("\n")) orig?.(s);
|
|
31
|
+
else {
|
|
32
|
+
out.clearLine?.(0);
|
|
33
|
+
out.cursorTo?.(0);
|
|
34
|
+
out.write(q);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
rl.question(q, (a) => {
|
|
39
|
+
rl.close();
|
|
40
|
+
if (hide) process.stdout.write("\n");
|
|
41
|
+
done(a.trim());
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
const allThreads = (root, board) => {
|
|
45
|
+
const dir = join(root, "design", "comments");
|
|
46
|
+
return (board ? [board] : listBoards(dir)).flatMap((b) => replay(readLog(dir, b)).map((t) => ({
|
|
47
|
+
...t,
|
|
48
|
+
board: b
|
|
49
|
+
})));
|
|
50
|
+
};
|
|
51
|
+
async function commentsCommand(root, action, value, opts) {
|
|
52
|
+
switch (action) {
|
|
53
|
+
case "connect": {
|
|
54
|
+
if (!value) throw new Error("usage: comments connect <published-url>");
|
|
55
|
+
const canvasPassword = opts.canvasPassword ?? process.env.MARVER_PASSWORD ?? await ask("canvas password (blank if the canvas is ungated): ", true);
|
|
56
|
+
if (opts.invite) {
|
|
57
|
+
const name = opts.name ?? await ask("display name: ");
|
|
58
|
+
const password = opts.password ?? await ask("choose a password: ", true);
|
|
59
|
+
await connectClaim(root, value, String(opts.invite), {
|
|
60
|
+
password,
|
|
61
|
+
name
|
|
62
|
+
}, canvasPassword || void 0);
|
|
63
|
+
} else {
|
|
64
|
+
const email = opts.email ?? await ask("email: ");
|
|
65
|
+
const password = opts.password ?? await ask("password: ", true);
|
|
66
|
+
await connect(root, value, email, password, canvasPassword || void 0);
|
|
67
|
+
}
|
|
68
|
+
console.log(`[${NAME}] connected - design/.local/collab.json holds the device credential (gitignored)`);
|
|
69
|
+
console.log(`[${NAME}] \`${NAME} dev\` now syncs comments every 30s; \`${NAME} comments sync\` does one exchange`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
case "sync": {
|
|
73
|
+
const collab = loadCollab(root);
|
|
74
|
+
if (!collab) throw new Error(`not connected - run \`${NAME} comments connect <url>\` first`);
|
|
75
|
+
const out = await syncOnce(root, collab);
|
|
76
|
+
for (const [b, n] of Object.entries(out)) console.log(` ${b}: pulled ${n.pulled}, pushed ${n.pushed}`);
|
|
77
|
+
if (!Object.keys(out).length) console.log(" nothing to sync");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
case "list": {
|
|
81
|
+
let threads = allThreads(root, opts.board);
|
|
82
|
+
if (opts.open) threads = threads.filter((t) => !t.resolved);
|
|
83
|
+
if (opts.json) return void console.log(JSON.stringify(threads, null, 2));
|
|
84
|
+
if (!threads.length) return void console.log(` no ${opts.open ? "open " : ""}comments`);
|
|
85
|
+
for (const t of threads) {
|
|
86
|
+
const anchor = t.frame ? ` on ${t.frame}` : "";
|
|
87
|
+
console.log(` ${t.resolved ? "✓" : "○"} ${t.id}${anchor} - ${t.author?.name ?? "?"}: ${t.body ?? ""}${t.replies.length ? ` (+${t.replies.length})` : ""}${t.addressedIn ? ` → ${t.addressedIn}` : ""}`);
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
case "reply": {
|
|
92
|
+
if (!value || !opts.body) throw new Error("usage: comments reply <thread-id> --body \"...\"");
|
|
93
|
+
const t = allThreads(root).find((t) => t.id === value);
|
|
94
|
+
if (!t) throw new Error(`no thread ${value} in design/comments/`);
|
|
95
|
+
appendEvents(join(root, "design", "comments"), t.board, [{
|
|
96
|
+
id: randomUUID(),
|
|
97
|
+
ts: Date.now(),
|
|
98
|
+
type: "reply",
|
|
99
|
+
commentId: randomUUID(),
|
|
100
|
+
parentId: t.id,
|
|
101
|
+
author: localAuthor(root),
|
|
102
|
+
body: String(opts.body)
|
|
103
|
+
}]);
|
|
104
|
+
await pushIfConnected(root);
|
|
105
|
+
console.log(` replied to ${t.id}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
case "resolve": {
|
|
109
|
+
if (!value) throw new Error("usage: comments resolve <thread-id> [--addressed-in <frame>]");
|
|
110
|
+
const t = allThreads(root).find((t) => t.id === value);
|
|
111
|
+
if (!t) throw new Error(`no thread ${value} in design/comments/`);
|
|
112
|
+
appendEvents(join(root, "design", "comments"), t.board, [{
|
|
113
|
+
id: randomUUID(),
|
|
114
|
+
ts: Date.now(),
|
|
115
|
+
type: "resolve",
|
|
116
|
+
commentId: t.id,
|
|
117
|
+
...opts.addressedIn ? { addressedIn: String(opts.addressedIn) } : {}
|
|
118
|
+
}]);
|
|
119
|
+
await pushIfConnected(root);
|
|
120
|
+
console.log(` resolved ${t.id}${opts.addressedIn ? ` - addressed in ${opts.addressedIn}` : ""}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
case "invite": {
|
|
124
|
+
if (!value || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) throw new Error("usage: comments invite <email>");
|
|
125
|
+
const { token } = await ownerApi(root, "invite", { email: value });
|
|
126
|
+
const url = loadCollab(root).url.replace(/\/+$/, "");
|
|
127
|
+
console.log(` invite for ${value} (single-use, 7 days):`);
|
|
128
|
+
console.log(` ${url}/#/i/${token}`);
|
|
129
|
+
console.log(` send them that link (plus the canvas password) - it opens straight into`);
|
|
130
|
+
console.log(` "pick your name and password".`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
case "revoke":
|
|
134
|
+
if (!value) throw new Error("usage: comments revoke <email>");
|
|
135
|
+
await ownerApi(root, "revoke", { email: value });
|
|
136
|
+
console.log(` revoked ${value} - their sessions are dead`);
|
|
137
|
+
return;
|
|
138
|
+
default: throw new Error(`unknown action "${action}" - connect | sync | list | reply | resolve | invite | revoke`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Owner-only account call against the connected canvas, via the device credential. */
|
|
142
|
+
const ownerApi = async (root, path, body) => {
|
|
143
|
+
const collab = loadCollab(root);
|
|
144
|
+
if (!collab) throw new Error(`not connected - run \`${NAME} comments connect <url>\` first`);
|
|
145
|
+
const res = await fetch(`${collab.url.replace(/\/+$/, "")}/__mv/api/${path}`, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: {
|
|
148
|
+
authorization: `Bearer ${collab.token}`,
|
|
149
|
+
"content-type": "application/json"
|
|
150
|
+
},
|
|
151
|
+
body: JSON.stringify(body)
|
|
152
|
+
});
|
|
153
|
+
const data = await res.json().catch(() => ({}));
|
|
154
|
+
if (!res.ok) throw new Error(data.error ?? `canvas answered ${res.status}`);
|
|
155
|
+
return data;
|
|
156
|
+
};
|
|
157
|
+
const pushIfConnected = async (root) => {
|
|
158
|
+
const collab = loadCollab(root);
|
|
159
|
+
if (collab) await syncOnce(root, collab).catch((e) => console.log(` (push deferred: ${e.message})`));
|
|
160
|
+
};
|
|
161
|
+
/** Author snapshot for CLI-born events: the connected account (the server validates
|
|
162
|
+
* authors against the session - an authorless push would be rejected), else the
|
|
163
|
+
* local dev profile. */
|
|
164
|
+
const localAuthor = (root) => {
|
|
165
|
+
const collab = loadCollab(root);
|
|
166
|
+
if (collab?.email) return {
|
|
167
|
+
email: collab.email,
|
|
168
|
+
name: collab.name
|
|
169
|
+
};
|
|
170
|
+
try {
|
|
171
|
+
const p = JSON.parse(readFileSync(join(root, "design", ".local", "profile.json"), "utf8"));
|
|
172
|
+
if (p?.email) return {
|
|
173
|
+
email: p.email,
|
|
174
|
+
name: p.name
|
|
175
|
+
};
|
|
176
|
+
} catch {}
|
|
177
|
+
};
|
|
178
|
+
//#endregion
|
|
179
|
+
export { commentsCommand };
|