@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,2154 @@
1
+ /**
2
+ * Workspace API route handlers.
3
+ *
4
+ * Extracted from server/index.ts. Owns all /api/workspaces/* endpoints
5
+ * including workspace CRUD, service management, available repos,
6
+ * dependency checks, workspace-level commands, and sync.
7
+ */
8
+
9
+ import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
10
+ import { loadApplicationTree } from "../../shared/environment-manager/load-applications.js";
11
+ import { localCommandsFor } from "../../shared/stacks/manifest-commands.js";
12
+ import * as http from "http";
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+ import { execSync } from "child_process";
16
+ import { readBody, sendJson, send400 } from "./http-helpers.js";
17
+ import type { ResolvedService } from "../../shared/types/index.js";
18
+ import type { EditorManager } from "./editor-manager.js";
19
+ import type { GitHubService } from "./github-service.js";
20
+ import { JETBRAINS_EDITORS } from "./editor-manager.js";
21
+ import {
22
+ readWorkspacesFile,
23
+ writeWorkspacesFile,
24
+ setActiveWorkspace,
25
+ activateWorkspace,
26
+ deactivateWorkspace,
27
+ setFocusedWorkspace,
28
+ getActiveWorkspace,
29
+ getWorkspaceByName,
30
+ isWorkspaceActive,
31
+ createWorkspace,
32
+ deleteWorkspaceConfig,
33
+ listWorkspaceServices,
34
+ resolveServiceSource,
35
+ parseAddress,
36
+ noAutostartRepos,
37
+ computeMissingDeps,
38
+ generateVSCodeWorkspace,
39
+ syncJetBrainsWorkspaceSymlinks,
40
+ DEN_SERVICES_DIR,
41
+ DEN_WORKSPACES_DIR,
42
+ } from "../../shared/environment-manager/workspaces.js";
43
+ import {
44
+ checkServiceHealth,
45
+ claimHealthyService,
46
+ runServiceCommand,
47
+ resolveService,
48
+ hasRunningOpctlContainers,
49
+ releaseWorkspaceDaemons,
50
+ } from "../../shared/environment-manager/service-runner.js";
51
+ import {
52
+ ensureManagedServiceClone,
53
+ ensureManagedServiceCloneNewBranch,
54
+ ensureDevManagedClone,
55
+ deleteWorkspaceDirectory,
56
+ renameWorkspaceDirectory,
57
+ checkManagedCloneBehind,
58
+ pullManagedClone,
59
+ runSetupHidden,
60
+ } from "../../shared/environment-manager/workspace-manager.js";
61
+ import {
62
+ expandSameRepoWaitDeps,
63
+ getWaitDeps,
64
+ } from "../../shared/environment-manager/dag.js";
65
+ import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
66
+ import {
67
+ deriveStartTargets,
68
+ filterClosureForStart,
69
+ orderForUp,
70
+ } from "../../shared/environment-manager/derivation/derived-up.js";
71
+ import { readManifest } from "../../shared/manifest/read-manifest.js";
72
+ import {
73
+ checkRepoSecrets,
74
+ describeSecretsCheck,
75
+ seedSecretsFromSibling,
76
+ } from "../../shared/environment-manager/secrets-check.js";
77
+ import { serviceEnvProviderRepos } from "../../shared/environment-manager/service-env.js";
78
+ import { depService } from "../../shared/manifest/deps.js";
79
+ import { waitForReady } from "../../shared/environment-manager/readiness.js";
80
+ import {
81
+ readCatalog,
82
+ resolveRepoGhSlug,
83
+ upsertCatalogEntry,
84
+ } from "../../shared/environment-manager/catalog.js";
85
+ import { getReposRoot, readUserConfig } from "../../shared/den-config.js";
86
+ import { getDistRoot } from "../../shared/paths.js";
87
+ import { getSudoEnv, prewarmSudo } from "./sudo-store.js";
88
+ import {
89
+ GIT_REMOTE_TIMEOUT_MS,
90
+ DETACH_WINDOW_MS,
91
+ } from "../../shared/constants.js";
92
+
93
+ import { product } from "../../shared/product.js";
94
+ // Anchored to the den root, NOT process.cwd(): ws.directory values are
95
+ // stored relative to this and written back via path.relative below — a
96
+ // cwd-started-elsewhere server would resolve and persist garbage paths.
97
+ const ROOT = getDistRoot();
98
+
99
+ /**
100
+ * Handle all /api/workspaces/* and /api/workspace/* routes.
101
+ *
102
+ * Same contract as the original inline handlers: uses return sendJson/send400
103
+ * to end the response. Caller checks res.writableEnded to know if handled.
104
+ */
105
+ export async function handleWorkspaceRoutes(
106
+ req: http.IncomingMessage,
107
+ res: http.ServerResponse,
108
+ pathname: string,
109
+ url: URL,
110
+ editors: EditorManager,
111
+ github: GitHubService,
112
+ ): Promise<void> {
113
+ // ── Workspace cleanup helpers ─────────────────────────────────────────────
114
+ /**
115
+ * Compute which clones can be deleted when removing `wsName`.
116
+ *
117
+ * devManaged — repos den cloned under <reposRoot>/<wsName>/ for this ws
118
+ * denManaged — .den-services clones exclusively owned by this workspace
119
+ */
120
+ function computeCleanupTargets(wsName: string): {
121
+ devManaged: Array<{ address: string; cloneDir: string }>;
122
+ denManaged: Array<{ address: string; cloneDir: string }>;
123
+ } {
124
+ const wsFile = readWorkspacesFile();
125
+ const ws = wsFile.workspaces[wsName];
126
+ if (!ws) return { devManaged: [], denManaged: [] };
127
+
128
+ const catalog = readCatalog();
129
+ const reposRoot = getReposRoot();
130
+ const wsDevRoot = path.join(reposRoot, wsName);
131
+
132
+ const otherWorkspaces = Object.entries(wsFile.workspaces)
133
+ .filter(([n]) => n !== wsName)
134
+ .map(([, cfg]) => cfg);
135
+
136
+ const devManaged: Array<{ address: string; cloneDir: string }> = [];
137
+ const denManaged: Array<{ address: string; cloneDir: string }> = [];
138
+
139
+ for (const [address, entry] of Object.entries(ws.services)) {
140
+ if (entry.mode === "dev-managed") {
141
+ // Only include repos den cloned into <reposRoot>/<wsName>/ — not
142
+ // repos the engineer linked from their own existing checkout.
143
+ if (entry.path?.startsWith(wsDevRoot) && fs.existsSync(entry.path)) {
144
+ devManaged.push({ address, cloneDir: entry.path });
145
+ }
146
+ } else if (entry.mode === "managed") {
147
+ const { repoName } = parseAddress(address, catalog);
148
+ const branch = entry.branch ?? "main";
149
+ const cloneDir = path.join(DEN_SERVICES_DIR, repoName, branch);
150
+ if (!fs.existsSync(cloneDir)) continue;
151
+ const usedByOther = otherWorkspaces.some((other) =>
152
+ Object.entries(other.services).some(([addr, e]) => {
153
+ if (e.mode !== "managed") return false;
154
+ const { repoName: r } = parseAddress(addr, catalog);
155
+ return r === repoName && (e.branch ?? "main") === branch;
156
+ }),
157
+ );
158
+ if (!usedByOther) denManaged.push({ address, cloneDir });
159
+ }
160
+ }
161
+
162
+ return { devManaged, denManaged };
163
+ }
164
+
165
+ // ── Editor capability sets ────────────────────────────────────────────────
166
+ const VSCODE_LIKE_EDITORS = new Set(["vscode", "cursor"]);
167
+
168
+ // ── API: workspaces ───────────────────────────────────────────────────────
169
+ if (pathname === "/api/workspaces") {
170
+ if (req.method === "POST") {
171
+ try {
172
+ const body = await readBody(req);
173
+ const { name, action } = JSON.parse(body) as {
174
+ name?: string;
175
+ /** activate | deactivate | focus | use (default: use = activate+focus) */
176
+ action?: "activate" | "deactivate" | "focus" | "use";
177
+ };
178
+ if (!name) return send400(res, "name required");
179
+ const act = action ?? "use";
180
+ let released: Awaited<ReturnType<typeof releaseWorkspaceDaemons>> = [];
181
+ switch (act) {
182
+ case "activate":
183
+ activateWorkspace(name);
184
+ break;
185
+ case "deactivate":
186
+ deactivateWorkspace(name);
187
+ // Release AFTER the workspace has left active[] so shared services
188
+ // see the post-deactivation owner set (design decision 5).
189
+ released = await releaseWorkspaceDaemons(name);
190
+ break;
191
+ case "focus":
192
+ setFocusedWorkspace(name);
193
+ break;
194
+ case "use":
195
+ setActiveWorkspace(name);
196
+ break;
197
+ default: {
198
+ const _exhaustive: never = act;
199
+ return send400(res, `unknown action: ${String(_exhaustive)}`);
200
+ }
201
+ }
202
+ const file = readWorkspacesFile();
203
+ return sendJson(res, {
204
+ ok: true,
205
+ active: file.active,
206
+ focus: file.focus,
207
+ released,
208
+ });
209
+ } catch (e) {
210
+ return send400(res, `Invalid request: ${(e as Error).message}`);
211
+ }
212
+ }
213
+ if (req.method === "DELETE") {
214
+ try {
215
+ const body = await readBody(req);
216
+ const { name, cleanupDirectory, cleanupDev, cleanupDen } = JSON.parse(
217
+ body,
218
+ ) as {
219
+ name?: string;
220
+ cleanupDirectory?: boolean;
221
+ cleanupDev?: boolean;
222
+ cleanupDen?: boolean;
223
+ };
224
+ if (!name) return send400(res, "name required");
225
+ if (name === "default")
226
+ return send400(res, "cannot delete the default workspace");
227
+
228
+ // Delete workspace directory (den-workspaces/<name>)
229
+ if (cleanupDirectory) {
230
+ try {
231
+ deleteWorkspaceDirectory(name);
232
+ } catch {
233
+ /* best-effort */
234
+ }
235
+ }
236
+
237
+ if (cleanupDev || cleanupDen) {
238
+ const { devManaged, denManaged } = computeCleanupTargets(name);
239
+
240
+ if (cleanupDev) {
241
+ // Dev-managed clones
242
+ for (const { cloneDir } of devManaged) {
243
+ try {
244
+ fs.rmSync(cloneDir, { recursive: true, force: true });
245
+ } catch {
246
+ /* best-effort */
247
+ }
248
+ }
249
+ }
250
+
251
+ if (cleanupDen) {
252
+ for (const { cloneDir } of denManaged) {
253
+ try {
254
+ fs.rmSync(cloneDir, { recursive: true, force: true });
255
+ } catch {
256
+ /* best-effort */
257
+ }
258
+ }
259
+ }
260
+ }
261
+
262
+ deleteWorkspaceConfig(name);
263
+ return sendJson(res, { ok: true });
264
+ } catch (e) {
265
+ return send400(res, `Invalid request: ${(e as Error).message}`);
266
+ }
267
+ }
268
+ // Augment with resolved absolute directories so the UI can open them.
269
+ // Priority: ws.directory field → den-workspaces/<name> → legacy reposRoot/<name>
270
+ // → reposRoot itself as last resort.
271
+ const wsFile = readWorkspacesFile();
272
+ const reposRoot = getReposRoot();
273
+ const resolvedDirs: Record<string, string> = {};
274
+ for (const [name, ws] of Object.entries(wsFile.workspaces)) {
275
+ if (ws.directory) {
276
+ resolvedDirs[name] = path.resolve(ROOT, ws.directory);
277
+ } else {
278
+ const gwDir = path.join(DEN_WORKSPACES_DIR, name);
279
+ const legacyDir = path.join(reposRoot, name);
280
+ if (fs.existsSync(gwDir)) {
281
+ resolvedDirs[name] = gwDir;
282
+ } else if (fs.existsSync(legacyDir)) {
283
+ resolvedDirs[name] = legacyDir;
284
+ } else {
285
+ resolvedDirs[name] = reposRoot;
286
+ }
287
+ }
288
+ }
289
+ const editor = readUserConfig().editor ?? "";
290
+ const catalog = readCatalog();
291
+ const codeWorkspacePaths: Record<string, string> = {};
292
+
293
+ if (VSCODE_LIKE_EDITORS.has(editor)) {
294
+ for (const [name, ws] of Object.entries(wsFile.workspaces)) {
295
+ try {
296
+ codeWorkspacePaths[name] = generateVSCodeWorkspace(name, ws, catalog);
297
+ } catch {
298
+ // Non-fatal: fall back to directory path in the UI
299
+ }
300
+ }
301
+ } else if (
302
+ JETBRAINS_EDITORS.has(editor) ||
303
+ editors.isTerminalEditor(editor)
304
+ ) {
305
+ // JetBrains and terminal editors both use the symlink-in-workspace-dir
306
+ // approach: open the workspace folder and each editor discovers repos
307
+ // via symlinks (JetBrains scans .git roots; vim/nvim/emacs traverse dirs).
308
+ syncJetBrainsWorkspaceSymlinks(wsFile);
309
+ }
310
+
311
+ return sendJson(res, { ...wsFile, resolvedDirs, codeWorkspacePaths });
312
+ }
313
+
314
+ // ── API: rename workspace ─────────────────────────────────────────────────
315
+ if (pathname === "/api/workspaces/rename" && req.method === "POST") {
316
+ try {
317
+ const { oldName, newName } = JSON.parse(await readBody(req)) as {
318
+ oldName?: string;
319
+ newName?: string;
320
+ };
321
+ if (!oldName || !newName)
322
+ return send400(res, "oldName and newName required");
323
+ // eslint-disable-next-line no-control-regex
324
+ if (/["'\\/<>\x00-\x1f]/.test(newName))
325
+ return send400(
326
+ res,
327
+ "newName must not contain quotes, slashes, or control characters",
328
+ );
329
+ const file = readWorkspacesFile();
330
+ if (!file.workspaces[oldName])
331
+ return send400(res, `Workspace "${oldName}" not found`);
332
+ if (file.workspaces[newName])
333
+ return send400(res, `Workspace "${newName}" already exists`);
334
+
335
+ // Rename workspace config entry
336
+ file.workspaces[newName] = file.workspaces[oldName];
337
+ delete file.workspaces[oldName];
338
+ file.active = file.active.map((n) => (n === oldName ? newName : n));
339
+ if (file.focus === oldName) file.focus = newName;
340
+
341
+ // Rename the local directory on disk if one exists, then update paths.
342
+ // Read location priority: ws.directory → den-workspaces/<oldName> → legacy reposRoot/<oldName>.
343
+ const ws = file.workspaces[newName];
344
+ const reposRoot = getReposRoot();
345
+ let oldDir: string;
346
+ if (ws.directory) {
347
+ oldDir = path.resolve(ROOT, ws.directory);
348
+ } else {
349
+ const gwOld = path.join(DEN_WORKSPACES_DIR, oldName);
350
+ const legacyOld = path.join(reposRoot, oldName);
351
+ oldDir = fs.existsSync(gwOld)
352
+ ? gwOld
353
+ : fs.existsSync(legacyOld)
354
+ ? legacyOld
355
+ : gwOld;
356
+ }
357
+ const newDir = renameWorkspaceDirectory(oldDir, newName);
358
+ if (newDir) {
359
+ if (ws.directory) {
360
+ ws.directory = path.relative(ROOT, newDir);
361
+ }
362
+ for (const svc of Object.values(ws.services ?? {})) {
363
+ const entry = svc as { path?: string };
364
+ if (
365
+ entry.path?.startsWith(oldDir + path.sep) ||
366
+ entry.path === oldDir
367
+ ) {
368
+ entry.path = newDir + entry.path.slice(oldDir.length);
369
+ }
370
+ }
371
+ }
372
+
373
+ writeWorkspacesFile(file);
374
+ return sendJson(res, { ok: true });
375
+ } catch (e) {
376
+ return send400(res, `Rename failed: ${(e as Error).message}`);
377
+ }
378
+ }
379
+
380
+ // ── API: workspace cleanup preview ───────────────────────────────────────
381
+ // Returns which clones would be deleted when removing this workspace.
382
+ if (pathname === "/api/workspaces/cleanup-preview" && req.method === "GET") {
383
+ const wsName = url.searchParams.get("name");
384
+ if (!wsName) return send400(res, "name required");
385
+ return sendJson(res, computeCleanupTargets(wsName));
386
+ }
387
+
388
+ // ── API: create workspace ─────────────────────────────────────────────────
389
+ if (pathname === "/api/workspaces/create" && req.method === "POST") {
390
+ try {
391
+ const body = await readBody(req);
392
+ const { name } = JSON.parse(body) as { name?: string };
393
+ if (!name) return send400(res, "name required");
394
+ // eslint-disable-next-line no-control-regex
395
+ if (/["'\\/<>\x00-\x1f]/.test(name))
396
+ return send400(
397
+ res,
398
+ "name must not contain quotes, slashes, or control characters",
399
+ );
400
+ const file = readWorkspacesFile();
401
+ if (file.workspaces[name])
402
+ return send400(res, `workspace "${name}" already exists`);
403
+ createWorkspace(name, { services: {} });
404
+ return sendJson(res, { ok: true, name });
405
+ } catch (e) {
406
+ return send400(res, `Invalid request: ${(e as Error).message}`);
407
+ }
408
+ }
409
+
410
+ // ── API: GitHub branches ──────────────────────────────────────────────────
411
+ if (pathname === "/api/github/branches" && req.method === "GET") {
412
+ const gh = url.searchParams.get("gh") ?? "";
413
+ return sendJson(res, github.listBranches(gh));
414
+ }
415
+
416
+ // ── API: GitHub repos ─────────────────────────────────────────────────────
417
+ if (pathname === "/api/github/repos" && req.method === "GET") {
418
+ const forceRefresh = url.searchParams.get("refresh") === "1";
419
+ return sendJson(res, github.listOrgRepos(forceRefresh));
420
+ }
421
+
422
+ // ── API: available repos (for adding to workspace) ────────────────────────
423
+ // Returns two groups:
424
+ // local — repos discovered recursively under repos_root (dev checkouts)
425
+ // catalog — catalog entries not found locally (den can clone them)
426
+ //
427
+ // Each entry carries:
428
+ // relPath — path relative to repos_root (used as catalog key and workspace address)
429
+ // name — leaf directory name (display label)
430
+ // absPath — absolute filesystem path (stored as linkedPath for dev-managed repos)
431
+ if (pathname === "/api/workspaces/available-repos" && req.method === "GET") {
432
+ const reposRoot = getReposRoot();
433
+ const wsOverride = url.searchParams.get("workspace");
434
+ const wsFile = wsOverride ? readWorkspacesFile() : null;
435
+ const ws =
436
+ wsOverride && wsFile?.workspaces[wsOverride]
437
+ ? { config: wsFile.workspaces[wsOverride] }
438
+ : getActiveWorkspace();
439
+ const catalog = readCatalog();
440
+ const existingAddresses = new Set(Object.keys(ws?.config.services ?? {}));
441
+
442
+ // Build a set of gh slugs already represented in the workspace, so that
443
+ // alternate local paths for the same repo (e.g. TestWorkspace/shop) are
444
+ // correctly flagged as already-added.
445
+ const existingGhSlugs = new Set<string>();
446
+ for (const addr of existingAddresses) {
447
+ const { repoName } = parseAddress(addr, catalog);
448
+ const gh = catalog[repoName]?.gh;
449
+ if (gh) existingGhSlugs.add(gh.toLowerCase());
450
+ }
451
+
452
+ // Get git remote origin for a local path (fast, silent on failure).
453
+ const getRemoteGh = (absPath: string): string | null => {
454
+ try {
455
+ const remote = execSync("git remote get-url origin", {
456
+ cwd: absPath,
457
+ encoding: "utf-8",
458
+ stdio: ["ignore", "pipe", "ignore"],
459
+ timeout: GIT_REMOTE_TIMEOUT_MS,
460
+ }).trim();
461
+ // Normalise SSH and HTTPS GitHub URLs → "owner/repo"
462
+ const m =
463
+ remote.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/) ?? null;
464
+ return m ? m[1].toLowerCase() : null;
465
+ } catch {
466
+ return null;
467
+ }
468
+ };
469
+
470
+ type RepoEntry = {
471
+ name: string;
472
+ relPath: string;
473
+ absPath: string | null;
474
+ hasManifest: boolean;
475
+ alreadyAdded: boolean;
476
+ source: "local" | "catalog" | "managed";
477
+ gh: string | null;
478
+ currentBranch?: string | null;
479
+ };
480
+ const entries: RepoEntry[] = [];
481
+ const seenRelPaths = new Set<string>();
482
+
483
+ // Recursively walk repos_root, stopping at directories that have .den.yml.
484
+ // We don't recurse into repo directories themselves.
485
+ const walkDir = (dir: string, relPrefix: string, depth: number): void => {
486
+ if (depth > 4) return; // safety limit
487
+ let dirents: fs.Dirent[];
488
+ try {
489
+ dirents = fs.readdirSync(dir, { withFileTypes: true });
490
+ } catch {
491
+ return;
492
+ }
493
+
494
+ for (const d of dirents) {
495
+ if (!d.isDirectory() && !d.isSymbolicLink()) continue;
496
+ if (d.name.startsWith(".")) continue; // skip .git, .github, .DS_Store, etc.
497
+ const absPath = path.join(dir, d.name);
498
+ if (absPath === ROOT) continue; // skip den itself
499
+ const relPath = relPrefix ? `${relPrefix}/${d.name}` : d.name;
500
+ const hasManifest = fs.existsSync(
501
+ path.join(absPath, `${product().manifestYml}`),
502
+ );
503
+ if (hasManifest) {
504
+ const gh = catalog[relPath]?.gh ?? getRemoteGh(absPath);
505
+ // Already added if the exact address is in the workspace, OR if
506
+ // a repo with the same GitHub remote is already present.
507
+ const alreadyAdded =
508
+ existingAddresses.has(relPath) ||
509
+ (!!gh && existingGhSlugs.has(gh.toLowerCase()));
510
+ entries.push({
511
+ name: d.name,
512
+ relPath,
513
+ absPath,
514
+ hasManifest: true,
515
+ alreadyAdded,
516
+ source: "local",
517
+ gh: gh ?? null,
518
+ });
519
+ seenRelPaths.add(relPath);
520
+ // Don't recurse into repo directories.
521
+ } else {
522
+ // Not a repo — recurse to find repos in subdirectories.
523
+ walkDir(absPath, relPath, depth + 1);
524
+ }
525
+ }
526
+ };
527
+ walkDir(reposRoot, "", 0);
528
+
529
+ // Managed clones live at .den-services/<repo>/<branch>/
530
+ // Walk two levels: repo dir → branch dir (each branch dir is a full clone).
531
+ // Multiple workspaces pointing to the same repo+branch share the same clone.
532
+ const getCurrentBranch = (absPath: string): string | null => {
533
+ try {
534
+ return execSync("git rev-parse --abbrev-ref HEAD", {
535
+ cwd: absPath,
536
+ encoding: "utf-8",
537
+ stdio: ["ignore", "pipe", "ignore"],
538
+ timeout: GIT_REMOTE_TIMEOUT_MS,
539
+ }).trim();
540
+ } catch {
541
+ return null;
542
+ }
543
+ };
544
+ try {
545
+ const repoDirs = fs.readdirSync(DEN_SERVICES_DIR, {
546
+ withFileTypes: true,
547
+ });
548
+ for (const repoDir of repoDirs) {
549
+ if (!repoDir.isDirectory()) continue;
550
+ const repoName = repoDir.name;
551
+ const repoDirPath = path.join(DEN_SERVICES_DIR, repoName);
552
+ let branchDirs: fs.Dirent[];
553
+ try {
554
+ branchDirs = fs.readdirSync(repoDirPath, { withFileTypes: true });
555
+ } catch {
556
+ continue;
557
+ }
558
+ for (const branchDir of branchDirs) {
559
+ if (!branchDir.isDirectory()) continue;
560
+ const branchName = branchDir.name;
561
+ const absPath = path.join(repoDirPath, branchName);
562
+ // Only treat as a branch clone if it's actually a git repo
563
+ if (!fs.existsSync(path.join(absPath, ".git"))) continue;
564
+ const key = `${repoName}::${branchName}`; // unique per repo+branch
565
+ if (seenRelPaths.has(key)) continue;
566
+ const hasManifest = fs.existsSync(
567
+ path.join(absPath, `${product().manifestYml}`),
568
+ );
569
+ const gh = catalog[repoName]?.gh ?? getRemoteGh(absPath);
570
+ const alreadyAdded =
571
+ existingAddresses.has(repoName) ||
572
+ (!!gh && existingGhSlugs.has(gh.toLowerCase()));
573
+ const currentBranch = getCurrentBranch(absPath) ?? branchName;
574
+ entries.push({
575
+ name: repoName,
576
+ relPath: repoName, // catalog key used as workspace address
577
+ absPath,
578
+ hasManifest,
579
+ alreadyAdded,
580
+ source: "managed",
581
+ gh: gh ?? null,
582
+ currentBranch,
583
+ });
584
+ seenRelPaths.add(key);
585
+ seenRelPaths.add(repoName); // prevent catalog fallback from duplicating
586
+ }
587
+ }
588
+ } catch {
589
+ /* the managed-clones dir does not exist yet */
590
+ }
591
+
592
+ // Catalog-only entries — repos den knows about but not found locally.
593
+ for (const [relPath, entry] of Object.entries(catalog)) {
594
+ if (seenRelPaths.has(relPath)) continue;
595
+ const name = relPath.includes("/")
596
+ ? relPath.slice(relPath.lastIndexOf("/") + 1)
597
+ : relPath;
598
+ const alreadyAdded =
599
+ existingAddresses.has(relPath) ||
600
+ (!!entry.gh && existingGhSlugs.has(entry.gh.toLowerCase()));
601
+ entries.push({
602
+ name,
603
+ relPath,
604
+ absPath: null,
605
+ hasManifest: false,
606
+ alreadyAdded,
607
+ source: "catalog",
608
+ gh: entry.gh,
609
+ });
610
+ }
611
+
612
+ return sendJson(res, entries);
613
+ }
614
+
615
+ // ── API: per-system auto-start toggle ─────────────────────────────────────
616
+ // POST { workspace, repo, autostart } — sets `autostart: false` on the repo's
617
+ // workspace entry (or removes the field when true, keeping the yaml clean).
618
+ // The entry may be keyed by the bare repo name or by "repo/subsystem" keys.
619
+ if (pathname === "/api/workspaces/services/autostart") {
620
+ if (req.method !== "POST") {
621
+ res.writeHead(405);
622
+ res.end();
623
+ return;
624
+ }
625
+ try {
626
+ const body = await readBody(req);
627
+ const { workspace, repo, autostart } = JSON.parse(body) as {
628
+ workspace?: string;
629
+ repo?: string;
630
+ autostart?: boolean;
631
+ };
632
+ if (!workspace || !repo) return send400(res, "workspace, repo required");
633
+ if (typeof autostart !== "boolean")
634
+ return send400(res, '"autostart" boolean required');
635
+ const file = readWorkspacesFile();
636
+ const ws = file.workspaces[workspace];
637
+ if (!ws) return send400(res, `Unknown workspace "${workspace}"`);
638
+ const catalog = readCatalog();
639
+ const keys = Object.keys(ws.services).filter(
640
+ (k) => parseAddress(k, catalog).repoName === repo,
641
+ );
642
+ if (keys.length === 0)
643
+ return send400(res, `"${repo}" is not in workspace "${workspace}"`);
644
+ for (const k of keys) {
645
+ if (autostart) delete ws.services[k].autostart;
646
+ else ws.services[k].autostart = false;
647
+ }
648
+ writeWorkspacesFile(file);
649
+ return sendJson(res, { ok: true, repo, autostart });
650
+ } catch (e) {
651
+ return send400(res, `Invalid request: ${(e as Error).message}`);
652
+ }
653
+ }
654
+
655
+ // ── API: per-system mode switch (use: local / use: den) ────────────────
656
+ // POST { workspace, repo, mode, branch? } — web equivalent of the CLI's
657
+ // use-local / use-managed. Rewrites all of the repo's entries (bare + any
658
+ // "repo/sub" keys) with the new mode, mirroring switchRepoMode: dev-managed
659
+ // requires a checkout at <reposRoot>/<repo>; managed tracks `branch`
660
+ // (default main; the clone is ensured at up time). Preserves `autostart`.
661
+ if (pathname === "/api/workspaces/services/mode") {
662
+ if (req.method !== "POST") {
663
+ res.writeHead(405);
664
+ res.end();
665
+ return;
666
+ }
667
+ try {
668
+ const body = await readBody(req);
669
+ const { workspace, repo, mode, branch } = JSON.parse(body) as {
670
+ workspace?: string;
671
+ repo?: string;
672
+ mode?: string;
673
+ branch?: string;
674
+ };
675
+ if (!workspace || !repo) return send400(res, "workspace, repo required");
676
+ if (mode !== "dev-managed" && mode !== "managed")
677
+ return send400(res, 'mode must be "dev-managed" or "managed"');
678
+ const file = readWorkspacesFile();
679
+ const wsCfg = file.workspaces[workspace];
680
+ if (!wsCfg) return send400(res, `Unknown workspace "${workspace}"`);
681
+ const catalog = readCatalog();
682
+ const keys = Object.keys(wsCfg.services).filter(
683
+ (k) => parseAddress(k, catalog).repoName === repo,
684
+ );
685
+ if (keys.length === 0)
686
+ return send400(res, `"${repo}" is not in workspace "${workspace}"`);
687
+
688
+ let localPath: string | undefined;
689
+ if (mode === "dev-managed") {
690
+ localPath = path.join(getReposRoot(), repo);
691
+ if (!fs.existsSync(localPath)) {
692
+ return send400(
693
+ res,
694
+ `No local checkout found at ${localPath} — clone it there first, or use ${product().cli} register for a custom path`,
695
+ );
696
+ }
697
+ }
698
+ const effectiveBranch = branch ?? "main";
699
+ for (const k of keys) {
700
+ const keepOff = wsCfg.services[k]?.autostart === false;
701
+ wsCfg.services[k] = {
702
+ mode,
703
+ ...(mode === "dev-managed" && localPath ? { path: localPath } : {}),
704
+ ...(mode === "managed" ? { branch: effectiveBranch } : {}),
705
+ ...(keepOff ? { autostart: false } : {}),
706
+ };
707
+ }
708
+ writeWorkspacesFile(file);
709
+ return sendJson(res, {
710
+ ok: true,
711
+ repo,
712
+ mode,
713
+ ...(localPath ? { path: localPath } : {}),
714
+ ...(mode === "managed" ? { branch: effectiveBranch } : {}),
715
+ });
716
+ } catch (e) {
717
+ return send400(res, `Invalid request: ${(e as Error).message}`);
718
+ }
719
+ }
720
+
721
+ // ── API: workspace services (add / remove) ────────────────────────────────
722
+ if (pathname === "/api/workspaces/services") {
723
+ if (req.method === "POST") {
724
+ try {
725
+ const body = await readBody(req);
726
+ const {
727
+ workspace,
728
+ address,
729
+ mode,
730
+ linkedPath,
731
+ branch,
732
+ ghSlug,
733
+ subfolder,
734
+ newBranch,
735
+ } = JSON.parse(body) as {
736
+ workspace?: string;
737
+ address?: string;
738
+ mode?: "dev-managed" | "managed";
739
+ /** Absolute path to an existing local checkout (dev-managed from disk). */
740
+ linkedPath?: string;
741
+ branch?: string;
742
+ /** GitHub slug (owner/repo) — present when cloning from GitHub. */
743
+ ghSlug?: string;
744
+ /** Optional subfolder within the workspace clone directory (dev-managed from GitHub). */
745
+ subfolder?: string;
746
+ /**
747
+ * If set, clone at `branch` (base) then create this new local branch on top.
748
+ * The workspace entry tracks `newBranch`, not `branch`.
749
+ */
750
+ newBranch?: string;
751
+ };
752
+ if (!address || !mode) return send400(res, "address and mode required");
753
+
754
+ // Validate branch names against injection (used in git commands)
755
+ const SAFE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
756
+ if (branch && !SAFE_BRANCH_RE.test(branch))
757
+ return send400(res, "invalid branch name");
758
+ if (newBranch && !SAFE_BRANCH_RE.test(newBranch))
759
+ return send400(res, "invalid newBranch name");
760
+ // ghSlug is interpolated into the git clone URL — reject anything that
761
+ // isn't a strict owner/repo slug to block shell/argument injection.
762
+ const SAFE_SLUG_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
763
+ if (ghSlug && !SAFE_SLUG_RE.test(ghSlug))
764
+ return send400(res, "invalid ghSlug");
765
+ // subfolder is joined into the clone target path — reject traversal and
766
+ // absolute paths so a clone can't escape the workspace directory.
767
+ if (
768
+ subfolder &&
769
+ (!/^[a-zA-Z0-9._/-]+$/.test(subfolder) ||
770
+ subfolder.split("/").includes("..") ||
771
+ path.isAbsolute(subfolder))
772
+ )
773
+ return send400(res, "invalid subfolder");
774
+
775
+ // Prefer explicit workspace name; fall back to focused workspace.
776
+ const file = readWorkspacesFile();
777
+ const wsName = workspace ?? file.focus ?? file.active[0];
778
+ if (!wsName || !file.workspaces[wsName])
779
+ return send400(res, `workspace "${wsName ?? ""}" not found`);
780
+
781
+ let catalog = readCatalog();
782
+ const { repoName } = parseAddress(address, catalog);
783
+ // For new repos, parseAddress may not resolve correctly before the catalog entry
784
+ // exists — fall back to using the address directly as the catalog key.
785
+ const catalogKey = catalog[repoName] ? repoName : address;
786
+
787
+ // Resolve where the repo lives on disk (or clone it if requested).
788
+ let resolvedPath = linkedPath;
789
+ if (!resolvedPath && ghSlug) {
790
+ const leafName = address.includes("/")
791
+ ? address.slice(address.lastIndexOf("/") + 1)
792
+ : address;
793
+ if (mode === "dev-managed") {
794
+ // Always clone into den-workspaces/<wsName>/[subfolder/]<leafName>/
795
+ const parts = [DEN_WORKSPACES_DIR, wsName];
796
+ if (subfolder) parts.push(...subfolder.split("/").filter(Boolean));
797
+ parts.push(leafName);
798
+ const targetDir = path.join(...parts);
799
+ resolvedPath = ensureDevManagedClone(
800
+ targetDir,
801
+ ghSlug,
802
+ branch ?? "main",
803
+ );
804
+ // If the user requested a new local branch, create it on top of the clone.
805
+ if (newBranch && resolvedPath) {
806
+ execSync(`git checkout -b "${newBranch}"`, {
807
+ cwd: resolvedPath,
808
+ stdio: "ignore",
809
+ });
810
+ }
811
+ // Setup is handled inside ensureDevManagedClone → runSetupIfDefined,
812
+ // which opens a terminal window for interactive input.
813
+ } else {
814
+ // managed: clone into .den-services/<catalogKey>/
815
+ if (newBranch) {
816
+ // Clone at base branch, then create newBranch; dir is named after newBranch.
817
+ ensureManagedServiceCloneNewBranch(
818
+ catalogKey,
819
+ ghSlug,
820
+ branch ?? "main",
821
+ newBranch,
822
+ );
823
+ } else {
824
+ ensureManagedServiceClone(catalogKey, ghSlug, branch ?? "main");
825
+ }
826
+ }
827
+ }
828
+
829
+ if (!catalog[catalogKey]) {
830
+ if (mode === "dev-managed" && (resolvedPath || ghSlug)) {
831
+ upsertCatalogEntry(catalogKey, { gh: ghSlug ?? "" });
832
+ catalog = readCatalog();
833
+ } else if (mode === "managed" && ghSlug) {
834
+ upsertCatalogEntry(catalogKey, { gh: ghSlug });
835
+ catalog = readCatalog();
836
+ } else {
837
+ return send400(
838
+ res,
839
+ `"${catalogKey}" is not in the catalog — register it first`,
840
+ );
841
+ }
842
+ }
843
+
844
+ // If a new branch was created on top of the base, the workspace tracks it.
845
+ const effectiveBranch = newBranch ?? branch ?? "main";
846
+ // Preserve per-entry toggles (autostart) when re-adding/rewriting an
847
+ // existing entry — rebuilding the literal from scratch silently drops
848
+ // them otherwise.
849
+ const prevEntry = file.workspaces[wsName].services[address];
850
+ file.workspaces[wsName].services[address] = {
851
+ mode,
852
+ ...(mode === "dev-managed" && resolvedPath
853
+ ? { path: resolvedPath }
854
+ : {}),
855
+ ...(mode === "managed" ? { branch: effectiveBranch } : {}),
856
+ ...(prevEntry?.autostart === false ? { autostart: false } : {}),
857
+ };
858
+ writeWorkspacesFile(file);
859
+
860
+ // Check if the newly added service has depends_on not covered by the workspace
861
+ const missingDeps: string[] = [];
862
+ try {
863
+ const wsConfig = file.workspaces[wsName];
864
+ const resolved = resolveServiceSource(
865
+ address,
866
+ wsName,
867
+ wsConfig,
868
+ catalog,
869
+ );
870
+ if (resolved) {
871
+ const manifest = readManifest(resolved.repoPath);
872
+ const { subsystem } = parseAddress(address, catalog);
873
+ const deps = manifest?.services?.[subsystem]?.depends_on ?? [];
874
+ const existingAddresses = new Set(Object.keys(wsConfig.services));
875
+ for (const rawDep of deps) {
876
+ const dep = depService(rawDep);
877
+ const { repoName: depRepo } = parseAddress(dep, catalog);
878
+ if (
879
+ !existingAddresses.has(dep) &&
880
+ !existingAddresses.has(depRepo)
881
+ )
882
+ missingDeps.push(dep);
883
+ }
884
+ }
885
+ } catch {
886
+ /* ignore — manifest may not exist yet for managed repos */
887
+ }
888
+
889
+ // Check if a setup command exists for the newly added service
890
+ let setupStarted = false;
891
+ try {
892
+ const addedManifest = resolvedPath
893
+ ? readManifest(resolvedPath)
894
+ : null;
895
+ const { subsystem: addedSubsystem } = parseAddress(address, catalog);
896
+ const setupDef =
897
+ addedManifest?.services?.[addedSubsystem]?.commands?.local?.setup;
898
+ setupStarted = !!setupDef;
899
+ } catch {
900
+ /* ignore */
901
+ }
902
+
903
+ return sendJson(res, { ok: true, missingDeps, setupStarted });
904
+ } catch (e) {
905
+ return send400(res, `Invalid request: ${(e as Error).message}`);
906
+ }
907
+ }
908
+ if (req.method === "DELETE") {
909
+ try {
910
+ const body = await readBody(req);
911
+ const { workspace, address } = JSON.parse(body) as {
912
+ workspace?: string;
913
+ address?: string;
914
+ };
915
+ if (!address) return send400(res, "address required");
916
+
917
+ // Prefer explicit workspace name; fall back to focused workspace.
918
+ const file = readWorkspacesFile();
919
+ const wsName = workspace ?? file.focus ?? file.active[0];
920
+ if (!wsName || !file.workspaces[wsName])
921
+ return send400(res, `workspace "${wsName ?? ""}" not found`);
922
+
923
+ const services = file.workspaces[wsName].services;
924
+ const catalog = readCatalog();
925
+ // Delete exact match, or all subsystem entries for the repo
926
+ // (handles both bare-key workspaces like "shop" and
927
+ // explicit-subsystem workspaces like "shop/api", "shop/db").
928
+ if (services[address] !== undefined) {
929
+ delete services[address];
930
+ } else {
931
+ for (const key of Object.keys(services)) {
932
+ if (parseAddress(key, catalog).repoName === address) {
933
+ delete services[key];
934
+ }
935
+ }
936
+ }
937
+ writeWorkspacesFile(file);
938
+ return sendJson(res, { ok: true });
939
+ } catch (e) {
940
+ return send400(res, `Invalid request: ${(e as Error).message}`);
941
+ }
942
+ }
943
+ }
944
+
945
+ // ── API: workspace settings ──────────────────────────────────────────────
946
+ // GET /api/workspaces/settings?workspace=<name> → { editor }
947
+ // POST /api/workspaces/settings body: { workspace, editor }
948
+ if (pathname === "/api/workspaces/settings") {
949
+ if (req.method === "GET") {
950
+ const wsName =
951
+ url.searchParams.get("workspace") ?? getActiveWorkspace()?.name;
952
+ if (!wsName) return sendJson(res, { editor: null });
953
+ const file = readWorkspacesFile();
954
+ const ws = file.workspaces[wsName];
955
+ return sendJson(res, { editor: ws?.editor ?? null });
956
+ }
957
+ if (req.method === "POST") {
958
+ try {
959
+ const body = JSON.parse(await readBody(req)) as {
960
+ workspace?: string;
961
+ editor?: string | null;
962
+ };
963
+ const wsName = body.workspace ?? getActiveWorkspace()?.name;
964
+ if (!wsName) return send400(res, "workspace required");
965
+ const file = readWorkspacesFile();
966
+ const ws = file.workspaces[wsName];
967
+ if (!ws) return send400(res, `Workspace "${wsName}" not found`);
968
+ if (body.editor === null || body.editor === "") {
969
+ delete ws.editor;
970
+ } else if (body.editor !== undefined) {
971
+ if (!editors.isKnownEditor(body.editor))
972
+ return send400(res, `Unknown editor "${body.editor}"`);
973
+ ws.editor = body.editor;
974
+ }
975
+ writeWorkspacesFile(file);
976
+ return sendJson(res, { ok: true, editor: ws.editor ?? null });
977
+ } catch (e) {
978
+ return send400(res, `Invalid request: ${(e as Error).message}`);
979
+ }
980
+ }
981
+ }
982
+
983
+ // ── API: workspace dependency check ──────────────────────────────────────
984
+ // Returns missing deps for each service in the specified (or active) workspace.
985
+ if (pathname === "/api/workspaces/deps-check" && req.method === "GET") {
986
+ const wsOverride = url.searchParams.get("workspace");
987
+ const wsFile = wsOverride ? readWorkspacesFile() : null;
988
+ const ws =
989
+ wsOverride && wsFile?.workspaces[wsOverride]
990
+ ? { name: wsOverride, config: wsFile!.workspaces[wsOverride] }
991
+ : getActiveWorkspace();
992
+ if (!ws) return sendJson(res, []);
993
+ const catalog = readCatalog();
994
+ return sendJson(res, computeMissingDeps(ws.config, catalog, ws.name));
995
+ }
996
+
997
+ // ── API: derived dependency graph ─────────────────────────────────────────
998
+ // Returns the derived system dependency graph as JSON (same engine the CLI
999
+ // `den graph` and derived `up`/`down` use). Optional ?seed=<repo> returns
1000
+ // that repo's closure; otherwise the full known-systems graph.
1001
+ if (pathname === "/api/graph" && req.method === "GET") {
1002
+ const seed = url.searchParams.get("seed") ?? undefined;
1003
+ // A repo name resolves to a different checkout (or to nothing) per
1004
+ // workspace, so the caller says which workspace it is rendering. Any
1005
+ // workspace may be graphed — reading a graph doesn't require activation.
1006
+ const wsParam = url.searchParams.get("workspace") ?? undefined;
1007
+ if (wsParam && !getWorkspaceByName(wsParam))
1008
+ return send400(res, `unknown workspace "${wsParam}"`);
1009
+ try {
1010
+ const graph = seed
1011
+ ? deriveGraph({ scope: "closure", seed, workspace: wsParam })
1012
+ : deriveGraph({ scope: "all", workspace: wsParam });
1013
+ // Include the canonical startup order (infra-stack first … entry repo
1014
+ // last) so the web can lay the graph out along the real dependency flow,
1015
+ // breaking soft/event back-call cycles the same way `den up` does.
1016
+ return sendJson(res, { ...graph, order: orderForUp(graph) });
1017
+ } catch (e) {
1018
+ return send400(res, `graph derivation failed: ${(e as Error).message}`);
1019
+ }
1020
+ }
1021
+
1022
+ // ── API: applications ─────────────────────────────────────────────────────
1023
+ // Org > Application > Repo > Services for a workspace — same as
1024
+ // `den apps --json` — with each application's service addresses for
1025
+ // the UI's per-application up/down/restart. No `workspace` = the focused one.
1026
+ if (pathname === "/api/applications" && req.method === "GET") {
1027
+ const wsParam = url.searchParams.get("workspace") ?? undefined;
1028
+ if (wsParam && !getWorkspaceByName(wsParam))
1029
+ return send400(res, `unknown workspace "${wsParam}"`);
1030
+ try {
1031
+ return sendJson(res, loadApplicationTree(wsParam));
1032
+ } catch (e) {
1033
+ return send400(res, `applications failed: ${(e as Error).message}`);
1034
+ }
1035
+ }
1036
+
1037
+ // ── API: workspace-level command (SSE) ───────────────────────────────────
1038
+ // Runs a command (up/down/restart/setup) on all services in the active
1039
+ // workspace in DERIVED dependency order, streaming labeled output as SSE events.
1040
+ if (pathname === "/api/workspace/exec") {
1041
+ const cmd = url.searchParams.get("cmd");
1042
+ if (!cmd) return send400(res, "cmd required");
1043
+
1044
+ res.writeHead(200, {
1045
+ "Content-Type": "text/event-stream",
1046
+ "Cache-Control": "no-cache",
1047
+ Connection: "keep-alive",
1048
+ "X-Accel-Buffering": "no",
1049
+ });
1050
+
1051
+ // Client disconnect cleanup — set when the browser closes the SSE stream
1052
+ // (tab close, navigation, refresh mid-operation). The flag is polled by
1053
+ // the sequential runner and the readiness poller so in-flight work bails
1054
+ // out instead of writing to a dead socket and leaking descriptors.
1055
+ let cancelled = false;
1056
+ let activeProc: ReturnType<typeof runServiceCommand>["proc"] | null = null;
1057
+ let activeDetachTimer: ReturnType<typeof setTimeout> | null = null;
1058
+ req.on("close", () => {
1059
+ cancelled = true;
1060
+ if (activeDetachTimer !== null) {
1061
+ clearTimeout(activeDetachTimer);
1062
+ activeDetachTimer = null;
1063
+ }
1064
+ if (activeProc) {
1065
+ try {
1066
+ activeProc.kill();
1067
+ } catch {
1068
+ /* already exited */
1069
+ }
1070
+ activeProc = null;
1071
+ }
1072
+ });
1073
+
1074
+ const emit = (obj: unknown): void => {
1075
+ if (cancelled) return;
1076
+ try {
1077
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
1078
+ } catch {
1079
+ // Socket closed mid-write — mark cancelled so subsequent loops bail.
1080
+ cancelled = true;
1081
+ }
1082
+ };
1083
+
1084
+ const wsParam = url.searchParams.get("workspace");
1085
+ if (wsParam && !isWorkspaceActive(wsParam)) {
1086
+ emit({
1087
+ type: "error",
1088
+ text: `Workspace "${wsParam}" is not active — activate it first`,
1089
+ });
1090
+ emit({ type: "done", exitCode: 1, duration: 0 });
1091
+ res.end();
1092
+ return;
1093
+ }
1094
+ const ws = wsParam ? getWorkspaceByName(wsParam) : getActiveWorkspace();
1095
+ if (!ws) {
1096
+ emit({ type: "error", text: "No active workspace" });
1097
+ emit({ type: "done", exitCode: 1, duration: 0 });
1098
+ res.end();
1099
+ return;
1100
+ }
1101
+
1102
+ const catalog = readCatalog();
1103
+ // Modules and libraries have no runtime: never started or stopped.
1104
+ const allServices = listWorkspaceServices(
1105
+ ws.config,
1106
+ catalog,
1107
+ ws.name,
1108
+ ).filter((s) => hasRuntime(repoKind(s.repoPath)));
1109
+ if (allServices.length === 0) {
1110
+ emit({ type: "error", text: "No services in active workspace" });
1111
+ emit({ type: "done", exitCode: 1, duration: 0 });
1112
+ res.end();
1113
+ return;
1114
+ }
1115
+
1116
+ // Optional comma-separated address filter — used by workspace switching
1117
+ // to target only the services that differ between workspaces.
1118
+ const addressFilter = url.searchParams.get("addresses");
1119
+ const addressSet = addressFilter
1120
+ ? new Set(addressFilter.split(",").map((a) => a.trim()))
1121
+ : null;
1122
+ // For a workspace-wide STARTING command (up/restart, no explicit address
1123
+ // filter) exclude systems whose auto-start is toggled off (`autostart:
1124
+ // false` on the workspace entry). Down still stops them — the toggle only
1125
+ // governs starting. An explicit address filter is a deliberate target, so
1126
+ // the toggle is bypassed there.
1127
+ const startingCmd = cmd === "up" || cmd === "restart";
1128
+ const noAutostart = startingCmd
1129
+ ? noAutostartRepos(ws.config, catalog)
1130
+ : new Set<string>();
1131
+
1132
+ // `seed=<repo>` — "up with deps": start that system AND its dependency
1133
+ // closure. Derived HERE rather than by the client sending an address list,
1134
+ // for two reasons: startClosure's positional rule (everything strictly
1135
+ // below the seed in the start order, never above) is load-bearing, and the
1136
+ // two behaviours this mode needs — "already healthy → skip" and the
1137
+ // auto-start-off toggle — are both gated on there being no explicit
1138
+ // address filter, so no amount of client-side expansion could reach them.
1139
+ const seedRepo =
1140
+ startingCmd || cmd === "down" ? url.searchParams.get("seed") : null;
1141
+ let closureRepos: Set<string> | null = null;
1142
+ if (seedRepo && cmd === "down") {
1143
+ // Symmetric with "up with deps": the SAME closure, torn down in reverse.
1144
+ // What you started together you stop together — the alternative (walking
1145
+ // to dependents instead) means the two menu items operate on different
1146
+ // sets, which is not what the pairing promises.
1147
+ //
1148
+ // The exec route already reverses the rank sort for `down`, so the seed
1149
+ // stops first and its foundations last.
1150
+ const graph = deriveGraph({ scope: "all", workspace: ws.name });
1151
+ const { order } = deriveStartTargets(graph, seedRepo);
1152
+ closureRepos = new Set(order);
1153
+ emit({
1154
+ type: "line",
1155
+ text: `${seedRepo}: stopping with dependencies — ${[...order].reverse().join(" → ")}`,
1156
+ });
1157
+
1158
+ // Stopping shared foundations can break systems OUTSIDE this closure that
1159
+ // are still running on them (infra-stack underpins most of the fleet).
1160
+ // Name them rather than letting them fall over silently.
1161
+ const atRisk = new Set<string>();
1162
+ for (const e of graph.edges) {
1163
+ if (e.kind === "jwks" || e.ordering === false) continue;
1164
+ if (!closureRepos.has(e.to) || closureRepos.has(e.from)) continue;
1165
+ if (allServices.some((s) => s.repoName === e.from)) atRisk.add(e.from);
1166
+ }
1167
+ if (atRisk.size > 0) {
1168
+ emit({
1169
+ type: "line",
1170
+ text: `⚠ also depends on what this stops: ${[...atRisk].sort().join(", ")} — stop or restart them if they misbehave`,
1171
+ });
1172
+ }
1173
+
1174
+ const missing = order.filter(
1175
+ (r) => !allServices.some((s) => s.repoName === r),
1176
+ );
1177
+ if (missing.length > 0) {
1178
+ emit({
1179
+ type: "line",
1180
+ text: `Not in this workspace, skipping: ${missing.join(", ")}`,
1181
+ });
1182
+ }
1183
+ } else if (seedRepo) {
1184
+ // Positional closure: everything the seed sits ON TOP OF, never anything
1185
+ // above it. Includes tolerant deps that rank below (an event consumer the
1186
+ // graph already draws underneath) and excludes tolerant deps that rank
1187
+ // above (a soft http call up the stack). See startClosure.
1188
+ const { order } = deriveStartTargets(
1189
+ deriveGraph({ scope: "all", workspace: ws.name }),
1190
+ seedRepo,
1191
+ );
1192
+ if (order.length === 0) {
1193
+ emit({
1194
+ type: "error",
1195
+ text: `${seedRepo} has no derived dependency closure — is it in this workspace?`,
1196
+ });
1197
+ emit({ type: "done", exitCode: 1, duration: 0 });
1198
+ res.end();
1199
+ return;
1200
+ }
1201
+ // A system the user switched auto-start OFF is still skipped here — but
1202
+ // never the seed, which is what they just clicked. Mirrors `den up
1203
+ // <repo>` (cli/index.ts).
1204
+ const policy = filterClosureForStart(order, seedRepo, noAutostart);
1205
+ closureRepos = new Set(policy.start);
1206
+ const offInClosure = policy.skippedOff;
1207
+ emit({
1208
+ type: "line",
1209
+ text: `${seedRepo}: starting with dependencies — ${[...closureRepos].join(" → ")}`,
1210
+ });
1211
+ if (offInClosure.length > 0) {
1212
+ emit({
1213
+ type: "line",
1214
+ text: `Skipping (auto-start off): ${offInClosure.join(", ")} (toggle in the card ⋮ menu)`,
1215
+ });
1216
+ }
1217
+ const missing = [...closureRepos].filter(
1218
+ (r) => !allServices.some((s) => s.repoName === r),
1219
+ );
1220
+ if (missing.length > 0) {
1221
+ emit({
1222
+ type: "line",
1223
+ text: `Not in this workspace, skipping: ${missing.join(", ")}`,
1224
+ });
1225
+ }
1226
+ }
1227
+
1228
+ const skippedOff: string[] = [];
1229
+ const addresses = allServices
1230
+ .map((s) => s.address)
1231
+ .filter((a) => {
1232
+ if (addressSet) return addressSet.has(a);
1233
+ const repoName = parseAddress(a, catalog).repoName;
1234
+ // Closure membership already applied the auto-start rule above, with
1235
+ // the seed exempted — don't re-apply it and drop the seed.
1236
+ if (closureRepos) return closureRepos.has(repoName);
1237
+ if (noAutostart.has(repoName)) {
1238
+ skippedOff.push(a);
1239
+ return false;
1240
+ }
1241
+ return true;
1242
+ });
1243
+
1244
+ if (skippedOff.length > 0) {
1245
+ emit({
1246
+ type: "line",
1247
+ text: `Skipping (auto-start off): ${skippedOff.join(", ")} (toggle in the card ⋮ menu, or start from its tab)`,
1248
+ });
1249
+ }
1250
+
1251
+ if (addresses.length === 0) {
1252
+ emit({ type: "done", exitCode: 0, duration: 0 });
1253
+ res.end();
1254
+ return;
1255
+ }
1256
+
1257
+ let ordered: string[];
1258
+ try {
1259
+ // Order by the DERIVED dependency graph (hard edges first) instead of
1260
+ // hand-written .den.yml depends_on — manifests are unreliable. We rank
1261
+ // each workspace address by its repo's position in the derived order.
1262
+ const rank = new Map(
1263
+ orderForUp(deriveGraph({ scope: "all", workspace: ws.name })).map(
1264
+ (repo, i) => [repo, i],
1265
+ ),
1266
+ );
1267
+ const rankOf = (addr: string): number =>
1268
+ rank.get(parseAddress(addr, catalog).repoName) ??
1269
+ Number.MAX_SAFE_INTEGER;
1270
+ const sorted = [...addresses].sort((a, b) => rankOf(a) - rankOf(b));
1271
+ ordered = cmd === "down" ? sorted.reverse() : sorted;
1272
+ } catch (e) {
1273
+ emit({ type: "error", text: (e as Error).message });
1274
+ emit({ type: "done", exitCode: 1, duration: 0 });
1275
+ res.end();
1276
+ return;
1277
+ }
1278
+
1279
+ // waitThenStart below blocks on a service's `wait: true` deps' health
1280
+ // before running its command — but for a SAME-REPO dep, the only thing
1281
+ // that makes it healthy is its own `up`. Prepend failing same-repo
1282
+ // wait-deps so the wait has something to wait FOR.
1283
+ //
1284
+ // This runs for whole-workspace starts too, not just explicit addresses
1285
+ // (#68): ranking is per REPO, so every subsystem of a repo ties and falls
1286
+ // back to manifest order — a manifest that lists a dependent before its
1287
+ // dependency deadlocked a plain `up` exactly the same way. Being correct
1288
+ // here rather than relying on file order costs nothing when the manifest
1289
+ // already reads deps-first: such deps are already queued and are filtered
1290
+ // out before any probe runs.
1291
+ if (startingCmd) {
1292
+ // Resolve/probe lazily and memoize — this now runs on every workspace
1293
+ // up, and the naive version re-read each repo's manifest once per
1294
+ // subsystem.
1295
+ const resolvedCache = new Map<string, ResolvedService | null>();
1296
+ const resolveCached = (addr: string): ResolvedService | null => {
1297
+ if (!resolvedCache.has(addr))
1298
+ resolvedCache.set(addr, resolveService(addr, ws.name));
1299
+ return resolvedCache.get(addr) ?? null;
1300
+ };
1301
+ const waitDepsCache = new Map<string, string[]>();
1302
+ let announced = false;
1303
+ try {
1304
+ const expansion = await expandSameRepoWaitDeps(ordered, {
1305
+ repoOf: (addr) => parseAddress(addr, catalog).repoName,
1306
+ waitDepsOf: (addr) => {
1307
+ if (!waitDepsCache.has(addr))
1308
+ waitDepsCache.set(
1309
+ addr,
1310
+ getWaitDeps(addr, catalog, ws.config, ws.name),
1311
+ );
1312
+ return waitDepsCache.get(addr) ?? [];
1313
+ },
1314
+ // Two address spellings can name ONE service (a bare repo name and
1315
+ // `repo/entrypoint`, or a `repo/repo` dep). Collapsing them on repo +
1316
+ // subsystem is what stops such a dep being treated as missing and
1317
+ // started a second time.
1318
+ identityOf: (addr) => {
1319
+ const r = resolveCached(addr);
1320
+ return r ? `${r.repoName}/${r.subsystem}` : addr;
1321
+ },
1322
+ isFailing: async (addr) => {
1323
+ if (cancelled) return false; // client gone — stop probing
1324
+ const depResolved = resolveCached(addr);
1325
+ if (!depResolved) return false;
1326
+ // Announce on the FIRST real probe only: when every manifest
1327
+ // already reads deps-first nothing is probed, and an unconditional
1328
+ // line would print on every workspace up for nothing.
1329
+ if (!announced) {
1330
+ announced = true;
1331
+ emit({ type: "line", text: "checking dependency health…" });
1332
+ }
1333
+ const h = await checkServiceHealth(depResolved, "local");
1334
+ return h.health === "error";
1335
+ },
1336
+ });
1337
+ // ALWAYS take the expansion's order. It both ADDS deps and REORDERS
1338
+ // deps-first, and those are independent: when every failing dep was
1339
+ // already in the explicit list, `added` is empty while the reorder is
1340
+ // exactly what prevents the deadlock. Gating this on `added` would
1341
+ // keep the fix for one path and drop it for the other.
1342
+ ordered = expansion.ordered;
1343
+ if (expansion.added.length > 0) {
1344
+ // `cmd` is global to the run, so an added dep inherits it — on
1345
+ // `restart` it is taken DOWN and re-upped, not merely started. Say so.
1346
+ const verb = cmd === "restart" ? "Restarting" : "Starting";
1347
+ emit({
1348
+ type: "line",
1349
+ text: `${verb} required same-system dependencies first: ${expansion.added.join(", ")}`,
1350
+ });
1351
+ }
1352
+ } catch (e) {
1353
+ // Degrade to the unexpanded order rather than killing a start the user
1354
+ // asked for: this block awaits manifest reads and health probes, and an
1355
+ // uncaught throw here would unwind past the SSE `done` event, hanging
1356
+ // the run pane instead of failing it.
1357
+ emit({
1358
+ type: "line",
1359
+ text: `⚠ could not expand dependencies (${(e as Error).message}) — starting as requested`,
1360
+ });
1361
+ }
1362
+ }
1363
+
1364
+ // Setup runs once per repo, on its entrypoint — not once per subsystem.
1365
+ // Several manifests declare `setup` on multiple services (e.g. shop/db AND
1366
+ // shop/api), so the fan-out ran `make setup` repeatedly per repo; each run
1367
+ // also dirties the managed clone (codegen), which blocks `git pull` on sync.
1368
+ // Workspace-wide (no address filter) setup also targets MANAGED
1369
+ // clones only — setup mutates a checkout with codegen/installs, which is
1370
+ // uninvited in an engineer's own dev-managed repo. An explicit address
1371
+ // (card ⋮ setup) still runs regardless of mode.
1372
+ if (cmd === "setup") {
1373
+ const chosenByRepo = new Map<string, string>();
1374
+ const skippedDev: string[] = [];
1375
+ for (const addr of ordered) {
1376
+ const r = resolveService(addr);
1377
+ if (!r) continue;
1378
+ if (!addressSet && r.mode === "dev-managed") {
1379
+ const repoName = parseAddress(addr, catalog).repoName;
1380
+ if (!skippedDev.includes(repoName)) skippedDev.push(repoName);
1381
+ continue;
1382
+ }
1383
+ const m = readManifest(r.repoPath);
1384
+ const hasSetup =
1385
+ "setup" in (m?.services?.[r.subsystem]?.commands?.local ?? {});
1386
+ if (!hasSetup) continue;
1387
+ const repoName = parseAddress(addr, catalog).repoName;
1388
+ const isEntry = m?.entrypoint ? r.subsystem === m.entrypoint : false;
1389
+ if (isEntry || !chosenByRepo.has(repoName))
1390
+ chosenByRepo.set(repoName, addr);
1391
+ }
1392
+ const keep = new Set(chosenByRepo.values());
1393
+ ordered = ordered.filter((addr) => keep.has(addr));
1394
+ if (skippedDev.length > 0) {
1395
+ emit({
1396
+ type: "line",
1397
+ text: `Skipping dev-managed (your own checkouts): ${skippedDev.join(", ")} — run setup from each card's ⋮ menu to include one`,
1398
+ });
1399
+ }
1400
+ }
1401
+
1402
+ const startTime = Date.now();
1403
+ let overallExit = 0;
1404
+ let ranCount = 0;
1405
+
1406
+ // Run services sequentially in dependency order
1407
+ const runNext = (i: number): void => {
1408
+ if (cancelled) {
1409
+ // Client disconnected — drop further work silently. res.end() on a
1410
+ // dead socket is a no-op; don't emit anything that could retrigger
1411
+ // the try/catch guard in `emit`.
1412
+ try {
1413
+ res.end();
1414
+ } catch {
1415
+ /* already closed */
1416
+ }
1417
+ return;
1418
+ }
1419
+ if (i >= ordered.length) {
1420
+ if (ranCount === 0) {
1421
+ emit({
1422
+ type: "line",
1423
+ text: `No services in this workspace have a "${cmd}" command.`,
1424
+ });
1425
+ }
1426
+ emit({
1427
+ type: "done",
1428
+ exitCode: overallExit,
1429
+ duration: Date.now() - startTime,
1430
+ });
1431
+ res.end();
1432
+ return;
1433
+ }
1434
+
1435
+ const address = ordered[i];
1436
+
1437
+ // Resolve service once — reused for both the skip check and the run.
1438
+ const resolved = resolveService(address, ws.name);
1439
+ const svcRepoName = resolved?.repoName ?? address;
1440
+ const svcSubsystem = resolved?.subsystem ?? address;
1441
+
1442
+ // Pre-check: skip services that don't have the requested command.
1443
+ // `down` is always attempted — it's opctl-conventional (stops running
1444
+ // containers with no declared command) and no-ops gracefully otherwise.
1445
+ // `restart` is orchestrated as down → verify → up, so it only needs `up`.
1446
+ if (resolved) {
1447
+ // A managed repo that was never cloned has no manifest, so it
1448
+ // fails the has-command check below and used to be dropped silently —
1449
+ // leaving "no services have an up command" as the only feedback on a
1450
+ // fresh install. Call it out and point at the control that fixes it.
1451
+ if (resolved.mode === "managed" && !fs.existsSync(resolved.repoPath)) {
1452
+ emit({
1453
+ type: "line",
1454
+ text: ` ${address}: not cloned yet — run Sync to clone it, then retry ${cmd}`,
1455
+ });
1456
+ runNext(i + 1);
1457
+ return;
1458
+ }
1459
+ // Same gate as the sync chain: never START a service whose declared
1460
+ // local secrets are still unset — placeholders bake themselves into
1461
+ // database volumes at first init. `down` is always allowed.
1462
+ if (cmd === "up" || cmd === "restart") {
1463
+ const secrets = checkRepoSecrets(resolved.repoPath);
1464
+ if (secrets && !secrets.ok) {
1465
+ for (const line of describeSecretsCheck(
1466
+ resolved.repoName,
1467
+ secrets,
1468
+ )) {
1469
+ emit({ type: "line", text: ` ${line}` });
1470
+ }
1471
+ runNext(i + 1);
1472
+ return;
1473
+ }
1474
+ }
1475
+ const svcCmds = localCommandsFor(resolved, ws.name);
1476
+ const hasCmd =
1477
+ cmd === "down" ||
1478
+ cmd in svcCmds ||
1479
+ (cmd === "restart" && "up" in svcCmds);
1480
+ if (!hasCmd) {
1481
+ runNext(i + 1);
1482
+ return;
1483
+ }
1484
+ }
1485
+
1486
+ ranCount++;
1487
+
1488
+ emit({ type: "label", text: address });
1489
+
1490
+ // detachFired: true once the detach timer fires so onClose can return early
1491
+ // and not emit a duplicate svc-done after the timer kills the tail proc.
1492
+ let detachFired = false;
1493
+ let detachTimer: ReturnType<typeof setTimeout> | null = null;
1494
+ let didRunNext = false;
1495
+ const safeRunNext = (n: number) => {
1496
+ if (!didRunNext) {
1497
+ didRunNext = true;
1498
+ runNext(n);
1499
+ }
1500
+ };
1501
+
1502
+ const doStart = (): void => {
1503
+ prewarmSudo();
1504
+ const startCmd = cmd === "restart" ? "up" : cmd;
1505
+ const { proc, detachable, interactive } = runServiceCommand({
1506
+ address,
1507
+ // restart is orchestrated: phase 1 (down + verify) already ran, so
1508
+ // phase 2 is a TRUE `up` — full detach/tail/readiness semantics —
1509
+ // instead of the old single-shell "down && up" synthesis.
1510
+ command: startCmd,
1511
+ workspace: ws.name,
1512
+ // Web-triggered ups always daemonize so output streams into the
1513
+ // browser log pane. `cli_tabs` only affects the CLI (`den up
1514
+ // --tabs`); opening Terminal.app from a button the user clicked in
1515
+ // the UI just hides the command they came here to watch.
1516
+ runtime: "daemon",
1517
+ env: getSudoEnv(),
1518
+ onLine: (line) => emit({ type: "line", text: line }),
1519
+ onClose: (exitCode) => {
1520
+ // Release outer-scope refs so the disconnect handler no longer
1521
+ // tries to kill a process/timer that's already gone.
1522
+ if (activeProc === proc) activeProc = null;
1523
+ if (detachFired) return; // detach timer already emitted svc-done for this service
1524
+ // Command finished before the detach window — cancel the timer and complete normally.
1525
+ if (detachTimer !== null) {
1526
+ clearTimeout(detachTimer);
1527
+ // Clear the shared ref BEFORE nulling the local, otherwise the
1528
+ // comparison is always false and activeDetachTimer stays stale.
1529
+ if (activeDetachTimer === detachTimer) activeDetachTimer = null;
1530
+ detachTimer = null;
1531
+ }
1532
+ if (exitCode !== 0) overallExit = exitCode;
1533
+ emit({
1534
+ type: "svc-done",
1535
+ address,
1536
+ exitCode,
1537
+ repoName: svcRepoName,
1538
+ subsystem: svcSubsystem,
1539
+ });
1540
+ safeRunNext(i + 1);
1541
+ },
1542
+ });
1543
+
1544
+ // Track current proc so req.on('close') can kill it on disconnect.
1545
+ activeProc = proc;
1546
+
1547
+ if (interactive) {
1548
+ // Can't run interactive commands over HTTP — skip with guidance
1549
+ emit({
1550
+ type: "error",
1551
+ text: `${address}: requires interactive terminal — run: ${product().cli} setup ${address}`,
1552
+ });
1553
+ safeRunNext(i + 1);
1554
+ return;
1555
+ }
1556
+
1557
+ if (detachable && (cmd === "up" || cmd === "restart")) {
1558
+ detachTimer = setTimeout(() => {
1559
+ detachFired = true;
1560
+ detachTimer = null;
1561
+ activeDetachTimer = null;
1562
+ try {
1563
+ proc.kill();
1564
+ } catch {
1565
+ /* tail may have already exited */
1566
+ }
1567
+ emit({
1568
+ type: "svc-done",
1569
+ address,
1570
+ exitCode: 0,
1571
+ detached: true,
1572
+ repoName: svcRepoName,
1573
+ subsystem: svcSubsystem,
1574
+ });
1575
+ safeRunNext(i + 1);
1576
+ }, DETACH_WINDOW_MS);
1577
+ activeDetachTimer = detachTimer;
1578
+ }
1579
+ };
1580
+
1581
+ const waitThenStart = (): void => {
1582
+ // Before starting an up/restart, block on any `wait: true` deps until
1583
+ // their health check is green. Default (no flagged deps) → doStart() now.
1584
+ if (cmd === "up" || cmd === "restart") {
1585
+ const waitDeps = getWaitDeps(address, catalog, ws.config, ws.name);
1586
+ if (waitDeps.length > 0) {
1587
+ void (async () => {
1588
+ for (const depAddr of waitDeps) {
1589
+ if (cancelled) return;
1590
+ const depResolved = resolveService(depAddr);
1591
+ if (!depResolved) {
1592
+ emit({
1593
+ type: "line",
1594
+ text: `⚠ wait dep ${depAddr} not resolvable — skipping readiness check`,
1595
+ });
1596
+ continue;
1597
+ }
1598
+ emit({
1599
+ type: "line",
1600
+ text: `waiting for ${depAddr} to be ready…`,
1601
+ });
1602
+ const result = await waitForReady(depResolved, "local", {
1603
+ onTick: (detail) =>
1604
+ emit({ type: "line", text: ` ${depAddr}: ${detail}` }),
1605
+ isCancelled: () => cancelled,
1606
+ });
1607
+ if (cancelled) return;
1608
+ if (!result.ok) {
1609
+ emit({
1610
+ type: "error",
1611
+ text: `timed out waiting for ${depAddr} after ${Math.round(result.elapsedMs / 1000)}s: ${result.detail ?? "not ready"}`,
1612
+ });
1613
+ overallExit = 1;
1614
+ emit({
1615
+ type: "svc-done",
1616
+ address,
1617
+ exitCode: 1,
1618
+ repoName: svcRepoName,
1619
+ subsystem: svcSubsystem,
1620
+ });
1621
+ safeRunNext(i + 1);
1622
+ return;
1623
+ }
1624
+ emit({
1625
+ type: "line",
1626
+ text: result.skipped
1627
+ ? `${depAddr}: no health check configured — proceeding`
1628
+ : `${depAddr} ready (${Math.round(result.elapsedMs / 1000)}s)`,
1629
+ });
1630
+ }
1631
+ if (cancelled) return;
1632
+ doStart();
1633
+ })();
1634
+ return;
1635
+ }
1636
+ }
1637
+
1638
+ doStart();
1639
+ };
1640
+
1641
+ // Workspace-wide runs consult LIVE state before acting (matches the
1642
+ // CLI's derived-up `skipHealthyAll` behavior). An explicit address
1643
+ // filter is a deliberate target and bypasses these checks:
1644
+ // - up: a service that already answers its health check is left alone
1645
+ // - down: a service with no running containers and no healthy probe
1646
+ // is already stopped — running its down script is pointless noise
1647
+ if (!addressSet && resolved && (cmd === "up" || cmd === "down")) {
1648
+ void (async () => {
1649
+ if (cmd === "up") {
1650
+ const health = await checkServiceHealth(resolved, "local");
1651
+ if (cancelled) return;
1652
+ if (health.health === "ok") {
1653
+ // Skipping must not lose the refcount — this workspace is an
1654
+ // owner of what's running, whether den tracked it or not.
1655
+ const claim = claimHealthyService(resolved, ws.name);
1656
+ emit({
1657
+ type: "line",
1658
+ text:
1659
+ `${address} already healthy — skipping up` +
1660
+ (claim === "adopted"
1661
+ ? " (adopted its running containers)"
1662
+ : claim === "attached"
1663
+ ? ` (owned by "${ws.name}" too)`
1664
+ : ""),
1665
+ });
1666
+ emit({
1667
+ type: "svc-done",
1668
+ address,
1669
+ exitCode: 0,
1670
+ skipped: true,
1671
+ repoName: svcRepoName,
1672
+ subsystem: svcSubsystem,
1673
+ });
1674
+ safeRunNext(i + 1);
1675
+ return;
1676
+ }
1677
+ } else {
1678
+ let running = hasRunningOpctlContainers(resolved);
1679
+ if (!running) {
1680
+ // Not opctl-visible but still responding (host-run process)?
1681
+ // Then it IS running — proceed with the down.
1682
+ const h = await checkServiceHealth(resolved, "local");
1683
+ running = h.health === "ok";
1684
+ }
1685
+ if (cancelled) return;
1686
+ if (!running) {
1687
+ emit({
1688
+ type: "line",
1689
+ text: `${address} already stopped — skipping down`,
1690
+ });
1691
+ emit({
1692
+ type: "svc-done",
1693
+ address,
1694
+ exitCode: 0,
1695
+ skipped: true,
1696
+ repoName: svcRepoName,
1697
+ subsystem: svcSubsystem,
1698
+ });
1699
+ safeRunNext(i + 1);
1700
+ return;
1701
+ }
1702
+ }
1703
+ waitThenStart();
1704
+ })();
1705
+ return;
1706
+ }
1707
+
1708
+ // Restart = orchestrated down → VERIFY → up. Phase 1 stops the service
1709
+ // (opctl-aware; "nothing to stop" counts as success) and only proceeds to
1710
+ // the up when the down actually succeeded — the old single-shell
1711
+ // "down && up" synthesis required both commands declared in the manifest
1712
+ // and gave no verification point. An already-stopped service skips the
1713
+ // down phase entirely (running its manifest `down` while stopped is
1714
+ // pointless and often exits non-zero).
1715
+ if (cmd === "restart") {
1716
+ void (async () => {
1717
+ let alreadyDown = resolved
1718
+ ? !hasRunningOpctlContainers(resolved)
1719
+ : false;
1720
+ if (alreadyDown && resolved) {
1721
+ // Not opctl-visible but still responding (non-opctl process)?
1722
+ // Then it IS running — go through the down phase.
1723
+ const h = await checkServiceHealth(resolved, "local");
1724
+ if (h.health === "ok") alreadyDown = false;
1725
+ }
1726
+ if (cancelled) return;
1727
+ if (alreadyDown) {
1728
+ emit({
1729
+ type: "line",
1730
+ text: "── already stopped — starting up ──",
1731
+ });
1732
+ waitThenStart();
1733
+ return;
1734
+ }
1735
+
1736
+ emit({ type: "line", text: "── restart: stopping ──" });
1737
+ prewarmSudo();
1738
+ const { proc: downProc } = runServiceCommand({
1739
+ address,
1740
+ command: "down",
1741
+ workspace: ws.name,
1742
+ env: getSudoEnv(),
1743
+ onLine: (line) => emit({ type: "line", text: line }),
1744
+ onClose: (downExit) => {
1745
+ if (activeProc === downProc) activeProc = null;
1746
+ if (cancelled) {
1747
+ try {
1748
+ res.end();
1749
+ } catch {
1750
+ /* already closed */
1751
+ }
1752
+ return;
1753
+ }
1754
+ if (downExit !== 0) {
1755
+ overallExit = downExit;
1756
+ emit({
1757
+ type: "error",
1758
+ text: `${address}: down failed (exit ${downExit}) — aborting restart`,
1759
+ });
1760
+ emit({
1761
+ type: "svc-done",
1762
+ address,
1763
+ exitCode: downExit,
1764
+ repoName: svcRepoName,
1765
+ subsystem: svcSubsystem,
1766
+ });
1767
+ safeRunNext(i + 1);
1768
+ return;
1769
+ }
1770
+ emit({ type: "line", text: "── down ✓ — starting up ──" });
1771
+ waitThenStart();
1772
+ },
1773
+ });
1774
+ activeProc = downProc;
1775
+ })();
1776
+ return;
1777
+ }
1778
+
1779
+ waitThenStart();
1780
+ };
1781
+
1782
+ runNext(0);
1783
+ return;
1784
+ }
1785
+
1786
+ // ── API: workspace sync (SSE) ─────────────────────────────────────────────
1787
+ /**
1788
+ * Run one command for a managed service, streaming its output into
1789
+ * the sync SSE. Resolves true on exit 0. No manifest pre-gating: the runner
1790
+ * itself owns command semantics (conventional `down` works with nothing
1791
+ * declared; a genuinely missing command surfaces as its error output).
1792
+ *
1793
+ * Detachable commands report on tail-detach rather than on health, so their
1794
+ * success line says "started" and the health dot carries the truth from
1795
+ * there. Sync itself only uses this for `down` — it never starts anything.
1796
+ */
1797
+ async function runManagedStep(
1798
+ address: string,
1799
+ repoName: string,
1800
+ command: string,
1801
+ emit: (o: unknown) => void,
1802
+ isCancelled: () => boolean,
1803
+ ): Promise<boolean> {
1804
+ emit({ type: "line", text: ` ${repoName}: ${command}…` });
1805
+ return new Promise<boolean>((resolve) => {
1806
+ let settled = false;
1807
+ let detached = false;
1808
+ const done = (ok: boolean): void => {
1809
+ if (settled) return;
1810
+ settled = true;
1811
+ resolve(ok);
1812
+ };
1813
+ try {
1814
+ const result = runServiceCommand({
1815
+ address,
1816
+ command,
1817
+ onLine: (line) => {
1818
+ if (!isCancelled()) emit({ type: "line", text: ` ${line}` });
1819
+ },
1820
+ onClose: (code) => {
1821
+ // For a detachable command the tail was likely killed by the
1822
+ // detach timer below — that close is bookkeeping, not a verdict.
1823
+ if (settled) return;
1824
+ if (code === 0) {
1825
+ emit({
1826
+ type: "line",
1827
+ text: detached
1828
+ ? ` ${repoName}: ${command} started (running in background)`
1829
+ : ` ${repoName}: ${command} ✓`,
1830
+ });
1831
+ } else {
1832
+ emit({
1833
+ type: "line",
1834
+ text: ` ${repoName}: ${command} failed (exit ${code})`,
1835
+ });
1836
+ }
1837
+ done(code === 0);
1838
+ },
1839
+ });
1840
+ // onClose is never invoked synchronously (see runServiceCommand), so
1841
+ // this assignment always lands before the wording above is needed.
1842
+ detached = result.detachable;
1843
+ if (result.detachable) {
1844
+ // Daemon tails stream INDEFINITELY — that's the live-log feature the
1845
+ // card exec route is built on, so onClose never fires on success and
1846
+ // this promise would never resolve (the first live run held the sync
1847
+ // stream open forever). Give the tail a detach window to surface
1848
+ // early crash output, then declare the service started, stop the
1849
+ // tail, and let the health dot carry the truth from here.
1850
+ setTimeout(() => {
1851
+ if (settled) return;
1852
+ try {
1853
+ result.proc.kill();
1854
+ } catch {
1855
+ /* tail may already be gone */
1856
+ }
1857
+ emit({
1858
+ type: "line",
1859
+ text: ` ${repoName}: ${command} started (detached — output continues in the service log)`,
1860
+ });
1861
+ done(true);
1862
+ }, DETACH_WINDOW_MS);
1863
+ }
1864
+ } catch (e) {
1865
+ emit({
1866
+ type: "line",
1867
+ text: ` ${repoName}: ${command} error — ${(e as Error).message}`,
1868
+ });
1869
+ done(false);
1870
+ }
1871
+ });
1872
+ }
1873
+
1874
+ if (pathname === "/api/workspace/sync") {
1875
+ res.writeHead(200, {
1876
+ "Content-Type": "text/event-stream",
1877
+ "Cache-Control": "no-cache",
1878
+ Connection: "keep-alive",
1879
+ "X-Accel-Buffering": "no",
1880
+ });
1881
+
1882
+ let cancelled = false;
1883
+ req.on("close", () => {
1884
+ cancelled = true;
1885
+ });
1886
+
1887
+ const emit = (obj: unknown): void => {
1888
+ if (cancelled) return;
1889
+ try {
1890
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
1891
+ } catch {
1892
+ cancelled = true;
1893
+ }
1894
+ };
1895
+
1896
+ const ws = getActiveWorkspace();
1897
+ if (!ws) {
1898
+ emit({ type: "error", text: "No active workspace" });
1899
+ emit({ type: "done" });
1900
+ res.end();
1901
+ return;
1902
+ }
1903
+
1904
+ const catalog = readCatalog();
1905
+ const services = listWorkspaceServices(ws.config, catalog, ws.name);
1906
+ // Optional ?repo=<name> syncs a single system (the card's ⟳ sync action);
1907
+ // omitted syncs the whole workspace (the master ⟳ Sync button).
1908
+ const onlyRepo = url.searchParams.get("repo");
1909
+ // Only sync managed services. Deduplicate by repoPath.
1910
+ const seen = new Set<string>();
1911
+ const targets = services.filter((s) => {
1912
+ if (s.mode !== "managed") return false;
1913
+ if (onlyRepo && s.repoName !== onlyRepo) return false;
1914
+ if (seen.has(s.repoPath)) return false;
1915
+ seen.add(s.repoPath);
1916
+ return true;
1917
+ });
1918
+
1919
+ if (targets.length === 0) {
1920
+ // Distinguish "you asked for a repo we can't sync" from "nothing to do",
1921
+ // so a stale card or a dev-managed repo doesn't look like a silent no-op.
1922
+ emit({
1923
+ type: "line",
1924
+ text: onlyRepo
1925
+ ? `No managed repo named "${onlyRepo}" in this workspace — nothing to sync.`
1926
+ : "No managed services to sync.",
1927
+ });
1928
+ emit({ type: "done" });
1929
+ res.end();
1930
+ return;
1931
+ }
1932
+
1933
+ emit({
1934
+ type: "line",
1935
+ text: onlyRepo
1936
+ ? `Checking ${onlyRepo} for updates…\n`
1937
+ : `Checking ${targets.length} managed repo(s) for updates…\n`,
1938
+ });
1939
+
1940
+ // Repos other repos read shared config from — a failure in one of these
1941
+ // must stop the chain, not just skip it (see the gates below).
1942
+ const providerRepos = serviceEnvProviderRepos();
1943
+
1944
+ for (const svc of targets) {
1945
+ if (cancelled) break;
1946
+ emit({ type: "line", text: ` ${svc.repoName} checking…` });
1947
+ // A sync chain that started (pull landed) but didn't finish (setup or up
1948
+ // failed / was interrupted) leaves the clone new and its environment or
1949
+ // run-state old. Without a marker the next sync sees behind=0, says
1950
+ // "up to date", and the service stays broken with no way back from this
1951
+ // button — which is exactly what happened the first time this ran. The
1952
+ // marker spans the WHOLE chain: written when we commit to it, cleared
1953
+ // only after `up` succeeds.
1954
+ const pendingMarker = path.join(
1955
+ svc.repoPath,
1956
+ `.${product().name}-setup-pending`,
1957
+ );
1958
+ const chainPending = fs.existsSync(pendingMarker);
1959
+ // A managed repo that was never cloned can't be checked, let
1960
+ // alone pulled — and syncing it IS the intent, so it bootstraps below
1961
+ // (down → clone → setup) instead of reporting "no upstream".
1962
+ const cloneExists = fs.existsSync(svc.repoPath);
1963
+ let behind = 0;
1964
+ if (cloneExists) {
1965
+ // Async spawn-based git so the event loop stays responsive — inline
1966
+ // execSync here previously froze the whole server for the duration of
1967
+ // each `git fetch` (5-30s per repo over VPN).
1968
+ const chk = await checkManagedCloneBehind(
1969
+ svc.repoPath,
1970
+ () => cancelled,
1971
+ );
1972
+ if (cancelled || chk.cancelled) break;
1973
+ if (chk.behind === -1) {
1974
+ emit({
1975
+ type: "line",
1976
+ text: ` ${svc.repoName}: no upstream — skipping`,
1977
+ });
1978
+ continue;
1979
+ }
1980
+ behind = chk.behind;
1981
+ if (behind === 0 && !chainPending) {
1982
+ emit({ type: "line", text: ` ${svc.repoName}: up to date ✓` });
1983
+ continue;
1984
+ }
1985
+ }
1986
+
1987
+ // Something to do: explicit down → (clone|pull) → setup. Down FIRST
1988
+ // is the point of the ordering — the service must never observe mixed
1989
+ // state (new code under an old venv is how files-api died: uvicorn
1990
+ // hot-reloaded mid-pull into an import its environment couldn't satisfy).
1991
+ // Down runs against the BARE repo name: it is conventional and needs no
1992
+ // manifest, which matters when the clone doesn't exist yet but the
1993
+ // containers do (started from another checkout).
1994
+ emit({
1995
+ type: "line",
1996
+ text: !cloneExists
1997
+ ? ` ${svc.repoName}: not cloned yet — bootstrapping (down → clone → setup)`
1998
+ : behind > 0
1999
+ ? ` ${svc.repoName}: ${behind} commit(s) behind — down → pull → setup`
2000
+ : ` ${svc.repoName}: up to date, but a previous sync did not finish — resuming setup`,
2001
+ });
2002
+ if (cloneExists) {
2003
+ try {
2004
+ fs.writeFileSync(pendingMarker, `${new Date().toISOString()}\n`);
2005
+ } catch {
2006
+ /* best-effort — worst case we just don't retry next time */
2007
+ }
2008
+ }
2009
+ prewarmSudo();
2010
+
2011
+ const downOk = await runManagedStep(
2012
+ svc.repoName,
2013
+ svc.repoName,
2014
+ "down",
2015
+ emit,
2016
+ () => cancelled,
2017
+ );
2018
+ if (cancelled) break;
2019
+ if (!downOk) {
2020
+ emit({
2021
+ type: "line",
2022
+ text: ` ${svc.repoName}: down failed — leaving this repo untouched (sync again to retry)`,
2023
+ });
2024
+ continue;
2025
+ }
2026
+
2027
+ if (!cloneExists) {
2028
+ // A catalog entry can exist with an empty `gh` (the dev-managed add
2029
+ // flow writes one), which used to read as "not in the catalog" and
2030
+ // skip the clone entirely. Derive the conventional slug instead, and
2031
+ // persist it so every other flow (CLI up/setup) stops hitting this.
2032
+ const ghSlug = resolveRepoGhSlug(svc.repoName);
2033
+ if (!readCatalog()[svc.repoName]?.gh) {
2034
+ upsertCatalogEntry(svc.repoName, { gh: ghSlug });
2035
+ }
2036
+ const branch = (svc as { branch?: string | null }).branch ?? "main";
2037
+ emit({
2038
+ type: "line",
2039
+ text: ` ${svc.repoName}: cloning ${ghSlug} (${branch})…`,
2040
+ });
2041
+ try {
2042
+ // Synchronous (same helper the workspace-add flow uses) — the server
2043
+ // is busy for the duration of the clone. runSetup=false: setup runs
2044
+ // below with prompt auto-answering and the retry marker.
2045
+ ensureManagedServiceClone(svc.repoName, ghSlug, branch, false);
2046
+ } catch (e) {
2047
+ emit({
2048
+ type: "line",
2049
+ text: ` ${svc.repoName}: clone failed — ${(e as Error).message}`,
2050
+ });
2051
+ continue;
2052
+ }
2053
+ emit({ type: "line", text: ` ${svc.repoName}: clone ✓` });
2054
+
2055
+ // A fresh clone carries stack's placeholder secrets, which blocks its
2056
+ // own `make setup`. When another checkout of this same repo already
2057
+ // holds real values, copy them across BEFORE setup runs — that is what
2058
+ // the engineer would do by hand, and it makes the common case
2059
+ // (a sibling checkout already configured) need no prompting at all.
2060
+ const seeded = seedSecretsFromSibling(svc.repoName, svc.repoPath);
2061
+ if (seeded) {
2062
+ emit({
2063
+ type: "line",
2064
+ text: ` ${svc.repoName}: filled ${seeded.filled.length} local secret(s) from ${seeded.donorPath} — ${seeded.filled.join(", ")}`,
2065
+ });
2066
+ }
2067
+ try {
2068
+ fs.writeFileSync(pendingMarker, `${new Date().toISOString()}\n`);
2069
+ } catch {
2070
+ /* best-effort */
2071
+ }
2072
+ }
2073
+
2074
+ if (cloneExists && behind > 0) {
2075
+ const result = await pullManagedClone(
2076
+ svc.repoPath,
2077
+ behind,
2078
+ () => cancelled,
2079
+ );
2080
+ if (cancelled || result.cancelled) break;
2081
+ if (!result.pulled) {
2082
+ emit({
2083
+ type: "line",
2084
+ text: ` ${svc.repoName}: pull failed — ${result.error ?? "unknown"}; left stopped and unchanged`,
2085
+ });
2086
+ // Marker stays: the next sync retries the whole chain.
2087
+ continue;
2088
+ }
2089
+ emit({
2090
+ type: "line",
2091
+ text: ` ${svc.repoName}: pulled ${result.behind} commit(s) ✓`,
2092
+ });
2093
+ }
2094
+
2095
+ // Single non-interactive setup authority, shared with the CLI's clone
2096
+ // bootstrap (runSetupHidden): env-hardened, tty-less, stdin auto-answers
2097
+ // "n", stall detector kills anything that still blocks. Resolves the
2098
+ // setup command itself (manifest first, else `make setup`) and no-ops
2099
+ // cleanly when there is nothing to run — so no pre-gating here. The
2100
+ // prompt text streams below; whatever was declined is visible.
2101
+ emit({
2102
+ type: "line",
2103
+ text: ` ${svc.repoName}: running setup (non-interactive — prompts auto-answered "n"; use \`${product().cli} setup ${svc.repoName}\` for interactive control)`,
2104
+ });
2105
+ const setupRes = await runSetupHidden(svc.repoPath, (line) => {
2106
+ if (!cancelled) emit({ type: "line", text: ` ${line}` });
2107
+ });
2108
+ if (cancelled) break;
2109
+ if (setupRes.stalledPrompt) {
2110
+ emit({
2111
+ type: "line",
2112
+ text: ` ${svc.repoName}: setup stalled on a prompt (${setupRes.stalledPrompt}) and was killed — run \`${product().cli} setup ${svc.repoName}\` interactively`,
2113
+ });
2114
+ }
2115
+ if (setupRes.code !== 0) {
2116
+ // Starting a service whose environment no longer matches its code is
2117
+ // the crash-loop this chain exists to prevent — stay down, honestly.
2118
+ emit({
2119
+ type: "line",
2120
+ text: ` ${svc.repoName}: setup failed (exit ${setupRes.code}) — leaving stopped; sync again to retry`,
2121
+ });
2122
+ if (providerRepos.has(svc.repoName)) {
2123
+ emit({
2124
+ type: "line",
2125
+ text: ` ${svc.repoName}: other services read shared config from this repo — stopping the sync here rather than starting them against a half-configured dependency`,
2126
+ });
2127
+ break;
2128
+ }
2129
+ continue;
2130
+ }
2131
+ emit({ type: "line", text: ` ${svc.repoName}: setup ✓` });
2132
+
2133
+ // Sync deliberately ENDS here. It brings the checkout up to date; it does
2134
+ // not change run state. Starting a service is an explicit action the
2135
+ // engineer takes from the card (▶ up), so a sync can never surprise them
2136
+ // by booting something — least of all against config they haven't looked
2137
+ // at yet. The marker spans clone/pull → setup, so clear it now that the
2138
+ // chain has finished.
2139
+ try {
2140
+ fs.unlinkSync(pendingMarker);
2141
+ } catch {
2142
+ /* best-effort */
2143
+ }
2144
+ emit({
2145
+ type: "line",
2146
+ text: ` ${svc.repoName}: synced ✓ — not started (use ▶ up on the card when you're ready)`,
2147
+ });
2148
+ }
2149
+
2150
+ emit({ type: "done" });
2151
+ res.end();
2152
+ return;
2153
+ }
2154
+ }