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
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ # Every checkout has the same line endings, on every OS. A plugin's digest counts every byte, so a CRLF checkout would make it a different plugin.
2
+ * text=auto eol=lf
@@ -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.
@@ -12,6 +12,7 @@
12
12
  "sandbox",
13
13
  "extensions",
14
14
  "builder",
15
+ "plugin-share",
15
16
  "agent-panel",
16
17
  "manifest",
17
18
  "save",
@@ -0,0 +1,2 @@
1
+ # Every checkout has the same line endings, on every OS. A plugin's digest counts every byte, so a CRLF checkout would make it a different plugin.
2
+ * text=auto eol=lf
@@ -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
  },
@@ -7,6 +7,7 @@
7
7
  "save",
8
8
  "home",
9
9
  "extensions",
10
+ "plugin-share",
10
11
  "acryl-door",
11
12
  "acryl-web-server",
12
13
  "acryl-page"
@@ -0,0 +1,2 @@
1
+ # Every checkout has the same line endings, on every OS. A plugin's digest counts every byte, so a CRLF checkout would make it a different plugin.
2
+ * text=auto eol=lf
@@ -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
  },
@@ -7,6 +7,7 @@
7
7
  "manifest",
8
8
  "save",
9
9
  "extensions",
10
+ "plugin-share",
10
11
  "acryl-door",
11
12
  "acryl-web-server",
12
13
  "acryl-page"
@@ -0,0 +1,2 @@
1
+ # Every checkout has the same line endings, on every OS. A plugin's digest counts every byte, so a CRLF checkout would make it a different plugin.
2
+ * text=auto eol=lf
@@ -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: