truecanvas 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +265 -0
  3. package/dist/catalog-worker.js +7 -0
  4. package/dist/chunk-753N4QRO.js +317 -0
  5. package/dist/chunk-J6CJBZO6.js +23 -0
  6. package/dist/chunk-WB35FXGP.js +3366 -0
  7. package/dist/cli.js +2931 -0
  8. package/dist/core/index.d.ts +820 -0
  9. package/dist/core/index.js +71 -0
  10. package/dist/editor/assets/controls-BQYSgX53.css +1 -0
  11. package/dist/editor/assets/controls-BYFjuGCq.js +9 -0
  12. package/dist/editor/assets/hub-C4_700Hv.js +2 -0
  13. package/dist/editor/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  14. package/dist/editor/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  15. package/dist/editor/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  16. package/dist/editor/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  17. package/dist/editor/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  18. package/dist/editor/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  19. package/dist/editor/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  20. package/dist/editor/assets/main-CfAJbDf1.js +25 -0
  21. package/dist/editor/assets/react-DB-4Zxce.js +1 -0
  22. package/dist/editor/favicon.svg +3 -0
  23. package/dist/editor/hub.html +16 -0
  24. package/dist/editor/index.html +16 -0
  25. package/dist/next/index.d.ts +11 -0
  26. package/dist/next/index.js +83 -0
  27. package/dist/next/loader.cjs +16210 -0
  28. package/dist/runtime/host.d.ts +23 -0
  29. package/dist/runtime/host.js +412 -0
  30. package/dist/runtime/index.d.ts +42 -0
  31. package/dist/runtime/index.js +11 -0
  32. package/package.json +108 -0
package/dist/cli.js ADDED
@@ -0,0 +1,2931 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ DEVICES,
4
+ EditError,
5
+ Workspace,
6
+ clearCompare,
7
+ componentsDir,
8
+ describeComponent,
9
+ findByPath,
10
+ findDevice,
11
+ findNode,
12
+ frameChanges,
13
+ layoutFiles,
14
+ listRoutes,
15
+ loadConfig,
16
+ outlineDoc,
17
+ parseCanvas,
18
+ readDesignTokens,
19
+ readTokenData,
20
+ syncComponentRegistry,
21
+ writeCompare
22
+ } from "./chunk-WB35FXGP.js";
23
+ import "./chunk-753N4QRO.js";
24
+
25
+ // src/cli/index.ts
26
+ import { spawn as spawn3 } from "child_process";
27
+ import fs12 from "fs";
28
+ import path12 from "path";
29
+ import { parseArgs } from "util";
30
+
31
+ // src/server/http.ts
32
+ import http from "http";
33
+ import fs4 from "fs";
34
+ import path4 from "path";
35
+ import { randomUUID } from "crypto";
36
+ import { fileURLToPath } from "url";
37
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
38
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
39
+ import launchEditor from "launch-editor";
40
+
41
+ // src/server/mcp.ts
42
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
43
+ import { z } from "zod";
44
+ var INSTRUCTIONS = `Truecanvas is a design canvas whose layers are the project's real React components.
45
+ 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.
46
+
47
+ Workflow:
48
+ 1. list_canvases \u2192 get_canvas to read the tree. Every node shows an id like [24:10] (line:col). Ids change after every edit, so use the ids returned by the latest call.
49
+ 2. list_components / get_component to learn the design system's props, and get_design_tokens for the Tailwind theme (colors, radius, fonts). Prefer composing existing components over raw HTML. Layout wrappers are plain <div>s with Tailwind classes (flex, gap-*, p-*) using those tokens.
50
+ 3. Edit with set_props, set_text, insert_jsx, replace_node, move_node, wrap_nodes, create_frame, update_frame. Imports for catalog components are added automatically.
51
+ Library components (e.g. Paper shaders like MeshGradient) are in the catalog too: add_background puts one behind a frame or layer, apply_preset applies a named look. Array props are plain literals: colors={["#fff", "#000"]}.
52
+ For mobile screens create frames with a device (e.g. device="iphone-16"): the right size, plus touch + pixel-ratio emulation in screenshots.
53
+ 4. screenshot_frame to check the result visually. get_selection tells you what the user has selected in the editor ("make this denser" = the selection).
54
+ 5. focus to show the user what you changed. Everything is undoable by the user.`;
55
+ var literal = z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number(), z.boolean()]))]);
56
+ var deviceIds = DEVICES.map((d) => d.id);
57
+ function createMcpServer(ws, shots, session) {
58
+ const server = new McpServer({ name: "truecanvas", version: "0.1.0" }, { instructions: INSTRUCTIONS });
59
+ const actor = () => {
60
+ const name = server.server.getClientVersion()?.name ?? "agent";
61
+ ws.touchAgent(session(), name);
62
+ return { kind: "agent", name };
63
+ };
64
+ const text = (t) => ({ content: [{ type: "text", text: t }] });
65
+ const fail = (err) => ({ content: [{ type: "text", text: `Error: ${err.message}` }], isError: true });
66
+ let lastCanvas = null;
67
+ const resolveCanvas = (canvas) => {
68
+ const c2 = canvas ?? lastCanvas ?? ws.selection.canvas ?? ws.canvases()[0];
69
+ if (!c2) throw new Error("No canvases yet. Use create_canvas.");
70
+ lastCanvas = c2;
71
+ return c2;
72
+ };
73
+ const presence = (canvas, action, label, ids = [], frame = null) => {
74
+ const a = actor();
75
+ ws.emit({ type: "presence", presence: { session: session(), name: a.kind === "agent" ? a.name : "agent", canvas, action, label, ids, frame, at: Date.now() } });
76
+ };
77
+ const VERBS = {
78
+ set_props: "Editing props",
79
+ set_text: "Writing text",
80
+ set_class: "Adjusting layout",
81
+ insert_jsx: "Inserting",
82
+ insert_component: "Inserting",
83
+ replace: "Rewriting",
84
+ duplicate: "Duplicating",
85
+ delete: "Deleting",
86
+ move: "Moving",
87
+ reorder: "Moving",
88
+ wrap: "Wrapping in auto layout",
89
+ create_frame: "Creating a frame",
90
+ update_frame: "Updating frame",
91
+ create_variants: "Building variants",
92
+ add_background: "Adding a background",
93
+ apply_preset: "Applying a preset",
94
+ import_route: "Linking a page",
95
+ explore_copy: "Exploring a copy",
96
+ apply_to_page: "Applying to the page",
97
+ add_animation: "Animating",
98
+ create_component: "Creating a component",
99
+ add_component_frame: "Opening a component",
100
+ remove_animation: "Removing an animation"
101
+ };
102
+ async function edit(make) {
103
+ let cmd;
104
+ try {
105
+ cmd = make();
106
+ } catch (err) {
107
+ return fail(err);
108
+ }
109
+ try {
110
+ const c2 = cmd;
111
+ const before = [c2.id, ...Array.isArray(c2.ids) ? c2.ids : [], c2.parent].filter((x) => typeof x === "string");
112
+ const frameRef = typeof c2.frame === "string" ? c2.frame : null;
113
+ presence(cmd.canvas, "editing", `${VERBS[cmd.op] ?? "Editing"}\u2026`, before.filter((x) => /^\d+:\d+$/.test(x)), frameRef ?? before.find((x) => !/^\d+:\d+$/.test(x)) ?? null);
114
+ const res = await ws.run(cmd, actor());
115
+ presence(cmd.canvas, "editing", res.label, res.ids);
116
+ const frames = /* @__PURE__ */ new Set();
117
+ for (const id of res.ids) {
118
+ const hit = findNode(res.doc, id);
119
+ if (hit) frames.add(hit.frame.frameName);
120
+ }
121
+ const outline = frames.size ? [...frames].map((f) => outlineDoc(res.doc, { frame: f }).split("\n").slice(1).join("\n")).join("\n") : "";
122
+ return text(`\u2713 ${res.label} in ${cmd.canvas}${res.ids.length ? ` \u2192 ${res.ids.map((i) => `[${i}]`).join(" ")}` : ""}${outline ? `
123
+
124
+ Updated frame (ids are fresh):
125
+ ${outline}` : ""}`);
126
+ } catch (err) {
127
+ return fail(err);
128
+ }
129
+ }
130
+ const canvasArg = { canvas: z.string().optional().describe("Canvas name. Defaults to the canvas open in the editor.") };
131
+ server.registerTool(
132
+ "list_canvases",
133
+ { title: "List canvases", description: "List canvas files with their frames.", annotations: { readOnlyHint: true } },
134
+ async () => {
135
+ actor();
136
+ const lines = ws.canvases().map((c2) => {
137
+ const doc = ws.doc(c2);
138
+ return `${c2} (${doc.file}): ${doc.frames.map((f) => `"${f.frameName}" ${f.width}\xD7${f.height ?? "hug"}`).join(", ") || "no frames"}`;
139
+ });
140
+ return text(lines.join("\n") || "No canvases. Use create_canvas.");
141
+ }
142
+ );
143
+ server.registerTool(
144
+ "get_canvas",
145
+ {
146
+ title: "Read canvas tree",
147
+ description: "Outline of a canvas: frames and their JSX tree with node ids, props and text.",
148
+ inputSchema: { ...canvasArg, frame: z.string().optional().describe("Only this frame (name or id)."), depth: z.number().int().optional() },
149
+ annotations: { readOnlyHint: true }
150
+ },
151
+ async ({ canvas, frame, depth }) => {
152
+ try {
153
+ const c2 = resolveCanvas(canvas);
154
+ presence(c2, "reading", frame ? `Reading ${frame}` : "Reading the canvas", [], frame ?? null);
155
+ return text(outlineDoc(ws.doc(c2), { frame, depth }));
156
+ } catch (e) {
157
+ return fail(e);
158
+ }
159
+ }
160
+ );
161
+ server.registerTool(
162
+ "get_node",
163
+ {
164
+ title: "Read node source",
165
+ description: "Exact TSX source of a node, its props, and the component's prop types.",
166
+ inputSchema: { ...canvasArg, id: z.string() },
167
+ annotations: { readOnlyHint: true }
168
+ },
169
+ async ({ canvas, id }) => {
170
+ try {
171
+ const c2 = resolveCanvas(canvas);
172
+ const source = ws.read(c2);
173
+ const doc = ws.doc(c2);
174
+ const hit = findNode(doc, id);
175
+ if (!hit) return fail(new Error(`No node ${id}.`));
176
+ presence(c2, "reading", `Reading ${hit.node.name}`, [id]);
177
+ await ws.catalog.load();
178
+ const spec = hit.node.kind === "component" ? ws.catalog.get(hit.node.name) : void 0;
179
+ return text(
180
+ [`${hit.node.name} [${id}] in frame "${hit.frame.frameName}", ${doc.file}:${hit.node.line}`, "```tsx", source.slice(hit.node.start, hit.node.end), "```", spec ? describeComponent(spec, true) : ""].join("\n")
181
+ );
182
+ } catch (e) {
183
+ return fail(e);
184
+ }
185
+ }
186
+ );
187
+ server.registerTool(
188
+ "list_components",
189
+ {
190
+ title: "List components",
191
+ description: "The project's React components with their props (from TypeScript types).",
192
+ inputSchema: { query: z.string().optional().describe("Filter by name or file.") },
193
+ annotations: { readOnlyHint: true }
194
+ },
195
+ async ({ query }) => {
196
+ actor();
197
+ const q = query?.toLowerCase();
198
+ const list = (await ws.catalog.load()).filter((c2) => !q || c2.name.toLowerCase().includes(q) || c2.file.toLowerCase().includes(q));
199
+ return text(list.map((c2) => describeComponent(c2)).join("\n") || "No components found.");
200
+ }
201
+ );
202
+ server.registerTool(
203
+ "get_component",
204
+ {
205
+ title: "Component details",
206
+ description: "Full prop types, defaults and docs of one component.",
207
+ inputSchema: { name: z.string() },
208
+ annotations: { readOnlyHint: true }
209
+ },
210
+ async ({ name }) => {
211
+ actor();
212
+ await ws.catalog.load();
213
+ const spec = ws.catalog.get(name);
214
+ return spec ? text(describeComponent(spec, true)) : fail(new Error(`Unknown component ${name}.`));
215
+ }
216
+ );
217
+ server.registerTool(
218
+ "get_design_tokens",
219
+ {
220
+ title: "Design tokens",
221
+ description: "The project's Tailwind theme tokens (colors, fonts, radius, shadows) to use in className.",
222
+ annotations: { readOnlyHint: true }
223
+ },
224
+ async () => {
225
+ actor();
226
+ return text(readDesignTokens(ws.config.root));
227
+ }
228
+ );
229
+ server.registerTool(
230
+ "get_selection",
231
+ {
232
+ title: "Get user selection",
233
+ description: "What the user currently has selected in the Truecanvas editor, with the source of each node.",
234
+ annotations: { readOnlyHint: true }
235
+ },
236
+ async () => {
237
+ actor();
238
+ const { canvas, ids } = ws.selection;
239
+ if (!canvas) return text("The editor is not open.");
240
+ if (!ids.length) return text(`Nothing selected. Open canvas: ${canvas}.`);
241
+ lastCanvas = canvas;
242
+ presence(canvas, "looking", "Looking at your selection", ids);
243
+ const doc = ws.doc(canvas);
244
+ const source = ws.read(canvas);
245
+ const parts = [`canvas: ${canvas}`];
246
+ for (const id of ids) {
247
+ const hit = findNode(doc, id);
248
+ if (!hit) continue;
249
+ parts.push(`[${id}] ${hit.node.name} in frame "${hit.frame.frameName}"
250
+ \`\`\`tsx
251
+ ${source.slice(hit.node.start, hit.node.end)}
252
+ \`\`\``);
253
+ }
254
+ return text(parts.join("\n\n"));
255
+ }
256
+ );
257
+ server.registerTool(
258
+ "set_props",
259
+ {
260
+ title: "Set props",
261
+ description: "Set literal props on a node. null removes the prop (falls back to its default).",
262
+ inputSchema: { ...canvasArg, id: z.string(), props: z.record(z.string(), literal) },
263
+ annotations: { idempotentHint: true }
264
+ },
265
+ ({ canvas, id, props }) => edit(() => ({ op: "set_props", canvas: resolveCanvas(canvas), id, props }))
266
+ );
267
+ server.registerTool(
268
+ "set_text",
269
+ {
270
+ title: "Set text",
271
+ description: "Replace the text content of a node (or of a text node).",
272
+ inputSchema: { ...canvasArg, id: z.string(), text: z.string() },
273
+ annotations: { idempotentHint: true }
274
+ },
275
+ ({ canvas, id, text: t }) => edit(() => ({ op: "set_text", canvas: resolveCanvas(canvas), id, text: t }))
276
+ );
277
+ server.registerTool(
278
+ "set_class_name",
279
+ {
280
+ title: "Set Tailwind classes",
281
+ description: "Replace a node's className (Tailwind). Use for layout: flex, gap-*, p-*, items-*, justify-*.",
282
+ inputSchema: { ...canvasArg, id: z.string(), className: z.string() },
283
+ annotations: { idempotentHint: true }
284
+ },
285
+ ({ canvas, id, className }) => edit(() => ({ op: "set_class", canvas: resolveCanvas(canvas), id, className }))
286
+ );
287
+ server.registerTool(
288
+ "insert_jsx",
289
+ {
290
+ title: "Insert JSX",
291
+ description: "Insert a JSX snippet as a child of a frame or node. Catalog components are imported automatically.",
292
+ inputSchema: {
293
+ ...canvasArg,
294
+ parent: z.string().describe("Frame name/id or node id."),
295
+ index: z.number().int().optional().describe("Child index; omit to append."),
296
+ jsx: z.string()
297
+ }
298
+ },
299
+ ({ canvas, parent, index, jsx }) => edit(() => ({ op: "insert_jsx", canvas: resolveCanvas(canvas), parent, index, jsx }))
300
+ );
301
+ server.registerTool(
302
+ "replace_node",
303
+ {
304
+ title: "Replace node",
305
+ description: "Replace a node (and its children) with a JSX snippet.",
306
+ inputSchema: { ...canvasArg, id: z.string(), jsx: z.string() }
307
+ },
308
+ ({ canvas, id, jsx }) => edit(() => ({ op: "replace", canvas: resolveCanvas(canvas), id, jsx }))
309
+ );
310
+ server.registerTool(
311
+ "duplicate_nodes",
312
+ {
313
+ title: "Duplicate",
314
+ description: "Duplicate nodes or frames in place (frames are placed to the right).",
315
+ inputSchema: { ...canvasArg, ids: z.array(z.string()).min(1) }
316
+ },
317
+ ({ canvas, ids }) => edit(() => ({ op: "duplicate", canvas: resolveCanvas(canvas), ids }))
318
+ );
319
+ server.registerTool(
320
+ "delete_nodes",
321
+ {
322
+ title: "Delete",
323
+ description: "Delete nodes or frames.",
324
+ inputSchema: { ...canvasArg, ids: z.array(z.string()).min(1) },
325
+ annotations: { destructiveHint: true }
326
+ },
327
+ ({ canvas, ids }) => edit(() => ({ op: "delete", canvas: resolveCanvas(canvas), ids }))
328
+ );
329
+ server.registerTool(
330
+ "move_node",
331
+ {
332
+ title: "Move node",
333
+ description: "Move a node to another parent (frame or node) at an index.",
334
+ inputSchema: { ...canvasArg, id: z.string(), parent: z.string(), index: z.number().int().optional() }
335
+ },
336
+ ({ canvas, id, parent, index }) => edit(() => ({ op: "move", canvas: resolveCanvas(canvas), id, parent, index }))
337
+ );
338
+ server.registerTool(
339
+ "wrap_nodes",
340
+ {
341
+ title: "Wrap in auto layout",
342
+ description: "Wrap sibling nodes in a <div> with Tailwind layout classes (default: flex flex-col gap-2).",
343
+ inputSchema: { ...canvasArg, ids: z.array(z.string()).min(1), className: z.string().optional() }
344
+ },
345
+ ({ canvas, ids, className }) => edit(() => ({ op: "wrap", canvas: resolveCanvas(canvas), ids, className }))
346
+ );
347
+ server.registerTool(
348
+ "create_frame",
349
+ {
350
+ title: "Create frame",
351
+ description: "Add a frame (artboard). Placed right of existing frames unless x/y are given. Omit height to hug content.",
352
+ inputSchema: {
353
+ ...canvasArg,
354
+ name: z.string(),
355
+ width: z.number().optional().describe("Default 1024."),
356
+ height: z.number().nullable().optional(),
357
+ x: z.number().optional(),
358
+ y: z.number().optional(),
359
+ theme: z.enum(["light", "dark"]).nullable().optional(),
360
+ device: z.enum(deviceIds).optional().describe(`Device preset; sets width/height. ${DEVICES.map((d) => `${d.id} ${d.width}\xD7${d.height}`).join(", ")}`),
361
+ jsx: z.string().optional().describe("Initial content.")
362
+ }
363
+ },
364
+ ({ canvas, ...rest }) => edit(() => ({ op: "create_frame", canvas: resolveCanvas(canvas), ...rest }))
365
+ );
366
+ server.registerTool(
367
+ "update_frame",
368
+ {
369
+ title: "Update frame",
370
+ description: "Rename, move, resize or theme a frame. height: null = hug content; theme: null = inherit.",
371
+ inputSchema: {
372
+ ...canvasArg,
373
+ frame: z.string().describe("Frame name or id."),
374
+ name: z.string().optional(),
375
+ x: z.number().optional(),
376
+ y: z.number().optional(),
377
+ width: z.number().optional(),
378
+ height: z.number().nullable().optional(),
379
+ theme: z.enum(["light", "dark"]).nullable().optional(),
380
+ device: z.enum(deviceIds).nullable().optional().describe("Device preset (sets size); null removes it.")
381
+ }
382
+ },
383
+ ({ canvas, ...rest }) => edit(() => ({ op: "update_frame", canvas: resolveCanvas(canvas), ...rest }))
384
+ );
385
+ server.registerTool(
386
+ "create_variants_frame",
387
+ {
388
+ title: "Variants frame",
389
+ description: "New frame showing a component once per value of a union/boolean prop. Great for reviewing states.",
390
+ inputSchema: { ...canvasArg, component: z.string(), prop: z.string(), props: z.record(z.string(), literal).optional() }
391
+ },
392
+ ({ canvas, ...rest }) => edit(() => ({ op: "create_variants", canvas: resolveCanvas(canvas), ...rest }))
393
+ );
394
+ server.registerTool(
395
+ "import_page",
396
+ {
397
+ title: "Link an app page",
398
+ description: "Add a frame showing one of the app's real pages, with its layouts. By default the frame is LINKED: its layers are the page's own JSX and editing them edits the page and layout files directly (git tracks it). Pages with data or logic are view only when linked. Pass copy=true for a detached copy in the canvas file instead.",
399
+ inputSchema: {
400
+ ...canvasArg,
401
+ route: z.string().describe('URL path like "/" or "/pricing", or the page file'),
402
+ name: z.string().optional(),
403
+ width: z.number().optional().describe("Default 1440."),
404
+ copy: z.boolean().optional().describe("Copy the page's JSX into the canvas instead of linking. Default false.")
405
+ }
406
+ },
407
+ ({ canvas, ...rest }) => edit(() => ({ op: "import_route", canvas: resolveCanvas(canvas), ...rest }))
408
+ );
409
+ server.registerTool(
410
+ "explore_copy",
411
+ {
412
+ title: "Explore a copy of a page",
413
+ description: "Copy a linked frame into a new exploration frame next to it. The copy lives in the canvas file, so you can try bold changes without touching the real page. When it's good, apply_to_page writes it back.",
414
+ inputSchema: { ...canvasArg, frame: z.string().describe("Name or id of a linked frame."), name: z.string().optional() }
415
+ },
416
+ ({ canvas, ...rest }) => edit(() => ({ op: "explore_copy", canvas: resolveCanvas(canvas), ...rest }))
417
+ );
418
+ server.registerTool(
419
+ "apply_to_page",
420
+ {
421
+ title: "Apply exploration to page",
422
+ description: "Write an exploration frame (made with explore_copy) back to its page file: the page's returned JSX is replaced with the frame's content, needed imports are added and unused ones removed. The page's layouts are not changed. Undoable.",
423
+ inputSchema: { ...canvasArg, frame: z.string().describe("Name or id of the exploration frame.") }
424
+ },
425
+ ({ canvas, ...rest }) => edit(() => ({ op: "apply_to_page", canvas: resolveCanvas(canvas), ...rest }))
426
+ );
427
+ server.registerTool(
428
+ "create_component",
429
+ {
430
+ title: "Create a component",
431
+ description: "Create a new component file in the project's components folder. With `from` (a node id), the selected layers move into it and are replaced by <Name />, imports included. Without it, a starter component is created and, with `parent`, inserted there. Then use open_component to design it.",
432
+ inputSchema: {
433
+ ...canvasArg,
434
+ name: z.string().describe('PascalCase, e.g. "PricingCard"'),
435
+ from: z.string().optional().describe("Node id to turn into the component."),
436
+ parent: z.string().optional(),
437
+ index: z.number().optional()
438
+ }
439
+ },
440
+ ({ canvas, ...rest }) => edit(() => ({ op: "create_component", canvas: resolveCanvas(canvas), ...rest }))
441
+ );
442
+ server.registerTool(
443
+ "open_component",
444
+ {
445
+ title: "Open a main component",
446
+ description: `Add a main component frame for a project component (Figma's main component): its layers are the component's own JSX, edits go to its file, and every instance in the app follows. Expressions inside it (props, {children}) are read-only; className={cn("\u2026", className)} edits the static classes. Reuses the frame if it's already open.`,
447
+ inputSchema: { ...canvasArg, component: z.string().describe("Component name from list_components.") }
448
+ },
449
+ ({ canvas, component }) => edit(() => ({ op: "add_component_frame", canvas: resolveCanvas(canvas), component }))
450
+ );
451
+ server.registerTool(
452
+ "add_animation",
453
+ {
454
+ title: "Animate a layer",
455
+ description: `Make a layer animate in when it scrolls into view. kind "reveal" wraps the layer in <Reveal> (effects: fade-up, fade-down, fade-in, blur-in, scale-in, slide-left, slide-right). kind "text" wraps the layer's text in <TextAnimate> (effects: words, letters, blur-in, slide-up, typewriter), keeping inline elements like <em>. The first use adds both components to the project (components/motion/), plain React, no dependencies. Calling it again on an animated layer updates its settings. For a staggered list, give each item a growing delay.`,
456
+ inputSchema: {
457
+ ...canvasArg,
458
+ id: z.string().describe("Layer to animate (or its existing Reveal/TextAnimate)."),
459
+ kind: z.enum(["reveal", "text"]),
460
+ effect: z.string().optional(),
461
+ delay: z.number().min(0).max(5).optional().describe("Seconds before it starts."),
462
+ duration: z.number().min(0.05).max(5).optional().describe("Seconds."),
463
+ stagger: z.number().min(0).max(1).optional().describe("Text only: seconds between words or letters."),
464
+ once: z.boolean().optional().describe("Only the first time it enters the view. Default true.")
465
+ }
466
+ },
467
+ ({ canvas, ...rest }) => edit(() => ({ op: "add_animation", canvas: resolveCanvas(canvas), ...rest }))
468
+ );
469
+ server.registerTool(
470
+ "remove_animation",
471
+ {
472
+ title: "Remove an animation",
473
+ description: "Unwrap a layer's <Reveal> or <TextAnimate>, leaving the layer as it was. The import goes when no longer used.",
474
+ inputSchema: { ...canvasArg, id: z.string(), kind: z.enum(["reveal", "text"]) }
475
+ },
476
+ ({ canvas, ...rest }) => edit(() => ({ op: "remove_animation", canvas: resolveCanvas(canvas), ...rest }))
477
+ );
478
+ server.registerTool(
479
+ "play_frame",
480
+ {
481
+ title: "Play a frame",
482
+ description: `Show animations in the user's editor. "play": the frame becomes one screen tall and scrolls inside, so scroll reveals run like on the site. "replay": rerun the entrance animations. "stop": back to the full-height view. (screenshot_frame always captures animations finished.)`,
483
+ inputSchema: { ...canvasArg, frame: z.string(), mode: z.enum(["play", "replay", "stop"]).default("replay") },
484
+ annotations: { readOnlyHint: true }
485
+ },
486
+ async ({ canvas, frame, mode }) => {
487
+ const c2 = resolveCanvas(canvas);
488
+ let f;
489
+ try {
490
+ f = ws.findFrameOf(c2, frame);
491
+ } catch (e) {
492
+ return fail(e);
493
+ }
494
+ presence(c2, "looking", mode === "play" ? "Playing" : mode === "stop" ? "Stopped" : "Replaying", [f.id]);
495
+ ws.emit({ type: "motion", canvas: c2, frame: f.frameName, action: mode });
496
+ return text(`\u2713 ${mode === "play" ? "Playing" : mode === "stop" ? "Stopped" : "Replayed"} ${f.frameName} in the editor.`);
497
+ }
498
+ );
499
+ server.registerTool(
500
+ "add_background",
501
+ {
502
+ title: "Add background",
503
+ description: "Put a component (typically a shader like MeshGradient or GrainGradient) behind a frame or layer, filling it. Optionally with a named preset.",
504
+ inputSchema: {
505
+ ...canvasArg,
506
+ parent: z.string().describe("Frame name/id or node id."),
507
+ component: z.string().describe("Component name, e.g. MeshGradient."),
508
+ preset: z.string().optional().describe("Preset name from get_component.")
509
+ }
510
+ },
511
+ ({ canvas, ...rest }) => edit(() => ({ op: "add_background", canvas: resolveCanvas(canvas), ...rest }))
512
+ );
513
+ server.registerTool(
514
+ "apply_preset",
515
+ {
516
+ title: "Apply preset",
517
+ description: "Apply one of a component's named presets (see get_component) to a node.",
518
+ inputSchema: { ...canvasArg, id: z.string(), preset: z.string() }
519
+ },
520
+ ({ canvas, id, preset }) => edit(() => ({ op: "apply_preset", canvas: resolveCanvas(canvas), id, preset }))
521
+ );
522
+ server.registerTool(
523
+ "create_canvas",
524
+ {
525
+ title: "Create canvas",
526
+ description: "Create a new canvas file with a starter frame.",
527
+ inputSchema: { name: z.string().describe("kebab-case, e.g. onboarding") }
528
+ },
529
+ async ({ name }) => {
530
+ try {
531
+ ws.createCanvas(name, actor());
532
+ return text(`\u2713 Created canvas ${name} (${ws.relFile(name)}). Next.js picks the new route up in a second.`);
533
+ } catch (e) {
534
+ return fail(e);
535
+ }
536
+ }
537
+ );
538
+ server.registerTool(
539
+ "list_comments",
540
+ {
541
+ title: "Read comments",
542
+ description: "Comments people left on the canvas (pinned to frames/layers), with the source of the commented layer. Address them, then resolve_comment.",
543
+ inputSchema: { ...canvasArg, include_resolved: z.boolean().optional() },
544
+ annotations: { readOnlyHint: true }
545
+ },
546
+ async ({ canvas, include_resolved }) => {
547
+ try {
548
+ const c2 = resolveCanvas(canvas);
549
+ const threads = ws.comments.list(c2).filter((t) => include_resolved || !t.resolved);
550
+ presence(c2, "reading", `Reading ${threads.length} comment${threads.length === 1 ? "" : "s"}`);
551
+ if (!threads.length) return text(include_resolved ? "No comments on this canvas." : "No open comments. (include_resolved: true shows resolved ones.)");
552
+ const doc = ws.doc(c2);
553
+ const source = ws.read(c2);
554
+ const parts = threads.map((t) => {
555
+ let where = "";
556
+ if (t.node) {
557
+ const node = findByPath(doc, t.node.path.split(".").map(Number));
558
+ if (node && node.name === t.node.name) where = `
559
+ on [${node.id}] ${node.name}:
560
+ \`\`\`tsx
561
+ ${source.slice(node.start, node.end)}
562
+ \`\`\``;
563
+ else where = `
564
+ on a ${t.node.name} (it may have moved)`;
565
+ }
566
+ const msgs = t.messages.map((m) => ` ${m.author.name}${m.author.kind === "agent" ? " (agent)" : ""}: ${m.text}`).join("\n");
567
+ return `thread ${t.id}${t.resolved ? " [resolved]" : ""} in frame "${t.frame}" at ${t.x},${t.y}${where}
568
+ ${msgs}`;
569
+ });
570
+ return text(parts.join("\n\n"));
571
+ } catch (e) {
572
+ return fail(e);
573
+ }
574
+ }
575
+ );
576
+ server.registerTool(
577
+ "reply_comment",
578
+ {
579
+ title: "Reply to a comment",
580
+ description: "Reply in a comment thread, e.g. to explain a change or ask a question.",
581
+ inputSchema: { ...canvasArg, thread: z.string(), text: z.string() }
582
+ },
583
+ async ({ canvas, thread, text: body }) => {
584
+ try {
585
+ const c2 = resolveCanvas(canvas);
586
+ const a = actor();
587
+ const t = ws.comments.reply(c2, thread, body, { name: a.kind === "agent" ? a.name : "agent", kind: "agent" });
588
+ ws.commentsChanged(c2);
589
+ presence(c2, "editing", "Replied to a comment", [], t.frame);
590
+ return text(`\u2713 Replied in thread ${t.id}.`);
591
+ } catch (e) {
592
+ return fail(e);
593
+ }
594
+ }
595
+ );
596
+ server.registerTool(
597
+ "resolve_comment",
598
+ {
599
+ title: "Resolve a comment",
600
+ description: "Mark a comment thread as resolved once you've addressed it, optionally with a short note of what you changed.",
601
+ inputSchema: { ...canvasArg, thread: z.string(), note: z.string().optional() }
602
+ },
603
+ async ({ canvas, thread, note }) => {
604
+ try {
605
+ const c2 = resolveCanvas(canvas);
606
+ const a = actor();
607
+ const author = { name: a.kind === "agent" ? a.name : "agent", kind: "agent" };
608
+ if (note?.trim()) ws.comments.reply(c2, thread, note, author);
609
+ const t = ws.comments.resolve(c2, thread, true, author);
610
+ ws.commentsChanged(c2);
611
+ presence(c2, "editing", "Resolved a comment", [], t.frame);
612
+ return text(`\u2713 Resolved thread ${t.id}.`);
613
+ } catch (e) {
614
+ return fail(e);
615
+ }
616
+ }
617
+ );
618
+ server.registerTool(
619
+ "rename_canvas",
620
+ {
621
+ title: "Rename canvas",
622
+ description: "Rename a canvas (page). Renames its .canvas.tsx file.",
623
+ inputSchema: { canvas: z.string(), name: z.string().describe("kebab-case") }
624
+ },
625
+ async ({ canvas, name }) => {
626
+ try {
627
+ actor();
628
+ ws.renameCanvas(canvas, name);
629
+ if (lastCanvas === canvas) lastCanvas = name;
630
+ return text(`\u2713 Renamed ${canvas} to ${name}.`);
631
+ } catch (e) {
632
+ return fail(e);
633
+ }
634
+ }
635
+ );
636
+ server.registerTool(
637
+ "delete_canvas",
638
+ {
639
+ title: "Delete canvas",
640
+ description: "Delete a canvas (page) file. Ask the user first.",
641
+ inputSchema: { canvas: z.string() },
642
+ annotations: { destructiveHint: true }
643
+ },
644
+ async ({ canvas }) => {
645
+ try {
646
+ actor();
647
+ ws.deleteCanvas(canvas);
648
+ if (lastCanvas === canvas) lastCanvas = null;
649
+ return text(`\u2713 Deleted ${canvas}. The user can restore it from the editor during this session.`);
650
+ } catch (e) {
651
+ return fail(e);
652
+ }
653
+ }
654
+ );
655
+ server.registerTool(
656
+ "screenshot_frame",
657
+ {
658
+ title: "Screenshot frame",
659
+ description: "PNG of a frame rendered by the real app. Use it to check your work.",
660
+ inputSchema: {
661
+ ...canvasArg,
662
+ frame: z.string().describe("Frame name or id."),
663
+ theme: z.enum(["light", "dark"]).optional().describe("Override for frames that inherit."),
664
+ scale: z.number().min(0.5).max(2).optional().describe("Device pixel ratio, default 1.")
665
+ },
666
+ annotations: { readOnlyHint: true }
667
+ },
668
+ async ({ canvas, frame, theme, scale }) => {
669
+ try {
670
+ const c2 = resolveCanvas(canvas);
671
+ const f = ws.findFrameOf(c2, frame);
672
+ presence(c2, "looking", "Taking a screenshot", [], f.frameName);
673
+ const device = findDevice(f.device);
674
+ const png = await shots.frame({ canvas: c2, frame: f.frameName, width: f.width, height: f.height, theme: f.theme ?? theme ?? "light", scale, mobile: device ? device.kind !== "desktop" : false });
675
+ return { content: [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }, { type: "text", text: `Frame "${f.frameName}" ${f.width}\xD7${f.height ?? "hug"}` }] };
676
+ } catch (e) {
677
+ return fail(e);
678
+ }
679
+ }
680
+ );
681
+ server.registerTool(
682
+ "focus",
683
+ {
684
+ title: "Show in editor",
685
+ description: "Select and zoom to nodes or frames in the user's editor, to show them what you did.",
686
+ inputSchema: { ...canvasArg, ids: z.array(z.string()).min(1) },
687
+ annotations: { readOnlyHint: true }
688
+ },
689
+ async ({ canvas, ids }) => {
690
+ const a = actor();
691
+ const c2 = resolveCanvas(canvas);
692
+ presence(c2, "looking", "Look here", ids);
693
+ ws.emit({ type: "focus", canvas: c2, ids, actor: a.kind === "agent" ? a.name : "agent" });
694
+ return text("\u2713 Shown in the editor.");
695
+ }
696
+ );
697
+ server.registerTool(
698
+ "undo",
699
+ { title: "Undo", description: "Undo the last change on a canvas (yours or the user's).", inputSchema: { ...canvasArg } },
700
+ async ({ canvas }) => {
701
+ try {
702
+ return text(`\u2713 ${(await ws.undo(resolveCanvas(canvas), actor())).label}`);
703
+ } catch (e) {
704
+ return fail(e);
705
+ }
706
+ }
707
+ );
708
+ server.registerTool(
709
+ "redo",
710
+ { title: "Redo", description: "Redo the last undone change.", inputSchema: { ...canvasArg } },
711
+ async ({ canvas }) => {
712
+ try {
713
+ return text(`\u2713 ${(await ws.redo(resolveCanvas(canvas), actor())).label}`);
714
+ } catch (e) {
715
+ return fail(e);
716
+ }
717
+ }
718
+ );
719
+ return server;
720
+ }
721
+
722
+ // src/server/thumbnails.ts
723
+ import crypto from "crypto";
724
+ import fs from "fs";
725
+ import path from "path";
726
+ var Thumbnails = class _Thumbnails {
727
+ constructor(ws, shots) {
728
+ this.ws = ws;
729
+ this.shots = shots;
730
+ this.dir = path.join(ws.config.root, "node_modules", ".cache", "truecanvas", "thumbs");
731
+ }
732
+ ws;
733
+ shots;
734
+ queue = Promise.resolve();
735
+ dir;
736
+ /** Props a component gets in its thumbnail: defaults, required placeholders, a label, or a preset. */
737
+ static previewProps(spec, preset) {
738
+ const props = {};
739
+ for (const p2 of spec.props) {
740
+ if (p2.optional || p2.default !== void 0 || p2.name === "children") continue;
741
+ if (p2.type === "string") props[p2.name] = p2.name === "title" || p2.name === "label" ? spec.name : p2.name;
742
+ else if (p2.type === "enum") props[p2.name] = p2.options[0];
743
+ else if (p2.type === "boolean") props[p2.name] = false;
744
+ else if (p2.type === "number") props[p2.name] = 0;
745
+ }
746
+ const children = spec.props.find((p2) => p2.name === "children");
747
+ if (spec.acceptsChildren && children && children.default === void 0) props.children = spec.name;
748
+ if (spec.library && !spec.acceptsChildren) Object.assign(props, { width: 320, height: 200 });
749
+ const p = preset ? spec.presets?.find((x) => x.name === preset) : void 0;
750
+ if (p) Object.assign(props, p.props);
751
+ return props;
752
+ }
753
+ key(spec, props) {
754
+ let stamp = "";
755
+ try {
756
+ const file = spec.library ? path.join(this.ws.config.root, "node_modules", spec.library, "package.json") : path.join(this.ws.config.root, spec.file);
757
+ stamp = String(fs.statSync(file).mtimeMs);
758
+ } catch {
759
+ stamp = "0";
760
+ }
761
+ return crypto.createHash("sha1").update(JSON.stringify({ v: 1, name: spec.name, props, stamp })).digest("hex").slice(0, 16);
762
+ }
763
+ async get(name, preset) {
764
+ await this.ws.catalog.load();
765
+ const spec = this.ws.catalog.get(name);
766
+ if (!spec) throw new Error(`Unknown component ${name}`);
767
+ const props = _Thumbnails.previewProps(spec, preset);
768
+ const file = path.join(this.dir, `${name}-${this.key(spec, props)}.png`);
769
+ if (fs.existsSync(file)) return fs.readFileSync(file);
770
+ const job = this.queue.then(async () => {
771
+ if (fs.existsSync(file)) return fs.readFileSync(file);
772
+ const url = `${this.ws.config.appUrl}/truecanvas/__preview?component=${encodeURIComponent(name)}&props=${encodeURIComponent(JSON.stringify(props))}`;
773
+ const png = await this.shots.element(url, { width: 640, height: 480 });
774
+ fs.mkdirSync(this.dir, { recursive: true });
775
+ fs.writeFileSync(file, png);
776
+ return png;
777
+ });
778
+ this.queue = job.catch(() => void 0);
779
+ return job;
780
+ }
781
+ };
782
+
783
+ // src/server/screenshot.ts
784
+ import fs2 from "fs";
785
+ import os from "os";
786
+ import path2 from "path";
787
+ function findChromium() {
788
+ if (process.env.TRUECANVAS_CHROME) return process.env.TRUECANVAS_CHROME;
789
+ const cache2 = path2.join(os.homedir(), ".cache", "ms-playwright");
790
+ if (fs2.existsSync(cache2)) {
791
+ const shells = fs2.readdirSync(cache2).filter((d) => d.startsWith("chromium_headless_shell-")).sort().reverse();
792
+ for (const d of shells) {
793
+ 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"]) {
794
+ const p = path2.join(cache2, d, sub);
795
+ if (fs2.existsSync(p)) return p;
796
+ }
797
+ }
798
+ }
799
+ for (const p of [
800
+ "/usr/bin/chromium-browser",
801
+ "/usr/bin/chromium",
802
+ "/usr/bin/google-chrome-stable",
803
+ "/usr/bin/google-chrome",
804
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
805
+ "/Applications/Chromium.app/Contents/MacOS/Chromium"
806
+ ]) {
807
+ if (fs2.existsSync(p)) return p;
808
+ }
809
+ return void 0;
810
+ }
811
+ var Screenshotter = class {
812
+ constructor(appUrl) {
813
+ this.appUrl = appUrl;
814
+ }
815
+ appUrl;
816
+ browser = null;
817
+ idle = null;
818
+ executable() {
819
+ return findChromium();
820
+ }
821
+ async getBrowser() {
822
+ if (!this.browser) {
823
+ const { chromium } = await import("playwright-core");
824
+ const executablePath = this.executable();
825
+ if (!executablePath) throw new Error("No Chromium found for screenshots. Install Chrome/Chromium or set TRUECANVAS_CHROME.");
826
+ this.browser = chromium.launch({ executablePath, headless: true, args: ["--disable-gpu", "--disable-dev-shm-usage"] });
827
+ this.browser.catch(() => this.browser = null);
828
+ }
829
+ return this.browser;
830
+ }
831
+ touch() {
832
+ if (this.idle) clearTimeout(this.idle);
833
+ this.idle = setTimeout(() => void this.close(), 6e4);
834
+ }
835
+ async close() {
836
+ const b = this.browser;
837
+ this.browser = null;
838
+ if (b) await (await b).close().catch(() => {
839
+ });
840
+ }
841
+ /** Screenshot of the [data-tc-frame] element of a page (Assets thumbnails). */
842
+ async element(url, viewport) {
843
+ const browser = await this.getBrowser();
844
+ this.touch();
845
+ const ctx = await browser.newContext({ viewport, deviceScaleFactor: 2, colorScheme: "light" });
846
+ try {
847
+ const page = await ctx.newPage();
848
+ await page.goto(`${url}${url.includes("?") ? "&" : "?"}still=1`, { waitUntil: "load", timeout: 3e4 });
849
+ const el = await page.waitForSelector("[data-tc-frame]", { timeout: 2e4 });
850
+ await page.evaluate(() => document.fonts.ready.then(() => new Promise((r) => setTimeout(r, 250))));
851
+ await page.waitForFunction(() => window.__tcStill === true, void 0, { timeout: 6e3 }).catch(() => {
852
+ });
853
+ return await el.screenshot({ type: "png", omitBackground: true });
854
+ } finally {
855
+ await ctx.close();
856
+ this.touch();
857
+ }
858
+ }
859
+ async frame(opts) {
860
+ const browser = await this.getBrowser();
861
+ this.touch();
862
+ const ctx = await browser.newContext({
863
+ viewport: { width: opts.width, height: opts.height ?? 800 },
864
+ deviceScaleFactor: opts.scale ?? 1,
865
+ colorScheme: opts.theme === "dark" ? "dark" : "light",
866
+ // device frames: touch input, (hover: none) and mobile viewport rules
867
+ isMobile: !!opts.mobile,
868
+ hasTouch: !!opts.mobile
869
+ });
870
+ try {
871
+ const page = await ctx.newPage();
872
+ const url = `${this.appUrl}/truecanvas/${encodeURIComponent(opts.canvas)}?frame=${encodeURIComponent(opts.frame)}&theme=${opts.theme}&still=1`;
873
+ await page.goto(url, { waitUntil: "load", timeout: 3e4 });
874
+ await page.waitForSelector("[data-tc-frame]", { timeout: 2e4 });
875
+ if (!opts.height) {
876
+ const h = await page.evaluate(() => Math.ceil(document.querySelector("[data-tc-frame]").getBoundingClientRect().height));
877
+ await page.setViewportSize({ width: opts.width, height: Math.min(Math.max(h, 100), opts.maxHeight ?? 16e3) });
878
+ }
879
+ await page.waitForFunction(() => window.__tcStill === true, void 0, { timeout: 6e3 }).catch(() => {
880
+ });
881
+ await page.evaluate(() => document.fonts.ready.then(() => new Promise((r) => setTimeout(r, 50))));
882
+ await page.waitForTimeout(150);
883
+ const format = opts.jpeg ? { type: "jpeg", quality: 78 } : { type: "png" };
884
+ return await page.screenshot(format);
885
+ } finally {
886
+ await ctx.close();
887
+ this.touch();
888
+ }
889
+ }
890
+ };
891
+
892
+ // src/server/review.ts
893
+ import fs3 from "fs";
894
+ import path3 from "path";
895
+ import { createHash } from "crypto";
896
+ var actorName = (e) => e.actor.kind === "agent" ? e.actor.name : e.actor.kind === "file" ? "code editor" : "you";
897
+ async function versionAt(ws, canvas, ref) {
898
+ const rel = ws.relFile(canvas);
899
+ const source = await ws.git.show(ref, rel);
900
+ const files = {};
901
+ if (source !== null) {
902
+ for (const f of parseCanvas(canvas, rel, source).frames) {
903
+ if (!f.page) continue;
904
+ for (const file of [f.page, ...layoutFiles(ws.config, f.page)]) files[file] = await ws.git.show(ref, file);
905
+ }
906
+ }
907
+ return { source, files };
908
+ }
909
+ var readNow = (ws, file) => {
910
+ try {
911
+ return fs3.readFileSync(path3.join(ws.config.root, file), "utf8");
912
+ } catch {
913
+ return null;
914
+ }
915
+ };
916
+ async function reviewPage(ws, canvas, ref, since) {
917
+ const rel = ws.relFile(canvas);
918
+ const exists = fs3.existsSync(ws.file(canvas));
919
+ const old = await versionAt(ws, canvas, ref);
920
+ if (!exists && old.source === null) return null;
921
+ const now = exists ? ws.read(canvas) : null;
922
+ const files = [];
923
+ if (old.source !== now) files.push(rel);
924
+ const status = {};
925
+ if (old.source === null) {
926
+ for (const f of parseCanvas(canvas, rel, now).frames) status[f.frameName] = "added";
927
+ } else if (now === null) {
928
+ for (const f of parseCanvas(canvas, rel, old.source).frames) status[f.frameName] = "removed";
929
+ } else {
930
+ for (const [name, s] of Object.entries(frameChanges(old.source, now))) if (s !== "same") status[name] = s;
931
+ const linkedNow = ws.doc(canvas).frames.filter((f) => f.page);
932
+ for (const f of linkedNow) {
933
+ for (const file of [f.page, ...layoutFiles(ws.config, f.page)]) {
934
+ const before = file in old.files ? old.files[file] : await ws.git.show(ref, file);
935
+ if (before !== readNow(ws, file)) {
936
+ status[f.frameName] ??= "changed";
937
+ if (!files.includes(file)) files.push(file);
938
+ }
939
+ }
940
+ }
941
+ }
942
+ const comments = path3.join(ws.config.canvasDir, `${canvas}.comments.json`);
943
+ if (await ws.git.show(ref, comments) !== readNow(ws, comments)) files.push(comments);
944
+ if (!files.length) return null;
945
+ const history = exists ? ws.changesSince(canvas, since) : [];
946
+ const frames = Object.entries(status).map(([name, s]) => ({
947
+ name,
948
+ status: s,
949
+ changes: history.filter((e) => e.frames?.includes(name)).map((e) => ({ label: e.label, actor: actorName(e), at: e.at }))
950
+ }));
951
+ const loose = history.filter((e) => !e.frames?.length || !e.frames.some((n) => n in status));
952
+ if (loose.length && frames.length) frames[0].changes.push(...loose.map((e) => ({ label: e.label, actor: actorName(e), at: e.at })));
953
+ for (const f of frames) f.changes.sort((a, b) => a.at - b.at);
954
+ return { canvas, status: old.source === null ? "added" : now === null ? "deleted" : "modified", frames, files };
955
+ }
956
+ async function reviewAll(ws) {
957
+ const status = await ws.git.status();
958
+ if (!status.repo) return [];
959
+ const since = await ws.git.lastCommitAt();
960
+ const ref = status.unborn ? "__none__" : "HEAD";
961
+ const names = new Set(ws.canvases());
962
+ for (const f of status.files) {
963
+ const m = new RegExp(`^${ws.config.canvasDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/(.+)\\.canvas\\.tsx$`).exec(f.path);
964
+ if (m) names.add(m[1]);
965
+ }
966
+ const changed = new Set(status.files.map((f) => f.path));
967
+ const linked = new Set(ws.linkedFiles());
968
+ const out = [];
969
+ for (const canvas of names) {
970
+ const own = [ws.relFile(canvas), path3.join(ws.config.canvasDir, `${canvas}.comments.json`)];
971
+ const touchesLinked = [...linked].some((f) => changed.has(f));
972
+ if (!own.some((f) => changed.has(f)) && !touchesLinked) continue;
973
+ const page = await reviewPage(ws, canvas, ref, since).catch(() => null);
974
+ if (page) out.push(page);
975
+ }
976
+ return out;
977
+ }
978
+ var cache = /* @__PURE__ */ new Map();
979
+ var queue = Promise.resolve();
980
+ function renderFrame(ws, shots, canvas, frame, side, ref) {
981
+ const job = queue.then(() => render(ws, shots, canvas, frame, side, ref));
982
+ queue = job.catch(() => void 0);
983
+ return job;
984
+ }
985
+ async function render(ws, shots, canvas, frame, side, ref) {
986
+ let source;
987
+ let files = {};
988
+ if (side === "after") source = fs3.existsSync(ws.file(canvas)) ? ws.read(canvas) : null;
989
+ else {
990
+ const old = await versionAt(ws, canvas, ref);
991
+ source = old.source;
992
+ for (const [f2, content] of Object.entries(old.files)) if (content !== null && content !== readNow(ws, f2)) files[f2] = content;
993
+ }
994
+ if (source === null) return null;
995
+ const doc = parseCanvas(canvas, ws.relFile(canvas), source);
996
+ const f = doc.frames.find((x) => x.frameName === frame);
997
+ if (!f) return null;
998
+ const linkedNow = f.page ? [f.page, ...layoutFiles(ws.config, f.page)].map((x) => readNow(ws, x) ?? "").join("\0") : "";
999
+ const key = createHash("sha1").update([canvas, frame, side, source, JSON.stringify(files), side === "after" ? linkedNow : ""].join("\0")).digest("hex");
1000
+ const hit = cache.get(key);
1001
+ if (hit) return hit;
1002
+ let name = canvas;
1003
+ const snapshot = `${canvas}.${side === "before" ? "base" : "now"}`;
1004
+ if (side === "before" || !fs3.existsSync(ws.file(canvas))) name = writeCompare(ws.config, canvas, source, files, snapshot);
1005
+ try {
1006
+ const device = findDevice(f.device);
1007
+ 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 });
1008
+ if (cache.size > 60) cache.delete(cache.keys().next().value);
1009
+ cache.set(key, png);
1010
+ return png;
1011
+ } finally {
1012
+ if (name !== canvas) clearCompare(ws.config, snapshot);
1013
+ }
1014
+ }
1015
+ function prDesignSection(pages, images) {
1016
+ if (!pages.length) return "";
1017
+ const lines = ["## Design changes", "", "_Made in Truecanvas._", ""];
1018
+ for (const page of pages) {
1019
+ for (const f of page.frames) {
1020
+ lines.push(`### ${f.name} \`${page.canvas}\`${f.status === "added" ? " \xB7 new" : f.status === "removed" ? " \xB7 removed" : ""}`, "");
1021
+ const img = images.find((i) => i.canvas === page.canvas && i.frame === f.name);
1022
+ if (img && (img.before || img.after)) {
1023
+ if (img.before && img.after) lines.push("| Before | After |", "| --- | --- |", `| <img src="${img.before}" width="420"> | <img src="${img.after}" width="420"> |`, "");
1024
+ else lines.push(`<img src="${img.after ?? img.before}" width="420">`, "");
1025
+ }
1026
+ const seen = /* @__PURE__ */ new Set();
1027
+ for (const c2 of f.changes) {
1028
+ if (seen.has(c2.label)) continue;
1029
+ seen.add(c2.label);
1030
+ lines.push(`- ${c2.label}${c2.actor !== "you" ? ` _(${c2.actor})_` : ""}`);
1031
+ }
1032
+ if (f.changes.length) lines.push("");
1033
+ }
1034
+ }
1035
+ return lines.join("\n");
1036
+ }
1037
+
1038
+ // src/server/http.ts
1039
+ var MIME = {
1040
+ ".html": "text/html; charset=utf-8",
1041
+ ".js": "text/javascript; charset=utf-8",
1042
+ ".css": "text/css; charset=utf-8",
1043
+ ".svg": "image/svg+xml",
1044
+ ".woff2": "font/woff2",
1045
+ ".png": "image/png",
1046
+ ".json": "application/json"
1047
+ };
1048
+ async function startServer(ws) {
1049
+ const { config } = ws;
1050
+ const editorDir = fileURLToPath(new URL("./editor/", import.meta.url));
1051
+ const shots = new Screenshotter(config.appUrl);
1052
+ let prCache = null;
1053
+ const thumbs = new Thumbnails(ws, shots);
1054
+ const syncRegistry = () => ws.catalog.load().then((list) => syncComponentRegistry(config, list)).catch(() => {
1055
+ });
1056
+ void syncRegistry();
1057
+ const origin = `http://localhost:${config.port}`;
1058
+ const allowedHosts = /* @__PURE__ */ new Set([`localhost:${config.port}`, `127.0.0.1:${config.port}`]);
1059
+ const allowedOrigins = /* @__PURE__ */ new Set([origin, `http://127.0.0.1:${config.port}`]);
1060
+ const clients = /* @__PURE__ */ new Set();
1061
+ const broadcast = (e) => {
1062
+ const data2 = `data: ${JSON.stringify(e)}
1063
+
1064
+ `;
1065
+ for (const res of clients) res.write(data2);
1066
+ };
1067
+ ws.on(broadcast);
1068
+ setInterval(() => {
1069
+ for (const res of clients) res.write(": ping\n\n");
1070
+ ws.pruneAgents();
1071
+ }, 2e4).unref();
1072
+ const transports = /* @__PURE__ */ new Map();
1073
+ const lastUse = /* @__PURE__ */ new Map();
1074
+ setInterval(() => {
1075
+ for (const [id, at] of lastUse) {
1076
+ if (Date.now() - at < 60 * 6e4) continue;
1077
+ lastUse.delete(id);
1078
+ void transports.get(id)?.close();
1079
+ }
1080
+ }, 5 * 6e4).unref();
1081
+ async function handleMcp(req, res) {
1082
+ const sessionId = req.headers["mcp-session-id"];
1083
+ const body = req.method === "POST" ? await readJson(req) : void 0;
1084
+ let transport = sessionId ? transports.get(sessionId) : void 0;
1085
+ if (req.method === "DELETE" && sessionId) ws.dropAgent(sessionId);
1086
+ if (sessionId && transport) lastUse.set(sessionId, Date.now());
1087
+ if (!transport) {
1088
+ if (req.method !== "POST" || !isInitializeRequest(body)) {
1089
+ return json(res, 400, { jsonrpc: "2.0", error: { code: -32e3, message: "No valid MCP session. Re-initialize." }, id: null });
1090
+ }
1091
+ let sid = "";
1092
+ const t = new StreamableHTTPServerTransport({
1093
+ sessionIdGenerator: () => randomUUID(),
1094
+ onsessioninitialized: (id) => {
1095
+ sid = id;
1096
+ transports.set(id, t);
1097
+ }
1098
+ });
1099
+ t.onclose = () => {
1100
+ if (sid) {
1101
+ transports.delete(sid);
1102
+ lastUse.delete(sid);
1103
+ ws.dropAgent(sid);
1104
+ }
1105
+ };
1106
+ const server2 = createMcpServer(ws, shots, () => sid);
1107
+ await server2.connect(t);
1108
+ transport = t;
1109
+ }
1110
+ await transport.handleRequest(req, res, body);
1111
+ }
1112
+ async function handleApi(req, res, url) {
1113
+ const route = `${req.method} ${url.pathname}`;
1114
+ const user = { kind: "user" };
1115
+ switch (route) {
1116
+ case "GET /api/state":
1117
+ return json(res, 200, {
1118
+ appUrl: config.appUrl,
1119
+ root: config.root,
1120
+ projectName: path4.basename(config.root),
1121
+ canvases: ws.canvases(),
1122
+ darkMode: config.darkMode,
1123
+ agents: [...ws.agents.values()],
1124
+ feed: ws.publicFeed().slice(-100),
1125
+ mcpUrl: `${origin}/mcp`,
1126
+ componentsDir: componentsDir(config)
1127
+ });
1128
+ case "GET /api/canvas": {
1129
+ const name = url.searchParams.get("name");
1130
+ return json(res, 200, { doc: ws.doc(name), history: ws.historyState(name) });
1131
+ }
1132
+ case "GET /api/thumb": {
1133
+ const name = url.searchParams.get("component") ?? "";
1134
+ const preset = url.searchParams.get("preset") ?? void 0;
1135
+ const png = await thumbs.get(name, preset);
1136
+ res.writeHead(200, { "content-type": "image/png", "cache-control": "private, max-age=30" });
1137
+ return res.end(png);
1138
+ }
1139
+ // ---------- git ----------
1140
+ case "GET /api/git/status":
1141
+ return json(res, 200, { ...await ws.git.status(), linked: ws.designFiles() });
1142
+ case "GET /api/git/branches":
1143
+ return json(res, 200, await ws.git.branches());
1144
+ case "GET /api/git/log": {
1145
+ const canvas = url.searchParams.get("canvas");
1146
+ return json(res, 200, { commits: await ws.git.log(canvas ? [ws.relFile(canvas), ws.comments.file(canvas)] : [config.canvasDir]) });
1147
+ }
1148
+ case "POST /api/git/fetch":
1149
+ await ws.git.fetch();
1150
+ return json(res, 200, { ...await ws.git.status(), linked: ws.designFiles() });
1151
+ case "POST /api/git/commit": {
1152
+ const { message, scope } = await readJson(req);
1153
+ const status = await ws.git.status();
1154
+ const linked = new Set(ws.designFiles());
1155
+ const files = status.files.map((f) => f.path).filter((f) => scope === "all" || f.startsWith(`${config.canvasDir}/`) || linked.has(f));
1156
+ const hash = await ws.git.commit(message, files);
1157
+ ws.emit({ type: "git" });
1158
+ return json(res, 200, { hash });
1159
+ }
1160
+ case "POST /api/git/push":
1161
+ await ws.git.push();
1162
+ ws.emit({ type: "git" });
1163
+ return json(res, 200, { ok: true });
1164
+ case "POST /api/git/pull":
1165
+ await ws.git.pull();
1166
+ ws.emit({ type: "git" });
1167
+ return json(res, 200, { ok: true });
1168
+ case "POST /api/git/switch": {
1169
+ const { branch, create } = await readJson(req);
1170
+ await ws.git.switch(branch, create);
1171
+ ws.emit({ type: "git" });
1172
+ return json(res, 200, { ok: true });
1173
+ }
1174
+ // ---------- review & pull requests ----------
1175
+ case "GET /api/git/changes":
1176
+ return json(res, 200, { pages: await reviewAll(ws) });
1177
+ case "GET /api/git/preview": {
1178
+ const canvas = url.searchParams.get("canvas") ?? "";
1179
+ const frame = url.searchParams.get("frame") ?? "";
1180
+ const side = url.searchParams.get("side") === "before" ? "before" : "after";
1181
+ const st = await ws.git.status();
1182
+ const img = await renderFrame(ws, shots, canvas, frame, side, st.unborn ? "__none__" : "HEAD");
1183
+ if (!img) return json(res, 404, { error: "No such frame in that version." });
1184
+ res.writeHead(200, { "content-type": "image/jpeg", "cache-control": "no-store" });
1185
+ return res.end(img);
1186
+ }
1187
+ case "GET /api/git/diff": {
1188
+ const canvas = url.searchParams.get("canvas") ?? "";
1189
+ const page = await reviewPage(ws, canvas, "HEAD", 0);
1190
+ return json(res, 200, { diff: page ? await ws.git.diff(page.files) : "" });
1191
+ }
1192
+ case "GET /api/git/pr": {
1193
+ const st = await ws.git.status();
1194
+ if (!st.remote || !st.branch) return json(res, 200, { pr: null, github: false });
1195
+ const key = `${st.branch}`;
1196
+ if (url.searchParams.get("fresh") || !prCache || prCache.key !== key || Date.now() - prCache.at > 2e4) {
1197
+ prCache = { key, at: Date.now(), pr: await ws.git.pullRequest().catch(() => null) };
1198
+ }
1199
+ return json(res, 200, { pr: prCache.pr, github: /github\.com/.test(st.remote) });
1200
+ }
1201
+ case "POST /api/git/pr": {
1202
+ const { title, body, screenshots, draft } = await readJson(req);
1203
+ if (!title?.trim()) throw new EditError("Give the pull request a title.");
1204
+ const st = await ws.git.status();
1205
+ if (!st.remote) throw new EditError("This project has no GitHub remote yet. Publish it to GitHub first.");
1206
+ const base = st.defaultBranch ?? "main";
1207
+ if (!st.branch || st.branch === base) throw new EditError(`You're on ${base}. Start a branch for your changes first.`);
1208
+ await ws.git.push();
1209
+ if (!/github\.com/.test(st.remote)) throw new EditError(`Pushed ${st.branch}. Opening pull requests from Truecanvas needs a GitHub remote (origin is ${st.remote}); open it on your git host.`);
1210
+ const fork = await ws.git.mergeBase(base);
1211
+ const pages = [];
1212
+ if (fork) {
1213
+ const since = await ws.git.commitAt(fork);
1214
+ for (const canvas of ws.canvases()) {
1215
+ const page = await reviewPage(ws, canvas, fork, since).catch(() => null);
1216
+ if (page) pages.push(page);
1217
+ }
1218
+ }
1219
+ const images = [];
1220
+ if (screenshots && fork && pages.length) {
1221
+ const repo = await ws.git.githubRepo();
1222
+ const files = [];
1223
+ const targets = pages.flatMap((p) => p.frames.filter((f) => f.status !== "removed").map((f) => ({ canvas: p.canvas, frame: f.name, added: f.status === "added" }))).slice(0, 6);
1224
+ const stamp = Date.now().toString(36);
1225
+ const slug = (v) => v.replace(/[^\w.-]+/g, "-");
1226
+ for (const t of targets) {
1227
+ const img = { canvas: t.canvas, frame: t.frame, before: null, after: null };
1228
+ for (const side of ["before", "after"]) {
1229
+ if (side === "before" && t.added) continue;
1230
+ const data2 = await renderFrame(ws, shots, t.canvas, t.frame, side, fork).catch(() => null);
1231
+ if (!data2) continue;
1232
+ const file = `${slug(st.branch)}/${stamp}/${slug(t.canvas)}-${slug(t.frame)}-${side}.jpg`;
1233
+ files.push({ path: file, data: data2 });
1234
+ if (repo) img[side] = `https://github.com/${repo}/blob/truecanvas-previews/${file.split("/").map(encodeURIComponent).join("/")}?raw=true`;
1235
+ }
1236
+ images.push(img);
1237
+ }
1238
+ if (files.length && repo) await ws.git.commitToSideBranch("truecanvas-previews", files, `Previews for ${st.branch}`);
1239
+ }
1240
+ const fullBody = [body?.trim(), prDesignSection(pages, images)].filter(Boolean).join("\n\n");
1241
+ let prUrl;
1242
+ try {
1243
+ prUrl = await ws.git.createPullRequest({ title: title.trim(), body: fullBody, base, draft });
1244
+ } catch (err) {
1245
+ const existing = /https:\/\/github\.com\/\S+\/pull\/\d+/.exec(err.message);
1246
+ if (!existing) throw err;
1247
+ prUrl = existing[0];
1248
+ }
1249
+ prCache = null;
1250
+ ws.emit({ type: "git" });
1251
+ return json(res, 200, { url: prUrl });
1252
+ }
1253
+ case "POST /api/git/publish": {
1254
+ const { name, private: isPrivate } = await readJson(req);
1255
+ const remote = await ws.git.publish(name, isPrivate !== false);
1256
+ ws.emit({ type: "git" });
1257
+ return json(res, 200, { remote });
1258
+ }
1259
+ case "POST /api/compare": {
1260
+ const { canvas, ref } = await readJson(req);
1261
+ const before = await ws.git.show(ref, ws.relFile(canvas));
1262
+ if (before === null) return json(res, 400, { error: `This page doesn't exist on ${ref}.` });
1263
+ const files = {};
1264
+ const touched = /* @__PURE__ */ new Set();
1265
+ for (const f of parseCanvas(canvas, ws.relFile(canvas), before).frames) {
1266
+ if (!f.page) continue;
1267
+ for (const file of [f.page, ...layoutFiles(config, f.page)]) {
1268
+ const old = await ws.git.show(ref, file);
1269
+ if (old === null) continue;
1270
+ const now = fs4.existsSync(path4.join(config.root, file)) ? fs4.readFileSync(path4.join(config.root, file), "utf8") : null;
1271
+ if (old !== now) {
1272
+ files[file] = old;
1273
+ touched.add(f.frameName);
1274
+ }
1275
+ }
1276
+ }
1277
+ const name = writeCompare(config, canvas, before, files);
1278
+ const changes = frameChanges(before, ws.read(canvas));
1279
+ for (const frame of touched) if (changes[frame] === "same") changes[frame] = "changed";
1280
+ return json(res, 200, { name, doc: parseCanvas(name, ws.relFile(canvas), before), changes });
1281
+ }
1282
+ case "POST /api/compare/clear": {
1283
+ const { canvas } = await readJson(req);
1284
+ clearCompare(config, canvas);
1285
+ return json(res, 200, { ok: true });
1286
+ }
1287
+ // ---------- comments ----------
1288
+ case "GET /api/comments":
1289
+ return json(res, 200, { threads: ws.comments.list(url.searchParams.get("canvas") ?? "") });
1290
+ case "POST /api/comments": {
1291
+ const b = await readJson(req);
1292
+ if (!b.text?.trim()) return json(res, 400, { error: "Write a comment." });
1293
+ const thread = ws.comments.add(b.canvas, { ...b, author: await ws.userAuthor() });
1294
+ ws.commentsChanged(b.canvas);
1295
+ return json(res, 200, { thread });
1296
+ }
1297
+ case "POST /api/comments/reply": {
1298
+ const b = await readJson(req);
1299
+ if (!b.text?.trim()) return json(res, 400, { error: "Write a reply." });
1300
+ const thread = ws.comments.reply(b.canvas, b.id, b.text, await ws.userAuthor());
1301
+ ws.commentsChanged(b.canvas);
1302
+ return json(res, 200, { thread });
1303
+ }
1304
+ case "POST /api/comments/resolve": {
1305
+ const b = await readJson(req);
1306
+ const thread = ws.comments.resolve(b.canvas, b.id, b.resolved, await ws.userAuthor());
1307
+ ws.commentsChanged(b.canvas);
1308
+ return json(res, 200, { thread });
1309
+ }
1310
+ case "POST /api/comments/delete": {
1311
+ const b = await readJson(req);
1312
+ ws.comments.remove(b.canvas, b.id);
1313
+ ws.commentsChanged(b.canvas);
1314
+ return json(res, 200, { ok: true });
1315
+ }
1316
+ case "GET /api/routes":
1317
+ return json(res, 200, { routes: listRoutes(config) });
1318
+ case "GET /api/tokens":
1319
+ return json(res, 200, readTokenData(config.root));
1320
+ case "GET /api/components":
1321
+ return json(res, 200, { components: await ws.catalog.load() });
1322
+ case "GET /api/events": {
1323
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
1324
+ res.write(": connected\n\n");
1325
+ clients.add(res);
1326
+ req.on("close", () => clients.delete(res));
1327
+ return;
1328
+ }
1329
+ case "POST /api/command": {
1330
+ const cmd = await readJson(req);
1331
+ const out = await ws.run(cmd, user);
1332
+ return json(res, 200, { doc: out.doc, ids: out.ids, label: out.label });
1333
+ }
1334
+ case "POST /api/undo":
1335
+ case "POST /api/redo": {
1336
+ const { canvas } = await readJson(req);
1337
+ const out = route.endsWith("undo") ? await ws.undo(canvas, user) : await ws.redo(canvas, user);
1338
+ return json(res, 200, out);
1339
+ }
1340
+ case "POST /api/selection": {
1341
+ const sel = await readJson(req);
1342
+ ws.selection = { canvas: sel.canvas, ids: sel.ids ?? [] };
1343
+ return json(res, 200, { ok: true });
1344
+ }
1345
+ case "GET /api/canvases":
1346
+ return json(res, 200, { canvases: ws.canvasInfos() });
1347
+ case "POST /api/canvases": {
1348
+ const { name } = await readJson(req);
1349
+ ws.createCanvas(name, user);
1350
+ return json(res, 200, { canvases: ws.canvases() });
1351
+ }
1352
+ case "POST /api/canvases/rename": {
1353
+ const { from, to } = await readJson(req);
1354
+ return json(res, 200, { name: ws.renameCanvas(from, to) });
1355
+ }
1356
+ case "POST /api/canvases/duplicate": {
1357
+ const { name } = await readJson(req);
1358
+ return json(res, 200, { name: ws.duplicateCanvas(name) });
1359
+ }
1360
+ case "POST /api/canvases/delete": {
1361
+ const { name } = await readJson(req);
1362
+ ws.deleteCanvas(name);
1363
+ return json(res, 200, { ok: true });
1364
+ }
1365
+ case "POST /api/canvases/restore": {
1366
+ const { name } = await readJson(req);
1367
+ return json(res, 200, { name: ws.restoreCanvas(name) });
1368
+ }
1369
+ case "POST /api/open": {
1370
+ const { canvas, id } = await readJson(req);
1371
+ const hit = id ? ws.nodeSource(canvas, id) : null;
1372
+ const target = `${hit?.abs ?? ws.file(canvas)}:${hit?.node.line ?? 1}:${(hit?.node.col ?? 0) + 1}`;
1373
+ launchEditor(target, config.editor);
1374
+ return json(res, 200, { ok: true });
1375
+ }
1376
+ case "POST /api/open-file": {
1377
+ const { file } = await readJson(req);
1378
+ const abs = path4.resolve(config.root, file);
1379
+ const rel = path4.relative(config.root, abs);
1380
+ if (rel.startsWith("..") || path4.isAbsolute(rel)) return json(res, 400, { error: "Outside project." });
1381
+ launchEditor(abs, config.editor);
1382
+ return json(res, 200, { ok: true });
1383
+ }
1384
+ case "POST /api/source": {
1385
+ const { canvas, id } = await readJson(req);
1386
+ const hit = ws.nodeSource(canvas, id);
1387
+ if (!hit) return json(res, 404, { error: "Not found" });
1388
+ return json(res, 200, { code: dedent(hit.source.slice(hit.node.start, hit.node.end), hit.node.col), file: hit.rel, line: hit.node.line });
1389
+ }
1390
+ }
1391
+ return json(res, 404, { error: `No route ${route}` });
1392
+ }
1393
+ function serveStatic(res, pathname) {
1394
+ let file = path4.join(editorDir, pathname === "/" ? "index.html" : pathname);
1395
+ if (!file.startsWith(editorDir) || !fs4.existsSync(file) || fs4.statSync(file).isDirectory()) file = path4.join(editorDir, "index.html");
1396
+ if (!fs4.existsSync(file)) {
1397
+ res.writeHead(500, { "content-type": "text/plain" });
1398
+ return res.end("Editor bundle missing. Run `pnpm build` in packages/truecanvas.");
1399
+ }
1400
+ const ext = path4.extname(file);
1401
+ const immutable = pathname.startsWith("/assets/");
1402
+ res.writeHead(200, { "content-type": MIME[ext] ?? "application/octet-stream", "cache-control": immutable ? "public, max-age=31536000, immutable" : "no-cache" });
1403
+ fs4.createReadStream(file).pipe(res);
1404
+ }
1405
+ const server = http.createServer(async (req, res) => {
1406
+ try {
1407
+ if (!allowedHosts.has(req.headers.host ?? "")) return json(res, 403, { error: "Forbidden host" });
1408
+ const reqOrigin = req.headers.origin;
1409
+ if (reqOrigin && !allowedOrigins.has(reqOrigin)) return json(res, 403, { error: "Forbidden origin" });
1410
+ const url = new URL(req.url ?? "/", origin);
1411
+ if (url.pathname === "/mcp") return await handleMcp(req, res);
1412
+ if (url.pathname.startsWith("/api/")) return await handleApi(req, res, url);
1413
+ if (req.method === "GET") return serveStatic(res, url.pathname);
1414
+ json(res, 405, { error: "Method not allowed" });
1415
+ } catch (err) {
1416
+ const status = err instanceof EditError ? 400 : 500;
1417
+ if (status === 500) console.error(err);
1418
+ if (!res.headersSent) json(res, status, { error: err.message });
1419
+ else res.end();
1420
+ }
1421
+ });
1422
+ const canvasDir = path4.join(config.root, config.canvasDir);
1423
+ fs4.mkdirSync(canvasDir, { recursive: true });
1424
+ const pending = /* @__PURE__ */ new Map();
1425
+ fs4.watch(canvasDir, (_event, filename) => {
1426
+ if (!filename) return;
1427
+ if (filename.endsWith(".comments.json")) {
1428
+ ws.commentsChanged(filename.slice(0, -".comments.json".length));
1429
+ return;
1430
+ }
1431
+ clearTimeout(pending.get(filename));
1432
+ pending.set(
1433
+ filename,
1434
+ setTimeout(() => {
1435
+ try {
1436
+ ws.externalChange(filename);
1437
+ } catch (err) {
1438
+ console.error(err);
1439
+ }
1440
+ }, 60)
1441
+ );
1442
+ });
1443
+ const appDirAbs = path4.join(config.root, config.appDir);
1444
+ if (fs4.existsSync(appDirAbs)) {
1445
+ const appPending = /* @__PURE__ */ new Map();
1446
+ fs4.watch(appDirAbs, { recursive: true }, (_event, filename) => {
1447
+ if (!filename || !/(^|[\\/])(page|layout)\.[jt]sx$/.test(filename) || /^truecanvas[\\/]/.test(filename)) return;
1448
+ const rel = path4.join(config.appDir, filename).split(path4.sep).join("/");
1449
+ clearTimeout(appPending.get(rel));
1450
+ appPending.set(
1451
+ rel,
1452
+ setTimeout(() => {
1453
+ try {
1454
+ ws.linkedFileChanged(rel);
1455
+ } catch (err) {
1456
+ console.error(err);
1457
+ }
1458
+ }, 60)
1459
+ );
1460
+ });
1461
+ }
1462
+ let catalogTimer;
1463
+ for (const dir of componentRoots(config.components)) {
1464
+ const abs = path4.join(config.root, dir);
1465
+ if (!fs4.existsSync(abs)) continue;
1466
+ fs4.watch(abs, { recursive: true }, () => {
1467
+ clearTimeout(catalogTimer);
1468
+ catalogTimer = setTimeout(() => {
1469
+ ws.catalog.invalidate();
1470
+ broadcast({ type: "catalog" });
1471
+ void syncRegistry();
1472
+ }, 250);
1473
+ });
1474
+ }
1475
+ await new Promise((resolve, reject) => {
1476
+ server.once("error", reject);
1477
+ server.listen(config.port, "127.0.0.1", resolve);
1478
+ });
1479
+ const shutdown = async () => {
1480
+ await shots.close();
1481
+ for (const t of transports.values()) await t.close().catch(() => {
1482
+ });
1483
+ server.close();
1484
+ };
1485
+ return { server, origin, shutdown };
1486
+ }
1487
+ function componentRoots(globs) {
1488
+ return [...new Set(globs.map((g) => g.split("/").filter((p) => !/[*?{[]/.test(p)).join("/")).filter(Boolean))];
1489
+ }
1490
+ function json(res, status, body) {
1491
+ res.writeHead(status, { "content-type": "application/json" });
1492
+ res.end(JSON.stringify(body));
1493
+ }
1494
+ async function readJson(req) {
1495
+ const chunks = [];
1496
+ let size = 0;
1497
+ for await (const chunk of req) {
1498
+ size += chunk.length;
1499
+ if (size > 8 * 1024 * 1024) throw new EditError("Request too large.");
1500
+ chunks.push(chunk);
1501
+ }
1502
+ const raw = Buffer.concat(chunks).toString("utf8");
1503
+ return raw ? JSON.parse(raw) : {};
1504
+ }
1505
+ function dedent(code, col) {
1506
+ const pad = " ".repeat(col);
1507
+ return code.split("\n").map((l, i) => i > 0 && l.startsWith(pad) ? l.slice(col) : l).join("\n");
1508
+ }
1509
+
1510
+ // src/server/stdio.ts
1511
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
1512
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1513
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
1514
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
1515
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
1516
+ async function runStdioBridge(mcpUrl) {
1517
+ let ready;
1518
+ let failed;
1519
+ const upstream = new Promise((res, rej) => {
1520
+ ready = res;
1521
+ failed = rej;
1522
+ });
1523
+ const server = new Server({ name: "truecanvas", version: "0.1.0" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
1524
+ server.oninitialized = async () => {
1525
+ try {
1526
+ const info = server.getClientVersion();
1527
+ const client = new Client({ name: info?.name ?? "stdio-agent", version: info?.version ?? "0.0.0" });
1528
+ await client.connect(new StreamableHTTPClientTransport(new URL(mcpUrl)));
1529
+ ready(client);
1530
+ } catch (err) {
1531
+ failed(err);
1532
+ }
1533
+ };
1534
+ server.setRequestHandler(ListToolsRequestSchema, async () => (await upstream).listTools());
1535
+ server.setRequestHandler(CallToolRequestSchema, async (req) => (await upstream).callTool(req.params));
1536
+ await server.connect(new StdioServerTransport());
1537
+ }
1538
+
1539
+ // src/core/init.ts
1540
+ import fs5 from "fs";
1541
+ import path5 from "path";
1542
+ var CONFIG_FILES = ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.mts"];
1543
+ function nextConfigStatus(root) {
1544
+ for (const name of CONFIG_FILES) {
1545
+ const file = path5.join(root, name);
1546
+ if (fs5.existsSync(file)) return { file, wrapped: fs5.readFileSync(file, "utf8").includes("withTruecanvas") };
1547
+ }
1548
+ return { file: null, wrapped: false };
1549
+ }
1550
+ function initProject(config) {
1551
+ const { root } = config;
1552
+ const report = { done: [], todo: [] };
1553
+ const status = nextConfigStatus(root);
1554
+ if (!status.file) {
1555
+ const file = path5.join(root, "next.config.ts");
1556
+ fs5.writeFileSync(file, `import type { NextConfig } from "next";
1557
+ import { withTruecanvas } from "truecanvas/next";
1558
+
1559
+ const nextConfig: NextConfig = {};
1560
+
1561
+ export default withTruecanvas(nextConfig);
1562
+ `);
1563
+ report.done.push("Created next.config.ts with withTruecanvas()");
1564
+ } else if (status.wrapped) {
1565
+ report.done.push(`${path5.basename(status.file)} already uses withTruecanvas()`);
1566
+ } else {
1567
+ const src = fs5.readFileSync(status.file, "utf8");
1568
+ const esm = /export\s+default\s+/.test(src);
1569
+ const cjs = /module\.exports\s*=\s*/.test(src);
1570
+ let next = null;
1571
+ if (esm) {
1572
+ next = src.replace(/export\s+default\s+([\s\S]+?);?\s*$/, (_m, expr) => `export default withTruecanvas(${expr.trim().replace(/;$/, "")});
1573
+ `);
1574
+ next = addImport(next, `import { withTruecanvas } from "truecanvas/next";`);
1575
+ } else if (cjs) {
1576
+ next = src.replace(/module\.exports\s*=\s*([\s\S]+?);?\s*$/, (_m, expr) => `module.exports = withTruecanvas(${expr.trim().replace(/;$/, "")});
1577
+ `);
1578
+ next = `const { withTruecanvas } = require("truecanvas/next");
1579
+ ${next}`;
1580
+ }
1581
+ if (next && next !== src && next.includes("withTruecanvas(")) {
1582
+ fs5.writeFileSync(status.file, next);
1583
+ report.done.push(`Wrapped ${path5.basename(status.file)} with withTruecanvas()`);
1584
+ } else {
1585
+ report.todo.push(`Wrap your ${path5.basename(status.file)} export with withTruecanvas() from "truecanvas/next"`);
1586
+ }
1587
+ }
1588
+ const pkgFile = path5.join(root, "package.json");
1589
+ try {
1590
+ const raw = fs5.readFileSync(pkgFile, "utf8");
1591
+ const pkg = JSON.parse(raw);
1592
+ pkg.scripts ??= {};
1593
+ if (!Object.values(pkg.scripts).some((v) => typeof v === "string" && v.includes("truecanvas"))) {
1594
+ pkg.scripts.canvas = "truecanvas dev";
1595
+ const indent = /\n(\s+)"/.exec(raw)?.[1] ?? " ";
1596
+ fs5.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, indent)}
1597
+ `);
1598
+ report.done.push(`Added the "canvas" script (npm run canvas)`);
1599
+ }
1600
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1601
+ if (!("tailwindcss" in deps)) report.todo.push("Tailwind CSS isn't installed: layout and style controls write Tailwind classes");
1602
+ if (!("next" in deps)) report.todo.push("Next.js isn't in package.json: Truecanvas currently needs a Next.js (App Router) app");
1603
+ } catch {
1604
+ report.todo.push("Couldn't read package.json");
1605
+ }
1606
+ const ignoreFile = path5.join(root, ".gitignore");
1607
+ const entry = `/${config.appDir}/truecanvas/`;
1608
+ const ignore = fs5.existsSync(ignoreFile) ? fs5.readFileSync(ignoreFile, "utf8") : "";
1609
+ if (!ignore.split("\n").some((l) => l.trim() === entry || l.trim() === entry.slice(1))) {
1610
+ fs5.writeFileSync(ignoreFile, `${ignore}${ignore && !ignore.endsWith("\n") ? "\n" : ""}
1611
+ # Truecanvas (generated dev-only route)
1612
+ ${entry}
1613
+ `);
1614
+ report.done.push(`Ignored ${entry} in .gitignore`);
1615
+ }
1616
+ for (const name of ["tailwind.config.ts", "tailwind.config.js", "tailwind.config.mjs", "tailwind.config.cjs"]) {
1617
+ const file = path5.join(root, name);
1618
+ if (!fs5.existsSync(file)) continue;
1619
+ const src = fs5.readFileSync(file, "utf8");
1620
+ const glob = `./${config.canvasDir}/**/*.{ts,tsx}`;
1621
+ if (src.includes(config.canvasDir)) break;
1622
+ const m = /content\s*:\s*\[([ \t]*\n?)([ \t]*)/.exec(src);
1623
+ if (m) {
1624
+ const indent = m[2] || " ";
1625
+ const at = m.index + m[0].length;
1626
+ const entry2 = m[1].includes("\n") ? `${JSON.stringify(glob)},
1627
+ ${indent}` : `${JSON.stringify(glob)}, `;
1628
+ fs5.writeFileSync(file, src.slice(0, at) + entry2 + src.slice(at));
1629
+ report.done.push(`Added ${config.canvasDir} to Tailwind's content in ${name}`);
1630
+ } else report.todo.push(`Add "${glob}" to the content list in ${name} so Tailwind styles your canvases`);
1631
+ break;
1632
+ }
1633
+ if (!fs5.existsSync(path5.join(root, config.appDir))) report.todo.push(`No ${config.appDir}/ folder found: Truecanvas needs the Next.js App Router`);
1634
+ return report;
1635
+ }
1636
+ function addImport(src, line) {
1637
+ const lines = src.split("\n");
1638
+ let at = 0;
1639
+ lines.forEach((l, i) => {
1640
+ if (/^\s*import\s/.test(l) || /^\s*["']use (client|server)["']/.test(l)) at = i + 1;
1641
+ });
1642
+ while (at > 0 && at < lines.length && !/;\s*$|from\s+["'][^"']+["']\s*;?\s*$/.test(lines[at - 1])) at++;
1643
+ lines.splice(at, 0, line);
1644
+ return lines.join("\n");
1645
+ }
1646
+
1647
+ // src/hub/server.ts
1648
+ import http2 from "http";
1649
+ import fs9 from "fs";
1650
+ import os5 from "os";
1651
+ import path9 from "path";
1652
+ import { randomUUID as randomUUID2 } from "crypto";
1653
+ import { fileURLToPath as fileURLToPath2 } from "url";
1654
+ import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
1655
+ import { StreamableHTTPServerTransport as StreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
1656
+ import { Client as Client2 } from "@modelcontextprotocol/sdk/client/index.js";
1657
+ import { StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
1658
+ import { CallToolRequestSchema as CallToolRequestSchema2, ListToolsRequestSchema as ListToolsRequestSchema2, isInitializeRequest as isInitializeRequest2 } from "@modelcontextprotocol/sdk/types.js";
1659
+
1660
+ // src/hub/projects.ts
1661
+ import fs6 from "fs";
1662
+ import os2 from "os";
1663
+ import path6 from "path";
1664
+ var CONFIG_DIR = path6.join(process.env.XDG_CONFIG_HOME ?? path6.join(os2.homedir(), ".config"), "truecanvas");
1665
+ var FILE = path6.join(CONFIG_DIR, "projects.json");
1666
+ var ProjectStore = class {
1667
+ list = [];
1668
+ constructor() {
1669
+ try {
1670
+ this.list = JSON.parse(fs6.readFileSync(FILE, "utf8"));
1671
+ } catch {
1672
+ this.list = [];
1673
+ }
1674
+ }
1675
+ save() {
1676
+ fs6.mkdirSync(CONFIG_DIR, { recursive: true });
1677
+ fs6.writeFileSync(FILE, JSON.stringify(this.list, null, 2));
1678
+ }
1679
+ all() {
1680
+ return [...this.list].sort((a, b) => b.lastOpened - a.lastOpened).map((p) => ({ ...p, ...inspect(p.path) }));
1681
+ }
1682
+ touch(dir) {
1683
+ const abs = path6.resolve(dir);
1684
+ const existing = this.list.find((p) => p.path === abs);
1685
+ if (existing) existing.lastOpened = Date.now();
1686
+ else this.list.push({ path: abs, name: projectName(abs), lastOpened: Date.now() });
1687
+ this.save();
1688
+ }
1689
+ forget(dir) {
1690
+ this.list = this.list.filter((p) => p.path !== path6.resolve(dir));
1691
+ this.save();
1692
+ }
1693
+ };
1694
+ function readPkg(dir) {
1695
+ try {
1696
+ return JSON.parse(fs6.readFileSync(path6.join(dir, "package.json"), "utf8"));
1697
+ } catch {
1698
+ return null;
1699
+ }
1700
+ }
1701
+ function projectName(dir) {
1702
+ const pkg = readPkg(dir);
1703
+ const name = typeof pkg?.name === "string" ? pkg.name : "";
1704
+ return name && !name.startsWith("@") ? name : path6.basename(dir);
1705
+ }
1706
+ function inspect(dir) {
1707
+ const pkg = readPkg(dir);
1708
+ if (!pkg) return { next: false, ready: false, canvases: 0, exists: fs6.existsSync(dir) };
1709
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1710
+ let canvases = 0;
1711
+ for (const d of ["canvas", "src/canvas"]) {
1712
+ try {
1713
+ canvases += fs6.readdirSync(path6.join(dir, d)).filter((f) => f.endsWith(".canvas.tsx")).length;
1714
+ } catch {
1715
+ }
1716
+ }
1717
+ return { next: "next" in deps, ready: "truecanvas" in deps, canvases, exists: true };
1718
+ }
1719
+ function discover() {
1720
+ const home = os2.homedir();
1721
+ const roots = ["Github", "GitHub", "github", "Projects", "projects", "code", "Code", "dev", "src", "Developer", "Documents", "work"].map((d) => path6.join(home, d)).filter((d) => fs6.existsSync(d));
1722
+ const found = [];
1723
+ let visited = 0;
1724
+ const walk = (dir, depth) => {
1725
+ if (depth > 3 || visited > 3e3 || found.length > 60) return;
1726
+ visited++;
1727
+ let entries;
1728
+ try {
1729
+ entries = fs6.readdirSync(dir, { withFileTypes: true });
1730
+ } catch {
1731
+ return;
1732
+ }
1733
+ if (entries.some((e) => e.name === "package.json")) {
1734
+ const info = inspect(dir);
1735
+ if (info.next) found.push({ path: dir, name: projectName(dir), ready: info.ready });
1736
+ }
1737
+ for (const e of entries) {
1738
+ if (!e.isDirectory() || e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist" || e.name === "build") continue;
1739
+ walk(path6.join(dir, e.name), depth + 1);
1740
+ }
1741
+ };
1742
+ for (const r of roots) walk(r, 0);
1743
+ return found;
1744
+ }
1745
+ function listDirs(dir) {
1746
+ const abs = path6.resolve(dir.replace(/^~(?=$|\/)/, os2.homedir()));
1747
+ const dirs = fs6.readdirSync(abs, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => ({ name: e.name, next: inspect(path6.join(abs, e.name)).next })).sort((a, b) => Number(b.next) - Number(a.next) || a.name.localeCompare(b.name));
1748
+ return { path: abs, dirs };
1749
+ }
1750
+
1751
+ // src/hub/runner.ts
1752
+ import { execFile, spawn as spawn2 } from "child_process";
1753
+ import fs8 from "fs";
1754
+ import net from "net";
1755
+ import os4 from "os";
1756
+ import path8 from "path";
1757
+
1758
+ // src/cli/setup.ts
1759
+ import { spawn } from "child_process";
1760
+ import { createHash as createHash2 } from "crypto";
1761
+ import fs7 from "fs";
1762
+ import os3 from "os";
1763
+ import path7 from "path";
1764
+ function detectPm(dir) {
1765
+ for (let d = dir; ; d = path7.dirname(d)) {
1766
+ if (fs7.existsSync(path7.join(d, "pnpm-lock.yaml")) || fs7.existsSync(path7.join(d, "pnpm-workspace.yaml"))) return "pnpm";
1767
+ if (fs7.existsSync(path7.join(d, "yarn.lock"))) return "yarn";
1768
+ if (fs7.existsSync(path7.join(d, "bun.lock")) || fs7.existsSync(path7.join(d, "bun.lockb"))) return "bun";
1769
+ if (fs7.existsSync(path7.join(d, "package-lock.json"))) return "npm";
1770
+ if (path7.dirname(d) === d || fs7.existsSync(path7.join(d, ".git"))) break;
1771
+ }
1772
+ const agent = process.env.npm_config_user_agent ?? "";
1773
+ if (agent.startsWith("pnpm")) return "pnpm";
1774
+ if (agent.startsWith("yarn")) return "yarn";
1775
+ if (agent.startsWith("bun")) return "bun";
1776
+ return "npm";
1777
+ }
1778
+ function addDevArgs(pm, spec) {
1779
+ return pm === "npm" ? ["install", "-D", spec] : pm === "bun" ? ["add", "-d", spec] : ["add", "-D", spec];
1780
+ }
1781
+ function runScript(pm, script) {
1782
+ return pm === "npm" ? `npm run ${script}` : `${pm} ${script}`;
1783
+ }
1784
+ var execInherit = (cmd, args, cwd) => new Promise((resolve, reject) => {
1785
+ const p = spawn(cmd, args, { cwd, stdio: "inherit", env: { ...process.env, PATH: `${path7.dirname(process.execPath)}${path7.delimiter}${process.env.PATH ?? ""}` } });
1786
+ p.on("error", reject);
1787
+ p.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`)));
1788
+ });
1789
+ function packageDir(cliFile) {
1790
+ return path7.resolve(path7.dirname(cliFile), "..");
1791
+ }
1792
+ function packageVersion(pkgDir) {
1793
+ try {
1794
+ return JSON.parse(fs7.readFileSync(path7.join(pkgDir, "package.json"), "utf8")).version;
1795
+ } catch {
1796
+ return "0.0.0";
1797
+ }
1798
+ }
1799
+ function isSourceCheckout(pkgDir) {
1800
+ return !pkgDir.split(path7.sep).includes("node_modules") && fs7.existsSync(path7.join(pkgDir, "src", "cli"));
1801
+ }
1802
+ async function packageSpec(pkgDir, exec) {
1803
+ if (process.env.TRUECANVAS_PACKAGE) return process.env.TRUECANVAS_PACKAGE;
1804
+ const version = packageVersion(pkgDir);
1805
+ if (!isSourceCheckout(pkgDir)) return `truecanvas@${version}`;
1806
+ const cacheDir = path7.join(os3.homedir(), ".cache", "truecanvas");
1807
+ fs7.mkdirSync(cacheDir, { recursive: true });
1808
+ const file = path7.join(cacheDir, `truecanvas-${version}.tgz`);
1809
+ await exec("npm", ["pack", "--silent", "--pack-destination", cacheDir], pkgDir);
1810
+ const hash = createHash2("sha256").update(fs7.readFileSync(file)).digest("hex").slice(0, 10);
1811
+ const named = path7.join(cacheDir, `truecanvas-${version}-${hash}.tgz`);
1812
+ fs7.renameSync(file, named);
1813
+ return named;
1814
+ }
1815
+ function installedIn(root) {
1816
+ let declared = false;
1817
+ try {
1818
+ const pkg = JSON.parse(fs7.readFileSync(path7.join(root, "package.json"), "utf8"));
1819
+ declared = "truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies };
1820
+ } catch {
1821
+ }
1822
+ const file = path7.join(root, "node_modules", "truecanvas", "package.json");
1823
+ const installed = fs7.existsSync(file);
1824
+ return { declared, installed, version: installed ? packageVersion(path7.dirname(file)) : null };
1825
+ }
1826
+ function readPackage(root) {
1827
+ try {
1828
+ return JSON.parse(fs7.readFileSync(path7.join(root, "package.json"), "utf8"));
1829
+ } catch {
1830
+ return null;
1831
+ }
1832
+ }
1833
+ function depVersion(root, name) {
1834
+ const file = path7.join(root, "node_modules", name, "package.json");
1835
+ if (fs7.existsSync(file)) return packageVersion(path7.dirname(file));
1836
+ const pkg = readPackage(root);
1837
+ return pkg ? { ...pkg.dependencies, ...pkg.devDependencies }[name] ?? null : null;
1838
+ }
1839
+ function mergeJson(file, update) {
1840
+ let data2 = {};
1841
+ if (fs7.existsSync(file)) {
1842
+ try {
1843
+ data2 = JSON.parse(fs7.readFileSync(file, "utf8"));
1844
+ } catch {
1845
+ throw new Error(`${file} isn't valid JSON; fix it or remove it, then run connect again.`);
1846
+ }
1847
+ }
1848
+ const before = JSON.stringify(data2);
1849
+ update(data2);
1850
+ if (JSON.stringify(data2) === before) return "unchanged";
1851
+ fs7.mkdirSync(path7.dirname(file), { recursive: true });
1852
+ fs7.writeFileSync(file, `${JSON.stringify(data2, null, 2)}
1853
+ `);
1854
+ return "written";
1855
+ }
1856
+ function connectAgents(root, port, opts = {}) {
1857
+ const url = `http://localhost:${port}/mcp`;
1858
+ const out = { written: [], unchanged: [] };
1859
+ const add = (rel, update) => out[mergeJson(path7.join(root, rel), update)].push(rel);
1860
+ const servers = (d, key) => d[key] ??= {};
1861
+ add(".mcp.json", (d) => void (servers(d, "mcpServers").truecanvas = { type: "http", url }));
1862
+ if (opts.cursor || fs7.existsSync(path7.join(root, ".cursor"))) add(".cursor/mcp.json", (d) => void (servers(d, "mcpServers").truecanvas = { url }));
1863
+ if (opts.vscode || fs7.existsSync(path7.join(root, ".vscode"))) add(".vscode/mcp.json", (d) => void (servers(d, "servers").truecanvas = { type: "http", url }));
1864
+ return out;
1865
+ }
1866
+ function hasAgentConfig(root, port) {
1867
+ try {
1868
+ const d = JSON.parse(fs7.readFileSync(path7.join(root, ".mcp.json"), "utf8"));
1869
+ const url = d?.mcpServers?.truecanvas?.url;
1870
+ return typeof url === "string" && (port === void 0 || url.includes(`:${port}/`));
1871
+ } catch {
1872
+ return false;
1873
+ }
1874
+ }
1875
+
1876
+ // src/hub/runner.ts
1877
+ function portFree(port) {
1878
+ return new Promise((resolve) => {
1879
+ const srv = net.createServer();
1880
+ srv.once("error", () => resolve(false));
1881
+ srv.listen(port, "127.0.0.1", () => srv.close(() => resolve(true)));
1882
+ });
1883
+ }
1884
+ async function freePort(from, taken) {
1885
+ for (let p = from; p < from + 200; p++) if (!taken.has(p) && await portFree(p)) return p;
1886
+ throw new Error("No free port");
1887
+ }
1888
+ var Runner = class {
1889
+ constructor(cli, onChange) {
1890
+ this.cli = cli;
1891
+ this.onChange = onChange;
1892
+ }
1893
+ cli;
1894
+ onChange;
1895
+ running = /* @__PURE__ */ new Map();
1896
+ async open(dir) {
1897
+ const existing = this.running.get(dir);
1898
+ if (existing && existing.status !== "failed") return existing;
1899
+ const taken = new Set([...this.running.values()].flatMap((r) => [r.editorPort, r.appPort]));
1900
+ const editorPort = await freePort(4810, taken);
1901
+ taken.add(editorPort);
1902
+ const appPort = await freePort(3010, taken);
1903
+ const proc = spawn2(process.execPath, [this.cli, "dev", "--no-open", "--port", String(editorPort), "--app", `http://localhost:${appPort}`], {
1904
+ cwd: dir,
1905
+ env: { ...process.env, PATH: `${path8.dirname(process.execPath)}:${process.env.PATH ?? ""}`, FORCE_COLOR: "0", TRUECANVAS_HUB: "1" },
1906
+ stdio: ["ignore", "pipe", "pipe"]
1907
+ });
1908
+ const run2 = { path: dir, editorPort, appPort, proc, status: "starting", log: [], startedAt: Date.now() };
1909
+ this.running.set(dir, run2);
1910
+ const capture2 = (chunk) => {
1911
+ for (const line of chunk.toString().split("\n")) if (line.trim()) run2.log.push(line.replace(/\x1b\[[0-9;]*m/g, ""));
1912
+ if (run2.log.length > 200) run2.log.splice(0, run2.log.length - 200);
1913
+ };
1914
+ proc.stdout.on("data", capture2);
1915
+ proc.stderr.on("data", capture2);
1916
+ proc.on("exit", (code) => {
1917
+ if (this.running.get(dir) !== run2) return;
1918
+ if (run2.status === "starting" || code) {
1919
+ run2.status = "failed";
1920
+ run2.error = run2.log.slice(-6).join("\n") || `exited with ${code}`;
1921
+ } else this.running.delete(dir);
1922
+ this.onChange();
1923
+ });
1924
+ this.onChange();
1925
+ const until = Date.now() + 6e4;
1926
+ while (Date.now() < until && run2.status === "starting") {
1927
+ try {
1928
+ const res = await fetch(`http://localhost:${editorPort}/api/state`, { signal: AbortSignal.timeout(1e3) });
1929
+ if (res.ok) {
1930
+ run2.status = "ready";
1931
+ break;
1932
+ }
1933
+ } catch {
1934
+ }
1935
+ await new Promise((r) => setTimeout(r, 400));
1936
+ }
1937
+ if (run2.status === "starting") {
1938
+ run2.status = "failed";
1939
+ run2.error = "Truecanvas didn't start within a minute.";
1940
+ }
1941
+ this.onChange();
1942
+ return run2;
1943
+ }
1944
+ close(dir) {
1945
+ const run2 = this.running.get(dir);
1946
+ if (!run2) return;
1947
+ this.running.delete(dir);
1948
+ run2.proc.kill("SIGTERM");
1949
+ setTimeout(() => run2.proc.exitCode === null && run2.proc.kill("SIGKILL"), 4e3).unref();
1950
+ this.onChange();
1951
+ }
1952
+ closeAll() {
1953
+ for (const dir of [...this.running.keys()]) this.close(dir);
1954
+ }
1955
+ /** Resident memory (MB) of each project's process tree. */
1956
+ memory() {
1957
+ return new Promise((resolve) => {
1958
+ execFile("ps", ["-eo", "pid=,ppid=,rss="], (err, out) => {
1959
+ if (err) return resolve({});
1960
+ const rows = out.trim().split("\n").map((l) => l.trim().split(/\s+/).map(Number));
1961
+ const kids = /* @__PURE__ */ new Map();
1962
+ const rss = /* @__PURE__ */ new Map();
1963
+ for (const [pid, ppid, kb] of rows) {
1964
+ rss.set(pid, kb);
1965
+ kids.set(ppid, [...kids.get(ppid) ?? [], pid]);
1966
+ }
1967
+ const total = (pid) => (rss.get(pid) ?? 0) + (kids.get(pid) ?? []).reduce((s, k) => s + total(k), 0);
1968
+ const outMap = {};
1969
+ for (const [dir, run2] of this.running) if (run2.proc.pid) outMap[dir] = Math.round(total(run2.proc.pid) / 1024);
1970
+ resolve(outMap);
1971
+ });
1972
+ });
1973
+ }
1974
+ };
1975
+ var jobs = /* @__PURE__ */ new Map();
1976
+ var jobSeq = 0;
1977
+ function getJob(id) {
1978
+ return jobs.get(id);
1979
+ }
1980
+ async function install(job, cmd, args, cwd) {
1981
+ try {
1982
+ await run(job, cmd, args, cwd);
1983
+ } catch (err) {
1984
+ const ignoredBuilds = job.log.some((l) => l.includes("ERR_PNPM_IGNORED_BUILDS"));
1985
+ const installed = fs8.existsSync(path8.join(cwd, "node_modules", "truecanvas", "package.json"));
1986
+ if (!(ignoredBuilds && installed)) throw err;
1987
+ job.log.push("(pnpm only complained about other packages' build scripts; Truecanvas is installed, continuing)");
1988
+ }
1989
+ }
1990
+ function run(job, cmd, args, cwd) {
1991
+ job.log.push(`$ ${cmd} ${args.join(" ")}`);
1992
+ return new Promise((resolve, reject) => {
1993
+ const p = spawn2(cmd, args, { cwd, env: { ...process.env, PATH: `${path8.dirname(process.execPath)}:${process.env.PATH ?? ""}`, CI: "1", FORCE_COLOR: "0" }, stdio: ["ignore", "pipe", "pipe"] });
1994
+ const capture2 = (c2) => {
1995
+ for (const line of c2.toString().split("\n")) if (line.trim()) job.log.push(line.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, ""));
1996
+ if (job.log.length > 300) job.log.splice(0, job.log.length - 300);
1997
+ };
1998
+ p.stdout.on("data", capture2);
1999
+ p.stderr.on("data", capture2);
2000
+ p.on("error", reject);
2001
+ p.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} exited with ${code}`)));
2002
+ });
2003
+ }
2004
+ function startJob(title, work) {
2005
+ const job = { id: String(++jobSeq), title, status: "running", log: [] };
2006
+ jobs.set(job.id, job);
2007
+ work(job).then((result) => {
2008
+ job.status = "done";
2009
+ if (result) job.result = result;
2010
+ }).catch((err) => {
2011
+ job.status = "failed";
2012
+ job.log.push(`\u2717 ${err.message}`);
2013
+ });
2014
+ return job;
2015
+ }
2016
+ function packageManager(dir) {
2017
+ if (fs8.existsSync(path8.join(dir, "pnpm-lock.yaml"))) return { cmd: "pnpm", add: ["add", "-D"] };
2018
+ if (fs8.existsSync(path8.join(dir, "yarn.lock"))) return { cmd: "yarn", add: ["add", "-D"] };
2019
+ if (fs8.existsSync(path8.join(dir, "bun.lock")) || fs8.existsSync(path8.join(dir, "bun.lockb"))) return { cmd: "bun", add: ["add", "-d"] };
2020
+ return { cmd: "npm", add: ["install", "-D"] };
2021
+ }
2022
+ function setupJob(dir, pkgDir, cli) {
2023
+ return startJob(`Set up ${path8.basename(dir)}`, async (job) => {
2024
+ const pm = packageManager(dir);
2025
+ const source = await packageSpec(pkgDir, (cmd, args, cwd) => run(job, cmd, args, cwd));
2026
+ await install(job, pm.cmd, [...pm.add, source], dir);
2027
+ await run(job, process.execPath, [cli, "init"], dir);
2028
+ return dir;
2029
+ });
2030
+ }
2031
+ function createJob(name, parent, pkgDir, cli) {
2032
+ const safe = name.trim().toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/^-+|-+$/g, "");
2033
+ const parentDir = path8.resolve(parent.replace(/^~(?=$|\/)/, os4.homedir()));
2034
+ const dir = path8.join(parentDir, safe);
2035
+ return startJob(`Create ${safe}`, async (job) => {
2036
+ if (!safe) throw new Error("Pick a project name.");
2037
+ if (fs8.existsSync(dir)) throw new Error(`${dir} already exists.`);
2038
+ fs8.mkdirSync(parentDir, { recursive: true });
2039
+ await run(job, "npx", ["--yes", "create-next-app@latest", safe, "--ts", "--tailwind", "--app", "--eslint", "--no-src-dir", "--import-alias", "@/*", "--use-pnpm", "--turbopack", "--yes"], parentDir);
2040
+ const source = await packageSpec(pkgDir, (cmd, args, cwd) => run(job, cmd, args, cwd));
2041
+ await install(job, "pnpm", ["add", "-D", source], dir);
2042
+ await run(job, process.execPath, [cli, "init"], dir);
2043
+ return dir;
2044
+ });
2045
+ }
2046
+ function capture(cmd, args, cwd) {
2047
+ return new Promise((resolve) => {
2048
+ execFile(
2049
+ cmd,
2050
+ args,
2051
+ { cwd, timeout: 6e4, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" } },
2052
+ (err, stdout, stderr) => resolve({ ok: !err, out: err ? (stderr || err.message).trim() : stdout })
2053
+ );
2054
+ });
2055
+ }
2056
+ function cloneJob(repo, parent, pkgDir, cli) {
2057
+ const parentDir = path8.resolve(parent.replace(/^~(?=$|\/)/, os4.homedir()));
2058
+ const name = repo.replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
2059
+ const dir = path8.join(parentDir, name);
2060
+ return startJob(`Clone ${name}`, async (job) => {
2061
+ if (!name || !/^[\w.-]+$/.test(name)) throw new Error("Pick a repository (owner/name or a git URL).");
2062
+ if (fs8.existsSync(dir)) throw new Error(`${dir} already exists. Open it with Open folder instead.`);
2063
+ fs8.mkdirSync(parentDir, { recursive: true });
2064
+ const url = /^(https?:|git@|ssh:|file:)/.test(repo);
2065
+ if (url) await run(job, "git", ["clone", repo, dir], parentDir);
2066
+ else await run(job, "gh", ["repo", "clone", repo, dir], parentDir);
2067
+ if (!fs8.existsSync(path8.join(dir, "package.json"))) throw new Error("Cloned, but there's no package.json at the root: Truecanvas needs a Next.js app.");
2068
+ const pm = packageManager(dir);
2069
+ await install(job, pm.cmd, ["install"], dir).catch(async (err) => {
2070
+ if (!fs8.existsSync(path8.join(dir, "node_modules"))) throw err;
2071
+ });
2072
+ const pkg = JSON.parse(fs8.readFileSync(path8.join(dir, "package.json"), "utf8"));
2073
+ if (!("truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies })) {
2074
+ const source = await packageSpec(pkgDir, (cmd, args, cwd) => run(job, cmd, args, cwd));
2075
+ await install(job, pm.cmd, [...pm.add, source], dir);
2076
+ await run(job, process.execPath, [cli, "init"], dir);
2077
+ } else job.log.push("Truecanvas is already part of this project.");
2078
+ return dir;
2079
+ });
2080
+ }
2081
+
2082
+ // src/hub/server.ts
2083
+ var MIME2 = {
2084
+ ".html": "text/html; charset=utf-8",
2085
+ ".js": "text/javascript; charset=utf-8",
2086
+ ".css": "text/css; charset=utf-8",
2087
+ ".svg": "image/svg+xml",
2088
+ ".woff2": "font/woff2",
2089
+ ".png": "image/png"
2090
+ };
2091
+ var HUB_INSTRUCTIONS = `Truecanvas hub: several projects, each a Next.js app with canvases of real React components.
2092
+ Your tools act on the current project (the active tab in the Truecanvas window, unless you pick one with open_project).
2093
+ Use list_projects to see them. All other tools are the regular Truecanvas canvas tools for that project.`;
2094
+ async function startHub(opts) {
2095
+ const store = new ProjectStore();
2096
+ const editorDir = fileURLToPath2(new URL("./editor/", import.meta.url));
2097
+ const pkgDir = path9.resolve(path9.dirname(opts.cli), "..");
2098
+ const origin = `http://localhost:${opts.port}`;
2099
+ const allowedHosts = /* @__PURE__ */ new Set([`localhost:${opts.port}`, `127.0.0.1:${opts.port}`]);
2100
+ const clients = /* @__PURE__ */ new Set();
2101
+ let active = null;
2102
+ const broadcast = () => {
2103
+ for (const res of clients) res.write(`data: {"type":"state"}
2104
+
2105
+ `);
2106
+ for (const fn of toolListeners) fn();
2107
+ };
2108
+ const runner = new Runner(opts.cli, broadcast);
2109
+ setInterval(() => {
2110
+ for (const res of clients) res.write(": ping\n\n");
2111
+ }, 2e4).unref();
2112
+ let focus = null;
2113
+ async function state() {
2114
+ const memory = await runner.memory();
2115
+ return {
2116
+ origin,
2117
+ active,
2118
+ focus,
2119
+ projects: store.all().map((p) => {
2120
+ const r = runner.running.get(p.path);
2121
+ return { ...p, running: r ? { status: r.status, editor: `http://localhost:${r.editorPort}`, app: `http://localhost:${r.appPort}`, error: r.error, memory: memory[p.path] ?? null } : null };
2122
+ })
2123
+ };
2124
+ }
2125
+ const toolListeners = /* @__PURE__ */ new Set();
2126
+ const transports = /* @__PURE__ */ new Map();
2127
+ function createProxy() {
2128
+ const server2 = new Server2({ name: "truecanvas", version: "0.1.0" }, { capabilities: { tools: { listChanged: true } }, instructions: HUB_INSTRUCTIONS });
2129
+ let current = null;
2130
+ const upstreams = /* @__PURE__ */ new Map();
2131
+ const target = () => current ?? active;
2132
+ const upstream = (dir) => {
2133
+ const run2 = runner.running.get(dir);
2134
+ if (!run2 || run2.status !== "ready") return null;
2135
+ let c2 = upstreams.get(dir);
2136
+ if (!c2) {
2137
+ const info = server2.getClientVersion();
2138
+ const client = new Client2({ name: info?.name ?? "agent", version: info?.version ?? "0.0.0" });
2139
+ c2 = client.connect(new StreamableHTTPClientTransport2(new URL(`http://localhost:${run2.editorPort}/mcp`))).then(() => client);
2140
+ c2.catch(() => upstreams.delete(dir));
2141
+ upstreams.set(dir, c2);
2142
+ }
2143
+ return c2;
2144
+ };
2145
+ const notify = () => void server2.sendToolListChanged().catch(() => {
2146
+ });
2147
+ toolListeners.add(notify);
2148
+ server2.onclose = () => toolListeners.delete(notify);
2149
+ const hubTools = [
2150
+ { name: "list_projects", description: "Projects known to the Truecanvas hub, which are open, and which one your tools act on.", inputSchema: { type: "object", properties: {} } },
2151
+ {
2152
+ name: "open_project",
2153
+ description: "Open a project (starts its app if needed) and make it the target of your tools.",
2154
+ inputSchema: { type: "object", properties: { project: { type: "string", description: "Project name or path." } }, required: ["project"] }
2155
+ }
2156
+ ];
2157
+ server2.setRequestHandler(ListToolsRequestSchema2, async () => {
2158
+ const dir = target();
2159
+ const up = dir ? upstream(dir) : null;
2160
+ const projectTools = up ? (await (await up).listTools()).tools : [];
2161
+ return { tools: [...hubTools, ...projectTools] };
2162
+ });
2163
+ server2.setRequestHandler(CallToolRequestSchema2, async (req) => {
2164
+ const text = (t, isError = false) => ({ content: [{ type: "text", text: t }], isError });
2165
+ if (req.params.name === "list_projects") {
2166
+ const s = await state();
2167
+ const lines = s.projects.map((p) => `${p.path === target() ? "\u2192 " : " "}${p.name} (${p.path})${p.running ? ` [${p.running.status}]` : ""}${p.ready ? "" : " (Truecanvas not set up)"}`);
2168
+ return text(lines.join("\n") || "No projects yet. The user can add some from the Dashboard.");
2169
+ }
2170
+ if (req.params.name === "open_project") {
2171
+ const q = String(req.params.arguments?.project ?? "").toLowerCase();
2172
+ const p = store.all().find((x) => x.name.toLowerCase() === q || x.path.toLowerCase() === q || path9.basename(x.path).toLowerCase() === q);
2173
+ if (!p) return text(`No project "${q}". Use list_projects.`, true);
2174
+ 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);
2175
+ store.touch(p.path);
2176
+ const run2 = await runner.open(p.path);
2177
+ if (run2.status !== "ready") return text(`Couldn't start ${p.name}: ${run2.error}`, true);
2178
+ current = p.path;
2179
+ notify();
2180
+ return text(`\u2713 ${p.name} is open. Your tools now act on it.`);
2181
+ }
2182
+ const dir = target();
2183
+ const up = dir ? upstream(dir) : null;
2184
+ if (!up) return text("No project is open. Use list_projects and open_project, or open one in the Truecanvas window.", true);
2185
+ return await (await up).callTool(req.params);
2186
+ });
2187
+ return server2;
2188
+ }
2189
+ async function handleMcp(req, res) {
2190
+ const sessionId = req.headers["mcp-session-id"];
2191
+ const body = req.method === "POST" ? await readJson2(req) : void 0;
2192
+ let transport = sessionId ? transports.get(sessionId) : void 0;
2193
+ if (!transport) {
2194
+ if (req.method !== "POST" || !isInitializeRequest2(body)) return json2(res, 400, { jsonrpc: "2.0", error: { code: -32e3, message: "No valid MCP session." }, id: null });
2195
+ const t = new StreamableHTTPServerTransport2({ sessionIdGenerator: () => randomUUID2(), onsessioninitialized: (id) => void transports.set(id, t) });
2196
+ t.onclose = () => {
2197
+ if (t.sessionId) transports.delete(t.sessionId);
2198
+ };
2199
+ await createProxy().connect(t);
2200
+ transport = t;
2201
+ }
2202
+ await transport.handleRequest(req, res, body);
2203
+ }
2204
+ async function api(req, res, url) {
2205
+ const route = `${req.method} ${url.pathname}`;
2206
+ const body = req.method === "POST" ? await readJson2(req) : {};
2207
+ switch (route) {
2208
+ case "GET /api/hub/state":
2209
+ return json2(res, 200, await state());
2210
+ case "GET /api/hub/discover": {
2211
+ const known = new Set(store.all().map((p) => p.path));
2212
+ return json2(res, 200, { found: discover().filter((f) => !known.has(f.path)) });
2213
+ }
2214
+ case "GET /api/hub/icon": {
2215
+ const icon = findIcon(url.searchParams.get("path") ?? "");
2216
+ if (!icon) return json2(res, 404, { error: "No icon" });
2217
+ res.writeHead(200, { "content-type": ICON_MIME[path9.extname(icon)] ?? "application/octet-stream", "cache-control": "private, max-age=300" });
2218
+ return fs9.createReadStream(icon).pipe(res);
2219
+ }
2220
+ case "GET /api/hub/ls":
2221
+ return json2(res, 200, listDirs(url.searchParams.get("path") || "~"));
2222
+ case "GET /api/hub/events":
2223
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
2224
+ res.write(": connected\n\n");
2225
+ clients.add(res);
2226
+ req.on("close", () => clients.delete(res));
2227
+ return;
2228
+ case "POST /api/hub/add": {
2229
+ const dir = path9.resolve(body.path.replace(/^~(?=$|\/)/, process.env.HOME ?? "~"));
2230
+ if (!fs9.existsSync(path9.join(dir, "package.json"))) return json2(res, 400, { error: "No package.json in that folder. Pick the root of a Next.js app." });
2231
+ store.touch(dir);
2232
+ broadcast();
2233
+ return json2(res, 200, { path: dir });
2234
+ }
2235
+ case "POST /api/hub/open": {
2236
+ store.touch(body.path);
2237
+ active = body.path;
2238
+ if (body.focus) focus = { path: body.path, at: Date.now() };
2239
+ const run2 = await runner.open(body.path);
2240
+ broadcast();
2241
+ return json2(res, 200, { status: run2.status, editor: `http://localhost:${run2.editorPort}`, error: run2.error });
2242
+ }
2243
+ case "POST /api/hub/close":
2244
+ runner.close(body.path);
2245
+ if (active === body.path) active = null;
2246
+ broadcast();
2247
+ return json2(res, 200, { ok: true });
2248
+ case "POST /api/hub/active":
2249
+ active = body.path || null;
2250
+ broadcast();
2251
+ return json2(res, 200, { ok: true });
2252
+ case "POST /api/hub/forget":
2253
+ runner.close(body.path);
2254
+ store.forget(body.path);
2255
+ broadcast();
2256
+ return json2(res, 200, { ok: true });
2257
+ case "POST /api/hub/setup":
2258
+ return json2(res, 200, setupJob(body.path, pkgDir, opts.cli));
2259
+ case "POST /api/hub/create":
2260
+ return json2(res, 200, createJob(body.name, body.parent, pkgDir, opts.cli));
2261
+ // ---------- GitHub ----------
2262
+ case "GET /api/hub/github/repos": {
2263
+ const r = await capture("gh", ["repo", "list", "--limit", "100", "--json", "nameWithOwner,description,updatedAt,isPrivate"], os5.homedir());
2264
+ if (!r.ok) return json2(res, 200, { gh: false, error: r.out.split("\n")[0], repos: [] });
2265
+ const repos = JSON.parse(r.out).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
2266
+ return json2(res, 200, { gh: true, repos });
2267
+ }
2268
+ case "POST /api/hub/clone":
2269
+ return json2(res, 200, cloneJob(body.repo, body.parent || "~", pkgDir, opts.cli));
2270
+ case "GET /api/hub/prs": {
2271
+ const dir = url.searchParams.get("path") ?? "";
2272
+ const r = await capture("gh", ["pr", "list", "--limit", "30", "--json", "number,title,headRefName,author,updatedAt,isDraft,url"], dir);
2273
+ if (!r.ok) {
2274
+ const msg = /no git remotes|not a git repository|none of the git remotes/i.test(r.out) ? "This project isn't on GitHub yet. Publish it from the Git panel (branch pill) first." : /gh auth login|not logged/i.test(r.out) ? "Sign in to GitHub first: run gh auth login in a terminal." : r.out.split("\n")[0];
2275
+ return json2(res, 200, { error: msg, prs: [] });
2276
+ }
2277
+ return json2(res, 200, { prs: JSON.parse(r.out) });
2278
+ }
2279
+ case "POST /api/hub/pr-checkout": {
2280
+ const r = await capture("gh", ["pr", "checkout", String(Number(body.number))], body.path);
2281
+ if (!r.ok) {
2282
+ const dirty = /local changes|would be overwritten|uncommitted/i.test(r.out);
2283
+ return json2(res, 400, { error: dirty ? "This project has uncommitted changes. Commit them (Git panel) before opening another pull request." : r.out.split("\n").slice(-2).join(" ") });
2284
+ }
2285
+ store.touch(body.path);
2286
+ active = body.path;
2287
+ await runner.open(body.path);
2288
+ broadcast();
2289
+ return json2(res, 200, { ok: true });
2290
+ }
2291
+ case "POST /api/hub/quit":
2292
+ json2(res, 200, { ok: true });
2293
+ runner.closeAll();
2294
+ setTimeout(() => process.exit(0), 800);
2295
+ return;
2296
+ }
2297
+ const job = /^GET \/api\/hub\/jobs\/(\d+)$/.exec(route);
2298
+ if (job) {
2299
+ const j = getJob(job[1]);
2300
+ if (j?.status === "done" && j.result) {
2301
+ store.touch(j.result);
2302
+ broadcast();
2303
+ }
2304
+ return j ? json2(res, 200, j) : json2(res, 404, { error: "No such job" });
2305
+ }
2306
+ return json2(res, 404, { error: `No route ${route}` });
2307
+ }
2308
+ function serveStatic(res, pathname) {
2309
+ let file = path9.join(editorDir, pathname === "/" ? "hub.html" : pathname);
2310
+ if (!file.startsWith(editorDir) || !fs9.existsSync(file) || fs9.statSync(file).isDirectory()) file = path9.join(editorDir, "hub.html");
2311
+ if (!fs9.existsSync(file)) {
2312
+ res.writeHead(500, { "content-type": "text/plain" });
2313
+ return res.end("Hub bundle missing. Run `pnpm build`.");
2314
+ }
2315
+ res.writeHead(200, { "content-type": MIME2[path9.extname(file)] ?? "application/octet-stream", "cache-control": pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache" });
2316
+ fs9.createReadStream(file).pipe(res);
2317
+ }
2318
+ const server = http2.createServer(async (req, res) => {
2319
+ try {
2320
+ if (!allowedHosts.has(req.headers.host ?? "")) return json2(res, 403, { error: "Forbidden host" });
2321
+ const reqOrigin = req.headers.origin;
2322
+ if (reqOrigin && reqOrigin !== origin && reqOrigin !== `http://127.0.0.1:${opts.port}`) return json2(res, 403, { error: "Forbidden origin" });
2323
+ const url = new URL(req.url ?? "/", origin);
2324
+ if (url.pathname === "/mcp") return await handleMcp(req, res);
2325
+ if (url.pathname.startsWith("/api/")) return await api(req, res, url);
2326
+ if (req.method === "GET") return serveStatic(res, url.pathname);
2327
+ json2(res, 405, { error: "Method not allowed" });
2328
+ } catch (err) {
2329
+ if (!res.headersSent) json2(res, 500, { error: err.message });
2330
+ else res.end();
2331
+ }
2332
+ });
2333
+ await new Promise((resolve, reject) => {
2334
+ server.once("error", reject);
2335
+ server.listen(opts.port, "127.0.0.1", resolve);
2336
+ });
2337
+ const shutdown = () => {
2338
+ runner.closeAll();
2339
+ server.close();
2340
+ };
2341
+ process.on("SIGINT", () => (shutdown(), process.exit(0)));
2342
+ process.on("SIGTERM", () => (shutdown(), process.exit(0)));
2343
+ return { origin, shutdown };
2344
+ }
2345
+ var ICON_MIME = { ".ico": "image/x-icon", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
2346
+ function findIcon(dir) {
2347
+ if (!dir || !fs9.existsSync(path9.join(dir, "package.json"))) return null;
2348
+ const names = ["icon.svg", "icon.png", "favicon.svg", "favicon.ico", "icon.ico", "apple-icon.png", "favicon.png", "logo.svg", "icon.jpg"];
2349
+ const roots = ["app", "src/app", "public", "static", "assets"];
2350
+ for (const root of roots) {
2351
+ for (const name of names) {
2352
+ const file = path9.join(dir, root, name);
2353
+ if (fs9.existsSync(file) && fs9.statSync(file).size < 2e6) return file;
2354
+ }
2355
+ }
2356
+ for (const root of ["app", "src/app"]) {
2357
+ let entries = [];
2358
+ try {
2359
+ entries = fs9.readdirSync(path9.join(dir, root), { withFileTypes: true });
2360
+ } catch {
2361
+ continue;
2362
+ }
2363
+ for (const e of entries) {
2364
+ if (!e.isDirectory()) continue;
2365
+ for (const name of names.slice(0, 4)) {
2366
+ const file = path9.join(dir, root, e.name, name);
2367
+ if (fs9.existsSync(file)) return file;
2368
+ }
2369
+ }
2370
+ }
2371
+ return null;
2372
+ }
2373
+ function json2(res, status, body) {
2374
+ res.writeHead(status, { "content-type": "application/json" });
2375
+ res.end(JSON.stringify(body));
2376
+ }
2377
+ async function readJson2(req) {
2378
+ const chunks = [];
2379
+ let size = 0;
2380
+ for await (const chunk of req) {
2381
+ size += chunk.length;
2382
+ if (size > 4 * 1024 * 1024) throw new Error("Request too large.");
2383
+ chunks.push(chunk);
2384
+ }
2385
+ const raw = Buffer.concat(chunks).toString("utf8");
2386
+ return raw ? JSON.parse(raw) : {};
2387
+ }
2388
+
2389
+ // src/hub/desktop.ts
2390
+ import fs10 from "fs";
2391
+ import os6 from "os";
2392
+ import path10 from "path";
2393
+ var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
2394
+ <defs>
2395
+ <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff9466"/><stop offset="1" stop-color="#ea6a3c"/></linearGradient>
2396
+ <linearGradient id="hl" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".35"/><stop offset=".08" stop-color="#fff" stop-opacity="0"/></linearGradient>
2397
+ <filter id="sh" x="-20%" y="-20%" width="140%" height="140%"><feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#000" flood-opacity=".22"/></filter>
2398
+ </defs>
2399
+ <g filter="url(#sh)">
2400
+ <rect x="100" y="100" width="824" height="824" rx="190" fill="url(#bg)"/>
2401
+ <rect x="100" y="100" width="824" height="824" rx="190" fill="url(#hl)"/>
2402
+ </g>
2403
+ <path fill="#f6f5f1" fill-rule="evenodd" d="M236 236h552v168H236z M428 316h168v472H428z"/>
2404
+ </svg>
2405
+ `;
2406
+ var data = () => process.env.XDG_DATA_HOME ?? path10.join(os6.homedir(), ".local", "share");
2407
+ function installDesktop(cli, port) {
2408
+ const dir = path10.join(data(), "truecanvas");
2409
+ fs10.mkdirSync(dir, { recursive: true });
2410
+ const icon = path10.join(dir, "icon.svg");
2411
+ fs10.writeFileSync(icon, ICON);
2412
+ const launcher = path10.join(dir, "launch.sh");
2413
+ const url = `http://localhost:${port}`;
2414
+ fs10.writeFileSync(
2415
+ launcher,
2416
+ `#!/bin/sh
2417
+ # Truecanvas launcher: start the hub if needed, then open its window.
2418
+ NODE=${JSON.stringify(process.execPath)}
2419
+ CLI=${JSON.stringify(cli)}
2420
+ URL=${JSON.stringify(url)}
2421
+ LOG="\${XDG_STATE_HOME:-$HOME/.local/state}/truecanvas-hub.log"
2422
+ # apps started from the desktop don't get your shell's PATH: next, pnpm and npx need it
2423
+ export PATH=${JSON.stringify([path10.dirname(process.execPath), ...(process.env.PATH ?? "").split(":")].filter((p, i, a) => p && a.indexOf(p) === i).join(":"))}
2424
+ mkdir -p "$(dirname "$LOG")"
2425
+
2426
+ if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
2427
+ nohup "$NODE" "$CLI" hub --port ${port} >"$LOG" 2>&1 &
2428
+ i=0
2429
+ while [ $i -lt 60 ] && ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; do sleep 0.25; i=$((i+1)); done
2430
+ fi
2431
+
2432
+ PROFILE=${JSON.stringify(path10.join(dir, "window"))}
2433
+ for b in google-chrome google-chrome-stable chromium-browser chromium brave-browser microsoft-edge; do
2434
+ if command -v "$b" >/dev/null 2>&1; then
2435
+ exec "$b" --app="$URL" --class=Truecanvas --user-data-dir="$PROFILE" --no-first-run --no-default-browser-check
2436
+ fi
2437
+ done
2438
+ exec xdg-open "$URL"
2439
+ `,
2440
+ { mode: 493 }
2441
+ );
2442
+ const apps = path10.join(data(), "applications");
2443
+ fs10.mkdirSync(apps, { recursive: true });
2444
+ const desktop = path10.join(apps, "truecanvas.desktop");
2445
+ fs10.writeFileSync(
2446
+ desktop,
2447
+ `[Desktop Entry]
2448
+ Type=Application
2449
+ Name=Truecanvas
2450
+ GenericName=Design canvas
2451
+ Comment=Design with your real React components
2452
+ Exec=${launcher}
2453
+ Icon=${icon}
2454
+ Terminal=false
2455
+ Categories=Graphics;
2456
+ Keywords=design;canvas;react;figma;mcp;
2457
+ StartupWMClass=Truecanvas
2458
+ StartupNotify=true
2459
+ `
2460
+ );
2461
+ let command = path10.join(os6.homedir(), ".local", "bin", "truecanvas");
2462
+ const ours = !fs10.existsSync(command) || fs10.readFileSync(command, "utf8").includes("# truecanvas wrapper");
2463
+ if (ours) {
2464
+ fs10.mkdirSync(path10.dirname(command), { recursive: true });
2465
+ fs10.writeFileSync(command, `#!/bin/sh
2466
+ # truecanvas wrapper (installed by \`truecanvas desktop\`)
2467
+ exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
2468
+ `, { mode: 493 });
2469
+ } else command = null;
2470
+ return { desktop, launcher, icon, command };
2471
+ }
2472
+ function launcherPath() {
2473
+ const p = path10.join(data(), "truecanvas", "launch.sh");
2474
+ return fs10.existsSync(p) ? p : null;
2475
+ }
2476
+ function uninstallDesktop() {
2477
+ const removed = [];
2478
+ const cmd = path10.join(os6.homedir(), ".local", "bin", "truecanvas");
2479
+ if (fs10.existsSync(cmd) && fs10.readFileSync(cmd, "utf8").includes("# truecanvas wrapper")) {
2480
+ fs10.rmSync(cmd);
2481
+ removed.push(cmd);
2482
+ }
2483
+ for (const f of [path10.join(data(), "applications", "truecanvas.desktop"), path10.join(data(), "truecanvas")]) {
2484
+ if (fs10.existsSync(f)) {
2485
+ fs10.rmSync(f, { recursive: true, force: true });
2486
+ removed.push(f);
2487
+ }
2488
+ }
2489
+ return removed;
2490
+ }
2491
+
2492
+ // src/cli/index.ts
2493
+ import { fileURLToPath as fileURLToPath3 } from "url";
2494
+ import { createInterface } from "readline/promises";
2495
+
2496
+ // src/cli/doctor.ts
2497
+ import { execFile as execFile2 } from "child_process";
2498
+ import fs11 from "fs";
2499
+ import path11 from "path";
2500
+ var major = (v) => v ? Number(/(\d+)(?:\.(\d+))?/.exec(v)?.[1] ?? 0) : 0;
2501
+ function has(cmd, args, cwd) {
2502
+ return new Promise((resolve) => execFile2(cmd, args, { cwd, timeout: 8e3 }, (err, stdout, stderr) => resolve(err ? null : `${stdout}${stderr}`)));
2503
+ }
2504
+ async function doctor(root, cliVersion) {
2505
+ const checks = [];
2506
+ const add = (c2) => checks.push(c2);
2507
+ const node = process.versions.node;
2508
+ 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)" });
2509
+ const pkg = readPackage(root);
2510
+ if (!pkg) {
2511
+ 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" });
2512
+ return checks;
2513
+ }
2514
+ const pm = detectPm(root);
2515
+ const next = depVersion(root, "next");
2516
+ 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" });
2517
+ 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` });
2518
+ else add({ label: "Next.js", status: "ok", detail: next });
2519
+ const react = depVersion(root, "react");
2520
+ 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+)" });
2521
+ const tw = depVersion(root, "tailwindcss");
2522
+ 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" });
2523
+ const config = loadConfig(root);
2524
+ add(fs11.existsSync(path11.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/)" });
2525
+ const tc = installedIn(root);
2526
+ if (!tc.declared) add({ label: "Truecanvas in the project", status: "fail", detail: "not a dependency", fix: "npx truecanvas (sets it up)" });
2527
+ else if (!tc.installed) add({ label: "Truecanvas in the project", status: "fail", detail: "declared but not installed", fix: `${pm} install` });
2528
+ 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` });
2529
+ else add({ label: "Truecanvas in the project", status: "ok", detail: tc.version ?? "" });
2530
+ const wrapped = nextConfigStatus(root);
2531
+ add(wrapped.wrapped ? { label: "next.config", status: "ok", detail: `${path11.basename(wrapped.file)} uses withTruecanvas()` } : { label: "next.config", status: "fail", detail: wrapped.file ? "not wrapped" : "missing", fix: "npx truecanvas init" });
2532
+ const scripts = pkg.scripts ?? {};
2533
+ const script = Object.entries(scripts).find(([, v]) => v.includes("truecanvas"));
2534
+ 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")' });
2535
+ const ignore = fs11.existsSync(path11.join(root, ".gitignore")) ? fs11.readFileSync(path11.join(root, ".gitignore"), "utf8") : "";
2536
+ 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" });
2537
+ 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)" });
2538
+ const chrome = findChromium();
2539
+ add(chrome ? { label: "Screenshots", status: "ok", detail: path11.basename(chrome) } : { label: "Screenshots", status: "warn", detail: "no Chromium found", fix: "npx playwright install chromium-headless-shell (or install Chrome)" });
2540
+ const git = await has("git", ["rev-parse", "--is-inside-work-tree"], root);
2541
+ add(git ? { label: "Git", status: "ok" } : { label: "Git", status: "warn", detail: "not a repository", fix: "git init (branches, history, comments and compare use git)" });
2542
+ const gh = await has("gh", ["auth", "status"], root);
2543
+ add(gh !== null ? { label: "GitHub CLI", status: "ok", detail: "signed in" } : { label: "GitHub CLI", status: "warn", detail: "missing or signed out", fix: "Install gh (https://cli.github.com) and run gh auth login (for pull requests)" });
2544
+ const app = await fetch(config.appUrl, { signal: AbortSignal.timeout(1500), redirect: "manual" }).then(() => true).catch(() => false);
2545
+ add({ label: "App", status: "ok", detail: app ? `running at ${config.appUrl}` : `not running (truecanvas starts it at ${config.appUrl})` });
2546
+ return checks;
2547
+ }
2548
+
2549
+ // src/cli/index.ts
2550
+ var HELP = `truecanvas: design with your real React components
2551
+
2552
+ Usage
2553
+ truecanvas In a Next.js app: set it up (asks once) and start the editor.
2554
+ Elsewhere: open the Truecanvas window.
2555
+ truecanvas connect Add Truecanvas to this project's .mcp.json (Claude Code, Cursor,
2556
+ VS Code): commit it and every teammate's agent connects
2557
+ truecanvas doctor Check this project and machine, with a fix for each problem
2558
+ truecanvas dev Start the editor + MCP server for this project (no window)
2559
+ truecanvas setup Install and set up Truecanvas in this project, without starting
2560
+ truecanvas init Only write the config (next.config, script, canvas folder)
2561
+ truecanvas open Open the Truecanvas window (projects dashboard + tabs)
2562
+ truecanvas quit Quit the window and every app it started
2563
+ truecanvas desktop Add Truecanvas to your app launcher (Linux); "desktop remove" undoes it
2564
+ truecanvas mcp stdio MCP server for agents without HTTP support
2565
+
2566
+ Options
2567
+ -y, --yes Don't ask: set up and connect agents
2568
+ --port <n> Editor/MCP port (default 4800)
2569
+ --app <url> Your Next.js dev URL (default http://localhost:3000)
2570
+ --no-next Don't start \`next dev\` (by default it starts when the app isn't running)
2571
+ --no-open Don't open the editor in your browser
2572
+ --cursor, --vscode connect: also write .cursor/mcp.json / .vscode/mcp.json
2573
+ -v, --version Print the version
2574
+ `;
2575
+ var color = process.stdout.isTTY && !process.env.NO_COLOR;
2576
+ var paint = (open, close) => (s) => color ? `\x1B[${open}m${s}\x1B[${close}m` : s;
2577
+ var c = {
2578
+ dim: paint("2", "22"),
2579
+ bold: paint("1", "22"),
2580
+ accent: paint("38;5;75", "39"),
2581
+ green: paint("32", "39"),
2582
+ yellow: paint("33", "39"),
2583
+ red: paint("31", "39")
2584
+ };
2585
+ async function main() {
2586
+ const { positionals, values } = parseArgs({
2587
+ allowPositionals: true,
2588
+ options: {
2589
+ port: { type: "string" },
2590
+ app: { type: "string" },
2591
+ next: { type: "boolean", default: true },
2592
+ "no-next": { type: "boolean" },
2593
+ open: { type: "boolean", default: true },
2594
+ "no-open": { type: "boolean" },
2595
+ help: { type: "boolean", short: "h" },
2596
+ yes: { type: "boolean", short: "y" },
2597
+ cursor: { type: "boolean" },
2598
+ vscode: { type: "boolean" },
2599
+ version: { type: "boolean", short: "v" }
2600
+ }
2601
+ });
2602
+ const cli = fileURLToPath3(import.meta.url);
2603
+ const pkgDir = packageDir(cli);
2604
+ const version = packageVersion(pkgDir);
2605
+ const command = positionals[0] ?? "start";
2606
+ if (values.version) return console.log(version);
2607
+ if (values.help || command === "help") {
2608
+ console.log(HELP);
2609
+ return;
2610
+ }
2611
+ const yes = !!values.yes;
2612
+ if (command === "start") {
2613
+ const root2 = process.cwd();
2614
+ if (!readPackage(root2) || !("next" in { ...readPackage(root2).dependencies, ...readPackage(root2).devDependencies })) {
2615
+ return openWindow(cli, values.port ? Number(values.port) : 4800);
2616
+ }
2617
+ if (!await ensureSetup(root2, pkgDir, version, yes)) return;
2618
+ if (await openInWindow(root2, Number(values.port ?? 4800))) return;
2619
+ const local = path12.join(root2, "node_modules", "truecanvas", "dist", "cli.js");
2620
+ if (fs12.existsSync(local) && fs12.realpathSync(local) !== fs12.realpathSync(cli)) {
2621
+ const child = spawn3(process.execPath, [local, "dev", ...process.argv.slice(2).filter((a) => a !== "start" && a !== "-y" && a !== "--yes")], { stdio: "inherit" });
2622
+ for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => child.kill(sig));
2623
+ child.on("exit", (code) => process.exit(code ?? 0));
2624
+ return;
2625
+ }
2626
+ }
2627
+ if (command === "setup") {
2628
+ const root2 = process.cwd();
2629
+ if (!await ensureSetup(root2, pkgDir, version, true, true)) process.exit(1);
2630
+ return;
2631
+ }
2632
+ if (command === "connect") {
2633
+ const root2 = process.cwd();
2634
+ if (!readPackage(root2)) {
2635
+ console.error("Run truecanvas connect from your project root (no package.json here).");
2636
+ process.exit(1);
2637
+ }
2638
+ const config2 = loadConfig(root2, { port: values.port ? Number(values.port) : void 0 });
2639
+ printConnect(connectAgents(root2, config2.port, { cursor: values.cursor, vscode: values.vscode }), config2.port);
2640
+ return;
2641
+ }
2642
+ if (command === "doctor") {
2643
+ const checks = await doctor(process.cwd(), version);
2644
+ console.log(`
2645
+ ${c.accent("\u25C6")} ${c.bold("Truecanvas doctor")} ${c.dim(`v${version}`)}
2646
+ `);
2647
+ for (const ch of checks) {
2648
+ const mark = ch.status === "ok" ? c.green("\u2713") : ch.status === "warn" ? c.yellow("!") : c.red("\u2717");
2649
+ console.log(` ${mark} ${ch.label}${ch.detail ? c.dim(` ${ch.detail}`) : ""}`);
2650
+ if (ch.fix && ch.status !== "ok") console.log(` ${c.dim("\u2192")} ${ch.fix}`);
2651
+ }
2652
+ const failed = checks.filter((ch) => ch.status === "fail").length;
2653
+ console.log(failed ? `
2654
+ ${failed} problem${failed === 1 ? "" : "s"} to fix.
2655
+ ` : `
2656
+ ${c.green("All good.")}
2657
+ `);
2658
+ process.exit(failed ? 1 : 0);
2659
+ }
2660
+ if (command === "hub") {
2661
+ const port = values.port ? Number(values.port) : 4800;
2662
+ try {
2663
+ const { origin: origin2 } = await startHub({ port, cli });
2664
+ console.log(`
2665
+ ${c.accent("\u25C6")} ${c.bold("Truecanvas")} hub on ${c.bold(origin2)}
2666
+ ${c.dim("MCP")} ${origin2}/mcp ${c.dim("(follows the active tab)")}
2667
+ `);
2668
+ if (values.open && !values["no-open"]) openBrowser(origin2);
2669
+ } catch (err) {
2670
+ if (err.code === "EADDRINUSE") {
2671
+ console.error(`Port ${port} is busy. If a project is running there with \`truecanvas dev\`, stop it first: the hub uses ${port} so your MCP URL keeps working.`);
2672
+ process.exit(1);
2673
+ }
2674
+ throw err;
2675
+ }
2676
+ return;
2677
+ }
2678
+ if (command === "open") return openWindow(cli, values.port ? Number(values.port) : 4800);
2679
+ if (command === "quit") {
2680
+ const url = `http://localhost:${values.port ?? 4800}`;
2681
+ const up = await fetch(`${url}/api/hub/state`, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok).catch(() => false);
2682
+ if (!up) return console.log("Truecanvas isn't running.");
2683
+ await fetch(`${url}/api/hub/quit`, { method: "POST" }).catch(() => {
2684
+ });
2685
+ return console.log(`${c.green("\u2713")} Truecanvas quit. Project apps it started are stopped too.`);
2686
+ }
2687
+ if (command === "desktop") {
2688
+ if (positionals[1] === "remove") {
2689
+ const removed = uninstallDesktop();
2690
+ console.log(removed.length ? `${c.green("\u2713")} Removed the launcher.` : "Nothing to remove.");
2691
+ return;
2692
+ }
2693
+ const out = installDesktop(cli, 4800);
2694
+ console.log(`${c.green("\u2713")} Truecanvas is in your app launcher.
2695
+ ${c.dim(out.desktop)}`);
2696
+ if (out.command) console.log(`${c.green("\u2713")} The ${c.accent("truecanvas")} command is installed: ${c.dim(out.command)}`);
2697
+ console.log(` Open it from Activities, or run ${c.accent("truecanvas")}. Quit with ${c.accent("truecanvas quit")} or from the window's menu.`);
2698
+ return;
2699
+ }
2700
+ const root = process.cwd();
2701
+ if (!fs12.existsSync(path12.join(root, "package.json"))) {
2702
+ console.error("Run truecanvas from your Next.js project root (no package.json here).");
2703
+ process.exit(1);
2704
+ }
2705
+ const config = loadConfig(root, { port: values.port ? Number(values.port) : void 0, appUrl: values.app });
2706
+ const ws = new Workspace(config);
2707
+ if (command === "init") {
2708
+ const report = initProject(config);
2709
+ ws.ensureRoute();
2710
+ console.log(`
2711
+ ${c.accent("\u25C6")} ${c.bold("Truecanvas")} is set up in ${c.bold(path12.basename(root))}
2712
+ `);
2713
+ for (const line of report.done) console.log(` ${c.green("\u2713")} ${line}`);
2714
+ console.log(` ${c.green("\u2713")} Canvas folder ${c.bold(config.canvasDir)} and dev route ${c.bold(`${config.appDir}/truecanvas`)}`);
2715
+ for (const line of report.todo) console.log(` ${c.dim("!")} ${line}`);
2716
+ console.log(`
2717
+ Next: ${c.accent("npm run canvas")} ${c.dim("(starts your app and the editor)")}
2718
+ `);
2719
+ return;
2720
+ }
2721
+ if (command === "mcp") {
2722
+ console.log = console.error;
2723
+ const mcpUrl = `http://localhost:${config.port}/mcp`;
2724
+ const running = await fetch(`http://localhost:${config.port}/api/state`).then((r) => r.ok).catch(() => false);
2725
+ if (!running) {
2726
+ ws.ensureRoute();
2727
+ await startServer(ws);
2728
+ console.error(`Truecanvas editor: http://localhost:${config.port}`);
2729
+ }
2730
+ await runStdioBridge(mcpUrl);
2731
+ return;
2732
+ }
2733
+ if (command !== "dev" && command !== "start") {
2734
+ console.log(`Unknown command "${command}".
2735
+ `);
2736
+ console.log(HELP);
2737
+ process.exit(1);
2738
+ }
2739
+ if (command === "dev" && await openInWindow(root, config.port)) return;
2740
+ const status = nextConfigStatus(root);
2741
+ if (!status.wrapped) {
2742
+ const report = initProject(config);
2743
+ for (const line of report.done) console.log(` ${c.green("\u2713")} ${line}`);
2744
+ for (const line of report.todo) console.log(` ${c.dim("!")} ${line}`);
2745
+ }
2746
+ ws.ensureRoute();
2747
+ let started;
2748
+ try {
2749
+ started = await startServer(ws);
2750
+ } catch (err) {
2751
+ if (err.code === "EADDRINUSE") {
2752
+ console.error(`Port ${config.port} is busy. Is Truecanvas already running? Use --port to pick another.`);
2753
+ process.exit(1);
2754
+ }
2755
+ throw err;
2756
+ }
2757
+ const { origin, shutdown } = started;
2758
+ const mcp = `${origin}/mcp`;
2759
+ let next = null;
2760
+ const appUp = await reachable(config.appUrl);
2761
+ if (!appUp && values.next && !values["no-next"]) next = startNext(root, config.appUrl);
2762
+ console.log(`
2763
+ ${c.accent("\u25C6")} ${c.bold("Truecanvas")} ${c.dim(`v${version}`)}
2764
+
2765
+ ${c.dim("Editor")} ${c.bold(origin)}
2766
+ ${c.dim("MCP")} ${mcp}
2767
+ ${c.dim("App")} ${config.appUrl} ${c.dim(appUp ? "(already running)" : next ? "(starting next dev\u2026)" : "(not running)")}
2768
+ ${c.dim("Canvases")} ${ws.canvases().join(", ") || "none"}
2769
+
2770
+ ${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)")}`}
2771
+ `);
2772
+ if (next) {
2773
+ const exited = new Promise((r) => next.once("exit", () => r("exited")));
2774
+ const result = await Promise.race([waitFor(config.appUrl, 9e4), exited]);
2775
+ if (result === true) console.log(` ${c.green("\u2713")} App ready at ${config.appUrl}
2776
+ `);
2777
+ 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.")}
2778
+ `);
2779
+ else console.log(` ${c.dim("next dev is taking a while. The editor will connect when it's up.")}
2780
+ `);
2781
+ }
2782
+ if (values.open && !values["no-open"]) openBrowser(origin);
2783
+ let stopping = false;
2784
+ const stop = async () => {
2785
+ if (stopping) return;
2786
+ stopping = true;
2787
+ next?.kill("SIGINT");
2788
+ await shutdown();
2789
+ process.exit(0);
2790
+ };
2791
+ process.on("SIGINT", stop);
2792
+ process.on("SIGTERM", stop);
2793
+ next?.on("exit", (code) => {
2794
+ if (!stopping) console.log(` ${c.dim(`next dev exited (${code ?? "signal"}).`)}`);
2795
+ });
2796
+ }
2797
+ async function openWindow(cli, port) {
2798
+ const url = `http://localhost:${port}`;
2799
+ const launcher = launcherPath();
2800
+ if (launcher) {
2801
+ spawn3(launcher, [], { stdio: "ignore", detached: true }).unref();
2802
+ return console.log(`${c.accent("\u25C6")} Opening Truecanvas\u2026`);
2803
+ }
2804
+ const up = await fetch(`${url}/api/hub/state`, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok).catch(() => false);
2805
+ if (!up) {
2806
+ spawn3(process.execPath, [cli, "hub", "--port", String(port), "--no-open"], { stdio: "ignore", detached: true }).unref();
2807
+ for (let i = 0; i < 40 && !await fetch(`${url}/api/hub/state`).then((r) => r.ok).catch(() => false); i++) await new Promise((r) => setTimeout(r, 250));
2808
+ }
2809
+ openBrowser(url);
2810
+ console.log(`${c.accent("\u25C6")} Truecanvas is at ${c.bold(url)} ${c.dim("(tip: `truecanvas desktop` adds it to your app launcher)")}`);
2811
+ }
2812
+ async function openInWindow(root, port) {
2813
+ const hub = `http://localhost:${port}`;
2814
+ const up = await fetch(`${hub}/api/hub/state`, { signal: AbortSignal.timeout(1e3) }).then((r) => r.ok).catch(() => false);
2815
+ if (!up) return false;
2816
+ const post = (route, body) => fetch(`${hub}${route}`, { method: "POST", headers: { "content-type": "application/json", origin: hub }, body: JSON.stringify(body) }).catch(() => null);
2817
+ await post("/api/hub/add", { path: root });
2818
+ await post("/api/hub/open", { path: root, focus: "1" });
2819
+ console.log(`${c.accent("\u25C6")} Opened ${c.bold(path12.basename(root))} in the Truecanvas window ${c.dim(`(${hub})`)}.`);
2820
+ return true;
2821
+ }
2822
+ async function ask(question) {
2823
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2824
+ try {
2825
+ const answer = (await rl.question(` ${question} ${c.dim("(Y/n)")} `)).trim().toLowerCase();
2826
+ return answer === "" || answer === "y" || answer === "yes";
2827
+ } finally {
2828
+ rl.close();
2829
+ }
2830
+ }
2831
+ async function ensureSetup(root, pkgDir, version, yes, force = false) {
2832
+ const tc = installedIn(root);
2833
+ const wrapped = nextConfigStatus(root).wrapped;
2834
+ if (tc.declared && tc.installed && wrapped && !force) return true;
2835
+ const name = readPackage(root)?.name ?? path12.basename(root);
2836
+ if (!yes) {
2837
+ if (!process.stdin.isTTY) {
2838
+ console.error(`Truecanvas isn't set up in ${name} yet. Run again with --yes to set it up.`);
2839
+ return false;
2840
+ }
2841
+ console.log(`
2842
+ ${c.accent("\u25C6")} ${c.bold("Truecanvas")} ${c.dim(`v${version}`)}
2843
+ `);
2844
+ console.log(` This adds Truecanvas to ${c.bold(name)}: a dev dependency, a wrapper in next.config,`);
2845
+ console.log(` a ${c.bold("canvas")} script and a canvas with your homepage. Nothing changes in production builds.
2846
+ `);
2847
+ if (!await ask("Set it up?")) return false;
2848
+ }
2849
+ const pm = detectPm(root);
2850
+ if (!tc.declared || !tc.installed) {
2851
+ try {
2852
+ const spec = await packageSpec(pkgDir, (cmd, args, cwd) => execInherit(cmd, args, cwd));
2853
+ console.log(`
2854
+ ${c.dim(`Installing ${spec.includes("/") ? "this build of truecanvas" : spec} with ${pm}\u2026`)}
2855
+ `);
2856
+ await execInherit(pm, addDevArgs(pm, spec), root).catch((err) => {
2857
+ if (!installedIn(root).installed) throw err;
2858
+ });
2859
+ } catch (err) {
2860
+ console.error(`
2861
+ ${c.red("\u2717")} Couldn't install Truecanvas: ${err.message}`);
2862
+ return false;
2863
+ }
2864
+ }
2865
+ const config = loadConfig(root);
2866
+ const report = initProject(config);
2867
+ new Workspace(config).ensureRoute();
2868
+ console.log("");
2869
+ for (const line of report.done) console.log(` ${c.green("\u2713")} ${line}`);
2870
+ console.log(` ${c.green("\u2713")} Canvas folder ${c.bold(config.canvasDir)}`);
2871
+ for (const line of report.todo) console.log(` ${c.yellow("!")} ${line}`);
2872
+ if (!hasAgentConfig(root) && (yes || process.stdin.isTTY && await ask("Connect agents (Claude Code, Cursor\u2026) through this project's .mcp.json?"))) {
2873
+ printConnect(connectAgents(root, config.port), config.port);
2874
+ }
2875
+ console.log(`
2876
+ Next time, start it with ${c.accent(runScript(pm, "canvas"))} ${c.dim("(or npx truecanvas)")}
2877
+ `);
2878
+ return true;
2879
+ }
2880
+ function printConnect(res, port) {
2881
+ for (const f of res.written) console.log(` ${c.green("\u2713")} Truecanvas added to ${c.bold(f)}`);
2882
+ for (const f of res.unchanged) console.log(` ${c.green("\u2713")} ${c.bold(f)} already lists Truecanvas`);
2883
+ console.log(` ${c.dim("Commit it: anyone who clones the repo gets Truecanvas in their agent.")}`);
2884
+ console.log(` ${c.dim(`Or for all your projects (Claude Code): claude mcp add --scope user --transport http truecanvas http://localhost:${port}/mcp`)}`);
2885
+ }
2886
+ async function reachable(url) {
2887
+ try {
2888
+ await fetch(url, { signal: AbortSignal.timeout(1500), redirect: "manual" });
2889
+ return true;
2890
+ } catch {
2891
+ return false;
2892
+ }
2893
+ }
2894
+ async function waitFor(url, ms) {
2895
+ const until = Date.now() + ms;
2896
+ while (Date.now() < until) {
2897
+ if (await reachable(url)) return true;
2898
+ await new Promise((r) => setTimeout(r, 700));
2899
+ }
2900
+ return false;
2901
+ }
2902
+ function startNext(root, appUrl) {
2903
+ const port = new URL(appUrl).port || "3000";
2904
+ const bin = path12.join(root, "node_modules", ".bin", "next");
2905
+ const [cmd, args] = fs12.existsSync(bin) ? [bin, ["dev", "-p", port]] : ["npx", ["next", "dev", "-p", port]];
2906
+ const child = spawn3(cmd, args, { cwd: root, env: { ...process.env, ...process.env.NO_COLOR ? {} : { FORCE_COLOR: "1" } }, stdio: ["ignore", "pipe", "pipe"] });
2907
+ const prefix = c.dim("next \u2502 ");
2908
+ for (const stream of [child.stdout, child.stderr]) {
2909
+ let buf = "";
2910
+ stream.on("data", (chunk) => {
2911
+ buf += chunk.toString();
2912
+ const lines = buf.split("\n");
2913
+ buf = lines.pop();
2914
+ for (const line of lines) if (line.trim()) process.stdout.write(` ${prefix}${line}
2915
+ `);
2916
+ });
2917
+ }
2918
+ return child;
2919
+ }
2920
+ function openBrowser(url) {
2921
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2922
+ try {
2923
+ spawn3(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).on("error", () => {
2924
+ }).unref();
2925
+ } catch {
2926
+ }
2927
+ }
2928
+ main().catch((err) => {
2929
+ console.error(err);
2930
+ process.exit(1);
2931
+ });