acrylblends 1.0.6 → 1.0.8

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 (40) hide show
  1. package/dist/index.js +272 -1
  2. package/dist/index.js.map +1 -1
  3. package/docs/framework/guides/production.md +3 -3
  4. package/lib/{audit-GcLtvNLV.d.ts → audit-BD8_2pkh.d.ts} +25 -3
  5. package/lib/cordis/door.d.ts +2 -2
  6. package/lib/cordis/door.js +79 -6
  7. package/lib/door/host.d.ts +2 -2
  8. package/lib/door/host.js +1 -1
  9. package/lib/door/page.d.ts +24 -1
  10. package/lib/door/page.js +138 -9
  11. package/lib/door/protocol.d.ts +1 -1
  12. package/lib/door/protocol.js +1 -1
  13. package/lib/{protocol-Dt0AZBOz.js → protocol-CQ_if7Dg.js} +4 -0
  14. package/lib/{protocol-CR31M7K_.d.ts → protocol-a2aqrp2X.d.ts} +3 -0
  15. package/lib/{snapshot-text-CtQm3n8n.js → snapshot-text-m13Pk-AI.js} +95 -17
  16. package/package.json +1 -1
  17. package/plugins/acryl-door/plugin.json +3 -0
  18. package/plugins/acryl-door/src/cli/acryl.ts +33 -4
  19. package/plugins/acryl-door/src/host/channel.ts +107 -18
  20. package/plugins/acryl-door/src/host/door.ts +5 -2
  21. package/plugins/acryl-door/src/host/page-socket.ts +78 -0
  22. package/plugins/acryl-door/src/host/shim.ts +2 -1
  23. package/plugins/acryl-door/src/index.ts +10 -0
  24. package/plugins/acryl-door/src/page/channel.ts +67 -5
  25. package/plugins/acryl-door/src/page/driver/sensitivity.ts +39 -3
  26. package/plugins/acryl-door/src/page/index.ts +16 -1
  27. package/plugins/acryl-door/src/page/protocol.ts +3 -0
  28. package/plugins/acryl-door/src/page/socket-source.ts +75 -0
  29. package/plugins/agent-panel/client/main.ts +182 -20
  30. package/plugins/agent-panel/client/markdown.ts +219 -0
  31. package/plugins/agent-panel/headless.ts +20 -0
  32. package/plugins/agent-panel/index.ts +190 -50
  33. package/plugins/desktop/main.ts +50 -3
  34. package/plugins/desktop/menu.ts +60 -0
  35. package/plugins/desktop/window.ts +48 -2
  36. package/plugins/extensions/index.ts +81 -4
  37. package/templates/agent/AGENTS.md +4 -2
  38. package/templates/agent/src/app.ts +9 -1
  39. package/templates/agent/src/routes.ts +13 -3
  40. package/templates/agent/test/app.test.ts +27 -2
@@ -1,23 +1,69 @@
1
1
  /** The one window of a desktop app: the operating system's frame around a page the app's own server serves, with the renderer locked down. */
2
- import type { BrowserWindowConstructorOptions } from 'electron'
3
2
 
4
3
  export interface WindowSpec {
5
4
  readonly title: string
6
5
  readonly width?: number
7
6
  readonly height?: number
7
+ /** Where the window's top left corner goes; Electron centers the window when these are left out. */
8
+ readonly x?: number
9
+ readonly y?: number
10
+ }
11
+
12
+ export interface Rect {
13
+ readonly x: number
14
+ readonly y: number
15
+ readonly width: number
16
+ readonly height: number
17
+ }
18
+
19
+ /** How far each new window sits from the one it was opened from. */
20
+ export const CASCADE_STEP = 32
21
+
22
+ /**
23
+ * Where a new window goes so that it does not cover another one exactly: down and to the right of `from` by one step, and back at the top left of the work area when that would put
24
+ * it past the edge of the screen (so a row of windows cascades, then starts again).
25
+ */
26
+ export function cascade(
27
+ from: Rect,
28
+ work: Rect,
29
+ size: { width: number; height: number },
30
+ ): { x: number; y: number } {
31
+ const x = from.x + CASCADE_STEP
32
+ const y = from.y + CASCADE_STEP
33
+ const fitsRight = x + size.width <= work.x + work.width
34
+ const fitsBottom = y + size.height <= work.y + work.height
35
+ return { x: fitsRight ? x : work.x, y: fitsBottom ? y : work.y }
8
36
  }
9
37
 
10
38
  /**
11
39
  * Secure BrowserWindow options. The settings are ACRYL Desktop's: context isolation on, no Node in the page, the sandbox on, web security on; the window stays
12
40
  * hidden until the page has painted (`show: false`, shown on `ready-to-show`).
13
41
  */
14
- export function windowOptions(spec: WindowSpec): BrowserWindowConstructorOptions {
42
+ export interface WindowOptions {
43
+ readonly title: string
44
+ readonly width: number
45
+ readonly height: number
46
+ readonly minWidth: number
47
+ readonly minHeight: number
48
+ readonly x?: number
49
+ readonly y?: number
50
+ readonly show: boolean
51
+ readonly webPreferences: {
52
+ readonly contextIsolation: boolean
53
+ readonly nodeIntegration: boolean
54
+ readonly sandbox: boolean
55
+ readonly webSecurity: boolean
56
+ }
57
+ }
58
+
59
+ export function windowOptions(spec: WindowSpec): WindowOptions {
15
60
  return {
16
61
  title: spec.title,
17
62
  width: spec.width ?? 1100,
18
63
  height: spec.height ?? 760,
19
64
  minWidth: 480,
20
65
  minHeight: 360,
66
+ ...(spec.x === undefined || spec.y === undefined ? {} : { x: spec.x, y: spec.y }),
21
67
  show: false,
22
68
  webPreferences: {
23
69
  contextIsolation: true,
@@ -75,8 +75,19 @@ export interface ExtensionsConfig {
75
75
  sources?: readonly ExtensionSource[]
76
76
  /** When true, every source that would install by itself asks for approval instead (`ask`). */
77
77
  approval?: boolean
78
+ /**
79
+ * Production: the extension folders of the sources that install by themselves are started straight from where they are, once, and nothing can be installed, updated or
80
+ * removed afterwards (those calls are refused). No staging, no state file, no ledger, no approval queue are written or asked: the code that runs is the code in the folder,
81
+ * as it is deployed. One line says what was started, and a recorded extension whose folder is missing is a warning.
82
+ */
83
+ readOnly?: boolean
84
+ /** Where the read-only start says what it did (the console by default). */
85
+ log?: (line: string) => void
78
86
  }
79
87
 
88
+ const READ_ONLY =
89
+ 'the extensions of this app are read-only here (production): nothing can be installed, updated, removed or approved while it runs'
90
+
80
91
  export interface ExtensionInfo {
81
92
  readonly name: string
82
93
  readonly source: string
@@ -215,6 +226,8 @@ export class Extensions extends Service {
215
226
  private readonly requests = new Map<string, { dir: string; version: string }>()
216
227
  private tail: Promise<unknown> = Promise.resolve()
217
228
  private disposed = false
229
+ private readonly readOnly: boolean
230
+ private readonly say: (line: string) => void
218
231
  /**
219
232
  * The context the service was constructed with. Inside a service method `this.ctx` is Cordis's shadow context, which resolves services as the
220
233
  * service's own fiber, so a plugin mounted from it would read `ctx.router` from the installer's fiber and fail. Extensions mount from this one.
@@ -224,6 +237,8 @@ export class Extensions extends Service {
224
237
  constructor(ctx: Context, config: ExtensionsConfig = {}) {
225
238
  super(ctx, 'extensions')
226
239
  this.owner = ctx
240
+ this.readOnly = config.readOnly === true
241
+ this.say = config.log ?? ((line) => console.log(line))
227
242
  const { home, root } = ctx.appInstance
228
243
  this.sources = (config.sources ?? [{ dir: join(home, 'extensions'), trust: 'install' }]).map(
229
244
  (source) => ({
@@ -235,10 +250,12 @@ export class Extensions extends Service {
235
250
  this.stateFile = join(home, 'extensions.json')
236
251
  this.ledgerFile = join(home, 'extensions-ledger.jsonl')
237
252
  // Effects run their disposers newest first: settle pending operations, then dispose every mounted extension, then drop the hook.
238
- ctx.effect(
239
- () => resolveFromApp(this.stageRoot, root, routerAliases()),
240
- 'extensions: resolve staged imports from the app',
241
- )
253
+ // Staged copies live in the app's home, outside the app: their bare imports are resolved from the app. A read-only start imports the folders where they are (inside the app).
254
+ if (!this.readOnly)
255
+ ctx.effect(
256
+ () => resolveFromApp(this.stageRoot, root, routerAliases()),
257
+ 'extensions: resolve staged imports from the app',
258
+ )
242
259
  ctx.effect(
243
260
  () => async () => {
244
261
  for (const entry of [...this.installed.values()].reverse())
@@ -265,16 +282,19 @@ export class Extensions extends Service {
265
282
 
266
283
  /** Install a folder, or update the installed extension of the same name. The folder must be inside one of the sources. */
267
284
  install(source: string): Promise<InstallResult> {
285
+ if (this.readOnly) return Promise.reject(new Error(READ_ONLY))
268
286
  return this.serial(() => this.installNow(source))
269
287
  }
270
288
 
271
289
  /** Remove an installed extension: dispose it, forget it, delete its staged copies. Its source folder is left alone. */
272
290
  remove(name: string): Promise<RemoveResult> {
291
+ if (this.readOnly) return Promise.reject(new Error(READ_ONLY))
273
292
  return this.serial(() => this.removeNow(name))
274
293
  }
275
294
 
276
295
  /** Bring every installed extension in line with its source, and install new folders from sources trusted to install. */
277
296
  reload(): Promise<InstallResult[]> {
297
+ if (this.readOnly) return Promise.reject(new Error(READ_ONLY))
278
298
  return this.serial(async () => {
279
299
  const results: InstallResult[] = []
280
300
  for (const entry of [...this.installed.values()]) {
@@ -317,6 +337,7 @@ export class Extensions extends Service {
317
337
 
318
338
  /** Approve the version that is waiting, then install it. Refused when the source changed since it was requested. */
319
339
  approve(name: string): Promise<InstallResult> {
340
+ if (this.readOnly) return Promise.reject(new Error(READ_ONLY))
320
341
  return this.serial(async (): Promise<InstallResult> => {
321
342
  const request = this.requests.get(name)
322
343
  if (!request) {
@@ -349,6 +370,7 @@ export class Extensions extends Service {
349
370
 
350
371
  /** Refuse the version that is waiting. Nothing changes except the ledger. */
351
372
  deny(name: string): Promise<RemoveResult> {
373
+ if (this.readOnly) return Promise.reject(new Error(READ_ONLY))
352
374
  return this.serial(async (): Promise<RemoveResult> => {
353
375
  const request = this.requests.get(name)
354
376
  if (!request)
@@ -701,7 +723,62 @@ export class Extensions extends Service {
701
723
  }
702
724
 
703
725
  /** Never throws: one extension's failure is recorded and the others still start. */
726
+ /**
727
+ * The production start: every folder of a source that installs by itself is loaded from where it is and mounted; the line says which. An extension that the app's own record
728
+ * (`extensions.json`, written by dev) names while its folder is gone is named in a warning, because it is not started.
729
+ */
730
+ private async startupReadOnly(): Promise<StartupSummary> {
731
+ const summary: StartupSummary = { installed: [], pending: [], stale: [], failed: [] }
732
+ const found = this.discover()
733
+ for (const folder of found) {
734
+ if (folder.trust !== 'install') {
735
+ summary.pending.push(folder.name)
736
+ continue
737
+ }
738
+ try {
739
+ const module = await this.load(folder.dir, folder.name)
740
+ const mounted = await this.mount(module)
741
+ this.installed.set(folder.name, {
742
+ name: folder.name,
743
+ source: folder.dir,
744
+ version: hashFolder(folder.dir),
745
+ dir: folder.dir,
746
+ module,
747
+ fiber: mounted.fiber,
748
+ })
749
+ if (mounted.error !== undefined)
750
+ summary.failed.push({ name: folder.name, error: message(mounted.error) })
751
+ else summary.installed.push(folder.name)
752
+ } catch (error) {
753
+ summary.failed.push({ name: folder.name, error: message(error) })
754
+ }
755
+ }
756
+ this.say(
757
+ summary.installed.length === 0
758
+ ? `extensions: none found in ${this.sources.map((source) => basename(source.dir)).join(', ')}/`
759
+ : `extensions (read-only): ${summary.installed.join(', ')}`,
760
+ )
761
+ for (const failure of summary.failed)
762
+ this.say(`WARNING: extension "${failure.name}" did not start: ${failure.error}`)
763
+ try {
764
+ if (existsSync(this.stateFile)) {
765
+ for (const recorded of Object.keys(
766
+ JSON.parse(readFileSync(this.stateFile, 'utf8')) as Record<string, unknown>,
767
+ )) {
768
+ if (!found.some((folder) => folder.name === recorded))
769
+ this.say(
770
+ `WARNING: ${basename(this.stateFile)} records the extension "${recorded}", but ${this.sources.map((source) => basename(source.dir)).join(', ')}/ has no folder for it: it is not started in production. Commit its folder, or run the app in development and install it again`,
771
+ )
772
+ }
773
+ }
774
+ } catch {
775
+ // A record that cannot be read says nothing about what to start.
776
+ }
777
+ return summary
778
+ }
779
+
704
780
  private async startup(): Promise<StartupSummary> {
781
+ if (this.readOnly) return this.startupReadOnly()
705
782
  const summary: StartupSummary = { installed: [], pending: [], stale: [], failed: [] }
706
783
  let records: Record<string, Record_> = {}
707
784
  try {
@@ -7,6 +7,8 @@ and an optional `check(ctx)` that receives the plugin's own context (so `ctx.rou
7
7
 
8
8
  **Write an extension, not a row.** A feature you build while the app runs is a folder `plugins/<name>/` installed with `.acryl/bin/acryl plugin add plugins/<name>` (the `extensions` plugin loads it again at every start). Add a row to `blend.yaml` and an entry to the `registry` of `src/app.ts` only when the person asks for a plugin that is part of the app itself. Both kinds may import the framework by name (`acrylblends`, `acrylblends/body-limit`, `acryl-router`); a row's relative imports of its sibling plugins also work.
9
9
 
10
- **Drive the app you build.** `.acryl/bin/acryl look` reads the open page as text with refs, and `acryl act click|type|press|scroll|wait --ref <ref>` presses its buttons, opens its sidebars and fills its forms (look again before each act: the refs change). `act navigate --path /settings` goes to another page of the app; a path the app does not serve answers `404 at /nope` and the window shows a 404 page, so navigate on from there. In the desktop app (`npm run desktop`) the app's windows and menu are verbs too: `acryl window list`, `acryl window open --path /settings`, `acryl look --window <id>` (and `act ... --window <id>`) to drive that window's page, `acryl window resize|focus|close --id <id>`, `acryl menu list` and `acryl menu click --path "File>New Note"`. Closing the last window and Quit end the desktop app, so they need `--force`: do not use it unless the person asks. When a person asks for settings, build them as a page at `/settings`, so `acryl window open --path /settings` opens them in every app. Without a desktop app these answer "no desktop window is attached; run npm run desktop".
10
+ **Drive the app you build.** `.acryl/bin/acryl look` reads the open page as text with refs, and `acryl act click|type|press|scroll|wait --ref <ref>` presses its buttons, opens its sidebars and fills its forms (look again before each act: the refs change). `act navigate --path /settings` goes to another page of the app; a path the app does not serve answers `404 at /nope` and the window shows a 404 page, so navigate on from there. In the desktop app (`npm run desktop`) the app's windows and menu are verbs too: `acryl window list`, `acryl window open --path /settings`, `acryl look --window <id>` (and `act ... --window <id>`) to drive that window's page, `acryl window resize|focus|close --id <id>`, `acryl menu list` and `acryl menu click --path "File>New Note"`. Closing the last window and Quit end the desktop app, so they need `--force`: do not use it unless the person asks. The app has a `/settings` page already (the appearance: system, light or dark, kept in the browser) and the desktop app's menu has a Settings item that opens it; add a person's other settings to that page, not to a new one, so `acryl menu click --path "File>Settings..."` and `acryl window open --path /settings` keep opening them (on macOS the item is in the application menu). Without a desktop app these answer "no desktop window is attached; run npm run desktop".
11
11
 
12
- **Production.** The agent, the builder and the extensions are development tools: `POST /ask` spends the model key and runs tools, a plugin installed live runs in this process, and none of it is behind a login. With `NODE_ENV=production` `src/app.ts` leaves those three rows out, so `/ask`, `/extensions*` and `/tasks*` are not mounted; the page and `GET /health` stay. `ACRYL_PRODUCTION_KEEP=agent,builder,extensions` keeps the named rows, and each one kept prints a `WARNING` line at start.
12
+ **Keep to two event streams a page.** A browser lets a page hold six connections to one host, across all the windows of the app, and an `EventSource` holds one for as long as the page is open. The door and the panel use none (they are WebSockets), but a page you build that streams several things in two windows can fill all six, and then every request waits. Give a page at most two streams: share one stream for several cells, or fetch what changes now and then instead.
13
+
14
+ **Production.** The agent, the builder and the extensions are development tools: `POST /ask` spends the model key and runs tools, a plugin installed live runs in this process, and none of it is behind a login. With `NODE_ENV=production` `src/app.ts` leaves the agent and the builder out, so `/ask`, `/extensions*` and `/tasks*` are not mounted; the page and `GET /health` stay. The extensions you installed (the folders of `plugins/`, committed with the app) still run in production, read-only: nothing can be installed or changed there. `ACRYL_PRODUCTION_KEEP=agent,builder,extensions` keeps the named rows, and each one kept prints a `WARNING` line at start.
@@ -64,7 +64,12 @@ export interface App {
64
64
  stop(): Promise<void>
65
65
  }
66
66
 
67
- /** The development rows production leaves out, and what each one gives a visitor. */
67
+ /**
68
+ * The development rows, and what each one gives a visitor. Production leaves the agent and the builder out. The extensions row is not left out: it starts again, read-only,
69
+ * the extensions in the app's `plugins/` folder (what the agent built and installed in development, committed with the app), so a deployed app has its own features; nothing
70
+ * can be installed, updated or approved while it runs, and `/ask`, `/extensions*` and `/tasks*` stay unmounted (they need the agent). `ACRYL_PRODUCTION_KEEP=extensions` keeps the
71
+ * live install itself.
72
+ */
68
73
  const DEVELOPMENT_ROWS: Record<string, string> = {
69
74
  agent: 'POST /ask runs the agent, which spends the model key and calls its tools',
70
75
  builder: 'the agent can write plugin source into plugins/',
@@ -91,6 +96,7 @@ export async function createApp(
91
96
  // serves a folder's door, so the tests that start several apps set it).
92
97
  const listen = options.listen !== false
93
98
  const leaveOut = new Set<string>()
99
+ let extensionsReadOnly = false
94
100
  if (!listen) leaveOut.add('acryl-node-server').add('acryl-door')
95
101
  if (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off')
96
102
  leaveOut.add('acryl-door')
@@ -114,6 +120,7 @@ export async function createApp(
114
120
  console.warn(
115
121
  `WARNING: the "${row}" row runs in production because ACRYL_PRODUCTION_KEEP names it: ${danger}. Nothing here is behind a login; put the app behind your own.`,
116
122
  )
123
+ else if (row === 'extensions') extensionsReadOnly = true
117
124
  else leaveOut.add(row)
118
125
  }
119
126
  }
@@ -141,6 +148,7 @@ export async function createApp(
141
148
  tasks: { manifest: `sha256:${manifest.digest}` },
142
149
  // The `approval` parameter in blend.yaml (the extensions row) turns this source into one that asks.
143
150
  extensions: {
151
+ ...(extensionsReadOnly ? { readOnly: true } : {}),
144
152
  sources: [
145
153
  { dir: extensionsDir, trust: 'install' },
146
154
  ...(legacy ? [{ dir: legacyDir, trust: 'install' as const }] : []),
@@ -16,9 +16,18 @@ const DOOR_TAG =
16
16
  /**
17
17
  * The app's own routes on the router. Always: the page and `GET /health`. While the agent, the extensions and the task recorder run: `POST /ask` for the agent; `GET /extensions`
18
18
  * (and `POST /extensions/approve` and `/deny`) and `GET /tasks` to observe and steer what it did (every run is a durable task owning one task per tool call); `POST /tasks/cancel`.
19
- * What changes is also streamed: `GET /api/state/tasks/events` and `GET /api/state/extensions/events`. In production `src/app.ts` leaves the agent, builder and extensions rows
20
- * out (unless `ACRYL_PRODUCTION_KEEP` names them), and then those routes are not mounted at all. The mounts are removed when the plugin is disposed.
19
+ * What changes is also streamed: `GET /api/state/tasks/events` and `GET /api/state/extensions/events`. In production `src/app.ts` leaves the agent and builder rows out
20
+ * and starts the extensions read-only (unless `ACRYL_PRODUCTION_KEEP` names them), and then those routes are not mounted at all. The mounts are removed when the plugin is disposed.
21
21
  */
22
+ /** The page's look: the person's choice from /settings (kept in this browser), applied before the page paints. */
23
+ const APPEARANCE_STYLE =
24
+ '<style>:root{color-scheme:light dark}html[data-theme=dark]{color-scheme:dark;background:#111827;color:#f9fafb}html[data-theme=light]{color-scheme:light;background:#fff;color:#111827}body{font:16px system-ui,sans-serif;margin:2rem auto;max-width:42rem;padding:0 1rem}a{color:inherit}</style>'
25
+ const APPEARANCE_SCRIPT =
26
+ "<script>(function(){try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t}catch(e){}})()</script>"
27
+
28
+ /** `/settings`: the one setting the template has, the appearance. The desktop app's Settings menu item opens this page. */
29
+ const settingsPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Settings - ${APP_NAME}</title>${APPEARANCE_STYLE}${APPEARANCE_SCRIPT}</head><body><h1>Settings</h1><p><label for="theme">Appearance</label> <select id="theme" name="theme"><option value="system">Match the system</option><option value="light">Light</option><option value="dark">Dark</option></select></p><p id="saved" role="status"></p><p><a href="/">Back to ${APP_NAME}</a></p><script>(function(){var select=document.getElementById('theme'),saved=document.getElementById('saved');try{select.value=localStorage.getItem('theme')||'system'}catch(e){}select.addEventListener('change',function(){var value=select.value;try{if(value==='system'){localStorage.removeItem('theme');delete document.documentElement.dataset.theme}else{localStorage.setItem('theme',value);document.documentElement.dataset.theme=value}saved.textContent='Saved: '+select.options[select.selectedIndex].text}catch(e){saved.textContent='This browser would not keep the setting.'}})})()</script></body></html>`
30
+
22
31
  export const routes = {
23
32
  name: 'routes',
24
33
  inject: ['router'],
@@ -37,11 +46,12 @@ export const routes = {
37
46
  .get('/', (c) =>
38
47
  (c.req.header('accept') ?? '').includes('text/html')
39
48
  ? c.html(
40
- `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title></head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). Ask it for a feature in the agent panel on this page (or with <code>POST /ask</code>).</p>${DOOR_TAG}</body></html>`,
49
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title>${APPEARANCE_STYLE}${APPEARANCE_SCRIPT}</head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). Ask it for a feature in the agent panel on this page (or with <code>POST /ask</code>).</p>${DOOR_TAG}</body></html>`,
41
50
  )
42
51
  : c.json({ app: APP_NAME, ok: true }),
43
52
  )
44
53
  .get('/health', (c) => c.json({ ok: true }))
54
+ .get('/settings', (c) => c.html(settingsPage))
45
55
  ctx.router.route('/', base)
46
56
  // The routes that give a visitor the agent: mounted only while the agent, the extensions and the tasks run (see the header).
47
57
  ctx.inject(['agent', 'extensions', 'tasks'], (inner) => {
@@ -97,7 +97,7 @@ const asking = (app: App) =>
97
97
  body: JSON.stringify({ question: 'add 2 3' }),
98
98
  })
99
99
 
100
- test('in production the agent, the builder and the extensions are left out: no /ask, no live install, no approve route; the page and /health stay', () =>
100
+ test('in production the agent and the builder are left out and the extensions are read-only: no /ask, no live install, no approve route; the page and /health stay', () =>
101
101
  inEnvironment({ NODE_ENV: 'production', ACRYL_PRODUCTION_KEEP: undefined }, async (app) => {
102
102
  assert.equal((await app.root.router.request('/health')).status, 200)
103
103
  assert.equal((await app.root.router.request('/')).status, 200)
@@ -112,7 +112,12 @@ test('in production the agent, the builder and the extensions are left out: no /
112
112
  await response.body?.cancel()
113
113
  }
114
114
  assert.equal((app.root as unknown as { agent?: unknown }).agent, undefined)
115
- assert.equal((app.root as unknown as { extensions?: unknown }).extensions, undefined)
115
+ // The service is there (it starts what plugins/ holds, once) and refuses every change.
116
+ const service = (
117
+ app.root as unknown as { extensions?: { install(source: string): Promise<unknown> } }
118
+ ).extensions
119
+ assert.ok(service, 'the extensions service starts read-only in production')
120
+ await assert.rejects(service.install('/anywhere'), /read-only here \(production\)/)
116
121
  }))
117
122
 
118
123
  test('ACRYL_PRODUCTION_KEEP keeps the named rows in production and says so at start; a name it does not know is reported', () =>
@@ -139,3 +144,23 @@ test('outside production nothing changes: the agent answers and the extensions r
139
144
  assert.equal((await app.root.router.request('/extensions')).status, 200)
140
145
  assert.deepEqual(warnings, [])
141
146
  }))
147
+
148
+ test('the app has a /settings page with the appearance setting, and the home page applies it', () =>
149
+ inEnvironment({ NODE_ENV: 'test', ACRYL_PRODUCTION_KEEP: undefined }, async (app) => {
150
+ const settings = await app.root.router.request('/settings', {
151
+ headers: { accept: 'text/html' },
152
+ })
153
+ assert.equal(settings.status, 200)
154
+ const page = await settings.text()
155
+ assert.match(page, /<h1>Settings<\/h1>/)
156
+ assert.match(page, /<select id="theme"/)
157
+ for (const option of ['System', 'Light', 'Dark'])
158
+ assert.match(page, new RegExp(`>(?:Match the )?${option.toLowerCase()}|>${option}<`, 'i'))
159
+ // The setting is kept in the browser and applied to the home page before it paints.
160
+ const home = await (
161
+ await app.root.router.request('/', { headers: { accept: 'text/html' } })
162
+ ).text()
163
+ assert.match(home, /localStorage\.getItem\('theme'\)/)
164
+ // ... without leaving a global behind for the page's own code to collide with.
165
+ assert.doesNotMatch(home, /<script>\s*(?:var|let|const) /)
166
+ }))