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,635 @@
1
+ /**
2
+ * The app manifest (`blend.yaml`): the app as data, in ACRYL's own Blend format (`blends.acryl.dev/v1alpha1`).
3
+ *
4
+ * Extracted from ACRYL Blends (`runtime/blends-core`). A manifest this plugin accepts is a valid ACRYL `blend.yaml`: a test checks
5
+ * the templates' manifests against ACRYL's JSON schema, so an app can move into ACRYL without conversion.
6
+ *
7
+ * - A **Blueprint** is a definition with ordered `spec.rows` (`id` is the slot, `name` the plugin it loads, optional `config` and
8
+ * `disabled`) and typed `spec.parameters` substituted into config as `{{parameters.name}}`.
9
+ * - A **Blend** is an instance of a Blueprint: it declares `spec.lineage` (which Blueprint, which version), and resolves over that
10
+ * parent. Its `spec.overrides` change inherited rows by id (a shallow merge: keys it sets win), and its own `spec.rows` are added
11
+ * after them. `spec.extends` names the parent explicitly (it must agree with the lineage). A parent resolves with its own defaults.
12
+ * - `planUpgrade` (upgrade.ts) answers the question ACRYL leaves open: when the Blueprint moves to a new version, what changed, and
13
+ * which of this Blend's overrides and rows it breaks.
14
+ *
15
+ * Problems are diagnostics with a code and a path, never a bare "invalid". The code an app can use stays in `src/app.ts` as a static
16
+ * registry of imports (so it type-checks); the manifest chooses, orders, configures and switches those plugins. Extensions are not
17
+ * listed here: they come and go while the app runs.
18
+ */
19
+ import { createHash } from 'node:crypto'
20
+ import { existsSync, readFileSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+ import type { Context, Fiber } from '@deepseek-ai/cordis'
23
+ import { parse } from 'yaml'
24
+
25
+ export const API_VERSION = 'blends.acryl.dev/v1alpha1'
26
+ /** The file name ACRYL uses for an app definition. */
27
+ export const MANIFEST_FILE = 'blend.yaml'
28
+
29
+ export type ParameterType = 'string' | 'number' | 'boolean'
30
+ export type ParameterValue = string | number | boolean
31
+
32
+ export interface ParameterDeclaration {
33
+ readonly type: ParameterType
34
+ readonly default: ParameterValue
35
+ }
36
+
37
+ /** A row as written in `spec.rows`. */
38
+ export interface BlendRow {
39
+ readonly id: string
40
+ readonly name: string
41
+ readonly config?: Readonly<Record<string, unknown>>
42
+ readonly disabled?: boolean
43
+ }
44
+
45
+ /** A change to an inherited row, by id. What it sets wins; what it omits is inherited. */
46
+ export interface OverrideEntry {
47
+ readonly id: string
48
+ readonly name?: string
49
+ readonly config?: Readonly<Record<string, unknown>>
50
+ readonly disabled?: boolean
51
+ }
52
+
53
+ /** A row as the app mounts it: `use` is the registry entry the row's `name` selects. */
54
+ export interface Row {
55
+ readonly id: string
56
+ readonly use: string
57
+ readonly config?: Readonly<Record<string, unknown>>
58
+ readonly disabled?: boolean
59
+ }
60
+
61
+ export interface Metadata {
62
+ readonly id: string
63
+ readonly name: string
64
+ readonly version: string
65
+ readonly category?: string
66
+ readonly description?: string
67
+ readonly license?: string
68
+ /** Anything but an explicit `public` is private: `save` never pushes a private app to a public remote. */
69
+ readonly visibility?: 'private' | 'public'
70
+ }
71
+
72
+ /** Which Blueprint, at which version, a Blend was made from. */
73
+ export interface Lineage {
74
+ readonly blueprint: string
75
+ readonly blueprintVersion: string
76
+ }
77
+
78
+ export interface Manifest {
79
+ readonly apiVersion: typeof API_VERSION
80
+ readonly kind: 'Blueprint' | 'Blend'
81
+ readonly metadata: Metadata
82
+ readonly spec: {
83
+ readonly runtime: 'cordis'
84
+ readonly extends?: string
85
+ readonly lineage?: Lineage
86
+ readonly parameters?: Readonly<Record<string, ParameterDeclaration>>
87
+ readonly rows?: readonly BlendRow[]
88
+ readonly overrides?: readonly OverrideEntry[]
89
+ }
90
+ }
91
+
92
+ export type DiagnosticCode =
93
+ | 'parse-error'
94
+ | 'schema-error'
95
+ | 'duplicate-row-id'
96
+ | 'unknown-plugin'
97
+ | 'dangling-parameter-reference'
98
+ | 'unused-parameter'
99
+ | 'parameter-type-mismatch'
100
+ | 'parameter-override-unknown'
101
+ | 'blend-without-lineage'
102
+ | 'blueprint-with-lineage'
103
+ | 'lineage-extends-mismatch'
104
+ | 'overrides-without-parent'
105
+ | 'parent-not-supplied'
106
+ | 'parent-cycle'
107
+ | 'override-unknown-id'
108
+ | 'insert-id-collision'
109
+
110
+ /** Every problem names where it is, as a path into the document. */
111
+ export interface Diagnostic {
112
+ readonly code: DiagnosticCode
113
+ readonly path: string
114
+ readonly message: string
115
+ }
116
+
117
+ export class ManifestError extends Error {
118
+ override name = 'ManifestError'
119
+ readonly diagnostics: readonly Diagnostic[]
120
+ constructor(source: string, diagnostics: readonly Diagnostic[]) {
121
+ super(
122
+ `${source} is not a valid app manifest:\n${diagnostics.map((d) => ` ${d.path}: ${d.message} [${d.code}]`).join('\n')}`,
123
+ )
124
+ this.diagnostics = diagnostics
125
+ }
126
+ }
127
+
128
+ export const DEFINITION_ID = /^[a-z0-9]+(\.[a-z0-9-]+)+$/
129
+ const ROW_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
130
+ export const SEMVER =
131
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
132
+ const PARAMETER = /^[A-Za-z][A-Za-z0-9_-]*$/
133
+ const TOKEN = /\{\{parameters\.([A-Za-z0-9_-]+)\}\}/g
134
+ const WHOLE_TOKEN = /^\{\{parameters\.([A-Za-z0-9_-]+)\}\}$/
135
+ const TOP_FIELDS = new Set(['apiVersion', 'kind', 'metadata', 'spec'])
136
+ const METADATA_FIELDS = new Set([
137
+ 'id',
138
+ 'name',
139
+ 'version',
140
+ 'category',
141
+ 'description',
142
+ 'license',
143
+ 'visibility',
144
+ ])
145
+ const SPEC_FIELDS = new Set(['runtime', 'extends', 'lineage', 'parameters', 'rows', 'overrides'])
146
+ const ROW_FIELDS = new Set(['id', 'name', 'config', 'disabled'])
147
+ const OVERRIDE_FIELDS = new Set(['id', 'name', 'config', 'disabled'])
148
+ const LINEAGE_FIELDS = new Set(['blueprint', 'blueprintVersion'])
149
+ const PARAMETER_FIELDS = new Set(['type', 'default'])
150
+ const TYPES = new Set<ParameterType>(['string', 'number', 'boolean'])
151
+
152
+ const isObject = (value: unknown): value is Record<string, unknown> =>
153
+ typeof value === 'object' && value !== null && !Array.isArray(value)
154
+
155
+ /** Every string inside a config value, with its path. Tokens are recognised in string values only. */
156
+ function walkStrings(
157
+ value: unknown,
158
+ path: string,
159
+ visit: (text: string, path: string) => void,
160
+ ): void {
161
+ if (typeof value === 'string') visit(value, path)
162
+ else if (Array.isArray(value))
163
+ for (const [index, item] of value.entries()) walkStrings(item, `${path}[${index}]`, visit)
164
+ else if (isObject(value))
165
+ for (const [key, item] of Object.entries(value)) walkStrings(item, `${path}.${key}`, visit)
166
+ }
167
+
168
+ /** The id of the definition a document resolves over: `extends`, else a Blend's lineage Blueprint. */
169
+ export function parentIdOf(document: Pick<Manifest, 'kind' | 'spec'>): string | undefined {
170
+ if (document.spec.extends !== undefined) return document.spec.extends
171
+ return document.kind === 'Blend' ? document.spec.lineage?.blueprint : undefined
172
+ }
173
+
174
+ /**
175
+ * Validate one parsed document on its own (its parent is not read here; see `loadManifest`). An empty list means valid. `known` is
176
+ * the registry's names, so an unknown row `name` is reported here, before anything is mounted.
177
+ */
178
+ export function validate(document: unknown, known?: Iterable<string>): Diagnostic[] {
179
+ const problems: Diagnostic[] = []
180
+ const add = (code: DiagnosticCode, path: string, message: string) =>
181
+ problems.push({ code, path, message })
182
+ if (!isObject(document))
183
+ return [{ code: 'schema-error', path: '$', message: 'the manifest must be a map' }]
184
+ for (const key of Object.keys(document))
185
+ if (!TOP_FIELDS.has(key)) add('schema-error', key, `unknown field '${key}'`)
186
+ if (document.apiVersion !== API_VERSION)
187
+ add('schema-error', 'apiVersion', `must be '${API_VERSION}'`)
188
+ if (document.kind !== 'Blueprint' && document.kind !== 'Blend')
189
+ add('schema-error', 'kind', "must be 'Blueprint' or 'Blend'")
190
+
191
+ const metadata = document.metadata
192
+ if (!isObject(metadata))
193
+ add('schema-error', 'metadata', 'missing required field metadata (id, name, version)')
194
+ else {
195
+ for (const key of Object.keys(metadata))
196
+ if (!METADATA_FIELDS.has(key))
197
+ add('schema-error', `metadata.${key}`, `unknown field '${key}'`)
198
+ if (typeof metadata.id !== 'string' || !DEFINITION_ID.test(metadata.id))
199
+ add('schema-error', 'metadata.id', "must be a dotted id such as 'acrylblends.my-app'")
200
+ if (typeof metadata.name !== 'string' || metadata.name === '')
201
+ add('schema-error', 'metadata.name', 'missing required field name')
202
+ if (typeof metadata.version !== 'string' || !SEMVER.test(metadata.version))
203
+ add('schema-error', 'metadata.version', 'must be a semantic version such as 0.1.0')
204
+ if (
205
+ metadata.visibility !== undefined &&
206
+ metadata.visibility !== 'private' &&
207
+ metadata.visibility !== 'public'
208
+ )
209
+ add('schema-error', 'metadata.visibility', "must be 'private' or 'public'")
210
+ }
211
+
212
+ const spec = document.spec
213
+ const declared = new Map<string, ParameterType>()
214
+ const used = new Set<string>()
215
+ const registry = known ? new Set(known) : undefined
216
+ const ids = new Set<string>()
217
+ if (!isObject(spec)) {
218
+ add('schema-error', 'spec', 'missing required field spec (runtime)')
219
+ return problems
220
+ }
221
+ for (const key of Object.keys(spec))
222
+ if (!SPEC_FIELDS.has(key)) add('schema-error', `spec.${key}`, `unknown field '${key}'`)
223
+ if (spec.runtime !== 'cordis') add('schema-error', 'spec.runtime', "must be 'cordis'")
224
+
225
+ // Kind, lineage and extends agree with each other.
226
+ const lineage = spec.lineage
227
+ if (lineage !== undefined) {
228
+ if (!isObject(lineage))
229
+ add('schema-error', 'spec.lineage', 'must be { blueprint, blueprintVersion }')
230
+ else {
231
+ for (const key of Object.keys(lineage))
232
+ if (!LINEAGE_FIELDS.has(key))
233
+ add('schema-error', `spec.lineage.${key}`, `unknown field '${key}'`)
234
+ if (typeof lineage.blueprint !== 'string' || !DEFINITION_ID.test(lineage.blueprint))
235
+ add('schema-error', 'spec.lineage.blueprint', 'must be a dotted Blueprint id')
236
+ if (typeof lineage.blueprintVersion !== 'string' || !SEMVER.test(lineage.blueprintVersion))
237
+ add('schema-error', 'spec.lineage.blueprintVersion', 'must be a semantic version')
238
+ }
239
+ }
240
+ if (
241
+ spec.extends !== undefined &&
242
+ (typeof spec.extends !== 'string' || !DEFINITION_ID.test(spec.extends))
243
+ )
244
+ add('schema-error', 'spec.extends', 'must be a dotted definition id')
245
+ if (document.kind === 'Blend' && lineage === undefined)
246
+ add(
247
+ 'blend-without-lineage',
248
+ 'spec.lineage',
249
+ 'a Blend must declare spec.lineage (its Blueprint and version)',
250
+ )
251
+ if (document.kind === 'Blueprint' && lineage !== undefined)
252
+ add(
253
+ 'blueprint-with-lineage',
254
+ 'spec.lineage',
255
+ 'a Blueprint must not declare spec.lineage; lineage belongs to Blends',
256
+ )
257
+ if (isObject(lineage) && spec.extends !== undefined && spec.extends !== lineage.blueprint)
258
+ add(
259
+ 'lineage-extends-mismatch',
260
+ 'spec.extends',
261
+ `extends '${String(spec.extends)}' must agree with lineage.blueprint '${String(lineage.blueprint)}'`,
262
+ )
263
+
264
+ const parameters = spec.parameters ?? {}
265
+ if (!isObject(parameters))
266
+ add('schema-error', 'spec.parameters', 'must be a map of name to { type, default }')
267
+ else {
268
+ for (const [name, declaration] of Object.entries(parameters)) {
269
+ const path = `spec.parameters.${name}`
270
+ if (!PARAMETER.test(name))
271
+ add('schema-error', path, 'a parameter name is letters, digits, _ and -')
272
+ if (!isObject(declaration) || !TYPES.has(declaration.type as ParameterType)) {
273
+ add('schema-error', `${path}.type`, "must be 'string', 'number' or 'boolean'")
274
+ continue
275
+ }
276
+ for (const key of Object.keys(declaration))
277
+ if (!PARAMETER_FIELDS.has(key))
278
+ add('schema-error', `${path}.${key}`, `unknown field '${key}'`)
279
+ if (typeof declaration.default !== declaration.type)
280
+ add('parameter-type-mismatch', `${path}.default`, `must be a ${declaration.type}`)
281
+ declared.set(name, declaration.type as ParameterType)
282
+ }
283
+ }
284
+
285
+ const checkTokens = (config: unknown, path: string) => {
286
+ walkStrings(config, path, (text, at) => {
287
+ for (const [, name] of text.matchAll(TOKEN)) {
288
+ used.add(name as string)
289
+ if (!declared.has(name as string))
290
+ add('dangling-parameter-reference', at, `refers to undeclared parameter '${name}'`)
291
+ }
292
+ })
293
+ }
294
+ const checkFlags = (entry: Record<string, unknown>, path: string, fields: Set<string>) => {
295
+ for (const key of Object.keys(entry))
296
+ if (!fields.has(key)) add('schema-error', `${path}.${key}`, `unknown field '${key}'`)
297
+ if (entry.disabled !== undefined && typeof entry.disabled !== 'boolean')
298
+ add('schema-error', `${path}.disabled`, 'must be true or false')
299
+ if (entry.config !== undefined && !isObject(entry.config))
300
+ add('schema-error', `${path}.config`, 'must be a map')
301
+ if (isObject(entry.config)) checkTokens(entry.config, `${path}.config`)
302
+ }
303
+ const checkName = (name: unknown, path: string) => {
304
+ if (registry && typeof name === 'string' && !registry.has(name))
305
+ add(
306
+ 'unknown-plugin',
307
+ path,
308
+ `no plugin '${name}' in the app's registry (known: ${[...registry].join(', ')})`,
309
+ )
310
+ }
311
+
312
+ const rows = spec.rows ?? []
313
+ if (!Array.isArray(rows)) add('schema-error', 'spec.rows', 'must be a list of rows')
314
+ else {
315
+ for (const [index, row] of (rows as unknown[]).entries()) {
316
+ const path = `spec.rows[${index}]`
317
+ if (!isObject(row)) {
318
+ add('schema-error', path, 'a row must be a map with an id and a name')
319
+ continue
320
+ }
321
+ if (typeof row.id !== 'string' || !ROW_ID.test(row.id)) {
322
+ add('schema-error', `${path}.id`, 'must be lowercase letters, digits and dashes')
323
+ continue
324
+ }
325
+ if (ids.has(row.id)) add('duplicate-row-id', `${path}.id`, `the id '${row.id}' is used twice`)
326
+ ids.add(row.id)
327
+ if (typeof row.name !== 'string' || row.name === '')
328
+ add(
329
+ 'schema-error',
330
+ `${path}.name`,
331
+ 'missing required field name (the plugin this row loads)',
332
+ )
333
+ else checkName(row.name, `${path}.name`)
334
+ checkFlags(row, path, ROW_FIELDS)
335
+ }
336
+ }
337
+
338
+ const overrides = spec.overrides ?? []
339
+ if (!Array.isArray(overrides))
340
+ add('schema-error', 'spec.overrides', 'must be a list of overrides')
341
+ else {
342
+ const seen = new Set<string>()
343
+ for (const [index, entry] of (overrides as unknown[]).entries()) {
344
+ const path = `spec.overrides[${index}]`
345
+ if (!isObject(entry)) {
346
+ add('schema-error', path, 'an override must be a map with an id')
347
+ continue
348
+ }
349
+ if (typeof entry.id !== 'string' || entry.id === '') {
350
+ add(
351
+ 'schema-error',
352
+ `${path}.id`,
353
+ 'missing required field id (the inherited row it changes)',
354
+ )
355
+ continue
356
+ }
357
+ if (seen.has(entry.id))
358
+ add('duplicate-row-id', `${path}.id`, `the override id '${entry.id}' is used twice`)
359
+ seen.add(entry.id)
360
+ if (entry.name !== undefined) {
361
+ if (typeof entry.name !== 'string' || entry.name === '')
362
+ add('schema-error', `${path}.name`, 'must be a plugin name')
363
+ else checkName(entry.name, `${path}.name`)
364
+ }
365
+ checkFlags(entry, path, OVERRIDE_FIELDS)
366
+ }
367
+ if (overrides.length > 0 && parentIdOf(document as unknown as Manifest) === undefined)
368
+ add(
369
+ 'overrides-without-parent',
370
+ 'spec.overrides',
371
+ 'overrides need a parent: declare spec.extends, or make this a Blend with a lineage',
372
+ )
373
+ }
374
+ for (const name of declared.keys())
375
+ if (!used.has(name))
376
+ add('unused-parameter', `spec.parameters.${name}`, `'${name}' is declared but not used`)
377
+ return problems
378
+ }
379
+
380
+ function substitute(value: unknown, values: Readonly<Record<string, ParameterValue>>): unknown {
381
+ if (typeof value === 'string') {
382
+ const whole = WHOLE_TOKEN.exec(value)
383
+ if (whole) return values[whole[1] as string] ?? value
384
+ return value.replace(TOKEN, (token, name: string) =>
385
+ name in values ? String(values[name]) : token,
386
+ )
387
+ }
388
+ if (Array.isArray(value)) return value.map((item) => substitute(item, values))
389
+ if (isObject(value))
390
+ return Object.fromEntries(
391
+ Object.entries(value).map(([key, item]) => [key, substitute(item, values)]),
392
+ )
393
+ return value
394
+ }
395
+
396
+ /** A manifest with its parameters resolved: every row's config has its tokens replaced, typed where a value is one whole token. */
397
+ export interface ResolvedManifest extends Manifest {
398
+ /** The rows to mount, in order: the parent's rows (with overrides applied), then this document's own. `use` selects the registry entry. */
399
+ readonly plugins: readonly Row[]
400
+ readonly values: Readonly<Record<string, ParameterValue>>
401
+ /** sha256 of the manifest file's bytes. */
402
+ readonly digest: string
403
+ }
404
+
405
+ export interface LoadOptions {
406
+ readonly known?: Iterable<string>
407
+ /** Values for the parameters of this document. A parent always resolves with its own defaults. */
408
+ readonly values?: Readonly<Record<string, ParameterValue>>
409
+ /** Where the parent Blueprint of a Blend lives: its file path by definition id, or undefined when it is not supplied. */
410
+ readonly getBlueprint?: (id: string) => string | undefined
411
+ }
412
+
413
+ function parseFile(file: string): { text: string; document: unknown } | Diagnostic[] {
414
+ if (!existsSync(file))
415
+ return [{ code: 'parse-error', path: '$', message: `${file} does not exist` }]
416
+ const text = readFileSync(file, 'utf8')
417
+ try {
418
+ return { text, document: parse(text) }
419
+ } catch (error) {
420
+ return [
421
+ {
422
+ code: 'parse-error',
423
+ path: '$',
424
+ message: error instanceof Error ? error.message : String(error),
425
+ },
426
+ ]
427
+ }
428
+ }
429
+
430
+ function collectValues(
431
+ declarations: Readonly<Record<string, ParameterDeclaration>>,
432
+ given: Readonly<Record<string, ParameterValue>> | undefined,
433
+ diagnostics: Diagnostic[],
434
+ ): Record<string, ParameterValue> {
435
+ const values: Record<string, ParameterValue> = {}
436
+ for (const [name, declaration] of Object.entries(declarations)) values[name] = declaration.default
437
+ for (const [name, value] of Object.entries(given ?? {})) {
438
+ const declared = declarations[name]
439
+ if (!declared)
440
+ diagnostics.push({
441
+ code: 'parameter-override-unknown',
442
+ path: `spec.parameters.${name}`,
443
+ message: `no parameter '${name}' to set`,
444
+ })
445
+ else if (typeof value !== declared.type)
446
+ diagnostics.push({
447
+ code: 'parameter-type-mismatch',
448
+ path: `spec.parameters.${name}`,
449
+ message: `must be a ${declared.type}`,
450
+ })
451
+ else values[name] = value
452
+ }
453
+ return values
454
+ }
455
+
456
+ const substituted = (
457
+ config: Readonly<Record<string, unknown>> | undefined,
458
+ values: Record<string, ParameterValue>,
459
+ ) => (config === undefined ? {} : { config: substitute(config, values) as Record<string, unknown> })
460
+
461
+ /** A shallow per-key merge: keys the override sets win, keys it omits are inherited. The parent's row is never changed. */
462
+ function merged(row: Row, entry: OverrideEntry, values: Record<string, ParameterValue>): Row {
463
+ const config =
464
+ entry.config === undefined
465
+ ? row.config
466
+ : { ...row.config, ...(substitute(entry.config, values) as Record<string, unknown>) }
467
+ const disabled = entry.disabled ?? row.disabled
468
+ return {
469
+ id: row.id,
470
+ use: entry.name ?? row.use,
471
+ ...(config === undefined ? {} : { config }),
472
+ ...(disabled === undefined ? {} : { disabled }),
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Resolve one document over its parent chain. Every problem is a diagnostic; nothing throws for invalid input. Rules (ACRYL's): the
478
+ * parent is `extends`, else a Blend's lineage Blueprint; a parent resolves with its own defaults; an override must name a row the
479
+ * resolved parent has; an own row must not reuse a parent row's id; a cycle or a missing parent is reported.
480
+ */
481
+ export function resolveRows(
482
+ document: Manifest,
483
+ options: Pick<LoadOptions, 'values' | 'getBlueprint'>,
484
+ chain: readonly string[],
485
+ diagnostics: Diagnostic[],
486
+ where = '',
487
+ ): { rows: Row[]; values: Record<string, ParameterValue> } {
488
+ const at = (path: string) => `${where}${path}`
489
+ const values = collectValues(document.spec.parameters ?? {}, options.values, diagnostics)
490
+ let inherited: Row[] = []
491
+ // False when the parent could not be read or resolved: its problems are reported, and checks against its rows would only add noise.
492
+ let parentResolved = parentIdOf(document) === undefined
493
+ const parentId = parentIdOf(document)
494
+ const parentPath = document.spec.extends !== undefined ? 'spec.extends' : 'spec.lineage.blueprint'
495
+ if (parentId !== undefined) {
496
+ if (chain.includes(parentId)) {
497
+ diagnostics.push({
498
+ code: 'parent-cycle',
499
+ path: at(parentPath),
500
+ message: `parent '${parentId}' closes a cycle in the resolution chain`,
501
+ })
502
+ } else {
503
+ const file = options.getBlueprint?.(parentId)
504
+ const read = file === undefined ? undefined : parseFile(file)
505
+ if (read === undefined || Array.isArray(read)) {
506
+ diagnostics.push({
507
+ code: 'parent-not-supplied',
508
+ path: at(parentPath),
509
+ message:
510
+ read === undefined
511
+ ? `parent '${parentId}' is not supplied (getBlueprint returned nothing)`
512
+ : `parent '${parentId}': ${read[0]?.message}`,
513
+ })
514
+ } else {
515
+ const problems = validate(read.document)
516
+ if (problems.length > 0) {
517
+ for (const problem of problems)
518
+ diagnostics.push({ ...problem, path: `parent '${parentId}': ${problem.path}` })
519
+ } else {
520
+ inherited = resolveRows(
521
+ read.document as Manifest,
522
+ options.getBlueprint ? { getBlueprint: options.getBlueprint } : {},
523
+ [...chain, parentId],
524
+ diagnostics,
525
+ `parent '${parentId}': `,
526
+ ).rows
527
+ parentResolved = !diagnostics.some((d) => d.path.startsWith(`parent '${parentId}'`))
528
+ }
529
+ }
530
+ }
531
+ }
532
+ const own: Row[] = (document.spec.rows ?? []).map((row) => ({
533
+ id: row.id,
534
+ use: row.name,
535
+ ...substituted(row.config, values),
536
+ ...(row.disabled === undefined ? {} : { disabled: row.disabled }),
537
+ }))
538
+ const parentIds = new Set(inherited.map((row) => row.id))
539
+ if (parentId !== undefined && parentResolved) {
540
+ for (const [index, entry] of (document.spec.overrides ?? []).entries())
541
+ if (!parentIds.has(entry.id))
542
+ diagnostics.push({
543
+ code: 'override-unknown-id',
544
+ path: at(`spec.overrides[${index}].id`),
545
+ message: `override targets row id '${entry.id}', which the resolved parent does not have`,
546
+ })
547
+ for (const [index, row] of own.entries())
548
+ if (parentIds.has(row.id))
549
+ diagnostics.push({
550
+ code: 'insert-id-collision',
551
+ path: at(`spec.rows[${index}].id`),
552
+ message: `row id '${row.id}' already exists in the resolved parent`,
553
+ })
554
+ }
555
+ const overrideById = new Map((document.spec.overrides ?? []).map((entry) => [entry.id, entry]))
556
+ const rows = [
557
+ ...inherited.map((row) =>
558
+ overrideById.has(row.id)
559
+ ? merged(row, overrideById.get(row.id) as OverrideEntry, values)
560
+ : row,
561
+ ),
562
+ ...own,
563
+ ]
564
+ return { rows, values }
565
+ }
566
+
567
+ /**
568
+ * Read, validate and resolve a manifest. A Blend resolves over its Blueprint (`getBlueprint`). Throws a `ManifestError` with every
569
+ * diagnostic when anything is invalid.
570
+ */
571
+ export function loadManifest(file: string, options: LoadOptions = {}): ResolvedManifest {
572
+ const read = parseFile(file)
573
+ if (Array.isArray(read)) throw new ManifestError(file, read)
574
+ const diagnostics = validate(read.document, options.known)
575
+ if (diagnostics.some((d) => d.code === 'schema-error' || d.code === 'parse-error'))
576
+ throw new ManifestError(file, diagnostics)
577
+ const raw = read.document as Manifest
578
+ const { rows, values } = resolveRows(raw, options, [raw.metadata.id], diagnostics)
579
+ // A row inherited from a parent (or renamed by an override) can name a plugin this app does not have.
580
+ if (options.known) {
581
+ const registry = new Set(options.known)
582
+ const own = new Set((raw.spec.rows ?? []).map((row) => row.id))
583
+ for (const row of rows)
584
+ if (!own.has(row.id) && !registry.has(row.use))
585
+ diagnostics.push({
586
+ code: 'unknown-plugin',
587
+ path: `resolved row '${row.id}'`,
588
+ message: `no plugin '${row.use}' in the app's registry (known: ${[...registry].join(', ')})`,
589
+ })
590
+ }
591
+ if (diagnostics.length > 0) throw new ManifestError(file, diagnostics)
592
+ return {
593
+ ...raw,
594
+ plugins: rows,
595
+ values,
596
+ digest: createHash('sha256').update(read.text).digest('hex'),
597
+ }
598
+ }
599
+
600
+ /** What an app can mount: a Cordis plugin per name. */
601
+ export type Registry = Readonly<Record<string, unknown>>
602
+
603
+ /**
604
+ * Mount the manifest's enabled rows in order, each with its config merged under `overrides[id]` (runtime values such as the port win),
605
+ * awaiting each one, because a fiber still waiting on an injected service reports done too early. Returns the fibers, in order.
606
+ */
607
+ export async function compose(
608
+ root: Context,
609
+ manifest: Pick<ResolvedManifest, 'plugins'>,
610
+ registry: Registry,
611
+ overrides: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {},
612
+ ): Promise<Fiber[]> {
613
+ const fibers: Fiber[] = []
614
+ for (const row of manifest.plugins) {
615
+ if (row.disabled) continue
616
+ const plugin = registry[row.use]
617
+ if (!plugin)
618
+ throw new ManifestError('the manifest', [
619
+ { code: 'unknown-plugin', path: row.id, message: `no plugin '${row.use}' in the registry` },
620
+ ])
621
+ const config = { ...(row.config ?? {}), ...(overrides[row.id] ?? {}) }
622
+ const fiber = root.plugin(plugin as Parameters<Context['plugin']>[0], config as never)
623
+ fibers.push(fiber)
624
+ await fiber.await()
625
+ }
626
+ return fibers
627
+ }
628
+
629
+ /** The usual place for Blueprints: a folder with one `<definition id>.yaml` per Blueprint (for example `blueprints/acme.shop.yaml`). */
630
+ export function blueprintsIn(directory: string): (id: string) => string | undefined {
631
+ return (id) => {
632
+ const file = join(directory, `${id}.yaml`)
633
+ return DEFINITION_ID.test(id) && existsSync(file) ? file : undefined
634
+ }
635
+ }