truecanvas 0.2.0 → 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/dist/cli.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ viewerHtml
4
+ } from "./chunk-M7B3W7EP.js";
2
5
  import {
3
6
  DEVICES,
4
7
  EditError,
@@ -7,6 +10,7 @@ import {
7
10
  assertCanvasName,
8
11
  clearCompare,
9
12
  componentsDir,
13
+ configStatus,
10
14
  describeComponent,
11
15
  detectPm,
12
16
  findByPath,
@@ -16,8 +20,6 @@ import {
16
20
  initProject,
17
21
  layoutFiles,
18
22
  listRoutes,
19
- loadConfig,
20
- nextConfigStatus,
21
23
  outlineDoc,
22
24
  parseCanvas,
23
25
  readDesignTokens,
@@ -26,11 +28,15 @@ import {
26
28
  shadcnAdd,
27
29
  shadcnRegistry,
28
30
  shadcnStatus,
31
+ shellSafe,
29
32
  syncComponentRegistry,
30
33
  toolEnv,
31
34
  writeCompare
32
- } from "./chunk-ZPARY6OD.js";
33
- import "./chunk-T2TCBSTG.js";
35
+ } from "./chunk-OVYSWA2F.js";
36
+ import {
37
+ loadConfig
38
+ } from "./chunk-PBKX3SBC.js";
39
+ import "./chunk-WU7NBTJH.js";
34
40
  import {
35
41
  ICON_LIBRARIES,
36
42
  iconLibraries,
@@ -41,14 +47,14 @@ import {
41
47
 
42
48
  // src/cli/index.ts
43
49
  import { spawn as spawn3 } from "child_process";
44
- import fs11 from "fs";
45
- import path11 from "path";
50
+ import fs13 from "fs";
51
+ import path14 from "path";
46
52
  import { parseArgs } from "util";
47
53
 
48
54
  // src/server/http.ts
49
55
  import http from "http";
50
- import fs4 from "fs";
51
- import path4 from "path";
56
+ import fs6 from "fs";
57
+ import path7 from "path";
52
58
  import { randomUUID } from "crypto";
53
59
  import { fileURLToPath } from "url";
54
60
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -84,6 +90,276 @@ function addShadcnComponents(root, names) {
84
90
  return exclusive(() => shadcnAdd(root, names));
85
91
  }
86
92
 
93
+ // src/share/snapshot.ts
94
+ import crypto from "crypto";
95
+ import fs from "fs";
96
+ import path from "path";
97
+ var MAX_ASSETS_BYTES = 80 * 1024 * 1024;
98
+ function sharesDir(root) {
99
+ return path.join(root, "node_modules", ".cache", "truecanvas", "shares");
100
+ }
101
+ async function createSnapshot(ws, shots, canvas, opts = {}) {
102
+ const doc = ws.doc(canvas);
103
+ const frames = doc.frames.filter((f) => !opts.frames || opts.frames.includes(f.frameName));
104
+ if (!frames.length) throw new Error(`Canvas "${canvas}" has no frames to share.`);
105
+ const id = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[-:T]/g, "").replace(/^(\d{8})/, "$1-");
106
+ const dir = path.join(sharesDir(ws.config.root), canvas, id);
107
+ fs.mkdirSync(path.join(dir, "frames"), { recursive: true });
108
+ fs.mkdirSync(path.join(dir, "assets"), { recursive: true });
109
+ const assets = new Assets(path.join(dir, "assets"));
110
+ const appOrigin = new URL(ws.config.appUrl).origin;
111
+ const out = [];
112
+ const external = /* @__PURE__ */ new Set();
113
+ const used = /* @__PURE__ */ new Set();
114
+ for (const f of frames) {
115
+ const device = findDevice(f.device);
116
+ const raw = await shots.snapshot({ canvas, frame: f.frameName, width: f.width, height: f.height, theme: f.theme ?? "light", mobile: device ? device.kind !== "desktop" : false });
117
+ for (const u of raw.requests) if (new URL(u).origin !== appOrigin) external.add(new URL(u).origin);
118
+ let slug = f.frameName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "frame";
119
+ while (used.has(slug)) slug += "-2";
120
+ used.add(slug);
121
+ fs.writeFileSync(path.join(dir, "frames", `${slug}.html`), await freeze(raw, assets));
122
+ fs.writeFileSync(path.join(dir, "frames", `${slug}.png`), raw.png);
123
+ out.push({ name: f.frameName, x: f.x, y: f.y, width: raw.width, height: f.height ?? raw.height, theme: f.theme, device: f.device, html: `frames/${slug}.html`, image: `frames/${slug}.png` });
124
+ }
125
+ const manifest = {
126
+ format: 1,
127
+ project: path.basename(ws.config.root),
128
+ canvas,
129
+ id,
130
+ createdAt: Date.now(),
131
+ frames: out,
132
+ missing: [...assets.missing],
133
+ external: [...external].filter((o) => !/fonts\.(googleapis|gstatic)\.com$/.test(new URL(o).host))
134
+ };
135
+ fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(manifest, null, 2));
136
+ fs.writeFileSync(path.join(dir, "index.html"), viewerHtml());
137
+ return { dir, manifest };
138
+ }
139
+ async function freeze(raw, assets) {
140
+ let css = "";
141
+ for (const sheet of raw.css) {
142
+ let text = sheet.text;
143
+ if (sheet.href && !text) text = await fetchText(sheet.href) ?? "";
144
+ text = await replaceAsync(text, /url\(\s*(['"]?)([^'")]+)\1\s*\)/g, async (m, _q, u) => {
145
+ const local = await assets.local(u, sheet.base, "../assets/");
146
+ return local ? `url("${local}")` : m;
147
+ });
148
+ css += sheet.media && sheet.media !== "all" ? `@media ${sheet.media} {
149
+ ${text}
150
+ }
151
+ ` : `${text}
152
+ `;
153
+ }
154
+ let html = raw.html;
155
+ html = await replaceAsync(html, /\s(src|poster|href|xlink:href)="([^"]+)"/g, async (m, attr, u) => {
156
+ if (u.startsWith("tc-canvas:")) return m;
157
+ const local = await assets.local(decodeEntities(u), raw.url, "../assets/");
158
+ return local ? ` ${attr}="${local}"` : m;
159
+ });
160
+ html = await replaceAsync(html, /url\(("|['"]?)([^'")&]+)\1\)/g, async (m, q, u) => {
161
+ const local = await assets.local(decodeEntities(u), raw.url, "../assets/");
162
+ return local ? `url(${q}${local}${q})` : m;
163
+ });
164
+ for (const c2 of raw.canvases) {
165
+ const local = assets.store(c2.png, "png");
166
+ html = html.split(`src="tc-canvas:${c2.index}"`).join(`src="../assets/${local}"`);
167
+ }
168
+ html = html.replace(/\ssrc="tc-canvas:\d+"/g, "");
169
+ const head = `<meta name="robots" content="noindex"><style>${css.replace(/<\/style/gi, "<\\/style")}</style>`;
170
+ return html.includes("</head>") ? html.replace("</head>", `${head}</head>`) : head + html;
171
+ }
172
+ var Assets = class {
173
+ constructor(dir) {
174
+ this.dir = dir;
175
+ }
176
+ dir;
177
+ byUrl = /* @__PURE__ */ new Map();
178
+ bytes = 0;
179
+ missing = /* @__PURE__ */ new Set();
180
+ /** The local path (with `prefix`) for an asset URL, or null to leave it as is (data:, #fragment). */
181
+ local(spec, base, prefix) {
182
+ if (/^(data:|#|about:|blob:)/i.test(spec.trim())) return Promise.resolve(null);
183
+ let url;
184
+ try {
185
+ url = new URL(spec.trim(), base).href;
186
+ } catch {
187
+ return Promise.resolve(null);
188
+ }
189
+ if (!/^https?:/.test(url)) return Promise.resolve(null);
190
+ if (!this.byUrl.has(url)) this.byUrl.set(url, this.download(url));
191
+ return this.byUrl.get(url).then((name) => name ? prefix + name : null);
192
+ }
193
+ async download(url) {
194
+ if (this.bytes > MAX_ASSETS_BYTES) {
195
+ this.missing.add(url);
196
+ return null;
197
+ }
198
+ try {
199
+ const res = await fetch(url, { signal: AbortSignal.timeout(2e4) });
200
+ if (!res.ok) throw new Error(String(res.status));
201
+ const buf = Buffer.from(await res.arrayBuffer());
202
+ this.bytes += buf.length;
203
+ return this.store(buf, extension(url, res.headers.get("content-type")));
204
+ } catch {
205
+ this.missing.add(url);
206
+ return null;
207
+ }
208
+ }
209
+ store(buf, ext) {
210
+ const name = `${crypto.createHash("sha256").update(buf).digest("hex").slice(0, 20)}.${ext}`;
211
+ const file = path.join(this.dir, name);
212
+ if (!fs.existsSync(file)) fs.writeFileSync(file, buf);
213
+ return name;
214
+ }
215
+ };
216
+ var TYPES = {
217
+ "image/png": "png",
218
+ "image/jpeg": "jpg",
219
+ "image/webp": "webp",
220
+ "image/avif": "avif",
221
+ "image/gif": "gif",
222
+ "image/svg+xml": "svg",
223
+ "image/x-icon": "ico",
224
+ "image/vnd.microsoft.icon": "ico",
225
+ "font/woff2": "woff2",
226
+ "font/woff": "woff",
227
+ "font/ttf": "ttf",
228
+ "font/otf": "otf",
229
+ "application/font-woff2": "woff2",
230
+ "video/mp4": "mp4",
231
+ "video/webm": "webm"
232
+ };
233
+ function extension(url, type) {
234
+ const fromType = TYPES[(type ?? "").split(";")[0].trim()];
235
+ if (fromType) return fromType;
236
+ const ext = path.extname(new URL(url).pathname).slice(1).toLowerCase();
237
+ return /^[a-z0-9]{1,5}$/.test(ext) ? ext : "bin";
238
+ }
239
+ async function fetchText(url) {
240
+ try {
241
+ const res = await fetch(url, { signal: AbortSignal.timeout(15e3), headers: { "user-agent": "Mozilla/5.0 Chrome/130 Safari/537.36" } });
242
+ return res.ok ? await res.text() : null;
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+ var decodeEntities = (s) => s.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
248
+ async function replaceAsync(input, re, fn) {
249
+ const matches = [...input.matchAll(re)];
250
+ const replaced = await Promise.all(matches.map((m) => fn(...m)));
251
+ let out = "";
252
+ let last = 0;
253
+ matches.forEach((m, i) => {
254
+ out += input.slice(last, m.index) + replaced[i];
255
+ last = m.index + m[0].length;
256
+ });
257
+ return out + input.slice(last);
258
+ }
259
+
260
+ // src/share/publish.ts
261
+ import fs2 from "fs";
262
+ import os from "os";
263
+ import path2 from "path";
264
+ var configFile = () => path2.join(process.env.XDG_CONFIG_HOME ?? path2.join(os.homedir(), ".config"), "truecanvas", "share.json");
265
+ function reviewSite() {
266
+ const { TRUECANVAS_REVIEW_URL: url, TRUECANVAS_REVIEW_TOKEN: token } = process.env;
267
+ if (url && token) return { url: url.replace(/\/+$/, ""), token };
268
+ try {
269
+ const saved = JSON.parse(fs2.readFileSync(configFile(), "utf8"));
270
+ return saved.url && saved.token ? saved : null;
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+ async function connectReviewSite(url, token) {
276
+ const site = { url: url.trim().replace(/\/+$/, ""), token: token.trim() };
277
+ if (!/^https?:\/\//.test(site.url)) throw new Error("The review site URL starts with https://");
278
+ const res = await request(site, "GET", "/api/shares");
279
+ const file = configFile();
280
+ fs2.mkdirSync(path2.dirname(file), { recursive: true });
281
+ fs2.writeFileSync(file, JSON.stringify(site, null, 2), { mode: 384 });
282
+ return res;
283
+ }
284
+ var MAX_FILE = 4.4 * 1024 * 1024;
285
+ async function publishSnapshot(site, dir, manifest, opts = {}) {
286
+ const share = await request(site, "POST", "/api/shares", { project: manifest.project, canvas: manifest.canvas, title: opts.title ?? manifest.canvas });
287
+ const base = `/api/shares/${share.slug}`;
288
+ const known = new Set((await request(site, "GET", `${base}/assets`)).assets);
289
+ const files = [];
290
+ for (const name of fs2.readdirSync(path2.join(dir, "assets"))) if (!known.has(name)) files.push({ local: path2.join(dir, "assets", name), remote: `assets/${name}` });
291
+ for (const f of manifest.frames) for (const p of [f.html, f.image]) files.push({ local: path2.join(dir, p), remote: `${manifest.id}/${p}` });
292
+ const skipped = [];
293
+ let uploaded = 0;
294
+ const queue2 = [...files];
295
+ const worker = async () => {
296
+ for (let f = queue2.shift(); f; f = queue2.shift()) {
297
+ const body = fs2.readFileSync(f.local);
298
+ if (body.length > MAX_FILE) {
299
+ skipped.push(f.remote);
300
+ continue;
301
+ }
302
+ await request(site, "PUT", `${base}/files`, body, { "x-path": f.remote, "content-type": contentType(f.local) });
303
+ uploaded++;
304
+ }
305
+ };
306
+ await Promise.all(Array.from({ length: 4 }, worker));
307
+ if (skipped.some((s) => s.endsWith(".html"))) throw new Error(`A frame is too large to upload (over 4.4 MB): ${skipped.filter((s) => s.endsWith(".html")).join(", ")}`);
308
+ const change = { ...opts.password !== void 0 ? { password: opts.password } : {}, ...opts.title ? { title: opts.title } : {} };
309
+ if (Object.keys(change).length) await request(site, "PATCH", base, change);
310
+ const done = await request(site, "POST", `${base}/versions`, { id: manifest.id, createdAt: manifest.createdAt, frames: manifest.frames });
311
+ return { url: done.url, version: manifest.id, versions: done.versions, uploaded, skipped, password: opts.password === void 0 ? share.password : !!opts.password };
312
+ }
313
+ async function deleteShare(site, project, canvas) {
314
+ const found = await request(site, "GET", `/api/shares?project=${encodeURIComponent(project)}&canvas=${encodeURIComponent(canvas)}`);
315
+ if (!found.slug) return false;
316
+ await request(site, "DELETE", `/api/shares/${found.slug}`);
317
+ return true;
318
+ }
319
+ async function updateShare(site, project, canvas, change) {
320
+ const share = await request(site, "POST", "/api/shares", { project, canvas });
321
+ await request(site, "PATCH", `/api/shares/${share.slug}`, change);
322
+ return share.url;
323
+ }
324
+ async function request(site, method, route, body, headers = {}) {
325
+ const raw = Buffer.isBuffer(body);
326
+ let res;
327
+ try {
328
+ res = await fetch(site.url + route, {
329
+ method,
330
+ headers: { authorization: `Bearer ${site.token}`, ...body !== void 0 && !raw ? { "content-type": "application/json" } : {}, ...headers },
331
+ body: body === void 0 ? void 0 : raw ? new Uint8Array(body) : JSON.stringify(body),
332
+ signal: AbortSignal.timeout(6e4)
333
+ });
334
+ } catch (err) {
335
+ throw new Error(`Couldn't reach the review site at ${site.url}: ${err.message}`);
336
+ }
337
+ if (res.status === 401) throw new Error("The review site refused the token. Run `truecanvas share setup` again with the REVIEW_TOKEN from the site's environment variables.");
338
+ const data2 = await res.json().catch(() => ({}));
339
+ if (!res.ok) throw new Error(`Review site: ${data2.error ?? `${res.status} ${res.statusText}`}`);
340
+ return data2;
341
+ }
342
+ function contentType(file) {
343
+ const ext = path2.extname(file).slice(1).toLowerCase();
344
+ const types = {
345
+ html: "text/html; charset=utf-8",
346
+ png: "image/png",
347
+ jpg: "image/jpeg",
348
+ webp: "image/webp",
349
+ avif: "image/avif",
350
+ gif: "image/gif",
351
+ svg: "image/svg+xml",
352
+ ico: "image/x-icon",
353
+ woff2: "font/woff2",
354
+ woff: "font/woff",
355
+ ttf: "font/ttf",
356
+ otf: "font/otf",
357
+ mp4: "video/mp4",
358
+ webm: "video/webm"
359
+ };
360
+ return types[ext] ?? "application/octet-stream";
361
+ }
362
+
87
363
  // src/server/mcp.ts
88
364
  var INSTRUCTIONS = `Truecanvas is a design canvas whose layers are the project's real React components.
89
365
  A canvas is a .tsx file (canvas/<name>.canvas.tsx) with <Frame> artboards; every edit you make is written to that file as clean TSX and appears live in the user's editor window.
@@ -577,7 +853,7 @@ ${source.slice(hit.node.start, hit.node.end)}
577
853
  async ({ name }) => {
578
854
  try {
579
855
  ws.createCanvas(name, actor());
580
- return text(`\u2713 Created canvas ${name} (${ws.relFile(name)}). Next.js picks the new route up in a second.`);
856
+ return text(`\u2713 Created canvas ${name} (${ws.relFile(name)}). The app picks it up in a second.`);
581
857
  } catch (e) {
582
858
  return fail(e);
583
859
  }
@@ -611,8 +887,9 @@ ${source.slice(node.start, node.end)}
611
887
  else where = `
612
888
  on a ${t.node.name} (it may have moved)`;
613
889
  }
614
- const msgs = t.messages.map((m) => ` ${m.author.name}${m.author.kind === "agent" ? " (agent)" : ""}: ${m.text}`).join("\n");
615
- return `thread ${t.id}${t.resolved ? " [resolved]" : ""} in frame "${t.frame}" at ${t.x},${t.y}${where}
890
+ const msgs = t.messages.map((m) => ` ${m.author.name}${m.author.kind === "agent" ? " (agent)" : m.author.kind === "client" ? " (client)" : ""}: ${m.text}`).join("\n");
891
+ const shared = t.share ? " [from a client on the share link: replies and resolving are sent to the client]" : "";
892
+ return `thread ${t.id}${t.resolved ? " [resolved]" : ""}${shared} in frame "${t.frame}" at ${t.x},${t.y}${where}
616
893
  ${msgs}`;
617
894
  });
618
895
  return text(parts.join("\n\n"));
@@ -652,9 +929,9 @@ ${msgs}`;
652
929
  try {
653
930
  const c2 = resolveCanvas(canvas);
654
931
  const a = actor();
655
- const author = { name: a.kind === "agent" ? a.name : "agent", kind: "agent" };
656
- if (note?.trim()) ws.comments.reply(c2, thread, note, author);
657
- const t = ws.comments.resolve(c2, thread, true, author);
932
+ const author2 = { name: a.kind === "agent" ? a.name : "agent", kind: "agent" };
933
+ if (note?.trim()) ws.comments.reply(c2, thread, note, author2);
934
+ const t = ws.comments.resolve(c2, thread, true, author2);
658
935
  ws.commentsChanged(c2);
659
936
  presence(c2, "editing", "Resolved a comment", [], t.frame);
660
937
  return text(`\u2713 Resolved thread ${t.id}.`);
@@ -700,6 +977,36 @@ ${msgs}`;
700
977
  }
701
978
  }
702
979
  );
980
+ server.registerTool(
981
+ "share_canvas",
982
+ {
983
+ title: "Share canvas",
984
+ description: "Share a canvas with a client as a link: every frame is rendered by the app and frozen into static HTML (no scripts, no API calls), then published to the studio's review site as a new version of the canvas's link. Without a review site set up, returns a local preview instead. Data the app showed while rendering is in the snapshot: use sample data, never real client data.",
985
+ inputSchema: {
986
+ ...canvasArg,
987
+ frames: z.array(z.string()).optional().describe("Frame names to share; omit for all."),
988
+ title: z.string().optional().describe('Title the client sees, e.g. "Homepage, round 2".')
989
+ }
990
+ },
991
+ async ({ canvas, frames, title }) => {
992
+ try {
993
+ const c2 = resolveCanvas(canvas);
994
+ presence(c2, "looking", "Preparing a share link");
995
+ const { dir, manifest } = await createSnapshot(ws, shots, c2, { frames });
996
+ const preview = `http://localhost:${ws.config.port}/share/${encodeURIComponent(c2)}/${manifest.id}/`;
997
+ const notes = [
998
+ manifest.external.length ? `While rendering, the app called ${manifest.external.join(", ")}: whatever it showed is in the snapshot.` : "",
999
+ manifest.missing.length ? `${manifest.missing.length} asset(s) couldn't be fetched.` : ""
1000
+ ].filter(Boolean);
1001
+ const site = reviewSite();
1002
+ if (!site) return text([`\u2713 Snapshot ready (${manifest.frames.length} frames). No review site is set up, so this is a local preview: ${preview}`, "To get client links, the user runs `npx truecanvas share setup`.", ...notes].join("\n"));
1003
+ const published = await publishSnapshot(site, dir, manifest, { title });
1004
+ return text([`\u2713 Shared ${manifest.frames.length} frame(s): ${published.url}`, `Version ${published.versions} of this link${published.password ? ", password protected" : ""}.`, ...notes].join("\n"));
1005
+ } catch (e) {
1006
+ return fail(e);
1007
+ }
1008
+ }
1009
+ );
703
1010
  server.registerTool(
704
1011
  "screenshot_frame",
705
1012
  {
@@ -870,14 +1177,14 @@ ${out.out.slice(-1500)}`));
870
1177
  }
871
1178
 
872
1179
  // src/server/thumbnails.ts
873
- import crypto from "crypto";
874
- import fs from "fs";
875
- import path from "path";
1180
+ import crypto2 from "crypto";
1181
+ import fs3 from "fs";
1182
+ import path3 from "path";
876
1183
  var Thumbnails = class _Thumbnails {
877
1184
  constructor(ws, shots) {
878
1185
  this.ws = ws;
879
1186
  this.shots = shots;
880
- this.dir = path.join(ws.config.root, "node_modules", ".cache", "truecanvas", "thumbs");
1187
+ this.dir = path3.join(ws.config.root, "node_modules", ".cache", "truecanvas", "thumbs");
881
1188
  }
882
1189
  ws;
883
1190
  shots;
@@ -903,26 +1210,26 @@ var Thumbnails = class _Thumbnails {
903
1210
  key(spec, props) {
904
1211
  let stamp = "";
905
1212
  try {
906
- const file = spec.library ? path.join(this.ws.config.root, "node_modules", spec.library, "package.json") : path.join(this.ws.config.root, spec.file);
907
- stamp = String(fs.statSync(file).mtimeMs);
1213
+ const file = spec.library ? path3.join(this.ws.config.root, "node_modules", spec.library, "package.json") : path3.join(this.ws.config.root, spec.file);
1214
+ stamp = String(fs3.statSync(file).mtimeMs);
908
1215
  } catch {
909
1216
  stamp = "0";
910
1217
  }
911
- return crypto.createHash("sha1").update(JSON.stringify({ v: 1, name: spec.name, props, stamp })).digest("hex").slice(0, 16);
1218
+ return crypto2.createHash("sha1").update(JSON.stringify({ v: 1, name: spec.name, props, stamp })).digest("hex").slice(0, 16);
912
1219
  }
913
1220
  async get(name, preset) {
914
1221
  await this.ws.catalog.load();
915
1222
  const spec = this.ws.catalog.get(name);
916
1223
  if (!spec) throw new Error(`Unknown component ${name}`);
917
1224
  const props = _Thumbnails.previewProps(spec, preset);
918
- const file = path.join(this.dir, `${name}-${this.key(spec, props)}.png`);
919
- if (fs.existsSync(file)) return fs.readFileSync(file);
1225
+ const file = path3.join(this.dir, `${name}-${this.key(spec, props)}.png`);
1226
+ if (fs3.existsSync(file)) return fs3.readFileSync(file);
920
1227
  const job = this.queue.then(async () => {
921
- if (fs.existsSync(file)) return fs.readFileSync(file);
1228
+ if (fs3.existsSync(file)) return fs3.readFileSync(file);
922
1229
  const url = `${this.ws.config.appUrl}/truecanvas/__preview?component=${encodeURIComponent(name)}&props=${encodeURIComponent(JSON.stringify(props))}`;
923
1230
  const png = await this.shots.element(url, { width: 640, height: 480 });
924
- fs.mkdirSync(this.dir, { recursive: true });
925
- fs.writeFileSync(file, png);
1231
+ fs3.mkdirSync(this.dir, { recursive: true });
1232
+ fs3.writeFileSync(file, png);
926
1233
  return png;
927
1234
  });
928
1235
  this.queue = job.catch(() => void 0);
@@ -931,22 +1238,32 @@ var Thumbnails = class _Thumbnails {
931
1238
  };
932
1239
 
933
1240
  // src/server/screenshot.ts
934
- import fs2 from "fs";
935
- import os from "os";
936
- import path2 from "path";
1241
+ import fs4 from "fs";
1242
+ import os2 from "os";
1243
+ import path4 from "path";
937
1244
  function findChromium() {
938
1245
  if (process.env.TRUECANVAS_CHROME) return process.env.TRUECANVAS_CHROME;
939
- const cache2 = path2.join(os.homedir(), ".cache", "ms-playwright");
940
- if (fs2.existsSync(cache2)) {
941
- const shells = fs2.readdirSync(cache2).filter((d) => d.startsWith("chromium_headless_shell-")).sort().reverse();
1246
+ const cache2 = process.env.PLAYWRIGHT_BROWSERS_PATH || (process.platform === "win32" ? path4.join(process.env.LOCALAPPDATA ?? path4.join(os2.homedir(), "AppData", "Local"), "ms-playwright") : process.platform === "darwin" ? path4.join(os2.homedir(), "Library", "Caches", "ms-playwright") : path4.join(os2.homedir(), ".cache", "ms-playwright"));
1247
+ if (fs4.existsSync(cache2)) {
1248
+ const shells = fs4.readdirSync(cache2).filter((d) => d.startsWith("chromium_headless_shell-")).sort().reverse();
942
1249
  for (const d of shells) {
943
- for (const sub of ["chrome-headless-shell-linux64/chrome-headless-shell", "chrome-linux/headless_shell", "chrome-headless-shell-mac-arm64/chrome-headless-shell", "chrome-headless-shell-mac-x64/chrome-headless-shell"]) {
944
- const p = path2.join(cache2, d, sub);
945
- if (fs2.existsSync(p)) return p;
1250
+ for (const sub of [
1251
+ "chrome-headless-shell-linux64/chrome-headless-shell",
1252
+ "chrome-linux/headless_shell",
1253
+ "chrome-headless-shell-mac-arm64/chrome-headless-shell",
1254
+ "chrome-headless-shell-mac-x64/chrome-headless-shell",
1255
+ "chrome-headless-shell-win64/chrome-headless-shell.exe",
1256
+ "chrome-win/headless_shell.exe"
1257
+ ]) {
1258
+ const p = path4.join(cache2, d, sub);
1259
+ if (fs4.existsSync(p)) return p;
946
1260
  }
947
1261
  }
948
1262
  }
1263
+ const programFiles = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
949
1264
  for (const p of [
1265
+ ...programFiles.flatMap((dir) => [path4.join(dir, "Google", "Chrome", "Application", "chrome.exe"), path4.join(dir, "Microsoft", "Edge", "Application", "msedge.exe")]),
1266
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
950
1267
  "/usr/bin/chromium-browser",
951
1268
  "/usr/bin/chromium",
952
1269
  "/usr/bin/google-chrome-stable",
@@ -954,7 +1271,7 @@ function findChromium() {
954
1271
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
955
1272
  "/Applications/Chromium.app/Contents/MacOS/Chromium"
956
1273
  ]) {
957
- if (fs2.existsSync(p)) return p;
1274
+ if (fs4.existsSync(p)) return p;
958
1275
  }
959
1276
  return void 0;
960
1277
  }
@@ -1027,7 +1344,14 @@ var Screenshotter = class {
1027
1344
  });
1028
1345
  }
1029
1346
  async frame(opts) {
1030
- const options = {
1347
+ return this.withContext(this.frameOptions(opts), async (ctx) => {
1348
+ const page = await this.openFrame(ctx, opts);
1349
+ const format = opts.jpeg ? { type: "jpeg", quality: 78 } : { type: "png" };
1350
+ return await page.screenshot(format);
1351
+ });
1352
+ }
1353
+ frameOptions(opts) {
1354
+ return {
1031
1355
  viewport: { width: opts.width, height: opts.height ?? 800 },
1032
1356
  deviceScaleFactor: opts.scale ?? 1,
1033
1357
  colorScheme: opts.theme === "dark" ? "dark" : "light",
@@ -1035,28 +1359,135 @@ var Screenshotter = class {
1035
1359
  isMobile: !!opts.mobile,
1036
1360
  hasTouch: !!opts.mobile
1037
1361
  };
1038
- return this.withContext(options, async (ctx) => {
1039
- const page = await ctx.newPage();
1040
- const url = `${this.appUrl}/truecanvas/${encodeURIComponent(opts.canvas)}?frame=${encodeURIComponent(opts.frame)}&theme=${opts.theme}&still=1`;
1041
- await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1042
- await page.waitForSelector("[data-tc-frame]", { timeout: 2e4 });
1043
- if (!opts.height) {
1044
- const h = await page.evaluate(() => Math.ceil(document.querySelector("[data-tc-frame]").getBoundingClientRect().height));
1045
- await page.setViewportSize({ width: opts.width, height: Math.min(Math.max(h, 100), opts.maxHeight ?? 16e3) });
1046
- }
1047
- await page.waitForFunction(() => window.__tcStill === true, void 0, { timeout: 6e3 }).catch(() => {
1362
+ }
1363
+ /** Loads a frame, sizes hug frames to their content and waits until it has settled on its final state. */
1364
+ async openFrame(ctx, opts) {
1365
+ const page = await ctx.newPage();
1366
+ const url = `${this.appUrl}/truecanvas/${encodeURIComponent(opts.canvas)}?frame=${encodeURIComponent(opts.frame)}&theme=${opts.theme}&still=1`;
1367
+ await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1368
+ await page.waitForSelector("[data-tc-frame]", { timeout: 2e4 });
1369
+ if (!opts.height) {
1370
+ const h = await page.evaluate(() => Math.ceil(document.querySelector("[data-tc-frame]").getBoundingClientRect().height));
1371
+ await page.setViewportSize({ width: opts.width, height: Math.min(Math.max(h, 100), opts.maxHeight ?? 16e3) });
1372
+ }
1373
+ await page.waitForFunction(() => window.__tcStill === true, void 0, { timeout: 6e3 }).catch(() => {
1374
+ });
1375
+ await page.evaluate(() => document.fonts.ready.then(() => new Promise((r) => setTimeout(r, 50))));
1376
+ await page.waitForTimeout(150);
1377
+ return page;
1378
+ }
1379
+ /**
1380
+ * A frame frozen into static HTML (share links): the rendered DOM without
1381
+ * scripts, every stylesheet's text, and pixels of the <canvas> elements
1382
+ * (shaders, charts), which only exist at runtime. Assets are left as URLs
1383
+ * for the caller to fetch.
1384
+ */
1385
+ async snapshot(opts) {
1386
+ return this.withContext(this.frameOptions(opts), async (ctx) => {
1387
+ const requests = /* @__PURE__ */ new Set();
1388
+ ctx.on("request", (r) => {
1389
+ if (/^https?:/.test(r.url())) requests.add(r.url());
1048
1390
  });
1049
- await page.evaluate(() => document.fonts.ready.then(() => new Promise((r) => setTimeout(r, 50))));
1050
- await page.waitForTimeout(150);
1051
- const format = opts.jpeg ? { type: "jpeg", quality: 78 } : { type: "png" };
1052
- return await page.screenshot(format);
1391
+ const page = await this.openFrame(ctx, opts);
1392
+ await page.evaluate(() => Promise.all([...document.images].map((img) => img.complete ? null : new Promise((r) => (img.onload = r, img.onerror = r, setTimeout(r, 4e3))))));
1393
+ const raw = await page.evaluate(serializePage);
1394
+ const canvases = [];
1395
+ for (const c2 of raw.canvases) {
1396
+ if (c2.w < 1 || c2.h < 1) continue;
1397
+ const png2 = await page.screenshot({ type: "png", clip: { x: c2.x, y: c2.y, width: c2.w, height: c2.h }, omitBackground: true }).catch(() => null);
1398
+ if (png2) canvases.push({ index: c2.index, png: png2 });
1399
+ }
1400
+ const png = await page.screenshot({ type: "png", fullPage: true });
1401
+ const viewport = page.viewportSize();
1402
+ return { url: raw.url, html: raw.html, css: raw.css, canvases, png, width: viewport.width, height: raw.height, requests: [...requests] };
1053
1403
  });
1054
1404
  }
1055
1405
  };
1406
+ function serializePage() {
1407
+ const abs = (u) => {
1408
+ try {
1409
+ return new URL(u, location.href).href;
1410
+ } catch {
1411
+ return u;
1412
+ }
1413
+ };
1414
+ for (const img of Array.from(document.images)) {
1415
+ if (img.currentSrc) img.setAttribute("src", img.currentSrc);
1416
+ for (const a of ["srcset", "sizes", "loading", "decoding"]) img.removeAttribute(a);
1417
+ }
1418
+ for (const input of Array.from(document.querySelectorAll("input"))) {
1419
+ if (input.type === "checkbox" || input.type === "radio") input.toggleAttribute("checked", input.checked);
1420
+ else if (input.type !== "password" && input.type !== "file") input.setAttribute("value", input.value);
1421
+ }
1422
+ for (const t of Array.from(document.querySelectorAll("textarea"))) t.textContent = t.value;
1423
+ for (const o of Array.from(document.querySelectorAll("option"))) o.toggleAttribute("selected", o.selected);
1424
+ const canvases = Array.from(document.querySelectorAll("canvas")).map((c2, index) => {
1425
+ c2.setAttribute("data-tc-canvas", String(index));
1426
+ const r = c2.getBoundingClientRect();
1427
+ return { index, x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height };
1428
+ });
1429
+ const css = [];
1430
+ const collect = (sheet, media = "") => {
1431
+ let rules;
1432
+ try {
1433
+ rules = sheet.cssRules;
1434
+ } catch {
1435
+ if (sheet.href) css.push({ base: sheet.href, text: "", media: sheet.media?.mediaText || media, href: sheet.href });
1436
+ return;
1437
+ }
1438
+ let text = "";
1439
+ for (const rule of Array.from(rules)) {
1440
+ if (rule instanceof CSSImportRule) {
1441
+ if (rule.styleSheet) collect(rule.styleSheet, rule.media?.mediaText || "");
1442
+ continue;
1443
+ }
1444
+ text += `${rule.cssText}
1445
+ `;
1446
+ }
1447
+ css.push({ base: sheet.href || location.href, text, media: sheet.media?.mediaText || media });
1448
+ };
1449
+ for (const sheet of Array.from(document.styleSheets)) if (!sheet.disabled) collect(sheet);
1450
+ for (const sheet of document.adoptedStyleSheets ?? []) collect(sheet);
1451
+ const root = document.documentElement.cloneNode(true);
1452
+ for (const el of Array.from(root.querySelectorAll("script, noscript, style, link, template, iframe, object, embed, nextjs-portal, [data-nextjs-toast]"))) {
1453
+ el.remove();
1454
+ }
1455
+ for (const c2 of Array.from(root.querySelectorAll("canvas[data-tc-canvas]"))) {
1456
+ const i = Number(c2.getAttribute("data-tc-canvas"));
1457
+ const img = document.createElement("img");
1458
+ for (const a of Array.from(c2.attributes)) if (a.name !== "width" && a.name !== "height") img.setAttribute(a.name, a.value);
1459
+ img.setAttribute("src", `tc-canvas:${i}`);
1460
+ img.setAttribute("alt", "");
1461
+ const r = canvases[i];
1462
+ img.style.width = `${r.w}px`;
1463
+ img.style.height = `${r.h}px`;
1464
+ c2.replaceWith(img);
1465
+ }
1466
+ for (const el of [root, ...Array.from(root.querySelectorAll("*"))]) {
1467
+ for (const a of Array.from(el.attributes)) {
1468
+ const n = a.name;
1469
+ if (n.startsWith("on") || n === "data-tc" || n === "data-tc-canvas" || n === "data-tc-hidden") el.removeAttribute(n);
1470
+ }
1471
+ for (const n of ["src", "poster", "xlink:href"]) {
1472
+ const v = el.getAttribute(n);
1473
+ if (v && !/^(data:|#|tc-canvas:)/.test(v)) el.setAttribute(n, abs(v));
1474
+ }
1475
+ const href = el.getAttribute("href");
1476
+ if (href !== null && el.tagName.toLowerCase() === "a") {
1477
+ el.setAttribute("data-href", href);
1478
+ el.removeAttribute("href");
1479
+ el.style.cursor = "pointer";
1480
+ } else if (href && !href.startsWith("#") && !href.startsWith("data:")) el.setAttribute("href", abs(href));
1481
+ }
1482
+ for (const f of Array.from(root.querySelectorAll("form"))) f.removeAttribute("action");
1483
+ const height = Math.ceil(document.querySelector("[data-tc-frame]")?.getBoundingClientRect().height ?? document.documentElement.scrollHeight);
1484
+ return { url: location.href, html: `<!doctype html>
1485
+ ${root.outerHTML}`, css, canvases, height };
1486
+ }
1056
1487
 
1057
1488
  // src/server/review.ts
1058
- import fs3 from "fs";
1059
- import path3 from "path";
1489
+ import fs5 from "fs";
1490
+ import path5 from "path";
1060
1491
  import { createHash } from "crypto";
1061
1492
  var actorName = (e) => e.actor.kind === "agent" ? e.actor.name : e.actor.kind === "file" ? "code editor" : "you";
1062
1493
  async function versionAt(ws, canvas, ref) {
@@ -1073,14 +1504,14 @@ async function versionAt(ws, canvas, ref) {
1073
1504
  }
1074
1505
  var readNow = (ws, file) => {
1075
1506
  try {
1076
- return fs3.readFileSync(path3.join(ws.config.root, file), "utf8");
1507
+ return fs5.readFileSync(path5.join(ws.config.root, file), "utf8");
1077
1508
  } catch {
1078
1509
  return null;
1079
1510
  }
1080
1511
  };
1081
1512
  async function reviewPage(ws, canvas, ref, since) {
1082
1513
  const rel = ws.relFile(canvas);
1083
- const exists = fs3.existsSync(ws.file(canvas));
1514
+ const exists = fs5.existsSync(ws.file(canvas));
1084
1515
  const old = await versionAt(ws, canvas, ref);
1085
1516
  if (!exists && old.source === null) return null;
1086
1517
  const now = exists ? ws.read(canvas) : null;
@@ -1104,7 +1535,7 @@ async function reviewPage(ws, canvas, ref, since) {
1104
1535
  }
1105
1536
  }
1106
1537
  }
1107
- const comments = path3.join(ws.config.canvasDir, `${canvas}.comments.json`);
1538
+ const comments = `${ws.config.canvasDir}/${canvas}.comments.json`;
1108
1539
  if (await ws.git.show(ref, comments) !== readNow(ws, comments)) files.push(comments);
1109
1540
  if (!files.length) return null;
1110
1541
  const history = exists ? ws.changesSince(canvas, since) : [];
@@ -1132,7 +1563,7 @@ async function reviewAll(ws) {
1132
1563
  const linked = new Set(ws.linkedFiles());
1133
1564
  const out = [];
1134
1565
  for (const canvas of names) {
1135
- const own = [ws.relFile(canvas), path3.join(ws.config.canvasDir, `${canvas}.comments.json`)];
1566
+ const own = [ws.relFile(canvas), `${ws.config.canvasDir}/${canvas}.comments.json`];
1136
1567
  const touchesLinked = [...linked].some((f) => changed.has(f));
1137
1568
  if (!own.some((f) => changed.has(f)) && !touchesLinked) continue;
1138
1569
  const page = await reviewPage(ws, canvas, ref, since).catch(() => null);
@@ -1150,7 +1581,7 @@ function renderFrame(ws, shots, canvas, frame, side, ref) {
1150
1581
  async function render(ws, shots, canvas, frame, side, ref) {
1151
1582
  let source;
1152
1583
  let files = {};
1153
- if (side === "after") source = fs3.existsSync(ws.file(canvas)) ? ws.read(canvas) : null;
1584
+ if (side === "after") source = fs5.existsSync(ws.file(canvas)) ? ws.read(canvas) : null;
1154
1585
  else {
1155
1586
  const old = await versionAt(ws, canvas, ref);
1156
1587
  source = old.source;
@@ -1166,7 +1597,7 @@ async function render(ws, shots, canvas, frame, side, ref) {
1166
1597
  if (hit) return hit;
1167
1598
  let name = canvas;
1168
1599
  const snapshot = `${canvas}.${side === "before" ? "base" : "now"}`;
1169
- if (side === "before" || !fs3.existsSync(ws.file(canvas))) name = writeCompare(ws.config, canvas, source, files, snapshot);
1600
+ if (side === "before" || !fs5.existsSync(ws.file(canvas))) name = writeCompare(ws.config, canvas, source, files, snapshot);
1170
1601
  try {
1171
1602
  const device = findDevice(f.device);
1172
1603
  const png = await shots.frame({ canvas: name, frame, width: f.width, height: f.height, theme: f.theme ?? "light", scale: Math.min(2, Math.max(0.4, 1e3 / f.width)), mobile: device ? device.kind !== "desktop" : false, jpeg: true, maxHeight: 6e3 });
@@ -1218,6 +1649,150 @@ async function readJson(req, limit = 8 * 1024 * 1024) {
1218
1649
  }
1219
1650
  }
1220
1651
 
1652
+ // src/share/sync.ts
1653
+ import path6 from "path";
1654
+ var EVERY = 2e4;
1655
+ var LOOKUP_EVERY = 5 * 6e4;
1656
+ var CommentSync = class {
1657
+ constructor(ws) {
1658
+ this.ws = ws;
1659
+ }
1660
+ ws;
1661
+ links = /* @__PURE__ */ new Map();
1662
+ remote = /* @__PURE__ */ new Map();
1663
+ timer = null;
1664
+ kickTimer = null;
1665
+ running = null;
1666
+ off = null;
1667
+ start() {
1668
+ this.timer = setInterval(() => void this.syncAll(), EVERY);
1669
+ this.timer.unref();
1670
+ setTimeout(() => void this.syncAll(), 3e3).unref();
1671
+ this.off = this.ws.on((e) => {
1672
+ if (e.type !== "comments") return;
1673
+ if (this.kickTimer) clearTimeout(this.kickTimer);
1674
+ this.kickTimer = setTimeout(() => void this.syncAll(), 1500);
1675
+ });
1676
+ }
1677
+ stop() {
1678
+ if (this.timer) clearInterval(this.timer);
1679
+ if (this.kickTimer) clearTimeout(this.kickTimer);
1680
+ this.off?.();
1681
+ }
1682
+ /** A canvas was just shared: look its link up again on the next sync. */
1683
+ forget(canvas) {
1684
+ this.links.delete(canvas);
1685
+ }
1686
+ syncAll() {
1687
+ this.running ??= this.pass().finally(() => this.running = null);
1688
+ return this.running;
1689
+ }
1690
+ async pass() {
1691
+ const site = reviewSite();
1692
+ if (!site) return;
1693
+ for (const canvas of this.ws.canvases()) {
1694
+ try {
1695
+ await this.syncCanvas(site, canvas);
1696
+ } catch {
1697
+ }
1698
+ }
1699
+ }
1700
+ async slugOf(site, canvas) {
1701
+ const known = this.links.get(canvas);
1702
+ if (known && (known.slug || Date.now() - known.at < LOOKUP_EVERY)) return known.slug;
1703
+ const project = path6.basename(this.ws.config.root);
1704
+ const res = await call(site, "GET", `/api/shares?project=${encodeURIComponent(project)}&canvas=${encodeURIComponent(canvas)}`);
1705
+ this.links.set(canvas, { slug: res.slug, at: Date.now() });
1706
+ return res.slug;
1707
+ }
1708
+ async syncCanvas(site, canvas) {
1709
+ const slug = await this.slugOf(site, canvas);
1710
+ if (!slug) return;
1711
+ const cached = this.remote.get(slug);
1712
+ const res = await call(site, "GET", `/api/shares/${slug}/comments?since=${cached?.updated ?? 0}`);
1713
+ const remote = res.threads ?? cached?.threads ?? [];
1714
+ this.remote.set(slug, { updated: res.updated, threads: remote });
1715
+ const byId = new Map(remote.map((t) => [t.id, t]));
1716
+ const dismissed = this.ws.comments.dismissed(canvas);
1717
+ this.ws.comments.merge(canvas, (local) => {
1718
+ let changed = false;
1719
+ for (const r of remote) {
1720
+ if (dismissed.has(r.id)) continue;
1721
+ const l = local.find((t) => t.id === r.id);
1722
+ if (!l) {
1723
+ local.push(fromRemote(r, slug));
1724
+ changed = true;
1725
+ continue;
1726
+ }
1727
+ if (!l.share) continue;
1728
+ const have = new Set(l.messages.map((m) => m.id));
1729
+ for (const m of r.messages) {
1730
+ if (have.has(m.id)) continue;
1731
+ l.messages.push(message(m));
1732
+ changed = true;
1733
+ }
1734
+ if (changed) l.messages.sort((a, b) => a.at - b.at);
1735
+ if (r.resolved !== l.resolved && (r.resolvedAt ?? 0) > (l.resolvedAt ?? 0)) {
1736
+ l.resolved = r.resolved;
1737
+ l.resolvedBy = r.resolved && r.resolvedBy ? author(r.resolvedBy) : void 0;
1738
+ l.resolvedAt = r.resolvedAt;
1739
+ changed = true;
1740
+ }
1741
+ }
1742
+ return changed;
1743
+ });
1744
+ let pushed = false;
1745
+ for (const l of this.ws.comments.list(canvas)) {
1746
+ if (l.share?.link !== slug) continue;
1747
+ const r = byId.get(l.id);
1748
+ if (!r) continue;
1749
+ const there = new Set(r.messages.map((m) => m.id));
1750
+ for (const m of l.messages) {
1751
+ if (there.has(m.id) || m.author.kind === "client") continue;
1752
+ await call(site, "POST", `/api/shares/${slug}/comments`, { thread: l.id, message: { id: m.id, name: displayName(m.author), text: m.text, at: m.at } });
1753
+ pushed = true;
1754
+ }
1755
+ if (l.resolved !== r.resolved && (l.resolvedAt ?? 0) > (r.resolvedAt ?? 0)) {
1756
+ await call(site, "POST", `/api/shares/${slug}/comments`, { thread: l.id, resolved: l.resolved, name: l.resolvedBy ? displayName(l.resolvedBy) : "Studio" });
1757
+ pushed = true;
1758
+ }
1759
+ }
1760
+ if (pushed) this.remote.delete(slug);
1761
+ }
1762
+ };
1763
+ function fromRemote(r, slug) {
1764
+ return {
1765
+ id: r.id,
1766
+ frame: r.frame,
1767
+ x: r.x,
1768
+ y: r.y,
1769
+ node: null,
1770
+ resolved: r.resolved,
1771
+ ...r.resolved && r.resolvedBy ? { resolvedBy: author(r.resolvedBy) } : {},
1772
+ ...r.resolvedAt ? { resolvedAt: r.resolvedAt } : {},
1773
+ messages: r.messages.map(message),
1774
+ createdAt: r.createdAt,
1775
+ share: { link: slug, version: r.version }
1776
+ };
1777
+ }
1778
+ var author = (a) => ({ name: a.name, kind: a.kind === "client" ? "client" : "user" });
1779
+ var message = (m) => ({ id: m.id, author: author(m.author), text: m.text, at: m.at });
1780
+ function displayName(a) {
1781
+ if (a.kind !== "agent") return a.name;
1782
+ const base = a.name.replace(/[-_ ]?(code|cli|agent)$/i, "") || a.name;
1783
+ return base.charAt(0).toUpperCase() + base.slice(1);
1784
+ }
1785
+ async function call(site, method, route, body) {
1786
+ const res = await fetch(site.url + route, {
1787
+ method,
1788
+ headers: { authorization: `Bearer ${site.token}`, ...body ? { "content-type": "application/json" } : {} },
1789
+ body: body ? JSON.stringify(body) : void 0,
1790
+ signal: AbortSignal.timeout(2e4)
1791
+ });
1792
+ if (!res.ok) throw new Error(`Review site ${res.status}`);
1793
+ return res.json();
1794
+ }
1795
+
1221
1796
  // src/server/http.ts
1222
1797
  var MIME = {
1223
1798
  ".html": "text/html; charset=utf-8",
@@ -1226,7 +1801,14 @@ var MIME = {
1226
1801
  ".svg": "image/svg+xml",
1227
1802
  ".woff2": "font/woff2",
1228
1803
  ".png": "image/png",
1229
- ".json": "application/json"
1804
+ ".json": "application/json",
1805
+ ".jpg": "image/jpeg",
1806
+ ".webp": "image/webp",
1807
+ ".avif": "image/avif",
1808
+ ".gif": "image/gif",
1809
+ ".woff": "font/woff",
1810
+ ".ttf": "font/ttf",
1811
+ ".ico": "image/x-icon"
1230
1812
  };
1231
1813
  async function startServer(ws) {
1232
1814
  const { config } = ws;
@@ -1234,6 +1816,8 @@ async function startServer(ws) {
1234
1816
  const shots = new Screenshotter(config.appUrl);
1235
1817
  let prCache = null;
1236
1818
  const thumbs = new Thumbnails(ws, shots);
1819
+ const commentSync = new CommentSync(ws);
1820
+ commentSync.start();
1237
1821
  const syncRegistry = () => ws.catalog.load().then((list) => syncComponentRegistry(config, list)).catch(() => {
1238
1822
  });
1239
1823
  void syncRegistry();
@@ -1299,8 +1883,9 @@ async function startServer(ws) {
1299
1883
  case "GET /api/state":
1300
1884
  return json(res, 200, {
1301
1885
  appUrl: config.appUrl,
1886
+ framework: config.framework,
1302
1887
  root: config.root,
1303
- projectName: path4.basename(config.root),
1888
+ projectName: path7.basename(config.root),
1304
1889
  canvases: ws.canvases(),
1305
1890
  darkMode: config.darkMode,
1306
1891
  agents: [...ws.agents.values()],
@@ -1332,11 +1917,11 @@ async function startServer(ws) {
1332
1917
  await ws.git.fetch();
1333
1918
  return json(res, 200, { ...await ws.git.status(), linked: ws.designFiles() });
1334
1919
  case "POST /api/git/commit": {
1335
- const { message, scope } = await readJson(req);
1920
+ const { message: message2, scope } = await readJson(req);
1336
1921
  const status = await ws.git.status();
1337
1922
  const linked = new Set(ws.designFiles());
1338
1923
  const files = status.files.map((f) => f.path).filter((f) => scope === "all" || f.startsWith(`${config.canvasDir}/`) || linked.has(f));
1339
- const hash = await ws.git.commit(message, files);
1924
+ const hash = await ws.git.commit(message2, files);
1340
1925
  ws.emit({ type: "git" });
1341
1926
  return json(res, 200, { hash });
1342
1927
  }
@@ -1450,7 +2035,7 @@ async function startServer(ws) {
1450
2035
  for (const file of [f.page, ...layoutFiles(config, f.page)]) {
1451
2036
  const old = await ws.git.show(ref, file);
1452
2037
  if (old === null) continue;
1453
- const now = fs4.existsSync(path4.join(config.root, file)) ? fs4.readFileSync(path4.join(config.root, file), "utf8") : null;
2038
+ const now = fs6.existsSync(path7.join(config.root, file)) ? fs6.readFileSync(path7.join(config.root, file), "utf8") : null;
1454
2039
  if (old !== now) {
1455
2040
  files[file] = old;
1456
2041
  touched.add(f.frameName);
@@ -1580,12 +2165,26 @@ async function startServer(ws) {
1580
2165
  }
1581
2166
  case "POST /api/open-file": {
1582
2167
  const { file } = await readJson(req);
1583
- const abs = path4.resolve(config.root, file);
1584
- const rel = path4.relative(config.root, abs);
1585
- if (rel.startsWith("..") || path4.isAbsolute(rel)) return json(res, 400, { error: "Outside project." });
2168
+ const abs = path7.resolve(config.root, file);
2169
+ const rel = path7.relative(config.root, abs);
2170
+ if (rel.startsWith("..") || path7.isAbsolute(rel)) return json(res, 400, { error: "Outside project." });
1586
2171
  launchEditor(abs, config.editor);
1587
2172
  return json(res, 200, { ok: true });
1588
2173
  }
2174
+ case "GET /api/share/status": {
2175
+ const site = reviewSite();
2176
+ return json(res, 200, { site: site ? site.url : null });
2177
+ }
2178
+ case "POST /api/share/snapshot": {
2179
+ const { canvas, frames, local, title, password } = await readJson(req);
2180
+ assertCanvasName(canvas);
2181
+ const { dir, manifest } = await createSnapshot(ws, shots, canvas, { frames });
2182
+ const preview = `${origin}/share/${encodeURIComponent(canvas)}/${manifest.id}/`;
2183
+ const site = local ? null : reviewSite();
2184
+ const published = site ? await publishSnapshot(site, dir, manifest, { title, password }) : null;
2185
+ if (published) commentSync.forget(canvas);
2186
+ return json(res, 200, { manifest, preview, published });
2187
+ }
1589
2188
  case "POST /api/source": {
1590
2189
  const { canvas, id } = await readJson(req);
1591
2190
  const hit = ws.nodeSource(canvas, id);
@@ -1596,23 +2195,32 @@ async function startServer(ws) {
1596
2195
  return json(res, 404, { error: `No route ${route}` });
1597
2196
  }
1598
2197
  function serveStatic(res, pathname) {
1599
- let file = path4.join(editorDir, pathname === "/" ? "index.html" : pathname);
1600
- if (!inside(editorDir, file) || !fs4.existsSync(file) || fs4.statSync(file).isDirectory()) file = path4.join(editorDir, "index.html");
1601
- if (!fs4.existsSync(file)) {
2198
+ let file = path7.join(editorDir, pathname === "/" ? "index.html" : pathname);
2199
+ if (!inside(editorDir, file) || !fs6.existsSync(file) || fs6.statSync(file).isDirectory()) file = path7.join(editorDir, "index.html");
2200
+ if (!fs6.existsSync(file)) {
1602
2201
  res.writeHead(500, { "content-type": "text/plain" });
1603
2202
  return res.end("Editor bundle missing. Run `pnpm build` in packages/truecanvas.");
1604
2203
  }
1605
- const ext = path4.extname(file);
2204
+ const ext = path7.extname(file);
1606
2205
  const immutable = pathname.startsWith("/assets/");
1607
2206
  res.writeHead(200, { "content-type": MIME[ext] ?? "application/octet-stream", "cache-control": immutable ? "public, max-age=31536000, immutable" : "no-cache" });
1608
- fs4.createReadStream(file).on("error", () => res.destroy()).pipe(res);
2207
+ fs6.createReadStream(file).on("error", () => res.destroy()).pipe(res);
2208
+ }
2209
+ function serveShare(res, rest) {
2210
+ const root = sharesDir(config.root);
2211
+ let file = path7.join(root, decodeURIComponent(rest));
2212
+ if (rest.endsWith("/")) file = path7.join(file, "index.html");
2213
+ if (!inside(root, file) || !fs6.existsSync(file) || fs6.statSync(file).isDirectory()) return json(res, 404, { error: "Not found" });
2214
+ res.writeHead(200, { "content-type": MIME[path7.extname(file)] ?? "application/octet-stream", "cache-control": "no-cache", "x-content-type-options": "nosniff", "access-control-allow-origin": "*" });
2215
+ fs6.createReadStream(file).on("error", () => res.destroy()).pipe(res);
1609
2216
  }
1610
2217
  const server = http.createServer(async (req, res) => {
1611
2218
  try {
1612
2219
  if (!allowedHosts.has(req.headers.host ?? "")) return json(res, 403, { error: "Forbidden host" });
2220
+ const url = new URL(req.url ?? "/", origin);
2221
+ if (req.method === "GET" && url.pathname.startsWith("/share/")) return serveShare(res, url.pathname.slice("/share/".length));
1613
2222
  const reqOrigin = req.headers.origin;
1614
2223
  if (reqOrigin && !allowedOrigins.has(reqOrigin)) return json(res, 403, { error: "Forbidden origin" });
1615
- const url = new URL(req.url ?? "/", origin);
1616
2224
  if (url.pathname === "/mcp") return await handleMcp(req, res);
1617
2225
  if (url.pathname.startsWith("/api/")) return await handleApi(req, res, url);
1618
2226
  if (req.method === "GET") return serveStatic(res, url.pathname);
@@ -1624,8 +2232,8 @@ async function startServer(ws) {
1624
2232
  else res.end();
1625
2233
  }
1626
2234
  });
1627
- const canvasDir = path4.join(config.root, config.canvasDir);
1628
- fs4.mkdirSync(canvasDir, { recursive: true });
2235
+ const canvasDir = path7.join(config.root, config.canvasDir);
2236
+ fs6.mkdirSync(canvasDir, { recursive: true });
1629
2237
  const pending = /* @__PURE__ */ new Map();
1630
2238
  const onCanvasFile = (filename) => {
1631
2239
  if (!filename) return;
@@ -1650,7 +2258,7 @@ async function startServer(ws) {
1650
2258
  const watchCanvasDir = () => {
1651
2259
  let ino = null;
1652
2260
  try {
1653
- ino = fs4.statSync(canvasDir).ino;
2261
+ ino = fs6.statSync(canvasDir).ino;
1654
2262
  } catch {
1655
2263
  ino = null;
1656
2264
  }
@@ -1659,21 +2267,21 @@ async function startServer(ws) {
1659
2267
  canvasWatcher = null;
1660
2268
  watchedIno = ino;
1661
2269
  if (ino === null) return;
1662
- canvasWatcher = fs4.watch(canvasDir, (_event, filename) => onCanvasFile(filename));
2270
+ canvasWatcher = fs6.watch(canvasDir, (_event, filename) => onCanvasFile(filename));
1663
2271
  canvasWatcher.on("error", () => {
1664
2272
  canvasWatcher?.close();
1665
2273
  canvasWatcher = null;
1666
2274
  });
1667
- for (const f of fs4.readdirSync(canvasDir)) onCanvasFile(f);
2275
+ for (const f of fs6.readdirSync(canvasDir)) onCanvasFile(f);
1668
2276
  };
1669
2277
  watchCanvasDir();
1670
2278
  setInterval(watchCanvasDir, 2e3).unref();
1671
- const appDirAbs = path4.join(config.root, config.appDir);
1672
- if (fs4.existsSync(appDirAbs)) {
2279
+ const appDirAbs = path7.join(config.root, config.appDir);
2280
+ if (config.framework === "next" && fs6.existsSync(appDirAbs)) {
1673
2281
  const appPending = /* @__PURE__ */ new Map();
1674
- const appWatcher = fs4.watch(appDirAbs, { recursive: true }, (_event, filename) => {
2282
+ const appWatcher = fs6.watch(appDirAbs, { recursive: true }, (_event, filename) => {
1675
2283
  if (!filename || !/(^|[\\/])(page|layout)\.[jt]sx$/.test(filename) || /^truecanvas[\\/]/.test(filename)) return;
1676
- const rel = path4.join(config.appDir, filename).split(path4.sep).join("/");
2284
+ const rel = path7.join(config.appDir, filename).split(path7.sep).join("/");
1677
2285
  clearTimeout(appPending.get(rel));
1678
2286
  appPending.set(
1679
2287
  rel,
@@ -1690,9 +2298,9 @@ async function startServer(ws) {
1690
2298
  }
1691
2299
  let catalogTimer;
1692
2300
  for (const dir of componentRoots(config.components)) {
1693
- const abs = path4.join(config.root, dir);
1694
- if (!fs4.existsSync(abs)) continue;
1695
- const componentWatcher = fs4.watch(abs, { recursive: true }, () => {
2301
+ const abs = path7.join(config.root, dir);
2302
+ if (!fs6.existsSync(abs)) continue;
2303
+ const componentWatcher = fs6.watch(abs, { recursive: true }, () => {
1696
2304
  clearTimeout(catalogTimer);
1697
2305
  catalogTimer = setTimeout(() => {
1698
2306
  ws.catalog.invalidate();
@@ -1707,6 +2315,7 @@ async function startServer(ws) {
1707
2315
  server.listen(config.port, "127.0.0.1", resolve);
1708
2316
  });
1709
2317
  const shutdown = async () => {
2318
+ commentSync.stop();
1710
2319
  await shots.close();
1711
2320
  for (const t of transports.values()) await t.close().catch(() => {
1712
2321
  });
@@ -1726,8 +2335,8 @@ function dedent(code, col) {
1726
2335
  return code.split("\n").map((l, i) => i > 0 && l.startsWith(pad) ? l.slice(col) : l).join("\n");
1727
2336
  }
1728
2337
  function inside(dir, file) {
1729
- const rel = path4.relative(dir, file);
1730
- return !!rel && !rel.startsWith("..") && !path4.isAbsolute(rel);
2338
+ const rel = path7.relative(dir, file);
2339
+ return !!rel && !rel.startsWith("..") && !path7.isAbsolute(rel);
1731
2340
  }
1732
2341
 
1733
2342
  // src/server/stdio.ts
@@ -1765,11 +2374,14 @@ async function runStdioBridge(mcpUrl) {
1765
2374
  await server.connect(new StdioServerTransport());
1766
2375
  }
1767
2376
 
2377
+ // src/cli/index.ts
2378
+ import { createRequire } from "module";
2379
+
1768
2380
  // src/hub/server.ts
1769
2381
  import http2 from "http";
1770
- import fs8 from "fs";
1771
- import os5 from "os";
1772
- import path8 from "path";
2382
+ import fs10 from "fs";
2383
+ import os6 from "os";
2384
+ import path11 from "path";
1773
2385
  import { randomUUID as randomUUID2 } from "crypto";
1774
2386
  import { fileURLToPath as fileURLToPath2 } from "url";
1775
2387
  import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
@@ -1779,42 +2391,42 @@ import { StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from
1779
2391
  import { CallToolRequestSchema as CallToolRequestSchema2, ListToolsRequestSchema as ListToolsRequestSchema2, isInitializeRequest as isInitializeRequest2 } from "@modelcontextprotocol/sdk/types.js";
1780
2392
 
1781
2393
  // src/hub/projects.ts
1782
- import fs5 from "fs";
1783
- import os2 from "os";
1784
- import path5 from "path";
1785
- var CONFIG_DIR = path5.join(process.env.XDG_CONFIG_HOME ?? path5.join(os2.homedir(), ".config"), "truecanvas");
1786
- var FILE = path5.join(CONFIG_DIR, "projects.json");
2394
+ import fs7 from "fs";
2395
+ import os3 from "os";
2396
+ import path8 from "path";
2397
+ var CONFIG_DIR = path8.join(process.env.XDG_CONFIG_HOME ?? path8.join(os3.homedir(), ".config"), "truecanvas");
2398
+ var FILE = path8.join(CONFIG_DIR, "projects.json");
1787
2399
  var ProjectStore = class {
1788
2400
  list = [];
1789
2401
  constructor() {
1790
2402
  try {
1791
- this.list = JSON.parse(fs5.readFileSync(FILE, "utf8"));
2403
+ this.list = JSON.parse(fs7.readFileSync(FILE, "utf8"));
1792
2404
  } catch {
1793
2405
  this.list = [];
1794
2406
  }
1795
2407
  }
1796
2408
  save() {
1797
- fs5.mkdirSync(CONFIG_DIR, { recursive: true });
1798
- fs5.writeFileSync(FILE, JSON.stringify(this.list, null, 2));
2409
+ fs7.mkdirSync(CONFIG_DIR, { recursive: true });
2410
+ fs7.writeFileSync(FILE, JSON.stringify(this.list, null, 2));
1799
2411
  }
1800
2412
  all() {
1801
2413
  return [...this.list].sort((a, b) => b.lastOpened - a.lastOpened).map((p) => ({ ...p, ...inspect(p.path) }));
1802
2414
  }
1803
2415
  touch(dir) {
1804
- const abs = path5.resolve(dir);
2416
+ const abs = path8.resolve(dir);
1805
2417
  const existing = this.list.find((p) => p.path === abs);
1806
2418
  if (existing) existing.lastOpened = Date.now();
1807
2419
  else this.list.push({ path: abs, name: projectName(abs), lastOpened: Date.now() });
1808
2420
  this.save();
1809
2421
  }
1810
2422
  forget(dir) {
1811
- this.list = this.list.filter((p) => p.path !== path5.resolve(dir));
2423
+ this.list = this.list.filter((p) => p.path !== path8.resolve(dir));
1812
2424
  this.save();
1813
2425
  }
1814
2426
  };
1815
2427
  function readPkg(dir) {
1816
2428
  try {
1817
- return JSON.parse(fs5.readFileSync(path5.join(dir, "package.json"), "utf8"));
2429
+ return JSON.parse(fs7.readFileSync(path8.join(dir, "package.json"), "utf8"));
1818
2430
  } catch {
1819
2431
  return null;
1820
2432
  }
@@ -1822,24 +2434,24 @@ function readPkg(dir) {
1822
2434
  function projectName(dir) {
1823
2435
  const pkg = readPkg(dir);
1824
2436
  const name = typeof pkg?.name === "string" ? pkg.name : "";
1825
- return name && !name.startsWith("@") ? name : path5.basename(dir);
2437
+ return name && !name.startsWith("@") ? name : path8.basename(dir);
1826
2438
  }
1827
2439
  function inspect(dir) {
1828
2440
  const pkg = readPkg(dir);
1829
- if (!pkg) return { next: false, ready: false, canvases: 0, exists: fs5.existsSync(dir) };
2441
+ if (!pkg) return { next: false, ready: false, canvases: 0, exists: fs7.existsSync(dir) };
1830
2442
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1831
2443
  let canvases = 0;
1832
2444
  for (const d of ["canvas", "src/canvas"]) {
1833
2445
  try {
1834
- canvases += fs5.readdirSync(path5.join(dir, d)).filter((f) => f.endsWith(".canvas.tsx")).length;
2446
+ canvases += fs7.readdirSync(path8.join(dir, d)).filter((f) => f.endsWith(".canvas.tsx")).length;
1835
2447
  } catch {
1836
2448
  }
1837
2449
  }
1838
- return { next: "next" in deps, ready: "truecanvas" in deps, canvases, exists: true };
2450
+ return { next: "next" in deps || "vite" in deps && "react" in deps, ready: "truecanvas" in deps, canvases, exists: true };
1839
2451
  }
1840
2452
  function discover() {
1841
- const home = os2.homedir();
1842
- const roots = ["Github", "GitHub", "github", "Projects", "projects", "code", "Code", "dev", "src", "Developer", "Documents", "work"].map((d) => path5.join(home, d)).filter((d) => fs5.existsSync(d));
2453
+ const home = os3.homedir();
2454
+ const roots = ["Github", "GitHub", "github", "Projects", "projects", "code", "Code", "dev", "src", "Developer", "Documents", "work"].map((d) => path8.join(home, d)).filter((d) => fs7.existsSync(d));
1843
2455
  const found = [];
1844
2456
  let visited = 0;
1845
2457
  const walk = (dir, depth) => {
@@ -1847,7 +2459,7 @@ function discover() {
1847
2459
  visited++;
1848
2460
  let entries;
1849
2461
  try {
1850
- entries = fs5.readdirSync(dir, { withFileTypes: true });
2462
+ entries = fs7.readdirSync(dir, { withFileTypes: true });
1851
2463
  } catch {
1852
2464
  return;
1853
2465
  }
@@ -1857,31 +2469,31 @@ function discover() {
1857
2469
  }
1858
2470
  for (const e of entries) {
1859
2471
  if (!e.isDirectory() || e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist" || e.name === "build") continue;
1860
- walk(path5.join(dir, e.name), depth + 1);
2472
+ walk(path8.join(dir, e.name), depth + 1);
1861
2473
  }
1862
2474
  };
1863
2475
  for (const r of roots) walk(r, 0);
1864
2476
  return found;
1865
2477
  }
1866
2478
  function listDirs(dir) {
1867
- const abs = path5.resolve(dir.replace(/^~(?=$|\/)/, os2.homedir()));
1868
- const dirs = fs5.readdirSync(abs, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => ({ name: e.name, next: inspect(path5.join(abs, e.name)).next })).sort((a, b) => Number(b.next) - Number(a.next) || a.name.localeCompare(b.name));
2479
+ const abs = path8.resolve(dir.replace(/^~(?=$|[\\/])/, os3.homedir()));
2480
+ const dirs = fs7.readdirSync(abs, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => ({ name: e.name, next: inspect(path8.join(abs, e.name)).next })).sort((a, b) => Number(b.next) - Number(a.next) || a.name.localeCompare(b.name));
1869
2481
  return { path: abs, dirs };
1870
2482
  }
1871
2483
 
1872
2484
  // src/hub/runner.ts
1873
2485
  import { execFile, spawn as spawn2 } from "child_process";
1874
- import fs7 from "fs";
2486
+ import fs9 from "fs";
1875
2487
  import net from "net";
1876
- import os4 from "os";
1877
- import path7 from "path";
2488
+ import os5 from "os";
2489
+ import path10 from "path";
1878
2490
 
1879
2491
  // src/cli/setup.ts
1880
2492
  import { spawn } from "child_process";
1881
2493
  import { createHash as createHash2 } from "crypto";
1882
- import fs6 from "fs";
1883
- import os3 from "os";
1884
- import path6 from "path";
2494
+ import fs8 from "fs";
2495
+ import os4 from "os";
2496
+ import path9 from "path";
1885
2497
  function addDevArgs(pm, spec) {
1886
2498
  return addArgs(pm, [spec], true);
1887
2499
  }
@@ -1889,65 +2501,66 @@ function runScript(pm, script) {
1889
2501
  return pm === "npm" ? `npm run ${script}` : `${pm} ${script}`;
1890
2502
  }
1891
2503
  var execInherit = (cmd, args, cwd) => new Promise((resolve, reject) => {
1892
- const p = spawn(cmd, args, { cwd, stdio: "inherit", env: toolEnv() });
2504
+ const safe = shellSafe(cmd, args);
2505
+ const p = spawn(safe.cmd, safe.args, { cwd, stdio: "inherit", env: toolEnv(), shell: safe.shell });
1893
2506
  p.on("error", reject);
1894
2507
  p.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`)));
1895
2508
  });
1896
2509
  function packageDir(cliFile) {
1897
- return path6.resolve(path6.dirname(cliFile), "..");
2510
+ return path9.resolve(path9.dirname(cliFile), "..");
1898
2511
  }
1899
2512
  function packageVersion(pkgDir) {
1900
2513
  try {
1901
- return JSON.parse(fs6.readFileSync(path6.join(pkgDir, "package.json"), "utf8")).version;
2514
+ return JSON.parse(fs8.readFileSync(path9.join(pkgDir, "package.json"), "utf8")).version;
1902
2515
  } catch {
1903
2516
  return "0.0.0";
1904
2517
  }
1905
2518
  }
1906
2519
  function isSourceCheckout(pkgDir) {
1907
- return !pkgDir.split(path6.sep).includes("node_modules") && fs6.existsSync(path6.join(pkgDir, "src", "cli"));
2520
+ return !pkgDir.split(path9.sep).includes("node_modules") && fs8.existsSync(path9.join(pkgDir, "src", "cli"));
1908
2521
  }
1909
2522
  async function packageSpec(pkgDir, exec) {
1910
2523
  if (process.env.TRUECANVAS_PACKAGE) return process.env.TRUECANVAS_PACKAGE;
1911
2524
  const version = packageVersion(pkgDir);
1912
2525
  if (!isSourceCheckout(pkgDir)) return `truecanvas@${version}`;
1913
- const cacheDir = path6.join(os3.homedir(), ".cache", "truecanvas");
1914
- fs6.mkdirSync(cacheDir, { recursive: true });
1915
- const file = path6.join(cacheDir, `truecanvas-${version}.tgz`);
2526
+ const cacheDir = path9.join(os4.homedir(), ".cache", "truecanvas");
2527
+ fs8.mkdirSync(cacheDir, { recursive: true });
2528
+ const file = path9.join(cacheDir, `truecanvas-${version}.tgz`);
1916
2529
  await exec("npm", ["pack", "--silent", "--pack-destination", cacheDir], pkgDir);
1917
- const hash = createHash2("sha256").update(fs6.readFileSync(file)).digest("hex").slice(0, 10);
1918
- const named = path6.join(cacheDir, `truecanvas-${version}-${hash}.tgz`);
1919
- fs6.renameSync(file, named);
2530
+ const hash = createHash2("sha256").update(fs8.readFileSync(file)).digest("hex").slice(0, 10);
2531
+ const named = path9.join(cacheDir, `truecanvas-${version}-${hash}.tgz`);
2532
+ fs8.renameSync(file, named);
1920
2533
  return named;
1921
2534
  }
1922
2535
  function installedIn(root) {
1923
2536
  let declared = false;
1924
2537
  try {
1925
- const pkg = JSON.parse(fs6.readFileSync(path6.join(root, "package.json"), "utf8"));
2538
+ const pkg = JSON.parse(fs8.readFileSync(path9.join(root, "package.json"), "utf8"));
1926
2539
  declared = "truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies };
1927
2540
  } catch {
1928
2541
  }
1929
- const file = path6.join(root, "node_modules", "truecanvas", "package.json");
1930
- const installed = fs6.existsSync(file);
1931
- return { declared, installed, version: installed ? packageVersion(path6.dirname(file)) : null };
2542
+ const file = path9.join(root, "node_modules", "truecanvas", "package.json");
2543
+ const installed = fs8.existsSync(file);
2544
+ return { declared, installed, version: installed ? packageVersion(path9.dirname(file)) : null };
1932
2545
  }
1933
2546
  function readPackage(root) {
1934
2547
  try {
1935
- return JSON.parse(fs6.readFileSync(path6.join(root, "package.json"), "utf8"));
2548
+ return JSON.parse(fs8.readFileSync(path9.join(root, "package.json"), "utf8"));
1936
2549
  } catch {
1937
2550
  return null;
1938
2551
  }
1939
2552
  }
1940
2553
  function depVersion(root, name) {
1941
- const file = path6.join(root, "node_modules", name, "package.json");
1942
- if (fs6.existsSync(file)) return packageVersion(path6.dirname(file));
2554
+ const file = path9.join(root, "node_modules", name, "package.json");
2555
+ if (fs8.existsSync(file)) return packageVersion(path9.dirname(file));
1943
2556
  const pkg = readPackage(root);
1944
2557
  return pkg ? { ...pkg.dependencies, ...pkg.devDependencies }[name] ?? null : null;
1945
2558
  }
1946
2559
  function mergeJson(file, update) {
1947
2560
  let data2 = {};
1948
- if (fs6.existsSync(file)) {
2561
+ if (fs8.existsSync(file)) {
1949
2562
  try {
1950
- data2 = JSON.parse(fs6.readFileSync(file, "utf8"));
2563
+ data2 = JSON.parse(fs8.readFileSync(file, "utf8"));
1951
2564
  } catch {
1952
2565
  throw new Error(`${file} isn't valid JSON; fix it or remove it, then run connect again.`);
1953
2566
  }
@@ -1955,24 +2568,24 @@ function mergeJson(file, update) {
1955
2568
  const before = JSON.stringify(data2);
1956
2569
  update(data2);
1957
2570
  if (JSON.stringify(data2) === before) return "unchanged";
1958
- fs6.mkdirSync(path6.dirname(file), { recursive: true });
1959
- fs6.writeFileSync(file, `${JSON.stringify(data2, null, 2)}
2571
+ fs8.mkdirSync(path9.dirname(file), { recursive: true });
2572
+ fs8.writeFileSync(file, `${JSON.stringify(data2, null, 2)}
1960
2573
  `);
1961
2574
  return "written";
1962
2575
  }
1963
2576
  function connectAgents(root, port, opts = {}) {
1964
2577
  const url = `http://localhost:${port}/mcp`;
1965
2578
  const out = { written: [], unchanged: [] };
1966
- const add = (rel, update) => out[mergeJson(path6.join(root, rel), update)].push(rel);
2579
+ const add = (rel, update) => out[mergeJson(path9.join(root, rel), update)].push(rel);
1967
2580
  const servers = (d, key) => d[key] ??= {};
1968
2581
  add(".mcp.json", (d) => void (servers(d, "mcpServers").truecanvas = { type: "http", url }));
1969
- if (opts.cursor || fs6.existsSync(path6.join(root, ".cursor"))) add(".cursor/mcp.json", (d) => void (servers(d, "mcpServers").truecanvas = { url }));
1970
- if (opts.vscode || fs6.existsSync(path6.join(root, ".vscode"))) add(".vscode/mcp.json", (d) => void (servers(d, "servers").truecanvas = { type: "http", url }));
2582
+ if (opts.cursor || fs8.existsSync(path9.join(root, ".cursor"))) add(".cursor/mcp.json", (d) => void (servers(d, "mcpServers").truecanvas = { url }));
2583
+ if (opts.vscode || fs8.existsSync(path9.join(root, ".vscode"))) add(".vscode/mcp.json", (d) => void (servers(d, "servers").truecanvas = { type: "http", url }));
1971
2584
  return out;
1972
2585
  }
1973
2586
  function hasAgentConfig(root, port) {
1974
2587
  try {
1975
- const d = JSON.parse(fs6.readFileSync(path6.join(root, ".mcp.json"), "utf8"));
2588
+ const d = JSON.parse(fs8.readFileSync(path9.join(root, ".mcp.json"), "utf8"));
1976
2589
  const url = d?.mcpServers?.truecanvas?.url;
1977
2590
  return typeof url === "string" && (port === void 0 || url.includes(`:${port}/`));
1978
2591
  } catch {
@@ -2025,7 +2638,7 @@ var Runner = class {
2025
2638
  return next;
2026
2639
  }
2027
2640
  async start(dir) {
2028
- if (!fs7.existsSync(path7.join(dir, "package.json"))) throw new Error(`${dir} isn't there anymore (or has no package.json).`);
2641
+ if (!fs9.existsSync(path10.join(dir, "package.json"))) throw new Error(`${dir} isn't there anymore (or has no package.json).`);
2029
2642
  const [editorPort, appPort] = await this.allocatePorts();
2030
2643
  const proc = spawn2(process.execPath, [this.cli, "dev", "--no-open", "--port", String(editorPort), "--app", `http://localhost:${appPort}`], {
2031
2644
  cwd: dir,
@@ -2120,7 +2733,8 @@ var Runner = class {
2120
2733
  };
2121
2734
  function killTree(proc, signal) {
2122
2735
  try {
2123
- if (process.platform !== "win32" && proc.pid) process.kill(-proc.pid, signal);
2736
+ if (process.platform === "win32" && proc.pid) spawn2("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { stdio: "ignore" }).on("error", () => proc.kill());
2737
+ else if (proc.pid) process.kill(-proc.pid, signal);
2124
2738
  else proc.kill(signal);
2125
2739
  } catch {
2126
2740
  proc.kill(signal);
@@ -2136,7 +2750,7 @@ async function install(job, cmd, args, cwd) {
2136
2750
  await run2(job, cmd, args, cwd);
2137
2751
  } catch (err) {
2138
2752
  const ignoredBuilds = job.log.some((l) => l.includes("ERR_PNPM_IGNORED_BUILDS"));
2139
- const installed = fs7.existsSync(path7.join(cwd, "node_modules", "truecanvas", "package.json"));
2753
+ const installed = fs9.existsSync(path10.join(cwd, "node_modules", "truecanvas", "package.json"));
2140
2754
  if (!(ignoredBuilds && installed)) throw err;
2141
2755
  job.log.push("(pnpm only complained about other packages' build scripts; Truecanvas is installed, continuing)");
2142
2756
  }
@@ -2144,9 +2758,10 @@ async function install(job, cmd, args, cwd) {
2144
2758
  function run2(job, cmd, args, cwd) {
2145
2759
  job.log.push(`$ ${cmd} ${args.join(" ")}`);
2146
2760
  return new Promise((resolve, reject) => {
2147
- const p = spawn2(cmd, args, { cwd, env: toolEnv({ CI: "1", FORCE_COLOR: "0" }), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
2761
+ const safe = shellSafe(cmd, args);
2762
+ const p = spawn2(safe.cmd, safe.args, { cwd, env: toolEnv({ CI: "1", FORCE_COLOR: "0" }), stdio: ["ignore", "pipe", "pipe"], shell: safe.shell });
2148
2763
  const capture2 = (c2) => {
2149
- for (const line of c2.toString().split("\n")) if (line.trim()) job.log.push(line.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, ""));
2764
+ for (const line of c2.toString().split(/\r?\n/)) if (line.trim()) job.log.push(line.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, ""));
2150
2765
  if (job.log.length > 300) job.log.splice(0, job.log.length - 300);
2151
2766
  };
2152
2767
  p.stdout.on("data", capture2);
@@ -2172,11 +2787,11 @@ function packageManager(dir) {
2172
2787
  return { cmd: pm, add: addArgs(pm, [], true) };
2173
2788
  }
2174
2789
  async function preferredPm() {
2175
- const { ok } = await capture("pnpm", ["--version"], os4.homedir());
2790
+ const { ok } = await capture("pnpm", ["--version"], os5.homedir());
2176
2791
  return ok ? "pnpm" : "npm";
2177
2792
  }
2178
2793
  function setupJob(dir, pkgDir, cli) {
2179
- return startJob(`Set up ${path7.basename(dir)}`, async (job) => {
2794
+ return startJob(`Set up ${path10.basename(dir)}`, async (job) => {
2180
2795
  const pm = packageManager(dir);
2181
2796
  const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
2182
2797
  await install(job, pm.cmd, [...pm.add, source], dir);
@@ -2186,12 +2801,12 @@ function setupJob(dir, pkgDir, cli) {
2186
2801
  }
2187
2802
  function createJob(name, parent, pkgDir, cli) {
2188
2803
  const safe = name.trim().toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/^-+|-+$/g, "");
2189
- const parentDir = path7.resolve(parent.replace(/^~(?=$|\/)/, os4.homedir()));
2190
- const dir = path7.join(parentDir, safe);
2804
+ const parentDir = path10.resolve(parent.replace(/^~(?=$|[\\/])/, os5.homedir()));
2805
+ const dir = path10.join(parentDir, safe);
2191
2806
  return startJob(`Create ${safe}`, async (job) => {
2192
2807
  if (!safe) throw new Error("Pick a project name.");
2193
- if (fs7.existsSync(dir)) throw new Error(`${dir} already exists.`);
2194
- fs7.mkdirSync(parentDir, { recursive: true });
2808
+ if (fs9.existsSync(dir)) throw new Error(`${dir} already exists.`);
2809
+ fs9.mkdirSync(parentDir, { recursive: true });
2195
2810
  const pm = await preferredPm();
2196
2811
  await run2(job, "npx", ["--yes", "create-next-app@latest", safe, "--ts", "--tailwind", "--app", "--eslint", "--no-src-dir", "--import-alias", "@/*", `--use-${pm}`, "--turbopack", "--yes"], parentDir);
2197
2812
  const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
@@ -2202,31 +2817,32 @@ function createJob(name, parent, pkgDir, cli) {
2202
2817
  }
2203
2818
  function capture(cmd, args, cwd) {
2204
2819
  return new Promise((resolve) => {
2820
+ const safe = shellSafe(cmd, args);
2205
2821
  execFile(
2206
- cmd,
2207
- args,
2208
- { cwd, timeout: 6e4, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" } },
2822
+ safe.cmd,
2823
+ safe.args,
2824
+ { shell: safe.shell, cwd, timeout: 6e4, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" } },
2209
2825
  (err, stdout, stderr) => resolve({ ok: !err, out: err ? (stderr || err.message).trim() : stdout })
2210
2826
  );
2211
2827
  });
2212
2828
  }
2213
2829
  function cloneJob(repo, parent, pkgDir, cli) {
2214
- const parentDir = path7.resolve(parent.replace(/^~(?=$|\/)/, os4.homedir()));
2830
+ const parentDir = path10.resolve(parent.replace(/^~(?=$|[\\/])/, os5.homedir()));
2215
2831
  const name = repo.replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
2216
- const dir = path7.join(parentDir, name);
2832
+ const dir = path10.join(parentDir, name);
2217
2833
  return startJob(`Clone ${name}`, async (job) => {
2218
2834
  if (!name || !/^[\w.-]+$/.test(name)) throw new Error("Pick a repository (owner/name or a git URL).");
2219
- if (fs7.existsSync(dir)) throw new Error(`${dir} already exists. Open it with Open folder instead.`);
2220
- fs7.mkdirSync(parentDir, { recursive: true });
2835
+ if (fs9.existsSync(dir)) throw new Error(`${dir} already exists. Open it with Open folder instead.`);
2836
+ fs9.mkdirSync(parentDir, { recursive: true });
2221
2837
  const url = /^(https?:|git@|ssh:|file:)/.test(repo);
2222
2838
  if (url) await run2(job, "git", ["clone", repo, dir], parentDir);
2223
2839
  else await run2(job, "gh", ["repo", "clone", repo, dir], parentDir);
2224
- if (!fs7.existsSync(path7.join(dir, "package.json"))) throw new Error("Cloned, but there's no package.json at the root: Truecanvas needs a Next.js app.");
2840
+ if (!fs9.existsSync(path10.join(dir, "package.json"))) throw new Error("Cloned, but there's no package.json at the root: Truecanvas needs a Next.js or Vite app.");
2225
2841
  const pm = packageManager(dir);
2226
2842
  await install(job, pm.cmd, ["install"], dir).catch(async (err) => {
2227
- if (!fs7.existsSync(path7.join(dir, "node_modules"))) throw err;
2843
+ if (!fs9.existsSync(path10.join(dir, "node_modules"))) throw err;
2228
2844
  });
2229
- const pkg = JSON.parse(fs7.readFileSync(path7.join(dir, "package.json"), "utf8"));
2845
+ const pkg = JSON.parse(fs9.readFileSync(path10.join(dir, "package.json"), "utf8"));
2230
2846
  if (!("truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies })) {
2231
2847
  const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
2232
2848
  await install(job, pm.cmd, [...pm.add, source], dir);
@@ -2245,13 +2861,13 @@ var MIME2 = {
2245
2861
  ".woff2": "font/woff2",
2246
2862
  ".png": "image/png"
2247
2863
  };
2248
- var HUB_INSTRUCTIONS = `Truecanvas hub: several projects, each a Next.js app with canvases of real React components.
2864
+ var HUB_INSTRUCTIONS = `Truecanvas hub: several projects, each a Next.js or Vite + React app with canvases of real React components.
2249
2865
  Your tools act on the current project (the active tab in the Truecanvas window, unless you pick one with open_project).
2250
2866
  Use list_projects to see them. All other tools are the regular Truecanvas canvas tools for that project.`;
2251
2867
  async function startHub(opts) {
2252
2868
  const store = new ProjectStore();
2253
2869
  const editorDir = fileURLToPath2(new URL("./editor/", import.meta.url));
2254
- const pkgDir = path8.resolve(path8.dirname(opts.cli), "..");
2870
+ const pkgDir = path11.resolve(path11.dirname(opts.cli), "..");
2255
2871
  const origin = `http://localhost:${opts.port}`;
2256
2872
  const allowedHosts = /* @__PURE__ */ new Set([`localhost:${opts.port}`, `127.0.0.1:${opts.port}`]);
2257
2873
  const clients = /* @__PURE__ */ new Set();
@@ -2356,7 +2972,7 @@ async function startHub(opts) {
2356
2972
  }
2357
2973
  if (req.params.name === "open_project") {
2358
2974
  const q = String(req.params.arguments?.project ?? "").toLowerCase();
2359
- const p = store.all().find((x) => x.name.toLowerCase() === q || x.path.toLowerCase() === q || path8.basename(x.path).toLowerCase() === q);
2975
+ const p = store.all().find((x) => x.name.toLowerCase() === q || x.path.toLowerCase() === q || path11.basename(x.path).toLowerCase() === q);
2360
2976
  if (!p) return text(`No project "${q}". Use list_projects.`, true);
2361
2977
  if (!p.ready) return text(`${p.name} isn't set up for Truecanvas yet. Ask the user to click "Set up" on the Dashboard.`, true);
2362
2978
  store.touch(p.path);
@@ -2411,8 +3027,8 @@ async function startHub(opts) {
2411
3027
  case "GET /api/hub/icon": {
2412
3028
  const icon = findIcon(url.searchParams.get("path") ?? "");
2413
3029
  if (!icon) return json2(res, 404, { error: "No icon" });
2414
- res.writeHead(200, { "content-type": ICON_MIME[path8.extname(icon)] ?? "application/octet-stream", "cache-control": "private, max-age=300" });
2415
- return fs8.createReadStream(icon).on("error", () => res.destroy()).pipe(res);
3030
+ res.writeHead(200, { "content-type": ICON_MIME[path11.extname(icon)] ?? "application/octet-stream", "cache-control": "private, max-age=300" });
3031
+ return fs10.createReadStream(icon).on("error", () => res.destroy()).pipe(res);
2416
3032
  }
2417
3033
  case "GET /api/hub/ls":
2418
3034
  return json2(res, 200, listDirs(url.searchParams.get("path") || "~"));
@@ -2423,8 +3039,8 @@ async function startHub(opts) {
2423
3039
  req.on("close", () => clients.delete(res));
2424
3040
  return;
2425
3041
  case "POST /api/hub/add": {
2426
- const dir = path8.resolve(body.path.replace(/^~(?=$|\/)/, process.env.HOME ?? "~"));
2427
- if (!fs8.existsSync(path8.join(dir, "package.json"))) return json2(res, 400, { error: "No package.json in that folder. Pick the root of a Next.js app." });
3042
+ const dir = path11.resolve(body.path.replace(/^~(?=$|[\\/])/, os6.homedir()));
3043
+ if (!fs10.existsSync(path11.join(dir, "package.json"))) return json2(res, 400, { error: "No package.json in that folder. Pick the root of a Next.js or Vite app." });
2428
3044
  store.touch(dir);
2429
3045
  broadcast();
2430
3046
  return json2(res, 200, { path: dir, ready: store.all().find((p) => p.path === dir)?.ready ?? false });
@@ -2463,7 +3079,7 @@ async function startHub(opts) {
2463
3079
  return json2(res, 200, createJob(body.name, body.parent, pkgDir, opts.cli));
2464
3080
  // ---------- GitHub ----------
2465
3081
  case "GET /api/hub/github/repos": {
2466
- const r = await capture("gh", ["repo", "list", "--limit", "100", "--json", "nameWithOwner,description,updatedAt,isPrivate"], os5.homedir());
3082
+ const r = await capture("gh", ["repo", "list", "--limit", "100", "--json", "nameWithOwner,description,updatedAt,isPrivate"], os6.homedir());
2467
3083
  if (!r.ok) return json2(res, 200, { gh: false, error: r.out.split("\n")[0], repos: [] });
2468
3084
  const repos = JSON.parse(r.out).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
2469
3085
  return json2(res, 200, { gh: true, repos });
@@ -2508,14 +3124,14 @@ async function startHub(opts) {
2508
3124
  return json2(res, 404, { error: `No route ${route}` });
2509
3125
  }
2510
3126
  function serveStatic(res, pathname) {
2511
- let file = path8.join(editorDir, pathname === "/" ? "hub.html" : pathname);
2512
- if (!inside2(editorDir, file) || !fs8.existsSync(file) || fs8.statSync(file).isDirectory()) file = path8.join(editorDir, "hub.html");
2513
- if (!fs8.existsSync(file)) {
3127
+ let file = path11.join(editorDir, pathname === "/" ? "hub.html" : pathname);
3128
+ if (!inside2(editorDir, file) || !fs10.existsSync(file) || fs10.statSync(file).isDirectory()) file = path11.join(editorDir, "hub.html");
3129
+ if (!fs10.existsSync(file)) {
2514
3130
  res.writeHead(500, { "content-type": "text/plain" });
2515
3131
  return res.end("Hub bundle missing. Run `pnpm build`.");
2516
3132
  }
2517
- res.writeHead(200, { "content-type": MIME2[path8.extname(file)] ?? "application/octet-stream", "cache-control": pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache" });
2518
- fs8.createReadStream(file).on("error", () => res.destroy()).pipe(res);
3133
+ res.writeHead(200, { "content-type": MIME2[path11.extname(file)] ?? "application/octet-stream", "cache-control": pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache" });
3134
+ fs10.createReadStream(file).on("error", () => res.destroy()).pipe(res);
2519
3135
  }
2520
3136
  const server = http2.createServer(async (req, res) => {
2521
3137
  try {
@@ -2547,27 +3163,27 @@ async function startHub(opts) {
2547
3163
  }
2548
3164
  var ICON_MIME = { ".ico": "image/x-icon", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
2549
3165
  function findIcon(dir) {
2550
- if (!dir || !fs8.existsSync(path8.join(dir, "package.json"))) return null;
3166
+ if (!dir || !fs10.existsSync(path11.join(dir, "package.json"))) return null;
2551
3167
  const names = ["icon.svg", "icon.png", "favicon.svg", "favicon.ico", "icon.ico", "apple-icon.png", "favicon.png", "logo.svg", "icon.jpg"];
2552
3168
  const roots = ["app", "src/app", "public", "static", "assets"];
2553
3169
  for (const root of roots) {
2554
3170
  for (const name of names) {
2555
- const file = path8.join(dir, root, name);
2556
- if (fs8.existsSync(file) && fs8.statSync(file).size < 2e6) return file;
3171
+ const file = path11.join(dir, root, name);
3172
+ if (fs10.existsSync(file) && fs10.statSync(file).size < 2e6) return file;
2557
3173
  }
2558
3174
  }
2559
3175
  for (const root of ["app", "src/app"]) {
2560
3176
  let entries = [];
2561
3177
  try {
2562
- entries = fs8.readdirSync(path8.join(dir, root), { withFileTypes: true });
3178
+ entries = fs10.readdirSync(path11.join(dir, root), { withFileTypes: true });
2563
3179
  } catch {
2564
3180
  continue;
2565
3181
  }
2566
3182
  for (const e of entries) {
2567
3183
  if (!e.isDirectory()) continue;
2568
3184
  for (const name of names.slice(0, 4)) {
2569
- const file = path8.join(dir, root, e.name, name);
2570
- if (fs8.existsSync(file)) return file;
3185
+ const file = path11.join(dir, root, e.name, name);
3186
+ if (fs10.existsSync(file)) return file;
2571
3187
  }
2572
3188
  }
2573
3189
  }
@@ -2578,14 +3194,14 @@ function json2(res, status, body) {
2578
3194
  res.end(JSON.stringify(body));
2579
3195
  }
2580
3196
  function inside2(dir, file) {
2581
- const rel = path8.relative(dir, file);
2582
- return !!rel && !rel.startsWith("..") && !path8.isAbsolute(rel);
3197
+ const rel = path11.relative(dir, file);
3198
+ return !!rel && !rel.startsWith("..") && !path11.isAbsolute(rel);
2583
3199
  }
2584
3200
 
2585
3201
  // src/hub/desktop.ts
2586
- import fs9 from "fs";
2587
- import os6 from "os";
2588
- import path9 from "path";
3202
+ import fs11 from "fs";
3203
+ import os7 from "os";
3204
+ import path12 from "path";
2589
3205
  var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
2590
3206
  <defs>
2591
3207
  <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff9466"/><stop offset="1" stop-color="#ea6a3c"/></linearGradient>
@@ -2599,15 +3215,15 @@ var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
2599
3215
  <path fill="#f6f5f1" fill-rule="evenodd" d="M236 236h552v168H236z M428 316h168v472H428z"/>
2600
3216
  </svg>
2601
3217
  `;
2602
- var data = () => process.env.XDG_DATA_HOME ?? path9.join(os6.homedir(), ".local", "share");
3218
+ var data = () => process.env.XDG_DATA_HOME ?? path12.join(os7.homedir(), ".local", "share");
2603
3219
  function installDesktop(cli, port) {
2604
- const dir = path9.join(data(), "truecanvas");
2605
- fs9.mkdirSync(dir, { recursive: true });
2606
- const icon = path9.join(dir, "icon.svg");
2607
- fs9.writeFileSync(icon, ICON);
2608
- const launcher = path9.join(dir, "launch.sh");
3220
+ const dir = path12.join(data(), "truecanvas");
3221
+ fs11.mkdirSync(dir, { recursive: true });
3222
+ const icon = path12.join(dir, "icon.svg");
3223
+ fs11.writeFileSync(icon, ICON);
3224
+ const launcher = path12.join(dir, "launch.sh");
2609
3225
  const url = `http://localhost:${port}`;
2610
- fs9.writeFileSync(
3226
+ fs11.writeFileSync(
2611
3227
  launcher,
2612
3228
  `#!/bin/sh
2613
3229
  # Truecanvas launcher: start the hub if needed, then open its window.
@@ -2616,7 +3232,7 @@ CLI=${JSON.stringify(cli)}
2616
3232
  URL=${JSON.stringify(url)}
2617
3233
  LOG="\${XDG_STATE_HOME:-$HOME/.local/state}/truecanvas-hub.log"
2618
3234
  # apps started from the desktop don't get your shell's PATH: next, pnpm and npx need it
2619
- export PATH=${JSON.stringify([path9.dirname(process.execPath), ...(process.env.PATH ?? "").split(path9.delimiter)].filter((p, i, a) => p && a.indexOf(p) === i).join(path9.delimiter))}
3235
+ export PATH=${JSON.stringify([path12.dirname(process.execPath), ...(process.env.PATH ?? "").split(path12.delimiter)].filter((p, i, a) => p && a.indexOf(p) === i).join(path12.delimiter))}
2620
3236
  mkdir -p "$(dirname "$LOG")"
2621
3237
 
2622
3238
  if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
@@ -2625,7 +3241,7 @@ if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
2625
3241
  while [ $i -lt 60 ] && ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; do sleep 0.25; i=$((i+1)); done
2626
3242
  fi
2627
3243
 
2628
- PROFILE=${JSON.stringify(path9.join(dir, "window"))}
3244
+ PROFILE=${JSON.stringify(path12.join(dir, "window"))}
2629
3245
  for b in google-chrome google-chrome-stable chromium-browser chromium brave-browser microsoft-edge; do
2630
3246
  if command -v "$b" >/dev/null 2>&1; then
2631
3247
  exec "$b" --app="$URL" --class=Truecanvas --user-data-dir="$PROFILE" --no-first-run --no-default-browser-check
@@ -2635,10 +3251,10 @@ exec xdg-open "$URL"
2635
3251
  `,
2636
3252
  { mode: 493 }
2637
3253
  );
2638
- const apps = path9.join(data(), "applications");
2639
- fs9.mkdirSync(apps, { recursive: true });
2640
- const desktop = path9.join(apps, "truecanvas.desktop");
2641
- fs9.writeFileSync(
3254
+ const apps = path12.join(data(), "applications");
3255
+ fs11.mkdirSync(apps, { recursive: true });
3256
+ const desktop = path12.join(apps, "truecanvas.desktop");
3257
+ fs11.writeFileSync(
2642
3258
  desktop,
2643
3259
  `[Desktop Entry]
2644
3260
  Type=Application
@@ -2654,11 +3270,11 @@ StartupWMClass=Truecanvas
2654
3270
  StartupNotify=true
2655
3271
  `
2656
3272
  );
2657
- let command = path9.join(os6.homedir(), ".local", "bin", "truecanvas");
2658
- const ours = !fs9.existsSync(command) || fs9.readFileSync(command, "utf8").includes("# truecanvas wrapper");
3273
+ let command = path12.join(os7.homedir(), ".local", "bin", "truecanvas");
3274
+ const ours = !fs11.existsSync(command) || fs11.readFileSync(command, "utf8").includes("# truecanvas wrapper");
2659
3275
  if (ours) {
2660
- fs9.mkdirSync(path9.dirname(command), { recursive: true });
2661
- fs9.writeFileSync(command, `#!/bin/sh
3276
+ fs11.mkdirSync(path12.dirname(command), { recursive: true });
3277
+ fs11.writeFileSync(command, `#!/bin/sh
2662
3278
  # truecanvas wrapper (installed by \`truecanvas desktop\`)
2663
3279
  exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
2664
3280
  `, { mode: 493 });
@@ -2666,19 +3282,19 @@ exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
2666
3282
  return { desktop, launcher, icon, command };
2667
3283
  }
2668
3284
  function launcherPath() {
2669
- const p = path9.join(data(), "truecanvas", "launch.sh");
2670
- return fs9.existsSync(p) ? p : null;
3285
+ const p = path12.join(data(), "truecanvas", "launch.sh");
3286
+ return fs11.existsSync(p) ? p : null;
2671
3287
  }
2672
3288
  function uninstallDesktop() {
2673
3289
  const removed = [];
2674
- const cmd = path9.join(os6.homedir(), ".local", "bin", "truecanvas");
2675
- if (fs9.existsSync(cmd) && fs9.readFileSync(cmd, "utf8").includes("# truecanvas wrapper")) {
2676
- fs9.rmSync(cmd);
3290
+ const cmd = path12.join(os7.homedir(), ".local", "bin", "truecanvas");
3291
+ if (fs11.existsSync(cmd) && fs11.readFileSync(cmd, "utf8").includes("# truecanvas wrapper")) {
3292
+ fs11.rmSync(cmd);
2677
3293
  removed.push(cmd);
2678
3294
  }
2679
- for (const f of [path9.join(data(), "applications", "truecanvas.desktop"), path9.join(data(), "truecanvas")]) {
2680
- if (fs9.existsSync(f)) {
2681
- fs9.rmSync(f, { recursive: true, force: true });
3295
+ for (const f of [path12.join(data(), "applications", "truecanvas.desktop"), path12.join(data(), "truecanvas")]) {
3296
+ if (fs11.existsSync(f)) {
3297
+ fs11.rmSync(f, { recursive: true, force: true });
2682
3298
  removed.push(f);
2683
3299
  }
2684
3300
  }
@@ -2691,8 +3307,8 @@ import { createInterface } from "readline/promises";
2691
3307
 
2692
3308
  // src/cli/doctor.ts
2693
3309
  import { execFile as execFile2 } from "child_process";
2694
- import fs10 from "fs";
2695
- import path10 from "path";
3310
+ import fs12 from "fs";
3311
+ import path13 from "path";
2696
3312
  var major = (v) => v ? Number(/(\d+)(?:\.(\d+))?/.exec(v)?.[1] ?? 0) : 0;
2697
3313
  function has(cmd, args, cwd) {
2698
3314
  return new Promise((resolve) => execFile2(cmd, args, { cwd, timeout: 8e3 }, (err, stdout, stderr) => resolve(err ? null : `${stdout}${stderr}`)));
@@ -2701,38 +3317,42 @@ async function doctor(root, cliVersion) {
2701
3317
  const checks = [];
2702
3318
  const add = (c2) => checks.push(c2);
2703
3319
  const node = process.versions.node;
2704
- add(major(node) >= 20 ? { label: "Node.js", status: "ok", detail: node } : { label: "Node.js", status: "fail", detail: node, fix: "Install Node.js 20 or newer (https://nodejs.org)" });
3320
+ add(major(node) >= 22 ? { label: "Node.js", status: "ok", detail: node } : { label: "Node.js", status: "fail", detail: node, fix: "Install Node.js 22 or newer (24 LTS recommended: https://nodejs.org)" });
2705
3321
  const pkg = readPackage(root);
2706
3322
  if (!pkg) {
2707
- add({ label: "Project", status: "fail", detail: "no package.json here", fix: "Run truecanvas from the root of your Next.js app, or create one: npm create truecanvas" });
3323
+ add({ label: "Project", status: "fail", detail: "no package.json here", fix: "Run truecanvas from the root of your Next.js or Vite app, or create one: npm create truecanvas" });
2708
3324
  return checks;
2709
3325
  }
2710
3326
  const pm = detectPm(root);
2711
3327
  const next = depVersion(root, "next");
2712
- if (!next) add({ label: "Next.js", status: "fail", detail: "not a dependency", fix: "Truecanvas works with Next.js (App Router) apps for now. Create one: npm create truecanvas" });
3328
+ const vite = !next ? depVersion(root, "vite") : null;
3329
+ if (vite) add(major(vite) >= 5 ? { label: "Vite", status: "ok", detail: vite } : { label: "Vite", status: "fail", detail: vite, fix: `Upgrade to Vite 5 or newer: ${pm} ${pm === "npm" ? "install -D" : "add -D"} vite@latest` });
3330
+ else if (!next) add({ label: "Next.js or Vite", status: "fail", detail: "neither is a dependency", fix: "Truecanvas works with Next.js (App Router) and Vite + React apps. Create one: npm create truecanvas" });
2713
3331
  else if (major(next) < 15) add({ label: "Next.js", status: "fail", detail: next, fix: `Upgrade to Next.js 15 or newer: ${pm} ${pm === "npm" ? "install" : "add"} next@latest` });
2714
3332
  else add({ label: "Next.js", status: "ok", detail: next });
2715
3333
  const react = depVersion(root, "react");
2716
- add(react && major(react) >= 19 ? { label: "React", status: "ok", detail: react } : { label: "React", status: "fail", detail: react ?? "missing", fix: "Truecanvas needs React 19 (comes with Next.js 15+)" });
3334
+ add(react && major(react) >= 19 ? { label: "React", status: "ok", detail: react } : { label: "React", status: "fail", detail: react ?? "missing", fix: `Truecanvas needs React 19: ${pm} ${pm === "npm" ? "install" : "add"} react@latest react-dom@latest` });
2717
3335
  const tw = depVersion(root, "tailwindcss");
2718
3336
  add(tw ? { label: "Tailwind CSS", status: "ok", detail: tw } : { label: "Tailwind CSS", status: "warn", detail: "not installed", fix: "Optional, but the layout and style controls write Tailwind classes" });
2719
3337
  const config = loadConfig(root);
2720
- add(fs10.existsSync(path10.join(root, config.appDir)) ? { label: "App Router", status: "ok", detail: config.appDir } : { label: "App Router", status: "fail", detail: `no ${config.appDir}/`, fix: "Truecanvas renders through the App Router (app/ or src/app/)" });
3338
+ if (config.framework === "next") add(fs12.existsSync(path13.join(root, config.appDir)) ? { label: "App Router", status: "ok", detail: config.appDir } : { label: "App Router", status: "fail", detail: `no ${config.appDir}/`, fix: "Truecanvas renders through the App Router (app/ or src/app/)" });
2721
3339
  const tc = installedIn(root);
2722
3340
  if (!tc.declared) add({ label: "Truecanvas in the project", status: "fail", detail: "not a dependency", fix: "npx truecanvas (sets it up)" });
2723
3341
  else if (!tc.installed) add({ label: "Truecanvas in the project", status: "fail", detail: "declared but not installed", fix: `${pm} install` });
2724
3342
  else if (tc.version !== cliVersion) add({ label: "Truecanvas in the project", status: "warn", detail: `${tc.version} (this CLI is ${cliVersion})`, fix: `Use the project's version: ${runScript(pm, "canvas")}, or update: ${pm} ${pm === "npm" ? "install -D" : "add -D"} truecanvas@latest` });
2725
3343
  else add({ label: "Truecanvas in the project", status: "ok", detail: tc.version ?? "" });
2726
- const wrapped = nextConfigStatus(root);
2727
- add(wrapped.wrapped ? { label: "next.config", status: "ok", detail: `${path10.basename(wrapped.file)} uses withTruecanvas()` } : { label: "next.config", status: "fail", detail: wrapped.file ? "not wrapped" : "missing", fix: "npx truecanvas init" });
3344
+ const wrapped = configStatus(root, config.framework);
3345
+ const configLabel = config.framework === "vite" ? "vite.config" : "next.config";
3346
+ const uses = config.framework === "vite" ? "has the truecanvas() plugin" : "uses withTruecanvas()";
3347
+ add(wrapped.wrapped ? { label: configLabel, status: "ok", detail: `${path13.basename(wrapped.file)} ${uses}` } : { label: configLabel, status: "fail", detail: wrapped.file ? "Truecanvas not enabled" : "missing", fix: "npx truecanvas init" });
2728
3348
  const scripts = pkg.scripts ?? {};
2729
3349
  const script = Object.entries(scripts).find(([, v]) => v.includes("truecanvas"));
2730
3350
  add(script ? { label: "Start script", status: "ok", detail: runScript(pm, script[0]) } : { label: "Start script", status: "warn", detail: "none", fix: 'npx truecanvas init (adds "canvas": "truecanvas dev")' });
2731
- const ignore = fs10.existsSync(path10.join(root, ".gitignore")) ? fs10.readFileSync(path10.join(root, ".gitignore"), "utf8") : "";
2732
- add(ignore.includes(`${config.appDir}/truecanvas`) ? { label: "Generated route ignored", status: "ok" } : { label: "Generated route ignored", status: "warn", detail: `${config.appDir}/truecanvas/ isn't in .gitignore`, fix: "npx truecanvas init" });
3351
+ const ignore = fs12.existsSync(path13.join(root, ".gitignore")) ? fs12.readFileSync(path13.join(root, ".gitignore"), "utf8") : "";
3352
+ add(ignore.includes(config.routeDir) ? { label: "Generated route ignored", status: "ok" } : { label: "Generated route ignored", status: "warn", detail: `${config.routeDir}/ isn't in .gitignore`, fix: "npx truecanvas init" });
2733
3353
  add(hasAgentConfig(root) ? { label: "Agents", status: "ok", detail: ".mcp.json lists Truecanvas" } : { label: "Agents", status: "warn", detail: "not connected for this project", fix: "npx truecanvas connect (then commit .mcp.json)" });
2734
3354
  const chrome = findChromium();
2735
- add(chrome ? { label: "Screenshots", status: "ok", detail: path10.basename(chrome) } : { label: "Screenshots", status: "warn", detail: "no Chromium found", fix: "npx playwright install chromium-headless-shell (or install Chrome)" });
3355
+ add(chrome ? { label: "Screenshots", status: "ok", detail: path13.basename(chrome) } : { label: "Screenshots", status: "warn", detail: "no Chromium found", fix: "npx playwright install chromium-headless-shell (or install Chrome)" });
2736
3356
  const git = await has("git", ["rev-parse", "--is-inside-work-tree"], root);
2737
3357
  add(git ? { label: "Git", status: "ok" } : { label: "Git", status: "warn", detail: "not a repository", fix: "git init (branches, history, comments and compare use git)" });
2738
3358
  const gh = await has("gh", ["auth", "status"], root);
@@ -2746,24 +3366,27 @@ async function doctor(root, cliVersion) {
2746
3366
  var HELP = `truecanvas: design with your real React components
2747
3367
 
2748
3368
  Usage
2749
- truecanvas In a Next.js app: set it up (asks once) and start the editor.
3369
+ truecanvas In a Next.js or Vite app: set it up (asks once) and start the editor.
2750
3370
  Elsewhere: open the Truecanvas window.
2751
3371
  truecanvas connect Add Truecanvas to this project's .mcp.json (Claude Code, Cursor,
2752
3372
  VS Code): commit it and every teammate's agent connects
2753
3373
  truecanvas doctor Check this project and machine, with a fix for each problem
2754
3374
  truecanvas dev Start the editor + MCP server for this project (no window)
2755
3375
  truecanvas setup Install and set up Truecanvas in this project, without starting
2756
- truecanvas init Only write the config (next.config, script, canvas folder)
3376
+ truecanvas init Only write the config (next/vite config, script, canvas folder)
2757
3377
  truecanvas open Open the Truecanvas window (projects dashboard + tabs)
2758
3378
  truecanvas quit Quit the window and every app it started
2759
3379
  truecanvas desktop Add Truecanvas to your app launcher (Linux); "desktop remove" undoes it
3380
+ truecanvas share [canvas] Share a canvas as a link for clients (needs truecanvas running).
3381
+ --password <p>, --no-password, --revoke, --restore, --delete, --title <t>, --local (preview only)
3382
+ truecanvas share setup Connect your studio's review site (URL + REVIEW_TOKEN)
2760
3383
  truecanvas mcp stdio MCP server for agents without HTTP support
2761
3384
 
2762
3385
  Options
2763
3386
  -y, --yes Don't ask: set up and connect agents
2764
3387
  --port <n> Editor/MCP port (default 4800)
2765
- --app <url> Your Next.js dev URL (default http://localhost:3000)
2766
- --no-next Don't start \`next dev\` (by default it starts when the app isn't running)
3388
+ --app <url> Your app's dev URL (default http://localhost:3000, Vite :5173)
3389
+ --no-next Don't start the app's dev server (by default it starts when the app isn't running)
2767
3390
  --no-open Don't open the editor in your browser
2768
3391
  --cursor, --vscode connect: also write .cursor/mcp.json / .vscode/mcp.json
2769
3392
  -v, --version Print the version
@@ -2792,7 +3415,15 @@ async function main() {
2792
3415
  yes: { type: "boolean", short: "y" },
2793
3416
  cursor: { type: "boolean" },
2794
3417
  vscode: { type: "boolean" },
2795
- version: { type: "boolean", short: "v" }
3418
+ version: { type: "boolean", short: "v" },
3419
+ local: { type: "boolean" },
3420
+ title: { type: "string" },
3421
+ password: { type: "string" },
3422
+ "no-password": { type: "boolean" },
3423
+ revoke: { type: "boolean" },
3424
+ restore: { type: "boolean" },
3425
+ delete: { type: "boolean" },
3426
+ token: { type: "string" }
2796
3427
  }
2797
3428
  });
2798
3429
  const cli = fileURLToPath3(import.meta.url);
@@ -2807,13 +3438,14 @@ async function main() {
2807
3438
  const yes = !!values.yes;
2808
3439
  if (command === "start") {
2809
3440
  const root2 = process.cwd();
2810
- if (!readPackage(root2) || !("next" in { ...readPackage(root2).dependencies, ...readPackage(root2).devDependencies })) {
3441
+ const deps = { ...readPackage(root2)?.dependencies, ...readPackage(root2)?.devDependencies };
3442
+ if (!("next" in deps) && !("vite" in deps && "react" in deps)) {
2811
3443
  return openWindow(cli, values.port ? Number(values.port) : 4800);
2812
3444
  }
2813
3445
  if (!await ensureSetup(root2, pkgDir, version, yes)) return;
2814
3446
  if (await openInWindow(root2, Number(values.port ?? 4800))) return;
2815
- const local = path11.join(root2, "node_modules", "truecanvas", "dist", "cli.js");
2816
- if (fs11.existsSync(local) && fs11.realpathSync(local) !== fs11.realpathSync(cli)) {
3447
+ const local = path14.join(root2, "node_modules", "truecanvas", "dist", "cli.js");
3448
+ if (fs13.existsSync(local) && fs13.realpathSync(local) !== fs13.realpathSync(cli)) {
2817
3449
  const child = spawn3(process.execPath, [local, "dev", ...process.argv.slice(2).filter((a) => a !== "start" && a !== "-y" && a !== "--yes")], { stdio: "inherit" });
2818
3450
  for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => child.kill(sig));
2819
3451
  child.on("exit", (code) => process.exit(code ?? 0));
@@ -2881,6 +3513,7 @@ async function main() {
2881
3513
  return console.log(`${c.green("\u2713")} Truecanvas quit. Project apps it started are stopped too.`);
2882
3514
  }
2883
3515
  if (command === "desktop") {
3516
+ if (process.platform !== "linux") return console.log(`The app launcher entry is Linux-only for now. Run ${c.accent("truecanvas open")} to open the window.`);
2884
3517
  if (positionals[1] === "remove") {
2885
3518
  const removed = uninstallDesktop();
2886
3519
  console.log(removed.length ? `${c.green("\u2713")} Removed the launcher.` : "Nothing to remove.");
@@ -2894,8 +3527,8 @@ async function main() {
2894
3527
  return;
2895
3528
  }
2896
3529
  const root = process.cwd();
2897
- if (!fs11.existsSync(path11.join(root, "package.json"))) {
2898
- console.error("Run truecanvas from your Next.js project root (no package.json here).");
3530
+ if (!fs13.existsSync(path14.join(root, "package.json"))) {
3531
+ console.error("Run truecanvas from your app's root (no package.json here).");
2899
3532
  process.exit(1);
2900
3533
  }
2901
3534
  const config = loadConfig(root, { port: values.port ? Number(values.port) : void 0, appUrl: values.app });
@@ -2904,16 +3537,88 @@ async function main() {
2904
3537
  const report = initProject(config);
2905
3538
  ws.ensureRoute();
2906
3539
  console.log(`
2907
- ${c.accent("\u25C6")} ${c.bold("Truecanvas")} is set up in ${c.bold(path11.basename(root))}
3540
+ ${c.accent("\u25C6")} ${c.bold("Truecanvas")} is set up in ${c.bold(path14.basename(root))}
2908
3541
  `);
2909
3542
  for (const line of report.done) console.log(` ${c.green("\u2713")} ${line}`);
2910
- console.log(` ${c.green("\u2713")} Canvas folder ${c.bold(config.canvasDir)} and dev route ${c.bold(`${config.appDir}/truecanvas`)}`);
3543
+ console.log(` ${c.green("\u2713")} Canvas folder ${c.bold(config.canvasDir)} and dev route ${c.bold(config.routeDir)}`);
2911
3544
  for (const line of report.todo) console.log(` ${c.dim("!")} ${line}`);
2912
3545
  console.log(`
2913
3546
  Next: ${c.accent("npm run canvas")} ${c.dim("(starts your app and the editor)")}
2914
3547
  `);
2915
3548
  return;
2916
3549
  }
3550
+ if (command === "share") {
3551
+ if (positionals[1] === "setup") {
3552
+ const url = positionals[2] ?? await prompt("Review site URL (e.g. https://review.your-studio.com):");
3553
+ const token = values.token ?? process.env.TRUECANVAS_REVIEW_TOKEN ?? await prompt("Its REVIEW_TOKEN:");
3554
+ try {
3555
+ const { brand } = await connectReviewSite(url, token);
3556
+ console.log(` ${c.green("\u2713")} Connected to ${c.bold(brand.name)} (${url}). Share with ${c.accent("npx truecanvas share <canvas>")}.`);
3557
+ } catch (err) {
3558
+ console.error(` ${c.red("\u2717")} ${err.message}`);
3559
+ process.exit(1);
3560
+ }
3561
+ return;
3562
+ }
3563
+ const base = `http://localhost:${config.port}`;
3564
+ const state = await fetch(`${base}/api/state`).then((r) => r.ok ? r.json() : null).catch(() => null);
3565
+ const canvas = positionals[1] ?? (state?.canvases.length === 1 ? state.canvases[0] : null);
3566
+ if (values.delete) {
3567
+ const site = reviewSite();
3568
+ if (!site || !canvas) {
3569
+ console.error(site ? "Name the canvas: truecanvas share <canvas> --delete" : `No review site yet: ${c.accent("npx truecanvas share setup")}`);
3570
+ process.exit(1);
3571
+ }
3572
+ const done = await deleteShare(site, path14.basename(root), canvas);
3573
+ console.log(done ? ` ${c.green("\u2713")} Deleted the link of ${canvas}, with its versions and comments.` : ` ${canvas} has no link.`);
3574
+ return;
3575
+ }
3576
+ if (values.revoke || values.restore || values["no-password"]) {
3577
+ const site = reviewSite();
3578
+ if (!site || !canvas) {
3579
+ console.error(site ? "Name the canvas: truecanvas share <canvas> --revoke" : `No review site yet: ${c.accent("npx truecanvas share setup")}`);
3580
+ process.exit(1);
3581
+ }
3582
+ const change = { ...values.revoke ? { revoked: true } : values.restore ? { revoked: false } : {}, ...values["no-password"] ? { password: null } : {} };
3583
+ const url = await updateShare(site, path14.basename(root), canvas, change);
3584
+ console.log(` ${c.green("\u2713")} ${values.revoke ? "Link revoked" : values.restore ? "Link restored" : "Password removed"}: ${url}`);
3585
+ return;
3586
+ }
3587
+ if (!state) {
3588
+ console.error(`Truecanvas isn't running for this project. Start it first: ${c.accent("npx truecanvas")}`);
3589
+ process.exit(1);
3590
+ }
3591
+ if (!canvas || !state.canvases.includes(canvas)) {
3592
+ console.error(`Which canvas? ${state.canvases.join(", ") || "(none)"}
3593
+ truecanvas share <canvas>`);
3594
+ process.exit(1);
3595
+ }
3596
+ const publish = !values.local && !!reviewSite();
3597
+ console.log(` ${c.dim(`Rendering ${canvas} through your app${publish ? " and publishing it" : ""}\u2026`)}`);
3598
+ const password = values.password;
3599
+ const res = await fetch(`${base}/api/share/snapshot`, { method: "POST", headers: { "content-type": "application/json", origin: base }, body: JSON.stringify({ canvas, local: !publish, title: values.title, password }) });
3600
+ const body = await res.json();
3601
+ if (!res.ok) {
3602
+ console.error(` ${c.red("\u2717")} ${body.error}`);
3603
+ process.exit(1);
3604
+ }
3605
+ const { manifest, published } = body;
3606
+ console.log(` ${c.green("\u2713")} ${manifest.frames.length} frame${manifest.frames.length === 1 ? "" : "s"} frozen: no scripts, no API calls.`);
3607
+ if (manifest.external.length) console.log(` ${c.yellow("!")} While rendering, the app called ${manifest.external.join(", ")}. Whatever it showed is in the snapshot: use sample data for client links.`);
3608
+ if (manifest.missing.length) console.log(` ${c.yellow("!")} ${manifest.missing.length} asset${manifest.missing.length === 1 ? "" : "s"} couldn't be fetched.`);
3609
+ if (published?.skipped.length) console.log(` ${c.yellow("!")} Left out (over 4.4 MB): ${published.skipped.join(", ")}`);
3610
+ if (published) {
3611
+ console.log(` ${c.green("\u2713")} Shared: ${c.bold(published.url)} ${c.dim(`(version ${published.versions}${published.password ? ", password protected" : ""})`)}
3612
+ `);
3613
+ if (values.open && !values["no-open"]) openBrowser(published.url);
3614
+ } else {
3615
+ console.log(` ${c.dim("Preview")} ${c.bold(body.preview)}`);
3616
+ if (!values.local) console.log(` ${c.dim("To send links to clients, connect your review site:")} ${c.accent("npx truecanvas share setup")}`);
3617
+ console.log("");
3618
+ if (values.open && !values["no-open"]) openBrowser(body.preview);
3619
+ }
3620
+ return;
3621
+ }
2917
3622
  if (command === "mcp") {
2918
3623
  console.log = console.error;
2919
3624
  const mcpUrl = `http://localhost:${config.port}/mcp`;
@@ -2934,7 +3639,7 @@ async function main() {
2934
3639
  process.exit(1);
2935
3640
  }
2936
3641
  if (command === "dev" && await openInWindow(root, config.port)) return;
2937
- const status = nextConfigStatus(root);
3642
+ const status = configStatus(root, config.framework);
2938
3643
  if (!status.wrapped) {
2939
3644
  const report = initProject(config);
2940
3645
  for (const line of report.done) console.log(` ${c.green("\u2713")} ${line}`);
@@ -2955,13 +3660,14 @@ async function main() {
2955
3660
  const mcp = `${origin}/mcp`;
2956
3661
  let next = null;
2957
3662
  const appUp = await reachable(config.appUrl);
2958
- if (!appUp && values.next && !values["no-next"]) next = startNext(root, config.appUrl);
3663
+ const devCommand = config.framework === "vite" ? "vite" : "next dev";
3664
+ if (!appUp && values.next && !values["no-next"]) next = startApp(root, config.appUrl, config.framework);
2959
3665
  console.log(`
2960
3666
  ${c.accent("\u25C6")} ${c.bold("Truecanvas")} ${c.dim(`v${version}`)}
2961
3667
 
2962
3668
  ${c.dim("Editor")} ${c.bold(origin)}
2963
3669
  ${c.dim("MCP")} ${mcp}
2964
- ${c.dim("App")} ${config.appUrl} ${c.dim(appUp ? "(already running)" : next ? "(starting next dev\u2026)" : "(not running)")}
3670
+ ${c.dim("App")} ${config.appUrl} ${c.dim(appUp ? "(already running)" : next ? `(starting ${devCommand}\u2026)` : "(not running)")}
2965
3671
  ${c.dim("Canvases")} ${ws.canvases().join(", ") || "none"}
2966
3672
 
2967
3673
  ${hasAgentConfig(root, config.port) ? ` ${c.dim("Agents")} connected through .mcp.json` : ` ${c.dim("Connect your agent:")} ${c.accent("npx truecanvas connect")} ${c.dim("(writes .mcp.json; commit it for your team)")}`}
@@ -2971,9 +3677,9 @@ ${hasAgentConfig(root, config.port) ? ` ${c.dim("Agents")} connected through
2971
3677
  const result = await Promise.race([waitFor(config.appUrl, 9e4), exited]);
2972
3678
  if (result === true) console.log(` ${c.green("\u2713")} App ready at ${config.appUrl}
2973
3679
  `);
2974
- else if (result === "exited") console.log(` ${c.dim("next dev stopped (see above). Start your app, the editor connects as soon as it's up.")}
3680
+ else if (result === "exited") console.log(` ${c.dim(`${devCommand} stopped (see above). Start your app, the editor connects as soon as it's up.`)}
2975
3681
  `);
2976
- else console.log(` ${c.dim("next dev is taking a while. The editor will connect when it's up.")}
3682
+ else console.log(` ${c.dim(`${devCommand} is taking a while. The editor will connect when it's up.`)}
2977
3683
  `);
2978
3684
  }
2979
3685
  if (values.open && !values["no-open"]) openBrowser(origin);
@@ -2982,15 +3688,15 @@ ${hasAgentConfig(root, config.port) ? ` ${c.dim("Agents")} connected through
2982
3688
  if (stopping) return;
2983
3689
  stopping = true;
2984
3690
  const nextGone = next && next.exitCode === null ? new Promise((r) => next.once("exit", r)) : Promise.resolve();
2985
- next?.kill("SIGINT");
3691
+ if (next) stopTree(next, "SIGINT");
2986
3692
  await Promise.all([shutdown(), Promise.race([nextGone, new Promise((r) => setTimeout(r, 4e3))])]);
2987
- if (next && next.exitCode === null) next.kill("SIGKILL");
3693
+ if (next && next.exitCode === null) stopTree(next, "SIGKILL");
2988
3694
  process.exit(0);
2989
3695
  };
2990
3696
  process.on("SIGINT", stop);
2991
3697
  process.on("SIGTERM", stop);
2992
3698
  next?.on("exit", (code) => {
2993
- if (!stopping) console.log(` ${c.dim(`next dev exited (${code ?? "signal"}).`)}`);
3699
+ if (!stopping) console.log(` ${c.dim(`${devCommand} exited (${code ?? "signal"}).`)}`);
2994
3700
  });
2995
3701
  }
2996
3702
  async function openWindow(cli, port) {
@@ -3015,9 +3721,17 @@ async function openInWindow(root, port) {
3015
3721
  const post = (route, body) => fetch(`${hub}${route}`, { method: "POST", headers: { "content-type": "application/json", origin: hub }, body: JSON.stringify(body) }).catch(() => null);
3016
3722
  await post("/api/hub/add", { path: root });
3017
3723
  await post("/api/hub/open", { path: root, focus: "1" });
3018
- console.log(`${c.accent("\u25C6")} Opened ${c.bold(path11.basename(root))} in the Truecanvas window ${c.dim(`(${hub})`)}.`);
3724
+ console.log(`${c.accent("\u25C6")} Opened ${c.bold(path14.basename(root))} in the Truecanvas window ${c.dim(`(${hub})`)}.`);
3019
3725
  return true;
3020
3726
  }
3727
+ async function prompt(question) {
3728
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3729
+ try {
3730
+ return (await rl.question(` ${question} `)).trim();
3731
+ } finally {
3732
+ rl.close();
3733
+ }
3734
+ }
3021
3735
  async function ask(question) {
3022
3736
  const rl = createInterface({ input: process.stdin, output: process.stdout });
3023
3737
  try {
@@ -3029,9 +3743,9 @@ async function ask(question) {
3029
3743
  }
3030
3744
  async function ensureSetup(root, pkgDir, version, yes, force = false) {
3031
3745
  const tc = installedIn(root);
3032
- const wrapped = nextConfigStatus(root).wrapped;
3746
+ const wrapped = configStatus(root).wrapped;
3033
3747
  if (tc.declared && tc.installed && wrapped && !force) return true;
3034
- const name = readPackage(root)?.name ?? path11.basename(root);
3748
+ const name = readPackage(root)?.name ?? path14.basename(root);
3035
3749
  if (!yes) {
3036
3750
  if (!process.stdin.isTTY) {
3037
3751
  console.error(`Truecanvas isn't set up in ${name} yet. Run again with --yes to set it up.`);
@@ -3040,7 +3754,7 @@ async function ensureSetup(root, pkgDir, version, yes, force = false) {
3040
3754
  console.log(`
3041
3755
  ${c.accent("\u25C6")} ${c.bold("Truecanvas")} ${c.dim(`v${version}`)}
3042
3756
  `);
3043
- console.log(` This adds Truecanvas to ${c.bold(name)}: a dev dependency, a wrapper in next.config,`);
3757
+ console.log(` This adds Truecanvas to ${c.bold(name)}: a dev dependency, a plugin in ${configStatus(root).framework === "vite" ? "vite.config" : "next.config"},`);
3044
3758
  console.log(` a ${c.bold("canvas")} script and a canvas with your homepage. Nothing changes in production builds.
3045
3759
  `);
3046
3760
  if (!await ask("Set it up?")) return false;
@@ -3098,19 +3812,21 @@ async function waitFor(url, ms) {
3098
3812
  }
3099
3813
  return false;
3100
3814
  }
3101
- function startNext(root, appUrl) {
3102
- const port = new URL(appUrl).port || "3000";
3103
- const bin = path11.join(root, "node_modules", ".bin", "next");
3104
- const devArgs = ["dev", "-p", port, "-H", "127.0.0.1"];
3105
- const [cmd, args] = fs11.existsSync(bin) ? [bin, devArgs] : ["npx", ["next", ...devArgs]];
3106
- const child = spawn3(cmd, args, { cwd: root, env: toolEnv(process.env.NO_COLOR ? {} : { FORCE_COLOR: "1" }), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
3107
- child.on("error", (err) => console.error(` ${c.dim("next \u2502")} couldn't start next dev: ${err.message}`));
3108
- const prefix = c.dim("next \u2502 ");
3815
+ function startApp(root, appUrl, framework) {
3816
+ const port = new URL(appUrl).port || (framework === "vite" ? "5173" : "3000");
3817
+ const devArgs = framework === "vite" ? ["--port", port, "--strictPort", "--host", "127.0.0.1"] : ["dev", "-p", port, "-H", "127.0.0.1"];
3818
+ const pkg = framework === "vite" ? "vite" : "next";
3819
+ const bin = binScript(root, pkg);
3820
+ const [cmd, args] = bin ? [process.execPath, [bin, ...devArgs]] : [process.platform === "win32" ? "npx.cmd" : "npx", [pkg, ...devArgs]];
3821
+ const label = framework === "vite" ? "vite" : "next";
3822
+ const child = spawn3(cmd, args, { cwd: root, env: toolEnv(process.env.NO_COLOR ? {} : { FORCE_COLOR: "1" }), stdio: ["ignore", "pipe", "pipe"], shell: !bin && process.platform === "win32" });
3823
+ child.on("error", (err) => console.error(` ${c.dim(`${label} \u2502`)} couldn't start ${pkg}: ${err.message}`));
3824
+ const prefix = c.dim(`${label} \u2502 `);
3109
3825
  for (const stream of [child.stdout, child.stderr]) {
3110
3826
  let buf = "";
3111
3827
  stream.on("data", (chunk) => {
3112
3828
  buf += chunk.toString();
3113
- const lines = buf.split("\n");
3829
+ const lines = buf.split(/\r?\n/);
3114
3830
  buf = lines.pop();
3115
3831
  for (const line of lines) if (line.trim()) process.stdout.write(` ${prefix}${line}
3116
3832
  `);
@@ -3118,6 +3834,22 @@ function startNext(root, appUrl) {
3118
3834
  }
3119
3835
  return child;
3120
3836
  }
3837
+ function binScript(root, pkg) {
3838
+ try {
3839
+ const req = createRequire(path14.join(root, "package.json"));
3840
+ const manifest = req.resolve(`${pkg}/package.json`);
3841
+ const { bin } = JSON.parse(fs13.readFileSync(manifest, "utf8"));
3842
+ const rel = typeof bin === "string" ? bin : bin?.[pkg];
3843
+ return rel ? path14.join(path14.dirname(manifest), rel) : null;
3844
+ } catch {
3845
+ return null;
3846
+ }
3847
+ }
3848
+ function stopTree(child, signal) {
3849
+ if (process.platform === "win32" && child.pid) spawn3("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }).on("error", () => {
3850
+ });
3851
+ else child.kill(signal);
3852
+ }
3121
3853
  function openBrowser(url) {
3122
3854
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
3123
3855
  try {