@marver-design/marver 0.9.0 → 0.10.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 +95 -0
- package/README.md +4 -4
- package/dist/{build-D9gimz6K.mjs → build-BkckEcZd.mjs} +2 -2
- package/dist/cli.mjs +12 -3
- package/dist/{daemon-2Lft_lVV.mjs → daemon-B_8BmmSJ.mjs} +486 -15
- package/dist/{dev-CTtkqVo_.mjs → dev-Bm-R_-gs.mjs} +4 -4
- package/dist/{init-8Giknvy1.mjs → init-D2EQEbV6.mjs} +4 -3
- package/dist/{manifest-DJHU7qfu.mjs → manifest-DIsp3ldB.mjs} +44 -12
- package/dist/{plugin-BrBWk2Qn.mjs → plugin-DhkR3NdW.mjs} +192 -4
- package/dist/shot-BRgPaFCs.mjs +254 -0
- package/dist/shot-DkkwuCZ2.mjs +29 -0
- package/package.json +1 -1
- package/src/client/frame-host/main.tsx +8 -1
- package/src/client/shell/App.tsx +271 -31
- package/src/client/shell/Comments.tsx +1 -1
- package/src/client/shell/Play.tsx +5 -4
- package/src/client/shell/store.ts +56 -2
- package/src/client/shell/styles.css +25 -0
- package/templates/AGENTS-embedded.md +11 -4
- package/templates/AGENTS-studio.md +11 -4
- package/templates/instructions/boards.md +3 -1
- package/templates/instructions/jam.md +93 -6
|
@@ -138,15 +138,46 @@ function firstExisting(root, candidates) {
|
|
|
138
138
|
* evidence rather than a guess. Whatever wins must still be on PATH - the daemon has to
|
|
139
139
|
* spawn it.
|
|
140
140
|
*/
|
|
141
|
-
/** Ordered by preference when a machine has
|
|
142
|
-
const AGENTS = [
|
|
141
|
+
/** Ordered by preference when a machine has several installed and none is running us. */
|
|
142
|
+
const AGENTS = [
|
|
143
|
+
"claude",
|
|
144
|
+
"codex",
|
|
145
|
+
"cursor",
|
|
146
|
+
"droid",
|
|
147
|
+
"opencode",
|
|
148
|
+
"grok",
|
|
149
|
+
"pi"
|
|
150
|
+
];
|
|
143
151
|
/** Env vars each CLI sets in the processes it spawns. Deliberately NOT CODEX_HOME or
|
|
144
152
|
* ANTHROPIC_API_KEY: those are configuration a human exports by hand, not evidence that
|
|
145
|
-
* the tool is running right now.
|
|
153
|
+
* the tool is running right now. (opencode's `AGENT=1` and pi's `AI_AGENT` are skipped
|
|
154
|
+
* for the same reason in reverse - too generic to prove WHICH tool is running.)
|
|
155
|
+
* droid and grok set no marker at all in the shells they spawn (verified against the
|
|
156
|
+
* grok-build source and droid 0.200.0), so they are found by PATH order only. */
|
|
146
157
|
const MARKERS = {
|
|
147
158
|
claude: ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"],
|
|
148
|
-
codex: ["CODEX_SANDBOX", "CODEX_THREAD_ID"]
|
|
159
|
+
codex: ["CODEX_SANDBOX", "CODEX_THREAD_ID"],
|
|
160
|
+
cursor: ["CURSOR_AGENT"],
|
|
161
|
+
droid: [],
|
|
162
|
+
opencode: ["OPENCODE", "OPENCODE_PID"],
|
|
163
|
+
grok: [],
|
|
164
|
+
pi: ["PI_CODING_AGENT", "PI_SESSION_ID"]
|
|
149
165
|
};
|
|
166
|
+
/** The executable each agent name maps to. Only cursor differs: its CLI installs BOTH
|
|
167
|
+
* `cursor-agent` and a bare `agent` - and grok's installer symlinks `agent` too, so the
|
|
168
|
+
* short name is a coin flip on a machine with both. The long name is unambiguous. */
|
|
169
|
+
const AGENT_BIN = {
|
|
170
|
+
claude: "claude",
|
|
171
|
+
codex: "codex",
|
|
172
|
+
cursor: "cursor-agent",
|
|
173
|
+
droid: "droid",
|
|
174
|
+
opencode: "opencode",
|
|
175
|
+
grok: "grok",
|
|
176
|
+
pi: "pi"
|
|
177
|
+
};
|
|
178
|
+
/** The valid `jam.agent` values as an English list - one string, so every surface
|
|
179
|
+
* (config warning, init note, dev boot line) names the same set and none goes stale. */
|
|
180
|
+
const AGENT_NAMES = `${AGENTS.slice(0, -1).map((a) => `"${a}"`).join(", ")}, or "${AGENTS[AGENTS.length - 1]}"`;
|
|
150
181
|
/** Is `cmd` an executable FILE on PATH? A direct stat per PATH entry - no spawn, no shell,
|
|
151
182
|
* so this is safe to call on every dev boot. Two deliberate narrowings, both in service of
|
|
152
183
|
* "found means spawnable", because arming an agent that cannot run is worse than staying off:
|
|
@@ -183,8 +214,8 @@ function onPath(cmd, env = process.env) {
|
|
|
183
214
|
* that line names the tool it actually is. One word to correct, once per repo. */
|
|
184
215
|
function detectAgent(env = process.env) {
|
|
185
216
|
const running = AGENTS.find((a) => MARKERS[a].some((k) => env[k]));
|
|
186
|
-
if (running && onPath(running, env)) return running;
|
|
187
|
-
return AGENTS.find((a) => onPath(a, env));
|
|
217
|
+
if (running && onPath(AGENT_BIN[running], env)) return running;
|
|
218
|
+
return AGENTS.find((a) => onPath(AGENT_BIN[a], env));
|
|
188
219
|
}
|
|
189
220
|
//#endregion
|
|
190
221
|
//#region src/server/config.ts
|
|
@@ -278,15 +309,16 @@ const plainObject = (v) => {
|
|
|
278
309
|
* claim every @marver mention, fail it, and never retry. */
|
|
279
310
|
function resolveAgent(named) {
|
|
280
311
|
if (named === void 0) return detectAgent() ?? "no-agent";
|
|
281
|
-
if (named !== "
|
|
282
|
-
console.warn(`[marver] design/config.ts: jam.agent ${show(named)} is not an agent marver can spawn - Live Jam is off. Use
|
|
312
|
+
if (typeof named !== "string" || !Object.hasOwn(AGENT_BIN, named)) {
|
|
313
|
+
console.warn(`[marver] design/config.ts: jam.agent ${show(named)} is not an agent marver can spawn - Live Jam is off. Use ${AGENT_NAMES}.`);
|
|
283
314
|
return "bad-agent";
|
|
284
315
|
}
|
|
285
|
-
|
|
286
|
-
|
|
316
|
+
const agent = named;
|
|
317
|
+
if (!onPath(AGENT_BIN[agent])) {
|
|
318
|
+
console.warn(`[marver] design/config.ts names jam.agent "${agent}", but \`${AGENT_BIN[agent]}\` is not on PATH - Live Jam is off until it is installed.`);
|
|
287
319
|
return "bad-agent";
|
|
288
320
|
}
|
|
289
|
-
return
|
|
321
|
+
return agent;
|
|
290
322
|
}
|
|
291
323
|
/** Resolve the jam block. Live Jam is ON by default: an absent or partial `jam` resolves
|
|
292
324
|
* to whatever agent CLI this machine has, so a fresh workspace jams with nothing
|
|
@@ -564,4 +596,4 @@ function writeManifest(root, manifest) {
|
|
|
564
596
|
}
|
|
565
597
|
const hash = (s) => createHash("sha256").update(s).digest("hex");
|
|
566
598
|
//#endregion
|
|
567
|
-
export {
|
|
599
|
+
export { writeManifest as a, detectAgent as c, toFrameId as i, detectHost as l, hash as n, DEFAULTS as o, scanFrames as r, loadConfig as s, affectedFrameIds as t, readJson as u };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
|
|
2
2
|
import { n as localProfile, t as isConnected } from "./profile-BkiWglVE.mjs";
|
|
3
|
-
import {
|
|
4
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
3
|
+
import { a as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-DIsp3ldB.mjs";
|
|
4
|
+
import { copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { randomBytes } from "node:crypto";
|
|
@@ -82,7 +82,7 @@ function contained(target, base) {
|
|
|
82
82
|
return false;
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
-
function apiMiddleware(root) {
|
|
85
|
+
function apiMiddleware(root, opts = {}) {
|
|
86
86
|
const boardsDir = join(root, "design", "boards");
|
|
87
87
|
const boardPath = (name) => {
|
|
88
88
|
if (!BOARD_NAME.test(name)) return null;
|
|
@@ -90,6 +90,31 @@ function apiMiddleware(root) {
|
|
|
90
90
|
const p = resolve(boardsDir, `${name}.json`);
|
|
91
91
|
return contained(p, boardsDir) ? p : null;
|
|
92
92
|
};
|
|
93
|
+
const validName = (n) => typeof n === "string" && n.length >= 1 && n.length <= 64 && BOARD_NAME.test(n);
|
|
94
|
+
const underRoot = (dir) => {
|
|
95
|
+
try {
|
|
96
|
+
const rr = realpathSync(root);
|
|
97
|
+
const rd = realpathSync(dir);
|
|
98
|
+
return rd === rr || rd.startsWith(rr + sep);
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const notSymlink = (p) => {
|
|
104
|
+
try {
|
|
105
|
+
return !existsSync(p) || !lstatSync(p).isSymbolicLink();
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const nodeExists = (p) => {
|
|
111
|
+
try {
|
|
112
|
+
lstatSync(p);
|
|
113
|
+
return true;
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
93
118
|
try {
|
|
94
119
|
for (const f of readdirSync(boardsDir)) if (f.endsWith(".tmp")) rmSync(join(boardsDir, f), { force: true });
|
|
95
120
|
} catch {}
|
|
@@ -115,6 +140,72 @@ function apiMiddleware(root) {
|
|
|
115
140
|
};
|
|
116
141
|
}));
|
|
117
142
|
}
|
|
143
|
+
if (path === "boards/rename" && req.method === "POST") {
|
|
144
|
+
if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
|
|
145
|
+
const raw = await readBody(req);
|
|
146
|
+
if (raw == null) return json(res, 400, { error: "body too large or unreadable" });
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(raw);
|
|
150
|
+
} catch {
|
|
151
|
+
return json(res, 400, { error: "malformed JSON" });
|
|
152
|
+
}
|
|
153
|
+
if (!parsed || typeof parsed !== "object") return json(res, 400, { error: "expected an object" });
|
|
154
|
+
const { from, to } = parsed;
|
|
155
|
+
if (!validName(from) || !validName(to)) return json(res, 400, { error: "invalid board name" });
|
|
156
|
+
if (to === "all-scenes" || from === "all-scenes" || to === from) return json(res, 400, { error: "invalid rename" });
|
|
157
|
+
if (!underRoot(boardsDir)) return json(res, 400, { error: "boards directory escapes the project" });
|
|
158
|
+
const fromPath = boardPath(from), toPath = boardPath(to);
|
|
159
|
+
if (!fromPath || !toPath) return json(res, 400, { error: "invalid board name" });
|
|
160
|
+
if (!existsSync(fromPath)) return json(res, 404, { error: `board "${from}" does not exist` });
|
|
161
|
+
if (!notSymlink(fromPath)) return json(res, 400, { error: "refusing to rename a symlinked board file" });
|
|
162
|
+
if (isConnected(root)) return json(res, 409, { error: "disconnect before renaming boards - comment sync is board-keyed" });
|
|
163
|
+
if (existsSync(join(root, "design", "comments", `${from}.jsonl`))) return json(res, 409, { error: "rename a board before commenting on it - this board has threads" });
|
|
164
|
+
if (nodeExists(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
|
|
165
|
+
try {
|
|
166
|
+
linkSync(fromPath, toPath);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const code = err.code;
|
|
169
|
+
if (code === "EEXIST") return json(res, 409, { error: `a board named "${to}" already exists` });
|
|
170
|
+
if (code !== "EPERM" && code !== "ENOSYS" && code !== "ENOTSUP" && code !== "EOPNOTSUPP") throw err;
|
|
171
|
+
if (nodeExists(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
|
|
172
|
+
renameSync(fromPath, toPath);
|
|
173
|
+
return json(res, 200, { name: to });
|
|
174
|
+
}
|
|
175
|
+
rmSync(fromPath);
|
|
176
|
+
return json(res, 200, { name: to });
|
|
177
|
+
}
|
|
178
|
+
if (path === "boards/reorder" && req.method === "POST") {
|
|
179
|
+
if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
|
|
180
|
+
const raw = await readBody(req);
|
|
181
|
+
if (raw == null) return json(res, 400, { error: "body too large or unreadable" });
|
|
182
|
+
let parsed;
|
|
183
|
+
try {
|
|
184
|
+
parsed = JSON.parse(raw);
|
|
185
|
+
} catch {
|
|
186
|
+
return json(res, 400, { error: "malformed JSON" });
|
|
187
|
+
}
|
|
188
|
+
if (!parsed || typeof parsed !== "object") return json(res, 400, { error: "expected an object" });
|
|
189
|
+
const order = parsed.order;
|
|
190
|
+
if (!Array.isArray(order) || order.length < 1 || order.length > 200) return json(res, 400, { error: "invalid order" });
|
|
191
|
+
if (!order.every(validName) || order.some((n) => n === "all-scenes")) return json(res, 400, { error: "invalid board name in order" });
|
|
192
|
+
if (new Set(order).size !== order.length) return json(res, 400, { error: "duplicate board in order" });
|
|
193
|
+
if (!underRoot(boardsDir)) return json(res, 400, { error: "boards directory escapes the project" });
|
|
194
|
+
for (let i = 0; i < order.length; i++) {
|
|
195
|
+
const p = boardPath(order[i]);
|
|
196
|
+
if (!p || !existsSync(p) || !notSymlink(p)) continue;
|
|
197
|
+
let obj;
|
|
198
|
+
try {
|
|
199
|
+
obj = JSON.parse(readFileSync(p, "utf8"));
|
|
200
|
+
} catch {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (!obj || typeof obj !== "object") continue;
|
|
204
|
+
obj.order = i;
|
|
205
|
+
atomicWrite(p, JSON.stringify(obj, null, 2) + "\n");
|
|
206
|
+
}
|
|
207
|
+
return json(res, 200, { ok: true });
|
|
208
|
+
}
|
|
118
209
|
const boardMatch = /^boards\/([^/]+)$/.exec(path);
|
|
119
210
|
if (boardMatch) {
|
|
120
211
|
const p = boardPath(boardMatch[1]);
|
|
@@ -167,6 +258,30 @@ function apiMiddleware(root) {
|
|
|
167
258
|
return json(res, 200, { sha256: hash(next2) });
|
|
168
259
|
}
|
|
169
260
|
}
|
|
261
|
+
if (path === "shot" && req.method === "GET") {
|
|
262
|
+
const { readDevInfo } = await import("./work-CLrmY-vQ.mjs");
|
|
263
|
+
const token = readDevInfo(root)?.token;
|
|
264
|
+
if (!ownerGated(req) && !(token && req.headers["x-mv-work"] === token)) return json(res, 403, { error: "forbidden" });
|
|
265
|
+
const frameId = url.searchParams.get("frame") ?? "";
|
|
266
|
+
const theme = url.searchParams.get("theme") ?? "light";
|
|
267
|
+
const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
|
|
268
|
+
const { shootFrame } = await import("./shot-BRgPaFCs.mjs");
|
|
269
|
+
const r = await shootFrame({
|
|
270
|
+
root,
|
|
271
|
+
viewports: opts.viewports ?? {},
|
|
272
|
+
frameId,
|
|
273
|
+
theme,
|
|
274
|
+
origin
|
|
275
|
+
});
|
|
276
|
+
if (!r.ok) return json(res, r.error.startsWith("unknown frame") ? 404 : r.error === "invalid theme" ? 400 : 503, { error: r.error });
|
|
277
|
+
return json(res, 200, {
|
|
278
|
+
path: r.path,
|
|
279
|
+
frame: frameId,
|
|
280
|
+
theme,
|
|
281
|
+
width: r.width,
|
|
282
|
+
height: r.height
|
|
283
|
+
});
|
|
284
|
+
}
|
|
170
285
|
if (path === "me" && req.method === "GET") return json(res, 200, {
|
|
171
286
|
user: localProfile(root),
|
|
172
287
|
role: "owner",
|
|
@@ -565,7 +680,15 @@ function marverPlugin(ctx) {
|
|
|
565
680
|
}));
|
|
566
681
|
});
|
|
567
682
|
});
|
|
568
|
-
|
|
683
|
+
const listeningOrigin = () => {
|
|
684
|
+
const a = server.httpServer?.address();
|
|
685
|
+
if (!a || typeof a === "string") return null;
|
|
686
|
+
return `http://${a.family === "IPv6" ? a.address === "::" ? "[::1]" : `[${a.address}]` : a.address === "0.0.0.0" ? "127.0.0.1" : a.address}:${a.port}`;
|
|
687
|
+
};
|
|
688
|
+
server.middlewares.use(apiMiddleware(root, {
|
|
689
|
+
viewports: config.viewports,
|
|
690
|
+
origin: listeningOrigin
|
|
691
|
+
}));
|
|
569
692
|
server.middlewares.use(routesMiddleware(server, clientDir));
|
|
570
693
|
devServer = server;
|
|
571
694
|
const regen = debounce(() => {
|
|
@@ -630,6 +753,71 @@ function marverPlugin(ctx) {
|
|
|
630
753
|
});
|
|
631
754
|
server.httpServer?.once("close", () => watcher.close());
|
|
632
755
|
} catch {}
|
|
756
|
+
try {
|
|
757
|
+
const inbox = join(root, "design", ".local", "shots");
|
|
758
|
+
mkdirSync(inbox, { recursive: true });
|
|
759
|
+
const origin = listeningOrigin;
|
|
760
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
761
|
+
const processRequest = (file) => {
|
|
762
|
+
if (!file.endsWith(".request.json") || inFlight.has(file)) return;
|
|
763
|
+
const reqPath = join(inbox, file);
|
|
764
|
+
if (!existsSync(reqPath)) return;
|
|
765
|
+
inFlight.add(file);
|
|
766
|
+
setTimeout(async () => {
|
|
767
|
+
try {
|
|
768
|
+
let spec;
|
|
769
|
+
try {
|
|
770
|
+
spec = JSON.parse(readFileSync(reqPath, "utf8"));
|
|
771
|
+
} catch {
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const base = file.replace(/\.request\.json$/, "");
|
|
775
|
+
const resultPath = join(inbox, `${base}.result.json`);
|
|
776
|
+
const frameId = typeof spec.frame === "string" ? spec.frame : "";
|
|
777
|
+
const theme = typeof spec.theme === "string" ? spec.theme : "light";
|
|
778
|
+
const o = origin();
|
|
779
|
+
const write = (r) => {
|
|
780
|
+
try {
|
|
781
|
+
writeFileSync(resultPath, JSON.stringify(r, null, 2));
|
|
782
|
+
} catch {}
|
|
783
|
+
};
|
|
784
|
+
if (!o) {
|
|
785
|
+
write({
|
|
786
|
+
ok: false,
|
|
787
|
+
error: "dev server has no address yet - retry"
|
|
788
|
+
});
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
const { shootFrame } = await import("./shot-BRgPaFCs.mjs");
|
|
792
|
+
write(await shootFrame({
|
|
793
|
+
root,
|
|
794
|
+
viewports: config.viewports,
|
|
795
|
+
frameId,
|
|
796
|
+
theme,
|
|
797
|
+
origin: o
|
|
798
|
+
}));
|
|
799
|
+
try {
|
|
800
|
+
rmSync(reqPath, { force: true });
|
|
801
|
+
} catch {}
|
|
802
|
+
} finally {
|
|
803
|
+
inFlight.delete(file);
|
|
804
|
+
}
|
|
805
|
+
}, 60);
|
|
806
|
+
};
|
|
807
|
+
const inboxWatcher = watch(inbox, { persistent: false }, (_e, file) => {
|
|
808
|
+
if (file) processRequest(file);
|
|
809
|
+
});
|
|
810
|
+
const sweep = setInterval(() => {
|
|
811
|
+
try {
|
|
812
|
+
for (const f of readdirSync(inbox)) processRequest(f);
|
|
813
|
+
} catch {}
|
|
814
|
+
}, 1e3);
|
|
815
|
+
sweep.unref?.();
|
|
816
|
+
server.httpServer?.once("close", () => {
|
|
817
|
+
inboxWatcher.close();
|
|
818
|
+
clearInterval(sweep);
|
|
819
|
+
});
|
|
820
|
+
} catch {}
|
|
633
821
|
}
|
|
634
822
|
};
|
|
635
823
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { i as ROUTE } from "./cli.mjs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
//#region src/server/shot.ts
|
|
7
|
+
/**
|
|
8
|
+
* Frame screenshots without a dependency - the system's own Chrome, driven over CDP
|
|
9
|
+
* (Node 22 ships a WebSocket client, so this is ~zero cost).
|
|
10
|
+
*
|
|
11
|
+
* This exists for Live Jam's verify loop: a jam agent has no shell (deliberately - the job
|
|
12
|
+
* packet carries untrusted text), so "look at what you built" must be a capability the dev
|
|
13
|
+
* server provides, not a command the agent runs. The /api/shot endpoint calls this; the
|
|
14
|
+
* `marver shot` CLI and a plain WebFetch both reach that endpoint.
|
|
15
|
+
*
|
|
16
|
+
* Readiness is DETERMINISTIC, not a sleep: poll until #root (or body, for html frames) has
|
|
17
|
+
* children, then wait for fonts. A frame that never mounts fails with the page's own
|
|
18
|
+
* exception text - which is exactly what the agent needs to fix it.
|
|
19
|
+
*/
|
|
20
|
+
const CHROMES = [
|
|
21
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
22
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
23
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
24
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
25
|
+
"/usr/bin/google-chrome-stable",
|
|
26
|
+
"/usr/bin/google-chrome",
|
|
27
|
+
"/usr/bin/chromium",
|
|
28
|
+
"/usr/bin/chromium-browser"
|
|
29
|
+
];
|
|
30
|
+
/** The browser binary to drive, or null. MARVER_CHROME overrides; otherwise first known install. */
|
|
31
|
+
function findChrome() {
|
|
32
|
+
const env = process.env.MARVER_CHROME;
|
|
33
|
+
if (env) return existsSync(env) ? env : null;
|
|
34
|
+
return CHROMES.find((p) => existsSync(p)) ?? null;
|
|
35
|
+
}
|
|
36
|
+
const slug = (frameId) => frameId.replace(/\//g, "--");
|
|
37
|
+
/** Resolve a frame from the manifest and screenshot it - the shared core behind BOTH
|
|
38
|
+
* transports (the HTTP /api/shot endpoint and the file-drop inbox), so they validate and
|
|
39
|
+
* name output identically. `origin` is the dev server's own base URL. */
|
|
40
|
+
async function shootFrame(opts) {
|
|
41
|
+
const { root, viewports, frameId, theme, origin } = opts;
|
|
42
|
+
if (!/^[a-z0-9-]+$/i.test(theme)) return {
|
|
43
|
+
ok: false,
|
|
44
|
+
error: "invalid theme"
|
|
45
|
+
};
|
|
46
|
+
let manifest = {};
|
|
47
|
+
try {
|
|
48
|
+
manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
|
|
49
|
+
} catch {}
|
|
50
|
+
const frame = (manifest.frames ?? []).find((f) => f.id === frameId);
|
|
51
|
+
if (!frame) return {
|
|
52
|
+
ok: false,
|
|
53
|
+
error: `unknown frame "${frameId}" - ids are in design/manifest.json`
|
|
54
|
+
};
|
|
55
|
+
const vp = viewports[frame.viewport ?? ""] ?? viewports.mobile ?? {
|
|
56
|
+
width: 390,
|
|
57
|
+
height: 844
|
|
58
|
+
};
|
|
59
|
+
const shotsDir = join(root, "design", ".local", "shots");
|
|
60
|
+
mkdirSync(shotsDir, { recursive: true });
|
|
61
|
+
const rel = `design/.local/shots/${slug(frameId)}--${theme}.png`;
|
|
62
|
+
const result = await capture({
|
|
63
|
+
url: frame.kind === "html" ? `${origin}/${frame.file}?theme=${encodeURIComponent(theme)}` : `${origin}${ROUTE}/frame/?id=${encodeURIComponent(frameId)}&theme=${encodeURIComponent(theme)}`,
|
|
64
|
+
width: vp.width,
|
|
65
|
+
height: vp.height,
|
|
66
|
+
out: join(root, rel)
|
|
67
|
+
});
|
|
68
|
+
return result.ok ? {
|
|
69
|
+
ok: true,
|
|
70
|
+
path: rel,
|
|
71
|
+
width: vp.width,
|
|
72
|
+
height: vp.height
|
|
73
|
+
} : result;
|
|
74
|
+
}
|
|
75
|
+
/** One capture at a time: shots are seconds apart at most, and a Chrome per concurrent
|
|
76
|
+
* request would stampede the machine mid-jam. */
|
|
77
|
+
let chain = Promise.resolve();
|
|
78
|
+
function capture(req) {
|
|
79
|
+
const run = chain.then(() => captureNow(req), () => captureNow(req));
|
|
80
|
+
chain = run;
|
|
81
|
+
return run;
|
|
82
|
+
}
|
|
83
|
+
async function captureNow({ url, width, height, out, timeoutMs = 3e4 }) {
|
|
84
|
+
const bin = findChrome();
|
|
85
|
+
if (!bin) return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: "no Chrome/Chromium found - install one or set MARVER_CHROME to a browser binary"
|
|
88
|
+
};
|
|
89
|
+
const profile = mkdtempSync(join(tmpdir(), "mv-shot-"));
|
|
90
|
+
const chrome = spawn(bin, [
|
|
91
|
+
"--headless=new",
|
|
92
|
+
"--disable-gpu",
|
|
93
|
+
"--hide-scrollbars",
|
|
94
|
+
"--no-first-run",
|
|
95
|
+
"--no-default-browser-check",
|
|
96
|
+
"--disable-extensions",
|
|
97
|
+
`--user-data-dir=${profile}`,
|
|
98
|
+
"--remote-debugging-port=0",
|
|
99
|
+
"about:blank"
|
|
100
|
+
], { stdio: [
|
|
101
|
+
"ignore",
|
|
102
|
+
"ignore",
|
|
103
|
+
"pipe"
|
|
104
|
+
] });
|
|
105
|
+
let ws = null;
|
|
106
|
+
let watchdog;
|
|
107
|
+
try {
|
|
108
|
+
const wsUrl = await new Promise((resolve, reject) => {
|
|
109
|
+
let buf = "";
|
|
110
|
+
const to = setTimeout(() => reject(/* @__PURE__ */ new Error("the browser did not expose devtools in time")), 15e3);
|
|
111
|
+
chrome.stderr?.on("data", (d) => {
|
|
112
|
+
buf += d;
|
|
113
|
+
const m = /DevTools listening on (ws:\/\/\S+)/.exec(buf);
|
|
114
|
+
if (m) {
|
|
115
|
+
clearTimeout(to);
|
|
116
|
+
resolve(m[1]);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
chrome.on("error", (e) => {
|
|
120
|
+
clearTimeout(to);
|
|
121
|
+
reject(e);
|
|
122
|
+
});
|
|
123
|
+
chrome.on("exit", () => {
|
|
124
|
+
clearTimeout(to);
|
|
125
|
+
reject(/* @__PURE__ */ new Error("the browser exited before exposing devtools"));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
ws = new WebSocket(wsUrl);
|
|
129
|
+
await new Promise((res, rej) => {
|
|
130
|
+
ws.onopen = () => res();
|
|
131
|
+
ws.onerror = () => rej(/* @__PURE__ */ new Error("devtools socket failed"));
|
|
132
|
+
});
|
|
133
|
+
let seq = 0;
|
|
134
|
+
const pending = /* @__PURE__ */ new Map();
|
|
135
|
+
let lastException = "";
|
|
136
|
+
const failPending = (why) => {
|
|
137
|
+
for (const [id, cb] of pending) {
|
|
138
|
+
pending.delete(id);
|
|
139
|
+
cb({ error: { message: why } });
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
ws.onclose = () => failPending("devtools socket closed");
|
|
143
|
+
ws.onerror = () => failPending("devtools socket error");
|
|
144
|
+
ws.onmessage = (e) => {
|
|
145
|
+
let m;
|
|
146
|
+
try {
|
|
147
|
+
m = JSON.parse(String(e.data));
|
|
148
|
+
} catch {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (m.id && pending.has(m.id)) {
|
|
152
|
+
pending.get(m.id)(m);
|
|
153
|
+
pending.delete(m.id);
|
|
154
|
+
}
|
|
155
|
+
if (m.method === "Runtime.exceptionThrown") {
|
|
156
|
+
const d = m.params?.exceptionDetails;
|
|
157
|
+
lastException = String(d?.exception?.description ?? d?.text ?? "").split("\n")[0];
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const send = (method, params = {}, sessionId) => new Promise((res, rej) => {
|
|
161
|
+
const id = ++seq;
|
|
162
|
+
pending.set(id, (m) => m.error ? rej(/* @__PURE__ */ new Error(`${method}: ${m.error.message}`)) : res(m.result));
|
|
163
|
+
try {
|
|
164
|
+
ws.send(JSON.stringify({
|
|
165
|
+
id,
|
|
166
|
+
method,
|
|
167
|
+
params,
|
|
168
|
+
sessionId
|
|
169
|
+
}));
|
|
170
|
+
} catch (e) {
|
|
171
|
+
pending.delete(id);
|
|
172
|
+
rej(e);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
watchdog = setTimeout(() => {
|
|
176
|
+
try {
|
|
177
|
+
chrome.kill("SIGKILL");
|
|
178
|
+
} catch {}
|
|
179
|
+
}, timeoutMs + 15e3);
|
|
180
|
+
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
|
|
181
|
+
const { sessionId } = await send("Target.attachToTarget", {
|
|
182
|
+
targetId,
|
|
183
|
+
flatten: true
|
|
184
|
+
});
|
|
185
|
+
await send("Emulation.setDeviceMetricsOverride", {
|
|
186
|
+
width,
|
|
187
|
+
height,
|
|
188
|
+
deviceScaleFactor: 2,
|
|
189
|
+
mobile: false
|
|
190
|
+
}, sessionId);
|
|
191
|
+
await send("Page.enable", {}, sessionId);
|
|
192
|
+
await send("Runtime.enable", {}, sessionId);
|
|
193
|
+
const nav = await send("Page.navigate", { url }, sessionId);
|
|
194
|
+
if (nav?.errorText && nav.errorText !== "net::ERR_ABORTED") return {
|
|
195
|
+
ok: false,
|
|
196
|
+
error: `could not load the frame (${nav.errorText}) - is the dev server reachable at ${new URL(url).origin}?`
|
|
197
|
+
};
|
|
198
|
+
const t0 = Date.now();
|
|
199
|
+
let ready = false;
|
|
200
|
+
while (Date.now() - t0 < timeoutMs) {
|
|
201
|
+
if ((await send("Runtime.evaluate", {
|
|
202
|
+
expression: `(() => { const el = document.getElementById('root') ?? document.body; return !!el && el.childElementCount > 0 && document.readyState !== 'loading' })()`,
|
|
203
|
+
returnByValue: true
|
|
204
|
+
}, sessionId).catch(() => null))?.result?.value) {
|
|
205
|
+
ready = true;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
await new Promise((r2) => setTimeout(r2, 150));
|
|
209
|
+
}
|
|
210
|
+
if (!ready) return {
|
|
211
|
+
ok: false,
|
|
212
|
+
error: `the frame never rendered${lastException ? ` - the page threw: ${lastException}` : " (no exception surfaced - is the dev server reachable from this machine?)"}`
|
|
213
|
+
};
|
|
214
|
+
await send("Runtime.evaluate", {
|
|
215
|
+
expression: "document.fonts.ready.then(() => true)",
|
|
216
|
+
awaitPromise: true,
|
|
217
|
+
returnByValue: true
|
|
218
|
+
}, sessionId).catch(() => null);
|
|
219
|
+
await new Promise((r2) => setTimeout(r2, 250));
|
|
220
|
+
const errEval = await send("Runtime.evaluate", {
|
|
221
|
+
expression: "window.__mvFrameError || \"\"",
|
|
222
|
+
returnByValue: true
|
|
223
|
+
}, sessionId).catch(() => null);
|
|
224
|
+
const frameError = typeof errEval?.result?.value === "string" ? errEval.result.value : "";
|
|
225
|
+
if (frameError) return {
|
|
226
|
+
ok: false,
|
|
227
|
+
error: `the frame rendered an error - ${frameError}`
|
|
228
|
+
};
|
|
229
|
+
const shot = await send("Page.captureScreenshot", { format: "png" }, sessionId);
|
|
230
|
+
writeFileSync(out, Buffer.from(String(shot.data), "base64"));
|
|
231
|
+
return { ok: true };
|
|
232
|
+
} catch (err) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
error: err.message
|
|
236
|
+
};
|
|
237
|
+
} finally {
|
|
238
|
+
if (watchdog) clearTimeout(watchdog);
|
|
239
|
+
try {
|
|
240
|
+
ws?.close();
|
|
241
|
+
} catch {}
|
|
242
|
+
chrome.kill("SIGKILL");
|
|
243
|
+
chrome.once("exit", () => {
|
|
244
|
+
try {
|
|
245
|
+
rmSync(profile, {
|
|
246
|
+
recursive: true,
|
|
247
|
+
force: true
|
|
248
|
+
});
|
|
249
|
+
} catch {}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
//#endregion
|
|
254
|
+
export { shootFrame };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { n as NAME } from "./cli.mjs";
|
|
2
|
+
import { readDevInfo } from "./work-CLrmY-vQ.mjs";
|
|
3
|
+
//#region src/cli/shot.ts
|
|
4
|
+
/**
|
|
5
|
+
* `marver shot <scene/frame>` - render one frame headless and print the PNG path.
|
|
6
|
+
*
|
|
7
|
+
* The shell-ful agents' door into the same verify loop jam teaches: build, shoot, LOOK.
|
|
8
|
+
* Thin wrapper over the dev server's /api/shot (shot.ts has the capture story).
|
|
9
|
+
*/
|
|
10
|
+
async function shotCommand(root, frame, opts) {
|
|
11
|
+
if (!frame) throw new Error(`name the frame: ${NAME} shot <scene/frame> [--theme <name>]`);
|
|
12
|
+
const info = readDevInfo(root);
|
|
13
|
+
if (!info) throw new Error(`\`${NAME} dev\` is not running in this repo (design/.local/dev.json not found) - start it first.`);
|
|
14
|
+
const qs = new URLSearchParams({
|
|
15
|
+
frame,
|
|
16
|
+
...opts.theme ? { theme: opts.theme } : {}
|
|
17
|
+
});
|
|
18
|
+
let res;
|
|
19
|
+
try {
|
|
20
|
+
res = await fetch(`http://localhost:${info.port}/__mv/api/shot?${qs}`, { headers: { "x-mv-work": info.token } });
|
|
21
|
+
} catch {
|
|
22
|
+
throw new Error(`could not reach \`${NAME} dev\` on port ${info.port} - is it still running?`);
|
|
23
|
+
}
|
|
24
|
+
const data = await res.json().catch(() => ({}));
|
|
25
|
+
if (!res.ok || !data.path) throw new Error(data.error ?? `shot failed (${res.status})`);
|
|
26
|
+
console.log(data.path);
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
export { shotCommand };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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,
|
|
@@ -20,7 +20,14 @@ document.documentElement.classList.toggle('dark', theme === 'dark')
|
|
|
20
20
|
|
|
21
21
|
const post = (msg: Record<string, unknown>) => { if (window.parent !== window) window.parent.postMessage(msg, '*') }
|
|
22
22
|
|
|
23
|
+
// A render failure posts sh:error to the shell, but a HEADLESS screenshot has no shell to
|
|
24
|
+
// hear it - so also stamp the error on the document, where the shot capture can read it.
|
|
25
|
+
// This is what lets `marver shot` / the file-drop result report "the frame crashed" to an
|
|
26
|
+
// agent that cannot see the PNG (only the JSON), instead of a clean-looking ok:true.
|
|
27
|
+
const markError = (message: string) => { (window as any).__mvFrameError = message }
|
|
28
|
+
|
|
23
29
|
function fail(message: string) {
|
|
30
|
+
markError(message)
|
|
24
31
|
post({ type: 'sh:error', id, message })
|
|
25
32
|
document.getElementById('root')!.innerHTML =
|
|
26
33
|
`<div style="font-family:ui-monospace,monospace;font-size:12px;padding:16px;color:#b42318">
|
|
@@ -35,7 +42,7 @@ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt
|
|
|
35
42
|
class Boundary extends Component<{ children: ReactNode }, { err: Error | null }> {
|
|
36
43
|
state = { err: null as Error | null }
|
|
37
44
|
static getDerivedStateFromError(err: Error) { return { err } }
|
|
38
|
-
componentDidCatch(err: Error) { post({ type: 'sh:error', id, message: err.message }) }
|
|
45
|
+
componentDidCatch(err: Error) { markError(err.message); post({ type: 'sh:error', id, message: err.message }) }
|
|
39
46
|
render() {
|
|
40
47
|
if (!this.state.err) return this.props.children
|
|
41
48
|
return createElement('div', { style: { fontFamily: 'ui-monospace,monospace', fontSize: 12, padding: 16, color: '#b42318' } },
|