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.
Files changed (74) hide show
  1. package/bin/acrylblends.js +4 -0
  2. package/dist/index.d.ts +117 -0
  3. package/dist/index.js +434 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +50 -4
  6. package/plugins/agent/agent.ts +152 -0
  7. package/plugins/agent/index.ts +20 -0
  8. package/plugins/agent/memory.ts +24 -0
  9. package/plugins/agent/model.ts +79 -0
  10. package/plugins/agent/plugin.json +10 -0
  11. package/plugins/agent/tools.ts +50 -0
  12. package/plugins/builder/index.ts +193 -0
  13. package/plugins/builder/plugin.json +11 -0
  14. package/plugins/builder/script.ts +162 -0
  15. package/plugins/events/index.ts +42 -0
  16. package/plugins/events/plugin.json +9 -0
  17. package/plugins/extensions/index.ts +738 -0
  18. package/plugins/extensions/ledger.ts +39 -0
  19. package/plugins/extensions/plugin.json +12 -0
  20. package/plugins/extensions/resolve.ts +30 -0
  21. package/plugins/extensions/stage.ts +44 -0
  22. package/plugins/http/index.ts +64 -0
  23. package/plugins/http/plugin.json +10 -0
  24. package/plugins/instance/index.ts +167 -0
  25. package/plugins/instance/plugin.json +10 -0
  26. package/plugins/manifest/index.ts +635 -0
  27. package/plugins/manifest/lock.ts +94 -0
  28. package/plugins/manifest/plugin.json +13 -0
  29. package/plugins/manifest/upgrade-cli.ts +70 -0
  30. package/plugins/manifest/upgrade.ts +97 -0
  31. package/plugins/openai/index.ts +159 -0
  32. package/plugins/openai/plugin.json +11 -0
  33. package/plugins/openai/prompt.ts +31 -0
  34. package/plugins/sandbox/index.ts +150 -0
  35. package/plugins/sandbox/plugin.json +8 -0
  36. package/plugins/sandbox/runner.mjs +18 -0
  37. package/plugins/save/cli.ts +19 -0
  38. package/plugins/save/index.ts +55 -0
  39. package/plugins/save/plugin.json +12 -0
  40. package/plugins/server/index.ts +138 -0
  41. package/plugins/server/plugin.json +10 -0
  42. package/plugins/settings/index.ts +270 -0
  43. package/plugins/settings/merge.ts +79 -0
  44. package/plugins/settings/plugin.json +13 -0
  45. package/plugins/tasks/index.ts +232 -0
  46. package/plugins/tasks/plugin.json +11 -0
  47. package/plugins/tasks/store.ts +86 -0
  48. package/plugins/tsconfig.json +19 -0
  49. package/templates/agent/Dockerfile +15 -0
  50. package/templates/agent/README.md +52 -0
  51. package/templates/agent/blend.yaml +58 -0
  52. package/templates/agent/dockerignore +3 -0
  53. package/templates/agent/gitignore +2 -0
  54. package/templates/agent/package.json +23 -0
  55. package/templates/agent/src/app.ts +98 -0
  56. package/templates/agent/src/main.ts +25 -0
  57. package/templates/agent/src/routes.ts +62 -0
  58. package/templates/agent/template.json +16 -0
  59. package/templates/agent/test/agent.test.ts +45 -0
  60. package/templates/agent/test/app.test.ts +46 -0
  61. package/templates/agent/test/builder.test.ts +186 -0
  62. package/templates/agent/test/model.test.ts +89 -0
  63. package/templates/agent/tsconfig.json +19 -0
  64. package/templates/app/README.md +26 -0
  65. package/templates/app/blend.yaml +23 -0
  66. package/templates/app/gitignore +2 -0
  67. package/templates/app/package.json +23 -0
  68. package/templates/app/src/app.ts +61 -0
  69. package/templates/app/src/main.ts +20 -0
  70. package/templates/app/src/routes.ts +14 -0
  71. package/templates/app/template.json +1 -0
  72. package/templates/app/test/app.test.ts +56 -0
  73. package/templates/app/tsconfig.json +19 -0
  74. 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
+ }