acrylblends 0.7.7 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +14 -6
- package/dist/index.js +130 -31
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +3 -3
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/agents/index.md +18 -3
- package/docs/framework/getting-started/basic.md +1 -2
- package/docs/framework/getting-started/desktop.md +3 -3
- package/docs/framework/guides/bring-your-own-tool.md +216 -0
- package/docs/framework/guides/client-modules.md +4 -3
- package/docs/framework/guides/create-acrylblends.md +5 -4
- package/docs/framework/guides/data.md +7 -6
- package/docs/framework/helpers/streaming.md +12 -27
- package/docs/framework/upstream.json +3 -3
- package/lib/browser/read-sse.js +84 -0
- package/lib/db/sqlite.d.ts +1 -1
- package/lib/db.d.ts +1 -84
- package/lib/index-BLTYsyc1.d.ts +93 -0
- package/lib/index-D4FAsBZu.d.ts +85 -0
- package/lib/kv/db.d.ts +2 -2
- package/lib/kv/json.d.ts +1 -1
- package/lib/kv/json.js +87 -24
- package/lib/kv.d.ts +1 -92
- package/lib/node-server.d.ts +3 -3
- package/lib/node-server.js +14 -8
- package/lib/read-sse.d.ts +33 -0
- package/lib/read-sse.js +84 -0
- package/package.json +5 -1
- package/plugins/acryl-db/plugin.json +7 -2
- package/plugins/acryl-db/src/conformance.ts +224 -0
- package/plugins/acryl-db/upstream.json +2 -2
- package/plugins/acryl-db-sqlite/plugin.json +4 -1
- package/plugins/acryl-db-sqlite/upstream.json +1 -1
- package/plugins/acryl-door/LICENSE +47 -0
- package/plugins/acryl-door/plugin.json +14 -0
- package/plugins/acryl-door/src/cli/acryl.ts +207 -0
- package/plugins/acryl-door/src/host/api.ts +9 -0
- package/plugins/acryl-door/src/host/audit.ts +64 -0
- package/plugins/acryl-door/src/host/channel.ts +142 -0
- package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
- package/plugins/acryl-door/src/host/door-file.ts +94 -0
- package/plugins/acryl-door/src/host/door.ts +195 -0
- package/plugins/acryl-door/src/host/http.ts +80 -0
- package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
- package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
- package/plugins/acryl-door/src/host/page-routes.ts +65 -0
- package/plugins/acryl-door/src/host/shim.ts +65 -0
- package/plugins/acryl-door/src/host/verbs.ts +109 -0
- package/plugins/acryl-door/src/index.ts +133 -0
- package/plugins/acryl-door/src/page/api.ts +9 -0
- package/plugins/acryl-door/src/page/channel.ts +135 -0
- package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
- package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
- package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
- package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
- package/plugins/acryl-door/src/page/index.ts +40 -0
- package/plugins/acryl-door/src/page/indicator.ts +72 -0
- package/plugins/acryl-door/src/page/main.ts +7 -0
- package/plugins/acryl-door/src/page/protocol.ts +213 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
- package/plugins/acryl-door/src/page/user-input.ts +24 -0
- package/plugins/acryl-door/upstream.json +28 -0
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-kv/plugin.json +7 -2
- package/plugins/acryl-kv/src/conformance.ts +226 -0
- package/plugins/acryl-kv/upstream.json +1 -1
- package/plugins/acryl-kv-db/plugin.json +4 -1
- package/plugins/acryl-kv-db/upstream.json +1 -1
- package/plugins/acryl-kv-json/plugin.json +4 -1
- package/plugins/acryl-kv-json/src/open.ts +86 -22
- package/plugins/acryl-kv-json/upstream.json +2 -2
- package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
- package/plugins/acryl-node-server/src/client-modules.ts +10 -9
- package/plugins/acryl-node-server/upstream.json +2 -2
- package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
- package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
- package/plugins/acryl-router/src/service.ts +3 -0
- package/plugins/acryl-router/upstream.json +2 -2
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/agent-panel/client/main.ts +159 -0
- package/plugins/agent-panel/index.ts +232 -0
- package/plugins/agent-panel/plugin.json +21 -0
- package/plugins/builder/index.ts +2 -2
- package/plugins/builder/plugin.json +1 -1
- package/plugins/desktop/main.ts +3 -3
- package/plugins/desktop/plugin.json +2 -2
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/main.ts +2 -2
- package/plugins/desktop-web/plugin.json +2 -2
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/extensions/index.ts +19 -1
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +17 -3
- package/plugins/manifest/lock.ts +7 -3
- package/plugins/manifest/upgrade-cli.ts +1 -1
- package/plugins/save/cli.ts +1 -1
- package/plugins/save/plugin.json +1 -1
- package/plugins/tsconfig.json +6 -0
- package/shared/AGENTS.core.md +10 -10
- package/shared/DEFAULTS.md +45 -0
- package/shared/SKILL.md +14 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/README.md +6 -6
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +8 -8
- package/templates/agent/src/app.ts +46 -17
- package/templates/agent/src/main.ts +2 -2
- package/templates/agent/src/routes.ts +19 -6
- package/templates/agent/template.json +2 -1
- package/templates/agent/test/agent.test.ts +11 -5
- package/templates/agent/test/app.test.ts +30 -9
- package/templates/agent/test/builder.test.ts +35 -24
- package/templates/agent/test/model.test.ts +25 -20
- package/templates/agent/tsconfig.json +48 -50
- package/templates/blank/AGENTS.md +1 -1
- package/templates/blank/README.md +2 -2
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +8 -8
- package/templates/blank/src/about.tsx +2 -2
- package/templates/blank/src/app.ts +29 -9
- package/templates/blank/src/main.ts +1 -1
- package/templates/blank/src/routes.ts +1 -1
- package/templates/blank/test/app.test.ts +29 -10
- package/templates/blank/tsconfig.json +1 -1
- package/templates/chat/AGENTS.md +16 -0
- package/templates/chat/blend.yaml +29 -0
- package/templates/chat/gitignore +6 -0
- package/templates/chat/migrations/001-chat.sql +12 -0
- package/templates/chat/package.json +33 -0
- package/templates/chat/src/app.ts +106 -0
- package/templates/chat/src/chat-app.ts +101 -0
- package/templates/chat/src/client/chat.tsx +115 -0
- package/templates/chat/src/client/tsconfig.json +26 -0
- package/templates/chat/src/db.ts +43 -0
- package/templates/chat/src/door.ts +7 -0
- package/templates/chat/src/llm.ts +42 -0
- package/templates/chat/src/main.ts +28 -0
- package/templates/chat/src/page.tsx +40 -0
- package/templates/chat/src/routes.ts +15 -0
- package/templates/chat/src/runtime.ts +17 -0
- package/templates/chat/template.json +11 -0
- package/templates/chat/test/chat.test.ts +198 -0
- package/templates/chat/test/server.test.ts +78 -0
- package/templates/chat/tsconfig.json +20 -0
- package/templates/nodejs/AGENTS.md +9 -0
- package/templates/nodejs/blend.yaml +26 -0
- package/templates/nodejs/gitignore +3 -0
- package/templates/nodejs/package.json +30 -0
- package/templates/nodejs/src/app.ts +92 -0
- package/templates/nodejs/src/client/counter.tsx +12 -0
- package/templates/nodejs/src/client/tsconfig.json +26 -0
- package/templates/nodejs/src/door.ts +7 -0
- package/templates/nodejs/src/main.ts +25 -0
- package/templates/nodejs/src/page.tsx +29 -0
- package/templates/nodejs/src/routes.ts +40 -0
- package/templates/nodejs/template.json +3 -0
- package/templates/nodejs/test/app.test.ts +78 -0
- package/templates/nodejs/test/server.test.ts +83 -0
- package/templates/nodejs/tsconfig.json +20 -0
- package/templates/notes/AGENTS.md +26 -0
- package/templates/notes/blend.yaml +29 -0
- package/templates/notes/gitignore +6 -0
- package/templates/notes/migrations/001-notes.sql +18 -0
- package/templates/notes/package.json +33 -0
- package/templates/notes/src/app.ts +106 -0
- package/templates/notes/src/auth.ts +57 -0
- package/templates/notes/src/client/counter.tsx +21 -0
- package/templates/notes/src/client/tsconfig.json +26 -0
- package/templates/notes/src/db.ts +50 -0
- package/templates/notes/src/door.ts +7 -0
- package/templates/notes/src/main.ts +28 -0
- package/templates/notes/src/notes-app.ts +205 -0
- package/templates/notes/src/routes.ts +14 -0
- package/templates/notes/src/runtime.ts +17 -0
- package/templates/notes/src/schema.ts +38 -0
- package/templates/notes/src/views.tsx +136 -0
- package/templates/notes/template.json +11 -0
- package/templates/notes/test/notes.test.ts +280 -0
- package/templates/notes/test/server.test.ts +78 -0
- package/templates/notes/tsconfig.json +20 -0
- package/templates/todo/README.md +1 -1
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +8 -8
- package/templates/todo/src/app.ts +32 -12
- package/templates/todo/src/main.ts +1 -1
- package/templates/todo/src/todo.ts +4 -4
- package/templates/todo/test/todo.test.ts +48 -23
- package/templates/todo/tsconfig.json +1 -1
- package/templates/todo/upstream.json +2 -2
- package/lib/database-D_UN6We8.d.ts +0 -44
- package/lib/kv-DWDm0Lja.d.ts +0 -39
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "acrylblends",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|
|
@@ -402,6 +402,10 @@
|
|
|
402
402
|
"types": "./lib/schema-validator.d.ts",
|
|
403
403
|
"default": "./lib/schema-validator.js"
|
|
404
404
|
},
|
|
405
|
+
"./read-sse": {
|
|
406
|
+
"types": "./lib/read-sse.d.ts",
|
|
407
|
+
"default": "./lib/read-sse.js"
|
|
408
|
+
},
|
|
405
409
|
"./node-server": {
|
|
406
410
|
"types": "./lib/node-server.d.ts",
|
|
407
411
|
"default": "./lib/node-server.js"
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@deepseek-ai/cordis": "4.0.4"
|
|
9
9
|
},
|
|
10
|
-
"mount": "it is the port, not a service: a plugin declares inject: ['db'] and imports its types from '
|
|
11
|
-
"omit": ["
|
|
10
|
+
"mount": "it is the port, not a service: a plugin declares inject: ['db'] and imports its types from '../plugins/acryl-db/src/index.ts' (or the ctx.db type from '../plugins/acryl-db/src/cordis.ts'); add acryl-db-sqlite to provide it",
|
|
11
|
+
"omit": ["**/*.test.*", "**/*.case.ts"],
|
|
12
|
+
"imports": {
|
|
13
|
+
"#acryl-db": "acryl-db/src/index.ts",
|
|
14
|
+
"#acryl-db/conformance": "acryl-db/src/conformance.ts"
|
|
15
|
+
},
|
|
16
|
+
"suite": "src/conformance.ts"
|
|
12
17
|
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `db` conformance suite: what every provider of the port must do, as Vitest tests. A provider passes it to be accepted
|
|
3
|
+
* (`describeDbConformance('acryl-db-sqlite', open)` in its own tests); the SQL is the portable subset, with `?` bindings, so the same
|
|
4
|
+
* suite runs unchanged on SQLite and Postgres.
|
|
5
|
+
*
|
|
6
|
+
* A `?` inside a string literal, a quoted identifier or a comment is text, not a binding. Foreign keys are enforced (`ON DELETE CASCADE` works) and `INSERT ... RETURNING` answers through `get`, on every provider.
|
|
7
|
+
*
|
|
8
|
+
* The transaction cases are the contract the SQL facade documents (and its SQLite tests check): other calls and transactions wait for
|
|
9
|
+
* an active transaction, a rejected callback rolls back and rejects with its own error, and a handle is dead once its transaction settles.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
16
|
+
import type { Db, DbExecutor } from './facade/database.ts'
|
|
17
|
+
import { applyMigrations } from './facade/migrations.ts'
|
|
18
|
+
import { readMigrationFiles } from './migration-files.ts'
|
|
19
|
+
import { MIGRATIONS_TABLE } from './index.ts'
|
|
20
|
+
|
|
21
|
+
/** A fresh, empty database from the provider under test, and how to close it. */
|
|
22
|
+
export interface DbUnderTest {
|
|
23
|
+
readonly db: Db
|
|
24
|
+
close(): Promise<void>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A promise and the function that settles it, for holding a transaction open. */
|
|
28
|
+
function gate(): { readonly promise: Promise<void>, readonly open: () => void } {
|
|
29
|
+
let open!: () => void
|
|
30
|
+
const promise = new Promise<void>(resolve => { open = resolve })
|
|
31
|
+
return { promise, open }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function describeDbConformance(provider: string, open: () => Promise<DbUnderTest>): void {
|
|
35
|
+
describe(`db conformance: ${provider}`, () => {
|
|
36
|
+
const opened: DbUnderTest[] = []
|
|
37
|
+
const fresh = async (): Promise<Db> => {
|
|
38
|
+
const subject = await open()
|
|
39
|
+
opened.push(subject)
|
|
40
|
+
return subject.db
|
|
41
|
+
}
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
await Promise.all(opened.splice(0).map(subject => subject.close()))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('round-trips null, integers, bigints in the safe range, reals, text and bytes', async () => {
|
|
47
|
+
const db = await fresh()
|
|
48
|
+
await db.exec('CREATE TABLE value_probe (n INTEGER, i INTEGER, b BIGINT, r DOUBLE PRECISION, t TEXT, x BYTEA)')
|
|
49
|
+
const bytes = new Uint8Array([0, 1, 254, 255])
|
|
50
|
+
await db.run('INSERT INTO value_probe (n, i, b, r, t, x) VALUES (?, ?, ?, ?, ?, ?)', null, 42, 9_007_199_254_740_991n, 1.5, 'café ✓', bytes)
|
|
51
|
+
const row = await db.get<{ n: unknown, i: unknown, b: unknown, r: unknown, t: unknown, x: unknown }>('SELECT n, i, b, r, t, x FROM value_probe')
|
|
52
|
+
expect(row).toEqual({ n: null, i: 42, b: 9_007_199_254_740_991, r: 1.5, t: 'café ✓', x: bytes })
|
|
53
|
+
expect(row?.x).toBeInstanceOf(Uint8Array)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('answers get with undefined and all with [] when nothing matches', async () => {
|
|
57
|
+
const db = await fresh()
|
|
58
|
+
await db.exec('CREATE TABLE empty_probe (value INTEGER)')
|
|
59
|
+
expect(await db.get('SELECT value FROM empty_probe WHERE value = ?', 1)).toBeUndefined()
|
|
60
|
+
expect(await db.all('SELECT value FROM empty_probe')).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('reports how many rows run changed', async () => {
|
|
64
|
+
const db = await fresh()
|
|
65
|
+
await db.exec('CREATE TABLE change_probe (value INTEGER)')
|
|
66
|
+
expect(await db.run('INSERT INTO change_probe (value) VALUES (?), (?), (?)', 1, 2, 3)).toEqual({ changes: 3 })
|
|
67
|
+
expect(await db.run('UPDATE change_probe SET value = value + 10 WHERE value < ?', 3)).toEqual({ changes: 2 })
|
|
68
|
+
expect(await db.run('DELETE FROM change_probe WHERE value = ?', 99)).toEqual({ changes: 0 })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('runs several statements in one exec', async () => {
|
|
72
|
+
const db = await fresh()
|
|
73
|
+
await db.exec('CREATE TABLE multi_probe (value INTEGER); INSERT INTO multi_probe (value) VALUES (1); INSERT INTO multi_probe (value) VALUES (2);')
|
|
74
|
+
expect(await db.all('SELECT value FROM multi_probe ORDER BY value')).toEqual([{ value: 1 }, { value: 2 }])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('enforces foreign keys: ON DELETE CASCADE removes the children, and an orphan insert is refused', async () => {
|
|
78
|
+
const db = await fresh()
|
|
79
|
+
await db.exec('CREATE TABLE fk_parent (id INTEGER PRIMARY KEY); CREATE TABLE fk_child (id INTEGER PRIMARY KEY, parent INTEGER NOT NULL REFERENCES fk_parent (id) ON DELETE CASCADE)')
|
|
80
|
+
await db.run('INSERT INTO fk_parent (id) VALUES (?), (?)', 1, 2)
|
|
81
|
+
await db.run('INSERT INTO fk_child (id, parent) VALUES (?, ?), (?, ?), (?, ?)', 10, 1, 11, 1, 20, 2)
|
|
82
|
+
expect(await db.run('DELETE FROM fk_parent WHERE id = ?', 1)).toEqual({ changes: 1 })
|
|
83
|
+
expect(await db.all('SELECT id FROM fk_child ORDER BY id')).toEqual([{ id: 20 }])
|
|
84
|
+
await expect(db.run('INSERT INTO fk_child (id, parent) VALUES (?, ?)', 30, 99)).rejects.toThrow()
|
|
85
|
+
expect(await db.all('SELECT id FROM fk_child ORDER BY id')).toEqual([{ id: 20 }])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('returns the inserted row through get with INSERT ... RETURNING, inside a transaction too', async () => {
|
|
89
|
+
const db = await fresh()
|
|
90
|
+
await db.exec('CREATE TABLE returning_probe (id INTEGER PRIMARY KEY, body TEXT NOT NULL)')
|
|
91
|
+
expect(await db.get('INSERT INTO returning_probe (id, body) VALUES (?, ?) RETURNING id, body', 1, 'first')).toEqual({ id: 1, body: 'first' })
|
|
92
|
+
const second = await db.transaction(tx => tx.get<{ id: number }>('INSERT INTO returning_probe (id, body) VALUES (?, ?) RETURNING id', 2, 'second'))
|
|
93
|
+
expect(second).toEqual({ id: 2 })
|
|
94
|
+
expect(await db.all('SELECT body FROM returning_probe ORDER BY id')).toEqual([{ body: 'first' }, { body: 'second' }])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('binds only the ? outside string literals, quoted identifiers and comments', async () => {
|
|
98
|
+
const db = await fresh()
|
|
99
|
+
expect(await db.get("SELECT '?' AS q, CAST(? AS INTEGER) AS v, 'it''s ?' AS r", 7)).toEqual({ q: '?', v: 7, r: "it's ?" })
|
|
100
|
+
expect(await db.get('SELECT CAST(? AS INTEGER) AS v /* not ? a binding */, CAST(? AS INTEGER) AS w -- nor ?\n', 1, 2)).toEqual({ v: 1, w: 2 })
|
|
101
|
+
await db.exec('CREATE TABLE "odd?name" ("col?" INTEGER, body TEXT)')
|
|
102
|
+
await db.run('INSERT INTO "odd?name" ("col?", body) VALUES (?, \'what?\')', 3)
|
|
103
|
+
expect(await db.all('SELECT "col?" AS c, body FROM "odd?name" WHERE "col?" = ?', 3)).toEqual([{ c: 3, body: 'what?' }])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('commits work done through the transaction handle and resolves the callback value', async () => {
|
|
107
|
+
const db = await fresh()
|
|
108
|
+
const value = await db.transaction(async tx => {
|
|
109
|
+
await tx.exec('CREATE TABLE commit_probe (value INTEGER)')
|
|
110
|
+
await tx.run('INSERT INTO commit_probe (value) VALUES (?)', 1)
|
|
111
|
+
return 'committed'
|
|
112
|
+
})
|
|
113
|
+
expect(value).toBe('committed')
|
|
114
|
+
expect(await db.all('SELECT value FROM commit_probe')).toEqual([{ value: 1 }])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('rolls back when the callback rejects, and rejects with that same error', async () => {
|
|
118
|
+
const db = await fresh()
|
|
119
|
+
await db.exec('CREATE TABLE rollback_probe (value INTEGER)')
|
|
120
|
+
const failure = new Error('roll back')
|
|
121
|
+
const outcome = db.transaction(async tx => {
|
|
122
|
+
await tx.run('INSERT INTO rollback_probe (value) VALUES (?)', 1)
|
|
123
|
+
throw failure
|
|
124
|
+
})
|
|
125
|
+
await expect(outcome).rejects.toBe(failure)
|
|
126
|
+
expect(await db.all('SELECT value FROM rollback_probe')).toEqual([])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('makes other calls and transactions wait for an active transaction, then runs them in call order', async () => {
|
|
130
|
+
const db = await fresh()
|
|
131
|
+
await db.exec('CREATE TABLE queue_probe (value INTEGER)')
|
|
132
|
+
const started = gate()
|
|
133
|
+
const release = gate()
|
|
134
|
+
const first = db.transaction(async tx => {
|
|
135
|
+
await tx.run('INSERT INTO queue_probe (value) VALUES (?)', 1)
|
|
136
|
+
started.open()
|
|
137
|
+
await release.promise
|
|
138
|
+
})
|
|
139
|
+
await started.promise
|
|
140
|
+
const settled: string[] = []
|
|
141
|
+
const write = db.run('INSERT INTO queue_probe (value) VALUES (?)', 2).then(() => { settled.push('write') })
|
|
142
|
+
const second = db.transaction(async tx => { await tx.run('INSERT INTO queue_probe (value) VALUES (?)', 3) }).then(() => { settled.push('second') })
|
|
143
|
+
const read = db.all<{ value: number }>('SELECT value FROM queue_probe ORDER BY value').then(rows => { settled.push('read'); return rows })
|
|
144
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
145
|
+
expect(settled).toEqual([])
|
|
146
|
+
release.open()
|
|
147
|
+
await Promise.all([first, write, second])
|
|
148
|
+
expect(await read).toEqual([{ value: 1 }, { value: 2 }, { value: 3 }])
|
|
149
|
+
expect(settled).toEqual(['write', 'second', 'read'])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('keeps a rolled-back transaction from leaking into calls made while it ran', async () => {
|
|
153
|
+
const db = await fresh()
|
|
154
|
+
await db.exec('CREATE TABLE isolation_probe (value INTEGER)')
|
|
155
|
+
const transaction = db.transaction(async tx => {
|
|
156
|
+
await tx.run('INSERT INTO isolation_probe (value) VALUES (?)', 1)
|
|
157
|
+
await Promise.resolve()
|
|
158
|
+
throw new Error('roll back')
|
|
159
|
+
})
|
|
160
|
+
const before = db.all('SELECT value FROM isolation_probe')
|
|
161
|
+
const write = db.run('INSERT INTO isolation_probe (value) VALUES (?)', 2)
|
|
162
|
+
const after = db.all('SELECT value FROM isolation_probe')
|
|
163
|
+
await expect(transaction).rejects.toThrow('roll back')
|
|
164
|
+
await write
|
|
165
|
+
expect(await before).toEqual([])
|
|
166
|
+
expect(await after).toEqual([{ value: 2 }])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('rejects a transaction handle used after its transaction settles', async () => {
|
|
170
|
+
const db = await fresh()
|
|
171
|
+
await db.exec('CREATE TABLE stale_probe (value INTEGER)')
|
|
172
|
+
let handle!: DbExecutor
|
|
173
|
+
await db.transaction(async tx => {
|
|
174
|
+
handle = tx
|
|
175
|
+
await tx.run('INSERT INTO stale_probe (value) VALUES (?)', 1)
|
|
176
|
+
})
|
|
177
|
+
await expect(handle.run('INSERT INTO stale_probe (value) VALUES (?)', 2)).rejects.toThrow()
|
|
178
|
+
await expect(handle.get('SELECT value FROM stale_probe')).rejects.toThrow()
|
|
179
|
+
expect(await db.all('SELECT value FROM stale_probe')).toEqual([{ value: 1 }])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('applies migrations once and in order, and refuses a schema newer than the code', async () => {
|
|
183
|
+
const db = await fresh()
|
|
184
|
+
const first = [{ version: 1, statements: ['CREATE TABLE notes (body TEXT)', "INSERT INTO notes (body) VALUES ('kept')"] }]
|
|
185
|
+
await applyMigrations(db, first, { table: MIGRATIONS_TABLE })
|
|
186
|
+
await applyMigrations(db, first, { table: MIGRATIONS_TABLE })
|
|
187
|
+
await applyMigrations(db, [...first, { version: 2, statements: ['ALTER TABLE notes ADD COLUMN title TEXT'] }], { table: MIGRATIONS_TABLE })
|
|
188
|
+
expect(await db.all('SELECT body, title FROM notes')).toEqual([{ body: 'kept', title: null }])
|
|
189
|
+
expect(await db.get(`SELECT version FROM ${MIGRATIONS_TABLE}`)).toEqual({ version: 2 })
|
|
190
|
+
await expect(applyMigrations(db, first, { table: MIGRATIONS_TABLE })).rejects.toThrow('Schema version 2 is newer than supported version 1')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('rolls every pending migration back together when one fails', async () => {
|
|
194
|
+
const db = await fresh()
|
|
195
|
+
const failing = [
|
|
196
|
+
{ version: 1, statements: ['CREATE TABLE migration_first (value TEXT)', "INSERT INTO migration_first (value) VALUES ('retained')"] },
|
|
197
|
+
{ version: 2, statements: ['CREATE TABLE migration_second (value TEXT)', 'THIS IS NOT SQL'] },
|
|
198
|
+
]
|
|
199
|
+
await expect(applyMigrations(db, failing, { table: MIGRATIONS_TABLE })).rejects.toThrow()
|
|
200
|
+
await expect(db.all('SELECT value FROM migration_first')).rejects.toThrow()
|
|
201
|
+
// A provider may create the version table when it opens; then it still says 0.
|
|
202
|
+
const version = await db.get<{ version: unknown }>(`SELECT version FROM ${MIGRATIONS_TABLE}`).catch(() => undefined)
|
|
203
|
+
expect(version === undefined || Number(version.version) === 0).toBe(true)
|
|
204
|
+
await applyMigrations(db, [failing[0]!, { version: 2, statements: ['CREATE TABLE migration_second (value TEXT)'] }], { table: MIGRATIONS_TABLE })
|
|
205
|
+
expect(await db.get(`SELECT version FROM ${MIGRATIONS_TABLE}`)).toEqual({ version: 2 })
|
|
206
|
+
expect(await db.all('SELECT value FROM migration_first')).toEqual([{ value: 'retained' }])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('applies an app\'s numbered migration files', async () => {
|
|
210
|
+
const db = await fresh()
|
|
211
|
+
const folder = await mkdtemp(join(tmpdir(), 'acryl-db-migrations-'))
|
|
212
|
+
try {
|
|
213
|
+
await writeFile(join(folder, '001-notes.sql'), 'CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT NOT NULL);\nINSERT INTO notes (id, body) VALUES (1, \'first\');\n')
|
|
214
|
+
await writeFile(join(folder, '002-tags.sql'), 'CREATE TABLE tags (note INTEGER NOT NULL, tag TEXT NOT NULL);\n')
|
|
215
|
+
await applyMigrations(db, await readMigrationFiles(folder), { table: MIGRATIONS_TABLE })
|
|
216
|
+
await db.run('INSERT INTO tags (note, tag) VALUES (?, ?)', 1, 'todo')
|
|
217
|
+
expect(await db.all('SELECT body, tag FROM notes JOIN tags ON tags.note = notes.id')).toEqual([{ body: 'first', tag: 'todo' }])
|
|
218
|
+
expect(await db.get(`SELECT version FROM ${MIGRATIONS_TABLE}`)).toEqual({ version: 2 })
|
|
219
|
+
} finally {
|
|
220
|
+
await rm(folder, { recursive: true, force: true })
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"source": {
|
|
3
3
|
"repo": "acryldev/acryl",
|
|
4
4
|
"path": "plugins/acryl-db",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
|
|
6
6
|
},
|
|
7
7
|
"vendored": [
|
|
8
8
|
"src",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"LICENSE"
|
|
11
11
|
],
|
|
12
12
|
"rewrittenSpecifiers": 0,
|
|
13
|
-
"digest": "sha256:
|
|
13
|
+
"digest": "sha256:656f5bb8468ea1d1f5cef2fc14c4c3d3c8cfa1d85a75cebbf6bc98f1b2d245a7",
|
|
14
14
|
"testsNotRun": {},
|
|
15
15
|
"notice": [
|
|
16
16
|
"LICENSE"
|
|
@@ -9,5 +9,8 @@
|
|
|
9
9
|
"@deepseek-ai/schemastery": "3.18.4"
|
|
10
10
|
},
|
|
11
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)",
|
|
12
|
-
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"]
|
|
12
|
+
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
|
|
13
|
+
"imports": {
|
|
14
|
+
"#acryl-db-sqlite": "acryl-db-sqlite/src/index.ts"
|
|
15
|
+
}
|
|
13
16
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 WebBoxes s.r.o.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
The files under src/facade are derived from the SQLite facade of chord in pi (https://github.com/earendil-works/pi, packages/chord, commit d5b3de22d0e24a3e9c38a9410d3329b099717283), used under this license:
|
|
26
|
+
|
|
27
|
+
MIT License
|
|
28
|
+
|
|
29
|
+
Copyright (c) 2025 Mario Zechner
|
|
30
|
+
|
|
31
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
32
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
33
|
+
in the Software without restriction, including without limitation the rights
|
|
34
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
35
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
36
|
+
furnished to do so, subject to the following conditions:
|
|
37
|
+
|
|
38
|
+
The above copyright notice and this permission notice shall be included in all
|
|
39
|
+
copies or substantial portions of the Software.
|
|
40
|
+
|
|
41
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
42
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
43
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
44
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
45
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
46
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
47
|
+
SOFTWARE.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "acryl-door",
|
|
3
|
+
"description": "The door of the app: an agent working in the app folder looks at the running page, acts on it (click, type, select, press, scroll, wait) and adds, removes or reloads plugins live, through .acryl/bin/acryl. Loopback only, a per-run secret in .acryl/door.json, an audit log and an in-page indicator with a stop button. Vendored from ACRYL.",
|
|
4
|
+
"requires": ["acryl-router", "acryl-node-server", "instance"],
|
|
5
|
+
"inject": ["router", "appInstance", "nodeServer"],
|
|
6
|
+
"provides": ["door"],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
9
|
+
"@deepseek-ai/schemastery": "3.18.4"
|
|
10
|
+
},
|
|
11
|
+
"mount": "mount it after acryl-node-server and extensions in development builds: root.plugin(door); add <script type=\"module\" src=\"/api/acryl-door/page.js\"></script> to the pages; the command is .acryl/bin/acryl",
|
|
12
|
+
"gitignore": [".acryl/"],
|
|
13
|
+
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The door's operator client: what `<app>/.acryl/bin/acryl` runs. It finds the app from the folder it is started in (the nearest
|
|
4
|
+
* `.acryl/door.json` upward, or `--dir`), never through PATH or a machine-wide registry, so it reaches only the app running from that
|
|
5
|
+
* folder. The secret is read from that file and sent as a `Bearer` header; it is never printed.
|
|
6
|
+
*
|
|
7
|
+
* acryl brief | look | act <op> ... | plugin list|add|remove|reload | tool list|call | verbs (--json for the raw value)
|
|
8
|
+
* acryl control <...> the same, the ACRYL Host form (`control snapshot`, `control click`, ... are look and act)
|
|
9
|
+
*
|
|
10
|
+
* Exit codes: 0 done, 1 the verb failed (its code and message are printed), 2 a usage error, 3 no running app in this folder.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, realpathSync } from 'node:fs'
|
|
14
|
+
import { dirname, join, resolve } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { readDoorFile, STATE_DIR, DOOR_FILE } from '../host/door-file.ts'
|
|
17
|
+
|
|
18
|
+
const ACT_OPS = new Set(['click', 'type', 'select', 'press', 'scroll', 'wait'])
|
|
19
|
+
const VALUED = new Set(['--dir', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--tool', '--args'])
|
|
20
|
+
const FLAGS = new Set(['--json', '--submit', '--no-clear', '--gone', '--help'])
|
|
21
|
+
|
|
22
|
+
export class UsageError extends Error {}
|
|
23
|
+
|
|
24
|
+
export interface Invocation {
|
|
25
|
+
readonly verb: string
|
|
26
|
+
readonly args: Record<string, unknown>
|
|
27
|
+
readonly json: boolean
|
|
28
|
+
readonly dir: string | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const USAGE = `usage: acryl <verb> [options] [--json] [--dir <app folder>]
|
|
32
|
+
brief
|
|
33
|
+
look [--cursor <n>] [--max-nodes <n>]
|
|
34
|
+
act click --ref <r>
|
|
35
|
+
act type --ref <r> --text <t> [--submit] [--no-clear]
|
|
36
|
+
act select --ref <r> --option <o>
|
|
37
|
+
act press --key <key> [--ref <r>]
|
|
38
|
+
act scroll --direction up|down|left|right [--ref <r>] [--amount <px>]
|
|
39
|
+
act wait (--text <t> | --role <role> [--name <n>]) [--gone] [--timeout-ms <ms>]
|
|
40
|
+
plugin list | plugin add <folder> | plugin remove <name> | plugin reload
|
|
41
|
+
tool list | tool call --tool <name> [--args '<json>']
|
|
42
|
+
verbs`
|
|
43
|
+
|
|
44
|
+
function integer(values: Map<string, string>, flag: string): number | undefined {
|
|
45
|
+
const raw = values.get(flag)
|
|
46
|
+
if (raw === undefined) return undefined
|
|
47
|
+
const value = Number(raw)
|
|
48
|
+
if (!Number.isInteger(value) || value < 0) throw new UsageError(`${flag} must be a non-negative integer`)
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @throws UsageError. */
|
|
53
|
+
export function parseInvocation(argv: readonly string[]): Invocation {
|
|
54
|
+
const words: string[] = []
|
|
55
|
+
const values = new Map<string, string>()
|
|
56
|
+
const set = new Set<string>()
|
|
57
|
+
for (let at = 0; at < argv.length; at += 1) {
|
|
58
|
+
const arg = argv[at] as string
|
|
59
|
+
if (VALUED.has(arg)) {
|
|
60
|
+
const value = argv[at + 1]
|
|
61
|
+
if (value === undefined) throw new UsageError(`${arg} needs a value`)
|
|
62
|
+
values.set(arg, value)
|
|
63
|
+
at += 1
|
|
64
|
+
} else if (FLAGS.has(arg)) set.add(arg)
|
|
65
|
+
else if (arg.startsWith('--')) throw new UsageError(`unknown option ${arg}`)
|
|
66
|
+
else words.push(arg)
|
|
67
|
+
}
|
|
68
|
+
if (set.has('--help')) throw new UsageError(USAGE)
|
|
69
|
+
if (words[0] === 'control') words.shift()
|
|
70
|
+
// The ACRYL Host form: `control snapshot` is look, `control click` is act click.
|
|
71
|
+
if (words[0] === 'snapshot') words[0] = 'look'
|
|
72
|
+
else if (words[0] !== undefined && ACT_OPS.has(words[0])) words.unshift('act')
|
|
73
|
+
const [first, second, third] = words
|
|
74
|
+
const json = set.has('--json')
|
|
75
|
+
const dir = values.get('--dir')
|
|
76
|
+
const make = (verb: string, args: Record<string, unknown> = {}): Invocation => ({ verb, args, json, dir })
|
|
77
|
+
switch (first) {
|
|
78
|
+
case 'brief': return make('brief')
|
|
79
|
+
case 'verbs': return make('verbs')
|
|
80
|
+
case 'look': {
|
|
81
|
+
const cursor = integer(values, '--cursor')
|
|
82
|
+
const maxNodes = integer(values, '--max-nodes')
|
|
83
|
+
return make('look', { ...(cursor === undefined ? {} : { cursor }), ...(maxNodes === undefined ? {} : { maxNodes }) })
|
|
84
|
+
}
|
|
85
|
+
case 'act': {
|
|
86
|
+
if (second === undefined || !ACT_OPS.has(second)) throw new UsageError(`act needs one of ${[...ACT_OPS].join(', ')}\n${USAGE}`)
|
|
87
|
+
const amount = integer(values, '--amount')
|
|
88
|
+
const timeoutMs = integer(values, '--timeout-ms')
|
|
89
|
+
const pick = (flag: string, key: string): Record<string, string> => (values.has(flag) ? { [key]: values.get(flag) as string } : {})
|
|
90
|
+
return make('act', {
|
|
91
|
+
op: second,
|
|
92
|
+
...pick('--ref', 'ref'), ...pick('--text', 'text'), ...pick('--option', 'option'), ...pick('--key', 'key'),
|
|
93
|
+
...pick('--direction', 'direction'), ...pick('--role', 'role'), ...pick('--name', 'name'),
|
|
94
|
+
...(amount === undefined ? {} : { amount }),
|
|
95
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
96
|
+
...(set.has('--submit') ? { submit: true } : {}),
|
|
97
|
+
...(set.has('--no-clear') ? { clear: false } : {}),
|
|
98
|
+
...(set.has('--gone') ? { gone: true } : {}),
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
case 'plugin': {
|
|
102
|
+
if (second === 'list' || second === 'reload') return make(`plugin-${second}`)
|
|
103
|
+
if (second === 'add') { if (third === undefined) throw new UsageError('plugin add needs a folder, e.g. plugin add plugins/pomodoro'); return make('plugin-add', { folder: third }) }
|
|
104
|
+
if (second === 'remove') { if (third === undefined) throw new UsageError('plugin remove needs a plugin name'); return make('plugin-remove', { name: third }) }
|
|
105
|
+
throw new UsageError(`plugin needs list, add, remove or reload\n${USAGE}`)
|
|
106
|
+
}
|
|
107
|
+
case 'tool': {
|
|
108
|
+
if (second === 'list') return make('tool-list')
|
|
109
|
+
if (second === 'call') {
|
|
110
|
+
const tool = values.get('--tool')
|
|
111
|
+
if (tool === undefined) throw new UsageError('tool call needs --tool <name>')
|
|
112
|
+
const raw = values.get('--args')
|
|
113
|
+
let args: unknown = {}
|
|
114
|
+
if (raw !== undefined) {
|
|
115
|
+
try { args = JSON.parse(raw) } catch { throw new UsageError('--args must be JSON') }
|
|
116
|
+
}
|
|
117
|
+
return make('tool-call', { tool, args })
|
|
118
|
+
}
|
|
119
|
+
throw new UsageError(`tool needs list or call\n${USAGE}`)
|
|
120
|
+
}
|
|
121
|
+
default: throw new UsageError(USAGE)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The app folder: `--dir`, or the nearest folder upward from `cwd` that has `.acryl/door.json` (or at least `.acryl/`). */
|
|
126
|
+
export function findApp(cwd: string, dir: string | undefined): string | undefined {
|
|
127
|
+
if (dir !== undefined) return resolve(cwd, dir)
|
|
128
|
+
let fallback: string | undefined
|
|
129
|
+
for (let at = resolve(cwd); ; at = dirname(at)) {
|
|
130
|
+
if (existsSync(join(at, STATE_DIR, DOOR_FILE))) return at
|
|
131
|
+
if (fallback === undefined && existsSync(join(at, STATE_DIR))) fallback = at
|
|
132
|
+
if (dirname(at) === at) return fallback
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface Io {
|
|
137
|
+
readonly cwd: string
|
|
138
|
+
readonly out: (text: string) => void
|
|
139
|
+
readonly err: (text: string) => void
|
|
140
|
+
readonly fetch?: typeof fetch
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Run one invocation. @returns the exit code. */
|
|
144
|
+
export async function main(argv: readonly string[], io: Io): Promise<number> {
|
|
145
|
+
let invocation: Invocation
|
|
146
|
+
try {
|
|
147
|
+
invocation = parseInvocation(argv)
|
|
148
|
+
} catch (cause) {
|
|
149
|
+
if (cause instanceof UsageError) { io.err(`${cause.message}\n`); return 2 }
|
|
150
|
+
throw cause
|
|
151
|
+
}
|
|
152
|
+
const app = findApp(io.cwd, invocation.dir)
|
|
153
|
+
const file = app === undefined ? undefined : readDoorFile(app)
|
|
154
|
+
if (app === undefined || file === undefined) {
|
|
155
|
+
io.err(`no running app${app === undefined ? ` in ${io.cwd} or above it` : ` in ${app}`}: start it (npm run dev) in its folder, then try again\n`)
|
|
156
|
+
return 3
|
|
157
|
+
}
|
|
158
|
+
const request = io.fetch ?? fetch
|
|
159
|
+
const headers = { authorization: `Bearer ${file.secret}`, 'content-type': 'application/json' }
|
|
160
|
+
let response: Response
|
|
161
|
+
try {
|
|
162
|
+
response = invocation.verb === 'verbs'
|
|
163
|
+
? await request(`${file.url}/api/acryl-door/verbs`, { headers })
|
|
164
|
+
: await request(`${file.url}/api/acryl-door/call`, { method: 'POST', headers, body: JSON.stringify({ verb: invocation.verb, args: invocation.args }) })
|
|
165
|
+
} catch {
|
|
166
|
+
io.err(`no running app in ${app}: ${file.url} does not answer (it stopped without removing .acryl/door.json?); start it again\n`)
|
|
167
|
+
return 3
|
|
168
|
+
}
|
|
169
|
+
if (response.status === 403) {
|
|
170
|
+
io.err(`refused by the app in ${app}: the secret did not match (the app restarted? run the command again)\n`)
|
|
171
|
+
return 1
|
|
172
|
+
}
|
|
173
|
+
const body = await response.json().catch(() => undefined) as { ok?: boolean; value?: unknown; text?: string; code?: string; message?: string; verbs?: unknown } | undefined
|
|
174
|
+
if (body === undefined) { io.err(`the app answered ${String(response.status)} with no readable body\n`); return 1 }
|
|
175
|
+
if (invocation.verb === 'verbs') {
|
|
176
|
+
io.out(invocation.json ? `${JSON.stringify(body.verbs)}\n` : `${(body.verbs as string[]).join('\n')}\n`)
|
|
177
|
+
return 0
|
|
178
|
+
}
|
|
179
|
+
if (body.ok !== true) {
|
|
180
|
+
io.err(invocation.json ? `${JSON.stringify(body)}\n` : `${body.code ?? 'error'}: ${body.message ?? JSON.stringify(body)}\n`)
|
|
181
|
+
return 1
|
|
182
|
+
}
|
|
183
|
+
io.out(invocation.json ? `${JSON.stringify(body.value)}\n` : `${body.text ?? ''}\n`)
|
|
184
|
+
return 0
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** True when this file is the program Node was started with (not imported by a test). */
|
|
188
|
+
function isProgram(): boolean {
|
|
189
|
+
const entry = process.argv[1]
|
|
190
|
+
if (entry === undefined) return false
|
|
191
|
+
try {
|
|
192
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url))
|
|
193
|
+
} catch {
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (isProgram()) {
|
|
199
|
+
main(process.argv.slice(2), {
|
|
200
|
+
cwd: process.cwd(),
|
|
201
|
+
out: (text) => { process.stdout.write(text) },
|
|
202
|
+
err: (text) => { process.stderr.write(text) },
|
|
203
|
+
}).then((code) => { process.exitCode = code }, (cause: unknown) => {
|
|
204
|
+
process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`)
|
|
205
|
+
process.exitCode = 1
|
|
206
|
+
})
|
|
207
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The door's host core for another Host to build its own door on (`acryl-door/host`): the page channel and its routes, and the audit log.
|
|
3
|
+
* ACRYL's DSH Host uses it in `acryl-agent-control`, with its own tools, approval and secret.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { UiChannel, type PageStream } from './channel.ts'
|
|
7
|
+
export { AuditLog, auditPath, type AuditEntry } from './audit.ts'
|
|
8
|
+
export { HEARTBEAT_MS, pageChannelRoutes, type PageRoutesOptions } from './page-routes.ts'
|
|
9
|
+
export { renderSnapshot } from '../page/snapshot-text.ts'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The audit log: one JSON line per door call, append-only, in the app's own `.acryl/audit/` (moved from `acryl-agent-control`, spec 041).
|
|
3
|
+
*
|
|
4
|
+
* It is the record of what the agent did to the app. It never holds text the agent typed or values it read,
|
|
5
|
+
* only which control was touched and how the call ended.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeSync } from 'node:fs'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import type { AuditEntry } from '../page/protocol.ts'
|
|
11
|
+
|
|
12
|
+
export type { AuditEntry } from '../page/protocol.ts'
|
|
13
|
+
|
|
14
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
/** The audit log below a folder the caller owns (the app's `.acryl/` here; ACRYL's app home for the DSH face). */
|
|
17
|
+
export function auditPath(folder: string): string {
|
|
18
|
+
return join(folder, 'audit', 'ui-control.jsonl')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class AuditLog {
|
|
22
|
+
private readonly path: string
|
|
23
|
+
|
|
24
|
+
constructor(path: string) {
|
|
25
|
+
this.path = path
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Append one line; a failing disk never breaks the tool call. */
|
|
29
|
+
record(entry: AuditEntry): void {
|
|
30
|
+
try {
|
|
31
|
+
mkdirSync(dirname(this.path), { recursive: true })
|
|
32
|
+
this.rotateIfLarge()
|
|
33
|
+
const fd = openSync(this.path, 'a', 0o600)
|
|
34
|
+
try {
|
|
35
|
+
writeSync(fd, `${JSON.stringify(entry)}\n`)
|
|
36
|
+
} finally {
|
|
37
|
+
closeSync(fd)
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Best effort: the record is important, but the app must keep working.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The most recent entries, oldest first. */
|
|
45
|
+
recent(limit = 100): AuditEntry[] {
|
|
46
|
+
try {
|
|
47
|
+
const lines = readFileSync(this.path, 'utf8').split('\n').filter(line => line !== '').slice(-limit)
|
|
48
|
+
return lines.flatMap((line) => {
|
|
49
|
+
try { return [JSON.parse(line) as AuditEntry] } catch { return [] }
|
|
50
|
+
})
|
|
51
|
+
} catch {
|
|
52
|
+
return []
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private rotateIfLarge(): void {
|
|
57
|
+
try {
|
|
58
|
+
if (statSync(this.path).size >= MAX_LOG_BYTES) renameSync(this.path, `${this.path}.1`)
|
|
59
|
+
} catch {
|
|
60
|
+
// Nothing to rotate yet.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|