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.
Files changed (52) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.js +44 -10
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/guides/create-acrylblends.md +4 -2
  5. package/docs/framework/workshop/README.md +4 -2
  6. package/docs/framework/workshop/facilitator.md +1 -1
  7. package/lib/cordis/door.d.ts +54 -2
  8. package/lib/cordis/door.js +131 -4
  9. package/package.json +1 -1
  10. package/plugins/acryl-door/plugin.json +3 -0
  11. package/plugins/acryl-door/src/cli/acryl.ts +63 -6
  12. package/plugins/acryl-door/src/host/door.ts +91 -5
  13. package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
  14. package/plugins/acryl-door/src/host/verbs.ts +11 -1
  15. package/plugins/acryl-door/src/index.ts +2 -1
  16. package/plugins/builder/index.ts +13 -35
  17. package/plugins/builder/plugin.json +4 -2
  18. package/plugins/extensions/index.ts +78 -19
  19. package/plugins/extensions/plugin-store.ts +27 -0
  20. package/plugins/extensions/plugin.json +1 -1
  21. package/plugins/extensions/routes.ts +92 -0
  22. package/plugins/extensions/stage.ts +15 -5
  23. package/plugins/home/index.ts +3 -1
  24. package/plugins/manifest/plugin.json +1 -1
  25. package/plugins/plugin-share/README.md +66 -0
  26. package/plugins/plugin-share/index.ts +581 -0
  27. package/plugins/plugin-share/package.ts +186 -0
  28. package/plugins/plugin-share/plugin.json +15 -0
  29. package/plugins/plugin-share/publish.ts +65 -0
  30. package/plugins/plugin-share/pull-check.ts +129 -0
  31. package/plugins/plugin-share/pulled-records.ts +70 -0
  32. package/templates/agent/.gitattributes +2 -0
  33. package/templates/agent/blend.yaml +2 -0
  34. package/templates/agent/src/app.ts +6 -0
  35. package/templates/agent/template.json +1 -0
  36. package/templates/blank/.gitattributes +2 -0
  37. package/templates/blank/blend.yaml +3 -0
  38. package/templates/blank/src/app.ts +8 -0
  39. package/templates/blank/template.json +1 -0
  40. package/templates/chat/.gitattributes +2 -0
  41. package/templates/chat/blend.yaml +3 -0
  42. package/templates/chat/src/app.ts +8 -0
  43. package/templates/chat/template.json +1 -0
  44. package/templates/nodejs/.gitattributes +2 -0
  45. package/templates/nodejs/blend.yaml +3 -0
  46. package/templates/nodejs/src/app.ts +8 -0
  47. package/templates/nodejs/template.json +1 -0
  48. package/templates/notes/.gitattributes +2 -0
  49. package/templates/notes/blend.yaml +3 -0
  50. package/templates/notes/src/app.ts +8 -0
  51. package/templates/notes/template.json +1 -0
  52. 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,
@@ -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 { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
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 use: input { name, description? }. Returns its folder.',
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
- const target = join(packagesDir, name)
170
- rmSync(target, { recursive: true, force: true })
171
- mkdirSync(packagesDir, { recursive: true })
172
- cpSync(folder(name), target, {
173
- recursive: true,
174
- filter: (path) => !path.includes('node_modules'),
175
- })
176
- writeFileSync(
177
- join(target, 'package.json'),
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 { resolveFromApp, routerAliases } from './resolve.ts'
35
- import { discard, hashFolder, prune, stage } from './stage.ts'
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
- () => resolveFromApp(this.stageRoot, root, routerAliases()),
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 (hashFolder(entry.source) !== entry.version)
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) || hashFolder(request.dir) !== request.version) {
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 = hashFolder(dir)
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 = stage(dir, this.stageRoot, name)
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.dir)
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.dir)
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
- prune(this.stageRoot, name, [staged.dir])
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
- prune(this.stageRoot, name, [])
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: hashFolder(folder.dir),
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 (!existsSync(record.dir)) {
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(ctx, config ?? {})
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.4.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 { digestFolder, FOLDER_DIGEST_SKIPPED } from '@webboxes/blends-core'
11
-
12
- /** A short content digest of a folder, so an unchanged source keeps its version. */
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 = hashFolder(source)
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
+ }
@@ -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(`the home page already has a widget "${widget.id}"`)
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.4.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
  }