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
@@ -0,0 +1,467 @@
1
+ /**
2
+ * Sharing a plugin between people: `pull(source)` turns a release someone published into a running plugin in this app. It fetches without running
3
+ * anything, decides from the package's files (`pull-check.ts`), and hands a folder to `extensions`, which owns the install and its rollback.
4
+ * Design: docs/plans/plugin-share.md.
5
+ */
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ readFileSync,
11
+ renameSync,
12
+ rmSync,
13
+ writeFileSync,
14
+ } from 'node:fs'
15
+ import { dirname, join, resolve } from 'node:path'
16
+ import { type Context, Service } from '@deepseek-ai/cordis'
17
+ import { findSecrets } from '@webboxes/app-persistence'
18
+ import { digestFolder, type PluginFile } from '@webboxes/blends-core'
19
+ import {
20
+ bumpVersion,
21
+ gitCli,
22
+ githubCli,
23
+ githubReleasesOverHttps,
24
+ httpsDownload,
25
+ InvalidPluginSourceError,
26
+ type PluginSource,
27
+ PluginStore,
28
+ PullError,
29
+ packFolder,
30
+ parsePluginSource,
31
+ pullPlugin,
32
+ type ReleasePort,
33
+ readTarball,
34
+ sharePlugin,
35
+ storeRootOf,
36
+ } from '@webboxes/plugin-hub'
37
+ import { packagePlugin } from '../builder/package.ts'
38
+ import { NAME } from '../extensions/index.ts'
39
+ import type {} from '../instance/index.ts'
40
+ import { LOCK_FILE, writeLock } from '../manifest/lock.ts'
41
+ import {
42
+ type PublishOptions,
43
+ type PublishPlan,
44
+ type PublishPorts,
45
+ type PublishRefused,
46
+ type PublishResult,
47
+ pullLineOf,
48
+ samePlan,
49
+ } from './publish.ts'
50
+ import {
51
+ missingServices,
52
+ type PullRefused,
53
+ pullRefusal,
54
+ refuse,
55
+ splitDigest,
56
+ } from './pull-check.ts'
57
+ import { type PulledRecord, pulledRecords, withPulledRecord } from './pulled-records.ts'
58
+
59
+ export type {
60
+ PublishOptions,
61
+ PublishPlan,
62
+ PublishPorts,
63
+ PublishRefused,
64
+ PublishResult,
65
+ } from './publish.ts'
66
+
67
+ /** One remembered plugin the app fetched again at its start because its folder was gone. */
68
+ export type RestoreOutcome =
69
+ | { readonly name: string; readonly ok: true }
70
+ | { readonly name: string; readonly ok: false; readonly reason: string; readonly next: string }
71
+
72
+ export interface PluginShareConfig {
73
+ /** Where pulled plugins go; must be an extensions source. Defaults to `<project>/plugins`. */
74
+ dir?: string
75
+ /** Where a `github:` source reads releases. Defaults to GitHub over HTTPS. */
76
+ releases?: ReleasePort
77
+ /** Where a publish goes. Defaults to the person's own `gh` and `git`. */
78
+ publish?: PublishPorts
79
+ }
80
+
81
+ export type PullResult =
82
+ | {
83
+ readonly ok: true
84
+ readonly name: string
85
+ readonly version: string
86
+ /** The sha512 of the release's tarball, as the registry lists it. */
87
+ readonly integrity: string
88
+ /** The sha256 of the plugin's content: the same on every OS. A source can name it (`@sha256:<digest>`). */
89
+ readonly digest: string
90
+ readonly action: 'installed' | 'updated' | 'unchanged'
91
+ readonly state: string
92
+ /** Whether the app's `blend.yaml` and lock now name this source, so a copy of the app can fetch it. A local file names nothing to fetch. */
93
+ readonly remembered: boolean
94
+ }
95
+ | PullRefused
96
+
97
+ declare module '@deepseek-ai/cordis' {
98
+ interface Context {
99
+ pluginShare: PluginShare
100
+ }
101
+ }
102
+
103
+ /** The license a new public plugin is offered under; the plan shows it before the person says yes. */
104
+ const DEFAULT_LICENSE = 'MIT'
105
+ const LICENSE_ID = /^[A-Za-z0-9][A-Za-z0-9.+-]*$/u
106
+
107
+ const publishRefused = (reason: string, next: string): PublishRefused => ({
108
+ ok: false,
109
+ reason,
110
+ next,
111
+ })
112
+
113
+ const SOURCE_FORMS = 'github:<owner>/<repo>[@<version>] or a .tgz path or address'
114
+
115
+ /** Read what a person wrote. A `.tgz` path may be relative to the app folder; the hub only takes absolute ones. */
116
+ function readSource(
117
+ input: string,
118
+ root: string,
119
+ ): { readonly source: PluginSource; readonly digest?: string } {
120
+ const { source, digest } = splitDigest(input.trim())
121
+ const isLocalTarball = /\.(?:tgz|tar\.gz)$/iu.test(source) && !/^[a-z][a-z0-9+]+:/iu.test(source)
122
+ const parsed = parsePluginSource(isLocalTarball ? resolve(root, source) : source)
123
+ if (parsed.kind !== 'github' && parsed.kind !== 'tarball-file' && parsed.kind !== 'tarball-url') {
124
+ const named = parsed.kind === 'name' ? 'a bare name' : parsed.kind
125
+ throw new InvalidPluginSourceError(
126
+ input,
127
+ `${named} sources are not offered here; pull ${SOURCE_FORMS}`,
128
+ )
129
+ }
130
+ return { source: parsed, ...(digest === undefined ? {} : { digest }) }
131
+ }
132
+
133
+ /** The source as another machine can fetch it: the version pinned; a file on this machine names nothing. */
134
+ function recordedSource(source: PluginSource, version: string): string | undefined {
135
+ if (source.kind === 'github') return `github:${source.owner}/${source.repo}@${version}`
136
+ return source.kind === 'tarball-url' ? source.url : undefined
137
+ }
138
+
139
+ const reasonOf = (cause: unknown): string => {
140
+ if (cause instanceof PullError || cause instanceof InvalidPluginSourceError) return cause.reason
141
+ return cause instanceof Error ? cause.message : String(cause)
142
+ }
143
+
144
+ export class PluginShare extends Service {
145
+ /** What was fetched again at this start. */
146
+ readonly restored: Promise<readonly RestoreOutcome[]>
147
+ private readonly dir: string
148
+ private readonly releases: ReleasePort
149
+ private readonly ports: PublishPorts
150
+ /** One at a time: two pulls of one name must not race for its folder, nor two publishes for its package. */
151
+ private queue: Promise<unknown> = Promise.resolve()
152
+
153
+ constructor(ctx: Context, config: PluginShareConfig) {
154
+ super(ctx, 'pluginShare')
155
+ this.dir = resolve(config.dir ?? join(ctx.appInstance.root, 'plugins'))
156
+ this.releases = config.releases ?? githubReleasesOverHttps()
157
+ this.ports = config.publish ?? { host: githubCli(), git: gitCli() }
158
+ // Removing a plugin removes its record. Listened to here, not asked of each caller: every way of removing it goes through extensions.
159
+ ctx.on('extensions/changed', (name, change) => {
160
+ if (change === 'removed') this.remember(name, undefined)
161
+ })
162
+ // Not awaited by the app's start: a person offline still gets their app, and the warning says what is missing.
163
+ this.restored = this.inTurn(() => this.restore())
164
+ }
165
+
166
+ /** Pull a plugin into the running app. Never throws for what a person can act on: a refusal says what is wrong and what to do, and changes nothing. */
167
+ pull(source: string): Promise<PullResult> {
168
+ return this.inTurn(() => this.pullNow(source))
169
+ }
170
+
171
+ private inTurn<T>(step: () => Promise<T>): Promise<T> {
172
+ const run = this.queue.then(step)
173
+ this.queue = run.catch(() => undefined)
174
+ return run
175
+ }
176
+
177
+ /**
178
+ * What publishing this plugin would do, read without sending anything. A plan is the only thing an agent can get: it never confirms for a person.
179
+ */
180
+ planPublish(name: string, options: PublishOptions = {}): Promise<PublishPlan | PublishRefused> {
181
+ return this.inTurn(() => this.planNow(name, options))
182
+ }
183
+
184
+ /**
185
+ * Publish exactly the plan a person said yes to: it is made again, and if anything differs (the plugin, the account, a release made meanwhile) nothing
186
+ * is sent. Only the person's own surfaces call this: the frame's confirm and the terminal's prompt. No verb reaches it.
187
+ */
188
+ publish(
189
+ name: string,
190
+ confirmed: PublishPlan,
191
+ options: PublishOptions = {},
192
+ ): Promise<PublishResult> {
193
+ return this.inTurn(async () => {
194
+ const plan = await this.planNow(name, options)
195
+ if ('ok' in plan) return plan
196
+ if (!samePlan(plan, confirmed)) {
197
+ return publishRefused(
198
+ `this plugin's publish changed since you confirmed it (now ${plan.owner}/${plan.repo} v${plan.version}, digest ${plan.digest}); nothing was sent`,
199
+ 'look at the new plan and say yes again',
200
+ )
201
+ }
202
+ const { home } = this.ctx.appInstance
203
+ try {
204
+ const shared = await sharePlugin(
205
+ join(this.packages, name),
206
+ {
207
+ owner: plan.owner,
208
+ repo: plan.repo,
209
+ visibility: plan.visibility,
210
+ kind: 'source-plugin',
211
+ },
212
+ { ...this.ports, store: new PluginStore(storeRootOf(home)) },
213
+ )
214
+ if (shared.status === 'refused')
215
+ return publishRefused(shared.reason, 'fix what it names; nothing was sent')
216
+ return {
217
+ ok: true,
218
+ url: shared.url,
219
+ releaseUrl: shared.releaseUrl,
220
+ version: shared.version,
221
+ integrity: shared.integrity,
222
+ digest: plan.digest,
223
+ pullLine: plan.pullLine,
224
+ }
225
+ } catch (cause) {
226
+ return publishRefused(
227
+ reasonOf(cause),
228
+ 'look at the repository; part of this publish may have been sent',
229
+ )
230
+ }
231
+ })
232
+ }
233
+
234
+ /** Write one plugin's record into `blend.yaml`, and the lock when the app has one, so the two still match. @returns whether the app holds the record. */
235
+ private remember(name: string, record: PulledRecord | undefined): boolean {
236
+ const { root } = this.ctx.appInstance
237
+ const file = join(root, 'blend.yaml')
238
+ if (!existsSync(file)) return false
239
+ const text = readFileSync(file, 'utf8')
240
+ const next = withPulledRecord(text, name, record)
241
+ if (next !== text) {
242
+ writeFileSync(`${file}.tmp`, next)
243
+ renameSync(`${file}.tmp`, file)
244
+ if (existsSync(join(root, LOCK_FILE))) writeLock(root)
245
+ }
246
+ return record === undefined || pulledRecords(next)[name] !== undefined
247
+ }
248
+
249
+ /** Fetch the remembered plugins whose folder is gone, exactly as recorded: other bytes are refused, and the record stays for the person to decide. */
250
+ private async restore(): Promise<readonly RestoreOutcome[]> {
251
+ const file = join(this.ctx.appInstance.root, 'blend.yaml')
252
+ if (!existsSync(file)) return []
253
+ const outcomes: RestoreOutcome[] = []
254
+ for (const [name, record] of Object.entries(pulledRecords(readFileSync(file, 'utf8')))) {
255
+ if (existsSync(join(this.dir, name))) continue
256
+ const result = await this.pullNow(
257
+ `${record.source}@sha256:${record.digest}`,
258
+ record.integrity,
259
+ )
260
+ outcomes.push(
261
+ result.ok
262
+ ? { name, ok: true }
263
+ : { name, ok: false, reason: result.reason, next: result.next },
264
+ )
265
+ if (!result.ok)
266
+ console.warn(
267
+ `plugin-share: ${name} was not fetched again: ${result.reason}. ${result.next}.`,
268
+ )
269
+ }
270
+ return outcomes
271
+ }
272
+
273
+ private get packages(): string {
274
+ return join(this.ctx.appInstance.home, 'plugin-packages')
275
+ }
276
+
277
+ private async planNow(
278
+ name: string,
279
+ options: PublishOptions,
280
+ ): Promise<PublishPlan | PublishRefused> {
281
+ const source = join(this.dir, name)
282
+ if (!NAME.test(name) || !existsSync(source))
283
+ return publishRefused(
284
+ `this app has no plugin "${name}" in plugins/`,
285
+ 'name a folder of plugins/',
286
+ )
287
+ try {
288
+ const { host } = this.ports
289
+ const owner = options.owner ?? host.login()
290
+ const repo = name
291
+ const description = `The ${name} Cordis plugin.`
292
+ const license = options.license ?? DEFAULT_LICENSE
293
+ if (!LICENSE_ID.test(license))
294
+ return publishRefused(
295
+ `"${license}" is not a license id`,
296
+ 'write one such as MIT or Apache-2.0',
297
+ )
298
+ const release = { owner, repo, license }
299
+ const existing = host.repository(owner, repo)
300
+ let packaged = await packagePlugin(source, this.packages, name, description, { release })
301
+ const released =
302
+ existing !== undefined && host.releaseExists(owner, repo, `v${packaged.version}`)
303
+ ? packaged.version
304
+ : undefined
305
+ if (released !== undefined)
306
+ packaged = await packagePlugin(source, this.packages, name, description, {
307
+ release,
308
+ version: bumpVersion(released, 'patch'),
309
+ })
310
+ const secrets = findSecrets(
311
+ readTarball(packFolder(packaged.dir).tarball).map((file) => ({
312
+ path: file.path,
313
+ text: Buffer.from(file.content).toString('utf8'),
314
+ })),
315
+ )
316
+ if (secrets.length > 0) {
317
+ return publishRefused(
318
+ `the secret check found ${String(secrets.length)} possible secret(s) in ${secrets.map((secret) => secret.path).join(', ')}; nothing was sent`,
319
+ "take them out of the plugin and read them from the app's settings",
320
+ )
321
+ }
322
+ return {
323
+ owner,
324
+ repo,
325
+ version: packaged.version,
326
+ visibility: 'public',
327
+ license,
328
+ createsRepository: existing === undefined,
329
+ ...(released === undefined ? {} : { released }),
330
+ files: packaged.files,
331
+ integrity: packaged.integrity,
332
+ digest: packaged.digest,
333
+ pullLine: pullLineOf(owner, repo, packaged.version, packaged.digest),
334
+ }
335
+ } catch (cause) {
336
+ return publishRefused(reasonOf(cause), 'fix it, or sign in with gh; nothing was sent')
337
+ }
338
+ }
339
+
340
+ private async pullNow(input: string, expectIntegrity?: string): Promise<PullResult> {
341
+ const { home, root } = this.ctx.appInstance
342
+ let read: ReturnType<typeof readSource>
343
+ try {
344
+ read = readSource(input, root)
345
+ } catch (cause) {
346
+ return refuse(
347
+ 'source',
348
+ reasonOf(cause),
349
+ `write ${SOURCE_FORMS}, with @sha256:<digest> at the end to name the content`,
350
+ )
351
+ }
352
+ mkdirSync(home, { recursive: true })
353
+ const scratch = mkdtempSync(join(home, 'plugin-pull-'))
354
+ try {
355
+ let pulled: Awaited<ReturnType<typeof pullPlugin>>
356
+ try {
357
+ pulled = await pullPlugin(
358
+ read.source,
359
+ {
360
+ store: new PluginStore(scratch),
361
+ releases: this.releases,
362
+ download: httpsDownload(),
363
+ },
364
+ expectIntegrity === undefined ? {} : { expectIntegrity },
365
+ )
366
+ } catch (cause) {
367
+ return refuse(
368
+ 'fetch',
369
+ reasonOf(cause),
370
+ 'check the source with the person who released it; nothing was installed',
371
+ )
372
+ }
373
+ const files: PluginFile[] = readTarball(readFileSync(pulled.stored.path)).map(
374
+ ({ path, content }) => ({ path, bytes: content, kind: 'file' }),
375
+ )
376
+ const refused = pullRefusal(pulled.manifest, files, {
377
+ digest: read.digest,
378
+ nameFits: (name) => NAME.test(name),
379
+ hasService: (service) => this.ctx.get(service as never) !== undefined,
380
+ })
381
+ if (refused !== undefined) return refused
382
+ return await this.install(
383
+ pulled.stored.name,
384
+ pulled.stored.version,
385
+ pulled.stored.integrity,
386
+ files,
387
+ recordedSource(read.source, pulled.stored.version),
388
+ )
389
+ } finally {
390
+ rmSync(scratch, { recursive: true, force: true })
391
+ }
392
+ }
393
+
394
+ /** Put the files in `plugins/<name>/` and install them; whatever does not end running takes its folder with it. */
395
+ private async install(
396
+ name: string,
397
+ version: string,
398
+ integrity: string,
399
+ files: readonly PluginFile[],
400
+ source: string | undefined,
401
+ ): Promise<PullResult> {
402
+ const target = join(this.dir, name)
403
+ const incoming = join(this.dir, `.pulling-${name}`)
404
+ rmSync(incoming, { recursive: true, force: true })
405
+ try {
406
+ for (const file of files) {
407
+ const path = join(incoming, ...file.path.split('/'))
408
+ mkdirSync(dirname(path), { recursive: true })
409
+ writeFileSync(path, file.bytes)
410
+ }
411
+ const digest = digestFolder(incoming)
412
+ // The folder may be there already: the same bytes are already pulled, anything else is someone's work.
413
+ const wrote = !existsSync(target)
414
+ if (wrote) renameSync(incoming, target)
415
+ else if (digestFolder(target) !== digest) {
416
+ return refuse(
417
+ 'folder-exists',
418
+ `plugins/${name} holds a different copy of this plugin`,
419
+ `keep it, or remove plugins/${name} and pull again`,
420
+ )
421
+ }
422
+ const undo = () => {
423
+ if (wrote) rmSync(target, { recursive: true, force: true })
424
+ }
425
+ let result: Awaited<ReturnType<Context['extensions']['install']>>
426
+ try {
427
+ result = await this.ctx.extensions.install(target)
428
+ } catch (cause) {
429
+ undo()
430
+ return refuse(
431
+ 'install',
432
+ reasonOf(cause),
433
+ 'this app does not install plugins while it runs like this',
434
+ )
435
+ }
436
+ if (!result.ok) {
437
+ undo()
438
+ return refuse('install', result.errors.join('; '), result.next)
439
+ }
440
+ if (result.state === 'pending') {
441
+ await this.ctx.extensions.remove(name)
442
+ undo()
443
+ return missingServices(result.missing)
444
+ }
445
+ return {
446
+ ok: true,
447
+ name,
448
+ version,
449
+ integrity,
450
+ digest,
451
+ action: result.action,
452
+ state: result.state,
453
+ remembered: source !== undefined && this.remember(name, { source, integrity, digest }),
454
+ }
455
+ } finally {
456
+ rmSync(incoming, { recursive: true, force: true })
457
+ }
458
+ }
459
+ }
460
+
461
+ export const pluginShare = {
462
+ name: 'plugin-share',
463
+ inject: ['extensions', 'appInstance'],
464
+ apply(ctx: Context, config: PluginShareConfig) {
465
+ new PluginShare(ctx, config ?? {})
466
+ },
467
+ }
@@ -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", "builder", "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
+ )