acrylblends 1.1.0 → 1.3.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/README.md +9 -0
- package/dist/index.d.ts +47 -2
- package/dist/index.js +2017 -32
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +5 -3
- package/docs/framework/workshop/README.md +139 -153
- package/docs/framework/workshop/facilitator.md +69 -101
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +87 -9
- package/lib/cordis/door.js +324 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.js +1 -1
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -1
- package/lib/door/page.js +46 -7
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/cli/acryl.ts +63 -6
- package/plugins/acryl-door/src/host/channel.ts +86 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +128 -24
- package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +14 -1
- package/plugins/acryl-door/src/index.ts +168 -30
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +41 -2
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +238 -26
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +146 -57
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/builder/index.ts +13 -35
- package/plugins/builder/package.ts +186 -0
- package/plugins/builder/plugin.json +3 -1
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +47 -18
- package/plugins/extensions/plugin-store.ts +27 -0
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/extensions/stage.ts +15 -5
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +6 -0
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/plugin.json +1 -1
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/plugins/plugin-share/README.md +51 -0
- package/plugins/plugin-share/index.ts +467 -0
- package/plugins/plugin-share/plugin.json +15 -0
- package/plugins/plugin-share/publish.ts +65 -0
- package/plugins/plugin-share/pull-check.ts +129 -0
- package/plugins/plugin-share/pulled-records.ts +70 -0
- package/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/.gitattributes +2 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/blend.yaml +6 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +154 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +38 -19
- package/templates/agent/template.json +3 -0
- package/templates/agent/test/app.test.ts +45 -3
- package/templates/blank/.gitattributes +2 -0
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/.gitattributes +2 -0
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/.gitattributes +2 -0
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/.gitattributes +2 -0
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/.gitattributes +2 -0
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- package/shared/SKILL.md +0 -14
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notes people sent, in the app's own data: one JSON line per note in `notes.jsonl`, the ids the builder has seen in `seen.json`, and a note's
|
|
3
|
+
* screenshot as a file in `shots/`. Appending never rewrites a note, so a crash loses at most the note being written; an image is written before its
|
|
4
|
+
* note, so a note never names a missing file.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/** The element a note was pinned on: a CSS selector to find it again, and a short name to show. */
|
|
12
|
+
export interface PinnedElement {
|
|
13
|
+
readonly selector: string
|
|
14
|
+
readonly label: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ScreenshotMediaType = 'image/webp' | 'image/png'
|
|
18
|
+
|
|
19
|
+
/** A note's screenshot, as stored beside the notes. */
|
|
20
|
+
export interface FeedbackScreenshot {
|
|
21
|
+
/** The file under `shots/`. */
|
|
22
|
+
readonly file: string
|
|
23
|
+
readonly mediaType: ScreenshotMediaType
|
|
24
|
+
readonly width: number
|
|
25
|
+
readonly height: number
|
|
26
|
+
readonly bytes: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A note already pinned on an element of a page, as that page's own script sees it: where to find the element again and the number its badge
|
|
31
|
+
* shows. It carries no note text, because anyone who opens the page can read this.
|
|
32
|
+
*/
|
|
33
|
+
export interface FeedbackPin {
|
|
34
|
+
readonly selector: string
|
|
35
|
+
readonly number: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FeedbackNote {
|
|
39
|
+
readonly id: string
|
|
40
|
+
/** ISO time it was sent. */
|
|
41
|
+
readonly at: string
|
|
42
|
+
readonly text: string
|
|
43
|
+
/** The page's path (and query) it was sent from. */
|
|
44
|
+
readonly page: string
|
|
45
|
+
/** Absent for "Nothing specific". */
|
|
46
|
+
readonly element?: PinnedElement
|
|
47
|
+
/** What the person saw when they sent it; absent when the browser refused the capture. */
|
|
48
|
+
readonly screenshot?: FeedbackScreenshot
|
|
49
|
+
/** The builder has seen it in the frame. */
|
|
50
|
+
readonly seen: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One screenshot as the page sent it, decoded and checked. */
|
|
54
|
+
export interface NewScreenshot {
|
|
55
|
+
readonly mediaType: ScreenshotMediaType
|
|
56
|
+
readonly width: number
|
|
57
|
+
readonly height: number
|
|
58
|
+
readonly data: Buffer
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface NewNote {
|
|
62
|
+
readonly text: string
|
|
63
|
+
readonly page: string
|
|
64
|
+
readonly element?: PinnedElement
|
|
65
|
+
readonly screenshot?: NewScreenshot
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A stored screenshot's bytes and type. */
|
|
69
|
+
export interface StoredScreenshot {
|
|
70
|
+
readonly bytes: Buffer
|
|
71
|
+
readonly mediaType: ScreenshotMediaType
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const LIMITS = {
|
|
75
|
+
text: 2000,
|
|
76
|
+
page: 500,
|
|
77
|
+
selector: 500,
|
|
78
|
+
label: 120,
|
|
79
|
+
/** Decoded image bytes; the page downscales and re-encodes below this. */
|
|
80
|
+
screenshotBytes: 512 * 1024,
|
|
81
|
+
screenshotSide: 2000,
|
|
82
|
+
} as const
|
|
83
|
+
|
|
84
|
+
const DATA_URL = /^data:(image\/(?:webp|png));base64,([A-Za-z0-9+/]+={0,2})$/u
|
|
85
|
+
const NOTE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
|
|
86
|
+
|
|
87
|
+
/** One screenshot from the page: a WebP or PNG data URL, its size, and the bytes. A string says what is wrong. */
|
|
88
|
+
export function parseScreenshot(value: unknown): NewScreenshot | string {
|
|
89
|
+
if (typeof value !== 'object' || value === null) return 'the screenshot must be an object'
|
|
90
|
+
const { data, width, height } = value as Record<string, unknown>
|
|
91
|
+
if (typeof data !== 'string') return 'the screenshot has no image'
|
|
92
|
+
const match = DATA_URL.exec(data)
|
|
93
|
+
if (match === null || match[1] === undefined || match[2] === undefined)
|
|
94
|
+
return 'the screenshot must be a WebP or PNG data URL'
|
|
95
|
+
const mediaType = match[1] as ScreenshotMediaType
|
|
96
|
+
const bytes = Buffer.from(match[2], 'base64')
|
|
97
|
+
if (bytes.length === 0) return 'the screenshot is empty'
|
|
98
|
+
if (bytes.length > LIMITS.screenshotBytes)
|
|
99
|
+
return `a screenshot is at most ${String(Math.round(LIMITS.screenshotBytes / 1024))} KB`
|
|
100
|
+
const webp =
|
|
101
|
+
bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP'
|
|
102
|
+
const png = bytes[0] === 0x89 && bytes.subarray(1, 4).toString() === 'PNG'
|
|
103
|
+
if ((mediaType === 'image/webp' && !webp) || (mediaType === 'image/png' && !png))
|
|
104
|
+
return 'the screenshot is not the image its type names'
|
|
105
|
+
if (
|
|
106
|
+
!Number.isInteger(width) ||
|
|
107
|
+
!Number.isInteger(height) ||
|
|
108
|
+
(width as number) < 1 ||
|
|
109
|
+
(height as number) < 1 ||
|
|
110
|
+
(width as number) > LIMITS.screenshotSide ||
|
|
111
|
+
(height as number) > LIMITS.screenshotSide
|
|
112
|
+
)
|
|
113
|
+
return `the screenshot needs its size, up to ${String(LIMITS.screenshotSide)} px a side`
|
|
114
|
+
return { mediaType, width: width as number, height: height as number, data: bytes }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A note as a page sent it, checked; a string says what is wrong. */
|
|
118
|
+
export function parseNewNote(body: unknown): NewNote | string {
|
|
119
|
+
if (body === null || typeof body !== 'object') return 'send a JSON object'
|
|
120
|
+
const { text, page, element, screenshot } = body as Record<string, unknown>
|
|
121
|
+
if (typeof text !== 'string' || text.trim() === '') return 'write a note'
|
|
122
|
+
if (text.length > LIMITS.text) return `a note has at most ${LIMITS.text} characters`
|
|
123
|
+
if (typeof page !== 'string' || !page.startsWith('/') || page.length > LIMITS.page)
|
|
124
|
+
return 'the page must be a path'
|
|
125
|
+
let pinned: PinnedElement | undefined
|
|
126
|
+
if (element !== undefined && element !== null) {
|
|
127
|
+
if (typeof element !== 'object') return 'the element must be an object'
|
|
128
|
+
const { selector, label } = element as Record<string, unknown>
|
|
129
|
+
if (typeof selector !== 'string' || selector === '' || selector.length > LIMITS.selector)
|
|
130
|
+
return 'the element needs a selector'
|
|
131
|
+
if (typeof label !== 'string' || label === '' || label.length > LIMITS.label)
|
|
132
|
+
return 'the element needs a name'
|
|
133
|
+
pinned = { selector, label }
|
|
134
|
+
}
|
|
135
|
+
let shot: NewScreenshot | undefined
|
|
136
|
+
if (screenshot !== undefined && screenshot !== null) {
|
|
137
|
+
const parsed = parseScreenshot(screenshot)
|
|
138
|
+
if (typeof parsed === 'string') return parsed
|
|
139
|
+
shot = parsed
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
text: text.trim(),
|
|
143
|
+
page,
|
|
144
|
+
...(pinned === undefined ? {} : { element: pinned }),
|
|
145
|
+
...(shot === undefined ? {} : { screenshot: shot }),
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function storedScreenshot(raw: unknown): FeedbackScreenshot | undefined {
|
|
150
|
+
if (raw === null || typeof raw !== 'object') return undefined
|
|
151
|
+
const { file, mediaType, width, height, bytes } = raw as Record<string, unknown>
|
|
152
|
+
if (
|
|
153
|
+
typeof file !== 'string' ||
|
|
154
|
+
(mediaType !== 'image/webp' && mediaType !== 'image/png') ||
|
|
155
|
+
!Number.isInteger(width) ||
|
|
156
|
+
!Number.isInteger(height) ||
|
|
157
|
+
!Number.isInteger(bytes)
|
|
158
|
+
)
|
|
159
|
+
return undefined
|
|
160
|
+
return {
|
|
161
|
+
file,
|
|
162
|
+
mediaType,
|
|
163
|
+
width: width as number,
|
|
164
|
+
height: height as number,
|
|
165
|
+
bytes: bytes as number,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export class FeedbackStore {
|
|
170
|
+
private readonly notesFile: string
|
|
171
|
+
private readonly seenFile: string
|
|
172
|
+
private readonly shotsDir: string
|
|
173
|
+
/** Writes one after another, so two notes never interleave in the file. */
|
|
174
|
+
private queue: Promise<unknown> = Promise.resolve()
|
|
175
|
+
|
|
176
|
+
constructor(dir: string) {
|
|
177
|
+
this.notesFile = join(dir, 'notes.jsonl')
|
|
178
|
+
this.seenFile = join(dir, 'seen.json')
|
|
179
|
+
this.shotsDir = join(dir, 'shots')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
add(note: NewNote, now: Date = new Date()): Promise<FeedbackNote> {
|
|
183
|
+
const id = randomUUID()
|
|
184
|
+
const shot = note.screenshot
|
|
185
|
+
const stored = {
|
|
186
|
+
id,
|
|
187
|
+
at: now.toISOString(),
|
|
188
|
+
text: note.text,
|
|
189
|
+
page: note.page,
|
|
190
|
+
...(note.element === undefined ? {} : { element: note.element }),
|
|
191
|
+
...(shot === undefined
|
|
192
|
+
? {}
|
|
193
|
+
: {
|
|
194
|
+
screenshot: {
|
|
195
|
+
file: `${id}.${shot.mediaType === 'image/webp' ? 'webp' : 'png'}`,
|
|
196
|
+
mediaType: shot.mediaType,
|
|
197
|
+
width: shot.width,
|
|
198
|
+
height: shot.height,
|
|
199
|
+
bytes: shot.data.length,
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
}
|
|
203
|
+
return this.serial(async () => {
|
|
204
|
+
await mkdir(join(this.notesFile, '..'), { recursive: true })
|
|
205
|
+
if (shot !== undefined) {
|
|
206
|
+
// The image first: a note in the file always has its screenshot.
|
|
207
|
+
await mkdir(this.shotsDir, { recursive: true })
|
|
208
|
+
await writeFile(
|
|
209
|
+
join(this.shotsDir, `${id}.${shot.mediaType === 'image/webp' ? 'webp' : 'png'}`),
|
|
210
|
+
shot.data,
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
await appendFile(this.notesFile, `${JSON.stringify(stored)}\n`)
|
|
214
|
+
return { ...stored, seen: false }
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** One note's screenshot, when it has one and the file is still there. */
|
|
219
|
+
async screenshot(id: string): Promise<StoredScreenshot | undefined> {
|
|
220
|
+
if (!NOTE_ID.test(id)) return undefined
|
|
221
|
+
for (const [ext, mediaType] of [
|
|
222
|
+
['webp', 'image/webp'],
|
|
223
|
+
['png', 'image/png'],
|
|
224
|
+
] as const) {
|
|
225
|
+
try {
|
|
226
|
+
return { bytes: await readFile(join(this.shotsDir, `${id}.${ext}`)), mediaType }
|
|
227
|
+
} catch {
|
|
228
|
+
// Not this type; try the other.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return undefined
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The pins of one page, for the badges its visitors see: the elements a note was pinned on, numbered in the order their first note came,
|
|
236
|
+
* oldest first. One pin per element, so two notes on the same element share its badge, and the number of a pin never moves once it is given.
|
|
237
|
+
*/
|
|
238
|
+
async pins(page: string): Promise<FeedbackPin[]> {
|
|
239
|
+
const pins: FeedbackPin[] = []
|
|
240
|
+
const taken = new Set<string>()
|
|
241
|
+
for (const note of (await this.list()).reverse()) {
|
|
242
|
+
const element = note.element
|
|
243
|
+
if (note.page !== page || element === undefined || taken.has(element.selector)) continue
|
|
244
|
+
taken.add(element.selector)
|
|
245
|
+
pins.push({ selector: element.selector, number: pins.length + 1 })
|
|
246
|
+
}
|
|
247
|
+
return pins
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Every note, newest first. A line that is not a note is skipped. */
|
|
251
|
+
async list(): Promise<FeedbackNote[]> {
|
|
252
|
+
if (!existsSync(this.notesFile)) return []
|
|
253
|
+
const seen = await this.seen()
|
|
254
|
+
const notes: FeedbackNote[] = []
|
|
255
|
+
for (const line of (await readFile(this.notesFile, 'utf8')).split('\n')) {
|
|
256
|
+
if (line.trim() === '') continue
|
|
257
|
+
try {
|
|
258
|
+
const raw = JSON.parse(line) as Omit<FeedbackNote, 'seen'> & {
|
|
259
|
+
readonly screenshot?: unknown
|
|
260
|
+
}
|
|
261
|
+
if (
|
|
262
|
+
typeof raw.id === 'string' &&
|
|
263
|
+
typeof raw.at === 'string' &&
|
|
264
|
+
typeof raw.text === 'string' &&
|
|
265
|
+
typeof raw.page === 'string'
|
|
266
|
+
) {
|
|
267
|
+
const { screenshot, ...rest } = raw
|
|
268
|
+
const shot = storedScreenshot(screenshot)
|
|
269
|
+
notes.push({
|
|
270
|
+
...rest,
|
|
271
|
+
seen: seen.has(raw.id),
|
|
272
|
+
...(shot === undefined ? {} : { screenshot: shot }),
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
} catch {
|
|
276
|
+
// A torn last line: skipped.
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return notes.reverse()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
markSeen(ids: readonly string[]): Promise<void> {
|
|
283
|
+
return this.serial(async () => {
|
|
284
|
+
const seen = await this.seen()
|
|
285
|
+
for (const id of ids) seen.add(id)
|
|
286
|
+
await mkdir(join(this.seenFile, '..'), { recursive: true })
|
|
287
|
+
await writeFile(this.seenFile, JSON.stringify([...seen]))
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private async seen(): Promise<Set<string>> {
|
|
292
|
+
try {
|
|
293
|
+
const ids: unknown = JSON.parse(await readFile(this.seenFile, 'utf8'))
|
|
294
|
+
return new Set(
|
|
295
|
+
Array.isArray(ids) ? ids.filter((id): id is string => typeof id === 'string') : [],
|
|
296
|
+
)
|
|
297
|
+
} catch {
|
|
298
|
+
return new Set()
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private serial<T>(work: () => Promise<T>): Promise<T> {
|
|
303
|
+
const next = this.queue.then(work, work)
|
|
304
|
+
this.queue = next.catch(() => {})
|
|
305
|
+
return next
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's home page as a place plugins put their widgets (`ctx.home`).
|
|
3
|
+
*
|
|
4
|
+
* A feature is its own plugin, so its UI must reach the page without anyone editing the page's code: a plugin registers a widget, the home page
|
|
5
|
+
* renders every widget registered at the time of the request, and the widget leaves the page with the plugin that registered it (switched off in
|
|
6
|
+
* Settings > Plugins, removed, or reloaded). The widget is HTML rendered into the page itself, not a frame, so the page, its tests and the door's
|
|
7
|
+
* `look` all see it.
|
|
8
|
+
*
|
|
9
|
+
* A widget names its plugin's URLs as the app's own server answers them (`/notes/widget.js`, `fetch('/notes/items')`): on `npm start` they are at the
|
|
10
|
+
* root, and in the frame the canvas page sends them to the same routes under its prefix.
|
|
11
|
+
*/
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
13
|
+
|
|
14
|
+
/** One widget on the home page. */
|
|
15
|
+
export interface HomeWidget {
|
|
16
|
+
/** Unique on the page: the name of the plugin that registers it. */
|
|
17
|
+
readonly id: string
|
|
18
|
+
/** The widget's heading; the page escapes it. */
|
|
19
|
+
readonly title: string
|
|
20
|
+
/** The widget's HTML, rendered into the page at each request. It is inserted as it is: escape any user text in it. */
|
|
21
|
+
render(): string | Promise<string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Home {
|
|
25
|
+
/**
|
|
26
|
+
* Put a widget on the home page. Call it inside `ctx.effect` so the widget leaves with the plugin:
|
|
27
|
+
* `ctx.effect(() => ctx.home.widget({ id: 'notes', title: 'Notes', render: () => '<div id="notes"></div>' }))`.
|
|
28
|
+
* @returns the disposer, which takes the widget off the page.
|
|
29
|
+
* @throws when a widget with the same id is already on the page.
|
|
30
|
+
*/
|
|
31
|
+
widget(widget: HomeWidget): () => void
|
|
32
|
+
/** The widgets on the page now, in the order they were registered. */
|
|
33
|
+
widgets(): readonly HomeWidget[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
declare module '@deepseek-ai/cordis' {
|
|
37
|
+
interface Context {
|
|
38
|
+
home: Home
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ID = /^[a-z0-9][a-z0-9-]*$/
|
|
43
|
+
|
|
44
|
+
export function createHome(): Home {
|
|
45
|
+
const registered = new Map<string, HomeWidget>()
|
|
46
|
+
return {
|
|
47
|
+
widget(widget) {
|
|
48
|
+
if (!ID.test(widget.id))
|
|
49
|
+
throw new Error(
|
|
50
|
+
`a widget id is a plugin name (lowercase letters, digits and dashes), not "${widget.id}"`,
|
|
51
|
+
)
|
|
52
|
+
if (registered.has(widget.id))
|
|
53
|
+
throw new Error(`the home page already has a widget "${widget.id}"`)
|
|
54
|
+
registered.set(widget.id, widget)
|
|
55
|
+
return () => {
|
|
56
|
+
if (registered.get(widget.id) === widget) registered.delete(widget.id)
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
widgets: () => [...registered.values()],
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const escapeHtml = (text: string): string =>
|
|
64
|
+
text
|
|
65
|
+
.replaceAll('&', '&')
|
|
66
|
+
.replaceAll('<', '<')
|
|
67
|
+
.replaceAll('>', '>')
|
|
68
|
+
.replaceAll('"', '"')
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The widgets' look, the ACRYL frame's plain one: each widget a card, and the plain HTML a widget writes (a list, a form, inputs, buttons, an output)
|
|
72
|
+
* styled inside it, light or dark with the person's system. Every selector is under `.widget`, so the page around the widgets keeps its own styles,
|
|
73
|
+
* and a widget's own `<style>` still wins (it comes later on the page).
|
|
74
|
+
*/
|
|
75
|
+
export const WIDGET_STYLES = `.widget{--widget-bg:#fff;--widget-surface:#f7f7f9;--widget-border:#e2e2e8;--widget-text:#18181b;--widget-dim:#6b6b75;--widget-accent:#5468ff;--widget-accent-dim:rgba(84,104,255,.18);color-scheme:light;margin:16px 0;padding:16px 18px;border:1px solid var(--widget-border);border-radius:12px;background:var(--widget-surface);color:var(--widget-text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
76
|
+
@media (prefers-color-scheme:dark){.widget{--widget-bg:#0b0b0d;--widget-surface:#18181b;--widget-border:#2a2a2f;--widget-text:#e8e8ea;--widget-dim:#8b8b93;color-scheme:dark}}
|
|
77
|
+
.widget h2{margin:0 0 12px;font-size:15px;font-weight:600}
|
|
78
|
+
.widget p{margin:0 0 10px}
|
|
79
|
+
.widget p:last-child,.widget ul:last-child,.widget ol:last-child,.widget form:last-child{margin-bottom:0}
|
|
80
|
+
.widget ul,.widget ol{list-style:none;margin:0 0 12px;padding:0}
|
|
81
|
+
.widget li{display:flex;align-items:center;gap:8px;padding:7px 0;border-top:1px solid var(--widget-border);overflow-wrap:anywhere}
|
|
82
|
+
.widget li:first-child{border-top:0}
|
|
83
|
+
.widget li>:last-child:is(button,form){margin-left:auto}
|
|
84
|
+
.widget a{color:var(--widget-accent);text-decoration:none}
|
|
85
|
+
.widget a:hover{text-decoration:underline}
|
|
86
|
+
.widget form{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 10px}
|
|
87
|
+
.widget input,.widget textarea,.widget select{flex:1 1 140px;min-width:0;padding:7px 10px;border:1px solid var(--widget-border);border-radius:8px;background:var(--widget-bg);color:inherit;font:inherit}
|
|
88
|
+
.widget input::placeholder,.widget textarea::placeholder{color:var(--widget-dim)}
|
|
89
|
+
.widget input:focus,.widget textarea:focus,.widget select:focus{outline:2px solid var(--widget-accent-dim);border-color:var(--widget-accent)}
|
|
90
|
+
.widget button{padding:7px 14px;border:0;border-radius:8px;background:var(--widget-accent);color:#fff;font:inherit;font-weight:500;cursor:pointer}
|
|
91
|
+
.widget button:hover{filter:brightness(1.1)}
|
|
92
|
+
.widget li button{flex:none;padding:3px 10px;border:1px solid var(--widget-border);background:transparent;color:var(--widget-dim);font-weight:400}
|
|
93
|
+
.widget output{font-weight:600;font-variant-numeric:tabular-nums}
|
|
94
|
+
.widget [role=alert]{color:#d9480f}`
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The widgets as one block of the page: their styles ({@link WIDGET_STYLES}), then a `<section data-widget="<id>">` with the heading and the
|
|
98
|
+
* widget's HTML for each; nothing at all when there is no widget. A widget whose render throws shows one line saying so, and the page still renders.
|
|
99
|
+
*/
|
|
100
|
+
export async function renderWidgets(home: Home): Promise<string> {
|
|
101
|
+
const sections = await Promise.all(
|
|
102
|
+
home.widgets().map(async (widget) => {
|
|
103
|
+
let body: string
|
|
104
|
+
try {
|
|
105
|
+
body = await widget.render()
|
|
106
|
+
} catch (cause) {
|
|
107
|
+
body = `<p role="alert">This widget could not be shown: ${escapeHtml(cause instanceof Error ? cause.message : String(cause))}</p>`
|
|
108
|
+
}
|
|
109
|
+
return `<section class="widget" data-widget="${escapeHtml(widget.id)}">\n<h2>${escapeHtml(widget.title)}</h2>\n${body}\n</section>`
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
return sections.length === 0
|
|
113
|
+
? ''
|
|
114
|
+
: [`<style data-home-widgets>${WIDGET_STYLES}</style>`, ...sections].join('\n')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Provides `ctx.home`. Mount it before the routes that render the home page and before the plugins that put widgets on it. */
|
|
118
|
+
export const name = 'home'
|
|
119
|
+
|
|
120
|
+
export function apply(ctx: Context): void {
|
|
121
|
+
ctx.provide('home', createHome())
|
|
122
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "home",
|
|
3
|
+
"description": "The app's home page as a place plugins put their widgets: a plugin registers a widget in ctx.effect, the page renders it, and it leaves with the plugin.",
|
|
4
|
+
"requires": [],
|
|
5
|
+
"inject": [],
|
|
6
|
+
"provides": ["home"],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4"
|
|
9
|
+
},
|
|
10
|
+
"mount": "a row `- id: home` / `name: home` before the routes row in blend.yaml; the routes render renderWidgets(ctx.home) on the home page; a plugin does ctx.effect(() => ctx.home.widget({ id, title, render }))"
|
|
11
|
+
}
|
|
@@ -30,6 +30,11 @@ export interface AppInstance {
|
|
|
30
30
|
readonly root: string
|
|
31
31
|
/** The app's own data: settings, installed extensions, the extension ledger. Never shared with another app. */
|
|
32
32
|
readonly home: string
|
|
33
|
+
/**
|
|
34
|
+
* The host's engine home: where the engine keeps its own files, the logs included. ACRYL's plugins read it (the support download serves `dshHome/logs`);
|
|
35
|
+
* in this host the engine and the app share one home, so it equals `home`.
|
|
36
|
+
*/
|
|
37
|
+
readonly dshHome: string
|
|
33
38
|
readonly port: PortPreference
|
|
34
39
|
/** The interface to listen on. Absent means loopback only; a container sets `0.0.0.0` so its published port reaches the app. */
|
|
35
40
|
readonly host?: string
|
|
@@ -84,6 +89,7 @@ function family(input: {
|
|
|
84
89
|
name: input.name,
|
|
85
90
|
root: input.root,
|
|
86
91
|
home: input.home,
|
|
92
|
+
dshHome: input.home,
|
|
87
93
|
port: Object.freeze({ ...input.port }),
|
|
88
94
|
runLockFile: join(input.home, RUN_LOCK_FILE),
|
|
89
95
|
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's own plugins: the folders of `plugins/` that are not library copies (`plugins/library.json` lists those). A feature is one of them, and
|
|
3
|
+
* `blend.yaml` mounts it with a row of its own (`- id: notes` / `name: notes`), with no import in `src/app.ts`: a row whose name the static registry
|
|
4
|
+
* does not have resolves to the folder of that name here. So `blend.yaml` plus `plugins/` is the whole composition, and a version, a shared app or a
|
|
5
|
+
* blueprint made from the folder starts the same plugins.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { basename, join } from 'node:path'
|
|
9
|
+
import { pathToFileURL } from 'node:url'
|
|
10
|
+
|
|
11
|
+
/** The file a plugin folder starts from, in the order they are looked for. */
|
|
12
|
+
export const PLUGIN_ENTRIES = ['index.ts', 'index.js', 'index.mjs'] as const
|
|
13
|
+
/** A plugin's name: its folder's name and its Loader row id. */
|
|
14
|
+
const NAME = /^[a-z0-9][a-z0-9-]*$/
|
|
15
|
+
|
|
16
|
+
/** The library copies a plugins folder holds (`library.json`, written by `acrylblends add`); absent or unreadable means none. */
|
|
17
|
+
export function libraryCopies(dir: string): ReadonlySet<string> {
|
|
18
|
+
try {
|
|
19
|
+
const listed = (
|
|
20
|
+
JSON.parse(readFileSync(join(dir, 'library.json'), 'utf8')) as { plugins?: unknown }
|
|
21
|
+
).plugins
|
|
22
|
+
return new Set(
|
|
23
|
+
Array.isArray(listed)
|
|
24
|
+
? listed.filter((name): name is string => typeof name === 'string')
|
|
25
|
+
: [],
|
|
26
|
+
)
|
|
27
|
+
} catch {
|
|
28
|
+
return new Set()
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The entry file of a plugin folder, or undefined when it has none. */
|
|
33
|
+
export function pluginEntry(dir: string): string | undefined {
|
|
34
|
+
return PLUGIN_ENTRIES.map((file) => join(dir, file)).find((path) => existsSync(path))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The names of the app's own plugins in `dir` (the app's `plugins/`): folders with an entry file that are not library copies, sorted. */
|
|
38
|
+
export function appPluginNames(dir: string): string[] {
|
|
39
|
+
if (!existsSync(dir)) return []
|
|
40
|
+
const library = libraryCopies(dir)
|
|
41
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
42
|
+
.filter(
|
|
43
|
+
(entry) =>
|
|
44
|
+
entry.isDirectory() &&
|
|
45
|
+
NAME.test(entry.name) &&
|
|
46
|
+
!library.has(entry.name) &&
|
|
47
|
+
pluginEntry(join(dir, entry.name)) !== undefined,
|
|
48
|
+
)
|
|
49
|
+
.map((entry) => entry.name)
|
|
50
|
+
.sort()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A plugin module as a row mounts it. */
|
|
54
|
+
export interface AppPluginModule {
|
|
55
|
+
readonly name: string
|
|
56
|
+
readonly apply: (...args: never[]) => unknown
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Import the app's own plugins `names` from `dir`, each checked to be a Cordis plugin whose `name` is its folder's.
|
|
61
|
+
* @throws an error that names the folder and the fix, for a folder that does not export a plugin.
|
|
62
|
+
*/
|
|
63
|
+
export async function loadAppPlugins(
|
|
64
|
+
dir: string,
|
|
65
|
+
names: readonly string[],
|
|
66
|
+
): Promise<Record<string, AppPluginModule>> {
|
|
67
|
+
const loaded: Record<string, AppPluginModule> = {}
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const entry = pluginEntry(join(dir, name))
|
|
70
|
+
if (entry === undefined) throw new Error(`plugins/${name} has no ${PLUGIN_ENTRIES.join(', ')}`)
|
|
71
|
+
const module = (await import(pathToFileURL(entry).href)) as Partial<AppPluginModule> & {
|
|
72
|
+
default?: Partial<AppPluginModule>
|
|
73
|
+
}
|
|
74
|
+
const plugin = typeof module.apply === 'function' ? module : module.default
|
|
75
|
+
if (plugin === undefined || typeof plugin.apply !== 'function')
|
|
76
|
+
throw new Error(
|
|
77
|
+
`plugins/${name}/${basename(entry)} must export a Cordis plugin: export const name = '${name}' and export function apply(ctx) { ... }`,
|
|
78
|
+
)
|
|
79
|
+
if (plugin.name !== undefined && plugin.name !== name)
|
|
80
|
+
throw new Error(
|
|
81
|
+
`plugins/${name} exports the plugin name "${String(plugin.name)}"; a plugin's name is its folder's, "${name}"`,
|
|
82
|
+
)
|
|
83
|
+
loaded[name] = { ...plugin, name } as AppPluginModule
|
|
84
|
+
}
|
|
85
|
+
return loaded
|
|
86
|
+
}
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* which of this Blend's overrides and rows it breaks.
|
|
14
14
|
*
|
|
15
15
|
* Problems are diagnostics with a code and a path, never a bare "invalid". The code an app can use stays in `src/app.ts` as a static
|
|
16
|
-
* registry of imports (so it type-checks); the manifest chooses, orders, configures and switches those plugins.
|
|
17
|
-
* listed here: they come and go
|
|
16
|
+
* registry of imports (so it type-checks); the manifest chooses, orders, configures and switches those plugins. A row may also name one of the
|
|
17
|
+
* app's own plugin folders (`plugins/<name>/`, app-plugins.ts): that is how a feature is mounted. Extensions are not listed here: they come and go
|
|
18
|
+
* while the app runs.
|
|
18
19
|
*/
|
|
19
20
|
import { createHash } from 'node:crypto'
|
|
20
21
|
import { existsSync, readFileSync } from 'node:fs'
|
|
@@ -633,3 +634,12 @@ export function blueprintsIn(directory: string): (id: string) => string | undefi
|
|
|
633
634
|
return DEFINITION_ID.test(id) && existsSync(file) ? file : undefined
|
|
634
635
|
}
|
|
635
636
|
}
|
|
637
|
+
|
|
638
|
+
export {
|
|
639
|
+
type AppPluginModule,
|
|
640
|
+
appPluginNames,
|
|
641
|
+
libraryCopies,
|
|
642
|
+
loadAppPlugins,
|
|
643
|
+
PLUGIN_ENTRIES,
|
|
644
|
+
pluginEntry,
|
|
645
|
+
} from './app-plugins.ts'
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@deepseek-ai/cordis": "4.0.4",
|
|
9
9
|
"yaml": "2.9.0",
|
|
10
|
-
"@webboxes/blends-core": "0.
|
|
10
|
+
"@webboxes/blends-core": "0.5.0"
|
|
11
11
|
},
|
|
12
12
|
"mount": "in src/app.ts: const manifest = loadManifest(join(chosen.root, 'blend.yaml')); await compose(root, manifest, registry)"
|
|
13
13
|
}
|