@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,1371 @@
1
+ /**
2
+ * Shared header logic — service health dots
3
+ *
4
+ * Both index.html and services.html include this script.
5
+ * It expects:
6
+ * - a <div id="health"> in the header to render dots into
7
+ * - a <select id="env-sel"> whose change event triggers a re-ping
8
+ *
9
+ * Health data is driven entirely by __DEN_CLI__.json manifests — no hardcoded
10
+ * service names. Each registered service that declares a health.url for
11
+ * the current env gets a dot.
12
+ */
13
+
14
+ import {
15
+ buildWorkspaceMenuModel,
16
+ buildWorkspacePillModel,
17
+ } from "./workspace-picker.js";
18
+
19
+ // 5s between polls — measured from each poll's COMPLETION (schedulePoll
20
+ // re-arms after renderHealthDots resolves), so slow probes can't overlap.
21
+ const POLL_INTERVAL_MS = 5_000;
22
+
23
+ // Last painted health results, keyed `repo/service`, so a new poll can seed
24
+ // from them and update dots in place instead of collapsing the strip and
25
+ // regrowing it (which shifted the whole page below — see renderHealthDots).
26
+ let _healthAcc = new Map();
27
+ let _healthAccGen = -1;
28
+ let pollTimer = null;
29
+ // Generation token: every (re)start of polling bumps this. A stale tick (an
30
+ // env the user already switched away from, or a slow fetch resolving late)
31
+ // must neither paint the DOM nor re-arm the loop for its outdated env —
32
+ // without this, one in-flight poll during an env switch permanently hijacked
33
+ // the dots back to the old env.
34
+ let pollGen = 0;
35
+
36
+ // ── Editor preference (cached) ─────────────────────────────────────────────────
37
+ let _editorKey = null;
38
+ let _editorKeyFetched = false;
39
+ async function _loadEditorKey() {
40
+ if (_editorKeyFetched) return;
41
+ _editorKeyFetched = true;
42
+ try {
43
+ const data = await fetch("/api/settings").then((r) => r.json());
44
+ _editorKey = data.editor ?? null;
45
+ } catch {
46
+ /* settings unavailable */
47
+ }
48
+ }
49
+
50
+ function _injectNavStyles() {
51
+ if (document.getElementById("__DEN_CLI__-nav-styles")) return;
52
+ const s = document.createElement("style");
53
+ s.id = "__DEN_CLI__-nav-styles";
54
+ s.textContent = `
55
+ .nav-open-btn {
56
+ background: none; border: none; color: var(--muted, #8b949e);
57
+ cursor: pointer; font-size: 11px; padding: 0 3px; line-height: 1;
58
+ opacity: 0; transition: opacity .15s, color .1s;
59
+ flex-shrink: 0;
60
+ }
61
+ .health-item:hover .nav-open-btn,
62
+ .health-group:hover .nav-open-btn { opacity: 1; }
63
+ .nav-open-btn:hover { color: var(--accent, #58a6ff) !important; }
64
+ /* Repo dots boxed by application (Org > Application > Repo). */
65
+ .health-app {
66
+ display: flex; align-items: center; gap: 8px;
67
+ padding: 1px 8px 1px 6px; border: 1px solid var(--border, #30363d);
68
+ border-radius: 10px;
69
+ }
70
+ .health-app-label {
71
+ font-size: 10px; font-weight: 600; letter-spacing: .03em;
72
+ color: var(--text, #c9d1d9); opacity: .7; white-space: nowrap;
73
+ }
74
+ `;
75
+ document.head.appendChild(s);
76
+ }
77
+
78
+ // ── Changelog modal ────────────────────────────────────────────────────────────
79
+
80
+ const LS_CHANGELOG_SEEN = "__DEN_CLI__-changelog-seen";
81
+ let _changelogEntries = [];
82
+ let _changelogInjected = false;
83
+
84
+ function _injectChangelogStyles() {
85
+ if (_changelogInjected) return;
86
+ _changelogInjected = true;
87
+
88
+ const style = document.createElement("style");
89
+ style.textContent = `
90
+ .v-pill {
91
+ font-family: var(--mono, monospace); font-size: 10px;
92
+ padding: 2px 8px; border-radius: 10px;
93
+ border: 1px solid var(--border, #30363d); color: var(--muted, #8b949e);
94
+ background: none; cursor: pointer; user-select: none;
95
+ transition: color .1s, border-color .1s; position: relative;
96
+ flex-shrink: 0;
97
+ }
98
+ .v-pill:hover { color: var(--accent, #58a6ff); border-color: var(--accent, #58a6ff); }
99
+ .v-pip {
100
+ position: absolute; top: -3px; right: -3px;
101
+ width: 7px; height: 7px; border-radius: 50%;
102
+ background: var(--accent, #58a6ff);
103
+ border: 1.5px solid var(--bg, #0d1117);
104
+ display: none; pointer-events: none;
105
+ }
106
+ .cl-overlay {
107
+ position: fixed; inset: 0; z-index: 1000;
108
+ background: rgba(0,0,0,.65);
109
+ display: none; align-items: center; justify-content: center;
110
+ }
111
+ .cl-overlay.open { display: flex; }
112
+ .cl-box {
113
+ background: var(--surface, #161b22);
114
+ border: 1px solid var(--border, #30363d);
115
+ border-radius: 10px; width: min(580px, 92vw); max-height: 78vh;
116
+ display: flex; flex-direction: column; overflow: hidden;
117
+ box-shadow: 0 8px 40px rgba(0,0,0,.65);
118
+ }
119
+ .cl-hdr {
120
+ display: flex; align-items: center; gap: 10px;
121
+ padding: 13px 18px; border-bottom: 1px solid var(--border, #30363d); flex-shrink: 0;
122
+ }
123
+ .cl-hdr h3 {
124
+ font-family: var(--mono, monospace); font-size: 13px; font-weight: 600;
125
+ color: var(--text, #c9d1d9); flex: 1; margin: 0;
126
+ }
127
+ .cl-close {
128
+ background: none; border: none; color: var(--muted, #8b949e);
129
+ font-size: 18px; cursor: pointer; line-height: 1;
130
+ padding: 2px 5px; border-radius: 3px; transition: color .1s, background .1s;
131
+ }
132
+ .cl-close:hover { color: var(--text, #c9d1d9); background: var(--border, #30363d); }
133
+ .cl-body { overflow-y: auto; padding: 16px 18px; flex: 1; }
134
+ .cl-entry { margin-bottom: 18px; }
135
+ .cl-ver-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; }
136
+ .cl-ver {
137
+ font-family: var(--mono, monospace); font-size: 13px; font-weight: 700;
138
+ color: var(--accent, #58a6ff);
139
+ }
140
+ .cl-date { font-size: 11px; color: var(--muted, #8b949e); }
141
+ .cl-note { font-size: 12px; color: var(--muted, #8b949e); margin: 0 0 8px; font-style: italic; }
142
+ .cl-sec {
143
+ font-size: 10px; font-weight: 600; text-transform: uppercase;
144
+ letter-spacing: .07em; color: var(--muted, #8b949e); margin: 8px 0 4px;
145
+ }
146
+ .cl-items { list-style: none; padding: 0; margin: 0; }
147
+ .cl-items li {
148
+ font-size: 12px; color: var(--text, #c9d1d9);
149
+ padding: 2px 0 2px 14px; position: relative; line-height: 1.5;
150
+ }
151
+ .cl-items li::before { content: "·"; position: absolute; left: 2px; color: var(--muted, #8b949e); }
152
+ .cl-subitems { list-style: none; padding: 0; margin: 2px 0 0 0; }
153
+ .cl-subitems li { font-size: 11.5px; padding: 1px 0 1px 14px; color: var(--muted, #8b949e); }
154
+ .cl-subitems li::before { content: "–"; position: absolute; left: 2px; }
155
+ .cl-items code {
156
+ font-family: var(--mono, monospace); font-size: 10.5px;
157
+ color: var(--accent, #58a6ff); background: rgba(88,166,255,.1);
158
+ padding: 1px 4px; border-radius: 3px;
159
+ }
160
+ .cl-divider { border: none; border-top: 1px solid var(--border, #30363d); margin: 14px 0; }
161
+ .cl-copy-group { display: flex; gap: 4px; margin-left: auto; flex-shrink: 0; }
162
+ .cl-copy-btn, .cl-slack-btn {
163
+ background: none; border: 1px solid var(--border, #30363d); color: var(--muted, #8b949e);
164
+ padding: 3px 6px; border-radius: 4px; line-height: 0;
165
+ cursor: pointer; flex-shrink: 0;
166
+ display: inline-flex; align-items: center; justify-content: center;
167
+ transition: color .1s, border-color .1s;
168
+ }
169
+ .cl-copy-btn:hover { color: var(--accent, #58a6ff); border-color: var(--accent, #58a6ff); }
170
+ .cl-copy-btn.copied { color: #3fb950; border-color: #3fb950; }
171
+ .cl-slack-btn:hover { color: #e01e5a; border-color: #e01e5a; }
172
+ .cl-slack-btn.copied { color: #3fb950; border-color: #3fb950; }
173
+ .cl-footer {
174
+ padding: 10px 18px; border-top: 1px solid var(--border, #30363d);
175
+ display: flex; justify-content: flex-end; flex-shrink: 0;
176
+ background: var(--surface, #161b22);
177
+ }
178
+ .cl-got-it {
179
+ background: var(--accent, #58a6ff); border: none; color: #000;
180
+ padding: 5px 16px; border-radius: 5px; font-size: 12px;
181
+ font-weight: 600; cursor: pointer;
182
+ }
183
+ .cl-got-it:hover { opacity: .85; }
184
+ `;
185
+ document.head.appendChild(style);
186
+
187
+ // Modal overlay
188
+ const overlay = document.createElement("div");
189
+ overlay.id = "cl-overlay";
190
+ overlay.className = "cl-overlay";
191
+ overlay.innerHTML =
192
+ '<div class="cl-box">' +
193
+ '<div class="cl-hdr"><h3>What\'s new</h3><button class="cl-close" id="cl-close-btn" title="Close">×</button></div>' +
194
+ '<div class="cl-body" id="cl-body"></div>' +
195
+ '<div class="cl-footer"><button class="cl-got-it" id="cl-got-it-btn">Got it</button></div>' +
196
+ "</div>";
197
+ document.body.appendChild(overlay);
198
+
199
+ overlay.addEventListener("click", (e) => {
200
+ if (e.target === overlay) window.closeChangelog();
201
+ });
202
+ document
203
+ .getElementById("cl-close-btn")
204
+ ?.addEventListener("click", () => window.closeChangelog());
205
+ document
206
+ .getElementById("cl-got-it-btn")
207
+ ?.addEventListener("click", () => window.closeChangelog());
208
+
209
+ document.addEventListener("keydown", (e) => {
210
+ if (e.key === "Escape") window.closeChangelog();
211
+ });
212
+ }
213
+
214
+ function _renderChangelogBody(entries) {
215
+ const body = document.getElementById("cl-body");
216
+ if (!body) return;
217
+ if (!entries.length) {
218
+ body.innerHTML =
219
+ '<p style="color:var(--muted,#8b949e);font-size:12px">No entries found.</p>';
220
+ return;
221
+ }
222
+ const escHtml = (s) =>
223
+ String(s)
224
+ .replace(/&/g, "&amp;")
225
+ .replace(/</g, "&lt;")
226
+ .replace(/>/g, "&gt;");
227
+ const renderItem = (s) =>
228
+ escHtml(s)
229
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
230
+ .replace(/`([^`]+)`/g, "<code>$1</code>");
231
+ body.innerHTML = entries
232
+ .map(
233
+ (entry, i) =>
234
+ (i > 0 ? '<hr class="cl-divider">' : "") +
235
+ '<div class="cl-entry">' +
236
+ '<div class="cl-ver-row">' +
237
+ '<span class="cl-ver">v' +
238
+ escHtml(entry.version) +
239
+ "</span>" +
240
+ '<span class="cl-date">' +
241
+ escHtml(entry.date) +
242
+ "</span>" +
243
+ '<div class="cl-copy-group">' +
244
+ '<button class="cl-copy-btn" data-cl-version="' +
245
+ escHtml(entry.version) +
246
+ '" title="Copy as Markdown">' +
247
+ '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
248
+ '<rect x="4" y="0.5" width="8.5" height="8.5" rx="1.5"/>' +
249
+ '<path d="M8.5 9v2.5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h2.5"/>' +
250
+ "</svg>" +
251
+ "</button>" +
252
+ '<button class="cl-slack-btn" data-cl-version="' +
253
+ escHtml(entry.version) +
254
+ '" title="Copy for Slack">' +
255
+ '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
256
+ '<path d="M1 2a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H7.5L5 12v-3H2a1 1 0 0 1-1-1V2z"/>' +
257
+ "</svg>" +
258
+ "</button>" +
259
+ "</div>" +
260
+ "</div>" +
261
+ (entry.note
262
+ ? '<p class="cl-note">' + renderItem(entry.note) + "</p>"
263
+ : "") +
264
+ Object.entries(entry.sections ?? {})
265
+ .map(
266
+ ([sec, items]) =>
267
+ '<div class="cl-sec">' +
268
+ escHtml(sec) +
269
+ "</div>" +
270
+ '<ul class="cl-items">' +
271
+ items
272
+ .map(
273
+ (it) =>
274
+ "<li>" +
275
+ renderItem(it.text) +
276
+ (it.sub?.length
277
+ ? '<ul class="cl-subitems">' +
278
+ it.sub
279
+ .map((s) => "<li>" + renderItem(s) + "</li>")
280
+ .join("") +
281
+ "</ul>"
282
+ : "") +
283
+ "</li>",
284
+ )
285
+ .join("") +
286
+ "</ul>",
287
+ )
288
+ .join("") +
289
+ "</div>",
290
+ )
291
+ .join("");
292
+
293
+ const _slackIcon =
294
+ '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
295
+ '<path d="M1 2a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H7.5L5 12v-3H2a1 1 0 0 1-1-1V2z"/>' +
296
+ "</svg>";
297
+ const _copyIcon =
298
+ '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
299
+ '<rect x="4" y="0.5" width="8.5" height="8.5" rx="1.5"/>' +
300
+ '<path d="M8.5 9v2.5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h2.5"/>' +
301
+ "</svg>";
302
+ const _checkIcon =
303
+ '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
304
+ '<polyline points="2,7 5,10 11,3"/>' +
305
+ "</svg>";
306
+
307
+ // Markdown copy — plain text for GitHub, MD editors, etc.
308
+ body.querySelectorAll(".cl-copy-btn").forEach((btn) => {
309
+ btn.addEventListener("click", async () => {
310
+ const entry = _changelogEntries.find(
311
+ (e) => e.version === btn.dataset.clVersion,
312
+ );
313
+ if (!entry) return;
314
+ try {
315
+ await copyToClipboard(entry.raw || _buildMarkdown(entry));
316
+ btn.innerHTML = _checkIcon;
317
+ btn.classList.add("copied");
318
+ setTimeout(() => {
319
+ btn.innerHTML = _copyIcon;
320
+ btn.classList.remove("copied");
321
+ }, 2000);
322
+ } catch {
323
+ btn.title = "Copy failed — try again";
324
+ setTimeout(() => (btn.title = "Copy as Markdown"), 2000);
325
+ }
326
+ });
327
+ });
328
+
329
+ // Rich copy — contenteditable + execCommand produces DOM-originated HTML that
330
+ // Slack (and Notion, Docs) trusts and renders as formatted text.
331
+ body.querySelectorAll(".cl-slack-btn").forEach((btn) => {
332
+ btn.addEventListener("click", async () => {
333
+ const entry = _changelogEntries.find(
334
+ (e) => e.version === btn.dataset.clVersion,
335
+ );
336
+ if (!entry) return;
337
+ try {
338
+ await _writeRichToClipboard(_buildRichHtml(entry));
339
+ btn.innerHTML = _checkIcon;
340
+ btn.classList.add("copied");
341
+ setTimeout(() => {
342
+ btn.innerHTML = _slackIcon;
343
+ btn.classList.remove("copied");
344
+ }, 2000);
345
+ } catch {
346
+ btn.title = "Copy failed — try again";
347
+ setTimeout(() => (btn.title = "Copy for Slack"), 2000);
348
+ }
349
+ });
350
+ });
351
+ }
352
+
353
+ /**
354
+ * Write plain text to clipboard.
355
+ * Uses navigator.clipboard when available (secure contexts), falls back
356
+ * to execCommand('copy') for http:// origins like http://__DEN_CLI__:7777.
357
+ * Shared utility — use this instead of rolling your own copy logic.
358
+ */
359
+ export function copyToClipboard(text) {
360
+ if (navigator.clipboard?.writeText)
361
+ return navigator.clipboard.writeText(text);
362
+ return new Promise((resolve, reject) => {
363
+ const ta = document.createElement("textarea");
364
+ ta.value = text;
365
+ ta.style.cssText = "position:fixed;opacity:0;top:0;left:0";
366
+ document.body.appendChild(ta);
367
+ ta.focus();
368
+ ta.select();
369
+ try {
370
+ document.execCommand("copy")
371
+ ? resolve()
372
+ : reject(new Error("execCommand failed"));
373
+ } catch (e) {
374
+ reject(e);
375
+ } finally {
376
+ document.body.removeChild(ta);
377
+ }
378
+ });
379
+ }
380
+
381
+ /**
382
+ * Write rich HTML to the clipboard via a hidden contenteditable element.
383
+ *
384
+ * ClipboardItem produces clipboard entries that Slack ignores. Copying from
385
+ * an actual DOM selection (the "copy from webpage" path) puts HTML on the
386
+ * clipboard in the format Slack's paste handler trusts and renders as
387
+ * formatted text.
388
+ */
389
+ function _writeRichToClipboard(html) {
390
+ return new Promise((resolve, reject) => {
391
+ const el = document.createElement("div");
392
+ el.contentEditable = "true";
393
+ el.style.cssText =
394
+ "position:fixed;opacity:0;top:0;left:0;pointer-events:none";
395
+ el.innerHTML = html;
396
+ document.body.appendChild(el);
397
+ const range = document.createRange();
398
+ range.selectNodeContents(el);
399
+ const sel = window.getSelection();
400
+ sel.removeAllRanges();
401
+ sel.addRange(range);
402
+ try {
403
+ document.execCommand("copy")
404
+ ? resolve()
405
+ : reject(new Error("execCommand failed"));
406
+ } catch (e) {
407
+ reject(e);
408
+ } finally {
409
+ sel.removeAllRanges();
410
+ document.body.removeChild(el);
411
+ }
412
+ });
413
+ }
414
+
415
+ /**
416
+ * Build rich HTML for Slack paste. Uses inline formatting Slack renders:
417
+ * <strong> for bold, <code> for inline code, <ul><li> for bullets.
418
+ * Block-level headings are replaced with bold paragraphs so Slack doesn't
419
+ * strip them.
420
+ */
421
+ function _buildRichHtml(entry) {
422
+ const esc = (s) =>
423
+ String(s)
424
+ .replace(/&/g, "&amp;")
425
+ .replace(/</g, "&lt;")
426
+ .replace(/>/g, "&gt;");
427
+ const renderItem = (s) =>
428
+ esc(s)
429
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
430
+ .replace(/`([^`]+)`/g, "<code>$1</code>");
431
+
432
+ const sections = Object.entries(entry.sections ?? {})
433
+ .map(([sec, items]) => {
434
+ const liItems = items.flatMap((it) => [
435
+ `<li>${renderItem(it.text)}</li>`,
436
+ ...(it.sub ?? []).map((s) => `<li>${renderItem(s)}</li>`),
437
+ ]);
438
+ return `<p><strong>${esc(sec)}</strong></p><ul>${liItems.join("")}</ul>`;
439
+ })
440
+ .join("");
441
+ return `<p><strong>v${esc(entry.version)} — ${esc(entry.date)}</strong></p>${sections}`;
442
+ }
443
+
444
+ /** Reconstruct markdown from a structured entry as a fallback. */
445
+ function _buildMarkdown(entry) {
446
+ const lines = [`## [${entry.version}] — ${entry.date}`];
447
+ for (const [sec, items] of Object.entries(entry.sections ?? {})) {
448
+ lines.push("", `### ${sec}`, "");
449
+ for (const item of items) {
450
+ lines.push(`- ${item.text}`);
451
+ for (const s of item.sub ?? []) lines.push(` - ${s}`);
452
+ }
453
+ }
454
+ return lines.join("\n");
455
+ }
456
+
457
+ window.openChangelog = function () {
458
+ _injectChangelogStyles();
459
+ _renderChangelogBody(_changelogEntries);
460
+ const overlay = document.getElementById("cl-overlay");
461
+ if (overlay) overlay.classList.add("open");
462
+ };
463
+
464
+ window.closeChangelog = function () {
465
+ const overlay = document.getElementById("cl-overlay");
466
+ if (overlay) overlay.classList.remove("open");
467
+ if (_changelogEntries[0]?.version) {
468
+ try {
469
+ localStorage.setItem(LS_CHANGELOG_SEEN, _changelogEntries[0].version);
470
+ } catch {}
471
+ }
472
+ const pip = document.getElementById("v-pip");
473
+ if (pip) pip.style.display = "none";
474
+ };
475
+
476
+ /**
477
+ * Fetch changelog, populate the version pill, and auto-open if unread.
478
+ * Call once per page from each page's init function.
479
+ */
480
+ export async function initChangelog() {
481
+ _injectChangelogStyles();
482
+ try {
483
+ _changelogEntries = await fetch("/api/changelog").then((r) => r.json());
484
+ } catch {
485
+ _changelogEntries = [];
486
+ }
487
+
488
+ const latest = _changelogEntries[0];
489
+ const pill = document.getElementById("version-pill");
490
+ if (pill && latest?.version) {
491
+ pill.querySelector(".v-txt").textContent = "v" + latest.version;
492
+ }
493
+
494
+ let lastSeen = null;
495
+ try {
496
+ lastSeen = localStorage.getItem(LS_CHANGELOG_SEEN);
497
+ } catch {}
498
+
499
+ if (latest?.version && lastSeen !== latest.version) {
500
+ const pip = document.getElementById("v-pip");
501
+ if (pip) pip.style.display = "block";
502
+ window.openChangelog();
503
+ }
504
+ }
505
+
506
+ // ── __DEN_TITLE__ config ──────────────────────────────────────────────────────────────
507
+
508
+ let ___DEN_CLI__Config = null;
509
+
510
+ async function getDenConfig() {
511
+ if (___DEN_CLI__Config) return ___DEN_CLI__Config;
512
+ try {
513
+ ___DEN_CLI__Config = await fetch("/api/__DEN_CLI__/config").then((r) =>
514
+ r.json(),
515
+ );
516
+ } catch {
517
+ ___DEN_CLI__Config = {
518
+ environments: ["local"],
519
+ };
520
+ }
521
+ return ___DEN_CLI__Config;
522
+ }
523
+
524
+ /**
525
+ * Apply __DEN_CLI__ config to the nav — hide "Local Dev" when local environment is disabled.
526
+ * Call once on page load from each page's init function.
527
+ */
528
+ export async function applyDenConfig() {
529
+ const config = await getDenConfig();
530
+ _renderOrgCrumb(config.org);
531
+ const localEnabled = config.environments?.includes("local") ?? true;
532
+ if (!localEnabled) {
533
+ document
534
+ .querySelectorAll("nav a[href='/'], nav a[href='/local-dev']")
535
+ .forEach((el) => {
536
+ el.style.display = "none";
537
+ });
538
+ }
539
+ return config;
540
+ }
541
+
542
+ /**
543
+ * The org (root of Org > Application > Repo > Services) as a small crumb
544
+ * before the product name: "Globex › __DEN_CLI__".
545
+ */
546
+ function _renderOrgCrumb(org) {
547
+ const h1 = document.querySelector("header h1");
548
+ if (!h1 || !org?.title || document.getElementById("org-crumb")) return;
549
+ const crumb = document.createElement("span");
550
+ crumb.id = "org-crumb";
551
+ crumb.title = `Org: ${org.title} (${org.id})`;
552
+ crumb.style.cssText =
553
+ "font-size:12px;font-weight:600;color:var(--text,#c9d1d9);" +
554
+ "white-space:nowrap;flex-shrink:0;display:flex;align-items:center;gap:8px";
555
+ crumb.textContent = org.title;
556
+ const sep = document.createElement("span");
557
+ sep.textContent = "›";
558
+ sep.style.cssText = "color:var(--muted,#8b949e);font-weight:400";
559
+ crumb.appendChild(sep);
560
+ h1.parentNode.insertBefore(crumb, h1);
561
+ }
562
+
563
+ // ── Applications (for grouping the health dots) ───────────────────────────────
564
+
565
+ // Focused workspace's applications, refreshed at most every 30s so a config
566
+ // edit shows up without a reload but polls don't refetch every 5s.
567
+ let _apps = null;
568
+ let _appsAt = 0;
569
+ async function _loadApps() {
570
+ if (_apps && Date.now() - _appsAt < 30_000) return _apps;
571
+ try {
572
+ const r = await fetch("/api/applications", {
573
+ signal: AbortSignal.timeout(5_000),
574
+ });
575
+ if (r.ok) {
576
+ _apps = (await r.json()).applications ?? [];
577
+ _appsAt = Date.now();
578
+ }
579
+ } catch {
580
+ /* keep the last set (or none: dots render ungrouped) */
581
+ }
582
+ return _apps ?? [];
583
+ }
584
+
585
+ /**
586
+ * Order repos by application and say which application box each sits in.
587
+ * Declared applications get a box; a repo that is its own application (or is
588
+ * in none) renders bare. Pure — exported for tests.
589
+ */
590
+ export function groupReposByApplication(repos, apps) {
591
+ const appOf = new Map();
592
+ for (const a of apps || [])
593
+ for (const r of a.repos || []) if (!appOf.has(r)) appOf.set(r, a);
594
+ const out = [];
595
+ const seen = new Set();
596
+ for (const a of apps || []) {
597
+ const members = repos.filter((r) => appOf.get(r) === a);
598
+ if (!members.length) continue;
599
+ members.forEach((r) => seen.add(r));
600
+ out.push({ app: a.source === "declared" ? a : null, repos: members });
601
+ }
602
+ const rest = repos.filter((r) => !seen.has(r));
603
+ if (rest.length) out.push({ app: null, repos: rest });
604
+ return out;
605
+ }
606
+
607
+ // ── Workspace pill ────────────────────────────────────────────────────────────
608
+
609
+ // Set when a page opts into switching (initWorkspacePill's `onSwitch`): the
610
+ // pill becomes a dropdown of every known workspace instead of a static label.
611
+ let _wsOnSwitch = null;
612
+ let _wsMenuOpen = false;
613
+
614
+ /**
615
+ * Render the active workspace name into <span id="ws-pill">.
616
+ * Pass the id of the env <select> to show the pill only when env is "local".
617
+ * Omit envSelId (or pass null) to always show it (e.g. Local Dev page).
618
+ *
619
+ * Pass `onSwitch` to make the pill a workspace picker: choosing an entry POSTs
620
+ * `use` (activate + focus, same as `__DEN_CLI__ workspace use`) and then calls
621
+ * onSwitch(name) so the page can reload whatever it derives from the focused
622
+ * workspace. Pages with their own workspace UI (Local Dev) omit it.
623
+ */
624
+ export async function initWorkspacePill(envSelId = null, { onSwitch } = {}) {
625
+ const envSel = envSelId ? document.getElementById(envSelId) : null;
626
+ _wsOnSwitch = onSwitch ?? null;
627
+ await _renderPill(envSel);
628
+ if (envSel) {
629
+ envSel.addEventListener("change", () => _renderPill(envSel));
630
+ }
631
+ }
632
+
633
+ /** Re-fetch and re-render the workspace pill (call after switching workspaces). */
634
+ export async function refreshWorkspacePill() {
635
+ const envSel = document.getElementById("env-sel");
636
+ await _renderPill(envSel);
637
+ }
638
+
639
+ async function _renderPill(envSel) {
640
+ const el = document.getElementById("ws-pill");
641
+ if (!el) return;
642
+ const isLocal = !envSel || envSel.value === "local";
643
+ if (!isLocal) {
644
+ _closeWorkspaceMenu();
645
+ _pillHost(el).style.display = "none";
646
+ return;
647
+ }
648
+ try {
649
+ const data = await fetch("/api/workspaces").then((r) => r.json());
650
+ const { label, title } = buildWorkspacePillModel(data);
651
+ if (_wsOnSwitch) {
652
+ _renderPickerPill(el, data, label, title);
653
+ } else {
654
+ el.textContent = label;
655
+ el.title = data.active?.length > 1 ? title : "";
656
+ }
657
+ // The pill starts as inline display:none in every page's markup; clear it
658
+ // on the pill AND its picker wrapper, or the wrapper shows an empty box.
659
+ el.style.display = "";
660
+ _pillHost(el).style.display = "";
661
+ } catch {
662
+ _closeWorkspaceMenu();
663
+ _pillHost(el).style.display = "none";
664
+ }
665
+ }
666
+
667
+ /**
668
+ * The element whose visibility tracks the pill's: the wrapper once the picker
669
+ * has been built (so the menu is hidden with it), the pill itself before that.
670
+ */
671
+ function _pillHost(el) {
672
+ return el.parentElement?.classList.contains("ws-pill-wrap")
673
+ ? el.parentElement
674
+ : el;
675
+ }
676
+
677
+ function _injectWorkspacePickerStyles() {
678
+ if (document.getElementById("__DEN_CLI__-ws-picker-styles")) return;
679
+ const s = document.createElement("style");
680
+ s.id = "__DEN_CLI__-ws-picker-styles";
681
+ s.textContent = `
682
+ .ws-pill-wrap { position: relative; display: inline-flex; flex-shrink: 0; }
683
+ #ws-pill.ws-pill-btn { cursor: pointer; user-select: none; display: inline-flex; align-items: center; gap: 5px; }
684
+ #ws-pill.ws-pill-btn:hover, #ws-pill.ws-pill-btn.open { border-color: var(--accent, #58a6ff); }
685
+ .ws-pill-caret { font-size: 8px; opacity: .7; }
686
+ .ws-menu {
687
+ display: none; position: absolute; top: calc(100% + 4px); left: 0; z-index: 200;
688
+ background: var(--surface, #161b22); border: 1px solid var(--border, #30363d);
689
+ border-radius: 6px; min-width: 190px; max-height: 280px; overflow-y: auto;
690
+ box-shadow: 0 4px 16px rgba(0,0,0,.4); padding: 3px 0;
691
+ }
692
+ .ws-menu.open { display: block; }
693
+ .ws-menu-item {
694
+ display: flex; align-items: center; gap: 8px; width: 100%;
695
+ background: none; border: none; text-align: left;
696
+ padding: 6px 12px; cursor: pointer; font-size: 12px;
697
+ font-family: var(--mono, monospace); color: var(--text, #c9d1d9); white-space: nowrap;
698
+ }
699
+ .ws-menu-item:hover { background: var(--border, #30363d); }
700
+ .ws-menu-item[disabled] { cursor: default; }
701
+ .ws-menu-mark { width: 10px; flex-shrink: 0; color: var(--accent, #58a6ff); }
702
+ .ws-menu-tag { margin-left: auto; font-size: 10px; color: var(--muted, #8b949e); }
703
+ `;
704
+ document.head.appendChild(s);
705
+ }
706
+
707
+ /** Turn the static pill into a button + dropdown (idempotent). */
708
+ function _renderPickerPill(el, data, label, title) {
709
+ _injectWorkspacePickerStyles();
710
+
711
+ let wrap = el.parentElement;
712
+ if (!wrap?.classList.contains("ws-pill-wrap")) {
713
+ wrap = document.createElement("span");
714
+ wrap.className = "ws-pill-wrap";
715
+ el.replaceWith(wrap);
716
+ wrap.appendChild(el);
717
+ el.classList.add("ws-pill-btn");
718
+ el.setAttribute("role", "button");
719
+ el.setAttribute("tabindex", "0");
720
+ el.addEventListener("click", () => _toggleWorkspaceMenu());
721
+ el.addEventListener("keydown", (e) => {
722
+ if (e.key === "Enter" || e.key === " ") {
723
+ e.preventDefault();
724
+ _toggleWorkspaceMenu();
725
+ }
726
+ });
727
+ }
728
+
729
+ el.replaceChildren();
730
+ const name = document.createElement("span");
731
+ name.textContent = label;
732
+ const caret = document.createElement("span");
733
+ caret.className = "ws-pill-caret";
734
+ caret.textContent = "▾";
735
+ el.append(name, caret);
736
+ el.title = title;
737
+
738
+ let menu = wrap.querySelector(".ws-menu");
739
+ if (!menu) {
740
+ menu = document.createElement("div");
741
+ menu.className = "ws-menu";
742
+ wrap.appendChild(menu);
743
+ }
744
+ _paintWorkspaceMenu(menu, data);
745
+ }
746
+
747
+ function _paintWorkspaceMenu(menu, data) {
748
+ const { items } = buildWorkspaceMenuModel(data);
749
+ menu.replaceChildren();
750
+ if (items.length === 0) {
751
+ const empty = document.createElement("button");
752
+ empty.className = "ws-menu-item";
753
+ empty.disabled = true;
754
+ empty.textContent = "No workspaces";
755
+ menu.appendChild(empty);
756
+ return;
757
+ }
758
+ for (const item of items) {
759
+ const btn = document.createElement("button");
760
+ btn.className = "ws-menu-item";
761
+ btn.type = "button";
762
+ // Selecting an inactive workspace activates it (POST `use`), which only
763
+ // rewrites workspaces.json — no services are started.
764
+ btn.title = item.focused
765
+ ? "Current workspace"
766
+ : item.active
767
+ ? "Focus this workspace"
768
+ : "Activate and focus this workspace";
769
+
770
+ const mark = document.createElement("span");
771
+ mark.className = "ws-menu-mark";
772
+ mark.textContent = item.focused ? "●" : "";
773
+ const name = document.createElement("span");
774
+ name.textContent = item.name;
775
+ btn.append(mark, name);
776
+
777
+ if (!item.active) {
778
+ const tag = document.createElement("span");
779
+ tag.className = "ws-menu-tag";
780
+ tag.textContent = "not active";
781
+ btn.appendChild(tag);
782
+ }
783
+
784
+ btn.addEventListener("click", () => _switchWorkspace(item));
785
+ menu.appendChild(btn);
786
+ }
787
+ }
788
+
789
+ function _toggleWorkspaceMenu(force = null) {
790
+ const menu = document.querySelector(".ws-pill-wrap .ws-menu");
791
+ const pill = document.getElementById("ws-pill");
792
+ if (!menu) return;
793
+ _wsMenuOpen = force ?? !_wsMenuOpen;
794
+ menu.classList.toggle("open", _wsMenuOpen);
795
+ pill?.classList.toggle("open", _wsMenuOpen);
796
+ }
797
+
798
+ function _closeWorkspaceMenu() {
799
+ if (_wsMenuOpen) _toggleWorkspaceMenu(false);
800
+ }
801
+
802
+ document.addEventListener("click", (e) => {
803
+ if (_wsMenuOpen && !e.target.closest(".ws-pill-wrap")) _closeWorkspaceMenu();
804
+ });
805
+ document.addEventListener("keydown", (e) => {
806
+ if (e.key === "Escape") _closeWorkspaceMenu();
807
+ });
808
+
809
+ async function _switchWorkspace(item) {
810
+ _closeWorkspaceMenu();
811
+ if (item.focused) return;
812
+ const onSwitch = _wsOnSwitch;
813
+ try {
814
+ const res = await fetch("/api/workspaces", {
815
+ method: "POST",
816
+ headers: { "Content-Type": "application/json" },
817
+ // `use` = activate + focus, matching `__DEN_CLI__ workspace use`. Focus alone
818
+ // is rejected by the server for a workspace that is not active yet.
819
+ body: JSON.stringify({ name: item.name, action: "use" }),
820
+ });
821
+ if (!res.ok) {
822
+ const text = await res.text().catch(() => "");
823
+ throw new Error(text || `HTTP ${res.status}`);
824
+ }
825
+ } catch (err) {
826
+ alert(`Could not switch to workspace "${item.name}": ${err.message}`);
827
+ return;
828
+ }
829
+ await refreshWorkspacePill();
830
+ try {
831
+ await onSwitch?.(item.name);
832
+ } catch {
833
+ /* the page's own refresh failing must not break the switch */
834
+ }
835
+ }
836
+
837
+ // ── Health dots ───────────────────────────────────────────────────────────────
838
+
839
+ /** Call once on page load. Does NOT manage env changes — caller handles that. */
840
+ export async function initHealth() {
841
+ const env = document.getElementById("env-sel")?.value ?? "local";
842
+ const gen = ++pollGen;
843
+ await renderHealthDots(env, gen);
844
+ schedulePoll(env, gen);
845
+ }
846
+
847
+ /** Call whenever the environment changes. Cancels the current poll and restarts it for the new env. */
848
+ export async function refreshHealth(env, { force = false } = {}) {
849
+ const gen = ++pollGen;
850
+ clearTimeout(pollTimer);
851
+ setCheckingState(env);
852
+ await renderHealthDots(env, gen, force);
853
+ schedulePoll(env, gen);
854
+ }
855
+
856
+ // Background tabs throttle chained timers (Chrome clamps to ~1/min after a few
857
+ // hidden minutes), so the dots freeze while hidden — refresh immediately on
858
+ // return instead of showing pre-hide state for up to another minute.
859
+ document.addEventListener("visibilitychange", () => {
860
+ if (!document.hidden && pollGen > 0) {
861
+ refreshHealth(document.getElementById("env-sel")?.value ?? "local");
862
+ }
863
+ });
864
+
865
+ /** Immediately show all dots as "checking" so the user can see the env changed. */
866
+ function setCheckingState(env) {
867
+ const container = document.getElementById("health");
868
+ if (!container) return;
869
+ // Pulse all dots yellow while we re-ping
870
+ container.querySelectorAll(".dot").forEach((d) => {
871
+ d.className = "dot running";
872
+ });
873
+ }
874
+
875
+ function schedulePoll(env, gen) {
876
+ if (gen !== pollGen) return; // superseded by an env switch/re-init
877
+ clearTimeout(pollTimer);
878
+ pollTimer = setTimeout(async () => {
879
+ try {
880
+ await renderHealthDots(env, gen);
881
+ } finally {
882
+ // ALWAYS re-arm: a thrown/aborted tick must not silently kill polling
883
+ // (that froze the dots at their last state until a page reload).
884
+ schedulePoll(env, gen);
885
+ }
886
+ }, POLL_INTERVAL_MS);
887
+ }
888
+
889
+ /**
890
+ * Stream health results, painting each batch as it lands.
891
+ *
892
+ * The checks run concurrently but the slowest are shell probes that shell out
893
+ * to the aws CLI (~0.45s each), so waiting for the whole set made every dot
894
+ * resolve at the pace of the worst one. Streaming paints ~75% of them inside
895
+ * 200ms. Batched through requestAnimationFrame so 30 results cause a handful of
896
+ * repaints, not 30.
897
+ */
898
+ async function renderHealthDots(env, gen = pollGen, force = false) {
899
+ const container = document.getElementById("health");
900
+ if (!container) return;
901
+
902
+ await Promise.all([_loadEditorKey(), _loadApps()]);
903
+ _injectNavStyles();
904
+
905
+ // Merge into the PREVIOUS poll's results rather than starting empty.
906
+ //
907
+ // Each poll streams results as checks complete, repainting per batch. Painting
908
+ // only what has landed so far made the strip collapse (13 items → 2) and
909
+ // regrow on every 5s poll; because the strip sits above the content, the whole
910
+ // page below it — the dependency graph especially — visibly jumped ~30px each
911
+ // time. Seeding from the last known set keeps the item count stable, so a poll
912
+ // updates dot COLOURS in place instead of rebuilding the row.
913
+ const acc = new Map(gen === _healthAccGen ? _healthAcc : []);
914
+ _healthAccGen = gen;
915
+ const keyOf = (r) => `${r.repo}/${r.service}`;
916
+ let scheduled = false;
917
+ const paint = () => {
918
+ scheduled = false;
919
+ if (gen !== pollGen) return; // a newer poll owns the DOM now
920
+ _healthAcc = acc;
921
+ _paintHealthDots(container, env, _sortHealth([...acc.values()]));
922
+ };
923
+ const schedule = () => {
924
+ if (scheduled) return;
925
+ scheduled = true;
926
+ requestAnimationFrame(paint);
927
+ };
928
+
929
+ const fresh = new Map();
930
+ const streamed = await _streamHealth(env, force, (r) => {
931
+ acc.set(keyOf(r), r);
932
+ fresh.set(keyOf(r), r);
933
+ schedule();
934
+ });
935
+
936
+ if (!streamed) {
937
+ // Stream unavailable (older server, proxy that buffers SSE) — fall back to
938
+ // the plain endpoint so the dots still resolve.
939
+ try {
940
+ const rs = await fetch(
941
+ `/api/health?env=${env}${force ? "&refresh=1" : ""}`,
942
+ {
943
+ signal: AbortSignal.timeout(10_000),
944
+ },
945
+ ).then((r) => r.json());
946
+ fresh.clear();
947
+ for (const r of rs) fresh.set(keyOf(r), r);
948
+ } catch {
949
+ /* server unreachable */
950
+ }
951
+ }
952
+ if (gen !== pollGen) return;
953
+ // Settle on exactly what THIS poll returned, so a service removed from the
954
+ // workspace disappears instead of lingering from the seeded set.
955
+ if (fresh.size > 0) {
956
+ _healthAcc = fresh;
957
+ _paintHealthDots(container, env, _sortHealth([...fresh.values()]));
958
+ }
959
+ }
960
+
961
+ /**
962
+ * Stable display order. `idx` is the server's graph-ranked position (entry repo
963
+ * first … infra-stack last); results arrive in completion order, which differs
964
+ * every poll, so sorting by it is what keeps the dots from reshuffling. Falls
965
+ * back to repo name when the server doesn't send `idx`.
966
+ */
967
+ function _sortHealth(results) {
968
+ return results.sort(
969
+ (a, b) =>
970
+ (a.idx ?? Number.MAX_SAFE_INTEGER) - (b.idx ?? Number.MAX_SAFE_INTEGER) ||
971
+ a.repo.localeCompare(b.repo) ||
972
+ String(a.service).localeCompare(String(b.service)),
973
+ );
974
+ }
975
+
976
+ /**
977
+ * Read /api/health/stream to completion, calling onResult per check.
978
+ * Resolves true if the stream ran, false if the caller should fall back.
979
+ */
980
+ function _streamHealth(env, force, onResult) {
981
+ return new Promise((resolve) => {
982
+ let es;
983
+ let got = false;
984
+ // Backstop: a hung stream must not stall the poll chain forever.
985
+ const guard = setTimeout(() => {
986
+ try {
987
+ es?.close();
988
+ } catch {}
989
+ resolve(got);
990
+ }, 15_000);
991
+ const done = (ok) => {
992
+ clearTimeout(guard);
993
+ try {
994
+ es?.close();
995
+ } catch {}
996
+ resolve(ok);
997
+ };
998
+ try {
999
+ // force bypasses the server-side health cache — used right after a repo
1000
+ // sync, where the probes on disk may have just changed.
1001
+ es = new EventSource(
1002
+ `/api/health/stream?env=${encodeURIComponent(env)}${force ? "&refresh=1" : ""}`,
1003
+ );
1004
+ } catch {
1005
+ clearTimeout(guard);
1006
+ return resolve(false);
1007
+ }
1008
+ es.onmessage = (e) => {
1009
+ let msg;
1010
+ try {
1011
+ msg = JSON.parse(e.data);
1012
+ } catch {
1013
+ return;
1014
+ }
1015
+ if (msg.type === "result") {
1016
+ got = true;
1017
+ onResult(msg.result);
1018
+ } else if (msg.type === "done") {
1019
+ done(true);
1020
+ } else if (msg.type === "error") {
1021
+ done(got);
1022
+ }
1023
+ };
1024
+ // Errors before any result mean "fall back"; after results, keep what we got.
1025
+ es.onerror = () => done(got);
1026
+ });
1027
+ }
1028
+
1029
+ /** One repo's dot + label in the health row. */
1030
+ function _repoHealthItem(repo, items, escAttr) {
1031
+ // Show open-in-editor icon if editor is configured and any item is dev-managed
1032
+ const devItem = items.find((i) => i.mode === "dev-managed" && i.repoPath);
1033
+ const openIcon =
1034
+ _editorKey && devItem
1035
+ ? `<button class="nav-open-btn" data-open-path="${escAttr(devItem.repoPath)}" title="Open in editor">↗</button>`
1036
+ : "";
1037
+
1038
+ const isSingle = items.length === 1 && items[0].service === repo;
1039
+ if (isSingle) {
1040
+ const { service, url, ok, status, error } = items[0];
1041
+ const state = ok === true ? "ok" : ok === false ? "err" : "";
1042
+ const tip =
1043
+ ok == null
1044
+ ? "no health check"
1045
+ : error
1046
+ ? `${url} — ${error}`
1047
+ : `${url} → ${status}`;
1048
+ return `<div class="health-item" title="${escAttr(tip)}">
1049
+ <span class="dot ${state}" id="h-${repo}-${service}"></span>
1050
+ <span>${repo}</span>
1051
+ ${openIcon}
1052
+ </div>`;
1053
+ }
1054
+ // Multi-service repo: ONE dot for the system's main component (the
1055
+ // entrypoint), so a down app isn't masked green by its shared-infra
1056
+ // sidecars. An entrypoint-less infra provider (infra-stack) falls back
1057
+ // to an aggregate across its subsystems.
1058
+ const entry = items.find((i) => i.isEntrypoint);
1059
+ let overallState, tip;
1060
+ if (entry) {
1061
+ overallState = entry.ok === true ? "ok" : entry.ok === false ? "err" : "";
1062
+ tip =
1063
+ entry.ok == null
1064
+ ? "no health check"
1065
+ : entry.error
1066
+ ? `${entry.url} — ${entry.error}`
1067
+ : `${entry.url} → ${entry.status}`;
1068
+ } else {
1069
+ const allOk = items.every((i) => i.ok === true);
1070
+ const anyOk = items.some((i) => i.ok === true);
1071
+ overallState = allOk ? "ok" : anyOk ? "running" : "err";
1072
+ tip = `${items.length} services`;
1073
+ }
1074
+ return `<div class="health-item" title="${escAttr(tip)}">
1075
+ <span class="dot ${overallState}"></span>
1076
+ <span>${repo}</span>
1077
+ ${openIcon}
1078
+ </div>`;
1079
+ }
1080
+
1081
+ function _paintHealthDots(container, env, results) {
1082
+ const escAttr = (s) =>
1083
+ String(s ?? "")
1084
+ .replace(/&/g, "&amp;")
1085
+ .replace(/"/g, "&quot;");
1086
+
1087
+ if (!results.length) {
1088
+ container.innerHTML = `<div class="health-item" style="color:var(--muted);font-size:11px">${env}: no services</div>`;
1089
+ } else {
1090
+ // Group by repo so multi-service repos don't produce duplicate labels.
1091
+ const byRepo = new Map();
1092
+ for (const item of results) {
1093
+ if (!byRepo.has(item.repo)) byRepo.set(item.repo, []);
1094
+ byRepo.get(item.repo).push(item);
1095
+ }
1096
+ const repoHtml = new Map(
1097
+ [...byRepo.entries()].map(([repo, items]) => [
1098
+ repo,
1099
+ _repoHealthItem(repo, items, escAttr),
1100
+ ]),
1101
+ );
1102
+ container.innerHTML = groupReposByApplication([...byRepo.keys()], _apps)
1103
+ .map(({ app, repos }) => {
1104
+ const inner = repos.map((r) => repoHtml.get(r)).join("");
1105
+ if (!app) return inner;
1106
+ const tip = [app.title, app.owner, app.description]
1107
+ .filter(Boolean)
1108
+ .join(" · ");
1109
+ return `<div class="health-app" title="${escAttr(tip)}"><span class="health-app-label">${escAttr(app.name)}</span>${inner}</div>`;
1110
+ })
1111
+ .join("");
1112
+
1113
+ // Wire open-in-editor click handlers (data-open-path avoids inline onclick escaping)
1114
+ container
1115
+ .querySelectorAll(".nav-open-btn[data-open-path]")
1116
+ .forEach((btn) => {
1117
+ btn.addEventListener("click", (e) => {
1118
+ e.stopPropagation();
1119
+ fetch("/api/editor/open", {
1120
+ method: "POST",
1121
+ headers: { "Content-Type": "application/json" },
1122
+ body: JSON.stringify({ path: btn.dataset.openPath }),
1123
+ }).then((r) => {
1124
+ if (!r.ok)
1125
+ r.json().then((d) =>
1126
+ console.warn("[__DEN_CLI__] open-in-editor:", d.error),
1127
+ );
1128
+ });
1129
+ });
1130
+ });
1131
+ }
1132
+
1133
+ // Broadcast results so other components (e.g. dep graph) can stay in sync
1134
+ document.dispatchEvent(
1135
+ new CustomEvent("__DEN_CLI__:health-update", { detail: results }),
1136
+ );
1137
+
1138
+ // Refresh button — shown on every page, no per-page HTML changes needed
1139
+ const btn = document.createElement("button");
1140
+ btn.title = "Refresh service health";
1141
+ btn.textContent = "⟳";
1142
+ btn.style.cssText =
1143
+ "background:none;border:none;color:var(--muted);cursor:pointer;" +
1144
+ "font-size:13px;padding:1px 4px;border-radius:3px;line-height:1;margin-left:4px;" +
1145
+ "transition:color .1s;";
1146
+ btn.addEventListener("mouseenter", () => (btn.style.color = "var(--text)"));
1147
+ btn.addEventListener("mouseleave", () => (btn.style.color = "var(--muted)"));
1148
+ btn.addEventListener("click", () => {
1149
+ const currentEnv = document.getElementById("env-sel")?.value ?? env;
1150
+ refreshHealth(currentEnv); // pulses dots yellow while re-pinging
1151
+ });
1152
+ container.appendChild(btn);
1153
+ }
1154
+
1155
+ // ── Log badge (shared across pages) ───────────────────────────────────────────
1156
+
1157
+ const LS_OPEN_LOGS = "__DEN_CLI__-open-logs";
1158
+
1159
+ /**
1160
+ * Read __DEN_CLI__-open-logs from localStorage and populate the #log-badge element.
1161
+ * Call once on page load from any page that has a Logs nav link with a badge.
1162
+ */
1163
+ export function initLogBadge() {
1164
+ _updateBadgeFromStorage();
1165
+ // Stay in sync if another tab opens/closes a log stream
1166
+ window.addEventListener("storage", (e) => {
1167
+ if (e.key === LS_OPEN_LOGS) _updateBadgeFromStorage();
1168
+ });
1169
+ }
1170
+
1171
+ function _updateBadgeFromStorage() {
1172
+ const el = document.getElementById("log-badge");
1173
+ if (!el) return;
1174
+ try {
1175
+ const logs = JSON.parse(localStorage.getItem(LS_OPEN_LOGS) || "[]");
1176
+ const count = Array.isArray(logs) ? logs.length : 0;
1177
+ el.textContent = count;
1178
+ el.style.display = count > 0 ? "inline" : "none";
1179
+ } catch {
1180
+ el.style.display = "none";
1181
+ }
1182
+ }
1183
+
1184
+ /** Add a log entry to localStorage (call before navigating to /logs). */
1185
+ export function storeOpenLog(repo, service, env) {
1186
+ try {
1187
+ const logs = JSON.parse(localStorage.getItem(LS_OPEN_LOGS) || "[]");
1188
+ const filtered = logs.filter(
1189
+ (l) => !(l.repo === repo && l.service === service),
1190
+ );
1191
+ filtered.push({ repo, service, env });
1192
+ localStorage.setItem(LS_OPEN_LOGS, JSON.stringify(filtered));
1193
+ _updateBadgeFromStorage();
1194
+ } catch {
1195
+ /* noop */
1196
+ }
1197
+ }
1198
+
1199
+ // ── Primary-service resolution (shared by local-dev tabs + logs toolbar) ─────
1200
+ /**
1201
+ * Map repo → its PRIMARY service (the "main system" service) from an
1202
+ * /api/services payload. Precedence: entrypoint > root-named (service===repo)
1203
+ * > first seen. This is the same rollup the health dots, log tabs, and logs
1204
+ * toolbar key off, so "one thing per system" means the same thing everywhere.
1205
+ */
1206
+ export function computePrimaryByRepo(svcList) {
1207
+ const primary = new Map();
1208
+ const rank = new Map();
1209
+ for (const s of svcList || []) {
1210
+ const r = s.isEntrypoint ? 2 : s.service === s.repo ? 1 : 0;
1211
+ if (!primary.has(s.repo) || r > (rank.get(s.repo) ?? -1)) {
1212
+ primary.set(s.repo, s.address);
1213
+ rank.set(s.repo, r);
1214
+ }
1215
+ }
1216
+ return primary;
1217
+ }
1218
+
1219
+ // ── Stale-server watch (self-initializing on every page) ─────────────────────
1220
+ // The __DEN_CLI__ web server loads its TypeScript at startup; edit server code
1221
+ // without restarting and it keeps serving the OLD handlers while still serving
1222
+ // FRESH HTML from disk — a confusing mismatch (the Releases-tab POST/SSE bug was
1223
+ // exactly this). The server exposes /api/server-info { stale } (any src/*.ts
1224
+ // newer than its start); when stale we surface an amber header pill offering a
1225
+ // one-click Restart (POST /api/web/restart → detached `__DEN_CLI__ web restart`),
1226
+ // then reconnect to the fresh daemon and reload.
1227
+
1228
+ const SERVER_INFO_POLL_MS = 12_000;
1229
+ let _lastServerInfo = null; // most recent { pid, startedAt }
1230
+ let _staleStylesInjected = false;
1231
+
1232
+ function _injectStaleStyles() {
1233
+ if (_staleStylesInjected) return;
1234
+ _staleStylesInjected = true;
1235
+ const s = document.createElement("style");
1236
+ s.textContent = `
1237
+ .stale-pill {
1238
+ font-family: var(--mono, monospace); font-size: 10px; font-weight: 600;
1239
+ padding: 2px 9px; border-radius: 10px; cursor: pointer; user-select: none;
1240
+ border: 1px solid var(--yellow, #d29922); color: var(--yellow, #d29922);
1241
+ background: rgba(210,153,34,0.12);
1242
+ display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0;
1243
+ animation: stale-pulse 2s ease-in-out infinite;
1244
+ }
1245
+ .stale-pill:hover { color: #0d1117; background: var(--yellow, #d29922); }
1246
+ .stale-pill b { font-weight: 700; }
1247
+ @keyframes stale-pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
1248
+ .restart-overlay {
1249
+ position: fixed; inset: 0; z-index: 2000; background: rgba(0,0,0,.82);
1250
+ display: flex; align-items: center; justify-content: center;
1251
+ flex-direction: column; gap: 16px;
1252
+ color: var(--text, #c9d1d9); font-family: var(--mono, monospace);
1253
+ }
1254
+ .restart-overlay .spinner {
1255
+ width: 30px; height: 30px; border-radius: 50%;
1256
+ border: 3px solid var(--border, #30363d); border-top-color: var(--accent, #58a6ff);
1257
+ animation: stale-spin .8s linear infinite;
1258
+ }
1259
+ @keyframes stale-spin { to { transform: rotate(360deg); } }
1260
+ .restart-overlay .msg { font-size: 13px; }
1261
+ .restart-overlay .hint {
1262
+ font-size: 11px; color: var(--muted, #8b949e);
1263
+ max-width: 380px; text-align: center; line-height: 1.5;
1264
+ }
1265
+ `;
1266
+ document.head.appendChild(s);
1267
+ }
1268
+
1269
+ function _renderStalePill(info) {
1270
+ let pill = document.getElementById("stale-pill");
1271
+ if (!info || !info.stale) {
1272
+ if (pill) pill.style.display = "none";
1273
+ return;
1274
+ }
1275
+ _injectStaleStyles();
1276
+ if (!pill) {
1277
+ const header = document.querySelector("header");
1278
+ if (!header) return;
1279
+ pill = document.createElement("button");
1280
+ pill.id = "stale-pill";
1281
+ pill.className = "stale-pill";
1282
+ pill.title =
1283
+ "This __DEN_CLI__ web server is running code older than your working tree — " +
1284
+ "click to restart and pick up your changes. Running deploys continue on " +
1285
+ "GitHub; an open deploy log reattaches after the reload.";
1286
+ pill.innerHTML = "⚠ stale server · <b>Restart</b>";
1287
+ pill.addEventListener("click", _requestRestart);
1288
+ const vpill = document.getElementById("version-pill");
1289
+ if (vpill && vpill.parentNode)
1290
+ vpill.parentNode.insertBefore(pill, vpill.nextSibling);
1291
+ else header.appendChild(pill);
1292
+ }
1293
+ pill.style.display = "";
1294
+ }
1295
+
1296
+ async function _fetchServerInfo() {
1297
+ try {
1298
+ return await fetch("/api/server-info", {
1299
+ cache: "no-store",
1300
+ signal: AbortSignal.timeout(4000),
1301
+ }).then((r) => r.json());
1302
+ } catch {
1303
+ return null;
1304
+ }
1305
+ }
1306
+
1307
+ async function _staleTick() {
1308
+ const info = await _fetchServerInfo();
1309
+ if (info && info.pid != null)
1310
+ _lastServerInfo = { pid: info.pid, startedAt: info.startedAt };
1311
+ _renderStalePill(info);
1312
+ setTimeout(_staleTick, SERVER_INFO_POLL_MS);
1313
+ }
1314
+
1315
+ async function _requestRestart() {
1316
+ // No confirm(): the pill exists only when the server IS stale, and clicking
1317
+ // it is already the statement of intent. The restart is cheap and reversible
1318
+ // — running deploys continue on GitHub regardless; only an open deploy LOG in
1319
+ // this tab is lost, and it reattaches on reload.
1320
+ const before = _lastServerInfo;
1321
+ _showRestartOverlay();
1322
+ try {
1323
+ await fetch("/api/web/restart", {
1324
+ method: "POST",
1325
+ signal: AbortSignal.timeout(4000),
1326
+ });
1327
+ } catch {
1328
+ // The server usually dies before it can answer — expected; poll for the new one.
1329
+ }
1330
+ _pollForNewServer(before, Date.now());
1331
+ }
1332
+
1333
+ function _showRestartOverlay() {
1334
+ _injectStaleStyles();
1335
+ if (document.getElementById("restart-overlay")) return;
1336
+ const o = document.createElement("div");
1337
+ o.id = "restart-overlay";
1338
+ o.className = "restart-overlay";
1339
+ o.innerHTML =
1340
+ '<div class="spinner"></div>' +
1341
+ '<div class="msg">Restarting __DEN_CLI__ web…</div>' +
1342
+ '<div class="hint" id="restart-hint">Reconnecting — this page will reload automatically.</div>';
1343
+ document.body.appendChild(o);
1344
+ }
1345
+
1346
+ async function _pollForNewServer(before, startedAt) {
1347
+ const info = await _fetchServerInfo();
1348
+ const isNew =
1349
+ info &&
1350
+ info.pid != null &&
1351
+ (!before || info.pid !== before.pid || info.startedAt !== before.startedAt);
1352
+ if (isNew) {
1353
+ location.reload();
1354
+ return;
1355
+ }
1356
+ if (Date.now() - startedAt > 90_000) {
1357
+ const hint = document.getElementById("restart-hint");
1358
+ if (hint)
1359
+ hint.innerHTML =
1360
+ "Still restarting… if this persists, run <b>__DEN_CLI__ web restart</b> in a terminal, then reload.";
1361
+ }
1362
+ setTimeout(() => _pollForNewServer(before, startedAt), 1500);
1363
+ }
1364
+
1365
+ // Self-init: begin watching once there's a <header> to anchor the pill into.
1366
+ if (typeof document !== "undefined") {
1367
+ const _bootStaleWatch = () => _staleTick();
1368
+ if (document.readyState === "loading")
1369
+ document.addEventListener("DOMContentLoaded", _bootStaleWatch);
1370
+ else _bootStaleWatch();
1371
+ }