truecanvas 0.4.1 → 0.6.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 +13 -4
- package/dist/chunk-D565QDII.js +814 -0
- package/dist/chunk-LKFOLM3L.js +54 -0
- package/dist/chunk-NAK4DQWO.js +1005 -0
- package/dist/{chunk-M7B3W7EP.js → chunk-OPKROZI2.js} +333 -7
- package/dist/chunk-OZOV4Y6C.js +45 -0
- package/dist/{chunk-A4QIAVTG.js → chunk-QJ5M3G4E.js} +88 -852
- package/dist/{chunk-TSHD2S36.js → chunk-VWKLYIY6.js} +1 -1
- package/dist/cli.js +798 -962
- package/dist/core/index.js +14 -11
- package/dist/editor/assets/{Menu-BJ3OmqzH.css → Menu-CACFFH7S.css} +1 -1
- package/dist/editor/assets/hub-B_TP_af8.js +2 -0
- package/dist/editor/assets/main-Dj60SnCH.js +26 -0
- package/dist/editor/hub.html +3 -3
- package/dist/editor/index.html +3 -3
- package/dist/hub.d.ts +88 -0
- package/dist/hub.js +15 -0
- package/dist/share/index.d.ts +77 -2
- package/dist/share/index.js +13 -1
- package/dist/vite/index.js +3 -2
- package/package.json +5 -1
- package/dist/chunk-PBKX3SBC.js +0 -97
- package/dist/editor/assets/hub-B5hqZCM2.js +0 -2
- package/dist/editor/assets/main-woTyWmya.js +0 -26
- /package/dist/editor/assets/{Menu-C0Ti-6T2.js → Menu-CZr87VA3.js} +0 -0
package/dist/cli.js
CHANGED
|
@@ -1,41 +1,64 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
addDevArgs,
|
|
4
|
+
connectAgents,
|
|
5
|
+
depVersion,
|
|
6
|
+
execInherit,
|
|
7
|
+
hasAgentConfig,
|
|
8
|
+
hubPort,
|
|
9
|
+
installedIn,
|
|
10
|
+
packageDir,
|
|
11
|
+
packageSpec,
|
|
12
|
+
packageVersion,
|
|
13
|
+
readJson,
|
|
14
|
+
readPackage,
|
|
15
|
+
runScript,
|
|
16
|
+
startHub
|
|
17
|
+
} from "./chunk-NAK4DQWO.js";
|
|
18
|
+
import {
|
|
19
|
+
CHUNK,
|
|
20
|
+
decodeFrame,
|
|
21
|
+
encodeFrame,
|
|
22
|
+
responseHeaders,
|
|
23
|
+
safePath,
|
|
3
24
|
viewerHtml
|
|
4
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-OPKROZI2.js";
|
|
5
26
|
import {
|
|
6
27
|
DEVICES,
|
|
7
|
-
EditError,
|
|
8
28
|
Workspace,
|
|
9
|
-
addArgs,
|
|
10
29
|
assertCanvasName,
|
|
11
30
|
clearCompare,
|
|
12
31
|
componentsDir,
|
|
13
32
|
configStatus,
|
|
14
33
|
describeComponent,
|
|
15
|
-
detectPm,
|
|
16
|
-
findByPath,
|
|
17
34
|
findDevice,
|
|
18
|
-
findNode,
|
|
19
35
|
frameChanges,
|
|
20
36
|
initProject,
|
|
21
37
|
layoutFiles,
|
|
22
38
|
listRoutes,
|
|
23
39
|
outlineDoc,
|
|
24
|
-
parseCanvas,
|
|
25
40
|
readDesignTokens,
|
|
26
41
|
readTokenData,
|
|
27
|
-
run,
|
|
28
42
|
shadcnAdd,
|
|
29
43
|
shadcnRegistry,
|
|
30
44
|
shadcnStatus,
|
|
31
|
-
shellSafe,
|
|
32
45
|
syncComponentRegistry,
|
|
33
|
-
toolEnv,
|
|
34
46
|
writeCompare
|
|
35
|
-
} from "./chunk-
|
|
47
|
+
} from "./chunk-QJ5M3G4E.js";
|
|
48
|
+
import {
|
|
49
|
+
EditError,
|
|
50
|
+
addArgs,
|
|
51
|
+
detectPm,
|
|
52
|
+
findByPath,
|
|
53
|
+
findNode,
|
|
54
|
+
parseCanvas,
|
|
55
|
+
run,
|
|
56
|
+
toolEnv
|
|
57
|
+
} from "./chunk-D565QDII.js";
|
|
36
58
|
import {
|
|
37
59
|
loadConfig
|
|
38
|
-
} from "./chunk-
|
|
60
|
+
} from "./chunk-OZOV4Y6C.js";
|
|
61
|
+
import "./chunk-LKFOLM3L.js";
|
|
39
62
|
import "./chunk-2IIAMOFH.js";
|
|
40
63
|
import {
|
|
41
64
|
ICON_LIBRARIES,
|
|
@@ -46,15 +69,15 @@ import {
|
|
|
46
69
|
} from "./chunk-C4SATRZT.js";
|
|
47
70
|
|
|
48
71
|
// src/cli/index.ts
|
|
49
|
-
import { spawn
|
|
50
|
-
import
|
|
51
|
-
import
|
|
72
|
+
import { spawn } from "child_process";
|
|
73
|
+
import fs9 from "fs";
|
|
74
|
+
import path11 from "path";
|
|
52
75
|
import { parseArgs } from "util";
|
|
53
76
|
|
|
54
77
|
// src/server/http.ts
|
|
55
78
|
import http from "http";
|
|
56
79
|
import fs6 from "fs";
|
|
57
|
-
import
|
|
80
|
+
import path8 from "path";
|
|
58
81
|
import { randomUUID } from "crypto";
|
|
59
82
|
import { fileURLToPath } from "url";
|
|
60
83
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
@@ -62,6 +85,7 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
|
62
85
|
import launchEditor from "launch-editor";
|
|
63
86
|
|
|
64
87
|
// src/server/mcp.ts
|
|
88
|
+
import path3 from "path";
|
|
65
89
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
66
90
|
import { z } from "zod";
|
|
67
91
|
|
|
@@ -281,9 +305,33 @@ async function connectReviewSite(url, token) {
|
|
|
281
305
|
fs2.writeFileSync(file, JSON.stringify(site, null, 2), { mode: 384 });
|
|
282
306
|
return res;
|
|
283
307
|
}
|
|
308
|
+
var ACCESS_MODES = ["invited", "password", "public"];
|
|
309
|
+
async function siteFeatures(site) {
|
|
310
|
+
const res = await request(site, "GET", "/api/shares");
|
|
311
|
+
return res.features ?? [];
|
|
312
|
+
}
|
|
313
|
+
var NO_SIGN_IN = "This review site doesn't have sign-in yet. Update it (packages/review-worker) and set BETTER_AUTH_SECRET, STUDIO_EMAILS and STUDIO_EMAIL_FROM to invite people by email.";
|
|
284
314
|
var MAX_FILE = 4.4 * 1024 * 1024;
|
|
285
315
|
async function publishSnapshot(site, dir, manifest, opts = {}) {
|
|
286
|
-
const
|
|
316
|
+
const can = await siteFeatures(site);
|
|
317
|
+
const signIn = can.includes("access") && can.includes("invites");
|
|
318
|
+
if ((opts.access || opts.invite?.length) && !signIn) throw new Error(NO_SIGN_IN);
|
|
319
|
+
if (opts.access === "password" && !opts.password) throw new Error("Pass the password to protect the link with: --password <p>");
|
|
320
|
+
if (opts.live) {
|
|
321
|
+
const needed = "url" in opts.live ? "live-url" : "live-files";
|
|
322
|
+
if (!can.includes(needed))
|
|
323
|
+
throw new Error(
|
|
324
|
+
"url" in opts.live ? "This review site doesn't support live sites yet. Update it (packages/review-worker) to share a live URL." : "This review site doesn't host live sites. Deploy packages/review-worker with LIVE_HOST_SUFFIX set, or pass the URL where the site runs instead."
|
|
325
|
+
);
|
|
326
|
+
if ("dir" in opts.live && !fs2.statSync(opts.live.dir, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`No build folder at ${opts.live.dir}`);
|
|
327
|
+
}
|
|
328
|
+
const firstAccess = signIn ? opts.access ?? (opts.password ? "password" : "invited") : void 0;
|
|
329
|
+
const share = await request(site, "POST", "/api/shares", {
|
|
330
|
+
project: manifest.project,
|
|
331
|
+
canvas: manifest.canvas,
|
|
332
|
+
title: opts.title ?? manifest.canvas,
|
|
333
|
+
...firstAccess ? { access: firstAccess === "invited" ? "invited" : "public" } : {}
|
|
334
|
+
});
|
|
287
335
|
const base = `/api/shares/${share.slug}`;
|
|
288
336
|
const known = new Set((await request(site, "GET", `${base}/assets`)).assets);
|
|
289
337
|
const files = [];
|
|
@@ -305,10 +353,64 @@ async function publishSnapshot(site, dir, manifest, opts = {}) {
|
|
|
305
353
|
};
|
|
306
354
|
await Promise.all(Array.from({ length: 4 }, worker));
|
|
307
355
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
356
|
+
if (opts.live && "dir" in opts.live) {
|
|
357
|
+
const liveDir = opts.live.dir;
|
|
358
|
+
const liveFiles = walk(liveDir);
|
|
359
|
+
const pending = [...liveFiles];
|
|
360
|
+
const liveWorker = async () => {
|
|
361
|
+
for (let rel = pending.shift(); rel; rel = pending.shift()) {
|
|
362
|
+
const body = fs2.readFileSync(path2.join(liveDir, rel));
|
|
363
|
+
if (body.length > MAX_FILE) {
|
|
364
|
+
skipped.push(`live/${rel}`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
await request(site, "PUT", `${base}/live/files?version=${manifest.id}`, body, { "x-path": rel, "content-type": contentType(rel) });
|
|
368
|
+
uploaded++;
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
await Promise.all(Array.from({ length: 6 }, liveWorker));
|
|
372
|
+
}
|
|
373
|
+
const change = {
|
|
374
|
+
...opts.password !== void 0 ? { password: opts.password } : {},
|
|
375
|
+
...opts.title ? { title: opts.title } : {},
|
|
376
|
+
...opts.access ? { access: opts.access } : {}
|
|
377
|
+
};
|
|
378
|
+
let password = share.password;
|
|
379
|
+
let access = share.access ?? null;
|
|
380
|
+
if (Object.keys(change).length) {
|
|
381
|
+
const after = await request(site, "PATCH", base, change);
|
|
382
|
+
password = after.password;
|
|
383
|
+
access = after.access ?? access;
|
|
384
|
+
}
|
|
385
|
+
const live = opts.live ? "url" in opts.live ? { url: opts.live.url } : { files: true } : void 0;
|
|
386
|
+
const done = await request(site, "POST", `${base}/versions`, { id: manifest.id, createdAt: manifest.createdAt, frames: manifest.frames, live });
|
|
387
|
+
const invited = opts.invite?.length ? await inviteTo(site, share.slug, opts.invite) : [];
|
|
388
|
+
return { url: done.url, version: manifest.id, versions: done.versions, uploaded, skipped, password, live: done.live ?? null, access, invited };
|
|
389
|
+
}
|
|
390
|
+
async function inviteTo(site, slug, emails) {
|
|
391
|
+
const res = await request(site, "POST", `/api/shares/${slug}/invites`, { emails });
|
|
392
|
+
return res.invited;
|
|
393
|
+
}
|
|
394
|
+
function parseEmails(values) {
|
|
395
|
+
const all = (Array.isArray(values) ? values : values ? [values] : []).flatMap((v) => v.split(/[\s,;]+/));
|
|
396
|
+
const emails = [...new Set(all.map((e) => e.trim().toLowerCase()).filter(Boolean))];
|
|
397
|
+
const bad = emails.filter((e) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e));
|
|
398
|
+
if (bad.length) throw new Error(`Not an email: ${bad.join(", ")}`);
|
|
399
|
+
return emails;
|
|
400
|
+
}
|
|
401
|
+
async function updateAccess(site, project, canvas, change) {
|
|
402
|
+
const can = await siteFeatures(site);
|
|
403
|
+
if (!can.includes("access") || !can.includes("invites")) throw new Error(NO_SIGN_IN);
|
|
404
|
+
const found = await request(site, "GET", `/api/shares?project=${encodeURIComponent(project)}&canvas=${encodeURIComponent(canvas)}`);
|
|
405
|
+
if (!found.slug) throw new Error(`${canvas} has no link yet. Share it first: npx truecanvas share ${canvas}`);
|
|
406
|
+
let access = null;
|
|
407
|
+
if (change.access || change.password) {
|
|
408
|
+
const after = await request(site, "PATCH", `/api/shares/${found.slug}`, { ...change.access ? { access: change.access } : {}, ...change.password ? { password: change.password } : {} });
|
|
409
|
+
access = after.access ?? null;
|
|
410
|
+
}
|
|
411
|
+
const invited = change.invite?.length ? await inviteTo(site, found.slug, change.invite) : [];
|
|
412
|
+
const share = await request(site, "GET", `/api/shares/${found.slug}`);
|
|
413
|
+
return { url: `${site.url}/s/${found.slug}`, access: access ?? share.access ?? null, invited };
|
|
312
414
|
}
|
|
313
415
|
async function deleteShare(site, project, canvas) {
|
|
314
416
|
const found = await request(site, "GET", `/api/shares?project=${encodeURIComponent(project)}&canvas=${encodeURIComponent(canvas)}`);
|
|
@@ -339,12 +441,33 @@ async function request(site, method, route, body, headers = {}) {
|
|
|
339
441
|
if (!res.ok) throw new Error(`Review site: ${data2.error ?? `${res.status} ${res.statusText}`}`);
|
|
340
442
|
return data2;
|
|
341
443
|
}
|
|
444
|
+
function walk(root, rel = "") {
|
|
445
|
+
const out = [];
|
|
446
|
+
for (const entry of fs2.readdirSync(path2.join(root, rel), { withFileTypes: true })) {
|
|
447
|
+
if (entry.name.startsWith(".")) continue;
|
|
448
|
+
const child = rel ? `${rel}/${entry.name}` : entry.name;
|
|
449
|
+
if (entry.isDirectory()) out.push(...walk(root, child));
|
|
450
|
+
else if (entry.isFile()) out.push(child);
|
|
451
|
+
}
|
|
452
|
+
return out;
|
|
453
|
+
}
|
|
342
454
|
function contentType(file) {
|
|
343
455
|
const ext = path2.extname(file).slice(1).toLowerCase();
|
|
344
456
|
const types = {
|
|
345
457
|
html: "text/html; charset=utf-8",
|
|
458
|
+
css: "text/css; charset=utf-8",
|
|
459
|
+
js: "text/javascript; charset=utf-8",
|
|
460
|
+
mjs: "text/javascript; charset=utf-8",
|
|
461
|
+
json: "application/json",
|
|
462
|
+
map: "application/json",
|
|
463
|
+
txt: "text/plain; charset=utf-8",
|
|
464
|
+
xml: "application/xml",
|
|
465
|
+
webmanifest: "application/manifest+json",
|
|
466
|
+
wasm: "application/wasm",
|
|
467
|
+
pdf: "application/pdf",
|
|
346
468
|
png: "image/png",
|
|
347
469
|
jpg: "image/jpeg",
|
|
470
|
+
jpeg: "image/jpeg",
|
|
348
471
|
webp: "image/webp",
|
|
349
472
|
avif: "image/avif",
|
|
350
473
|
gif: "image/gif",
|
|
@@ -375,7 +498,7 @@ Workflow:
|
|
|
375
498
|
5. focus to show the user what you changed. Everything is undoable by the user.`;
|
|
376
499
|
var literal = z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number(), z.boolean()]))]);
|
|
377
500
|
var deviceIds = DEVICES.map((d) => d.id);
|
|
378
|
-
function createMcpServer(ws, shots, session) {
|
|
501
|
+
function createMcpServer(ws, shots, session, sessions) {
|
|
379
502
|
const server = new McpServer({ name: "truecanvas", version: "0.1.0" }, { instructions: INSTRUCTIONS });
|
|
380
503
|
const actor = () => {
|
|
381
504
|
const name = server.server.getClientVersion()?.name ?? "agent";
|
|
@@ -985,10 +1108,14 @@ ${msgs}`;
|
|
|
985
1108
|
inputSchema: {
|
|
986
1109
|
...canvasArg,
|
|
987
1110
|
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".')
|
|
1111
|
+
title: z.string().optional().describe('Title the client sees, e.g. "Homepage, round 2".'),
|
|
1112
|
+
live: z.string().optional().describe('A live version clients open with "View live": the https URL where the app runs, or a static build folder (Next `out/`, Vite `dist/`, relative to the project) that the review site hosts.'),
|
|
1113
|
+
invite: z.array(z.string()).optional().describe("Emails of people to invite (the client). Each gets an email with a link that signs them in and opens the design. Only invite addresses the user gave you."),
|
|
1114
|
+
access: z.enum(["invited", "password", "public"]).optional().describe("Who can open the link: invited people (the default for new links on review sites with sign-in), a password (the user gives it to you), or anyone with the link. Existing links keep theirs unless this is set."),
|
|
1115
|
+
password: z.string().optional().describe(`The link's password, with access "password".`)
|
|
989
1116
|
}
|
|
990
1117
|
},
|
|
991
|
-
async ({ canvas, frames, title }) => {
|
|
1118
|
+
async ({ canvas, frames, title, live, invite, access, password }) => {
|
|
992
1119
|
try {
|
|
993
1120
|
const c2 = resolveCanvas(canvas);
|
|
994
1121
|
presence(c2, "looking", "Preparing a share link");
|
|
@@ -1000,8 +1127,55 @@ ${msgs}`;
|
|
|
1000
1127
|
].filter(Boolean);
|
|
1001
1128
|
const site = reviewSite();
|
|
1002
1129
|
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
|
|
1004
|
-
|
|
1130
|
+
const liveSite = live ? /^https?:\/\//.test(live) ? { url: live } : { dir: path3.resolve(ws.config.root, live) } : void 0;
|
|
1131
|
+
const published = await publishSnapshot(site, dir, manifest, { title, live: liveSite, access, password, invite: invite?.length ? parseEmails(invite) : void 0 });
|
|
1132
|
+
const who = published.access === "invited" ? ", invited people only" : published.access === "public" ? ", anyone with the link" : published.password ? ", password protected" : "";
|
|
1133
|
+
return text(
|
|
1134
|
+
[
|
|
1135
|
+
`\u2713 Shared ${manifest.frames.length} frame(s): ${published.url}`,
|
|
1136
|
+
`Version ${published.versions} of this link${who}.`,
|
|
1137
|
+
published.live ? `Live version: ${published.live}` : "",
|
|
1138
|
+
...published.invited.map((i) => i.error ? `Couldn't email ${i.email}: ${i.error}` : `Invited ${i.email} (emailed a sign-in link).`),
|
|
1139
|
+
published.access === "invited" && !published.invited.length ? "Only invited people and the studio can open it: share again with `invite` to invite the client." : "",
|
|
1140
|
+
...notes
|
|
1141
|
+
].filter(Boolean).join("\n")
|
|
1142
|
+
);
|
|
1143
|
+
} catch (e) {
|
|
1144
|
+
return fail(e);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
);
|
|
1148
|
+
server.registerTool(
|
|
1149
|
+
"start_session",
|
|
1150
|
+
{
|
|
1151
|
+
title: "Start a live session",
|
|
1152
|
+
description: "Start a live session on a canvas's share link: while it runs, people with access to the link see the real frames from the app (not the frozen snapshot), they update as the canvas is edited, everyone sees each other's cursors, and comments arrive instantly. The canvas must have been shared once (share_canvas). It runs until stop_session or until Truecanvas stops. Only start one when the user asks.",
|
|
1153
|
+
inputSchema: { ...canvasArg }
|
|
1154
|
+
},
|
|
1155
|
+
async ({ canvas }) => {
|
|
1156
|
+
try {
|
|
1157
|
+
if (!sessions) throw new Error("Live sessions run from the Truecanvas server (npx truecanvas).");
|
|
1158
|
+
const c2 = resolveCanvas(canvas);
|
|
1159
|
+
const s = await sessions.start(c2);
|
|
1160
|
+
return text(`\u2713 Live session started on ${c2}: ${s.url}
|
|
1161
|
+
People with access to the link now see the frames live. Stop it with stop_session.`);
|
|
1162
|
+
} catch (e) {
|
|
1163
|
+
return fail(e);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
);
|
|
1167
|
+
server.registerTool(
|
|
1168
|
+
"stop_session",
|
|
1169
|
+
{
|
|
1170
|
+
title: "Stop a live session",
|
|
1171
|
+
description: "Stop a canvas's live session: the link goes back to its latest published version.",
|
|
1172
|
+
inputSchema: { ...canvasArg }
|
|
1173
|
+
},
|
|
1174
|
+
async ({ canvas }) => {
|
|
1175
|
+
try {
|
|
1176
|
+
if (!sessions) throw new Error("Live sessions run from the Truecanvas server (npx truecanvas).");
|
|
1177
|
+
const c2 = resolveCanvas(canvas);
|
|
1178
|
+
return text(sessions.stop(c2) ? `\u2713 Stopped the live session on ${c2}.` : `${c2} has no live session.`);
|
|
1005
1179
|
} catch (e) {
|
|
1006
1180
|
return fail(e);
|
|
1007
1181
|
}
|
|
@@ -1179,12 +1353,12 @@ ${out.out.slice(-1500)}`));
|
|
|
1179
1353
|
// src/server/thumbnails.ts
|
|
1180
1354
|
import crypto2 from "crypto";
|
|
1181
1355
|
import fs3 from "fs";
|
|
1182
|
-
import
|
|
1356
|
+
import path4 from "path";
|
|
1183
1357
|
var Thumbnails = class _Thumbnails {
|
|
1184
1358
|
constructor(ws, shots) {
|
|
1185
1359
|
this.ws = ws;
|
|
1186
1360
|
this.shots = shots;
|
|
1187
|
-
this.dir =
|
|
1361
|
+
this.dir = path4.join(ws.config.root, "node_modules", ".cache", "truecanvas", "thumbs");
|
|
1188
1362
|
}
|
|
1189
1363
|
ws;
|
|
1190
1364
|
shots;
|
|
@@ -1210,7 +1384,7 @@ var Thumbnails = class _Thumbnails {
|
|
|
1210
1384
|
key(spec, props) {
|
|
1211
1385
|
let stamp = "";
|
|
1212
1386
|
try {
|
|
1213
|
-
const file = spec.library ?
|
|
1387
|
+
const file = spec.library ? path4.join(this.ws.config.root, "node_modules", spec.library, "package.json") : path4.join(this.ws.config.root, spec.file);
|
|
1214
1388
|
stamp = String(fs3.statSync(file).mtimeMs);
|
|
1215
1389
|
} catch {
|
|
1216
1390
|
stamp = "0";
|
|
@@ -1222,7 +1396,7 @@ var Thumbnails = class _Thumbnails {
|
|
|
1222
1396
|
const spec = this.ws.catalog.get(name);
|
|
1223
1397
|
if (!spec) throw new Error(`Unknown component ${name}`);
|
|
1224
1398
|
const props = _Thumbnails.previewProps(spec, preset);
|
|
1225
|
-
const file =
|
|
1399
|
+
const file = path4.join(this.dir, `${name}-${this.key(spec, props)}.png`);
|
|
1226
1400
|
if (fs3.existsSync(file)) return fs3.readFileSync(file);
|
|
1227
1401
|
const job = this.queue.then(async () => {
|
|
1228
1402
|
if (fs3.existsSync(file)) return fs3.readFileSync(file);
|
|
@@ -1240,10 +1414,10 @@ var Thumbnails = class _Thumbnails {
|
|
|
1240
1414
|
// src/server/screenshot.ts
|
|
1241
1415
|
import fs4 from "fs";
|
|
1242
1416
|
import os2 from "os";
|
|
1243
|
-
import
|
|
1417
|
+
import path5 from "path";
|
|
1244
1418
|
function findChromium() {
|
|
1245
1419
|
if (process.env.TRUECANVAS_CHROME) return process.env.TRUECANVAS_CHROME;
|
|
1246
|
-
const cache2 = process.env.PLAYWRIGHT_BROWSERS_PATH || (process.platform === "win32" ?
|
|
1420
|
+
const cache2 = process.env.PLAYWRIGHT_BROWSERS_PATH || (process.platform === "win32" ? path5.join(process.env.LOCALAPPDATA ?? path5.join(os2.homedir(), "AppData", "Local"), "ms-playwright") : process.platform === "darwin" ? path5.join(os2.homedir(), "Library", "Caches", "ms-playwright") : path5.join(os2.homedir(), ".cache", "ms-playwright"));
|
|
1247
1421
|
if (fs4.existsSync(cache2)) {
|
|
1248
1422
|
const shells = fs4.readdirSync(cache2).filter((d) => d.startsWith("chromium_headless_shell-")).sort().reverse();
|
|
1249
1423
|
for (const d of shells) {
|
|
@@ -1255,14 +1429,14 @@ function findChromium() {
|
|
|
1255
1429
|
"chrome-headless-shell-win64/chrome-headless-shell.exe",
|
|
1256
1430
|
"chrome-win/headless_shell.exe"
|
|
1257
1431
|
]) {
|
|
1258
|
-
const p =
|
|
1432
|
+
const p = path5.join(cache2, d, sub);
|
|
1259
1433
|
if (fs4.existsSync(p)) return p;
|
|
1260
1434
|
}
|
|
1261
1435
|
}
|
|
1262
1436
|
}
|
|
1263
1437
|
const programFiles = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
|
|
1264
1438
|
for (const p of [
|
|
1265
|
-
...programFiles.flatMap((dir) => [
|
|
1439
|
+
...programFiles.flatMap((dir) => [path5.join(dir, "Google", "Chrome", "Application", "chrome.exe"), path5.join(dir, "Microsoft", "Edge", "Application", "msedge.exe")]),
|
|
1266
1440
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
1267
1441
|
"/usr/bin/chromium-browser",
|
|
1268
1442
|
"/usr/bin/chromium",
|
|
@@ -1487,7 +1661,7 @@ ${root.outerHTML}`, css, canvases, height };
|
|
|
1487
1661
|
|
|
1488
1662
|
// src/server/review.ts
|
|
1489
1663
|
import fs5 from "fs";
|
|
1490
|
-
import
|
|
1664
|
+
import path6 from "path";
|
|
1491
1665
|
import { createHash } from "crypto";
|
|
1492
1666
|
var actorName = (e) => e.actor.kind === "agent" ? e.actor.name : e.actor.kind === "file" ? "code editor" : "you";
|
|
1493
1667
|
async function versionAt(ws, canvas, ref) {
|
|
@@ -1504,7 +1678,7 @@ async function versionAt(ws, canvas, ref) {
|
|
|
1504
1678
|
}
|
|
1505
1679
|
var readNow = (ws, file) => {
|
|
1506
1680
|
try {
|
|
1507
|
-
return fs5.readFileSync(
|
|
1681
|
+
return fs5.readFileSync(path6.join(ws.config.root, file), "utf8");
|
|
1508
1682
|
} catch {
|
|
1509
1683
|
return null;
|
|
1510
1684
|
}
|
|
@@ -1631,33 +1805,17 @@ function prDesignSection(pages, images) {
|
|
|
1631
1805
|
return lines.join("\n");
|
|
1632
1806
|
}
|
|
1633
1807
|
|
|
1634
|
-
// src/server/body.ts
|
|
1635
|
-
async function readJson(req, limit = 8 * 1024 * 1024) {
|
|
1636
|
-
const chunks = [];
|
|
1637
|
-
let size = 0;
|
|
1638
|
-
for await (const chunk of req) {
|
|
1639
|
-
size += chunk.length;
|
|
1640
|
-
if (size > limit) throw new EditError("Request too large.");
|
|
1641
|
-
chunks.push(chunk);
|
|
1642
|
-
}
|
|
1643
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1644
|
-
if (!raw) return {};
|
|
1645
|
-
try {
|
|
1646
|
-
return JSON.parse(raw);
|
|
1647
|
-
} catch {
|
|
1648
|
-
throw new EditError("The request body isn't valid JSON.");
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
1808
|
// src/share/sync.ts
|
|
1653
|
-
import
|
|
1809
|
+
import path7 from "path";
|
|
1654
1810
|
var EVERY = 2e4;
|
|
1655
1811
|
var LOOKUP_EVERY = 5 * 6e4;
|
|
1656
1812
|
var CommentSync = class {
|
|
1657
|
-
constructor(ws) {
|
|
1813
|
+
constructor(ws, onClientMessage) {
|
|
1658
1814
|
this.ws = ws;
|
|
1815
|
+
this.onClientMessage = onClientMessage;
|
|
1659
1816
|
}
|
|
1660
1817
|
ws;
|
|
1818
|
+
onClientMessage;
|
|
1661
1819
|
links = /* @__PURE__ */ new Map();
|
|
1662
1820
|
remote = /* @__PURE__ */ new Map();
|
|
1663
1821
|
timer = null;
|
|
@@ -1700,7 +1858,7 @@ var CommentSync = class {
|
|
|
1700
1858
|
async slugOf(site, canvas) {
|
|
1701
1859
|
const known = this.links.get(canvas);
|
|
1702
1860
|
if (known && (known.slug || Date.now() - known.at < LOOKUP_EVERY)) return known.slug;
|
|
1703
|
-
const project =
|
|
1861
|
+
const project = path7.basename(this.ws.config.root);
|
|
1704
1862
|
const res = await call(site, "GET", `/api/shares?project=${encodeURIComponent(project)}&canvas=${encodeURIComponent(canvas)}`);
|
|
1705
1863
|
this.links.set(canvas, { slug: res.slug, at: Date.now() });
|
|
1706
1864
|
return res.slug;
|
|
@@ -1714,6 +1872,10 @@ var CommentSync = class {
|
|
|
1714
1872
|
this.remote.set(slug, { updated: res.updated, threads: remote });
|
|
1715
1873
|
const byId = new Map(remote.map((t) => [t.id, t]));
|
|
1716
1874
|
const dismissed = this.ws.comments.dismissed(canvas);
|
|
1875
|
+
const arrived = [];
|
|
1876
|
+
const fromClient = (m) => {
|
|
1877
|
+
if (m.author.kind === "client") arrived.push({ canvas, name: m.author.name, text: m.text });
|
|
1878
|
+
};
|
|
1717
1879
|
this.ws.comments.merge(canvas, (local) => {
|
|
1718
1880
|
let changed = false;
|
|
1719
1881
|
for (const r of remote) {
|
|
@@ -1721,6 +1883,7 @@ var CommentSync = class {
|
|
|
1721
1883
|
const l = local.find((t) => t.id === r.id);
|
|
1722
1884
|
if (!l) {
|
|
1723
1885
|
local.push(fromRemote(r, slug));
|
|
1886
|
+
r.messages.forEach(fromClient);
|
|
1724
1887
|
changed = true;
|
|
1725
1888
|
continue;
|
|
1726
1889
|
}
|
|
@@ -1729,6 +1892,7 @@ var CommentSync = class {
|
|
|
1729
1892
|
for (const m of r.messages) {
|
|
1730
1893
|
if (have.has(m.id)) continue;
|
|
1731
1894
|
l.messages.push(message(m));
|
|
1895
|
+
fromClient(m);
|
|
1732
1896
|
changed = true;
|
|
1733
1897
|
}
|
|
1734
1898
|
if (changed) l.messages.sort((a, b) => a.at - b.at);
|
|
@@ -1741,6 +1905,7 @@ var CommentSync = class {
|
|
|
1741
1905
|
}
|
|
1742
1906
|
return changed;
|
|
1743
1907
|
});
|
|
1908
|
+
if (this.onClientMessage) for (const e of arrived) this.onClientMessage(e);
|
|
1744
1909
|
let pushed = false;
|
|
1745
1910
|
for (const l of this.ws.comments.list(canvas)) {
|
|
1746
1911
|
if (l.share?.link !== slug) continue;
|
|
@@ -1793,6 +1958,349 @@ async function call(site, method, route, body) {
|
|
|
1793
1958
|
return res.json();
|
|
1794
1959
|
}
|
|
1795
1960
|
|
|
1961
|
+
// src/share/session.ts
|
|
1962
|
+
var BUFFER_LIMIT = 4 * 1024 * 1024;
|
|
1963
|
+
var MAX_REQUEST_BODY = 10 * 1024 * 1024;
|
|
1964
|
+
var LiveSession = class {
|
|
1965
|
+
constructor(site, slug, canvas, appUrl, opts) {
|
|
1966
|
+
this.site = site;
|
|
1967
|
+
this.slug = slug;
|
|
1968
|
+
this.opts = opts;
|
|
1969
|
+
this.app = new URL(appUrl);
|
|
1970
|
+
this.state = { canvas, status: "connecting", url: `${site.url}/s/${slug}`, error: null, people: [], you: null };
|
|
1971
|
+
}
|
|
1972
|
+
site;
|
|
1973
|
+
slug;
|
|
1974
|
+
opts;
|
|
1975
|
+
socket = null;
|
|
1976
|
+
stopped = false;
|
|
1977
|
+
retry = null;
|
|
1978
|
+
backoff = 1e3;
|
|
1979
|
+
inflight = /* @__PURE__ */ new Map();
|
|
1980
|
+
relays = /* @__PURE__ */ new Map();
|
|
1981
|
+
cursorTimer = null;
|
|
1982
|
+
cursorLast = 0;
|
|
1983
|
+
cursorNext = null;
|
|
1984
|
+
app;
|
|
1985
|
+
state;
|
|
1986
|
+
start() {
|
|
1987
|
+
this.stopped = false;
|
|
1988
|
+
this.connect();
|
|
1989
|
+
return this;
|
|
1990
|
+
}
|
|
1991
|
+
stop() {
|
|
1992
|
+
this.stopped = true;
|
|
1993
|
+
if (this.retry) clearTimeout(this.retry);
|
|
1994
|
+
this.socket?.close(1e3, "The studio ended the session");
|
|
1995
|
+
this.socket = null;
|
|
1996
|
+
this.cleanup();
|
|
1997
|
+
this.setState({ status: "stopped", people: [] });
|
|
1998
|
+
}
|
|
1999
|
+
/** The studio's cursor on the canvas (canvas coordinates), or null when it left. Throttled. */
|
|
2000
|
+
cursor(x, y) {
|
|
2001
|
+
this.cursorNext = { x, y };
|
|
2002
|
+
const wait = 50 - (Date.now() - this.cursorLast);
|
|
2003
|
+
if (this.cursorTimer) return;
|
|
2004
|
+
const send = () => {
|
|
2005
|
+
this.cursorTimer = null;
|
|
2006
|
+
if (!this.cursorNext) return;
|
|
2007
|
+
this.cursorLast = Date.now();
|
|
2008
|
+
this.sendText({ t: "cursor", ...this.cursorNext });
|
|
2009
|
+
this.cursorNext = null;
|
|
2010
|
+
};
|
|
2011
|
+
if (wait <= 0) send();
|
|
2012
|
+
else this.cursorTimer = setTimeout(send, wait);
|
|
2013
|
+
}
|
|
2014
|
+
setState(change) {
|
|
2015
|
+
this.state = { ...this.state, ...change };
|
|
2016
|
+
this.opts.onState?.(this.state);
|
|
2017
|
+
}
|
|
2018
|
+
connect() {
|
|
2019
|
+
if (this.stopped) return;
|
|
2020
|
+
const url = new URL(`${this.site.url}/api/shares/${encodeURIComponent(this.slug)}/room`);
|
|
2021
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
2022
|
+
url.searchParams.set("host", "1");
|
|
2023
|
+
url.searchParams.set("name", this.opts.name);
|
|
2024
|
+
url.searchParams.set("route", this.opts.route);
|
|
2025
|
+
const socket = new WebSocket(url, { headers: { authorization: `Bearer ${this.site.token}` } });
|
|
2026
|
+
socket.binaryType = "arraybuffer";
|
|
2027
|
+
this.socket = socket;
|
|
2028
|
+
let opened = false;
|
|
2029
|
+
const keepAlive = setInterval(() => socket.readyState === WebSocket.OPEN && socket.send("ping"), 3e4);
|
|
2030
|
+
keepAlive.unref?.();
|
|
2031
|
+
socket.onopen = () => {
|
|
2032
|
+
opened = true;
|
|
2033
|
+
this.backoff = 1e3;
|
|
2034
|
+
this.setState({ status: "live", error: null });
|
|
2035
|
+
};
|
|
2036
|
+
socket.onmessage = (m) => {
|
|
2037
|
+
if (typeof m.data === "string") this.onText(m.data);
|
|
2038
|
+
else void this.onFrame(m.data);
|
|
2039
|
+
};
|
|
2040
|
+
socket.onclose = (e) => {
|
|
2041
|
+
clearInterval(keepAlive);
|
|
2042
|
+
if (this.socket !== socket) return;
|
|
2043
|
+
this.socket = null;
|
|
2044
|
+
this.cleanup();
|
|
2045
|
+
if (this.stopped) return;
|
|
2046
|
+
if (e.code === 4e3) {
|
|
2047
|
+
this.stopped = true;
|
|
2048
|
+
this.setState({ status: "stopped", error: "Another Truecanvas started a session on this link.", people: [] });
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
this.setState({ status: "reconnecting", error: opened ? null : "Couldn't reach the review site.", people: [] });
|
|
2052
|
+
this.retry = setTimeout(() => this.connect(), this.backoff);
|
|
2053
|
+
this.backoff = Math.min(this.backoff * 2, 15e3);
|
|
2054
|
+
};
|
|
2055
|
+
socket.onerror = () => {
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
cleanup() {
|
|
2059
|
+
for (const f of this.inflight.values()) f.controller.abort();
|
|
2060
|
+
this.inflight.clear();
|
|
2061
|
+
for (const r of this.relays.values()) r.socket?.close();
|
|
2062
|
+
this.relays.clear();
|
|
2063
|
+
}
|
|
2064
|
+
sendText(msg) {
|
|
2065
|
+
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(msg));
|
|
2066
|
+
}
|
|
2067
|
+
send(header, payload) {
|
|
2068
|
+
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encodeFrame(header, payload));
|
|
2069
|
+
}
|
|
2070
|
+
/** Don't pile megabytes on a slow link: wait until the socket drains. */
|
|
2071
|
+
async drained() {
|
|
2072
|
+
while (this.socket && this.socket.readyState === WebSocket.OPEN && this.socket.bufferedAmount > BUFFER_LIMIT) await new Promise((r) => setTimeout(r, 15));
|
|
2073
|
+
}
|
|
2074
|
+
onText(data2) {
|
|
2075
|
+
if (data2 === "pong") return;
|
|
2076
|
+
let e;
|
|
2077
|
+
try {
|
|
2078
|
+
e = JSON.parse(data2);
|
|
2079
|
+
} catch {
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
if (e.t === "hello") this.setState({ people: e.people, you: e.you.id });
|
|
2083
|
+
else if (e.t === "join") this.setState({ people: [...this.state.people.filter((p) => p.id !== e.person.id), e.person] });
|
|
2084
|
+
else if (e.t === "leave") this.setState({ people: this.state.people.filter((p) => p.id !== e.id) });
|
|
2085
|
+
this.opts.onEvent?.(e);
|
|
2086
|
+
}
|
|
2087
|
+
// ---------- the tunnel ----------
|
|
2088
|
+
/** The app URL for a tunneled path, or null if it would leave the app. */
|
|
2089
|
+
target(path12, ws = false) {
|
|
2090
|
+
const safe = safePath(path12);
|
|
2091
|
+
if (!safe) return null;
|
|
2092
|
+
const url = new URL(safe, this.app);
|
|
2093
|
+
if (url.origin !== this.app.origin) return null;
|
|
2094
|
+
if (ws) url.protocol = this.app.protocol === "https:" ? "wss:" : "ws:";
|
|
2095
|
+
return url;
|
|
2096
|
+
}
|
|
2097
|
+
async onFrame(data2) {
|
|
2098
|
+
let frame;
|
|
2099
|
+
try {
|
|
2100
|
+
frame = decodeFrame(data2);
|
|
2101
|
+
} catch {
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
const h = frame.header;
|
|
2105
|
+
switch (h.t) {
|
|
2106
|
+
case "req":
|
|
2107
|
+
return this.request(h);
|
|
2108
|
+
case "req-body": {
|
|
2109
|
+
const f = this.inflight.get(h.id);
|
|
2110
|
+
if (!f?.body) return;
|
|
2111
|
+
f.size += frame.payload.length;
|
|
2112
|
+
if (f.size > MAX_REQUEST_BODY) {
|
|
2113
|
+
f.controller.abort();
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
f.body.enqueue(frame.payload.slice());
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
case "req-end": {
|
|
2120
|
+
const f = this.inflight.get(h.id);
|
|
2121
|
+
try {
|
|
2122
|
+
f?.body?.close();
|
|
2123
|
+
} catch {
|
|
2124
|
+
}
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
case "req-abort":
|
|
2128
|
+
this.inflight.get(h.id)?.controller.abort();
|
|
2129
|
+
this.inflight.delete(h.id);
|
|
2130
|
+
return;
|
|
2131
|
+
case "ws-open":
|
|
2132
|
+
return this.openRelay(h.id, h.path, h.protocols);
|
|
2133
|
+
case "ws-msg": {
|
|
2134
|
+
const r = this.relays.get(h.id);
|
|
2135
|
+
if (!r) return;
|
|
2136
|
+
const data3 = h.text ? new TextDecoder().decode(frame.payload) : frame.payload.slice();
|
|
2137
|
+
if (r.socket?.readyState === WebSocket.OPEN) r.socket.send(data3);
|
|
2138
|
+
else r.queue.push({ data: data3 });
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
case "ws-close": {
|
|
2142
|
+
const r = this.relays.get(h.id);
|
|
2143
|
+
this.relays.delete(h.id);
|
|
2144
|
+
r?.socket?.close();
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
async request(h) {
|
|
2150
|
+
const target = this.target(h.path);
|
|
2151
|
+
if (!target || !["GET", "HEAD", "POST"].includes(h.method)) {
|
|
2152
|
+
this.send({ t: "res", id: h.id, status: 403, headers: [["content-type", "text/plain; charset=utf-8"]] });
|
|
2153
|
+
this.send({ t: "res-body", id: h.id }, new TextEncoder().encode("Not part of the app."));
|
|
2154
|
+
this.send({ t: "res-end", id: h.id });
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
const controller = new AbortController();
|
|
2158
|
+
const entry = { controller, body: null, size: 0 };
|
|
2159
|
+
let body;
|
|
2160
|
+
if (h.body) body = new ReadableStream({ start: (c2) => void (entry.body = c2) });
|
|
2161
|
+
this.inflight.set(h.id, entry);
|
|
2162
|
+
try {
|
|
2163
|
+
const headers = new Headers();
|
|
2164
|
+
for (const [k, v] of h.headers) {
|
|
2165
|
+
try {
|
|
2166
|
+
headers.append(k, v);
|
|
2167
|
+
} catch {
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
const res = await fetch(target, {
|
|
2171
|
+
method: h.method,
|
|
2172
|
+
headers,
|
|
2173
|
+
body,
|
|
2174
|
+
redirect: "manual",
|
|
2175
|
+
signal: controller.signal,
|
|
2176
|
+
...body ? { duplex: "half" } : {}
|
|
2177
|
+
});
|
|
2178
|
+
this.send({ t: "res", id: h.id, status: res.status, headers: this.answerHeaders(res.headers) });
|
|
2179
|
+
if (res.body && h.method !== "HEAD") {
|
|
2180
|
+
const reader = res.body.getReader();
|
|
2181
|
+
for (; ; ) {
|
|
2182
|
+
const { done, value } = await reader.read();
|
|
2183
|
+
if (done) break;
|
|
2184
|
+
for (let i = 0; i < value.length; i += CHUNK) {
|
|
2185
|
+
await this.drained();
|
|
2186
|
+
if (!this.inflight.has(h.id)) {
|
|
2187
|
+
await reader.cancel().catch(() => {
|
|
2188
|
+
});
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
this.send({ t: "res-body", id: h.id }, value.subarray(i, i + CHUNK));
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
this.send({ t: "res-end", id: h.id });
|
|
2196
|
+
} catch (err) {
|
|
2197
|
+
if (!controller.signal.aborted) this.send({ t: "res-error", id: h.id, message: err.message || "request failed" });
|
|
2198
|
+
} finally {
|
|
2199
|
+
this.inflight.delete(h.id);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
/** The app's answer headers, with redirects to itself made relative (they'd point at localhost otherwise). */
|
|
2203
|
+
answerHeaders(headers) {
|
|
2204
|
+
const out = responseHeaders(headers);
|
|
2205
|
+
return out.map(([k, v]) => {
|
|
2206
|
+
if (k !== "location") return [k, v];
|
|
2207
|
+
try {
|
|
2208
|
+
const to = new URL(v, this.app);
|
|
2209
|
+
return [k, to.origin === this.app.origin ? to.pathname + to.search + to.hash : v];
|
|
2210
|
+
} catch {
|
|
2211
|
+
return [k, v];
|
|
2212
|
+
}
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
openRelay(id, path12, protocols) {
|
|
2216
|
+
const target = this.target(path12, true);
|
|
2217
|
+
if (!target) {
|
|
2218
|
+
this.send({ t: "ws-close", id, code: 1008, reason: "Not part of the app" });
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
2221
|
+
const relay = { socket: null, queue: [] };
|
|
2222
|
+
this.relays.set(id, relay);
|
|
2223
|
+
let socket;
|
|
2224
|
+
try {
|
|
2225
|
+
socket = new WebSocket(target, protocols);
|
|
2226
|
+
} catch (err) {
|
|
2227
|
+
this.relays.delete(id);
|
|
2228
|
+
this.send({ t: "ws-close", id, code: 1011, reason: err.message.slice(0, 100) });
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
socket.binaryType = "arraybuffer";
|
|
2232
|
+
relay.socket = socket;
|
|
2233
|
+
socket.onopen = () => {
|
|
2234
|
+
this.send({ t: "ws-ready", id, protocol: socket.protocol });
|
|
2235
|
+
for (const m of relay.queue) socket.send(m.data);
|
|
2236
|
+
relay.queue = [];
|
|
2237
|
+
};
|
|
2238
|
+
socket.onmessage = (m) => {
|
|
2239
|
+
const text = typeof m.data === "string";
|
|
2240
|
+
this.send({ t: "ws-msg", id, text }, text ? new TextEncoder().encode(m.data) : new Uint8Array(m.data));
|
|
2241
|
+
};
|
|
2242
|
+
socket.onclose = (e) => {
|
|
2243
|
+
if (!this.relays.has(id)) return;
|
|
2244
|
+
this.relays.delete(id);
|
|
2245
|
+
this.send({ t: "ws-close", id, code: e.code, reason: e.reason });
|
|
2246
|
+
};
|
|
2247
|
+
socket.onerror = () => {
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
};
|
|
2251
|
+
var Sessions = class {
|
|
2252
|
+
constructor(project, appUrl, events = {}) {
|
|
2253
|
+
this.project = project;
|
|
2254
|
+
this.appUrl = appUrl;
|
|
2255
|
+
this.events = events;
|
|
2256
|
+
}
|
|
2257
|
+
project;
|
|
2258
|
+
appUrl;
|
|
2259
|
+
events;
|
|
2260
|
+
sessions = /* @__PURE__ */ new Map();
|
|
2261
|
+
list() {
|
|
2262
|
+
return [...this.sessions.values()].map((s) => s.state);
|
|
2263
|
+
}
|
|
2264
|
+
get(canvas) {
|
|
2265
|
+
return this.sessions.get(canvas) ?? null;
|
|
2266
|
+
}
|
|
2267
|
+
/** Starts (or returns) the canvas's session. The canvas needs a link: share it first. */
|
|
2268
|
+
async start(canvas, opts = {}) {
|
|
2269
|
+
const running = this.sessions.get(canvas);
|
|
2270
|
+
if (running && running.state.status !== "stopped") return running.state;
|
|
2271
|
+
const site = reviewSite();
|
|
2272
|
+
if (!site) throw new Error("No review site yet: connect one with `npx truecanvas share setup`, share the canvas, then start a session.");
|
|
2273
|
+
const features = await siteFeatures(site);
|
|
2274
|
+
if (!features.includes("session")) throw new Error("This review site doesn't support live sessions yet: deploy the latest review site (packages/review-worker) with LIVE_HOST_SUFFIX set.");
|
|
2275
|
+
const res = await fetch(`${site.url}/api/shares?project=${encodeURIComponent(this.project)}&canvas=${encodeURIComponent(canvas)}`, {
|
|
2276
|
+
headers: { authorization: `Bearer ${site.token}` },
|
|
2277
|
+
signal: AbortSignal.timeout(2e4)
|
|
2278
|
+
});
|
|
2279
|
+
if (!res.ok) throw new Error(`Review site: ${res.status} ${res.statusText}`);
|
|
2280
|
+
const { slug } = await res.json();
|
|
2281
|
+
if (!slug) throw new Error(`${canvas} has no link yet. Share it once (Share, or \`npx truecanvas share ${canvas}\`), then start a session.`);
|
|
2282
|
+
const session = new LiveSession(site, slug, canvas, this.appUrl, {
|
|
2283
|
+
name: opts.name ?? "Studio",
|
|
2284
|
+
route: "/truecanvas/",
|
|
2285
|
+
onEvent: (e) => this.events.onEvent?.(canvas, e),
|
|
2286
|
+
onState: (s) => this.events.onState?.(s)
|
|
2287
|
+
});
|
|
2288
|
+
this.sessions.set(canvas, session);
|
|
2289
|
+
session.start();
|
|
2290
|
+
return session.state;
|
|
2291
|
+
}
|
|
2292
|
+
stop(canvas) {
|
|
2293
|
+
const s = this.sessions.get(canvas);
|
|
2294
|
+
if (!s) return false;
|
|
2295
|
+
s.stop();
|
|
2296
|
+
this.sessions.delete(canvas);
|
|
2297
|
+
return true;
|
|
2298
|
+
}
|
|
2299
|
+
stopAll() {
|
|
2300
|
+
for (const canvas of [...this.sessions.keys()]) this.stop(canvas);
|
|
2301
|
+
}
|
|
2302
|
+
};
|
|
2303
|
+
|
|
1796
2304
|
// src/server/http.ts
|
|
1797
2305
|
var MIME = {
|
|
1798
2306
|
".html": "text/html; charset=utf-8",
|
|
@@ -1816,7 +2324,12 @@ async function startServer(ws) {
|
|
|
1816
2324
|
const shots = new Screenshotter(config.appUrl);
|
|
1817
2325
|
let prCache = null;
|
|
1818
2326
|
const thumbs = new Thumbnails(ws, shots);
|
|
1819
|
-
const
|
|
2327
|
+
const hub = process.env.TRUECANVAS_HUB_URL;
|
|
2328
|
+
const commentSync = new CommentSync(
|
|
2329
|
+
ws,
|
|
2330
|
+
hub ? (e) => void fetch(`${hub}/api/hub/notify`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: config.root, ...e }), signal: AbortSignal.timeout(5e3) }).catch(() => {
|
|
2331
|
+
}) : void 0
|
|
2332
|
+
);
|
|
1820
2333
|
commentSync.start();
|
|
1821
2334
|
const syncRegistry = () => ws.catalog.load().then((list) => syncComponentRegistry(config, list)).catch(() => {
|
|
1822
2335
|
});
|
|
@@ -1832,6 +2345,19 @@ async function startServer(ws) {
|
|
|
1832
2345
|
for (const res of clients) res.write(data2);
|
|
1833
2346
|
};
|
|
1834
2347
|
ws.on(broadcast);
|
|
2348
|
+
const sendRaw = (e) => {
|
|
2349
|
+
const data2 = `data: ${JSON.stringify(e)}
|
|
2350
|
+
|
|
2351
|
+
`;
|
|
2352
|
+
for (const res of clients) res.write(data2);
|
|
2353
|
+
};
|
|
2354
|
+
const sessions = new Sessions(path8.basename(config.root), config.appUrl, {
|
|
2355
|
+
onEvent: (canvas, e) => {
|
|
2356
|
+
if (e.t === "comments") void commentSync.syncAll();
|
|
2357
|
+
else sendRaw({ type: "room", canvas, event: e });
|
|
2358
|
+
},
|
|
2359
|
+
onState: (s) => sendRaw({ type: "session", session: s })
|
|
2360
|
+
});
|
|
1835
2361
|
setInterval(() => {
|
|
1836
2362
|
for (const res of clients) res.write(": ping\n\n");
|
|
1837
2363
|
ws.pruneAgents();
|
|
@@ -1870,7 +2396,7 @@ async function startServer(ws) {
|
|
|
1870
2396
|
ws.dropAgent(sid);
|
|
1871
2397
|
}
|
|
1872
2398
|
};
|
|
1873
|
-
const server2 = createMcpServer(ws, shots, () => sid);
|
|
2399
|
+
const server2 = createMcpServer(ws, shots, () => sid, sessions);
|
|
1874
2400
|
await server2.connect(t);
|
|
1875
2401
|
transport = t;
|
|
1876
2402
|
}
|
|
@@ -1885,7 +2411,7 @@ async function startServer(ws) {
|
|
|
1885
2411
|
appUrl: config.appUrl,
|
|
1886
2412
|
framework: config.framework,
|
|
1887
2413
|
root: config.root,
|
|
1888
|
-
projectName:
|
|
2414
|
+
projectName: path8.basename(config.root),
|
|
1889
2415
|
canvases: ws.canvases(),
|
|
1890
2416
|
darkMode: config.darkMode,
|
|
1891
2417
|
agents: [...ws.agents.values()],
|
|
@@ -2035,7 +2561,7 @@ async function startServer(ws) {
|
|
|
2035
2561
|
for (const file of [f.page, ...layoutFiles(config, f.page)]) {
|
|
2036
2562
|
const old = await ws.git.show(ref, file);
|
|
2037
2563
|
if (old === null) continue;
|
|
2038
|
-
const now = fs6.existsSync(
|
|
2564
|
+
const now = fs6.existsSync(path8.join(config.root, file)) ? fs6.readFileSync(path8.join(config.root, file), "utf8") : null;
|
|
2039
2565
|
if (old !== now) {
|
|
2040
2566
|
files[file] = old;
|
|
2041
2567
|
touched.add(f.frameName);
|
|
@@ -2165,26 +2691,50 @@ async function startServer(ws) {
|
|
|
2165
2691
|
}
|
|
2166
2692
|
case "POST /api/open-file": {
|
|
2167
2693
|
const { file } = await readJson(req);
|
|
2168
|
-
const abs =
|
|
2169
|
-
const rel =
|
|
2170
|
-
if (rel.startsWith("..") ||
|
|
2694
|
+
const abs = path8.resolve(config.root, file);
|
|
2695
|
+
const rel = path8.relative(config.root, abs);
|
|
2696
|
+
if (rel.startsWith("..") || path8.isAbsolute(rel)) return json(res, 400, { error: "Outside project." });
|
|
2171
2697
|
launchEditor(abs, config.editor);
|
|
2172
2698
|
return json(res, 200, { ok: true });
|
|
2173
2699
|
}
|
|
2174
2700
|
case "GET /api/share/status": {
|
|
2175
2701
|
const site = reviewSite();
|
|
2176
|
-
|
|
2702
|
+
const features = site ? await siteFeatures(site).catch(() => []) : [];
|
|
2703
|
+
return json(res, 200, { site: site ? site.url : null, features });
|
|
2177
2704
|
}
|
|
2178
2705
|
case "POST /api/share/snapshot": {
|
|
2179
|
-
const { canvas, frames, local, title, password } = await readJson(req);
|
|
2706
|
+
const { canvas, frames, local, title, password, live, access, invite } = await readJson(req);
|
|
2180
2707
|
assertCanvasName(canvas);
|
|
2181
2708
|
const { dir, manifest } = await createSnapshot(ws, shots, canvas, { frames });
|
|
2182
2709
|
const preview = `${origin}/share/${encodeURIComponent(canvas)}/${manifest.id}/`;
|
|
2183
2710
|
const site = local ? null : reviewSite();
|
|
2184
|
-
const published = site ? await publishSnapshot(site, dir, manifest, { title, password }) : null;
|
|
2711
|
+
const published = site ? await publishSnapshot(site, dir, manifest, { title, password, live, access, invite: invite?.length ? parseEmails(invite) : void 0 }) : null;
|
|
2185
2712
|
if (published) commentSync.forget(canvas);
|
|
2186
2713
|
return json(res, 200, { manifest, preview, published });
|
|
2187
2714
|
}
|
|
2715
|
+
// live sessions: clients follow the canvas live on its link (editor button, CLI, MCP, desktop tray)
|
|
2716
|
+
case "GET /api/session":
|
|
2717
|
+
return json(res, 200, { sessions: sessions.list() });
|
|
2718
|
+
case "POST /api/session/start": {
|
|
2719
|
+
const body = await readJson(req);
|
|
2720
|
+
const canvas = body.canvas ?? ws.selection.canvas ?? ws.canvases()[0];
|
|
2721
|
+
if (!canvas) return json(res, 400, { error: "No canvas to share live yet." });
|
|
2722
|
+
assertCanvasName(canvas);
|
|
2723
|
+
return json(res, 200, { session: await sessions.start(canvas, { name: body.name }) });
|
|
2724
|
+
}
|
|
2725
|
+
case "POST /api/session/stop": {
|
|
2726
|
+
const { canvas } = await readJson(req);
|
|
2727
|
+
if (canvas) return json(res, 200, { stopped: sessions.stop(canvas) });
|
|
2728
|
+
const any = sessions.list().length > 0;
|
|
2729
|
+
sessions.stopAll();
|
|
2730
|
+
return json(res, 200, { stopped: any });
|
|
2731
|
+
}
|
|
2732
|
+
case "POST /api/session/cursor": {
|
|
2733
|
+
const { canvas, x, y } = await readJson(req);
|
|
2734
|
+
const on = typeof x === "number" && typeof y === "number" && Number.isFinite(x) && Number.isFinite(y);
|
|
2735
|
+
sessions.get(canvas)?.cursor(on ? x : null, on ? y : null);
|
|
2736
|
+
return json(res, 200, { ok: true });
|
|
2737
|
+
}
|
|
2188
2738
|
case "POST /api/source": {
|
|
2189
2739
|
const { canvas, id } = await readJson(req);
|
|
2190
2740
|
const hit = ws.nodeSource(canvas, id);
|
|
@@ -2195,23 +2745,23 @@ async function startServer(ws) {
|
|
|
2195
2745
|
return json(res, 404, { error: `No route ${route}` });
|
|
2196
2746
|
}
|
|
2197
2747
|
function serveStatic(res, pathname) {
|
|
2198
|
-
let file =
|
|
2199
|
-
if (!inside(editorDir, file) || !fs6.existsSync(file) || fs6.statSync(file).isDirectory()) file =
|
|
2748
|
+
let file = path8.join(editorDir, pathname === "/" ? "index.html" : pathname);
|
|
2749
|
+
if (!inside(editorDir, file) || !fs6.existsSync(file) || fs6.statSync(file).isDirectory()) file = path8.join(editorDir, "index.html");
|
|
2200
2750
|
if (!fs6.existsSync(file)) {
|
|
2201
2751
|
res.writeHead(500, { "content-type": "text/plain" });
|
|
2202
2752
|
return res.end("Editor bundle missing. Run `pnpm build` in packages/truecanvas.");
|
|
2203
2753
|
}
|
|
2204
|
-
const ext =
|
|
2754
|
+
const ext = path8.extname(file);
|
|
2205
2755
|
const immutable = pathname.startsWith("/assets/");
|
|
2206
2756
|
res.writeHead(200, { "content-type": MIME[ext] ?? "application/octet-stream", "cache-control": immutable ? "public, max-age=31536000, immutable" : "no-cache" });
|
|
2207
2757
|
fs6.createReadStream(file).on("error", () => res.destroy()).pipe(res);
|
|
2208
2758
|
}
|
|
2209
2759
|
function serveShare(res, rest) {
|
|
2210
2760
|
const root = sharesDir(config.root);
|
|
2211
|
-
let file =
|
|
2212
|
-
if (rest.endsWith("/")) file =
|
|
2761
|
+
let file = path8.join(root, decodeURIComponent(rest));
|
|
2762
|
+
if (rest.endsWith("/")) file = path8.join(file, "index.html");
|
|
2213
2763
|
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[
|
|
2764
|
+
res.writeHead(200, { "content-type": MIME[path8.extname(file)] ?? "application/octet-stream", "cache-control": "no-cache", "x-content-type-options": "nosniff", "access-control-allow-origin": "*" });
|
|
2215
2765
|
fs6.createReadStream(file).on("error", () => res.destroy()).pipe(res);
|
|
2216
2766
|
}
|
|
2217
2767
|
const server = http.createServer(async (req, res) => {
|
|
@@ -2232,7 +2782,7 @@ async function startServer(ws) {
|
|
|
2232
2782
|
else res.end();
|
|
2233
2783
|
}
|
|
2234
2784
|
});
|
|
2235
|
-
const canvasDir =
|
|
2785
|
+
const canvasDir = path8.join(config.root, config.canvasDir);
|
|
2236
2786
|
fs6.mkdirSync(canvasDir, { recursive: true });
|
|
2237
2787
|
const pending = /* @__PURE__ */ new Map();
|
|
2238
2788
|
const onCanvasFile = (filename) => {
|
|
@@ -2276,12 +2826,12 @@ async function startServer(ws) {
|
|
|
2276
2826
|
};
|
|
2277
2827
|
watchCanvasDir();
|
|
2278
2828
|
setInterval(watchCanvasDir, 2e3).unref();
|
|
2279
|
-
const appDirAbs =
|
|
2829
|
+
const appDirAbs = path8.join(config.root, config.appDir);
|
|
2280
2830
|
if (config.framework === "next" && fs6.existsSync(appDirAbs)) {
|
|
2281
2831
|
const appPending = /* @__PURE__ */ new Map();
|
|
2282
2832
|
const appWatcher = fs6.watch(appDirAbs, { recursive: true }, (_event, filename) => {
|
|
2283
2833
|
if (!filename || !/(^|[\\/])(page|layout)\.[jt]sx$/.test(filename) || /^truecanvas[\\/]/.test(filename)) return;
|
|
2284
|
-
const rel =
|
|
2834
|
+
const rel = path8.join(config.appDir, filename).split(path8.sep).join("/");
|
|
2285
2835
|
clearTimeout(appPending.get(rel));
|
|
2286
2836
|
appPending.set(
|
|
2287
2837
|
rel,
|
|
@@ -2298,7 +2848,7 @@ async function startServer(ws) {
|
|
|
2298
2848
|
}
|
|
2299
2849
|
let catalogTimer;
|
|
2300
2850
|
for (const dir of componentRoots(config.components)) {
|
|
2301
|
-
const abs =
|
|
2851
|
+
const abs = path8.join(config.root, dir);
|
|
2302
2852
|
if (!fs6.existsSync(abs)) continue;
|
|
2303
2853
|
const componentWatcher = fs6.watch(abs, { recursive: true }, () => {
|
|
2304
2854
|
clearTimeout(catalogTimer);
|
|
@@ -2315,6 +2865,7 @@ async function startServer(ws) {
|
|
|
2315
2865
|
server.listen(config.port, "127.0.0.1", resolve);
|
|
2316
2866
|
});
|
|
2317
2867
|
const shutdown = async () => {
|
|
2868
|
+
sessions.stopAll();
|
|
2318
2869
|
commentSync.stop();
|
|
2319
2870
|
await shots.close();
|
|
2320
2871
|
for (const t of transports.values()) await t.close().catch(() => {
|
|
@@ -2335,8 +2886,8 @@ function dedent(code, col) {
|
|
|
2335
2886
|
return code.split("\n").map((l, i) => i > 0 && l.startsWith(pad) ? l.slice(col) : l).join("\n");
|
|
2336
2887
|
}
|
|
2337
2888
|
function inside(dir, file) {
|
|
2338
|
-
const rel =
|
|
2339
|
-
return !!rel && !rel.startsWith("..") && !
|
|
2889
|
+
const rel = path8.relative(dir, file);
|
|
2890
|
+
return !!rel && !rel.startsWith("..") && !path8.isAbsolute(rel);
|
|
2340
2891
|
}
|
|
2341
2892
|
|
|
2342
2893
|
// src/server/stdio.ts
|
|
@@ -2377,831 +2928,10 @@ async function runStdioBridge(mcpUrl) {
|
|
|
2377
2928
|
// src/cli/index.ts
|
|
2378
2929
|
import { createRequire } from "module";
|
|
2379
2930
|
|
|
2380
|
-
// src/hub/
|
|
2381
|
-
import http2 from "http";
|
|
2382
|
-
import fs10 from "fs";
|
|
2383
|
-
import os6 from "os";
|
|
2384
|
-
import path11 from "path";
|
|
2385
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
2386
|
-
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2387
|
-
import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2388
|
-
import { StreamableHTTPServerTransport as StreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
2389
|
-
import { Client as Client2 } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2390
|
-
import { StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
2391
|
-
import { CallToolRequestSchema as CallToolRequestSchema2, ListToolsRequestSchema as ListToolsRequestSchema2, isInitializeRequest as isInitializeRequest2 } from "@modelcontextprotocol/sdk/types.js";
|
|
2392
|
-
|
|
2393
|
-
// src/hub/projects.ts
|
|
2931
|
+
// src/hub/desktop.ts
|
|
2394
2932
|
import fs7 from "fs";
|
|
2395
2933
|
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");
|
|
2399
|
-
var ProjectStore = class {
|
|
2400
|
-
list = [];
|
|
2401
|
-
constructor() {
|
|
2402
|
-
try {
|
|
2403
|
-
this.list = JSON.parse(fs7.readFileSync(FILE, "utf8"));
|
|
2404
|
-
} catch {
|
|
2405
|
-
this.list = [];
|
|
2406
|
-
}
|
|
2407
|
-
}
|
|
2408
|
-
save() {
|
|
2409
|
-
fs7.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
2410
|
-
fs7.writeFileSync(FILE, JSON.stringify(this.list, null, 2));
|
|
2411
|
-
}
|
|
2412
|
-
all() {
|
|
2413
|
-
return [...this.list].sort((a, b) => b.lastOpened - a.lastOpened).map((p) => ({ ...p, ...inspect(p.path) }));
|
|
2414
|
-
}
|
|
2415
|
-
touch(dir) {
|
|
2416
|
-
const abs = path8.resolve(dir);
|
|
2417
|
-
const existing = this.list.find((p) => p.path === abs);
|
|
2418
|
-
if (existing) existing.lastOpened = Date.now();
|
|
2419
|
-
else this.list.push({ path: abs, name: projectName(abs), lastOpened: Date.now() });
|
|
2420
|
-
this.save();
|
|
2421
|
-
}
|
|
2422
|
-
forget(dir) {
|
|
2423
|
-
this.list = this.list.filter((p) => p.path !== path8.resolve(dir));
|
|
2424
|
-
this.save();
|
|
2425
|
-
}
|
|
2426
|
-
};
|
|
2427
|
-
function readPkg(dir) {
|
|
2428
|
-
try {
|
|
2429
|
-
return JSON.parse(fs7.readFileSync(path8.join(dir, "package.json"), "utf8"));
|
|
2430
|
-
} catch {
|
|
2431
|
-
return null;
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
function projectName(dir) {
|
|
2435
|
-
const pkg = readPkg(dir);
|
|
2436
|
-
const name = typeof pkg?.name === "string" ? pkg.name : "";
|
|
2437
|
-
return name && !name.startsWith("@") ? name : path8.basename(dir);
|
|
2438
|
-
}
|
|
2439
|
-
function inspect(dir) {
|
|
2440
|
-
const pkg = readPkg(dir);
|
|
2441
|
-
if (!pkg) return { next: false, ready: false, canvases: 0, exists: fs7.existsSync(dir) };
|
|
2442
|
-
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
2443
|
-
let canvases = 0;
|
|
2444
|
-
for (const d of ["canvas", "src/canvas"]) {
|
|
2445
|
-
try {
|
|
2446
|
-
canvases += fs7.readdirSync(path8.join(dir, d)).filter((f) => f.endsWith(".canvas.tsx")).length;
|
|
2447
|
-
} catch {
|
|
2448
|
-
}
|
|
2449
|
-
}
|
|
2450
|
-
return { next: "next" in deps || "vite" in deps && "react" in deps, ready: "truecanvas" in deps, canvases, exists: true };
|
|
2451
|
-
}
|
|
2452
|
-
function discover() {
|
|
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));
|
|
2455
|
-
const found = [];
|
|
2456
|
-
let visited = 0;
|
|
2457
|
-
const walk = (dir, depth) => {
|
|
2458
|
-
if (depth > 3 || visited > 3e3 || found.length > 60) return;
|
|
2459
|
-
visited++;
|
|
2460
|
-
let entries;
|
|
2461
|
-
try {
|
|
2462
|
-
entries = fs7.readdirSync(dir, { withFileTypes: true });
|
|
2463
|
-
} catch {
|
|
2464
|
-
return;
|
|
2465
|
-
}
|
|
2466
|
-
if (entries.some((e) => e.name === "package.json")) {
|
|
2467
|
-
const info = inspect(dir);
|
|
2468
|
-
if (info.next) found.push({ path: dir, name: projectName(dir), ready: info.ready });
|
|
2469
|
-
}
|
|
2470
|
-
for (const e of entries) {
|
|
2471
|
-
if (!e.isDirectory() || e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist" || e.name === "build") continue;
|
|
2472
|
-
walk(path8.join(dir, e.name), depth + 1);
|
|
2473
|
-
}
|
|
2474
|
-
};
|
|
2475
|
-
for (const r of roots) walk(r, 0);
|
|
2476
|
-
return found;
|
|
2477
|
-
}
|
|
2478
|
-
function listDirs(dir) {
|
|
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));
|
|
2481
|
-
return { path: abs, dirs };
|
|
2482
|
-
}
|
|
2483
|
-
|
|
2484
|
-
// src/hub/runner.ts
|
|
2485
|
-
import { execFile, spawn as spawn2 } from "child_process";
|
|
2486
|
-
import fs9 from "fs";
|
|
2487
|
-
import net from "net";
|
|
2488
|
-
import os5 from "os";
|
|
2489
|
-
import path10 from "path";
|
|
2490
|
-
|
|
2491
|
-
// src/cli/setup.ts
|
|
2492
|
-
import { spawn } from "child_process";
|
|
2493
|
-
import { createHash as createHash2 } from "crypto";
|
|
2494
|
-
import fs8 from "fs";
|
|
2495
|
-
import os4 from "os";
|
|
2496
2934
|
import path9 from "path";
|
|
2497
|
-
function addDevArgs(pm, spec) {
|
|
2498
|
-
return addArgs(pm, [spec], true);
|
|
2499
|
-
}
|
|
2500
|
-
function runScript(pm, script) {
|
|
2501
|
-
return pm === "npm" ? `npm run ${script}` : `${pm} ${script}`;
|
|
2502
|
-
}
|
|
2503
|
-
var execInherit = (cmd, args, cwd) => new Promise((resolve, reject) => {
|
|
2504
|
-
const safe = shellSafe(cmd, args);
|
|
2505
|
-
const p = spawn(safe.cmd, safe.args, { cwd, stdio: "inherit", env: toolEnv(), shell: safe.shell });
|
|
2506
|
-
p.on("error", reject);
|
|
2507
|
-
p.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`)));
|
|
2508
|
-
});
|
|
2509
|
-
function packageDir(cliFile) {
|
|
2510
|
-
return path9.resolve(path9.dirname(cliFile), "..");
|
|
2511
|
-
}
|
|
2512
|
-
function packageVersion(pkgDir) {
|
|
2513
|
-
try {
|
|
2514
|
-
return JSON.parse(fs8.readFileSync(path9.join(pkgDir, "package.json"), "utf8")).version;
|
|
2515
|
-
} catch {
|
|
2516
|
-
return "0.0.0";
|
|
2517
|
-
}
|
|
2518
|
-
}
|
|
2519
|
-
function isSourceCheckout(pkgDir) {
|
|
2520
|
-
return !pkgDir.split(path9.sep).includes("node_modules") && fs8.existsSync(path9.join(pkgDir, "src", "cli"));
|
|
2521
|
-
}
|
|
2522
|
-
async function packageSpec(pkgDir, exec) {
|
|
2523
|
-
if (process.env.TRUECANVAS_PACKAGE) return process.env.TRUECANVAS_PACKAGE;
|
|
2524
|
-
const version = packageVersion(pkgDir);
|
|
2525
|
-
if (!isSourceCheckout(pkgDir)) return `truecanvas@${version}`;
|
|
2526
|
-
const cacheDir = path9.join(os4.homedir(), ".cache", "truecanvas");
|
|
2527
|
-
fs8.mkdirSync(cacheDir, { recursive: true });
|
|
2528
|
-
const file = path9.join(cacheDir, `truecanvas-${version}.tgz`);
|
|
2529
|
-
await exec("npm", ["pack", "--silent", "--pack-destination", cacheDir], pkgDir);
|
|
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);
|
|
2533
|
-
return named;
|
|
2534
|
-
}
|
|
2535
|
-
function installedIn(root) {
|
|
2536
|
-
let declared = false;
|
|
2537
|
-
try {
|
|
2538
|
-
const pkg = JSON.parse(fs8.readFileSync(path9.join(root, "package.json"), "utf8"));
|
|
2539
|
-
declared = "truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies };
|
|
2540
|
-
} catch {
|
|
2541
|
-
}
|
|
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 };
|
|
2545
|
-
}
|
|
2546
|
-
function readPackage(root) {
|
|
2547
|
-
try {
|
|
2548
|
-
return JSON.parse(fs8.readFileSync(path9.join(root, "package.json"), "utf8"));
|
|
2549
|
-
} catch {
|
|
2550
|
-
return null;
|
|
2551
|
-
}
|
|
2552
|
-
}
|
|
2553
|
-
function depVersion(root, name) {
|
|
2554
|
-
const file = path9.join(root, "node_modules", name, "package.json");
|
|
2555
|
-
if (fs8.existsSync(file)) return packageVersion(path9.dirname(file));
|
|
2556
|
-
const pkg = readPackage(root);
|
|
2557
|
-
return pkg ? { ...pkg.dependencies, ...pkg.devDependencies }[name] ?? null : null;
|
|
2558
|
-
}
|
|
2559
|
-
function mergeJson(file, update) {
|
|
2560
|
-
let data2 = {};
|
|
2561
|
-
if (fs8.existsSync(file)) {
|
|
2562
|
-
try {
|
|
2563
|
-
data2 = JSON.parse(fs8.readFileSync(file, "utf8"));
|
|
2564
|
-
} catch {
|
|
2565
|
-
throw new Error(`${file} isn't valid JSON; fix it or remove it, then run connect again.`);
|
|
2566
|
-
}
|
|
2567
|
-
}
|
|
2568
|
-
const before = JSON.stringify(data2);
|
|
2569
|
-
update(data2);
|
|
2570
|
-
if (JSON.stringify(data2) === before) return "unchanged";
|
|
2571
|
-
fs8.mkdirSync(path9.dirname(file), { recursive: true });
|
|
2572
|
-
fs8.writeFileSync(file, `${JSON.stringify(data2, null, 2)}
|
|
2573
|
-
`);
|
|
2574
|
-
return "written";
|
|
2575
|
-
}
|
|
2576
|
-
function connectAgents(root, port, opts = {}) {
|
|
2577
|
-
const url = `http://localhost:${port}/mcp`;
|
|
2578
|
-
const out = { written: [], unchanged: [] };
|
|
2579
|
-
const add = (rel, update) => out[mergeJson(path9.join(root, rel), update)].push(rel);
|
|
2580
|
-
const servers = (d, key) => d[key] ??= {};
|
|
2581
|
-
add(".mcp.json", (d) => void (servers(d, "mcpServers").truecanvas = { type: "http", url }));
|
|
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 }));
|
|
2584
|
-
return out;
|
|
2585
|
-
}
|
|
2586
|
-
function hasAgentConfig(root, port) {
|
|
2587
|
-
try {
|
|
2588
|
-
const d = JSON.parse(fs8.readFileSync(path9.join(root, ".mcp.json"), "utf8"));
|
|
2589
|
-
const url = d?.mcpServers?.truecanvas?.url;
|
|
2590
|
-
return typeof url === "string" && (port === void 0 || url.includes(`:${port}/`));
|
|
2591
|
-
} catch {
|
|
2592
|
-
return false;
|
|
2593
|
-
}
|
|
2594
|
-
}
|
|
2595
|
-
|
|
2596
|
-
// src/hub/runner.ts
|
|
2597
|
-
function portFree(port) {
|
|
2598
|
-
return new Promise((resolve) => {
|
|
2599
|
-
const srv = net.createServer();
|
|
2600
|
-
srv.once("error", () => resolve(false));
|
|
2601
|
-
srv.listen(port, "127.0.0.1", () => srv.close(() => resolve(true)));
|
|
2602
|
-
});
|
|
2603
|
-
}
|
|
2604
|
-
async function freePort(from, taken) {
|
|
2605
|
-
for (let p = from; p < from + 200; p++) if (!taken.has(p) && await portFree(p)) return p;
|
|
2606
|
-
throw new Error("No free port");
|
|
2607
|
-
}
|
|
2608
|
-
var Runner = class {
|
|
2609
|
-
constructor(cli, onChange) {
|
|
2610
|
-
this.cli = cli;
|
|
2611
|
-
this.onChange = onChange;
|
|
2612
|
-
}
|
|
2613
|
-
cli;
|
|
2614
|
-
onChange;
|
|
2615
|
-
running = /* @__PURE__ */ new Map();
|
|
2616
|
-
opening = /* @__PURE__ */ new Map();
|
|
2617
|
-
ports = Promise.resolve();
|
|
2618
|
-
/** Opens a project; concurrent calls for the same folder share one start. */
|
|
2619
|
-
open(dir) {
|
|
2620
|
-
const existing = this.running.get(dir);
|
|
2621
|
-
if (existing && existing.status !== "failed") return Promise.resolve(existing);
|
|
2622
|
-
const pending = this.opening.get(dir);
|
|
2623
|
-
if (pending) return pending;
|
|
2624
|
-
const job = this.start(dir).finally(() => this.opening.delete(dir));
|
|
2625
|
-
this.opening.set(dir, job);
|
|
2626
|
-
return job;
|
|
2627
|
-
}
|
|
2628
|
-
/** Two ports, picked one project at a time so parallel opens never collide. */
|
|
2629
|
-
allocatePorts() {
|
|
2630
|
-
const next = this.ports.then(async () => {
|
|
2631
|
-
const taken = new Set([...this.running.values()].flatMap((r) => [r.editorPort, r.appPort]));
|
|
2632
|
-
const editorPort = await freePort(4810, taken);
|
|
2633
|
-
taken.add(editorPort);
|
|
2634
|
-
return [editorPort, await freePort(3010, taken)];
|
|
2635
|
-
});
|
|
2636
|
-
this.ports = next.catch(() => {
|
|
2637
|
-
});
|
|
2638
|
-
return next;
|
|
2639
|
-
}
|
|
2640
|
-
async start(dir) {
|
|
2641
|
-
if (!fs9.existsSync(path10.join(dir, "package.json"))) throw new Error(`${dir} isn't there anymore (or has no package.json).`);
|
|
2642
|
-
const [editorPort, appPort] = await this.allocatePorts();
|
|
2643
|
-
const proc = spawn2(process.execPath, [this.cli, "dev", "--no-open", "--port", String(editorPort), "--app", `http://localhost:${appPort}`], {
|
|
2644
|
-
cwd: dir,
|
|
2645
|
-
env: toolEnv({ FORCE_COLOR: "0", TRUECANVAS_HUB: "1" }),
|
|
2646
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
2647
|
-
// its own process group: closing the project also stops the next dev it started
|
|
2648
|
-
detached: process.platform !== "win32"
|
|
2649
|
-
});
|
|
2650
|
-
const run3 = { path: dir, editorPort, appPort, proc, status: "starting", log: [], startedAt: Date.now() };
|
|
2651
|
-
this.running.set(dir, run3);
|
|
2652
|
-
const capture2 = (chunk) => {
|
|
2653
|
-
for (const line of chunk.toString().split("\n")) if (line.trim()) run3.log.push(line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
2654
|
-
if (run3.log.length > 200) run3.log.splice(0, run3.log.length - 200);
|
|
2655
|
-
};
|
|
2656
|
-
proc.stdout.on("data", capture2);
|
|
2657
|
-
proc.stderr.on("data", capture2);
|
|
2658
|
-
proc.on("error", (err) => {
|
|
2659
|
-
run3.status = "failed";
|
|
2660
|
-
run3.error = err.message;
|
|
2661
|
-
this.onChange();
|
|
2662
|
-
});
|
|
2663
|
-
proc.on("exit", (code) => {
|
|
2664
|
-
if (this.running.get(dir) !== run3) return;
|
|
2665
|
-
if (run3.status === "starting" || code) {
|
|
2666
|
-
run3.status = "failed";
|
|
2667
|
-
run3.error = run3.log.slice(-6).join("\n") || `exited with ${code}`;
|
|
2668
|
-
} else this.running.delete(dir);
|
|
2669
|
-
this.onChange();
|
|
2670
|
-
});
|
|
2671
|
-
this.onChange();
|
|
2672
|
-
const until = Date.now() + 6e4;
|
|
2673
|
-
while (Date.now() < until && run3.status === "starting") {
|
|
2674
|
-
try {
|
|
2675
|
-
const res = await fetch(`http://127.0.0.1:${editorPort}/api/state`, { signal: AbortSignal.timeout(1e3) });
|
|
2676
|
-
if (res.ok) {
|
|
2677
|
-
run3.status = "ready";
|
|
2678
|
-
break;
|
|
2679
|
-
}
|
|
2680
|
-
} catch {
|
|
2681
|
-
}
|
|
2682
|
-
await new Promise((r) => setTimeout(r, 400));
|
|
2683
|
-
}
|
|
2684
|
-
if (run3.status === "starting") {
|
|
2685
|
-
run3.status = "failed";
|
|
2686
|
-
run3.error = "Truecanvas didn't start within a minute.";
|
|
2687
|
-
killTree(run3.proc, "SIGTERM");
|
|
2688
|
-
}
|
|
2689
|
-
this.onChange();
|
|
2690
|
-
return run3;
|
|
2691
|
-
}
|
|
2692
|
-
/** Stops a project and everything it started; resolves once it has exited (or after 5s). */
|
|
2693
|
-
close(dir) {
|
|
2694
|
-
const run3 = this.running.get(dir);
|
|
2695
|
-
if (!run3) return Promise.resolve();
|
|
2696
|
-
this.running.delete(dir);
|
|
2697
|
-
this.onChange();
|
|
2698
|
-
if (run3.proc.exitCode !== null || run3.proc.signalCode !== null) return Promise.resolve();
|
|
2699
|
-
return new Promise((resolve) => {
|
|
2700
|
-
const force = setTimeout(() => killTree(run3.proc, "SIGKILL"), 4e3);
|
|
2701
|
-
const done = setTimeout(resolve, 5e3);
|
|
2702
|
-
run3.proc.once("exit", () => {
|
|
2703
|
-
clearTimeout(force);
|
|
2704
|
-
clearTimeout(done);
|
|
2705
|
-
resolve();
|
|
2706
|
-
});
|
|
2707
|
-
killTree(run3.proc, "SIGTERM");
|
|
2708
|
-
});
|
|
2709
|
-
}
|
|
2710
|
-
closeAll() {
|
|
2711
|
-
return Promise.all([...this.running.keys()].map((dir) => this.close(dir))).then(() => {
|
|
2712
|
-
});
|
|
2713
|
-
}
|
|
2714
|
-
/** Resident memory (MB) of each project's process tree. */
|
|
2715
|
-
memory() {
|
|
2716
|
-
return new Promise((resolve) => {
|
|
2717
|
-
execFile("ps", ["-eo", "pid=,ppid=,rss="], (err, out) => {
|
|
2718
|
-
if (err) return resolve({});
|
|
2719
|
-
const rows = out.trim().split("\n").map((l) => l.trim().split(/\s+/).map(Number));
|
|
2720
|
-
const kids = /* @__PURE__ */ new Map();
|
|
2721
|
-
const rss = /* @__PURE__ */ new Map();
|
|
2722
|
-
for (const [pid, ppid, kb] of rows) {
|
|
2723
|
-
rss.set(pid, kb);
|
|
2724
|
-
kids.set(ppid, [...kids.get(ppid) ?? [], pid]);
|
|
2725
|
-
}
|
|
2726
|
-
const total = (pid) => (rss.get(pid) ?? 0) + (kids.get(pid) ?? []).reduce((s, k) => s + total(k), 0);
|
|
2727
|
-
const outMap = {};
|
|
2728
|
-
for (const [dir, run3] of this.running) if (run3.proc.pid) outMap[dir] = Math.round(total(run3.proc.pid) / 1024);
|
|
2729
|
-
resolve(outMap);
|
|
2730
|
-
});
|
|
2731
|
-
});
|
|
2732
|
-
}
|
|
2733
|
-
};
|
|
2734
|
-
function killTree(proc, signal) {
|
|
2735
|
-
try {
|
|
2736
|
-
if (process.platform === "win32" && proc.pid) spawn2("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { stdio: "ignore" }).on("error", () => proc.kill());
|
|
2737
|
-
else if (proc.pid) process.kill(-proc.pid, signal);
|
|
2738
|
-
else proc.kill(signal);
|
|
2739
|
-
} catch {
|
|
2740
|
-
proc.kill(signal);
|
|
2741
|
-
}
|
|
2742
|
-
}
|
|
2743
|
-
var jobs = /* @__PURE__ */ new Map();
|
|
2744
|
-
var jobSeq = 0;
|
|
2745
|
-
function getJob(id) {
|
|
2746
|
-
return jobs.get(id);
|
|
2747
|
-
}
|
|
2748
|
-
async function install(job, cmd, args, cwd) {
|
|
2749
|
-
try {
|
|
2750
|
-
await run2(job, cmd, args, cwd);
|
|
2751
|
-
} catch (err) {
|
|
2752
|
-
const ignoredBuilds = job.log.some((l) => l.includes("ERR_PNPM_IGNORED_BUILDS"));
|
|
2753
|
-
const installed = fs9.existsSync(path10.join(cwd, "node_modules", "truecanvas", "package.json"));
|
|
2754
|
-
if (!(ignoredBuilds && installed)) throw err;
|
|
2755
|
-
job.log.push("(pnpm only complained about other packages' build scripts; Truecanvas is installed, continuing)");
|
|
2756
|
-
}
|
|
2757
|
-
}
|
|
2758
|
-
function run2(job, cmd, args, cwd) {
|
|
2759
|
-
job.log.push(`$ ${cmd} ${args.join(" ")}`);
|
|
2760
|
-
return new Promise((resolve, reject) => {
|
|
2761
|
-
const safe = shellSafe(cmd, args);
|
|
2762
|
-
const p = spawn2(safe.cmd, safe.args, { cwd, env: toolEnv({ CI: "1", FORCE_COLOR: "0" }), stdio: ["ignore", "pipe", "pipe"], shell: safe.shell });
|
|
2763
|
-
const capture2 = (c2) => {
|
|
2764
|
-
for (const line of c2.toString().split(/\r?\n/)) if (line.trim()) job.log.push(line.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, ""));
|
|
2765
|
-
if (job.log.length > 300) job.log.splice(0, job.log.length - 300);
|
|
2766
|
-
};
|
|
2767
|
-
p.stdout.on("data", capture2);
|
|
2768
|
-
p.stderr.on("data", capture2);
|
|
2769
|
-
p.on("error", reject);
|
|
2770
|
-
p.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} exited with ${code}`)));
|
|
2771
|
-
});
|
|
2772
|
-
}
|
|
2773
|
-
function startJob(title, work) {
|
|
2774
|
-
const job = { id: String(++jobSeq), title, status: "running", log: [] };
|
|
2775
|
-
jobs.set(job.id, job);
|
|
2776
|
-
work(job).then((result) => {
|
|
2777
|
-
job.status = "done";
|
|
2778
|
-
if (result) job.result = result;
|
|
2779
|
-
}).catch((err) => {
|
|
2780
|
-
job.status = "failed";
|
|
2781
|
-
job.log.push(`\u2717 ${err.message}`);
|
|
2782
|
-
});
|
|
2783
|
-
return job;
|
|
2784
|
-
}
|
|
2785
|
-
function packageManager(dir) {
|
|
2786
|
-
const pm = detectPm(dir);
|
|
2787
|
-
return { cmd: pm, add: addArgs(pm, [], true) };
|
|
2788
|
-
}
|
|
2789
|
-
async function preferredPm() {
|
|
2790
|
-
const { ok } = await capture("pnpm", ["--version"], os5.homedir());
|
|
2791
|
-
return ok ? "pnpm" : "npm";
|
|
2792
|
-
}
|
|
2793
|
-
function setupJob(dir, pkgDir, cli) {
|
|
2794
|
-
return startJob(`Set up ${path10.basename(dir)}`, async (job) => {
|
|
2795
|
-
const pm = packageManager(dir);
|
|
2796
|
-
const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
|
|
2797
|
-
await install(job, pm.cmd, [...pm.add, source], dir);
|
|
2798
|
-
await run2(job, process.execPath, [cli, "init"], dir);
|
|
2799
|
-
return dir;
|
|
2800
|
-
});
|
|
2801
|
-
}
|
|
2802
|
-
function createJob(name, parent, pkgDir, cli) {
|
|
2803
|
-
const safe = name.trim().toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2804
|
-
const parentDir = path10.resolve(parent.replace(/^~(?=$|[\\/])/, os5.homedir()));
|
|
2805
|
-
const dir = path10.join(parentDir, safe);
|
|
2806
|
-
return startJob(`Create ${safe}`, async (job) => {
|
|
2807
|
-
if (!safe) throw new Error("Pick a project name.");
|
|
2808
|
-
if (fs9.existsSync(dir)) throw new Error(`${dir} already exists.`);
|
|
2809
|
-
fs9.mkdirSync(parentDir, { recursive: true });
|
|
2810
|
-
const pm = await preferredPm();
|
|
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);
|
|
2812
|
-
const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
|
|
2813
|
-
await install(job, pm, addArgs(pm, [source], true), dir);
|
|
2814
|
-
await run2(job, process.execPath, [cli, "init"], dir);
|
|
2815
|
-
return dir;
|
|
2816
|
-
});
|
|
2817
|
-
}
|
|
2818
|
-
function capture(cmd, args, cwd) {
|
|
2819
|
-
return new Promise((resolve) => {
|
|
2820
|
-
const safe = shellSafe(cmd, args);
|
|
2821
|
-
execFile(
|
|
2822
|
-
safe.cmd,
|
|
2823
|
-
safe.args,
|
|
2824
|
-
{ shell: safe.shell, cwd, timeout: 6e4, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" } },
|
|
2825
|
-
(err, stdout, stderr) => resolve({ ok: !err, out: err ? (stderr || err.message).trim() : stdout })
|
|
2826
|
-
);
|
|
2827
|
-
});
|
|
2828
|
-
}
|
|
2829
|
-
function cloneJob(repo, parent, pkgDir, cli) {
|
|
2830
|
-
const parentDir = path10.resolve(parent.replace(/^~(?=$|[\\/])/, os5.homedir()));
|
|
2831
|
-
const name = repo.replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
|
|
2832
|
-
const dir = path10.join(parentDir, name);
|
|
2833
|
-
return startJob(`Clone ${name}`, async (job) => {
|
|
2834
|
-
if (!name || !/^[\w.-]+$/.test(name)) throw new Error("Pick a repository (owner/name or a git URL).");
|
|
2835
|
-
if (fs9.existsSync(dir)) throw new Error(`${dir} already exists. Open it with Open folder instead.`);
|
|
2836
|
-
fs9.mkdirSync(parentDir, { recursive: true });
|
|
2837
|
-
const url = /^(https?:|git@|ssh:|file:)/.test(repo);
|
|
2838
|
-
if (url) await run2(job, "git", ["clone", repo, dir], parentDir);
|
|
2839
|
-
else await run2(job, "gh", ["repo", "clone", repo, dir], parentDir);
|
|
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.");
|
|
2841
|
-
const pm = packageManager(dir);
|
|
2842
|
-
await install(job, pm.cmd, ["install"], dir).catch(async (err) => {
|
|
2843
|
-
if (!fs9.existsSync(path10.join(dir, "node_modules"))) throw err;
|
|
2844
|
-
});
|
|
2845
|
-
const pkg = JSON.parse(fs9.readFileSync(path10.join(dir, "package.json"), "utf8"));
|
|
2846
|
-
if (!("truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies })) {
|
|
2847
|
-
const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
|
|
2848
|
-
await install(job, pm.cmd, [...pm.add, source], dir);
|
|
2849
|
-
await run2(job, process.execPath, [cli, "init"], dir);
|
|
2850
|
-
} else job.log.push("Truecanvas is already part of this project.");
|
|
2851
|
-
return dir;
|
|
2852
|
-
});
|
|
2853
|
-
}
|
|
2854
|
-
|
|
2855
|
-
// src/hub/server.ts
|
|
2856
|
-
var MIME2 = {
|
|
2857
|
-
".html": "text/html; charset=utf-8",
|
|
2858
|
-
".js": "text/javascript; charset=utf-8",
|
|
2859
|
-
".css": "text/css; charset=utf-8",
|
|
2860
|
-
".svg": "image/svg+xml",
|
|
2861
|
-
".woff2": "font/woff2",
|
|
2862
|
-
".png": "image/png"
|
|
2863
|
-
};
|
|
2864
|
-
var HUB_INSTRUCTIONS = `Truecanvas hub: several projects, each a Next.js or Vite + React app with canvases of real React components.
|
|
2865
|
-
Your tools act on the current project (the active tab in the Truecanvas window, unless you pick one with open_project).
|
|
2866
|
-
Use list_projects to see them. All other tools are the regular Truecanvas canvas tools for that project.`;
|
|
2867
|
-
async function startHub(opts) {
|
|
2868
|
-
const store = new ProjectStore();
|
|
2869
|
-
const editorDir = fileURLToPath2(new URL("./editor/", import.meta.url));
|
|
2870
|
-
const pkgDir = path11.resolve(path11.dirname(opts.cli), "..");
|
|
2871
|
-
const origin = `http://localhost:${opts.port}`;
|
|
2872
|
-
const allowedHosts = /* @__PURE__ */ new Set([`localhost:${opts.port}`, `127.0.0.1:${opts.port}`]);
|
|
2873
|
-
const clients = /* @__PURE__ */ new Set();
|
|
2874
|
-
let active = null;
|
|
2875
|
-
const broadcast = () => {
|
|
2876
|
-
for (const res of clients) res.write(`data: {"type":"state"}
|
|
2877
|
-
|
|
2878
|
-
`);
|
|
2879
|
-
for (const fn of toolListeners) fn();
|
|
2880
|
-
};
|
|
2881
|
-
const runner = new Runner(opts.cli, broadcast);
|
|
2882
|
-
setInterval(() => {
|
|
2883
|
-
for (const res of clients) res.write(": ping\n\n");
|
|
2884
|
-
}, 2e4).unref();
|
|
2885
|
-
let focus = null;
|
|
2886
|
-
async function state() {
|
|
2887
|
-
const memory = await runner.memory();
|
|
2888
|
-
return {
|
|
2889
|
-
origin,
|
|
2890
|
-
active,
|
|
2891
|
-
focus,
|
|
2892
|
-
projects: store.all().map((p) => {
|
|
2893
|
-
const r = runner.running.get(p.path);
|
|
2894
|
-
return { ...p, running: r ? { status: r.status, editor: `http://localhost:${r.editorPort}`, app: `http://localhost:${r.appPort}`, error: r.error, memory: memory[p.path] ?? null } : null };
|
|
2895
|
-
})
|
|
2896
|
-
};
|
|
2897
|
-
}
|
|
2898
|
-
const toolListeners = /* @__PURE__ */ new Set();
|
|
2899
|
-
const transports = /* @__PURE__ */ new Map();
|
|
2900
|
-
const lastUse = /* @__PURE__ */ new Map();
|
|
2901
|
-
setInterval(() => {
|
|
2902
|
-
for (const [id, at] of lastUse) {
|
|
2903
|
-
if (Date.now() - at < 60 * 6e4) continue;
|
|
2904
|
-
lastUse.delete(id);
|
|
2905
|
-
void transports.get(id)?.close();
|
|
2906
|
-
}
|
|
2907
|
-
}, 5 * 6e4).unref();
|
|
2908
|
-
function createProxy() {
|
|
2909
|
-
const server2 = new Server2({ name: "truecanvas", version: "0.1.0" }, { capabilities: { tools: { listChanged: true } }, instructions: HUB_INSTRUCTIONS });
|
|
2910
|
-
let current = null;
|
|
2911
|
-
const upstreams = /* @__PURE__ */ new Map();
|
|
2912
|
-
const target = () => current ?? active;
|
|
2913
|
-
const drop = (dir) => {
|
|
2914
|
-
const hit = upstreams.get(dir);
|
|
2915
|
-
upstreams.delete(dir);
|
|
2916
|
-
void hit?.client.then((c2) => c2.close()).catch(() => {
|
|
2917
|
-
});
|
|
2918
|
-
};
|
|
2919
|
-
const upstream = (dir) => {
|
|
2920
|
-
const run3 = runner.running.get(dir);
|
|
2921
|
-
if (!run3 || run3.status !== "ready") return null;
|
|
2922
|
-
const hit = upstreams.get(dir);
|
|
2923
|
-
if (hit && hit.pid === run3.proc.pid) return hit.client;
|
|
2924
|
-
if (hit) drop(dir);
|
|
2925
|
-
const info = server2.getClientVersion();
|
|
2926
|
-
const client = new Client2({ name: info?.name ?? "agent", version: info?.version ?? "0.0.0" });
|
|
2927
|
-
const c2 = client.connect(new StreamableHTTPClientTransport2(new URL(`http://127.0.0.1:${run3.editorPort}/mcp`))).then(() => client);
|
|
2928
|
-
c2.catch(() => upstreams.delete(dir));
|
|
2929
|
-
upstreams.set(dir, { pid: run3.proc.pid, client: c2 });
|
|
2930
|
-
return c2;
|
|
2931
|
-
};
|
|
2932
|
-
const withUpstream = async (dir, fn) => {
|
|
2933
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2934
|
-
const up = upstream(dir);
|
|
2935
|
-
if (!up) return null;
|
|
2936
|
-
try {
|
|
2937
|
-
return await fn(await up);
|
|
2938
|
-
} catch (err) {
|
|
2939
|
-
drop(dir);
|
|
2940
|
-
const lost = /session|not connected|ECONNREFUSED|ECONNRESET|fetch failed|404/i.test(err.message ?? "");
|
|
2941
|
-
if (attempt === 1 || !lost) throw err;
|
|
2942
|
-
}
|
|
2943
|
-
}
|
|
2944
|
-
return null;
|
|
2945
|
-
};
|
|
2946
|
-
const notify = () => void server2.sendToolListChanged().catch(() => {
|
|
2947
|
-
});
|
|
2948
|
-
toolListeners.add(notify);
|
|
2949
|
-
server2.onclose = () => {
|
|
2950
|
-
toolListeners.delete(notify);
|
|
2951
|
-
for (const dir of [...upstreams.keys()]) drop(dir);
|
|
2952
|
-
};
|
|
2953
|
-
const hubTools = [
|
|
2954
|
-
{ name: "list_projects", description: "Projects known to the Truecanvas hub, which are open, and which one your tools act on.", inputSchema: { type: "object", properties: {} } },
|
|
2955
|
-
{
|
|
2956
|
-
name: "open_project",
|
|
2957
|
-
description: "Open a project (starts its app if needed) and make it the target of your tools.",
|
|
2958
|
-
inputSchema: { type: "object", properties: { project: { type: "string", description: "Project name or path." } }, required: ["project"] }
|
|
2959
|
-
}
|
|
2960
|
-
];
|
|
2961
|
-
server2.setRequestHandler(ListToolsRequestSchema2, async () => {
|
|
2962
|
-
const dir = target();
|
|
2963
|
-
const projectTools = dir ? (await withUpstream(dir, (c2) => c2.listTools()).catch(() => null))?.tools ?? [] : [];
|
|
2964
|
-
return { tools: [...hubTools, ...projectTools] };
|
|
2965
|
-
});
|
|
2966
|
-
server2.setRequestHandler(CallToolRequestSchema2, async (req) => {
|
|
2967
|
-
const text = (t, isError = false) => ({ content: [{ type: "text", text: t }], isError });
|
|
2968
|
-
if (req.params.name === "list_projects") {
|
|
2969
|
-
const s = await state();
|
|
2970
|
-
const lines = s.projects.map((p) => `${p.path === target() ? "\u2192 " : " "}${p.name} (${p.path})${p.running ? ` [${p.running.status}]` : ""}${p.ready ? "" : " (Truecanvas not set up)"}`);
|
|
2971
|
-
return text(lines.join("\n") || "No projects yet. The user can add some from the Dashboard.");
|
|
2972
|
-
}
|
|
2973
|
-
if (req.params.name === "open_project") {
|
|
2974
|
-
const q = String(req.params.arguments?.project ?? "").toLowerCase();
|
|
2975
|
-
const p = store.all().find((x) => x.name.toLowerCase() === q || x.path.toLowerCase() === q || path11.basename(x.path).toLowerCase() === q);
|
|
2976
|
-
if (!p) return text(`No project "${q}". Use list_projects.`, true);
|
|
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);
|
|
2978
|
-
store.touch(p.path);
|
|
2979
|
-
const run3 = await runner.open(p.path);
|
|
2980
|
-
if (run3.status !== "ready") return text(`Couldn't start ${p.name}: ${run3.error}`, true);
|
|
2981
|
-
current = p.path;
|
|
2982
|
-
notify();
|
|
2983
|
-
return text(`\u2713 ${p.name} is open. Your tools now act on it.`);
|
|
2984
|
-
}
|
|
2985
|
-
const dir = target();
|
|
2986
|
-
const out = dir ? await withUpstream(dir, (c2) => c2.callTool(req.params, void 0, { timeout: 18e4 })) : null;
|
|
2987
|
-
if (!out) return text("No project is open. Use list_projects and open_project, or open one in the Truecanvas window.", true);
|
|
2988
|
-
return out;
|
|
2989
|
-
});
|
|
2990
|
-
return server2;
|
|
2991
|
-
}
|
|
2992
|
-
async function handleMcp(req, res) {
|
|
2993
|
-
const sessionId = req.headers["mcp-session-id"];
|
|
2994
|
-
const body = req.method === "POST" ? await readJson(req) : void 0;
|
|
2995
|
-
let transport = sessionId ? transports.get(sessionId) : void 0;
|
|
2996
|
-
if (sessionId && transport) lastUse.set(sessionId, Date.now());
|
|
2997
|
-
if (!transport) {
|
|
2998
|
-
if (req.method !== "POST" || !isInitializeRequest2(body)) return json2(res, 400, { jsonrpc: "2.0", error: { code: -32e3, message: "No valid MCP session." }, id: null });
|
|
2999
|
-
const t = new StreamableHTTPServerTransport2({
|
|
3000
|
-
sessionIdGenerator: () => randomUUID2(),
|
|
3001
|
-
onsessioninitialized: (id) => {
|
|
3002
|
-
transports.set(id, t);
|
|
3003
|
-
lastUse.set(id, Date.now());
|
|
3004
|
-
}
|
|
3005
|
-
});
|
|
3006
|
-
t.onclose = () => {
|
|
3007
|
-
if (t.sessionId) {
|
|
3008
|
-
transports.delete(t.sessionId);
|
|
3009
|
-
lastUse.delete(t.sessionId);
|
|
3010
|
-
}
|
|
3011
|
-
};
|
|
3012
|
-
await createProxy().connect(t);
|
|
3013
|
-
transport = t;
|
|
3014
|
-
}
|
|
3015
|
-
await transport.handleRequest(req, res, body);
|
|
3016
|
-
}
|
|
3017
|
-
async function api(req, res, url) {
|
|
3018
|
-
const route = `${req.method} ${url.pathname}`;
|
|
3019
|
-
const body = req.method === "POST" ? await readJson(req) : {};
|
|
3020
|
-
switch (route) {
|
|
3021
|
-
case "GET /api/hub/state":
|
|
3022
|
-
return json2(res, 200, await state());
|
|
3023
|
-
case "GET /api/hub/discover": {
|
|
3024
|
-
const known = new Set(store.all().map((p) => p.path));
|
|
3025
|
-
return json2(res, 200, { found: discover().filter((f) => !known.has(f.path)) });
|
|
3026
|
-
}
|
|
3027
|
-
case "GET /api/hub/icon": {
|
|
3028
|
-
const icon = findIcon(url.searchParams.get("path") ?? "");
|
|
3029
|
-
if (!icon) return json2(res, 404, { error: "No icon" });
|
|
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);
|
|
3032
|
-
}
|
|
3033
|
-
case "GET /api/hub/ls":
|
|
3034
|
-
return json2(res, 200, listDirs(url.searchParams.get("path") || "~"));
|
|
3035
|
-
case "GET /api/hub/events":
|
|
3036
|
-
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
3037
|
-
res.write(": connected\n\n");
|
|
3038
|
-
clients.add(res);
|
|
3039
|
-
req.on("close", () => clients.delete(res));
|
|
3040
|
-
return;
|
|
3041
|
-
case "POST /api/hub/add": {
|
|
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." });
|
|
3044
|
-
store.touch(dir);
|
|
3045
|
-
broadcast();
|
|
3046
|
-
return json2(res, 200, { path: dir, ready: store.all().find((p) => p.path === dir)?.ready ?? false });
|
|
3047
|
-
}
|
|
3048
|
-
case "POST /api/hub/open": {
|
|
3049
|
-
store.touch(body.path);
|
|
3050
|
-
active = body.path;
|
|
3051
|
-
if (body.focus) focus = { path: body.path, at: Date.now() };
|
|
3052
|
-
let run3;
|
|
3053
|
-
try {
|
|
3054
|
-
run3 = await runner.open(body.path);
|
|
3055
|
-
} catch (err) {
|
|
3056
|
-
broadcast();
|
|
3057
|
-
return json2(res, 400, { error: err.message });
|
|
3058
|
-
}
|
|
3059
|
-
broadcast();
|
|
3060
|
-
return json2(res, 200, { status: run3.status, editor: `http://localhost:${run3.editorPort}`, error: run3.error });
|
|
3061
|
-
}
|
|
3062
|
-
case "POST /api/hub/close":
|
|
3063
|
-
runner.close(body.path);
|
|
3064
|
-
if (active === body.path) active = null;
|
|
3065
|
-
broadcast();
|
|
3066
|
-
return json2(res, 200, { ok: true });
|
|
3067
|
-
case "POST /api/hub/active":
|
|
3068
|
-
active = body.path || null;
|
|
3069
|
-
broadcast();
|
|
3070
|
-
return json2(res, 200, { ok: true });
|
|
3071
|
-
case "POST /api/hub/forget":
|
|
3072
|
-
runner.close(body.path);
|
|
3073
|
-
store.forget(body.path);
|
|
3074
|
-
broadcast();
|
|
3075
|
-
return json2(res, 200, { ok: true });
|
|
3076
|
-
case "POST /api/hub/setup":
|
|
3077
|
-
return json2(res, 200, setupJob(body.path, pkgDir, opts.cli));
|
|
3078
|
-
case "POST /api/hub/create":
|
|
3079
|
-
return json2(res, 200, createJob(body.name, body.parent, pkgDir, opts.cli));
|
|
3080
|
-
// ---------- GitHub ----------
|
|
3081
|
-
case "GET /api/hub/github/repos": {
|
|
3082
|
-
const r = await capture("gh", ["repo", "list", "--limit", "100", "--json", "nameWithOwner,description,updatedAt,isPrivate"], os6.homedir());
|
|
3083
|
-
if (!r.ok) return json2(res, 200, { gh: false, error: r.out.split("\n")[0], repos: [] });
|
|
3084
|
-
const repos = JSON.parse(r.out).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
3085
|
-
return json2(res, 200, { gh: true, repos });
|
|
3086
|
-
}
|
|
3087
|
-
case "POST /api/hub/clone":
|
|
3088
|
-
return json2(res, 200, cloneJob(body.repo, body.parent || "~", pkgDir, opts.cli));
|
|
3089
|
-
case "GET /api/hub/prs": {
|
|
3090
|
-
const dir = url.searchParams.get("path") ?? "";
|
|
3091
|
-
const r = await capture("gh", ["pr", "list", "--limit", "30", "--json", "number,title,headRefName,author,updatedAt,isDraft,url"], dir);
|
|
3092
|
-
if (!r.ok) {
|
|
3093
|
-
const msg = /no git remotes|not a git repository|none of the git remotes/i.test(r.out) ? "This project isn't on GitHub yet. Publish it from the Git panel (branch pill) first." : /gh auth login|not logged/i.test(r.out) ? "Sign in to GitHub first: run gh auth login in a terminal." : r.out.split("\n")[0];
|
|
3094
|
-
return json2(res, 200, { error: msg, prs: [] });
|
|
3095
|
-
}
|
|
3096
|
-
return json2(res, 200, { prs: JSON.parse(r.out) });
|
|
3097
|
-
}
|
|
3098
|
-
case "POST /api/hub/pr-checkout": {
|
|
3099
|
-
const r = await capture("gh", ["pr", "checkout", String(Number(body.number))], body.path);
|
|
3100
|
-
if (!r.ok) {
|
|
3101
|
-
const dirty = /local changes|would be overwritten|uncommitted/i.test(r.out);
|
|
3102
|
-
return json2(res, 400, { error: dirty ? "This project has uncommitted changes. Commit them (Git panel) before opening another pull request." : r.out.split("\n").slice(-2).join(" ") });
|
|
3103
|
-
}
|
|
3104
|
-
store.touch(body.path);
|
|
3105
|
-
active = body.path;
|
|
3106
|
-
await runner.open(body.path);
|
|
3107
|
-
broadcast();
|
|
3108
|
-
return json2(res, 200, { ok: true });
|
|
3109
|
-
}
|
|
3110
|
-
case "POST /api/hub/quit":
|
|
3111
|
-
json2(res, 200, { ok: true });
|
|
3112
|
-
void runner.closeAll().finally(() => process.exit(0));
|
|
3113
|
-
return;
|
|
3114
|
-
}
|
|
3115
|
-
const job = /^GET \/api\/hub\/jobs\/(\d+)$/.exec(route);
|
|
3116
|
-
if (job) {
|
|
3117
|
-
const j = getJob(job[1]);
|
|
3118
|
-
if (j?.status === "done" && j.result) {
|
|
3119
|
-
store.touch(j.result);
|
|
3120
|
-
broadcast();
|
|
3121
|
-
}
|
|
3122
|
-
return j ? json2(res, 200, j) : json2(res, 404, { error: "No such job" });
|
|
3123
|
-
}
|
|
3124
|
-
return json2(res, 404, { error: `No route ${route}` });
|
|
3125
|
-
}
|
|
3126
|
-
function serveStatic(res, pathname) {
|
|
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)) {
|
|
3130
|
-
res.writeHead(500, { "content-type": "text/plain" });
|
|
3131
|
-
return res.end("Hub bundle missing. Run `pnpm build`.");
|
|
3132
|
-
}
|
|
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);
|
|
3135
|
-
}
|
|
3136
|
-
const server = http2.createServer(async (req, res) => {
|
|
3137
|
-
try {
|
|
3138
|
-
if (!allowedHosts.has(req.headers.host ?? "")) return json2(res, 403, { error: "Forbidden host" });
|
|
3139
|
-
const reqOrigin = req.headers.origin;
|
|
3140
|
-
if (reqOrigin && reqOrigin !== origin && reqOrigin !== `http://127.0.0.1:${opts.port}`) return json2(res, 403, { error: "Forbidden origin" });
|
|
3141
|
-
const url = new URL(req.url ?? "/", origin);
|
|
3142
|
-
if (url.pathname === "/mcp") return await handleMcp(req, res);
|
|
3143
|
-
if (url.pathname.startsWith("/api/")) return await api(req, res, url);
|
|
3144
|
-
if (req.method === "GET") return serveStatic(res, url.pathname);
|
|
3145
|
-
json2(res, 405, { error: "Method not allowed" });
|
|
3146
|
-
} catch (err) {
|
|
3147
|
-
if (!res.headersSent) json2(res, err instanceof EditError ? 400 : 500, { error: err.message });
|
|
3148
|
-
else res.end();
|
|
3149
|
-
}
|
|
3150
|
-
});
|
|
3151
|
-
await new Promise((resolve, reject) => {
|
|
3152
|
-
server.once("error", reject);
|
|
3153
|
-
server.listen(opts.port, "127.0.0.1", resolve);
|
|
3154
|
-
});
|
|
3155
|
-
const shutdown = () => {
|
|
3156
|
-
server.close();
|
|
3157
|
-
return runner.closeAll();
|
|
3158
|
-
};
|
|
3159
|
-
const exit = () => void shutdown().finally(() => process.exit(0));
|
|
3160
|
-
process.once("SIGINT", exit);
|
|
3161
|
-
process.once("SIGTERM", exit);
|
|
3162
|
-
return { origin, shutdown };
|
|
3163
|
-
}
|
|
3164
|
-
var ICON_MIME = { ".ico": "image/x-icon", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
|
|
3165
|
-
function findIcon(dir) {
|
|
3166
|
-
if (!dir || !fs10.existsSync(path11.join(dir, "package.json"))) return null;
|
|
3167
|
-
const names = ["icon.svg", "icon.png", "favicon.svg", "favicon.ico", "icon.ico", "apple-icon.png", "favicon.png", "logo.svg", "icon.jpg"];
|
|
3168
|
-
const roots = ["app", "src/app", "public", "static", "assets"];
|
|
3169
|
-
for (const root of roots) {
|
|
3170
|
-
for (const name of names) {
|
|
3171
|
-
const file = path11.join(dir, root, name);
|
|
3172
|
-
if (fs10.existsSync(file) && fs10.statSync(file).size < 2e6) return file;
|
|
3173
|
-
}
|
|
3174
|
-
}
|
|
3175
|
-
for (const root of ["app", "src/app"]) {
|
|
3176
|
-
let entries = [];
|
|
3177
|
-
try {
|
|
3178
|
-
entries = fs10.readdirSync(path11.join(dir, root), { withFileTypes: true });
|
|
3179
|
-
} catch {
|
|
3180
|
-
continue;
|
|
3181
|
-
}
|
|
3182
|
-
for (const e of entries) {
|
|
3183
|
-
if (!e.isDirectory()) continue;
|
|
3184
|
-
for (const name of names.slice(0, 4)) {
|
|
3185
|
-
const file = path11.join(dir, root, e.name, name);
|
|
3186
|
-
if (fs10.existsSync(file)) return file;
|
|
3187
|
-
}
|
|
3188
|
-
}
|
|
3189
|
-
}
|
|
3190
|
-
return null;
|
|
3191
|
-
}
|
|
3192
|
-
function json2(res, status, body) {
|
|
3193
|
-
res.writeHead(status, { "content-type": "application/json" });
|
|
3194
|
-
res.end(JSON.stringify(body));
|
|
3195
|
-
}
|
|
3196
|
-
function inside2(dir, file) {
|
|
3197
|
-
const rel = path11.relative(dir, file);
|
|
3198
|
-
return !!rel && !rel.startsWith("..") && !path11.isAbsolute(rel);
|
|
3199
|
-
}
|
|
3200
|
-
|
|
3201
|
-
// src/hub/desktop.ts
|
|
3202
|
-
import fs11 from "fs";
|
|
3203
|
-
import os7 from "os";
|
|
3204
|
-
import path12 from "path";
|
|
3205
2935
|
var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
|
3206
2936
|
<defs>
|
|
3207
2937
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff9466"/><stop offset="1" stop-color="#ea6a3c"/></linearGradient>
|
|
@@ -3215,15 +2945,15 @@ var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
|
|
3215
2945
|
<path fill="#f6f5f1" fill-rule="evenodd" d="M236 236h552v168H236z M428 316h168v472H428z"/>
|
|
3216
2946
|
</svg>
|
|
3217
2947
|
`;
|
|
3218
|
-
var data = () => process.env.XDG_DATA_HOME ??
|
|
2948
|
+
var data = () => process.env.XDG_DATA_HOME ?? path9.join(os3.homedir(), ".local", "share");
|
|
3219
2949
|
function installDesktop(cli, port) {
|
|
3220
|
-
const dir =
|
|
3221
|
-
|
|
3222
|
-
const icon =
|
|
3223
|
-
|
|
3224
|
-
const launcher =
|
|
2950
|
+
const dir = path9.join(data(), "truecanvas");
|
|
2951
|
+
fs7.mkdirSync(dir, { recursive: true });
|
|
2952
|
+
const icon = path9.join(dir, "icon.svg");
|
|
2953
|
+
fs7.writeFileSync(icon, ICON);
|
|
2954
|
+
const launcher = path9.join(dir, "launch.sh");
|
|
3225
2955
|
const url = `http://localhost:${port}`;
|
|
3226
|
-
|
|
2956
|
+
fs7.writeFileSync(
|
|
3227
2957
|
launcher,
|
|
3228
2958
|
`#!/bin/sh
|
|
3229
2959
|
# Truecanvas launcher: start the hub if needed, then open its window.
|
|
@@ -3232,7 +2962,7 @@ CLI=${JSON.stringify(cli)}
|
|
|
3232
2962
|
URL=${JSON.stringify(url)}
|
|
3233
2963
|
LOG="\${XDG_STATE_HOME:-$HOME/.local/state}/truecanvas-hub.log"
|
|
3234
2964
|
# apps started from the desktop don't get your shell's PATH: next, pnpm and npx need it
|
|
3235
|
-
export PATH=${JSON.stringify([
|
|
2965
|
+
export PATH=${JSON.stringify([path9.dirname(process.execPath), ...(process.env.PATH ?? "").split(path9.delimiter)].filter((p, i, a) => p && a.indexOf(p) === i).join(path9.delimiter))}
|
|
3236
2966
|
mkdir -p "$(dirname "$LOG")"
|
|
3237
2967
|
|
|
3238
2968
|
if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
|
|
@@ -3241,7 +2971,7 @@ if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
|
|
|
3241
2971
|
while [ $i -lt 60 ] && ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; do sleep 0.25; i=$((i+1)); done
|
|
3242
2972
|
fi
|
|
3243
2973
|
|
|
3244
|
-
PROFILE=${JSON.stringify(
|
|
2974
|
+
PROFILE=${JSON.stringify(path9.join(dir, "window"))}
|
|
3245
2975
|
for b in google-chrome google-chrome-stable chromium-browser chromium brave-browser microsoft-edge; do
|
|
3246
2976
|
if command -v "$b" >/dev/null 2>&1; then
|
|
3247
2977
|
exec "$b" --app="$URL" --class=Truecanvas --user-data-dir="$PROFILE" --no-first-run --no-default-browser-check
|
|
@@ -3251,10 +2981,10 @@ exec xdg-open "$URL"
|
|
|
3251
2981
|
`,
|
|
3252
2982
|
{ mode: 493 }
|
|
3253
2983
|
);
|
|
3254
|
-
const apps =
|
|
3255
|
-
|
|
3256
|
-
const desktop =
|
|
3257
|
-
|
|
2984
|
+
const apps = path9.join(data(), "applications");
|
|
2985
|
+
fs7.mkdirSync(apps, { recursive: true });
|
|
2986
|
+
const desktop = path9.join(apps, "truecanvas.desktop");
|
|
2987
|
+
fs7.writeFileSync(
|
|
3258
2988
|
desktop,
|
|
3259
2989
|
`[Desktop Entry]
|
|
3260
2990
|
Type=Application
|
|
@@ -3270,11 +3000,11 @@ StartupWMClass=Truecanvas
|
|
|
3270
3000
|
StartupNotify=true
|
|
3271
3001
|
`
|
|
3272
3002
|
);
|
|
3273
|
-
let command =
|
|
3274
|
-
const ours = !
|
|
3003
|
+
let command = path9.join(os3.homedir(), ".local", "bin", "truecanvas");
|
|
3004
|
+
const ours = !fs7.existsSync(command) || fs7.readFileSync(command, "utf8").includes("# truecanvas wrapper");
|
|
3275
3005
|
if (ours) {
|
|
3276
|
-
|
|
3277
|
-
|
|
3006
|
+
fs7.mkdirSync(path9.dirname(command), { recursive: true });
|
|
3007
|
+
fs7.writeFileSync(command, `#!/bin/sh
|
|
3278
3008
|
# truecanvas wrapper (installed by \`truecanvas desktop\`)
|
|
3279
3009
|
exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
|
|
3280
3010
|
`, { mode: 493 });
|
|
@@ -3282,19 +3012,19 @@ exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
|
|
|
3282
3012
|
return { desktop, launcher, icon, command };
|
|
3283
3013
|
}
|
|
3284
3014
|
function launcherPath() {
|
|
3285
|
-
const p =
|
|
3286
|
-
return
|
|
3015
|
+
const p = path9.join(data(), "truecanvas", "launch.sh");
|
|
3016
|
+
return fs7.existsSync(p) ? p : null;
|
|
3287
3017
|
}
|
|
3288
3018
|
function uninstallDesktop() {
|
|
3289
3019
|
const removed = [];
|
|
3290
|
-
const cmd =
|
|
3291
|
-
if (
|
|
3292
|
-
|
|
3020
|
+
const cmd = path9.join(os3.homedir(), ".local", "bin", "truecanvas");
|
|
3021
|
+
if (fs7.existsSync(cmd) && fs7.readFileSync(cmd, "utf8").includes("# truecanvas wrapper")) {
|
|
3022
|
+
fs7.rmSync(cmd);
|
|
3293
3023
|
removed.push(cmd);
|
|
3294
3024
|
}
|
|
3295
|
-
for (const f of [
|
|
3296
|
-
if (
|
|
3297
|
-
|
|
3025
|
+
for (const f of [path9.join(data(), "applications", "truecanvas.desktop"), path9.join(data(), "truecanvas")]) {
|
|
3026
|
+
if (fs7.existsSync(f)) {
|
|
3027
|
+
fs7.rmSync(f, { recursive: true, force: true });
|
|
3298
3028
|
removed.push(f);
|
|
3299
3029
|
}
|
|
3300
3030
|
}
|
|
@@ -3302,16 +3032,16 @@ function uninstallDesktop() {
|
|
|
3302
3032
|
}
|
|
3303
3033
|
|
|
3304
3034
|
// src/cli/index.ts
|
|
3305
|
-
import { fileURLToPath as
|
|
3035
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3306
3036
|
import { createInterface } from "readline/promises";
|
|
3307
3037
|
|
|
3308
3038
|
// src/cli/doctor.ts
|
|
3309
|
-
import { execFile
|
|
3310
|
-
import
|
|
3311
|
-
import
|
|
3039
|
+
import { execFile } from "child_process";
|
|
3040
|
+
import fs8 from "fs";
|
|
3041
|
+
import path10 from "path";
|
|
3312
3042
|
var major = (v) => v ? Number(/(\d+)(?:\.(\d+))?/.exec(v)?.[1] ?? 0) : 0;
|
|
3313
3043
|
function has(cmd, args, cwd) {
|
|
3314
|
-
return new Promise((resolve) =>
|
|
3044
|
+
return new Promise((resolve) => execFile(cmd, args, { cwd, timeout: 8e3 }, (err, stdout, stderr) => resolve(err ? null : `${stdout}${stderr}`)));
|
|
3315
3045
|
}
|
|
3316
3046
|
async function doctor(root, cliVersion) {
|
|
3317
3047
|
const checks = [];
|
|
@@ -3335,7 +3065,7 @@ async function doctor(root, cliVersion) {
|
|
|
3335
3065
|
const tw = depVersion(root, "tailwindcss");
|
|
3336
3066
|
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" });
|
|
3337
3067
|
const config = loadConfig(root);
|
|
3338
|
-
if (config.framework === "next") add(
|
|
3068
|
+
if (config.framework === "next") add(fs8.existsSync(path10.join(root, config.appDir)) ? { label: "App Router", status: "ok", detail: config.appDir } : { label: "App Router", status: "fail", detail: `no ${config.appDir}/`, fix: "Truecanvas renders through the App Router (app/ or src/app/)" });
|
|
3339
3069
|
const tc = installedIn(root);
|
|
3340
3070
|
if (!tc.declared) add({ label: "Truecanvas in the project", status: "fail", detail: "not a dependency", fix: "npx truecanvas (sets it up)" });
|
|
3341
3071
|
else if (!tc.installed) add({ label: "Truecanvas in the project", status: "fail", detail: "declared but not installed", fix: `${pm} install` });
|
|
@@ -3344,15 +3074,15 @@ async function doctor(root, cliVersion) {
|
|
|
3344
3074
|
const wrapped = configStatus(root, config.framework);
|
|
3345
3075
|
const configLabel = config.framework === "vite" ? "vite.config" : "next.config";
|
|
3346
3076
|
const uses = config.framework === "vite" ? "has the truecanvas() plugin" : "uses withTruecanvas()";
|
|
3347
|
-
add(wrapped.wrapped ? { label: configLabel, status: "ok", detail: `${
|
|
3077
|
+
add(wrapped.wrapped ? { label: configLabel, status: "ok", detail: `${path10.basename(wrapped.file)} ${uses}` } : { label: configLabel, status: "fail", detail: wrapped.file ? "Truecanvas not enabled" : "missing", fix: "npx truecanvas init" });
|
|
3348
3078
|
const scripts = pkg.scripts ?? {};
|
|
3349
3079
|
const script = Object.entries(scripts).find(([, v]) => v.includes("truecanvas"));
|
|
3350
3080
|
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")' });
|
|
3351
|
-
const ignore =
|
|
3081
|
+
const ignore = fs8.existsSync(path10.join(root, ".gitignore")) ? fs8.readFileSync(path10.join(root, ".gitignore"), "utf8") : "";
|
|
3352
3082
|
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" });
|
|
3353
3083
|
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)" });
|
|
3354
3084
|
const chrome = findChromium();
|
|
3355
|
-
add(chrome ? { label: "Screenshots", status: "ok", detail:
|
|
3085
|
+
add(chrome ? { label: "Screenshots", status: "ok", detail: path10.basename(chrome) } : { label: "Screenshots", status: "warn", detail: "no Chromium found", fix: "npx playwright install chromium-headless-shell (or install Chrome)" });
|
|
3356
3086
|
const git = await has("git", ["rev-parse", "--is-inside-work-tree"], root);
|
|
3357
3087
|
add(git ? { label: "Git", status: "ok" } : { label: "Git", status: "warn", detail: "not a repository", fix: "git init (branches, history, comments and compare use git)" });
|
|
3358
3088
|
const gh = await has("gh", ["auth", "status"], root);
|
|
@@ -3378,7 +3108,13 @@ Usage
|
|
|
3378
3108
|
truecanvas quit Quit the window and every app it started
|
|
3379
3109
|
truecanvas desktop Add Truecanvas to your app launcher (Linux); "desktop remove" undoes it
|
|
3380
3110
|
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)
|
|
3111
|
+
--password <p>, --no-password, --revoke, --restore, --delete, --title <t>, --local (preview only),
|
|
3112
|
+
--live <url|dir>: a live version clients open with "View live" (where the app runs, or a static build)
|
|
3113
|
+
--invite <emails>: invite people by email (they sign in with a link; new links are invited-only)
|
|
3114
|
+
--access invited|password|public: who can open the link
|
|
3115
|
+
--link-only: with --invite or --access, change the link without a new version
|
|
3116
|
+
--session: a live session (until Ctrl+C): the link shows the canvas live from your app,
|
|
3117
|
+
with everyone's cursors and comments in real time
|
|
3382
3118
|
truecanvas share setup Connect your studio's review site (URL + REVIEW_TOKEN)
|
|
3383
3119
|
truecanvas mcp stdio MCP server for agents without HTTP support
|
|
3384
3120
|
|
|
@@ -3401,6 +3137,55 @@ var c = {
|
|
|
3401
3137
|
yellow: paint("33", "39"),
|
|
3402
3138
|
red: paint("31", "39")
|
|
3403
3139
|
};
|
|
3140
|
+
var accessLabel = (access) => access === "invited" ? "invited people only" : access === "password" ? "password protected" : access === "public" ? "anyone with the link" : "";
|
|
3141
|
+
async function liveSession(base, canvas) {
|
|
3142
|
+
const call2 = async (route, body) => {
|
|
3143
|
+
const res = await fetch(`${base}${route}`, body === void 0 ? void 0 : { method: "POST", headers: { "content-type": "application/json", origin: base }, body: JSON.stringify(body) });
|
|
3144
|
+
const data2 = await res.json().catch(() => ({}));
|
|
3145
|
+
if (!res.ok) throw new Error(data2.error ?? `${res.status} ${res.statusText}`);
|
|
3146
|
+
return data2;
|
|
3147
|
+
};
|
|
3148
|
+
let session;
|
|
3149
|
+
try {
|
|
3150
|
+
session = (await call2("/api/session/start", { canvas })).session;
|
|
3151
|
+
} catch (err) {
|
|
3152
|
+
console.error(` ${c.red("\u2717")} ${err.message}`);
|
|
3153
|
+
process.exit(1);
|
|
3154
|
+
}
|
|
3155
|
+
console.log(` ${c.green("\u25CF")} Live session on ${c.bold(canvas)}: ${c.bold(session.url)}`);
|
|
3156
|
+
console.log(` ${c.dim("People with access to the link see the canvas live from your app. Ctrl+C ends the session.")}`);
|
|
3157
|
+
let last = "";
|
|
3158
|
+
const tick = async () => {
|
|
3159
|
+
const all = (await call2("/api/session").catch(() => ({ sessions: [] }))).sessions;
|
|
3160
|
+
const s = all.find((x) => x.canvas === canvas);
|
|
3161
|
+
if (!s || s.status === "stopped") {
|
|
3162
|
+
console.log(` ${c.yellow("!")} The session ended${s?.error ? `: ${s.error}` : "."}`);
|
|
3163
|
+
process.exit(s?.error ? 1 : 0);
|
|
3164
|
+
}
|
|
3165
|
+
const here = s.people.filter((p) => p.id !== s.you).map((p) => p.name);
|
|
3166
|
+
const line = `${s.status}${here.length ? ` \xB7 here: ${here.join(", ")}` : ""}`;
|
|
3167
|
+
if (line !== last) console.log(` ${s.status === "live" ? c.green("\u25CF") : c.yellow("\u25CB")} ${line}`);
|
|
3168
|
+
last = line;
|
|
3169
|
+
};
|
|
3170
|
+
const timer = setInterval(() => void tick(), 2e3);
|
|
3171
|
+
const end = async () => {
|
|
3172
|
+
clearInterval(timer);
|
|
3173
|
+
await call2("/api/session/stop", { canvas }).catch(() => {
|
|
3174
|
+
});
|
|
3175
|
+
console.log(` ${c.dim("Session ended: the link shows the latest published version again.")}`);
|
|
3176
|
+
process.exit(0);
|
|
3177
|
+
};
|
|
3178
|
+
process.once("SIGINT", () => void end());
|
|
3179
|
+
process.once("SIGTERM", () => void end());
|
|
3180
|
+
await new Promise(() => {
|
|
3181
|
+
});
|
|
3182
|
+
}
|
|
3183
|
+
function printInvited(invited) {
|
|
3184
|
+
for (const i of invited) {
|
|
3185
|
+
if (i.error) console.log(` ${c.yellow("!")} Couldn't email ${i.email}: ${i.error}`);
|
|
3186
|
+
else console.log(` ${c.green("\u2713")} Invited ${i.email}${i.link ? c.dim(` (local review site: ${i.link})`) : ""}`);
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3404
3189
|
async function main() {
|
|
3405
3190
|
const { positionals, values } = parseArgs({
|
|
3406
3191
|
allowPositionals: true,
|
|
@@ -3423,10 +3208,15 @@ async function main() {
|
|
|
3423
3208
|
revoke: { type: "boolean" },
|
|
3424
3209
|
restore: { type: "boolean" },
|
|
3425
3210
|
delete: { type: "boolean" },
|
|
3426
|
-
token: { type: "string" }
|
|
3211
|
+
token: { type: "string" },
|
|
3212
|
+
live: { type: "string" },
|
|
3213
|
+
invite: { type: "string", multiple: true },
|
|
3214
|
+
access: { type: "string" },
|
|
3215
|
+
"link-only": { type: "boolean" },
|
|
3216
|
+
session: { type: "boolean" }
|
|
3427
3217
|
}
|
|
3428
3218
|
});
|
|
3429
|
-
const cli =
|
|
3219
|
+
const cli = fileURLToPath2(import.meta.url);
|
|
3430
3220
|
const pkgDir = packageDir(cli);
|
|
3431
3221
|
const version = packageVersion(pkgDir);
|
|
3432
3222
|
const command = positionals[0] ?? "start";
|
|
@@ -3440,13 +3230,13 @@ async function main() {
|
|
|
3440
3230
|
const root2 = process.cwd();
|
|
3441
3231
|
const deps = { ...readPackage(root2)?.dependencies, ...readPackage(root2)?.devDependencies };
|
|
3442
3232
|
if (!("next" in deps) && !("vite" in deps && "react" in deps)) {
|
|
3443
|
-
return openWindow(cli, values.port ? Number(values.port) :
|
|
3233
|
+
return openWindow(cli, values.port ? Number(values.port) : hubPort());
|
|
3444
3234
|
}
|
|
3445
3235
|
if (!await ensureSetup(root2, pkgDir, version, yes)) return;
|
|
3446
|
-
if (await openInWindow(root2, Number(values.port
|
|
3447
|
-
const local =
|
|
3448
|
-
if (
|
|
3449
|
-
const child =
|
|
3236
|
+
if (await openInWindow(root2, values.port ? Number(values.port) : hubPort())) return;
|
|
3237
|
+
const local = path11.join(root2, "node_modules", "truecanvas", "dist", "cli.js");
|
|
3238
|
+
if (fs9.existsSync(local) && fs9.realpathSync(local) !== fs9.realpathSync(cli)) {
|
|
3239
|
+
const child = spawn(process.execPath, [local, "dev", ...process.argv.slice(2).filter((a) => a !== "start" && a !== "-y" && a !== "--yes")], { stdio: "inherit" });
|
|
3450
3240
|
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => child.kill(sig));
|
|
3451
3241
|
child.on("exit", (code) => process.exit(code ?? 0));
|
|
3452
3242
|
return;
|
|
@@ -3503,9 +3293,9 @@ async function main() {
|
|
|
3503
3293
|
}
|
|
3504
3294
|
return;
|
|
3505
3295
|
}
|
|
3506
|
-
if (command === "open") return openWindow(cli, values.port ? Number(values.port) :
|
|
3296
|
+
if (command === "open") return openWindow(cli, values.port ? Number(values.port) : hubPort());
|
|
3507
3297
|
if (command === "quit") {
|
|
3508
|
-
const url = `http://localhost:${values.port ??
|
|
3298
|
+
const url = `http://localhost:${values.port ?? hubPort()}`;
|
|
3509
3299
|
const up = await fetch(`${url}/api/hub/state`, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok).catch(() => false);
|
|
3510
3300
|
if (!up) return console.log("Truecanvas isn't running.");
|
|
3511
3301
|
await fetch(`${url}/api/hub/quit`, { method: "POST" }).catch(() => {
|
|
@@ -3527,7 +3317,7 @@ async function main() {
|
|
|
3527
3317
|
return;
|
|
3528
3318
|
}
|
|
3529
3319
|
const root = process.cwd();
|
|
3530
|
-
if (!
|
|
3320
|
+
if (!fs9.existsSync(path11.join(root, "package.json"))) {
|
|
3531
3321
|
console.error("Run truecanvas from your app's root (no package.json here).");
|
|
3532
3322
|
process.exit(1);
|
|
3533
3323
|
}
|
|
@@ -3537,7 +3327,7 @@ async function main() {
|
|
|
3537
3327
|
const report = initProject(config);
|
|
3538
3328
|
ws.ensureRoute();
|
|
3539
3329
|
console.log(`
|
|
3540
|
-
${c.accent("\u25C6")} ${c.bold("Truecanvas")} is set up in ${c.bold(
|
|
3330
|
+
${c.accent("\u25C6")} ${c.bold("Truecanvas")} is set up in ${c.bold(path11.basename(root))}
|
|
3541
3331
|
`);
|
|
3542
3332
|
for (const line of report.done) console.log(` ${c.green("\u2713")} ${line}`);
|
|
3543
3333
|
console.log(` ${c.green("\u2713")} Canvas folder ${c.bold(config.canvasDir)} and dev route ${c.bold(config.routeDir)}`);
|
|
@@ -3569,7 +3359,7 @@ async function main() {
|
|
|
3569
3359
|
console.error(site ? "Name the canvas: truecanvas share <canvas> --delete" : `No review site yet: ${c.accent("npx truecanvas share setup")}`);
|
|
3570
3360
|
process.exit(1);
|
|
3571
3361
|
}
|
|
3572
|
-
const done = await deleteShare(site,
|
|
3362
|
+
const done = await deleteShare(site, path11.basename(root), canvas);
|
|
3573
3363
|
console.log(done ? ` ${c.green("\u2713")} Deleted the link of ${canvas}, with its versions and comments.` : ` ${canvas} has no link.`);
|
|
3574
3364
|
return;
|
|
3575
3365
|
}
|
|
@@ -3580,10 +3370,38 @@ async function main() {
|
|
|
3580
3370
|
process.exit(1);
|
|
3581
3371
|
}
|
|
3582
3372
|
const change = { ...values.revoke ? { revoked: true } : values.restore ? { revoked: false } : {}, ...values["no-password"] ? { password: null } : {} };
|
|
3583
|
-
const url = await updateShare(site,
|
|
3373
|
+
const url = await updateShare(site, path11.basename(root), canvas, change);
|
|
3584
3374
|
console.log(` ${c.green("\u2713")} ${values.revoke ? "Link revoked" : values.restore ? "Link restored" : "Password removed"}: ${url}`);
|
|
3585
3375
|
return;
|
|
3586
3376
|
}
|
|
3377
|
+
let invite = [];
|
|
3378
|
+
try {
|
|
3379
|
+
invite = parseEmails(values.invite);
|
|
3380
|
+
} catch (err) {
|
|
3381
|
+
console.error(` ${c.red("\u2717")} ${err.message}`);
|
|
3382
|
+
process.exit(1);
|
|
3383
|
+
}
|
|
3384
|
+
const access = values.access;
|
|
3385
|
+
if (access && !ACCESS_MODES.includes(access)) {
|
|
3386
|
+
console.error(` ${c.red("\u2717")} --access is ${ACCESS_MODES.join(", ")}`);
|
|
3387
|
+
process.exit(1);
|
|
3388
|
+
}
|
|
3389
|
+
if (values["link-only"]) {
|
|
3390
|
+
const site = reviewSite();
|
|
3391
|
+
if (!site || !canvas || !access && !invite.length) {
|
|
3392
|
+
console.error(!site ? `No review site yet: ${c.accent("npx truecanvas share setup")}` : !canvas ? "Name the canvas: truecanvas share <canvas> --invite name@client.com --link-only" : "--link-only goes with --invite or --access");
|
|
3393
|
+
process.exit(1);
|
|
3394
|
+
}
|
|
3395
|
+
try {
|
|
3396
|
+
const done = await updateAccess(site, path11.basename(root), canvas, { access, password: values.password, invite });
|
|
3397
|
+
console.log(` ${c.green("\u2713")} ${done.url} ${c.dim(`(${accessLabel(done.access)})`)}`);
|
|
3398
|
+
printInvited(done.invited);
|
|
3399
|
+
} catch (err) {
|
|
3400
|
+
console.error(` ${c.red("\u2717")} ${err.message}`);
|
|
3401
|
+
process.exit(1);
|
|
3402
|
+
}
|
|
3403
|
+
return;
|
|
3404
|
+
}
|
|
3587
3405
|
if (!state) {
|
|
3588
3406
|
console.error(`Truecanvas isn't running for this project. Start it first: ${c.accent("npx truecanvas")}`);
|
|
3589
3407
|
process.exit(1);
|
|
@@ -3593,10 +3411,24 @@ async function main() {
|
|
|
3593
3411
|
truecanvas share <canvas>`);
|
|
3594
3412
|
process.exit(1);
|
|
3595
3413
|
}
|
|
3414
|
+
if (values.session) return liveSession(base, canvas);
|
|
3596
3415
|
const publish = !values.local && !!reviewSite();
|
|
3597
3416
|
console.log(` ${c.dim(`Rendering ${canvas} through your app${publish ? " and publishing it" : ""}\u2026`)}`);
|
|
3598
3417
|
const password = values.password;
|
|
3599
|
-
const
|
|
3418
|
+
const live = values.live ? /^https?:\/\//.test(values.live) ? { url: values.live } : { dir: path11.resolve(values.live) } : void 0;
|
|
3419
|
+
if (live && "dir" in live && !fs9.existsSync(path11.join(live.dir, "index.html"))) {
|
|
3420
|
+
console.error(` ${c.red("\u2717")} No index.html in ${live.dir}. Pass a static build folder (Next \`out/\`, Vite \`dist/\`) or the URL where the app runs.`);
|
|
3421
|
+
process.exit(1);
|
|
3422
|
+
}
|
|
3423
|
+
if ((live || access || invite.length) && !publish) {
|
|
3424
|
+
console.error(` ${c.red("\u2717")} ${live ? "--live" : access ? "--access" : "--invite"} needs a review site: ${c.accent("npx truecanvas share setup")}`);
|
|
3425
|
+
process.exit(1);
|
|
3426
|
+
}
|
|
3427
|
+
const res = await fetch(`${base}/api/share/snapshot`, {
|
|
3428
|
+
method: "POST",
|
|
3429
|
+
headers: { "content-type": "application/json", origin: base },
|
|
3430
|
+
body: JSON.stringify({ canvas, local: !publish, title: values.title, password, live, access, invite })
|
|
3431
|
+
});
|
|
3600
3432
|
const body = await res.json();
|
|
3601
3433
|
if (!res.ok) {
|
|
3602
3434
|
console.error(` ${c.red("\u2717")} ${body.error}`);
|
|
@@ -3608,8 +3440,12 @@ async function main() {
|
|
|
3608
3440
|
if (manifest.missing.length) console.log(` ${c.yellow("!")} ${manifest.missing.length} asset${manifest.missing.length === 1 ? "" : "s"} couldn't be fetched.`);
|
|
3609
3441
|
if (published?.skipped.length) console.log(` ${c.yellow("!")} Left out (over 4.4 MB): ${published.skipped.join(", ")}`);
|
|
3610
3442
|
if (published) {
|
|
3611
|
-
|
|
3612
|
-
`);
|
|
3443
|
+
const who = published.access ? `, ${accessLabel(published.access)}` : published.password ? ", password protected" : "";
|
|
3444
|
+
console.log(` ${c.green("\u2713")} Shared: ${c.bold(published.url)} ${c.dim(`(version ${published.versions}${who})`)}`);
|
|
3445
|
+
if (published.live) console.log(` ${c.green("\u2713")} Live: ${published.live} ${c.dim(published.access !== "public" && (published.access || published.password) ? "(opens from the link's View live button)" : "")}`);
|
|
3446
|
+
printInvited(published.invited);
|
|
3447
|
+
if (published.access === "invited" && !published.invited.length) console.log(` ${c.dim("Only invited people and your studio can open it. Invite someone:")} ${c.accent(`npx truecanvas share ${canvas} --invite name@client.com --link-only`)}`);
|
|
3448
|
+
console.log("");
|
|
3613
3449
|
if (values.open && !values["no-open"]) openBrowser(published.url);
|
|
3614
3450
|
} else {
|
|
3615
3451
|
console.log(` ${c.dim("Preview")} ${c.bold(body.preview)}`);
|
|
@@ -3703,12 +3539,12 @@ async function openWindow(cli, port) {
|
|
|
3703
3539
|
const url = `http://localhost:${port}`;
|
|
3704
3540
|
const launcher = launcherPath();
|
|
3705
3541
|
if (launcher) {
|
|
3706
|
-
|
|
3542
|
+
spawn(launcher, [], { stdio: "ignore", detached: true }).unref();
|
|
3707
3543
|
return console.log(`${c.accent("\u25C6")} Opening Truecanvas\u2026`);
|
|
3708
3544
|
}
|
|
3709
3545
|
const up = await fetch(`${url}/api/hub/state`, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok).catch(() => false);
|
|
3710
3546
|
if (!up) {
|
|
3711
|
-
|
|
3547
|
+
spawn(process.execPath, [cli, "hub", "--port", String(port), "--no-open"], { stdio: "ignore", detached: true }).unref();
|
|
3712
3548
|
for (let i = 0; i < 40 && !await fetch(`${url}/api/hub/state`).then((r) => r.ok).catch(() => false); i++) await new Promise((r) => setTimeout(r, 250));
|
|
3713
3549
|
}
|
|
3714
3550
|
openBrowser(url);
|
|
@@ -3721,7 +3557,7 @@ async function openInWindow(root, port) {
|
|
|
3721
3557
|
const post = (route, body) => fetch(`${hub}${route}`, { method: "POST", headers: { "content-type": "application/json", origin: hub }, body: JSON.stringify(body) }).catch(() => null);
|
|
3722
3558
|
await post("/api/hub/add", { path: root });
|
|
3723
3559
|
await post("/api/hub/open", { path: root, focus: "1" });
|
|
3724
|
-
console.log(`${c.accent("\u25C6")} Opened ${c.bold(
|
|
3560
|
+
console.log(`${c.accent("\u25C6")} Opened ${c.bold(path11.basename(root))} in the Truecanvas window ${c.dim(`(${hub})`)}.`);
|
|
3725
3561
|
return true;
|
|
3726
3562
|
}
|
|
3727
3563
|
async function prompt(question) {
|
|
@@ -3745,7 +3581,7 @@ async function ensureSetup(root, pkgDir, version, yes, force = false) {
|
|
|
3745
3581
|
const tc = installedIn(root);
|
|
3746
3582
|
const wrapped = configStatus(root).wrapped;
|
|
3747
3583
|
if (tc.declared && tc.installed && wrapped && !force) return true;
|
|
3748
|
-
const name = readPackage(root)?.name ??
|
|
3584
|
+
const name = readPackage(root)?.name ?? path11.basename(root);
|
|
3749
3585
|
if (!yes) {
|
|
3750
3586
|
if (!process.stdin.isTTY) {
|
|
3751
3587
|
console.error(`Truecanvas isn't set up in ${name} yet. Run again with --yes to set it up.`);
|
|
@@ -3819,7 +3655,7 @@ function startApp(root, appUrl, framework) {
|
|
|
3819
3655
|
const bin = binScript(root, pkg);
|
|
3820
3656
|
const [cmd, args] = bin ? [process.execPath, [bin, ...devArgs]] : [process.platform === "win32" ? "npx.cmd" : "npx", [pkg, ...devArgs]];
|
|
3821
3657
|
const label = framework === "vite" ? "vite" : "next";
|
|
3822
|
-
const child =
|
|
3658
|
+
const child = spawn(cmd, args, { cwd: root, env: toolEnv(process.env.NO_COLOR ? {} : { FORCE_COLOR: "1" }), stdio: ["ignore", "pipe", "pipe"], shell: !bin && process.platform === "win32" });
|
|
3823
3659
|
child.on("error", (err) => console.error(` ${c.dim(`${label} \u2502`)} couldn't start ${pkg}: ${err.message}`));
|
|
3824
3660
|
const prefix = c.dim(`${label} \u2502 `);
|
|
3825
3661
|
for (const stream of [child.stdout, child.stderr]) {
|
|
@@ -3836,24 +3672,24 @@ function startApp(root, appUrl, framework) {
|
|
|
3836
3672
|
}
|
|
3837
3673
|
function binScript(root, pkg) {
|
|
3838
3674
|
try {
|
|
3839
|
-
const req = createRequire(
|
|
3675
|
+
const req = createRequire(path11.join(root, "package.json"));
|
|
3840
3676
|
const manifest = req.resolve(`${pkg}/package.json`);
|
|
3841
|
-
const { bin } = JSON.parse(
|
|
3677
|
+
const { bin } = JSON.parse(fs9.readFileSync(manifest, "utf8"));
|
|
3842
3678
|
const rel = typeof bin === "string" ? bin : bin?.[pkg];
|
|
3843
|
-
return rel ?
|
|
3679
|
+
return rel ? path11.join(path11.dirname(manifest), rel) : null;
|
|
3844
3680
|
} catch {
|
|
3845
3681
|
return null;
|
|
3846
3682
|
}
|
|
3847
3683
|
}
|
|
3848
3684
|
function stopTree(child, signal) {
|
|
3849
|
-
if (process.platform === "win32" && child.pid)
|
|
3685
|
+
if (process.platform === "win32" && child.pid) spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }).on("error", () => {
|
|
3850
3686
|
});
|
|
3851
3687
|
else child.kill(signal);
|
|
3852
3688
|
}
|
|
3853
3689
|
function openBrowser(url) {
|
|
3854
3690
|
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
3855
3691
|
try {
|
|
3856
|
-
|
|
3692
|
+
spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).on("error", () => {
|
|
3857
3693
|
}).unref();
|
|
3858
3694
|
} catch {
|
|
3859
3695
|
}
|