acrylblends 1.0.1 → 1.0.3

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.
Files changed (38) hide show
  1. package/README.md +19 -15
  2. package/bin/acrylblends.js +9 -1
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +136 -5
  5. package/dist/index.js.map +1 -1
  6. package/docs/framework/README.md +1 -0
  7. package/docs/framework/agents/index.md +18 -0
  8. package/docs/framework/cordis/migration-to-1-0.md +4 -3
  9. package/docs/framework/getting-started/desktop.md +20 -0
  10. package/docs/framework/guides/create-acrylblends.md +21 -0
  11. package/docs/framework/guides/data.md +12 -0
  12. package/docs/framework/guides/production.md +11 -1
  13. package/docs/framework/workshop/README.md +381 -0
  14. package/docs/framework/workshop/facilitator.md +107 -0
  15. package/lib/cordis/db-sqlite.js +10 -2
  16. package/lib/cordis/kv-json.js +10 -2
  17. package/lib/cordis/node-server.js +1 -1
  18. package/lib/cordis/router-http.js +1 -1
  19. package/lib/node-server.js +1 -1
  20. package/lib/{test-websocket-ZXHNHQSU.js → test-websocket-D8WWqE5G.js} +1 -2
  21. package/package.json +1 -1
  22. package/plugins/acryl-db-sqlite/plugin.json +1 -1
  23. package/plugins/acryl-db-sqlite/src/index.ts +8 -1
  24. package/plugins/acryl-kv-json/plugin.json +1 -1
  25. package/plugins/acryl-kv-json/src/index.ts +8 -1
  26. package/plugins/acryl-node-server/src/adapter/request.ts +3 -2
  27. package/plugins/agent-panel/headless.ts +24 -2
  28. package/plugins/desktop/launch.ts +48 -0
  29. package/plugins/desktop/plugin.json +1 -1
  30. package/plugins/desktop-web/launch.ts +48 -0
  31. package/plugins/desktop-web/plugin.json +1 -1
  32. package/templates/chat/AGENTS.md +1 -1
  33. package/templates/chat/src/access.ts +110 -0
  34. package/templates/chat/src/chat-app.ts +7 -1
  35. package/templates/chat/src/routes.ts +5 -1
  36. package/templates/chat/test/chat.test.ts +65 -0
  37. package/templates/notes/test/notes.test.ts +1 -1
  38. package/templates/todo/src/main.ts +2 -0
@@ -0,0 +1,107 @@
1
+ # Facilitator notes: the 90-minute ACRYL Blends workshop
2
+
3
+ Read the attendee handout first (`docs/framework/workshop/README.md`); this page is the room around it: what to put on each machine, what actually breaks, and
4
+ where to cut when the clock runs. Every timing and every failure below was measured on the published `acrylblends` 1.0.0 during the readiness walk
5
+ (`docs/plans/workshop-readiness.md`).
6
+
7
+ ## Before the room: put this on every machine
8
+
9
+ | What | Why | How |
10
+ | --- | --- | --- |
11
+ | Node.js 22.19 or newer (24 LTS or 26 tested), npm 11 | the app runs TypeScript directly, no build step | `node -v`; if missing, the LTS from nodejs.org or `brew install node` (Node 26 today, which is tested) |
12
+ | git | the registry block clones the registry | `git --version` |
13
+ | A warm npm cache | the first `npm create` downloads the initializer, `npm install` downloads Electron | create one app and add the desktop shell once per machine (below) |
14
+ | A coding agent, logged in | step 7 drives Codex or Claude Code through the panel | `codex login status` or run `claude` once |
15
+ | `~/acryl-workshop/prepared/` | the no-network fallback and the reset | copy the two prepared apps (below) |
16
+ | A GitHub account with `gh auth login` | step 10 forks the workshop registry and opens a pull request | `gh auth status` |
17
+ | A device with `git` and a text editor | the edits in steps 2, 4 and 8 are small but real | `git --version` |
18
+
19
+ Warm-up, once per machine (this is also the exact smoke test that the room works):
20
+
21
+ ```sh
22
+ mkdir -p ~/acryl-workshop && cd ~/acryl-workshop
23
+ npm create acrylblends@latest warmup notes
24
+ cd warmup && npm install && npm test
25
+ npx acrylblends add desktop && npm install
26
+ cd ~/acryl-workshop && cp -R warmup prepared-notes && rm -rf warmup
27
+ ```
28
+
29
+ That leaves `prepared-notes` with everything installed, including Electron.
30
+
31
+ ## Timings, checkpoints, and what to make optional
32
+
33
+ The handout's own timings, as measured on the published 1.0.2 (macOS, cold npm cache in a throwaway home):
34
+
35
+ | Minutes | Step | Measured | If late |
36
+ | --- | --- | --- | --- |
37
+ | 0-10 | installs and checks | seconds each | never cut |
38
+ | 10-15 | create and run the blank app | create 9 s cold (2 s warm), install 2 s, boot 2 s | never cut |
39
+ | 15-22 | change one route | reload is instant | never cut |
40
+ | 22-30 | tests and the typecheck | test 0.5-2 s, check 2.5-4 s | keep; cut the "write a test" exercise |
41
+ | 30-38 | data: JSON then SQLite (**optional**) | two adds plus edits, ~8 min | **cut first** |
42
+ | 38-45 | the desktop window | add 2 s, install 3 s, first Electron boot ~20 s | pre-warm Electron on every machine or demo it |
43
+ | 45-53 | pull a starter | create 4 s, install 3 s, runs at once | keep; it is the fastest win in the room |
44
+ | 53-60 | create from a template | create + install + add desktop 4 s | drop the second app |
45
+ | 60-75 | the agent builds a feature | set-up 4 s; codex wrote a page and tests in about 7 minutes | never cut; demo it if the room is behind |
46
+ | 75-82 | upgrade | seconds: a fresh app answers "everything is up to date" | keep; it is a 2-minute talk |
47
+ | 82-90 | publish and the hero moment | the registry steps are seconds; the PR is talk | always keep at least the hero talk |
48
+
49
+ Checkpoints in the handout, and what to look for as you walk the room: **15 minutes** every screen shows `{"app":"my-hero","ok":true}`; **45 minutes**
50
+ every screen has the app in a desktop window; **75 minutes** every screen has `/board` open with the agent's page. A machine behind at a checkpoint is
51
+ what the optional column is for, and the handout names the same choices so nobody has to guess.
52
+
53
+ ## What breaks in the room, and what to say
54
+
55
+ These are the defects found by the walk. They are known; say so rather than letting people think they broke something.
56
+
57
+ - **The data step is the slow one.** Wiring `kv` then `db` by hand is about ten minutes of edits for a newcomer (two rows, two registry lines, two
58
+ type imports, the migrations folder). It is the block the handout names optional, and the one to help with first.
59
+ - **A relative migrations path in `blend.yaml` fails.** `migrations: migrations` in the row stops the app with "this app has no file base URL to
60
+ resolve it from". The folder is named in `src/app.ts`'s compose options instead; the handout teaches that form and the stuck line names the error.
61
+ - **`pi` shows up only when the app can see its login.** 1.0.2 offers pi as a chat driver when `DEEPSEEK_API_KEY` is exported or `~/.pi/agent/auth.json`
62
+ holds a deepseek entry, read from the HOME the app runs in. On a normal attendee machine it is there; in a run with a moved HOME it is not (and pi
63
+ would fail on its first message anyway). `pty:pi` needs only the program.
64
+ - **The agent run takes minutes, not seconds.** Codex wrote the page and its tests in about seven; tell the room to start it and keep talking.
65
+ - **Publishing.** The workshop registry is `github.com/acryldev/workshop-2026-11-20`, public and open. Attendees fork it, run
66
+ `node scripts/make-entry.mjs ../agent-app <login>.agent-app --description "..."`, push and open a pull request; a check merges it, usually well under
67
+ two minutes, and a refusal comes as a comment. Taking an app down is a pull request that removes its folder. Pulling is
68
+ `--from <id> --registry github:acryldev/workshop-2026-11-20` (rehearsed on 1.0.2: create 12 s, install 3 s, runs at once).
69
+ - **Browser-only attendees (TODO(M9)).** The handout's hosted-workspace stub is empty until M9 is live; until then a browser-only attendee can follow
70
+ along but not create an app.
71
+ - **node-pty's install script (C5).** `npm install` warns that npm 11 blocked `node-pty`'s script. The terminal drivers still list; the workshop does
72
+ not open one.
73
+
74
+ ## The no-network fallback
75
+
76
+ If the venue has no reliable network:
77
+
78
+ 1. Every machine gets `prepared-notes` and a prepared agent app from the sheet above, so steps 1-6 skip creation and installs.
79
+ 2. Step 8 uses the cloned registry and the installed `blends-core`; no network needed.
80
+ 3. Step 7 needs the agent's own model, so it cannot run offline. Demo it from your machine on a tethered connection, or record it (a 4-minute
81
+ screen recording of the codex run is enough).
82
+ 4. Keep one machine on a phone hotspot for `npm create` failures.
83
+
84
+ ## Demo script for the agent block (5 minutes, if you demo instead of hands-on)
85
+
86
+ 1. On the prepared agent app: `npm run dev`, open the page, click the panel button.
87
+ 2. Ask: "Add a route GET /agent-built to src/routes.ts that returns {\"built\": true}, then run the tests and tell me what happened."
88
+ 3. Narrate while it runs: the agent reads `AGENTS.md`, reads `docs/framework`, edits `src/routes.ts`, runs the tests; the terminal prints
89
+ `Change detected … Restarting`; reload `/agent-built` and show `{"built":true}`.
90
+ 4. Point at the panel's stop button and the audit line the panel writes under `.acrylblends/audit/`.
91
+
92
+ ## Resetting between sessions
93
+
94
+ Everything a person makes lives inside their app folder and `~/acryl-workshop`. To reset a machine:
95
+
96
+ ```sh
97
+ rm -rf ~/acryl-workshop/[a-z]*-app ~/acryl-workshop/registry
98
+ cp -R ~/acryl-workshop/prepared/* ~/acryl-workshop/ # if you keep a prepared/ folder
99
+ ```
100
+
101
+ Nothing is installed globally except the agent and Bun; neither needs cleaning between sessions.
102
+
103
+ ## What is still open
104
+
105
+ - Starter pull (B1), the panel's rows (C1) and the upgrade command in the docs (B2) are with the framework team; when they land, the handout's
106
+ TODO lines become normal steps and this page's "what breaks" list shrinks.
107
+ - The Windows and Linux runs of the attendee handout are being replayed separately; add what differs to the handout's OS column rather than here.
@@ -15,6 +15,10 @@ import z from "@deepseek-ai/schemastery";
15
15
  */
16
16
  /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
17
17
  const name = "acryl-db-sqlite";
18
+ /** The project folder of an acrylblends app (`appInstance.root`), when the instance has one: where a relative path in a row's config is read from, since such an app has no Loader base URL. */
19
+ function appRoot(value) {
20
+ return typeof value === "object" && value !== null && "root" in value && typeof value.root === "string" ? value.root : void 0;
21
+ }
18
22
  function isAppHome(value) {
19
23
  return typeof value === "object" && value !== null && "home" in value && typeof value.home === "string";
20
24
  }
@@ -26,12 +30,16 @@ const Config = z.object({
26
30
  });
27
31
  /** The file name used in the app home when `path` is not configured. */
28
32
  const DEFAULT_FILE = "data.sqlite";
29
- /** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise. */
33
+ /** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise, or to the app's project folder (`appInstance.root`) where there is no base URL. */
30
34
  function resolveFromApp(ctx, path, option) {
31
35
  if (path.startsWith("file:")) return fileURLToPath(path);
32
36
  if (isAbsolute(path)) return path;
33
37
  const base = ctx.baseUrl;
34
- if (base === void 0 || !base.startsWith("file:")) throw new Error(`acryl-db-sqlite: ${option} ${JSON.stringify(path)} is relative, but this app has no file base URL to resolve it from; give an absolute path`);
38
+ if (base === void 0 || !base.startsWith("file:")) {
39
+ const root = appRoot(ctx.get("appInstance"));
40
+ if (root !== void 0) return resolve(root, path);
41
+ throw new Error(`acryl-db-sqlite: ${option} ${JSON.stringify(path)} is relative, but this app has no file base URL to resolve it from; give an absolute path`);
42
+ }
35
43
  return resolve(base.endsWith("/") ? fileURLToPath(base) : dirname(fileURLToPath(base)), path);
36
44
  }
37
45
  async function apply(ctx, rawConfig = {}) {
@@ -19,12 +19,20 @@ function isAppHome(value) {
19
19
  const Config = z.object({ path: z.string().description("The JSON file, absolute or relative to the app. Defaults to kv.json in the ACRYL app home; required where there is none.") });
20
20
  /** The file name used in the app home when `path` is not configured. */
21
21
  const DEFAULT_FILE = "kv.json";
22
- /** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise. */
22
+ /** The project folder of an acrylblends app (`appInstance.root`), when the instance has one: where a relative path in a row's config is read from, since such an app has no Loader base URL. */
23
+ function appRoot(value) {
24
+ return typeof value === "object" && value !== null && "root" in value && typeof value.root === "string" ? value.root : void 0;
25
+ }
26
+ /** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise, or to the app's project folder (`appInstance.root`) where there is no base URL. */
23
27
  function resolveFromApp(ctx, path) {
24
28
  if (path.startsWith("file:")) return fileURLToPath(path);
25
29
  if (isAbsolute(path)) return path;
26
30
  const base = ctx.baseUrl;
27
- if (base === void 0 || !base.startsWith("file:")) throw new Error(`acryl-kv-json: path ${JSON.stringify(path)} is relative, but this app has no file base URL to resolve it from; give an absolute path`);
31
+ if (base === void 0 || !base.startsWith("file:")) {
32
+ const root = appRoot(ctx.get("appInstance"));
33
+ if (root !== void 0) return resolve(root, path);
34
+ throw new Error(`acryl-kv-json: path ${JSON.stringify(path)} is relative, but this app has no file base URL to resolve it from; give an absolute path`);
35
+ }
28
36
  return resolve(base.endsWith("/") ? fileURLToPath(base) : dirname(fileURLToPath(base)), path);
29
37
  }
30
38
  async function apply(ctx, rawConfig = {}) {
@@ -1,4 +1,4 @@
1
- import { c as Request, i as createAdaptorServer, l as RequestError, n as UpgradeEmitter, r as UpgradeHub, t as testWebSocket } from "../test-websocket-ZXHNHQSU.js";
1
+ import { c as Request, i as createAdaptorServer, l as RequestError, n as UpgradeEmitter, r as UpgradeHub, t as testWebSocket } from "../test-websocket-D8WWqE5G.js";
2
2
  import { t as serveStatic } from "../serve-static-3uo_9rjF.js";
3
3
  import { Service } from "@deepseek-ai/cordis";
4
4
  import z from "@deepseek-ai/schemastery";
@@ -1,4 +1,4 @@
1
- import { n as UpgradeEmitter, r as UpgradeHub } from "../test-websocket-ZXHNHQSU.js";
1
+ import { n as UpgradeEmitter, r as UpgradeHub } from "../test-websocket-D8WWqE5G.js";
2
2
  import "./node-server.js";
3
3
  import { Readable } from "node:stream";
4
4
  import { Service } from "@deepseek-ai/cordis";
@@ -1,4 +1,4 @@
1
- import { a as serve, i as createAdaptorServer, l as RequestError, n as UpgradeEmitter, o as upgradeWebSocket, r as UpgradeHub, s as getRequestListener, t as testWebSocket } from "./test-websocket-ZXHNHQSU.js";
1
+ import { a as serve, i as createAdaptorServer, l as RequestError, n as UpgradeEmitter, o as upgradeWebSocket, r as UpgradeHub, s as getRequestListener, t as testWebSocket } from "./test-websocket-D8WWqE5G.js";
2
2
  import { t as serveStatic } from "./serve-static-3uo_9rjF.js";
3
3
  import { getConnInfo } from "./node-server/conninfo.js";
4
4
  import { n as clientModules, t as buildClientModules } from "./client-modules-CLvWOjbd.js";
@@ -226,8 +226,7 @@ const readBodyBufferedBeforeDisconnect = (incoming, chunks) => {
226
226
  else if (incomingWithRecovery[bodyBufferedLengthBeforeDisconnectKey] !== void 0 && incoming.readableLength !== incomingWithRecovery[bodyBufferedLengthBeforeDisconnectKey]) result = newBodyUnusableError();
227
227
  else {
228
228
  const bodyChunks = chunks ?? [];
229
- const chunk = incoming.read();
230
- if (chunk !== null) bodyChunks.push(toBufferChunk(chunk, incoming.readableEncoding));
229
+ for (let chunk = incoming.read(); chunk !== null; chunk = incoming.read()) bodyChunks.push(toBufferChunk(chunk, incoming.readableEncoding));
231
230
  const buffer = bodyChunks.length === 1 ? bodyChunks[0] : Buffer.concat(bodyChunks);
232
231
  result = buffer;
233
232
  const contentLength = incoming.headers["content-length"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,7 +8,7 @@
8
8
  "@deepseek-ai/cordis": "4.0.4",
9
9
  "@deepseek-ai/schemastery": "3.18.4"
10
10
  },
11
- "mount": "mount it after instance: root.plugin(dbSqlite, { migrations: 'migrations' }) with the migrations/ folder (001-name.sql, ...) next to src/; then a plugin that declares inject: ['db'] calls await ctx.db.all('SELECT ...', value)",
11
+ "mount": "mount it after instance, either as a row in blend.yaml (`- id: db` / `name: acryl-db-sqlite` / `config: { migrations: migrations }`, registered in src/app.ts as 'acryl-db-sqlite') or as root.plugin(dbSqlite, { migrations: 'migrations' }); a relative path is read from the app's folder, and the migrations/ folder (001-name.sql, ...) sits next to src/. Then a plugin that declares inject: ['db'] calls await ctx.db.all('SELECT ...', value)",
12
12
  "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
13
13
  "imports": {
14
14
  "#acryl-db-sqlite": "acryl-db-sqlite/src/index.ts"
@@ -30,6 +30,11 @@ interface AppHome {
30
30
  readonly home: string
31
31
  }
32
32
 
33
+ /** The project folder of an acrylblends app (`appInstance.root`), when the instance has one: where a relative path in a row's config is read from, since such an app has no Loader base URL. */
34
+ function appRoot(value: unknown): string | undefined {
35
+ return typeof value === 'object' && value !== null && 'root' in value && typeof value.root === 'string' ? value.root : undefined
36
+ }
37
+
33
38
  function isAppHome(value: unknown): value is AppHome {
34
39
  return typeof value === 'object' && value !== null && 'home' in value && typeof value.home === 'string'
35
40
  }
@@ -51,12 +56,14 @@ export const Config: z<Config> = z.object({
51
56
  /** The file name used in the app home when `path` is not configured. */
52
57
  export const DEFAULT_FILE = 'data.sqlite'
53
58
 
54
- /** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise. */
59
+ /** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise, or to the app's project folder (`appInstance.root`) where there is no base URL. */
55
60
  function resolveFromApp(ctx: Context, path: string, option: string): string {
56
61
  if (path.startsWith('file:')) return fileURLToPath(path)
57
62
  if (isAbsolute(path)) return path
58
63
  const base = ctx.baseUrl
59
64
  if (base === undefined || !base.startsWith('file:')) {
65
+ const root = appRoot(ctx.get('appInstance'))
66
+ if (root !== undefined) return resolve(root, path)
60
67
  throw new Error(`acryl-db-sqlite: ${option} ${JSON.stringify(path)} is relative, but this app has no file base URL to resolve it from; give an absolute path`)
61
68
  }
62
69
  const appDir = base.endsWith('/') ? fileURLToPath(base) : dirname(fileURLToPath(base))
@@ -8,7 +8,7 @@
8
8
  "@deepseek-ai/cordis": "4.0.4",
9
9
  "@deepseek-ai/schemastery": "3.18.4"
10
10
  },
11
- "mount": "mount it after instance: root.plugin(kvJson, {}) (the file is kv.json in the app home, or set path); then a plugin that declares inject: ['kv'] calls await ctx.kv.set('theme', 'dark') and ctx.kv.update('visits', (n) => (n ?? 0) + 1)",
11
+ "mount": "mount it after instance, as a row in blend.yaml (`- id: kv` / `name: acryl-kv-json`, registered in src/app.ts as 'acryl-kv-json') or as root.plugin(kvJson, {}) (the file is kv.json in the app home, or set path; a relative path is read from the app's folder); then a plugin that declares inject: ['kv'] calls await ctx.kv.set('theme', 'dark') and ctx.kv.update('visits', (n) => (n ?? 0) + 1)",
12
12
  "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
13
13
  "imports": {
14
14
  "#acryl-kv-json": "acryl-kv-json/src/index.ts"
@@ -41,12 +41,19 @@ export const Config: z<Config> = z.object({
41
41
  /** The file name used in the app home when `path` is not configured. */
42
42
  export const DEFAULT_FILE = 'kv.json'
43
43
 
44
- /** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise. */
44
+ /** The project folder of an acrylblends app (`appInstance.root`), when the instance has one: where a relative path in a row's config is read from, since such an app has no Loader base URL. */
45
+ function appRoot(value: unknown): string | undefined {
46
+ return typeof value === 'object' && value !== null && 'root' in value && typeof value.root === 'string' ? value.root : undefined
47
+ }
48
+
49
+ /** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise, or to the app's project folder (`appInstance.root`) where there is no base URL. */
45
50
  function resolveFromApp(ctx: Context, path: string): string {
46
51
  if (path.startsWith('file:')) return fileURLToPath(path)
47
52
  if (isAbsolute(path)) return path
48
53
  const base = ctx.baseUrl
49
54
  if (base === undefined || !base.startsWith('file:')) {
55
+ const root = appRoot(ctx.get('appInstance'))
56
+ if (root !== undefined) return resolve(root, path)
50
57
  throw new Error(`acryl-kv-json: path ${JSON.stringify(path)} is relative, but this app has no file base URL to resolve it from; give an absolute path`)
51
58
  }
52
59
  const appDir = base.endsWith('/') ? fileURLToPath(base) : dirname(fileURLToPath(base))
@@ -135,8 +135,9 @@ const readBodyBufferedBeforeDisconnect = (
135
135
  result = newBodyUnusableError()
136
136
  } else {
137
137
  const bodyChunks = chunks ?? []
138
- const chunk = incoming.read() as Buffer | string | null
139
- if (chunk !== null) {
138
+ // Drain what is still buffered. On a destroyed stream one read() returns all of it on some Node versions and only the first chunk on others (Node 26), so read until
139
+ // the stream says there is no more: that is what Readable#read() promises, and it needs nothing of Node's internals.
140
+ for (let chunk = incoming.read() as Buffer | string | null; chunk !== null; chunk = incoming.read()) {
140
141
  bodyChunks.push(toBufferChunk(chunk, incoming.readableEncoding))
141
142
  }
142
143
  const buffer = bodyChunks.length === 1 ? bodyChunks[0] : Buffer.concat(bodyChunks)
@@ -1,5 +1,8 @@
1
1
  /** The panel's own drivers, all `chat`: the app's agent, and the coding agents (`claude`, `codex`, `pi`) started headless in the app folder. */
2
2
  import { spawn, spawnSync } from 'node:child_process'
3
+ import { readFileSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
3
6
  import type { Context } from '@deepseek-ai/cordis'
4
7
  import type { ChatDriver, ChatEvent } from './drivers.ts'
5
8
 
@@ -62,11 +65,30 @@ export function onPath(program: string): boolean {
62
65
  return found.status === 0
63
66
  }
64
67
 
65
- /** The headless programs this machine can offer: installed (pi also needs its key in the environment). */
68
+ /** Whether pi can reach DeepSeek: the key in the environment, or the one pi stored for itself when its owner logged in (`~/.pi/agent/auth.json`, a `deepseek` entry). */
69
+ export function piHasDeepseekKey(
70
+ env: NodeJS.ProcessEnv = process.env,
71
+ home: string = homedir(),
72
+ ): boolean {
73
+ if (env.DEEPSEEK_API_KEY) return true
74
+ try {
75
+ const stored = JSON.parse(
76
+ readFileSync(
77
+ join(env.PI_CODING_AGENT_DIR ?? join(home, '.pi', 'agent'), 'auth.json'),
78
+ 'utf8',
79
+ ),
80
+ ) as Record<string, { key?: unknown } | undefined>
81
+ return typeof stored.deepseek?.key === 'string' && stored.deepseek.key !== ''
82
+ } catch {
83
+ return false
84
+ }
85
+ }
86
+
87
+ /** The headless programs this machine can offer: installed (an executable on the PATH; a wrapper function around it is fine) and, for pi, able to reach its model. */
66
88
  export function availableHeadless(wanted?: readonly HeadlessName[]): HeadlessName[] {
67
89
  const found: HeadlessName[] = []
68
90
  for (const driver of wanted ?? (['claude', 'codex', 'pi'] as const)) {
69
- if (driver === 'pi' && !process.env.DEEPSEEK_API_KEY) continue
91
+ if (driver === 'pi' && !piHasDeepseekKey()) continue
70
92
  if (onPath(driver)) found.push(driver)
71
93
  }
72
94
  return found
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `npm run desktop`: starts Electron on `main.ts` next to this file. It exists for one case: on Linux where the kernel restricts unprivileged user namespaces
3
+ * (Ubuntu 23.10 and later: `kernel.apparmor_restrict_unprivileged_userns=1`), Chromium's sandbox cannot start for an Electron that npm unpacked into node_modules, and Electron
4
+ * aborts before any of our code runs. There, and only there, it starts with `--no-sandbox` and says so; everywhere else Electron starts exactly as it does with the sandbox on.
5
+ */
6
+ import { spawn } from 'node:child_process'
7
+ import { readFileSync } from 'node:fs'
8
+ import { createRequire } from 'node:module'
9
+ import { join } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ /** The sysctl that makes the sandbox fail for an Electron without an AppArmor profile; its value, or `undefined` where it does not exist (every system but a recent Ubuntu). */
13
+ export const RESTRICT_USERNS = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
14
+
15
+ /** Whether the sandbox cannot start here: Linux, with the restriction on. `read` returns a file's text, or throws when there is none. */
16
+ export function sandboxBlocked(
17
+ platform: string,
18
+ read: (path: string) => string = (path) => readFileSync(path, 'utf8'),
19
+ ): boolean {
20
+ if (platform !== 'linux') return false
21
+ try {
22
+ return read(RESTRICT_USERNS).trim() === '1'
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ export const SANDBOX_NOTICE =
29
+ "desktop: this Linux restricts unprivileged user namespaces (kernel.apparmor_restrict_unprivileged_userns=1), so Electron's sandbox cannot start; starting with --no-sandbox. " +
30
+ 'To keep the sandbox, give Electron an AppArmor profile that allows `userns` (the way the VS Code and Chrome packages do): see docs/framework/getting-started/desktop.md'
31
+
32
+ /** The arguments for Electron: the entry file, with `--no-sandbox` first when the sandbox is blocked. */
33
+ export function electronArgs(entry: string, blocked: boolean): string[] {
34
+ return blocked ? ['--no-sandbox', entry] : [entry]
35
+ }
36
+
37
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
38
+ const electron = createRequire(import.meta.url)('electron') as string
39
+ // ACRYLBLENDS_DESKTOP_SANDBOX=1: the owner has given Electron an AppArmor profile and wants the sandbox.
40
+ const blocked =
41
+ process.env.ACRYLBLENDS_DESKTOP_SANDBOX !== '1' && sandboxBlocked(process.platform)
42
+ if (blocked) console.warn(SANDBOX_NOTICE)
43
+ const child = spawn(electron, electronArgs(join(import.meta.dirname, 'main.ts'), blocked), {
44
+ stdio: 'inherit',
45
+ })
46
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => child.kill(signal))
47
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
48
+ }
@@ -9,7 +9,7 @@
9
9
  "electron": "44.9.3"
10
10
  },
11
11
  "scripts": {
12
- "desktop": "electron plugins/desktop/main.ts"
12
+ "desktop": "node plugins/desktop/launch.ts"
13
13
  },
14
14
  "omit": ["**/*.test.*"],
15
15
  "mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in plugins/desktop)"
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `npm run desktop`: starts Electron on `main.ts` next to this file. It exists for one case: on Linux where the kernel restricts unprivileged user namespaces
3
+ * (Ubuntu 23.10 and later: `kernel.apparmor_restrict_unprivileged_userns=1`), Chromium's sandbox cannot start for an Electron that npm unpacked into node_modules, and Electron
4
+ * aborts before any of our code runs. There, and only there, it starts with `--no-sandbox` and says so; everywhere else Electron starts exactly as it does with the sandbox on.
5
+ */
6
+ import { spawn } from 'node:child_process'
7
+ import { readFileSync } from 'node:fs'
8
+ import { createRequire } from 'node:module'
9
+ import { join } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ /** The sysctl that makes the sandbox fail for an Electron without an AppArmor profile; its value, or `undefined` where it does not exist (every system but a recent Ubuntu). */
13
+ export const RESTRICT_USERNS = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
14
+
15
+ /** Whether the sandbox cannot start here: Linux, with the restriction on. `read` returns a file's text, or throws when there is none. */
16
+ export function sandboxBlocked(
17
+ platform: string,
18
+ read: (path: string) => string = (path) => readFileSync(path, 'utf8'),
19
+ ): boolean {
20
+ if (platform !== 'linux') return false
21
+ try {
22
+ return read(RESTRICT_USERNS).trim() === '1'
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ export const SANDBOX_NOTICE =
29
+ "desktop: this Linux restricts unprivileged user namespaces (kernel.apparmor_restrict_unprivileged_userns=1), so Electron's sandbox cannot start; starting with --no-sandbox. " +
30
+ 'To keep the sandbox, give Electron an AppArmor profile that allows `userns` (the way the VS Code and Chrome packages do): see docs/framework/getting-started/desktop.md'
31
+
32
+ /** The arguments for Electron: the entry file, with `--no-sandbox` first when the sandbox is blocked. */
33
+ export function electronArgs(entry: string, blocked: boolean): string[] {
34
+ return blocked ? ['--no-sandbox', entry] : [entry]
35
+ }
36
+
37
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
38
+ const electron = createRequire(import.meta.url)('electron') as string
39
+ // ACRYLBLENDS_DESKTOP_SANDBOX=1: the owner has given Electron an AppArmor profile and wants the sandbox.
40
+ const blocked =
41
+ process.env.ACRYLBLENDS_DESKTOP_SANDBOX !== '1' && sandboxBlocked(process.platform)
42
+ if (blocked) console.warn(SANDBOX_NOTICE)
43
+ const child = spawn(electron, electronArgs(join(import.meta.dirname, 'main.ts'), blocked), {
44
+ stdio: 'inherit',
45
+ })
46
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => child.kill(signal))
47
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
48
+ }
@@ -9,7 +9,7 @@
9
9
  "electron": "44.9.3"
10
10
  },
11
11
  "scripts": {
12
- "desktop": "electron plugins/desktop-web/main.ts"
12
+ "desktop": "node plugins/desktop-web/launch.ts"
13
13
  },
14
14
  "gitignore": [".acrylblends/"],
15
15
  "omit": ["**/*.test.*"],
@@ -13,4 +13,4 @@ A Cordis app: `blend.yaml` lists the plugins that are mounted (`acryl-router`, `
13
13
 
14
14
  ## Before you ship
15
15
 
16
- Read `docs/framework/getting-started/deploy.md`. In short: `npm ci --omit=dev`, `npm run build`, `NODE_ENV=production npm start`; the app listens on `127.0.0.1` (`HOST=0.0.0.0` only in a container); the SQLite file goes outside the code folder (`CHAT_DB=/var/lib/chat/chat.sqlite`) and is backed up; the model key is an environment variable of the service (a root-only environment file for systemd), never a file in the repository. Anyone who can reach the app can spend your key: put it behind a login or on a private network before it is public.
16
+ Read `docs/framework/getting-started/deploy.md`. In short: `npm ci --omit=dev`, `npm run build`, `NODE_ENV=production npm start`; the app listens on `127.0.0.1` (`HOST=0.0.0.0` only in a container); the SQLite file goes outside the code folder (`CHAT_DB=/var/lib/chat/chat.sqlite`) and is backed up; the model key is an environment variable of the service (a root-only environment file for systemd), never a file in the repository. Anyone who can reach the app can spend your key, so with `NODE_ENV=production` the chat is **closed**: every route but `GET /health` answers 503 (`src/access.ts`). Set `CHAT_ACCESS_TOKEN` (the browser asks for it; any user name, the token as the password), or name the chat in `ACRYL_PRODUCTION_KEEP=chat` to make it public on purpose: that prints a `WARNING` at start and limits one address to `CHAT_RATE_PER_MINUTE` messages a minute (default 20). Prefer your own login in front of a public chat.
@@ -0,0 +1,110 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto'
2
+ import { connectionOf, createMiddleware } from '../plugins/acryl-router/src/helpers/index.ts'
3
+ import type { Context } from '../plugins/acryl-router/src/index.ts'
4
+ import { basicAuth } from '../plugins/acryl-router/src/middleware/index.ts'
5
+
6
+ /** Who may use the chat, and what the app prints about it at start. */
7
+ export type Access =
8
+ /** Development, and every test: no gate. */
9
+ | { kind: 'open' }
10
+ /** Production with `ACRYL_PRODUCTION_KEEP=chat`: anyone, with a per-IP rate limit. */
11
+ | { kind: 'public'; perMinute: number; trustProxy: boolean }
12
+ /** Production with `CHAT_ACCESS_TOKEN`: the browser asks for it (any user name, the token as the password). */
13
+ | { kind: 'token'; token: string }
14
+ /** Production with neither: every route but `/health` answers 503. */
15
+ | { kind: 'closed' }
16
+
17
+ export interface Decision {
18
+ readonly access: Access
19
+ /** Lines to print at start (a `WARNING` for a public chat, the reason for a closed one). */
20
+ readonly notices: readonly string[]
21
+ }
22
+
23
+ /**
24
+ * Anyone who can reach the chat spends the model key, so in production it is closed by default. Name the chat in `ACRYL_PRODUCTION_KEEP` to make it public
25
+ * (loudly, with a rate limit), or set `CHAT_ACCESS_TOKEN` to ask for a shared token.
26
+ */
27
+ export function chatAccess(env: Readonly<Record<string, string | undefined>>): Decision {
28
+ if (env.NODE_ENV !== 'production') return { access: { kind: 'open' }, notices: [] }
29
+ const keep = (env.ACRYL_PRODUCTION_KEEP ?? '')
30
+ .split(',')
31
+ .map((name) => name.trim())
32
+ .filter(Boolean)
33
+ if (keep.includes('chat')) {
34
+ const perMinute = Number(env.CHAT_RATE_PER_MINUTE)
35
+ return {
36
+ access: {
37
+ kind: 'public',
38
+ perMinute: Number.isInteger(perMinute) && perMinute > 0 ? perMinute : 20,
39
+ trustProxy: env.TRUST_PROXY === '1',
40
+ },
41
+ notices: [
42
+ 'WARNING: the chat is public in production because ACRYL_PRODUCTION_KEEP names "chat": anyone who can reach it spends your model key (each address is limited to a few messages a minute). Put your own login in front of it, or set CHAT_ACCESS_TOKEN instead.',
43
+ ],
44
+ }
45
+ }
46
+ const token = env.CHAT_ACCESS_TOKEN?.trim()
47
+ if (token) return { access: { kind: 'token', token }, notices: [] }
48
+ return {
49
+ access: { kind: 'closed' },
50
+ notices: [
51
+ 'The chat is closed: with NODE_ENV=production it needs CHAT_ACCESS_TOKEN (the browser asks for it) or ACRYL_PRODUCTION_KEEP=chat to be public. Every route but /health answers 503 until then.',
52
+ ],
53
+ }
54
+ }
55
+
56
+ const digest = (text: string) => createHash('sha256').update(text).digest()
57
+
58
+ /** The middleware for an access mode, mounted before the routes. `/health` is always open. */
59
+ export function accessGate(access: Access) {
60
+ if (access.kind === 'open') return undefined
61
+ if (access.kind === 'closed')
62
+ return createMiddleware(async (c, next) => {
63
+ if (c.req.path === '/health') return next()
64
+ return c.json(
65
+ {
66
+ error:
67
+ 'The chat is closed. Set CHAT_ACCESS_TOKEN, or ACRYL_PRODUCTION_KEEP=chat to make it public.',
68
+ },
69
+ 503,
70
+ )
71
+ })
72
+ if (access.kind === 'token') {
73
+ const expected = digest(access.token)
74
+ const ask = basicAuth({
75
+ realm: 'chat',
76
+ verifyUser: (_user, password) => timingSafeEqual(digest(password), expected),
77
+ })
78
+ return createMiddleware(async (c, next) => (c.req.path === '/health' ? next() : ask(c, next)))
79
+ }
80
+ return rateLimit(access.perMinute, access.trustProxy)
81
+ }
82
+
83
+ /** At most `perMinute` messages a minute from one address (`POST /api/chat`); the table forgets an address a minute after its last message. */
84
+ function rateLimit(perMinute: number, trustProxy: boolean) {
85
+ const seen = new Map<string, number[]>()
86
+ const addressOf = (c: Context) => {
87
+ if (trustProxy) {
88
+ const forwarded = c.req.header('x-forwarded-for')?.split(',').at(-1)?.trim()
89
+ if (forwarded) return forwarded
90
+ }
91
+ return connectionOf(c).remoteAddress ?? 'unknown'
92
+ }
93
+ return createMiddleware(async (c, next) => {
94
+ if (c.req.method !== 'POST' || c.req.path !== '/api/chat') return next()
95
+ const now = Date.now()
96
+ for (const [address, times] of seen)
97
+ if (now - (times.at(-1) ?? 0) > 60_000) seen.delete(address)
98
+ const address = addressOf(c)
99
+ const recent = (seen.get(address) ?? []).filter((time) => now - time < 60_000)
100
+ if (recent.length >= perMinute) {
101
+ seen.set(address, recent)
102
+ const wait = Math.ceil((60_000 - (now - (recent[0] ?? now))) / 1000)
103
+ c.header('retry-after', String(wait))
104
+ return c.json({ error: 'Too many messages. Try again in a minute.' }, 429)
105
+ }
106
+ recent.push(now)
107
+ seen.set(address, recent)
108
+ return next()
109
+ })
110
+ }
@@ -5,6 +5,7 @@ import {
5
5
  streamSSE,
6
6
  } from '../plugins/acryl-router/src/helpers/index.ts'
7
7
  import { Acryl } from '../plugins/acryl-router/src/index.ts'
8
+ import { type Access, accessGate } from './access.ts'
8
9
  import { chatStore } from './db.ts'
9
10
  import { type Llm, openAiCompatible } from './llm.ts'
10
11
  import { chatPage } from './page.tsx'
@@ -32,9 +33,14 @@ const ChatSchema: StandardSchema<ChatInput> = {
32
33
  },
33
34
  }
34
35
 
35
- export function createApp(db: Db, llm: Llm = openAiCompatible) {
36
+ /** `access` is who may use the chat (default: anyone, as in development and in the tests); see `src/access.ts`. */
37
+ export function createApp(db: Db, llm: Llm = openAiCompatible, access: Access = { kind: 'open' }) {
36
38
  const store = chatStore(db)
37
39
  const app = new Acryl()
40
+ const gate = accessGate(access)
41
+ if (gate) app.use(gate)
42
+
43
+ app.get('/health', (c) => c.json({ ok: true }))
38
44
 
39
45
  app.get('/', async (c) => {
40
46
  const id = Number(c.req.query('c'))
@@ -1,5 +1,6 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
2
  import type {} from '../plugins/acryl-db/src/cordis.ts'
3
+ import { chatAccess } from './access.ts'
3
4
  import { createApp } from './chat-app.ts'
4
5
  import { openAiCompatible } from './llm.ts'
5
6
  import { createClient } from './runtime.ts'
@@ -9,7 +10,10 @@ export const routes = {
9
10
  name: 'routes',
10
11
  inject: ['router', 'db'],
11
12
  apply(ctx: Context) {
12
- ctx.router.route('/', createApp(ctx.db, openAiCompatible))
13
+ // In production the chat is closed unless it has a token or is made public on purpose: anyone who reaches it spends the model key.
14
+ const { access, notices } = chatAccess(process.env)
15
+ for (const line of notices) console.warn(line)
16
+ ctx.router.route('/', createApp(ctx.db, openAiCompatible, access))
13
17
  ctx.router.route('/client', createClient())
14
18
  },
15
19
  }