@human-synthesis/norns 0.1.0 → 0.2.1
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/package.json +1 -1
- package/src/kernel/address.js +4 -0
- package/src/kernel/emit-units.js +283 -7
- package/src/kernel/emit-wrangler.js +22 -2
- package/src/kernel/expr-compile.js +4 -1
- package/src/kernel/flow.js +444 -0
- package/src/kernel/generate.js +500 -23
- package/src/kernel/index.js +9 -2
- package/src/kernel/meta.js +145 -1
- package/src/kernel/refine.js +78 -0
- package/src/kernel/trace.js +215 -27
- package/src/live-client.js +144 -0
- package/src/server/boot.js +22 -0
- package/src/server/db.js +5 -1
- package/src/server/endpoint.js +142 -0
- package/src/server/index.js +3 -0
- package/src/server/job.js +102 -0
- package/src/server/room.js +17 -0
- package/src/server/service.js +188 -0
package/src/kernel/generate.js
CHANGED
|
@@ -22,14 +22,20 @@ import { isAddress, listUnits, parseAddress } from './address.js';
|
|
|
22
22
|
import { schemaEmitter } from './emit-schema.js';
|
|
23
23
|
import {
|
|
24
24
|
actionsEmitter,
|
|
25
|
+
componentKey,
|
|
26
|
+
endpointsEmitter,
|
|
27
|
+
jobsEmitter,
|
|
28
|
+
humanizeName,
|
|
25
29
|
pagesEmitter,
|
|
26
30
|
policiesEmitter,
|
|
27
31
|
queriesEmitter,
|
|
28
32
|
remotesEmitter,
|
|
33
|
+
servicesEmitter,
|
|
29
34
|
triggersEmitter
|
|
30
35
|
} from './emit-units.js';
|
|
31
36
|
import { machinesEmitter } from './emit-machines.js';
|
|
32
37
|
import { wranglerFile } from './emit-wrangler.js';
|
|
38
|
+
import { emitFlow } from './flow.js';
|
|
33
39
|
import { buildGraph } from './graph.js';
|
|
34
40
|
import { loadSpecs, validateSpecs } from './validate.js';
|
|
35
41
|
|
|
@@ -49,6 +55,10 @@ export function checkGenerate(specs, opts = {}) {
|
|
|
49
55
|
const refusals = [];
|
|
50
56
|
const graph = buildGraph(specs.modules);
|
|
51
57
|
if (opts.contracts) refusals.push(...checkBindings(specs, opts.contracts));
|
|
58
|
+
refusals.push(...checkLiveBindings(specs));
|
|
59
|
+
refusals.push(...checkSnippetBindings(specs, opts.snippetSlots ?? null));
|
|
60
|
+
refusals.push(...checkTokenOverrides(specs, opts.tokens ?? null));
|
|
61
|
+
refusals.push(...checkNetworkInBodies(specs));
|
|
52
62
|
|
|
53
63
|
for (const [moduleName, spec] of Object.entries(specs.modules)) {
|
|
54
64
|
for (const unit of listUnits(moduleName, spec)) {
|
|
@@ -81,6 +91,9 @@ export function checkGenerate(specs, opts = {}) {
|
|
|
81
91
|
});
|
|
82
92
|
}
|
|
83
93
|
}
|
|
94
|
+
if (unit.kind === 'Service' || unit.kind === 'Endpoint') {
|
|
95
|
+
refusals.push(...checkServiceSecrets(unit));
|
|
96
|
+
}
|
|
84
97
|
if (unit.kind === 'Query') {
|
|
85
98
|
const q = unit.value;
|
|
86
99
|
if (q && typeof q === 'object' && !q.live && !q.groupBy && q.limit === undefined) {
|
|
@@ -98,6 +111,128 @@ export function checkGenerate(specs, opts = {}) {
|
|
|
98
111
|
return refusals;
|
|
99
112
|
}
|
|
100
113
|
|
|
114
|
+
const SECRET_SHAPE_RE =
|
|
115
|
+
/(?:^|[^A-Za-z0-9])(?:sk|pk|rk)_(?:live|test|prod)_[A-Za-z0-9]{8,}|^(?:ghp|gho|github_pat)_|^xox[a-z]-|^AKIA[0-9A-Z]{12}|^eyJ[A-Za-z0-9_-]{10,}/;
|
|
116
|
+
|
|
117
|
+
/** Token-shaped: known prefixes, or long spaceless mixed-class strings that are not URLs. */
|
|
118
|
+
function looksLikeSecret(s) {
|
|
119
|
+
if (typeof s !== 'string') return false;
|
|
120
|
+
if (SECRET_SHAPE_RE.test(s)) return true;
|
|
121
|
+
return (
|
|
122
|
+
s.length >= 32 &&
|
|
123
|
+
!/\s/.test(s) &&
|
|
124
|
+
/[A-Z]/.test(s) &&
|
|
125
|
+
/[a-z]/.test(s) &&
|
|
126
|
+
/[0-9]/.test(s) &&
|
|
127
|
+
!/^https?:\/\//.test(s)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function* stringLeaves(value, path = '') {
|
|
132
|
+
if (typeof value === 'string') yield [path, value];
|
|
133
|
+
else if (value && typeof value === 'object') {
|
|
134
|
+
for (const [k, child] of Object.entries(value)) {
|
|
135
|
+
yield* stringLeaves(child, path ? `${path}.${k}` : k);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* D15: credentials never in spec. Refuses userinfo/query params in a
|
|
142
|
+
* service base URL and any token-shaped literal anywhere in the unit —
|
|
143
|
+
* `auth.binding` is a binding *name*; the value lives in the environment
|
|
144
|
+
* (`wrangler secret put`).
|
|
145
|
+
*
|
|
146
|
+
* @param {{ address: string, value: * }} unit a Service unit
|
|
147
|
+
* @returns {Refusal[]}
|
|
148
|
+
*/
|
|
149
|
+
export function checkServiceSecrets(unit) {
|
|
150
|
+
/** @type {Refusal[]} */
|
|
151
|
+
const refusals = [];
|
|
152
|
+
const svc = unit.value ?? {};
|
|
153
|
+
const push = (path, message) =>
|
|
154
|
+
refusals.push({
|
|
155
|
+
address: unit.address,
|
|
156
|
+
path: `${unit.address}.${path}`,
|
|
157
|
+
code: 'SECRET_IN_SPEC',
|
|
158
|
+
message,
|
|
159
|
+
fix: 'keep only an UPPER_SNAKE binding name in spec and set the value with `wrangler secret put`'
|
|
160
|
+
});
|
|
161
|
+
if (typeof svc.base === 'string') {
|
|
162
|
+
try {
|
|
163
|
+
const u = new URL(svc.base);
|
|
164
|
+
if (u.username || u.password) push('base', 'base URL embeds userinfo credentials');
|
|
165
|
+
if (u.search) push('base', 'base URL embeds query parameters — move keys/tokens to an env binding');
|
|
166
|
+
} catch {
|
|
167
|
+
// meta-schema already rejects non-URL bases
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const [path, s] of stringLeaves(svc)) {
|
|
171
|
+
if (path !== 'base' && looksLikeSecret(s)) {
|
|
172
|
+
push(path, `"${s.slice(0, 8)}…" looks like a literal secret — credentials never go in spec`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return refusals;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Global `fetch(` — not `.fetch(` (DO stubs, service bindings) or `myfetch(`. */
|
|
179
|
+
const GLOBAL_FETCH_RE = /(?<![.\w])fetch\s*\(/;
|
|
180
|
+
|
|
181
|
+
function* customBodyFiles(moduleName, spec) {
|
|
182
|
+
for (const [name, a] of Object.entries(spec.actions ?? {})) {
|
|
183
|
+
if (a?.impl === 'custom') yield [`${moduleName}.Action.${name}`, `src/${moduleName}/actions/${name}.c`];
|
|
184
|
+
}
|
|
185
|
+
for (const [name, j] of Object.entries(spec.jobs ?? {})) {
|
|
186
|
+
if (j?.impl === 'custom') yield [`${moduleName}.Job.${name}`, `src/${moduleName}/jobs/${name}.c`];
|
|
187
|
+
}
|
|
188
|
+
for (const name of Object.keys(spec.functions ?? {})) {
|
|
189
|
+
yield [`${moduleName}.Function.${name}`, `src/${moduleName}/functions/${name}.c`];
|
|
190
|
+
}
|
|
191
|
+
for (const name of Object.keys(spec.endpoints ?? {})) {
|
|
192
|
+
yield [`${moduleName}.Endpoint.${name}`, `src/${moduleName}/endpoints/${name}.c`];
|
|
193
|
+
}
|
|
194
|
+
for (const [name, w] of Object.entries(spec.workers ?? {})) {
|
|
195
|
+
if (typeof w?.source === 'string') yield [`${moduleName}.Worker.${name}`, w.source];
|
|
196
|
+
}
|
|
197
|
+
for (const [name, r] of Object.entries(spec.routes ?? {})) {
|
|
198
|
+
if (typeof r?.source === 'string') yield [`${moduleName}.Route.${name}`, r.source];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* X-07: the generated service client is the only network path a custom
|
|
204
|
+
* body may take. Scans declared L3 bodies (custom actions/jobs, functions,
|
|
205
|
+
* endpoints, worker/route sources) for direct global `fetch(` calls.
|
|
206
|
+
* No-op for in-memory specs (no `dir`) — the lint needs files on disk.
|
|
207
|
+
*
|
|
208
|
+
* @param {{ dir?: string, modules: Record<string, *> }} specs
|
|
209
|
+
* @returns {Refusal[]}
|
|
210
|
+
*/
|
|
211
|
+
export function checkNetworkInBodies(specs) {
|
|
212
|
+
if (typeof specs.dir !== 'string') return [];
|
|
213
|
+
const appRoot = dirname(specs.dir);
|
|
214
|
+
/** @type {Refusal[]} */
|
|
215
|
+
const refusals = [];
|
|
216
|
+
for (const [moduleName, spec] of Object.entries(specs.modules)) {
|
|
217
|
+
for (const [address, rel] of customBodyFiles(moduleName, spec)) {
|
|
218
|
+
const file = join(appRoot, rel);
|
|
219
|
+
if (!existsSync(file)) continue;
|
|
220
|
+
const hit = readFileSync(file, 'utf-8')
|
|
221
|
+
.split('\n')
|
|
222
|
+
.findIndex((line) => GLOBAL_FETCH_RE.test(line));
|
|
223
|
+
if (hit === -1) continue;
|
|
224
|
+
refusals.push({
|
|
225
|
+
address,
|
|
226
|
+
path: `${rel}:${hit + 1}`,
|
|
227
|
+
code: 'UNDECLARED_NETWORK',
|
|
228
|
+
message: `custom body calls global fetch() at ${rel}:${hit + 1} — the generated service client is the only network path`,
|
|
229
|
+
fix: 'declare the host as a Service (with auth binding + operations) and call it through the generated client'
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return refusals;
|
|
234
|
+
}
|
|
235
|
+
|
|
101
236
|
/**
|
|
102
237
|
* Validate page `components:` entries against palette props contracts
|
|
103
238
|
* (U-02). Each entry is normalized the way the pages emitter binds it: the
|
|
@@ -117,7 +252,8 @@ export function checkBindings(specs, contracts) {
|
|
|
117
252
|
(page.components ?? []).forEach((entry, i) => {
|
|
118
253
|
const keys = Object.keys(entry);
|
|
119
254
|
if (keys.length === 0) return;
|
|
120
|
-
const
|
|
255
|
+
const first = componentKey(entry);
|
|
256
|
+
const rest = keys.filter((k) => k !== first);
|
|
121
257
|
const tag = first[0].toUpperCase() + first.slice(1);
|
|
122
258
|
const contract = contracts[tag];
|
|
123
259
|
if (!contract) return;
|
|
@@ -127,6 +263,12 @@ export function checkBindings(specs, contracts) {
|
|
|
127
263
|
typeof primary === 'string' && isAddress(primary) ? parseAddress(primary) : null;
|
|
128
264
|
const props = {};
|
|
129
265
|
if (parsed) props[parsed.kind === 'Action' ? 'action' : 'data'] = primary;
|
|
266
|
+
else if (primary && typeof primary === 'object' && !Array.isArray(primary)) {
|
|
267
|
+
// realtime bindings (K-27) — the emitter turns these into
|
|
268
|
+
// streamSource/roomChannel props, so validate that shape
|
|
269
|
+
if (typeof primary.stream === 'string') props.streamSource = primary.stream;
|
|
270
|
+
if (typeof primary.room === 'string') props.roomChannel = primary.room;
|
|
271
|
+
}
|
|
130
272
|
for (const key of rest) props[key] = entry[key];
|
|
131
273
|
|
|
132
274
|
const result = v.safeParse(contract, props);
|
|
@@ -146,6 +288,254 @@ export function checkBindings(specs, contracts) {
|
|
|
146
288
|
return refusals;
|
|
147
289
|
}
|
|
148
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Validate realtime page bindings (K-27) — object-primary component
|
|
293
|
+
* entries like `{ streamText: { stream: "m.Endpoint.x" } }` and
|
|
294
|
+
* `{ chatThread: { room: "m.Worker.x", sends: [...], receives: [...] } }`.
|
|
295
|
+
* `stream` must target an Endpoint with a `stream` output mode; `room` a
|
|
296
|
+
* Worker declared `room: true`. Optional `sends`/`receives` message-name
|
|
297
|
+
* lists are cross-checked against the Room's declared message schemas
|
|
298
|
+
* (`in` for sends, `out` for receives). Needs no palette contracts — the
|
|
299
|
+
* target shape lives in the spec itself.
|
|
300
|
+
*
|
|
301
|
+
* @param {{ modules: Record<string, *> }} specs
|
|
302
|
+
* @returns {Refusal[]}
|
|
303
|
+
*/
|
|
304
|
+
export function checkLiveBindings(specs) {
|
|
305
|
+
/** @type {Refusal[]} */
|
|
306
|
+
const refusals = [];
|
|
307
|
+
for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
|
|
308
|
+
for (const [pageName, page] of Object.entries(moduleSpec.pages ?? {})) {
|
|
309
|
+
(page.components ?? []).forEach((entry, i) => {
|
|
310
|
+
const first = componentKey(entry);
|
|
311
|
+
if (!first) return;
|
|
312
|
+
const binding = entry[first];
|
|
313
|
+
if (!binding || typeof binding !== 'object' || Array.isArray(binding)) return;
|
|
314
|
+
const tag = first[0].toUpperCase() + first.slice(1);
|
|
315
|
+
const refuse = (sub, message, fix) =>
|
|
316
|
+
refusals.push({
|
|
317
|
+
address: `${moduleName}.Page.${pageName}`,
|
|
318
|
+
path: `${moduleName}.Page.${pageName}.components[${i}].${first}.${sub}`,
|
|
319
|
+
code: 'INVALID_BINDING',
|
|
320
|
+
message,
|
|
321
|
+
fix
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
if ('stream' in binding) {
|
|
325
|
+
const addr = binding.stream;
|
|
326
|
+
const parsed = typeof addr === 'string' && isAddress(addr) ? parseAddress(addr) : null;
|
|
327
|
+
const target =
|
|
328
|
+
parsed?.kind === 'Endpoint'
|
|
329
|
+
? specs.modules[parsed.module]?.endpoints?.[parsed.name]
|
|
330
|
+
: undefined;
|
|
331
|
+
if (!parsed || parsed.kind !== 'Endpoint' || !target) {
|
|
332
|
+
refuse(
|
|
333
|
+
'stream',
|
|
334
|
+
`<${tag}> \`stream\` binding needs an existing Endpoint address, got ${JSON.stringify(addr)}`,
|
|
335
|
+
'point `stream` at a declared module.Endpoint.name'
|
|
336
|
+
);
|
|
337
|
+
} else if (!target.stream) {
|
|
338
|
+
refuse(
|
|
339
|
+
'stream',
|
|
340
|
+
`${addr} has no \`stream\` output mode — <${tag}> consumes typed SSE frames`,
|
|
341
|
+
'declare `stream: { frame: { … } }` on the Endpoint (output and stream are exclusive)'
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if ('room' in binding) {
|
|
347
|
+
const addr = binding.room;
|
|
348
|
+
const parsed = typeof addr === 'string' && isAddress(addr) ? parseAddress(addr) : null;
|
|
349
|
+
const target =
|
|
350
|
+
parsed?.kind === 'Worker'
|
|
351
|
+
? specs.modules[parsed.module]?.workers?.[parsed.name]
|
|
352
|
+
: undefined;
|
|
353
|
+
if (!parsed || parsed.kind !== 'Worker' || !target) {
|
|
354
|
+
refuse(
|
|
355
|
+
'room',
|
|
356
|
+
`<${tag}> \`room\` binding needs an existing Worker address, got ${JSON.stringify(addr)}`,
|
|
357
|
+
'point `room` at a declared module.Worker.name'
|
|
358
|
+
);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (target.room !== true) {
|
|
362
|
+
refuse(
|
|
363
|
+
'room',
|
|
364
|
+
`${addr} is not a Room — <${tag}> needs a Worker declared \`room: true\``,
|
|
365
|
+
'set `room: true` (plus `messages`/`state`) on the Worker'
|
|
366
|
+
);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const messages = target.messages ?? {};
|
|
370
|
+
for (const [listKey, dir] of [
|
|
371
|
+
['sends', 'in'],
|
|
372
|
+
['receives', 'out']
|
|
373
|
+
]) {
|
|
374
|
+
for (const name of Array.isArray(binding[listKey]) ? binding[listKey] : []) {
|
|
375
|
+
if (messages[name]?.[dir]) continue;
|
|
376
|
+
refuse(
|
|
377
|
+
listKey,
|
|
378
|
+
`${addr} declares no \`${dir}\` schema for message ${JSON.stringify(name)}`,
|
|
379
|
+
`declare \`messages.${name}.${dir}\` on the Worker or drop it from \`${listKey}\``
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return refusals;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Validate Snippet bindings (U-07): a page component prop bound to a
|
|
392
|
+
* `m.Snippet.n` address must reference a declared Snippet unit, and — when
|
|
393
|
+
* palette slot metadata is available — its declared `args` must match the
|
|
394
|
+
* slot's calling convention (the emitter forwards those args as props to
|
|
395
|
+
* the custom body, so a mismatch is a broken render, not a style issue).
|
|
396
|
+
*
|
|
397
|
+
* @param {{ modules: Record<string, *> }} specs
|
|
398
|
+
* @param {Record<string, Record<string, string[]>> | null} [slots]
|
|
399
|
+
* `snippetSlots` from @human-synthesis/norns-ui/contracts: tag → prop → args
|
|
400
|
+
* @returns {Refusal[]}
|
|
401
|
+
*/
|
|
402
|
+
export function checkSnippetBindings(specs, slots = null) {
|
|
403
|
+
/** @type {Refusal[]} */
|
|
404
|
+
const refusals = [];
|
|
405
|
+
for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
|
|
406
|
+
for (const [pageName, page] of Object.entries(moduleSpec.pages ?? {})) {
|
|
407
|
+
(page.components ?? []).forEach((entry, i) => {
|
|
408
|
+
const keys = Object.keys(entry ?? {});
|
|
409
|
+
if (keys.length === 0) return;
|
|
410
|
+
const first = componentKey(entry);
|
|
411
|
+
const tag = first[0].toUpperCase() + first.slice(1);
|
|
412
|
+
for (const key of keys) {
|
|
413
|
+
const value = entry[key];
|
|
414
|
+
if (typeof value !== 'string' || !isAddress(value)) continue;
|
|
415
|
+
const parsed = parseAddress(value);
|
|
416
|
+
if (parsed.kind !== 'Snippet') continue;
|
|
417
|
+
const refuse = (message, fix) =>
|
|
418
|
+
refusals.push({
|
|
419
|
+
address: `${moduleName}.Page.${pageName}`,
|
|
420
|
+
path: `${moduleName}.Page.${pageName}.components[${i}].${key}`,
|
|
421
|
+
code: 'INVALID_BINDING',
|
|
422
|
+
message,
|
|
423
|
+
fix
|
|
424
|
+
});
|
|
425
|
+
const target = specs.modules[parsed.module]?.snippets?.[parsed.name];
|
|
426
|
+
if (!target) {
|
|
427
|
+
refuse(
|
|
428
|
+
`no Snippet declared at ${value}`,
|
|
429
|
+
`declare \`snippets.${parsed.name}\` (with \`args\`) in module "${parsed.module}" — the body lives in src/${parsed.module}/snippets/${parsed.name}.n`
|
|
430
|
+
);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const want = slots?.[tag]?.[key];
|
|
434
|
+
if (!want) continue;
|
|
435
|
+
const got = target.args ?? [];
|
|
436
|
+
if (want.length !== got.length || want.some((a, j) => a !== got[j])) {
|
|
437
|
+
refuse(
|
|
438
|
+
`<${tag}> \`${key}\` slot passes (${want.join(', ')}) but ${value} declares args (${got.join(', ')})`,
|
|
439
|
+
`set \`args: [${want.map((a) => `"${a}"`).join(', ')}]\` on the Snippet — the emitter forwards them as same-named props`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return refusals;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const TOKEN_NAME_RE = /^--[a-z][a-z0-9-]*$/;
|
|
450
|
+
|
|
451
|
+
const asVarName = (name) => (name.startsWith('--') ? name : `--${name}`);
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Validate app-level design-token overrides (U-10): `app.settings.tokens`
|
|
455
|
+
* is a record of token name → CSS value, emitted verbatim into a
|
|
456
|
+
* generated stylesheet — so names must be kebab-case custom properties
|
|
457
|
+
* (checked against the palette token manifest when available) and values
|
|
458
|
+
* must not be able to escape their declaration.
|
|
459
|
+
*
|
|
460
|
+
* @param {{ app?: * }} specs
|
|
461
|
+
* @param {{ vars?: Record<string, string> } | null} [tokens]
|
|
462
|
+
* `tokens` section of @human-synthesis/norns-ui/manifest
|
|
463
|
+
* @returns {Refusal[]}
|
|
464
|
+
*/
|
|
465
|
+
export function checkTokenOverrides(specs, tokens = null) {
|
|
466
|
+
const overrides = specs.app?.settings?.tokens;
|
|
467
|
+
if (overrides === undefined) return [];
|
|
468
|
+
const at = (name) => ({
|
|
469
|
+
address: 'app.settings.tokens',
|
|
470
|
+
path: name ? `app.settings.tokens.${name}` : 'app.settings.tokens'
|
|
471
|
+
});
|
|
472
|
+
if (overrides === null || typeof overrides !== 'object' || Array.isArray(overrides)) {
|
|
473
|
+
return [
|
|
474
|
+
{
|
|
475
|
+
...at(''),
|
|
476
|
+
code: 'INVALID_TOKEN',
|
|
477
|
+
message: 'settings.tokens must be a record of design-token name → CSS value',
|
|
478
|
+
fix: 'e.g. `"settings": { "tokens": { "color-primary-500": "oklch(55% 0.2 260)" } }`'
|
|
479
|
+
}
|
|
480
|
+
];
|
|
481
|
+
}
|
|
482
|
+
/** @type {Refusal[]} */
|
|
483
|
+
const refusals = [];
|
|
484
|
+
const known = tokens?.vars ? new Set(Object.keys(tokens.vars)) : null;
|
|
485
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
486
|
+
const varName = asVarName(name);
|
|
487
|
+
if (!TOKEN_NAME_RE.test(varName)) {
|
|
488
|
+
refusals.push({
|
|
489
|
+
...at(name),
|
|
490
|
+
code: 'INVALID_TOKEN',
|
|
491
|
+
message: `"${name}" is not a token name (lowercase kebab-case, optional leading --)`
|
|
492
|
+
});
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (known && !known.has(varName)) {
|
|
496
|
+
refusals.push({
|
|
497
|
+
...at(name),
|
|
498
|
+
code: 'UNKNOWN_TOKEN',
|
|
499
|
+
message: `"${varName}" is not a palette design token`,
|
|
500
|
+
fix: 'browse `tokens.vars` in @human-synthesis/norns-ui/manifest for the addressable set'
|
|
501
|
+
});
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
505
|
+
refusals.push({
|
|
506
|
+
...at(name),
|
|
507
|
+
code: 'INVALID_TOKEN',
|
|
508
|
+
message: `override for "${varName}" must be a non-empty CSS value string`
|
|
509
|
+
});
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
// eslint-disable-next-line no-control-regex
|
|
513
|
+
if (/[;{}]|url\s*\(|[-]/i.test(value)) {
|
|
514
|
+
refusals.push({
|
|
515
|
+
...at(name),
|
|
516
|
+
code: 'INVALID_TOKEN',
|
|
517
|
+
message: `override for "${varName}" is not a plain CSS value — \`;\`, braces, \`url()\` and control characters are refused`
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return refusals;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Normalized `--name → value` record from `app.settings.tokens`, or null
|
|
526
|
+
* when the app declares no overrides.
|
|
527
|
+
*
|
|
528
|
+
* @param {{ app?: * }} specs
|
|
529
|
+
* @returns {Record<string, string> | null}
|
|
530
|
+
*/
|
|
531
|
+
export function tokenOverrides(specs) {
|
|
532
|
+
const raw = specs.app?.settings?.tokens;
|
|
533
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
534
|
+
const entries = Object.entries(raw);
|
|
535
|
+
if (entries.length === 0) return null;
|
|
536
|
+
return Object.fromEntries(entries.map(([name, value]) => [asVarName(name), value]));
|
|
537
|
+
}
|
|
538
|
+
|
|
149
539
|
/**
|
|
150
540
|
* Emitters (K-10..K-12). Each: { name, emit(ctx) } where ctx =
|
|
151
541
|
* { moduleName, moduleSpec, specs, graph } and the return value is a list
|
|
@@ -158,9 +548,12 @@ export const EMITTERS = [
|
|
|
158
548
|
machinesEmitter,
|
|
159
549
|
queriesEmitter,
|
|
160
550
|
actionsEmitter,
|
|
551
|
+
servicesEmitter,
|
|
552
|
+
jobsEmitter,
|
|
161
553
|
triggersEmitter,
|
|
162
554
|
pagesEmitter,
|
|
163
|
-
remotesEmitter
|
|
555
|
+
remotesEmitter,
|
|
556
|
+
endpointsEmitter
|
|
164
557
|
];
|
|
165
558
|
|
|
166
559
|
const require = createRequire(import.meta.url);
|
|
@@ -171,6 +564,8 @@ const FILE_KINDS = {
|
|
|
171
564
|
'actions.c': 'Action',
|
|
172
565
|
'machines.c': 'Action',
|
|
173
566
|
'policies.c': 'Policy',
|
|
567
|
+
'services.c': 'Service',
|
|
568
|
+
'jobs.c': 'Job',
|
|
174
569
|
'triggers.c': 'Trigger'
|
|
175
570
|
};
|
|
176
571
|
|
|
@@ -233,18 +628,26 @@ export class GenerateError extends Error {
|
|
|
233
628
|
}
|
|
234
629
|
|
|
235
630
|
/**
|
|
236
|
-
* Load palette props contracts from the app's own
|
|
237
|
-
* that don't use norns-ui simply skip binding
|
|
631
|
+
* Load palette props contracts + snippet slot metadata from the app's own
|
|
632
|
+
* dependency tree. Apps that don't use norns-ui simply skip binding
|
|
633
|
+
* validation.
|
|
238
634
|
*
|
|
239
635
|
* @param {string} appRoot
|
|
240
|
-
* @returns {Record<string, *> | null}
|
|
636
|
+
* @returns {{ contracts: Record<string, *> | null, snippetSlots: Record<string, *> | null, tokens: { vars?: Record<string, string> } | null }}
|
|
241
637
|
*/
|
|
242
|
-
function
|
|
638
|
+
function loadPalette(appRoot) {
|
|
243
639
|
try {
|
|
244
640
|
const appRequire = createRequire(join(appRoot, 'package.json'));
|
|
245
|
-
|
|
641
|
+
const mod = appRequire('@human-synthesis/norns-ui/contracts');
|
|
642
|
+
let tokens = null;
|
|
643
|
+
try {
|
|
644
|
+
tokens = appRequire('@human-synthesis/norns-ui/manifest')?.tokens ?? null;
|
|
645
|
+
} catch {
|
|
646
|
+
tokens = null;
|
|
647
|
+
}
|
|
648
|
+
return { contracts: mod.contracts ?? null, snippetSlots: mod.snippetSlots ?? null, tokens };
|
|
246
649
|
} catch {
|
|
247
|
-
return null;
|
|
650
|
+
return { contracts: null, snippetSlots: null, tokens: null };
|
|
248
651
|
}
|
|
249
652
|
}
|
|
250
653
|
|
|
@@ -280,22 +683,78 @@ export function liveRouteFile() {
|
|
|
280
683
|
/**
|
|
281
684
|
* Root layout for the generated route tree (app-level, like wrangler.json).
|
|
282
685
|
* Plain `.svelte` — no Pug/Civet — so it stays outside the vetted-subset
|
|
283
|
-
* surface. Imports the app's global stylesheet when `src/app.css` exists
|
|
686
|
+
* surface. Imports the app's global stylesheet when `src/app.css` exists,
|
|
687
|
+
* then the generated token-override sheet (after, so `app.settings.tokens`
|
|
688
|
+
* wins over library defaults pulled in via app.css).
|
|
284
689
|
*
|
|
690
|
+
* When the specs declare statically-routed Pages, the layout is an admin
|
|
691
|
+
* shell: sidebar nav (one link per Page, active state via aria-current)
|
|
692
|
+
* around the content. Styled by the `.norns-*` atoms in norns-ui; override
|
|
693
|
+
* through `app.settings.tokens` or `src/app.css`.
|
|
694
|
+
*
|
|
695
|
+
* @param {{ app?: *, modules: Record<string, *> }} specs
|
|
285
696
|
* @param {boolean} hasAppCss
|
|
697
|
+
* @param {boolean} [hasTokens]
|
|
286
698
|
* @returns {{ path: string, text: string }}
|
|
287
699
|
*/
|
|
288
|
-
export function layoutFile(hasAppCss) {
|
|
700
|
+
export function layoutFile(specs, hasAppCss, hasTokens = false) {
|
|
701
|
+
const nav = [];
|
|
702
|
+
for (const [moduleName, spec] of Object.entries(specs?.modules ?? {})) {
|
|
703
|
+
for (const [name, p] of Object.entries(spec?.pages ?? {})) {
|
|
704
|
+
const route = p?.route;
|
|
705
|
+
if (typeof route !== 'string' || route.includes('[') || route.includes(':')) continue;
|
|
706
|
+
// Generic page names label as their module: companies.Page.index → "Companies".
|
|
707
|
+
const label = ['index', 'home', 'main', 'page'].includes(name) ? moduleName : name;
|
|
708
|
+
nav.push({ href: route, label: humanizeName(label) });
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
nav.sort((a, b) => a.href.localeCompare(b.href));
|
|
712
|
+
const brand = humanizeName(specs?.app?.name ?? 'App');
|
|
713
|
+
|
|
714
|
+
const script = [
|
|
715
|
+
'<script>',
|
|
716
|
+
...(hasAppCss ? ["\timport '$custom/app.css';"] : []),
|
|
717
|
+
...(hasTokens ? ["\timport './tokens.css';"] : []),
|
|
718
|
+
...(nav.length ? ["\timport { page } from '$app/state';"] : []),
|
|
719
|
+
'\tlet { children } = $props();',
|
|
720
|
+
...(nav.length ? [`\tconst nav = ${JSON.stringify(nav)};`] : []),
|
|
721
|
+
'</script>'
|
|
722
|
+
];
|
|
723
|
+
const body = nav.length
|
|
724
|
+
? [
|
|
725
|
+
'<div class="norns-shell">',
|
|
726
|
+
'\t<aside class="norns-sidebar">',
|
|
727
|
+
`\t\t<div class="norns-brand">${brand}</div>`,
|
|
728
|
+
'\t\t<nav class="norns-nav">',
|
|
729
|
+
'\t\t\t{#each nav as item (item.href)}',
|
|
730
|
+
"\t\t\t\t<a href={item.href} aria-current={page.url.pathname === item.href ? 'page' : undefined}>{item.label}</a>",
|
|
731
|
+
'\t\t\t{/each}',
|
|
732
|
+
'\t\t</nav>',
|
|
733
|
+
'\t</aside>',
|
|
734
|
+
'\t<main class="norns-main">{@render children()}</main>',
|
|
735
|
+
'</div>'
|
|
736
|
+
]
|
|
737
|
+
: ['{@render children()}'];
|
|
289
738
|
return {
|
|
290
739
|
path: 'routes/+layout.svelte',
|
|
740
|
+
text: ['<!-- GENERATED by `norns generate` — do not edit. -->', ...script, '', ...body, ''].join('\n')
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* App-level stylesheet applying `app.settings.tokens` overrides (U-10).
|
|
746
|
+
*
|
|
747
|
+
* @param {Record<string, string>} overrides normalized `--name → value`
|
|
748
|
+
* @returns {{ path: string, text: string }}
|
|
749
|
+
*/
|
|
750
|
+
export function tokensFile(overrides) {
|
|
751
|
+
return {
|
|
752
|
+
path: 'routes/tokens.css',
|
|
291
753
|
text: [
|
|
292
|
-
'
|
|
293
|
-
'
|
|
294
|
-
...(
|
|
295
|
-
'
|
|
296
|
-
'</script>',
|
|
297
|
-
'',
|
|
298
|
-
'{@render children()}',
|
|
754
|
+
'/* GENERATED by `norns generate` — do not edit. */',
|
|
755
|
+
':root {',
|
|
756
|
+
...Object.entries(overrides).map(([name, value]) => `\t${name}: ${value};`),
|
|
757
|
+
'}',
|
|
299
758
|
''
|
|
300
759
|
].join('\n')
|
|
301
760
|
};
|
|
@@ -330,8 +789,15 @@ export function generateApp(dir, opts = {}) {
|
|
|
330
789
|
.map((i) => ({ address: i.address, code: 'INVALID_SPEC', message: i.message }))
|
|
331
790
|
);
|
|
332
791
|
}
|
|
333
|
-
const
|
|
334
|
-
const
|
|
792
|
+
const palette = loadPalette(appRoot);
|
|
793
|
+
const contracts = opts.contracts ?? palette.contracts;
|
|
794
|
+
const snippetSlots = opts.snippetSlots ?? palette.snippetSlots;
|
|
795
|
+
const tokens = opts.tokens ?? palette.tokens;
|
|
796
|
+
const refusals = checkGenerate(specs, {
|
|
797
|
+
...(contracts ? { contracts } : {}),
|
|
798
|
+
...(snippetSlots ? { snippetSlots } : {}),
|
|
799
|
+
...(tokens ? { tokens } : {})
|
|
800
|
+
});
|
|
335
801
|
if (refusals.length > 0) throw new GenerateError(refusals);
|
|
336
802
|
|
|
337
803
|
const cache = opts.force ? { moduleHashes: {} } : readCache(cacheFile);
|
|
@@ -351,11 +817,14 @@ export function generateApp(dir, opts = {}) {
|
|
|
351
817
|
cache.moduleHashes[moduleName] = specs.hashes[moduleName];
|
|
352
818
|
}
|
|
353
819
|
|
|
354
|
-
// App-level: the wrangler config derives from all modules
|
|
355
|
-
//
|
|
356
|
-
|
|
820
|
+
// App-level: the wrangler config derives from all modules AND app.tron
|
|
821
|
+
// (name, dialect, cloudflare settings), so refresh it whenever anything
|
|
822
|
+
// re-emitted, the app spec itself changed, or it's missing entirely.
|
|
823
|
+
const appChanged = cache.appHash !== specs.hashes.app;
|
|
824
|
+
if (pending.length > 0 || appChanged || !existsSync(join(outRoot, 'wrangler.json'))) {
|
|
357
825
|
pending.push(wranglerFile(specs));
|
|
358
826
|
}
|
|
827
|
+
cache.appHash = specs.hashes.app;
|
|
359
828
|
|
|
360
829
|
// App-level: the live SSE route exists iff any query is live.
|
|
361
830
|
if (
|
|
@@ -365,9 +834,15 @@ export function generateApp(dir, opts = {}) {
|
|
|
365
834
|
pending.push(liveRouteFile());
|
|
366
835
|
}
|
|
367
836
|
|
|
837
|
+
// App-level: token overrides from `app.settings.tokens` (U-10).
|
|
838
|
+
const overrides = tokenOverrides(specs);
|
|
839
|
+
if (overrides && (pending.length > 0 || !existsSync(join(outRoot, 'routes', 'tokens.css')))) {
|
|
840
|
+
pending.push(tokensFile(overrides));
|
|
841
|
+
}
|
|
842
|
+
|
|
368
843
|
// App-level: a root layout so generated routes render inside a shell.
|
|
369
844
|
if (pending.length > 0 || !existsSync(join(outRoot, 'routes', '+layout.svelte'))) {
|
|
370
|
-
pending.push(layoutFile(existsSync(join(appRoot, 'src', 'app.css'))));
|
|
845
|
+
pending.push(layoutFile(specs, existsSync(join(appRoot, 'src', 'app.css')), Boolean(overrides)));
|
|
371
846
|
}
|
|
372
847
|
|
|
373
848
|
const failures = selfCheck(pending);
|
|
@@ -393,5 +868,7 @@ export function generateApp(dir, opts = {}) {
|
|
|
393
868
|
mkdirSync(dirname(cacheFile), { recursive: true });
|
|
394
869
|
writeFileSync(cacheFile, JSON.stringify(cache, null, '\t') + '\n');
|
|
395
870
|
|
|
871
|
+
emitFlow(specs, { force: opts.force });
|
|
872
|
+
|
|
396
873
|
return { version: specs.version, written, skipped, refusals: [] };
|
|
397
874
|
}
|
package/src/kernel/index.js
CHANGED
|
@@ -49,7 +49,8 @@ export {
|
|
|
49
49
|
} from './absorb.js';
|
|
50
50
|
export { inferKind, inferCapabilities, inferAuth, adoptUnit, adoptFiles } from './adopt.js';
|
|
51
51
|
export { loadSpecs, validateSpecs } from './validate.js';
|
|
52
|
-
export { generateApp, checkGenerate, checkBindings, layoutFile, liveRouteFile, selfCheck, GenerateError, EMITTERS } from './generate.js';
|
|
52
|
+
export { generateApp, checkGenerate, checkBindings, checkServiceSecrets, checkNetworkInBodies, checkTokenOverrides, tokenOverrides, tokensFile, layoutFile, liveRouteFile, selfCheck, GenerateError, EMITTERS } from './generate.js';
|
|
53
|
+
export { indexBody, unitFlow, buildModuleFlow, emitFlow, flowApp, flowDelta } from './flow.js';
|
|
53
54
|
export { wranglerConfig, wranglerFile } from './emit-wrangler.js';
|
|
54
55
|
export { emitModuleMachines, machinesEmitter } from './emit-machines.js';
|
|
55
56
|
export { migrateApp } from './migrate.js';
|
|
@@ -59,13 +60,19 @@ export {
|
|
|
59
60
|
emitModulePolicies,
|
|
60
61
|
emitModuleQueries,
|
|
61
62
|
emitModuleActions,
|
|
63
|
+
emitModuleServices,
|
|
64
|
+
emitModuleJobs,
|
|
62
65
|
emitModuleTriggers,
|
|
63
66
|
emitModulePages,
|
|
64
67
|
emitModuleRemotes,
|
|
68
|
+
emitModuleEndpoints,
|
|
65
69
|
policiesEmitter,
|
|
66
70
|
queriesEmitter,
|
|
67
71
|
actionsEmitter,
|
|
72
|
+
servicesEmitter,
|
|
73
|
+
jobsEmitter,
|
|
68
74
|
triggersEmitter,
|
|
69
75
|
pagesEmitter,
|
|
70
|
-
remotesEmitter
|
|
76
|
+
remotesEmitter,
|
|
77
|
+
endpointsEmitter
|
|
71
78
|
} from './emit-units.js';
|