@marver-design/marver 0.3.1 → 0.4.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 +89 -0
- package/README.md +2 -0
- package/dist/auth-B36fMCM3.mjs +245 -0
- package/dist/{build-Ckmyci3O.mjs → build-p3xmXU3b.mjs} +52 -12
- 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-C2oTKuXe.mjs → dev-DZi1yRhn.mjs} +17 -1
- package/dist/{init-DolP_Ld4.mjs → init-Ck8z-HiD.mjs} +1 -0
- package/dist/{plugin-Crp4CAma.mjs → plugin-CtQqO5ZZ.mjs} +114 -0
- 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 +2 -1
- package/src/client/frame-host/bridge.js +231 -0
- package/src/client/shell/App.tsx +73 -7
- package/src/client/shell/Comments.tsx +463 -0
- package/src/client/shell/canvas/FrameNode.tsx +32 -2
- package/src/client/shell/comments-store.ts +180 -0
- package/src/client/shell/hash.ts +14 -3
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/store.ts +4 -1
- package/src/client/shell/styles.css +159 -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/dist/serve-OtA9Nlow.mjs +0 -207
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,95 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@marver-design/marver`. Format follows [Keep a Changelog](https://keepachangelog.com); versions follow semver.
|
|
4
4
|
|
|
5
|
+
## 0.4.0 - 2026-08-14
|
|
6
|
+
|
|
7
|
+
The collaboration release (SPEC-M3): the canvas becomes a place where colleagues,
|
|
8
|
+
the designer, and the coding agent close the feedback loop together.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Element-anchored comments.** `C` enters comment mode: click any element inside a
|
|
13
|
+
frame (laser outlines guide you) and the thread pins to it - fractional position
|
|
14
|
+
inside the element's box, so pins ride through responsive reflow. Threads are
|
|
15
|
+
Google-Docs-shaped: root + one level of replies, resolve/reopen. Anchors survive
|
|
16
|
+
agent edits via a verified ladder (semantics → CSS path → fuzzy quote match);
|
|
17
|
+
a dead anchor parks the thread visibly at the frame edge, never silently deleted.
|
|
18
|
+
Inactive frames collapse their open threads into a top-right avatar stack.
|
|
19
|
+
`Shift+C` hides all pins; `?c=<thread>` deep-links a specific thread through the
|
|
20
|
+
password gate (copy-link on every card).
|
|
21
|
+
- **Real commenter identity, no email infrastructure.** Viewers on a published canvas
|
|
22
|
+
claim an account through a single-use invite link (owner mints it; the link travels
|
|
23
|
+
over Slack/DM) - display name, password, avatar. Accounts are scrypt-verified with
|
|
24
|
+
per-user salts; sessions survive restarts. The first account owns the canvas;
|
|
25
|
+
`MARVER_OWNER_EMAIL` prints the owner's one-time claim link in the deploy logs.
|
|
26
|
+
Avatars fall back to initials on a deterministic color.
|
|
27
|
+
- **Gate v2: one credential per persona.** The gate on a collaboration canvas has
|
|
28
|
+
three doors: guests pay the canvas password (read-only), members sign in with
|
|
29
|
+
their OWN password (their session IS gate passage - they never touch the shared
|
|
30
|
+
secret), and an invite link (`<url>/#/i/<token>`) opens straight into the claim -
|
|
31
|
+
email shown as an INVITED chip, profile-picture picker (client-side 128px
|
|
32
|
+
downscale), display name, password. Sign-in/claim endpoints sit in front of the
|
|
33
|
+
gate (rate-limited, non-enumerating); rotating the canvas password only ever
|
|
34
|
+
affects guests. Primary buttons stay disabled until their mandatory fields are
|
|
35
|
+
filled, with a tooltip naming what's missing. The boards payload carries the
|
|
36
|
+
owner's display name, so a read-only refusal says who to ask. Static canvases
|
|
37
|
+
keep the single-field gate untouched.
|
|
38
|
+
- **One deploy, comments live everywhere.** `marver serve` grows a collaboration API
|
|
39
|
+
(REST + SSE) when `MARVER_DATA_DIR` names a durable volume; the published canvas is
|
|
40
|
+
the comments' home. `marver dev` two-way syncs every 30s (`marver comments connect
|
|
41
|
+
<url>` once), so client feedback lands in `design/comments/<board>.jsonl` - a
|
|
42
|
+
git-tracked, append-only event log the agent reads with zero tooling. Republishing
|
|
43
|
+
never clobbers collected feedback (the store unions the bundle seed on boot).
|
|
44
|
+
- **The agent works the queue.** `marver comments list --open --json` / `reply` /
|
|
45
|
+
`resolve --addressed-in <frame>` - resolving records which variant answered the
|
|
46
|
+
feedback, making the fork-don't-overwrite doctrine auditable. `marver comments
|
|
47
|
+
invite <email>` mints single-use invite links from the CLI (owner only), `revoke
|
|
48
|
+
<email>` retires an account. `instructions/iterate.md` carries the
|
|
49
|
+
comments-as-work-queue discipline; `instructions/publish.md` is the agent-facing
|
|
50
|
+
deploy runbook (boards policy, gate, volume, accounts) and AGENTS.md routes to it.
|
|
51
|
+
- **Laser mode.** `L` (or the toolbar crosshair) outlines every element in every frame
|
|
52
|
+
with depth-hued borders (60° per nesting level) plus a DevTools-style hover label.
|
|
53
|
+
Zero layout shift - outlines only. Clicking an element copies its full address for
|
|
54
|
+
the agent - frame source file + CSS path (+ JSX source location when stamped).
|
|
55
|
+
Comment mode shows only the hover highlight (no full rainbow) and a chat-teardrop
|
|
56
|
+
cursor, so picking an element to comment on stays calm.
|
|
57
|
+
- **Default-closed publishing.** `marver build` now requires a publish policy:
|
|
58
|
+
`design/publish.json` names each published board with `read` or `comment` rights
|
|
59
|
+
(enforced server-side, not just hidden in UI). `--boards a,b` stays as an explicit
|
|
60
|
+
override; publishing everything takes a deliberate `--all-boards`.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- **`c` is comment mode now** (the Figma/Miro convention). Copy-selected-file-paths
|
|
65
|
+
moved from `c` to `y`.
|
|
66
|
+
- `marver build` without a publish policy fails with instructions instead of
|
|
67
|
+
publishing everything - the privacy default flipped closed.
|
|
68
|
+
|
|
69
|
+
### Security
|
|
70
|
+
|
|
71
|
+
- Comment mutations are CSRF-protected (double-submit cookie + origin checks) and
|
|
72
|
+
rate-limited; session and invite tokens are stored hashed; every pushed event is
|
|
73
|
+
validated hard (author must match the session, thread ids cannot be hijacked,
|
|
74
|
+
edits are owner-only, timestamps cannot come from the future) - and accepted
|
|
75
|
+
events are never rewritten, so id-keyed sync converges byte-identically.
|
|
76
|
+
- v1 trust boundary, stated plainly: frame code in your design repo runs same-origin
|
|
77
|
+
with the shell - review what you merge. Full frame sandboxing is the v1.1 follow-up
|
|
78
|
+
(SPEC-M3 §0 records the probe results and the plan).
|
|
79
|
+
|
|
80
|
+
### Durability & concurrency
|
|
81
|
+
|
|
82
|
+
- Comment appends and auth writes are `fsync`'d before they are acknowledged - a
|
|
83
|
+
comment or account confirmed to the client survives a crash or volume interruption,
|
|
84
|
+
not just the page cache.
|
|
85
|
+
- `auth.json`'s read-modify-write is guarded by a cross-process lock (stale-stolen
|
|
86
|
+
after 10s, never deadlocks) so a deploy overlap or an accidental second instance
|
|
87
|
+
cannot resurrect a revoked account or drop an invite. The comment log needs no lock:
|
|
88
|
+
append-only + id-keyed union is conflict-free by construction (and git merges it
|
|
89
|
+
with `merge=union`). Two honest v1 limits, documented not hidden: thread ordering
|
|
90
|
+
uses client timestamps (a badly-skewed clock can mis-order a resolve/reopen race -
|
|
91
|
+
re-resolve to fix), and comment logs have no hard size ceiling (fine at the design-
|
|
92
|
+
review scale marver targets; a per-board cap is a later concern).
|
|
93
|
+
|
|
5
94
|
## 0.3.1 - 2026-08-13
|
|
6
95
|
|
|
7
96
|
### Fixed
|
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ Then, to your agent:
|
|
|
19
19
|
- **Devices view**: the Devices menu (or hotkeys `1`-`5`) sizes every frame to mobile / tablet / laptop / monitor / tv to sweep your breakpoints; `0` restores your own layout exactly. Widths live in `design/config.ts`.
|
|
20
20
|
- `data-goto="scene/frame"` on any element links frames into a walkable prototype.
|
|
21
21
|
- **Content frames**: specs, Mermaid diagrams, and mood boards live on the same canvas as the screens - import `Doc`, `Md`, `Diagram`, `Img` from `@marver-design/marver/content` and think a feature through *before* any pixels exist. Diagrams ship pre-themed (both modes), content frames auto-size to their content, and everything - devices, play mode, publish - works on them identically. Works in a repo with no app at all: idea first, design second.
|
|
22
|
+
- **Comments**: Google-Docs-style feedback, pinned to actual elements. Press `C`, click a div inside a frame, write - the thread lives on that element, survives edits via a layered anchor (source semantics → structure → fuzzy text), and collapses to an avatar stack when the frame isn't active. Viewers on a published canvas comment with real names and avatars (invite-link accounts, no email infrastructure); `marver dev` syncs the same threads into `design/comments/*.jsonl`, where your agent works the queue: `npx marver comments list --open --json` → fork a variant → `resolve --addressed-in`. Live via SSE; one deploy, no extra services (set `MARVER_DATA_DIR` on a volume + `MARVER_OWNER_EMAIL` for the first account).
|
|
23
|
+
- **Laser mode**: `L` outlines every element in every frame with depth-hued borders plus a hover label - the fastest way to see structure. Click any element to copy its full address (frame file + CSS path) for the agent. Comment mode is the calm cousin - one at a time with laser - showing only the hovered element so picking a comment target never overwhelms.
|
|
22
24
|
- **Upgrade**: `npm i -D @marver-design/marver@latest && npx marver init`. The canvas tells you when a new version is out (one anonymous registry check per day, cached in `design/.local/`; `MARVER_NO_UPDATE_CHECK=1` disables). Re-running init refreshes the managed files (AGENTS.md, `design/instructions/`) - your edits to them are detected and preserved; when both you and a release changed a file, the fresh version is staged at `design/.local/latest/` for you (or your agent) to merge. Everything else in `design/` is yours and never touched.
|
|
23
25
|
- Uninstall: delete `design/`, remove the dependency. (If `init` patched your tsconfig `exclude`, revert that one line.)
|
|
24
26
|
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
4
|
+
//#region src/server/auth.ts
|
|
5
|
+
/**
|
|
6
|
+
* Accounts, invites, sessions (SPEC-M3 §3) - the minimal credible implementation,
|
|
7
|
+
* extending the gate's own idiom. No framework: per-user salted scrypt verifiers,
|
|
8
|
+
* opaque session tokens stored hashed, single-use invite links as the identity
|
|
9
|
+
* bootstrap (no email infrastructure anywhere).
|
|
10
|
+
*
|
|
11
|
+
* Password = read, account = comment: everything here concerns accounts only; the
|
|
12
|
+
* shared gate password (serve.ts) remains the outer READ boundary.
|
|
13
|
+
*
|
|
14
|
+
* State lives in MARVER_DATA_DIR as two small JSON files rewritten atomically -
|
|
15
|
+
* users change rarely; the event-log treatment is reserved for comments.
|
|
16
|
+
*/
|
|
17
|
+
const SCRYPT = {
|
|
18
|
+
N: 2 ** 15,
|
|
19
|
+
r: 8,
|
|
20
|
+
p: 1,
|
|
21
|
+
keylen: 32,
|
|
22
|
+
maxmem: 67108864
|
|
23
|
+
};
|
|
24
|
+
const INVITE_TTL = 6048e5;
|
|
25
|
+
const SESSION_TTL = 2592e6;
|
|
26
|
+
const normEmail = (e) => e.trim().toLowerCase();
|
|
27
|
+
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
28
|
+
const token = () => randomBytes(32).toString("base64url");
|
|
29
|
+
const storeFile = (dir) => join(dir, "auth.json");
|
|
30
|
+
/** Only a MISSING file is an empty store. A present-but-unreadable/corrupt auth.json
|
|
31
|
+
* must fail CLOSED - treating it as empty would let the owner bootstrap re-run and
|
|
32
|
+
* a later save overwrite every account. */
|
|
33
|
+
function loadStore(dir) {
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = readFileSync(storeFile(dir), "utf8");
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err.code === "ENOENT") return {
|
|
39
|
+
users: [],
|
|
40
|
+
invites: [],
|
|
41
|
+
sessions: []
|
|
42
|
+
};
|
|
43
|
+
throw new Error(`auth store unreadable (${err.message}) - refusing to treat it as empty`);
|
|
44
|
+
}
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(raw);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error("auth store is corrupt JSON - refusing to treat it as empty. Restore it or delete it deliberately.");
|
|
50
|
+
}
|
|
51
|
+
if (!Array.isArray(parsed?.users) || !Array.isArray(parsed?.invites) || !Array.isArray(parsed?.sessions)) throw new Error("auth store has an unexpected shape - refusing to load it");
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
/** Atomic rewrite (tmp + rename) - a crash mid-write must never lose every account.
|
|
55
|
+
* 0600 throughout: the store holds emails and password verifiers. */
|
|
56
|
+
function saveStore(dir, store) {
|
|
57
|
+
const file = storeFile(dir);
|
|
58
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
store.invites = store.invites.filter((i) => i.exp > now);
|
|
61
|
+
store.sessions = store.sessions.filter((s) => s.exp > now);
|
|
62
|
+
const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
|
63
|
+
const fd = openSync(tmp, "wx", 384);
|
|
64
|
+
try {
|
|
65
|
+
writeSync(fd, JSON.stringify(store, null, 2));
|
|
66
|
+
fsyncSync(fd);
|
|
67
|
+
} finally {
|
|
68
|
+
closeSync(fd);
|
|
69
|
+
}
|
|
70
|
+
renameSync(tmp, file);
|
|
71
|
+
}
|
|
72
|
+
/** Cross-process mutex over auth.json's read-modify-write. Within one Node process
|
|
73
|
+
* the store mutations are already synchronous and atomic; this covers deploy overlap
|
|
74
|
+
* and any accidental multi-instance run (the supported setup is single-instance) -
|
|
75
|
+
* without it, a sign-in that loaded a pre-revoke snapshot could rename it back over a
|
|
76
|
+
* successful revoke. A crashed holder's lock is stolen after 10s; waiting past 5s
|
|
77
|
+
* fails loudly rather than hanging. */
|
|
78
|
+
function withLock(dir, fn) {
|
|
79
|
+
mkdirSync(dir, { recursive: true });
|
|
80
|
+
const lock = join(dir, ".auth.lock");
|
|
81
|
+
const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
82
|
+
const deadline = Date.now() + 5e3;
|
|
83
|
+
for (;;) try {
|
|
84
|
+
closeSync(openSync(lock, "wx"));
|
|
85
|
+
break;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if (e.code !== "EEXIST") throw e;
|
|
88
|
+
try {
|
|
89
|
+
if (Date.now() - statSync(lock).mtimeMs > 1e4) {
|
|
90
|
+
unlinkSync(lock);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (Date.now() > deadline) throw new Error("auth store is busy - please retry");
|
|
97
|
+
nap(25);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return fn();
|
|
101
|
+
} finally {
|
|
102
|
+
try {
|
|
103
|
+
unlinkSync(lock);
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const findUser = (store, email) => store.users.find((u) => normEmail(u.email) === normEmail(email));
|
|
108
|
+
/** Mint a single-use invite for an email (this IS the allowlist entry - inviting an
|
|
109
|
+
* address authorizes it). Re-inviting an email replaces its pending invite. The raw
|
|
110
|
+
* token is returned exactly once; only its hash is stored. */
|
|
111
|
+
/** The owner's display name - public-safe (never the email). Null before claim. */
|
|
112
|
+
function ownerName(dir) {
|
|
113
|
+
return loadStore(dir).users.find((u) => u.role === "owner")?.name?.trim() || null;
|
|
114
|
+
}
|
|
115
|
+
/** Peek at a live invite: the claim screens show WHO the invite is for. The raw
|
|
116
|
+
* token is the proof - holding it means the owner sent it to you. */
|
|
117
|
+
function inviteInfo(dir, rawToken) {
|
|
118
|
+
const hash = sha256(rawToken);
|
|
119
|
+
const invite = loadStore(dir).invites.find((i) => i.tokenHash === hash && i.exp > Date.now());
|
|
120
|
+
return invite ? { email: invite.emailNorm } : null;
|
|
121
|
+
}
|
|
122
|
+
function createInvite(dir, email) {
|
|
123
|
+
return withLock(dir, () => {
|
|
124
|
+
const store = loadStore(dir);
|
|
125
|
+
if (findUser(store, email)) throw new Error(`${normEmail(email)} already has an account`);
|
|
126
|
+
const raw = token();
|
|
127
|
+
const exp = Date.now() + INVITE_TTL;
|
|
128
|
+
store.invites = store.invites.filter((i) => i.emailNorm !== normEmail(email));
|
|
129
|
+
store.invites.push({
|
|
130
|
+
emailNorm: normEmail(email),
|
|
131
|
+
tokenHash: sha256(raw),
|
|
132
|
+
exp
|
|
133
|
+
});
|
|
134
|
+
saveStore(dir, store);
|
|
135
|
+
return {
|
|
136
|
+
token: raw,
|
|
137
|
+
exp
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** Claim an invite: burns it, creates the account, opens the first session. */
|
|
142
|
+
function claimInvite(dir, rawToken, profile) {
|
|
143
|
+
if (profile.password.length < 8) throw new Error("password must be at least 8 characters");
|
|
144
|
+
if (!profile.name.trim()) throw new Error("a display name is required");
|
|
145
|
+
return withLock(dir, () => {
|
|
146
|
+
const store = loadStore(dir);
|
|
147
|
+
const hash = sha256(rawToken);
|
|
148
|
+
const invite = store.invites.find((i) => i.tokenHash === hash && i.exp > Date.now());
|
|
149
|
+
if (!invite) throw new Error("this invite link is invalid, expired, or already used");
|
|
150
|
+
store.invites = store.invites.filter((i) => i !== invite);
|
|
151
|
+
const salt = randomBytes(16).toString("hex");
|
|
152
|
+
const user = {
|
|
153
|
+
email: invite.emailNorm,
|
|
154
|
+
name: profile.name.trim(),
|
|
155
|
+
avatar: profile.avatar,
|
|
156
|
+
role: store.users.length ? "member" : "owner",
|
|
157
|
+
salt,
|
|
158
|
+
hash: scryptSync(profile.password, Buffer.from(salt, "hex"), SCRYPT.keylen, SCRYPT).toString("hex"),
|
|
159
|
+
params: SCRYPT,
|
|
160
|
+
createdAt: Date.now()
|
|
161
|
+
};
|
|
162
|
+
store.users.push(user);
|
|
163
|
+
const session = pushSession(store, user);
|
|
164
|
+
saveStore(dir, store);
|
|
165
|
+
return {
|
|
166
|
+
user,
|
|
167
|
+
session
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** Password sign-in. One generic failure - never reveal whether the email exists. */
|
|
172
|
+
function signIn(dir, email, password) {
|
|
173
|
+
return withLock(dir, () => {
|
|
174
|
+
const store = loadStore(dir);
|
|
175
|
+
const user = findUser(store, email);
|
|
176
|
+
const salt = user ? Buffer.from(user.salt, "hex") : randomBytes(16);
|
|
177
|
+
const params = user?.params ?? SCRYPT;
|
|
178
|
+
const got = scryptSync(password, salt, params.keylen, params);
|
|
179
|
+
const want = user ? Buffer.from(user.hash, "hex") : randomBytes(SCRYPT.keylen);
|
|
180
|
+
if (!user || got.length !== want.length || !timingSafeEqual(got, want)) return null;
|
|
181
|
+
const session = pushSession(store, user);
|
|
182
|
+
saveStore(dir, store);
|
|
183
|
+
return {
|
|
184
|
+
user,
|
|
185
|
+
session
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function pushSession(store, user) {
|
|
190
|
+
const raw = token();
|
|
191
|
+
store.sessions.push({
|
|
192
|
+
tokenHash: sha256(raw),
|
|
193
|
+
emailNorm: normEmail(user.email),
|
|
194
|
+
exp: Date.now() + SESSION_TTL
|
|
195
|
+
});
|
|
196
|
+
return raw;
|
|
197
|
+
}
|
|
198
|
+
/** Resolve a session token to its user; null when unknown or expired. */
|
|
199
|
+
function sessionUser(dir, rawToken) {
|
|
200
|
+
const store = loadStore(dir);
|
|
201
|
+
const hash = sha256(rawToken);
|
|
202
|
+
const s = store.sessions.find((s) => s.tokenHash === hash && s.exp > Date.now());
|
|
203
|
+
return s ? store.users.find((u) => normEmail(u.email) === s.emailNorm) ?? null : null;
|
|
204
|
+
}
|
|
205
|
+
function signOut(dir, rawToken) {
|
|
206
|
+
withLock(dir, () => {
|
|
207
|
+
const store = loadStore(dir);
|
|
208
|
+
const hash = sha256(rawToken);
|
|
209
|
+
store.sessions = store.sessions.filter((s) => s.tokenHash !== hash);
|
|
210
|
+
saveStore(dir, store);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/** Update name/avatar on an existing account. */
|
|
214
|
+
function updateProfile(dir, email, patch) {
|
|
215
|
+
return withLock(dir, () => {
|
|
216
|
+
const store = loadStore(dir);
|
|
217
|
+
const user = findUser(store, email);
|
|
218
|
+
if (!user) throw new Error("no such account");
|
|
219
|
+
if (patch.name?.trim()) user.name = patch.name.trim();
|
|
220
|
+
if (patch.avatar !== void 0) user.avatar = patch.avatar || void 0;
|
|
221
|
+
saveStore(dir, store);
|
|
222
|
+
return user;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** Remove an account and all its sessions (owner action). The LAST owner cannot be
|
|
226
|
+
* removed - a store with members but no owner has no one left to administer it,
|
|
227
|
+
* and bootstrap will not re-run while any user exists. */
|
|
228
|
+
function revokeUser(dir, email) {
|
|
229
|
+
withLock(dir, () => {
|
|
230
|
+
const store = loadStore(dir);
|
|
231
|
+
if (store.users.find((u) => normEmail(u.email) === normEmail(email))?.role === "owner" && !store.users.some((u) => u.role === "owner" && normEmail(u.email) !== normEmail(email))) throw new Error("cannot remove the last owner - the canvas would have no administrator left");
|
|
232
|
+
store.users = store.users.filter((u) => normEmail(u.email) !== normEmail(email));
|
|
233
|
+
store.sessions = store.sessions.filter((s) => s.emailNorm !== normEmail(email));
|
|
234
|
+
store.invites = store.invites.filter((i) => i.emailNorm !== normEmail(email));
|
|
235
|
+
saveStore(dir, store);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
/** The public shape of a user - what other viewers (and events) may see. */
|
|
239
|
+
const publicUser = (u) => ({
|
|
240
|
+
email: u.email,
|
|
241
|
+
name: u.name,
|
|
242
|
+
avatar: u.avatar
|
|
243
|
+
});
|
|
244
|
+
//#endregion
|
|
245
|
+
export { claimInvite, createInvite, inviteInfo, loadStore, normEmail, ownerName, publicUser, revokeUser, sessionUser, signIn, signOut, updateProfile };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { i as ROUTE, n as NAME } from "./cli.mjs";
|
|
2
2
|
import { a as loadConfig, n as scanFrames, o as detectHost } from "./manifest-DW-T52MM.mjs";
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
3
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-CtQqO5ZZ.mjs";
|
|
4
4
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, sep } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -43,6 +43,37 @@ function scanAssetRefs(src, moduleId) {
|
|
|
43
43
|
}
|
|
44
44
|
/** Same shape the client's assetUrl accepts: relative, inside design/assets/, no tricks. */
|
|
45
45
|
const isLocalAssetRef = (p) => !!p && !p.includes(":") && !p.startsWith("/") && !p.startsWith("\\") && !p.split("/").some((s) => s === ".." || s === "");
|
|
46
|
+
/** The publish policy (SPEC-M3 §4): design/publish.json names what ships and with what
|
|
47
|
+
* rights. Publishing is default-CLOSED - no policy and no explicit flag = no build.
|
|
48
|
+
* Returns name -> 'read' | 'comment'. Boards absent from the result do not ship. */
|
|
49
|
+
function resolvePublish(root, allBoards, boardsFlag, allBoardsFlag) {
|
|
50
|
+
const known = (n) => n === "all-scenes" || !!allBoards[n];
|
|
51
|
+
if (boardsFlag !== void 0) {
|
|
52
|
+
const names = boardsFlag.split(",").map((s) => s.trim()).filter(Boolean);
|
|
53
|
+
if (!names.length) throw new Error("--boards was given but named no boards");
|
|
54
|
+
const missing = names.filter((n) => !known(n));
|
|
55
|
+
if (missing.length) throw new Error(`--boards names not found in design/boards/: ${missing.join(", ")}`);
|
|
56
|
+
return Object.fromEntries(names.map((n) => [n, "comment"]));
|
|
57
|
+
}
|
|
58
|
+
if (allBoardsFlag) return Object.fromEntries(["all-scenes", ...Object.keys(allBoards).filter((n) => n !== "all-scenes")].map((n) => [n, "comment"]));
|
|
59
|
+
const policyFile = join(root, "design", "publish.json");
|
|
60
|
+
if (!existsSync(policyFile)) throw new Error("publishing is default-closed and no publish policy exists.\n Either declare one in design/publish.json: { \"boards\": { \"<board>\": \"read\" | \"comment\" } }\n or be explicit: --boards a,b (publish just those) · --all-boards (publish everything)");
|
|
61
|
+
let policy;
|
|
62
|
+
try {
|
|
63
|
+
policy = JSON.parse(readFileSync(policyFile, "utf8"));
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error("design/publish.json is not valid JSON");
|
|
66
|
+
}
|
|
67
|
+
const entries = Object.entries(policy?.boards ?? {});
|
|
68
|
+
if (!entries.length) throw new Error("design/publish.json has no \"boards\" entries - publishing is default-closed, name what ships");
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const [n, level] of entries) {
|
|
71
|
+
if (!known(n)) throw new Error(`design/publish.json names an unknown board: ${n}`);
|
|
72
|
+
if (level !== "read" && level !== "comment") throw new Error(`design/publish.json: board "${n}" has level "${level}" - use "read" or "comment"`);
|
|
73
|
+
out[n] = level;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
46
77
|
/** Read every board file; returns name -> parsed json. Bad JSON fails the build loudly. */
|
|
47
78
|
function readBoards(root) {
|
|
48
79
|
const dir = join(root, "design", "boards");
|
|
@@ -104,7 +135,7 @@ export function layoutChain(fileKey) {
|
|
|
104
135
|
}
|
|
105
136
|
`;
|
|
106
137
|
}
|
|
107
|
-
async function buildSite(root, boardsFlag) {
|
|
138
|
+
async function buildSite(root, boardsFlag, allBoardsFlag) {
|
|
108
139
|
const config = await loadConfig(root);
|
|
109
140
|
const host = detectHost(root);
|
|
110
141
|
const pkgDir = packageDir();
|
|
@@ -112,13 +143,8 @@ async function buildSite(root, boardsFlag) {
|
|
|
112
143
|
const outDir = join(root, "design", ".dist");
|
|
113
144
|
const manifest = scanFrames(root);
|
|
114
145
|
const allBoards = readBoards(root);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
publishedNames = boardsFlag.split(",").map((s) => s.trim()).filter(Boolean);
|
|
118
|
-
if (!publishedNames.length) throw new Error("--boards was given but named no boards");
|
|
119
|
-
const missing = publishedNames.filter((n) => n !== "all-scenes" && !allBoards[n]);
|
|
120
|
-
if (missing.length) throw new Error(`--boards names not found in design/boards/: ${missing.join(", ")}`);
|
|
121
|
-
} else publishedNames = ["all-scenes", ...Object.keys(allBoards).filter((n) => n !== "all-scenes")];
|
|
146
|
+
const rights = resolvePublish(root, allBoards, boardsFlag, allBoardsFlag);
|
|
147
|
+
const publishedNames = Object.keys(rights);
|
|
122
148
|
const includeAll = publishedNames.includes("all-scenes");
|
|
123
149
|
const boards = {};
|
|
124
150
|
for (const n of publishedNames) if (allBoards[n]) boards[n] = allBoards[n];
|
|
@@ -138,7 +164,8 @@ async function buildSite(root, boardsFlag) {
|
|
|
138
164
|
},
|
|
139
165
|
boards,
|
|
140
166
|
names: publishedNames,
|
|
141
|
-
default: publishedNames[0]
|
|
167
|
+
default: publishedNames[0],
|
|
168
|
+
rights
|
|
142
169
|
};
|
|
143
170
|
const registryFile = posix(join(clientDir, "frame-host", "registry.ts"));
|
|
144
171
|
const plugins = [{
|
|
@@ -258,6 +285,18 @@ async function buildSite(root, boardsFlag) {
|
|
|
258
285
|
cpSync(srcFile, dest);
|
|
259
286
|
copiedAssets++;
|
|
260
287
|
}
|
|
288
|
+
const commentsDir = join(root, "design", "comments");
|
|
289
|
+
if (existsSync(commentsDir)) {
|
|
290
|
+
let seeded = 0;
|
|
291
|
+
for (const n of publishedNames) {
|
|
292
|
+
const f = join(commentsDir, `${n}.jsonl`);
|
|
293
|
+
if (!existsSync(f)) continue;
|
|
294
|
+
mkdirSync(join(outDir, "design", "comments"), { recursive: true });
|
|
295
|
+
cpSync(f, join(outDir, "design", "comments", `${n}.jsonl`));
|
|
296
|
+
seeded++;
|
|
297
|
+
}
|
|
298
|
+
if (seeded) console.log(` comments: ${seeded} board log${seeded === 1 ? "" : "s"} seeded`);
|
|
299
|
+
}
|
|
261
300
|
let name = basename(root);
|
|
262
301
|
try {
|
|
263
302
|
name = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name ?? name;
|
|
@@ -282,10 +321,11 @@ async function buildSite(root, boardsFlag) {
|
|
|
282
321
|
writeFileSync(join(outDir, "meta.json"), JSON.stringify({
|
|
283
322
|
name,
|
|
284
323
|
branding: config.share.branding,
|
|
285
|
-
logo
|
|
324
|
+
logo,
|
|
325
|
+
rights
|
|
286
326
|
}));
|
|
287
327
|
console.log(`\n ${NAME} build → design/.dist`);
|
|
288
|
-
console.log(` boards: ${publishedNames.join(", ")}`);
|
|
328
|
+
console.log(` boards: ${publishedNames.map((n) => `${n} (${rights[n]})`).join(", ")}`);
|
|
289
329
|
console.log(` frames: ${frames.length}${includeAll ? "" : ` of ${manifest.frames.length} (build-time filter)`}`);
|
|
290
330
|
if (copiedAssets) console.log(` assets: ${copiedAssets} referenced file${copiedAssets === 1 ? "" : "s"} from design/assets/ (unreferenced assets never ship)`);
|
|
291
331
|
if (!includeAll && existsSync(join(root, "public"))) console.log(` note: the host public/ directory ships in full - the --boards filter covers frames, not public assets`);
|
package/dist/cli.mjs
CHANGED
|
@@ -39,14 +39,14 @@ function version() {
|
|
|
39
39
|
}
|
|
40
40
|
const cli = cac(NAME);
|
|
41
41
|
cli.command("init", "Scaffold design/ in this repo").option("--mode <mode>", "studio | embedded", { default: "studio" }).option("--no-demo", "Skip the demo scene (the demo ships unless this flag is passed)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
|
|
42
|
-
const { init } = await import("./init-
|
|
42
|
+
const { init } = await import("./init-Ck8z-HiD.mjs");
|
|
43
43
|
init(resolve(opts.root), {
|
|
44
44
|
mode: opts.mode === "embedded" ? "embedded" : "studio",
|
|
45
45
|
demo: opts.demo !== false
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
48
|
cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
|
|
49
|
-
const { dev } = await import("./dev-
|
|
49
|
+
const { dev } = await import("./dev-DZi1yRhn.mjs");
|
|
50
50
|
let port;
|
|
51
51
|
if (opts.port !== void 0) {
|
|
52
52
|
const n = Number(opts.port);
|
|
@@ -55,18 +55,18 @@ cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root",
|
|
|
55
55
|
}
|
|
56
56
|
await dev(resolve(opts.root), port);
|
|
57
57
|
});
|
|
58
|
-
cli.command("build", "Static export → design/.dist").option("--boards <names>", "Publish only these boards (comma-separated); the
|
|
59
|
-
const { buildSite } = await import("./build-
|
|
58
|
+
cli.command("build", "Static export → design/.dist (what ships comes from design/publish.json - publishing is default-closed)").option("--boards <names>", "Publish only these boards (comma-separated); overrides the publish policy").option("--all-boards", "Publish every board - the loud override for the default-closed policy").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
|
|
59
|
+
const { buildSite } = await import("./build-p3xmXU3b.mjs");
|
|
60
60
|
try {
|
|
61
61
|
const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
|
|
62
|
-
await buildSite(resolve(opts.root), boards);
|
|
62
|
+
await buildSite(resolve(opts.root), boards, opts.allBoards === true);
|
|
63
63
|
} catch (err) {
|
|
64
64
|
console.error(`[${NAME}] build failed: ${err.message}`);
|
|
65
65
|
process.exit(1);
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
68
|
cli.command("serve", "Serve design/.dist (set MARVER_PASSWORD to gate it)").option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default $PORT or 4199)").action(async (opts) => {
|
|
69
|
-
const { serve } = await import("./serve-
|
|
69
|
+
const { serve } = await import("./serve-D_KBK7Oy.mjs");
|
|
70
70
|
let port;
|
|
71
71
|
if (opts.port !== void 0) {
|
|
72
72
|
const n = Number(opts.port);
|
|
@@ -74,6 +74,15 @@ cli.command("serve", "Serve design/.dist (set MARVER_PASSWORD to gate it)").opti
|
|
|
74
74
|
}
|
|
75
75
|
serve(resolve(opts.root), port);
|
|
76
76
|
});
|
|
77
|
+
cli.command("comments <action> [value]", "Comment collaboration: connect <url> · sync · list · reply <thread> · resolve <thread> · invite <email> · revoke <email>").option("--root <dir>", "Host repo root", { default: "." }).option("--invite <token>", "connect: claim this invite instead of signing in").option("--canvas-password <password>", "connect: the canvas gate password (default $MARVER_PASSWORD or prompt)").option("--email <email>", "connect: account email (skips the prompt)").option("--password <password>", "connect: account password (skips the prompt - mind your shell history)").option("--name <name>", "connect --invite: display name for the new account").option("--open", "list: only unresolved threads").option("--json", "list: machine-readable output").option("--board <board>", "scope to one board").option("--body <text>", "reply: the reply text").option("--addressed-in <frame>", "resolve: the variant frame that answered the feedback").action(async (action, value, opts) => {
|
|
78
|
+
const { commentsCommand } = await import("./comments-odHzYdO3.mjs");
|
|
79
|
+
try {
|
|
80
|
+
await commentsCommand(resolve(opts.root), action, value, opts);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error(`[${NAME}] ${err.message}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
77
86
|
cli.help();
|
|
78
87
|
cli.version(version());
|
|
79
88
|
cli.parse();
|