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,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
|
+
}
|