acrylblends 0.0.0-stage → 0.2.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/bin/acrylblends.js +4 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.js +434 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -4
- package/plugins/agent/agent.ts +152 -0
- package/plugins/agent/index.ts +20 -0
- package/plugins/agent/memory.ts +24 -0
- package/plugins/agent/model.ts +79 -0
- package/plugins/agent/plugin.json +10 -0
- package/plugins/agent/tools.ts +50 -0
- package/plugins/builder/index.ts +193 -0
- package/plugins/builder/plugin.json +11 -0
- package/plugins/builder/script.ts +162 -0
- package/plugins/events/index.ts +42 -0
- package/plugins/events/plugin.json +9 -0
- package/plugins/extensions/index.ts +738 -0
- package/plugins/extensions/ledger.ts +39 -0
- package/plugins/extensions/plugin.json +12 -0
- package/plugins/extensions/resolve.ts +30 -0
- package/plugins/extensions/stage.ts +44 -0
- package/plugins/http/index.ts +64 -0
- package/plugins/http/plugin.json +10 -0
- package/plugins/instance/index.ts +167 -0
- package/plugins/instance/plugin.json +10 -0
- package/plugins/manifest/index.ts +635 -0
- package/plugins/manifest/lock.ts +94 -0
- package/plugins/manifest/plugin.json +13 -0
- package/plugins/manifest/upgrade-cli.ts +70 -0
- package/plugins/manifest/upgrade.ts +97 -0
- package/plugins/openai/index.ts +159 -0
- package/plugins/openai/plugin.json +11 -0
- package/plugins/openai/prompt.ts +31 -0
- package/plugins/sandbox/index.ts +150 -0
- package/plugins/sandbox/plugin.json +8 -0
- package/plugins/sandbox/runner.mjs +18 -0
- package/plugins/save/cli.ts +19 -0
- package/plugins/save/index.ts +55 -0
- package/plugins/save/plugin.json +12 -0
- package/plugins/server/index.ts +138 -0
- package/plugins/server/plugin.json +10 -0
- package/plugins/settings/index.ts +270 -0
- package/plugins/settings/merge.ts +79 -0
- package/plugins/settings/plugin.json +13 -0
- package/plugins/tasks/index.ts +232 -0
- package/plugins/tasks/plugin.json +11 -0
- package/plugins/tasks/store.ts +86 -0
- package/plugins/tsconfig.json +19 -0
- package/templates/agent/Dockerfile +15 -0
- package/templates/agent/README.md +52 -0
- package/templates/agent/blend.yaml +58 -0
- package/templates/agent/dockerignore +3 -0
- package/templates/agent/gitignore +2 -0
- package/templates/agent/package.json +23 -0
- package/templates/agent/src/app.ts +98 -0
- package/templates/agent/src/main.ts +25 -0
- package/templates/agent/src/routes.ts +62 -0
- package/templates/agent/template.json +16 -0
- package/templates/agent/test/agent.test.ts +45 -0
- package/templates/agent/test/app.test.ts +46 -0
- package/templates/agent/test/builder.test.ts +186 -0
- package/templates/agent/test/model.test.ts +89 -0
- package/templates/agent/tsconfig.json +19 -0
- package/templates/app/README.md +26 -0
- package/templates/app/blend.yaml +23 -0
- package/templates/app/gitignore +2 -0
- package/templates/app/package.json +23 -0
- package/templates/app/src/app.ts +61 -0
- package/templates/app/src/main.ts +20 -0
- package/templates/app/src/routes.ts +14 -0
- package/templates/app/template.json +1 -0
- package/templates/app/test/app.test.ts +56 -0
- package/templates/app/tsconfig.json +19 -0
- package/README.md +0 -3
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The extensions service (`ctx.extensions`): install, update, remove and reload Cordis plugins while the app runs.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from ACRYL (`plugins/acryl-extension-context/lib/install.js`, `stage.js`, `reconcile.js`), where an agent-written plugin
|
|
5
|
+
* installs, runs, updates and is removed live in the packaged app on macOS, Windows and Linux. ACRYL goes through pnpm and the Harness
|
|
6
|
+
* Loader; here an extension is a folder with an `index.ts` (or `.js`) that exports a Cordis plugin, mounted as a child fiber of this
|
|
7
|
+
* plugin. The rules carried over:
|
|
8
|
+
*
|
|
9
|
+
* - Check before changing anything; every answer is a structured result the agent can act on (`ok`, `stage`, `errors`, `next`).
|
|
10
|
+
* - Stage a content-hashed copy and import it fresh, so an update runs new code without a restart (`stage.ts`).
|
|
11
|
+
* - Import the new version while the old one still runs; dispose the old one only then. If the new one fails to start, mount the old
|
|
12
|
+
* one again (`restoredPrevious`), so a broken update never leaves the app without the version that worked.
|
|
13
|
+
* - Report the real fiber state: `active`, `pending` (with the services it waits for) or `failed` (with the real error).
|
|
14
|
+
* - One operation at a time, in order. A ledger line for every change (`ledger.ts`).
|
|
15
|
+
* - At startup, reinstall what was installed; install new folders only from sources trusted to `install`, and only list the rest
|
|
16
|
+
* (ACRYL's trust rule: code that arrived through a `git pull` must not run until someone asks for it).
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
renameSync,
|
|
24
|
+
rmSync,
|
|
25
|
+
statSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
} from 'node:fs'
|
|
28
|
+
import { basename, isAbsolute, join, relative, resolve } from 'node:path'
|
|
29
|
+
import { pathToFileURL } from 'node:url'
|
|
30
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
31
|
+
import { type Context, type Fiber, Service } from '@deepseek-ai/cordis'
|
|
32
|
+
import type {} from '../instance/index.ts'
|
|
33
|
+
import type { Sandbox } from '../sandbox/index.ts'
|
|
34
|
+
import { appendLedger, type LedgerEntry, readLedger } from './ledger.ts'
|
|
35
|
+
import { resolveFromApp } from './resolve.ts'
|
|
36
|
+
import { hashFolder, prune, stage } from './stage.ts'
|
|
37
|
+
|
|
38
|
+
/** A fiber's lifecycle state, by name (Cordis's own enum is a `const enum`, which does not exist at run time). */
|
|
39
|
+
export type ExtensionState = 'pending' | 'loading' | 'active' | 'failed' | 'disposed' | 'unloading'
|
|
40
|
+
const STATES: readonly ExtensionState[] = [
|
|
41
|
+
'pending',
|
|
42
|
+
'loading',
|
|
43
|
+
'active',
|
|
44
|
+
'failed',
|
|
45
|
+
'disposed',
|
|
46
|
+
'unloading',
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
/** Whether new folders found in a source are installed at startup, or only listed until someone installs them. */
|
|
50
|
+
/** `install`: installed at start; `list`: only reported; `ask`: an install waits for an explicit approve (or deny), recorded in the ledger. */
|
|
51
|
+
export type Trust = 'install' | 'list' | 'ask'
|
|
52
|
+
|
|
53
|
+
export interface ExtensionSource {
|
|
54
|
+
readonly dir: string
|
|
55
|
+
readonly trust: Trust
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ExtensionsConfig {
|
|
59
|
+
/** Where extension folders may come from. Defaults to `<app home>/extensions`, trusted to install. */
|
|
60
|
+
sources?: readonly ExtensionSource[]
|
|
61
|
+
/** When true, every source that would install by itself asks for approval instead (`ask`). */
|
|
62
|
+
approval?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ExtensionInfo {
|
|
66
|
+
readonly name: string
|
|
67
|
+
readonly source: string
|
|
68
|
+
readonly version: string
|
|
69
|
+
readonly state: ExtensionState
|
|
70
|
+
/** Services an extension waits for, when it is pending. */
|
|
71
|
+
readonly missing: readonly string[]
|
|
72
|
+
/** The source folder no longer exists; the last installed version still runs. */
|
|
73
|
+
readonly stale: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type InstallResult =
|
|
77
|
+
| {
|
|
78
|
+
readonly ok: true
|
|
79
|
+
readonly name: string
|
|
80
|
+
readonly action: 'installed' | 'updated' | 'unchanged'
|
|
81
|
+
readonly state: ExtensionState
|
|
82
|
+
readonly missing: readonly string[]
|
|
83
|
+
readonly version: string
|
|
84
|
+
}
|
|
85
|
+
| {
|
|
86
|
+
readonly ok: false
|
|
87
|
+
/** check: before anything changed; import: loading the code; activate: starting it; verify: its own `check` failed. */
|
|
88
|
+
readonly stage: 'check' | 'import' | 'activate' | 'verify' | 'approval'
|
|
89
|
+
readonly name?: string
|
|
90
|
+
readonly errors: readonly string[]
|
|
91
|
+
/** Present for an update: whether the version that was running is running again. */
|
|
92
|
+
readonly restoredPrevious?: boolean
|
|
93
|
+
readonly next: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type RemoveResult =
|
|
97
|
+
| { readonly ok: true; readonly name: string }
|
|
98
|
+
| { readonly ok: false; readonly name: string; readonly errors: readonly string[] }
|
|
99
|
+
|
|
100
|
+
export interface StartupSummary {
|
|
101
|
+
readonly installed: string[]
|
|
102
|
+
readonly pending: string[]
|
|
103
|
+
readonly stale: string[]
|
|
104
|
+
readonly failed: Array<{ name: string; error: string }>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A Cordis plugin module as an extension exports it. */
|
|
108
|
+
interface PluginModule {
|
|
109
|
+
readonly name?: unknown
|
|
110
|
+
readonly inject?: unknown
|
|
111
|
+
readonly apply: (...args: unknown[]) => unknown
|
|
112
|
+
/** Optional self-check, run after the plugin starts: it throws (or rejects) when the plugin does not work. */
|
|
113
|
+
readonly check?: (ctx: Context) => unknown
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface Installed {
|
|
117
|
+
readonly name: string
|
|
118
|
+
readonly source: string
|
|
119
|
+
readonly version: string
|
|
120
|
+
/** The staged copy that is mounted. */
|
|
121
|
+
readonly dir: string
|
|
122
|
+
readonly module: PluginModule
|
|
123
|
+
fiber: Fiber
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface Record_ {
|
|
127
|
+
readonly source: string
|
|
128
|
+
readonly version: string
|
|
129
|
+
readonly dir: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
declare module '@deepseek-ai/cordis' {
|
|
133
|
+
interface Context {
|
|
134
|
+
extensions: Extensions
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface Events {
|
|
138
|
+
/** An extension was installed, updated, restored or removed. */
|
|
139
|
+
'extensions/changed'(
|
|
140
|
+
name: string,
|
|
141
|
+
action:
|
|
142
|
+
| 'installed'
|
|
143
|
+
| 'updated'
|
|
144
|
+
| 'restored'
|
|
145
|
+
| 'removed'
|
|
146
|
+
| 'requested'
|
|
147
|
+
| 'approved'
|
|
148
|
+
| 'denied',
|
|
149
|
+
): void
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
|
154
|
+
const CHECK_TIMEOUT_MS = 10_000
|
|
155
|
+
const ENTRIES = ['index.ts', 'index.js', 'index.mjs']
|
|
156
|
+
const KEPT =
|
|
157
|
+
'The version that was running is still running. Fix the error above, then install again.'
|
|
158
|
+
const RESTORED =
|
|
159
|
+
'The version that was running is running again. Fix the error above, then install again.'
|
|
160
|
+
const NOTHING = 'Nothing was installed. Fix the error above, then install again.'
|
|
161
|
+
const WAITING =
|
|
162
|
+
'Nothing was installed. The owner must approve this version (approve) or refuse it (deny); then it installs.'
|
|
163
|
+
|
|
164
|
+
const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
|
|
165
|
+
const inside = (path: string, folder: string) => {
|
|
166
|
+
const rel = relative(resolve(folder), resolve(path))
|
|
167
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function injected(module: PluginModule): string[] {
|
|
171
|
+
const inject = module.inject
|
|
172
|
+
if (Array.isArray(inject))
|
|
173
|
+
return inject.filter((name): name is string => typeof name === 'string')
|
|
174
|
+
if (inject && typeof inject === 'object') {
|
|
175
|
+
const required = (inject as { required?: unknown }).required
|
|
176
|
+
if (Array.isArray(required))
|
|
177
|
+
return required.filter((name): name is string => typeof name === 'string')
|
|
178
|
+
return Object.keys(inject)
|
|
179
|
+
}
|
|
180
|
+
return []
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The service. One instance serves one app home. */
|
|
184
|
+
export class Extensions extends Service {
|
|
185
|
+
readonly sources: readonly ExtensionSource[]
|
|
186
|
+
readonly stageRoot: string
|
|
187
|
+
readonly stateFile: string
|
|
188
|
+
readonly ledgerFile: string
|
|
189
|
+
/** Settles when the startup pass is done. */
|
|
190
|
+
readonly ready: Promise<StartupSummary>
|
|
191
|
+
private readonly installed = new Map<string, Installed>()
|
|
192
|
+
private readonly stale = new Set<string>()
|
|
193
|
+
/** The version approved for each name in an `ask` source, and installs still waiting for a decision. */
|
|
194
|
+
private readonly approved = new Map<string, string>()
|
|
195
|
+
private readonly requests = new Map<string, { dir: string; version: string }>()
|
|
196
|
+
private tail: Promise<unknown> = Promise.resolve()
|
|
197
|
+
private disposed = false
|
|
198
|
+
|
|
199
|
+
constructor(ctx: Context, config: ExtensionsConfig = {}) {
|
|
200
|
+
super(ctx, 'extensions')
|
|
201
|
+
const { home, root } = ctx.appInstance
|
|
202
|
+
this.sources = (config.sources ?? [{ dir: join(home, 'extensions'), trust: 'install' }]).map(
|
|
203
|
+
(source) => ({
|
|
204
|
+
dir: resolve(source.dir),
|
|
205
|
+
trust: config.approval === true && source.trust === 'install' ? 'ask' : source.trust,
|
|
206
|
+
}),
|
|
207
|
+
)
|
|
208
|
+
this.stageRoot = join(home, 'extensions-staged')
|
|
209
|
+
this.stateFile = join(home, 'extensions.json')
|
|
210
|
+
this.ledgerFile = join(home, 'extensions-ledger.jsonl')
|
|
211
|
+
// Effects run their disposers newest first: settle pending operations, then dispose every mounted extension, then drop the hook.
|
|
212
|
+
ctx.effect(
|
|
213
|
+
() => resolveFromApp(this.stageRoot, root),
|
|
214
|
+
'extensions: resolve staged imports from the app',
|
|
215
|
+
)
|
|
216
|
+
ctx.effect(
|
|
217
|
+
() => async () => {
|
|
218
|
+
for (const entry of [...this.installed.values()].reverse())
|
|
219
|
+
await entry.fiber.dispose().catch(() => undefined)
|
|
220
|
+
this.installed.clear()
|
|
221
|
+
},
|
|
222
|
+
'extensions: dispose mounted extensions',
|
|
223
|
+
)
|
|
224
|
+
ctx.effect(
|
|
225
|
+
() => async () => {
|
|
226
|
+
this.disposed = true
|
|
227
|
+
await this.tail.catch(() => undefined)
|
|
228
|
+
},
|
|
229
|
+
'extensions: settle pending operations',
|
|
230
|
+
)
|
|
231
|
+
this.ready = this.serial(() => this.startup())
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Install a folder, or update the installed extension of the same name. The folder must be inside one of the sources. */
|
|
235
|
+
install(source: string): Promise<InstallResult> {
|
|
236
|
+
return this.serial(() => this.installNow(source))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Remove an installed extension: dispose it, forget it, delete its staged copies. Its source folder is left alone. */
|
|
240
|
+
remove(name: string): Promise<RemoveResult> {
|
|
241
|
+
return this.serial(() => this.removeNow(name))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Bring every installed extension in line with its source, and install new folders from sources trusted to install. */
|
|
245
|
+
reload(): Promise<InstallResult[]> {
|
|
246
|
+
return this.serial(async () => {
|
|
247
|
+
const results: InstallResult[] = []
|
|
248
|
+
for (const entry of [...this.installed.values()]) {
|
|
249
|
+
if (!existsSync(entry.source)) {
|
|
250
|
+
this.stale.add(entry.name)
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
if (hashFolder(entry.source) !== entry.version)
|
|
254
|
+
results.push(await this.installNow(entry.source))
|
|
255
|
+
}
|
|
256
|
+
for (const found of this.discover()) {
|
|
257
|
+
if (found.trust === 'install' && !this.installed.has(found.name))
|
|
258
|
+
results.push(await this.installNow(found.dir))
|
|
259
|
+
}
|
|
260
|
+
return results
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** What is installed, with each extension's live state. */
|
|
265
|
+
list(): ExtensionInfo[] {
|
|
266
|
+
return [...this.installed.values()]
|
|
267
|
+
.map((entry) => {
|
|
268
|
+
const state = STATES[entry.fiber.state] ?? 'failed'
|
|
269
|
+
return {
|
|
270
|
+
name: entry.name,
|
|
271
|
+
source: entry.source,
|
|
272
|
+
version: entry.version,
|
|
273
|
+
state,
|
|
274
|
+
missing: state === 'pending' ? this.missing(entry.module) : [],
|
|
275
|
+
stale: this.stale.has(entry.name),
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Installs waiting for an owner's decision. */
|
|
282
|
+
approvals(): Array<{ name: string; version: string; dir: string }> {
|
|
283
|
+
return [...this.requests.entries()].map(([name, request]) => ({ name, ...request }))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Approve the version that is waiting, then install it. Refused when the source changed since it was requested. */
|
|
287
|
+
approve(name: string): Promise<InstallResult> {
|
|
288
|
+
return this.serial(async (): Promise<InstallResult> => {
|
|
289
|
+
const request = this.requests.get(name)
|
|
290
|
+
if (!request) {
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
stage: 'approval',
|
|
294
|
+
name,
|
|
295
|
+
errors: [`nothing is waiting for approval for "${name}"`],
|
|
296
|
+
next: NOTHING,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
this.requests.delete(name)
|
|
300
|
+
if (!existsSync(request.dir) || hashFolder(request.dir) !== request.version) {
|
|
301
|
+
return {
|
|
302
|
+
ok: false,
|
|
303
|
+
stage: 'approval',
|
|
304
|
+
name,
|
|
305
|
+
errors: [
|
|
306
|
+
'the source changed after it was requested; the new version needs its own request',
|
|
307
|
+
],
|
|
308
|
+
next: NOTHING,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
this.approved.set(name, request.version)
|
|
312
|
+
this.record({ kind: 'approved', name, version: request.version })
|
|
313
|
+
this.ctx.emit('extensions/changed', name, 'approved')
|
|
314
|
+
return this.installNow(request.dir)
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Refuse the version that is waiting. Nothing changes except the ledger. */
|
|
319
|
+
deny(name: string): Promise<RemoveResult> {
|
|
320
|
+
return this.serial(async (): Promise<RemoveResult> => {
|
|
321
|
+
const request = this.requests.get(name)
|
|
322
|
+
if (!request)
|
|
323
|
+
return { ok: false, name, errors: [`nothing is waiting for approval for "${name}"`] }
|
|
324
|
+
this.requests.delete(name)
|
|
325
|
+
this.record({ kind: 'denied', name, version: request.version })
|
|
326
|
+
this.ctx.emit('extensions/changed', name, 'denied')
|
|
327
|
+
return { ok: true, name }
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private trustOf(dir: string): Trust {
|
|
332
|
+
return this.sources.find((source) => inside(dir, source.dir))?.trust ?? 'list'
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Extension folders in the sources that are not installed. */
|
|
336
|
+
pending(): Array<{ name: string; dir: string; trust: Trust }> {
|
|
337
|
+
return this.discover().filter((found) => !this.installed.has(found.name))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Every change so far, oldest first. */
|
|
341
|
+
ledger(): LedgerEntry[] {
|
|
342
|
+
return readLedger(this.ledgerFile)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private serial<T>(run: () => Promise<T>): Promise<T> {
|
|
346
|
+
const guarded = () => {
|
|
347
|
+
if (this.disposed) return Promise.reject(new Error('extensions: the service is disposed'))
|
|
348
|
+
return run()
|
|
349
|
+
}
|
|
350
|
+
const result = this.tail.then(guarded, guarded)
|
|
351
|
+
this.tail = result.catch(() => undefined)
|
|
352
|
+
return result
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private discover(): Array<{ name: string; dir: string; trust: Trust }> {
|
|
356
|
+
const found: Array<{ name: string; dir: string; trust: Trust }> = []
|
|
357
|
+
for (const source of this.sources) {
|
|
358
|
+
if (!existsSync(source.dir)) continue
|
|
359
|
+
for (const entry of readdirSync(source.dir, { withFileTypes: true })) {
|
|
360
|
+
const dir = join(source.dir, entry.name)
|
|
361
|
+
if (
|
|
362
|
+
entry.isDirectory() &&
|
|
363
|
+
NAME.test(entry.name) &&
|
|
364
|
+
ENTRIES.some((file) => existsSync(join(dir, file)))
|
|
365
|
+
) {
|
|
366
|
+
if (!found.some((other) => other.name === entry.name))
|
|
367
|
+
found.push({ name: entry.name, dir, trust: source.trust })
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return found
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The checks that need no code to run. */
|
|
375
|
+
private check(source: string): { name: string; dir: string; errors: string[] } {
|
|
376
|
+
const dir = resolve(source)
|
|
377
|
+
const name = basename(dir)
|
|
378
|
+
const errors: string[] = []
|
|
379
|
+
if (!isAbsolute(source)) errors.push(`the path must be absolute, got "${source}"`)
|
|
380
|
+
if (!this.sources.some((allowed) => inside(dir, allowed.dir))) {
|
|
381
|
+
errors.push(
|
|
382
|
+
`"${dir}" is not inside an extension source (${this.sources.map((allowed) => allowed.dir).join(', ')})`,
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
if (!NAME.test(name))
|
|
386
|
+
errors.push(
|
|
387
|
+
`the folder name "${name}" must be lowercase letters, digits and dashes; it is the extension's name`,
|
|
388
|
+
)
|
|
389
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) errors.push(`"${dir}" is not a folder`)
|
|
390
|
+
else if (!ENTRIES.some((file) => existsSync(join(dir, file))))
|
|
391
|
+
errors.push(`"${dir}" has no ${ENTRIES.join(', ')}`)
|
|
392
|
+
return { name, dir, errors }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* A tool folder (`tool.json` next to an `index.ts` that exports `run(input)`) is code we do not trust, so it is never imported
|
|
397
|
+
* here. It becomes a generated plugin that registers a tool whose every call runs in the sandbox, and whose examples run in the
|
|
398
|
+
* sandbox as the install's self-check. `tool.json`: `{ "description": "...", "examples": [{ "input": ..., "output": ... }] }`.
|
|
399
|
+
*/
|
|
400
|
+
private toolModule(dir: string, name: string): PluginModule | undefined {
|
|
401
|
+
const file = join(dir, 'tool.json')
|
|
402
|
+
if (!existsSync(file)) return undefined
|
|
403
|
+
let spec: { description?: unknown; examples?: unknown; timeoutMs?: unknown }
|
|
404
|
+
try {
|
|
405
|
+
spec = JSON.parse(readFileSync(file, 'utf8'))
|
|
406
|
+
} catch (error) {
|
|
407
|
+
throw new Error(`tool.json is not valid JSON: ${message(error)}`)
|
|
408
|
+
}
|
|
409
|
+
if (typeof spec.description !== 'string' || spec.description.trim() === '')
|
|
410
|
+
throw new Error('tool.json needs a "description"')
|
|
411
|
+
const examples = spec.examples
|
|
412
|
+
if (
|
|
413
|
+
!Array.isArray(examples) ||
|
|
414
|
+
examples.length === 0 ||
|
|
415
|
+
!examples.every(
|
|
416
|
+
(example) =>
|
|
417
|
+
example && typeof example === 'object' && 'input' in example && 'output' in example,
|
|
418
|
+
)
|
|
419
|
+
) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
'tool.json needs "examples": a non-empty list of { "input": ..., "output": ... }; they are run in the sandbox when the tool is installed',
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
const timeoutMs =
|
|
425
|
+
typeof spec.timeoutMs === 'number' ? Math.min(Math.max(spec.timeoutMs, 100), 30_000) : 5000
|
|
426
|
+
const entry = ENTRIES.map((candidate) => join(dir, candidate)).find((path) =>
|
|
427
|
+
existsSync(path),
|
|
428
|
+
) as string
|
|
429
|
+
const description = spec.description
|
|
430
|
+
const run = (ctx: Context, input: unknown) =>
|
|
431
|
+
// `get`, not `ctx.sandbox`: the self-check runs from the service's own context, which does not declare the dependency.
|
|
432
|
+
((ctx.get as (service: string) => unknown)('sandbox') as Sandbox)
|
|
433
|
+
.run(entry, input, { timeoutMs })
|
|
434
|
+
.then((result) => {
|
|
435
|
+
if (!result.ok) throw new Error(`${result.kind}: ${result.error}`)
|
|
436
|
+
return result.output
|
|
437
|
+
})
|
|
438
|
+
return {
|
|
439
|
+
name,
|
|
440
|
+
inject: ['tools', 'sandbox'],
|
|
441
|
+
apply: ((ctx: Context) => {
|
|
442
|
+
const tools = (ctx.get as (service: string) => unknown)('tools') as {
|
|
443
|
+
register(
|
|
444
|
+
tool: string,
|
|
445
|
+
definition: { description: string; run(input: unknown): unknown },
|
|
446
|
+
): () => void
|
|
447
|
+
}
|
|
448
|
+
ctx.effect(() => tools.register(name, { description, run: (input) => run(ctx, input) }))
|
|
449
|
+
}) as PluginModule['apply'],
|
|
450
|
+
check: async (ctx: Context) => {
|
|
451
|
+
for (const [index, example] of (
|
|
452
|
+
examples as Array<{ input: unknown; output: unknown }>
|
|
453
|
+
).entries()) {
|
|
454
|
+
const actual = await run(ctx, example.input)
|
|
455
|
+
if (!isDeepStrictEqual(actual, example.output)) {
|
|
456
|
+
throw new Error(
|
|
457
|
+
`example ${index + 1} expected ${JSON.stringify(example.output)} but the tool returned ${JSON.stringify(actual)}`,
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private async load(dir: string, name: string): Promise<PluginModule> {
|
|
466
|
+
const tool = this.toolModule(dir, name)
|
|
467
|
+
if (tool) return tool
|
|
468
|
+
const entry = ENTRIES.map((file) => join(dir, file)).find((path) => existsSync(path)) as string
|
|
469
|
+
const module = (await import(pathToFileURL(entry).href)) as Partial<PluginModule> & {
|
|
470
|
+
default?: Partial<PluginModule>
|
|
471
|
+
}
|
|
472
|
+
const plugin = typeof module.apply === 'function' ? module : module.default
|
|
473
|
+
if (!plugin || typeof plugin.apply !== 'function') {
|
|
474
|
+
throw new Error(
|
|
475
|
+
`${basename(entry)} must export a Cordis plugin: "export const name = '${name}'" and "export function apply(ctx) { ... }"`,
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
if (plugin.name !== undefined && plugin.name !== name) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
`the plugin is named "${String(plugin.name)}" but its folder is "${name}"; they must match`,
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
return { ...plugin, name } as PluginModule
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private missing(module: PluginModule): string[] {
|
|
487
|
+
return injected(module).filter((service) => this.ctx.get(service) === undefined)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Run the extension's own `check`, if it has one, with a time limit. Returns the failure, or `undefined` when it passes. */
|
|
491
|
+
private async verify(
|
|
492
|
+
module: PluginModule,
|
|
493
|
+
): Promise<{ stage: 'verify'; error: string } | undefined> {
|
|
494
|
+
if (typeof module.check !== 'function') return undefined
|
|
495
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
496
|
+
try {
|
|
497
|
+
const limit = new Promise<never>((_, reject) => {
|
|
498
|
+
timer = setTimeout(
|
|
499
|
+
() => reject(new Error(`its check did not finish within ${CHECK_TIMEOUT_MS} ms`)),
|
|
500
|
+
CHECK_TIMEOUT_MS,
|
|
501
|
+
)
|
|
502
|
+
})
|
|
503
|
+
await Promise.race([Promise.resolve(module.check(this.ctx)), limit])
|
|
504
|
+
return undefined
|
|
505
|
+
} catch (error) {
|
|
506
|
+
return { stage: 'verify', error: `its check failed: ${message(error)}` }
|
|
507
|
+
} finally {
|
|
508
|
+
clearTimeout(timer)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Mount a module as a child fiber and wait until it settles. */
|
|
513
|
+
private async mount(module: PluginModule): Promise<{ fiber: Fiber; error?: unknown }> {
|
|
514
|
+
// An extension is a plugin module whose config, if any, comes from its own defaults.
|
|
515
|
+
const fiber = this.ctx.plugin(module as unknown as Parameters<Context['plugin']>[0])
|
|
516
|
+
try {
|
|
517
|
+
await fiber.await()
|
|
518
|
+
return { fiber }
|
|
519
|
+
} catch (error) {
|
|
520
|
+
return { fiber, error }
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private record(entry: Omit<LedgerEntry, 'at'>): void {
|
|
525
|
+
appendLedger(this.ledgerFile, entry)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private saveState(): void {
|
|
529
|
+
const records: Record<string, Record_> = {}
|
|
530
|
+
for (const entry of this.installed.values())
|
|
531
|
+
records[entry.name] = { source: entry.source, version: entry.version, dir: entry.dir }
|
|
532
|
+
mkdirSync(join(this.stateFile, '..'), { recursive: true })
|
|
533
|
+
const temporary = `${this.stateFile}.${String(process.pid)}.tmp`
|
|
534
|
+
writeFileSync(temporary, `${JSON.stringify(records, null, 2)}\n`)
|
|
535
|
+
renameSync(temporary, this.stateFile)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private async installNow(source: string): Promise<InstallResult> {
|
|
539
|
+
const { name, dir, errors } = this.check(source)
|
|
540
|
+
if (errors.length > 0) return { ok: false, stage: 'check', name, errors, next: NOTHING }
|
|
541
|
+
const previous = this.installed.get(name)
|
|
542
|
+
const version = hashFolder(dir)
|
|
543
|
+
const running =
|
|
544
|
+
previous && previous.version === version && STATES[previous.fiber.state] !== 'failed'
|
|
545
|
+
// A source that asks: this exact version must have been approved. Anything else waits, and the ledger says so.
|
|
546
|
+
if (!running && this.trustOf(dir) === 'ask' && this.approved.get(name) !== version) {
|
|
547
|
+
if (this.requests.get(name)?.version !== version) {
|
|
548
|
+
this.requests.set(name, { dir, version })
|
|
549
|
+
this.record({ kind: 'requested', name, version })
|
|
550
|
+
this.ctx.emit('extensions/changed', name, 'requested')
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
ok: false,
|
|
554
|
+
stage: 'approval',
|
|
555
|
+
name,
|
|
556
|
+
errors: [`version ${version} is waiting for approval`],
|
|
557
|
+
next: WAITING,
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (previous && previous.version === version && STATES[previous.fiber.state] !== 'failed') {
|
|
561
|
+
const state = STATES[previous.fiber.state] ?? 'failed'
|
|
562
|
+
return {
|
|
563
|
+
ok: true,
|
|
564
|
+
name,
|
|
565
|
+
action: 'unchanged',
|
|
566
|
+
state,
|
|
567
|
+
missing: state === 'pending' ? this.missing(previous.module) : [],
|
|
568
|
+
version,
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Import the new version while the old one keeps running: a syntax error or a missing export changes nothing.
|
|
573
|
+
const staged = stage(dir, this.stageRoot, name)
|
|
574
|
+
let module: PluginModule
|
|
575
|
+
try {
|
|
576
|
+
module = await this.load(staged.dir, name)
|
|
577
|
+
} catch (error) {
|
|
578
|
+
rmSync(staged.dir, { recursive: true, force: true })
|
|
579
|
+
this.record({ kind: 'refused', name, version: staged.version, error: message(error) })
|
|
580
|
+
return {
|
|
581
|
+
ok: false,
|
|
582
|
+
stage: 'import',
|
|
583
|
+
name,
|
|
584
|
+
errors: [message(error)],
|
|
585
|
+
...(previous ? { restoredPrevious: true } : {}),
|
|
586
|
+
next: previous ? KEPT : NOTHING,
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (previous) await previous.fiber.dispose()
|
|
591
|
+
const mounted = await this.mount(module)
|
|
592
|
+
const state = STATES[mounted.fiber.state] ?? 'failed'
|
|
593
|
+
// Commit before "done": a plugin that started still has to pass its own check before the install counts.
|
|
594
|
+
const failure =
|
|
595
|
+
mounted.error !== undefined || state === 'failed'
|
|
596
|
+
? {
|
|
597
|
+
stage: 'activate' as const,
|
|
598
|
+
error: message(mounted.error ?? 'the plugin failed to start'),
|
|
599
|
+
}
|
|
600
|
+
: state === 'active'
|
|
601
|
+
? await this.verify(module)
|
|
602
|
+
: undefined
|
|
603
|
+
if (failure) {
|
|
604
|
+
await mounted.fiber.dispose().catch(() => undefined)
|
|
605
|
+
rmSync(staged.dir, { recursive: true, force: true })
|
|
606
|
+
const error = failure.error
|
|
607
|
+
this.record({ kind: 'refused', name, version: staged.version, error })
|
|
608
|
+
let restored = false
|
|
609
|
+
if (previous) {
|
|
610
|
+
const again = await this.mount(previous.module)
|
|
611
|
+
restored = again.error === undefined && STATES[again.fiber.state] !== 'failed'
|
|
612
|
+
previous.fiber = again.fiber
|
|
613
|
+
if (restored) {
|
|
614
|
+
this.record({ kind: 'restored', name, version: previous.version })
|
|
615
|
+
this.ctx.emit('extensions/changed', name, 'restored')
|
|
616
|
+
} else {
|
|
617
|
+
this.installed.delete(name)
|
|
618
|
+
this.saveState()
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
ok: false,
|
|
623
|
+
stage: failure.stage,
|
|
624
|
+
name,
|
|
625
|
+
errors: [error],
|
|
626
|
+
...(previous ? { restoredPrevious: restored } : {}),
|
|
627
|
+
next: restored ? RESTORED : NOTHING,
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const action = previous ? 'updated' : 'installed'
|
|
632
|
+
this.installed.set(name, {
|
|
633
|
+
name,
|
|
634
|
+
source: dir,
|
|
635
|
+
version: staged.version,
|
|
636
|
+
dir: staged.dir,
|
|
637
|
+
module,
|
|
638
|
+
fiber: mounted.fiber,
|
|
639
|
+
})
|
|
640
|
+
this.stale.delete(name)
|
|
641
|
+
this.saveState()
|
|
642
|
+
prune(this.stageRoot, name, [staged.dir])
|
|
643
|
+
this.record({ kind: action, name, version: staged.version })
|
|
644
|
+
this.ctx.emit('extensions/changed', name, action)
|
|
645
|
+
return {
|
|
646
|
+
ok: true,
|
|
647
|
+
name,
|
|
648
|
+
action,
|
|
649
|
+
state,
|
|
650
|
+
missing: state === 'pending' ? this.missing(module) : [],
|
|
651
|
+
version: staged.version,
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private async removeNow(name: string): Promise<RemoveResult> {
|
|
656
|
+
const entry = this.installed.get(name)
|
|
657
|
+
if (!entry) return { ok: false, name, errors: [`"${name}" is not installed`] }
|
|
658
|
+
await entry.fiber.dispose()
|
|
659
|
+
this.installed.delete(name)
|
|
660
|
+
this.stale.delete(name)
|
|
661
|
+
this.saveState()
|
|
662
|
+
prune(this.stageRoot, name, [])
|
|
663
|
+
this.record({ kind: 'removed', name, version: entry.version })
|
|
664
|
+
this.ctx.emit('extensions/changed', name, 'removed')
|
|
665
|
+
return { ok: true, name }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Never throws: one extension's failure is recorded and the others still start. */
|
|
669
|
+
private async startup(): Promise<StartupSummary> {
|
|
670
|
+
const summary: StartupSummary = { installed: [], pending: [], stale: [], failed: [] }
|
|
671
|
+
let records: Record<string, Record_> = {}
|
|
672
|
+
try {
|
|
673
|
+
if (existsSync(this.stateFile))
|
|
674
|
+
records = JSON.parse(readFileSync(this.stateFile, 'utf8')) as Record<string, Record_>
|
|
675
|
+
} catch (error) {
|
|
676
|
+
summary.failed.push({ name: 'extensions.json', error: message(error) })
|
|
677
|
+
}
|
|
678
|
+
for (const [name, record] of Object.entries(records)) {
|
|
679
|
+
try {
|
|
680
|
+
// What was installed before was approved then: an unchanged source starts again without asking.
|
|
681
|
+
this.approved.set(name, record.version)
|
|
682
|
+
if (existsSync(record.source)) {
|
|
683
|
+
const result = await this.installNow(record.source)
|
|
684
|
+
if (result.ok) summary.installed.push(name)
|
|
685
|
+
else summary.failed.push({ name, error: result.errors.join('; ') })
|
|
686
|
+
continue
|
|
687
|
+
}
|
|
688
|
+
// The source is gone: the last installed version still runs, and is reported as stale.
|
|
689
|
+
summary.stale.push(name)
|
|
690
|
+
this.stale.add(name)
|
|
691
|
+
if (!existsSync(record.dir)) {
|
|
692
|
+
summary.failed.push({ name, error: 'neither the source nor the staged copy exists' })
|
|
693
|
+
continue
|
|
694
|
+
}
|
|
695
|
+
const module = await this.load(record.dir, name)
|
|
696
|
+
const mounted = await this.mount(module)
|
|
697
|
+
if (mounted.error !== undefined)
|
|
698
|
+
summary.failed.push({ name, error: message(mounted.error) })
|
|
699
|
+
this.installed.set(name, {
|
|
700
|
+
name,
|
|
701
|
+
source: record.source,
|
|
702
|
+
version: record.version,
|
|
703
|
+
dir: record.dir,
|
|
704
|
+
module,
|
|
705
|
+
fiber: mounted.fiber,
|
|
706
|
+
})
|
|
707
|
+
} catch (error) {
|
|
708
|
+
summary.failed.push({ name, error: message(error) })
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
for (const found of this.discover()) {
|
|
712
|
+
if (this.installed.has(found.name)) continue
|
|
713
|
+
if (found.trust !== 'install') {
|
|
714
|
+
summary.pending.push(found.name)
|
|
715
|
+
continue
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
const result = await this.installNow(found.dir)
|
|
719
|
+
if (result.ok) summary.installed.push(found.name)
|
|
720
|
+
else summary.failed.push({ name: found.name, error: result.errors.join('; ') })
|
|
721
|
+
} catch (error) {
|
|
722
|
+
summary.failed.push({ name: found.name, error: message(error) })
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (Object.keys(records).length > 0 || summary.installed.length > 0) this.saveState()
|
|
726
|
+
return summary
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** The plugin: mount it after `instance`. Installed extensions mount as its children and leave with it. */
|
|
731
|
+
export const extensions = {
|
|
732
|
+
name: 'extensions',
|
|
733
|
+
inject: ['appInstance'],
|
|
734
|
+
async apply(ctx: Context, config: ExtensionsConfig) {
|
|
735
|
+
const service = new Extensions(ctx, config ?? {})
|
|
736
|
+
await service.ready
|
|
737
|
+
},
|
|
738
|
+
}
|