truecanvas 0.1.0 → 0.2.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,46 @@ import {
3
3
  DEVICES,
4
4
  EditError,
5
5
  Workspace,
6
+ addArgs,
7
+ assertCanvasName,
6
8
  clearCompare,
7
9
  componentsDir,
8
10
  describeComponent,
11
+ detectPm,
9
12
  findByPath,
10
13
  findDevice,
11
14
  findNode,
12
15
  frameChanges,
16
+ initProject,
13
17
  layoutFiles,
14
18
  listRoutes,
15
19
  loadConfig,
20
+ nextConfigStatus,
16
21
  outlineDoc,
17
22
  parseCanvas,
18
23
  readDesignTokens,
19
24
  readTokenData,
25
+ run,
26
+ shadcnAdd,
27
+ shadcnRegistry,
28
+ shadcnStatus,
20
29
  syncComponentRegistry,
30
+ toolEnv,
21
31
  writeCompare
22
- } from "./chunk-WB35FXGP.js";
23
- import "./chunk-753N4QRO.js";
32
+ } from "./chunk-ZPARY6OD.js";
33
+ import "./chunk-T2TCBSTG.js";
34
+ import {
35
+ ICON_LIBRARIES,
36
+ iconLibraries,
37
+ iconLibrary,
38
+ loadIcons,
39
+ searchIcons
40
+ } from "./chunk-C4SATRZT.js";
24
41
 
25
42
  // src/cli/index.ts
26
43
  import { spawn as spawn3 } from "child_process";
27
- import fs12 from "fs";
28
- import path12 from "path";
44
+ import fs11 from "fs";
45
+ import path11 from "path";
29
46
  import { parseArgs } from "util";
30
47
 
31
48
  // src/server/http.ts
@@ -41,6 +58,33 @@ import launchEditor from "launch-editor";
41
58
  // src/server/mcp.ts
42
59
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
43
60
  import { z } from "zod";
61
+
62
+ // src/core/libraries.ts
63
+ function libraryState(root) {
64
+ return { packageManager: detectPm(root), icons: iconLibraries(root), shadcn: shadcnStatus(root) };
65
+ }
66
+ var lock = Promise.resolve();
67
+ function exclusive(job) {
68
+ const next = lock.then(job, job);
69
+ lock = next.catch(() => {
70
+ });
71
+ return next;
72
+ }
73
+ function installIconLibrary(root, id) {
74
+ const lib = iconLibrary(id);
75
+ if (!lib) throw new EditError(`Unknown icon library "${id}". Known: ${iconLibraries(root).map((l) => l.id).join(", ")}.`);
76
+ return exclusive(async () => {
77
+ const pm = detectPm(root);
78
+ const res = await run(pm, addArgs(pm, [lib.package]), root);
79
+ return { ...res, package: lib.package };
80
+ });
81
+ }
82
+ function addShadcnComponents(root, names) {
83
+ if (!names.length) throw new EditError("Name at least one component, e.g. button.");
84
+ return exclusive(() => shadcnAdd(root, names));
85
+ }
86
+
87
+ // src/server/mcp.ts
44
88
  var INSTRUCTIONS = `Truecanvas is a design canvas whose layers are the project's real React components.
45
89
  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
90
 
@@ -50,6 +94,7 @@ Workflow:
50
94
  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
95
  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
96
  For mobile screens create frames with a device (e.g. device="iphone-16"): the right size, plus touch + pixel-ratio emulation in screenshots.
97
+ 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
98
  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
99
  5. focus to show the user what you changed. Everything is undoable by the user.`;
55
100
  var literal = z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number(), z.boolean()]))]);
@@ -67,6 +112,7 @@ function createMcpServer(ws, shots, session) {
67
112
  const resolveCanvas = (canvas) => {
68
113
  const c2 = canvas ?? lastCanvas ?? ws.selection.canvas ?? ws.canvases()[0];
69
114
  if (!c2) throw new Error("No canvases yet. Use create_canvas.");
115
+ assertCanvasName(c2);
70
116
  lastCanvas = c2;
71
117
  return c2;
72
118
  };
@@ -80,6 +126,7 @@ function createMcpServer(ws, shots, session) {
80
126
  set_class: "Adjusting layout",
81
127
  insert_jsx: "Inserting",
82
128
  insert_component: "Inserting",
129
+ insert_icon: "Inserting",
83
130
  replace: "Rewriting",
84
131
  duplicate: "Duplicating",
85
132
  delete: "Deleting",
@@ -110,7 +157,8 @@ function createMcpServer(ws, shots, session) {
110
157
  const c2 = cmd;
111
158
  const before = [c2.id, ...Array.isArray(c2.ids) ? c2.ids : [], c2.parent].filter((x) => typeof x === "string");
112
159
  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);
160
+ const isLayerId = (x) => /^(?:.+#)?t?\d+:\d+$/.test(x);
161
+ presence(cmd.canvas, "editing", `${VERBS[cmd.op] ?? "Editing"}\u2026`, before.filter(isLayerId), frameRef ?? before.find((x) => !isLayerId(x)) ?? null);
114
162
  const res = await ws.run(cmd, actor());
115
163
  presence(cmd.canvas, "editing", res.label, res.ids);
116
164
  const frames = /* @__PURE__ */ new Set();
@@ -716,6 +764,108 @@ ${msgs}`;
716
764
  }
717
765
  }
718
766
  );
767
+ const root = ws.config.root;
768
+ const installedIcons = () => iconLibraries(root).filter((l) => l.version);
769
+ server.registerTool(
770
+ "list_libraries",
771
+ {
772
+ title: "List libraries",
773
+ description: "Icon libraries (installed or available) and shadcn/ui status for this project.",
774
+ annotations: { readOnlyHint: true }
775
+ },
776
+ () => {
777
+ const st = libraryState(root);
778
+ 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}` : ""})`);
779
+ 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)";
780
+ return text(`Package manager: ${st.packageManager}
781
+ Icon libraries:
782
+ ${icons.join("\n")}
783
+ shadcn/ui: ${shadcn}`);
784
+ }
785
+ );
786
+ server.registerTool(
787
+ "install_library",
788
+ {
789
+ title: "Install icon library",
790
+ description: "Install an icon library into the project with its package manager (adds a dependency).",
791
+ inputSchema: { library: z.enum(ICON_LIBRARIES.map((l) => l.id)) }
792
+ },
793
+ async ({ library }) => {
794
+ try {
795
+ const out = await installIconLibrary(root, library);
796
+ if (!out.ok) return fail(new Error(`Installing ${out.package} failed:
797
+ ${out.out.slice(-1500)}`));
798
+ return text(`\u2713 Installed ${out.package}. Use search_icons to find icons.`);
799
+ } catch (err) {
800
+ return fail(err);
801
+ }
802
+ }
803
+ );
804
+ server.registerTool(
805
+ "search_icons",
806
+ {
807
+ title: "Search icons",
808
+ description: "Find icon names in an installed icon library. Without a query, lists the first icons.",
809
+ inputSchema: {
810
+ query: z.string().optional(),
811
+ library: z.string().optional().describe("Library id (lucide, tabler\u2026). Defaults to the first installed one."),
812
+ limit: z.number().int().min(1).max(500).optional()
813
+ },
814
+ annotations: { readOnlyHint: true }
815
+ },
816
+ async ({ query, library, limit }) => {
817
+ try {
818
+ const lib = library ?? installedIcons()[0]?.id;
819
+ if (!lib) return fail(new Error("No icon library is installed. Use install_library (lucide is a good default)."));
820
+ const res = searchIcons(await loadIcons(root, lib), query ?? "", limit ?? 60);
821
+ if (!res.icons.length) return text(`No icons match "${query}" in ${lib}.`);
822
+ return text(`${res.total} match${res.total === 1 ? "" : "es"} in ${lib}${res.total > res.icons.length ? ` (first ${res.icons.length})` : ""}:
823
+ ${res.icons.map((i) => i.name).join(", ")}`);
824
+ } catch (err) {
825
+ return fail(err);
826
+ }
827
+ }
828
+ );
829
+ server.registerTool(
830
+ "insert_icon",
831
+ {
832
+ title: "Insert icon",
833
+ description: "Insert an icon from an installed library as a child of a frame or node, with its import. className defaults to size-4.",
834
+ inputSchema: {
835
+ ...canvasArg,
836
+ parent: z.string(),
837
+ name: z.string().describe("Icon name from search_icons, e.g. Search"),
838
+ library: z.string().optional().describe("Library id. Defaults to the first installed one."),
839
+ index: z.number().int().optional(),
840
+ className: z.string().optional()
841
+ }
842
+ },
843
+ ({ canvas, parent, name, library, index, className }) => edit(() => {
844
+ const lib = library ?? installedIcons()[0]?.id;
845
+ if (!lib) throw new Error("No icon library is installed. Use install_library first.");
846
+ return { op: "insert_icon", canvas: resolveCanvas(canvas), parent, name, library: lib, index, className };
847
+ })
848
+ );
849
+ server.registerTool(
850
+ "add_shadcn_components",
851
+ {
852
+ title: "Add shadcn/ui components",
853
+ 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.",
854
+ inputSchema: { names: z.array(z.string()).min(1).describe('Registry names, e.g. ["button", "card", "dialog"]') }
855
+ },
856
+ async ({ names }) => {
857
+ try {
858
+ const out = await addShadcnComponents(root, names);
859
+ if (!out.ok) return fail(new Error(`shadcn failed:
860
+ ${out.out.slice(-1500)}`));
861
+ ws.catalog.invalidate();
862
+ const already = names.filter((n) => !out.added.includes(n));
863
+ 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.`);
864
+ } catch (err) {
865
+ return fail(err);
866
+ }
867
+ }
868
+ );
719
869
  return server;
720
870
  }
721
871
 
@@ -823,14 +973,40 @@ var Screenshotter = class {
823
973
  const { chromium } = await import("playwright-core");
824
974
  const executablePath = this.executable();
825
975
  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);
976
+ const launching = chromium.launch({ executablePath, headless: true, args: ["--disable-gpu", "--disable-dev-shm-usage"] });
977
+ this.browser = launching;
978
+ launching.then(
979
+ // a crashed browser is forgotten, so the next screenshot starts a fresh one
980
+ (b) => b.on("disconnected", () => this.browser === launching && (this.browser = null)),
981
+ () => this.browser === launching && (this.browser = null)
982
+ );
828
983
  }
829
984
  return this.browser;
830
985
  }
831
- touch() {
986
+ busy = 0;
987
+ waiting = [];
988
+ /**
989
+ * Runs one screenshot in its own browser context. At most two run at once
990
+ * (memory), and the browser only closes once nothing is in flight.
991
+ */
992
+ async withContext(options, fn) {
993
+ if (this.busy >= 2) await new Promise((r) => this.waiting.push(r));
994
+ this.busy++;
832
995
  if (this.idle) clearTimeout(this.idle);
833
- this.idle = setTimeout(() => void this.close(), 6e4);
996
+ try {
997
+ const browser = await this.getBrowser();
998
+ const ctx = await browser.newContext(options);
999
+ try {
1000
+ return await fn(ctx);
1001
+ } finally {
1002
+ await ctx.close().catch(() => {
1003
+ });
1004
+ }
1005
+ } finally {
1006
+ this.busy--;
1007
+ this.waiting.shift()?.();
1008
+ if (this.busy === 0) this.idle = setTimeout(() => void this.close(), 6e4);
1009
+ }
834
1010
  }
835
1011
  async close() {
836
1012
  const b = this.browser;
@@ -840,10 +1016,7 @@ var Screenshotter = class {
840
1016
  }
841
1017
  /** Screenshot of the [data-tc-frame] element of a page (Assets thumbnails). */
842
1018
  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 {
1019
+ return this.withContext({ viewport, deviceScaleFactor: 2, colorScheme: "light" }, async (ctx) => {
847
1020
  const page = await ctx.newPage();
848
1021
  await page.goto(`${url}${url.includes("?") ? "&" : "?"}still=1`, { waitUntil: "load", timeout: 3e4 });
849
1022
  const el = await page.waitForSelector("[data-tc-frame]", { timeout: 2e4 });
@@ -851,23 +1024,18 @@ var Screenshotter = class {
851
1024
  await page.waitForFunction(() => window.__tcStill === true, void 0, { timeout: 6e3 }).catch(() => {
852
1025
  });
853
1026
  return await el.screenshot({ type: "png", omitBackground: true });
854
- } finally {
855
- await ctx.close();
856
- this.touch();
857
- }
1027
+ });
858
1028
  }
859
1029
  async frame(opts) {
860
- const browser = await this.getBrowser();
861
- this.touch();
862
- const ctx = await browser.newContext({
1030
+ const options = {
863
1031
  viewport: { width: opts.width, height: opts.height ?? 800 },
864
1032
  deviceScaleFactor: opts.scale ?? 1,
865
1033
  colorScheme: opts.theme === "dark" ? "dark" : "light",
866
1034
  // device frames: touch input, (hover: none) and mobile viewport rules
867
1035
  isMobile: !!opts.mobile,
868
1036
  hasTouch: !!opts.mobile
869
- });
870
- try {
1037
+ };
1038
+ return this.withContext(options, async (ctx) => {
871
1039
  const page = await ctx.newPage();
872
1040
  const url = `${this.appUrl}/truecanvas/${encodeURIComponent(opts.canvas)}?frame=${encodeURIComponent(opts.frame)}&theme=${opts.theme}&still=1`;
873
1041
  await page.goto(url, { waitUntil: "load", timeout: 3e4 });
@@ -882,10 +1050,7 @@ var Screenshotter = class {
882
1050
  await page.waitForTimeout(150);
883
1051
  const format = opts.jpeg ? { type: "jpeg", quality: 78 } : { type: "png" };
884
1052
  return await page.screenshot(format);
885
- } finally {
886
- await ctx.close();
887
- this.touch();
888
- }
1053
+ });
889
1054
  }
890
1055
  };
891
1056
 
@@ -1035,6 +1200,24 @@ function prDesignSection(pages, images) {
1035
1200
  return lines.join("\n");
1036
1201
  }
1037
1202
 
1203
+ // src/server/body.ts
1204
+ async function readJson(req, limit = 8 * 1024 * 1024) {
1205
+ const chunks = [];
1206
+ let size = 0;
1207
+ for await (const chunk of req) {
1208
+ size += chunk.length;
1209
+ if (size > limit) throw new EditError("Request too large.");
1210
+ chunks.push(chunk);
1211
+ }
1212
+ const raw = Buffer.concat(chunks).toString("utf8");
1213
+ if (!raw) return {};
1214
+ try {
1215
+ return JSON.parse(raw);
1216
+ } catch {
1217
+ throw new EditError("The request body isn't valid JSON.");
1218
+ }
1219
+ }
1220
+
1038
1221
  // src/server/http.ts
1039
1222
  var MIME = {
1040
1223
  ".html": "text/html; charset=utf-8",
@@ -1319,6 +1502,28 @@ async function startServer(ws) {
1319
1502
  return json(res, 200, readTokenData(config.root));
1320
1503
  case "GET /api/components":
1321
1504
  return json(res, 200, { components: await ws.catalog.load() });
1505
+ // ---------- libraries: icon sets and shadcn/ui ----------
1506
+ case "GET /api/libraries":
1507
+ return json(res, 200, libraryState(config.root));
1508
+ case "GET /api/libraries/shadcn":
1509
+ return json(res, 200, { ...await shadcnRegistry(), status: shadcnStatus(config.root) });
1510
+ case "GET /api/icons": {
1511
+ const icons = await loadIcons(config.root, url.searchParams.get("library") ?? "");
1512
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 240, 1e3);
1513
+ return json(res, 200, searchIcons(icons, url.searchParams.get("q") ?? "", limit));
1514
+ }
1515
+ case "POST /api/libraries/icons": {
1516
+ const { id } = await readJson(req);
1517
+ const out = await installIconLibrary(config.root, id);
1518
+ return json(res, 200, { ...out, out: out.out.slice(-4e3), state: libraryState(config.root) });
1519
+ }
1520
+ case "POST /api/libraries/shadcn": {
1521
+ const { names } = await readJson(req);
1522
+ const out = await addShadcnComponents(config.root, Array.isArray(names) ? names.map(String) : []);
1523
+ ws.catalog.invalidate();
1524
+ broadcast({ type: "catalog" });
1525
+ return json(res, 200, { ...out, out: out.out.slice(-4e3), state: libraryState(config.root) });
1526
+ }
1322
1527
  case "GET /api/events": {
1323
1528
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
1324
1529
  res.write(": connected\n\n");
@@ -1392,7 +1597,7 @@ async function startServer(ws) {
1392
1597
  }
1393
1598
  function serveStatic(res, pathname) {
1394
1599
  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");
1600
+ if (!inside(editorDir, file) || !fs4.existsSync(file) || fs4.statSync(file).isDirectory()) file = path4.join(editorDir, "index.html");
1396
1601
  if (!fs4.existsSync(file)) {
1397
1602
  res.writeHead(500, { "content-type": "text/plain" });
1398
1603
  return res.end("Editor bundle missing. Run `pnpm build` in packages/truecanvas.");
@@ -1400,7 +1605,7 @@ async function startServer(ws) {
1400
1605
  const ext = path4.extname(file);
1401
1606
  const immutable = pathname.startsWith("/assets/");
1402
1607
  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);
1608
+ fs4.createReadStream(file).on("error", () => res.destroy()).pipe(res);
1404
1609
  }
1405
1610
  const server = http.createServer(async (req, res) => {
1406
1611
  try {
@@ -1422,7 +1627,7 @@ async function startServer(ws) {
1422
1627
  const canvasDir = path4.join(config.root, config.canvasDir);
1423
1628
  fs4.mkdirSync(canvasDir, { recursive: true });
1424
1629
  const pending = /* @__PURE__ */ new Map();
1425
- fs4.watch(canvasDir, (_event, filename) => {
1630
+ const onCanvasFile = (filename) => {
1426
1631
  if (!filename) return;
1427
1632
  if (filename.endsWith(".comments.json")) {
1428
1633
  ws.commentsChanged(filename.slice(0, -".comments.json".length));
@@ -1439,11 +1644,34 @@ async function startServer(ws) {
1439
1644
  }
1440
1645
  }, 60)
1441
1646
  );
1442
- });
1647
+ };
1648
+ let canvasWatcher = null;
1649
+ let watchedIno = null;
1650
+ const watchCanvasDir = () => {
1651
+ let ino = null;
1652
+ try {
1653
+ ino = fs4.statSync(canvasDir).ino;
1654
+ } catch {
1655
+ ino = null;
1656
+ }
1657
+ if (ino === watchedIno && canvasWatcher) return;
1658
+ canvasWatcher?.close();
1659
+ canvasWatcher = null;
1660
+ watchedIno = ino;
1661
+ if (ino === null) return;
1662
+ canvasWatcher = fs4.watch(canvasDir, (_event, filename) => onCanvasFile(filename));
1663
+ canvasWatcher.on("error", () => {
1664
+ canvasWatcher?.close();
1665
+ canvasWatcher = null;
1666
+ });
1667
+ for (const f of fs4.readdirSync(canvasDir)) onCanvasFile(f);
1668
+ };
1669
+ watchCanvasDir();
1670
+ setInterval(watchCanvasDir, 2e3).unref();
1443
1671
  const appDirAbs = path4.join(config.root, config.appDir);
1444
1672
  if (fs4.existsSync(appDirAbs)) {
1445
1673
  const appPending = /* @__PURE__ */ new Map();
1446
- fs4.watch(appDirAbs, { recursive: true }, (_event, filename) => {
1674
+ const appWatcher = fs4.watch(appDirAbs, { recursive: true }, (_event, filename) => {
1447
1675
  if (!filename || !/(^|[\\/])(page|layout)\.[jt]sx$/.test(filename) || /^truecanvas[\\/]/.test(filename)) return;
1448
1676
  const rel = path4.join(config.appDir, filename).split(path4.sep).join("/");
1449
1677
  clearTimeout(appPending.get(rel));
@@ -1458,12 +1686,13 @@ async function startServer(ws) {
1458
1686
  }, 60)
1459
1687
  );
1460
1688
  });
1689
+ appWatcher.on("error", (err) => console.error(`Stopped watching ${config.appDir}: ${err.message}`));
1461
1690
  }
1462
1691
  let catalogTimer;
1463
1692
  for (const dir of componentRoots(config.components)) {
1464
1693
  const abs = path4.join(config.root, dir);
1465
1694
  if (!fs4.existsSync(abs)) continue;
1466
- fs4.watch(abs, { recursive: true }, () => {
1695
+ const componentWatcher = fs4.watch(abs, { recursive: true }, () => {
1467
1696
  clearTimeout(catalogTimer);
1468
1697
  catalogTimer = setTimeout(() => {
1469
1698
  ws.catalog.invalidate();
@@ -1471,6 +1700,7 @@ async function startServer(ws) {
1471
1700
  void syncRegistry();
1472
1701
  }, 250);
1473
1702
  });
1703
+ componentWatcher.on("error", (err) => console.error(`Stopped watching ${dir}: ${err.message}`));
1474
1704
  }
1475
1705
  await new Promise((resolve, reject) => {
1476
1706
  server.once("error", reject);
@@ -1491,21 +1721,14 @@ function json(res, status, body) {
1491
1721
  res.writeHead(status, { "content-type": "application/json" });
1492
1722
  res.end(JSON.stringify(body));
1493
1723
  }
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
1724
  function dedent(code, col) {
1506
1725
  const pad = " ".repeat(col);
1507
1726
  return code.split("\n").map((l, i) => i > 0 && l.startsWith(pad) ? l.slice(col) : l).join("\n");
1508
1727
  }
1728
+ function inside(dir, file) {
1729
+ const rel = path4.relative(dir, file);
1730
+ return !!rel && !rel.startsWith("..") && !path4.isAbsolute(rel);
1731
+ }
1509
1732
 
1510
1733
  // src/server/stdio.ts
1511
1734
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -1514,141 +1737,39 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
1514
1737
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
1515
1738
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
1516
1739
  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
1740
  const server = new Server({ name: "truecanvas", version: "0.1.0" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
1524
- server.oninitialized = async () => {
1525
- try {
1741
+ let client = null;
1742
+ const connect = () => {
1743
+ if (!client) {
1526
1744
  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);
1745
+ const c2 = new Client({ name: info?.name ?? "stdio-agent", version: info?.version ?? "0.0.0" });
1746
+ const pending = c2.connect(new StreamableHTTPClientTransport(new URL(mcpUrl))).then(() => c2);
1747
+ pending.catch(() => client === pending && (client = null));
1748
+ client = pending;
1532
1749
  }
1750
+ return client;
1533
1751
  };
1534
- server.setRequestHandler(ListToolsRequestSchema, async () => (await upstream).listTools());
1535
- server.setRequestHandler(CallToolRequestSchema, async (req) => (await upstream).callTool(req.params));
1536
- await server.connect(new StdioServerTransport());
1537
- }
1538
-
1539
- // src/core/init.ts
1540
- import fs5 from "fs";
1541
- import path5 from "path";
1542
- var CONFIG_FILES = ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.mts"];
1543
- function nextConfigStatus(root) {
1544
- for (const name of CONFIG_FILES) {
1545
- const file = path5.join(root, name);
1546
- if (fs5.existsSync(file)) return { file, wrapped: fs5.readFileSync(file, "utf8").includes("withTruecanvas") };
1547
- }
1548
- return { file: null, wrapped: false };
1549
- }
1550
- function initProject(config) {
1551
- const { root } = config;
1552
- const report = { done: [], todo: [] };
1553
- const status = nextConfigStatus(root);
1554
- if (!status.file) {
1555
- const file = path5.join(root, "next.config.ts");
1556
- fs5.writeFileSync(file, `import type { NextConfig } from "next";
1557
- import { withTruecanvas } from "truecanvas/next";
1558
-
1559
- const nextConfig: NextConfig = {};
1560
-
1561
- export default withTruecanvas(nextConfig);
1562
- `);
1563
- report.done.push("Created next.config.ts with withTruecanvas()");
1564
- } else if (status.wrapped) {
1565
- report.done.push(`${path5.basename(status.file)} already uses withTruecanvas()`);
1566
- } else {
1567
- const src = fs5.readFileSync(status.file, "utf8");
1568
- const esm = /export\s+default\s+/.test(src);
1569
- const cjs = /module\.exports\s*=\s*/.test(src);
1570
- let next = null;
1571
- if (esm) {
1572
- next = src.replace(/export\s+default\s+([\s\S]+?);?\s*$/, (_m, expr) => `export default withTruecanvas(${expr.trim().replace(/;$/, "")});
1573
- `);
1574
- next = addImport(next, `import { withTruecanvas } from "truecanvas/next";`);
1575
- } else if (cjs) {
1576
- next = src.replace(/module\.exports\s*=\s*([\s\S]+?);?\s*$/, (_m, expr) => `module.exports = withTruecanvas(${expr.trim().replace(/;$/, "")});
1577
- `);
1578
- next = `const { withTruecanvas } = require("truecanvas/next");
1579
- ${next}`;
1580
- }
1581
- if (next && next !== src && next.includes("withTruecanvas(")) {
1582
- fs5.writeFileSync(status.file, next);
1583
- report.done.push(`Wrapped ${path5.basename(status.file)} with withTruecanvas()`);
1584
- } else {
1585
- report.todo.push(`Wrap your ${path5.basename(status.file)} export with withTruecanvas() from "truecanvas/next"`);
1586
- }
1587
- }
1588
- const pkgFile = path5.join(root, "package.json");
1589
- try {
1590
- const raw = fs5.readFileSync(pkgFile, "utf8");
1591
- const pkg = JSON.parse(raw);
1592
- pkg.scripts ??= {};
1593
- if (!Object.values(pkg.scripts).some((v) => typeof v === "string" && v.includes("truecanvas"))) {
1594
- pkg.scripts.canvas = "truecanvas dev";
1595
- const indent = /\n(\s+)"/.exec(raw)?.[1] ?? " ";
1596
- fs5.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, indent)}
1597
- `);
1598
- report.done.push(`Added the "canvas" script (npm run canvas)`);
1752
+ const withUpstream = async (fn) => {
1753
+ for (let attempt = 0; ; attempt++) {
1754
+ try {
1755
+ return await fn(await connect());
1756
+ } catch (err) {
1757
+ client = null;
1758
+ const lost = /session|not connected|ECONNREFUSED|ECONNRESET|fetch failed|404/i.test(err.message ?? "");
1759
+ if (attempt > 0 || !lost) throw err;
1760
+ }
1599
1761
  }
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");
1762
+ };
1763
+ server.setRequestHandler(ListToolsRequestSchema, () => withUpstream((c2) => c2.listTools()));
1764
+ server.setRequestHandler(CallToolRequestSchema, (req) => withUpstream((c2) => c2.callTool(req.params, void 0, { timeout: 18e4 })));
1765
+ await server.connect(new StdioServerTransport());
1645
1766
  }
1646
1767
 
1647
1768
  // src/hub/server.ts
1648
1769
  import http2 from "http";
1649
- import fs9 from "fs";
1770
+ import fs8 from "fs";
1650
1771
  import os5 from "os";
1651
- import path9 from "path";
1772
+ import path8 from "path";
1652
1773
  import { randomUUID as randomUUID2 } from "crypto";
1653
1774
  import { fileURLToPath as fileURLToPath2 } from "url";
1654
1775
  import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
@@ -1658,42 +1779,42 @@ import { StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from
1658
1779
  import { CallToolRequestSchema as CallToolRequestSchema2, ListToolsRequestSchema as ListToolsRequestSchema2, isInitializeRequest as isInitializeRequest2 } from "@modelcontextprotocol/sdk/types.js";
1659
1780
 
1660
1781
  // src/hub/projects.ts
1661
- import fs6 from "fs";
1782
+ import fs5 from "fs";
1662
1783
  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");
1784
+ import path5 from "path";
1785
+ var CONFIG_DIR = path5.join(process.env.XDG_CONFIG_HOME ?? path5.join(os2.homedir(), ".config"), "truecanvas");
1786
+ var FILE = path5.join(CONFIG_DIR, "projects.json");
1666
1787
  var ProjectStore = class {
1667
1788
  list = [];
1668
1789
  constructor() {
1669
1790
  try {
1670
- this.list = JSON.parse(fs6.readFileSync(FILE, "utf8"));
1791
+ this.list = JSON.parse(fs5.readFileSync(FILE, "utf8"));
1671
1792
  } catch {
1672
1793
  this.list = [];
1673
1794
  }
1674
1795
  }
1675
1796
  save() {
1676
- fs6.mkdirSync(CONFIG_DIR, { recursive: true });
1677
- fs6.writeFileSync(FILE, JSON.stringify(this.list, null, 2));
1797
+ fs5.mkdirSync(CONFIG_DIR, { recursive: true });
1798
+ fs5.writeFileSync(FILE, JSON.stringify(this.list, null, 2));
1678
1799
  }
1679
1800
  all() {
1680
1801
  return [...this.list].sort((a, b) => b.lastOpened - a.lastOpened).map((p) => ({ ...p, ...inspect(p.path) }));
1681
1802
  }
1682
1803
  touch(dir) {
1683
- const abs = path6.resolve(dir);
1804
+ const abs = path5.resolve(dir);
1684
1805
  const existing = this.list.find((p) => p.path === abs);
1685
1806
  if (existing) existing.lastOpened = Date.now();
1686
1807
  else this.list.push({ path: abs, name: projectName(abs), lastOpened: Date.now() });
1687
1808
  this.save();
1688
1809
  }
1689
1810
  forget(dir) {
1690
- this.list = this.list.filter((p) => p.path !== path6.resolve(dir));
1811
+ this.list = this.list.filter((p) => p.path !== path5.resolve(dir));
1691
1812
  this.save();
1692
1813
  }
1693
1814
  };
1694
1815
  function readPkg(dir) {
1695
1816
  try {
1696
- return JSON.parse(fs6.readFileSync(path6.join(dir, "package.json"), "utf8"));
1817
+ return JSON.parse(fs5.readFileSync(path5.join(dir, "package.json"), "utf8"));
1697
1818
  } catch {
1698
1819
  return null;
1699
1820
  }
@@ -1701,16 +1822,16 @@ function readPkg(dir) {
1701
1822
  function projectName(dir) {
1702
1823
  const pkg = readPkg(dir);
1703
1824
  const name = typeof pkg?.name === "string" ? pkg.name : "";
1704
- return name && !name.startsWith("@") ? name : path6.basename(dir);
1825
+ return name && !name.startsWith("@") ? name : path5.basename(dir);
1705
1826
  }
1706
1827
  function inspect(dir) {
1707
1828
  const pkg = readPkg(dir);
1708
- if (!pkg) return { next: false, ready: false, canvases: 0, exists: fs6.existsSync(dir) };
1829
+ if (!pkg) return { next: false, ready: false, canvases: 0, exists: fs5.existsSync(dir) };
1709
1830
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1710
1831
  let canvases = 0;
1711
1832
  for (const d of ["canvas", "src/canvas"]) {
1712
1833
  try {
1713
- canvases += fs6.readdirSync(path6.join(dir, d)).filter((f) => f.endsWith(".canvas.tsx")).length;
1834
+ canvases += fs5.readdirSync(path5.join(dir, d)).filter((f) => f.endsWith(".canvas.tsx")).length;
1714
1835
  } catch {
1715
1836
  }
1716
1837
  }
@@ -1718,7 +1839,7 @@ function inspect(dir) {
1718
1839
  }
1719
1840
  function discover() {
1720
1841
  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));
1842
+ const roots = ["Github", "GitHub", "github", "Projects", "projects", "code", "Code", "dev", "src", "Developer", "Documents", "work"].map((d) => path5.join(home, d)).filter((d) => fs5.existsSync(d));
1722
1843
  const found = [];
1723
1844
  let visited = 0;
1724
1845
  const walk = (dir, depth) => {
@@ -1726,7 +1847,7 @@ function discover() {
1726
1847
  visited++;
1727
1848
  let entries;
1728
1849
  try {
1729
- entries = fs6.readdirSync(dir, { withFileTypes: true });
1850
+ entries = fs5.readdirSync(dir, { withFileTypes: true });
1730
1851
  } catch {
1731
1852
  return;
1732
1853
  }
@@ -1736,111 +1857,97 @@ function discover() {
1736
1857
  }
1737
1858
  for (const e of entries) {
1738
1859
  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);
1860
+ walk(path5.join(dir, e.name), depth + 1);
1740
1861
  }
1741
1862
  };
1742
1863
  for (const r of roots) walk(r, 0);
1743
1864
  return found;
1744
1865
  }
1745
1866
  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));
1867
+ const abs = path5.resolve(dir.replace(/^~(?=$|\/)/, os2.homedir()));
1868
+ const dirs = fs5.readdirSync(abs, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => ({ name: e.name, next: inspect(path5.join(abs, e.name)).next })).sort((a, b) => Number(b.next) - Number(a.next) || a.name.localeCompare(b.name));
1748
1869
  return { path: abs, dirs };
1749
1870
  }
1750
1871
 
1751
1872
  // src/hub/runner.ts
1752
1873
  import { execFile, spawn as spawn2 } from "child_process";
1753
- import fs8 from "fs";
1874
+ import fs7 from "fs";
1754
1875
  import net from "net";
1755
1876
  import os4 from "os";
1756
- import path8 from "path";
1877
+ import path7 from "path";
1757
1878
 
1758
1879
  // src/cli/setup.ts
1759
1880
  import { spawn } from "child_process";
1760
1881
  import { createHash as createHash2 } from "crypto";
1761
- import fs7 from "fs";
1882
+ import fs6 from "fs";
1762
1883
  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
- }
1884
+ import path6 from "path";
1778
1885
  function addDevArgs(pm, spec) {
1779
- return pm === "npm" ? ["install", "-D", spec] : pm === "bun" ? ["add", "-d", spec] : ["add", "-D", spec];
1886
+ return addArgs(pm, [spec], true);
1780
1887
  }
1781
1888
  function runScript(pm, script) {
1782
1889
  return pm === "npm" ? `npm run ${script}` : `${pm} ${script}`;
1783
1890
  }
1784
1891
  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 ?? ""}` } });
1892
+ const p = spawn(cmd, args, { cwd, stdio: "inherit", env: toolEnv() });
1786
1893
  p.on("error", reject);
1787
1894
  p.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`)));
1788
1895
  });
1789
1896
  function packageDir(cliFile) {
1790
- return path7.resolve(path7.dirname(cliFile), "..");
1897
+ return path6.resolve(path6.dirname(cliFile), "..");
1791
1898
  }
1792
1899
  function packageVersion(pkgDir) {
1793
1900
  try {
1794
- return JSON.parse(fs7.readFileSync(path7.join(pkgDir, "package.json"), "utf8")).version;
1901
+ return JSON.parse(fs6.readFileSync(path6.join(pkgDir, "package.json"), "utf8")).version;
1795
1902
  } catch {
1796
1903
  return "0.0.0";
1797
1904
  }
1798
1905
  }
1799
1906
  function isSourceCheckout(pkgDir) {
1800
- return !pkgDir.split(path7.sep).includes("node_modules") && fs7.existsSync(path7.join(pkgDir, "src", "cli"));
1907
+ return !pkgDir.split(path6.sep).includes("node_modules") && fs6.existsSync(path6.join(pkgDir, "src", "cli"));
1801
1908
  }
1802
1909
  async function packageSpec(pkgDir, exec) {
1803
1910
  if (process.env.TRUECANVAS_PACKAGE) return process.env.TRUECANVAS_PACKAGE;
1804
1911
  const version = packageVersion(pkgDir);
1805
1912
  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`);
1913
+ const cacheDir = path6.join(os3.homedir(), ".cache", "truecanvas");
1914
+ fs6.mkdirSync(cacheDir, { recursive: true });
1915
+ const file = path6.join(cacheDir, `truecanvas-${version}.tgz`);
1809
1916
  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);
1917
+ const hash = createHash2("sha256").update(fs6.readFileSync(file)).digest("hex").slice(0, 10);
1918
+ const named = path6.join(cacheDir, `truecanvas-${version}-${hash}.tgz`);
1919
+ fs6.renameSync(file, named);
1813
1920
  return named;
1814
1921
  }
1815
1922
  function installedIn(root) {
1816
1923
  let declared = false;
1817
1924
  try {
1818
- const pkg = JSON.parse(fs7.readFileSync(path7.join(root, "package.json"), "utf8"));
1925
+ const pkg = JSON.parse(fs6.readFileSync(path6.join(root, "package.json"), "utf8"));
1819
1926
  declared = "truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies };
1820
1927
  } catch {
1821
1928
  }
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 };
1929
+ const file = path6.join(root, "node_modules", "truecanvas", "package.json");
1930
+ const installed = fs6.existsSync(file);
1931
+ return { declared, installed, version: installed ? packageVersion(path6.dirname(file)) : null };
1825
1932
  }
1826
1933
  function readPackage(root) {
1827
1934
  try {
1828
- return JSON.parse(fs7.readFileSync(path7.join(root, "package.json"), "utf8"));
1935
+ return JSON.parse(fs6.readFileSync(path6.join(root, "package.json"), "utf8"));
1829
1936
  } catch {
1830
1937
  return null;
1831
1938
  }
1832
1939
  }
1833
1940
  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));
1941
+ const file = path6.join(root, "node_modules", name, "package.json");
1942
+ if (fs6.existsSync(file)) return packageVersion(path6.dirname(file));
1836
1943
  const pkg = readPackage(root);
1837
1944
  return pkg ? { ...pkg.dependencies, ...pkg.devDependencies }[name] ?? null : null;
1838
1945
  }
1839
1946
  function mergeJson(file, update) {
1840
1947
  let data2 = {};
1841
- if (fs7.existsSync(file)) {
1948
+ if (fs6.existsSync(file)) {
1842
1949
  try {
1843
- data2 = JSON.parse(fs7.readFileSync(file, "utf8"));
1950
+ data2 = JSON.parse(fs6.readFileSync(file, "utf8"));
1844
1951
  } catch {
1845
1952
  throw new Error(`${file} isn't valid JSON; fix it or remove it, then run connect again.`);
1846
1953
  }
@@ -1848,24 +1955,24 @@ function mergeJson(file, update) {
1848
1955
  const before = JSON.stringify(data2);
1849
1956
  update(data2);
1850
1957
  if (JSON.stringify(data2) === before) return "unchanged";
1851
- fs7.mkdirSync(path7.dirname(file), { recursive: true });
1852
- fs7.writeFileSync(file, `${JSON.stringify(data2, null, 2)}
1958
+ fs6.mkdirSync(path6.dirname(file), { recursive: true });
1959
+ fs6.writeFileSync(file, `${JSON.stringify(data2, null, 2)}
1853
1960
  `);
1854
1961
  return "written";
1855
1962
  }
1856
1963
  function connectAgents(root, port, opts = {}) {
1857
1964
  const url = `http://localhost:${port}/mcp`;
1858
1965
  const out = { written: [], unchanged: [] };
1859
- const add = (rel, update) => out[mergeJson(path7.join(root, rel), update)].push(rel);
1966
+ const add = (rel, update) => out[mergeJson(path6.join(root, rel), update)].push(rel);
1860
1967
  const servers = (d, key) => d[key] ??= {};
1861
1968
  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 }));
1969
+ if (opts.cursor || fs6.existsSync(path6.join(root, ".cursor"))) add(".cursor/mcp.json", (d) => void (servers(d, "mcpServers").truecanvas = { url }));
1970
+ if (opts.vscode || fs6.existsSync(path6.join(root, ".vscode"))) add(".vscode/mcp.json", (d) => void (servers(d, "servers").truecanvas = { type: "http", url }));
1864
1971
  return out;
1865
1972
  }
1866
1973
  function hasAgentConfig(root, port) {
1867
1974
  try {
1868
- const d = JSON.parse(fs7.readFileSync(path7.join(root, ".mcp.json"), "utf8"));
1975
+ const d = JSON.parse(fs6.readFileSync(path6.join(root, ".mcp.json"), "utf8"));
1869
1976
  const url = d?.mcpServers?.truecanvas?.url;
1870
1977
  return typeof url === "string" && (port === void 0 || url.includes(`:${port}/`));
1871
1978
  } catch {
@@ -1893,64 +2000,103 @@ var Runner = class {
1893
2000
  cli;
1894
2001
  onChange;
1895
2002
  running = /* @__PURE__ */ new Map();
1896
- async open(dir) {
2003
+ opening = /* @__PURE__ */ new Map();
2004
+ ports = Promise.resolve();
2005
+ /** Opens a project; concurrent calls for the same folder share one start. */
2006
+ open(dir) {
1897
2007
  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);
2008
+ if (existing && existing.status !== "failed") return Promise.resolve(existing);
2009
+ const pending = this.opening.get(dir);
2010
+ if (pending) return pending;
2011
+ const job = this.start(dir).finally(() => this.opening.delete(dir));
2012
+ this.opening.set(dir, job);
2013
+ return job;
2014
+ }
2015
+ /** Two ports, picked one project at a time so parallel opens never collide. */
2016
+ allocatePorts() {
2017
+ const next = this.ports.then(async () => {
2018
+ const taken = new Set([...this.running.values()].flatMap((r) => [r.editorPort, r.appPort]));
2019
+ const editorPort = await freePort(4810, taken);
2020
+ taken.add(editorPort);
2021
+ return [editorPort, await freePort(3010, taken)];
2022
+ });
2023
+ this.ports = next.catch(() => {
2024
+ });
2025
+ return next;
2026
+ }
2027
+ async start(dir) {
2028
+ if (!fs7.existsSync(path7.join(dir, "package.json"))) throw new Error(`${dir} isn't there anymore (or has no package.json).`);
2029
+ const [editorPort, appPort] = await this.allocatePorts();
1903
2030
  const proc = spawn2(process.execPath, [this.cli, "dev", "--no-open", "--port", String(editorPort), "--app", `http://localhost:${appPort}`], {
1904
2031
  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"]
2032
+ env: toolEnv({ FORCE_COLOR: "0", TRUECANVAS_HUB: "1" }),
2033
+ stdio: ["ignore", "pipe", "pipe"],
2034
+ // its own process group: closing the project also stops the next dev it started
2035
+ detached: process.platform !== "win32"
1907
2036
  });
1908
- const run2 = { path: dir, editorPort, appPort, proc, status: "starting", log: [], startedAt: Date.now() };
1909
- this.running.set(dir, run2);
2037
+ const run3 = { path: dir, editorPort, appPort, proc, status: "starting", log: [], startedAt: Date.now() };
2038
+ this.running.set(dir, run3);
1910
2039
  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);
2040
+ for (const line of chunk.toString().split("\n")) if (line.trim()) run3.log.push(line.replace(/\x1b\[[0-9;]*m/g, ""));
2041
+ if (run3.log.length > 200) run3.log.splice(0, run3.log.length - 200);
1913
2042
  };
1914
2043
  proc.stdout.on("data", capture2);
1915
2044
  proc.stderr.on("data", capture2);
2045
+ proc.on("error", (err) => {
2046
+ run3.status = "failed";
2047
+ run3.error = err.message;
2048
+ this.onChange();
2049
+ });
1916
2050
  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}`;
2051
+ if (this.running.get(dir) !== run3) return;
2052
+ if (run3.status === "starting" || code) {
2053
+ run3.status = "failed";
2054
+ run3.error = run3.log.slice(-6).join("\n") || `exited with ${code}`;
1921
2055
  } else this.running.delete(dir);
1922
2056
  this.onChange();
1923
2057
  });
1924
2058
  this.onChange();
1925
2059
  const until = Date.now() + 6e4;
1926
- while (Date.now() < until && run2.status === "starting") {
2060
+ while (Date.now() < until && run3.status === "starting") {
1927
2061
  try {
1928
- const res = await fetch(`http://localhost:${editorPort}/api/state`, { signal: AbortSignal.timeout(1e3) });
2062
+ const res = await fetch(`http://127.0.0.1:${editorPort}/api/state`, { signal: AbortSignal.timeout(1e3) });
1929
2063
  if (res.ok) {
1930
- run2.status = "ready";
2064
+ run3.status = "ready";
1931
2065
  break;
1932
2066
  }
1933
2067
  } catch {
1934
2068
  }
1935
2069
  await new Promise((r) => setTimeout(r, 400));
1936
2070
  }
1937
- if (run2.status === "starting") {
1938
- run2.status = "failed";
1939
- run2.error = "Truecanvas didn't start within a minute.";
2071
+ if (run3.status === "starting") {
2072
+ run3.status = "failed";
2073
+ run3.error = "Truecanvas didn't start within a minute.";
2074
+ killTree(run3.proc, "SIGTERM");
1940
2075
  }
1941
2076
  this.onChange();
1942
- return run2;
2077
+ return run3;
1943
2078
  }
2079
+ /** Stops a project and everything it started; resolves once it has exited (or after 5s). */
1944
2080
  close(dir) {
1945
- const run2 = this.running.get(dir);
1946
- if (!run2) return;
2081
+ const run3 = this.running.get(dir);
2082
+ if (!run3) return Promise.resolve();
1947
2083
  this.running.delete(dir);
1948
- run2.proc.kill("SIGTERM");
1949
- setTimeout(() => run2.proc.exitCode === null && run2.proc.kill("SIGKILL"), 4e3).unref();
1950
2084
  this.onChange();
2085
+ if (run3.proc.exitCode !== null || run3.proc.signalCode !== null) return Promise.resolve();
2086
+ return new Promise((resolve) => {
2087
+ const force = setTimeout(() => killTree(run3.proc, "SIGKILL"), 4e3);
2088
+ const done = setTimeout(resolve, 5e3);
2089
+ run3.proc.once("exit", () => {
2090
+ clearTimeout(force);
2091
+ clearTimeout(done);
2092
+ resolve();
2093
+ });
2094
+ killTree(run3.proc, "SIGTERM");
2095
+ });
1951
2096
  }
1952
2097
  closeAll() {
1953
- for (const dir of [...this.running.keys()]) this.close(dir);
2098
+ return Promise.all([...this.running.keys()].map((dir) => this.close(dir))).then(() => {
2099
+ });
1954
2100
  }
1955
2101
  /** Resident memory (MB) of each project's process tree. */
1956
2102
  memory() {
@@ -1966,12 +2112,20 @@ var Runner = class {
1966
2112
  }
1967
2113
  const total = (pid) => (rss.get(pid) ?? 0) + (kids.get(pid) ?? []).reduce((s, k) => s + total(k), 0);
1968
2114
  const outMap = {};
1969
- for (const [dir, run2] of this.running) if (run2.proc.pid) outMap[dir] = Math.round(total(run2.proc.pid) / 1024);
2115
+ for (const [dir, run3] of this.running) if (run3.proc.pid) outMap[dir] = Math.round(total(run3.proc.pid) / 1024);
1970
2116
  resolve(outMap);
1971
2117
  });
1972
2118
  });
1973
2119
  }
1974
2120
  };
2121
+ function killTree(proc, signal) {
2122
+ try {
2123
+ if (process.platform !== "win32" && proc.pid) process.kill(-proc.pid, signal);
2124
+ else proc.kill(signal);
2125
+ } catch {
2126
+ proc.kill(signal);
2127
+ }
2128
+ }
1975
2129
  var jobs = /* @__PURE__ */ new Map();
1976
2130
  var jobSeq = 0;
1977
2131
  function getJob(id) {
@@ -1979,18 +2133,18 @@ function getJob(id) {
1979
2133
  }
1980
2134
  async function install(job, cmd, args, cwd) {
1981
2135
  try {
1982
- await run(job, cmd, args, cwd);
2136
+ await run2(job, cmd, args, cwd);
1983
2137
  } catch (err) {
1984
2138
  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"));
2139
+ const installed = fs7.existsSync(path7.join(cwd, "node_modules", "truecanvas", "package.json"));
1986
2140
  if (!(ignoredBuilds && installed)) throw err;
1987
2141
  job.log.push("(pnpm only complained about other packages' build scripts; Truecanvas is installed, continuing)");
1988
2142
  }
1989
2143
  }
1990
- function run(job, cmd, args, cwd) {
2144
+ function run2(job, cmd, args, cwd) {
1991
2145
  job.log.push(`$ ${cmd} ${args.join(" ")}`);
1992
2146
  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"] });
2147
+ const p = spawn2(cmd, args, { cwd, env: toolEnv({ CI: "1", FORCE_COLOR: "0" }), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
1994
2148
  const capture2 = (c2) => {
1995
2149
  for (const line of c2.toString().split("\n")) if (line.trim()) job.log.push(line.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, ""));
1996
2150
  if (job.log.length > 300) job.log.splice(0, job.log.length - 300);
@@ -2014,32 +2168,35 @@ function startJob(title, work) {
2014
2168
  return job;
2015
2169
  }
2016
2170
  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"] };
2171
+ const pm = detectPm(dir);
2172
+ return { cmd: pm, add: addArgs(pm, [], true) };
2173
+ }
2174
+ async function preferredPm() {
2175
+ const { ok } = await capture("pnpm", ["--version"], os4.homedir());
2176
+ return ok ? "pnpm" : "npm";
2021
2177
  }
2022
2178
  function setupJob(dir, pkgDir, cli) {
2023
- return startJob(`Set up ${path8.basename(dir)}`, async (job) => {
2179
+ return startJob(`Set up ${path7.basename(dir)}`, async (job) => {
2024
2180
  const pm = packageManager(dir);
2025
- const source = await packageSpec(pkgDir, (cmd, args, cwd) => run(job, cmd, args, cwd));
2181
+ const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
2026
2182
  await install(job, pm.cmd, [...pm.add, source], dir);
2027
- await run(job, process.execPath, [cli, "init"], dir);
2183
+ await run2(job, process.execPath, [cli, "init"], dir);
2028
2184
  return dir;
2029
2185
  });
2030
2186
  }
2031
2187
  function createJob(name, parent, pkgDir, cli) {
2032
2188
  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);
2189
+ const parentDir = path7.resolve(parent.replace(/^~(?=$|\/)/, os4.homedir()));
2190
+ const dir = path7.join(parentDir, safe);
2035
2191
  return startJob(`Create ${safe}`, async (job) => {
2036
2192
  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);
2193
+ if (fs7.existsSync(dir)) throw new Error(`${dir} already exists.`);
2194
+ fs7.mkdirSync(parentDir, { recursive: true });
2195
+ const pm = await preferredPm();
2196
+ await run2(job, "npx", ["--yes", "create-next-app@latest", safe, "--ts", "--tailwind", "--app", "--eslint", "--no-src-dir", "--import-alias", "@/*", `--use-${pm}`, "--turbopack", "--yes"], parentDir);
2197
+ const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
2198
+ await install(job, pm, addArgs(pm, [source], true), dir);
2199
+ await run2(job, process.execPath, [cli, "init"], dir);
2043
2200
  return dir;
2044
2201
  });
2045
2202
  }
@@ -2054,26 +2211,26 @@ function capture(cmd, args, cwd) {
2054
2211
  });
2055
2212
  }
2056
2213
  function cloneJob(repo, parent, pkgDir, cli) {
2057
- const parentDir = path8.resolve(parent.replace(/^~(?=$|\/)/, os4.homedir()));
2214
+ const parentDir = path7.resolve(parent.replace(/^~(?=$|\/)/, os4.homedir()));
2058
2215
  const name = repo.replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
2059
- const dir = path8.join(parentDir, name);
2216
+ const dir = path7.join(parentDir, name);
2060
2217
  return startJob(`Clone ${name}`, async (job) => {
2061
2218
  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 });
2219
+ if (fs7.existsSync(dir)) throw new Error(`${dir} already exists. Open it with Open folder instead.`);
2220
+ fs7.mkdirSync(parentDir, { recursive: true });
2064
2221
  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.");
2222
+ if (url) await run2(job, "git", ["clone", repo, dir], parentDir);
2223
+ else await run2(job, "gh", ["repo", "clone", repo, dir], parentDir);
2224
+ if (!fs7.existsSync(path7.join(dir, "package.json"))) throw new Error("Cloned, but there's no package.json at the root: Truecanvas needs a Next.js app.");
2068
2225
  const pm = packageManager(dir);
2069
2226
  await install(job, pm.cmd, ["install"], dir).catch(async (err) => {
2070
- if (!fs8.existsSync(path8.join(dir, "node_modules"))) throw err;
2227
+ if (!fs7.existsSync(path7.join(dir, "node_modules"))) throw err;
2071
2228
  });
2072
- const pkg = JSON.parse(fs8.readFileSync(path8.join(dir, "package.json"), "utf8"));
2229
+ const pkg = JSON.parse(fs7.readFileSync(path7.join(dir, "package.json"), "utf8"));
2073
2230
  if (!("truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies })) {
2074
- const source = await packageSpec(pkgDir, (cmd, args, cwd) => run(job, cmd, args, cwd));
2231
+ const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
2075
2232
  await install(job, pm.cmd, [...pm.add, source], dir);
2076
- await run(job, process.execPath, [cli, "init"], dir);
2233
+ await run2(job, process.execPath, [cli, "init"], dir);
2077
2234
  } else job.log.push("Truecanvas is already part of this project.");
2078
2235
  return dir;
2079
2236
  });
@@ -2094,7 +2251,7 @@ Use list_projects to see them. All other tools are the regular Truecanvas canvas
2094
2251
  async function startHub(opts) {
2095
2252
  const store = new ProjectStore();
2096
2253
  const editorDir = fileURLToPath2(new URL("./editor/", import.meta.url));
2097
- const pkgDir = path9.resolve(path9.dirname(opts.cli), "..");
2254
+ const pkgDir = path8.resolve(path8.dirname(opts.cli), "..");
2098
2255
  const origin = `http://localhost:${opts.port}`;
2099
2256
  const allowedHosts = /* @__PURE__ */ new Set([`localhost:${opts.port}`, `127.0.0.1:${opts.port}`]);
2100
2257
  const clients = /* @__PURE__ */ new Set();
@@ -2124,28 +2281,59 @@ async function startHub(opts) {
2124
2281
  }
2125
2282
  const toolListeners = /* @__PURE__ */ new Set();
2126
2283
  const transports = /* @__PURE__ */ new Map();
2284
+ const lastUse = /* @__PURE__ */ new Map();
2285
+ setInterval(() => {
2286
+ for (const [id, at] of lastUse) {
2287
+ if (Date.now() - at < 60 * 6e4) continue;
2288
+ lastUse.delete(id);
2289
+ void transports.get(id)?.close();
2290
+ }
2291
+ }, 5 * 6e4).unref();
2127
2292
  function createProxy() {
2128
2293
  const server2 = new Server2({ name: "truecanvas", version: "0.1.0" }, { capabilities: { tools: { listChanged: true } }, instructions: HUB_INSTRUCTIONS });
2129
2294
  let current = null;
2130
2295
  const upstreams = /* @__PURE__ */ new Map();
2131
2296
  const target = () => current ?? active;
2297
+ const drop = (dir) => {
2298
+ const hit = upstreams.get(dir);
2299
+ upstreams.delete(dir);
2300
+ void hit?.client.then((c2) => c2.close()).catch(() => {
2301
+ });
2302
+ };
2132
2303
  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
- }
2304
+ const run3 = runner.running.get(dir);
2305
+ if (!run3 || run3.status !== "ready") return null;
2306
+ const hit = upstreams.get(dir);
2307
+ if (hit && hit.pid === run3.proc.pid) return hit.client;
2308
+ if (hit) drop(dir);
2309
+ const info = server2.getClientVersion();
2310
+ const client = new Client2({ name: info?.name ?? "agent", version: info?.version ?? "0.0.0" });
2311
+ const c2 = client.connect(new StreamableHTTPClientTransport2(new URL(`http://127.0.0.1:${run3.editorPort}/mcp`))).then(() => client);
2312
+ c2.catch(() => upstreams.delete(dir));
2313
+ upstreams.set(dir, { pid: run3.proc.pid, client: c2 });
2143
2314
  return c2;
2144
2315
  };
2316
+ const withUpstream = async (dir, fn) => {
2317
+ for (let attempt = 0; attempt < 2; attempt++) {
2318
+ const up = upstream(dir);
2319
+ if (!up) return null;
2320
+ try {
2321
+ return await fn(await up);
2322
+ } catch (err) {
2323
+ drop(dir);
2324
+ const lost = /session|not connected|ECONNREFUSED|ECONNRESET|fetch failed|404/i.test(err.message ?? "");
2325
+ if (attempt === 1 || !lost) throw err;
2326
+ }
2327
+ }
2328
+ return null;
2329
+ };
2145
2330
  const notify = () => void server2.sendToolListChanged().catch(() => {
2146
2331
  });
2147
2332
  toolListeners.add(notify);
2148
- server2.onclose = () => toolListeners.delete(notify);
2333
+ server2.onclose = () => {
2334
+ toolListeners.delete(notify);
2335
+ for (const dir of [...upstreams.keys()]) drop(dir);
2336
+ };
2149
2337
  const hubTools = [
2150
2338
  { 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
2339
  {
@@ -2156,8 +2344,7 @@ async function startHub(opts) {
2156
2344
  ];
2157
2345
  server2.setRequestHandler(ListToolsRequestSchema2, async () => {
2158
2346
  const dir = target();
2159
- const up = dir ? upstream(dir) : null;
2160
- const projectTools = up ? (await (await up).listTools()).tools : [];
2347
+ const projectTools = dir ? (await withUpstream(dir, (c2) => c2.listTools()).catch(() => null))?.tools ?? [] : [];
2161
2348
  return { tools: [...hubTools, ...projectTools] };
2162
2349
  });
2163
2350
  server2.setRequestHandler(CallToolRequestSchema2, async (req) => {
@@ -2169,32 +2356,42 @@ async function startHub(opts) {
2169
2356
  }
2170
2357
  if (req.params.name === "open_project") {
2171
2358
  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);
2359
+ const p = store.all().find((x) => x.name.toLowerCase() === q || x.path.toLowerCase() === q || path8.basename(x.path).toLowerCase() === q);
2173
2360
  if (!p) return text(`No project "${q}". Use list_projects.`, true);
2174
2361
  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
2362
  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);
2363
+ const run3 = await runner.open(p.path);
2364
+ if (run3.status !== "ready") return text(`Couldn't start ${p.name}: ${run3.error}`, true);
2178
2365
  current = p.path;
2179
2366
  notify();
2180
2367
  return text(`\u2713 ${p.name} is open. Your tools now act on it.`);
2181
2368
  }
2182
2369
  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);
2370
+ const out = dir ? await withUpstream(dir, (c2) => c2.callTool(req.params, void 0, { timeout: 18e4 })) : null;
2371
+ if (!out) return text("No project is open. Use list_projects and open_project, or open one in the Truecanvas window.", true);
2372
+ return out;
2186
2373
  });
2187
2374
  return server2;
2188
2375
  }
2189
2376
  async function handleMcp(req, res) {
2190
2377
  const sessionId = req.headers["mcp-session-id"];
2191
- const body = req.method === "POST" ? await readJson2(req) : void 0;
2378
+ const body = req.method === "POST" ? await readJson(req) : void 0;
2192
2379
  let transport = sessionId ? transports.get(sessionId) : void 0;
2380
+ if (sessionId && transport) lastUse.set(sessionId, Date.now());
2193
2381
  if (!transport) {
2194
2382
  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) });
2383
+ const t = new StreamableHTTPServerTransport2({
2384
+ sessionIdGenerator: () => randomUUID2(),
2385
+ onsessioninitialized: (id) => {
2386
+ transports.set(id, t);
2387
+ lastUse.set(id, Date.now());
2388
+ }
2389
+ });
2196
2390
  t.onclose = () => {
2197
- if (t.sessionId) transports.delete(t.sessionId);
2391
+ if (t.sessionId) {
2392
+ transports.delete(t.sessionId);
2393
+ lastUse.delete(t.sessionId);
2394
+ }
2198
2395
  };
2199
2396
  await createProxy().connect(t);
2200
2397
  transport = t;
@@ -2203,7 +2400,7 @@ async function startHub(opts) {
2203
2400
  }
2204
2401
  async function api(req, res, url) {
2205
2402
  const route = `${req.method} ${url.pathname}`;
2206
- const body = req.method === "POST" ? await readJson2(req) : {};
2403
+ const body = req.method === "POST" ? await readJson(req) : {};
2207
2404
  switch (route) {
2208
2405
  case "GET /api/hub/state":
2209
2406
  return json2(res, 200, await state());
@@ -2214,8 +2411,8 @@ async function startHub(opts) {
2214
2411
  case "GET /api/hub/icon": {
2215
2412
  const icon = findIcon(url.searchParams.get("path") ?? "");
2216
2413
  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);
2414
+ res.writeHead(200, { "content-type": ICON_MIME[path8.extname(icon)] ?? "application/octet-stream", "cache-control": "private, max-age=300" });
2415
+ return fs8.createReadStream(icon).on("error", () => res.destroy()).pipe(res);
2219
2416
  }
2220
2417
  case "GET /api/hub/ls":
2221
2418
  return json2(res, 200, listDirs(url.searchParams.get("path") || "~"));
@@ -2226,19 +2423,25 @@ async function startHub(opts) {
2226
2423
  req.on("close", () => clients.delete(res));
2227
2424
  return;
2228
2425
  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." });
2426
+ const dir = path8.resolve(body.path.replace(/^~(?=$|\/)/, process.env.HOME ?? "~"));
2427
+ if (!fs8.existsSync(path8.join(dir, "package.json"))) return json2(res, 400, { error: "No package.json in that folder. Pick the root of a Next.js app." });
2231
2428
  store.touch(dir);
2232
2429
  broadcast();
2233
- return json2(res, 200, { path: dir });
2430
+ return json2(res, 200, { path: dir, ready: store.all().find((p) => p.path === dir)?.ready ?? false });
2234
2431
  }
2235
2432
  case "POST /api/hub/open": {
2236
2433
  store.touch(body.path);
2237
2434
  active = body.path;
2238
2435
  if (body.focus) focus = { path: body.path, at: Date.now() };
2239
- const run2 = await runner.open(body.path);
2436
+ let run3;
2437
+ try {
2438
+ run3 = await runner.open(body.path);
2439
+ } catch (err) {
2440
+ broadcast();
2441
+ return json2(res, 400, { error: err.message });
2442
+ }
2240
2443
  broadcast();
2241
- return json2(res, 200, { status: run2.status, editor: `http://localhost:${run2.editorPort}`, error: run2.error });
2444
+ return json2(res, 200, { status: run3.status, editor: `http://localhost:${run3.editorPort}`, error: run3.error });
2242
2445
  }
2243
2446
  case "POST /api/hub/close":
2244
2447
  runner.close(body.path);
@@ -2290,8 +2493,7 @@ async function startHub(opts) {
2290
2493
  }
2291
2494
  case "POST /api/hub/quit":
2292
2495
  json2(res, 200, { ok: true });
2293
- runner.closeAll();
2294
- setTimeout(() => process.exit(0), 800);
2496
+ void runner.closeAll().finally(() => process.exit(0));
2295
2497
  return;
2296
2498
  }
2297
2499
  const job = /^GET \/api\/hub\/jobs\/(\d+)$/.exec(route);
@@ -2306,14 +2508,14 @@ async function startHub(opts) {
2306
2508
  return json2(res, 404, { error: `No route ${route}` });
2307
2509
  }
2308
2510
  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)) {
2511
+ let file = path8.join(editorDir, pathname === "/" ? "hub.html" : pathname);
2512
+ if (!inside2(editorDir, file) || !fs8.existsSync(file) || fs8.statSync(file).isDirectory()) file = path8.join(editorDir, "hub.html");
2513
+ if (!fs8.existsSync(file)) {
2312
2514
  res.writeHead(500, { "content-type": "text/plain" });
2313
2515
  return res.end("Hub bundle missing. Run `pnpm build`.");
2314
2516
  }
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);
2517
+ res.writeHead(200, { "content-type": MIME2[path8.extname(file)] ?? "application/octet-stream", "cache-control": pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache" });
2518
+ fs8.createReadStream(file).on("error", () => res.destroy()).pipe(res);
2317
2519
  }
2318
2520
  const server = http2.createServer(async (req, res) => {
2319
2521
  try {
@@ -2326,7 +2528,7 @@ async function startHub(opts) {
2326
2528
  if (req.method === "GET") return serveStatic(res, url.pathname);
2327
2529
  json2(res, 405, { error: "Method not allowed" });
2328
2530
  } catch (err) {
2329
- if (!res.headersSent) json2(res, 500, { error: err.message });
2531
+ if (!res.headersSent) json2(res, err instanceof EditError ? 400 : 500, { error: err.message });
2330
2532
  else res.end();
2331
2533
  }
2332
2534
  });
@@ -2335,36 +2537,37 @@ async function startHub(opts) {
2335
2537
  server.listen(opts.port, "127.0.0.1", resolve);
2336
2538
  });
2337
2539
  const shutdown = () => {
2338
- runner.closeAll();
2339
2540
  server.close();
2541
+ return runner.closeAll();
2340
2542
  };
2341
- process.on("SIGINT", () => (shutdown(), process.exit(0)));
2342
- process.on("SIGTERM", () => (shutdown(), process.exit(0)));
2543
+ const exit = () => void shutdown().finally(() => process.exit(0));
2544
+ process.once("SIGINT", exit);
2545
+ process.once("SIGTERM", exit);
2343
2546
  return { origin, shutdown };
2344
2547
  }
2345
2548
  var ICON_MIME = { ".ico": "image/x-icon", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
2346
2549
  function findIcon(dir) {
2347
- if (!dir || !fs9.existsSync(path9.join(dir, "package.json"))) return null;
2550
+ if (!dir || !fs8.existsSync(path8.join(dir, "package.json"))) return null;
2348
2551
  const names = ["icon.svg", "icon.png", "favicon.svg", "favicon.ico", "icon.ico", "apple-icon.png", "favicon.png", "logo.svg", "icon.jpg"];
2349
2552
  const roots = ["app", "src/app", "public", "static", "assets"];
2350
2553
  for (const root of roots) {
2351
2554
  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;
2555
+ const file = path8.join(dir, root, name);
2556
+ if (fs8.existsSync(file) && fs8.statSync(file).size < 2e6) return file;
2354
2557
  }
2355
2558
  }
2356
2559
  for (const root of ["app", "src/app"]) {
2357
2560
  let entries = [];
2358
2561
  try {
2359
- entries = fs9.readdirSync(path9.join(dir, root), { withFileTypes: true });
2562
+ entries = fs8.readdirSync(path8.join(dir, root), { withFileTypes: true });
2360
2563
  } catch {
2361
2564
  continue;
2362
2565
  }
2363
2566
  for (const e of entries) {
2364
2567
  if (!e.isDirectory()) continue;
2365
2568
  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;
2569
+ const file = path8.join(dir, root, e.name, name);
2570
+ if (fs8.existsSync(file)) return file;
2368
2571
  }
2369
2572
  }
2370
2573
  }
@@ -2374,22 +2577,15 @@ function json2(res, status, body) {
2374
2577
  res.writeHead(status, { "content-type": "application/json" });
2375
2578
  res.end(JSON.stringify(body));
2376
2579
  }
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) : {};
2580
+ function inside2(dir, file) {
2581
+ const rel = path8.relative(dir, file);
2582
+ return !!rel && !rel.startsWith("..") && !path8.isAbsolute(rel);
2387
2583
  }
2388
2584
 
2389
2585
  // src/hub/desktop.ts
2390
- import fs10 from "fs";
2586
+ import fs9 from "fs";
2391
2587
  import os6 from "os";
2392
- import path10 from "path";
2588
+ import path9 from "path";
2393
2589
  var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
2394
2590
  <defs>
2395
2591
  <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 +2599,15 @@ var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
2403
2599
  <path fill="#f6f5f1" fill-rule="evenodd" d="M236 236h552v168H236z M428 316h168v472H428z"/>
2404
2600
  </svg>
2405
2601
  `;
2406
- var data = () => process.env.XDG_DATA_HOME ?? path10.join(os6.homedir(), ".local", "share");
2602
+ var data = () => process.env.XDG_DATA_HOME ?? path9.join(os6.homedir(), ".local", "share");
2407
2603
  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");
2604
+ const dir = path9.join(data(), "truecanvas");
2605
+ fs9.mkdirSync(dir, { recursive: true });
2606
+ const icon = path9.join(dir, "icon.svg");
2607
+ fs9.writeFileSync(icon, ICON);
2608
+ const launcher = path9.join(dir, "launch.sh");
2413
2609
  const url = `http://localhost:${port}`;
2414
- fs10.writeFileSync(
2610
+ fs9.writeFileSync(
2415
2611
  launcher,
2416
2612
  `#!/bin/sh
2417
2613
  # Truecanvas launcher: start the hub if needed, then open its window.
@@ -2420,7 +2616,7 @@ CLI=${JSON.stringify(cli)}
2420
2616
  URL=${JSON.stringify(url)}
2421
2617
  LOG="\${XDG_STATE_HOME:-$HOME/.local/state}/truecanvas-hub.log"
2422
2618
  # 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(":"))}
2619
+ export PATH=${JSON.stringify([path9.dirname(process.execPath), ...(process.env.PATH ?? "").split(path9.delimiter)].filter((p, i, a) => p && a.indexOf(p) === i).join(path9.delimiter))}
2424
2620
  mkdir -p "$(dirname "$LOG")"
2425
2621
 
2426
2622
  if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
@@ -2429,7 +2625,7 @@ if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
2429
2625
  while [ $i -lt 60 ] && ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; do sleep 0.25; i=$((i+1)); done
2430
2626
  fi
2431
2627
 
2432
- PROFILE=${JSON.stringify(path10.join(dir, "window"))}
2628
+ PROFILE=${JSON.stringify(path9.join(dir, "window"))}
2433
2629
  for b in google-chrome google-chrome-stable chromium-browser chromium brave-browser microsoft-edge; do
2434
2630
  if command -v "$b" >/dev/null 2>&1; then
2435
2631
  exec "$b" --app="$URL" --class=Truecanvas --user-data-dir="$PROFILE" --no-first-run --no-default-browser-check
@@ -2439,10 +2635,10 @@ exec xdg-open "$URL"
2439
2635
  `,
2440
2636
  { mode: 493 }
2441
2637
  );
2442
- const apps = path10.join(data(), "applications");
2443
- fs10.mkdirSync(apps, { recursive: true });
2444
- const desktop = path10.join(apps, "truecanvas.desktop");
2445
- fs10.writeFileSync(
2638
+ const apps = path9.join(data(), "applications");
2639
+ fs9.mkdirSync(apps, { recursive: true });
2640
+ const desktop = path9.join(apps, "truecanvas.desktop");
2641
+ fs9.writeFileSync(
2446
2642
  desktop,
2447
2643
  `[Desktop Entry]
2448
2644
  Type=Application
@@ -2458,11 +2654,11 @@ StartupWMClass=Truecanvas
2458
2654
  StartupNotify=true
2459
2655
  `
2460
2656
  );
2461
- let command = path10.join(os6.homedir(), ".local", "bin", "truecanvas");
2462
- const ours = !fs10.existsSync(command) || fs10.readFileSync(command, "utf8").includes("# truecanvas wrapper");
2657
+ let command = path9.join(os6.homedir(), ".local", "bin", "truecanvas");
2658
+ const ours = !fs9.existsSync(command) || fs9.readFileSync(command, "utf8").includes("# truecanvas wrapper");
2463
2659
  if (ours) {
2464
- fs10.mkdirSync(path10.dirname(command), { recursive: true });
2465
- fs10.writeFileSync(command, `#!/bin/sh
2660
+ fs9.mkdirSync(path9.dirname(command), { recursive: true });
2661
+ fs9.writeFileSync(command, `#!/bin/sh
2466
2662
  # truecanvas wrapper (installed by \`truecanvas desktop\`)
2467
2663
  exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
2468
2664
  `, { mode: 493 });
@@ -2470,19 +2666,19 @@ exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
2470
2666
  return { desktop, launcher, icon, command };
2471
2667
  }
2472
2668
  function launcherPath() {
2473
- const p = path10.join(data(), "truecanvas", "launch.sh");
2474
- return fs10.existsSync(p) ? p : null;
2669
+ const p = path9.join(data(), "truecanvas", "launch.sh");
2670
+ return fs9.existsSync(p) ? p : null;
2475
2671
  }
2476
2672
  function uninstallDesktop() {
2477
2673
  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);
2674
+ const cmd = path9.join(os6.homedir(), ".local", "bin", "truecanvas");
2675
+ if (fs9.existsSync(cmd) && fs9.readFileSync(cmd, "utf8").includes("# truecanvas wrapper")) {
2676
+ fs9.rmSync(cmd);
2481
2677
  removed.push(cmd);
2482
2678
  }
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 });
2679
+ for (const f of [path9.join(data(), "applications", "truecanvas.desktop"), path9.join(data(), "truecanvas")]) {
2680
+ if (fs9.existsSync(f)) {
2681
+ fs9.rmSync(f, { recursive: true, force: true });
2486
2682
  removed.push(f);
2487
2683
  }
2488
2684
  }
@@ -2495,8 +2691,8 @@ import { createInterface } from "readline/promises";
2495
2691
 
2496
2692
  // src/cli/doctor.ts
2497
2693
  import { execFile as execFile2 } from "child_process";
2498
- import fs11 from "fs";
2499
- import path11 from "path";
2694
+ import fs10 from "fs";
2695
+ import path10 from "path";
2500
2696
  var major = (v) => v ? Number(/(\d+)(?:\.(\d+))?/.exec(v)?.[1] ?? 0) : 0;
2501
2697
  function has(cmd, args, cwd) {
2502
2698
  return new Promise((resolve) => execFile2(cmd, args, { cwd, timeout: 8e3 }, (err, stdout, stderr) => resolve(err ? null : `${stdout}${stderr}`)));
@@ -2521,22 +2717,22 @@ async function doctor(root, cliVersion) {
2521
2717
  const tw = depVersion(root, "tailwindcss");
2522
2718
  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
2719
  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/)" });
2720
+ add(fs10.existsSync(path10.join(root, config.appDir)) ? { label: "App Router", status: "ok", detail: config.appDir } : { label: "App Router", status: "fail", detail: `no ${config.appDir}/`, fix: "Truecanvas renders through the App Router (app/ or src/app/)" });
2525
2721
  const tc = installedIn(root);
2526
2722
  if (!tc.declared) add({ label: "Truecanvas in the project", status: "fail", detail: "not a dependency", fix: "npx truecanvas (sets it up)" });
2527
2723
  else if (!tc.installed) add({ label: "Truecanvas in the project", status: "fail", detail: "declared but not installed", fix: `${pm} install` });
2528
2724
  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
2725
  else add({ label: "Truecanvas in the project", status: "ok", detail: tc.version ?? "" });
2530
2726
  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" });
2727
+ add(wrapped.wrapped ? { label: "next.config", status: "ok", detail: `${path10.basename(wrapped.file)} uses withTruecanvas()` } : { label: "next.config", status: "fail", detail: wrapped.file ? "not wrapped" : "missing", fix: "npx truecanvas init" });
2532
2728
  const scripts = pkg.scripts ?? {};
2533
2729
  const script = Object.entries(scripts).find(([, v]) => v.includes("truecanvas"));
2534
2730
  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") : "";
2731
+ const ignore = fs10.existsSync(path10.join(root, ".gitignore")) ? fs10.readFileSync(path10.join(root, ".gitignore"), "utf8") : "";
2536
2732
  add(ignore.includes(`${config.appDir}/truecanvas`) ? { label: "Generated route ignored", status: "ok" } : { label: "Generated route ignored", status: "warn", detail: `${config.appDir}/truecanvas/ isn't in .gitignore`, fix: "npx truecanvas init" });
2537
2733
  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
2734
  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)" });
2735
+ add(chrome ? { label: "Screenshots", status: "ok", detail: path10.basename(chrome) } : { label: "Screenshots", status: "warn", detail: "no Chromium found", fix: "npx playwright install chromium-headless-shell (or install Chrome)" });
2540
2736
  const git = await has("git", ["rev-parse", "--is-inside-work-tree"], root);
2541
2737
  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
2738
  const gh = await has("gh", ["auth", "status"], root);
@@ -2616,8 +2812,8 @@ async function main() {
2616
2812
  }
2617
2813
  if (!await ensureSetup(root2, pkgDir, version, yes)) return;
2618
2814
  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)) {
2815
+ const local = path11.join(root2, "node_modules", "truecanvas", "dist", "cli.js");
2816
+ if (fs11.existsSync(local) && fs11.realpathSync(local) !== fs11.realpathSync(cli)) {
2621
2817
  const child = spawn3(process.execPath, [local, "dev", ...process.argv.slice(2).filter((a) => a !== "start" && a !== "-y" && a !== "--yes")], { stdio: "inherit" });
2622
2818
  for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => child.kill(sig));
2623
2819
  child.on("exit", (code) => process.exit(code ?? 0));
@@ -2698,7 +2894,7 @@ async function main() {
2698
2894
  return;
2699
2895
  }
2700
2896
  const root = process.cwd();
2701
- if (!fs12.existsSync(path12.join(root, "package.json"))) {
2897
+ if (!fs11.existsSync(path11.join(root, "package.json"))) {
2702
2898
  console.error("Run truecanvas from your Next.js project root (no package.json here).");
2703
2899
  process.exit(1);
2704
2900
  }
@@ -2708,7 +2904,7 @@ async function main() {
2708
2904
  const report = initProject(config);
2709
2905
  ws.ensureRoute();
2710
2906
  console.log(`
2711
- ${c.accent("\u25C6")} ${c.bold("Truecanvas")} is set up in ${c.bold(path12.basename(root))}
2907
+ ${c.accent("\u25C6")} ${c.bold("Truecanvas")} is set up in ${c.bold(path11.basename(root))}
2712
2908
  `);
2713
2909
  for (const line of report.done) console.log(` ${c.green("\u2713")} ${line}`);
2714
2910
  console.log(` ${c.green("\u2713")} Canvas folder ${c.bold(config.canvasDir)} and dev route ${c.bold(`${config.appDir}/truecanvas`)}`);
@@ -2721,7 +2917,8 @@ async function main() {
2721
2917
  if (command === "mcp") {
2722
2918
  console.log = console.error;
2723
2919
  const mcpUrl = `http://localhost:${config.port}/mcp`;
2724
- const running = await fetch(`http://localhost:${config.port}/api/state`).then((r) => r.ok).catch(() => false);
2920
+ const answers = (p) => fetch(`http://127.0.0.1:${config.port}${p}`).then((r) => r.ok).catch(() => false);
2921
+ const running = await answers("/api/state") || await answers("/api/hub/state");
2725
2922
  if (!running) {
2726
2923
  ws.ensureRoute();
2727
2924
  await startServer(ws);
@@ -2784,8 +2981,10 @@ ${hasAgentConfig(root, config.port) ? ` ${c.dim("Agents")} connected through
2784
2981
  const stop = async () => {
2785
2982
  if (stopping) return;
2786
2983
  stopping = true;
2984
+ const nextGone = next && next.exitCode === null ? new Promise((r) => next.once("exit", r)) : Promise.resolve();
2787
2985
  next?.kill("SIGINT");
2788
- await shutdown();
2986
+ await Promise.all([shutdown(), Promise.race([nextGone, new Promise((r) => setTimeout(r, 4e3))])]);
2987
+ if (next && next.exitCode === null) next.kill("SIGKILL");
2789
2988
  process.exit(0);
2790
2989
  };
2791
2990
  process.on("SIGINT", stop);
@@ -2816,7 +3015,7 @@ async function openInWindow(root, port) {
2816
3015
  const post = (route, body) => fetch(`${hub}${route}`, { method: "POST", headers: { "content-type": "application/json", origin: hub }, body: JSON.stringify(body) }).catch(() => null);
2817
3016
  await post("/api/hub/add", { path: root });
2818
3017
  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})`)}.`);
3018
+ console.log(`${c.accent("\u25C6")} Opened ${c.bold(path11.basename(root))} in the Truecanvas window ${c.dim(`(${hub})`)}.`);
2820
3019
  return true;
2821
3020
  }
2822
3021
  async function ask(question) {
@@ -2832,7 +3031,7 @@ async function ensureSetup(root, pkgDir, version, yes, force = false) {
2832
3031
  const tc = installedIn(root);
2833
3032
  const wrapped = nextConfigStatus(root).wrapped;
2834
3033
  if (tc.declared && tc.installed && wrapped && !force) return true;
2835
- const name = readPackage(root)?.name ?? path12.basename(root);
3034
+ const name = readPackage(root)?.name ?? path11.basename(root);
2836
3035
  if (!yes) {
2837
3036
  if (!process.stdin.isTTY) {
2838
3037
  console.error(`Truecanvas isn't set up in ${name} yet. Run again with --yes to set it up.`);
@@ -2901,9 +3100,11 @@ async function waitFor(url, ms) {
2901
3100
  }
2902
3101
  function startNext(root, appUrl) {
2903
3102
  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"] });
3103
+ const bin = path11.join(root, "node_modules", ".bin", "next");
3104
+ const devArgs = ["dev", "-p", port, "-H", "127.0.0.1"];
3105
+ const [cmd, args] = fs11.existsSync(bin) ? [bin, devArgs] : ["npx", ["next", ...devArgs]];
3106
+ const child = spawn3(cmd, args, { cwd: root, env: toolEnv(process.env.NO_COLOR ? {} : { FORCE_COLOR: "1" }), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
3107
+ child.on("error", (err) => console.error(` ${c.dim("next \u2502")} couldn't start next dev: ${err.message}`));
2907
3108
  const prefix = c.dim("next \u2502 ");
2908
3109
  for (const stream of [child.stdout, child.stderr]) {
2909
3110
  let buf = "";