twombly 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +187 -55
- package/dist/ui/assets/Landing-CrWkZ8qH.css +1 -0
- package/dist/ui/assets/Landing-J2K0EUo5.js +111 -0
- package/dist/ui/assets/LandingStage-BcivO6Eh.css +1 -0
- package/dist/ui/assets/LandingStage-u99Kd45d.js +1 -0
- package/dist/ui/assets/agentPrompt-osLcSdHt.js +1 -0
- package/dist/ui/assets/index-DHQst-JO.css +1 -0
- package/dist/ui/assets/index-Dk5KUFe_.js +10 -0
- package/dist/ui/assets/rolldown-runtime-WkwZ5CU-.js +1 -0
- package/dist/ui/index.html +13 -3
- package/dist/ui/legal.css +34 -10
- package/dist/ui/privacy.html +14 -7
- package/dist/ui/sitemap.xml +6 -0
- package/dist/ui/terms.html +6 -2
- package/dist/ui/theme.js +6 -0
- package/package.json +1 -1
- package/dist/ui/assets/Landing-BKJA0CJt.js +0 -111
- package/dist/ui/assets/Landing-Da4YRp2T.css +0 -1
- package/dist/ui/assets/LandingStage-CqdIuQDE.css +0 -1
- package/dist/ui/assets/LandingStage-DyrNB5Ea.js +0 -1
- package/dist/ui/assets/index-B5osE1GC.js +0 -10
- package/dist/ui/assets/index-Dw-Z6e31.css +0 -1
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 as copyFile2, mkdtemp, readFile as
|
|
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
|
|
@@ -33,6 +33,7 @@ var FORBIDDEN = [
|
|
|
33
33
|
{ test: /\.map$/i, code: "SOURCE_MAP", what: "a source map", fix: "Source maps publish original source code. Turn them off in the build or delete the .map files." },
|
|
34
34
|
{ test: /(^|\/)\.next\/(server|cache)(\/|$)/, code: "SERVER_CODE", what: "server output from Next.js", fix: 'Use a static export (output: "export") and add its output folder.' },
|
|
35
35
|
{ test: /\.(php|rb|py|go|rs|java|cs|sh|bash|zsh|exe|dll|so|dylib)$/i, code: "SERVER_CODE", what: "server or native code" },
|
|
36
|
+
{ test: /\.(zip|7z|rar|tar|tgz|dmg|pkg|mpkg|msi|msix|appx|apk|aab|ipa|jar|deb|rpm|appimage|iso|bat|cmd|ps1|vbs|scr|xpi|crx)$/i, code: "DOWNLOAD_FILE", what: "an installer or archive", fix: "Frames show a design; they don't hand out files. Delete it from the folder." },
|
|
36
37
|
{ test: /(^|\/)(package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|tsconfig\.json|wrangler\.(toml|jsonc?)|dockerfile|docker-compose\.ya?ml)$/i, code: "PROJECT_FILE", what: "a project file", fix: "That looks like a project folder. Build first and add the output folder only." }
|
|
37
38
|
];
|
|
38
39
|
function checkPath(path) {
|
|
@@ -198,7 +199,7 @@ function frameCsp(ancestors) {
|
|
|
198
199
|
`frame-ancestors ${ancestors.join(" ")}`
|
|
199
200
|
].join("; ");
|
|
200
201
|
}
|
|
201
|
-
var FRAME_SANDBOX = "allow-scripts allow-same-origin allow-forms allow-modals allow-pointer-lock
|
|
202
|
+
var FRAME_SANDBOX = "allow-scripts allow-same-origin allow-forms allow-modals allow-pointer-lock";
|
|
202
203
|
var FRAME_BRIDGE_PATH = "/__twombly/bridge.js";
|
|
203
204
|
var FRAME_POSTER_PATH = "/__twombly/poster.png";
|
|
204
205
|
var FRAME_BRIDGE_JS = `(() => {
|
|
@@ -244,6 +245,9 @@ function cleanStroke(input) {
|
|
|
244
245
|
}
|
|
245
246
|
var WORKING_MS = 3 * 6e4;
|
|
246
247
|
var WORKING_TAIL_MS = 3e4;
|
|
248
|
+
function cleanSettings(value) {
|
|
249
|
+
return { invertScroll: value?.invertScroll === true, theme: value?.theme === "light" || value?.theme === "dark" ? value.theme : void 0 };
|
|
250
|
+
}
|
|
247
251
|
var COLORS = ["#F24822", "#FF7A00", "#14AE5C", "#0D99FF", "#9747FF", "#E5277B", "#00B5B8", "#7B61FF"];
|
|
248
252
|
function colorOf(id) {
|
|
249
253
|
let h = 0;
|
|
@@ -261,7 +265,7 @@ function nextPosition(frames, newRow = false) {
|
|
|
261
265
|
}
|
|
262
266
|
|
|
263
267
|
// ../../skills/twombly/SKILL.md
|
|
264
|
-
var SKILL_default = "---\nname: twombly\ndescription: Lay out design explorations on a shared canvas. Use when asked to \"put this on Twombly\", \"put these on a canvas\", \"lay out these versions\", \"show me all the variations side by side\", or to share explorations with real-time cursors and comments. Works with any framework in any repo.\n---\n\n# Twombly\n\nTwombly shows design work as live frames on an infinite canvas. You build each version, turn it into a folder of static files, and add it. The canvas opens on `localhost`; `init` returns its address. Nothing is online until the person clicks Share. After that the local canvas and the link are one state: everything you add, update or move appears for everyone straight away.\n\nRun every command as `npx -y twombly@latest <command>`; this guide shortens that to `twombly <command>`. Output is JSON.\n\n## The rule that decides everything\n\n**A frame is a folder of static files that runs with no server and no network.** It can use anything in its own folder and nothing else. The browser enforces this: outside requests, form posts and server code all fail, locally and once shared.\n\nThe person's repo may have a database, an API, auth and environment keys. None of that goes in a frame. Your job is to adapt the design so it works without them:\n\n- **Data and APIs:** replace with realistic mock data, written inline or as JSON in the folder. For public CMS content, save a snapshot as JSON when you build.\n- **Auth and providers:** skip login, and mount the design with mock providers and a fake signed-in user.\n- **Environment values:** never include them. Build with none set. Don't copy `.env` files. If the build needs a value to compile, use an obviously fake one. **Never run or read a secret manager such as Infisical or Doppler for this, even if the project is set up for it.** If you can't get what you need without one, ask the person.\n- **Analytics and widgets:** remove tag managers, analytics, chat widgets and similar scripts, including any in page templates such as `app.html` or `_document`.\n- **Real customer data:** never copy it from a database, fixture or log. Invent names, emails and numbers. The gates can't detect this, so it's on you. Links are public.\n- **Fonts, images and video:** copy the files into the folder with relative or root paths. For a hosted font such as Google Fonts, download the files and use `@font-face`; Google only serves `woff2` to a browser user agent. If a font is licensed, tell the person it will be publicly hosted. Add up media sizes before downloading, take only what the design shows, and compress video.\n- **One screen of a routed app:** a router reads the page's address, which won't match the screen you want. Write a small entry file that mounts that screen with a memory or hash router, instead of trying to reach its route.\n\n## Limits\n\n100 MB per frame, 25 MB per file, 5000 files per frame, 500 frames per canvas. A URL can't be added, not even `localhost`: build it or `capture` it. Refused files: `.env`, credentials, keys, databases, source maps, server code, `node_modules` and project files such as `package.json`. Pop-ups, camera and microphone are off.\n\n## Steps\n\n1. **Start the canvas.** From the project root, or in a monorepo from the app's own folder so `.twombly/work` can find its packages: `twombly init --name \"Notification Explorations\" --no-open`. Pick a short, specific name. Run it once per canvas. The result's `local` field is the canvas's address. If `created` is false, the canvas already existed: see [Other agents and more canvases](#other-agents-and-more-canvases).\n2. **Open it beside the chat.** The person should see the canvas next to this conversation, in the app they're already using. Open the `local` address in that app's built-in browser: the Claude desktop app's browser pane, the ChatGPT app's in-app browser, or the built-in browser in Cursor, VS Code or any other editor. Only if the app has none, run `twombly open`, which uses their default browser.\n3. **Build each version** as its own static folder, keeping work under `.twombly/work/<task>/<version>` so the person's source stays clean. Pick a `<task>` no other agent would, because a shared folder gets overwritten. Use the first approach below that works. The canvas tells people an agent is working: `init` and the slow commands keep that showing for three minutes. Run `twombly working` only before a longer gap.\n4. **Add it:** `twombly add <folder> --title \"V1 Compact\" --width 1280 --height 800 --level build`, saying which level below it is. Use the design's real size, such as `--width 390 --height 844` for a phone. Use `--height auto` to fit a tall page.\n5. **Read the output.** If `ok` is false, fix every item in `problems` and run `add` again. Each one includes a `fix`. The frame isn't placed until it passes.\n6. **Look at it.** The result has a `screenshot` path. Open it and check the frame shows the design you meant, with the content in the right state. The gates can't tell if a page came out unstyled or on the wrong screen; a common miss is Tailwind classes that were never generated. Tailwind 4 skips gitignored folders, so source copied into `.twombly` needs an `@source`.\n7. **Test interactions, if they matter.** Open the `url` from `twombly preview <frame>` in your browser tool and use the page inside its iframe. On the canvas, a frame ignores clicks until it's double-clicked, and frame URLs refuse to open on their own. Tell the person what you didn't test.\n8. **Finish:** run `twombly done` so the background goes still, then tell the person the canvas is open. They click Share when they're ready. The first time, Google sign-in opens in their regular browser, and sharing continues on its own once they finish.\n\n## Choosing how to build a frame\n\nUse the first one that fits. Say so if you end up lower than the first, because the frame is less faithful. **If the app has server routes, auth or a database, skip straight to `component`**: a static export of that kind of app rarely works and isn't worth the attempt.\n\n1. `--level build`: **a real static build.** Vite: `vite build --base ./ --outDir .twombly/work/v1`. Next.js: `output: 'export'` in a temporary config (static export can't use server features). SvelteKit: `adapter-static` with a `fallback`, `ssr = false` in the root layout, and any server `load` swapped for mock data in a universal `+layout.ts`. Astro: `astro build`. Plain HTML: use the folder as is. To show one state of a bigger app, add a build entry that renders just that screen.\n2. `--level component`: **the component on its own.** Write a tiny entry file that imports the real component with mock props, and bundle it with Vite or esbuild into a folder. This is the usual answer when the app needs a backend. It keeps the real code and styles.\n3. `--level capture`: **freeze what's on screen.** With the app already running: `twombly capture http://localhost:3000/page --out .twombly/work/v1 --width 1280 --height 800`, then add that folder. It looks right but no longer responds, because scripts are removed.\n4. `--level image`: **a picture**, as a last resort. `twombly capture <url> --out <folder> --image`, or `twombly add shot.png --title \"V1\"`.\n\nSeveral versions can share one build: read the variant from `location.search` and pass `--entry \"index.html?variant=2\"`. Shared files are only uploaded once.\n\nAdd several frames in one command, which is faster, instead of writing a loop. Nothing is placed unless all of them pass. Each item takes `folder`, `title`, `entry`, `width`, `height`, `level`, `newRow`, `x` and `y`, and flags on the command apply to all:\n\n```sh\ntwombly add out --width 1280 --height 800 --level component --frames '[{\"title\":\"A\",\"entry\":\"index.html?variant=a\"},{\"title\":\"B\",\"entry\":\"index.html?variant=b\"}]'\n```\n\n### Example: the component level with Vite\n\nFor most projects, this is all it takes. It works the same for React, Svelte or Vue with that framework's Vite plugin. Keep it in your own folder under `.twombly/work`; here, `composer`.\n\n```js\n// .twombly/work/composer/vite.config.mjs\nimport { defineConfig } from 'vite';\nimport { resolve } from 'node:path';\nimport react from '@vitejs/plugin-react';\n\nconst root = resolve(import.meta.dirname, '../../..');\nexport default defineConfig({\n root: import.meta.dirname,\n base: './',\n plugins: [react()],\n resolve: { alias: { '@': root } }, // match the project's path aliases\n define: { 'process.env': {} },\n build: { outDir: 'out', emptyOutDir: true, sourcemap: false },\n});\n```\n\n```html\n<!-- .twombly/work/composer/index.html -->\n<!doctype html><html><body><div id=\"root\"></div><script type=\"module\" src=\"./main.jsx\"></script></body></html>\n```\n\n```jsx\n// .twombly/work/composer/main.jsx: the real component, the real styles, invented data.\nimport { createRoot } from 'react-dom/client';\nimport '@/styles/globals.css';\nimport { Composer } from '@/components/composer';\n\nconst variant = new URLSearchParams(location.search).get('variant');\ncreateRoot(document.getElementById('root')).render(<Composer variant={variant} onSend={() => {}} messages={MOCK_MESSAGES} />);\n```\n\nThen: `npx vite build --config .twombly/work/composer/vite.config.mjs` and `twombly add .twombly/work/composer/out --title \"A\" --entry \"index.html?variant=a\" --level component`. If the project uses Tailwind, add its Vite or PostCSS plugin to the config so the classes are generated.\n\n## Commands\n\n`twombly help` lists every command and flag. `<frame>` is a frame's id or its exact title. Frames fill in left to right; use `--new-row` to start a new group, for example one row per direction.\n\nUse `update` to change a version. Don't remove and re-add it, because that loses its comments. It keeps the frame's entry and size unless you change them. Each frame keeps its own copy of its files, so after rebuilding, update the set together: `twombly update out --frames '[{\"frame\":\"A\"},{\"frame\":\"B\"}]'`. Nothing changes unless all of them pass.\n\n## What the person is looking at\n\nWhen the person says \"this\" or \"these\" without naming a frame, run `twombly context`. It returns the frames they have open fullscreen, are using, or have selected, in that order, plus any comment they have open. `looking: false` means no canvas is open in a browser. If nothing is selected, ask which frame they mean.\n\n`twombly comments` returns every comment, with the frame it's on, where on the frame (in the frame's pixels), whether it's resolved, and `mine: true` for the person's own comments.\n\nEach frame in `context` and `list` has a `source`: the folder or file it was last added or updated from. Edit what built it, rebuild, then `update` the frame. Frames added before this existed show `null`.\n\n## Other agents and more canvases\n\nA folder has one default canvas, and every agent in that folder or below it lands on it. `init` on an existing canvas joins it and keeps its name. A folder can also hold any number of named canvases, each with its own address, frames and link.\n\n**Use a named canvas** when your work is a separate exploration, or the person asks for its own canvas: `twombly init --canvas \"Pricing\" --no-open`. Then pass `--canvas \"Pricing\"` on every command, because nothing remembers it between commands. `init --canvas` with a name that exists joins it, so agents can share one by agreeing on the name. `twombly canvases` lists what's here.\n\n**On a canvas with other agents:**\n\n- Only change your own frames, unless the person asks. Any agent's `update`, `move` and `remove` work on any frame, and `arrange` moves all of them.\n- Give frames titles no one else would use, such as \"Pricing: V1\", since commands find frames by title. A repeated title gets a warning, and then has to be addressed by id.\n- Add each set in one `--frames` command, with `--new-row` on its first frame, so it stays together.\n- If `update` says someone else changed a frame, run `twombly list` and don't retry without checking whose work you'd replace.\n- Run `twombly done` when you finish. The canvas keeps showing work until every agent that ran `init` has finished, or three minutes after the last activity.\n\n**Worktrees:** a canvas lives in the folder it was started in. A worktree outside the repo gets its own canvas, which is deleted with the worktree, so tell the person that before removing it. A worktree inside the repo joins the repo's default canvas if there is one.\n\n## Sharing\n\nThe person shares by clicking Share. Only run `twombly share` if they explicitly ask you to, because it publishes the canvas to a public link. If they ask you to take it down, run `twombly unshare`. That deletes the link and keeps the canvas on this machine, comments included.\n\nOnce it's shared, every `add`, `update`, `move` and `remove` goes live immediately. Check your work with `twombly check` before updating a canvas people are looking at.\n";
|
|
268
|
+
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";
|
|
265
269
|
|
|
266
270
|
// src/capture.ts
|
|
267
271
|
import { createHash } from "node:crypto";
|
|
@@ -303,7 +307,7 @@ async function capture(url, out, options) {
|
|
|
303
307
|
return { folder: out, assets: 1, failed: [] };
|
|
304
308
|
}
|
|
305
309
|
const saved2 = /* @__PURE__ */ new Map(), failed = [];
|
|
306
|
-
const
|
|
310
|
+
const save2 = async (assetUrl) => {
|
|
307
311
|
if (assetUrl.startsWith("data:") || assetUrl.startsWith("blob:")) return null;
|
|
308
312
|
if (saved2.has(assetUrl)) return saved2.get(assetUrl);
|
|
309
313
|
try {
|
|
@@ -333,7 +337,7 @@ async function capture(url, out, options) {
|
|
|
333
337
|
} catch {
|
|
334
338
|
continue;
|
|
335
339
|
}
|
|
336
|
-
const name = await
|
|
340
|
+
const name = await save2(absolute);
|
|
337
341
|
if (!name) continue;
|
|
338
342
|
output = output.split(match[0]).join(`url("${from === "css" ? name.replace("assets/", "") : name}")`);
|
|
339
343
|
}
|
|
@@ -391,7 +395,7 @@ async function capture(url, out, options) {
|
|
|
391
395
|
});
|
|
392
396
|
const replacements = [];
|
|
393
397
|
for (const attribute of found.attributes) {
|
|
394
|
-
const name = await
|
|
398
|
+
const name = await save2(attribute.value);
|
|
395
399
|
if (name) replacements.push({ tag: attribute.tag, name: attribute.name, value: name });
|
|
396
400
|
}
|
|
397
401
|
const styles = await Promise.all(found.styles.map((style) => rewriteCss(style, found.base, "html")));
|
|
@@ -414,11 +418,11 @@ async function capture(url, out, options) {
|
|
|
414
418
|
}
|
|
415
419
|
|
|
416
420
|
// src/daemon.ts
|
|
417
|
-
import { createHash as createHash3, randomBytes } from "node:crypto";
|
|
421
|
+
import { createHash as createHash3, randomBytes as randomBytes2 } from "node:crypto";
|
|
418
422
|
import { createReadStream as createReadStream2 } from "node:fs";
|
|
419
|
-
import { copyFile, mkdir as
|
|
423
|
+
import { copyFile, mkdir as mkdir7, readFile as readFile6, stat as stat3 } from "node:fs/promises";
|
|
420
424
|
import { createServer as createServer2 } from "node:http";
|
|
421
|
-
import { dirname as dirname3, extname as extname2, join as
|
|
425
|
+
import { dirname as dirname3, extname as extname2, join as join9 } from "node:path";
|
|
422
426
|
import { WebSocketServer, WebSocket } from "ws";
|
|
423
427
|
|
|
424
428
|
// src/credentials.ts
|
|
@@ -593,7 +597,7 @@ var checkedAt = 0;
|
|
|
593
597
|
function interfaceFolders() {
|
|
594
598
|
if (OVERRIDE) return [OVERRIDE];
|
|
595
599
|
const folder = (released) => join5(STORE, String(released));
|
|
596
|
-
return [...saved.filter((released) => released >
|
|
600
|
+
return [...saved.filter((released) => released > 1793935262296).map(folder), BUNDLED, ...saved.filter((released) => released <= 1793935262296).map(folder)];
|
|
597
601
|
}
|
|
598
602
|
function unpack(body) {
|
|
599
603
|
const { payload, signature } = JSON.parse(body);
|
|
@@ -627,7 +631,7 @@ async function check() {
|
|
|
627
631
|
const release = unpack(body);
|
|
628
632
|
if (!release) return;
|
|
629
633
|
etag = response.headers.get("etag") || "";
|
|
630
|
-
if (release.released > Math.max(saved[0] ?? 0,
|
|
634
|
+
if (release.released > Math.max(saved[0] ?? 0, 1793935262296)) await install(release.released, release.files);
|
|
631
635
|
}
|
|
632
636
|
function startCheck() {
|
|
633
637
|
if (checking) return;
|
|
@@ -783,8 +787,7 @@ import { mkdir as mkdir5, readFile as readFile4, rename as rename3, writeFile as
|
|
|
783
787
|
import { join as join7 } from "node:path";
|
|
784
788
|
var FILE = join7(HOME, "settings.json");
|
|
785
789
|
async function readSettings() {
|
|
786
|
-
|
|
787
|
-
return { invertScroll: saved2?.invertScroll === true };
|
|
790
|
+
return cleanSettings(await readFile4(FILE, "utf8").then(JSON.parse).catch(() => null));
|
|
788
791
|
}
|
|
789
792
|
async function writeSettings(settings2) {
|
|
790
793
|
await mkdir5(HOME, { recursive: true });
|
|
@@ -793,11 +796,89 @@ async function writeSettings(settings2) {
|
|
|
793
796
|
await rename3(temporary, FILE);
|
|
794
797
|
}
|
|
795
798
|
|
|
799
|
+
// src/usage.ts
|
|
800
|
+
import { randomBytes } from "node:crypto";
|
|
801
|
+
import { existsSync } from "node:fs";
|
|
802
|
+
import { mkdir as mkdir6, readFile as readFile5, rename as rename4, rm as rm5, writeFile as writeFile6 } from "node:fs/promises";
|
|
803
|
+
import { homedir as homedir2 } from "node:os";
|
|
804
|
+
import { join as join8 } from "node:path";
|
|
805
|
+
var FILE2 = join8(HOME, "usage.json");
|
|
806
|
+
var OFF = join8(HOME, "usage-off");
|
|
807
|
+
var CHECK_MS2 = 36e5;
|
|
808
|
+
var ERRORS_PER_RUN = 20;
|
|
809
|
+
var state = null;
|
|
810
|
+
var saving = null;
|
|
811
|
+
var errorsSent = 0;
|
|
812
|
+
var usageOn = () => process.env.TWOMBLY_NO_USAGE !== "1" && !existsSync(OFF);
|
|
813
|
+
var scrub = (text2, max) => text2.split(homedir2()).join("~").slice(0, max);
|
|
814
|
+
var today = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
815
|
+
var post = (path, body) => fetch(ENDPOINT + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(1e4) });
|
|
816
|
+
async function setUsage(on) {
|
|
817
|
+
await mkdir6(HOME, { recursive: true });
|
|
818
|
+
if (on) await rm5(OFF, { force: true });
|
|
819
|
+
else await writeFile6(OFF, "");
|
|
820
|
+
}
|
|
821
|
+
async function load() {
|
|
822
|
+
const saved2 = await readFile5(FILE2, "utf8").then(JSON.parse).catch(() => null);
|
|
823
|
+
const install3 = typeof saved2?.install === "string" && /^[a-z0-9]{16,40}$/.test(saved2.install) ? saved2.install : randomBytes(12).toString("hex");
|
|
824
|
+
return { install: install3, day: typeof saved2?.day === "string" ? saved2.day : today(), counts: saved2?.counts && typeof saved2.counts === "object" ? saved2.counts : {} };
|
|
825
|
+
}
|
|
826
|
+
function save() {
|
|
827
|
+
if (saving) return;
|
|
828
|
+
saving = setTimeout(async () => {
|
|
829
|
+
saving = null;
|
|
830
|
+
if (!state) return;
|
|
831
|
+
await mkdir6(HOME, { recursive: true }).catch(() => {
|
|
832
|
+
});
|
|
833
|
+
const temporary = `${FILE2}.${process.pid}`;
|
|
834
|
+
await writeFile6(temporary, JSON.stringify(state)).then(() => rename4(temporary, FILE2)).catch(() => {
|
|
835
|
+
});
|
|
836
|
+
}, 1e3);
|
|
837
|
+
}
|
|
838
|
+
function tally(key, by = 1) {
|
|
839
|
+
if (!state || !usageOn()) return;
|
|
840
|
+
state.counts[key] = (state.counts[key] || 0) + by;
|
|
841
|
+
save();
|
|
842
|
+
}
|
|
843
|
+
async function flush() {
|
|
844
|
+
if (!state || state.day === today()) return;
|
|
845
|
+
const { day, counts } = state;
|
|
846
|
+
state.day = today();
|
|
847
|
+
state.counts = {};
|
|
848
|
+
save();
|
|
849
|
+
if (!usageOn() || !Object.values(counts).some(Boolean)) return;
|
|
850
|
+
const canvases2 = Object.keys(await readRegistry()).length;
|
|
851
|
+
await post("/api/usage", { install: state.install, day, version: "0.4.1", platform: process.platform, counts: { ...counts, canvases: canvases2 } }).catch(() => {
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
async function startUsage() {
|
|
855
|
+
state = await load();
|
|
856
|
+
await flush();
|
|
857
|
+
tally("starts");
|
|
858
|
+
setInterval(() => {
|
|
859
|
+
flush().catch(() => {
|
|
860
|
+
});
|
|
861
|
+
}, CHECK_MS2).unref();
|
|
862
|
+
}
|
|
863
|
+
function reportError(error, source = "tool") {
|
|
864
|
+
if (!usageOn() || ++errorsSent > ERRORS_PER_RUN) return;
|
|
865
|
+
post("/api/errors", {
|
|
866
|
+
source,
|
|
867
|
+
mode: "local",
|
|
868
|
+
install: state?.install,
|
|
869
|
+
version: "0.4.1",
|
|
870
|
+
type: String(error.type || "Error").slice(0, 80),
|
|
871
|
+
message: scrub(String(error.message), 500),
|
|
872
|
+
stack: scrub(String(error.stack || ""), 4e3)
|
|
873
|
+
}).catch(() => {
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
796
877
|
// src/daemon.ts
|
|
797
878
|
var IDLE_MS = 6 * 36e5;
|
|
798
879
|
var LOGIN_MS = 10 * 6e4;
|
|
799
880
|
var ID_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
|
|
800
|
-
var randomId = (length) => [...
|
|
881
|
+
var randomId = (length) => [...randomBytes2(length)].map((b) => ID_ALPHABET[b % ID_ALPHABET.length]).join("");
|
|
801
882
|
var Failure = class extends Error {
|
|
802
883
|
status;
|
|
803
884
|
extra;
|
|
@@ -812,7 +893,7 @@ var credential = null;
|
|
|
812
893
|
var pendingLogin = null;
|
|
813
894
|
function loginUrl() {
|
|
814
895
|
if (!pendingLogin || Date.now() - pendingLogin.at > LOGIN_MS) {
|
|
815
|
-
pendingLogin = { state:
|
|
896
|
+
pendingLogin = { state: randomBytes2(24).toString("hex"), verifier: randomBytes2(32).toString("base64url"), at: Date.now() };
|
|
816
897
|
}
|
|
817
898
|
const challenge = createHash3("sha256").update(pendingLogin.verifier).digest("base64url");
|
|
818
899
|
return `${ENDPOINT}/auth/machine/start?` + new URLSearchParams({ port: String(PORT), state: pendingLogin.state, challenge });
|
|
@@ -820,6 +901,7 @@ function loginUrl() {
|
|
|
820
901
|
function setCredential(next) {
|
|
821
902
|
credential = next;
|
|
822
903
|
for (const canvas of canvases.values()) canvas.event({ t: "auth", me: next?.me || null });
|
|
904
|
+
if (next) syncSettings();
|
|
823
905
|
}
|
|
824
906
|
async function edge(path, init = {}) {
|
|
825
907
|
if (!credential) throw new Failure(401, "Sign in first");
|
|
@@ -836,6 +918,23 @@ async function edge(path, init = {}) {
|
|
|
836
918
|
if (!response.ok) throw new Failure(response.status, data.error || `The server answered ${response.status}`, data.problems ? { problems: data.problems } : void 0);
|
|
837
919
|
return data;
|
|
838
920
|
}
|
|
921
|
+
async function useSettings(next) {
|
|
922
|
+
if (JSON.stringify(next) === JSON.stringify(settings)) return;
|
|
923
|
+
settings = next;
|
|
924
|
+
await writeSettings(settings);
|
|
925
|
+
for (const canvas of canvases.values()) canvas.event({ t: "settings", settings });
|
|
926
|
+
}
|
|
927
|
+
var settingsChosen = 0;
|
|
928
|
+
function syncSettings() {
|
|
929
|
+
if (!credential) return;
|
|
930
|
+
const chosen = settingsChosen;
|
|
931
|
+
edge("/api/settings").then(({ settings: account }) => {
|
|
932
|
+
if (chosen !== settingsChosen) return;
|
|
933
|
+
if (account) return useSettings(account);
|
|
934
|
+
if (settings.invertScroll || settings.theme) return edge("/api/settings", { method: "POST", body: JSON.stringify(settings) });
|
|
935
|
+
}).catch(() => {
|
|
936
|
+
});
|
|
937
|
+
}
|
|
839
938
|
var STROKE_UPLOAD = 40;
|
|
840
939
|
var THREAD_UPLOAD_CHARS = 5e5;
|
|
841
940
|
var THREAD_ID = /^[a-z0-9]{8,32}$/;
|
|
@@ -1165,7 +1264,7 @@ var Canvas = class {
|
|
|
1165
1264
|
const { files } = await folderContent(frameDir(this.project, frame.id));
|
|
1166
1265
|
let poster = null, posterBytes = null;
|
|
1167
1266
|
if (frame.hasPoster) {
|
|
1168
|
-
posterBytes = await
|
|
1267
|
+
posterBytes = await readFile6(posterFile(this.project, frame.id)).catch(() => null);
|
|
1169
1268
|
if (posterBytes) poster = createHash3("sha256").update(posterBytes).digest("hex");
|
|
1170
1269
|
}
|
|
1171
1270
|
const manifest = files.map(({ path, hash, size: size2 }) => ({ path, hash, size: size2 }));
|
|
@@ -1179,7 +1278,7 @@ var Canvas = class {
|
|
|
1179
1278
|
await Promise.all(Array.from({ length: 6 }, async () => {
|
|
1180
1279
|
while (next < queue.length) {
|
|
1181
1280
|
const file2 = queue[next++];
|
|
1182
|
-
await put(file2.hash, await
|
|
1281
|
+
await put(file2.hash, await readFile6(file2.absolute));
|
|
1183
1282
|
}
|
|
1184
1283
|
}));
|
|
1185
1284
|
if (poster && posterBytes && wanted.has(poster)) await put(poster, posterBytes);
|
|
@@ -1354,6 +1453,7 @@ async function command(name, input) {
|
|
|
1354
1453
|
found = project;
|
|
1355
1454
|
}
|
|
1356
1455
|
const canvas2 = await canvasForProject(found);
|
|
1456
|
+
if (created) tally("canvasesCreated");
|
|
1357
1457
|
if (input.open !== false && !canvas2.watchers) openBrowser(`${DAEMON_ORIGIN}/${canvas2.id}`);
|
|
1358
1458
|
canvas2.working(WORKING_MS, true);
|
|
1359
1459
|
return { ...summary(canvas2), created };
|
|
@@ -1410,7 +1510,7 @@ async function command(name, input) {
|
|
|
1410
1510
|
return summary(canvas);
|
|
1411
1511
|
}
|
|
1412
1512
|
case "rename":
|
|
1413
|
-
await
|
|
1513
|
+
await rename5(canvas, input.name);
|
|
1414
1514
|
return summary(canvas);
|
|
1415
1515
|
case "share": {
|
|
1416
1516
|
if (!credential) throw new Failure(401, "The person needs to sign in first. Ask them to click Share on the canvas.");
|
|
@@ -1425,7 +1525,7 @@ async function command(name, input) {
|
|
|
1425
1525
|
}
|
|
1426
1526
|
});
|
|
1427
1527
|
}
|
|
1428
|
-
async function
|
|
1528
|
+
async function rename5(canvas, name) {
|
|
1429
1529
|
const clean = String(name || "").trim().slice(0, LIMITS.nameChars);
|
|
1430
1530
|
if (!clean) throw new Failure(400, "Expected a name");
|
|
1431
1531
|
if (canvas.data.remote) await edge(`/api/canvases/${canvas.data.remote.id}`, { method: "PATCH", body: JSON.stringify({ name: clean }) });
|
|
@@ -1442,7 +1542,7 @@ async function install2(canvas, id, folder, screenshot) {
|
|
|
1442
1542
|
await replaceFolder(folder, frameDir(canvas.project, id));
|
|
1443
1543
|
if (screenshot) {
|
|
1444
1544
|
const poster = posterFile(canvas.project, id);
|
|
1445
|
-
await
|
|
1545
|
+
await mkdir7(dirname3(poster), { recursive: true });
|
|
1446
1546
|
await copyFile(screenshot, poster);
|
|
1447
1547
|
}
|
|
1448
1548
|
return (await folderContent(frameDir(canvas.project, id))).content;
|
|
@@ -1480,6 +1580,7 @@ async function place(canvas, input) {
|
|
|
1480
1580
|
frames.push({ ...placedFrame(frame), warnings });
|
|
1481
1581
|
}
|
|
1482
1582
|
canvas.working(WORKING_TAIL_MS);
|
|
1583
|
+
tally("framesAdded", frames.length);
|
|
1483
1584
|
return { frames, canvas: canvasResult(canvas) };
|
|
1484
1585
|
}
|
|
1485
1586
|
async function revise(canvas, input) {
|
|
@@ -1507,6 +1608,7 @@ async function revise(canvas, input) {
|
|
|
1507
1608
|
frames.push({ ...placedFrame(frame), warnings: [] });
|
|
1508
1609
|
}
|
|
1509
1610
|
canvas.working(WORKING_TAIL_MS);
|
|
1611
|
+
tally("framesUpdated", frames.length);
|
|
1510
1612
|
return { frames, canvas: canvasResult(canvas) };
|
|
1511
1613
|
}
|
|
1512
1614
|
var JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
|
|
@@ -1557,7 +1659,7 @@ async function serveUi(response, pathname) {
|
|
|
1557
1659
|
let file2 = null;
|
|
1558
1660
|
if (clean && !clean.includes("..")) {
|
|
1559
1661
|
for (const folder of interfaceFolders()) {
|
|
1560
|
-
const candidate =
|
|
1662
|
+
const candidate = join9(folder, clean);
|
|
1561
1663
|
if ((await stat3(candidate).catch(() => null))?.isFile()) {
|
|
1562
1664
|
file2 = candidate;
|
|
1563
1665
|
break;
|
|
@@ -1566,7 +1668,7 @@ async function serveUi(response, pathname) {
|
|
|
1566
1668
|
}
|
|
1567
1669
|
if (!file2) {
|
|
1568
1670
|
await freshInterface();
|
|
1569
|
-
file2 =
|
|
1671
|
+
file2 = join9(interfaceFolders()[0], "index.html");
|
|
1570
1672
|
}
|
|
1571
1673
|
const type = contentType(file2);
|
|
1572
1674
|
const headers = { "content-type": type, "x-content-type-options": "nosniff", "cache-control": extname2(file2) === ".html" ? "no-cache" : "public, max-age=3600" };
|
|
@@ -1593,15 +1695,15 @@ async function servePreview(response, canvasId, frameId) {
|
|
|
1593
1695
|
async function route(request, response) {
|
|
1594
1696
|
const url = new URL(request.url || "/", DAEMON_ORIGIN);
|
|
1595
1697
|
const path = url.pathname, method = request.method || "GET";
|
|
1596
|
-
if (path === "/health") return reply(response, 200, { ok: true, build: "0.
|
|
1698
|
+
if (path === "/health") return reply(response, 200, { ok: true, build: "0.4.1+1793935262296", api: TOOL_API, pid: process.pid });
|
|
1597
1699
|
if (path.startsWith("/cli/") && method === "POST") return reply(response, 200, await command(path.slice(5), await readBody(request)));
|
|
1598
1700
|
if (path === "/auth/callback" && method === "GET") {
|
|
1599
|
-
const code = url.searchParams.get("code"),
|
|
1701
|
+
const code = url.searchParams.get("code"), state2 = url.searchParams.get("state");
|
|
1600
1702
|
const page = (text2, status = 200) => {
|
|
1601
1703
|
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'" });
|
|
1602
1704
|
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>`);
|
|
1603
1705
|
};
|
|
1604
|
-
if (!code || !pendingLogin ||
|
|
1706
|
+
if (!code || !pendingLogin || state2 !== pendingLogin.state) return page("That sign-in expired. Try again.", 400);
|
|
1605
1707
|
const exchange = await fetch(`${ENDPOINT}/auth/machine/exchange`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code, verifier: pendingLogin.verifier }) });
|
|
1606
1708
|
const data = await exchange.json().catch(() => ({}));
|
|
1607
1709
|
pendingLogin = null;
|
|
@@ -1615,6 +1717,7 @@ async function route(request, response) {
|
|
|
1615
1717
|
const canvas = id ? await canvasById(id).catch(() => null) : null;
|
|
1616
1718
|
if (canvas?.data.remote) await canvas.pull().catch(() => {
|
|
1617
1719
|
});
|
|
1720
|
+
syncSettings();
|
|
1618
1721
|
const body = {
|
|
1619
1722
|
mode: "local",
|
|
1620
1723
|
me: credential?.me || null,
|
|
@@ -1625,10 +1728,14 @@ async function route(request, response) {
|
|
|
1625
1728
|
}
|
|
1626
1729
|
if (path === "/api/login" && method === "POST") return reply(response, 200, await command("login", {}));
|
|
1627
1730
|
if (path === "/api/settings" && method === "POST") {
|
|
1628
|
-
const { invertScroll } = await readBody(request);
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1731
|
+
const { invertScroll, theme } = await readBody(request);
|
|
1732
|
+
let next = settings;
|
|
1733
|
+
settingsChosen++;
|
|
1734
|
+
if (typeof invertScroll === "boolean") next = { ...next, invertScroll };
|
|
1735
|
+
if (theme === "light" || theme === "dark") next = { ...next, theme };
|
|
1736
|
+
await useSettings(next);
|
|
1737
|
+
if (credential) await edge("/api/settings", { method: "POST", body: JSON.stringify(next) }).catch(() => {
|
|
1738
|
+
});
|
|
1632
1739
|
return reply(response, 200, settings);
|
|
1633
1740
|
}
|
|
1634
1741
|
const match = path.match(/^\/api\/canvases\/([a-z0-9]+)\/(ticket|share|unshare)$/);
|
|
@@ -1638,8 +1745,18 @@ async function route(request, response) {
|
|
|
1638
1745
|
if (!canvas.data.remote || !credential) return reply(response, 200, { url: `ws://localhost:${PORT}/local/${canvas.id}` });
|
|
1639
1746
|
return reply(response, 200, await edge(`/api/canvases/${canvas.data.remote.id}/ticket`, { method: "POST", body: "{}" }));
|
|
1640
1747
|
}
|
|
1641
|
-
if (match[2] === "share")
|
|
1748
|
+
if (match[2] === "share") {
|
|
1749
|
+
const url2 = await canvas.run(() => canvas.share());
|
|
1750
|
+
tally("shares");
|
|
1751
|
+
return reply(response, 200, { url: url2 });
|
|
1752
|
+
}
|
|
1642
1753
|
await canvas.run(() => canvas.unshare());
|
|
1754
|
+
tally("unshares");
|
|
1755
|
+
return reply(response, 200, { ok: true });
|
|
1756
|
+
}
|
|
1757
|
+
if (path === "/api/errors" && method === "POST") {
|
|
1758
|
+
const body = await readBody(request);
|
|
1759
|
+
if (typeof body.message === "string") reportError(body, "canvas");
|
|
1643
1760
|
return reply(response, 200, { ok: true });
|
|
1644
1761
|
}
|
|
1645
1762
|
if (path.startsWith("/api/")) return reply(response, 404, { error: "Not found" });
|
|
@@ -1660,6 +1777,7 @@ function touch() {
|
|
|
1660
1777
|
async function runDaemon() {
|
|
1661
1778
|
credential = await readCredential();
|
|
1662
1779
|
settings = await readSettings();
|
|
1780
|
+
syncSettings();
|
|
1663
1781
|
const server = createServer2(async (request, response) => {
|
|
1664
1782
|
if (!trusted(request)) {
|
|
1665
1783
|
response.writeHead(403, JSON_HEADERS);
|
|
@@ -1677,6 +1795,7 @@ async function runDaemon() {
|
|
|
1677
1795
|
}
|
|
1678
1796
|
if (error instanceof Failure) return reply(response, error.status, { error: error.message, ...error.extra });
|
|
1679
1797
|
console.error(error);
|
|
1798
|
+
reportError(error instanceof Error ? { type: error.name, message: error.message, stack: error.stack } : { message: String(error) });
|
|
1680
1799
|
reply(response, 500, { error: error instanceof Error ? error.message : "Something went wrong" });
|
|
1681
1800
|
} finally {
|
|
1682
1801
|
if (counted) inFlight--;
|
|
@@ -1696,7 +1815,10 @@ async function runDaemon() {
|
|
|
1696
1815
|
}
|
|
1697
1816
|
sockets.handleUpgrade(request, socket, head, (client) => {
|
|
1698
1817
|
if (match[2]) canvas.attachEvents(client);
|
|
1699
|
-
else
|
|
1818
|
+
else {
|
|
1819
|
+
tally("canvasViews");
|
|
1820
|
+
canvas.attach(client).catch(() => client.close());
|
|
1821
|
+
}
|
|
1700
1822
|
});
|
|
1701
1823
|
});
|
|
1702
1824
|
await new Promise((resolve3, reject) => {
|
|
@@ -1706,11 +1828,13 @@ async function runDaemon() {
|
|
|
1706
1828
|
touch();
|
|
1707
1829
|
startInterfaceUpdates().catch(() => {
|
|
1708
1830
|
});
|
|
1831
|
+
startUsage().catch(() => {
|
|
1832
|
+
});
|
|
1709
1833
|
}
|
|
1710
1834
|
|
|
1711
1835
|
// src/gates.ts
|
|
1712
1836
|
import { createServer as createServer3 } from "node:http";
|
|
1713
|
-
import { mkdir as
|
|
1837
|
+
import { mkdir as mkdir8, readFile as readFile7, stat as stat4, writeFile as writeFile7 } from "node:fs/promises";
|
|
1714
1838
|
import { dirname as dirname4 } from "node:path";
|
|
1715
1839
|
var dedupe = (problems) => [...new Map(problems.map((p) => [`${p.code}:${p.file || ""}:${p.message}`, p])).values()];
|
|
1716
1840
|
async function scanFolder(folder) {
|
|
@@ -1727,7 +1851,7 @@ async function scanFolder(folder) {
|
|
|
1727
1851
|
continue;
|
|
1728
1852
|
}
|
|
1729
1853
|
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 });
|
|
1730
|
-
problems.push(...scanBytes(file2.path, await
|
|
1854
|
+
problems.push(...scanBytes(file2.path, await readFile7(file2.absolute)));
|
|
1731
1855
|
}
|
|
1732
1856
|
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." });
|
|
1733
1857
|
if (bytes > LIMITS.frameBytes) {
|
|
@@ -1745,7 +1869,7 @@ function entryProblems(files, entry) {
|
|
|
1745
1869
|
async function findReferences(folder, needle) {
|
|
1746
1870
|
const found = [];
|
|
1747
1871
|
for (const file2 of await hashFolder(folder)) {
|
|
1748
|
-
const bytes = await
|
|
1872
|
+
const bytes = await readFile7(file2.absolute);
|
|
1749
1873
|
if (looksLikeText(bytes) && bytes.includes(needle)) found.push(file2.path);
|
|
1750
1874
|
if (found.length === 3) break;
|
|
1751
1875
|
}
|
|
@@ -1838,8 +1962,8 @@ async function browserGates(folder, options, launch) {
|
|
|
1838
1962
|
const shot = await page.locator("#frame").screenshot({ type: "png", timeout: 15e3 }).catch(() => null);
|
|
1839
1963
|
if (shot) {
|
|
1840
1964
|
if (options.poster) {
|
|
1841
|
-
await
|
|
1842
|
-
await
|
|
1965
|
+
await mkdir8(dirname4(options.poster), { recursive: true });
|
|
1966
|
+
await writeFile7(options.poster, shot);
|
|
1843
1967
|
}
|
|
1844
1968
|
const colors = await page.evaluate(async (base64) => {
|
|
1845
1969
|
const image = new Image();
|
|
@@ -1902,7 +2026,7 @@ async function runGates(folder, options) {
|
|
|
1902
2026
|
}
|
|
1903
2027
|
|
|
1904
2028
|
// src/cli.ts
|
|
1905
|
-
var HELP = `twombly ${"0.
|
|
2029
|
+
var HELP = `twombly ${"0.4.1"}: put design explorations on a shared canvas.
|
|
1906
2030
|
|
|
1907
2031
|
init [--name "Name"] [--no-open] create or join this folder's canvas
|
|
1908
2032
|
init --canvas "Name" [--no-open] create or join a separate named canvas in this folder
|
|
@@ -1925,6 +2049,7 @@ var HELP = `twombly ${"0.3.0"}: put design explorations on a shared canvas.
|
|
|
1925
2049
|
list | move <frame> --x N --y N | resize <frame> --width N --height N
|
|
1926
2050
|
retitle <frame> "T" | remove <frame> | arrange [--columns N] | rename "Name"
|
|
1927
2051
|
open | status | login | logout | stop
|
|
2052
|
+
usage on | off the daily counts and error reports this machine sends to Twombly
|
|
1928
2053
|
share | unshare only when the person asks: this publishes a public link
|
|
1929
2054
|
guide how to adapt a project into frames. Read this first.`;
|
|
1930
2055
|
var SWITCHES = /* @__PURE__ */ new Set(["no-open", "new-row", "image"]);
|
|
@@ -2003,12 +2128,12 @@ async function health() {
|
|
|
2003
2128
|
var builtAt = (build) => Number(build.split("+")[1]) || 0;
|
|
2004
2129
|
var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
2005
2130
|
var IDLE_WAIT_MS = 6e4;
|
|
2006
|
-
var usable = (running) => !!running && (running.build === "0.
|
|
2131
|
+
var usable = (running) => !!running && (running.build === "0.4.1+1793935262296" || running.api === TOOL_API && builtAt(running.build) > builtAt("0.4.1+1793935262296"));
|
|
2007
2132
|
async function ensureDaemon() {
|
|
2008
2133
|
const running = await health();
|
|
2009
2134
|
if (usable(running)) return;
|
|
2010
2135
|
if (running) {
|
|
2011
|
-
if (builtAt(running.build) > builtAt("0.
|
|
2136
|
+
if (builtAt(running.build) > builtAt("0.4.1+1793935262296")) fail("A newer twombly is running on this machine. Run this with twombly@latest.");
|
|
2012
2137
|
let stopped = false;
|
|
2013
2138
|
for (const end = Date.now() + IDLE_WAIT_MS; !stopped && Date.now() < end; ) {
|
|
2014
2139
|
const answer = await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: '{"ifIdle":true}' }).then((response) => response.json()).catch(() => ({ stopped: true }));
|
|
@@ -2043,13 +2168,13 @@ async function prepare(source) {
|
|
|
2043
2168
|
const extension = extname3(source).toLowerCase();
|
|
2044
2169
|
const isImage = IMAGE.has(extension);
|
|
2045
2170
|
if (!isImage && extension !== ".html" && extension !== ".htm") fail("Point at a folder of static files, an HTML file or an image.");
|
|
2046
|
-
const temporary = await mkdtemp(
|
|
2171
|
+
const temporary = await mkdtemp(join10(tmpdir(), "twombly-"));
|
|
2047
2172
|
if (isImage) {
|
|
2048
2173
|
const name = `image${extension}`;
|
|
2049
|
-
await copyFile2(source,
|
|
2050
|
-
await
|
|
2174
|
+
await copyFile2(source, join10(temporary, name));
|
|
2175
|
+
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="">`);
|
|
2051
2176
|
} else {
|
|
2052
|
-
await copyFile2(source,
|
|
2177
|
+
await copyFile2(source, join10(temporary, "index.html"));
|
|
2053
2178
|
}
|
|
2054
2179
|
return { folder: temporary, temporary, image: isImage };
|
|
2055
2180
|
}
|
|
@@ -2082,7 +2207,7 @@ async function frameItems(flags, known) {
|
|
|
2082
2207
|
const { frames: _, ...shared } = flags;
|
|
2083
2208
|
let items;
|
|
2084
2209
|
try {
|
|
2085
|
-
items = JSON.parse(value.trimStart().startsWith("[") ? value : await
|
|
2210
|
+
items = JSON.parse(value.trimStart().startsWith("[") ? value : await readFile8(value, "utf8"));
|
|
2086
2211
|
} catch (error) {
|
|
2087
2212
|
fail(`--frames: ${error instanceof Error ? error.message : error}`);
|
|
2088
2213
|
}
|
|
@@ -2102,7 +2227,7 @@ async function frameItems(flags, known) {
|
|
|
2102
2227
|
}
|
|
2103
2228
|
async function placeFrames(project, many, jobs, command2, item) {
|
|
2104
2229
|
await markWorking();
|
|
2105
|
-
const scratch = await mkdtemp(
|
|
2230
|
+
const scratch = await mkdtemp(join10(tmpdir(), "twombly-gates-"));
|
|
2106
2231
|
const gates = gateSession(), prepared = /* @__PURE__ */ new Map(), temporary = [];
|
|
2107
2232
|
let keepScratch = false;
|
|
2108
2233
|
try {
|
|
@@ -2117,7 +2242,7 @@ async function placeFrames(project, many, jobs, command2, item) {
|
|
|
2117
2242
|
await Promise.all(Array.from({ length: CONCURRENT_GATES }, async () => {
|
|
2118
2243
|
while (next < jobs.length) {
|
|
2119
2244
|
const index = next++, job = jobs[index];
|
|
2120
|
-
results[index] = await gates.run(prepared.get(job.source).folder, { entry: job.entry, width: job.width, height: job.height, poster:
|
|
2245
|
+
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`) });
|
|
2121
2246
|
}
|
|
2122
2247
|
}));
|
|
2123
2248
|
if (results.some((result) => !result.ok)) {
|
|
@@ -2141,8 +2266,8 @@ async function placeFrames(project, many, jobs, command2, item) {
|
|
|
2141
2266
|
print({ ok: true, placed: true, frames: placed.map(({ frame, ...rest }) => ({ ...frame, ...rest })), canvas, next: NEXT });
|
|
2142
2267
|
} finally {
|
|
2143
2268
|
await gates.close();
|
|
2144
|
-
for (const folder of temporary) await
|
|
2145
|
-
if (!keepScratch) await
|
|
2269
|
+
for (const folder of temporary) await rm6(folder, { recursive: true, force: true });
|
|
2270
|
+
if (!keepScratch) await rm6(scratch, { recursive: true, force: true });
|
|
2146
2271
|
}
|
|
2147
2272
|
}
|
|
2148
2273
|
async function addFrames(project, source, many, items) {
|
|
@@ -2159,7 +2284,7 @@ async function addFrames(project, source, many, items) {
|
|
|
2159
2284
|
entry: text(flags, "entry") || "index.html",
|
|
2160
2285
|
width: size(flags, "width", 1280),
|
|
2161
2286
|
height: flags.height === "auto" ? "auto" : size(flags, "height", 800),
|
|
2162
|
-
id:
|
|
2287
|
+
id: randomBytes3(8).toString("hex").replace(/[01lo]/g, "a").slice(0, 12)
|
|
2163
2288
|
};
|
|
2164
2289
|
});
|
|
2165
2290
|
await placeFrames(project, many, jobs, "place", (job, { result, image }) => ({
|
|
@@ -2229,7 +2354,7 @@ async function main() {
|
|
|
2229
2354
|
return;
|
|
2230
2355
|
case "--version":
|
|
2231
2356
|
case "version":
|
|
2232
|
-
process.stdout.write("0.
|
|
2357
|
+
process.stdout.write("0.4.1\n");
|
|
2233
2358
|
return;
|
|
2234
2359
|
case "guide":
|
|
2235
2360
|
process.stdout.write(SKILL_default);
|
|
@@ -2260,15 +2385,15 @@ async function main() {
|
|
|
2260
2385
|
if (!source) fail("twombly check <folder> checks a build before it is added. It needs the build's folder.");
|
|
2261
2386
|
await markWorking();
|
|
2262
2387
|
const { folder, temporary } = await prepare(resolve2(source));
|
|
2263
|
-
const scratch = await mkdtemp(
|
|
2264
|
-
const shot =
|
|
2388
|
+
const scratch = await mkdtemp(join10(tmpdir(), "twombly-check-"));
|
|
2389
|
+
const shot = join10(scratch, "screenshot.png");
|
|
2265
2390
|
try {
|
|
2266
2391
|
const height = flags.height === "auto" ? "auto" : size(flags, "height", 800);
|
|
2267
2392
|
const result = await runGates(folder, { entry: text(flags, "entry") || "index.html", width: size(flags, "width", 1280), height, poster: shot });
|
|
2268
2393
|
print(result);
|
|
2269
2394
|
if (!result.ok) process.exitCode = 1;
|
|
2270
2395
|
} finally {
|
|
2271
|
-
if (temporary) await
|
|
2396
|
+
if (temporary) await rm6(temporary, { recursive: true, force: true });
|
|
2272
2397
|
}
|
|
2273
2398
|
return;
|
|
2274
2399
|
}
|
|
@@ -2370,6 +2495,13 @@ async function main() {
|
|
|
2370
2495
|
case "logout":
|
|
2371
2496
|
print({ ok: true, ...await call("logout") });
|
|
2372
2497
|
return;
|
|
2498
|
+
case "usage": {
|
|
2499
|
+
const choice = positional[0];
|
|
2500
|
+
if (choice !== void 0 && choice !== "on" && choice !== "off") fail("Run: twombly usage on, or twombly usage off");
|
|
2501
|
+
if (choice) await setUsage(choice === "on");
|
|
2502
|
+
print({ ok: true, usage: usageOn() ? "on" : "off" });
|
|
2503
|
+
return;
|
|
2504
|
+
}
|
|
2373
2505
|
case "stop": {
|
|
2374
2506
|
if (await health()) await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: "{}" }).catch(() => {
|
|
2375
2507
|
});
|