truecanvas 0.2.0 → 0.4.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 +81 -181
- package/dist/catalog-worker.js +1 -1
- package/dist/chunk-M7B3W7EP.js +659 -0
- package/dist/{chunk-ZPARY6OD.js → chunk-OVYSWA2F.js} +387 -340
- package/dist/chunk-PBKX3SBC.js +97 -0
- package/dist/chunk-TSHD2S36.js +28 -0
- package/dist/{chunk-T2TCBSTG.js → chunk-WU7NBTJH.js} +25 -18
- package/dist/cli.js +989 -257
- package/dist/core/index.d.ts +56 -10
- package/dist/core/index.js +16 -25
- package/dist/editor/assets/{Menu-DXPm0EXo.css → Menu-BJ3OmqzH.css} +1 -1
- package/dist/editor/assets/hub-B5hqZCM2.js +2 -0
- package/dist/editor/assets/{main-DlADROMw.js → main-woTyWmya.js} +5 -5
- package/dist/editor/hub.html +3 -3
- package/dist/editor/index.html +3 -3
- package/dist/next/index.js +3 -2
- package/dist/share/index.d.ts +36 -0
- package/dist/share/index.js +6 -0
- package/dist/vite/index.d.ts +33 -0
- package/dist/vite/index.js +81 -0
- package/package.json +19 -6
- package/dist/editor/assets/hub-BnbEAnt6.js +0 -2
- /package/dist/editor/assets/{Menu-BFifVcgz.js → Menu-C0Ti-6T2.js} +0 -0
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
viewerHtml
|
|
4
|
+
} from "./chunk-M7B3W7EP.js";
|
|
2
5
|
import {
|
|
3
6
|
DEVICES,
|
|
4
7
|
EditError,
|
|
@@ -7,6 +10,7 @@ import {
|
|
|
7
10
|
assertCanvasName,
|
|
8
11
|
clearCompare,
|
|
9
12
|
componentsDir,
|
|
13
|
+
configStatus,
|
|
10
14
|
describeComponent,
|
|
11
15
|
detectPm,
|
|
12
16
|
findByPath,
|
|
@@ -16,8 +20,6 @@ import {
|
|
|
16
20
|
initProject,
|
|
17
21
|
layoutFiles,
|
|
18
22
|
listRoutes,
|
|
19
|
-
loadConfig,
|
|
20
|
-
nextConfigStatus,
|
|
21
23
|
outlineDoc,
|
|
22
24
|
parseCanvas,
|
|
23
25
|
readDesignTokens,
|
|
@@ -26,11 +28,15 @@ import {
|
|
|
26
28
|
shadcnAdd,
|
|
27
29
|
shadcnRegistry,
|
|
28
30
|
shadcnStatus,
|
|
31
|
+
shellSafe,
|
|
29
32
|
syncComponentRegistry,
|
|
30
33
|
toolEnv,
|
|
31
34
|
writeCompare
|
|
32
|
-
} from "./chunk-
|
|
33
|
-
import
|
|
35
|
+
} from "./chunk-OVYSWA2F.js";
|
|
36
|
+
import {
|
|
37
|
+
loadConfig
|
|
38
|
+
} from "./chunk-PBKX3SBC.js";
|
|
39
|
+
import "./chunk-WU7NBTJH.js";
|
|
34
40
|
import {
|
|
35
41
|
ICON_LIBRARIES,
|
|
36
42
|
iconLibraries,
|
|
@@ -41,14 +47,14 @@ import {
|
|
|
41
47
|
|
|
42
48
|
// src/cli/index.ts
|
|
43
49
|
import { spawn as spawn3 } from "child_process";
|
|
44
|
-
import
|
|
45
|
-
import
|
|
50
|
+
import fs13 from "fs";
|
|
51
|
+
import path14 from "path";
|
|
46
52
|
import { parseArgs } from "util";
|
|
47
53
|
|
|
48
54
|
// src/server/http.ts
|
|
49
55
|
import http from "http";
|
|
50
|
-
import
|
|
51
|
-
import
|
|
56
|
+
import fs6 from "fs";
|
|
57
|
+
import path7 from "path";
|
|
52
58
|
import { randomUUID } from "crypto";
|
|
53
59
|
import { fileURLToPath } from "url";
|
|
54
60
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
@@ -84,6 +90,276 @@ function addShadcnComponents(root, names) {
|
|
|
84
90
|
return exclusive(() => shadcnAdd(root, names));
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
// src/share/snapshot.ts
|
|
94
|
+
import crypto from "crypto";
|
|
95
|
+
import fs from "fs";
|
|
96
|
+
import path from "path";
|
|
97
|
+
var MAX_ASSETS_BYTES = 80 * 1024 * 1024;
|
|
98
|
+
function sharesDir(root) {
|
|
99
|
+
return path.join(root, "node_modules", ".cache", "truecanvas", "shares");
|
|
100
|
+
}
|
|
101
|
+
async function createSnapshot(ws, shots, canvas, opts = {}) {
|
|
102
|
+
const doc = ws.doc(canvas);
|
|
103
|
+
const frames = doc.frames.filter((f) => !opts.frames || opts.frames.includes(f.frameName));
|
|
104
|
+
if (!frames.length) throw new Error(`Canvas "${canvas}" has no frames to share.`);
|
|
105
|
+
const id = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[-:T]/g, "").replace(/^(\d{8})/, "$1-");
|
|
106
|
+
const dir = path.join(sharesDir(ws.config.root), canvas, id);
|
|
107
|
+
fs.mkdirSync(path.join(dir, "frames"), { recursive: true });
|
|
108
|
+
fs.mkdirSync(path.join(dir, "assets"), { recursive: true });
|
|
109
|
+
const assets = new Assets(path.join(dir, "assets"));
|
|
110
|
+
const appOrigin = new URL(ws.config.appUrl).origin;
|
|
111
|
+
const out = [];
|
|
112
|
+
const external = /* @__PURE__ */ new Set();
|
|
113
|
+
const used = /* @__PURE__ */ new Set();
|
|
114
|
+
for (const f of frames) {
|
|
115
|
+
const device = findDevice(f.device);
|
|
116
|
+
const raw = await shots.snapshot({ canvas, frame: f.frameName, width: f.width, height: f.height, theme: f.theme ?? "light", mobile: device ? device.kind !== "desktop" : false });
|
|
117
|
+
for (const u of raw.requests) if (new URL(u).origin !== appOrigin) external.add(new URL(u).origin);
|
|
118
|
+
let slug = f.frameName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "frame";
|
|
119
|
+
while (used.has(slug)) slug += "-2";
|
|
120
|
+
used.add(slug);
|
|
121
|
+
fs.writeFileSync(path.join(dir, "frames", `${slug}.html`), await freeze(raw, assets));
|
|
122
|
+
fs.writeFileSync(path.join(dir, "frames", `${slug}.png`), raw.png);
|
|
123
|
+
out.push({ name: f.frameName, x: f.x, y: f.y, width: raw.width, height: f.height ?? raw.height, theme: f.theme, device: f.device, html: `frames/${slug}.html`, image: `frames/${slug}.png` });
|
|
124
|
+
}
|
|
125
|
+
const manifest = {
|
|
126
|
+
format: 1,
|
|
127
|
+
project: path.basename(ws.config.root),
|
|
128
|
+
canvas,
|
|
129
|
+
id,
|
|
130
|
+
createdAt: Date.now(),
|
|
131
|
+
frames: out,
|
|
132
|
+
missing: [...assets.missing],
|
|
133
|
+
external: [...external].filter((o) => !/fonts\.(googleapis|gstatic)\.com$/.test(new URL(o).host))
|
|
134
|
+
};
|
|
135
|
+
fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
136
|
+
fs.writeFileSync(path.join(dir, "index.html"), viewerHtml());
|
|
137
|
+
return { dir, manifest };
|
|
138
|
+
}
|
|
139
|
+
async function freeze(raw, assets) {
|
|
140
|
+
let css = "";
|
|
141
|
+
for (const sheet of raw.css) {
|
|
142
|
+
let text = sheet.text;
|
|
143
|
+
if (sheet.href && !text) text = await fetchText(sheet.href) ?? "";
|
|
144
|
+
text = await replaceAsync(text, /url\(\s*(['"]?)([^'")]+)\1\s*\)/g, async (m, _q, u) => {
|
|
145
|
+
const local = await assets.local(u, sheet.base, "../assets/");
|
|
146
|
+
return local ? `url("${local}")` : m;
|
|
147
|
+
});
|
|
148
|
+
css += sheet.media && sheet.media !== "all" ? `@media ${sheet.media} {
|
|
149
|
+
${text}
|
|
150
|
+
}
|
|
151
|
+
` : `${text}
|
|
152
|
+
`;
|
|
153
|
+
}
|
|
154
|
+
let html = raw.html;
|
|
155
|
+
html = await replaceAsync(html, /\s(src|poster|href|xlink:href)="([^"]+)"/g, async (m, attr, u) => {
|
|
156
|
+
if (u.startsWith("tc-canvas:")) return m;
|
|
157
|
+
const local = await assets.local(decodeEntities(u), raw.url, "../assets/");
|
|
158
|
+
return local ? ` ${attr}="${local}"` : m;
|
|
159
|
+
});
|
|
160
|
+
html = await replaceAsync(html, /url\(("|['"]?)([^'")&]+)\1\)/g, async (m, q, u) => {
|
|
161
|
+
const local = await assets.local(decodeEntities(u), raw.url, "../assets/");
|
|
162
|
+
return local ? `url(${q}${local}${q})` : m;
|
|
163
|
+
});
|
|
164
|
+
for (const c2 of raw.canvases) {
|
|
165
|
+
const local = assets.store(c2.png, "png");
|
|
166
|
+
html = html.split(`src="tc-canvas:${c2.index}"`).join(`src="../assets/${local}"`);
|
|
167
|
+
}
|
|
168
|
+
html = html.replace(/\ssrc="tc-canvas:\d+"/g, "");
|
|
169
|
+
const head = `<meta name="robots" content="noindex"><style>${css.replace(/<\/style/gi, "<\\/style")}</style>`;
|
|
170
|
+
return html.includes("</head>") ? html.replace("</head>", `${head}</head>`) : head + html;
|
|
171
|
+
}
|
|
172
|
+
var Assets = class {
|
|
173
|
+
constructor(dir) {
|
|
174
|
+
this.dir = dir;
|
|
175
|
+
}
|
|
176
|
+
dir;
|
|
177
|
+
byUrl = /* @__PURE__ */ new Map();
|
|
178
|
+
bytes = 0;
|
|
179
|
+
missing = /* @__PURE__ */ new Set();
|
|
180
|
+
/** The local path (with `prefix`) for an asset URL, or null to leave it as is (data:, #fragment). */
|
|
181
|
+
local(spec, base, prefix) {
|
|
182
|
+
if (/^(data:|#|about:|blob:)/i.test(spec.trim())) return Promise.resolve(null);
|
|
183
|
+
let url;
|
|
184
|
+
try {
|
|
185
|
+
url = new URL(spec.trim(), base).href;
|
|
186
|
+
} catch {
|
|
187
|
+
return Promise.resolve(null);
|
|
188
|
+
}
|
|
189
|
+
if (!/^https?:/.test(url)) return Promise.resolve(null);
|
|
190
|
+
if (!this.byUrl.has(url)) this.byUrl.set(url, this.download(url));
|
|
191
|
+
return this.byUrl.get(url).then((name) => name ? prefix + name : null);
|
|
192
|
+
}
|
|
193
|
+
async download(url) {
|
|
194
|
+
if (this.bytes > MAX_ASSETS_BYTES) {
|
|
195
|
+
this.missing.add(url);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(2e4) });
|
|
200
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
201
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
202
|
+
this.bytes += buf.length;
|
|
203
|
+
return this.store(buf, extension(url, res.headers.get("content-type")));
|
|
204
|
+
} catch {
|
|
205
|
+
this.missing.add(url);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
store(buf, ext) {
|
|
210
|
+
const name = `${crypto.createHash("sha256").update(buf).digest("hex").slice(0, 20)}.${ext}`;
|
|
211
|
+
const file = path.join(this.dir, name);
|
|
212
|
+
if (!fs.existsSync(file)) fs.writeFileSync(file, buf);
|
|
213
|
+
return name;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
var TYPES = {
|
|
217
|
+
"image/png": "png",
|
|
218
|
+
"image/jpeg": "jpg",
|
|
219
|
+
"image/webp": "webp",
|
|
220
|
+
"image/avif": "avif",
|
|
221
|
+
"image/gif": "gif",
|
|
222
|
+
"image/svg+xml": "svg",
|
|
223
|
+
"image/x-icon": "ico",
|
|
224
|
+
"image/vnd.microsoft.icon": "ico",
|
|
225
|
+
"font/woff2": "woff2",
|
|
226
|
+
"font/woff": "woff",
|
|
227
|
+
"font/ttf": "ttf",
|
|
228
|
+
"font/otf": "otf",
|
|
229
|
+
"application/font-woff2": "woff2",
|
|
230
|
+
"video/mp4": "mp4",
|
|
231
|
+
"video/webm": "webm"
|
|
232
|
+
};
|
|
233
|
+
function extension(url, type) {
|
|
234
|
+
const fromType = TYPES[(type ?? "").split(";")[0].trim()];
|
|
235
|
+
if (fromType) return fromType;
|
|
236
|
+
const ext = path.extname(new URL(url).pathname).slice(1).toLowerCase();
|
|
237
|
+
return /^[a-z0-9]{1,5}$/.test(ext) ? ext : "bin";
|
|
238
|
+
}
|
|
239
|
+
async function fetchText(url) {
|
|
240
|
+
try {
|
|
241
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(15e3), headers: { "user-agent": "Mozilla/5.0 Chrome/130 Safari/537.36" } });
|
|
242
|
+
return res.ok ? await res.text() : null;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
var decodeEntities = (s) => s.replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'");
|
|
248
|
+
async function replaceAsync(input, re, fn) {
|
|
249
|
+
const matches = [...input.matchAll(re)];
|
|
250
|
+
const replaced = await Promise.all(matches.map((m) => fn(...m)));
|
|
251
|
+
let out = "";
|
|
252
|
+
let last = 0;
|
|
253
|
+
matches.forEach((m, i) => {
|
|
254
|
+
out += input.slice(last, m.index) + replaced[i];
|
|
255
|
+
last = m.index + m[0].length;
|
|
256
|
+
});
|
|
257
|
+
return out + input.slice(last);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/share/publish.ts
|
|
261
|
+
import fs2 from "fs";
|
|
262
|
+
import os from "os";
|
|
263
|
+
import path2 from "path";
|
|
264
|
+
var configFile = () => path2.join(process.env.XDG_CONFIG_HOME ?? path2.join(os.homedir(), ".config"), "truecanvas", "share.json");
|
|
265
|
+
function reviewSite() {
|
|
266
|
+
const { TRUECANVAS_REVIEW_URL: url, TRUECANVAS_REVIEW_TOKEN: token } = process.env;
|
|
267
|
+
if (url && token) return { url: url.replace(/\/+$/, ""), token };
|
|
268
|
+
try {
|
|
269
|
+
const saved = JSON.parse(fs2.readFileSync(configFile(), "utf8"));
|
|
270
|
+
return saved.url && saved.token ? saved : null;
|
|
271
|
+
} catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function connectReviewSite(url, token) {
|
|
276
|
+
const site = { url: url.trim().replace(/\/+$/, ""), token: token.trim() };
|
|
277
|
+
if (!/^https?:\/\//.test(site.url)) throw new Error("The review site URL starts with https://");
|
|
278
|
+
const res = await request(site, "GET", "/api/shares");
|
|
279
|
+
const file = configFile();
|
|
280
|
+
fs2.mkdirSync(path2.dirname(file), { recursive: true });
|
|
281
|
+
fs2.writeFileSync(file, JSON.stringify(site, null, 2), { mode: 384 });
|
|
282
|
+
return res;
|
|
283
|
+
}
|
|
284
|
+
var MAX_FILE = 4.4 * 1024 * 1024;
|
|
285
|
+
async function publishSnapshot(site, dir, manifest, opts = {}) {
|
|
286
|
+
const share = await request(site, "POST", "/api/shares", { project: manifest.project, canvas: manifest.canvas, title: opts.title ?? manifest.canvas });
|
|
287
|
+
const base = `/api/shares/${share.slug}`;
|
|
288
|
+
const known = new Set((await request(site, "GET", `${base}/assets`)).assets);
|
|
289
|
+
const files = [];
|
|
290
|
+
for (const name of fs2.readdirSync(path2.join(dir, "assets"))) if (!known.has(name)) files.push({ local: path2.join(dir, "assets", name), remote: `assets/${name}` });
|
|
291
|
+
for (const f of manifest.frames) for (const p of [f.html, f.image]) files.push({ local: path2.join(dir, p), remote: `${manifest.id}/${p}` });
|
|
292
|
+
const skipped = [];
|
|
293
|
+
let uploaded = 0;
|
|
294
|
+
const queue2 = [...files];
|
|
295
|
+
const worker = async () => {
|
|
296
|
+
for (let f = queue2.shift(); f; f = queue2.shift()) {
|
|
297
|
+
const body = fs2.readFileSync(f.local);
|
|
298
|
+
if (body.length > MAX_FILE) {
|
|
299
|
+
skipped.push(f.remote);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
await request(site, "PUT", `${base}/files`, body, { "x-path": f.remote, "content-type": contentType(f.local) });
|
|
303
|
+
uploaded++;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
await Promise.all(Array.from({ length: 4 }, worker));
|
|
307
|
+
if (skipped.some((s) => s.endsWith(".html"))) throw new Error(`A frame is too large to upload (over 4.4 MB): ${skipped.filter((s) => s.endsWith(".html")).join(", ")}`);
|
|
308
|
+
const change = { ...opts.password !== void 0 ? { password: opts.password } : {}, ...opts.title ? { title: opts.title } : {} };
|
|
309
|
+
if (Object.keys(change).length) await request(site, "PATCH", base, change);
|
|
310
|
+
const done = await request(site, "POST", `${base}/versions`, { id: manifest.id, createdAt: manifest.createdAt, frames: manifest.frames });
|
|
311
|
+
return { url: done.url, version: manifest.id, versions: done.versions, uploaded, skipped, password: opts.password === void 0 ? share.password : !!opts.password };
|
|
312
|
+
}
|
|
313
|
+
async function deleteShare(site, project, canvas) {
|
|
314
|
+
const found = await request(site, "GET", `/api/shares?project=${encodeURIComponent(project)}&canvas=${encodeURIComponent(canvas)}`);
|
|
315
|
+
if (!found.slug) return false;
|
|
316
|
+
await request(site, "DELETE", `/api/shares/${found.slug}`);
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
async function updateShare(site, project, canvas, change) {
|
|
320
|
+
const share = await request(site, "POST", "/api/shares", { project, canvas });
|
|
321
|
+
await request(site, "PATCH", `/api/shares/${share.slug}`, change);
|
|
322
|
+
return share.url;
|
|
323
|
+
}
|
|
324
|
+
async function request(site, method, route, body, headers = {}) {
|
|
325
|
+
const raw = Buffer.isBuffer(body);
|
|
326
|
+
let res;
|
|
327
|
+
try {
|
|
328
|
+
res = await fetch(site.url + route, {
|
|
329
|
+
method,
|
|
330
|
+
headers: { authorization: `Bearer ${site.token}`, ...body !== void 0 && !raw ? { "content-type": "application/json" } : {}, ...headers },
|
|
331
|
+
body: body === void 0 ? void 0 : raw ? new Uint8Array(body) : JSON.stringify(body),
|
|
332
|
+
signal: AbortSignal.timeout(6e4)
|
|
333
|
+
});
|
|
334
|
+
} catch (err) {
|
|
335
|
+
throw new Error(`Couldn't reach the review site at ${site.url}: ${err.message}`);
|
|
336
|
+
}
|
|
337
|
+
if (res.status === 401) throw new Error("The review site refused the token. Run `truecanvas share setup` again with the REVIEW_TOKEN from the site's environment variables.");
|
|
338
|
+
const data2 = await res.json().catch(() => ({}));
|
|
339
|
+
if (!res.ok) throw new Error(`Review site: ${data2.error ?? `${res.status} ${res.statusText}`}`);
|
|
340
|
+
return data2;
|
|
341
|
+
}
|
|
342
|
+
function contentType(file) {
|
|
343
|
+
const ext = path2.extname(file).slice(1).toLowerCase();
|
|
344
|
+
const types = {
|
|
345
|
+
html: "text/html; charset=utf-8",
|
|
346
|
+
png: "image/png",
|
|
347
|
+
jpg: "image/jpeg",
|
|
348
|
+
webp: "image/webp",
|
|
349
|
+
avif: "image/avif",
|
|
350
|
+
gif: "image/gif",
|
|
351
|
+
svg: "image/svg+xml",
|
|
352
|
+
ico: "image/x-icon",
|
|
353
|
+
woff2: "font/woff2",
|
|
354
|
+
woff: "font/woff",
|
|
355
|
+
ttf: "font/ttf",
|
|
356
|
+
otf: "font/otf",
|
|
357
|
+
mp4: "video/mp4",
|
|
358
|
+
webm: "video/webm"
|
|
359
|
+
};
|
|
360
|
+
return types[ext] ?? "application/octet-stream";
|
|
361
|
+
}
|
|
362
|
+
|
|
87
363
|
// src/server/mcp.ts
|
|
88
364
|
var INSTRUCTIONS = `Truecanvas is a design canvas whose layers are the project's real React components.
|
|
89
365
|
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.
|
|
@@ -577,7 +853,7 @@ ${source.slice(hit.node.start, hit.node.end)}
|
|
|
577
853
|
async ({ name }) => {
|
|
578
854
|
try {
|
|
579
855
|
ws.createCanvas(name, actor());
|
|
580
|
-
return text(`\u2713 Created canvas ${name} (${ws.relFile(name)}).
|
|
856
|
+
return text(`\u2713 Created canvas ${name} (${ws.relFile(name)}). The app picks it up in a second.`);
|
|
581
857
|
} catch (e) {
|
|
582
858
|
return fail(e);
|
|
583
859
|
}
|
|
@@ -611,8 +887,9 @@ ${source.slice(node.start, node.end)}
|
|
|
611
887
|
else where = `
|
|
612
888
|
on a ${t.node.name} (it may have moved)`;
|
|
613
889
|
}
|
|
614
|
-
const msgs = t.messages.map((m) => ` ${m.author.name}${m.author.kind === "agent" ? " (agent)" : ""}: ${m.text}`).join("\n");
|
|
615
|
-
|
|
890
|
+
const msgs = t.messages.map((m) => ` ${m.author.name}${m.author.kind === "agent" ? " (agent)" : m.author.kind === "client" ? " (client)" : ""}: ${m.text}`).join("\n");
|
|
891
|
+
const shared = t.share ? " [from a client on the share link: replies and resolving are sent to the client]" : "";
|
|
892
|
+
return `thread ${t.id}${t.resolved ? " [resolved]" : ""}${shared} in frame "${t.frame}" at ${t.x},${t.y}${where}
|
|
616
893
|
${msgs}`;
|
|
617
894
|
});
|
|
618
895
|
return text(parts.join("\n\n"));
|
|
@@ -652,9 +929,9 @@ ${msgs}`;
|
|
|
652
929
|
try {
|
|
653
930
|
const c2 = resolveCanvas(canvas);
|
|
654
931
|
const a = actor();
|
|
655
|
-
const
|
|
656
|
-
if (note?.trim()) ws.comments.reply(c2, thread, note,
|
|
657
|
-
const t = ws.comments.resolve(c2, thread, true,
|
|
932
|
+
const author2 = { name: a.kind === "agent" ? a.name : "agent", kind: "agent" };
|
|
933
|
+
if (note?.trim()) ws.comments.reply(c2, thread, note, author2);
|
|
934
|
+
const t = ws.comments.resolve(c2, thread, true, author2);
|
|
658
935
|
ws.commentsChanged(c2);
|
|
659
936
|
presence(c2, "editing", "Resolved a comment", [], t.frame);
|
|
660
937
|
return text(`\u2713 Resolved thread ${t.id}.`);
|
|
@@ -700,6 +977,36 @@ ${msgs}`;
|
|
|
700
977
|
}
|
|
701
978
|
}
|
|
702
979
|
);
|
|
980
|
+
server.registerTool(
|
|
981
|
+
"share_canvas",
|
|
982
|
+
{
|
|
983
|
+
title: "Share canvas",
|
|
984
|
+
description: "Share a canvas with a client as a link: every frame is rendered by the app and frozen into static HTML (no scripts, no API calls), then published to the studio's review site as a new version of the canvas's link. Without a review site set up, returns a local preview instead. Data the app showed while rendering is in the snapshot: use sample data, never real client data.",
|
|
985
|
+
inputSchema: {
|
|
986
|
+
...canvasArg,
|
|
987
|
+
frames: z.array(z.string()).optional().describe("Frame names to share; omit for all."),
|
|
988
|
+
title: z.string().optional().describe('Title the client sees, e.g. "Homepage, round 2".')
|
|
989
|
+
}
|
|
990
|
+
},
|
|
991
|
+
async ({ canvas, frames, title }) => {
|
|
992
|
+
try {
|
|
993
|
+
const c2 = resolveCanvas(canvas);
|
|
994
|
+
presence(c2, "looking", "Preparing a share link");
|
|
995
|
+
const { dir, manifest } = await createSnapshot(ws, shots, c2, { frames });
|
|
996
|
+
const preview = `http://localhost:${ws.config.port}/share/${encodeURIComponent(c2)}/${manifest.id}/`;
|
|
997
|
+
const notes = [
|
|
998
|
+
manifest.external.length ? `While rendering, the app called ${manifest.external.join(", ")}: whatever it showed is in the snapshot.` : "",
|
|
999
|
+
manifest.missing.length ? `${manifest.missing.length} asset(s) couldn't be fetched.` : ""
|
|
1000
|
+
].filter(Boolean);
|
|
1001
|
+
const site = reviewSite();
|
|
1002
|
+
if (!site) return text([`\u2713 Snapshot ready (${manifest.frames.length} frames). No review site is set up, so this is a local preview: ${preview}`, "To get client links, the user runs `npx truecanvas share setup`.", ...notes].join("\n"));
|
|
1003
|
+
const published = await publishSnapshot(site, dir, manifest, { title });
|
|
1004
|
+
return text([`\u2713 Shared ${manifest.frames.length} frame(s): ${published.url}`, `Version ${published.versions} of this link${published.password ? ", password protected" : ""}.`, ...notes].join("\n"));
|
|
1005
|
+
} catch (e) {
|
|
1006
|
+
return fail(e);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
);
|
|
703
1010
|
server.registerTool(
|
|
704
1011
|
"screenshot_frame",
|
|
705
1012
|
{
|
|
@@ -870,14 +1177,14 @@ ${out.out.slice(-1500)}`));
|
|
|
870
1177
|
}
|
|
871
1178
|
|
|
872
1179
|
// src/server/thumbnails.ts
|
|
873
|
-
import
|
|
874
|
-
import
|
|
875
|
-
import
|
|
1180
|
+
import crypto2 from "crypto";
|
|
1181
|
+
import fs3 from "fs";
|
|
1182
|
+
import path3 from "path";
|
|
876
1183
|
var Thumbnails = class _Thumbnails {
|
|
877
1184
|
constructor(ws, shots) {
|
|
878
1185
|
this.ws = ws;
|
|
879
1186
|
this.shots = shots;
|
|
880
|
-
this.dir =
|
|
1187
|
+
this.dir = path3.join(ws.config.root, "node_modules", ".cache", "truecanvas", "thumbs");
|
|
881
1188
|
}
|
|
882
1189
|
ws;
|
|
883
1190
|
shots;
|
|
@@ -903,26 +1210,26 @@ var Thumbnails = class _Thumbnails {
|
|
|
903
1210
|
key(spec, props) {
|
|
904
1211
|
let stamp = "";
|
|
905
1212
|
try {
|
|
906
|
-
const file = spec.library ?
|
|
907
|
-
stamp = String(
|
|
1213
|
+
const file = spec.library ? path3.join(this.ws.config.root, "node_modules", spec.library, "package.json") : path3.join(this.ws.config.root, spec.file);
|
|
1214
|
+
stamp = String(fs3.statSync(file).mtimeMs);
|
|
908
1215
|
} catch {
|
|
909
1216
|
stamp = "0";
|
|
910
1217
|
}
|
|
911
|
-
return
|
|
1218
|
+
return crypto2.createHash("sha1").update(JSON.stringify({ v: 1, name: spec.name, props, stamp })).digest("hex").slice(0, 16);
|
|
912
1219
|
}
|
|
913
1220
|
async get(name, preset) {
|
|
914
1221
|
await this.ws.catalog.load();
|
|
915
1222
|
const spec = this.ws.catalog.get(name);
|
|
916
1223
|
if (!spec) throw new Error(`Unknown component ${name}`);
|
|
917
1224
|
const props = _Thumbnails.previewProps(spec, preset);
|
|
918
|
-
const file =
|
|
919
|
-
if (
|
|
1225
|
+
const file = path3.join(this.dir, `${name}-${this.key(spec, props)}.png`);
|
|
1226
|
+
if (fs3.existsSync(file)) return fs3.readFileSync(file);
|
|
920
1227
|
const job = this.queue.then(async () => {
|
|
921
|
-
if (
|
|
1228
|
+
if (fs3.existsSync(file)) return fs3.readFileSync(file);
|
|
922
1229
|
const url = `${this.ws.config.appUrl}/truecanvas/__preview?component=${encodeURIComponent(name)}&props=${encodeURIComponent(JSON.stringify(props))}`;
|
|
923
1230
|
const png = await this.shots.element(url, { width: 640, height: 480 });
|
|
924
|
-
|
|
925
|
-
|
|
1231
|
+
fs3.mkdirSync(this.dir, { recursive: true });
|
|
1232
|
+
fs3.writeFileSync(file, png);
|
|
926
1233
|
return png;
|
|
927
1234
|
});
|
|
928
1235
|
this.queue = job.catch(() => void 0);
|
|
@@ -931,22 +1238,32 @@ var Thumbnails = class _Thumbnails {
|
|
|
931
1238
|
};
|
|
932
1239
|
|
|
933
1240
|
// src/server/screenshot.ts
|
|
934
|
-
import
|
|
935
|
-
import
|
|
936
|
-
import
|
|
1241
|
+
import fs4 from "fs";
|
|
1242
|
+
import os2 from "os";
|
|
1243
|
+
import path4 from "path";
|
|
937
1244
|
function findChromium() {
|
|
938
1245
|
if (process.env.TRUECANVAS_CHROME) return process.env.TRUECANVAS_CHROME;
|
|
939
|
-
const cache2 =
|
|
940
|
-
if (
|
|
941
|
-
const shells =
|
|
1246
|
+
const cache2 = process.env.PLAYWRIGHT_BROWSERS_PATH || (process.platform === "win32" ? path4.join(process.env.LOCALAPPDATA ?? path4.join(os2.homedir(), "AppData", "Local"), "ms-playwright") : process.platform === "darwin" ? path4.join(os2.homedir(), "Library", "Caches", "ms-playwright") : path4.join(os2.homedir(), ".cache", "ms-playwright"));
|
|
1247
|
+
if (fs4.existsSync(cache2)) {
|
|
1248
|
+
const shells = fs4.readdirSync(cache2).filter((d) => d.startsWith("chromium_headless_shell-")).sort().reverse();
|
|
942
1249
|
for (const d of shells) {
|
|
943
|
-
for (const sub of [
|
|
944
|
-
|
|
945
|
-
|
|
1250
|
+
for (const sub of [
|
|
1251
|
+
"chrome-headless-shell-linux64/chrome-headless-shell",
|
|
1252
|
+
"chrome-linux/headless_shell",
|
|
1253
|
+
"chrome-headless-shell-mac-arm64/chrome-headless-shell",
|
|
1254
|
+
"chrome-headless-shell-mac-x64/chrome-headless-shell",
|
|
1255
|
+
"chrome-headless-shell-win64/chrome-headless-shell.exe",
|
|
1256
|
+
"chrome-win/headless_shell.exe"
|
|
1257
|
+
]) {
|
|
1258
|
+
const p = path4.join(cache2, d, sub);
|
|
1259
|
+
if (fs4.existsSync(p)) return p;
|
|
946
1260
|
}
|
|
947
1261
|
}
|
|
948
1262
|
}
|
|
1263
|
+
const programFiles = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
|
|
949
1264
|
for (const p of [
|
|
1265
|
+
...programFiles.flatMap((dir) => [path4.join(dir, "Google", "Chrome", "Application", "chrome.exe"), path4.join(dir, "Microsoft", "Edge", "Application", "msedge.exe")]),
|
|
1266
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
950
1267
|
"/usr/bin/chromium-browser",
|
|
951
1268
|
"/usr/bin/chromium",
|
|
952
1269
|
"/usr/bin/google-chrome-stable",
|
|
@@ -954,7 +1271,7 @@ function findChromium() {
|
|
|
954
1271
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
955
1272
|
"/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
956
1273
|
]) {
|
|
957
|
-
if (
|
|
1274
|
+
if (fs4.existsSync(p)) return p;
|
|
958
1275
|
}
|
|
959
1276
|
return void 0;
|
|
960
1277
|
}
|
|
@@ -1027,7 +1344,14 @@ var Screenshotter = class {
|
|
|
1027
1344
|
});
|
|
1028
1345
|
}
|
|
1029
1346
|
async frame(opts) {
|
|
1030
|
-
|
|
1347
|
+
return this.withContext(this.frameOptions(opts), async (ctx) => {
|
|
1348
|
+
const page = await this.openFrame(ctx, opts);
|
|
1349
|
+
const format = opts.jpeg ? { type: "jpeg", quality: 78 } : { type: "png" };
|
|
1350
|
+
return await page.screenshot(format);
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
frameOptions(opts) {
|
|
1354
|
+
return {
|
|
1031
1355
|
viewport: { width: opts.width, height: opts.height ?? 800 },
|
|
1032
1356
|
deviceScaleFactor: opts.scale ?? 1,
|
|
1033
1357
|
colorScheme: opts.theme === "dark" ? "dark" : "light",
|
|
@@ -1035,28 +1359,135 @@ var Screenshotter = class {
|
|
|
1035
1359
|
isMobile: !!opts.mobile,
|
|
1036
1360
|
hasTouch: !!opts.mobile
|
|
1037
1361
|
};
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
await page.
|
|
1362
|
+
}
|
|
1363
|
+
/** Loads a frame, sizes hug frames to their content and waits until it has settled on its final state. */
|
|
1364
|
+
async openFrame(ctx, opts) {
|
|
1365
|
+
const page = await ctx.newPage();
|
|
1366
|
+
const url = `${this.appUrl}/truecanvas/${encodeURIComponent(opts.canvas)}?frame=${encodeURIComponent(opts.frame)}&theme=${opts.theme}&still=1`;
|
|
1367
|
+
await page.goto(url, { waitUntil: "load", timeout: 3e4 });
|
|
1368
|
+
await page.waitForSelector("[data-tc-frame]", { timeout: 2e4 });
|
|
1369
|
+
if (!opts.height) {
|
|
1370
|
+
const h = await page.evaluate(() => Math.ceil(document.querySelector("[data-tc-frame]").getBoundingClientRect().height));
|
|
1371
|
+
await page.setViewportSize({ width: opts.width, height: Math.min(Math.max(h, 100), opts.maxHeight ?? 16e3) });
|
|
1372
|
+
}
|
|
1373
|
+
await page.waitForFunction(() => window.__tcStill === true, void 0, { timeout: 6e3 }).catch(() => {
|
|
1374
|
+
});
|
|
1375
|
+
await page.evaluate(() => document.fonts.ready.then(() => new Promise((r) => setTimeout(r, 50))));
|
|
1376
|
+
await page.waitForTimeout(150);
|
|
1377
|
+
return page;
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* A frame frozen into static HTML (share links): the rendered DOM without
|
|
1381
|
+
* scripts, every stylesheet's text, and pixels of the <canvas> elements
|
|
1382
|
+
* (shaders, charts), which only exist at runtime. Assets are left as URLs
|
|
1383
|
+
* for the caller to fetch.
|
|
1384
|
+
*/
|
|
1385
|
+
async snapshot(opts) {
|
|
1386
|
+
return this.withContext(this.frameOptions(opts), async (ctx) => {
|
|
1387
|
+
const requests = /* @__PURE__ */ new Set();
|
|
1388
|
+
ctx.on("request", (r) => {
|
|
1389
|
+
if (/^https?:/.test(r.url())) requests.add(r.url());
|
|
1048
1390
|
});
|
|
1049
|
-
|
|
1050
|
-
await page.
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1391
|
+
const page = await this.openFrame(ctx, opts);
|
|
1392
|
+
await page.evaluate(() => Promise.all([...document.images].map((img) => img.complete ? null : new Promise((r) => (img.onload = r, img.onerror = r, setTimeout(r, 4e3))))));
|
|
1393
|
+
const raw = await page.evaluate(serializePage);
|
|
1394
|
+
const canvases = [];
|
|
1395
|
+
for (const c2 of raw.canvases) {
|
|
1396
|
+
if (c2.w < 1 || c2.h < 1) continue;
|
|
1397
|
+
const png2 = await page.screenshot({ type: "png", clip: { x: c2.x, y: c2.y, width: c2.w, height: c2.h }, omitBackground: true }).catch(() => null);
|
|
1398
|
+
if (png2) canvases.push({ index: c2.index, png: png2 });
|
|
1399
|
+
}
|
|
1400
|
+
const png = await page.screenshot({ type: "png", fullPage: true });
|
|
1401
|
+
const viewport = page.viewportSize();
|
|
1402
|
+
return { url: raw.url, html: raw.html, css: raw.css, canvases, png, width: viewport.width, height: raw.height, requests: [...requests] };
|
|
1053
1403
|
});
|
|
1054
1404
|
}
|
|
1055
1405
|
};
|
|
1406
|
+
function serializePage() {
|
|
1407
|
+
const abs = (u) => {
|
|
1408
|
+
try {
|
|
1409
|
+
return new URL(u, location.href).href;
|
|
1410
|
+
} catch {
|
|
1411
|
+
return u;
|
|
1412
|
+
}
|
|
1413
|
+
};
|
|
1414
|
+
for (const img of Array.from(document.images)) {
|
|
1415
|
+
if (img.currentSrc) img.setAttribute("src", img.currentSrc);
|
|
1416
|
+
for (const a of ["srcset", "sizes", "loading", "decoding"]) img.removeAttribute(a);
|
|
1417
|
+
}
|
|
1418
|
+
for (const input of Array.from(document.querySelectorAll("input"))) {
|
|
1419
|
+
if (input.type === "checkbox" || input.type === "radio") input.toggleAttribute("checked", input.checked);
|
|
1420
|
+
else if (input.type !== "password" && input.type !== "file") input.setAttribute("value", input.value);
|
|
1421
|
+
}
|
|
1422
|
+
for (const t of Array.from(document.querySelectorAll("textarea"))) t.textContent = t.value;
|
|
1423
|
+
for (const o of Array.from(document.querySelectorAll("option"))) o.toggleAttribute("selected", o.selected);
|
|
1424
|
+
const canvases = Array.from(document.querySelectorAll("canvas")).map((c2, index) => {
|
|
1425
|
+
c2.setAttribute("data-tc-canvas", String(index));
|
|
1426
|
+
const r = c2.getBoundingClientRect();
|
|
1427
|
+
return { index, x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height };
|
|
1428
|
+
});
|
|
1429
|
+
const css = [];
|
|
1430
|
+
const collect = (sheet, media = "") => {
|
|
1431
|
+
let rules;
|
|
1432
|
+
try {
|
|
1433
|
+
rules = sheet.cssRules;
|
|
1434
|
+
} catch {
|
|
1435
|
+
if (sheet.href) css.push({ base: sheet.href, text: "", media: sheet.media?.mediaText || media, href: sheet.href });
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
let text = "";
|
|
1439
|
+
for (const rule of Array.from(rules)) {
|
|
1440
|
+
if (rule instanceof CSSImportRule) {
|
|
1441
|
+
if (rule.styleSheet) collect(rule.styleSheet, rule.media?.mediaText || "");
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
text += `${rule.cssText}
|
|
1445
|
+
`;
|
|
1446
|
+
}
|
|
1447
|
+
css.push({ base: sheet.href || location.href, text, media: sheet.media?.mediaText || media });
|
|
1448
|
+
};
|
|
1449
|
+
for (const sheet of Array.from(document.styleSheets)) if (!sheet.disabled) collect(sheet);
|
|
1450
|
+
for (const sheet of document.adoptedStyleSheets ?? []) collect(sheet);
|
|
1451
|
+
const root = document.documentElement.cloneNode(true);
|
|
1452
|
+
for (const el of Array.from(root.querySelectorAll("script, noscript, style, link, template, iframe, object, embed, nextjs-portal, [data-nextjs-toast]"))) {
|
|
1453
|
+
el.remove();
|
|
1454
|
+
}
|
|
1455
|
+
for (const c2 of Array.from(root.querySelectorAll("canvas[data-tc-canvas]"))) {
|
|
1456
|
+
const i = Number(c2.getAttribute("data-tc-canvas"));
|
|
1457
|
+
const img = document.createElement("img");
|
|
1458
|
+
for (const a of Array.from(c2.attributes)) if (a.name !== "width" && a.name !== "height") img.setAttribute(a.name, a.value);
|
|
1459
|
+
img.setAttribute("src", `tc-canvas:${i}`);
|
|
1460
|
+
img.setAttribute("alt", "");
|
|
1461
|
+
const r = canvases[i];
|
|
1462
|
+
img.style.width = `${r.w}px`;
|
|
1463
|
+
img.style.height = `${r.h}px`;
|
|
1464
|
+
c2.replaceWith(img);
|
|
1465
|
+
}
|
|
1466
|
+
for (const el of [root, ...Array.from(root.querySelectorAll("*"))]) {
|
|
1467
|
+
for (const a of Array.from(el.attributes)) {
|
|
1468
|
+
const n = a.name;
|
|
1469
|
+
if (n.startsWith("on") || n === "data-tc" || n === "data-tc-canvas" || n === "data-tc-hidden") el.removeAttribute(n);
|
|
1470
|
+
}
|
|
1471
|
+
for (const n of ["src", "poster", "xlink:href"]) {
|
|
1472
|
+
const v = el.getAttribute(n);
|
|
1473
|
+
if (v && !/^(data:|#|tc-canvas:)/.test(v)) el.setAttribute(n, abs(v));
|
|
1474
|
+
}
|
|
1475
|
+
const href = el.getAttribute("href");
|
|
1476
|
+
if (href !== null && el.tagName.toLowerCase() === "a") {
|
|
1477
|
+
el.setAttribute("data-href", href);
|
|
1478
|
+
el.removeAttribute("href");
|
|
1479
|
+
el.style.cursor = "pointer";
|
|
1480
|
+
} else if (href && !href.startsWith("#") && !href.startsWith("data:")) el.setAttribute("href", abs(href));
|
|
1481
|
+
}
|
|
1482
|
+
for (const f of Array.from(root.querySelectorAll("form"))) f.removeAttribute("action");
|
|
1483
|
+
const height = Math.ceil(document.querySelector("[data-tc-frame]")?.getBoundingClientRect().height ?? document.documentElement.scrollHeight);
|
|
1484
|
+
return { url: location.href, html: `<!doctype html>
|
|
1485
|
+
${root.outerHTML}`, css, canvases, height };
|
|
1486
|
+
}
|
|
1056
1487
|
|
|
1057
1488
|
// src/server/review.ts
|
|
1058
|
-
import
|
|
1059
|
-
import
|
|
1489
|
+
import fs5 from "fs";
|
|
1490
|
+
import path5 from "path";
|
|
1060
1491
|
import { createHash } from "crypto";
|
|
1061
1492
|
var actorName = (e) => e.actor.kind === "agent" ? e.actor.name : e.actor.kind === "file" ? "code editor" : "you";
|
|
1062
1493
|
async function versionAt(ws, canvas, ref) {
|
|
@@ -1073,14 +1504,14 @@ async function versionAt(ws, canvas, ref) {
|
|
|
1073
1504
|
}
|
|
1074
1505
|
var readNow = (ws, file) => {
|
|
1075
1506
|
try {
|
|
1076
|
-
return
|
|
1507
|
+
return fs5.readFileSync(path5.join(ws.config.root, file), "utf8");
|
|
1077
1508
|
} catch {
|
|
1078
1509
|
return null;
|
|
1079
1510
|
}
|
|
1080
1511
|
};
|
|
1081
1512
|
async function reviewPage(ws, canvas, ref, since) {
|
|
1082
1513
|
const rel = ws.relFile(canvas);
|
|
1083
|
-
const exists =
|
|
1514
|
+
const exists = fs5.existsSync(ws.file(canvas));
|
|
1084
1515
|
const old = await versionAt(ws, canvas, ref);
|
|
1085
1516
|
if (!exists && old.source === null) return null;
|
|
1086
1517
|
const now = exists ? ws.read(canvas) : null;
|
|
@@ -1104,7 +1535,7 @@ async function reviewPage(ws, canvas, ref, since) {
|
|
|
1104
1535
|
}
|
|
1105
1536
|
}
|
|
1106
1537
|
}
|
|
1107
|
-
const comments =
|
|
1538
|
+
const comments = `${ws.config.canvasDir}/${canvas}.comments.json`;
|
|
1108
1539
|
if (await ws.git.show(ref, comments) !== readNow(ws, comments)) files.push(comments);
|
|
1109
1540
|
if (!files.length) return null;
|
|
1110
1541
|
const history = exists ? ws.changesSince(canvas, since) : [];
|
|
@@ -1132,7 +1563,7 @@ async function reviewAll(ws) {
|
|
|
1132
1563
|
const linked = new Set(ws.linkedFiles());
|
|
1133
1564
|
const out = [];
|
|
1134
1565
|
for (const canvas of names) {
|
|
1135
|
-
const own = [ws.relFile(canvas),
|
|
1566
|
+
const own = [ws.relFile(canvas), `${ws.config.canvasDir}/${canvas}.comments.json`];
|
|
1136
1567
|
const touchesLinked = [...linked].some((f) => changed.has(f));
|
|
1137
1568
|
if (!own.some((f) => changed.has(f)) && !touchesLinked) continue;
|
|
1138
1569
|
const page = await reviewPage(ws, canvas, ref, since).catch(() => null);
|
|
@@ -1150,7 +1581,7 @@ function renderFrame(ws, shots, canvas, frame, side, ref) {
|
|
|
1150
1581
|
async function render(ws, shots, canvas, frame, side, ref) {
|
|
1151
1582
|
let source;
|
|
1152
1583
|
let files = {};
|
|
1153
|
-
if (side === "after") source =
|
|
1584
|
+
if (side === "after") source = fs5.existsSync(ws.file(canvas)) ? ws.read(canvas) : null;
|
|
1154
1585
|
else {
|
|
1155
1586
|
const old = await versionAt(ws, canvas, ref);
|
|
1156
1587
|
source = old.source;
|
|
@@ -1166,7 +1597,7 @@ async function render(ws, shots, canvas, frame, side, ref) {
|
|
|
1166
1597
|
if (hit) return hit;
|
|
1167
1598
|
let name = canvas;
|
|
1168
1599
|
const snapshot = `${canvas}.${side === "before" ? "base" : "now"}`;
|
|
1169
|
-
if (side === "before" || !
|
|
1600
|
+
if (side === "before" || !fs5.existsSync(ws.file(canvas))) name = writeCompare(ws.config, canvas, source, files, snapshot);
|
|
1170
1601
|
try {
|
|
1171
1602
|
const device = findDevice(f.device);
|
|
1172
1603
|
const png = await shots.frame({ canvas: name, frame, width: f.width, height: f.height, theme: f.theme ?? "light", scale: Math.min(2, Math.max(0.4, 1e3 / f.width)), mobile: device ? device.kind !== "desktop" : false, jpeg: true, maxHeight: 6e3 });
|
|
@@ -1218,6 +1649,150 @@ async function readJson(req, limit = 8 * 1024 * 1024) {
|
|
|
1218
1649
|
}
|
|
1219
1650
|
}
|
|
1220
1651
|
|
|
1652
|
+
// src/share/sync.ts
|
|
1653
|
+
import path6 from "path";
|
|
1654
|
+
var EVERY = 2e4;
|
|
1655
|
+
var LOOKUP_EVERY = 5 * 6e4;
|
|
1656
|
+
var CommentSync = class {
|
|
1657
|
+
constructor(ws) {
|
|
1658
|
+
this.ws = ws;
|
|
1659
|
+
}
|
|
1660
|
+
ws;
|
|
1661
|
+
links = /* @__PURE__ */ new Map();
|
|
1662
|
+
remote = /* @__PURE__ */ new Map();
|
|
1663
|
+
timer = null;
|
|
1664
|
+
kickTimer = null;
|
|
1665
|
+
running = null;
|
|
1666
|
+
off = null;
|
|
1667
|
+
start() {
|
|
1668
|
+
this.timer = setInterval(() => void this.syncAll(), EVERY);
|
|
1669
|
+
this.timer.unref();
|
|
1670
|
+
setTimeout(() => void this.syncAll(), 3e3).unref();
|
|
1671
|
+
this.off = this.ws.on((e) => {
|
|
1672
|
+
if (e.type !== "comments") return;
|
|
1673
|
+
if (this.kickTimer) clearTimeout(this.kickTimer);
|
|
1674
|
+
this.kickTimer = setTimeout(() => void this.syncAll(), 1500);
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
stop() {
|
|
1678
|
+
if (this.timer) clearInterval(this.timer);
|
|
1679
|
+
if (this.kickTimer) clearTimeout(this.kickTimer);
|
|
1680
|
+
this.off?.();
|
|
1681
|
+
}
|
|
1682
|
+
/** A canvas was just shared: look its link up again on the next sync. */
|
|
1683
|
+
forget(canvas) {
|
|
1684
|
+
this.links.delete(canvas);
|
|
1685
|
+
}
|
|
1686
|
+
syncAll() {
|
|
1687
|
+
this.running ??= this.pass().finally(() => this.running = null);
|
|
1688
|
+
return this.running;
|
|
1689
|
+
}
|
|
1690
|
+
async pass() {
|
|
1691
|
+
const site = reviewSite();
|
|
1692
|
+
if (!site) return;
|
|
1693
|
+
for (const canvas of this.ws.canvases()) {
|
|
1694
|
+
try {
|
|
1695
|
+
await this.syncCanvas(site, canvas);
|
|
1696
|
+
} catch {
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
async slugOf(site, canvas) {
|
|
1701
|
+
const known = this.links.get(canvas);
|
|
1702
|
+
if (known && (known.slug || Date.now() - known.at < LOOKUP_EVERY)) return known.slug;
|
|
1703
|
+
const project = path6.basename(this.ws.config.root);
|
|
1704
|
+
const res = await call(site, "GET", `/api/shares?project=${encodeURIComponent(project)}&canvas=${encodeURIComponent(canvas)}`);
|
|
1705
|
+
this.links.set(canvas, { slug: res.slug, at: Date.now() });
|
|
1706
|
+
return res.slug;
|
|
1707
|
+
}
|
|
1708
|
+
async syncCanvas(site, canvas) {
|
|
1709
|
+
const slug = await this.slugOf(site, canvas);
|
|
1710
|
+
if (!slug) return;
|
|
1711
|
+
const cached = this.remote.get(slug);
|
|
1712
|
+
const res = await call(site, "GET", `/api/shares/${slug}/comments?since=${cached?.updated ?? 0}`);
|
|
1713
|
+
const remote = res.threads ?? cached?.threads ?? [];
|
|
1714
|
+
this.remote.set(slug, { updated: res.updated, threads: remote });
|
|
1715
|
+
const byId = new Map(remote.map((t) => [t.id, t]));
|
|
1716
|
+
const dismissed = this.ws.comments.dismissed(canvas);
|
|
1717
|
+
this.ws.comments.merge(canvas, (local) => {
|
|
1718
|
+
let changed = false;
|
|
1719
|
+
for (const r of remote) {
|
|
1720
|
+
if (dismissed.has(r.id)) continue;
|
|
1721
|
+
const l = local.find((t) => t.id === r.id);
|
|
1722
|
+
if (!l) {
|
|
1723
|
+
local.push(fromRemote(r, slug));
|
|
1724
|
+
changed = true;
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
if (!l.share) continue;
|
|
1728
|
+
const have = new Set(l.messages.map((m) => m.id));
|
|
1729
|
+
for (const m of r.messages) {
|
|
1730
|
+
if (have.has(m.id)) continue;
|
|
1731
|
+
l.messages.push(message(m));
|
|
1732
|
+
changed = true;
|
|
1733
|
+
}
|
|
1734
|
+
if (changed) l.messages.sort((a, b) => a.at - b.at);
|
|
1735
|
+
if (r.resolved !== l.resolved && (r.resolvedAt ?? 0) > (l.resolvedAt ?? 0)) {
|
|
1736
|
+
l.resolved = r.resolved;
|
|
1737
|
+
l.resolvedBy = r.resolved && r.resolvedBy ? author(r.resolvedBy) : void 0;
|
|
1738
|
+
l.resolvedAt = r.resolvedAt;
|
|
1739
|
+
changed = true;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
return changed;
|
|
1743
|
+
});
|
|
1744
|
+
let pushed = false;
|
|
1745
|
+
for (const l of this.ws.comments.list(canvas)) {
|
|
1746
|
+
if (l.share?.link !== slug) continue;
|
|
1747
|
+
const r = byId.get(l.id);
|
|
1748
|
+
if (!r) continue;
|
|
1749
|
+
const there = new Set(r.messages.map((m) => m.id));
|
|
1750
|
+
for (const m of l.messages) {
|
|
1751
|
+
if (there.has(m.id) || m.author.kind === "client") continue;
|
|
1752
|
+
await call(site, "POST", `/api/shares/${slug}/comments`, { thread: l.id, message: { id: m.id, name: displayName(m.author), text: m.text, at: m.at } });
|
|
1753
|
+
pushed = true;
|
|
1754
|
+
}
|
|
1755
|
+
if (l.resolved !== r.resolved && (l.resolvedAt ?? 0) > (r.resolvedAt ?? 0)) {
|
|
1756
|
+
await call(site, "POST", `/api/shares/${slug}/comments`, { thread: l.id, resolved: l.resolved, name: l.resolvedBy ? displayName(l.resolvedBy) : "Studio" });
|
|
1757
|
+
pushed = true;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
if (pushed) this.remote.delete(slug);
|
|
1761
|
+
}
|
|
1762
|
+
};
|
|
1763
|
+
function fromRemote(r, slug) {
|
|
1764
|
+
return {
|
|
1765
|
+
id: r.id,
|
|
1766
|
+
frame: r.frame,
|
|
1767
|
+
x: r.x,
|
|
1768
|
+
y: r.y,
|
|
1769
|
+
node: null,
|
|
1770
|
+
resolved: r.resolved,
|
|
1771
|
+
...r.resolved && r.resolvedBy ? { resolvedBy: author(r.resolvedBy) } : {},
|
|
1772
|
+
...r.resolvedAt ? { resolvedAt: r.resolvedAt } : {},
|
|
1773
|
+
messages: r.messages.map(message),
|
|
1774
|
+
createdAt: r.createdAt,
|
|
1775
|
+
share: { link: slug, version: r.version }
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
var author = (a) => ({ name: a.name, kind: a.kind === "client" ? "client" : "user" });
|
|
1779
|
+
var message = (m) => ({ id: m.id, author: author(m.author), text: m.text, at: m.at });
|
|
1780
|
+
function displayName(a) {
|
|
1781
|
+
if (a.kind !== "agent") return a.name;
|
|
1782
|
+
const base = a.name.replace(/[-_ ]?(code|cli|agent)$/i, "") || a.name;
|
|
1783
|
+
return base.charAt(0).toUpperCase() + base.slice(1);
|
|
1784
|
+
}
|
|
1785
|
+
async function call(site, method, route, body) {
|
|
1786
|
+
const res = await fetch(site.url + route, {
|
|
1787
|
+
method,
|
|
1788
|
+
headers: { authorization: `Bearer ${site.token}`, ...body ? { "content-type": "application/json" } : {} },
|
|
1789
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
1790
|
+
signal: AbortSignal.timeout(2e4)
|
|
1791
|
+
});
|
|
1792
|
+
if (!res.ok) throw new Error(`Review site ${res.status}`);
|
|
1793
|
+
return res.json();
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1221
1796
|
// src/server/http.ts
|
|
1222
1797
|
var MIME = {
|
|
1223
1798
|
".html": "text/html; charset=utf-8",
|
|
@@ -1226,7 +1801,14 @@ var MIME = {
|
|
|
1226
1801
|
".svg": "image/svg+xml",
|
|
1227
1802
|
".woff2": "font/woff2",
|
|
1228
1803
|
".png": "image/png",
|
|
1229
|
-
".json": "application/json"
|
|
1804
|
+
".json": "application/json",
|
|
1805
|
+
".jpg": "image/jpeg",
|
|
1806
|
+
".webp": "image/webp",
|
|
1807
|
+
".avif": "image/avif",
|
|
1808
|
+
".gif": "image/gif",
|
|
1809
|
+
".woff": "font/woff",
|
|
1810
|
+
".ttf": "font/ttf",
|
|
1811
|
+
".ico": "image/x-icon"
|
|
1230
1812
|
};
|
|
1231
1813
|
async function startServer(ws) {
|
|
1232
1814
|
const { config } = ws;
|
|
@@ -1234,6 +1816,8 @@ async function startServer(ws) {
|
|
|
1234
1816
|
const shots = new Screenshotter(config.appUrl);
|
|
1235
1817
|
let prCache = null;
|
|
1236
1818
|
const thumbs = new Thumbnails(ws, shots);
|
|
1819
|
+
const commentSync = new CommentSync(ws);
|
|
1820
|
+
commentSync.start();
|
|
1237
1821
|
const syncRegistry = () => ws.catalog.load().then((list) => syncComponentRegistry(config, list)).catch(() => {
|
|
1238
1822
|
});
|
|
1239
1823
|
void syncRegistry();
|
|
@@ -1299,8 +1883,9 @@ async function startServer(ws) {
|
|
|
1299
1883
|
case "GET /api/state":
|
|
1300
1884
|
return json(res, 200, {
|
|
1301
1885
|
appUrl: config.appUrl,
|
|
1886
|
+
framework: config.framework,
|
|
1302
1887
|
root: config.root,
|
|
1303
|
-
projectName:
|
|
1888
|
+
projectName: path7.basename(config.root),
|
|
1304
1889
|
canvases: ws.canvases(),
|
|
1305
1890
|
darkMode: config.darkMode,
|
|
1306
1891
|
agents: [...ws.agents.values()],
|
|
@@ -1332,11 +1917,11 @@ async function startServer(ws) {
|
|
|
1332
1917
|
await ws.git.fetch();
|
|
1333
1918
|
return json(res, 200, { ...await ws.git.status(), linked: ws.designFiles() });
|
|
1334
1919
|
case "POST /api/git/commit": {
|
|
1335
|
-
const { message, scope } = await readJson(req);
|
|
1920
|
+
const { message: message2, scope } = await readJson(req);
|
|
1336
1921
|
const status = await ws.git.status();
|
|
1337
1922
|
const linked = new Set(ws.designFiles());
|
|
1338
1923
|
const files = status.files.map((f) => f.path).filter((f) => scope === "all" || f.startsWith(`${config.canvasDir}/`) || linked.has(f));
|
|
1339
|
-
const hash = await ws.git.commit(
|
|
1924
|
+
const hash = await ws.git.commit(message2, files);
|
|
1340
1925
|
ws.emit({ type: "git" });
|
|
1341
1926
|
return json(res, 200, { hash });
|
|
1342
1927
|
}
|
|
@@ -1450,7 +2035,7 @@ async function startServer(ws) {
|
|
|
1450
2035
|
for (const file of [f.page, ...layoutFiles(config, f.page)]) {
|
|
1451
2036
|
const old = await ws.git.show(ref, file);
|
|
1452
2037
|
if (old === null) continue;
|
|
1453
|
-
const now =
|
|
2038
|
+
const now = fs6.existsSync(path7.join(config.root, file)) ? fs6.readFileSync(path7.join(config.root, file), "utf8") : null;
|
|
1454
2039
|
if (old !== now) {
|
|
1455
2040
|
files[file] = old;
|
|
1456
2041
|
touched.add(f.frameName);
|
|
@@ -1580,12 +2165,26 @@ async function startServer(ws) {
|
|
|
1580
2165
|
}
|
|
1581
2166
|
case "POST /api/open-file": {
|
|
1582
2167
|
const { file } = await readJson(req);
|
|
1583
|
-
const abs =
|
|
1584
|
-
const rel =
|
|
1585
|
-
if (rel.startsWith("..") ||
|
|
2168
|
+
const abs = path7.resolve(config.root, file);
|
|
2169
|
+
const rel = path7.relative(config.root, abs);
|
|
2170
|
+
if (rel.startsWith("..") || path7.isAbsolute(rel)) return json(res, 400, { error: "Outside project." });
|
|
1586
2171
|
launchEditor(abs, config.editor);
|
|
1587
2172
|
return json(res, 200, { ok: true });
|
|
1588
2173
|
}
|
|
2174
|
+
case "GET /api/share/status": {
|
|
2175
|
+
const site = reviewSite();
|
|
2176
|
+
return json(res, 200, { site: site ? site.url : null });
|
|
2177
|
+
}
|
|
2178
|
+
case "POST /api/share/snapshot": {
|
|
2179
|
+
const { canvas, frames, local, title, password } = await readJson(req);
|
|
2180
|
+
assertCanvasName(canvas);
|
|
2181
|
+
const { dir, manifest } = await createSnapshot(ws, shots, canvas, { frames });
|
|
2182
|
+
const preview = `${origin}/share/${encodeURIComponent(canvas)}/${manifest.id}/`;
|
|
2183
|
+
const site = local ? null : reviewSite();
|
|
2184
|
+
const published = site ? await publishSnapshot(site, dir, manifest, { title, password }) : null;
|
|
2185
|
+
if (published) commentSync.forget(canvas);
|
|
2186
|
+
return json(res, 200, { manifest, preview, published });
|
|
2187
|
+
}
|
|
1589
2188
|
case "POST /api/source": {
|
|
1590
2189
|
const { canvas, id } = await readJson(req);
|
|
1591
2190
|
const hit = ws.nodeSource(canvas, id);
|
|
@@ -1596,23 +2195,32 @@ async function startServer(ws) {
|
|
|
1596
2195
|
return json(res, 404, { error: `No route ${route}` });
|
|
1597
2196
|
}
|
|
1598
2197
|
function serveStatic(res, pathname) {
|
|
1599
|
-
let file =
|
|
1600
|
-
if (!inside(editorDir, file) || !
|
|
1601
|
-
if (!
|
|
2198
|
+
let file = path7.join(editorDir, pathname === "/" ? "index.html" : pathname);
|
|
2199
|
+
if (!inside(editorDir, file) || !fs6.existsSync(file) || fs6.statSync(file).isDirectory()) file = path7.join(editorDir, "index.html");
|
|
2200
|
+
if (!fs6.existsSync(file)) {
|
|
1602
2201
|
res.writeHead(500, { "content-type": "text/plain" });
|
|
1603
2202
|
return res.end("Editor bundle missing. Run `pnpm build` in packages/truecanvas.");
|
|
1604
2203
|
}
|
|
1605
|
-
const ext =
|
|
2204
|
+
const ext = path7.extname(file);
|
|
1606
2205
|
const immutable = pathname.startsWith("/assets/");
|
|
1607
2206
|
res.writeHead(200, { "content-type": MIME[ext] ?? "application/octet-stream", "cache-control": immutable ? "public, max-age=31536000, immutable" : "no-cache" });
|
|
1608
|
-
|
|
2207
|
+
fs6.createReadStream(file).on("error", () => res.destroy()).pipe(res);
|
|
2208
|
+
}
|
|
2209
|
+
function serveShare(res, rest) {
|
|
2210
|
+
const root = sharesDir(config.root);
|
|
2211
|
+
let file = path7.join(root, decodeURIComponent(rest));
|
|
2212
|
+
if (rest.endsWith("/")) file = path7.join(file, "index.html");
|
|
2213
|
+
if (!inside(root, file) || !fs6.existsSync(file) || fs6.statSync(file).isDirectory()) return json(res, 404, { error: "Not found" });
|
|
2214
|
+
res.writeHead(200, { "content-type": MIME[path7.extname(file)] ?? "application/octet-stream", "cache-control": "no-cache", "x-content-type-options": "nosniff", "access-control-allow-origin": "*" });
|
|
2215
|
+
fs6.createReadStream(file).on("error", () => res.destroy()).pipe(res);
|
|
1609
2216
|
}
|
|
1610
2217
|
const server = http.createServer(async (req, res) => {
|
|
1611
2218
|
try {
|
|
1612
2219
|
if (!allowedHosts.has(req.headers.host ?? "")) return json(res, 403, { error: "Forbidden host" });
|
|
2220
|
+
const url = new URL(req.url ?? "/", origin);
|
|
2221
|
+
if (req.method === "GET" && url.pathname.startsWith("/share/")) return serveShare(res, url.pathname.slice("/share/".length));
|
|
1613
2222
|
const reqOrigin = req.headers.origin;
|
|
1614
2223
|
if (reqOrigin && !allowedOrigins.has(reqOrigin)) return json(res, 403, { error: "Forbidden origin" });
|
|
1615
|
-
const url = new URL(req.url ?? "/", origin);
|
|
1616
2224
|
if (url.pathname === "/mcp") return await handleMcp(req, res);
|
|
1617
2225
|
if (url.pathname.startsWith("/api/")) return await handleApi(req, res, url);
|
|
1618
2226
|
if (req.method === "GET") return serveStatic(res, url.pathname);
|
|
@@ -1624,8 +2232,8 @@ async function startServer(ws) {
|
|
|
1624
2232
|
else res.end();
|
|
1625
2233
|
}
|
|
1626
2234
|
});
|
|
1627
|
-
const canvasDir =
|
|
1628
|
-
|
|
2235
|
+
const canvasDir = path7.join(config.root, config.canvasDir);
|
|
2236
|
+
fs6.mkdirSync(canvasDir, { recursive: true });
|
|
1629
2237
|
const pending = /* @__PURE__ */ new Map();
|
|
1630
2238
|
const onCanvasFile = (filename) => {
|
|
1631
2239
|
if (!filename) return;
|
|
@@ -1650,7 +2258,7 @@ async function startServer(ws) {
|
|
|
1650
2258
|
const watchCanvasDir = () => {
|
|
1651
2259
|
let ino = null;
|
|
1652
2260
|
try {
|
|
1653
|
-
ino =
|
|
2261
|
+
ino = fs6.statSync(canvasDir).ino;
|
|
1654
2262
|
} catch {
|
|
1655
2263
|
ino = null;
|
|
1656
2264
|
}
|
|
@@ -1659,21 +2267,21 @@ async function startServer(ws) {
|
|
|
1659
2267
|
canvasWatcher = null;
|
|
1660
2268
|
watchedIno = ino;
|
|
1661
2269
|
if (ino === null) return;
|
|
1662
|
-
canvasWatcher =
|
|
2270
|
+
canvasWatcher = fs6.watch(canvasDir, (_event, filename) => onCanvasFile(filename));
|
|
1663
2271
|
canvasWatcher.on("error", () => {
|
|
1664
2272
|
canvasWatcher?.close();
|
|
1665
2273
|
canvasWatcher = null;
|
|
1666
2274
|
});
|
|
1667
|
-
for (const f of
|
|
2275
|
+
for (const f of fs6.readdirSync(canvasDir)) onCanvasFile(f);
|
|
1668
2276
|
};
|
|
1669
2277
|
watchCanvasDir();
|
|
1670
2278
|
setInterval(watchCanvasDir, 2e3).unref();
|
|
1671
|
-
const appDirAbs =
|
|
1672
|
-
if (
|
|
2279
|
+
const appDirAbs = path7.join(config.root, config.appDir);
|
|
2280
|
+
if (config.framework === "next" && fs6.existsSync(appDirAbs)) {
|
|
1673
2281
|
const appPending = /* @__PURE__ */ new Map();
|
|
1674
|
-
const appWatcher =
|
|
2282
|
+
const appWatcher = fs6.watch(appDirAbs, { recursive: true }, (_event, filename) => {
|
|
1675
2283
|
if (!filename || !/(^|[\\/])(page|layout)\.[jt]sx$/.test(filename) || /^truecanvas[\\/]/.test(filename)) return;
|
|
1676
|
-
const rel =
|
|
2284
|
+
const rel = path7.join(config.appDir, filename).split(path7.sep).join("/");
|
|
1677
2285
|
clearTimeout(appPending.get(rel));
|
|
1678
2286
|
appPending.set(
|
|
1679
2287
|
rel,
|
|
@@ -1690,9 +2298,9 @@ async function startServer(ws) {
|
|
|
1690
2298
|
}
|
|
1691
2299
|
let catalogTimer;
|
|
1692
2300
|
for (const dir of componentRoots(config.components)) {
|
|
1693
|
-
const abs =
|
|
1694
|
-
if (!
|
|
1695
|
-
const componentWatcher =
|
|
2301
|
+
const abs = path7.join(config.root, dir);
|
|
2302
|
+
if (!fs6.existsSync(abs)) continue;
|
|
2303
|
+
const componentWatcher = fs6.watch(abs, { recursive: true }, () => {
|
|
1696
2304
|
clearTimeout(catalogTimer);
|
|
1697
2305
|
catalogTimer = setTimeout(() => {
|
|
1698
2306
|
ws.catalog.invalidate();
|
|
@@ -1707,6 +2315,7 @@ async function startServer(ws) {
|
|
|
1707
2315
|
server.listen(config.port, "127.0.0.1", resolve);
|
|
1708
2316
|
});
|
|
1709
2317
|
const shutdown = async () => {
|
|
2318
|
+
commentSync.stop();
|
|
1710
2319
|
await shots.close();
|
|
1711
2320
|
for (const t of transports.values()) await t.close().catch(() => {
|
|
1712
2321
|
});
|
|
@@ -1726,8 +2335,8 @@ function dedent(code, col) {
|
|
|
1726
2335
|
return code.split("\n").map((l, i) => i > 0 && l.startsWith(pad) ? l.slice(col) : l).join("\n");
|
|
1727
2336
|
}
|
|
1728
2337
|
function inside(dir, file) {
|
|
1729
|
-
const rel =
|
|
1730
|
-
return !!rel && !rel.startsWith("..") && !
|
|
2338
|
+
const rel = path7.relative(dir, file);
|
|
2339
|
+
return !!rel && !rel.startsWith("..") && !path7.isAbsolute(rel);
|
|
1731
2340
|
}
|
|
1732
2341
|
|
|
1733
2342
|
// src/server/stdio.ts
|
|
@@ -1765,11 +2374,14 @@ async function runStdioBridge(mcpUrl) {
|
|
|
1765
2374
|
await server.connect(new StdioServerTransport());
|
|
1766
2375
|
}
|
|
1767
2376
|
|
|
2377
|
+
// src/cli/index.ts
|
|
2378
|
+
import { createRequire } from "module";
|
|
2379
|
+
|
|
1768
2380
|
// src/hub/server.ts
|
|
1769
2381
|
import http2 from "http";
|
|
1770
|
-
import
|
|
1771
|
-
import
|
|
1772
|
-
import
|
|
2382
|
+
import fs10 from "fs";
|
|
2383
|
+
import os6 from "os";
|
|
2384
|
+
import path11 from "path";
|
|
1773
2385
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1774
2386
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1775
2387
|
import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -1779,42 +2391,42 @@ import { StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from
|
|
|
1779
2391
|
import { CallToolRequestSchema as CallToolRequestSchema2, ListToolsRequestSchema as ListToolsRequestSchema2, isInitializeRequest as isInitializeRequest2 } from "@modelcontextprotocol/sdk/types.js";
|
|
1780
2392
|
|
|
1781
2393
|
// src/hub/projects.ts
|
|
1782
|
-
import
|
|
1783
|
-
import
|
|
1784
|
-
import
|
|
1785
|
-
var CONFIG_DIR =
|
|
1786
|
-
var FILE =
|
|
2394
|
+
import fs7 from "fs";
|
|
2395
|
+
import os3 from "os";
|
|
2396
|
+
import path8 from "path";
|
|
2397
|
+
var CONFIG_DIR = path8.join(process.env.XDG_CONFIG_HOME ?? path8.join(os3.homedir(), ".config"), "truecanvas");
|
|
2398
|
+
var FILE = path8.join(CONFIG_DIR, "projects.json");
|
|
1787
2399
|
var ProjectStore = class {
|
|
1788
2400
|
list = [];
|
|
1789
2401
|
constructor() {
|
|
1790
2402
|
try {
|
|
1791
|
-
this.list = JSON.parse(
|
|
2403
|
+
this.list = JSON.parse(fs7.readFileSync(FILE, "utf8"));
|
|
1792
2404
|
} catch {
|
|
1793
2405
|
this.list = [];
|
|
1794
2406
|
}
|
|
1795
2407
|
}
|
|
1796
2408
|
save() {
|
|
1797
|
-
|
|
1798
|
-
|
|
2409
|
+
fs7.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
2410
|
+
fs7.writeFileSync(FILE, JSON.stringify(this.list, null, 2));
|
|
1799
2411
|
}
|
|
1800
2412
|
all() {
|
|
1801
2413
|
return [...this.list].sort((a, b) => b.lastOpened - a.lastOpened).map((p) => ({ ...p, ...inspect(p.path) }));
|
|
1802
2414
|
}
|
|
1803
2415
|
touch(dir) {
|
|
1804
|
-
const abs =
|
|
2416
|
+
const abs = path8.resolve(dir);
|
|
1805
2417
|
const existing = this.list.find((p) => p.path === abs);
|
|
1806
2418
|
if (existing) existing.lastOpened = Date.now();
|
|
1807
2419
|
else this.list.push({ path: abs, name: projectName(abs), lastOpened: Date.now() });
|
|
1808
2420
|
this.save();
|
|
1809
2421
|
}
|
|
1810
2422
|
forget(dir) {
|
|
1811
|
-
this.list = this.list.filter((p) => p.path !==
|
|
2423
|
+
this.list = this.list.filter((p) => p.path !== path8.resolve(dir));
|
|
1812
2424
|
this.save();
|
|
1813
2425
|
}
|
|
1814
2426
|
};
|
|
1815
2427
|
function readPkg(dir) {
|
|
1816
2428
|
try {
|
|
1817
|
-
return JSON.parse(
|
|
2429
|
+
return JSON.parse(fs7.readFileSync(path8.join(dir, "package.json"), "utf8"));
|
|
1818
2430
|
} catch {
|
|
1819
2431
|
return null;
|
|
1820
2432
|
}
|
|
@@ -1822,24 +2434,24 @@ function readPkg(dir) {
|
|
|
1822
2434
|
function projectName(dir) {
|
|
1823
2435
|
const pkg = readPkg(dir);
|
|
1824
2436
|
const name = typeof pkg?.name === "string" ? pkg.name : "";
|
|
1825
|
-
return name && !name.startsWith("@") ? name :
|
|
2437
|
+
return name && !name.startsWith("@") ? name : path8.basename(dir);
|
|
1826
2438
|
}
|
|
1827
2439
|
function inspect(dir) {
|
|
1828
2440
|
const pkg = readPkg(dir);
|
|
1829
|
-
if (!pkg) return { next: false, ready: false, canvases: 0, exists:
|
|
2441
|
+
if (!pkg) return { next: false, ready: false, canvases: 0, exists: fs7.existsSync(dir) };
|
|
1830
2442
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1831
2443
|
let canvases = 0;
|
|
1832
2444
|
for (const d of ["canvas", "src/canvas"]) {
|
|
1833
2445
|
try {
|
|
1834
|
-
canvases +=
|
|
2446
|
+
canvases += fs7.readdirSync(path8.join(dir, d)).filter((f) => f.endsWith(".canvas.tsx")).length;
|
|
1835
2447
|
} catch {
|
|
1836
2448
|
}
|
|
1837
2449
|
}
|
|
1838
|
-
return { next: "next" in deps, ready: "truecanvas" in deps, canvases, exists: true };
|
|
2450
|
+
return { next: "next" in deps || "vite" in deps && "react" in deps, ready: "truecanvas" in deps, canvases, exists: true };
|
|
1839
2451
|
}
|
|
1840
2452
|
function discover() {
|
|
1841
|
-
const home =
|
|
1842
|
-
const roots = ["Github", "GitHub", "github", "Projects", "projects", "code", "Code", "dev", "src", "Developer", "Documents", "work"].map((d) =>
|
|
2453
|
+
const home = os3.homedir();
|
|
2454
|
+
const roots = ["Github", "GitHub", "github", "Projects", "projects", "code", "Code", "dev", "src", "Developer", "Documents", "work"].map((d) => path8.join(home, d)).filter((d) => fs7.existsSync(d));
|
|
1843
2455
|
const found = [];
|
|
1844
2456
|
let visited = 0;
|
|
1845
2457
|
const walk = (dir, depth) => {
|
|
@@ -1847,7 +2459,7 @@ function discover() {
|
|
|
1847
2459
|
visited++;
|
|
1848
2460
|
let entries;
|
|
1849
2461
|
try {
|
|
1850
|
-
entries =
|
|
2462
|
+
entries = fs7.readdirSync(dir, { withFileTypes: true });
|
|
1851
2463
|
} catch {
|
|
1852
2464
|
return;
|
|
1853
2465
|
}
|
|
@@ -1857,31 +2469,31 @@ function discover() {
|
|
|
1857
2469
|
}
|
|
1858
2470
|
for (const e of entries) {
|
|
1859
2471
|
if (!e.isDirectory() || e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist" || e.name === "build") continue;
|
|
1860
|
-
walk(
|
|
2472
|
+
walk(path8.join(dir, e.name), depth + 1);
|
|
1861
2473
|
}
|
|
1862
2474
|
};
|
|
1863
2475
|
for (const r of roots) walk(r, 0);
|
|
1864
2476
|
return found;
|
|
1865
2477
|
}
|
|
1866
2478
|
function listDirs(dir) {
|
|
1867
|
-
const abs =
|
|
1868
|
-
const dirs =
|
|
2479
|
+
const abs = path8.resolve(dir.replace(/^~(?=$|[\\/])/, os3.homedir()));
|
|
2480
|
+
const dirs = fs7.readdirSync(abs, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => ({ name: e.name, next: inspect(path8.join(abs, e.name)).next })).sort((a, b) => Number(b.next) - Number(a.next) || a.name.localeCompare(b.name));
|
|
1869
2481
|
return { path: abs, dirs };
|
|
1870
2482
|
}
|
|
1871
2483
|
|
|
1872
2484
|
// src/hub/runner.ts
|
|
1873
2485
|
import { execFile, spawn as spawn2 } from "child_process";
|
|
1874
|
-
import
|
|
2486
|
+
import fs9 from "fs";
|
|
1875
2487
|
import net from "net";
|
|
1876
|
-
import
|
|
1877
|
-
import
|
|
2488
|
+
import os5 from "os";
|
|
2489
|
+
import path10 from "path";
|
|
1878
2490
|
|
|
1879
2491
|
// src/cli/setup.ts
|
|
1880
2492
|
import { spawn } from "child_process";
|
|
1881
2493
|
import { createHash as createHash2 } from "crypto";
|
|
1882
|
-
import
|
|
1883
|
-
import
|
|
1884
|
-
import
|
|
2494
|
+
import fs8 from "fs";
|
|
2495
|
+
import os4 from "os";
|
|
2496
|
+
import path9 from "path";
|
|
1885
2497
|
function addDevArgs(pm, spec) {
|
|
1886
2498
|
return addArgs(pm, [spec], true);
|
|
1887
2499
|
}
|
|
@@ -1889,65 +2501,66 @@ function runScript(pm, script) {
|
|
|
1889
2501
|
return pm === "npm" ? `npm run ${script}` : `${pm} ${script}`;
|
|
1890
2502
|
}
|
|
1891
2503
|
var execInherit = (cmd, args, cwd) => new Promise((resolve, reject) => {
|
|
1892
|
-
const
|
|
2504
|
+
const safe = shellSafe(cmd, args);
|
|
2505
|
+
const p = spawn(safe.cmd, safe.args, { cwd, stdio: "inherit", env: toolEnv(), shell: safe.shell });
|
|
1893
2506
|
p.on("error", reject);
|
|
1894
2507
|
p.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`)));
|
|
1895
2508
|
});
|
|
1896
2509
|
function packageDir(cliFile) {
|
|
1897
|
-
return
|
|
2510
|
+
return path9.resolve(path9.dirname(cliFile), "..");
|
|
1898
2511
|
}
|
|
1899
2512
|
function packageVersion(pkgDir) {
|
|
1900
2513
|
try {
|
|
1901
|
-
return JSON.parse(
|
|
2514
|
+
return JSON.parse(fs8.readFileSync(path9.join(pkgDir, "package.json"), "utf8")).version;
|
|
1902
2515
|
} catch {
|
|
1903
2516
|
return "0.0.0";
|
|
1904
2517
|
}
|
|
1905
2518
|
}
|
|
1906
2519
|
function isSourceCheckout(pkgDir) {
|
|
1907
|
-
return !pkgDir.split(
|
|
2520
|
+
return !pkgDir.split(path9.sep).includes("node_modules") && fs8.existsSync(path9.join(pkgDir, "src", "cli"));
|
|
1908
2521
|
}
|
|
1909
2522
|
async function packageSpec(pkgDir, exec) {
|
|
1910
2523
|
if (process.env.TRUECANVAS_PACKAGE) return process.env.TRUECANVAS_PACKAGE;
|
|
1911
2524
|
const version = packageVersion(pkgDir);
|
|
1912
2525
|
if (!isSourceCheckout(pkgDir)) return `truecanvas@${version}`;
|
|
1913
|
-
const cacheDir =
|
|
1914
|
-
|
|
1915
|
-
const file =
|
|
2526
|
+
const cacheDir = path9.join(os4.homedir(), ".cache", "truecanvas");
|
|
2527
|
+
fs8.mkdirSync(cacheDir, { recursive: true });
|
|
2528
|
+
const file = path9.join(cacheDir, `truecanvas-${version}.tgz`);
|
|
1916
2529
|
await exec("npm", ["pack", "--silent", "--pack-destination", cacheDir], pkgDir);
|
|
1917
|
-
const hash = createHash2("sha256").update(
|
|
1918
|
-
const named =
|
|
1919
|
-
|
|
2530
|
+
const hash = createHash2("sha256").update(fs8.readFileSync(file)).digest("hex").slice(0, 10);
|
|
2531
|
+
const named = path9.join(cacheDir, `truecanvas-${version}-${hash}.tgz`);
|
|
2532
|
+
fs8.renameSync(file, named);
|
|
1920
2533
|
return named;
|
|
1921
2534
|
}
|
|
1922
2535
|
function installedIn(root) {
|
|
1923
2536
|
let declared = false;
|
|
1924
2537
|
try {
|
|
1925
|
-
const pkg = JSON.parse(
|
|
2538
|
+
const pkg = JSON.parse(fs8.readFileSync(path9.join(root, "package.json"), "utf8"));
|
|
1926
2539
|
declared = "truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1927
2540
|
} catch {
|
|
1928
2541
|
}
|
|
1929
|
-
const file =
|
|
1930
|
-
const installed =
|
|
1931
|
-
return { declared, installed, version: installed ? packageVersion(
|
|
2542
|
+
const file = path9.join(root, "node_modules", "truecanvas", "package.json");
|
|
2543
|
+
const installed = fs8.existsSync(file);
|
|
2544
|
+
return { declared, installed, version: installed ? packageVersion(path9.dirname(file)) : null };
|
|
1932
2545
|
}
|
|
1933
2546
|
function readPackage(root) {
|
|
1934
2547
|
try {
|
|
1935
|
-
return JSON.parse(
|
|
2548
|
+
return JSON.parse(fs8.readFileSync(path9.join(root, "package.json"), "utf8"));
|
|
1936
2549
|
} catch {
|
|
1937
2550
|
return null;
|
|
1938
2551
|
}
|
|
1939
2552
|
}
|
|
1940
2553
|
function depVersion(root, name) {
|
|
1941
|
-
const file =
|
|
1942
|
-
if (
|
|
2554
|
+
const file = path9.join(root, "node_modules", name, "package.json");
|
|
2555
|
+
if (fs8.existsSync(file)) return packageVersion(path9.dirname(file));
|
|
1943
2556
|
const pkg = readPackage(root);
|
|
1944
2557
|
return pkg ? { ...pkg.dependencies, ...pkg.devDependencies }[name] ?? null : null;
|
|
1945
2558
|
}
|
|
1946
2559
|
function mergeJson(file, update) {
|
|
1947
2560
|
let data2 = {};
|
|
1948
|
-
if (
|
|
2561
|
+
if (fs8.existsSync(file)) {
|
|
1949
2562
|
try {
|
|
1950
|
-
data2 = JSON.parse(
|
|
2563
|
+
data2 = JSON.parse(fs8.readFileSync(file, "utf8"));
|
|
1951
2564
|
} catch {
|
|
1952
2565
|
throw new Error(`${file} isn't valid JSON; fix it or remove it, then run connect again.`);
|
|
1953
2566
|
}
|
|
@@ -1955,24 +2568,24 @@ function mergeJson(file, update) {
|
|
|
1955
2568
|
const before = JSON.stringify(data2);
|
|
1956
2569
|
update(data2);
|
|
1957
2570
|
if (JSON.stringify(data2) === before) return "unchanged";
|
|
1958
|
-
|
|
1959
|
-
|
|
2571
|
+
fs8.mkdirSync(path9.dirname(file), { recursive: true });
|
|
2572
|
+
fs8.writeFileSync(file, `${JSON.stringify(data2, null, 2)}
|
|
1960
2573
|
`);
|
|
1961
2574
|
return "written";
|
|
1962
2575
|
}
|
|
1963
2576
|
function connectAgents(root, port, opts = {}) {
|
|
1964
2577
|
const url = `http://localhost:${port}/mcp`;
|
|
1965
2578
|
const out = { written: [], unchanged: [] };
|
|
1966
|
-
const add = (rel, update) => out[mergeJson(
|
|
2579
|
+
const add = (rel, update) => out[mergeJson(path9.join(root, rel), update)].push(rel);
|
|
1967
2580
|
const servers = (d, key) => d[key] ??= {};
|
|
1968
2581
|
add(".mcp.json", (d) => void (servers(d, "mcpServers").truecanvas = { type: "http", url }));
|
|
1969
|
-
if (opts.cursor ||
|
|
1970
|
-
if (opts.vscode ||
|
|
2582
|
+
if (opts.cursor || fs8.existsSync(path9.join(root, ".cursor"))) add(".cursor/mcp.json", (d) => void (servers(d, "mcpServers").truecanvas = { url }));
|
|
2583
|
+
if (opts.vscode || fs8.existsSync(path9.join(root, ".vscode"))) add(".vscode/mcp.json", (d) => void (servers(d, "servers").truecanvas = { type: "http", url }));
|
|
1971
2584
|
return out;
|
|
1972
2585
|
}
|
|
1973
2586
|
function hasAgentConfig(root, port) {
|
|
1974
2587
|
try {
|
|
1975
|
-
const d = JSON.parse(
|
|
2588
|
+
const d = JSON.parse(fs8.readFileSync(path9.join(root, ".mcp.json"), "utf8"));
|
|
1976
2589
|
const url = d?.mcpServers?.truecanvas?.url;
|
|
1977
2590
|
return typeof url === "string" && (port === void 0 || url.includes(`:${port}/`));
|
|
1978
2591
|
} catch {
|
|
@@ -2025,7 +2638,7 @@ var Runner = class {
|
|
|
2025
2638
|
return next;
|
|
2026
2639
|
}
|
|
2027
2640
|
async start(dir) {
|
|
2028
|
-
if (!
|
|
2641
|
+
if (!fs9.existsSync(path10.join(dir, "package.json"))) throw new Error(`${dir} isn't there anymore (or has no package.json).`);
|
|
2029
2642
|
const [editorPort, appPort] = await this.allocatePorts();
|
|
2030
2643
|
const proc = spawn2(process.execPath, [this.cli, "dev", "--no-open", "--port", String(editorPort), "--app", `http://localhost:${appPort}`], {
|
|
2031
2644
|
cwd: dir,
|
|
@@ -2120,7 +2733,8 @@ var Runner = class {
|
|
|
2120
2733
|
};
|
|
2121
2734
|
function killTree(proc, signal) {
|
|
2122
2735
|
try {
|
|
2123
|
-
if (process.platform
|
|
2736
|
+
if (process.platform === "win32" && proc.pid) spawn2("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { stdio: "ignore" }).on("error", () => proc.kill());
|
|
2737
|
+
else if (proc.pid) process.kill(-proc.pid, signal);
|
|
2124
2738
|
else proc.kill(signal);
|
|
2125
2739
|
} catch {
|
|
2126
2740
|
proc.kill(signal);
|
|
@@ -2136,7 +2750,7 @@ async function install(job, cmd, args, cwd) {
|
|
|
2136
2750
|
await run2(job, cmd, args, cwd);
|
|
2137
2751
|
} catch (err) {
|
|
2138
2752
|
const ignoredBuilds = job.log.some((l) => l.includes("ERR_PNPM_IGNORED_BUILDS"));
|
|
2139
|
-
const installed =
|
|
2753
|
+
const installed = fs9.existsSync(path10.join(cwd, "node_modules", "truecanvas", "package.json"));
|
|
2140
2754
|
if (!(ignoredBuilds && installed)) throw err;
|
|
2141
2755
|
job.log.push("(pnpm only complained about other packages' build scripts; Truecanvas is installed, continuing)");
|
|
2142
2756
|
}
|
|
@@ -2144,9 +2758,10 @@ async function install(job, cmd, args, cwd) {
|
|
|
2144
2758
|
function run2(job, cmd, args, cwd) {
|
|
2145
2759
|
job.log.push(`$ ${cmd} ${args.join(" ")}`);
|
|
2146
2760
|
return new Promise((resolve, reject) => {
|
|
2147
|
-
const
|
|
2761
|
+
const safe = shellSafe(cmd, args);
|
|
2762
|
+
const p = spawn2(safe.cmd, safe.args, { cwd, env: toolEnv({ CI: "1", FORCE_COLOR: "0" }), stdio: ["ignore", "pipe", "pipe"], shell: safe.shell });
|
|
2148
2763
|
const capture2 = (c2) => {
|
|
2149
|
-
for (const line of c2.toString().split(
|
|
2764
|
+
for (const line of c2.toString().split(/\r?\n/)) if (line.trim()) job.log.push(line.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, ""));
|
|
2150
2765
|
if (job.log.length > 300) job.log.splice(0, job.log.length - 300);
|
|
2151
2766
|
};
|
|
2152
2767
|
p.stdout.on("data", capture2);
|
|
@@ -2172,11 +2787,11 @@ function packageManager(dir) {
|
|
|
2172
2787
|
return { cmd: pm, add: addArgs(pm, [], true) };
|
|
2173
2788
|
}
|
|
2174
2789
|
async function preferredPm() {
|
|
2175
|
-
const { ok } = await capture("pnpm", ["--version"],
|
|
2790
|
+
const { ok } = await capture("pnpm", ["--version"], os5.homedir());
|
|
2176
2791
|
return ok ? "pnpm" : "npm";
|
|
2177
2792
|
}
|
|
2178
2793
|
function setupJob(dir, pkgDir, cli) {
|
|
2179
|
-
return startJob(`Set up ${
|
|
2794
|
+
return startJob(`Set up ${path10.basename(dir)}`, async (job) => {
|
|
2180
2795
|
const pm = packageManager(dir);
|
|
2181
2796
|
const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
|
|
2182
2797
|
await install(job, pm.cmd, [...pm.add, source], dir);
|
|
@@ -2186,12 +2801,12 @@ function setupJob(dir, pkgDir, cli) {
|
|
|
2186
2801
|
}
|
|
2187
2802
|
function createJob(name, parent, pkgDir, cli) {
|
|
2188
2803
|
const safe = name.trim().toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2189
|
-
const parentDir =
|
|
2190
|
-
const dir =
|
|
2804
|
+
const parentDir = path10.resolve(parent.replace(/^~(?=$|[\\/])/, os5.homedir()));
|
|
2805
|
+
const dir = path10.join(parentDir, safe);
|
|
2191
2806
|
return startJob(`Create ${safe}`, async (job) => {
|
|
2192
2807
|
if (!safe) throw new Error("Pick a project name.");
|
|
2193
|
-
if (
|
|
2194
|
-
|
|
2808
|
+
if (fs9.existsSync(dir)) throw new Error(`${dir} already exists.`);
|
|
2809
|
+
fs9.mkdirSync(parentDir, { recursive: true });
|
|
2195
2810
|
const pm = await preferredPm();
|
|
2196
2811
|
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
2812
|
const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
|
|
@@ -2202,31 +2817,32 @@ function createJob(name, parent, pkgDir, cli) {
|
|
|
2202
2817
|
}
|
|
2203
2818
|
function capture(cmd, args, cwd) {
|
|
2204
2819
|
return new Promise((resolve) => {
|
|
2820
|
+
const safe = shellSafe(cmd, args);
|
|
2205
2821
|
execFile(
|
|
2206
|
-
cmd,
|
|
2207
|
-
args,
|
|
2208
|
-
{ cwd, timeout: 6e4, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" } },
|
|
2822
|
+
safe.cmd,
|
|
2823
|
+
safe.args,
|
|
2824
|
+
{ shell: safe.shell, cwd, timeout: 6e4, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" } },
|
|
2209
2825
|
(err, stdout, stderr) => resolve({ ok: !err, out: err ? (stderr || err.message).trim() : stdout })
|
|
2210
2826
|
);
|
|
2211
2827
|
});
|
|
2212
2828
|
}
|
|
2213
2829
|
function cloneJob(repo, parent, pkgDir, cli) {
|
|
2214
|
-
const parentDir =
|
|
2830
|
+
const parentDir = path10.resolve(parent.replace(/^~(?=$|[\\/])/, os5.homedir()));
|
|
2215
2831
|
const name = repo.replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
|
|
2216
|
-
const dir =
|
|
2832
|
+
const dir = path10.join(parentDir, name);
|
|
2217
2833
|
return startJob(`Clone ${name}`, async (job) => {
|
|
2218
2834
|
if (!name || !/^[\w.-]+$/.test(name)) throw new Error("Pick a repository (owner/name or a git URL).");
|
|
2219
|
-
if (
|
|
2220
|
-
|
|
2835
|
+
if (fs9.existsSync(dir)) throw new Error(`${dir} already exists. Open it with Open folder instead.`);
|
|
2836
|
+
fs9.mkdirSync(parentDir, { recursive: true });
|
|
2221
2837
|
const url = /^(https?:|git@|ssh:|file:)/.test(repo);
|
|
2222
2838
|
if (url) await run2(job, "git", ["clone", repo, dir], parentDir);
|
|
2223
2839
|
else await run2(job, "gh", ["repo", "clone", repo, dir], parentDir);
|
|
2224
|
-
if (!
|
|
2840
|
+
if (!fs9.existsSync(path10.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.");
|
|
2225
2841
|
const pm = packageManager(dir);
|
|
2226
2842
|
await install(job, pm.cmd, ["install"], dir).catch(async (err) => {
|
|
2227
|
-
if (!
|
|
2843
|
+
if (!fs9.existsSync(path10.join(dir, "node_modules"))) throw err;
|
|
2228
2844
|
});
|
|
2229
|
-
const pkg = JSON.parse(
|
|
2845
|
+
const pkg = JSON.parse(fs9.readFileSync(path10.join(dir, "package.json"), "utf8"));
|
|
2230
2846
|
if (!("truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies })) {
|
|
2231
2847
|
const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
|
|
2232
2848
|
await install(job, pm.cmd, [...pm.add, source], dir);
|
|
@@ -2245,13 +2861,13 @@ var MIME2 = {
|
|
|
2245
2861
|
".woff2": "font/woff2",
|
|
2246
2862
|
".png": "image/png"
|
|
2247
2863
|
};
|
|
2248
|
-
var HUB_INSTRUCTIONS = `Truecanvas hub: several projects, each a Next.js app with canvases of real React components.
|
|
2864
|
+
var HUB_INSTRUCTIONS = `Truecanvas hub: several projects, each a Next.js or Vite + React app with canvases of real React components.
|
|
2249
2865
|
Your tools act on the current project (the active tab in the Truecanvas window, unless you pick one with open_project).
|
|
2250
2866
|
Use list_projects to see them. All other tools are the regular Truecanvas canvas tools for that project.`;
|
|
2251
2867
|
async function startHub(opts) {
|
|
2252
2868
|
const store = new ProjectStore();
|
|
2253
2869
|
const editorDir = fileURLToPath2(new URL("./editor/", import.meta.url));
|
|
2254
|
-
const pkgDir =
|
|
2870
|
+
const pkgDir = path11.resolve(path11.dirname(opts.cli), "..");
|
|
2255
2871
|
const origin = `http://localhost:${opts.port}`;
|
|
2256
2872
|
const allowedHosts = /* @__PURE__ */ new Set([`localhost:${opts.port}`, `127.0.0.1:${opts.port}`]);
|
|
2257
2873
|
const clients = /* @__PURE__ */ new Set();
|
|
@@ -2356,7 +2972,7 @@ async function startHub(opts) {
|
|
|
2356
2972
|
}
|
|
2357
2973
|
if (req.params.name === "open_project") {
|
|
2358
2974
|
const q = String(req.params.arguments?.project ?? "").toLowerCase();
|
|
2359
|
-
const p = store.all().find((x) => x.name.toLowerCase() === q || x.path.toLowerCase() === q ||
|
|
2975
|
+
const p = store.all().find((x) => x.name.toLowerCase() === q || x.path.toLowerCase() === q || path11.basename(x.path).toLowerCase() === q);
|
|
2360
2976
|
if (!p) return text(`No project "${q}". Use list_projects.`, true);
|
|
2361
2977
|
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);
|
|
2362
2978
|
store.touch(p.path);
|
|
@@ -2411,8 +3027,8 @@ async function startHub(opts) {
|
|
|
2411
3027
|
case "GET /api/hub/icon": {
|
|
2412
3028
|
const icon = findIcon(url.searchParams.get("path") ?? "");
|
|
2413
3029
|
if (!icon) return json2(res, 404, { error: "No icon" });
|
|
2414
|
-
res.writeHead(200, { "content-type": ICON_MIME[
|
|
2415
|
-
return
|
|
3030
|
+
res.writeHead(200, { "content-type": ICON_MIME[path11.extname(icon)] ?? "application/octet-stream", "cache-control": "private, max-age=300" });
|
|
3031
|
+
return fs10.createReadStream(icon).on("error", () => res.destroy()).pipe(res);
|
|
2416
3032
|
}
|
|
2417
3033
|
case "GET /api/hub/ls":
|
|
2418
3034
|
return json2(res, 200, listDirs(url.searchParams.get("path") || "~"));
|
|
@@ -2423,8 +3039,8 @@ async function startHub(opts) {
|
|
|
2423
3039
|
req.on("close", () => clients.delete(res));
|
|
2424
3040
|
return;
|
|
2425
3041
|
case "POST /api/hub/add": {
|
|
2426
|
-
const dir =
|
|
2427
|
-
if (!
|
|
3042
|
+
const dir = path11.resolve(body.path.replace(/^~(?=$|[\\/])/, os6.homedir()));
|
|
3043
|
+
if (!fs10.existsSync(path11.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." });
|
|
2428
3044
|
store.touch(dir);
|
|
2429
3045
|
broadcast();
|
|
2430
3046
|
return json2(res, 200, { path: dir, ready: store.all().find((p) => p.path === dir)?.ready ?? false });
|
|
@@ -2463,7 +3079,7 @@ async function startHub(opts) {
|
|
|
2463
3079
|
return json2(res, 200, createJob(body.name, body.parent, pkgDir, opts.cli));
|
|
2464
3080
|
// ---------- GitHub ----------
|
|
2465
3081
|
case "GET /api/hub/github/repos": {
|
|
2466
|
-
const r = await capture("gh", ["repo", "list", "--limit", "100", "--json", "nameWithOwner,description,updatedAt,isPrivate"],
|
|
3082
|
+
const r = await capture("gh", ["repo", "list", "--limit", "100", "--json", "nameWithOwner,description,updatedAt,isPrivate"], os6.homedir());
|
|
2467
3083
|
if (!r.ok) return json2(res, 200, { gh: false, error: r.out.split("\n")[0], repos: [] });
|
|
2468
3084
|
const repos = JSON.parse(r.out).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
2469
3085
|
return json2(res, 200, { gh: true, repos });
|
|
@@ -2508,14 +3124,14 @@ async function startHub(opts) {
|
|
|
2508
3124
|
return json2(res, 404, { error: `No route ${route}` });
|
|
2509
3125
|
}
|
|
2510
3126
|
function serveStatic(res, pathname) {
|
|
2511
|
-
let file =
|
|
2512
|
-
if (!inside2(editorDir, file) || !
|
|
2513
|
-
if (!
|
|
3127
|
+
let file = path11.join(editorDir, pathname === "/" ? "hub.html" : pathname);
|
|
3128
|
+
if (!inside2(editorDir, file) || !fs10.existsSync(file) || fs10.statSync(file).isDirectory()) file = path11.join(editorDir, "hub.html");
|
|
3129
|
+
if (!fs10.existsSync(file)) {
|
|
2514
3130
|
res.writeHead(500, { "content-type": "text/plain" });
|
|
2515
3131
|
return res.end("Hub bundle missing. Run `pnpm build`.");
|
|
2516
3132
|
}
|
|
2517
|
-
res.writeHead(200, { "content-type": MIME2[
|
|
2518
|
-
|
|
3133
|
+
res.writeHead(200, { "content-type": MIME2[path11.extname(file)] ?? "application/octet-stream", "cache-control": pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache" });
|
|
3134
|
+
fs10.createReadStream(file).on("error", () => res.destroy()).pipe(res);
|
|
2519
3135
|
}
|
|
2520
3136
|
const server = http2.createServer(async (req, res) => {
|
|
2521
3137
|
try {
|
|
@@ -2547,27 +3163,27 @@ async function startHub(opts) {
|
|
|
2547
3163
|
}
|
|
2548
3164
|
var ICON_MIME = { ".ico": "image/x-icon", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
|
|
2549
3165
|
function findIcon(dir) {
|
|
2550
|
-
if (!dir || !
|
|
3166
|
+
if (!dir || !fs10.existsSync(path11.join(dir, "package.json"))) return null;
|
|
2551
3167
|
const names = ["icon.svg", "icon.png", "favicon.svg", "favicon.ico", "icon.ico", "apple-icon.png", "favicon.png", "logo.svg", "icon.jpg"];
|
|
2552
3168
|
const roots = ["app", "src/app", "public", "static", "assets"];
|
|
2553
3169
|
for (const root of roots) {
|
|
2554
3170
|
for (const name of names) {
|
|
2555
|
-
const file =
|
|
2556
|
-
if (
|
|
3171
|
+
const file = path11.join(dir, root, name);
|
|
3172
|
+
if (fs10.existsSync(file) && fs10.statSync(file).size < 2e6) return file;
|
|
2557
3173
|
}
|
|
2558
3174
|
}
|
|
2559
3175
|
for (const root of ["app", "src/app"]) {
|
|
2560
3176
|
let entries = [];
|
|
2561
3177
|
try {
|
|
2562
|
-
entries =
|
|
3178
|
+
entries = fs10.readdirSync(path11.join(dir, root), { withFileTypes: true });
|
|
2563
3179
|
} catch {
|
|
2564
3180
|
continue;
|
|
2565
3181
|
}
|
|
2566
3182
|
for (const e of entries) {
|
|
2567
3183
|
if (!e.isDirectory()) continue;
|
|
2568
3184
|
for (const name of names.slice(0, 4)) {
|
|
2569
|
-
const file =
|
|
2570
|
-
if (
|
|
3185
|
+
const file = path11.join(dir, root, e.name, name);
|
|
3186
|
+
if (fs10.existsSync(file)) return file;
|
|
2571
3187
|
}
|
|
2572
3188
|
}
|
|
2573
3189
|
}
|
|
@@ -2578,14 +3194,14 @@ function json2(res, status, body) {
|
|
|
2578
3194
|
res.end(JSON.stringify(body));
|
|
2579
3195
|
}
|
|
2580
3196
|
function inside2(dir, file) {
|
|
2581
|
-
const rel =
|
|
2582
|
-
return !!rel && !rel.startsWith("..") && !
|
|
3197
|
+
const rel = path11.relative(dir, file);
|
|
3198
|
+
return !!rel && !rel.startsWith("..") && !path11.isAbsolute(rel);
|
|
2583
3199
|
}
|
|
2584
3200
|
|
|
2585
3201
|
// src/hub/desktop.ts
|
|
2586
|
-
import
|
|
2587
|
-
import
|
|
2588
|
-
import
|
|
3202
|
+
import fs11 from "fs";
|
|
3203
|
+
import os7 from "os";
|
|
3204
|
+
import path12 from "path";
|
|
2589
3205
|
var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
|
2590
3206
|
<defs>
|
|
2591
3207
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff9466"/><stop offset="1" stop-color="#ea6a3c"/></linearGradient>
|
|
@@ -2599,15 +3215,15 @@ var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
|
|
2599
3215
|
<path fill="#f6f5f1" fill-rule="evenodd" d="M236 236h552v168H236z M428 316h168v472H428z"/>
|
|
2600
3216
|
</svg>
|
|
2601
3217
|
`;
|
|
2602
|
-
var data = () => process.env.XDG_DATA_HOME ??
|
|
3218
|
+
var data = () => process.env.XDG_DATA_HOME ?? path12.join(os7.homedir(), ".local", "share");
|
|
2603
3219
|
function installDesktop(cli, port) {
|
|
2604
|
-
const dir =
|
|
2605
|
-
|
|
2606
|
-
const icon =
|
|
2607
|
-
|
|
2608
|
-
const launcher =
|
|
3220
|
+
const dir = path12.join(data(), "truecanvas");
|
|
3221
|
+
fs11.mkdirSync(dir, { recursive: true });
|
|
3222
|
+
const icon = path12.join(dir, "icon.svg");
|
|
3223
|
+
fs11.writeFileSync(icon, ICON);
|
|
3224
|
+
const launcher = path12.join(dir, "launch.sh");
|
|
2609
3225
|
const url = `http://localhost:${port}`;
|
|
2610
|
-
|
|
3226
|
+
fs11.writeFileSync(
|
|
2611
3227
|
launcher,
|
|
2612
3228
|
`#!/bin/sh
|
|
2613
3229
|
# Truecanvas launcher: start the hub if needed, then open its window.
|
|
@@ -2616,7 +3232,7 @@ CLI=${JSON.stringify(cli)}
|
|
|
2616
3232
|
URL=${JSON.stringify(url)}
|
|
2617
3233
|
LOG="\${XDG_STATE_HOME:-$HOME/.local/state}/truecanvas-hub.log"
|
|
2618
3234
|
# apps started from the desktop don't get your shell's PATH: next, pnpm and npx need it
|
|
2619
|
-
export PATH=${JSON.stringify([
|
|
3235
|
+
export PATH=${JSON.stringify([path12.dirname(process.execPath), ...(process.env.PATH ?? "").split(path12.delimiter)].filter((p, i, a) => p && a.indexOf(p) === i).join(path12.delimiter))}
|
|
2620
3236
|
mkdir -p "$(dirname "$LOG")"
|
|
2621
3237
|
|
|
2622
3238
|
if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
|
|
@@ -2625,7 +3241,7 @@ if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
|
|
|
2625
3241
|
while [ $i -lt 60 ] && ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; do sleep 0.25; i=$((i+1)); done
|
|
2626
3242
|
fi
|
|
2627
3243
|
|
|
2628
|
-
PROFILE=${JSON.stringify(
|
|
3244
|
+
PROFILE=${JSON.stringify(path12.join(dir, "window"))}
|
|
2629
3245
|
for b in google-chrome google-chrome-stable chromium-browser chromium brave-browser microsoft-edge; do
|
|
2630
3246
|
if command -v "$b" >/dev/null 2>&1; then
|
|
2631
3247
|
exec "$b" --app="$URL" --class=Truecanvas --user-data-dir="$PROFILE" --no-first-run --no-default-browser-check
|
|
@@ -2635,10 +3251,10 @@ exec xdg-open "$URL"
|
|
|
2635
3251
|
`,
|
|
2636
3252
|
{ mode: 493 }
|
|
2637
3253
|
);
|
|
2638
|
-
const apps =
|
|
2639
|
-
|
|
2640
|
-
const desktop =
|
|
2641
|
-
|
|
3254
|
+
const apps = path12.join(data(), "applications");
|
|
3255
|
+
fs11.mkdirSync(apps, { recursive: true });
|
|
3256
|
+
const desktop = path12.join(apps, "truecanvas.desktop");
|
|
3257
|
+
fs11.writeFileSync(
|
|
2642
3258
|
desktop,
|
|
2643
3259
|
`[Desktop Entry]
|
|
2644
3260
|
Type=Application
|
|
@@ -2654,11 +3270,11 @@ StartupWMClass=Truecanvas
|
|
|
2654
3270
|
StartupNotify=true
|
|
2655
3271
|
`
|
|
2656
3272
|
);
|
|
2657
|
-
let command =
|
|
2658
|
-
const ours = !
|
|
3273
|
+
let command = path12.join(os7.homedir(), ".local", "bin", "truecanvas");
|
|
3274
|
+
const ours = !fs11.existsSync(command) || fs11.readFileSync(command, "utf8").includes("# truecanvas wrapper");
|
|
2659
3275
|
if (ours) {
|
|
2660
|
-
|
|
2661
|
-
|
|
3276
|
+
fs11.mkdirSync(path12.dirname(command), { recursive: true });
|
|
3277
|
+
fs11.writeFileSync(command, `#!/bin/sh
|
|
2662
3278
|
# truecanvas wrapper (installed by \`truecanvas desktop\`)
|
|
2663
3279
|
exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
|
|
2664
3280
|
`, { mode: 493 });
|
|
@@ -2666,19 +3282,19 @@ exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
|
|
|
2666
3282
|
return { desktop, launcher, icon, command };
|
|
2667
3283
|
}
|
|
2668
3284
|
function launcherPath() {
|
|
2669
|
-
const p =
|
|
2670
|
-
return
|
|
3285
|
+
const p = path12.join(data(), "truecanvas", "launch.sh");
|
|
3286
|
+
return fs11.existsSync(p) ? p : null;
|
|
2671
3287
|
}
|
|
2672
3288
|
function uninstallDesktop() {
|
|
2673
3289
|
const removed = [];
|
|
2674
|
-
const cmd =
|
|
2675
|
-
if (
|
|
2676
|
-
|
|
3290
|
+
const cmd = path12.join(os7.homedir(), ".local", "bin", "truecanvas");
|
|
3291
|
+
if (fs11.existsSync(cmd) && fs11.readFileSync(cmd, "utf8").includes("# truecanvas wrapper")) {
|
|
3292
|
+
fs11.rmSync(cmd);
|
|
2677
3293
|
removed.push(cmd);
|
|
2678
3294
|
}
|
|
2679
|
-
for (const f of [
|
|
2680
|
-
if (
|
|
2681
|
-
|
|
3295
|
+
for (const f of [path12.join(data(), "applications", "truecanvas.desktop"), path12.join(data(), "truecanvas")]) {
|
|
3296
|
+
if (fs11.existsSync(f)) {
|
|
3297
|
+
fs11.rmSync(f, { recursive: true, force: true });
|
|
2682
3298
|
removed.push(f);
|
|
2683
3299
|
}
|
|
2684
3300
|
}
|
|
@@ -2691,8 +3307,8 @@ import { createInterface } from "readline/promises";
|
|
|
2691
3307
|
|
|
2692
3308
|
// src/cli/doctor.ts
|
|
2693
3309
|
import { execFile as execFile2 } from "child_process";
|
|
2694
|
-
import
|
|
2695
|
-
import
|
|
3310
|
+
import fs12 from "fs";
|
|
3311
|
+
import path13 from "path";
|
|
2696
3312
|
var major = (v) => v ? Number(/(\d+)(?:\.(\d+))?/.exec(v)?.[1] ?? 0) : 0;
|
|
2697
3313
|
function has(cmd, args, cwd) {
|
|
2698
3314
|
return new Promise((resolve) => execFile2(cmd, args, { cwd, timeout: 8e3 }, (err, stdout, stderr) => resolve(err ? null : `${stdout}${stderr}`)));
|
|
@@ -2701,38 +3317,42 @@ async function doctor(root, cliVersion) {
|
|
|
2701
3317
|
const checks = [];
|
|
2702
3318
|
const add = (c2) => checks.push(c2);
|
|
2703
3319
|
const node = process.versions.node;
|
|
2704
|
-
add(major(node) >=
|
|
3320
|
+
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)" });
|
|
2705
3321
|
const pkg = readPackage(root);
|
|
2706
3322
|
if (!pkg) {
|
|
2707
|
-
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" });
|
|
3323
|
+
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" });
|
|
2708
3324
|
return checks;
|
|
2709
3325
|
}
|
|
2710
3326
|
const pm = detectPm(root);
|
|
2711
3327
|
const next = depVersion(root, "next");
|
|
2712
|
-
|
|
3328
|
+
const vite = !next ? depVersion(root, "vite") : null;
|
|
3329
|
+
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` });
|
|
3330
|
+
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" });
|
|
2713
3331
|
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` });
|
|
2714
3332
|
else add({ label: "Next.js", status: "ok", detail: next });
|
|
2715
3333
|
const react = depVersion(root, "react");
|
|
2716
|
-
add(react && major(react) >= 19 ? { label: "React", status: "ok", detail: react } : { label: "React", status: "fail", detail: react ?? "missing", fix:
|
|
3334
|
+
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` });
|
|
2717
3335
|
const tw = depVersion(root, "tailwindcss");
|
|
2718
3336
|
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" });
|
|
2719
3337
|
const config = loadConfig(root);
|
|
2720
|
-
add(
|
|
3338
|
+
if (config.framework === "next") add(fs12.existsSync(path13.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/)" });
|
|
2721
3339
|
const tc = installedIn(root);
|
|
2722
3340
|
if (!tc.declared) add({ label: "Truecanvas in the project", status: "fail", detail: "not a dependency", fix: "npx truecanvas (sets it up)" });
|
|
2723
3341
|
else if (!tc.installed) add({ label: "Truecanvas in the project", status: "fail", detail: "declared but not installed", fix: `${pm} install` });
|
|
2724
3342
|
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` });
|
|
2725
3343
|
else add({ label: "Truecanvas in the project", status: "ok", detail: tc.version ?? "" });
|
|
2726
|
-
const wrapped =
|
|
2727
|
-
|
|
3344
|
+
const wrapped = configStatus(root, config.framework);
|
|
3345
|
+
const configLabel = config.framework === "vite" ? "vite.config" : "next.config";
|
|
3346
|
+
const uses = config.framework === "vite" ? "has the truecanvas() plugin" : "uses withTruecanvas()";
|
|
3347
|
+
add(wrapped.wrapped ? { label: configLabel, status: "ok", detail: `${path13.basename(wrapped.file)} ${uses}` } : { label: configLabel, status: "fail", detail: wrapped.file ? "Truecanvas not enabled" : "missing", fix: "npx truecanvas init" });
|
|
2728
3348
|
const scripts = pkg.scripts ?? {};
|
|
2729
3349
|
const script = Object.entries(scripts).find(([, v]) => v.includes("truecanvas"));
|
|
2730
3350
|
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")' });
|
|
2731
|
-
const ignore =
|
|
2732
|
-
add(ignore.includes(
|
|
3351
|
+
const ignore = fs12.existsSync(path13.join(root, ".gitignore")) ? fs12.readFileSync(path13.join(root, ".gitignore"), "utf8") : "";
|
|
3352
|
+
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" });
|
|
2733
3353
|
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)" });
|
|
2734
3354
|
const chrome = findChromium();
|
|
2735
|
-
add(chrome ? { label: "Screenshots", status: "ok", detail:
|
|
3355
|
+
add(chrome ? { label: "Screenshots", status: "ok", detail: path13.basename(chrome) } : { label: "Screenshots", status: "warn", detail: "no Chromium found", fix: "npx playwright install chromium-headless-shell (or install Chrome)" });
|
|
2736
3356
|
const git = await has("git", ["rev-parse", "--is-inside-work-tree"], root);
|
|
2737
3357
|
add(git ? { label: "Git", status: "ok" } : { label: "Git", status: "warn", detail: "not a repository", fix: "git init (branches, history, comments and compare use git)" });
|
|
2738
3358
|
const gh = await has("gh", ["auth", "status"], root);
|
|
@@ -2746,24 +3366,27 @@ async function doctor(root, cliVersion) {
|
|
|
2746
3366
|
var HELP = `truecanvas: design with your real React components
|
|
2747
3367
|
|
|
2748
3368
|
Usage
|
|
2749
|
-
truecanvas In a Next.js app: set it up (asks once) and start the editor.
|
|
3369
|
+
truecanvas In a Next.js or Vite app: set it up (asks once) and start the editor.
|
|
2750
3370
|
Elsewhere: open the Truecanvas window.
|
|
2751
3371
|
truecanvas connect Add Truecanvas to this project's .mcp.json (Claude Code, Cursor,
|
|
2752
3372
|
VS Code): commit it and every teammate's agent connects
|
|
2753
3373
|
truecanvas doctor Check this project and machine, with a fix for each problem
|
|
2754
3374
|
truecanvas dev Start the editor + MCP server for this project (no window)
|
|
2755
3375
|
truecanvas setup Install and set up Truecanvas in this project, without starting
|
|
2756
|
-
truecanvas init Only write the config (next
|
|
3376
|
+
truecanvas init Only write the config (next/vite config, script, canvas folder)
|
|
2757
3377
|
truecanvas open Open the Truecanvas window (projects dashboard + tabs)
|
|
2758
3378
|
truecanvas quit Quit the window and every app it started
|
|
2759
3379
|
truecanvas desktop Add Truecanvas to your app launcher (Linux); "desktop remove" undoes it
|
|
3380
|
+
truecanvas share [canvas] Share a canvas as a link for clients (needs truecanvas running).
|
|
3381
|
+
--password <p>, --no-password, --revoke, --restore, --delete, --title <t>, --local (preview only)
|
|
3382
|
+
truecanvas share setup Connect your studio's review site (URL + REVIEW_TOKEN)
|
|
2760
3383
|
truecanvas mcp stdio MCP server for agents without HTTP support
|
|
2761
3384
|
|
|
2762
3385
|
Options
|
|
2763
3386
|
-y, --yes Don't ask: set up and connect agents
|
|
2764
3387
|
--port <n> Editor/MCP port (default 4800)
|
|
2765
|
-
--app <url> Your
|
|
2766
|
-
--no-next Don't start
|
|
3388
|
+
--app <url> Your app's dev URL (default http://localhost:3000, Vite :5173)
|
|
3389
|
+
--no-next Don't start the app's dev server (by default it starts when the app isn't running)
|
|
2767
3390
|
--no-open Don't open the editor in your browser
|
|
2768
3391
|
--cursor, --vscode connect: also write .cursor/mcp.json / .vscode/mcp.json
|
|
2769
3392
|
-v, --version Print the version
|
|
@@ -2792,7 +3415,15 @@ async function main() {
|
|
|
2792
3415
|
yes: { type: "boolean", short: "y" },
|
|
2793
3416
|
cursor: { type: "boolean" },
|
|
2794
3417
|
vscode: { type: "boolean" },
|
|
2795
|
-
version: { type: "boolean", short: "v" }
|
|
3418
|
+
version: { type: "boolean", short: "v" },
|
|
3419
|
+
local: { type: "boolean" },
|
|
3420
|
+
title: { type: "string" },
|
|
3421
|
+
password: { type: "string" },
|
|
3422
|
+
"no-password": { type: "boolean" },
|
|
3423
|
+
revoke: { type: "boolean" },
|
|
3424
|
+
restore: { type: "boolean" },
|
|
3425
|
+
delete: { type: "boolean" },
|
|
3426
|
+
token: { type: "string" }
|
|
2796
3427
|
}
|
|
2797
3428
|
});
|
|
2798
3429
|
const cli = fileURLToPath3(import.meta.url);
|
|
@@ -2807,13 +3438,14 @@ async function main() {
|
|
|
2807
3438
|
const yes = !!values.yes;
|
|
2808
3439
|
if (command === "start") {
|
|
2809
3440
|
const root2 = process.cwd();
|
|
2810
|
-
|
|
3441
|
+
const deps = { ...readPackage(root2)?.dependencies, ...readPackage(root2)?.devDependencies };
|
|
3442
|
+
if (!("next" in deps) && !("vite" in deps && "react" in deps)) {
|
|
2811
3443
|
return openWindow(cli, values.port ? Number(values.port) : 4800);
|
|
2812
3444
|
}
|
|
2813
3445
|
if (!await ensureSetup(root2, pkgDir, version, yes)) return;
|
|
2814
3446
|
if (await openInWindow(root2, Number(values.port ?? 4800))) return;
|
|
2815
|
-
const local =
|
|
2816
|
-
if (
|
|
3447
|
+
const local = path14.join(root2, "node_modules", "truecanvas", "dist", "cli.js");
|
|
3448
|
+
if (fs13.existsSync(local) && fs13.realpathSync(local) !== fs13.realpathSync(cli)) {
|
|
2817
3449
|
const child = spawn3(process.execPath, [local, "dev", ...process.argv.slice(2).filter((a) => a !== "start" && a !== "-y" && a !== "--yes")], { stdio: "inherit" });
|
|
2818
3450
|
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => child.kill(sig));
|
|
2819
3451
|
child.on("exit", (code) => process.exit(code ?? 0));
|
|
@@ -2881,6 +3513,7 @@ async function main() {
|
|
|
2881
3513
|
return console.log(`${c.green("\u2713")} Truecanvas quit. Project apps it started are stopped too.`);
|
|
2882
3514
|
}
|
|
2883
3515
|
if (command === "desktop") {
|
|
3516
|
+
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.`);
|
|
2884
3517
|
if (positionals[1] === "remove") {
|
|
2885
3518
|
const removed = uninstallDesktop();
|
|
2886
3519
|
console.log(removed.length ? `${c.green("\u2713")} Removed the launcher.` : "Nothing to remove.");
|
|
@@ -2894,8 +3527,8 @@ async function main() {
|
|
|
2894
3527
|
return;
|
|
2895
3528
|
}
|
|
2896
3529
|
const root = process.cwd();
|
|
2897
|
-
if (!
|
|
2898
|
-
console.error("Run truecanvas from your
|
|
3530
|
+
if (!fs13.existsSync(path14.join(root, "package.json"))) {
|
|
3531
|
+
console.error("Run truecanvas from your app's root (no package.json here).");
|
|
2899
3532
|
process.exit(1);
|
|
2900
3533
|
}
|
|
2901
3534
|
const config = loadConfig(root, { port: values.port ? Number(values.port) : void 0, appUrl: values.app });
|
|
@@ -2904,16 +3537,88 @@ async function main() {
|
|
|
2904
3537
|
const report = initProject(config);
|
|
2905
3538
|
ws.ensureRoute();
|
|
2906
3539
|
console.log(`
|
|
2907
|
-
${c.accent("\u25C6")} ${c.bold("Truecanvas")} is set up in ${c.bold(
|
|
3540
|
+
${c.accent("\u25C6")} ${c.bold("Truecanvas")} is set up in ${c.bold(path14.basename(root))}
|
|
2908
3541
|
`);
|
|
2909
3542
|
for (const line of report.done) console.log(` ${c.green("\u2713")} ${line}`);
|
|
2910
|
-
console.log(` ${c.green("\u2713")} Canvas folder ${c.bold(config.canvasDir)} and dev route ${c.bold(
|
|
3543
|
+
console.log(` ${c.green("\u2713")} Canvas folder ${c.bold(config.canvasDir)} and dev route ${c.bold(config.routeDir)}`);
|
|
2911
3544
|
for (const line of report.todo) console.log(` ${c.dim("!")} ${line}`);
|
|
2912
3545
|
console.log(`
|
|
2913
3546
|
Next: ${c.accent("npm run canvas")} ${c.dim("(starts your app and the editor)")}
|
|
2914
3547
|
`);
|
|
2915
3548
|
return;
|
|
2916
3549
|
}
|
|
3550
|
+
if (command === "share") {
|
|
3551
|
+
if (positionals[1] === "setup") {
|
|
3552
|
+
const url = positionals[2] ?? await prompt("Review site URL (e.g. https://review.your-studio.com):");
|
|
3553
|
+
const token = values.token ?? process.env.TRUECANVAS_REVIEW_TOKEN ?? await prompt("Its REVIEW_TOKEN:");
|
|
3554
|
+
try {
|
|
3555
|
+
const { brand } = await connectReviewSite(url, token);
|
|
3556
|
+
console.log(` ${c.green("\u2713")} Connected to ${c.bold(brand.name)} (${url}). Share with ${c.accent("npx truecanvas share <canvas>")}.`);
|
|
3557
|
+
} catch (err) {
|
|
3558
|
+
console.error(` ${c.red("\u2717")} ${err.message}`);
|
|
3559
|
+
process.exit(1);
|
|
3560
|
+
}
|
|
3561
|
+
return;
|
|
3562
|
+
}
|
|
3563
|
+
const base = `http://localhost:${config.port}`;
|
|
3564
|
+
const state = await fetch(`${base}/api/state`).then((r) => r.ok ? r.json() : null).catch(() => null);
|
|
3565
|
+
const canvas = positionals[1] ?? (state?.canvases.length === 1 ? state.canvases[0] : null);
|
|
3566
|
+
if (values.delete) {
|
|
3567
|
+
const site = reviewSite();
|
|
3568
|
+
if (!site || !canvas) {
|
|
3569
|
+
console.error(site ? "Name the canvas: truecanvas share <canvas> --delete" : `No review site yet: ${c.accent("npx truecanvas share setup")}`);
|
|
3570
|
+
process.exit(1);
|
|
3571
|
+
}
|
|
3572
|
+
const done = await deleteShare(site, path14.basename(root), canvas);
|
|
3573
|
+
console.log(done ? ` ${c.green("\u2713")} Deleted the link of ${canvas}, with its versions and comments.` : ` ${canvas} has no link.`);
|
|
3574
|
+
return;
|
|
3575
|
+
}
|
|
3576
|
+
if (values.revoke || values.restore || values["no-password"]) {
|
|
3577
|
+
const site = reviewSite();
|
|
3578
|
+
if (!site || !canvas) {
|
|
3579
|
+
console.error(site ? "Name the canvas: truecanvas share <canvas> --revoke" : `No review site yet: ${c.accent("npx truecanvas share setup")}`);
|
|
3580
|
+
process.exit(1);
|
|
3581
|
+
}
|
|
3582
|
+
const change = { ...values.revoke ? { revoked: true } : values.restore ? { revoked: false } : {}, ...values["no-password"] ? { password: null } : {} };
|
|
3583
|
+
const url = await updateShare(site, path14.basename(root), canvas, change);
|
|
3584
|
+
console.log(` ${c.green("\u2713")} ${values.revoke ? "Link revoked" : values.restore ? "Link restored" : "Password removed"}: ${url}`);
|
|
3585
|
+
return;
|
|
3586
|
+
}
|
|
3587
|
+
if (!state) {
|
|
3588
|
+
console.error(`Truecanvas isn't running for this project. Start it first: ${c.accent("npx truecanvas")}`);
|
|
3589
|
+
process.exit(1);
|
|
3590
|
+
}
|
|
3591
|
+
if (!canvas || !state.canvases.includes(canvas)) {
|
|
3592
|
+
console.error(`Which canvas? ${state.canvases.join(", ") || "(none)"}
|
|
3593
|
+
truecanvas share <canvas>`);
|
|
3594
|
+
process.exit(1);
|
|
3595
|
+
}
|
|
3596
|
+
const publish = !values.local && !!reviewSite();
|
|
3597
|
+
console.log(` ${c.dim(`Rendering ${canvas} through your app${publish ? " and publishing it" : ""}\u2026`)}`);
|
|
3598
|
+
const password = values.password;
|
|
3599
|
+
const res = await fetch(`${base}/api/share/snapshot`, { method: "POST", headers: { "content-type": "application/json", origin: base }, body: JSON.stringify({ canvas, local: !publish, title: values.title, password }) });
|
|
3600
|
+
const body = await res.json();
|
|
3601
|
+
if (!res.ok) {
|
|
3602
|
+
console.error(` ${c.red("\u2717")} ${body.error}`);
|
|
3603
|
+
process.exit(1);
|
|
3604
|
+
}
|
|
3605
|
+
const { manifest, published } = body;
|
|
3606
|
+
console.log(` ${c.green("\u2713")} ${manifest.frames.length} frame${manifest.frames.length === 1 ? "" : "s"} frozen: no scripts, no API calls.`);
|
|
3607
|
+
if (manifest.external.length) console.log(` ${c.yellow("!")} While rendering, the app called ${manifest.external.join(", ")}. Whatever it showed is in the snapshot: use sample data for client links.`);
|
|
3608
|
+
if (manifest.missing.length) console.log(` ${c.yellow("!")} ${manifest.missing.length} asset${manifest.missing.length === 1 ? "" : "s"} couldn't be fetched.`);
|
|
3609
|
+
if (published?.skipped.length) console.log(` ${c.yellow("!")} Left out (over 4.4 MB): ${published.skipped.join(", ")}`);
|
|
3610
|
+
if (published) {
|
|
3611
|
+
console.log(` ${c.green("\u2713")} Shared: ${c.bold(published.url)} ${c.dim(`(version ${published.versions}${published.password ? ", password protected" : ""})`)}
|
|
3612
|
+
`);
|
|
3613
|
+
if (values.open && !values["no-open"]) openBrowser(published.url);
|
|
3614
|
+
} else {
|
|
3615
|
+
console.log(` ${c.dim("Preview")} ${c.bold(body.preview)}`);
|
|
3616
|
+
if (!values.local) console.log(` ${c.dim("To send links to clients, connect your review site:")} ${c.accent("npx truecanvas share setup")}`);
|
|
3617
|
+
console.log("");
|
|
3618
|
+
if (values.open && !values["no-open"]) openBrowser(body.preview);
|
|
3619
|
+
}
|
|
3620
|
+
return;
|
|
3621
|
+
}
|
|
2917
3622
|
if (command === "mcp") {
|
|
2918
3623
|
console.log = console.error;
|
|
2919
3624
|
const mcpUrl = `http://localhost:${config.port}/mcp`;
|
|
@@ -2934,7 +3639,7 @@ async function main() {
|
|
|
2934
3639
|
process.exit(1);
|
|
2935
3640
|
}
|
|
2936
3641
|
if (command === "dev" && await openInWindow(root, config.port)) return;
|
|
2937
|
-
const status =
|
|
3642
|
+
const status = configStatus(root, config.framework);
|
|
2938
3643
|
if (!status.wrapped) {
|
|
2939
3644
|
const report = initProject(config);
|
|
2940
3645
|
for (const line of report.done) console.log(` ${c.green("\u2713")} ${line}`);
|
|
@@ -2955,13 +3660,14 @@ async function main() {
|
|
|
2955
3660
|
const mcp = `${origin}/mcp`;
|
|
2956
3661
|
let next = null;
|
|
2957
3662
|
const appUp = await reachable(config.appUrl);
|
|
2958
|
-
|
|
3663
|
+
const devCommand = config.framework === "vite" ? "vite" : "next dev";
|
|
3664
|
+
if (!appUp && values.next && !values["no-next"]) next = startApp(root, config.appUrl, config.framework);
|
|
2959
3665
|
console.log(`
|
|
2960
3666
|
${c.accent("\u25C6")} ${c.bold("Truecanvas")} ${c.dim(`v${version}`)}
|
|
2961
3667
|
|
|
2962
3668
|
${c.dim("Editor")} ${c.bold(origin)}
|
|
2963
3669
|
${c.dim("MCP")} ${mcp}
|
|
2964
|
-
${c.dim("App")} ${config.appUrl} ${c.dim(appUp ? "(already running)" : next ?
|
|
3670
|
+
${c.dim("App")} ${config.appUrl} ${c.dim(appUp ? "(already running)" : next ? `(starting ${devCommand}\u2026)` : "(not running)")}
|
|
2965
3671
|
${c.dim("Canvases")} ${ws.canvases().join(", ") || "none"}
|
|
2966
3672
|
|
|
2967
3673
|
${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)")}`}
|
|
@@ -2971,9 +3677,9 @@ ${hasAgentConfig(root, config.port) ? ` ${c.dim("Agents")} connected through
|
|
|
2971
3677
|
const result = await Promise.race([waitFor(config.appUrl, 9e4), exited]);
|
|
2972
3678
|
if (result === true) console.log(` ${c.green("\u2713")} App ready at ${config.appUrl}
|
|
2973
3679
|
`);
|
|
2974
|
-
else if (result === "exited") console.log(` ${c.dim(
|
|
3680
|
+
else if (result === "exited") console.log(` ${c.dim(`${devCommand} stopped (see above). Start your app, the editor connects as soon as it's up.`)}
|
|
2975
3681
|
`);
|
|
2976
|
-
else console.log(` ${c.dim(
|
|
3682
|
+
else console.log(` ${c.dim(`${devCommand} is taking a while. The editor will connect when it's up.`)}
|
|
2977
3683
|
`);
|
|
2978
3684
|
}
|
|
2979
3685
|
if (values.open && !values["no-open"]) openBrowser(origin);
|
|
@@ -2982,15 +3688,15 @@ ${hasAgentConfig(root, config.port) ? ` ${c.dim("Agents")} connected through
|
|
|
2982
3688
|
if (stopping) return;
|
|
2983
3689
|
stopping = true;
|
|
2984
3690
|
const nextGone = next && next.exitCode === null ? new Promise((r) => next.once("exit", r)) : Promise.resolve();
|
|
2985
|
-
next
|
|
3691
|
+
if (next) stopTree(next, "SIGINT");
|
|
2986
3692
|
await Promise.all([shutdown(), Promise.race([nextGone, new Promise((r) => setTimeout(r, 4e3))])]);
|
|
2987
|
-
if (next && next.exitCode === null) next
|
|
3693
|
+
if (next && next.exitCode === null) stopTree(next, "SIGKILL");
|
|
2988
3694
|
process.exit(0);
|
|
2989
3695
|
};
|
|
2990
3696
|
process.on("SIGINT", stop);
|
|
2991
3697
|
process.on("SIGTERM", stop);
|
|
2992
3698
|
next?.on("exit", (code) => {
|
|
2993
|
-
if (!stopping) console.log(` ${c.dim(
|
|
3699
|
+
if (!stopping) console.log(` ${c.dim(`${devCommand} exited (${code ?? "signal"}).`)}`);
|
|
2994
3700
|
});
|
|
2995
3701
|
}
|
|
2996
3702
|
async function openWindow(cli, port) {
|
|
@@ -3015,9 +3721,17 @@ async function openInWindow(root, port) {
|
|
|
3015
3721
|
const post = (route, body) => fetch(`${hub}${route}`, { method: "POST", headers: { "content-type": "application/json", origin: hub }, body: JSON.stringify(body) }).catch(() => null);
|
|
3016
3722
|
await post("/api/hub/add", { path: root });
|
|
3017
3723
|
await post("/api/hub/open", { path: root, focus: "1" });
|
|
3018
|
-
console.log(`${c.accent("\u25C6")} Opened ${c.bold(
|
|
3724
|
+
console.log(`${c.accent("\u25C6")} Opened ${c.bold(path14.basename(root))} in the Truecanvas window ${c.dim(`(${hub})`)}.`);
|
|
3019
3725
|
return true;
|
|
3020
3726
|
}
|
|
3727
|
+
async function prompt(question) {
|
|
3728
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3729
|
+
try {
|
|
3730
|
+
return (await rl.question(` ${question} `)).trim();
|
|
3731
|
+
} finally {
|
|
3732
|
+
rl.close();
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3021
3735
|
async function ask(question) {
|
|
3022
3736
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3023
3737
|
try {
|
|
@@ -3029,9 +3743,9 @@ async function ask(question) {
|
|
|
3029
3743
|
}
|
|
3030
3744
|
async function ensureSetup(root, pkgDir, version, yes, force = false) {
|
|
3031
3745
|
const tc = installedIn(root);
|
|
3032
|
-
const wrapped =
|
|
3746
|
+
const wrapped = configStatus(root).wrapped;
|
|
3033
3747
|
if (tc.declared && tc.installed && wrapped && !force) return true;
|
|
3034
|
-
const name = readPackage(root)?.name ??
|
|
3748
|
+
const name = readPackage(root)?.name ?? path14.basename(root);
|
|
3035
3749
|
if (!yes) {
|
|
3036
3750
|
if (!process.stdin.isTTY) {
|
|
3037
3751
|
console.error(`Truecanvas isn't set up in ${name} yet. Run again with --yes to set it up.`);
|
|
@@ -3040,7 +3754,7 @@ async function ensureSetup(root, pkgDir, version, yes, force = false) {
|
|
|
3040
3754
|
console.log(`
|
|
3041
3755
|
${c.accent("\u25C6")} ${c.bold("Truecanvas")} ${c.dim(`v${version}`)}
|
|
3042
3756
|
`);
|
|
3043
|
-
console.log(` This adds Truecanvas to ${c.bold(name)}: a dev dependency, a
|
|
3757
|
+
console.log(` This adds Truecanvas to ${c.bold(name)}: a dev dependency, a plugin in ${configStatus(root).framework === "vite" ? "vite.config" : "next.config"},`);
|
|
3044
3758
|
console.log(` a ${c.bold("canvas")} script and a canvas with your homepage. Nothing changes in production builds.
|
|
3045
3759
|
`);
|
|
3046
3760
|
if (!await ask("Set it up?")) return false;
|
|
@@ -3098,19 +3812,21 @@ async function waitFor(url, ms) {
|
|
|
3098
3812
|
}
|
|
3099
3813
|
return false;
|
|
3100
3814
|
}
|
|
3101
|
-
function
|
|
3102
|
-
const port = new URL(appUrl).port || "3000";
|
|
3103
|
-
const
|
|
3104
|
-
const
|
|
3105
|
-
const
|
|
3106
|
-
const
|
|
3107
|
-
|
|
3108
|
-
const
|
|
3815
|
+
function startApp(root, appUrl, framework) {
|
|
3816
|
+
const port = new URL(appUrl).port || (framework === "vite" ? "5173" : "3000");
|
|
3817
|
+
const devArgs = framework === "vite" ? ["--port", port, "--strictPort", "--host", "127.0.0.1"] : ["dev", "-p", port, "-H", "127.0.0.1"];
|
|
3818
|
+
const pkg = framework === "vite" ? "vite" : "next";
|
|
3819
|
+
const bin = binScript(root, pkg);
|
|
3820
|
+
const [cmd, args] = bin ? [process.execPath, [bin, ...devArgs]] : [process.platform === "win32" ? "npx.cmd" : "npx", [pkg, ...devArgs]];
|
|
3821
|
+
const label = framework === "vite" ? "vite" : "next";
|
|
3822
|
+
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" });
|
|
3823
|
+
child.on("error", (err) => console.error(` ${c.dim(`${label} \u2502`)} couldn't start ${pkg}: ${err.message}`));
|
|
3824
|
+
const prefix = c.dim(`${label} \u2502 `);
|
|
3109
3825
|
for (const stream of [child.stdout, child.stderr]) {
|
|
3110
3826
|
let buf = "";
|
|
3111
3827
|
stream.on("data", (chunk) => {
|
|
3112
3828
|
buf += chunk.toString();
|
|
3113
|
-
const lines = buf.split(
|
|
3829
|
+
const lines = buf.split(/\r?\n/);
|
|
3114
3830
|
buf = lines.pop();
|
|
3115
3831
|
for (const line of lines) if (line.trim()) process.stdout.write(` ${prefix}${line}
|
|
3116
3832
|
`);
|
|
@@ -3118,6 +3834,22 @@ function startNext(root, appUrl) {
|
|
|
3118
3834
|
}
|
|
3119
3835
|
return child;
|
|
3120
3836
|
}
|
|
3837
|
+
function binScript(root, pkg) {
|
|
3838
|
+
try {
|
|
3839
|
+
const req = createRequire(path14.join(root, "package.json"));
|
|
3840
|
+
const manifest = req.resolve(`${pkg}/package.json`);
|
|
3841
|
+
const { bin } = JSON.parse(fs13.readFileSync(manifest, "utf8"));
|
|
3842
|
+
const rel = typeof bin === "string" ? bin : bin?.[pkg];
|
|
3843
|
+
return rel ? path14.join(path14.dirname(manifest), rel) : null;
|
|
3844
|
+
} catch {
|
|
3845
|
+
return null;
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
function stopTree(child, signal) {
|
|
3849
|
+
if (process.platform === "win32" && child.pid) spawn3("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }).on("error", () => {
|
|
3850
|
+
});
|
|
3851
|
+
else child.kill(signal);
|
|
3852
|
+
}
|
|
3121
3853
|
function openBrowser(url) {
|
|
3122
3854
|
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
3123
3855
|
try {
|