twombly 0.2.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 +461 -160
- 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/index-B5osE1GC.js +10 -0
- package/dist/ui/assets/index-Dw-Z6e31.css +1 -0
- package/dist/ui/index.html +7 -4
- package/dist/ui/og.png +0 -0
- package/dist/ui/people/jaytel.jpg +0 -0
- package/dist/ui/people/marvin.jpg +0 -0
- package/package.json +1 -1
- package/dist/ui/assets/Landing-COHWYWgT.js +0 -109
- package/dist/ui/assets/Landing-I1VCX2fy.css +0 -1
- package/dist/ui/assets/LandingStage-D1WT-zxZ.css +0 -1
- package/dist/ui/assets/LandingStage-DQBFsXtF.js +0 -1
- package/dist/ui/assets/agentPrompt-CELnjtMR.js +0 -1
- package/dist/ui/assets/index-C8yZ6_xT.css +0 -1
- package/dist/ui/assets/index-NwhZOePg.js +0 -34
- package/dist/ui/assets/rolldown-runtime-WkwZ5CU-.js +0 -1
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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
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";
|
|
@@ -13,7 +13,7 @@ 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,
|
|
@@ -244,6 +244,12 @@ function cleanStroke(input) {
|
|
|
244
244
|
}
|
|
245
245
|
var WORKING_MS = 3 * 6e4;
|
|
246
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
|
+
}
|
|
247
253
|
var FRAME_ID = /^[a-km-z2-9]{8,32}$/;
|
|
248
254
|
var FRAME_GAP = 80;
|
|
249
255
|
function nextPosition(frames, newRow = false) {
|
|
@@ -255,7 +261,7 @@ function nextPosition(frames, newRow = false) {
|
|
|
255
261
|
}
|
|
256
262
|
|
|
257
263
|
// ../../skills/twombly/SKILL.md
|
|
258
|
-
var SKILL_default = "---\nname: twombly\ndescription: Lay out design explorations on a shared canvas. Use when asked to \"put this on Twombly\", \"put these on a canvas\", \"lay out these versions\", \"show me all the variations side by side\", or to share explorations with real-time cursors and comments. Works with any framework in any repo.\n---\n\n# Twombly\n\nTwombly shows design work as live frames on an infinite canvas. You build each version, turn it into a folder of static files, and add it. The canvas opens on `localhost`; `init` returns its address. Nothing is online until the person clicks Share. After that the local canvas and the link are one state: everything you add, update or move appears for everyone straight away.\n\nRun every command as `npx -y twombly@latest <command>`; this guide shortens that to `twombly <command>`. Output is JSON.\n\n## The rule that decides everything\n\n**A frame is a folder of static files that runs with no server and no network.** It can use anything in its own folder and nothing else. The browser enforces this: outside requests, form posts and server code all fail, locally and once shared.\n\nThe person's repo may have a database, an API, auth and environment keys. None of that goes in a frame. Your job is to adapt the design so it works without them:\n\n- **Data and APIs:** replace with realistic mock data, written inline or as JSON in the folder. For public CMS content, save a snapshot as JSON when you build.\n- **Auth and providers:** skip login, and mount the design with mock providers and a fake signed-in user.\n- **Environment values:** never include them. Build with none set. Don't copy `.env` files. If the build needs a value to compile, use an obviously fake one. **Never run or read a secret manager such as Infisical or Doppler for this, even if the project is set up for it.** If you can't get what you need without one, ask the person.\n- **Analytics and widgets:** remove tag managers, analytics, chat widgets and similar scripts, including any in page templates such as `app.html` or `_document`.\n- **Real customer data:** never copy it from a database, fixture or log. Invent names, emails and numbers. The gates can't detect this, so it's on you. Links are public.\n- **Fonts, images and video:** copy the files into the folder with relative or root paths. For a hosted font such as Google Fonts, download the files and use `@font-face`; Google only serves `woff2` to a browser user agent. If a font is licensed, tell the person it will be publicly hosted. Add up media sizes before downloading, take only what the design shows, and compress video.\n- **One screen of a routed app:** a router reads the page's address, which won't match the screen you want. Write a small entry file that mounts that screen with a memory or hash router, instead of trying to reach its route.\n\n## Limits\n\n100 MB per frame, 25 MB per file, 5000 files per frame, 200 frames per canvas. A URL can't be added, not even `localhost`: build it or `capture` it. Refused files: `.env`, credentials, keys, databases, source maps, server code, `node_modules` and project files such as `package.json`. Pop-ups, camera and microphone are off.\n\n## Steps\n\n1. **Start the canvas.** From the project root, or in a monorepo from the app's own folder so `.twombly/work` can find its packages: `twombly init --name \"Notification Explorations\" --no-open`. Pick a short, specific name. Run it once per canvas. The result's `local` field is the canvas's address.\n2. **Open it beside the chat.** The person should see the canvas next to this conversation, in the app they're already using. Open the `local` address in that app's built-in browser: the Claude desktop app's browser pane, the ChatGPT app's in-app browser, or the built-in browser in Cursor, VS Code or any other editor. Only if the app has none, run `twombly open`, which uses their default browser.\n3. **Build each version** as its own static folder, keeping work under `.twombly/work/<version>` so the person's source stays clean. Use the first approach below that works. The canvas background moves so people can see you're at work: `init` and the slow commands keep it going for three minutes. Run `twombly working` only before a longer gap.\n4. **Add it:** `twombly add <folder> --title \"V1 Compact\" --width 1280 --height 800 --level build`, saying which level below it is. Use the design's real size, such as `--width 390 --height 844` for a phone. Use `--height auto` to fit a tall page.\n5. **Read the output.** If `ok` is false, fix every item in `problems` and run `add` again. Each one includes a `fix`. The frame isn't placed until it passes.\n6. **Look at it.** The result has a `screenshot` path. Open it and check the frame shows the design you meant, with the content in the right state. The gates can't tell if a page came out unstyled or on the wrong screen; a common miss is Tailwind classes that were never generated. Tailwind 4 skips gitignored folders, so source copied into `.twombly` needs an `@source`.\n7. **Test interactions, if they matter.** Open the `url` from `twombly preview <frame>` in your browser tool and use the page inside its iframe. On the canvas, a frame ignores clicks until it's double-clicked, and frame URLs refuse to open on their own. Tell the person what you didn't test.\n8. **Finish:** run `twombly done` so the background goes still, then tell the person the canvas is open. They click Share when they're ready. The first time, Google sign-in opens in their regular browser, and sharing continues on its own once they finish.\n\n## Choosing how to build a frame\n\nUse the first one that fits. Say so if you end up lower than the first, because the frame is less faithful. **If the app has server routes, auth or a database, skip straight to `component`**: a static export of that kind of app rarely works and isn't worth the attempt.\n\n1. `--level build`: **a real static build.** Vite: `vite build --base ./ --outDir .twombly/work/v1`. Next.js: `output: 'export'` in a temporary config (static export can't use server features). SvelteKit: `adapter-static` with a `fallback`, `ssr = false` in the root layout, and any server `load` swapped for mock data in a universal `+layout.ts`. Astro: `astro build`. Plain HTML: use the folder as is. To show one state of a bigger app, add a build entry that renders just that screen.\n2. `--level component`: **the component on its own.** Write a tiny entry file that imports the real component with mock props, and bundle it with Vite or esbuild into a folder. This is the usual answer when the app needs a backend. It keeps the real code and styles.\n3. `--level capture`: **freeze what's on screen.** With the app already running: `twombly capture http://localhost:3000/page --out .twombly/work/v1 --width 1280 --height 800`, then add that folder. It looks right but no longer responds, because scripts are removed.\n4. `--level image`: **a picture**, as a last resort. `twombly capture <url> --out <folder> --image`, or `twombly add shot.png --title \"V1\"`.\n\nSeveral versions can share one build: read the variant from `location.search` and pass `--entry \"index.html?variant=2\"`. Shared files are only uploaded once.\n\nAdd several frames in one command, which is faster, instead of writing a loop. Nothing is placed unless all of them pass. Each item takes `folder`, `title`, `entry`, `width`, `height`, `level`, `newRow`, `x` and `y`, and flags on the command apply to all:\n\n```sh\ntwombly add out --width 1280 --height 800 --level component --frames '[{\"title\":\"A\",\"entry\":\"index.html?variant=a\"},{\"title\":\"B\",\"entry\":\"index.html?variant=b\"}]'\n```\n\n### Example: the component level with Vite\n\nFor most projects, this is all it takes. It works the same for React, Svelte or Vue with that framework's Vite plugin. Keep it under `.twombly/work`.\n\n```js\n// .twombly/work/vite.config.mjs\nimport { defineConfig } from 'vite';\nimport { resolve } from 'node:path';\nimport react from '@vitejs/plugin-react';\n\nconst root = resolve(import.meta.dirname, '../..');\nexport default defineConfig({\n root: import.meta.dirname,\n base: './',\n plugins: [react()],\n resolve: { alias: { '@': root } }, // match the project's path aliases\n define: { 'process.env': {} },\n build: { outDir: 'out', emptyOutDir: true, sourcemap: false },\n});\n```\n\n```html\n<!-- .twombly/work/index.html -->\n<!doctype html><html><body><div id=\"root\"></div><script type=\"module\" src=\"./main.jsx\"></script></body></html>\n```\n\n```jsx\n// .twombly/work/main.jsx: the real component, the real styles, invented data.\nimport { createRoot } from 'react-dom/client';\nimport '@/styles/globals.css';\nimport { Composer } from '@/components/composer';\n\nconst variant = new URLSearchParams(location.search).get('variant');\ncreateRoot(document.getElementById('root')).render(<Composer variant={variant} onSend={() => {}} messages={MOCK_MESSAGES} />);\n```\n\nThen: `npx vite build --config .twombly/work/vite.config.mjs` and `twombly add .twombly/work/out --title \"A\" --entry \"index.html?variant=a\" --level component`. If the project uses Tailwind, add its Vite or PostCSS plugin to the config so the classes are generated.\n\n## Commands\n\n`twombly help` lists every command and flag. `<frame>` is a frame's id or its exact title. Frames fill in left to right; use `--new-row` to start a new group, for example one row per direction.\n\nUse `update` to change a version. Don't remove and re-add it, because that loses its comments. It keeps the frame's entry and size unless you change them. Each frame keeps its own copy of its files, so after rebuilding, update the set together: `twombly update out --frames '[{\"frame\":\"A\"},{\"frame\":\"B\"}]'`. Nothing changes unless all of them pass.\n\n## Sharing\n\nThe person shares by clicking Share. Only run `twombly share` if they explicitly ask you to, because it publishes the canvas to a public link. If they ask you to take it down, run `twombly unshare`. That deletes the link and its comments and leaves the local canvas.\n\nOnce it's shared, every `add`, `update`, `move` and `remove` goes live immediately. Check your work with `twombly check` before updating a canvas people are looking at.\n";
|
|
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";
|
|
259
265
|
|
|
260
266
|
// src/capture.ts
|
|
261
267
|
import { createHash } from "node:crypto";
|
|
@@ -410,9 +416,9 @@ async function capture(url, out, options) {
|
|
|
410
416
|
// src/daemon.ts
|
|
411
417
|
import { createHash as createHash3, randomBytes } from "node:crypto";
|
|
412
418
|
import { createReadStream as createReadStream2 } from "node:fs";
|
|
413
|
-
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";
|
|
414
420
|
import { createServer as createServer2 } from "node:http";
|
|
415
|
-
import { extname as extname2, join as join8 } from "node:path";
|
|
421
|
+
import { dirname as dirname3, extname as extname2, join as join8 } from "node:path";
|
|
416
422
|
import { WebSocketServer, WebSocket } from "ws";
|
|
417
423
|
|
|
418
424
|
// src/credentials.ts
|
|
@@ -429,6 +435,7 @@ var PORT = Number(process.env.TWOMBLY_PORT) || 4747;
|
|
|
429
435
|
var ENDPOINT = (process.env.TWOMBLY_ENDPOINT || "https://twombly.app").replace(/\/+$/, "");
|
|
430
436
|
var DAEMON_ORIGIN = `http://localhost:${PORT}`;
|
|
431
437
|
var NO_OPEN = process.env.TWOMBLY_NO_OPEN === "1";
|
|
438
|
+
var TOOL_API = 3;
|
|
432
439
|
|
|
433
440
|
// src/credentials.ts
|
|
434
441
|
var run = promisify(execFile);
|
|
@@ -586,7 +593,7 @@ var checkedAt = 0;
|
|
|
586
593
|
function interfaceFolders() {
|
|
587
594
|
if (OVERRIDE) return [OVERRIDE];
|
|
588
595
|
const folder = (released) => join5(STORE, String(released));
|
|
589
|
-
return [...saved.filter((released) => released >
|
|
596
|
+
return [...saved.filter((released) => released > 1793910384715).map(folder), BUNDLED, ...saved.filter((released) => released <= 1793910384715).map(folder)];
|
|
590
597
|
}
|
|
591
598
|
function unpack(body) {
|
|
592
599
|
const { payload, signature } = JSON.parse(body);
|
|
@@ -620,7 +627,7 @@ async function check() {
|
|
|
620
627
|
const release = unpack(body);
|
|
621
628
|
if (!release) return;
|
|
622
629
|
etag = response.headers.get("etag") || "";
|
|
623
|
-
if (release.released > Math.max(saved[0] ?? 0,
|
|
630
|
+
if (release.released > Math.max(saved[0] ?? 0, 1793910384715)) await install(release.released, release.files);
|
|
624
631
|
}
|
|
625
632
|
function startCheck() {
|
|
626
633
|
if (checking) return;
|
|
@@ -660,6 +667,44 @@ async function findProject(from) {
|
|
|
660
667
|
dir = up;
|
|
661
668
|
}
|
|
662
669
|
}
|
|
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
|
+
}
|
|
663
708
|
var canvasFile = (project) => join6(project, DIR, "canvas.json");
|
|
664
709
|
var frameDir = (project, id) => join6(project, DIR, "frames", id);
|
|
665
710
|
var posterFile = (project, id) => join6(project, DIR, "posters", `${id}.png`);
|
|
@@ -675,6 +720,7 @@ async function ensureProjectDir(project) {
|
|
|
675
720
|
await mkdir4(join6(project, DIR, "frames"), { recursive: true });
|
|
676
721
|
await mkdir4(join6(project, DIR, "posters"), { recursive: true });
|
|
677
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");
|
|
678
724
|
}
|
|
679
725
|
function defaultName(project) {
|
|
680
726
|
return basename(project).replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) || "Untitled";
|
|
@@ -700,12 +746,16 @@ async function folderContent(folder) {
|
|
|
700
746
|
return { files, content: await contentHash(files) };
|
|
701
747
|
}
|
|
702
748
|
async function replaceFolder(source, target) {
|
|
703
|
-
const temp = `${target}.${process.pid}.tmp`;
|
|
749
|
+
const temp = `${target}.${process.pid}.tmp`, old = `${target}.${process.pid}.old`;
|
|
704
750
|
await rm4(temp, { recursive: true, force: true });
|
|
751
|
+
await rm4(old, { recursive: true, force: true });
|
|
705
752
|
await mkdir4(dirname2(target), { recursive: true });
|
|
706
753
|
await cp(source, temp, { recursive: true, verbatimSymlinks: false, filter: (path) => basename(path) !== ".DS_Store" });
|
|
707
|
-
await
|
|
754
|
+
await rename2(target, old).catch((error) => {
|
|
755
|
+
if (error.code !== "ENOENT") throw error;
|
|
756
|
+
});
|
|
708
757
|
await rename2(temp, target);
|
|
758
|
+
await rm4(old, { recursive: true, force: true });
|
|
709
759
|
}
|
|
710
760
|
var registryFile = join6(HOME, "registry.json");
|
|
711
761
|
async function readRegistry() {
|
|
@@ -715,12 +765,17 @@ async function readRegistry() {
|
|
|
715
765
|
return {};
|
|
716
766
|
}
|
|
717
767
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
+
});
|
|
724
779
|
}
|
|
725
780
|
|
|
726
781
|
// src/settings.ts
|
|
@@ -782,6 +837,10 @@ async function edge(path, init = {}) {
|
|
|
782
837
|
return data;
|
|
783
838
|
}
|
|
784
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;
|
|
785
844
|
var Canvas = class {
|
|
786
845
|
id;
|
|
787
846
|
project;
|
|
@@ -791,7 +850,13 @@ var Canvas = class {
|
|
|
791
850
|
servers = /* @__PURE__ */ new Map();
|
|
792
851
|
queue = Promise.resolve();
|
|
793
852
|
saveTimer = null;
|
|
853
|
+
serversTimer = null;
|
|
854
|
+
writeTurn = inOrder();
|
|
794
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();
|
|
795
860
|
constructor(project, data) {
|
|
796
861
|
this.project = project;
|
|
797
862
|
this.data = data;
|
|
@@ -807,13 +872,26 @@ var Canvas = class {
|
|
|
807
872
|
save() {
|
|
808
873
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
809
874
|
this.saveTimer = setTimeout(() => {
|
|
810
|
-
writeCanvas(this.project, this.data).catch(() => {
|
|
875
|
+
this.writeTurn(() => writeCanvas(this.project, this.data)).catch(() => {
|
|
811
876
|
});
|
|
812
877
|
}, 150);
|
|
813
878
|
}
|
|
814
879
|
async saveNow() {
|
|
815
880
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
816
|
-
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) });
|
|
817
895
|
}
|
|
818
896
|
serverFor(frame) {
|
|
819
897
|
const key = `${frame.id}:${frame.content}`;
|
|
@@ -848,9 +926,18 @@ var Canvas = class {
|
|
|
848
926
|
workingLeft() {
|
|
849
927
|
return Math.max(0, this.workingUntil - Date.now());
|
|
850
928
|
}
|
|
851
|
-
/**
|
|
852
|
-
|
|
853
|
-
|
|
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
|
+
}
|
|
854
941
|
const left = this.workingLeft();
|
|
855
942
|
this.broadcast({ t: "working", ms: left });
|
|
856
943
|
if (this.data.remote) edge(`/api/canvases/${this.data.remote.id}/working`, { method: "POST", body: JSON.stringify({ ms: left }) }).catch(() => {
|
|
@@ -865,7 +952,10 @@ var Canvas = class {
|
|
|
865
952
|
this.sockets.add(socket);
|
|
866
953
|
touch();
|
|
867
954
|
const self = randomId(10);
|
|
868
|
-
socket.on("close", () =>
|
|
955
|
+
socket.on("close", () => {
|
|
956
|
+
this.sockets.delete(socket);
|
|
957
|
+
this.closeServersWhenUnwatched();
|
|
958
|
+
});
|
|
869
959
|
socket.on("message", (raw) => {
|
|
870
960
|
let message;
|
|
871
961
|
try {
|
|
@@ -883,22 +973,104 @@ var Canvas = class {
|
|
|
883
973
|
self,
|
|
884
974
|
role: "owner",
|
|
885
975
|
doc: { id: this.id, name: this.data.name, frames, strokes: this.data.strokes ?? [] },
|
|
886
|
-
threads: [],
|
|
976
|
+
threads: this.data.threads ?? [],
|
|
887
977
|
peers: [this.peer(self)],
|
|
888
|
-
|
|
978
|
+
// Shared but signed out, this is a copy the room can't hear about.
|
|
979
|
+
capabilities: { comments: !this.data.remote, huddle: false },
|
|
889
980
|
working: this.workingLeft()
|
|
890
981
|
}));
|
|
891
982
|
}
|
|
892
983
|
attachEvents(socket) {
|
|
893
984
|
this.events.add(socket);
|
|
894
|
-
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
|
+
}
|
|
895
1064
|
}
|
|
896
1065
|
async handle(socket, m) {
|
|
897
1066
|
if (m.t === "ping") return socket.send('{"t":"pong"}');
|
|
898
1067
|
if (this.data.remote) return;
|
|
899
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);
|
|
900
1070
|
if (m.t === "frame.remove") {
|
|
901
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);
|
|
902
1074
|
this.data.frames = this.data.frames.filter((frame) => frame.id !== m.id);
|
|
903
1075
|
if (this.data.frames.length !== before) {
|
|
904
1076
|
this.broadcast({ t: "frame.remove", id: m.id });
|
|
@@ -909,9 +1081,9 @@ var Canvas = class {
|
|
|
909
1081
|
if (m.t === "frame.restore") {
|
|
910
1082
|
const f = m.frame;
|
|
911
1083
|
if (this.data.frames.some((frame) => frame.id === f.id)) return;
|
|
912
|
-
const restored = { id: f.id, title: f.title, x: f.x, y: f.y, w: f.w, h: f.h, z: f.z, entry: f.entry, content: f.content, level: f.level, hasPoster: !!f.posterUrl, updatedAt: Date.now() };
|
|
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) };
|
|
913
1085
|
this.data.frames.push(restored);
|
|
914
|
-
|
|
1086
|
+
await this.announce(restored);
|
|
915
1087
|
this.save();
|
|
916
1088
|
return;
|
|
917
1089
|
}
|
|
@@ -948,16 +1120,16 @@ var Canvas = class {
|
|
|
948
1120
|
frame.w = Math.max(40, Math.min(8e3, frame.w));
|
|
949
1121
|
frame.h = Math.max(40, Math.min(8e3, frame.h));
|
|
950
1122
|
frame.updatedAt = Date.now();
|
|
951
|
-
|
|
1123
|
+
await this.announce(frame);
|
|
952
1124
|
this.save();
|
|
953
1125
|
return frame;
|
|
954
1126
|
}
|
|
955
1127
|
/** Once shared, the room is the truth. Bring the local list up to date with it. */
|
|
956
1128
|
async pull() {
|
|
957
1129
|
if (!this.data.remote) return;
|
|
958
|
-
let doc;
|
|
1130
|
+
let doc, threads;
|
|
959
1131
|
try {
|
|
960
|
-
({ doc } = await edge(`/api/canvases/${this.data.remote.id}`));
|
|
1132
|
+
({ doc, threads } = await edge(`/api/canvases/${this.data.remote.id}`));
|
|
961
1133
|
} catch (error) {
|
|
962
1134
|
if (error instanceof Failure && (error.status === 404 || error.status === 403)) {
|
|
963
1135
|
this.data.remote = null;
|
|
@@ -969,6 +1141,7 @@ var Canvas = class {
|
|
|
969
1141
|
}
|
|
970
1142
|
this.data.name = doc.name;
|
|
971
1143
|
if (doc.strokes) this.data.strokes = doc.strokes;
|
|
1144
|
+
if (threads) this.data.threads = threads;
|
|
972
1145
|
const known = new Map(this.data.frames.map((frame) => [frame.id, frame]));
|
|
973
1146
|
this.data.frames = doc.frames.map((frame) => ({
|
|
974
1147
|
id: frame.id,
|
|
@@ -982,7 +1155,8 @@ var Canvas = class {
|
|
|
982
1155
|
content: frame.content,
|
|
983
1156
|
level: frame.level,
|
|
984
1157
|
hasPoster: known.get(frame.id)?.hasPoster ?? !!frame.posterUrl,
|
|
985
|
-
updatedAt: frame.updatedAt
|
|
1158
|
+
updatedAt: frame.updatedAt,
|
|
1159
|
+
source: known.get(frame.id)?.source
|
|
986
1160
|
}));
|
|
987
1161
|
await this.saveNow();
|
|
988
1162
|
}
|
|
@@ -1031,6 +1205,19 @@ var Canvas = class {
|
|
|
1031
1205
|
for (let i = 0; i < strokes.length; i += STROKE_UPLOAD) {
|
|
1032
1206
|
await edge(`/api/canvases/${created.id}/strokes`, { method: "POST", body: JSON.stringify({ strokes: strokes.slice(i, i + STROKE_UPLOAD) }) });
|
|
1033
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();
|
|
1034
1221
|
} catch (error) {
|
|
1035
1222
|
await edge(`/api/canvases/${created.id}`, { method: "DELETE" }).catch(() => {
|
|
1036
1223
|
});
|
|
@@ -1043,8 +1230,8 @@ var Canvas = class {
|
|
|
1043
1230
|
}
|
|
1044
1231
|
async unshare() {
|
|
1045
1232
|
if (!this.data.remote) return;
|
|
1046
|
-
await this.pull()
|
|
1047
|
-
|
|
1233
|
+
await this.pull();
|
|
1234
|
+
if (!this.data.remote) return;
|
|
1048
1235
|
await edge(`/api/canvases/${this.data.remote.id}`, { method: "DELETE" }).catch((error) => {
|
|
1049
1236
|
if (error.status !== 404) throw error;
|
|
1050
1237
|
});
|
|
@@ -1054,16 +1241,24 @@ var Canvas = class {
|
|
|
1054
1241
|
}
|
|
1055
1242
|
};
|
|
1056
1243
|
var canvases = /* @__PURE__ */ new Map();
|
|
1057
|
-
|
|
1244
|
+
var opening = /* @__PURE__ */ new Map();
|
|
1245
|
+
function canvasById(id) {
|
|
1058
1246
|
const open = canvases.get(id);
|
|
1059
|
-
if (open) return open;
|
|
1060
|
-
|
|
1061
|
-
if (!
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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;
|
|
1067
1262
|
}
|
|
1068
1263
|
async function canvasForProject(project) {
|
|
1069
1264
|
const found = await findProject(project);
|
|
@@ -1085,8 +1280,36 @@ var summary = (canvas) => ({
|
|
|
1085
1280
|
name: canvas.data.name,
|
|
1086
1281
|
local: `${DAEMON_ORIGIN}/${canvas.id}`,
|
|
1087
1282
|
link: canvas.data.remote?.url || null,
|
|
1088
|
-
frames: canvas.data.frames.map(({ id, title, x, y, w, h, entry, level: level2 }) => ({ id, title, x, y, w, h, entry, level: level2 }))
|
|
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 }))
|
|
1089
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();
|
|
1090
1313
|
function findFrame(canvas, ref) {
|
|
1091
1314
|
const byId = canvas.data.frames.find((frame) => frame.id === ref);
|
|
1092
1315
|
if (byId) return byId;
|
|
@@ -1097,6 +1320,7 @@ function findFrame(canvas, ref) {
|
|
|
1097
1320
|
async function command(name, input) {
|
|
1098
1321
|
touch();
|
|
1099
1322
|
if (name === "stop") {
|
|
1323
|
+
if (input.ifIdle && inFlight > 1) return { stopped: false };
|
|
1100
1324
|
setTimeout(() => process.exit(0), 50);
|
|
1101
1325
|
return { stopped: true };
|
|
1102
1326
|
}
|
|
@@ -1119,18 +1343,21 @@ async function command(name, input) {
|
|
|
1119
1343
|
return { signedIn: false };
|
|
1120
1344
|
}
|
|
1121
1345
|
if (name === "init") {
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
found
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
+
});
|
|
1134
1361
|
}
|
|
1135
1362
|
const canvas = await canvasForProject(input.project);
|
|
1136
1363
|
if (name === "working") return { working: canvas.working(Number(input.ms) || 0) > 0 };
|
|
@@ -1139,6 +1366,10 @@ async function command(name, input) {
|
|
|
1139
1366
|
switch (name) {
|
|
1140
1367
|
case "list":
|
|
1141
1368
|
return summary(canvas);
|
|
1369
|
+
case "context":
|
|
1370
|
+
return contextOf(canvas);
|
|
1371
|
+
case "comments":
|
|
1372
|
+
return { comments: visibleThreads(canvas).map((thread) => commentsOf(canvas, thread)) };
|
|
1142
1373
|
case "open":
|
|
1143
1374
|
openBrowser(`${DAEMON_ORIGIN}/${canvas.id}`);
|
|
1144
1375
|
return summary(canvas);
|
|
@@ -1207,45 +1438,76 @@ async function patchFrame(canvas, id, patch) {
|
|
|
1207
1438
|
await canvas.applyPatch(id, patch);
|
|
1208
1439
|
await canvas.saveNow();
|
|
1209
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 });
|
|
1210
1452
|
async function place(canvas, input) {
|
|
1211
|
-
const
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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
|
+
}
|
|
1230
1482
|
canvas.working(WORKING_TAIL_MS);
|
|
1231
|
-
return {
|
|
1483
|
+
return { frames, canvas: canvasResult(canvas) };
|
|
1232
1484
|
}
|
|
1233
1485
|
async function revise(canvas, input) {
|
|
1234
|
-
const
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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
|
+
}
|
|
1247
1509
|
canvas.working(WORKING_TAIL_MS);
|
|
1248
|
-
return {
|
|
1510
|
+
return { frames, canvas: canvasResult(canvas) };
|
|
1249
1511
|
}
|
|
1250
1512
|
var JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
|
|
1251
1513
|
var reply = (response, status, value) => {
|
|
@@ -1331,7 +1593,7 @@ async function servePreview(response, canvasId, frameId) {
|
|
|
1331
1593
|
async function route(request, response) {
|
|
1332
1594
|
const url = new URL(request.url || "/", DAEMON_ORIGIN);
|
|
1333
1595
|
const path = url.pathname, method = request.method || "GET";
|
|
1334
|
-
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 });
|
|
1335
1597
|
if (path.startsWith("/cli/") && method === "POST") return reply(response, 200, await command(path.slice(5), await readBody(request)));
|
|
1336
1598
|
if (path === "/auth/callback" && method === "GET") {
|
|
1337
1599
|
const code = url.searchParams.get("code"), state = url.searchParams.get("state");
|
|
@@ -1386,6 +1648,7 @@ async function route(request, response) {
|
|
|
1386
1648
|
if (preview) return servePreview(response, preview[1], preview[2]);
|
|
1387
1649
|
return serveUi(response, path);
|
|
1388
1650
|
}
|
|
1651
|
+
var inFlight = 0;
|
|
1389
1652
|
var idleTimer = null;
|
|
1390
1653
|
function touch() {
|
|
1391
1654
|
if (idleTimer) clearTimeout(idleTimer);
|
|
@@ -1403,6 +1666,8 @@ async function runDaemon() {
|
|
|
1403
1666
|
response.end('{"error":"Forbidden"}');
|
|
1404
1667
|
return;
|
|
1405
1668
|
}
|
|
1669
|
+
const counted = /^\/(cli|api\/canvases)\//.test(request.url || "");
|
|
1670
|
+
if (counted) inFlight++;
|
|
1406
1671
|
try {
|
|
1407
1672
|
await route(request, response);
|
|
1408
1673
|
} catch (error) {
|
|
@@ -1413,6 +1678,8 @@ async function runDaemon() {
|
|
|
1413
1678
|
if (error instanceof Failure) return reply(response, error.status, { error: error.message, ...error.extra });
|
|
1414
1679
|
console.error(error);
|
|
1415
1680
|
reply(response, 500, { error: error instanceof Error ? error.message : "Something went wrong" });
|
|
1681
|
+
} finally {
|
|
1682
|
+
if (counted) inFlight--;
|
|
1416
1683
|
}
|
|
1417
1684
|
});
|
|
1418
1685
|
const sockets = new WebSocketServer({ noServer: true });
|
|
@@ -1443,8 +1710,8 @@ async function runDaemon() {
|
|
|
1443
1710
|
|
|
1444
1711
|
// src/gates.ts
|
|
1445
1712
|
import { createServer as createServer3 } from "node:http";
|
|
1446
|
-
import { mkdir as
|
|
1447
|
-
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";
|
|
1448
1715
|
var dedupe = (problems) => [...new Map(problems.map((p) => [`${p.code}:${p.file || ""}:${p.message}`, p])).values()];
|
|
1449
1716
|
async function scanFolder(folder) {
|
|
1450
1717
|
const problems = [];
|
|
@@ -1571,7 +1838,7 @@ async function browserGates(folder, options, launch) {
|
|
|
1571
1838
|
const shot = await page.locator("#frame").screenshot({ type: "png", timeout: 15e3 }).catch(() => null);
|
|
1572
1839
|
if (shot) {
|
|
1573
1840
|
if (options.poster) {
|
|
1574
|
-
await
|
|
1841
|
+
await mkdir7(dirname4(options.poster), { recursive: true });
|
|
1575
1842
|
await writeFile6(options.poster, shot);
|
|
1576
1843
|
}
|
|
1577
1844
|
const colors = await page.evaluate(async (base64) => {
|
|
@@ -1635,9 +1902,12 @@ async function runGates(folder, options) {
|
|
|
1635
1902
|
}
|
|
1636
1903
|
|
|
1637
1904
|
// src/cli.ts
|
|
1638
|
-
var HELP = `twombly ${"0.
|
|
1905
|
+
var HELP = `twombly ${"0.3.0"}: put design explorations on a shared canvas.
|
|
1639
1906
|
|
|
1640
|
-
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
|
|
1641
1911
|
add <folder|file> --title "T" run the gates, then place a frame
|
|
1642
1912
|
[--width 1280] [--height 800|auto] [--entry index.html] [--level build|component|capture|image]
|
|
1643
1913
|
[--new-row] [--x N --y N]
|
|
@@ -1650,6 +1920,8 @@ var HELP = `twombly ${"0.2.0"}: put design explorations on a shared canvas.
|
|
|
1650
1920
|
[--width 1280] [--height 800] [--image] [--selector css]
|
|
1651
1921
|
preview <frame> a page with just that frame, for testing interactions
|
|
1652
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
|
|
1653
1925
|
list | move <frame> --x N --y N | resize <frame> --width N --height N
|
|
1654
1926
|
retitle <frame> "T" | remove <frame> | arrange [--columns N] | rename "Name"
|
|
1655
1927
|
open | status | login | logout | stop
|
|
@@ -1688,7 +1960,7 @@ function parse(argv) {
|
|
|
1688
1960
|
return { positional, flags };
|
|
1689
1961
|
}
|
|
1690
1962
|
function checkFlags(name, flags) {
|
|
1691
|
-
const known = FLAGS[name] || [];
|
|
1963
|
+
const known = name === "canvases" ? [] : [...FLAGS[name] || [], "canvas"];
|
|
1692
1964
|
for (const flag of Object.keys(flags)) {
|
|
1693
1965
|
if (!known.includes(flag)) fail(`Unknown flag --${flag}. ${known.length ? `"${name}" takes: ${known.map((item) => `--${item}`).join(", ")}.` : `"${name}" takes no flags.`}`);
|
|
1694
1966
|
}
|
|
@@ -1728,18 +2000,31 @@ async function health() {
|
|
|
1728
2000
|
return null;
|
|
1729
2001
|
}
|
|
1730
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"));
|
|
1731
2007
|
async function ensureDaemon() {
|
|
1732
2008
|
const running = await health();
|
|
1733
|
-
if (running
|
|
2009
|
+
if (usable(running)) return;
|
|
1734
2010
|
if (running) {
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
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);
|
|
1738
2023
|
}
|
|
1739
2024
|
spawn2(process.execPath, [fileURLToPath2(import.meta.url), "__daemon"], { detached: true, stdio: "ignore" }).unref();
|
|
1740
2025
|
for (let attempt = 0; attempt < 60; attempt++) {
|
|
1741
|
-
await
|
|
1742
|
-
if ((await health())
|
|
2026
|
+
await sleep(100);
|
|
2027
|
+
if (usable(await health())) return;
|
|
1743
2028
|
}
|
|
1744
2029
|
fail(`Could not start the background server on port ${PORT}. Is something else using it?`);
|
|
1745
2030
|
}
|
|
@@ -1761,20 +2046,27 @@ async function prepare(source) {
|
|
|
1761
2046
|
const temporary = await mkdtemp(join9(tmpdir(), "twombly-"));
|
|
1762
2047
|
if (isImage) {
|
|
1763
2048
|
const name = `image${extension}`;
|
|
1764
|
-
await
|
|
2049
|
+
await copyFile2(source, join9(temporary, name));
|
|
1765
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="">`);
|
|
1766
2051
|
} else {
|
|
1767
|
-
await
|
|
2052
|
+
await copyFile2(source, join9(temporary, "index.html"));
|
|
1768
2053
|
}
|
|
1769
2054
|
return { folder: temporary, temporary, image: isImage };
|
|
1770
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
|
+
}
|
|
1771
2061
|
async function currentProject() {
|
|
1772
|
-
const project = await
|
|
1773
|
-
if (
|
|
1774
|
-
|
|
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");
|
|
1775
2067
|
}
|
|
1776
2068
|
async function markWorking() {
|
|
1777
|
-
const project = await
|
|
2069
|
+
const project = await locate();
|
|
1778
2070
|
if (project) await call("working", { project, ms: WORKING_MS });
|
|
1779
2071
|
}
|
|
1780
2072
|
function gateFailure(result) {
|
|
@@ -1808,7 +2100,7 @@ async function frameItems(flags, known) {
|
|
|
1808
2100
|
return merged;
|
|
1809
2101
|
});
|
|
1810
2102
|
}
|
|
1811
|
-
async function placeFrames(project, many, jobs,
|
|
2103
|
+
async function placeFrames(project, many, jobs, command2, item) {
|
|
1812
2104
|
await markWorking();
|
|
1813
2105
|
const scratch = await mkdtemp(join9(tmpdir(), "twombly-gates-"));
|
|
1814
2106
|
const gates = gateSession(), prepared = /* @__PURE__ */ new Map(), temporary = [];
|
|
@@ -1836,19 +2128,17 @@ async function placeFrames(project, many, jobs, place2) {
|
|
|
1836
2128
|
warnings: results.flatMap((result, index) => result.warnings.map((warning) => `${jobs[index].label}: ${warning}`))
|
|
1837
2129
|
});
|
|
1838
2130
|
}
|
|
1839
|
-
const
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
}
|
|
1850
|
-
if (!many) return print({ ok: true, placed: true, ...placed[0], next: NEXT });
|
|
1851
|
-
print({ ok: true, placed: true, frames: placed.map(({ frame, canvas: _, ...rest }) => ({ ...frame, ...rest })), canvas: placed[placed.length - 1].canvas, next: NEXT });
|
|
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 });
|
|
1852
2142
|
} finally {
|
|
1853
2143
|
await gates.close();
|
|
1854
2144
|
for (const folder of temporary) await rm5(folder, { recursive: true, force: true });
|
|
@@ -1872,30 +2162,20 @@ async function addFrames(project, source, many, items) {
|
|
|
1872
2162
|
id: randomBytes2(8).toString("hex").replace(/[01lo]/g, "a").slice(0, 12)
|
|
1873
2163
|
};
|
|
1874
2164
|
});
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
h: result.height,
|
|
1886
|
-
entry: job.entry,
|
|
1887
|
-
content,
|
|
1888
|
-
level: level(job.flags, image ? "image" : "build"),
|
|
1889
|
-
hasPoster: !!poster,
|
|
1890
|
-
x: number(job.flags, "x"),
|
|
1891
|
-
y: number(job.flags, "y"),
|
|
1892
|
-
newRow: job.flags["new-row"] === true
|
|
1893
|
-
});
|
|
1894
|
-
});
|
|
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
|
+
}));
|
|
1895
2175
|
}
|
|
1896
2176
|
async function updateFrames(project, source, many, items) {
|
|
1897
2177
|
const { frames } = await call("list", { project });
|
|
1898
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2178
|
+
const seen = /* @__PURE__ */ new Set(), bases = /* @__PURE__ */ new Map();
|
|
1899
2179
|
const jobs = items.map((flags) => {
|
|
1900
2180
|
const ref = text(flags, "frame"), folder = text(flags, "folder") || source;
|
|
1901
2181
|
if (!ref || !folder) fail("twombly update <frame> <folder|file>");
|
|
@@ -1904,6 +2184,7 @@ async function updateFrames(project, source, many, items) {
|
|
|
1904
2184
|
const frame = matches[0];
|
|
1905
2185
|
if (seen.has(frame.id)) fail(`--frames lists "${ref}" twice.`);
|
|
1906
2186
|
seen.add(frame.id);
|
|
2187
|
+
bases.set(frame.id, frame.content);
|
|
1907
2188
|
level(flags, "build");
|
|
1908
2189
|
return {
|
|
1909
2190
|
flags,
|
|
@@ -1915,20 +2196,15 @@ async function updateFrames(project, source, many, items) {
|
|
|
1915
2196
|
height: flags.height === "auto" ? "auto" : size(flags, "height", frame.h)
|
|
1916
2197
|
};
|
|
1917
2198
|
});
|
|
1918
|
-
await placeFrames(project, many, jobs,
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
content,
|
|
1928
|
-
hasPoster: poster ? true : void 0,
|
|
1929
|
-
level: text(job.flags, "level") || image ? level(job.flags, image ? "image" : "build") : void 0
|
|
1930
|
-
});
|
|
1931
|
-
});
|
|
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
|
+
}));
|
|
1932
2208
|
}
|
|
1933
2209
|
async function main() {
|
|
1934
2210
|
const [name, ...rest] = process.argv.slice(2);
|
|
@@ -1938,6 +2214,10 @@ async function main() {
|
|
|
1938
2214
|
return;
|
|
1939
2215
|
}
|
|
1940
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
|
+
}
|
|
1941
2221
|
switch (name) {
|
|
1942
2222
|
case "__daemon":
|
|
1943
2223
|
return runDaemon();
|
|
@@ -1949,15 +2229,30 @@ async function main() {
|
|
|
1949
2229
|
return;
|
|
1950
2230
|
case "--version":
|
|
1951
2231
|
case "version":
|
|
1952
|
-
process.stdout.write("0.
|
|
2232
|
+
process.stdout.write("0.3.0\n");
|
|
1953
2233
|
return;
|
|
1954
2234
|
case "guide":
|
|
1955
2235
|
process.stdout.write(SKILL_default);
|
|
1956
2236
|
return;
|
|
1957
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
|
+
}
|
|
1958
2247
|
const project = await findProject(process.cwd()) || process.cwd();
|
|
1959
|
-
const
|
|
1960
|
-
|
|
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 })) });
|
|
1961
2256
|
return;
|
|
1962
2257
|
}
|
|
1963
2258
|
case "check": {
|
|
@@ -2002,6 +2297,12 @@ async function main() {
|
|
|
2002
2297
|
case "list":
|
|
2003
2298
|
print({ ok: true, ...await call("list", { project: await currentProject() }) });
|
|
2004
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;
|
|
2005
2306
|
case "open":
|
|
2006
2307
|
print({ ok: true, ...await call("open", { project: await currentProject() }) });
|
|
2007
2308
|
return;
|
|
@@ -2011,7 +2312,7 @@ async function main() {
|
|
|
2011
2312
|
return;
|
|
2012
2313
|
}
|
|
2013
2314
|
case "status":
|
|
2014
|
-
print({ ok: true, ...await call("status", { project: await
|
|
2315
|
+
print({ ok: true, ...await call("status", { project: await locate() }) });
|
|
2015
2316
|
return;
|
|
2016
2317
|
case "remove": {
|
|
2017
2318
|
if (!positional[0]) fail("twombly remove <frame>");
|