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.
- package/dist/index.js +272 -1
- package/dist/index.js.map +1 -1
- package/docs/framework/guides/production.md +3 -3
- package/lib/{audit-GcLtvNLV.d.ts → audit-BD8_2pkh.d.ts} +25 -3
- package/lib/cordis/door.d.ts +2 -2
- package/lib/cordis/door.js +79 -6
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +24 -1
- package/lib/door/page.js +138 -9
- package/lib/door/protocol.d.ts +1 -1
- package/lib/door/protocol.js +1 -1
- package/lib/{protocol-Dt0AZBOz.js → protocol-CQ_if7Dg.js} +4 -0
- package/lib/{protocol-CR31M7K_.d.ts → protocol-a2aqrp2X.d.ts} +3 -0
- package/lib/{snapshot-text-CtQm3n8n.js → snapshot-text-m13Pk-AI.js} +95 -17
- package/package.json +1 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/cli/acryl.ts +33 -4
- package/plugins/acryl-door/src/host/channel.ts +107 -18
- package/plugins/acryl-door/src/host/door.ts +5 -2
- package/plugins/acryl-door/src/host/page-socket.ts +78 -0
- package/plugins/acryl-door/src/host/shim.ts +2 -1
- package/plugins/acryl-door/src/index.ts +10 -0
- package/plugins/acryl-door/src/page/channel.ts +67 -5
- package/plugins/acryl-door/src/page/driver/sensitivity.ts +39 -3
- package/plugins/acryl-door/src/page/index.ts +16 -1
- package/plugins/acryl-door/src/page/protocol.ts +3 -0
- package/plugins/acryl-door/src/page/socket-source.ts +75 -0
- package/plugins/agent-panel/client/main.ts +182 -20
- package/plugins/agent-panel/client/markdown.ts +219 -0
- package/plugins/agent-panel/headless.ts +20 -0
- package/plugins/agent-panel/index.ts +190 -50
- package/plugins/desktop/main.ts +50 -3
- package/plugins/desktop/menu.ts +60 -0
- package/plugins/desktop/window.ts +48 -2
- package/plugins/extensions/index.ts +81 -4
- package/templates/agent/AGENTS.md +4 -2
- package/templates/agent/src/app.ts +9 -1
- package/templates/agent/src/routes.ts +13 -3
- 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
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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.
|
|
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
|
-
**
|
|
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
|
-
/**
|
|
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
|
|
20
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}))
|