@monster-orange/den 0.2.8 → 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 (127) hide show
  1. package/CHANGELOG.md +1547 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +507 -4
  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/auth/headless-login.ts +1 -1
  22. package/src/shared/auth/sudo-vault.ts +93 -31
  23. package/src/shared/auth/token-providers/index.ts +7 -3
  24. package/src/shared/brand.ts +7 -0
  25. package/src/shared/config-error.ts +33 -3
  26. package/src/shared/constants.ts +3 -24
  27. package/src/shared/den-config.ts +141 -5
  28. package/src/shared/edge/config.ts +147 -21
  29. package/src/shared/edge/exec.ts +31 -0
  30. package/src/shared/edge/included.ts +169 -0
  31. package/src/shared/edge/render.ts +69 -14
  32. package/src/shared/edge/runtime.ts +128 -121
  33. package/src/shared/edge/status.ts +68 -8
  34. package/src/shared/edge/tls.ts +328 -0
  35. package/src/shared/edge/types.ts +25 -9
  36. package/src/shared/environment-manager/default-workspace.ts +16 -1
  37. package/src/shared/environment-manager/health.ts +73 -38
  38. package/src/shared/environment-manager/scope.ts +168 -0
  39. package/src/shared/environment-manager/service-runner.ts +789 -43
  40. package/src/shared/environment-manager/up-targets.ts +132 -0
  41. package/src/shared/health/rollup.ts +48 -0
  42. package/src/shared/open-url.ts +21 -2
  43. package/src/shared/org-sources.ts +78 -59
  44. package/src/shared/org.ts +53 -5
  45. package/src/shared/plugins/manifest.ts +406 -0
  46. package/src/shared/plugins/store.ts +358 -0
  47. package/src/shared/process/legacy-adopt.ts +285 -0
  48. package/src/shared/process/process-group.ts +202 -0
  49. package/src/shared/process/spawn-streaming.ts +7 -0
  50. package/src/shared/secrets/doppler.ts +1 -1
  51. package/src/shared/secrets/index.ts +279 -50
  52. package/src/shared/secrets/none.ts +56 -0
  53. package/src/shared/secrets/strategies.ts +202 -0
  54. package/src/shared/secrets/types.ts +1 -1
  55. package/src/shared/smoke/git-remote.ts +40 -0
  56. package/src/shared/stacks/manifest-commands.ts +1 -1
  57. package/src/shared/stacks/namespace.ts +178 -0
  58. package/src/shared/stacks/owners.ts +303 -0
  59. package/src/shared/stacks/render.ts +8 -3
  60. package/src/shared/stacks/runtime.ts +100 -16
  61. package/src/shared/stacks/status.ts +63 -12
  62. package/src/shared/strategies/registry.ts +89 -0
  63. package/src/shared/theme/contract.ts +538 -0
  64. package/src/shared/types/index.ts +12 -3
  65. package/src/ui/cli/create.ts +24 -7
  66. package/src/ui/cli/doctor-command.ts +131 -0
  67. package/src/ui/cli/edge-command.ts +56 -10
  68. package/src/ui/cli/flag-parser.ts +7 -2
  69. package/src/ui/cli/health-display.ts +25 -0
  70. package/src/ui/cli/index.ts +548 -60
  71. package/src/ui/cli/notice.ts +20 -0
  72. package/src/ui/cli/plugin-command.ts +849 -0
  73. package/src/ui/cli/stack-command.ts +63 -2
  74. package/src/ui/cli/test-runner.ts +11 -9
  75. package/src/ui/cli/theme-command.ts +136 -0
  76. package/src/ui/cli/web-command.ts +47 -23
  77. package/src/ui/mcp/api.ts +28 -2
  78. package/src/ui/mcp/scope.ts +22 -1
  79. package/src/ui/mcp/tools.ts +50 -7
  80. package/src/ui/server/appearance-prefs.ts +95 -0
  81. package/src/ui/server/daemon-reaper.ts +29 -1
  82. package/src/ui/server/engine-stamp.ts +83 -0
  83. package/src/ui/server/github-service.ts +23 -44
  84. package/src/ui/server/graph-page.ts +20 -0
  85. package/src/ui/server/graph-prefs.ts +44 -1
  86. package/src/ui/server/http-errors.ts +293 -0
  87. package/src/ui/server/http-helpers.ts +22 -3
  88. package/src/ui/server/index.ts +532 -51
  89. package/src/ui/server/public/appearance.js +531 -0
  90. package/src/ui/server/public/edge-page.js +248 -0
  91. package/src/ui/server/public/edge.html +251 -0
  92. package/src/ui/server/public/environments.html +65 -83
  93. package/src/ui/server/public/error-page.js +247 -0
  94. package/src/ui/server/public/error.html +260 -0
  95. package/src/ui/server/public/graph-model.js +332 -14
  96. package/src/ui/server/public/graph.css +141 -47
  97. package/src/ui/server/public/graph.js +223 -44
  98. package/src/ui/server/public/header.js +396 -600
  99. package/src/ui/server/public/health-fit.js +248 -0
  100. package/src/ui/server/public/health-rollup.js +128 -0
  101. package/src/ui/server/public/i18n/es.js +287 -0
  102. package/src/ui/server/public/i18n/ja.js +276 -0
  103. package/src/ui/server/public/index.html +139 -438
  104. package/src/ui/server/public/local-dev.html +3468 -4044
  105. package/src/ui/server/public/logs.html +71 -208
  106. package/src/ui/server/public/panel.js +14 -2
  107. package/src/ui/server/public/release.html +175 -389
  108. package/src/ui/server/public/services.html +596 -770
  109. package/src/ui/server/public/settings-page.js +759 -0
  110. package/src/ui/server/public/settings-registry.js +521 -0
  111. package/src/ui/server/public/settings.html +590 -0
  112. package/src/ui/server/public/shell.js +3869 -0
  113. package/src/ui/server/public/topbar.js +1413 -0
  114. package/src/ui/server/public/version-label.js +12 -0
  115. package/src/ui/server/public/workspace-picker.js +74 -34
  116. package/src/ui/server/readonly-routes.ts +12 -1
  117. package/src/ui/server/services-list.ts +94 -17
  118. package/src/ui/server/settings-distribution.ts +74 -0
  119. package/src/ui/server/shell-prefs.ts +192 -0
  120. package/src/ui/server/theme-routes.ts +292 -0
  121. package/src/ui/server/theme-runtime.ts +407 -0
  122. package/src/ui/server/ui-assets.ts +16 -1
  123. package/src/ui/server/ui-language.ts +50 -0
  124. package/src/ui/server/workspace-routes.ts +45 -3
  125. package/themes/den/den-theme.json +72 -0
  126. package/themes/den/favicon.ico +0 -0
  127. 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
@@ -703,6 +705,13 @@ export interface WorkspaceConfig {
703
705
  * resolved org is computed on read (see workspace-org.ts).
704
706
  */
705
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;
706
715
  }
707
716
 
708
717
  export interface WorkspacesFile {
@@ -146,17 +146,26 @@ export function renderDistribution(
146
146
  ...bannerLines(title).map((l) => ` - "${l}"`),
147
147
  "",
148
148
  "# The GitHub org (or user) that owns the repos Den clones and lists.",
149
- "github_org: your-github-org",
149
+ "# Unset, Den lists no GitHub repos (and never calls gh for them). E.g.",
150
+ "# github_org: example-org",
150
151
  "",
151
- "# Secrets backend: env reads plain environment variables; doppler and",
152
- "# 1password are the others.",
152
+ "# Secrets backend: none (this distribution needs no secrets store), or",
153
+ "# env (plain environment variables), doppler or 1password. A backend's",
154
+ "# refs say where Den's own features find their secrets, e.g.",
155
+ `# refs: { sudo: { key: ${prefix}_LOCAL_ADMIN_PWD } }`,
153
156
  "secrets:",
154
- " provider: env",
157
+ " provider: none",
155
158
  "",
156
- "# This instance's HTTPS edge: https://<service>.<domain>.",
159
+ "# This instance's edge: <service>.<domain>, one Traefik for its routes.",
157
160
  "edge:",
158
161
  ` org: ${name}`,
159
162
  ` domain: ${name}.localhost`,
163
+ " # TLS: none serves http only. For https, pick a strategy:",
164
+ " # tls: { strategy: mkcert } # a local CA, per machine",
165
+ " # tls: { strategy: mkcert, ca: ~/path/to/ca } # an existing mkcert CA",
166
+ " # tls: { strategy: files, include: edge/dynamic, mounts: { <host>: <container> } }",
167
+ " # # your own certs, in included Traefik config",
168
+ " tls: none",
160
169
  "",
161
170
  "environments: [local]",
162
171
  "",
@@ -344,13 +353,21 @@ export function runCreate(argv: string[], cwd: string): number {
344
353
  denVersion: runningDenVersion(),
345
354
  });
346
355
  const cli = args.cli ?? args.name;
347
- const rel = path.relative(cwd, args.dir) || ".";
356
+ // Relative only when the new directory is under the caller's cwd; a
357
+ // path that climbs out (../../..) is harder to read than the absolute one.
358
+ const rel = path.relative(cwd, args.dir);
359
+ const cdPath =
360
+ rel === ""
361
+ ? "."
362
+ : rel.startsWith("..") || path.isAbsolute(rel)
363
+ ? args.dir
364
+ : rel;
348
365
  console.log(
349
366
  `Created ${args.name} in ${args.dir} (${plural(written.length, "file")}).`,
350
367
  );
351
368
  console.log("");
352
369
  console.log("Next:");
353
- console.log(` cd ${rel}`);
370
+ console.log(` cd ${cdPath}`);
354
371
  console.log(" npm install");
355
372
  console.log(` ./bin/${cli} --help`);
356
373
  return 0;