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.
- package/README.md +5 -2
- package/dist/index.d.ts +8 -5
- package/dist/index.js +97 -38
- package/dist/index.js.map +1 -1
- package/legacy/README.md +1 -1
- package/legacy/agent/46c6eadf9230/src/app.ts +293 -0
- package/legacy/agent/46c6eadf9230/src/routes.ts +163 -0
- package/legacy/layouts.json +26 -0
- package/lib/{client-modules-Bkdr83jO.js → client-modules-C5qiVroz.js} +1 -1
- package/lib/cordis/door.d.ts +27 -6
- package/lib/cordis/door.js +41 -10
- package/lib/node-server/register.js +6 -1
- package/lib/node-server.js +1 -1
- package/lib/{transpile-QUK9b3eG.js → transpile-CRRaixyS.js} +19 -4
- package/package.json +1 -1
- package/plugins/acryl-door/src/cli/acryl.ts +13 -4
- package/plugins/acryl-door/src/host/door.ts +41 -3
- package/plugins/acryl-door/src/host/verbs.ts +3 -3
- package/plugins/acryl-node-server/src/register.ts +10 -1
- package/plugins/acryl-node-server/src/transpile.ts +25 -6
- package/plugins/acryl-page/src/favicon.ts +19 -0
- package/plugins/acryl-page/src/index.ts +18 -13
- package/plugins/agent-panel/client/main.ts +9 -0
- package/plugins/agent-panel/headless.ts +2 -2
- package/plugins/desktop/app-page.ts +151 -0
- package/plugins/desktop/assets/acrylblends-logo.png +0 -0
- package/plugins/desktop/brand.ts +164 -0
- package/plugins/desktop/frame.ts +88 -7
- package/plugins/desktop/git.ts +44 -0
- package/plugins/desktop/host-supervisor.ts +63 -0
- package/plugins/desktop/launch.ts +17 -1
- package/plugins/desktop/main.ts +268 -17
- package/plugins/desktop/preload.cjs +8 -0
- package/plugins/desktop/product.ts +117 -0
- package/plugins/desktop/window.ts +4 -0
- package/plugins/desktop-web/main.ts +3 -2
- package/plugins/desktop-web/plugin.json +1 -1
- package/plugins/extensions/index.ts +64 -13
- package/plugins/instance/archive.ts +395 -0
- package/plugins/instance/bundle-overlay.ts +10 -0
- package/plugins/instance/bundle.ts +201 -0
- package/plugins/instance/home.ts +386 -0
- package/plugins/instance/index.ts +132 -24
- package/plugins/instance/storage.ts +413 -0
- package/plugins/plugin-share/index.ts +99 -1
- package/plugins/plugin-share/publish.ts +17 -3
- package/plugins/plugin-share/pull-check.ts +2 -0
- package/plugins/plugin-share/pull-list.ts +71 -0
- package/plugins/showcase/cards.ts +162 -0
- package/plugins/showcase/index.ts +116 -0
- package/plugins/showcase/plugin.json +11 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +1 -1
- package/templates/agent/AGENTS.md +4 -0
- package/templates/agent/README.md +1 -1
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/gitignore +2 -0
- package/templates/agent/src/app.ts +2 -0
- package/templates/agent/src/routes.ts +23 -5
- package/templates/agent/template.json +1 -0
- package/templates/agent/test/app.test.ts +26 -0
- package/templates/blank/README.md +1 -1
- package/templates/blank/gitignore +2 -0
- package/templates/blank/src/routes.ts +9 -5
- package/templates/chat/gitignore +2 -0
- package/templates/chat/src/chat-app.ts +7 -3
- package/templates/chat/src/llm.ts +7 -1
- package/templates/chat/test/chat.test.ts +16 -1
- package/templates/nodejs/gitignore +2 -0
- package/templates/notes/gitignore +2 -0
- package/templates/todo/README.md +1 -1
- package/templates/todo/gitignore +2 -0
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
readFileSync,
|
|
11
11
|
renameSync,
|
|
12
12
|
rmSync,
|
|
13
|
+
statSync,
|
|
13
14
|
writeFileSync,
|
|
14
15
|
} from 'node:fs'
|
|
15
16
|
import { dirname, join, resolve } from 'node:path'
|
|
@@ -54,6 +55,7 @@ import {
|
|
|
54
55
|
refuse,
|
|
55
56
|
splitDigest,
|
|
56
57
|
} from './pull-check.ts'
|
|
58
|
+
import { LIST_MAX_BYTES, parsePullList, pullListLocation } from './pull-list.ts'
|
|
57
59
|
import { type PulledRecord, pulledRecords, withPulledRecord } from './pulled-records.ts'
|
|
58
60
|
|
|
59
61
|
export type {
|
|
@@ -76,6 +78,8 @@ export interface PluginShareConfig {
|
|
|
76
78
|
releases?: ReleasePort
|
|
77
79
|
/** Where a publish goes. Defaults to the person's own `gh` and `git`. */
|
|
78
80
|
publish?: PublishPorts
|
|
81
|
+
/** How an https address is read: a release `.tgz` and a list of sources. Defaults to HTTPS with the hub's size and time limits. */
|
|
82
|
+
download?: (url: string) => Promise<Uint8Array>
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
export type PullResult =
|
|
@@ -94,6 +98,19 @@ export type PullResult =
|
|
|
94
98
|
}
|
|
95
99
|
| PullRefused
|
|
96
100
|
|
|
101
|
+
/** One source of a list and what pulling it did. */
|
|
102
|
+
export interface PullListEntry {
|
|
103
|
+
/** Its line in the list. */
|
|
104
|
+
readonly line: number
|
|
105
|
+
readonly source: string
|
|
106
|
+
readonly result: PullResult
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A list pulled: every source with its own result (some may be refused while the others install), or the list itself refused. */
|
|
110
|
+
export type PullListResult =
|
|
111
|
+
| { readonly ok: true; readonly from: string; readonly entries: readonly PullListEntry[] }
|
|
112
|
+
| PullRefused
|
|
113
|
+
|
|
97
114
|
/** One plugin installed while the app runs, as a person reads the list: what it is and, for one pulled, where it came from and who released it. */
|
|
98
115
|
/** What extensions says about an installed plugin (its state, the services a pending one waits for, ...), and where a pulled one came from. */
|
|
99
116
|
export interface SharedPluginInfo extends ExtensionInfo {
|
|
@@ -101,6 +118,8 @@ export interface SharedPluginInfo extends ExtensionInfo {
|
|
|
101
118
|
readonly pulledFrom?: string
|
|
102
119
|
/** Who released it: the account of a `github:` source or of a release address on github.com. */
|
|
103
120
|
readonly author?: string
|
|
121
|
+
/** The version its `package.json` names (`version` is the installed content's short digest); absent without one. */
|
|
122
|
+
readonly release?: string
|
|
104
123
|
}
|
|
105
124
|
|
|
106
125
|
/** What removing a plugin did with its folder in `plugins/`. */
|
|
@@ -130,6 +149,20 @@ declare module '@deepseek-ai/cordis' {
|
|
|
130
149
|
const authorOf = (source: string): string | undefined =>
|
|
131
150
|
(/^github:([^/]+)\//u.exec(source) ?? /^https:\/\/github\.com\/([^/]+)\//u.exec(source))?.[1]
|
|
132
151
|
|
|
152
|
+
/** The version a plugin folder's `package.json` names, when it names a plain one. */
|
|
153
|
+
function releaseOf(folder: string): string | undefined {
|
|
154
|
+
try {
|
|
155
|
+
const version = (
|
|
156
|
+
JSON.parse(readFileSync(join(folder, 'package.json'), 'utf8')) as { version?: unknown }
|
|
157
|
+
).version
|
|
158
|
+
return typeof version === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/u.test(version)
|
|
159
|
+
? version
|
|
160
|
+
: undefined
|
|
161
|
+
} catch {
|
|
162
|
+
return undefined
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
133
166
|
/** The license a new public plugin is offered under; the plan shows it before the person says yes. */
|
|
134
167
|
const DEFAULT_LICENSE = 'MIT'
|
|
135
168
|
const LICENSE_ID = /^[A-Za-z0-9][A-Za-z0-9.+-]*$/u
|
|
@@ -176,6 +209,7 @@ export class PluginShare extends Service {
|
|
|
176
209
|
readonly restored: Promise<readonly RestoreOutcome[]>
|
|
177
210
|
private readonly dir: string
|
|
178
211
|
private readonly releases: ReleasePort
|
|
212
|
+
private readonly download: (url: string) => Promise<Uint8Array>
|
|
179
213
|
private readonly ports: PublishPorts
|
|
180
214
|
/** One at a time: two pulls of one name must not race for its folder, nor two publishes for its package. */
|
|
181
215
|
private queue: Promise<unknown> = Promise.resolve()
|
|
@@ -193,6 +227,7 @@ export class PluginShare extends Service {
|
|
|
193
227
|
this.own = ctx
|
|
194
228
|
this.dir = resolve(config.dir ?? join(ctx.appInstance.root, 'plugins'))
|
|
195
229
|
this.releases = config.releases ?? githubReleasesOverHttps()
|
|
230
|
+
this.download = config.download ?? httpsDownload()
|
|
196
231
|
this.ports = config.publish ?? { host: githubCli(), git: gitCli() }
|
|
197
232
|
// Removing a plugin removes its record. Listened to here, not asked of each caller: every way of removing it goes through extensions.
|
|
198
233
|
ctx.on('extensions/changed', (name, change) => {
|
|
@@ -209,6 +244,67 @@ export class PluginShare extends Service {
|
|
|
209
244
|
return this.inTurn(() => this.pullNow(source))
|
|
210
245
|
}
|
|
211
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Pull every source of a list, a file in the app or an https address (a gist, a file in the group's repository): one after the other and each on
|
|
249
|
+
* its own, so a source that is refused, or clashes with a plugin already here, is reported with its reason while the others install and keep running.
|
|
250
|
+
* A list that cannot be read, or has a line that is not one source, pulls nothing.
|
|
251
|
+
*/
|
|
252
|
+
async pullList(from: string): Promise<PullListResult> {
|
|
253
|
+
const text = await this.readList(from)
|
|
254
|
+
if (typeof text !== 'string') return text
|
|
255
|
+
const { sources, problems } = parsePullList(text)
|
|
256
|
+
if (problems.length > 0)
|
|
257
|
+
return refuse('list', `${from}: ${problems.join('; ')}`, 'fix the list; nothing was pulled')
|
|
258
|
+
if (sources.length === 0)
|
|
259
|
+
return refuse(
|
|
260
|
+
'list',
|
|
261
|
+
`${from} names no plugin`,
|
|
262
|
+
'write one source per line, e.g. github:someone/pomodoro@0.1.0',
|
|
263
|
+
)
|
|
264
|
+
const entries: PullListEntry[] = []
|
|
265
|
+
for (const { line, source } of sources)
|
|
266
|
+
entries.push({ line, source, result: await this.pull(source) })
|
|
267
|
+
return { ok: true, from, entries }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The text of a list, or why it cannot be read. */
|
|
271
|
+
private async readList(from: string): Promise<string | PullRefused> {
|
|
272
|
+
const where = pullListLocation(from)
|
|
273
|
+
if (where === undefined)
|
|
274
|
+
return refuse(
|
|
275
|
+
'list',
|
|
276
|
+
`"${from}" is not a list`,
|
|
277
|
+
'name a file in the app (e.g. group.txt) or an https address of one',
|
|
278
|
+
)
|
|
279
|
+
try {
|
|
280
|
+
let bytes: Uint8Array
|
|
281
|
+
if (where.kind === 'address') bytes = await this.download(where.url)
|
|
282
|
+
else {
|
|
283
|
+
const path = resolve(this.own.appInstance.root, where.path)
|
|
284
|
+
if (!existsSync(path) || !statSync(path).isFile())
|
|
285
|
+
return refuse(
|
|
286
|
+
'list',
|
|
287
|
+
`there is no file ${where.path}`,
|
|
288
|
+
'name a file in the app, or an https address',
|
|
289
|
+
)
|
|
290
|
+
bytes = readFileSync(path)
|
|
291
|
+
}
|
|
292
|
+
if (bytes.length > LIST_MAX_BYTES)
|
|
293
|
+
return refuse(
|
|
294
|
+
'list',
|
|
295
|
+
`${from} is larger than ${String(LIST_MAX_BYTES)} bytes, so it is not a list of plugins`,
|
|
296
|
+
'name the list itself',
|
|
297
|
+
)
|
|
298
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
299
|
+
} catch (cause) {
|
|
300
|
+
return refuse(
|
|
301
|
+
'list',
|
|
302
|
+
`${from} could not be read: ${reasonOf(cause)}`,
|
|
303
|
+
'check the address; nothing was pulled',
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
212
308
|
/**
|
|
213
309
|
* The content digest of a pulled plugin as it was installed: set before its folder appears, so whoever sees the folder change can tell the release as
|
|
214
310
|
* pulled from an edit (the frame then does not load the app again for it). A pull from a local file has no record in `blend.yaml`; this knows it too.
|
|
@@ -224,10 +320,12 @@ export class PluginShare extends Service {
|
|
|
224
320
|
return this.own.extensions.list().map((info) => {
|
|
225
321
|
const record = records[info.name]
|
|
226
322
|
const author = record === undefined ? undefined : authorOf(record.source)
|
|
323
|
+
const release = releaseOf(join(this.dir, info.name))
|
|
227
324
|
return {
|
|
228
325
|
...info,
|
|
229
326
|
...(record === undefined ? {} : { pulledFrom: record.source }),
|
|
230
327
|
...(author === undefined ? {} : { author }),
|
|
328
|
+
...(release === undefined ? {} : { release }),
|
|
231
329
|
}
|
|
232
330
|
})
|
|
233
331
|
}
|
|
@@ -457,7 +555,7 @@ export class PluginShare extends Service {
|
|
|
457
555
|
{
|
|
458
556
|
store: new PluginStore(scratch),
|
|
459
557
|
releases: this.releases,
|
|
460
|
-
download:
|
|
558
|
+
download: this.download,
|
|
461
559
|
},
|
|
462
560
|
expectIntegrity === undefined ? {} : { expectIntegrity },
|
|
463
561
|
)
|
|
@@ -58,8 +58,22 @@ export interface PublishPorts {
|
|
|
58
58
|
export const pullLineOf = (owner: string, repo: string, version: string, digest: string): string =>
|
|
59
59
|
`npm run acryl -- plugin pull github:${owner}/${repo}@${version}@sha256:${digest}`
|
|
60
60
|
|
|
61
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* The same plan: what the person confirmed is what would be published now. `released` only explains the version, and is gone once the next one is packaged.
|
|
63
|
+
* Field by field, so a plan that came back through JSON from another surface (the frame's confirm) is the same plan whatever order its fields arrived in.
|
|
64
|
+
*/
|
|
62
65
|
export function samePlan(a: PublishPlan, b: PublishPlan): boolean {
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
return (
|
|
67
|
+
a.owner === b.owner &&
|
|
68
|
+
a.repo === b.repo &&
|
|
69
|
+
a.version === b.version &&
|
|
70
|
+
a.visibility === b.visibility &&
|
|
71
|
+
a.license === b.license &&
|
|
72
|
+
a.createsRepository === b.createsRepository &&
|
|
73
|
+
a.integrity === b.integrity &&
|
|
74
|
+
a.digest === b.digest &&
|
|
75
|
+
a.pullLine === b.pullLine &&
|
|
76
|
+
a.files.length === b.files.length &&
|
|
77
|
+
a.files.every((file, index) => file === b.files[index])
|
|
78
|
+
)
|
|
65
79
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A group's list of plugins: one source per line, as each person sends it (`github:<owner>/<repo>@<version>@sha256:<digest>`, or the whole line
|
|
3
|
+
* `plugin publish` prints). The list is text a person or a group keeps (a file in the app, a gist, a file in a repository), so reading it is
|
|
4
|
+
* forgiving about comments, blank lines and the pasted command, and strict about anything else: a line that is not one source is named, not guessed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A list is a few lines of text: anything larger is not a list of plugins. */
|
|
8
|
+
export const LIST_MAX_BYTES = 64 * 1024
|
|
9
|
+
|
|
10
|
+
/** More sources than a group has people is a mistake, not a workshop. */
|
|
11
|
+
export const LIST_MAX_SOURCES = 100
|
|
12
|
+
|
|
13
|
+
/** The command a person pastes from `plugin publish`: only its source is kept. */
|
|
14
|
+
const PULL_COMMAND = /^(?:npm run acryl --\s+)?plugin pull\s+/u
|
|
15
|
+
|
|
16
|
+
/** A `#` at the start of a line or after a space begins a comment; a source never holds one there. */
|
|
17
|
+
const COMMENT = /(?:^|\s)#.*$/u
|
|
18
|
+
|
|
19
|
+
export interface PullListLine {
|
|
20
|
+
/** Its line in the list, from 1, so a problem is easy to find. */
|
|
21
|
+
readonly line: number
|
|
22
|
+
readonly source: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ParsedPullList {
|
|
26
|
+
/** The sources in their order, each once. */
|
|
27
|
+
readonly sources: readonly PullListLine[]
|
|
28
|
+
/** Lines that are not one source, each with what is wrong. */
|
|
29
|
+
readonly problems: readonly string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The sources of a list, in order and without repeats, and the lines that are not a source. */
|
|
33
|
+
export function parsePullList(text: string): ParsedPullList {
|
|
34
|
+
const sources: PullListLine[] = []
|
|
35
|
+
const problems: string[] = []
|
|
36
|
+
const seen = new Set<string>()
|
|
37
|
+
text.split(/\r?\n/u).forEach((raw, index) => {
|
|
38
|
+
const line = index + 1
|
|
39
|
+
const value = raw.replace(COMMENT, '').trim().replace(PULL_COMMAND, '').trim()
|
|
40
|
+
if (value === '') return
|
|
41
|
+
if (/\s/u.test(value)) {
|
|
42
|
+
problems.push(
|
|
43
|
+
`line ${String(line)} holds more than one word ("${value}"); write one source per line`,
|
|
44
|
+
)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
if (seen.has(value)) return
|
|
48
|
+
seen.add(value)
|
|
49
|
+
sources.push({ line, source: value })
|
|
50
|
+
})
|
|
51
|
+
if (sources.length > LIST_MAX_SOURCES)
|
|
52
|
+
problems.push(
|
|
53
|
+
`the list has ${String(sources.length)} sources; a list holds at most ${String(LIST_MAX_SOURCES)}`,
|
|
54
|
+
)
|
|
55
|
+
return { sources, problems }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Where a list is read from: an https address, or a file (relative to the app folder). */
|
|
59
|
+
export type PullListLocation =
|
|
60
|
+
| { readonly kind: 'address'; readonly url: string }
|
|
61
|
+
| { readonly kind: 'file'; readonly path: string }
|
|
62
|
+
|
|
63
|
+
/** @returns undefined for something that is neither (an http: address, a github: source): the caller names what a list may be. */
|
|
64
|
+
export function pullListLocation(from: string): PullListLocation | undefined {
|
|
65
|
+
const value = from.trim()
|
|
66
|
+
if (value === '') return undefined
|
|
67
|
+
if (/^https:\/\//iu.test(value)) return { kind: 'address', url: value }
|
|
68
|
+
// A drive letter (C:\...) is a path; any other scheme is not a place a list is read from.
|
|
69
|
+
if (/^[a-z][a-z0-9+.-]+:/iu.test(value) && !/^[a-z]:[\\/]/iu.test(value)) return undefined
|
|
70
|
+
return { kind: 'file', path: value }
|
|
71
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The showcase's cards: what each plugin in the app is, who made it and where to find what it adds. Pure, so the page, the stream and the tests
|
|
3
|
+
* render the same thing.
|
|
4
|
+
*/
|
|
5
|
+
import type { SharedPluginInfo } from '../plugin-share/index.ts'
|
|
6
|
+
|
|
7
|
+
/** One plugin as the showcase shows it. */
|
|
8
|
+
export interface ShowcaseCard {
|
|
9
|
+
readonly name: string
|
|
10
|
+
/** The release's version, or the installed content's short digest for a plugin without one. */
|
|
11
|
+
readonly version: string
|
|
12
|
+
readonly state: string
|
|
13
|
+
/** The GitHub login of whoever released it; absent for a plugin made in this app or pulled from a local file. */
|
|
14
|
+
readonly author?: string
|
|
15
|
+
/** Where it came from: the pulled source, or `this app`. */
|
|
16
|
+
readonly source: string
|
|
17
|
+
/** The pages it serves (`GET` routes without parameters), as the plugin wrote them. */
|
|
18
|
+
readonly pages: readonly string[]
|
|
19
|
+
/** It has a widget on the home page. */
|
|
20
|
+
readonly widget: boolean
|
|
21
|
+
/** The tool of its name, when it registered one. */
|
|
22
|
+
readonly tool?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What else the app knows about its plugins: the tools registered and the home page's widgets, by name. */
|
|
26
|
+
export interface Offered {
|
|
27
|
+
readonly tools: readonly string[]
|
|
28
|
+
readonly widgets: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A GitHub login: what `author` must look like before the page links to github.com. */
|
|
32
|
+
const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/u
|
|
33
|
+
|
|
34
|
+
/** A path a person can open: no `:param`, no wildcard. */
|
|
35
|
+
const PAGE = /^\/[^:*]*$/u
|
|
36
|
+
|
|
37
|
+
/** The cards, one per plugin, in the order the app lists them. */
|
|
38
|
+
export function showcaseCards(
|
|
39
|
+
plugins: readonly SharedPluginInfo[],
|
|
40
|
+
offered: Offered,
|
|
41
|
+
): ShowcaseCard[] {
|
|
42
|
+
return plugins.map((plugin) => {
|
|
43
|
+
const pages = [
|
|
44
|
+
...new Set(
|
|
45
|
+
plugin.routes
|
|
46
|
+
.filter((route) => route.method === 'GET' && PAGE.test(route.path))
|
|
47
|
+
.map((route) => route.path),
|
|
48
|
+
),
|
|
49
|
+
]
|
|
50
|
+
return {
|
|
51
|
+
name: plugin.name,
|
|
52
|
+
version: plugin.release ?? plugin.version,
|
|
53
|
+
state: plugin.state,
|
|
54
|
+
...(plugin.author !== undefined && LOGIN.test(plugin.author)
|
|
55
|
+
? { author: plugin.author }
|
|
56
|
+
: {}),
|
|
57
|
+
source: plugin.pulledFrom ?? 'this app',
|
|
58
|
+
pages,
|
|
59
|
+
widget: offered.widgets.includes(plugin.name),
|
|
60
|
+
...(offered.tools.includes(plugin.name) ? { tool: plugin.name } : {}),
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const escapeHtml = (text: string): string =>
|
|
66
|
+
text
|
|
67
|
+
.replaceAll('&', '&')
|
|
68
|
+
.replaceAll('<', '<')
|
|
69
|
+
.replaceAll('>', '>')
|
|
70
|
+
.replaceAll('"', '"')
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The path the app is served at, read from the showcase's own request path: `''` on the app's own server, `/app` in the frame. Links are built from it,
|
|
74
|
+
* because a relative link cannot name the app's root (`./` from `/app/showcase` is `/app/`, which the app does not serve).
|
|
75
|
+
*/
|
|
76
|
+
export function appBase(requestPath: string): string {
|
|
77
|
+
const at = requestPath.lastIndexOf('/showcase')
|
|
78
|
+
return at <= 0 ? '' : requestPath.slice(0, at)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A path of the app as the browser asks for it: under `base`, and the root as `base` itself (or `/`). */
|
|
82
|
+
const inApp = (base: string, path: string): string =>
|
|
83
|
+
path === '/' ? base || '/' : `${base}${path}`
|
|
84
|
+
|
|
85
|
+
/** The cards as HTML: the list the page shows, and what the stream sends on each change. Every value is escaped. */
|
|
86
|
+
export function renderCards(cards: readonly ShowcaseCard[], base = ''): string {
|
|
87
|
+
if (cards.length === 0)
|
|
88
|
+
return '<p class="empty">No plugins yet. Pull one: <code>npm run acryl -- plugin pull github:<owner>/<repo></code>, or a group\'s list with <code>--from group.txt</code>.</p>'
|
|
89
|
+
return cards
|
|
90
|
+
.map((card) => {
|
|
91
|
+
const author =
|
|
92
|
+
card.author === undefined
|
|
93
|
+
? '<span class="dim">made here</span>'
|
|
94
|
+
: `<a href="https://github.com/${escapeHtml(card.author)}" target="_blank" rel="noopener">@${escapeHtml(card.author)}</a>`
|
|
95
|
+
const links = [
|
|
96
|
+
...card.pages.map(
|
|
97
|
+
(page) => `<a href="${escapeHtml(inApp(base, page))}">${escapeHtml(page)}</a>`,
|
|
98
|
+
),
|
|
99
|
+
...(card.widget ? [`<a href="${escapeHtml(inApp(base, '/'))}">home widget</a>`] : []),
|
|
100
|
+
...(card.tool === undefined
|
|
101
|
+
? []
|
|
102
|
+
: [
|
|
103
|
+
`<span title="the app's agent can call it">tool <code>${escapeHtml(card.tool)}</code></span>`,
|
|
104
|
+
]),
|
|
105
|
+
]
|
|
106
|
+
return [
|
|
107
|
+
`<article class="card" data-plugin="${escapeHtml(card.name)}" data-state="${escapeHtml(card.state)}">`,
|
|
108
|
+
`<header><h2>${escapeHtml(card.name)}</h2><p class="meta"><span class="version">${escapeHtml(card.version)}</span><span class="state state-${escapeHtml(card.state)}">${escapeHtml(card.state)}</span></p></header>`,
|
|
109
|
+
`<p class="author">${author}</p>`,
|
|
110
|
+
`<p class="source"><code>${escapeHtml(card.source)}</code></p>`,
|
|
111
|
+
links.length === 0
|
|
112
|
+
? '<p class="dim">no page of its own</p>'
|
|
113
|
+
: `<p class="links">${links.join(' ')}</p>`,
|
|
114
|
+
'</article>',
|
|
115
|
+
].join('')
|
|
116
|
+
})
|
|
117
|
+
.join('\n')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const STYLES = `:root{--bg:#fff;--surface:#f7f7f9;--border:#e2e2e8;--text:#18181b;--dim:#6b6b75;--accent:#5468ff;--ok:#2f9e44;--warn:#d9480f;color-scheme:light}
|
|
121
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0b0b0d;--surface:#18181b;--border:#2a2a2f;--text:#e8e8ea;--dim:#8b8b93;color-scheme:dark}}
|
|
122
|
+
*{box-sizing:border-box}
|
|
123
|
+
body{margin:0;padding:24px 16px;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
124
|
+
main{max-width:1080px;margin:0 auto}
|
|
125
|
+
h1{margin:0 0 4px;font-size:20px}
|
|
126
|
+
.lead{margin:0 0 20px;color:var(--dim)}
|
|
127
|
+
#cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}
|
|
128
|
+
.card{padding:14px 16px;border:1px solid var(--border);border-radius:12px;background:var(--surface);min-width:0}
|
|
129
|
+
.card header{margin-bottom:8px}
|
|
130
|
+
.card h2{margin:0 0 4px;font-size:15px;font-weight:600;overflow-wrap:anywhere}
|
|
131
|
+
.card .meta{display:flex;align-items:center;gap:8px;margin:0}
|
|
132
|
+
.version{color:var(--dim);font-variant-numeric:tabular-nums}
|
|
133
|
+
.state{padding:1px 8px;border-radius:999px;border:1px solid var(--border);font-size:12px;color:var(--dim)}
|
|
134
|
+
.state-active{color:var(--ok);border-color:currentColor}
|
|
135
|
+
.state-failed,.state-pending{color:var(--warn);border-color:currentColor}
|
|
136
|
+
.card p{margin:4px 0}
|
|
137
|
+
.source code{font-size:12px;color:var(--dim);overflow-wrap:anywhere}
|
|
138
|
+
.links{display:flex;flex-wrap:wrap;gap:6px 12px}
|
|
139
|
+
a{color:var(--accent);text-decoration:none}
|
|
140
|
+
a:hover{text-decoration:underline}
|
|
141
|
+
.dim,.empty{color:var(--dim)}
|
|
142
|
+
#live{font-size:12px;color:var(--dim)}`
|
|
143
|
+
|
|
144
|
+
/** The whole page; the script follows the stream and replaces the cards on each change. */
|
|
145
|
+
export function renderPage(cards: readonly ShowcaseCard[], base = ''): string {
|
|
146
|
+
return `<!doctype html>
|
|
147
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
148
|
+
<title>Showcase</title><style>${STYLES}</style></head>
|
|
149
|
+
<body><main>
|
|
150
|
+
<h1>Showcase</h1>
|
|
151
|
+
<p class="lead">Every plugin in this app, who made it and where it came from. <span id="live" role="status"></span></p>
|
|
152
|
+
<section id="cards" aria-live="polite">${renderCards(cards, base)}</section>
|
|
153
|
+
</main>
|
|
154
|
+
<script type="module">
|
|
155
|
+
const cards = document.getElementById('cards')
|
|
156
|
+
const live = document.getElementById('live')
|
|
157
|
+
const events = new EventSource(${JSON.stringify(`${base}/showcase/events`).replaceAll('<', '\\u003c')})
|
|
158
|
+
events.addEventListener('cards', (event) => { cards.innerHTML = event.data; live.textContent = 'live' })
|
|
159
|
+
events.onerror = () => { live.textContent = 'reconnecting...' }
|
|
160
|
+
</script>
|
|
161
|
+
</body></html>`
|
|
162
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The collective app's showcase: every plugin in the app as a card (name, author, version, source, state, links to what it adds) at `/showcase`,
|
|
3
|
+
* updated live as plugins are pulled and removed.
|
|
4
|
+
*
|
|
5
|
+
* A group pulls its members' plugins into one app (`plugin pull --from group.txt`); this page is where they see what the group made. It reads the
|
|
6
|
+
* app's plugins through `pluginShare` (which knows where each pulled one came from), and follows `extensions/changed`: every install, update,
|
|
7
|
+
* restore and removal goes through extensions, so one event covers them all. The stream's open connections end with the plugin.
|
|
8
|
+
*/
|
|
9
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
10
|
+
import { streamSSE } from '../acryl-router/src/helpers/index.ts'
|
|
11
|
+
import { Acryl } from '../acryl-router/src/index.ts'
|
|
12
|
+
import type {} from '../extensions/index.ts'
|
|
13
|
+
import type {} from '../plugin-share/index.ts'
|
|
14
|
+
import {
|
|
15
|
+
appBase,
|
|
16
|
+
type Offered,
|
|
17
|
+
renderCards,
|
|
18
|
+
renderPage,
|
|
19
|
+
type ShowcaseCard,
|
|
20
|
+
showcaseCards,
|
|
21
|
+
} from './cards.ts'
|
|
22
|
+
|
|
23
|
+
export type { ShowcaseCard } from './cards.ts'
|
|
24
|
+
export { appBase, renderCards, showcaseCards } from './cards.ts'
|
|
25
|
+
|
|
26
|
+
export const name = 'showcase'
|
|
27
|
+
export const inject = ['router', 'pluginShare']
|
|
28
|
+
|
|
29
|
+
/** A comment line this often keeps an idle stream open through proxies and browsers. */
|
|
30
|
+
const KEEP_ALIVE_MS = 15_000
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The names an optional service lists, or none when it is absent or is not the service the showcase knows. A name can be someone else's: in the
|
|
34
|
+
* frame `tools` is the host's (the DSH harness's tool registry, with another shape), not the app's agent tools, so the shape is checked, not assumed.
|
|
35
|
+
*/
|
|
36
|
+
function namesOf(service: unknown, method: string, key: string): string[] {
|
|
37
|
+
const list = (service as Record<string, unknown> | undefined)?.[method]
|
|
38
|
+
if (typeof list !== 'function') return []
|
|
39
|
+
let entries: unknown
|
|
40
|
+
try {
|
|
41
|
+
entries = (list as () => unknown).call(service)
|
|
42
|
+
} catch {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(entries)) return []
|
|
46
|
+
return entries.flatMap((entry) => {
|
|
47
|
+
const value = (entry as Record<string, unknown> | null)?.[key]
|
|
48
|
+
return typeof value === 'string' ? [value] : []
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The optional services the cards link to, read where they are now: an app without an agent has no tools, one without a home page no widgets. */
|
|
53
|
+
function offered(ctx: Context): Offered {
|
|
54
|
+
const get = ctx.get as (service: string) => unknown
|
|
55
|
+
return {
|
|
56
|
+
tools: namesOf(get('tools'), 'list', 'name'),
|
|
57
|
+
widgets: namesOf(get('home'), 'widgets', 'id'),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function apply(ctx: Context): void {
|
|
62
|
+
const cards = (): ShowcaseCard[] => showcaseCards(ctx.pluginShare.list(), offered(ctx))
|
|
63
|
+
|
|
64
|
+
// The pages that follow the stream; each gets the cards again after every change, rendered for the path it is served at.
|
|
65
|
+
const listeners = new Set<(now: readonly ShowcaseCard[]) => void>()
|
|
66
|
+
const ends = new Set<() => void>()
|
|
67
|
+
ctx.on('extensions/changed', () => {
|
|
68
|
+
if (listeners.size === 0) return
|
|
69
|
+
const now = cards()
|
|
70
|
+
for (const listener of listeners) listener(now)
|
|
71
|
+
})
|
|
72
|
+
ctx.effect(
|
|
73
|
+
() => () => {
|
|
74
|
+
for (const end of ends) end()
|
|
75
|
+
},
|
|
76
|
+
'showcase: end the open streams',
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const app = new Acryl()
|
|
80
|
+
.get('/showcase', (c) =>
|
|
81
|
+
c.html(renderPage(cards(), appBase(c.req.path)), 200, { 'cache-control': 'no-store' }),
|
|
82
|
+
)
|
|
83
|
+
.get('/showcase/plugins', (c) => c.json(cards(), 200, { 'cache-control': 'no-store' }))
|
|
84
|
+
.get('/showcase/events', (c) =>
|
|
85
|
+
streamSSE(c, async (stream) => {
|
|
86
|
+
const base = appBase(c.req.path)
|
|
87
|
+
let queue: Promise<unknown> = Promise.resolve()
|
|
88
|
+
const send = (now: readonly ShowcaseCard[]): void => {
|
|
89
|
+
queue = queue
|
|
90
|
+
.then(() => stream.writeSSE({ event: 'cards', data: renderCards(now, base) }))
|
|
91
|
+
.catch(() => undefined)
|
|
92
|
+
}
|
|
93
|
+
let finish = (): void => undefined
|
|
94
|
+
const ended = new Promise<void>((resolve) => {
|
|
95
|
+
finish = resolve
|
|
96
|
+
stream.onAbort(resolve)
|
|
97
|
+
})
|
|
98
|
+
ends.add(finish)
|
|
99
|
+
listeners.add(send)
|
|
100
|
+
send(cards())
|
|
101
|
+
const keepAlive = setInterval(() => {
|
|
102
|
+
queue = queue.then(() => stream.write(': keepalive\n\n')).catch(() => undefined)
|
|
103
|
+
}, KEEP_ALIVE_MS)
|
|
104
|
+
keepAlive.unref()
|
|
105
|
+
try {
|
|
106
|
+
await ended
|
|
107
|
+
} finally {
|
|
108
|
+
clearInterval(keepAlive)
|
|
109
|
+
listeners.delete(send)
|
|
110
|
+
ends.delete(finish)
|
|
111
|
+
await queue
|
|
112
|
+
}
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
ctx.router.route('/', app)
|
|
116
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "showcase",
|
|
3
|
+
"description": "The collective app's showcase: a page at /showcase with a card for every plugin in the app (name, author, version, where it came from, its state, and links to its pages, its home widget and its tool), updated live as plugins are pulled and removed.",
|
|
4
|
+
"requires": ["acryl-router", "plugin-share", "extensions"],
|
|
5
|
+
"inject": ["router", "pluginShare"],
|
|
6
|
+
"provides": [],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4"
|
|
9
|
+
},
|
|
10
|
+
"mount": "a row `- id: showcase` / `name: showcase` after the plugin-share row in blend.yaml, and 'showcase': showcase in the registry of src/app.ts (acrylblends add showcase does both)"
|
|
11
|
+
}
|
|
@@ -8,5 +8,5 @@ description: Use when the person asks to remove, delete, switch off, hide or und
|
|
|
8
8
|
|
|
9
9
|
Find the exact plugin in `plugins/` and its row in `blend.yaml` (never guess the name). To hide it for now, switch it off: `disabled: true` on
|
|
10
10
|
its row (the switch in Settings > Plugins does the same); everything stays and it comes back when switched on. To remove it, and only when the
|
|
11
|
-
person clearly asked: delete its row, its folder and its test; its data in
|
|
11
|
+
person clearly asked: delete its row, its folder and its test; its data in `~/.acryl/apps/<id>/data/` stays unless they ask. Check with `npm test` and
|
|
12
12
|
`.acryl/bin/acryl look` that it is gone before you say so. Only touch the plugin the person named.
|
|
@@ -5,6 +5,10 @@ Besides `src/routes.ts`, the app runs an agent (`POST /ask`) that can write plug
|
|
|
5
5
|
rollback; `GET /extensions` and `GET /tasks` show what it did. A plugin there is a folder with an `index.ts` exporting `name`, `inject`, `apply(ctx)`
|
|
6
6
|
and an optional `check(ctx)` that receives the plugin's own context (so `ctx.router` reads as in `apply`). It may import `acryl-router`.
|
|
7
7
|
|
|
8
|
+
**A plugin's UI on the home page.** The home page shows the widget of every plugin that registers one (the `home` row): `inject: ['home']` and
|
|
9
|
+
`ctx.effect(() => ctx.home.widget({ id: '<plugin name>', title, render: () => '<p>...</p>' }))`. The widget leaves with its plugin, and a second
|
|
10
|
+
plugin with the same widget id is refused naming the first. A widget is the way a plugin someone else pulls into this app shows up on the first screen.
|
|
11
|
+
|
|
8
12
|
**Write an extension, not a row.** A feature you build while the app runs is a folder `plugins/<name>/` installed with `.acryl/bin/acryl plugin add plugins/<name>` (the `extensions` plugin loads it again at every start). Add a row to `blend.yaml` (`- id: <name>` / `name: <name>`; `src/app.ts` finds the folder by that name) only when the person asks for a plugin that is part of the app itself. Both kinds may import the framework by name (`acrylblends`, `acrylblends/body-limit`, `acryl-router`); a row's relative imports of its sibling plugins also work.
|
|
9
13
|
|
|
10
14
|
**Drive the app you build.** `.acryl/bin/acryl look` reads the open page as text with refs, and `acryl act click|type|press|scroll|wait --ref <ref>` presses its buttons, opens its sidebars and fills its forms (look again before each act: the refs change). `act navigate --path /settings` goes to another page of the app; a path the app does not serve answers `404 at /nope` and the window shows a 404 page, so navigate on from there. In the desktop app (`npm run desktop`) the app's windows and menu are verbs too: `acryl window list`, `acryl window open --path /settings`, `acryl look --window <id>` (and `act ... --window <id>`) to drive that window's page, `acryl window resize|focus|close --id <id>`, `acryl menu list` and `acryl menu click --path "File>New Note"`. Closing the last window and Quit end the desktop app, so they need `--force`: do not use it unless the person asks. The app has a `/settings` page already (the appearance: system, light or dark, kept in the browser) and the desktop app's menu has a Settings item that opens it; add a person's other settings to that page, not to a new one, so `acryl menu click --path "File>Settings..."` and `acryl window open --path /settings` keep opening them (on macOS the item is in the application menu). Without a desktop app these answer "no desktop window is attached; run npm run desktop".
|
|
@@ -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
|
|
8
|
+
npm run dev # prints its URL; data lives in ~/.acryl/apps/<id>/data/; it restarts when a file changes (a new port if ACRYLBLENDS_PORT=0)
|
|
9
9
|
npm test
|
|
10
10
|
npm run check # type-check
|
|
11
11
|
|
|
@@ -39,6 +39,9 @@ spec:
|
|
|
39
39
|
steps: "{{parameters.steps}}"
|
|
40
40
|
- id: router
|
|
41
41
|
name: acryl-router
|
|
42
|
+
# The home page's widgets: a plugin puts its UI on the home page with ctx.home.widget (one pulled from someone else's app too), and it leaves with the plugin.
|
|
43
|
+
- id: home
|
|
44
|
+
name: home
|
|
42
45
|
- id: state
|
|
43
46
|
name: acryl-state
|
|
44
47
|
- id: sandbox
|
|
@@ -10,6 +10,7 @@ import { agent, memory, model, scripted, tools } from '../plugins/agent/index.ts
|
|
|
10
10
|
import * as panel from '../plugins/agent-panel/index.ts'
|
|
11
11
|
import { builder } from '../plugins/builder/index.ts'
|
|
12
12
|
import { extensions } from '../plugins/extensions/index.ts'
|
|
13
|
+
import * as home from '../plugins/home/index.ts'
|
|
13
14
|
import { type AppInstance, instance } from '../plugins/instance/index.ts'
|
|
14
15
|
import {
|
|
15
16
|
appPluginNames,
|
|
@@ -34,6 +35,7 @@ export const registry = {
|
|
|
34
35
|
memory,
|
|
35
36
|
agent,
|
|
36
37
|
'acryl-router': router,
|
|
38
|
+
home,
|
|
37
39
|
'acryl-state': state,
|
|
38
40
|
extensions,
|
|
39
41
|
builder,
|