@monster-orange/den 0.0.0-stage → 0.1.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 (231) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -2
  4. package/bin/den +61 -0
  5. package/docs/distributions.md +256 -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 +104 -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 +406 -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 +108 -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 +729 -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/applications.ts +138 -0
  91. package/src/shared/auth/api-token.ts +416 -0
  92. package/src/shared/auth/auth0-helper.ts +284 -0
  93. package/src/shared/auth/headless-login.ts +265 -0
  94. package/src/shared/auth/jwt-helper.ts +186 -0
  95. package/src/shared/auth/sudo-vault.ts +69 -0
  96. package/src/shared/auth/test-accounts.ts +98 -0
  97. package/src/shared/auth/token-injection.ts +184 -0
  98. package/src/shared/auth/token-providers/auth0.ts +80 -0
  99. package/src/shared/auth/token-providers/index.ts +182 -0
  100. package/src/shared/auth/token-refresh.ts +273 -0
  101. package/src/shared/banner.ts +15 -0
  102. package/src/shared/brand.ts +79 -0
  103. package/src/shared/cli-tabs.ts +131 -0
  104. package/src/shared/constants.ts +128 -0
  105. package/src/shared/den-config.ts +566 -0
  106. package/src/shared/dev-metrics/emit.ts +354 -0
  107. package/src/shared/edge/config.ts +229 -0
  108. package/src/shared/edge/render.ts +221 -0
  109. package/src/shared/edge/runtime.ts +268 -0
  110. package/src/shared/edge/types.ts +62 -0
  111. package/src/shared/engine.ts +67 -0
  112. package/src/shared/environment/aws-profiles.ts +49 -0
  113. package/src/shared/environment/env-selector.ts +161 -0
  114. package/src/shared/environment/playwright-env.ts +82 -0
  115. package/src/shared/environment-manager/catalog.ts +77 -0
  116. package/src/shared/environment-manager/clone-guard.ts +163 -0
  117. package/src/shared/environment-manager/dag.ts +286 -0
  118. package/src/shared/environment-manager/default-workspace.ts +182 -0
  119. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  120. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  121. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  122. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  123. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  124. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  125. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  126. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  127. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  128. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  129. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  130. package/src/shared/environment-manager/derivation/text.ts +40 -0
  131. package/src/shared/environment-manager/derivation/types.ts +69 -0
  132. package/src/shared/environment-manager/health.ts +589 -0
  133. package/src/shared/environment-manager/index.ts +6 -0
  134. package/src/shared/environment-manager/load-applications.ts +78 -0
  135. package/src/shared/environment-manager/log-tail.ts +212 -0
  136. package/src/shared/environment-manager/make-targets.ts +78 -0
  137. package/src/shared/environment-manager/opctl.ts +334 -0
  138. package/src/shared/environment-manager/readiness.ts +93 -0
  139. package/src/shared/environment-manager/register-local.ts +72 -0
  140. package/src/shared/environment-manager/secrets-check.ts +260 -0
  141. package/src/shared/environment-manager/service-env.ts +110 -0
  142. package/src/shared/environment-manager/service-runner.ts +1588 -0
  143. package/src/shared/environment-manager/up-plan.ts +302 -0
  144. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  145. package/src/shared/environment-manager/workspaces.ts +860 -0
  146. package/src/shared/manifest/deps.ts +27 -0
  147. package/src/shared/manifest/migrate-manifest.ts +101 -0
  148. package/src/shared/manifest/read-manifest.ts +160 -0
  149. package/src/shared/manifest/schema.ts +299 -0
  150. package/src/shared/manifest/service-links.ts +93 -0
  151. package/src/shared/manifest/validate-manifest.ts +140 -0
  152. package/src/shared/manifest/workspace-manifests.ts +92 -0
  153. package/src/shared/open-url.ts +122 -0
  154. package/src/shared/org.ts +44 -0
  155. package/src/shared/paths.ts +178 -0
  156. package/src/shared/perf/optional-fields.ts +105 -0
  157. package/src/shared/perf/report-normalizer.ts +404 -0
  158. package/src/shared/perf/report-paths.ts +19 -0
  159. package/src/shared/perf/report-store.ts +110 -0
  160. package/src/shared/perf/report-types.ts +70 -0
  161. package/src/shared/perf/test-paths.ts +25 -0
  162. package/src/shared/process/log-rotation.ts +147 -0
  163. package/src/shared/process/spawn-streaming.ts +229 -0
  164. package/src/shared/product.ts +92 -0
  165. package/src/shared/profile.ts +141 -0
  166. package/src/shared/repo-kind.ts +32 -0
  167. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  168. package/src/shared/reporting/slack-reporter.ts +340 -0
  169. package/src/shared/results/result-reader.ts +266 -0
  170. package/src/shared/results/result-writer.ts +168 -0
  171. package/src/shared/secrets/doppler.ts +126 -0
  172. package/src/shared/secrets/env.ts +48 -0
  173. package/src/shared/secrets/index.ts +197 -0
  174. package/src/shared/secrets/keys.ts +33 -0
  175. package/src/shared/secrets/onepassword.ts +75 -0
  176. package/src/shared/secrets/types.ts +52 -0
  177. package/src/shared/service-url.ts +119 -0
  178. package/src/shared/smoke/branch-resolver.ts +141 -0
  179. package/src/shared/smoke/git-remote.ts +70 -0
  180. package/src/shared/smoke/repo-cloner.ts +151 -0
  181. package/src/shared/stacks/config.ts +137 -0
  182. package/src/shared/stacks/manifest-commands.ts +88 -0
  183. package/src/shared/stacks/render.ts +253 -0
  184. package/src/shared/stacks/runtime.ts +334 -0
  185. package/src/shared/stacks/types.ts +53 -0
  186. package/src/shared/terminal/tabs.ts +212 -0
  187. package/src/shared/test-discovery/module-scanner.ts +569 -0
  188. package/src/shared/types/index.ts +678 -0
  189. package/src/shared/ui-marker.ts +19 -0
  190. package/src/ui/cli/applications-command.ts +116 -0
  191. package/src/ui/cli/config-command.ts +68 -0
  192. package/src/ui/cli/create-main.ts +9 -0
  193. package/src/ui/cli/create.ts +360 -0
  194. package/src/ui/cli/edge-command.ts +91 -0
  195. package/src/ui/cli/flag-parser.ts +246 -0
  196. package/src/ui/cli/graph-command.ts +524 -0
  197. package/src/ui/cli/health-display.ts +63 -0
  198. package/src/ui/cli/index.ts +3656 -0
  199. package/src/ui/cli/init.ts +855 -0
  200. package/src/ui/cli/invocation-target.ts +175 -0
  201. package/src/ui/cli/logs-command.ts +104 -0
  202. package/src/ui/cli/module-selection.ts +31 -0
  203. package/src/ui/cli/prompts.ts +767 -0
  204. package/src/ui/cli/results-viewer.ts +436 -0
  205. package/src/ui/cli/secrets-preflight.ts +91 -0
  206. package/src/ui/cli/stack-command.ts +144 -0
  207. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  208. package/src/ui/cli/test-runner.ts +1312 -0
  209. package/src/ui/cli/web-command.ts +1073 -0
  210. package/src/ui/server/changelog-parser.ts +94 -0
  211. package/src/ui/server/daemon-reaper.ts +129 -0
  212. package/src/ui/server/editor-manager.ts +301 -0
  213. package/src/ui/server/github-service.ts +223 -0
  214. package/src/ui/server/http-helpers.ts +98 -0
  215. package/src/ui/server/index.ts +3236 -0
  216. package/src/ui/server/log-history.ts +452 -0
  217. package/src/ui/server/perf-report.ts +42 -0
  218. package/src/ui/server/public/environments.html +494 -0
  219. package/src/ui/server/public/favicon.ico +0 -0
  220. package/src/ui/server/public/header.js +1371 -0
  221. package/src/ui/server/public/index.html +2595 -0
  222. package/src/ui/server/public/local-dev.html +7405 -0
  223. package/src/ui/server/public/logs.html +1489 -0
  224. package/src/ui/server/public/perf-report.js +359 -0
  225. package/src/ui/server/public/release.html +2111 -0
  226. package/src/ui/server/public/services.html +1098 -0
  227. package/src/ui/server/public/workspace-picker.js +69 -0
  228. package/src/ui/server/release-deploy-session.ts +246 -0
  229. package/src/ui/server/release-service.ts +1125 -0
  230. package/src/ui/server/sudo-store.ts +111 -0
  231. package/src/ui/server/workspace-routes.ts +2154 -0
@@ -0,0 +1,1098 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>__DEN_TITLE__ · Services</title>
7
+ <style>
8
+ *,
9
+ *::before,
10
+ *::after {
11
+ box-sizing: border-box;
12
+ margin: 0;
13
+ padding: 0;
14
+ }
15
+
16
+ :root {
17
+ --bg: #0d1117;
18
+ --surface: #161b22;
19
+ --border: #30363d;
20
+ --text: #c9d1d9;
21
+ --muted: #8b949e;
22
+ --accent: #58a6ff;
23
+ --green: #3fb950;
24
+ --red: #f85149;
25
+ --yellow: #d29922;
26
+ --mono: "SF Mono", "Cascadia Code", "Fira Code", "Menlo", monospace;
27
+ }
28
+
29
+ html,
30
+ body {
31
+ height: 100%;
32
+ background: var(--bg);
33
+ color: var(--text);
34
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
35
+ font-size: 13px;
36
+ }
37
+
38
+ body {
39
+ display: flex;
40
+ flex-direction: column;
41
+ overflow: hidden;
42
+ }
43
+
44
+ /* ── Header ───────────────────────────────────────────────────────────── */
45
+ header {
46
+ display: flex;
47
+ align-items: center;
48
+ gap: 10px;
49
+ padding: 7px 14px;
50
+ background: var(--surface);
51
+ border-bottom: 1px solid var(--border);
52
+ flex-shrink: 0;
53
+ }
54
+ header h1 {
55
+ font-family: var(--mono);
56
+ font-size: 14px;
57
+ font-weight: 600;
58
+ letter-spacing: -0.02em;
59
+ }
60
+ .h-spacer {
61
+ flex: 1;
62
+ }
63
+
64
+ /* ── Health indicators ────────────────────────────────────────────────── */
65
+ .health {
66
+ display: flex;
67
+ align-items: center;
68
+ gap: 10px;
69
+ padding: 0 4px;
70
+ }
71
+ .health-item {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 5px;
75
+ font-size: 11px;
76
+ color: var(--muted);
77
+ }
78
+ .health-group {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 4px;
82
+ font-size: 11px;
83
+ color: var(--muted);
84
+ }
85
+ .health-group-label {
86
+ color: var(--muted);
87
+ margin-right: 1px;
88
+ }
89
+ .health-group-dots {
90
+ display: flex;
91
+ align-items: center;
92
+ gap: 3px;
93
+ }
94
+
95
+ /* ── Nav ──────────────────────────────────────────────────────────────── */
96
+ nav {
97
+ display: flex;
98
+ gap: 2px;
99
+ }
100
+ nav a {
101
+ color: var(--muted);
102
+ text-decoration: none;
103
+ padding: 3px 10px;
104
+ border-radius: 4px;
105
+ font-size: 12px;
106
+ font-weight: 500;
107
+ transition: color 0.1s;
108
+ }
109
+ nav a:hover {
110
+ color: var(--text);
111
+ background: var(--border);
112
+ }
113
+ nav a.active {
114
+ color: var(--text);
115
+ background: var(--surface);
116
+ }
117
+
118
+ /* ── Shared: dot ──────────────────────────────────────────────────────── */
119
+ .dot {
120
+ width: 7px;
121
+ height: 7px;
122
+ border-radius: 50%;
123
+ background: var(--muted);
124
+ flex-shrink: 0;
125
+ display: inline-block;
126
+ }
127
+ .dot.ok {
128
+ background: var(--green);
129
+ }
130
+ .dot.err {
131
+ background: var(--red);
132
+ }
133
+ .dot.running {
134
+ background: var(--yellow);
135
+ animation: pulse 1s ease-in-out infinite;
136
+ }
137
+ @keyframes pulse {
138
+ 0%,
139
+ 100% {
140
+ opacity: 1;
141
+ }
142
+ 50% {
143
+ opacity: 0.35;
144
+ }
145
+ }
146
+
147
+ /* ── Controls bar ─────────────────────────────────────────────────────── */
148
+ .controls {
149
+ display: flex;
150
+ align-items: center;
151
+ gap: 8px;
152
+ padding: 6px 14px;
153
+ background: var(--surface);
154
+ border-bottom: 1px solid var(--border);
155
+ flex-shrink: 0;
156
+ }
157
+ select {
158
+ background: var(--bg);
159
+ border: 1px solid var(--border);
160
+ color: var(--text);
161
+ padding: 4px 8px;
162
+ border-radius: 6px;
163
+ font-size: 12px;
164
+ cursor: pointer;
165
+ outline: none;
166
+ }
167
+ select:focus {
168
+ border-color: var(--accent);
169
+ }
170
+
171
+ /* ── Main ─────────────────────────────────────────────────────────────── */
172
+ main {
173
+ flex: 1;
174
+ display: flex;
175
+ flex-direction: column;
176
+ overflow: hidden;
177
+ min-height: 0;
178
+ }
179
+
180
+ /* ── Service cards ────────────────────────────────────────────────────── */
181
+ .services-pane {
182
+ padding: 12px 14px;
183
+ flex-shrink: 0;
184
+ display: flex;
185
+ flex-direction: column;
186
+ gap: 14px;
187
+ }
188
+ /* One section per application (Org > Application > Repo > Services). */
189
+ .app-section-head {
190
+ display: flex;
191
+ align-items: baseline;
192
+ gap: 8px;
193
+ flex-wrap: wrap;
194
+ margin-bottom: 8px;
195
+ padding-bottom: 4px;
196
+ border-bottom: 1px solid var(--border);
197
+ }
198
+ .app-section-title {
199
+ font-size: 13px;
200
+ font-weight: 600;
201
+ color: var(--text);
202
+ }
203
+ .app-section-owner {
204
+ font-size: 11px;
205
+ color: var(--text);
206
+ border: 1px solid var(--border);
207
+ border-radius: 10px;
208
+ padding: 0 8px;
209
+ }
210
+ .app-section-desc {
211
+ font-size: 11px;
212
+ color: var(--muted);
213
+ }
214
+ .app-cards {
215
+ display: flex;
216
+ flex-wrap: wrap;
217
+ gap: 10px;
218
+ }
219
+
220
+ /* Systems and sub-services share one shape — solid vs dashed with a
221
+ coloured left rule — so the grid reads as one hierarchy rather than two
222
+ unrelated card styles. Systems take the app's accent blue; sub-services
223
+ are muted and dashed, so a scan lands on systems first. */
224
+ .service-card {
225
+ background: var(--surface);
226
+ border: 1px solid var(--border);
227
+ border-left: 3px solid var(--accent);
228
+ border-radius: 8px;
229
+ padding: 12px 14px;
230
+ min-width: 220px;
231
+ }
232
+ .service-card.subservice {
233
+ border-style: dashed;
234
+ border-color: #3d444d;
235
+ /* Left rule stays solid on both — a 3px dashed vertical rule renders as
236
+ scattered dots. The dashed outer border carries the distinction. */
237
+ border-left: 3px solid var(--muted);
238
+ }
239
+ .service-card.subservice .service-name {
240
+ color: var(--muted);
241
+ }
242
+ .service-card-header {
243
+ display: flex;
244
+ align-items: center;
245
+ gap: 8px;
246
+ margin-bottom: 10px;
247
+ }
248
+ .service-card-header .dot {
249
+ width: 8px;
250
+ height: 8px;
251
+ }
252
+ .service-name {
253
+ font-size: 12px;
254
+ font-weight: 600;
255
+ color: var(--text);
256
+ }
257
+ .service-repo {
258
+ font-size: 11px;
259
+ color: var(--muted);
260
+ }
261
+ /* Site link: the service's URL for the selected env, from the health
262
+ broadcast (openUrl). Hidden until health supplies one. */
263
+ .site-link {
264
+ display: inline-flex;
265
+ align-items: center;
266
+ gap: 4px;
267
+ margin-top: 3px;
268
+ font-size: 11px;
269
+ font-family: var(--mono);
270
+ color: var(--accent);
271
+ text-decoration: none;
272
+ word-break: break-all;
273
+ }
274
+ .site-link:hover,
275
+ .site-link:focus-visible {
276
+ text-decoration: underline;
277
+ }
278
+ .site-link[hidden] {
279
+ display: none;
280
+ }
281
+ .service-meta {
282
+ display: flex;
283
+ gap: 6px;
284
+ margin-bottom: 8px;
285
+ flex-wrap: wrap;
286
+ }
287
+ .badge {
288
+ font-size: 10px;
289
+ padding: 1px 6px;
290
+ border-radius: 10px;
291
+ border: 1px solid var(--border);
292
+ color: var(--muted);
293
+ }
294
+ .badge.dev-managed {
295
+ border-color: #388bfd44;
296
+ color: #58a6ff;
297
+ }
298
+ .badge.managed {
299
+ border-color: #a371f744;
300
+ color: #a371f7;
301
+ }
302
+ .badge.always-on {
303
+ border-color: #3fb95044;
304
+ color: #3fb950;
305
+ }
306
+ .badge.branch {
307
+ border-color: var(--border);
308
+ font-family: var(--mono);
309
+ }
310
+ .cmd-row {
311
+ display: flex;
312
+ flex-wrap: wrap;
313
+ gap: 6px;
314
+ }
315
+ .cmd-btn {
316
+ background: var(--bg);
317
+ border: 1px solid var(--border);
318
+ color: var(--text);
319
+ padding: 4px 10px;
320
+ border-radius: 5px;
321
+ font-size: 11px;
322
+ font-weight: 500;
323
+ cursor: pointer;
324
+ transition:
325
+ border-color 0.1s,
326
+ color 0.1s;
327
+ }
328
+ .cmd-btn:hover:not(:disabled) {
329
+ border-color: var(--accent);
330
+ color: var(--accent);
331
+ }
332
+ .cmd-btn:disabled {
333
+ opacity: 0.45;
334
+ cursor: not-allowed;
335
+ }
336
+ .cmd-btn.logs-btn {
337
+ border-color: var(--muted);
338
+ }
339
+ .cmd-btn.logs-btn:hover:not(:disabled) {
340
+ border-color: var(--yellow);
341
+ color: var(--yellow);
342
+ }
343
+
344
+ .empty-state {
345
+ padding: 40px 14px;
346
+ color: var(--muted);
347
+ font-size: 12px;
348
+ text-align: center;
349
+ }
350
+
351
+ /* ── Pane bar ─────────────────────────────────────────────────────────── */
352
+ .pane-bar {
353
+ display: flex;
354
+ align-items: center;
355
+ justify-content: space-between;
356
+ padding: 3px 12px;
357
+ background: var(--surface);
358
+ border-bottom: 1px solid var(--border);
359
+ flex-shrink: 0;
360
+ }
361
+ .pane-bar span {
362
+ font-size: 10px;
363
+ font-weight: 600;
364
+ text-transform: uppercase;
365
+ letter-spacing: 0.07em;
366
+ color: var(--muted);
367
+ }
368
+ .pane-bar button {
369
+ background: none;
370
+ border: none;
371
+ color: var(--muted);
372
+ font-size: 11px;
373
+ cursor: pointer;
374
+ padding: 2px 6px;
375
+ border-radius: 3px;
376
+ }
377
+ .pane-bar button:hover {
378
+ color: var(--text);
379
+ background: var(--border);
380
+ }
381
+
382
+ /* ── Output pane ──────────────────────────────────────────────────────── */
383
+ .output-pane {
384
+ flex: 1;
385
+ display: flex;
386
+ flex-direction: column;
387
+ min-height: 0;
388
+ }
389
+ #output {
390
+ flex: 1;
391
+ overflow-y: auto;
392
+ padding: 10px 14px;
393
+ font-family: var(--mono);
394
+ font-size: 11.5px;
395
+ line-height: 1.65;
396
+ white-space: pre-wrap;
397
+ word-break: break-all;
398
+ min-height: 0;
399
+ }
400
+ #output .cmd {
401
+ color: var(--accent);
402
+ }
403
+ #output .err {
404
+ color: var(--red);
405
+ }
406
+ #output .done-ok {
407
+ color: var(--green);
408
+ }
409
+ #output .done-fail {
410
+ color: var(--red);
411
+ }
412
+ </style>
413
+ </head>
414
+ <body>
415
+ <header>
416
+ <img
417
+ src="/favicon.ico"
418
+ width="16"
419
+ height="16"
420
+ alt=""
421
+ style="flex-shrink: 0"
422
+ />
423
+ <h1>__DEN_CLI__</h1>
424
+ <button
425
+ class="v-pill"
426
+ id="version-pill"
427
+ onclick="openChangelog()"
428
+ title="View changelog"
429
+ >
430
+ <span class="v-pip" id="v-pip"></span><span class="v-txt">v…</span>
431
+ </button>
432
+ <div class="health" id="health" title="Service health"></div>
433
+ <span
434
+ id="ws-pill"
435
+ style="
436
+ font-size: 11px;
437
+ font-family: var(--mono);
438
+ padding: 2px 8px;
439
+ border-radius: 10px;
440
+ border: 1px solid #388bfd44;
441
+ color: #58a6ff;
442
+ background: #58a6ff10;
443
+ display: none;
444
+ "
445
+ ></span>
446
+ <div class="h-spacer"></div>
447
+ <nav>
448
+ <a href="/">Local Dev</a>
449
+ <a href="/services" class="active">Services</a>
450
+ <a href="/environments">Environments</a>
451
+ <a href="/release">Releases</a>
452
+ <a href="/logs"
453
+ >Logs
454
+ <span
455
+ id="log-badge"
456
+ style="
457
+ background: var(--yellow);
458
+ color: #0d1117;
459
+ font-size: 10px;
460
+ font-weight: 700;
461
+ padding: 1px 5px;
462
+ border-radius: 8px;
463
+ line-height: 1.4;
464
+ display: none;
465
+ "
466
+ ></span
467
+ ></a>
468
+ <a href="/tests">Tests</a>
469
+ </nav>
470
+ </header>
471
+
472
+ <div class="controls">
473
+ <select id="env-sel" title="Environment">
474
+ <option value="local">local</option>
475
+ </select>
476
+ <span style="font-size: 11px; color: var(--muted)">workspace:</span>
477
+ <select
478
+ id="workspace-sel"
479
+ title="Active workspace"
480
+ style="font-family: var(--mono)"
481
+ >
482
+ <option value="default">default</option>
483
+ </select>
484
+ <span id="status-text" style="font-size: 11px; color: var(--muted)"
485
+ >idle</span
486
+ >
487
+ </div>
488
+
489
+ <main>
490
+ <div class="services-pane" id="services-pane">
491
+ <div class="empty-state">Loading services…</div>
492
+ </div>
493
+
494
+ <div class="pane-bar">
495
+ <span>Output</span>
496
+ <button onclick="clearOutput()">Clear</button>
497
+ </div>
498
+
499
+ <div class="output-pane">
500
+ <pre id="output"></pre>
501
+ </div>
502
+ </main>
503
+
504
+ <script type="module">
505
+ import {
506
+ initHealth,
507
+ refreshHealth,
508
+ initWorkspacePill,
509
+ applyDenConfig,
510
+ initLogBadge,
511
+ initChangelog,
512
+ computePrimaryByRepo,
513
+ } from "/header.js";
514
+
515
+ let es = null;
516
+
517
+ // Release any open SSE socket on reload / navigation / bfcache-enter so the
518
+ // reloaded page isn't starved of a connection by a lingering stream.
519
+ window.addEventListener("pagehide", () => {
520
+ try {
521
+ es?.close();
522
+ } catch {}
523
+ es = null;
524
+ });
525
+
526
+ // ── Init ──────────────────────────────────────────────────────────────────
527
+ async function init() {
528
+ const envs = await fetch("/api/environments")
529
+ .then((r) => r.json())
530
+ .catch(() => ["local"]);
531
+ const envSel = document.getElementById("env-sel");
532
+ envSel.innerHTML = envs
533
+ .map((e) => `<option value="${e}">${e}</option>`)
534
+ .join("");
535
+ // '__DEN_CLI__-env' matches index.html/logs.html.
536
+ const savedEnv = localStorage.getItem("__DEN_CLI__-env");
537
+ if (savedEnv && envs.includes(savedEnv)) envSel.value = savedEnv;
538
+
539
+ await Promise.all([
540
+ loadWorkspaces(),
541
+ loadServices(),
542
+ initHealth(),
543
+ initWorkspacePill("env-sel"),
544
+ applyDenConfig(),
545
+ initChangelog(),
546
+ ]);
547
+ initLogBadge();
548
+ }
549
+
550
+ document.getElementById("env-sel").addEventListener("change", () => {
551
+ const env = document.getElementById("env-sel").value;
552
+ localStorage.setItem("__DEN_CLI__-env", env);
553
+ loadServices();
554
+ // Restart the header health poll for the new env — without this the dots
555
+ // kept polling the page-load env forever (index.html/logs.html do this too).
556
+ refreshHealth(env);
557
+ });
558
+
559
+ document
560
+ .getElementById("workspace-sel")
561
+ .addEventListener("change", async () => {
562
+ const name = document.getElementById("workspace-sel").value;
563
+ await fetch("/api/workspaces", {
564
+ method: "POST",
565
+ headers: { "Content-Type": "application/json" },
566
+ body: JSON.stringify({ name }),
567
+ });
568
+ await loadServices();
569
+ });
570
+
571
+ // ── Load workspaces ───────────────────────────────────────────────────────
572
+ async function loadWorkspaces() {
573
+ try {
574
+ const data = await fetch("/api/workspaces").then((r) => r.json());
575
+ const sel = document.getElementById("workspace-sel");
576
+ const names = Object.keys(data.workspaces ?? {});
577
+ const focus = Array.isArray(data.active)
578
+ ? (data.focus ?? data.active[0])
579
+ : data.active;
580
+ sel.innerHTML = names
581
+ .map(
582
+ (n) =>
583
+ `<option value="${n}" ${n === focus ? "selected" : ""}>${n}</option>`,
584
+ )
585
+ .join("");
586
+ } catch {
587
+ /* ignore — default workspace shown */
588
+ }
589
+ }
590
+
591
+ // ── Workspace panel ───────────────────────────────────────────────────────
592
+ window.toggleWsPanel = function () {
593
+ const panel = document.getElementById("ws-panel");
594
+ const open = panel.classList.toggle("open");
595
+ if (open) refreshWsPanel();
596
+ };
597
+
598
+ async function refreshWsPanel() {
599
+ const data = await fetch("/api/workspaces")
600
+ .then((r) => r.json())
601
+ .catch(() => null);
602
+ if (!data) return;
603
+
604
+ const focus = Array.isArray(data.active)
605
+ ? (data.focus ?? data.active[0])
606
+ : data.active;
607
+ // Update active label
608
+ document.getElementById("ws-active-label").textContent = Array.isArray(
609
+ data.active,
610
+ )
611
+ ? data.active.join(", ")
612
+ : (data.active ?? "");
613
+
614
+ // Current services list
615
+ const services = Object.entries(
616
+ data.workspaces?.[focus]?.services ?? {},
617
+ );
618
+ const listEl = document.getElementById("ws-service-list");
619
+ if (services.length === 0) {
620
+ listEl.innerHTML = '<span class="ws-msg">No services yet.</span>';
621
+ } else {
622
+ listEl.innerHTML = services
623
+ .map(([addr, entry]) => {
624
+ const meta =
625
+ entry.mode === "dev-managed"
626
+ ? `<span style="color:var(--muted);font-size:11px">dev-managed · ${entry.path ?? ""}</span>`
627
+ : `<span style="color:var(--muted);font-size:11px">managed · ${entry.branch ?? "main"}</span>`;
628
+ return `<div class="ws-service-row">
629
+ <span>${addr}</span>${meta}
630
+ <button title="Remove" onclick="removeService('${addr}')">×</button>
631
+ </div>`;
632
+ })
633
+ .join("");
634
+ }
635
+
636
+ // Available repos dropdown
637
+ const repos = await fetch("/api/workspaces/available-repos")
638
+ .then((r) => r.json())
639
+ .catch(() => []);
640
+ const repoSel = document.getElementById("ws-repo-sel");
641
+ repoSel.innerHTML =
642
+ '<option value="">Select repo…</option>' +
643
+ repos
644
+ .map((r) => {
645
+ const label = r.hasManifest ? `${r.name} ✓` : r.name;
646
+ const disabled = r.alreadyAdded ? " disabled" : "";
647
+ const suffix = r.alreadyAdded ? " (added)" : "";
648
+ return `<option value="${r.name}" data-path="${r.absPath}"${disabled}>${label}${suffix}</option>`;
649
+ })
650
+ .join("");
651
+ }
652
+
653
+ window.createWorkspace = async function () {
654
+ const name = document.getElementById("ws-name-input").value.trim();
655
+ const msg = document.getElementById("ws-create-msg");
656
+ if (!name) {
657
+ msg.textContent = "Name required";
658
+ msg.className = "ws-msg err";
659
+ return;
660
+ }
661
+ const res = await fetch("/api/workspaces/create", {
662
+ method: "POST",
663
+ headers: { "Content-Type": "application/json" },
664
+ body: JSON.stringify({ name }),
665
+ });
666
+ if (res.ok) {
667
+ msg.textContent = `✓ Created "${name}"`;
668
+ msg.className = "ws-msg ok";
669
+ document.getElementById("ws-name-input").value = "";
670
+ await loadWorkspaces();
671
+ await refreshWsPanel();
672
+ } else {
673
+ msg.textContent = await res.text();
674
+ msg.className = "ws-msg err";
675
+ }
676
+ };
677
+
678
+ window.deleteWorkspace = async function () {
679
+ const name = document.getElementById("workspace-sel").value;
680
+ const msg = document.getElementById("ws-delete-msg");
681
+ if (name === "default") {
682
+ msg.textContent = "Cannot delete default";
683
+ msg.className = "ws-msg err";
684
+ return;
685
+ }
686
+ if (!confirm(`Delete workspace "${name}"?`)) return;
687
+ const res = await fetch("/api/workspaces", {
688
+ method: "DELETE",
689
+ headers: { "Content-Type": "application/json" },
690
+ body: JSON.stringify({ name }),
691
+ });
692
+ if (res.ok) {
693
+ msg.textContent = `✓ Deleted "${name}"`;
694
+ msg.className = "ws-msg ok";
695
+ await loadWorkspaces();
696
+ await Promise.all([loadServices(), refreshWsPanel()]);
697
+ } else {
698
+ msg.textContent = await res.text();
699
+ msg.className = "ws-msg err";
700
+ }
701
+ };
702
+
703
+ window.addService = async function () {
704
+ const repoSel = document.getElementById("ws-repo-sel");
705
+ const mode = document.getElementById("ws-mode-sel").value;
706
+ const msg = document.getElementById("ws-add-msg");
707
+ const repoName = repoSel.value;
708
+ if (!repoName) {
709
+ msg.textContent = "Select a repo";
710
+ msg.className = "ws-msg err";
711
+ return;
712
+ }
713
+ const opt = repoSel.options[repoSel.selectedIndex];
714
+ const linkedPath = opt.dataset.path;
715
+ const res = await fetch("/api/workspaces/services", {
716
+ method: "POST",
717
+ headers: { "Content-Type": "application/json" },
718
+ body: JSON.stringify({ address: repoName, mode, linkedPath }),
719
+ });
720
+ if (res.ok) {
721
+ msg.textContent = `✓ Added ${repoName}`;
722
+ msg.className = "ws-msg ok";
723
+ await Promise.all([loadServices(), refreshWsPanel()]);
724
+ } else {
725
+ msg.textContent = await res.text();
726
+ msg.className = "ws-msg err";
727
+ }
728
+ };
729
+
730
+ window.removeService = async function (address) {
731
+ const msg = document.getElementById("ws-add-msg");
732
+ const res = await fetch("/api/workspaces/services", {
733
+ method: "DELETE",
734
+ headers: { "Content-Type": "application/json" },
735
+ body: JSON.stringify({ address }),
736
+ });
737
+ if (res.ok) {
738
+ await Promise.all([loadServices(), refreshWsPanel()]);
739
+ } else {
740
+ msg.textContent = await res.text();
741
+ msg.className = "ws-msg err";
742
+ }
743
+ };
744
+
745
+ // ── Load services ─────────────────────────────────────────────────────────
746
+ async function loadServices() {
747
+ const env = document.getElementById("env-sel").value;
748
+ const pane = document.getElementById("services-pane");
749
+ pane.innerHTML = '<div class="empty-state">Loading…</div>';
750
+
751
+ let services;
752
+ let apps = [];
753
+ try {
754
+ const [svcRes, appRes] = await Promise.all([
755
+ fetch(`/api/services?env=${env}`),
756
+ fetch("/api/applications").catch(() => null),
757
+ ]);
758
+ services = await svcRes.json();
759
+ if (appRes?.ok) apps = (await appRes.json()).applications ?? [];
760
+ } catch {
761
+ pane.innerHTML =
762
+ '<div class="empty-state">Failed to load services</div>';
763
+ return;
764
+ }
765
+
766
+ if (!services.length) {
767
+ pane.innerHTML = `<div class="empty-state">No services registered.<br><code>__DEN_CLI__ register &lt;path&gt;</code> to add a repo.</div>`;
768
+ return;
769
+ }
770
+
771
+ // A "system" is a repo's primary service (entrypoint > root-named >
772
+ // first seen) — the same rollup the health dots, log tabs and logs
773
+ // toolbar use, so "one thing per system" means the same thing here.
774
+ // Everything else is a sub-service.
775
+ const primaryByRepo = computePrimaryByRepo(services);
776
+
777
+ // The shared-infra repo's own sub-services (`graph.infra.repo`; the
778
+ // server marks them `infra`) are the shared infra that repo IS. They
779
+ // carry no usable actions of their own — their only command was a
780
+ // lifecycle one, already dropped for sub-services — so the cards were
781
+ // pure noise beside the system card that already represents them.
782
+ // Sub-services of OTHER repos are kept: several own real work
783
+ // (shop/db's migrate, notes-api/db's lint) that exists nowhere else.
784
+ const visible = services.filter(
785
+ (s) =>
786
+ !s.infra ||
787
+ primaryByRepo.get(s.repo) ===
788
+ (s.address ?? `${s.repo}/${s.service}`),
789
+ );
790
+
791
+ // Top-down dependency order — entry repo (shop-web) first, infra-stack
792
+ // last — matching the health row, the dep graph and `__DEN_CLI__ status`.
793
+ // graphRank comes from the server, which owns deriveGraph. Sub-services
794
+ // sort under their own system rather than floating to the end.
795
+ visible.sort(
796
+ (a, b) =>
797
+ (a.graphRank ?? Number.MAX_SAFE_INTEGER) -
798
+ (b.graphRank ?? Number.MAX_SAFE_INTEGER) ||
799
+ a.repo.localeCompare(b.repo) ||
800
+ (primaryByRepo.get(a.repo) ===
801
+ (a.address ?? `${a.repo}/${a.service}`)
802
+ ? -1
803
+ : 0) -
804
+ (primaryByRepo.get(b.repo) ===
805
+ (b.address ?? `${b.repo}/${b.service}`)
806
+ ? -1
807
+ : 0) ||
808
+ a.service.localeCompare(b.service),
809
+ );
810
+
811
+ // Lifecycle commands on a sub-service are a lie: the resource is owned by
812
+ // its system or by shared infra-stack infra (files-api/db, for one,
813
+ // is a handle onto shared localstack — `up` there does nothing useful).
814
+ // Domain commands are NOT hidden: shop/db's migrate/seed/rollback and
815
+ // notes-api/db's migrate/lint are real work you can only do here.
816
+ const LIFECYCLE_CMDS = new Set(["up", "down", "restart"]);
817
+
818
+ const cardHtml = (s) => {
819
+ const addr = s.address ?? `${s.repo}/${s.service}`;
820
+ const isSub = primaryByRepo.get(s.repo) !== addr;
821
+ const usableCmds = isSub
822
+ ? s.commands.filter((cmd) => !LIFECYCLE_CMDS.has(cmd))
823
+ : s.commands;
824
+ const cmdButtons = usableCmds
825
+ .map(
826
+ (cmd) =>
827
+ `<button class="cmd-btn" data-repo="${s.repo}" data-service="${s.service}" data-cmd="${cmd}" data-type="exec">${cmd}</button>`,
828
+ )
829
+ .join("");
830
+ const logsButton = s.logs
831
+ ? `<button class="cmd-btn logs-btn" data-repo="${s.repo}" data-service="${s.service}" data-type="logs">logs</button>`
832
+ : "";
833
+ const hasCaps = usableCmds.length > 0 || s.logs;
834
+ const emptyNote =
835
+ s.cloned === false
836
+ ? "not cloned yet — run Sync in Local Dev"
837
+ : isSub
838
+ ? "managed by its system"
839
+ : "no commands for this env";
840
+ const modeBadge = s.mode
841
+ ? `<span class="badge ${s.mode}">${s.mode}</span>`
842
+ : "";
843
+ const unclonedBadge =
844
+ s.cloned === false
845
+ ? `<span class="badge" style="border-color:var(--yellow);color:var(--yellow)" title="Not cloned yet">⬇ not cloned</span>`
846
+ : "";
847
+ const branchBadge = s.branch
848
+ ? `<span class="badge branch">${s.branch}</span>`
849
+ : "";
850
+ return `
851
+ <div class="service-card${isSub ? " subservice" : ""}">
852
+ <div class="service-card-header">
853
+ <span class="dot" id="dot-${s.repo}-${s.service}"></span>
854
+ <div>
855
+ <div class="service-name">${addr}</div>
856
+ <div class="service-repo">${s.repo}</div>
857
+ <a class="site-link" id="site-${s.repo}-${s.service}" target="_blank" rel="noopener" hidden></a>
858
+ </div>
859
+ </div>
860
+ <div class="service-meta">${modeBadge}${branchBadge}${unclonedBadge}</div>
861
+ <div class="cmd-row">
862
+ ${hasCaps ? cmdButtons + logsButton : `<span style="color:var(--muted);font-size:11px">${emptyNote}</span>`}
863
+ </div>
864
+ </div>`;
865
+ };
866
+
867
+ // Section the cards by application, keeping the order above within
868
+ // each. Repos that are their own application share one section; any
869
+ // card no application claims goes last.
870
+ const esc = (t) =>
871
+ String(t ?? "").replace(
872
+ /[&<>"]/g,
873
+ (c) =>
874
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
875
+ );
876
+ const appOf = new Map();
877
+ for (const a of apps) for (const r of a.repos) appOf.set(r, a);
878
+ const sections = [];
879
+ for (const a of apps.filter((x) => x.source === "declared")) {
880
+ const cards = visible.filter((s) => appOf.get(s.repo) === a);
881
+ if (cards.length) sections.push({ app: a, cards });
882
+ }
883
+ const standalone = visible.filter(
884
+ (s) => appOf.get(s.repo)?.source === "repo",
885
+ );
886
+ if (standalone.length)
887
+ sections.push({
888
+ app: {
889
+ title: "Single-repo applications",
890
+ description: "Each repo is its own application.",
891
+ },
892
+ cards: standalone,
893
+ });
894
+ const rest = visible.filter((s) => !appOf.has(s.repo));
895
+ if (rest.length)
896
+ sections.push({
897
+ app: apps.length
898
+ ? {
899
+ title: "Not in an application",
900
+ description: "Registered, but not in this workspace.",
901
+ }
902
+ : null,
903
+ cards: rest,
904
+ });
905
+ pane.innerHTML = sections
906
+ .map(
907
+ ({ app, cards }) => `
908
+ <section class="app-section"${app?.name ? ` data-app="${esc(app.name)}"` : ""}>
909
+ ${
910
+ app
911
+ ? `<div class="app-section-head">
912
+ <span class="app-section-title">${esc(app.title)}</span>
913
+ ${app.owner ? `<span class="app-section-owner" title="Owner">${esc(app.owner)}</span>` : ""}
914
+ ${app.description ? `<span class="app-section-desc">${esc(app.description)}</span>` : ""}
915
+ </div>`
916
+ : ""
917
+ }
918
+ <div class="app-cards">${cards.map(cardHtml).join("")}</div>
919
+ </section>`,
920
+ )
921
+ .join("");
922
+
923
+ // Attach click handlers
924
+ pane.querySelectorAll(".cmd-btn").forEach((btn) => {
925
+ btn.addEventListener("click", () => {
926
+ const { repo, service, cmd, type } = btn.dataset;
927
+ if (type === "exec") runExec(repo, service, cmd, btn);
928
+ else runLogs(repo, service, btn);
929
+ });
930
+ });
931
+
932
+ // Re-render destroys the dot elements, and the poll may have already
933
+ // fired before this ran — repaint from the last broadcast so the cards
934
+ // aren't blank until the next tick.
935
+ applyHealthToCards(_lastHealth);
936
+ }
937
+
938
+ // ── Run command ───────────────────────────────────────────────────────────
939
+ function runExec(repo, service, cmd, btn) {
940
+ if (es) {
941
+ es.close();
942
+ es = null;
943
+ }
944
+ const env = document.getElementById("env-sel").value;
945
+
946
+ disableAllButtons();
947
+ setDot(repo, service, "running");
948
+ setStatus(`running ${service}:${cmd}…`);
949
+ appendLine(`\n▶ ${repo}/${service} · ${cmd} · ${env}\n`, "cmd");
950
+
951
+ const params = new URLSearchParams({ repo, service, cmd, env });
952
+ es = new EventSource(`/api/service/exec?${params}`);
953
+ handleSSE(es, repo, service);
954
+ }
955
+
956
+ function runLogs(repo, service, btn) {
957
+ if (es) {
958
+ es.close();
959
+ es = null;
960
+ }
961
+ const env = document.getElementById("env-sel").value;
962
+
963
+ // A log tail is a continuous stream, not a command. It never emits
964
+ // `done`, so disabling the buttons here left every button on the page
965
+ // dead — cursor and all — until a manual reload. Tailing also says
966
+ // nothing about health, so the dot is left to the health poll rather
967
+ // than being forced to "running". Starting any other action closes this
968
+ // stream (both runners close `es` first).
969
+ setStatus(`streaming ${service} logs — start any command to stop`);
970
+ appendLine(`\n▶ ${repo}/${service} logs · ${env}\n`, "cmd");
971
+
972
+ const params = new URLSearchParams({ repo, service, env });
973
+ es = new EventSource(`/api/service/logs?${params}`);
974
+ handleSSE(es, repo, service, { stream: true });
975
+ }
976
+
977
+ function handleSSE(evtSrc, repo, service, opts = {}) {
978
+ // Streams (log tails) never disabled the buttons and never own the dot,
979
+ // so they must not re-enable or repaint either.
980
+ const isStream = opts.stream === true;
981
+ evtSrc.onmessage = (e) => {
982
+ const msg = JSON.parse(e.data);
983
+ if (msg.type === "cmd") {
984
+ appendLine(msg.text, "cmd");
985
+ } else if (msg.type === "line") {
986
+ appendLine(msg.text, "");
987
+ } else if (msg.type === "error") {
988
+ appendLine(msg.text, "err");
989
+ } else if (msg.type === "done") {
990
+ const ok = msg.exitCode === 0;
991
+ if (msg.duration > 0) {
992
+ appendLine(
993
+ `\n${ok ? "✓ Done" : "✗ Failed"} in ${fmtDuration(msg.duration)}\n`,
994
+ ok ? "done-ok" : "done-fail",
995
+ );
996
+ }
997
+ if (!isStream) {
998
+ setDot(repo, service, ok ? "ok" : "err");
999
+ enableAllButtons();
1000
+ }
1001
+ setStatus("idle");
1002
+ evtSrc.close();
1003
+ es = null;
1004
+ }
1005
+ };
1006
+ evtSrc.onerror = () => {
1007
+ appendLine("Connection closed", "err");
1008
+ // A tail ending is not a health signal — leave the dot to the poll.
1009
+ if (!isStream) {
1010
+ setDot(repo, service, "err");
1011
+ enableAllButtons();
1012
+ }
1013
+ setStatus("idle");
1014
+ evtSrc?.close();
1015
+ es = null;
1016
+ };
1017
+ }
1018
+
1019
+ // ── Output ────────────────────────────────────────────────────────────────
1020
+ function appendLine(text, cls) {
1021
+ const out = document.getElementById("output");
1022
+ const span = document.createElement("span");
1023
+ if (cls) span.className = cls;
1024
+ span.textContent = text + "\n";
1025
+ out.appendChild(span);
1026
+ out.scrollTop = out.scrollHeight;
1027
+ }
1028
+ function clearOutput() {
1029
+ document.getElementById("output").innerHTML = "";
1030
+ }
1031
+
1032
+ // ── Status / dots ─────────────────────────────────────────────────────────
1033
+ function setStatus(text) {
1034
+ document.getElementById("status-text").textContent = text;
1035
+ }
1036
+ function setDot(repo, service, state) {
1037
+ const el = document.getElementById(`dot-${repo}-${service}`);
1038
+ if (el) el.className = `dot ${state}`;
1039
+ }
1040
+
1041
+ // ── Card health ───────────────────────────────────────────────────────────
1042
+ // Card dots follow the header's existing poll instead of running their own:
1043
+ // header.js broadcasts every poll and manual refresh as
1044
+ // `__DEN_CLI__:health-update`, the same feed local-dev's graph subscribes to.
1045
+ // Nothing had ever subscribed here, so a card dot was only written by a
1046
+ // command's outcome — meaning it showed a stale exit status, or stayed
1047
+ // muted forever on a page where you'd run nothing. That looked like the
1048
+ // header and the cards disagreeing about health.
1049
+ let _lastHealth = [];
1050
+ function applyHealthToCards(results) {
1051
+ for (const h of results || []) {
1052
+ // ok === null means "no health check declared". Leave it muted rather
1053
+ // than painting it red, which would claim the service is down.
1054
+ const state = h.ok === true ? "ok" : h.ok === false ? "err" : "";
1055
+ const el = document.getElementById(`dot-${h.repo}-${h.service}`);
1056
+ if (el) el.className = `dot${state ? " " + state : ""}`;
1057
+ const link = document.getElementById(`site-${h.repo}-${h.service}`);
1058
+ if (link) {
1059
+ if (h.openUrl) {
1060
+ link.href = h.openUrl;
1061
+ link.textContent = `${h.openUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")} ↗`;
1062
+ link.title = `Open ${h.openUrl} in a new tab`;
1063
+ link.hidden = false;
1064
+ } else {
1065
+ link.hidden = true;
1066
+ }
1067
+ }
1068
+ }
1069
+ }
1070
+ document.addEventListener("__DEN_CLI__:health-update", (e) => {
1071
+ _lastHealth = e.detail || [];
1072
+ applyHealthToCards(_lastHealth);
1073
+ });
1074
+
1075
+ // ── Button helpers ────────────────────────────────────────────────────────
1076
+ function disableAllButtons() {
1077
+ document
1078
+ .querySelectorAll(".cmd-btn")
1079
+ .forEach((b) => (b.disabled = true));
1080
+ }
1081
+ function enableAllButtons() {
1082
+ document
1083
+ .querySelectorAll(".cmd-btn")
1084
+ .forEach((b) => (b.disabled = false));
1085
+ }
1086
+
1087
+ // ── Helpers ───────────────────────────────────────────────────────────────
1088
+ function fmtDuration(ms) {
1089
+ if (ms < 1000) return `${ms}ms`;
1090
+ const s = Math.floor(ms / 1000),
1091
+ m = Math.floor(s / 60);
1092
+ return m > 0 ? `${m}m ${s % 60}s` : `${s}s`;
1093
+ }
1094
+
1095
+ init();
1096
+ </script>
1097
+ </body>
1098
+ </html>