acrylblends 0.0.0-stage → 0.2.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.
Files changed (74) hide show
  1. package/bin/acrylblends.js +4 -0
  2. package/dist/index.d.ts +117 -0
  3. package/dist/index.js +434 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +50 -4
  6. package/plugins/agent/agent.ts +152 -0
  7. package/plugins/agent/index.ts +20 -0
  8. package/plugins/agent/memory.ts +24 -0
  9. package/plugins/agent/model.ts +79 -0
  10. package/plugins/agent/plugin.json +10 -0
  11. package/plugins/agent/tools.ts +50 -0
  12. package/plugins/builder/index.ts +193 -0
  13. package/plugins/builder/plugin.json +11 -0
  14. package/plugins/builder/script.ts +162 -0
  15. package/plugins/events/index.ts +42 -0
  16. package/plugins/events/plugin.json +9 -0
  17. package/plugins/extensions/index.ts +738 -0
  18. package/plugins/extensions/ledger.ts +39 -0
  19. package/plugins/extensions/plugin.json +12 -0
  20. package/plugins/extensions/resolve.ts +30 -0
  21. package/plugins/extensions/stage.ts +44 -0
  22. package/plugins/http/index.ts +64 -0
  23. package/plugins/http/plugin.json +10 -0
  24. package/plugins/instance/index.ts +167 -0
  25. package/plugins/instance/plugin.json +10 -0
  26. package/plugins/manifest/index.ts +635 -0
  27. package/plugins/manifest/lock.ts +94 -0
  28. package/plugins/manifest/plugin.json +13 -0
  29. package/plugins/manifest/upgrade-cli.ts +70 -0
  30. package/plugins/manifest/upgrade.ts +97 -0
  31. package/plugins/openai/index.ts +159 -0
  32. package/plugins/openai/plugin.json +11 -0
  33. package/plugins/openai/prompt.ts +31 -0
  34. package/plugins/sandbox/index.ts +150 -0
  35. package/plugins/sandbox/plugin.json +8 -0
  36. package/plugins/sandbox/runner.mjs +18 -0
  37. package/plugins/save/cli.ts +19 -0
  38. package/plugins/save/index.ts +55 -0
  39. package/plugins/save/plugin.json +12 -0
  40. package/plugins/server/index.ts +138 -0
  41. package/plugins/server/plugin.json +10 -0
  42. package/plugins/settings/index.ts +270 -0
  43. package/plugins/settings/merge.ts +79 -0
  44. package/plugins/settings/plugin.json +13 -0
  45. package/plugins/tasks/index.ts +232 -0
  46. package/plugins/tasks/plugin.json +11 -0
  47. package/plugins/tasks/store.ts +86 -0
  48. package/plugins/tsconfig.json +19 -0
  49. package/templates/agent/Dockerfile +15 -0
  50. package/templates/agent/README.md +52 -0
  51. package/templates/agent/blend.yaml +58 -0
  52. package/templates/agent/dockerignore +3 -0
  53. package/templates/agent/gitignore +2 -0
  54. package/templates/agent/package.json +23 -0
  55. package/templates/agent/src/app.ts +98 -0
  56. package/templates/agent/src/main.ts +25 -0
  57. package/templates/agent/src/routes.ts +62 -0
  58. package/templates/agent/template.json +16 -0
  59. package/templates/agent/test/agent.test.ts +45 -0
  60. package/templates/agent/test/app.test.ts +46 -0
  61. package/templates/agent/test/builder.test.ts +186 -0
  62. package/templates/agent/test/model.test.ts +89 -0
  63. package/templates/agent/tsconfig.json +19 -0
  64. package/templates/app/README.md +26 -0
  65. package/templates/app/blend.yaml +23 -0
  66. package/templates/app/gitignore +2 -0
  67. package/templates/app/package.json +23 -0
  68. package/templates/app/src/app.ts +61 -0
  69. package/templates/app/src/main.ts +20 -0
  70. package/templates/app/src/routes.ts +14 -0
  71. package/templates/app/template.json +1 -0
  72. package/templates/app/test/app.test.ts +56 -0
  73. package/templates/app/tsconfig.json +19 -0
  74. package/README.md +0 -3
@@ -0,0 +1,62 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type {} from './plugins/agent/index.ts'
3
+ import type {} from './plugins/extensions/index.ts'
4
+ import type {} from './plugins/http/index.ts'
5
+ import type {} from './plugins/tasks/index.ts'
6
+
7
+ export const APP_NAME = '__APP_NAME__'
8
+
9
+ /**
10
+ * The app's own routes: `POST /ask` for the agent; `GET /extensions` and `GET /tasks` to observe what it did (every run is a durable
11
+ * task owning one task per tool call); `POST /tasks/cancel`. Each route lives in a `ctx.effect`.
12
+ */
13
+ export const routes = {
14
+ name: 'routes',
15
+ inject: ['http', 'agent', 'extensions', 'tasks'],
16
+ apply(ctx: Context) {
17
+ ctx.effect(() => ctx.http.route('GET', '/', () => ({ body: { app: APP_NAME, ok: true } })))
18
+ ctx.effect(() => ctx.http.route('GET', '/health', () => ({ body: { ok: true } })))
19
+ ctx.effect(() =>
20
+ ctx.http.route('GET', '/extensions', () => ({
21
+ body: {
22
+ installed: ctx.extensions.list(),
23
+ notInstalled: ctx.extensions.pending(),
24
+ waitingForApproval: ctx.extensions.approvals(),
25
+ },
26
+ })),
27
+ )
28
+ // The owner's decision on an install that is waiting (the `approval` parameter in blend.yaml).
29
+ for (const [path, decide] of [
30
+ ['/extensions/approve', (name: string) => ctx.extensions.approve(name)],
31
+ ['/extensions/deny', (name: string) => ctx.extensions.deny(name)],
32
+ ] as const) {
33
+ ctx.effect(() =>
34
+ ctx.http.route('POST', path, async ({ body }) => {
35
+ const name = (body as { name?: unknown } | undefined)?.name
36
+ if (typeof name !== 'string')
37
+ return { status: 400, body: { error: 'name must be a string' } }
38
+ return { body: await decide(name) }
39
+ }),
40
+ )
41
+ }
42
+ ctx.effect(() =>
43
+ ctx.http.route('GET', '/tasks', () => ({ body: ctx.tasks.list().slice(0, 100) })),
44
+ )
45
+ ctx.effect(() =>
46
+ ctx.http.route('POST', '/tasks/cancel', ({ body }) => {
47
+ const id = (body as { id?: unknown } | undefined)?.id
48
+ if (typeof id !== 'string' || !ctx.tasks.get(id))
49
+ return { status: 404, body: { error: 'no such task' } }
50
+ return { body: { cancelled: ctx.tasks.cancel(id) } }
51
+ }),
52
+ )
53
+ ctx.effect(() =>
54
+ ctx.http.route('POST', '/ask', async ({ body }) => {
55
+ const question = (body as { question?: unknown } | undefined)?.question
56
+ if (typeof question !== 'string')
57
+ return { status: 400, body: { error: 'question must be a string' } }
58
+ return { body: await ctx.agent.run(question) }
59
+ }),
60
+ )
61
+ },
62
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "plugins": [
3
+ "instance",
4
+ "http",
5
+ "events",
6
+ "server",
7
+ "tasks",
8
+ "agent",
9
+ "openai",
10
+ "sandbox",
11
+ "extensions",
12
+ "builder",
13
+ "manifest",
14
+ "save"
15
+ ]
16
+ }
@@ -0,0 +1,45 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { test } from 'node:test'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { type App, createApp } from '../src/app.ts'
8
+ import { pinnedInstance, withPort } from '../src/plugins/instance/index.ts'
9
+
10
+ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
11
+
12
+ 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))
15
+ try {
16
+ await run(app)
17
+ } finally {
18
+ await app.stop()
19
+ await rm(home, { recursive: true, force: true })
20
+ }
21
+ }
22
+
23
+ const ask = (app: App, body: unknown) =>
24
+ fetch(`${app.url}/ask`, {
25
+ method: 'POST',
26
+ headers: { 'content-type': 'application/json' },
27
+ body: JSON.stringify(body),
28
+ })
29
+
30
+ test('the agent uses a tool and answers over HTTP', () =>
31
+ withApp(async (app) => {
32
+ const answer = (await (await ask(app, { question: 'add 2 3' })).json()) as {
33
+ output: string
34
+ trace: unknown[]
35
+ }
36
+ assert.equal(answer.output, 'The answer is 5.')
37
+ assert.deepEqual(answer.trace, [{ tool: 'add', input: [2, 3], result: 5 }])
38
+ }))
39
+
40
+ test('a body without a question is refused', () =>
41
+ withApp(async (app) => {
42
+ const response = await ask(app, {})
43
+ assert.equal(response.status, 400)
44
+ await response.body?.cancel()
45
+ }))
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { test } from 'node:test'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { type App, createApp } from '../src/app.ts'
8
+ import { pinnedInstance, withPort } from '../src/plugins/instance/index.ts'
9
+ import { APP_NAME } from '../src/routes.ts'
10
+
11
+ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
12
+
13
+ /** Boot the app in a throwaway home on any free port, run the test, always stop it and remove the home. */
14
+ 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))
17
+ try {
18
+ await run(app)
19
+ } finally {
20
+ await app.stop()
21
+ await rm(home, { recursive: true, force: true })
22
+ }
23
+ }
24
+
25
+ test('serves the index route', () =>
26
+ withApp(async (app) => {
27
+ const response = await fetch(`${app.url}/`)
28
+ assert.equal(response.status, 200)
29
+ assert.deepEqual(await response.json(), { app: APP_NAME, ok: true })
30
+ }))
31
+
32
+ test('answers health checks and 404 for unknown paths', () =>
33
+ withApp(async (app) => {
34
+ assert.equal((await fetch(`${app.url}/health`)).status, 200)
35
+ const missing = await fetch(`${app.url}/nope`)
36
+ assert.equal(missing.status, 404)
37
+ await missing.body?.cancel()
38
+ }))
39
+
40
+ test('stops listening when stopped', async () => {
41
+ const home = await mkdtemp(join(tmpdir(), 'app-test-'))
42
+ const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
43
+ await app.stop()
44
+ await assert.rejects(fetch(`${app.url}/health`))
45
+ await rm(home, { recursive: true, force: true })
46
+ })
@@ -0,0 +1,186 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { test } from 'node:test'
6
+ import { createApp } from '../src/app.ts'
7
+ import { pinnedInstance, withPort } from '../src/plugins/instance/index.ts'
8
+
9
+ /** The agent extends the running app through POST /ask; a restart keeps what it built. */
10
+ 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-'))
12
+ const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
13
+ let app = await createApp(chosen)
14
+ const ask = async (question: string) => {
15
+ const response = await fetch(`${app.url}/ask`, {
16
+ method: 'POST',
17
+ headers: { 'content-type': 'application/json' },
18
+ body: JSON.stringify({ question }),
19
+ })
20
+ return ((await response.json()) as { output: string }).output
21
+ }
22
+ const hello = async () => {
23
+ const response = await fetch(`${app.url}/hello`)
24
+ return response.status === 200
25
+ ? ((await response.json()) as { text: string }).text
26
+ : response.status
27
+ }
28
+ try {
29
+ assert.equal(await ask('create route hello /hello Hello'), 'hello: installed, active.')
30
+ assert.equal(await hello(), 'Hello')
31
+ assert.equal(await ask('update route hello /hello Bonjour'), 'hello: updated, active.')
32
+ assert.equal(await hello(), 'Bonjour')
33
+ assert.match(await ask('break plugin hello'), /Previous version restored: true/)
34
+ assert.equal(await hello(), 'Bonjour')
35
+
36
+ // The broken version stays on disk, so put the working one back before restarting.
37
+ await ask('update route hello /hello Bonjour')
38
+ await app.stop()
39
+ app = await createApp(chosen)
40
+ assert.equal(await hello(), 'Bonjour')
41
+
42
+ assert.equal(await ask('remove plugin hello'), 'hello: done.')
43
+ assert.equal(await hello(), 404)
44
+
45
+ // 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<{
47
+ kind: string
48
+ status: string
49
+ parent?: string
50
+ }>
51
+ const runs = records.filter((record) => record.kind === 'agent-run')
52
+ assert.equal(runs.length, 5)
53
+ assert.ok(runs.every((run) => run.status === 'completed'))
54
+ assert.ok(
55
+ records.some(
56
+ (record) => record.kind === 'tool:install_plugin' && record.parent !== undefined,
57
+ ),
58
+ )
59
+ } finally {
60
+ await app.stop()
61
+ await rm(project, { recursive: true, force: true })
62
+ }
63
+ })
64
+
65
+ 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-'))
67
+ const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
68
+ const app = await createApp(chosen, { values: { approval: true } })
69
+ const post = async (path: string, body: unknown) =>
70
+ (await (
71
+ await fetch(`${app.url}${path}`, {
72
+ method: 'POST',
73
+ headers: { 'content-type': 'application/json' },
74
+ body: JSON.stringify(body),
75
+ })
76
+ ).json()) as Record<string, unknown>
77
+ try {
78
+ assert.equal(
79
+ (await post('/ask', { question: 'create route hello /hello Hello' })).output,
80
+ "hello: waiting for the owner's approval.",
81
+ )
82
+ assert.equal((await fetch(`${app.url}/hello`)).status, 404)
83
+ const listed = (await (await fetch(`${app.url}/extensions`)).json()) as {
84
+ waitingForApproval: Array<{ name: string }>
85
+ }
86
+ assert.deepEqual(
87
+ listed.waitingForApproval.map((request) => request.name),
88
+ ['hello'],
89
+ )
90
+ assert.equal(
91
+ ((await post('/extensions/approve', { name: 'hello' })) as { state?: string }).state,
92
+ 'active',
93
+ )
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 }>
96
+ assert.ok(
97
+ tasks.length > 0 && tasks.every((task) => /^sha256:[0-9a-f]{64}$/.test(task.manifest ?? '')),
98
+ )
99
+ } finally {
100
+ await app.stop()
101
+ await rm(project, { recursive: true, force: true })
102
+ }
103
+ })
104
+
105
+ 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-'))
107
+ const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
108
+ const app = await createApp(chosen)
109
+ const ask = async (question: string) =>
110
+ (
111
+ (await (
112
+ await fetch(`${app.url}/ask`, {
113
+ method: 'POST',
114
+ headers: { 'content-type': 'application/json' },
115
+ body: JSON.stringify({ question }),
116
+ })
117
+ ).json()) as { output: string }
118
+ ).output
119
+ try {
120
+ assert.equal(await ask('create tool triple 3'), 'triple: installed, active.')
121
+ assert.equal(await ask('run tool triple 14'), '{"n":42}')
122
+ const refused = await ask('spy tool snoop')
123
+ assert.match(refused, /^Refused: its check failed: denied:/)
124
+ const listed = (await (await fetch(`${app.url}/extensions`)).json()) as {
125
+ installed: Array<{ name: string }>
126
+ }
127
+ assert.deepEqual(
128
+ listed.installed.map((extension) => extension.name),
129
+ ['triple'],
130
+ )
131
+ } finally {
132
+ await app.stop()
133
+ await rm(project, { recursive: true, force: true })
134
+ }
135
+ })
136
+
137
+ test('GET /events streams what happens: the run as a task, and the install as an extension change', async () => {
138
+ const project = await mkdtemp(join(tmpdir(), 'events-project-'))
139
+ const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
140
+ const app = await createApp(chosen)
141
+ const controller = new AbortController()
142
+ try {
143
+ const stream = await fetch(`${app.url}/events`, { signal: controller.signal })
144
+ const reader = stream.body!.getReader()
145
+ const decoder = new TextDecoder()
146
+ let text = ''
147
+ const seen = (what: RegExp) => what.test(text)
148
+ const pump = (async () => {
149
+ for (;;) {
150
+ const { value, done } = await reader.read()
151
+ if (done) return
152
+ text += decoder.decode(value, { stream: true })
153
+ }
154
+ })().catch(() => undefined)
155
+ await fetch(`${app.url}/ask`, {
156
+ method: 'POST',
157
+ headers: { 'content-type': 'application/json' },
158
+ body: JSON.stringify({ question: 'create route hello /hello Hello' }),
159
+ })
160
+ for (
161
+ let waited = 0;
162
+ waited < 3000 && !seen(/event: extensions\/changed\ndata: [^\n]*"installed"/);
163
+ waited += 25
164
+ ) {
165
+ await new Promise((done) => setTimeout(done, 25))
166
+ }
167
+ assert.ok(seen(/event: connected/), 'connected')
168
+ assert.ok(
169
+ seen(/event: tasks\/changed\ndata: [^\n]*"kind":"agent-run"[^\n]*"status":"running"/),
170
+ 'the run starts as a task',
171
+ )
172
+ assert.ok(
173
+ seen(/event: tasks\/changed\ndata: [^\n]*"kind":"tool:install_plugin"/),
174
+ 'the install is a task',
175
+ )
176
+ assert.ok(
177
+ seen(/event: extensions\/changed\ndata: \{"args":\["hello","installed"\]\}/),
178
+ 'the extension is reported',
179
+ )
180
+ controller.abort()
181
+ await pump
182
+ } finally {
183
+ await app.stop()
184
+ await rm(project, { recursive: true, force: true })
185
+ }
186
+ })
@@ -0,0 +1,89 @@
1
+ import assert from 'node:assert/strict'
2
+ 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
+ import { join } from 'node:path'
7
+ import { test } from 'node:test'
8
+ import { createApp } from '../src/app.ts'
9
+ import { pinnedInstance, withPort } from '../src/plugins/instance/index.ts'
10
+
11
+ /** A plugin no scripted command knows: the model writes it from scratch. */
12
+ const COUNTER = `import type { Context } from '@deepseek-ai/cordis'
13
+ export const name = 'counter'
14
+ export const inject = ['http']
15
+ export function apply(ctx: Context) {
16
+ let count = 0
17
+ const http = ctx.get('http') as { route(m: string, p: string, h: () => { body: unknown }): () => void }
18
+ ctx.effect(() => http.route('GET', '/count', () => ({ body: { count: ++count } })))
19
+ }
20
+ `
21
+
22
+ /** Stands in for a real model API: plans write_plugin, then install_plugin, then answers. */
23
+ async function modelApi() {
24
+ const replies = [
25
+ {
26
+ tool_calls: [
27
+ {
28
+ id: '1',
29
+ type: 'function',
30
+ function: {
31
+ name: 'write_plugin',
32
+ arguments: JSON.stringify({ name: 'counter', files: { 'index.ts': COUNTER } }),
33
+ },
34
+ },
35
+ ],
36
+ },
37
+ {
38
+ tool_calls: [
39
+ {
40
+ id: '2',
41
+ type: 'function',
42
+ function: { name: 'install_plugin', arguments: '{"name":"counter"}' },
43
+ },
44
+ ],
45
+ },
46
+ { content: 'I built a counter: GET /count.' },
47
+ ]
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))
58
+ return {
59
+ url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`,
60
+ close: () => new Promise<void>((done) => server.close(() => done())),
61
+ }
62
+ }
63
+
64
+ test('with a real model, the agent writes a new Cordis plugin and the running app serves it', async () => {
65
+ const api = await modelApi()
66
+ const project = await mkdtemp(join(tmpdir(), 'model-project-'))
67
+ const app = await createApp(
68
+ withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0),
69
+ {
70
+ model: { baseUrl: api.url, model: 'test' },
71
+ },
72
+ )
73
+ try {
74
+ const answer = await (
75
+ await fetch(`${app.url}/ask`, {
76
+ method: 'POST',
77
+ headers: { 'content-type': 'application/json' },
78
+ body: JSON.stringify({ question: 'add a counter route' }),
79
+ })
80
+ ).json()
81
+ 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 })
84
+ } finally {
85
+ await app.stop()
86
+ await api.close()
87
+ await rm(project, { recursive: true, force: true })
88
+ }
89
+ })
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["ES2023"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "exactOptionalPropertyTypes": true,
10
+ "noImplicitOverride": true,
11
+ "verbatimModuleSyntax": true,
12
+ "erasableSyntaxOnly": true,
13
+ "allowImportingTsExtensions": true,
14
+ "noEmit": true,
15
+ "skipLibCheck": true,
16
+ "types": ["node"]
17
+ },
18
+ "include": ["src", "test", "extensions"]
19
+ }
@@ -0,0 +1,26 @@
1
+ # __APP_NAME__
2
+
3
+ A Cordis app on Node, scaffolded by `acrylblends init`. TypeScript, run by Node with no build step. Every part is a
4
+ Cordis plugin.
5
+
6
+ ```sh
7
+ npm install
8
+ npm run dev # prints its URL; data lives in .acrylblends/
9
+ npm test
10
+ npm run check # type-check
11
+ ```
12
+
13
+ - `src/main.ts` chooses where the app lives (`src/plugins/instance`) and starts it.
14
+ - `blend.yaml` is the app as data: which plugins it mounts, in what order, with what config and parameters. Switch
15
+ one off with `disabled: true`. `npm run lock` writes `blend.lock.json`: the manifest's digest and a digest of
16
+ every plugin folder, so a diff of the lock shows what changed.
17
+ - `src/app.ts` holds the registry of plugins the code provides, and mounts the manifest's rows.
18
+ - `src/routes.ts` is your own plugin: routes added inside `ctx.effect`.
19
+ - `src/plugins/` holds the plugins copied from the ACRYLBLENDS library. They are yours now; edit them freely.
20
+ Add more with `acrylblends add <plugin>`.
21
+
22
+ `npm run save -- "message"` commits the app with git (and pushes when it has a remote). It refuses a file that looks like
23
+ it holds a secret, and refuses to push a private app (anything but `visibility: public` in `blend.yaml`) to a public
24
+ remote.
25
+
26
+ Set `ACRYLBLENDS_HOME` to keep data elsewhere, and `ACRYLBLENDS_PORT` to choose the first port tried.
@@ -0,0 +1,23 @@
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: http
17
+ name: http
18
+ - id: routes
19
+ name: routes
20
+ - id: server
21
+ name: server
22
+ config:
23
+ hostname: "{{parameters.hostname}}"
@@ -0,0 +1,2 @@
1
+ node_modules/
2
+ .acrylblends/
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.19.0"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/main.ts",
11
+ "dev": "node --watch src/main.ts",
12
+ "test": "node --test",
13
+ "check": "tsc --noEmit",
14
+ "lock": "node src/plugins/manifest/lock.ts",
15
+ "save": "node src/plugins/save/cli.ts",
16
+ "upgrade": "node src/plugins/manifest/upgrade-cli.ts"
17
+ },
18
+ "dependencies": {},
19
+ "devDependencies": {
20
+ "@types/node": "^24.0.0",
21
+ "typescript": "6.0.3"
22
+ }
23
+ }
@@ -0,0 +1,61 @@
1
+ import { join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { Context, type Fiber } from '@deepseek-ai/cordis'
4
+ import { http } from './plugins/http/index.ts'
5
+ import { type AppInstance, instance } from './plugins/instance/index.ts'
6
+ import {
7
+ blueprintsIn,
8
+ compose,
9
+ loadManifest,
10
+ type ParameterValue,
11
+ } from './plugins/manifest/index.ts'
12
+ import { server } from './plugins/server/index.ts'
13
+ import { routes } from './routes.ts'
14
+
15
+ /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
16
+ export const registry = { http, routes, server }
17
+
18
+ /** The manifest next to this app's source. */
19
+ export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
20
+
21
+ /**
22
+ * The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
23
+ * `createApp` and the tests read it the same way.
24
+ */
25
+ export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
26
+ return loadManifest(MANIFEST, {
27
+ known: Object.keys(registry),
28
+ getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
29
+ ...(values ? { values } : {}),
30
+ })
31
+ }
32
+
33
+ export interface App {
34
+ readonly root: Context
35
+ readonly url: string
36
+ stop(): Promise<void>
37
+ }
38
+
39
+ /** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
40
+ export async function createApp(
41
+ chosen: AppInstance,
42
+ options: { values?: Readonly<Record<string, ParameterValue>> } = {},
43
+ ): Promise<App> {
44
+ const manifest = readManifest(options.values)
45
+ const root = new Context()
46
+ const first = root.plugin(instance, chosen)
47
+ await first.await()
48
+ const fibers: Fiber[] = [
49
+ first,
50
+ ...(await compose(root, manifest, registry, {
51
+ server: { port: chosen.port.start, scan: chosen.port.scan },
52
+ })),
53
+ ]
54
+ return {
55
+ root,
56
+ url: root.server.url,
57
+ async stop() {
58
+ for (const fiber of [...fibers].reverse()) await fiber.dispose()
59
+ },
60
+ }
61
+ }
@@ -0,0 +1,20 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { createApp } from './app.ts'
3
+ import { selectInstance } from './plugins/instance/index.ts'
4
+ import { APP_NAME } from './routes.ts'
5
+
6
+ // The composition root: the one place that reads the environment (through selectInstance) and chooses where the app lives.
7
+ const chosen = selectInstance({
8
+ projectRoot: fileURLToPath(new URL('..', import.meta.url)),
9
+ env: process.env,
10
+ })
11
+ const app = await createApp(chosen)
12
+ console.log(`${APP_NAME} listening on ${app.url} (data in ${chosen.home})`)
13
+
14
+ const stop = async () => {
15
+ await app.stop()
16
+ console.log('stopped')
17
+ process.exit(0)
18
+ }
19
+ process.once('SIGINT', stop)
20
+ process.once('SIGTERM', stop)
@@ -0,0 +1,14 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type {} from './plugins/http/index.ts'
3
+
4
+ export const APP_NAME = '__APP_NAME__'
5
+
6
+ /** The app's own routes. Each one lives in a `ctx.effect`, so it is removed when this plugin is disposed. */
7
+ export const routes = {
8
+ name: 'routes',
9
+ inject: ['http'],
10
+ apply(ctx: Context) {
11
+ ctx.effect(() => ctx.http.route('GET', '/', () => ({ body: { app: APP_NAME, ok: true } })))
12
+ ctx.effect(() => ctx.http.route('GET', '/health', () => ({ body: { ok: true } })))
13
+ },
14
+ }
@@ -0,0 +1 @@
1
+ { "plugins": ["instance", "http", "server", "manifest", "save"] }