truecanvas 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,29 +3,49 @@ import {
3
3
  DEVICES,
4
4
  EditError,
5
5
  Workspace,
6
+ addArgs,
7
+ assertCanvasName,
6
8
  clearCompare,
7
9
  componentsDir,
10
+ configStatus,
8
11
  describeComponent,
12
+ detectPm,
9
13
  findByPath,
10
14
  findDevice,
11
15
  findNode,
12
16
  frameChanges,
17
+ initProject,
13
18
  layoutFiles,
14
19
  listRoutes,
15
- loadConfig,
16
20
  outlineDoc,
17
21
  parseCanvas,
18
22
  readDesignTokens,
19
23
  readTokenData,
24
+ run,
25
+ shadcnAdd,
26
+ shadcnRegistry,
27
+ shadcnStatus,
28
+ shellSafe,
20
29
  syncComponentRegistry,
30
+ toolEnv,
21
31
  writeCompare
22
- } from "./chunk-WB35FXGP.js";
23
- import "./chunk-753N4QRO.js";
32
+ } from "./chunk-XWQ5MS24.js";
33
+ import {
34
+ loadConfig
35
+ } from "./chunk-PBKX3SBC.js";
36
+ import "./chunk-WU7NBTJH.js";
37
+ import {
38
+ ICON_LIBRARIES,
39
+ iconLibraries,
40
+ iconLibrary,
41
+ loadIcons,
42
+ searchIcons
43
+ } from "./chunk-C4SATRZT.js";
24
44
 
25
45
  // src/cli/index.ts
26
46
  import { spawn as spawn3 } from "child_process";
27
- import fs12 from "fs";
28
- import path12 from "path";
47
+ import fs11 from "fs";
48
+ import path11 from "path";
29
49
  import { parseArgs } from "util";
30
50
 
31
51
  // src/server/http.ts
@@ -41,6 +61,33 @@ import launchEditor from "launch-editor";
41
61
  // src/server/mcp.ts
42
62
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
43
63
  import { z } from "zod";
64
+
65
+ // src/core/libraries.ts
66
+ function libraryState(root) {
67
+ return { packageManager: detectPm(root), icons: iconLibraries(root), shadcn: shadcnStatus(root) };
68
+ }
69
+ var lock = Promise.resolve();
70
+ function exclusive(job) {
71
+ const next = lock.then(job, job);
72
+ lock = next.catch(() => {
73
+ });
74
+ return next;
75
+ }
76
+ function installIconLibrary(root, id) {
77
+ const lib = iconLibrary(id);
78
+ if (!lib) throw new EditError(`Unknown icon library "${id}". Known: ${iconLibraries(root).map((l) => l.id).join(", ")}.`);
79
+ return exclusive(async () => {
80
+ const pm = detectPm(root);
81
+ const res = await run(pm, addArgs(pm, [lib.package]), root);
82
+ return { ...res, package: lib.package };
83
+ });
84
+ }
85
+ function addShadcnComponents(root, names) {
86
+ if (!names.length) throw new EditError("Name at least one component, e.g. button.");
87
+ return exclusive(() => shadcnAdd(root, names));
88
+ }
89
+
90
+ // src/server/mcp.ts
44
91
  var INSTRUCTIONS = `Truecanvas is a design canvas whose layers are the project's real React components.
45
92
  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
93
 
@@ -50,6 +97,7 @@ Workflow:
50
97
  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
98
  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
99
  For mobile screens create frames with a device (e.g. device="iphone-16"): the right size, plus touch + pixel-ratio emulation in screenshots.
100
+ Icons and shadcn/ui: list_libraries shows what's installed. install_library adds an icon set (lucide, tabler, phosphor, heroicons, radix); search_icons finds names and insert_icon places one with its import. add_shadcn_components copies shadcn/ui components into the project (setting shadcn up if needed); they then appear in list_components like any project component.
53
101
  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
102
  5. focus to show the user what you changed. Everything is undoable by the user.`;
55
103
  var literal = z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number(), z.boolean()]))]);
@@ -67,6 +115,7 @@ function createMcpServer(ws, shots, session) {
67
115
  const resolveCanvas = (canvas) => {
68
116
  const c2 = canvas ?? lastCanvas ?? ws.selection.canvas ?? ws.canvases()[0];
69
117
  if (!c2) throw new Error("No canvases yet. Use create_canvas.");
118
+ assertCanvasName(c2);
70
119
  lastCanvas = c2;
71
120
  return c2;
72
121
  };
@@ -80,6 +129,7 @@ function createMcpServer(ws, shots, session) {
80
129
  set_class: "Adjusting layout",
81
130
  insert_jsx: "Inserting",
82
131
  insert_component: "Inserting",
132
+ insert_icon: "Inserting",
83
133
  replace: "Rewriting",
84
134
  duplicate: "Duplicating",
85
135
  delete: "Deleting",
@@ -110,7 +160,8 @@ function createMcpServer(ws, shots, session) {
110
160
  const c2 = cmd;
111
161
  const before = [c2.id, ...Array.isArray(c2.ids) ? c2.ids : [], c2.parent].filter((x) => typeof x === "string");
112
162
  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);
163
+ const isLayerId = (x) => /^(?:.+#)?t?\d+:\d+$/.test(x);
164
+ presence(cmd.canvas, "editing", `${VERBS[cmd.op] ?? "Editing"}\u2026`, before.filter(isLayerId), frameRef ?? before.find((x) => !isLayerId(x)) ?? null);
114
165
  const res = await ws.run(cmd, actor());
115
166
  presence(cmd.canvas, "editing", res.label, res.ids);
116
167
  const frames = /* @__PURE__ */ new Set();
@@ -529,7 +580,7 @@ ${source.slice(hit.node.start, hit.node.end)}
529
580
  async ({ name }) => {
530
581
  try {
531
582
  ws.createCanvas(name, actor());
532
- return text(`\u2713 Created canvas ${name} (${ws.relFile(name)}). Next.js picks the new route up in a second.`);
583
+ return text(`\u2713 Created canvas ${name} (${ws.relFile(name)}). The app picks it up in a second.`);
533
584
  } catch (e) {
534
585
  return fail(e);
535
586
  }
@@ -716,6 +767,108 @@ ${msgs}`;
716
767
  }
717
768
  }
718
769
  );
770
+ const root = ws.config.root;
771
+ const installedIcons = () => iconLibraries(root).filter((l) => l.version);
772
+ server.registerTool(
773
+ "list_libraries",
774
+ {
775
+ title: "List libraries",
776
+ description: "Icon libraries (installed or available) and shadcn/ui status for this project.",
777
+ annotations: { readOnlyHint: true }
778
+ },
779
+ () => {
780
+ const st = libraryState(root);
781
+ const icons = st.icons.map((l) => ` ${l.id.padEnd(16)} ${l.version ? `installed ${l.version}` : "not installed"} (${l.package}${l.from !== l.package ? `, imports from ${l.from}` : ""})`);
782
+ const shadcn = st.shadcn.initialized ? `set up, components in ${st.shadcn.uiDir}: ${st.shadcn.installed.join(", ") || "none yet"}` : "not set up (add_shadcn_components sets it up)";
783
+ return text(`Package manager: ${st.packageManager}
784
+ Icon libraries:
785
+ ${icons.join("\n")}
786
+ shadcn/ui: ${shadcn}`);
787
+ }
788
+ );
789
+ server.registerTool(
790
+ "install_library",
791
+ {
792
+ title: "Install icon library",
793
+ description: "Install an icon library into the project with its package manager (adds a dependency).",
794
+ inputSchema: { library: z.enum(ICON_LIBRARIES.map((l) => l.id)) }
795
+ },
796
+ async ({ library }) => {
797
+ try {
798
+ const out = await installIconLibrary(root, library);
799
+ if (!out.ok) return fail(new Error(`Installing ${out.package} failed:
800
+ ${out.out.slice(-1500)}`));
801
+ return text(`\u2713 Installed ${out.package}. Use search_icons to find icons.`);
802
+ } catch (err) {
803
+ return fail(err);
804
+ }
805
+ }
806
+ );
807
+ server.registerTool(
808
+ "search_icons",
809
+ {
810
+ title: "Search icons",
811
+ description: "Find icon names in an installed icon library. Without a query, lists the first icons.",
812
+ inputSchema: {
813
+ query: z.string().optional(),
814
+ library: z.string().optional().describe("Library id (lucide, tabler\u2026). Defaults to the first installed one."),
815
+ limit: z.number().int().min(1).max(500).optional()
816
+ },
817
+ annotations: { readOnlyHint: true }
818
+ },
819
+ async ({ query, library, limit }) => {
820
+ try {
821
+ const lib = library ?? installedIcons()[0]?.id;
822
+ if (!lib) return fail(new Error("No icon library is installed. Use install_library (lucide is a good default)."));
823
+ const res = searchIcons(await loadIcons(root, lib), query ?? "", limit ?? 60);
824
+ if (!res.icons.length) return text(`No icons match "${query}" in ${lib}.`);
825
+ return text(`${res.total} match${res.total === 1 ? "" : "es"} in ${lib}${res.total > res.icons.length ? ` (first ${res.icons.length})` : ""}:
826
+ ${res.icons.map((i) => i.name).join(", ")}`);
827
+ } catch (err) {
828
+ return fail(err);
829
+ }
830
+ }
831
+ );
832
+ server.registerTool(
833
+ "insert_icon",
834
+ {
835
+ title: "Insert icon",
836
+ description: "Insert an icon from an installed library as a child of a frame or node, with its import. className defaults to size-4.",
837
+ inputSchema: {
838
+ ...canvasArg,
839
+ parent: z.string(),
840
+ name: z.string().describe("Icon name from search_icons, e.g. Search"),
841
+ library: z.string().optional().describe("Library id. Defaults to the first installed one."),
842
+ index: z.number().int().optional(),
843
+ className: z.string().optional()
844
+ }
845
+ },
846
+ ({ canvas, parent, name, library, index, className }) => edit(() => {
847
+ const lib = library ?? installedIcons()[0]?.id;
848
+ if (!lib) throw new Error("No icon library is installed. Use install_library first.");
849
+ return { op: "insert_icon", canvas: resolveCanvas(canvas), parent, name, library: lib, index, className };
850
+ })
851
+ );
852
+ server.registerTool(
853
+ "add_shadcn_components",
854
+ {
855
+ title: "Add shadcn/ui components",
856
+ description: "Copy shadcn/ui components into the project with the shadcn CLI (sets shadcn up first if needed). They then show up in list_components.",
857
+ inputSchema: { names: z.array(z.string()).min(1).describe('Registry names, e.g. ["button", "card", "dialog"]') }
858
+ },
859
+ async ({ names }) => {
860
+ try {
861
+ const out = await addShadcnComponents(root, names);
862
+ if (!out.ok) return fail(new Error(`shadcn failed:
863
+ ${out.out.slice(-1500)}`));
864
+ ws.catalog.invalidate();
865
+ const already = names.filter((n) => !out.added.includes(n));
866
+ return text(`\u2713 ${out.added.length ? `Added ${out.added.join(", ")}` : "Nothing new to add"}${already.length ? ` (already there: ${already.join(", ")})` : ""}. Use list_components to see their props.`);
867
+ } catch (err) {
868
+ return fail(err);
869
+ }
870
+ }
871
+ );
719
872
  return server;
720
873
  }
721
874
 
@@ -786,17 +939,27 @@ import os from "os";
786
939
  import path2 from "path";
787
940
  function findChromium() {
788
941
  if (process.env.TRUECANVAS_CHROME) return process.env.TRUECANVAS_CHROME;
789
- const cache2 = path2.join(os.homedir(), ".cache", "ms-playwright");
942
+ const cache2 = process.env.PLAYWRIGHT_BROWSERS_PATH || (process.platform === "win32" ? path2.join(process.env.LOCALAPPDATA ?? path2.join(os.homedir(), "AppData", "Local"), "ms-playwright") : process.platform === "darwin" ? path2.join(os.homedir(), "Library", "Caches", "ms-playwright") : path2.join(os.homedir(), ".cache", "ms-playwright"));
790
943
  if (fs2.existsSync(cache2)) {
791
944
  const shells = fs2.readdirSync(cache2).filter((d) => d.startsWith("chromium_headless_shell-")).sort().reverse();
792
945
  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"]) {
946
+ for (const sub of [
947
+ "chrome-headless-shell-linux64/chrome-headless-shell",
948
+ "chrome-linux/headless_shell",
949
+ "chrome-headless-shell-mac-arm64/chrome-headless-shell",
950
+ "chrome-headless-shell-mac-x64/chrome-headless-shell",
951
+ "chrome-headless-shell-win64/chrome-headless-shell.exe",
952
+ "chrome-win/headless_shell.exe"
953
+ ]) {
794
954
  const p = path2.join(cache2, d, sub);
795
955
  if (fs2.existsSync(p)) return p;
796
956
  }
797
957
  }
798
958
  }
959
+ const programFiles = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
799
960
  for (const p of [
961
+ ...programFiles.flatMap((dir) => [path2.join(dir, "Google", "Chrome", "Application", "chrome.exe"), path2.join(dir, "Microsoft", "Edge", "Application", "msedge.exe")]),
962
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
800
963
  "/usr/bin/chromium-browser",
801
964
  "/usr/bin/chromium",
802
965
  "/usr/bin/google-chrome-stable",
@@ -823,14 +986,40 @@ var Screenshotter = class {
823
986
  const { chromium } = await import("playwright-core");
824
987
  const executablePath = this.executable();
825
988
  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);
989
+ const launching = chromium.launch({ executablePath, headless: true, args: ["--disable-gpu", "--disable-dev-shm-usage"] });
990
+ this.browser = launching;
991
+ launching.then(
992
+ // a crashed browser is forgotten, so the next screenshot starts a fresh one
993
+ (b) => b.on("disconnected", () => this.browser === launching && (this.browser = null)),
994
+ () => this.browser === launching && (this.browser = null)
995
+ );
828
996
  }
829
997
  return this.browser;
830
998
  }
831
- touch() {
999
+ busy = 0;
1000
+ waiting = [];
1001
+ /**
1002
+ * Runs one screenshot in its own browser context. At most two run at once
1003
+ * (memory), and the browser only closes once nothing is in flight.
1004
+ */
1005
+ async withContext(options, fn) {
1006
+ if (this.busy >= 2) await new Promise((r) => this.waiting.push(r));
1007
+ this.busy++;
832
1008
  if (this.idle) clearTimeout(this.idle);
833
- this.idle = setTimeout(() => void this.close(), 6e4);
1009
+ try {
1010
+ const browser = await this.getBrowser();
1011
+ const ctx = await browser.newContext(options);
1012
+ try {
1013
+ return await fn(ctx);
1014
+ } finally {
1015
+ await ctx.close().catch(() => {
1016
+ });
1017
+ }
1018
+ } finally {
1019
+ this.busy--;
1020
+ this.waiting.shift()?.();
1021
+ if (this.busy === 0) this.idle = setTimeout(() => void this.close(), 6e4);
1022
+ }
834
1023
  }
835
1024
  async close() {
836
1025
  const b = this.browser;
@@ -840,10 +1029,7 @@ var Screenshotter = class {
840
1029
  }
841
1030
  /** Screenshot of the [data-tc-frame] element of a page (Assets thumbnails). */
842
1031
  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 {
1032
+ return this.withContext({ viewport, deviceScaleFactor: 2, colorScheme: "light" }, async (ctx) => {
847
1033
  const page = await ctx.newPage();
848
1034
  await page.goto(`${url}${url.includes("?") ? "&" : "?"}still=1`, { waitUntil: "load", timeout: 3e4 });
849
1035
  const el = await page.waitForSelector("[data-tc-frame]", { timeout: 2e4 });
@@ -851,23 +1037,18 @@ var Screenshotter = class {
851
1037
  await page.waitForFunction(() => window.__tcStill === true, void 0, { timeout: 6e3 }).catch(() => {
852
1038
  });
853
1039
  return await el.screenshot({ type: "png", omitBackground: true });
854
- } finally {
855
- await ctx.close();
856
- this.touch();
857
- }
1040
+ });
858
1041
  }
859
1042
  async frame(opts) {
860
- const browser = await this.getBrowser();
861
- this.touch();
862
- const ctx = await browser.newContext({
1043
+ const options = {
863
1044
  viewport: { width: opts.width, height: opts.height ?? 800 },
864
1045
  deviceScaleFactor: opts.scale ?? 1,
865
1046
  colorScheme: opts.theme === "dark" ? "dark" : "light",
866
1047
  // device frames: touch input, (hover: none) and mobile viewport rules
867
1048
  isMobile: !!opts.mobile,
868
1049
  hasTouch: !!opts.mobile
869
- });
870
- try {
1050
+ };
1051
+ return this.withContext(options, async (ctx) => {
871
1052
  const page = await ctx.newPage();
872
1053
  const url = `${this.appUrl}/truecanvas/${encodeURIComponent(opts.canvas)}?frame=${encodeURIComponent(opts.frame)}&theme=${opts.theme}&still=1`;
873
1054
  await page.goto(url, { waitUntil: "load", timeout: 3e4 });
@@ -882,10 +1063,7 @@ var Screenshotter = class {
882
1063
  await page.waitForTimeout(150);
883
1064
  const format = opts.jpeg ? { type: "jpeg", quality: 78 } : { type: "png" };
884
1065
  return await page.screenshot(format);
885
- } finally {
886
- await ctx.close();
887
- this.touch();
888
- }
1066
+ });
889
1067
  }
890
1068
  };
891
1069
 
@@ -939,7 +1117,7 @@ async function reviewPage(ws, canvas, ref, since) {
939
1117
  }
940
1118
  }
941
1119
  }
942
- const comments = path3.join(ws.config.canvasDir, `${canvas}.comments.json`);
1120
+ const comments = `${ws.config.canvasDir}/${canvas}.comments.json`;
943
1121
  if (await ws.git.show(ref, comments) !== readNow(ws, comments)) files.push(comments);
944
1122
  if (!files.length) return null;
945
1123
  const history = exists ? ws.changesSince(canvas, since) : [];
@@ -967,7 +1145,7 @@ async function reviewAll(ws) {
967
1145
  const linked = new Set(ws.linkedFiles());
968
1146
  const out = [];
969
1147
  for (const canvas of names) {
970
- const own = [ws.relFile(canvas), path3.join(ws.config.canvasDir, `${canvas}.comments.json`)];
1148
+ const own = [ws.relFile(canvas), `${ws.config.canvasDir}/${canvas}.comments.json`];
971
1149
  const touchesLinked = [...linked].some((f) => changed.has(f));
972
1150
  if (!own.some((f) => changed.has(f)) && !touchesLinked) continue;
973
1151
  const page = await reviewPage(ws, canvas, ref, since).catch(() => null);
@@ -1035,6 +1213,24 @@ function prDesignSection(pages, images) {
1035
1213
  return lines.join("\n");
1036
1214
  }
1037
1215
 
1216
+ // src/server/body.ts
1217
+ async function readJson(req, limit = 8 * 1024 * 1024) {
1218
+ const chunks = [];
1219
+ let size = 0;
1220
+ for await (const chunk of req) {
1221
+ size += chunk.length;
1222
+ if (size > limit) throw new EditError("Request too large.");
1223
+ chunks.push(chunk);
1224
+ }
1225
+ const raw = Buffer.concat(chunks).toString("utf8");
1226
+ if (!raw) return {};
1227
+ try {
1228
+ return JSON.parse(raw);
1229
+ } catch {
1230
+ throw new EditError("The request body isn't valid JSON.");
1231
+ }
1232
+ }
1233
+
1038
1234
  // src/server/http.ts
1039
1235
  var MIME = {
1040
1236
  ".html": "text/html; charset=utf-8",
@@ -1116,6 +1312,7 @@ async function startServer(ws) {
1116
1312
  case "GET /api/state":
1117
1313
  return json(res, 200, {
1118
1314
  appUrl: config.appUrl,
1315
+ framework: config.framework,
1119
1316
  root: config.root,
1120
1317
  projectName: path4.basename(config.root),
1121
1318
  canvases: ws.canvases(),
@@ -1319,6 +1516,28 @@ async function startServer(ws) {
1319
1516
  return json(res, 200, readTokenData(config.root));
1320
1517
  case "GET /api/components":
1321
1518
  return json(res, 200, { components: await ws.catalog.load() });
1519
+ // ---------- libraries: icon sets and shadcn/ui ----------
1520
+ case "GET /api/libraries":
1521
+ return json(res, 200, libraryState(config.root));
1522
+ case "GET /api/libraries/shadcn":
1523
+ return json(res, 200, { ...await shadcnRegistry(), status: shadcnStatus(config.root) });
1524
+ case "GET /api/icons": {
1525
+ const icons = await loadIcons(config.root, url.searchParams.get("library") ?? "");
1526
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 240, 1e3);
1527
+ return json(res, 200, searchIcons(icons, url.searchParams.get("q") ?? "", limit));
1528
+ }
1529
+ case "POST /api/libraries/icons": {
1530
+ const { id } = await readJson(req);
1531
+ const out = await installIconLibrary(config.root, id);
1532
+ return json(res, 200, { ...out, out: out.out.slice(-4e3), state: libraryState(config.root) });
1533
+ }
1534
+ case "POST /api/libraries/shadcn": {
1535
+ const { names } = await readJson(req);
1536
+ const out = await addShadcnComponents(config.root, Array.isArray(names) ? names.map(String) : []);
1537
+ ws.catalog.invalidate();
1538
+ broadcast({ type: "catalog" });
1539
+ return json(res, 200, { ...out, out: out.out.slice(-4e3), state: libraryState(config.root) });
1540
+ }
1322
1541
  case "GET /api/events": {
1323
1542
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
1324
1543
  res.write(": connected\n\n");
@@ -1392,7 +1611,7 @@ async function startServer(ws) {
1392
1611
  }
1393
1612
  function serveStatic(res, pathname) {
1394
1613
  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");
1614
+ if (!inside(editorDir, file) || !fs4.existsSync(file) || fs4.statSync(file).isDirectory()) file = path4.join(editorDir, "index.html");
1396
1615
  if (!fs4.existsSync(file)) {
1397
1616
  res.writeHead(500, { "content-type": "text/plain" });
1398
1617
  return res.end("Editor bundle missing. Run `pnpm build` in packages/truecanvas.");
@@ -1400,7 +1619,7 @@ async function startServer(ws) {
1400
1619
  const ext = path4.extname(file);
1401
1620
  const immutable = pathname.startsWith("/assets/");
1402
1621
  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);
1622
+ fs4.createReadStream(file).on("error", () => res.destroy()).pipe(res);
1404
1623
  }
1405
1624
  const server = http.createServer(async (req, res) => {
1406
1625
  try {
@@ -1422,7 +1641,7 @@ async function startServer(ws) {
1422
1641
  const canvasDir = path4.join(config.root, config.canvasDir);
1423
1642
  fs4.mkdirSync(canvasDir, { recursive: true });
1424
1643
  const pending = /* @__PURE__ */ new Map();
1425
- fs4.watch(canvasDir, (_event, filename) => {
1644
+ const onCanvasFile = (filename) => {
1426
1645
  if (!filename) return;
1427
1646
  if (filename.endsWith(".comments.json")) {
1428
1647
  ws.commentsChanged(filename.slice(0, -".comments.json".length));
@@ -1439,11 +1658,34 @@ async function startServer(ws) {
1439
1658
  }
1440
1659
  }, 60)
1441
1660
  );
1442
- });
1661
+ };
1662
+ let canvasWatcher = null;
1663
+ let watchedIno = null;
1664
+ const watchCanvasDir = () => {
1665
+ let ino = null;
1666
+ try {
1667
+ ino = fs4.statSync(canvasDir).ino;
1668
+ } catch {
1669
+ ino = null;
1670
+ }
1671
+ if (ino === watchedIno && canvasWatcher) return;
1672
+ canvasWatcher?.close();
1673
+ canvasWatcher = null;
1674
+ watchedIno = ino;
1675
+ if (ino === null) return;
1676
+ canvasWatcher = fs4.watch(canvasDir, (_event, filename) => onCanvasFile(filename));
1677
+ canvasWatcher.on("error", () => {
1678
+ canvasWatcher?.close();
1679
+ canvasWatcher = null;
1680
+ });
1681
+ for (const f of fs4.readdirSync(canvasDir)) onCanvasFile(f);
1682
+ };
1683
+ watchCanvasDir();
1684
+ setInterval(watchCanvasDir, 2e3).unref();
1443
1685
  const appDirAbs = path4.join(config.root, config.appDir);
1444
- if (fs4.existsSync(appDirAbs)) {
1686
+ if (config.framework === "next" && fs4.existsSync(appDirAbs)) {
1445
1687
  const appPending = /* @__PURE__ */ new Map();
1446
- fs4.watch(appDirAbs, { recursive: true }, (_event, filename) => {
1688
+ const appWatcher = fs4.watch(appDirAbs, { recursive: true }, (_event, filename) => {
1447
1689
  if (!filename || !/(^|[\\/])(page|layout)\.[jt]sx$/.test(filename) || /^truecanvas[\\/]/.test(filename)) return;
1448
1690
  const rel = path4.join(config.appDir, filename).split(path4.sep).join("/");
1449
1691
  clearTimeout(appPending.get(rel));
@@ -1458,12 +1700,13 @@ async function startServer(ws) {
1458
1700
  }, 60)
1459
1701
  );
1460
1702
  });
1703
+ appWatcher.on("error", (err) => console.error(`Stopped watching ${config.appDir}: ${err.message}`));
1461
1704
  }
1462
1705
  let catalogTimer;
1463
1706
  for (const dir of componentRoots(config.components)) {
1464
1707
  const abs = path4.join(config.root, dir);
1465
1708
  if (!fs4.existsSync(abs)) continue;
1466
- fs4.watch(abs, { recursive: true }, () => {
1709
+ const componentWatcher = fs4.watch(abs, { recursive: true }, () => {
1467
1710
  clearTimeout(catalogTimer);
1468
1711
  catalogTimer = setTimeout(() => {
1469
1712
  ws.catalog.invalidate();
@@ -1471,6 +1714,7 @@ async function startServer(ws) {
1471
1714
  void syncRegistry();
1472
1715
  }, 250);
1473
1716
  });
1717
+ componentWatcher.on("error", (err) => console.error(`Stopped watching ${dir}: ${err.message}`));
1474
1718
  }
1475
1719
  await new Promise((resolve, reject) => {
1476
1720
  server.once("error", reject);
@@ -1491,21 +1735,14 @@ function json(res, status, body) {
1491
1735
  res.writeHead(status, { "content-type": "application/json" });
1492
1736
  res.end(JSON.stringify(body));
1493
1737
  }
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
1738
  function dedent(code, col) {
1506
1739
  const pad = " ".repeat(col);
1507
1740
  return code.split("\n").map((l, i) => i > 0 && l.startsWith(pad) ? l.slice(col) : l).join("\n");
1508
1741
  }
1742
+ function inside(dir, file) {
1743
+ const rel = path4.relative(dir, file);
1744
+ return !!rel && !rel.startsWith("..") && !path4.isAbsolute(rel);
1745
+ }
1509
1746
 
1510
1747
  // src/server/stdio.ts
1511
1748
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -1514,141 +1751,42 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
1514
1751
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
1515
1752
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
1516
1753
  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
1754
  const server = new Server({ name: "truecanvas", version: "0.1.0" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
1524
- server.oninitialized = async () => {
1525
- try {
1755
+ let client = null;
1756
+ const connect = () => {
1757
+ if (!client) {
1526
1758
  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);
1759
+ const c2 = new Client({ name: info?.name ?? "stdio-agent", version: info?.version ?? "0.0.0" });
1760
+ const pending = c2.connect(new StreamableHTTPClientTransport(new URL(mcpUrl))).then(() => c2);
1761
+ pending.catch(() => client === pending && (client = null));
1762
+ client = pending;
1763
+ }
1764
+ return client;
1765
+ };
1766
+ const withUpstream = async (fn) => {
1767
+ for (let attempt = 0; ; attempt++) {
1768
+ try {
1769
+ return await fn(await connect());
1770
+ } catch (err) {
1771
+ client = null;
1772
+ const lost = /session|not connected|ECONNREFUSED|ECONNRESET|fetch failed|404/i.test(err.message ?? "");
1773
+ if (attempt > 0 || !lost) throw err;
1774
+ }
1532
1775
  }
1533
1776
  };
1534
- server.setRequestHandler(ListToolsRequestSchema, async () => (await upstream).listTools());
1535
- server.setRequestHandler(CallToolRequestSchema, async (req) => (await upstream).callTool(req.params));
1777
+ server.setRequestHandler(ListToolsRequestSchema, () => withUpstream((c2) => c2.listTools()));
1778
+ server.setRequestHandler(CallToolRequestSchema, (req) => withUpstream((c2) => c2.callTool(req.params, void 0, { timeout: 18e4 })));
1536
1779
  await server.connect(new StdioServerTransport());
1537
1780
  }
1538
1781
 
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
- }
1782
+ // src/cli/index.ts
1783
+ import { createRequire } from "module";
1646
1784
 
1647
1785
  // src/hub/server.ts
1648
1786
  import http2 from "http";
1649
- import fs9 from "fs";
1787
+ import fs8 from "fs";
1650
1788
  import os5 from "os";
1651
- import path9 from "path";
1789
+ import path8 from "path";
1652
1790
  import { randomUUID as randomUUID2 } from "crypto";
1653
1791
  import { fileURLToPath as fileURLToPath2 } from "url";
1654
1792
  import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
@@ -1658,42 +1796,42 @@ import { StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from
1658
1796
  import { CallToolRequestSchema as CallToolRequestSchema2, ListToolsRequestSchema as ListToolsRequestSchema2, isInitializeRequest as isInitializeRequest2 } from "@modelcontextprotocol/sdk/types.js";
1659
1797
 
1660
1798
  // src/hub/projects.ts
1661
- import fs6 from "fs";
1799
+ import fs5 from "fs";
1662
1800
  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");
1801
+ import path5 from "path";
1802
+ var CONFIG_DIR = path5.join(process.env.XDG_CONFIG_HOME ?? path5.join(os2.homedir(), ".config"), "truecanvas");
1803
+ var FILE = path5.join(CONFIG_DIR, "projects.json");
1666
1804
  var ProjectStore = class {
1667
1805
  list = [];
1668
1806
  constructor() {
1669
1807
  try {
1670
- this.list = JSON.parse(fs6.readFileSync(FILE, "utf8"));
1808
+ this.list = JSON.parse(fs5.readFileSync(FILE, "utf8"));
1671
1809
  } catch {
1672
1810
  this.list = [];
1673
1811
  }
1674
1812
  }
1675
1813
  save() {
1676
- fs6.mkdirSync(CONFIG_DIR, { recursive: true });
1677
- fs6.writeFileSync(FILE, JSON.stringify(this.list, null, 2));
1814
+ fs5.mkdirSync(CONFIG_DIR, { recursive: true });
1815
+ fs5.writeFileSync(FILE, JSON.stringify(this.list, null, 2));
1678
1816
  }
1679
1817
  all() {
1680
1818
  return [...this.list].sort((a, b) => b.lastOpened - a.lastOpened).map((p) => ({ ...p, ...inspect(p.path) }));
1681
1819
  }
1682
1820
  touch(dir) {
1683
- const abs = path6.resolve(dir);
1821
+ const abs = path5.resolve(dir);
1684
1822
  const existing = this.list.find((p) => p.path === abs);
1685
1823
  if (existing) existing.lastOpened = Date.now();
1686
1824
  else this.list.push({ path: abs, name: projectName(abs), lastOpened: Date.now() });
1687
1825
  this.save();
1688
1826
  }
1689
1827
  forget(dir) {
1690
- this.list = this.list.filter((p) => p.path !== path6.resolve(dir));
1828
+ this.list = this.list.filter((p) => p.path !== path5.resolve(dir));
1691
1829
  this.save();
1692
1830
  }
1693
1831
  };
1694
1832
  function readPkg(dir) {
1695
1833
  try {
1696
- return JSON.parse(fs6.readFileSync(path6.join(dir, "package.json"), "utf8"));
1834
+ return JSON.parse(fs5.readFileSync(path5.join(dir, "package.json"), "utf8"));
1697
1835
  } catch {
1698
1836
  return null;
1699
1837
  }
@@ -1701,24 +1839,24 @@ function readPkg(dir) {
1701
1839
  function projectName(dir) {
1702
1840
  const pkg = readPkg(dir);
1703
1841
  const name = typeof pkg?.name === "string" ? pkg.name : "";
1704
- return name && !name.startsWith("@") ? name : path6.basename(dir);
1842
+ return name && !name.startsWith("@") ? name : path5.basename(dir);
1705
1843
  }
1706
1844
  function inspect(dir) {
1707
1845
  const pkg = readPkg(dir);
1708
- if (!pkg) return { next: false, ready: false, canvases: 0, exists: fs6.existsSync(dir) };
1846
+ if (!pkg) return { next: false, ready: false, canvases: 0, exists: fs5.existsSync(dir) };
1709
1847
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1710
1848
  let canvases = 0;
1711
1849
  for (const d of ["canvas", "src/canvas"]) {
1712
1850
  try {
1713
- canvases += fs6.readdirSync(path6.join(dir, d)).filter((f) => f.endsWith(".canvas.tsx")).length;
1851
+ canvases += fs5.readdirSync(path5.join(dir, d)).filter((f) => f.endsWith(".canvas.tsx")).length;
1714
1852
  } catch {
1715
1853
  }
1716
1854
  }
1717
- return { next: "next" in deps, ready: "truecanvas" in deps, canvases, exists: true };
1855
+ return { next: "next" in deps || "vite" in deps && "react" in deps, ready: "truecanvas" in deps, canvases, exists: true };
1718
1856
  }
1719
1857
  function discover() {
1720
1858
  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));
1859
+ const roots = ["Github", "GitHub", "github", "Projects", "projects", "code", "Code", "dev", "src", "Developer", "Documents", "work"].map((d) => path5.join(home, d)).filter((d) => fs5.existsSync(d));
1722
1860
  const found = [];
1723
1861
  let visited = 0;
1724
1862
  const walk = (dir, depth) => {
@@ -1726,7 +1864,7 @@ function discover() {
1726
1864
  visited++;
1727
1865
  let entries;
1728
1866
  try {
1729
- entries = fs6.readdirSync(dir, { withFileTypes: true });
1867
+ entries = fs5.readdirSync(dir, { withFileTypes: true });
1730
1868
  } catch {
1731
1869
  return;
1732
1870
  }
@@ -1736,111 +1874,98 @@ function discover() {
1736
1874
  }
1737
1875
  for (const e of entries) {
1738
1876
  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);
1877
+ walk(path5.join(dir, e.name), depth + 1);
1740
1878
  }
1741
1879
  };
1742
1880
  for (const r of roots) walk(r, 0);
1743
1881
  return found;
1744
1882
  }
1745
1883
  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));
1884
+ const abs = path5.resolve(dir.replace(/^~(?=$|[\\/])/, os2.homedir()));
1885
+ const dirs = fs5.readdirSync(abs, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => ({ name: e.name, next: inspect(path5.join(abs, e.name)).next })).sort((a, b) => Number(b.next) - Number(a.next) || a.name.localeCompare(b.name));
1748
1886
  return { path: abs, dirs };
1749
1887
  }
1750
1888
 
1751
1889
  // src/hub/runner.ts
1752
1890
  import { execFile, spawn as spawn2 } from "child_process";
1753
- import fs8 from "fs";
1891
+ import fs7 from "fs";
1754
1892
  import net from "net";
1755
1893
  import os4 from "os";
1756
- import path8 from "path";
1894
+ import path7 from "path";
1757
1895
 
1758
1896
  // src/cli/setup.ts
1759
1897
  import { spawn } from "child_process";
1760
1898
  import { createHash as createHash2 } from "crypto";
1761
- import fs7 from "fs";
1899
+ import fs6 from "fs";
1762
1900
  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
- }
1901
+ import path6 from "path";
1778
1902
  function addDevArgs(pm, spec) {
1779
- return pm === "npm" ? ["install", "-D", spec] : pm === "bun" ? ["add", "-d", spec] : ["add", "-D", spec];
1903
+ return addArgs(pm, [spec], true);
1780
1904
  }
1781
1905
  function runScript(pm, script) {
1782
1906
  return pm === "npm" ? `npm run ${script}` : `${pm} ${script}`;
1783
1907
  }
1784
1908
  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 ?? ""}` } });
1909
+ const safe = shellSafe(cmd, args);
1910
+ const p = spawn(safe.cmd, safe.args, { cwd, stdio: "inherit", env: toolEnv(), shell: safe.shell });
1786
1911
  p.on("error", reject);
1787
1912
  p.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`)));
1788
1913
  });
1789
1914
  function packageDir(cliFile) {
1790
- return path7.resolve(path7.dirname(cliFile), "..");
1915
+ return path6.resolve(path6.dirname(cliFile), "..");
1791
1916
  }
1792
1917
  function packageVersion(pkgDir) {
1793
1918
  try {
1794
- return JSON.parse(fs7.readFileSync(path7.join(pkgDir, "package.json"), "utf8")).version;
1919
+ return JSON.parse(fs6.readFileSync(path6.join(pkgDir, "package.json"), "utf8")).version;
1795
1920
  } catch {
1796
1921
  return "0.0.0";
1797
1922
  }
1798
1923
  }
1799
1924
  function isSourceCheckout(pkgDir) {
1800
- return !pkgDir.split(path7.sep).includes("node_modules") && fs7.existsSync(path7.join(pkgDir, "src", "cli"));
1925
+ return !pkgDir.split(path6.sep).includes("node_modules") && fs6.existsSync(path6.join(pkgDir, "src", "cli"));
1801
1926
  }
1802
1927
  async function packageSpec(pkgDir, exec) {
1803
1928
  if (process.env.TRUECANVAS_PACKAGE) return process.env.TRUECANVAS_PACKAGE;
1804
1929
  const version = packageVersion(pkgDir);
1805
1930
  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`);
1931
+ const cacheDir = path6.join(os3.homedir(), ".cache", "truecanvas");
1932
+ fs6.mkdirSync(cacheDir, { recursive: true });
1933
+ const file = path6.join(cacheDir, `truecanvas-${version}.tgz`);
1809
1934
  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);
1935
+ const hash = createHash2("sha256").update(fs6.readFileSync(file)).digest("hex").slice(0, 10);
1936
+ const named = path6.join(cacheDir, `truecanvas-${version}-${hash}.tgz`);
1937
+ fs6.renameSync(file, named);
1813
1938
  return named;
1814
1939
  }
1815
1940
  function installedIn(root) {
1816
1941
  let declared = false;
1817
1942
  try {
1818
- const pkg = JSON.parse(fs7.readFileSync(path7.join(root, "package.json"), "utf8"));
1943
+ const pkg = JSON.parse(fs6.readFileSync(path6.join(root, "package.json"), "utf8"));
1819
1944
  declared = "truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies };
1820
1945
  } catch {
1821
1946
  }
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 };
1947
+ const file = path6.join(root, "node_modules", "truecanvas", "package.json");
1948
+ const installed = fs6.existsSync(file);
1949
+ return { declared, installed, version: installed ? packageVersion(path6.dirname(file)) : null };
1825
1950
  }
1826
1951
  function readPackage(root) {
1827
1952
  try {
1828
- return JSON.parse(fs7.readFileSync(path7.join(root, "package.json"), "utf8"));
1953
+ return JSON.parse(fs6.readFileSync(path6.join(root, "package.json"), "utf8"));
1829
1954
  } catch {
1830
1955
  return null;
1831
1956
  }
1832
1957
  }
1833
1958
  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));
1959
+ const file = path6.join(root, "node_modules", name, "package.json");
1960
+ if (fs6.existsSync(file)) return packageVersion(path6.dirname(file));
1836
1961
  const pkg = readPackage(root);
1837
1962
  return pkg ? { ...pkg.dependencies, ...pkg.devDependencies }[name] ?? null : null;
1838
1963
  }
1839
1964
  function mergeJson(file, update) {
1840
1965
  let data2 = {};
1841
- if (fs7.existsSync(file)) {
1966
+ if (fs6.existsSync(file)) {
1842
1967
  try {
1843
- data2 = JSON.parse(fs7.readFileSync(file, "utf8"));
1968
+ data2 = JSON.parse(fs6.readFileSync(file, "utf8"));
1844
1969
  } catch {
1845
1970
  throw new Error(`${file} isn't valid JSON; fix it or remove it, then run connect again.`);
1846
1971
  }
@@ -1848,24 +1973,24 @@ function mergeJson(file, update) {
1848
1973
  const before = JSON.stringify(data2);
1849
1974
  update(data2);
1850
1975
  if (JSON.stringify(data2) === before) return "unchanged";
1851
- fs7.mkdirSync(path7.dirname(file), { recursive: true });
1852
- fs7.writeFileSync(file, `${JSON.stringify(data2, null, 2)}
1976
+ fs6.mkdirSync(path6.dirname(file), { recursive: true });
1977
+ fs6.writeFileSync(file, `${JSON.stringify(data2, null, 2)}
1853
1978
  `);
1854
1979
  return "written";
1855
1980
  }
1856
1981
  function connectAgents(root, port, opts = {}) {
1857
1982
  const url = `http://localhost:${port}/mcp`;
1858
1983
  const out = { written: [], unchanged: [] };
1859
- const add = (rel, update) => out[mergeJson(path7.join(root, rel), update)].push(rel);
1984
+ const add = (rel, update) => out[mergeJson(path6.join(root, rel), update)].push(rel);
1860
1985
  const servers = (d, key) => d[key] ??= {};
1861
1986
  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 }));
1987
+ if (opts.cursor || fs6.existsSync(path6.join(root, ".cursor"))) add(".cursor/mcp.json", (d) => void (servers(d, "mcpServers").truecanvas = { url }));
1988
+ if (opts.vscode || fs6.existsSync(path6.join(root, ".vscode"))) add(".vscode/mcp.json", (d) => void (servers(d, "servers").truecanvas = { type: "http", url }));
1864
1989
  return out;
1865
1990
  }
1866
1991
  function hasAgentConfig(root, port) {
1867
1992
  try {
1868
- const d = JSON.parse(fs7.readFileSync(path7.join(root, ".mcp.json"), "utf8"));
1993
+ const d = JSON.parse(fs6.readFileSync(path6.join(root, ".mcp.json"), "utf8"));
1869
1994
  const url = d?.mcpServers?.truecanvas?.url;
1870
1995
  return typeof url === "string" && (port === void 0 || url.includes(`:${port}/`));
1871
1996
  } catch {
@@ -1893,64 +2018,103 @@ var Runner = class {
1893
2018
  cli;
1894
2019
  onChange;
1895
2020
  running = /* @__PURE__ */ new Map();
1896
- async open(dir) {
2021
+ opening = /* @__PURE__ */ new Map();
2022
+ ports = Promise.resolve();
2023
+ /** Opens a project; concurrent calls for the same folder share one start. */
2024
+ open(dir) {
1897
2025
  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);
2026
+ if (existing && existing.status !== "failed") return Promise.resolve(existing);
2027
+ const pending = this.opening.get(dir);
2028
+ if (pending) return pending;
2029
+ const job = this.start(dir).finally(() => this.opening.delete(dir));
2030
+ this.opening.set(dir, job);
2031
+ return job;
2032
+ }
2033
+ /** Two ports, picked one project at a time so parallel opens never collide. */
2034
+ allocatePorts() {
2035
+ const next = this.ports.then(async () => {
2036
+ const taken = new Set([...this.running.values()].flatMap((r) => [r.editorPort, r.appPort]));
2037
+ const editorPort = await freePort(4810, taken);
2038
+ taken.add(editorPort);
2039
+ return [editorPort, await freePort(3010, taken)];
2040
+ });
2041
+ this.ports = next.catch(() => {
2042
+ });
2043
+ return next;
2044
+ }
2045
+ async start(dir) {
2046
+ if (!fs7.existsSync(path7.join(dir, "package.json"))) throw new Error(`${dir} isn't there anymore (or has no package.json).`);
2047
+ const [editorPort, appPort] = await this.allocatePorts();
1903
2048
  const proc = spawn2(process.execPath, [this.cli, "dev", "--no-open", "--port", String(editorPort), "--app", `http://localhost:${appPort}`], {
1904
2049
  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"]
2050
+ env: toolEnv({ FORCE_COLOR: "0", TRUECANVAS_HUB: "1" }),
2051
+ stdio: ["ignore", "pipe", "pipe"],
2052
+ // its own process group: closing the project also stops the next dev it started
2053
+ detached: process.platform !== "win32"
1907
2054
  });
1908
- const run2 = { path: dir, editorPort, appPort, proc, status: "starting", log: [], startedAt: Date.now() };
1909
- this.running.set(dir, run2);
2055
+ const run3 = { path: dir, editorPort, appPort, proc, status: "starting", log: [], startedAt: Date.now() };
2056
+ this.running.set(dir, run3);
1910
2057
  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);
2058
+ for (const line of chunk.toString().split("\n")) if (line.trim()) run3.log.push(line.replace(/\x1b\[[0-9;]*m/g, ""));
2059
+ if (run3.log.length > 200) run3.log.splice(0, run3.log.length - 200);
1913
2060
  };
1914
2061
  proc.stdout.on("data", capture2);
1915
2062
  proc.stderr.on("data", capture2);
2063
+ proc.on("error", (err) => {
2064
+ run3.status = "failed";
2065
+ run3.error = err.message;
2066
+ this.onChange();
2067
+ });
1916
2068
  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}`;
2069
+ if (this.running.get(dir) !== run3) return;
2070
+ if (run3.status === "starting" || code) {
2071
+ run3.status = "failed";
2072
+ run3.error = run3.log.slice(-6).join("\n") || `exited with ${code}`;
1921
2073
  } else this.running.delete(dir);
1922
2074
  this.onChange();
1923
2075
  });
1924
2076
  this.onChange();
1925
2077
  const until = Date.now() + 6e4;
1926
- while (Date.now() < until && run2.status === "starting") {
2078
+ while (Date.now() < until && run3.status === "starting") {
1927
2079
  try {
1928
- const res = await fetch(`http://localhost:${editorPort}/api/state`, { signal: AbortSignal.timeout(1e3) });
2080
+ const res = await fetch(`http://127.0.0.1:${editorPort}/api/state`, { signal: AbortSignal.timeout(1e3) });
1929
2081
  if (res.ok) {
1930
- run2.status = "ready";
2082
+ run3.status = "ready";
1931
2083
  break;
1932
2084
  }
1933
2085
  } catch {
1934
2086
  }
1935
2087
  await new Promise((r) => setTimeout(r, 400));
1936
2088
  }
1937
- if (run2.status === "starting") {
1938
- run2.status = "failed";
1939
- run2.error = "Truecanvas didn't start within a minute.";
2089
+ if (run3.status === "starting") {
2090
+ run3.status = "failed";
2091
+ run3.error = "Truecanvas didn't start within a minute.";
2092
+ killTree(run3.proc, "SIGTERM");
1940
2093
  }
1941
2094
  this.onChange();
1942
- return run2;
2095
+ return run3;
1943
2096
  }
2097
+ /** Stops a project and everything it started; resolves once it has exited (or after 5s). */
1944
2098
  close(dir) {
1945
- const run2 = this.running.get(dir);
1946
- if (!run2) return;
2099
+ const run3 = this.running.get(dir);
2100
+ if (!run3) return Promise.resolve();
1947
2101
  this.running.delete(dir);
1948
- run2.proc.kill("SIGTERM");
1949
- setTimeout(() => run2.proc.exitCode === null && run2.proc.kill("SIGKILL"), 4e3).unref();
1950
2102
  this.onChange();
2103
+ if (run3.proc.exitCode !== null || run3.proc.signalCode !== null) return Promise.resolve();
2104
+ return new Promise((resolve) => {
2105
+ const force = setTimeout(() => killTree(run3.proc, "SIGKILL"), 4e3);
2106
+ const done = setTimeout(resolve, 5e3);
2107
+ run3.proc.once("exit", () => {
2108
+ clearTimeout(force);
2109
+ clearTimeout(done);
2110
+ resolve();
2111
+ });
2112
+ killTree(run3.proc, "SIGTERM");
2113
+ });
1951
2114
  }
1952
2115
  closeAll() {
1953
- for (const dir of [...this.running.keys()]) this.close(dir);
2116
+ return Promise.all([...this.running.keys()].map((dir) => this.close(dir))).then(() => {
2117
+ });
1954
2118
  }
1955
2119
  /** Resident memory (MB) of each project's process tree. */
1956
2120
  memory() {
@@ -1966,12 +2130,21 @@ var Runner = class {
1966
2130
  }
1967
2131
  const total = (pid) => (rss.get(pid) ?? 0) + (kids.get(pid) ?? []).reduce((s, k) => s + total(k), 0);
1968
2132
  const outMap = {};
1969
- for (const [dir, run2] of this.running) if (run2.proc.pid) outMap[dir] = Math.round(total(run2.proc.pid) / 1024);
2133
+ for (const [dir, run3] of this.running) if (run3.proc.pid) outMap[dir] = Math.round(total(run3.proc.pid) / 1024);
1970
2134
  resolve(outMap);
1971
2135
  });
1972
2136
  });
1973
2137
  }
1974
2138
  };
2139
+ function killTree(proc, signal) {
2140
+ try {
2141
+ if (process.platform === "win32" && proc.pid) spawn2("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { stdio: "ignore" }).on("error", () => proc.kill());
2142
+ else if (proc.pid) process.kill(-proc.pid, signal);
2143
+ else proc.kill(signal);
2144
+ } catch {
2145
+ proc.kill(signal);
2146
+ }
2147
+ }
1975
2148
  var jobs = /* @__PURE__ */ new Map();
1976
2149
  var jobSeq = 0;
1977
2150
  function getJob(id) {
@@ -1979,20 +2152,21 @@ function getJob(id) {
1979
2152
  }
1980
2153
  async function install(job, cmd, args, cwd) {
1981
2154
  try {
1982
- await run(job, cmd, args, cwd);
2155
+ await run2(job, cmd, args, cwd);
1983
2156
  } catch (err) {
1984
2157
  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"));
2158
+ const installed = fs7.existsSync(path7.join(cwd, "node_modules", "truecanvas", "package.json"));
1986
2159
  if (!(ignoredBuilds && installed)) throw err;
1987
2160
  job.log.push("(pnpm only complained about other packages' build scripts; Truecanvas is installed, continuing)");
1988
2161
  }
1989
2162
  }
1990
- function run(job, cmd, args, cwd) {
2163
+ function run2(job, cmd, args, cwd) {
1991
2164
  job.log.push(`$ ${cmd} ${args.join(" ")}`);
1992
2165
  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"] });
2166
+ const safe = shellSafe(cmd, args);
2167
+ const p = spawn2(safe.cmd, safe.args, { cwd, env: toolEnv({ CI: "1", FORCE_COLOR: "0" }), stdio: ["ignore", "pipe", "pipe"], shell: safe.shell });
1994
2168
  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, ""));
2169
+ for (const line of c2.toString().split(/\r?\n/)) if (line.trim()) job.log.push(line.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, ""));
1996
2170
  if (job.log.length > 300) job.log.splice(0, job.log.length - 300);
1997
2171
  };
1998
2172
  p.stdout.on("data", capture2);
@@ -2014,66 +2188,70 @@ function startJob(title, work) {
2014
2188
  return job;
2015
2189
  }
2016
2190
  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"] };
2191
+ const pm = detectPm(dir);
2192
+ return { cmd: pm, add: addArgs(pm, [], true) };
2193
+ }
2194
+ async function preferredPm() {
2195
+ const { ok } = await capture("pnpm", ["--version"], os4.homedir());
2196
+ return ok ? "pnpm" : "npm";
2021
2197
  }
2022
2198
  function setupJob(dir, pkgDir, cli) {
2023
- return startJob(`Set up ${path8.basename(dir)}`, async (job) => {
2199
+ return startJob(`Set up ${path7.basename(dir)}`, async (job) => {
2024
2200
  const pm = packageManager(dir);
2025
- const source = await packageSpec(pkgDir, (cmd, args, cwd) => run(job, cmd, args, cwd));
2201
+ const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
2026
2202
  await install(job, pm.cmd, [...pm.add, source], dir);
2027
- await run(job, process.execPath, [cli, "init"], dir);
2203
+ await run2(job, process.execPath, [cli, "init"], dir);
2028
2204
  return dir;
2029
2205
  });
2030
2206
  }
2031
2207
  function createJob(name, parent, pkgDir, cli) {
2032
2208
  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);
2209
+ const parentDir = path7.resolve(parent.replace(/^~(?=$|[\\/])/, os4.homedir()));
2210
+ const dir = path7.join(parentDir, safe);
2035
2211
  return startJob(`Create ${safe}`, async (job) => {
2036
2212
  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);
2213
+ if (fs7.existsSync(dir)) throw new Error(`${dir} already exists.`);
2214
+ fs7.mkdirSync(parentDir, { recursive: true });
2215
+ const pm = await preferredPm();
2216
+ await run2(job, "npx", ["--yes", "create-next-app@latest", safe, "--ts", "--tailwind", "--app", "--eslint", "--no-src-dir", "--import-alias", "@/*", `--use-${pm}`, "--turbopack", "--yes"], parentDir);
2217
+ const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
2218
+ await install(job, pm, addArgs(pm, [source], true), dir);
2219
+ await run2(job, process.execPath, [cli, "init"], dir);
2043
2220
  return dir;
2044
2221
  });
2045
2222
  }
2046
2223
  function capture(cmd, args, cwd) {
2047
2224
  return new Promise((resolve) => {
2225
+ const safe = shellSafe(cmd, args);
2048
2226
  execFile(
2049
- cmd,
2050
- args,
2051
- { cwd, timeout: 6e4, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" } },
2227
+ safe.cmd,
2228
+ safe.args,
2229
+ { shell: safe.shell, cwd, timeout: 6e4, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" } },
2052
2230
  (err, stdout, stderr) => resolve({ ok: !err, out: err ? (stderr || err.message).trim() : stdout })
2053
2231
  );
2054
2232
  });
2055
2233
  }
2056
2234
  function cloneJob(repo, parent, pkgDir, cli) {
2057
- const parentDir = path8.resolve(parent.replace(/^~(?=$|\/)/, os4.homedir()));
2235
+ const parentDir = path7.resolve(parent.replace(/^~(?=$|[\\/])/, os4.homedir()));
2058
2236
  const name = repo.replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
2059
- const dir = path8.join(parentDir, name);
2237
+ const dir = path7.join(parentDir, name);
2060
2238
  return startJob(`Clone ${name}`, async (job) => {
2061
2239
  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 });
2240
+ if (fs7.existsSync(dir)) throw new Error(`${dir} already exists. Open it with Open folder instead.`);
2241
+ fs7.mkdirSync(parentDir, { recursive: true });
2064
2242
  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.");
2243
+ if (url) await run2(job, "git", ["clone", repo, dir], parentDir);
2244
+ else await run2(job, "gh", ["repo", "clone", repo, dir], parentDir);
2245
+ if (!fs7.existsSync(path7.join(dir, "package.json"))) throw new Error("Cloned, but there's no package.json at the root: Truecanvas needs a Next.js or Vite app.");
2068
2246
  const pm = packageManager(dir);
2069
2247
  await install(job, pm.cmd, ["install"], dir).catch(async (err) => {
2070
- if (!fs8.existsSync(path8.join(dir, "node_modules"))) throw err;
2248
+ if (!fs7.existsSync(path7.join(dir, "node_modules"))) throw err;
2071
2249
  });
2072
- const pkg = JSON.parse(fs8.readFileSync(path8.join(dir, "package.json"), "utf8"));
2250
+ const pkg = JSON.parse(fs7.readFileSync(path7.join(dir, "package.json"), "utf8"));
2073
2251
  if (!("truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies })) {
2074
- const source = await packageSpec(pkgDir, (cmd, args, cwd) => run(job, cmd, args, cwd));
2252
+ const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
2075
2253
  await install(job, pm.cmd, [...pm.add, source], dir);
2076
- await run(job, process.execPath, [cli, "init"], dir);
2254
+ await run2(job, process.execPath, [cli, "init"], dir);
2077
2255
  } else job.log.push("Truecanvas is already part of this project.");
2078
2256
  return dir;
2079
2257
  });
@@ -2088,13 +2266,13 @@ var MIME2 = {
2088
2266
  ".woff2": "font/woff2",
2089
2267
  ".png": "image/png"
2090
2268
  };
2091
- var HUB_INSTRUCTIONS = `Truecanvas hub: several projects, each a Next.js app with canvases of real React components.
2269
+ var HUB_INSTRUCTIONS = `Truecanvas hub: several projects, each a Next.js or Vite + React app with canvases of real React components.
2092
2270
  Your tools act on the current project (the active tab in the Truecanvas window, unless you pick one with open_project).
2093
2271
  Use list_projects to see them. All other tools are the regular Truecanvas canvas tools for that project.`;
2094
2272
  async function startHub(opts) {
2095
2273
  const store = new ProjectStore();
2096
2274
  const editorDir = fileURLToPath2(new URL("./editor/", import.meta.url));
2097
- const pkgDir = path9.resolve(path9.dirname(opts.cli), "..");
2275
+ const pkgDir = path8.resolve(path8.dirname(opts.cli), "..");
2098
2276
  const origin = `http://localhost:${opts.port}`;
2099
2277
  const allowedHosts = /* @__PURE__ */ new Set([`localhost:${opts.port}`, `127.0.0.1:${opts.port}`]);
2100
2278
  const clients = /* @__PURE__ */ new Set();
@@ -2124,28 +2302,59 @@ async function startHub(opts) {
2124
2302
  }
2125
2303
  const toolListeners = /* @__PURE__ */ new Set();
2126
2304
  const transports = /* @__PURE__ */ new Map();
2305
+ const lastUse = /* @__PURE__ */ new Map();
2306
+ setInterval(() => {
2307
+ for (const [id, at] of lastUse) {
2308
+ if (Date.now() - at < 60 * 6e4) continue;
2309
+ lastUse.delete(id);
2310
+ void transports.get(id)?.close();
2311
+ }
2312
+ }, 5 * 6e4).unref();
2127
2313
  function createProxy() {
2128
2314
  const server2 = new Server2({ name: "truecanvas", version: "0.1.0" }, { capabilities: { tools: { listChanged: true } }, instructions: HUB_INSTRUCTIONS });
2129
2315
  let current = null;
2130
2316
  const upstreams = /* @__PURE__ */ new Map();
2131
2317
  const target = () => current ?? active;
2318
+ const drop = (dir) => {
2319
+ const hit = upstreams.get(dir);
2320
+ upstreams.delete(dir);
2321
+ void hit?.client.then((c2) => c2.close()).catch(() => {
2322
+ });
2323
+ };
2132
2324
  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
- }
2325
+ const run3 = runner.running.get(dir);
2326
+ if (!run3 || run3.status !== "ready") return null;
2327
+ const hit = upstreams.get(dir);
2328
+ if (hit && hit.pid === run3.proc.pid) return hit.client;
2329
+ if (hit) drop(dir);
2330
+ const info = server2.getClientVersion();
2331
+ const client = new Client2({ name: info?.name ?? "agent", version: info?.version ?? "0.0.0" });
2332
+ const c2 = client.connect(new StreamableHTTPClientTransport2(new URL(`http://127.0.0.1:${run3.editorPort}/mcp`))).then(() => client);
2333
+ c2.catch(() => upstreams.delete(dir));
2334
+ upstreams.set(dir, { pid: run3.proc.pid, client: c2 });
2143
2335
  return c2;
2144
2336
  };
2337
+ const withUpstream = async (dir, fn) => {
2338
+ for (let attempt = 0; attempt < 2; attempt++) {
2339
+ const up = upstream(dir);
2340
+ if (!up) return null;
2341
+ try {
2342
+ return await fn(await up);
2343
+ } catch (err) {
2344
+ drop(dir);
2345
+ const lost = /session|not connected|ECONNREFUSED|ECONNRESET|fetch failed|404/i.test(err.message ?? "");
2346
+ if (attempt === 1 || !lost) throw err;
2347
+ }
2348
+ }
2349
+ return null;
2350
+ };
2145
2351
  const notify = () => void server2.sendToolListChanged().catch(() => {
2146
2352
  });
2147
2353
  toolListeners.add(notify);
2148
- server2.onclose = () => toolListeners.delete(notify);
2354
+ server2.onclose = () => {
2355
+ toolListeners.delete(notify);
2356
+ for (const dir of [...upstreams.keys()]) drop(dir);
2357
+ };
2149
2358
  const hubTools = [
2150
2359
  { 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
2360
  {
@@ -2156,8 +2365,7 @@ async function startHub(opts) {
2156
2365
  ];
2157
2366
  server2.setRequestHandler(ListToolsRequestSchema2, async () => {
2158
2367
  const dir = target();
2159
- const up = dir ? upstream(dir) : null;
2160
- const projectTools = up ? (await (await up).listTools()).tools : [];
2368
+ const projectTools = dir ? (await withUpstream(dir, (c2) => c2.listTools()).catch(() => null))?.tools ?? [] : [];
2161
2369
  return { tools: [...hubTools, ...projectTools] };
2162
2370
  });
2163
2371
  server2.setRequestHandler(CallToolRequestSchema2, async (req) => {
@@ -2169,32 +2377,42 @@ async function startHub(opts) {
2169
2377
  }
2170
2378
  if (req.params.name === "open_project") {
2171
2379
  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);
2380
+ const p = store.all().find((x) => x.name.toLowerCase() === q || x.path.toLowerCase() === q || path8.basename(x.path).toLowerCase() === q);
2173
2381
  if (!p) return text(`No project "${q}". Use list_projects.`, true);
2174
2382
  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
2383
  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);
2384
+ const run3 = await runner.open(p.path);
2385
+ if (run3.status !== "ready") return text(`Couldn't start ${p.name}: ${run3.error}`, true);
2178
2386
  current = p.path;
2179
2387
  notify();
2180
2388
  return text(`\u2713 ${p.name} is open. Your tools now act on it.`);
2181
2389
  }
2182
2390
  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);
2391
+ const out = dir ? await withUpstream(dir, (c2) => c2.callTool(req.params, void 0, { timeout: 18e4 })) : null;
2392
+ if (!out) return text("No project is open. Use list_projects and open_project, or open one in the Truecanvas window.", true);
2393
+ return out;
2186
2394
  });
2187
2395
  return server2;
2188
2396
  }
2189
2397
  async function handleMcp(req, res) {
2190
2398
  const sessionId = req.headers["mcp-session-id"];
2191
- const body = req.method === "POST" ? await readJson2(req) : void 0;
2399
+ const body = req.method === "POST" ? await readJson(req) : void 0;
2192
2400
  let transport = sessionId ? transports.get(sessionId) : void 0;
2401
+ if (sessionId && transport) lastUse.set(sessionId, Date.now());
2193
2402
  if (!transport) {
2194
2403
  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) });
2404
+ const t = new StreamableHTTPServerTransport2({
2405
+ sessionIdGenerator: () => randomUUID2(),
2406
+ onsessioninitialized: (id) => {
2407
+ transports.set(id, t);
2408
+ lastUse.set(id, Date.now());
2409
+ }
2410
+ });
2196
2411
  t.onclose = () => {
2197
- if (t.sessionId) transports.delete(t.sessionId);
2412
+ if (t.sessionId) {
2413
+ transports.delete(t.sessionId);
2414
+ lastUse.delete(t.sessionId);
2415
+ }
2198
2416
  };
2199
2417
  await createProxy().connect(t);
2200
2418
  transport = t;
@@ -2203,7 +2421,7 @@ async function startHub(opts) {
2203
2421
  }
2204
2422
  async function api(req, res, url) {
2205
2423
  const route = `${req.method} ${url.pathname}`;
2206
- const body = req.method === "POST" ? await readJson2(req) : {};
2424
+ const body = req.method === "POST" ? await readJson(req) : {};
2207
2425
  switch (route) {
2208
2426
  case "GET /api/hub/state":
2209
2427
  return json2(res, 200, await state());
@@ -2214,8 +2432,8 @@ async function startHub(opts) {
2214
2432
  case "GET /api/hub/icon": {
2215
2433
  const icon = findIcon(url.searchParams.get("path") ?? "");
2216
2434
  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);
2435
+ res.writeHead(200, { "content-type": ICON_MIME[path8.extname(icon)] ?? "application/octet-stream", "cache-control": "private, max-age=300" });
2436
+ return fs8.createReadStream(icon).on("error", () => res.destroy()).pipe(res);
2219
2437
  }
2220
2438
  case "GET /api/hub/ls":
2221
2439
  return json2(res, 200, listDirs(url.searchParams.get("path") || "~"));
@@ -2226,19 +2444,25 @@ async function startHub(opts) {
2226
2444
  req.on("close", () => clients.delete(res));
2227
2445
  return;
2228
2446
  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." });
2447
+ const dir = path8.resolve(body.path.replace(/^~(?=$|[\\/])/, os5.homedir()));
2448
+ if (!fs8.existsSync(path8.join(dir, "package.json"))) return json2(res, 400, { error: "No package.json in that folder. Pick the root of a Next.js or Vite app." });
2231
2449
  store.touch(dir);
2232
2450
  broadcast();
2233
- return json2(res, 200, { path: dir });
2451
+ return json2(res, 200, { path: dir, ready: store.all().find((p) => p.path === dir)?.ready ?? false });
2234
2452
  }
2235
2453
  case "POST /api/hub/open": {
2236
2454
  store.touch(body.path);
2237
2455
  active = body.path;
2238
2456
  if (body.focus) focus = { path: body.path, at: Date.now() };
2239
- const run2 = await runner.open(body.path);
2457
+ let run3;
2458
+ try {
2459
+ run3 = await runner.open(body.path);
2460
+ } catch (err) {
2461
+ broadcast();
2462
+ return json2(res, 400, { error: err.message });
2463
+ }
2240
2464
  broadcast();
2241
- return json2(res, 200, { status: run2.status, editor: `http://localhost:${run2.editorPort}`, error: run2.error });
2465
+ return json2(res, 200, { status: run3.status, editor: `http://localhost:${run3.editorPort}`, error: run3.error });
2242
2466
  }
2243
2467
  case "POST /api/hub/close":
2244
2468
  runner.close(body.path);
@@ -2290,8 +2514,7 @@ async function startHub(opts) {
2290
2514
  }
2291
2515
  case "POST /api/hub/quit":
2292
2516
  json2(res, 200, { ok: true });
2293
- runner.closeAll();
2294
- setTimeout(() => process.exit(0), 800);
2517
+ void runner.closeAll().finally(() => process.exit(0));
2295
2518
  return;
2296
2519
  }
2297
2520
  const job = /^GET \/api\/hub\/jobs\/(\d+)$/.exec(route);
@@ -2306,14 +2529,14 @@ async function startHub(opts) {
2306
2529
  return json2(res, 404, { error: `No route ${route}` });
2307
2530
  }
2308
2531
  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)) {
2532
+ let file = path8.join(editorDir, pathname === "/" ? "hub.html" : pathname);
2533
+ if (!inside2(editorDir, file) || !fs8.existsSync(file) || fs8.statSync(file).isDirectory()) file = path8.join(editorDir, "hub.html");
2534
+ if (!fs8.existsSync(file)) {
2312
2535
  res.writeHead(500, { "content-type": "text/plain" });
2313
2536
  return res.end("Hub bundle missing. Run `pnpm build`.");
2314
2537
  }
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);
2538
+ res.writeHead(200, { "content-type": MIME2[path8.extname(file)] ?? "application/octet-stream", "cache-control": pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache" });
2539
+ fs8.createReadStream(file).on("error", () => res.destroy()).pipe(res);
2317
2540
  }
2318
2541
  const server = http2.createServer(async (req, res) => {
2319
2542
  try {
@@ -2326,7 +2549,7 @@ async function startHub(opts) {
2326
2549
  if (req.method === "GET") return serveStatic(res, url.pathname);
2327
2550
  json2(res, 405, { error: "Method not allowed" });
2328
2551
  } catch (err) {
2329
- if (!res.headersSent) json2(res, 500, { error: err.message });
2552
+ if (!res.headersSent) json2(res, err instanceof EditError ? 400 : 500, { error: err.message });
2330
2553
  else res.end();
2331
2554
  }
2332
2555
  });
@@ -2335,36 +2558,37 @@ async function startHub(opts) {
2335
2558
  server.listen(opts.port, "127.0.0.1", resolve);
2336
2559
  });
2337
2560
  const shutdown = () => {
2338
- runner.closeAll();
2339
2561
  server.close();
2562
+ return runner.closeAll();
2340
2563
  };
2341
- process.on("SIGINT", () => (shutdown(), process.exit(0)));
2342
- process.on("SIGTERM", () => (shutdown(), process.exit(0)));
2564
+ const exit = () => void shutdown().finally(() => process.exit(0));
2565
+ process.once("SIGINT", exit);
2566
+ process.once("SIGTERM", exit);
2343
2567
  return { origin, shutdown };
2344
2568
  }
2345
2569
  var ICON_MIME = { ".ico": "image/x-icon", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
2346
2570
  function findIcon(dir) {
2347
- if (!dir || !fs9.existsSync(path9.join(dir, "package.json"))) return null;
2571
+ if (!dir || !fs8.existsSync(path8.join(dir, "package.json"))) return null;
2348
2572
  const names = ["icon.svg", "icon.png", "favicon.svg", "favicon.ico", "icon.ico", "apple-icon.png", "favicon.png", "logo.svg", "icon.jpg"];
2349
2573
  const roots = ["app", "src/app", "public", "static", "assets"];
2350
2574
  for (const root of roots) {
2351
2575
  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;
2576
+ const file = path8.join(dir, root, name);
2577
+ if (fs8.existsSync(file) && fs8.statSync(file).size < 2e6) return file;
2354
2578
  }
2355
2579
  }
2356
2580
  for (const root of ["app", "src/app"]) {
2357
2581
  let entries = [];
2358
2582
  try {
2359
- entries = fs9.readdirSync(path9.join(dir, root), { withFileTypes: true });
2583
+ entries = fs8.readdirSync(path8.join(dir, root), { withFileTypes: true });
2360
2584
  } catch {
2361
2585
  continue;
2362
2586
  }
2363
2587
  for (const e of entries) {
2364
2588
  if (!e.isDirectory()) continue;
2365
2589
  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;
2590
+ const file = path8.join(dir, root, e.name, name);
2591
+ if (fs8.existsSync(file)) return file;
2368
2592
  }
2369
2593
  }
2370
2594
  }
@@ -2374,22 +2598,15 @@ function json2(res, status, body) {
2374
2598
  res.writeHead(status, { "content-type": "application/json" });
2375
2599
  res.end(JSON.stringify(body));
2376
2600
  }
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) : {};
2601
+ function inside2(dir, file) {
2602
+ const rel = path8.relative(dir, file);
2603
+ return !!rel && !rel.startsWith("..") && !path8.isAbsolute(rel);
2387
2604
  }
2388
2605
 
2389
2606
  // src/hub/desktop.ts
2390
- import fs10 from "fs";
2607
+ import fs9 from "fs";
2391
2608
  import os6 from "os";
2392
- import path10 from "path";
2609
+ import path9 from "path";
2393
2610
  var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
2394
2611
  <defs>
2395
2612
  <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff9466"/><stop offset="1" stop-color="#ea6a3c"/></linearGradient>
@@ -2403,15 +2620,15 @@ var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
2403
2620
  <path fill="#f6f5f1" fill-rule="evenodd" d="M236 236h552v168H236z M428 316h168v472H428z"/>
2404
2621
  </svg>
2405
2622
  `;
2406
- var data = () => process.env.XDG_DATA_HOME ?? path10.join(os6.homedir(), ".local", "share");
2623
+ var data = () => process.env.XDG_DATA_HOME ?? path9.join(os6.homedir(), ".local", "share");
2407
2624
  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");
2625
+ const dir = path9.join(data(), "truecanvas");
2626
+ fs9.mkdirSync(dir, { recursive: true });
2627
+ const icon = path9.join(dir, "icon.svg");
2628
+ fs9.writeFileSync(icon, ICON);
2629
+ const launcher = path9.join(dir, "launch.sh");
2413
2630
  const url = `http://localhost:${port}`;
2414
- fs10.writeFileSync(
2631
+ fs9.writeFileSync(
2415
2632
  launcher,
2416
2633
  `#!/bin/sh
2417
2634
  # Truecanvas launcher: start the hub if needed, then open its window.
@@ -2420,7 +2637,7 @@ CLI=${JSON.stringify(cli)}
2420
2637
  URL=${JSON.stringify(url)}
2421
2638
  LOG="\${XDG_STATE_HOME:-$HOME/.local/state}/truecanvas-hub.log"
2422
2639
  # 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(":"))}
2640
+ export PATH=${JSON.stringify([path9.dirname(process.execPath), ...(process.env.PATH ?? "").split(path9.delimiter)].filter((p, i, a) => p && a.indexOf(p) === i).join(path9.delimiter))}
2424
2641
  mkdir -p "$(dirname "$LOG")"
2425
2642
 
2426
2643
  if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
@@ -2429,7 +2646,7 @@ if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
2429
2646
  while [ $i -lt 60 ] && ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; do sleep 0.25; i=$((i+1)); done
2430
2647
  fi
2431
2648
 
2432
- PROFILE=${JSON.stringify(path10.join(dir, "window"))}
2649
+ PROFILE=${JSON.stringify(path9.join(dir, "window"))}
2433
2650
  for b in google-chrome google-chrome-stable chromium-browser chromium brave-browser microsoft-edge; do
2434
2651
  if command -v "$b" >/dev/null 2>&1; then
2435
2652
  exec "$b" --app="$URL" --class=Truecanvas --user-data-dir="$PROFILE" --no-first-run --no-default-browser-check
@@ -2439,10 +2656,10 @@ exec xdg-open "$URL"
2439
2656
  `,
2440
2657
  { mode: 493 }
2441
2658
  );
2442
- const apps = path10.join(data(), "applications");
2443
- fs10.mkdirSync(apps, { recursive: true });
2444
- const desktop = path10.join(apps, "truecanvas.desktop");
2445
- fs10.writeFileSync(
2659
+ const apps = path9.join(data(), "applications");
2660
+ fs9.mkdirSync(apps, { recursive: true });
2661
+ const desktop = path9.join(apps, "truecanvas.desktop");
2662
+ fs9.writeFileSync(
2446
2663
  desktop,
2447
2664
  `[Desktop Entry]
2448
2665
  Type=Application
@@ -2458,11 +2675,11 @@ StartupWMClass=Truecanvas
2458
2675
  StartupNotify=true
2459
2676
  `
2460
2677
  );
2461
- let command = path10.join(os6.homedir(), ".local", "bin", "truecanvas");
2462
- const ours = !fs10.existsSync(command) || fs10.readFileSync(command, "utf8").includes("# truecanvas wrapper");
2678
+ let command = path9.join(os6.homedir(), ".local", "bin", "truecanvas");
2679
+ const ours = !fs9.existsSync(command) || fs9.readFileSync(command, "utf8").includes("# truecanvas wrapper");
2463
2680
  if (ours) {
2464
- fs10.mkdirSync(path10.dirname(command), { recursive: true });
2465
- fs10.writeFileSync(command, `#!/bin/sh
2681
+ fs9.mkdirSync(path9.dirname(command), { recursive: true });
2682
+ fs9.writeFileSync(command, `#!/bin/sh
2466
2683
  # truecanvas wrapper (installed by \`truecanvas desktop\`)
2467
2684
  exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
2468
2685
  `, { mode: 493 });
@@ -2470,19 +2687,19 @@ exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
2470
2687
  return { desktop, launcher, icon, command };
2471
2688
  }
2472
2689
  function launcherPath() {
2473
- const p = path10.join(data(), "truecanvas", "launch.sh");
2474
- return fs10.existsSync(p) ? p : null;
2690
+ const p = path9.join(data(), "truecanvas", "launch.sh");
2691
+ return fs9.existsSync(p) ? p : null;
2475
2692
  }
2476
2693
  function uninstallDesktop() {
2477
2694
  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);
2695
+ const cmd = path9.join(os6.homedir(), ".local", "bin", "truecanvas");
2696
+ if (fs9.existsSync(cmd) && fs9.readFileSync(cmd, "utf8").includes("# truecanvas wrapper")) {
2697
+ fs9.rmSync(cmd);
2481
2698
  removed.push(cmd);
2482
2699
  }
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 });
2700
+ for (const f of [path9.join(data(), "applications", "truecanvas.desktop"), path9.join(data(), "truecanvas")]) {
2701
+ if (fs9.existsSync(f)) {
2702
+ fs9.rmSync(f, { recursive: true, force: true });
2486
2703
  removed.push(f);
2487
2704
  }
2488
2705
  }
@@ -2495,8 +2712,8 @@ import { createInterface } from "readline/promises";
2495
2712
 
2496
2713
  // src/cli/doctor.ts
2497
2714
  import { execFile as execFile2 } from "child_process";
2498
- import fs11 from "fs";
2499
- import path11 from "path";
2715
+ import fs10 from "fs";
2716
+ import path10 from "path";
2500
2717
  var major = (v) => v ? Number(/(\d+)(?:\.(\d+))?/.exec(v)?.[1] ?? 0) : 0;
2501
2718
  function has(cmd, args, cwd) {
2502
2719
  return new Promise((resolve) => execFile2(cmd, args, { cwd, timeout: 8e3 }, (err, stdout, stderr) => resolve(err ? null : `${stdout}${stderr}`)));
@@ -2505,38 +2722,42 @@ async function doctor(root, cliVersion) {
2505
2722
  const checks = [];
2506
2723
  const add = (c2) => checks.push(c2);
2507
2724
  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)" });
2725
+ add(major(node) >= 22 ? { label: "Node.js", status: "ok", detail: node } : { label: "Node.js", status: "fail", detail: node, fix: "Install Node.js 22 or newer (24 LTS recommended: https://nodejs.org)" });
2509
2726
  const pkg = readPackage(root);
2510
2727
  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" });
2728
+ add({ label: "Project", status: "fail", detail: "no package.json here", fix: "Run truecanvas from the root of your Next.js or Vite app, or create one: npm create truecanvas" });
2512
2729
  return checks;
2513
2730
  }
2514
2731
  const pm = detectPm(root);
2515
2732
  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" });
2733
+ const vite = !next ? depVersion(root, "vite") : null;
2734
+ if (vite) add(major(vite) >= 5 ? { label: "Vite", status: "ok", detail: vite } : { label: "Vite", status: "fail", detail: vite, fix: `Upgrade to Vite 5 or newer: ${pm} ${pm === "npm" ? "install -D" : "add -D"} vite@latest` });
2735
+ else if (!next) add({ label: "Next.js or Vite", status: "fail", detail: "neither is a dependency", fix: "Truecanvas works with Next.js (App Router) and Vite + React apps. Create one: npm create truecanvas" });
2517
2736
  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
2737
  else add({ label: "Next.js", status: "ok", detail: next });
2519
2738
  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+)" });
2739
+ add(react && major(react) >= 19 ? { label: "React", status: "ok", detail: react } : { label: "React", status: "fail", detail: react ?? "missing", fix: `Truecanvas needs React 19: ${pm} ${pm === "npm" ? "install" : "add"} react@latest react-dom@latest` });
2521
2740
  const tw = depVersion(root, "tailwindcss");
2522
2741
  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
2742
  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/)" });
2743
+ if (config.framework === "next") add(fs10.existsSync(path10.join(root, config.appDir)) ? { label: "App Router", status: "ok", detail: config.appDir } : { label: "App Router", status: "fail", detail: `no ${config.appDir}/`, fix: "Truecanvas renders through the App Router (app/ or src/app/)" });
2525
2744
  const tc = installedIn(root);
2526
2745
  if (!tc.declared) add({ label: "Truecanvas in the project", status: "fail", detail: "not a dependency", fix: "npx truecanvas (sets it up)" });
2527
2746
  else if (!tc.installed) add({ label: "Truecanvas in the project", status: "fail", detail: "declared but not installed", fix: `${pm} install` });
2528
2747
  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
2748
  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" });
2749
+ const wrapped = configStatus(root, config.framework);
2750
+ const configLabel = config.framework === "vite" ? "vite.config" : "next.config";
2751
+ const uses = config.framework === "vite" ? "has the truecanvas() plugin" : "uses withTruecanvas()";
2752
+ add(wrapped.wrapped ? { label: configLabel, status: "ok", detail: `${path10.basename(wrapped.file)} ${uses}` } : { label: configLabel, status: "fail", detail: wrapped.file ? "Truecanvas not enabled" : "missing", fix: "npx truecanvas init" });
2532
2753
  const scripts = pkg.scripts ?? {};
2533
2754
  const script = Object.entries(scripts).find(([, v]) => v.includes("truecanvas"));
2534
2755
  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" });
2756
+ const ignore = fs10.existsSync(path10.join(root, ".gitignore")) ? fs10.readFileSync(path10.join(root, ".gitignore"), "utf8") : "";
2757
+ add(ignore.includes(config.routeDir) ? { label: "Generated route ignored", status: "ok" } : { label: "Generated route ignored", status: "warn", detail: `${config.routeDir}/ isn't in .gitignore`, fix: "npx truecanvas init" });
2537
2758
  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
2759
  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)" });
2760
+ add(chrome ? { label: "Screenshots", status: "ok", detail: path10.basename(chrome) } : { label: "Screenshots", status: "warn", detail: "no Chromium found", fix: "npx playwright install chromium-headless-shell (or install Chrome)" });
2540
2761
  const git = await has("git", ["rev-parse", "--is-inside-work-tree"], root);
2541
2762
  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
2763
  const gh = await has("gh", ["auth", "status"], root);
@@ -2550,14 +2771,14 @@ async function doctor(root, cliVersion) {
2550
2771
  var HELP = `truecanvas: design with your real React components
2551
2772
 
2552
2773
  Usage
2553
- truecanvas In a Next.js app: set it up (asks once) and start the editor.
2774
+ truecanvas In a Next.js or Vite app: set it up (asks once) and start the editor.
2554
2775
  Elsewhere: open the Truecanvas window.
2555
2776
  truecanvas connect Add Truecanvas to this project's .mcp.json (Claude Code, Cursor,
2556
2777
  VS Code): commit it and every teammate's agent connects
2557
2778
  truecanvas doctor Check this project and machine, with a fix for each problem
2558
2779
  truecanvas dev Start the editor + MCP server for this project (no window)
2559
2780
  truecanvas setup Install and set up Truecanvas in this project, without starting
2560
- truecanvas init Only write the config (next.config, script, canvas folder)
2781
+ truecanvas init Only write the config (next/vite config, script, canvas folder)
2561
2782
  truecanvas open Open the Truecanvas window (projects dashboard + tabs)
2562
2783
  truecanvas quit Quit the window and every app it started
2563
2784
  truecanvas desktop Add Truecanvas to your app launcher (Linux); "desktop remove" undoes it
@@ -2566,8 +2787,8 @@ Usage
2566
2787
  Options
2567
2788
  -y, --yes Don't ask: set up and connect agents
2568
2789
  --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)
2790
+ --app <url> Your app's dev URL (default http://localhost:3000, Vite :5173)
2791
+ --no-next Don't start the app's dev server (by default it starts when the app isn't running)
2571
2792
  --no-open Don't open the editor in your browser
2572
2793
  --cursor, --vscode connect: also write .cursor/mcp.json / .vscode/mcp.json
2573
2794
  -v, --version Print the version
@@ -2611,13 +2832,14 @@ async function main() {
2611
2832
  const yes = !!values.yes;
2612
2833
  if (command === "start") {
2613
2834
  const root2 = process.cwd();
2614
- if (!readPackage(root2) || !("next" in { ...readPackage(root2).dependencies, ...readPackage(root2).devDependencies })) {
2835
+ const deps = { ...readPackage(root2)?.dependencies, ...readPackage(root2)?.devDependencies };
2836
+ if (!("next" in deps) && !("vite" in deps && "react" in deps)) {
2615
2837
  return openWindow(cli, values.port ? Number(values.port) : 4800);
2616
2838
  }
2617
2839
  if (!await ensureSetup(root2, pkgDir, version, yes)) return;
2618
2840
  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)) {
2841
+ const local = path11.join(root2, "node_modules", "truecanvas", "dist", "cli.js");
2842
+ if (fs11.existsSync(local) && fs11.realpathSync(local) !== fs11.realpathSync(cli)) {
2621
2843
  const child = spawn3(process.execPath, [local, "dev", ...process.argv.slice(2).filter((a) => a !== "start" && a !== "-y" && a !== "--yes")], { stdio: "inherit" });
2622
2844
  for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => child.kill(sig));
2623
2845
  child.on("exit", (code) => process.exit(code ?? 0));
@@ -2685,6 +2907,7 @@ async function main() {
2685
2907
  return console.log(`${c.green("\u2713")} Truecanvas quit. Project apps it started are stopped too.`);
2686
2908
  }
2687
2909
  if (command === "desktop") {
2910
+ if (process.platform !== "linux") return console.log(`The app launcher entry is Linux-only for now. Run ${c.accent("truecanvas open")} to open the window.`);
2688
2911
  if (positionals[1] === "remove") {
2689
2912
  const removed = uninstallDesktop();
2690
2913
  console.log(removed.length ? `${c.green("\u2713")} Removed the launcher.` : "Nothing to remove.");
@@ -2698,8 +2921,8 @@ async function main() {
2698
2921
  return;
2699
2922
  }
2700
2923
  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).");
2924
+ if (!fs11.existsSync(path11.join(root, "package.json"))) {
2925
+ console.error("Run truecanvas from your app's root (no package.json here).");
2703
2926
  process.exit(1);
2704
2927
  }
2705
2928
  const config = loadConfig(root, { port: values.port ? Number(values.port) : void 0, appUrl: values.app });
@@ -2708,10 +2931,10 @@ async function main() {
2708
2931
  const report = initProject(config);
2709
2932
  ws.ensureRoute();
2710
2933
  console.log(`
2711
- ${c.accent("\u25C6")} ${c.bold("Truecanvas")} is set up in ${c.bold(path12.basename(root))}
2934
+ ${c.accent("\u25C6")} ${c.bold("Truecanvas")} is set up in ${c.bold(path11.basename(root))}
2712
2935
  `);
2713
2936
  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`)}`);
2937
+ console.log(` ${c.green("\u2713")} Canvas folder ${c.bold(config.canvasDir)} and dev route ${c.bold(config.routeDir)}`);
2715
2938
  for (const line of report.todo) console.log(` ${c.dim("!")} ${line}`);
2716
2939
  console.log(`
2717
2940
  Next: ${c.accent("npm run canvas")} ${c.dim("(starts your app and the editor)")}
@@ -2721,7 +2944,8 @@ async function main() {
2721
2944
  if (command === "mcp") {
2722
2945
  console.log = console.error;
2723
2946
  const mcpUrl = `http://localhost:${config.port}/mcp`;
2724
- const running = await fetch(`http://localhost:${config.port}/api/state`).then((r) => r.ok).catch(() => false);
2947
+ const answers = (p) => fetch(`http://127.0.0.1:${config.port}${p}`).then((r) => r.ok).catch(() => false);
2948
+ const running = await answers("/api/state") || await answers("/api/hub/state");
2725
2949
  if (!running) {
2726
2950
  ws.ensureRoute();
2727
2951
  await startServer(ws);
@@ -2737,7 +2961,7 @@ async function main() {
2737
2961
  process.exit(1);
2738
2962
  }
2739
2963
  if (command === "dev" && await openInWindow(root, config.port)) return;
2740
- const status = nextConfigStatus(root);
2964
+ const status = configStatus(root, config.framework);
2741
2965
  if (!status.wrapped) {
2742
2966
  const report = initProject(config);
2743
2967
  for (const line of report.done) console.log(` ${c.green("\u2713")} ${line}`);
@@ -2758,13 +2982,14 @@ async function main() {
2758
2982
  const mcp = `${origin}/mcp`;
2759
2983
  let next = null;
2760
2984
  const appUp = await reachable(config.appUrl);
2761
- if (!appUp && values.next && !values["no-next"]) next = startNext(root, config.appUrl);
2985
+ const devCommand = config.framework === "vite" ? "vite" : "next dev";
2986
+ if (!appUp && values.next && !values["no-next"]) next = startApp(root, config.appUrl, config.framework);
2762
2987
  console.log(`
2763
2988
  ${c.accent("\u25C6")} ${c.bold("Truecanvas")} ${c.dim(`v${version}`)}
2764
2989
 
2765
2990
  ${c.dim("Editor")} ${c.bold(origin)}
2766
2991
  ${c.dim("MCP")} ${mcp}
2767
- ${c.dim("App")} ${config.appUrl} ${c.dim(appUp ? "(already running)" : next ? "(starting next dev\u2026)" : "(not running)")}
2992
+ ${c.dim("App")} ${config.appUrl} ${c.dim(appUp ? "(already running)" : next ? `(starting ${devCommand}\u2026)` : "(not running)")}
2768
2993
  ${c.dim("Canvases")} ${ws.canvases().join(", ") || "none"}
2769
2994
 
2770
2995
  ${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)")}`}
@@ -2774,9 +2999,9 @@ ${hasAgentConfig(root, config.port) ? ` ${c.dim("Agents")} connected through
2774
2999
  const result = await Promise.race([waitFor(config.appUrl, 9e4), exited]);
2775
3000
  if (result === true) console.log(` ${c.green("\u2713")} App ready at ${config.appUrl}
2776
3001
  `);
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.")}
3002
+ else if (result === "exited") console.log(` ${c.dim(`${devCommand} stopped (see above). Start your app, the editor connects as soon as it's up.`)}
2778
3003
  `);
2779
- else console.log(` ${c.dim("next dev is taking a while. The editor will connect when it's up.")}
3004
+ else console.log(` ${c.dim(`${devCommand} is taking a while. The editor will connect when it's up.`)}
2780
3005
  `);
2781
3006
  }
2782
3007
  if (values.open && !values["no-open"]) openBrowser(origin);
@@ -2784,14 +3009,16 @@ ${hasAgentConfig(root, config.port) ? ` ${c.dim("Agents")} connected through
2784
3009
  const stop = async () => {
2785
3010
  if (stopping) return;
2786
3011
  stopping = true;
2787
- next?.kill("SIGINT");
2788
- await shutdown();
3012
+ const nextGone = next && next.exitCode === null ? new Promise((r) => next.once("exit", r)) : Promise.resolve();
3013
+ if (next) stopTree(next, "SIGINT");
3014
+ await Promise.all([shutdown(), Promise.race([nextGone, new Promise((r) => setTimeout(r, 4e3))])]);
3015
+ if (next && next.exitCode === null) stopTree(next, "SIGKILL");
2789
3016
  process.exit(0);
2790
3017
  };
2791
3018
  process.on("SIGINT", stop);
2792
3019
  process.on("SIGTERM", stop);
2793
3020
  next?.on("exit", (code) => {
2794
- if (!stopping) console.log(` ${c.dim(`next dev exited (${code ?? "signal"}).`)}`);
3021
+ if (!stopping) console.log(` ${c.dim(`${devCommand} exited (${code ?? "signal"}).`)}`);
2795
3022
  });
2796
3023
  }
2797
3024
  async function openWindow(cli, port) {
@@ -2816,7 +3043,7 @@ async function openInWindow(root, port) {
2816
3043
  const post = (route, body) => fetch(`${hub}${route}`, { method: "POST", headers: { "content-type": "application/json", origin: hub }, body: JSON.stringify(body) }).catch(() => null);
2817
3044
  await post("/api/hub/add", { path: root });
2818
3045
  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})`)}.`);
3046
+ console.log(`${c.accent("\u25C6")} Opened ${c.bold(path11.basename(root))} in the Truecanvas window ${c.dim(`(${hub})`)}.`);
2820
3047
  return true;
2821
3048
  }
2822
3049
  async function ask(question) {
@@ -2830,9 +3057,9 @@ async function ask(question) {
2830
3057
  }
2831
3058
  async function ensureSetup(root, pkgDir, version, yes, force = false) {
2832
3059
  const tc = installedIn(root);
2833
- const wrapped = nextConfigStatus(root).wrapped;
3060
+ const wrapped = configStatus(root).wrapped;
2834
3061
  if (tc.declared && tc.installed && wrapped && !force) return true;
2835
- const name = readPackage(root)?.name ?? path12.basename(root);
3062
+ const name = readPackage(root)?.name ?? path11.basename(root);
2836
3063
  if (!yes) {
2837
3064
  if (!process.stdin.isTTY) {
2838
3065
  console.error(`Truecanvas isn't set up in ${name} yet. Run again with --yes to set it up.`);
@@ -2841,7 +3068,7 @@ async function ensureSetup(root, pkgDir, version, yes, force = false) {
2841
3068
  console.log(`
2842
3069
  ${c.accent("\u25C6")} ${c.bold("Truecanvas")} ${c.dim(`v${version}`)}
2843
3070
  `);
2844
- console.log(` This adds Truecanvas to ${c.bold(name)}: a dev dependency, a wrapper in next.config,`);
3071
+ console.log(` This adds Truecanvas to ${c.bold(name)}: a dev dependency, a plugin in ${configStatus(root).framework === "vite" ? "vite.config" : "next.config"},`);
2845
3072
  console.log(` a ${c.bold("canvas")} script and a canvas with your homepage. Nothing changes in production builds.
2846
3073
  `);
2847
3074
  if (!await ask("Set it up?")) return false;
@@ -2899,17 +3126,21 @@ async function waitFor(url, ms) {
2899
3126
  }
2900
3127
  return false;
2901
3128
  }
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 ");
3129
+ function startApp(root, appUrl, framework) {
3130
+ const port = new URL(appUrl).port || (framework === "vite" ? "5173" : "3000");
3131
+ const devArgs = framework === "vite" ? ["--port", port, "--strictPort", "--host", "127.0.0.1"] : ["dev", "-p", port, "-H", "127.0.0.1"];
3132
+ const pkg = framework === "vite" ? "vite" : "next";
3133
+ const bin = binScript(root, pkg);
3134
+ const [cmd, args] = bin ? [process.execPath, [bin, ...devArgs]] : [process.platform === "win32" ? "npx.cmd" : "npx", [pkg, ...devArgs]];
3135
+ const label = framework === "vite" ? "vite" : "next";
3136
+ const child = spawn3(cmd, args, { cwd: root, env: toolEnv(process.env.NO_COLOR ? {} : { FORCE_COLOR: "1" }), stdio: ["ignore", "pipe", "pipe"], shell: !bin && process.platform === "win32" });
3137
+ child.on("error", (err) => console.error(` ${c.dim(`${label} \u2502`)} couldn't start ${pkg}: ${err.message}`));
3138
+ const prefix = c.dim(`${label} \u2502 `);
2908
3139
  for (const stream of [child.stdout, child.stderr]) {
2909
3140
  let buf = "";
2910
3141
  stream.on("data", (chunk) => {
2911
3142
  buf += chunk.toString();
2912
- const lines = buf.split("\n");
3143
+ const lines = buf.split(/\r?\n/);
2913
3144
  buf = lines.pop();
2914
3145
  for (const line of lines) if (line.trim()) process.stdout.write(` ${prefix}${line}
2915
3146
  `);
@@ -2917,6 +3148,22 @@ function startNext(root, appUrl) {
2917
3148
  }
2918
3149
  return child;
2919
3150
  }
3151
+ function binScript(root, pkg) {
3152
+ try {
3153
+ const req = createRequire(path11.join(root, "package.json"));
3154
+ const manifest = req.resolve(`${pkg}/package.json`);
3155
+ const { bin } = JSON.parse(fs11.readFileSync(manifest, "utf8"));
3156
+ const rel = typeof bin === "string" ? bin : bin?.[pkg];
3157
+ return rel ? path11.join(path11.dirname(manifest), rel) : null;
3158
+ } catch {
3159
+ return null;
3160
+ }
3161
+ }
3162
+ function stopTree(child, signal) {
3163
+ if (process.platform === "win32" && child.pid) spawn3("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }).on("error", () => {
3164
+ });
3165
+ else child.kill(signal);
3166
+ }
2920
3167
  function openBrowser(url) {
2921
3168
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2922
3169
  try {