@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
@@ -15,7 +15,9 @@
15
15
  * to any workspace/catalog service is process-death-equivalent evidence and
16
16
  * is removed directly. Records another root registered (all roots of a
17
17
  * product share the file) are never touched, and an unstamped legacy record
18
- * is not removed for being unresolvable.
18
+ * is not removed for being unresolvable. A record whose checkout is gone, or
19
+ * whose service moved to another checkout with nothing of it running
20
+ * (`staleReason`), is removed whatever the address's health says.
19
21
  */
20
22
 
21
23
  import type { ServiceHealthResult } from "../../shared/environment-manager/health.js";
@@ -49,6 +51,19 @@ export interface DaemonReaperDeps {
49
51
  * and an unstamped (pre-0.2.7) one is not dropped for being unresolvable.
50
52
  */
51
53
  rootOf: (daemon: DaemonRecord) => "here" | "other" | "unstamped";
54
+ /**
55
+ * Why a record describes nothing that can be running whatever its health
56
+ * (service-runner's daemonStaleReason: its checkout is gone, or the
57
+ * service moved to another checkout and none of its containers run), or
58
+ * null. Injected; omitted, no record is dropped for it. `running` is the
59
+ * container listing, taken once per sweep by `runningContainers`.
60
+ */
61
+ staleReason?: (
62
+ daemon: DaemonRecord,
63
+ running: Set<string> | null,
64
+ ) => string | null;
65
+ /** The running container base names (null: unverifiable), once a sweep. */
66
+ runningContainers?: () => Promise<Set<string> | null>;
52
67
  }
53
68
 
54
69
  export interface DaemonReapResult {
@@ -96,6 +111,11 @@ export async function reapDeadDaemons(
96
111
  ): Promise<DaemonReapResult> {
97
112
  const removed: string[] = [];
98
113
  const kept: string[] = [];
114
+ const running = deps.staleReason
115
+ ? await (deps.runningContainers?.() ?? Promise.resolve(null)).catch(
116
+ () => null,
117
+ )
118
+ : null;
99
119
 
100
120
  await Promise.allSettled(
101
121
  deps.listDaemons().map(async (daemon) => {
@@ -105,6 +125,14 @@ export async function reapDeadDaemons(
105
125
  kept.push(daemon.address);
106
126
  return;
107
127
  }
128
+ // Its checkout is gone, or the service has moved and nothing of
129
+ // the record's runs: whatever the address's health says (it is
130
+ // another checkout's), the record is stale.
131
+ if (deps.staleReason?.(daemon, running)) {
132
+ deps.unregisterDaemon(daemon.address);
133
+ removed.push(daemon.address);
134
+ return;
135
+ }
108
136
  const service = resolveForDaemon(deps, daemon);
109
137
  if (!service) {
110
138
  // No longer in any owning workspace/catalog — nothing could restart
@@ -0,0 +1,83 @@
1
+ /**
2
+ * MD-2 (0.3.0-dev.38): the engine a running web server loaded against the
3
+ * engine on disk. A pin that upgrades Den rewrites node_modules under a
4
+ * server that keeps the old code in memory while it serves the new pages
5
+ * from disk, so the new pages' routes 404. The server records the engine's
6
+ * version and build id at startup; page requests compare them with the
7
+ * engine's package.json now (cached for a few seconds).
8
+ *
9
+ * The build id is a short hash of that package.json: it changes with any
10
+ * re-pin, even one that keeps the version string (a git-sha pin).
11
+ */
12
+ import { createHash } from "crypto";
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+
16
+ export interface EngineStamp {
17
+ version: string | null;
18
+ buildId: string | null;
19
+ }
20
+
21
+ /** The engine's version and build id from its package.json, now. */
22
+ export function readEngineStamp(engineRoot: string): EngineStamp {
23
+ try {
24
+ const raw = fs.readFileSync(path.join(engineRoot, "package.json"));
25
+ const pkg = JSON.parse(raw.toString("utf-8")) as { version?: unknown };
26
+ return {
27
+ version: typeof pkg.version === "string" ? pkg.version : null,
28
+ buildId: createHash("sha256").update(raw).digest("hex").slice(0, 12),
29
+ };
30
+ } catch {
31
+ return { version: null, buildId: null };
32
+ }
33
+ }
34
+
35
+ export interface EngineCheck {
36
+ loaded: EngineStamp;
37
+ onDisk: EngineStamp;
38
+ /** The engine on disk is not the one this server runs. */
39
+ stale: boolean;
40
+ }
41
+
42
+ /** How long a comparison is reused. */
43
+ export const ENGINE_CHECK_TTL_MS = 3_000;
44
+
45
+ /**
46
+ * The loaded stamp (read once, now) and a cached comparison with the disk.
47
+ * An unreadable package.json on disk is never "stale": nothing is known.
48
+ */
49
+ export function engineWatch(
50
+ engineRoot: () => string,
51
+ opts: { ttlMs?: number; now?: () => number } = {},
52
+ ): { loaded: EngineStamp; check(): EngineCheck } {
53
+ const ttl = opts.ttlMs ?? ENGINE_CHECK_TTL_MS;
54
+ const now = opts.now ?? Date.now;
55
+ const loaded = readEngineStamp(engineRoot());
56
+ let cache: { at: number; value: EngineCheck } | null = null;
57
+ return {
58
+ loaded,
59
+ check() {
60
+ const t = now();
61
+ if (cache && t - cache.at < ttl) return cache.value;
62
+ const onDisk = readEngineStamp(engineRoot());
63
+ const stale =
64
+ onDisk.buildId !== null &&
65
+ loaded.buildId !== null &&
66
+ onDisk.buildId !== loaded.buildId;
67
+ cache = { at: t, value: { loaded, onDisk, stale } };
68
+ return cache.value;
69
+ },
70
+ };
71
+ }
72
+
73
+ /** The restart message (pages, 404s, MCP): both versions and the command. */
74
+ export function engineStaleMessage(c: EngineCheck, cli: string): string {
75
+ const same = c.onDisk.version === c.loaded.version;
76
+ const v = (s: EngineStamp) =>
77
+ (s.version ?? "an unknown version") +
78
+ (same && s.buildId ? ` (build ${s.buildId})` : "");
79
+ return (
80
+ `Den was updated to ${v(c.onDisk)}. This server is still running ${v(c.loaded)}. ` +
81
+ `Restart it: ${cli} web restart (or your service restart).`
82
+ );
83
+ }
@@ -7,12 +7,15 @@
7
7
 
8
8
  import { execFileSync, execSync } from "child_process";
9
9
  import * as path from "path";
10
- import { readDenConfig } from "../../shared/den-config.js";
10
+ import {
11
+ configuredGithubOrg,
12
+ isPlaceholderGithubOrg,
13
+ readDenConfig,
14
+ } from "../../shared/den-config.js";
11
15
  import { readCatalog } from "../../shared/environment-manager/catalog.js";
12
16
  import { githubOwnersOf } from "../../shared/org-sources.js";
13
17
  import {
14
18
  GH_API_TIMEOUT_MS,
15
- GIT_REMOTE_TIMEOUT_MS,
16
19
  BRANCH_CACHE_TTL_MS,
17
20
  REPO_CACHE_TTL_MS,
18
21
  } from "../../shared/constants.js";
@@ -40,6 +43,7 @@ export class GitHubService {
40
43
  { names: string[]; fetchedAt: number }
41
44
  >();
42
45
  private reposCache: { data: GitHubRepo[]; fetchedAt: number } | null = null;
46
+ private warnedNotConfigured = false;
43
47
 
44
48
  /**
45
49
  * List branches for a GitHub repo slug ("owner/repo").
@@ -96,11 +100,13 @@ export class GitHubService {
96
100
 
97
101
  const orgs = this.resolveOrgs();
98
102
  if (orgs.length === 0) {
99
- console.warn(
100
- "[github-service] listOrgRepos: no GitHub org resolvable — " +
101
- `add \`github_org: <slug>\` to ${product().configFile}, or register at least ` +
102
- "one catalog entry with a slashed `gh:` field",
103
- );
103
+ if (!this.warnedNotConfigured) {
104
+ this.warnedNotConfigured = true;
105
+ console.log(
106
+ "[github-service] GitHub repos not configured: set `github_org: <your-org>` in " +
107
+ `${product().configFile} to list them (no gh is run until then)`,
108
+ );
109
+ }
104
110
  return [];
105
111
  }
106
112
  const data = orgs.flatMap((org) => this.listOneOrg(org));
@@ -172,18 +178,16 @@ export class GitHubService {
172
178
 
173
179
  /**
174
180
  * Every GitHub org Den lists repos from: `github_org`, then the orgs in
175
- * config/den.yml `orgs:`, then every owner in the catalog (several orgs'
176
- * repos can sit side by side). With none of those, fall back to
177
- * the den repo's own `origin` URL — makes upgraded installs Just
178
- * Work without anyone editing config/den.yml. Existing users
179
- * who pre-date `github_org` or had it commented out still get
180
- * the right value as long as den itself was cloned from the
181
- * same org as their services (the common case).
181
+ * the org config's `orgs:`, then every owner in the catalog (several
182
+ * orgs' repos can sit side by side). With `github_org` unset or still a
183
+ * placeholder (`your-github-org`), none: GitHub is not configured, and Den
184
+ * runs no gh for it.
182
185
  */
183
186
  private resolveOrgs(): string[] {
184
187
  const config = readDenConfig();
185
- const orgs = new Set<string>();
186
- if (config.github_org) orgs.add(config.github_org);
188
+ const primary = configuredGithubOrg(config);
189
+ if (!primary) return [];
190
+ const orgs = new Set<string>([primary]);
187
191
  // A legacy `orgs:` key is a GitHub owner; an ORG-1d org lists its GitHub
188
192
  // sources' owners, and one with no GitHub source adds none (discovery is
189
193
  // hidden for it).
@@ -196,33 +200,8 @@ export class GitHubService {
196
200
  }
197
201
  // Org names reach a shell command: GitHub owners are letters, digits and
198
202
  // hyphens, so anything else is dropped.
199
- const valid = [...orgs].filter((o) => /^[A-Za-z0-9][A-Za-z0-9-]*$/.test(o));
200
- if (valid.length > 0) return valid;
201
- const fallback = this.originOrg();
202
- return fallback ? [fallback] : [];
203
- }
204
-
205
- private originOrg(): string | null {
206
- // Fall back to the den repo's origin. Works for:
207
- // SSH: git@github.com:ORG/den.git
208
- // HTTPS: https://github.com/ORG/den.git
209
- // https://github.com/ORG/den
210
- try {
211
- const url = execFileSync(
212
- "git",
213
- ["-C", ROOT, "config", "--get", "remote.origin.url"],
214
- {
215
- encoding: "utf-8",
216
- timeout: GIT_REMOTE_TIMEOUT_MS,
217
- stdio: ["ignore", "pipe", "ignore"],
218
- },
219
- ).trim();
220
- const m = url.match(/[:/]([^/:]+)\/[^/]+?(?:\.git)?$/);
221
- if (m) return m[1];
222
- } catch {
223
- /* no git remote available */
224
- }
225
-
226
- return null;
203
+ return [...orgs].filter(
204
+ (o) => /^[A-Za-z0-9][A-Za-z0-9-]*$/.test(o) && !isPlaceholderGithubOrg(o),
205
+ );
227
206
  }
228
207
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The Graph page (R22, 0.3.0-dev.32, Ward 2026-11-05): the workspace graph
3
+ * alone, full width, at /graph. It is served from local-dev.html (the
4
+ * Workspaces page, R24) with the shell's page id and the <title> swapped:
5
+ * the same graph, actions and details panel, no second copy of them. With
6
+ * page id "graph" the page hides the workspace list and draws only the
7
+ * graph.
8
+ */
9
+
10
+ const SHELL = '<div id="den-shell" data-page="local-dev"';
11
+ const TITLE = "· Workspaces</title>";
12
+
13
+ /** local-dev.html → the Graph page. Throws when the markers are gone. */
14
+ export function graphPageHtml(localDev: string): string {
15
+ if (!localDev.includes(SHELL) || !localDev.includes(TITLE))
16
+ throw new Error("local-dev.html: the Graph page's markers are missing");
17
+ return localDev
18
+ .replace(SHELL, '<div id="den-shell" data-page="graph"')
19
+ .replace(TITLE, "· Graph</title>");
20
+ }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Graph preferences: how the Local Dev graph is laid out for this engineer.
2
+ * Graph preferences: how the graph is laid out for this engineer, and the
3
+ * Workspaces page's own view prefs (its list, its table's grouping).
3
4
  * Stored in the user config as `graph:` and served by /api/settings, so the
4
5
  * layout is remembered between sessions (the page mirrors it to localStorage
5
6
  * so the first paint does not wait on the server).
@@ -14,6 +15,16 @@ export interface GraphPrefs {
14
15
  density: "detailed" | "compact";
15
16
  /** Details panel width in px. */
16
17
  panelWidth: number;
18
+ /**
19
+ * The Workspaces page's workspace list: open, or collapsed to a 40px strip (R23,
20
+ * 0.3.0-dev.30, Ward QA 2026-11-05), so the graph gets the width.
21
+ */
22
+ workspaces: "open" | "collapsed";
23
+ /**
24
+ * The Workspaces page's repo table (R24d, 0.3.0-dev.35): grouped by
25
+ * nothing, by application (as `<cli> apps`) or by org.
26
+ */
27
+ groupBy: "none" | "app" | "org";
17
28
  }
18
29
 
19
30
  export const GRAPH_DEFAULTS: GraphPrefs = {
@@ -21,12 +32,16 @@ export const GRAPH_DEFAULTS: GraphPrefs = {
21
32
  group: "org",
22
33
  density: "detailed",
23
34
  panelWidth: 440,
35
+ workspaces: "open",
36
+ groupBy: "none",
24
37
  };
25
38
 
26
39
  const CHOICES = {
27
40
  direction: ["down", "right"],
28
41
  group: ["org", "app"],
29
42
  density: ["detailed", "compact"],
43
+ workspaces: ["open", "collapsed"],
44
+ groupBy: ["none", "app", "org"],
30
45
  } as const;
31
46
 
32
47
  const PANEL_MIN = 360;
@@ -87,3 +102,31 @@ export function mergeGraphPrefs(
87
102
  }
88
103
  return { ok: true, value: out };
89
104
  }
105
+
106
+ /**
107
+ * The stored block after a POSTed patch (0.3.0-dev.32, a consumer's nit):
108
+ * only the keys someone set are kept, and a key set to null is removed, so
109
+ * setting a pref and clearing it again leaves user.yml as it was. Nothing
110
+ * left: undefined (the caller drops `graph:`). Any bad field fails it.
111
+ */
112
+ export function patchStoredGraph(
113
+ stored: unknown,
114
+ patch: unknown,
115
+ ):
116
+ | { ok: true; value: Record<string, unknown> | undefined }
117
+ | { ok: false; error: string } {
118
+ if (!isObject(patch)) return { ok: false, error: "graph must be an object" };
119
+ const out: Record<string, unknown> = isObject(stored) ? { ...stored } : {};
120
+ for (const [key, v] of Object.entries(patch)) {
121
+ if (!(key in GRAPH_DEFAULTS))
122
+ return { ok: false, error: `unknown graph setting "${key}"` };
123
+ if (v === null) {
124
+ delete out[key];
125
+ continue;
126
+ }
127
+ const c = check(key as keyof GraphPrefs, v);
128
+ if (!c.ok) return c;
129
+ out[key] = c.value;
130
+ }
131
+ return { ok: true, value: Object.keys(out).length ? out : undefined };
132
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Errors people can see and act on (R26, 0.3.0-dev.38).
3
+ *
4
+ * R26a a page request (a browser navigation: GET/HEAD, Accept text/html,
5
+ * not /api/*) that fails gets the shell frame around an error page
6
+ * (public/error.html, drawn by error-page.js): 404 "Page not found"
7
+ * or 500 "Something went wrong", with the request id (422 "Config
8
+ * is invalid" for a ConfigError).
9
+ * R26b every /api/* error is JSON, one shape:
10
+ * { ok: false, error, code, requestId }. jsonApiErrors() makes that
11
+ * true for every route at the response, whatever the route sent
12
+ * (send400's text, a route's own { ok: false, error } body, …).
13
+ *
14
+ * Each request gets a short id (X-Request-Id on every response); the
15
+ * server log line of a failure carries it, so a person's "request a1b2c3d4"
16
+ * finds the stack trace.
17
+ */
18
+ import { randomBytes } from "crypto";
19
+ import * as fs from "fs";
20
+ import * as http from "http";
21
+ import * as path from "path";
22
+ import { PLUGIN_FILE, PLUGIN_ID } from "../../shared/plugins/manifest.js";
23
+ import { PLUGINS_DIR, readLock } from "../../shared/plugins/store.js";
24
+
25
+ /** A short request id: 8 hex characters. */
26
+ export function newRequestId(): string {
27
+ return randomBytes(4).toString("hex");
28
+ }
29
+
30
+ /** The stable `code` for an HTTP status. */
31
+ export function codeForStatus(status: number): string {
32
+ const codes: Record<number, string> = {
33
+ 400: "bad_request",
34
+ 401: "unauthorized",
35
+ 403: "forbidden",
36
+ 404: "not_found",
37
+ 405: "method_not_allowed",
38
+ 408: "timeout",
39
+ 409: "conflict",
40
+ 410: "gone",
41
+ 413: "too_large",
42
+ 415: "unsupported_media_type",
43
+ 422: "invalid",
44
+ 423: "locked",
45
+ 429: "rate_limited",
46
+ 500: "internal",
47
+ 501: "not_implemented",
48
+ 502: "bad_gateway",
49
+ 503: "unavailable",
50
+ 504: "timeout",
51
+ };
52
+ return codes[status] ?? (status >= 500 ? "internal" : `http_${status}`);
53
+ }
54
+
55
+ export interface ApiErrorBody {
56
+ ok: false;
57
+ error: string;
58
+ code: string;
59
+ requestId: string;
60
+ [field: string]: unknown;
61
+ }
62
+
63
+ /**
64
+ * The R26b body for an error response: `raw` is what the route sent. A JSON
65
+ * object keeps its fields (a route's own `runCmd`, `reason`, …) and gains
66
+ * ok, error, code and requestId; text becomes the message.
67
+ */
68
+ export function apiErrorBody(
69
+ status: number,
70
+ raw: string,
71
+ requestId: string,
72
+ ): ApiErrorBody {
73
+ const fallback = http.STATUS_CODES[status] ?? `HTTP ${status}`;
74
+ let obj: Record<string, unknown> | null = null;
75
+ const text = raw.trim();
76
+ if (text.startsWith("{")) {
77
+ try {
78
+ const v = JSON.parse(text) as unknown;
79
+ if (v && typeof v === "object" && !Array.isArray(v))
80
+ obj = v as Record<string, unknown>;
81
+ } catch {
82
+ /* text that looks like JSON: the message */
83
+ }
84
+ }
85
+ if (obj) {
86
+ const error =
87
+ typeof obj.error === "string" && obj.error
88
+ ? obj.error
89
+ : typeof obj.message === "string" && obj.message
90
+ ? obj.message
91
+ : fallback;
92
+ return {
93
+ ...obj,
94
+ ok: false,
95
+ error,
96
+ code:
97
+ typeof obj.code === "string" && obj.code
98
+ ? obj.code
99
+ : codeForStatus(status),
100
+ requestId,
101
+ };
102
+ }
103
+ return {
104
+ ok: false,
105
+ error: text || fallback,
106
+ code: codeForStatus(status),
107
+ requestId,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Make every error status this response ends with a JSON R26b body. A 2xx
113
+ * or 3xx (an SSE stream among them) passes through untouched; an error's
114
+ * body is held until end() and rewritten. Call once per /api request,
115
+ * before the route runs.
116
+ */
117
+ export function jsonApiErrors(
118
+ res: http.ServerResponse,
119
+ requestId: string,
120
+ ): void {
121
+ const origWriteHead = res.writeHead.bind(res) as (
122
+ ...a: unknown[]
123
+ ) => http.ServerResponse;
124
+ const origWrite = res.write.bind(res) as (...a: unknown[]) => boolean;
125
+ const origEnd = res.end.bind(res) as (...a: unknown[]) => http.ServerResponse;
126
+ let held: { status: number; headers: http.OutgoingHttpHeaders } | null = null;
127
+ const chunks: Buffer[] = [];
128
+ const isError = (s: number): boolean => s >= 400;
129
+
130
+ const hold = (status: number, headers: unknown): void => {
131
+ const h: http.OutgoingHttpHeaders = {};
132
+ if (Array.isArray(headers)) {
133
+ for (let i = 0; i + 1 < headers.length; i += 2)
134
+ h[String(headers[i]).toLowerCase()] = headers[i + 1] as string;
135
+ } else if (headers && typeof headers === "object") {
136
+ for (const [k, v] of Object.entries(headers))
137
+ h[k.toLowerCase()] = v as string;
138
+ }
139
+ held = { status, headers: h };
140
+ };
141
+
142
+ (res as unknown as { writeHead: unknown }).writeHead = (
143
+ status: number,
144
+ ...rest: unknown[]
145
+ ) => {
146
+ if (!isError(status)) return origWriteHead(status, ...rest);
147
+ const headers =
148
+ typeof rest[0] === "string" ? rest[1] : (rest[0] as unknown);
149
+ hold(status, headers);
150
+ return res;
151
+ };
152
+ (res as unknown as { write: unknown }).write = (
153
+ chunk: unknown,
154
+ ...rest: unknown[]
155
+ ) => {
156
+ if (!held && !res.headersSent && isError(res.statusCode))
157
+ hold(res.statusCode, {});
158
+ if (!held) return origWrite(chunk, ...rest);
159
+ if (chunk != null && typeof chunk !== "function")
160
+ chunks.push(
161
+ Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf-8"),
162
+ );
163
+ return true;
164
+ };
165
+ (res as unknown as { end: unknown }).end = (...args: unknown[]) => {
166
+ if (!held && !res.headersSent && isError(res.statusCode))
167
+ hold(res.statusCode, {});
168
+ if (!held) return origEnd(...args);
169
+ const chunk = args[0];
170
+ if (chunk != null && typeof chunk !== "function")
171
+ chunks.push(
172
+ Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf-8"),
173
+ );
174
+ const h = held as { status: number; headers: http.OutgoingHttpHeaders };
175
+ held = null;
176
+ const body = JSON.stringify(
177
+ apiErrorBody(
178
+ h.status,
179
+ Buffer.concat(chunks).toString("utf-8"),
180
+ requestId,
181
+ ),
182
+ );
183
+ const headers: http.OutgoingHttpHeaders = { ...h.headers };
184
+ delete headers["content-length"];
185
+ headers["content-type"] = "application/json";
186
+ headers["cache-control"] = "no-store";
187
+ origWriteHead(h.status, headers);
188
+ return origEnd(body);
189
+ };
190
+ }
191
+
192
+ /** A browser navigation to a page: what gets the shell's error page. */
193
+ export function wantsHtmlPage(
194
+ req: http.IncomingMessage,
195
+ pathname: string,
196
+ ): boolean {
197
+ if (req.method !== "GET" && req.method !== "HEAD") return false;
198
+ if (pathname === "/api" || pathname.startsWith("/api/")) return false;
199
+ return String(req.headers.accept ?? "").includes("text/html");
200
+ }
201
+
202
+ /**
203
+ * What failed, for a person and the log, never with query values: the
204
+ * path, with id-like segments (numbers, long hex, uuids) as `:id`.
205
+ */
206
+ export function routeTemplate(pathname: string): string {
207
+ return (
208
+ pathname
209
+ .split("/")
210
+ .map((seg) =>
211
+ /^\d+$/.test(seg) ||
212
+ /^[0-9a-f]{12,}$/i.test(seg) ||
213
+ /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(seg)
214
+ ? ":id"
215
+ : seg,
216
+ )
217
+ .join("/") || "/"
218
+ );
219
+ }
220
+
221
+ export interface PluginRoute {
222
+ id: string;
223
+ /** Its name from its manifest, when it is installed. */
224
+ name: string | null;
225
+ installed: boolean;
226
+ /** In plugins.lock.json (added, but its files are missing). */
227
+ locked: boolean;
228
+ }
229
+
230
+ /**
231
+ * The plugin a page path belongs to: plugin pages live under
232
+ * /plugins/<id>/ (PL-SPEC §4: served from plugins/<id>/). Null for any
233
+ * other path.
234
+ */
235
+ export function pluginForPath(
236
+ pathname: string,
237
+ distRoot: string,
238
+ ): PluginRoute | null {
239
+ const m = pathname.match(/^\/plugins\/([^/]+)(?:\/|$)/);
240
+ if (!m || !PLUGIN_ID.test(m[1])) return null;
241
+ const id = m[1];
242
+ const dir = path.join(distRoot, PLUGINS_DIR, id);
243
+ let name: string | null = null;
244
+ let installed = false;
245
+ try {
246
+ const manifest = JSON.parse(
247
+ fs.readFileSync(path.join(dir, PLUGIN_FILE), "utf-8"),
248
+ ) as { name?: unknown };
249
+ installed = true;
250
+ if (typeof manifest.name === "string") name = manifest.name;
251
+ } catch {
252
+ installed = fs.existsSync(path.join(dir, "den-theme.json"));
253
+ }
254
+ let locked = false;
255
+ try {
256
+ locked = Object.prototype.hasOwnProperty.call(readLock(distRoot), id);
257
+ } catch {
258
+ /* no lock */
259
+ }
260
+ return { id, name, installed, locked };
261
+ }
262
+
263
+ /** What error.html is told: everything error-page.js draws from. */
264
+ export interface ErrorPageData {
265
+ /** 422: the config fails validation (a ConfigError). */
266
+ status: 404 | 422 | 500;
267
+ /** 404 and 422: the path asked for (no query). */
268
+ path?: string;
269
+ /** 422: the ConfigError's message (it names config keys, never values). */
270
+ message?: string;
271
+ /** 422: each problem: its file, key path and message (0.3.0-dev.40). */
272
+ issues?: Array<{ file: string; key: string; message: string }>;
273
+ /** 422: the distribution's CLI, for "`<cli> validate`". */
274
+ cli?: string;
275
+ /** 500: what failed. */
276
+ method?: string;
277
+ route?: string;
278
+ requestId: string;
279
+ time: string;
280
+ version: string | null;
281
+ plugin?: PluginRoute | null;
282
+ /** MD-2: the restart message, when the engine on disk is newer. */
283
+ engine?: { loaded: string | null; onDisk: string | null; message: string };
284
+ }
285
+
286
+ /**
287
+ * error.html with its data in a JSON script (a `<` in any value can't
288
+ * close the script: it is escaped).
289
+ */
290
+ export function fillErrorPage(html: string, data: ErrorPageData): string {
291
+ const json = JSON.stringify(data).replace(/</g, "\\u003c");
292
+ return html.replace("@@DEN_ERROR@@", () => json);
293
+ }
@@ -65,21 +65,40 @@ export function sendJson(
65
65
  res.end(body);
66
66
  }
67
67
 
68
- export function serveFile(res: http.ServerResponse, filePath: string): void {
68
+ /**
69
+ * A last fill for every served page, set by the server: the appearance
70
+ * placeholders the pre-paint script reads (@@DEN_MODE@@, @@DEN_DEFAULT_MODE@@).
71
+ */
72
+ let htmlFill: ((html: string) => string) | null = null;
73
+ export function setHtmlFill(fn: ((html: string) => string) | null): void {
74
+ htmlFill = fn;
75
+ }
76
+
77
+ export function serveFile(
78
+ res: http.ServerResponse,
79
+ filePath: string,
80
+ /** A page served from another's file (the Graph page): its raw text in, out. */
81
+ transform?: (html: string) => string,
82
+ /** The status to answer with (an error page's: R26a). */
83
+ status = 200,
84
+ ): void {
69
85
  try {
70
86
  let data: Buffer | string = fs.readFileSync(filePath);
87
+ if (transform) data = transform(data.toString("utf-8"));
71
88
  // Pages are static and product-neutral: their product names are
72
89
  // placeholders (__DEN_CLI__, …) filled from the running profile.
73
90
  if (filePath.endsWith(".html") || filePath.endsWith(".js"))
74
91
  data = fillProductTokens(data.toString("utf-8"));
75
- if (filePath.endsWith(".html"))
92
+ if (filePath.endsWith(".html")) {
76
93
  data = addDenUiMarker(brandHtmlTitle(String(data), readBrand()));
94
+ if (htmlFill) data = htmlFill(data);
95
+ }
77
96
  const headers: Record<string, string> = { "Content-Type": mime(filePath) };
78
97
  // App code must never be stale (no ETag/Last-Modified validators are sent,
79
98
  // so any heuristic caching would have no way to revalidate).
80
99
  if (filePath.endsWith(".html") || filePath.endsWith(".js"))
81
100
  headers["Cache-Control"] = "no-store";
82
- res.writeHead(200, headers);
101
+ res.writeHead(status, headers);
83
102
  res.end(data);
84
103
  } catch {
85
104
  res.writeHead(404, { "Content-Type": "text/plain" });