@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,1588 @@
1
+ /**
2
+ * Service Runner
3
+ *
4
+ * Resolves a service address → repo path → reads .den.yml →
5
+ * executes commands (up / down / restart / any named command) with
6
+ * streaming output.
7
+ */
8
+
9
+ import { hasRuntime, repoKind } from "../repo-kind.js";
10
+ import { effectiveLocalCommands } from "../stacks/manifest-commands.js";
11
+ import fs from "fs";
12
+ import path from "path";
13
+ import { readManifest } from "../manifest/read-manifest.js";
14
+ import {
15
+ spawnStreaming,
16
+ spawnInteractive,
17
+ getServiceLogPath,
18
+ } from "../process/spawn-streaming.js";
19
+ import { readUserConfig } from "../den-config.js";
20
+ import { lookupTab, recordTab, forgetTab } from "../cli-tabs.js";
21
+ import {
22
+ closeTab,
23
+ openTab,
24
+ resolveTerminalApp,
25
+ shellQuote,
26
+ } from "../terminal/tabs.js";
27
+ import { readCatalog } from "./catalog.js";
28
+ import { buildServiceEnv } from "./service-env.js";
29
+ import {
30
+ devOpContainerNames,
31
+ diffSpawnedContainers,
32
+ forceStopOpctlContainers,
33
+ inspectOpctlContainer,
34
+ isOpctlAvailable,
35
+ listRunningOpctlContainers,
36
+ } from "./opctl.js";
37
+ import {
38
+ getActiveWorkspace,
39
+ getActiveWorkspaces,
40
+ getFocusedWorkspaceName,
41
+ getWorkspaceByName,
42
+ resolveServiceSource,
43
+ listWorkspaceServices,
44
+ } from "./workspaces.js";
45
+ import {
46
+ checkServiceHealth,
47
+ checkCatalogHealth,
48
+ type ServiceHealthResult,
49
+ } from "./health.js";
50
+ import { EventEmitter } from "events";
51
+ import type { CommandDef, ResolvedService } from "../types/index.js";
52
+ import type { ChildProcess } from "child_process";
53
+ import { getStateDir } from "../paths.js";
54
+
55
+ import { product } from "../product.js";
56
+ // Re-export so existing callers don't need to change their import path.
57
+ export type { ServiceHealthResult };
58
+ export { checkServiceHealth, checkCatalogHealth };
59
+
60
+ // ── Daemon registry ───────────────────────────────────────────────────────────
61
+
62
+ export interface DaemonRecord {
63
+ address: string;
64
+ repoName: string;
65
+ subsystem: string;
66
+ repoPath: string;
67
+ logPath: string;
68
+ startedAt: Date;
69
+ /**
70
+ * Branch identity for share/conflict checks. Null/undefined means
71
+ * "unspecified" (typical for dev-managed checkouts).
72
+ */
73
+ branch?: string | null;
74
+ /**
75
+ * Active workspaces that currently own this running instance (refcount).
76
+ * Empty/missing on legacy records — filled on first attach/up.
77
+ */
78
+ workspaces?: string[];
79
+ /**
80
+ * opctl container names this service spawned, captured at `up` time. Used to
81
+ * shut the service down via `opctl container down` without a declared down
82
+ * command. Optional for backward-compat with records written before this.
83
+ */
84
+ containers?: string[];
85
+ }
86
+
87
+ export type ServiceConflictReason = "identity_mismatch" | "orphan_containers";
88
+
89
+ export class ServiceConflictError extends Error {
90
+ readonly reason: ServiceConflictReason;
91
+ readonly address: string;
92
+ readonly owners: string[];
93
+ readonly running: { repoPath: string; branch: string | null };
94
+ readonly requested: { repoPath: string; branch: string | null };
95
+ readonly orphanContainers?: string[];
96
+
97
+ constructor(opts: {
98
+ reason: ServiceConflictReason;
99
+ address: string;
100
+ owners: string[];
101
+ running: { repoPath: string; branch: string | null };
102
+ requested: { repoPath: string; branch: string | null };
103
+ orphanContainers?: string[];
104
+ message?: string;
105
+ }) {
106
+ const msg =
107
+ opts.message ??
108
+ (opts.reason === "orphan_containers"
109
+ ? `Service "${opts.address}" has running opctl containers (${(opts.orphanContainers ?? []).join(", ")}) not tracked by ${product().cli} — refuse to start a second instance`
110
+ : `Service "${opts.address}" is already running for workspace(s) [${opts.owners.join(", ")}] at ${opts.running.repoPath}` +
111
+ ` (branch: ${opts.running.branch ?? "n/a"}), which differs from requested ${opts.requested.repoPath}` +
112
+ ` (branch: ${opts.requested.branch ?? "n/a"})`);
113
+ super(msg);
114
+ this.name = "ServiceConflictError";
115
+ this.reason = opts.reason;
116
+ this.address = opts.address;
117
+ this.owners = opts.owners;
118
+ this.running = opts.running;
119
+ this.requested = opts.requested;
120
+ this.orphanContainers = opts.orphanContainers;
121
+ }
122
+ }
123
+
124
+ // JSON representation — startedAt serialises as an ISO string.
125
+ type DaemonRecordJSON = Omit<DaemonRecord, "startedAt"> & { startedAt: string };
126
+
127
+ interface DaemonsFile {
128
+ denPid: number;
129
+ daemons: DaemonRecordJSON[];
130
+ }
131
+
132
+ const _daemonRegistry = new Map<string, DaemonRecord>();
133
+ let daemonRegistryLoadedFrom: string | null = null;
134
+
135
+ function getDaemonsFile(): string {
136
+ return (
137
+ process.env.DEN_DAEMONS_FILE ?? path.join(getStateDir(), "daemons.json")
138
+ );
139
+ }
140
+
141
+ function normalizeBranch(branch: string | null | undefined): string | null {
142
+ return branch ?? null;
143
+ }
144
+
145
+ export function sameDaemonIdentity(
146
+ record: Pick<DaemonRecord, "repoPath" | "branch">,
147
+ repoPath: string,
148
+ branch: string | null | undefined,
149
+ ): boolean {
150
+ return (
151
+ record.repoPath === repoPath &&
152
+ normalizeBranch(record.branch) === normalizeBranch(branch)
153
+ );
154
+ }
155
+
156
+ function toDaemonRecord(json: DaemonRecordJSON): DaemonRecord {
157
+ return {
158
+ ...json,
159
+ workspaces: Array.isArray(json.workspaces) ? [...json.workspaces] : [],
160
+ branch: normalizeBranch(json.branch),
161
+ startedAt: new Date(json.startedAt),
162
+ };
163
+ }
164
+
165
+ function readDaemonsFile(filePath: string): Map<string, DaemonRecord> {
166
+ const map = new Map<string, DaemonRecord>();
167
+ try {
168
+ if (!fs.existsSync(filePath)) return map;
169
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as
170
+ | DaemonsFile
171
+ | DaemonRecordJSON[];
172
+ const records = Array.isArray(parsed) ? parsed : (parsed.daemons ?? []);
173
+ for (const record of records) {
174
+ if (!record.address) continue;
175
+ map.set(record.address, toDaemonRecord(record));
176
+ }
177
+ } catch {
178
+ // Corrupt or missing file: keep registry empty.
179
+ }
180
+ return map;
181
+ }
182
+
183
+ function replaceDaemonRegistry(records: Map<string, DaemonRecord>): void {
184
+ _daemonRegistry.clear();
185
+ for (const [address, record] of records.entries()) {
186
+ _daemonRegistry.set(address, record);
187
+ }
188
+ }
189
+
190
+ function saveDaemonsFile(filePath: string): void {
191
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
192
+ const records: DaemonRecordJSON[] = Array.from(_daemonRegistry.values()).map(
193
+ (d) => ({ ...d, startedAt: d.startedAt.toISOString() }),
194
+ );
195
+ const file: DaemonsFile = { denPid: process.pid, daemons: records };
196
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
197
+ try {
198
+ fs.writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n", "utf-8");
199
+ fs.renameSync(tmp, filePath);
200
+ } catch {
201
+ try {
202
+ fs.unlinkSync(tmp);
203
+ } catch {
204
+ /* ignore cleanup failure */
205
+ }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Populate the in-memory registry from the persisted daemons file.
211
+ * Call once at server startup so daemons started in a previous session
212
+ * are visible to the shutdown prompt.
213
+ *
214
+ * The persisted `denPid` is retained for file compatibility only. It is not
215
+ * a whole-file validity gate: short-lived CLI processes start daemons and exit,
216
+ * while those daemons can still be valid and should remain visible to later CLI
217
+ * calls and the web UI.
218
+ */
219
+ export function loadDaemonsFromFile(): void {
220
+ const filePath = getDaemonsFile();
221
+ replaceDaemonRegistry(readDaemonsFile(filePath));
222
+ daemonRegistryLoadedFrom = filePath;
223
+ }
224
+
225
+ export function ensureDaemonRegistryLoaded(): void {
226
+ const filePath = getDaemonsFile();
227
+ if (daemonRegistryLoadedFrom === filePath) return;
228
+ loadDaemonsFromFile();
229
+ }
230
+
231
+ export function registerDaemon(record: DaemonRecord): void {
232
+ loadDaemonsFromFile();
233
+ const existing = _daemonRegistry.get(record.address);
234
+ // Owners are only inherited from a record describing the SAME checkout — a
235
+ // registration for a different path/branch replaces the instance, so keeping
236
+ // the old owners would refcount workspaces against something they never
237
+ // started. (`up` blocks on identity mismatch before reaching here; adoption
238
+ // after a lost record is the path that can legitimately differ.)
239
+ const inherited = sameDaemonIdentity(
240
+ existing ?? { repoPath: record.repoPath, branch: record.branch },
241
+ record.repoPath,
242
+ record.branch,
243
+ )
244
+ ? (existing?.workspaces ?? [])
245
+ : [];
246
+ const workspaces = [...new Set([...inherited, ...(record.workspaces ?? [])])];
247
+ _daemonRegistry.set(record.address, {
248
+ ...record,
249
+ workspaces,
250
+ branch: normalizeBranch(record.branch),
251
+ });
252
+ saveDaemonsFile(getDaemonsFile());
253
+ }
254
+
255
+ export function unregisterDaemon(address: string): void {
256
+ loadDaemonsFromFile();
257
+ _daemonRegistry.delete(address);
258
+ saveDaemonsFile(getDaemonsFile());
259
+ }
260
+
261
+ /**
262
+ * Merge observed container base-names into a daemon's record — used to refine
263
+ * the spawn-time seed with the containers a run ACTUALLY created (union, so a
264
+ * partial observation never loses declared candidates). No-op when the daemon
265
+ * has since been unregistered.
266
+ */
267
+ export function updateDaemonContainers(
268
+ address: string,
269
+ containers: string[],
270
+ ): void {
271
+ if (containers.length === 0) return;
272
+ loadDaemonsFromFile();
273
+ const existing = _daemonRegistry.get(address);
274
+ if (!existing) return;
275
+ const merged = [...new Set([...(existing.containers ?? []), ...containers])];
276
+ _daemonRegistry.set(address, { ...existing, containers: merged });
277
+ saveDaemonsFile(getDaemonsFile());
278
+ }
279
+
280
+ export function listDaemons(): DaemonRecord[] {
281
+ loadDaemonsFromFile();
282
+ return Array.from(_daemonRegistry.values());
283
+ }
284
+
285
+ export function findDaemonByAddress(address: string): DaemonRecord | undefined {
286
+ loadDaemonsFromFile();
287
+ return _daemonRegistry.get(address);
288
+ }
289
+
290
+ /** Add a workspace owner to a running daemon (share / attach). */
291
+ export function attachDaemonOwner(address: string, workspace: string): void {
292
+ loadDaemonsFromFile();
293
+ const existing = _daemonRegistry.get(address);
294
+ if (!existing) return;
295
+ const workspaces = existing.workspaces ?? [];
296
+ if (!workspaces.includes(workspace)) {
297
+ existing.workspaces = [...workspaces, workspace];
298
+ _daemonRegistry.set(address, existing);
299
+ saveDaemonsFile(getDaemonsFile());
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Remove a workspace owner. Returns true when the daemon was fully
305
+ * unregistered (no owners left); false when other owners remain.
306
+ */
307
+ export function detachDaemonOwner(
308
+ address: string,
309
+ workspace: string,
310
+ ): { removed: boolean; remaining: string[] } {
311
+ loadDaemonsFromFile();
312
+ const existing = _daemonRegistry.get(address);
313
+ if (!existing) return { removed: true, remaining: [] };
314
+ const remaining = (existing.workspaces ?? []).filter((w) => w !== workspace);
315
+ if (remaining.length === 0) {
316
+ _daemonRegistry.delete(address);
317
+ saveDaemonsFile(getDaemonsFile());
318
+ return { removed: true, remaining: [] };
319
+ }
320
+ existing.workspaces = remaining;
321
+ _daemonRegistry.set(address, existing);
322
+ saveDaemonsFile(getDaemonsFile());
323
+ return { removed: false, remaining };
324
+ }
325
+
326
+ /**
327
+ * A daemon's owners that are still in the active set.
328
+ *
329
+ * Refcount decisions must never count a workspace that has since been
330
+ * deactivated or deleted: an inactive "owner" can't run `down` for itself
331
+ * (`-w` requires activation), so counting it makes the last real owner detach
332
+ * instead of stop and leaves the service unstoppable without `--force`.
333
+ * `releaseWorkspaceDaemons` keeps the registry honest going forward; this
334
+ * filter also self-heals records written before it existed.
335
+ */
336
+ export function activeDaemonOwners(
337
+ record: Pick<DaemonRecord, "workspaces"> | undefined,
338
+ active: Set<string> = new Set(getActiveWorkspaces()),
339
+ ): string[] {
340
+ return (record?.workspaces ?? []).filter((w) => active.has(w));
341
+ }
342
+
343
+ /** Outcome of releasing one daemon during a workspace deactivation. */
344
+ export interface DaemonRelease {
345
+ address: string;
346
+ /** "detached" when other active owners remain, "stopped" when it was downed. */
347
+ outcome: "detached" | "stopped" | "stop-failed";
348
+ /** Active owners still holding the service after a detach. */
349
+ remaining: string[];
350
+ }
351
+
352
+ /**
353
+ * Release every daemon `workspace` owns (design decision 5: "deactivate
354
+ * detaches ownership and stops only if last owner").
355
+ *
356
+ * Call AFTER the workspace has left `active[]` so the remaining-owner test
357
+ * sees the post-deactivation world. Sole-owner instances are stopped through
358
+ * the normal `down` path with `force` — the refcount question is already
359
+ * settled here, and forcing skips a redundant second ownership check.
360
+ */
361
+ export async function releaseWorkspaceDaemons(
362
+ workspace: string,
363
+ onLine: (line: string) => void = () => {},
364
+ ): Promise<DaemonRelease[]> {
365
+ const owned = listDaemons().filter((d) =>
366
+ (d.workspaces ?? []).includes(workspace),
367
+ );
368
+ const active = new Set(getActiveWorkspaces());
369
+ const releases: DaemonRelease[] = [];
370
+
371
+ for (const daemon of owned) {
372
+ const remaining = activeDaemonOwners(daemon, active).filter(
373
+ (w) => w !== workspace,
374
+ );
375
+ if (remaining.length > 0) {
376
+ detachDaemonOwner(daemon.address, workspace);
377
+ onLine(
378
+ `detached "${workspace}" from ${daemon.address} — still running for [${remaining.join(", ")}]`,
379
+ );
380
+ releases.push({
381
+ address: daemon.address,
382
+ outcome: "detached",
383
+ remaining,
384
+ });
385
+ continue;
386
+ }
387
+ const exitCode = await new Promise<number>((resolve) => {
388
+ runServiceCommand({
389
+ address: daemon.address,
390
+ command: "down",
391
+ workspace,
392
+ force: true,
393
+ onLine,
394
+ onClose: resolve,
395
+ });
396
+ });
397
+ releases.push({
398
+ address: daemon.address,
399
+ outcome: exitCode === 0 ? "stopped" : "stop-failed",
400
+ remaining: [],
401
+ });
402
+ }
403
+
404
+ return releases;
405
+ }
406
+
407
+ type ServiceIdentity = Pick<
408
+ ResolvedService,
409
+ "address" | "repoPath" | "branch" | "repoName"
410
+ >;
411
+
412
+ /** Running opctl containers a service declares but den has no record of. */
413
+ function findOrphanContainers(resolved: ServiceIdentity): string[] {
414
+ if (!isOpctlAvailable()) return [];
415
+ const declared = [
416
+ ...devOpContainerNames(resolved.repoPath),
417
+ resolved.repoName,
418
+ ].filter(Boolean);
419
+ if (declared.length === 0) return [];
420
+ const running = new Set(listRunningOpctlContainers().map((c) => c.base));
421
+ return [...new Set(declared.filter((n) => running.has(n)))];
422
+ }
423
+
424
+ function mountsRepo(mountSources: string[], repoPath: string): boolean {
425
+ return mountSources.some(
426
+ (source) =>
427
+ source === repoPath || source.startsWith(`${repoPath}${path.sep}`),
428
+ );
429
+ }
430
+
431
+ /**
432
+ * Untracked containers that verifiably run `resolved`'s own checkout, decided
433
+ * by bind mounts (see `inspectOpctlContainer`).
434
+ *
435
+ * Den can lose a daemon record while the containers keep running — a web
436
+ * server restart, a crash, a reaper sweep. Without a way back, every later `up`
437
+ * hits the orphan-container block and the only exit is a manual teardown, so
438
+ * these containers are adopted into the registry instead. Containers whose
439
+ * mounts point at a different checkout are NOT adoptable: those are a genuine
440
+ * conflict and must stay blocked.
441
+ */
442
+ export function findAdoptableContainers(
443
+ resolved: ServiceIdentity,
444
+ orphans: string[] = findOrphanContainers(resolved),
445
+ ): { containers: string[]; startedAt: Date | null } {
446
+ const containers: string[] = [];
447
+ let startedAt: Date | null = null;
448
+ for (const base of orphans) {
449
+ const info = inspectOpctlContainer(base);
450
+ // No inspect result — can't claim it and can't blame it; leave it alone.
451
+ if (!info || !mountsRepo(info.mountSources, resolved.repoPath)) continue;
452
+ containers.push(base);
453
+ if (info.startedAt && (!startedAt || info.startedAt < startedAt)) {
454
+ startedAt = info.startedAt;
455
+ }
456
+ }
457
+ return { containers, startedAt };
458
+ }
459
+
460
+ /**
461
+ * Re-register already-running containers as a daemon owned by `workspace`.
462
+ * Returns the adopted container names ([] when there is nothing to adopt).
463
+ */
464
+ export function adoptRunningContainers(
465
+ resolved: ResolvedService,
466
+ workspace: string,
467
+ ): string[] {
468
+ const { containers, startedAt } = findAdoptableContainers(resolved);
469
+ if (containers.length === 0) return [];
470
+ registerDaemon({
471
+ address: resolved.address,
472
+ repoName: resolved.repoName,
473
+ subsystem: resolved.subsystem,
474
+ repoPath: resolved.repoPath,
475
+ branch: resolved.branch,
476
+ workspaces: [workspace],
477
+ logPath: getServiceLogPath(resolved.repoName, resolved.subsystem),
478
+ startedAt: startedAt ?? new Date(),
479
+ containers: [
480
+ ...new Set([...containers, ...devOpContainerNames(resolved.repoPath)]),
481
+ ],
482
+ });
483
+ return containers;
484
+ }
485
+
486
+ /**
487
+ * Record `workspace` as an owner of a service a caller is about to skip because
488
+ * it already answers its health check.
489
+ *
490
+ * Skipping is right — nothing needs starting — but skipping SILENTLY loses the
491
+ * refcount: the workspace never appears as an owner, so its `down` reports it
492
+ * doesn't own the service, and another owner's `down` takes it away without
493
+ * warning. Returns how ownership was recorded so callers can say so.
494
+ */
495
+ export function claimHealthyService(
496
+ resolved: ResolvedService,
497
+ workspace: string,
498
+ ): "attached" | "adopted" | "untracked" {
499
+ const existing = findDaemonByAddress(resolved.address);
500
+ if (existing) {
501
+ if (!sameDaemonIdentity(existing, resolved.repoPath, resolved.branch)) {
502
+ // Healthy, but the record describes a different checkout — leave the
503
+ // registry alone rather than claim something we can't vouch for.
504
+ return "untracked";
505
+ }
506
+ attachDaemonOwner(resolved.address, workspace);
507
+ return "attached";
508
+ }
509
+ return adoptRunningContainers(resolved, workspace).length > 0
510
+ ? "adopted"
511
+ : "untracked";
512
+ }
513
+
514
+ /**
515
+ * Check whether starting `resolved` for `workspace` conflicts with a live
516
+ * daemon or orphan opctl containers. Returns null when start/attach is OK;
517
+ * callers should attach when a matching daemon already exists, or adopt when
518
+ * `findAdoptableContainers` reports untracked containers of the same checkout.
519
+ */
520
+ export function findDaemonConflict(
521
+ resolved: Pick<
522
+ ResolvedService,
523
+ "address" | "repoPath" | "branch" | "repoName"
524
+ >,
525
+ ): ServiceConflictError | null {
526
+ loadDaemonsFromFile();
527
+ const existing = _daemonRegistry.get(resolved.address);
528
+ if (existing) {
529
+ if (sameDaemonIdentity(existing, resolved.repoPath, resolved.branch)) {
530
+ return null;
531
+ }
532
+ return new ServiceConflictError({
533
+ reason: "identity_mismatch",
534
+ address: resolved.address,
535
+ owners: existing.workspaces ?? [],
536
+ running: {
537
+ repoPath: existing.repoPath,
538
+ branch: normalizeBranch(existing.branch),
539
+ },
540
+ requested: {
541
+ repoPath: resolved.repoPath,
542
+ branch: normalizeBranch(resolved.branch),
543
+ },
544
+ });
545
+ }
546
+
547
+ const orphans = findOrphanContainers(resolved);
548
+ if (orphans.length === 0) return null;
549
+ if (findAdoptableContainers(resolved, orphans).containers.length > 0) {
550
+ return null;
551
+ }
552
+ return new ServiceConflictError({
553
+ reason: "orphan_containers",
554
+ address: resolved.address,
555
+ owners: [],
556
+ running: {
557
+ repoPath: resolved.repoPath,
558
+ branch: normalizeBranch(resolved.branch),
559
+ },
560
+ requested: {
561
+ repoPath: resolved.repoPath,
562
+ branch: normalizeBranch(resolved.branch),
563
+ },
564
+ orphanContainers: orphans,
565
+ });
566
+ }
567
+
568
+ function canResolveDaemonAddress(d: DaemonRecord): boolean {
569
+ if (resolveService(d.address)) return true;
570
+ for (const w of d.workspaces ?? []) {
571
+ if (resolveService(d.address, w)) return true;
572
+ }
573
+ for (const w of getActiveWorkspaces()) {
574
+ if (resolveService(d.address, w)) return true;
575
+ }
576
+ return false;
577
+ }
578
+
579
+ /**
580
+ * True when a registered daemon is opctl-based (has candidate container names)
581
+ * and NONE of its candidates are currently running — i.e. its containers died
582
+ * or were killed out from under it.
583
+ *
584
+ * Non-opctl daemons (no discoverable containers) can't be verified this way and
585
+ * are treated as alive, same contract as `pruneDeadDaemons`. Pass `running`
586
+ * when checking many daemons to avoid one `docker ps` per record.
587
+ */
588
+ export function isDaemonVerifiablyDead(
589
+ d: DaemonRecord,
590
+ running: Set<string> | null = isOpctlAvailable()
591
+ ? new Set(listRunningOpctlContainers().map((c) => c.base))
592
+ : null,
593
+ ): boolean {
594
+ if (!running) return false; // opctl unavailable — can't verify liveness
595
+ const opctlCandidates = [
596
+ ...(d.containers ?? []),
597
+ ...devOpContainerNames(d.repoPath),
598
+ ].filter(Boolean);
599
+ if (opctlCandidates.length === 0) return false; // non-opctl — trust it
600
+ return ![...opctlCandidates, d.repoName].some((c) => running.has(c));
601
+ }
602
+
603
+ /**
604
+ * Remove persisted daemons that are no longer running — called at startup to
605
+ * clear stale entries from previous sessions. Otherwise they reopen as tabs in
606
+ * the web UI and clutter the shutdown prompt.
607
+ *
608
+ * A daemon is dropped when:
609
+ * - its address no longer resolves to a workspace/catalog service, OR
610
+ * - it is opctl-based (has captured containers or a discoverable dev-op) and
611
+ * NONE of its candidate containers are currently running.
612
+ *
613
+ * Daemons with no discoverable opctl containers are KEPT — we can't verify them
614
+ * via `opctl container ls`, so we err toward keeping a possibly-live process
615
+ * (e.g. a non-opctl dev server). Health is deliberately NOT used: a service's
616
+ * shared-infra health check (Postgres/Redis/LocalStack from infra-stack) stays
617
+ * green whether or not the service itself is running. Returns pruned addresses.
618
+ */
619
+ export function pruneDeadDaemons(): string[] {
620
+ const daemons = listDaemons();
621
+ if (daemons.length === 0) return [];
622
+ const running = isOpctlAvailable()
623
+ ? new Set(listRunningOpctlContainers().map((c) => c.base))
624
+ : null;
625
+ const pruned: string[] = [];
626
+ for (const d of daemons) {
627
+ try {
628
+ if (!canResolveDaemonAddress(d)) {
629
+ unregisterDaemon(d.address);
630
+ pruned.push(d.address);
631
+ continue;
632
+ }
633
+ if (isDaemonVerifiablyDead(d, running)) {
634
+ unregisterDaemon(d.address);
635
+ pruned.push(d.address);
636
+ }
637
+ } catch {
638
+ /* keep the entry on any error */
639
+ }
640
+ }
641
+ return pruned;
642
+ }
643
+
644
+ // ── Service resolution ────────────────────────────────────────────────────────
645
+
646
+ /**
647
+ * Resolve a service address against a workspace + catalog.
648
+ * When `workspaceName` is omitted, uses the focused workspace.
649
+ * Returns null if the service is not found.
650
+ */
651
+ export function resolveService(
652
+ address: string,
653
+ workspaceName?: string,
654
+ ): ResolvedService | null {
655
+ const ws = workspaceName
656
+ ? getWorkspaceByName(workspaceName)
657
+ : getActiveWorkspace();
658
+ if (!ws) return null;
659
+ const catalog = readCatalog();
660
+
661
+ let resolved = resolveServiceSource(address, ws.name, ws.config, catalog);
662
+
663
+ // Bare multi-service repo registered ONLY by subsystem (e.g. "shop/db" +
664
+ // "shop/api" with no bare "shop" entry): resolve via a "repo/subsystem" entry,
665
+ // preferring the manifest entrypoint, so the bare repo name is still runnable
666
+ // (the derived graph + `den up` address repos by bare name).
667
+ if (!resolved && !address.includes("/")) {
668
+ const subAddrs = Object.keys(ws.config.services).filter((a) =>
669
+ a.startsWith(`${address}/`),
670
+ );
671
+ if (subAddrs.length > 0) {
672
+ const first = resolveServiceSource(
673
+ subAddrs[0],
674
+ ws.name,
675
+ ws.config,
676
+ catalog,
677
+ );
678
+ const entry = first
679
+ ? readManifest(first.repoPath)?.entrypoint
680
+ : undefined;
681
+ const chosen =
682
+ entry && subAddrs.includes(`${address}/${entry}`)
683
+ ? `${address}/${entry}`
684
+ : subAddrs[0];
685
+ resolved = resolveServiceSource(chosen, ws.name, ws.config, catalog);
686
+ }
687
+ }
688
+ if (!resolved) return null;
689
+
690
+ // Bare repo address correction: when the address is just the repo name, the
691
+ // subsystem defaults to the repo name, which usually isn't a manifest service
692
+ // key. Map it to a runnable subsystem so command lookup
693
+ // (`manifest.services[subsystem]`) hits a real entry:
694
+ // - single-service repo → that one service
695
+ // - multi-service repo → the declared `entrypoint` (e.g. links-api's
696
+ // services are pg/redis/localstack/api, entrypoint
697
+ // api → `make _api-up`). Without this the derived
698
+ // `up` fails with "command up not found".
699
+ // (The listing path, expandMonoRepoEntry, instead fans a bare multi-service
700
+ // entry out to ALL subsystems; here we run the single entrypoint node.)
701
+ if (resolved.subsystem === resolved.repoName) {
702
+ const manifest = readManifest(resolved.repoPath);
703
+ const serviceKeys = Object.keys(manifest?.services ?? {});
704
+ if (!serviceKeys.includes(resolved.subsystem)) {
705
+ if (serviceKeys.length === 1) {
706
+ return { ...resolved, subsystem: serviceKeys[0] };
707
+ }
708
+ if (manifest?.entrypoint && serviceKeys.includes(manifest.entrypoint)) {
709
+ return { ...resolved, subsystem: manifest.entrypoint };
710
+ }
711
+ }
712
+ }
713
+
714
+ return resolved;
715
+ }
716
+
717
+ /** Return all services in the focused (or named) workspace. */
718
+ export function getWorkspaceServices(
719
+ workspaceName?: string,
720
+ ): ResolvedService[] {
721
+ const ws = workspaceName
722
+ ? getWorkspaceByName(workspaceName)
723
+ : getActiveWorkspace();
724
+ if (!ws) return [];
725
+ const catalog = readCatalog();
726
+ return listWorkspaceServices(ws.config, catalog, ws.name);
727
+ }
728
+
729
+ // ── Command execution ─────────────────────────────────────────────────────────
730
+
731
+ function commandDefToString(cmdDef: CommandDef): string {
732
+ return typeof cmdDef === "string" ? cmdDef : cmdDef.cmd;
733
+ }
734
+
735
+ function isLegacyOpctlKillCommand(cmdString: string): boolean {
736
+ return /^opctl\s+kill\s*$/.test(cmdString.trim());
737
+ }
738
+
739
+ function inferOpctlContainerName(resolved: ResolvedService): string {
740
+ return resolved.repoName || path.basename(resolved.repoPath);
741
+ }
742
+
743
+ /**
744
+ * Whether `resolved` speaks for the repo's MAIN container for teardown
745
+ * purposes: a bare repo address, a single-service repo, a repo with no
746
+ * declared entrypoint, or the entrypoint subsystem itself.
747
+ *
748
+ * Auxiliary subsystems (files-api/s3, links-api/pg, …) are thin
749
+ * wrappers around shared infra or one-shot bootstrap ops. By opctl convention
750
+ * the container named exactly for the repo ("files-api") belongs to the
751
+ * entrypoint, so an auxiliary down must never claim it — that is how downing
752
+ * `files-api/s3` from one workspace SIGKILLed the files-api API that
753
+ * another workspace was running.
754
+ */
755
+ function ownsRepoContainers(
756
+ resolved: Pick<ResolvedService, "address" | "repoPath" | "subsystem">,
757
+ ): boolean {
758
+ if (!resolved.address.includes("/")) return true;
759
+ const manifest = readManifest(resolved.repoPath);
760
+ const serviceKeys = Object.keys(manifest?.services ?? {});
761
+ if (serviceKeys.length <= 1) return true;
762
+ const entry = manifest?.entrypoint;
763
+ if (!entry) return true; // no declared entrypoint — keep repo-wide behavior
764
+ return resolved.subsystem === entry;
765
+ }
766
+
767
+ /**
768
+ * The container base-names to seed a daemon record with at spawn time.
769
+ *
770
+ * Repo-owning addresses get the full manifest declaration; auxiliary
771
+ * subsystems only get names with affinity to themselves, so an s3/pg/redis
772
+ * record never claims the repo's main container and can never arm a
773
+ * repo-wide kill through the registry candidate set.
774
+ */
775
+ function spawnContainerCandidates(
776
+ resolved: Pick<
777
+ ResolvedService,
778
+ "address" | "repoPath" | "subsystem" | "repoName"
779
+ >,
780
+ ): string[] {
781
+ const declared = devOpContainerNames(resolved.repoPath);
782
+ if (ownsRepoContainers(resolved)) return declared;
783
+ return declared.filter(
784
+ (n) =>
785
+ n === resolved.subsystem ||
786
+ n === `${resolved.repoName}-${resolved.subsystem}` ||
787
+ n.endsWith(`-${resolved.subsystem}`),
788
+ );
789
+ }
790
+
791
+ /**
792
+ * The opctl container base-names to take down for a service: candidate names
793
+ * (captured at spawn, declared in the repo's dev/up op, or the repo name)
794
+ * intersected with what is actually running now (`opctl container ls`).
795
+ *
796
+ * Matching live state means no spawn-time timing guesswork and a natural no-op
797
+ * when nothing is running. Returns [] when opctl is unavailable or nothing
798
+ * matches (so the caller falls back to the declared `down` command — e.g.
799
+ * infra-stack's nested-op containers, or a non-opctl service).
800
+ *
801
+ * Auxiliary subsystems (see `ownsRepoContainers`) never target the repo's main
802
+ * container: their down must not take out the entrypoint daemon that other
803
+ * addresses/workspaces own.
804
+ */
805
+ function resolveOpctlDownTargets(resolved: ResolvedService): string[] {
806
+ if (!isOpctlAvailable()) return [];
807
+ ensureDaemonRegistryLoaded();
808
+ const repoWide = ownsRepoContainers(resolved);
809
+ const candidates = new Set<string>(
810
+ [
811
+ ...(_daemonRegistry.get(resolved.address)?.containers ?? []),
812
+ ...devOpContainerNames(resolved.repoPath),
813
+ resolved.repoName,
814
+ ].filter(Boolean),
815
+ );
816
+ // A manifest-less resolve keeps the BARE address ("shop"), while daemons are
817
+ // registered under subsystem addresses ("shop/api") — so also take any
818
+ // registry entry whose repo matches. Without this, a fresh checkout (clone
819
+ // not yet created) can never down a service the previous checkout started.
820
+ // Only repo-wide addresses may sweep in sibling records: an auxiliary
821
+ // subsystem must not inherit the entrypoint's containers.
822
+ if (repoWide) {
823
+ for (const [addr, rec] of _daemonRegistry) {
824
+ if (
825
+ addr === resolved.repoName ||
826
+ addr.startsWith(`${resolved.repoName}/`)
827
+ ) {
828
+ for (const c of rec.containers ?? []) candidates.add(c);
829
+ }
830
+ }
831
+ }
832
+ const targets = new Set<string>();
833
+ // Conventional container naming: a repo's opctl containers are named either
834
+ // exactly for the repo ("files-api") or repo-prefixed
835
+ // ("shop-api" for shop, "jobs-runtime-small"). The prefix rule only
836
+ // claims a base no OTHER catalog repo claims more precisely, so `down shop`
837
+ // can never take shop-web's container. This is what makes `down` work with
838
+ // no manifest and no clone on disk — down is conventional by contract.
839
+ const otherRepos = Object.keys(readCatalog()).filter(
840
+ (r) => r !== resolved.repoName,
841
+ );
842
+ const claimedByOther = (base: string): boolean =>
843
+ otherRepos.some((o) => base === o || base.startsWith(`${o}-`));
844
+ // An auxiliary subsystem only claims bases with NAME AFFINITY to itself
845
+ // ("pg", "shop-postgres", "stack-local-localstack"), never the repo-named or
846
+ // other entrypoint containers its repo-wide candidate set (manifest names,
847
+ // legacy registry records) would otherwise sweep in.
848
+ const subsystemAffine = (base: string): boolean =>
849
+ base === resolved.subsystem ||
850
+ base === `${resolved.repoName}-${resolved.subsystem}` ||
851
+ base.endsWith(`-${resolved.subsystem}`);
852
+ for (const c of listRunningOpctlContainers()) {
853
+ const matched =
854
+ candidates.has(c.base) ||
855
+ c.base === resolved.repoName ||
856
+ (c.base.startsWith(`${resolved.repoName}-`) && !claimedByOther(c.base));
857
+ if (!matched) continue;
858
+ if (!repoWide && !subsystemAffine(c.base)) continue;
859
+ targets.add(c.base);
860
+ }
861
+ return [...targets];
862
+ }
863
+
864
+ /**
865
+ * True when any of the service's opctl containers are currently running —
866
+ * the run-state signal `restart` uses to skip a pointless (and possibly
867
+ * error-prone) down phase on an already-stopped service.
868
+ */
869
+ export function hasRunningOpctlContainers(resolved: ResolvedService): boolean {
870
+ return resolveOpctlDownTargets(resolved).length > 0;
871
+ }
872
+
873
+ export function rewriteLegacyOpctlKillCommandForContainer(
874
+ command: string,
875
+ cmdString: string,
876
+ containerName: string,
877
+ onLine?: (line: string) => void,
878
+ ): string {
879
+ if (command !== "down" || !isLegacyOpctlKillCommand(cmdString)) {
880
+ return cmdString;
881
+ }
882
+
883
+ const rewritten = `opctl container delete --label container-name=${shellQuote(containerName)}`;
884
+ onLine?.(
885
+ `legacy "opctl kill" mapped to opctl container delete for ${containerName}`,
886
+ );
887
+ return rewritten;
888
+ }
889
+
890
+ function rewriteLegacyOpctlKillCommand(
891
+ command: string,
892
+ cmdString: string,
893
+ resolved: ResolvedService,
894
+ onLine?: (line: string) => void,
895
+ ): string {
896
+ return rewriteLegacyOpctlKillCommandForContainer(
897
+ command,
898
+ cmdString,
899
+ inferOpctlContainerName(resolved),
900
+ onLine,
901
+ );
902
+ }
903
+
904
+ function isOpctlContainerDownCommand(cmd: string): boolean {
905
+ return /\bopctl\s+container\s+down\b/i.test(cmd);
906
+ }
907
+
908
+ /** Base names from `opctl container down <base> [--force]` (and `;`-chained forms). */
909
+ function opctlDownBasesFromCommand(cmd: string): string[] {
910
+ const bases: string[] = [];
911
+ for (const part of cmd.split(/;|&&/)) {
912
+ const m = part.match(/opctl\s+container\s+down\s+(\S+)/i);
913
+ if (!m) continue;
914
+ bases.push(m[1].replace(/^['"]|['"]$/g, ""));
915
+ }
916
+ return bases;
917
+ }
918
+
919
+ export function shouldDetachServiceCommand(
920
+ command: string,
921
+ cmdDef: CommandDef,
922
+ ): boolean {
923
+ return command === "down" || command === "setup"
924
+ ? false
925
+ : typeof cmdDef === "string"
926
+ ? true
927
+ : // restart ends with a long-running up, so it detaches unless the
928
+ // command says it finishes on its own (`detachable: false`).
929
+ (cmdDef.detachable ?? true);
930
+ }
931
+
932
+ export function isInteractiveServiceCommand(cmdDef: CommandDef): boolean {
933
+ return typeof cmdDef === "string" ? false : (cmdDef.interactive ?? false);
934
+ }
935
+
936
+ /**
937
+ * Runtime for a service command: a real terminal tab, or a backgrounded daemon
938
+ * whose output is streamed. Only starting commands can run in a tab — a `down`
939
+ * in a tab would close before you could read it.
940
+ *
941
+ * `DEN_FORCE_TABS` (the CLI's `--tabs` / `--no-tabs`) wins over the
942
+ * `cli_tabs` preference. Shared by the CLI and the web exec routes so the
943
+ * preference means the same thing wherever an up is triggered from.
944
+ */
945
+ export function resolveServiceRuntime(command: string): "daemon" | "tab" {
946
+ if (command !== "up" && command !== "restart") return "daemon";
947
+ if (process.env.DEN_FORCE_TABS === "1") return "tab";
948
+ if (process.env.DEN_FORCE_TABS === "0") return "daemon";
949
+ return readUserConfig().cli_tabs ? "tab" : "daemon";
950
+ }
951
+
952
+ export interface RunCommandOptions {
953
+ /** Service address: "shop/api" or "tasks-runner" */
954
+ address: string;
955
+ /** Command name from .den.yml services.<subsystem>.commands.local: "up", "down", "restart" */
956
+ command: string;
957
+ onLine: (line: string) => void;
958
+ onClose: (exitCode: number) => void;
959
+ env?: NodeJS.ProcessEnv;
960
+ runtime?: "daemon" | "tab";
961
+ /**
962
+ * Workspace to resolve/own this command against. Defaults to focus.
963
+ * Must be active when provided by CLI/API callers (enforced upstream).
964
+ */
965
+ workspace?: string;
966
+ /**
967
+ * For `down`: stop the instance even when other workspaces still own it.
968
+ */
969
+ force?: boolean;
970
+ /**
971
+ * Fires when the SERVICE process itself exits (daemon mode). onClose only
972
+ * tracks the streaming tail, so without this a `make up` that dies AFTER
973
+ * the detach window is invisible to fail-fast callers.
974
+ */
975
+ onServiceExit?: (exitCode: number) => void;
976
+ }
977
+
978
+ export interface RunCommandResult {
979
+ proc: ChildProcess;
980
+ /** True when the command is expected to run indefinitely (e.g. `up`). */
981
+ detachable: boolean;
982
+ /**
983
+ * True when the command requires interactive terminal access (HITL).
984
+ * The process inherits the parent's stdin/stdout/stderr — no output is
985
+ * streamed via onLine. The caller must suspend any spinner before the
986
+ * command runs.
987
+ */
988
+ interactive: boolean;
989
+ /**
990
+ * For detachable daemon commands: path to the log file the service is
991
+ * writing to. Callers can surface this so the user knows where to look.
992
+ */
993
+ logPath?: string;
994
+ tab?: {
995
+ terminalApp: "iterm2" | "terminal";
996
+ tabId: string;
997
+ };
998
+ }
999
+
1000
+ /**
1001
+ * Resolve and execute a service command.
1002
+ * Returns the spawned child process and whether it is a long-running (detachable) command.
1003
+ * Detachable procs have already been unref()'d — the caller should not await their exit.
1004
+ */
1005
+ export function runServiceCommand(
1006
+ options: RunCommandOptions,
1007
+ ): RunCommandResult {
1008
+ const {
1009
+ address,
1010
+ command,
1011
+ onLine,
1012
+ onClose,
1013
+ env: optionEnv,
1014
+ onServiceExit,
1015
+ force = false,
1016
+ } = options;
1017
+ const runtime = options.runtime ?? "daemon";
1018
+ const workspace = options.workspace ?? getFocusedWorkspaceName() ?? "default";
1019
+
1020
+ // Inject den-resolved shared env (the org config's service_env) under any explicit
1021
+ // per-call env, so consumer repos get correct locations regardless of where
1022
+ // their checkout lives. buildServiceEnv never overrides caller-set values.
1023
+ const sharedEnv = buildServiceEnv();
1024
+ const env: NodeJS.ProcessEnv = { ...sharedEnv, ...optionEnv };
1025
+
1026
+ // The tab variant carries only the den-RESOLVED values, never the caller's
1027
+ // env: `getSudoEnv()`'s PATH points at a private askpass dir that must not
1028
+ // leak into a user's shell, and a tab already has the user's own PATH.
1029
+ const tabEnv: Record<string, string> = {};
1030
+ for (const [key, value] of Object.entries(sharedEnv)) {
1031
+ if (typeof value === "string" && value) tabEnv[key] = value;
1032
+ }
1033
+
1034
+ const dummy = (): RunCommandResult => ({
1035
+ proc: new EventEmitter() as unknown as ChildProcess,
1036
+ detachable: false,
1037
+ interactive: false,
1038
+ });
1039
+
1040
+ // NEVER invoke onClose synchronously (zalgo): callers register the callback
1041
+ // in the same expression that receives our return value, so a sync call
1042
+ // observes their `proc` binding in TDZ — that ReferenceError crashed the
1043
+ // whole web server when workspace Down hit a system with nothing to stop.
1044
+ const closeAsync = (code: number): void => {
1045
+ setImmediate(() => onClose(code));
1046
+ };
1047
+
1048
+ const resolved = resolveService(address, workspace);
1049
+ if (!resolved) {
1050
+ onLine(
1051
+ `Error: service "${address}" not found in catalog or workspace "${workspace}"`,
1052
+ );
1053
+ closeAsync(1);
1054
+ return dummy();
1055
+ }
1056
+
1057
+ const { repoPath, subsystem } = resolved;
1058
+
1059
+ // ── Up: share-if-identical / hard-block on conflict ─────────────────────────
1060
+ if (command === "up" || command === "restart") {
1061
+ let existing = findDaemonByAddress(resolved.address);
1062
+ // A registry record whose containers all died (killed, crashed, reaped)
1063
+ // must not gate the start: attaching to it would report "already running"
1064
+ // while nothing runs, and a mismatched-identity corpse would raise a
1065
+ // conflict against a daemon that no longer exists. Drop it and start fresh.
1066
+ if (existing && isDaemonVerifiablyDead(existing)) {
1067
+ onLine(
1068
+ `stale daemon record for ${resolved.address} — no running containers; starting fresh`,
1069
+ );
1070
+ unregisterDaemon(resolved.address);
1071
+ existing = undefined;
1072
+ }
1073
+ if (
1074
+ existing &&
1075
+ sameDaemonIdentity(existing, resolved.repoPath, resolved.branch)
1076
+ ) {
1077
+ attachDaemonOwner(resolved.address, workspace);
1078
+ const owners = findDaemonByAddress(resolved.address)?.workspaces ?? [
1079
+ workspace,
1080
+ ];
1081
+ onLine(
1082
+ `already running (shared) — owners: [${owners.join(", ")}] at ${resolved.repoPath}`,
1083
+ );
1084
+ // restart still needs to bounce; only short-circuit pure `up`.
1085
+ if (command === "up") {
1086
+ closeAsync(0);
1087
+ return dummy();
1088
+ }
1089
+ // A restart interrupts every owner, which is exactly what `down`'s
1090
+ // refcount refuses to do silently. Restarting a shared service is still
1091
+ // allowed (it comes back), but the other owners must be named.
1092
+ const coOwners = activeDaemonOwners({ workspaces: owners }).filter(
1093
+ (w) => w !== workspace,
1094
+ );
1095
+ if (coOwners.length > 0) {
1096
+ onLine(
1097
+ `Warning: restarting a shared instance — this interrupts [${coOwners.join(", ")}]`,
1098
+ );
1099
+ }
1100
+ } else {
1101
+ const conflict = findDaemonConflict(resolved);
1102
+ if (conflict) {
1103
+ onLine(`Error: ${conflict.message}`);
1104
+ closeAsync(1);
1105
+ return dummy();
1106
+ }
1107
+ const adopted = adoptRunningContainers(resolved, workspace);
1108
+ if (adopted.length > 0) {
1109
+ onLine(
1110
+ `adopted already-running containers (${adopted.join(", ")}) — owners: [${workspace}] at ${resolved.repoPath}`,
1111
+ );
1112
+ // restart still needs to bounce; only short-circuit pure `up`.
1113
+ if (command === "up") {
1114
+ closeAsync(0);
1115
+ return dummy();
1116
+ }
1117
+ }
1118
+ }
1119
+ }
1120
+
1121
+ // ── Down: refcount detach unless last owner or --force ──────────────────────
1122
+ // Only ACTIVE owners count: a deactivated workspace can't stop the service
1123
+ // itself, so letting it hold a reference would wedge the instance.
1124
+ if (command === "down" && !force) {
1125
+ const existing = findDaemonByAddress(resolved.address);
1126
+ const owners = activeDaemonOwners(existing);
1127
+ if (owners.length > 0 && !owners.includes(workspace)) {
1128
+ onLine(
1129
+ `workspace "${workspace}" does not own running "${resolved.address}" (owners: [${owners.join(", ")}]) — use \`--force\` to stop it anyway`,
1130
+ );
1131
+ closeAsync(0);
1132
+ return dummy();
1133
+ }
1134
+ if (owners.length > 1 && owners.includes(workspace)) {
1135
+ detachDaemonOwner(resolved.address, workspace);
1136
+ const remaining = owners.filter((w) => w !== workspace);
1137
+ onLine(
1138
+ `detached workspace "${workspace}" — still running for [${remaining.join(", ")}]`,
1139
+ );
1140
+ closeAsync(0);
1141
+ return dummy();
1142
+ }
1143
+ }
1144
+
1145
+ // Down via opctl: stop the service's running containers directly, identified
1146
+ // from live `opctl container ls` ∪ docker `opctl_<base>_<hash>` names — no
1147
+ // declared down command (or even a manifest) required. Falls through to the
1148
+ // manifest-defined down only when no opctl container matches (infra-stack's
1149
+ // nested-op containers, non-opctl services, or nothing running).
1150
+ if (command === "down") {
1151
+ const targets = resolveOpctlDownTargets(resolved);
1152
+ if (targets.length > 0) {
1153
+ const downCmd = targets
1154
+ .map((name) => `opctl container down ${shellQuote(name)} --force`)
1155
+ .join("; ");
1156
+ onLine(`opctl container down: ${targets.join(", ")}`);
1157
+ const proc = spawnStreaming(
1158
+ downCmd,
1159
+ [],
1160
+ // spawnStreaming uses this as the COMPLETE child env, so process.env
1161
+ // (notably PATH) must be included or `opctl` isn't found and every
1162
+ // teardown exits 1 — leaving every container running.
1163
+ { ...process.env, ...env },
1164
+ onLine,
1165
+ (exitCode) => {
1166
+ unregisterDaemon(resolved.address);
1167
+ const existing = lookupTab(resolved.address);
1168
+ if (existing) {
1169
+ try {
1170
+ closeTab(existing.tabId, existing.terminalApp);
1171
+ forgetTab(resolved.address);
1172
+ } catch {
1173
+ /* best-effort tab cleanup */
1174
+ }
1175
+ }
1176
+ // opctl can exit 1 even when the teardown succeeded — judge the
1177
+ // OUTCOME, not the exit code: if none of the service's containers
1178
+ // are running anymore, the down worked. (Everything downstream —
1179
+ // restart's verify gate, the ✓/✗ in the UI, CLI down — keys off
1180
+ // this close code.)
1181
+ //
1182
+ // When containers ARE still running, fall back to `docker rm -f`:
1183
+ // opctl re-elevates with `sudo -E`, which fails on sudoers that grant
1184
+ // NOPASSWD for opctl without SETENV ("sorry, you are not allowed to
1185
+ // preserve the environment"). Docker socket access needs no sudo.
1186
+ if (exitCode !== 0) {
1187
+ const remaining = resolveOpctlDownTargets(resolved);
1188
+ if (remaining.length === 0) {
1189
+ onLine(
1190
+ `opctl exited ${exitCode} but the containers are stopped — down succeeded`,
1191
+ );
1192
+ onClose(0);
1193
+ return;
1194
+ }
1195
+ onLine(
1196
+ `opctl container down failed (exit ${exitCode}); falling back to docker for: ${remaining.join(", ")}`,
1197
+ );
1198
+ const { stopped, failed } = forceStopOpctlContainers(remaining);
1199
+ for (const name of stopped) onLine(`docker rm -f ${name}`);
1200
+ if (failed.length > 0) {
1201
+ onLine(`docker fallback failed for: ${failed.join(", ")}`);
1202
+ } else if (resolveOpctlDownTargets(resolved).length === 0) {
1203
+ onLine(
1204
+ "docker fallback stopped remaining containers — down succeeded",
1205
+ );
1206
+ onClose(0);
1207
+ return;
1208
+ }
1209
+ }
1210
+ onClose(exitCode);
1211
+ },
1212
+ // The clone may not exist yet (fresh checkout, managed repo
1213
+ // never pulled) — a nonexistent cwd makes spawn fail with ENOENT and
1214
+ // the teardown "fails" while every container keeps running. opctl
1215
+ // doesn't need the repo dir to stop containers by name.
1216
+ fs.existsSync(repoPath) ? repoPath : undefined,
1217
+ false,
1218
+ );
1219
+ return { proc, detachable: false, interactive: false };
1220
+ }
1221
+ }
1222
+
1223
+ const manifest = readManifest(repoPath);
1224
+ if (!manifest) {
1225
+ // Down stays conventional even with no manifest on disk (fresh checkout,
1226
+ // clone not yet created): no matching containers + nothing declared means
1227
+ // there is nothing to stop — success, same contract as the declared-down
1228
+ // fallthrough below.
1229
+ if (command === "down") {
1230
+ onLine(
1231
+ `nothing to stop (no matching opctl containers; no manifest at ${repoPath})`,
1232
+ );
1233
+ closeAsync(0);
1234
+ return dummy();
1235
+ }
1236
+ onLine(`Error: could not read ${product().manifestYml} in ${repoPath}`);
1237
+ closeAsync(1);
1238
+ return dummy();
1239
+ }
1240
+
1241
+ const svcCommands = effectiveLocalCommands(
1242
+ manifest,
1243
+ subsystem,
1244
+ resolved.repoName,
1245
+ workspace,
1246
+ );
1247
+ let cmdDef = svcCommands[command];
1248
+ const requestedCmdDef = cmdDef;
1249
+
1250
+ // `restart` fallback: synthesise `make down && make up` when no explicit
1251
+ // restart command exists but both down and up are defined.
1252
+ if (!cmdDef && command === "restart") {
1253
+ const downDef = svcCommands["down"];
1254
+ const upDef = svcCommands["up"];
1255
+ if (downDef && upDef) {
1256
+ const downCmd = rewriteLegacyOpctlKillCommand(
1257
+ "down",
1258
+ commandDefToString(downDef),
1259
+ resolved,
1260
+ );
1261
+ const upCmd = commandDefToString(upDef);
1262
+ cmdDef = `${downCmd} && ${upCmd}`;
1263
+ }
1264
+ }
1265
+
1266
+ if (!cmdDef) {
1267
+ // Down is opctl-conventional: when no container is running AND the manifest
1268
+ // declares no down command, there is simply nothing to stop — that's a
1269
+ // successful down, not an error (keeps the ⋮ menu / workspace Down safe to
1270
+ // offer on every system).
1271
+ if (command === "down") {
1272
+ onLine("nothing to stop (no running opctl containers, no down command)");
1273
+ closeAsync(0);
1274
+ return dummy();
1275
+ }
1276
+ onLine(
1277
+ `Error: command "${command}" not found for service "${address}" (subsystem "${subsystem}") in local environment`,
1278
+ );
1279
+ closeAsync(1);
1280
+ return dummy();
1281
+ }
1282
+
1283
+ const cmdString = rewriteLegacyOpctlKillCommand(
1284
+ command,
1285
+ commandDefToString(cmdDef),
1286
+ resolved,
1287
+ onLine,
1288
+ );
1289
+
1290
+ // Manifest downs that are just `opctl container down <name>` must not be
1291
+ // spawned raw: opctl re-elevates with `sudo -E` and fails on sudoers without
1292
+ // SETENV even when there is nothing to stop — which is why files-api/api
1293
+ // reported ✗ while connections/scribe's shell scripts reported ✓ (and left
1294
+ // containers running when discovery only trusted `opctl container ls`).
1295
+ // Nothing running → success. Something running → the resilient path above
1296
+ // already handles it via docker-aware discovery; if we somehow get here with
1297
+ // targets, route through the same docker fallback instead of a naked opctl.
1298
+ if (command === "down" && isOpctlContainerDownCommand(cmdString)) {
1299
+ // Bases the manifest names explicitly are the repo author's instruction and
1300
+ // are always honoured; the implicit repo-name addition only applies to
1301
+ // repo-owning addresses — an auxiliary subsystem must not widen its down
1302
+ // to the entrypoint's container.
1303
+ const bases = [
1304
+ ...new Set([
1305
+ ...opctlDownBasesFromCommand(cmdString),
1306
+ ...(ownsRepoContainers(resolved) ? [resolved.repoName] : []),
1307
+ ]),
1308
+ ];
1309
+ const running = listRunningOpctlContainers()
1310
+ .map((c) => c.base)
1311
+ .filter((base) => bases.includes(base));
1312
+ if (running.length === 0) {
1313
+ onLine(
1314
+ `nothing to stop (no running opctl containers for ${bases.join(", ")})`,
1315
+ );
1316
+ unregisterDaemon(resolved.address);
1317
+ closeAsync(0);
1318
+ return dummy();
1319
+ }
1320
+ onLine(
1321
+ `opctl container down (manifest): ${running.join(", ")} — using docker-resilient path`,
1322
+ );
1323
+ const { stopped, failed } = forceStopOpctlContainers(running);
1324
+ for (const name of stopped) onLine(`docker rm -f ${name}`);
1325
+ if (failed.length > 0) {
1326
+ onLine(`docker stop failed for: ${failed.join(", ")}`);
1327
+ closeAsync(1);
1328
+ return dummy();
1329
+ }
1330
+ unregisterDaemon(resolved.address);
1331
+ closeAsync(0);
1332
+ return dummy();
1333
+ }
1334
+
1335
+ // down/setup always run to completion — never spawn as background daemons.
1336
+ // restart detaches unless its command is marked `detachable: false`.
1337
+ // All other string commands (typically "up") default to detachable.
1338
+ const detachable = shouldDetachServiceCommand(command, cmdDef);
1339
+ const interactive = isInteractiveServiceCommand(cmdDef);
1340
+
1341
+ if (interactive) {
1342
+ // HITL command — inherit the full TTY; onLine is not called.
1343
+ const proc = spawnInteractive(
1344
+ cmdString,
1345
+ { ...process.env, ...env },
1346
+ onClose,
1347
+ repoPath,
1348
+ );
1349
+ return { proc, detachable: false, interactive: true };
1350
+ }
1351
+
1352
+ const closeExistingTab = (): void => {
1353
+ const existing = lookupTab(resolved.address);
1354
+ if (!existing) return;
1355
+ try {
1356
+ closeTab(existing.tabId, existing.terminalApp);
1357
+ forgetTab(resolved.address);
1358
+ } catch (error) {
1359
+ onLine(`Warning: ${(error as Error).message}`);
1360
+ }
1361
+ };
1362
+
1363
+ const openCommandInTab = (commandToOpen: string): RunCommandResult => {
1364
+ try {
1365
+ const terminalApp = resolveTerminalApp(readUserConfig().terminal);
1366
+ const tab = openTab({
1367
+ cmd: commandToOpen,
1368
+ cwd: repoPath,
1369
+ label: resolved.address,
1370
+ terminalApp,
1371
+ env: tabEnv,
1372
+ });
1373
+ recordTab(resolved.address, {
1374
+ ...tab,
1375
+ cwd: repoPath,
1376
+ command: commandToOpen,
1377
+ });
1378
+ onLine(
1379
+ `-> opened ${tab.terminalApp === "iterm2" ? "iTerm2" : "Terminal"} tab for ${resolved.address}`,
1380
+ );
1381
+ closeAsync(0);
1382
+ return {
1383
+ proc: new EventEmitter() as unknown as ChildProcess,
1384
+ detachable: false,
1385
+ interactive: false,
1386
+ tab,
1387
+ };
1388
+ } catch (error) {
1389
+ onLine(`Error: ${(error as Error).message}`);
1390
+ closeAsync(1);
1391
+ return dummy();
1392
+ }
1393
+ };
1394
+
1395
+ if (runtime === "tab" && command === "up") {
1396
+ return openCommandInTab(cmdString);
1397
+ }
1398
+
1399
+ if (runtime === "tab" && command === "restart") {
1400
+ closeExistingTab();
1401
+ if (requestedCmdDef) return openCommandInTab(cmdString);
1402
+
1403
+ const downDef = svcCommands["down"];
1404
+ const upDef = svcCommands["up"];
1405
+ if (downDef && upDef) {
1406
+ const downCmd = rewriteLegacyOpctlKillCommand(
1407
+ "down",
1408
+ commandDefToString(downDef),
1409
+ resolved,
1410
+ );
1411
+ const upCmd = commandDefToString(upDef);
1412
+ const proc = spawnStreaming(
1413
+ downCmd,
1414
+ [],
1415
+ { ...process.env, ...env },
1416
+ onLine,
1417
+ (exitCode) => {
1418
+ if (exitCode !== 0) {
1419
+ onClose(exitCode);
1420
+ return;
1421
+ }
1422
+ const result = openCommandInTab(upCmd);
1423
+ void result;
1424
+ },
1425
+ repoPath,
1426
+ false,
1427
+ );
1428
+ return { proc, detachable: false, interactive: false };
1429
+ }
1430
+ }
1431
+
1432
+ // Detachable commands (up / restart) run as daemons: output is written to a
1433
+ // log file so the service survives den restarts. The returned proc is the
1434
+ // `tail` subprocess used for streaming — kill it to stop streaming.
1435
+ const logPath = detachable
1436
+ ? getServiceLogPath(resolved.repoName, resolved.subsystem)
1437
+ : undefined;
1438
+ if (detachable && logPath) {
1439
+ onLine(`→ log: ${logPath}`);
1440
+ }
1441
+
1442
+ // Track long-running services so the shutdown interceptor can offer to stop them.
1443
+ // Snapshot running containers first so wrappedOnClose can diff what this run
1444
+ // actually spawned and refine the record.
1445
+ const preSpawnContainers =
1446
+ detachable && logPath && command !== "down" && isOpctlAvailable()
1447
+ ? listRunningOpctlContainers()
1448
+ : null;
1449
+ if (detachable && logPath && command !== "down") {
1450
+ registerDaemon({
1451
+ address: resolved.address,
1452
+ repoName: resolved.repoName,
1453
+ subsystem: resolved.subsystem,
1454
+ repoPath: resolved.repoPath,
1455
+ branch: resolved.branch,
1456
+ workspaces: [workspace],
1457
+ logPath,
1458
+ startedAt: new Date(),
1459
+ // Seed with the service's declared opctl container names (affinity-
1460
+ // filtered for auxiliary subsystems) so `down` can stop them via
1461
+ // `opctl container down` without a declared down command. Refined with
1462
+ // the actually-spawned containers when the streaming tail detaches.
1463
+ // (Live `opctl container ls` is still the source of truth at down time;
1464
+ // this just seeds the candidate set.)
1465
+ containers: spawnContainerCandidates(resolved),
1466
+ });
1467
+ }
1468
+
1469
+ // When a down command completes, remove from the registry. Scripted downs
1470
+ // (links-api's down.sh, etc.) can exit 0 while leaving opctl_<repo>_*
1471
+ // containers running — sweep those via docker so "✔ down" means stopped.
1472
+ const wrappedOnClose = (exitCode: number) => {
1473
+ if (command === "down") {
1474
+ const remaining = resolveOpctlDownTargets(resolved);
1475
+ let sweptClean = false;
1476
+ if (remaining.length > 0) {
1477
+ onLine(
1478
+ `down left running opctl containers — stopping via docker: ${remaining.join(", ")}`,
1479
+ );
1480
+ const { stopped, failed } = forceStopOpctlContainers(remaining);
1481
+ for (const name of stopped) onLine(`docker rm -f ${name}`);
1482
+ if (failed.length > 0) {
1483
+ onLine(`docker stop failed for: ${failed.join(", ")}`);
1484
+ onClose(1);
1485
+ return;
1486
+ }
1487
+ sweptClean = true;
1488
+ }
1489
+ // A non-zero down that we then verifiably swept clean IS a successful
1490
+ // teardown. A non-zero down with nothing to sweep is not: for a service
1491
+ // whose containers den can't see (non-opctl, or opctl unavailable),
1492
+ // "nothing left to stop" is an absence of evidence, not evidence the
1493
+ // teardown worked — reporting ✔ there would hide every real failure.
1494
+ const effective = exitCode === 0 || sweptClean ? 0 : exitCode;
1495
+ if (effective === 0) {
1496
+ unregisterDaemon(resolved.address);
1497
+ closeExistingTab();
1498
+ }
1499
+ onClose(effective);
1500
+ return;
1501
+ }
1502
+ // Ups fire onClose when the streaming tail detaches — by then the run's
1503
+ // containers usually exist, so record what actually appeared. Best-effort:
1504
+ // slow image builds may not have spawned yet; the declared seed remains.
1505
+ if (preSpawnContainers) {
1506
+ updateDaemonContainers(
1507
+ resolved.address,
1508
+ diffSpawnedContainers(preSpawnContainers, listRunningOpctlContainers()),
1509
+ );
1510
+ }
1511
+ onClose(exitCode);
1512
+ };
1513
+
1514
+ const proc = spawnStreaming(
1515
+ cmdString,
1516
+ [],
1517
+ { ...process.env, ...env },
1518
+ onLine,
1519
+ wrappedOnClose,
1520
+ repoPath,
1521
+ detachable,
1522
+ logPath,
1523
+ onServiceExit,
1524
+ );
1525
+ return { proc, detachable, interactive: false, logPath };
1526
+ }
1527
+
1528
+ // ── Health checks ─────────────────────────────────────────────────────────────
1529
+ // (implementation lives in health.ts — re-exported above)
1530
+
1531
+ /**
1532
+ * Check health for all services in the active workspace, in parallel.
1533
+ * Uses catalog-wide scan so mono-repo subsystems are shown individually.
1534
+ */
1535
+ export async function checkWorkspaceHealth(): Promise<ServiceHealthResult[]> {
1536
+ return checkCatalogHealth();
1537
+ }
1538
+
1539
+ // ── Catalog + status listing ──────────────────────────────────────────────────
1540
+
1541
+ export interface ServiceListEntry extends ServiceHealthResult {
1542
+ commandsAvailable: string[];
1543
+ }
1544
+
1545
+ /**
1546
+ * List all services in the active workspace with health and available commands.
1547
+ */
1548
+ export async function listServices(
1549
+ workspaceName?: string,
1550
+ ): Promise<ServiceListEntry[]> {
1551
+ const ws = workspaceName
1552
+ ? getWorkspaceByName(workspaceName)
1553
+ : getActiveWorkspace();
1554
+ if (!ws) return [];
1555
+ const catalog = readCatalog();
1556
+ // Modules and libraries have no runtime: no card, no health, no commands.
1557
+ const services = listWorkspaceServices(ws.config, catalog, ws.name).filter(
1558
+ (s) => hasRuntime(repoKind(s.repoPath)),
1559
+ );
1560
+ const healthResults = await Promise.all(
1561
+ services.map((s) => checkServiceHealth(s)),
1562
+ );
1563
+
1564
+ return healthResults.map((h, i) => {
1565
+ const resolved = services[i];
1566
+ let commandsAvailable: string[] = [];
1567
+ try {
1568
+ const manifest = readManifest(resolved.repoPath);
1569
+ commandsAvailable = manifest
1570
+ ? Object.keys(
1571
+ effectiveLocalCommands(
1572
+ manifest,
1573
+ resolved.subsystem,
1574
+ resolved.repoName,
1575
+ ws.name,
1576
+ ),
1577
+ )
1578
+ : [];
1579
+ } catch {
1580
+ /* ignore */
1581
+ }
1582
+
1583
+ return {
1584
+ ...h,
1585
+ commandsAvailable,
1586
+ };
1587
+ });
1588
+ }