@marver-design/marver 0.16.1 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
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.17.0 - 2026-09-04
6
+
7
+ ### Added
8
+
9
+ - **Shots in batch.** `npx marver shot a b c`, `--scene <name>` or `--all` render many
10
+ frames as ONE operation: one headless browser, several frames at a time inside it
11
+ (`MARVER_SHOT_CONCURRENCY`, default up to 6 sized to the machine). `POST /api/shots`
12
+ and the jam inbox (`{"scene":...}`, `{"frames":[...]}`, `{"all":true}`) take the same
13
+ ask; every frame answers for itself (`results[]`, in the order asked). Measured on a
14
+ 31-frame canvas: 65 s of one-at-a-time calls becomes 8 s; a 5-frame scene, 10 s
15
+ becomes 2 s; a single shot, 2.1 s becomes 1.05 s.
16
+ - A capture that ran out of settle budget says so: `unsettled: true` and a note, instead
17
+ of a silently half-drawn PNG.
18
+
19
+ ### Fixed
20
+
21
+ - **A shot's Chrome could outlive the server** - Ctrl-C, a closed terminal or `kill -9`
22
+ mid-shot left a headless copy of the user's own Chrome running for ever, and on macOS
23
+ such a ghost can swallow every link the machine opens. The browser is now driven over
24
+ Chrome's debugging pipe, so it dies with the server however the server dies, and none
25
+ exists between shots. The first `marver dev` after upgrading kills the ghosts and
26
+ removes the profiles earlier versions left in the temp dir.
27
+ - Chrome starts and opens tabs several times faster: a stale `--disable-gpu` flag was
28
+ costing ~0.5 s per start and per tab.
29
+
5
30
  ## 0.16.1 - 2026-09-03
6
31
 
7
32
  ### Added
package/README.md CHANGED
@@ -84,7 +84,7 @@ The trust boundary is hard: only comments written on the owner's machine trigger
84
84
 
85
85
  ## Working state
86
86
 
87
- The same glow, driven from the terminal. When your agent takes a request, it creates the frame files first, pins them on a board, and runs `npx marver work start <scene/frame ...>` - you see the work land on the canvas in seconds, watch it shimmer while subagents build in parallel, and see it settle on `work done`. Marks self-expire, so a crashed agent never leaves a frame glowing. And `npx marver shot <scene/frame> [--scale 4]` renders one frame headless to a PNG, so the agent can look at what it built before it says it is done - the same picture you get from the canvas's copy-as-image.
87
+ The same glow, driven from the terminal. When your agent takes a request, it creates the frame files first, pins them on a board, and runs `npx marver work start <scene/frame ...>` - you see the work land on the canvas in seconds, watch it shimmer while subagents build in parallel, and see it settle on `work done`. Marks self-expire, so a crashed agent never leaves a frame glowing. And `npx marver shot --scene <scene>` (or `<scene/frame ...>`, `--all`; `[--scale 4]`) renders frames headless to PNGs - a whole scene in one browser, several frames at a time - so the agent can look at what it built before it says it is done - the same picture you get from the canvas's copy-as-image.
88
88
 
89
89
  ## Commands
90
90
 
@@ -97,7 +97,7 @@ The same glow, driven from the terminal. When your agent takes a request, it cre
97
97
  | `npx marver share …` | The roster (owner): `add <who> [--role]` · `remove` · `block` / `unblock` · `general <mode>` · `list` · `requests` · `explain <who>` · `who` |
98
98
  | `npx marver comments …` | The agent's queue: `connect <url>` · `sync` · `list` · `reply` · `resolve` · `invite <email>` · `revoke <email>` |
99
99
  | `npx marver work …` | Working glow from the terminal: `start <scene/frame …>` · `done … \| --all` · `list` |
100
- | `npx marver shot <frame> [--scale 1-4]` | Render one frame headless and print the PNG path (needs `dev` running); 2x by default |
100
+ | `npx marver shot <frame ...> \| --scene <name> \| --all [--scale 1-4] [--json]` | Render frames headless and print the PNG paths (needs `dev` running); a scene is one browser, several frames at a time; 2x by default |
101
101
  | `npx marver boards [--json]` | The sidebar as the files say it is: folders, boards in reading order with their title, `order` and description, the landing board |
102
102
 
103
103
  ## Shortcuts
@@ -1,7 +1,7 @@
1
1
  import { i as ROUTE, n as NAME } from "./cli.mjs";
2
2
  import { a as listBoardFiles, d as flatten, f as isBoardName, n as checkBoardsDir, s as readRegistry, t as boardFields, u as buildTree } from "./boards-BmxcT3Lc.mjs";
3
3
  import { l as detectHost, n as scanFrames, s as loadConfig } from "./manifest-CaslQIAO.mjs";
4
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BeBGu3gH.mjs";
4
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-D2msH1cj.mjs";
5
5
  import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { basename, dirname, join, sep } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -504,7 +504,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
504
504
  const realAssets = existsSync(assetsDir) ? realpathSync(assetsDir) : null;
505
505
  for (const r of refs) {
506
506
  if (!isLocalAssetRef(r) || !r.endsWith(".poster.png") || existsSync(join(assetsDir, r))) continue;
507
- const { ensurePoster } = await import("./poster-CoyobbGW.mjs");
507
+ const { ensurePoster } = await import("./poster-BEjUcQP3.mjs");
508
508
  const g = await ensurePoster(assetsDir, r.slice(0, -11));
509
509
  if (!g.ok) throw new Error(`design/assets/${r}: ${g.error}`);
510
510
  console.log(` poster: rendered design/assets/${r} (${g.width}×${g.height})`);
package/dist/cli.mjs CHANGED
@@ -71,7 +71,7 @@ cli.command("init", "Scaffold design/ in this repo").option("--mode <mode>", "st
71
71
  });
72
72
  });
73
73
  for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot reload, comments, Live Jam)"], ["canvas", "Start the local canvas - same as dev"]]) cli.command(name, desc).option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
74
- const { dev } = await import("./dev-DH2W7Ffw.mjs");
74
+ const { dev } = await import("./dev-LnIISva5.mjs");
75
75
  let port;
76
76
  if (opts.port !== void 0) {
77
77
  const n = Number(opts.port);
@@ -81,7 +81,7 @@ for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot
81
81
  await dev(resolve$1(opts.root), port);
82
82
  });
83
83
  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("--embed-seeds", "Copy comment history INTO the web root (identifying - every event carries its author's email)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
84
- const { buildSite } = await import("./build-B4yPgFNF.mjs");
84
+ const { buildSite } = await import("./build-D_g53Bp2.mjs");
85
85
  try {
86
86
  const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
87
87
  await buildSite(resolve$1(opts.root), boards, opts.allBoards === true, opts.embedSeeds === true);
@@ -135,10 +135,10 @@ cli.command("boards", "The sidebar as files: every folder and board in reading o
135
135
  process.exit(1);
136
136
  }
137
137
  });
138
- cli.command("shot <frame>", "Render one frame headless and print the PNG path (needs `dev` running)").option("--root <dir>", "Host repo root", { default: "." }).option("--theme <name>", "Theme to render (default: light)").option("--scale <n>", "Device pixels per CSS px, 1-4 (default: 2; 4 for print-quality stills)").action(async (frame, opts) => {
139
- const { shotCommand } = await import("./shot-By1AItpD.mjs");
138
+ cli.command("shot [...frames]", "Render frames headless and print the PNG paths (needs `dev` running): ids, --scene <name> or --all").option("--root <dir>", "Host repo root", { default: "." }).option("--scene <name>", "Every frame of one scene").option("--all", "Every frame of the canvas").option("--theme <name>", "Theme to render (default: light)").option("--scale <n>", "Device pixels per CSS px, 1-4 (default: 2; 4 for print-quality stills)").option("--json", "Print the results as JSON ({ results: [...] }) instead of paths").action(async (frames, opts) => {
139
+ const { shotCommand } = await import("./shot-BzQ0PXKH.mjs");
140
140
  try {
141
- await shotCommand(resolve$1(opts.root), frame, opts);
141
+ await shotCommand(resolve$1(opts.root), frames, opts);
142
142
  } catch (err) {
143
143
  console.error(`[${NAME}] ${err.message}`);
144
144
  process.exit(1);
@@ -1,6 +1,6 @@
1
1
  import { n as NAME, r as PKG } from "./cli.mjs";
2
2
  import { l as detectHost, s as loadConfig } from "./manifest-CaslQIAO.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BeBGu3gH.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-D2msH1cj.mjs";
4
4
  import { realpathSync } from "node:fs";
5
5
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -255,9 +255,14 @@ async function dev(root, portFlag) {
255
255
  const port = typeof addr === "object" && addr ? addr.port : picked.port;
256
256
  if (picked.fellBack) console.log(`\n port ${desiredPort} is in use - serving "${projectName}" on ${port} instead`);
257
257
  console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
258
+ setTimeout(async () => {
259
+ try {
260
+ await (await import("./shot-DlmTO8AF.mjs")).sweepGhosts((m) => console.log(` ${m}`));
261
+ } catch {}
262
+ }, 0);
258
263
  try {
259
264
  const { staleManagedInstructions } = await import("./managed-HwHVNI3h.mjs");
260
- const { installedVersion } = await import("./plugin-BeBGu3gH.mjs").then((n) => n.i);
265
+ const { installedVersion } = await import("./plugin-D2msH1cj.mjs").then((n) => n.i);
261
266
  const stale = staleManagedInstructions(root);
262
267
  if (stale.length) {
263
268
  const shown = stale.slice(0, 4).join(", ") + (stale.length > 4 ? `, +${stale.length - 4} more` : "");
@@ -422,7 +422,7 @@ function apiMiddleware(root, opts = {}) {
422
422
  };
423
423
  const asPng = url.searchParams.get("format") === "png";
424
424
  const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
425
- const { shootFrame } = await import("./shot-z-d-zMzf.mjs");
425
+ const { shootFrame } = await import("./shot-DlmTO8AF.mjs");
426
426
  const r = await shootFrame({
427
427
  root,
428
428
  viewports: opts.viewports ?? {},
@@ -441,6 +441,7 @@ function apiMiddleware(root, opts = {}) {
441
441
  height: r.height,
442
442
  scale: r.scale,
443
443
  ...r.truncated ? { truncated: true } : {},
444
+ ...r.unsettled ? { unsettled: true } : {},
444
445
  ...r.note ? { note: r.note } : {}
445
446
  };
446
447
  if (!asPng) return json(res, 200, summary);
@@ -453,11 +454,46 @@ function apiMiddleware(root, opts = {}) {
453
454
  });
454
455
  return res.end(png);
455
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
+ }
456
492
  if (path === "poster" && req.method === "GET") {
457
493
  if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
458
494
  const src = url.searchParams.get("src") ?? "";
459
- const { ensurePoster, isLocalClip } = await import("./poster-CoyobbGW.mjs");
460
- const { isLocalAssetRef } = await import("./build-B4yPgFNF.mjs");
495
+ const { ensurePoster, isLocalClip } = await import("./poster-BEjUcQP3.mjs");
496
+ const { isLocalAssetRef } = await import("./build-D_g53Bp2.mjs");
461
497
  if (!isLocalAssetRef(src) || !isLocalClip(src)) return json(res, 400, { error: "src must be a clip under design/assets/" });
462
498
  const r = await ensurePoster(join(root, "design", "assets"), src);
463
499
  if (!r.ok) return json(res, r.error.includes("does not exist") ? 404 : 503, { error: r.error });
@@ -982,23 +1018,68 @@ function marverPlugin(ctx) {
982
1018
  if (!existsSync(reqPath)) return;
983
1019
  inFlight.add(file);
984
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
+ }
985
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
+ };
986
1042
  let spec;
987
1043
  try {
988
- spec = JSON.parse(readFileSync(reqPath, "utf8"));
989
- } 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
+ });
990
1080
  return;
991
1081
  }
992
- const base = file.replace(/\.request\.json$/, "");
993
- const resultPath = join(inbox, `${base}.result.json`);
994
- const frameId = typeof spec.frame === "string" ? spec.frame : "";
995
- const theme = typeof spec.theme === "string" ? spec.theme : "light";
996
1082
  const o = origin();
997
- const write = (r) => {
998
- try {
999
- writeFileSync(resultPath, JSON.stringify(r, null, 2));
1000
- } catch {}
1001
- };
1002
1083
  if (!o) {
1003
1084
  write({
1004
1085
  ok: false,
@@ -1006,18 +1087,55 @@ function marverPlugin(ctx) {
1006
1087
  });
1007
1088
  return;
1008
1089
  }
1009
- const { shootFrame } = await import("./shot-z-d-zMzf.mjs");
1010
- write(await shootFrame({
1011
- root,
1012
- viewports: config.viewports,
1013
- frameId,
1014
- theme,
1015
- origin: o
1016
- }));
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
+ }
1017
1117
  try {
1018
- rmSync(reqPath, { force: true });
1019
- } 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
+ }
1020
1135
  } finally {
1136
+ try {
1137
+ rmSync(claimed, { force: true });
1138
+ } catch {}
1021
1139
  inFlight.delete(file);
1022
1140
  }
1023
1141
  }, 60);
@@ -1,4 +1,4 @@
1
- import { capture, findChrome } from "./shot-z-d-zMzf.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 };