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,66 @@
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
+ A plugin whose route or home widget another plugin already has is refused, and the message names that plugin. A different copy of a plugin with the same name is refused too, and the message says where the copy in the app came from.
18
+
19
+ ## List and remove
20
+
21
+ ```
22
+ npm run acryl -- plugin list
23
+ npm run acryl -- plugin remove <name>
24
+ ```
25
+
26
+ The list names each plugin installed while the app runs, its version and state. A pulled one also shows the source it came from and its author, the GitHub account that released it.
27
+
28
+ Remove takes the plugin out of the running app and forgets its record. Its folder goes too when it still holds the release exactly as pulled; the pull line brings it back. A folder with someone's work in it stays, and the answer says so: the app's own plugin, or a pulled one changed since. The app then starts it again at its next start unless you delete the folder.
29
+
30
+ Both work in the frame as well.
31
+
32
+ ## Publish
33
+
34
+ ```
35
+ npm run acryl -- plugin publish <name> [--owner <account>] [--license <id>]
36
+ ```
37
+
38
+ 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.
39
+
40
+ ## An app made by an older version
41
+
42
+ Every new app has this plugin, except one made from the todo template, which installs nothing live. `upgrade` does not add it to an older app, so add it and mount it by hand:
43
+
44
+ ```
45
+ npx acrylblends add plugin-share
46
+ npm install
47
+ ```
48
+
49
+ In `src/app.ts`, import it, add it to the registry and give it the plugins folder:
50
+
51
+ ```ts
52
+ import { pluginShare } from '../plugins/plugin-share/index.ts'
53
+ // in the registry
54
+ 'plugin-share': pluginShare,
55
+ // in the configuration passed to compose
56
+ 'plugin-share': { dir: extensionsDir },
57
+ ```
58
+
59
+ In `blend.yaml`, add the row after `builder`:
60
+
61
+ ```yaml
62
+ - id: plugin-share
63
+ name: plugin-share
64
+ ```
65
+
66
+ Then restart the app.
@@ -0,0 +1,581 @@
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 { type ExtensionInfo, NAME } from '../extensions/index.ts'
38
+ import type {} from '../instance/index.ts'
39
+ import { LOCK_FILE, writeLock } from '../manifest/lock.ts'
40
+ import { packagePlugin } from './package.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
+ /** One plugin installed while the app runs, as a person reads the list: what it is and, for one pulled, where it came from and who released it. */
98
+ /** What extensions says about an installed plugin (its state, the services a pending one waits for, ...), and where a pulled one came from. */
99
+ export interface SharedPluginInfo extends ExtensionInfo {
100
+ /** The source it was pulled from (`github:<owner>/<repo>@<version>` or an address); absent for one made in this app or pulled from a local file. */
101
+ readonly pulledFrom?: string
102
+ /** Who released it: the account of a `github:` source or of a release address on github.com. */
103
+ readonly author?: string
104
+ }
105
+
106
+ /** What removing a plugin did with its folder in `plugins/`. */
107
+ export type RemovedFolder =
108
+ /** It held the release as pulled, so nothing of anyone's is lost: the pull line brings it back. */
109
+ | 'deleted'
110
+ /** It is this app's own work, or a pulled plugin changed after the pull: it stays, and the app starts it again at its next start. */
111
+ | 'kept'
112
+ | 'none'
113
+
114
+ export type SharedRemoveResult =
115
+ | {
116
+ readonly ok: true
117
+ readonly name: string
118
+ readonly folder: RemovedFolder
119
+ readonly note?: string
120
+ }
121
+ | { readonly ok: false; readonly name: string; readonly errors: readonly string[] }
122
+
123
+ declare module '@deepseek-ai/cordis' {
124
+ interface Context {
125
+ pluginShare: PluginShare
126
+ }
127
+ }
128
+
129
+ /** The account that released a source: the owner of a `github:<owner>/<repo>@<version>` source or of a release address on github.com. */
130
+ const authorOf = (source: string): string | undefined =>
131
+ (/^github:([^/]+)\//u.exec(source) ?? /^https:\/\/github\.com\/([^/]+)\//u.exec(source))?.[1]
132
+
133
+ /** The license a new public plugin is offered under; the plan shows it before the person says yes. */
134
+ const DEFAULT_LICENSE = 'MIT'
135
+ const LICENSE_ID = /^[A-Za-z0-9][A-Za-z0-9.+-]*$/u
136
+
137
+ const publishRefused = (reason: string, next: string): PublishRefused => ({
138
+ ok: false,
139
+ reason,
140
+ next,
141
+ })
142
+
143
+ const SOURCE_FORMS = 'github:<owner>/<repo>[@<version>] or a .tgz path or address'
144
+
145
+ /** Read what a person wrote. A `.tgz` path may be relative to the app folder; the hub only takes absolute ones. */
146
+ function readSource(
147
+ input: string,
148
+ root: string,
149
+ ): { readonly source: PluginSource; readonly digest?: string } {
150
+ const { source, digest } = splitDigest(input.trim())
151
+ const isLocalTarball = /\.(?:tgz|tar\.gz)$/iu.test(source) && !/^[a-z][a-z0-9+]+:/iu.test(source)
152
+ const parsed = parsePluginSource(isLocalTarball ? resolve(root, source) : source)
153
+ if (parsed.kind !== 'github' && parsed.kind !== 'tarball-file' && parsed.kind !== 'tarball-url') {
154
+ const named = parsed.kind === 'name' ? 'a bare name' : parsed.kind
155
+ throw new InvalidPluginSourceError(
156
+ input,
157
+ `${named} sources are not offered here; pull ${SOURCE_FORMS}`,
158
+ )
159
+ }
160
+ return { source: parsed, ...(digest === undefined ? {} : { digest }) }
161
+ }
162
+
163
+ /** The source as another machine can fetch it: the version pinned; a file on this machine names nothing. */
164
+ function recordedSource(source: PluginSource, version: string): string | undefined {
165
+ if (source.kind === 'github') return `github:${source.owner}/${source.repo}@${version}`
166
+ return source.kind === 'tarball-url' ? source.url : undefined
167
+ }
168
+
169
+ const reasonOf = (cause: unknown): string => {
170
+ if (cause instanceof PullError || cause instanceof InvalidPluginSourceError) return cause.reason
171
+ return cause instanceof Error ? cause.message : String(cause)
172
+ }
173
+
174
+ export class PluginShare extends Service {
175
+ /** What was fetched again at this start. */
176
+ readonly restored: Promise<readonly RestoreOutcome[]>
177
+ private readonly dir: string
178
+ private readonly releases: ReleasePort
179
+ private readonly ports: PublishPorts
180
+ /** One at a time: two pulls of one name must not race for its folder, nor two publishes for its package. */
181
+ private queue: Promise<unknown> = Promise.resolve()
182
+ /** The content digest of each plugin pulled while the app runs, a local file's too, which `blend.yaml` does not remember. */
183
+ private readonly live = new Map<string, string>()
184
+
185
+ /**
186
+ * The context the service was constructed with, in the app's realm. Inside a service method `this.ctx` is Cordis's shadow context of the caller: in the
187
+ * frame that is the host's door, which does not see the app's own services (`home`), so a plugin needing them was refused. As `extensions` does.
188
+ */
189
+ private readonly own: Context
190
+
191
+ constructor(ctx: Context, config: PluginShareConfig) {
192
+ super(ctx, 'pluginShare')
193
+ this.own = ctx
194
+ this.dir = resolve(config.dir ?? join(ctx.appInstance.root, 'plugins'))
195
+ this.releases = config.releases ?? githubReleasesOverHttps()
196
+ this.ports = config.publish ?? { host: githubCli(), git: gitCli() }
197
+ // Removing a plugin removes its record. Listened to here, not asked of each caller: every way of removing it goes through extensions.
198
+ ctx.on('extensions/changed', (name, change) => {
199
+ if (change !== 'removed') return
200
+ this.live.delete(name)
201
+ this.remember(name, undefined)
202
+ })
203
+ // Not awaited by the app's start: a person offline still gets their app, and the warning says what is missing.
204
+ this.restored = this.inTurn(() => this.restore())
205
+ }
206
+
207
+ /** 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. */
208
+ pull(source: string): Promise<PullResult> {
209
+ return this.inTurn(() => this.pullNow(source))
210
+ }
211
+
212
+ /**
213
+ * The content digest of a pulled plugin as it was installed: set before its folder appears, so whoever sees the folder change can tell the release as
214
+ * pulled from an edit (the frame then does not load the app again for it). A pull from a local file has no record in `blend.yaml`; this knows it too.
215
+ */
216
+ pulledDigest(name: string): string | undefined {
217
+ return this.live.get(name) ?? this.recorded(name)?.digest
218
+ }
219
+
220
+ /** The plugins installed while the app runs, each pulled one with its source and author. */
221
+ list(): SharedPluginInfo[] {
222
+ const file = join(this.own.appInstance.root, 'blend.yaml')
223
+ const records = existsSync(file) ? pulledRecords(readFileSync(file, 'utf8')) : {}
224
+ return this.own.extensions.list().map((info) => {
225
+ const record = records[info.name]
226
+ const author = record === undefined ? undefined : authorOf(record.source)
227
+ return {
228
+ ...info,
229
+ ...(record === undefined ? {} : { pulledFrom: record.source }),
230
+ ...(author === undefined ? {} : { author }),
231
+ }
232
+ })
233
+ }
234
+
235
+ /**
236
+ * Take a plugin out of the running app and forget its record. A pulled plugin's folder is deleted when it still holds the release as pulled; a
237
+ * folder with someone's work in it (this app's own plugin, or a pulled one changed since) stays, and the answer says so.
238
+ */
239
+ remove(name: string): Promise<SharedRemoveResult> {
240
+ return this.inTurn(() => this.removeNow(name))
241
+ }
242
+
243
+ private inTurn<T>(step: () => Promise<T>): Promise<T> {
244
+ const run = this.queue.then(step)
245
+ this.queue = run.catch(() => undefined)
246
+ return run
247
+ }
248
+
249
+ /**
250
+ * 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.
251
+ */
252
+ planPublish(name: string, options: PublishOptions = {}): Promise<PublishPlan | PublishRefused> {
253
+ return this.inTurn(() => this.planNow(name, options))
254
+ }
255
+
256
+ /**
257
+ * 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
258
+ * is sent. Only the person's own surfaces call this: the frame's confirm and the terminal's prompt. No verb reaches it.
259
+ */
260
+ publish(
261
+ name: string,
262
+ confirmed: PublishPlan,
263
+ options: PublishOptions = {},
264
+ ): Promise<PublishResult> {
265
+ return this.inTurn(async () => {
266
+ const plan = await this.planNow(name, options)
267
+ if ('ok' in plan) return plan
268
+ if (!samePlan(plan, confirmed)) {
269
+ return publishRefused(
270
+ `this plugin's publish changed since you confirmed it (now ${plan.owner}/${plan.repo} v${plan.version}, digest ${plan.digest}); nothing was sent`,
271
+ 'look at the new plan and say yes again',
272
+ )
273
+ }
274
+ const { home } = this.own.appInstance
275
+ try {
276
+ const shared = await sharePlugin(
277
+ join(this.packages, name),
278
+ {
279
+ owner: plan.owner,
280
+ repo: plan.repo,
281
+ visibility: plan.visibility,
282
+ kind: 'source-plugin',
283
+ },
284
+ { ...this.ports, store: new PluginStore(storeRootOf(home)) },
285
+ )
286
+ if (shared.status === 'refused')
287
+ return publishRefused(shared.reason, 'fix what it names; nothing was sent')
288
+ return {
289
+ ok: true,
290
+ url: shared.url,
291
+ releaseUrl: shared.releaseUrl,
292
+ version: shared.version,
293
+ integrity: shared.integrity,
294
+ digest: plan.digest,
295
+ pullLine: plan.pullLine,
296
+ }
297
+ } catch (cause) {
298
+ return publishRefused(
299
+ reasonOf(cause),
300
+ 'look at the repository; part of this publish may have been sent',
301
+ )
302
+ }
303
+ })
304
+ }
305
+
306
+ /** 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. */
307
+ private remember(name: string, record: PulledRecord | undefined): boolean {
308
+ const { root } = this.own.appInstance
309
+ const file = join(root, 'blend.yaml')
310
+ if (!existsSync(file)) return false
311
+ const text = readFileSync(file, 'utf8')
312
+ const next = withPulledRecord(text, name, record)
313
+ if (next !== text) {
314
+ writeFileSync(`${file}.tmp`, next)
315
+ renameSync(`${file}.tmp`, file)
316
+ if (existsSync(join(root, LOCK_FILE))) writeLock(root)
317
+ }
318
+ return record === undefined || pulledRecords(next)[name] !== undefined
319
+ }
320
+
321
+ private async removeNow(name: string): Promise<SharedRemoveResult> {
322
+ // Read before the removal, which forgets both the record and the live digest.
323
+ const pulled = this.pulledDigest(name)
324
+ const removed = await this.own.extensions.remove(name)
325
+ if (!removed.ok) return removed
326
+ const folder = join(this.dir, name)
327
+ if (!existsSync(folder)) return { ok: true, name, folder: 'none' }
328
+ if (pulled !== undefined && digestFolder(folder) === pulled) {
329
+ rmSync(folder, { recursive: true, force: true })
330
+ return { ok: true, name, folder: 'deleted' }
331
+ }
332
+ const why = pulled === undefined ? "is this app's own plugin" : 'was changed after the pull'
333
+ return {
334
+ ok: true,
335
+ name,
336
+ folder: 'kept',
337
+ note: `plugins/${name} ${why}, so it stays and the app starts it again at its next start; delete the folder to drop it for good`,
338
+ }
339
+ }
340
+
341
+ /** What `blend.yaml` remembers about one pulled plugin now. */
342
+ private recorded(name: string): PulledRecord | undefined {
343
+ const file = join(this.own.appInstance.root, 'blend.yaml')
344
+ return existsSync(file) ? pulledRecords(readFileSync(file, 'utf8'))[name] : undefined
345
+ }
346
+
347
+ /** Fetch the remembered plugins whose folder is gone, exactly as recorded: other bytes are refused, and the record stays for the person to decide. */
348
+ private async restore(): Promise<readonly RestoreOutcome[]> {
349
+ const file = join(this.own.appInstance.root, 'blend.yaml')
350
+ if (!existsSync(file)) return []
351
+ const outcomes: RestoreOutcome[] = []
352
+ for (const [name, record] of Object.entries(pulledRecords(readFileSync(file, 'utf8')))) {
353
+ if (existsSync(join(this.dir, name))) continue
354
+ const result = await this.pullNow(
355
+ `${record.source}@sha256:${record.digest}`,
356
+ record.integrity,
357
+ )
358
+ outcomes.push(
359
+ result.ok
360
+ ? { name, ok: true }
361
+ : { name, ok: false, reason: result.reason, next: result.next },
362
+ )
363
+ if (!result.ok)
364
+ console.warn(
365
+ `plugin-share: ${name} was not fetched again: ${result.reason}. ${result.next}.`,
366
+ )
367
+ }
368
+ return outcomes
369
+ }
370
+
371
+ private get packages(): string {
372
+ return join(this.own.appInstance.home, 'plugin-packages')
373
+ }
374
+
375
+ private async planNow(
376
+ name: string,
377
+ options: PublishOptions,
378
+ ): Promise<PublishPlan | PublishRefused> {
379
+ const source = join(this.dir, name)
380
+ if (!NAME.test(name) || !existsSync(source))
381
+ return publishRefused(
382
+ `this app has no plugin "${name}" in plugins/`,
383
+ 'name a folder of plugins/',
384
+ )
385
+ try {
386
+ const { host } = this.ports
387
+ const owner = options.owner ?? host.login()
388
+ const repo = name
389
+ const description = `The ${name} Cordis plugin.`
390
+ const license = options.license ?? DEFAULT_LICENSE
391
+ if (!LICENSE_ID.test(license))
392
+ return publishRefused(
393
+ `"${license}" is not a license id`,
394
+ 'write one such as MIT or Apache-2.0',
395
+ )
396
+ const release = { owner, repo, license }
397
+ const existing = host.repository(owner, repo)
398
+ let packaged = await packagePlugin(source, this.packages, name, description, { release })
399
+ const released =
400
+ existing !== undefined && host.releaseExists(owner, repo, `v${packaged.version}`)
401
+ ? packaged.version
402
+ : undefined
403
+ if (released !== undefined)
404
+ packaged = await packagePlugin(source, this.packages, name, description, {
405
+ release,
406
+ version: bumpVersion(released, 'patch'),
407
+ })
408
+ const secrets = findSecrets(
409
+ readTarball(packFolder(packaged.dir).tarball).map((file) => ({
410
+ path: file.path,
411
+ text: Buffer.from(file.content).toString('utf8'),
412
+ })),
413
+ )
414
+ if (secrets.length > 0) {
415
+ return publishRefused(
416
+ `the secret check found ${String(secrets.length)} possible secret(s) in ${secrets.map((secret) => secret.path).join(', ')}; nothing was sent`,
417
+ "take them out of the plugin and read them from the app's settings",
418
+ )
419
+ }
420
+ return {
421
+ owner,
422
+ repo,
423
+ version: packaged.version,
424
+ visibility: 'public',
425
+ license,
426
+ createsRepository: existing === undefined,
427
+ ...(released === undefined ? {} : { released }),
428
+ files: packaged.files,
429
+ integrity: packaged.integrity,
430
+ digest: packaged.digest,
431
+ pullLine: pullLineOf(owner, repo, packaged.version, packaged.digest),
432
+ }
433
+ } catch (cause) {
434
+ return publishRefused(reasonOf(cause), 'fix it, or sign in with gh; nothing was sent')
435
+ }
436
+ }
437
+
438
+ private async pullNow(input: string, expectIntegrity?: string): Promise<PullResult> {
439
+ const { home, root } = this.own.appInstance
440
+ let read: ReturnType<typeof readSource>
441
+ try {
442
+ read = readSource(input, root)
443
+ } catch (cause) {
444
+ return refuse(
445
+ 'source',
446
+ reasonOf(cause),
447
+ `write ${SOURCE_FORMS}, with @sha256:<digest> at the end to name the content`,
448
+ )
449
+ }
450
+ mkdirSync(home, { recursive: true })
451
+ const scratch = mkdtempSync(join(home, 'plugin-pull-'))
452
+ try {
453
+ let pulled: Awaited<ReturnType<typeof pullPlugin>>
454
+ try {
455
+ pulled = await pullPlugin(
456
+ read.source,
457
+ {
458
+ store: new PluginStore(scratch),
459
+ releases: this.releases,
460
+ download: httpsDownload(),
461
+ },
462
+ expectIntegrity === undefined ? {} : { expectIntegrity },
463
+ )
464
+ } catch (cause) {
465
+ return refuse(
466
+ 'fetch',
467
+ reasonOf(cause),
468
+ 'check the source with the person who released it; nothing was installed',
469
+ )
470
+ }
471
+ const files: PluginFile[] = readTarball(readFileSync(pulled.stored.path)).map(
472
+ ({ path, content }) => ({ path, bytes: content, kind: 'file' }),
473
+ )
474
+ const refused = pullRefusal(pulled.manifest, files, {
475
+ digest: read.digest,
476
+ nameFits: (name) => NAME.test(name),
477
+ hasService: (service) => this.own.get(service as never) !== undefined,
478
+ })
479
+ if (refused !== undefined) return refused
480
+ return await this.install(
481
+ pulled.stored.name,
482
+ pulled.stored.version,
483
+ pulled.stored.integrity,
484
+ files,
485
+ recordedSource(read.source, pulled.stored.version),
486
+ )
487
+ } finally {
488
+ rmSync(scratch, { recursive: true, force: true })
489
+ }
490
+ }
491
+
492
+ /** Put the files in `plugins/<name>/` and install them; whatever does not end running takes its folder with it. */
493
+ private async install(
494
+ name: string,
495
+ version: string,
496
+ integrity: string,
497
+ files: readonly PluginFile[],
498
+ source: string | undefined,
499
+ ): Promise<PullResult> {
500
+ const target = join(this.dir, name)
501
+ const incoming = join(this.dir, `.pulling-${name}`)
502
+ rmSync(incoming, { recursive: true, force: true })
503
+ try {
504
+ for (const file of files) {
505
+ const path = join(incoming, ...file.path.split('/'))
506
+ mkdirSync(dirname(path), { recursive: true })
507
+ writeFileSync(path, file.bytes)
508
+ }
509
+ const digest = digestFolder(incoming)
510
+ // The folder may be there already: the same bytes are already pulled, anything else is someone's work.
511
+ const wrote = !existsSync(target)
512
+ const before = this.recorded(name)
513
+ const held = wrote ? undefined : digestFolder(target)
514
+ if (held !== undefined && held !== digest) {
515
+ const holder = before === undefined ? 'made in this app' : `pulled from ${before.source}`
516
+ // Only an untouched pulled copy goes with `plugin remove`; anything else is someone's work, which the person moves themselves.
517
+ const next =
518
+ before !== undefined && held === before.digest
519
+ ? `keep it, or remove it (npm run acryl -- plugin remove ${name}) and pull again`
520
+ : `keep it, or move plugins/${name} out of plugins/ and pull again`
521
+ return refuse(
522
+ 'folder-exists',
523
+ `plugins/${name} holds a different copy of this plugin, ${holder}`,
524
+ next,
525
+ )
526
+ }
527
+ const liveBefore = this.live.get(name)
528
+ this.live.set(name, digest)
529
+ if (wrote) renameSync(incoming, target)
530
+ // Remembered as the folder appears, before it is installed, so whoever sees the folder can tell it is these released bytes (the frame then does
531
+ // not load the app again for a plugin the app installs live); a pull that does not end running puts back what was remembered before.
532
+ const remembered = source !== undefined && this.remember(name, { source, integrity, digest })
533
+ const undo = () => {
534
+ if (wrote) rmSync(target, { recursive: true, force: true })
535
+ if (remembered) this.remember(name, before)
536
+ if (liveBefore === undefined) this.live.delete(name)
537
+ else this.live.set(name, liveBefore)
538
+ }
539
+ let result: Awaited<ReturnType<Context['extensions']['install']>>
540
+ try {
541
+ result = await this.own.extensions.install(target)
542
+ } catch (cause) {
543
+ undo()
544
+ return refuse(
545
+ 'install',
546
+ reasonOf(cause),
547
+ 'this app does not install plugins while it runs like this',
548
+ )
549
+ }
550
+ if (!result.ok) {
551
+ undo()
552
+ return refuse('install', result.errors.join('; '), result.next)
553
+ }
554
+ if (result.state === 'pending') {
555
+ await this.own.extensions.remove(name)
556
+ undo()
557
+ return missingServices(result.missing)
558
+ }
559
+ return {
560
+ ok: true,
561
+ name,
562
+ version,
563
+ integrity,
564
+ digest,
565
+ action: result.action,
566
+ state: result.state,
567
+ remembered,
568
+ }
569
+ } finally {
570
+ rmSync(incoming, { recursive: true, force: true })
571
+ }
572
+ }
573
+ }
574
+
575
+ export const pluginShare = {
576
+ name: 'plugin-share',
577
+ inject: ['extensions', 'appInstance'],
578
+ apply(ctx: Context, config: PluginShareConfig) {
579
+ new PluginShare(ctx, config ?? {})
580
+ },
581
+ }