@marver-design/marver 0.10.2 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +161 -0
- package/README.md +16 -3
- package/dist/auth-B5yuwnOq.mjs +494 -0
- package/dist/{build-zUf_kwKp.mjs → build-DI31MFTB.mjs} +3 -2
- package/dist/cli.mjs +6 -6
- package/dist/{collab-s3k5byM1.mjs → collab-CWaG3Q4w.mjs} +43 -11
- package/dist/{comments-J06jqCVV.mjs → comments-CRqaO2MH.mjs} +26 -11
- package/dist/{comments-BZBKhKRO.mjs → comments-DZyobpxG.mjs} +8 -2
- package/dist/{manifest-DIsp3ldB.mjs → config-t9coJ-Pq.mjs} +3 -233
- package/dist/{daemon-BpKBJZee.mjs → daemon-DSmaS453.mjs} +23 -5
- package/dist/{dev-D2PG6igI.mjs → dev-BWIL2ewj.mjs} +117 -13
- package/dist/{init-DwV0_d9G.mjs → init-BSclDg4I.mjs} +2 -1
- package/dist/manifest-C2tzkNaC.mjs +233 -0
- package/dist/marver-id-B8-3WiHk.mjs +411 -0
- package/dist/marver-id-gate-BDzW6ahN.mjs +728 -0
- package/dist/{plugin-C1sDewZ3.mjs → plugin-DjBFxEQ7.mjs} +12 -84
- package/dist/{profile-BkiWglVE.mjs → profile-DcsJyppw.mjs} +9 -6
- package/dist/secure-cookie-_K1Hsx8H.mjs +26 -0
- package/dist/{serve-CZqPnj19.mjs → serve-3APqaNSB.mjs} +196 -43
- package/dist/sync-DELGomPk.mjs +397 -0
- package/dist/update-DuWDj5nR.mjs +77 -0
- package/dist/utm-CxC3QN5X.mjs +20 -0
- package/package.json +1 -1
- package/src/shared/utm.ts +2 -2
- package/templates/instructions/configure.md +4 -0
- package/templates/instructions/publish.md +99 -16
- package/dist/auth-KQ9Aj-nB.mjs +0 -245
- package/dist/events-BMtBvvgU.mjs +0 -101
- package/dist/sync-BJKKmy1n.mjs +0 -150
- package/dist/{shot-D7I0MCLu.mjs → shot-Cyv3GN79.mjs} +1 -1
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
+
import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
|
|
3
|
+
import { t as CLI_TOKEN_CHARS } from "./auth-B5yuwnOq.mjs";
|
|
4
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
//#region src/shared/events.ts
|
|
9
|
+
/** Events the other side lacks, by id - the sync payload in either direction. */
|
|
10
|
+
function diffEvents(mine, theirIds) {
|
|
11
|
+
const have = new Set(theirIds);
|
|
12
|
+
return mine.filter((e) => !have.has(e.id));
|
|
13
|
+
}
|
|
14
|
+
/** Replay a board's events into current thread state. Deterministic: replay order is
|
|
15
|
+
* (ts, id) so two stores holding the same event SET always derive the same state.
|
|
16
|
+
* TWO passes - creates first, then everything else - so a reply whose author's clock
|
|
17
|
+
* ran ahead of the creator's still lands instead of being dropped forever. */
|
|
18
|
+
function replay(events) {
|
|
19
|
+
const ordered = [...events].sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1));
|
|
20
|
+
const threads = /* @__PURE__ */ new Map();
|
|
21
|
+
for (const ev of ordered) {
|
|
22
|
+
if (ev.type !== "create") continue;
|
|
23
|
+
if (!ev.commentId || threads.has(ev.commentId)) continue;
|
|
24
|
+
threads.set(ev.commentId, {
|
|
25
|
+
id: ev.commentId,
|
|
26
|
+
board: ev.board,
|
|
27
|
+
nodeKey: ev.nodeKey,
|
|
28
|
+
frame: ev.frame,
|
|
29
|
+
anchor: ev.anchor,
|
|
30
|
+
author: ev.author,
|
|
31
|
+
body: ev.body,
|
|
32
|
+
ts: ev.ts,
|
|
33
|
+
resolved: false,
|
|
34
|
+
agent: ev.agent,
|
|
35
|
+
agentMeta: ev.agentMeta,
|
|
36
|
+
replies: [],
|
|
37
|
+
reactions: {}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
for (const ev of ordered) switch (ev.type) {
|
|
41
|
+
case "reply": {
|
|
42
|
+
const t = ev.parentId ? threads.get(ev.parentId) : void 0;
|
|
43
|
+
if (!t || !ev.commentId || t.replies.some((r) => r.id === ev.commentId)) break;
|
|
44
|
+
t.replies.push({
|
|
45
|
+
id: ev.commentId,
|
|
46
|
+
author: ev.author,
|
|
47
|
+
body: ev.body,
|
|
48
|
+
ts: ev.ts,
|
|
49
|
+
agent: ev.agent,
|
|
50
|
+
agentMeta: ev.agentMeta
|
|
51
|
+
});
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case "edit": {
|
|
55
|
+
if (!ev.commentId || ev.body === void 0) break;
|
|
56
|
+
const t = threads.get(ev.commentId);
|
|
57
|
+
if (t) {
|
|
58
|
+
t.body = ev.body;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
for (const th of threads.values()) {
|
|
62
|
+
const r = th.replies.find((r) => r.id === ev.commentId);
|
|
63
|
+
if (r) {
|
|
64
|
+
r.body = ev.body;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case "resolve": {
|
|
71
|
+
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
72
|
+
if (t) {
|
|
73
|
+
t.resolved = true;
|
|
74
|
+
if (ev.addressedIn) t.addressedIn = ev.addressedIn;
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case "reopen": {
|
|
79
|
+
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
80
|
+
if (t) {
|
|
81
|
+
t.resolved = false;
|
|
82
|
+
t.addressedIn = void 0;
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
case "reanchor": {
|
|
87
|
+
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
88
|
+
if (t && ev.anchor != null) t.anchor = ev.anchor;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "react": {
|
|
92
|
+
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
93
|
+
const who = ev.author?.email;
|
|
94
|
+
if (!t || !who || !ev.emoji) break;
|
|
95
|
+
const users = t.reactions[ev.emoji] ??= [];
|
|
96
|
+
const at = users.indexOf(who);
|
|
97
|
+
if (at === -1) users.push(who);
|
|
98
|
+
else {
|
|
99
|
+
users.splice(at, 1);
|
|
100
|
+
if (!users.length) delete t.reactions[ev.emoji];
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return [...threads.values()];
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/server/sync.ts
|
|
109
|
+
/**
|
|
110
|
+
* Dev ↔ published comment sync - one merge rule, run from the dev side.
|
|
111
|
+
*
|
|
112
|
+
* The published serve is the canonical home; the repo's design/comments/ is the
|
|
113
|
+
* mirror the agent reads. Each exchange is: pull remote events → union into local
|
|
114
|
+
* files; push the local events the remote lacks (chunked - the server unions too).
|
|
115
|
+
* Idempotent and retry-safe by construction, so a dropped exchange costs nothing.
|
|
116
|
+
*
|
|
117
|
+
* Credentials: ~/.marver/canvases/<hash-of-project-path>.json {url, token},
|
|
118
|
+
* written by `comments connect`. Deliberately OUTSIDE the repository - a dev
|
|
119
|
+
* server serves the repository, and no amount of path guarding makes a secret
|
|
120
|
+
* inside a published tree safe. The token is always a server SESSION - expiring,
|
|
121
|
+
* and ended by rotating MARVER_CLI_TOKEN - never the operator's secret itself,
|
|
122
|
+
* which is traded for one and then forgotten.
|
|
123
|
+
*/
|
|
124
|
+
var sync_exports = /* @__PURE__ */ __exportAll({
|
|
125
|
+
connect: () => connect,
|
|
126
|
+
connectClaim: () => connectClaim,
|
|
127
|
+
connectToken: () => connectToken,
|
|
128
|
+
loadCollab: () => loadCollab,
|
|
129
|
+
saveCollab: () => saveCollab,
|
|
130
|
+
syncOnce: () => syncOnce
|
|
131
|
+
});
|
|
132
|
+
/**
|
|
133
|
+
* The credential does not live in the repository.
|
|
134
|
+
*
|
|
135
|
+
* It used to, at `design/.local/collab.json`, and `marver dev` puts the
|
|
136
|
+
* repository on the web so frames can import from it. Two rounds of guards -
|
|
137
|
+
* a deny list, then a realpath check - each closed a way to read it and each was
|
|
138
|
+
* followed by another: a symlink, then `public/`, then Vite's derived
|
|
139
|
+
* `index`/`.html` candidates. Enumerating every path a bundler might resolve is
|
|
140
|
+
* not a thing anyone can finish.
|
|
141
|
+
*
|
|
142
|
+
* So the file moved out of reach instead. Under the user's home, keyed by the
|
|
143
|
+
* absolute path of the project it belongs to, one file per canvas per machine.
|
|
144
|
+
* Nothing serves that directory, so there is no path to guard.
|
|
145
|
+
*
|
|
146
|
+
* The old location is still read once, moved, and removed - a credential left
|
|
147
|
+
* behind in a repo is exactly the thing being fixed.
|
|
148
|
+
*/
|
|
149
|
+
const legacyCollabFile = (root) => join(root, "design", ".local", "collab.json");
|
|
150
|
+
const collabDir = () => join(homedir(), ".marver", "canvases");
|
|
151
|
+
const collabFile = (root) => {
|
|
152
|
+
let canonical = root;
|
|
153
|
+
try {
|
|
154
|
+
canonical = realpathSync(root);
|
|
155
|
+
} catch {}
|
|
156
|
+
const key = createHash("sha256").update(canonical).digest("hex").slice(0, 32);
|
|
157
|
+
return join(collabDir(), `${key}.json`);
|
|
158
|
+
};
|
|
159
|
+
/** Move a credential written by an older marver out of the repository. Best
|
|
160
|
+
* effort in one direction only: if anything here fails the old file is left
|
|
161
|
+
* alone and `loadCollab` still reads it, so nobody is locked out by a tidy-up. */
|
|
162
|
+
function migrateLegacy(root) {
|
|
163
|
+
const from = legacyCollabFile(root);
|
|
164
|
+
if (!existsSync(from)) return;
|
|
165
|
+
if (readCollab(collabFile(root))) {
|
|
166
|
+
try {
|
|
167
|
+
unlinkSync(from);
|
|
168
|
+
} catch {}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const collab = readCollab(from);
|
|
172
|
+
if (!collab) return;
|
|
173
|
+
try {
|
|
174
|
+
saveCollab(root, collab);
|
|
175
|
+
} catch {}
|
|
176
|
+
}
|
|
177
|
+
/** A credential file, or null if it is missing, unreadable or not one. */
|
|
178
|
+
function readCollab(file) {
|
|
179
|
+
try {
|
|
180
|
+
const c = JSON.parse(readFileSync(file, "utf8"));
|
|
181
|
+
return typeof c?.url === "string" && typeof c?.token === "string" ? c : null;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function loadCollab(root) {
|
|
187
|
+
migrateLegacy(root);
|
|
188
|
+
return readCollab(collabFile(root)) ?? readCollab(legacyCollabFile(root));
|
|
189
|
+
}
|
|
190
|
+
function saveCollab(root, collab) {
|
|
191
|
+
const dir = collabDir();
|
|
192
|
+
const file = collabFile(root);
|
|
193
|
+
mkdirSync(dir, {
|
|
194
|
+
recursive: true,
|
|
195
|
+
mode: 448
|
|
196
|
+
});
|
|
197
|
+
try {
|
|
198
|
+
chmodSync(dir, 448);
|
|
199
|
+
} catch {}
|
|
200
|
+
/**
|
|
201
|
+
* Written through a temp file created O_EXCL at 0600, then renamed.
|
|
202
|
+
*
|
|
203
|
+
* Writing in place and fixing the mode afterwards leaves a window - and, after
|
|
204
|
+
* a crash, no window at all but a token sitting under the previous file's
|
|
205
|
+
* permissions. Creating the temp file with the mode means the credential is
|
|
206
|
+
* never on disk readable, not even briefly, and rename is atomic so a reader
|
|
207
|
+
* sees the old file or the new one.
|
|
208
|
+
*/
|
|
209
|
+
const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
|
210
|
+
const fd = openSync(tmp, "wx", 384);
|
|
211
|
+
try {
|
|
212
|
+
writeSync(fd, JSON.stringify(collab, null, 2) + "\n");
|
|
213
|
+
} finally {
|
|
214
|
+
closeSync(fd);
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
renameSync(tmp, file);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
try {
|
|
220
|
+
unlinkSync(tmp);
|
|
221
|
+
} catch {}
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
unlinkSync(legacyCollabFile(root));
|
|
226
|
+
} catch {}
|
|
227
|
+
}
|
|
228
|
+
/** One full exchange. Returns per-board counts, or throws on auth/network failure. */
|
|
229
|
+
async function syncOnce(root, collab) {
|
|
230
|
+
const dir = join(root, "design", "comments");
|
|
231
|
+
const auth = { authorization: `Bearer ${collab.token}` };
|
|
232
|
+
const base = collab.url.replace(/\/+$/, "");
|
|
233
|
+
const bres = await fetch(`${base}/__mv/api/boards`, { headers: auth });
|
|
234
|
+
if (bres.status === 401) throw new Error("the connect token was rejected - run `comments connect` again");
|
|
235
|
+
if (!bres.ok) throw new Error(`published canvas answered ${bres.status} - is it up?`);
|
|
236
|
+
const { rights } = await bres.json();
|
|
237
|
+
const boards = [.../* @__PURE__ */ new Set([...Object.keys(rights), ...listBoards(dir)])];
|
|
238
|
+
const out = {};
|
|
239
|
+
const failures = [];
|
|
240
|
+
for (const board of boards) {
|
|
241
|
+
if (!(board in rights)) continue;
|
|
242
|
+
const res = await fetch(`${base}/__mv/api/comments/${board}`, { headers: auth });
|
|
243
|
+
if (!res.ok) {
|
|
244
|
+
failures.push(`${board}: pull ${res.status}`);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const { events: remote } = await res.json();
|
|
248
|
+
const pulled = appendEvents(dir, board, remote).length;
|
|
249
|
+
let pushed = 0;
|
|
250
|
+
if (rights[board] === "comment") {
|
|
251
|
+
const missing = diffEvents(readLog(dir, board), remote.map((e) => e.id)).filter((e) => !e.agent);
|
|
252
|
+
for (let i = 0; i < missing.length; i += 100) {
|
|
253
|
+
const r = await fetch(`${base}/__mv/api/comments/${board}`, {
|
|
254
|
+
method: "POST",
|
|
255
|
+
headers: {
|
|
256
|
+
...auth,
|
|
257
|
+
"content-type": "application/json"
|
|
258
|
+
},
|
|
259
|
+
body: JSON.stringify({ events: missing.slice(i, i + 100) })
|
|
260
|
+
});
|
|
261
|
+
if (r.ok) pushed += (await r.json()).accepted ?? 0;
|
|
262
|
+
else failures.push(`${board}: push ${r.status} ${(await r.json().catch(() => null))?.error ?? ""}`.trim());
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
out[board] = {
|
|
266
|
+
pulled,
|
|
267
|
+
pushed
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (failures.length) throw new Error(`sync incomplete - ${failures.join(" · ")}`);
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
/** The account endpoints live BEHIND the canvas gate (the outer READ boundary), so
|
|
274
|
+
* connecting passes the gate first when a canvas password is set. */
|
|
275
|
+
async function gateCookie(base, canvasPassword) {
|
|
276
|
+
if (!canvasPassword) return "";
|
|
277
|
+
const cookie = (await fetch(`${base}/__mv/auth`, {
|
|
278
|
+
method: "POST",
|
|
279
|
+
redirect: "manual",
|
|
280
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
281
|
+
body: `password=${encodeURIComponent(canvasPassword)}&next=`
|
|
282
|
+
})).headers.getSetCookie?.().find((c) => c.startsWith("mv_a=")) ?? "";
|
|
283
|
+
const tok = /^mv_a=([^;]+)/.exec(cookie)?.[1];
|
|
284
|
+
if (!tok) throw new Error("the canvas password was not accepted");
|
|
285
|
+
return `mv_a=${tok}`;
|
|
286
|
+
}
|
|
287
|
+
async function sessionFrom(res, ctx) {
|
|
288
|
+
if (!res.ok) {
|
|
289
|
+
const err = (await res.json().catch(() => null))?.error;
|
|
290
|
+
throw new Error(err ?? `${ctx} failed (${res.status})`);
|
|
291
|
+
}
|
|
292
|
+
const cookie = res.headers.getSetCookie?.().find((c) => c.startsWith("mv_s=")) ?? "";
|
|
293
|
+
const token = /^mv_s=([\w-]+)/.exec(cookie)?.[1];
|
|
294
|
+
if (!token) throw new Error("the canvas did not issue a session - is collaboration enabled on it (MARVER_DATA_DIR)?");
|
|
295
|
+
return token;
|
|
296
|
+
}
|
|
297
|
+
/** Sign in against a published canvas and persist the device credential. The account's
|
|
298
|
+
* identity rides along: locally-born events carry it as their author snapshot (the
|
|
299
|
+
* server validates the claim against the session - it never rewrites events). */
|
|
300
|
+
async function connect(root, url, email, password, canvasPassword) {
|
|
301
|
+
const base = url.replace(/\/+$/, "");
|
|
302
|
+
const gate = await gateCookie(base, canvasPassword);
|
|
303
|
+
const res = await fetch(`${base}/__mv/api/auth/signin`, {
|
|
304
|
+
method: "POST",
|
|
305
|
+
headers: {
|
|
306
|
+
"content-type": "application/json",
|
|
307
|
+
...gate ? { cookie: gate } : {}
|
|
308
|
+
},
|
|
309
|
+
body: JSON.stringify({
|
|
310
|
+
email,
|
|
311
|
+
password
|
|
312
|
+
})
|
|
313
|
+
});
|
|
314
|
+
if ((res.headers.get("content-type") ?? "").includes("text/html")) throw new Error(`this canvas signs people in with Marver ID, so there is no password to use here.
|
|
315
|
+
Set MARVER_CLI_TOKEN=\$(openssl rand -hex 24) on the canvas, then run:
|
|
316
|
+
npx marver comments connect ${base} --token <that same value>`);
|
|
317
|
+
const token = await sessionFrom(res, "sign-in");
|
|
318
|
+
const user = (await res.clone().json().catch(() => null))?.user;
|
|
319
|
+
saveCollab(root, {
|
|
320
|
+
url: base,
|
|
321
|
+
token,
|
|
322
|
+
email: user?.email ?? email,
|
|
323
|
+
name: user?.name,
|
|
324
|
+
avatar: user?.avatar
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
/** Claim an invite from the CLI (the dev-first path - no published UI needed). */
|
|
328
|
+
async function connectClaim(root, url, invite, profile, canvasPassword) {
|
|
329
|
+
const base = url.replace(/\/+$/, "");
|
|
330
|
+
const gate = await gateCookie(base, canvasPassword);
|
|
331
|
+
const res = await fetch(`${base}/__mv/api/auth/claim`, {
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers: {
|
|
334
|
+
"content-type": "application/json",
|
|
335
|
+
...gate ? { cookie: gate } : {}
|
|
336
|
+
},
|
|
337
|
+
body: JSON.stringify({
|
|
338
|
+
token: invite,
|
|
339
|
+
...profile
|
|
340
|
+
})
|
|
341
|
+
});
|
|
342
|
+
const token = await sessionFrom(res, "claim");
|
|
343
|
+
const user = (await res.clone().json().catch(() => null))?.user;
|
|
344
|
+
saveCollab(root, {
|
|
345
|
+
url: base,
|
|
346
|
+
token,
|
|
347
|
+
email: user?.email,
|
|
348
|
+
name: user?.name ?? profile.name,
|
|
349
|
+
avatar: user?.avatar
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Connect with the canvas's `MARVER_CLI_TOKEN` - the operator's own credential.
|
|
354
|
+
*
|
|
355
|
+
* The path that works whatever the canvas gates on, and the only one that works
|
|
356
|
+
* when it gates on Marver ID: an identity account has no password for `connect`
|
|
357
|
+
* to present, and the session its sign-in produced lives in an HttpOnly cookie
|
|
358
|
+
* the terminal cannot read. The token is set in the deployment environment
|
|
359
|
+
* rather than handed out by a page, because a page that mints CLI credentials is
|
|
360
|
+
* a page an authored frame can drive - see the note on `operatorUser`.
|
|
361
|
+
*
|
|
362
|
+
* Checked before it is written, so a mistyped token fails here, once, in front
|
|
363
|
+
* of somebody - rather than being persisted and failing on every sync from now
|
|
364
|
+
* on. No canvas password is needed: the token IS gate passage.
|
|
365
|
+
*/
|
|
366
|
+
async function connectToken(root, url, token) {
|
|
367
|
+
const base = url.replace(/\/+$/, "");
|
|
368
|
+
if (!token.trim()) throw new Error("that token is empty - is MARVER_CLI_TOKEN set in your shell?");
|
|
369
|
+
if (token.length < 32) throw new Error(`that token is ${token.length} characters and the canvas will not honour anything under 32.\n Generate one: MARVER_CLI_TOKEN=\$(openssl rand -hex 24), set it on the canvas, and use the same value here.`);
|
|
370
|
+
if (!CLI_TOKEN_CHARS.test(token)) throw new Error("that token has characters an Authorization header cannot carry (letters, digits, _ and - only).\n If it came from `openssl rand -base64`, regenerate it with `openssl rand -hex 24`\n and set the same value as MARVER_CLI_TOKEN on the canvas.");
|
|
371
|
+
const res = await fetch(`${base}/__mv/api/cli-session`, {
|
|
372
|
+
method: "POST",
|
|
373
|
+
headers: {
|
|
374
|
+
authorization: `Bearer ${token}`,
|
|
375
|
+
"content-type": "application/json"
|
|
376
|
+
},
|
|
377
|
+
body: "{}"
|
|
378
|
+
});
|
|
379
|
+
const html = (res.headers.get("content-type") ?? "").includes("text/html");
|
|
380
|
+
if (res.status === 404) throw new Error(`${base} has no CLI sign-in - it is running a marver older than 0.11.0`);
|
|
381
|
+
if (res.status === 401 || html) throw new Error(`the canvas would not accept that token.
|
|
382
|
+
- is it the same value as MARVER_CLI_TOKEN on ${base}?\n - has the owner signed in yet? the token acts as the owner, so it does nothing until one exists
|
|
383
|
+
- is MARVER_DATA_DIR set on the canvas? without it there are no accounts at all
|
|
384
|
+
- is the canvas running marver 0.11.0 or newer? older ones have no CLI sign-in at all`);
|
|
385
|
+
if (!res.ok) throw new Error(`the canvas answered ${res.status} - is it up?`);
|
|
386
|
+
const body = await res.json().catch(() => null);
|
|
387
|
+
if (!body?.token || !body?.user?.email) throw new Error("the canvas accepted the token but issued nothing - is it running a current marver?");
|
|
388
|
+
saveCollab(root, {
|
|
389
|
+
url: base,
|
|
390
|
+
token: body.token,
|
|
391
|
+
email: body.user.email,
|
|
392
|
+
name: body.user.name,
|
|
393
|
+
avatar: body.user.avatar
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
//#endregion
|
|
397
|
+
export { syncOnce as a, loadCollab as i, connectClaim as n, sync_exports as o, connectToken as r, replay as s, connect as t };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
+
import { r as PKG } from "./cli.mjs";
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
//#region src/server/update.ts
|
|
7
|
+
/**
|
|
8
|
+
* Update discovery - dev only, and deliberately boring about privacy: one anonymous
|
|
9
|
+
* registry metadata GET per day (the same request `npm view` makes), cached in
|
|
10
|
+
* design/.local/, nothing sent beyond the request itself. Offline, slow, or
|
|
11
|
+
* firewalled registries degrade to silence. MARVER_NO_UPDATE_CHECK=1 disables it.
|
|
12
|
+
* Published bundles never check anything - viewers are not the owner.
|
|
13
|
+
*/
|
|
14
|
+
var update_exports = /* @__PURE__ */ __exportAll({
|
|
15
|
+
checkUpdate: () => checkUpdate,
|
|
16
|
+
installedVersion: () => installedVersion
|
|
17
|
+
});
|
|
18
|
+
const TTL = 864e5;
|
|
19
|
+
/** Installed version: walk up from this module to the package's own package.json
|
|
20
|
+
* (one level from dist/, two from src/server/ - the walk covers both). */
|
|
21
|
+
function installedVersion() {
|
|
22
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
for (let i = 0; i < 4; i++) {
|
|
24
|
+
try {
|
|
25
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
26
|
+
if (pkg.name === "@marver-design/marver") return pkg.version;
|
|
27
|
+
} catch {}
|
|
28
|
+
dir = dirname(dir);
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/** Strictly-newer numeric compare; anything non-numeric (prerelease tags) compares false. */
|
|
33
|
+
const newer = (a, b) => {
|
|
34
|
+
const pa = a.split(".").map((n) => parseInt(n, 10));
|
|
35
|
+
const pb = b.split(".").map((n) => parseInt(n, 10));
|
|
36
|
+
for (let i = 0; i < 3; i++) {
|
|
37
|
+
const x = pa[i] ?? 0, y = pb[i] ?? 0;
|
|
38
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
|
|
39
|
+
if (x !== y) return x > y;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
};
|
|
43
|
+
/** The latest published version when strictly newer than installed, else null. Never throws. */
|
|
44
|
+
async function checkUpdate(root) {
|
|
45
|
+
if (process.env.MARVER_NO_UPDATE_CHECK) return null;
|
|
46
|
+
const current = installedVersion();
|
|
47
|
+
if (!current) return null;
|
|
48
|
+
const latest = await latestVersion(root);
|
|
49
|
+
return latest && newer(latest, current) ? latest : null;
|
|
50
|
+
}
|
|
51
|
+
async function latestVersion(root) {
|
|
52
|
+
const cacheFile = join(root, "design", ".local", "update-check.json");
|
|
53
|
+
const wellFormed = (v) => typeof v === "string" && /^\d+\.\d+\.\d+$/.test(v);
|
|
54
|
+
try {
|
|
55
|
+
const c = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
56
|
+
const age = Date.now() - c.checkedAt;
|
|
57
|
+
if (typeof c.checkedAt === "number" && age >= 0 && age < TTL) return wellFormed(c.latest) ? c.latest : null;
|
|
58
|
+
} catch {}
|
|
59
|
+
let latest = null;
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/latest`, { signal: AbortSignal.timeout(3e3) });
|
|
62
|
+
if (res.ok) {
|
|
63
|
+
const v = (await res.json())?.version;
|
|
64
|
+
if (wellFormed(v)) latest = v;
|
|
65
|
+
}
|
|
66
|
+
} catch {}
|
|
67
|
+
try {
|
|
68
|
+
mkdirSync(join(root, "design", ".local"), { recursive: true });
|
|
69
|
+
writeFileSync(cacheFile, JSON.stringify({
|
|
70
|
+
checkedAt: Date.now(),
|
|
71
|
+
latest
|
|
72
|
+
}) + "\n");
|
|
73
|
+
} catch {}
|
|
74
|
+
return latest;
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
77
|
+
export { installedVersion as n, update_exports as r, checkUpdate as t };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/shared/utm.ts
|
|
2
|
+
/** Automatic attribution for the powered-by links: every canvas tags its marver.design
|
|
3
|
+
* link so site analytics can see WHICH canvas sent a visitor - zero user setup.
|
|
4
|
+
* Conventional UTM semantics (stable groupings first, the variable last):
|
|
5
|
+
* utm_source = the surface class ('published-canvas' | 'dev-canvas')
|
|
6
|
+
* utm_medium = the link unit ('powered-by')
|
|
7
|
+
* utm_campaign = THIS canvas's name, slugged ("Marver tour" -> "marver-tour")
|
|
8
|
+
* utm_content = the placement ('gate' badge | 'shell' wordmark | 'sign-in' finish page)
|
|
9
|
+
*/
|
|
10
|
+
function poweredByUrl(canvasName, source, content) {
|
|
11
|
+
const slug = (canvasName ?? "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
12
|
+
return `https://marver.design/?${new URLSearchParams({
|
|
13
|
+
utm_source: source,
|
|
14
|
+
utm_medium: "powered-by",
|
|
15
|
+
...slug ? { utm_campaign: slug } : {},
|
|
16
|
+
utm_content: content
|
|
17
|
+
}).toString()}`;
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { poweredByUrl as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
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 - comment @marver and your own coding agent does the work. The tool ships no AI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
package/src/shared/utm.ts
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
* utm_source = the surface class ('published-canvas' | 'dev-canvas')
|
|
5
5
|
* utm_medium = the link unit ('powered-by')
|
|
6
6
|
* utm_campaign = THIS canvas's name, slugged ("Marver tour" -> "marver-tour")
|
|
7
|
-
* utm_content = the placement ('gate' badge | 'shell' wordmark)
|
|
7
|
+
* utm_content = the placement ('gate' badge | 'shell' wordmark | 'sign-in' finish page)
|
|
8
8
|
*/
|
|
9
9
|
export function poweredByUrl(
|
|
10
10
|
canvasName: string | undefined,
|
|
11
11
|
source: 'published-canvas' | 'dev-canvas',
|
|
12
|
-
content: 'gate' | 'shell',
|
|
12
|
+
content: 'gate' | 'shell' | 'sign-in',
|
|
13
13
|
): string {
|
|
14
14
|
const slug = (canvasName ?? '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
15
15
|
const q = new URLSearchParams({
|
|
@@ -72,6 +72,10 @@ If collaboration is deployed, each engineer runs `marver comments connect
|
|
|
72
72
|
cloud sync on top of git. Git carries the committed comments; `connect` adds
|
|
73
73
|
the real-time stream from published viewers.
|
|
74
74
|
|
|
75
|
+
On a canvas gated by Marver Sign In there are no per-engineer CLI accounts:
|
|
76
|
+
identity accounts have no password to connect with. The repo connects once with
|
|
77
|
+
the canvas's `MARVER_CLI_TOKEN`, acting as the owner - see instructions/publish.md.
|
|
78
|
+
|
|
75
79
|
## Dev identity (who comments render as)
|
|
76
80
|
|
|
77
81
|
`marver dev` resolves the local author from `design/.local/` - never invent or
|