twombly 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2084 -0
- package/dist/ui/assets/Landing-COHWYWgT.js +109 -0
- package/dist/ui/assets/Landing-I1VCX2fy.css +1 -0
- package/dist/ui/assets/LandingStage-D1WT-zxZ.css +1 -0
- package/dist/ui/assets/LandingStage-DQBFsXtF.js +1 -0
- package/dist/ui/assets/agentPrompt-CELnjtMR.js +1 -0
- package/dist/ui/assets/google-sheets-C6gOto98.svg +55 -0
- package/dist/ui/assets/google-slides-B6uumWEX.svg +61 -0
- package/dist/ui/assets/index-C8yZ6_xT.css +1 -0
- package/dist/ui/assets/index-NwhZOePg.js +34 -0
- package/dist/ui/assets/rolldown-runtime-WkwZ5CU-.js +1 -0
- package/dist/ui/favicon.png +0 -0
- package/dist/ui/index.html +17 -0
- package/dist/ui/legal.css +10 -0
- package/dist/ui/privacy.html +38 -0
- package/dist/ui/skill.md +6 -0
- package/dist/ui/terms.html +36 -0
- package/dist/ui/twombly-wordmark.svg +9 -0
- package/package.json +23 -4
- package/README.md +0 -3
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2084 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
5
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
6
|
+
import { copyFile, mkdir as mkdir7, mkdtemp, readFile as readFile7, rm as rm5, stat as stat5, writeFile as writeFile7 } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { basename as basename2, extname as extname3, join as join9, resolve as resolve2 } from "node:path";
|
|
9
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
10
|
+
|
|
11
|
+
// ../protocol/src/gates.ts
|
|
12
|
+
var LIMITS = {
|
|
13
|
+
fileBytes: 25 * 1024 * 1024,
|
|
14
|
+
frameBytes: 100 * 1024 * 1024,
|
|
15
|
+
frameFiles: 5e3,
|
|
16
|
+
canvasFrames: 200,
|
|
17
|
+
scanBytes: 8 * 1024 * 1024,
|
|
18
|
+
titleChars: 120,
|
|
19
|
+
nameChars: 120,
|
|
20
|
+
commentChars: 5e3,
|
|
21
|
+
strokePoints: 3e3,
|
|
22
|
+
canvasStrokes: 2e3
|
|
23
|
+
};
|
|
24
|
+
var STATIC_FIX = "Frames are static front-end files only. Delete it from the folder; if the design needs what it provided, use mock data.";
|
|
25
|
+
var FORBIDDEN = [
|
|
26
|
+
{ test: /(^|\/)\.env(\.|$)/i, code: "ENV_FILE", what: "an environment file" },
|
|
27
|
+
{ test: /(^|\/)\.git(\/|$)/, code: "GIT_DIRECTORY", what: "a git directory" },
|
|
28
|
+
{ test: /(^|\/)node_modules(\/|$)/, code: "NODE_MODULES", what: "node_modules", fix: "Build first and add the output folder, not the project. Copy any file the page needs next to it." },
|
|
29
|
+
{ test: /(^|\/)(\.npmrc|\.netrc|\.pypirc|\.htpasswd|credentials(\.json)?|secrets?\.(json|ya?ml|toml)|service[-_]?account.*\.json)$/i, code: "CREDENTIAL_FILE", what: "a credentials file" },
|
|
30
|
+
{ test: /\.(pem|key|p12|pfx|crt|cer|keystore|jks)$/i, code: "KEY_FILE", what: "a key or certificate" },
|
|
31
|
+
{ test: /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/, code: "KEY_FILE", what: "an SSH key" },
|
|
32
|
+
{ test: /\.(sql|sqlite|sqlite3|db|dump|bak)$/i, code: "DATABASE_FILE", what: "a database file" },
|
|
33
|
+
{ test: /\.map$/i, code: "SOURCE_MAP", what: "a source map", fix: "Source maps publish original source code. Turn them off in the build or delete the .map files." },
|
|
34
|
+
{ test: /(^|\/)\.next\/(server|cache)(\/|$)/, code: "SERVER_CODE", what: "server output from Next.js", fix: 'Use a static export (output: "export") and add its output folder.' },
|
|
35
|
+
{ test: /\.(php|rb|py|go|rs|java|cs|sh|bash|zsh|exe|dll|so|dylib)$/i, code: "SERVER_CODE", what: "server or native code" },
|
|
36
|
+
{ test: /(^|\/)(package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|tsconfig\.json|wrangler\.(toml|jsonc?)|dockerfile|docker-compose\.ya?ml)$/i, code: "PROJECT_FILE", what: "a project file", fix: "That looks like a project folder. Build first and add the output folder only." }
|
|
37
|
+
];
|
|
38
|
+
function checkPath(path) {
|
|
39
|
+
if (path.startsWith("/") || path.includes("\\") || path.split("/").some((part) => part === ".." || part === "")) {
|
|
40
|
+
return { code: "BAD_PATH", message: `"${path}" is not a clean relative path.`, fix: "Use relative paths inside the frame folder.", file: path };
|
|
41
|
+
}
|
|
42
|
+
for (const rule of FORBIDDEN) {
|
|
43
|
+
if (rule.test.test(path)) return { code: rule.code, message: `"${path}" is ${rule.what}.`, fix: rule.fix || STATIC_FIX, file: path };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
var SECRET_PATTERNS = [
|
|
48
|
+
{ name: "a private key", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY/ },
|
|
49
|
+
{ name: "an AWS access key", re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/ },
|
|
50
|
+
{ name: "an Anthropic API key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
|
|
51
|
+
{ name: "an OpenAI API key", re: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{32,}/ },
|
|
52
|
+
{ name: "a Stripe secret key", re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/ },
|
|
53
|
+
{ name: "a Stripe webhook secret", re: /\bwhsec_[A-Za-z0-9]{24,}/ },
|
|
54
|
+
{ name: "a GitHub token", re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{40,}/ },
|
|
55
|
+
{ name: "a GitLab token", re: /\bglpat-[A-Za-z0-9_-]{20,}/ },
|
|
56
|
+
{ name: "a Slack token", re: /\bxox[abeoprs]-[A-Za-z0-9-]{10,}/ },
|
|
57
|
+
{ name: "a Slack webhook", re: /hooks\.slack\.com\/services\/T[A-Za-z0-9]+\/B[A-Za-z0-9]+\/[A-Za-z0-9]+/ },
|
|
58
|
+
{ name: "a Google API key", re: /\bAIza[A-Za-z0-9_-]{35}\b/ },
|
|
59
|
+
{ name: "a Google OAuth client secret", re: /\bGOCSPX-[A-Za-z0-9_-]{20,}/ },
|
|
60
|
+
{ name: "a SendGrid key", re: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/ },
|
|
61
|
+
{ name: "a Twilio key", re: /\bSK[a-f0-9]{32}\b/ },
|
|
62
|
+
{ name: "a Supabase secret key", re: /\bsb_secret_[A-Za-z0-9_-]{16,}/ },
|
|
63
|
+
{ name: "a Vercel or Resend key", re: /\bre_[A-Za-z0-9]{8}_[A-Za-z0-9]{16,}|\bvercel_blob_rw_[A-Za-z0-9_]{16,}/ },
|
|
64
|
+
{ name: "an npm token", re: /\bnpm_[A-Za-z0-9]{36}\b/ },
|
|
65
|
+
{ name: "a database address with a password", re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqps?):\/\/[^\s:@"'`/]+:[^\s:@"'`/]{3,}@[^\s"'`]+/i }
|
|
66
|
+
];
|
|
67
|
+
var ASSIGNED_SECRET = /(?:api[_-]?key|api[_-]?secret|secret[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|private[_-]?key|service[_-]?role(?:[_-]?key)?|password|passwd)["'`]?\s*[:=]\s*["'`]([^"'`\s]{20,})["'`]/gi;
|
|
68
|
+
var JWT = /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g;
|
|
69
|
+
var PLACEHOLDER = /^(?:x+|your|example|placeholder|test|mock|demo|fake|dummy|sample|<|\$\{|process\.env|import\.meta)/i;
|
|
70
|
+
function entropy(value) {
|
|
71
|
+
const counts = /* @__PURE__ */ new Map();
|
|
72
|
+
for (const c of value) counts.set(c, (counts.get(c) || 0) + 1);
|
|
73
|
+
let bits = 0;
|
|
74
|
+
for (const n of counts.values()) {
|
|
75
|
+
const p = n / value.length;
|
|
76
|
+
bits -= p * Math.log2(p);
|
|
77
|
+
}
|
|
78
|
+
return bits;
|
|
79
|
+
}
|
|
80
|
+
function secretProblem(file2, what) {
|
|
81
|
+
return {
|
|
82
|
+
code: "SECRET_FOUND",
|
|
83
|
+
message: `"${file2}" contains what looks like ${what}.`,
|
|
84
|
+
fix: "Remove it, and rebuild without any environment values. Frames are public static files: anything in them can be read by anyone with the link. Use mock data instead of calling the service.",
|
|
85
|
+
file: file2
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function scanText(file2, text2) {
|
|
89
|
+
const found = /* @__PURE__ */ new Map();
|
|
90
|
+
for (const { name, re } of SECRET_PATTERNS) if (re.test(text2)) found.set(name, secretProblem(file2, name));
|
|
91
|
+
for (const match of text2.matchAll(ASSIGNED_SECRET)) {
|
|
92
|
+
const value = match[1];
|
|
93
|
+
if (!PLACEHOLDER.test(value) && entropy(value) >= 3.5) {
|
|
94
|
+
found.set("assigned", secretProblem(file2, "a secret assigned to a key or password field"));
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const match of text2.matchAll(JWT)) {
|
|
99
|
+
if (hasRoleClaim(match[0])) {
|
|
100
|
+
found.set("jwt", secretProblem(file2, "a service token"));
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [...found.values()];
|
|
105
|
+
}
|
|
106
|
+
function hasRoleClaim(jwt) {
|
|
107
|
+
try {
|
|
108
|
+
const payload = JSON.parse(atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
|
|
109
|
+
return typeof payload === "object" && !!payload && ("role" in payload || "iss" in payload);
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function looksLikeText(bytes) {
|
|
115
|
+
const end = Math.min(bytes.length, 8192);
|
|
116
|
+
for (let i = 0; i < end; i++) if (bytes[i] === 0) return false;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
function scanBytes(file2, bytes) {
|
|
120
|
+
if (!looksLikeText(bytes)) return [];
|
|
121
|
+
return scanText(file2, new TextDecoder().decode(bytes.subarray(0, LIMITS.scanBytes)));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ../protocol/src/frames.ts
|
|
125
|
+
async function contentHash(files) {
|
|
126
|
+
const lines = files.map((f) => `${f.path}
|
|
127
|
+
${f.hash}`).sort().join("\n");
|
|
128
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lines));
|
|
129
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 20);
|
|
130
|
+
}
|
|
131
|
+
function candidatePaths(pathname) {
|
|
132
|
+
let path;
|
|
133
|
+
try {
|
|
134
|
+
path = decodeURIComponent(pathname);
|
|
135
|
+
} catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
path = path.replace(/^\/+/, "");
|
|
139
|
+
if (path.split("/").some((part) => part === "..")) return [];
|
|
140
|
+
if (!path) return ["index.html"];
|
|
141
|
+
if (path.endsWith("/")) return [path + "index.html"];
|
|
142
|
+
return [path, path + ".html", path + "/index.html"];
|
|
143
|
+
}
|
|
144
|
+
var TYPES = {
|
|
145
|
+
html: "text/html; charset=utf-8",
|
|
146
|
+
htm: "text/html; charset=utf-8",
|
|
147
|
+
css: "text/css; charset=utf-8",
|
|
148
|
+
js: "text/javascript; charset=utf-8",
|
|
149
|
+
mjs: "text/javascript; charset=utf-8",
|
|
150
|
+
cjs: "text/javascript; charset=utf-8",
|
|
151
|
+
json: "application/json; charset=utf-8",
|
|
152
|
+
txt: "text/plain; charset=utf-8",
|
|
153
|
+
svg: "image/svg+xml",
|
|
154
|
+
xml: "application/xml",
|
|
155
|
+
png: "image/png",
|
|
156
|
+
jpg: "image/jpeg",
|
|
157
|
+
jpeg: "image/jpeg",
|
|
158
|
+
gif: "image/gif",
|
|
159
|
+
webp: "image/webp",
|
|
160
|
+
avif: "image/avif",
|
|
161
|
+
ico: "image/x-icon",
|
|
162
|
+
woff: "font/woff",
|
|
163
|
+
woff2: "font/woff2",
|
|
164
|
+
ttf: "font/ttf",
|
|
165
|
+
otf: "font/otf",
|
|
166
|
+
eot: "application/vnd.ms-fontobject",
|
|
167
|
+
mp4: "video/mp4",
|
|
168
|
+
webm: "video/webm",
|
|
169
|
+
mov: "video/quicktime",
|
|
170
|
+
mp3: "audio/mpeg",
|
|
171
|
+
wav: "audio/wav",
|
|
172
|
+
ogg: "audio/ogg",
|
|
173
|
+
m4a: "audio/mp4",
|
|
174
|
+
wasm: "application/wasm",
|
|
175
|
+
pdf: "application/pdf",
|
|
176
|
+
webmanifest: "application/manifest+json",
|
|
177
|
+
map: "application/json",
|
|
178
|
+
glb: "model/gltf-binary",
|
|
179
|
+
gltf: "model/gltf+json"
|
|
180
|
+
};
|
|
181
|
+
function contentType(path) {
|
|
182
|
+
return TYPES[path.split(".").pop()?.toLowerCase() || ""] || "application/octet-stream";
|
|
183
|
+
}
|
|
184
|
+
function frameCsp(ancestors) {
|
|
185
|
+
return [
|
|
186
|
+
"default-src 'self'",
|
|
187
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:",
|
|
188
|
+
"style-src 'self' 'unsafe-inline'",
|
|
189
|
+
"img-src 'self' data: blob:",
|
|
190
|
+
"font-src 'self' data:",
|
|
191
|
+
"media-src 'self' data: blob:",
|
|
192
|
+
"connect-src 'self' data: blob:",
|
|
193
|
+
"worker-src 'self' blob:",
|
|
194
|
+
"frame-src 'self' blob: data:",
|
|
195
|
+
"object-src 'none'",
|
|
196
|
+
"base-uri 'self'",
|
|
197
|
+
"form-action 'none'",
|
|
198
|
+
`frame-ancestors ${ancestors.join(" ")}`
|
|
199
|
+
].join("; ");
|
|
200
|
+
}
|
|
201
|
+
var FRAME_SANDBOX = "allow-scripts allow-same-origin allow-forms allow-modals allow-pointer-lock allow-downloads";
|
|
202
|
+
var FRAME_BRIDGE_PATH = "/__twombly/bridge.js";
|
|
203
|
+
var FRAME_POSTER_PATH = "/__twombly/poster.png";
|
|
204
|
+
var FRAME_BRIDGE_JS = `(() => {
|
|
205
|
+
if (window.parent === window) return;
|
|
206
|
+
const post = message => { try { window.parent.postMessage({ twombly: true, ...message }, '*'); } catch {} };
|
|
207
|
+
let queued = null, scheduled = false;
|
|
208
|
+
addEventListener('pointermove', event => {
|
|
209
|
+
queued = { kind: 'pointer', x: event.clientX, y: event.clientY };
|
|
210
|
+
if (scheduled) return;
|
|
211
|
+
scheduled = true;
|
|
212
|
+
requestAnimationFrame(() => { scheduled = false; post(queued); });
|
|
213
|
+
}, { capture: true, passive: true });
|
|
214
|
+
addEventListener('wheel', event => {
|
|
215
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
216
|
+
event.preventDefault();
|
|
217
|
+
post({ kind: 'zoom', x: event.clientX, y: event.clientY, dy: event.deltaY });
|
|
218
|
+
}, { capture: true, passive: false });
|
|
219
|
+
addEventListener('keydown', event => { if (event.key === 'Escape') post({ kind: 'escape' }); }, { capture: true });
|
|
220
|
+
})();`;
|
|
221
|
+
function injectBridge(html) {
|
|
222
|
+
const tag = `<script src="${FRAME_BRIDGE_PATH}"></script>`;
|
|
223
|
+
const head = html.match(/<head(\s[^>]*)?>/i);
|
|
224
|
+
if (head && head.index !== void 0) {
|
|
225
|
+
const at = head.index + head[0].length;
|
|
226
|
+
return html.slice(0, at) + tag + html.slice(at);
|
|
227
|
+
}
|
|
228
|
+
const doctype = html.match(/^\s*<!doctype[^>]*>/i);
|
|
229
|
+
if (doctype) return doctype[0] + tag + html.slice(doctype[0].length);
|
|
230
|
+
return tag + html;
|
|
231
|
+
}
|
|
232
|
+
var FRAME_ONLY_MESSAGE = "This frame only opens inside a canvas.";
|
|
233
|
+
|
|
234
|
+
// ../protocol/src/index.ts
|
|
235
|
+
var FIDELITY_LEVELS = ["build", "component", "capture", "image"];
|
|
236
|
+
function cleanStroke(input) {
|
|
237
|
+
const s = input;
|
|
238
|
+
if (!s || typeof s !== "object" || typeof s.id !== "string" || !/^[a-z0-9]{8,32}$/.test(s.id)) return null;
|
|
239
|
+
if (typeof s.color !== "string" || !/^#[0-9a-f]{6}$/i.test(s.color)) return null;
|
|
240
|
+
if (typeof s.size !== "number" || !Number.isFinite(s.size) || s.size <= 0 || s.size > 2e3) return null;
|
|
241
|
+
if (!Array.isArray(s.points) || !s.points.length || s.points.length > LIMITS.strokePoints) return null;
|
|
242
|
+
if (!s.points.every((p) => Array.isArray(p) && p.length === 3 && p.every((n) => typeof n === "number" && Number.isFinite(n)))) return null;
|
|
243
|
+
return { id: s.id, frameId: typeof s.frameId === "string" && FRAME_ID.test(s.frameId) ? s.frameId : null, points: s.points, color: s.color, size: s.size };
|
|
244
|
+
}
|
|
245
|
+
var WORKING_MS = 3 * 6e4;
|
|
246
|
+
var WORKING_TAIL_MS = 3e4;
|
|
247
|
+
var FRAME_ID = /^[a-km-z2-9]{8,32}$/;
|
|
248
|
+
var FRAME_GAP = 80;
|
|
249
|
+
function nextPosition(frames, newRow = false) {
|
|
250
|
+
if (!frames.length) return { x: 0, y: 0 };
|
|
251
|
+
const lastRowY = Math.max(...frames.map((f) => f.y));
|
|
252
|
+
const lastRow = frames.filter((f) => f.y === lastRowY);
|
|
253
|
+
if (newRow) return { x: Math.min(...frames.map((f) => f.x)), y: Math.max(...frames.map((f) => f.y + f.h)) + FRAME_GAP + 40 };
|
|
254
|
+
return { x: Math.max(...lastRow.map((f) => f.x + f.w)) + FRAME_GAP, y: lastRowY };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ../../skills/twombly/SKILL.md
|
|
258
|
+
var SKILL_default = "---\nname: twombly\ndescription: Lay out design explorations on a shared canvas. Use when asked to \"put this on Twombly\", \"put these on a canvas\", \"lay out these versions\", \"show me all the variations side by side\", or to share explorations with real-time cursors and comments. Works with any framework in any repo.\n---\n\n# Twombly\n\nTwombly shows design work as live frames on an infinite canvas. You build each version, turn it into a folder of static files, and add it. The canvas opens on `localhost`; `init` returns its address. Nothing is online until the person clicks Share. After that the local canvas and the link are one state: everything you add, update or move appears for everyone straight away.\n\nRun every command as `npx -y twombly@latest <command>`; this guide shortens that to `twombly <command>`. Output is JSON.\n\n## The rule that decides everything\n\n**A frame is a folder of static files that runs with no server and no network.** It can use anything in its own folder and nothing else. The browser enforces this: outside requests, form posts and server code all fail, locally and once shared.\n\nThe person's repo may have a database, an API, auth and environment keys. None of that goes in a frame. Your job is to adapt the design so it works without them:\n\n- **Data and APIs:** replace with realistic mock data, written inline or as JSON in the folder. For public CMS content, save a snapshot as JSON when you build.\n- **Auth and providers:** skip login, and mount the design with mock providers and a fake signed-in user.\n- **Environment values:** never include them. Build with none set. Don't copy `.env` files. If the build needs a value to compile, use an obviously fake one. **Never run or read a secret manager such as Infisical or Doppler for this, even if the project is set up for it.** If you can't get what you need without one, ask the person.\n- **Analytics and widgets:** remove tag managers, analytics, chat widgets and similar scripts, including any in page templates such as `app.html` or `_document`.\n- **Real customer data:** never copy it from a database, fixture or log. Invent names, emails and numbers. The gates can't detect this, so it's on you. Links are public.\n- **Fonts, images and video:** copy the files into the folder with relative or root paths. For a hosted font such as Google Fonts, download the files and use `@font-face`; Google only serves `woff2` to a browser user agent. If a font is licensed, tell the person it will be publicly hosted. Add up media sizes before downloading, take only what the design shows, and compress video.\n- **One screen of a routed app:** a router reads the page's address, which won't match the screen you want. Write a small entry file that mounts that screen with a memory or hash router, instead of trying to reach its route.\n\n## Limits\n\n100 MB per frame, 25 MB per file, 5000 files per frame, 200 frames per canvas. A URL can't be added, not even `localhost`: build it or `capture` it. Refused files: `.env`, credentials, keys, databases, source maps, server code, `node_modules` and project files such as `package.json`. Pop-ups, camera and microphone are off.\n\n## Steps\n\n1. **Start the canvas.** From the project root, or in a monorepo from the app's own folder so `.twombly/work` can find its packages: `twombly init --name \"Notification Explorations\" --no-open`. Pick a short, specific name. Run it once per canvas. The result's `local` field is the canvas's address.\n2. **Open it beside the chat.** The person should see the canvas next to this conversation, in the app they're already using. Open the `local` address in that app's built-in browser: the Claude desktop app's browser pane, the ChatGPT app's in-app browser, or the built-in browser in Cursor, VS Code or any other editor. Only if the app has none, run `twombly open`, which uses their default browser.\n3. **Build each version** as its own static folder, keeping work under `.twombly/work/<version>` so the person's source stays clean. Use the first approach below that works. The canvas background moves so people can see you're at work: `init` and the slow commands keep it going for three minutes. Run `twombly working` only before a longer gap.\n4. **Add it:** `twombly add <folder> --title \"V1 Compact\" --width 1280 --height 800 --level build`, saying which level below it is. Use the design's real size, such as `--width 390 --height 844` for a phone. Use `--height auto` to fit a tall page.\n5. **Read the output.** If `ok` is false, fix every item in `problems` and run `add` again. Each one includes a `fix`. The frame isn't placed until it passes.\n6. **Look at it.** The result has a `screenshot` path. Open it and check the frame shows the design you meant, with the content in the right state. The gates can't tell if a page came out unstyled or on the wrong screen; a common miss is Tailwind classes that were never generated. Tailwind 4 skips gitignored folders, so source copied into `.twombly` needs an `@source`.\n7. **Test interactions, if they matter.** Open the `url` from `twombly preview <frame>` in your browser tool and use the page inside its iframe. On the canvas, a frame ignores clicks until it's double-clicked, and frame URLs refuse to open on their own. Tell the person what you didn't test.\n8. **Finish:** run `twombly done` so the background goes still, then tell the person the canvas is open. They click Share when they're ready. The first time, Google sign-in opens in their regular browser, and sharing continues on its own once they finish.\n\n## Choosing how to build a frame\n\nUse the first one that fits. Say so if you end up lower than the first, because the frame is less faithful. **If the app has server routes, auth or a database, skip straight to `component`**: a static export of that kind of app rarely works and isn't worth the attempt.\n\n1. `--level build`: **a real static build.** Vite: `vite build --base ./ --outDir .twombly/work/v1`. Next.js: `output: 'export'` in a temporary config (static export can't use server features). SvelteKit: `adapter-static` with a `fallback`, `ssr = false` in the root layout, and any server `load` swapped for mock data in a universal `+layout.ts`. Astro: `astro build`. Plain HTML: use the folder as is. To show one state of a bigger app, add a build entry that renders just that screen.\n2. `--level component`: **the component on its own.** Write a tiny entry file that imports the real component with mock props, and bundle it with Vite or esbuild into a folder. This is the usual answer when the app needs a backend. It keeps the real code and styles.\n3. `--level capture`: **freeze what's on screen.** With the app already running: `twombly capture http://localhost:3000/page --out .twombly/work/v1 --width 1280 --height 800`, then add that folder. It looks right but no longer responds, because scripts are removed.\n4. `--level image`: **a picture**, as a last resort. `twombly capture <url> --out <folder> --image`, or `twombly add shot.png --title \"V1\"`.\n\nSeveral versions can share one build: read the variant from `location.search` and pass `--entry \"index.html?variant=2\"`. Shared files are only uploaded once.\n\nAdd several frames in one command, which is faster, instead of writing a loop. Nothing is placed unless all of them pass. Each item takes `folder`, `title`, `entry`, `width`, `height`, `level`, `newRow`, `x` and `y`, and flags on the command apply to all:\n\n```sh\ntwombly add out --width 1280 --height 800 --level component --frames '[{\"title\":\"A\",\"entry\":\"index.html?variant=a\"},{\"title\":\"B\",\"entry\":\"index.html?variant=b\"}]'\n```\n\n### Example: the component level with Vite\n\nFor most projects, this is all it takes. It works the same for React, Svelte or Vue with that framework's Vite plugin. Keep it under `.twombly/work`.\n\n```js\n// .twombly/work/vite.config.mjs\nimport { defineConfig } from 'vite';\nimport { resolve } from 'node:path';\nimport react from '@vitejs/plugin-react';\n\nconst root = resolve(import.meta.dirname, '../..');\nexport default defineConfig({\n root: import.meta.dirname,\n base: './',\n plugins: [react()],\n resolve: { alias: { '@': root } }, // match the project's path aliases\n define: { 'process.env': {} },\n build: { outDir: 'out', emptyOutDir: true, sourcemap: false },\n});\n```\n\n```html\n<!-- .twombly/work/index.html -->\n<!doctype html><html><body><div id=\"root\"></div><script type=\"module\" src=\"./main.jsx\"></script></body></html>\n```\n\n```jsx\n// .twombly/work/main.jsx: the real component, the real styles, invented data.\nimport { createRoot } from 'react-dom/client';\nimport '@/styles/globals.css';\nimport { Composer } from '@/components/composer';\n\nconst variant = new URLSearchParams(location.search).get('variant');\ncreateRoot(document.getElementById('root')).render(<Composer variant={variant} onSend={() => {}} messages={MOCK_MESSAGES} />);\n```\n\nThen: `npx vite build --config .twombly/work/vite.config.mjs` and `twombly add .twombly/work/out --title \"A\" --entry \"index.html?variant=a\" --level component`. If the project uses Tailwind, add its Vite or PostCSS plugin to the config so the classes are generated.\n\n## Commands\n\n`twombly help` lists every command and flag. `<frame>` is a frame's id or its exact title. Frames fill in left to right; use `--new-row` to start a new group, for example one row per direction.\n\nUse `update` to change a version. Don't remove and re-add it, because that loses its comments. It keeps the frame's entry and size unless you change them. Each frame keeps its own copy of its files, so after rebuilding, update the set together: `twombly update out --frames '[{\"frame\":\"A\"},{\"frame\":\"B\"}]'`. Nothing changes unless all of them pass.\n\n## Sharing\n\nThe person shares by clicking Share. Only run `twombly share` if they explicitly ask you to, because it publishes the canvas to a public link. If they ask you to take it down, run `twombly unshare`. That deletes the link and its comments and leaves the local canvas.\n\nOnce it's shared, every `add`, `update`, `move` and `remove` goes live immediately. Check your work with `twombly check` before updating a canvas people are looking at.\n";
|
|
259
|
+
|
|
260
|
+
// src/capture.ts
|
|
261
|
+
import { createHash } from "node:crypto";
|
|
262
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
263
|
+
import { extname, join } from "node:path";
|
|
264
|
+
|
|
265
|
+
// src/browser.ts
|
|
266
|
+
import { chromium } from "playwright-core";
|
|
267
|
+
async function launchBrowser() {
|
|
268
|
+
const attempts = [() => chromium.launch({ channel: "chrome" }), () => chromium.launch({ channel: "msedge" }), () => chromium.launch()];
|
|
269
|
+
let last;
|
|
270
|
+
for (const attempt of attempts) {
|
|
271
|
+
try {
|
|
272
|
+
return await attempt();
|
|
273
|
+
} catch (error) {
|
|
274
|
+
last = error;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
throw Object.assign(new Error("No browser found"), { cause: last, code: "NO_BROWSER" });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/capture.ts
|
|
281
|
+
var EXT = { "text/css": ".css", "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif", "image/svg+xml": ".svg", "image/avif": ".avif", "font/woff2": ".woff2", "font/woff": ".woff", "font/ttf": ".ttf", "video/mp4": ".mp4", "video/webm": ".webm" };
|
|
282
|
+
async function capture(url, out, options) {
|
|
283
|
+
const browser = await launchBrowser();
|
|
284
|
+
try {
|
|
285
|
+
const context = await browser.newContext({ viewport: { width: options.width, height: options.height } });
|
|
286
|
+
const page = await context.newPage();
|
|
287
|
+
await page.goto(url, { waitUntil: "load", timeout: 45e3 });
|
|
288
|
+
await page.waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {
|
|
289
|
+
});
|
|
290
|
+
await page.waitForTimeout(options.wait ?? 800);
|
|
291
|
+
await rm(out, { recursive: true, force: true });
|
|
292
|
+
await mkdir(join(out, "assets"), { recursive: true });
|
|
293
|
+
if (options.image) {
|
|
294
|
+
const target = options.selector ? page.locator(options.selector).first() : page;
|
|
295
|
+
await writeFile(join(out, "assets/capture.png"), await target.screenshot({ type: "png", fullPage: !options.selector }));
|
|
296
|
+
await writeFile(join(out, "index.html"), `<!doctype html><meta charset="utf-8"><style>html,body{margin:0;background:#fff}img{display:block;max-width:100%;height:auto}</style><img src="assets/capture.png" alt="">`);
|
|
297
|
+
return { folder: out, assets: 1, failed: [] };
|
|
298
|
+
}
|
|
299
|
+
const saved2 = /* @__PURE__ */ new Map(), failed = [];
|
|
300
|
+
const save = async (assetUrl) => {
|
|
301
|
+
if (assetUrl.startsWith("data:") || assetUrl.startsWith("blob:")) return null;
|
|
302
|
+
if (saved2.has(assetUrl)) return saved2.get(assetUrl);
|
|
303
|
+
try {
|
|
304
|
+
const response = await context.request.get(assetUrl, { timeout: 2e4 });
|
|
305
|
+
if (!response.ok()) throw new Error(String(response.status()));
|
|
306
|
+
const type = (response.headers()["content-type"] || "").split(";")[0].trim();
|
|
307
|
+
let bytes = await response.body();
|
|
308
|
+
const hash = createHash("sha256").update(assetUrl).digest("hex").slice(0, 16);
|
|
309
|
+
const name = `assets/${hash}${EXT[type] || extname(new URL(assetUrl).pathname) || ""}`;
|
|
310
|
+
saved2.set(assetUrl, name);
|
|
311
|
+
if (type === "text/css") bytes = Buffer.from(await rewriteCss(bytes.toString("utf8"), assetUrl, "css"));
|
|
312
|
+
await writeFile(join(out, name), bytes);
|
|
313
|
+
return name;
|
|
314
|
+
} catch {
|
|
315
|
+
failed.push(assetUrl);
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
const rewriteCss = async (css, base, from) => {
|
|
320
|
+
const matches = [...css.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)];
|
|
321
|
+
let output = css;
|
|
322
|
+
for (const match of matches) {
|
|
323
|
+
if (match[2].startsWith("data:") || match[2].startsWith("#")) continue;
|
|
324
|
+
let absolute;
|
|
325
|
+
try {
|
|
326
|
+
absolute = new URL(match[2], base).href;
|
|
327
|
+
} catch {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const name = await save(absolute);
|
|
331
|
+
if (!name) continue;
|
|
332
|
+
output = output.split(match[0]).join(`url("${from === "css" ? name.replace("assets/", "") : name}")`);
|
|
333
|
+
}
|
|
334
|
+
return output;
|
|
335
|
+
};
|
|
336
|
+
const found = await page.evaluate(() => {
|
|
337
|
+
const absolute = (value) => {
|
|
338
|
+
try {
|
|
339
|
+
return new URL(value, document.baseURI).href;
|
|
340
|
+
} catch {
|
|
341
|
+
return value;
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
document.querySelectorAll("canvas").forEach((canvas) => {
|
|
345
|
+
try {
|
|
346
|
+
const image = document.createElement("img");
|
|
347
|
+
image.src = canvas.toDataURL();
|
|
348
|
+
image.style.cssText = canvas.style.cssText;
|
|
349
|
+
image.width = canvas.clientWidth;
|
|
350
|
+
image.height = canvas.clientHeight;
|
|
351
|
+
image.className = canvas.className;
|
|
352
|
+
canvas.replaceWith(image);
|
|
353
|
+
} catch {
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
document.querySelectorAll("input,textarea").forEach((field) => field.setAttribute("value", field.value));
|
|
357
|
+
document.querySelectorAll("style").forEach((style) => {
|
|
358
|
+
if (style.textContent?.trim() || !style.sheet) return;
|
|
359
|
+
try {
|
|
360
|
+
style.textContent = [...style.sheet.cssRules].map((rule) => rule.cssText).join("\n");
|
|
361
|
+
} catch {
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
document.querySelectorAll("script,noscript,link[rel=preload],link[rel=modulepreload],link[rel=prefetch],link[rel=dns-prefetch],link[rel=preconnect],link[rel=manifest],base,meta[http-equiv]").forEach((node) => node.remove());
|
|
365
|
+
const attributes = [];
|
|
366
|
+
const nodes = [...document.querySelectorAll("[src],[poster],[srcset],link[rel=stylesheet],link[rel~=icon]")];
|
|
367
|
+
nodes.forEach((node, tag) => {
|
|
368
|
+
node.setAttribute("data-oc", String(tag));
|
|
369
|
+
for (const name of ["src", "poster", "href"]) {
|
|
370
|
+
const value = node.getAttribute(name);
|
|
371
|
+
if (value) attributes.push({ tag, name, value: absolute(value) });
|
|
372
|
+
}
|
|
373
|
+
if (node.tagName === "IMG" && node.currentSrc) {
|
|
374
|
+
attributes.push({ tag, name: "src", value: node.currentSrc });
|
|
375
|
+
node.removeAttribute("srcset");
|
|
376
|
+
} else if (node.hasAttribute("srcset")) {
|
|
377
|
+
const best = (node.getAttribute("srcset") || "").split(",").pop()?.trim().split(/\s+/)[0];
|
|
378
|
+
if (best) attributes.push({ tag, name: "src", value: absolute(best) });
|
|
379
|
+
node.removeAttribute("srcset");
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
const styles2 = [...document.querySelectorAll("style")].map((style) => style.textContent || "");
|
|
383
|
+
const inline2 = [...document.querySelectorAll('[style*="url("]')].map((node) => node.getAttribute("style") || "");
|
|
384
|
+
return { attributes, styles: styles2, inline: inline2, base: document.baseURI };
|
|
385
|
+
});
|
|
386
|
+
const replacements = [];
|
|
387
|
+
for (const attribute of found.attributes) {
|
|
388
|
+
const name = await save(attribute.value);
|
|
389
|
+
if (name) replacements.push({ tag: attribute.tag, name: attribute.name, value: name });
|
|
390
|
+
}
|
|
391
|
+
const styles = await Promise.all(found.styles.map((style) => rewriteCss(style, found.base, "html")));
|
|
392
|
+
const inline = await Promise.all(found.inline.map((style) => rewriteCss(style, found.base, "html")));
|
|
393
|
+
const html = await page.evaluate(({ replacements: replacements2, styles: styles2, inline: inline2 }) => {
|
|
394
|
+
for (const { tag, name, value } of replacements2) document.querySelector(`[data-oc="${tag}"]`)?.setAttribute(name, value);
|
|
395
|
+
document.querySelectorAll("[data-oc]").forEach((node) => node.removeAttribute("data-oc"));
|
|
396
|
+
document.querySelectorAll("style").forEach((style, index) => {
|
|
397
|
+
style.textContent = styles2[index];
|
|
398
|
+
});
|
|
399
|
+
document.querySelectorAll('[style*="url("]').forEach((node, index) => node.setAttribute("style", inline2[index]));
|
|
400
|
+
return "<!doctype html>\n" + document.documentElement.outerHTML;
|
|
401
|
+
}, { replacements, styles, inline });
|
|
402
|
+
await writeFile(join(out, "index.html"), html);
|
|
403
|
+
return { folder: out, assets: saved2.size, failed };
|
|
404
|
+
} finally {
|
|
405
|
+
await browser.close().catch(() => {
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/daemon.ts
|
|
411
|
+
import { createHash as createHash3, randomBytes } from "node:crypto";
|
|
412
|
+
import { createReadStream as createReadStream2 } from "node:fs";
|
|
413
|
+
import { readFile as readFile5, stat as stat3 } from "node:fs/promises";
|
|
414
|
+
import { createServer as createServer2 } from "node:http";
|
|
415
|
+
import { extname as extname2, join as join8 } from "node:path";
|
|
416
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
417
|
+
|
|
418
|
+
// src/credentials.ts
|
|
419
|
+
import { execFile, spawn } from "node:child_process";
|
|
420
|
+
import { mkdir as mkdir2, readFile, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
|
|
421
|
+
import { join as join3 } from "node:path";
|
|
422
|
+
import { promisify } from "node:util";
|
|
423
|
+
|
|
424
|
+
// src/paths.ts
|
|
425
|
+
import { homedir } from "node:os";
|
|
426
|
+
import { join as join2 } from "node:path";
|
|
427
|
+
var HOME = process.env.TWOMBLY_HOME || join2(homedir(), ".twombly");
|
|
428
|
+
var PORT = Number(process.env.TWOMBLY_PORT) || 4747;
|
|
429
|
+
var ENDPOINT = (process.env.TWOMBLY_ENDPOINT || "https://twombly.app").replace(/\/+$/, "");
|
|
430
|
+
var DAEMON_ORIGIN = `http://localhost:${PORT}`;
|
|
431
|
+
var NO_OPEN = process.env.TWOMBLY_NO_OPEN === "1";
|
|
432
|
+
|
|
433
|
+
// src/credentials.ts
|
|
434
|
+
var run = promisify(execFile);
|
|
435
|
+
var SERVICE = "twombly";
|
|
436
|
+
var useKeychain = process.platform === "darwin" && !process.env.TWOMBLY_HOME;
|
|
437
|
+
var file = join3(HOME, "credentials.json");
|
|
438
|
+
async function readCredential() {
|
|
439
|
+
try {
|
|
440
|
+
if (useKeychain) {
|
|
441
|
+
const { stdout } = await run("security", ["find-generic-password", "-s", SERVICE, "-a", ENDPOINT, "-w"]);
|
|
442
|
+
return JSON.parse(stdout.trim());
|
|
443
|
+
}
|
|
444
|
+
return JSON.parse(await readFile(file, "utf8"))[ENDPOINT] || null;
|
|
445
|
+
} catch {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async function writeCredential(credential2) {
|
|
450
|
+
const value = JSON.stringify(credential2);
|
|
451
|
+
if (useKeychain) {
|
|
452
|
+
await new Promise((resolve3, reject) => {
|
|
453
|
+
const child = spawn("security", ["-i"], { stdio: ["pipe", "ignore", "ignore"] });
|
|
454
|
+
child.on("error", reject);
|
|
455
|
+
child.on("exit", (code) => code === 0 ? resolve3() : reject(new Error("Could not save to the Keychain")));
|
|
456
|
+
const quoted = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
457
|
+
child.stdin.end(`add-generic-password -U -s ${SERVICE} -a "${ENDPOINT}" -w "${quoted}"
|
|
458
|
+
`);
|
|
459
|
+
});
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
await mkdir2(HOME, { recursive: true, mode: 448 });
|
|
463
|
+
let all = {};
|
|
464
|
+
try {
|
|
465
|
+
all = JSON.parse(await readFile(file, "utf8"));
|
|
466
|
+
} catch {
|
|
467
|
+
}
|
|
468
|
+
all[ENDPOINT] = credential2;
|
|
469
|
+
await writeFile2(file, JSON.stringify(all), { mode: 384 });
|
|
470
|
+
}
|
|
471
|
+
async function clearCredential() {
|
|
472
|
+
if (useKeychain) {
|
|
473
|
+
await run("security", ["delete-generic-password", "-s", SERVICE, "-a", ENDPOINT]).catch(() => {
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
const all = JSON.parse(await readFile(file, "utf8"));
|
|
479
|
+
delete all[ENDPOINT];
|
|
480
|
+
await writeFile2(file, JSON.stringify(all), { mode: 384 });
|
|
481
|
+
} catch {
|
|
482
|
+
await rm2(file, { force: true });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// src/frameserver.ts
|
|
487
|
+
import { createReadStream } from "node:fs";
|
|
488
|
+
import { readFile as readFile2, stat } from "node:fs/promises";
|
|
489
|
+
import { createServer } from "node:http";
|
|
490
|
+
import { join as join4 } from "node:path";
|
|
491
|
+
var isFile = (path) => stat(path).then((s) => s.isFile() ? s : null, () => null);
|
|
492
|
+
async function startFrameServer(folder, poster) {
|
|
493
|
+
const server = createServer(async (request, response) => {
|
|
494
|
+
const url = new URL(request.url || "/", "http://localhost");
|
|
495
|
+
const headers = {
|
|
496
|
+
"content-security-policy": frameCsp(["http://localhost:*", "http://127.0.0.1:*"]),
|
|
497
|
+
"x-content-type-options": "nosniff",
|
|
498
|
+
"referrer-policy": "no-referrer",
|
|
499
|
+
"cross-origin-resource-policy": "cross-origin",
|
|
500
|
+
"cache-control": "no-cache"
|
|
501
|
+
};
|
|
502
|
+
const send = (status, body, type = "text/plain; charset=utf-8") => {
|
|
503
|
+
response.writeHead(status, { ...headers, "content-type": type });
|
|
504
|
+
response.end(body);
|
|
505
|
+
};
|
|
506
|
+
try {
|
|
507
|
+
if (request.method !== "GET" && request.method !== "HEAD") return send(405, "Method not allowed");
|
|
508
|
+
if (request.headers["sec-fetch-dest"] === "document") return send(403, FRAME_ONLY_MESSAGE);
|
|
509
|
+
if (url.pathname === FRAME_BRIDGE_PATH) return send(200, FRAME_BRIDGE_JS, "text/javascript; charset=utf-8");
|
|
510
|
+
if (url.pathname === FRAME_POSTER_PATH) return poster && await isFile(poster) ? send(200, await readFile2(poster), "image/png") : send(404, "Not found");
|
|
511
|
+
let found = null, size2 = 0, name = "";
|
|
512
|
+
for (const candidate of candidatePaths(url.pathname)) {
|
|
513
|
+
const info = await isFile(join4(folder, candidate));
|
|
514
|
+
if (info) {
|
|
515
|
+
found = join4(folder, candidate);
|
|
516
|
+
size2 = info.size;
|
|
517
|
+
name = candidate;
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (!found && request.headers["sec-fetch-dest"] === "iframe") {
|
|
522
|
+
const info = await isFile(join4(folder, "index.html"));
|
|
523
|
+
if (info) {
|
|
524
|
+
found = join4(folder, "index.html");
|
|
525
|
+
size2 = info.size;
|
|
526
|
+
name = "index.html";
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (!found) return send(404, "Not found");
|
|
530
|
+
const type = contentType(name);
|
|
531
|
+
if (type.startsWith("text/html")) return send(200, injectBridge(await readFile2(found, "utf8")), type);
|
|
532
|
+
const range = String(request.headers.range || "").match(/^bytes=(\d*)-(\d*)$/);
|
|
533
|
+
if (range && (range[1] || range[2])) {
|
|
534
|
+
const start = range[1] ? Number(range[1]) : Math.max(0, size2 - Number(range[2]));
|
|
535
|
+
const end = range[1] && range[2] ? Math.min(Number(range[2]), size2 - 1) : size2 - 1;
|
|
536
|
+
if (start > end) {
|
|
537
|
+
response.writeHead(416, { ...headers, "content-range": `bytes */${size2}` });
|
|
538
|
+
return response.end();
|
|
539
|
+
}
|
|
540
|
+
response.writeHead(206, { ...headers, "content-type": type, "accept-ranges": "bytes", "content-range": `bytes ${start}-${end}/${size2}`, "content-length": end - start + 1 });
|
|
541
|
+
return createReadStream(found, { start, end }).pipe(response);
|
|
542
|
+
}
|
|
543
|
+
response.writeHead(200, { ...headers, "content-type": type, "accept-ranges": "bytes", "content-length": size2 });
|
|
544
|
+
if (request.method === "HEAD") return response.end();
|
|
545
|
+
createReadStream(found).pipe(response);
|
|
546
|
+
} catch {
|
|
547
|
+
if (!response.headersSent) send(500, "Could not read the file");
|
|
548
|
+
else response.destroy();
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
await new Promise((resolve3) => server.listen(0, "127.0.0.1", resolve3));
|
|
552
|
+
const { port } = server.address();
|
|
553
|
+
return { origin: `http://localhost:${port}`, close: () => {
|
|
554
|
+
server.closeAllConnections();
|
|
555
|
+
server.close();
|
|
556
|
+
} };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// src/interface.ts
|
|
560
|
+
import { createPublicKey, verify } from "node:crypto";
|
|
561
|
+
import { mkdir as mkdir3, readdir, rename, rm as rm3, writeFile as writeFile3 } from "node:fs/promises";
|
|
562
|
+
import { dirname, join as join5 } from "node:path";
|
|
563
|
+
import { fileURLToPath } from "node:url";
|
|
564
|
+
|
|
565
|
+
// src/release.ts
|
|
566
|
+
var INTERFACE_VERSION = 1;
|
|
567
|
+
var INTERFACE_PUBLIC_KEY = "MCowBQYDK2VwAyEA2/JCcP+q2Ht/fjTqlMvBzwrUS07UAXNp1z6bqY0hG9U=";
|
|
568
|
+
|
|
569
|
+
// src/interface.ts
|
|
570
|
+
var OVERRIDE = process.env.TWOMBLY_UI;
|
|
571
|
+
var UPDATES = !OVERRIDE && process.env.TWOMBLY_NO_INTERFACE_UPDATES !== "1";
|
|
572
|
+
var BUNDLED = join5(dirname(fileURLToPath(import.meta.url)), "ui");
|
|
573
|
+
var STORE = join5(HOME, "interface", String(INTERFACE_VERSION));
|
|
574
|
+
var PUBLIC_KEY = createPublicKey({ key: Buffer.from(process.env.TWOMBLY_INTERFACE_KEY || INTERFACE_PUBLIC_KEY, "base64"), format: "der", type: "spki" });
|
|
575
|
+
var KEEP = 3;
|
|
576
|
+
var CHECK_MS = 36e5;
|
|
577
|
+
var CHECK_ON_OPEN_MS = 5 * 6e4;
|
|
578
|
+
var WAIT_ON_OPEN_MS = 600;
|
|
579
|
+
var DOWNLOAD_MS = 3e4;
|
|
580
|
+
var MAX_CHARS = 64 * 1024 * 1024;
|
|
581
|
+
var SAFE_PATH = /^[\w.@-]+(\/[\w.@-]+)*$/;
|
|
582
|
+
var saved = [];
|
|
583
|
+
var etag = "";
|
|
584
|
+
var checking = null;
|
|
585
|
+
var checkedAt = 0;
|
|
586
|
+
function interfaceFolders() {
|
|
587
|
+
if (OVERRIDE) return [OVERRIDE];
|
|
588
|
+
const folder = (released) => join5(STORE, String(released));
|
|
589
|
+
return [...saved.filter((released) => released > 1793896544071).map(folder), BUNDLED, ...saved.filter((released) => released <= 1793896544071).map(folder)];
|
|
590
|
+
}
|
|
591
|
+
function unpack(body) {
|
|
592
|
+
const { payload, signature } = JSON.parse(body);
|
|
593
|
+
if (typeof payload !== "string" || typeof signature !== "string") return null;
|
|
594
|
+
if (!verify(null, Buffer.from(payload), PUBLIC_KEY, Buffer.from(signature, "base64"))) return null;
|
|
595
|
+
const release = JSON.parse(payload);
|
|
596
|
+
if (release.interface !== INTERFACE_VERSION || !Number.isSafeInteger(release.released) || !release.files?.["index.html"]) return null;
|
|
597
|
+
const safe = Object.entries(release.files).every(([path, bytes]) => SAFE_PATH.test(path) && !path.split("/").includes("..") && typeof bytes === "string");
|
|
598
|
+
return safe ? release : null;
|
|
599
|
+
}
|
|
600
|
+
async function install(released, files) {
|
|
601
|
+
const temporary = join5(STORE, `.${released}-${process.pid}`);
|
|
602
|
+
try {
|
|
603
|
+
for (const [path, bytes] of Object.entries(files)) {
|
|
604
|
+
await mkdir3(dirname(join5(temporary, path)), { recursive: true });
|
|
605
|
+
await writeFile3(join5(temporary, path), Buffer.from(bytes, "base64"));
|
|
606
|
+
}
|
|
607
|
+
await rename(temporary, join5(STORE, String(released)));
|
|
608
|
+
} catch (error) {
|
|
609
|
+
await rm3(temporary, { recursive: true, force: true });
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
saved = [released, ...saved].sort((a, b) => b - a);
|
|
613
|
+
for (const old of saved.splice(KEEP)) await rm3(join5(STORE, String(old)), { recursive: true, force: true });
|
|
614
|
+
}
|
|
615
|
+
async function check() {
|
|
616
|
+
const response = await fetch(`${ENDPOINT}/interface/${INTERFACE_VERSION}`, { headers: etag ? { "if-none-match": etag } : {}, signal: AbortSignal.timeout(DOWNLOAD_MS) });
|
|
617
|
+
if (response.status !== 200) return;
|
|
618
|
+
const body = await response.text();
|
|
619
|
+
if (body.length > MAX_CHARS) return;
|
|
620
|
+
const release = unpack(body);
|
|
621
|
+
if (!release) return;
|
|
622
|
+
etag = response.headers.get("etag") || "";
|
|
623
|
+
if (release.released > Math.max(saved[0] ?? 0, 1793896544071)) await install(release.released, release.files);
|
|
624
|
+
}
|
|
625
|
+
function startCheck() {
|
|
626
|
+
if (checking) return;
|
|
627
|
+
checkedAt = Date.now();
|
|
628
|
+
checking = check().catch(() => {
|
|
629
|
+
}).finally(() => {
|
|
630
|
+
checking = null;
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
async function freshInterface() {
|
|
634
|
+
if (!UPDATES) return;
|
|
635
|
+
if (Date.now() - checkedAt > CHECK_ON_OPEN_MS) startCheck();
|
|
636
|
+
if (checking) await Promise.race([checking, new Promise((done) => setTimeout(done, WAIT_ON_OPEN_MS))]);
|
|
637
|
+
}
|
|
638
|
+
async function startInterfaceUpdates() {
|
|
639
|
+
if (!UPDATES) return;
|
|
640
|
+
await mkdir3(STORE, { recursive: true });
|
|
641
|
+
const names = await readdir(STORE);
|
|
642
|
+
for (const name of names) if (name.startsWith(".")) await rm3(join5(STORE, name), { recursive: true, force: true });
|
|
643
|
+
saved = names.filter((name) => /^\d+$/.test(name)).map(Number).sort((a, b) => b - a);
|
|
644
|
+
startCheck();
|
|
645
|
+
setInterval(startCheck, CHECK_MS).unref();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// src/project.ts
|
|
649
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
650
|
+
import { cp, mkdir as mkdir4, readdir as readdir2, readFile as readFile3, rename as rename2, rm as rm4, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
|
|
651
|
+
import { basename, dirname as dirname2, join as join6, relative, resolve, sep } from "node:path";
|
|
652
|
+
var DIR = ".twombly";
|
|
653
|
+
var exists = (path) => stat2(path).then(() => true, () => false);
|
|
654
|
+
async function findProject(from) {
|
|
655
|
+
let dir = resolve(from);
|
|
656
|
+
while (true) {
|
|
657
|
+
if (await exists(join6(dir, DIR, "canvas.json"))) return dir;
|
|
658
|
+
const up = dirname2(dir);
|
|
659
|
+
if (up === dir) return null;
|
|
660
|
+
dir = up;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
var canvasFile = (project) => join6(project, DIR, "canvas.json");
|
|
664
|
+
var frameDir = (project, id) => join6(project, DIR, "frames", id);
|
|
665
|
+
var posterFile = (project, id) => join6(project, DIR, "posters", `${id}.png`);
|
|
666
|
+
async function readCanvas(project) {
|
|
667
|
+
return JSON.parse(await readFile3(canvasFile(project), "utf8"));
|
|
668
|
+
}
|
|
669
|
+
async function writeCanvas(project, canvas) {
|
|
670
|
+
const path = canvasFile(project), temp = `${path}.${process.pid}.tmp`;
|
|
671
|
+
await writeFile4(temp, JSON.stringify(canvas, null, 2) + "\n");
|
|
672
|
+
await rename2(temp, path);
|
|
673
|
+
}
|
|
674
|
+
async function ensureProjectDir(project) {
|
|
675
|
+
await mkdir4(join6(project, DIR, "frames"), { recursive: true });
|
|
676
|
+
await mkdir4(join6(project, DIR, "posters"), { recursive: true });
|
|
677
|
+
await writeFile4(join6(project, DIR, ".gitignore"), "*\n");
|
|
678
|
+
}
|
|
679
|
+
function defaultName(project) {
|
|
680
|
+
return basename(project).replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) || "Untitled";
|
|
681
|
+
}
|
|
682
|
+
async function hashFolder(folder) {
|
|
683
|
+
const out = [];
|
|
684
|
+
const walk = async (dir) => {
|
|
685
|
+
for (const entry of await readdir2(dir, { withFileTypes: true })) {
|
|
686
|
+
const absolute = join6(dir, entry.name);
|
|
687
|
+
if (entry.isDirectory()) await walk(absolute);
|
|
688
|
+
else if (entry.isFile()) {
|
|
689
|
+
if (entry.name === ".DS_Store") continue;
|
|
690
|
+
const bytes = await readFile3(absolute);
|
|
691
|
+
out.push({ absolute, path: relative(folder, absolute).split(sep).join("/"), size: bytes.length, hash: createHash2("sha256").update(bytes).digest("hex") });
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
await walk(folder);
|
|
696
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
697
|
+
}
|
|
698
|
+
async function folderContent(folder) {
|
|
699
|
+
const files = await hashFolder(folder);
|
|
700
|
+
return { files, content: await contentHash(files) };
|
|
701
|
+
}
|
|
702
|
+
async function replaceFolder(source, target) {
|
|
703
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
704
|
+
await rm4(temp, { recursive: true, force: true });
|
|
705
|
+
await mkdir4(dirname2(target), { recursive: true });
|
|
706
|
+
await cp(source, temp, { recursive: true, verbatimSymlinks: false, filter: (path) => basename(path) !== ".DS_Store" });
|
|
707
|
+
await rm4(target, { recursive: true, force: true });
|
|
708
|
+
await rename2(temp, target);
|
|
709
|
+
}
|
|
710
|
+
var registryFile = join6(HOME, "registry.json");
|
|
711
|
+
async function readRegistry() {
|
|
712
|
+
try {
|
|
713
|
+
return JSON.parse(await readFile3(registryFile, "utf8"));
|
|
714
|
+
} catch {
|
|
715
|
+
return {};
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
async function register(id, project) {
|
|
719
|
+
const registry = await readRegistry();
|
|
720
|
+
if (registry[id] === project) return;
|
|
721
|
+
registry[id] = project;
|
|
722
|
+
await mkdir4(HOME, { recursive: true });
|
|
723
|
+
await writeFile4(registryFile, JSON.stringify(registry, null, 2));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// src/settings.ts
|
|
727
|
+
import { mkdir as mkdir5, readFile as readFile4, rename as rename3, writeFile as writeFile5 } from "node:fs/promises";
|
|
728
|
+
import { join as join7 } from "node:path";
|
|
729
|
+
var FILE = join7(HOME, "settings.json");
|
|
730
|
+
async function readSettings() {
|
|
731
|
+
const saved2 = await readFile4(FILE, "utf8").then(JSON.parse).catch(() => null);
|
|
732
|
+
return { invertScroll: saved2?.invertScroll === true };
|
|
733
|
+
}
|
|
734
|
+
async function writeSettings(settings2) {
|
|
735
|
+
await mkdir5(HOME, { recursive: true });
|
|
736
|
+
const temporary = `${FILE}.${process.pid}`;
|
|
737
|
+
await writeFile5(temporary, JSON.stringify(settings2));
|
|
738
|
+
await rename3(temporary, FILE);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// src/daemon.ts
|
|
742
|
+
var IDLE_MS = 6 * 36e5;
|
|
743
|
+
var LOGIN_MS = 10 * 6e4;
|
|
744
|
+
var ID_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
|
|
745
|
+
var randomId = (length) => [...randomBytes(length)].map((b) => ID_ALPHABET[b % ID_ALPHABET.length]).join("");
|
|
746
|
+
var Failure = class extends Error {
|
|
747
|
+
status;
|
|
748
|
+
extra;
|
|
749
|
+
constructor(status, message, extra) {
|
|
750
|
+
super(message);
|
|
751
|
+
this.status = status;
|
|
752
|
+
this.extra = extra;
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
var settings = { invertScroll: false };
|
|
756
|
+
var credential = null;
|
|
757
|
+
var pendingLogin = null;
|
|
758
|
+
function loginUrl() {
|
|
759
|
+
if (!pendingLogin || Date.now() - pendingLogin.at > LOGIN_MS) {
|
|
760
|
+
pendingLogin = { state: randomBytes(24).toString("hex"), verifier: randomBytes(32).toString("base64url"), at: Date.now() };
|
|
761
|
+
}
|
|
762
|
+
const challenge = createHash3("sha256").update(pendingLogin.verifier).digest("base64url");
|
|
763
|
+
return `${ENDPOINT}/auth/machine/start?` + new URLSearchParams({ port: String(PORT), state: pendingLogin.state, challenge });
|
|
764
|
+
}
|
|
765
|
+
function setCredential(next) {
|
|
766
|
+
credential = next;
|
|
767
|
+
for (const canvas of canvases.values()) canvas.event({ t: "auth", me: next?.me || null });
|
|
768
|
+
}
|
|
769
|
+
async function edge(path, init = {}) {
|
|
770
|
+
if (!credential) throw new Failure(401, "Sign in first");
|
|
771
|
+
const response = await fetch(ENDPOINT + path, {
|
|
772
|
+
...init,
|
|
773
|
+
headers: { authorization: `Bearer ${credential.token}`, ...init.body && !(init.body instanceof Uint8Array) ? { "content-type": "application/json" } : {}, ...init.headers }
|
|
774
|
+
});
|
|
775
|
+
const data = await response.json().catch(() => ({}));
|
|
776
|
+
if (response.status === 401) {
|
|
777
|
+
await clearCredential();
|
|
778
|
+
setCredential(null);
|
|
779
|
+
throw new Failure(401, "Sign in first");
|
|
780
|
+
}
|
|
781
|
+
if (!response.ok) throw new Failure(response.status, data.error || `The server answered ${response.status}`, data.problems ? { problems: data.problems } : void 0);
|
|
782
|
+
return data;
|
|
783
|
+
}
|
|
784
|
+
var STROKE_UPLOAD = 40;
|
|
785
|
+
var Canvas = class {
|
|
786
|
+
id;
|
|
787
|
+
project;
|
|
788
|
+
data;
|
|
789
|
+
sockets = /* @__PURE__ */ new Set();
|
|
790
|
+
events = /* @__PURE__ */ new Set();
|
|
791
|
+
servers = /* @__PURE__ */ new Map();
|
|
792
|
+
queue = Promise.resolve();
|
|
793
|
+
saveTimer = null;
|
|
794
|
+
workingUntil = 0;
|
|
795
|
+
constructor(project, data) {
|
|
796
|
+
this.project = project;
|
|
797
|
+
this.data = data;
|
|
798
|
+
this.id = data.id;
|
|
799
|
+
}
|
|
800
|
+
/** One change at a time, so files and uploads never interleave. */
|
|
801
|
+
run(work) {
|
|
802
|
+
const next = this.queue.then(work, work);
|
|
803
|
+
this.queue = next.catch(() => {
|
|
804
|
+
});
|
|
805
|
+
return next;
|
|
806
|
+
}
|
|
807
|
+
save() {
|
|
808
|
+
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
809
|
+
this.saveTimer = setTimeout(() => {
|
|
810
|
+
writeCanvas(this.project, this.data).catch(() => {
|
|
811
|
+
});
|
|
812
|
+
}, 150);
|
|
813
|
+
}
|
|
814
|
+
async saveNow() {
|
|
815
|
+
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
816
|
+
await writeCanvas(this.project, this.data);
|
|
817
|
+
}
|
|
818
|
+
serverFor(frame) {
|
|
819
|
+
const key = `${frame.id}:${frame.content}`;
|
|
820
|
+
let server = this.servers.get(key);
|
|
821
|
+
if (!server) {
|
|
822
|
+
for (const [old, running] of this.servers) if (old.startsWith(`${frame.id}:`)) {
|
|
823
|
+
running.then((s) => s.close());
|
|
824
|
+
this.servers.delete(old);
|
|
825
|
+
}
|
|
826
|
+
server = startFrameServer(frameDir(this.project, frame.id), frame.hasPoster ? posterFile(this.project, frame.id) : null);
|
|
827
|
+
this.servers.set(key, server);
|
|
828
|
+
}
|
|
829
|
+
return server;
|
|
830
|
+
}
|
|
831
|
+
async toFrame(frame) {
|
|
832
|
+
const { origin } = await this.serverFor(frame);
|
|
833
|
+
return { ...frame, url: `${origin}/${frame.entry}`, posterUrl: frame.hasPoster ? origin + FRAME_POSTER_PATH : null };
|
|
834
|
+
}
|
|
835
|
+
sendTo(sockets, message, except) {
|
|
836
|
+
const text2 = JSON.stringify(message);
|
|
837
|
+
for (const socket of sockets) if (socket !== except && socket.readyState === WebSocket.OPEN) socket.send(text2);
|
|
838
|
+
}
|
|
839
|
+
broadcast(message) {
|
|
840
|
+
this.sendTo(this.sockets, message);
|
|
841
|
+
}
|
|
842
|
+
event(message) {
|
|
843
|
+
this.sendTo(this.events, message);
|
|
844
|
+
}
|
|
845
|
+
get watchers() {
|
|
846
|
+
return this.sockets.size + this.events.size;
|
|
847
|
+
}
|
|
848
|
+
workingLeft() {
|
|
849
|
+
return Math.max(0, this.workingUntil - Date.now());
|
|
850
|
+
}
|
|
851
|
+
/** An agent is at work for at least this long; zero means it's finished. Everyone looking at the canvas sees it. */
|
|
852
|
+
working(ms) {
|
|
853
|
+
this.workingUntil = ms > 0 ? Math.max(this.workingUntil, Date.now() + ms) : 0;
|
|
854
|
+
const left = this.workingLeft();
|
|
855
|
+
this.broadcast({ t: "working", ms: left });
|
|
856
|
+
if (this.data.remote) edge(`/api/canvases/${this.data.remote.id}/working`, { method: "POST", body: JSON.stringify({ ms: left }) }).catch(() => {
|
|
857
|
+
});
|
|
858
|
+
return left;
|
|
859
|
+
}
|
|
860
|
+
peer(selfId) {
|
|
861
|
+
const me = credential?.me;
|
|
862
|
+
return { id: selfId, userId: me?.id || "local", name: me?.name || "You", picture: me?.picture || null, color: "#0D99FF", owner: true, guest: false, huddle: null };
|
|
863
|
+
}
|
|
864
|
+
async attach(socket) {
|
|
865
|
+
this.sockets.add(socket);
|
|
866
|
+
touch();
|
|
867
|
+
const self = randomId(10);
|
|
868
|
+
socket.on("close", () => this.sockets.delete(socket));
|
|
869
|
+
socket.on("message", (raw) => {
|
|
870
|
+
let message;
|
|
871
|
+
try {
|
|
872
|
+
message = JSON.parse(String(raw));
|
|
873
|
+
} catch {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
this.handle(socket, message).catch(() => {
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
const frames = await Promise.all(this.data.frames.map((frame) => this.toFrame(frame)));
|
|
880
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
881
|
+
socket.send(JSON.stringify({
|
|
882
|
+
t: "hello",
|
|
883
|
+
self,
|
|
884
|
+
role: "owner",
|
|
885
|
+
doc: { id: this.id, name: this.data.name, frames, strokes: this.data.strokes ?? [] },
|
|
886
|
+
threads: [],
|
|
887
|
+
peers: [this.peer(self)],
|
|
888
|
+
capabilities: { comments: false, huddle: false },
|
|
889
|
+
working: this.workingLeft()
|
|
890
|
+
}));
|
|
891
|
+
}
|
|
892
|
+
attachEvents(socket) {
|
|
893
|
+
this.events.add(socket);
|
|
894
|
+
socket.on("close", () => this.events.delete(socket));
|
|
895
|
+
}
|
|
896
|
+
async handle(socket, m) {
|
|
897
|
+
if (m.t === "ping") return socket.send('{"t":"pong"}');
|
|
898
|
+
if (this.data.remote) return;
|
|
899
|
+
if (m.t === "frame.patch") return this.applyPatch(m.id, m.patch);
|
|
900
|
+
if (m.t === "frame.remove") {
|
|
901
|
+
const before = this.data.frames.length;
|
|
902
|
+
this.data.frames = this.data.frames.filter((frame) => frame.id !== m.id);
|
|
903
|
+
if (this.data.frames.length !== before) {
|
|
904
|
+
this.broadcast({ t: "frame.remove", id: m.id });
|
|
905
|
+
this.save();
|
|
906
|
+
}
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (m.t === "frame.restore") {
|
|
910
|
+
const f = m.frame;
|
|
911
|
+
if (this.data.frames.some((frame) => frame.id === f.id)) return;
|
|
912
|
+
const restored = { id: f.id, title: f.title, x: f.x, y: f.y, w: f.w, h: f.h, z: f.z, entry: f.entry, content: f.content, level: f.level, hasPoster: !!f.posterUrl, updatedAt: Date.now() };
|
|
913
|
+
this.data.frames.push(restored);
|
|
914
|
+
this.broadcast({ t: "frame.upsert", frame: await this.toFrame(restored) });
|
|
915
|
+
this.save();
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (m.t === "stroke.add") {
|
|
919
|
+
const strokes = this.data.strokes ??= [];
|
|
920
|
+
const clean = cleanStroke(m.stroke);
|
|
921
|
+
if (!clean || strokes.length >= LIMITS.canvasStrokes && !strokes.some((item) => item.id === clean.id)) return this.broadcast({ t: "stroke.remove", id: String(m.stroke?.id) });
|
|
922
|
+
const stroke = { ...clean, authorId: "local", createdAt: Date.now() };
|
|
923
|
+
this.data.strokes = [...strokes.filter((item) => item.id !== stroke.id), stroke];
|
|
924
|
+
this.broadcast({ t: "strokes", strokes: [stroke] });
|
|
925
|
+
this.save();
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (m.t === "stroke.remove") {
|
|
929
|
+
this.data.strokes = (this.data.strokes ?? []).filter((item) => item.id !== m.id);
|
|
930
|
+
this.broadcast({ t: "stroke.remove", id: m.id });
|
|
931
|
+
this.save();
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (m.t === "canvas.rename") {
|
|
935
|
+
const name = String(m.name || "").trim().slice(0, LIMITS.nameChars);
|
|
936
|
+
if (name) {
|
|
937
|
+
this.data.name = name;
|
|
938
|
+
this.broadcast({ t: "canvas", name });
|
|
939
|
+
this.save();
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
async applyPatch(id, patch) {
|
|
944
|
+
const frame = this.data.frames.find((item) => item.id === id);
|
|
945
|
+
if (!frame) return null;
|
|
946
|
+
if (typeof patch.title === "string" && patch.title.trim()) frame.title = patch.title.trim().slice(0, LIMITS.titleChars);
|
|
947
|
+
for (const key of ["x", "y", "z", "w", "h"]) if (typeof patch[key] === "number" && Number.isFinite(patch[key])) frame[key] = patch[key];
|
|
948
|
+
frame.w = Math.max(40, Math.min(8e3, frame.w));
|
|
949
|
+
frame.h = Math.max(40, Math.min(8e3, frame.h));
|
|
950
|
+
frame.updatedAt = Date.now();
|
|
951
|
+
this.broadcast({ t: "frame.upsert", frame: await this.toFrame(frame) });
|
|
952
|
+
this.save();
|
|
953
|
+
return frame;
|
|
954
|
+
}
|
|
955
|
+
/** Once shared, the room is the truth. Bring the local list up to date with it. */
|
|
956
|
+
async pull() {
|
|
957
|
+
if (!this.data.remote) return;
|
|
958
|
+
let doc;
|
|
959
|
+
try {
|
|
960
|
+
({ doc } = await edge(`/api/canvases/${this.data.remote.id}`));
|
|
961
|
+
} catch (error) {
|
|
962
|
+
if (error instanceof Failure && (error.status === 404 || error.status === 403)) {
|
|
963
|
+
this.data.remote = null;
|
|
964
|
+
await this.saveNow();
|
|
965
|
+
this.event({ t: "shared", url: null });
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
throw error;
|
|
969
|
+
}
|
|
970
|
+
this.data.name = doc.name;
|
|
971
|
+
if (doc.strokes) this.data.strokes = doc.strokes;
|
|
972
|
+
const known = new Map(this.data.frames.map((frame) => [frame.id, frame]));
|
|
973
|
+
this.data.frames = doc.frames.map((frame) => ({
|
|
974
|
+
id: frame.id,
|
|
975
|
+
title: frame.title,
|
|
976
|
+
x: frame.x,
|
|
977
|
+
y: frame.y,
|
|
978
|
+
w: frame.w,
|
|
979
|
+
h: frame.h,
|
|
980
|
+
z: frame.z,
|
|
981
|
+
entry: frame.entry,
|
|
982
|
+
content: frame.content,
|
|
983
|
+
level: frame.level,
|
|
984
|
+
hasPoster: known.get(frame.id)?.hasPoster ?? !!frame.posterUrl,
|
|
985
|
+
updatedAt: frame.updatedAt
|
|
986
|
+
}));
|
|
987
|
+
await this.saveNow();
|
|
988
|
+
}
|
|
989
|
+
/** Sends a frame's files up, skipping what's already there, then places the frame in the room. */
|
|
990
|
+
async uploadFrame(remoteId, frame, onFile) {
|
|
991
|
+
const { files } = await folderContent(frameDir(this.project, frame.id));
|
|
992
|
+
let poster = null, posterBytes = null;
|
|
993
|
+
if (frame.hasPoster) {
|
|
994
|
+
posterBytes = await readFile5(posterFile(this.project, frame.id)).catch(() => null);
|
|
995
|
+
if (posterBytes) poster = createHash3("sha256").update(posterBytes).digest("hex");
|
|
996
|
+
}
|
|
997
|
+
const manifest = files.map(({ path, hash, size: size2 }) => ({ path, hash, size: size2 }));
|
|
998
|
+
const { missing } = await edge(`/api/canvases/${remoteId}/uploads`, { method: "POST", body: JSON.stringify({ files: manifest, poster }) });
|
|
999
|
+
const wanted = new Set(missing);
|
|
1000
|
+
const queue = files.filter((file2, index) => wanted.has(file2.hash) && files.findIndex((other) => other.hash === file2.hash) === index);
|
|
1001
|
+
const put = async (hash, bytes) => {
|
|
1002
|
+
await edge(`/api/canvases/${remoteId}/blobs/${hash}`, { method: "PUT", body: bytes });
|
|
1003
|
+
};
|
|
1004
|
+
let next = 0;
|
|
1005
|
+
await Promise.all(Array.from({ length: 6 }, async () => {
|
|
1006
|
+
while (next < queue.length) {
|
|
1007
|
+
const file2 = queue[next++];
|
|
1008
|
+
await put(file2.hash, await readFile5(file2.absolute));
|
|
1009
|
+
}
|
|
1010
|
+
}));
|
|
1011
|
+
if (poster && posterBytes && wanted.has(poster)) await put(poster, posterBytes);
|
|
1012
|
+
onFile?.();
|
|
1013
|
+
const { id, title, x, y, w, h, z, entry, level: level2 } = frame;
|
|
1014
|
+
const result = await edge(`/api/canvases/${remoteId}/frames`, { method: "POST", body: JSON.stringify({ id, title, x, y, w, h, z, entry, level: level2, files: manifest, poster }) });
|
|
1015
|
+
return result.frame;
|
|
1016
|
+
}
|
|
1017
|
+
async share() {
|
|
1018
|
+
if (this.data.remote) return this.data.remote.url;
|
|
1019
|
+
if (!credential) throw new Failure(401, "Sign in first");
|
|
1020
|
+
const created = await edge("/api/canvases", { method: "POST", body: JSON.stringify({ name: this.data.name }) });
|
|
1021
|
+
const total = this.data.frames.length;
|
|
1022
|
+
let done = 0;
|
|
1023
|
+
this.event({ t: "share-progress", done, total });
|
|
1024
|
+
try {
|
|
1025
|
+
for (const frame of this.data.frames) {
|
|
1026
|
+
await this.uploadFrame(created.id, frame, () => {
|
|
1027
|
+
this.event({ t: "share-progress", done: ++done, total });
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
const strokes = this.data.strokes ?? [];
|
|
1031
|
+
for (let i = 0; i < strokes.length; i += STROKE_UPLOAD) {
|
|
1032
|
+
await edge(`/api/canvases/${created.id}/strokes`, { method: "POST", body: JSON.stringify({ strokes: strokes.slice(i, i + STROKE_UPLOAD) }) });
|
|
1033
|
+
}
|
|
1034
|
+
} catch (error) {
|
|
1035
|
+
await edge(`/api/canvases/${created.id}`, { method: "DELETE" }).catch(() => {
|
|
1036
|
+
});
|
|
1037
|
+
throw error;
|
|
1038
|
+
}
|
|
1039
|
+
this.data.remote = { id: created.id, url: created.url };
|
|
1040
|
+
await this.saveNow();
|
|
1041
|
+
this.event({ t: "shared", url: created.url });
|
|
1042
|
+
return created.url;
|
|
1043
|
+
}
|
|
1044
|
+
async unshare() {
|
|
1045
|
+
if (!this.data.remote) return;
|
|
1046
|
+
await this.pull().catch(() => {
|
|
1047
|
+
});
|
|
1048
|
+
await edge(`/api/canvases/${this.data.remote.id}`, { method: "DELETE" }).catch((error) => {
|
|
1049
|
+
if (error.status !== 404) throw error;
|
|
1050
|
+
});
|
|
1051
|
+
this.data.remote = null;
|
|
1052
|
+
await this.saveNow();
|
|
1053
|
+
this.event({ t: "shared", url: null });
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
var canvases = /* @__PURE__ */ new Map();
|
|
1057
|
+
async function canvasById(id) {
|
|
1058
|
+
const open = canvases.get(id);
|
|
1059
|
+
if (open) return open;
|
|
1060
|
+
const project = (await readRegistry())[id];
|
|
1061
|
+
if (!project) throw new Failure(404, "That canvas is not on this machine");
|
|
1062
|
+
const data = await readCanvas(project).catch(() => null);
|
|
1063
|
+
if (!data || data.id !== id) throw new Failure(404, "That canvas is not on this machine");
|
|
1064
|
+
const canvas = new Canvas(project, data);
|
|
1065
|
+
canvases.set(id, canvas);
|
|
1066
|
+
return canvas;
|
|
1067
|
+
}
|
|
1068
|
+
async function canvasForProject(project) {
|
|
1069
|
+
const found = await findProject(project);
|
|
1070
|
+
if (!found) throw new Failure(404, "No canvas here yet. Run: twombly init");
|
|
1071
|
+
const { id } = await readCanvas(found);
|
|
1072
|
+
await register(id, found);
|
|
1073
|
+
return canvasById(id);
|
|
1074
|
+
}
|
|
1075
|
+
function openBrowser(url) {
|
|
1076
|
+
if (NO_OPEN) return;
|
|
1077
|
+
import("node:child_process").then(({ spawn: spawn3 }) => {
|
|
1078
|
+
const [command2, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
1079
|
+
spawn3(command2, args, { stdio: "ignore", detached: true }).unref();
|
|
1080
|
+
}).catch(() => {
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
var summary = (canvas) => ({
|
|
1084
|
+
id: canvas.id,
|
|
1085
|
+
name: canvas.data.name,
|
|
1086
|
+
local: `${DAEMON_ORIGIN}/${canvas.id}`,
|
|
1087
|
+
link: canvas.data.remote?.url || null,
|
|
1088
|
+
frames: canvas.data.frames.map(({ id, title, x, y, w, h, entry, level: level2 }) => ({ id, title, x, y, w, h, entry, level: level2 }))
|
|
1089
|
+
});
|
|
1090
|
+
function findFrame(canvas, ref) {
|
|
1091
|
+
const byId = canvas.data.frames.find((frame) => frame.id === ref);
|
|
1092
|
+
if (byId) return byId;
|
|
1093
|
+
const byTitle = canvas.data.frames.filter((frame) => frame.title === ref);
|
|
1094
|
+
if (byTitle.length === 1) return byTitle[0];
|
|
1095
|
+
throw new Failure(404, byTitle.length ? `More than one frame is titled "${ref}". Use its id.` : `No frame "${ref}". Run: twombly list`);
|
|
1096
|
+
}
|
|
1097
|
+
async function command(name, input) {
|
|
1098
|
+
touch();
|
|
1099
|
+
if (name === "stop") {
|
|
1100
|
+
setTimeout(() => process.exit(0), 50);
|
|
1101
|
+
return { stopped: true };
|
|
1102
|
+
}
|
|
1103
|
+
if (name === "status") {
|
|
1104
|
+
let canvas2 = null;
|
|
1105
|
+
if (input.project) canvas2 = await canvasForProject(input.project).catch(() => null);
|
|
1106
|
+
return { signedIn: !!credential, me: credential?.me || null, endpoint: ENDPOINT, canvas: canvas2 && summary(canvas2) };
|
|
1107
|
+
}
|
|
1108
|
+
if (name === "login") {
|
|
1109
|
+
if (credential) return { signedIn: true, me: credential.me };
|
|
1110
|
+
const url = loginUrl();
|
|
1111
|
+
openBrowser(url);
|
|
1112
|
+
return { signedIn: false, url };
|
|
1113
|
+
}
|
|
1114
|
+
if (name === "logout") {
|
|
1115
|
+
if (credential) await fetch(`${ENDPOINT}/auth/signout`, { method: "POST", headers: { authorization: `Bearer ${credential.token}` } }).catch(() => {
|
|
1116
|
+
});
|
|
1117
|
+
await clearCredential();
|
|
1118
|
+
setCredential(null);
|
|
1119
|
+
return { signedIn: false };
|
|
1120
|
+
}
|
|
1121
|
+
if (name === "init") {
|
|
1122
|
+
const project = input.project;
|
|
1123
|
+
let found = await findProject(project);
|
|
1124
|
+
if (!found) {
|
|
1125
|
+
await ensureProjectDir(project);
|
|
1126
|
+
await writeCanvas(project, { version: 1, id: randomId(12), name: String(input.name || "Untitled").slice(0, LIMITS.nameChars), frames: [], remote: null });
|
|
1127
|
+
found = project;
|
|
1128
|
+
}
|
|
1129
|
+
const canvas2 = await canvasForProject(found);
|
|
1130
|
+
if (input.rename && input.name && input.name !== canvas2.data.name) await rename4(canvas2, input.name);
|
|
1131
|
+
if (input.open !== false && !canvas2.watchers) openBrowser(`${DAEMON_ORIGIN}/${canvas2.id}`);
|
|
1132
|
+
canvas2.working(WORKING_MS);
|
|
1133
|
+
return summary(canvas2);
|
|
1134
|
+
}
|
|
1135
|
+
const canvas = await canvasForProject(input.project);
|
|
1136
|
+
if (name === "working") return { working: canvas.working(Number(input.ms) || 0) > 0 };
|
|
1137
|
+
return canvas.run(async () => {
|
|
1138
|
+
if (canvas.data.remote) await canvas.pull();
|
|
1139
|
+
switch (name) {
|
|
1140
|
+
case "list":
|
|
1141
|
+
return summary(canvas);
|
|
1142
|
+
case "open":
|
|
1143
|
+
openBrowser(`${DAEMON_ORIGIN}/${canvas.id}`);
|
|
1144
|
+
return summary(canvas);
|
|
1145
|
+
case "preview": {
|
|
1146
|
+
const { id, title, w, h } = findFrame(canvas, input.frame);
|
|
1147
|
+
return { frame: { id, title, w, h }, url: `${DAEMON_ORIGIN}/preview/${canvas.id}/${id}` };
|
|
1148
|
+
}
|
|
1149
|
+
case "place":
|
|
1150
|
+
return place(canvas, input);
|
|
1151
|
+
case "revise":
|
|
1152
|
+
return revise(canvas, input);
|
|
1153
|
+
case "remove": {
|
|
1154
|
+
const frame = findFrame(canvas, input.frame);
|
|
1155
|
+
if (canvas.data.remote) await edge(`/api/canvases/${canvas.data.remote.id}/frames/${frame.id}`, { method: "DELETE" });
|
|
1156
|
+
canvas.data.frames = canvas.data.frames.filter((item) => item.id !== frame.id);
|
|
1157
|
+
canvas.broadcast({ t: "frame.remove", id: frame.id });
|
|
1158
|
+
await canvas.saveNow();
|
|
1159
|
+
return summary(canvas);
|
|
1160
|
+
}
|
|
1161
|
+
case "patch": {
|
|
1162
|
+
const frame = findFrame(canvas, input.frame);
|
|
1163
|
+
await patchFrame(canvas, frame.id, input.patch);
|
|
1164
|
+
return summary(canvas);
|
|
1165
|
+
}
|
|
1166
|
+
case "arrange": {
|
|
1167
|
+
const frames = [...canvas.data.frames].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
1168
|
+
const columns = Math.max(1, Number(input.columns) || Math.ceil(Math.sqrt(frames.length)));
|
|
1169
|
+
let y = 0;
|
|
1170
|
+
for (let row = 0; row < Math.ceil(frames.length / columns); row++) {
|
|
1171
|
+
const cells = frames.slice(row * columns, (row + 1) * columns);
|
|
1172
|
+
let x = 0;
|
|
1173
|
+
for (const frame of cells) {
|
|
1174
|
+
await patchFrame(canvas, frame.id, { x, y });
|
|
1175
|
+
x += frame.w + 80;
|
|
1176
|
+
}
|
|
1177
|
+
y += Math.max(...cells.map((frame) => frame.h)) + 120;
|
|
1178
|
+
}
|
|
1179
|
+
return summary(canvas);
|
|
1180
|
+
}
|
|
1181
|
+
case "rename":
|
|
1182
|
+
await rename4(canvas, input.name);
|
|
1183
|
+
return summary(canvas);
|
|
1184
|
+
case "share": {
|
|
1185
|
+
if (!credential) throw new Failure(401, "The person needs to sign in first. Ask them to click Share on the canvas.");
|
|
1186
|
+
const url = await canvas.share();
|
|
1187
|
+
return { ...summary(canvas), link: url };
|
|
1188
|
+
}
|
|
1189
|
+
case "unshare":
|
|
1190
|
+
await canvas.unshare();
|
|
1191
|
+
return summary(canvas);
|
|
1192
|
+
default:
|
|
1193
|
+
throw new Failure(400, `Unknown command ${name}`);
|
|
1194
|
+
}
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
async function rename4(canvas, name) {
|
|
1198
|
+
const clean = String(name || "").trim().slice(0, LIMITS.nameChars);
|
|
1199
|
+
if (!clean) throw new Failure(400, "Expected a name");
|
|
1200
|
+
if (canvas.data.remote) await edge(`/api/canvases/${canvas.data.remote.id}`, { method: "PATCH", body: JSON.stringify({ name: clean }) });
|
|
1201
|
+
canvas.data.name = clean;
|
|
1202
|
+
canvas.broadcast({ t: "canvas", name: clean });
|
|
1203
|
+
await canvas.saveNow();
|
|
1204
|
+
}
|
|
1205
|
+
async function patchFrame(canvas, id, patch) {
|
|
1206
|
+
if (canvas.data.remote) await edge(`/api/canvases/${canvas.data.remote.id}/frames/${id}`, { method: "PATCH", body: JSON.stringify(patch) });
|
|
1207
|
+
await canvas.applyPatch(id, patch);
|
|
1208
|
+
await canvas.saveNow();
|
|
1209
|
+
}
|
|
1210
|
+
async function place(canvas, input) {
|
|
1211
|
+
const position = nextPosition(canvas.data.frames, !!input.newRow);
|
|
1212
|
+
const frame = {
|
|
1213
|
+
id: input.id,
|
|
1214
|
+
title: String(input.title).slice(0, LIMITS.titleChars),
|
|
1215
|
+
w: input.w,
|
|
1216
|
+
h: input.h,
|
|
1217
|
+
x: typeof input.x === "number" ? input.x : position.x,
|
|
1218
|
+
y: typeof input.y === "number" ? input.y : position.y,
|
|
1219
|
+
z: canvas.data.frames.length,
|
|
1220
|
+
entry: input.entry,
|
|
1221
|
+
content: input.content,
|
|
1222
|
+
level: input.level,
|
|
1223
|
+
hasPoster: !!input.hasPoster,
|
|
1224
|
+
updatedAt: Date.now()
|
|
1225
|
+
};
|
|
1226
|
+
if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, frame);
|
|
1227
|
+
canvas.data.frames.push(frame);
|
|
1228
|
+
await canvas.saveNow();
|
|
1229
|
+
canvas.broadcast({ t: "frame.upsert", frame: await canvas.toFrame(frame) });
|
|
1230
|
+
canvas.working(WORKING_TAIL_MS);
|
|
1231
|
+
return { frame: { id: frame.id, title: frame.title, x: frame.x, y: frame.y, w: frame.w, h: frame.h }, canvas: { local: `${DAEMON_ORIGIN}/${canvas.id}`, link: canvas.data.remote?.url || null, frames: canvas.data.frames.length } };
|
|
1232
|
+
}
|
|
1233
|
+
async function revise(canvas, input) {
|
|
1234
|
+
const frame = canvas.data.frames.find((item) => item.id === input.id);
|
|
1235
|
+
if (!frame) throw new Failure(404, "That frame is gone");
|
|
1236
|
+
const next = { ...frame, updatedAt: Date.now() };
|
|
1237
|
+
if (input.title) next.title = String(input.title).slice(0, LIMITS.titleChars);
|
|
1238
|
+
for (const key of ["w", "h"]) if (typeof input[key] === "number") next[key] = input[key];
|
|
1239
|
+
if (input.entry) next.entry = input.entry;
|
|
1240
|
+
if (input.level) next.level = input.level;
|
|
1241
|
+
if (input.content) next.content = input.content;
|
|
1242
|
+
if (typeof input.hasPoster === "boolean") next.hasPoster = input.hasPoster;
|
|
1243
|
+
if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, next);
|
|
1244
|
+
Object.assign(frame, next);
|
|
1245
|
+
await canvas.saveNow();
|
|
1246
|
+
canvas.broadcast({ t: "frame.upsert", frame: await canvas.toFrame(frame) });
|
|
1247
|
+
canvas.working(WORKING_TAIL_MS);
|
|
1248
|
+
return { frame: { id: frame.id, title: frame.title, x: frame.x, y: frame.y, w: frame.w, h: frame.h }, canvas: { local: `${DAEMON_ORIGIN}/${canvas.id}`, link: canvas.data.remote?.url || null, frames: canvas.data.frames.length } };
|
|
1249
|
+
}
|
|
1250
|
+
var JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
|
|
1251
|
+
var reply = (response, status, value) => {
|
|
1252
|
+
response.writeHead(status, JSON_HEADERS);
|
|
1253
|
+
response.end(JSON.stringify(value));
|
|
1254
|
+
};
|
|
1255
|
+
var readBody = (request) => new Promise((resolve3, reject) => {
|
|
1256
|
+
let body = "";
|
|
1257
|
+
request.on("data", (chunk) => {
|
|
1258
|
+
body += chunk;
|
|
1259
|
+
if (body.length > 4e6) {
|
|
1260
|
+
reject(new Failure(413, "Too large"));
|
|
1261
|
+
request.destroy();
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
request.on("end", () => {
|
|
1265
|
+
try {
|
|
1266
|
+
resolve3(body ? JSON.parse(body) : {});
|
|
1267
|
+
} catch {
|
|
1268
|
+
reject(new Failure(400, "Expected JSON"));
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
request.on("error", reject);
|
|
1272
|
+
});
|
|
1273
|
+
var ALLOWED_HOSTS = /* @__PURE__ */ new Set([`localhost:${PORT}`, `127.0.0.1:${PORT}`]);
|
|
1274
|
+
var ALLOWED_ORIGINS = /* @__PURE__ */ new Set([`http://localhost:${PORT}`, `http://127.0.0.1:${PORT}`]);
|
|
1275
|
+
function trusted(request) {
|
|
1276
|
+
if (!ALLOWED_HOSTS.has(String(request.headers.host))) return false;
|
|
1277
|
+
const origin = request.headers.origin;
|
|
1278
|
+
return !origin || ALLOWED_ORIGINS.has(origin);
|
|
1279
|
+
}
|
|
1280
|
+
var APP_CSP = [
|
|
1281
|
+
"default-src 'self'",
|
|
1282
|
+
"script-src 'self'",
|
|
1283
|
+
"style-src 'self' 'unsafe-inline'",
|
|
1284
|
+
"img-src 'self' data: http: https:",
|
|
1285
|
+
"font-src 'self'",
|
|
1286
|
+
"connect-src 'self' ws: wss:",
|
|
1287
|
+
"frame-src http: https:",
|
|
1288
|
+
"media-src 'self' blob:",
|
|
1289
|
+
"object-src 'none'",
|
|
1290
|
+
"base-uri 'self'",
|
|
1291
|
+
"frame-ancestors 'none'"
|
|
1292
|
+
].join("; ");
|
|
1293
|
+
async function serveUi(response, pathname) {
|
|
1294
|
+
const clean = pathname.replace(/^\/+/, "");
|
|
1295
|
+
let file2 = null;
|
|
1296
|
+
if (clean && !clean.includes("..")) {
|
|
1297
|
+
for (const folder of interfaceFolders()) {
|
|
1298
|
+
const candidate = join8(folder, clean);
|
|
1299
|
+
if ((await stat3(candidate).catch(() => null))?.isFile()) {
|
|
1300
|
+
file2 = candidate;
|
|
1301
|
+
break;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
if (!file2) {
|
|
1306
|
+
await freshInterface();
|
|
1307
|
+
file2 = join8(interfaceFolders()[0], "index.html");
|
|
1308
|
+
}
|
|
1309
|
+
const type = contentType(file2);
|
|
1310
|
+
const headers = { "content-type": type, "x-content-type-options": "nosniff", "cache-control": extname2(file2) === ".html" ? "no-cache" : "public, max-age=3600" };
|
|
1311
|
+
if (type.startsWith("text/html")) {
|
|
1312
|
+
headers["content-security-policy"] = APP_CSP;
|
|
1313
|
+
headers["permissions-policy"] = "microphone=(self)";
|
|
1314
|
+
}
|
|
1315
|
+
response.writeHead(200, headers);
|
|
1316
|
+
createReadStream2(file2).pipe(response);
|
|
1317
|
+
}
|
|
1318
|
+
var escapeHtml = (text2) => text2.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
1319
|
+
async function servePreview(response, canvasId, frameId) {
|
|
1320
|
+
const canvas = await canvasById(canvasId);
|
|
1321
|
+
const frame = await canvas.toFrame(findFrame(canvas, frameId));
|
|
1322
|
+
response.writeHead(200, {
|
|
1323
|
+
"content-type": "text/html; charset=utf-8",
|
|
1324
|
+
"cache-control": "no-store",
|
|
1325
|
+
"referrer-policy": "no-referrer",
|
|
1326
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-src http://localhost:*; base-uri 'none'; frame-ancestors 'none'"
|
|
1327
|
+
});
|
|
1328
|
+
const title = escapeHtml(frame.title);
|
|
1329
|
+
response.end(`<!doctype html><meta charset="utf-8"><title>${title}</title><style>html,body{margin:0;background:#fff}iframe{display:block;border:0;width:${frame.w}px;height:${frame.h}px}</style><iframe src="${escapeHtml(frame.url)}" title="${title}" sandbox="${FRAME_SANDBOX}" referrerpolicy="no-referrer" allow=""></iframe>`);
|
|
1330
|
+
}
|
|
1331
|
+
async function route(request, response) {
|
|
1332
|
+
const url = new URL(request.url || "/", DAEMON_ORIGIN);
|
|
1333
|
+
const path = url.pathname, method = request.method || "GET";
|
|
1334
|
+
if (path === "/health") return reply(response, 200, { ok: true, build: "0.2.0+1793896544071", pid: process.pid });
|
|
1335
|
+
if (path.startsWith("/cli/") && method === "POST") return reply(response, 200, await command(path.slice(5), await readBody(request)));
|
|
1336
|
+
if (path === "/auth/callback" && method === "GET") {
|
|
1337
|
+
const code = url.searchParams.get("code"), state = url.searchParams.get("state");
|
|
1338
|
+
const page = (text2, status = 200) => {
|
|
1339
|
+
response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'" });
|
|
1340
|
+
response.end(`<!doctype html><meta charset="utf-8"><title>Twombly</title><style>body{font:13px/1.5 -apple-system,system-ui,sans-serif;display:grid;place-items:center;height:100vh;margin:0}@media(prefers-color-scheme:dark){body{background:#1e1e1e;color:#fff}}</style><p>${text2}</p><script>setTimeout(()=>window.close(),600)</script>`);
|
|
1341
|
+
};
|
|
1342
|
+
if (!code || !pendingLogin || state !== pendingLogin.state) return page("That sign-in expired. Try again.", 400);
|
|
1343
|
+
const exchange = await fetch(`${ENDPOINT}/auth/machine/exchange`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code, verifier: pendingLogin.verifier }) });
|
|
1344
|
+
const data = await exchange.json().catch(() => ({}));
|
|
1345
|
+
pendingLogin = null;
|
|
1346
|
+
if (!exchange.ok || !data.token || !data.me) return page("That sign-in expired. Try again.", 400);
|
|
1347
|
+
await writeCredential({ token: data.token, me: data.me });
|
|
1348
|
+
setCredential({ token: data.token, me: data.me });
|
|
1349
|
+
return page("Signed in. You can close this tab.");
|
|
1350
|
+
}
|
|
1351
|
+
if (path === "/api/bootstrap" && method === "GET") {
|
|
1352
|
+
const id = url.searchParams.get("canvas") || "";
|
|
1353
|
+
const canvas = id ? await canvasById(id).catch(() => null) : null;
|
|
1354
|
+
if (canvas?.data.remote) await canvas.pull().catch(() => {
|
|
1355
|
+
});
|
|
1356
|
+
const body = {
|
|
1357
|
+
mode: "local",
|
|
1358
|
+
me: credential?.me || null,
|
|
1359
|
+
canvas: canvas && { id: canvas.id, name: canvas.data.name, url: canvas.data.remote?.url || null, owner: true },
|
|
1360
|
+
settings
|
|
1361
|
+
};
|
|
1362
|
+
return reply(response, 200, body);
|
|
1363
|
+
}
|
|
1364
|
+
if (path === "/api/login" && method === "POST") return reply(response, 200, await command("login", {}));
|
|
1365
|
+
if (path === "/api/settings" && method === "POST") {
|
|
1366
|
+
const { invertScroll } = await readBody(request);
|
|
1367
|
+
if (typeof invertScroll === "boolean") settings = { ...settings, invertScroll };
|
|
1368
|
+
await writeSettings(settings);
|
|
1369
|
+
for (const canvas of canvases.values()) canvas.event({ t: "settings", settings });
|
|
1370
|
+
return reply(response, 200, settings);
|
|
1371
|
+
}
|
|
1372
|
+
const match = path.match(/^\/api\/canvases\/([a-z0-9]+)\/(ticket|share|unshare)$/);
|
|
1373
|
+
if (match && method === "POST") {
|
|
1374
|
+
const canvas = await canvasById(match[1]);
|
|
1375
|
+
if (match[2] === "ticket") {
|
|
1376
|
+
if (!canvas.data.remote || !credential) return reply(response, 200, { url: `ws://localhost:${PORT}/local/${canvas.id}` });
|
|
1377
|
+
return reply(response, 200, await edge(`/api/canvases/${canvas.data.remote.id}/ticket`, { method: "POST", body: "{}" }));
|
|
1378
|
+
}
|
|
1379
|
+
if (match[2] === "share") return reply(response, 200, { url: await canvas.run(() => canvas.share()) });
|
|
1380
|
+
await canvas.run(() => canvas.unshare());
|
|
1381
|
+
return reply(response, 200, { ok: true });
|
|
1382
|
+
}
|
|
1383
|
+
if (path.startsWith("/api/")) return reply(response, 404, { error: "Not found" });
|
|
1384
|
+
if (method !== "GET" && method !== "HEAD") return reply(response, 405, { error: "Method not allowed" });
|
|
1385
|
+
const preview = path.match(/^\/preview\/([a-z0-9]+)\/([a-z0-9]+)$/);
|
|
1386
|
+
if (preview) return servePreview(response, preview[1], preview[2]);
|
|
1387
|
+
return serveUi(response, path);
|
|
1388
|
+
}
|
|
1389
|
+
var idleTimer = null;
|
|
1390
|
+
function touch() {
|
|
1391
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1392
|
+
idleTimer = setTimeout(() => {
|
|
1393
|
+
if ([...canvases.values()].some((canvas) => canvas.watchers)) return touch();
|
|
1394
|
+
process.exit(0);
|
|
1395
|
+
}, IDLE_MS);
|
|
1396
|
+
}
|
|
1397
|
+
async function runDaemon() {
|
|
1398
|
+
credential = await readCredential();
|
|
1399
|
+
settings = await readSettings();
|
|
1400
|
+
const server = createServer2(async (request, response) => {
|
|
1401
|
+
if (!trusted(request)) {
|
|
1402
|
+
response.writeHead(403, JSON_HEADERS);
|
|
1403
|
+
response.end('{"error":"Forbidden"}');
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
try {
|
|
1407
|
+
await route(request, response);
|
|
1408
|
+
} catch (error) {
|
|
1409
|
+
if (response.headersSent) {
|
|
1410
|
+
response.destroy();
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
if (error instanceof Failure) return reply(response, error.status, { error: error.message, ...error.extra });
|
|
1414
|
+
console.error(error);
|
|
1415
|
+
reply(response, 500, { error: error instanceof Error ? error.message : "Something went wrong" });
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
const sockets = new WebSocketServer({ noServer: true });
|
|
1419
|
+
server.on("upgrade", async (request, socket, head) => {
|
|
1420
|
+
const match = (request.url || "").match(/^\/local\/([a-z0-9]+)(\?events=1)?$/);
|
|
1421
|
+
if (!match || !trusted(request) || !request.headers.origin) {
|
|
1422
|
+
socket.destroy();
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
const canvas = await canvasById(match[1]).catch(() => null);
|
|
1426
|
+
if (!canvas) {
|
|
1427
|
+
socket.destroy();
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
sockets.handleUpgrade(request, socket, head, (client) => {
|
|
1431
|
+
if (match[2]) canvas.attachEvents(client);
|
|
1432
|
+
else canvas.attach(client).catch(() => client.close());
|
|
1433
|
+
});
|
|
1434
|
+
});
|
|
1435
|
+
await new Promise((resolve3, reject) => {
|
|
1436
|
+
server.once("error", reject);
|
|
1437
|
+
server.listen(PORT, "127.0.0.1", resolve3);
|
|
1438
|
+
});
|
|
1439
|
+
touch();
|
|
1440
|
+
startInterfaceUpdates().catch(() => {
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// src/gates.ts
|
|
1445
|
+
import { createServer as createServer3 } from "node:http";
|
|
1446
|
+
import { mkdir as mkdir6, readFile as readFile6, stat as stat4, writeFile as writeFile6 } from "node:fs/promises";
|
|
1447
|
+
import { dirname as dirname3 } from "node:path";
|
|
1448
|
+
var dedupe = (problems) => [...new Map(problems.map((p) => [`${p.code}:${p.file || ""}:${p.message}`, p])).values()];
|
|
1449
|
+
async function scanFolder(folder) {
|
|
1450
|
+
const problems = [];
|
|
1451
|
+
const info = await stat4(folder).catch(() => null);
|
|
1452
|
+
if (!info?.isDirectory()) return { problems: [{ code: "NOT_A_FOLDER", message: `"${folder}" is not a folder.`, fix: "Point at a folder of built static files." }], files: [], bytes: 0 };
|
|
1453
|
+
const files = await hashFolder(folder);
|
|
1454
|
+
let bytes = 0;
|
|
1455
|
+
for (const file2 of files) {
|
|
1456
|
+
bytes += file2.size;
|
|
1457
|
+
const problem = checkPath(file2.path);
|
|
1458
|
+
if (problem) {
|
|
1459
|
+
problems.push(problem);
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
if (file2.size > LIMITS.fileBytes) problems.push({ code: "FILE_TOO_LARGE", message: `"${file2.path}" is ${(file2.size / 1048576).toFixed(1)} MB; the limit is ${LIMITS.fileBytes / 1048576} MB.`, fix: "Compress it, or swap in a smaller asset.", file: file2.path });
|
|
1463
|
+
problems.push(...scanBytes(file2.path, await readFile6(file2.absolute)));
|
|
1464
|
+
}
|
|
1465
|
+
if (files.length > LIMITS.frameFiles) problems.push({ code: "TOO_MANY_FILES", message: `${files.length} files; the limit is ${LIMITS.frameFiles}.`, fix: "This looks like more than one built page. Add only what the frame needs." });
|
|
1466
|
+
if (bytes > LIMITS.frameBytes) {
|
|
1467
|
+
const largest = [...files].sort((a, b) => b.size - a.size).slice(0, 5).map((file2) => `${file2.path} (${(file2.size / 1048576).toFixed(1)} MB)`).join(", ");
|
|
1468
|
+
problems.push({ code: "FRAME_TOO_LARGE", message: `${(bytes / 1048576).toFixed(1)} MB in total; the limit is ${LIMITS.frameBytes / 1048576} MB. Largest: ${largest}.`, fix: "Remove what this design doesn't show, and compress the rest." });
|
|
1469
|
+
}
|
|
1470
|
+
return { problems, files, bytes };
|
|
1471
|
+
}
|
|
1472
|
+
function entryProblems(files, entry) {
|
|
1473
|
+
const entryPath = entry.split(/[?#]/)[0];
|
|
1474
|
+
const candidates = candidatePaths("/" + entryPath);
|
|
1475
|
+
if (files.some((file2) => candidates.includes(file2.path))) return [];
|
|
1476
|
+
return [{ code: "NO_ENTRY", message: `"${entryPath}" is not in the folder.`, fix: "Build to a folder with an index.html at its top level, or pass --entry." }];
|
|
1477
|
+
}
|
|
1478
|
+
async function findReferences(folder, needle) {
|
|
1479
|
+
const found = [];
|
|
1480
|
+
for (const file2 of await hashFolder(folder)) {
|
|
1481
|
+
const bytes = await readFile6(file2.absolute);
|
|
1482
|
+
if (looksLikeText(bytes) && bytes.includes(needle)) found.push(file2.path);
|
|
1483
|
+
if (found.length === 3) break;
|
|
1484
|
+
}
|
|
1485
|
+
return found;
|
|
1486
|
+
}
|
|
1487
|
+
async function browserGates(folder, options, launch) {
|
|
1488
|
+
const problems = [], warnings = [];
|
|
1489
|
+
const traces = /* @__PURE__ */ new Map();
|
|
1490
|
+
const server = await startFrameServer(folder);
|
|
1491
|
+
const width = options.width, height = options.height === "auto" ? 800 : options.height;
|
|
1492
|
+
const harness = createServer3((_, response) => {
|
|
1493
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
1494
|
+
response.end(`<!doctype html><style>html,body{margin:0;background:#fff}iframe{display:block;border:0;width:${width}px;height:${height}px}</style><iframe id="frame" sandbox="${FRAME_SANDBOX}" src="${server.origin}/${options.entry}"></iframe>`);
|
|
1495
|
+
});
|
|
1496
|
+
await new Promise((resolve3) => harness.listen(0, "127.0.0.1", resolve3));
|
|
1497
|
+
const harnessOrigin = `http://localhost:${harness.address().port}`;
|
|
1498
|
+
let browser;
|
|
1499
|
+
try {
|
|
1500
|
+
browser = await launch();
|
|
1501
|
+
} catch {
|
|
1502
|
+
harness.close();
|
|
1503
|
+
server.close();
|
|
1504
|
+
return { ok: false, warnings, problems: [{ code: "NO_BROWSER", message: "No browser available to check the frame.", fix: "Install Google Chrome, or run: npx playwright-core install chromium" }] };
|
|
1505
|
+
}
|
|
1506
|
+
try {
|
|
1507
|
+
const context = await browser.newContext({ viewport: { width: Math.max(width, 400), height: Math.max(height, 300) }, deviceScaleFactor: 2 });
|
|
1508
|
+
const page = await context.newPage();
|
|
1509
|
+
const isFrameOrigin = (url) => url.startsWith(server.origin + "/") || url === server.origin;
|
|
1510
|
+
const external = (url) => {
|
|
1511
|
+
let host = url;
|
|
1512
|
+
try {
|
|
1513
|
+
host = new URL(url).host;
|
|
1514
|
+
} catch {
|
|
1515
|
+
}
|
|
1516
|
+
const problem = { code: "EXTERNAL_REQUEST", message: `The frame tried to reach ${host} (${url.slice(0, 160)}).`, fix: /google-analytics|googletagmanager|clarity\.ms|segment\.|sentry|hotjar|posthog|plausible|intercom|mixpanel|amplitude/i.test(url) ? "That is analytics or a third-party widget, which a frame doesn't need. Delete the tag or skip loading it in this build." : /fonts\.(googleapis|gstatic)|typekit|fontshare|fonts\.bunny/i.test(url) ? "Download the font files into the folder and load them with @font-face and a local path." : "Frames can only use their own files. For an asset, copy it into the folder and use a relative path. For an API call, replace it with mock data. For a tag or widget in the page template, delete it." };
|
|
1517
|
+
problems.push(problem);
|
|
1518
|
+
traces.set(problem, host);
|
|
1519
|
+
};
|
|
1520
|
+
page.on("pageerror", (error) => {
|
|
1521
|
+
problems.push({ code: "UNCAUGHT_ERROR", message: `The page threw: ${String(error.message).slice(0, 400)}`, fix: "Usually a missing provider, env value or API response, or a router reading the file path as a route. Mount the design with mock props and data, and use a memory or hash router." });
|
|
1522
|
+
});
|
|
1523
|
+
page.on("console", (message) => {
|
|
1524
|
+
if (message.type() !== "error") return;
|
|
1525
|
+
const text2 = message.text();
|
|
1526
|
+
if (/Content Security Policy/i.test(text2)) {
|
|
1527
|
+
const blocked = text2.match(/['"`](https?:\/\/[^'"`\s]+)['"`]/);
|
|
1528
|
+
if (blocked && !isFrameOrigin(blocked[1])) return external(blocked[1]);
|
|
1529
|
+
if (!blocked) warnings.push(`Blocked by the frame's rules: ${text2.slice(0, 300)}`);
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
if (/Failed to load resource/.test(text2)) return;
|
|
1533
|
+
warnings.push(`Console error: ${text2.slice(0, 300)}`);
|
|
1534
|
+
});
|
|
1535
|
+
page.on("response", (response) => {
|
|
1536
|
+
const url = response.url();
|
|
1537
|
+
if (!isFrameOrigin(url) || response.status() < 400 || url.endsWith("/favicon.ico")) return;
|
|
1538
|
+
const path = new URL(url).pathname;
|
|
1539
|
+
const problem = { code: "MISSING_FILE", message: `The frame asked for ${path} and it isn't in the folder (${response.status()}).`, fix: /^\/(api|graphql|trpc|auth)(\/|$)/.test(path) ? "That is a server route, and frames have no server. Replace the call with mock data." : "Copy the file into the folder, or remove the reference. If it is an API call, replace it with mock data.", file: path.slice(1) };
|
|
1540
|
+
problems.push(problem);
|
|
1541
|
+
traces.set(problem, path.slice(1));
|
|
1542
|
+
});
|
|
1543
|
+
page.on("requestfailed", (request) => {
|
|
1544
|
+
const url = request.url();
|
|
1545
|
+
if (url.startsWith("data:") || url.startsWith("blob:") || url.startsWith(harnessOrigin)) return;
|
|
1546
|
+
if (!isFrameOrigin(url)) return external(url);
|
|
1547
|
+
if (request.failure()?.errorText !== "net::ERR_ABORTED") problems.push({ code: "REQUEST_FAILED", message: `Loading ${new URL(url).pathname} failed: ${request.failure()?.errorText}.`, fix: "Check the file exists and its path is right." });
|
|
1548
|
+
});
|
|
1549
|
+
await page.goto(harnessOrigin, { waitUntil: "load", timeout: 3e4 }).catch((error) => {
|
|
1550
|
+
problems.push({ code: "LOAD_TIMEOUT", message: `The frame took too long to load: ${String(error.message).slice(0, 200)}`, fix: "Something is waiting on a server. Replace it with mock data." });
|
|
1551
|
+
});
|
|
1552
|
+
await page.waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
|
|
1553
|
+
});
|
|
1554
|
+
await page.waitForTimeout(800);
|
|
1555
|
+
const frame = page.frames().find((item) => isFrameOrigin(item.url()));
|
|
1556
|
+
let content;
|
|
1557
|
+
if (frame) {
|
|
1558
|
+
content = await frame.evaluate(() => ({
|
|
1559
|
+
width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth || 0),
|
|
1560
|
+
height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0)
|
|
1561
|
+
})).catch(() => void 0);
|
|
1562
|
+
} else {
|
|
1563
|
+
problems.push({ code: "FRAME_DID_NOT_LOAD", message: "The frame page did not load.", fix: "Check the entry file." });
|
|
1564
|
+
}
|
|
1565
|
+
const finalHeight = options.height === "auto" ? Math.max(120, Math.min(8e3, content?.height || 800)) : height;
|
|
1566
|
+
if (options.height === "auto") await page.evaluate((h) => {
|
|
1567
|
+
document.getElementById("frame").style.height = `${h}px`;
|
|
1568
|
+
}, finalHeight);
|
|
1569
|
+
if (options.height === "auto") await page.setViewportSize({ width: Math.max(width, 400), height: Math.min(4e3, finalHeight) });
|
|
1570
|
+
await page.waitForTimeout(150);
|
|
1571
|
+
const shot = await page.locator("#frame").screenshot({ type: "png", timeout: 15e3 }).catch(() => null);
|
|
1572
|
+
if (shot) {
|
|
1573
|
+
if (options.poster) {
|
|
1574
|
+
await mkdir6(dirname3(options.poster), { recursive: true });
|
|
1575
|
+
await writeFile6(options.poster, shot);
|
|
1576
|
+
}
|
|
1577
|
+
const colors = await page.evaluate(async (base64) => {
|
|
1578
|
+
const image = new Image();
|
|
1579
|
+
image.src = `data:image/png;base64,${base64}`;
|
|
1580
|
+
await image.decode();
|
|
1581
|
+
const canvas = document.createElement("canvas");
|
|
1582
|
+
canvas.width = 96;
|
|
1583
|
+
canvas.height = 96;
|
|
1584
|
+
const paint = canvas.getContext("2d");
|
|
1585
|
+
paint.drawImage(image, 0, 0, 96, 96);
|
|
1586
|
+
const data = paint.getImageData(0, 0, 96, 96).data, seen = /* @__PURE__ */ new Set();
|
|
1587
|
+
for (let i = 0; i < data.length; i += 4) seen.add(data[i] >> 3 << 10 | data[i + 1] >> 3 << 5 | data[i + 2] >> 3);
|
|
1588
|
+
return seen.size;
|
|
1589
|
+
}, shot.toString("base64")).catch(() => 2);
|
|
1590
|
+
if (colors < 2) problems.push({ code: "BLANK_RENDER", message: "The frame rendered as a single flat color.", fix: "It probably needs data, a provider or a route it does not have. Open it, find what is missing and mock it. Check the screenshot." });
|
|
1591
|
+
}
|
|
1592
|
+
if (content && options.height !== "auto" && content.height > height * 1.02) warnings.push(`The page is ${content.height}px tall and the frame is ${height}px; it will scroll. Pass --height auto to show it all.`);
|
|
1593
|
+
if (content && content.width > width * 1.02) warnings.push(`The page is ${content.width}px wide and the frame is ${width}px; it overflows sideways.`);
|
|
1594
|
+
await context.close();
|
|
1595
|
+
const unique = dedupe(problems);
|
|
1596
|
+
for (const problem of unique) {
|
|
1597
|
+
const trace = traces.get(problem);
|
|
1598
|
+
const references = trace ? await findReferences(folder, trace) : [];
|
|
1599
|
+
if (references.length) problem.message += ` Mentioned in ${references.join(", ")}.`;
|
|
1600
|
+
}
|
|
1601
|
+
return { ok: unique.length === 0, problems: unique, warnings: [...new Set(warnings)], content, height: finalHeight, screenshot: options.poster };
|
|
1602
|
+
} finally {
|
|
1603
|
+
harness.closeAllConnections();
|
|
1604
|
+
harness.close();
|
|
1605
|
+
server.close();
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
function gateSession() {
|
|
1609
|
+
const scans = /* @__PURE__ */ new Map();
|
|
1610
|
+
let browser = null;
|
|
1611
|
+
const launch = () => browser ??= launchBrowser();
|
|
1612
|
+
return {
|
|
1613
|
+
async run(folder, options) {
|
|
1614
|
+
if (!scans.has(folder)) scans.set(folder, scanFolder(folder));
|
|
1615
|
+
const scan = await scans.get(folder);
|
|
1616
|
+
const size2 = { files: scan.files.length, bytes: scan.bytes };
|
|
1617
|
+
const notFolder = scan.problems.some((problem) => problem.code === "NOT_A_FOLDER");
|
|
1618
|
+
const problems = notFolder ? scan.problems : [...scan.problems, ...entryProblems(scan.files, options.entry)];
|
|
1619
|
+
if (problems.length) return { ok: false, problems: dedupe(problems), warnings: [], ...size2 };
|
|
1620
|
+
return { ...await browserGates(folder, options, launch), ...size2 };
|
|
1621
|
+
},
|
|
1622
|
+
close: async () => {
|
|
1623
|
+
await browser?.then((open) => open.close()).catch(() => {
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
async function runGates(folder, options) {
|
|
1629
|
+
const session = gateSession();
|
|
1630
|
+
try {
|
|
1631
|
+
return await session.run(folder, options);
|
|
1632
|
+
} finally {
|
|
1633
|
+
await session.close();
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// src/cli.ts
|
|
1638
|
+
var HELP = `twombly ${"0.2.0"}: put design explorations on a shared canvas.
|
|
1639
|
+
|
|
1640
|
+
init [--name "Name"] [--no-open] create or reopen this project's canvas
|
|
1641
|
+
add <folder|file> --title "T" run the gates, then place a frame
|
|
1642
|
+
[--width 1280] [--height 800|auto] [--entry index.html] [--level build|component|capture|image]
|
|
1643
|
+
[--new-row] [--x N --y N]
|
|
1644
|
+
[--frames '[{"title":"A","entry":"index.html?v=a"}]'] several at once, as JSON or a JSON file
|
|
1645
|
+
update <frame> <folder|file> replace a frame's contents, keeping its comments
|
|
1646
|
+
[--title "T"] [--width N] [--height N|auto] [--entry path] [--level ...]
|
|
1647
|
+
update <folder> --frames '[{"frame":"A"},{"frame":"B"}]' several at once
|
|
1648
|
+
check <folder> run the gates without placing anything
|
|
1649
|
+
capture <url> --out <folder> freeze a running page into a static folder
|
|
1650
|
+
[--width 1280] [--height 800] [--image] [--selector css]
|
|
1651
|
+
preview <frame> a page with just that frame, for testing interactions
|
|
1652
|
+
working | done show on the canvas that you're building, or that you've finished
|
|
1653
|
+
list | move <frame> --x N --y N | resize <frame> --width N --height N
|
|
1654
|
+
retitle <frame> "T" | remove <frame> | arrange [--columns N] | rename "Name"
|
|
1655
|
+
open | status | login | logout | stop
|
|
1656
|
+
share | unshare only when the person asks: this publishes a public link
|
|
1657
|
+
guide how to adapt a project into frames. Read this first.`;
|
|
1658
|
+
var SWITCHES = /* @__PURE__ */ new Set(["no-open", "new-row", "image"]);
|
|
1659
|
+
var FLAGS = {
|
|
1660
|
+
init: ["name", "no-open"],
|
|
1661
|
+
add: ["title", "width", "height", "entry", "level", "new-row", "x", "y", "frames"],
|
|
1662
|
+
update: ["title", "width", "height", "entry", "level", "frames"],
|
|
1663
|
+
check: ["entry", "width", "height"],
|
|
1664
|
+
capture: ["out", "width", "height", "image", "selector", "wait"],
|
|
1665
|
+
move: ["x", "y"],
|
|
1666
|
+
resize: ["width", "height"],
|
|
1667
|
+
arrange: ["columns"]
|
|
1668
|
+
};
|
|
1669
|
+
function parse(argv) {
|
|
1670
|
+
const positional = [], flags = {};
|
|
1671
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1672
|
+
const arg = argv[i];
|
|
1673
|
+
if (!arg.startsWith("--")) {
|
|
1674
|
+
positional.push(arg);
|
|
1675
|
+
continue;
|
|
1676
|
+
}
|
|
1677
|
+
const at = arg.indexOf("=");
|
|
1678
|
+
if (at > 0) {
|
|
1679
|
+
flags[arg.slice(2, at)] = arg.slice(at + 1);
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
const next = argv[i + 1];
|
|
1683
|
+
if (!SWITCHES.has(arg.slice(2)) && next !== void 0 && !next.startsWith("--")) {
|
|
1684
|
+
flags[arg.slice(2)] = next;
|
|
1685
|
+
i++;
|
|
1686
|
+
} else flags[arg.slice(2)] = true;
|
|
1687
|
+
}
|
|
1688
|
+
return { positional, flags };
|
|
1689
|
+
}
|
|
1690
|
+
function checkFlags(name, flags) {
|
|
1691
|
+
const known = FLAGS[name] || [];
|
|
1692
|
+
for (const flag of Object.keys(flags)) {
|
|
1693
|
+
if (!known.includes(flag)) fail(`Unknown flag --${flag}. ${known.length ? `"${name}" takes: ${known.map((item) => `--${item}`).join(", ")}.` : `"${name}" takes no flags.`}`);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
var print = (value) => process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
1697
|
+
function fail(message, extra = {}) {
|
|
1698
|
+
print({ ok: false, error: message, ...extra });
|
|
1699
|
+
process.exit(1);
|
|
1700
|
+
}
|
|
1701
|
+
function number(flags, name) {
|
|
1702
|
+
const value = flags[name];
|
|
1703
|
+
if (value === void 0) return void 0;
|
|
1704
|
+
const parsed = Number(value);
|
|
1705
|
+
if (!Number.isFinite(parsed)) fail(`--${name} needs a number`);
|
|
1706
|
+
return parsed;
|
|
1707
|
+
}
|
|
1708
|
+
function text(flags, name) {
|
|
1709
|
+
const value = flags[name];
|
|
1710
|
+
return typeof value === "string" ? value : void 0;
|
|
1711
|
+
}
|
|
1712
|
+
function size(flags, name, fallback, low = 40, high = 8e3) {
|
|
1713
|
+
const value = number(flags, name) ?? fallback;
|
|
1714
|
+
if (value < low || value > high) fail(`--${name} must be between ${low} and ${high}`);
|
|
1715
|
+
return Math.round(value);
|
|
1716
|
+
}
|
|
1717
|
+
function level(flags, fallback) {
|
|
1718
|
+
const value = text(flags, "level");
|
|
1719
|
+
if (!value) return fallback;
|
|
1720
|
+
if (!FIDELITY_LEVELS.includes(value)) fail(`--level must be one of ${FIDELITY_LEVELS.join(", ")}`);
|
|
1721
|
+
return value;
|
|
1722
|
+
}
|
|
1723
|
+
async function health() {
|
|
1724
|
+
try {
|
|
1725
|
+
const response = await fetch(`${DAEMON_ORIGIN}/health`, { signal: AbortSignal.timeout(800) });
|
|
1726
|
+
return response.ok ? await response.json() : null;
|
|
1727
|
+
} catch {
|
|
1728
|
+
return null;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
async function ensureDaemon() {
|
|
1732
|
+
const running = await health();
|
|
1733
|
+
if (running?.build === "0.2.0+1793896544071") return;
|
|
1734
|
+
if (running) {
|
|
1735
|
+
await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: "{}" }).catch(() => {
|
|
1736
|
+
});
|
|
1737
|
+
await new Promise((done) => setTimeout(done, 200));
|
|
1738
|
+
}
|
|
1739
|
+
spawn2(process.execPath, [fileURLToPath2(import.meta.url), "__daemon"], { detached: true, stdio: "ignore" }).unref();
|
|
1740
|
+
for (let attempt = 0; attempt < 60; attempt++) {
|
|
1741
|
+
await new Promise((done) => setTimeout(done, 100));
|
|
1742
|
+
if ((await health())?.build === "0.2.0+1793896544071") return;
|
|
1743
|
+
}
|
|
1744
|
+
fail(`Could not start the background server on port ${PORT}. Is something else using it?`);
|
|
1745
|
+
}
|
|
1746
|
+
async function call(name, input = {}) {
|
|
1747
|
+
await ensureDaemon();
|
|
1748
|
+
const response = await fetch(`${DAEMON_ORIGIN}/cli/${name}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) });
|
|
1749
|
+
const data = await response.json().catch(() => ({}));
|
|
1750
|
+
if (!response.ok) fail(data.error || `Failed (${response.status})`, data.problems ? { problems: data.problems } : {});
|
|
1751
|
+
return data;
|
|
1752
|
+
}
|
|
1753
|
+
var IMAGE = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg"]);
|
|
1754
|
+
async function prepare(source) {
|
|
1755
|
+
const info = await stat5(source).catch(() => null);
|
|
1756
|
+
if (!info) fail(`"${source}" does not exist.`);
|
|
1757
|
+
if (info.isDirectory()) return { folder: source, temporary: null, image: false };
|
|
1758
|
+
const extension = extname3(source).toLowerCase();
|
|
1759
|
+
const isImage = IMAGE.has(extension);
|
|
1760
|
+
if (!isImage && extension !== ".html" && extension !== ".htm") fail("Point at a folder of static files, an HTML file or an image.");
|
|
1761
|
+
const temporary = await mkdtemp(join9(tmpdir(), "twombly-"));
|
|
1762
|
+
if (isImage) {
|
|
1763
|
+
const name = `image${extension}`;
|
|
1764
|
+
await copyFile(source, join9(temporary, name));
|
|
1765
|
+
await writeFile7(join9(temporary, "index.html"), `<!doctype html><meta charset="utf-8"><title>${basename2(source)}</title><style>html,body{margin:0;background:#fff}img{display:block;width:100%;height:auto}</style><img src="${name}" alt="">`);
|
|
1766
|
+
} else {
|
|
1767
|
+
await copyFile(source, join9(temporary, "index.html"));
|
|
1768
|
+
}
|
|
1769
|
+
return { folder: temporary, temporary, image: isImage };
|
|
1770
|
+
}
|
|
1771
|
+
async function currentProject() {
|
|
1772
|
+
const project = await findProject(process.cwd());
|
|
1773
|
+
if (!project) fail("No canvas here yet. Run: twombly init");
|
|
1774
|
+
return project;
|
|
1775
|
+
}
|
|
1776
|
+
async function markWorking() {
|
|
1777
|
+
const project = await findProject(process.cwd());
|
|
1778
|
+
if (project) await call("working", { project, ms: WORKING_MS });
|
|
1779
|
+
}
|
|
1780
|
+
function gateFailure(result) {
|
|
1781
|
+
print({ ok: false, placed: false, problems: result.problems, warnings: result.warnings, screenshot: result.screenshot });
|
|
1782
|
+
process.exitCode = 1;
|
|
1783
|
+
}
|
|
1784
|
+
var NEXT = "Open the screenshot. Passing the gates does not prove it looks right. To test interactions, open the url from: twombly preview <frame>";
|
|
1785
|
+
var CONCURRENT_GATES = 4;
|
|
1786
|
+
async function frameItems(flags, known) {
|
|
1787
|
+
const value = flags.frames;
|
|
1788
|
+
if (value === void 0) return [flags];
|
|
1789
|
+
if (typeof value !== "string") fail("--frames needs JSON or a JSON file.");
|
|
1790
|
+
const { frames: _, ...shared } = flags;
|
|
1791
|
+
let items;
|
|
1792
|
+
try {
|
|
1793
|
+
items = JSON.parse(value.trimStart().startsWith("[") ? value : await readFile7(value, "utf8"));
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
fail(`--frames: ${error instanceof Error ? error.message : error}`);
|
|
1796
|
+
}
|
|
1797
|
+
if (!Array.isArray(items) || !items.length) fail("--frames needs a list of frames.");
|
|
1798
|
+
return items.map((item) => {
|
|
1799
|
+
const unknown = Object.keys(item).find((key) => !known.includes(key));
|
|
1800
|
+
if (unknown) fail(`--frames: unknown field "${unknown}". Each frame takes: ${known.join(", ")}.`);
|
|
1801
|
+
const merged = { ...shared };
|
|
1802
|
+
for (const [key, field] of Object.entries(item)) {
|
|
1803
|
+
if (key === "newRow") {
|
|
1804
|
+
if (field) merged["new-row"] = true;
|
|
1805
|
+
else delete merged["new-row"];
|
|
1806
|
+
} else merged[key] = String(field);
|
|
1807
|
+
}
|
|
1808
|
+
return merged;
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
async function placeFrames(project, many, jobs, place2) {
|
|
1812
|
+
await markWorking();
|
|
1813
|
+
const scratch = await mkdtemp(join9(tmpdir(), "twombly-gates-"));
|
|
1814
|
+
const gates = gateSession(), prepared = /* @__PURE__ */ new Map(), temporary = [];
|
|
1815
|
+
let keepScratch = false;
|
|
1816
|
+
try {
|
|
1817
|
+
for (const job of jobs) {
|
|
1818
|
+
if (prepared.has(job.source)) continue;
|
|
1819
|
+
const ready = await prepare(job.source);
|
|
1820
|
+
prepared.set(job.source, ready);
|
|
1821
|
+
if (ready.temporary) temporary.push(ready.temporary);
|
|
1822
|
+
}
|
|
1823
|
+
const results = [];
|
|
1824
|
+
let next = 0;
|
|
1825
|
+
await Promise.all(Array.from({ length: CONCURRENT_GATES }, async () => {
|
|
1826
|
+
while (next < jobs.length) {
|
|
1827
|
+
const index = next++, job = jobs[index];
|
|
1828
|
+
results[index] = await gates.run(prepared.get(job.source).folder, { entry: job.entry, width: job.width, height: job.height, poster: join9(scratch, `${job.id}.png`) });
|
|
1829
|
+
}
|
|
1830
|
+
}));
|
|
1831
|
+
if (results.some((result) => !result.ok)) {
|
|
1832
|
+
keepScratch = true;
|
|
1833
|
+
if (!many) return gateFailure(results[0]);
|
|
1834
|
+
return gateFailure({
|
|
1835
|
+
problems: results.flatMap((result, index) => result.problems.map((problem) => ({ ...problem, frame: jobs[index].label, screenshot: result.screenshot }))),
|
|
1836
|
+
warnings: results.flatMap((result, index) => result.warnings.map((warning) => `${jobs[index].label}: ${warning}`))
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
const placed = [];
|
|
1840
|
+
for (const [index, job] of jobs.entries()) {
|
|
1841
|
+
const result = results[index], { folder, image } = prepared.get(job.source);
|
|
1842
|
+
const poster = result.screenshot ? posterFile(project, job.id) : void 0;
|
|
1843
|
+
await replaceFolder(folder, frameDir(project, job.id));
|
|
1844
|
+
if (poster) {
|
|
1845
|
+
await mkdir7(join9(project, ".twombly/posters"), { recursive: true });
|
|
1846
|
+
await copyFile(result.screenshot, poster);
|
|
1847
|
+
}
|
|
1848
|
+
placed.push({ ...await place2(job, { result, folder, image, poster }), warnings: result.warnings, screenshot: poster, contentSize: result.content });
|
|
1849
|
+
}
|
|
1850
|
+
if (!many) return print({ ok: true, placed: true, ...placed[0], next: NEXT });
|
|
1851
|
+
print({ ok: true, placed: true, frames: placed.map(({ frame, canvas: _, ...rest }) => ({ ...frame, ...rest })), canvas: placed[placed.length - 1].canvas, next: NEXT });
|
|
1852
|
+
} finally {
|
|
1853
|
+
await gates.close();
|
|
1854
|
+
for (const folder of temporary) await rm5(folder, { recursive: true, force: true });
|
|
1855
|
+
if (!keepScratch) await rm5(scratch, { recursive: true, force: true });
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
async function addFrames(project, source, many, items) {
|
|
1859
|
+
const jobs = items.map((flags) => {
|
|
1860
|
+
const folder = text(flags, "folder") || source, title = text(flags, "title");
|
|
1861
|
+
if (!folder || !title) fail('twombly add <folder|file> --title "Title"');
|
|
1862
|
+
level(flags, "build");
|
|
1863
|
+
number(flags, "x");
|
|
1864
|
+
number(flags, "y");
|
|
1865
|
+
return {
|
|
1866
|
+
flags,
|
|
1867
|
+
label: title,
|
|
1868
|
+
source: resolve2(folder),
|
|
1869
|
+
entry: text(flags, "entry") || "index.html",
|
|
1870
|
+
width: size(flags, "width", 1280),
|
|
1871
|
+
height: flags.height === "auto" ? "auto" : size(flags, "height", 800),
|
|
1872
|
+
id: randomBytes2(8).toString("hex").replace(/[01lo]/g, "a").slice(0, 12)
|
|
1873
|
+
};
|
|
1874
|
+
});
|
|
1875
|
+
const titles = (await call("list", { project })).frames.map((frame) => frame.title);
|
|
1876
|
+
await placeFrames(project, many, jobs, async (job, { result, image, poster }) => {
|
|
1877
|
+
const { content } = await folderContent(frameDir(project, job.id));
|
|
1878
|
+
if (titles.includes(job.label)) result.warnings.push(`Another frame is already titled "${job.label}". Give each a distinct title, or use update to replace it.`);
|
|
1879
|
+
titles.push(job.label);
|
|
1880
|
+
return call("place", {
|
|
1881
|
+
project,
|
|
1882
|
+
id: job.id,
|
|
1883
|
+
title: job.label,
|
|
1884
|
+
w: job.width,
|
|
1885
|
+
h: result.height,
|
|
1886
|
+
entry: job.entry,
|
|
1887
|
+
content,
|
|
1888
|
+
level: level(job.flags, image ? "image" : "build"),
|
|
1889
|
+
hasPoster: !!poster,
|
|
1890
|
+
x: number(job.flags, "x"),
|
|
1891
|
+
y: number(job.flags, "y"),
|
|
1892
|
+
newRow: job.flags["new-row"] === true
|
|
1893
|
+
});
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
async function updateFrames(project, source, many, items) {
|
|
1897
|
+
const { frames } = await call("list", { project });
|
|
1898
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1899
|
+
const jobs = items.map((flags) => {
|
|
1900
|
+
const ref = text(flags, "frame"), folder = text(flags, "folder") || source;
|
|
1901
|
+
if (!ref || !folder) fail("twombly update <frame> <folder|file>");
|
|
1902
|
+
const matches = frames.filter((frame2) => frame2.id === ref || frame2.title === ref);
|
|
1903
|
+
if (matches.length !== 1) fail(matches.length ? `More than one frame is titled "${ref}". Use its id.` : `No frame "${ref}". Run: twombly list`);
|
|
1904
|
+
const frame = matches[0];
|
|
1905
|
+
if (seen.has(frame.id)) fail(`--frames lists "${ref}" twice.`);
|
|
1906
|
+
seen.add(frame.id);
|
|
1907
|
+
level(flags, "build");
|
|
1908
|
+
return {
|
|
1909
|
+
flags,
|
|
1910
|
+
label: frame.title,
|
|
1911
|
+
id: frame.id,
|
|
1912
|
+
source: resolve2(folder),
|
|
1913
|
+
entry: text(flags, "entry") || frame.entry,
|
|
1914
|
+
width: size(flags, "width", frame.w),
|
|
1915
|
+
height: flags.height === "auto" ? "auto" : size(flags, "height", frame.h)
|
|
1916
|
+
};
|
|
1917
|
+
});
|
|
1918
|
+
await placeFrames(project, many, jobs, async (job, { result, image, poster }) => {
|
|
1919
|
+
const { content } = await folderContent(frameDir(project, job.id));
|
|
1920
|
+
return call("revise", {
|
|
1921
|
+
project,
|
|
1922
|
+
id: job.id,
|
|
1923
|
+
title: text(job.flags, "title"),
|
|
1924
|
+
w: job.width,
|
|
1925
|
+
h: result.height,
|
|
1926
|
+
entry: job.entry,
|
|
1927
|
+
content,
|
|
1928
|
+
hasPoster: poster ? true : void 0,
|
|
1929
|
+
level: text(job.flags, "level") || image ? level(job.flags, image ? "image" : "build") : void 0
|
|
1930
|
+
});
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
async function main() {
|
|
1934
|
+
const [name, ...rest] = process.argv.slice(2);
|
|
1935
|
+
const { positional, flags } = parse(rest);
|
|
1936
|
+
if (flags.help !== void 0 || positional.includes("-h")) {
|
|
1937
|
+
process.stdout.write(HELP + "\n");
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
if (name && !["--help", "-h", "--version", "help", "version", "guide", "__daemon"].includes(name)) checkFlags(name, flags);
|
|
1941
|
+
switch (name) {
|
|
1942
|
+
case "__daemon":
|
|
1943
|
+
return runDaemon();
|
|
1944
|
+
case void 0:
|
|
1945
|
+
case "help":
|
|
1946
|
+
case "--help":
|
|
1947
|
+
case "-h":
|
|
1948
|
+
process.stdout.write(HELP + "\n");
|
|
1949
|
+
return;
|
|
1950
|
+
case "--version":
|
|
1951
|
+
case "version":
|
|
1952
|
+
process.stdout.write("0.2.0\n");
|
|
1953
|
+
return;
|
|
1954
|
+
case "guide":
|
|
1955
|
+
process.stdout.write(SKILL_default);
|
|
1956
|
+
return;
|
|
1957
|
+
case "init": {
|
|
1958
|
+
const project = await findProject(process.cwd()) || process.cwd();
|
|
1959
|
+
const given = text(flags, "name");
|
|
1960
|
+
print({ ok: true, ...await call("init", { project, name: given || defaultName(project), rename: !!given, open: flags["no-open"] === void 0 }), next: "Frames must be static files that work with no server or network. Read how to adapt this project: twombly guide" });
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
case "check": {
|
|
1964
|
+
const source = positional[0];
|
|
1965
|
+
if (!source) fail("twombly check <folder> checks a build before it is added. It needs the build's folder.");
|
|
1966
|
+
await markWorking();
|
|
1967
|
+
const { folder, temporary } = await prepare(resolve2(source));
|
|
1968
|
+
const scratch = await mkdtemp(join9(tmpdir(), "twombly-check-"));
|
|
1969
|
+
const shot = join9(scratch, "screenshot.png");
|
|
1970
|
+
try {
|
|
1971
|
+
const height = flags.height === "auto" ? "auto" : size(flags, "height", 800);
|
|
1972
|
+
const result = await runGates(folder, { entry: text(flags, "entry") || "index.html", width: size(flags, "width", 1280), height, poster: shot });
|
|
1973
|
+
print(result);
|
|
1974
|
+
if (!result.ok) process.exitCode = 1;
|
|
1975
|
+
} finally {
|
|
1976
|
+
if (temporary) await rm5(temporary, { recursive: true, force: true });
|
|
1977
|
+
}
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
case "capture": {
|
|
1981
|
+
const url = positional[0], out = text(flags, "out");
|
|
1982
|
+
if (!url || !out) fail("twombly capture <url> --out <folder>");
|
|
1983
|
+
await markWorking();
|
|
1984
|
+
const result = await capture(url, resolve2(out), { width: size(flags, "width", 1280), height: size(flags, "height", 800), image: flags.image === true, selector: text(flags, "selector"), wait: number(flags, "wait") });
|
|
1985
|
+
print({ ok: true, ...result, next: `twombly add ${out} --title "..." --level ${flags.image === true ? "image" : "capture"}` });
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
case "add": {
|
|
1989
|
+
return addFrames(await currentProject(), positional[0], flags.frames !== void 0, await frameItems(flags, ["folder", "title", "width", "height", "entry", "level", "newRow", "x", "y"]));
|
|
1990
|
+
}
|
|
1991
|
+
case "update": {
|
|
1992
|
+
const many = flags.frames !== void 0;
|
|
1993
|
+
const items = many ? await frameItems(flags, ["frame", "folder", "title", "width", "height", "entry", "level"]) : [{ ...flags, ...positional[0] ? { frame: positional[0] } : {} }];
|
|
1994
|
+
return updateFrames(await currentProject(), many ? positional[0] : positional[1], many, items);
|
|
1995
|
+
}
|
|
1996
|
+
case "working":
|
|
1997
|
+
print({ ok: true, ...await call("working", { project: await currentProject(), ms: WORKING_MS }) });
|
|
1998
|
+
return;
|
|
1999
|
+
case "done":
|
|
2000
|
+
print({ ok: true, ...await call("working", { project: await currentProject(), ms: 0 }) });
|
|
2001
|
+
return;
|
|
2002
|
+
case "list":
|
|
2003
|
+
print({ ok: true, ...await call("list", { project: await currentProject() }) });
|
|
2004
|
+
return;
|
|
2005
|
+
case "open":
|
|
2006
|
+
print({ ok: true, ...await call("open", { project: await currentProject() }) });
|
|
2007
|
+
return;
|
|
2008
|
+
case "preview": {
|
|
2009
|
+
if (!positional[0]) fail("twombly preview <frame>");
|
|
2010
|
+
print({ ok: true, ...await call("preview", { project: await currentProject(), frame: positional[0] }) });
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
case "status":
|
|
2014
|
+
print({ ok: true, ...await call("status", { project: await findProject(process.cwd()) }) });
|
|
2015
|
+
return;
|
|
2016
|
+
case "remove": {
|
|
2017
|
+
if (!positional[0]) fail("twombly remove <frame>");
|
|
2018
|
+
print({ ok: true, ...await call("remove", { project: await currentProject(), frame: positional[0] }) });
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
case "move": {
|
|
2022
|
+
const x = number(flags, "x"), y = number(flags, "y");
|
|
2023
|
+
if (!positional[0] || x === void 0 || y === void 0) fail("twombly move <frame> --x N --y N");
|
|
2024
|
+
print({ ok: true, ...await call("patch", { project: await currentProject(), frame: positional[0], patch: { x, y } }) });
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
case "resize": {
|
|
2028
|
+
if (flags.height === "auto") fail("resize takes a number. To fit the page: twombly update <frame> <folder> --height auto");
|
|
2029
|
+
if (!positional[0] || flags.width === void 0 || flags.height === void 0) fail("twombly resize <frame> --width N --height N");
|
|
2030
|
+
print({ ok: true, ...await call("patch", { project: await currentProject(), frame: positional[0], patch: { w: size(flags, "width", 1280), h: size(flags, "height", 800) } }) });
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
case "retitle": {
|
|
2034
|
+
if (!positional[0] || !positional[1]) fail('twombly retitle <frame> "New title"');
|
|
2035
|
+
print({ ok: true, ...await call("patch", { project: await currentProject(), frame: positional[0], patch: { title: positional[1] } }) });
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
case "arrange":
|
|
2039
|
+
print({ ok: true, ...await call("arrange", { project: await currentProject(), columns: number(flags, "columns") }) });
|
|
2040
|
+
return;
|
|
2041
|
+
case "rename": {
|
|
2042
|
+
if (!positional[0]) fail('twombly rename "Name"');
|
|
2043
|
+
print({ ok: true, ...await call("rename", { project: await currentProject(), name: positional[0] }) });
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
case "share":
|
|
2047
|
+
print({ ok: true, ...await call("share", { project: await currentProject() }) });
|
|
2048
|
+
return;
|
|
2049
|
+
case "unshare":
|
|
2050
|
+
print({ ok: true, ...await call("unshare", { project: await currentProject() }) });
|
|
2051
|
+
return;
|
|
2052
|
+
case "login": {
|
|
2053
|
+
const result = await call("login");
|
|
2054
|
+
if (result.signedIn) {
|
|
2055
|
+
print({ ok: true, ...result });
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
for (let attempt = 0; attempt < 300; attempt++) {
|
|
2059
|
+
await new Promise((done) => setTimeout(done, 1e3));
|
|
2060
|
+
const status = await call("status");
|
|
2061
|
+
if (status.signedIn) {
|
|
2062
|
+
print({ ok: true, signedIn: true, me: status.me });
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
fail("Sign-in timed out.");
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
case "logout":
|
|
2070
|
+
print({ ok: true, ...await call("logout") });
|
|
2071
|
+
return;
|
|
2072
|
+
case "stop": {
|
|
2073
|
+
if (await health()) await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: "{}" }).catch(() => {
|
|
2074
|
+
});
|
|
2075
|
+
print({ ok: true, stopped: true });
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
default:
|
|
2079
|
+
fail(`Unknown command "${name}". Run: twombly help`);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
main().catch((error) => {
|
|
2083
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
2084
|
+
});
|