@monster-orange/den 0.2.8 → 0.4.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 (158) hide show
  1. package/CHANGELOG.md +1731 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +730 -6
  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 +12 -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/agents/default-pack/be-dev.md +20 -0
  22. package/src/shared/agents/default-pack/devops.md +14 -0
  23. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  24. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  25. package/src/shared/agents/default-pack/planner.md +17 -0
  26. package/src/shared/agents/default-pack/reviewer.md +14 -0
  27. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  28. package/src/shared/agents/den-claude.ts +173 -0
  29. package/src/shared/agents/generic.ts +52 -0
  30. package/src/shared/agents/personas.ts +218 -0
  31. package/src/shared/agents/records.ts +118 -0
  32. package/src/shared/agents/runtime.ts +192 -0
  33. package/src/shared/agents/runtimes.ts +47 -0
  34. package/src/shared/auth/headless-login.ts +1 -1
  35. package/src/shared/auth/sudo-vault.ts +93 -31
  36. package/src/shared/auth/token-providers/index.ts +7 -3
  37. package/src/shared/brand.ts +7 -0
  38. package/src/shared/config-error.ts +33 -3
  39. package/src/shared/constants.ts +3 -24
  40. package/src/shared/den-config.ts +257 -5
  41. package/src/shared/edge/config.ts +147 -21
  42. package/src/shared/edge/exec.ts +31 -0
  43. package/src/shared/edge/included.ts +169 -0
  44. package/src/shared/edge/render.ts +69 -14
  45. package/src/shared/edge/runtime.ts +128 -121
  46. package/src/shared/edge/status.ts +68 -8
  47. package/src/shared/edge/tls.ts +328 -0
  48. package/src/shared/edge/types.ts +25 -9
  49. package/src/shared/engine.ts +23 -0
  50. package/src/shared/environment-manager/default-workspace.ts +16 -1
  51. package/src/shared/environment-manager/health.ts +73 -38
  52. package/src/shared/environment-manager/scope.ts +168 -0
  53. package/src/shared/environment-manager/service-runner.ts +789 -43
  54. package/src/shared/environment-manager/up-targets.ts +132 -0
  55. package/src/shared/environment-manager/workspaces.ts +19 -0
  56. package/src/shared/health/rollup.ts +48 -0
  57. package/src/shared/open-url.ts +21 -2
  58. package/src/shared/org-sources.ts +78 -59
  59. package/src/shared/org.ts +53 -5
  60. package/src/shared/plugins/manifest.ts +406 -0
  61. package/src/shared/plugins/store.ts +358 -0
  62. package/src/shared/process/legacy-adopt.ts +285 -0
  63. package/src/shared/process/process-group.ts +202 -0
  64. package/src/shared/process/spawn-streaming.ts +7 -0
  65. package/src/shared/secret-env.ts +70 -0
  66. package/src/shared/secrets/doppler.ts +1 -1
  67. package/src/shared/secrets/index.ts +279 -50
  68. package/src/shared/secrets/none.ts +56 -0
  69. package/src/shared/secrets/strategies.ts +202 -0
  70. package/src/shared/secrets/types.ts +1 -1
  71. package/src/shared/sessions/client.ts +277 -0
  72. package/src/shared/sessions/den-pty.ts +97 -0
  73. package/src/shared/sessions/env.ts +79 -0
  74. package/src/shared/sessions/host.ts +667 -0
  75. package/src/shared/sessions/names.ts +85 -0
  76. package/src/shared/sessions/paths.ts +27 -0
  77. package/src/shared/sessions/protocol.ts +72 -0
  78. package/src/shared/sessions/pty.ts +141 -0
  79. package/src/shared/sessions/ring-buffer.ts +160 -0
  80. package/src/shared/sessions/types.ts +81 -0
  81. package/src/shared/smoke/git-remote.ts +40 -0
  82. package/src/shared/stacks/manifest-commands.ts +1 -1
  83. package/src/shared/stacks/namespace.ts +178 -0
  84. package/src/shared/stacks/owners.ts +303 -0
  85. package/src/shared/stacks/render.ts +8 -3
  86. package/src/shared/stacks/runtime.ts +100 -16
  87. package/src/shared/stacks/status.ts +63 -12
  88. package/src/shared/strategies/registry.ts +89 -0
  89. package/src/shared/theme/contract.ts +538 -0
  90. package/src/shared/types/index.ts +12 -3
  91. package/src/ui/cli/agents-command.ts +812 -0
  92. package/src/ui/cli/create.ts +24 -7
  93. package/src/ui/cli/doctor-command.ts +260 -0
  94. package/src/ui/cli/edge-command.ts +56 -10
  95. package/src/ui/cli/env-probe.ts +266 -0
  96. package/src/ui/cli/flag-parser.ts +18 -5
  97. package/src/ui/cli/health-display.ts +25 -0
  98. package/src/ui/cli/index.ts +580 -60
  99. package/src/ui/cli/notice.ts +20 -0
  100. package/src/ui/cli/plugin-command.ts +849 -0
  101. package/src/ui/cli/sessions-command.ts +718 -0
  102. package/src/ui/cli/stack-command.ts +63 -2
  103. package/src/ui/cli/test-runner.ts +11 -9
  104. package/src/ui/cli/theme-command.ts +136 -0
  105. package/src/ui/cli/web-command.ts +47 -23
  106. package/src/ui/mcp/api.ts +28 -2
  107. package/src/ui/mcp/scope.ts +145 -20
  108. package/src/ui/mcp/server.ts +27 -2
  109. package/src/ui/mcp/tools.ts +94 -12
  110. package/src/ui/server/appearance-prefs.ts +95 -0
  111. package/src/ui/server/audit.ts +35 -0
  112. package/src/ui/server/daemon-reaper.ts +29 -1
  113. package/src/ui/server/engine-stamp.ts +83 -0
  114. package/src/ui/server/github-service.ts +23 -44
  115. package/src/ui/server/graph-page.ts +20 -0
  116. package/src/ui/server/graph-prefs.ts +44 -1
  117. package/src/ui/server/http-errors.ts +293 -0
  118. package/src/ui/server/http-helpers.ts +22 -3
  119. package/src/ui/server/index.ts +539 -51
  120. package/src/ui/server/public/appearance.js +531 -0
  121. package/src/ui/server/public/edge-page.js +248 -0
  122. package/src/ui/server/public/edge.html +251 -0
  123. package/src/ui/server/public/environments.html +65 -83
  124. package/src/ui/server/public/error-page.js +247 -0
  125. package/src/ui/server/public/error.html +260 -0
  126. package/src/ui/server/public/graph-model.js +332 -14
  127. package/src/ui/server/public/graph.css +141 -47
  128. package/src/ui/server/public/graph.js +223 -44
  129. package/src/ui/server/public/header.js +396 -600
  130. package/src/ui/server/public/health-fit.js +248 -0
  131. package/src/ui/server/public/health-rollup.js +128 -0
  132. package/src/ui/server/public/i18n/es.js +287 -0
  133. package/src/ui/server/public/i18n/ja.js +276 -0
  134. package/src/ui/server/public/index.html +139 -438
  135. package/src/ui/server/public/local-dev.html +3468 -4044
  136. package/src/ui/server/public/logs.html +71 -208
  137. package/src/ui/server/public/panel.js +14 -2
  138. package/src/ui/server/public/release.html +175 -389
  139. package/src/ui/server/public/services.html +596 -770
  140. package/src/ui/server/public/settings-page.js +759 -0
  141. package/src/ui/server/public/settings-registry.js +521 -0
  142. package/src/ui/server/public/settings.html +590 -0
  143. package/src/ui/server/public/shell.js +3869 -0
  144. package/src/ui/server/public/topbar.js +1413 -0
  145. package/src/ui/server/public/version-label.js +12 -0
  146. package/src/ui/server/public/workspace-picker.js +74 -34
  147. package/src/ui/server/readonly-routes.ts +37 -3
  148. package/src/ui/server/services-list.ts +94 -17
  149. package/src/ui/server/settings-distribution.ts +74 -0
  150. package/src/ui/server/shell-prefs.ts +192 -0
  151. package/src/ui/server/theme-routes.ts +292 -0
  152. package/src/ui/server/theme-runtime.ts +407 -0
  153. package/src/ui/server/ui-assets.ts +16 -1
  154. package/src/ui/server/ui-language.ts +50 -0
  155. package/src/ui/server/workspace-routes.ts +48 -18
  156. package/themes/den/den-theme.json +72 -0
  157. package/themes/den/favicon.ico +0 -0
  158. package/themes/den/mark.svg +6 -0
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Shell preferences (0.3.0-dev.17): what this engineer pinned to the rail
3
+ * and whether the rail is collapsed; since 0.3.0-dev.19 (R21 Navigation)
4
+ * also the rail pages they hid and the order they put them in. Stored in the user config as `shell:`
5
+ * and served by /api/settings, like `graph:` and `appearance:`. A view
6
+ * preference only: pinning changes no edge route, container or service.
7
+ * The shell mirrors it to localStorage so the first paint does not wait on
8
+ * the server.
9
+ */
10
+
11
+ export interface ShellPrefs {
12
+ /** Pinned repos or services (`repo` or `repo/service`), in pin order. */
13
+ pins: string[];
14
+ /** The rail shows icons only. */
15
+ collapsed: boolean;
16
+ /** NAV page ids the rail leaves out (Settings is never one). */
17
+ hidden: string[];
18
+ /** NAV page ids in the engineer's order; pages not listed keep theirs. */
19
+ order: string[];
20
+ /**
21
+ * The runs dock is open (R16a, 0.3.0-dev.30, Ward QA 2026-11-05): it
22
+ * starts collapsed to its bar; opening it is the engineer's choice and
23
+ * remembered, never forced by a run starting.
24
+ */
25
+ dock: boolean;
26
+ /** The open dock's output height in px; 0 is the default (170px). */
27
+ dockHeight: number;
28
+ }
29
+
30
+ export const SHELL_DEFAULTS: ShellPrefs = {
31
+ pins: [],
32
+ collapsed: false,
33
+ hidden: [],
34
+ order: [],
35
+ dock: false,
36
+ dockHeight: 0,
37
+ };
38
+
39
+ /** The dock height's bounds (the page also caps it at half the window). */
40
+ export const DOCK_MIN = 60;
41
+ export const DOCK_MAX = 2000;
42
+
43
+ function checkDockHeight(
44
+ v: unknown,
45
+ ): { ok: true; value: number } | { ok: false; error: string } {
46
+ if (typeof v !== "number" || !Number.isFinite(v))
47
+ return { ok: false, error: dockHeightError() };
48
+ const h = Math.round(v);
49
+ if (h !== 0 && (h < DOCK_MIN || h > DOCK_MAX))
50
+ return { ok: false, error: dockHeightError() };
51
+ return { ok: true, value: h };
52
+ }
53
+ function dockHeightError(): string {
54
+ return `shell.dockHeight must be 0 (the default) or a number from ${DOCK_MIN} to ${DOCK_MAX}`;
55
+ }
56
+
57
+ /** How many page ids `hidden` and `order` keep. */
58
+ export const MAX_NAV_IDS = 64;
59
+ /** A NAV id, as shell.js's registerNav takes it. */
60
+ const NAV_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
61
+
62
+ /** How many pins the rail keeps. */
63
+ export const MAX_PINS = 24;
64
+ const PIN =
65
+ /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}(\/[A-Za-z0-9][A-Za-z0-9._-]{0,99})?$/;
66
+
67
+ const isObject = (v: unknown): v is Record<string, unknown> =>
68
+ typeof v === "object" && v !== null && !Array.isArray(v);
69
+
70
+ function checkPins(
71
+ v: unknown,
72
+ ): { ok: true; value: string[] } | { ok: false; error: string } {
73
+ if (!Array.isArray(v))
74
+ return { ok: false, error: "shell.pins must be a list" };
75
+ const out: string[] = [];
76
+ for (const p of v) {
77
+ if (typeof p !== "string" || !PIN.test(p))
78
+ return {
79
+ ok: false,
80
+ error: `shell.pins: "${String(p)}" is not a repo or repo/service name`,
81
+ };
82
+ if (!out.includes(p)) out.push(p);
83
+ }
84
+ if (out.length > MAX_PINS)
85
+ return { ok: false, error: `shell.pins: at most ${MAX_PINS}` };
86
+ return { ok: true, value: out };
87
+ }
88
+
89
+ function checkNavIds(
90
+ key: "hidden" | "order",
91
+ v: unknown,
92
+ ): { ok: true; value: string[] } | { ok: false; error: string } {
93
+ if (!Array.isArray(v))
94
+ return { ok: false, error: `shell.${key} must be a list` };
95
+ const out: string[] = [];
96
+ for (const id of v) {
97
+ if (typeof id !== "string" || !NAV_ID.test(id))
98
+ return {
99
+ ok: false,
100
+ error: `shell.${key}: "${String(id)}" is not a page id`,
101
+ };
102
+ if (!out.includes(id)) out.push(id);
103
+ }
104
+ if (out.length > MAX_NAV_IDS)
105
+ return { ok: false, error: `shell.${key}: at most ${MAX_NAV_IDS}` };
106
+ return { ok: true, value: out };
107
+ }
108
+
109
+ /** The stored block, normalised: a missing or hand-broken field is its default. */
110
+ export function shellPrefs(stored: unknown): ShellPrefs {
111
+ const out: ShellPrefs = {
112
+ pins: [],
113
+ collapsed: false,
114
+ hidden: [],
115
+ order: [],
116
+ dock: false,
117
+ dockHeight: 0,
118
+ };
119
+ if (!isObject(stored)) return out;
120
+ if (typeof stored.dock === "boolean") out.dock = stored.dock;
121
+ if (stored.dockHeight !== undefined) {
122
+ const h = checkDockHeight(stored.dockHeight);
123
+ if (h.ok) out.dockHeight = h.value;
124
+ }
125
+ const pins = checkPins(stored.pins);
126
+ if (pins.ok) out.pins = pins.value;
127
+ if (typeof stored.collapsed === "boolean") out.collapsed = stored.collapsed;
128
+ for (const key of ["hidden", "order"] as const) {
129
+ const ids = checkNavIds(key, stored[key]);
130
+ if (ids.ok) out[key] = ids.value;
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /** Merge a POSTed (partial) block over the current prefs; any bad field fails it. */
136
+ export function mergeShellPrefs(
137
+ current: ShellPrefs,
138
+ patch: unknown,
139
+ ): { ok: true; value: ShellPrefs } | { ok: false; error: string } {
140
+ if (!isObject(patch)) return { ok: false, error: "shell must be an object" };
141
+ const out: ShellPrefs = {
142
+ ...current,
143
+ pins: [...current.pins],
144
+ hidden: [...current.hidden],
145
+ order: [...current.order],
146
+ };
147
+ for (const key of Object.keys(patch)) {
148
+ // null clears a pref back to its default (0.3.0-dev.32): the stored
149
+ // block drops it, so set-then-clear leaves user.yml as it was.
150
+ if (patch[key] === null && key in SHELL_DEFAULTS) {
151
+ const d = SHELL_DEFAULTS[key as keyof ShellPrefs];
152
+ (out as unknown as Record<string, unknown>)[key] = Array.isArray(d)
153
+ ? []
154
+ : d;
155
+ continue;
156
+ }
157
+ if (key === "pins") {
158
+ const p = checkPins(patch.pins);
159
+ if (!p.ok) return p;
160
+ out.pins = p.value;
161
+ } else if (key === "collapsed") {
162
+ if (typeof patch.collapsed !== "boolean")
163
+ return { ok: false, error: "shell.collapsed must be true or false" };
164
+ out.collapsed = patch.collapsed;
165
+ } else if (key === "dock") {
166
+ if (typeof patch.dock !== "boolean")
167
+ return { ok: false, error: "shell.dock must be true or false" };
168
+ out.dock = patch.dock;
169
+ } else if (key === "dockHeight") {
170
+ const h = checkDockHeight(patch.dockHeight);
171
+ if (!h.ok) return h;
172
+ out.dockHeight = h.value;
173
+ } else if (key === "hidden" || key === "order") {
174
+ const ids = checkNavIds(key, patch[key]);
175
+ if (!ids.ok) return ids;
176
+ out[key] = ids.value;
177
+ } else return { ok: false, error: `shell.${key} is not a setting` };
178
+ }
179
+ return { ok: true, value: out };
180
+ }
181
+
182
+ /** The block to store: none for the defaults, so user.yml stays lean. */
183
+ export function storedShell(p: ShellPrefs): Partial<ShellPrefs> | undefined {
184
+ const out: Partial<ShellPrefs> = {};
185
+ if (p.pins.length) out.pins = p.pins;
186
+ if (p.collapsed) out.collapsed = true;
187
+ if (p.hidden.length) out.hidden = p.hidden;
188
+ if (p.order.length) out.order = p.order;
189
+ if (p.dock) out.dock = true;
190
+ if (p.dockHeight) out.dockHeight = p.dockHeight;
191
+ return Object.keys(out).length ? out : undefined;
192
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Theme routes (GET only):
3
+ *
4
+ * /theme.css the active theme as CSS custom properties, both
5
+ * modes (see theme-runtime.ts renderThemeCss)
6
+ * /theme/<id>/<file> a file the theme's validated manifest names (its
7
+ * mark .svg, its .woff2 fonts), nothing else
8
+ * /theme-favicon/<id>?mode=light|dark&v=<version>-<hash>
9
+ * theme <id>'s browser-tab icon in that mode (its
10
+ * `favicon`, else its mark's file, the dark variant
11
+ * in dark mode, else Den's own icon); `v` only makes
12
+ * the URL change with the file. /theme-favicon with
13
+ * no id is the active theme's, never cached.
14
+ * /api/themes the installed themes, the active one (with its
15
+ * favicon URLs per mode) and the org default (what
16
+ * appearance.theme null draws)
17
+ *
18
+ * Collaborators are injectable for tests.
19
+ */
20
+ import { createHash } from "crypto";
21
+ import * as fs from "fs";
22
+ import * as http from "http";
23
+ import * as path from "path";
24
+ import { send404, sendJson } from "./http-helpers.js";
25
+ import type { AppearancePrefs } from "./appearance-prefs.js";
26
+ import type { ThemeManifest, ThemeMode } from "../../shared/theme/contract.js";
27
+ import {
28
+ faviconFile,
29
+ findTheme,
30
+ listThemes,
31
+ renderThemeCss,
32
+ resolveActiveTheme,
33
+ themeAsset,
34
+ themeAssetUrl,
35
+ type ActiveTheme,
36
+ type ThemeRoots,
37
+ } from "./theme-runtime.js";
38
+
39
+ export interface ThemeRouteDeps {
40
+ roots: () => ThemeRoots;
41
+ /** The user's appearance prefs (user.yml). */
42
+ prefs: () => AppearancePrefs;
43
+ /** The org config's `theme:`. */
44
+ distributionTheme: () => string | null | undefined;
45
+ warn: (line: string) => void;
46
+ /** Den's own tab icon (public/favicon.ico), for a theme with a text mark. */
47
+ denFavicon?: string;
48
+ }
49
+
50
+ function denFaviconPath(deps: ThemeRouteDeps): string {
51
+ return (
52
+ deps.denFavicon ??
53
+ path.join(
54
+ deps.roots().engineRoot,
55
+ "src",
56
+ "ui",
57
+ "server",
58
+ "public",
59
+ "favicon.ico",
60
+ )
61
+ );
62
+ }
63
+
64
+ /**
65
+ * The file and type theme `id` shows in the browser tab in `mode`: a file
66
+ * its manifest names (themeAsset's rule), else Den's own icon.
67
+ */
68
+ function faviconAsset(
69
+ deps: ThemeRouteDeps,
70
+ id: string,
71
+ theme: ThemeManifest,
72
+ mode: ThemeMode,
73
+ ): { file: string; type: string } | null {
74
+ const rel = faviconFile(theme, mode);
75
+ if (rel) return themeAsset(deps.roots(), id, rel);
76
+ const file = denFaviconPath(deps);
77
+ return fs.existsSync(file) ? { file, type: "image/x-icon" } : null;
78
+ }
79
+
80
+ /** /theme-favicon/<id>?mode=<mode>&v=<version>-<content hash>. */
81
+ function faviconUrl(
82
+ deps: ThemeRouteDeps,
83
+ a: ActiveTheme,
84
+ mode: ThemeMode,
85
+ ): string | null {
86
+ const asset = faviconAsset(deps, a.id, a.theme, mode);
87
+ if (!asset) return null;
88
+ const hash = createHash("sha256")
89
+ .update(fs.readFileSync(asset.file))
90
+ .digest("hex")
91
+ .slice(0, 10);
92
+ return `/theme-favicon/${a.id}?mode=${mode}&v=${encodeURIComponent(`${a.theme.version}-${hash}`)}`;
93
+ }
94
+
95
+ /** The active theme for these deps (the user's, the distribution's, den). */
96
+ export function activeTheme(deps: ThemeRouteDeps): ActiveTheme | null {
97
+ let prefs: AppearancePrefs = { theme: null, mode: "system" };
98
+ let dist: string | null | undefined = null;
99
+ try {
100
+ prefs = deps.prefs();
101
+ } catch (e) {
102
+ deps.warn(`[theme] user config unreadable: ${(e as Error).message}`);
103
+ }
104
+ try {
105
+ dist = deps.distributionTheme();
106
+ } catch (e) {
107
+ deps.warn(`[theme] org config unreadable: ${(e as Error).message}`);
108
+ }
109
+ return resolveActiveTheme(
110
+ deps.roots(),
111
+ { user: prefs.theme, distribution: typeof dist === "string" ? dist : null },
112
+ deps.warn,
113
+ );
114
+ }
115
+
116
+ /**
117
+ * The org default: the theme drawn when you have chosen none (the org
118
+ * config's `brand.theme` if it is installed and valid, else den).
119
+ */
120
+ export function orgDefaultTheme(deps: ThemeRouteDeps): ActiveTheme | null {
121
+ let dist: string | null | undefined = null;
122
+ try {
123
+ dist = deps.distributionTheme();
124
+ } catch {
125
+ /* warned by activeTheme */
126
+ }
127
+ return resolveActiveTheme(
128
+ deps.roots(),
129
+ { user: null, distribution: typeof dist === "string" ? dist : null },
130
+ deps.warn,
131
+ );
132
+ }
133
+
134
+ function markOf(a: ActiveTheme) {
135
+ const m = a.theme.mark ?? {};
136
+ return {
137
+ svg: m.svg ? themeAssetUrl(a.id, m.svg) : null,
138
+ svg_dark: m.svg_dark ? themeAssetUrl(a.id, m.svg_dark) : null,
139
+ png: m.png ? themeAssetUrl(a.id, m.png) : null,
140
+ png_dark: m.png_dark ? themeAssetUrl(a.id, m.png_dark) : null,
141
+ webp: m.webp ? themeAssetUrl(a.id, m.webp) : null,
142
+ webp_dark: m.webp_dark ? themeAssetUrl(a.id, m.webp_dark) : null,
143
+ text: m.text ?? null,
144
+ shape: m.shape ?? null,
145
+ };
146
+ }
147
+
148
+ /** Handles the routes above; false when `pathname` is not one of them. */
149
+ export function handleThemeRoutes(
150
+ req: http.IncomingMessage,
151
+ res: http.ServerResponse,
152
+ pathname: string,
153
+ deps: ThemeRouteDeps,
154
+ ): boolean {
155
+ const isAsset = pathname.startsWith("/theme/");
156
+ const isFavicon =
157
+ pathname === "/theme-favicon" || pathname.startsWith("/theme-favicon/");
158
+ if (
159
+ pathname !== "/theme.css" &&
160
+ pathname !== "/api/themes" &&
161
+ !isAsset &&
162
+ !isFavicon
163
+ )
164
+ return false;
165
+ if (req.method !== "GET" && req.method !== "HEAD") {
166
+ res.writeHead(405, { Allow: "GET" });
167
+ res.end("read-only");
168
+ return true;
169
+ }
170
+
171
+ if (isFavicon) {
172
+ const id = pathname.slice("/theme-favicon/".length);
173
+ const q = new URL(req.url ?? "/", "http://localhost").searchParams;
174
+ const mode: ThemeMode = q.get("mode") === "dark" ? "dark" : "light";
175
+ let t: { id: string; theme: ThemeManifest } | null = null;
176
+ if (pathname === "/theme-favicon") t = activeTheme(deps);
177
+ else {
178
+ const found = /^[a-z][a-z0-9-]{1,39}$/.test(id)
179
+ ? findTheme(deps.roots(), id)
180
+ : null;
181
+ if (found?.ok && found.theme) t = { id: found.id, theme: found.theme };
182
+ }
183
+ const asset = t && faviconAsset(deps, t.id, t.theme, mode);
184
+ if (!asset) {
185
+ send404(res);
186
+ return true;
187
+ }
188
+ const headers: Record<string, string> = {
189
+ "Content-Type": asset.type,
190
+ "X-Content-Type-Options": "nosniff",
191
+ // The versioned URL names the file; the bare one follows your choice.
192
+ "Cache-Control": pathname === "/theme-favicon" ? "no-store" : "no-cache",
193
+ };
194
+ if (asset.type === "image/svg+xml")
195
+ headers["Content-Security-Policy"] =
196
+ "default-src 'none'; style-src 'unsafe-inline'";
197
+ res.writeHead(200, headers);
198
+ res.end(req.method === "HEAD" ? undefined : fs.readFileSync(asset.file));
199
+ return true;
200
+ }
201
+
202
+ if (pathname === "/theme.css") {
203
+ const a = activeTheme(deps);
204
+ res.writeHead(200, {
205
+ "Content-Type": "text/css; charset=utf-8",
206
+ // The theme follows the user's choice: never serve yesterday's.
207
+ "Cache-Control": "no-store",
208
+ });
209
+ res.end(a ? renderThemeCss(a.id, a.theme) : "/* no valid theme */\n");
210
+ return true;
211
+ }
212
+
213
+ if (pathname === "/api/themes") {
214
+ const a = activeTheme(deps);
215
+ let prefs: AppearancePrefs = { theme: null, mode: "system" };
216
+ try {
217
+ prefs = deps.prefs();
218
+ } catch {
219
+ /* warned by activeTheme */
220
+ }
221
+ const org = orgDefaultTheme(deps);
222
+ sendJson(res, {
223
+ // What "Org default" in the Theme menu means: appearance.theme null.
224
+ org_default: org ? { id: org.id, name: org.theme.name } : null,
225
+ active: a
226
+ ? {
227
+ id: a.id,
228
+ name: a.theme.name,
229
+ version: a.theme.version,
230
+ source: a.source,
231
+ chosen_by: a.chosenBy,
232
+ default_mode: a.theme.default_mode ?? "dark",
233
+ mark: markOf(a),
234
+ // The tab icon per mode: the shell sets the one for the mode
235
+ // it draws in (appearance.js).
236
+ favicon: {
237
+ light: faviconUrl(deps, a, "light"),
238
+ dark: faviconUrl(deps, a, "dark"),
239
+ },
240
+ }
241
+ : null,
242
+ prefs,
243
+ themes: listThemes(deps.roots()).map((t) => ({
244
+ id: t.id,
245
+ name: t.name,
246
+ version: t.version,
247
+ source: t.source,
248
+ ok: t.ok,
249
+ ...(t.problem ? { problem: t.problem } : {}),
250
+ })),
251
+ });
252
+ return true;
253
+ }
254
+
255
+ // /theme/<id>/<file…>. Each segment is decoded and must be a plain name:
256
+ // an encoded slash, dot segment or NUL never reaches the lookup, which in
257
+ // turn only matches the exact paths the manifest names.
258
+ const [id, ...segs] = pathname.slice("/theme/".length).split("/");
259
+ let rel: string;
260
+ try {
261
+ const parts = segs.map((s) => decodeURIComponent(s));
262
+ if (
263
+ !id ||
264
+ parts.length === 0 ||
265
+ parts.some((p) => !p || p === "." || p === ".." || /[\\/\0]/.test(p))
266
+ ) {
267
+ send404(res);
268
+ return true;
269
+ }
270
+ rel = parts.join("/");
271
+ } catch {
272
+ send404(res);
273
+ return true;
274
+ }
275
+ const asset = themeAsset(deps.roots(), id, rel);
276
+ if (!asset) {
277
+ send404(res);
278
+ return true;
279
+ }
280
+ const headers: Record<string, string> = {
281
+ "Content-Type": asset.type,
282
+ "X-Content-Type-Options": "nosniff",
283
+ "Cache-Control": "no-cache",
284
+ };
285
+ // Opened directly, an SVG is a document: let it run nothing, fetch nothing.
286
+ if (asset.type === "image/svg+xml")
287
+ headers["Content-Security-Policy"] =
288
+ "default-src 'none'; style-src 'unsafe-inline'";
289
+ res.writeHead(200, headers);
290
+ res.end(req.method === "HEAD" ? undefined : fs.readFileSync(asset.file));
291
+ return true;
292
+ }