acrylblends 1.3.2 → 1.5.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 (71) hide show
  1. package/README.md +5 -2
  2. package/dist/index.d.ts +8 -5
  3. package/dist/index.js +97 -38
  4. package/dist/index.js.map +1 -1
  5. package/legacy/README.md +1 -1
  6. package/legacy/agent/46c6eadf9230/src/app.ts +293 -0
  7. package/legacy/agent/46c6eadf9230/src/routes.ts +163 -0
  8. package/legacy/layouts.json +26 -0
  9. package/lib/{client-modules-Bkdr83jO.js → client-modules-C5qiVroz.js} +1 -1
  10. package/lib/cordis/door.d.ts +27 -6
  11. package/lib/cordis/door.js +41 -10
  12. package/lib/node-server/register.js +6 -1
  13. package/lib/node-server.js +1 -1
  14. package/lib/{transpile-QUK9b3eG.js → transpile-CRRaixyS.js} +19 -4
  15. package/package.json +1 -1
  16. package/plugins/acryl-door/src/cli/acryl.ts +13 -4
  17. package/plugins/acryl-door/src/host/door.ts +41 -3
  18. package/plugins/acryl-door/src/host/verbs.ts +3 -3
  19. package/plugins/acryl-node-server/src/register.ts +10 -1
  20. package/plugins/acryl-node-server/src/transpile.ts +25 -6
  21. package/plugins/acryl-page/src/favicon.ts +19 -0
  22. package/plugins/acryl-page/src/index.ts +18 -13
  23. package/plugins/agent-panel/client/main.ts +9 -0
  24. package/plugins/agent-panel/headless.ts +2 -2
  25. package/plugins/desktop/app-page.ts +151 -0
  26. package/plugins/desktop/assets/acrylblends-logo.png +0 -0
  27. package/plugins/desktop/brand.ts +164 -0
  28. package/plugins/desktop/frame.ts +88 -7
  29. package/plugins/desktop/git.ts +44 -0
  30. package/plugins/desktop/host-supervisor.ts +63 -0
  31. package/plugins/desktop/launch.ts +17 -1
  32. package/plugins/desktop/main.ts +268 -17
  33. package/plugins/desktop/preload.cjs +8 -0
  34. package/plugins/desktop/product.ts +117 -0
  35. package/plugins/desktop/window.ts +4 -0
  36. package/plugins/desktop-web/main.ts +3 -2
  37. package/plugins/desktop-web/plugin.json +1 -1
  38. package/plugins/extensions/index.ts +64 -13
  39. package/plugins/instance/archive.ts +395 -0
  40. package/plugins/instance/bundle-overlay.ts +10 -0
  41. package/plugins/instance/bundle.ts +201 -0
  42. package/plugins/instance/home.ts +386 -0
  43. package/plugins/instance/index.ts +132 -24
  44. package/plugins/instance/storage.ts +413 -0
  45. package/plugins/plugin-share/index.ts +99 -1
  46. package/plugins/plugin-share/publish.ts +17 -3
  47. package/plugins/plugin-share/pull-check.ts +2 -0
  48. package/plugins/plugin-share/pull-list.ts +71 -0
  49. package/plugins/showcase/cards.ts +162 -0
  50. package/plugins/showcase/index.ts +116 -0
  51. package/plugins/showcase/plugin.json +11 -0
  52. package/shared/skills/acryl-remove-plugin/SKILL.md +1 -1
  53. package/templates/agent/AGENTS.md +4 -0
  54. package/templates/agent/README.md +1 -1
  55. package/templates/agent/blend.yaml +3 -0
  56. package/templates/agent/gitignore +2 -0
  57. package/templates/agent/src/app.ts +2 -0
  58. package/templates/agent/src/routes.ts +23 -5
  59. package/templates/agent/template.json +1 -0
  60. package/templates/agent/test/app.test.ts +26 -0
  61. package/templates/blank/README.md +1 -1
  62. package/templates/blank/gitignore +2 -0
  63. package/templates/blank/src/routes.ts +9 -5
  64. package/templates/chat/gitignore +2 -0
  65. package/templates/chat/src/chat-app.ts +7 -3
  66. package/templates/chat/src/llm.ts +7 -1
  67. package/templates/chat/test/chat.test.ts +16 -1
  68. package/templates/nodejs/gitignore +2 -0
  69. package/templates/notes/gitignore +2 -0
  70. package/templates/todo/README.md +1 -1
  71. package/templates/todo/gitignore +2 -0
@@ -3,6 +3,7 @@ import { Acryl } from '../plugins/acryl-router/src/index.ts'
3
3
  import { bodyLimit } from '../plugins/acryl-router/src/middleware/index.ts'
4
4
  import type {} from '../plugins/agent/index.ts'
5
5
  import type {} from '../plugins/extensions/index.ts'
6
+ import { type Home, renderWidgets } from '../plugins/home/index.ts'
6
7
  import type {} from '../plugins/tasks/index.ts'
7
8
 
8
9
  export const APP_NAME = '__APP_NAME__'
@@ -59,20 +60,24 @@ const appearanceScript = (prefix: string) => `<script src="${prefix}/appearance.
59
60
  const settingsPage = (prefix: string) =>
60
61
  `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Settings - ${APP_NAME}</title>${APPEARANCE_STYLE}${appearanceScript(prefix)}</head><body><h1>Settings</h1><p><label for="theme">Appearance</label> <select id="theme" name="theme"><option value="system">Match the system</option><option value="light">Light</option><option value="dark">Dark</option></select></p><p id="saved" role="status"></p><p><a href="${prefix}/">Back to ${APP_NAME}</a></p><script>(function(){var select=document.getElementById('theme'),saved=document.getElementById('saved');try{select.value=localStorage.getItem('theme')||'system'}catch(e){}select.addEventListener('change',function(){var value=select.value;try{if(value==='system'){localStorage.removeItem('theme');delete document.documentElement.dataset.theme}else{localStorage.setItem('theme',value);document.documentElement.dataset.theme=value}saved.textContent='Saved: '+select.options[select.selectedIndex].text}catch(e){saved.textContent='This browser would not keep the setting.'}})})()</script></body></html>`
61
62
 
63
+ const widgetsOf = (home: Home | undefined): Promise<string> =>
64
+ home === undefined ? Promise.resolve('') : renderWidgets(home)
65
+
62
66
  /**
63
67
  * The app's own pages and routes, mountable at any prefix: the standalone server mounts it at the root, the frame row at {@link FRAME_MOUNT_PREFIX}.
64
- * Every absolute URL a page writes is derived from the prefix it is mounted at.
68
+ * Every absolute URL a page writes is derived from the prefix it is mounted at. The home page shows the widgets of the app's plugins (`ctx.home`), read
69
+ * at each request through `home()`, so a plugin pulled, switched off or removed shows at the next load; without a home service the page has none.
65
70
  */
66
- export function makeApp(prefix = ''): Acryl {
71
+ export function makeApp(prefix = '', home: () => Home | undefined = () => undefined): Acryl {
67
72
  const limit = bodyLimit({ maxSize: 64 * 1024 })
68
73
  return (
69
74
  new Acryl()
70
75
  .use(limit)
71
76
  // A browser gets a page (the door's and the agent panel's script tags are added where they are mounted); everything else gets the JSON.
72
- .get('/', (c) =>
77
+ .get('/', async (c) =>
73
78
  (c.req.header('accept') ?? '').includes('text/html')
74
79
  ? c.html(
75
- `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title>${APPEARANCE_STYLE}${appearanceScript(prefix)}</head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). In ACRYL, ask it for a change in the chat beside the app (or with <code>POST /ask</code>); published with the assistant on, it helps visitors use the app without changing it.</p>${DOOR_TAG}</body></html>`,
80
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title>${APPEARANCE_STYLE}${appearanceScript(prefix)}</head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). In ACRYL, ask it for a change in the chat beside the app (or with <code>POST /ask</code>); published with the assistant on, it helps visitors use the app without changing it.</p>${await widgetsOf(home())}${DOOR_TAG}</body></html>`,
76
81
  )
77
82
  : c.json({ app: APP_NAME, ok: true }),
78
83
  )
@@ -102,7 +107,20 @@ export const routes = {
102
107
  }
103
108
  const limit = bodyLimit({ maxSize: 64 * 1024 })
104
109
  // The app's own page and routes at the one prefix; the agent routes below mount at the same prefix.
105
- ctx.router.route(prefix === '' ? '/' : prefix, makeApp(prefix))
110
+ // The home page's widgets while a `home` row runs: an app made before the agent template mounted `home` (its blend.yaml has no such row) keeps its pages.
111
+ let home: Home | undefined
112
+ ctx.inject(['home'], (inner) => {
113
+ inner.effect(() => {
114
+ home = inner.home
115
+ return () => {
116
+ home = undefined
117
+ }
118
+ }, 'routes: the home page widgets')
119
+ })
120
+ ctx.router.route(
121
+ prefix === '' ? '/' : prefix,
122
+ makeApp(prefix, () => home),
123
+ )
106
124
  // The routes that give a visitor the agent: mounted only while the agent, the extensions and the tasks run (see the header).
107
125
  // `agentRoutes: false` (the production Assistant) keeps them unmounted although the agent runs.
108
126
  if (options.agentRoutes === false) return
@@ -2,6 +2,7 @@
2
2
  "plugins": [
3
3
  "instance",
4
4
  "acryl-router",
5
+ "home",
5
6
  "acryl-state",
6
7
  "acryl-node-server",
7
8
  "acryl-page",
@@ -44,6 +44,32 @@ test('answers health checks and 404 for unknown paths', () =>
44
44
  await missing.body?.cancel()
45
45
  }))
46
46
 
47
+ test('a plugin puts its widget on the home page, and the widget leaves with the plugin', () =>
48
+ withApp(async (app) => {
49
+ const page = async () =>
50
+ (await app.root.router.request('/', { headers: { accept: 'text/html' } })).text()
51
+ const feature = app.root.plugin({
52
+ name: 'hello-widget',
53
+ inject: ['home'],
54
+ apply(ctx: typeof app.root) {
55
+ ctx.effect(() =>
56
+ ctx.home.widget({
57
+ id: 'hello-widget',
58
+ title: 'Hello',
59
+ render: () => '<p id="hello">hi</p>',
60
+ }),
61
+ )
62
+ },
63
+ })
64
+ await feature.await()
65
+ assert.match(
66
+ await page(),
67
+ /<section class="widget" data-widget="hello-widget">[\s\S]*<p id="hello">hi<\/p>/,
68
+ )
69
+ await feature.dispose()
70
+ assert.doesNotMatch(await page(), /data-widget="hello-widget"/)
71
+ }))
72
+
47
73
  /** 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
74
  /**
49
75
  * Whether this environment forbids listening at all: only a sandbox's refusal (EPERM/EACCES) means the real-server test must skip. A loaded
@@ -5,7 +5,7 @@ Cordis plugin.
5
5
 
6
6
  ```sh
7
7
  npm install
8
- npm run dev # prints its URL; data lives in .acrylblends/
8
+ npm run dev # prints its URL; data lives in ~/.acryl/apps/<id>/data/
9
9
  npm test
10
10
  npm run check # type-check
11
11
  ```
@@ -2,3 +2,5 @@ node_modules/
2
2
  .acrylblends/
3
3
  .acryl/
4
4
  package-lock.json
5
+ .env
6
+ .env.*
@@ -9,7 +9,7 @@ export const APP_NAME = '__APP_NAME__'
9
9
  /**
10
10
  * The only place the app's mount prefix is named. On its own server (production, `npm start`) the app mounts at the root and
11
11
  * its public URLs are `/`, `/about`, `/health`; when the ACRYL frame hosts it (`npm run dev`) the routes mount here, and the
12
- * frame opens its tile at `/app/`. Every page link and asset URL this template writes is derived from the prefix it is
12
+ * frame opens its tile at `/app` (`/app/` answers too). Every page link and asset URL this template writes is derived from the prefix it is
13
13
  * mounted at, so the same source serves both.
14
14
  */
15
15
  export const FRAME_MOUNT_PREFIX = '/app'
@@ -50,14 +50,14 @@ const homePage = (name: string, widgets: string): string => `<!doctype html>
50
50
  * The app's own pages and routes, mountable at any prefix: the standalone server mounts it at the root, the frame host at {@link FRAME_MOUNT_PREFIX}.
51
51
  * The first screen shows the widgets of the app's plugins (`ctx.home`), read at each request, so a plugin switched on or off shows at the next load.
52
52
  */
53
- export function makeApp(home: Home): Acryl {
53
+ export function makeApp(home: Home, name: () => string = () => APP_NAME): Acryl {
54
54
  return (
55
55
  new Acryl()
56
56
  // A browser gets the first screen; an API client gets the JSON hello.
57
57
  .get('/', async (c) =>
58
58
  (c.req.header('accept') ?? '').includes('text/html')
59
- ? c.html(homePage(APP_NAME, await renderWidgets(home)))
60
- : c.json({ app: APP_NAME, ok: true }),
59
+ ? c.html(homePage(name(), await renderWidgets(home)))
60
+ : c.json({ app: name(), ok: true }),
61
61
  )
62
62
  .get('/health', (c) => c.json({ ok: true }))
63
63
  .get('/about', (c) => c.html(aboutPage()))
@@ -70,6 +70,10 @@ export const routes = {
70
70
  inject: ['router', 'home'],
71
71
  apply(ctx: Context, options: { readonly prefix?: string } = {}) {
72
72
  const prefix = options.prefix ?? ''
73
- ctx.router.route(prefix === '' ? '/' : prefix, makeApp(ctx.home))
73
+ // The name the app has now (Settings > App renames it), else the one the scaffold was made with.
74
+ ctx.router.route(
75
+ prefix === '' ? '/' : prefix,
76
+ makeApp(ctx.home, () => ctx.get('appInstance')?.name ?? APP_NAME),
77
+ )
74
78
  },
75
79
  }
@@ -5,3 +5,5 @@ data/
5
5
  *.sqlite
6
6
  *.sqlite-*
7
7
  package-lock.json
8
+ .env
9
+ .env.*
@@ -7,7 +7,7 @@ import {
7
7
  import { Acryl } from '../plugins/acryl-router/src/index.ts'
8
8
  import { type Access, accessGate } from './access.ts'
9
9
  import { chatStore } from './db.ts'
10
- import { type Llm, openAiCompatible } from './llm.ts'
10
+ import { type Llm, MissingKey, openAiCompatible } from './llm.ts'
11
11
  import { chatPage } from './page.tsx'
12
12
 
13
13
  interface ChatInput {
@@ -99,10 +99,14 @@ export function createApp(
99
99
  } catch (error) {
100
100
  if (!signal.aborted) {
101
101
  console.error(error)
102
- // The reason stays in the log; the page gets a plain message.
102
+ // The reason stays in the log; the page gets a plain message, or how to set the missing key.
103
103
  await stream.writeSSE({
104
104
  event: 'error',
105
- data: JSON.stringify('The model could not answer. Try again.'),
105
+ data: JSON.stringify(
106
+ error instanceof MissingKey
107
+ ? error.message
108
+ : 'The model could not answer. Try again.',
109
+ ),
106
110
  })
107
111
  }
108
112
  } finally {
@@ -4,13 +4,19 @@ export type Llm = (
4
4
  signal: AbortSignal,
5
5
  ) => AsyncIterable<string>
6
6
 
7
+ /** The model has no key: the one failure whose message the page may show, since it names the fix and holds no detail from the service. */
8
+ export class MissingKey extends Error {}
9
+
7
10
  /**
8
11
  * An OpenAI-compatible chat completions API with `stream: true` (DeepSeek by default). The key is read from DEEPSEEK_API_KEY at the moment of the call and
9
12
  * is never stored; DEEPSEEK_BASE_URL and DEEPSEEK_MODEL choose the service.
10
13
  */
11
14
  export const openAiCompatible: Llm = async function* (messages, signal) {
12
15
  const key = process.env.DEEPSEEK_API_KEY
13
- if (!key) throw new Error('DEEPSEEK_API_KEY is not set')
16
+ if (!key)
17
+ throw new MissingKey(
18
+ 'DEEPSEEK_API_KEY is not set: set it in the terminal that runs the app, then start the app again.',
19
+ )
14
20
  const base = (process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com').replace(/\/$/, '')
15
21
  const response = await fetch(`${base}/chat/completions`, {
16
22
  method: 'POST',
@@ -12,7 +12,7 @@ import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
12
12
  import { chatAccess } from '../src/access.ts'
13
13
  import { createApp as boot } from '../src/app.ts'
14
14
  import { createApp } from '../src/chat-app.ts'
15
- import type { Llm } from '../src/llm.ts'
15
+ import { type Llm, openAiCompatible } from '../src/llm.ts'
16
16
  import { FRAME_MOUNT_PREFIX } from '../src/routes.ts'
17
17
 
18
18
  /** Throwaway homes and databases live inside the project (`.acrylblends/` is ignored by git), because a sandboxed agent may not write to the system temp folder. */
@@ -155,6 +155,21 @@ test('a failing model sends an error event with a plain message and no detail',
155
155
  assert.match(last?.[1] ?? '', /could not answer/)
156
156
  })
157
157
 
158
+ test('a model without its key tells the page which variable to set', async () => {
159
+ const saved = process.env.DEEPSEEK_API_KEY
160
+ delete process.env.DEEPSEEK_API_KEY
161
+ try {
162
+ const app = createApp(await memoryDb(), openAiCompatible)
163
+ const last = events(await (await app.request('/api/chat', post({ message: 'hi' }))).text()).at(
164
+ -1,
165
+ )
166
+ assert.equal(last?.[0], 'error')
167
+ assert.match(last?.[1] ?? '', /DEEPSEEK_API_KEY is not set/)
168
+ } finally {
169
+ if (saved !== undefined) process.env.DEEPSEEK_API_KEY = saved
170
+ }
171
+ })
172
+
158
173
  test('when the client goes away the model call is aborted and what arrived is kept', async () => {
159
174
  let sawAbort: (value: boolean) => void = () => {}
160
175
  const aborted = new Promise<boolean>((resolve) => {
@@ -2,3 +2,5 @@ node_modules/
2
2
  .acrylblends/
3
3
  .acryl/
4
4
  package-lock.json
5
+ .env
6
+ .env.*
@@ -5,3 +5,5 @@ data/
5
5
  *.sqlite
6
6
  *.sqlite-*
7
7
  package-lock.json
8
+ .env
9
+ .env.*
@@ -21,7 +21,7 @@ curl -N $URL/todo/events
21
21
 
22
22
  The routes only answer a caller on this machine: a request from another site's page (a foreign `Origin`, or `Origin: null`) or addressed to another host name gets `403`.
23
23
 
24
- Open `$URL/todo` for the page. Stop the app and start it again: the list is still there (`<home>/todo.json`, `.acrylblends/` unless `ACRYLBLENDS_HOME` says otherwise).
24
+ Open `$URL/todo` for the page. Stop the app and start it again: the list is still there (`<home>/todo.json`, `~/.acryl/apps/<id>/data/` unless `ACRYLBLENDS_HOME` says otherwise).
25
25
 
26
26
  - `blend.yaml` is the app as data. `npm run lock` records what it is built from; `npm run save` commits it, refusing secrets and public remotes for a private app.
27
27
  - The framework plugins are in `plugins/acryl-*/src`. Do not edit them: they are ACRYL's, vendored; the text that explains them is in the ACRYLBLENDS docs.
@@ -2,3 +2,5 @@ node_modules/
2
2
  .acrylblends/
3
3
  .acryl/
4
4
  package-lock.json
5
+ .env
6
+ .env.*