@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.
- package/CHANGELOG.md +46 -0
- package/dist/{build-BZaPa2DS.mjs → build-B8z902TW.mjs} +2 -2
- package/dist/cli.mjs +5 -5
- package/dist/{collab-CXy8gqoz.mjs → collab-pLGzSvm5.mjs} +16 -5
- package/dist/{comments-odHzYdO3.mjs → comments-BrpC86Kf.mjs} +9 -15
- package/dist/{comments-Ba8mU600.mjs → comments-DSwMhdbZ.mjs} +2 -8
- package/dist/daemon-C-huGHAM.mjs +899 -0
- package/dist/{dev-DaPQ9xA5.mjs → dev-BYm9ebfN.mjs} +14 -3
- package/dist/events-BMtBvvgU.mjs +101 -0
- package/dist/{init-DsCUmlCW.mjs → init-DrLomSWq.mjs} +13 -1
- package/dist/ledger-wFvEIEGi.mjs +64 -0
- package/dist/{manifest-C8FODq2S.mjs → manifest-D3eaARf4.mjs} +16 -1
- package/dist/{plugin-wMY9lNf3.mjs → plugin-BVFuRfEo.mjs} +56 -40
- package/dist/profile-BkiWglVE.mjs +39 -0
- package/dist/{serve-D_KBK7Oy.mjs → serve-CwAfayJk.mjs} +3 -3
- package/dist/{sync-CkBk-tUk.mjs → sync-Ch4Bymb1.mjs} +4 -102
- package/package.json +1 -1
- package/src/client/frame-host/inspect.js +15 -2
- package/src/client/shell/App.tsx +91 -9
- package/src/client/shell/Comments.tsx +418 -68
- package/src/client/shell/Play.tsx +10 -6
- package/src/client/shell/Toolbar.tsx +14 -2
- package/src/client/shell/canvas/FrameNode.tsx +33 -6
- package/src/client/shell/comments-store.ts +47 -6
- package/src/client/shell/icons.tsx +4 -0
- package/src/client/shell/mentions.ts +18 -0
- package/src/client/shell/store.ts +30 -2
- package/src/client/shell/styles.css +294 -21
- package/src/shared/events.ts +22 -5
- package/templates/AGENTS-embedded.md +1 -0
- package/templates/AGENTS-studio.md +1 -0
- package/templates/instructions/configure.md +15 -0
- package/templates/instructions/jam.md +85 -0
- 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-
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
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-
|
|
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-
|
|
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 {
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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:
|
|
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-
|
|
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-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
53
|
-
const { appendEvents, readLog } = await import("./comments-
|
|
54
|
-
const { dataDir } = await import("./comments-
|
|
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
|
|
2
|
-
import {
|
|
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 {
|
|
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.
|
|
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,
|