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.
- package/dist/index.d.ts +2 -1
- package/dist/index.js +39 -7
- package/dist/index.js.map +1 -1
- package/docs/framework/guides/create-acrylblends.md +4 -2
- package/docs/framework/workshop/README.md +2 -2
- package/docs/framework/workshop/facilitator.md +1 -1
- package/lib/cordis/door.d.ts +45 -2
- package/lib/cordis/door.js +120 -0
- 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 +75 -1
- 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/package.ts +186 -0
- package/plugins/builder/plugin.json +3 -1
- package/plugins/extensions/index.ts +21 -17
- package/plugins/extensions/plugin-store.ts +27 -0
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/stage.ts +15 -5
- package/plugins/manifest/plugin.json +1 -1
- package/plugins/plugin-share/README.md +51 -0
- package/plugins/plugin-share/index.ts +467 -0
- package/plugins/plugin-share/plugin.json +15 -0
- package/plugins/plugin-share/publish.ts +65 -0
- package/plugins/plugin-share/pull-check.ts +129 -0
- package/plugins/plugin-share/pulled-records.ts +70 -0
- package/templates/agent/.gitattributes +2 -0
- package/templates/agent/blend.yaml +2 -0
- package/templates/agent/src/app.ts +4 -0
- package/templates/agent/template.json +1 -0
- package/templates/blank/.gitattributes +2 -0
- package/templates/chat/.gitattributes +2 -0
- package/templates/nodejs/.gitattributes +2 -0
- package/templates/notes/.gitattributes +2 -0
- 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,
|
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 './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
|
|
|
@@ -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 {
|
|
35
|
-
import {
|
|
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
|
-
() =>
|
|
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 (
|
|
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) ||
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 (!
|
|
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(
|
|
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.
|
|
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 {
|
|
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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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.
|