acrylblends 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.js +39 -7
  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 +2 -2
  6. package/docs/framework/workshop/facilitator.md +1 -1
  7. package/lib/cordis/door.d.ts +45 -2
  8. package/lib/cordis/door.js +120 -0
  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 +75 -1
  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/package.ts +186 -0
  18. package/plugins/builder/plugin.json +3 -1
  19. package/plugins/extensions/index.ts +21 -17
  20. package/plugins/extensions/plugin-store.ts +27 -0
  21. package/plugins/extensions/plugin.json +1 -1
  22. package/plugins/extensions/stage.ts +15 -5
  23. package/plugins/manifest/plugin.json +1 -1
  24. package/plugins/plugin-share/README.md +51 -0
  25. package/plugins/plugin-share/index.ts +467 -0
  26. package/plugins/plugin-share/plugin.json +15 -0
  27. package/plugins/plugin-share/publish.ts +65 -0
  28. package/plugins/plugin-share/pull-check.ts +129 -0
  29. package/plugins/plugin-share/pulled-records.ts +70 -0
  30. package/templates/agent/.gitattributes +2 -0
  31. package/templates/agent/blend.yaml +2 -0
  32. package/templates/agent/src/app.ts +4 -0
  33. package/templates/agent/template.json +1 -0
  34. package/templates/blank/.gitattributes +2 -0
  35. package/templates/chat/.gitattributes +2 -0
  36. package/templates/nodejs/.gitattributes +2 -0
  37. package/templates/notes/.gitattributes +2 -0
  38. package/templates/todo/.gitattributes +2 -0
@@ -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 './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
 
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Turning a plugin folder into what other people's apps can pull: a package folder and its release tarball, with the same bytes on every OS. The
3
+ * hub owns the tarball and the contract, blends-core what "portable" and "digest" mean; this module only decides what goes in.
4
+ */
5
+ import {
6
+ cpSync,
7
+ existsSync,
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ rmSync,
12
+ writeFileSync,
13
+ } from 'node:fs'
14
+ import { extname, join } from 'node:path'
15
+ import { digestFiles, pluginFiles, portabilityIssues } from '@webboxes/blends-core'
16
+ import { packFolder, readTarball, syncServiceContract } from '@webboxes/plugin-hub'
17
+
18
+ /** The package.json of a standalone plugin package: the host framework is a peer, never a second installed copy. */
19
+ export function packageManifest(
20
+ name: string,
21
+ description: string,
22
+ version = '0.1.0',
23
+ release?: PackageRelease,
24
+ ): Record<string, unknown> {
25
+ return {
26
+ name,
27
+ version,
28
+ description,
29
+ ...(release === undefined
30
+ ? {}
31
+ : {
32
+ license: release.license,
33
+ repository: {
34
+ type: 'git',
35
+ url: `git+https://github.com/${release.owner}/${release.repo}.git`,
36
+ },
37
+ }),
38
+ type: 'module',
39
+ exports: { '.': './index.ts', './package.json': './package.json' },
40
+ // No `files` list: a plugin's plugin.json, data and .js files go with it, and the packer's own skips (node_modules, .git) apply.
41
+ // ACRYL measured it: a host package listed under dependencies brings a second copy of the framework, and its services stop
42
+ // matching after a restart. A peer names the version and installs nothing.
43
+ peerDependencies: { '@deepseek-ai/cordis': '4.0.4' },
44
+ keywords: ['cordis', 'cordis-plugin', 'acrylblends'],
45
+ }
46
+ }
47
+
48
+ /** Where a package is going to be released: written in now, so what is planned is byte for byte what is released. */
49
+ export interface PackageRelease {
50
+ readonly owner: string
51
+ readonly repo: string
52
+ readonly license: string
53
+ }
54
+
55
+ export interface PackageOptions {
56
+ /** This version, instead of the one already packaged. */
57
+ readonly version?: string
58
+ readonly release?: PackageRelease
59
+ }
60
+
61
+ export interface PackagedPlugin {
62
+ readonly dir: string
63
+ readonly version: string
64
+ readonly files: readonly string[]
65
+ /** The release file, next to the package folder. */
66
+ readonly tarball: string
67
+ /** The tarball's sha512, as the registry lists it. */
68
+ readonly integrity: string
69
+ /** The sha256 of the plugin's content, which a source can name (`@sha256:<digest>`). */
70
+ readonly digest: string
71
+ }
72
+
73
+ /** Text whose line endings an editor or git on Windows may change: LF in the package, so those endings are not part of the plugin's identity. */
74
+ const TEXT = new Set([
75
+ '.ts',
76
+ '.mts',
77
+ '.cts',
78
+ '.tsx',
79
+ '.js',
80
+ '.mjs',
81
+ '.cjs',
82
+ '.jsx',
83
+ '.json',
84
+ '.md',
85
+ '.txt',
86
+ '.css',
87
+ '.html',
88
+ '.yml',
89
+ '.yaml',
90
+ '.svg',
91
+ ])
92
+
93
+ function withLineFeeds(dir: string): void {
94
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
95
+ const path = join(dir, entry.name)
96
+ if (entry.isDirectory()) withLineFeeds(path)
97
+ else if (entry.isFile() && TEXT.has(extname(entry.name).toLowerCase())) {
98
+ const text = readFileSync(path, 'utf8')
99
+ if (text.includes('\r\n')) writeFileSync(path, text.replaceAll('\r\n', '\n'))
100
+ }
101
+ }
102
+ }
103
+
104
+ /** What the hub's share would otherwise add on the way out, changing the bytes after they were planned. */
105
+ function markPublic(target: string): void {
106
+ const file = join(target, 'package.json')
107
+ const manifest = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
108
+ writeFileSync(
109
+ file,
110
+ `${JSON.stringify({ ...manifest, acryl: { ...(manifest.acryl as object), visibility: 'public' } }, null, 2)}\n`,
111
+ )
112
+ }
113
+
114
+ function versionIn(target: string): string | undefined {
115
+ try {
116
+ const { version } = JSON.parse(readFileSync(join(target, 'package.json'), 'utf8')) as {
117
+ version?: unknown
118
+ }
119
+ return typeof version === 'string' ? version : undefined
120
+ } catch {
121
+ return undefined
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Package the plugin in `source` as `<packages>/<name>/` and `<packages>/<name>-<version>.tgz`. The person's folder is not touched. A version already
127
+ * packaged stays: a release is made once, and a person bumps it deliberately.
128
+ * @throws when the plugin would not work on every OS or its contract cannot be read; no package is left.
129
+ */
130
+ export async function packagePlugin(
131
+ source: string,
132
+ packages: string,
133
+ name: string,
134
+ description: string,
135
+ options: PackageOptions = {},
136
+ ): Promise<PackagedPlugin> {
137
+ const target = join(packages, name)
138
+ const version = options.version ?? versionIn(target)
139
+ rmSync(target, { recursive: true, force: true })
140
+ mkdirSync(packages, { recursive: true })
141
+ cpSync(source, target, { recursive: true, filter: (path) => !path.includes('node_modules') })
142
+ try {
143
+ writeFileSync(
144
+ join(target, 'package.json'),
145
+ `${JSON.stringify(packageManifest(name, description, version, options.release), null, 2)}\n`,
146
+ )
147
+ if (!existsSync(join(target, 'README.md'))) {
148
+ writeFileSync(
149
+ join(target, 'README.md'),
150
+ `# ${name}\n\n${description}\n\nA Cordis plugin. Once it is released, pull it into an ACRYLBLENDS app while that app runs (\`npm run dev\`): \`npm run acryl -- plugin pull github:<owner>/<repo>\`.\n`,
151
+ )
152
+ }
153
+ withLineFeeds(target)
154
+ const issues = portabilityIssues(pluginFiles(target))
155
+ if (issues.length > 0)
156
+ throw new Error(
157
+ `it would not work on every OS: ${issues.map((issue) => `${issue.path}: ${issue.reason}`).join('; ')}`,
158
+ )
159
+ // The contract other people's apps and the registry read: what the plugin needs and provides, from its code.
160
+ const contract = await syncServiceContract(target)
161
+ if (!contract.ok)
162
+ throw new Error(`its service contract cannot be read: ${contract.errors.join('; ')}`)
163
+ if (options.release !== undefined) markPublic(target)
164
+ const packed = packFolder(target)
165
+ const tarball = join(packages, packed.fileName)
166
+ writeFileSync(tarball, packed.tarball)
167
+ const digest = digestFiles(
168
+ readTarball(packed.tarball).map(({ path, content }) => ({
169
+ path,
170
+ bytes: content,
171
+ kind: 'file' as const,
172
+ })),
173
+ )
174
+ return {
175
+ dir: target,
176
+ version: packed.version,
177
+ files: packed.files,
178
+ tarball,
179
+ integrity: packed.integrity,
180
+ digest,
181
+ }
182
+ } catch (cause) {
183
+ rmSync(target, { recursive: true, force: true })
184
+ throw cause
185
+ }
186
+ }
@@ -5,7 +5,9 @@
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,8 @@ 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 { folderPluginStore } from './stage.ts'
36
36
 
37
37
  /** A fiber's lifecycle state, by name (Cordis's own enum is a `const enum`, which does not exist at run time). */
38
38
  export type ExtensionState = 'pending' | 'loading' | 'active' | 'failed' | 'disposed' | 'unloading'
@@ -224,7 +224,6 @@ interface StateService {
224
224
 
225
225
  export class Extensions extends Service {
226
226
  readonly sources: readonly ExtensionSource[]
227
- readonly stageRoot: string
228
227
  readonly stateFile: string
229
228
  readonly ledgerFile: string
230
229
  /** Settles when the startup pass is done. */
@@ -245,10 +244,12 @@ export class Extensions extends Service {
245
244
  * 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
245
  */
247
246
  private readonly owner: Context
247
+ private readonly store: PluginStore
248
248
 
249
- constructor(ctx: Context, config: ExtensionsConfig = {}) {
249
+ constructor(ctx: Context, config: ExtensionsConfig, store: PluginStore) {
250
250
  super(ctx, 'extensions')
251
251
  this.owner = ctx
252
+ this.store = store
252
253
  this.readOnly = config.readOnly === true
253
254
  this.off = new Set(config.off ?? [])
254
255
  this.rows = new Set(config.rows ?? [])
@@ -260,14 +261,13 @@ export class Extensions extends Service {
260
261
  trust: config.approval === true && source.trust === 'install' ? 'ask' : source.trust,
261
262
  }),
262
263
  )
263
- this.stageRoot = join(home, 'extensions-staged')
264
264
  this.stateFile = join(home, 'extensions.json')
265
265
  this.ledgerFile = join(home, 'extensions-ledger.jsonl')
266
266
  // Effects run their disposers newest first: settle pending operations, then dispose every mounted extension, then drop the hook.
267
267
  // 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
268
  if (!this.readOnly)
269
269
  ctx.effect(
270
- () => resolveFromApp(this.stageRoot, root, routerAliases()),
270
+ () => this.store.resolveImportsFrom(root),
271
271
  'extensions: resolve staged imports from the app',
272
272
  )
273
273
  ctx.effect(
@@ -317,7 +317,7 @@ export class Extensions extends Service {
317
317
  this.stale.add(entry.name)
318
318
  continue
319
319
  }
320
- if (hashFolder(entry.source) !== entry.version)
320
+ if (pluginVersion(entry.source) !== entry.version)
321
321
  results.push(await this.installNow(entry.source))
322
322
  }
323
323
  for (const found of this.discover()) {
@@ -365,7 +365,7 @@ export class Extensions extends Service {
365
365
  }
366
366
  }
367
367
  this.requests.delete(name)
368
- if (!existsSync(request.dir) || hashFolder(request.dir) !== request.version) {
368
+ if (!existsSync(request.dir) || pluginVersion(request.dir) !== request.version) {
369
369
  return {
370
370
  ok: false,
371
371
  stage: 'approval',
@@ -617,7 +617,7 @@ export class Extensions extends Service {
617
617
  const { name, dir, errors } = this.check(source)
618
618
  if (errors.length > 0) return { ok: false, stage: 'check', name, errors, next: NOTHING }
619
619
  const previous = this.installed.get(name)
620
- const version = hashFolder(dir)
620
+ const version = pluginVersion(dir)
621
621
  const running =
622
622
  previous && previous.version === version && STATES[previous.fiber.state] !== 'failed'
623
623
  // A source that asks: this exact version must have been approved. Anything else waits, and the ledger says so.
@@ -648,12 +648,12 @@ export class Extensions extends Service {
648
648
  }
649
649
 
650
650
  // 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)
651
+ const staged = this.store.put(name, dir)
652
652
  let module: PluginModule
653
653
  try {
654
654
  module = await this.load(staged.dir, name)
655
655
  } catch (error) {
656
- discard(staged.dir)
656
+ this.store.discard(staged)
657
657
  this.record({ kind: 'refused', name, version: staged.version, error: message(error) })
658
658
  return {
659
659
  ok: false,
@@ -680,7 +680,7 @@ export class Extensions extends Service {
680
680
  : undefined
681
681
  if (failure) {
682
682
  await mounted.fiber.dispose().catch(() => undefined)
683
- discard(staged.dir)
683
+ this.store.discard(staged)
684
684
  const error = failure.error
685
685
  this.record({ kind: 'refused', name, version: staged.version, error })
686
686
  let restored = false
@@ -717,7 +717,7 @@ export class Extensions extends Service {
717
717
  })
718
718
  this.stale.delete(name)
719
719
  this.saveState()
720
- prune(this.stageRoot, name, [staged.dir])
720
+ this.store.keepOnly(name, staged)
721
721
  this.record({ kind: action, name, version: staged.version })
722
722
  this.ctx.emit('extensions/changed', name, action)
723
723
  return {
@@ -737,7 +737,7 @@ export class Extensions extends Service {
737
737
  this.installed.delete(name)
738
738
  this.stale.delete(name)
739
739
  this.saveState()
740
- prune(this.stageRoot, name, [])
740
+ this.store.keepOnly(name)
741
741
  this.record({ kind: 'removed', name, version: entry.version })
742
742
  this.ctx.emit('extensions/changed', name, 'removed')
743
743
  return { ok: true, name }
@@ -762,7 +762,7 @@ export class Extensions extends Service {
762
762
  this.installed.set(folder.name, {
763
763
  name: folder.name,
764
764
  source: folder.dir,
765
- version: hashFolder(folder.dir),
765
+ version: pluginVersion(folder.dir),
766
766
  dir: folder.dir,
767
767
  module,
768
768
  fiber: mounted.fiber,
@@ -825,7 +825,7 @@ export class Extensions extends Service {
825
825
  // The source is gone: the last installed version still runs, and is reported as stale.
826
826
  summary.stale.push(name)
827
827
  this.stale.add(name)
828
- if (!existsSync(record.dir)) {
828
+ if (!this.store.holds(record)) {
829
829
  summary.failed.push({ name, error: 'neither the source nor the staged copy exists' })
830
830
  continue
831
831
  }
@@ -869,7 +869,11 @@ export const extensions = {
869
869
  name: 'extensions',
870
870
  inject: ['appInstance'],
871
871
  async apply(ctx: Context, config: ExtensionsConfig) {
872
- const service = new Extensions(ctx, config ?? {})
872
+ const service = new Extensions(
873
+ ctx,
874
+ config ?? {},
875
+ folderPluginStore(join(ctx.appInstance.home, 'extensions-staged')),
876
+ )
873
877
  await service.ready
874
878
  },
875
879
  }
@@ -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
  }
@@ -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
+ }
@@ -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
  }
@@ -0,0 +1,51 @@
1
+ # plugin-share
2
+
3
+ Share plugins between people. Pull a plugin someone released into your running app, and publish your own as a plan a person confirms.
4
+
5
+ ## Pull
6
+
7
+ ```
8
+ npm run acryl -- plugin pull github:<owner>/<repo>@<version>
9
+ ```
10
+
11
+ The line someone gives you after publishing also carries `@sha256:<digest>`. Then a release with different bytes is refused, with exit 1, and nothing changes.
12
+
13
+ The app must be running (`npm run dev`), because the pull installs into it live. A stopped app never installs offline: the line exits 3 and asks you to start the app and run the same line again.
14
+
15
+ The app remembers where each pulled plugin came from, in the `plugin-share` row of `blend.yaml`. A copy of the app fetches the same bytes at its next start.
16
+
17
+ ## Publish
18
+
19
+ ```
20
+ npm run acryl -- plugin publish <name> [--owner <account>] [--license <id>]
21
+ ```
22
+
23
+ It prints the plan: account, repository, version, visibility and license. Only a person's yes at the terminal publishes. Declining sends nothing. An agent gets the plan and cannot say yes. The output ends with the pull line to give to others.
24
+
25
+ ## An app made by an older version
26
+
27
+ Every new app has this plugin. `upgrade` does not add it to an older app, so add it and mount it by hand:
28
+
29
+ ```
30
+ npx acrylblends add plugin-share
31
+ npm install
32
+ ```
33
+
34
+ In `src/app.ts`, import it, add it to the registry and give it the plugins folder:
35
+
36
+ ```ts
37
+ import { pluginShare } from '../plugins/plugin-share/index.ts'
38
+ // in the registry
39
+ 'plugin-share': pluginShare,
40
+ // in the configuration passed to compose
41
+ 'plugin-share': { dir: extensionsDir },
42
+ ```
43
+
44
+ In `blend.yaml`, add the row after `builder`:
45
+
46
+ ```yaml
47
+ - id: plugin-share
48
+ name: plugin-share
49
+ ```
50
+
51
+ Then restart the app.