acrylblends 0.7.8 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/index.d.ts +13 -5
  2. package/dist/index.js +72 -13
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +2 -2
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/getting-started/basic.md +1 -2
  7. package/docs/framework/getting-started/desktop.md +1 -1
  8. package/docs/framework/guides/bring-your-own-tool.md +216 -0
  9. package/docs/framework/guides/create-acrylblends.md +5 -4
  10. package/docs/framework/guides/data.md +2 -1
  11. package/docs/framework/upstream.json +3 -3
  12. package/lib/node-server.js +1 -0
  13. package/package.json +1 -1
  14. package/plugins/acryl-db/plugin.json +6 -1
  15. package/plugins/acryl-db/src/conformance.ts +224 -0
  16. package/plugins/acryl-db/upstream.json +1 -1
  17. package/plugins/acryl-db-sqlite/plugin.json +4 -1
  18. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  19. package/plugins/acryl-door/LICENSE +47 -0
  20. package/plugins/acryl-door/plugin.json +14 -0
  21. package/plugins/acryl-door/src/cli/acryl.ts +207 -0
  22. package/plugins/acryl-door/src/host/api.ts +9 -0
  23. package/plugins/acryl-door/src/host/audit.ts +64 -0
  24. package/plugins/acryl-door/src/host/channel.ts +142 -0
  25. package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
  26. package/plugins/acryl-door/src/host/door-file.ts +94 -0
  27. package/plugins/acryl-door/src/host/door.ts +195 -0
  28. package/plugins/acryl-door/src/host/http.ts +80 -0
  29. package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
  30. package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
  31. package/plugins/acryl-door/src/host/page-routes.ts +65 -0
  32. package/plugins/acryl-door/src/host/shim.ts +65 -0
  33. package/plugins/acryl-door/src/host/verbs.ts +109 -0
  34. package/plugins/acryl-door/src/index.ts +133 -0
  35. package/plugins/acryl-door/src/page/api.ts +9 -0
  36. package/plugins/acryl-door/src/page/channel.ts +135 -0
  37. package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
  38. package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
  39. package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
  40. package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
  41. package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
  42. package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
  43. package/plugins/acryl-door/src/page/index.ts +40 -0
  44. package/plugins/acryl-door/src/page/indicator.ts +72 -0
  45. package/plugins/acryl-door/src/page/main.ts +7 -0
  46. package/plugins/acryl-door/src/page/protocol.ts +213 -0
  47. package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
  48. package/plugins/acryl-door/src/page/user-input.ts +24 -0
  49. package/plugins/acryl-door/upstream.json +28 -0
  50. package/plugins/acryl-inspect/upstream.json +1 -1
  51. package/plugins/acryl-kv/plugin.json +6 -1
  52. package/plugins/acryl-kv/src/conformance.ts +226 -0
  53. package/plugins/acryl-kv/upstream.json +1 -1
  54. package/plugins/acryl-kv-db/plugin.json +4 -1
  55. package/plugins/acryl-kv-db/upstream.json +1 -1
  56. package/plugins/acryl-kv-json/plugin.json +4 -1
  57. package/plugins/acryl-kv-json/upstream.json +1 -1
  58. package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
  59. package/plugins/acryl-node-server/upstream.json +2 -2
  60. package/plugins/acryl-router/src/service.ts +3 -0
  61. package/plugins/acryl-router/upstream.json +2 -2
  62. package/plugins/acryl-router-http/upstream.json +1 -1
  63. package/plugins/acryl-state/upstream.json +1 -1
  64. package/plugins/agent-panel/client/main.ts +159 -0
  65. package/plugins/agent-panel/index.ts +232 -0
  66. package/plugins/agent-panel/plugin.json +21 -0
  67. package/plugins/desktop/upstream.json +1 -1
  68. package/plugins/desktop-web/upstream.json +1 -1
  69. package/plugins/extensions/resolve.ts +16 -2
  70. package/plugins/tsconfig.json +6 -0
  71. package/shared/DEFAULTS.md +11 -6
  72. package/shared/SKILL.md +1 -1
  73. package/templates/agent/blend.yaml +3 -0
  74. package/templates/agent/src/app.ts +18 -2
  75. package/templates/agent/src/routes.ts +14 -1
  76. package/templates/agent/template.json +2 -1
  77. package/templates/agent/test/agent.test.ts +10 -4
  78. package/templates/agent/test/app.test.ts +29 -8
  79. package/templates/agent/test/builder.test.ts +34 -23
  80. package/templates/agent/test/model.test.ts +24 -19
  81. package/templates/blank/src/app.ts +25 -5
  82. package/templates/blank/test/app.test.ts +28 -9
  83. package/templates/chat/AGENTS.md +16 -0
  84. package/templates/chat/blend.yaml +32 -0
  85. package/templates/chat/gitignore +6 -0
  86. package/templates/chat/migrations/001-chat.sql +12 -0
  87. package/templates/chat/package.json +33 -0
  88. package/templates/chat/src/app.ts +107 -0
  89. package/templates/chat/src/chat-app.ts +101 -0
  90. package/templates/chat/src/client/chat.tsx +115 -0
  91. package/templates/chat/src/client/tsconfig.json +26 -0
  92. package/templates/chat/src/db.ts +43 -0
  93. package/templates/chat/src/door.ts +7 -0
  94. package/templates/chat/src/llm.ts +42 -0
  95. package/templates/chat/src/main.ts +28 -0
  96. package/templates/chat/src/page.tsx +40 -0
  97. package/templates/chat/src/routes.ts +15 -0
  98. package/templates/chat/src/runtime.ts +17 -0
  99. package/templates/chat/template.json +12 -0
  100. package/templates/chat/test/chat.test.ts +198 -0
  101. package/templates/chat/test/server.test.ts +78 -0
  102. package/templates/chat/tsconfig.json +20 -0
  103. package/templates/nodejs/AGENTS.md +9 -0
  104. package/templates/nodejs/blend.yaml +29 -0
  105. package/templates/nodejs/gitignore +3 -0
  106. package/templates/nodejs/package.json +30 -0
  107. package/templates/nodejs/src/app.ts +95 -0
  108. package/templates/nodejs/src/client/counter.tsx +12 -0
  109. package/templates/nodejs/src/client/tsconfig.json +26 -0
  110. package/templates/nodejs/src/door.ts +7 -0
  111. package/templates/nodejs/src/main.ts +25 -0
  112. package/templates/nodejs/src/page.tsx +29 -0
  113. package/templates/nodejs/src/routes.ts +40 -0
  114. package/templates/nodejs/template.json +11 -0
  115. package/templates/nodejs/test/app.test.ts +78 -0
  116. package/templates/nodejs/test/server.test.ts +83 -0
  117. package/templates/nodejs/tsconfig.json +20 -0
  118. package/templates/notes/AGENTS.md +26 -0
  119. package/templates/notes/blend.yaml +32 -0
  120. package/templates/notes/gitignore +6 -0
  121. package/templates/notes/migrations/001-notes.sql +18 -0
  122. package/templates/notes/package.json +33 -0
  123. package/templates/notes/src/app.ts +107 -0
  124. package/templates/notes/src/auth.ts +57 -0
  125. package/templates/notes/src/client/counter.tsx +21 -0
  126. package/templates/notes/src/client/tsconfig.json +26 -0
  127. package/templates/notes/src/db.ts +50 -0
  128. package/templates/notes/src/door.ts +7 -0
  129. package/templates/notes/src/main.ts +28 -0
  130. package/templates/notes/src/notes-app.ts +205 -0
  131. package/templates/notes/src/routes.ts +14 -0
  132. package/templates/notes/src/runtime.ts +17 -0
  133. package/templates/notes/src/schema.ts +38 -0
  134. package/templates/notes/src/views.tsx +136 -0
  135. package/templates/notes/template.json +12 -0
  136. package/templates/notes/test/notes.test.ts +280 -0
  137. package/templates/notes/test/server.test.ts +78 -0
  138. package/templates/notes/tsconfig.json +20 -0
  139. package/templates/todo/src/app.ts +27 -7
  140. package/templates/todo/test/todo.test.ts +47 -22
  141. package/templates/todo/upstream.json +1 -1
@@ -11,6 +11,7 @@
11
11
  "extensions",
12
12
  "builder",
13
13
  "manifest",
14
- "save"
14
+ "save",
15
+ "acryl-door"
15
16
  ]
16
17
  }
@@ -1,17 +1,23 @@
1
1
  import assert from 'node:assert/strict'
2
+ import { mkdirSync } from 'node:fs'
2
3
  import { mkdtemp, rm } from 'node:fs/promises'
3
- import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { test } from 'node:test'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
8
8
  import { type App, createApp } from '../src/app.ts'
9
9
 
10
+ /** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
11
+ const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
12
+ mkdirSync(scratch, { recursive: true })
13
+
10
14
  const projectRoot = fileURLToPath(new URL('..', import.meta.url))
11
15
 
12
16
  async function withApp(run: (app: App) => Promise<void>) {
13
- const home = await mkdtemp(join(tmpdir(), 'agent-test-'))
14
- const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
17
+ const home = await mkdtemp(join(scratch, 'agent-test-'))
18
+ const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
19
+ listen: false,
20
+ })
15
21
  try {
16
22
  await run(app)
17
23
  } finally {
@@ -21,7 +27,7 @@ async function withApp(run: (app: App) => Promise<void>) {
21
27
  }
22
28
 
23
29
  const ask = (app: App, body: unknown) =>
24
- fetch(`${app.url}/ask`, {
30
+ app.root.router.request(`/ask`, {
25
31
  method: 'POST',
26
32
  headers: { 'content-type': 'application/json' },
27
33
  body: JSON.stringify(body),
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
+ import { mkdirSync } from 'node:fs'
2
3
  import { mkdtemp, rm } from 'node:fs/promises'
3
- import { tmpdir } from 'node:os'
4
+ import { createServer } from 'node:net'
4
5
  import { join } from 'node:path'
5
6
  import { test } from 'node:test'
6
7
  import { fileURLToPath } from 'node:url'
@@ -8,12 +9,18 @@ import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
8
9
  import { type App, createApp } from '../src/app.ts'
9
10
  import { APP_NAME } from '../src/routes.ts'
10
11
 
12
+ /** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
13
+ const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
14
+ mkdirSync(scratch, { recursive: true })
15
+
11
16
  const projectRoot = fileURLToPath(new URL('..', import.meta.url))
12
17
 
13
18
  /** Boot the app in a throwaway home on any free port, run the test, always stop it and remove the home. */
14
19
  async function withApp(run: (app: App) => Promise<void>) {
15
- const home = await mkdtemp(join(tmpdir(), 'app-test-'))
16
- const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
20
+ const home = await mkdtemp(join(scratch, 'app-test-'))
21
+ const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
22
+ listen: false,
23
+ })
17
24
  try {
18
25
  await run(app)
19
26
  } finally {
@@ -24,21 +31,35 @@ async function withApp(run: (app: App) => Promise<void>) {
24
31
 
25
32
  test('serves the index route', () =>
26
33
  withApp(async (app) => {
27
- const response = await fetch(`${app.url}/`)
34
+ const response = await app.root.router.request(`/`)
28
35
  assert.equal(response.status, 200)
29
36
  assert.deepEqual(await response.json(), { app: APP_NAME, ok: true })
30
37
  }))
31
38
 
32
39
  test('answers health checks and 404 for unknown paths', () =>
33
40
  withApp(async (app) => {
34
- assert.equal((await fetch(`${app.url}/health`)).status, 200)
35
- const missing = await fetch(`${app.url}/nope`)
41
+ assert.equal((await app.root.router.request(`/health`)).status, 200)
42
+ const missing = await app.root.router.request(`/nope`)
36
43
  assert.equal(missing.status, 404)
37
44
  await missing.body?.cancel()
38
45
  }))
39
46
 
40
- test('stops listening when stopped', async () => {
41
- const home = await mkdtemp(join(tmpdir(), 'app-test-'))
47
+ /** A sandbox may forbid listening on a port (Codex's workspace-write does); the one test that starts the real server is skipped there, and says so. */
48
+ const canListen = await new Promise<boolean>((done) => {
49
+ const probe = createServer()
50
+ probe.once('error', () => done(false))
51
+ probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
52
+ })
53
+ const skip = canListen ? false : 'this environment does not allow listening on a port'
54
+ if (!canListen)
55
+ console.log(
56
+ `skipped: the test that starts the real server (${skip}); every other test ran in process`,
57
+ )
58
+
59
+ test('stops listening when stopped', { skip }, async () => {
60
+ const home = await mkdtemp(join(scratch, 'app-test-'))
61
+ // The door is left out (ACRYL_DOOR=off): one process at a time serves a folder's door.
62
+ process.env.ACRYL_DOOR = 'off'
42
63
  const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
43
64
  await app.stop()
44
65
  await assert.rejects(fetch(`${app.url}/health`))
@@ -1,18 +1,23 @@
1
1
  import assert from 'node:assert/strict'
2
+ import { mkdirSync } from 'node:fs'
2
3
  import { mkdtemp, rm } from 'node:fs/promises'
3
- import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { test } from 'node:test'
6
+ import { fileURLToPath } from 'node:url'
6
7
  import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
7
8
  import { createApp } from '../src/app.ts'
8
9
 
10
+ /** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
11
+ const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
12
+ mkdirSync(scratch, { recursive: true })
13
+
9
14
  /** The agent extends the running app through POST /ask; a restart keeps what it built. */
10
15
  test('the agent builds, updates, protects and removes a plugin in the running app, and a restart keeps it', async () => {
11
- const project = await mkdtemp(join(tmpdir(), 'builder-project-'))
16
+ const project = await mkdtemp(join(scratch, 'builder-project-'))
12
17
  const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
13
- let app = await createApp(chosen)
18
+ let app = await createApp(chosen, { listen: false })
14
19
  const ask = async (question: string) => {
15
- const response = await fetch(`${app.url}/ask`, {
20
+ const response = await app.root.router.request(`/ask`, {
16
21
  method: 'POST',
17
22
  headers: { 'content-type': 'application/json' },
18
23
  body: JSON.stringify({ question }),
@@ -20,7 +25,7 @@ test('the agent builds, updates, protects and removes a plugin in the running ap
20
25
  return ((await response.json()) as { output: string }).output
21
26
  }
22
27
  const hello = async () => {
23
- const response = await fetch(`${app.url}/hello`)
28
+ const response = await app.root.router.request(`/hello`)
24
29
  return response.status === 200
25
30
  ? ((await response.json()) as { text: string }).text
26
31
  : response.status
@@ -36,14 +41,14 @@ test('the agent builds, updates, protects and removes a plugin in the running ap
36
41
  // The broken version stays on disk, so put the working one back before restarting.
37
42
  await ask('update route hello /hello Bonjour')
38
43
  await app.stop()
39
- app = await createApp(chosen)
44
+ app = await createApp(chosen, { listen: false })
40
45
  assert.equal(await hello(), 'Bonjour')
41
46
 
42
47
  assert.equal(await ask('remove plugin hello'), 'hello: done.')
43
48
  assert.equal(await hello(), 404)
44
49
 
45
50
  // Every run is a durable task that owns its tool calls, and the records survived the restart.
46
- const records = (await (await fetch(`${app.url}/tasks`)).json()) as Array<{
51
+ const records = (await (await app.root.router.request(`/tasks`)).json()) as Array<{
47
52
  kind: string
48
53
  status: string
49
54
  parent?: string
@@ -63,12 +68,12 @@ test('the agent builds, updates, protects and removes a plugin in the running ap
63
68
  })
64
69
 
65
70
  test('with approval on, an install waits for the owner; approving it makes it live', async () => {
66
- const project = await mkdtemp(join(tmpdir(), 'approval-project-'))
71
+ const project = await mkdtemp(join(scratch, 'approval-project-'))
67
72
  const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
68
- const app = await createApp(chosen, { values: { approval: true } })
73
+ const app = await createApp(chosen, { values: { approval: true }, listen: false })
69
74
  const post = async (path: string, body: unknown) =>
70
75
  (await (
71
- await fetch(`${app.url}${path}`, {
76
+ await app.root.router.request(`${path}`, {
72
77
  method: 'POST',
73
78
  headers: { 'content-type': 'application/json' },
74
79
  body: JSON.stringify(body),
@@ -79,8 +84,8 @@ test('with approval on, an install waits for the owner; approving it makes it li
79
84
  (await post('/ask', { question: 'create route hello /hello Hello' })).output,
80
85
  "hello: waiting for the owner's approval.",
81
86
  )
82
- assert.equal((await fetch(`${app.url}/hello`)).status, 404)
83
- const listed = (await (await fetch(`${app.url}/extensions`)).json()) as {
87
+ assert.equal((await app.root.router.request(`/hello`)).status, 404)
88
+ const listed = (await (await app.root.router.request(`/extensions`)).json()) as {
84
89
  waitingForApproval: Array<{ name: string }>
85
90
  }
86
91
  assert.deepEqual(
@@ -91,8 +96,10 @@ test('with approval on, an install waits for the owner; approving it makes it li
91
96
  ((await post('/extensions/approve', { name: 'hello' })) as { state?: string }).state,
92
97
  'active',
93
98
  )
94
- assert.deepEqual(await (await fetch(`${app.url}/hello`)).json(), { text: 'Hello' })
95
- const tasks = (await (await fetch(`${app.url}/tasks`)).json()) as Array<{ manifest?: string }>
99
+ assert.deepEqual(await (await app.root.router.request(`/hello`)).json(), { text: 'Hello' })
100
+ const tasks = (await (await app.root.router.request(`/tasks`)).json()) as Array<{
101
+ manifest?: string
102
+ }>
96
103
  assert.ok(
97
104
  tasks.length > 0 && tasks.every((task) => /^sha256:[0-9a-f]{64}$/.test(task.manifest ?? '')),
98
105
  )
@@ -103,13 +110,13 @@ test('with approval on, an install waits for the owner; approving it makes it li
103
110
  })
104
111
 
105
112
  test('the agent builds a sandboxed tool, calls it, and the sandbox refuses a tool that reads outside its folder', async () => {
106
- const project = await mkdtemp(join(tmpdir(), 'tool-project-'))
113
+ const project = await mkdtemp(join(scratch, 'tool-project-'))
107
114
  const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
108
- const app = await createApp(chosen)
115
+ const app = await createApp(chosen, { listen: false })
109
116
  const ask = async (question: string) =>
110
117
  (
111
118
  (await (
112
- await fetch(`${app.url}/ask`, {
119
+ await app.root.router.request(`/ask`, {
113
120
  method: 'POST',
114
121
  headers: { 'content-type': 'application/json' },
115
122
  body: JSON.stringify({ question }),
@@ -121,7 +128,7 @@ test('the agent builds a sandboxed tool, calls it, and the sandbox refuses a too
121
128
  assert.equal(await ask('run tool triple 14'), '{"n":42}')
122
129
  const refused = await ask('spy tool snoop')
123
130
  assert.match(refused, /^Refused: its check failed: denied:/)
124
- const listed = (await (await fetch(`${app.url}/extensions`)).json()) as {
131
+ const listed = (await (await app.root.router.request(`/extensions`)).json()) as {
125
132
  installed: Array<{ name: string }>
126
133
  }
127
134
  assert.deepEqual(
@@ -135,13 +142,13 @@ test('the agent builds a sandboxed tool, calls it, and the sandbox refuses a too
135
142
  })
136
143
 
137
144
  test('what happens is streamed from the state cells: the run as a task, and the install as an extension', async () => {
138
- const project = await mkdtemp(join(tmpdir(), 'events-project-'))
145
+ const project = await mkdtemp(join(scratch, 'events-project-'))
139
146
  const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
140
- const app = await createApp(chosen)
147
+ const app = await createApp(chosen, { listen: false })
141
148
  const controller = new AbortController()
142
149
  /** Follow one server-sent stream: its text so far. */
143
150
  const follow = async (path: string) => {
144
- const stream = await fetch(`${app.url}${path}`, { signal: controller.signal })
151
+ const stream = await app.root.router.request(`${path}`, { signal: controller.signal })
145
152
  assert.equal(stream.status, 200, path)
146
153
  const reader = stream.body!.getReader()
147
154
  const decoder = new TextDecoder()
@@ -153,12 +160,12 @@ test('what happens is streamed from the state cells: the run as a task, and the
153
160
  seen.text += decoder.decode(value, { stream: true })
154
161
  }
155
162
  })().catch(() => undefined)
156
- return { seen, pump }
163
+ return { seen, pump, reader }
157
164
  }
158
165
  try {
159
166
  const tasksStream = await follow('/api/state/tasks/events')
160
167
  const extensionsStream = await follow('/api/state/extensions/events')
161
- await fetch(`${app.url}/ask`, {
168
+ await app.root.router.request(`/ask`, {
162
169
  method: 'POST',
163
170
  headers: { 'content-type': 'application/json' },
164
171
  body: JSON.stringify({ question: 'create route hello /hello Hello' }),
@@ -185,7 +192,11 @@ test('what happens is streamed from the state cells: the run as a task, and the
185
192
  arrived(extensionsStream.seen, /"name":"hello"[^\n]*"state":"active"/),
186
193
  'the extension is reported',
187
194
  )
195
+ // In process there is no socket to close: the readers are cancelled, which ends the streams and lets the pumps finish.
188
196
  controller.abort()
197
+ await Promise.all([tasksStream.reader.cancel(), extensionsStream.reader.cancel()]).catch(
198
+ () => undefined,
199
+ )
189
200
  await Promise.all([tasksStream.pump, extensionsStream.pump])
190
201
  } finally {
191
202
  await app.stop()
@@ -1,16 +1,20 @@
1
1
  import assert from 'node:assert/strict'
2
+ import { mkdirSync } from 'node:fs'
2
3
  import { mkdtemp, rm } from 'node:fs/promises'
3
- import { createServer } from 'node:http'
4
- import type { AddressInfo } from 'node:net'
5
- import { tmpdir } from 'node:os'
6
4
  import { join } from 'node:path'
7
5
  import { test } from 'node:test'
6
+ import { fileURLToPath } from 'node:url'
8
7
  import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
9
8
  import { createApp } from '../src/app.ts'
10
9
 
10
+ /** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
11
+ const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
12
+ mkdirSync(scratch, { recursive: true })
13
+
11
14
  /** A plugin no scripted command knows: the model writes it from scratch. */
12
15
  const COUNTER = `import type { Context } from '@deepseek-ai/cordis'
13
16
  import { Acryl } from 'acrylblends'
17
+
14
18
  export const name = 'counter'
15
19
  export const inject = ['router']
16
20
  export function apply(ctx: Context) {
@@ -45,42 +49,43 @@ async function modelApi() {
45
49
  },
46
50
  { content: 'I built a counter: GET /count.' },
47
51
  ]
48
- const server = createServer(async (request, response) => {
49
- for await (const _ of request) {
50
- // the body is not needed: the replies are in order
51
- }
52
- response.writeHead(200, { 'content-type': 'application/json' })
53
- response.end(
54
- JSON.stringify({ choices: [{ message: replies.shift() ?? { content: 'no more' } }] }),
55
- )
56
- })
57
- await new Promise<void>((done) => server.listen(0, '127.0.0.1', done))
52
+ // The model API is answered in process: the global fetch is wrapped for the model's address, so no port is needed.
53
+ const realFetch = globalThis.fetch
54
+ const url = 'http://model.test/v1'
55
+ globalThis.fetch = async (input, init) => {
56
+ const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
57
+ if (!target.startsWith(url)) return realFetch(input, init)
58
+ return Response.json({ choices: [{ message: replies.shift() ?? { content: 'no more' } }] })
59
+ }
58
60
  return {
59
- url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`,
60
- close: () => new Promise<void>((done) => server.close(() => done())),
61
+ url,
62
+ close: async () => {
63
+ globalThis.fetch = realFetch
64
+ },
61
65
  }
62
66
  }
63
67
 
64
68
  test('with a real model, the agent writes a new Cordis plugin and the running app serves it', async () => {
65
69
  const api = await modelApi()
66
- const project = await mkdtemp(join(tmpdir(), 'model-project-'))
70
+ const project = await mkdtemp(join(scratch, 'model-project-'))
67
71
  const app = await createApp(
68
72
  withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0),
69
73
  {
70
74
  model: { baseUrl: api.url, model: 'test' },
75
+ listen: false,
71
76
  },
72
77
  )
73
78
  try {
74
79
  const answer = await (
75
- await fetch(`${app.url}/ask`, {
80
+ await app.root.router.request(`/ask`, {
76
81
  method: 'POST',
77
82
  headers: { 'content-type': 'application/json' },
78
83
  body: JSON.stringify({ question: 'add a counter route' }),
79
84
  })
80
85
  ).json()
81
86
  assert.equal((answer as { output: string }).output, 'I built a counter: GET /count.')
82
- assert.deepEqual(await (await fetch(`${app.url}/count`)).json(), { count: 1 })
83
- assert.deepEqual(await (await fetch(`${app.url}/count`)).json(), { count: 2 })
87
+ assert.deepEqual(await (await app.root.router.request(`/count`)).json(), { count: 1 })
88
+ assert.deepEqual(await (await app.root.router.request(`/count`)).json(), { count: 2 })
84
89
  } finally {
85
90
  await app.stop()
86
91
  await api.close()
@@ -30,8 +30,19 @@ export function readManifest(values?: Readonly<Record<string, ParameterValue>>)
30
30
  })
31
31
  }
32
32
 
33
+ /** The manifest without its server row: the app is then reached in process, through `root.router.request`. */
34
+ function withoutServer<M extends { plugins: readonly { use: string }[] }>(manifest: M): M {
35
+ return {
36
+ ...manifest,
37
+ plugins: manifest.plugins.map((row) =>
38
+ row.use === 'acryl-node-server' ? { ...row, disabled: true } : row,
39
+ ),
40
+ }
41
+ }
42
+
33
43
  export interface App {
34
44
  readonly root: Context
45
+ /** The address the server listens on; empty when the app was made with `listen: false`. */
35
46
  readonly url: string
36
47
  stop(): Promise<void>
37
48
  }
@@ -39,7 +50,11 @@ export interface App {
39
50
  /** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
40
51
  export async function createApp(
41
52
  chosen: AppInstance,
42
- options: { values?: Readonly<Record<string, ParameterValue>> } = {},
53
+ options: {
54
+ values?: Readonly<Record<string, ParameterValue>>
55
+ /** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
56
+ listen?: boolean
57
+ } = {},
43
58
  ): Promise<App> {
44
59
  const manifest = readManifest(options.values)
45
60
  const root = new Context()
@@ -47,13 +62,18 @@ export async function createApp(
47
62
  await first.await()
48
63
  const fibers: Fiber[] = [
49
64
  first,
50
- ...(await compose(root, manifest, registry, {
51
- server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
52
- })),
65
+ ...(await compose(
66
+ root,
67
+ options.listen === false ? withoutServer(manifest) : manifest,
68
+ registry,
69
+ {
70
+ server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
71
+ },
72
+ )),
53
73
  ]
54
74
  return {
55
75
  root,
56
- url: root.nodeServer.url,
76
+ url: options.listen === false ? '' : root.nodeServer.url,
57
77
  async stop() {
58
78
  for (const fiber of [...fibers].reverse()) await fiber.dispose()
59
79
  },
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
+ import { mkdirSync } from 'node:fs'
2
3
  import { mkdtemp, rm } from 'node:fs/promises'
3
- import { tmpdir } from 'node:os'
4
+ import { createServer } from 'node:net'
4
5
  import { join } from 'node:path'
5
6
  import { test } from 'node:test'
6
7
  import { fileURLToPath } from 'node:url'
@@ -8,12 +9,18 @@ import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
8
9
  import { type App, createApp } from '../src/app.ts'
9
10
  import { APP_NAME } from '../src/routes.ts'
10
11
 
12
+ /** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
13
+ const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
14
+ mkdirSync(scratch, { recursive: true })
15
+
11
16
  const projectRoot = fileURLToPath(new URL('..', import.meta.url))
12
17
 
13
18
  /** Boot the app in a throwaway home on any free port, run the test, always stop it and remove the home. */
14
19
  async function withApp(run: (app: App) => Promise<void>) {
15
- const home = await mkdtemp(join(tmpdir(), 'app-test-'))
16
- const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
20
+ const home = await mkdtemp(join(scratch, 'app-test-'))
21
+ const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
22
+ listen: false,
23
+ })
17
24
  try {
18
25
  await run(app)
19
26
  } finally {
@@ -24,14 +31,14 @@ async function withApp(run: (app: App) => Promise<void>) {
24
31
 
25
32
  test('serves the index route', () =>
26
33
  withApp(async (app) => {
27
- const response = await fetch(`${app.url}/`)
34
+ const response = await app.root.router.request(`/`)
28
35
  assert.equal(response.status, 200)
29
36
  assert.deepEqual(await response.json(), { app: APP_NAME, ok: true })
30
37
  }))
31
38
 
32
39
  test('renders a .tsx page, and an error in it names the .tsx line', () =>
33
40
  withApp(async (app) => {
34
- const response = await fetch(`${app.url}/about`)
41
+ const response = await app.root.router.request(`/about`)
35
42
  assert.equal(response.status, 200)
36
43
  assert.match(await response.text(), /<main><h1>About<\/h1>/)
37
44
  const { failingPage } = await import('../src/about.tsx')
@@ -46,14 +53,26 @@ test('renders a .tsx page, and an error in it names the .tsx line', () =>
46
53
 
47
54
  test('answers health checks and 404 for unknown paths', () =>
48
55
  withApp(async (app) => {
49
- assert.equal((await fetch(`${app.url}/health`)).status, 200)
50
- const missing = await fetch(`${app.url}/nope`)
56
+ assert.equal((await app.root.router.request(`/health`)).status, 200)
57
+ const missing = await app.root.router.request(`/nope`)
51
58
  assert.equal(missing.status, 404)
52
59
  await missing.body?.cancel()
53
60
  }))
54
61
 
55
- test('stops listening when stopped', async () => {
56
- const home = await mkdtemp(join(tmpdir(), 'app-test-'))
62
+ /** A sandbox may forbid listening on a port (Codex's workspace-write does); the one test that starts the real server is skipped there, and says so. */
63
+ const canListen = await new Promise<boolean>((done) => {
64
+ const probe = createServer()
65
+ probe.once('error', () => done(false))
66
+ probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
67
+ })
68
+ const skip = canListen ? false : 'this environment does not allow listening on a port'
69
+ if (!canListen)
70
+ console.log(
71
+ `skipped: the test that starts the real server (${skip}); every other test ran in process`,
72
+ )
73
+
74
+ test('stops listening when stopped', { skip }, async () => {
75
+ const home = await mkdtemp(join(scratch, 'app-test-'))
57
76
  const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
58
77
  await app.stop()
59
78
  await assert.rejects(fetch(`${app.url}/health`))
@@ -0,0 +1,16 @@
1
+
2
+ ## This app (chat): a streamed answer, history in a database
3
+
4
+ A Cordis app: `blend.yaml` lists the plugins that are mounted (`acryl-router`, `acryl-db-sqlite`, `routes`, `acryl-node-server`), `src/app.ts` is the one file that names the SQLite provider, and `src/routes.ts` is the app's own plugin: it builds the app on the `db` port and mounts it.
5
+
6
+ - **The dependency rule:** only `src/app.ts` (the `opener` in `package.json`) imports the provider. Every other file takes a `Db` (`import type { Db } from '../plugins/acryl-db/src/index.ts'`) as an argument and never imports `node:sqlite`, `pg`, another driver or `acryl-db-sqlite`. `npm test` and `npm run check` run `acrylblends check-deps`, which names the file and line of a violation; do not move the `opener` entry to silence it.
7
+ - `src/llm.ts`: `Llm`, what the app needs from a model (messages in, pieces out, an aborted signal stops the work), and `openAiCompatible`, which calls an OpenAI-compatible chat completions API with `stream: true`. The base URL is `DEEPSEEK_BASE_URL` (default `https://api.deepseek.com`), the model `DEEPSEEK_MODEL` (default `deepseek-chat`), the key **only** the environment variable `DEEPSEEK_API_KEY`, read at the moment of the call: never write it to a file, a response or a log.
8
+ - `src/db.ts`: `chatStore(db)`. The routes only call `chatStore`; keep SQL out of them. Every `db` call returns a promise: `await` it. The database is `data/chat.sqlite` (or the file `$CHAT_DB` names), opened by the `db` row with `migrations/` applied on start. The shape of the database is `migrations/NNN-name.sql`: to change it add the next numbered file and never edit one that has run. See `docs/framework/guides/data.md`.
9
+ - `src/chat-app.ts`: `createApp(db, llm)`. `POST /api/chat` answers Server-Sent Events: `meta` (the conversation id), a `token` per piece, then `done` or `error`. The request's signal (`c.req.raw.signal`) goes to the model call, so a closed tab or the Stop button stops the request to the model; what had arrived is kept.
10
+ - `src/client/chat.tsx`: the client component (own `tsconfig.json`, browser types): sends the message with `fetch`, reads the events as they arrive, shows the answer growing, and aborts on Stop. `clientModules` compiles it when the browser asks; `npm run build` compiles it once into `dist/client`. See `docs/framework/guides/client-modules.md`.
11
+ - `test/chat.test.ts` tests every route with a mock `Llm` (never the network); keep it that way. It boots the whole app on a database file in a throwaway home and takes `app.root.db`. `test/server.test.ts` starts the real server and stops it with a signal.
12
+ - To try it with a real model: `DEEPSEEK_API_KEY=... npm run dev`.
13
+
14
+ ## Before you ship
15
+
16
+ Read `docs/framework/getting-started/deploy.md`. In short: `npm ci --omit=dev`, `npm run build`, `NODE_ENV=production npm start`; the app listens on `127.0.0.1` (`HOST=0.0.0.0` only in a container); the SQLite file goes outside the code folder (`CHAT_DB=/var/lib/chat/chat.sqlite`) and is backed up; the model key is an environment variable of the service (a root-only environment file for systemd), never a file in the repository. Anyone who can reach the app can spend your key: put it behind a login or on a private network before it is public.
@@ -0,0 +1,32 @@
1
+ # The app as data, in ACRYL's Blend format (blends.acryl.dev/v1alpha1): which plugins it mounts, in what order, with what config.
2
+ # Edit this, not app.ts, to switch a plugin off (disabled: true) or change its config. `npm run lock` records what the app is built from.
3
+ apiVersion: blends.acryl.dev/v1alpha1
4
+ kind: Blueprint
5
+ metadata:
6
+ id: acrylblends.__APP_NAME__
7
+ name: __APP_NAME__
8
+ version: 0.1.0
9
+ visibility: private # `npm run save` never pushes a private app to a public remote
10
+ spec:
11
+ runtime: cordis
12
+ parameters:
13
+ # The address the server listens on.
14
+ hostname: { type: string, default: 127.0.0.1 }
15
+ rows:
16
+ - id: router
17
+ name: acryl-router
18
+ # The db port, on one SQLite file. The file and the migrations folder are set in src/app.ts (data/chat.sqlite, or the file the CHAT_DB environment variable names).
19
+ - id: db
20
+ name: acryl-db-sqlite
21
+ - id: routes
22
+ name: routes
23
+ # Plugins installed while the app runs (the door's `plugin add`): development only, src/app.ts leaves it out when NODE_ENV=production.
24
+ - id: extensions
25
+ name: extensions
26
+ - id: server
27
+ name: acryl-node-server
28
+ config:
29
+ hostname: "{{parameters.hostname}}"
30
+ # The door: an agent working in this folder looks at the page and acts on it through .acryl/bin/acryl. Development only: src/app.ts leaves it out when NODE_ENV=production.
31
+ - id: acryl-door
32
+ name: acryl-door
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ .acrylblends/
3
+ .acryl/
4
+ data/
5
+ *.sqlite
6
+ *.sqlite-*
@@ -0,0 +1,12 @@
1
+ CREATE TABLE conversations (
2
+ id INTEGER PRIMARY KEY,
3
+ title TEXT NOT NULL,
4
+ created_at TEXT NOT NULL
5
+ );
6
+
7
+ CREATE TABLE messages (
8
+ id INTEGER PRIMARY KEY,
9
+ conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
10
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
11
+ content TEXT NOT NULL
12
+ );
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "imports": {
7
+ "#acryl-jsx/jsx-runtime": "./plugins/acryl-router/src/core/jsx/jsx-runtime.ts",
8
+ "#acryl-jsx/jsx-dev-runtime": "./plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts"
9
+ },
10
+ "engines": {
11
+ "node": ">=22.19.0"
12
+ },
13
+ "scripts": {
14
+ "start": "node --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
15
+ "dev": "node --watch --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
16
+ "test": "acrylblends check-deps && node --import ./plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
17
+ "check": "tsc --noEmit && tsc --noEmit -p src/client && acrylblends check-deps",
18
+ "build": "acrylblends build-client",
19
+ "lock": "node plugins/manifest/lock.ts",
20
+ "save": "node plugins/save/cli.ts",
21
+ "upgrade": "node plugins/manifest/upgrade-cli.ts"
22
+ },
23
+ "dependencies": {
24
+ "acrylblends": "__ACRYLBLENDS_RANGE__",
25
+ "typescript": "6.0.3"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^24.0.0"
29
+ },
30
+ "acryl": {
31
+ "opener": "src/app.ts"
32
+ }
33
+ }