@monster-orange/den 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +1655 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +619 -3
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +9 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/agent-actor.ts +38 -3
  22. package/src/shared/auth/headless-login.ts +1 -1
  23. package/src/shared/auth/sudo-vault.ts +93 -31
  24. package/src/shared/auth/token-providers/index.ts +7 -3
  25. package/src/shared/brand.ts +7 -0
  26. package/src/shared/config-error.ts +63 -0
  27. package/src/shared/constants.ts +3 -24
  28. package/src/shared/den-config.ts +185 -7
  29. package/src/shared/edge/config.ts +147 -21
  30. package/src/shared/edge/exec.ts +31 -0
  31. package/src/shared/edge/included.ts +169 -0
  32. package/src/shared/edge/render.ts +69 -14
  33. package/src/shared/edge/runtime.ts +128 -121
  34. package/src/shared/edge/status.ts +68 -8
  35. package/src/shared/edge/tls.ts +328 -0
  36. package/src/shared/edge/types.ts +25 -9
  37. package/src/shared/environment-manager/catalog.ts +52 -1
  38. package/src/shared/environment-manager/default-workspace.ts +26 -3
  39. package/src/shared/environment-manager/git-remote.ts +49 -0
  40. package/src/shared/environment-manager/health.ts +73 -38
  41. package/src/shared/environment-manager/scope.ts +168 -0
  42. package/src/shared/environment-manager/service-runner.ts +789 -43
  43. package/src/shared/environment-manager/up-targets.ts +132 -0
  44. package/src/shared/environment-manager/workspace-manager.ts +67 -25
  45. package/src/shared/environment-manager/workspace-org.ts +26 -11
  46. package/src/shared/health/rollup.ts +48 -0
  47. package/src/shared/manifest/service-links.ts +20 -8
  48. package/src/shared/open-url.ts +21 -2
  49. package/src/shared/org-sources.ts +328 -0
  50. package/src/shared/org.ts +53 -5
  51. package/src/shared/plugins/manifest.ts +406 -0
  52. package/src/shared/plugins/store.ts +358 -0
  53. package/src/shared/process/legacy-adopt.ts +285 -0
  54. package/src/shared/process/process-group.ts +202 -0
  55. package/src/shared/process/spawn-streaming.ts +7 -0
  56. package/src/shared/secrets/doppler.ts +1 -1
  57. package/src/shared/secrets/index.ts +279 -50
  58. package/src/shared/secrets/none.ts +56 -0
  59. package/src/shared/secrets/strategies.ts +202 -0
  60. package/src/shared/secrets/types.ts +1 -1
  61. package/src/shared/smoke/git-remote.ts +40 -0
  62. package/src/shared/stacks/manifest-commands.ts +1 -1
  63. package/src/shared/stacks/namespace.ts +178 -0
  64. package/src/shared/stacks/owners.ts +303 -0
  65. package/src/shared/stacks/render.ts +8 -3
  66. package/src/shared/stacks/runtime.ts +100 -16
  67. package/src/shared/stacks/status.ts +63 -12
  68. package/src/shared/strategies/registry.ts +89 -0
  69. package/src/shared/theme/contract.ts +538 -0
  70. package/src/shared/types/index.ts +24 -5
  71. package/src/ui/cli/clone-plan.ts +154 -23
  72. package/src/ui/cli/create.ts +24 -7
  73. package/src/ui/cli/doctor-command.ts +131 -0
  74. package/src/ui/cli/edge-command.ts +56 -10
  75. package/src/ui/cli/flag-parser.ts +62 -3
  76. package/src/ui/cli/health-display.ts +25 -0
  77. package/src/ui/cli/index.ts +649 -72
  78. package/src/ui/cli/notice.ts +20 -0
  79. package/src/ui/cli/org-command.ts +12 -2
  80. package/src/ui/cli/plugin-command.ts +849 -0
  81. package/src/ui/cli/stack-command.ts +63 -2
  82. package/src/ui/cli/test-runner.ts +11 -9
  83. package/src/ui/cli/theme-command.ts +136 -0
  84. package/src/ui/cli/up-args.ts +15 -0
  85. package/src/ui/cli/web-command.ts +47 -23
  86. package/src/ui/mcp/api.ts +28 -2
  87. package/src/ui/mcp/scope.ts +298 -0
  88. package/src/ui/mcp/server.ts +70 -8
  89. package/src/ui/mcp/tools.ts +151 -24
  90. package/src/ui/server/appearance-prefs.ts +95 -0
  91. package/src/ui/server/audit.ts +90 -3
  92. package/src/ui/server/daemon-reaper.ts +29 -1
  93. package/src/ui/server/engine-stamp.ts +83 -0
  94. package/src/ui/server/github-service.ts +29 -45
  95. package/src/ui/server/graph-page.ts +20 -0
  96. package/src/ui/server/graph-prefs.ts +44 -1
  97. package/src/ui/server/http-errors.ts +293 -0
  98. package/src/ui/server/http-helpers.ts +22 -3
  99. package/src/ui/server/index.ts +590 -45
  100. package/src/ui/server/public/appearance.js +531 -0
  101. package/src/ui/server/public/edge-page.js +248 -0
  102. package/src/ui/server/public/edge.html +251 -0
  103. package/src/ui/server/public/environments.html +65 -83
  104. package/src/ui/server/public/error-page.js +247 -0
  105. package/src/ui/server/public/error.html +260 -0
  106. package/src/ui/server/public/graph-model.js +332 -14
  107. package/src/ui/server/public/graph.css +141 -47
  108. package/src/ui/server/public/graph.js +223 -44
  109. package/src/ui/server/public/header.js +396 -600
  110. package/src/ui/server/public/health-fit.js +248 -0
  111. package/src/ui/server/public/health-rollup.js +128 -0
  112. package/src/ui/server/public/i18n/es.js +287 -0
  113. package/src/ui/server/public/i18n/ja.js +276 -0
  114. package/src/ui/server/public/index.html +139 -438
  115. package/src/ui/server/public/local-dev.html +3468 -4044
  116. package/src/ui/server/public/logs.html +71 -208
  117. package/src/ui/server/public/panel.js +15 -3
  118. package/src/ui/server/public/release.html +175 -389
  119. package/src/ui/server/public/services.html +596 -770
  120. package/src/ui/server/public/settings-page.js +759 -0
  121. package/src/ui/server/public/settings-registry.js +521 -0
  122. package/src/ui/server/public/settings.html +590 -0
  123. package/src/ui/server/public/shell.js +3869 -0
  124. package/src/ui/server/public/topbar.js +1413 -0
  125. package/src/ui/server/public/version-label.js +12 -0
  126. package/src/ui/server/public/workspace-picker.js +74 -34
  127. package/src/ui/server/readonly-routes.ts +24 -4
  128. package/src/ui/server/release-service.ts +25 -3
  129. package/src/ui/server/services-list.ts +94 -17
  130. package/src/ui/server/settings-distribution.ts +74 -0
  131. package/src/ui/server/shell-prefs.ts +192 -0
  132. package/src/ui/server/theme-routes.ts +292 -0
  133. package/src/ui/server/theme-runtime.ts +407 -0
  134. package/src/ui/server/ui-assets.ts +16 -1
  135. package/src/ui/server/ui-language.ts +50 -0
  136. package/src/ui/server/workspace-routes.ts +55 -9
  137. package/themes/den/den-theme.json +72 -0
  138. package/themes/den/favicon.ico +0 -0
  139. package/themes/den/mark.svg +6 -0
@@ -0,0 +1,538 @@
1
+ /**
2
+ * The theme contract, den.theme/v1: a look and feel for the web UI, published
3
+ * in a git repo as `den-theme.json` (see schemas/den.theme.v1.json and the
4
+ * den-themes repo's README).
5
+ *
6
+ * validateThemeDir checks what `<cli> theme validate` and `theme add` check:
7
+ * the JSON Schema, the contrast rules below (both modes), the files the
8
+ * manifest names, and that the id matches the directory when asked.
9
+ */
10
+ import * as fs from "fs";
11
+ import * as path from "path";
12
+ import Ajv2020 from "ajv/dist/2020.js";
13
+ import addFormats from "ajv-formats";
14
+ import semver from "semver";
15
+ import { engineVersion, getEngineRoot } from "../paths.js";
16
+
17
+ export const THEME_SCHEMA_ID = "den.theme/v1";
18
+ export const THEME_FILE = "den-theme.json";
19
+
20
+ export type ThemeMode = "dark" | "light";
21
+ export type ColorToken =
22
+ | "bg"
23
+ | "surface"
24
+ | "raised"
25
+ | "sunken"
26
+ | "text"
27
+ | "muted"
28
+ | "faint"
29
+ | "border"
30
+ | "border-strong"
31
+ | "accent"
32
+ | "accent-text"
33
+ | "accent-fill"
34
+ | "on-accent"
35
+ | "ok"
36
+ | "warn"
37
+ | "bad"
38
+ | "remote";
39
+
40
+ export interface ThemeManifest {
41
+ schema: typeof THEME_SCHEMA_ID;
42
+ id: string;
43
+ name: string;
44
+ version: string;
45
+ license: string;
46
+ description?: string;
47
+ author?: string;
48
+ homepage?: string;
49
+ den?: string;
50
+ default_mode?: ThemeMode;
51
+ type?: { sans?: string; mono?: string; display?: string; ja?: string };
52
+ shape?: {
53
+ radius?: string;
54
+ "radius-button"?: string;
55
+ density?: "compact" | "comfortable";
56
+ };
57
+ mark?: {
58
+ svg?: string;
59
+ svg_dark?: string;
60
+ png?: string;
61
+ png_dark?: string;
62
+ webp?: string;
63
+ webp_dark?: string;
64
+ text?: string;
65
+ shape?: "square" | "rounded" | "circle";
66
+ };
67
+ /** The browser-tab icon (.png, .svg or .ico); else the mark's file. */
68
+ favicon?: string;
69
+ fonts?: {
70
+ family: string;
71
+ license: string;
72
+ files: { path: string; weight?: string; style?: "normal" | "italic" }[];
73
+ }[];
74
+ modes: Record<
75
+ ThemeMode,
76
+ Record<ColorToken, string> & { shadow?: string; bar?: string }
77
+ >;
78
+ }
79
+
80
+ /**
81
+ * [foreground, background, minimum ratio]: every text token on every ground
82
+ * at 4.5:1 (WCAG 1.4.3), and the non-text UI tokens on every ground at 3:1
83
+ * (1.4.11). faint is text too (captions, labels), so it is held to 4.5.
84
+ */
85
+ /** Every token Den draws text in. accent is never text (use accent-text). */
86
+ export const TEXT_TOKENS: readonly ColorToken[] = [
87
+ "text",
88
+ "muted",
89
+ "faint",
90
+ "accent-text",
91
+ "ok",
92
+ "warn",
93
+ "bad",
94
+ "remote",
95
+ ];
96
+ /** Every ground text and controls sit on. */
97
+ export const GROUNDS: readonly ColorToken[] = [
98
+ "bg",
99
+ "surface",
100
+ "raised",
101
+ "sunken",
102
+ ];
103
+
104
+ export const CONTRAST_RULES: readonly [ColorToken, ColorToken, number][] = [
105
+ ...TEXT_TOKENS.flatMap((fg) =>
106
+ GROUNDS.map((bg): [ColorToken, ColorToken, number] => [fg, bg, 4.5]),
107
+ ),
108
+ ["on-accent", "accent-fill", 4.5],
109
+ // Non-text UI: focus rings, selection, control borders (WCAG 1.4.11).
110
+ ...(["accent", "border-strong"] as const).flatMap((fg) =>
111
+ GROUNDS.map((bg): [ColorToken, ColorToken, number] => [fg, bg, 3]),
112
+ ),
113
+ ];
114
+
115
+ /** WCAG relative luminance of #rrggbb. */
116
+ function luminance(hex: string): number {
117
+ const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
118
+ const [r, g, b] = c.map((v) =>
119
+ v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4,
120
+ );
121
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
122
+ }
123
+
124
+ /** WCAG contrast ratio of two #rrggbb colors (1–21). */
125
+ export function contrastRatio(a: string, b: string): number {
126
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
127
+ return (hi + 0.05) / (lo + 0.05);
128
+ }
129
+
130
+ export interface ContrastFailure {
131
+ mode: ThemeMode;
132
+ fg: ColorToken;
133
+ bg: ColorToken;
134
+ ratio: number;
135
+ min: number;
136
+ }
137
+
138
+ export function checkContrast(theme: ThemeManifest): ContrastFailure[] {
139
+ const out: ContrastFailure[] = [];
140
+ for (const mode of ["dark", "light"] as const) {
141
+ const t = theme.modes[mode];
142
+ for (const [fg, bg, min] of CONTRAST_RULES) {
143
+ const ratio = contrastRatio(t[fg], t[bg]);
144
+ // Unrounded, as the den-themes validator: 4.49 fails 4.5.
145
+ if (ratio < min)
146
+ out.push({ mode, fg, bg, ratio: Math.floor(ratio * 100) / 100, min });
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+
152
+ export interface ThemeIssue {
153
+ /** JSON pointer into den-theme.json, or "" for the file itself. */
154
+ at: string;
155
+ message: string;
156
+ }
157
+
158
+ export interface ThemeValidation {
159
+ ok: boolean;
160
+ theme?: ThemeManifest;
161
+ issues: ThemeIssue[];
162
+ contrast: ContrastFailure[];
163
+ }
164
+
165
+ let validator: ReturnType<InstanceType<typeof Ajv2020>["compile"]> | null =
166
+ null;
167
+
168
+ function schemaValidator() {
169
+ if (validator) return validator;
170
+ const schema = JSON.parse(
171
+ fs.readFileSync(
172
+ path.join(getEngineRoot(), "schemas", "den.theme.v1.json"),
173
+ "utf-8",
174
+ ),
175
+ ) as object;
176
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
177
+ addFormats(ajv);
178
+ validator = ajv.compile(schema);
179
+ return validator;
180
+ }
181
+
182
+ /**
183
+ * Why an SVG mark is unsafe, or null. Den shows it with <img>, which runs
184
+ * nothing, but the file is also opened directly: refuse script, handlers,
185
+ * foreign HTML, entities and anything fetched from elsewhere.
186
+ */
187
+ export function unsafeSvg(svg: string): string | null {
188
+ if (/<script/i.test(svg)) return "has a <script>";
189
+ if (/<foreignObject/i.test(svg)) return "has a <foreignObject>";
190
+ if (/<!ENTITY|<!DOCTYPE/i.test(svg)) return "declares entities";
191
+ if (/\son\w+\s*=/i.test(svg)) return "has an event handler";
192
+ if (/(?:xlink:)?href\s*=\s*["'](?!#)/i.test(svg))
193
+ return "references an external resource (href must start with #)";
194
+ if (/url\(\s*["']?(?!#)/i.test(svg))
195
+ return "references an external resource (url() must start with #)";
196
+ return null;
197
+ }
198
+
199
+ /** The image kinds a mark can be drawn from: one per mark. */
200
+ export const MARK_IMAGE_KINDS = ["svg", "png", "webp"] as const;
201
+ export type MarkImageKind = (typeof MARK_IMAGE_KINDS)[number];
202
+
203
+ /** The image kinds a mark names (svg, png, webp; a dark variant counts). */
204
+ export function markImageKinds(
205
+ mark: ThemeManifest["mark"] | undefined,
206
+ ): MarkImageKind[] {
207
+ const m = mark ?? {};
208
+ return MARK_IMAGE_KINDS.filter((k) => m[k] || m[`${k}_dark`]);
209
+ }
210
+
211
+ /**
212
+ * The mark's image file for a mode: the dark variant in dark mode when the
213
+ * mark has one, else the base file; null for a text mark (or none).
214
+ */
215
+ export function markImage(
216
+ mark: ThemeManifest["mark"] | undefined,
217
+ mode: ThemeMode,
218
+ ): { kind: MarkImageKind; file: string } | null {
219
+ const m = mark ?? {};
220
+ for (const kind of MARK_IMAGE_KINDS) {
221
+ const base = m[kind];
222
+ if (!base) continue;
223
+ const dark = m[`${kind}_dark`];
224
+ return { kind, file: mode === "dark" && dark ? dark : base };
225
+ }
226
+ return null;
227
+ }
228
+
229
+ /** The largest raster mark or favicon file, in bytes. */
230
+ export const IMAGE_MAX_BYTES = 64 * 1024;
231
+ /** A raster mark's smallest side: 2× the top bar's 22px mark. */
232
+ export const RASTER_MIN_PX = 44;
233
+
234
+ const PNG_SIGNATURE = Buffer.from([
235
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
236
+ ]);
237
+
238
+ /**
239
+ * A PNG's or a WebP's size in pixels, from its own header (PNG IHDR; WebP
240
+ * VP8, VP8L or VP8X), or null when `buf` is not that format.
241
+ */
242
+ export function rasterSize(
243
+ buf: Buffer,
244
+ format: "png" | "webp",
245
+ ): { width: number; height: number } | null {
246
+ if (format === "png") {
247
+ if (buf.length < 24 || !buf.subarray(0, 8).equals(PNG_SIGNATURE))
248
+ return null;
249
+ if (buf.toString("latin1", 12, 16) !== "IHDR") return null;
250
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
251
+ }
252
+ if (
253
+ buf.length < 30 ||
254
+ buf.toString("latin1", 0, 4) !== "RIFF" ||
255
+ buf.toString("latin1", 8, 12) !== "WEBP"
256
+ )
257
+ return null;
258
+ const chunk = buf.toString("latin1", 12, 16);
259
+ if (chunk === "VP8X")
260
+ return {
261
+ width: buf.readUIntLE(24, 3) + 1,
262
+ height: buf.readUIntLE(27, 3) + 1,
263
+ };
264
+ if (chunk === "VP8L") {
265
+ if (buf[20] !== 0x2f) return null;
266
+ const b = buf.readUInt32LE(21);
267
+ return { width: (b & 0x3fff) + 1, height: ((b >>> 14) & 0x3fff) + 1 };
268
+ }
269
+ if (chunk === "VP8 ") {
270
+ if (buf[23] !== 0x9d || buf[24] !== 0x01 || buf[25] !== 0x2a) return null;
271
+ return {
272
+ width: buf.readUInt16LE(26) & 0x3fff,
273
+ height: buf.readUInt16LE(28) & 0x3fff,
274
+ };
275
+ }
276
+ return null;
277
+ }
278
+
279
+ /** Whether `buf` starts as an .ico file does (00 00 01 00). */
280
+ function isIco(buf: Buffer): boolean {
281
+ return (
282
+ buf.length >= 6 &&
283
+ buf[0] === 0 &&
284
+ buf[1] === 0 &&
285
+ buf[2] === 1 &&
286
+ buf[3] === 0
287
+ );
288
+ }
289
+
290
+ /**
291
+ * What is wrong with a raster mark or favicon file, as messages (empty when
292
+ * nothing): its first bytes must be its extension's format, it is at most
293
+ * 64 KB, and a raster mark is at least 44×44 px.
294
+ */
295
+ export function imageFileIssues(
296
+ rel: string,
297
+ buf: Buffer,
298
+ opts: { minPx?: number } = {},
299
+ ): string[] {
300
+ const ext = path.extname(rel).toLowerCase();
301
+ const out: string[] = [];
302
+ if (buf.length > IMAGE_MAX_BYTES)
303
+ out.push(
304
+ `${rel} is ${Math.ceil(buf.length / 1024)} KB: a mark or favicon file is at most ${IMAGE_MAX_BYTES / 1024} KB`,
305
+ );
306
+ if (ext === ".ico") {
307
+ if (!isIco(buf))
308
+ out.push(`${rel} is not an ICO file (it does not start 00 00 01 00)`);
309
+ return out;
310
+ }
311
+ const format = ext === ".png" ? "png" : ext === ".webp" ? "webp" : null;
312
+ if (!format) return out;
313
+ const size = rasterSize(buf, format);
314
+ if (!size) {
315
+ out.push(
316
+ format === "png"
317
+ ? `${rel} is not a PNG file (it does not start with the PNG signature 89 50 4E 47 0D 0A 1A 0A)`
318
+ : `${rel} is not a WebP file (it does not start RIFF….WEBP with a VP8, VP8L or VP8X header)`,
319
+ );
320
+ return out;
321
+ }
322
+ const min = opts.minPx ?? 0;
323
+ if (size.width < min || size.height < min)
324
+ out.push(
325
+ `${rel} is ${size.width}×${size.height} px: a raster mark is at least ${min}×${min} px (2× the 22px bar mark)`,
326
+ );
327
+ return out;
328
+ }
329
+
330
+ /** Every file the manifest references, relative to the theme's directory. */
331
+ export function themeFiles(theme: ThemeManifest): string[] {
332
+ return [
333
+ theme.mark?.svg,
334
+ theme.mark?.svg_dark,
335
+ theme.mark?.png,
336
+ theme.mark?.png_dark,
337
+ theme.mark?.webp,
338
+ theme.mark?.webp_dark,
339
+ theme.favicon,
340
+ ...(theme.fonts ?? []).flatMap((f) => f.files.map((x) => x.path)),
341
+ ].filter((p): p is string => !!p);
342
+ }
343
+
344
+ const SPDX_MESSAGE =
345
+ "license must be an SPDX expression (e.g. MIT, OFL-1.1, LicenseRef-x); UNLICENSED, NONE and NOASSERTION are refused";
346
+ const CSS_MESSAGE =
347
+ "must be a plain CSS value: no ; { } < > or backslash, url(), @import or expression()";
348
+
349
+ /**
350
+ * Say what a rule means where the schema error would not: a `not` clause
351
+ * reports only "must NOT be valid", and the SPDX pattern is unreadable.
352
+ */
353
+ export function friendlyMessage(e: {
354
+ instancePath: string;
355
+ keyword: string;
356
+ }): string | null {
357
+ const license = /\/license$/.test(e.instancePath);
358
+ if (license && (e.keyword === "not" || e.keyword === "pattern"))
359
+ return SPDX_MESSAGE;
360
+ if (e.keyword === "not") return CSS_MESSAGE;
361
+ return null;
362
+ }
363
+
364
+ type SchemaError = {
365
+ instancePath: string;
366
+ keyword: string;
367
+ message?: string;
368
+ params?: Record<string, unknown>;
369
+ };
370
+
371
+ /**
372
+ * Schema errors as issues, with the friendly messages above. `extra` says
373
+ * what another schema's own rules mean (den.plugin/v1's capabilities); the
374
+ * plugin schema shares the SPDX rule, so it shares this too.
375
+ */
376
+ export function schemaIssues(
377
+ errors: SchemaError[] | null | undefined,
378
+ extra: (e: SchemaError) => string | null = () => null,
379
+ ): ThemeIssue[] {
380
+ return (errors ?? []).map((e) => ({
381
+ at: e.instancePath,
382
+ message:
383
+ extra(e) ??
384
+ friendlyMessage(e) ??
385
+ `${e.message ?? "invalid"}${
386
+ e.params && "additionalProperty" in e.params
387
+ ? ` (${String(e.params.additionalProperty)})`
388
+ : ""
389
+ }`,
390
+ }));
391
+ }
392
+
393
+ /**
394
+ * Why a manifest's `den` range excludes the running Den, or null. A range
395
+ * that is not valid semver is an error too. Prereleases count: 0.3.0-dev.7
396
+ * satisfies ">=0.3.0-0 <0.4.0" but not ">=0.3.0", which gets a hint
397
+ * (PL-SPEC d4: validate fails, load skips).
398
+ */
399
+ export function denRangeIssue(
400
+ range: string | undefined,
401
+ version: string | undefined = engineVersion(),
402
+ ): string | null {
403
+ if (range === undefined) return null;
404
+ if (!semver.validRange(range))
405
+ return `den range "${range}" is not a semver range`;
406
+ if (!version) return null;
407
+ if (semver.satisfies(version, range, { includePrerelease: true }))
408
+ return null;
409
+ // A prerelease Den that misses only for being a prerelease (>=0.3.0 on
410
+ // 0.3.0-dev.9): say how to write the range, from its own lower bound.
411
+ const release = semver.prerelease(version)
412
+ ? `${semver.major(version)}.${semver.minor(version)}.${semver.patch(version)}`
413
+ : null;
414
+ const hint =
415
+ release && semver.satisfies(release, range)
416
+ ? ` (prerelease builds need a -0 range, e.g. >=${semver.minVersion(range)?.version ?? release}-0)`
417
+ : "";
418
+ return `needs Den ${range}; this is ${version}${hint}`;
419
+ }
420
+
421
+ /**
422
+ * Validate the theme in `dir`. With `expectId`, the manifest's id must match
423
+ * (a repo of several themes keeps each under themes/<id>/).
424
+ */
425
+ export function validateThemeDir(
426
+ dir: string,
427
+ opts: { expectId?: string; denVersion?: string } = {},
428
+ ): ThemeValidation {
429
+ const file = path.join(dir, THEME_FILE);
430
+ const fail = (message: string): ThemeValidation => ({
431
+ ok: false,
432
+ issues: [{ at: "", message }],
433
+ contrast: [],
434
+ });
435
+ let raw: unknown;
436
+ try {
437
+ raw = JSON.parse(fs.readFileSync(file, "utf-8"));
438
+ } catch (e) {
439
+ return fail(
440
+ fs.existsSync(file)
441
+ ? `${THEME_FILE} is not valid JSON: ${(e as Error).message}`
442
+ : `no ${THEME_FILE} in ${dir}`,
443
+ );
444
+ }
445
+ const validate = schemaValidator();
446
+ if (!validate(raw)) {
447
+ return {
448
+ ok: false,
449
+ issues: schemaIssues(validate.errors),
450
+ contrast: [],
451
+ };
452
+ }
453
+ const theme = raw as ThemeManifest;
454
+ const issues: ThemeIssue[] = [];
455
+ if (opts.expectId && theme.id !== opts.expectId)
456
+ issues.push({
457
+ at: "/id",
458
+ message: `id "${theme.id}" does not match its directory "${opts.expectId}"`,
459
+ });
460
+ const kinds = markImageKinds(theme.mark);
461
+ if (kinds.length > 1)
462
+ issues.push({
463
+ at: "/mark",
464
+ message: `mark gives ${kinds.join(" and ")}: a mark is one image (svg, png or webp), or text`,
465
+ });
466
+ for (const k of ["png", "webp"] as const)
467
+ if (theme.mark?.[`${k}_dark`] && !theme.mark[k])
468
+ issues.push({
469
+ at: `/mark/${k}_dark`,
470
+ message: `mark.${k}_dark needs mark.${k} (the base file, for light mode)`,
471
+ });
472
+ const rasterMarks = new Set(
473
+ [
474
+ theme.mark?.png,
475
+ theme.mark?.png_dark,
476
+ theme.mark?.webp,
477
+ theme.mark?.webp_dark,
478
+ ].filter(Boolean),
479
+ );
480
+ const root = path.resolve(dir);
481
+ for (const rel of themeFiles(theme)) {
482
+ const abs = path.resolve(root, rel);
483
+ if (!abs.startsWith(root + path.sep))
484
+ issues.push({ at: "", message: `${rel} is outside the theme` });
485
+ else if (!fs.existsSync(abs))
486
+ issues.push({ at: "", message: `${rel} is missing` });
487
+ else if (rel.endsWith(".svg")) {
488
+ const bad = unsafeSvg(fs.readFileSync(abs, "utf-8"));
489
+ if (bad)
490
+ issues.push({
491
+ at: "",
492
+ message: `${rel} ${bad}: a mark is an image only`,
493
+ });
494
+ if (rel === theme.favicon && fs.statSync(abs).size > IMAGE_MAX_BYTES)
495
+ issues.push(
496
+ ...imageFileIssues(rel, fs.readFileSync(abs)).map((message) => ({
497
+ at: "",
498
+ message,
499
+ })),
500
+ );
501
+ } else if (/\.(png|webp|ico)$/.test(rel)) {
502
+ // A raster mark or favicon: its format by its first bytes, its size,
503
+ // and (a mark) its pixels, read from its own header.
504
+ for (const message of imageFileIssues(rel, fs.readFileSync(abs), {
505
+ minPx: rasterMarks.has(rel) ? RASTER_MIN_PX : 0,
506
+ }))
507
+ issues.push({ at: "", message });
508
+ } else if (rel.endsWith(".woff2")) {
509
+ const head = Buffer.alloc(4);
510
+ const fd = fs.openSync(abs, "r");
511
+ fs.readSync(fd, head, 0, 4, 0);
512
+ fs.closeSync(fd);
513
+ if (head.toString("latin1") !== "wOF2")
514
+ issues.push({ at: "", message: `${rel} is not a woff2 file` });
515
+ if (
516
+ !fs
517
+ .readdirSync(path.dirname(abs))
518
+ .some((f) => /^(OFL|LICEN[CS]E|COPYING)(\.(txt|md))?$/i.test(f))
519
+ )
520
+ issues.push({
521
+ at: "",
522
+ message: `${rel}: no OFL.txt or LICENSE beside it`,
523
+ });
524
+ }
525
+ }
526
+ for (const f of theme.fonts ?? [])
527
+ if (!f.license.trim())
528
+ issues.push({ at: "/fonts", message: `${f.family}: no license` });
529
+ const range = denRangeIssue(theme.den, opts.denVersion);
530
+ if (range) issues.push({ at: "/den", message: range });
531
+ const contrast = checkContrast(theme);
532
+ return {
533
+ ok: issues.length === 0 && contrast.length === 0,
534
+ theme,
535
+ issues,
536
+ contrast,
537
+ };
538
+ }
@@ -228,10 +228,12 @@ export type ServiceHealthConfig =
228
228
  ));
229
229
 
230
230
  /**
231
- * A command can be a plain shell string (detached by default) or an object
232
- * that explicitly controls behaviour:
231
+ * A command can be a plain shell string or an object that explicitly
232
+ * controls behaviour. up and restart detach by default; every other command
233
+ * runs to completion unless it says `detachable: true` (0.3.0-dev.40):
233
234
  *
234
- * up: make up # string — detached: true (default)
235
+ * up: make up # string — detached (up and restart)
236
+ * preflight: make check # string — runs to completion
235
237
  * seed:
236
238
  * cmd: make db-seed
237
239
  * detachable: false # wait for exit, stream output until done
@@ -622,8 +624,18 @@ export interface DiscoveredRepo {
622
624
  // ── Service catalog & workspace types ────────────────────────────────────────
623
625
 
624
626
  export interface ServiceCatalogEntry {
625
- /** Full GitHub slug: "Globex-Corp/repo-name" — used when cloning managed repos */
626
- gh: string;
627
+ /**
628
+ * Full GitHub slug: "Globex-Corp/repo-name" — used when cloning managed
629
+ * repos. GitHub shorthand: a repo on another host (or none) sets `remote`.
630
+ */
631
+ gh?: string;
632
+ /**
633
+ * The repo's git URL, for any host (ORG-1d): `https://…`, `ssh://…`,
634
+ * `git@host:owner/repo.git`, `file://…` or an absolute path. A repo whose
635
+ * org has no source that claims it clones and pulls from here through
636
+ * plain git and the user's own credential helper.
637
+ */
638
+ remote?: string;
627
639
  }
628
640
 
629
641
  /** Keyed by GH repo name (e.g. "shop", "tasks-runner") */
@@ -693,6 +705,13 @@ export interface WorkspaceConfig {
693
705
  * resolved org is computed on read (see workspace-org.ts).
694
706
  */
695
707
  org?: string;
708
+ /**
709
+ * The Compose project suffix this workspace's stacks run under, in place of
710
+ * its own name (`default`: `main`). Workspaces that set the same value
711
+ * share their stacks' projects, and so their volumes and data. Wins over
712
+ * the blueprint's `stack_namespace`. See stacks/namespace.ts.
713
+ */
714
+ stack_namespace?: string;
696
715
  }
697
716
 
698
717
  export interface WorkspacesFile {