acrylblends 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -1
- package/dist/index.js +44 -10
- package/dist/index.js.map +1 -1
- package/docs/framework/guides/create-acrylblends.md +4 -2
- package/docs/framework/workshop/README.md +4 -2
- package/docs/framework/workshop/facilitator.md +1 -1
- package/lib/cordis/door.d.ts +54 -2
- package/lib/cordis/door.js +131 -4
- package/package.json +1 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/cli/acryl.ts +63 -6
- package/plugins/acryl-door/src/host/door.ts +91 -5
- package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
- package/plugins/acryl-door/src/host/verbs.ts +11 -1
- package/plugins/acryl-door/src/index.ts +2 -1
- package/plugins/builder/index.ts +13 -35
- package/plugins/builder/plugin.json +4 -2
- package/plugins/extensions/index.ts +78 -19
- package/plugins/extensions/plugin-store.ts +27 -0
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/routes.ts +92 -0
- package/plugins/extensions/stage.ts +15 -5
- package/plugins/home/index.ts +3 -1
- package/plugins/manifest/plugin.json +1 -1
- package/plugins/plugin-share/README.md +66 -0
- package/plugins/plugin-share/index.ts +581 -0
- package/plugins/plugin-share/package.ts +186 -0
- package/plugins/plugin-share/plugin.json +15 -0
- package/plugins/plugin-share/publish.ts +65 -0
- package/plugins/plugin-share/pull-check.ts +129 -0
- package/plugins/plugin-share/pulled-records.ts +70 -0
- package/templates/agent/.gitattributes +2 -0
- package/templates/agent/blend.yaml +2 -0
- package/templates/agent/src/app.ts +6 -0
- package/templates/agent/template.json +1 -0
- package/templates/blank/.gitattributes +2 -0
- package/templates/blank/blend.yaml +3 -0
- package/templates/blank/src/app.ts +8 -0
- package/templates/blank/template.json +1 -0
- package/templates/chat/.gitattributes +2 -0
- package/templates/chat/blend.yaml +3 -0
- package/templates/chat/src/app.ts +8 -0
- package/templates/chat/template.json +1 -0
- package/templates/nodejs/.gitattributes +2 -0
- package/templates/nodejs/blend.yaml +3 -0
- package/templates/nodejs/src/app.ts +8 -0
- package/templates/nodejs/template.json +1 -0
- package/templates/notes/.gitattributes +2 -0
- package/templates/notes/blend.yaml +3 -0
- package/templates/notes/src/app.ts +8 -0
- package/templates/notes/template.json +1 -0
- package/templates/todo/.gitattributes +2 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "plugin-share",
|
|
3
|
+
"description": "Share plugins between people. Pull one someone released into the running app: fetched without running anything, checked by digest and for every OS, then installed live. Publish one, only as a plan a person confirmed.",
|
|
4
|
+
"requires": ["instance", "extensions", "manifest"],
|
|
5
|
+
"inject": ["extensions", "appInstance"],
|
|
6
|
+
"provides": ["pluginShare"],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
9
|
+
"@webboxes/app-persistence": "0.1.1",
|
|
10
|
+
"@webboxes/blends-core": "0.5.0",
|
|
11
|
+
"@webboxes/plugin-hub": "0.2.0",
|
|
12
|
+
"yaml": "2.9.0"
|
|
13
|
+
},
|
|
14
|
+
"mount": "mount it after extensions, in development builds: root.plugin(pluginShare, { dir: join(root, 'plugins') }); dir must be an extensions source"
|
|
15
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishing a plugin: what a person is asked to say yes to, and the comparison that makes the yes mean exactly that. Pure; the service does the I/O.
|
|
3
|
+
*/
|
|
4
|
+
import type { PublishGitPort, RepositoryHostPort } from '@webboxes/plugin-hub'
|
|
5
|
+
|
|
6
|
+
/** Everything that leaves the machine, named before anything does. */
|
|
7
|
+
export interface PublishPlan {
|
|
8
|
+
readonly owner: string
|
|
9
|
+
readonly repo: string
|
|
10
|
+
readonly version: string
|
|
11
|
+
readonly visibility: 'public'
|
|
12
|
+
readonly license: string
|
|
13
|
+
/** The repository does not exist yet: publishing creates it. */
|
|
14
|
+
readonly createsRepository: boolean
|
|
15
|
+
/** The version already released there; the plan then releases the next patch. */
|
|
16
|
+
readonly released?: string
|
|
17
|
+
readonly files: readonly string[]
|
|
18
|
+
/** The sha512 of the release file. */
|
|
19
|
+
readonly integrity: string
|
|
20
|
+
/** The sha256 of the plugin's content. */
|
|
21
|
+
readonly digest: string
|
|
22
|
+
/** What a friend runs to get exactly these bytes. */
|
|
23
|
+
readonly pullLine: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PublishRefused {
|
|
27
|
+
readonly ok: false
|
|
28
|
+
readonly reason: string
|
|
29
|
+
readonly next: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type PublishResult =
|
|
33
|
+
| {
|
|
34
|
+
readonly ok: true
|
|
35
|
+
readonly url: string
|
|
36
|
+
readonly releaseUrl: string
|
|
37
|
+
readonly version: string
|
|
38
|
+
readonly integrity: string
|
|
39
|
+
readonly digest: string
|
|
40
|
+
readonly pullLine: string
|
|
41
|
+
}
|
|
42
|
+
| PublishRefused
|
|
43
|
+
|
|
44
|
+
export interface PublishOptions {
|
|
45
|
+
/** The account or organization; the signed-in one when absent. */
|
|
46
|
+
readonly owner?: string
|
|
47
|
+
/** The license the plugin is offered under, as an SPDX id; MIT when absent. The plan shows it before the person says yes. */
|
|
48
|
+
readonly license?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Where a publish goes through: the person's own `gh` and `git`. A test passes stand-ins, so no test publishes. */
|
|
52
|
+
export interface PublishPorts {
|
|
53
|
+
readonly host: RepositoryHostPort
|
|
54
|
+
readonly git: PublishGitPort
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The line a friend copies: it names the bytes, so the check comes for free. */
|
|
58
|
+
export const pullLineOf = (owner: string, repo: string, version: string, digest: string): string =>
|
|
59
|
+
`npm run acryl -- plugin pull github:${owner}/${repo}@${version}@sha256:${digest}`
|
|
60
|
+
|
|
61
|
+
/** The same plan: what the person confirmed is what would be published now. `released` only explains the version, and is gone once the next one is packaged. */
|
|
62
|
+
export function samePlan(a: PublishPlan, b: PublishPlan): boolean {
|
|
63
|
+
const compared = ({ released: _released, ...rest }: PublishPlan) => JSON.stringify(rest)
|
|
64
|
+
return compared(a) === compared(b)
|
|
65
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What decides whether a pulled plugin may enter this app. Pure, over the files of the package: nothing here touches a disk or runs the plugin,
|
|
3
|
+
* so a refusal has nothing to undo (Clean Architecture: policy free of I/O).
|
|
4
|
+
*/
|
|
5
|
+
import { digestFiles, type PluginFile, portabilityIssues } from '@webboxes/blends-core'
|
|
6
|
+
import { hostInjectFromSource } from '@webboxes/plugin-hub'
|
|
7
|
+
|
|
8
|
+
export type Refusal =
|
|
9
|
+
| 'source'
|
|
10
|
+
| 'fetch'
|
|
11
|
+
| 'name'
|
|
12
|
+
| 'digest'
|
|
13
|
+
| 'portability'
|
|
14
|
+
| 'dependencies'
|
|
15
|
+
| 'entry'
|
|
16
|
+
| 'inject'
|
|
17
|
+
| 'missing-service'
|
|
18
|
+
| 'folder-exists'
|
|
19
|
+
| 'install'
|
|
20
|
+
|
|
21
|
+
export interface PullRefused {
|
|
22
|
+
readonly ok: false
|
|
23
|
+
readonly refusal: Refusal
|
|
24
|
+
readonly reason: string
|
|
25
|
+
/** What the person can do about it. */
|
|
26
|
+
readonly next: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const refuse = (refusal: Refusal, reason: string, next: string): PullRefused => ({
|
|
30
|
+
ok: false,
|
|
31
|
+
refusal,
|
|
32
|
+
reason,
|
|
33
|
+
next,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const SHA256 = /^[0-9a-f]{64}$/
|
|
37
|
+
const DIGEST_MARK = '@sha256:'
|
|
38
|
+
|
|
39
|
+
/** The source a person wrote, split into where it comes from and the content digest it names. @throws when the digest is malformed. */
|
|
40
|
+
export function splitDigest(input: string): { readonly source: string; readonly digest?: string } {
|
|
41
|
+
const at = input.lastIndexOf(DIGEST_MARK)
|
|
42
|
+
if (at === -1) return { source: input }
|
|
43
|
+
const digest = input.slice(at + DIGEST_MARK.length)
|
|
44
|
+
if (!SHA256.test(digest))
|
|
45
|
+
throw new Error(`"${digest}" is not a content digest: it has 64 lowercase hex characters`)
|
|
46
|
+
return { source: input.slice(0, at), digest }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The entry files the extensions folder loads, in its order. */
|
|
50
|
+
const ENTRIES = ['index.ts', 'index.js', 'index.mjs']
|
|
51
|
+
|
|
52
|
+
const text = (file: PluginFile): string => Buffer.from(file.bytes).toString('utf8')
|
|
53
|
+
|
|
54
|
+
export interface PullContext {
|
|
55
|
+
/** The content digest the source names. */
|
|
56
|
+
readonly digest?: string | undefined
|
|
57
|
+
/** Whether the extensions folder can hold a plugin of this name. */
|
|
58
|
+
readonly nameFits: (name: string) => boolean
|
|
59
|
+
/** Whether this app has the service right now. */
|
|
60
|
+
readonly hasService: (name: string) => boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The first reason this package may not enter the app, or undefined. */
|
|
64
|
+
export function pullRefusal(
|
|
65
|
+
manifest: Readonly<Record<string, unknown>>,
|
|
66
|
+
files: readonly PluginFile[],
|
|
67
|
+
context: PullContext,
|
|
68
|
+
): PullRefused | undefined {
|
|
69
|
+
const name = String(manifest.name)
|
|
70
|
+
if (!context.nameFits(name)) {
|
|
71
|
+
return refuse(
|
|
72
|
+
'name',
|
|
73
|
+
`the package is called "${name}", which a folder in plugins/ cannot hold`,
|
|
74
|
+
'ask its author to release it under lowercase letters, digits and dashes',
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
const digest = digestFiles(files)
|
|
78
|
+
if (context.digest !== undefined && context.digest !== digest) {
|
|
79
|
+
return refuse(
|
|
80
|
+
'digest',
|
|
81
|
+
`${digest} is not the digest the source names (${context.digest}); these are different bytes`,
|
|
82
|
+
'ask the person who gave you the digest which release they meant',
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
const issues = portabilityIssues(files)
|
|
86
|
+
if (issues.length > 0) {
|
|
87
|
+
return refuse(
|
|
88
|
+
'portability',
|
|
89
|
+
issues.map((issue) => `${issue.path}: ${issue.reason}`).join('; '),
|
|
90
|
+
'ask its author to fix this and release again, so it works on every OS',
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
const dependencies = Object.keys(
|
|
94
|
+
(manifest.dependencies as Record<string, unknown> | undefined) ?? {},
|
|
95
|
+
)
|
|
96
|
+
if (dependencies.length > 0) {
|
|
97
|
+
return refuse(
|
|
98
|
+
'dependencies',
|
|
99
|
+
`it needs ${dependencies.join(', ')} installed, and a pull installs nothing`,
|
|
100
|
+
'ask its author to bundle them or name them as peers',
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
const entry = ENTRIES.flatMap((path) => files.filter((file) => file.path === path))[0]
|
|
104
|
+
if (entry === undefined) {
|
|
105
|
+
return refuse(
|
|
106
|
+
'entry',
|
|
107
|
+
`it has none of ${ENTRIES.join(', ')} at its top level`,
|
|
108
|
+
'this package is not a plugin an app can load; ask its author',
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
const inject = hostInjectFromSource(text(entry))
|
|
112
|
+
if (inject !== undefined && 'error' in inject) {
|
|
113
|
+
return refuse(
|
|
114
|
+
'inject',
|
|
115
|
+
`${inject.error}. A pull reads what a plugin needs without running it`,
|
|
116
|
+
'ask its author to list the services as plain names',
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
const missing = (inject?.names ?? []).filter((service) => !context.hasService(service))
|
|
120
|
+
if (missing.length > 0) return missingServices(missing)
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const missingServices = (missing: readonly string[]): PullRefused =>
|
|
125
|
+
refuse(
|
|
126
|
+
'missing-service',
|
|
127
|
+
`it needs ${missing.join(', ')}, which this app does not have`,
|
|
128
|
+
'add the plugin that provides it, then pull again',
|
|
129
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an app remembers about the plugins it pulled: their source and digests, in the `plugin-share` row's config of `blend.yaml`. A row's config is
|
|
3
|
+
* resolved into `blend.lock.json` too, so the lock records them with no new format, and a copy of the app can fetch exactly the same bytes. Pure text
|
|
4
|
+
* to text; comments and the person's formatting stay.
|
|
5
|
+
*/
|
|
6
|
+
import { isMap, isSeq, parseDocument } from 'yaml'
|
|
7
|
+
|
|
8
|
+
export const ROW = 'plugin-share'
|
|
9
|
+
|
|
10
|
+
export interface PulledRecord {
|
|
11
|
+
/** `github:<owner>/<repo>@<version>` or an https address: something another machine can fetch. */
|
|
12
|
+
readonly source: string
|
|
13
|
+
/** The sha512 of the release file. */
|
|
14
|
+
readonly integrity: string
|
|
15
|
+
/** The sha256 of the plugin's content. */
|
|
16
|
+
readonly digest: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const isRecord = (value: unknown): value is PulledRecord => {
|
|
20
|
+
const record = value as Partial<Record<keyof PulledRecord, unknown>> | null
|
|
21
|
+
return (
|
|
22
|
+
typeof record?.source === 'string' &&
|
|
23
|
+
typeof record.integrity === 'string' &&
|
|
24
|
+
typeof record.digest === 'string'
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The plugins this definition remembers pulling. */
|
|
29
|
+
export function pulledRecords(text: string): Readonly<Record<string, PulledRecord>> {
|
|
30
|
+
const rows = (parseDocument(text).toJS() as { spec?: { rows?: unknown } } | null)?.spec?.rows
|
|
31
|
+
if (!Array.isArray(rows)) return {}
|
|
32
|
+
const pulled = (
|
|
33
|
+
rows.find((row: { name?: unknown }) => row?.name === ROW) as
|
|
34
|
+
| { config?: { pulled?: unknown } }
|
|
35
|
+
| undefined
|
|
36
|
+
)?.config?.pulled
|
|
37
|
+
if (typeof pulled !== 'object' || pulled === null) return {}
|
|
38
|
+
return Object.fromEntries(
|
|
39
|
+
Object.entries(pulled).filter(([, record]) => isRecord(record)),
|
|
40
|
+
) as Record<string, PulledRecord>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The definition with one plugin's record written, or taken out when `record` is undefined. Unchanged when it has no `plugin-share` row: an app
|
|
45
|
+
* composed without the row has nowhere to remember.
|
|
46
|
+
*/
|
|
47
|
+
export function withPulledRecord(
|
|
48
|
+
text: string,
|
|
49
|
+
name: string,
|
|
50
|
+
record: PulledRecord | undefined,
|
|
51
|
+
): string {
|
|
52
|
+
const document = parseDocument(text)
|
|
53
|
+
const rows = document.getIn(['spec', 'rows'])
|
|
54
|
+
if (!isSeq(rows)) return text
|
|
55
|
+
const row = rows.items.find((item) => isMap(item) && item.get('name') === ROW)
|
|
56
|
+
if (!isMap(row)) return text
|
|
57
|
+
if (record === undefined) {
|
|
58
|
+
if (!row.hasIn(['config', 'pulled', name])) return text
|
|
59
|
+
row.deleteIn(['config', 'pulled', name])
|
|
60
|
+
const left = row.getIn(['config', 'pulled'])
|
|
61
|
+
if (isMap(left) && left.items.length === 0) row.deleteIn(['config', 'pulled'])
|
|
62
|
+
const config = row.get('config')
|
|
63
|
+
if (isMap(config) && config.items.length === 0) row.delete('config')
|
|
64
|
+
} else {
|
|
65
|
+
row.setIn(['config'], row.get('config') ?? document.createNode({}))
|
|
66
|
+
if (!row.hasIn(['config', 'pulled'])) row.setIn(['config', 'pulled'], document.createNode({}))
|
|
67
|
+
row.setIn(['config', 'pulled', name], document.createNode(record))
|
|
68
|
+
}
|
|
69
|
+
return String(document)
|
|
70
|
+
}
|
|
@@ -49,6 +49,8 @@ spec:
|
|
|
49
49
|
approval: "{{parameters.approval}}"
|
|
50
50
|
- id: builder
|
|
51
51
|
name: builder
|
|
52
|
+
- id: plugin-share
|
|
53
|
+
name: plugin-share
|
|
52
54
|
- id: save
|
|
53
55
|
name: save
|
|
54
56
|
# The panel: a chat button on every page in development (the app's own agent, or Codex, Claude or pi started in this folder). It starts nothing in production.
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type ParameterValue,
|
|
21
21
|
} from '../plugins/manifest/index.ts'
|
|
22
22
|
import { ASSISTANT_PROMPT, type OpenAIConfig, openai } from '../plugins/openai/index.ts'
|
|
23
|
+
import { pluginShare } from '../plugins/plugin-share/index.ts'
|
|
23
24
|
import { sandboxPlugin } from '../plugins/sandbox/index.ts'
|
|
24
25
|
import { save } from '../plugins/save/index.ts'
|
|
25
26
|
import { tasks } from '../plugins/tasks/index.ts'
|
|
@@ -36,6 +37,7 @@ export const registry = {
|
|
|
36
37
|
'acryl-state': state,
|
|
37
38
|
extensions,
|
|
38
39
|
builder,
|
|
40
|
+
'plugin-share': pluginShare,
|
|
39
41
|
save,
|
|
40
42
|
sandbox: sandboxPlugin,
|
|
41
43
|
routes,
|
|
@@ -77,6 +79,7 @@ export interface App {
|
|
|
77
79
|
const DEVELOPMENT_ROWS: Record<string, string> = {
|
|
78
80
|
agent: 'POST /ask runs the agent, which spends the model key and calls its tools',
|
|
79
81
|
builder: 'the agent can write plugin source into plugins/',
|
|
82
|
+
'plugin-share': 'a plugin pulled from a release runs in this process',
|
|
80
83
|
extensions:
|
|
81
84
|
'a plugin installed live runs in this process, and POST /extensions/approve decides installs',
|
|
82
85
|
}
|
|
@@ -213,8 +216,11 @@ export async function composeInto(
|
|
|
213
216
|
...(legacy ? [{ dir: legacyDir, trust: 'install' as const }] : []),
|
|
214
217
|
],
|
|
215
218
|
rows: ownRows,
|
|
219
|
+
// Messages name a route as the plugin wrote it, not under the frame's canvas prefix.
|
|
220
|
+
...(options.host === true ? { routePrefix: FRAME_MOUNT_PREFIX } : {}),
|
|
216
221
|
},
|
|
217
222
|
builder: { dir: extensionsDir },
|
|
223
|
+
'plugin-share': { dir: extensionsDir },
|
|
218
224
|
...(assistant
|
|
219
225
|
? {
|
|
220
226
|
// Every answer comes right after a look, so it says what the visitor's page shows now.
|
|
@@ -25,6 +25,9 @@ spec:
|
|
|
25
25
|
# a row is never started twice. Read-only when NODE_ENV=production.
|
|
26
26
|
- id: extensions
|
|
27
27
|
name: extensions
|
|
28
|
+
# Sharing: `npm run acryl -- plugin pull github:<owner>/<repo>@<version>` installs a plugin someone released (development only).
|
|
29
|
+
- id: plugin-share
|
|
30
|
+
name: plugin-share
|
|
28
31
|
- id: server
|
|
29
32
|
name: acryl-node-server
|
|
30
33
|
config:
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
loadManifest,
|
|
16
16
|
type ParameterValue,
|
|
17
17
|
} from '../plugins/manifest/index.ts'
|
|
18
|
+
import { pluginShare } from '../plugins/plugin-share/index.ts'
|
|
18
19
|
import { doorEnabled } from './door.ts'
|
|
19
20
|
import { FRAME_MOUNT_PREFIX, routes } from './routes.ts'
|
|
20
21
|
|
|
@@ -25,6 +26,7 @@ export const registry = {
|
|
|
25
26
|
'acryl-door': door,
|
|
26
27
|
home,
|
|
27
28
|
extensions,
|
|
29
|
+
'plugin-share': pluginShare,
|
|
28
30
|
routes,
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -106,6 +108,8 @@ export async function composeInto(
|
|
|
106
108
|
const leaveOut = new Set<string>(options.host === true ? HOST_ROWS : [])
|
|
107
109
|
if (!own) leaveOut.add('acryl-node-server').add('acryl-door')
|
|
108
110
|
if (!doorEnabled()) leaveOut.add('acryl-door')
|
|
111
|
+
// A pull runs a stranger's code and a publish leaves the machine: development only, like the door.
|
|
112
|
+
if (process.env.NODE_ENV === 'production') leaveOut.add('plugin-share')
|
|
109
113
|
const manifest = without(read, ...leaveOut)
|
|
110
114
|
// The rows that name one of the app's own plugin folders (a feature: plugins/<name>/ and its row) rather than a registry import. Each one that runs
|
|
111
115
|
// is imported here; all of them, off or on, belong to their row, so the extensions row never starts them a second time.
|
|
@@ -129,7 +133,11 @@ export async function composeInto(
|
|
|
129
133
|
...(process.env.NODE_ENV === 'production' ? { readOnly: true } : {}),
|
|
130
134
|
sources: [{ dir: PLUGINS_DIR, trust: 'install' }],
|
|
131
135
|
rows: ownRows,
|
|
136
|
+
// Messages name a route as the plugin wrote it, not under the frame's canvas prefix.
|
|
137
|
+
...(prefix === undefined ? {} : { routePrefix: prefix }),
|
|
132
138
|
},
|
|
139
|
+
// Pulled plugins go where the extensions row installs from.
|
|
140
|
+
'plugin-share': { dir: PLUGINS_DIR },
|
|
133
141
|
// Only the app's own server listens; the frame host owns the server and its port.
|
|
134
142
|
...(own ? { server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 } } : {}),
|
|
135
143
|
},
|
|
@@ -24,6 +24,9 @@ spec:
|
|
|
24
24
|
# Read-only when NODE_ENV=production.
|
|
25
25
|
- id: extensions
|
|
26
26
|
name: extensions
|
|
27
|
+
# Sharing: `npm run acryl -- plugin pull github:<owner>/<repo>@<version>` installs a plugin someone released (development only).
|
|
28
|
+
- id: plugin-share
|
|
29
|
+
name: plugin-share
|
|
27
30
|
- id: server
|
|
28
31
|
name: acryl-node-server
|
|
29
32
|
config:
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
loadManifest,
|
|
16
16
|
type ParameterValue,
|
|
17
17
|
} from '../plugins/manifest/index.ts'
|
|
18
|
+
import { pluginShare } from '../plugins/plugin-share/index.ts'
|
|
18
19
|
import { doorEnabled } from './door.ts'
|
|
19
20
|
import { FRAME_MOUNT_PREFIX, routes } from './routes.ts'
|
|
20
21
|
|
|
@@ -25,6 +26,7 @@ export const registry = {
|
|
|
25
26
|
'acryl-node-server': nodeServer,
|
|
26
27
|
'acryl-door': door,
|
|
27
28
|
extensions,
|
|
29
|
+
'plugin-share': pluginShare,
|
|
28
30
|
routes,
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -106,6 +108,8 @@ export async function composeInto(
|
|
|
106
108
|
const leaveOut = new Set<string>(options.host === true ? HOST_ROWS : [])
|
|
107
109
|
if (!own) leaveOut.add('acryl-node-server').add('acryl-door')
|
|
108
110
|
if (!doorEnabled()) leaveOut.add('acryl-door')
|
|
111
|
+
// A pull runs a stranger's code and a publish leaves the machine: development only, like the door.
|
|
112
|
+
if (process.env.NODE_ENV === 'production') leaveOut.add('plugin-share')
|
|
109
113
|
const manifest = without(read, ...leaveOut)
|
|
110
114
|
// The rows that name one of the app's own plugin folders (a feature: plugins/<name>/ and its row) rather than a registry import. Each one that runs
|
|
111
115
|
// is imported here; all of them, off or on, belong to their row, so the extensions row never starts them a second time.
|
|
@@ -134,7 +138,11 @@ export async function composeInto(
|
|
|
134
138
|
...(process.env.NODE_ENV === 'production' ? { readOnly: true } : {}),
|
|
135
139
|
sources: [{ dir: PLUGINS_DIR, trust: 'install' }],
|
|
136
140
|
rows: ownRows,
|
|
141
|
+
// Messages name a route as the plugin wrote it, not under the frame's canvas prefix.
|
|
142
|
+
...(prefix === undefined ? {} : { routePrefix: prefix }),
|
|
137
143
|
},
|
|
144
|
+
// Pulled plugins go where the extensions row installs from.
|
|
145
|
+
'plugin-share': { dir: PLUGINS_DIR },
|
|
138
146
|
// Only the app's own server listens; the frame host owns the server and its port.
|
|
139
147
|
...(own ? { server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 } } : {}),
|
|
140
148
|
},
|
|
@@ -21,6 +21,9 @@ spec:
|
|
|
21
21
|
# Read-only when NODE_ENV=production.
|
|
22
22
|
- id: extensions
|
|
23
23
|
name: extensions
|
|
24
|
+
# Sharing: `npm run acryl -- plugin pull github:<owner>/<repo>@<version>` installs a plugin someone released (development only).
|
|
25
|
+
- id: plugin-share
|
|
26
|
+
name: plugin-share
|
|
24
27
|
- id: server
|
|
25
28
|
name: acryl-node-server
|
|
26
29
|
config:
|