truecanvas 0.5.0 → 0.6.1
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 +6 -0
- package/dist/chunk-D565QDII.js +814 -0
- package/dist/chunk-LKFOLM3L.js +54 -0
- package/dist/chunk-NAK4DQWO.js +1005 -0
- package/dist/{chunk-DV76O5SZ.js → chunk-OPKROZI2.js} +261 -4
- 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 +739 -938
- package/dist/core/index.js +14 -11
- package/dist/editor/assets/Menu-BGfgJ59U.css +1 -0
- package/dist/editor/assets/hub-DOR9_rf9.js +2 -0
- package/dist/editor/assets/main-vJb_YsG2.js +27 -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 +73 -1
- 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/Menu-BJ3OmqzH.css +0 -1
- 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-BbEG2pRj.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,9 +69,9 @@ 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
|
|
@@ -282,18 +305,33 @@ async function connectReviewSite(url, token) {
|
|
|
282
305
|
fs2.writeFileSync(file, JSON.stringify(site, null, 2), { mode: 384 });
|
|
283
306
|
return res;
|
|
284
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.";
|
|
285
314
|
var MAX_FILE = 4.4 * 1024 * 1024;
|
|
286
315
|
async function publishSnapshot(site, dir, manifest, opts = {}) {
|
|
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>");
|
|
287
320
|
if (opts.live) {
|
|
288
|
-
const { features = [] } = await request(site, "GET", "/api/shares");
|
|
289
321
|
const needed = "url" in opts.live ? "live-url" : "live-files";
|
|
290
|
-
if (!
|
|
322
|
+
if (!can.includes(needed))
|
|
291
323
|
throw new Error(
|
|
292
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."
|
|
293
325
|
);
|
|
294
326
|
if ("dir" in opts.live && !fs2.statSync(opts.live.dir, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`No build folder at ${opts.live.dir}`);
|
|
295
327
|
}
|
|
296
|
-
const
|
|
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
|
+
});
|
|
297
335
|
const base = `/api/shares/${share.slug}`;
|
|
298
336
|
const known = new Set((await request(site, "GET", `${base}/assets`)).assets);
|
|
299
337
|
const files = [];
|
|
@@ -332,11 +370,47 @@ async function publishSnapshot(site, dir, manifest, opts = {}) {
|
|
|
332
370
|
};
|
|
333
371
|
await Promise.all(Array.from({ length: 6 }, liveWorker));
|
|
334
372
|
}
|
|
335
|
-
const change = {
|
|
336
|
-
|
|
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
|
+
}
|
|
337
385
|
const live = opts.live ? "url" in opts.live ? { url: opts.live.url } : { files: true } : void 0;
|
|
338
386
|
const done = await request(site, "POST", `${base}/versions`, { id: manifest.id, createdAt: manifest.createdAt, frames: manifest.frames, live });
|
|
339
|
-
|
|
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 };
|
|
340
414
|
}
|
|
341
415
|
async function deleteShare(site, project, canvas) {
|
|
342
416
|
const found = await request(site, "GET", `/api/shares?project=${encodeURIComponent(project)}&canvas=${encodeURIComponent(canvas)}`);
|
|
@@ -424,7 +498,7 @@ Workflow:
|
|
|
424
498
|
5. focus to show the user what you changed. Everything is undoable by the user.`;
|
|
425
499
|
var literal = z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number(), z.boolean()]))]);
|
|
426
500
|
var deviceIds = DEVICES.map((d) => d.id);
|
|
427
|
-
function createMcpServer(ws, shots, session) {
|
|
501
|
+
function createMcpServer(ws, shots, session, sessions) {
|
|
428
502
|
const server = new McpServer({ name: "truecanvas", version: "0.1.0" }, { instructions: INSTRUCTIONS });
|
|
429
503
|
const actor = () => {
|
|
430
504
|
const name = server.server.getClientVersion()?.name ?? "agent";
|
|
@@ -1035,10 +1109,13 @@ ${msgs}`;
|
|
|
1035
1109
|
...canvasArg,
|
|
1036
1110
|
frames: z.array(z.string()).optional().describe("Frame names to share; omit for all."),
|
|
1037
1111
|
title: z.string().optional().describe('Title the client sees, e.g. "Homepage, round 2".'),
|
|
1038
|
-
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.')
|
|
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".`)
|
|
1039
1116
|
}
|
|
1040
1117
|
},
|
|
1041
|
-
async ({ canvas, frames, title, live }) => {
|
|
1118
|
+
async ({ canvas, frames, title, live, invite, access, password }) => {
|
|
1042
1119
|
try {
|
|
1043
1120
|
const c2 = resolveCanvas(canvas);
|
|
1044
1121
|
presence(c2, "looking", "Preparing a share link");
|
|
@@ -1051,12 +1128,15 @@ ${msgs}`;
|
|
|
1051
1128
|
const site = reviewSite();
|
|
1052
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"));
|
|
1053
1130
|
const liveSite = live ? /^https?:\/\//.test(live) ? { url: live } : { dir: path3.resolve(ws.config.root, live) } : void 0;
|
|
1054
|
-
const published = await publishSnapshot(site, dir, manifest, { title, live: liveSite });
|
|
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" : "";
|
|
1055
1133
|
return text(
|
|
1056
1134
|
[
|
|
1057
1135
|
`\u2713 Shared ${manifest.frames.length} frame(s): ${published.url}`,
|
|
1058
|
-
`Version ${published.versions} of this link${
|
|
1136
|
+
`Version ${published.versions} of this link${who}.`,
|
|
1059
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." : "",
|
|
1060
1140
|
...notes
|
|
1061
1141
|
].filter(Boolean).join("\n")
|
|
1062
1142
|
);
|
|
@@ -1065,6 +1145,42 @@ ${msgs}`;
|
|
|
1065
1145
|
}
|
|
1066
1146
|
}
|
|
1067
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.`);
|
|
1179
|
+
} catch (e) {
|
|
1180
|
+
return fail(e);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
);
|
|
1068
1184
|
server.registerTool(
|
|
1069
1185
|
"screenshot_frame",
|
|
1070
1186
|
{
|
|
@@ -1543,6 +1659,9 @@ function serializePage() {
|
|
|
1543
1659
|
${root.outerHTML}`, css, canvases, height };
|
|
1544
1660
|
}
|
|
1545
1661
|
|
|
1662
|
+
// src/server/app-run.ts
|
|
1663
|
+
var appRun = { state: "external", log: [] };
|
|
1664
|
+
|
|
1546
1665
|
// src/server/review.ts
|
|
1547
1666
|
import fs5 from "fs";
|
|
1548
1667
|
import path6 from "path";
|
|
@@ -1689,33 +1808,17 @@ function prDesignSection(pages, images) {
|
|
|
1689
1808
|
return lines.join("\n");
|
|
1690
1809
|
}
|
|
1691
1810
|
|
|
1692
|
-
// src/server/body.ts
|
|
1693
|
-
async function readJson(req, limit = 8 * 1024 * 1024) {
|
|
1694
|
-
const chunks = [];
|
|
1695
|
-
let size = 0;
|
|
1696
|
-
for await (const chunk of req) {
|
|
1697
|
-
size += chunk.length;
|
|
1698
|
-
if (size > limit) throw new EditError("Request too large.");
|
|
1699
|
-
chunks.push(chunk);
|
|
1700
|
-
}
|
|
1701
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1702
|
-
if (!raw) return {};
|
|
1703
|
-
try {
|
|
1704
|
-
return JSON.parse(raw);
|
|
1705
|
-
} catch {
|
|
1706
|
-
throw new EditError("The request body isn't valid JSON.");
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
1811
|
// src/share/sync.ts
|
|
1711
1812
|
import path7 from "path";
|
|
1712
1813
|
var EVERY = 2e4;
|
|
1713
1814
|
var LOOKUP_EVERY = 5 * 6e4;
|
|
1714
1815
|
var CommentSync = class {
|
|
1715
|
-
constructor(ws) {
|
|
1816
|
+
constructor(ws, onClientMessage) {
|
|
1716
1817
|
this.ws = ws;
|
|
1818
|
+
this.onClientMessage = onClientMessage;
|
|
1717
1819
|
}
|
|
1718
1820
|
ws;
|
|
1821
|
+
onClientMessage;
|
|
1719
1822
|
links = /* @__PURE__ */ new Map();
|
|
1720
1823
|
remote = /* @__PURE__ */ new Map();
|
|
1721
1824
|
timer = null;
|
|
@@ -1772,6 +1875,10 @@ var CommentSync = class {
|
|
|
1772
1875
|
this.remote.set(slug, { updated: res.updated, threads: remote });
|
|
1773
1876
|
const byId = new Map(remote.map((t) => [t.id, t]));
|
|
1774
1877
|
const dismissed = this.ws.comments.dismissed(canvas);
|
|
1878
|
+
const arrived = [];
|
|
1879
|
+
const fromClient = (m) => {
|
|
1880
|
+
if (m.author.kind === "client") arrived.push({ canvas, name: m.author.name, text: m.text });
|
|
1881
|
+
};
|
|
1775
1882
|
this.ws.comments.merge(canvas, (local) => {
|
|
1776
1883
|
let changed = false;
|
|
1777
1884
|
for (const r of remote) {
|
|
@@ -1779,6 +1886,7 @@ var CommentSync = class {
|
|
|
1779
1886
|
const l = local.find((t) => t.id === r.id);
|
|
1780
1887
|
if (!l) {
|
|
1781
1888
|
local.push(fromRemote(r, slug));
|
|
1889
|
+
r.messages.forEach(fromClient);
|
|
1782
1890
|
changed = true;
|
|
1783
1891
|
continue;
|
|
1784
1892
|
}
|
|
@@ -1787,6 +1895,7 @@ var CommentSync = class {
|
|
|
1787
1895
|
for (const m of r.messages) {
|
|
1788
1896
|
if (have.has(m.id)) continue;
|
|
1789
1897
|
l.messages.push(message(m));
|
|
1898
|
+
fromClient(m);
|
|
1790
1899
|
changed = true;
|
|
1791
1900
|
}
|
|
1792
1901
|
if (changed) l.messages.sort((a, b) => a.at - b.at);
|
|
@@ -1799,6 +1908,7 @@ var CommentSync = class {
|
|
|
1799
1908
|
}
|
|
1800
1909
|
return changed;
|
|
1801
1910
|
});
|
|
1911
|
+
if (this.onClientMessage) for (const e of arrived) this.onClientMessage(e);
|
|
1802
1912
|
let pushed = false;
|
|
1803
1913
|
for (const l of this.ws.comments.list(canvas)) {
|
|
1804
1914
|
if (l.share?.link !== slug) continue;
|
|
@@ -1851,6 +1961,360 @@ async function call(site, method, route, body) {
|
|
|
1851
1961
|
return res.json();
|
|
1852
1962
|
}
|
|
1853
1963
|
|
|
1964
|
+
// src/share/session.ts
|
|
1965
|
+
var BUFFER_LIMIT = 4 * 1024 * 1024;
|
|
1966
|
+
var MAX_REQUEST_BODY = 10 * 1024 * 1024;
|
|
1967
|
+
var LiveSession = class {
|
|
1968
|
+
constructor(site, slug, canvas, appUrl, opts) {
|
|
1969
|
+
this.site = site;
|
|
1970
|
+
this.slug = slug;
|
|
1971
|
+
this.opts = opts;
|
|
1972
|
+
this.app = new URL(appUrl);
|
|
1973
|
+
this.state = { canvas, status: "connecting", url: `${site.url}/s/${slug}`, error: null, people: [], you: null };
|
|
1974
|
+
}
|
|
1975
|
+
site;
|
|
1976
|
+
slug;
|
|
1977
|
+
opts;
|
|
1978
|
+
socket = null;
|
|
1979
|
+
stopped = false;
|
|
1980
|
+
retry = null;
|
|
1981
|
+
backoff = 1e3;
|
|
1982
|
+
inflight = /* @__PURE__ */ new Map();
|
|
1983
|
+
relays = /* @__PURE__ */ new Map();
|
|
1984
|
+
cursorTimer = null;
|
|
1985
|
+
cursorLast = 0;
|
|
1986
|
+
cursorNext = null;
|
|
1987
|
+
app;
|
|
1988
|
+
state;
|
|
1989
|
+
start() {
|
|
1990
|
+
this.stopped = false;
|
|
1991
|
+
this.connect();
|
|
1992
|
+
return this;
|
|
1993
|
+
}
|
|
1994
|
+
stop() {
|
|
1995
|
+
this.stopped = true;
|
|
1996
|
+
if (this.retry) clearTimeout(this.retry);
|
|
1997
|
+
this.socket?.close(1e3, "The studio ended the session");
|
|
1998
|
+
this.socket = null;
|
|
1999
|
+
this.cleanup();
|
|
2000
|
+
this.setState({ status: "stopped", people: [] });
|
|
2001
|
+
}
|
|
2002
|
+
/** The studio's cursor on the canvas (canvas coordinates), or null when it left. Throttled. */
|
|
2003
|
+
cursor(x, y) {
|
|
2004
|
+
this.cursorNext = { x, y };
|
|
2005
|
+
const wait = 50 - (Date.now() - this.cursorLast);
|
|
2006
|
+
if (this.cursorTimer) return;
|
|
2007
|
+
const send = () => {
|
|
2008
|
+
this.cursorTimer = null;
|
|
2009
|
+
if (!this.cursorNext) return;
|
|
2010
|
+
this.cursorLast = Date.now();
|
|
2011
|
+
this.sendText({ t: "cursor", ...this.cursorNext });
|
|
2012
|
+
this.cursorNext = null;
|
|
2013
|
+
};
|
|
2014
|
+
if (wait <= 0) send();
|
|
2015
|
+
else this.cursorTimer = setTimeout(send, wait);
|
|
2016
|
+
}
|
|
2017
|
+
setState(change) {
|
|
2018
|
+
this.state = { ...this.state, ...change };
|
|
2019
|
+
this.opts.onState?.(this.state);
|
|
2020
|
+
}
|
|
2021
|
+
connect() {
|
|
2022
|
+
if (this.stopped) return;
|
|
2023
|
+
const url = new URL(`${this.site.url}/api/shares/${encodeURIComponent(this.slug)}/room`);
|
|
2024
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
2025
|
+
url.searchParams.set("host", "1");
|
|
2026
|
+
url.searchParams.set("name", this.opts.name);
|
|
2027
|
+
url.searchParams.set("route", this.opts.route);
|
|
2028
|
+
const socket = new WebSocket(url, { headers: { authorization: `Bearer ${this.site.token}` } });
|
|
2029
|
+
socket.binaryType = "arraybuffer";
|
|
2030
|
+
this.socket = socket;
|
|
2031
|
+
let opened = false;
|
|
2032
|
+
const keepAlive = setInterval(() => socket.readyState === WebSocket.OPEN && socket.send("ping"), 3e4);
|
|
2033
|
+
keepAlive.unref?.();
|
|
2034
|
+
socket.onopen = () => {
|
|
2035
|
+
opened = true;
|
|
2036
|
+
this.backoff = 1e3;
|
|
2037
|
+
this.setState({ status: "live", error: null });
|
|
2038
|
+
};
|
|
2039
|
+
socket.onmessage = (m) => {
|
|
2040
|
+
if (typeof m.data === "string") this.onText(m.data);
|
|
2041
|
+
else void this.onFrame(m.data);
|
|
2042
|
+
};
|
|
2043
|
+
socket.onclose = (e) => {
|
|
2044
|
+
clearInterval(keepAlive);
|
|
2045
|
+
if (this.socket !== socket) return;
|
|
2046
|
+
this.socket = null;
|
|
2047
|
+
this.cleanup();
|
|
2048
|
+
if (this.stopped) return;
|
|
2049
|
+
if (e.code === 4e3) {
|
|
2050
|
+
this.stopped = true;
|
|
2051
|
+
this.setState({ status: "stopped", error: "Another Truecanvas started a session on this link.", people: [] });
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
this.setState({ status: "reconnecting", error: opened ? null : "Couldn't reach the review site.", people: [] });
|
|
2055
|
+
this.retry = setTimeout(() => this.connect(), this.backoff);
|
|
2056
|
+
this.backoff = Math.min(this.backoff * 2, 15e3);
|
|
2057
|
+
};
|
|
2058
|
+
socket.onerror = () => {
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
cleanup() {
|
|
2062
|
+
for (const f of this.inflight.values()) f.controller.abort();
|
|
2063
|
+
this.inflight.clear();
|
|
2064
|
+
for (const r of this.relays.values()) r.socket?.close();
|
|
2065
|
+
this.relays.clear();
|
|
2066
|
+
}
|
|
2067
|
+
sendText(msg) {
|
|
2068
|
+
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(msg));
|
|
2069
|
+
}
|
|
2070
|
+
send(header, payload) {
|
|
2071
|
+
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encodeFrame(header, payload));
|
|
2072
|
+
}
|
|
2073
|
+
/** Don't pile megabytes on a slow link: wait until the socket drains. */
|
|
2074
|
+
async drained() {
|
|
2075
|
+
while (this.socket && this.socket.readyState === WebSocket.OPEN && this.socket.bufferedAmount > BUFFER_LIMIT) await new Promise((r) => setTimeout(r, 15));
|
|
2076
|
+
}
|
|
2077
|
+
onText(data2) {
|
|
2078
|
+
if (data2 === "pong") return;
|
|
2079
|
+
let e;
|
|
2080
|
+
try {
|
|
2081
|
+
e = JSON.parse(data2);
|
|
2082
|
+
} catch {
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
if (e.t === "hello") this.setState({ people: e.people, you: e.you.id });
|
|
2086
|
+
else if (e.t === "join") this.setState({ people: [...this.state.people.filter((p) => p.id !== e.person.id), e.person] });
|
|
2087
|
+
else if (e.t === "leave") this.setState({ people: this.state.people.filter((p) => p.id !== e.id) });
|
|
2088
|
+
this.opts.onEvent?.(e);
|
|
2089
|
+
}
|
|
2090
|
+
// ---------- the tunnel ----------
|
|
2091
|
+
/** The app URL for a tunneled path, or null if it would leave the app. */
|
|
2092
|
+
target(path12, ws = false) {
|
|
2093
|
+
const safe = safePath(path12);
|
|
2094
|
+
if (!safe) return null;
|
|
2095
|
+
const url = new URL(safe, this.app);
|
|
2096
|
+
if (url.origin !== this.app.origin) return null;
|
|
2097
|
+
if (ws) url.protocol = this.app.protocol === "https:" ? "wss:" : "ws:";
|
|
2098
|
+
return url;
|
|
2099
|
+
}
|
|
2100
|
+
async onFrame(data2) {
|
|
2101
|
+
let frame;
|
|
2102
|
+
try {
|
|
2103
|
+
frame = decodeFrame(data2);
|
|
2104
|
+
} catch {
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
const h = frame.header;
|
|
2108
|
+
switch (h.t) {
|
|
2109
|
+
case "req":
|
|
2110
|
+
return this.request(h);
|
|
2111
|
+
case "req-body": {
|
|
2112
|
+
const f = this.inflight.get(h.id);
|
|
2113
|
+
if (!f?.body) return;
|
|
2114
|
+
f.size += frame.payload.length;
|
|
2115
|
+
if (f.size > MAX_REQUEST_BODY) {
|
|
2116
|
+
f.controller.abort();
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
f.body.enqueue(frame.payload.slice());
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
case "req-end": {
|
|
2123
|
+
const f = this.inflight.get(h.id);
|
|
2124
|
+
try {
|
|
2125
|
+
f?.body?.close();
|
|
2126
|
+
} catch {
|
|
2127
|
+
}
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
case "req-abort":
|
|
2131
|
+
this.inflight.get(h.id)?.controller.abort();
|
|
2132
|
+
this.inflight.delete(h.id);
|
|
2133
|
+
return;
|
|
2134
|
+
case "ws-open":
|
|
2135
|
+
return this.openRelay(h.id, h.path, h.protocols);
|
|
2136
|
+
case "ws-msg": {
|
|
2137
|
+
const r = this.relays.get(h.id);
|
|
2138
|
+
if (!r) return;
|
|
2139
|
+
const data3 = h.text ? new TextDecoder().decode(frame.payload) : frame.payload.slice();
|
|
2140
|
+
if (r.socket?.readyState === WebSocket.OPEN) r.socket.send(data3);
|
|
2141
|
+
else r.queue.push({ data: data3 });
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
case "ws-close": {
|
|
2145
|
+
const r = this.relays.get(h.id);
|
|
2146
|
+
this.relays.delete(h.id);
|
|
2147
|
+
r?.socket?.close();
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
async request(h) {
|
|
2153
|
+
const target = this.target(h.path);
|
|
2154
|
+
if (!target || !["GET", "HEAD", "POST"].includes(h.method)) {
|
|
2155
|
+
this.send({ t: "res", id: h.id, status: 403, headers: [["content-type", "text/plain; charset=utf-8"]] });
|
|
2156
|
+
this.send({ t: "res-body", id: h.id }, new TextEncoder().encode("Not part of the app."));
|
|
2157
|
+
this.send({ t: "res-end", id: h.id });
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
const controller = new AbortController();
|
|
2161
|
+
const entry = { controller, body: null, size: 0 };
|
|
2162
|
+
let body;
|
|
2163
|
+
if (h.body) body = new ReadableStream({ start: (c2) => void (entry.body = c2) });
|
|
2164
|
+
this.inflight.set(h.id, entry);
|
|
2165
|
+
try {
|
|
2166
|
+
const headers = new Headers();
|
|
2167
|
+
for (const [k, v] of h.headers) {
|
|
2168
|
+
try {
|
|
2169
|
+
headers.append(k, v);
|
|
2170
|
+
} catch {
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
const res = await fetch(target, {
|
|
2174
|
+
method: h.method,
|
|
2175
|
+
headers,
|
|
2176
|
+
body,
|
|
2177
|
+
redirect: "manual",
|
|
2178
|
+
signal: controller.signal,
|
|
2179
|
+
...body ? { duplex: "half" } : {}
|
|
2180
|
+
});
|
|
2181
|
+
this.send({ t: "res", id: h.id, status: res.status, headers: this.answerHeaders(res.headers) });
|
|
2182
|
+
if (res.body && h.method !== "HEAD") {
|
|
2183
|
+
const reader = res.body.getReader();
|
|
2184
|
+
for (; ; ) {
|
|
2185
|
+
const { done, value } = await reader.read();
|
|
2186
|
+
if (done) break;
|
|
2187
|
+
for (let i = 0; i < value.length; i += CHUNK) {
|
|
2188
|
+
await this.drained();
|
|
2189
|
+
if (!this.inflight.has(h.id)) {
|
|
2190
|
+
await reader.cancel().catch(() => {
|
|
2191
|
+
});
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
this.send({ t: "res-body", id: h.id }, value.subarray(i, i + CHUNK));
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
this.send({ t: "res-end", id: h.id });
|
|
2199
|
+
} catch (err) {
|
|
2200
|
+
if (!controller.signal.aborted) this.send({ t: "res-error", id: h.id, message: err.message || "request failed" });
|
|
2201
|
+
} finally {
|
|
2202
|
+
this.inflight.delete(h.id);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
/** The app's answer headers, with redirects to itself made relative (they'd point at localhost otherwise). */
|
|
2206
|
+
answerHeaders(headers) {
|
|
2207
|
+
const out = responseHeaders(headers);
|
|
2208
|
+
return out.map(([k, v]) => {
|
|
2209
|
+
if (k !== "location") return [k, v];
|
|
2210
|
+
try {
|
|
2211
|
+
const to = new URL(v, this.app);
|
|
2212
|
+
return [k, to.origin === this.app.origin ? to.pathname + to.search + to.hash : v];
|
|
2213
|
+
} catch {
|
|
2214
|
+
return [k, v];
|
|
2215
|
+
}
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
openRelay(id, path12, protocols) {
|
|
2219
|
+
const target = this.target(path12, true);
|
|
2220
|
+
if (!target) {
|
|
2221
|
+
this.send({ t: "ws-close", id, code: 1008, reason: "Not part of the app" });
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
const relay = { socket: null, queue: [] };
|
|
2225
|
+
this.relays.set(id, relay);
|
|
2226
|
+
let socket;
|
|
2227
|
+
try {
|
|
2228
|
+
socket = new WebSocket(target, protocols);
|
|
2229
|
+
} catch (err) {
|
|
2230
|
+
this.relays.delete(id);
|
|
2231
|
+
this.send({ t: "ws-close", id, code: 1011, reason: err.message.slice(0, 100) });
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
socket.binaryType = "arraybuffer";
|
|
2235
|
+
relay.socket = socket;
|
|
2236
|
+
socket.onopen = () => {
|
|
2237
|
+
this.send({ t: "ws-ready", id, protocol: socket.protocol });
|
|
2238
|
+
for (const m of relay.queue) socket.send(m.data);
|
|
2239
|
+
relay.queue = [];
|
|
2240
|
+
};
|
|
2241
|
+
socket.onmessage = (m) => {
|
|
2242
|
+
const text = typeof m.data === "string";
|
|
2243
|
+
this.send({ t: "ws-msg", id, text }, text ? new TextEncoder().encode(m.data) : new Uint8Array(m.data));
|
|
2244
|
+
};
|
|
2245
|
+
socket.onclose = (e) => {
|
|
2246
|
+
if (!this.relays.has(id)) return;
|
|
2247
|
+
this.relays.delete(id);
|
|
2248
|
+
this.send({ t: "ws-close", id, code: e.code, reason: e.reason });
|
|
2249
|
+
};
|
|
2250
|
+
socket.onerror = () => {
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
};
|
|
2254
|
+
var Sessions = class {
|
|
2255
|
+
constructor(project, appUrl, events = {}) {
|
|
2256
|
+
this.project = project;
|
|
2257
|
+
this.appUrl = appUrl;
|
|
2258
|
+
this.events = events;
|
|
2259
|
+
}
|
|
2260
|
+
project;
|
|
2261
|
+
appUrl;
|
|
2262
|
+
events;
|
|
2263
|
+
sessions = /* @__PURE__ */ new Map();
|
|
2264
|
+
list() {
|
|
2265
|
+
return [...this.sessions.values()].map((s) => s.state);
|
|
2266
|
+
}
|
|
2267
|
+
get(canvas) {
|
|
2268
|
+
return this.sessions.get(canvas) ?? null;
|
|
2269
|
+
}
|
|
2270
|
+
/**
|
|
2271
|
+
* Starts (or returns) the canvas's session. A canvas that has no link yet is
|
|
2272
|
+
* shared first through `opts.share` (invited-only on sites with sign-in), so
|
|
2273
|
+
* going live is one click.
|
|
2274
|
+
*/
|
|
2275
|
+
async start(canvas, opts = {}) {
|
|
2276
|
+
const running = this.sessions.get(canvas);
|
|
2277
|
+
if (running && running.state.status !== "stopped") return running.state;
|
|
2278
|
+
const site = reviewSite();
|
|
2279
|
+
if (!site) throw new Error("No review site yet: connect one with `npx truecanvas share setup`, share the canvas, then start a session.");
|
|
2280
|
+
const features = await siteFeatures(site);
|
|
2281
|
+
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.");
|
|
2282
|
+
const lookup = async () => {
|
|
2283
|
+
const res = await fetch(`${site.url}/api/shares?project=${encodeURIComponent(this.project)}&canvas=${encodeURIComponent(canvas)}`, {
|
|
2284
|
+
headers: { authorization: `Bearer ${site.token}` },
|
|
2285
|
+
signal: AbortSignal.timeout(2e4)
|
|
2286
|
+
});
|
|
2287
|
+
if (!res.ok) throw new Error(`Review site: ${res.status} ${res.statusText}`);
|
|
2288
|
+
return (await res.json()).slug;
|
|
2289
|
+
};
|
|
2290
|
+
let slug = await lookup();
|
|
2291
|
+
if (!slug && opts.share) {
|
|
2292
|
+
await opts.share();
|
|
2293
|
+
slug = await lookup();
|
|
2294
|
+
}
|
|
2295
|
+
if (!slug) throw new Error(`${canvas} has no link yet. Share it once (Share, or \`npx truecanvas share ${canvas}\`), then start a session.`);
|
|
2296
|
+
const session = new LiveSession(site, slug, canvas, this.appUrl, {
|
|
2297
|
+
name: opts.name ?? "Studio",
|
|
2298
|
+
route: "/truecanvas/",
|
|
2299
|
+
onEvent: (e) => this.events.onEvent?.(canvas, e),
|
|
2300
|
+
onState: (s) => this.events.onState?.(s)
|
|
2301
|
+
});
|
|
2302
|
+
this.sessions.set(canvas, session);
|
|
2303
|
+
session.start();
|
|
2304
|
+
return session.state;
|
|
2305
|
+
}
|
|
2306
|
+
stop(canvas) {
|
|
2307
|
+
const s = this.sessions.get(canvas);
|
|
2308
|
+
if (!s) return false;
|
|
2309
|
+
s.stop();
|
|
2310
|
+
this.sessions.delete(canvas);
|
|
2311
|
+
return true;
|
|
2312
|
+
}
|
|
2313
|
+
stopAll() {
|
|
2314
|
+
for (const canvas of [...this.sessions.keys()]) this.stop(canvas);
|
|
2315
|
+
}
|
|
2316
|
+
};
|
|
2317
|
+
|
|
1854
2318
|
// src/server/http.ts
|
|
1855
2319
|
var MIME = {
|
|
1856
2320
|
".html": "text/html; charset=utf-8",
|
|
@@ -1874,7 +2338,12 @@ async function startServer(ws) {
|
|
|
1874
2338
|
const shots = new Screenshotter(config.appUrl);
|
|
1875
2339
|
let prCache = null;
|
|
1876
2340
|
const thumbs = new Thumbnails(ws, shots);
|
|
1877
|
-
const
|
|
2341
|
+
const hub = process.env.TRUECANVAS_HUB_URL;
|
|
2342
|
+
const commentSync = new CommentSync(
|
|
2343
|
+
ws,
|
|
2344
|
+
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(() => {
|
|
2345
|
+
}) : void 0
|
|
2346
|
+
);
|
|
1878
2347
|
commentSync.start();
|
|
1879
2348
|
const syncRegistry = () => ws.catalog.load().then((list) => syncComponentRegistry(config, list)).catch(() => {
|
|
1880
2349
|
});
|
|
@@ -1890,6 +2359,19 @@ async function startServer(ws) {
|
|
|
1890
2359
|
for (const res of clients) res.write(data2);
|
|
1891
2360
|
};
|
|
1892
2361
|
ws.on(broadcast);
|
|
2362
|
+
const sendRaw = (e) => {
|
|
2363
|
+
const data2 = `data: ${JSON.stringify(e)}
|
|
2364
|
+
|
|
2365
|
+
`;
|
|
2366
|
+
for (const res of clients) res.write(data2);
|
|
2367
|
+
};
|
|
2368
|
+
const sessions = new Sessions(path8.basename(config.root), config.appUrl, {
|
|
2369
|
+
onEvent: (canvas, e) => {
|
|
2370
|
+
if (e.t === "comments") void commentSync.syncAll();
|
|
2371
|
+
else sendRaw({ type: "room", canvas, event: e });
|
|
2372
|
+
},
|
|
2373
|
+
onState: (s) => sendRaw({ type: "session", session: s })
|
|
2374
|
+
});
|
|
1893
2375
|
setInterval(() => {
|
|
1894
2376
|
for (const res of clients) res.write(": ping\n\n");
|
|
1895
2377
|
ws.pruneAgents();
|
|
@@ -1928,7 +2410,7 @@ async function startServer(ws) {
|
|
|
1928
2410
|
ws.dropAgent(sid);
|
|
1929
2411
|
}
|
|
1930
2412
|
};
|
|
1931
|
-
const server2 = createMcpServer(ws, shots, () => sid);
|
|
2413
|
+
const server2 = createMcpServer(ws, shots, () => sid, sessions);
|
|
1932
2414
|
await server2.connect(t);
|
|
1933
2415
|
transport = t;
|
|
1934
2416
|
}
|
|
@@ -1938,6 +2420,8 @@ async function startServer(ws) {
|
|
|
1938
2420
|
const route = `${req.method} ${url.pathname}`;
|
|
1939
2421
|
const user = { kind: "user" };
|
|
1940
2422
|
switch (route) {
|
|
2423
|
+
case "GET /api/app":
|
|
2424
|
+
return json(res, 200, appRun);
|
|
1941
2425
|
case "GET /api/state":
|
|
1942
2426
|
return json(res, 200, {
|
|
1943
2427
|
appUrl: config.appUrl,
|
|
@@ -2231,18 +2715,49 @@ async function startServer(ws) {
|
|
|
2231
2715
|
}
|
|
2232
2716
|
case "GET /api/share/status": {
|
|
2233
2717
|
const site = reviewSite();
|
|
2234
|
-
|
|
2718
|
+
const features = site ? await siteFeatures(site).catch(() => []) : [];
|
|
2719
|
+
return json(res, 200, { site: site ? site.url : null, features });
|
|
2235
2720
|
}
|
|
2236
2721
|
case "POST /api/share/snapshot": {
|
|
2237
|
-
const { canvas, frames, local, title, password, live } = await readJson(req);
|
|
2722
|
+
const { canvas, frames, local, title, password, live, access, invite } = await readJson(req);
|
|
2238
2723
|
assertCanvasName(canvas);
|
|
2239
2724
|
const { dir, manifest } = await createSnapshot(ws, shots, canvas, { frames });
|
|
2240
2725
|
const preview = `${origin}/share/${encodeURIComponent(canvas)}/${manifest.id}/`;
|
|
2241
2726
|
const site = local ? null : reviewSite();
|
|
2242
|
-
const published = site ? await publishSnapshot(site, dir, manifest, { title, password, live }) : null;
|
|
2727
|
+
const published = site ? await publishSnapshot(site, dir, manifest, { title, password, live, access, invite: invite?.length ? parseEmails(invite) : void 0 }) : null;
|
|
2243
2728
|
if (published) commentSync.forget(canvas);
|
|
2244
2729
|
return json(res, 200, { manifest, preview, published });
|
|
2245
2730
|
}
|
|
2731
|
+
// live sessions: clients follow the canvas live on its link (editor button, CLI, MCP, desktop tray)
|
|
2732
|
+
case "GET /api/session":
|
|
2733
|
+
return json(res, 200, { sessions: sessions.list() });
|
|
2734
|
+
case "POST /api/session/start": {
|
|
2735
|
+
const body = await readJson(req);
|
|
2736
|
+
const canvas = body.canvas ?? ws.selection.canvas ?? ws.canvases()[0];
|
|
2737
|
+
if (!canvas) return json(res, 400, { error: "No canvas to share live yet." });
|
|
2738
|
+
assertCanvasName(canvas);
|
|
2739
|
+
const share = async () => {
|
|
2740
|
+
const site = reviewSite();
|
|
2741
|
+
if (!site) return;
|
|
2742
|
+
const { dir, manifest } = await createSnapshot(ws, shots, canvas, {});
|
|
2743
|
+
await publishSnapshot(site, dir, manifest, {});
|
|
2744
|
+
commentSync.forget(canvas);
|
|
2745
|
+
};
|
|
2746
|
+
return json(res, 200, { session: await sessions.start(canvas, { name: body.name, share }) });
|
|
2747
|
+
}
|
|
2748
|
+
case "POST /api/session/stop": {
|
|
2749
|
+
const { canvas } = await readJson(req);
|
|
2750
|
+
if (canvas) return json(res, 200, { stopped: sessions.stop(canvas) });
|
|
2751
|
+
const any = sessions.list().length > 0;
|
|
2752
|
+
sessions.stopAll();
|
|
2753
|
+
return json(res, 200, { stopped: any });
|
|
2754
|
+
}
|
|
2755
|
+
case "POST /api/session/cursor": {
|
|
2756
|
+
const { canvas, x, y } = await readJson(req);
|
|
2757
|
+
const on = typeof x === "number" && typeof y === "number" && Number.isFinite(x) && Number.isFinite(y);
|
|
2758
|
+
sessions.get(canvas)?.cursor(on ? x : null, on ? y : null);
|
|
2759
|
+
return json(res, 200, { ok: true });
|
|
2760
|
+
}
|
|
2246
2761
|
case "POST /api/source": {
|
|
2247
2762
|
const { canvas, id } = await readJson(req);
|
|
2248
2763
|
const hit = ws.nodeSource(canvas, id);
|
|
@@ -2373,6 +2888,7 @@ async function startServer(ws) {
|
|
|
2373
2888
|
server.listen(config.port, "127.0.0.1", resolve);
|
|
2374
2889
|
});
|
|
2375
2890
|
const shutdown = async () => {
|
|
2891
|
+
sessions.stopAll();
|
|
2376
2892
|
commentSync.stop();
|
|
2377
2893
|
await shots.close();
|
|
2378
2894
|
for (const t of transports.values()) await t.close().catch(() => {
|
|
@@ -2435,831 +2951,10 @@ async function runStdioBridge(mcpUrl) {
|
|
|
2435
2951
|
// src/cli/index.ts
|
|
2436
2952
|
import { createRequire } from "module";
|
|
2437
2953
|
|
|
2438
|
-
// src/hub/
|
|
2439
|
-
import http2 from "http";
|
|
2440
|
-
import fs10 from "fs";
|
|
2441
|
-
import os6 from "os";
|
|
2442
|
-
import path12 from "path";
|
|
2443
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
2444
|
-
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2445
|
-
import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2446
|
-
import { StreamableHTTPServerTransport as StreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
2447
|
-
import { Client as Client2 } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2448
|
-
import { StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
2449
|
-
import { CallToolRequestSchema as CallToolRequestSchema2, ListToolsRequestSchema as ListToolsRequestSchema2, isInitializeRequest as isInitializeRequest2 } from "@modelcontextprotocol/sdk/types.js";
|
|
2450
|
-
|
|
2451
|
-
// src/hub/projects.ts
|
|
2954
|
+
// src/hub/desktop.ts
|
|
2452
2955
|
import fs7 from "fs";
|
|
2453
2956
|
import os3 from "os";
|
|
2454
2957
|
import path9 from "path";
|
|
2455
|
-
var CONFIG_DIR = path9.join(process.env.XDG_CONFIG_HOME ?? path9.join(os3.homedir(), ".config"), "truecanvas");
|
|
2456
|
-
var FILE = path9.join(CONFIG_DIR, "projects.json");
|
|
2457
|
-
var ProjectStore = class {
|
|
2458
|
-
list = [];
|
|
2459
|
-
constructor() {
|
|
2460
|
-
try {
|
|
2461
|
-
this.list = JSON.parse(fs7.readFileSync(FILE, "utf8"));
|
|
2462
|
-
} catch {
|
|
2463
|
-
this.list = [];
|
|
2464
|
-
}
|
|
2465
|
-
}
|
|
2466
|
-
save() {
|
|
2467
|
-
fs7.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
2468
|
-
fs7.writeFileSync(FILE, JSON.stringify(this.list, null, 2));
|
|
2469
|
-
}
|
|
2470
|
-
all() {
|
|
2471
|
-
return [...this.list].sort((a, b) => b.lastOpened - a.lastOpened).map((p) => ({ ...p, ...inspect(p.path) }));
|
|
2472
|
-
}
|
|
2473
|
-
touch(dir) {
|
|
2474
|
-
const abs = path9.resolve(dir);
|
|
2475
|
-
const existing = this.list.find((p) => p.path === abs);
|
|
2476
|
-
if (existing) existing.lastOpened = Date.now();
|
|
2477
|
-
else this.list.push({ path: abs, name: projectName(abs), lastOpened: Date.now() });
|
|
2478
|
-
this.save();
|
|
2479
|
-
}
|
|
2480
|
-
forget(dir) {
|
|
2481
|
-
this.list = this.list.filter((p) => p.path !== path9.resolve(dir));
|
|
2482
|
-
this.save();
|
|
2483
|
-
}
|
|
2484
|
-
};
|
|
2485
|
-
function readPkg(dir) {
|
|
2486
|
-
try {
|
|
2487
|
-
return JSON.parse(fs7.readFileSync(path9.join(dir, "package.json"), "utf8"));
|
|
2488
|
-
} catch {
|
|
2489
|
-
return null;
|
|
2490
|
-
}
|
|
2491
|
-
}
|
|
2492
|
-
function projectName(dir) {
|
|
2493
|
-
const pkg = readPkg(dir);
|
|
2494
|
-
const name = typeof pkg?.name === "string" ? pkg.name : "";
|
|
2495
|
-
return name && !name.startsWith("@") ? name : path9.basename(dir);
|
|
2496
|
-
}
|
|
2497
|
-
function inspect(dir) {
|
|
2498
|
-
const pkg = readPkg(dir);
|
|
2499
|
-
if (!pkg) return { next: false, ready: false, canvases: 0, exists: fs7.existsSync(dir) };
|
|
2500
|
-
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
2501
|
-
let canvases = 0;
|
|
2502
|
-
for (const d of ["canvas", "src/canvas"]) {
|
|
2503
|
-
try {
|
|
2504
|
-
canvases += fs7.readdirSync(path9.join(dir, d)).filter((f) => f.endsWith(".canvas.tsx")).length;
|
|
2505
|
-
} catch {
|
|
2506
|
-
}
|
|
2507
|
-
}
|
|
2508
|
-
return { next: "next" in deps || "vite" in deps && "react" in deps, ready: "truecanvas" in deps, canvases, exists: true };
|
|
2509
|
-
}
|
|
2510
|
-
function discover() {
|
|
2511
|
-
const home = os3.homedir();
|
|
2512
|
-
const roots = ["Github", "GitHub", "github", "Projects", "projects", "code", "Code", "dev", "src", "Developer", "Documents", "work"].map((d) => path9.join(home, d)).filter((d) => fs7.existsSync(d));
|
|
2513
|
-
const found = [];
|
|
2514
|
-
let visited = 0;
|
|
2515
|
-
const walk2 = (dir, depth) => {
|
|
2516
|
-
if (depth > 3 || visited > 3e3 || found.length > 60) return;
|
|
2517
|
-
visited++;
|
|
2518
|
-
let entries;
|
|
2519
|
-
try {
|
|
2520
|
-
entries = fs7.readdirSync(dir, { withFileTypes: true });
|
|
2521
|
-
} catch {
|
|
2522
|
-
return;
|
|
2523
|
-
}
|
|
2524
|
-
if (entries.some((e) => e.name === "package.json")) {
|
|
2525
|
-
const info = inspect(dir);
|
|
2526
|
-
if (info.next) found.push({ path: dir, name: projectName(dir), ready: info.ready });
|
|
2527
|
-
}
|
|
2528
|
-
for (const e of entries) {
|
|
2529
|
-
if (!e.isDirectory() || e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist" || e.name === "build") continue;
|
|
2530
|
-
walk2(path9.join(dir, e.name), depth + 1);
|
|
2531
|
-
}
|
|
2532
|
-
};
|
|
2533
|
-
for (const r of roots) walk2(r, 0);
|
|
2534
|
-
return found;
|
|
2535
|
-
}
|
|
2536
|
-
function listDirs(dir) {
|
|
2537
|
-
const abs = path9.resolve(dir.replace(/^~(?=$|[\\/])/, os3.homedir()));
|
|
2538
|
-
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(path9.join(abs, e.name)).next })).sort((a, b) => Number(b.next) - Number(a.next) || a.name.localeCompare(b.name));
|
|
2539
|
-
return { path: abs, dirs };
|
|
2540
|
-
}
|
|
2541
|
-
|
|
2542
|
-
// src/hub/runner.ts
|
|
2543
|
-
import { execFile, spawn as spawn2 } from "child_process";
|
|
2544
|
-
import fs9 from "fs";
|
|
2545
|
-
import net from "net";
|
|
2546
|
-
import os5 from "os";
|
|
2547
|
-
import path11 from "path";
|
|
2548
|
-
|
|
2549
|
-
// src/cli/setup.ts
|
|
2550
|
-
import { spawn } from "child_process";
|
|
2551
|
-
import { createHash as createHash2 } from "crypto";
|
|
2552
|
-
import fs8 from "fs";
|
|
2553
|
-
import os4 from "os";
|
|
2554
|
-
import path10 from "path";
|
|
2555
|
-
function addDevArgs(pm, spec) {
|
|
2556
|
-
return addArgs(pm, [spec], true);
|
|
2557
|
-
}
|
|
2558
|
-
function runScript(pm, script) {
|
|
2559
|
-
return pm === "npm" ? `npm run ${script}` : `${pm} ${script}`;
|
|
2560
|
-
}
|
|
2561
|
-
var execInherit = (cmd, args, cwd) => new Promise((resolve, reject) => {
|
|
2562
|
-
const safe = shellSafe(cmd, args);
|
|
2563
|
-
const p = spawn(safe.cmd, safe.args, { cwd, stdio: "inherit", env: toolEnv(), shell: safe.shell });
|
|
2564
|
-
p.on("error", reject);
|
|
2565
|
-
p.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`)));
|
|
2566
|
-
});
|
|
2567
|
-
function packageDir(cliFile) {
|
|
2568
|
-
return path10.resolve(path10.dirname(cliFile), "..");
|
|
2569
|
-
}
|
|
2570
|
-
function packageVersion(pkgDir) {
|
|
2571
|
-
try {
|
|
2572
|
-
return JSON.parse(fs8.readFileSync(path10.join(pkgDir, "package.json"), "utf8")).version;
|
|
2573
|
-
} catch {
|
|
2574
|
-
return "0.0.0";
|
|
2575
|
-
}
|
|
2576
|
-
}
|
|
2577
|
-
function isSourceCheckout(pkgDir) {
|
|
2578
|
-
return !pkgDir.split(path10.sep).includes("node_modules") && fs8.existsSync(path10.join(pkgDir, "src", "cli"));
|
|
2579
|
-
}
|
|
2580
|
-
async function packageSpec(pkgDir, exec) {
|
|
2581
|
-
if (process.env.TRUECANVAS_PACKAGE) return process.env.TRUECANVAS_PACKAGE;
|
|
2582
|
-
const version = packageVersion(pkgDir);
|
|
2583
|
-
if (!isSourceCheckout(pkgDir)) return `truecanvas@${version}`;
|
|
2584
|
-
const cacheDir = path10.join(os4.homedir(), ".cache", "truecanvas");
|
|
2585
|
-
fs8.mkdirSync(cacheDir, { recursive: true });
|
|
2586
|
-
const file = path10.join(cacheDir, `truecanvas-${version}.tgz`);
|
|
2587
|
-
await exec("npm", ["pack", "--silent", "--pack-destination", cacheDir], pkgDir);
|
|
2588
|
-
const hash = createHash2("sha256").update(fs8.readFileSync(file)).digest("hex").slice(0, 10);
|
|
2589
|
-
const named = path10.join(cacheDir, `truecanvas-${version}-${hash}.tgz`);
|
|
2590
|
-
fs8.renameSync(file, named);
|
|
2591
|
-
return named;
|
|
2592
|
-
}
|
|
2593
|
-
function installedIn(root) {
|
|
2594
|
-
let declared = false;
|
|
2595
|
-
try {
|
|
2596
|
-
const pkg = JSON.parse(fs8.readFileSync(path10.join(root, "package.json"), "utf8"));
|
|
2597
|
-
declared = "truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies };
|
|
2598
|
-
} catch {
|
|
2599
|
-
}
|
|
2600
|
-
const file = path10.join(root, "node_modules", "truecanvas", "package.json");
|
|
2601
|
-
const installed = fs8.existsSync(file);
|
|
2602
|
-
return { declared, installed, version: installed ? packageVersion(path10.dirname(file)) : null };
|
|
2603
|
-
}
|
|
2604
|
-
function readPackage(root) {
|
|
2605
|
-
try {
|
|
2606
|
-
return JSON.parse(fs8.readFileSync(path10.join(root, "package.json"), "utf8"));
|
|
2607
|
-
} catch {
|
|
2608
|
-
return null;
|
|
2609
|
-
}
|
|
2610
|
-
}
|
|
2611
|
-
function depVersion(root, name) {
|
|
2612
|
-
const file = path10.join(root, "node_modules", name, "package.json");
|
|
2613
|
-
if (fs8.existsSync(file)) return packageVersion(path10.dirname(file));
|
|
2614
|
-
const pkg = readPackage(root);
|
|
2615
|
-
return pkg ? { ...pkg.dependencies, ...pkg.devDependencies }[name] ?? null : null;
|
|
2616
|
-
}
|
|
2617
|
-
function mergeJson(file, update) {
|
|
2618
|
-
let data2 = {};
|
|
2619
|
-
if (fs8.existsSync(file)) {
|
|
2620
|
-
try {
|
|
2621
|
-
data2 = JSON.parse(fs8.readFileSync(file, "utf8"));
|
|
2622
|
-
} catch {
|
|
2623
|
-
throw new Error(`${file} isn't valid JSON; fix it or remove it, then run connect again.`);
|
|
2624
|
-
}
|
|
2625
|
-
}
|
|
2626
|
-
const before = JSON.stringify(data2);
|
|
2627
|
-
update(data2);
|
|
2628
|
-
if (JSON.stringify(data2) === before) return "unchanged";
|
|
2629
|
-
fs8.mkdirSync(path10.dirname(file), { recursive: true });
|
|
2630
|
-
fs8.writeFileSync(file, `${JSON.stringify(data2, null, 2)}
|
|
2631
|
-
`);
|
|
2632
|
-
return "written";
|
|
2633
|
-
}
|
|
2634
|
-
function connectAgents(root, port, opts = {}) {
|
|
2635
|
-
const url = `http://localhost:${port}/mcp`;
|
|
2636
|
-
const out = { written: [], unchanged: [] };
|
|
2637
|
-
const add = (rel, update) => out[mergeJson(path10.join(root, rel), update)].push(rel);
|
|
2638
|
-
const servers = (d, key) => d[key] ??= {};
|
|
2639
|
-
add(".mcp.json", (d) => void (servers(d, "mcpServers").truecanvas = { type: "http", url }));
|
|
2640
|
-
if (opts.cursor || fs8.existsSync(path10.join(root, ".cursor"))) add(".cursor/mcp.json", (d) => void (servers(d, "mcpServers").truecanvas = { url }));
|
|
2641
|
-
if (opts.vscode || fs8.existsSync(path10.join(root, ".vscode"))) add(".vscode/mcp.json", (d) => void (servers(d, "servers").truecanvas = { type: "http", url }));
|
|
2642
|
-
return out;
|
|
2643
|
-
}
|
|
2644
|
-
function hasAgentConfig(root, port) {
|
|
2645
|
-
try {
|
|
2646
|
-
const d = JSON.parse(fs8.readFileSync(path10.join(root, ".mcp.json"), "utf8"));
|
|
2647
|
-
const url = d?.mcpServers?.truecanvas?.url;
|
|
2648
|
-
return typeof url === "string" && (port === void 0 || url.includes(`:${port}/`));
|
|
2649
|
-
} catch {
|
|
2650
|
-
return false;
|
|
2651
|
-
}
|
|
2652
|
-
}
|
|
2653
|
-
|
|
2654
|
-
// src/hub/runner.ts
|
|
2655
|
-
function portFree(port) {
|
|
2656
|
-
return new Promise((resolve) => {
|
|
2657
|
-
const srv = net.createServer();
|
|
2658
|
-
srv.once("error", () => resolve(false));
|
|
2659
|
-
srv.listen(port, "127.0.0.1", () => srv.close(() => resolve(true)));
|
|
2660
|
-
});
|
|
2661
|
-
}
|
|
2662
|
-
async function freePort(from, taken) {
|
|
2663
|
-
for (let p = from; p < from + 200; p++) if (!taken.has(p) && await portFree(p)) return p;
|
|
2664
|
-
throw new Error("No free port");
|
|
2665
|
-
}
|
|
2666
|
-
var Runner = class {
|
|
2667
|
-
constructor(cli, onChange) {
|
|
2668
|
-
this.cli = cli;
|
|
2669
|
-
this.onChange = onChange;
|
|
2670
|
-
}
|
|
2671
|
-
cli;
|
|
2672
|
-
onChange;
|
|
2673
|
-
running = /* @__PURE__ */ new Map();
|
|
2674
|
-
opening = /* @__PURE__ */ new Map();
|
|
2675
|
-
ports = Promise.resolve();
|
|
2676
|
-
/** Opens a project; concurrent calls for the same folder share one start. */
|
|
2677
|
-
open(dir) {
|
|
2678
|
-
const existing = this.running.get(dir);
|
|
2679
|
-
if (existing && existing.status !== "failed") return Promise.resolve(existing);
|
|
2680
|
-
const pending = this.opening.get(dir);
|
|
2681
|
-
if (pending) return pending;
|
|
2682
|
-
const job = this.start(dir).finally(() => this.opening.delete(dir));
|
|
2683
|
-
this.opening.set(dir, job);
|
|
2684
|
-
return job;
|
|
2685
|
-
}
|
|
2686
|
-
/** Two ports, picked one project at a time so parallel opens never collide. */
|
|
2687
|
-
allocatePorts() {
|
|
2688
|
-
const next = this.ports.then(async () => {
|
|
2689
|
-
const taken = new Set([...this.running.values()].flatMap((r) => [r.editorPort, r.appPort]));
|
|
2690
|
-
const editorPort = await freePort(4810, taken);
|
|
2691
|
-
taken.add(editorPort);
|
|
2692
|
-
return [editorPort, await freePort(3010, taken)];
|
|
2693
|
-
});
|
|
2694
|
-
this.ports = next.catch(() => {
|
|
2695
|
-
});
|
|
2696
|
-
return next;
|
|
2697
|
-
}
|
|
2698
|
-
async start(dir) {
|
|
2699
|
-
if (!fs9.existsSync(path11.join(dir, "package.json"))) throw new Error(`${dir} isn't there anymore (or has no package.json).`);
|
|
2700
|
-
const [editorPort, appPort] = await this.allocatePorts();
|
|
2701
|
-
const proc = spawn2(process.execPath, [this.cli, "dev", "--no-open", "--port", String(editorPort), "--app", `http://localhost:${appPort}`], {
|
|
2702
|
-
cwd: dir,
|
|
2703
|
-
env: toolEnv({ FORCE_COLOR: "0", TRUECANVAS_HUB: "1" }),
|
|
2704
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
2705
|
-
// its own process group: closing the project also stops the next dev it started
|
|
2706
|
-
detached: process.platform !== "win32"
|
|
2707
|
-
});
|
|
2708
|
-
const run3 = { path: dir, editorPort, appPort, proc, status: "starting", log: [], startedAt: Date.now() };
|
|
2709
|
-
this.running.set(dir, run3);
|
|
2710
|
-
const capture2 = (chunk) => {
|
|
2711
|
-
for (const line of chunk.toString().split("\n")) if (line.trim()) run3.log.push(line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
2712
|
-
if (run3.log.length > 200) run3.log.splice(0, run3.log.length - 200);
|
|
2713
|
-
};
|
|
2714
|
-
proc.stdout.on("data", capture2);
|
|
2715
|
-
proc.stderr.on("data", capture2);
|
|
2716
|
-
proc.on("error", (err) => {
|
|
2717
|
-
run3.status = "failed";
|
|
2718
|
-
run3.error = err.message;
|
|
2719
|
-
this.onChange();
|
|
2720
|
-
});
|
|
2721
|
-
proc.on("exit", (code) => {
|
|
2722
|
-
if (this.running.get(dir) !== run3) return;
|
|
2723
|
-
if (run3.status === "starting" || code) {
|
|
2724
|
-
run3.status = "failed";
|
|
2725
|
-
run3.error = run3.log.slice(-6).join("\n") || `exited with ${code}`;
|
|
2726
|
-
} else this.running.delete(dir);
|
|
2727
|
-
this.onChange();
|
|
2728
|
-
});
|
|
2729
|
-
this.onChange();
|
|
2730
|
-
const until = Date.now() + 6e4;
|
|
2731
|
-
while (Date.now() < until && run3.status === "starting") {
|
|
2732
|
-
try {
|
|
2733
|
-
const res = await fetch(`http://127.0.0.1:${editorPort}/api/state`, { signal: AbortSignal.timeout(1e3) });
|
|
2734
|
-
if (res.ok) {
|
|
2735
|
-
run3.status = "ready";
|
|
2736
|
-
break;
|
|
2737
|
-
}
|
|
2738
|
-
} catch {
|
|
2739
|
-
}
|
|
2740
|
-
await new Promise((r) => setTimeout(r, 400));
|
|
2741
|
-
}
|
|
2742
|
-
if (run3.status === "starting") {
|
|
2743
|
-
run3.status = "failed";
|
|
2744
|
-
run3.error = "Truecanvas didn't start within a minute.";
|
|
2745
|
-
killTree(run3.proc, "SIGTERM");
|
|
2746
|
-
}
|
|
2747
|
-
this.onChange();
|
|
2748
|
-
return run3;
|
|
2749
|
-
}
|
|
2750
|
-
/** Stops a project and everything it started; resolves once it has exited (or after 5s). */
|
|
2751
|
-
close(dir) {
|
|
2752
|
-
const run3 = this.running.get(dir);
|
|
2753
|
-
if (!run3) return Promise.resolve();
|
|
2754
|
-
this.running.delete(dir);
|
|
2755
|
-
this.onChange();
|
|
2756
|
-
if (run3.proc.exitCode !== null || run3.proc.signalCode !== null) return Promise.resolve();
|
|
2757
|
-
return new Promise((resolve) => {
|
|
2758
|
-
const force = setTimeout(() => killTree(run3.proc, "SIGKILL"), 4e3);
|
|
2759
|
-
const done = setTimeout(resolve, 5e3);
|
|
2760
|
-
run3.proc.once("exit", () => {
|
|
2761
|
-
clearTimeout(force);
|
|
2762
|
-
clearTimeout(done);
|
|
2763
|
-
resolve();
|
|
2764
|
-
});
|
|
2765
|
-
killTree(run3.proc, "SIGTERM");
|
|
2766
|
-
});
|
|
2767
|
-
}
|
|
2768
|
-
closeAll() {
|
|
2769
|
-
return Promise.all([...this.running.keys()].map((dir) => this.close(dir))).then(() => {
|
|
2770
|
-
});
|
|
2771
|
-
}
|
|
2772
|
-
/** Resident memory (MB) of each project's process tree. */
|
|
2773
|
-
memory() {
|
|
2774
|
-
return new Promise((resolve) => {
|
|
2775
|
-
execFile("ps", ["-eo", "pid=,ppid=,rss="], (err, out) => {
|
|
2776
|
-
if (err) return resolve({});
|
|
2777
|
-
const rows = out.trim().split("\n").map((l) => l.trim().split(/\s+/).map(Number));
|
|
2778
|
-
const kids = /* @__PURE__ */ new Map();
|
|
2779
|
-
const rss = /* @__PURE__ */ new Map();
|
|
2780
|
-
for (const [pid, ppid, kb] of rows) {
|
|
2781
|
-
rss.set(pid, kb);
|
|
2782
|
-
kids.set(ppid, [...kids.get(ppid) ?? [], pid]);
|
|
2783
|
-
}
|
|
2784
|
-
const total = (pid) => (rss.get(pid) ?? 0) + (kids.get(pid) ?? []).reduce((s, k) => s + total(k), 0);
|
|
2785
|
-
const outMap = {};
|
|
2786
|
-
for (const [dir, run3] of this.running) if (run3.proc.pid) outMap[dir] = Math.round(total(run3.proc.pid) / 1024);
|
|
2787
|
-
resolve(outMap);
|
|
2788
|
-
});
|
|
2789
|
-
});
|
|
2790
|
-
}
|
|
2791
|
-
};
|
|
2792
|
-
function killTree(proc, signal) {
|
|
2793
|
-
try {
|
|
2794
|
-
if (process.platform === "win32" && proc.pid) spawn2("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { stdio: "ignore" }).on("error", () => proc.kill());
|
|
2795
|
-
else if (proc.pid) process.kill(-proc.pid, signal);
|
|
2796
|
-
else proc.kill(signal);
|
|
2797
|
-
} catch {
|
|
2798
|
-
proc.kill(signal);
|
|
2799
|
-
}
|
|
2800
|
-
}
|
|
2801
|
-
var jobs = /* @__PURE__ */ new Map();
|
|
2802
|
-
var jobSeq = 0;
|
|
2803
|
-
function getJob(id) {
|
|
2804
|
-
return jobs.get(id);
|
|
2805
|
-
}
|
|
2806
|
-
async function install(job, cmd, args, cwd) {
|
|
2807
|
-
try {
|
|
2808
|
-
await run2(job, cmd, args, cwd);
|
|
2809
|
-
} catch (err) {
|
|
2810
|
-
const ignoredBuilds = job.log.some((l) => l.includes("ERR_PNPM_IGNORED_BUILDS"));
|
|
2811
|
-
const installed = fs9.existsSync(path11.join(cwd, "node_modules", "truecanvas", "package.json"));
|
|
2812
|
-
if (!(ignoredBuilds && installed)) throw err;
|
|
2813
|
-
job.log.push("(pnpm only complained about other packages' build scripts; Truecanvas is installed, continuing)");
|
|
2814
|
-
}
|
|
2815
|
-
}
|
|
2816
|
-
function run2(job, cmd, args, cwd) {
|
|
2817
|
-
job.log.push(`$ ${cmd} ${args.join(" ")}`);
|
|
2818
|
-
return new Promise((resolve, reject) => {
|
|
2819
|
-
const safe = shellSafe(cmd, args);
|
|
2820
|
-
const p = spawn2(safe.cmd, safe.args, { cwd, env: toolEnv({ CI: "1", FORCE_COLOR: "0" }), stdio: ["ignore", "pipe", "pipe"], shell: safe.shell });
|
|
2821
|
-
const capture2 = (c2) => {
|
|
2822
|
-
for (const line of c2.toString().split(/\r?\n/)) if (line.trim()) job.log.push(line.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, ""));
|
|
2823
|
-
if (job.log.length > 300) job.log.splice(0, job.log.length - 300);
|
|
2824
|
-
};
|
|
2825
|
-
p.stdout.on("data", capture2);
|
|
2826
|
-
p.stderr.on("data", capture2);
|
|
2827
|
-
p.on("error", reject);
|
|
2828
|
-
p.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} exited with ${code}`)));
|
|
2829
|
-
});
|
|
2830
|
-
}
|
|
2831
|
-
function startJob(title, work) {
|
|
2832
|
-
const job = { id: String(++jobSeq), title, status: "running", log: [] };
|
|
2833
|
-
jobs.set(job.id, job);
|
|
2834
|
-
work(job).then((result) => {
|
|
2835
|
-
job.status = "done";
|
|
2836
|
-
if (result) job.result = result;
|
|
2837
|
-
}).catch((err) => {
|
|
2838
|
-
job.status = "failed";
|
|
2839
|
-
job.log.push(`\u2717 ${err.message}`);
|
|
2840
|
-
});
|
|
2841
|
-
return job;
|
|
2842
|
-
}
|
|
2843
|
-
function packageManager(dir) {
|
|
2844
|
-
const pm = detectPm(dir);
|
|
2845
|
-
return { cmd: pm, add: addArgs(pm, [], true) };
|
|
2846
|
-
}
|
|
2847
|
-
async function preferredPm() {
|
|
2848
|
-
const { ok } = await capture("pnpm", ["--version"], os5.homedir());
|
|
2849
|
-
return ok ? "pnpm" : "npm";
|
|
2850
|
-
}
|
|
2851
|
-
function setupJob(dir, pkgDir, cli) {
|
|
2852
|
-
return startJob(`Set up ${path11.basename(dir)}`, async (job) => {
|
|
2853
|
-
const pm = packageManager(dir);
|
|
2854
|
-
const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
|
|
2855
|
-
await install(job, pm.cmd, [...pm.add, source], dir);
|
|
2856
|
-
await run2(job, process.execPath, [cli, "init"], dir);
|
|
2857
|
-
return dir;
|
|
2858
|
-
});
|
|
2859
|
-
}
|
|
2860
|
-
function createJob(name, parent, pkgDir, cli) {
|
|
2861
|
-
const safe = name.trim().toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2862
|
-
const parentDir = path11.resolve(parent.replace(/^~(?=$|[\\/])/, os5.homedir()));
|
|
2863
|
-
const dir = path11.join(parentDir, safe);
|
|
2864
|
-
return startJob(`Create ${safe}`, async (job) => {
|
|
2865
|
-
if (!safe) throw new Error("Pick a project name.");
|
|
2866
|
-
if (fs9.existsSync(dir)) throw new Error(`${dir} already exists.`);
|
|
2867
|
-
fs9.mkdirSync(parentDir, { recursive: true });
|
|
2868
|
-
const pm = await preferredPm();
|
|
2869
|
-
await run2(job, "npx", ["--yes", "create-next-app@latest", safe, "--ts", "--tailwind", "--app", "--eslint", "--no-src-dir", "--import-alias", "@/*", `--use-${pm}`, "--turbopack", "--yes"], parentDir);
|
|
2870
|
-
const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
|
|
2871
|
-
await install(job, pm, addArgs(pm, [source], true), dir);
|
|
2872
|
-
await run2(job, process.execPath, [cli, "init"], dir);
|
|
2873
|
-
return dir;
|
|
2874
|
-
});
|
|
2875
|
-
}
|
|
2876
|
-
function capture(cmd, args, cwd) {
|
|
2877
|
-
return new Promise((resolve) => {
|
|
2878
|
-
const safe = shellSafe(cmd, args);
|
|
2879
|
-
execFile(
|
|
2880
|
-
safe.cmd,
|
|
2881
|
-
safe.args,
|
|
2882
|
-
{ shell: safe.shell, cwd, timeout: 6e4, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" } },
|
|
2883
|
-
(err, stdout, stderr) => resolve({ ok: !err, out: err ? (stderr || err.message).trim() : stdout })
|
|
2884
|
-
);
|
|
2885
|
-
});
|
|
2886
|
-
}
|
|
2887
|
-
function cloneJob(repo, parent, pkgDir, cli) {
|
|
2888
|
-
const parentDir = path11.resolve(parent.replace(/^~(?=$|[\\/])/, os5.homedir()));
|
|
2889
|
-
const name = repo.replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
|
|
2890
|
-
const dir = path11.join(parentDir, name);
|
|
2891
|
-
return startJob(`Clone ${name}`, async (job) => {
|
|
2892
|
-
if (!name || !/^[\w.-]+$/.test(name)) throw new Error("Pick a repository (owner/name or a git URL).");
|
|
2893
|
-
if (fs9.existsSync(dir)) throw new Error(`${dir} already exists. Open it with Open folder instead.`);
|
|
2894
|
-
fs9.mkdirSync(parentDir, { recursive: true });
|
|
2895
|
-
const url = /^(https?:|git@|ssh:|file:)/.test(repo);
|
|
2896
|
-
if (url) await run2(job, "git", ["clone", repo, dir], parentDir);
|
|
2897
|
-
else await run2(job, "gh", ["repo", "clone", repo, dir], parentDir);
|
|
2898
|
-
if (!fs9.existsSync(path11.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.");
|
|
2899
|
-
const pm = packageManager(dir);
|
|
2900
|
-
await install(job, pm.cmd, ["install"], dir).catch(async (err) => {
|
|
2901
|
-
if (!fs9.existsSync(path11.join(dir, "node_modules"))) throw err;
|
|
2902
|
-
});
|
|
2903
|
-
const pkg = JSON.parse(fs9.readFileSync(path11.join(dir, "package.json"), "utf8"));
|
|
2904
|
-
if (!("truecanvas" in { ...pkg.dependencies, ...pkg.devDependencies })) {
|
|
2905
|
-
const source = await packageSpec(pkgDir, (cmd, args, cwd) => run2(job, cmd, args, cwd));
|
|
2906
|
-
await install(job, pm.cmd, [...pm.add, source], dir);
|
|
2907
|
-
await run2(job, process.execPath, [cli, "init"], dir);
|
|
2908
|
-
} else job.log.push("Truecanvas is already part of this project.");
|
|
2909
|
-
return dir;
|
|
2910
|
-
});
|
|
2911
|
-
}
|
|
2912
|
-
|
|
2913
|
-
// src/hub/server.ts
|
|
2914
|
-
var MIME2 = {
|
|
2915
|
-
".html": "text/html; charset=utf-8",
|
|
2916
|
-
".js": "text/javascript; charset=utf-8",
|
|
2917
|
-
".css": "text/css; charset=utf-8",
|
|
2918
|
-
".svg": "image/svg+xml",
|
|
2919
|
-
".woff2": "font/woff2",
|
|
2920
|
-
".png": "image/png"
|
|
2921
|
-
};
|
|
2922
|
-
var HUB_INSTRUCTIONS = `Truecanvas hub: several projects, each a Next.js or Vite + React app with canvases of real React components.
|
|
2923
|
-
Your tools act on the current project (the active tab in the Truecanvas window, unless you pick one with open_project).
|
|
2924
|
-
Use list_projects to see them. All other tools are the regular Truecanvas canvas tools for that project.`;
|
|
2925
|
-
async function startHub(opts) {
|
|
2926
|
-
const store = new ProjectStore();
|
|
2927
|
-
const editorDir = fileURLToPath2(new URL("./editor/", import.meta.url));
|
|
2928
|
-
const pkgDir = path12.resolve(path12.dirname(opts.cli), "..");
|
|
2929
|
-
const origin = `http://localhost:${opts.port}`;
|
|
2930
|
-
const allowedHosts = /* @__PURE__ */ new Set([`localhost:${opts.port}`, `127.0.0.1:${opts.port}`]);
|
|
2931
|
-
const clients = /* @__PURE__ */ new Set();
|
|
2932
|
-
let active = null;
|
|
2933
|
-
const broadcast = () => {
|
|
2934
|
-
for (const res of clients) res.write(`data: {"type":"state"}
|
|
2935
|
-
|
|
2936
|
-
`);
|
|
2937
|
-
for (const fn of toolListeners) fn();
|
|
2938
|
-
};
|
|
2939
|
-
const runner = new Runner(opts.cli, broadcast);
|
|
2940
|
-
setInterval(() => {
|
|
2941
|
-
for (const res of clients) res.write(": ping\n\n");
|
|
2942
|
-
}, 2e4).unref();
|
|
2943
|
-
let focus = null;
|
|
2944
|
-
async function state() {
|
|
2945
|
-
const memory = await runner.memory();
|
|
2946
|
-
return {
|
|
2947
|
-
origin,
|
|
2948
|
-
active,
|
|
2949
|
-
focus,
|
|
2950
|
-
projects: store.all().map((p) => {
|
|
2951
|
-
const r = runner.running.get(p.path);
|
|
2952
|
-
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 };
|
|
2953
|
-
})
|
|
2954
|
-
};
|
|
2955
|
-
}
|
|
2956
|
-
const toolListeners = /* @__PURE__ */ new Set();
|
|
2957
|
-
const transports = /* @__PURE__ */ new Map();
|
|
2958
|
-
const lastUse = /* @__PURE__ */ new Map();
|
|
2959
|
-
setInterval(() => {
|
|
2960
|
-
for (const [id, at] of lastUse) {
|
|
2961
|
-
if (Date.now() - at < 60 * 6e4) continue;
|
|
2962
|
-
lastUse.delete(id);
|
|
2963
|
-
void transports.get(id)?.close();
|
|
2964
|
-
}
|
|
2965
|
-
}, 5 * 6e4).unref();
|
|
2966
|
-
function createProxy() {
|
|
2967
|
-
const server2 = new Server2({ name: "truecanvas", version: "0.1.0" }, { capabilities: { tools: { listChanged: true } }, instructions: HUB_INSTRUCTIONS });
|
|
2968
|
-
let current = null;
|
|
2969
|
-
const upstreams = /* @__PURE__ */ new Map();
|
|
2970
|
-
const target = () => current ?? active;
|
|
2971
|
-
const drop = (dir) => {
|
|
2972
|
-
const hit = upstreams.get(dir);
|
|
2973
|
-
upstreams.delete(dir);
|
|
2974
|
-
void hit?.client.then((c2) => c2.close()).catch(() => {
|
|
2975
|
-
});
|
|
2976
|
-
};
|
|
2977
|
-
const upstream = (dir) => {
|
|
2978
|
-
const run3 = runner.running.get(dir);
|
|
2979
|
-
if (!run3 || run3.status !== "ready") return null;
|
|
2980
|
-
const hit = upstreams.get(dir);
|
|
2981
|
-
if (hit && hit.pid === run3.proc.pid) return hit.client;
|
|
2982
|
-
if (hit) drop(dir);
|
|
2983
|
-
const info = server2.getClientVersion();
|
|
2984
|
-
const client = new Client2({ name: info?.name ?? "agent", version: info?.version ?? "0.0.0" });
|
|
2985
|
-
const c2 = client.connect(new StreamableHTTPClientTransport2(new URL(`http://127.0.0.1:${run3.editorPort}/mcp`))).then(() => client);
|
|
2986
|
-
c2.catch(() => upstreams.delete(dir));
|
|
2987
|
-
upstreams.set(dir, { pid: run3.proc.pid, client: c2 });
|
|
2988
|
-
return c2;
|
|
2989
|
-
};
|
|
2990
|
-
const withUpstream = async (dir, fn) => {
|
|
2991
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2992
|
-
const up = upstream(dir);
|
|
2993
|
-
if (!up) return null;
|
|
2994
|
-
try {
|
|
2995
|
-
return await fn(await up);
|
|
2996
|
-
} catch (err) {
|
|
2997
|
-
drop(dir);
|
|
2998
|
-
const lost = /session|not connected|ECONNREFUSED|ECONNRESET|fetch failed|404/i.test(err.message ?? "");
|
|
2999
|
-
if (attempt === 1 || !lost) throw err;
|
|
3000
|
-
}
|
|
3001
|
-
}
|
|
3002
|
-
return null;
|
|
3003
|
-
};
|
|
3004
|
-
const notify = () => void server2.sendToolListChanged().catch(() => {
|
|
3005
|
-
});
|
|
3006
|
-
toolListeners.add(notify);
|
|
3007
|
-
server2.onclose = () => {
|
|
3008
|
-
toolListeners.delete(notify);
|
|
3009
|
-
for (const dir of [...upstreams.keys()]) drop(dir);
|
|
3010
|
-
};
|
|
3011
|
-
const hubTools = [
|
|
3012
|
-
{ name: "list_projects", description: "Projects known to the Truecanvas hub, which are open, and which one your tools act on.", inputSchema: { type: "object", properties: {} } },
|
|
3013
|
-
{
|
|
3014
|
-
name: "open_project",
|
|
3015
|
-
description: "Open a project (starts its app if needed) and make it the target of your tools.",
|
|
3016
|
-
inputSchema: { type: "object", properties: { project: { type: "string", description: "Project name or path." } }, required: ["project"] }
|
|
3017
|
-
}
|
|
3018
|
-
];
|
|
3019
|
-
server2.setRequestHandler(ListToolsRequestSchema2, async () => {
|
|
3020
|
-
const dir = target();
|
|
3021
|
-
const projectTools = dir ? (await withUpstream(dir, (c2) => c2.listTools()).catch(() => null))?.tools ?? [] : [];
|
|
3022
|
-
return { tools: [...hubTools, ...projectTools] };
|
|
3023
|
-
});
|
|
3024
|
-
server2.setRequestHandler(CallToolRequestSchema2, async (req) => {
|
|
3025
|
-
const text = (t, isError = false) => ({ content: [{ type: "text", text: t }], isError });
|
|
3026
|
-
if (req.params.name === "list_projects") {
|
|
3027
|
-
const s = await state();
|
|
3028
|
-
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)"}`);
|
|
3029
|
-
return text(lines.join("\n") || "No projects yet. The user can add some from the Dashboard.");
|
|
3030
|
-
}
|
|
3031
|
-
if (req.params.name === "open_project") {
|
|
3032
|
-
const q = String(req.params.arguments?.project ?? "").toLowerCase();
|
|
3033
|
-
const p = store.all().find((x) => x.name.toLowerCase() === q || x.path.toLowerCase() === q || path12.basename(x.path).toLowerCase() === q);
|
|
3034
|
-
if (!p) return text(`No project "${q}". Use list_projects.`, true);
|
|
3035
|
-
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);
|
|
3036
|
-
store.touch(p.path);
|
|
3037
|
-
const run3 = await runner.open(p.path);
|
|
3038
|
-
if (run3.status !== "ready") return text(`Couldn't start ${p.name}: ${run3.error}`, true);
|
|
3039
|
-
current = p.path;
|
|
3040
|
-
notify();
|
|
3041
|
-
return text(`\u2713 ${p.name} is open. Your tools now act on it.`);
|
|
3042
|
-
}
|
|
3043
|
-
const dir = target();
|
|
3044
|
-
const out = dir ? await withUpstream(dir, (c2) => c2.callTool(req.params, void 0, { timeout: 18e4 })) : null;
|
|
3045
|
-
if (!out) return text("No project is open. Use list_projects and open_project, or open one in the Truecanvas window.", true);
|
|
3046
|
-
return out;
|
|
3047
|
-
});
|
|
3048
|
-
return server2;
|
|
3049
|
-
}
|
|
3050
|
-
async function handleMcp(req, res) {
|
|
3051
|
-
const sessionId = req.headers["mcp-session-id"];
|
|
3052
|
-
const body = req.method === "POST" ? await readJson(req) : void 0;
|
|
3053
|
-
let transport = sessionId ? transports.get(sessionId) : void 0;
|
|
3054
|
-
if (sessionId && transport) lastUse.set(sessionId, Date.now());
|
|
3055
|
-
if (!transport) {
|
|
3056
|
-
if (req.method !== "POST" || !isInitializeRequest2(body)) return json2(res, 400, { jsonrpc: "2.0", error: { code: -32e3, message: "No valid MCP session." }, id: null });
|
|
3057
|
-
const t = new StreamableHTTPServerTransport2({
|
|
3058
|
-
sessionIdGenerator: () => randomUUID2(),
|
|
3059
|
-
onsessioninitialized: (id) => {
|
|
3060
|
-
transports.set(id, t);
|
|
3061
|
-
lastUse.set(id, Date.now());
|
|
3062
|
-
}
|
|
3063
|
-
});
|
|
3064
|
-
t.onclose = () => {
|
|
3065
|
-
if (t.sessionId) {
|
|
3066
|
-
transports.delete(t.sessionId);
|
|
3067
|
-
lastUse.delete(t.sessionId);
|
|
3068
|
-
}
|
|
3069
|
-
};
|
|
3070
|
-
await createProxy().connect(t);
|
|
3071
|
-
transport = t;
|
|
3072
|
-
}
|
|
3073
|
-
await transport.handleRequest(req, res, body);
|
|
3074
|
-
}
|
|
3075
|
-
async function api(req, res, url) {
|
|
3076
|
-
const route = `${req.method} ${url.pathname}`;
|
|
3077
|
-
const body = req.method === "POST" ? await readJson(req) : {};
|
|
3078
|
-
switch (route) {
|
|
3079
|
-
case "GET /api/hub/state":
|
|
3080
|
-
return json2(res, 200, await state());
|
|
3081
|
-
case "GET /api/hub/discover": {
|
|
3082
|
-
const known = new Set(store.all().map((p) => p.path));
|
|
3083
|
-
return json2(res, 200, { found: discover().filter((f) => !known.has(f.path)) });
|
|
3084
|
-
}
|
|
3085
|
-
case "GET /api/hub/icon": {
|
|
3086
|
-
const icon = findIcon(url.searchParams.get("path") ?? "");
|
|
3087
|
-
if (!icon) return json2(res, 404, { error: "No icon" });
|
|
3088
|
-
res.writeHead(200, { "content-type": ICON_MIME[path12.extname(icon)] ?? "application/octet-stream", "cache-control": "private, max-age=300" });
|
|
3089
|
-
return fs10.createReadStream(icon).on("error", () => res.destroy()).pipe(res);
|
|
3090
|
-
}
|
|
3091
|
-
case "GET /api/hub/ls":
|
|
3092
|
-
return json2(res, 200, listDirs(url.searchParams.get("path") || "~"));
|
|
3093
|
-
case "GET /api/hub/events":
|
|
3094
|
-
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
3095
|
-
res.write(": connected\n\n");
|
|
3096
|
-
clients.add(res);
|
|
3097
|
-
req.on("close", () => clients.delete(res));
|
|
3098
|
-
return;
|
|
3099
|
-
case "POST /api/hub/add": {
|
|
3100
|
-
const dir = path12.resolve(body.path.replace(/^~(?=$|[\\/])/, os6.homedir()));
|
|
3101
|
-
if (!fs10.existsSync(path12.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." });
|
|
3102
|
-
store.touch(dir);
|
|
3103
|
-
broadcast();
|
|
3104
|
-
return json2(res, 200, { path: dir, ready: store.all().find((p) => p.path === dir)?.ready ?? false });
|
|
3105
|
-
}
|
|
3106
|
-
case "POST /api/hub/open": {
|
|
3107
|
-
store.touch(body.path);
|
|
3108
|
-
active = body.path;
|
|
3109
|
-
if (body.focus) focus = { path: body.path, at: Date.now() };
|
|
3110
|
-
let run3;
|
|
3111
|
-
try {
|
|
3112
|
-
run3 = await runner.open(body.path);
|
|
3113
|
-
} catch (err) {
|
|
3114
|
-
broadcast();
|
|
3115
|
-
return json2(res, 400, { error: err.message });
|
|
3116
|
-
}
|
|
3117
|
-
broadcast();
|
|
3118
|
-
return json2(res, 200, { status: run3.status, editor: `http://localhost:${run3.editorPort}`, error: run3.error });
|
|
3119
|
-
}
|
|
3120
|
-
case "POST /api/hub/close":
|
|
3121
|
-
runner.close(body.path);
|
|
3122
|
-
if (active === body.path) active = null;
|
|
3123
|
-
broadcast();
|
|
3124
|
-
return json2(res, 200, { ok: true });
|
|
3125
|
-
case "POST /api/hub/active":
|
|
3126
|
-
active = body.path || null;
|
|
3127
|
-
broadcast();
|
|
3128
|
-
return json2(res, 200, { ok: true });
|
|
3129
|
-
case "POST /api/hub/forget":
|
|
3130
|
-
runner.close(body.path);
|
|
3131
|
-
store.forget(body.path);
|
|
3132
|
-
broadcast();
|
|
3133
|
-
return json2(res, 200, { ok: true });
|
|
3134
|
-
case "POST /api/hub/setup":
|
|
3135
|
-
return json2(res, 200, setupJob(body.path, pkgDir, opts.cli));
|
|
3136
|
-
case "POST /api/hub/create":
|
|
3137
|
-
return json2(res, 200, createJob(body.name, body.parent, pkgDir, opts.cli));
|
|
3138
|
-
// ---------- GitHub ----------
|
|
3139
|
-
case "GET /api/hub/github/repos": {
|
|
3140
|
-
const r = await capture("gh", ["repo", "list", "--limit", "100", "--json", "nameWithOwner,description,updatedAt,isPrivate"], os6.homedir());
|
|
3141
|
-
if (!r.ok) return json2(res, 200, { gh: false, error: r.out.split("\n")[0], repos: [] });
|
|
3142
|
-
const repos = JSON.parse(r.out).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
3143
|
-
return json2(res, 200, { gh: true, repos });
|
|
3144
|
-
}
|
|
3145
|
-
case "POST /api/hub/clone":
|
|
3146
|
-
return json2(res, 200, cloneJob(body.repo, body.parent || "~", pkgDir, opts.cli));
|
|
3147
|
-
case "GET /api/hub/prs": {
|
|
3148
|
-
const dir = url.searchParams.get("path") ?? "";
|
|
3149
|
-
const r = await capture("gh", ["pr", "list", "--limit", "30", "--json", "number,title,headRefName,author,updatedAt,isDraft,url"], dir);
|
|
3150
|
-
if (!r.ok) {
|
|
3151
|
-
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];
|
|
3152
|
-
return json2(res, 200, { error: msg, prs: [] });
|
|
3153
|
-
}
|
|
3154
|
-
return json2(res, 200, { prs: JSON.parse(r.out) });
|
|
3155
|
-
}
|
|
3156
|
-
case "POST /api/hub/pr-checkout": {
|
|
3157
|
-
const r = await capture("gh", ["pr", "checkout", String(Number(body.number))], body.path);
|
|
3158
|
-
if (!r.ok) {
|
|
3159
|
-
const dirty = /local changes|would be overwritten|uncommitted/i.test(r.out);
|
|
3160
|
-
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(" ") });
|
|
3161
|
-
}
|
|
3162
|
-
store.touch(body.path);
|
|
3163
|
-
active = body.path;
|
|
3164
|
-
await runner.open(body.path);
|
|
3165
|
-
broadcast();
|
|
3166
|
-
return json2(res, 200, { ok: true });
|
|
3167
|
-
}
|
|
3168
|
-
case "POST /api/hub/quit":
|
|
3169
|
-
json2(res, 200, { ok: true });
|
|
3170
|
-
void runner.closeAll().finally(() => process.exit(0));
|
|
3171
|
-
return;
|
|
3172
|
-
}
|
|
3173
|
-
const job = /^GET \/api\/hub\/jobs\/(\d+)$/.exec(route);
|
|
3174
|
-
if (job) {
|
|
3175
|
-
const j = getJob(job[1]);
|
|
3176
|
-
if (j?.status === "done" && j.result) {
|
|
3177
|
-
store.touch(j.result);
|
|
3178
|
-
broadcast();
|
|
3179
|
-
}
|
|
3180
|
-
return j ? json2(res, 200, j) : json2(res, 404, { error: "No such job" });
|
|
3181
|
-
}
|
|
3182
|
-
return json2(res, 404, { error: `No route ${route}` });
|
|
3183
|
-
}
|
|
3184
|
-
function serveStatic(res, pathname) {
|
|
3185
|
-
let file = path12.join(editorDir, pathname === "/" ? "hub.html" : pathname);
|
|
3186
|
-
if (!inside2(editorDir, file) || !fs10.existsSync(file) || fs10.statSync(file).isDirectory()) file = path12.join(editorDir, "hub.html");
|
|
3187
|
-
if (!fs10.existsSync(file)) {
|
|
3188
|
-
res.writeHead(500, { "content-type": "text/plain" });
|
|
3189
|
-
return res.end("Hub bundle missing. Run `pnpm build`.");
|
|
3190
|
-
}
|
|
3191
|
-
res.writeHead(200, { "content-type": MIME2[path12.extname(file)] ?? "application/octet-stream", "cache-control": pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache" });
|
|
3192
|
-
fs10.createReadStream(file).on("error", () => res.destroy()).pipe(res);
|
|
3193
|
-
}
|
|
3194
|
-
const server = http2.createServer(async (req, res) => {
|
|
3195
|
-
try {
|
|
3196
|
-
if (!allowedHosts.has(req.headers.host ?? "")) return json2(res, 403, { error: "Forbidden host" });
|
|
3197
|
-
const reqOrigin = req.headers.origin;
|
|
3198
|
-
if (reqOrigin && reqOrigin !== origin && reqOrigin !== `http://127.0.0.1:${opts.port}`) return json2(res, 403, { error: "Forbidden origin" });
|
|
3199
|
-
const url = new URL(req.url ?? "/", origin);
|
|
3200
|
-
if (url.pathname === "/mcp") return await handleMcp(req, res);
|
|
3201
|
-
if (url.pathname.startsWith("/api/")) return await api(req, res, url);
|
|
3202
|
-
if (req.method === "GET") return serveStatic(res, url.pathname);
|
|
3203
|
-
json2(res, 405, { error: "Method not allowed" });
|
|
3204
|
-
} catch (err) {
|
|
3205
|
-
if (!res.headersSent) json2(res, err instanceof EditError ? 400 : 500, { error: err.message });
|
|
3206
|
-
else res.end();
|
|
3207
|
-
}
|
|
3208
|
-
});
|
|
3209
|
-
await new Promise((resolve, reject) => {
|
|
3210
|
-
server.once("error", reject);
|
|
3211
|
-
server.listen(opts.port, "127.0.0.1", resolve);
|
|
3212
|
-
});
|
|
3213
|
-
const shutdown = () => {
|
|
3214
|
-
server.close();
|
|
3215
|
-
return runner.closeAll();
|
|
3216
|
-
};
|
|
3217
|
-
const exit = () => void shutdown().finally(() => process.exit(0));
|
|
3218
|
-
process.once("SIGINT", exit);
|
|
3219
|
-
process.once("SIGTERM", exit);
|
|
3220
|
-
return { origin, shutdown };
|
|
3221
|
-
}
|
|
3222
|
-
var ICON_MIME = { ".ico": "image/x-icon", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
|
|
3223
|
-
function findIcon(dir) {
|
|
3224
|
-
if (!dir || !fs10.existsSync(path12.join(dir, "package.json"))) return null;
|
|
3225
|
-
const names = ["icon.svg", "icon.png", "favicon.svg", "favicon.ico", "icon.ico", "apple-icon.png", "favicon.png", "logo.svg", "icon.jpg"];
|
|
3226
|
-
const roots = ["app", "src/app", "public", "static", "assets"];
|
|
3227
|
-
for (const root of roots) {
|
|
3228
|
-
for (const name of names) {
|
|
3229
|
-
const file = path12.join(dir, root, name);
|
|
3230
|
-
if (fs10.existsSync(file) && fs10.statSync(file).size < 2e6) return file;
|
|
3231
|
-
}
|
|
3232
|
-
}
|
|
3233
|
-
for (const root of ["app", "src/app"]) {
|
|
3234
|
-
let entries = [];
|
|
3235
|
-
try {
|
|
3236
|
-
entries = fs10.readdirSync(path12.join(dir, root), { withFileTypes: true });
|
|
3237
|
-
} catch {
|
|
3238
|
-
continue;
|
|
3239
|
-
}
|
|
3240
|
-
for (const e of entries) {
|
|
3241
|
-
if (!e.isDirectory()) continue;
|
|
3242
|
-
for (const name of names.slice(0, 4)) {
|
|
3243
|
-
const file = path12.join(dir, root, e.name, name);
|
|
3244
|
-
if (fs10.existsSync(file)) return file;
|
|
3245
|
-
}
|
|
3246
|
-
}
|
|
3247
|
-
}
|
|
3248
|
-
return null;
|
|
3249
|
-
}
|
|
3250
|
-
function json2(res, status, body) {
|
|
3251
|
-
res.writeHead(status, { "content-type": "application/json" });
|
|
3252
|
-
res.end(JSON.stringify(body));
|
|
3253
|
-
}
|
|
3254
|
-
function inside2(dir, file) {
|
|
3255
|
-
const rel = path12.relative(dir, file);
|
|
3256
|
-
return !!rel && !rel.startsWith("..") && !path12.isAbsolute(rel);
|
|
3257
|
-
}
|
|
3258
|
-
|
|
3259
|
-
// src/hub/desktop.ts
|
|
3260
|
-
import fs11 from "fs";
|
|
3261
|
-
import os7 from "os";
|
|
3262
|
-
import path13 from "path";
|
|
3263
2958
|
var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
|
3264
2959
|
<defs>
|
|
3265
2960
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff9466"/><stop offset="1" stop-color="#ea6a3c"/></linearGradient>
|
|
@@ -3273,15 +2968,15 @@ var ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
|
|
3273
2968
|
<path fill="#f6f5f1" fill-rule="evenodd" d="M236 236h552v168H236z M428 316h168v472H428z"/>
|
|
3274
2969
|
</svg>
|
|
3275
2970
|
`;
|
|
3276
|
-
var data = () => process.env.XDG_DATA_HOME ??
|
|
2971
|
+
var data = () => process.env.XDG_DATA_HOME ?? path9.join(os3.homedir(), ".local", "share");
|
|
3277
2972
|
function installDesktop(cli, port) {
|
|
3278
|
-
const dir =
|
|
3279
|
-
|
|
3280
|
-
const icon =
|
|
3281
|
-
|
|
3282
|
-
const launcher =
|
|
2973
|
+
const dir = path9.join(data(), "truecanvas");
|
|
2974
|
+
fs7.mkdirSync(dir, { recursive: true });
|
|
2975
|
+
const icon = path9.join(dir, "icon.svg");
|
|
2976
|
+
fs7.writeFileSync(icon, ICON);
|
|
2977
|
+
const launcher = path9.join(dir, "launch.sh");
|
|
3283
2978
|
const url = `http://localhost:${port}`;
|
|
3284
|
-
|
|
2979
|
+
fs7.writeFileSync(
|
|
3285
2980
|
launcher,
|
|
3286
2981
|
`#!/bin/sh
|
|
3287
2982
|
# Truecanvas launcher: start the hub if needed, then open its window.
|
|
@@ -3290,7 +2985,7 @@ CLI=${JSON.stringify(cli)}
|
|
|
3290
2985
|
URL=${JSON.stringify(url)}
|
|
3291
2986
|
LOG="\${XDG_STATE_HOME:-$HOME/.local/state}/truecanvas-hub.log"
|
|
3292
2987
|
# apps started from the desktop don't get your shell's PATH: next, pnpm and npx need it
|
|
3293
|
-
export PATH=${JSON.stringify([
|
|
2988
|
+
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))}
|
|
3294
2989
|
mkdir -p "$(dirname "$LOG")"
|
|
3295
2990
|
|
|
3296
2991
|
if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
|
|
@@ -3299,7 +2994,7 @@ if ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; then
|
|
|
3299
2994
|
while [ $i -lt 60 ] && ! curl -sf "$URL/api/hub/state" >/dev/null 2>&1; do sleep 0.25; i=$((i+1)); done
|
|
3300
2995
|
fi
|
|
3301
2996
|
|
|
3302
|
-
PROFILE=${JSON.stringify(
|
|
2997
|
+
PROFILE=${JSON.stringify(path9.join(dir, "window"))}
|
|
3303
2998
|
for b in google-chrome google-chrome-stable chromium-browser chromium brave-browser microsoft-edge; do
|
|
3304
2999
|
if command -v "$b" >/dev/null 2>&1; then
|
|
3305
3000
|
exec "$b" --app="$URL" --class=Truecanvas --user-data-dir="$PROFILE" --no-first-run --no-default-browser-check
|
|
@@ -3309,10 +3004,10 @@ exec xdg-open "$URL"
|
|
|
3309
3004
|
`,
|
|
3310
3005
|
{ mode: 493 }
|
|
3311
3006
|
);
|
|
3312
|
-
const apps =
|
|
3313
|
-
|
|
3314
|
-
const desktop =
|
|
3315
|
-
|
|
3007
|
+
const apps = path9.join(data(), "applications");
|
|
3008
|
+
fs7.mkdirSync(apps, { recursive: true });
|
|
3009
|
+
const desktop = path9.join(apps, "truecanvas.desktop");
|
|
3010
|
+
fs7.writeFileSync(
|
|
3316
3011
|
desktop,
|
|
3317
3012
|
`[Desktop Entry]
|
|
3318
3013
|
Type=Application
|
|
@@ -3328,11 +3023,11 @@ StartupWMClass=Truecanvas
|
|
|
3328
3023
|
StartupNotify=true
|
|
3329
3024
|
`
|
|
3330
3025
|
);
|
|
3331
|
-
let command =
|
|
3332
|
-
const ours = !
|
|
3026
|
+
let command = path9.join(os3.homedir(), ".local", "bin", "truecanvas");
|
|
3027
|
+
const ours = !fs7.existsSync(command) || fs7.readFileSync(command, "utf8").includes("# truecanvas wrapper");
|
|
3333
3028
|
if (ours) {
|
|
3334
|
-
|
|
3335
|
-
|
|
3029
|
+
fs7.mkdirSync(path9.dirname(command), { recursive: true });
|
|
3030
|
+
fs7.writeFileSync(command, `#!/bin/sh
|
|
3336
3031
|
# truecanvas wrapper (installed by \`truecanvas desktop\`)
|
|
3337
3032
|
exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
|
|
3338
3033
|
`, { mode: 493 });
|
|
@@ -3340,19 +3035,19 @@ exec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"
|
|
|
3340
3035
|
return { desktop, launcher, icon, command };
|
|
3341
3036
|
}
|
|
3342
3037
|
function launcherPath() {
|
|
3343
|
-
const p =
|
|
3344
|
-
return
|
|
3038
|
+
const p = path9.join(data(), "truecanvas", "launch.sh");
|
|
3039
|
+
return fs7.existsSync(p) ? p : null;
|
|
3345
3040
|
}
|
|
3346
3041
|
function uninstallDesktop() {
|
|
3347
3042
|
const removed = [];
|
|
3348
|
-
const cmd =
|
|
3349
|
-
if (
|
|
3350
|
-
|
|
3043
|
+
const cmd = path9.join(os3.homedir(), ".local", "bin", "truecanvas");
|
|
3044
|
+
if (fs7.existsSync(cmd) && fs7.readFileSync(cmd, "utf8").includes("# truecanvas wrapper")) {
|
|
3045
|
+
fs7.rmSync(cmd);
|
|
3351
3046
|
removed.push(cmd);
|
|
3352
3047
|
}
|
|
3353
|
-
for (const f of [
|
|
3354
|
-
if (
|
|
3355
|
-
|
|
3048
|
+
for (const f of [path9.join(data(), "applications", "truecanvas.desktop"), path9.join(data(), "truecanvas")]) {
|
|
3049
|
+
if (fs7.existsSync(f)) {
|
|
3050
|
+
fs7.rmSync(f, { recursive: true, force: true });
|
|
3356
3051
|
removed.push(f);
|
|
3357
3052
|
}
|
|
3358
3053
|
}
|
|
@@ -3360,16 +3055,16 @@ function uninstallDesktop() {
|
|
|
3360
3055
|
}
|
|
3361
3056
|
|
|
3362
3057
|
// src/cli/index.ts
|
|
3363
|
-
import { fileURLToPath as
|
|
3058
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3364
3059
|
import { createInterface } from "readline/promises";
|
|
3365
3060
|
|
|
3366
3061
|
// src/cli/doctor.ts
|
|
3367
|
-
import { execFile
|
|
3368
|
-
import
|
|
3369
|
-
import
|
|
3062
|
+
import { execFile } from "child_process";
|
|
3063
|
+
import fs8 from "fs";
|
|
3064
|
+
import path10 from "path";
|
|
3370
3065
|
var major = (v) => v ? Number(/(\d+)(?:\.(\d+))?/.exec(v)?.[1] ?? 0) : 0;
|
|
3371
3066
|
function has(cmd, args, cwd) {
|
|
3372
|
-
return new Promise((resolve) =>
|
|
3067
|
+
return new Promise((resolve) => execFile(cmd, args, { cwd, timeout: 8e3 }, (err, stdout, stderr) => resolve(err ? null : `${stdout}${stderr}`)));
|
|
3373
3068
|
}
|
|
3374
3069
|
async function doctor(root, cliVersion) {
|
|
3375
3070
|
const checks = [];
|
|
@@ -3393,7 +3088,7 @@ async function doctor(root, cliVersion) {
|
|
|
3393
3088
|
const tw = depVersion(root, "tailwindcss");
|
|
3394
3089
|
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" });
|
|
3395
3090
|
const config = loadConfig(root);
|
|
3396
|
-
if (config.framework === "next") add(
|
|
3091
|
+
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/)" });
|
|
3397
3092
|
const tc = installedIn(root);
|
|
3398
3093
|
if (!tc.declared) add({ label: "Truecanvas in the project", status: "fail", detail: "not a dependency", fix: "npx truecanvas (sets it up)" });
|
|
3399
3094
|
else if (!tc.installed) add({ label: "Truecanvas in the project", status: "fail", detail: "declared but not installed", fix: `${pm} install` });
|
|
@@ -3402,15 +3097,15 @@ async function doctor(root, cliVersion) {
|
|
|
3402
3097
|
const wrapped = configStatus(root, config.framework);
|
|
3403
3098
|
const configLabel = config.framework === "vite" ? "vite.config" : "next.config";
|
|
3404
3099
|
const uses = config.framework === "vite" ? "has the truecanvas() plugin" : "uses withTruecanvas()";
|
|
3405
|
-
add(wrapped.wrapped ? { label: configLabel, status: "ok", detail: `${
|
|
3100
|
+
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" });
|
|
3406
3101
|
const scripts = pkg.scripts ?? {};
|
|
3407
3102
|
const script = Object.entries(scripts).find(([, v]) => v.includes("truecanvas"));
|
|
3408
3103
|
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")' });
|
|
3409
|
-
const ignore =
|
|
3104
|
+
const ignore = fs8.existsSync(path10.join(root, ".gitignore")) ? fs8.readFileSync(path10.join(root, ".gitignore"), "utf8") : "";
|
|
3410
3105
|
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" });
|
|
3411
3106
|
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)" });
|
|
3412
3107
|
const chrome = findChromium();
|
|
3413
|
-
add(chrome ? { label: "Screenshots", status: "ok", detail:
|
|
3108
|
+
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)" });
|
|
3414
3109
|
const git = await has("git", ["rev-parse", "--is-inside-work-tree"], root);
|
|
3415
3110
|
add(git ? { label: "Git", status: "ok" } : { label: "Git", status: "warn", detail: "not a repository", fix: "git init (branches, history, comments and compare use git)" });
|
|
3416
3111
|
const gh = await has("gh", ["auth", "status"], root);
|
|
@@ -3438,6 +3133,11 @@ Usage
|
|
|
3438
3133
|
truecanvas share [canvas] Share a canvas as a link for clients (needs truecanvas running).
|
|
3439
3134
|
--password <p>, --no-password, --revoke, --restore, --delete, --title <t>, --local (preview only),
|
|
3440
3135
|
--live <url|dir>: a live version clients open with "View live" (where the app runs, or a static build)
|
|
3136
|
+
--invite <emails>: invite people by email (they sign in with a link; new links are invited-only)
|
|
3137
|
+
--access invited|password|public: who can open the link
|
|
3138
|
+
--link-only: with --invite or --access, change the link without a new version
|
|
3139
|
+
--session: a live session (until Ctrl+C): the link shows the canvas live from your app,
|
|
3140
|
+
with everyone's cursors and comments in real time
|
|
3441
3141
|
truecanvas share setup Connect your studio's review site (URL + REVIEW_TOKEN)
|
|
3442
3142
|
truecanvas mcp stdio MCP server for agents without HTTP support
|
|
3443
3143
|
|
|
@@ -3460,6 +3160,55 @@ var c = {
|
|
|
3460
3160
|
yellow: paint("33", "39"),
|
|
3461
3161
|
red: paint("31", "39")
|
|
3462
3162
|
};
|
|
3163
|
+
var accessLabel = (access) => access === "invited" ? "invited people only" : access === "password" ? "password protected" : access === "public" ? "anyone with the link" : "";
|
|
3164
|
+
async function liveSession(base, canvas) {
|
|
3165
|
+
const call2 = async (route, body) => {
|
|
3166
|
+
const res = await fetch(`${base}${route}`, body === void 0 ? void 0 : { method: "POST", headers: { "content-type": "application/json", origin: base }, body: JSON.stringify(body) });
|
|
3167
|
+
const data2 = await res.json().catch(() => ({}));
|
|
3168
|
+
if (!res.ok) throw new Error(data2.error ?? `${res.status} ${res.statusText}`);
|
|
3169
|
+
return data2;
|
|
3170
|
+
};
|
|
3171
|
+
let session;
|
|
3172
|
+
try {
|
|
3173
|
+
session = (await call2("/api/session/start", { canvas })).session;
|
|
3174
|
+
} catch (err) {
|
|
3175
|
+
console.error(` ${c.red("\u2717")} ${err.message}`);
|
|
3176
|
+
process.exit(1);
|
|
3177
|
+
}
|
|
3178
|
+
console.log(` ${c.green("\u25CF")} Live session on ${c.bold(canvas)}: ${c.bold(session.url)}`);
|
|
3179
|
+
console.log(` ${c.dim("People with access to the link see the canvas live from your app. Ctrl+C ends the session.")}`);
|
|
3180
|
+
let last = "";
|
|
3181
|
+
const tick = async () => {
|
|
3182
|
+
const all = (await call2("/api/session").catch(() => ({ sessions: [] }))).sessions;
|
|
3183
|
+
const s = all.find((x) => x.canvas === canvas);
|
|
3184
|
+
if (!s || s.status === "stopped") {
|
|
3185
|
+
console.log(` ${c.yellow("!")} The session ended${s?.error ? `: ${s.error}` : "."}`);
|
|
3186
|
+
process.exit(s?.error ? 1 : 0);
|
|
3187
|
+
}
|
|
3188
|
+
const here = s.people.filter((p) => p.id !== s.you).map((p) => p.name);
|
|
3189
|
+
const line = `${s.status}${here.length ? ` \xB7 here: ${here.join(", ")}` : ""}`;
|
|
3190
|
+
if (line !== last) console.log(` ${s.status === "live" ? c.green("\u25CF") : c.yellow("\u25CB")} ${line}`);
|
|
3191
|
+
last = line;
|
|
3192
|
+
};
|
|
3193
|
+
const timer = setInterval(() => void tick(), 2e3);
|
|
3194
|
+
const end = async () => {
|
|
3195
|
+
clearInterval(timer);
|
|
3196
|
+
await call2("/api/session/stop", { canvas }).catch(() => {
|
|
3197
|
+
});
|
|
3198
|
+
console.log(` ${c.dim("Session ended: the link shows the latest published version again.")}`);
|
|
3199
|
+
process.exit(0);
|
|
3200
|
+
};
|
|
3201
|
+
process.once("SIGINT", () => void end());
|
|
3202
|
+
process.once("SIGTERM", () => void end());
|
|
3203
|
+
await new Promise(() => {
|
|
3204
|
+
});
|
|
3205
|
+
}
|
|
3206
|
+
function printInvited(invited) {
|
|
3207
|
+
for (const i of invited) {
|
|
3208
|
+
if (i.error) console.log(` ${c.yellow("!")} Couldn't email ${i.email}: ${i.error}`);
|
|
3209
|
+
else console.log(` ${c.green("\u2713")} Invited ${i.email}${i.link ? c.dim(` (local review site: ${i.link})`) : ""}`);
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3463
3212
|
async function main() {
|
|
3464
3213
|
const { positionals, values } = parseArgs({
|
|
3465
3214
|
allowPositionals: true,
|
|
@@ -3483,10 +3232,14 @@ async function main() {
|
|
|
3483
3232
|
restore: { type: "boolean" },
|
|
3484
3233
|
delete: { type: "boolean" },
|
|
3485
3234
|
token: { type: "string" },
|
|
3486
|
-
live: { type: "string" }
|
|
3235
|
+
live: { type: "string" },
|
|
3236
|
+
invite: { type: "string", multiple: true },
|
|
3237
|
+
access: { type: "string" },
|
|
3238
|
+
"link-only": { type: "boolean" },
|
|
3239
|
+
session: { type: "boolean" }
|
|
3487
3240
|
}
|
|
3488
3241
|
});
|
|
3489
|
-
const cli =
|
|
3242
|
+
const cli = fileURLToPath2(import.meta.url);
|
|
3490
3243
|
const pkgDir = packageDir(cli);
|
|
3491
3244
|
const version = packageVersion(pkgDir);
|
|
3492
3245
|
const command = positionals[0] ?? "start";
|
|
@@ -3500,13 +3253,13 @@ async function main() {
|
|
|
3500
3253
|
const root2 = process.cwd();
|
|
3501
3254
|
const deps = { ...readPackage(root2)?.dependencies, ...readPackage(root2)?.devDependencies };
|
|
3502
3255
|
if (!("next" in deps) && !("vite" in deps && "react" in deps)) {
|
|
3503
|
-
return openWindow(cli, values.port ? Number(values.port) :
|
|
3256
|
+
return openWindow(cli, values.port ? Number(values.port) : hubPort());
|
|
3504
3257
|
}
|
|
3505
3258
|
if (!await ensureSetup(root2, pkgDir, version, yes)) return;
|
|
3506
|
-
if (await openInWindow(root2, Number(values.port
|
|
3507
|
-
const local =
|
|
3508
|
-
if (
|
|
3509
|
-
const child =
|
|
3259
|
+
if (await openInWindow(root2, values.port ? Number(values.port) : hubPort())) return;
|
|
3260
|
+
const local = path11.join(root2, "node_modules", "truecanvas", "dist", "cli.js");
|
|
3261
|
+
if (fs9.existsSync(local) && fs9.realpathSync(local) !== fs9.realpathSync(cli)) {
|
|
3262
|
+
const child = spawn(process.execPath, [local, "dev", ...process.argv.slice(2).filter((a) => a !== "start" && a !== "-y" && a !== "--yes")], { stdio: "inherit" });
|
|
3510
3263
|
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => child.kill(sig));
|
|
3511
3264
|
child.on("exit", (code) => process.exit(code ?? 0));
|
|
3512
3265
|
return;
|
|
@@ -3563,9 +3316,9 @@ async function main() {
|
|
|
3563
3316
|
}
|
|
3564
3317
|
return;
|
|
3565
3318
|
}
|
|
3566
|
-
if (command === "open") return openWindow(cli, values.port ? Number(values.port) :
|
|
3319
|
+
if (command === "open") return openWindow(cli, values.port ? Number(values.port) : hubPort());
|
|
3567
3320
|
if (command === "quit") {
|
|
3568
|
-
const url = `http://localhost:${values.port ??
|
|
3321
|
+
const url = `http://localhost:${values.port ?? hubPort()}`;
|
|
3569
3322
|
const up = await fetch(`${url}/api/hub/state`, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok).catch(() => false);
|
|
3570
3323
|
if (!up) return console.log("Truecanvas isn't running.");
|
|
3571
3324
|
await fetch(`${url}/api/hub/quit`, { method: "POST" }).catch(() => {
|
|
@@ -3587,7 +3340,7 @@ async function main() {
|
|
|
3587
3340
|
return;
|
|
3588
3341
|
}
|
|
3589
3342
|
const root = process.cwd();
|
|
3590
|
-
if (!
|
|
3343
|
+
if (!fs9.existsSync(path11.join(root, "package.json"))) {
|
|
3591
3344
|
console.error("Run truecanvas from your app's root (no package.json here).");
|
|
3592
3345
|
process.exit(1);
|
|
3593
3346
|
}
|
|
@@ -3597,7 +3350,7 @@ async function main() {
|
|
|
3597
3350
|
const report = initProject(config);
|
|
3598
3351
|
ws.ensureRoute();
|
|
3599
3352
|
console.log(`
|
|
3600
|
-
${c.accent("\u25C6")} ${c.bold("Truecanvas")} is set up in ${c.bold(
|
|
3353
|
+
${c.accent("\u25C6")} ${c.bold("Truecanvas")} is set up in ${c.bold(path11.basename(root))}
|
|
3601
3354
|
`);
|
|
3602
3355
|
for (const line of report.done) console.log(` ${c.green("\u2713")} ${line}`);
|
|
3603
3356
|
console.log(` ${c.green("\u2713")} Canvas folder ${c.bold(config.canvasDir)} and dev route ${c.bold(config.routeDir)}`);
|
|
@@ -3629,7 +3382,7 @@ async function main() {
|
|
|
3629
3382
|
console.error(site ? "Name the canvas: truecanvas share <canvas> --delete" : `No review site yet: ${c.accent("npx truecanvas share setup")}`);
|
|
3630
3383
|
process.exit(1);
|
|
3631
3384
|
}
|
|
3632
|
-
const done = await deleteShare(site,
|
|
3385
|
+
const done = await deleteShare(site, path11.basename(root), canvas);
|
|
3633
3386
|
console.log(done ? ` ${c.green("\u2713")} Deleted the link of ${canvas}, with its versions and comments.` : ` ${canvas} has no link.`);
|
|
3634
3387
|
return;
|
|
3635
3388
|
}
|
|
@@ -3640,10 +3393,38 @@ async function main() {
|
|
|
3640
3393
|
process.exit(1);
|
|
3641
3394
|
}
|
|
3642
3395
|
const change = { ...values.revoke ? { revoked: true } : values.restore ? { revoked: false } : {}, ...values["no-password"] ? { password: null } : {} };
|
|
3643
|
-
const url = await updateShare(site,
|
|
3396
|
+
const url = await updateShare(site, path11.basename(root), canvas, change);
|
|
3644
3397
|
console.log(` ${c.green("\u2713")} ${values.revoke ? "Link revoked" : values.restore ? "Link restored" : "Password removed"}: ${url}`);
|
|
3645
3398
|
return;
|
|
3646
3399
|
}
|
|
3400
|
+
let invite = [];
|
|
3401
|
+
try {
|
|
3402
|
+
invite = parseEmails(values.invite);
|
|
3403
|
+
} catch (err) {
|
|
3404
|
+
console.error(` ${c.red("\u2717")} ${err.message}`);
|
|
3405
|
+
process.exit(1);
|
|
3406
|
+
}
|
|
3407
|
+
const access = values.access;
|
|
3408
|
+
if (access && !ACCESS_MODES.includes(access)) {
|
|
3409
|
+
console.error(` ${c.red("\u2717")} --access is ${ACCESS_MODES.join(", ")}`);
|
|
3410
|
+
process.exit(1);
|
|
3411
|
+
}
|
|
3412
|
+
if (values["link-only"]) {
|
|
3413
|
+
const site = reviewSite();
|
|
3414
|
+
if (!site || !canvas || !access && !invite.length) {
|
|
3415
|
+
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");
|
|
3416
|
+
process.exit(1);
|
|
3417
|
+
}
|
|
3418
|
+
try {
|
|
3419
|
+
const done = await updateAccess(site, path11.basename(root), canvas, { access, password: values.password, invite });
|
|
3420
|
+
console.log(` ${c.green("\u2713")} ${done.url} ${c.dim(`(${accessLabel(done.access)})`)}`);
|
|
3421
|
+
printInvited(done.invited);
|
|
3422
|
+
} catch (err) {
|
|
3423
|
+
console.error(` ${c.red("\u2717")} ${err.message}`);
|
|
3424
|
+
process.exit(1);
|
|
3425
|
+
}
|
|
3426
|
+
return;
|
|
3427
|
+
}
|
|
3647
3428
|
if (!state) {
|
|
3648
3429
|
console.error(`Truecanvas isn't running for this project. Start it first: ${c.accent("npx truecanvas")}`);
|
|
3649
3430
|
process.exit(1);
|
|
@@ -3653,19 +3434,24 @@ async function main() {
|
|
|
3653
3434
|
truecanvas share <canvas>`);
|
|
3654
3435
|
process.exit(1);
|
|
3655
3436
|
}
|
|
3437
|
+
if (values.session) return liveSession(base, canvas);
|
|
3656
3438
|
const publish = !values.local && !!reviewSite();
|
|
3657
3439
|
console.log(` ${c.dim(`Rendering ${canvas} through your app${publish ? " and publishing it" : ""}\u2026`)}`);
|
|
3658
3440
|
const password = values.password;
|
|
3659
|
-
const live = values.live ? /^https?:\/\//.test(values.live) ? { url: values.live } : { dir:
|
|
3660
|
-
if (live && "dir" in live && !
|
|
3441
|
+
const live = values.live ? /^https?:\/\//.test(values.live) ? { url: values.live } : { dir: path11.resolve(values.live) } : void 0;
|
|
3442
|
+
if (live && "dir" in live && !fs9.existsSync(path11.join(live.dir, "index.html"))) {
|
|
3661
3443
|
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.`);
|
|
3662
3444
|
process.exit(1);
|
|
3663
3445
|
}
|
|
3664
|
-
if (live && !publish) {
|
|
3665
|
-
console.error(` ${c.red("\u2717")} --live needs a review site: ${c.accent("npx truecanvas share setup")}`);
|
|
3446
|
+
if ((live || access || invite.length) && !publish) {
|
|
3447
|
+
console.error(` ${c.red("\u2717")} ${live ? "--live" : access ? "--access" : "--invite"} needs a review site: ${c.accent("npx truecanvas share setup")}`);
|
|
3666
3448
|
process.exit(1);
|
|
3667
3449
|
}
|
|
3668
|
-
const res = await fetch(`${base}/api/share/snapshot`, {
|
|
3450
|
+
const res = await fetch(`${base}/api/share/snapshot`, {
|
|
3451
|
+
method: "POST",
|
|
3452
|
+
headers: { "content-type": "application/json", origin: base },
|
|
3453
|
+
body: JSON.stringify({ canvas, local: !publish, title: values.title, password, live, access, invite })
|
|
3454
|
+
});
|
|
3669
3455
|
const body = await res.json();
|
|
3670
3456
|
if (!res.ok) {
|
|
3671
3457
|
console.error(` ${c.red("\u2717")} ${body.error}`);
|
|
@@ -3677,8 +3463,11 @@ async function main() {
|
|
|
3677
3463
|
if (manifest.missing.length) console.log(` ${c.yellow("!")} ${manifest.missing.length} asset${manifest.missing.length === 1 ? "" : "s"} couldn't be fetched.`);
|
|
3678
3464
|
if (published?.skipped.length) console.log(` ${c.yellow("!")} Left out (over 4.4 MB): ${published.skipped.join(", ")}`);
|
|
3679
3465
|
if (published) {
|
|
3680
|
-
|
|
3681
|
-
|
|
3466
|
+
const who = published.access ? `, ${accessLabel(published.access)}` : published.password ? ", password protected" : "";
|
|
3467
|
+
console.log(` ${c.green("\u2713")} Shared: ${c.bold(published.url)} ${c.dim(`(version ${published.versions}${who})`)}`);
|
|
3468
|
+
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)" : "")}`);
|
|
3469
|
+
printInvited(published.invited);
|
|
3470
|
+
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`)}`);
|
|
3682
3471
|
console.log("");
|
|
3683
3472
|
if (values.open && !values["no-open"]) openBrowser(published.url);
|
|
3684
3473
|
} else {
|
|
@@ -3731,7 +3520,10 @@ async function main() {
|
|
|
3731
3520
|
let next = null;
|
|
3732
3521
|
const appUp = await reachable(config.appUrl);
|
|
3733
3522
|
const devCommand = config.framework === "vite" ? "vite" : "next dev";
|
|
3734
|
-
if (!appUp && values.next && !values["no-next"])
|
|
3523
|
+
if (!appUp && values.next && !values["no-next"]) {
|
|
3524
|
+
next = startApp(root, config.appUrl, config.framework);
|
|
3525
|
+
appRun.state = "starting";
|
|
3526
|
+
}
|
|
3735
3527
|
console.log(`
|
|
3736
3528
|
${c.accent("\u25C6")} ${c.bold("Truecanvas")} ${c.dim(`v${version}`)}
|
|
3737
3529
|
|
|
@@ -3744,7 +3536,11 @@ ${hasAgentConfig(root, config.port) ? ` ${c.dim("Agents")} connected through
|
|
|
3744
3536
|
`);
|
|
3745
3537
|
if (next) {
|
|
3746
3538
|
const exited = new Promise((r) => next.once("exit", () => r("exited")));
|
|
3539
|
+
void exited.then(() => {
|
|
3540
|
+
if (appRun.state !== "ready") appRun.state = "failed";
|
|
3541
|
+
});
|
|
3747
3542
|
const result = await Promise.race([waitFor(config.appUrl, 9e4), exited]);
|
|
3543
|
+
if (result === true) appRun.state = "ready";
|
|
3748
3544
|
if (result === true) console.log(` ${c.green("\u2713")} App ready at ${config.appUrl}
|
|
3749
3545
|
`);
|
|
3750
3546
|
else if (result === "exited") console.log(` ${c.dim(`${devCommand} stopped (see above). Start your app, the editor connects as soon as it's up.`)}
|
|
@@ -3773,12 +3569,12 @@ async function openWindow(cli, port) {
|
|
|
3773
3569
|
const url = `http://localhost:${port}`;
|
|
3774
3570
|
const launcher = launcherPath();
|
|
3775
3571
|
if (launcher) {
|
|
3776
|
-
|
|
3572
|
+
spawn(launcher, [], { stdio: "ignore", detached: true }).unref();
|
|
3777
3573
|
return console.log(`${c.accent("\u25C6")} Opening Truecanvas\u2026`);
|
|
3778
3574
|
}
|
|
3779
3575
|
const up = await fetch(`${url}/api/hub/state`, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok).catch(() => false);
|
|
3780
3576
|
if (!up) {
|
|
3781
|
-
|
|
3577
|
+
spawn(process.execPath, [cli, "hub", "--port", String(port), "--no-open"], { stdio: "ignore", detached: true }).unref();
|
|
3782
3578
|
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));
|
|
3783
3579
|
}
|
|
3784
3580
|
openBrowser(url);
|
|
@@ -3791,7 +3587,7 @@ async function openInWindow(root, port) {
|
|
|
3791
3587
|
const post = (route, body) => fetch(`${hub}${route}`, { method: "POST", headers: { "content-type": "application/json", origin: hub }, body: JSON.stringify(body) }).catch(() => null);
|
|
3792
3588
|
await post("/api/hub/add", { path: root });
|
|
3793
3589
|
await post("/api/hub/open", { path: root, focus: "1" });
|
|
3794
|
-
console.log(`${c.accent("\u25C6")} Opened ${c.bold(
|
|
3590
|
+
console.log(`${c.accent("\u25C6")} Opened ${c.bold(path11.basename(root))} in the Truecanvas window ${c.dim(`(${hub})`)}.`);
|
|
3795
3591
|
return true;
|
|
3796
3592
|
}
|
|
3797
3593
|
async function prompt(question) {
|
|
@@ -3815,7 +3611,7 @@ async function ensureSetup(root, pkgDir, version, yes, force = false) {
|
|
|
3815
3611
|
const tc = installedIn(root);
|
|
3816
3612
|
const wrapped = configStatus(root).wrapped;
|
|
3817
3613
|
if (tc.declared && tc.installed && wrapped && !force) return true;
|
|
3818
|
-
const name = readPackage(root)?.name ??
|
|
3614
|
+
const name = readPackage(root)?.name ?? path11.basename(root);
|
|
3819
3615
|
if (!yes) {
|
|
3820
3616
|
if (!process.stdin.isTTY) {
|
|
3821
3617
|
console.error(`Truecanvas isn't set up in ${name} yet. Run again with --yes to set it up.`);
|
|
@@ -3889,7 +3685,7 @@ function startApp(root, appUrl, framework) {
|
|
|
3889
3685
|
const bin = binScript(root, pkg);
|
|
3890
3686
|
const [cmd, args] = bin ? [process.execPath, [bin, ...devArgs]] : [process.platform === "win32" ? "npx.cmd" : "npx", [pkg, ...devArgs]];
|
|
3891
3687
|
const label = framework === "vite" ? "vite" : "next";
|
|
3892
|
-
const child =
|
|
3688
|
+
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" });
|
|
3893
3689
|
child.on("error", (err) => console.error(` ${c.dim(`${label} \u2502`)} couldn't start ${pkg}: ${err.message}`));
|
|
3894
3690
|
const prefix = c.dim(`${label} \u2502 `);
|
|
3895
3691
|
for (const stream of [child.stdout, child.stderr]) {
|
|
@@ -3898,32 +3694,37 @@ function startApp(root, appUrl, framework) {
|
|
|
3898
3694
|
buf += chunk.toString();
|
|
3899
3695
|
const lines = buf.split(/\r?\n/);
|
|
3900
3696
|
buf = lines.pop();
|
|
3901
|
-
for (const line of lines)
|
|
3697
|
+
for (const line of lines) {
|
|
3698
|
+
if (!line.trim()) continue;
|
|
3699
|
+
process.stdout.write(` ${prefix}${line}
|
|
3902
3700
|
`);
|
|
3701
|
+
appRun.log.push(line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
3702
|
+
if (appRun.log.length > 20) appRun.log.shift();
|
|
3703
|
+
}
|
|
3903
3704
|
});
|
|
3904
3705
|
}
|
|
3905
3706
|
return child;
|
|
3906
3707
|
}
|
|
3907
3708
|
function binScript(root, pkg) {
|
|
3908
3709
|
try {
|
|
3909
|
-
const req = createRequire(
|
|
3710
|
+
const req = createRequire(path11.join(root, "package.json"));
|
|
3910
3711
|
const manifest = req.resolve(`${pkg}/package.json`);
|
|
3911
|
-
const { bin } = JSON.parse(
|
|
3712
|
+
const { bin } = JSON.parse(fs9.readFileSync(manifest, "utf8"));
|
|
3912
3713
|
const rel = typeof bin === "string" ? bin : bin?.[pkg];
|
|
3913
|
-
return rel ?
|
|
3714
|
+
return rel ? path11.join(path11.dirname(manifest), rel) : null;
|
|
3914
3715
|
} catch {
|
|
3915
3716
|
return null;
|
|
3916
3717
|
}
|
|
3917
3718
|
}
|
|
3918
3719
|
function stopTree(child, signal) {
|
|
3919
|
-
if (process.platform === "win32" && child.pid)
|
|
3720
|
+
if (process.platform === "win32" && child.pid) spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }).on("error", () => {
|
|
3920
3721
|
});
|
|
3921
3722
|
else child.kill(signal);
|
|
3922
3723
|
}
|
|
3923
3724
|
function openBrowser(url) {
|
|
3924
3725
|
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
3925
3726
|
try {
|
|
3926
|
-
|
|
3727
|
+
spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).on("error", () => {
|
|
3927
3728
|
}).unref();
|
|
3928
3729
|
} catch {
|
|
3929
3730
|
}
|