twombly 0.1.0 → 0.3.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 +853 -248
- package/dist/ui/assets/Landing-BKJA0CJt.js +111 -0
- package/dist/ui/assets/Landing-Da4YRp2T.css +1 -0
- package/dist/ui/assets/LandingStage-CqdIuQDE.css +1 -0
- package/dist/ui/assets/LandingStage-DyrNB5Ea.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-B5osE1GC.js +10 -0
- package/dist/ui/assets/index-Dw-Z6e31.css +1 -0
- package/dist/ui/favicon.png +0 -0
- package/dist/ui/index.html +8 -3
- package/dist/ui/legal.css +1 -0
- package/dist/ui/og.png +0 -0
- package/dist/ui/people/jaytel.jpg +0 -0
- package/dist/ui/people/marvin.jpg +0 -0
- package/dist/ui/privacy.html +2 -1
- package/dist/ui/skill.md +6 -0
- package/dist/ui/terms.html +2 -1
- package/dist/ui/twombly-wordmark.svg +9 -0
- package/package.json +1 -1
- package/dist/ui/assets/index-BHNy3m8-.js +0 -33
- package/dist/ui/assets/index-CL4B1FMQ.css +0 -1
package/dist/cli.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { spawn as spawn2 } from "node:child_process";
|
|
5
5
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
6
|
-
import { copyFile
|
|
6
|
+
import { copyFile as copyFile2, mkdtemp, readFile as readFile7, rm as rm5, stat as stat5, writeFile as writeFile7 } from "node:fs/promises";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
|
-
import { basename as basename2, extname as extname3, join as
|
|
8
|
+
import { basename as basename2, extname as extname3, join as join9, resolve as resolve2 } from "node:path";
|
|
9
9
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
10
10
|
|
|
11
11
|
// ../protocol/src/gates.ts
|
|
@@ -13,11 +13,13 @@ var LIMITS = {
|
|
|
13
13
|
fileBytes: 25 * 1024 * 1024,
|
|
14
14
|
frameBytes: 100 * 1024 * 1024,
|
|
15
15
|
frameFiles: 5e3,
|
|
16
|
-
canvasFrames:
|
|
16
|
+
canvasFrames: 500,
|
|
17
17
|
scanBytes: 8 * 1024 * 1024,
|
|
18
18
|
titleChars: 120,
|
|
19
19
|
nameChars: 120,
|
|
20
|
-
commentChars: 5e3
|
|
20
|
+
commentChars: 5e3,
|
|
21
|
+
strokePoints: 3e3,
|
|
22
|
+
canvasStrokes: 2e3
|
|
21
23
|
};
|
|
22
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.";
|
|
23
25
|
var FORBIDDEN = [
|
|
@@ -231,8 +233,24 @@ var FRAME_ONLY_MESSAGE = "This frame only opens inside a canvas.";
|
|
|
231
233
|
|
|
232
234
|
// ../protocol/src/index.ts
|
|
233
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
|
+
}
|
|
234
245
|
var WORKING_MS = 3 * 6e4;
|
|
235
246
|
var WORKING_TAIL_MS = 3e4;
|
|
247
|
+
var COLORS = ["#F24822", "#FF7A00", "#14AE5C", "#0D99FF", "#9747FF", "#E5277B", "#00B5B8", "#7B61FF"];
|
|
248
|
+
function colorOf(id) {
|
|
249
|
+
let h = 0;
|
|
250
|
+
for (const c of String(id)) h = h * 31 + c.charCodeAt(0) >>> 0;
|
|
251
|
+
return COLORS[h % COLORS.length];
|
|
252
|
+
}
|
|
253
|
+
var FRAME_ID = /^[a-km-z2-9]{8,32}$/;
|
|
236
254
|
var FRAME_GAP = 80;
|
|
237
255
|
function nextPosition(frames, newRow = false) {
|
|
238
256
|
if (!frames.length) return { x: 0, y: 0 };
|
|
@@ -243,7 +261,7 @@ function nextPosition(frames, newRow = false) {
|
|
|
243
261
|
}
|
|
244
262
|
|
|
245
263
|
// ../../skills/twombly/SKILL.md
|
|
246
|
-
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 commands with `twombly <command>`, or `npx -y twombly <command>` if it isn't installed. 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.\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.\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 and images:** 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`. If a font is licensed, tell the person it will be publicly hosted. Leave out images the design doesn't show: a frame is limited to 100 MB.\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## Steps\n\n1. **Start the canvas.** From the project root: `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. Run `twombly working` before each build: the canvas background moves for three minutes so people can see you're at work, and running it again extends that.\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.\n7. **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\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```sh\ntwombly init [--name \"Name\"] [--no-open] # create or reopen this project's canvas\ntwombly add <folder|file> --title \"T\" [--width 1280] [--height 800|auto] [--entry index.html] [--level build] [--new-row] [--x N --y N]\ntwombly update <frame> <folder|file> [--title \"T\"] [--width N] [--height N|auto] [--entry path] [--level build]\ntwombly check <folder> [--entry index.html] [--width 1280] [--height 800] # run the gates without adding\ntwombly list # frames, positions and the live link\ntwombly move <frame> --x N --y N\ntwombly resize <frame> --width N --height N\ntwombly retitle <frame> \"New title\"\ntwombly remove <frame>\ntwombly arrange [--columns N] # tidy into a grid\ntwombly rename \"Canvas name\"\ntwombly capture <url> --out <folder> [--width N] [--height N] [--image] [--selector css]\ntwombly working # show on the canvas that you're at work, for three minutes\ntwombly done # you've finished: the canvas goes still\ntwombly open # open the canvas in the default browser\ntwombly status # sign-in and canvas state\n```\n\n`<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. Each frame keeps its own copy of its files, so if several frames came from one build, `update` each one.\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";
|
|
264
|
+
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, 500 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. If `created` is false, the canvas already existed: see [Other agents and more canvases](#other-agents-and-more-canvases).\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/<task>/<version>` so the person's source stays clean. Pick a `<task>` no other agent would, because a shared folder gets overwritten. Use the first approach below that works. The canvas tells people an agent is working: `init` and the slow commands keep that showing 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 in your own folder under `.twombly/work`; here, `composer`.\n\n```js\n// .twombly/work/composer/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/composer/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/composer/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/composer/vite.config.mjs` and `twombly add .twombly/work/composer/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## What the person is looking at\n\nWhen the person says \"this\" or \"these\" without naming a frame, run `twombly context`. It returns the frames they have open fullscreen, are using, or have selected, in that order, plus any comment they have open. `looking: false` means no canvas is open in a browser. If nothing is selected, ask which frame they mean.\n\n`twombly comments` returns every comment, with the frame it's on, where on the frame (in the frame's pixels), whether it's resolved, and `mine: true` for the person's own comments.\n\nEach frame in `context` and `list` has a `source`: the folder or file it was last added or updated from. Edit what built it, rebuild, then `update` the frame. Frames added before this existed show `null`.\n\n## Other agents and more canvases\n\nA folder has one default canvas, and every agent in that folder or below it lands on it. `init` on an existing canvas joins it and keeps its name. A folder can also hold any number of named canvases, each with its own address, frames and link.\n\n**Use a named canvas** when your work is a separate exploration, or the person asks for its own canvas: `twombly init --canvas \"Pricing\" --no-open`. Then pass `--canvas \"Pricing\"` on every command, because nothing remembers it between commands. `init --canvas` with a name that exists joins it, so agents can share one by agreeing on the name. `twombly canvases` lists what's here.\n\n**On a canvas with other agents:**\n\n- Only change your own frames, unless the person asks. Any agent's `update`, `move` and `remove` work on any frame, and `arrange` moves all of them.\n- Give frames titles no one else would use, such as \"Pricing: V1\", since commands find frames by title. A repeated title gets a warning, and then has to be addressed by id.\n- Add each set in one `--frames` command, with `--new-row` on its first frame, so it stays together.\n- If `update` says someone else changed a frame, run `twombly list` and don't retry without checking whose work you'd replace.\n- Run `twombly done` when you finish. The canvas keeps showing work until every agent that ran `init` has finished, or three minutes after the last activity.\n\n**Worktrees:** a canvas lives in the folder it was started in. A worktree outside the repo gets its own canvas, which is deleted with the worktree, so tell the person that before removing it. A worktree inside the repo joins the repo's default canvas if there is one.\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 keeps the canvas on this machine, comments included.\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";
|
|
247
265
|
|
|
248
266
|
// src/capture.ts
|
|
249
267
|
import { createHash } from "node:crypto";
|
|
@@ -284,10 +302,10 @@ async function capture(url, out, options) {
|
|
|
284
302
|
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="">`);
|
|
285
303
|
return { folder: out, assets: 1, failed: [] };
|
|
286
304
|
}
|
|
287
|
-
const
|
|
305
|
+
const saved2 = /* @__PURE__ */ new Map(), failed = [];
|
|
288
306
|
const save = async (assetUrl) => {
|
|
289
307
|
if (assetUrl.startsWith("data:") || assetUrl.startsWith("blob:")) return null;
|
|
290
|
-
if (
|
|
308
|
+
if (saved2.has(assetUrl)) return saved2.get(assetUrl);
|
|
291
309
|
try {
|
|
292
310
|
const response = await context.request.get(assetUrl, { timeout: 2e4 });
|
|
293
311
|
if (!response.ok()) throw new Error(String(response.status()));
|
|
@@ -295,7 +313,7 @@ async function capture(url, out, options) {
|
|
|
295
313
|
let bytes = await response.body();
|
|
296
314
|
const hash = createHash("sha256").update(assetUrl).digest("hex").slice(0, 16);
|
|
297
315
|
const name = `assets/${hash}${EXT[type] || extname(new URL(assetUrl).pathname) || ""}`;
|
|
298
|
-
|
|
316
|
+
saved2.set(assetUrl, name);
|
|
299
317
|
if (type === "text/css") bytes = Buffer.from(await rewriteCss(bytes.toString("utf8"), assetUrl, "css"));
|
|
300
318
|
await writeFile(join(out, name), bytes);
|
|
301
319
|
return name;
|
|
@@ -388,7 +406,7 @@ async function capture(url, out, options) {
|
|
|
388
406
|
return "<!doctype html>\n" + document.documentElement.outerHTML;
|
|
389
407
|
}, { replacements, styles, inline });
|
|
390
408
|
await writeFile(join(out, "index.html"), html);
|
|
391
|
-
return { folder: out, assets:
|
|
409
|
+
return { folder: out, assets: saved2.size, failed };
|
|
392
410
|
} finally {
|
|
393
411
|
await browser.close().catch(() => {
|
|
394
412
|
});
|
|
@@ -398,10 +416,9 @@ async function capture(url, out, options) {
|
|
|
398
416
|
// src/daemon.ts
|
|
399
417
|
import { createHash as createHash3, randomBytes } from "node:crypto";
|
|
400
418
|
import { createReadStream as createReadStream2 } from "node:fs";
|
|
401
|
-
import { readFile as readFile5, stat as stat3 } from "node:fs/promises";
|
|
419
|
+
import { copyFile, mkdir as mkdir6, readFile as readFile5, stat as stat3 } from "node:fs/promises";
|
|
402
420
|
import { createServer as createServer2 } from "node:http";
|
|
403
|
-
import { dirname as
|
|
404
|
-
import { fileURLToPath } from "node:url";
|
|
421
|
+
import { dirname as dirname3, extname as extname2, join as join8 } from "node:path";
|
|
405
422
|
import { WebSocketServer, WebSocket } from "ws";
|
|
406
423
|
|
|
407
424
|
// src/credentials.ts
|
|
@@ -418,6 +435,7 @@ var PORT = Number(process.env.TWOMBLY_PORT) || 4747;
|
|
|
418
435
|
var ENDPOINT = (process.env.TWOMBLY_ENDPOINT || "https://twombly.app").replace(/\/+$/, "");
|
|
419
436
|
var DAEMON_ORIGIN = `http://localhost:${PORT}`;
|
|
420
437
|
var NO_OPEN = process.env.TWOMBLY_NO_OPEN === "1";
|
|
438
|
+
var TOOL_API = 3;
|
|
421
439
|
|
|
422
440
|
// src/credentials.ts
|
|
423
441
|
var run = promisify(execFile);
|
|
@@ -545,36 +563,164 @@ async function startFrameServer(folder, poster) {
|
|
|
545
563
|
} };
|
|
546
564
|
}
|
|
547
565
|
|
|
566
|
+
// src/interface.ts
|
|
567
|
+
import { createPublicKey, verify } from "node:crypto";
|
|
568
|
+
import { mkdir as mkdir3, readdir, rename, rm as rm3, writeFile as writeFile3 } from "node:fs/promises";
|
|
569
|
+
import { dirname, join as join5 } from "node:path";
|
|
570
|
+
import { fileURLToPath } from "node:url";
|
|
571
|
+
|
|
572
|
+
// src/release.ts
|
|
573
|
+
var INTERFACE_VERSION = 1;
|
|
574
|
+
var INTERFACE_PUBLIC_KEY = "MCowBQYDK2VwAyEA2/JCcP+q2Ht/fjTqlMvBzwrUS07UAXNp1z6bqY0hG9U=";
|
|
575
|
+
|
|
576
|
+
// src/interface.ts
|
|
577
|
+
var OVERRIDE = process.env.TWOMBLY_UI;
|
|
578
|
+
var UPDATES = !OVERRIDE && process.env.TWOMBLY_NO_INTERFACE_UPDATES !== "1";
|
|
579
|
+
var BUNDLED = join5(dirname(fileURLToPath(import.meta.url)), "ui");
|
|
580
|
+
var STORE = join5(HOME, "interface", String(INTERFACE_VERSION));
|
|
581
|
+
var PUBLIC_KEY = createPublicKey({ key: Buffer.from(process.env.TWOMBLY_INTERFACE_KEY || INTERFACE_PUBLIC_KEY, "base64"), format: "der", type: "spki" });
|
|
582
|
+
var KEEP = 3;
|
|
583
|
+
var CHECK_MS = 36e5;
|
|
584
|
+
var CHECK_ON_OPEN_MS = 5 * 6e4;
|
|
585
|
+
var WAIT_ON_OPEN_MS = 600;
|
|
586
|
+
var DOWNLOAD_MS = 3e4;
|
|
587
|
+
var MAX_CHARS = 64 * 1024 * 1024;
|
|
588
|
+
var SAFE_PATH = /^[\w.@-]+(\/[\w.@-]+)*$/;
|
|
589
|
+
var saved = [];
|
|
590
|
+
var etag = "";
|
|
591
|
+
var checking = null;
|
|
592
|
+
var checkedAt = 0;
|
|
593
|
+
function interfaceFolders() {
|
|
594
|
+
if (OVERRIDE) return [OVERRIDE];
|
|
595
|
+
const folder = (released) => join5(STORE, String(released));
|
|
596
|
+
return [...saved.filter((released) => released > 1793910384715).map(folder), BUNDLED, ...saved.filter((released) => released <= 1793910384715).map(folder)];
|
|
597
|
+
}
|
|
598
|
+
function unpack(body) {
|
|
599
|
+
const { payload, signature } = JSON.parse(body);
|
|
600
|
+
if (typeof payload !== "string" || typeof signature !== "string") return null;
|
|
601
|
+
if (!verify(null, Buffer.from(payload), PUBLIC_KEY, Buffer.from(signature, "base64"))) return null;
|
|
602
|
+
const release = JSON.parse(payload);
|
|
603
|
+
if (release.interface !== INTERFACE_VERSION || !Number.isSafeInteger(release.released) || !release.files?.["index.html"]) return null;
|
|
604
|
+
const safe = Object.entries(release.files).every(([path, bytes]) => SAFE_PATH.test(path) && !path.split("/").includes("..") && typeof bytes === "string");
|
|
605
|
+
return safe ? release : null;
|
|
606
|
+
}
|
|
607
|
+
async function install(released, files) {
|
|
608
|
+
const temporary = join5(STORE, `.${released}-${process.pid}`);
|
|
609
|
+
try {
|
|
610
|
+
for (const [path, bytes] of Object.entries(files)) {
|
|
611
|
+
await mkdir3(dirname(join5(temporary, path)), { recursive: true });
|
|
612
|
+
await writeFile3(join5(temporary, path), Buffer.from(bytes, "base64"));
|
|
613
|
+
}
|
|
614
|
+
await rename(temporary, join5(STORE, String(released)));
|
|
615
|
+
} catch (error) {
|
|
616
|
+
await rm3(temporary, { recursive: true, force: true });
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
619
|
+
saved = [released, ...saved].sort((a, b) => b - a);
|
|
620
|
+
for (const old of saved.splice(KEEP)) await rm3(join5(STORE, String(old)), { recursive: true, force: true });
|
|
621
|
+
}
|
|
622
|
+
async function check() {
|
|
623
|
+
const response = await fetch(`${ENDPOINT}/interface/${INTERFACE_VERSION}`, { headers: etag ? { "if-none-match": etag } : {}, signal: AbortSignal.timeout(DOWNLOAD_MS) });
|
|
624
|
+
if (response.status !== 200) return;
|
|
625
|
+
const body = await response.text();
|
|
626
|
+
if (body.length > MAX_CHARS) return;
|
|
627
|
+
const release = unpack(body);
|
|
628
|
+
if (!release) return;
|
|
629
|
+
etag = response.headers.get("etag") || "";
|
|
630
|
+
if (release.released > Math.max(saved[0] ?? 0, 1793910384715)) await install(release.released, release.files);
|
|
631
|
+
}
|
|
632
|
+
function startCheck() {
|
|
633
|
+
if (checking) return;
|
|
634
|
+
checkedAt = Date.now();
|
|
635
|
+
checking = check().catch(() => {
|
|
636
|
+
}).finally(() => {
|
|
637
|
+
checking = null;
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
async function freshInterface() {
|
|
641
|
+
if (!UPDATES) return;
|
|
642
|
+
if (Date.now() - checkedAt > CHECK_ON_OPEN_MS) startCheck();
|
|
643
|
+
if (checking) await Promise.race([checking, new Promise((done) => setTimeout(done, WAIT_ON_OPEN_MS))]);
|
|
644
|
+
}
|
|
645
|
+
async function startInterfaceUpdates() {
|
|
646
|
+
if (!UPDATES) return;
|
|
647
|
+
await mkdir3(STORE, { recursive: true });
|
|
648
|
+
const names = await readdir(STORE);
|
|
649
|
+
for (const name of names) if (name.startsWith(".")) await rm3(join5(STORE, name), { recursive: true, force: true });
|
|
650
|
+
saved = names.filter((name) => /^\d+$/.test(name)).map(Number).sort((a, b) => b - a);
|
|
651
|
+
startCheck();
|
|
652
|
+
setInterval(startCheck, CHECK_MS).unref();
|
|
653
|
+
}
|
|
654
|
+
|
|
548
655
|
// src/project.ts
|
|
549
656
|
import { createHash as createHash2 } from "node:crypto";
|
|
550
|
-
import { cp, mkdir as
|
|
551
|
-
import { basename, dirname, join as
|
|
657
|
+
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";
|
|
658
|
+
import { basename, dirname as dirname2, join as join6, relative, resolve, sep } from "node:path";
|
|
552
659
|
var DIR = ".twombly";
|
|
553
660
|
var exists = (path) => stat2(path).then(() => true, () => false);
|
|
554
661
|
async function findProject(from) {
|
|
555
662
|
let dir = resolve(from);
|
|
556
663
|
while (true) {
|
|
557
|
-
if (await exists(
|
|
558
|
-
const up =
|
|
664
|
+
if (await exists(join6(dir, DIR, "canvas.json"))) return dir;
|
|
665
|
+
const up = dirname2(dir);
|
|
559
666
|
if (up === dir) return null;
|
|
560
667
|
dir = up;
|
|
561
668
|
}
|
|
562
669
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
670
|
+
function inOrder() {
|
|
671
|
+
let last = Promise.resolve();
|
|
672
|
+
return (work) => {
|
|
673
|
+
const next = last.then(work, work);
|
|
674
|
+
last = next.catch(() => {
|
|
675
|
+
});
|
|
676
|
+
return next;
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
var NAMED = "canvases";
|
|
680
|
+
function canvasSlug(name) {
|
|
681
|
+
const slug = name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
682
|
+
return slug || createHash2("sha256").update(name).digest("hex").slice(0, 12);
|
|
683
|
+
}
|
|
684
|
+
var namedProject = (root, name) => join6(root, DIR, NAMED, canvasSlug(name));
|
|
685
|
+
async function canvasesAbove(from) {
|
|
686
|
+
const out = [];
|
|
687
|
+
const read = async (project, slug) => {
|
|
688
|
+
const canvas = await readCanvas(project).catch(() => null);
|
|
689
|
+
if (canvas) out.push({ project, slug, id: canvas.id, name: canvas.name, frames: canvas.frames.length, link: canvas.remote?.url || null });
|
|
690
|
+
};
|
|
691
|
+
for (let dir = resolve(from); ; dir = dirname2(dir)) {
|
|
692
|
+
await read(dir, null);
|
|
693
|
+
const named = await readdir2(join6(dir, DIR, NAMED)).catch(() => []);
|
|
694
|
+
for (const slug of named.sort()) await read(join6(dir, DIR, NAMED, slug), slug);
|
|
695
|
+
if (dirname2(dir) === dir) return out;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
async function findNamed(from, ref) {
|
|
699
|
+
const slug = canvasSlug(ref), all = await canvasesAbove(from);
|
|
700
|
+
return all.find((entry) => entry.slug === slug || entry.id === ref) ?? all.find((entry) => canvasSlug(entry.name) === slug) ?? null;
|
|
701
|
+
}
|
|
702
|
+
async function findRoot(from) {
|
|
703
|
+
for (let dir = resolve(from); ; dir = dirname2(dir)) {
|
|
704
|
+
if (await exists(join6(dir, DIR, "canvas.json")) || await exists(join6(dir, DIR, NAMED))) return dir;
|
|
705
|
+
if (dirname2(dir) === dir) return null;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
var canvasFile = (project) => join6(project, DIR, "canvas.json");
|
|
709
|
+
var frameDir = (project, id) => join6(project, DIR, "frames", id);
|
|
710
|
+
var posterFile = (project, id) => join6(project, DIR, "posters", `${id}.png`);
|
|
566
711
|
async function readCanvas(project) {
|
|
567
712
|
return JSON.parse(await readFile3(canvasFile(project), "utf8"));
|
|
568
713
|
}
|
|
569
714
|
async function writeCanvas(project, canvas) {
|
|
570
715
|
const path = canvasFile(project), temp = `${path}.${process.pid}.tmp`;
|
|
571
|
-
await
|
|
572
|
-
await
|
|
716
|
+
await writeFile4(temp, JSON.stringify(canvas, null, 2) + "\n");
|
|
717
|
+
await rename2(temp, path);
|
|
573
718
|
}
|
|
574
719
|
async function ensureProjectDir(project) {
|
|
575
|
-
await
|
|
576
|
-
await
|
|
577
|
-
await
|
|
720
|
+
await mkdir4(join6(project, DIR, "frames"), { recursive: true });
|
|
721
|
+
await mkdir4(join6(project, DIR, "posters"), { recursive: true });
|
|
722
|
+
await writeFile4(join6(project, DIR, ".gitignore"), "*\n");
|
|
723
|
+
if (basename(dirname2(project)) === NAMED && basename(dirname2(dirname2(project))) === DIR) await writeFile4(join6(dirname2(dirname2(project)), ".gitignore"), "*\n");
|
|
578
724
|
}
|
|
579
725
|
function defaultName(project) {
|
|
580
726
|
return basename(project).replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) || "Untitled";
|
|
@@ -582,8 +728,8 @@ function defaultName(project) {
|
|
|
582
728
|
async function hashFolder(folder) {
|
|
583
729
|
const out = [];
|
|
584
730
|
const walk = async (dir) => {
|
|
585
|
-
for (const entry of await
|
|
586
|
-
const absolute =
|
|
731
|
+
for (const entry of await readdir2(dir, { withFileTypes: true })) {
|
|
732
|
+
const absolute = join6(dir, entry.name);
|
|
587
733
|
if (entry.isDirectory()) await walk(absolute);
|
|
588
734
|
else if (entry.isFile()) {
|
|
589
735
|
if (entry.name === ".DS_Store") continue;
|
|
@@ -600,14 +746,18 @@ async function folderContent(folder) {
|
|
|
600
746
|
return { files, content: await contentHash(files) };
|
|
601
747
|
}
|
|
602
748
|
async function replaceFolder(source, target) {
|
|
603
|
-
const temp = `${target}.${process.pid}.tmp`;
|
|
604
|
-
await
|
|
605
|
-
await
|
|
749
|
+
const temp = `${target}.${process.pid}.tmp`, old = `${target}.${process.pid}.old`;
|
|
750
|
+
await rm4(temp, { recursive: true, force: true });
|
|
751
|
+
await rm4(old, { recursive: true, force: true });
|
|
752
|
+
await mkdir4(dirname2(target), { recursive: true });
|
|
606
753
|
await cp(source, temp, { recursive: true, verbatimSymlinks: false, filter: (path) => basename(path) !== ".DS_Store" });
|
|
607
|
-
await
|
|
608
|
-
|
|
754
|
+
await rename2(target, old).catch((error) => {
|
|
755
|
+
if (error.code !== "ENOENT") throw error;
|
|
756
|
+
});
|
|
757
|
+
await rename2(temp, target);
|
|
758
|
+
await rm4(old, { recursive: true, force: true });
|
|
609
759
|
}
|
|
610
|
-
var registryFile =
|
|
760
|
+
var registryFile = join6(HOME, "registry.json");
|
|
611
761
|
async function readRegistry() {
|
|
612
762
|
try {
|
|
613
763
|
return JSON.parse(await readFile3(registryFile, "utf8"));
|
|
@@ -615,31 +765,35 @@ async function readRegistry() {
|
|
|
615
765
|
return {};
|
|
616
766
|
}
|
|
617
767
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
768
|
+
var registryTurn = inOrder();
|
|
769
|
+
function register(id, project) {
|
|
770
|
+
return registryTurn(async () => {
|
|
771
|
+
const registry = await readRegistry();
|
|
772
|
+
if (registry[id] === project) return;
|
|
773
|
+
registry[id] = project;
|
|
774
|
+
await mkdir4(HOME, { recursive: true });
|
|
775
|
+
const temp = `${registryFile}.${process.pid}.tmp`;
|
|
776
|
+
await writeFile4(temp, JSON.stringify(registry, null, 2));
|
|
777
|
+
await rename2(temp, registryFile);
|
|
778
|
+
});
|
|
624
779
|
}
|
|
625
780
|
|
|
626
781
|
// src/settings.ts
|
|
627
|
-
import { mkdir as
|
|
628
|
-
import { join as
|
|
629
|
-
var FILE =
|
|
782
|
+
import { mkdir as mkdir5, readFile as readFile4, rename as rename3, writeFile as writeFile5 } from "node:fs/promises";
|
|
783
|
+
import { join as join7 } from "node:path";
|
|
784
|
+
var FILE = join7(HOME, "settings.json");
|
|
630
785
|
async function readSettings() {
|
|
631
|
-
const
|
|
632
|
-
return { invertScroll:
|
|
786
|
+
const saved2 = await readFile4(FILE, "utf8").then(JSON.parse).catch(() => null);
|
|
787
|
+
return { invertScroll: saved2?.invertScroll === true };
|
|
633
788
|
}
|
|
634
789
|
async function writeSettings(settings2) {
|
|
635
|
-
await
|
|
790
|
+
await mkdir5(HOME, { recursive: true });
|
|
636
791
|
const temporary = `${FILE}.${process.pid}`;
|
|
637
|
-
await
|
|
638
|
-
await
|
|
792
|
+
await writeFile5(temporary, JSON.stringify(settings2));
|
|
793
|
+
await rename3(temporary, FILE);
|
|
639
794
|
}
|
|
640
795
|
|
|
641
796
|
// src/daemon.ts
|
|
642
|
-
var UI = process.env.TWOMBLY_UI || join7(dirname2(fileURLToPath(import.meta.url)), "ui");
|
|
643
797
|
var IDLE_MS = 6 * 36e5;
|
|
644
798
|
var LOGIN_MS = 10 * 6e4;
|
|
645
799
|
var ID_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
|
|
@@ -682,6 +836,11 @@ async function edge(path, init = {}) {
|
|
|
682
836
|
if (!response.ok) throw new Failure(response.status, data.error || `The server answered ${response.status}`, data.problems ? { problems: data.problems } : void 0);
|
|
683
837
|
return data;
|
|
684
838
|
}
|
|
839
|
+
var STROKE_UPLOAD = 40;
|
|
840
|
+
var THREAD_UPLOAD_CHARS = 5e5;
|
|
841
|
+
var THREAD_ID = /^[a-z0-9]{8,32}$/;
|
|
842
|
+
var finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
843
|
+
var FRAME_SERVERS_IDLE_MS = Number(process.env.TWOMBLY_FRAME_IDLE_MS) || 5 * 6e4;
|
|
685
844
|
var Canvas = class {
|
|
686
845
|
id;
|
|
687
846
|
project;
|
|
@@ -691,7 +850,13 @@ var Canvas = class {
|
|
|
691
850
|
servers = /* @__PURE__ */ new Map();
|
|
692
851
|
queue = Promise.resolve();
|
|
693
852
|
saveTimer = null;
|
|
853
|
+
serversTimer = null;
|
|
854
|
+
writeTurn = inOrder();
|
|
694
855
|
workingUntil = 0;
|
|
856
|
+
agents = 0;
|
|
857
|
+
contexts = /* @__PURE__ */ new Map();
|
|
858
|
+
/** So undoing a delete in the page keeps where the frame came from. */
|
|
859
|
+
removedSources = /* @__PURE__ */ new Map();
|
|
695
860
|
constructor(project, data) {
|
|
696
861
|
this.project = project;
|
|
697
862
|
this.data = data;
|
|
@@ -707,13 +872,26 @@ var Canvas = class {
|
|
|
707
872
|
save() {
|
|
708
873
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
709
874
|
this.saveTimer = setTimeout(() => {
|
|
710
|
-
writeCanvas(this.project, this.data).catch(() => {
|
|
875
|
+
this.writeTurn(() => writeCanvas(this.project, this.data)).catch(() => {
|
|
711
876
|
});
|
|
712
877
|
}, 150);
|
|
713
878
|
}
|
|
714
879
|
async saveNow() {
|
|
715
880
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
716
|
-
await writeCanvas(this.project, this.data);
|
|
881
|
+
await this.writeTurn(() => writeCanvas(this.project, this.data));
|
|
882
|
+
}
|
|
883
|
+
/** Frame servers are only for people looking. They come back, on new ports, the next time someone does. */
|
|
884
|
+
closeServersWhenUnwatched() {
|
|
885
|
+
if (this.serversTimer) clearTimeout(this.serversTimer);
|
|
886
|
+
this.serversTimer = setTimeout(() => {
|
|
887
|
+
if (this.sockets.size) return;
|
|
888
|
+
for (const running of this.servers.values()) running.then((server) => server.close());
|
|
889
|
+
this.servers.clear();
|
|
890
|
+
}, FRAME_SERVERS_IDLE_MS);
|
|
891
|
+
}
|
|
892
|
+
/** Tells anyone looking about a frame, without starting its server for nobody. */
|
|
893
|
+
async announce(frame) {
|
|
894
|
+
if (this.sockets.size) this.broadcast({ t: "frame.upsert", frame: await this.toFrame(frame) });
|
|
717
895
|
}
|
|
718
896
|
serverFor(frame) {
|
|
719
897
|
const key = `${frame.id}:${frame.content}`;
|
|
@@ -748,9 +926,18 @@ var Canvas = class {
|
|
|
748
926
|
workingLeft() {
|
|
749
927
|
return Math.max(0, this.workingUntil - Date.now());
|
|
750
928
|
}
|
|
751
|
-
/**
|
|
752
|
-
|
|
753
|
-
|
|
929
|
+
/**
|
|
930
|
+
* An agent is at work for at least this long; zero means it's finished. Everyone looking at the canvas sees it.
|
|
931
|
+
* Agents have no identity, so each `init` counts as one more and it only stops once each has finished, or time runs out.
|
|
932
|
+
*/
|
|
933
|
+
working(ms, joining = false) {
|
|
934
|
+
if (!this.workingLeft()) this.agents = 0;
|
|
935
|
+
if (joining) this.agents++;
|
|
936
|
+
if (ms > 0) this.workingUntil = Math.max(this.workingUntil, Date.now() + ms);
|
|
937
|
+
else if (--this.agents <= 0) {
|
|
938
|
+
this.agents = 0;
|
|
939
|
+
this.workingUntil = 0;
|
|
940
|
+
}
|
|
754
941
|
const left = this.workingLeft();
|
|
755
942
|
this.broadcast({ t: "working", ms: left });
|
|
756
943
|
if (this.data.remote) edge(`/api/canvases/${this.data.remote.id}/working`, { method: "POST", body: JSON.stringify({ ms: left }) }).catch(() => {
|
|
@@ -765,7 +952,10 @@ var Canvas = class {
|
|
|
765
952
|
this.sockets.add(socket);
|
|
766
953
|
touch();
|
|
767
954
|
const self = randomId(10);
|
|
768
|
-
socket.on("close", () =>
|
|
955
|
+
socket.on("close", () => {
|
|
956
|
+
this.sockets.delete(socket);
|
|
957
|
+
this.closeServersWhenUnwatched();
|
|
958
|
+
});
|
|
769
959
|
socket.on("message", (raw) => {
|
|
770
960
|
let message;
|
|
771
961
|
try {
|
|
@@ -782,23 +972,105 @@ var Canvas = class {
|
|
|
782
972
|
t: "hello",
|
|
783
973
|
self,
|
|
784
974
|
role: "owner",
|
|
785
|
-
doc: { id: this.id, name: this.data.name, frames },
|
|
786
|
-
threads: [],
|
|
975
|
+
doc: { id: this.id, name: this.data.name, frames, strokes: this.data.strokes ?? [] },
|
|
976
|
+
threads: this.data.threads ?? [],
|
|
787
977
|
peers: [this.peer(self)],
|
|
788
|
-
|
|
978
|
+
// Shared but signed out, this is a copy the room can't hear about.
|
|
979
|
+
capabilities: { comments: !this.data.remote, huddle: false },
|
|
789
980
|
working: this.workingLeft()
|
|
790
981
|
}));
|
|
791
982
|
}
|
|
792
983
|
attachEvents(socket) {
|
|
793
984
|
this.events.add(socket);
|
|
794
|
-
socket.on("close", () =>
|
|
985
|
+
socket.on("close", () => {
|
|
986
|
+
this.events.delete(socket);
|
|
987
|
+
this.contexts.delete(socket);
|
|
988
|
+
});
|
|
989
|
+
socket.on("message", (raw) => {
|
|
990
|
+
let message;
|
|
991
|
+
try {
|
|
992
|
+
message = JSON.parse(String(raw));
|
|
993
|
+
} catch {
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const context = message?.t === "context" ? cleanContext(message.context) : null;
|
|
997
|
+
if (context) this.contexts.set(socket, { context, at: Date.now() });
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
/** What the person means by "this", from whichever of their windows changed it last. Null when none is open. */
|
|
1001
|
+
context() {
|
|
1002
|
+
let latest = null;
|
|
1003
|
+
for (const entry of this.contexts.values()) if (!latest || entry.at > latest.at) latest = entry;
|
|
1004
|
+
return latest?.context ?? null;
|
|
1005
|
+
}
|
|
1006
|
+
// ---------- comments, while local ----------
|
|
1007
|
+
author() {
|
|
1008
|
+
const me = credential?.me, id = me?.id || "local";
|
|
1009
|
+
return { id, name: me?.name || "You", picture: me?.picture || null, color: colorOf(id) };
|
|
1010
|
+
}
|
|
1011
|
+
thread(id) {
|
|
1012
|
+
return (this.data.threads ?? []).find((thread) => thread.id === id);
|
|
1013
|
+
}
|
|
1014
|
+
putThread(thread) {
|
|
1015
|
+
const threads = this.data.threads ??= [], index = threads.findIndex((item) => item.id === thread.id);
|
|
1016
|
+
if (index < 0) threads.push(thread);
|
|
1017
|
+
else threads[index] = thread;
|
|
1018
|
+
this.broadcast({ t: "thread", thread });
|
|
1019
|
+
this.save();
|
|
1020
|
+
}
|
|
1021
|
+
dropThread(id) {
|
|
1022
|
+
this.data.threads = (this.data.threads ?? []).filter((thread) => thread.id !== id);
|
|
1023
|
+
this.broadcast({ t: "thread.remove", id });
|
|
1024
|
+
this.save();
|
|
1025
|
+
}
|
|
1026
|
+
frameIdOf(id) {
|
|
1027
|
+
return typeof id === "string" && this.data.frames.some((frame) => frame.id === id) ? id : null;
|
|
1028
|
+
}
|
|
1029
|
+
handleComment(m) {
|
|
1030
|
+
const text2 = (value) => typeof value === "string" ? value.trim().slice(0, LIMITS.commentChars) : "";
|
|
1031
|
+
const comment = (value) => ({ id: randomId(12), author: this.author(), text: value, createdAt: Date.now() });
|
|
1032
|
+
switch (m.t) {
|
|
1033
|
+
case "thread.create": {
|
|
1034
|
+
const value = text2(m.text);
|
|
1035
|
+
if (!value || !finite(m.x) || !finite(m.y) || typeof m.id !== "string" || !THREAD_ID.test(m.id) || this.thread(m.id)) return;
|
|
1036
|
+
const first = comment(value);
|
|
1037
|
+
return this.putThread({ id: m.id, frameId: this.frameIdOf(m.frameId), x: m.x, y: m.y, resolved: false, createdAt: first.createdAt, comments: [first] });
|
|
1038
|
+
}
|
|
1039
|
+
case "thread.move": {
|
|
1040
|
+
const thread = this.thread(m.id);
|
|
1041
|
+
if (thread && finite(m.x) && finite(m.y)) this.putThread({ ...thread, frameId: this.frameIdOf(m.frameId), x: m.x, y: m.y });
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
case "thread.resolve": {
|
|
1045
|
+
const thread = this.thread(m.id);
|
|
1046
|
+
if (thread) this.putThread({ ...thread, resolved: !!m.resolved });
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
case "thread.delete":
|
|
1050
|
+
if (this.thread(m.id)) this.dropThread(m.id);
|
|
1051
|
+
return;
|
|
1052
|
+
case "comment.add": {
|
|
1053
|
+
const thread = this.thread(m.threadId), value = text2(m.text);
|
|
1054
|
+
if (thread && value) this.putThread({ ...thread, comments: [...thread.comments, comment(value)] });
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
case "comment.delete": {
|
|
1058
|
+
const thread = this.thread(m.threadId);
|
|
1059
|
+
if (!thread || !thread.comments.some((item) => item.id === m.id)) return;
|
|
1060
|
+
if (thread.comments[0].id === m.id) return this.dropThread(thread.id);
|
|
1061
|
+
this.putThread({ ...thread, comments: thread.comments.filter((item) => item.id !== m.id) });
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
795
1064
|
}
|
|
796
1065
|
async handle(socket, m) {
|
|
797
1066
|
if (m.t === "ping") return socket.send('{"t":"pong"}');
|
|
798
1067
|
if (this.data.remote) return;
|
|
799
1068
|
if (m.t === "frame.patch") return this.applyPatch(m.id, m.patch);
|
|
1069
|
+
if (m.t.startsWith("thread.") || m.t.startsWith("comment.")) return this.handleComment(m);
|
|
800
1070
|
if (m.t === "frame.remove") {
|
|
801
1071
|
const before = this.data.frames.length;
|
|
1072
|
+
const removed = this.data.frames.find((frame) => frame.id === m.id);
|
|
1073
|
+
if (removed) this.removedSources.set(removed.id, removed.source);
|
|
802
1074
|
this.data.frames = this.data.frames.filter((frame) => frame.id !== m.id);
|
|
803
1075
|
if (this.data.frames.length !== before) {
|
|
804
1076
|
this.broadcast({ t: "frame.remove", id: m.id });
|
|
@@ -809,9 +1081,25 @@ var Canvas = class {
|
|
|
809
1081
|
if (m.t === "frame.restore") {
|
|
810
1082
|
const f = m.frame;
|
|
811
1083
|
if (this.data.frames.some((frame) => frame.id === f.id)) return;
|
|
812
|
-
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() };
|
|
1084
|
+
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(), source: this.removedSources.get(f.id) };
|
|
813
1085
|
this.data.frames.push(restored);
|
|
814
|
-
|
|
1086
|
+
await this.announce(restored);
|
|
1087
|
+
this.save();
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
if (m.t === "stroke.add") {
|
|
1091
|
+
const strokes = this.data.strokes ??= [];
|
|
1092
|
+
const clean = cleanStroke(m.stroke);
|
|
1093
|
+
if (!clean || strokes.length >= LIMITS.canvasStrokes && !strokes.some((item) => item.id === clean.id)) return this.broadcast({ t: "stroke.remove", id: String(m.stroke?.id) });
|
|
1094
|
+
const stroke = { ...clean, authorId: "local", createdAt: Date.now() };
|
|
1095
|
+
this.data.strokes = [...strokes.filter((item) => item.id !== stroke.id), stroke];
|
|
1096
|
+
this.broadcast({ t: "strokes", strokes: [stroke] });
|
|
1097
|
+
this.save();
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
if (m.t === "stroke.remove") {
|
|
1101
|
+
this.data.strokes = (this.data.strokes ?? []).filter((item) => item.id !== m.id);
|
|
1102
|
+
this.broadcast({ t: "stroke.remove", id: m.id });
|
|
815
1103
|
this.save();
|
|
816
1104
|
return;
|
|
817
1105
|
}
|
|
@@ -832,16 +1120,16 @@ var Canvas = class {
|
|
|
832
1120
|
frame.w = Math.max(40, Math.min(8e3, frame.w));
|
|
833
1121
|
frame.h = Math.max(40, Math.min(8e3, frame.h));
|
|
834
1122
|
frame.updatedAt = Date.now();
|
|
835
|
-
|
|
1123
|
+
await this.announce(frame);
|
|
836
1124
|
this.save();
|
|
837
1125
|
return frame;
|
|
838
1126
|
}
|
|
839
1127
|
/** Once shared, the room is the truth. Bring the local list up to date with it. */
|
|
840
1128
|
async pull() {
|
|
841
1129
|
if (!this.data.remote) return;
|
|
842
|
-
let doc;
|
|
1130
|
+
let doc, threads;
|
|
843
1131
|
try {
|
|
844
|
-
({ doc } = await edge(`/api/canvases/${this.data.remote.id}`));
|
|
1132
|
+
({ doc, threads } = await edge(`/api/canvases/${this.data.remote.id}`));
|
|
845
1133
|
} catch (error) {
|
|
846
1134
|
if (error instanceof Failure && (error.status === 404 || error.status === 403)) {
|
|
847
1135
|
this.data.remote = null;
|
|
@@ -852,6 +1140,8 @@ var Canvas = class {
|
|
|
852
1140
|
throw error;
|
|
853
1141
|
}
|
|
854
1142
|
this.data.name = doc.name;
|
|
1143
|
+
if (doc.strokes) this.data.strokes = doc.strokes;
|
|
1144
|
+
if (threads) this.data.threads = threads;
|
|
855
1145
|
const known = new Map(this.data.frames.map((frame) => [frame.id, frame]));
|
|
856
1146
|
this.data.frames = doc.frames.map((frame) => ({
|
|
857
1147
|
id: frame.id,
|
|
@@ -865,7 +1155,8 @@ var Canvas = class {
|
|
|
865
1155
|
content: frame.content,
|
|
866
1156
|
level: frame.level,
|
|
867
1157
|
hasPoster: known.get(frame.id)?.hasPoster ?? !!frame.posterUrl,
|
|
868
|
-
updatedAt: frame.updatedAt
|
|
1158
|
+
updatedAt: frame.updatedAt,
|
|
1159
|
+
source: known.get(frame.id)?.source
|
|
869
1160
|
}));
|
|
870
1161
|
await this.saveNow();
|
|
871
1162
|
}
|
|
@@ -910,6 +1201,23 @@ var Canvas = class {
|
|
|
910
1201
|
this.event({ t: "share-progress", done: ++done, total });
|
|
911
1202
|
});
|
|
912
1203
|
}
|
|
1204
|
+
const strokes = this.data.strokes ?? [];
|
|
1205
|
+
for (let i = 0; i < strokes.length; i += STROKE_UPLOAD) {
|
|
1206
|
+
await edge(`/api/canvases/${created.id}/strokes`, { method: "POST", body: JSON.stringify({ strokes: strokes.slice(i, i + STROKE_UPLOAD) }) });
|
|
1207
|
+
}
|
|
1208
|
+
let batch = [], chars = 0;
|
|
1209
|
+
const send = async () => {
|
|
1210
|
+
if (batch.length) await edge(`/api/canvases/${created.id}/threads`, { method: "POST", body: JSON.stringify({ threads: batch }) });
|
|
1211
|
+
batch = [];
|
|
1212
|
+
chars = 0;
|
|
1213
|
+
};
|
|
1214
|
+
for (const thread of this.data.threads ?? []) {
|
|
1215
|
+
const size2 = JSON.stringify(thread).length;
|
|
1216
|
+
if (chars + size2 > THREAD_UPLOAD_CHARS) await send();
|
|
1217
|
+
batch.push(thread);
|
|
1218
|
+
chars += size2;
|
|
1219
|
+
}
|
|
1220
|
+
await send();
|
|
913
1221
|
} catch (error) {
|
|
914
1222
|
await edge(`/api/canvases/${created.id}`, { method: "DELETE" }).catch(() => {
|
|
915
1223
|
});
|
|
@@ -922,8 +1230,8 @@ var Canvas = class {
|
|
|
922
1230
|
}
|
|
923
1231
|
async unshare() {
|
|
924
1232
|
if (!this.data.remote) return;
|
|
925
|
-
await this.pull()
|
|
926
|
-
|
|
1233
|
+
await this.pull();
|
|
1234
|
+
if (!this.data.remote) return;
|
|
927
1235
|
await edge(`/api/canvases/${this.data.remote.id}`, { method: "DELETE" }).catch((error) => {
|
|
928
1236
|
if (error.status !== 404) throw error;
|
|
929
1237
|
});
|
|
@@ -933,16 +1241,24 @@ var Canvas = class {
|
|
|
933
1241
|
}
|
|
934
1242
|
};
|
|
935
1243
|
var canvases = /* @__PURE__ */ new Map();
|
|
936
|
-
|
|
1244
|
+
var opening = /* @__PURE__ */ new Map();
|
|
1245
|
+
function canvasById(id) {
|
|
937
1246
|
const open = canvases.get(id);
|
|
938
|
-
if (open) return open;
|
|
939
|
-
|
|
940
|
-
if (!
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1247
|
+
if (open) return Promise.resolve(open);
|
|
1248
|
+
let pending = opening.get(id);
|
|
1249
|
+
if (!pending) {
|
|
1250
|
+
pending = (async () => {
|
|
1251
|
+
const project = (await readRegistry())[id];
|
|
1252
|
+
if (!project) throw new Failure(404, "That canvas is not on this machine");
|
|
1253
|
+
const data = await readCanvas(project).catch(() => null);
|
|
1254
|
+
if (!data || data.id !== id) throw new Failure(404, "That canvas is not on this machine");
|
|
1255
|
+
const canvas = new Canvas(project, data);
|
|
1256
|
+
canvases.set(id, canvas);
|
|
1257
|
+
return canvas;
|
|
1258
|
+
})().finally(() => opening.delete(id));
|
|
1259
|
+
opening.set(id, pending);
|
|
1260
|
+
}
|
|
1261
|
+
return pending;
|
|
946
1262
|
}
|
|
947
1263
|
async function canvasForProject(project) {
|
|
948
1264
|
const found = await findProject(project);
|
|
@@ -964,8 +1280,36 @@ var summary = (canvas) => ({
|
|
|
964
1280
|
name: canvas.data.name,
|
|
965
1281
|
local: `${DAEMON_ORIGIN}/${canvas.id}`,
|
|
966
1282
|
link: canvas.data.remote?.url || null,
|
|
967
|
-
frames: canvas.data.frames.map(({ id, title, x, y, w, h, entry, level: level2 }) => ({ id, title, x, y, w, h, entry, level: level2 }))
|
|
1283
|
+
frames: canvas.data.frames.map(({ id, title, x, y, w, h, entry, level: level2, content, source }) => ({ id, title, x, y, w, h, entry, level: level2, content, source: source ?? null }))
|
|
968
1284
|
});
|
|
1285
|
+
function cleanContext(input) {
|
|
1286
|
+
const c = input;
|
|
1287
|
+
if (!c || typeof c !== "object" || !Array.isArray(c.frames)) return null;
|
|
1288
|
+
const via = c.via === "fullscreen" || c.via === "interacting" || c.via === "selected" ? c.via : null;
|
|
1289
|
+
const frames = c.frames.filter((id) => typeof id === "string").slice(0, LIMITS.canvasFrames);
|
|
1290
|
+
return { via, frames, thread: typeof c.thread === "string" ? c.thread : null };
|
|
1291
|
+
}
|
|
1292
|
+
function commentsOf(canvas, thread) {
|
|
1293
|
+
const me = credential?.me?.id;
|
|
1294
|
+
const frame = thread.frameId ? canvas.data.frames.find((item) => item.id === thread.frameId) : void 0;
|
|
1295
|
+
return {
|
|
1296
|
+
id: thread.id,
|
|
1297
|
+
frame: frame ? { id: frame.id, title: frame.title } : null,
|
|
1298
|
+
// Where the pin is: in the frame's own pixels, or on the open canvas.
|
|
1299
|
+
at: frame ? { x: Math.round(thread.x * frame.w), y: Math.round(thread.y * frame.h) } : { x: Math.round(thread.x), y: Math.round(thread.y) },
|
|
1300
|
+
resolved: thread.resolved,
|
|
1301
|
+
comments: thread.comments.map((comment) => ({ by: comment.author.name, mine: comment.author.id === "local" || comment.author.id === me, text: comment.text, at: new Date(comment.createdAt).toISOString() }))
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
var visibleThreads = (canvas) => (canvas.data.threads ?? []).filter((thread) => !thread.frameId || canvas.data.frames.some((frame) => frame.id === thread.frameId));
|
|
1305
|
+
function contextOf(canvas) {
|
|
1306
|
+
const context = canvas.context();
|
|
1307
|
+
if (!context) return { looking: false, via: null, frames: [], comment: null };
|
|
1308
|
+
const frames = context.frames.flatMap((id) => canvas.data.frames.filter((frame) => frame.id === id)).map(({ id, title, w, h, entry, source }) => ({ id, title, w, h, entry, source: source ?? null }));
|
|
1309
|
+
const thread = visibleThreads(canvas).find((item) => item.id === context.thread);
|
|
1310
|
+
return { looking: true, via: frames.length ? context.via : null, frames, comment: thread ? commentsOf(canvas, thread) : null };
|
|
1311
|
+
}
|
|
1312
|
+
var initTurn = inOrder();
|
|
969
1313
|
function findFrame(canvas, ref) {
|
|
970
1314
|
const byId = canvas.data.frames.find((frame) => frame.id === ref);
|
|
971
1315
|
if (byId) return byId;
|
|
@@ -976,6 +1320,7 @@ function findFrame(canvas, ref) {
|
|
|
976
1320
|
async function command(name, input) {
|
|
977
1321
|
touch();
|
|
978
1322
|
if (name === "stop") {
|
|
1323
|
+
if (input.ifIdle && inFlight > 1) return { stopped: false };
|
|
979
1324
|
setTimeout(() => process.exit(0), 50);
|
|
980
1325
|
return { stopped: true };
|
|
981
1326
|
}
|
|
@@ -998,18 +1343,21 @@ async function command(name, input) {
|
|
|
998
1343
|
return { signedIn: false };
|
|
999
1344
|
}
|
|
1000
1345
|
if (name === "init") {
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
found
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1346
|
+
return initTurn(async () => {
|
|
1347
|
+
const project = input.project;
|
|
1348
|
+
const nearest = await findProject(project);
|
|
1349
|
+
let found = input.exact && nearest !== project ? null : nearest;
|
|
1350
|
+
const created = !found;
|
|
1351
|
+
if (!found) {
|
|
1352
|
+
await ensureProjectDir(project);
|
|
1353
|
+
await writeCanvas(project, { version: 1, id: randomId(12), name: String(input.name || "Untitled").slice(0, LIMITS.nameChars), frames: [], remote: null });
|
|
1354
|
+
found = project;
|
|
1355
|
+
}
|
|
1356
|
+
const canvas2 = await canvasForProject(found);
|
|
1357
|
+
if (input.open !== false && !canvas2.watchers) openBrowser(`${DAEMON_ORIGIN}/${canvas2.id}`);
|
|
1358
|
+
canvas2.working(WORKING_MS, true);
|
|
1359
|
+
return { ...summary(canvas2), created };
|
|
1360
|
+
});
|
|
1013
1361
|
}
|
|
1014
1362
|
const canvas = await canvasForProject(input.project);
|
|
1015
1363
|
if (name === "working") return { working: canvas.working(Number(input.ms) || 0) > 0 };
|
|
@@ -1018,9 +1366,17 @@ async function command(name, input) {
|
|
|
1018
1366
|
switch (name) {
|
|
1019
1367
|
case "list":
|
|
1020
1368
|
return summary(canvas);
|
|
1369
|
+
case "context":
|
|
1370
|
+
return contextOf(canvas);
|
|
1371
|
+
case "comments":
|
|
1372
|
+
return { comments: visibleThreads(canvas).map((thread) => commentsOf(canvas, thread)) };
|
|
1021
1373
|
case "open":
|
|
1022
1374
|
openBrowser(`${DAEMON_ORIGIN}/${canvas.id}`);
|
|
1023
1375
|
return summary(canvas);
|
|
1376
|
+
case "preview": {
|
|
1377
|
+
const { id, title, w, h } = findFrame(canvas, input.frame);
|
|
1378
|
+
return { frame: { id, title, w, h }, url: `${DAEMON_ORIGIN}/preview/${canvas.id}/${id}` };
|
|
1379
|
+
}
|
|
1024
1380
|
case "place":
|
|
1025
1381
|
return place(canvas, input);
|
|
1026
1382
|
case "revise":
|
|
@@ -1054,7 +1410,7 @@ async function command(name, input) {
|
|
|
1054
1410
|
return summary(canvas);
|
|
1055
1411
|
}
|
|
1056
1412
|
case "rename":
|
|
1057
|
-
await
|
|
1413
|
+
await rename4(canvas, input.name);
|
|
1058
1414
|
return summary(canvas);
|
|
1059
1415
|
case "share": {
|
|
1060
1416
|
if (!credential) throw new Failure(401, "The person needs to sign in first. Ask them to click Share on the canvas.");
|
|
@@ -1069,7 +1425,7 @@ async function command(name, input) {
|
|
|
1069
1425
|
}
|
|
1070
1426
|
});
|
|
1071
1427
|
}
|
|
1072
|
-
async function
|
|
1428
|
+
async function rename4(canvas, name) {
|
|
1073
1429
|
const clean = String(name || "").trim().slice(0, LIMITS.nameChars);
|
|
1074
1430
|
if (!clean) throw new Failure(400, "Expected a name");
|
|
1075
1431
|
if (canvas.data.remote) await edge(`/api/canvases/${canvas.data.remote.id}`, { method: "PATCH", body: JSON.stringify({ name: clean }) });
|
|
@@ -1082,45 +1438,76 @@ async function patchFrame(canvas, id, patch) {
|
|
|
1082
1438
|
await canvas.applyPatch(id, patch);
|
|
1083
1439
|
await canvas.saveNow();
|
|
1084
1440
|
}
|
|
1441
|
+
async function install2(canvas, id, folder, screenshot) {
|
|
1442
|
+
await replaceFolder(folder, frameDir(canvas.project, id));
|
|
1443
|
+
if (screenshot) {
|
|
1444
|
+
const poster = posterFile(canvas.project, id);
|
|
1445
|
+
await mkdir6(dirname3(poster), { recursive: true });
|
|
1446
|
+
await copyFile(screenshot, poster);
|
|
1447
|
+
}
|
|
1448
|
+
return (await folderContent(frameDir(canvas.project, id))).content;
|
|
1449
|
+
}
|
|
1450
|
+
var placedFrame = ({ id, title, x, y, w, h }) => ({ id, title, x, y, w, h });
|
|
1451
|
+
var canvasResult = (canvas) => ({ local: `${DAEMON_ORIGIN}/${canvas.id}`, link: canvas.data.remote?.url || null, frames: canvas.data.frames.length });
|
|
1085
1452
|
async function place(canvas, input) {
|
|
1086
|
-
const
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1453
|
+
const count = canvas.data.frames.length;
|
|
1454
|
+
if (count + input.items.length > LIMITS.canvasFrames) throw new Failure(422, `A canvas can hold ${LIMITS.canvasFrames} frames, and this one has ${count}. Remove some, or start another: twombly init --canvas "Name"`);
|
|
1455
|
+
const frames = [];
|
|
1456
|
+
for (const item of input.items) {
|
|
1457
|
+
const title = String(item.title).slice(0, LIMITS.titleChars);
|
|
1458
|
+
const warnings = canvas.data.frames.some((other) => other.title === title) ? [`Another frame is already titled "${title}". Give each a distinct title, or use update to replace it.`] : [];
|
|
1459
|
+
const content = await install2(canvas, item.id, item.folder, item.screenshot);
|
|
1460
|
+
const position = nextPosition(canvas.data.frames, !!item.newRow);
|
|
1461
|
+
const frame = {
|
|
1462
|
+
id: item.id,
|
|
1463
|
+
title,
|
|
1464
|
+
w: item.w,
|
|
1465
|
+
h: item.h,
|
|
1466
|
+
x: typeof item.x === "number" ? item.x : position.x,
|
|
1467
|
+
y: typeof item.y === "number" ? item.y : position.y,
|
|
1468
|
+
z: canvas.data.frames.length,
|
|
1469
|
+
entry: item.entry,
|
|
1470
|
+
content,
|
|
1471
|
+
level: item.level,
|
|
1472
|
+
hasPoster: !!item.screenshot,
|
|
1473
|
+
updatedAt: Date.now(),
|
|
1474
|
+
source: typeof item.source === "string" ? item.source : void 0
|
|
1475
|
+
};
|
|
1476
|
+
if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, frame);
|
|
1477
|
+
canvas.data.frames.push(frame);
|
|
1478
|
+
await canvas.saveNow();
|
|
1479
|
+
await canvas.announce(frame);
|
|
1480
|
+
frames.push({ ...placedFrame(frame), warnings });
|
|
1481
|
+
}
|
|
1105
1482
|
canvas.working(WORKING_TAIL_MS);
|
|
1106
|
-
return {
|
|
1483
|
+
return { frames, canvas: canvasResult(canvas) };
|
|
1107
1484
|
}
|
|
1108
1485
|
async function revise(canvas, input) {
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1486
|
+
const targets = input.items.map((item) => {
|
|
1487
|
+
const frame = canvas.data.frames.find((other) => other.id === item.id);
|
|
1488
|
+
if (!frame) throw new Failure(404, `"${item.label}" is gone. Run: twombly list`);
|
|
1489
|
+
if (frame.content !== item.base) throw new Failure(409, `"${frame.title}" was updated by someone else while this was being checked, so nothing changed. Run twombly list, and update again only if you mean to replace their work.`);
|
|
1490
|
+
return frame;
|
|
1491
|
+
});
|
|
1492
|
+
const frames = [];
|
|
1493
|
+
for (const [index, item] of input.items.entries()) {
|
|
1494
|
+
const frame = targets[index];
|
|
1495
|
+
const next = { ...frame, updatedAt: Date.now() };
|
|
1496
|
+
if (item.title) next.title = String(item.title).slice(0, LIMITS.titleChars);
|
|
1497
|
+
for (const key of ["w", "h"]) if (typeof item[key] === "number") next[key] = item[key];
|
|
1498
|
+
if (item.entry) next.entry = item.entry;
|
|
1499
|
+
if (item.level) next.level = item.level;
|
|
1500
|
+
if (typeof item.source === "string") next.source = item.source;
|
|
1501
|
+
next.content = await install2(canvas, item.id, item.folder, item.screenshot);
|
|
1502
|
+
if (item.screenshot) next.hasPoster = true;
|
|
1503
|
+
if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, next);
|
|
1504
|
+
Object.assign(frame, next);
|
|
1505
|
+
await canvas.saveNow();
|
|
1506
|
+
await canvas.announce(frame);
|
|
1507
|
+
frames.push({ ...placedFrame(frame), warnings: [] });
|
|
1508
|
+
}
|
|
1122
1509
|
canvas.working(WORKING_TAIL_MS);
|
|
1123
|
-
return {
|
|
1510
|
+
return { frames, canvas: canvasResult(canvas) };
|
|
1124
1511
|
}
|
|
1125
1512
|
var JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
|
|
1126
1513
|
var reply = (response, status, value) => {
|
|
@@ -1167,9 +1554,20 @@ var APP_CSP = [
|
|
|
1167
1554
|
].join("; ");
|
|
1168
1555
|
async function serveUi(response, pathname) {
|
|
1169
1556
|
const clean = pathname.replace(/^\/+/, "");
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1557
|
+
let file2 = null;
|
|
1558
|
+
if (clean && !clean.includes("..")) {
|
|
1559
|
+
for (const folder of interfaceFolders()) {
|
|
1560
|
+
const candidate = join8(folder, clean);
|
|
1561
|
+
if ((await stat3(candidate).catch(() => null))?.isFile()) {
|
|
1562
|
+
file2 = candidate;
|
|
1563
|
+
break;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
if (!file2) {
|
|
1568
|
+
await freshInterface();
|
|
1569
|
+
file2 = join8(interfaceFolders()[0], "index.html");
|
|
1570
|
+
}
|
|
1173
1571
|
const type = contentType(file2);
|
|
1174
1572
|
const headers = { "content-type": type, "x-content-type-options": "nosniff", "cache-control": extname2(file2) === ".html" ? "no-cache" : "public, max-age=3600" };
|
|
1175
1573
|
if (type.startsWith("text/html")) {
|
|
@@ -1179,10 +1577,23 @@ async function serveUi(response, pathname) {
|
|
|
1179
1577
|
response.writeHead(200, headers);
|
|
1180
1578
|
createReadStream2(file2).pipe(response);
|
|
1181
1579
|
}
|
|
1580
|
+
var escapeHtml = (text2) => text2.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
1581
|
+
async function servePreview(response, canvasId, frameId) {
|
|
1582
|
+
const canvas = await canvasById(canvasId);
|
|
1583
|
+
const frame = await canvas.toFrame(findFrame(canvas, frameId));
|
|
1584
|
+
response.writeHead(200, {
|
|
1585
|
+
"content-type": "text/html; charset=utf-8",
|
|
1586
|
+
"cache-control": "no-store",
|
|
1587
|
+
"referrer-policy": "no-referrer",
|
|
1588
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-src http://localhost:*; base-uri 'none'; frame-ancestors 'none'"
|
|
1589
|
+
});
|
|
1590
|
+
const title = escapeHtml(frame.title);
|
|
1591
|
+
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>`);
|
|
1592
|
+
}
|
|
1182
1593
|
async function route(request, response) {
|
|
1183
1594
|
const url = new URL(request.url || "/", DAEMON_ORIGIN);
|
|
1184
1595
|
const path = url.pathname, method = request.method || "GET";
|
|
1185
|
-
if (path === "/health") return reply(response, 200, { ok: true, build: "0.
|
|
1596
|
+
if (path === "/health") return reply(response, 200, { ok: true, build: "0.3.0+1793910384715", api: TOOL_API, pid: process.pid });
|
|
1186
1597
|
if (path.startsWith("/cli/") && method === "POST") return reply(response, 200, await command(path.slice(5), await readBody(request)));
|
|
1187
1598
|
if (path === "/auth/callback" && method === "GET") {
|
|
1188
1599
|
const code = url.searchParams.get("code"), state = url.searchParams.get("state");
|
|
@@ -1233,8 +1644,11 @@ async function route(request, response) {
|
|
|
1233
1644
|
}
|
|
1234
1645
|
if (path.startsWith("/api/")) return reply(response, 404, { error: "Not found" });
|
|
1235
1646
|
if (method !== "GET" && method !== "HEAD") return reply(response, 405, { error: "Method not allowed" });
|
|
1647
|
+
const preview = path.match(/^\/preview\/([a-z0-9]+)\/([a-z0-9]+)$/);
|
|
1648
|
+
if (preview) return servePreview(response, preview[1], preview[2]);
|
|
1236
1649
|
return serveUi(response, path);
|
|
1237
1650
|
}
|
|
1651
|
+
var inFlight = 0;
|
|
1238
1652
|
var idleTimer = null;
|
|
1239
1653
|
function touch() {
|
|
1240
1654
|
if (idleTimer) clearTimeout(idleTimer);
|
|
@@ -1252,6 +1666,8 @@ async function runDaemon() {
|
|
|
1252
1666
|
response.end('{"error":"Forbidden"}');
|
|
1253
1667
|
return;
|
|
1254
1668
|
}
|
|
1669
|
+
const counted = /^\/(cli|api\/canvases)\//.test(request.url || "");
|
|
1670
|
+
if (counted) inFlight++;
|
|
1255
1671
|
try {
|
|
1256
1672
|
await route(request, response);
|
|
1257
1673
|
} catch (error) {
|
|
@@ -1262,6 +1678,8 @@ async function runDaemon() {
|
|
|
1262
1678
|
if (error instanceof Failure) return reply(response, error.status, { error: error.message, ...error.extra });
|
|
1263
1679
|
console.error(error);
|
|
1264
1680
|
reply(response, 500, { error: error instanceof Error ? error.message : "Something went wrong" });
|
|
1681
|
+
} finally {
|
|
1682
|
+
if (counted) inFlight--;
|
|
1265
1683
|
}
|
|
1266
1684
|
});
|
|
1267
1685
|
const sockets = new WebSocketServer({ noServer: true });
|
|
@@ -1286,17 +1704,19 @@ async function runDaemon() {
|
|
|
1286
1704
|
server.listen(PORT, "127.0.0.1", resolve3);
|
|
1287
1705
|
});
|
|
1288
1706
|
touch();
|
|
1707
|
+
startInterfaceUpdates().catch(() => {
|
|
1708
|
+
});
|
|
1289
1709
|
}
|
|
1290
1710
|
|
|
1291
1711
|
// src/gates.ts
|
|
1292
1712
|
import { createServer as createServer3 } from "node:http";
|
|
1293
|
-
import { mkdir as
|
|
1294
|
-
import { dirname as
|
|
1713
|
+
import { mkdir as mkdir7, readFile as readFile6, stat as stat4, writeFile as writeFile6 } from "node:fs/promises";
|
|
1714
|
+
import { dirname as dirname4 } from "node:path";
|
|
1295
1715
|
var dedupe = (problems) => [...new Map(problems.map((p) => [`${p.code}:${p.file || ""}:${p.message}`, p])).values()];
|
|
1296
|
-
async function
|
|
1716
|
+
async function scanFolder(folder) {
|
|
1297
1717
|
const problems = [];
|
|
1298
1718
|
const info = await stat4(folder).catch(() => null);
|
|
1299
|
-
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:
|
|
1719
|
+
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 };
|
|
1300
1720
|
const files = await hashFolder(folder);
|
|
1301
1721
|
let bytes = 0;
|
|
1302
1722
|
for (const file2 of files) {
|
|
@@ -1314,13 +1734,26 @@ async function staticGates(folder, entry) {
|
|
|
1314
1734
|
const largest = [...files].sort((a, b) => b.size - a.size).slice(0, 5).map((file2) => `${file2.path} (${(file2.size / 1048576).toFixed(1)} MB)`).join(", ");
|
|
1315
1735
|
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." });
|
|
1316
1736
|
}
|
|
1737
|
+
return { problems, files, bytes };
|
|
1738
|
+
}
|
|
1739
|
+
function entryProblems(files, entry) {
|
|
1317
1740
|
const entryPath = entry.split(/[?#]/)[0];
|
|
1318
1741
|
const candidates = candidatePaths("/" + entryPath);
|
|
1319
|
-
if (
|
|
1320
|
-
return {
|
|
1742
|
+
if (files.some((file2) => candidates.includes(file2.path))) return [];
|
|
1743
|
+
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." }];
|
|
1744
|
+
}
|
|
1745
|
+
async function findReferences(folder, needle) {
|
|
1746
|
+
const found = [];
|
|
1747
|
+
for (const file2 of await hashFolder(folder)) {
|
|
1748
|
+
const bytes = await readFile6(file2.absolute);
|
|
1749
|
+
if (looksLikeText(bytes) && bytes.includes(needle)) found.push(file2.path);
|
|
1750
|
+
if (found.length === 3) break;
|
|
1751
|
+
}
|
|
1752
|
+
return found;
|
|
1321
1753
|
}
|
|
1322
|
-
async function browserGates(folder, options) {
|
|
1754
|
+
async function browserGates(folder, options, launch) {
|
|
1323
1755
|
const problems = [], warnings = [];
|
|
1756
|
+
const traces = /* @__PURE__ */ new Map();
|
|
1324
1757
|
const server = await startFrameServer(folder);
|
|
1325
1758
|
const width = options.width, height = options.height === "auto" ? 800 : options.height;
|
|
1326
1759
|
const harness = createServer3((_, response) => {
|
|
@@ -1331,7 +1764,7 @@ async function browserGates(folder, options) {
|
|
|
1331
1764
|
const harnessOrigin = `http://localhost:${harness.address().port}`;
|
|
1332
1765
|
let browser;
|
|
1333
1766
|
try {
|
|
1334
|
-
browser = await
|
|
1767
|
+
browser = await launch();
|
|
1335
1768
|
} catch {
|
|
1336
1769
|
harness.close();
|
|
1337
1770
|
server.close();
|
|
@@ -1347,7 +1780,9 @@ async function browserGates(folder, options) {
|
|
|
1347
1780
|
host = new URL(url).host;
|
|
1348
1781
|
} catch {
|
|
1349
1782
|
}
|
|
1350
|
-
|
|
1783
|
+
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." };
|
|
1784
|
+
problems.push(problem);
|
|
1785
|
+
traces.set(problem, host);
|
|
1351
1786
|
};
|
|
1352
1787
|
page.on("pageerror", (error) => {
|
|
1353
1788
|
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." });
|
|
@@ -1368,7 +1803,9 @@ async function browserGates(folder, options) {
|
|
|
1368
1803
|
const url = response.url();
|
|
1369
1804
|
if (!isFrameOrigin(url) || response.status() < 400 || url.endsWith("/favicon.ico")) return;
|
|
1370
1805
|
const path = new URL(url).pathname;
|
|
1371
|
-
|
|
1806
|
+
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) };
|
|
1807
|
+
problems.push(problem);
|
|
1808
|
+
traces.set(problem, path.slice(1));
|
|
1372
1809
|
});
|
|
1373
1810
|
page.on("requestfailed", (request) => {
|
|
1374
1811
|
const url = request.url();
|
|
@@ -1401,8 +1838,8 @@ async function browserGates(folder, options) {
|
|
|
1401
1838
|
const shot = await page.locator("#frame").screenshot({ type: "png", timeout: 15e3 }).catch(() => null);
|
|
1402
1839
|
if (shot) {
|
|
1403
1840
|
if (options.poster) {
|
|
1404
|
-
await
|
|
1405
|
-
await
|
|
1841
|
+
await mkdir7(dirname4(options.poster), { recursive: true });
|
|
1842
|
+
await writeFile6(options.poster, shot);
|
|
1406
1843
|
}
|
|
1407
1844
|
const colors = await page.evaluate(async (base64) => {
|
|
1408
1845
|
const image = new Image();
|
|
@@ -1423,40 +1860,84 @@ async function browserGates(folder, options) {
|
|
|
1423
1860
|
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.`);
|
|
1424
1861
|
await context.close();
|
|
1425
1862
|
const unique = dedupe(problems);
|
|
1863
|
+
for (const problem of unique) {
|
|
1864
|
+
const trace = traces.get(problem);
|
|
1865
|
+
const references = trace ? await findReferences(folder, trace) : [];
|
|
1866
|
+
if (references.length) problem.message += ` Mentioned in ${references.join(", ")}.`;
|
|
1867
|
+
}
|
|
1426
1868
|
return { ok: unique.length === 0, problems: unique, warnings: [...new Set(warnings)], content, height: finalHeight, screenshot: options.poster };
|
|
1427
1869
|
} finally {
|
|
1428
|
-
await browser.close().catch(() => {
|
|
1429
|
-
});
|
|
1430
1870
|
harness.closeAllConnections();
|
|
1431
1871
|
harness.close();
|
|
1432
1872
|
server.close();
|
|
1433
1873
|
}
|
|
1434
1874
|
}
|
|
1875
|
+
function gateSession() {
|
|
1876
|
+
const scans = /* @__PURE__ */ new Map();
|
|
1877
|
+
let browser = null;
|
|
1878
|
+
const launch = () => browser ??= launchBrowser();
|
|
1879
|
+
return {
|
|
1880
|
+
async run(folder, options) {
|
|
1881
|
+
if (!scans.has(folder)) scans.set(folder, scanFolder(folder));
|
|
1882
|
+
const scan = await scans.get(folder);
|
|
1883
|
+
const size2 = { files: scan.files.length, bytes: scan.bytes };
|
|
1884
|
+
const notFolder = scan.problems.some((problem) => problem.code === "NOT_A_FOLDER");
|
|
1885
|
+
const problems = notFolder ? scan.problems : [...scan.problems, ...entryProblems(scan.files, options.entry)];
|
|
1886
|
+
if (problems.length) return { ok: false, problems: dedupe(problems), warnings: [], ...size2 };
|
|
1887
|
+
return { ...await browserGates(folder, options, launch), ...size2 };
|
|
1888
|
+
},
|
|
1889
|
+
close: async () => {
|
|
1890
|
+
await browser?.then((open) => open.close()).catch(() => {
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1435
1895
|
async function runGates(folder, options) {
|
|
1436
|
-
const
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1896
|
+
const session = gateSession();
|
|
1897
|
+
try {
|
|
1898
|
+
return await session.run(folder, options);
|
|
1899
|
+
} finally {
|
|
1900
|
+
await session.close();
|
|
1901
|
+
}
|
|
1440
1902
|
}
|
|
1441
1903
|
|
|
1442
1904
|
// src/cli.ts
|
|
1443
|
-
var HELP = `twombly ${"0.
|
|
1905
|
+
var HELP = `twombly ${"0.3.0"}: put design explorations on a shared canvas.
|
|
1444
1906
|
|
|
1445
|
-
init [--name "Name"] [--no-open] create or
|
|
1907
|
+
init [--name "Name"] [--no-open] create or join this folder's canvas
|
|
1908
|
+
init --canvas "Name" [--no-open] create or join a separate named canvas in this folder
|
|
1909
|
+
canvases list the canvases this folder can reach
|
|
1910
|
+
--canvas "Name" works on every command below, and picks that canvas
|
|
1446
1911
|
add <folder|file> --title "T" run the gates, then place a frame
|
|
1447
1912
|
[--width 1280] [--height 800|auto] [--entry index.html] [--level build|component|capture|image]
|
|
1448
1913
|
[--new-row] [--x N --y N]
|
|
1914
|
+
[--frames '[{"title":"A","entry":"index.html?v=a"}]'] several at once, as JSON or a JSON file
|
|
1449
1915
|
update <frame> <folder|file> replace a frame's contents, keeping its comments
|
|
1450
1916
|
[--title "T"] [--width N] [--height N|auto] [--entry path] [--level ...]
|
|
1451
|
-
|
|
1917
|
+
update <folder> --frames '[{"frame":"A"},{"frame":"B"}]' several at once
|
|
1918
|
+
check <folder> run the gates without placing anything
|
|
1452
1919
|
capture <url> --out <folder> freeze a running page into a static folder
|
|
1453
1920
|
[--width 1280] [--height 800] [--image] [--selector css]
|
|
1921
|
+
preview <frame> a page with just that frame, for testing interactions
|
|
1454
1922
|
working | done show on the canvas that you're building, or that you've finished
|
|
1923
|
+
context what the person has selected, open fullscreen or open as a comment
|
|
1924
|
+
comments every comment, and which are the person's own
|
|
1455
1925
|
list | move <frame> --x N --y N | resize <frame> --width N --height N
|
|
1456
1926
|
retitle <frame> "T" | remove <frame> | arrange [--columns N] | rename "Name"
|
|
1457
1927
|
open | status | login | logout | stop
|
|
1458
1928
|
share | unshare only when the person asks: this publishes a public link
|
|
1459
1929
|
guide how to adapt a project into frames. Read this first.`;
|
|
1930
|
+
var SWITCHES = /* @__PURE__ */ new Set(["no-open", "new-row", "image"]);
|
|
1931
|
+
var FLAGS = {
|
|
1932
|
+
init: ["name", "no-open"],
|
|
1933
|
+
add: ["title", "width", "height", "entry", "level", "new-row", "x", "y", "frames"],
|
|
1934
|
+
update: ["title", "width", "height", "entry", "level", "frames"],
|
|
1935
|
+
check: ["entry", "width", "height"],
|
|
1936
|
+
capture: ["out", "width", "height", "image", "selector", "wait"],
|
|
1937
|
+
move: ["x", "y"],
|
|
1938
|
+
resize: ["width", "height"],
|
|
1939
|
+
arrange: ["columns"]
|
|
1940
|
+
};
|
|
1460
1941
|
function parse(argv) {
|
|
1461
1942
|
const positional = [], flags = {};
|
|
1462
1943
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -1471,13 +1952,19 @@ function parse(argv) {
|
|
|
1471
1952
|
continue;
|
|
1472
1953
|
}
|
|
1473
1954
|
const next = argv[i + 1];
|
|
1474
|
-
if (next !== void 0 && !next.startsWith("--")) {
|
|
1955
|
+
if (!SWITCHES.has(arg.slice(2)) && next !== void 0 && !next.startsWith("--")) {
|
|
1475
1956
|
flags[arg.slice(2)] = next;
|
|
1476
1957
|
i++;
|
|
1477
1958
|
} else flags[arg.slice(2)] = true;
|
|
1478
1959
|
}
|
|
1479
1960
|
return { positional, flags };
|
|
1480
1961
|
}
|
|
1962
|
+
function checkFlags(name, flags) {
|
|
1963
|
+
const known = name === "canvases" ? [] : [...FLAGS[name] || [], "canvas"];
|
|
1964
|
+
for (const flag of Object.keys(flags)) {
|
|
1965
|
+
if (!known.includes(flag)) fail(`Unknown flag --${flag}. ${known.length ? `"${name}" takes: ${known.map((item) => `--${item}`).join(", ")}.` : `"${name}" takes no flags.`}`);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1481
1968
|
var print = (value) => process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
1482
1969
|
function fail(message, extra = {}) {
|
|
1483
1970
|
print({ ok: false, error: message, ...extra });
|
|
@@ -1513,18 +2000,31 @@ async function health() {
|
|
|
1513
2000
|
return null;
|
|
1514
2001
|
}
|
|
1515
2002
|
}
|
|
2003
|
+
var builtAt = (build) => Number(build.split("+")[1]) || 0;
|
|
2004
|
+
var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
2005
|
+
var IDLE_WAIT_MS = 6e4;
|
|
2006
|
+
var usable = (running) => !!running && (running.build === "0.3.0+1793910384715" || running.api === TOOL_API && builtAt(running.build) > builtAt("0.3.0+1793910384715"));
|
|
1516
2007
|
async function ensureDaemon() {
|
|
1517
2008
|
const running = await health();
|
|
1518
|
-
if (running
|
|
2009
|
+
if (usable(running)) return;
|
|
1519
2010
|
if (running) {
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
2011
|
+
if (builtAt(running.build) > builtAt("0.3.0+1793910384715")) fail("A newer twombly is running on this machine. Run this with twombly@latest.");
|
|
2012
|
+
let stopped = false;
|
|
2013
|
+
for (const end = Date.now() + IDLE_WAIT_MS; !stopped && Date.now() < end; ) {
|
|
2014
|
+
const answer = await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: '{"ifIdle":true}' }).then((response) => response.json()).catch(() => ({ stopped: true }));
|
|
2015
|
+
stopped = !!answer.stopped;
|
|
2016
|
+
if (!stopped) await sleep(500);
|
|
2017
|
+
}
|
|
2018
|
+
if (!stopped) {
|
|
2019
|
+
if (running.api === TOOL_API) return;
|
|
2020
|
+
fail("The background server is busy with another agent's work. Try again in a minute.");
|
|
2021
|
+
}
|
|
2022
|
+
await sleep(200);
|
|
1523
2023
|
}
|
|
1524
2024
|
spawn2(process.execPath, [fileURLToPath2(import.meta.url), "__daemon"], { detached: true, stdio: "ignore" }).unref();
|
|
1525
2025
|
for (let attempt = 0; attempt < 60; attempt++) {
|
|
1526
|
-
await
|
|
1527
|
-
if ((await health())
|
|
2026
|
+
await sleep(100);
|
|
2027
|
+
if (usable(await health())) return;
|
|
1528
2028
|
}
|
|
1529
2029
|
fail(`Could not start the background server on port ${PORT}. Is something else using it?`);
|
|
1530
2030
|
}
|
|
@@ -1543,32 +2043,181 @@ async function prepare(source) {
|
|
|
1543
2043
|
const extension = extname3(source).toLowerCase();
|
|
1544
2044
|
const isImage = IMAGE.has(extension);
|
|
1545
2045
|
if (!isImage && extension !== ".html" && extension !== ".htm") fail("Point at a folder of static files, an HTML file or an image.");
|
|
1546
|
-
const temporary = await mkdtemp(
|
|
2046
|
+
const temporary = await mkdtemp(join9(tmpdir(), "twombly-"));
|
|
1547
2047
|
if (isImage) {
|
|
1548
2048
|
const name = `image${extension}`;
|
|
1549
|
-
await
|
|
1550
|
-
await
|
|
2049
|
+
await copyFile2(source, join9(temporary, name));
|
|
2050
|
+
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="">`);
|
|
1551
2051
|
} else {
|
|
1552
|
-
await
|
|
2052
|
+
await copyFile2(source, join9(temporary, "index.html"));
|
|
1553
2053
|
}
|
|
1554
2054
|
return { folder: temporary, temporary, image: isImage };
|
|
1555
2055
|
}
|
|
2056
|
+
var chosenCanvas;
|
|
2057
|
+
async function locate() {
|
|
2058
|
+
if (chosenCanvas === void 0) return findProject(process.cwd());
|
|
2059
|
+
return (await findNamed(process.cwd(), chosenCanvas))?.project ?? null;
|
|
2060
|
+
}
|
|
1556
2061
|
async function currentProject() {
|
|
1557
|
-
const project = await
|
|
1558
|
-
if (
|
|
1559
|
-
|
|
2062
|
+
const project = await locate();
|
|
2063
|
+
if (project) return project;
|
|
2064
|
+
if (chosenCanvas !== void 0) fail(`No canvas "${chosenCanvas}" here. Run: twombly init --canvas "${chosenCanvas}"`);
|
|
2065
|
+
const others = await canvasesAbove(process.cwd());
|
|
2066
|
+
fail(others.length ? `This folder has no default canvas. Use --canvas with one of: ${others.map((entry) => `"${entry.name}"`).join(", ")}.` : "No canvas here yet. Run: twombly init");
|
|
1560
2067
|
}
|
|
1561
2068
|
async function markWorking() {
|
|
1562
|
-
const project = await
|
|
1563
|
-
if (project) await call("working", { project, ms:
|
|
2069
|
+
const project = await locate();
|
|
2070
|
+
if (project) await call("working", { project, ms: WORKING_MS });
|
|
1564
2071
|
}
|
|
1565
2072
|
function gateFailure(result) {
|
|
1566
2073
|
print({ ok: false, placed: false, problems: result.problems, warnings: result.warnings, screenshot: result.screenshot });
|
|
1567
|
-
process.
|
|
2074
|
+
process.exitCode = 1;
|
|
2075
|
+
}
|
|
2076
|
+
var NEXT = "Open the screenshot. Passing the gates does not prove it looks right. To test interactions, open the url from: twombly preview <frame>";
|
|
2077
|
+
var CONCURRENT_GATES = 4;
|
|
2078
|
+
async function frameItems(flags, known) {
|
|
2079
|
+
const value = flags.frames;
|
|
2080
|
+
if (value === void 0) return [flags];
|
|
2081
|
+
if (typeof value !== "string") fail("--frames needs JSON or a JSON file.");
|
|
2082
|
+
const { frames: _, ...shared } = flags;
|
|
2083
|
+
let items;
|
|
2084
|
+
try {
|
|
2085
|
+
items = JSON.parse(value.trimStart().startsWith("[") ? value : await readFile7(value, "utf8"));
|
|
2086
|
+
} catch (error) {
|
|
2087
|
+
fail(`--frames: ${error instanceof Error ? error.message : error}`);
|
|
2088
|
+
}
|
|
2089
|
+
if (!Array.isArray(items) || !items.length) fail("--frames needs a list of frames.");
|
|
2090
|
+
return items.map((item) => {
|
|
2091
|
+
const unknown = Object.keys(item).find((key) => !known.includes(key));
|
|
2092
|
+
if (unknown) fail(`--frames: unknown field "${unknown}". Each frame takes: ${known.join(", ")}.`);
|
|
2093
|
+
const merged = { ...shared };
|
|
2094
|
+
for (const [key, field] of Object.entries(item)) {
|
|
2095
|
+
if (key === "newRow") {
|
|
2096
|
+
if (field) merged["new-row"] = true;
|
|
2097
|
+
else delete merged["new-row"];
|
|
2098
|
+
} else merged[key] = String(field);
|
|
2099
|
+
}
|
|
2100
|
+
return merged;
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
async function placeFrames(project, many, jobs, command2, item) {
|
|
2104
|
+
await markWorking();
|
|
2105
|
+
const scratch = await mkdtemp(join9(tmpdir(), "twombly-gates-"));
|
|
2106
|
+
const gates = gateSession(), prepared = /* @__PURE__ */ new Map(), temporary = [];
|
|
2107
|
+
let keepScratch = false;
|
|
2108
|
+
try {
|
|
2109
|
+
for (const job of jobs) {
|
|
2110
|
+
if (prepared.has(job.source)) continue;
|
|
2111
|
+
const ready = await prepare(job.source);
|
|
2112
|
+
prepared.set(job.source, ready);
|
|
2113
|
+
if (ready.temporary) temporary.push(ready.temporary);
|
|
2114
|
+
}
|
|
2115
|
+
const results = [];
|
|
2116
|
+
let next = 0;
|
|
2117
|
+
await Promise.all(Array.from({ length: CONCURRENT_GATES }, async () => {
|
|
2118
|
+
while (next < jobs.length) {
|
|
2119
|
+
const index = next++, job = jobs[index];
|
|
2120
|
+
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`) });
|
|
2121
|
+
}
|
|
2122
|
+
}));
|
|
2123
|
+
if (results.some((result) => !result.ok)) {
|
|
2124
|
+
keepScratch = true;
|
|
2125
|
+
if (!many) return gateFailure(results[0]);
|
|
2126
|
+
return gateFailure({
|
|
2127
|
+
problems: results.flatMap((result, index) => result.problems.map((problem) => ({ ...problem, frame: jobs[index].label, screenshot: result.screenshot }))),
|
|
2128
|
+
warnings: results.flatMap((result, index) => result.warnings.map((warning) => `${jobs[index].label}: ${warning}`))
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
const items = jobs.map((job, index) => {
|
|
2132
|
+
const { folder, image } = prepared.get(job.source);
|
|
2133
|
+
return { id: job.id, folder, source: job.source, screenshot: results[index].screenshot, ...item(job, { result: results[index], folder, image, screenshot: results[index].screenshot }) };
|
|
2134
|
+
});
|
|
2135
|
+
const { frames, canvas } = await call(command2, { project, items });
|
|
2136
|
+
const placed = frames.map(({ warnings, ...frame }, index) => {
|
|
2137
|
+
const result = results[index];
|
|
2138
|
+
return { frame, warnings: [...result.warnings, ...warnings], screenshot: result.screenshot ? posterFile(project, jobs[index].id) : void 0, contentSize: result.content };
|
|
2139
|
+
});
|
|
2140
|
+
if (!many) return print({ ok: true, placed: true, ...placed[0], canvas, next: NEXT });
|
|
2141
|
+
print({ ok: true, placed: true, frames: placed.map(({ frame, ...rest }) => ({ ...frame, ...rest })), canvas, next: NEXT });
|
|
2142
|
+
} finally {
|
|
2143
|
+
await gates.close();
|
|
2144
|
+
for (const folder of temporary) await rm5(folder, { recursive: true, force: true });
|
|
2145
|
+
if (!keepScratch) await rm5(scratch, { recursive: true, force: true });
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
async function addFrames(project, source, many, items) {
|
|
2149
|
+
const jobs = items.map((flags) => {
|
|
2150
|
+
const folder = text(flags, "folder") || source, title = text(flags, "title");
|
|
2151
|
+
if (!folder || !title) fail('twombly add <folder|file> --title "Title"');
|
|
2152
|
+
level(flags, "build");
|
|
2153
|
+
number(flags, "x");
|
|
2154
|
+
number(flags, "y");
|
|
2155
|
+
return {
|
|
2156
|
+
flags,
|
|
2157
|
+
label: title,
|
|
2158
|
+
source: resolve2(folder),
|
|
2159
|
+
entry: text(flags, "entry") || "index.html",
|
|
2160
|
+
width: size(flags, "width", 1280),
|
|
2161
|
+
height: flags.height === "auto" ? "auto" : size(flags, "height", 800),
|
|
2162
|
+
id: randomBytes2(8).toString("hex").replace(/[01lo]/g, "a").slice(0, 12)
|
|
2163
|
+
};
|
|
2164
|
+
});
|
|
2165
|
+
await placeFrames(project, many, jobs, "place", (job, { result, image }) => ({
|
|
2166
|
+
title: job.label,
|
|
2167
|
+
w: job.width,
|
|
2168
|
+
h: result.height,
|
|
2169
|
+
entry: job.entry,
|
|
2170
|
+
level: level(job.flags, image ? "image" : "build"),
|
|
2171
|
+
x: number(job.flags, "x"),
|
|
2172
|
+
y: number(job.flags, "y"),
|
|
2173
|
+
newRow: job.flags["new-row"] === true
|
|
2174
|
+
}));
|
|
2175
|
+
}
|
|
2176
|
+
async function updateFrames(project, source, many, items) {
|
|
2177
|
+
const { frames } = await call("list", { project });
|
|
2178
|
+
const seen = /* @__PURE__ */ new Set(), bases = /* @__PURE__ */ new Map();
|
|
2179
|
+
const jobs = items.map((flags) => {
|
|
2180
|
+
const ref = text(flags, "frame"), folder = text(flags, "folder") || source;
|
|
2181
|
+
if (!ref || !folder) fail("twombly update <frame> <folder|file>");
|
|
2182
|
+
const matches = frames.filter((frame2) => frame2.id === ref || frame2.title === ref);
|
|
2183
|
+
if (matches.length !== 1) fail(matches.length ? `More than one frame is titled "${ref}". Use its id.` : `No frame "${ref}". Run: twombly list`);
|
|
2184
|
+
const frame = matches[0];
|
|
2185
|
+
if (seen.has(frame.id)) fail(`--frames lists "${ref}" twice.`);
|
|
2186
|
+
seen.add(frame.id);
|
|
2187
|
+
bases.set(frame.id, frame.content);
|
|
2188
|
+
level(flags, "build");
|
|
2189
|
+
return {
|
|
2190
|
+
flags,
|
|
2191
|
+
label: frame.title,
|
|
2192
|
+
id: frame.id,
|
|
2193
|
+
source: resolve2(folder),
|
|
2194
|
+
entry: text(flags, "entry") || frame.entry,
|
|
2195
|
+
width: size(flags, "width", frame.w),
|
|
2196
|
+
height: flags.height === "auto" ? "auto" : size(flags, "height", frame.h)
|
|
2197
|
+
};
|
|
2198
|
+
});
|
|
2199
|
+
await placeFrames(project, many, jobs, "revise", (job, { result, image }) => ({
|
|
2200
|
+
label: job.label,
|
|
2201
|
+
base: bases.get(job.id),
|
|
2202
|
+
title: text(job.flags, "title"),
|
|
2203
|
+
w: job.flags.width !== void 0 ? job.width : void 0,
|
|
2204
|
+
h: job.flags.height !== void 0 ? result.height : void 0,
|
|
2205
|
+
entry: text(job.flags, "entry"),
|
|
2206
|
+
level: text(job.flags, "level") || image ? level(job.flags, image ? "image" : "build") : void 0
|
|
2207
|
+
}));
|
|
1568
2208
|
}
|
|
1569
2209
|
async function main() {
|
|
1570
2210
|
const [name, ...rest] = process.argv.slice(2);
|
|
1571
2211
|
const { positional, flags } = parse(rest);
|
|
2212
|
+
if (flags.help !== void 0 || positional.includes("-h")) {
|
|
2213
|
+
process.stdout.write(HELP + "\n");
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
if (name && !["--help", "-h", "--version", "help", "version", "guide", "__daemon"].includes(name)) checkFlags(name, flags);
|
|
2217
|
+
if (flags.canvas !== void 0) {
|
|
2218
|
+
if (typeof flags.canvas !== "string" || !flags.canvas.trim()) fail("--canvas needs a name.");
|
|
2219
|
+
chosenCanvas = flags.canvas.trim();
|
|
2220
|
+
}
|
|
1572
2221
|
switch (name) {
|
|
1573
2222
|
case "__daemon":
|
|
1574
2223
|
return runDaemon();
|
|
@@ -1580,31 +2229,46 @@ async function main() {
|
|
|
1580
2229
|
return;
|
|
1581
2230
|
case "--version":
|
|
1582
2231
|
case "version":
|
|
1583
|
-
process.stdout.write("0.
|
|
2232
|
+
process.stdout.write("0.3.0\n");
|
|
1584
2233
|
return;
|
|
1585
2234
|
case "guide":
|
|
1586
2235
|
process.stdout.write(SKILL_default);
|
|
1587
2236
|
return;
|
|
1588
2237
|
case "init": {
|
|
2238
|
+
const next = "Frames must be static files that work with no server or network. Read how to adapt this project: twombly guide";
|
|
2239
|
+
const open = flags["no-open"] === void 0, given = text(flags, "name");
|
|
2240
|
+
if (chosenCanvas !== void 0) {
|
|
2241
|
+
if (given) fail("Use --canvas or --name, not both. --canvas is the name.");
|
|
2242
|
+
const found = await findNamed(process.cwd(), chosenCanvas);
|
|
2243
|
+
const project2 = found?.project || namedProject(await findRoot(process.cwd()) || process.cwd(), chosenCanvas);
|
|
2244
|
+
print({ ok: true, ...await call("init", { project: project2, exact: true, name: chosenCanvas, open }), next });
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
1589
2247
|
const project = await findProject(process.cwd()) || process.cwd();
|
|
1590
|
-
const
|
|
1591
|
-
|
|
2248
|
+
const result = await call("init", { project, name: given || defaultName(project), open });
|
|
2249
|
+
const note = !result.created && given && given !== result.name ? `This folder's canvas is "${result.name}", and you've joined it. For a separate one: twombly init --canvas "${given}"` : void 0;
|
|
2250
|
+
print({ ok: true, ...result, note, next });
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
case "canvases": {
|
|
2254
|
+
const found = await canvasesAbove(process.cwd());
|
|
2255
|
+
print({ ok: true, canvases: found.map(({ name: name2, slug, frames, id, link }) => ({ name: name2, canvas: slug, frames, local: `${DAEMON_ORIGIN}/${id}`, link })) });
|
|
1592
2256
|
return;
|
|
1593
2257
|
}
|
|
1594
2258
|
case "check": {
|
|
1595
2259
|
const source = positional[0];
|
|
1596
|
-
if (!source) fail("
|
|
2260
|
+
if (!source) fail("twombly check <folder> checks a build before it is added. It needs the build's folder.");
|
|
1597
2261
|
await markWorking();
|
|
1598
2262
|
const { folder, temporary } = await prepare(resolve2(source));
|
|
1599
|
-
const scratch = await mkdtemp(
|
|
1600
|
-
const shot =
|
|
2263
|
+
const scratch = await mkdtemp(join9(tmpdir(), "twombly-check-"));
|
|
2264
|
+
const shot = join9(scratch, "screenshot.png");
|
|
1601
2265
|
try {
|
|
1602
2266
|
const height = flags.height === "auto" ? "auto" : size(flags, "height", 800);
|
|
1603
2267
|
const result = await runGates(folder, { entry: text(flags, "entry") || "index.html", width: size(flags, "width", 1280), height, poster: shot });
|
|
1604
2268
|
print(result);
|
|
1605
2269
|
if (!result.ok) process.exitCode = 1;
|
|
1606
2270
|
} finally {
|
|
1607
|
-
if (temporary) await
|
|
2271
|
+
if (temporary) await rm5(temporary, { recursive: true, force: true });
|
|
1608
2272
|
}
|
|
1609
2273
|
return;
|
|
1610
2274
|
}
|
|
@@ -1617,83 +2281,12 @@ async function main() {
|
|
|
1617
2281
|
return;
|
|
1618
2282
|
}
|
|
1619
2283
|
case "add": {
|
|
1620
|
-
|
|
1621
|
-
if (!source || !title) fail('twombly add <folder|file> --title "Title"');
|
|
1622
|
-
const project = await currentProject();
|
|
1623
|
-
await markWorking();
|
|
1624
|
-
const { folder, temporary, image } = await prepare(resolve2(source));
|
|
1625
|
-
const id = randomBytes2(8).toString("hex").replace(/[01lo]/g, "a").slice(0, 12);
|
|
1626
|
-
const entry = text(flags, "entry") || "index.html", width = size(flags, "width", 1280);
|
|
1627
|
-
const height = flags.height === "auto" ? "auto" : size(flags, "height", 800);
|
|
1628
|
-
const shot = posterFile(project, id);
|
|
1629
|
-
try {
|
|
1630
|
-
const result = await runGates(folder, { entry, width, height, poster: shot });
|
|
1631
|
-
if (!result.ok) {
|
|
1632
|
-
await rm4(shot, { force: true });
|
|
1633
|
-
gateFailure(result);
|
|
1634
|
-
}
|
|
1635
|
-
await replaceFolder(folder, frameDir(project, id));
|
|
1636
|
-
const { content } = await folderContent(frameDir(project, id));
|
|
1637
|
-
const existing = await call("list", { project });
|
|
1638
|
-
if (existing.frames.some((frame) => frame.title === title)) result.warnings.push(`Another frame is already titled "${title}". Give each a distinct title, or use update to replace it.`);
|
|
1639
|
-
const placed = await call("place", {
|
|
1640
|
-
project,
|
|
1641
|
-
id,
|
|
1642
|
-
title,
|
|
1643
|
-
w: width,
|
|
1644
|
-
h: result.height,
|
|
1645
|
-
entry,
|
|
1646
|
-
content,
|
|
1647
|
-
level: level(flags, image ? "image" : "build"),
|
|
1648
|
-
hasPoster: !!result.screenshot,
|
|
1649
|
-
x: number(flags, "x"),
|
|
1650
|
-
y: number(flags, "y"),
|
|
1651
|
-
newRow: flags["new-row"] === true
|
|
1652
|
-
});
|
|
1653
|
-
print({ ok: true, placed: true, ...placed, warnings: result.warnings, screenshot: result.screenshot, next: "Open the screenshot. Passing the gates does not prove it looks right.", contentSize: result.content });
|
|
1654
|
-
} finally {
|
|
1655
|
-
if (temporary) await rm4(temporary, { recursive: true, force: true });
|
|
1656
|
-
}
|
|
1657
|
-
return;
|
|
2284
|
+
return addFrames(await currentProject(), positional[0], flags.frames !== void 0, await frameItems(flags, ["folder", "title", "width", "height", "entry", "level", "newRow", "x", "y"]));
|
|
1658
2285
|
}
|
|
1659
2286
|
case "update": {
|
|
1660
|
-
const
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
await markWorking();
|
|
1664
|
-
const before = await call("list", { project });
|
|
1665
|
-
const matches = before.frames.filter((frame2) => frame2.id === ref || frame2.title === ref);
|
|
1666
|
-
if (matches.length !== 1) fail(matches.length ? `More than one frame is titled "${ref}". Use its id.` : `No frame "${ref}". Run: twombly list`);
|
|
1667
|
-
const frame = matches[0];
|
|
1668
|
-
const { folder, temporary, image } = await prepare(resolve2(source));
|
|
1669
|
-
const entry = text(flags, "entry") || frame.entry, width = size(flags, "width", frame.w);
|
|
1670
|
-
const height = flags.height === "auto" ? "auto" : size(flags, "height", frame.h);
|
|
1671
|
-
const scratch = await mkdtemp(join8(tmpdir(), "twombly-update-"));
|
|
1672
|
-
const candidate = join8(scratch, "poster.png");
|
|
1673
|
-
try {
|
|
1674
|
-
const result = await runGates(folder, { entry, width, height, poster: candidate });
|
|
1675
|
-
if (!result.ok) gateFailure(result);
|
|
1676
|
-
await replaceFolder(folder, frameDir(project, frame.id));
|
|
1677
|
-
await mkdir6(join8(project, ".twombly/posters"), { recursive: true });
|
|
1678
|
-
await copyFile(candidate, posterFile(project, frame.id));
|
|
1679
|
-
const { content } = await folderContent(frameDir(project, frame.id));
|
|
1680
|
-
const updated = await call("revise", {
|
|
1681
|
-
project,
|
|
1682
|
-
id: frame.id,
|
|
1683
|
-
title: text(flags, "title"),
|
|
1684
|
-
w: width,
|
|
1685
|
-
h: result.height,
|
|
1686
|
-
entry,
|
|
1687
|
-
content,
|
|
1688
|
-
hasPoster: true,
|
|
1689
|
-
level: text(flags, "level") || image ? level(flags, image ? "image" : "build") : void 0
|
|
1690
|
-
});
|
|
1691
|
-
print({ ok: true, placed: true, ...updated, warnings: result.warnings, screenshot: posterFile(project, frame.id), next: "Open the screenshot. Passing the gates does not prove it looks right.", contentSize: result.content });
|
|
1692
|
-
} finally {
|
|
1693
|
-
if (temporary) await rm4(temporary, { recursive: true, force: true });
|
|
1694
|
-
await rm4(scratch, { recursive: true, force: true });
|
|
1695
|
-
}
|
|
1696
|
-
return;
|
|
2287
|
+
const many = flags.frames !== void 0;
|
|
2288
|
+
const items = many ? await frameItems(flags, ["frame", "folder", "title", "width", "height", "entry", "level"]) : [{ ...flags, ...positional[0] ? { frame: positional[0] } : {} }];
|
|
2289
|
+
return updateFrames(await currentProject(), many ? positional[0] : positional[1], many, items);
|
|
1697
2290
|
}
|
|
1698
2291
|
case "working":
|
|
1699
2292
|
print({ ok: true, ...await call("working", { project: await currentProject(), ms: WORKING_MS }) });
|
|
@@ -1704,11 +2297,22 @@ async function main() {
|
|
|
1704
2297
|
case "list":
|
|
1705
2298
|
print({ ok: true, ...await call("list", { project: await currentProject() }) });
|
|
1706
2299
|
return;
|
|
2300
|
+
case "context":
|
|
2301
|
+
print({ ok: true, ...await call("context", { project: await currentProject() }) });
|
|
2302
|
+
return;
|
|
2303
|
+
case "comments":
|
|
2304
|
+
print({ ok: true, ...await call("comments", { project: await currentProject() }) });
|
|
2305
|
+
return;
|
|
1707
2306
|
case "open":
|
|
1708
2307
|
print({ ok: true, ...await call("open", { project: await currentProject() }) });
|
|
1709
2308
|
return;
|
|
2309
|
+
case "preview": {
|
|
2310
|
+
if (!positional[0]) fail("twombly preview <frame>");
|
|
2311
|
+
print({ ok: true, ...await call("preview", { project: await currentProject(), frame: positional[0] }) });
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
1710
2314
|
case "status":
|
|
1711
|
-
print({ ok: true, ...await call("status", { project: await
|
|
2315
|
+
print({ ok: true, ...await call("status", { project: await locate() }) });
|
|
1712
2316
|
return;
|
|
1713
2317
|
case "remove": {
|
|
1714
2318
|
if (!positional[0]) fail("twombly remove <frame>");
|
|
@@ -1722,6 +2326,7 @@ async function main() {
|
|
|
1722
2326
|
return;
|
|
1723
2327
|
}
|
|
1724
2328
|
case "resize": {
|
|
2329
|
+
if (flags.height === "auto") fail("resize takes a number. To fit the page: twombly update <frame> <folder> --height auto");
|
|
1725
2330
|
if (!positional[0] || flags.width === void 0 || flags.height === void 0) fail("twombly resize <frame> --width N --height N");
|
|
1726
2331
|
print({ ok: true, ...await call("patch", { project: await currentProject(), frame: positional[0], patch: { w: size(flags, "width", 1280), h: size(flags, "height", 800) } }) });
|
|
1727
2332
|
return;
|