@marver-design/marver 0.7.0 → 0.8.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/{build-BZaPa2DS.mjs → build-B8z902TW.mjs} +2 -2
  3. package/dist/cli.mjs +5 -5
  4. package/dist/{collab-CXy8gqoz.mjs → collab-pLGzSvm5.mjs} +16 -5
  5. package/dist/{comments-odHzYdO3.mjs → comments-BrpC86Kf.mjs} +9 -15
  6. package/dist/{comments-Ba8mU600.mjs → comments-DSwMhdbZ.mjs} +2 -8
  7. package/dist/daemon-C-huGHAM.mjs +899 -0
  8. package/dist/{dev-DaPQ9xA5.mjs → dev-BYm9ebfN.mjs} +14 -3
  9. package/dist/events-BMtBvvgU.mjs +101 -0
  10. package/dist/{init-DsCUmlCW.mjs → init-DrLomSWq.mjs} +13 -1
  11. package/dist/ledger-wFvEIEGi.mjs +64 -0
  12. package/dist/{manifest-C8FODq2S.mjs → manifest-D3eaARf4.mjs} +16 -1
  13. package/dist/{plugin-wMY9lNf3.mjs → plugin-BVFuRfEo.mjs} +56 -40
  14. package/dist/profile-BkiWglVE.mjs +39 -0
  15. package/dist/{serve-D_KBK7Oy.mjs → serve-CwAfayJk.mjs} +3 -3
  16. package/dist/{sync-CkBk-tUk.mjs → sync-Ch4Bymb1.mjs} +4 -102
  17. package/package.json +1 -1
  18. package/src/client/frame-host/inspect.js +15 -2
  19. package/src/client/shell/App.tsx +91 -9
  20. package/src/client/shell/Comments.tsx +418 -68
  21. package/src/client/shell/Play.tsx +10 -6
  22. package/src/client/shell/Toolbar.tsx +14 -2
  23. package/src/client/shell/canvas/FrameNode.tsx +33 -6
  24. package/src/client/shell/comments-store.ts +47 -6
  25. package/src/client/shell/icons.tsx +4 -0
  26. package/src/client/shell/mentions.ts +18 -0
  27. package/src/client/shell/store.ts +30 -2
  28. package/src/client/shell/styles.css +294 -21
  29. package/src/shared/events.ts +22 -5
  30. package/templates/AGENTS-embedded.md +1 -0
  31. package/templates/AGENTS-studio.md +1 -0
  32. package/templates/instructions/configure.md +15 -0
  33. package/templates/instructions/jam.md +85 -0
  34. package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
@@ -1,6 +1,6 @@
1
1
  import { n as NAME, r as PKG } from "./cli.mjs";
2
- import { o as loadConfig, s as detectHost } from "./manifest-C8FODq2S.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-wMY9lNf3.mjs";
2
+ import { o as loadConfig, s as detectHost } from "./manifest-D3eaARf4.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BVFuRfEo.mjs";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createLogger, createServer, searchForWorkspaceRoot } from "vite";
@@ -141,7 +141,7 @@ async function dev(root, portFlag) {
141
141
  const port = typeof addr === "object" && addr ? addr.port : picked.port;
142
142
  if (picked.fellBack) console.log(`\n port ${desiredPort} is in use - serving "${projectName}" on ${port} instead`);
143
143
  console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
144
- const { loadCollab, syncOnce } = await import("./sync-CkBk-tUk.mjs").then((n) => n.a);
144
+ const { loadCollab, syncOnce } = await import("./sync-Ch4Bymb1.mjs");
145
145
  if (loadCollab(root)) console.log(` comments: syncing with the published canvas (design/.local/collab.json)\n`);
146
146
  let syncing = false;
147
147
  const tick = async () => {
@@ -156,6 +156,17 @@ async function dev(root, portFlag) {
156
156
  };
157
157
  tick();
158
158
  setInterval(tick, 3e4).unref();
159
+ if (config.jam?.agent) {
160
+ const { startJam } = await import("./daemon-C-huGHAM.mjs");
161
+ const jam = startJam(root, config.jam, (m) => console.log(m), (frames) => server.ws.send("sh:jam-activity", { frames }), (board) => server.ws.send("sh:jam-comment", { board }));
162
+ if (jam) {
163
+ const close = server.close.bind(server);
164
+ server.close = (async () => {
165
+ jam.stop();
166
+ return close();
167
+ });
168
+ }
169
+ }
159
170
  return server;
160
171
  }
161
172
  //#endregion
@@ -0,0 +1,101 @@
1
+ //#region src/shared/events.ts
2
+ /** Events the other side lacks, by id - the sync payload in either direction. */
3
+ function diffEvents(mine, theirIds) {
4
+ const have = new Set(theirIds);
5
+ return mine.filter((e) => !have.has(e.id));
6
+ }
7
+ /** Replay a board's events into current thread state. Deterministic: replay order is
8
+ * (ts, id) so two stores holding the same event SET always derive the same state.
9
+ * TWO passes - creates first, then everything else - so a reply whose author's clock
10
+ * ran ahead of the creator's still lands instead of being dropped forever. */
11
+ function replay(events) {
12
+ const ordered = [...events].sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1));
13
+ const threads = /* @__PURE__ */ new Map();
14
+ for (const ev of ordered) {
15
+ if (ev.type !== "create") continue;
16
+ if (!ev.commentId || threads.has(ev.commentId)) continue;
17
+ threads.set(ev.commentId, {
18
+ id: ev.commentId,
19
+ board: ev.board,
20
+ nodeKey: ev.nodeKey,
21
+ frame: ev.frame,
22
+ anchor: ev.anchor,
23
+ author: ev.author,
24
+ body: ev.body,
25
+ ts: ev.ts,
26
+ resolved: false,
27
+ agent: ev.agent,
28
+ agentMeta: ev.agentMeta,
29
+ replies: [],
30
+ reactions: {}
31
+ });
32
+ }
33
+ for (const ev of ordered) switch (ev.type) {
34
+ case "reply": {
35
+ const t = ev.parentId ? threads.get(ev.parentId) : void 0;
36
+ if (!t || !ev.commentId || t.replies.some((r) => r.id === ev.commentId)) break;
37
+ t.replies.push({
38
+ id: ev.commentId,
39
+ author: ev.author,
40
+ body: ev.body,
41
+ ts: ev.ts,
42
+ agent: ev.agent,
43
+ agentMeta: ev.agentMeta
44
+ });
45
+ break;
46
+ }
47
+ case "edit": {
48
+ if (!ev.commentId || ev.body === void 0) break;
49
+ const t = threads.get(ev.commentId);
50
+ if (t) {
51
+ t.body = ev.body;
52
+ break;
53
+ }
54
+ for (const th of threads.values()) {
55
+ const r = th.replies.find((r) => r.id === ev.commentId);
56
+ if (r) {
57
+ r.body = ev.body;
58
+ break;
59
+ }
60
+ }
61
+ break;
62
+ }
63
+ case "resolve": {
64
+ const t = ev.commentId ? threads.get(ev.commentId) : void 0;
65
+ if (t) {
66
+ t.resolved = true;
67
+ if (ev.addressedIn) t.addressedIn = ev.addressedIn;
68
+ }
69
+ break;
70
+ }
71
+ case "reopen": {
72
+ const t = ev.commentId ? threads.get(ev.commentId) : void 0;
73
+ if (t) {
74
+ t.resolved = false;
75
+ t.addressedIn = void 0;
76
+ }
77
+ break;
78
+ }
79
+ case "reanchor": {
80
+ const t = ev.commentId ? threads.get(ev.commentId) : void 0;
81
+ if (t && ev.anchor != null) t.anchor = ev.anchor;
82
+ break;
83
+ }
84
+ case "react": {
85
+ const t = ev.commentId ? threads.get(ev.commentId) : void 0;
86
+ const who = ev.author?.email;
87
+ if (!t || !who || !ev.emoji) break;
88
+ const users = t.reactions[ev.emoji] ??= [];
89
+ const at = users.indexOf(who);
90
+ if (at === -1) users.push(who);
91
+ else {
92
+ users.splice(at, 1);
93
+ if (!users.length) delete t.reactions[ev.emoji];
94
+ }
95
+ break;
96
+ }
97
+ }
98
+ return [...threads.values()];
99
+ }
100
+ //#endregion
101
+ export { replay as n, diffEvents as t };
@@ -1,5 +1,5 @@
1
1
  import { n as NAME } from "./cli.mjs";
2
- import { a as DEFAULTS, c as readJson, i as writeManifest, r as scanFrames, s as detectHost } from "./manifest-C8FODq2S.mjs";
2
+ import { a as DEFAULTS, c as readJson, i as writeManifest, r as scanFrames, s as detectHost } from "./manifest-D3eaARf4.mjs";
3
3
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join, relative } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -93,6 +93,18 @@ function init(root, opts) {
93
93
  writeManaged("AGENTS.md", readFileSync(join(templates, `AGENTS-${opts.mode}.md`), "utf8").replaceAll("{{UI_GUIDANCE}}", uiGuidance(host, noApp(host))).replace(/\{\{NEXT_NOTES\}\}\n?/, host.router === "next" ? NEXT_NOTES : ""));
94
94
  const agentsNow = readFileSync(join(design, "AGENTS.md"), "utf8");
95
95
  if (!agentsNow.startsWith(MANAGED_PREFIX) && !agentsNow.startsWith(LEGACY_PREFIX) && agentsNow.includes("# Design canvas - agent contract") && !agentsNow.includes("## The method (binding)")) console.warn(` note: design/AGENTS.md predates managed regeneration - if you never edited it, delete it and re-run init to get the current contract (incl. the design/instructions routing).`);
96
+ const claudeMd = join(root, "CLAUDE.md");
97
+ const importLine = "@design/AGENTS.md";
98
+ if (!existsSync(claudeMd)) {
99
+ writeFileSync(claudeMd, `# ${NAME}\n\nThis repo designs with ${NAME}. The agent contract for the canvas lives in design/AGENTS.md:\n\n${importLine}\n`);
100
+ created.push("CLAUDE.md");
101
+ } else {
102
+ const cur = readFileSync(claudeMd, "utf8");
103
+ if (!cur.includes(importLine)) {
104
+ writeFileSync(claudeMd, cur.replace(/\n*$/, "") + `\n\n${importLine}\n`);
105
+ created.push("CLAUDE.md (updated)");
106
+ }
107
+ }
96
108
  const instrRoot = join(templates, "instructions");
97
109
  for (const e of readdirSync(instrRoot, { withFileTypes: true })) if (e.isDirectory()) {
98
110
  for (const f of readdirSync(join(instrRoot, e.name))) if (f.endsWith(".md")) writeManaged(`instructions/${e.name}/${f}`, readFileSync(join(instrRoot, e.name, f), "utf8"));
@@ -0,0 +1,64 @@
1
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ //#region \0rolldown/runtime.js
4
+ var __defProp = Object.defineProperty;
5
+ var __exportAll = (all, no_symbols) => {
6
+ let target = {};
7
+ for (var name in all) __defProp(target, name, {
8
+ get: all[name],
9
+ enumerable: true
10
+ });
11
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
12
+ return target;
13
+ };
14
+ //#endregion
15
+ //#region src/server/jam/ledger.ts
16
+ /**
17
+ * The device-bound authorization ledger (SPEC-live-jam §1) - the whole trust boundary.
18
+ *
19
+ * When the dev POST accepts an owner-gated write, it records that event's id here. The
20
+ * daemon's owner-trigger check is `has(root, id)`, never a synced field: sync copies
21
+ * `origin` byte-for-byte, so a remote comment can spoof `origin:'local'` (proven RCE),
22
+ * but it can never appear in a file that is written only on THIS machine by the gated
23
+ * POST and never synced. Synced-in events are never in the ledger, so they never trigger.
24
+ *
25
+ * One `<board>\t<id>` per line, append-only, gitignored, never synced (design/.local/ is
26
+ * watch-ignored and sync-excluded). Agent-written events are never recorded (they are
27
+ * daemon-authored, not owner input, so they cannot self-authorize a next job).
28
+ *
29
+ * The key is (board, id), NOT id alone: event ids are client UUIDs that sync copies verbatim,
30
+ * so a remote collaborator could reuse an owner's ledgered id in a NEW malicious event. Binding
31
+ * to the board it was gate-written on defeats that - the forged copy lands on some board the
32
+ * ledger never authorized for that id, so it never triggers.
33
+ */
34
+ var ledger_exports = /* @__PURE__ */ __exportAll({
35
+ has: () => has,
36
+ record: () => record
37
+ });
38
+ const ledgerFile = (root) => join(root, "design", ".local", "jam-ledger");
39
+ const line = (board, id) => `${board}\t${id}`;
40
+ /** Was this (board, id) authorized on this device by the gated dev POST? */
41
+ function has(root, board, id) {
42
+ if (!board || !id) return false;
43
+ const file = ledgerFile(root);
44
+ if (!existsSync(file)) return false;
45
+ const want = line(board, id);
46
+ for (const l of readFileSync(file, "utf8").split("\n")) if (l === want) return true;
47
+ return false;
48
+ }
49
+ /** Authorize a (board, id). fsync'd (a 200-acked, ledgered write must survive a crash) and
50
+ * 0600 (owner-only). Idempotent enough: a duplicate line is harmless, `has` matches either. */
51
+ function record(root, board, id) {
52
+ if (!board || !id) return;
53
+ const file = ledgerFile(root);
54
+ mkdirSync(dirname(file), { recursive: true });
55
+ const fd = openSync(file, "a", 384);
56
+ try {
57
+ writeSync(fd, line(board, id) + "\n");
58
+ fsyncSync(fd);
59
+ } finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+ //#endregion
64
+ export { ledger_exports as n, has as t };
@@ -169,7 +169,8 @@ async function loadConfig(root) {
169
169
  branding: user.share?.branding !== false,
170
170
  name: typeof user.share?.name === "string" ? user.share.name : void 0,
171
171
  logo: typeof user.share?.logo === "string" ? user.share.logo : void 0
172
- }
172
+ },
173
+ jam: validJam(user.jam)
173
174
  };
174
175
  } catch (err) {
175
176
  console.error(`[marver] design/config.ts failed to load, using defaults:\n ${err.message}`);
@@ -183,6 +184,20 @@ function validPort(n) {
183
184
  function validZoom(n) {
184
185
  return typeof n === "number" && Number.isFinite(n) && n >= .1 && n <= 10 ? n : null;
185
186
  }
187
+ /** Normalize the jam block. An unknown/missing `agent` means Live Jam stays OFF (returns undefined),
188
+ * so a stray `jam: {}` never silently arms the daemon. Other fields fall back to lean defaults. */
189
+ function validJam(v) {
190
+ if (!v || typeof v !== "object") return void 0;
191
+ const j = v;
192
+ if (j.agent !== "claude" && j.agent !== "codex") return void 0;
193
+ const concurrency = typeof j.concurrency === "number" && Number.isInteger(j.concurrency) && j.concurrency >= 1 && j.concurrency <= 16 ? j.concurrency : 3;
194
+ return {
195
+ agent: j.agent,
196
+ concurrency,
197
+ subagents: j.subagents !== false,
198
+ proactive: j.proactive === true
199
+ };
200
+ }
186
201
  function validViewports(v) {
187
202
  if (!v || typeof v !== "object") return null;
188
203
  const out = {};
@@ -1,5 +1,6 @@
1
1
  import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
2
- import { i as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-C8FODq2S.mjs";
2
+ import { n as localProfile, t as isConnected } from "./profile-BkiWglVE.mjs";
3
+ import { i as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-D3eaARf4.mjs";
3
4
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
4
5
  import { basename, dirname, join, resolve, sep } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
@@ -7,6 +8,28 @@ import { randomBytes } from "node:crypto";
7
8
  //#region src/server/api.ts
8
9
  const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
9
10
  const BODY_LIMIT = 1e6;
11
+ const CSRF_MAX_AGE = 2592e3;
12
+ /** Read one cookie value off the request. */
13
+ function cookie(req, name) {
14
+ const m = new RegExp(`(?:^|;\\s*)${name}=([^;]+)`).exec(String(req.headers.cookie ?? ""));
15
+ return m ? m[1] : "";
16
+ }
17
+ /** The owner gate (SPEC-live-jam §1). The dev server is 127.0.0.1-only, so the live threat is a
18
+ * cross-origin drive-by page firing a POST at localhost. Two defenses, mirroring the published
19
+ * side (collab.ts:206): a double-submit cookie (mv_c is JS-readable same-origin, so a cross-origin
20
+ * page cannot read it to echo x-mv-c), plus an Origin allowlist. Only a POST that passes this is
21
+ * eligible to be ledgered, so a forged @marver can never authorize the local agent. */
22
+ function ownerGated(req) {
23
+ const c = cookie(req, "mv_c");
24
+ if (!c || req.headers["x-mv-c"] !== c) return false;
25
+ const origin = req.headers.origin;
26
+ if (origin) try {
27
+ if (new URL(String(origin)).host !== String(req.headers.host ?? "")) return false;
28
+ } catch {
29
+ return false;
30
+ }
31
+ return true;
32
+ }
10
33
  function json(res, status, body) {
11
34
  res.statusCode = status;
12
35
  res.setHeader("content-type", "application/json");
@@ -74,6 +97,7 @@ function apiMiddleware(root) {
74
97
  const url = new URL(req.url ?? "/", "http://x");
75
98
  if (!url.pathname.startsWith(`/__mv/api/`)) return next();
76
99
  const path = url.pathname.slice(`${ROUTE}/api/`.length);
100
+ if (req.method === "GET" && !cookie(req, "mv_c")) res.setHeader("set-cookie", `mv_c=${randomBytes(16).toString("base64url")}; Path=/; Max-Age=${CSRF_MAX_AGE}; SameSite=Lax`);
77
101
  try {
78
102
  if (path === "boards" && req.method === "GET") {
79
103
  mkdirSync(boardsDir, { recursive: true });
@@ -146,9 +170,11 @@ function apiMiddleware(root) {
146
170
  if (path === "me" && req.method === "GET") return json(res, 200, {
147
171
  user: localProfile(root),
148
172
  role: "owner",
149
- local: true
173
+ local: true,
174
+ connected: isConnected(root)
150
175
  });
151
176
  if (path === "profile" && req.method === "POST") {
177
+ if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
152
178
  const raw = await readBody(req);
153
179
  if (raw == null) return json(res, 400, { error: "body too large" });
154
180
  let b;
@@ -159,17 +185,22 @@ function apiMiddleware(root) {
159
185
  }
160
186
  const dir = join(root, "design", ".local");
161
187
  mkdirSync(dir, { recursive: true });
188
+ const { validAvatar } = await import("./collab-pLGzSvm5.mjs");
189
+ let cur = {};
190
+ try {
191
+ cur = JSON.parse(readFileSync(join(dir, "profile.json"), "utf8"));
192
+ } catch {}
162
193
  const prof = {
163
- ...localProfile(root),
194
+ ...cur,
164
195
  ...typeof b.name === "string" && b.name.trim() ? { name: b.name.trim() } : {},
165
196
  ...typeof b.email === "string" ? { email: b.email.trim() } : {},
166
- ...typeof b.avatar === "string" ? { avatar: b.avatar || void 0 } : {}
197
+ ...b.avatar === "" ? { avatar: void 0 } : validAvatar(b.avatar) ? { avatar: b.avatar } : {}
167
198
  };
168
199
  atomicWrite(join(dir, "profile.json"), JSON.stringify(prof, null, 2) + "\n");
169
- return json(res, 200, { user: prof });
200
+ return json(res, 200, { user: localProfile(root) });
170
201
  }
171
202
  if (path === "boards.rights" && req.method === "GET") {
172
- const { listBoards } = await import("./comments-Ba8mU600.mjs").then((n) => n.n);
203
+ const { listBoards } = await import("./comments-DSwMhdbZ.mjs");
173
204
  return json(res, 200, {
174
205
  local: true,
175
206
  stored: listBoards(join(root, "design", "comments"))
@@ -177,10 +208,11 @@ function apiMiddleware(root) {
177
208
  }
178
209
  const cm = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
179
210
  if (cm) {
180
- const { appendEvents, readLog } = await import("./comments-Ba8mU600.mjs").then((n) => n.n);
211
+ const { appendEvents, readLog } = await import("./comments-DSwMhdbZ.mjs");
181
212
  const dir = join(root, "design", "comments");
182
213
  if (req.method === "GET") return json(res, 200, { events: readLog(dir, cm[1]) });
183
214
  if (req.method === "POST") {
215
+ if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
184
216
  const raw = await readBody(req);
185
217
  if (raw == null) return json(res, 400, { error: "body too large" });
186
218
  let b;
@@ -191,23 +223,28 @@ function apiMiddleware(root) {
191
223
  }
192
224
  const incoming = Array.isArray(b.events) ? b.events : [];
193
225
  const me = localProfile(root);
194
- const stamped = incoming.map((ev) => ({
195
- ...ev,
196
- board: ev.board ?? cm[1],
197
- author: ev.author ?? ([
198
- "create",
199
- "reply",
200
- "react",
201
- "edit"
202
- ].includes(ev.type) ? me : void 0)
203
- }));
226
+ const stamped = incoming.map((ev) => {
227
+ const { agent: _a, agentMeta: _am, origin: _o, ...clean } = ev;
228
+ return {
229
+ ...clean,
230
+ board: clean.board ?? cm[1],
231
+ author: clean.author ?? ([
232
+ "create",
233
+ "reply",
234
+ "react",
235
+ "edit"
236
+ ].includes(clean.type) ? me : void 0)
237
+ };
238
+ });
204
239
  const fresh = appendEvents(dir, cm[1], stamped);
240
+ const { record } = await import("./ledger-wFvEIEGi.mjs").then((n) => n.n);
241
+ for (const ev of fresh) if (ev.type === "create" || ev.type === "reply") record(root, cm[1], ev.id);
205
242
  backgroundPush(root);
206
243
  return json(res, 200, { accepted: fresh.length });
207
244
  }
208
245
  }
209
246
  if (path === "sync" && req.method === "POST") {
210
- const { loadCollab, syncOnce } = await import("./sync-CkBk-tUk.mjs").then((n) => n.a);
247
+ const { loadCollab, syncOnce } = await import("./sync-Ch4Bymb1.mjs");
211
248
  const collab = loadCollab(root);
212
249
  if (!collab) return json(res, 200, { connected: false });
213
250
  try {
@@ -228,27 +265,6 @@ function apiMiddleware(root) {
228
265
  }
229
266
  };
230
267
  }
231
- function localProfile(root) {
232
- try {
233
- const c = JSON.parse(readFileSync(join(root, "design", ".local", "collab.json"), "utf8"));
234
- if (typeof c?.email === "string" && c.email) return {
235
- email: c.email,
236
- name: c.name ?? "Designer"
237
- };
238
- } catch {}
239
- try {
240
- const p = JSON.parse(readFileSync(join(root, "design", ".local", "profile.json"), "utf8"));
241
- if (typeof p?.name === "string") return {
242
- email: p.email ?? "",
243
- name: p.name,
244
- avatar: p.avatar
245
- };
246
- } catch {}
247
- return {
248
- email: "",
249
- name: "Designer"
250
- };
251
- }
252
268
  let pushTimer = null;
253
269
  /** Debounced fire-and-forget push after a local write; failures are silent - the
254
270
  * 30s sync loop is the reliability layer, this is just latency. */
@@ -257,7 +273,7 @@ function backgroundPush(root) {
257
273
  pushTimer = setTimeout(async () => {
258
274
  pushTimer = null;
259
275
  try {
260
- const { loadCollab, syncOnce } = await import("./sync-CkBk-tUk.mjs").then((n) => n.a);
276
+ const { loadCollab, syncOnce } = await import("./sync-Ch4Bymb1.mjs");
261
277
  const collab = loadCollab(root);
262
278
  if (collab) await syncOnce(root, collab);
263
279
  } catch {}
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ //#region src/server/profile.ts
4
+ /**
5
+ * The dev identity - ONE resolver for every server-side consumer (api, jam daemon, CLI).
6
+ *
7
+ * Two sources merge in design/.local/:
8
+ * - profile.json {name, email?, avatar?} - set from the composer's avatar popover
9
+ * - collab.json {email, name} - the connect account, written by `comments connect`
10
+ *
11
+ * The connect account wins name + email (published serves validate author == session, so
12
+ * events born here must carry an identity the remote will accept). The avatar always comes
13
+ * from profile.json - connect doesn't carry one, and the local photo is still YOUR photo.
14
+ */
15
+ const readJson = (path) => {
16
+ try {
17
+ const v = JSON.parse(readFileSync(path, "utf8"));
18
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
19
+ } catch {
20
+ return {};
21
+ }
22
+ };
23
+ const str = (v) => typeof v === "string" ? v.trim() : "";
24
+ /** True when a connect account provides the identity - name/email are then read-only in dev. */
25
+ function isConnected(root) {
26
+ return !!str(readJson(join(root, "design", ".local", "collab.json")).email);
27
+ }
28
+ function localProfile(root) {
29
+ const dir = join(root, "design", ".local");
30
+ const prof = readJson(join(dir, "profile.json"));
31
+ const collab = readJson(join(dir, "collab.json"));
32
+ return {
33
+ name: str(collab.name) || str(prof.name) || "You",
34
+ email: str(collab.email) || str(prof.email),
35
+ avatar: str(prof.avatar) || void 0
36
+ };
37
+ }
38
+ //#endregion
39
+ export { localProfile as n, isConnected as t };
@@ -49,9 +49,9 @@ async function serve(root, portFlag) {
49
49
  let bearerCheck = null;
50
50
  let sessionCheck = null;
51
51
  if (process.env.MARVER_DATA_DIR) {
52
- const { collabHandler } = await import("./collab-CXy8gqoz.mjs");
53
- const { appendEvents, readLog } = await import("./comments-Ba8mU600.mjs").then((n) => n.n);
54
- const { dataDir } = await import("./comments-Ba8mU600.mjs").then((n) => n.n);
52
+ const { collabHandler } = await import("./collab-pLGzSvm5.mjs");
53
+ const { appendEvents, readLog } = await import("./comments-DSwMhdbZ.mjs");
54
+ const { dataDir } = await import("./comments-DSwMhdbZ.mjs");
55
55
  const dir = dataDir();
56
56
  const seedDir = join(dist, "design", "comments");
57
57
  if (existsSync(seedDir)) for (const f of readdirSync(seedDir)) {
@@ -1,98 +1,7 @@
1
- import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
- import { i as readLog, r as listBoards, t as appendEvents } from "./comments-Ba8mU600.mjs";
1
+ import { t as diffEvents } from "./events-BMtBvvgU.mjs";
2
+ import { appendEvents, listBoards, readLog } from "./comments-DSwMhdbZ.mjs";
3
3
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- //#region src/shared/events.ts
6
- /** Events the other side lacks, by id - the sync payload in either direction. */
7
- function diffEvents(mine, theirIds) {
8
- const have = new Set(theirIds);
9
- return mine.filter((e) => !have.has(e.id));
10
- }
11
- /** Replay a board's events into current thread state. Deterministic: replay order is
12
- * (ts, id) so two stores holding the same event SET always derive the same state.
13
- * TWO passes - creates first, then everything else - so a reply whose author's clock
14
- * ran ahead of the creator's still lands instead of being dropped forever. */
15
- function replay(events) {
16
- const ordered = [...events].sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1));
17
- const threads = /* @__PURE__ */ new Map();
18
- for (const ev of ordered) {
19
- if (ev.type !== "create") continue;
20
- if (!ev.commentId || threads.has(ev.commentId)) continue;
21
- threads.set(ev.commentId, {
22
- id: ev.commentId,
23
- board: ev.board,
24
- nodeKey: ev.nodeKey,
25
- frame: ev.frame,
26
- anchor: ev.anchor,
27
- author: ev.author,
28
- body: ev.body,
29
- ts: ev.ts,
30
- resolved: false,
31
- replies: [],
32
- reactions: {}
33
- });
34
- }
35
- for (const ev of ordered) switch (ev.type) {
36
- case "reply": {
37
- const t = ev.parentId ? threads.get(ev.parentId) : void 0;
38
- if (!t || !ev.commentId || t.replies.some((r) => r.id === ev.commentId)) break;
39
- t.replies.push({
40
- id: ev.commentId,
41
- author: ev.author,
42
- body: ev.body,
43
- ts: ev.ts
44
- });
45
- break;
46
- }
47
- case "edit": {
48
- if (!ev.commentId || ev.body === void 0) break;
49
- const t = threads.get(ev.commentId);
50
- if (t) {
51
- t.body = ev.body;
52
- break;
53
- }
54
- for (const th of threads.values()) {
55
- const r = th.replies.find((r) => r.id === ev.commentId);
56
- if (r) {
57
- r.body = ev.body;
58
- break;
59
- }
60
- }
61
- break;
62
- }
63
- case "resolve": {
64
- const t = ev.commentId ? threads.get(ev.commentId) : void 0;
65
- if (t) {
66
- t.resolved = true;
67
- if (ev.addressedIn) t.addressedIn = ev.addressedIn;
68
- }
69
- break;
70
- }
71
- case "reopen": {
72
- const t = ev.commentId ? threads.get(ev.commentId) : void 0;
73
- if (t) {
74
- t.resolved = false;
75
- t.addressedIn = void 0;
76
- }
77
- break;
78
- }
79
- case "react": {
80
- const t = ev.commentId ? threads.get(ev.commentId) : void 0;
81
- const who = ev.author?.email;
82
- if (!t || !who || !ev.emoji) break;
83
- const users = t.reactions[ev.emoji] ??= [];
84
- const at = users.indexOf(who);
85
- if (at === -1) users.push(who);
86
- else {
87
- users.splice(at, 1);
88
- if (!users.length) delete t.reactions[ev.emoji];
89
- }
90
- break;
91
- }
92
- }
93
- return [...threads.values()];
94
- }
95
- //#endregion
96
5
  //#region src/server/sync.ts
97
6
  /**
98
7
  * Dev ↔ published comment sync (SPEC-M3 §2) - one merge rule, run from the dev side.
@@ -105,13 +14,6 @@ function replay(events) {
105
14
  * Credentials: design/.local/collab.json {url, token} written by `comments connect`.
106
15
  * The token is a server session held by the DEV PROCESS only - pages never see it.
107
16
  */
108
- var sync_exports = /* @__PURE__ */ __exportAll({
109
- connect: () => connect,
110
- connectClaim: () => connectClaim,
111
- loadCollab: () => loadCollab,
112
- saveCollab: () => saveCollab,
113
- syncOnce: () => syncOnce
114
- });
115
17
  const collabFile = (root) => join(root, "design", ".local", "collab.json");
116
18
  function loadCollab(root) {
117
19
  try {
@@ -148,7 +50,7 @@ async function syncOnce(root, collab) {
148
50
  const pulled = appendEvents(dir, board, remote).length;
149
51
  let pushed = 0;
150
52
  if (rights[board] === "comment") {
151
- const missing = diffEvents(readLog(dir, board), remote.map((e) => e.id));
53
+ const missing = diffEvents(readLog(dir, board), remote.map((e) => e.id)).filter((e) => !e.agent);
152
54
  for (let i = 0; i < missing.length; i += 100) {
153
55
  const r = await fetch(`${base}/__mv/api/comments/${board}`, {
154
56
  method: "POST",
@@ -245,4 +147,4 @@ async function connectClaim(root, url, invite, profile, canvasPassword) {
245
147
  });
246
148
  }
247
149
  //#endregion
248
- export { sync_exports as a, syncOnce as i, connectClaim as n, replay as o, loadCollab as r, connect as t };
150
+ export { connect, connectClaim, loadCollab, syncOnce };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components. The tool ships no AI - your coding agent is the designer.",
5
5
  "type": "module",
6
6
  "private": false,