@monster-orange/den 0.2.8 → 0.3.0

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