acrylblends 1.1.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.
- package/README.md +9 -0
- package/dist/index.d.ts +47 -2
- package/dist/index.js +2017 -32
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +5 -3
- package/docs/framework/workshop/README.md +139 -153
- package/docs/framework/workshop/facilitator.md +69 -101
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +87 -9
- package/lib/cordis/door.js +324 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.js +1 -1
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -1
- package/lib/door/page.js +46 -7
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -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/channel.ts +86 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +128 -24
- package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +14 -1
- package/plugins/acryl-door/src/index.ts +168 -30
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +41 -2
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +238 -26
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +146 -57
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/builder/index.ts +13 -35
- package/plugins/builder/package.ts +186 -0
- package/plugins/builder/plugin.json +3 -1
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +47 -18
- package/plugins/extensions/plugin-store.ts +27 -0
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/extensions/stage.ts +15 -5
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +6 -0
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/plugin.json +1 -1
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/plugins/plugin-share/README.md +51 -0
- package/plugins/plugin-share/index.ts +467 -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/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/.gitattributes +2 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/blend.yaml +6 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +154 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +38 -19
- package/templates/agent/template.json +3 -0
- package/templates/agent/test/app.test.ts +45 -3
- package/templates/blank/.gitattributes +2 -0
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/.gitattributes +2 -0
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/.gitattributes +2 -0
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/.gitattributes +2 -0
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/.gitattributes +2 -0
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- package/shared/SKILL.md +0 -14
|
@@ -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
|
+
)
|