@aedis/cli 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +20 -2
- package/dist/aedis.js +1833 -0
- package/package.json +50 -4
package/dist/aedis.js
ADDED
|
@@ -0,0 +1,1833 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { verify } from "sigstore";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { gunzipSync } from "node:zlib";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
//#region ../../apps/www/lib/untrusted.ts
|
|
10
|
+
/**
|
|
11
|
+
* Invisible code points that carry no visible text but can hide instructions from the person reading while an
|
|
12
|
+
* agent still reads them (ASCII smuggling with Unicode tags, zero-width text, bidi overrides that reorder what is
|
|
13
|
+
* shown). Zero-width joiner/non-joiner and the LRM/RLM marks stay: emoji sequences and Persian, Arabic and Indic
|
|
14
|
+
* scripts need them.
|
|
15
|
+
*/
|
|
16
|
+
const INVISIBLE = /[\u200B\u2060-\u2064\uFEFF\u202A-\u202E\u2066-\u2069\u{E0000}-\u{E007F}]/gu;
|
|
17
|
+
/** Removes hidden code points so what the person sees is what the agent gets, and the other way round. */
|
|
18
|
+
function stripInvisible(text) {
|
|
19
|
+
return text.replace(INVISIBLE, "");
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region ../../apps/www/registry/aedis/lib/contrast.ts
|
|
23
|
+
const NUM = String.raw`(\d+(?:\.\d+)?|\.\d+)`;
|
|
24
|
+
const HEX$1 = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
25
|
+
const RGBA = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(0|1|0?\.\d+)\s*)?\)$/i;
|
|
26
|
+
const OKLCH = new RegExp(String.raw`^oklch\(\s*${NUM}(%?)\s+${NUM}\s+${NUM}(?:deg)?\s*(?:\/\s*${NUM}(%?)\s*)?\)$`, "i");
|
|
27
|
+
/** Reads oklch(L C H [/ A]) with L in 0–1 or 0–100 %, C in 0–0.5, H in 0–360. Null when malformed or out of range. */
|
|
28
|
+
function parseOklch(value) {
|
|
29
|
+
const m = OKLCH.exec(value);
|
|
30
|
+
if (!m) return null;
|
|
31
|
+
const l = m[2] ? Number(m[1]) / 100 : Number(m[1]);
|
|
32
|
+
const alpha = m[5] === void 0 ? 1 : m[6] ? Number(m[5]) / 100 : Number(m[5]);
|
|
33
|
+
const c = Number(m[3]);
|
|
34
|
+
const h = Number(m[4]);
|
|
35
|
+
if (l > 1 || c > .5 || h > 360 || alpha > 1) return null;
|
|
36
|
+
return {
|
|
37
|
+
l,
|
|
38
|
+
c,
|
|
39
|
+
h,
|
|
40
|
+
alpha
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** OKLCH to sRGB (0–255, clamped to the gamut). */
|
|
44
|
+
function oklchToRgba({ l, c, h, alpha }) {
|
|
45
|
+
const a = c * Math.cos(h * Math.PI / 180);
|
|
46
|
+
const b = c * Math.sin(h * Math.PI / 180);
|
|
47
|
+
const l_ = (l + .3963377774 * a + .2158037573 * b) ** 3;
|
|
48
|
+
const m_ = (l - .1055613458 * a - .0638541728 * b) ** 3;
|
|
49
|
+
const s_ = (l - .0894841775 * a - 1.291485548 * b) ** 3;
|
|
50
|
+
const linear = [
|
|
51
|
+
4.0767416621 * l_ - 3.3077115913 * m_ + .2309699292 * s_,
|
|
52
|
+
-1.2684380046 * l_ + 2.6097574011 * m_ - .3413193965 * s_,
|
|
53
|
+
-.0041960863 * l_ - .7034186147 * m_ + 1.707614701 * s_
|
|
54
|
+
];
|
|
55
|
+
const gamma = (x) => x <= .0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - .055;
|
|
56
|
+
const [r, g, bl] = linear.map((x) => Math.round(Math.min(1, Math.max(0, gamma(x))) * 255));
|
|
57
|
+
return [
|
|
58
|
+
r,
|
|
59
|
+
g,
|
|
60
|
+
bl,
|
|
61
|
+
alpha
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
/** Reads #rgb, #rrggbb, #rrggbbaa, rgb()/rgba() and oklch(). Throws on anything else. */
|
|
65
|
+
function parseColor(value) {
|
|
66
|
+
const hex = HEX$1.exec(value);
|
|
67
|
+
if (hex) {
|
|
68
|
+
const h = hex[1].length === 3 ? [...hex[1]].map((c) => c + c).join("") : hex[1];
|
|
69
|
+
const [r, g, b, a = 255] = [
|
|
70
|
+
0,
|
|
71
|
+
2,
|
|
72
|
+
4,
|
|
73
|
+
6
|
|
74
|
+
].filter((i) => i < h.length).map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
75
|
+
return [
|
|
76
|
+
r,
|
|
77
|
+
g,
|
|
78
|
+
b,
|
|
79
|
+
a / 255
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
const rgba = RGBA.exec(value);
|
|
83
|
+
if (rgba && rgba.slice(1, 4).every((c) => Number(c) <= 255)) return [
|
|
84
|
+
Number(rgba[1]),
|
|
85
|
+
Number(rgba[2]),
|
|
86
|
+
Number(rgba[3]),
|
|
87
|
+
rgba[4] === void 0 ? 1 : Number(rgba[4])
|
|
88
|
+
];
|
|
89
|
+
const oklch = parseOklch(value);
|
|
90
|
+
if (oklch) return oklchToRgba(oklch);
|
|
91
|
+
throw new Error(`Unsupported color: ${value}`);
|
|
92
|
+
}
|
|
93
|
+
const over = ([r, g, b, a], [br, bg, bb]) => [
|
|
94
|
+
r * a + br * (1 - a),
|
|
95
|
+
g * a + bg * (1 - a),
|
|
96
|
+
b * a + bb * (1 - a),
|
|
97
|
+
1
|
|
98
|
+
];
|
|
99
|
+
const luminance = ([r, g, b]) => {
|
|
100
|
+
const lin = (c) => (c /= 255) <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4;
|
|
101
|
+
return .2126 * lin(r) + .7152 * lin(g) + .0722 * lin(b);
|
|
102
|
+
};
|
|
103
|
+
/** WCAG contrast ratio. Translucent colors are composited over `page` (white by default). */
|
|
104
|
+
function contrast(foreground, background, page = "#ffffff") {
|
|
105
|
+
const bg = over(parseColor(background), parseColor(page));
|
|
106
|
+
const fg = over(parseColor(foreground), bg);
|
|
107
|
+
const [hi, lo] = [luminance(fg), luminance(bg)].sort((a, b) => b - a);
|
|
108
|
+
return (hi + .05) / (lo + .05);
|
|
109
|
+
}
|
|
110
|
+
/** Euclidean distance in sRGB, 0–1: how far apart two opaque colors look at a glance. */
|
|
111
|
+
function colorDistance(a, b) {
|
|
112
|
+
const [x, y] = [parseColor(a), parseColor(b)];
|
|
113
|
+
return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]) / (255 * Math.sqrt(3));
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region ../../apps/www/registry/aedis/lib/tokens.ts
|
|
117
|
+
/** Every color has a role, not a hue. The CSS variable is `--<name>` and the Tailwind color is `<name>`. */
|
|
118
|
+
const colors = {
|
|
119
|
+
background: {
|
|
120
|
+
light: "#f6f7f9",
|
|
121
|
+
dark: "#0d1015",
|
|
122
|
+
brand: "#f5f1e8"
|
|
123
|
+
},
|
|
124
|
+
surface: {
|
|
125
|
+
light: "#ffffff",
|
|
126
|
+
dark: "#161a21",
|
|
127
|
+
brand: "#fbf8f1"
|
|
128
|
+
},
|
|
129
|
+
"surface-raised": {
|
|
130
|
+
light: "#ffffff",
|
|
131
|
+
dark: "#1d222b",
|
|
132
|
+
brand: "#ffffff"
|
|
133
|
+
},
|
|
134
|
+
muted: {
|
|
135
|
+
light: "#eef0f4",
|
|
136
|
+
dark: "#232833",
|
|
137
|
+
brand: "#ece5d5"
|
|
138
|
+
},
|
|
139
|
+
foreground: {
|
|
140
|
+
light: "#161b26",
|
|
141
|
+
dark: "#eceff4",
|
|
142
|
+
brand: "#1a2740"
|
|
143
|
+
},
|
|
144
|
+
"muted-foreground": {
|
|
145
|
+
light: "#5c6475",
|
|
146
|
+
dark: "#9ba4b4",
|
|
147
|
+
brand: "#5a6276"
|
|
148
|
+
},
|
|
149
|
+
border: {
|
|
150
|
+
light: "#dce0e7",
|
|
151
|
+
dark: "#2c323d",
|
|
152
|
+
brand: "#ddd4c2"
|
|
153
|
+
},
|
|
154
|
+
"border-control": {
|
|
155
|
+
light: "#8a92a3",
|
|
156
|
+
dark: "#646d80",
|
|
157
|
+
brand: "#8a8270"
|
|
158
|
+
},
|
|
159
|
+
hairline: {
|
|
160
|
+
light: "rgba(22,27,38,0.08)",
|
|
161
|
+
dark: "rgba(255,255,255,0.08)",
|
|
162
|
+
brand: "rgba(26,39,64,0.10)"
|
|
163
|
+
},
|
|
164
|
+
primary: {
|
|
165
|
+
light: "#2457d6",
|
|
166
|
+
dark: "#3565e8",
|
|
167
|
+
brand: "#1a2740"
|
|
168
|
+
},
|
|
169
|
+
"primary-foreground": {
|
|
170
|
+
light: "#ffffff",
|
|
171
|
+
dark: "#ffffff",
|
|
172
|
+
brand: "#ffffff"
|
|
173
|
+
},
|
|
174
|
+
secondary: {
|
|
175
|
+
light: "#ffffff",
|
|
176
|
+
dark: "#161a21",
|
|
177
|
+
brand: "#fbf8f1"
|
|
178
|
+
},
|
|
179
|
+
"secondary-foreground": {
|
|
180
|
+
light: "#161b26",
|
|
181
|
+
dark: "#eceff4",
|
|
182
|
+
brand: "#1a2740"
|
|
183
|
+
},
|
|
184
|
+
link: {
|
|
185
|
+
light: "#2457d6",
|
|
186
|
+
dark: "#7aa2ff",
|
|
187
|
+
brand: "#1a2740"
|
|
188
|
+
},
|
|
189
|
+
accent: {
|
|
190
|
+
light: "#2457d6",
|
|
191
|
+
dark: "#7aa2ff",
|
|
192
|
+
brand: "#c8a24c"
|
|
193
|
+
},
|
|
194
|
+
"accent-foreground": {
|
|
195
|
+
light: "#ffffff",
|
|
196
|
+
dark: "#0d1015",
|
|
197
|
+
brand: "#1a2740"
|
|
198
|
+
},
|
|
199
|
+
"accent-soft": {
|
|
200
|
+
light: "#e8eefc",
|
|
201
|
+
dark: "#1b2742",
|
|
202
|
+
brand: "#f3ebd6"
|
|
203
|
+
},
|
|
204
|
+
ring: {
|
|
205
|
+
light: "rgba(36,87,214,0.45)",
|
|
206
|
+
dark: "rgba(122,162,255,0.50)",
|
|
207
|
+
brand: "rgba(26,39,64,0.45)"
|
|
208
|
+
},
|
|
209
|
+
success: {
|
|
210
|
+
light: "#18794e",
|
|
211
|
+
dark: "#3dd68c",
|
|
212
|
+
brand: "#18794e"
|
|
213
|
+
},
|
|
214
|
+
warning: {
|
|
215
|
+
light: "#a35200",
|
|
216
|
+
dark: "#f5a524",
|
|
217
|
+
brand: "#a35200"
|
|
218
|
+
},
|
|
219
|
+
destructive: {
|
|
220
|
+
light: "#c9302c",
|
|
221
|
+
dark: "#ff6369",
|
|
222
|
+
brand: "#c9302c"
|
|
223
|
+
},
|
|
224
|
+
"destructive-solid": {
|
|
225
|
+
light: "#c9302c",
|
|
226
|
+
dark: "#c93a3f",
|
|
227
|
+
brand: "#c9302c"
|
|
228
|
+
},
|
|
229
|
+
"destructive-foreground": {
|
|
230
|
+
light: "#ffffff",
|
|
231
|
+
dark: "#ffffff",
|
|
232
|
+
brand: "#ffffff"
|
|
233
|
+
},
|
|
234
|
+
"material-thin": {
|
|
235
|
+
light: "rgba(255,255,255,0.55)",
|
|
236
|
+
dark: "rgba(22,26,33,0.55)",
|
|
237
|
+
brand: "rgba(251,248,241,0.55)"
|
|
238
|
+
},
|
|
239
|
+
"material-regular": {
|
|
240
|
+
light: "rgba(255,255,255,0.72)",
|
|
241
|
+
dark: "rgba(22,26,33,0.72)",
|
|
242
|
+
brand: "rgba(251,248,241,0.72)"
|
|
243
|
+
},
|
|
244
|
+
"material-thick": {
|
|
245
|
+
light: "rgba(255,255,255,0.86)",
|
|
246
|
+
dark: "rgba(22,26,33,0.86)",
|
|
247
|
+
brand: "rgba(251,248,241,0.86)"
|
|
248
|
+
},
|
|
249
|
+
"material-edge": {
|
|
250
|
+
light: "rgba(255,255,255,0.60)",
|
|
251
|
+
dark: "rgba(255,255,255,0.08)",
|
|
252
|
+
brand: "rgba(255,255,255,0.60)"
|
|
253
|
+
},
|
|
254
|
+
scrim: {
|
|
255
|
+
light: "rgba(13,16,21,0.32)",
|
|
256
|
+
dark: "rgba(0,0,0,0.55)",
|
|
257
|
+
brand: "rgba(26,39,64,0.32)"
|
|
258
|
+
},
|
|
259
|
+
"chart-1": {
|
|
260
|
+
light: "#2457d6",
|
|
261
|
+
dark: "#7aa2ff",
|
|
262
|
+
brand: "#1a2740"
|
|
263
|
+
},
|
|
264
|
+
"chart-2": {
|
|
265
|
+
light: "#0f8577",
|
|
266
|
+
dark: "#3dd6c4",
|
|
267
|
+
brand: "#8a6a1f"
|
|
268
|
+
},
|
|
269
|
+
"chart-3": {
|
|
270
|
+
light: "#b25e09",
|
|
271
|
+
dark: "#f5a524",
|
|
272
|
+
brand: "#2f6f73"
|
|
273
|
+
},
|
|
274
|
+
"chart-4": {
|
|
275
|
+
light: "#8e4ec6",
|
|
276
|
+
dark: "#c79bff",
|
|
277
|
+
brand: "#a1452b"
|
|
278
|
+
},
|
|
279
|
+
"chart-5": {
|
|
280
|
+
light: "#d1366b",
|
|
281
|
+
dark: "#ff7aa8",
|
|
282
|
+
brand: "#6b4c9a"
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
/** Two layers: one short and defined, one long and soft. Tailwind: `shadow-<name>`. */
|
|
286
|
+
const shadows = {
|
|
287
|
+
"elevation-1": {
|
|
288
|
+
light: "0 1px 1px rgba(16,24,40,0.04), 0 1px 2px rgba(16,24,40,0.06)",
|
|
289
|
+
dark: "0 1px 2px rgba(0,0,0,0.40)",
|
|
290
|
+
brand: "0 1px 1px rgba(26,39,64,0.05), 0 1px 2px rgba(26,39,64,0.07)"
|
|
291
|
+
},
|
|
292
|
+
"elevation-2": {
|
|
293
|
+
light: "0 2px 4px rgba(16,24,40,0.06), 0 4px 12px rgba(16,24,40,0.08)",
|
|
294
|
+
dark: "0 2px 6px rgba(0,0,0,0.45), 0 6px 16px rgba(0,0,0,0.35)",
|
|
295
|
+
brand: "0 2px 4px rgba(26,39,64,0.06), 0 4px 12px rgba(26,39,64,0.09)"
|
|
296
|
+
},
|
|
297
|
+
"elevation-3": {
|
|
298
|
+
light: "0 4px 8px rgba(16,24,40,0.06), 0 12px 28px rgba(16,24,40,0.12)",
|
|
299
|
+
dark: "0 4px 10px rgba(0,0,0,0.5), 0 14px 32px rgba(0,0,0,0.45)",
|
|
300
|
+
brand: "0 4px 8px rgba(26,39,64,0.07), 0 12px 28px rgba(26,39,64,0.13)"
|
|
301
|
+
},
|
|
302
|
+
"elevation-4": {
|
|
303
|
+
light: "0 8px 16px rgba(16,24,40,0.08), 0 24px 56px rgba(16,24,40,0.18)",
|
|
304
|
+
dark: "0 8px 20px rgba(0,0,0,0.55), 0 28px 64px rgba(0,0,0,0.55)",
|
|
305
|
+
brand: "0 8px 16px rgba(26,39,64,0.09), 0 24px 56px rgba(26,39,64,0.19)"
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
const fonts = {
|
|
309
|
+
sans: "-apple-system, BlinkMacSystemFont, \"Segoe UI Variable Display\", \"Segoe UI\", system-ui, sans-serif",
|
|
310
|
+
mono: "ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, Consolas, monospace"
|
|
311
|
+
};
|
|
312
|
+
/** Tracking tightens and leading drops as size grows. Tailwind: `text-<name>`. */
|
|
313
|
+
const typeScale = {
|
|
314
|
+
display: {
|
|
315
|
+
size: "3rem",
|
|
316
|
+
lineHeight: 1.05,
|
|
317
|
+
tracking: "-0.025em",
|
|
318
|
+
weight: 700
|
|
319
|
+
},
|
|
320
|
+
"title-1": {
|
|
321
|
+
size: "2.125rem",
|
|
322
|
+
lineHeight: 1.12,
|
|
323
|
+
tracking: "-0.02em",
|
|
324
|
+
weight: 700
|
|
325
|
+
},
|
|
326
|
+
"title-2": {
|
|
327
|
+
size: "1.625rem",
|
|
328
|
+
lineHeight: 1.18,
|
|
329
|
+
tracking: "-0.015em",
|
|
330
|
+
weight: 600
|
|
331
|
+
},
|
|
332
|
+
"title-3": {
|
|
333
|
+
size: "1.25rem",
|
|
334
|
+
lineHeight: 1.25,
|
|
335
|
+
tracking: "-0.01em",
|
|
336
|
+
weight: 600
|
|
337
|
+
},
|
|
338
|
+
headline: {
|
|
339
|
+
size: "1.0625rem",
|
|
340
|
+
lineHeight: 1.35,
|
|
341
|
+
tracking: "-0.005em",
|
|
342
|
+
weight: 600
|
|
343
|
+
},
|
|
344
|
+
body: {
|
|
345
|
+
size: "1rem",
|
|
346
|
+
lineHeight: 1.5,
|
|
347
|
+
tracking: "0em",
|
|
348
|
+
weight: 400
|
|
349
|
+
},
|
|
350
|
+
callout: {
|
|
351
|
+
size: "0.9375rem",
|
|
352
|
+
lineHeight: 1.45,
|
|
353
|
+
tracking: "0em",
|
|
354
|
+
weight: 400
|
|
355
|
+
},
|
|
356
|
+
footnote: {
|
|
357
|
+
size: "0.8125rem",
|
|
358
|
+
lineHeight: 1.4,
|
|
359
|
+
tracking: "0.005em",
|
|
360
|
+
weight: 400
|
|
361
|
+
},
|
|
362
|
+
caption: {
|
|
363
|
+
size: "0.75rem",
|
|
364
|
+
lineHeight: 1.35,
|
|
365
|
+
tracking: "0.01em",
|
|
366
|
+
weight: 500
|
|
367
|
+
},
|
|
368
|
+
code: {
|
|
369
|
+
size: "0.875rem",
|
|
370
|
+
lineHeight: 1.6,
|
|
371
|
+
tracking: "0em",
|
|
372
|
+
weight: 400
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
/** Concentric radii: the inner radius is the outer one minus the padding between them. Tailwind: `rounded-<name>`. */
|
|
376
|
+
const radii = {
|
|
377
|
+
xs: "6px",
|
|
378
|
+
sm: "8px",
|
|
379
|
+
md: "12px",
|
|
380
|
+
lg: "16px",
|
|
381
|
+
xl: "22px"
|
|
382
|
+
};
|
|
383
|
+
/** backdrop-filter radius of each material. */
|
|
384
|
+
const blurs = {
|
|
385
|
+
thin: "12px",
|
|
386
|
+
regular: "20px",
|
|
387
|
+
thick: "28px",
|
|
388
|
+
materialize: "6px"
|
|
389
|
+
};
|
|
390
|
+
/** Long, soft entrances; short exits. */
|
|
391
|
+
const durations = {
|
|
392
|
+
press: "140ms",
|
|
393
|
+
release: "520ms",
|
|
394
|
+
fast: "200ms",
|
|
395
|
+
base: "380ms",
|
|
396
|
+
spring: "560ms",
|
|
397
|
+
sheet: "700ms",
|
|
398
|
+
stagger: "50ms",
|
|
399
|
+
enter: "900ms",
|
|
400
|
+
"enter-stagger": "90ms"
|
|
401
|
+
};
|
|
402
|
+
/** [foreground, background, minimum ratio] every theme must pass: text 4.5:1, control outlines 3:1 (WCAG 1.4.3, 1.4.11). */
|
|
403
|
+
const contrastPairs = [
|
|
404
|
+
...[
|
|
405
|
+
"background",
|
|
406
|
+
"surface",
|
|
407
|
+
"surface-raised",
|
|
408
|
+
"muted"
|
|
409
|
+
].flatMap((bg) => [[
|
|
410
|
+
"foreground",
|
|
411
|
+
bg,
|
|
412
|
+
4.5
|
|
413
|
+
], [
|
|
414
|
+
"muted-foreground",
|
|
415
|
+
bg,
|
|
416
|
+
4.5
|
|
417
|
+
]]),
|
|
418
|
+
[
|
|
419
|
+
"primary-foreground",
|
|
420
|
+
"primary",
|
|
421
|
+
4.5
|
|
422
|
+
],
|
|
423
|
+
[
|
|
424
|
+
"secondary-foreground",
|
|
425
|
+
"secondary",
|
|
426
|
+
4.5
|
|
427
|
+
],
|
|
428
|
+
[
|
|
429
|
+
"accent-foreground",
|
|
430
|
+
"accent",
|
|
431
|
+
4.5
|
|
432
|
+
],
|
|
433
|
+
[
|
|
434
|
+
"foreground",
|
|
435
|
+
"accent-soft",
|
|
436
|
+
4.5
|
|
437
|
+
],
|
|
438
|
+
[
|
|
439
|
+
"destructive-foreground",
|
|
440
|
+
"destructive-solid",
|
|
441
|
+
4.5
|
|
442
|
+
],
|
|
443
|
+
[
|
|
444
|
+
"link",
|
|
445
|
+
"surface",
|
|
446
|
+
4.5
|
|
447
|
+
],
|
|
448
|
+
[
|
|
449
|
+
"link",
|
|
450
|
+
"background",
|
|
451
|
+
4.5
|
|
452
|
+
],
|
|
453
|
+
[
|
|
454
|
+
"destructive",
|
|
455
|
+
"surface",
|
|
456
|
+
4.5
|
|
457
|
+
],
|
|
458
|
+
[
|
|
459
|
+
"success",
|
|
460
|
+
"surface",
|
|
461
|
+
4.5
|
|
462
|
+
],
|
|
463
|
+
[
|
|
464
|
+
"warning",
|
|
465
|
+
"surface",
|
|
466
|
+
4.5
|
|
467
|
+
],
|
|
468
|
+
[
|
|
469
|
+
"border-control",
|
|
470
|
+
"surface",
|
|
471
|
+
3
|
|
472
|
+
],
|
|
473
|
+
[
|
|
474
|
+
"foreground",
|
|
475
|
+
"material-thick",
|
|
476
|
+
4.5
|
|
477
|
+
],
|
|
478
|
+
...[
|
|
479
|
+
"chart-1",
|
|
480
|
+
"chart-2",
|
|
481
|
+
"chart-3",
|
|
482
|
+
"chart-4",
|
|
483
|
+
"chart-5"
|
|
484
|
+
].flatMap((c) => [[
|
|
485
|
+
c,
|
|
486
|
+
"surface",
|
|
487
|
+
3
|
|
488
|
+
], [
|
|
489
|
+
c,
|
|
490
|
+
"background",
|
|
491
|
+
3
|
|
492
|
+
]])
|
|
493
|
+
];
|
|
494
|
+
//#endregion
|
|
495
|
+
//#region ../../apps/www/registry/aedis/lib/custom-theme.ts
|
|
496
|
+
/**
|
|
497
|
+
* base: body size in px, every size follows it. scale: how far headings sit above body (1 is the default scale).
|
|
498
|
+
* leading: body line height. Bounds keep body at 14 px or more and no text under 12 px.
|
|
499
|
+
*/
|
|
500
|
+
const typeRanges = {
|
|
501
|
+
base: [14, 18],
|
|
502
|
+
scale: [.8, 1.2],
|
|
503
|
+
leading: [1.3, 1.7]
|
|
504
|
+
};
|
|
505
|
+
const MIN_TEXT_PX = 12;
|
|
506
|
+
/** System fonts only: nothing is downloaded, so a theme cannot use a font request as a tracking channel. */
|
|
507
|
+
const themeFonts = {
|
|
508
|
+
system: fonts.sans,
|
|
509
|
+
rounded: `ui-rounded, "SF Pro Rounded", ${fonts.sans}`,
|
|
510
|
+
serif: "ui-serif, \"New York\", Georgia, \"Times New Roman\", serif"
|
|
511
|
+
};
|
|
512
|
+
/** A stored or shared theme larger than this is refused before parsing. */
|
|
513
|
+
const MAX_THEME_BYTES = 16384;
|
|
514
|
+
const modes = ["light", "dark"];
|
|
515
|
+
const blurNames = [
|
|
516
|
+
"thin",
|
|
517
|
+
"regular",
|
|
518
|
+
"thick"
|
|
519
|
+
];
|
|
520
|
+
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
521
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
522
|
+
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
523
|
+
const num = (n) => String(Number(n.toFixed(4)));
|
|
524
|
+
/** Hex or oklch() written back in canonical form; null for anything else. */
|
|
525
|
+
function normalizeColor(value) {
|
|
526
|
+
if (typeof value !== "string" || value.length > 64) return null;
|
|
527
|
+
const v = value.trim();
|
|
528
|
+
if (HEX.test(v)) {
|
|
529
|
+
const h = v.slice(1).toLowerCase();
|
|
530
|
+
return `#${h.length === 3 ? [...h].map((c) => c + c).join("") : h}`;
|
|
531
|
+
}
|
|
532
|
+
const o = parseOklch(v);
|
|
533
|
+
if (!o) return null;
|
|
534
|
+
return `oklch(${num(o.l)} ${num(o.c)} ${num(o.h)}${o.alpha === 1 ? "" : ` / ${num(o.alpha)}`})`;
|
|
535
|
+
}
|
|
536
|
+
function numberIn(value, min, max) {
|
|
537
|
+
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max ? Number(value.toFixed(2)) : null;
|
|
538
|
+
}
|
|
539
|
+
function unknownKeys(o, allowed, path) {
|
|
540
|
+
return Object.keys(o).filter((k) => !allowed.includes(k)).map((k) => `${path}${k} is not part of the theme schema`);
|
|
541
|
+
}
|
|
542
|
+
/** Validates untrusted input (a parsed JSON value) and returns a normalized theme, or every reason it is refused. */
|
|
543
|
+
function validateTheme(input) {
|
|
544
|
+
if (!isRecord(input)) return {
|
|
545
|
+
ok: false,
|
|
546
|
+
errors: ["theme must be an object"]
|
|
547
|
+
};
|
|
548
|
+
const errors = unknownKeys(input, [
|
|
549
|
+
"version",
|
|
550
|
+
"name",
|
|
551
|
+
"colors",
|
|
552
|
+
"radius",
|
|
553
|
+
"blur",
|
|
554
|
+
"shadow",
|
|
555
|
+
"motion",
|
|
556
|
+
"font",
|
|
557
|
+
"type"
|
|
558
|
+
], "");
|
|
559
|
+
if (input.version !== 1) errors.push("version must be 1");
|
|
560
|
+
const name = typeof input.name === "string" ? stripInvisible(input.name).trim() : "";
|
|
561
|
+
if (!name || name.length > 64) errors.push(`name must be 1–64 characters`);
|
|
562
|
+
const theme = {
|
|
563
|
+
version: 1,
|
|
564
|
+
name,
|
|
565
|
+
colors: {
|
|
566
|
+
light: {},
|
|
567
|
+
dark: {}
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
if (!isRecord(input.colors)) errors.push("colors must be an object with light and dark");
|
|
571
|
+
else {
|
|
572
|
+
errors.push(...unknownKeys(input.colors, modes, "colors."));
|
|
573
|
+
for (const mode of modes) {
|
|
574
|
+
const given = input.colors[mode] ?? {};
|
|
575
|
+
if (!isRecord(given)) {
|
|
576
|
+
errors.push(`colors.${mode} must be an object`);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
for (const [token, value] of Object.entries(given)) if (!has(colors, token)) errors.push(`colors.${mode}.${token} is not a theme color`);
|
|
580
|
+
else {
|
|
581
|
+
const color = normalizeColor(value);
|
|
582
|
+
if (color) theme.colors[mode][token] = color;
|
|
583
|
+
else errors.push(`colors.${mode}.${token} must be a hex color or oklch()`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
const ranged = (key, names, max) => {
|
|
588
|
+
const given = input[key];
|
|
589
|
+
if (given === void 0) return void 0;
|
|
590
|
+
if (!isRecord(given)) {
|
|
591
|
+
errors.push(`${key} must be an object`);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
errors.push(...unknownKeys(given, names, `${key}.`));
|
|
595
|
+
const out = {};
|
|
596
|
+
for (const n of names) {
|
|
597
|
+
if (given[n] === void 0) continue;
|
|
598
|
+
const v = numberIn(given[n], 0, max);
|
|
599
|
+
if (v === null) errors.push(`${key}.${n} must be a number from 0 to ${max}`);
|
|
600
|
+
else out[n] = v;
|
|
601
|
+
}
|
|
602
|
+
return out;
|
|
603
|
+
};
|
|
604
|
+
const radius = ranged("radius", Object.keys(radii), 32);
|
|
605
|
+
if (radius) theme.radius = radius;
|
|
606
|
+
const blur = ranged("blur", blurNames, 40);
|
|
607
|
+
if (blur) theme.blur = blur;
|
|
608
|
+
if (input.shadow !== void 0) {
|
|
609
|
+
const v = numberIn(input.shadow, 0, 2);
|
|
610
|
+
if (v === null) errors.push("shadow must be a number from 0 to 2");
|
|
611
|
+
else theme.shadow = v;
|
|
612
|
+
}
|
|
613
|
+
if (input.motion !== void 0) {
|
|
614
|
+
const v = numberIn(input.motion, 0, 1.5);
|
|
615
|
+
if (v === null) errors.push("motion must be a number from 0 to 1.5");
|
|
616
|
+
else theme.motion = v;
|
|
617
|
+
}
|
|
618
|
+
if (input.font !== void 0) {
|
|
619
|
+
if (typeof input.font === "string" && has(themeFonts, input.font)) theme.font = input.font;
|
|
620
|
+
else errors.push(`font must be one of ${Object.keys(themeFonts).join(", ")}`);
|
|
621
|
+
}
|
|
622
|
+
if (input.type !== void 0) {
|
|
623
|
+
if (!isRecord(input.type)) errors.push("type must be an object");
|
|
624
|
+
else {
|
|
625
|
+
errors.push(...unknownKeys(input.type, Object.keys(typeRanges), "type."));
|
|
626
|
+
const type = {};
|
|
627
|
+
for (const [k, [min, max]] of Object.entries(typeRanges)) {
|
|
628
|
+
if (input.type[k] === void 0) continue;
|
|
629
|
+
const v = numberIn(input.type[k], min, max);
|
|
630
|
+
if (v === null) errors.push(`type.${k} must be a number from ${min} to ${max}`);
|
|
631
|
+
else type[k] = v;
|
|
632
|
+
}
|
|
633
|
+
theme.type = type;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (errors.length === 0) errors.push(...readability(theme));
|
|
637
|
+
return errors.length ? {
|
|
638
|
+
ok: false,
|
|
639
|
+
errors
|
|
640
|
+
} : {
|
|
641
|
+
ok: true,
|
|
642
|
+
theme
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
/** Contrast AA for every pair, and a destructive action that never looks like the primary one. */
|
|
646
|
+
function readability(theme) {
|
|
647
|
+
return modes.flatMap((mode) => {
|
|
648
|
+
const c = resolvedColors(theme, mode);
|
|
649
|
+
const problems = contrastPairs.flatMap(([fg, bg, min]) => {
|
|
650
|
+
const ratio = contrast(c[fg], c[bg], c.background);
|
|
651
|
+
return ratio < min ? [`${mode}: ${fg} on ${bg} is ${ratio.toFixed(2)}:1, needs ${min}:1`] : [];
|
|
652
|
+
});
|
|
653
|
+
if (colorDistance(c["destructive-solid"], c.primary) < .15) problems.push(`${mode}: destructive-solid is too close to primary`);
|
|
654
|
+
return problems;
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
/** Parses a stored or shared theme (localStorage, URL, file): size limit, JSON, then full validation. */
|
|
658
|
+
function parseThemeJson(text) {
|
|
659
|
+
if (new TextEncoder().encode(text).length > 16384) return {
|
|
660
|
+
ok: false,
|
|
661
|
+
errors: [`theme is larger than ${MAX_THEME_BYTES} bytes`]
|
|
662
|
+
};
|
|
663
|
+
try {
|
|
664
|
+
return validateTheme(JSON.parse(text));
|
|
665
|
+
} catch {
|
|
666
|
+
return {
|
|
667
|
+
ok: false,
|
|
668
|
+
errors: ["theme is not valid JSON"]
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
function resolvedColors(theme, mode) {
|
|
673
|
+
return Object.fromEntries(Object.entries(colors).map(([k, v]) => [k, theme.colors[mode][k] ?? v[mode]]));
|
|
674
|
+
}
|
|
675
|
+
const scaleShadow = (value, factor) => value.replace(/rgba\((\d+),(\d+),(\d+),([\d.]+)\)/g, (_m, r, g, b, a) => `rgba(${r},${g},${b},${num(Math.min(1, Number(a) * factor))})`);
|
|
676
|
+
/** Every CSS variable a theme sets in one mode, including the defaults it keeps. Only schema variables. */
|
|
677
|
+
function themeVariables(theme, mode) {
|
|
678
|
+
const vars = {};
|
|
679
|
+
for (const [k, v] of Object.entries(resolvedColors(theme, mode))) vars[`--${k}`] = v;
|
|
680
|
+
for (const [k, v] of Object.entries(shadows)) vars[`--${k}`] = scaleShadow(v[mode], theme.shadow ?? 1);
|
|
681
|
+
for (const [k, v] of Object.entries(theme.radius ?? {})) vars[`--radius-${k}`] = `${v}px`;
|
|
682
|
+
for (const [k, v] of Object.entries(theme.blur ?? {})) vars[`--blur-${k}`] = `${v}px`;
|
|
683
|
+
if (theme.motion !== void 0) for (const [k, v] of Object.entries(durations)) vars[`--duration-${k}`] = `${num(parseFloat(v) * theme.motion)}ms`;
|
|
684
|
+
if (theme.font) vars["--font-sans"] = themeFonts[theme.font];
|
|
685
|
+
if (theme.type) Object.assign(vars, typeVariables(theme.type));
|
|
686
|
+
return vars;
|
|
687
|
+
}
|
|
688
|
+
/** Sizes in rem from the type settings: every size follows base, headings move by scale, nothing under 12 px. */
|
|
689
|
+
function typeVariables({ base = 16, scale = 1, leading }) {
|
|
690
|
+
const px = (rem) => parseFloat(rem) * 16;
|
|
691
|
+
const body = px(typeScale.body.size) * (base / 16);
|
|
692
|
+
const vars = {};
|
|
693
|
+
for (const [name, style] of Object.entries(typeScale)) {
|
|
694
|
+
const size = px(style.size) * (base / 16);
|
|
695
|
+
const out = size > body ? body + (size - body) * scale : size;
|
|
696
|
+
vars[`--text-${name}`] = `${num(Math.max(MIN_TEXT_PX, out) / 16)}rem`;
|
|
697
|
+
}
|
|
698
|
+
if (leading !== void 0) vars["--text-body--line-height"] = num(leading);
|
|
699
|
+
return vars;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* The theme as a stylesheet for server rendering inside `<style nonce>`: light on :root, dark under the system
|
|
703
|
+
* preference and `data-theme="dark"`. Built only from a validated theme's normalized values.
|
|
704
|
+
*/
|
|
705
|
+
function themeToCss(theme) {
|
|
706
|
+
const result = validateTheme(theme);
|
|
707
|
+
if (!result.ok) throw new Error(`Invalid theme: ${result.errors.join("; ")}`);
|
|
708
|
+
const body = (mode) => Object.entries(themeVariables(result.theme, mode)).map(([k, v]) => `${k}: ${v};`).join(" ");
|
|
709
|
+
return [
|
|
710
|
+
`:root { ${body("light")} }`,
|
|
711
|
+
`@media (prefers-color-scheme: dark) { :root:not([data-theme]) { ${body("dark")} } }`,
|
|
712
|
+
`:root[data-theme="dark"] { ${body("dark")} }`
|
|
713
|
+
].join("\n");
|
|
714
|
+
}
|
|
715
|
+
[
|
|
716
|
+
...Object.keys(colors).map((k) => `--${k}`),
|
|
717
|
+
...Object.keys(shadows).map((k) => `--${k}`),
|
|
718
|
+
...Object.keys(radii).map((k) => `--radius-${k}`),
|
|
719
|
+
...Object.keys(blurs).filter((k) => k !== "materialize").map((k) => `--blur-${k}`),
|
|
720
|
+
...Object.keys(durations).map((k) => `--duration-${k}`),
|
|
721
|
+
...Object.keys(typeScale).map((k) => `--text-${k}`)
|
|
722
|
+
];
|
|
723
|
+
//#endregion
|
|
724
|
+
//#region ../../apps/www/content/preset.ts
|
|
725
|
+
/**
|
|
726
|
+
* A Create preset: the choices of /create, as the id `aedis init --preset <id>` takes. The id names choices from fixed
|
|
727
|
+
* lists and nothing else, so a preset is data the CLI already knows how to apply: it can never carry code, a URL or a
|
|
728
|
+
* file path. The CLI decodes it, rebuilds the theme through validateTheme, prints it and asks before applying it.
|
|
729
|
+
*
|
|
730
|
+
* Format: <framework><theme><radius><motion><level>-<blocks, 2 hex>-<checksum, 8 hex>, e.g. nbms3-07-1f0c2a9e. The
|
|
731
|
+
* checksum (FNV-1a) catches an edited or mistyped id; it is not a signature, which a static page could not make.
|
|
732
|
+
*/
|
|
733
|
+
const presetChoices = {
|
|
734
|
+
framework: {
|
|
735
|
+
n: "Next.js",
|
|
736
|
+
v: "Vite"
|
|
737
|
+
},
|
|
738
|
+
theme: {
|
|
739
|
+
d: "Light and dark",
|
|
740
|
+
b: "Brand"
|
|
741
|
+
},
|
|
742
|
+
radius: {
|
|
743
|
+
s: "Small",
|
|
744
|
+
m: "Medium",
|
|
745
|
+
l: "Large"
|
|
746
|
+
},
|
|
747
|
+
motion: {
|
|
748
|
+
c: "Cinematic",
|
|
749
|
+
s: "Subtle",
|
|
750
|
+
o: "Off"
|
|
751
|
+
},
|
|
752
|
+
level: {
|
|
753
|
+
"1": "L1 Baseline",
|
|
754
|
+
"2": "L2 Hardened",
|
|
755
|
+
"3": "L3 Regulated"
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
/** Blocks a preset can add; each installs these registry items. */
|
|
759
|
+
const presetBlocks = {
|
|
760
|
+
"agent-console": {
|
|
761
|
+
label: "Agent console",
|
|
762
|
+
items: ["agent-console"]
|
|
763
|
+
},
|
|
764
|
+
"approval-inbox": {
|
|
765
|
+
label: "Approvals inbox",
|
|
766
|
+
items: ["approval-inbox"]
|
|
767
|
+
},
|
|
768
|
+
"usage-limits": {
|
|
769
|
+
label: "Usage and ceilings",
|
|
770
|
+
items: ["usage-limits"]
|
|
771
|
+
},
|
|
772
|
+
"secure-shell": {
|
|
773
|
+
label: "Secure shell",
|
|
774
|
+
items: ["secure-shell"]
|
|
775
|
+
},
|
|
776
|
+
charts: {
|
|
777
|
+
label: "Charts",
|
|
778
|
+
items: [
|
|
779
|
+
"area-chart",
|
|
780
|
+
"bar-chart",
|
|
781
|
+
"line-chart",
|
|
782
|
+
"pie-chart",
|
|
783
|
+
"radial-chart"
|
|
784
|
+
]
|
|
785
|
+
},
|
|
786
|
+
"mf-frame": {
|
|
787
|
+
label: "Micro-frontend frame",
|
|
788
|
+
items: ["mf-frame"]
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
const blockOrder = Object.keys(presetBlocks);
|
|
792
|
+
/**
|
|
793
|
+
* The levels, named as the Aedis Platform conformance levels: what the UI installs for each, and what the platform's
|
|
794
|
+
* own checks add (aedis-platform docs/conformance.md).
|
|
795
|
+
*/
|
|
796
|
+
const presetLevels = {
|
|
797
|
+
"1": {
|
|
798
|
+
items: [],
|
|
799
|
+
adds: "Security headers, bounded inputs and api() for every call (what init installs).",
|
|
800
|
+
platform: "Scorecard published, coverage ≥ 60 % lines."
|
|
801
|
+
},
|
|
802
|
+
"2": {
|
|
803
|
+
items: ["use-idle-timeout"],
|
|
804
|
+
adds: "Plus an idle timeout that signs out and revokes the session.",
|
|
805
|
+
platform: "SAST gate, Scorecard ≥ 7, coverage ≥ 75/60 % with no regression."
|
|
806
|
+
},
|
|
807
|
+
"3": {
|
|
808
|
+
items: ["use-idle-timeout", "approval-card"],
|
|
809
|
+
adds: "Plus approvals before any outbound action.",
|
|
810
|
+
platform: "DAST gate, evidence retained, coverage ≥ 85/70 %, production only by reviewed PR."
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
const ID = /^([nv])([db])([sml])([cso])([123])-([0-9a-f]{2})-([0-9a-f]{8})$/;
|
|
814
|
+
/** FNV-1a, 32 bit, as 8 hex digits. */
|
|
815
|
+
function checksum(text) {
|
|
816
|
+
let h = 2166136261;
|
|
817
|
+
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619) >>> 0;
|
|
818
|
+
return h.toString(16).padStart(8, "0");
|
|
819
|
+
}
|
|
820
|
+
function encodePreset(p) {
|
|
821
|
+
const mask = p.blocks.reduce((m, b) => m | 1 << blockOrder.indexOf(b), 0);
|
|
822
|
+
const code = `${p.framework}${p.theme}${p.radius}${p.motion}${p.level}-${mask.toString(16).padStart(2, "0")}`;
|
|
823
|
+
return `${code}-${checksum(code)}`;
|
|
824
|
+
}
|
|
825
|
+
/** The preset an id names, or why it is refused. Only ids made of known choices with a matching checksum pass. */
|
|
826
|
+
function decodePreset(id) {
|
|
827
|
+
const m = ID.exec(id.trim());
|
|
828
|
+
if (!m) return {
|
|
829
|
+
ok: false,
|
|
830
|
+
error: "not a preset id (expected e.g. nbms3-07-1f0c2a9e)"
|
|
831
|
+
};
|
|
832
|
+
if (checksum(id.trim().slice(0, -9)) !== m[7]) return {
|
|
833
|
+
ok: false,
|
|
834
|
+
error: "the checksum does not match: the id was edited or mistyped"
|
|
835
|
+
};
|
|
836
|
+
const mask = parseInt(m[6], 16);
|
|
837
|
+
if (mask >= 1 << blockOrder.length) return {
|
|
838
|
+
ok: false,
|
|
839
|
+
error: "unknown block"
|
|
840
|
+
};
|
|
841
|
+
return {
|
|
842
|
+
ok: true,
|
|
843
|
+
preset: {
|
|
844
|
+
framework: m[1],
|
|
845
|
+
theme: m[2],
|
|
846
|
+
radius: m[3],
|
|
847
|
+
motion: m[4],
|
|
848
|
+
level: m[5],
|
|
849
|
+
blocks: blockOrder.filter((_, i) => mask & 1 << i)
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
const radiusScale = {
|
|
854
|
+
s: .5,
|
|
855
|
+
m: 1,
|
|
856
|
+
l: 1.5
|
|
857
|
+
};
|
|
858
|
+
const motionScale = {
|
|
859
|
+
c: 1.25,
|
|
860
|
+
s: 1,
|
|
861
|
+
o: 0
|
|
862
|
+
};
|
|
863
|
+
/** The preset's theme, built from the choices and validated like any custom theme; null when it keeps every default. */
|
|
864
|
+
function presetTheme(p) {
|
|
865
|
+
if (p.theme === "d" && p.radius === "m" && p.motion === "s") return null;
|
|
866
|
+
const brand = Object.fromEntries(Object.entries(colors).filter(([, v]) => /^#/.test(v.brand)).map(([k, v]) => [k, v.brand]));
|
|
867
|
+
const result = validateTheme({
|
|
868
|
+
version: 1,
|
|
869
|
+
name: `Create preset ${encodePreset(p)}`,
|
|
870
|
+
colors: {
|
|
871
|
+
light: p.theme === "b" ? brand : {},
|
|
872
|
+
dark: {}
|
|
873
|
+
},
|
|
874
|
+
radius: Object.fromEntries(Object.entries(radii).map(([k, v]) => [k, Math.min(32, Math.round(parseFloat(v) * radiusScale[p.radius]))])),
|
|
875
|
+
motion: motionScale[p.motion]
|
|
876
|
+
});
|
|
877
|
+
if (!result.ok) throw new Error(`preset theme: ${result.errors.join("; ")}`);
|
|
878
|
+
return result.theme;
|
|
879
|
+
}
|
|
880
|
+
/** Every registry item the preset installs on top of init's, each once. */
|
|
881
|
+
const presetItems = (p) => [.../* @__PURE__ */ new Set([...presetLevels[p.level].items, ...p.blocks.flatMap((b) => presetBlocks[b].items)])];
|
|
882
|
+
//#endregion
|
|
883
|
+
//#region src/config.ts
|
|
884
|
+
const CONFIG_FILE = "aedis.json";
|
|
885
|
+
const REPOSITORY = "aedis-build/aedis-ui";
|
|
886
|
+
function defaultConfig(root, release) {
|
|
887
|
+
const src = existsSync(path.join(root, "src")) ? "src/" : "";
|
|
888
|
+
return {
|
|
889
|
+
release,
|
|
890
|
+
paths: {
|
|
891
|
+
ui: `${src}components/ui`,
|
|
892
|
+
lib: `${src}lib`,
|
|
893
|
+
hooks: `${src}hooks`,
|
|
894
|
+
styles: `${src}styles`
|
|
895
|
+
},
|
|
896
|
+
aliases: {
|
|
897
|
+
ui: "@/components/ui",
|
|
898
|
+
lib: "@/lib",
|
|
899
|
+
hooks: "@/hooks"
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
const RELEASE = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
904
|
+
const DIR = /^[\w./-]+$/;
|
|
905
|
+
const ALIAS = /^[@~\w./-]+$/;
|
|
906
|
+
/** Reads aedis.json and checks every field; a bad config is an error, never a guess. */
|
|
907
|
+
function readConfig(root) {
|
|
908
|
+
const file = path.join(root, CONFIG_FILE);
|
|
909
|
+
if (!existsSync(file)) return null;
|
|
910
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
911
|
+
const problems = [];
|
|
912
|
+
if (typeof raw.release !== "string" || !RELEASE.test(raw.release)) problems.push("release must look like v1.2.3");
|
|
913
|
+
for (const key of [
|
|
914
|
+
"ui",
|
|
915
|
+
"lib",
|
|
916
|
+
"hooks",
|
|
917
|
+
"styles"
|
|
918
|
+
]) {
|
|
919
|
+
const v = raw.paths?.[key];
|
|
920
|
+
if (typeof v !== "string" || !DIR.test(v) || v.split("/").includes("..") || v.startsWith("/")) problems.push(`paths.${key} must be a relative directory`);
|
|
921
|
+
}
|
|
922
|
+
for (const key of [
|
|
923
|
+
"ui",
|
|
924
|
+
"lib",
|
|
925
|
+
"hooks"
|
|
926
|
+
]) {
|
|
927
|
+
const v = raw.aliases?.[key];
|
|
928
|
+
if (typeof v !== "string" || !ALIAS.test(v)) problems.push(`aliases.${key} must be an import alias`);
|
|
929
|
+
}
|
|
930
|
+
if (problems.length) throw new Error(`${CONFIG_FILE}: ${problems.join("; ")}`);
|
|
931
|
+
return raw;
|
|
932
|
+
}
|
|
933
|
+
const isRelease = (value) => RELEASE.test(value);
|
|
934
|
+
//#endregion
|
|
935
|
+
//#region src/deps.ts
|
|
936
|
+
/** The package manager the project uses, from its lockfile. Yarn 2+ lockfiles start with __metadata. */
|
|
937
|
+
function detectPackageManager(root, exists = existsSync, read = (p) => readFileSync(p, "utf8")) {
|
|
938
|
+
const at = (f) => path.join(root, f);
|
|
939
|
+
if (exists(at("pnpm-lock.yaml"))) return "pnpm";
|
|
940
|
+
if (exists(at("bun.lock")) || exists(at("bun.lockb"))) return "bun";
|
|
941
|
+
if (exists(at("yarn.lock"))) return /__metadata:/.test(read(at("yarn.lock")).slice(0, 4096)) ? "yarn" : "yarn1";
|
|
942
|
+
return "npm";
|
|
943
|
+
}
|
|
944
|
+
/** Exact versions, no lifecycle scripts, whatever the package manager. */
|
|
945
|
+
function installCommand(pm, specs) {
|
|
946
|
+
for (const spec of specs) if (!EXACT.test(spec)) throw new Error(`refused dependency spec ${spec}`);
|
|
947
|
+
switch (pm) {
|
|
948
|
+
case "pnpm": return {
|
|
949
|
+
command: "pnpm",
|
|
950
|
+
args: [
|
|
951
|
+
"add",
|
|
952
|
+
"--save-exact",
|
|
953
|
+
"--ignore-scripts",
|
|
954
|
+
...specs
|
|
955
|
+
],
|
|
956
|
+
env: {}
|
|
957
|
+
};
|
|
958
|
+
case "bun": return {
|
|
959
|
+
command: "bun",
|
|
960
|
+
args: [
|
|
961
|
+
"add",
|
|
962
|
+
"--exact",
|
|
963
|
+
"--ignore-scripts",
|
|
964
|
+
...specs
|
|
965
|
+
],
|
|
966
|
+
env: {}
|
|
967
|
+
};
|
|
968
|
+
case "yarn": return {
|
|
969
|
+
command: "yarn",
|
|
970
|
+
args: [
|
|
971
|
+
"add",
|
|
972
|
+
"--exact",
|
|
973
|
+
...specs
|
|
974
|
+
],
|
|
975
|
+
env: { YARN_ENABLE_SCRIPTS: "false" }
|
|
976
|
+
};
|
|
977
|
+
case "yarn1": return {
|
|
978
|
+
command: "yarn",
|
|
979
|
+
args: [
|
|
980
|
+
"add",
|
|
981
|
+
"--exact",
|
|
982
|
+
"--ignore-scripts",
|
|
983
|
+
...specs
|
|
984
|
+
],
|
|
985
|
+
env: {}
|
|
986
|
+
};
|
|
987
|
+
default: return {
|
|
988
|
+
command: "npm",
|
|
989
|
+
args: [
|
|
990
|
+
"install",
|
|
991
|
+
"--save-exact",
|
|
992
|
+
"--ignore-scripts",
|
|
993
|
+
...specs
|
|
994
|
+
],
|
|
995
|
+
env: {}
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
const NAME$1 = String.raw`(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*`;
|
|
1000
|
+
const VERSION$1 = String.raw`\d+\.\d+\.\d+`;
|
|
1001
|
+
const EXACT = new RegExp(`^${NAME$1}@${VERSION$1}$`);
|
|
1002
|
+
const SPEC = new RegExp(`^(${NAME$1})(?:@(${VERSION$1}))?$`);
|
|
1003
|
+
const DAY = 864e5;
|
|
1004
|
+
const compare = (a, b) => {
|
|
1005
|
+
const [x, y] = [a.split(".").map(Number), b.split(".").map(Number)];
|
|
1006
|
+
return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
|
|
1007
|
+
};
|
|
1008
|
+
/**
|
|
1009
|
+
* Exact versions for the items' npm dependencies, checked against the npm registry's own publish dates: a pinned
|
|
1010
|
+
* version younger than 7 days is refused; an unpinned one becomes the newest stable version at least 7 days old.
|
|
1011
|
+
*/
|
|
1012
|
+
async function pickVersions(deps, { fetcher = fetch, now = Date.now(), minAgeDays = 7 } = {}) {
|
|
1013
|
+
const wanted = /* @__PURE__ */ new Map();
|
|
1014
|
+
for (const dep of deps) {
|
|
1015
|
+
const m = SPEC.exec(dep.trim());
|
|
1016
|
+
if (!m) throw new Error(`refused dependency ${dep}: use a name or name@x.y.z`);
|
|
1017
|
+
const [, name, version] = m;
|
|
1018
|
+
const previous = wanted.get(name);
|
|
1019
|
+
if (previous && version && previous !== version) throw new Error(`conflicting versions for ${name}: ${previous} and ${version}`);
|
|
1020
|
+
wanted.set(name, version ?? previous);
|
|
1021
|
+
}
|
|
1022
|
+
const out = [];
|
|
1023
|
+
for (const [name, version] of wanted) {
|
|
1024
|
+
const response = await fetcher(`https://registry.npmjs.org/${name.replace("/", "%2F")}`, { headers: { Accept: "application/json" } });
|
|
1025
|
+
if (!response.ok) throw new Error(`npm registry: ${name} (${response.status})`);
|
|
1026
|
+
const doc = await response.json();
|
|
1027
|
+
const age = (v) => doc.time?.[v] ? now - Date.parse(doc.time[v]) : -1;
|
|
1028
|
+
if (version) {
|
|
1029
|
+
if (!doc.versions?.[version]) throw new Error(`npm registry: ${name}@${version} does not exist`);
|
|
1030
|
+
if (age(version) < minAgeDays * DAY) throw new Error(`${name}@${version} is younger than ${minAgeDays} days; wait or pin an older version`);
|
|
1031
|
+
out.push(`${name}@${version}`);
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
const pick = Object.keys(doc.versions ?? {}).filter((v) => /^\d+\.\d+\.\d+$/.test(v) && age(v) >= minAgeDays * DAY).sort(compare).at(-1);
|
|
1035
|
+
if (!pick) throw new Error(`npm registry: no version of ${name} is ${minAgeDays} days old`);
|
|
1036
|
+
out.push(`${name}@${pick}`);
|
|
1037
|
+
}
|
|
1038
|
+
return out;
|
|
1039
|
+
}
|
|
1040
|
+
//#endregion
|
|
1041
|
+
//#region src/diff.ts
|
|
1042
|
+
/** A line diff of two texts ("-" local only, "+" registry only) with a little context; null when they are equal. */
|
|
1043
|
+
function lineDiff(local, registry, context = 2) {
|
|
1044
|
+
if (local === registry) return null;
|
|
1045
|
+
const a = local.split("\n");
|
|
1046
|
+
const b = registry.split("\n");
|
|
1047
|
+
if (a.length * b.length > 4e6) return `files differ (${a.length} vs ${b.length} lines)`;
|
|
1048
|
+
const lcs = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1));
|
|
1049
|
+
for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
1050
|
+
const ops = [];
|
|
1051
|
+
let i = 0;
|
|
1052
|
+
let j = 0;
|
|
1053
|
+
while (i < a.length || j < b.length) if (i < a.length && j < b.length && a[i] === b[j]) ops.push([" ", a[i++]]), j++;
|
|
1054
|
+
else if (i < a.length && (j === b.length || lcs[i + 1][j] >= lcs[i][j + 1])) ops.push(["-", a[i++]]);
|
|
1055
|
+
else ops.push(["+", b[j++]]);
|
|
1056
|
+
const keep = ops.map((op, k) => op[0] !== " " || ops.slice(Math.max(0, k - context), k + context + 1).some((o) => o[0] !== " "));
|
|
1057
|
+
const lines = [];
|
|
1058
|
+
ops.forEach((op, k) => {
|
|
1059
|
+
if (keep[k]) lines.push(`${op[0]} ${op[1]}`);
|
|
1060
|
+
else if (k === 0 || keep[k - 1]) lines.push(" …");
|
|
1061
|
+
});
|
|
1062
|
+
return lines.join("\n");
|
|
1063
|
+
}
|
|
1064
|
+
//#endregion
|
|
1065
|
+
//#region src/lint.ts
|
|
1066
|
+
const MARKUP = /dangerouslySetInnerHTML|\.(?:inner|outer)HTML\s*=|insertAdjacentHTML|document\.write\(/;
|
|
1067
|
+
const TRANSPORT = /\bfetch\(|new XMLHttpRequest\(|new WebSocket\(|new EventSource\(|\baxios\b/;
|
|
1068
|
+
const NATIVE_FIELD = /<(input|textarea)\b[^>]*>/gs;
|
|
1069
|
+
const NOT_TEXT = /\btype=["'{](?:file|checkbox|radio|hidden|range|color|submit|button|reset|image)\b/;
|
|
1070
|
+
const lineOf = (source, index) => source.slice(0, index).split("\n").length;
|
|
1071
|
+
/** Every finding in one file of one item, minus the rules the item is exempt from. */
|
|
1072
|
+
function lintSource(item, file, source, exempt = {}) {
|
|
1073
|
+
const found = [];
|
|
1074
|
+
const add = (rule, line, text) => {
|
|
1075
|
+
if (!exempt[rule]?.trim()) found.push({
|
|
1076
|
+
item,
|
|
1077
|
+
file,
|
|
1078
|
+
line,
|
|
1079
|
+
rule,
|
|
1080
|
+
text: text.trim().slice(0, 120)
|
|
1081
|
+
});
|
|
1082
|
+
};
|
|
1083
|
+
source.split("\n").forEach((text, i) => {
|
|
1084
|
+
if (MARKUP.test(text)) add("markup", i + 1, text);
|
|
1085
|
+
if (TRANSPORT.test(text)) add("transport", i + 1, text);
|
|
1086
|
+
});
|
|
1087
|
+
for (const m of source.matchAll(NATIVE_FIELD)) if (!/\bmaxLength\b/.test(m[0]) && !NOT_TEXT.test(m[0])) add("input", lineOf(source, m.index), m[0].replace(/\s+/g, " "));
|
|
1088
|
+
return found;
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Lints a shadcn-style registry.json: every file of every item, read with `read` (paths relative to the registry).
|
|
1092
|
+
* Paths that leave the registry's directory are refused.
|
|
1093
|
+
*/
|
|
1094
|
+
function lintRegistry(registry, read) {
|
|
1095
|
+
const items = registry?.items;
|
|
1096
|
+
if (!Array.isArray(items)) throw new Error("lint: registry.json has no items");
|
|
1097
|
+
return items.flatMap((entry) => {
|
|
1098
|
+
const name = String(entry.name);
|
|
1099
|
+
return (entry.files ?? []).flatMap((f) => {
|
|
1100
|
+
const file = String(f.path);
|
|
1101
|
+
if (file.startsWith("/") || file.split(/[\\/]/).includes("..")) throw new Error(`lint: ${name} has a file outside the registry: ${file}`);
|
|
1102
|
+
return lintSource(name, file, read(file), entry.meta?.aedisLint);
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
const lintMessages = {
|
|
1107
|
+
markup: "renders a string as markup",
|
|
1108
|
+
input: "a text field without maxLength",
|
|
1109
|
+
transport: "talks to the backend without api()"
|
|
1110
|
+
};
|
|
1111
|
+
//#endregion
|
|
1112
|
+
//#region src/paths.ts
|
|
1113
|
+
/**
|
|
1114
|
+
* Resolves a path from a registry item against the project root and refuses anything that would land outside it:
|
|
1115
|
+
* absolute paths, drive letters, "..", NUL bytes, backslash tricks and empty names.
|
|
1116
|
+
*/
|
|
1117
|
+
function insideProject(root, relative) {
|
|
1118
|
+
if (typeof relative !== "string" || relative.length === 0 || relative.length > 400) throw new Error("refused path: empty or too long");
|
|
1119
|
+
if (/[\u0000-\u001F]/.test(relative)) throw new Error(`refused path: control character in ${JSON.stringify(relative)}`);
|
|
1120
|
+
const normalized = relative.replace(/\\/g, "/");
|
|
1121
|
+
if (normalized.startsWith("/") || /^[a-zA-Z]:/.test(normalized) || normalized.startsWith("~")) throw new Error(`refused path: ${relative} is absolute`);
|
|
1122
|
+
if (normalized.split("/").some((part) => part === "..")) throw new Error(`refused path: ${relative} leaves the project`);
|
|
1123
|
+
const base = path.resolve(root);
|
|
1124
|
+
const full = path.resolve(base, normalized);
|
|
1125
|
+
if (full !== base && !full.startsWith(base + path.sep)) throw new Error(`refused path: ${relative} leaves the project`);
|
|
1126
|
+
if (full === base) throw new Error("refused path: the project root itself");
|
|
1127
|
+
return full;
|
|
1128
|
+
}
|
|
1129
|
+
//#endregion
|
|
1130
|
+
//#region src/tar.ts
|
|
1131
|
+
/**
|
|
1132
|
+
* Reads the regular files of a gzipped ustar archive into memory (the registry release). Nothing touches the disk;
|
|
1133
|
+
* links, devices and oversized archives are refused.
|
|
1134
|
+
*/
|
|
1135
|
+
function readTarGz(archive, { maxBytes = 67108864 } = {}) {
|
|
1136
|
+
const tar = gunzipSync(archive, { maxOutputLength: maxBytes });
|
|
1137
|
+
const files = /* @__PURE__ */ new Map();
|
|
1138
|
+
let offset = 0;
|
|
1139
|
+
let longName = null;
|
|
1140
|
+
const text = (start, length) => tar.subarray(start, start + length).toString("utf8").replace(/\0.*$/s, "");
|
|
1141
|
+
while (offset + 512 <= tar.length) {
|
|
1142
|
+
const header = tar.subarray(offset, offset + 512);
|
|
1143
|
+
if (header.every((b) => b === 0)) break;
|
|
1144
|
+
const size = parseInt(text(offset + 124, 12).trim() || "0", 8);
|
|
1145
|
+
if (!Number.isFinite(size) || size < 0) throw new Error("archive: bad entry size");
|
|
1146
|
+
const type = String.fromCharCode(header[156] || 48);
|
|
1147
|
+
const prefix = text(offset + 345, 155);
|
|
1148
|
+
let name = longName ?? (prefix ? `${prefix}/${text(offset, 100)}` : text(offset, 100));
|
|
1149
|
+
longName = null;
|
|
1150
|
+
const body = tar.subarray(offset + 512, offset + 512 + size);
|
|
1151
|
+
if (type === "L") longName = body.toString("utf8").replace(/\0.*$/s, "");
|
|
1152
|
+
else if (type === "0" || type === "\0") {
|
|
1153
|
+
name = name.replace(/^\.\//, "");
|
|
1154
|
+
files.set(name, Buffer.from(body));
|
|
1155
|
+
} else if (type === "1" || type === "2" || type === "3" || type === "4" || type === "6") throw new Error(`archive: refused entry type ${type} for ${name}`);
|
|
1156
|
+
offset += 512 + Math.ceil(size / 512) * 512;
|
|
1157
|
+
}
|
|
1158
|
+
return files;
|
|
1159
|
+
}
|
|
1160
|
+
//#endregion
|
|
1161
|
+
//#region src/verify.ts
|
|
1162
|
+
const ISSUER = "https://token.actions.githubusercontent.com";
|
|
1163
|
+
const WORKFLOW = ".github/workflows/release.yml";
|
|
1164
|
+
/** The platform's reusable release workflow: it builds and attests a registry in a job isolated from the repository (SLSA Build L3). */
|
|
1165
|
+
const PLATFORM_BUILDER = "aedis-build/aedis-platform/.github/workflows/release-registry-js.yml";
|
|
1166
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
1167
|
+
var ProvenanceError = class extends Error {
|
|
1168
|
+
constructor(message) {
|
|
1169
|
+
super(`provenance: ${message}`);
|
|
1170
|
+
this.name = "ProvenanceError";
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
/**
|
|
1174
|
+
* Verifies a registry release before anything from it is written: the Sigstore bundle must be signed by the
|
|
1175
|
+
* repository's release workflow for exactly this tag, or by the platform's reusable release workflow that it called,
|
|
1176
|
+
* and its SLSA provenance must name this archive (by sha256), that repository, that release workflow and this tag.
|
|
1177
|
+
* Any mismatch throws. Aedis UI and third-party registries get the same checks; only the repository differs.
|
|
1178
|
+
*/
|
|
1179
|
+
async function verifyRelease({ archive, bundle, release, assetName, check, repository = REPOSITORY }) {
|
|
1180
|
+
if (!bundle || typeof bundle !== "object") throw new ProvenanceError("missing attestation bundle");
|
|
1181
|
+
const own = `https://github.com/${repository}/${WORKFLOW}@refs/tags/${release}`;
|
|
1182
|
+
const identity = `^(?:${escape(own)}|${escape(`https://github.com/${PLATFORM_BUILDER}@`)}[^@]+)$`;
|
|
1183
|
+
try {
|
|
1184
|
+
await check(bundle, {
|
|
1185
|
+
certificateIdentityURI: identity,
|
|
1186
|
+
certificateIssuer: ISSUER
|
|
1187
|
+
});
|
|
1188
|
+
} catch (error) {
|
|
1189
|
+
throw new ProvenanceError(`signature does not verify for ${own} or the platform builder: ${error.message}`);
|
|
1190
|
+
}
|
|
1191
|
+
const envelope = bundle.dsseEnvelope;
|
|
1192
|
+
if (!envelope?.payload || envelope.payloadType !== "application/vnd.in-toto+json") throw new ProvenanceError("bundle carries no in-toto statement");
|
|
1193
|
+
let statement;
|
|
1194
|
+
try {
|
|
1195
|
+
statement = JSON.parse(Buffer.from(envelope.payload, "base64").toString("utf8"));
|
|
1196
|
+
} catch {
|
|
1197
|
+
throw new ProvenanceError("statement is not JSON");
|
|
1198
|
+
}
|
|
1199
|
+
if (statement._type !== "https://in-toto.io/Statement/v1") throw new ProvenanceError("unexpected statement type");
|
|
1200
|
+
if (statement.predicateType !== "https://slsa.dev/provenance/v1") throw new ProvenanceError("not SLSA provenance");
|
|
1201
|
+
const digest = createHash("sha256").update(archive).digest("hex");
|
|
1202
|
+
const subject = statement.subject?.find((s) => s.name === assetName);
|
|
1203
|
+
if (!subject) throw new ProvenanceError(`statement does not name ${assetName}`);
|
|
1204
|
+
if (subject.digest?.sha256 !== digest) throw new ProvenanceError(`${assetName} does not match its attestation (tampered or replaced)`);
|
|
1205
|
+
const workflow = statement.predicate?.buildDefinition?.externalParameters?.workflow;
|
|
1206
|
+
if (workflow?.repository !== `https://github.com/${repository}`) throw new ProvenanceError(`built from ${workflow?.repository ?? "an unknown repository"}`);
|
|
1207
|
+
if (workflow.path !== ".github/workflows/release.yml") throw new ProvenanceError(`built by ${workflow.path ?? "an unknown workflow"}, not ${WORKFLOW}`);
|
|
1208
|
+
if (workflow.ref !== `refs/tags/${release}`) throw new ProvenanceError(`built from ${workflow.ref ?? "an unknown ref"}, not ${release}`);
|
|
1209
|
+
return { digest };
|
|
1210
|
+
}
|
|
1211
|
+
//#endregion
|
|
1212
|
+
//#region src/registry.ts
|
|
1213
|
+
const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
1214
|
+
const MAX_ITEM_BYTES = 2097152;
|
|
1215
|
+
/** Checks the shape of a registry item; anything unexpected is an error, never a guess. */
|
|
1216
|
+
function parseItem(json, expected) {
|
|
1217
|
+
const item = json;
|
|
1218
|
+
if (!item || typeof item !== "object" || item.name !== expected) throw new Error(`registry: item ${expected} is missing or misnamed`);
|
|
1219
|
+
const strings = (v, field) => {
|
|
1220
|
+
if (v === void 0) return [];
|
|
1221
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) throw new Error(`registry: ${expected}.${field} must be a list of strings`);
|
|
1222
|
+
return v;
|
|
1223
|
+
};
|
|
1224
|
+
if (!Array.isArray(item.files) || item.files.length === 0) throw new Error(`registry: ${expected} has no files`);
|
|
1225
|
+
const files = item.files.map((f) => {
|
|
1226
|
+
if (typeof f.path !== "string" || typeof f.type !== "string" || typeof f.content !== "string") throw new Error(`registry: ${expected} has a malformed file`);
|
|
1227
|
+
if (f.target !== void 0 && typeof f.target !== "string") throw new Error(`registry: ${expected} has a malformed target`);
|
|
1228
|
+
return {
|
|
1229
|
+
path: f.path,
|
|
1230
|
+
type: f.type,
|
|
1231
|
+
target: f.target,
|
|
1232
|
+
content: f.content
|
|
1233
|
+
};
|
|
1234
|
+
});
|
|
1235
|
+
return {
|
|
1236
|
+
name: item.name,
|
|
1237
|
+
type: String(item.type),
|
|
1238
|
+
dependencies: strings(item.dependencies, "dependencies"),
|
|
1239
|
+
registryDependencies: strings(item.registryDependencies, "registryDependencies"),
|
|
1240
|
+
files
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
/** The release asset of a registry: <repository name>-registry-<tag>.tgz, as aedis-ui's release workflow names it. */
|
|
1244
|
+
const assetName = (release, repository = REPOSITORY) => `${repository.split("/")[1]}-registry-${release}.tgz`;
|
|
1245
|
+
const assetUrl = (repository, release, file) => `https://github.com/${repository}/releases/download/${release}/${file}`;
|
|
1246
|
+
const GITHUB = /^github:([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100})@(v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/;
|
|
1247
|
+
/** A third-party registry given as github:<owner>/<repo>@<tag>; null for anything else. Its namespace is @<owner>. */
|
|
1248
|
+
function githubRegistry(value) {
|
|
1249
|
+
const m = GITHUB.exec(value);
|
|
1250
|
+
return m ? {
|
|
1251
|
+
repository: `${m[1]}/${m[2]}`,
|
|
1252
|
+
release: m[3],
|
|
1253
|
+
namespace: `@${m[1].toLowerCase()}`
|
|
1254
|
+
} : null;
|
|
1255
|
+
}
|
|
1256
|
+
async function download(fetcher, url, limit) {
|
|
1257
|
+
const response = await fetcher(url, { redirect: "follow" });
|
|
1258
|
+
if (!response.ok) throw new Error(`download failed (${response.status}): ${url}`);
|
|
1259
|
+
const body = new Uint8Array(await response.arrayBuffer());
|
|
1260
|
+
if (body.byteLength > limit) throw new Error(`download too large: ${url}`);
|
|
1261
|
+
return body;
|
|
1262
|
+
}
|
|
1263
|
+
/** A signed release: the archive and its attestation are downloaded and verified before any item is read. */
|
|
1264
|
+
async function releaseSource(release, { fetcher = fetch, check, repository = REPOSITORY, namespace = "@aedis" }) {
|
|
1265
|
+
const name = assetName(release, repository);
|
|
1266
|
+
const archive = await download(fetcher, assetUrl(repository, release, name), 33554432);
|
|
1267
|
+
let bundle;
|
|
1268
|
+
try {
|
|
1269
|
+
bundle = JSON.parse(new TextDecoder().decode(await download(fetcher, assetUrl(repository, release, `${name}.sigstore`), 1048576)));
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
throw new Error(`provenance: missing attestation for ${release} (${error.message})`);
|
|
1272
|
+
}
|
|
1273
|
+
await verifyRelease({
|
|
1274
|
+
archive,
|
|
1275
|
+
bundle,
|
|
1276
|
+
release,
|
|
1277
|
+
assetName: name,
|
|
1278
|
+
check,
|
|
1279
|
+
repository
|
|
1280
|
+
});
|
|
1281
|
+
const files = readTarGz(archive);
|
|
1282
|
+
return {
|
|
1283
|
+
verified: true,
|
|
1284
|
+
namespace,
|
|
1285
|
+
label: `${repository} ${release} (verified)`,
|
|
1286
|
+
async get(item) {
|
|
1287
|
+
if (!NAME.test(item)) throw new Error(`registry: invalid item name ${JSON.stringify(item)}`);
|
|
1288
|
+
const raw = files.get(`r/${item}.json`);
|
|
1289
|
+
if (!raw) throw new Error(`registry: ${item} is not in ${release}`);
|
|
1290
|
+
return parseItem(JSON.parse(raw.toString("utf8")), item);
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
/** Another registry (e.g. a branch running locally). Not verified; the CLI says so before writing. */
|
|
1295
|
+
function urlSource(base, { fetcher = fetch } = {}) {
|
|
1296
|
+
const url = new URL(base.endsWith("/") ? base : `${base}/`);
|
|
1297
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error("--registry must be an http(s) URL");
|
|
1298
|
+
return {
|
|
1299
|
+
verified: false,
|
|
1300
|
+
namespace: "@aedis",
|
|
1301
|
+
label: `${url.href} (NOT verified)`,
|
|
1302
|
+
async get(item) {
|
|
1303
|
+
if (!NAME.test(item)) throw new Error(`registry: invalid item name ${JSON.stringify(item)}`);
|
|
1304
|
+
const body = await download(fetcher, new URL(`${item}.json`, url).href, MAX_ITEM_BYTES);
|
|
1305
|
+
return parseItem(JSON.parse(new TextDecoder().decode(body)), item);
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
/** The items and everything they depend on, dependencies first, each once. Only the source's own namespace is followed. */
|
|
1310
|
+
async function resolveItems(names, source) {
|
|
1311
|
+
const done = /* @__PURE__ */ new Map();
|
|
1312
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
1313
|
+
const visit = async (name) => {
|
|
1314
|
+
if (done.has(name) || visiting.has(name)) return;
|
|
1315
|
+
visiting.add(name);
|
|
1316
|
+
const item = await source.get(name);
|
|
1317
|
+
for (const dep of item.registryDependencies) {
|
|
1318
|
+
if (!dep.startsWith(`${source.namespace}/`)) throw new Error(`registry: ${name} depends on ${dep}, outside ${source.namespace}`);
|
|
1319
|
+
await visit(dep.slice(source.namespace.length + 1));
|
|
1320
|
+
}
|
|
1321
|
+
visiting.delete(name);
|
|
1322
|
+
done.set(name, item);
|
|
1323
|
+
};
|
|
1324
|
+
for (const name of names) await visit(name.startsWith(`${source.namespace}/`) ? name.slice(source.namespace.length + 1) : name);
|
|
1325
|
+
return [...done.values()];
|
|
1326
|
+
}
|
|
1327
|
+
//#endregion
|
|
1328
|
+
//#region src/mcp.ts
|
|
1329
|
+
/**
|
|
1330
|
+
* `aedis mcp`: a read-only docs server for coding agents, over the Model Context Protocol on stdio (JSON-RPC 2.0, one
|
|
1331
|
+
* message per line). It answers from the site's docs.json: list and search components, read one with its API and
|
|
1332
|
+
* security rules, and get its install command. It writes nothing and runs nothing.
|
|
1333
|
+
*/
|
|
1334
|
+
const DOCS_URL = "https://ui.aedis.build/docs.json";
|
|
1335
|
+
/** docs.json is about 110 KB; anything far larger is not ours. */
|
|
1336
|
+
const MAX_DOCS_BYTES = 2e6;
|
|
1337
|
+
const PROTOCOLS = [
|
|
1338
|
+
"2025-06-18",
|
|
1339
|
+
"2025-03-26",
|
|
1340
|
+
"2024-11-05"
|
|
1341
|
+
];
|
|
1342
|
+
const FIELDS = [
|
|
1343
|
+
"name",
|
|
1344
|
+
"title",
|
|
1345
|
+
"category",
|
|
1346
|
+
"description",
|
|
1347
|
+
"url",
|
|
1348
|
+
"install",
|
|
1349
|
+
"text"
|
|
1350
|
+
];
|
|
1351
|
+
/** Fetches and checks docs.json: same host, no redirects, bounded size, every field a string. */
|
|
1352
|
+
async function loadDocs(fetcher, url = DOCS_URL) {
|
|
1353
|
+
const response = await fetcher(url, { redirect: "error" });
|
|
1354
|
+
if (!response.ok) throw new Error(`docs: ${url} answered ${response.status}`);
|
|
1355
|
+
const body = await response.text();
|
|
1356
|
+
if (body.length > MAX_DOCS_BYTES) throw new Error("docs: docs.json is too large");
|
|
1357
|
+
const data = JSON.parse(body);
|
|
1358
|
+
const strings = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
1359
|
+
const items = Array.isArray(data.items) ? data.items : [];
|
|
1360
|
+
if (!strings(data.rules) || !items.length || !items.every((i) => i && typeof i === "object" && FIELDS.every((f) => typeof i[f] === "string"))) throw new Error("docs: docs.json does not have the expected shape");
|
|
1361
|
+
return {
|
|
1362
|
+
rules: data.rules,
|
|
1363
|
+
items: items.map((i) => Object.fromEntries(FIELDS.map((f) => [f, i[f]])))
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
const text = (t, isError = false) => ({
|
|
1367
|
+
content: [{
|
|
1368
|
+
type: "text",
|
|
1369
|
+
text: t
|
|
1370
|
+
}],
|
|
1371
|
+
...isError ? { isError: true } : {}
|
|
1372
|
+
});
|
|
1373
|
+
const line = (i) => `${i.name}: ${i.title} (${i.category}). ${i.description}`;
|
|
1374
|
+
const TOOLS = [
|
|
1375
|
+
{
|
|
1376
|
+
name: "list_components",
|
|
1377
|
+
description: "List every Aedis UI component, hook, library and block, optionally in one category (e.g. Forms, AI and agents, Blocks).",
|
|
1378
|
+
inputSchema: {
|
|
1379
|
+
type: "object",
|
|
1380
|
+
properties: { category: { type: "string" } }
|
|
1381
|
+
}
|
|
1382
|
+
},
|
|
1383
|
+
{
|
|
1384
|
+
name: "search_components",
|
|
1385
|
+
description: "Find Aedis UI items whose name, title, category or description contain every word of the query.",
|
|
1386
|
+
inputSchema: {
|
|
1387
|
+
type: "object",
|
|
1388
|
+
properties: { query: { type: "string" } },
|
|
1389
|
+
required: ["query"]
|
|
1390
|
+
}
|
|
1391
|
+
},
|
|
1392
|
+
{
|
|
1393
|
+
name: "get_component",
|
|
1394
|
+
description: "One item in full: install command, usage, API by part, security (sinks, defenses, what stays on the backend) and the rules to follow.",
|
|
1395
|
+
inputSchema: {
|
|
1396
|
+
type: "object",
|
|
1397
|
+
properties: { name: { type: "string" } },
|
|
1398
|
+
required: ["name"]
|
|
1399
|
+
}
|
|
1400
|
+
},
|
|
1401
|
+
{
|
|
1402
|
+
name: "get_install_command",
|
|
1403
|
+
description: "The pinned @aedis/cli command that adds an item after verifying the signed release.",
|
|
1404
|
+
inputSchema: {
|
|
1405
|
+
type: "object",
|
|
1406
|
+
properties: { name: { type: "string" } },
|
|
1407
|
+
required: ["name"]
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
];
|
|
1411
|
+
/** Answers one tool call from the docs. */
|
|
1412
|
+
function callTool(docs, name, args) {
|
|
1413
|
+
const arg = (k) => typeof args[k] === "string" ? args[k].trim().slice(0, 200) : "";
|
|
1414
|
+
const find = () => docs.items.find((i) => i.name === arg("name").toLowerCase());
|
|
1415
|
+
const missing = () => text(`No item named "${arg("name")}". Use list_components or search_components.`, true);
|
|
1416
|
+
switch (name) {
|
|
1417
|
+
case "list_components": {
|
|
1418
|
+
const category = arg("category").toLowerCase();
|
|
1419
|
+
const items = docs.items.filter((i) => !category || i.category.toLowerCase() === category);
|
|
1420
|
+
return items.length ? text(items.map(line).join("\n")) : text(`No category "${arg("category")}".`, true);
|
|
1421
|
+
}
|
|
1422
|
+
case "search_components": {
|
|
1423
|
+
const words = arg("query").toLowerCase().split(/\s+/).filter(Boolean);
|
|
1424
|
+
const hits = docs.items.filter((i) => words.every((w) => `${i.name} ${i.title} ${i.category} ${i.description}`.toLowerCase().includes(w)));
|
|
1425
|
+
return text(words.length && hits.length ? hits.map(line).join("\n") : "Nothing matches. Use list_components.");
|
|
1426
|
+
}
|
|
1427
|
+
case "get_component": {
|
|
1428
|
+
const item = find();
|
|
1429
|
+
return item ? text(`${item.text}\n\nRules for every Aedis UI item:\n${docs.rules.map((r) => `- ${r}`).join("\n")}`) : missing();
|
|
1430
|
+
}
|
|
1431
|
+
case "get_install_command": {
|
|
1432
|
+
const item = find();
|
|
1433
|
+
return item ? text(`${item.install}\n\nNew app? Run the init command once first (see ${item.url.replace(/\/(docs|blocks)\/.*$/, "/docs")}).`) : missing();
|
|
1434
|
+
}
|
|
1435
|
+
default: return null;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
/** Handles one JSON-RPC message; returns the reply, or null for a notification. Docs load on the first tool call. */
|
|
1439
|
+
async function handle(message, docs, version) {
|
|
1440
|
+
let req;
|
|
1441
|
+
try {
|
|
1442
|
+
req = JSON.parse(message);
|
|
1443
|
+
} catch {
|
|
1444
|
+
return {
|
|
1445
|
+
jsonrpc: "2.0",
|
|
1446
|
+
id: null,
|
|
1447
|
+
error: {
|
|
1448
|
+
code: -32700,
|
|
1449
|
+
message: "Parse error"
|
|
1450
|
+
}
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
if (req === null || typeof req !== "object" || typeof req.method !== "string") return {
|
|
1454
|
+
jsonrpc: "2.0",
|
|
1455
|
+
id: null,
|
|
1456
|
+
error: {
|
|
1457
|
+
code: -32600,
|
|
1458
|
+
message: "Invalid request"
|
|
1459
|
+
}
|
|
1460
|
+
};
|
|
1461
|
+
if (req.id === void 0) return null;
|
|
1462
|
+
const reply = (result) => ({
|
|
1463
|
+
jsonrpc: "2.0",
|
|
1464
|
+
id: req.id,
|
|
1465
|
+
result
|
|
1466
|
+
});
|
|
1467
|
+
const params = req.params ?? {};
|
|
1468
|
+
switch (req.method) {
|
|
1469
|
+
case "initialize": {
|
|
1470
|
+
const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
|
|
1471
|
+
return reply({
|
|
1472
|
+
protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
|
|
1473
|
+
capabilities: { tools: { listChanged: false } },
|
|
1474
|
+
serverInfo: {
|
|
1475
|
+
name: "aedis-ui-docs",
|
|
1476
|
+
version
|
|
1477
|
+
},
|
|
1478
|
+
instructions: "Read-only Aedis UI docs. Call get_component before using an item, follow its rules, and install it with get_install_command."
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
case "ping": return reply({});
|
|
1482
|
+
case "tools/list": return reply({ tools: TOOLS });
|
|
1483
|
+
case "tools/call": {
|
|
1484
|
+
const name = typeof params.name === "string" ? params.name : "";
|
|
1485
|
+
const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
|
|
1486
|
+
if (!TOOLS.some((t) => t.name === name)) return {
|
|
1487
|
+
jsonrpc: "2.0",
|
|
1488
|
+
id: req.id,
|
|
1489
|
+
error: {
|
|
1490
|
+
code: -32602,
|
|
1491
|
+
message: `Unknown tool ${name}`
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
try {
|
|
1495
|
+
return reply(callTool(await docs(), name, args));
|
|
1496
|
+
} catch (error) {
|
|
1497
|
+
return reply(text(`Could not load the docs: ${error.message}`, true));
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
default: return {
|
|
1501
|
+
jsonrpc: "2.0",
|
|
1502
|
+
id: req.id,
|
|
1503
|
+
error: {
|
|
1504
|
+
code: -32601,
|
|
1505
|
+
message: `Method not found: ${req.method}`
|
|
1506
|
+
}
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
/** Serves MCP over newline-delimited JSON until the input ends. */
|
|
1511
|
+
async function serve(lines, write, fetcher, version) {
|
|
1512
|
+
let cache = null;
|
|
1513
|
+
const docs = () => {
|
|
1514
|
+
cache ??= loadDocs(fetcher).catch((error) => {
|
|
1515
|
+
cache = null;
|
|
1516
|
+
throw error;
|
|
1517
|
+
});
|
|
1518
|
+
return cache;
|
|
1519
|
+
};
|
|
1520
|
+
for await (const message of lines) {
|
|
1521
|
+
if (!message.trim()) continue;
|
|
1522
|
+
const reply = await handle(message, docs, version);
|
|
1523
|
+
if (reply) write(`${JSON.stringify(reply)}\n`);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
//#endregion
|
|
1527
|
+
//#region src/theme.ts
|
|
1528
|
+
/**
|
|
1529
|
+
* `aedis theme import`: the same typed validation as the browser (#41) before anything is written. Returns the CSS
|
|
1530
|
+
* for the app to import after the Aedis theme, or every reason the theme was refused.
|
|
1531
|
+
*/
|
|
1532
|
+
function themeImport(json, source) {
|
|
1533
|
+
const result = parseThemeJson(json);
|
|
1534
|
+
if (!result.ok) return result;
|
|
1535
|
+
const name = result.theme.name.replace(/\*\//g, "");
|
|
1536
|
+
return {
|
|
1537
|
+
ok: true,
|
|
1538
|
+
name,
|
|
1539
|
+
css: `/* Generated by aedis theme import from ${source.replace(/\*\//g, "")} (${name}). Do not edit; import again. */\n${themeToCss(result.theme)}\n`
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
//#endregion
|
|
1543
|
+
//#region src/transform.ts
|
|
1544
|
+
/** Where a registry file goes in this project. */
|
|
1545
|
+
function targetOf(file, config) {
|
|
1546
|
+
const base = path.posix.basename(file.path.replace(/\\/g, "/"));
|
|
1547
|
+
switch (file.type) {
|
|
1548
|
+
case "registry:ui":
|
|
1549
|
+
case "registry:component": return `${config.paths.ui}/${base}`;
|
|
1550
|
+
case "registry:lib": return `${config.paths.lib}/${base}`;
|
|
1551
|
+
case "registry:hook": return `${config.paths.hooks}/${base}`;
|
|
1552
|
+
case "registry:file":
|
|
1553
|
+
if (!file.target) throw new Error(`registry: ${file.path} has no target`);
|
|
1554
|
+
return file.target.replace(/^styles\//, `${config.paths.styles}/`);
|
|
1555
|
+
default: throw new Error(`registry: unsupported file type ${file.type}`);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
/** Rewrites the registry's own import paths to this project's aliases. */
|
|
1559
|
+
function rewriteImports(content, config) {
|
|
1560
|
+
return content.replace(/(["'])@\/registry\/aedis\/ui\//g, `$1${config.aliases.ui}/`).replace(/(["'])@\/registry\/aedis\/hooks\//g, `$1${config.aliases.hooks}/`).replace(/(["'])@\/registry\/aedis\/lib\//g, `$1${config.aliases.lib}/`).replace(/(["'])@\/lib\//g, `$1${config.aliases.lib}/`);
|
|
1561
|
+
}
|
|
1562
|
+
/** Every file the items would write, with paths checked against the project root. */
|
|
1563
|
+
function planFiles(items, config, root) {
|
|
1564
|
+
return items.flatMap((item) => item.files.map((file) => {
|
|
1565
|
+
const relative = targetOf(file, config);
|
|
1566
|
+
return {
|
|
1567
|
+
item: item.name,
|
|
1568
|
+
relative,
|
|
1569
|
+
absolute: insideProject(root, relative),
|
|
1570
|
+
content: file.type === "registry:file" ? file.content : rewriteImports(file.content, config)
|
|
1571
|
+
};
|
|
1572
|
+
}));
|
|
1573
|
+
}
|
|
1574
|
+
//#endregion
|
|
1575
|
+
//#region src/cli.ts
|
|
1576
|
+
const VERSION = "0.1.0";
|
|
1577
|
+
/** What `aedis init` installs: config, theme tokens, cn and the security primitives. */
|
|
1578
|
+
const INIT_ITEMS = [
|
|
1579
|
+
"theme",
|
|
1580
|
+
"utils",
|
|
1581
|
+
"untrusted",
|
|
1582
|
+
"api",
|
|
1583
|
+
"security-headers"
|
|
1584
|
+
];
|
|
1585
|
+
const HELP = `aedis ${VERSION}: Aedis UI components with verified provenance
|
|
1586
|
+
|
|
1587
|
+
Usage
|
|
1588
|
+
npx @aedis/cli@${VERSION} init config, theme, cn and the security primitives
|
|
1589
|
+
npx @aedis/cli@${VERSION} init --preset <id> the same, plus the choices made on ui.aedis.build/create
|
|
1590
|
+
npx @aedis/cli@${VERSION} add <item...> add items and their dependencies
|
|
1591
|
+
npx @aedis/cli@${VERSION} diff [item...] compare local copies with the registry
|
|
1592
|
+
npx @aedis/cli@${VERSION} theme import <file.json>
|
|
1593
|
+
npx @aedis/cli@${VERSION} lint [registry.json] check a registry's items against the Aedis rules
|
|
1594
|
+
npx @aedis/cli@${VERSION} mcp read-only docs server for coding agents (MCP, stdio)
|
|
1595
|
+
|
|
1596
|
+
Options
|
|
1597
|
+
--release vX.Y.Z registry release (default: aedis.json, or v${VERSION} on init)
|
|
1598
|
+
--registry github:<owner>/<repo>@<tag>
|
|
1599
|
+
a third-party registry, verified like Aedis UI: Sigstore signature and SLSA
|
|
1600
|
+
provenance from that repository's release workflow, built there or by the
|
|
1601
|
+
Aedis platform's release workflow
|
|
1602
|
+
--registry <url> --allow-unverified
|
|
1603
|
+
any other registry, e.g. a local branch; refused unless you allow it
|
|
1604
|
+
--yes, -y do not ask before writing
|
|
1605
|
+
--overwrite replace local copies that differ from the registry
|
|
1606
|
+
--cwd <dir> project root (default: current directory)`;
|
|
1607
|
+
function parse(argv) {
|
|
1608
|
+
const flags = {
|
|
1609
|
+
yes: false,
|
|
1610
|
+
overwrite: false,
|
|
1611
|
+
allowUnverified: false
|
|
1612
|
+
};
|
|
1613
|
+
const rest = [];
|
|
1614
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1615
|
+
const a = argv[i];
|
|
1616
|
+
if (a === "--yes" || a === "-y") flags.yes = true;
|
|
1617
|
+
else if (a === "--overwrite") flags.overwrite = true;
|
|
1618
|
+
else if (a === "--allow-unverified") flags.allowUnverified = true;
|
|
1619
|
+
else if (a === "--registry" || a === "--release" || a === "--cwd" || a === "--preset") {
|
|
1620
|
+
const value = argv[++i];
|
|
1621
|
+
if (!value) throw new Error(`${a} needs a value`);
|
|
1622
|
+
flags[a.slice(2)] = value;
|
|
1623
|
+
} else if (a.startsWith("-")) throw new Error(`unknown option ${a}`);
|
|
1624
|
+
else rest.push(a);
|
|
1625
|
+
}
|
|
1626
|
+
return {
|
|
1627
|
+
flags,
|
|
1628
|
+
rest
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
async function source(config, flags, io) {
|
|
1632
|
+
if (flags.registry) {
|
|
1633
|
+
const github = githubRegistry(flags.registry);
|
|
1634
|
+
if (github) {
|
|
1635
|
+
io.log(`verifying ${github.repository} ${github.release}…`);
|
|
1636
|
+
return releaseSource(github.release, {
|
|
1637
|
+
fetcher: io.fetcher,
|
|
1638
|
+
check: io.check,
|
|
1639
|
+
repository: github.repository,
|
|
1640
|
+
namespace: github.namespace
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
if (!flags.allowUnverified) throw new Error(`refusing ${flags.registry}: it is not a signed release. Use github:<owner>/<repo>@<tag> for a verified registry, or pass --allow-unverified to install from it anyway`);
|
|
1644
|
+
io.log(`warning: ${flags.registry} is not a signed release; provenance is NOT verified.`);
|
|
1645
|
+
return urlSource(flags.registry, { fetcher: io.fetcher });
|
|
1646
|
+
}
|
|
1647
|
+
const release = flags.release ?? config.release;
|
|
1648
|
+
if (!isRelease(release)) throw new Error(`invalid release ${release}`);
|
|
1649
|
+
io.log(`verifying ${release}…`);
|
|
1650
|
+
return releaseSource(release, {
|
|
1651
|
+
fetcher: io.fetcher,
|
|
1652
|
+
check: io.check
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
async function add(names, config, flags, io, root) {
|
|
1656
|
+
if (!names.length) throw new Error("add: name at least one item, e.g. aedis add button");
|
|
1657
|
+
const from = await source(config, flags, io);
|
|
1658
|
+
const items = await resolveItems(names, from);
|
|
1659
|
+
const files = planFiles(items, config, root);
|
|
1660
|
+
const status = files.map((f) => {
|
|
1661
|
+
if (!existsSync(f.absolute)) return "create";
|
|
1662
|
+
return readFileSync(f.absolute, "utf8") === f.content ? "unchanged" : "differs";
|
|
1663
|
+
});
|
|
1664
|
+
const conflicts = files.filter((_f, i) => status[i] === "differs");
|
|
1665
|
+
if (conflicts.length && !flags.overwrite) {
|
|
1666
|
+
for (const f of conflicts) io.log(` local copy differs: ${f.relative}`);
|
|
1667
|
+
throw new Error("local copies differ from the registry: run aedis diff, send changes upstream, or pass --overwrite");
|
|
1668
|
+
}
|
|
1669
|
+
io.log(`from ${from.label}:`);
|
|
1670
|
+
files.forEach((f, i) => io.log(` ${status[i] === "create" ? "create " : status[i] === "differs" ? "overwrite" : "unchanged"} ${f.relative}`));
|
|
1671
|
+
const deps = [...new Set(items.flatMap((i) => i.dependencies))];
|
|
1672
|
+
const specs = deps.length ? await pickVersions(deps, {
|
|
1673
|
+
fetcher: io.fetcher,
|
|
1674
|
+
now: io.now
|
|
1675
|
+
}) : [];
|
|
1676
|
+
if (specs.length) io.log(`npm dependencies (exact, at least 7 days old): ${specs.join(" ")}`);
|
|
1677
|
+
if (!flags.yes && !await io.confirm("Write these files?")) {
|
|
1678
|
+
io.log("nothing written");
|
|
1679
|
+
return 1;
|
|
1680
|
+
}
|
|
1681
|
+
files.forEach((f, i) => {
|
|
1682
|
+
if (status[i] === "unchanged") return;
|
|
1683
|
+
mkdirSync(path.dirname(f.absolute), { recursive: true });
|
|
1684
|
+
writeFileSync(f.absolute, f.content);
|
|
1685
|
+
});
|
|
1686
|
+
if (specs.length) {
|
|
1687
|
+
const { command, args, env } = installCommand(detectPackageManager(root), specs);
|
|
1688
|
+
io.log(`$ ${command} ${args.join(" ")}`);
|
|
1689
|
+
const code = io.spawn(command, args, {
|
|
1690
|
+
cwd: root,
|
|
1691
|
+
env
|
|
1692
|
+
});
|
|
1693
|
+
if (code !== 0) throw new Error(`${command} exited with ${code}`);
|
|
1694
|
+
}
|
|
1695
|
+
io.log(`added ${items.map((i) => i.name).join(", ")}`);
|
|
1696
|
+
return 0;
|
|
1697
|
+
}
|
|
1698
|
+
async function diff(names, config, flags, io, root) {
|
|
1699
|
+
const from = await source(config, flags, io);
|
|
1700
|
+
const items = await resolveItems(names.length ? names : INIT_ITEMS, from);
|
|
1701
|
+
let changed = 0;
|
|
1702
|
+
for (const f of planFiles(items, config, root)) {
|
|
1703
|
+
if (!existsSync(f.absolute)) {
|
|
1704
|
+
if (names.length) io.log(`missing ${f.relative}`);
|
|
1705
|
+
continue;
|
|
1706
|
+
}
|
|
1707
|
+
const d = lineDiff(readFileSync(f.absolute, "utf8"), f.content);
|
|
1708
|
+
if (!d) continue;
|
|
1709
|
+
changed++;
|
|
1710
|
+
io.log(`--- ${f.relative} (local)\n+++ ${f.relative} (${from.label})\n${d}`);
|
|
1711
|
+
}
|
|
1712
|
+
io.log(changed ? `${changed} file(s) differ. Local copies are not meant to be edited in place: send the change upstream.` : "up to date");
|
|
1713
|
+
return changed ? 1 : 0;
|
|
1714
|
+
}
|
|
1715
|
+
/** Runs the CLI; returns the exit code. Every side effect goes through `io`, so it can be tested end to end. */
|
|
1716
|
+
async function run(argv, io) {
|
|
1717
|
+
try {
|
|
1718
|
+
if (argv.includes("--help") || argv.includes("-h")) return io.log(HELP), 0;
|
|
1719
|
+
if (argv.includes("--version")) return io.log(VERSION), 0;
|
|
1720
|
+
const { flags, rest } = parse(argv);
|
|
1721
|
+
const [command, ...args] = rest;
|
|
1722
|
+
if (!command || command === "help") return io.log(HELP), 0;
|
|
1723
|
+
if (command === "version") return io.log(VERSION), 0;
|
|
1724
|
+
if (command === "mcp") {
|
|
1725
|
+
if (!io.stdin || !io.stdout) throw new Error("mcp: needs stdin and stdout");
|
|
1726
|
+
await serve(io.stdin(), io.stdout, io.fetcher, VERSION);
|
|
1727
|
+
return 0;
|
|
1728
|
+
}
|
|
1729
|
+
const root = path.resolve(io.cwd, flags.cwd ?? ".");
|
|
1730
|
+
if (command === "lint") {
|
|
1731
|
+
const file = path.resolve(root, args[0] ?? "registry.json");
|
|
1732
|
+
const dir = path.dirname(file);
|
|
1733
|
+
const findings = lintRegistry(JSON.parse(readFileSync(file, "utf8")), (p) => readFileSync(insideProject(dir, p), "utf8"));
|
|
1734
|
+
for (const f of findings) io.log(` ${f.item} ${f.file}:${f.line} ${lintMessages[f.rule]} (${f.rule}): ${f.text}`);
|
|
1735
|
+
io.log(findings.length ? `${findings.length} finding(s); exempt an item only with a reason in meta.aedisLint` : "aedis lint: every item passes");
|
|
1736
|
+
return findings.length ? 1 : 0;
|
|
1737
|
+
}
|
|
1738
|
+
if (!existsSync(path.join(root, "package.json"))) throw new Error(`${root} has no package.json; run inside your app`);
|
|
1739
|
+
if (command === "init") {
|
|
1740
|
+
const config = readConfig(root) ?? defaultConfig(root, flags.release ?? `v0.1.0`);
|
|
1741
|
+
if (!isRelease(config.release)) throw new Error(`invalid release ${config.release}`);
|
|
1742
|
+
const decoded = flags.preset === void 0 ? null : decodePreset(flags.preset);
|
|
1743
|
+
if (decoded && !decoded.ok) throw new Error(`preset refused: ${decoded.error}`);
|
|
1744
|
+
const preset = decoded?.ok ? decoded.preset : null;
|
|
1745
|
+
if (preset) {
|
|
1746
|
+
const shown = {
|
|
1747
|
+
framework: presetChoices.framework[preset.framework],
|
|
1748
|
+
theme: presetChoices.theme[preset.theme],
|
|
1749
|
+
radius: presetChoices.radius[preset.radius],
|
|
1750
|
+
motion: presetChoices.motion[preset.motion],
|
|
1751
|
+
security: presetChoices.level[preset.level],
|
|
1752
|
+
blocks: preset.blocks.map((b) => presetBlocks[b].label)
|
|
1753
|
+
};
|
|
1754
|
+
io.log(`preset ${flags.preset}:
|
|
1755
|
+
${JSON.stringify(shown, null, 2)}`);
|
|
1756
|
+
}
|
|
1757
|
+
const code = await add([...INIT_ITEMS, ...preset ? presetItems(preset) : []], config, flags, io, root);
|
|
1758
|
+
const theme = code === 0 && preset ? presetTheme(preset) : null;
|
|
1759
|
+
if (theme) {
|
|
1760
|
+
const out = insideProject(root, `${config.paths.styles}/aedis-custom-theme.css`);
|
|
1761
|
+
mkdirSync(path.dirname(out), { recursive: true });
|
|
1762
|
+
writeFileSync(out, `/* Generated by aedis init --preset ${flags.preset}. Do not edit; run it again or use aedis theme import. */
|
|
1763
|
+
${themeToCss(theme)}
|
|
1764
|
+
`);
|
|
1765
|
+
io.log(`wrote ${path.relative(root, out)}; import it after aedis-theme.css`);
|
|
1766
|
+
}
|
|
1767
|
+
if (code === 0 && preset) io.log(`${presetChoices.level[preset.level]}: the platform adds ${presetLevels[preset.level].platform}`);
|
|
1768
|
+
if (code === 0 && !existsSync(path.join(root, "aedis.json"))) {
|
|
1769
|
+
writeFileSync(insideProject(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`);
|
|
1770
|
+
io.log(`wrote ${CONFIG_FILE} (release ${config.release}); import ${config.paths.styles}/aedis-theme.css after tailwindcss in your global CSS`);
|
|
1771
|
+
}
|
|
1772
|
+
return code;
|
|
1773
|
+
}
|
|
1774
|
+
if (command === "theme" && args[0] === "import") {
|
|
1775
|
+
if (!args[1]) throw new Error("theme import: give the theme's JSON file");
|
|
1776
|
+
const config = readConfig(root) ?? defaultConfig(root, `v0.1.0`);
|
|
1777
|
+
const input = insideProject(root, path.relative(root, path.resolve(io.cwd, args[1])));
|
|
1778
|
+
const result = themeImport(readFileSync(input, "utf8"), path.basename(input));
|
|
1779
|
+
if (!result.ok) {
|
|
1780
|
+
for (const e of result.errors) io.log(` ${e}`);
|
|
1781
|
+
throw new Error("theme refused");
|
|
1782
|
+
}
|
|
1783
|
+
const out = insideProject(root, `${config.paths.styles}/aedis-custom-theme.css`);
|
|
1784
|
+
mkdirSync(path.dirname(out), { recursive: true });
|
|
1785
|
+
writeFileSync(out, result.css);
|
|
1786
|
+
io.log(`wrote ${path.relative(root, out)} for "${result.name}"; import it after aedis-theme.css`);
|
|
1787
|
+
return 0;
|
|
1788
|
+
}
|
|
1789
|
+
const config = readConfig(root);
|
|
1790
|
+
if (!config) throw new Error(`no ${CONFIG_FILE}: run aedis init first`);
|
|
1791
|
+
if (command === "add") return await add(args, config, flags, io, root);
|
|
1792
|
+
if (command === "diff") return await diff(args, config, flags, io, root);
|
|
1793
|
+
throw new Error(`unknown command ${command}; see aedis --help`);
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
io.log(`error: ${error.message}`);
|
|
1796
|
+
return 1;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
//#endregion
|
|
1800
|
+
//#region src/main.ts
|
|
1801
|
+
const code = await run(process.argv.slice(2), {
|
|
1802
|
+
cwd: process.cwd(),
|
|
1803
|
+
fetcher: fetch,
|
|
1804
|
+
check: (bundle, options) => verify(bundle, options),
|
|
1805
|
+
spawn: (command, args, { cwd, env }) => spawnSync(command, args, {
|
|
1806
|
+
cwd,
|
|
1807
|
+
env: {
|
|
1808
|
+
...process.env,
|
|
1809
|
+
...env
|
|
1810
|
+
},
|
|
1811
|
+
stdio: "inherit",
|
|
1812
|
+
shell: process.platform === "win32"
|
|
1813
|
+
}).status ?? 1,
|
|
1814
|
+
confirm: async (question) => {
|
|
1815
|
+
if (!process.stdin.isTTY) return false;
|
|
1816
|
+
const rl = createInterface({
|
|
1817
|
+
input: process.stdin,
|
|
1818
|
+
output: process.stdout
|
|
1819
|
+
});
|
|
1820
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
1821
|
+
rl.close();
|
|
1822
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
1823
|
+
},
|
|
1824
|
+
log: (line) => console.log(line),
|
|
1825
|
+
stdin: () => createInterface({
|
|
1826
|
+
input: process.stdin,
|
|
1827
|
+
crlfDelay: Infinity
|
|
1828
|
+
}),
|
|
1829
|
+
stdout: (text) => process.stdout.write(text)
|
|
1830
|
+
});
|
|
1831
|
+
process.exitCode = code;
|
|
1832
|
+
//#endregion
|
|
1833
|
+
export {};
|