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