@marver-design/marver 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +4 -4
  3. package/dist/{boards-BdG1TJwU.mjs → boards-BmxcT3Lc.mjs} +32 -9
  4. package/dist/{boards-6hKVW42a.mjs → boards-PuVzw5Wp.mjs} +9 -4
  5. package/dist/{build-Ct0iMoSi.mjs → build-D_g53Bp2.mjs} +12 -8
  6. package/dist/cli.mjs +27 -16
  7. package/dist/{daemon-Cb4JjpgL.mjs → daemon-Bbh_jmui.mjs} +1 -1
  8. package/dist/{dev-CdcIuhZJ.mjs → dev-LnIISva5.mjs} +9 -4
  9. package/dist/{init-BQIpIpIv.mjs → init-B7YhcN2o.mjs} +1 -1
  10. package/dist/{manifest-CpbsqQ_v.mjs → manifest-CaslQIAO.mjs} +112 -15
  11. package/dist/{plugin-BXyezwfN.mjs → plugin-D2msH1cj.mjs} +223 -43
  12. package/dist/{poster-DOY7pax8.mjs → poster-BEjUcQP3.mjs} +13 -5
  13. package/dist/shot-BzQ0PXKH.mjs +86 -0
  14. package/dist/shot-DlmTO8AF.mjs +926 -0
  15. package/docs/live-jam.md +20 -8
  16. package/docs/publish.md +2 -2
  17. package/package.json +1 -1
  18. package/src/client/shell/App.tsx +42 -14
  19. package/src/client/shell/BoardList.tsx +88 -58
  20. package/src/client/shell/LockedApp.tsx +2 -1
  21. package/src/client/shell/Play.tsx +1 -0
  22. package/src/client/shell/labels.ts +4 -4
  23. package/src/client/shell/store.ts +76 -29
  24. package/src/client/shell/styles.css +5 -1
  25. package/src/shared/board-tree.ts +104 -46
  26. package/templates/AGENTS-embedded.md +13 -3
  27. package/templates/AGENTS-studio.md +13 -3
  28. package/templates/instructions/boards.md +27 -9
  29. package/templates/instructions/discover.md +13 -1
  30. package/templates/instructions/jam.md +15 -3
  31. package/templates/instructions/shape.md +2 -1
  32. package/dist/shot-By1AItpD.mjs +0 -30
  33. package/dist/shot-CwmHO5T4.mjs +0 -528
@@ -1,8 +1,8 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
2
  import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
3
- import { a as nodeExists, c as FOLDERS_FILE, f as readDescription, i as listBoardFiles, m as hash, n as checkBoardsDir, o as readRegistry, p as validateWire, r as isRegularFile, s as BOARD_NAME, t as boardFields } from "./boards-BdG1TJwU.mjs";
3
+ import { a as listBoardFiles, c as BOARD_NAME, g as hash, h as validateWire, i as isRegularFile, l as FOLDERS_FILE, m as readTitle, n as checkBoardsDir, o as nodeExists, p as readDescription, s as readRegistry, t as boardFields } from "./boards-BmxcT3Lc.mjs";
4
4
  import { n as localProfile, t as isConnected } from "./profile-BjAPAJSb.mjs";
5
- import { i as writeManifest, n as scanFrames, o as loadConfig, t as affectedFrameIds } from "./manifest-CpbsqQ_v.mjs";
5
+ import { a as writeManifest, n as scanFrames, r as setSceneTitle, s as loadConfig, t as affectedFrameIds } from "./manifest-CaslQIAO.mjs";
6
6
  import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
7
7
  import { basename, dirname, join, resolve, sep } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
@@ -155,9 +155,13 @@ function apiMiddleware(root, opts = {}) {
155
155
  return json(res, 400, { error: "malformed JSON" });
156
156
  }
157
157
  if (!parsed || typeof parsed !== "object") return json(res, 400, { error: "expected an object" });
158
- const { from, to } = parsed;
158
+ const { from, to: toRaw, title: titleRaw, baseHash } = parsed;
159
+ const to = toRaw === void 0 ? from : toRaw;
159
160
  if (!validName(from) || !validName(to)) return json(res, 400, { error: "invalid board name" });
160
- if (to === "all-scenes" || from === "all-scenes" || to === from) return json(res, 400, { error: "invalid rename" });
161
+ if (titleRaw !== void 0 && (typeof titleRaw !== "string" || Array.from(titleRaw).length > 120)) return json(res, 400, { error: "invalid title" });
162
+ if (baseHash !== void 0 && typeof baseHash !== "string") return json(res, 400, { error: "invalid baseHash" });
163
+ if (to === "all-scenes" || from === "all-scenes") return json(res, 400, { error: "invalid rename" });
164
+ if (to === from && titleRaw === void 0) return json(res, 400, { error: "invalid rename" });
161
165
  {
162
166
  const de = dirError();
163
167
  if (de) return json(res, 400, { error: de });
@@ -166,21 +170,74 @@ function apiMiddleware(root, opts = {}) {
166
170
  if (!fromPath || !toPath) return json(res, 400, { error: "invalid board name" });
167
171
  if (!existsSync(fromPath)) return json(res, 404, { error: `board "${from}" does not exist` });
168
172
  if (!notSymlink(fromPath)) return json(res, 400, { error: "refusing to rename a symlinked board file" });
169
- if (isConnected(root)) return json(res, 409, { error: "disconnect before renaming boards - comment sync is board-keyed" });
170
- if (existsSync(join(root, "design", "comments", `${from}.jsonl`))) return json(res, 409, { error: "rename a board before commenting on it - this board has threads" });
171
- if (nodeExists$1(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
172
- try {
173
- linkSync(fromPath, toPath);
174
- } catch (err) {
175
- const code = err.code;
176
- if (code === "EEXIST") return json(res, 409, { error: `a board named "${to}" already exists` });
177
- if (code !== "EPERM" && code !== "ENOSYS" && code !== "ENOTSUP" && code !== "EOPNOTSUPP") throw err;
173
+ const current = readFileSync(fromPath, "utf8");
174
+ if (baseHash !== void 0 && baseHash !== hash(current)) return json(res, 409, {
175
+ error: "board changed on disk",
176
+ sha256: hash(current)
177
+ });
178
+ let obj = null;
179
+ if (titleRaw !== void 0) {
180
+ let parsedBoard;
181
+ try {
182
+ parsedBoard = JSON.parse(current);
183
+ } catch {
184
+ return json(res, 422, { error: `board "${from}" is not valid JSON - fix the file` });
185
+ }
186
+ if (!parsedBoard || typeof parsedBoard !== "object" || Array.isArray(parsedBoard)) return json(res, 422, { error: `board "${from}" is not a JSON object - fix the file` });
187
+ obj = parsedBoard;
188
+ }
189
+ let next;
190
+ if (obj) {
191
+ const title = readTitle(titleRaw);
192
+ if (title) obj.title = title;
193
+ else delete obj.title;
194
+ next = JSON.stringify(obj, null, 2) + "\n";
195
+ }
196
+ if (to !== from) {
197
+ if (isConnected(root)) return json(res, 409, { error: "disconnect before renaming boards - comment sync is board-keyed" });
198
+ if (existsSync(join(root, "design", "comments", `${from}.jsonl`))) return json(res, 409, { error: "rename a board before commenting on it - this board has threads" });
178
199
  if (nodeExists$1(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
179
- renameSync(fromPath, toPath);
180
- return json(res, 200, { name: to });
200
+ try {
201
+ if (next !== void 0) writeFileSync(toPath, next, { flag: "wx" });
202
+ else linkSync(fromPath, toPath);
203
+ } catch (err) {
204
+ const code = err.code;
205
+ if (code === "EEXIST") return json(res, 409, { error: `a board named "${to}" already exists` });
206
+ if (next !== void 0 || code !== "EPERM" && code !== "ENOSYS" && code !== "ENOTSUP" && code !== "EOPNOTSUPP") throw err;
207
+ if (nodeExists$1(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
208
+ renameSync(fromPath, toPath);
209
+ return json(res, 200, { name: to });
210
+ }
211
+ rmSync(fromPath);
212
+ return json(res, 200, {
213
+ name: to,
214
+ ...next !== void 0 ? { sha256: hash(next) } : {}
215
+ });
216
+ }
217
+ atomicWrite(toPath, next);
218
+ return json(res, 200, {
219
+ name: to,
220
+ sha256: hash(next)
221
+ });
222
+ }
223
+ if (path === "scenes/rename" && req.method === "POST") {
224
+ if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
225
+ const raw = await readBody(req);
226
+ if (raw == null) return json(res, 400, { error: "body too large or unreadable" });
227
+ let parsed;
228
+ try {
229
+ parsed = JSON.parse(raw);
230
+ } catch {
231
+ return json(res, 400, { error: "malformed JSON" });
181
232
  }
182
- rmSync(fromPath);
183
- return json(res, 200, { name: to });
233
+ if (!parsed || typeof parsed !== "object") return json(res, 400, { error: "expected an object" });
234
+ const { scene, title } = parsed;
235
+ if (typeof scene !== "string" || !scene || scene.length > 128 || /[\\/]/.test(scene) || scene === "." || scene === "..") return json(res, 400, { error: "invalid scene name" });
236
+ if (typeof title !== "string" || Array.from(title).length > 120) return json(res, 400, { error: "invalid title" });
237
+ if (!scanFrames(root).scenes.some((sc) => sc.name === scene)) return json(res, 400, { error: `scene "${scene}" does not exist (no frames under design/scenes/${scene}/)` });
238
+ const e = setSceneTitle(root, scene, readTitle(title) ?? "");
239
+ if (e) return json(res, 400, { error: e });
240
+ return json(res, 200, { ok: true });
184
241
  }
185
242
  if (path === "boards/reorder" && req.method === "POST") {
186
243
  if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
@@ -249,10 +306,11 @@ function apiMiddleware(root, opts = {}) {
249
306
  if (e) return json(res, 422, { error: e });
250
307
  continue;
251
308
  }
252
- const description = readDescription(it.description);
309
+ const title = readTitle(it.title), description = readDescription(it.description);
253
310
  folders.push({
254
311
  name: it.folder,
255
312
  order: i,
313
+ ...title ? { title } : {},
256
314
  ...description ? { description } : {}
257
315
  });
258
316
  for (const [j, kid] of it.boards.entries()) {
@@ -339,6 +397,7 @@ function apiMiddleware(root, opts = {}) {
339
397
  const disk = JSON.parse(current);
340
398
  if (incoming.order === void 0 && typeof disk.order === "number" && Number.isFinite(disk.order)) incoming.order = disk.order;
341
399
  if (incoming.folder === void 0 && validName(disk.folder)) incoming.folder = disk.folder;
400
+ if (incoming.title === void 0 && readTitle(disk.title)) incoming.title = disk.title;
342
401
  if (incoming.description === void 0 && readDescription(disk.description)) incoming.description = disk.description;
343
402
  } catch {}
344
403
  const next2 = JSON.stringify(body.board, null, 2) + "\n";
@@ -363,7 +422,7 @@ function apiMiddleware(root, opts = {}) {
363
422
  };
364
423
  const asPng = url.searchParams.get("format") === "png";
365
424
  const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
366
- const { shootFrame } = await import("./shot-CwmHO5T4.mjs");
425
+ const { shootFrame } = await import("./shot-DlmTO8AF.mjs");
367
426
  const r = await shootFrame({
368
427
  root,
369
428
  viewports: opts.viewports ?? {},
@@ -382,6 +441,7 @@ function apiMiddleware(root, opts = {}) {
382
441
  height: r.height,
383
442
  scale: r.scale,
384
443
  ...r.truncated ? { truncated: true } : {},
444
+ ...r.unsettled ? { unsettled: true } : {},
385
445
  ...r.note ? { note: r.note } : {}
386
446
  };
387
447
  if (!asPng) return json(res, 200, summary);
@@ -394,11 +454,46 @@ function apiMiddleware(root, opts = {}) {
394
454
  });
395
455
  return res.end(png);
396
456
  }
457
+ if (path === "shots" && req.method === "POST") {
458
+ const { readDevInfo } = await import("./work-CLrmY-vQ.mjs");
459
+ const token = readDevInfo(root)?.token;
460
+ if (!ownerGated(req) && !(token && req.headers["x-mv-work"] === token)) return json(res, 403, { error: "forbidden" });
461
+ const raw = await readBody(req);
462
+ if (raw == null) return json(res, 400, { error: "body too large or unreadable" });
463
+ let parsed;
464
+ try {
465
+ parsed = JSON.parse(raw);
466
+ } catch {
467
+ return json(res, 400, { error: "malformed JSON" });
468
+ }
469
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return json(res, 400, { error: "expected an object" });
470
+ const body = parsed;
471
+ const theme = body.theme === void 0 ? "light" : body.theme;
472
+ if (typeof theme !== "string" || !/^[a-z0-9-]+$/i.test(theme)) return json(res, 400, { error: "invalid theme" });
473
+ const scale = body.scale === void 0 ? void 0 : body.scale;
474
+ if (scale !== void 0 && !(Number.isInteger(scale) && scale >= 1 && scale <= 4)) return json(res, 400, { error: "invalid scale" });
475
+ const { resolveFrames, shootBatch } = await import("./shot-DlmTO8AF.mjs");
476
+ const sel = resolveFrames(root, body);
477
+ if (!sel.ok) return json(res, sel.status, { error: sel.error });
478
+ const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
479
+ try {
480
+ return json(res, 200, { results: await shootBatch({
481
+ root,
482
+ viewports: opts.viewports ?? {},
483
+ frames: sel.frames,
484
+ theme,
485
+ origin,
486
+ scale
487
+ }) });
488
+ } catch (err) {
489
+ return json(res, 503, { error: err.message });
490
+ }
491
+ }
397
492
  if (path === "poster" && req.method === "GET") {
398
493
  if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
399
494
  const src = url.searchParams.get("src") ?? "";
400
- const { ensurePoster, isLocalClip } = await import("./poster-DOY7pax8.mjs");
401
- const { isLocalAssetRef } = await import("./build-Ct0iMoSi.mjs");
495
+ const { ensurePoster, isLocalClip } = await import("./poster-BEjUcQP3.mjs");
496
+ const { isLocalAssetRef } = await import("./build-D_g53Bp2.mjs");
402
497
  if (!isLocalAssetRef(src) || !isLocalClip(src)) return json(res, 400, { error: "src must be a clip under design/assets/" });
403
498
  const r = await ensurePoster(join(root, "design", "assets"), src);
404
499
  if (!r.ok) return json(res, r.error.includes("does not exist") ? 404 : 503, { error: r.error });
@@ -827,12 +922,15 @@ function marverPlugin(ctx) {
827
922
  server.middlewares.use(routesMiddleware(server, clientDir));
828
923
  devServer = server;
829
924
  let framesKey = JSON.stringify(manifest.frames);
925
+ let scenesKey = JSON.stringify(manifest.scenes);
830
926
  const regen = debounce(() => {
831
927
  manifest = scanFrames(root, projectInfo());
832
928
  const changed = writeManifest(root, manifest);
833
- const key = JSON.stringify(manifest.frames);
929
+ const key = JSON.stringify(manifest.frames), sKey = JSON.stringify(manifest.scenes);
834
930
  if (changed && key !== framesKey) server.ws.send("sh:manifest", manifest);
931
+ else if (changed && sKey !== scenesKey) server.ws.send("sh:scenes", { scenes: manifest.scenes });
835
932
  framesKey = key;
933
+ scenesKey = sKey;
836
934
  }, 150);
837
935
  const watched = [join(root, "design", "scenes"), join(root, "design", "components")];
838
936
  const inScope = (file) => watched.some((w) => file.startsWith(w));
@@ -920,23 +1018,68 @@ function marverPlugin(ctx) {
920
1018
  if (!existsSync(reqPath)) return;
921
1019
  inFlight.add(file);
922
1020
  setTimeout(async () => {
1021
+ const base = file.replace(/\.request\.json$/, "");
1022
+ const resultPath = join(inbox, `${base}.result.json`);
1023
+ const claimed = join(inbox, `.${base}.${process.pid}.${Date.now().toString(36)}.claimed`);
1024
+ try {
1025
+ renameSync(reqPath, claimed);
1026
+ } catch {
1027
+ inFlight.delete(file);
1028
+ return;
1029
+ }
923
1030
  try {
1031
+ const write = (r) => {
1032
+ const tmp = join(inbox, `.${base}.${process.pid}.tmp`);
1033
+ try {
1034
+ writeFileSync(tmp, JSON.stringify(r, null, 2));
1035
+ renameSync(tmp, resultPath);
1036
+ } catch {
1037
+ try {
1038
+ rmSync(tmp, { force: true });
1039
+ } catch {}
1040
+ }
1041
+ };
924
1042
  let spec;
925
1043
  try {
926
- spec = JSON.parse(readFileSync(reqPath, "utf8"));
927
- } catch {
1044
+ spec = JSON.parse(readFileSync(claimed, "utf8"));
1045
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) throw new Error("expected an object");
1046
+ } catch (err) {
1047
+ write({
1048
+ ok: false,
1049
+ error: `malformed request - ${err.message}`
1050
+ });
1051
+ return;
1052
+ }
1053
+ const theme = spec.theme === void 0 ? "light" : spec.theme;
1054
+ if (typeof theme !== "string" || !/^[a-z0-9-]+$/i.test(theme)) {
1055
+ write({
1056
+ ok: false,
1057
+ error: "invalid theme"
1058
+ });
1059
+ return;
1060
+ }
1061
+ const scale = spec.scale === void 0 ? void 0 : spec.scale;
1062
+ if (scale !== void 0 && !(Number.isInteger(scale) && scale >= 1 && scale <= 4)) {
1063
+ write({
1064
+ ok: false,
1065
+ error: "invalid scale"
1066
+ });
1067
+ return;
1068
+ }
1069
+ const ways = [
1070
+ "frame",
1071
+ "frames",
1072
+ "scene",
1073
+ "all"
1074
+ ].filter((k) => spec[k] !== void 0);
1075
+ if (ways.length !== 1) {
1076
+ write({
1077
+ ok: false,
1078
+ error: "name the frames one way: frame (an id), frames (an array of ids), scene (a name) or all (true)"
1079
+ });
928
1080
  return;
929
1081
  }
930
- const base = file.replace(/\.request\.json$/, "");
931
- const resultPath = join(inbox, `${base}.result.json`);
932
- const frameId = typeof spec.frame === "string" ? spec.frame : "";
933
- const theme = typeof spec.theme === "string" ? spec.theme : "light";
934
1082
  const o = origin();
935
- const write = (r) => {
936
- try {
937
- writeFileSync(resultPath, JSON.stringify(r, null, 2));
938
- } catch {}
939
- };
940
1083
  if (!o) {
941
1084
  write({
942
1085
  ok: false,
@@ -944,18 +1087,55 @@ function marverPlugin(ctx) {
944
1087
  });
945
1088
  return;
946
1089
  }
947
- const { shootFrame } = await import("./shot-CwmHO5T4.mjs");
948
- write(await shootFrame({
949
- root,
950
- viewports: config.viewports,
951
- frameId,
952
- theme,
953
- origin: o
954
- }));
1090
+ const { shootFrame, shootBatch, resolveFrames } = await import("./shot-DlmTO8AF.mjs");
1091
+ if (ways[0] === "frame") {
1092
+ if (typeof spec.frame !== "string") {
1093
+ write({
1094
+ ok: false,
1095
+ error: "frame must be a frame id"
1096
+ });
1097
+ return;
1098
+ }
1099
+ write(await shootFrame({
1100
+ root,
1101
+ viewports: config.viewports,
1102
+ frameId: spec.frame,
1103
+ theme,
1104
+ origin: o,
1105
+ scale
1106
+ }));
1107
+ return;
1108
+ }
1109
+ const sel = resolveFrames(root, spec);
1110
+ if (!sel.ok) {
1111
+ write({
1112
+ ok: false,
1113
+ error: sel.error
1114
+ });
1115
+ return;
1116
+ }
955
1117
  try {
956
- rmSync(reqPath, { force: true });
957
- } catch {}
1118
+ write({
1119
+ ok: true,
1120
+ results: await shootBatch({
1121
+ root,
1122
+ viewports: config.viewports,
1123
+ frames: sel.frames,
1124
+ theme,
1125
+ origin: o,
1126
+ scale
1127
+ })
1128
+ });
1129
+ } catch (err) {
1130
+ write({
1131
+ ok: false,
1132
+ error: err.message
1133
+ });
1134
+ }
958
1135
  } finally {
1136
+ try {
1137
+ rmSync(claimed, { force: true });
1138
+ } catch {}
959
1139
  inFlight.delete(file);
960
1140
  }
961
1141
  }, 60);
@@ -1,4 +1,4 @@
1
- import { capture, findChrome } from "./shot-CwmHO5T4.mjs";
1
+ import { capture, t as findChrome } from "./shot-DlmTO8AF.mjs";
2
2
  import { constants, copyFileSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, join, sep } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
@@ -52,15 +52,23 @@ html,body{margin:0;background:#000}#root video{display:block}
52
52
  setTimeout(function(){ if (!done && v.readyState >= 2) show() }, 4000);
53
53
  })();
54
54
  <\/script></body></html>`;
55
- /** Render `<clip>.poster.png` for a clip under design/assets/. Idempotent: an existing
56
- * poster (authored or generated) is left alone. */
57
- async function ensurePoster(assetsDir, src) {
55
+ /** Single-flight by output path: N frames of one batch referencing the same clip ask at the
56
+ * same moment, and each would otherwise pass the "exists" check and render the same poster. */
57
+ const rendering = /* @__PURE__ */ new Map();
58
+ function ensurePoster(assetsDir, src) {
59
+ const out = join(assetsDir, posterNameFor(src));
60
+ const live = rendering.get(out);
61
+ if (live) return live;
62
+ const run = ensurePosterNow(assetsDir, src, out).finally(() => rendering.delete(out));
63
+ rendering.set(out, run);
64
+ return run;
65
+ }
66
+ async function ensurePosterNow(assetsDir, src, out) {
58
67
  if (!isLocalClip(src)) return {
59
68
  ok: false,
60
69
  error: `not a local clip: ${src}`
61
70
  };
62
71
  const clip = join(assetsDir, src);
63
- const out = join(assetsDir, posterNameFor(src));
64
72
  if (existsSync(out)) return {
65
73
  ok: true,
66
74
  path: out,
@@ -0,0 +1,86 @@
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> [...]`, `--scene <name>`, `--all` - render frames headless and
6
+ * print the PNG paths, one per line, in the order asked.
7
+ *
8
+ * The shell-ful agents' door into the same verify loop jam teaches: build, shoot, LOOK. One
9
+ * frame goes through /api/shot; several, a scene or everything go through /api/shots as ONE
10
+ * operation (one browser, several frames at a time), so shooting a scene costs about what
11
+ * shooting a frame did. shot.ts (server) has the capture story.
12
+ */
13
+ async function shotCommand(root, frames, opts) {
14
+ const usage = `${NAME} shot <scene/frame ...> | --scene <name> | --all [--theme <name>] [--scale 1-4] [--json]`;
15
+ if ([
16
+ frames.length > 0,
17
+ !!opts.scene,
18
+ !!opts.all
19
+ ].filter(Boolean).length !== 1) throw new Error(`name the frames, one way: ${usage}`);
20
+ const info = readDevInfo(root);
21
+ if (!info) throw new Error(`\`${NAME} dev\` is not running in this repo (design/.local/dev.json not found) - start it first.`);
22
+ const scale = opts.scale != null ? Number(opts.scale) : void 0;
23
+ const theme = opts.theme;
24
+ const single = frames.length === 1 && !opts.scene && !opts.all;
25
+ let res;
26
+ try {
27
+ if (single) {
28
+ const qs = new URLSearchParams({
29
+ frame: frames[0],
30
+ ...theme ? { theme } : {},
31
+ ...scale != null ? { scale: String(scale) } : {}
32
+ });
33
+ res = await fetch(`http://localhost:${info.port}/__mv/api/shot?${qs}`, { headers: { "x-mv-work": info.token } });
34
+ } else {
35
+ const sel = opts.all ? { all: true } : opts.scene ? { scene: opts.scene } : { frames };
36
+ res = await fetch(`http://localhost:${info.port}/__mv/api/shots`, {
37
+ method: "POST",
38
+ headers: {
39
+ "x-mv-work": info.token,
40
+ "content-type": "application/json"
41
+ },
42
+ body: JSON.stringify({
43
+ ...sel,
44
+ ...theme ? { theme } : {},
45
+ ...scale != null ? { scale } : {}
46
+ })
47
+ });
48
+ }
49
+ } catch {
50
+ throw new Error(`could not reach \`${NAME} dev\` on port ${info.port} - is it still running?`);
51
+ }
52
+ const data = await res.json().catch(() => ({}));
53
+ if (!res.ok) throw new Error(data.error ?? `shot failed (${res.status})`);
54
+ const one = data;
55
+ const results = single ? [one.path ? {
56
+ frame: frames[0],
57
+ ok: true,
58
+ path: one.path,
59
+ width: one.width,
60
+ height: one.height,
61
+ scale: one.scale,
62
+ ...one.truncated ? { truncated: true } : {},
63
+ ...one.unsettled ? { unsettled: true } : {},
64
+ ...one.note ? { note: one.note } : {}
65
+ } : {
66
+ frame: frames[0],
67
+ ok: false,
68
+ error: one.error ?? "shot failed"
69
+ }] : data.results ?? [];
70
+ if (opts.json) {
71
+ console.log(JSON.stringify({ results }, null, 2));
72
+ if (results.some((r) => !r.ok)) process.exitCode = 1;
73
+ return;
74
+ }
75
+ let failed = false;
76
+ for (const r of results) if (r.ok) {
77
+ console.log(r.path);
78
+ if (r.note) console.error(`${r.frame}: ${r.note}`);
79
+ } else {
80
+ failed = true;
81
+ console.error(`${r.frame}: ${r.error}`);
82
+ }
83
+ if (failed) process.exitCode = 1;
84
+ }
85
+ //#endregion
86
+ export { shotCommand };