truecanvas 0.3.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 +31 -2
- package/dist/chunk-M7B3W7EP.js +659 -0
- package/dist/{chunk-XWQ5MS24.js → chunk-OVYSWA2F.js} +27 -7
- package/dist/cli.js +899 -213
- package/dist/core/index.d.ts +14 -1
- package/dist/core/index.js +1 -1
- package/dist/editor/assets/{Menu-DXPm0EXo.css → Menu-BJ3OmqzH.css} +1 -1
- package/dist/editor/assets/{hub-UhU8yp5F.js → hub-B5hqZCM2.js} +1 -1
- package/dist/editor/assets/{main-UtSaCfnK.js → main-woTyWmya.js} +5 -5
- package/dist/editor/hub.html +3 -3
- package/dist/editor/index.html +3 -3
- package/dist/share/index.d.ts +36 -0
- package/dist/share/index.js +6 -0
- package/package.json +5 -1
- /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,
|
|
@@ -29,7 +32,7 @@ import {
|
|
|
29
32
|
syncComponentRegistry,
|
|
30
33
|
toolEnv,
|
|
31
34
|
writeCompare
|
|
32
|
-
} from "./chunk-
|
|
35
|
+
} from "./chunk-OVYSWA2F.js";
|
|
33
36
|
import {
|
|
34
37
|
loadConfig
|
|
35
38
|
} from "./chunk-PBKX3SBC.js";
|
|
@@ -44,14 +47,14 @@ import {
|
|
|
44
47
|
|
|
45
48
|
// src/cli/index.ts
|
|
46
49
|
import { spawn as spawn3 } from "child_process";
|
|
47
|
-
import
|
|
48
|
-
import
|
|
50
|
+
import fs13 from "fs";
|
|
51
|
+
import path14 from "path";
|
|
49
52
|
import { parseArgs } from "util";
|
|
50
53
|
|
|
51
54
|
// src/server/http.ts
|
|
52
55
|
import http from "http";
|
|
53
|
-
import
|
|
54
|
-
import
|
|
56
|
+
import fs6 from "fs";
|
|
57
|
+
import path7 from "path";
|
|
55
58
|
import { randomUUID } from "crypto";
|
|
56
59
|
import { fileURLToPath } from "url";
|
|
57
60
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
@@ -87,6 +90,276 @@ function addShadcnComponents(root, names) {
|
|
|
87
90
|
return exclusive(() => shadcnAdd(root, names));
|
|
88
91
|
}
|
|
89
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
|
+
|
|
90
363
|
// src/server/mcp.ts
|
|
91
364
|
var INSTRUCTIONS = `Truecanvas is a design canvas whose layers are the project's real React components.
|
|
92
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.
|
|
@@ -614,8 +887,9 @@ ${source.slice(node.start, node.end)}
|
|
|
614
887
|
else where = `
|
|
615
888
|
on a ${t.node.name} (it may have moved)`;
|
|
616
889
|
}
|
|
617
|
-
const msgs = t.messages.map((m) => ` ${m.author.name}${m.author.kind === "agent" ? " (agent)" : ""}: ${m.text}`).join("\n");
|
|
618
|
-
|
|
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}
|
|
619
893
|
${msgs}`;
|
|
620
894
|
});
|
|
621
895
|
return text(parts.join("\n\n"));
|
|
@@ -655,9 +929,9 @@ ${msgs}`;
|
|
|
655
929
|
try {
|
|
656
930
|
const c2 = resolveCanvas(canvas);
|
|
657
931
|
const a = actor();
|
|
658
|
-
const
|
|
659
|
-
if (note?.trim()) ws.comments.reply(c2, thread, note,
|
|
660
|
-
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);
|
|
661
935
|
ws.commentsChanged(c2);
|
|
662
936
|
presence(c2, "editing", "Resolved a comment", [], t.frame);
|
|
663
937
|
return text(`\u2713 Resolved thread ${t.id}.`);
|
|
@@ -703,6 +977,36 @@ ${msgs}`;
|
|
|
703
977
|
}
|
|
704
978
|
}
|
|
705
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
|
+
);
|
|
706
1010
|
server.registerTool(
|
|
707
1011
|
"screenshot_frame",
|
|
708
1012
|
{
|
|
@@ -873,14 +1177,14 @@ ${out.out.slice(-1500)}`));
|
|
|
873
1177
|
}
|
|
874
1178
|
|
|
875
1179
|
// src/server/thumbnails.ts
|
|
876
|
-
import
|
|
877
|
-
import
|
|
878
|
-
import
|
|
1180
|
+
import crypto2 from "crypto";
|
|
1181
|
+
import fs3 from "fs";
|
|
1182
|
+
import path3 from "path";
|
|
879
1183
|
var Thumbnails = class _Thumbnails {
|
|
880
1184
|
constructor(ws, shots) {
|
|
881
1185
|
this.ws = ws;
|
|
882
1186
|
this.shots = shots;
|
|
883
|
-
this.dir =
|
|
1187
|
+
this.dir = path3.join(ws.config.root, "node_modules", ".cache", "truecanvas", "thumbs");
|
|
884
1188
|
}
|
|
885
1189
|
ws;
|
|
886
1190
|
shots;
|
|
@@ -906,26 +1210,26 @@ var Thumbnails = class _Thumbnails {
|
|
|
906
1210
|
key(spec, props) {
|
|
907
1211
|
let stamp = "";
|
|
908
1212
|
try {
|
|
909
|
-
const file = spec.library ?
|
|
910
|
-
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);
|
|
911
1215
|
} catch {
|
|
912
1216
|
stamp = "0";
|
|
913
1217
|
}
|
|
914
|
-
return
|
|
1218
|
+
return crypto2.createHash("sha1").update(JSON.stringify({ v: 1, name: spec.name, props, stamp })).digest("hex").slice(0, 16);
|
|
915
1219
|
}
|
|
916
1220
|
async get(name, preset) {
|
|
917
1221
|
await this.ws.catalog.load();
|
|
918
1222
|
const spec = this.ws.catalog.get(name);
|
|
919
1223
|
if (!spec) throw new Error(`Unknown component ${name}`);
|
|
920
1224
|
const props = _Thumbnails.previewProps(spec, preset);
|
|
921
|
-
const file =
|
|
922
|
-
if (
|
|
1225
|
+
const file = path3.join(this.dir, `${name}-${this.key(spec, props)}.png`);
|
|
1226
|
+
if (fs3.existsSync(file)) return fs3.readFileSync(file);
|
|
923
1227
|
const job = this.queue.then(async () => {
|
|
924
|
-
if (
|
|
1228
|
+
if (fs3.existsSync(file)) return fs3.readFileSync(file);
|
|
925
1229
|
const url = `${this.ws.config.appUrl}/truecanvas/__preview?component=${encodeURIComponent(name)}&props=${encodeURIComponent(JSON.stringify(props))}`;
|
|
926
1230
|
const png = await this.shots.element(url, { width: 640, height: 480 });
|
|
927
|
-
|
|
928
|
-
|
|
1231
|
+
fs3.mkdirSync(this.dir, { recursive: true });
|
|
1232
|
+
fs3.writeFileSync(file, png);
|
|
929
1233
|
return png;
|
|
930
1234
|
});
|
|
931
1235
|
this.queue = job.catch(() => void 0);
|
|
@@ -934,14 +1238,14 @@ var Thumbnails = class _Thumbnails {
|
|
|
934
1238
|
};
|
|
935
1239
|
|
|
936
1240
|
// src/server/screenshot.ts
|
|
937
|
-
import
|
|
938
|
-
import
|
|
939
|
-
import
|
|
1241
|
+
import fs4 from "fs";
|
|
1242
|
+
import os2 from "os";
|
|
1243
|
+
import path4 from "path";
|
|
940
1244
|
function findChromium() {
|
|
941
1245
|
if (process.env.TRUECANVAS_CHROME) return process.env.TRUECANVAS_CHROME;
|
|
942
|
-
const cache2 = process.env.PLAYWRIGHT_BROWSERS_PATH || (process.platform === "win32" ?
|
|
943
|
-
if (
|
|
944
|
-
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();
|
|
945
1249
|
for (const d of shells) {
|
|
946
1250
|
for (const sub of [
|
|
947
1251
|
"chrome-headless-shell-linux64/chrome-headless-shell",
|
|
@@ -951,14 +1255,14 @@ function findChromium() {
|
|
|
951
1255
|
"chrome-headless-shell-win64/chrome-headless-shell.exe",
|
|
952
1256
|
"chrome-win/headless_shell.exe"
|
|
953
1257
|
]) {
|
|
954
|
-
const p =
|
|
955
|
-
if (
|
|
1258
|
+
const p = path4.join(cache2, d, sub);
|
|
1259
|
+
if (fs4.existsSync(p)) return p;
|
|
956
1260
|
}
|
|
957
1261
|
}
|
|
958
1262
|
}
|
|
959
1263
|
const programFiles = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
|
|
960
1264
|
for (const p of [
|
|
961
|
-
...programFiles.flatMap((dir) => [
|
|
1265
|
+
...programFiles.flatMap((dir) => [path4.join(dir, "Google", "Chrome", "Application", "chrome.exe"), path4.join(dir, "Microsoft", "Edge", "Application", "msedge.exe")]),
|
|
962
1266
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
963
1267
|
"/usr/bin/chromium-browser",
|
|
964
1268
|
"/usr/bin/chromium",
|
|
@@ -967,7 +1271,7 @@ function findChromium() {
|
|
|
967
1271
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
968
1272
|
"/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
969
1273
|
]) {
|
|
970
|
-
if (
|
|
1274
|
+
if (fs4.existsSync(p)) return p;
|
|
971
1275
|
}
|
|
972
1276
|
return void 0;
|
|
973
1277
|
}
|
|
@@ -1040,7 +1344,14 @@ var Screenshotter = class {
|
|
|
1040
1344
|
});
|
|
1041
1345
|
}
|
|
1042
1346
|
async frame(opts) {
|
|
1043
|
-
|
|
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 {
|
|
1044
1355
|
viewport: { width: opts.width, height: opts.height ?? 800 },
|
|
1045
1356
|
deviceScaleFactor: opts.scale ?? 1,
|
|
1046
1357
|
colorScheme: opts.theme === "dark" ? "dark" : "light",
|
|
@@ -1048,28 +1359,135 @@ var Screenshotter = class {
|
|
|
1048
1359
|
isMobile: !!opts.mobile,
|
|
1049
1360
|
hasTouch: !!opts.mobile
|
|
1050
1361
|
};
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
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());
|
|
1061
1390
|
});
|
|
1062
|
-
|
|
1063
|
-
await page.
|
|
1064
|
-
const
|
|
1065
|
-
|
|
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] };
|
|
1066
1403
|
});
|
|
1067
1404
|
}
|
|
1068
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
|
+
}
|
|
1069
1487
|
|
|
1070
1488
|
// src/server/review.ts
|
|
1071
|
-
import
|
|
1072
|
-
import
|
|
1489
|
+
import fs5 from "fs";
|
|
1490
|
+
import path5 from "path";
|
|
1073
1491
|
import { createHash } from "crypto";
|
|
1074
1492
|
var actorName = (e) => e.actor.kind === "agent" ? e.actor.name : e.actor.kind === "file" ? "code editor" : "you";
|
|
1075
1493
|
async function versionAt(ws, canvas, ref) {
|
|
@@ -1086,14 +1504,14 @@ async function versionAt(ws, canvas, ref) {
|
|
|
1086
1504
|
}
|
|
1087
1505
|
var readNow = (ws, file) => {
|
|
1088
1506
|
try {
|
|
1089
|
-
return
|
|
1507
|
+
return fs5.readFileSync(path5.join(ws.config.root, file), "utf8");
|
|
1090
1508
|
} catch {
|
|
1091
1509
|
return null;
|
|
1092
1510
|
}
|
|
1093
1511
|
};
|
|
1094
1512
|
async function reviewPage(ws, canvas, ref, since) {
|
|
1095
1513
|
const rel = ws.relFile(canvas);
|
|
1096
|
-
const exists =
|
|
1514
|
+
const exists = fs5.existsSync(ws.file(canvas));
|
|
1097
1515
|
const old = await versionAt(ws, canvas, ref);
|
|
1098
1516
|
if (!exists && old.source === null) return null;
|
|
1099
1517
|
const now = exists ? ws.read(canvas) : null;
|
|
@@ -1163,7 +1581,7 @@ function renderFrame(ws, shots, canvas, frame, side, ref) {
|
|
|
1163
1581
|
async function render(ws, shots, canvas, frame, side, ref) {
|
|
1164
1582
|
let source;
|
|
1165
1583
|
let files = {};
|
|
1166
|
-
if (side === "after") source =
|
|
1584
|
+
if (side === "after") source = fs5.existsSync(ws.file(canvas)) ? ws.read(canvas) : null;
|
|
1167
1585
|
else {
|
|
1168
1586
|
const old = await versionAt(ws, canvas, ref);
|
|
1169
1587
|
source = old.source;
|
|
@@ -1179,7 +1597,7 @@ async function render(ws, shots, canvas, frame, side, ref) {
|
|
|
1179
1597
|
if (hit) return hit;
|
|
1180
1598
|
let name = canvas;
|
|
1181
1599
|
const snapshot = `${canvas}.${side === "before" ? "base" : "now"}`;
|
|
1182
|
-
if (side === "before" || !
|
|
1600
|
+
if (side === "before" || !fs5.existsSync(ws.file(canvas))) name = writeCompare(ws.config, canvas, source, files, snapshot);
|
|
1183
1601
|
try {
|
|
1184
1602
|
const device = findDevice(f.device);
|
|
1185
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 });
|
|
@@ -1231,6 +1649,150 @@ async function readJson(req, limit = 8 * 1024 * 1024) {
|
|
|
1231
1649
|
}
|
|
1232
1650
|
}
|
|
1233
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
|
+
|
|
1234
1796
|
// src/server/http.ts
|
|
1235
1797
|
var MIME = {
|
|
1236
1798
|
".html": "text/html; charset=utf-8",
|
|
@@ -1239,7 +1801,14 @@ var MIME = {
|
|
|
1239
1801
|
".svg": "image/svg+xml",
|
|
1240
1802
|
".woff2": "font/woff2",
|
|
1241
1803
|
".png": "image/png",
|
|
1242
|
-
".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"
|
|
1243
1812
|
};
|
|
1244
1813
|
async function startServer(ws) {
|
|
1245
1814
|
const { config } = ws;
|
|
@@ -1247,6 +1816,8 @@ async function startServer(ws) {
|
|
|
1247
1816
|
const shots = new Screenshotter(config.appUrl);
|
|
1248
1817
|
let prCache = null;
|
|
1249
1818
|
const thumbs = new Thumbnails(ws, shots);
|
|
1819
|
+
const commentSync = new CommentSync(ws);
|
|
1820
|
+
commentSync.start();
|
|
1250
1821
|
const syncRegistry = () => ws.catalog.load().then((list) => syncComponentRegistry(config, list)).catch(() => {
|
|
1251
1822
|
});
|
|
1252
1823
|
void syncRegistry();
|
|
@@ -1314,7 +1885,7 @@ async function startServer(ws) {
|
|
|
1314
1885
|
appUrl: config.appUrl,
|
|
1315
1886
|
framework: config.framework,
|
|
1316
1887
|
root: config.root,
|
|
1317
|
-
projectName:
|
|
1888
|
+
projectName: path7.basename(config.root),
|
|
1318
1889
|
canvases: ws.canvases(),
|
|
1319
1890
|
darkMode: config.darkMode,
|
|
1320
1891
|
agents: [...ws.agents.values()],
|
|
@@ -1346,11 +1917,11 @@ async function startServer(ws) {
|
|
|
1346
1917
|
await ws.git.fetch();
|
|
1347
1918
|
return json(res, 200, { ...await ws.git.status(), linked: ws.designFiles() });
|
|
1348
1919
|
case "POST /api/git/commit": {
|
|
1349
|
-
const { message, scope } = await readJson(req);
|
|
1920
|
+
const { message: message2, scope } = await readJson(req);
|
|
1350
1921
|
const status = await ws.git.status();
|
|
1351
1922
|
const linked = new Set(ws.designFiles());
|
|
1352
1923
|
const files = status.files.map((f) => f.path).filter((f) => scope === "all" || f.startsWith(`${config.canvasDir}/`) || linked.has(f));
|
|
1353
|
-
const hash = await ws.git.commit(
|
|
1924
|
+
const hash = await ws.git.commit(message2, files);
|
|
1354
1925
|
ws.emit({ type: "git" });
|
|
1355
1926
|
return json(res, 200, { hash });
|
|
1356
1927
|
}
|
|
@@ -1464,7 +2035,7 @@ async function startServer(ws) {
|
|
|
1464
2035
|
for (const file of [f.page, ...layoutFiles(config, f.page)]) {
|
|
1465
2036
|
const old = await ws.git.show(ref, file);
|
|
1466
2037
|
if (old === null) continue;
|
|
1467
|
-
const now =
|
|
2038
|
+
const now = fs6.existsSync(path7.join(config.root, file)) ? fs6.readFileSync(path7.join(config.root, file), "utf8") : null;
|
|
1468
2039
|
if (old !== now) {
|
|
1469
2040
|
files[file] = old;
|
|
1470
2041
|
touched.add(f.frameName);
|
|
@@ -1594,12 +2165,26 @@ async function startServer(ws) {
|
|
|
1594
2165
|
}
|
|
1595
2166
|
case "POST /api/open-file": {
|
|
1596
2167
|
const { file } = await readJson(req);
|
|
1597
|
-
const abs =
|
|
1598
|
-
const rel =
|
|
1599
|
-
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." });
|
|
1600
2171
|
launchEditor(abs, config.editor);
|
|
1601
2172
|
return json(res, 200, { ok: true });
|
|
1602
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
|
+
}
|
|
1603
2188
|
case "POST /api/source": {
|
|
1604
2189
|
const { canvas, id } = await readJson(req);
|
|
1605
2190
|
const hit = ws.nodeSource(canvas, id);
|
|
@@ -1610,23 +2195,32 @@ async function startServer(ws) {
|
|
|
1610
2195
|
return json(res, 404, { error: `No route ${route}` });
|
|
1611
2196
|
}
|
|
1612
2197
|
function serveStatic(res, pathname) {
|
|
1613
|
-
let file =
|
|
1614
|
-
if (!inside(editorDir, file) || !
|
|
1615
|
-
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)) {
|
|
1616
2201
|
res.writeHead(500, { "content-type": "text/plain" });
|
|
1617
2202
|
return res.end("Editor bundle missing. Run `pnpm build` in packages/truecanvas.");
|
|
1618
2203
|
}
|
|
1619
|
-
const ext =
|
|
2204
|
+
const ext = path7.extname(file);
|
|
1620
2205
|
const immutable = pathname.startsWith("/assets/");
|
|
1621
2206
|
res.writeHead(200, { "content-type": MIME[ext] ?? "application/octet-stream", "cache-control": immutable ? "public, max-age=31536000, immutable" : "no-cache" });
|
|
1622
|
-
|
|
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);
|
|
1623
2216
|
}
|
|
1624
2217
|
const server = http.createServer(async (req, res) => {
|
|
1625
2218
|
try {
|
|
1626
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));
|
|
1627
2222
|
const reqOrigin = req.headers.origin;
|
|
1628
2223
|
if (reqOrigin && !allowedOrigins.has(reqOrigin)) return json(res, 403, { error: "Forbidden origin" });
|
|
1629
|
-
const url = new URL(req.url ?? "/", origin);
|
|
1630
2224
|
if (url.pathname === "/mcp") return await handleMcp(req, res);
|
|
1631
2225
|
if (url.pathname.startsWith("/api/")) return await handleApi(req, res, url);
|
|
1632
2226
|
if (req.method === "GET") return serveStatic(res, url.pathname);
|
|
@@ -1638,8 +2232,8 @@ async function startServer(ws) {
|
|
|
1638
2232
|
else res.end();
|
|
1639
2233
|
}
|
|
1640
2234
|
});
|
|
1641
|
-
const canvasDir =
|
|
1642
|
-
|
|
2235
|
+
const canvasDir = path7.join(config.root, config.canvasDir);
|
|
2236
|
+
fs6.mkdirSync(canvasDir, { recursive: true });
|
|
1643
2237
|
const pending = /* @__PURE__ */ new Map();
|
|
1644
2238
|
const onCanvasFile = (filename) => {
|
|
1645
2239
|
if (!filename) return;
|
|
@@ -1664,7 +2258,7 @@ async function startServer(ws) {
|
|
|
1664
2258
|
const watchCanvasDir = () => {
|
|
1665
2259
|
let ino = null;
|
|
1666
2260
|
try {
|
|
1667
|
-
ino =
|
|
2261
|
+
ino = fs6.statSync(canvasDir).ino;
|
|
1668
2262
|
} catch {
|
|
1669
2263
|
ino = null;
|
|
1670
2264
|
}
|
|
@@ -1673,21 +2267,21 @@ async function startServer(ws) {
|
|
|
1673
2267
|
canvasWatcher = null;
|
|
1674
2268
|
watchedIno = ino;
|
|
1675
2269
|
if (ino === null) return;
|
|
1676
|
-
canvasWatcher =
|
|
2270
|
+
canvasWatcher = fs6.watch(canvasDir, (_event, filename) => onCanvasFile(filename));
|
|
1677
2271
|
canvasWatcher.on("error", () => {
|
|
1678
2272
|
canvasWatcher?.close();
|
|
1679
2273
|
canvasWatcher = null;
|
|
1680
2274
|
});
|
|
1681
|
-
for (const f of
|
|
2275
|
+
for (const f of fs6.readdirSync(canvasDir)) onCanvasFile(f);
|
|
1682
2276
|
};
|
|
1683
2277
|
watchCanvasDir();
|
|
1684
2278
|
setInterval(watchCanvasDir, 2e3).unref();
|
|
1685
|
-
const appDirAbs =
|
|
1686
|
-
if (config.framework === "next" &&
|
|
2279
|
+
const appDirAbs = path7.join(config.root, config.appDir);
|
|
2280
|
+
if (config.framework === "next" && fs6.existsSync(appDirAbs)) {
|
|
1687
2281
|
const appPending = /* @__PURE__ */ new Map();
|
|
1688
|
-
const appWatcher =
|
|
2282
|
+
const appWatcher = fs6.watch(appDirAbs, { recursive: true }, (_event, filename) => {
|
|
1689
2283
|
if (!filename || !/(^|[\\/])(page|layout)\.[jt]sx$/.test(filename) || /^truecanvas[\\/]/.test(filename)) return;
|
|
1690
|
-
const rel =
|
|
2284
|
+
const rel = path7.join(config.appDir, filename).split(path7.sep).join("/");
|
|
1691
2285
|
clearTimeout(appPending.get(rel));
|
|
1692
2286
|
appPending.set(
|
|
1693
2287
|
rel,
|
|
@@ -1704,9 +2298,9 @@ async function startServer(ws) {
|
|
|
1704
2298
|
}
|
|
1705
2299
|
let catalogTimer;
|
|
1706
2300
|
for (const dir of componentRoots(config.components)) {
|
|
1707
|
-
const abs =
|
|
1708
|
-
if (!
|
|
1709
|
-
const componentWatcher =
|
|
2301
|
+
const abs = path7.join(config.root, dir);
|
|
2302
|
+
if (!fs6.existsSync(abs)) continue;
|
|
2303
|
+
const componentWatcher = fs6.watch(abs, { recursive: true }, () => {
|
|
1710
2304
|
clearTimeout(catalogTimer);
|
|
1711
2305
|
catalogTimer = setTimeout(() => {
|
|
1712
2306
|
ws.catalog.invalidate();
|
|
@@ -1721,6 +2315,7 @@ async function startServer(ws) {
|
|
|
1721
2315
|
server.listen(config.port, "127.0.0.1", resolve);
|
|
1722
2316
|
});
|
|
1723
2317
|
const shutdown = async () => {
|
|
2318
|
+
commentSync.stop();
|
|
1724
2319
|
await shots.close();
|
|
1725
2320
|
for (const t of transports.values()) await t.close().catch(() => {
|
|
1726
2321
|
});
|
|
@@ -1740,8 +2335,8 @@ function dedent(code, col) {
|
|
|
1740
2335
|
return code.split("\n").map((l, i) => i > 0 && l.startsWith(pad) ? l.slice(col) : l).join("\n");
|
|
1741
2336
|
}
|
|
1742
2337
|
function inside(dir, file) {
|
|
1743
|
-
const rel =
|
|
1744
|
-
return !!rel && !rel.startsWith("..") && !
|
|
2338
|
+
const rel = path7.relative(dir, file);
|
|
2339
|
+
return !!rel && !rel.startsWith("..") && !path7.isAbsolute(rel);
|
|
1745
2340
|
}
|
|
1746
2341
|
|
|
1747
2342
|
// src/server/stdio.ts
|
|
@@ -1784,9 +2379,9 @@ import { createRequire } from "module";
|
|
|
1784
2379
|
|
|
1785
2380
|
// src/hub/server.ts
|
|
1786
2381
|
import http2 from "http";
|
|
1787
|
-
import
|
|
1788
|
-
import
|
|
1789
|
-
import
|
|
2382
|
+
import fs10 from "fs";
|
|
2383
|
+
import os6 from "os";
|
|
2384
|
+
import path11 from "path";
|
|
1790
2385
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1791
2386
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1792
2387
|
import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -1796,42 +2391,42 @@ import { StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from
|
|
|
1796
2391
|
import { CallToolRequestSchema as CallToolRequestSchema2, ListToolsRequestSchema as ListToolsRequestSchema2, isInitializeRequest as isInitializeRequest2 } from "@modelcontextprotocol/sdk/types.js";
|
|
1797
2392
|
|
|
1798
2393
|
// src/hub/projects.ts
|
|
1799
|
-
import
|
|
1800
|
-
import
|
|
1801
|
-
import
|
|
1802
|
-
var CONFIG_DIR =
|
|
1803
|
-
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");
|
|
1804
2399
|
var ProjectStore = class {
|
|
1805
2400
|
list = [];
|
|
1806
2401
|
constructor() {
|
|
1807
2402
|
try {
|
|
1808
|
-
this.list = JSON.parse(
|
|
2403
|
+
this.list = JSON.parse(fs7.readFileSync(FILE, "utf8"));
|
|
1809
2404
|
} catch {
|
|
1810
2405
|
this.list = [];
|
|
1811
2406
|
}
|
|
1812
2407
|
}
|
|
1813
2408
|
save() {
|
|
1814
|
-
|
|
1815
|
-
|
|
2409
|
+
fs7.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
2410
|
+
fs7.writeFileSync(FILE, JSON.stringify(this.list, null, 2));
|
|
1816
2411
|
}
|
|
1817
2412
|
all() {
|
|
1818
2413
|
return [...this.list].sort((a, b) => b.lastOpened - a.lastOpened).map((p) => ({ ...p, ...inspect(p.path) }));
|
|
1819
2414
|
}
|
|
1820
2415
|
touch(dir) {
|
|
1821
|
-
const abs =
|
|
2416
|
+
const abs = path8.resolve(dir);
|
|
1822
2417
|
const existing = this.list.find((p) => p.path === abs);
|
|
1823
2418
|
if (existing) existing.lastOpened = Date.now();
|
|
1824
2419
|
else this.list.push({ path: abs, name: projectName(abs), lastOpened: Date.now() });
|
|
1825
2420
|
this.save();
|
|
1826
2421
|
}
|
|
1827
2422
|
forget(dir) {
|
|
1828
|
-
this.list = this.list.filter((p) => p.path !==
|
|
2423
|
+
this.list = this.list.filter((p) => p.path !== path8.resolve(dir));
|
|
1829
2424
|
this.save();
|
|
1830
2425
|
}
|
|
1831
2426
|
};
|
|
1832
2427
|
function readPkg(dir) {
|
|
1833
2428
|
try {
|
|
1834
|
-
return JSON.parse(
|
|
2429
|
+
return JSON.parse(fs7.readFileSync(path8.join(dir, "package.json"), "utf8"));
|
|
1835
2430
|
} catch {
|
|
1836
2431
|
return null;
|
|
1837
2432
|
}
|
|
@@ -1839,24 +2434,24 @@ function readPkg(dir) {
|
|
|
1839
2434
|
function projectName(dir) {
|
|
1840
2435
|
const pkg = readPkg(dir);
|
|
1841
2436
|
const name = typeof pkg?.name === "string" ? pkg.name : "";
|
|
1842
|
-
return name && !name.startsWith("@") ? name :
|
|
2437
|
+
return name && !name.startsWith("@") ? name : path8.basename(dir);
|
|
1843
2438
|
}
|
|
1844
2439
|
function inspect(dir) {
|
|
1845
2440
|
const pkg = readPkg(dir);
|
|
1846
|
-
if (!pkg) return { next: false, ready: false, canvases: 0, exists:
|
|
2441
|
+
if (!pkg) return { next: false, ready: false, canvases: 0, exists: fs7.existsSync(dir) };
|
|
1847
2442
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1848
2443
|
let canvases = 0;
|
|
1849
2444
|
for (const d of ["canvas", "src/canvas"]) {
|
|
1850
2445
|
try {
|
|
1851
|
-
canvases +=
|
|
2446
|
+
canvases += fs7.readdirSync(path8.join(dir, d)).filter((f) => f.endsWith(".canvas.tsx")).length;
|
|
1852
2447
|
} catch {
|
|
1853
2448
|
}
|
|
1854
2449
|
}
|
|
1855
2450
|
return { next: "next" in deps || "vite" in deps && "react" in deps, ready: "truecanvas" in deps, canvases, exists: true };
|
|
1856
2451
|
}
|
|
1857
2452
|
function discover() {
|
|
1858
|
-
const home =
|
|
1859
|
-
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));
|
|
1860
2455
|
const found = [];
|
|
1861
2456
|
let visited = 0;
|
|
1862
2457
|
const walk = (dir, depth) => {
|
|
@@ -1864,7 +2459,7 @@ function discover() {
|
|
|
1864
2459
|
visited++;
|
|
1865
2460
|
let entries;
|
|
1866
2461
|
try {
|
|
1867
|
-
entries =
|
|
2462
|
+
entries = fs7.readdirSync(dir, { withFileTypes: true });
|
|
1868
2463
|
} catch {
|
|
1869
2464
|
return;
|
|
1870
2465
|
}
|
|
@@ -1874,31 +2469,31 @@ function discover() {
|
|
|
1874
2469
|
}
|
|
1875
2470
|
for (const e of entries) {
|
|
1876
2471
|
if (!e.isDirectory() || e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist" || e.name === "build") continue;
|
|
1877
|
-
walk(
|
|
2472
|
+
walk(path8.join(dir, e.name), depth + 1);
|
|
1878
2473
|
}
|
|
1879
2474
|
};
|
|
1880
2475
|
for (const r of roots) walk(r, 0);
|
|
1881
2476
|
return found;
|
|
1882
2477
|
}
|
|
1883
2478
|
function listDirs(dir) {
|
|
1884
|
-
const abs =
|
|
1885
|
-
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));
|
|
1886
2481
|
return { path: abs, dirs };
|
|
1887
2482
|
}
|
|
1888
2483
|
|
|
1889
2484
|
// src/hub/runner.ts
|
|
1890
2485
|
import { execFile, spawn as spawn2 } from "child_process";
|
|
1891
|
-
import
|
|
2486
|
+
import fs9 from "fs";
|
|
1892
2487
|
import net from "net";
|
|
1893
|
-
import
|
|
1894
|
-
import
|
|
2488
|
+
import os5 from "os";
|
|
2489
|
+
import path10 from "path";
|
|
1895
2490
|
|
|
1896
2491
|
// src/cli/setup.ts
|
|
1897
2492
|
import { spawn } from "child_process";
|
|
1898
2493
|
import { createHash as createHash2 } from "crypto";
|
|
1899
|
-
import
|
|
1900
|
-
import
|
|
1901
|
-
import
|
|
2494
|
+
import fs8 from "fs";
|
|
2495
|
+
import os4 from "os";
|
|
2496
|
+
import path9 from "path";
|
|
1902
2497
|
function addDevArgs(pm, spec) {
|
|
1903
2498
|
return addArgs(pm, [spec], true);
|
|
1904
2499
|
}
|
|
@@ -1912,60 +2507,60 @@ var execInherit = (cmd, args, cwd) => new Promise((resolve, reject) => {
|
|
|
1912
2507
|
p.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`)));
|
|
1913
2508
|
});
|
|
1914
2509
|
function packageDir(cliFile) {
|
|
1915
|
-
return
|
|
2510
|
+
return path9.resolve(path9.dirname(cliFile), "..");
|
|
1916
2511
|
}
|
|
1917
2512
|
function packageVersion(pkgDir) {
|
|
1918
2513
|
try {
|
|
1919
|
-
return JSON.parse(
|
|
2514
|
+
return JSON.parse(fs8.readFileSync(path9.join(pkgDir, "package.json"), "utf8")).version;
|
|
1920
2515
|
} catch {
|
|
1921
2516
|
return "0.0.0";
|
|
1922
2517
|
}
|
|
1923
2518
|
}
|
|
1924
2519
|
function isSourceCheckout(pkgDir) {
|
|
1925
|
-
return !pkgDir.split(
|
|
2520
|
+
return !pkgDir.split(path9.sep).includes("node_modules") && fs8.existsSync(path9.join(pkgDir, "src", "cli"));
|
|
1926
2521
|
}
|
|
1927
2522
|
async function packageSpec(pkgDir, exec) {
|
|
1928
2523
|
if (process.env.TRUECANVAS_PACKAGE) return process.env.TRUECANVAS_PACKAGE;
|
|
1929
2524
|
const version = packageVersion(pkgDir);
|
|
1930
2525
|
if (!isSourceCheckout(pkgDir)) return `truecanvas@${version}`;
|
|
1931
|
-
const cacheDir =
|
|
1932
|
-
|
|
1933
|
-
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`);
|
|
1934
2529
|
await exec("npm", ["pack", "--silent", "--pack-destination", cacheDir], pkgDir);
|
|
1935
|
-
const hash = createHash2("sha256").update(
|
|
1936
|
-
const named =
|
|
1937
|
-
|
|
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);
|
|
1938
2533
|
return named;
|
|
1939
2534
|
}
|
|
1940
2535
|
function installedIn(root) {
|
|
1941
2536
|
let declared = false;
|
|
1942
2537
|
try {
|
|
1943
|
-
const pkg = JSON.parse(
|
|
2538
|
+
const pkg = JSON.parse(fs8.readFileSync(path9.join(root, "package.json"), "utf8"));
|
|
1944
2539
|
declared = "truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1945
2540
|
} catch {
|
|
1946
2541
|
}
|
|
1947
|
-
const file =
|
|
1948
|
-
const installed =
|
|
1949
|
-
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 };
|
|
1950
2545
|
}
|
|
1951
2546
|
function readPackage(root) {
|
|
1952
2547
|
try {
|
|
1953
|
-
return JSON.parse(
|
|
2548
|
+
return JSON.parse(fs8.readFileSync(path9.join(root, "package.json"), "utf8"));
|
|
1954
2549
|
} catch {
|
|
1955
2550
|
return null;
|
|
1956
2551
|
}
|
|
1957
2552
|
}
|
|
1958
2553
|
function depVersion(root, name) {
|
|
1959
|
-
const file =
|
|
1960
|
-
if (
|
|
2554
|
+
const file = path9.join(root, "node_modules", name, "package.json");
|
|
2555
|
+
if (fs8.existsSync(file)) return packageVersion(path9.dirname(file));
|
|
1961
2556
|
const pkg = readPackage(root);
|
|
1962
2557
|
return pkg ? { ...pkg.dependencies, ...pkg.devDependencies }[name] ?? null : null;
|
|
1963
2558
|
}
|
|
1964
2559
|
function mergeJson(file, update) {
|
|
1965
2560
|
let data2 = {};
|
|
1966
|
-
if (
|
|
2561
|
+
if (fs8.existsSync(file)) {
|
|
1967
2562
|
try {
|
|
1968
|
-
data2 = JSON.parse(
|
|
2563
|
+
data2 = JSON.parse(fs8.readFileSync(file, "utf8"));
|
|
1969
2564
|
} catch {
|
|
1970
2565
|
throw new Error(`${file} isn't valid JSON; fix it or remove it, then run connect again.`);
|
|
1971
2566
|
}
|
|
@@ -1973,24 +2568,24 @@ function mergeJson(file, update) {
|
|
|
1973
2568
|
const before = JSON.stringify(data2);
|
|
1974
2569
|
update(data2);
|
|
1975
2570
|
if (JSON.stringify(data2) === before) return "unchanged";
|
|
1976
|
-
|
|
1977
|
-
|
|
2571
|
+
fs8.mkdirSync(path9.dirname(file), { recursive: true });
|
|
2572
|
+
fs8.writeFileSync(file, `${JSON.stringify(data2, null, 2)}
|
|
1978
2573
|
`);
|
|
1979
2574
|
return "written";
|
|
1980
2575
|
}
|
|
1981
2576
|
function connectAgents(root, port, opts = {}) {
|
|
1982
2577
|
const url = `http://localhost:${port}/mcp`;
|
|
1983
2578
|
const out = { written: [], unchanged: [] };
|
|
1984
|
-
const add = (rel, update) => out[mergeJson(
|
|
2579
|
+
const add = (rel, update) => out[mergeJson(path9.join(root, rel), update)].push(rel);
|
|
1985
2580
|
const servers = (d, key) => d[key] ??= {};
|
|
1986
2581
|
add(".mcp.json", (d) => void (servers(d, "mcpServers").truecanvas = { type: "http", url }));
|
|
1987
|
-
if (opts.cursor ||
|
|
1988
|
-
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 }));
|
|
1989
2584
|
return out;
|
|
1990
2585
|
}
|
|
1991
2586
|
function hasAgentConfig(root, port) {
|
|
1992
2587
|
try {
|
|
1993
|
-
const d = JSON.parse(
|
|
2588
|
+
const d = JSON.parse(fs8.readFileSync(path9.join(root, ".mcp.json"), "utf8"));
|
|
1994
2589
|
const url = d?.mcpServers?.truecanvas?.url;
|
|
1995
2590
|
return typeof url === "string" && (port === void 0 || url.includes(`:${port}/`));
|
|
1996
2591
|
} catch {
|
|
@@ -2043,7 +2638,7 @@ var Runner = class {
|
|
|
2043
2638
|
return next;
|
|
2044
2639
|
}
|
|
2045
2640
|
async start(dir) {
|
|
2046
|
-
if (!
|
|
2641
|
+
if (!fs9.existsSync(path10.join(dir, "package.json"))) throw new Error(`${dir} isn't there anymore (or has no package.json).`);
|
|
2047
2642
|
const [editorPort, appPort] = await this.allocatePorts();
|
|
2048
2643
|
const proc = spawn2(process.execPath, [this.cli, "dev", "--no-open", "--port", String(editorPort), "--app", `http://localhost:${appPort}`], {
|
|
2049
2644
|
cwd: dir,
|
|
@@ -2155,7 +2750,7 @@ async function install(job, cmd, args, cwd) {
|
|
|
2155
2750
|
await run2(job, cmd, args, cwd);
|
|
2156
2751
|
} catch (err) {
|
|
2157
2752
|
const ignoredBuilds = job.log.some((l) => l.includes("ERR_PNPM_IGNORED_BUILDS"));
|
|
2158
|
-
const installed =
|
|
2753
|
+
const installed = fs9.existsSync(path10.join(cwd, "node_modules", "truecanvas", "package.json"));
|
|
2159
2754
|
if (!(ignoredBuilds && installed)) throw err;
|
|
2160
2755
|
job.log.push("(pnpm only complained about other packages' build scripts; Truecanvas is installed, continuing)");
|
|
2161
2756
|
}
|
|
@@ -2192,11 +2787,11 @@ function packageManager(dir) {
|
|
|
2192
2787
|
return { cmd: pm, add: addArgs(pm, [], true) };
|
|
2193
2788
|
}
|
|
2194
2789
|
async function preferredPm() {
|
|
2195
|
-
const { ok } = await capture("pnpm", ["--version"],
|
|
2790
|
+
const { ok } = await capture("pnpm", ["--version"], os5.homedir());
|
|
2196
2791
|
return ok ? "pnpm" : "npm";
|
|
2197
2792
|
}
|
|
2198
2793
|
function setupJob(dir, pkgDir, cli) {
|
|
2199
|
-
return startJob(`Set up ${
|
|
2794
|
+
return startJob(`Set up ${path10.basename(dir)}`, async (job) => {
|
|
2200
2795
|
const pm = packageManager(dir);
|
|
2201
2796
|
const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
|
|
2202
2797
|
await install(job, pm.cmd, [...pm.add, source], dir);
|
|
@@ -2206,12 +2801,12 @@ function setupJob(dir, pkgDir, cli) {
|
|
|
2206
2801
|
}
|
|
2207
2802
|
function createJob(name, parent, pkgDir, cli) {
|
|
2208
2803
|
const safe = name.trim().toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2209
|
-
const parentDir =
|
|
2210
|
-
const dir =
|
|
2804
|
+
const parentDir = path10.resolve(parent.replace(/^~(?=$|[\\/])/, os5.homedir()));
|
|
2805
|
+
const dir = path10.join(parentDir, safe);
|
|
2211
2806
|
return startJob(`Create ${safe}`, async (job) => {
|
|
2212
2807
|
if (!safe) throw new Error("Pick a project name.");
|
|
2213
|
-
if (
|
|
2214
|
-
|
|
2808
|
+
if (fs9.existsSync(dir)) throw new Error(`${dir} already exists.`);
|
|
2809
|
+
fs9.mkdirSync(parentDir, { recursive: true });
|
|
2215
2810
|
const pm = await preferredPm();
|
|
2216
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);
|
|
2217
2812
|
const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
|
|
@@ -2232,22 +2827,22 @@ function capture(cmd, args, cwd) {
|
|
|
2232
2827
|
});
|
|
2233
2828
|
}
|
|
2234
2829
|
function cloneJob(repo, parent, pkgDir, cli) {
|
|
2235
|
-
const parentDir =
|
|
2830
|
+
const parentDir = path10.resolve(parent.replace(/^~(?=$|[\\/])/, os5.homedir()));
|
|
2236
2831
|
const name = repo.replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
|
|
2237
|
-
const dir =
|
|
2832
|
+
const dir = path10.join(parentDir, name);
|
|
2238
2833
|
return startJob(`Clone ${name}`, async (job) => {
|
|
2239
2834
|
if (!name || !/^[\w.-]+$/.test(name)) throw new Error("Pick a repository (owner/name or a git URL).");
|
|
2240
|
-
if (
|
|
2241
|
-
|
|
2835
|
+
if (fs9.existsSync(dir)) throw new Error(`${dir} already exists. Open it with Open folder instead.`);
|
|
2836
|
+
fs9.mkdirSync(parentDir, { recursive: true });
|
|
2242
2837
|
const url = /^(https?:|git@|ssh:|file:)/.test(repo);
|
|
2243
2838
|
if (url) await run2(job, "git", ["clone", repo, dir], parentDir);
|
|
2244
2839
|
else await run2(job, "gh", ["repo", "clone", repo, dir], parentDir);
|
|
2245
|
-
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.");
|
|
2246
2841
|
const pm = packageManager(dir);
|
|
2247
2842
|
await install(job, pm.cmd, ["install"], dir).catch(async (err) => {
|
|
2248
|
-
if (!
|
|
2843
|
+
if (!fs9.existsSync(path10.join(dir, "node_modules"))) throw err;
|
|
2249
2844
|
});
|
|
2250
|
-
const pkg = JSON.parse(
|
|
2845
|
+
const pkg = JSON.parse(fs9.readFileSync(path10.join(dir, "package.json"), "utf8"));
|
|
2251
2846
|
if (!("truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies })) {
|
|
2252
2847
|
const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
|
|
2253
2848
|
await install(job, pm.cmd, [...pm.add, source], dir);
|
|
@@ -2272,7 +2867,7 @@ Use list_projects to see them. All other tools are the regular Truecanvas canvas
|
|
|
2272
2867
|
async function startHub(opts) {
|
|
2273
2868
|
const store = new ProjectStore();
|
|
2274
2869
|
const editorDir = fileURLToPath2(new URL("./editor/", import.meta.url));
|
|
2275
|
-
const pkgDir =
|
|
2870
|
+
const pkgDir = path11.resolve(path11.dirname(opts.cli), "..");
|
|
2276
2871
|
const origin = `http://localhost:${opts.port}`;
|
|
2277
2872
|
const allowedHosts = /* @__PURE__ */ new Set([`localhost:${opts.port}`, `127.0.0.1:${opts.port}`]);
|
|
2278
2873
|
const clients = /* @__PURE__ */ new Set();
|
|
@@ -2377,7 +2972,7 @@ async function startHub(opts) {
|
|
|
2377
2972
|
}
|
|
2378
2973
|
if (req.params.name === "open_project") {
|
|
2379
2974
|
const q = String(req.params.arguments?.project ?? "").toLowerCase();
|
|
2380
|
-
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);
|
|
2381
2976
|
if (!p) return text(`No project "${q}". Use list_projects.`, true);
|
|
2382
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);
|
|
2383
2978
|
store.touch(p.path);
|
|
@@ -2432,8 +3027,8 @@ async function startHub(opts) {
|
|
|
2432
3027
|
case "GET /api/hub/icon": {
|
|
2433
3028
|
const icon = findIcon(url.searchParams.get("path") ?? "");
|
|
2434
3029
|
if (!icon) return json2(res, 404, { error: "No icon" });
|
|
2435
|
-
res.writeHead(200, { "content-type": ICON_MIME[
|
|
2436
|
-
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);
|
|
2437
3032
|
}
|
|
2438
3033
|
case "GET /api/hub/ls":
|
|
2439
3034
|
return json2(res, 200, listDirs(url.searchParams.get("path") || "~"));
|
|
@@ -2444,8 +3039,8 @@ async function startHub(opts) {
|
|
|
2444
3039
|
req.on("close", () => clients.delete(res));
|
|
2445
3040
|
return;
|
|
2446
3041
|
case "POST /api/hub/add": {
|
|
2447
|
-
const dir =
|
|
2448
|
-
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." });
|
|
2449
3044
|
store.touch(dir);
|
|
2450
3045
|
broadcast();
|
|
2451
3046
|
return json2(res, 200, { path: dir, ready: store.all().find((p) => p.path === dir)?.ready ?? false });
|
|
@@ -2484,7 +3079,7 @@ async function startHub(opts) {
|
|
|
2484
3079
|
return json2(res, 200, createJob(body.name, body.parent, pkgDir, opts.cli));
|
|
2485
3080
|
// ---------- GitHub ----------
|
|
2486
3081
|
case "GET /api/hub/github/repos": {
|
|
2487
|
-
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());
|
|
2488
3083
|
if (!r.ok) return json2(res, 200, { gh: false, error: r.out.split("\n")[0], repos: [] });
|
|
2489
3084
|
const repos = JSON.parse(r.out).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
2490
3085
|
return json2(res, 200, { gh: true, repos });
|
|
@@ -2529,14 +3124,14 @@ async function startHub(opts) {
|
|
|
2529
3124
|
return json2(res, 404, { error: `No route ${route}` });
|
|
2530
3125
|
}
|
|
2531
3126
|
function serveStatic(res, pathname) {
|
|
2532
|
-
let file =
|
|
2533
|
-
if (!inside2(editorDir, file) || !
|
|
2534
|
-
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)) {
|
|
2535
3130
|
res.writeHead(500, { "content-type": "text/plain" });
|
|
2536
3131
|
return res.end("Hub bundle missing. Run `pnpm build`.");
|
|
2537
3132
|
}
|
|
2538
|
-
res.writeHead(200, { "content-type": MIME2[
|
|
2539
|
-
|
|
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);
|
|
2540
3135
|
}
|
|
2541
3136
|
const server = http2.createServer(async (req, res) => {
|
|
2542
3137
|
try {
|
|
@@ -2568,27 +3163,27 @@ async function startHub(opts) {
|
|
|
2568
3163
|
}
|
|
2569
3164
|
var ICON_MIME = { ".ico": "image/x-icon", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
|
|
2570
3165
|
function findIcon(dir) {
|
|
2571
|
-
if (!dir || !
|
|
3166
|
+
if (!dir || !fs10.existsSync(path11.join(dir, "package.json"))) return null;
|
|
2572
3167
|
const names = ["icon.svg", "icon.png", "favicon.svg", "favicon.ico", "icon.ico", "apple-icon.png", "favicon.png", "logo.svg", "icon.jpg"];
|
|
2573
3168
|
const roots = ["app", "src/app", "public", "static", "assets"];
|
|
2574
3169
|
for (const root of roots) {
|
|
2575
3170
|
for (const name of names) {
|
|
2576
|
-
const file =
|
|
2577
|
-
if (
|
|
3171
|
+
const file = path11.join(dir, root, name);
|
|
3172
|
+
if (fs10.existsSync(file) && fs10.statSync(file).size < 2e6) return file;
|
|
2578
3173
|
}
|
|
2579
3174
|
}
|
|
2580
3175
|
for (const root of ["app", "src/app"]) {
|
|
2581
3176
|
let entries = [];
|
|
2582
3177
|
try {
|
|
2583
|
-
entries =
|
|
3178
|
+
entries = fs10.readdirSync(path11.join(dir, root), { withFileTypes: true });
|
|
2584
3179
|
} catch {
|
|
2585
3180
|
continue;
|
|
2586
3181
|
}
|
|
2587
3182
|
for (const e of entries) {
|
|
2588
3183
|
if (!e.isDirectory()) continue;
|
|
2589
3184
|
for (const name of names.slice(0, 4)) {
|
|
2590
|
-
const file =
|
|
2591
|
-
if (
|
|
3185
|
+
const file = path11.join(dir, root, e.name, name);
|
|
3186
|
+
if (fs10.existsSync(file)) return file;
|
|
2592
3187
|
}
|
|
2593
3188
|
}
|
|
2594
3189
|
}
|
|
@@ -2599,14 +3194,14 @@ function json2(res, status, body) {
|
|
|
2599
3194
|
res.end(JSON.stringify(body));
|
|
2600
3195
|
}
|
|
2601
3196
|
function inside2(dir, file) {
|
|
2602
|
-
const rel =
|
|
2603
|
-
return !!rel && !rel.startsWith("..") && !
|
|
3197
|
+
const rel = path11.relative(dir, file);
|
|
3198
|
+
return !!rel && !rel.startsWith("..") && !path11.isAbsolute(rel);
|
|
2604
3199
|
}
|
|
2605
3200
|
|
|
2606
3201
|
// src/hub/desktop.ts
|
|
2607
|
-
import
|
|
2608
|
-
import
|
|
2609
|
-
import
|
|
3202
|
+
import fs11 from "fs";
|
|
3203
|
+
import os7 from "os";
|
|
3204
|
+
import path12 from "path";
|
|
2610
3205
|
var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
|
2611
3206
|
<defs>
|
|
2612
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>
|
|
@@ -2620,15 +3215,15 @@ var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
|
|
2620
3215
|
<path fill="#f6f5f1" fill-rule="evenodd" d="M236 236h552v168H236z M428 316h168v472H428z"/>
|
|
2621
3216
|
</svg>
|
|
2622
3217
|
`;
|
|
2623
|
-
var data = () => process.env.XDG_DATA_HOME ??
|
|
3218
|
+
var data = () => process.env.XDG_DATA_HOME ?? path12.join(os7.homedir(), ".local", "share");
|
|
2624
3219
|
function installDesktop(cli, port) {
|
|
2625
|
-
const dir =
|
|
2626
|
-
|
|
2627
|
-
const icon =
|
|
2628
|
-
|
|
2629
|
-
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");
|
|
2630
3225
|
const url = `http://localhost:${port}`;
|
|
2631
|
-
|
|
3226
|
+
fs11.writeFileSync(
|
|
2632
3227
|
launcher,
|
|
2633
3228
|
`#!/bin/sh
|
|
2634
3229
|
# Truecanvas launcher: start the hub if needed, then open its window.
|
|
@@ -2637,7 +3232,7 @@ CLI=${JSON.stringify(cli)}
|
|
|
2637
3232
|
URL=${JSON.stringify(url)}
|
|
2638
3233
|
LOG="\${XDG_STATE_HOME:-$HOME/.local/state}/truecanvas-hub.log"
|
|
2639
3234
|
# apps started from the desktop don't get your shell's PATH: next, pnpm and npx need it
|
|
2640
|
-
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))}
|
|
2641
3236
|
mkdir -p "$(dirname "$LOG")"
|
|
2642
3237
|
|
|
2643
3238
|
if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
|
|
@@ -2646,7 +3241,7 @@ if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
|
|
|
2646
3241
|
while [ $i -lt 60 ] && ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; do sleep 0.25; i=$((i+1)); done
|
|
2647
3242
|
fi
|
|
2648
3243
|
|
|
2649
|
-
PROFILE=${JSON.stringify(
|
|
3244
|
+
PROFILE=${JSON.stringify(path12.join(dir, "window"))}
|
|
2650
3245
|
for b in google-chrome google-chrome-stable chromium-browser chromium brave-browser microsoft-edge; do
|
|
2651
3246
|
if command -v "$b" >/dev/null 2>&1; then
|
|
2652
3247
|
exec "$b" --app="$URL" --class=Truecanvas --user-data-dir="$PROFILE" --no-first-run --no-default-browser-check
|
|
@@ -2656,10 +3251,10 @@ exec xdg-open "$URL"
|
|
|
2656
3251
|
`,
|
|
2657
3252
|
{ mode: 493 }
|
|
2658
3253
|
);
|
|
2659
|
-
const apps =
|
|
2660
|
-
|
|
2661
|
-
const desktop =
|
|
2662
|
-
|
|
3254
|
+
const apps = path12.join(data(), "applications");
|
|
3255
|
+
fs11.mkdirSync(apps, { recursive: true });
|
|
3256
|
+
const desktop = path12.join(apps, "truecanvas.desktop");
|
|
3257
|
+
fs11.writeFileSync(
|
|
2663
3258
|
desktop,
|
|
2664
3259
|
`[Desktop Entry]
|
|
2665
3260
|
Type=Application
|
|
@@ -2675,11 +3270,11 @@ StartupWMClass=Truecanvas
|
|
|
2675
3270
|
StartupNotify=true
|
|
2676
3271
|
`
|
|
2677
3272
|
);
|
|
2678
|
-
let command =
|
|
2679
|
-
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");
|
|
2680
3275
|
if (ours) {
|
|
2681
|
-
|
|
2682
|
-
|
|
3276
|
+
fs11.mkdirSync(path12.dirname(command), { recursive: true });
|
|
3277
|
+
fs11.writeFileSync(command, `#!/bin/sh
|
|
2683
3278
|
# truecanvas wrapper (installed by \`truecanvas desktop\`)
|
|
2684
3279
|
exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
|
|
2685
3280
|
`, { mode: 493 });
|
|
@@ -2687,19 +3282,19 @@ exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
|
|
|
2687
3282
|
return { desktop, launcher, icon, command };
|
|
2688
3283
|
}
|
|
2689
3284
|
function launcherPath() {
|
|
2690
|
-
const p =
|
|
2691
|
-
return
|
|
3285
|
+
const p = path12.join(data(), "truecanvas", "launch.sh");
|
|
3286
|
+
return fs11.existsSync(p) ? p : null;
|
|
2692
3287
|
}
|
|
2693
3288
|
function uninstallDesktop() {
|
|
2694
3289
|
const removed = [];
|
|
2695
|
-
const cmd =
|
|
2696
|
-
if (
|
|
2697
|
-
|
|
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);
|
|
2698
3293
|
removed.push(cmd);
|
|
2699
3294
|
}
|
|
2700
|
-
for (const f of [
|
|
2701
|
-
if (
|
|
2702
|
-
|
|
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 });
|
|
2703
3298
|
removed.push(f);
|
|
2704
3299
|
}
|
|
2705
3300
|
}
|
|
@@ -2712,8 +3307,8 @@ import { createInterface } from "readline/promises";
|
|
|
2712
3307
|
|
|
2713
3308
|
// src/cli/doctor.ts
|
|
2714
3309
|
import { execFile as execFile2 } from "child_process";
|
|
2715
|
-
import
|
|
2716
|
-
import
|
|
3310
|
+
import fs12 from "fs";
|
|
3311
|
+
import path13 from "path";
|
|
2717
3312
|
var major = (v) => v ? Number(/(\d+)(?:\.(\d+))?/.exec(v)?.[1] ?? 0) : 0;
|
|
2718
3313
|
function has(cmd, args, cwd) {
|
|
2719
3314
|
return new Promise((resolve) => execFile2(cmd, args, { cwd, timeout: 8e3 }, (err, stdout, stderr) => resolve(err ? null : `${stdout}${stderr}`)));
|
|
@@ -2740,7 +3335,7 @@ async function doctor(root, cliVersion) {
|
|
|
2740
3335
|
const tw = depVersion(root, "tailwindcss");
|
|
2741
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" });
|
|
2742
3337
|
const config = loadConfig(root);
|
|
2743
|
-
if (config.framework === "next") 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/)" });
|
|
2744
3339
|
const tc = installedIn(root);
|
|
2745
3340
|
if (!tc.declared) add({ label: "Truecanvas in the project", status: "fail", detail: "not a dependency", fix: "npx truecanvas (sets it up)" });
|
|
2746
3341
|
else if (!tc.installed) add({ label: "Truecanvas in the project", status: "fail", detail: "declared but not installed", fix: `${pm} install` });
|
|
@@ -2749,15 +3344,15 @@ async function doctor(root, cliVersion) {
|
|
|
2749
3344
|
const wrapped = configStatus(root, config.framework);
|
|
2750
3345
|
const configLabel = config.framework === "vite" ? "vite.config" : "next.config";
|
|
2751
3346
|
const uses = config.framework === "vite" ? "has the truecanvas() plugin" : "uses withTruecanvas()";
|
|
2752
|
-
add(wrapped.wrapped ? { label: configLabel, status: "ok", detail: `${
|
|
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" });
|
|
2753
3348
|
const scripts = pkg.scripts ?? {};
|
|
2754
3349
|
const script = Object.entries(scripts).find(([, v]) => v.includes("truecanvas"));
|
|
2755
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")' });
|
|
2756
|
-
const ignore =
|
|
3351
|
+
const ignore = fs12.existsSync(path13.join(root, ".gitignore")) ? fs12.readFileSync(path13.join(root, ".gitignore"), "utf8") : "";
|
|
2757
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" });
|
|
2758
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)" });
|
|
2759
3354
|
const chrome = findChromium();
|
|
2760
|
-
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)" });
|
|
2761
3356
|
const git = await has("git", ["rev-parse", "--is-inside-work-tree"], root);
|
|
2762
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)" });
|
|
2763
3358
|
const gh = await has("gh", ["auth", "status"], root);
|
|
@@ -2782,6 +3377,9 @@ Usage
|
|
|
2782
3377
|
truecanvas open Open the Truecanvas window (projects dashboard + tabs)
|
|
2783
3378
|
truecanvas quit Quit the window and every app it started
|
|
2784
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)
|
|
2785
3383
|
truecanvas mcp stdio MCP server for agents without HTTP support
|
|
2786
3384
|
|
|
2787
3385
|
Options
|
|
@@ -2817,7 +3415,15 @@ async function main() {
|
|
|
2817
3415
|
yes: { type: "boolean", short: "y" },
|
|
2818
3416
|
cursor: { type: "boolean" },
|
|
2819
3417
|
vscode: { type: "boolean" },
|
|
2820
|
-
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" }
|
|
2821
3427
|
}
|
|
2822
3428
|
});
|
|
2823
3429
|
const cli = fileURLToPath3(import.meta.url);
|
|
@@ -2838,8 +3444,8 @@ async function main() {
|
|
|
2838
3444
|
}
|
|
2839
3445
|
if (!await ensureSetup(root2, pkgDir, version, yes)) return;
|
|
2840
3446
|
if (await openInWindow(root2, Number(values.port ?? 4800))) return;
|
|
2841
|
-
const local =
|
|
2842
|
-
if (
|
|
3447
|
+
const local = path14.join(root2, "node_modules", "truecanvas", "dist", "cli.js");
|
|
3448
|
+
if (fs13.existsSync(local) && fs13.realpathSync(local) !== fs13.realpathSync(cli)) {
|
|
2843
3449
|
const child = spawn3(process.execPath, [local, "dev", ...process.argv.slice(2).filter((a) => a !== "start" && a !== "-y" && a !== "--yes")], { stdio: "inherit" });
|
|
2844
3450
|
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => child.kill(sig));
|
|
2845
3451
|
child.on("exit", (code) => process.exit(code ?? 0));
|
|
@@ -2921,7 +3527,7 @@ async function main() {
|
|
|
2921
3527
|
return;
|
|
2922
3528
|
}
|
|
2923
3529
|
const root = process.cwd();
|
|
2924
|
-
if (!
|
|
3530
|
+
if (!fs13.existsSync(path14.join(root, "package.json"))) {
|
|
2925
3531
|
console.error("Run truecanvas from your app's root (no package.json here).");
|
|
2926
3532
|
process.exit(1);
|
|
2927
3533
|
}
|
|
@@ -2931,7 +3537,7 @@ async function main() {
|
|
|
2931
3537
|
const report = initProject(config);
|
|
2932
3538
|
ws.ensureRoute();
|
|
2933
3539
|
console.log(`
|
|
2934
|
-
${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))}
|
|
2935
3541
|
`);
|
|
2936
3542
|
for (const line of report.done) console.log(` ${c.green("\u2713")} ${line}`);
|
|
2937
3543
|
console.log(` ${c.green("\u2713")} Canvas folder ${c.bold(config.canvasDir)} and dev route ${c.bold(config.routeDir)}`);
|
|
@@ -2941,6 +3547,78 @@ async function main() {
|
|
|
2941
3547
|
`);
|
|
2942
3548
|
return;
|
|
2943
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
|
+
}
|
|
2944
3622
|
if (command === "mcp") {
|
|
2945
3623
|
console.log = console.error;
|
|
2946
3624
|
const mcpUrl = `http://localhost:${config.port}/mcp`;
|
|
@@ -3043,9 +3721,17 @@ async function openInWindow(root, port) {
|
|
|
3043
3721
|
const post = (route, body) => fetch(`${hub}${route}`, { method: "POST", headers: { "content-type": "application/json", origin: hub }, body: JSON.stringify(body) }).catch(() => null);
|
|
3044
3722
|
await post("/api/hub/add", { path: root });
|
|
3045
3723
|
await post("/api/hub/open", { path: root, focus: "1" });
|
|
3046
|
-
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})`)}.`);
|
|
3047
3725
|
return true;
|
|
3048
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
|
+
}
|
|
3049
3735
|
async function ask(question) {
|
|
3050
3736
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3051
3737
|
try {
|
|
@@ -3059,7 +3745,7 @@ async function ensureSetup(root, pkgDir, version, yes, force = false) {
|
|
|
3059
3745
|
const tc = installedIn(root);
|
|
3060
3746
|
const wrapped = configStatus(root).wrapped;
|
|
3061
3747
|
if (tc.declared && tc.installed && wrapped && !force) return true;
|
|
3062
|
-
const name = readPackage(root)?.name ??
|
|
3748
|
+
const name = readPackage(root)?.name ?? path14.basename(root);
|
|
3063
3749
|
if (!yes) {
|
|
3064
3750
|
if (!process.stdin.isTTY) {
|
|
3065
3751
|
console.error(`Truecanvas isn't set up in ${name} yet. Run again with --yes to set it up.`);
|
|
@@ -3150,11 +3836,11 @@ function startApp(root, appUrl, framework) {
|
|
|
3150
3836
|
}
|
|
3151
3837
|
function binScript(root, pkg) {
|
|
3152
3838
|
try {
|
|
3153
|
-
const req = createRequire(
|
|
3839
|
+
const req = createRequire(path14.join(root, "package.json"));
|
|
3154
3840
|
const manifest = req.resolve(`${pkg}/package.json`);
|
|
3155
|
-
const { bin } = JSON.parse(
|
|
3841
|
+
const { bin } = JSON.parse(fs13.readFileSync(manifest, "utf8"));
|
|
3156
3842
|
const rel = typeof bin === "string" ? bin : bin?.[pkg];
|
|
3157
|
-
return rel ?
|
|
3843
|
+
return rel ? path14.join(path14.dirname(manifest), rel) : null;
|
|
3158
3844
|
} catch {
|
|
3159
3845
|
return null;
|
|
3160
3846
|
}
|