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