twombly 0.2.0 → 0.4.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 +601 -194
- package/dist/ui/assets/Landing-D4-acwRF.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-cWuvBaKD.js +1 -0
- package/dist/ui/assets/agentPrompt-Bg7-ijHJ.js +1 -0
- package/dist/ui/assets/index-COkd0b_O.js +10 -0
- package/dist/ui/assets/index-DBaMyh-f.css +1 -0
- package/dist/ui/index.html +9 -3
- package/dist/ui/legal.css +34 -10
- 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 +11 -7
- package/dist/ui/terms.html +4 -2
- package/dist/ui/theme.js +6 -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/cli.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { spawn as spawn2 } from "node:child_process";
|
|
5
|
-
import { randomBytes as
|
|
6
|
-
import { copyFile
|
|
5
|
+
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
6
|
+
import { copyFile as copyFile2, mkdtemp, readFile as readFile8, rm as rm6, stat as stat5, writeFile as writeFile8 } 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 join10, resolve as resolve2 } from "node:path";
|
|
9
9
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
10
10
|
|
|
11
11
|
// ../protocol/src/gates.ts
|
|
@@ -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. 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";
|
|
@@ -297,7 +303,7 @@ async function capture(url, out, options) {
|
|
|
297
303
|
return { folder: out, assets: 1, failed: [] };
|
|
298
304
|
}
|
|
299
305
|
const saved2 = /* @__PURE__ */ new Map(), failed = [];
|
|
300
|
-
const
|
|
306
|
+
const save2 = async (assetUrl) => {
|
|
301
307
|
if (assetUrl.startsWith("data:") || assetUrl.startsWith("blob:")) return null;
|
|
302
308
|
if (saved2.has(assetUrl)) return saved2.get(assetUrl);
|
|
303
309
|
try {
|
|
@@ -327,7 +333,7 @@ async function capture(url, out, options) {
|
|
|
327
333
|
} catch {
|
|
328
334
|
continue;
|
|
329
335
|
}
|
|
330
|
-
const name = await
|
|
336
|
+
const name = await save2(absolute);
|
|
331
337
|
if (!name) continue;
|
|
332
338
|
output = output.split(match[0]).join(`url("${from === "css" ? name.replace("assets/", "") : name}")`);
|
|
333
339
|
}
|
|
@@ -385,7 +391,7 @@ async function capture(url, out, options) {
|
|
|
385
391
|
});
|
|
386
392
|
const replacements = [];
|
|
387
393
|
for (const attribute of found.attributes) {
|
|
388
|
-
const name = await
|
|
394
|
+
const name = await save2(attribute.value);
|
|
389
395
|
if (name) replacements.push({ tag: attribute.tag, name: attribute.name, value: name });
|
|
390
396
|
}
|
|
391
397
|
const styles = await Promise.all(found.styles.map((style) => rewriteCss(style, found.base, "html")));
|
|
@@ -408,11 +414,11 @@ async function capture(url, out, options) {
|
|
|
408
414
|
}
|
|
409
415
|
|
|
410
416
|
// src/daemon.ts
|
|
411
|
-
import { createHash as createHash3, randomBytes } from "node:crypto";
|
|
417
|
+
import { createHash as createHash3, randomBytes as randomBytes2 } from "node:crypto";
|
|
412
418
|
import { createReadStream as createReadStream2 } from "node:fs";
|
|
413
|
-
import { readFile as
|
|
419
|
+
import { copyFile, mkdir as mkdir7, readFile as readFile6, stat as stat3 } from "node:fs/promises";
|
|
414
420
|
import { createServer as createServer2 } from "node:http";
|
|
415
|
-
import { extname as extname2, join as
|
|
421
|
+
import { dirname as dirname3, extname as extname2, join as join9 } 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 > 1793918977725).map(folder), BUNDLED, ...saved.filter((released) => released <= 1793918977725).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, 1793918977725)) 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
|
|
@@ -729,7 +784,7 @@ import { join as join7 } from "node:path";
|
|
|
729
784
|
var FILE = join7(HOME, "settings.json");
|
|
730
785
|
async function readSettings() {
|
|
731
786
|
const saved2 = await readFile4(FILE, "utf8").then(JSON.parse).catch(() => null);
|
|
732
|
-
return { invertScroll: saved2?.invertScroll === true };
|
|
787
|
+
return { invertScroll: saved2?.invertScroll === true, theme: saved2?.theme === "light" || saved2?.theme === "dark" ? saved2.theme : void 0 };
|
|
733
788
|
}
|
|
734
789
|
async function writeSettings(settings2) {
|
|
735
790
|
await mkdir5(HOME, { recursive: true });
|
|
@@ -738,11 +793,89 @@ async function writeSettings(settings2) {
|
|
|
738
793
|
await rename3(temporary, FILE);
|
|
739
794
|
}
|
|
740
795
|
|
|
796
|
+
// src/usage.ts
|
|
797
|
+
import { randomBytes } from "node:crypto";
|
|
798
|
+
import { existsSync } from "node:fs";
|
|
799
|
+
import { mkdir as mkdir6, readFile as readFile5, rename as rename4, rm as rm5, writeFile as writeFile6 } from "node:fs/promises";
|
|
800
|
+
import { homedir as homedir2 } from "node:os";
|
|
801
|
+
import { join as join8 } from "node:path";
|
|
802
|
+
var FILE2 = join8(HOME, "usage.json");
|
|
803
|
+
var OFF = join8(HOME, "usage-off");
|
|
804
|
+
var CHECK_MS2 = 36e5;
|
|
805
|
+
var ERRORS_PER_RUN = 20;
|
|
806
|
+
var state = null;
|
|
807
|
+
var saving = null;
|
|
808
|
+
var errorsSent = 0;
|
|
809
|
+
var usageOn = () => process.env.TWOMBLY_NO_USAGE !== "1" && !existsSync(OFF);
|
|
810
|
+
var scrub = (text2, max) => text2.split(homedir2()).join("~").slice(0, max);
|
|
811
|
+
var today = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
812
|
+
var post = (path, body) => fetch(ENDPOINT + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(1e4) });
|
|
813
|
+
async function setUsage(on) {
|
|
814
|
+
await mkdir6(HOME, { recursive: true });
|
|
815
|
+
if (on) await rm5(OFF, { force: true });
|
|
816
|
+
else await writeFile6(OFF, "");
|
|
817
|
+
}
|
|
818
|
+
async function load() {
|
|
819
|
+
const saved2 = await readFile5(FILE2, "utf8").then(JSON.parse).catch(() => null);
|
|
820
|
+
const install3 = typeof saved2?.install === "string" && /^[a-z0-9]{16,40}$/.test(saved2.install) ? saved2.install : randomBytes(12).toString("hex");
|
|
821
|
+
return { install: install3, day: typeof saved2?.day === "string" ? saved2.day : today(), counts: saved2?.counts && typeof saved2.counts === "object" ? saved2.counts : {} };
|
|
822
|
+
}
|
|
823
|
+
function save() {
|
|
824
|
+
if (saving) return;
|
|
825
|
+
saving = setTimeout(async () => {
|
|
826
|
+
saving = null;
|
|
827
|
+
if (!state) return;
|
|
828
|
+
await mkdir6(HOME, { recursive: true }).catch(() => {
|
|
829
|
+
});
|
|
830
|
+
const temporary = `${FILE2}.${process.pid}`;
|
|
831
|
+
await writeFile6(temporary, JSON.stringify(state)).then(() => rename4(temporary, FILE2)).catch(() => {
|
|
832
|
+
});
|
|
833
|
+
}, 1e3);
|
|
834
|
+
}
|
|
835
|
+
function tally(key, by = 1) {
|
|
836
|
+
if (!state || !usageOn()) return;
|
|
837
|
+
state.counts[key] = (state.counts[key] || 0) + by;
|
|
838
|
+
save();
|
|
839
|
+
}
|
|
840
|
+
async function flush() {
|
|
841
|
+
if (!state || state.day === today()) return;
|
|
842
|
+
const { day, counts } = state;
|
|
843
|
+
state.day = today();
|
|
844
|
+
state.counts = {};
|
|
845
|
+
save();
|
|
846
|
+
if (!usageOn() || !Object.values(counts).some(Boolean)) return;
|
|
847
|
+
const canvases2 = Object.keys(await readRegistry()).length;
|
|
848
|
+
await post("/api/usage", { install: state.install, day, version: "0.4.0", platform: process.platform, counts: { ...counts, canvases: canvases2 } }).catch(() => {
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
async function startUsage() {
|
|
852
|
+
state = await load();
|
|
853
|
+
await flush();
|
|
854
|
+
tally("starts");
|
|
855
|
+
setInterval(() => {
|
|
856
|
+
flush().catch(() => {
|
|
857
|
+
});
|
|
858
|
+
}, CHECK_MS2).unref();
|
|
859
|
+
}
|
|
860
|
+
function reportError(error, source = "tool") {
|
|
861
|
+
if (!usageOn() || ++errorsSent > ERRORS_PER_RUN) return;
|
|
862
|
+
post("/api/errors", {
|
|
863
|
+
source,
|
|
864
|
+
mode: "local",
|
|
865
|
+
install: state?.install,
|
|
866
|
+
version: "0.4.0",
|
|
867
|
+
type: String(error.type || "Error").slice(0, 80),
|
|
868
|
+
message: scrub(String(error.message), 500),
|
|
869
|
+
stack: scrub(String(error.stack || ""), 4e3)
|
|
870
|
+
}).catch(() => {
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
|
|
741
874
|
// src/daemon.ts
|
|
742
875
|
var IDLE_MS = 6 * 36e5;
|
|
743
876
|
var LOGIN_MS = 10 * 6e4;
|
|
744
877
|
var ID_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
|
|
745
|
-
var randomId = (length) => [...
|
|
878
|
+
var randomId = (length) => [...randomBytes2(length)].map((b) => ID_ALPHABET[b % ID_ALPHABET.length]).join("");
|
|
746
879
|
var Failure = class extends Error {
|
|
747
880
|
status;
|
|
748
881
|
extra;
|
|
@@ -757,7 +890,7 @@ var credential = null;
|
|
|
757
890
|
var pendingLogin = null;
|
|
758
891
|
function loginUrl() {
|
|
759
892
|
if (!pendingLogin || Date.now() - pendingLogin.at > LOGIN_MS) {
|
|
760
|
-
pendingLogin = { state:
|
|
893
|
+
pendingLogin = { state: randomBytes2(24).toString("hex"), verifier: randomBytes2(32).toString("base64url"), at: Date.now() };
|
|
761
894
|
}
|
|
762
895
|
const challenge = createHash3("sha256").update(pendingLogin.verifier).digest("base64url");
|
|
763
896
|
return `${ENDPOINT}/auth/machine/start?` + new URLSearchParams({ port: String(PORT), state: pendingLogin.state, challenge });
|
|
@@ -782,6 +915,10 @@ async function edge(path, init = {}) {
|
|
|
782
915
|
return data;
|
|
783
916
|
}
|
|
784
917
|
var STROKE_UPLOAD = 40;
|
|
918
|
+
var THREAD_UPLOAD_CHARS = 5e5;
|
|
919
|
+
var THREAD_ID = /^[a-z0-9]{8,32}$/;
|
|
920
|
+
var finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
921
|
+
var FRAME_SERVERS_IDLE_MS = Number(process.env.TWOMBLY_FRAME_IDLE_MS) || 5 * 6e4;
|
|
785
922
|
var Canvas = class {
|
|
786
923
|
id;
|
|
787
924
|
project;
|
|
@@ -791,7 +928,13 @@ var Canvas = class {
|
|
|
791
928
|
servers = /* @__PURE__ */ new Map();
|
|
792
929
|
queue = Promise.resolve();
|
|
793
930
|
saveTimer = null;
|
|
931
|
+
serversTimer = null;
|
|
932
|
+
writeTurn = inOrder();
|
|
794
933
|
workingUntil = 0;
|
|
934
|
+
agents = 0;
|
|
935
|
+
contexts = /* @__PURE__ */ new Map();
|
|
936
|
+
/** So undoing a delete in the page keeps where the frame came from. */
|
|
937
|
+
removedSources = /* @__PURE__ */ new Map();
|
|
795
938
|
constructor(project, data) {
|
|
796
939
|
this.project = project;
|
|
797
940
|
this.data = data;
|
|
@@ -807,13 +950,26 @@ var Canvas = class {
|
|
|
807
950
|
save() {
|
|
808
951
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
809
952
|
this.saveTimer = setTimeout(() => {
|
|
810
|
-
writeCanvas(this.project, this.data).catch(() => {
|
|
953
|
+
this.writeTurn(() => writeCanvas(this.project, this.data)).catch(() => {
|
|
811
954
|
});
|
|
812
955
|
}, 150);
|
|
813
956
|
}
|
|
814
957
|
async saveNow() {
|
|
815
958
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
816
|
-
await writeCanvas(this.project, this.data);
|
|
959
|
+
await this.writeTurn(() => writeCanvas(this.project, this.data));
|
|
960
|
+
}
|
|
961
|
+
/** Frame servers are only for people looking. They come back, on new ports, the next time someone does. */
|
|
962
|
+
closeServersWhenUnwatched() {
|
|
963
|
+
if (this.serversTimer) clearTimeout(this.serversTimer);
|
|
964
|
+
this.serversTimer = setTimeout(() => {
|
|
965
|
+
if (this.sockets.size) return;
|
|
966
|
+
for (const running of this.servers.values()) running.then((server) => server.close());
|
|
967
|
+
this.servers.clear();
|
|
968
|
+
}, FRAME_SERVERS_IDLE_MS);
|
|
969
|
+
}
|
|
970
|
+
/** Tells anyone looking about a frame, without starting its server for nobody. */
|
|
971
|
+
async announce(frame) {
|
|
972
|
+
if (this.sockets.size) this.broadcast({ t: "frame.upsert", frame: await this.toFrame(frame) });
|
|
817
973
|
}
|
|
818
974
|
serverFor(frame) {
|
|
819
975
|
const key = `${frame.id}:${frame.content}`;
|
|
@@ -848,9 +1004,18 @@ var Canvas = class {
|
|
|
848
1004
|
workingLeft() {
|
|
849
1005
|
return Math.max(0, this.workingUntil - Date.now());
|
|
850
1006
|
}
|
|
851
|
-
/**
|
|
852
|
-
|
|
853
|
-
|
|
1007
|
+
/**
|
|
1008
|
+
* An agent is at work for at least this long; zero means it's finished. Everyone looking at the canvas sees it.
|
|
1009
|
+
* Agents have no identity, so each `init` counts as one more and it only stops once each has finished, or time runs out.
|
|
1010
|
+
*/
|
|
1011
|
+
working(ms, joining = false) {
|
|
1012
|
+
if (!this.workingLeft()) this.agents = 0;
|
|
1013
|
+
if (joining) this.agents++;
|
|
1014
|
+
if (ms > 0) this.workingUntil = Math.max(this.workingUntil, Date.now() + ms);
|
|
1015
|
+
else if (--this.agents <= 0) {
|
|
1016
|
+
this.agents = 0;
|
|
1017
|
+
this.workingUntil = 0;
|
|
1018
|
+
}
|
|
854
1019
|
const left = this.workingLeft();
|
|
855
1020
|
this.broadcast({ t: "working", ms: left });
|
|
856
1021
|
if (this.data.remote) edge(`/api/canvases/${this.data.remote.id}/working`, { method: "POST", body: JSON.stringify({ ms: left }) }).catch(() => {
|
|
@@ -865,7 +1030,10 @@ var Canvas = class {
|
|
|
865
1030
|
this.sockets.add(socket);
|
|
866
1031
|
touch();
|
|
867
1032
|
const self = randomId(10);
|
|
868
|
-
socket.on("close", () =>
|
|
1033
|
+
socket.on("close", () => {
|
|
1034
|
+
this.sockets.delete(socket);
|
|
1035
|
+
this.closeServersWhenUnwatched();
|
|
1036
|
+
});
|
|
869
1037
|
socket.on("message", (raw) => {
|
|
870
1038
|
let message;
|
|
871
1039
|
try {
|
|
@@ -883,22 +1051,104 @@ var Canvas = class {
|
|
|
883
1051
|
self,
|
|
884
1052
|
role: "owner",
|
|
885
1053
|
doc: { id: this.id, name: this.data.name, frames, strokes: this.data.strokes ?? [] },
|
|
886
|
-
threads: [],
|
|
1054
|
+
threads: this.data.threads ?? [],
|
|
887
1055
|
peers: [this.peer(self)],
|
|
888
|
-
|
|
1056
|
+
// Shared but signed out, this is a copy the room can't hear about.
|
|
1057
|
+
capabilities: { comments: !this.data.remote, huddle: false },
|
|
889
1058
|
working: this.workingLeft()
|
|
890
1059
|
}));
|
|
891
1060
|
}
|
|
892
1061
|
attachEvents(socket) {
|
|
893
1062
|
this.events.add(socket);
|
|
894
|
-
socket.on("close", () =>
|
|
1063
|
+
socket.on("close", () => {
|
|
1064
|
+
this.events.delete(socket);
|
|
1065
|
+
this.contexts.delete(socket);
|
|
1066
|
+
});
|
|
1067
|
+
socket.on("message", (raw) => {
|
|
1068
|
+
let message;
|
|
1069
|
+
try {
|
|
1070
|
+
message = JSON.parse(String(raw));
|
|
1071
|
+
} catch {
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
const context = message?.t === "context" ? cleanContext(message.context) : null;
|
|
1075
|
+
if (context) this.contexts.set(socket, { context, at: Date.now() });
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
/** What the person means by "this", from whichever of their windows changed it last. Null when none is open. */
|
|
1079
|
+
context() {
|
|
1080
|
+
let latest = null;
|
|
1081
|
+
for (const entry of this.contexts.values()) if (!latest || entry.at > latest.at) latest = entry;
|
|
1082
|
+
return latest?.context ?? null;
|
|
1083
|
+
}
|
|
1084
|
+
// ---------- comments, while local ----------
|
|
1085
|
+
author() {
|
|
1086
|
+
const me = credential?.me, id = me?.id || "local";
|
|
1087
|
+
return { id, name: me?.name || "You", picture: me?.picture || null, color: colorOf(id) };
|
|
1088
|
+
}
|
|
1089
|
+
thread(id) {
|
|
1090
|
+
return (this.data.threads ?? []).find((thread) => thread.id === id);
|
|
1091
|
+
}
|
|
1092
|
+
putThread(thread) {
|
|
1093
|
+
const threads = this.data.threads ??= [], index = threads.findIndex((item) => item.id === thread.id);
|
|
1094
|
+
if (index < 0) threads.push(thread);
|
|
1095
|
+
else threads[index] = thread;
|
|
1096
|
+
this.broadcast({ t: "thread", thread });
|
|
1097
|
+
this.save();
|
|
1098
|
+
}
|
|
1099
|
+
dropThread(id) {
|
|
1100
|
+
this.data.threads = (this.data.threads ?? []).filter((thread) => thread.id !== id);
|
|
1101
|
+
this.broadcast({ t: "thread.remove", id });
|
|
1102
|
+
this.save();
|
|
1103
|
+
}
|
|
1104
|
+
frameIdOf(id) {
|
|
1105
|
+
return typeof id === "string" && this.data.frames.some((frame) => frame.id === id) ? id : null;
|
|
1106
|
+
}
|
|
1107
|
+
handleComment(m) {
|
|
1108
|
+
const text2 = (value) => typeof value === "string" ? value.trim().slice(0, LIMITS.commentChars) : "";
|
|
1109
|
+
const comment = (value) => ({ id: randomId(12), author: this.author(), text: value, createdAt: Date.now() });
|
|
1110
|
+
switch (m.t) {
|
|
1111
|
+
case "thread.create": {
|
|
1112
|
+
const value = text2(m.text);
|
|
1113
|
+
if (!value || !finite(m.x) || !finite(m.y) || typeof m.id !== "string" || !THREAD_ID.test(m.id) || this.thread(m.id)) return;
|
|
1114
|
+
const first = comment(value);
|
|
1115
|
+
return this.putThread({ id: m.id, frameId: this.frameIdOf(m.frameId), x: m.x, y: m.y, resolved: false, createdAt: first.createdAt, comments: [first] });
|
|
1116
|
+
}
|
|
1117
|
+
case "thread.move": {
|
|
1118
|
+
const thread = this.thread(m.id);
|
|
1119
|
+
if (thread && finite(m.x) && finite(m.y)) this.putThread({ ...thread, frameId: this.frameIdOf(m.frameId), x: m.x, y: m.y });
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
case "thread.resolve": {
|
|
1123
|
+
const thread = this.thread(m.id);
|
|
1124
|
+
if (thread) this.putThread({ ...thread, resolved: !!m.resolved });
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
case "thread.delete":
|
|
1128
|
+
if (this.thread(m.id)) this.dropThread(m.id);
|
|
1129
|
+
return;
|
|
1130
|
+
case "comment.add": {
|
|
1131
|
+
const thread = this.thread(m.threadId), value = text2(m.text);
|
|
1132
|
+
if (thread && value) this.putThread({ ...thread, comments: [...thread.comments, comment(value)] });
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
case "comment.delete": {
|
|
1136
|
+
const thread = this.thread(m.threadId);
|
|
1137
|
+
if (!thread || !thread.comments.some((item) => item.id === m.id)) return;
|
|
1138
|
+
if (thread.comments[0].id === m.id) return this.dropThread(thread.id);
|
|
1139
|
+
this.putThread({ ...thread, comments: thread.comments.filter((item) => item.id !== m.id) });
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
895
1142
|
}
|
|
896
1143
|
async handle(socket, m) {
|
|
897
1144
|
if (m.t === "ping") return socket.send('{"t":"pong"}');
|
|
898
1145
|
if (this.data.remote) return;
|
|
899
1146
|
if (m.t === "frame.patch") return this.applyPatch(m.id, m.patch);
|
|
1147
|
+
if (m.t.startsWith("thread.") || m.t.startsWith("comment.")) return this.handleComment(m);
|
|
900
1148
|
if (m.t === "frame.remove") {
|
|
901
1149
|
const before = this.data.frames.length;
|
|
1150
|
+
const removed = this.data.frames.find((frame) => frame.id === m.id);
|
|
1151
|
+
if (removed) this.removedSources.set(removed.id, removed.source);
|
|
902
1152
|
this.data.frames = this.data.frames.filter((frame) => frame.id !== m.id);
|
|
903
1153
|
if (this.data.frames.length !== before) {
|
|
904
1154
|
this.broadcast({ t: "frame.remove", id: m.id });
|
|
@@ -909,9 +1159,9 @@ var Canvas = class {
|
|
|
909
1159
|
if (m.t === "frame.restore") {
|
|
910
1160
|
const f = m.frame;
|
|
911
1161
|
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() };
|
|
1162
|
+
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
1163
|
this.data.frames.push(restored);
|
|
914
|
-
|
|
1164
|
+
await this.announce(restored);
|
|
915
1165
|
this.save();
|
|
916
1166
|
return;
|
|
917
1167
|
}
|
|
@@ -948,16 +1198,16 @@ var Canvas = class {
|
|
|
948
1198
|
frame.w = Math.max(40, Math.min(8e3, frame.w));
|
|
949
1199
|
frame.h = Math.max(40, Math.min(8e3, frame.h));
|
|
950
1200
|
frame.updatedAt = Date.now();
|
|
951
|
-
|
|
1201
|
+
await this.announce(frame);
|
|
952
1202
|
this.save();
|
|
953
1203
|
return frame;
|
|
954
1204
|
}
|
|
955
1205
|
/** Once shared, the room is the truth. Bring the local list up to date with it. */
|
|
956
1206
|
async pull() {
|
|
957
1207
|
if (!this.data.remote) return;
|
|
958
|
-
let doc;
|
|
1208
|
+
let doc, threads;
|
|
959
1209
|
try {
|
|
960
|
-
({ doc } = await edge(`/api/canvases/${this.data.remote.id}`));
|
|
1210
|
+
({ doc, threads } = await edge(`/api/canvases/${this.data.remote.id}`));
|
|
961
1211
|
} catch (error) {
|
|
962
1212
|
if (error instanceof Failure && (error.status === 404 || error.status === 403)) {
|
|
963
1213
|
this.data.remote = null;
|
|
@@ -969,6 +1219,7 @@ var Canvas = class {
|
|
|
969
1219
|
}
|
|
970
1220
|
this.data.name = doc.name;
|
|
971
1221
|
if (doc.strokes) this.data.strokes = doc.strokes;
|
|
1222
|
+
if (threads) this.data.threads = threads;
|
|
972
1223
|
const known = new Map(this.data.frames.map((frame) => [frame.id, frame]));
|
|
973
1224
|
this.data.frames = doc.frames.map((frame) => ({
|
|
974
1225
|
id: frame.id,
|
|
@@ -982,7 +1233,8 @@ var Canvas = class {
|
|
|
982
1233
|
content: frame.content,
|
|
983
1234
|
level: frame.level,
|
|
984
1235
|
hasPoster: known.get(frame.id)?.hasPoster ?? !!frame.posterUrl,
|
|
985
|
-
updatedAt: frame.updatedAt
|
|
1236
|
+
updatedAt: frame.updatedAt,
|
|
1237
|
+
source: known.get(frame.id)?.source
|
|
986
1238
|
}));
|
|
987
1239
|
await this.saveNow();
|
|
988
1240
|
}
|
|
@@ -991,7 +1243,7 @@ var Canvas = class {
|
|
|
991
1243
|
const { files } = await folderContent(frameDir(this.project, frame.id));
|
|
992
1244
|
let poster = null, posterBytes = null;
|
|
993
1245
|
if (frame.hasPoster) {
|
|
994
|
-
posterBytes = await
|
|
1246
|
+
posterBytes = await readFile6(posterFile(this.project, frame.id)).catch(() => null);
|
|
995
1247
|
if (posterBytes) poster = createHash3("sha256").update(posterBytes).digest("hex");
|
|
996
1248
|
}
|
|
997
1249
|
const manifest = files.map(({ path, hash, size: size2 }) => ({ path, hash, size: size2 }));
|
|
@@ -1005,7 +1257,7 @@ var Canvas = class {
|
|
|
1005
1257
|
await Promise.all(Array.from({ length: 6 }, async () => {
|
|
1006
1258
|
while (next < queue.length) {
|
|
1007
1259
|
const file2 = queue[next++];
|
|
1008
|
-
await put(file2.hash, await
|
|
1260
|
+
await put(file2.hash, await readFile6(file2.absolute));
|
|
1009
1261
|
}
|
|
1010
1262
|
}));
|
|
1011
1263
|
if (poster && posterBytes && wanted.has(poster)) await put(poster, posterBytes);
|
|
@@ -1031,6 +1283,19 @@ var Canvas = class {
|
|
|
1031
1283
|
for (let i = 0; i < strokes.length; i += STROKE_UPLOAD) {
|
|
1032
1284
|
await edge(`/api/canvases/${created.id}/strokes`, { method: "POST", body: JSON.stringify({ strokes: strokes.slice(i, i + STROKE_UPLOAD) }) });
|
|
1033
1285
|
}
|
|
1286
|
+
let batch = [], chars = 0;
|
|
1287
|
+
const send = async () => {
|
|
1288
|
+
if (batch.length) await edge(`/api/canvases/${created.id}/threads`, { method: "POST", body: JSON.stringify({ threads: batch }) });
|
|
1289
|
+
batch = [];
|
|
1290
|
+
chars = 0;
|
|
1291
|
+
};
|
|
1292
|
+
for (const thread of this.data.threads ?? []) {
|
|
1293
|
+
const size2 = JSON.stringify(thread).length;
|
|
1294
|
+
if (chars + size2 > THREAD_UPLOAD_CHARS) await send();
|
|
1295
|
+
batch.push(thread);
|
|
1296
|
+
chars += size2;
|
|
1297
|
+
}
|
|
1298
|
+
await send();
|
|
1034
1299
|
} catch (error) {
|
|
1035
1300
|
await edge(`/api/canvases/${created.id}`, { method: "DELETE" }).catch(() => {
|
|
1036
1301
|
});
|
|
@@ -1043,8 +1308,8 @@ var Canvas = class {
|
|
|
1043
1308
|
}
|
|
1044
1309
|
async unshare() {
|
|
1045
1310
|
if (!this.data.remote) return;
|
|
1046
|
-
await this.pull()
|
|
1047
|
-
|
|
1311
|
+
await this.pull();
|
|
1312
|
+
if (!this.data.remote) return;
|
|
1048
1313
|
await edge(`/api/canvases/${this.data.remote.id}`, { method: "DELETE" }).catch((error) => {
|
|
1049
1314
|
if (error.status !== 404) throw error;
|
|
1050
1315
|
});
|
|
@@ -1054,16 +1319,24 @@ var Canvas = class {
|
|
|
1054
1319
|
}
|
|
1055
1320
|
};
|
|
1056
1321
|
var canvases = /* @__PURE__ */ new Map();
|
|
1057
|
-
|
|
1322
|
+
var opening = /* @__PURE__ */ new Map();
|
|
1323
|
+
function canvasById(id) {
|
|
1058
1324
|
const open = canvases.get(id);
|
|
1059
|
-
if (open) return open;
|
|
1060
|
-
|
|
1061
|
-
if (!
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1325
|
+
if (open) return Promise.resolve(open);
|
|
1326
|
+
let pending = opening.get(id);
|
|
1327
|
+
if (!pending) {
|
|
1328
|
+
pending = (async () => {
|
|
1329
|
+
const project = (await readRegistry())[id];
|
|
1330
|
+
if (!project) throw new Failure(404, "That canvas is not on this machine");
|
|
1331
|
+
const data = await readCanvas(project).catch(() => null);
|
|
1332
|
+
if (!data || data.id !== id) throw new Failure(404, "That canvas is not on this machine");
|
|
1333
|
+
const canvas = new Canvas(project, data);
|
|
1334
|
+
canvases.set(id, canvas);
|
|
1335
|
+
return canvas;
|
|
1336
|
+
})().finally(() => opening.delete(id));
|
|
1337
|
+
opening.set(id, pending);
|
|
1338
|
+
}
|
|
1339
|
+
return pending;
|
|
1067
1340
|
}
|
|
1068
1341
|
async function canvasForProject(project) {
|
|
1069
1342
|
const found = await findProject(project);
|
|
@@ -1085,8 +1358,36 @@ var summary = (canvas) => ({
|
|
|
1085
1358
|
name: canvas.data.name,
|
|
1086
1359
|
local: `${DAEMON_ORIGIN}/${canvas.id}`,
|
|
1087
1360
|
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 }))
|
|
1361
|
+
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
1362
|
});
|
|
1363
|
+
function cleanContext(input) {
|
|
1364
|
+
const c = input;
|
|
1365
|
+
if (!c || typeof c !== "object" || !Array.isArray(c.frames)) return null;
|
|
1366
|
+
const via = c.via === "fullscreen" || c.via === "interacting" || c.via === "selected" ? c.via : null;
|
|
1367
|
+
const frames = c.frames.filter((id) => typeof id === "string").slice(0, LIMITS.canvasFrames);
|
|
1368
|
+
return { via, frames, thread: typeof c.thread === "string" ? c.thread : null };
|
|
1369
|
+
}
|
|
1370
|
+
function commentsOf(canvas, thread) {
|
|
1371
|
+
const me = credential?.me?.id;
|
|
1372
|
+
const frame = thread.frameId ? canvas.data.frames.find((item) => item.id === thread.frameId) : void 0;
|
|
1373
|
+
return {
|
|
1374
|
+
id: thread.id,
|
|
1375
|
+
frame: frame ? { id: frame.id, title: frame.title } : null,
|
|
1376
|
+
// Where the pin is: in the frame's own pixels, or on the open canvas.
|
|
1377
|
+
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) },
|
|
1378
|
+
resolved: thread.resolved,
|
|
1379
|
+
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() }))
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
var visibleThreads = (canvas) => (canvas.data.threads ?? []).filter((thread) => !thread.frameId || canvas.data.frames.some((frame) => frame.id === thread.frameId));
|
|
1383
|
+
function contextOf(canvas) {
|
|
1384
|
+
const context = canvas.context();
|
|
1385
|
+
if (!context) return { looking: false, via: null, frames: [], comment: null };
|
|
1386
|
+
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 }));
|
|
1387
|
+
const thread = visibleThreads(canvas).find((item) => item.id === context.thread);
|
|
1388
|
+
return { looking: true, via: frames.length ? context.via : null, frames, comment: thread ? commentsOf(canvas, thread) : null };
|
|
1389
|
+
}
|
|
1390
|
+
var initTurn = inOrder();
|
|
1090
1391
|
function findFrame(canvas, ref) {
|
|
1091
1392
|
const byId = canvas.data.frames.find((frame) => frame.id === ref);
|
|
1092
1393
|
if (byId) return byId;
|
|
@@ -1097,6 +1398,7 @@ function findFrame(canvas, ref) {
|
|
|
1097
1398
|
async function command(name, input) {
|
|
1098
1399
|
touch();
|
|
1099
1400
|
if (name === "stop") {
|
|
1401
|
+
if (input.ifIdle && inFlight > 1) return { stopped: false };
|
|
1100
1402
|
setTimeout(() => process.exit(0), 50);
|
|
1101
1403
|
return { stopped: true };
|
|
1102
1404
|
}
|
|
@@ -1119,18 +1421,22 @@ async function command(name, input) {
|
|
|
1119
1421
|
return { signedIn: false };
|
|
1120
1422
|
}
|
|
1121
1423
|
if (name === "init") {
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
found
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1424
|
+
return initTurn(async () => {
|
|
1425
|
+
const project = input.project;
|
|
1426
|
+
const nearest = await findProject(project);
|
|
1427
|
+
let found = input.exact && nearest !== project ? null : nearest;
|
|
1428
|
+
const created = !found;
|
|
1429
|
+
if (!found) {
|
|
1430
|
+
await ensureProjectDir(project);
|
|
1431
|
+
await writeCanvas(project, { version: 1, id: randomId(12), name: String(input.name || "Untitled").slice(0, LIMITS.nameChars), frames: [], remote: null });
|
|
1432
|
+
found = project;
|
|
1433
|
+
}
|
|
1434
|
+
const canvas2 = await canvasForProject(found);
|
|
1435
|
+
if (created) tally("canvasesCreated");
|
|
1436
|
+
if (input.open !== false && !canvas2.watchers) openBrowser(`${DAEMON_ORIGIN}/${canvas2.id}`);
|
|
1437
|
+
canvas2.working(WORKING_MS, true);
|
|
1438
|
+
return { ...summary(canvas2), created };
|
|
1439
|
+
});
|
|
1134
1440
|
}
|
|
1135
1441
|
const canvas = await canvasForProject(input.project);
|
|
1136
1442
|
if (name === "working") return { working: canvas.working(Number(input.ms) || 0) > 0 };
|
|
@@ -1139,6 +1445,10 @@ async function command(name, input) {
|
|
|
1139
1445
|
switch (name) {
|
|
1140
1446
|
case "list":
|
|
1141
1447
|
return summary(canvas);
|
|
1448
|
+
case "context":
|
|
1449
|
+
return contextOf(canvas);
|
|
1450
|
+
case "comments":
|
|
1451
|
+
return { comments: visibleThreads(canvas).map((thread) => commentsOf(canvas, thread)) };
|
|
1142
1452
|
case "open":
|
|
1143
1453
|
openBrowser(`${DAEMON_ORIGIN}/${canvas.id}`);
|
|
1144
1454
|
return summary(canvas);
|
|
@@ -1179,7 +1489,7 @@ async function command(name, input) {
|
|
|
1179
1489
|
return summary(canvas);
|
|
1180
1490
|
}
|
|
1181
1491
|
case "rename":
|
|
1182
|
-
await
|
|
1492
|
+
await rename5(canvas, input.name);
|
|
1183
1493
|
return summary(canvas);
|
|
1184
1494
|
case "share": {
|
|
1185
1495
|
if (!credential) throw new Failure(401, "The person needs to sign in first. Ask them to click Share on the canvas.");
|
|
@@ -1194,7 +1504,7 @@ async function command(name, input) {
|
|
|
1194
1504
|
}
|
|
1195
1505
|
});
|
|
1196
1506
|
}
|
|
1197
|
-
async function
|
|
1507
|
+
async function rename5(canvas, name) {
|
|
1198
1508
|
const clean = String(name || "").trim().slice(0, LIMITS.nameChars);
|
|
1199
1509
|
if (!clean) throw new Failure(400, "Expected a name");
|
|
1200
1510
|
if (canvas.data.remote) await edge(`/api/canvases/${canvas.data.remote.id}`, { method: "PATCH", body: JSON.stringify({ name: clean }) });
|
|
@@ -1207,45 +1517,78 @@ async function patchFrame(canvas, id, patch) {
|
|
|
1207
1517
|
await canvas.applyPatch(id, patch);
|
|
1208
1518
|
await canvas.saveNow();
|
|
1209
1519
|
}
|
|
1520
|
+
async function install2(canvas, id, folder, screenshot) {
|
|
1521
|
+
await replaceFolder(folder, frameDir(canvas.project, id));
|
|
1522
|
+
if (screenshot) {
|
|
1523
|
+
const poster = posterFile(canvas.project, id);
|
|
1524
|
+
await mkdir7(dirname3(poster), { recursive: true });
|
|
1525
|
+
await copyFile(screenshot, poster);
|
|
1526
|
+
}
|
|
1527
|
+
return (await folderContent(frameDir(canvas.project, id))).content;
|
|
1528
|
+
}
|
|
1529
|
+
var placedFrame = ({ id, title, x, y, w, h }) => ({ id, title, x, y, w, h });
|
|
1530
|
+
var canvasResult = (canvas) => ({ local: `${DAEMON_ORIGIN}/${canvas.id}`, link: canvas.data.remote?.url || null, frames: canvas.data.frames.length });
|
|
1210
1531
|
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
|
-
|
|
1532
|
+
const count = canvas.data.frames.length;
|
|
1533
|
+
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"`);
|
|
1534
|
+
const frames = [];
|
|
1535
|
+
for (const item of input.items) {
|
|
1536
|
+
const title = String(item.title).slice(0, LIMITS.titleChars);
|
|
1537
|
+
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.`] : [];
|
|
1538
|
+
const content = await install2(canvas, item.id, item.folder, item.screenshot);
|
|
1539
|
+
const position = nextPosition(canvas.data.frames, !!item.newRow);
|
|
1540
|
+
const frame = {
|
|
1541
|
+
id: item.id,
|
|
1542
|
+
title,
|
|
1543
|
+
w: item.w,
|
|
1544
|
+
h: item.h,
|
|
1545
|
+
x: typeof item.x === "number" ? item.x : position.x,
|
|
1546
|
+
y: typeof item.y === "number" ? item.y : position.y,
|
|
1547
|
+
z: canvas.data.frames.length,
|
|
1548
|
+
entry: item.entry,
|
|
1549
|
+
content,
|
|
1550
|
+
level: item.level,
|
|
1551
|
+
hasPoster: !!item.screenshot,
|
|
1552
|
+
updatedAt: Date.now(),
|
|
1553
|
+
source: typeof item.source === "string" ? item.source : void 0
|
|
1554
|
+
};
|
|
1555
|
+
if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, frame);
|
|
1556
|
+
canvas.data.frames.push(frame);
|
|
1557
|
+
await canvas.saveNow();
|
|
1558
|
+
await canvas.announce(frame);
|
|
1559
|
+
frames.push({ ...placedFrame(frame), warnings });
|
|
1560
|
+
}
|
|
1230
1561
|
canvas.working(WORKING_TAIL_MS);
|
|
1231
|
-
|
|
1562
|
+
tally("framesAdded", frames.length);
|
|
1563
|
+
return { frames, canvas: canvasResult(canvas) };
|
|
1232
1564
|
}
|
|
1233
1565
|
async function revise(canvas, input) {
|
|
1234
|
-
const
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1566
|
+
const targets = input.items.map((item) => {
|
|
1567
|
+
const frame = canvas.data.frames.find((other) => other.id === item.id);
|
|
1568
|
+
if (!frame) throw new Failure(404, `"${item.label}" is gone. Run: twombly list`);
|
|
1569
|
+
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.`);
|
|
1570
|
+
return frame;
|
|
1571
|
+
});
|
|
1572
|
+
const frames = [];
|
|
1573
|
+
for (const [index, item] of input.items.entries()) {
|
|
1574
|
+
const frame = targets[index];
|
|
1575
|
+
const next = { ...frame, updatedAt: Date.now() };
|
|
1576
|
+
if (item.title) next.title = String(item.title).slice(0, LIMITS.titleChars);
|
|
1577
|
+
for (const key of ["w", "h"]) if (typeof item[key] === "number") next[key] = item[key];
|
|
1578
|
+
if (item.entry) next.entry = item.entry;
|
|
1579
|
+
if (item.level) next.level = item.level;
|
|
1580
|
+
if (typeof item.source === "string") next.source = item.source;
|
|
1581
|
+
next.content = await install2(canvas, item.id, item.folder, item.screenshot);
|
|
1582
|
+
if (item.screenshot) next.hasPoster = true;
|
|
1583
|
+
if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, next);
|
|
1584
|
+
Object.assign(frame, next);
|
|
1585
|
+
await canvas.saveNow();
|
|
1586
|
+
await canvas.announce(frame);
|
|
1587
|
+
frames.push({ ...placedFrame(frame), warnings: [] });
|
|
1588
|
+
}
|
|
1247
1589
|
canvas.working(WORKING_TAIL_MS);
|
|
1248
|
-
|
|
1590
|
+
tally("framesUpdated", frames.length);
|
|
1591
|
+
return { frames, canvas: canvasResult(canvas) };
|
|
1249
1592
|
}
|
|
1250
1593
|
var JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
|
|
1251
1594
|
var reply = (response, status, value) => {
|
|
@@ -1295,7 +1638,7 @@ async function serveUi(response, pathname) {
|
|
|
1295
1638
|
let file2 = null;
|
|
1296
1639
|
if (clean && !clean.includes("..")) {
|
|
1297
1640
|
for (const folder of interfaceFolders()) {
|
|
1298
|
-
const candidate =
|
|
1641
|
+
const candidate = join9(folder, clean);
|
|
1299
1642
|
if ((await stat3(candidate).catch(() => null))?.isFile()) {
|
|
1300
1643
|
file2 = candidate;
|
|
1301
1644
|
break;
|
|
@@ -1304,7 +1647,7 @@ async function serveUi(response, pathname) {
|
|
|
1304
1647
|
}
|
|
1305
1648
|
if (!file2) {
|
|
1306
1649
|
await freshInterface();
|
|
1307
|
-
file2 =
|
|
1650
|
+
file2 = join9(interfaceFolders()[0], "index.html");
|
|
1308
1651
|
}
|
|
1309
1652
|
const type = contentType(file2);
|
|
1310
1653
|
const headers = { "content-type": type, "x-content-type-options": "nosniff", "cache-control": extname2(file2) === ".html" ? "no-cache" : "public, max-age=3600" };
|
|
@@ -1331,15 +1674,15 @@ async function servePreview(response, canvasId, frameId) {
|
|
|
1331
1674
|
async function route(request, response) {
|
|
1332
1675
|
const url = new URL(request.url || "/", DAEMON_ORIGIN);
|
|
1333
1676
|
const path = url.pathname, method = request.method || "GET";
|
|
1334
|
-
if (path === "/health") return reply(response, 200, { ok: true, build: "0.
|
|
1677
|
+
if (path === "/health") return reply(response, 200, { ok: true, build: "0.4.0+1793918977725", api: TOOL_API, pid: process.pid });
|
|
1335
1678
|
if (path.startsWith("/cli/") && method === "POST") return reply(response, 200, await command(path.slice(5), await readBody(request)));
|
|
1336
1679
|
if (path === "/auth/callback" && method === "GET") {
|
|
1337
|
-
const code = url.searchParams.get("code"),
|
|
1680
|
+
const code = url.searchParams.get("code"), state2 = url.searchParams.get("state");
|
|
1338
1681
|
const page = (text2, status = 200) => {
|
|
1339
1682
|
response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'" });
|
|
1340
1683
|
response.end(`<!doctype html><meta charset="utf-8"><title>Twombly</title><style>body{font:13px/1.5 -apple-system,system-ui,sans-serif;display:grid;place-items:center;height:100vh;margin:0}@media(prefers-color-scheme:dark){body{background:#1e1e1e;color:#fff}}</style><p>${text2}</p><script>setTimeout(()=>window.close(),600)</script>`);
|
|
1341
1684
|
};
|
|
1342
|
-
if (!code || !pendingLogin ||
|
|
1685
|
+
if (!code || !pendingLogin || state2 !== pendingLogin.state) return page("That sign-in expired. Try again.", 400);
|
|
1343
1686
|
const exchange = await fetch(`${ENDPOINT}/auth/machine/exchange`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code, verifier: pendingLogin.verifier }) });
|
|
1344
1687
|
const data = await exchange.json().catch(() => ({}));
|
|
1345
1688
|
pendingLogin = null;
|
|
@@ -1363,8 +1706,9 @@ async function route(request, response) {
|
|
|
1363
1706
|
}
|
|
1364
1707
|
if (path === "/api/login" && method === "POST") return reply(response, 200, await command("login", {}));
|
|
1365
1708
|
if (path === "/api/settings" && method === "POST") {
|
|
1366
|
-
const { invertScroll } = await readBody(request);
|
|
1709
|
+
const { invertScroll, theme } = await readBody(request);
|
|
1367
1710
|
if (typeof invertScroll === "boolean") settings = { ...settings, invertScroll };
|
|
1711
|
+
if (theme === "light" || theme === "dark") settings = { ...settings, theme };
|
|
1368
1712
|
await writeSettings(settings);
|
|
1369
1713
|
for (const canvas of canvases.values()) canvas.event({ t: "settings", settings });
|
|
1370
1714
|
return reply(response, 200, settings);
|
|
@@ -1376,8 +1720,18 @@ async function route(request, response) {
|
|
|
1376
1720
|
if (!canvas.data.remote || !credential) return reply(response, 200, { url: `ws://localhost:${PORT}/local/${canvas.id}` });
|
|
1377
1721
|
return reply(response, 200, await edge(`/api/canvases/${canvas.data.remote.id}/ticket`, { method: "POST", body: "{}" }));
|
|
1378
1722
|
}
|
|
1379
|
-
if (match[2] === "share")
|
|
1723
|
+
if (match[2] === "share") {
|
|
1724
|
+
const url2 = await canvas.run(() => canvas.share());
|
|
1725
|
+
tally("shares");
|
|
1726
|
+
return reply(response, 200, { url: url2 });
|
|
1727
|
+
}
|
|
1380
1728
|
await canvas.run(() => canvas.unshare());
|
|
1729
|
+
tally("unshares");
|
|
1730
|
+
return reply(response, 200, { ok: true });
|
|
1731
|
+
}
|
|
1732
|
+
if (path === "/api/errors" && method === "POST") {
|
|
1733
|
+
const body = await readBody(request);
|
|
1734
|
+
if (typeof body.message === "string") reportError(body, "canvas");
|
|
1381
1735
|
return reply(response, 200, { ok: true });
|
|
1382
1736
|
}
|
|
1383
1737
|
if (path.startsWith("/api/")) return reply(response, 404, { error: "Not found" });
|
|
@@ -1386,6 +1740,7 @@ async function route(request, response) {
|
|
|
1386
1740
|
if (preview) return servePreview(response, preview[1], preview[2]);
|
|
1387
1741
|
return serveUi(response, path);
|
|
1388
1742
|
}
|
|
1743
|
+
var inFlight = 0;
|
|
1389
1744
|
var idleTimer = null;
|
|
1390
1745
|
function touch() {
|
|
1391
1746
|
if (idleTimer) clearTimeout(idleTimer);
|
|
@@ -1403,6 +1758,8 @@ async function runDaemon() {
|
|
|
1403
1758
|
response.end('{"error":"Forbidden"}');
|
|
1404
1759
|
return;
|
|
1405
1760
|
}
|
|
1761
|
+
const counted = /^\/(cli|api\/canvases)\//.test(request.url || "");
|
|
1762
|
+
if (counted) inFlight++;
|
|
1406
1763
|
try {
|
|
1407
1764
|
await route(request, response);
|
|
1408
1765
|
} catch (error) {
|
|
@@ -1412,7 +1769,10 @@ async function runDaemon() {
|
|
|
1412
1769
|
}
|
|
1413
1770
|
if (error instanceof Failure) return reply(response, error.status, { error: error.message, ...error.extra });
|
|
1414
1771
|
console.error(error);
|
|
1772
|
+
reportError(error instanceof Error ? { type: error.name, message: error.message, stack: error.stack } : { message: String(error) });
|
|
1415
1773
|
reply(response, 500, { error: error instanceof Error ? error.message : "Something went wrong" });
|
|
1774
|
+
} finally {
|
|
1775
|
+
if (counted) inFlight--;
|
|
1416
1776
|
}
|
|
1417
1777
|
});
|
|
1418
1778
|
const sockets = new WebSocketServer({ noServer: true });
|
|
@@ -1429,7 +1789,10 @@ async function runDaemon() {
|
|
|
1429
1789
|
}
|
|
1430
1790
|
sockets.handleUpgrade(request, socket, head, (client) => {
|
|
1431
1791
|
if (match[2]) canvas.attachEvents(client);
|
|
1432
|
-
else
|
|
1792
|
+
else {
|
|
1793
|
+
tally("canvasViews");
|
|
1794
|
+
canvas.attach(client).catch(() => client.close());
|
|
1795
|
+
}
|
|
1433
1796
|
});
|
|
1434
1797
|
});
|
|
1435
1798
|
await new Promise((resolve3, reject) => {
|
|
@@ -1439,12 +1802,14 @@ async function runDaemon() {
|
|
|
1439
1802
|
touch();
|
|
1440
1803
|
startInterfaceUpdates().catch(() => {
|
|
1441
1804
|
});
|
|
1805
|
+
startUsage().catch(() => {
|
|
1806
|
+
});
|
|
1442
1807
|
}
|
|
1443
1808
|
|
|
1444
1809
|
// src/gates.ts
|
|
1445
1810
|
import { createServer as createServer3 } from "node:http";
|
|
1446
|
-
import { mkdir as
|
|
1447
|
-
import { dirname as
|
|
1811
|
+
import { mkdir as mkdir8, readFile as readFile7, stat as stat4, writeFile as writeFile7 } from "node:fs/promises";
|
|
1812
|
+
import { dirname as dirname4 } from "node:path";
|
|
1448
1813
|
var dedupe = (problems) => [...new Map(problems.map((p) => [`${p.code}:${p.file || ""}:${p.message}`, p])).values()];
|
|
1449
1814
|
async function scanFolder(folder) {
|
|
1450
1815
|
const problems = [];
|
|
@@ -1460,7 +1825,7 @@ async function scanFolder(folder) {
|
|
|
1460
1825
|
continue;
|
|
1461
1826
|
}
|
|
1462
1827
|
if (file2.size > LIMITS.fileBytes) problems.push({ code: "FILE_TOO_LARGE", message: `"${file2.path}" is ${(file2.size / 1048576).toFixed(1)} MB; the limit is ${LIMITS.fileBytes / 1048576} MB.`, fix: "Compress it, or swap in a smaller asset.", file: file2.path });
|
|
1463
|
-
problems.push(...scanBytes(file2.path, await
|
|
1828
|
+
problems.push(...scanBytes(file2.path, await readFile7(file2.absolute)));
|
|
1464
1829
|
}
|
|
1465
1830
|
if (files.length > LIMITS.frameFiles) problems.push({ code: "TOO_MANY_FILES", message: `${files.length} files; the limit is ${LIMITS.frameFiles}.`, fix: "This looks like more than one built page. Add only what the frame needs." });
|
|
1466
1831
|
if (bytes > LIMITS.frameBytes) {
|
|
@@ -1478,7 +1843,7 @@ function entryProblems(files, entry) {
|
|
|
1478
1843
|
async function findReferences(folder, needle) {
|
|
1479
1844
|
const found = [];
|
|
1480
1845
|
for (const file2 of await hashFolder(folder)) {
|
|
1481
|
-
const bytes = await
|
|
1846
|
+
const bytes = await readFile7(file2.absolute);
|
|
1482
1847
|
if (looksLikeText(bytes) && bytes.includes(needle)) found.push(file2.path);
|
|
1483
1848
|
if (found.length === 3) break;
|
|
1484
1849
|
}
|
|
@@ -1571,8 +1936,8 @@ async function browserGates(folder, options, launch) {
|
|
|
1571
1936
|
const shot = await page.locator("#frame").screenshot({ type: "png", timeout: 15e3 }).catch(() => null);
|
|
1572
1937
|
if (shot) {
|
|
1573
1938
|
if (options.poster) {
|
|
1574
|
-
await
|
|
1575
|
-
await
|
|
1939
|
+
await mkdir8(dirname4(options.poster), { recursive: true });
|
|
1940
|
+
await writeFile7(options.poster, shot);
|
|
1576
1941
|
}
|
|
1577
1942
|
const colors = await page.evaluate(async (base64) => {
|
|
1578
1943
|
const image = new Image();
|
|
@@ -1635,9 +2000,12 @@ async function runGates(folder, options) {
|
|
|
1635
2000
|
}
|
|
1636
2001
|
|
|
1637
2002
|
// src/cli.ts
|
|
1638
|
-
var HELP = `twombly ${"0.
|
|
2003
|
+
var HELP = `twombly ${"0.4.0"}: put design explorations on a shared canvas.
|
|
1639
2004
|
|
|
1640
|
-
init [--name "Name"] [--no-open] create or
|
|
2005
|
+
init [--name "Name"] [--no-open] create or join this folder's canvas
|
|
2006
|
+
init --canvas "Name" [--no-open] create or join a separate named canvas in this folder
|
|
2007
|
+
canvases list the canvases this folder can reach
|
|
2008
|
+
--canvas "Name" works on every command below, and picks that canvas
|
|
1641
2009
|
add <folder|file> --title "T" run the gates, then place a frame
|
|
1642
2010
|
[--width 1280] [--height 800|auto] [--entry index.html] [--level build|component|capture|image]
|
|
1643
2011
|
[--new-row] [--x N --y N]
|
|
@@ -1650,9 +2018,12 @@ var HELP = `twombly ${"0.2.0"}: put design explorations on a shared canvas.
|
|
|
1650
2018
|
[--width 1280] [--height 800] [--image] [--selector css]
|
|
1651
2019
|
preview <frame> a page with just that frame, for testing interactions
|
|
1652
2020
|
working | done show on the canvas that you're building, or that you've finished
|
|
2021
|
+
context what the person has selected, open fullscreen or open as a comment
|
|
2022
|
+
comments every comment, and which are the person's own
|
|
1653
2023
|
list | move <frame> --x N --y N | resize <frame> --width N --height N
|
|
1654
2024
|
retitle <frame> "T" | remove <frame> | arrange [--columns N] | rename "Name"
|
|
1655
2025
|
open | status | login | logout | stop
|
|
2026
|
+
usage on | off the daily counts and error reports this machine sends to Twombly
|
|
1656
2027
|
share | unshare only when the person asks: this publishes a public link
|
|
1657
2028
|
guide how to adapt a project into frames. Read this first.`;
|
|
1658
2029
|
var SWITCHES = /* @__PURE__ */ new Set(["no-open", "new-row", "image"]);
|
|
@@ -1688,7 +2059,7 @@ function parse(argv) {
|
|
|
1688
2059
|
return { positional, flags };
|
|
1689
2060
|
}
|
|
1690
2061
|
function checkFlags(name, flags) {
|
|
1691
|
-
const known = FLAGS[name] || [];
|
|
2062
|
+
const known = name === "canvases" ? [] : [...FLAGS[name] || [], "canvas"];
|
|
1692
2063
|
for (const flag of Object.keys(flags)) {
|
|
1693
2064
|
if (!known.includes(flag)) fail(`Unknown flag --${flag}. ${known.length ? `"${name}" takes: ${known.map((item) => `--${item}`).join(", ")}.` : `"${name}" takes no flags.`}`);
|
|
1694
2065
|
}
|
|
@@ -1728,18 +2099,31 @@ async function health() {
|
|
|
1728
2099
|
return null;
|
|
1729
2100
|
}
|
|
1730
2101
|
}
|
|
2102
|
+
var builtAt = (build) => Number(build.split("+")[1]) || 0;
|
|
2103
|
+
var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
2104
|
+
var IDLE_WAIT_MS = 6e4;
|
|
2105
|
+
var usable = (running) => !!running && (running.build === "0.4.0+1793918977725" || running.api === TOOL_API && builtAt(running.build) > builtAt("0.4.0+1793918977725"));
|
|
1731
2106
|
async function ensureDaemon() {
|
|
1732
2107
|
const running = await health();
|
|
1733
|
-
if (running
|
|
2108
|
+
if (usable(running)) return;
|
|
1734
2109
|
if (running) {
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
2110
|
+
if (builtAt(running.build) > builtAt("0.4.0+1793918977725")) fail("A newer twombly is running on this machine. Run this with twombly@latest.");
|
|
2111
|
+
let stopped = false;
|
|
2112
|
+
for (const end = Date.now() + IDLE_WAIT_MS; !stopped && Date.now() < end; ) {
|
|
2113
|
+
const answer = await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: '{"ifIdle":true}' }).then((response) => response.json()).catch(() => ({ stopped: true }));
|
|
2114
|
+
stopped = !!answer.stopped;
|
|
2115
|
+
if (!stopped) await sleep(500);
|
|
2116
|
+
}
|
|
2117
|
+
if (!stopped) {
|
|
2118
|
+
if (running.api === TOOL_API) return;
|
|
2119
|
+
fail("The background server is busy with another agent's work. Try again in a minute.");
|
|
2120
|
+
}
|
|
2121
|
+
await sleep(200);
|
|
1738
2122
|
}
|
|
1739
2123
|
spawn2(process.execPath, [fileURLToPath2(import.meta.url), "__daemon"], { detached: true, stdio: "ignore" }).unref();
|
|
1740
2124
|
for (let attempt = 0; attempt < 60; attempt++) {
|
|
1741
|
-
await
|
|
1742
|
-
if ((await health())
|
|
2125
|
+
await sleep(100);
|
|
2126
|
+
if (usable(await health())) return;
|
|
1743
2127
|
}
|
|
1744
2128
|
fail(`Could not start the background server on port ${PORT}. Is something else using it?`);
|
|
1745
2129
|
}
|
|
@@ -1758,23 +2142,30 @@ async function prepare(source) {
|
|
|
1758
2142
|
const extension = extname3(source).toLowerCase();
|
|
1759
2143
|
const isImage = IMAGE.has(extension);
|
|
1760
2144
|
if (!isImage && extension !== ".html" && extension !== ".htm") fail("Point at a folder of static files, an HTML file or an image.");
|
|
1761
|
-
const temporary = await mkdtemp(
|
|
2145
|
+
const temporary = await mkdtemp(join10(tmpdir(), "twombly-"));
|
|
1762
2146
|
if (isImage) {
|
|
1763
2147
|
const name = `image${extension}`;
|
|
1764
|
-
await
|
|
1765
|
-
await
|
|
2148
|
+
await copyFile2(source, join10(temporary, name));
|
|
2149
|
+
await writeFile8(join10(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
2150
|
} else {
|
|
1767
|
-
await
|
|
2151
|
+
await copyFile2(source, join10(temporary, "index.html"));
|
|
1768
2152
|
}
|
|
1769
2153
|
return { folder: temporary, temporary, image: isImage };
|
|
1770
2154
|
}
|
|
2155
|
+
var chosenCanvas;
|
|
2156
|
+
async function locate() {
|
|
2157
|
+
if (chosenCanvas === void 0) return findProject(process.cwd());
|
|
2158
|
+
return (await findNamed(process.cwd(), chosenCanvas))?.project ?? null;
|
|
2159
|
+
}
|
|
1771
2160
|
async function currentProject() {
|
|
1772
|
-
const project = await
|
|
1773
|
-
if (
|
|
1774
|
-
|
|
2161
|
+
const project = await locate();
|
|
2162
|
+
if (project) return project;
|
|
2163
|
+
if (chosenCanvas !== void 0) fail(`No canvas "${chosenCanvas}" here. Run: twombly init --canvas "${chosenCanvas}"`);
|
|
2164
|
+
const others = await canvasesAbove(process.cwd());
|
|
2165
|
+
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
2166
|
}
|
|
1776
2167
|
async function markWorking() {
|
|
1777
|
-
const project = await
|
|
2168
|
+
const project = await locate();
|
|
1778
2169
|
if (project) await call("working", { project, ms: WORKING_MS });
|
|
1779
2170
|
}
|
|
1780
2171
|
function gateFailure(result) {
|
|
@@ -1790,7 +2181,7 @@ async function frameItems(flags, known) {
|
|
|
1790
2181
|
const { frames: _, ...shared } = flags;
|
|
1791
2182
|
let items;
|
|
1792
2183
|
try {
|
|
1793
|
-
items = JSON.parse(value.trimStart().startsWith("[") ? value : await
|
|
2184
|
+
items = JSON.parse(value.trimStart().startsWith("[") ? value : await readFile8(value, "utf8"));
|
|
1794
2185
|
} catch (error) {
|
|
1795
2186
|
fail(`--frames: ${error instanceof Error ? error.message : error}`);
|
|
1796
2187
|
}
|
|
@@ -1808,9 +2199,9 @@ async function frameItems(flags, known) {
|
|
|
1808
2199
|
return merged;
|
|
1809
2200
|
});
|
|
1810
2201
|
}
|
|
1811
|
-
async function placeFrames(project, many, jobs,
|
|
2202
|
+
async function placeFrames(project, many, jobs, command2, item) {
|
|
1812
2203
|
await markWorking();
|
|
1813
|
-
const scratch = await mkdtemp(
|
|
2204
|
+
const scratch = await mkdtemp(join10(tmpdir(), "twombly-gates-"));
|
|
1814
2205
|
const gates = gateSession(), prepared = /* @__PURE__ */ new Map(), temporary = [];
|
|
1815
2206
|
let keepScratch = false;
|
|
1816
2207
|
try {
|
|
@@ -1825,7 +2216,7 @@ async function placeFrames(project, many, jobs, place2) {
|
|
|
1825
2216
|
await Promise.all(Array.from({ length: CONCURRENT_GATES }, async () => {
|
|
1826
2217
|
while (next < jobs.length) {
|
|
1827
2218
|
const index = next++, job = jobs[index];
|
|
1828
|
-
results[index] = await gates.run(prepared.get(job.source).folder, { entry: job.entry, width: job.width, height: job.height, poster:
|
|
2219
|
+
results[index] = await gates.run(prepared.get(job.source).folder, { entry: job.entry, width: job.width, height: job.height, poster: join10(scratch, `${job.id}.png`) });
|
|
1829
2220
|
}
|
|
1830
2221
|
}));
|
|
1831
2222
|
if (results.some((result) => !result.ok)) {
|
|
@@ -1836,23 +2227,21 @@ async function placeFrames(project, many, jobs, place2) {
|
|
|
1836
2227
|
warnings: results.flatMap((result, index) => result.warnings.map((warning) => `${jobs[index].label}: ${warning}`))
|
|
1837
2228
|
});
|
|
1838
2229
|
}
|
|
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 });
|
|
2230
|
+
const items = jobs.map((job, index) => {
|
|
2231
|
+
const { folder, image } = prepared.get(job.source);
|
|
2232
|
+
return { id: job.id, folder, source: job.source, screenshot: results[index].screenshot, ...item(job, { result: results[index], folder, image, screenshot: results[index].screenshot }) };
|
|
2233
|
+
});
|
|
2234
|
+
const { frames, canvas } = await call(command2, { project, items });
|
|
2235
|
+
const placed = frames.map(({ warnings, ...frame }, index) => {
|
|
2236
|
+
const result = results[index];
|
|
2237
|
+
return { frame, warnings: [...result.warnings, ...warnings], screenshot: result.screenshot ? posterFile(project, jobs[index].id) : void 0, contentSize: result.content };
|
|
2238
|
+
});
|
|
2239
|
+
if (!many) return print({ ok: true, placed: true, ...placed[0], canvas, next: NEXT });
|
|
2240
|
+
print({ ok: true, placed: true, frames: placed.map(({ frame, ...rest }) => ({ ...frame, ...rest })), canvas, next: NEXT });
|
|
1852
2241
|
} finally {
|
|
1853
2242
|
await gates.close();
|
|
1854
|
-
for (const folder of temporary) await
|
|
1855
|
-
if (!keepScratch) await
|
|
2243
|
+
for (const folder of temporary) await rm6(folder, { recursive: true, force: true });
|
|
2244
|
+
if (!keepScratch) await rm6(scratch, { recursive: true, force: true });
|
|
1856
2245
|
}
|
|
1857
2246
|
}
|
|
1858
2247
|
async function addFrames(project, source, many, items) {
|
|
@@ -1869,33 +2258,23 @@ async function addFrames(project, source, many, items) {
|
|
|
1869
2258
|
entry: text(flags, "entry") || "index.html",
|
|
1870
2259
|
width: size(flags, "width", 1280),
|
|
1871
2260
|
height: flags.height === "auto" ? "auto" : size(flags, "height", 800),
|
|
1872
|
-
id:
|
|
2261
|
+
id: randomBytes3(8).toString("hex").replace(/[01lo]/g, "a").slice(0, 12)
|
|
1873
2262
|
};
|
|
1874
2263
|
});
|
|
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
|
-
});
|
|
2264
|
+
await placeFrames(project, many, jobs, "place", (job, { result, image }) => ({
|
|
2265
|
+
title: job.label,
|
|
2266
|
+
w: job.width,
|
|
2267
|
+
h: result.height,
|
|
2268
|
+
entry: job.entry,
|
|
2269
|
+
level: level(job.flags, image ? "image" : "build"),
|
|
2270
|
+
x: number(job.flags, "x"),
|
|
2271
|
+
y: number(job.flags, "y"),
|
|
2272
|
+
newRow: job.flags["new-row"] === true
|
|
2273
|
+
}));
|
|
1895
2274
|
}
|
|
1896
2275
|
async function updateFrames(project, source, many, items) {
|
|
1897
2276
|
const { frames } = await call("list", { project });
|
|
1898
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2277
|
+
const seen = /* @__PURE__ */ new Set(), bases = /* @__PURE__ */ new Map();
|
|
1899
2278
|
const jobs = items.map((flags) => {
|
|
1900
2279
|
const ref = text(flags, "frame"), folder = text(flags, "folder") || source;
|
|
1901
2280
|
if (!ref || !folder) fail("twombly update <frame> <folder|file>");
|
|
@@ -1904,6 +2283,7 @@ async function updateFrames(project, source, many, items) {
|
|
|
1904
2283
|
const frame = matches[0];
|
|
1905
2284
|
if (seen.has(frame.id)) fail(`--frames lists "${ref}" twice.`);
|
|
1906
2285
|
seen.add(frame.id);
|
|
2286
|
+
bases.set(frame.id, frame.content);
|
|
1907
2287
|
level(flags, "build");
|
|
1908
2288
|
return {
|
|
1909
2289
|
flags,
|
|
@@ -1915,20 +2295,15 @@ async function updateFrames(project, source, many, items) {
|
|
|
1915
2295
|
height: flags.height === "auto" ? "auto" : size(flags, "height", frame.h)
|
|
1916
2296
|
};
|
|
1917
2297
|
});
|
|
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
|
-
});
|
|
2298
|
+
await placeFrames(project, many, jobs, "revise", (job, { result, image }) => ({
|
|
2299
|
+
label: job.label,
|
|
2300
|
+
base: bases.get(job.id),
|
|
2301
|
+
title: text(job.flags, "title"),
|
|
2302
|
+
w: job.flags.width !== void 0 ? job.width : void 0,
|
|
2303
|
+
h: job.flags.height !== void 0 ? result.height : void 0,
|
|
2304
|
+
entry: text(job.flags, "entry"),
|
|
2305
|
+
level: text(job.flags, "level") || image ? level(job.flags, image ? "image" : "build") : void 0
|
|
2306
|
+
}));
|
|
1932
2307
|
}
|
|
1933
2308
|
async function main() {
|
|
1934
2309
|
const [name, ...rest] = process.argv.slice(2);
|
|
@@ -1938,6 +2313,10 @@ async function main() {
|
|
|
1938
2313
|
return;
|
|
1939
2314
|
}
|
|
1940
2315
|
if (name && !["--help", "-h", "--version", "help", "version", "guide", "__daemon"].includes(name)) checkFlags(name, flags);
|
|
2316
|
+
if (flags.canvas !== void 0) {
|
|
2317
|
+
if (typeof flags.canvas !== "string" || !flags.canvas.trim()) fail("--canvas needs a name.");
|
|
2318
|
+
chosenCanvas = flags.canvas.trim();
|
|
2319
|
+
}
|
|
1941
2320
|
switch (name) {
|
|
1942
2321
|
case "__daemon":
|
|
1943
2322
|
return runDaemon();
|
|
@@ -1949,15 +2328,30 @@ async function main() {
|
|
|
1949
2328
|
return;
|
|
1950
2329
|
case "--version":
|
|
1951
2330
|
case "version":
|
|
1952
|
-
process.stdout.write("0.
|
|
2331
|
+
process.stdout.write("0.4.0\n");
|
|
1953
2332
|
return;
|
|
1954
2333
|
case "guide":
|
|
1955
2334
|
process.stdout.write(SKILL_default);
|
|
1956
2335
|
return;
|
|
1957
2336
|
case "init": {
|
|
2337
|
+
const next = "Frames must be static files that work with no server or network. Read how to adapt this project: twombly guide";
|
|
2338
|
+
const open = flags["no-open"] === void 0, given = text(flags, "name");
|
|
2339
|
+
if (chosenCanvas !== void 0) {
|
|
2340
|
+
if (given) fail("Use --canvas or --name, not both. --canvas is the name.");
|
|
2341
|
+
const found = await findNamed(process.cwd(), chosenCanvas);
|
|
2342
|
+
const project2 = found?.project || namedProject(await findRoot(process.cwd()) || process.cwd(), chosenCanvas);
|
|
2343
|
+
print({ ok: true, ...await call("init", { project: project2, exact: true, name: chosenCanvas, open }), next });
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
1958
2346
|
const project = await findProject(process.cwd()) || process.cwd();
|
|
1959
|
-
const
|
|
1960
|
-
|
|
2347
|
+
const result = await call("init", { project, name: given || defaultName(project), open });
|
|
2348
|
+
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;
|
|
2349
|
+
print({ ok: true, ...result, note, next });
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
case "canvases": {
|
|
2353
|
+
const found = await canvasesAbove(process.cwd());
|
|
2354
|
+
print({ ok: true, canvases: found.map(({ name: name2, slug, frames, id, link }) => ({ name: name2, canvas: slug, frames, local: `${DAEMON_ORIGIN}/${id}`, link })) });
|
|
1961
2355
|
return;
|
|
1962
2356
|
}
|
|
1963
2357
|
case "check": {
|
|
@@ -1965,15 +2359,15 @@ async function main() {
|
|
|
1965
2359
|
if (!source) fail("twombly check <folder> checks a build before it is added. It needs the build's folder.");
|
|
1966
2360
|
await markWorking();
|
|
1967
2361
|
const { folder, temporary } = await prepare(resolve2(source));
|
|
1968
|
-
const scratch = await mkdtemp(
|
|
1969
|
-
const shot =
|
|
2362
|
+
const scratch = await mkdtemp(join10(tmpdir(), "twombly-check-"));
|
|
2363
|
+
const shot = join10(scratch, "screenshot.png");
|
|
1970
2364
|
try {
|
|
1971
2365
|
const height = flags.height === "auto" ? "auto" : size(flags, "height", 800);
|
|
1972
2366
|
const result = await runGates(folder, { entry: text(flags, "entry") || "index.html", width: size(flags, "width", 1280), height, poster: shot });
|
|
1973
2367
|
print(result);
|
|
1974
2368
|
if (!result.ok) process.exitCode = 1;
|
|
1975
2369
|
} finally {
|
|
1976
|
-
if (temporary) await
|
|
2370
|
+
if (temporary) await rm6(temporary, { recursive: true, force: true });
|
|
1977
2371
|
}
|
|
1978
2372
|
return;
|
|
1979
2373
|
}
|
|
@@ -2002,6 +2396,12 @@ async function main() {
|
|
|
2002
2396
|
case "list":
|
|
2003
2397
|
print({ ok: true, ...await call("list", { project: await currentProject() }) });
|
|
2004
2398
|
return;
|
|
2399
|
+
case "context":
|
|
2400
|
+
print({ ok: true, ...await call("context", { project: await currentProject() }) });
|
|
2401
|
+
return;
|
|
2402
|
+
case "comments":
|
|
2403
|
+
print({ ok: true, ...await call("comments", { project: await currentProject() }) });
|
|
2404
|
+
return;
|
|
2005
2405
|
case "open":
|
|
2006
2406
|
print({ ok: true, ...await call("open", { project: await currentProject() }) });
|
|
2007
2407
|
return;
|
|
@@ -2011,7 +2411,7 @@ async function main() {
|
|
|
2011
2411
|
return;
|
|
2012
2412
|
}
|
|
2013
2413
|
case "status":
|
|
2014
|
-
print({ ok: true, ...await call("status", { project: await
|
|
2414
|
+
print({ ok: true, ...await call("status", { project: await locate() }) });
|
|
2015
2415
|
return;
|
|
2016
2416
|
case "remove": {
|
|
2017
2417
|
if (!positional[0]) fail("twombly remove <frame>");
|
|
@@ -2069,6 +2469,13 @@ async function main() {
|
|
|
2069
2469
|
case "logout":
|
|
2070
2470
|
print({ ok: true, ...await call("logout") });
|
|
2071
2471
|
return;
|
|
2472
|
+
case "usage": {
|
|
2473
|
+
const choice = positional[0];
|
|
2474
|
+
if (choice !== void 0 && choice !== "on" && choice !== "off") fail("Run: twombly usage on, or twombly usage off");
|
|
2475
|
+
if (choice) await setUsage(choice === "on");
|
|
2476
|
+
print({ ok: true, usage: usageOn() ? "on" : "off" });
|
|
2477
|
+
return;
|
|
2478
|
+
}
|
|
2072
2479
|
case "stop": {
|
|
2073
2480
|
if (await health()) await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: "{}" }).catch(() => {
|
|
2074
2481
|
});
|