@monster-orange/den 0.0.0 → 0.2.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 (255) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +68 -0
  5. package/docs/distributions.md +499 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +101 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +403 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +124 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +757 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/agent-actor.ts +77 -0
  91. package/src/shared/applications.ts +138 -0
  92. package/src/shared/auth/api-token.ts +416 -0
  93. package/src/shared/auth/auth0-helper.ts +284 -0
  94. package/src/shared/auth/headless-login.ts +265 -0
  95. package/src/shared/auth/jwt-helper.ts +186 -0
  96. package/src/shared/auth/sudo-vault.ts +69 -0
  97. package/src/shared/auth/test-accounts.ts +98 -0
  98. package/src/shared/auth/token-injection.ts +184 -0
  99. package/src/shared/auth/token-providers/auth0.ts +80 -0
  100. package/src/shared/auth/token-providers/index.ts +182 -0
  101. package/src/shared/auth/token-refresh.ts +273 -0
  102. package/src/shared/banner.ts +15 -0
  103. package/src/shared/brand.ts +79 -0
  104. package/src/shared/cli-tabs.ts +131 -0
  105. package/src/shared/constants.ts +128 -0
  106. package/src/shared/den-config.ts +688 -0
  107. package/src/shared/dev-metrics/emit.ts +354 -0
  108. package/src/shared/edge/config.ts +229 -0
  109. package/src/shared/edge/render.ts +221 -0
  110. package/src/shared/edge/runtime.ts +268 -0
  111. package/src/shared/edge/status.ts +74 -0
  112. package/src/shared/edge/types.ts +62 -0
  113. package/src/shared/engine.ts +67 -0
  114. package/src/shared/environment/aws-profiles.ts +49 -0
  115. package/src/shared/environment/env-selector.ts +161 -0
  116. package/src/shared/environment/playwright-env.ts +82 -0
  117. package/src/shared/environment-manager/catalog.ts +77 -0
  118. package/src/shared/environment-manager/clone-guard.ts +163 -0
  119. package/src/shared/environment-manager/dag.ts +286 -0
  120. package/src/shared/environment-manager/default-workspace.ts +182 -0
  121. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  122. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  123. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  124. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  125. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  126. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  127. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  128. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  129. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  130. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  131. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  132. package/src/shared/environment-manager/derivation/text.ts +40 -0
  133. package/src/shared/environment-manager/derivation/types.ts +69 -0
  134. package/src/shared/environment-manager/health.ts +635 -0
  135. package/src/shared/environment-manager/index.ts +6 -0
  136. package/src/shared/environment-manager/load-applications.ts +108 -0
  137. package/src/shared/environment-manager/log-tail.ts +213 -0
  138. package/src/shared/environment-manager/make-targets.ts +78 -0
  139. package/src/shared/environment-manager/opctl.ts +335 -0
  140. package/src/shared/environment-manager/readiness.ts +93 -0
  141. package/src/shared/environment-manager/register-local.ts +72 -0
  142. package/src/shared/environment-manager/secrets-check.ts +260 -0
  143. package/src/shared/environment-manager/service-container.ts +47 -0
  144. package/src/shared/environment-manager/service-env.ts +110 -0
  145. package/src/shared/environment-manager/service-runner.ts +1603 -0
  146. package/src/shared/environment-manager/sync-targets.ts +66 -0
  147. package/src/shared/environment-manager/up-plan.ts +326 -0
  148. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  149. package/src/shared/environment-manager/workspaces.ts +860 -0
  150. package/src/shared/manifest/deps.ts +27 -0
  151. package/src/shared/manifest/merge-override.ts +75 -0
  152. package/src/shared/manifest/migrate-manifest.ts +101 -0
  153. package/src/shared/manifest/placement.ts +321 -0
  154. package/src/shared/manifest/read-manifest.ts +278 -0
  155. package/src/shared/manifest/schema.ts +313 -0
  156. package/src/shared/manifest/service-links.ts +93 -0
  157. package/src/shared/manifest/validate-layers.ts +229 -0
  158. package/src/shared/manifest/validate-manifest.ts +200 -0
  159. package/src/shared/manifest/workspace-manifests.ts +92 -0
  160. package/src/shared/open-url.ts +122 -0
  161. package/src/shared/org.ts +44 -0
  162. package/src/shared/paths.ts +190 -0
  163. package/src/shared/perf/optional-fields.ts +105 -0
  164. package/src/shared/perf/report-normalizer.ts +404 -0
  165. package/src/shared/perf/report-paths.ts +19 -0
  166. package/src/shared/perf/report-store.ts +110 -0
  167. package/src/shared/perf/report-types.ts +70 -0
  168. package/src/shared/perf/test-paths.ts +25 -0
  169. package/src/shared/process/log-rotation.ts +147 -0
  170. package/src/shared/process/spawn-streaming.ts +229 -0
  171. package/src/shared/product.ts +92 -0
  172. package/src/shared/profile.ts +141 -0
  173. package/src/shared/redact.ts +107 -0
  174. package/src/shared/repo-kind.ts +32 -0
  175. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  176. package/src/shared/reporting/slack-reporter.ts +340 -0
  177. package/src/shared/results/result-reader.ts +266 -0
  178. package/src/shared/results/result-writer.ts +168 -0
  179. package/src/shared/secrets/doppler.ts +126 -0
  180. package/src/shared/secrets/env.ts +48 -0
  181. package/src/shared/secrets/index.ts +197 -0
  182. package/src/shared/secrets/keys.ts +33 -0
  183. package/src/shared/secrets/onepassword.ts +75 -0
  184. package/src/shared/secrets/types.ts +52 -0
  185. package/src/shared/service-url.ts +119 -0
  186. package/src/shared/smoke/branch-resolver.ts +141 -0
  187. package/src/shared/smoke/git-remote.ts +70 -0
  188. package/src/shared/smoke/repo-cloner.ts +151 -0
  189. package/src/shared/stacks/config.ts +137 -0
  190. package/src/shared/stacks/manifest-commands.ts +118 -0
  191. package/src/shared/stacks/render.ts +253 -0
  192. package/src/shared/stacks/runtime.ts +334 -0
  193. package/src/shared/stacks/status.ts +99 -0
  194. package/src/shared/stacks/types.ts +53 -0
  195. package/src/shared/terminal/tabs.ts +212 -0
  196. package/src/shared/test-discovery/module-scanner.ts +569 -0
  197. package/src/shared/types/index.ts +701 -0
  198. package/src/shared/ui-marker.ts +19 -0
  199. package/src/ui/cli/applications-command.ts +148 -0
  200. package/src/ui/cli/config-command.ts +68 -0
  201. package/src/ui/cli/create-main.ts +9 -0
  202. package/src/ui/cli/create.ts +360 -0
  203. package/src/ui/cli/edge-command.ts +81 -0
  204. package/src/ui/cli/flag-parser.ts +246 -0
  205. package/src/ui/cli/graph-command.ts +570 -0
  206. package/src/ui/cli/health-display.ts +63 -0
  207. package/src/ui/cli/index.ts +3742 -0
  208. package/src/ui/cli/init.ts +855 -0
  209. package/src/ui/cli/invocation-target.ts +175 -0
  210. package/src/ui/cli/logs-command.ts +104 -0
  211. package/src/ui/cli/mcp-main.ts +13 -0
  212. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  213. package/src/ui/cli/module-selection.ts +31 -0
  214. package/src/ui/cli/prompts.ts +767 -0
  215. package/src/ui/cli/results-viewer.ts +436 -0
  216. package/src/ui/cli/secrets-preflight.ts +91 -0
  217. package/src/ui/cli/stack-command.ts +117 -0
  218. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  219. package/src/ui/cli/test-runner.ts +1319 -0
  220. package/src/ui/cli/web-command.ts +1073 -0
  221. package/src/ui/mcp/api.ts +284 -0
  222. package/src/ui/mcp/guards.ts +141 -0
  223. package/src/ui/mcp/server.ts +147 -0
  224. package/src/ui/mcp/tools.ts +565 -0
  225. package/src/ui/server/audit.ts +71 -0
  226. package/src/ui/server/changelog-parser.ts +105 -0
  227. package/src/ui/server/daemon-reaper.ts +129 -0
  228. package/src/ui/server/editor-manager.ts +301 -0
  229. package/src/ui/server/github-service.ts +223 -0
  230. package/src/ui/server/graph-prefs.ts +89 -0
  231. package/src/ui/server/health-row.ts +15 -0
  232. package/src/ui/server/http-helpers.ts +98 -0
  233. package/src/ui/server/index.ts +3350 -0
  234. package/src/ui/server/log-history.ts +453 -0
  235. package/src/ui/server/perf-report.ts +42 -0
  236. package/src/ui/server/public/environments.html +496 -0
  237. package/src/ui/server/public/favicon.ico +0 -0
  238. package/src/ui/server/public/graph-model.js +416 -0
  239. package/src/ui/server/public/graph.css +976 -0
  240. package/src/ui/server/public/graph.js +986 -0
  241. package/src/ui/server/public/header.js +1373 -0
  242. package/src/ui/server/public/index.html +2595 -0
  243. package/src/ui/server/public/local-dev.html +5344 -0
  244. package/src/ui/server/public/logs.html +1496 -0
  245. package/src/ui/server/public/panel.js +787 -0
  246. package/src/ui/server/public/perf-report.js +359 -0
  247. package/src/ui/server/public/release.html +2111 -0
  248. package/src/ui/server/public/services.html +1103 -0
  249. package/src/ui/server/public/workspace-picker.js +69 -0
  250. package/src/ui/server/readonly-routes.ts +200 -0
  251. package/src/ui/server/release-deploy-session.ts +246 -0
  252. package/src/ui/server/release-service.ts +1125 -0
  253. package/src/ui/server/sudo-store.ts +111 -0
  254. package/src/ui/server/ui-assets.ts +73 -0
  255. package/src/ui/server/workspace-routes.ts +2185 -0
@@ -0,0 +1,3350 @@
1
+ /**
2
+ * den Web UI Server
3
+ *
4
+ * Serves a browser-based test runner at http://localhost:7777
5
+ * Endpoints:
6
+ * GET / — UI (index.html)
7
+ * GET /report/* — Playwright HTML report (playwright-report/)
8
+ * GET /api/environments — Available environments
9
+ * GET /api/modules?pillar=X — Modules for a pillar (via moduleScanner)
10
+ * GET /api/run?pillar=X&... — SSE stream: runs tests, emits output line-by-line
11
+ */
12
+
13
+ import { composeHealthCmd } from "../../shared/stacks/manifest-commands.js";
14
+ import { healthCheckFields } from "./health-row.js";
15
+ import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
16
+ import * as http from "http";
17
+ import * as fs from "fs";
18
+ import * as os from "os";
19
+ import * as path from "path";
20
+ import { execSync, spawn, spawnSync } from "child_process";
21
+ import { fileURLToPath } from "url";
22
+ import { envSelector } from "../../shared/environment/env-selector.js";
23
+ import { moduleScanner } from "../../shared/test-discovery/module-scanner.js";
24
+ import { resolveFieldEnv } from "../../shared/perf/optional-fields.js";
25
+ import { testRunner } from "../cli/test-runner.js";
26
+ import { discoverRepos } from "../../pillars/smoke/discover.js";
27
+ import {
28
+ awsProfileEnv,
29
+ loadAwsProfiles,
30
+ } from "../../shared/environment/aws-profiles.js";
31
+ import { loadCurrentPerfRunReport } from "../../shared/perf/report-store.js";
32
+ import type {
33
+ Pillar,
34
+ Environment,
35
+ LogSource,
36
+ ResolvedService,
37
+ WorkspaceConfig,
38
+ } from "../../shared/types/index.js";
39
+ import { getRunDonePayload } from "./perf-report.js";
40
+ import { readCatalog } from "../../shared/environment-manager/catalog.js";
41
+ import {
42
+ readWorkspacesFile,
43
+ getActiveWorkspace,
44
+ getActiveWorkspaces,
45
+ getFocusedWorkspaceName,
46
+ getWorkspaceByName,
47
+ isWorkspaceActive,
48
+ listWorkspaceServices,
49
+ resolveDepAddress,
50
+ parseAddress,
51
+ readManifestAnyBranch,
52
+ noAutostartRepos,
53
+ } from "../../shared/environment-manager/workspaces.js";
54
+ import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
55
+ import { orderForDown } from "../../shared/environment-manager/derivation/derived-up.js";
56
+ import { findServiceContainer } from "../../shared/environment-manager/service-container.js";
57
+ import {
58
+ resolveHistorySource,
59
+ ensureSnapshot,
60
+ readPageBefore,
61
+ searchSystemHistory,
62
+ pruneDeadSnapshots,
63
+ isSafeServiceName,
64
+ } from "./log-history.js";
65
+ import {
66
+ pingHttp,
67
+ pingShell,
68
+ } from "../../shared/environment-manager/health.js";
69
+ import {
70
+ runServiceCommand,
71
+ resolveService,
72
+ listDaemons,
73
+ unregisterDaemon,
74
+ checkServiceHealth,
75
+ loadDaemonsFromFile,
76
+ pruneDeadDaemons,
77
+ isInteractiveServiceCommand,
78
+ rewriteLegacyOpctlKillCommandForContainer,
79
+ shouldDetachServiceCommand,
80
+ } from "../../shared/environment-manager/service-runner.js";
81
+ import { reapDeadDaemons } from "./daemon-reaper.js";
82
+ import {
83
+ loadManifest,
84
+ readManifest,
85
+ } from "../../shared/manifest/read-manifest.js";
86
+ import {
87
+ getServiceLogRoot,
88
+ spawnStreaming,
89
+ getServiceLogPath,
90
+ } from "../../shared/process/spawn-streaming.js";
91
+ import {
92
+ readDenConfig,
93
+ isProductionEnv,
94
+ isLocalEnabled,
95
+ readUserConfig,
96
+ writeUserConfig,
97
+ readReleaseConfig,
98
+ resolveReleaseTarget,
99
+ writeUserReleaseConfig,
100
+ type ReleaseConfig,
101
+ } from "../../shared/den-config.js";
102
+ import { readOrg } from "../../shared/org.js";
103
+ import { engineVersion, getDistRoot } from "../../shared/paths.js";
104
+ import { enginePath } from "../../shared/engine.js";
105
+ import {
106
+ releaseService,
107
+ DEPLOYS_OFF_MESSAGE,
108
+ type DeployPlanEntry,
109
+ type ReleaseEvent,
110
+ } from "./release-service.js";
111
+ import { deploySessions } from "./release-deploy-session.js";
112
+ import { spawnDetachedWebRestart } from "../cli/web-command.js";
113
+ import { recordInvocation } from "../../shared/dev-metrics/emit.js";
114
+ import {
115
+ validateManifest,
116
+ validateManifestLayers,
117
+ } from "../../shared/manifest/validate-manifest.js";
118
+ import { depService } from "../../shared/manifest/deps.js";
119
+ import {
120
+ placementRefusal,
121
+ resolveHealthCheck,
122
+ servicePlacement,
123
+ type Expect,
124
+ type Placement,
125
+ } from "../../shared/manifest/placement.js";
126
+ import {
127
+ isOpSignedIn,
128
+ hasSudoInVault,
129
+ readSudoFromVault,
130
+ saveSudoToVault,
131
+ } from "../../shared/auth/sudo-vault.js";
132
+ import { DEN_BANNER_LINES, DEN_SUBTITLE } from "../../shared/banner.js";
133
+ import { actorLabel } from "../../shared/agent-actor.js";
134
+ import {
135
+ actorFromHeaders,
136
+ actorSuffix,
137
+ recordAction,
138
+ setAuditSink,
139
+ } from "./audit.js";
140
+ import { handleReadonlyRoutes } from "./readonly-routes.js";
141
+ import { serveUiAsset } from "./ui-assets.js";
142
+ import { graphPrefs, mergeGraphPrefs } from "./graph-prefs.js";
143
+
144
+ const __filename = fileURLToPath(import.meta.url);
145
+ const __dirname = path.dirname(__filename);
146
+
147
+ const ROOT = getDistRoot();
148
+ const DIST_PUBLIC_DIR = path.join(__dirname, "public");
149
+ const SOURCE_PUBLIC_DIR = enginePath("src/ui/server/public");
150
+ const PUBLIC_DIR = fs.existsSync(path.join(DIST_PUBLIC_DIR, "index.html"))
151
+ ? DIST_PUBLIC_DIR
152
+ : SOURCE_PUBLIC_DIR;
153
+ const REPORT_DIR = path.join(ROOT, "playwright-report");
154
+
155
+ /** One service's health, as returned by /api/health and its stream. */
156
+ interface HealthResult {
157
+ repo: string;
158
+ service: string;
159
+ label: string;
160
+ /** The HTTP check's URL, or null (never a command: see `check`). */
161
+ url: string | null;
162
+ /** A shell check's command (e.g. a compose stack's `stack health`). */
163
+ check?: string;
164
+ repoPath: string;
165
+ mode: "dev-managed" | "managed";
166
+ isEntrypoint: boolean;
167
+ ok: boolean | null;
168
+ status?: number;
169
+ error?: string;
170
+ /** Where the app opens for this env (open-in-new-tab), or null. */
171
+ openUrl?: string | null;
172
+ /** Extra named links for this env. */
173
+ links?: OpenLink[];
174
+ /** Where it runs for this env, and where a remote one is (R13). */
175
+ runs: Placement["runs"];
176
+ target: Placement["target"];
177
+ }
178
+
179
+ /**
180
+ * Health is cached briefly so switching pages doesn't re-run every check. Short
181
+ * enough that a service going down still shows up within one poll interval.
182
+ */
183
+ /**
184
+ * Health results are cached host-agnostic; open links are rewritten per
185
+ * request so a viewer on an edge alias (e.g. *.internal) gets links that
186
+ * resolve from where they are.
187
+ */
188
+ function viewerRewrite(
189
+ host: string | undefined,
190
+ ): (r: HealthResult) => HealthResult {
191
+ let edge: { domain: string; aliases: string[] } | null = null;
192
+ try {
193
+ const cfg = readEdgeConfig();
194
+ edge = { domain: cfg.domain, aliases: cfg.aliases ?? [] };
195
+ } catch {
196
+ /* no edge configured */
197
+ }
198
+ if (!edge || !edge.aliases.length) return (r) => r;
199
+ const { domain, aliases } = edge;
200
+ const fix = (u: string) => rehost(u, domain, host, aliases);
201
+ return (r) => ({
202
+ ...r,
203
+ openUrl: r.openUrl ? fix(r.openUrl) : r.openUrl,
204
+ links: r.links?.map((l) => ({ ...l, url: fix(l.url) })),
205
+ });
206
+ }
207
+
208
+ const HEALTH_CACHE_TTL_MS = 8_000;
209
+ const healthCache = new Map<string, { results: HealthResult[]; at: number }>();
210
+ /** A workspace-scoped run covers a different service set than the default one. */
211
+ function healthCacheKey(env: Environment, workspace: string | null): string {
212
+ return `${env}|${workspace ?? "*"}`;
213
+ }
214
+ import {
215
+ LOG_SWEEP_INTERVAL_MS,
216
+ DEFAULT_PORT,
217
+ CLI_SPAWN_TIMEOUT_MS,
218
+ GIT_REMOTE_TIMEOUT_MS,
219
+ DAEMON_QUICK_EXIT_MS,
220
+ DAEMON_RETRY_DELAY_MS,
221
+ DAEMON_SHUTDOWN_GRACE_MS,
222
+ LIVE_TAIL_LINES,
223
+ HEALTH_CHECK_TIMEOUT_MS,
224
+ HOSTED_HEALTH_CHECK_TIMEOUT_MS,
225
+ } from "../../shared/constants.js";
226
+
227
+ const PORT = parseInt(process.env.PORT ?? String(readWebPortSafe()), 10);
228
+
229
+ // When this server process started. Used to detect a "stale" server — one whose
230
+ // loaded TypeScript is older than the on-disk source (the footgun where `make
231
+ // web`/`den web` keeps running old code after an edit). See computeStale().
232
+ const SERVER_STARTED_AT = Date.now();
233
+ const STALE_CACHE_TTL_MS = 2_000;
234
+ let _staleCache: { at: number; stale: boolean; scanned: number } | null = null;
235
+
236
+ /** Newest mtime (ms) among *.ts under `dir`, skipping node_modules + public/. */
237
+ function scanNewestTs(dir: string): { newest: number; count: number } {
238
+ let newest = 0;
239
+ let count = 0;
240
+ const stack = [dir];
241
+ while (stack.length) {
242
+ const d = stack.pop() as string;
243
+ let entries: fs.Dirent[];
244
+ try {
245
+ entries = fs.readdirSync(d, { withFileTypes: true });
246
+ } catch {
247
+ continue;
248
+ }
249
+ for (const e of entries) {
250
+ if (e.isDirectory()) {
251
+ // public/* is served fresh from disk per request (never stale); the
252
+ // server-side code that IS loaded at startup is the *.ts graph.
253
+ if (e.name === "node_modules" || e.name === "public") continue;
254
+ stack.push(path.join(d, e.name));
255
+ } else if (e.name.endsWith(".ts")) {
256
+ try {
257
+ const m = fs.statSync(path.join(d, e.name)).mtimeMs;
258
+ if (m > newest) newest = m;
259
+ count++;
260
+ } catch {
261
+ /* file vanished mid-scan — ignore */
262
+ }
263
+ }
264
+ }
265
+ }
266
+ return { newest, count };
267
+ }
268
+
269
+ /** True when any server-side *.ts is newer than this process's start. Cached. */
270
+ function computeStale(): { stale: boolean; scanned: number } {
271
+ const now = Date.now();
272
+ if (_staleCache && now - _staleCache.at < STALE_CACHE_TTL_MS) {
273
+ return { stale: _staleCache.stale, scanned: _staleCache.scanned };
274
+ }
275
+ const { newest, count } = scanNewestTs(enginePath("src"));
276
+ const stale = newest > SERVER_STARTED_AT;
277
+ _staleCache = { at: now, stale, scanned: count };
278
+ return { stale, scanned: count };
279
+ }
280
+ const SILENT = process.argv.includes("--silent");
281
+ /**
282
+ * Verbose mode — adds lifecycle detail to stdout + mirrors SSE `line` events
283
+ * from /api/run to stdout so the server terminal doubles as a live test
284
+ * console. Opt-in via `--verbose` flag or `DEN_LOG=verbose`. Default is
285
+ * unchanged: request lines only. The in-memory log ring populates either way
286
+ * so the /logs page's "den/server" stream works without the flag.
287
+ */
288
+ const VERBOSE =
289
+ process.argv.includes("--verbose") || process.env.DEN_LOG === "verbose";
290
+
291
+ // ── In-memory log ring ────────────────────────────────────────────────────────
292
+ // Every message written via serverLog() lands here in plain-text form (ANSI
293
+ // stripped) and fans out to any subscribers — primarily the /api/server-log
294
+ // SSE stream surfaced on the /logs page. Fixed-size ring so an idle server
295
+ // doesn't grow without bound; restart drops history (not persisted to disk).
296
+
297
+ interface LogEntry {
298
+ ts: string;
299
+ kind: string;
300
+ text: string;
301
+ /** Set when an agent (via `<cli> mcp`) asked for this action: its label. */
302
+ actor?: string;
303
+ }
304
+
305
+ const LOG_RING_MAX = 500;
306
+ const logRing: LogEntry[] = [];
307
+ const logSubscribers = new Set<(e: LogEntry) => void>();
308
+
309
+ // ANSI SGR escape sequences — stripping these requires a control-character
310
+ // literal (ESC = 0x1b), which triggers no-control-regex. Intentional.
311
+ // eslint-disable-next-line no-control-regex
312
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
313
+ function stripAnsi(s: string): string {
314
+ return s.replace(ANSI_RE, "");
315
+ }
316
+
317
+ function pushLog(kind: string, text: string, actor?: string): void {
318
+ const entry: LogEntry = {
319
+ ts: new Date().toISOString(),
320
+ kind,
321
+ text: stripAnsi(text),
322
+ ...(actor ? { actor } : {}),
323
+ };
324
+ logRing.push(entry);
325
+ if (logRing.length > LOG_RING_MAX) logRing.shift();
326
+ for (const sub of logSubscribers) {
327
+ try {
328
+ sub(entry);
329
+ } catch {
330
+ /* subscriber broke — don't let it take the server down */
331
+ }
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Log to stdout unless --silent was passed, AND fan out to the log ring so the
337
+ * /logs page and any connected SSE subscribers see the same stream.
338
+ */
339
+ function serverLog(msg: string, kind: string = "info", actor?: string): void {
340
+ pushLog(kind, msg, actor);
341
+ if (!SILENT) process.stdout.write(msg + "\n");
342
+ }
343
+
344
+ // Every audited action (exec, run, sync) lands in the log as a lifecycle
345
+ // line; an agent's carries its label so the /logs page can mark it.
346
+ setAuditSink((line, actor) =>
347
+ serverLog(line, "lifecycle", actor ? actorLabel(actor) : undefined),
348
+ );
349
+
350
+ /** ANSI helpers (no chalk dependency in the server) */
351
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
352
+ const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
353
+ const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
354
+ const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
355
+ const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
356
+
357
+ function logTimestamp(): string {
358
+ return dim(new Date().toLocaleTimeString("en-US", { hour12: false }));
359
+ }
360
+
361
+ function logStatus(code: number): string {
362
+ if (code < 300) return green(String(code));
363
+ if (code < 400) return yellow(String(code));
364
+ return red(String(code));
365
+ }
366
+
367
+ /** Static extensions we skip logging (too noisy). */
368
+ const STATIC_EXTS = new Set([
369
+ ".html",
370
+ ".js",
371
+ ".css",
372
+ ".ico",
373
+ ".map",
374
+ ".png",
375
+ ".svg",
376
+ ".woff",
377
+ ".woff2",
378
+ ]);
379
+
380
+ // ── Sudo password store ───────────────────────────────────────────────────────
381
+ import {
382
+ setSudoPassword,
383
+ clearSudoPassword,
384
+ getSudoEnv,
385
+ prewarmSudo,
386
+ hasSudoPassword,
387
+ hasPasswordlessSudo,
388
+ } from "./sudo-store.js";
389
+
390
+ // ── HTTP helpers ─────────────────────────────────────────────────────────────
391
+ import { sweepLogs } from "../../shared/process/log-rotation.js";
392
+ import {
393
+ readBody,
394
+ sendJson,
395
+ serveFile,
396
+ send400,
397
+ send404,
398
+ } from "./http-helpers.js";
399
+ import { orgOf, listOrgs } from "../../shared/manifest/service-links.js";
400
+ import {
401
+ readLatestSummary,
402
+ securityReportsDir,
403
+ } from "../../pillars/security/findings.js";
404
+
405
+ // ── Changelog parser ──────────────────────────────────────────────────────────
406
+ import { parseChangelog } from "./changelog-parser.js";
407
+
408
+ // ── Extracted services ───────────────────────────────────────────────────────
409
+ import { GitHubService } from "./github-service.js";
410
+ import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
411
+ import { handleWorkspaceRoutes } from "./workspace-routes.js";
412
+ import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
413
+ import {
414
+ linksFor,
415
+ openUrlFor,
416
+ rehost,
417
+ type OpenLink,
418
+ } from "../../shared/open-url.js";
419
+
420
+ import { product } from "../../shared/product.js";
421
+ /** config `web.port` (user > team), falling back to the built-in default. */
422
+ function readWebPortSafe(): number {
423
+ try {
424
+ return readWebPort();
425
+ } catch {
426
+ return DEFAULT_PORT;
427
+ }
428
+ }
429
+ const github = new GitHubService();
430
+ const editors = new EditorManager();
431
+
432
+ // ── Health check ─────────────────────────────────────────────────────────────
433
+ // pingHttp / pingShell are imported from shared health module above.
434
+
435
+ // ── Test source resolution ───────────────────────────────────────────────────
436
+ // Returns, for each repo contributing tests to a pillar, the resolved repo path
437
+ // plus its current git branch + short SHA. Used to surface the source of truth
438
+ // in the Tests UI and the run log so workspace/registry drift is visible.
439
+
440
+ interface TestSource {
441
+ repoName: string;
442
+ repoPath: string;
443
+ branch: string | null;
444
+ shortSha: string | null;
445
+ workspace: string | null;
446
+ }
447
+
448
+ function readGitField(gitArgs: string[], cwd: string): string | null {
449
+ const r = spawnSync("git", gitArgs, {
450
+ cwd,
451
+ encoding: "utf-8",
452
+ stdio: ["ignore", "pipe", "ignore"],
453
+ timeout: GIT_REMOTE_TIMEOUT_MS,
454
+ });
455
+ if (r.status !== 0 || !r.stdout) return null;
456
+ return r.stdout.trim() || null;
457
+ }
458
+
459
+ function collectTestSources(
460
+ pillar: "synthetic" | "smoke" | "journeys" | "security",
461
+ ): TestSource[] {
462
+ const workspace = getActiveWorkspace()?.name ?? null;
463
+ const sources: TestSource[] = [];
464
+ for (const repo of discoverRepos()) {
465
+ const manifest = repo.manifest as {
466
+ synthetics?: unknown;
467
+ smoke?: Record<string, unknown>;
468
+ journeys?: unknown;
469
+ security?: Record<string, unknown>;
470
+ } | null;
471
+ const matches =
472
+ pillar === "synthetic"
473
+ ? !!manifest?.synthetics
474
+ : pillar === "journeys"
475
+ ? !!manifest?.journeys
476
+ : pillar === "security"
477
+ ? Object.keys(manifest?.security ?? {}).length > 0
478
+ : Object.keys(manifest?.smoke ?? {}).length > 0;
479
+ if (!matches) continue;
480
+ sources.push({
481
+ repoName: repo.repoName,
482
+ repoPath: repo.repoPath,
483
+ branch: readGitField(
484
+ ["rev-parse", "--abbrev-ref", "HEAD"],
485
+ repo.repoPath,
486
+ ),
487
+ shortSha: readGitField(["rev-parse", "--short", "HEAD"], repo.repoPath),
488
+ workspace,
489
+ });
490
+ }
491
+ return sources;
492
+ }
493
+
494
+ function formatSourceLine(src: TestSource): string {
495
+ const wsTag = src.workspace ? ` @ ${src.workspace}` : "";
496
+ const branch = src.branch ?? "(no branch)";
497
+ const sha = src.shortSha ? ` (${src.shortSha})` : "";
498
+ return `${src.repoName}${wsTag} · ${branch}${sha} — ${src.repoPath}`;
499
+ }
500
+
501
+ // ── Lifecycle helpers ─────────────────────────────────────────────────────────
502
+
503
+ function formatDuration(ms: number): string {
504
+ if (ms < 1000) return `${ms}ms`;
505
+ const s = ms / 1000;
506
+ if (s < 60) return `${s.toFixed(1)}s`;
507
+ const m = Math.floor(s / 60);
508
+ const rs = Math.round(s - m * 60);
509
+ return `${m}m${rs}s`;
510
+ }
511
+
512
+ function shortRunId(): string {
513
+ return Math.random().toString(36).slice(2, 8);
514
+ }
515
+
516
+ /**
517
+ * When VERBOSE, mirror SSE `line` events to stdout prefixed with a run id so
518
+ * the server terminal doubles as a live test console. Off by default — this
519
+ * is the firehose mode, not for everyday use.
520
+ */
521
+ function mirrorLine(runId: string, text: string): void {
522
+ if (!VERBOSE) return;
523
+ process.stdout.write(` ${dim(`[${runId}]`)} ${stripAnsi(text)}\n`);
524
+ }
525
+
526
+ // ── Server ────────────────────────────────────────────────────────────────────
527
+
528
+ async function handleRequest(
529
+ req: http.IncomingMessage,
530
+ res: http.ServerResponse,
531
+ ): Promise<void> {
532
+ const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
533
+ const pathname = url.pathname;
534
+
535
+ // ── Request logging ────────────────────────────────────────────────────────
536
+ // /api/health fires on the UI's 5s polling interval forever — it dominates
537
+ // the request log and crowds out anything useful. Always filtered, from both
538
+ // stdout and the in-memory ring. Static assets are skipped for the same
539
+ // reason. Real errors on those paths still surface via send400/throw.
540
+ {
541
+ const ext = path.extname(pathname);
542
+ const isStatic = STATIC_EXTS.has(ext) || pathname === "/";
543
+ const isHealthPoll = pathname === "/api/health";
544
+ if (!isStatic && !isHealthPoll) {
545
+ const start = Date.now();
546
+ const method = bold((req.method ?? "GET").padEnd(4));
547
+ res.on("finish", () => {
548
+ const ms = dim(`${Date.now() - start}ms`);
549
+ serverLog(
550
+ ` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}`,
551
+ "request",
552
+ );
553
+ });
554
+ }
555
+ }
556
+
557
+ // ── Static UI ──────────────────────────────────────────────────────────────
558
+ // Local Dev is the default page when local environment is enabled.
559
+ if (
560
+ pathname === "/" ||
561
+ pathname === "/local-dev" ||
562
+ pathname === "/local-dev.html"
563
+ ) {
564
+ if (!isLocalEnabled()) {
565
+ res.writeHead(302, { Location: "/tests" });
566
+ res.end();
567
+ return;
568
+ }
569
+ return serveFile(res, path.join(PUBLIC_DIR, "local-dev.html"));
570
+ }
571
+
572
+ if (pathname === "/tests" || pathname === "/index.html") {
573
+ return serveFile(res, path.join(PUBLIC_DIR, "index.html"));
574
+ }
575
+
576
+ if (pathname === "/services" || pathname === "/services.html") {
577
+ return serveFile(res, path.join(PUBLIC_DIR, "services.html"));
578
+ }
579
+ if (pathname === "/environments" || pathname === "/environments.html") {
580
+ return serveFile(res, path.join(PUBLIC_DIR, "environments.html"));
581
+ }
582
+
583
+ if (pathname === "/release" || pathname === "/release.html") {
584
+ return serveFile(res, path.join(PUBLIC_DIR, "release.html"));
585
+ }
586
+
587
+ if (pathname === "/logs") {
588
+ return serveFile(res, path.join(PUBLIC_DIR, "logs.html"));
589
+ }
590
+
591
+ if (pathname === "/header.js") {
592
+ return serveFile(res, path.join(PUBLIC_DIR, "header.js"));
593
+ }
594
+
595
+ // The graph's modules (graph.js, panel.js, …) and vendored libraries (ELK).
596
+ if (serveUiAsset(res, pathname, PUBLIC_DIR)) return;
597
+
598
+ if (pathname === "/perf-report.js") {
599
+ return serveFile(res, path.join(PUBLIC_DIR, "perf-report.js"));
600
+ }
601
+
602
+ // Imported by header.js (the workspace pill's picker models).
603
+ if (pathname === "/workspace-picker.js") {
604
+ return serveFile(res, path.join(PUBLIC_DIR, "workspace-picker.js"));
605
+ }
606
+
607
+ if (pathname === "/favicon.ico") {
608
+ return serveFile(res, path.join(PUBLIC_DIR, "favicon.ico"));
609
+ }
610
+
611
+ // ── Playwright report ──────────────────────────────────────────────────────
612
+ if (pathname.startsWith("/report")) {
613
+ const relPath = pathname.slice("/report".length) || "/index.html";
614
+ const filePath = path.resolve(path.join(REPORT_DIR, relPath));
615
+
616
+ // Path traversal guard — require a separator boundary so a sibling dir
617
+ // sharing the REPORT_DIR prefix (e.g. "playwright-report-evil") can't pass.
618
+ if (
619
+ filePath !== REPORT_DIR &&
620
+ !filePath.startsWith(REPORT_DIR + path.sep)
621
+ ) {
622
+ res.writeHead(403);
623
+ res.end("Forbidden");
624
+ return;
625
+ }
626
+
627
+ // Serve index.html for directories
628
+ const target =
629
+ fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()
630
+ ? path.join(filePath, "index.html")
631
+ : filePath;
632
+
633
+ return serveFile(res, target);
634
+ }
635
+
636
+ // ── API: environments ──────────────────────────────────────────────────────
637
+ // Orgs the catalog's repos belong to, with config/den.yml display names.
638
+ if (pathname === "/api/orgs") {
639
+ const catalog = readCatalog();
640
+ const orgs = listOrgs(catalog, readDenConfig().orgs ?? {});
641
+ if (url.searchParams.get("repos") !== "1") return sendJson(res, orgs);
642
+ const repos = Object.fromEntries(
643
+ Object.keys(catalog).map((r) => [r, orgOf(r, catalog)]),
644
+ );
645
+ return sendJson(res, { orgs, repos });
646
+ }
647
+
648
+ if (pathname === "/api/environments") {
649
+ try {
650
+ const allEnvs = await envSelector.getAvailableEnvironments();
651
+ const { environments: enabledEnvs } = readDenConfig();
652
+ const envs = allEnvs.filter((e) => enabledEnvs.includes(e));
653
+ return sendJson(res, envs);
654
+ } catch (err) {
655
+ serverLog(` ⚠ /api/environments: ${(err as Error).message}`);
656
+ return sendJson(res, ["local"]);
657
+ }
658
+ }
659
+
660
+ // ── API: app URL for current env ──────────────────────────────────────────
661
+ if (pathname === "/api/app-url") {
662
+ const env = url.searchParams.get("env") ?? "local";
663
+ try {
664
+ const cfgPath = path.join(ROOT, "config", "environments", `${env}.json`);
665
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8")) as {
666
+ baseUrls?: { web?: string };
667
+ };
668
+ return sendJson(res, { url: cfg.baseUrls?.web ?? null });
669
+ } catch (err) {
670
+ serverLog(` ⚠ /api/app-url: ${(err as Error).message}`);
671
+ return sendJson(res, { url: null });
672
+ }
673
+ }
674
+
675
+ // ── API: modules ───────────────────────────────────────────────────────────
676
+ if (pathname === "/api/modules") {
677
+ const pillar = url.searchParams.get("pillar") as Pillar | null;
678
+ if (!pillar) return send400(res, "pillar required");
679
+ try {
680
+ const modules = await moduleScanner.scanModules(pillar);
681
+ return sendJson(
682
+ res,
683
+ modules.map((m) => ({
684
+ name: m.name,
685
+ displayName: m.displayName,
686
+ icon: m.icon,
687
+ testCount: m.testCount,
688
+ // Declarative launch-time options the web UI renders generically.
689
+ optionalFields: m.optionalFields ?? [],
690
+ })),
691
+ );
692
+ } catch (err) {
693
+ serverLog(` ⚠ /api/modules: ${(err as Error).message}`);
694
+ return sendJson(res, []);
695
+ }
696
+ }
697
+
698
+ // ── API: security scans ─────────────────────────────────────────────────────
699
+ // The newest ZAP run per env + mode (what the Tests page lists), and its
700
+ // HTML reports, served from reports/security/ only.
701
+ if (pathname === "/api/security/latest") {
702
+ const env = url.searchParams.get("env") ?? "local";
703
+ const modes = ["baseline", "api", "full"];
704
+ return sendJson(
705
+ res,
706
+ modes
707
+ .map((m) => readLatestSummary(env, m))
708
+ .filter((x): x is NonNullable<typeof x> => x !== null),
709
+ );
710
+ }
711
+ if (pathname.startsWith("/reports/security/")) {
712
+ const root = securityReportsDir();
713
+ const rel = decodeURIComponent(pathname.slice("/reports/security/".length));
714
+ const file = path.resolve(root, rel);
715
+ if (!file.startsWith(root + path.sep) || !/\.(html|json)$/.test(file))
716
+ return send404(res);
717
+ try {
718
+ const body = fs.readFileSync(file);
719
+ res.writeHead(200, {
720
+ "Content-Type": file.endsWith(".html")
721
+ ? "text/html; charset=utf-8"
722
+ : "application/json",
723
+ "Cache-Control": "no-store",
724
+ // ZAP's report pulls its own scripts; keep it away from Den's origin data.
725
+ "Content-Security-Policy": "sandbox allow-scripts",
726
+ });
727
+ return res.end(body);
728
+ } catch {
729
+ return send404(res);
730
+ }
731
+ }
732
+
733
+ // ── API: stacks, edge, security plan (read-only; see readonly-routes.ts) ──
734
+ if (await handleReadonlyRoutes(req, res, pathname, url)) return;
735
+
736
+ // ── API: test sources ─────────────────────────────────────────────────────
737
+ // Returns the repos that contribute tests for a given pillar, with their
738
+ // current branch + short SHA. Lets the Tests UI show "you are testing
739
+ // against X" so workspace vs registered-repo drift is visible.
740
+ if (pathname === "/api/test-sources") {
741
+ const pillar = url.searchParams.get("pillar");
742
+ if (
743
+ pillar !== "synthetic" &&
744
+ pillar !== "smoke" &&
745
+ pillar !== "journeys" &&
746
+ pillar !== "security"
747
+ ) {
748
+ return sendJson(res, []);
749
+ }
750
+ try {
751
+ return sendJson(res, collectTestSources(pillar));
752
+ } catch (err) {
753
+ serverLog(` ⚠ /api/test-sources: ${(err as Error).message}`);
754
+ return sendJson(res, []);
755
+ }
756
+ }
757
+
758
+ // ── API: den server log (SSE) ──────────────────────────────────────────
759
+ // Tails this server's own stdout-equivalent log stream. The /logs page opens
760
+ // it as "den/server" alongside service log panes so engineers can watch
761
+ // den's lifecycle events without leaving the browser. The ring replays
762
+ // recent history on connect; new entries stream as they happen.
763
+ //
764
+ // Events emitted:
765
+ // { type: "history", entries: LogEntry[] } — once, on open
766
+ // { type: "log", entry: LogEntry } — per new entry
767
+ if (pathname === "/api/server-log") {
768
+ res.writeHead(200, {
769
+ "Content-Type": "text/event-stream",
770
+ "Cache-Control": "no-cache",
771
+ Connection: "keep-alive",
772
+ "X-Accel-Buffering": "no",
773
+ });
774
+ const emit = (obj: unknown): void => {
775
+ try {
776
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
777
+ } catch {
778
+ /* client disconnected mid-write; cleanup runs on `close` */
779
+ }
780
+ };
781
+ emit({ type: "history", entries: logRing.slice() });
782
+ const subscriber = (entry: LogEntry): void => {
783
+ emit({ type: "log", entry });
784
+ };
785
+ logSubscribers.add(subscriber);
786
+ req.on("close", () => {
787
+ logSubscribers.delete(subscriber);
788
+ });
789
+ return;
790
+ }
791
+
792
+ // ── API: latest perf report ────────────────────────────────────────────────
793
+ if (pathname === "/api/perf-report/current") {
794
+ const report = loadCurrentPerfRunReport(ROOT);
795
+ if (!report) {
796
+ res.writeHead(404, { "Content-Type": "application/json" });
797
+ res.end(JSON.stringify({ error: "No perf report available" }));
798
+ return;
799
+ }
800
+
801
+ return sendJson(res, report);
802
+ }
803
+
804
+ // ── API: health check ─────────────────────────────────────────────────────
805
+ /**
806
+ * Workspaces a health run covers: one named workspace, else EVERY active one
807
+ * (focus first). Health is machine-level — a service running under a
808
+ * non-focused active workspace is still up, and its card must say so.
809
+ */
810
+ function healthWorkspaces(
811
+ workspaceName: string | null,
812
+ ): Array<{ name: string; config: WorkspaceConfig }> {
813
+ if (workspaceName) {
814
+ const named = getWorkspaceByName(workspaceName);
815
+ return named ? [named] : [];
816
+ }
817
+ const focus = getFocusedWorkspaceName();
818
+ const names = [
819
+ ...(focus ? [focus] : []),
820
+ ...getActiveWorkspaces().filter((n) => n !== focus),
821
+ ];
822
+ return names.flatMap((n) => getWorkspaceByName(n) ?? []);
823
+ }
824
+
825
+ /**
826
+ * Run every health check for `env` (or one service via `addressFilter`).
827
+ * `onResult` fires per check as it lands, so a caller can stream instead of
828
+ * waiting on the slowest.
829
+ */
830
+ async function computeHealth(
831
+ env: Environment,
832
+ addressFilter: string | null,
833
+ onResult?: (r: HealthResult) => void,
834
+ workspaceName: string | null = null,
835
+ ): Promise<HealthResult[]> {
836
+ // Use workspace-aware service lists so repos added via the catalog/workspace
837
+ // (e.g. files-api) are included, not just .den/ symlinks.
838
+ const workspaces = healthWorkspaces(workspaceName);
839
+ if (workspaces.length === 0) return [];
840
+ const catalog = readCatalog();
841
+ // One entry per address across active workspaces; focus wins a duplicate,
842
+ // and only one checkout of a shared address can be running anyway. The
843
+ // owning workspace travels with the service — dep addresses resolve against
844
+ // the workspace the service came from.
845
+ const byAddress = new Map<
846
+ string,
847
+ { svc: ResolvedService; ws: { name: string; config: WorkspaceConfig } }
848
+ >();
849
+ for (const ws of workspaces)
850
+ for (const svc of listWorkspaceServices(ws.config, catalog, ws.name))
851
+ // Modules and libraries have no runtime, so no health dot.
852
+ if (!byAddress.has(svc.address) && hasRuntime(repoKind(svc.repoPath)))
853
+ byAddress.set(svc.address, { svc, ws });
854
+ let services = [...byAddress.values()];
855
+ if (addressFilter) {
856
+ services = services.filter((s) => s.svc.address === addressFilter);
857
+ }
858
+
859
+ const entries: Array<{
860
+ address: string;
861
+ deps: string[];
862
+ repo: string;
863
+ service: string;
864
+ label: string;
865
+ checkType: "http" | "shell" | "none";
866
+ target: string;
867
+ repoPath: string;
868
+ mode: "dev-managed" | "managed";
869
+ isEntrypoint: boolean;
870
+ /** Conventional http://<repo>/healthz probe for no-health-check primaries. */
871
+ conventional: string | null;
872
+ openUrl: string | null;
873
+ links: OpenLink[];
874
+ /** The GitHub org that owns the repo. */
875
+ org: string;
876
+ /** Codes that count as up for a remote target.url probe. */
877
+ expect?: Expect;
878
+ meta: Pick<HealthResult, "runs" | "target">;
879
+ }> = [];
880
+ let edgeDomain: string | null = null;
881
+ try {
882
+ edgeDomain = readEdgeConfig().domain;
883
+ } catch {
884
+ /* no edge configured: local links come from manifests only */
885
+ }
886
+ for (const { svc: resolved, ws } of services) {
887
+ const manifest = readManifestAnyBranch(
888
+ resolved.repoName,
889
+ resolved.repoPath,
890
+ );
891
+ if (!manifest) {
892
+ // Keep the row (as "unknown"/muted) instead of dropping it: a service
893
+ // vanishing from the payload reads as "everything else is fine" and a
894
+ // merge-by-key client would freeze its last dot.
895
+ entries.push({
896
+ address: resolved.address,
897
+ deps: [],
898
+ repo: resolved.repoName,
899
+ service: resolved.subsystem,
900
+ label: resolved.subsystem,
901
+ checkType: "none",
902
+ target: "",
903
+ repoPath: resolved.repoPath,
904
+ mode: resolved.mode,
905
+ isEntrypoint: false,
906
+ conventional: null,
907
+ openUrl: null,
908
+ links: [],
909
+ org: orgOf(resolved.repoName, catalog),
910
+ meta: { runs: "none", target: null },
911
+ });
912
+ continue;
913
+ }
914
+ const svcCfg = manifest.services?.[resolved.subsystem];
915
+ // A compose-run repo with no declared check gets the stack's own health
916
+ // (container state + Docker healthchecks), as `den status` does.
917
+ const composeHealth =
918
+ env === "local"
919
+ ? composeHealthCmd(
920
+ manifest,
921
+ resolved.subsystem,
922
+ resolved.repoName,
923
+ ws.name,
924
+ )
925
+ : null;
926
+ // One resolution path with `<cli> status` (placement.ts): a remote
927
+ // service is checked by target.env's declaration, else target.url.
928
+ const plan = resolveHealthCheck(
929
+ manifest,
930
+ resolved.subsystem,
931
+ env,
932
+ composeHealth,
933
+ );
934
+ const healthCfg = plan.check;
935
+ const remote = plan.placement.runs === "remote";
936
+ // Keep services with no health check (checkType "none") so systems like
937
+ // jobs-runtime still appear — as an "unknown" (muted) dot, matching
938
+ // `den status`, rather than vanishing from the health row.
939
+ const deps = (svcCfg?.depends_on ?? []).map((rawDep) => {
940
+ const d = depService(rawDep);
941
+ const { repoName } = parseAddress(
942
+ resolveDepAddress(d, catalog, ws.config, ws.name),
943
+ catalog,
944
+ );
945
+ return repoName === resolved.repoName
946
+ ? resolveDepAddress(d, catalog, ws.config, ws.name)
947
+ : repoName;
948
+ });
949
+ entries.push({
950
+ address: resolved.address,
951
+ deps,
952
+ repo: resolved.repoName,
953
+ service: resolved.subsystem,
954
+ label: plan.label ?? healthCfg?.label ?? resolved.subsystem,
955
+ checkType: healthCfg
956
+ ? healthCfg.type === "shell"
957
+ ? "shell"
958
+ : "http"
959
+ : "none",
960
+ target: healthCfg
961
+ ? healthCfg.type === "shell"
962
+ ? healthCfg.cmd
963
+ : healthCfg.url
964
+ : "",
965
+ repoPath: resolved.repoPath,
966
+ mode: resolved.mode,
967
+ isEntrypoint: manifest.entrypoint === resolved.subsystem,
968
+ // Conventional local discovery (mirrors checkServiceHealth): a PRIMARY
969
+ // service with no declared local health check may still serve the stack
970
+ // convention at http://<repo>/healthz via opctl DNS.
971
+ conventional: (() => {
972
+ if (healthCfg || env !== "local" || remote) return null;
973
+ const keys = Object.keys(manifest.services ?? {});
974
+ const isPrimary = manifest.entrypoint
975
+ ? resolved.subsystem === manifest.entrypoint
976
+ : keys.length === 1;
977
+ return isPrimary ? `http://${resolved.repoName}/healthz` : null;
978
+ })(),
979
+ openUrl: openUrlFor({
980
+ manifest,
981
+ subsystem: resolved.subsystem,
982
+ repoName: resolved.repoName,
983
+ env,
984
+ healthUrl:
985
+ healthCfg && healthCfg.type !== "shell" ? healthCfg.url : null,
986
+ edgeDomain,
987
+ }),
988
+ links: linksFor(manifest, resolved.subsystem, env),
989
+ org: orgOf(resolved.repoName, catalog),
990
+ ...(plan.expect ? { expect: plan.expect } : {}),
991
+ meta: { runs: plan.placement.runs, target: plan.placement.target },
992
+ });
993
+ }
994
+
995
+ // Order systems top-down like the dependency graph — entry repo first
996
+ // (shop-web) … infra-stack last — so the health row reads the same
997
+ // direction as the graph (and `den status`). orderForDown is the reverse
998
+ // of the deps-first startup order.
999
+ const rank = new Map(
1000
+ orderForDown(
1001
+ deriveGraph({ scope: "all", workspace: workspaces[0]?.name }),
1002
+ ).map((repo, i) => [repo, i]),
1003
+ );
1004
+ const sortedEntries = [...entries].sort((a, b) => {
1005
+ const ra = rank.get(a.repo) ?? Number.MAX_SAFE_INTEGER;
1006
+ const rb = rank.get(b.repo) ?? Number.MAX_SAFE_INTEGER;
1007
+ return ra - rb || a.address.localeCompare(b.address);
1008
+ });
1009
+
1010
+ const checkEntry = async ({
1011
+ repo,
1012
+ service,
1013
+ label,
1014
+ checkType,
1015
+ target,
1016
+ repoPath,
1017
+ mode,
1018
+ isEntrypoint,
1019
+ conventional,
1020
+ meta,
1021
+ expect,
1022
+ }) => {
1023
+ if (checkType !== "none") {
1024
+ const started = Date.now();
1025
+ const ping =
1026
+ checkType === "shell"
1027
+ ? await pingShell(target, awsProfileEnv(env), repoPath)
1028
+ : await pingHttp(
1029
+ target,
1030
+ env === "local" && meta.runs !== "remote"
1031
+ ? HEALTH_CHECK_TIMEOUT_MS
1032
+ : HOSTED_HEALTH_CHECK_TIMEOUT_MS,
1033
+ expect,
1034
+ );
1035
+ return {
1036
+ repo,
1037
+ service,
1038
+ label,
1039
+ ...healthCheckFields(checkType, target),
1040
+ repoPath,
1041
+ mode,
1042
+ isEntrypoint,
1043
+ ...meta,
1044
+ ms: Date.now() - started,
1045
+ ...ping,
1046
+ };
1047
+ }
1048
+ // No declared health check: try the conventional probe when eligible.
1049
+ // Success → real "ok"; failure stays "unknown" (ok:null renders muted,
1050
+ // not red — we can't distinguish "down" from "no endpoint").
1051
+ if (conventional) {
1052
+ const probe = await pingHttp(conventional);
1053
+ if (probe.ok) {
1054
+ return {
1055
+ repo,
1056
+ service,
1057
+ label,
1058
+ url: conventional,
1059
+ repoPath,
1060
+ mode,
1061
+ isEntrypoint,
1062
+ ...meta,
1063
+ ...probe,
1064
+ };
1065
+ }
1066
+ }
1067
+ return {
1068
+ repo,
1069
+ service,
1070
+ label,
1071
+ url: null,
1072
+ repoPath,
1073
+ mode,
1074
+ isEntrypoint,
1075
+ ...meta,
1076
+ ok: null,
1077
+ };
1078
+ };
1079
+ // Stream each result the moment it lands. The checks run concurrently but
1080
+ // the slowest (shell probes that shell out to the aws CLI, ~0.45s) used to
1081
+ // gate ALL of them, so every dot waited on the worst one.
1082
+ // `idx` carries the graph-ranked position above into each STREAMED result.
1083
+ // Streaming emits in completion order, which varies per poll (shell probes
1084
+ // finish at wildly different speeds), so without a stable key the client
1085
+ // repaints the dots in a different order every 5s.
1086
+ const results = await Promise.all(
1087
+ sortedEntries.map((e, idx) =>
1088
+ checkEntry(e).then((r) => {
1089
+ const ranked = { ...r, openUrl: e.openUrl, links: e.links, idx };
1090
+ onResult?.(ranked);
1091
+ return ranked;
1092
+ }),
1093
+ ),
1094
+ );
1095
+ return results;
1096
+ }
1097
+
1098
+ // Full-env health, cached. Every page navigation used to re-run all 30 checks
1099
+ // from scratch — and the slowest are shell probes that shell out to the aws
1100
+ // CLI (~0.45s each) — so the dots took ~0.6s to resolve on EVERY switch. A
1101
+ // single-service `?address=` probe always runs live: it exists to re-check one
1102
+ // node on demand, where staleness is the whole thing being avoided.
1103
+ if (pathname === "/api/health") {
1104
+ const forViewer = viewerRewrite(req.headers.host);
1105
+ const env = (url.searchParams.get("env") ?? "local") as Environment;
1106
+ const addressFilter = url.searchParams.get("address");
1107
+ const wsParam = url.searchParams.get("workspace");
1108
+ if (wsParam && !getWorkspaceByName(wsParam))
1109
+ return send400(res, `unknown workspace "${wsParam}"`);
1110
+ if (addressFilter) {
1111
+ return sendJson(
1112
+ res,
1113
+ (await computeHealth(env, addressFilter, undefined, wsParam)).map(
1114
+ forViewer,
1115
+ ),
1116
+ );
1117
+ }
1118
+ const refresh = url.searchParams.get("refresh") === "1";
1119
+ const cacheKey = healthCacheKey(env, wsParam);
1120
+ const hit = refresh ? null : healthCache.get(cacheKey);
1121
+ if (hit && Date.now() - hit.at < HEALTH_CACHE_TTL_MS) {
1122
+ return sendJson(res, hit.results.map(forViewer));
1123
+ }
1124
+ const results = await computeHealth(env, null, undefined, wsParam);
1125
+ healthCache.set(cacheKey, { results, at: Date.now() });
1126
+ return sendJson(res, results.map(forViewer));
1127
+ }
1128
+
1129
+ // Same checks, streamed: each result is sent the moment it lands so the fast
1130
+ // ones paint immediately instead of every dot waiting on the slowest.
1131
+ if (pathname === "/api/health/stream") {
1132
+ const forViewer = viewerRewrite(req.headers.host);
1133
+ const env = (url.searchParams.get("env") ?? "local") as Environment;
1134
+ const wsParam = url.searchParams.get("workspace");
1135
+ if (wsParam && !getWorkspaceByName(wsParam))
1136
+ return send400(res, `unknown workspace "${wsParam}"`);
1137
+ res.writeHead(200, {
1138
+ "Content-Type": "text/event-stream",
1139
+ "Cache-Control": "no-cache",
1140
+ Connection: "keep-alive",
1141
+ });
1142
+ const send = (o: unknown): void => {
1143
+ res.write(`data: ${JSON.stringify(o)}\n\n`);
1144
+ };
1145
+
1146
+ const refresh = url.searchParams.get("refresh") === "1";
1147
+ const cacheKey = healthCacheKey(env, wsParam);
1148
+ const hit = refresh ? null : healthCache.get(cacheKey);
1149
+ if (hit && Date.now() - hit.at < HEALTH_CACHE_TTL_MS) {
1150
+ // Cached: replay in one go. Nothing to stagger — it's already known.
1151
+ for (const r of hit.results)
1152
+ send({ type: "result", result: forViewer(r) });
1153
+ send({ type: "done", cached: true });
1154
+ return res.end();
1155
+ }
1156
+
1157
+ try {
1158
+ const results = await computeHealth(
1159
+ env,
1160
+ null,
1161
+ (r) => send({ type: "result", result: forViewer(r) }),
1162
+ wsParam,
1163
+ );
1164
+ healthCache.set(cacheKey, { results, at: Date.now() });
1165
+ send({ type: "done", cached: false });
1166
+ } catch (e) {
1167
+ send({ type: "error", error: (e as Error).message });
1168
+ }
1169
+ return res.end();
1170
+ }
1171
+
1172
+ // ── API: services list (catalog-aware) ───────────────────────────────────
1173
+ if (pathname === "/api/services") {
1174
+ const env = (url.searchParams.get("env") ?? "local") as Environment;
1175
+ // Optional: caller may request services for a specific (non-active) workspace.
1176
+ const wsOverride = url.searchParams.get("workspace") ?? null;
1177
+
1178
+ // Conventional lifecycle locally: `down` is opctl-conventional (stops
1179
+ // running containers with no declared command, no-ops gracefully), and
1180
+ // `restart` is orchestrated by the exec runner as down → verify → up — so
1181
+ // every service with an `up` can offer both even when the manifest doesn't
1182
+ // declare them.
1183
+ const withDown = (cmds: string[]): string[] => {
1184
+ if (env !== "local") return cmds;
1185
+ const out = [...cmds];
1186
+ if (!out.includes("down")) out.push("down");
1187
+ if (!out.includes("restart") && out.includes("up")) out.push("restart");
1188
+ return out;
1189
+ };
1190
+
1191
+ // Legacy: discovered repos from .den/ symlinks / registered-repos.json
1192
+ const legacyRepos = discoverRepos();
1193
+ const legacyEntries = legacyRepos.flatMap((repo) => {
1194
+ const manifestLayers = loadManifest(repo.repoPath).layers;
1195
+ return Object.entries(repo.manifest.services ?? {}).map(
1196
+ ([svcName, svc]) => ({
1197
+ repo: repo.repoName,
1198
+ service: svcName,
1199
+ address: `${repo.repoName}/${svcName}`,
1200
+ repoPath: repo.repoPath,
1201
+ commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
1202
+ logs: svc.logs?.[env] ?? null,
1203
+ mode: "dev-managed" as const,
1204
+ branch: null as string | null,
1205
+ fromCatalog: false,
1206
+ isEntrypoint: repo.manifest.entrypoint === svcName,
1207
+ autostart: true, // legacy discovery has no workspace entry to toggle
1208
+ deps: (svc.depends_on ?? []).map(depService),
1209
+ cloned: true, // legacy discovery only ever finds on-disk repos
1210
+ manifestLayers,
1211
+ ...servicePlacement(repo.manifest, svcName, env),
1212
+ }),
1213
+ );
1214
+ });
1215
+
1216
+ // Catalog entries (workspace-aware)
1217
+ const catalog = readCatalog();
1218
+ const wsFile = wsOverride ? readWorkspacesFile() : null;
1219
+ const ws = wsOverride
1220
+ ? wsFile?.workspaces[wsOverride]
1221
+ ? { name: wsOverride, config: wsFile!.workspaces[wsOverride] }
1222
+ : null
1223
+ : getActiveWorkspace();
1224
+ const getCurrentBranch = (repoPath: string): string | null => {
1225
+ try {
1226
+ return execSync("git rev-parse --abbrev-ref HEAD", {
1227
+ cwd: repoPath,
1228
+ encoding: "utf-8",
1229
+ stdio: ["ignore", "pipe", "ignore"],
1230
+ timeout: GIT_REMOTE_TIMEOUT_MS,
1231
+ }).trim();
1232
+ } catch {
1233
+ return null;
1234
+ }
1235
+ };
1236
+
1237
+ const noAutostart = ws
1238
+ ? noAutostartRepos(ws.config, catalog)
1239
+ : new Set<string>();
1240
+ const daemonOwners = new Map(
1241
+ listDaemons().map((d) => [d.address, d.workspaces ?? []] as const),
1242
+ );
1243
+ // Modules and libraries have no runtime: no card, health or commands.
1244
+ // They show as contributors on the Applications tab instead.
1245
+ const catalogEntries = ws
1246
+ ? listWorkspaceServices(ws.config, catalog, ws.name)
1247
+ .filter((r) => hasRuntime(repoKind(r.repoPath)))
1248
+ .flatMap((resolved) => {
1249
+ // The manifest and the layers it was built from: repo or held,
1250
+ // plus override when manifests/<repo>.override.yml applied.
1251
+ const { manifest, layers: manifestLayers } = loadManifest(
1252
+ resolved.repoPath,
1253
+ );
1254
+ const svc = manifest?.services?.[resolved.subsystem];
1255
+ if (!manifest || !svc) {
1256
+ // Workspace membership is config-driven: a managed entry
1257
+ // whose clone isn't on disk yet (or a checkout without a usable
1258
+ // manifest) must still render everywhere — flagged `cloned:false`
1259
+ // so the UI can offer Sync/clone instead of dropping the system.
1260
+ // `branch` comes from the workspace entry; never probe git on a
1261
+ // path that may not exist.
1262
+ return [
1263
+ {
1264
+ repo: resolved.repoName,
1265
+ service: resolved.subsystem,
1266
+ address: resolved.address,
1267
+ repoPath: resolved.repoPath,
1268
+ commands: [] as string[],
1269
+ logs: null,
1270
+ mode: resolved.mode,
1271
+ branch: resolved.branch,
1272
+ fromCatalog: true,
1273
+ isEntrypoint: false,
1274
+ autostart: !noAutostart.has(resolved.repoName),
1275
+ owners: daemonOwners.get(resolved.address) ?? [],
1276
+ deps: [] as string[],
1277
+ cloned: fs.existsSync(resolved.repoPath),
1278
+ manifestLayers,
1279
+ ...servicePlacement(null, resolved.subsystem, env),
1280
+ },
1281
+ ];
1282
+ }
1283
+ return [
1284
+ {
1285
+ repo: resolved.repoName,
1286
+ service: resolved.subsystem,
1287
+ address: resolved.address,
1288
+ repoPath: resolved.repoPath,
1289
+ commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
1290
+ logs: svc.logs?.[env] ?? null,
1291
+ mode: resolved.mode,
1292
+ branch: resolved.branch ?? getCurrentBranch(resolved.repoPath),
1293
+ fromCatalog: true,
1294
+ isEntrypoint: manifest.entrypoint === resolved.subsystem,
1295
+ autostart: !noAutostart.has(resolved.repoName),
1296
+ owners: daemonOwners.get(resolved.address) ?? [],
1297
+ deps: (svc.depends_on ?? []).map((d) =>
1298
+ resolveDepAddress(depService(d), catalog, ws.config, ws.name),
1299
+ ),
1300
+ cloned: true,
1301
+ manifestLayers,
1302
+ ...servicePlacement(manifest, resolved.subsystem, env),
1303
+ },
1304
+ ];
1305
+ })
1306
+ : [];
1307
+
1308
+ // Merge: catalog entries take precedence over legacy for same repo.
1309
+ // When a workspace filter is active, also exclude legacy entries not in
1310
+ // the workspace (legacy discovery is not workspace-scoped).
1311
+ const catalogRepos = new Set(Object.keys(catalog));
1312
+ const wsServiceKeys = ws ? new Set(Object.keys(ws.config.services)) : null;
1313
+ const merged = [
1314
+ ...legacyEntries.filter((e) => {
1315
+ if (catalogRepos.has(e.repo)) return false;
1316
+ if (wsServiceKeys && !wsServiceKeys.has(e.repo)) return false;
1317
+ return true;
1318
+ }),
1319
+ ...catalogEntries,
1320
+ ];
1321
+
1322
+ // Attach the dependency-graph position so consumers can present systems
1323
+ // top-down (entry repo first, infra-stack last) — the same order the
1324
+ // health row and `den status` use. Added as a FIELD rather than by
1325
+ // re-sorting the array, because four pages consume this payload and some
1326
+ // rely on the current order.
1327
+ const graphRank = new Map(
1328
+ orderForDown(deriveGraph({ scope: "all", workspace: ws?.name })).map(
1329
+ (repo, i) => [repo, i],
1330
+ ),
1331
+ );
1332
+ const infraRepo = readDenConfig()?.graph?.infra?.repo ?? null;
1333
+ return sendJson(
1334
+ res,
1335
+ merged.map((e) => ({
1336
+ ...e,
1337
+ graphRank: graphRank.get(e.repo) ?? Number.MAX_SAFE_INTEGER,
1338
+ infra: infraRepo !== null && e.repo === infraRepo,
1339
+ })),
1340
+ );
1341
+ }
1342
+
1343
+ // ── API: service exec (SSE) — catalog-aware ────────────────────────────────
1344
+ if (pathname === "/api/service/exec") {
1345
+ const repoName = url.searchParams.get("repo");
1346
+ const svcName = url.searchParams.get("service");
1347
+ const cmdName = url.searchParams.get("cmd");
1348
+ const env = (url.searchParams.get("env") ?? "local") as Environment;
1349
+
1350
+ if (!repoName || !svcName || !cmdName)
1351
+ return send400(res, "repo, service, and cmd are required");
1352
+
1353
+ // Open the SSE stream immediately so any resolution errors are visible
1354
+ // in the output pane rather than triggering onerror ("Connection closed").
1355
+ res.writeHead(200, {
1356
+ "Content-Type": "text/event-stream",
1357
+ "Cache-Control": "no-cache",
1358
+ Connection: "keep-alive",
1359
+ "X-Accel-Buffering": "no",
1360
+ });
1361
+ const emit = (obj: unknown): void => {
1362
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
1363
+ };
1364
+ const fail = (msg: string): void => {
1365
+ emit({ type: "error", text: msg });
1366
+ emit({ type: "done", exitCode: 1, duration: 0 });
1367
+ res.end();
1368
+ };
1369
+
1370
+ const wsParam = url.searchParams.get("workspace");
1371
+ if (wsParam && !isWorkspaceActive(wsParam)) {
1372
+ return fail(`Workspace "${wsParam}" is not active — activate it first`);
1373
+ }
1374
+
1375
+ // Resolve repo path via resolveService (handles single-service repos where
1376
+ // the manifest key differs from the repo name, e.g. tasks-runner →
1377
+ // tool-runner). Try bare repo name first so single-service repos
1378
+ // don't need an explicit "repo/svc" address in the workspace config.
1379
+ let repoPath: string | undefined;
1380
+ let cmdDef:
1381
+ | string
1382
+ | { cmd: string; detachable?: boolean; interactive?: boolean }
1383
+ | undefined;
1384
+
1385
+ const resolved =
1386
+ resolveService(`${repoName}/${svcName}`, wsParam ?? undefined) ??
1387
+ resolveService(repoName, wsParam ?? undefined);
1388
+ if (resolved) {
1389
+ repoPath = resolved.repoPath;
1390
+ const manifest = readManifest(repoPath);
1391
+ // R13: nothing runs a command on a remote service.
1392
+ const refusal = placementRefusal(
1393
+ `${repoName}/${svcName}`,
1394
+ servicePlacement(manifest, resolved.subsystem, env),
1395
+ (e) => isProductionEnv(e),
1396
+ cmdName === "down" ? "stop" : "start",
1397
+ );
1398
+ if (refusal) return fail(refusal);
1399
+ if (manifest) {
1400
+ cmdDef =
1401
+ manifest.services?.[resolved.subsystem]?.commands?.[env]?.[cmdName];
1402
+ }
1403
+ }
1404
+
1405
+ if (!cmdDef) {
1406
+ // Legacy fallback: repos registered via .den/ symlinks / registered-repos.json
1407
+ const repos = discoverRepos();
1408
+ const repo = repos.find((r) => r.repoName === repoName);
1409
+ repoPath = repo?.repoPath;
1410
+ const refusal = repo
1411
+ ? placementRefusal(
1412
+ `${repoName}/${svcName}`,
1413
+ servicePlacement(repo.manifest, svcName, env),
1414
+ (e) => isProductionEnv(e),
1415
+ cmdName === "down" ? "stop" : "start",
1416
+ )
1417
+ : null;
1418
+ if (refusal) return fail(refusal);
1419
+ cmdDef = repo?.manifest.services?.[svcName]?.commands?.[env]?.[cmdName];
1420
+ }
1421
+
1422
+ if (!cmdDef || !repoPath) {
1423
+ // An un-cloned managed repo has no manifest to read commands from.
1424
+ // Report THAT, not "command not found" — the command may well exist; the
1425
+ // repo just isn't on disk yet.
1426
+ if (
1427
+ resolved &&
1428
+ resolved.mode === "managed" &&
1429
+ !fs.existsSync(resolved.repoPath)
1430
+ ) {
1431
+ return fail(
1432
+ `${repoName} is not cloned yet — run Sync (or ⋮ → clone now) to clone it into ` +
1433
+ `${product().servicesDir}/, then retry "${cmdName}".`,
1434
+ );
1435
+ }
1436
+ const ws = getActiveWorkspace();
1437
+ return fail(
1438
+ `Command "${cmdName}" not found for ${repoName}/${svcName} in env "${env}".` +
1439
+ (!ws ? " No active workspace." : ` Active workspace: ${ws.name}.`) +
1440
+ ` resolved=${JSON.stringify(resolved ?? null)}`,
1441
+ );
1442
+ }
1443
+
1444
+ const cmdString = rewriteLegacyOpctlKillCommandForContainer(
1445
+ cmdName,
1446
+ typeof cmdDef === "string" ? cmdDef : cmdDef.cmd,
1447
+ repoName,
1448
+ (line) => emit({ type: "line", text: line }),
1449
+ );
1450
+ const detached = shouldDetachServiceCommand(cmdName, cmdDef);
1451
+ const interactive = isInteractiveServiceCommand(cmdDef);
1452
+
1453
+ if (interactive) {
1454
+ return fail(
1455
+ `"${cmdName}" requires an interactive terminal (HITL).\n` +
1456
+ `Run in your terminal:\n cd ${repoPath}\n ${cmdString}`,
1457
+ );
1458
+ }
1459
+
1460
+ const startTime = Date.now();
1461
+ emit({ type: "cmd", text: `$ ${cmdString}`, detached });
1462
+
1463
+ const execId = shortRunId();
1464
+ const execDesc = `${repoName}/${svcName} ${cmdName} (${env})`;
1465
+ const execActor = actorFromHeaders(req.headers);
1466
+ recordAction(execActor, {
1467
+ verb: "exec",
1468
+ detail: `${execDesc} [${execId}]`,
1469
+ });
1470
+
1471
+ // For detachable commands (up/restart), the service is spawned as a daemon
1472
+ // writing to a log file so it survives den restarts. The tail proc
1473
+ // streams that log back to the SSE and keeps the connection open
1474
+ // indefinitely — the client stays attached until it closes the tab.
1475
+ const logPath = detached ? getServiceLogPath(repoName, svcName) : undefined;
1476
+ if (logPath) emit({ type: "line", text: `→ log: ${logPath}` });
1477
+
1478
+ let active = true; // false once client disconnects
1479
+
1480
+ prewarmSudo();
1481
+ const svcProc = spawnStreaming(
1482
+ cmdString,
1483
+ [],
1484
+ { ...process.env, E2E_ENVIRONMENT: env, ...getSudoEnv() },
1485
+ (line) => {
1486
+ mirrorLine(execId, line);
1487
+ emit({ type: "line", text: line });
1488
+ },
1489
+ (exitCode) => {
1490
+ if (!active) return; // client already closed — don't write to dead response
1491
+ const duration = Date.now() - startTime;
1492
+ serverLog(
1493
+ `■ exec ${execDesc} · exit=${exitCode} · ${formatDuration(duration)} [${execId}]${actorSuffix(execActor)}`,
1494
+ "lifecycle",
1495
+ execActor ? actorLabel(execActor) : undefined,
1496
+ );
1497
+ // Dev-tooling telemetry: one invocation per service command run. The
1498
+ // action is whitelisted to common lifecycle verbs (low cardinality);
1499
+ // repo/service identifiers are internal and omitted.
1500
+ recordInvocation({
1501
+ command: "service.exec",
1502
+ surface: "ux",
1503
+ durationMs: duration,
1504
+ exit: exitCode,
1505
+ extra: {
1506
+ action: [
1507
+ "up",
1508
+ "down",
1509
+ "restart",
1510
+ "setup",
1511
+ "build",
1512
+ "logs",
1513
+ ].includes(cmdName)
1514
+ ? cmdName
1515
+ : "other",
1516
+ env,
1517
+ },
1518
+ });
1519
+ if (cmdName === "down" && exitCode === 0) {
1520
+ unregisterDaemon(resolved?.address ?? `${repoName}/${svcName}`);
1521
+ }
1522
+ if (exitCode !== 0)
1523
+ emit({ type: "error", text: `Command failed (exit ${exitCode})` });
1524
+ emit({ type: "done", exitCode, duration });
1525
+ res.end();
1526
+ },
1527
+ repoPath,
1528
+ detached,
1529
+ logPath,
1530
+ // For detachable commands (up): fire when the launch script itself exits.
1531
+ // This happens when `make up` hands off to a container runtime and exits.
1532
+ // The client uses this to transition from startup output to ongoing logs.
1533
+ detached
1534
+ ? (exitCode) => {
1535
+ if (!active) return;
1536
+ emit({
1537
+ type: "service-exited",
1538
+ exitCode,
1539
+ repoName,
1540
+ subsystem: svcName,
1541
+ });
1542
+ }
1543
+ : undefined,
1544
+ );
1545
+
1546
+ if (detached) {
1547
+ // Tell the client the daemon is up; keep the tail proc (svcProc) alive
1548
+ // so startup output keeps streaming until `make up` finishes.
1549
+ // Kill the tail proc only when the client disconnects (tab closed).
1550
+ emit({ type: "started", detached: true });
1551
+ res.on("close", () => {
1552
+ active = false;
1553
+ try {
1554
+ svcProc.kill();
1555
+ } catch {
1556
+ /* already exited */
1557
+ }
1558
+ });
1559
+ }
1560
+ return;
1561
+ }
1562
+
1563
+ // ── API: service logs (SSE) ───────────────────────────────────────────────
1564
+ if (pathname === "/api/service/logs") {
1565
+ const repoName = url.searchParams.get("repo");
1566
+ const svcName = url.searchParams.get("service");
1567
+ const env = (url.searchParams.get("env") ?? "local") as Environment;
1568
+
1569
+ if (!repoName || !svcName)
1570
+ return send400(res, "repo and service are required");
1571
+
1572
+ const repos = discoverRepos();
1573
+ const repo = repos.find((r) => r.repoName === repoName);
1574
+ const logSource = repo?.manifest.services?.[svcName]?.logs?.[env] as
1575
+ | LogSource
1576
+ | undefined;
1577
+
1578
+ if (!logSource) {
1579
+ res.writeHead(404, { "Content-Type": "text/plain" });
1580
+ res.end(
1581
+ `No log source configured for ${repoName}/${svcName} in env "${env}"`,
1582
+ );
1583
+ return;
1584
+ }
1585
+
1586
+ res.writeHead(200, {
1587
+ "Content-Type": "text/event-stream",
1588
+ "Cache-Control": "no-cache",
1589
+ Connection: "keep-alive",
1590
+ "X-Accel-Buffering": "no",
1591
+ });
1592
+
1593
+ const emit = (obj: unknown): void => {
1594
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
1595
+ };
1596
+
1597
+ if (logSource.type === "process") {
1598
+ emit({
1599
+ type: "error",
1600
+ text: "process logs: attach to a running process is not yet supported",
1601
+ });
1602
+ res.end();
1603
+ return;
1604
+ }
1605
+
1606
+ if (logSource.type === "shell") {
1607
+ emit({ type: "cmd", text: `$ ${logSource.cmd}` });
1608
+
1609
+ let active = true;
1610
+ const lineBuffer: string[] = [];
1611
+ let confirmed = false;
1612
+ let quickTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
1613
+ quickTimer = null;
1614
+ confirmed = true;
1615
+ for (const line of lineBuffer) emit({ type: "line", text: line });
1616
+ lineBuffer.length = 0;
1617
+ }, DAEMON_QUICK_EXIT_MS);
1618
+
1619
+ const proc = testRunner.spawnStreaming(
1620
+ logSource.cmd,
1621
+ [],
1622
+ { ...process.env },
1623
+ (line) => {
1624
+ if (confirmed) {
1625
+ emit({ type: "line", text: line });
1626
+ } else {
1627
+ lineBuffer.push(line);
1628
+ }
1629
+ },
1630
+ (exitCode) => {
1631
+ if (!active) return;
1632
+ if (quickTimer !== null) {
1633
+ clearTimeout(quickTimer);
1634
+ quickTimer = null;
1635
+ lineBuffer.length = 0;
1636
+ emit({
1637
+ type: "line",
1638
+ text: `No running log stream for ${repoName}/${svcName}; the service may be down.`,
1639
+ });
1640
+ } else if (exitCode !== 0) {
1641
+ emit({
1642
+ type: "line",
1643
+ text: `── log stream exited (${exitCode}) ──`,
1644
+ });
1645
+ }
1646
+ emit({ type: "done", exitCode, duration: 0 });
1647
+ res.end();
1648
+ },
1649
+ repo?.repoPath,
1650
+ true, // detached — own process group
1651
+ );
1652
+
1653
+ res.on("close", () => {
1654
+ active = false;
1655
+ if (quickTimer !== null) {
1656
+ clearTimeout(quickTimer);
1657
+ quickTimer = null;
1658
+ }
1659
+ try {
1660
+ proc.kill("SIGTERM");
1661
+ } catch {
1662
+ /* already exited */
1663
+ }
1664
+ });
1665
+ return;
1666
+ }
1667
+
1668
+ if (logSource.type === "cloudwatch") {
1669
+ const region =
1670
+ logSource.region ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1";
1671
+ const profile = loadAwsProfiles()[env];
1672
+ const args = [
1673
+ "logs",
1674
+ "tail",
1675
+ logSource.logGroup,
1676
+ "--follow",
1677
+ "--region",
1678
+ region,
1679
+ ];
1680
+ if (logSource.logStream)
1681
+ args.push("--log-stream-name-prefix", logSource.logStream);
1682
+ if (profile) args.push("--profile", profile);
1683
+
1684
+ emit({ type: "cmd", text: `$ aws ${args.join(" ")}` });
1685
+
1686
+ // Track output lines to detect AWS auth errors
1687
+ const AUTH_KEYWORDS = [
1688
+ "session has expired",
1689
+ "reauthenticate",
1690
+ "aws login",
1691
+ "aws sso login",
1692
+ "not authorized",
1693
+ "invalid token",
1694
+ "ExpiredToken",
1695
+ "NoCredentialsError",
1696
+ ];
1697
+ let authRequired = false;
1698
+
1699
+ // Strip static credential env vars so --profile takes effect.
1700
+ // AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY have higher precedence than
1701
+ // --profile and will silently override it if left in the environment.
1702
+ const awsEnv = { ...process.env };
1703
+ if (profile) {
1704
+ delete awsEnv.AWS_ACCESS_KEY_ID;
1705
+ delete awsEnv.AWS_SECRET_ACCESS_KEY;
1706
+ delete awsEnv.AWS_SESSION_TOKEN;
1707
+ delete awsEnv.AWS_SECURITY_TOKEN;
1708
+ delete awsEnv.AWS_PROFILE;
1709
+ delete awsEnv.AWS_DEFAULT_PROFILE;
1710
+ }
1711
+
1712
+ const proc = testRunner.spawnStreaming(
1713
+ "aws",
1714
+ args,
1715
+ awsEnv,
1716
+ (line) => {
1717
+ const lower = line.toLowerCase();
1718
+ if (AUTH_KEYWORDS.some((k) => lower.includes(k.toLowerCase())))
1719
+ authRequired = true;
1720
+ emit({ type: "line", text: line });
1721
+ },
1722
+ (exitCode) => {
1723
+ emit({
1724
+ type: "done",
1725
+ exitCode,
1726
+ duration: 0,
1727
+ authRequired: authRequired && exitCode !== 0,
1728
+ });
1729
+ res.end();
1730
+ },
1731
+ undefined,
1732
+ true, // detached — own process group
1733
+ );
1734
+
1735
+ res.on("close", () => {
1736
+ try {
1737
+ proc.kill("SIGTERM");
1738
+ } catch {
1739
+ /* already exited */
1740
+ }
1741
+ });
1742
+ return;
1743
+ }
1744
+
1745
+ emit({
1746
+ type: "error",
1747
+ text: `Unsupported log source type: ${(logSource as LogSource).type}`,
1748
+ });
1749
+ res.end();
1750
+ return;
1751
+ }
1752
+
1753
+ // ── API: running daemons list ──────────────────────────────────────────────
1754
+ if (pathname === "/api/service/daemons") {
1755
+ const daemons = listDaemons().map((d) => ({
1756
+ address: d.address,
1757
+ repoName: d.repoName,
1758
+ subsystem: d.subsystem,
1759
+ repoPath: d.repoPath,
1760
+ branch: d.branch ?? null,
1761
+ workspaces: d.workspaces ?? [],
1762
+ logPath: d.logPath,
1763
+ startedAt: d.startedAt.toISOString(),
1764
+ }));
1765
+ return sendJson(res, daemons);
1766
+ }
1767
+
1768
+ // ── API: service taillog (SSE) — live log stream for a running service ───────
1769
+ //
1770
+ // Source resolution, in order:
1771
+ // 1. The service's manifest `logs.local` shell command (e.g. `docker logs -f`)
1772
+ // 2. The service's running container → `docker logs -f`: a compose
1773
+ // stack's `<stack>-<workspace>-<service>-1`, else an
1774
+ // `opctl_<base>_<hash>` one matched from the repo's op manifest / repo
1775
+ // name — real runtime logs with zero manifest config
1776
+ // 3. The den daemon startup log (~/.den/logs/<repo>/<service>.log) —
1777
+ // last resort, covers host-run processes with no container
1778
+ // A manifest command that exits within the quick-exit window (container not
1779
+ // found, stale filter) drops to tier 2/3, retrying tier 1 in between.
1780
+ // ── API: service log HISTORY — paged backwards to container startup ────────
1781
+ //
1782
+ // Docker retains the full log of the current container instance; this pages
1783
+ // it backwards with opaque cursors (byte offsets into an immutable snapshot
1784
+ // — see log-history.ts). env=local only in v1; other envs return
1785
+ // { unsupported: true } so the client renders a friendly notice.
1786
+ if (pathname === "/api/service/history") {
1787
+ // This handler sits ABOVE the blanket /api/service* local-dev guard
1788
+ // (later in this function), so it must enforce the guard itself.
1789
+ if (!isLocalEnabled()) {
1790
+ res.writeHead(403);
1791
+ res.end("local-dev is disabled");
1792
+ return;
1793
+ }
1794
+ const repoName = url.searchParams.get("repo");
1795
+ const svcName = url.searchParams.get("service");
1796
+ const env = url.searchParams.get("env") ?? "local";
1797
+ const cursor = url.searchParams.get("cursor") || null;
1798
+ const limit = Math.min(
1799
+ Math.max(
1800
+ parseInt(url.searchParams.get("limit") ?? "2000", 10) || 2000,
1801
+ 1,
1802
+ ),
1803
+ 10_000,
1804
+ );
1805
+ if (!repoName || !svcName)
1806
+ return send400(res, "repo and service are required");
1807
+ if (!isSafeServiceName(repoName) || !isSafeServiceName(svcName))
1808
+ return send400(res, "invalid repo/service name");
1809
+ try {
1810
+ const source = resolveHistorySource(repoName, svcName, env);
1811
+ if (source.kind === "unsupported")
1812
+ return sendJson(res, { unsupported: true, reason: source.reason });
1813
+ const snapshot = ensureSnapshot(source);
1814
+ if (!snapshot)
1815
+ return sendJson(res, {
1816
+ unsupported: true,
1817
+ reason: "could not snapshot the container log",
1818
+ });
1819
+ return sendJson(res, readPageBefore(snapshot, cursor, limit));
1820
+ } catch (e) {
1821
+ return send400(res, (e as Error).message);
1822
+ }
1823
+ }
1824
+
1825
+ // ── API: cross-system history search ───────────────────────────────────────
1826
+ //
1827
+ // Greps the FULL history of each requested system (targets = the /logs
1828
+ // toolbar's list, computed client-side so scoping lives in one place).
1829
+ if (pathname === "/api/logs/search") {
1830
+ // /api/logs is not covered by the downstream /api/service* local-dev
1831
+ // guard — enforce it here.
1832
+ if (!isLocalEnabled()) {
1833
+ res.writeHead(403);
1834
+ res.end("local-dev is disabled");
1835
+ return;
1836
+ }
1837
+ const q = url.searchParams.get("q") ?? "";
1838
+ const env = url.searchParams.get("env") ?? "local";
1839
+ const targets = (url.searchParams.get("targets") ?? "")
1840
+ .split(",")
1841
+ .map((t) => t.trim())
1842
+ .filter(Boolean)
1843
+ .slice(0, 40)
1844
+ .map((t) => {
1845
+ const slash = t.lastIndexOf("/");
1846
+ return { repo: t.slice(0, slash), service: t.slice(slash + 1) };
1847
+ })
1848
+ .filter(
1849
+ (t) =>
1850
+ t.repo &&
1851
+ t.service &&
1852
+ isSafeServiceName(t.repo) &&
1853
+ isSafeServiceName(t.service),
1854
+ );
1855
+ if (q.trim().length < 2) return send400(res, "q must be at least 2 chars");
1856
+ if (targets.length === 0) return send400(res, "targets required");
1857
+ void Promise.all(targets.map((t) => searchSystemHistory(t, q, env))).then(
1858
+ (results) => sendJson(res, { q, results }),
1859
+ );
1860
+ return;
1861
+ }
1862
+
1863
+ if (pathname === "/api/service/taillog") {
1864
+ const repoName = url.searchParams.get("repo");
1865
+ const svcName = url.searchParams.get("service");
1866
+ if (!repoName || !svcName)
1867
+ return send400(res, "repo and service are required");
1868
+
1869
+ // Resolve manifest log source. The client names an exact service, so
1870
+ // when the manifest has that key, use it directly — resolveService's
1871
+ // bare-repo correction rewrites the subsystem to the ENTRYPOINT (a
1872
+ // shop/db request would otherwise read api's log source).
1873
+ const tailResolved =
1874
+ resolveService(repoName) ?? resolveService(`${repoName}/${svcName}`);
1875
+ const tailManifest = tailResolved
1876
+ ? readManifest(tailResolved.repoPath)
1877
+ : null;
1878
+ const tailSubsystem = tailManifest?.services?.[svcName]
1879
+ ? svcName
1880
+ : (tailResolved?.subsystem ?? svcName);
1881
+ const logSource = tailManifest?.services?.[tailSubsystem]?.logs?.[
1882
+ "local"
1883
+ ] as LogSource | undefined;
1884
+
1885
+ const daemonLogPath = getServiceLogPath(repoName, svcName);
1886
+ const fallbackCmd = `tail -F -n ${LIVE_TAIL_LINES} "${daemonLogPath}"`;
1887
+
1888
+ const manifestCmd =
1889
+ logSource?.type === "shell" && logSource.cmd ? logSource.cmd : null;
1890
+ const manifestCwd = manifestCmd ? tailResolved?.repoPath : undefined;
1891
+
1892
+ res.writeHead(200, {
1893
+ "Content-Type": "text/event-stream",
1894
+ "Cache-Control": "no-cache",
1895
+ Connection: "keep-alive",
1896
+ "X-Accel-Buffering": "no",
1897
+ });
1898
+
1899
+ let active = true;
1900
+ const emit = (obj: unknown): void => {
1901
+ if (!active) return;
1902
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
1903
+ };
1904
+
1905
+ // If the manifest log command exits within QUICK_EXIT_MS, we consider it a
1906
+ // transient failure (e.g. container not yet running) and retry. After
1907
+ // MAX_RETRIES quick exits we give up and fall back to the daemon log.
1908
+ const QUICK_EXIT_MS = DAEMON_QUICK_EXIT_MS;
1909
+ const RETRY_DELAY_MS = DAEMON_RETRY_DELAY_MS;
1910
+ const MAX_RETRIES = 12; // up to ~60s of retries before giving up
1911
+
1912
+ let currentProc: ReturnType<typeof spawnStreaming> | null = null;
1913
+ let retryCount = 0;
1914
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
1915
+
1916
+ // Fallback tier 1: resolve the service's running container (its compose
1917
+ // stack's, else opctl's `opctl_<base>_<hash>`) and stream
1918
+ // `docker logs -f`. This gives REAL runtime logs for services with no (or
1919
+ // broken) manifest `logs.local` — no den daemon file needed. Returns
1920
+ // false when no such container is running.
1921
+ const startDockerFallback = (): boolean => {
1922
+ const container = findServiceContainer(
1923
+ repoName,
1924
+ tailResolved?.repoPath,
1925
+ tailSubsystem,
1926
+ );
1927
+ if (!container) return false;
1928
+ emit({
1929
+ type: "line",
1930
+ text: `── streaming container logs: ${container} ──`,
1931
+ });
1932
+ currentProc = spawnStreaming(
1933
+ `docker logs -f --tail ${LIVE_TAIL_LINES} ${container}`,
1934
+ [],
1935
+ { ...process.env },
1936
+ (line) => emit({ type: "line", text: line }),
1937
+ (exitCode) => {
1938
+ if (!active) return;
1939
+ // Container stopped/removed — end the stream; the client's
1940
+ // auto-reattach re-resolves against whatever is running next.
1941
+ emit({
1942
+ type: "line",
1943
+ text: `── container log stream ended (${exitCode}) ──`,
1944
+ });
1945
+ res.end();
1946
+ },
1947
+ undefined,
1948
+ true,
1949
+ );
1950
+ return true;
1951
+ };
1952
+
1953
+ // Fallback tier 2 (last resort): tail the den daemon startup log —
1954
+ // covers host-run processes (no container to derive logs from).
1955
+ const startDaemonFileFallback = (): void => {
1956
+ emit({
1957
+ type: "line",
1958
+ text: "── container log unavailable — watching startup log ──",
1959
+ });
1960
+ currentProc = spawnStreaming(
1961
+ fallbackCmd,
1962
+ [],
1963
+ { ...process.env },
1964
+ (line) => emit({ type: "line", text: line }),
1965
+ () => {
1966
+ if (active) res.end();
1967
+ },
1968
+ undefined,
1969
+ true,
1970
+ );
1971
+ };
1972
+
1973
+ const startFallback = (): void => {
1974
+ if (startDockerFallback()) return;
1975
+ startDaemonFileFallback();
1976
+ };
1977
+
1978
+ const tryManifestCmd = (): void => {
1979
+ // Buffer lines during the quick-exit window — only flush them to the
1980
+ // client once we know the command has been alive long enough to be real.
1981
+ // This prevents docker error messages from cluttering the tab on retries.
1982
+ const lineBuffer: string[] = [];
1983
+ let confirmed = false; // true once QUICK_EXIT_MS has passed without exit
1984
+
1985
+ let quickTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
1986
+ quickTimer = null;
1987
+ confirmed = true;
1988
+ // Flush any buffered lines now that we know the command is alive.
1989
+ for (const line of lineBuffer) emit({ type: "line", text: line });
1990
+ lineBuffer.length = 0;
1991
+ }, QUICK_EXIT_MS);
1992
+
1993
+ currentProc = spawnStreaming(
1994
+ manifestCmd!,
1995
+ [],
1996
+ { ...process.env },
1997
+ (line) => {
1998
+ if (confirmed) {
1999
+ emit({ type: "line", text: line });
2000
+ } else {
2001
+ lineBuffer.push(line);
2002
+ }
2003
+ },
2004
+ (exitCode) => {
2005
+ if (!active) return;
2006
+ if (quickTimer !== null) {
2007
+ // Exited before QUICK_EXIT_MS — the manifest log command is broken
2008
+ // or the container isn't ready. When den's own daemon log exists
2009
+ // (den spawned this service), fall back to it IMMEDIATELY instead
2010
+ // of retrying a dead command for ~60s of silent tab (the exact
2011
+ // failure mode of stale `docker ps --filter name=…` log commands).
2012
+ clearTimeout(quickTimer);
2013
+ quickTimer = null;
2014
+ lineBuffer.length = 0; // discard noisy error output
2015
+ if (startDockerFallback()) {
2016
+ // streaming the conventional container's logs
2017
+ } else if (fs.existsSync(daemonLogPath)) {
2018
+ startDaemonFileFallback();
2019
+ } else if (retryCount < MAX_RETRIES) {
2020
+ retryCount++;
2021
+ retryTimer = setTimeout(() => {
2022
+ retryTimer = null;
2023
+ if (active) tryManifestCmd();
2024
+ }, RETRY_DELAY_MS);
2025
+ } else {
2026
+ startFallback();
2027
+ }
2028
+ } else if (exitCode !== 0) {
2029
+ // Ran for a while then exited with error.
2030
+ emit({
2031
+ type: "line",
2032
+ text: `── log stream exited (${exitCode}) ──`,
2033
+ });
2034
+ res.end();
2035
+ } else {
2036
+ res.end();
2037
+ }
2038
+ },
2039
+ manifestCwd,
2040
+ true,
2041
+ );
2042
+ };
2043
+
2044
+ if (manifestCmd) {
2045
+ tryManifestCmd();
2046
+ } else {
2047
+ // No manifest log source (e.g. shop-web, control-plane): conventional
2048
+ // container logs first, daemon startup log as last resort.
2049
+ startFallback();
2050
+ }
2051
+
2052
+ res.on("close", () => {
2053
+ active = false;
2054
+ if (retryTimer) {
2055
+ clearTimeout(retryTimer);
2056
+ retryTimer = null;
2057
+ }
2058
+ try {
2059
+ currentProc?.kill("SIGTERM");
2060
+ } catch {
2061
+ /* already exited */
2062
+ }
2063
+ });
2064
+ return;
2065
+ }
2066
+
2067
+ // ── API: run tests (SSE) ───────────────────────────────────────────────────
2068
+ if (pathname === "/api/run") {
2069
+ const pillar = url.searchParams.get("pillar") as Pillar | null;
2070
+ const modules = url.searchParams.getAll("module").filter(Boolean);
2071
+ const env = (url.searchParams.get("env") ?? "local") as Environment;
2072
+ const baseApiUrl = url.searchParams.get("baseApiUrl") ?? undefined;
2073
+ const useLocalCheckout = url.searchParams.get("useLocalCheckout") === "1";
2074
+ const modeParam = url.searchParams.get("mode");
2075
+ const securityMode =
2076
+ modeParam === "api" || modeParam === "full" ? modeParam : "baseline";
2077
+ // Per-perf-test declarative optional-field values, as URL-encoded JSON:
2078
+ // { [moduleName]: { [fieldName]: value } }
2079
+ // Each module's declared `optionalFields` are resolved to env vars below and
2080
+ // routed ONLY to that module's k6 process (never globally).
2081
+ const optsRaw = url.searchParams.get("opts");
2082
+ let opts: Record<string, Record<string, unknown>> = {};
2083
+ if (optsRaw) {
2084
+ try {
2085
+ const parsed = JSON.parse(optsRaw);
2086
+ if (parsed && typeof parsed === "object") {
2087
+ opts = parsed as Record<string, Record<string, unknown>>;
2088
+ }
2089
+ } catch {
2090
+ // Malformed opts → ignore; fall back to per-field defaults.
2091
+ }
2092
+ }
2093
+
2094
+ if (!pillar) return send400(res, "pillar required");
2095
+
2096
+ // Resolve declarative optional fields → per-module env. Only the perf pillar
2097
+ // declares optionalFields; for every other pillar this is an empty map.
2098
+ let moduleEnv: Record<string, Record<string, string>> | undefined;
2099
+ if (pillar === "performance") {
2100
+ moduleEnv = {};
2101
+ try {
2102
+ const perfModules = await moduleScanner.scanModules("performance");
2103
+ // When "All modules" is selected the client sends no `module` params;
2104
+ // resolve fields for every perf module so its defaults still apply.
2105
+ const targetNames =
2106
+ modules.length > 0 ? modules : perfModules.map((m) => m.name);
2107
+ for (const m of perfModules) {
2108
+ if (!targetNames.includes(m.name)) continue;
2109
+ const fields = m.optionalFields ?? [];
2110
+ if (fields.length === 0) continue;
2111
+ const values = (opts[m.name] ?? {}) as Record<
2112
+ string,
2113
+ string | number | boolean | undefined
2114
+ >;
2115
+ const env = resolveFieldEnv(fields, values);
2116
+ if (Object.keys(env).length > 0) moduleEnv[m.name] = env;
2117
+ }
2118
+ } catch (err) {
2119
+ serverLog(` ⚠ /api/run optionalFields: ${(err as Error).message}`);
2120
+ }
2121
+ }
2122
+
2123
+ res.writeHead(200, {
2124
+ "Content-Type": "text/event-stream",
2125
+ "Cache-Control": "no-cache",
2126
+ Connection: "keep-alive",
2127
+ "X-Accel-Buffering": "no",
2128
+ });
2129
+
2130
+ const emit = (obj: unknown): void => {
2131
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
2132
+ };
2133
+
2134
+ const steps = testRunner.buildCommand({
2135
+ pillar,
2136
+ environment: env,
2137
+ modules,
2138
+ baseApiUrl,
2139
+ useLocalCheckout,
2140
+ moduleEnv,
2141
+ securityMode,
2142
+ });
2143
+
2144
+ if (steps.length === 0) {
2145
+ emit({
2146
+ type: "error",
2147
+ text: `No command configured for pillar: ${pillar}`,
2148
+ });
2149
+ res.end();
2150
+ return;
2151
+ }
2152
+
2153
+ // Log the source(s) of truth before the first step so run output captures
2154
+ // which repo/branch/SHA was actually tested. Silent for pillars that don't
2155
+ // read tests from repos (integration, perf are den-owned).
2156
+ if (
2157
+ pillar === "synthetic" ||
2158
+ pillar === "smoke" ||
2159
+ pillar === "journeys" ||
2160
+ pillar === "security"
2161
+ ) {
2162
+ try {
2163
+ const sources = collectTestSources(pillar);
2164
+ if (sources.length === 0) {
2165
+ emit({ type: "line", text: `→ source: (no repos discovered)` });
2166
+ } else {
2167
+ for (const s of sources) {
2168
+ emit({ type: "line", text: `→ source: ${formatSourceLine(s)}` });
2169
+ }
2170
+ }
2171
+ } catch (err) {
2172
+ emit({
2173
+ type: "line",
2174
+ text: `→ source: (resolution failed: ${(err as Error).message})`,
2175
+ });
2176
+ }
2177
+ }
2178
+
2179
+ const runId = shortRunId();
2180
+ const modDesc = modules.length ? modules.join(",") : "all";
2181
+ const runDesc = `${pillar}/${env} · modules=${modDesc}`;
2182
+ const runActor = actorFromHeaders(req.headers);
2183
+ recordAction(runActor, { verb: "run", detail: `${runDesc} [${runId}]` });
2184
+ if (
2185
+ pillar === "synthetic" ||
2186
+ pillar === "smoke" ||
2187
+ pillar === "journeys" ||
2188
+ pillar === "security"
2189
+ ) {
2190
+ try {
2191
+ const sources = collectTestSources(pillar);
2192
+ for (const s of sources) {
2193
+ serverLog(` source: ${formatSourceLine(s)} [${runId}]`, "lifecycle");
2194
+ }
2195
+ } catch {
2196
+ /* already emitted above as an SSE line */
2197
+ }
2198
+ }
2199
+
2200
+ const startTime = Date.now();
2201
+ let overallExit = 0;
2202
+
2203
+ const finishLifecycle = (exitCode: number, note?: string): void => {
2204
+ const dur = formatDuration(Date.now() - startTime);
2205
+ const extra = note ? ` · ${note}` : "";
2206
+ serverLog(
2207
+ `■ run ${runDesc} · exit=${exitCode} · ${dur}${extra} [${runId}]${actorSuffix(runActor)}`,
2208
+ "lifecycle",
2209
+ runActor ? actorLabel(runActor) : undefined,
2210
+ );
2211
+ // Dev-tooling telemetry: one invocation per test run (pillar/env are
2212
+ // low-cardinality enums; module count only — never module names/args).
2213
+ recordInvocation({
2214
+ command: "test.run",
2215
+ surface: "ux",
2216
+ durationMs: Date.now() - startTime,
2217
+ exit: exitCode,
2218
+ extra: {
2219
+ pillar: String(pillar),
2220
+ env: String(env),
2221
+ modules: modules.length,
2222
+ },
2223
+ });
2224
+ };
2225
+
2226
+ const runStep = (i: number): void => {
2227
+ if (i >= steps.length) {
2228
+ finishLifecycle(overallExit);
2229
+ emit(
2230
+ getRunDonePayload({
2231
+ pillar,
2232
+ exitCode: overallExit,
2233
+ duration: Date.now() - startTime,
2234
+ rootDir: ROOT,
2235
+ }),
2236
+ );
2237
+ res.end();
2238
+ return;
2239
+ }
2240
+
2241
+ const step = steps[i];
2242
+ emit({ type: "cmd", text: `$ ${step.command} ${step.args.join(" ")}` });
2243
+
2244
+ testRunner.spawnStreaming(
2245
+ step.command,
2246
+ step.args,
2247
+ step.env,
2248
+ (line) => {
2249
+ mirrorLine(runId, line);
2250
+ emit({ type: "line", text: line });
2251
+ },
2252
+ (exitCode) => {
2253
+ if (exitCode !== 0) {
2254
+ if (overallExit === 0) {
2255
+ overallExit = exitCode;
2256
+ }
2257
+ if (!step.continueOnFailure) {
2258
+ emit({
2259
+ type: "error",
2260
+ text: `Step "${step.label}" failed (exit ${exitCode}) — aborting`,
2261
+ });
2262
+ finishLifecycle(exitCode, `step "${step.label}" failed`);
2263
+ emit(
2264
+ getRunDonePayload({
2265
+ pillar,
2266
+ exitCode,
2267
+ duration: Date.now() - startTime,
2268
+ rootDir: ROOT,
2269
+ }),
2270
+ );
2271
+ res.end();
2272
+ return;
2273
+ }
2274
+ }
2275
+ runStep(i + 1);
2276
+ },
2277
+ );
2278
+ };
2279
+
2280
+ runStep(0);
2281
+ return;
2282
+ }
2283
+
2284
+ // ── API: AWS re-authenticate (SSE) ───────────────────────────────────────
2285
+ if (pathname === "/api/aws/login") {
2286
+ const env = (url.searchParams.get("env") ?? "local") as Environment;
2287
+ const profiles = loadAwsProfiles();
2288
+ const profile = profiles[env];
2289
+
2290
+ res.writeHead(200, {
2291
+ "Content-Type": "text/event-stream",
2292
+ "Cache-Control": "no-cache",
2293
+ Connection: "keep-alive",
2294
+ "X-Accel-Buffering": "no",
2295
+ });
2296
+
2297
+ const emit = (obj: unknown): void => {
2298
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
2299
+ };
2300
+
2301
+ if (!profile) {
2302
+ emit({
2303
+ type: "error",
2304
+ text: `No AWS profile configured for env "${env}". Run: make aws-configure`,
2305
+ });
2306
+ emit({ type: "done", exitCode: 1, duration: 0 });
2307
+ res.end();
2308
+ return;
2309
+ }
2310
+
2311
+ const args = ["sso", "login", "--profile", profile];
2312
+ emit({ type: "cmd", text: `$ aws ${args.join(" ")}` });
2313
+
2314
+ const proc = testRunner.spawnStreaming(
2315
+ "aws",
2316
+ args,
2317
+ { ...process.env },
2318
+ (line) => emit({ type: "line", text: line }),
2319
+ (exitCode) => {
2320
+ emit({ type: "done", exitCode, duration: 0 });
2321
+ res.end();
2322
+ },
2323
+ undefined,
2324
+ false, // not detached — we want signals to reach it
2325
+ );
2326
+
2327
+ res.on("close", () => {
2328
+ try {
2329
+ proc.kill("SIGTERM");
2330
+ } catch {
2331
+ /* already exited */
2332
+ }
2333
+ });
2334
+ return;
2335
+ }
2336
+
2337
+ // ── API: service catalog ──────────────────────────────────────────────────
2338
+ if (pathname === "/api/catalog") {
2339
+ return sendJson(res, readCatalog());
2340
+ }
2341
+
2342
+ // ── API: den config ────────────────────────────────────────────────────
2343
+ if (pathname === `/api/${product().cli}/config` && req.method === "GET") {
2344
+ // `org` resolved (id defaults to edge.org) for the header's org label.
2345
+ return sendJson(res, { ...readDenConfig(), org: readOrg() });
2346
+ }
2347
+
2348
+ // ── API: server info + staleness (drives the header "stale server" pill) ────
2349
+ if (pathname === "/api/server-info" && req.method === "GET") {
2350
+ const { stale, scanned } = computeStale();
2351
+ return sendJson(res, {
2352
+ pid: process.pid,
2353
+ port: PORT,
2354
+ // Den's version: the MCP server compares it with the routes it needs.
2355
+ version: engineVersion() ?? null,
2356
+ startedAt: new Date(SERVER_STARTED_AT).toISOString(),
2357
+ uptimeMs: Date.now() - SERVER_STARTED_AT,
2358
+ stale,
2359
+ scanned,
2360
+ });
2361
+ }
2362
+
2363
+ // ── API: restart the web server (from the "stale server" UI) ────────────────
2364
+ // Spawns a detached `den web restart` that stops THIS process and starts a
2365
+ // fresh daemon on the same port. Respond first, then spawn on the next tick so
2366
+ // the response flushes before the driver child arrives to stop us.
2367
+ if (pathname === "/api/web/restart" && req.method === "POST") {
2368
+ serverLog("↻ web restart requested from the UI", "lifecycle");
2369
+ sendJson(res, { restarting: true, pid: process.pid });
2370
+ setImmediate(() => {
2371
+ try {
2372
+ spawnDetachedWebRestart(PORT);
2373
+ } catch (e) {
2374
+ serverLog(` ⚠ web restart failed to spawn: ${(e as Error).message}`);
2375
+ }
2376
+ });
2377
+ return;
2378
+ }
2379
+
2380
+ // ── API: release config (GET effective / PUT personal override) ────────────
2381
+ // Deliberately placed before the local-dev guard: the Release tab is for
2382
+ // hosted deploys and must work when `local` is disabled.
2383
+ if (pathname === "/api/release/config") {
2384
+ if (req.method === "GET") return sendJson(res, releaseService.config());
2385
+ if (req.method === "PUT") {
2386
+ try {
2387
+ const patch = JSON.parse(await readBody(req)) as Partial<ReleaseConfig>;
2388
+ if (typeof patch !== "object" || patch === null || Array.isArray(patch))
2389
+ return send400(res, "expected a release config object");
2390
+ if (patch.repos !== undefined) {
2391
+ if (!Array.isArray(patch.repos))
2392
+ return send400(res, "repos must be an array");
2393
+ for (const r of patch.repos) {
2394
+ if (!r || typeof r.name !== "string" || typeof r.wave !== "number")
2395
+ return send400(
2396
+ res,
2397
+ "each repo needs a string name and numeric wave",
2398
+ );
2399
+ }
2400
+ }
2401
+ // Whether the page may deploy is config/den.yml's call, never the page's.
2402
+ delete (patch as { deploy?: boolean }).deploy;
2403
+ writeUserReleaseConfig(patch);
2404
+ return sendJson(res, releaseService.config());
2405
+ } catch (e) {
2406
+ return send400(res, `Invalid request: ${(e as Error).message}`);
2407
+ }
2408
+ }
2409
+ res.writeHead(405);
2410
+ res.end("Method not allowed");
2411
+ return;
2412
+ }
2413
+
2414
+ // ── API: release status (verify table) ─────────────────────────────────────
2415
+ if (pathname === "/api/release/status" && req.method === "GET") {
2416
+ const env = url.searchParams.get("env") ?? "";
2417
+ const refresh = url.searchParams.get("refresh") === "1";
2418
+ const cfg = releaseService.config();
2419
+ if (!cfg.environments.includes(env))
2420
+ return send400(res, `Unknown env: ${env}`);
2421
+ try {
2422
+ const rows = await releaseService.verifyEnv(env, refresh);
2423
+ return sendJson(res, { env, rows, fetchedAt: new Date().toISOString() });
2424
+ } catch (e) {
2425
+ serverLog(` ⚠ /api/release/status: ${(e as Error).message}`);
2426
+ return sendJson(res, { env, rows: [], error: (e as Error).message });
2427
+ }
2428
+ }
2429
+
2430
+ // ── API: live CD runs (Release page poll) ──────────────────────────────────
2431
+ // Deliberately not env-scoped — see RepoRunState: a run object does not carry
2432
+ // the environment it targets, so a run in flight blocks that repo outright.
2433
+ if (pathname === "/api/release/runs" && req.method === "GET") {
2434
+ const refresh = url.searchParams.get("refresh") === "1";
2435
+ try {
2436
+ return sendJson(res, {
2437
+ runs: await releaseService.listRepoRuns(refresh),
2438
+ });
2439
+ } catch (e) {
2440
+ serverLog(` ⚠ /api/release/runs: ${(e as Error).message}`);
2441
+ return sendJson(res, { runs: [], error: (e as Error).message });
2442
+ }
2443
+ }
2444
+
2445
+ // ── API: release branches (deploy-time branch selector) ────────────────────
2446
+ if (pathname === "/api/release/branches" && req.method === "GET") {
2447
+ const repo = url.searchParams.get("repo") ?? "";
2448
+ const cfg = releaseService.config();
2449
+ if (!cfg.repos.some((r) => r.name === repo))
2450
+ return send400(res, `Unknown repo: ${repo}`);
2451
+ try {
2452
+ return sendJson(res, await releaseService.listBranches(repo));
2453
+ } catch {
2454
+ return sendJson(res, ["main"]);
2455
+ }
2456
+ }
2457
+
2458
+ // ── API: release deploy (session-backed) ───────────────────────────────────
2459
+ // Decoupled from the browser so the log survives page navigation and den
2460
+ // keeps monitoring even with no client attached: POST starts a background
2461
+ // deploy SESSION, GET ?session= attaches an SSE stream that REPLAYS the log so
2462
+ // far then streams live events, DELETE dismisses (closes) it.
2463
+ // Policy: prod requires confirm==="prod"; deploy-all→prod is enforced
2464
+ // main-only; a single non-main→prod deploy is an allowed "hotfix".
2465
+ if (pathname === "/api/release/deploy/active" && req.method === "GET") {
2466
+ return sendJson(res, deploySessions.active());
2467
+ }
2468
+
2469
+ if (pathname === "/api/release/deploy") {
2470
+ // ── Start (POST) → { id, label, status, ... } | 4xx ──
2471
+ if (req.method === "POST") {
2472
+ if (!readReleaseConfig().deploy) {
2473
+ res.writeHead(403, { "Content-Type": "application/json" });
2474
+ res.end(JSON.stringify({ error: DEPLOYS_OFF_MESSAGE }));
2475
+ return;
2476
+ }
2477
+ let body: {
2478
+ env?: string;
2479
+ mode?: string;
2480
+ repo?: string;
2481
+ ref?: string;
2482
+ confirm?: string;
2483
+ };
2484
+ try {
2485
+ body = JSON.parse((await readBody(req)) || "{}");
2486
+ } catch {
2487
+ return send400(res, "invalid JSON body");
2488
+ }
2489
+ const env = body.env ?? "";
2490
+ const mode = body.mode ?? "all";
2491
+ const repoParam = body.repo ?? null;
2492
+ const refParam = body.ref || "";
2493
+ const confirm = body.confirm ?? "";
2494
+
2495
+ const cfg = releaseService.config();
2496
+ if (!cfg.environments.includes(env))
2497
+ return send400(res, `Unknown env: ${env}`);
2498
+ const isProd = env === "prod";
2499
+ if (isProd && confirm !== "prod")
2500
+ return send400(res, "Production deploy requires typed confirmation.");
2501
+
2502
+ const notices: ReleaseEvent[] = [];
2503
+ let plan: DeployPlanEntry[];
2504
+ let label: string;
2505
+ if (mode === "single") {
2506
+ if (!repoParam)
2507
+ return send400(res, "repo required for a single deploy.");
2508
+ const r = cfg.repos.find((x) => x.name === repoParam);
2509
+ if (!r) return send400(res, `Unknown repo: ${repoParam}`);
2510
+ const target = resolveReleaseTarget(r, env, cfg);
2511
+ if (target.environments.length > 1)
2512
+ return send400(
2513
+ res,
2514
+ `${r.name} has ${target.environments.length} ${env} environments (${target.environments.join(", ")}); deploy each one from GitHub.`,
2515
+ );
2516
+ const ref = refParam || target.branch;
2517
+ if (ref !== target.branch) {
2518
+ const branches = await releaseService.listBranches(repoParam);
2519
+ if (!branches.includes(ref))
2520
+ return send400(res, `Unknown branch "${ref}" for ${repoParam}.`);
2521
+ }
2522
+ if (isProd && ref !== target.branch)
2523
+ notices.push({
2524
+ type: "log",
2525
+ level: "warn",
2526
+ text: `⚠ HOTFIX: dispatching "${ref}" (not ${target.branch}) to PRODUCTION.`,
2527
+ });
2528
+ plan = [
2529
+ {
2530
+ repo: r.name,
2531
+ workflow: target.workflows[0],
2532
+ ref,
2533
+ wave: r.wave,
2534
+ inputs: target.inputs,
2535
+ },
2536
+ ];
2537
+ label = `${r.name} @ ${ref} → ${env}`;
2538
+ } else {
2539
+ const enabled = cfg.repos.filter((r) => r.enabled !== false);
2540
+ if (!enabled.length)
2541
+ return send400(res, "No repos enabled for deploy.");
2542
+ const targets = new Map(
2543
+ enabled.map((r) => [r.name, resolveReleaseTarget(r, env, cfg)]),
2544
+ );
2545
+ // A repo with several environments here (one per tenant) is never
2546
+ // part of deploy-all: each tenant is its own decision.
2547
+ const multi = enabled.filter(
2548
+ (r) => targets.get(r.name)!.environments.length > 1,
2549
+ );
2550
+ for (const r of multi)
2551
+ notices.push({
2552
+ type: "log",
2553
+ level: "warn",
2554
+ text: `Skipping ${r.name}: ${targets.get(r.name)!.environments.length} ${env} environments; deploy each from GitHub.`,
2555
+ });
2556
+ plan = enabled
2557
+ .filter((r) => !multi.includes(r))
2558
+ .map((r) => {
2559
+ const t = targets.get(r.name)!;
2560
+ return {
2561
+ repo: r.name,
2562
+ workflow: t.workflows[0],
2563
+ ref: t.branch,
2564
+ wave: r.wave,
2565
+ inputs: t.inputs,
2566
+ };
2567
+ });
2568
+ if (isProd) {
2569
+ const nonMain = plan.filter((p) => p.ref !== "main");
2570
+ if (nonMain.length)
2571
+ return send400(
2572
+ res,
2573
+ `Deploy-all to prod is main-only. Off-main: ${nonMain
2574
+ .map((p) => `${p.repo}@${p.ref}`)
2575
+ .join(", ")}. Use a per-repo Hotfix instead.`,
2576
+ );
2577
+ }
2578
+ label = `all → ${env}`;
2579
+ }
2580
+
2581
+ notices.unshift({
2582
+ type: "log",
2583
+ text: `▶ deploy ${mode} → ${env} · ${plan.length} repo(s)`,
2584
+ });
2585
+
2586
+ // Concurrent deploys of different systems are fine — only the same
2587
+ // repo+env twice would race two runs against one GitHub concurrency
2588
+ // group.
2589
+ const clashing = deploySessions.conflicts(env, plan);
2590
+ if (clashing.length > 0) {
2591
+ res.writeHead(409, { "Content-Type": "application/json" });
2592
+ res.end(
2593
+ JSON.stringify({
2594
+ error: `Already deploying to ${env}: ${clashing.join(", ")}. Wait for it to finish or dismiss its log.`,
2595
+ repos: clashing,
2596
+ }),
2597
+ );
2598
+ return;
2599
+ }
2600
+ const session = deploySessions.start({ env, mode, label, plan, notices });
2601
+ serverLog(
2602
+ `▶ release deploy ${mode} → ${env} (${plan.length} repos) [${session.id}]`,
2603
+ "lifecycle",
2604
+ );
2605
+ return sendJson(res, session);
2606
+ }
2607
+
2608
+ // ── Attach (GET, SSE): replay buffered log, then stream live ──
2609
+ if (req.method === "GET") {
2610
+ const id = url.searchParams.get("session") ?? "";
2611
+ res.writeHead(200, {
2612
+ "Content-Type": "text/event-stream",
2613
+ "Cache-Control": "no-cache",
2614
+ Connection: "keep-alive",
2615
+ "X-Accel-Buffering": "no",
2616
+ });
2617
+ const emit = (ev: unknown): void => {
2618
+ try {
2619
+ res.write(`data: ${JSON.stringify(ev)}\n\n`);
2620
+ } catch {
2621
+ /* client gone */
2622
+ }
2623
+ };
2624
+ const unsub = deploySessions.attach(id, emit);
2625
+ if (!unsub) {
2626
+ emit({
2627
+ type: "log",
2628
+ level: "error",
2629
+ text: "Deploy session not found (it may have finished long ago or been closed).",
2630
+ });
2631
+ emit({ type: "done", ok: false, summary: "Session unavailable." });
2632
+ res.end();
2633
+ return;
2634
+ }
2635
+ req.on("close", unsub);
2636
+ return;
2637
+ }
2638
+
2639
+ // ── Dismiss (DELETE): user closed the log ──
2640
+ if (req.method === "DELETE") {
2641
+ const id = url.searchParams.get("session") ?? "";
2642
+ return sendJson(res, { ok: deploySessions.dismiss(id) });
2643
+ }
2644
+
2645
+ res.writeHead(405);
2646
+ res.end("Method not allowed");
2647
+ return;
2648
+ }
2649
+
2650
+ // ── Local-dev API guard ───────────────────────────────────────────────────
2651
+ // All workspace / service management APIs require local environment to be enabled.
2652
+ if (
2653
+ (pathname.startsWith("/api/workspaces") ||
2654
+ pathname.startsWith("/api/workspace/") ||
2655
+ pathname.startsWith("/api/service")) &&
2656
+ !isLocalEnabled()
2657
+ ) {
2658
+ res.writeHead(403);
2659
+ res.end("Local environment is not enabled for this deployment");
2660
+ return;
2661
+ }
2662
+
2663
+ // ── Workspace routes (extracted to workspace-routes.ts) ──────────────────
2664
+ await handleWorkspaceRoutes(req, res, pathname, url, editors, github);
2665
+ if (res.headersSent) return;
2666
+
2667
+ // ── API: manifest validation ──────────────────────────────────────────────
2668
+ // POST /api/manifest/validate body: { content: string } (raw YAML string)
2669
+ // GET /api/manifest/validate?path=<absolute-path>
2670
+ if (pathname === "/api/manifest/validate") {
2671
+ if (req.method === "POST") {
2672
+ try {
2673
+ const body = await readBody(req);
2674
+ const { content } = JSON.parse(body) as { content?: string };
2675
+ if (typeof content !== "string")
2676
+ return send400(res, '"content" string required');
2677
+ return sendJson(res, validateManifest(content));
2678
+ } catch (e) {
2679
+ return send400(res, `Invalid request: ${(e as Error).message}`);
2680
+ }
2681
+ }
2682
+ if (req.method === "GET") {
2683
+ const filePath = url.searchParams.get("path");
2684
+ if (!filePath) return send400(res, '"path" query param required');
2685
+ try {
2686
+ const isDir =
2687
+ fs.existsSync(filePath) && fs.lstatSync(filePath).isDirectory();
2688
+ // A repo folder: every layer that applies (repo or held, override,
2689
+ // merged), flattened into the usual shape; each error names its file.
2690
+ if (isDir) {
2691
+ const layered = validateManifestLayers(filePath);
2692
+ if (layered.layers.length > 0)
2693
+ return sendJson(res, {
2694
+ valid: layered.valid,
2695
+ version: layered.layers[0].result.version,
2696
+ errors: layered.layers.flatMap((l) => l.result.errors),
2697
+ warnings: layered.layers.flatMap((l) => l.result.warnings),
2698
+ manifestLayers: loadManifest(filePath).layers,
2699
+ layers: layered.layers,
2700
+ });
2701
+ }
2702
+ const denYml = isDir
2703
+ ? path.join(filePath, `${product().manifestYml}`)
2704
+ : filePath;
2705
+ const content = fs.readFileSync(denYml, "utf-8");
2706
+ return sendJson(res, validateManifest(content));
2707
+ } catch (e) {
2708
+ return sendJson(res, {
2709
+ valid: false,
2710
+ version: 1,
2711
+ errors: [{ path: "", message: (e as Error).message }],
2712
+ warnings: [],
2713
+ });
2714
+ }
2715
+ }
2716
+ }
2717
+
2718
+ // ── API: changelog ────────────────────────────────────────────────────────
2719
+ // GET /api/changelog — parses CHANGELOG.md and returns structured entries
2720
+ if (pathname === "/api/changelog" && req.method === "GET") {
2721
+ const changelogPath = enginePath("CHANGELOG.md");
2722
+ try {
2723
+ const raw = fs.readFileSync(changelogPath, "utf-8");
2724
+ const entries = parseChangelog(raw);
2725
+ return sendJson(res, entries);
2726
+ } catch {
2727
+ return sendJson(res, []);
2728
+ }
2729
+ }
2730
+
2731
+ // ── API: local-dev toggle ─────────────────────────────────────────────────
2732
+ // POST /api/local-dev body: { enabled: boolean }
2733
+ if (pathname === "/api/local-dev" && req.method === "POST") {
2734
+ try {
2735
+ const body = await readBody(req);
2736
+ const { enabled } = JSON.parse(body) as { enabled?: boolean };
2737
+ if (typeof enabled !== "boolean")
2738
+ return send400(res, '"enabled" boolean required');
2739
+ const user = readUserConfig();
2740
+ writeUserConfig({ ...user, local_dev: enabled });
2741
+ return sendJson(res, { ok: true, local_dev: enabled });
2742
+ } catch (e) {
2743
+ return send400(res, `Invalid request: ${(e as Error).message}`);
2744
+ }
2745
+ }
2746
+
2747
+ // ── API: sudo password ────────────────────────────────────────────────────
2748
+ // POST /api/sudo-password body: { password: string, saveToVault?: boolean }
2749
+ // GET /api/sudo-password/status → { set: boolean }
2750
+ // GET /api/sudo-password/vault-status → { opAvailable, hasStored }
2751
+ // POST /api/sudo-password/load-from-vault → { ok, reason? }
2752
+ if (pathname === "/api/sudo-password") {
2753
+ if (req.method !== "POST") {
2754
+ res.writeHead(405);
2755
+ res.end();
2756
+ return;
2757
+ }
2758
+ try {
2759
+ const body = await readBody(req);
2760
+ const { password, saveToVault } = JSON.parse(body) as {
2761
+ password?: string;
2762
+ saveToVault?: boolean;
2763
+ };
2764
+ if (typeof password !== "string" || !password)
2765
+ return send400(res, '"password" string required');
2766
+ // Validate the password before storing it.
2767
+ try {
2768
+ // -k: ignore cached credentials for this check (don't permanently invalidate)
2769
+ execSync("sudo -k -S -v", {
2770
+ input: password + "\n",
2771
+ stdio: ["pipe", "pipe", "pipe"],
2772
+ timeout: CLI_SPAWN_TIMEOUT_MS,
2773
+ });
2774
+ } catch {
2775
+ return sendJson(res, { ok: false, error: "Incorrect password" }, 401);
2776
+ }
2777
+ setSudoPassword(password);
2778
+ let savedToVault = false;
2779
+ let vaultError: string | undefined;
2780
+ if (saveToVault) {
2781
+ try {
2782
+ await saveSudoToVault(password);
2783
+ savedToVault = true;
2784
+ } catch (err) {
2785
+ vaultError = (err as Error).message;
2786
+ }
2787
+ }
2788
+ return sendJson(res, { ok: true, savedToVault, vaultError });
2789
+ } catch (e) {
2790
+ return send400(res, `Invalid request: ${(e as Error).message}`);
2791
+ }
2792
+ }
2793
+
2794
+ if (pathname === "/api/sudo-password/status") {
2795
+ // passwordless: NOPASSWD sudoer (or warm cache) — no admin login needed.
2796
+ return sendJson(res, {
2797
+ set: hasSudoPassword(),
2798
+ passwordless: hasPasswordlessSudo(),
2799
+ });
2800
+ }
2801
+
2802
+ // Advertises vault capability to the UI so it knows whether to show the
2803
+ // "Save to 1Password" checkbox and whether auto-load is worth trying.
2804
+ if (pathname === "/api/sudo-password/vault-status") {
2805
+ const opAvailable = await isOpSignedIn();
2806
+ const hasStored = opAvailable ? await hasSudoInVault() : false;
2807
+ return sendJson(res, { opAvailable, hasStored });
2808
+ }
2809
+
2810
+ // Auto-unlock path: UI calls this when status.set is false. We read from
2811
+ // vault, validate via sudo -k -S -v, and set the in-memory store on success.
2812
+ // Returns a reason code on failure so the UI can fall through to manual
2813
+ // entry without surfacing op CLI noise.
2814
+ if (pathname === "/api/sudo-password/load-from-vault") {
2815
+ if (req.method !== "POST") {
2816
+ res.writeHead(405);
2817
+ res.end();
2818
+ return;
2819
+ }
2820
+ const password = await readSudoFromVault();
2821
+ if (!password) {
2822
+ return sendJson(res, { ok: false, reason: "not-found" });
2823
+ }
2824
+ try {
2825
+ execSync("sudo -k -S -v", {
2826
+ input: password + "\n",
2827
+ stdio: ["pipe", "pipe", "pipe"],
2828
+ timeout: CLI_SPAWN_TIMEOUT_MS,
2829
+ });
2830
+ } catch {
2831
+ // Stored password no longer matches the system — likely the user
2832
+ // changed their admin password. Leave it in the vault; UI will prompt
2833
+ // and they can choose to overwrite on save.
2834
+ return sendJson(res, { ok: false, reason: "stale" });
2835
+ }
2836
+ setSudoPassword(password);
2837
+ return sendJson(res, { ok: true });
2838
+ }
2839
+
2840
+ // ── API: user settings ────────────────────────────────────────────────────
2841
+ // GET /api/settings → { editor, editors, terminal, terminals, editor_overrides, languages }
2842
+ // POST /api/settings body: { editor?, terminal?, editor_overrides?, graph? }
2843
+ // graph: { direction, group, density, panelWidth } (see graph-prefs.ts)
2844
+ if (pathname === "/api/settings") {
2845
+ if (req.method === "GET") {
2846
+ const user = readUserConfig();
2847
+ const editorList = editors.listEditors();
2848
+ const terminalList = editors.listTerminals();
2849
+ return sendJson(res, {
2850
+ editor: user.editor ?? null,
2851
+ editors: editorList,
2852
+ terminal: user.terminal ?? null,
2853
+ terminals: terminalList,
2854
+ editor_overrides: user.editor_overrides ?? {},
2855
+ languages: SUPPORTED_LANGUAGES,
2856
+ log_window_lines: user.log_window_lines ?? 20_000,
2857
+ graph: graphPrefs(user.graph),
2858
+ });
2859
+ }
2860
+ if (req.method === "POST") {
2861
+ try {
2862
+ const body = JSON.parse(await readBody(req)) as {
2863
+ editor?: string | null;
2864
+ terminal?: string | null;
2865
+ editor_overrides?: Record<string, string> | null;
2866
+ graph?: unknown;
2867
+ };
2868
+ const user = readUserConfig();
2869
+ if (body.graph !== undefined) {
2870
+ const g = mergeGraphPrefs(graphPrefs(user.graph), body.graph);
2871
+ if (!g.ok) return send400(res, g.error);
2872
+ user.graph = g.value;
2873
+ }
2874
+ if (body.editor === null || body.editor === "") {
2875
+ delete user.editor;
2876
+ } else if (body.editor !== undefined) {
2877
+ if (!editors.isKnownEditor(body.editor))
2878
+ return send400(res, `Unknown editor "${body.editor}"`);
2879
+ user.editor = body.editor;
2880
+ }
2881
+ if (body.terminal === null || body.terminal === "") {
2882
+ delete user.terminal;
2883
+ } else if (body.terminal !== undefined) {
2884
+ if (!editors.isKnownTerminal(body.terminal))
2885
+ return send400(res, `Unknown terminal "${body.terminal}"`);
2886
+ user.terminal = body.terminal;
2887
+ }
2888
+ if (body.editor_overrides === null) {
2889
+ delete user.editor_overrides;
2890
+ } else if (body.editor_overrides !== undefined) {
2891
+ const langKeys = new Set<string>(
2892
+ SUPPORTED_LANGUAGES.map((l) => l.key),
2893
+ );
2894
+ const cleaned: Record<string, string> = {};
2895
+ for (const [lang, editorKey] of Object.entries(
2896
+ body.editor_overrides,
2897
+ )) {
2898
+ if (!langKeys.has(lang))
2899
+ return send400(res, `Unknown language "${lang}"`);
2900
+ if (!editorKey) continue; // empty = use default
2901
+ if (!editors.isKnownEditor(editorKey))
2902
+ return send400(res, `Unknown editor "${editorKey}"`);
2903
+ cleaned[lang] = editorKey;
2904
+ }
2905
+ if (Object.keys(cleaned).length > 0) {
2906
+ user.editor_overrides = cleaned;
2907
+ } else {
2908
+ delete user.editor_overrides;
2909
+ }
2910
+ }
2911
+ writeUserConfig(user);
2912
+ return sendJson(res, {
2913
+ ok: true,
2914
+ editor: user.editor ?? null,
2915
+ terminal: user.terminal ?? null,
2916
+ editor_overrides: user.editor_overrides ?? {},
2917
+ graph: graphPrefs(user.graph),
2918
+ });
2919
+ } catch (e) {
2920
+ return send400(res, `Invalid request: ${(e as Error).message}`);
2921
+ }
2922
+ }
2923
+ }
2924
+
2925
+ // ── API: open repo in editor ──────────────────────────────────────────────
2926
+ // POST /api/editor/open body: { path: string }
2927
+ if (pathname === "/api/editor/open" && req.method === "POST") {
2928
+ try {
2929
+ const body = JSON.parse(await readBody(req)) as { path?: string };
2930
+ if (!body.path) return send400(res, '"path" is required');
2931
+
2932
+ // Walk up to the nearest existing ancestor if the path doesn't exist.
2933
+ let openPath = body.path;
2934
+ while (openPath && !fs.existsSync(openPath)) {
2935
+ const parent = path.dirname(openPath);
2936
+ if (parent === openPath) break; // reached filesystem root
2937
+ openPath = parent;
2938
+ }
2939
+
2940
+ const user = readUserConfig();
2941
+ // Resolve editor: per-language > workspace > global default
2942
+ const ws = getActiveWorkspace();
2943
+ const effectiveEditor = editors.resolveEditorForRepo(
2944
+ openPath,
2945
+ user.editor,
2946
+ user.editor_overrides,
2947
+ ws?.config.editor,
2948
+ );
2949
+ if (!effectiveEditor)
2950
+ return sendJson(
2951
+ res,
2952
+ { ok: false, error: "No editor configured. Set one in Settings." },
2953
+ 400,
2954
+ );
2955
+ const cmd = editors.resolveEditorCmd(effectiveEditor);
2956
+ if (!cmd)
2957
+ return sendJson(
2958
+ res,
2959
+ {
2960
+ ok: false,
2961
+ error: `Editor "${effectiveEditor}" not found on this machine.`,
2962
+ },
2963
+ 400,
2964
+ );
2965
+ const safePath = openPath.replace(/'/g, "'\\''");
2966
+ if (editors.isTerminalEditor(effectiveEditor)) {
2967
+ // Write a temporary .command file (Terminal.app executes these on open,
2968
+ // no Automation permission required — unlike osascript do script).
2969
+ const tmp = path.join(
2970
+ os.tmpdir(),
2971
+ `${product().cli}-open-${Date.now()}.command`,
2972
+ );
2973
+ fs.writeFileSync(tmp, `#!/bin/bash\ncd '${safePath}' && '${cmd}' .\n`, {
2974
+ mode: 0o755,
2975
+ });
2976
+ const result = spawnSync("open", [tmp], {
2977
+ timeout: CLI_SPAWN_TIMEOUT_MS,
2978
+ });
2979
+ // Give Terminal a moment to read the file before we remove it.
2980
+ setTimeout(() => {
2981
+ try {
2982
+ fs.unlinkSync(tmp);
2983
+ } catch {
2984
+ /* ignore */
2985
+ }
2986
+ }, 3_000);
2987
+ if (result.status !== 0) {
2988
+ const stderr = result.stderr?.toString().trim() ?? "";
2989
+ return sendJson(
2990
+ res,
2991
+ {
2992
+ ok: false,
2993
+ error: stderr || "Failed to open Terminal.app.",
2994
+ runCmd: `${cmd} ${openPath}`,
2995
+ },
2996
+ 500,
2997
+ );
2998
+ }
2999
+ } else {
3000
+ spawn(cmd, [openPath], { detached: true, stdio: "ignore" }).unref();
3001
+ }
3002
+ return sendJson(res, { ok: true });
3003
+ } catch (e) {
3004
+ return send400(res, `Invalid request: ${(e as Error).message}`);
3005
+ }
3006
+ }
3007
+
3008
+ // ── API: open directory in terminal app ──────────────────────────────────
3009
+ // POST /api/terminal/open body: { path: string }
3010
+ if (pathname === "/api/terminal/open" && req.method === "POST") {
3011
+ try {
3012
+ const body = JSON.parse(await readBody(req)) as { path?: string };
3013
+ if (!body.path) return send400(res, '"path" is required');
3014
+
3015
+ // Walk up to the nearest existing ancestor.
3016
+ let openPath = body.path;
3017
+ while (openPath && !fs.existsSync(openPath)) {
3018
+ const parent = path.dirname(openPath);
3019
+ if (parent === openPath) break;
3020
+ openPath = parent;
3021
+ }
3022
+
3023
+ const user = readUserConfig();
3024
+ if (!user.terminal)
3025
+ return sendJson(
3026
+ res,
3027
+ {
3028
+ ok: false,
3029
+ error: "No terminal app configured. Set one in Settings.",
3030
+ },
3031
+ 400,
3032
+ );
3033
+
3034
+ const appName = editors.resolveTerminalApp(user.terminal);
3035
+ if (!appName)
3036
+ return sendJson(
3037
+ res,
3038
+ {
3039
+ ok: false,
3040
+ error: `Terminal "${user.terminal}" not found on this machine.`,
3041
+ },
3042
+ 400,
3043
+ );
3044
+
3045
+ // Write a temporary .command script that cd's into the directory and
3046
+ // starts an interactive shell. Most macOS terminal apps execute .command
3047
+ // files when opened via `open -a`.
3048
+ const safePath = openPath.replace(/'/g, "'\\''");
3049
+ const tmp = path.join(
3050
+ os.tmpdir(),
3051
+ `${product().cli}-term-${Date.now()}.command`,
3052
+ );
3053
+ fs.writeFileSync(tmp, `#!/bin/bash\ncd '${safePath}' && exec $SHELL\n`, {
3054
+ mode: 0o755,
3055
+ });
3056
+ const result = spawnSync("open", ["-a", appName, tmp], {
3057
+ timeout: 5_000,
3058
+ });
3059
+ setTimeout(() => {
3060
+ try {
3061
+ fs.unlinkSync(tmp);
3062
+ } catch {
3063
+ /* ignore */
3064
+ }
3065
+ }, 3_000);
3066
+
3067
+ if (result.status !== 0) {
3068
+ const stderr = result.stderr?.toString().trim() ?? "";
3069
+ return sendJson(
3070
+ res,
3071
+ {
3072
+ ok: false,
3073
+ error: stderr || `Failed to open ${appName}.`,
3074
+ runCmd: `cd '${safePath}'`,
3075
+ },
3076
+ 500,
3077
+ );
3078
+ }
3079
+ return sendJson(res, { ok: true });
3080
+ } catch (e) {
3081
+ return send400(res, `Invalid request: ${(e as Error).message}`);
3082
+ }
3083
+ }
3084
+
3085
+ // ── 404 ────────────────────────────────────────────────────────────────────
3086
+ res.writeHead(404, { "Content-Type": "text/plain" });
3087
+ res.end("Not found");
3088
+ }
3089
+
3090
+ const server = http.createServer((req, res) => {
3091
+ // An async handler with no catch turns any route throw into an unhandled
3092
+ // rejection — which CRASHES the whole server on modern Node (freezing every
3093
+ // health dot) and leaves the request hanging. Fail the one request instead.
3094
+ handleRequest(req, res).catch((err) => {
3095
+ console.error(`[server] ${req.method} ${req.url} failed:`, err);
3096
+ try {
3097
+ if (!res.headersSent) {
3098
+ res.writeHead(500, { "Content-Type": "text/plain" });
3099
+ }
3100
+ res.end("internal server error");
3101
+ } catch {
3102
+ /* socket already gone */
3103
+ }
3104
+ });
3105
+ });
3106
+
3107
+ server.on("error", (err: NodeJS.ErrnoException) => {
3108
+ if (err.code === "EADDRINUSE") {
3109
+ console.error(
3110
+ `\n ✗ Port ${PORT} is already in use.\n` +
3111
+ `\n Another ${product().cli} instance may be running. To fix:\n` +
3112
+ `\n lsof -ti :${PORT} | xargs kill # kill whatever is on the port` +
3113
+ `\n make web # then restart\n`,
3114
+ );
3115
+ process.exit(1);
3116
+ }
3117
+ throw err;
3118
+ });
3119
+
3120
+ // Restore daemon registry from the previous session, then drop any whose opctl
3121
+ // containers are no longer running. Stale entries — from a prior session, or a
3122
+ // service stopped outside den — otherwise reopen as tabs in the web UI and
3123
+ // clutter the shutdown prompt. Container liveness (not health) is the signal: a
3124
+ // service's shared-infra health check stays green even when the service is down.
3125
+ // Runs synchronously at startup so /api/service/daemons never hands the UI a
3126
+ // stale entry on its first load.
3127
+ loadDaemonsFromFile();
3128
+ const prunedDaemons = pruneDeadDaemons();
3129
+ if (prunedDaemons.length > 0) {
3130
+ console.error(
3131
+ `Cleared ${prunedDaemons.length} stale daemon(s): ${prunedDaemons.join(", ")}`,
3132
+ );
3133
+ }
3134
+
3135
+ // Background recovery: after a short delay (long enough for services to be
3136
+ // responding), health-check each loaded daemon. Removal requires strong
3137
+ // evidence — a missing service, or two consecutive "error" health checks
3138
+ // (a single transient timeout/5xx never unregisters a possibly-running
3139
+ // daemon). See daemon-reaper.ts; behavior pinned by its unit tests.
3140
+ void (async () => {
3141
+ await new Promise<void>((resolve) =>
3142
+ setTimeout(resolve, DAEMON_SHUTDOWN_GRACE_MS),
3143
+ );
3144
+ await reapDeadDaemons({
3145
+ listDaemons,
3146
+ resolveService,
3147
+ getActiveWorkspaces,
3148
+ checkServiceHealth: (service) => checkServiceHealth(service),
3149
+ unregisterDaemon,
3150
+ delay: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
3151
+ retryDelayMs: DAEMON_RETRY_DELAY_MS,
3152
+ });
3153
+ })();
3154
+
3155
+ const prunedSnapshots = pruneDeadSnapshots();
3156
+ if (prunedSnapshots > 0) {
3157
+ serverLog(`Pruned ${prunedSnapshots} orphaned log-history snapshot(s)`);
3158
+ }
3159
+
3160
+ // Cap service logs. Long-lived daemons never respawn (a localstack tail reached
3161
+ // 8.6 GB), so the spawn-time check alone is not enough — sweep on startup and
3162
+ // periodically. unref'd so it never holds the process open.
3163
+ function sweepServiceLogs(): void {
3164
+ try {
3165
+ const rotated = sweepLogs(getServiceLogRoot());
3166
+ if (rotated.length > 0) {
3167
+ serverLog(
3168
+ `Rotated ${rotated.length} oversized log(s): ${rotated
3169
+ .map((p) => path.basename(path.dirname(p)) + "/" + path.basename(p))
3170
+ .join(", ")}`,
3171
+ );
3172
+ }
3173
+ } catch (e) {
3174
+ serverLog(`Log sweep failed: ${(e as Error).message}`);
3175
+ }
3176
+ }
3177
+ sweepServiceLogs();
3178
+ setInterval(sweepServiceLogs, LOG_SWEEP_INTERVAL_MS).unref();
3179
+
3180
+ // Last-resort guards: an uncaught throw in an async callback (spawn handlers,
3181
+ // timers — code the per-request catch can't see) would kill the whole
3182
+ // dashboard; a TDZ bug in a down-flow callback did exactly that. For a local
3183
+ // dev server, staying alive degraded beats dying — log loudly and continue.
3184
+ process.on("uncaughtException", (err) => {
3185
+ console.error("[server] uncaught exception (continuing):", err);
3186
+ });
3187
+ process.on("unhandledRejection", (reason) => {
3188
+ console.error("[server] unhandled rejection (continuing):", reason);
3189
+ });
3190
+
3191
+ // Bind loopback only — this UI runs privileged local actions (sudo-backed
3192
+ // service commands), so it must not be reachable from the LAN. Opt out with
3193
+ // DEN_BIND_HOST=0.0.0.0 for deliberate exposure.
3194
+ const BIND_HOST = process.env.DEN_BIND_HOST ?? "127.0.0.1";
3195
+ server.listen(PORT, BIND_HOST, () => {
3196
+ const hosts = fs.existsSync("/etc/hosts")
3197
+ ? fs.readFileSync("/etc/hosts", "utf-8")
3198
+ : "";
3199
+ const host = hosts.includes(`127.0.0.1 ${product().cli}`)
3200
+ ? `${product().cli}`
3201
+ : "localhost";
3202
+
3203
+ const pkgVersion = engineVersion() ?? "?";
3204
+
3205
+ console.log();
3206
+ for (const line of DEN_BANNER_LINES) {
3207
+ console.log(`\x1b[36m${line}\x1b[0m`); // cyan without chalk dependency
3208
+ }
3209
+ console.log();
3210
+ console.log(` ${DEN_SUBTITLE.trim()}`);
3211
+ console.log(` v${pkgVersion}`);
3212
+ console.log();
3213
+ console.log(` Web UI → http://${host}:${PORT}`);
3214
+ console.log();
3215
+ });
3216
+
3217
+ // ── Graceful shutdown ─────────────────────────────────────────────────────────
3218
+
3219
+ async function stopDaemons(): Promise<void> {
3220
+ const daemons = listDaemons();
3221
+ if (daemons.length === 0) return;
3222
+
3223
+ console.log("\nStopping services…");
3224
+
3225
+ const isTTY = process.stdout.isTTY;
3226
+ const total = daemons.length;
3227
+
3228
+ if (isTTY) {
3229
+ // Print every service line up front, then update in-place using ANSI cursor movement.
3230
+ for (const d of daemons) {
3231
+ process.stdout.write(` ⏳ ${d.address}\n`);
3232
+ }
3233
+ // Cursor is now one row below the last service line.
3234
+
3235
+ const updateLine = (idx: number, mark: string, suffix: string): void => {
3236
+ const movesUp = total - idx; // rows to climb to reach this service line
3237
+ process.stdout.write(
3238
+ `\x1b[${movesUp}A` + // move cursor up to the service's line
3239
+ `\r\x1b[2K` + // clear the line
3240
+ ` ${mark} ${daemons[idx].address}${suffix}` +
3241
+ `\x1b[${movesUp}E`, // move cursor back to beginning of original row
3242
+ );
3243
+ };
3244
+
3245
+ await Promise.allSettled(
3246
+ daemons.map(
3247
+ (d, i) =>
3248
+ new Promise<void>((resolve) => {
3249
+ runServiceCommand({
3250
+ address: d.address,
3251
+ command: "down",
3252
+ onLine: () => {},
3253
+ onClose: (exitCode) => {
3254
+ updateLine(
3255
+ i,
3256
+ exitCode === 0 ? "✓" : "✗",
3257
+ exitCode !== 0 ? ` (exit ${exitCode})` : "",
3258
+ );
3259
+ resolve();
3260
+ },
3261
+ });
3262
+ }),
3263
+ ),
3264
+ );
3265
+ process.stdout.write("\n");
3266
+ } else {
3267
+ // Non-TTY: simple line-per-service output, no cursor tricks.
3268
+ await Promise.allSettled(
3269
+ daemons.map(
3270
+ (d) =>
3271
+ new Promise<void>((resolve) => {
3272
+ process.stdout.write(` ↓ ${d.address}\n`);
3273
+ runServiceCommand({
3274
+ address: d.address,
3275
+ command: "down",
3276
+ onLine: () => {},
3277
+ onClose: (exitCode) => {
3278
+ process.stdout.write(
3279
+ ` ${exitCode === 0 ? "✓" : "✗"} ${d.address}\n`,
3280
+ );
3281
+ resolve();
3282
+ },
3283
+ });
3284
+ }),
3285
+ ),
3286
+ );
3287
+ }
3288
+
3289
+ console.log("Services stopped.");
3290
+ }
3291
+
3292
+ async function handleShutdown(signal: string): Promise<void> {
3293
+ clearSudoPassword();
3294
+ const daemons = listDaemons();
3295
+ if (daemons.length === 0) {
3296
+ process.exit(0);
3297
+ }
3298
+
3299
+ const isTTY = process.stdin.isTTY;
3300
+
3301
+ if (!isTTY) {
3302
+ // Non-interactive shutdown usually means `den web` is being restarted.
3303
+ // Do not tear down service daemons just because the UI process is cycling.
3304
+ console.log(
3305
+ `\n${product().title} web received ${signal}; leaving ${daemons.length} running service${daemons.length === 1 ? "" : "s"} alive.`,
3306
+ );
3307
+ process.exit(0);
3308
+ }
3309
+
3310
+ // Interactive terminal: ask the user.
3311
+ console.log(`\nRunning services (started by ${product().cli}):`);
3312
+ for (const d of daemons) {
3313
+ const age = Math.round((Date.now() - d.startedAt.getTime()) / 1000);
3314
+ console.log(` • ${d.address} (up ${age}s)`);
3315
+ }
3316
+
3317
+ process.stdout.write("\nStop these services before exiting? [Y/n] ");
3318
+
3319
+ // Restore normal terminal input for readline.
3320
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
3321
+ process.stdin.resume();
3322
+
3323
+ await new Promise<void>((resolve) => {
3324
+ process.stdin.once("data", async (chunk) => {
3325
+ const answer = chunk.toString().trim().toLowerCase();
3326
+ if (answer === "" || answer === "y" || answer === "yes") {
3327
+ await stopDaemons();
3328
+ } else {
3329
+ console.log("Leaving services running.");
3330
+ }
3331
+ resolve();
3332
+ });
3333
+ });
3334
+
3335
+ process.exit(0);
3336
+ }
3337
+
3338
+ let _shuttingDown = false;
3339
+ process.on("SIGINT", () => {
3340
+ if (_shuttingDown) return;
3341
+ _shuttingDown = true;
3342
+ console.log(""); // newline after ^C
3343
+ void handleShutdown("SIGINT");
3344
+ });
3345
+
3346
+ process.on("SIGTERM", () => {
3347
+ if (_shuttingDown) return;
3348
+ _shuttingDown = true;
3349
+ void handleShutdown("SIGTERM");
3350
+ });