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