@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,407 @@
1
+ /**
2
+ * The theme runtime: which theme the web UI draws with, and that theme as CSS.
3
+ *
4
+ * Themes come from three places (see src/ui/cli/theme-command.ts):
5
+ * built-in <engineRoot>/themes/den/ Den's own, always present
6
+ * locked <distRoot>/plugins/<id>/ + lock vendored by `theme add`
7
+ * local <distRoot>/plugins/<id>/, no lock the distribution's own
8
+ *
9
+ * The active theme is the user's choice (user.yml `appearance.theme`), else
10
+ * the distribution's (org config `brand.theme`), else den. A choice that is not
11
+ * installed or fails validation falls through to the next, ending at den,
12
+ * with one warning in the server log per bad choice. Only a validated
13
+ * manifest is ever rendered: its values passed the schema's CSS-value rule
14
+ * (no ; { } < > \ url() @import), so writing them into custom properties
15
+ * cannot break out of the declaration.
16
+ */
17
+ import * as fs from "fs";
18
+ import * as path from "path";
19
+ import {
20
+ installedThemeIds,
21
+ readLock,
22
+ themeDirFor,
23
+ validationSummary,
24
+ } from "../cli/theme-command.js";
25
+ import {
26
+ themeDirIn,
27
+ validatePluginDir,
28
+ } from "../../shared/plugins/manifest.js";
29
+ import {
30
+ markImage,
31
+ themeFiles,
32
+ validateThemeDir,
33
+ type ColorToken,
34
+ type ThemeManifest,
35
+ type ThemeMode,
36
+ } from "../../shared/theme/contract.js";
37
+
38
+ export const BUILTIN_THEME = "den";
39
+
40
+ export type ThemeOrigin = "built-in" | "locked" | "local";
41
+
42
+ export interface ThemeRoots {
43
+ distRoot: string;
44
+ engineRoot: string;
45
+ }
46
+
47
+ export interface InstalledTheme {
48
+ id: string;
49
+ name: string;
50
+ version: string;
51
+ source: ThemeOrigin;
52
+ /** Validates under this Den: only then is it rendered or served. */
53
+ ok: boolean;
54
+ /** Why it is not ok. */
55
+ problem?: string;
56
+ dir: string;
57
+ theme?: ThemeManifest;
58
+ }
59
+
60
+ export const COLOR_TOKENS: readonly ColorToken[] = [
61
+ "bg",
62
+ "surface",
63
+ "raised",
64
+ "sunken",
65
+ "text",
66
+ "muted",
67
+ "faint",
68
+ "border",
69
+ "border-strong",
70
+ "accent",
71
+ "accent-text",
72
+ "accent-fill",
73
+ "on-accent",
74
+ "ok",
75
+ "warn",
76
+ "bad",
77
+ "remote",
78
+ ];
79
+
80
+ export function builtinThemeDir(engineRoot: string): string {
81
+ return path.join(engineRoot, "themes", BUILTIN_THEME);
82
+ }
83
+
84
+ function inspect(
85
+ id: string,
86
+ dir: string,
87
+ source: ThemeOrigin,
88
+ expectId?: string,
89
+ pluginDir?: string,
90
+ ): InstalledTheme {
91
+ // The theme itself, to render; the verdict is `validate`'s for the whole
92
+ // plugin (author/maintainer, `den` range, README, capabilities), so what
93
+ // fails validate never loads and is never offered (PL-SPEC d4).
94
+ const v = validateThemeDir(dir, expectId ? { expectId } : {});
95
+ const verdict = validatePluginDir(
96
+ pluginDir ?? dir,
97
+ expectId ? { expectId } : {},
98
+ );
99
+ const ok = v.ok && verdict.ok;
100
+ return {
101
+ id,
102
+ name: v.theme?.name ?? id,
103
+ version: v.theme?.version ?? "?",
104
+ source,
105
+ ok,
106
+ ...(ok ? {} : { problem: validationSummary(verdict.ok ? v : verdict) }),
107
+ dir,
108
+ ...(v.theme ? { theme: v.theme } : {}),
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Where plugin `id`'s theme is: plugins/<id>/ for a den-theme.json plugin,
114
+ * else the directory its den-plugin.json theme entry names.
115
+ */
116
+ function pluginThemeDir(distRoot: string, id: string): string {
117
+ const dir = themeDirFor(distRoot, id);
118
+ return themeDirIn(dir) ?? dir;
119
+ }
120
+
121
+ /** Every theme this distribution can use: den first, then plugins/<id>/ sorted. */
122
+ export function listThemes(roots: ThemeRoots): InstalledTheme[] {
123
+ let lock: ReturnType<typeof readLock> = {};
124
+ try {
125
+ lock = readLock(roots.distRoot);
126
+ } catch {
127
+ /* an unreadable lock: every theme is then local; `theme list` reports it */
128
+ }
129
+ const out = [
130
+ inspect(BUILTIN_THEME, builtinThemeDir(roots.engineRoot), "built-in"),
131
+ ];
132
+ for (const id of installedThemeIds(roots.distRoot)) {
133
+ if (id === BUILTIN_THEME) continue; // the built-in one is never shadowed
134
+ out.push(
135
+ inspect(
136
+ id,
137
+ pluginThemeDir(roots.distRoot, id),
138
+ lock[id]?.kinds?.includes("theme") ? "locked" : "local",
139
+ id,
140
+ themeDirFor(roots.distRoot, id),
141
+ ),
142
+ );
143
+ }
144
+ return out;
145
+ }
146
+
147
+ /** One theme by id, or null when no such theme is installed. */
148
+ export function findTheme(
149
+ roots: ThemeRoots,
150
+ id: string,
151
+ ): InstalledTheme | null {
152
+ if (id === BUILTIN_THEME)
153
+ return inspect(id, builtinThemeDir(roots.engineRoot), "built-in");
154
+ if (!/^[a-z][a-z0-9-]{1,39}$/.test(id)) return null;
155
+ if (!installedThemeIds(roots.distRoot).includes(id)) return null;
156
+ let locked = false;
157
+ try {
158
+ locked = !!readLock(roots.distRoot)[id]?.kinds?.includes("theme");
159
+ } catch {
160
+ /* see listThemes */
161
+ }
162
+ return inspect(
163
+ id,
164
+ pluginThemeDir(roots.distRoot, id),
165
+ locked ? "locked" : "local",
166
+ id,
167
+ themeDirFor(roots.distRoot, id),
168
+ );
169
+ }
170
+
171
+ export interface ThemeChoice {
172
+ /** user.yml `appearance.theme`. */
173
+ user?: string | null;
174
+ /** The org config's `brand.theme`. */
175
+ distribution?: string | null;
176
+ }
177
+
178
+ export interface ActiveTheme {
179
+ id: string;
180
+ theme: ThemeManifest;
181
+ dir: string;
182
+ source: ThemeOrigin;
183
+ /** Which choice won. */
184
+ chosenBy: "user" | "distribution" | "default";
185
+ }
186
+
187
+ const warned = new Set<string>();
188
+
189
+ /** Forget which warnings were logged (tests). */
190
+ export function resetThemeWarnings(): void {
191
+ warned.clear();
192
+ }
193
+
194
+ /**
195
+ * The theme to draw with. Never throws: a bad choice is warned about once
196
+ * and skipped. Null only if Den's own theme is unusable (a broken install).
197
+ */
198
+ export function resolveActiveTheme(
199
+ roots: ThemeRoots,
200
+ choice: ThemeChoice,
201
+ warn: (line: string) => void = (l) => console.warn(l),
202
+ ): ActiveTheme | null {
203
+ const candidates: [string, ActiveTheme["chosenBy"], string][] = [];
204
+ if (choice.user) candidates.push([choice.user, "user", "your appearance"]);
205
+ if (choice.distribution)
206
+ candidates.push([
207
+ choice.distribution,
208
+ "distribution",
209
+ "the distribution's `brand.theme`",
210
+ ]);
211
+ candidates.push([BUILTIN_THEME, "default", "the default"]);
212
+ for (const [id, chosenBy, label] of candidates) {
213
+ let t: InstalledTheme | null = null;
214
+ try {
215
+ t = findTheme(roots, String(id));
216
+ } catch (e) {
217
+ t = null;
218
+ warnOnce(
219
+ warn,
220
+ `${id}:error`,
221
+ () =>
222
+ `theme "${id}" (${label}) could not be read: ${(e as Error).message}`,
223
+ );
224
+ }
225
+ if (t?.ok && t.theme)
226
+ return {
227
+ id: t.id,
228
+ theme: t.theme,
229
+ dir: t.dir,
230
+ source: t.source,
231
+ chosenBy,
232
+ };
233
+ if (id === BUILTIN_THEME && chosenBy === "default") {
234
+ warnOnce(
235
+ warn,
236
+ "den:broken",
237
+ () =>
238
+ `the built-in theme is not valid (${t?.problem ?? "missing"}); the UI has no theme`,
239
+ );
240
+ return null;
241
+ }
242
+ warnOnce(warn, `${id}:${t ? "invalid" : "missing"}`, () =>
243
+ t
244
+ ? `theme "${id}" (${label}) is not valid under this Den: ${t.problem}; using the next choice`
245
+ : `theme "${id}" (${label}) is not installed; using the next choice`,
246
+ );
247
+ }
248
+ return null;
249
+ }
250
+
251
+ function warnOnce(
252
+ warn: (line: string) => void,
253
+ key: string,
254
+ line: () => string,
255
+ ): void {
256
+ if (warned.has(key)) return;
257
+ warned.add(key);
258
+ warn(`[theme] ${line()}`);
259
+ }
260
+
261
+ /** URL of a file in a theme, as /theme/<id>/<file> serves it. */
262
+ export function themeAssetUrl(id: string, rel: string): string {
263
+ return `/theme/${id}/${rel.split("/").map(encodeURIComponent).join("/")}`;
264
+ }
265
+
266
+ const DEFAULT_TYPE = {
267
+ sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif',
268
+ mono: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
269
+ ja: '"Hiragino Sans", "Noto Sans JP", "Yu Gothic", Meiryo, sans-serif',
270
+ };
271
+
272
+ /** A generic family ends a stack that can always resolve. */
273
+ const GENERIC_FAMILY =
274
+ /(?:^|,)\s*(?:sans-serif|serif|monospace|cursive|fantasy|system-ui|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|math|emoji)\s*$/i;
275
+
276
+ /**
277
+ * A theme's font stack, made safe to use alone: one that does not end in a
278
+ * generic family gets Den's stack after it, so a missing font never drops
279
+ * the UI to the browser's default serif. The pages use only var(--sans),
280
+ * var(--mono), var(--display); the fallbacks live here, nowhere else.
281
+ */
282
+ export function fontStack(stack: string | undefined, fallback: string): string {
283
+ if (!stack?.trim()) return fallback;
284
+ return GENERIC_FAMILY.test(stack) ? stack : `${stack}, ${fallback}`;
285
+ }
286
+
287
+ /**
288
+ * Den's base rules, the same under every theme. A link in body content is
289
+ * underlined (subtly, in a tint of its own color), so it never reads by
290
+ * color alone: a theme whose accent text is red would otherwise make links
291
+ * look like errors next to --bad. Nav links, buttons and pills are not
292
+ * body content. :where() keeps the rule at zero specificity, so any page
293
+ * style that draws a link differently wins.
294
+ */
295
+ const BASE_CSS = [
296
+ "/* Den base (every theme) */",
297
+ ':where(a[href]:not(nav a, header a, .topbar a, [role="button"], [class*="btn"], [class*="pill"], [class*="tab"])) {',
298
+ " text-decoration-line: underline;",
299
+ " text-decoration-thickness: 1px;",
300
+ " text-underline-offset: 0.2em;",
301
+ " text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);",
302
+ "}",
303
+ ':where(a[href]:not(nav a, header a, .topbar a, [role="button"], [class*="btn"], [class*="pill"], [class*="tab"])):hover {',
304
+ " text-decoration-color: currentColor;",
305
+ "}",
306
+ ];
307
+
308
+ /**
309
+ * The theme as CSS: @font-face for its fonts, type and shape on :root, and
310
+ * one block of color tokens per mode, selected by <html data-mode>. The
311
+ * theme's default mode also applies when data-mode is unset. Then Den's
312
+ * base rules (BASE_CSS).
313
+ */
314
+ export function renderThemeCss(id: string, theme: ThemeManifest): string {
315
+ const lines: string[] = [
316
+ `/* ${theme.name} ${theme.version} (${id}, den.theme/v1). Generated by Den from den-theme.json. */`,
317
+ ];
318
+ for (const f of theme.fonts ?? [])
319
+ for (const file of f.files) {
320
+ const [lo, hi] = (file.weight ?? "400").split(" ");
321
+ lines.push(
322
+ `@font-face { font-family: "${f.family}"; src: url("${themeAssetUrl(id, file.path)}") format("woff2"); font-weight: ${hi ? `${lo} ${hi}` : lo}; font-style: ${file.style ?? "normal"}; font-display: swap; }`,
323
+ );
324
+ }
325
+ const type = theme.type ?? {};
326
+ const shape = theme.shape ?? {};
327
+ const sans = fontStack(type.sans, DEFAULT_TYPE.sans);
328
+ lines.push(
329
+ ":root {",
330
+ ` --sans: ${sans};`,
331
+ ` --mono: ${fontStack(type.mono, DEFAULT_TYPE.mono)};`,
332
+ ` --display: ${fontStack(type.display, sans)};`,
333
+ ` --ja: ${fontStack(type.ja, DEFAULT_TYPE.ja)};`,
334
+ ` --radius: ${shape.radius ?? "8px"};`,
335
+ ` --radius-button: ${shape["radius-button"] ?? "6px"};`,
336
+ ` --density: ${shape.density === "compact" ? "0.85" : "1"};`,
337
+ "}",
338
+ );
339
+ const def: ThemeMode = theme.default_mode ?? "dark";
340
+ for (const mode of [def, def === "dark" ? "light" : "dark"] as ThemeMode[]) {
341
+ const c = theme.modes[mode];
342
+ const sel =
343
+ mode === def
344
+ ? `:root,\n:root[data-mode="${mode}"]`
345
+ : `:root[data-mode="${mode}"]`;
346
+ lines.push(`${sel} {`, ` color-scheme: ${mode};`);
347
+ for (const t of COLOR_TOKENS) lines.push(` --${t}: ${c[t]};`);
348
+ lines.push(
349
+ ` --shadow: ${c.shadow ?? (mode === "dark" ? "0 12px 30px rgb(0 0 0 / 0.5)" : "0 8px 24px rgb(0 0 0 / 0.14)")};`,
350
+ ` --bar: ${c.bar ?? "none"};`,
351
+ "}",
352
+ );
353
+ }
354
+ lines.push(...BASE_CSS);
355
+ return `${lines.join("\n")}\n`;
356
+ }
357
+
358
+ const ASSET_TYPES: Record<string, string> = {
359
+ ".svg": "image/svg+xml",
360
+ ".png": "image/png",
361
+ ".webp": "image/webp",
362
+ ".ico": "image/x-icon",
363
+ ".woff2": "font/woff2",
364
+ };
365
+
366
+ /**
367
+ * The theme's browser-tab icon in a mode, relative to its directory: its
368
+ * `favicon` (both modes), else its mark's image (the dark variant in dark
369
+ * mode, if it has one), else null (a text mark: Den's own icon).
370
+ */
371
+ export function faviconFile(
372
+ theme: ThemeManifest,
373
+ mode: ThemeMode,
374
+ ): string | null {
375
+ return theme.favicon ?? markImage(theme.mark, mode)?.file ?? null;
376
+ }
377
+
378
+ /**
379
+ * The file /theme/<id>/<rel> may serve, or null: only a file the validated
380
+ * manifest names (its mark, its favicon, its fonts), only .svg, .png,
381
+ * .webp, .ico or .woff2, only inside the theme's directory. `rel` is the
382
+ * decoded remainder of the URL path.
383
+ */
384
+ export function themeAsset(
385
+ roots: ThemeRoots,
386
+ id: string,
387
+ rel: string,
388
+ ): { file: string; type: string } | null {
389
+ const type = ASSET_TYPES[path.extname(rel).toLowerCase()];
390
+ if (!type) return null;
391
+ const t = findTheme(roots, id);
392
+ if (!t?.ok || !t.theme) return null;
393
+ if (!themeFiles(t.theme).includes(rel)) return null;
394
+ const root = path.resolve(t.dir);
395
+ const file = path.resolve(root, rel);
396
+ if (!file.startsWith(root + path.sep)) return null;
397
+ try {
398
+ // Resolve links too: the real file must still be inside the theme.
399
+ const real = fs.realpathSync(file);
400
+ const realRoot = fs.realpathSync(root);
401
+ if (!real.startsWith(realRoot + path.sep) || !fs.statSync(real).isFile())
402
+ return null;
403
+ return { file: real, type };
404
+ } catch {
405
+ return null;
406
+ }
407
+ }
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Static assets the graph needs beyond the pages themselves:
3
- * - the graph's own modules in public/ (UI_MODULES), served like header.js;
3
+ * - the UI's own modules in public/ (UI_MODULES: the graph, the appearance
4
+ * controls, the version label, the top bar's chips and overlays),
5
+ * served like header.js;
4
6
  * - browser libraries the UI loads, served by Den's own server from
5
7
  * node_modules (never a CDN), so the UI works offline. Each is a pinned
6
8
  * dependency in package.json. npm hoists Den's dependencies into a
@@ -19,7 +21,20 @@ export const UI_MODULES: readonly string[] = [
19
21
  "/graph-model.js",
20
22
  "/panel.js",
21
23
  "/graph.css",
24
+ "/appearance.js",
25
+ "/version-label.js",
22
26
  "/changelog-view.js",
27
+ "/health-fit.js",
28
+ "/health-rollup.js",
29
+ "/shell.js",
30
+ "/settings-registry.js",
31
+ "/settings-page.js",
32
+ "/topbar.js",
33
+ "/edge-page.js",
34
+ "/error-page.js",
35
+ // The UI's catalogs other than English (R19; en is in shell.js).
36
+ "/i18n/ja.js",
37
+ "/i18n/es.js",
23
38
  ];
24
39
 
25
40
  /** URL path → file inside node_modules. */
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The web UI's language (R19, 0.3.0-dev.39): a user pref, stored as
3
+ * `language:` in config/local/user.yml and served by /api/settings, like
4
+ * `editor`. Absent means English.
5
+ *
6
+ * The catalogs are public/shell.js's MESSAGES (en, the fallback) and one
7
+ * classic script per other language, public/i18n/<lang>.js, which adds its
8
+ * table to window.denMessages before shell.js runs. A page served in a
9
+ * language other than English gets that script and `<html lang>` from
10
+ * localizePage, so the first paint is already in that language. An English
11
+ * page is served exactly as it is on disk.
12
+ *
13
+ * Only Den's own words are translated: names (services, workspaces, repos,
14
+ * themes, languages), logs and command output never are.
15
+ */
16
+
17
+ /** Every language the UI ships a catalog for; the first is the fallback. */
18
+ export const UI_LANGUAGES = ["en", "ja", "es"] as const;
19
+ export type UiLanguage = (typeof UI_LANGUAGES)[number];
20
+ export const DEFAULT_UI_LANGUAGE: UiLanguage = "en";
21
+
22
+ export function isUiLanguage(v: unknown): v is UiLanguage {
23
+ return (
24
+ typeof v === "string" && (UI_LANGUAGES as readonly string[]).includes(v)
25
+ );
26
+ }
27
+
28
+ /** The stored value, normalised: an unknown or missing one is English. */
29
+ export function uiLanguage(stored: unknown): UiLanguage {
30
+ return isUiLanguage(stored) ? stored : DEFAULT_UI_LANGUAGE;
31
+ }
32
+
33
+ /** The catalog script a language's pages load before shell.js (none for en). */
34
+ export function catalogPath(lang: UiLanguage): string | null {
35
+ return lang === DEFAULT_UI_LANGUAGE ? null : `/i18n/${lang}.js`;
36
+ }
37
+
38
+ /**
39
+ * A page in `lang`: `<html lang>` set, and the language's catalog loaded
40
+ * just before shell.js, so the shell draws in it from the first paint.
41
+ * English, or a page without the shell, comes back unchanged.
42
+ */
43
+ export function localizePage(html: string, lang: UiLanguage): string {
44
+ const src = catalogPath(lang);
45
+ const shell = '<script src="/shell.js"></script>';
46
+ if (!src || !html.includes(shell)) return html;
47
+ return html
48
+ .replace(/<html lang="[^"]*"/, () => `<html lang="${lang}"`)
49
+ .replace(shell, () => `<script src="${src}"></script>${shell}`);
50
+ }
@@ -19,6 +19,7 @@ import type { ResolvedService } from "../../shared/types/index.js";
19
19
  import type { EditorManager } from "./editor-manager.js";
20
20
  import type { GitHubService } from "./github-service.js";
21
21
  import { JETBRAINS_EDITORS } from "./editor-manager.js";
22
+ import { stackNamespaceRows } from "../../shared/stacks/namespace.js";
22
23
  import {
23
24
  readWorkspacesFile,
24
25
  writeWorkspacesFile,
@@ -40,10 +41,12 @@ import {
40
41
  syncJetBrainsWorkspaceSymlinks,
41
42
  DEN_SERVICES_DIR,
42
43
  DEN_WORKSPACES_DIR,
44
+ resolveWorkspaceDir,
43
45
  } from "../../shared/environment-manager/workspaces.js";
44
46
  import {
45
47
  checkServiceHealth,
46
48
  claimHealthyService,
49
+ settledDaemonRecord,
47
50
  runServiceCommand,
48
51
  prepareServiceSecretEnv,
49
52
  resolveService,
@@ -515,21 +518,8 @@ export async function handleWorkspaceRoutes(
515
518
  const wsFile = readWorkspacesFile();
516
519
  const reposRoot = getReposRoot();
517
520
  const resolvedDirs: Record<string, string> = {};
518
- for (const [name, ws] of Object.entries(wsFile.workspaces)) {
519
- if (ws.directory) {
520
- resolvedDirs[name] = path.resolve(ROOT, ws.directory);
521
- } else {
522
- const gwDir = path.join(DEN_WORKSPACES_DIR, name);
523
- const legacyDir = path.join(reposRoot, name);
524
- if (fs.existsSync(gwDir)) {
525
- resolvedDirs[name] = gwDir;
526
- } else if (fs.existsSync(legacyDir)) {
527
- resolvedDirs[name] = legacyDir;
528
- } else {
529
- resolvedDirs[name] = reposRoot;
530
- }
531
- }
532
- }
521
+ for (const [name, ws] of Object.entries(wsFile.workspaces))
522
+ resolvedDirs[name] = resolveWorkspaceDir(name, ws, reposRoot);
533
523
  const editor = readUserConfig().editor ?? "";
534
524
  const catalog = readCatalog();
535
525
  const codeWorkspacePaths: Record<string, string> = {};
@@ -552,7 +542,21 @@ export async function handleWorkspaceRoutes(
552
542
  syncJetBrainsWorkspaceSymlinks(wsFile);
553
543
  }
554
544
 
555
- return sendJson(res, { ...wsFile, resolvedDirs, codeWorkspacePaths });
545
+ // Each workspace's stack namespace, resolved, with where it came from
546
+ // (workspace, blueprint or default) and whether it differs from the
547
+ // default suffix: the page's "stacks: <ns>" chip.
548
+ const stackNamespaces = Object.fromEntries(
549
+ stackNamespaceRows(Object.keys(wsFile.workspaces)).map((r) => [
550
+ r.workspace,
551
+ r,
552
+ ]),
553
+ );
554
+ return sendJson(res, {
555
+ ...wsFile,
556
+ resolvedDirs,
557
+ codeWorkspacePaths,
558
+ stackNamespaces,
559
+ });
556
560
  }
557
561
 
558
562
  // ── API: rename workspace ─────────────────────────────────────────────────
@@ -1775,10 +1779,31 @@ export async function handleWorkspaceRoutes(
1775
1779
  if (cmd === "up") {
1776
1780
  const health = await checkServiceHealth(resolved, "local");
1777
1781
  if (cancelled) return;
1778
- if (health.health === "ok") {
1782
+ // `conflict`: a shared stack namespace runs another checkout's
1783
+ // project; the real up refuses it, naming both.
1784
+ const claim =
1785
+ health.health === "ok"
1786
+ ? claimHealthyService(resolved, ws.name, (text) =>
1787
+ emit({ type: "line", text }),
1788
+ )
1789
+ : "untracked";
1790
+ // Another process answers on its port (said above): nothing is
1791
+ // claimed and nothing started on top of it (0.3.0-dev.39).
1792
+ if (health.health === "ok" && claim === "busy") {
1793
+ overallExit = 1;
1794
+ emit({
1795
+ type: "svc-done",
1796
+ address,
1797
+ exitCode: 1,
1798
+ repoName: svcRepoName,
1799
+ subsystem: svcSubsystem,
1800
+ });
1801
+ safeRunNext(i + 1);
1802
+ return;
1803
+ }
1804
+ if (health.health === "ok" && claim !== "conflict") {
1779
1805
  // Skipping must not lose the refcount — this workspace is an
1780
1806
  // owner of what's running, whether den tracked it or not.
1781
- const claim = claimHealthyService(resolved, ws.name);
1782
1807
  emit({
1783
1808
  type: "line",
1784
1809
  text:
@@ -1801,6 +1826,11 @@ export async function handleWorkspaceRoutes(
1801
1826
  return;
1802
1827
  }
1803
1828
  } else {
1829
+ // A pre-dev.37 record is settled before the skip decides
1830
+ // (0.3.0-dev.39): adopted, it is running; stale, it is dropped.
1831
+ settledDaemonRecord(resolved, ws.name, (text) =>
1832
+ emit({ type: "line", text }),
1833
+ );
1804
1834
  let running = hasRunningOpctlContainers(resolved);
1805
1835
  if (!running) {
1806
1836
  // Not opctl-visible but still responding (host-run process)?
@@ -0,0 +1,72 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/Monster-Orange/den/main/schemas/den.theme.v1.json",
3
+ "schema": "den.theme/v1",
4
+ "id": "den",
5
+ "name": "Den",
6
+ "version": "2.0.0",
7
+ "description": "Den's default: a sheltered workspace in walnut and umber neutrals with a moss-green accent. System fonts.",
8
+ "author": "Monster Orange",
9
+ "license": "MIT",
10
+ "homepage": "https://github.com/Monster-Orange/den-themes/tree/main/themes/den",
11
+ "default_mode": "dark",
12
+ "type": {
13
+ "sans": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif",
14
+ "mono": "ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace",
15
+ "display": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif",
16
+ "ja": "\"Hiragino Sans\", \"Hiragino Kaku Gothic ProN\", \"Noto Sans JP\", \"Yu Gothic\", Meiryo, sans-serif"
17
+ },
18
+ "shape": {
19
+ "radius": "8px",
20
+ "radius-button": "6px",
21
+ "density": "comfortable"
22
+ },
23
+ "mark": {
24
+ "svg": "mark.svg",
25
+ "shape": "rounded"
26
+ },
27
+ "favicon": "favicon.ico",
28
+ "modes": {
29
+ "light": {
30
+ "bg": "#f2eee8",
31
+ "surface": "#fbf9f6",
32
+ "raised": "#ffffff",
33
+ "sunken": "#e8e2d9",
34
+ "text": "#241e18",
35
+ "muted": "#5c5247",
36
+ "faint": "#6d6358",
37
+ "border": "#dcd3c7",
38
+ "border-strong": "#8d7f6d",
39
+ "accent": "#557a2a",
40
+ "accent-text": "#4a6b23",
41
+ "accent-fill": "#4a6b23",
42
+ "on-accent": "#ffffff",
43
+ "ok": "#2e7246",
44
+ "warn": "#8c5a10",
45
+ "bad": "#b0392a",
46
+ "remote": "#23677a",
47
+ "shadow": "0 8px 24px rgb(48 36 24 / 0.14)",
48
+ "bar": "linear-gradient(90deg, #557a2a 0%, #8a6a48 100%)"
49
+ },
50
+ "dark": {
51
+ "bg": "#1a1612",
52
+ "surface": "#221d18",
53
+ "raised": "#2b251f",
54
+ "sunken": "#14110e",
55
+ "text": "#ede6dc",
56
+ "muted": "#b5aa9b",
57
+ "faint": "#978b7e",
58
+ "border": "#3a322a",
59
+ "border-strong": "#7a6d5d",
60
+ "accent": "#9bbb63",
61
+ "accent-text": "#a8c672",
62
+ "accent-fill": "#9bbb63",
63
+ "on-accent": "#1a1612",
64
+ "ok": "#6fc28f",
65
+ "warn": "#e0a84e",
66
+ "bad": "#ef7f6a",
67
+ "remote": "#74b8c8",
68
+ "shadow": "0 12px 30px rgb(0 0 0 / 0.5)",
69
+ "bar": "linear-gradient(90deg, #9bbb63 0%, #a07c55 100%)"
70
+ }
71
+ }
72
+ }
Binary file
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="Den">
2
+ <rect width="32" height="32" rx="7" fill="#3a2e22"/>
3
+ <path d="M5 26.5h22" stroke="#9bbb63" stroke-width="2" stroke-linecap="round"/>
4
+ <path d="M8 26V15a8 8 0 0 1 16 0v11h-4.5V15.5a3.5 3.5 0 0 0-7 0V26z" fill="#f2eee8"/>
5
+ <path d="M13 26v-9.5a3 3 0 0 1 6 0V26z" fill="#c8a46a" opacity="0.9"/>
6
+ </svg>