acrylblends 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -1
- package/dist/index.js +44 -10
- package/dist/index.js.map +1 -1
- package/docs/framework/guides/create-acrylblends.md +4 -2
- package/docs/framework/workshop/README.md +4 -2
- package/docs/framework/workshop/facilitator.md +1 -1
- package/lib/cordis/door.d.ts +54 -2
- package/lib/cordis/door.js +131 -4
- package/package.json +1 -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/door.ts +91 -5
- package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
- package/plugins/acryl-door/src/host/verbs.ts +11 -1
- package/plugins/acryl-door/src/index.ts +2 -1
- package/plugins/builder/index.ts +13 -35
- package/plugins/builder/plugin.json +4 -2
- package/plugins/extensions/index.ts +78 -19
- package/plugins/extensions/plugin-store.ts +27 -0
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/routes.ts +92 -0
- package/plugins/extensions/stage.ts +15 -5
- package/plugins/home/index.ts +3 -1
- package/plugins/manifest/plugin.json +1 -1
- package/plugins/plugin-share/README.md +66 -0
- package/plugins/plugin-share/index.ts +581 -0
- package/plugins/plugin-share/package.ts +186 -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/templates/agent/.gitattributes +2 -0
- package/templates/agent/blend.yaml +2 -0
- package/templates/agent/src/app.ts +6 -0
- package/templates/agent/template.json +1 -0
- package/templates/blank/.gitattributes +2 -0
- package/templates/blank/blend.yaml +3 -0
- package/templates/blank/src/app.ts +8 -0
- package/templates/blank/template.json +1 -0
- package/templates/chat/.gitattributes +2 -0
- package/templates/chat/blend.yaml +3 -0
- package/templates/chat/src/app.ts +8 -0
- package/templates/chat/template.json +1 -0
- package/templates/nodejs/.gitattributes +2 -0
- package/templates/nodejs/blend.yaml +3 -0
- package/templates/nodejs/src/app.ts +8 -0
- package/templates/nodejs/template.json +1 -0
- package/templates/notes/.gitattributes +2 -0
- package/templates/notes/blend.yaml +3 -0
- package/templates/notes/src/app.ts +8 -0
- package/templates/notes/template.json +1 -0
- package/templates/todo/.gitattributes +2 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { Acryl } from 'acryl-router'
|
|
9
9
|
import { UiControlError } from '../page/protocol.ts'
|
|
10
|
-
import { Door, DoorError } from './door.ts'
|
|
10
|
+
import { Door, DoorError, type PublishPlanView } from './door.ts'
|
|
11
11
|
import { error, INVALID_JSON, jsonBody, operatorOnly, readJson, type ReportError } from './http.ts'
|
|
12
12
|
import { VERBS } from './verbs.ts'
|
|
13
13
|
|
|
@@ -35,4 +35,24 @@ export function operatorRoutes(prefix: string, secret: string, door: Door, repor
|
|
|
35
35
|
return c.json(error('the verb could not be run'), 500)
|
|
36
36
|
}
|
|
37
37
|
})
|
|
38
|
+
// A person's yes to a plan, from the prompt in their own terminal. Not a verb, so MCP and the chat do not offer it. An agent with this machine's
|
|
39
|
+
// secret could still reach it: the door keeps honest agents from publishing by accident, the person's account is the real boundary.
|
|
40
|
+
.post(`${prefix}/publish-confirmed`, operatorOnly(secret), jsonBody, async (c) => {
|
|
41
|
+
const body = await readJson(c)
|
|
42
|
+
const { name, confirm, owner, license } = (body === INVALID_JSON || typeof body !== 'object' || body === null ? {} : body) as { name?: unknown; confirm?: unknown; owner?: unknown; license?: unknown }
|
|
43
|
+
if (typeof name !== 'string' || typeof confirm !== 'object' || confirm === null) {
|
|
44
|
+
return c.json({ ok: false, code: 'invalid', message: 'the body is { name, confirm }' } satisfies CallResponse, 400)
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const value = await door.publishConfirmed(name, confirm as PublishPlanView, {
|
|
48
|
+
...(typeof owner === 'string' ? { owner } : {}),
|
|
49
|
+
...(typeof license === 'string' ? { license } : {}),
|
|
50
|
+
})
|
|
51
|
+
return c.json({ ok: true, value, text: JSON.stringify(value, null, 2) } satisfies CallResponse)
|
|
52
|
+
} catch (cause) {
|
|
53
|
+
if (cause instanceof DoorError) return c.json({ ok: false, code: cause.code, message: cause.message } satisfies CallResponse, 409)
|
|
54
|
+
reportError('publish a plugin', cause)
|
|
55
|
+
return c.json(error('the plugin could not be published'), 500)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
38
58
|
}
|
|
@@ -9,7 +9,7 @@ import { MAX_SNAPSHOT_NODES, MAX_WAIT_MS } from '../page/protocol.ts'
|
|
|
9
9
|
/** The verbs that only use the page: what a page-only door (a production assistant) offers. */
|
|
10
10
|
export const PAGE_VERBS = ['look', 'act'] as const satisfies readonly Verb[]
|
|
11
11
|
|
|
12
|
-
export const VERBS = ['brief', 'look', 'act', 'plugin-list', 'plugin-add', 'plugin-remove', 'plugin-reload', 'tool-list', 'tool-call', 'window-list', 'window-open', 'window-focus', 'window-close', 'window-resize', 'menu-list', 'menu-click'] as const
|
|
12
|
+
export const VERBS = ['brief', 'look', 'act', 'plugin-list', 'plugin-add', 'plugin-pull', 'plugin-publish', 'plugin-remove', 'plugin-reload', 'tool-list', 'tool-call', 'window-list', 'window-open', 'window-focus', 'window-close', 'window-resize', 'menu-list', 'menu-click'] as const
|
|
13
13
|
export type Verb = (typeof VERBS)[number]
|
|
14
14
|
|
|
15
15
|
/** The JSON Schema of a verb's arguments, as MCP lists it. */
|
|
@@ -86,6 +86,16 @@ export const VERB_SPECS: Readonly<Record<Verb, VerbSpec>> = {
|
|
|
86
86
|
description: 'Check, install and start a plugin folder inside the app, without a restart.',
|
|
87
87
|
arguments: { type: 'object', properties: { folder: { type: 'string', description: 'a folder inside the app, e.g. plugins/pomodoro' } }, required: ['folder'], additionalProperties: false },
|
|
88
88
|
},
|
|
89
|
+
'plugin-pull': {
|
|
90
|
+
usage: 'plugin pull <source>: fetch a plugin someone released (github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz), check it and start it, live',
|
|
91
|
+
description: 'Fetch a plugin someone released, without running anything; check its digest, that it works on every OS and that this app has the services it needs; then install and start it without a restart. A refusal says why and changes nothing.',
|
|
92
|
+
arguments: { type: 'object', properties: { source: { type: 'string', description: 'github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz path or https address' } }, required: ['source'], additionalProperties: false },
|
|
93
|
+
},
|
|
94
|
+
'plugin-publish': {
|
|
95
|
+
usage: 'plugin publish <name> [--owner <account>] [--license <id>]: show what publishing this plugin would send; in a person\'s own terminal, ask them',
|
|
96
|
+
description: 'Show what publishing a plugin would send: the account, repository, version, license, files and the line a friend pulls it with. Sends nothing. Only a person can say yes, in their own terminal or with Share in the frame; tell them the plan and how.',
|
|
97
|
+
arguments: { type: 'object', properties: { name: { type: 'string', description: 'a plugin of plugins/' }, owner: { type: 'string', description: 'the GitHub account or organization; the signed-in one when absent' }, license: { type: 'string', description: 'an SPDX license id; MIT when absent' } }, required: ['name'], additionalProperties: false },
|
|
98
|
+
},
|
|
89
99
|
'plugin-remove': {
|
|
90
100
|
usage: 'plugin remove <name>: stop and remove an installed plugin',
|
|
91
101
|
description: 'Stop and remove an installed plugin.',
|
|
@@ -19,7 +19,7 @@ import { auditPath, AuditLog } from './host/audit.ts'
|
|
|
19
19
|
import { UiChannel } from './host/channel.ts'
|
|
20
20
|
import { registerChatTools } from './host/chat-tools.ts'
|
|
21
21
|
import { PAGE_VERBS, VERBS } from './host/verbs.ts'
|
|
22
|
-
import { Door, type ExtensionsPort, type ToolsPort } from './host/door.ts'
|
|
22
|
+
import { Door, type ExtensionsPort, type PluginSharePort, type ToolsPort } from './host/door.ts'
|
|
23
23
|
import { assertDoorIsFree, newSecret, removeDoorFile, stateDir, writeDoorFile } from './host/door-file.ts'
|
|
24
24
|
import { DOOR_PREFIX, privateApi } from './host/http.ts'
|
|
25
25
|
import { mcpRoutes } from './host/mcp-routes.ts'
|
|
@@ -182,6 +182,7 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
182
182
|
app: { name: app.name, root: app.root, get url() { return address.url } },
|
|
183
183
|
...(appBase === '' ? {} : { appBase }),
|
|
184
184
|
extensions: () => ctx.get('extensions') as unknown as ExtensionsPort | undefined,
|
|
185
|
+
pluginShare: () => ctx.get('pluginShare') as unknown as PluginSharePort | undefined,
|
|
185
186
|
tools: () => ctx.get('tools') as unknown as ToolsPort | undefined,
|
|
186
187
|
expose: config.tools?.expose ?? [],
|
|
187
188
|
reloadOnChange: config.reloadOnChange !== false,
|
package/plugins/builder/index.ts
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* extensions folder, installs it, and gets back the real result (`active`, `pending` with what it waits for, or the error, with the
|
|
6
6
|
* previous version restored). Writes are confined to that folder. The tools register in `ctx.effect`, so they leave with the builder.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path'
|
|
10
10
|
import type { Context } from '@deepseek-ai/cordis'
|
|
11
11
|
import { scripted, type Tools } from '../agent/index.ts'
|
|
12
12
|
import { NAME } from '../extensions/index.ts'
|
|
13
13
|
import type {} from '../instance/index.ts'
|
|
14
|
+
import { packagePlugin } from '../plugin-share/package.ts'
|
|
14
15
|
import { builderRules } from './script.ts'
|
|
15
16
|
|
|
16
17
|
export interface BuilderConfig {
|
|
@@ -20,22 +21,6 @@ export interface BuilderConfig {
|
|
|
20
21
|
packagesDir?: string
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
/** The package.json of a standalone plugin package: the host framework is a peer, never a second installed copy. */
|
|
24
|
-
export function packageManifest(name: string, description: string): Record<string, unknown> {
|
|
25
|
-
return {
|
|
26
|
-
name,
|
|
27
|
-
version: '0.1.0',
|
|
28
|
-
description,
|
|
29
|
-
type: 'module',
|
|
30
|
-
exports: { '.': './index.ts', './package.json': './package.json' },
|
|
31
|
-
files: ['*.ts', '**/*.ts', 'README.md'],
|
|
32
|
-
// ACRYL measured it: a host package listed under dependencies brings a second copy of the framework, and its services stop
|
|
33
|
-
// matching after a restart. A peer names the version and installs nothing.
|
|
34
|
-
peerDependencies: { '@deepseek-ai/cordis': '4.0.4' },
|
|
35
|
-
keywords: ['cordis', 'cordis-plugin', 'acrylblends'],
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
24
|
const EXTENSIONS = new Set(['.ts', '.js', '.mjs', '.json', '.md'])
|
|
40
25
|
const MAX_BYTES = 256 * 1024
|
|
41
26
|
|
|
@@ -154,8 +139,8 @@ export const builder = {
|
|
|
154
139
|
)
|
|
155
140
|
register(
|
|
156
141
|
'package_plugin',
|
|
157
|
-
'Turn an extension into a standalone plugin package other apps can
|
|
158
|
-
(input) => {
|
|
142
|
+
'Turn an extension into a standalone plugin package other apps can pull: input { name, description? }. Writes the package folder and its release .tgz, the same bytes on every OS. Returns the folder, the file, its sha512 and its content digest.',
|
|
143
|
+
async (input) => {
|
|
159
144
|
const name = pluginName(input)
|
|
160
145
|
if (
|
|
161
146
|
!existsSync(join(folder(name), 'index.ts')) &&
|
|
@@ -166,24 +151,17 @@ export const builder = {
|
|
|
166
151
|
const description = String(
|
|
167
152
|
(input as { description?: unknown }).description ?? `The ${name} Cordis plugin.`,
|
|
168
153
|
)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
`${JSON.stringify(packageManifest(name, description), null, 2)}\n`,
|
|
179
|
-
)
|
|
180
|
-
if (!existsSync(join(target, 'README.md'))) {
|
|
181
|
-
writeFileSync(
|
|
182
|
-
join(target, 'README.md'),
|
|
183
|
-
`# ${name}\n\n${description}\n\nA Cordis plugin. In an ACRYLBLENDS app: \`acrylblends add <this folder>\`.\n`,
|
|
154
|
+
try {
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
name,
|
|
158
|
+
...(await packagePlugin(folder(name), packagesDir, name, description)),
|
|
159
|
+
}
|
|
160
|
+
} catch (cause) {
|
|
161
|
+
throw new BuilderError(
|
|
162
|
+
`${name}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
184
163
|
)
|
|
185
164
|
}
|
|
186
|
-
return { ok: true, name, dir: target }
|
|
187
165
|
},
|
|
188
166
|
)
|
|
189
167
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "builder",
|
|
3
3
|
"description": "The agent's tools for building the app it runs in: write, install, read, list and remove extensions, with real results.",
|
|
4
|
-
"requires": ["agent", "extensions", "sandbox"],
|
|
4
|
+
"requires": ["agent", "extensions", "sandbox", "plugin-share"],
|
|
5
5
|
"inject": ["extensions", "tools", "appInstance"],
|
|
6
6
|
"provides": [],
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@deepseek-ai/cordis": "4.0.4"
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
9
|
+
"@webboxes/blends-core": "0.5.0",
|
|
10
|
+
"@webboxes/plugin-hub": "0.2.0"
|
|
9
11
|
},
|
|
10
12
|
"mount": "mount it after agent and extensions: root.plugin(builder, { dir: join(root, 'plugins') }); dir must be an extensions source"
|
|
11
13
|
}
|
|
@@ -31,8 +31,15 @@ import { type Context, type Fiber, Service } from '@deepseek-ai/cordis'
|
|
|
31
31
|
import type {} from '../instance/index.ts'
|
|
32
32
|
import type { Sandbox } from '../sandbox/index.ts'
|
|
33
33
|
import { appendLedger, type LedgerEntry, readLedger } from './ledger.ts'
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
34
|
+
import { type PluginStore, pluginVersion } from './plugin-store.ts'
|
|
35
|
+
import {
|
|
36
|
+
addedRoutes,
|
|
37
|
+
type FiberRoutesView,
|
|
38
|
+
routeClash,
|
|
39
|
+
type ServedRoute,
|
|
40
|
+
servedRoutes,
|
|
41
|
+
} from './routes.ts'
|
|
42
|
+
import { folderPluginStore } from './stage.ts'
|
|
36
43
|
|
|
37
44
|
/** A fiber's lifecycle state, by name (Cordis's own enum is a `const enum`, which does not exist at run time). */
|
|
38
45
|
export type ExtensionState = 'pending' | 'loading' | 'active' | 'failed' | 'disposed' | 'unloading'
|
|
@@ -93,6 +100,8 @@ export interface ExtensionsConfig {
|
|
|
93
100
|
* are not extensions, so they are neither found nor started here, and installing one is refused with that reason.
|
|
94
101
|
*/
|
|
95
102
|
rows?: readonly string[]
|
|
103
|
+
/** The path the app is mounted under (the frame's canvas, `/app`): a message names a route as the plugin wrote it, without it. */
|
|
104
|
+
routePrefix?: string
|
|
96
105
|
}
|
|
97
106
|
|
|
98
107
|
const READ_ONLY =
|
|
@@ -121,7 +130,7 @@ export type InstallResult =
|
|
|
121
130
|
| {
|
|
122
131
|
readonly ok: false
|
|
123
132
|
/** check: before anything changed; import: loading the code; activate: starting it; verify: its own `check` failed. */
|
|
124
|
-
readonly stage: 'check' | 'import' | 'activate' | 'verify' | 'approval'
|
|
133
|
+
readonly stage: 'check' | 'import' | 'activate' | 'conflict' | 'verify' | 'approval'
|
|
125
134
|
readonly name?: string
|
|
126
135
|
readonly errors: readonly string[]
|
|
127
136
|
/** Present for an update: whether the version that was running is running again. */
|
|
@@ -157,6 +166,8 @@ interface Installed {
|
|
|
157
166
|
readonly dir: string
|
|
158
167
|
readonly module: PluginModule
|
|
159
168
|
fiber: Fiber
|
|
169
|
+
/** The routes its mount added, so a later plugin whose route is taken is told whose it is. */
|
|
170
|
+
readonly routes: readonly ServedRoute[]
|
|
160
171
|
}
|
|
161
172
|
|
|
162
173
|
interface Record_ {
|
|
@@ -224,7 +235,6 @@ interface StateService {
|
|
|
224
235
|
|
|
225
236
|
export class Extensions extends Service {
|
|
226
237
|
readonly sources: readonly ExtensionSource[]
|
|
227
|
-
readonly stageRoot: string
|
|
228
238
|
readonly stateFile: string
|
|
229
239
|
readonly ledgerFile: string
|
|
230
240
|
/** Settles when the startup pass is done. */
|
|
@@ -245,10 +255,14 @@ export class Extensions extends Service {
|
|
|
245
255
|
* service's own fiber, so a plugin mounted from it would read `ctx.router` from the installer's fiber and fail. Extensions mount from this one.
|
|
246
256
|
*/
|
|
247
257
|
private readonly owner: Context
|
|
258
|
+
private readonly store: PluginStore
|
|
259
|
+
private readonly routePrefix: string
|
|
248
260
|
|
|
249
|
-
constructor(ctx: Context, config: ExtensionsConfig
|
|
261
|
+
constructor(ctx: Context, config: ExtensionsConfig, store: PluginStore) {
|
|
250
262
|
super(ctx, 'extensions')
|
|
251
263
|
this.owner = ctx
|
|
264
|
+
this.store = store
|
|
265
|
+
this.routePrefix = (config.routePrefix ?? '').replace(/\/+$/u, '')
|
|
252
266
|
this.readOnly = config.readOnly === true
|
|
253
267
|
this.off = new Set(config.off ?? [])
|
|
254
268
|
this.rows = new Set(config.rows ?? [])
|
|
@@ -260,14 +274,13 @@ export class Extensions extends Service {
|
|
|
260
274
|
trust: config.approval === true && source.trust === 'install' ? 'ask' : source.trust,
|
|
261
275
|
}),
|
|
262
276
|
)
|
|
263
|
-
this.stageRoot = join(home, 'extensions-staged')
|
|
264
277
|
this.stateFile = join(home, 'extensions.json')
|
|
265
278
|
this.ledgerFile = join(home, 'extensions-ledger.jsonl')
|
|
266
279
|
// Effects run their disposers newest first: settle pending operations, then dispose every mounted extension, then drop the hook.
|
|
267
280
|
// Staged copies live in the app's home, outside the app: their bare imports are resolved from the app. A read-only start imports the folders where they are (inside the app).
|
|
268
281
|
if (!this.readOnly)
|
|
269
282
|
ctx.effect(
|
|
270
|
-
() =>
|
|
283
|
+
() => this.store.resolveImportsFrom(root),
|
|
271
284
|
'extensions: resolve staged imports from the app',
|
|
272
285
|
)
|
|
273
286
|
ctx.effect(
|
|
@@ -317,7 +330,7 @@ export class Extensions extends Service {
|
|
|
317
330
|
this.stale.add(entry.name)
|
|
318
331
|
continue
|
|
319
332
|
}
|
|
320
|
-
if (
|
|
333
|
+
if (pluginVersion(entry.source) !== entry.version)
|
|
321
334
|
results.push(await this.installNow(entry.source))
|
|
322
335
|
}
|
|
323
336
|
for (const found of this.discover()) {
|
|
@@ -365,7 +378,7 @@ export class Extensions extends Service {
|
|
|
365
378
|
}
|
|
366
379
|
}
|
|
367
380
|
this.requests.delete(name)
|
|
368
|
-
if (!existsSync(request.dir) ||
|
|
381
|
+
if (!existsSync(request.dir) || pluginVersion(request.dir) !== request.version) {
|
|
369
382
|
return {
|
|
370
383
|
ok: false,
|
|
371
384
|
stage: 'approval',
|
|
@@ -588,6 +601,41 @@ export class Extensions extends Service {
|
|
|
588
601
|
}
|
|
589
602
|
|
|
590
603
|
/** Mount a module as a child fiber and wait until it settles. */
|
|
604
|
+
/** Every route the app serves now, read from the router's labels on every running fiber. */
|
|
605
|
+
private served(): ServedRoute[] {
|
|
606
|
+
const fibers = [...this.ctx.registry.values()].flatMap(
|
|
607
|
+
(runtime) => [...runtime.fibers] as unknown as FiberRoutesView[],
|
|
608
|
+
)
|
|
609
|
+
return servedRoutes(fibers)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* A route the new plugin added that another plugin already serves: the router would answer with the other one, so the new route would never be
|
|
614
|
+
* reached. Refused before the plugin's own check, which the other plugin's answer would pass.
|
|
615
|
+
*/
|
|
616
|
+
private conflict(
|
|
617
|
+
added: readonly ServedRoute[],
|
|
618
|
+
before: readonly ServedRoute[],
|
|
619
|
+
): { stage: 'conflict'; error: string } | undefined {
|
|
620
|
+
const clash = routeClash(added, before)
|
|
621
|
+
if (clash === undefined) return undefined
|
|
622
|
+
const { route, taken } = clash
|
|
623
|
+
const owner =
|
|
624
|
+
[...this.installed.values()].find((entry) =>
|
|
625
|
+
entry.routes.some((own) => own.method === taken.method && own.path === taken.path),
|
|
626
|
+
)?.name ?? (taken.owner === '' ? 'the app' : taken.owner)
|
|
627
|
+
const shown = (path: string): string =>
|
|
628
|
+
this.routePrefix !== '' && path.startsWith(`${this.routePrefix}/`)
|
|
629
|
+
? path.slice(this.routePrefix.length)
|
|
630
|
+
: path
|
|
631
|
+
const same =
|
|
632
|
+
taken.path === route.path ? '' : ` (it serves ${taken.method} ${shown(taken.path)})`
|
|
633
|
+
return {
|
|
634
|
+
stage: 'conflict',
|
|
635
|
+
error: `${route.method} ${shown(route.path)} is already served by ${owner}${same}; rename this plugin's route, or remove ${owner} first`,
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
591
639
|
private async mount(module: PluginModule): Promise<{ fiber: Fiber; error?: unknown }> {
|
|
592
640
|
// An extension is a plugin module whose config, if any, comes from its own defaults.
|
|
593
641
|
const fiber = this.owner.plugin(module as unknown as Parameters<Context['plugin']>[0])
|
|
@@ -617,7 +665,7 @@ export class Extensions extends Service {
|
|
|
617
665
|
const { name, dir, errors } = this.check(source)
|
|
618
666
|
if (errors.length > 0) return { ok: false, stage: 'check', name, errors, next: NOTHING }
|
|
619
667
|
const previous = this.installed.get(name)
|
|
620
|
-
const version =
|
|
668
|
+
const version = pluginVersion(dir)
|
|
621
669
|
const running =
|
|
622
670
|
previous && previous.version === version && STATES[previous.fiber.state] !== 'failed'
|
|
623
671
|
// A source that asks: this exact version must have been approved. Anything else waits, and the ledger says so.
|
|
@@ -648,12 +696,12 @@ export class Extensions extends Service {
|
|
|
648
696
|
}
|
|
649
697
|
|
|
650
698
|
// Import the new version while the old one keeps running: a syntax error or a missing export changes nothing.
|
|
651
|
-
const staged =
|
|
699
|
+
const staged = this.store.put(name, dir)
|
|
652
700
|
let module: PluginModule
|
|
653
701
|
try {
|
|
654
702
|
module = await this.load(staged.dir, name)
|
|
655
703
|
} catch (error) {
|
|
656
|
-
discard(staged
|
|
704
|
+
this.store.discard(staged)
|
|
657
705
|
this.record({ kind: 'refused', name, version: staged.version, error: message(error) })
|
|
658
706
|
return {
|
|
659
707
|
ok: false,
|
|
@@ -666,8 +714,10 @@ export class Extensions extends Service {
|
|
|
666
714
|
}
|
|
667
715
|
|
|
668
716
|
if (previous) await previous.fiber.dispose()
|
|
717
|
+
const before = this.served()
|
|
669
718
|
const mounted = await this.mount(module)
|
|
670
719
|
const state = STATES[mounted.fiber.state] ?? 'failed'
|
|
720
|
+
const added = state === 'active' ? addedRoutes(before, this.served()) : []
|
|
671
721
|
// Commit before "done": a plugin that started still has to pass its own check before the install counts.
|
|
672
722
|
const failure =
|
|
673
723
|
mounted.error !== undefined || state === 'failed'
|
|
@@ -676,11 +726,11 @@ export class Extensions extends Service {
|
|
|
676
726
|
error: message(mounted.error ?? 'the plugin failed to start'),
|
|
677
727
|
}
|
|
678
728
|
: state === 'active'
|
|
679
|
-
? await this.verify(module, mounted.fiber.ctx)
|
|
729
|
+
? (this.conflict(added, before) ?? (await this.verify(module, mounted.fiber.ctx)))
|
|
680
730
|
: undefined
|
|
681
731
|
if (failure) {
|
|
682
732
|
await mounted.fiber.dispose().catch(() => undefined)
|
|
683
|
-
discard(staged
|
|
733
|
+
this.store.discard(staged)
|
|
684
734
|
const error = failure.error
|
|
685
735
|
this.record({ kind: 'refused', name, version: staged.version, error })
|
|
686
736
|
let restored = false
|
|
@@ -714,10 +764,11 @@ export class Extensions extends Service {
|
|
|
714
764
|
dir: staged.dir,
|
|
715
765
|
module,
|
|
716
766
|
fiber: mounted.fiber,
|
|
767
|
+
routes: added,
|
|
717
768
|
})
|
|
718
769
|
this.stale.delete(name)
|
|
719
770
|
this.saveState()
|
|
720
|
-
|
|
771
|
+
this.store.keepOnly(name, staged)
|
|
721
772
|
this.record({ kind: action, name, version: staged.version })
|
|
722
773
|
this.ctx.emit('extensions/changed', name, action)
|
|
723
774
|
return {
|
|
@@ -737,7 +788,7 @@ export class Extensions extends Service {
|
|
|
737
788
|
this.installed.delete(name)
|
|
738
789
|
this.stale.delete(name)
|
|
739
790
|
this.saveState()
|
|
740
|
-
|
|
791
|
+
this.store.keepOnly(name)
|
|
741
792
|
this.record({ kind: 'removed', name, version: entry.version })
|
|
742
793
|
this.ctx.emit('extensions/changed', name, 'removed')
|
|
743
794
|
return { ok: true, name }
|
|
@@ -758,14 +809,16 @@ export class Extensions extends Service {
|
|
|
758
809
|
}
|
|
759
810
|
try {
|
|
760
811
|
const module = await this.load(folder.dir, folder.name)
|
|
812
|
+
const before = this.served()
|
|
761
813
|
const mounted = await this.mount(module)
|
|
762
814
|
this.installed.set(folder.name, {
|
|
763
815
|
name: folder.name,
|
|
764
816
|
source: folder.dir,
|
|
765
|
-
version:
|
|
817
|
+
version: pluginVersion(folder.dir),
|
|
766
818
|
dir: folder.dir,
|
|
767
819
|
module,
|
|
768
820
|
fiber: mounted.fiber,
|
|
821
|
+
routes: addedRoutes(before, this.served()),
|
|
769
822
|
})
|
|
770
823
|
if (mounted.error !== undefined)
|
|
771
824
|
summary.failed.push({ name: folder.name, error: message(mounted.error) })
|
|
@@ -825,11 +878,12 @@ export class Extensions extends Service {
|
|
|
825
878
|
// The source is gone: the last installed version still runs, and is reported as stale.
|
|
826
879
|
summary.stale.push(name)
|
|
827
880
|
this.stale.add(name)
|
|
828
|
-
if (!
|
|
881
|
+
if (!this.store.holds(record)) {
|
|
829
882
|
summary.failed.push({ name, error: 'neither the source nor the staged copy exists' })
|
|
830
883
|
continue
|
|
831
884
|
}
|
|
832
885
|
const module = await this.load(record.dir, name)
|
|
886
|
+
const before = this.served()
|
|
833
887
|
const mounted = await this.mount(module)
|
|
834
888
|
if (mounted.error !== undefined)
|
|
835
889
|
summary.failed.push({ name, error: message(mounted.error) })
|
|
@@ -840,6 +894,7 @@ export class Extensions extends Service {
|
|
|
840
894
|
dir: record.dir,
|
|
841
895
|
module,
|
|
842
896
|
fiber: mounted.fiber,
|
|
897
|
+
routes: addedRoutes(before, this.served()),
|
|
843
898
|
})
|
|
844
899
|
} catch (error) {
|
|
845
900
|
summary.failed.push({ name, error: message(error) })
|
|
@@ -869,7 +924,11 @@ export const extensions = {
|
|
|
869
924
|
name: 'extensions',
|
|
870
925
|
inject: ['appInstance'],
|
|
871
926
|
async apply(ctx: Context, config: ExtensionsConfig) {
|
|
872
|
-
const service = new Extensions(
|
|
927
|
+
const service = new Extensions(
|
|
928
|
+
ctx,
|
|
929
|
+
config ?? {},
|
|
930
|
+
folderPluginStore(join(ctx.appInstance.home, 'extensions-staged')),
|
|
931
|
+
)
|
|
873
932
|
await service.ready
|
|
874
933
|
},
|
|
875
934
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where installed versions of a plugin are kept, as the install transaction needs it. The transaction (`index.ts`) owns this port and says nothing about folders,
|
|
3
|
+
* copies or URLs; `stage.ts` is the adapter that keeps them on disk (Clean Architecture: a boundary owned by the use case; the layout is a detail).
|
|
4
|
+
*/
|
|
5
|
+
import { digestFolder } from '@webboxes/blends-core'
|
|
6
|
+
|
|
7
|
+
/** One kept version of a plugin. `dir` is what the transaction imports it from. */
|
|
8
|
+
export interface StoredPlugin {
|
|
9
|
+
readonly dir: string
|
|
10
|
+
readonly version: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PluginStore {
|
|
14
|
+
/** Keep this source as a version of `name` and return it. An unchanged source returns the version already kept. */
|
|
15
|
+
put(name: string, source: string): StoredPlugin
|
|
16
|
+
/** Whether this version, recorded earlier, is still here. */
|
|
17
|
+
holds(plugin: StoredPlugin): boolean
|
|
18
|
+
/** Drop a version that was refused. */
|
|
19
|
+
discard(plugin: StoredPlugin): void
|
|
20
|
+
/** Drop every version of `name` but `plugin`; all of them when none is given. */
|
|
21
|
+
keepOnly(name: string, plugin?: StoredPlugin): void
|
|
22
|
+
/** Let kept plugins import what the app at `appRoot` provides, until the returned function is called. */
|
|
23
|
+
resolveImportsFrom(appRoot: string): () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A short content digest of a plugin's folder: an unchanged source keeps its version. */
|
|
27
|
+
export const pluginVersion = (dir: string): string => digestFolder(dir, 12)
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"provides": ["extensions"],
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@deepseek-ai/cordis": "4.0.4",
|
|
9
|
-
"@webboxes/blends-core": "0.
|
|
9
|
+
"@webboxes/blends-core": "0.5.0"
|
|
10
10
|
},
|
|
11
11
|
"mount": "mount it after instance: root.plugin(extensions, { sources: [{ dir: join(root, 'plugins'), trust: 'install' }] })"
|
|
12
12
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which routes a plugin brings, and whether one of them is already served (spec 049 T025). Plugins that do not know each other share one app: the router
|
|
3
|
+
* serves the first mount of a route and ignores the second without a word, so a plugin whose route is taken would look installed and answer nothing.
|
|
4
|
+
* The router labels each route it serves (`acryl-router: serves GET /notes`, under the mount's effect), so the routes are read from the running system.
|
|
5
|
+
* Pure functions over those labels; the service reads them from the registry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** One route as the router serves it. */
|
|
9
|
+
export interface ServedRoute {
|
|
10
|
+
readonly method: string
|
|
11
|
+
readonly path: string
|
|
12
|
+
/** The plugin whose fiber holds the label. */
|
|
13
|
+
readonly owner: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The slice of a Cordis effect this module reads. */
|
|
17
|
+
export interface EffectView {
|
|
18
|
+
readonly label: string
|
|
19
|
+
readonly children: readonly EffectView[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The slice of a Cordis fiber this module reads. */
|
|
23
|
+
export interface FiberRoutesView {
|
|
24
|
+
readonly runtime: { readonly name?: string | undefined } | null
|
|
25
|
+
getEffects(): readonly EffectView[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SERVES = /^acryl-router: serves ([A-Z]+) (\S+)$/u
|
|
29
|
+
|
|
30
|
+
/** Every route served by the given fibers, with the plugin that holds each. `ALL` is middleware or `all`, which wraps routes rather than taking them. */
|
|
31
|
+
export function servedRoutes(fibers: Iterable<FiberRoutesView>): ServedRoute[] {
|
|
32
|
+
const routes: ServedRoute[] = []
|
|
33
|
+
const walk = (effects: readonly EffectView[], owner: string): void => {
|
|
34
|
+
for (const effect of effects) {
|
|
35
|
+
const served = SERVES.exec(effect.label)
|
|
36
|
+
if (served?.[1] !== undefined && served[2] !== undefined && served[1] !== 'ALL')
|
|
37
|
+
routes.push({ method: served[1], path: served[2], owner })
|
|
38
|
+
walk(effect.children, owner)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const fiber of fibers) walk(fiber.getEffects(), fiber.runtime?.name ?? '')
|
|
42
|
+
return routes
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const key = (route: ServedRoute): string => `${route.method} ${route.path}`
|
|
46
|
+
|
|
47
|
+
/** The routes in `after` that `before` did not have: what one mount added, counted, so a second mount of the same route counts as added. */
|
|
48
|
+
export function addedRoutes(
|
|
49
|
+
before: readonly ServedRoute[],
|
|
50
|
+
after: readonly ServedRoute[],
|
|
51
|
+
): ServedRoute[] {
|
|
52
|
+
const left = new Map<string, number>()
|
|
53
|
+
for (const route of before) left.set(key(route), (left.get(key(route)) ?? 0) + 1)
|
|
54
|
+
const added: ServedRoute[] = []
|
|
55
|
+
for (const route of after) {
|
|
56
|
+
const count = left.get(key(route)) ?? 0
|
|
57
|
+
if (count > 0) left.set(key(route), count - 1)
|
|
58
|
+
else added.push(route)
|
|
59
|
+
}
|
|
60
|
+
return added
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const segments = (path: string): string[] => path.split('/').filter((part) => part !== '')
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether one request could match both paths: equal segments, a `:param` matching any one segment, a `*` matching the rest. `/notes` and `/notes/list` are
|
|
67
|
+
* two routes; `/notes/*` and `/notes/list` are not.
|
|
68
|
+
*/
|
|
69
|
+
export function pathsOverlap(a: string, b: string): boolean {
|
|
70
|
+
const x = segments(a)
|
|
71
|
+
const y = segments(b)
|
|
72
|
+
for (let i = 0; ; i += 1) {
|
|
73
|
+
const p = x[i]
|
|
74
|
+
const q = y[i]
|
|
75
|
+
if (p === '*' || q === '*') return true
|
|
76
|
+
if (p === undefined || q === undefined) return p === q
|
|
77
|
+
if (p.startsWith(':') || q.startsWith(':')) continue
|
|
78
|
+
if (p !== q) return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The first route `added` shares with one already served: same method and an overlapping path. */
|
|
83
|
+
export function routeClash(
|
|
84
|
+
added: readonly ServedRoute[],
|
|
85
|
+
served: readonly ServedRoute[],
|
|
86
|
+
): { readonly route: ServedRoute; readonly taken: ServedRoute } | undefined {
|
|
87
|
+
for (const route of added)
|
|
88
|
+
for (const taken of served)
|
|
89
|
+
if (taken.method === route.method && pathsOverlap(taken.path, route.path))
|
|
90
|
+
return { route, taken }
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs'
|
|
9
9
|
import { basename, join } from 'node:path'
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
export const hashFolder = (dir: string, length = 12): string => digestFolder(dir, length)
|
|
10
|
+
import { FOLDER_DIGEST_SKIPPED } from '@webboxes/blends-core'
|
|
11
|
+
import { type PluginStore, pluginVersion } from './plugin-store.ts'
|
|
12
|
+
import { resolveFromApp, routerAliases } from './resolve.ts'
|
|
14
13
|
|
|
15
14
|
/**
|
|
16
15
|
* Whether `node --watch` runs this process. It watches every module the process has imported, and keeps watching the paths of the runs before; a staged copy that is deleted
|
|
@@ -40,7 +39,7 @@ export function stage(
|
|
|
40
39
|
stageRoot: string,
|
|
41
40
|
name: string,
|
|
42
41
|
): { dir: string; version: string } {
|
|
43
|
-
const version =
|
|
42
|
+
const version = pluginVersion(source)
|
|
44
43
|
const existing = staged(stageRoot, name, version)
|
|
45
44
|
if (existing !== undefined) return { dir: existing, version }
|
|
46
45
|
const stamp = String(Date.now()).padStart(15, '0')
|
|
@@ -70,3 +69,14 @@ export function prune(stageRoot: string, name: string, keep: readonly string[]):
|
|
|
70
69
|
if (!keep.includes(path)) rmSync(path, { recursive: true, force: true })
|
|
71
70
|
}
|
|
72
71
|
}
|
|
72
|
+
|
|
73
|
+
/** The plugin store of an app: its versions as staged copies under `stageRoot`. */
|
|
74
|
+
export function folderPluginStore(stageRoot: string): PluginStore {
|
|
75
|
+
return {
|
|
76
|
+
put: (name, source) => stage(source, stageRoot, name),
|
|
77
|
+
holds: ({ dir }) => existsSync(dir),
|
|
78
|
+
discard: ({ dir }) => discard(dir),
|
|
79
|
+
keepOnly: (name, plugin) => prune(stageRoot, name, plugin ? [plugin.dir] : []),
|
|
80
|
+
resolveImportsFrom: (appRoot) => resolveFromApp(stageRoot, appRoot, routerAliases()),
|
|
81
|
+
}
|
|
82
|
+
}
|
package/plugins/home/index.ts
CHANGED
|
@@ -50,7 +50,9 @@ export function createHome(): Home {
|
|
|
50
50
|
`a widget id is a plugin name (lowercase letters, digits and dashes), not "${widget.id}"`,
|
|
51
51
|
)
|
|
52
52
|
if (registered.has(widget.id))
|
|
53
|
-
throw new Error(
|
|
53
|
+
throw new Error(
|
|
54
|
+
`the home page already has a widget "${widget.id}", the plugin ${widget.id}'s; a widget's id is the name of the plugin that puts it there`,
|
|
55
|
+
)
|
|
54
56
|
registered.set(widget.id, widget)
|
|
55
57
|
return () => {
|
|
56
58
|
if (registered.get(widget.id) === widget) registered.delete(widget.id)
|
|
@@ -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
|
}
|