@monster-orange/den 0.0.0 → 0.2.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 (255) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +68 -0
  5. package/docs/distributions.md +499 -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 +101 -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 +403 -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 +124 -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 +757 -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/agent-actor.ts +77 -0
  91. package/src/shared/applications.ts +138 -0
  92. package/src/shared/auth/api-token.ts +416 -0
  93. package/src/shared/auth/auth0-helper.ts +284 -0
  94. package/src/shared/auth/headless-login.ts +265 -0
  95. package/src/shared/auth/jwt-helper.ts +186 -0
  96. package/src/shared/auth/sudo-vault.ts +69 -0
  97. package/src/shared/auth/test-accounts.ts +98 -0
  98. package/src/shared/auth/token-injection.ts +184 -0
  99. package/src/shared/auth/token-providers/auth0.ts +80 -0
  100. package/src/shared/auth/token-providers/index.ts +182 -0
  101. package/src/shared/auth/token-refresh.ts +273 -0
  102. package/src/shared/banner.ts +15 -0
  103. package/src/shared/brand.ts +79 -0
  104. package/src/shared/cli-tabs.ts +131 -0
  105. package/src/shared/constants.ts +128 -0
  106. package/src/shared/den-config.ts +688 -0
  107. package/src/shared/dev-metrics/emit.ts +354 -0
  108. package/src/shared/edge/config.ts +229 -0
  109. package/src/shared/edge/render.ts +221 -0
  110. package/src/shared/edge/runtime.ts +268 -0
  111. package/src/shared/edge/status.ts +74 -0
  112. package/src/shared/edge/types.ts +62 -0
  113. package/src/shared/engine.ts +67 -0
  114. package/src/shared/environment/aws-profiles.ts +49 -0
  115. package/src/shared/environment/env-selector.ts +161 -0
  116. package/src/shared/environment/playwright-env.ts +82 -0
  117. package/src/shared/environment-manager/catalog.ts +77 -0
  118. package/src/shared/environment-manager/clone-guard.ts +163 -0
  119. package/src/shared/environment-manager/dag.ts +286 -0
  120. package/src/shared/environment-manager/default-workspace.ts +182 -0
  121. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  122. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  123. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  124. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  125. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  126. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  127. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  128. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  129. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  130. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  131. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  132. package/src/shared/environment-manager/derivation/text.ts +40 -0
  133. package/src/shared/environment-manager/derivation/types.ts +69 -0
  134. package/src/shared/environment-manager/health.ts +635 -0
  135. package/src/shared/environment-manager/index.ts +6 -0
  136. package/src/shared/environment-manager/load-applications.ts +108 -0
  137. package/src/shared/environment-manager/log-tail.ts +213 -0
  138. package/src/shared/environment-manager/make-targets.ts +78 -0
  139. package/src/shared/environment-manager/opctl.ts +335 -0
  140. package/src/shared/environment-manager/readiness.ts +93 -0
  141. package/src/shared/environment-manager/register-local.ts +72 -0
  142. package/src/shared/environment-manager/secrets-check.ts +260 -0
  143. package/src/shared/environment-manager/service-container.ts +47 -0
  144. package/src/shared/environment-manager/service-env.ts +110 -0
  145. package/src/shared/environment-manager/service-runner.ts +1603 -0
  146. package/src/shared/environment-manager/sync-targets.ts +66 -0
  147. package/src/shared/environment-manager/up-plan.ts +326 -0
  148. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  149. package/src/shared/environment-manager/workspaces.ts +860 -0
  150. package/src/shared/manifest/deps.ts +27 -0
  151. package/src/shared/manifest/merge-override.ts +75 -0
  152. package/src/shared/manifest/migrate-manifest.ts +101 -0
  153. package/src/shared/manifest/placement.ts +321 -0
  154. package/src/shared/manifest/read-manifest.ts +278 -0
  155. package/src/shared/manifest/schema.ts +313 -0
  156. package/src/shared/manifest/service-links.ts +93 -0
  157. package/src/shared/manifest/validate-layers.ts +229 -0
  158. package/src/shared/manifest/validate-manifest.ts +200 -0
  159. package/src/shared/manifest/workspace-manifests.ts +92 -0
  160. package/src/shared/open-url.ts +122 -0
  161. package/src/shared/org.ts +44 -0
  162. package/src/shared/paths.ts +190 -0
  163. package/src/shared/perf/optional-fields.ts +105 -0
  164. package/src/shared/perf/report-normalizer.ts +404 -0
  165. package/src/shared/perf/report-paths.ts +19 -0
  166. package/src/shared/perf/report-store.ts +110 -0
  167. package/src/shared/perf/report-types.ts +70 -0
  168. package/src/shared/perf/test-paths.ts +25 -0
  169. package/src/shared/process/log-rotation.ts +147 -0
  170. package/src/shared/process/spawn-streaming.ts +229 -0
  171. package/src/shared/product.ts +92 -0
  172. package/src/shared/profile.ts +141 -0
  173. package/src/shared/redact.ts +107 -0
  174. package/src/shared/repo-kind.ts +32 -0
  175. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  176. package/src/shared/reporting/slack-reporter.ts +340 -0
  177. package/src/shared/results/result-reader.ts +266 -0
  178. package/src/shared/results/result-writer.ts +168 -0
  179. package/src/shared/secrets/doppler.ts +126 -0
  180. package/src/shared/secrets/env.ts +48 -0
  181. package/src/shared/secrets/index.ts +197 -0
  182. package/src/shared/secrets/keys.ts +33 -0
  183. package/src/shared/secrets/onepassword.ts +75 -0
  184. package/src/shared/secrets/types.ts +52 -0
  185. package/src/shared/service-url.ts +119 -0
  186. package/src/shared/smoke/branch-resolver.ts +141 -0
  187. package/src/shared/smoke/git-remote.ts +70 -0
  188. package/src/shared/smoke/repo-cloner.ts +151 -0
  189. package/src/shared/stacks/config.ts +137 -0
  190. package/src/shared/stacks/manifest-commands.ts +118 -0
  191. package/src/shared/stacks/render.ts +253 -0
  192. package/src/shared/stacks/runtime.ts +334 -0
  193. package/src/shared/stacks/status.ts +99 -0
  194. package/src/shared/stacks/types.ts +53 -0
  195. package/src/shared/terminal/tabs.ts +212 -0
  196. package/src/shared/test-discovery/module-scanner.ts +569 -0
  197. package/src/shared/types/index.ts +701 -0
  198. package/src/shared/ui-marker.ts +19 -0
  199. package/src/ui/cli/applications-command.ts +148 -0
  200. package/src/ui/cli/config-command.ts +68 -0
  201. package/src/ui/cli/create-main.ts +9 -0
  202. package/src/ui/cli/create.ts +360 -0
  203. package/src/ui/cli/edge-command.ts +81 -0
  204. package/src/ui/cli/flag-parser.ts +246 -0
  205. package/src/ui/cli/graph-command.ts +570 -0
  206. package/src/ui/cli/health-display.ts +63 -0
  207. package/src/ui/cli/index.ts +3742 -0
  208. package/src/ui/cli/init.ts +855 -0
  209. package/src/ui/cli/invocation-target.ts +175 -0
  210. package/src/ui/cli/logs-command.ts +104 -0
  211. package/src/ui/cli/mcp-main.ts +13 -0
  212. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  213. package/src/ui/cli/module-selection.ts +31 -0
  214. package/src/ui/cli/prompts.ts +767 -0
  215. package/src/ui/cli/results-viewer.ts +436 -0
  216. package/src/ui/cli/secrets-preflight.ts +91 -0
  217. package/src/ui/cli/stack-command.ts +117 -0
  218. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  219. package/src/ui/cli/test-runner.ts +1319 -0
  220. package/src/ui/cli/web-command.ts +1073 -0
  221. package/src/ui/mcp/api.ts +284 -0
  222. package/src/ui/mcp/guards.ts +141 -0
  223. package/src/ui/mcp/server.ts +147 -0
  224. package/src/ui/mcp/tools.ts +565 -0
  225. package/src/ui/server/audit.ts +71 -0
  226. package/src/ui/server/changelog-parser.ts +105 -0
  227. package/src/ui/server/daemon-reaper.ts +129 -0
  228. package/src/ui/server/editor-manager.ts +301 -0
  229. package/src/ui/server/github-service.ts +223 -0
  230. package/src/ui/server/graph-prefs.ts +89 -0
  231. package/src/ui/server/health-row.ts +15 -0
  232. package/src/ui/server/http-helpers.ts +98 -0
  233. package/src/ui/server/index.ts +3350 -0
  234. package/src/ui/server/log-history.ts +453 -0
  235. package/src/ui/server/perf-report.ts +42 -0
  236. package/src/ui/server/public/environments.html +496 -0
  237. package/src/ui/server/public/favicon.ico +0 -0
  238. package/src/ui/server/public/graph-model.js +416 -0
  239. package/src/ui/server/public/graph.css +976 -0
  240. package/src/ui/server/public/graph.js +986 -0
  241. package/src/ui/server/public/header.js +1373 -0
  242. package/src/ui/server/public/index.html +2595 -0
  243. package/src/ui/server/public/local-dev.html +5344 -0
  244. package/src/ui/server/public/logs.html +1496 -0
  245. package/src/ui/server/public/panel.js +787 -0
  246. package/src/ui/server/public/perf-report.js +359 -0
  247. package/src/ui/server/public/release.html +2111 -0
  248. package/src/ui/server/public/services.html +1103 -0
  249. package/src/ui/server/public/workspace-picker.js +69 -0
  250. package/src/ui/server/readonly-routes.ts +200 -0
  251. package/src/ui/server/release-deploy-session.ts +246 -0
  252. package/src/ui/server/release-service.ts +1125 -0
  253. package/src/ui/server/sudo-store.ts +111 -0
  254. package/src/ui/server/ui-assets.ts +73 -0
  255. package/src/ui/server/workspace-routes.ts +2185 -0
@@ -0,0 +1,3742 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Den CLI
5
+ * Main entry point for the interactive CLI
6
+ */
7
+
8
+ import chalk from "chalk";
9
+ import oraSpinner from "ora";
10
+
11
+ /**
12
+ * Spinners are progress, not diagnostics: on a terminal they animate on
13
+ * stderr as usual; piped (CI, scripts) their lines go to stdout so stderr
14
+ * carries only real warnings and errors.
15
+ */
16
+ const ora = (text: string) =>
17
+ oraSpinner({
18
+ text,
19
+ stream: process.stderr.isTTY ? process.stderr : process.stdout,
20
+ });
21
+ import Table from "cli-table3";
22
+ import { cliPrompts } from "./prompts.js";
23
+ import { resultsViewer } from "./results-viewer.js";
24
+ import { testRunner } from "./test-runner.js";
25
+ import { envSelector } from "../../shared/environment/env-selector.js";
26
+ import {
27
+ recordInvocation,
28
+ recordFailure,
29
+ startTimer,
30
+ } from "../../shared/dev-metrics/emit.js";
31
+ import type { Environment, Pillar } from "../../shared/types/index.js";
32
+ import { moduleScanner } from "../../shared/test-discovery/module-scanner.js";
33
+ import { resolvePerformanceModules } from "./module-selection.js";
34
+ import {
35
+ readCatalog,
36
+ resolveRepoGhSlug,
37
+ upsertCatalogEntry,
38
+ } from "../../shared/environment-manager/catalog.js";
39
+ import { seedSecretsFromSibling } from "../../shared/environment-manager/secrets-check.js";
40
+ import {
41
+ readWorkspacesFile,
42
+ writeWorkspacesFile,
43
+ getActiveWorkspace,
44
+ getFocusedWorkspaceName,
45
+ isWorkspaceActive,
46
+ activateWorkspace,
47
+ deactivateWorkspace,
48
+ setActiveWorkspace,
49
+ createWorkspace,
50
+ deleteWorkspaceConfig,
51
+ parseAddress,
52
+ noAutostartRepos,
53
+ generateVSCodeWorkspace,
54
+ syncJetBrainsWorkspaceSymlinks,
55
+ DEN_SERVICES_DIR,
56
+ DEN_WORKSPACES_DIR,
57
+ } from "../../shared/environment-manager/workspaces.js";
58
+ import {
59
+ deleteWorkspaceDirectory,
60
+ checkBehindCount,
61
+ pullRepo,
62
+ ensureManagedServiceClone,
63
+ runSetupHidden,
64
+ runSetupInteractive,
65
+ hasSetupSucceeded,
66
+ hasSetupCommand,
67
+ markSetupSucceeded,
68
+ fetchAndFastForward,
69
+ } from "../../shared/environment-manager/workspace-manager.js";
70
+ import {
71
+ runServiceCommand,
72
+ claimHealthyService,
73
+ getWorkspaceServices,
74
+ ensureDaemonRegistryLoaded,
75
+ hasRunningOpctlContainers,
76
+ releaseWorkspaceDaemons,
77
+ resolveServiceRuntime,
78
+ } from "../../shared/environment-manager/service-runner.js";
79
+ import {
80
+ defaultWorkspaceRepos,
81
+ defaultWorkspaceNeedsSeeding,
82
+ defaultWorkspaceHasServices,
83
+ seedDefaultWorkspace,
84
+ topUpDefaultWorkspace,
85
+ markDefaultWorkspaceDeclined,
86
+ } from "../../shared/environment-manager/default-workspace.js";
87
+ import {
88
+ checkCatalogHealth,
89
+ rollupRepoHealth,
90
+ sortByRepoOrder,
91
+ } from "../../shared/environment-manager/health.js";
92
+ import { checkServiceHealth } from "../../shared/environment-manager/health.js";
93
+ import {
94
+ resolveStartupOrder,
95
+ getWaitDeps,
96
+ } from "../../shared/environment-manager/dag.js";
97
+ import { waitForReady } from "../../shared/environment-manager/readiness.js";
98
+ import { resolveService } from "../../shared/environment-manager/service-runner.js";
99
+ import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
100
+ import {
101
+ getReposRoot,
102
+ mergeUserConfig,
103
+ readDenConfig,
104
+ isProductionEnv,
105
+ readUserConfig,
106
+ } from "../../shared/den-config.js";
107
+ import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
108
+ import {
109
+ hardDepsOf,
110
+ orderForUp,
111
+ orderForDown,
112
+ deriveStartTargets,
113
+ } from "../../shared/environment-manager/derivation/derived-up.js";
114
+ import type { DerivedGraph } from "../../shared/environment-manager/derivation/types.js";
115
+ import { loadAliasResolver } from "../../shared/environment-manager/derivation/load-alias.js";
116
+ import {
117
+ cmdApps,
118
+ describeContributor,
119
+ focusedAppRepos,
120
+ takeAppFlag,
121
+ } from "./applications-command.js";
122
+ import { loadApplications } from "../../shared/environment-manager/load-applications.js";
123
+ import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
124
+ import type { RepoKind, ValidationResult } from "../../shared/types/index.js";
125
+ import { localCommandsFor } from "../../shared/stacks/manifest-commands.js";
126
+ import {
127
+ buildUpPlan,
128
+ type NodePlan,
129
+ } from "../../shared/environment-manager/up-plan.js";
130
+ import { execSync } from "child_process";
131
+ import * as fs from "fs";
132
+ import { readManifest } from "../../shared/manifest/read-manifest.js";
133
+ import * as nodePath from "path";
134
+ import { runInit } from "./init.js";
135
+ import {
136
+ validateManifest,
137
+ validateManifestLayers,
138
+ } from "../../shared/manifest/validate-manifest.js";
139
+ import {
140
+ placementRefusal,
141
+ placementSuffix,
142
+ servicePlacement,
143
+ } from "../../shared/manifest/placement.js";
144
+ import {
145
+ getDistRoot,
146
+ getEngineRoot,
147
+ getInvokeCwd,
148
+ } from "../../shared/paths.js";
149
+ import { engineBin } from "../../shared/engine.js";
150
+ import { runCreate } from "./create.js";
151
+ import {
152
+ confirmWithTimeout,
153
+ recoverStalledSetup,
154
+ STALLED_SETUP_CONFIRM_TIMEOUT_MS,
155
+ } from "./stalled-setup-recovery.js";
156
+ import { cleanupStaleTabs } from "../../shared/cli-tabs.js";
157
+ import { cmdConfig } from "./config-command.js";
158
+ import { cmdEdge } from "./edge-command.js";
159
+ import { cmdStack } from "./stack-command.js";
160
+ import { cmdWeb, displayWebHelp } from "./web-command.js";
161
+ import { cmdGraph } from "./graph-command.js";
162
+ import { resolveLogsTarget, tailServiceLogs } from "./logs-command.js";
163
+ import {
164
+ parseCliArgs,
165
+ unknownCommandFlags,
166
+ workspaceFlagRequiresActive,
167
+ type ParsedCliArgs,
168
+ } from "./flag-parser.js";
169
+ import {
170
+ resolveInvocationTarget,
171
+ findInvocationRepoRoot,
172
+ resolveCwdRepo,
173
+ } from "./invocation-target.js";
174
+ import { preflightSecrets } from "./secrets-preflight.js";
175
+ import {
176
+ formatHealthDot,
177
+ formatHealthLabel,
178
+ formatHealthReason,
179
+ } from "./health-display.js";
180
+ import { DEN_BANNER_LINES, DEN_DIVIDER } from "../../shared/banner.js";
181
+
182
+ import { product } from "../../shared/product.js";
183
+ const DEN_HELP_DESCRIPTION = ` ${product().title} - local-first test, service, and workspace orchestration`;
184
+
185
+ /**
186
+ * Concise `<command> -h` help for commands without a dedicated help renderer.
187
+ * Flag-heavy commands (up, graph, use-local, use-managed) have richer handlers.
188
+ */
189
+ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
190
+ down: {
191
+ usage: "down [repo] [flags]",
192
+ lines: [
193
+ "Stop the cwd (or named) repo's derived dependency closure, in reverse order.",
194
+ "-a/--all stops EVERY workspace system (reverse derived order, cwd-agnostic;",
195
+ "every down script runs — no already-stopped skip).",
196
+ "Flags: -a/--all · -A/--app <app> · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
197
+ ],
198
+ },
199
+ restart: {
200
+ usage: "restart [service...] [-A <app>]",
201
+ lines: [
202
+ `Restart the given services, or every repo in an application (\`${product().cli} apps\`).`,
203
+ ],
204
+ },
205
+ status: {
206
+ usage: "status [-A <app>]",
207
+ lines: [
208
+ "Show service health for the active workspace (or one application).",
209
+ ],
210
+ },
211
+ logs: {
212
+ usage: "logs [repo|repo/subsystem]",
213
+ lines: [
214
+ "Tail live local logs for the cwd (or named) repo's front-door service.",
215
+ "Sources, in order: the manifest's logs.local command, the service's",
216
+ `running container (docker logs -f), or the ${product().cli} daemon startup log.`,
217
+ `If the repo isn't running, offers to start it first (${product().cli} up).`,
218
+ ],
219
+ },
220
+ services: {
221
+ usage: "services",
222
+ lines: ["List all registered services in the catalog."],
223
+ },
224
+ register: {
225
+ usage: "register [path]",
226
+ lines: [
227
+ "With a path: add that checkout (dev-managed) under its manifest name.",
228
+ "Without one: pick the repo interactively.",
229
+ ],
230
+ },
231
+ setup: {
232
+ usage: "setup [service...] [--force|-f]",
233
+ lines: [
234
+ "Run 'make setup' once per system (clones missing managed repos first).",
235
+ "Default targets MANAGED clones only — setup's codegen/installs",
236
+ "shouldn't touch your own dev-managed checkouts uninvited.",
237
+ "--force (-f) includes dev-managed repos; naming a service always works.",
238
+ ],
239
+ },
240
+ sync: {
241
+ usage: "sync [service...]",
242
+ lines: ["Check managed repos for upstream updates and offer to pull."],
243
+ },
244
+ workspace: {
245
+ usage: "workspace <list|use|create|delete|code> [name]",
246
+ lines: ["Manage workspaces (named sets of services + their modes)."],
247
+ },
248
+ run: {
249
+ usage: "run [options]",
250
+ lines: ["Run tests (interactive picker, or pillar/module/env flags)."],
251
+ },
252
+ "view-results": {
253
+ usage: "view-results",
254
+ lines: ["Browse recent test results."],
255
+ },
256
+ create: {
257
+ usage:
258
+ "create <name> [dir] [--title <title>] [--cli <cli>] [--env-prefix <PREFIX>]",
259
+ lines: [
260
+ "Scaffold a new distribution (den.yml, config, bin shim, Makefile) in [dir].",
261
+ "Also: npx @monster-orange/den create <name>. See docs/distributions.md.",
262
+ ],
263
+ },
264
+ validate: {
265
+ usage: "validate [path|url]",
266
+ lines: [
267
+ `Validate a ${product().manifestYml} manifest (defaults to the cwd repo).`,
268
+ ],
269
+ },
270
+ init: {
271
+ usage: "init [repo-dir]",
272
+ lines: [
273
+ `In the ${product().cli} root: seed/reset the default workspace (all repos managed).`,
274
+ `In a service repo: scaffold that repo's ${product().manifestYml} manifest.`,
275
+ ],
276
+ },
277
+ config: {
278
+ usage: "config [get|set ...]",
279
+ lines: [`View or edit ${product().cli} configuration.`],
280
+ },
281
+ apps: {
282
+ usage: "apps [--json] (alias: applications)",
283
+ lines: [
284
+ "Org > Application > Repo > Services. Applications: config `applications:`,",
285
+ "manifest `application:`; any other repo is its own application.",
286
+ "Act on one: up|down|restart|status -A <app>.",
287
+ ],
288
+ },
289
+ stack: {
290
+ usage:
291
+ "stack [ls|up|down|restart|health|ps|logs] <name…> [--build] [-w <workspace>]",
292
+ lines: [
293
+ "Run repos' compose stacks with no host ports, at https://<route>.<stack>.<edge.domain>.",
294
+ ],
295
+ },
296
+ edge: {
297
+ usage: "edge [up|down|status|trust|untrust|routes]",
298
+ lines: [
299
+ "This org's HTTPS edge: own CA + cert, routes at <name>.<edge.domain>.",
300
+ ],
301
+ },
302
+ mcp: {
303
+ usage: "mcp [--port <web port>]",
304
+ lines: [
305
+ "An MCP server over stdio for agents: a client of the running web server",
306
+ `(\`${product().cli} web up\`). Read tools always; actions only as named in mcp.actions.`,
307
+ ],
308
+ },
309
+ "local-dev": {
310
+ usage: "local-dev [on|off]",
311
+ lines: [
312
+ "Toggle the local-dev experience (web Local Dev section + service mgmt).",
313
+ ],
314
+ },
315
+ menu: {
316
+ usage: "menu",
317
+ lines: ["Open the interactive menu (pillar/module/env picker)."],
318
+ },
319
+ };
320
+
321
+ function resolveCliBaseApiUrl(explicitBaseApiUrl?: string): string | undefined {
322
+ return (
323
+ explicitBaseApiUrl?.trim() ||
324
+ process.env.BASE_URL ||
325
+ process.env.BASE_API_URL ||
326
+ undefined
327
+ );
328
+ }
329
+
330
+ // ── Dev-tooling telemetry helpers ─────────────────────────────────────────────
331
+
332
+ /**
333
+ * Registered den CLI commands — the ONLY values allowed in the telemetry
334
+ * `command` attribute. Unknown input collapses to "unknown" so raw argv (which
335
+ * can contain paths or secrets) never reaches telemetry.
336
+ */
337
+ const TELEMETRY_COMMANDS = new Set<string>([
338
+ "run",
339
+ "view-results",
340
+ "menu",
341
+ "web",
342
+ "up",
343
+ "down",
344
+ "restart",
345
+ "status",
346
+ "logs",
347
+ "services",
348
+ "graph",
349
+ "register",
350
+ "use-local",
351
+ `use-managed`,
352
+ "setup",
353
+ "workspace",
354
+ "sync",
355
+ "init",
356
+ "validate",
357
+ "create",
358
+ "local-dev",
359
+ "config",
360
+ "edge",
361
+ "stack",
362
+ "apps",
363
+ "applications",
364
+ "mcp",
365
+ ]);
366
+
367
+ /** Map a raw command token to a PII-safe, registered telemetry command name. */
368
+ function telemetryCommandName(command?: string): string {
369
+ if (!command) return "none";
370
+ if (command === "--help" || command === "-h") return "help";
371
+ if (command === "--version" || command === "-v") return "version";
372
+ return TELEMETRY_COMMANDS.has(command) ? command : "unknown";
373
+ }
374
+
375
+ /** Error *class* for telemetry (never the message — PII). */
376
+ function telemetryReason(error: unknown): string {
377
+ const name = (error as { constructor?: { name?: string } })?.constructor
378
+ ?.name;
379
+ return name && name !== "Object" ? name : "Error";
380
+ }
381
+
382
+ /** Print one manifest's validation: a status line, then each issue. */
383
+ function printValidation(
384
+ label: string,
385
+ result: ValidationResult,
386
+ layer?: string,
387
+ ): void {
388
+ const tag = layer ? `${layer}, ` : "";
389
+ const plural = (n: number, word: string): string =>
390
+ `${n} ${word}${n === 1 ? "" : "s"}`;
391
+ if (result.valid && result.warnings.length === 0) {
392
+ console.log(
393
+ chalk.green(` ✓ ${label}`) +
394
+ chalk.gray(` (${tag}v${result.version}, no issues)`),
395
+ );
396
+ } else if (result.valid) {
397
+ console.log(
398
+ chalk.yellow(` ⚠ ${label}`) +
399
+ chalk.gray(
400
+ ` (${tag}v${result.version}, ${plural(result.warnings.length, "warning")})`,
401
+ ),
402
+ );
403
+ } else {
404
+ console.log(
405
+ chalk.red(` ✗ ${label}`) +
406
+ chalk.gray(
407
+ ` (${tag}v${result.version}, ${plural(result.errors.length, "error")}${result.warnings.length > 0 ? `, ${plural(result.warnings.length, "warning")}` : ""})`,
408
+ ),
409
+ );
410
+ }
411
+
412
+ for (const err of result.errors) {
413
+ const path = err.path ? chalk.gray(err.path) + " " : "";
414
+ console.log(` ${chalk.red("ERROR")} ${path}${err.message}`);
415
+ if (err.suggestion) {
416
+ console.log(` ${chalk.gray("→ " + err.suggestion)}`);
417
+ }
418
+ }
419
+ for (const warn of result.warnings) {
420
+ const path = warn.path ? chalk.gray(warn.path) + " " : "";
421
+ console.log(` ${chalk.yellow("WARNING")} ${path}${warn.message}`);
422
+ if (warn.suggestion) {
423
+ console.log(` ${chalk.gray("→ " + warn.suggestion)}`);
424
+ }
425
+ }
426
+ }
427
+
428
+ class DenCLI {
429
+ private static readonly serviceCommands = new Set([
430
+ "up",
431
+ "down",
432
+ "restart",
433
+ "status",
434
+ "logs",
435
+ "services",
436
+ ]);
437
+
438
+ /**
439
+ * Display the branded banner for the help screen.
440
+ */
441
+ private displayHelpBanner(): void {
442
+ console.log();
443
+ for (const line of DEN_BANNER_LINES) {
444
+ console.log(chalk.cyan(line));
445
+ }
446
+ console.log();
447
+ console.log(chalk.bold(DEN_HELP_DESCRIPTION));
448
+ console.log(chalk.gray(DEN_DIVIDER));
449
+ console.log();
450
+ }
451
+
452
+ /**
453
+ * Main CLI loop
454
+ */
455
+ async run(): Promise<void> {
456
+ // Parse command line arguments
457
+ const args = process.argv.slice(2);
458
+
459
+ // Dev-tooling telemetry: one `devtool.den.invocation` per CLI run. The
460
+ // exit hook captures every termination path (normal completion,
461
+ // process.exit, error) with the real exit code and writes the JSONL line
462
+ // synchronously. `telCmd` is always a REGISTERED command name (never raw
463
+ // argv — PII); it is refined once the args parse successfully.
464
+ const stopTimer = startTimer();
465
+ let telCmd = telemetryCommandName(args.find((a) => !a.startsWith("-")));
466
+ process.once("exit", (code) => {
467
+ recordInvocation({
468
+ command: telCmd,
469
+ surface: "cli",
470
+ durationMs: stopTimer(),
471
+ exit: code,
472
+ });
473
+ });
474
+
475
+ if (args.length > 0) {
476
+ try {
477
+ const parsed = parseCliArgs(args);
478
+ telCmd = telemetryCommandName(parsed.command);
479
+ this.applyGlobalFlags(parsed);
480
+ this.prepareServiceCommand(parsed.command);
481
+ await this.handleCommand(
482
+ parsed.command ? [parsed.command, ...parsed.args] : [],
483
+ );
484
+ } catch (error) {
485
+ recordFailure({
486
+ command: telCmd,
487
+ surface: "cli",
488
+ reason: telemetryReason(error),
489
+ durationMs: stopTimer(),
490
+ });
491
+ console.error(chalk.red(`\n❌ Error: ${(error as Error).message}`));
492
+ process.exit(1);
493
+ }
494
+ return;
495
+ }
496
+
497
+ // Bare `den` shows help. The interactive menu is `den menu`
498
+ // (what `make test` / `npm run cli` invoke).
499
+ this.displayHelp();
500
+ process.exit(0);
501
+ }
502
+
503
+ /**
504
+ * Handle command line arguments
505
+ */
506
+ private applyGlobalFlags(parsed: ParsedCliArgs): void {
507
+ if (parsed.flags.workspace) {
508
+ const file = readWorkspacesFile();
509
+ if (!file.workspaces[parsed.flags.workspace]) {
510
+ throw new Error(`Workspace "${parsed.flags.workspace}" does not exist`);
511
+ }
512
+ if (
513
+ workspaceFlagRequiresActive(parsed.command ?? "") &&
514
+ !isWorkspaceActive(parsed.flags.workspace)
515
+ ) {
516
+ throw new Error(
517
+ `Workspace "${parsed.flags.workspace}" is not active — run \`${product().cli} workspace activate ${parsed.flags.workspace}\` first`,
518
+ );
519
+ }
520
+ process.env.DEN_WORKSPACE_OVERRIDE = parsed.flags.workspace;
521
+ }
522
+
523
+ if (parsed.flags.forceTabs !== undefined) {
524
+ process.env.DEN_FORCE_TABS = parsed.flags.forceTabs ? "1" : "0";
525
+ }
526
+
527
+ if (parsed.flags.skipSecrets) {
528
+ process.env.DEN_SKIP_SECRETS = "1";
529
+ }
530
+ }
531
+
532
+ private prepareServiceCommand(command: string | undefined): void {
533
+ if (!command || !DenCLI.serviceCommands.has(command)) return;
534
+ ensureDaemonRegistryLoaded();
535
+ cleanupStaleTabs();
536
+ }
537
+
538
+ private async handleCommand(args: string[]): Promise<void> {
539
+ const command = args[0];
540
+
541
+ // Args before the first `--` terminator are den's own; everything
542
+ // after it is passthrough intended for a child command.
543
+ const terminator = args.indexOf("--");
544
+ const ownArgs = terminator === -1 ? args : args.slice(0, terminator);
545
+
546
+ // `<command> -h/--help` → that command's help (every command supports it).
547
+ // (`-h`/`--help` AS the command falls through to the switch → global help.)
548
+ if (
549
+ command &&
550
+ !["-h", "--help", "-v", "--version"].includes(command) &&
551
+ (ownArgs.includes("-h") || ownArgs.includes("--help"))
552
+ ) {
553
+ this.printCommandHelp(command);
554
+ return;
555
+ }
556
+
557
+ // Mutation subcommands must not silently ignore a mistyped flag — a
558
+ // typo'd --dry-run would otherwise perform a REAL bring-up.
559
+ if (command) {
560
+ const unknown = unknownCommandFlags(command, args.slice(1));
561
+ if (unknown.length > 0) {
562
+ console.log(
563
+ chalk.red(
564
+ `Unknown flag${unknown.length > 1 ? "s" : ""} for '${command}': ${unknown.join(", ")}`,
565
+ ),
566
+ );
567
+ console.log(
568
+ chalk.gray(
569
+ `Run \`${product().cli} ${command} --help\` for accepted flags`,
570
+ ),
571
+ );
572
+ process.exit(1);
573
+ }
574
+ }
575
+
576
+ try {
577
+ switch (command) {
578
+ case "run":
579
+ await this.runTestsCommand(args.slice(1));
580
+ break;
581
+
582
+ case "view-results":
583
+ await this.viewResultsCommand();
584
+ break;
585
+
586
+ case "menu":
587
+ // Interactive menu (pillar/module/env picker). inquirer holds stdin
588
+ // open after the last prompt, so exit explicitly when it returns.
589
+ await this.interactiveMode();
590
+ process.exit(0);
591
+ break;
592
+
593
+ case "web":
594
+ await cmdWeb(args.slice(1));
595
+ break;
596
+
597
+ // ── Environment manager commands ──────────────────────────────────
598
+ case "up":
599
+ await this.cmdServiceUp(args.slice(1));
600
+ break;
601
+
602
+ case "down":
603
+ await this.cmdServiceDown(args.slice(1));
604
+ break;
605
+
606
+ case "restart":
607
+ await this.cmdServiceRestart(args.slice(1));
608
+ break;
609
+
610
+ case "status":
611
+ await this.cmdServiceStatus(args.slice(1));
612
+ break;
613
+
614
+ case "logs":
615
+ await this.cmdLogs(args.slice(1));
616
+ break;
617
+
618
+ case "services":
619
+ await this.cmdListServices();
620
+ break;
621
+
622
+ case "graph":
623
+ await this.ensureWorkspaceSeeded();
624
+ await cmdGraph(args.slice(1));
625
+ break;
626
+
627
+ case "register":
628
+ await this.cmdRegister(args.slice(1));
629
+ break;
630
+
631
+ case "use-local":
632
+ await this.cmdUseMode(args.slice(1), "dev-managed");
633
+ break;
634
+
635
+ case `use-managed`:
636
+ await this.cmdUseMode(args.slice(1), "managed");
637
+ break;
638
+
639
+ case "setup":
640
+ await this.cmdSetup(args.slice(1));
641
+ break;
642
+
643
+ case "workspace":
644
+ await this.cmdWorkspace(args.slice(1));
645
+ break;
646
+
647
+ case "sync":
648
+ await this.cmdSync(args.slice(1));
649
+ break;
650
+
651
+ case "init": {
652
+ // In the den root, `init` seeds/resets the default workspace; pointed
653
+ // at a service repo it scaffolds that repo's .den.yml (manifest init).
654
+ const initTarget = args[1] ?? getInvokeCwd();
655
+ if (nodePath.resolve(initTarget) === getDistRoot()) {
656
+ await this.seedDefaultWorkspaceFlow("init");
657
+ } else {
658
+ await runInit(initTarget);
659
+ }
660
+ break;
661
+ }
662
+
663
+ case "validate":
664
+ await this.cmdValidate(args.slice(1));
665
+ break;
666
+
667
+ case "create": {
668
+ // bin/den routes `create` to its own entry (it needs no den.yml);
669
+ // this covers the CLI run directly from a checkout.
670
+ const code = runCreate(args.slice(1), getInvokeCwd());
671
+ if (code !== 0) process.exit(code);
672
+ break;
673
+ }
674
+
675
+ case "local-dev":
676
+ await this.cmdLocalDev(args[1]);
677
+ break;
678
+
679
+ case "config":
680
+ await cmdConfig(args.slice(1));
681
+ break;
682
+
683
+ case "edge":
684
+ await cmdEdge(args.slice(1));
685
+ break;
686
+
687
+ case "stack":
688
+ await cmdStack(args.slice(1));
689
+ break;
690
+
691
+ case "apps":
692
+ case "applications":
693
+ await this.ensureWorkspaceSeeded();
694
+ cmdApps(args.slice(1));
695
+ break;
696
+
697
+ case "mcp": {
698
+ // bin/den routes `mcp` to its own entry (stdout is the protocol);
699
+ // this covers the CLI run directly from a checkout.
700
+ await import("./mcp-stdout-guard.js");
701
+ const { runMcp } = await import("../mcp/server.js");
702
+ await runMcp(args.slice(1));
703
+ break;
704
+ }
705
+
706
+ case "--help":
707
+ case "-h":
708
+ this.displayHelp();
709
+ break;
710
+
711
+ case "--version":
712
+ case "-v":
713
+ this.displayVersion();
714
+ break;
715
+
716
+ default:
717
+ console.log(chalk.red(`Unknown command: ${command}`));
718
+ console.log(chalk.gray("Run with --help for usage information"));
719
+ process.exit(1);
720
+ }
721
+ } catch (error) {
722
+ recordFailure({
723
+ command: telemetryCommandName(command),
724
+ surface: "cli",
725
+ reason: telemetryReason(error),
726
+ });
727
+ console.error(chalk.red(`\n❌ Error: ${(error as Error).message}`));
728
+ process.exit(1);
729
+ }
730
+ }
731
+
732
+ /**
733
+ * Interactive mode
734
+ */
735
+ private async interactiveMode(): Promise<void> {
736
+ let running = true;
737
+
738
+ while (running) {
739
+ try {
740
+ const action = await cliPrompts.promptForMainAction();
741
+
742
+ switch (action) {
743
+ case "run":
744
+ await this.runTestsInteractive();
745
+ break;
746
+
747
+ case "results":
748
+ await this.openHtmlReport();
749
+ break;
750
+
751
+ case "exit":
752
+ running = false;
753
+ console.log(chalk.cyan("\n👋 Goodbye!\n"));
754
+ break;
755
+ }
756
+ } catch (error) {
757
+ if ((error as { isTtyError?: boolean }).isTtyError) {
758
+ console.error(
759
+ chalk.red("Prompt could not be rendered in this environment"),
760
+ );
761
+ process.exit(1);
762
+ } else {
763
+ console.error(chalk.red(`\n❌ Error: ${(error as Error).message}\n`));
764
+ }
765
+ }
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Run tests (interactive)
771
+ */
772
+ private async runTestsInteractive(): Promise<void> {
773
+ const pillar = await cliPrompts.promptForPillar();
774
+
775
+ // Ask which module to test (works for all pillars)
776
+ const module = await cliPrompts.promptForModule(pillar);
777
+
778
+ // Handle "Back" selection — return to main menu
779
+ if (module === "back") {
780
+ return;
781
+ }
782
+
783
+ // For smoke: if a specific service was chosen (not "all"), ask for collection
784
+ let smokeCollection: string | undefined;
785
+ if (pillar === "smoke" && module !== null) {
786
+ const collection = await cliPrompts.promptForSmokeCollection(module);
787
+ if (collection === "back") {
788
+ return;
789
+ }
790
+ smokeCollection = collection ?? undefined;
791
+ }
792
+
793
+ const environment = await cliPrompts.promptForEnvironment();
794
+
795
+ // Verify environment config exists
796
+ try {
797
+ await envSelector.loadEnvironment(environment);
798
+ } catch (error) {
799
+ console.log(
800
+ chalk.red(`\n❌ Environment config not found for: ${environment}\n`),
801
+ );
802
+ return;
803
+ }
804
+
805
+ // Run tests
806
+ try {
807
+ const modules =
808
+ pillar === "performance"
809
+ ? resolvePerformanceModules({
810
+ availableModules: await moduleScanner.scanModules("performance"),
811
+ selectedModule: module,
812
+ })
813
+ : module
814
+ ? [module]
815
+ : undefined;
816
+
817
+ await testRunner.runTests({
818
+ pillar,
819
+ environment,
820
+ modules,
821
+ smokeCollection,
822
+ baseApiUrl: resolveCliBaseApiUrl(),
823
+ });
824
+ } catch (error) {
825
+ console.error(
826
+ chalk.red(`\n❌ Test execution failed: ${(error as Error).message}\n`),
827
+ );
828
+ }
829
+ }
830
+
831
+ /**
832
+ * Run tests (command)
833
+ */
834
+ private async runTestsCommand(args: string[]): Promise<void> {
835
+ // Parse arguments
836
+ const pillarArg = args.find((arg) => arg.startsWith("--pillar="));
837
+ const envArg = args.find((arg) => arg.startsWith("--environment="));
838
+ const moduleArgs = args
839
+ .filter((arg) => arg.startsWith("--module="))
840
+ .map((arg) => arg.split("=")[1])
841
+ .filter(Boolean);
842
+ const repoArg = args.find((arg) => arg.startsWith("--repo="));
843
+ const baseApiUrlArg = args.find((arg) => arg.startsWith("--baseApiUrl="));
844
+ const useLocalCheckout = args.includes("--use-local-checkout");
845
+
846
+ if (pillarArg && envArg) {
847
+ const pillar = pillarArg.split("=")[1] as Pillar;
848
+ const environment = envArg.split("=")[1] as Environment;
849
+ const baseApiUrl = baseApiUrlArg?.split("=")[1];
850
+
851
+ let modules: string[] | undefined;
852
+ if (pillar === "performance") {
853
+ if (moduleArgs.length > 0) {
854
+ modules = moduleArgs;
855
+ } else {
856
+ modules = resolvePerformanceModules({
857
+ availableModules: await moduleScanner.scanModules("performance"),
858
+ selectedModule: null,
859
+ repoName: repoArg?.split("=")[1] ?? null,
860
+ });
861
+ }
862
+ } else {
863
+ modules = moduleArgs.length > 0 ? moduleArgs : undefined;
864
+ }
865
+
866
+ // Run tests
867
+ try {
868
+ await testRunner.runTests({
869
+ pillar,
870
+ environment,
871
+ modules,
872
+ baseApiUrl: resolveCliBaseApiUrl(baseApiUrl),
873
+ useLocalCheckout,
874
+ });
875
+ } catch (error) {
876
+ console.error(
877
+ chalk.red(
878
+ `\n❌ Test execution failed: ${(error as Error).message}\n`,
879
+ ),
880
+ );
881
+ process.exit(1);
882
+ }
883
+
884
+ return;
885
+ }
886
+
887
+ // No arguments, go interactive
888
+ await this.runTestsInteractive();
889
+ }
890
+
891
+ /**
892
+ * Open HTML report
893
+ */
894
+ private async openHtmlReport(): Promise<void> {
895
+ console.log(chalk.cyan("\n📊 Opening interactive test report...\n"));
896
+
897
+ const { spawn } = await import("child_process");
898
+
899
+ const reportProcess = spawn(
900
+ engineBin("playwright"),
901
+ ["show-report", "playwright-report"],
902
+ {
903
+ cwd: getDistRoot(),
904
+ stdio: "inherit",
905
+ },
906
+ );
907
+
908
+ await new Promise<void>((resolve, reject) => {
909
+ reportProcess.on("close", (code) => {
910
+ if (code === 0) {
911
+ resolve();
912
+ } else if (code === 1) {
913
+ console.log(chalk.yellow("\n⚠️ No test report found."));
914
+ console.log(chalk.gray("Run some tests first:\n"));
915
+ console.log(chalk.cyan(" make test-basic # Run basic tests"));
916
+ console.log(chalk.cyan(" make test-auth # Run auth tests"));
917
+ console.log(
918
+ chalk.cyan(" make test # Interactive test runner\n"),
919
+ );
920
+ resolve();
921
+ } else {
922
+ reject(new Error(`Report viewer exited with code ${code}`));
923
+ }
924
+ });
925
+
926
+ reportProcess.on("error", (error) => {
927
+ reject(error);
928
+ });
929
+ });
930
+ }
931
+
932
+ /**
933
+ * View results (interactive)
934
+ */
935
+ private async viewResultsInteractive(): Promise<void> {
936
+ const days = await cliPrompts.promptForResultsDays();
937
+
938
+ const spinner = ora("Loading results...").start();
939
+
940
+ try {
941
+ spinner.stop();
942
+ await resultsViewer.displaySummary(days);
943
+ } catch (error) {
944
+ spinner.fail(chalk.red("Failed to load results"));
945
+ throw error;
946
+ }
947
+ }
948
+
949
+ /**
950
+ * View results (command)
951
+ */
952
+ private async viewResultsCommand(): Promise<void> {
953
+ await this.viewResultsInteractive();
954
+ }
955
+
956
+ // ── Environment manager: interactive sub-menu ──────────────────────────────
957
+
958
+ /**
959
+ * Clone any managed repos in the active workspace that don't exist on disk yet.
960
+ * Works from workspace config + catalog only — no manifest needed — so it's safe
961
+ * to call before manifests are readable (i.e. the bootstrap / setup scenario).
962
+ */
963
+ private cloneMissingManagedRepos(): void {
964
+ const ws = getActiveWorkspace();
965
+ if (!ws) return;
966
+ const catalog = readCatalog();
967
+
968
+ for (const [address, wsEntry] of Object.entries(ws.config.services)) {
969
+ if (wsEntry.mode !== "managed") continue;
970
+ const { repoName } = parseAddress(address);
971
+ const catalogEntry = catalog[repoName];
972
+ if (!catalogEntry) continue;
973
+ const branch = wsEntry.branch ?? "main";
974
+ try {
975
+ ensureManagedServiceClone(repoName, catalogEntry.gh, branch);
976
+ } catch (err) {
977
+ console.log(
978
+ chalk.red(
979
+ ` ✗ Failed to clone ${repoName}: ${(err as Error).message}`,
980
+ ),
981
+ );
982
+ }
983
+ }
984
+ }
985
+
986
+ /**
987
+ * `den up --pull`: fast-forward managed clones in the start set
988
+ * before bringing them up. Dev-managed repos are the developer's own checkout
989
+ * and are never touched. Missing clones are skipped (they get freshly cloned
990
+ * anyway). Fast-forward-only never clobbers — a diverged clone is reported and
991
+ * left as-is, and the up proceeds on whatever commit is checked out.
992
+ */
993
+ private pullManagedClonesFor(repoNames: string[]): void {
994
+ const ws = getActiveWorkspace();
995
+ if (!ws) return;
996
+ const want = new Set(repoNames);
997
+ const seen = new Set<string>();
998
+ let any = false;
999
+ for (const [address, wsEntry] of Object.entries(ws.config.services)) {
1000
+ if (wsEntry.mode !== "managed") continue;
1001
+ const { repoName } = parseAddress(address);
1002
+ if (!want.has(repoName) || seen.has(repoName)) continue;
1003
+ seen.add(repoName);
1004
+ const branch = wsEntry.branch ?? "main";
1005
+ const cloneDir = nodePath.join(DEN_SERVICES_DIR, repoName, branch);
1006
+ if (!fs.existsSync(cloneDir)) continue; // missing → freshly cloned below
1007
+ any = true;
1008
+ const spinner = ora(`pull ${chalk.bold(repoName)}...`).start();
1009
+ const r = fetchAndFastForward(cloneDir);
1010
+ if (r.pulled) {
1011
+ spinner.succeed(
1012
+ `pulled ${chalk.bold(repoName)} (+${r.behind} commit${r.behind === 1 ? "" : "s"})`,
1013
+ );
1014
+ } else if (r.behind === 0) {
1015
+ spinner.succeed(`${chalk.bold(repoName)} up to date`);
1016
+ } else if (r.behind === -1) {
1017
+ spinner.warn(
1018
+ `${chalk.bold(repoName)} — no upstream / unreachable, skipped`,
1019
+ );
1020
+ } else {
1021
+ spinner.fail(
1022
+ `pull ${chalk.bold(repoName)} — ${r.behind} behind but fast-forward failed`,
1023
+ );
1024
+ console.log(
1025
+ chalk.yellow(
1026
+ ` Diverged or local changes in ${cloneDir}. Resolve manually ` +
1027
+ `(or \`${product().cli} sync\`); starting on the current commit.`,
1028
+ ),
1029
+ );
1030
+ }
1031
+ }
1032
+ if (any) console.log();
1033
+ }
1034
+
1035
+ /**
1036
+ * Ensure the managed repos in `repoNames` are cloned AND set up before
1037
+ * we try to run their `make up`. Only clones repos whose clone dir is MISSING —
1038
+ * never pulls an existing clone (that's `den sync`'s job; an implicit pull
1039
+ * mid-up would be surprising and could fail on local changes). For each freshly
1040
+ * cloned repo it runs setup hidden + non-interactive (mirroring the web UX)
1041
+ * rather than popping a Terminal window. Returns the repo names that failed to
1042
+ * clone or set up so the caller can fail-fast.
1043
+ */
1044
+ private async ensureManagedClonesFor(repoNames: string[]): Promise<string[]> {
1045
+ const ws = getActiveWorkspace();
1046
+ if (!ws) return [];
1047
+ const catalog = readCatalog();
1048
+ const want = new Set(repoNames);
1049
+ const handled = new Set<string>();
1050
+ const failed: string[] = [];
1051
+ // Repos that need setup run/re-run: freshly cloned, OR cloned previously but
1052
+ // never verifiably set up (no success marker — e.g. a prior setup failed).
1053
+ const needsSetup: { repoName: string; repoPath: string }[] = [];
1054
+
1055
+ for (const [address, wsEntry] of Object.entries(ws.config.services)) {
1056
+ if (wsEntry.mode !== "managed") continue;
1057
+ const { repoName } = parseAddress(address);
1058
+ if (!want.has(repoName) || handled.has(repoName)) continue;
1059
+ const branch = wsEntry.branch ?? "main";
1060
+ const cloneDir = nodePath.join(DEN_SERVICES_DIR, repoName, branch);
1061
+ handled.add(repoName);
1062
+
1063
+ if (fs.existsSync(cloneDir)) {
1064
+ // Already cloned. Only re-run setup when it hasn't verifiably succeeded
1065
+ // (no marker) AND the repo actually has a setup command — so a clone
1066
+ // whose setup previously failed is repaired instead of started broken.
1067
+ if (!hasSetupSucceeded(cloneDir) && hasSetupCommand(cloneDir)) {
1068
+ needsSetup.push({ repoName, repoPath: cloneDir });
1069
+ }
1070
+ continue;
1071
+ }
1072
+
1073
+ // Catalog entries written by the dev-managed add flow carry `gh: ""`, so
1074
+ // an entry can exist yet be unclonable. Derive the conventional slug and
1075
+ // persist the repair rather than cloning from an empty URL.
1076
+ const ghSlug = resolveRepoGhSlug(repoName, catalog);
1077
+ if (!catalog[repoName]?.gh) upsertCatalogEntry(repoName, { gh: ghSlug });
1078
+ try {
1079
+ // runSetup=false: clone only — setup is run below, hidden + non-interactive.
1080
+ ensureManagedServiceClone(repoName, ghSlug, branch, false);
1081
+ // Fill placeholder secrets from another checkout of the same repo
1082
+ // before setup runs — see seedSecretsFromSibling.
1083
+ const seeded = seedSecretsFromSibling(repoName, cloneDir);
1084
+ if (seeded) {
1085
+ console.log(
1086
+ chalk.gray(
1087
+ ` ↳ filled ${seeded.filled.length} local secret(s) from ${seeded.donorPath}: ${seeded.filled.join(", ")}`,
1088
+ ),
1089
+ );
1090
+ }
1091
+ needsSetup.push({ repoName, repoPath: cloneDir });
1092
+ } catch (err) {
1093
+ console.log(
1094
+ chalk.red(
1095
+ ` ✗ Failed to clone ${repoName}: ${(err as Error).message}`,
1096
+ ),
1097
+ );
1098
+ failed.push(repoName);
1099
+ }
1100
+ }
1101
+
1102
+ // Run setup non-interactively (no Terminal windows). The non-interactive env
1103
+ // (CI/HUSKY/git creds) lets it run unattended; GUI auth (1Password Touch ID)
1104
+ // may still surface. On failure, surface the actual error output (the tail,
1105
+ // including stderr) so the user sees WHY — not just a bare exit code — and
1106
+ // record nothing, so the next `den up` retries instead of starting broken.
1107
+ // If it stalled on an interactive prompt, offer an in-terminal re-run so the
1108
+ // user can answer it without leaving `den up`.
1109
+ for (const { repoName, repoPath } of needsSetup) {
1110
+ const spinner = ora(`setup ${chalk.bold(repoName)}...`).start();
1111
+ const tail: string[] = [];
1112
+ const { code, stalledPrompt } = await runSetupHidden(repoPath, (line) => {
1113
+ tail.push(line);
1114
+ if (tail.length > 25) tail.shift();
1115
+ spinner.text = `setup ${chalk.bold(repoName)}: ${chalk.gray(line.slice(0, 80))}`;
1116
+ });
1117
+ if (code === 0) {
1118
+ markSetupSucceeded(repoPath);
1119
+ spinner.succeed(`setup ${chalk.bold(repoName)}`);
1120
+ } else if (stalledPrompt) {
1121
+ spinner.fail(
1122
+ `setup ${chalk.bold(repoName)} needs an interactive terminal`,
1123
+ );
1124
+ // The hidden child is already dead (stall guard killed its process
1125
+ // group) and setups run one at a time in the foreground, so the
1126
+ // terminal is free. The confirm auto-declines after a timeout so an
1127
+ // unattended pty can never hang `up` (the guarantee d64bacb made).
1128
+ const recovered = await recoverStalledSetup(
1129
+ repoName,
1130
+ repoPath,
1131
+ stalledPrompt,
1132
+ {
1133
+ isInteractive: () =>
1134
+ !!(process.stdin.isTTY && process.stdout.isTTY),
1135
+ confirm: (message) =>
1136
+ confirmWithTimeout(message, STALLED_SETUP_CONFIRM_TIMEOUT_MS),
1137
+ runSetupInteractive,
1138
+ markSetupSucceeded,
1139
+ log: (line) => console.log(line),
1140
+ },
1141
+ );
1142
+ if (!recovered) failed.push(repoName);
1143
+ } else {
1144
+ spinner.fail(`setup ${chalk.bold(repoName)} failed (exit ${code})`);
1145
+ console.log(
1146
+ chalk.red(`\n ── setup output for ${repoName} (last lines) ──`),
1147
+ );
1148
+ for (const l of tail) console.log(chalk.gray(` │ ${l}`));
1149
+ console.log(
1150
+ chalk.red(` ───────────────────────────────────────────────`),
1151
+ );
1152
+ console.log(
1153
+ chalk.yellow(
1154
+ ` Fix setup in ${repoPath}, then re-run \`${product().cli} up\` ` +
1155
+ `(or \`${product().cli} setup ${repoName}\`).\n`,
1156
+ ),
1157
+ );
1158
+ failed.push(repoName);
1159
+ }
1160
+ }
1161
+
1162
+ return failed;
1163
+ }
1164
+
1165
+ /** The workspace a command acts on: -w override, else the focused one. */
1166
+ private focusedWs(): string {
1167
+ return (
1168
+ process.env.DEN_WORKSPACE_OVERRIDE ??
1169
+ getFocusedWorkspaceName() ??
1170
+ "default"
1171
+ );
1172
+ }
1173
+
1174
+ /** Return addresses from the workspace that have the given local command in their manifest. */
1175
+ private workspaceAddressesWithCommand(command: string): string[] {
1176
+ return getWorkspaceServices()
1177
+ .filter((s) => !!localCommandsFor(s, this.focusedWs())[command])
1178
+ .map((s) => s.address);
1179
+ }
1180
+
1181
+ private async manageWorkspacesInteractive(): Promise<void> {
1182
+ let running = true;
1183
+ while (running) {
1184
+ const action = await cliPrompts.promptForWorkspaceAction();
1185
+ const file = readWorkspacesFile();
1186
+
1187
+ switch (action) {
1188
+ case "list":
1189
+ await this.cmdWorkspace(["list"]);
1190
+ break;
1191
+ case "use": {
1192
+ const names = Object.keys(file.workspaces);
1193
+ const selected = await cliPrompts.promptSelectWorkspace(
1194
+ names,
1195
+ file.focus ?? file.active[0],
1196
+ );
1197
+ setActiveWorkspace(selected);
1198
+ console.log(
1199
+ chalk.green(
1200
+ `\n ✓ Activated and focused workspace: ${chalk.bold(selected)}\n`,
1201
+ ),
1202
+ );
1203
+ break;
1204
+ }
1205
+ case "create": {
1206
+ const name = await cliPrompts.promptForWorkspaceName(
1207
+ Object.keys(file.workspaces),
1208
+ );
1209
+ createWorkspace(name, { services: {} });
1210
+ const activate = await cliPrompts.confirm(
1211
+ `Switch to workspace "${name}" now?`,
1212
+ true,
1213
+ );
1214
+ if (activate) setActiveWorkspace(name);
1215
+ console.log(
1216
+ chalk.green(`\n ✓ Created workspace: ${chalk.bold(name)}\n`),
1217
+ );
1218
+ break;
1219
+ }
1220
+ case "add-service":
1221
+ // Register handles the full flow: path → gh slug → mode/branch → workspace entry
1222
+ await this.cmdRegister([]);
1223
+ break;
1224
+ case "remove-service": {
1225
+ const ws = getActiveWorkspace();
1226
+ if (!ws) {
1227
+ console.log(chalk.red("\n No active workspace.\n"));
1228
+ break;
1229
+ }
1230
+
1231
+ const existing = Object.keys(ws.config.services);
1232
+ if (existing.length === 0) {
1233
+ console.log(chalk.yellow("\n No services in this workspace.\n"));
1234
+ break;
1235
+ }
1236
+
1237
+ const selected = await cliPrompts.promptSelectServices(
1238
+ existing,
1239
+ `Remove from workspace "${ws.name}":`,
1240
+ );
1241
+ if (selected.length === 0) break;
1242
+
1243
+ const updatedFile = readWorkspacesFile();
1244
+ for (const addr of selected) {
1245
+ delete updatedFile.workspaces[ws.name].services[addr];
1246
+ }
1247
+ writeWorkspacesFile(updatedFile);
1248
+ console.log(
1249
+ chalk.green(
1250
+ `\n ✓ Removed ${selected.length} service(s) from ${ws.name}\n`,
1251
+ ),
1252
+ );
1253
+ break;
1254
+ }
1255
+ case "delete": {
1256
+ const names = Object.keys(file.workspaces).filter(
1257
+ (n) => n !== "default",
1258
+ );
1259
+ if (names.length === 0) {
1260
+ console.log(
1261
+ chalk.yellow(
1262
+ "\n No workspaces to delete (cannot delete default).\n",
1263
+ ),
1264
+ );
1265
+ break;
1266
+ }
1267
+ const name = await cliPrompts.promptSelectWorkspace(names, "");
1268
+ const confirmed = await cliPrompts.confirm(
1269
+ `Delete workspace "${name}"?`,
1270
+ false,
1271
+ );
1272
+ if (!confirmed) break;
1273
+ deleteWorkspaceDirectory(name);
1274
+ deleteWorkspaceConfig(name);
1275
+ console.log(chalk.green(`\n ✓ Deleted workspace: ${name}\n`));
1276
+ break;
1277
+ }
1278
+ case "back":
1279
+ running = false;
1280
+ break;
1281
+ }
1282
+ }
1283
+ }
1284
+
1285
+ // ── Environment manager: non-interactive commands ─────────────────────────
1286
+
1287
+ /** Run a named command (up/down/restart) on one or more service addresses. */
1288
+ private async runServicesCommand(
1289
+ addresses: string[],
1290
+ command: string,
1291
+ plan?: {
1292
+ /** Pre-computed run order (from the derived graph) — skips manifest topo. */
1293
+ order: string[];
1294
+ /** Hard deps to block on per address (derived), replacing manifest wait. */
1295
+ waitDepsFor?: (address: string) => string[];
1296
+ /** Abort the whole flow on the first failure (derived `up`). */
1297
+ failFast?: boolean;
1298
+ /** Skip ANY already-healthy node (derived `up` starts only what's down). */
1299
+ skipHealthyAll?: boolean;
1300
+ },
1301
+ opts?: { force?: boolean },
1302
+ ): Promise<void> {
1303
+ // How long to show startup output before handing control back to the menu
1304
+ // for detachable (long-running) commands like `up`.
1305
+ const DETACH_WINDOW_MS = 4000;
1306
+ const workspace =
1307
+ process.env.DEN_WORKSPACE_OVERRIDE ??
1308
+ getFocusedWorkspaceName() ??
1309
+ undefined;
1310
+ const force = opts?.force ?? false;
1311
+
1312
+ // Resolve dependency order. Derived mode supplies the order directly;
1313
+ // otherwise topo-sort manifest depends_on (`down` → reverse).
1314
+ let ordered = addresses;
1315
+ if (plan) {
1316
+ ordered = plan.order;
1317
+ } else {
1318
+ try {
1319
+ const catalog = readCatalog();
1320
+ const ws = getActiveWorkspace();
1321
+ const startupOrder = ws
1322
+ ? resolveStartupOrder(addresses, catalog, ws.config, ws.name)
1323
+ : addresses;
1324
+ if (command === "down") {
1325
+ ordered = [...startupOrder].reverse();
1326
+ } else {
1327
+ ordered = startupOrder;
1328
+ if (ordered.length > addresses.length) {
1329
+ const added = ordered.filter((a) => !addresses.includes(a));
1330
+ console.log(
1331
+ chalk.gray(`\n Including dependencies: ${added.join(", ")}\n`),
1332
+ );
1333
+ }
1334
+ }
1335
+ } catch (e: unknown) {
1336
+ console.log(chalk.red(`\n ⚠ ${(e as Error).message}\n`));
1337
+ return;
1338
+ }
1339
+ }
1340
+
1341
+ // Build a catalog + workspace reference once per run for readiness lookups.
1342
+ const catalog = readCatalog();
1343
+ const ws = getActiveWorkspace();
1344
+ // Derived `up` treats every node as a dependency, so any already-healthy
1345
+ // node may be skipped; otherwise only non-seed deps are skip-eligible.
1346
+ const seedAddresses = plan?.skipHealthyAll
1347
+ ? new Set<string>()
1348
+ : new Set(addresses);
1349
+ const runtime = resolveServiceRuntime(command);
1350
+
1351
+ // Fail-fast transparency: a partial abort intentionally leaves earlier
1352
+ // nodes running (they're often useful for debugging the failure) — but we
1353
+ // must SAY so, and post-detach service deaths must still trip fail-fast.
1354
+ const startedThisRun: string[] = [];
1355
+ const lateFailures = new Map<string, number>();
1356
+ const printPartialState = (): void => {
1357
+ if (startedThisRun.length === 0) return;
1358
+ console.log(
1359
+ chalk.yellow(
1360
+ ` Left running from this run: ${startedThisRun.join(", ")}`,
1361
+ ),
1362
+ );
1363
+ console.log(chalk.gray(` Stop them with: ${product().cli} down`));
1364
+ };
1365
+
1366
+ for (const address of ordered) {
1367
+ // A previously-detached service that has since died non-zero defeats the
1368
+ // point of continuing — its dependents will fail on a missing dep.
1369
+ if (plan?.failFast && lateFailures.size > 0) {
1370
+ const [failedAddr, code] = [...lateFailures.entries()][0];
1371
+ console.log(
1372
+ chalk.red(
1373
+ ` ✗ ${chalk.bold(failedAddr)} exited (${code}) after detaching — aborting (fail-fast).`,
1374
+ ),
1375
+ );
1376
+ printPartialState();
1377
+ return;
1378
+ }
1379
+ // R13: a remote service is never started or stopped from here. Said,
1380
+ // not failed, so a workspace or app up/down carries on past it.
1381
+ const placed = resolveService(address, workspace);
1382
+ const remote = placed
1383
+ ? placementRefusal(
1384
+ address,
1385
+ servicePlacement(
1386
+ readManifest(placed.repoPath),
1387
+ placed.subsystem,
1388
+ "local",
1389
+ ),
1390
+ (e) => isProductionEnv(e),
1391
+ command === "down" ? "stop" : "start",
1392
+ )
1393
+ : null;
1394
+ if (remote) {
1395
+ console.log(chalk.gray(` • ${remote}`));
1396
+ continue;
1397
+ }
1398
+ if (command === "up" && !seedAddresses.has(address)) {
1399
+ const resolved = resolveService(address, workspace);
1400
+ if (resolved) {
1401
+ const health = await checkServiceHealth(resolved, "local");
1402
+ if (health.health === "ok") {
1403
+ // Skipping must not lose the refcount — this workspace is an owner
1404
+ // of what's running, whether den tracked it or not.
1405
+ const claim = workspace
1406
+ ? claimHealthyService(resolved, workspace)
1407
+ : "untracked";
1408
+ const suffix =
1409
+ claim === "adopted"
1410
+ ? " (adopted its running containers)"
1411
+ : claim === "attached"
1412
+ ? ` (owned by "${workspace}" too)`
1413
+ : "";
1414
+ console.log(
1415
+ chalk.gray(
1416
+ ` • ${chalk.bold(address)} already healthy — skipping dependency up${suffix}`,
1417
+ ),
1418
+ );
1419
+ continue;
1420
+ }
1421
+ }
1422
+ }
1423
+
1424
+ // Block on any `wait: true` deps before starting this service on up/restart.
1425
+ if ((command === "up" || command === "restart") && ws) {
1426
+ const waitDeps = plan?.waitDepsFor
1427
+ ? plan.waitDepsFor(address)
1428
+ : getWaitDeps(address, catalog, ws.config, ws.name);
1429
+ let readinessFailed = false;
1430
+ for (const depAddr of waitDeps) {
1431
+ const depResolved = resolveService(depAddr);
1432
+ if (!depResolved) {
1433
+ console.log(
1434
+ chalk.yellow(
1435
+ ` ⚠ wait dep ${depAddr} not resolvable — skipping readiness check`,
1436
+ ),
1437
+ );
1438
+ continue;
1439
+ }
1440
+ const spinner = ora(
1441
+ `waiting for ${chalk.bold(depAddr)} to be ready…`,
1442
+ ).start();
1443
+ const result = await waitForReady(depResolved, "local", {
1444
+ onTick: (detail) => {
1445
+ spinner.text = `waiting for ${chalk.bold(depAddr)}: ${chalk.gray(detail)}`;
1446
+ },
1447
+ });
1448
+ if (!result.ok) {
1449
+ spinner.fail(
1450
+ `${chalk.bold(depAddr)} not ready after ${Math.round(result.elapsedMs / 1000)}s (${result.detail ?? "timeout"})`,
1451
+ );
1452
+ readinessFailed = true;
1453
+ break;
1454
+ }
1455
+ spinner.succeed(
1456
+ result.skipped
1457
+ ? `${chalk.bold(depAddr)} (no health check — proceeding)`
1458
+ : `${chalk.bold(depAddr)} ready (${Math.round(result.elapsedMs / 1000)}s)`,
1459
+ );
1460
+ }
1461
+ if (readinessFailed) {
1462
+ console.log(
1463
+ chalk.red(
1464
+ ` ✗ ${command} ${chalk.bold(address)} — blocked on readiness`,
1465
+ ),
1466
+ );
1467
+ if (plan?.failFast) {
1468
+ console.log(
1469
+ chalk.red(
1470
+ ` Aborting (fail-fast): a dependency did not become ready.`,
1471
+ ),
1472
+ );
1473
+ printPartialState();
1474
+ return;
1475
+ }
1476
+ continue;
1477
+ }
1478
+ }
1479
+
1480
+ const started = await new Promise<boolean>((resolve) => {
1481
+ const spinner = ora(`${command} ${chalk.bold(address)}...`).start();
1482
+ let detachTimer: ReturnType<typeof setTimeout> | null = null;
1483
+ let settled = false;
1484
+ // Keep the tail of the command's output. onLine only updates the
1485
+ // transient spinner text, so on failure the actual reason (e.g.
1486
+ // "could not read .den.yml in <path>") vanished with the spinner and
1487
+ // the user saw a bare "exit 1" — which is exactly how a broken fresh
1488
+ // checkout looked like an inexplicable mass failure.
1489
+ const lastLines: string[] = [];
1490
+ let result: ReturnType<typeof runServiceCommand> | null = null;
1491
+ let detachable = false;
1492
+ let interactive = false;
1493
+
1494
+ const settleFromClose = (exitCode: number): void => {
1495
+ if (settled) return;
1496
+ settled = true;
1497
+ if (detachTimer) clearTimeout(detachTimer);
1498
+ if (interactive) {
1499
+ // Reprint the result line after the interactive session ends.
1500
+ if (exitCode === 0) {
1501
+ console.log(chalk.green(` ✓ ${command} ${chalk.bold(address)}`));
1502
+ } else {
1503
+ console.log(
1504
+ chalk.red(
1505
+ ` ✗ ${command} ${chalk.bold(address)} (exit ${exitCode})`,
1506
+ ),
1507
+ );
1508
+ }
1509
+ } else if (exitCode === 0) {
1510
+ spinner.succeed(`${command} ${chalk.bold(address)}`);
1511
+ } else {
1512
+ spinner.fail(
1513
+ `${command} ${chalk.bold(address)} (exit ${exitCode})`,
1514
+ );
1515
+ for (const l of lastLines) {
1516
+ console.log(chalk.gray(` ${l}`));
1517
+ }
1518
+ }
1519
+ resolve(exitCode === 0);
1520
+ };
1521
+
1522
+ const settleAsBackground = (): void => {
1523
+ if (settled) return;
1524
+ settled = true;
1525
+
1526
+ // Detachable daemon mode returns a tail process for startup streaming.
1527
+ // Stop that tail only; the service itself was already daemonized.
1528
+ const kill = (
1529
+ result?.proc as
1530
+ | { kill?: (signal?: NodeJS.Signals) => boolean }
1531
+ | undefined
1532
+ )?.kill;
1533
+ try {
1534
+ kill?.call(result?.proc, "SIGTERM");
1535
+ } catch {
1536
+ /* best-effort tail cleanup */
1537
+ }
1538
+
1539
+ // The service process may have already died non-zero INSIDE the
1540
+ // detach window (onServiceExit) — that's a start failure, not a
1541
+ // background success.
1542
+ const lateExit = lateFailures.get(address);
1543
+ if (lateExit !== undefined) {
1544
+ spinner.fail(
1545
+ `${command} ${chalk.bold(address)} (service exited ${lateExit} during startup)`,
1546
+ );
1547
+ resolve(false);
1548
+ return;
1549
+ }
1550
+ spinner.succeed(
1551
+ `${command} ${chalk.bold(address)} ${chalk.gray("(running in background)")}`,
1552
+ );
1553
+ resolve(true);
1554
+ };
1555
+
1556
+ result = runServiceCommand({
1557
+ address,
1558
+ command,
1559
+ runtime,
1560
+ workspace,
1561
+ force: command === "down" ? force : false,
1562
+ onLine: (line) => {
1563
+ spinner.text = `${command} ${chalk.bold(address)}: ${chalk.gray(line.slice(0, 80))}`;
1564
+ const trimmed = line.trim();
1565
+ if (trimmed) {
1566
+ lastLines.push(trimmed.slice(0, 160));
1567
+ if (lastLines.length > 3) lastLines.shift();
1568
+ }
1569
+ },
1570
+ onClose: (exitCode) => {
1571
+ settleFromClose(exitCode);
1572
+ },
1573
+ // Daemon mode settles via the detach timer + tail close — never the
1574
+ // service's real exit. Record non-zero service deaths so a failure
1575
+ // inside OR after the detach window still trips fail-fast.
1576
+ onServiceExit: (exitCode) => {
1577
+ if (exitCode !== 0 && (command === "up" || command === "restart")) {
1578
+ lateFailures.set(address, exitCode);
1579
+ }
1580
+ },
1581
+ });
1582
+ if (settled) return;
1583
+
1584
+ detachable = result.detachable;
1585
+ interactive = result.interactive;
1586
+
1587
+ if (interactive) {
1588
+ // HITL — suspend the spinner immediately so the TTY is clean for user input.
1589
+ spinner.stop();
1590
+ console.log(
1591
+ chalk.cyan(
1592
+ `\n ↳ Interactive setup for ${chalk.bold(address)} — follow the prompts below:`,
1593
+ ),
1594
+ );
1595
+ console.log(chalk.gray(" " + "─".repeat(60)));
1596
+ return; // onClose drives resolve()
1597
+ }
1598
+
1599
+ // Only use the detach timer for long-running startup commands.
1600
+ // `down` and all other teardown commands must run to completion before
1601
+ // moving to the next service — concurrent Docker teardowns conflict.
1602
+ if (detachable && (command === "up" || command === "restart")) {
1603
+ detachTimer = setTimeout(settleAsBackground, DETACH_WINDOW_MS);
1604
+ }
1605
+ });
1606
+
1607
+ if (plan?.failFast && !started) {
1608
+ console.log(
1609
+ chalk.red(
1610
+ ` Aborting (fail-fast): ${chalk.bold(address)} failed to start.`,
1611
+ ),
1612
+ );
1613
+ printPartialState();
1614
+ return;
1615
+ }
1616
+ if (started && (command === "up" || command === "restart")) {
1617
+ startedThisRun.push(address);
1618
+ }
1619
+ }
1620
+
1621
+ // Post-run sweep: even without fail-fast, surface services that detached
1622
+ // "successfully" but whose process has since exited non-zero.
1623
+ if (lateFailures.size > 0) {
1624
+ for (const [addr, code] of lateFailures) {
1625
+ if (startedThisRun.includes(addr)) {
1626
+ console.log(
1627
+ chalk.yellow(
1628
+ ` ⚠ ${chalk.bold(addr)} exited (${code}) after detaching — check its log (${product().cli} web → /logs).`,
1629
+ ),
1630
+ );
1631
+ }
1632
+ }
1633
+ }
1634
+ }
1635
+
1636
+ private resolveNoArgServiceTargets(
1637
+ command: "up" | "down" | "restart",
1638
+ ): string[] {
1639
+ const ws = getActiveWorkspace();
1640
+ const catalog = readCatalog();
1641
+ if (ws) {
1642
+ const invocation = resolveInvocationTarget(ws.name, ws.config, catalog);
1643
+ if (invocation) {
1644
+ console.log(chalk.cyan(`\n Workspace: ${chalk.bold(ws.name)}`));
1645
+ console.log(chalk.gray(` Invocation repo: ${invocation.repoRoot}`));
1646
+ console.log(
1647
+ chalk.gray(` Resolved target: ${invocation.addresses.join(", ")}\n`),
1648
+ );
1649
+ return invocation.addresses;
1650
+ }
1651
+ }
1652
+
1653
+ if (command === "down") {
1654
+ // Down stops everything in the workspace — auto-start only governs the
1655
+ // STARTING half of workspace-wide ops.
1656
+ return getWorkspaceServices().map((s) => s.address);
1657
+ }
1658
+ // up/restart with no resolvable target: nothing to start implicitly.
1659
+ return [];
1660
+ }
1661
+
1662
+ /** Repo-system name for the current working directory, if any. */
1663
+ private cwdSeedRepo(): string | undefined {
1664
+ const root = findInvocationRepoRoot();
1665
+ return root ? nodePath.basename(root) : undefined;
1666
+ }
1667
+
1668
+ /**
1669
+ * Resolve a derive seed: an explicit known-repo arg, else the cwd repo if it
1670
+ * is a known repo. Returns undefined (no logging) when neither applies — the
1671
+ * caller then falls back to the legacy manifest path.
1672
+ */
1673
+ private derivedSeed(seedArg: string | undefined): string | undefined {
1674
+ const knownRepos = loadAliasResolver().knownRepos;
1675
+ const seed = seedArg ?? this.cwdSeedRepo();
1676
+ return seed && knownRepos.has(seed) ? seed : undefined;
1677
+ }
1678
+
1679
+ /** Render the read-only `den up --plan` view: per-node command + flags. */
1680
+ private renderUpPlan(nodes: NodePlan[]): void {
1681
+ const tag = (n: NodePlan): string => {
1682
+ const parts: string[] = [];
1683
+ if (n.noAutostart)
1684
+ parts.push(chalk.yellow("auto-start off, not-started"));
1685
+ if (n.interactive)
1686
+ parts.push(
1687
+ chalk.red(
1688
+ n.interactiveKind
1689
+ ? `interactive (${n.interactiveKind})`
1690
+ : "interactive",
1691
+ ),
1692
+ );
1693
+ if (!n.upCommand) parts.push(chalk.red("no up command"));
1694
+ if (parts.length === 0) parts.push(chalk.green("headless"));
1695
+ return parts.join(" ");
1696
+ };
1697
+ console.log(chalk.bold(" Plan (no changes will be made):\n"));
1698
+ for (const n of nodes) {
1699
+ const willStart = n.defaultAction === "start";
1700
+ const marker = willStart ? chalk.green("●") : chalk.yellow("○");
1701
+ console.log(` ${marker} ${chalk.bold(n.repo)} [${tag(n)}]`);
1702
+ console.log(
1703
+ chalk.gray(
1704
+ ` up: ${n.upCommand ?? "(none found)"} (${n.upSource})`,
1705
+ ),
1706
+ );
1707
+ if (n.healthUrl) console.log(chalk.gray(` health: ${n.healthUrl}`));
1708
+ if (n.interactive)
1709
+ console.log(chalk.gray(` ⚠ ${n.interactiveReason}`));
1710
+ for (const note of n.notes) console.log(chalk.gray(` • ${note}`));
1711
+ }
1712
+ const ask = nodes.filter((n) => n.defaultAction === "skip");
1713
+ console.log();
1714
+ console.log(
1715
+ chalk.gray(
1716
+ ` ● auto-start (headless) ○ needs a decision (optional/interactive) — ` +
1717
+ `${ask.length} node(s): ${ask.map((n) => n.repo).join(", ") || "none"}`,
1718
+ ),
1719
+ );
1720
+ console.log(
1721
+ chalk.gray(
1722
+ ` Run '${product().cli} up' to start; outliers are prompted (CLI) / toggled (web) / skipped (CI).\n`,
1723
+ ),
1724
+ );
1725
+ }
1726
+
1727
+ /** Bring up the seed repo's derived dependency closure (hard-ordered, fail-fast). */
1728
+ private async runDerivedUp(
1729
+ seed: string,
1730
+ include: boolean,
1731
+ dryRun: boolean,
1732
+ plan = false,
1733
+ pull = false,
1734
+ ): Promise<void> {
1735
+ const full = deriveGraph({ scope: "all" });
1736
+ // Same positional closure the web "up with deps" uses: everything the seed
1737
+ // sits ON TOP OF, never anything above it. Keeps the two entry points from
1738
+ // drifting — see startClosure.
1739
+ const derived = deriveStartTargets(full, seed, include);
1740
+ const { graph } = derived;
1741
+ const rawOrder = this.runtimeOnly(derived.order, "started");
1742
+ if (rawOrder.length === 0) {
1743
+ console.log(
1744
+ chalk.gray(
1745
+ `\n ${chalk.bold(seed)} has no derived dependencies to start.\n`,
1746
+ ),
1747
+ );
1748
+ return;
1749
+ }
1750
+ // Systems the user toggled to auto-start OFF in this workspace are not
1751
+ // started by a workspace-wide up — only when one IS the seed (you ran
1752
+ // `den up` from that repo / named it). They still appear in `--plan`,
1753
+ // flagged.
1754
+ const noAutostartSet = this.noAutostartSet();
1755
+ const order = rawOrder.filter((n) => n === seed || !noAutostartSet.has(n));
1756
+ const skippedOff = rawOrder.filter(
1757
+ (n) => n !== seed && noAutostartSet.has(n),
1758
+ );
1759
+ console.log(
1760
+ chalk.cyan(
1761
+ `\n Derived up for ${chalk.bold(seed)}${include ? " (including self)" : " (dependencies only)"}`,
1762
+ ),
1763
+ );
1764
+ console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
1765
+ if (skippedOff.length > 0) {
1766
+ console.log(
1767
+ chalk.gray(
1768
+ ` Skipping (auto-start off): ${skippedOff.join(", ")} ` +
1769
+ `(toggle in the web ⋮ menu, or start explicitly with \`${product().cli} up <repo>\`)\n`,
1770
+ ),
1771
+ );
1772
+ }
1773
+ // Retired config key — the toggle moved to per-workspace `autostart`.
1774
+ const staleOptional = (
1775
+ readDenConfig().graph as { optional?: string[] } | undefined
1776
+ )?.optional;
1777
+ if (staleOptional?.length) {
1778
+ console.log(
1779
+ chalk.gray(
1780
+ " Note: config graph.optional is no longer used — toggle auto-start per system in the web ⋮ menu (or workspaces.yml).\n",
1781
+ ),
1782
+ );
1783
+ }
1784
+ // `--plan`: surface the discovered+classified plan and stop. Same discovery
1785
+ // as a real `up`; just doesn't execute. Shows the FULL set with auto-start-
1786
+ // off flagged so you can see what would be skipped and why.
1787
+ if (plan) {
1788
+ this.renderUpPlan(
1789
+ buildUpPlan(graph, rawOrder, { noAutostart: noAutostartSet }),
1790
+ );
1791
+ return;
1792
+ }
1793
+ if (dryRun) {
1794
+ console.log(chalk.gray(" (--dry-run: not starting anything)\n"));
1795
+ return;
1796
+ }
1797
+ // Check the secrets backend first so any first-clone `make setup` (and the up
1798
+ // commands) have developer secrets available.
1799
+ preflightSecrets();
1800
+ // `--pull`: fast-forward existing managed clones in the start set
1801
+ // before bringing them up (dev-managed checkouts are never touched).
1802
+ if (pull) this.pullManagedClonesFor(order);
1803
+ // Bring the start-set's managed clones on disk AND set them up (hidden,
1804
+ // non-interactive) before running their `make up` — a managed repo that's
1805
+ // configured but not cloned would otherwise fail with "could not read
1806
+ // .den.yml". Fail-fast if any clone or setup fails.
1807
+ const failedClones = await this.ensureManagedClonesFor(order);
1808
+ if (failedClones.length > 0) {
1809
+ console.log(
1810
+ chalk.red(
1811
+ `\n Aborting (fail-fast): clone/setup failed for ${failedClones.join(", ")}.\n`,
1812
+ ),
1813
+ );
1814
+ return;
1815
+ }
1816
+ await this.runServicesCommand(order, "up", {
1817
+ order,
1818
+ waitDepsFor: (address) => hardDepsOf(graph, address),
1819
+ failFast: true,
1820
+ skipHealthyAll: true,
1821
+ });
1822
+ }
1823
+
1824
+ /**
1825
+ * Stop the seed repo and the dependencies it was started with — the same
1826
+ * closure `den up <seed>` brings up, torn down in reverse (seed first,
1827
+ * foundations last), so the two halves of the pair operate on one set. Matches
1828
+ * the web card's "down with deps".
1829
+ */
1830
+ private async runDerivedDown(
1831
+ seed: string,
1832
+ dryRun: boolean,
1833
+ force = false,
1834
+ ): Promise<void> {
1835
+ const full = deriveGraph({ scope: "all" });
1836
+ const upOrder = this.runtimeOnly(
1837
+ deriveStartTargets(full, seed).order,
1838
+ "stopped",
1839
+ );
1840
+ if (upOrder.length === 0) return;
1841
+ // Down stops EVERYTHING in the closure — the auto-start toggle only governs
1842
+ // the starting half ("quiet my machine" quiets all of them).
1843
+ const order = [...upOrder].reverse();
1844
+ // Stopping shared foundations can break systems OUTSIDE the closure that are
1845
+ // still running on them. Name them rather than letting them fall over
1846
+ // silently.
1847
+ const inClosure = new Set(order);
1848
+ const atRisk = [
1849
+ ...new Set(
1850
+ full.edges
1851
+ .filter(
1852
+ (e) =>
1853
+ e.kind !== "jwks" &&
1854
+ e.ordering !== false &&
1855
+ inClosure.has(e.to) &&
1856
+ !inClosure.has(e.from),
1857
+ )
1858
+ .map((e) => e.from),
1859
+ ),
1860
+ ].sort();
1861
+ console.log(
1862
+ chalk.cyan(`\n Derived down for ${chalk.bold(seed)} (+ dependencies)`),
1863
+ );
1864
+ console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
1865
+ if (atRisk.length > 0) {
1866
+ console.log(
1867
+ chalk.yellow(
1868
+ ` ⚠ also depends on what this stops: ${atRisk.join(", ")}\n` +
1869
+ ` stop or restart them if they misbehave\n`,
1870
+ ),
1871
+ );
1872
+ }
1873
+ if (dryRun) {
1874
+ console.log(chalk.gray(" (--dry-run: not stopping anything)\n"));
1875
+ return;
1876
+ }
1877
+ await this.runServicesCommand(order, "down", { order }, { force });
1878
+ }
1879
+
1880
+ /** Workspace repos with no runtime (modules, libraries) → their kind. */
1881
+ private noRuntimeRepos(): Map<string, RepoKind> {
1882
+ const out = new Map<string, RepoKind>();
1883
+ for (const s of getWorkspaceServices()) {
1884
+ const kind = repoKind(s.repoPath);
1885
+ if (!hasRuntime(kind)) out.set(s.repoName, kind);
1886
+ }
1887
+ return out;
1888
+ }
1889
+
1890
+ /** Drop modules/libraries from a start/stop order, saying so once. */
1891
+ private runtimeOnly(order: string[], verb: "started" | "stopped"): string[] {
1892
+ const none = this.noRuntimeRepos();
1893
+ const skipped = order.filter((r) => none.has(r));
1894
+ if (skipped.length > 0)
1895
+ console.log(
1896
+ chalk.gray(
1897
+ ` Not ${verb} (no runtime of their own): ${skipped.map((r) => `${r} (${none.get(r)})`).join(", ")}`,
1898
+ ),
1899
+ );
1900
+ return order.filter((r) => !none.has(r));
1901
+ }
1902
+
1903
+ /**
1904
+ * Every repo in the active workspace, sorted by the full derived up order —
1905
+ * the CLI counterpart of the web UI's master Up/Down buttons. cwd-agnostic.
1906
+ * Returns null when there is no active workspace (or it has no services).
1907
+ */
1908
+ private allWorkspaceOrder(only?: Set<string>): {
1909
+ rawOrder: string[];
1910
+ graph: DerivedGraph;
1911
+ } | null {
1912
+ // Modules and libraries ship inside another repo's runtime: never started.
1913
+ const services = getWorkspaceServices().filter(
1914
+ (s) =>
1915
+ (!only || only.has(s.repoName)) && hasRuntime(repoKind(s.repoPath)),
1916
+ );
1917
+ if (services.length === 0) return null;
1918
+ const repos = new Set(services.map((s) => s.repoName));
1919
+ const graph = deriveGraph({ scope: "all" });
1920
+ const rank = new Map(orderForUp(graph).map((repo, i) => [repo, i]));
1921
+ const rawOrder = [...repos].sort(
1922
+ (a, b) =>
1923
+ (rank.get(a) ?? Number.MAX_SAFE_INTEGER) -
1924
+ (rank.get(b) ?? Number.MAX_SAFE_INTEGER),
1925
+ );
1926
+ return { rawOrder, graph };
1927
+ }
1928
+
1929
+ private async runAllUp(
1930
+ dryRun: boolean,
1931
+ plan: boolean,
1932
+ pull: boolean,
1933
+ only?: Set<string>,
1934
+ ): Promise<void> {
1935
+ const target = this.allWorkspaceOrder(only);
1936
+ if (!target) {
1937
+ console.log(
1938
+ chalk.yellow(
1939
+ "\n No services in the active workspace — nothing to start.\n",
1940
+ ),
1941
+ );
1942
+ return;
1943
+ }
1944
+ const { rawOrder, graph } = target;
1945
+ const noAutostartSet = this.noAutostartSet();
1946
+ const skippedOff = rawOrder.filter((n) => noAutostartSet.has(n));
1947
+ const candidates = rawOrder.filter((n) => !noAutostartSet.has(n));
1948
+
1949
+ // plan / --dry-run are read-only and must run BEFORE the bootstrap below.
1950
+ // With clones missing the true order can't be known yet, so dry-run shows
1951
+ // the candidate set.
1952
+ if (plan) {
1953
+ this.renderUpPlan(
1954
+ buildUpPlan(graph, rawOrder, { noAutostart: noAutostartSet }),
1955
+ );
1956
+ return;
1957
+ }
1958
+ if (dryRun) {
1959
+ console.log(
1960
+ chalk.cyan(
1961
+ `\n ${only ? "Application" : "Workspace-wide"} up — ${chalk.bold(String(candidates.length))} candidate system(s)`,
1962
+ ),
1963
+ );
1964
+ console.log(chalk.gray(` Order: ${candidates.join(" → ")}\n`));
1965
+ if (skippedOff.length > 0) {
1966
+ console.log(
1967
+ chalk.gray(` Skipping (auto-start off): ${skippedOff.join(", ")}\n`),
1968
+ );
1969
+ }
1970
+ console.log(
1971
+ chalk.gray(
1972
+ " (--dry-run: not starting anything; a real run clones + sets up any missing managed repos first)\n",
1973
+ ),
1974
+ );
1975
+ return;
1976
+ }
1977
+ if (candidates.length === 0) {
1978
+ console.log(chalk.yellow("\n Nothing to start.\n"));
1979
+ return;
1980
+ }
1981
+
1982
+ // Bootstrap FIRST. The no-up filter below reads manifests, and a fresh
1983
+ // workspace has no clones to read — filtering first turned `up -a` into
1984
+ // "Nothing to start" on exactly the machine that needed it most. Starting
1985
+ // everything IS the stated intent, and managed repos are den's
1986
+ // to materialise: clone + setup happen implicitly, no separate command.
1987
+ preflightSecrets();
1988
+ if (pull) this.pullManagedClonesFor(candidates);
1989
+ const failedClones = await this.ensureManagedClonesFor(candidates);
1990
+ if (failedClones.length > 0) {
1991
+ console.log(
1992
+ chalk.red(
1993
+ `\n Aborting (fail-fast): clone/setup failed for ${failedClones.join(", ")}.\n`,
1994
+ ),
1995
+ );
1996
+ return;
1997
+ }
1998
+
1999
+ // Like the web master ▶ Up: a system with no runnable local `up` command
2000
+ // is skipped, not fatal. `-a` starts independent peers — one unrunnable
2001
+ // repo must not strand the rest (runServiceCommand exits 1 on a missing
2002
+ // command, and a fail-fast would turn that into a full abort). Now that
2003
+ // clones exist, the manifest lookup is meaningful.
2004
+ const order: string[] = [];
2005
+ const skippedNoUp: string[] = [];
2006
+ for (const repo of candidates) {
2007
+ const resolved = resolveService(repo);
2008
+ const upDef = resolved
2009
+ ? localCommandsFor(resolved, this.focusedWs())["up"]
2010
+ : undefined;
2011
+ (upDef ? order : skippedNoUp).push(repo);
2012
+ }
2013
+ console.log(
2014
+ chalk.cyan(
2015
+ `\n ${only ? "Application" : "Workspace-wide"} up — ${chalk.bold(String(order.length))} systems`,
2016
+ ),
2017
+ );
2018
+ console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
2019
+ if (skippedOff.length > 0) {
2020
+ console.log(
2021
+ chalk.gray(
2022
+ ` Skipping (auto-start off): ${skippedOff.join(", ")} ` +
2023
+ `(toggle in the web ⋮ menu, or start explicitly with \`${product().cli} up <repo>\`)\n`,
2024
+ ),
2025
+ );
2026
+ }
2027
+ if (skippedNoUp.length > 0) {
2028
+ console.log(
2029
+ chalk.gray(
2030
+ ` Skipping (no local up command): ${skippedNoUp.join(", ")}\n`,
2031
+ ),
2032
+ );
2033
+ }
2034
+ if (order.length === 0) {
2035
+ console.log(chalk.yellow(" Nothing to start.\n"));
2036
+ return;
2037
+ }
2038
+ // Preflight, pull and clones+setup already ran above, before the manifest
2039
+ // filter. From here: the ordered start with hard-dep gating and
2040
+ // healthy-skip. Unlike runDerivedUp there is NO failFast on the start
2041
+ // loop: `-a`'s systems are independent peers — one failure must not
2042
+ // strand the rest (the web master button continues too).
2043
+ const repoSet = new Set(order);
2044
+ await this.runServicesCommand(order, "up", {
2045
+ order,
2046
+ // Hard deps outside the workspace can't be waited on — filter to the set
2047
+ // we are actually starting.
2048
+ waitDepsFor: (address) =>
2049
+ hardDepsOf(graph, address).filter((dep) => repoSet.has(dep)),
2050
+ skipHealthyAll: true,
2051
+ });
2052
+ }
2053
+
2054
+ private async runAllDown(
2055
+ dryRun: boolean,
2056
+ force = false,
2057
+ only?: Set<string>,
2058
+ ): Promise<void> {
2059
+ const target = this.allWorkspaceOrder(only);
2060
+ if (!target) {
2061
+ console.log(
2062
+ chalk.yellow(
2063
+ "\n No services in the active workspace — nothing to stop.\n",
2064
+ ),
2065
+ );
2066
+ return;
2067
+ }
2068
+ // Reverse of the up order. Includes auto-start-off systems — the toggle
2069
+ // only governs starting ("quiet my machine" quiets all of them). Unlike
2070
+ // the web master Down, there is NO already-stopped skip here: every down
2071
+ // script runs (down on a stopped service is a cheap no-op), so systems
2072
+ // invisible to the container/health checks still get stopped.
2073
+ const order = [...target.rawOrder].reverse();
2074
+ console.log(
2075
+ chalk.cyan(
2076
+ `\n ${only ? "Application" : "Workspace-wide"} down — ${chalk.bold(String(order.length))} systems`,
2077
+ ),
2078
+ );
2079
+ console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
2080
+ if (dryRun) {
2081
+ console.log(chalk.gray(" (--dry-run: not stopping anything)\n"));
2082
+ return;
2083
+ }
2084
+ await this.runServicesCommand(order, "down", { order }, { force });
2085
+ }
2086
+
2087
+ private printUpHelp(): void {
2088
+ console.log();
2089
+ console.log(chalk.bold(` ${product().cli} up [repo] [flags]`));
2090
+ console.log();
2091
+ console.log(
2092
+ " Bring up the current repo (or [repo]) and its derived dependency closure,",
2093
+ );
2094
+ console.log(
2095
+ " ordered by the dependency graph. By default the current/seed repo IS started.",
2096
+ );
2097
+ console.log();
2098
+ console.log(chalk.bold(" Flags:"));
2099
+ const row = (f: string, d: string) =>
2100
+ console.log(` ${chalk.cyan(f.padEnd(20))}${d}`);
2101
+ row(
2102
+ "-x, --exclude",
2103
+ "start only the dependencies, not the current/seed repo",
2104
+ );
2105
+ row(
2106
+ "-a, --all",
2107
+ "start EVERY workspace system in derived order (cwd-agnostic; skips auto-start-off)",
2108
+ );
2109
+ row(
2110
+ "-A, --app <app>",
2111
+ `start every repo in an application (\`${product().cli} apps\`), in derived order`,
2112
+ );
2113
+ row("-n, --dry-run", "print the start order without starting anything");
2114
+ row(
2115
+ "--plan",
2116
+ "show the classified plan (commands, optional/interactive) and stop",
2117
+ );
2118
+ row(
2119
+ "--pull",
2120
+ "fast-forward managed clones in the start set first (ff-only; dev-managed untouched)",
2121
+ );
2122
+ row(
2123
+ "--manifest",
2124
+ `use legacy ${product().manifestYml} depends_on instead of the derived graph`,
2125
+ );
2126
+ row("--skip-secrets", "skip the secrets-backend sign-in preflight");
2127
+ row("-h, --help", "show this help");
2128
+ console.log();
2129
+ }
2130
+
2131
+ private printGraphHelp(): void {
2132
+ console.log();
2133
+ console.log(chalk.bold(` ${product().cli} graph [repo] [flags]`));
2134
+ console.log();
2135
+ console.log(
2136
+ " Derive & render the cross-service dependency graph from real repo signals",
2137
+ );
2138
+ console.log(
2139
+ " (SDK imports, *_URL env vars, IaC refs, EventBridge rules). Defaults to the",
2140
+ );
2141
+ console.log(" cwd repo's neighborhood; pass [repo] or --all for more.");
2142
+ console.log();
2143
+ console.log(chalk.bold(" Flags:"));
2144
+ const row = (f: string, d: string) =>
2145
+ console.log(` ${chalk.cyan(f.padEnd(22))}${d}`);
2146
+ row(
2147
+ "--all",
2148
+ "the full known-systems graph (not just the cwd neighborhood)",
2149
+ );
2150
+ row("-u, --up", "render nodes in start order (deps-first) — default");
2151
+ row("-d, --down", "render nodes in shutdown order (reverse)");
2152
+ row("--source=<s>", "derived | declared | union (default derived)");
2153
+ row("--kind=<k>", "all | hard | infra | http | event");
2154
+ row("--format=<f>", "text | json | dot (default text)");
2155
+ row(
2156
+ "--diff",
2157
+ `compare derived deps vs ${product().manifestYml} depends_on`,
2158
+ );
2159
+ row("--refresh", "ignore the per-repo cache and re-derive");
2160
+ row("-h, --help", "show this help");
2161
+ console.log();
2162
+ }
2163
+
2164
+ private printUseModeHelp(mode: "dev-managed" | "managed"): void {
2165
+ const verb = mode === "dev-managed" ? "use-local" : `use-managed`;
2166
+ console.log(`\n ${product().cli} ${verb} [repo]\n`);
2167
+ console.log(
2168
+ mode === "dev-managed"
2169
+ ? ` Point ${product().cli} at your LOCAL checkout (dev-managed) — defaults to the repo you're\n in, or a named repo's sibling under repos_root.`
2170
+ : ` Switch the repo back to a MANAGED clone (${product().servicesDir}) — defaults to\n the repo you're in, or a named repo.`,
2171
+ );
2172
+ console.log();
2173
+ console.log(chalk.bold(" Flags:"));
2174
+ const row = (f: string, d: string) =>
2175
+ console.log(` ${chalk.cyan(f.padEnd(18))}${d}`);
2176
+ if (mode === "managed")
2177
+ row("--branch=<name>", "branch for the managed clone (default main)");
2178
+ row("-h, --help", "show this help");
2179
+ console.log();
2180
+ }
2181
+
2182
+ /** Help for `<command> -h`: dedicated renderer for flag-heavy commands, else the table. */
2183
+ private printCommandHelp(command: string): void {
2184
+ switch (command) {
2185
+ case "up":
2186
+ return this.printUpHelp();
2187
+ case "graph":
2188
+ return this.printGraphHelp();
2189
+ case "use-local":
2190
+ return this.printUseModeHelp("dev-managed");
2191
+ case `use-managed`:
2192
+ return this.printUseModeHelp("managed");
2193
+ case "web":
2194
+ return displayWebHelp(); // its own detailed actions/options help
2195
+ }
2196
+ const entry = COMMAND_HELP[command === "applications" ? "apps" : command];
2197
+ if (!entry) {
2198
+ console.log(
2199
+ chalk.yellow(
2200
+ `\n No detailed help for '${command}'. Run '${product().cli} --help' for the command list.\n`,
2201
+ ),
2202
+ );
2203
+ return;
2204
+ }
2205
+ console.log(`\n ${product().cli} ${entry.usage}\n`);
2206
+ for (const line of entry.lines) console.log(` ${line}`);
2207
+ console.log();
2208
+ }
2209
+
2210
+ /**
2211
+ * Seed (or reset) the default workspace from the committed blueprint
2212
+ * (config/den.yml `defaults.workspace`), all repos managed. Prints
2213
+ * clear guidance and, on a TTY, asks first. `reason: "auto"` is the one-time
2214
+ * offer on a fresh clone; `reason: "init"` is the explicit `den init`.
2215
+ * Returns true if the workspace was seeded.
2216
+ */
2217
+ private async seedDefaultWorkspaceFlow(
2218
+ reason: "auto" | "init",
2219
+ ): Promise<boolean> {
2220
+ const repos = defaultWorkspaceRepos();
2221
+ if (repos.length === 0) {
2222
+ if (reason === "init") {
2223
+ console.log(
2224
+ chalk.yellow(
2225
+ `\n No defaults.workspace.repos in ${product().configFile} — nothing to seed.\n`,
2226
+ ),
2227
+ );
2228
+ }
2229
+ return false;
2230
+ }
2231
+ const willReset = defaultWorkspaceHasServices();
2232
+ const interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
2233
+
2234
+ console.log(
2235
+ chalk.cyan(`\n Default workspace — ${repos.length} repos, all managed:`),
2236
+ );
2237
+ console.log(chalk.gray(` ${repos.join(", ")}`));
2238
+ console.log(
2239
+ chalk.gray(
2240
+ ` ${product().title} clones + tracks them on main under ${product().servicesDir}/. Switch any`,
2241
+ ),
2242
+ );
2243
+ console.log(
2244
+ chalk.gray(
2245
+ ` to your own checkout anytime: \`${product().cli} use-local <repo>\` (revert: use-managed).\n`,
2246
+ ),
2247
+ );
2248
+
2249
+ if (interactive) {
2250
+ const question = willReset
2251
+ ? " Reset the default workspace to this shipped set?"
2252
+ : " Seed the default workspace now?";
2253
+ const ok = await cliPrompts.confirm(question, !willReset);
2254
+ if (!ok) {
2255
+ if (reason === "auto") {
2256
+ markDefaultWorkspaceDeclined(); // don't ask again
2257
+ console.log(
2258
+ chalk.gray(
2259
+ ` Skipped — run \`${product().cli} init\` to set it up later.\n`,
2260
+ ),
2261
+ );
2262
+ } else {
2263
+ console.log(chalk.gray(" Aborted.\n"));
2264
+ }
2265
+ return false;
2266
+ }
2267
+ }
2268
+
2269
+ const seeded = seedDefaultWorkspace();
2270
+ console.log(
2271
+ chalk.green(
2272
+ ` ✓ Default workspace ready — ${seeded.length} repos (managed). ` +
2273
+ `Run \`${product().cli} up <repo>\` to bring up its closure.\n`,
2274
+ ),
2275
+ );
2276
+ return true;
2277
+ }
2278
+
2279
+ /**
2280
+ * One-time offer to seed the default workspace on a fresh clone; afterwards
2281
+ * keep it converged on the blueprint (repos added to defaults later — e.g.
2282
+ * tasks-api — join the existing workspace managed).
2283
+ */
2284
+ private async ensureWorkspaceSeeded(): Promise<void> {
2285
+ if (defaultWorkspaceNeedsSeeding()) {
2286
+ await this.seedDefaultWorkspaceFlow("auto");
2287
+ return;
2288
+ }
2289
+ const added = topUpDefaultWorkspace();
2290
+ if (added.length > 0) {
2291
+ console.log(
2292
+ chalk.gray(
2293
+ ` default workspace: added standard repo(s) ${added.join(", ")} (managed)`,
2294
+ ),
2295
+ );
2296
+ }
2297
+ }
2298
+
2299
+ private async cmdServiceUp(args: string[]): Promise<void> {
2300
+ await this.ensureWorkspaceSeeded();
2301
+ // Tokens after a `--` terminator belong to a child command — they must
2302
+ // never be read as up's own flags (`den up -- -a` is NOT `up -a`).
2303
+ const terminator = args.indexOf("--");
2304
+ const { app, rest: ownArgs } = takeAppFlag(
2305
+ terminator === -1 ? args : args.slice(0, terminator),
2306
+ );
2307
+ const dryRun = ownArgs.includes("--dry-run") || ownArgs.includes("-n");
2308
+ const plan = ownArgs.includes("--plan");
2309
+ const pull = ownArgs.includes("--pull");
2310
+ // `-A <app>`: every repo in that application, in derived order (like -a, scoped).
2311
+ if (app) {
2312
+ await this.runAllUp(dryRun, plan, pull, focusedAppRepos(app));
2313
+ return;
2314
+ }
2315
+ const seedArg = ownArgs.find((a) => !a.startsWith("-"));
2316
+
2317
+ // `-a`/`--all`: the whole active workspace in derived order, regardless of
2318
+ // cwd — the CLI twin of the web UI's master ▶ Up button.
2319
+ if (ownArgs.includes("-a") || ownArgs.includes("--all")) {
2320
+ const conflicting = [
2321
+ "-x",
2322
+ "--exclude",
2323
+ "--manifest",
2324
+ "--no-derive",
2325
+ ].filter((f) => ownArgs.includes(f));
2326
+ if (conflicting.length > 0) {
2327
+ console.log(
2328
+ chalk.red(
2329
+ `\n -a/--all cannot be combined with ${conflicting.join(", ")}.\n`,
2330
+ ),
2331
+ );
2332
+ process.exit(1);
2333
+ }
2334
+ if (seedArg) {
2335
+ console.log(
2336
+ chalk.red(
2337
+ `\n -a/--all starts the whole workspace — drop the repo arg (${seedArg}) or the flag.\n`,
2338
+ ),
2339
+ );
2340
+ process.exit(1);
2341
+ }
2342
+ await this.runAllUp(dryRun, plan, pull);
2343
+ return;
2344
+ }
2345
+
2346
+ // Default: derive the dependency closure from the cwd repo (or an explicit
2347
+ // known-repo arg) and bring it up. Falls back to the legacy manifest path
2348
+ // when there is no known seed (explicit service address, or run outside a
2349
+ // known repo) or when --manifest/--no-derive is passed.
2350
+ const forceManifest =
2351
+ ownArgs.includes("--manifest") || ownArgs.includes("--no-derive");
2352
+ const seed = forceManifest ? undefined : this.derivedSeed(seedArg);
2353
+
2354
+ if (seed) {
2355
+ // Default now INCLUDES the current/seed repo; `-x`/`--exclude` starts only
2356
+ // its dependencies (the previous default).
2357
+ const include = !(
2358
+ ownArgs.includes("-x") || ownArgs.includes("--exclude")
2359
+ );
2360
+ await this.runDerivedUp(seed, include, dryRun, plan, pull);
2361
+ return;
2362
+ }
2363
+ await this.cmdServiceUpManifest(ownArgs.filter((a) => !a.startsWith("-")));
2364
+ }
2365
+
2366
+ /** Legacy: start the given services via manifest depends_on. */
2367
+ private async cmdServiceUpManifest(addresses: string[]): Promise<void> {
2368
+ // Resolve targets BEFORE the secrets preflight — otherwise running
2369
+ // `den up` from a non-service dir (e.g. the den repo root) triggers
2370
+ // an interactive sign-in just to print "nothing to do".
2371
+ const targets =
2372
+ addresses.length > 0 ? addresses : this.resolveNoArgServiceTargets("up");
2373
+ if (targets.length === 0) {
2374
+ const here = this.cwdSeedRepo();
2375
+ if (here === `${product().cli}`) {
2376
+ console.log(
2377
+ chalk.yellow(
2378
+ `\n You're in the ${product().cli} repo, which isn't a service itself.\n`,
2379
+ ) +
2380
+ chalk.gray(
2381
+ ` Start a system by name (\`${product().cli} up shop\`), cd into a service repo,\n` +
2382
+ ` or run \`${product().cli} graph\` to see the workspace.\n`,
2383
+ ),
2384
+ );
2385
+ } else {
2386
+ console.log(
2387
+ chalk.yellow(
2388
+ "No services specified and no invocation target resolvable — run from a service repo or pass a service name.",
2389
+ ),
2390
+ );
2391
+ }
2392
+ return;
2393
+ }
2394
+ preflightSecrets();
2395
+ await this.runServicesCommand(targets, "up");
2396
+ }
2397
+
2398
+ private async cmdServiceDown(args: string[]): Promise<void> {
2399
+ // Tokens after a `--` terminator belong to a child command — they must
2400
+ // never be read as down's own flags (`den down -- -a` must NOT tear
2401
+ // down the whole workspace).
2402
+ const terminator = args.indexOf("--");
2403
+ const { app, rest: ownArgs } = takeAppFlag(
2404
+ terminator === -1 ? args : args.slice(0, terminator),
2405
+ );
2406
+ const dryRun = ownArgs.includes("--dry-run") || ownArgs.includes("-n");
2407
+ const force = ownArgs.includes("--force") || ownArgs.includes("-f");
2408
+ if (app) {
2409
+ await this.runAllDown(dryRun, force, focusedAppRepos(app));
2410
+ return;
2411
+ }
2412
+
2413
+ // `-a`/`--all`: stop every workspace system in reverse derived order,
2414
+ // regardless of cwd — the CLI twin of the web UI's master ⏹ Down button
2415
+ // (minus its already-stopped skip: every down script runs).
2416
+ if (ownArgs.includes("-a") || ownArgs.includes("--all")) {
2417
+ const conflicting = ["--manifest", "--no-derive"].filter((f) =>
2418
+ ownArgs.includes(f),
2419
+ );
2420
+ if (conflicting.length > 0) {
2421
+ console.log(
2422
+ chalk.red(
2423
+ `\n -a/--all cannot be combined with ${conflicting.join(", ")}.\n`,
2424
+ ),
2425
+ );
2426
+ process.exit(1);
2427
+ }
2428
+ const seedArg = ownArgs.find((a) => !a.startsWith("-"));
2429
+ if (seedArg) {
2430
+ console.log(
2431
+ chalk.red(
2432
+ `\n -a/--all stops the whole workspace — drop the repo arg (${seedArg}) or the flag.\n`,
2433
+ ),
2434
+ );
2435
+ process.exit(1);
2436
+ }
2437
+ await this.runAllDown(dryRun, force);
2438
+ return;
2439
+ }
2440
+
2441
+ const forceManifest =
2442
+ ownArgs.includes("--manifest") || ownArgs.includes("--no-derive");
2443
+ const seedArg = ownArgs.find((a) => !a.startsWith("-"));
2444
+ const seed = forceManifest ? undefined : this.derivedSeed(seedArg);
2445
+
2446
+ if (seed) {
2447
+ await this.runDerivedDown(seed, dryRun, force);
2448
+ return;
2449
+ }
2450
+ const addresses = ownArgs.filter((a) => !a.startsWith("-"));
2451
+ const targets =
2452
+ addresses.length > 0
2453
+ ? addresses
2454
+ : this.resolveNoArgServiceTargets("down");
2455
+ await this.runServicesCommand(targets, "down", undefined, { force });
2456
+ }
2457
+
2458
+ private async cmdServiceRestart(argv: string[]): Promise<void> {
2459
+ const { app, rest: args } = takeAppFlag(argv);
2460
+ preflightSecrets();
2461
+ if (app) {
2462
+ const order =
2463
+ this.allWorkspaceOrder(focusedAppRepos(app))?.rawOrder ?? [];
2464
+ await this.runServicesCommand(order, "restart");
2465
+ return;
2466
+ }
2467
+ // Drop flag tokens so a bare `den restart --foo` doesn't treat the flag
2468
+ // as a service address (matches cmdServiceUp/cmdServiceDown).
2469
+ const filtered = args.filter((a) => !a.startsWith("-"));
2470
+ const addresses =
2471
+ filtered.length > 0
2472
+ ? filtered
2473
+ : this.resolveNoArgServiceTargets("restart");
2474
+ await this.runServicesCommand(addresses, "restart");
2475
+ }
2476
+
2477
+ /** Service address with a ⏸ marker when the repo is auto-start off. */
2478
+ private labelWithAutostart(
2479
+ address: string,
2480
+ noAutostart: Set<string>,
2481
+ ): string {
2482
+ const base = chalk.bold(address);
2483
+ return noAutostart.has(parseAddress(address).repoName)
2484
+ ? `${base} ${chalk.yellow("⏸")}`
2485
+ : base;
2486
+ }
2487
+
2488
+ /** Repos with auto-start toggled off in the active workspace. */
2489
+ private noAutostartSet(): Set<string> {
2490
+ const ws = getActiveWorkspace();
2491
+ if (!ws) return new Set();
2492
+ return noAutostartRepos(ws.config, readCatalog());
2493
+ }
2494
+
2495
+ private async cmdServiceStatus(argv: string[] = []): Promise<void> {
2496
+ await this.ensureWorkspaceSeeded();
2497
+ const { app, rest: args } = takeAppFlag(argv);
2498
+ const only = app ? focusedAppRepos(app) : null;
2499
+ const verbose = args.includes("--verbose") || args.includes("--all");
2500
+ const spinner = ora("Checking service health...").start();
2501
+ const raw = (await checkCatalogHealth()).filter(
2502
+ (h) => !only || only.has(h.repoName),
2503
+ );
2504
+ // Roll multi-service repos up to their entrypoint so shared-infra sidecars
2505
+ // (which read healthy off infra-stack) don't make a down app look up.
2506
+ const rolled = verbose ? raw : rollupRepoHealth(raw);
2507
+ // Order top-down like the graph: entry repo first (shop-web) … foundation
2508
+ // last (infra-stack), subsystems grouped under their repo.
2509
+ const order = orderForDown(deriveGraph({ scope: "all" }));
2510
+ const results = sortByRepoOrder(rolled, order);
2511
+ spinner.stop();
2512
+ const noAutostart = this.noAutostartSet();
2513
+
2514
+ if (results.length === 0) {
2515
+ console.log(
2516
+ chalk.yellow(
2517
+ `\n No services registered. Run: ${product().cli} register\n`,
2518
+ ),
2519
+ );
2520
+ return;
2521
+ }
2522
+
2523
+ const ws = getActiveWorkspace();
2524
+ console.log(
2525
+ chalk.cyan(`\n Workspace: ${chalk.bold(ws?.name ?? "none")}\n`),
2526
+ );
2527
+
2528
+ const table = new Table({
2529
+ head: ["Service", "Mode", "Branch", "Health", "Reason"],
2530
+ style: { head: ["cyan"] },
2531
+ });
2532
+
2533
+ for (const r of results) {
2534
+ const healthStr = formatHealthLabel(r.health);
2535
+ const reasonStr = formatHealthReason(r);
2536
+ const placed = placementSuffix({
2537
+ runs: r.runs ?? "none",
2538
+ target: r.target ?? null,
2539
+ });
2540
+
2541
+ table.push([
2542
+ this.labelWithAutostart(r.address, noAutostart) +
2543
+ (placed ? chalk.cyan(` ${placed}`) : ""),
2544
+ r.mode === "dev-managed"
2545
+ ? chalk.blue("dev-managed")
2546
+ : chalk.magenta("managed"),
2547
+ r.branch ?? chalk.gray("(dev-managed)"),
2548
+ healthStr,
2549
+ reasonStr,
2550
+ ]);
2551
+ }
2552
+
2553
+ console.log(table.toString());
2554
+ if (
2555
+ results.some((r) => noAutostart.has(parseAddress(r.address).repoName))
2556
+ ) {
2557
+ console.log(
2558
+ chalk.gray(" ⏸ auto-start off — not started by workspace up"),
2559
+ );
2560
+ }
2561
+ if (!verbose && raw.length !== results.length) {
2562
+ console.log(
2563
+ chalk.gray(
2564
+ ` ▪ multi-service repos show entrypoint health — \`${product().cli} status --verbose\` for all subsystems`,
2565
+ ),
2566
+ );
2567
+ }
2568
+ const contributors = loadApplications()
2569
+ .filter((a) => !app || a.name === app)
2570
+ .flatMap((a) => a.contributors);
2571
+ if (contributors.length > 0) {
2572
+ console.log(chalk.cyan("\n Contributes (no runtime of its own):"));
2573
+ for (const c of contributors)
2574
+ console.log(` ${describeContributor(c)}`);
2575
+ }
2576
+ console.log();
2577
+ }
2578
+
2579
+ private async cmdLogs(args: string[]): Promise<void> {
2580
+ await this.ensureWorkspaceSeeded();
2581
+ const alias = loadAliasResolver();
2582
+ const target = resolveLogsTarget(args, alias);
2583
+ if (target.kind === "unknown-repo") {
2584
+ console.log(chalk.red(`\n Unknown repo: ${target.arg}\n`));
2585
+ process.exit(1);
2586
+ }
2587
+ if (target.kind === "no-cwd-repo") {
2588
+ console.log(
2589
+ chalk.red(
2590
+ "\n Not inside a known repo — run from a repo folder or pass a repo name.\n",
2591
+ ),
2592
+ );
2593
+ process.exit(1);
2594
+ }
2595
+
2596
+ let resolved = resolveService(target.target);
2597
+ if (!resolved) {
2598
+ console.log(
2599
+ chalk.red(
2600
+ `\n Service "${target.target}" not found in catalog or active workspace.\n`,
2601
+ ),
2602
+ );
2603
+ process.exit(1);
2604
+ }
2605
+
2606
+ // The canonical run-state test (same as the web down-skip and log search):
2607
+ // container check first (cheap, host-truth), health probe as the fallback
2608
+ // for host-run processes with no container.
2609
+ const running =
2610
+ hasRunningOpctlContainers(resolved) ||
2611
+ (await checkServiceHealth(resolved, "local")).health === "ok";
2612
+
2613
+ let justStarted = false;
2614
+ if (!running) {
2615
+ const interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
2616
+ if (!interactive) {
2617
+ console.error(
2618
+ chalk.red(`${resolved.address} is not running.`) +
2619
+ chalk.gray(
2620
+ ` Start it with '${product().cli} up ${target.target}', then re-run '${product().cli} logs'.`,
2621
+ ),
2622
+ );
2623
+ process.exit(1);
2624
+ }
2625
+ console.log(
2626
+ chalk.yellow(
2627
+ `\n ${resolved.address} is not running (no container or healthy endpoint).`,
2628
+ ),
2629
+ );
2630
+ const start = await cliPrompts.confirm(
2631
+ `Start it now with '${product().cli} up ${target.target}'?`,
2632
+ true,
2633
+ );
2634
+ if (!start) {
2635
+ console.log(chalk.gray("\n Nothing to tail.\n"));
2636
+ // inquirer holds stdin open after a prompt — exit explicitly.
2637
+ process.exit(0);
2638
+ }
2639
+ await this.cmdServiceUp([target.target]);
2640
+ justStarted = true;
2641
+
2642
+ // `up` detaches after ~4s with no success signal. Re-resolve (a first
2643
+ // clone can correct a bare repo's subsystem to the manifest entrypoint)
2644
+ // and wait for readiness so we tail what actually started — fail-open
2645
+ // when the service has no health check.
2646
+ resolved = resolveService(target.target) ?? resolved;
2647
+ const addr = resolved.address;
2648
+ const spinner = ora(`Waiting for ${addr} to become ready...`).start();
2649
+ const ready = await waitForReady(resolved, "local", {
2650
+ onTick: (detail) => {
2651
+ spinner.text = `Waiting for ${addr}: ${detail}`;
2652
+ },
2653
+ });
2654
+ if (!ready.ok) {
2655
+ spinner.warn(
2656
+ chalk.yellow(
2657
+ `${resolved.address} is still not healthy (${ready.detail}) — tailing its startup log anyway.`,
2658
+ ),
2659
+ );
2660
+ } else if (ready.skipped) {
2661
+ spinner.stop();
2662
+ } else {
2663
+ spinner.succeed(`${resolved.address} is ready`);
2664
+ }
2665
+ }
2666
+
2667
+ const code = await tailServiceLogs(
2668
+ resolved,
2669
+ justStarted ? { waitForSourceMs: 15_000 } : {},
2670
+ );
2671
+ process.exit(code === 0 ? 0 : 1);
2672
+ }
2673
+
2674
+ private async cmdListServices(): Promise<void> {
2675
+ await this.ensureWorkspaceSeeded();
2676
+ const spinner = ora("Loading services...").start();
2677
+ const catalog = readCatalog();
2678
+ const results = await checkCatalogHealth(catalog);
2679
+ spinner.stop();
2680
+ const noAutostart = this.noAutostartSet();
2681
+
2682
+ if (results.length === 0) {
2683
+ console.log(chalk.yellow("\n No services registered.\n"));
2684
+ console.log(
2685
+ chalk.gray(` Register a repo: ${product().cli} register <path>\n`),
2686
+ );
2687
+ return;
2688
+ }
2689
+
2690
+ const table = new Table({
2691
+ head: ["Service", "Mode", "Health", "Commands"],
2692
+ style: { head: ["cyan"] },
2693
+ });
2694
+
2695
+ for (const r of results) {
2696
+ const healthStr = formatHealthDot(r.health);
2697
+
2698
+ let cmds: string[] = [];
2699
+ try {
2700
+ cmds = Object.keys(localCommandsFor(r, this.focusedWs()));
2701
+ } catch {
2702
+ /* ignore */
2703
+ }
2704
+
2705
+ const placed = placementSuffix({
2706
+ runs: r.runs ?? "none",
2707
+ target: r.target ?? null,
2708
+ });
2709
+ table.push([
2710
+ this.labelWithAutostart(r.address, noAutostart) +
2711
+ (placed ? chalk.cyan(` ${placed}`) : ""),
2712
+ r.mode === "dev-managed"
2713
+ ? chalk.blue("dev-managed")
2714
+ : chalk.magenta("managed"),
2715
+ healthStr,
2716
+ // A remote service's commands are never run from here.
2717
+ placed ? chalk.gray("—") : cmds.join(", "),
2718
+ ]);
2719
+ }
2720
+
2721
+ console.log(table.toString());
2722
+ if (
2723
+ results.some((r) => noAutostart.has(parseAddress(r.address).repoName))
2724
+ ) {
2725
+ console.log(
2726
+ chalk.gray(" ⏸ auto-start off — not started by workspace up"),
2727
+ );
2728
+ }
2729
+ console.log();
2730
+ }
2731
+
2732
+ private async cmdRegister(args: string[]): Promise<void> {
2733
+ if (args.includes("-h") || args.includes("--help")) {
2734
+ console.log(
2735
+ `\n ${product().cli} register [path] [--name <repo>] [--gh <owner/repo>]\n\n` +
2736
+ " With a path: add that checkout to the catalog and the focused\n" +
2737
+ " workspace as your own (dev-managed) copy, without prompts. The name\n" +
2738
+ " defaults to the manifest's name, then the folder name; the GitHub\n" +
2739
+ " slug to the git remote.\n" +
2740
+ " Without a path: choose a local checkout or a managed clone interactively.\n",
2741
+ );
2742
+ return;
2743
+ }
2744
+ const ws = getActiveWorkspace();
2745
+ if (!ws) {
2746
+ console.log(
2747
+ chalk.red(
2748
+ `\n No active workspace. Create one first: ${product().cli} workspace create\n`,
2749
+ ),
2750
+ );
2751
+ return;
2752
+ }
2753
+
2754
+ const repoPathArg = args.find(
2755
+ (a, i) => !a.startsWith("-") && !/^--(name|gh)$/.test(args[i - 1] ?? ""),
2756
+ );
2757
+ if (repoPathArg) {
2758
+ const flag = (n: string) => {
2759
+ const i = args.indexOf(n);
2760
+ return i >= 0 ? args[i + 1] : undefined;
2761
+ };
2762
+ await this.registerPath(ws, repoPathArg, flag("--name"), flag("--gh"));
2763
+ return;
2764
+ }
2765
+
2766
+ const source = await cliPrompts.promptForRegistrationSource();
2767
+
2768
+ if (source === "local") {
2769
+ await this.registerLinked(ws);
2770
+ } else {
2771
+ await this.registerManaged(ws);
2772
+ }
2773
+ }
2774
+
2775
+ /** Non-interactive `register <path>`: a dev-managed entry in the focused workspace. */
2776
+ private async registerPath(
2777
+ ws: {
2778
+ name: string;
2779
+ config: import("../../shared/types/index.js").WorkspaceConfig;
2780
+ },
2781
+ rawPath: string,
2782
+ nameArg: string | undefined,
2783
+ ghArg: string | undefined,
2784
+ ): Promise<void> {
2785
+ const { existsSync } = await import("fs");
2786
+ const repoPath = nodePath.resolve(rawPath);
2787
+ if (!existsSync(repoPath)) {
2788
+ console.log(chalk.red(`\n ❌ Path does not exist: ${repoPath}\n`));
2789
+ process.exitCode = 1;
2790
+ return;
2791
+ }
2792
+ const manifest = readManifest(repoPath);
2793
+ const repoName = nameArg ?? manifest?.name ?? nodePath.basename(repoPath);
2794
+ const coords = getGitHubCoords(repoPath);
2795
+ const gh = ghArg ?? (coords ? `${coords.owner}/${coords.repo}` : undefined);
2796
+ upsertCatalogEntry(repoName, { gh: gh ?? "" });
2797
+ await this.writeWorkspaceEntries(
2798
+ ws,
2799
+ repoName,
2800
+ manifest,
2801
+ "dev-managed",
2802
+ repoPath,
2803
+ undefined,
2804
+ true,
2805
+ );
2806
+ }
2807
+
2808
+ /** Register a local checkout as a linked service in the active workspace. */
2809
+ private async registerLinked(ws: {
2810
+ name: string;
2811
+ config: import("../../shared/types/index.js").WorkspaceConfig;
2812
+ }): Promise<void> {
2813
+ const catalog = readCatalog();
2814
+ const repoPath = await cliPrompts.promptForRepoPath(Object.keys(catalog));
2815
+
2816
+ const { existsSync } = await import("fs");
2817
+ if (!existsSync(repoPath)) {
2818
+ console.log(chalk.red(`\n ❌ Path does not exist: ${repoPath}\n`));
2819
+ return;
2820
+ }
2821
+
2822
+ const manifest = readManifest(repoPath);
2823
+ // Use the path relative to repos root as the catalog key so that repos in
2824
+ // project subdirectories get unique, path-based keys (e.g. "projectA/serviceA").
2825
+ const repoName = nodePath.relative(getReposRoot(), repoPath);
2826
+
2827
+ // Auto-detect GH slug from git remote; fall back to asking
2828
+ let ghSlug: string;
2829
+ const coords = getGitHubCoords(repoPath);
2830
+ if (coords) {
2831
+ ghSlug = `${coords.owner}/${coords.repo}`;
2832
+ } else {
2833
+ ghSlug = await cliPrompts.promptForGhSlug();
2834
+ }
2835
+
2836
+ upsertCatalogEntry(repoName, { gh: ghSlug });
2837
+
2838
+ const linkedPath = await cliPrompts.promptForLinkedPath(repoPath);
2839
+
2840
+ await this.writeWorkspaceEntries(
2841
+ ws,
2842
+ repoName,
2843
+ manifest,
2844
+ "dev-managed",
2845
+ linkedPath,
2846
+ undefined,
2847
+ );
2848
+ }
2849
+
2850
+ /** List org repos via `gh`, let user pick one + branch → managed service. */
2851
+ private async registerManaged(ws: {
2852
+ name: string;
2853
+ config: import("../../shared/types/index.js").WorkspaceConfig;
2854
+ }): Promise<void> {
2855
+ // Derive org from den's own git remote
2856
+ const { fileURLToPath: ftu } = await import("url");
2857
+ const { default: pathMod } = await import("path");
2858
+ const distRoot = pathMod.resolve(
2859
+ pathMod.dirname(ftu(import.meta.url)),
2860
+ "../../..",
2861
+ );
2862
+ const denCoords = getGitHubCoords(distRoot);
2863
+ // The instance's config names its org; the checkout's own remote is only a
2864
+ // fallback (inside a package it would name the engine's repo, not the org's).
2865
+ const org = readDenConfig().github_org ?? denCoords?.owner;
2866
+ if (!org || !/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(org)) {
2867
+ console.log(
2868
+ chalk.red(
2869
+ `\n ❌ No GitHub org to list: set github_org in ${product().configFile}\n`,
2870
+ ),
2871
+ );
2872
+ return;
2873
+ }
2874
+
2875
+ console.log(chalk.gray(`\n Fetching repos from ${org}…`));
2876
+ let repos: { name: string; nameWithOwner: string; defaultBranch: string }[];
2877
+ try {
2878
+ const raw = execSync(
2879
+ `gh repo list ${org} --json name,nameWithOwner,defaultBranchRef --limit 100`,
2880
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
2881
+ );
2882
+ const parsed = JSON.parse(raw) as {
2883
+ name: string;
2884
+ nameWithOwner: string;
2885
+ defaultBranchRef: { name: string };
2886
+ }[];
2887
+ repos = parsed.map((r) => ({
2888
+ name: r.name,
2889
+ nameWithOwner: r.nameWithOwner,
2890
+ defaultBranch: r.defaultBranchRef?.name ?? "main",
2891
+ }));
2892
+ } catch (err) {
2893
+ console.log(
2894
+ chalk.red(
2895
+ `\n ❌ Could not list repos via gh CLI: ${(err as Error).message}\n`,
2896
+ ),
2897
+ );
2898
+ console.log(
2899
+ chalk.gray(
2900
+ " Make sure `gh` is installed and authenticated (`gh auth status`).\n",
2901
+ ),
2902
+ );
2903
+ return;
2904
+ }
2905
+
2906
+ if (repos.length === 0) {
2907
+ console.log(chalk.yellow(`\n No repos found under ${org}.\n`));
2908
+ return;
2909
+ }
2910
+
2911
+ const selected = await cliPrompts.promptForGhOrgRepo(repos);
2912
+ const branch = await cliPrompts.promptForBranch(selected.defaultBranch);
2913
+
2914
+ const repoName = selected.name;
2915
+ upsertCatalogEntry(repoName, { gh: selected.nameWithOwner });
2916
+ console.log(
2917
+ chalk.gray(
2918
+ `\n ✓ Added ${repoName} to catalog (gh: ${selected.nameWithOwner})`,
2919
+ ),
2920
+ );
2921
+
2922
+ // For managed repos we may not have the manifest yet (not cloned);
2923
+ // register the repo-level address and let expand handle subsystems later
2924
+ await this.writeWorkspaceEntries(
2925
+ ws,
2926
+ repoName,
2927
+ null,
2928
+ "managed",
2929
+ undefined,
2930
+ branch,
2931
+ );
2932
+ }
2933
+
2934
+ /** Write workspace entries for all services in a repo's manifest. */
2935
+ private async writeWorkspaceEntries(
2936
+ ws: {
2937
+ name: string;
2938
+ config: import("../../shared/types/index.js").WorkspaceConfig;
2939
+ },
2940
+ repoName: string,
2941
+ manifest: ReturnType<typeof readManifest>,
2942
+ mode: "dev-managed" | "managed",
2943
+ linkedPath: string | undefined,
2944
+ branch: string | undefined,
2945
+ allServices = false,
2946
+ ): Promise<void> {
2947
+ const serviceKeys = Object.keys(manifest?.services ?? {});
2948
+ let addresses: string[];
2949
+ if (allServices) {
2950
+ // The bare repo name expands to every service in the manifest.
2951
+ addresses = [repoName];
2952
+ } else if (serviceKeys.length === 0) {
2953
+ addresses = [repoName];
2954
+ } else if (serviceKeys.length === 1) {
2955
+ // Single-service repo: always use the bare catalog key as the workspace address.
2956
+ // expandMonoRepoEntry corrects the subsystem during resolution.
2957
+ addresses = [repoName];
2958
+ } else {
2959
+ const all = serviceKeys.map((s) =>
2960
+ s === repoName ? repoName : `${repoName}/${s}`,
2961
+ );
2962
+ addresses = await cliPrompts.promptSelectServices(
2963
+ all,
2964
+ `Add which services to workspace "${ws.name}"?`,
2965
+ true,
2966
+ );
2967
+ }
2968
+
2969
+ if (addresses.length === 0) {
2970
+ console.log(chalk.yellow("\n No services selected.\n"));
2971
+ return;
2972
+ }
2973
+
2974
+ const file = readWorkspacesFile();
2975
+ const svcEntries = file.workspaces[ws.name].services;
2976
+ // Replace ALL existing entries for this repo (bare name + any "repo/subsystem"
2977
+ // addresses) before writing the new ones. Otherwise switching mode (e.g.
2978
+ // managed → dev-managed) on a multi-service repo leaves a stale entry
2979
+ // that silently shadows the new one during resolution. Preserve the per-entry
2980
+ // auto-start toggle across the rewrite.
2981
+ let hadAutostartOff = false;
2982
+ for (const key of Object.keys(svcEntries)) {
2983
+ if (key === repoName || key.startsWith(`${repoName}/`)) {
2984
+ if (svcEntries[key].autostart === false) hadAutostartOff = true;
2985
+ delete svcEntries[key];
2986
+ }
2987
+ }
2988
+ for (const addr of addresses) {
2989
+ svcEntries[addr] = {
2990
+ mode,
2991
+ ...(mode === "dev-managed" && linkedPath ? { path: linkedPath } : {}),
2992
+ ...(mode === "managed" && branch ? { branch } : {}),
2993
+ ...(hadAutostartOff ? { autostart: false } : {}),
2994
+ };
2995
+ }
2996
+ writeWorkspacesFile(file);
2997
+
2998
+ console.log(chalk.green(`\n ✓ Added to workspace "${ws.name}":\n`));
2999
+ for (const addr of addresses) {
3000
+ const detail =
3001
+ mode === "managed"
3002
+ ? chalk.gray(`managed branch: ${branch ?? "main"}`)
3003
+ : chalk.gray(`dev-managed path: ${linkedPath ?? ""}`);
3004
+ console.log(` ${chalk.bold(addr)} ${detail}`);
3005
+ }
3006
+ console.log();
3007
+ }
3008
+
3009
+ /**
3010
+ * `den use-local [repo]` / `den use-managed [repo]` — switch a repo's
3011
+ * boot source between your local checkout (dev-managed) and a managed
3012
+ * clone, in the active workspace. Defaults to the cwd repo; accepts a name.
3013
+ */
3014
+ private async cmdUseMode(
3015
+ args: string[],
3016
+ mode: "dev-managed" | "managed",
3017
+ ): Promise<void> {
3018
+ const repoArg = args.find((a) => !a.startsWith("-"));
3019
+ const alias = loadAliasResolver();
3020
+ let repo: string | undefined;
3021
+ let cwdRoot: string | undefined;
3022
+ if (repoArg) {
3023
+ const resolved = alias.resolve(repoArg);
3024
+ repo =
3025
+ resolved && alias.knownRepos.has(resolved)
3026
+ ? resolved
3027
+ : alias.knownRepos.has(repoArg)
3028
+ ? repoArg
3029
+ : undefined;
3030
+ if (!repo) {
3031
+ console.log(chalk.red(`\n Unknown repo: ${repoArg}\n`));
3032
+ return;
3033
+ }
3034
+ } else {
3035
+ const cwd = resolveCwdRepo(alias);
3036
+ if (!cwd) {
3037
+ console.log(
3038
+ chalk.red(
3039
+ "\n Not inside a known repo — run from a repo folder or pass a repo name.\n",
3040
+ ),
3041
+ );
3042
+ return;
3043
+ }
3044
+ repo = cwd.repo;
3045
+ cwdRoot = cwd.root;
3046
+ }
3047
+
3048
+ if (mode === "dev-managed") {
3049
+ const localPath = cwdRoot ?? nodePath.join(getReposRoot(), repo);
3050
+ if (!fs.existsSync(localPath)) {
3051
+ console.log(
3052
+ chalk.red(`\n No local checkout found at ${localPath}.`) +
3053
+ chalk.gray(
3054
+ `\n Clone it there first, or use '${product().cli} register' to set a custom path.\n`,
3055
+ ),
3056
+ );
3057
+ return;
3058
+ }
3059
+ this.switchRepoMode(repo, "dev-managed", localPath, undefined);
3060
+ } else {
3061
+ const branch =
3062
+ args.find((a) => a.startsWith("--branch="))?.split("=")[1] ?? "main";
3063
+ this.switchRepoMode(repo, "managed", undefined, branch);
3064
+ }
3065
+ }
3066
+
3067
+ /** Rewrite the active workspace's existing entries for a repo with a new mode. */
3068
+ private switchRepoMode(
3069
+ repoName: string,
3070
+ mode: "dev-managed" | "managed",
3071
+ linkedPath: string | undefined,
3072
+ branch: string | undefined,
3073
+ ): void {
3074
+ const ws = getActiveWorkspace();
3075
+ if (!ws) {
3076
+ console.log(chalk.red("\n No active workspace.\n"));
3077
+ return;
3078
+ }
3079
+ const file = readWorkspacesFile();
3080
+ const wsFile = file.workspaces[ws.name];
3081
+ if (!wsFile) {
3082
+ console.log(chalk.red(`\n Workspace "${ws.name}" not found.\n`));
3083
+ return;
3084
+ }
3085
+ if (!wsFile.services) wsFile.services = {};
3086
+ const svcEntries = wsFile.services;
3087
+ // Preserve the repo's existing subsystem addresses — just flip mode/path/branch.
3088
+ let keys = Object.keys(svcEntries).filter(
3089
+ (k) => k === repoName || k.startsWith(`${repoName}/`),
3090
+ );
3091
+ const added = keys.length === 0;
3092
+ if (added) keys = [repoName];
3093
+ for (const k of keys) {
3094
+ // Preserve the per-entry auto-start toggle across the mode rewrite.
3095
+ const keepOff = svcEntries[k]?.autostart === false;
3096
+ svcEntries[k] = {
3097
+ mode,
3098
+ ...(mode === "dev-managed" && linkedPath ? { path: linkedPath } : {}),
3099
+ ...(mode === "managed" ? { branch: branch ?? "main" } : {}),
3100
+ ...(keepOff ? { autostart: false } : {}),
3101
+ };
3102
+ }
3103
+ writeWorkspacesFile(file);
3104
+
3105
+ const detail =
3106
+ mode === "dev-managed"
3107
+ ? `dev-managed → ${linkedPath}`
3108
+ : `managed (branch: ${branch ?? "main"})`;
3109
+ console.log(chalk.green(`\n ✓ ${chalk.bold(repoName)} is now ${detail}`));
3110
+ console.log(
3111
+ chalk.gray(
3112
+ ` services: ${keys.join(", ")}${added ? " (added to workspace)" : ""}`,
3113
+ ),
3114
+ );
3115
+ const revert =
3116
+ mode === "dev-managed"
3117
+ ? `${product().cli} use-managed ${repoName}`
3118
+ : `${product().cli} use-local ${repoName}`;
3119
+ console.log(chalk.gray(` revert: ${revert}\n`));
3120
+ }
3121
+
3122
+ private async cmdSetup(args: string[]): Promise<void> {
3123
+ const force = args.includes("--force") || args.includes("-f");
3124
+ const names = args.filter((a) => !a.startsWith("-"));
3125
+
3126
+ // Clone any managed repos that aren't on disk yet before reading manifests.
3127
+ this.cloneMissingManagedRepos();
3128
+
3129
+ const services = getWorkspaceServices();
3130
+ if (services.length === 0) {
3131
+ console.log(
3132
+ chalk.yellow(
3133
+ `\n No services in active workspace. Run: ${product().cli} register\n`,
3134
+ ),
3135
+ );
3136
+ return;
3137
+ }
3138
+
3139
+ let targets =
3140
+ names.length > 0
3141
+ ? services.filter(
3142
+ (s) => names.includes(s.address) || names.includes(s.repoName),
3143
+ )
3144
+ : services;
3145
+
3146
+ // Workspace-wide default: MANAGED clones only. `make setup` runs
3147
+ // codegen/dependency installs that dirty a checkout — fine for clones
3148
+ // den owns, uninvited in an engineer's own dev-managed repo. `--force`
3149
+ // (-f) includes dev-managed; naming a repo explicitly always works.
3150
+ if (names.length === 0 && !force) {
3151
+ const skipped = [
3152
+ ...new Set(
3153
+ targets
3154
+ .filter((s) => s.mode === "dev-managed")
3155
+ .map((s) => s.repoName),
3156
+ ),
3157
+ ];
3158
+ targets = targets.filter((s) => s.mode === "managed");
3159
+ if (skipped.length > 0) {
3160
+ console.log(
3161
+ chalk.gray(
3162
+ `\n Skipping dev-managed (your own checkouts): ${skipped.join(", ")}` +
3163
+ `\n Include them with \`${product().cli} setup --force\`, or name one explicitly.`,
3164
+ ),
3165
+ );
3166
+ }
3167
+ }
3168
+
3169
+ // One setup per SYSTEM: run against the bare repo (resolves to the
3170
+ // entrypoint), not once per subsystem — several manifests declare `setup`
3171
+ // on multiple services and each run re-dirties the clone with codegen.
3172
+ // An explicitly named "repo/subsystem" address is honored as-is.
3173
+ const explicitAddrs = new Set(
3174
+ names.filter(
3175
+ (n) => n.includes("/") && targets.some((s) => s.address === n),
3176
+ ),
3177
+ );
3178
+ const seenRepos = new Set<string>();
3179
+ const runList: Array<{ label: string; target: string; path: string }> = [];
3180
+ for (const svc of targets) {
3181
+ if (explicitAddrs.has(svc.address)) {
3182
+ runList.push({
3183
+ label: svc.address,
3184
+ target: svc.address,
3185
+ path: svc.repoPath,
3186
+ });
3187
+ } else if (!seenRepos.has(svc.repoName)) {
3188
+ seenRepos.add(svc.repoName);
3189
+ runList.push({
3190
+ label: svc.repoName,
3191
+ target: svc.repoName,
3192
+ path: svc.repoPath,
3193
+ });
3194
+ }
3195
+ }
3196
+
3197
+ for (const item of runList) {
3198
+ console.log(
3199
+ chalk.cyan(`\n ▶ Setup: ${chalk.bold(item.label)} (${item.path})`),
3200
+ );
3201
+ await this.runServicesCommand([item.target], "setup");
3202
+ }
3203
+ }
3204
+
3205
+ private async cmdWorkspace(args: string[]): Promise<void> {
3206
+ const sub = args[0];
3207
+ const file = readWorkspacesFile();
3208
+ const activeSet = new Set(file.active);
3209
+ const focus = file.focus ?? file.active[0];
3210
+
3211
+ switch (sub) {
3212
+ case "list": {
3213
+ const table = new Table({
3214
+ head: ["Workspace", "Active", "Focus", "Services", "Type"],
3215
+ style: { head: ["cyan"] },
3216
+ });
3217
+ for (const [name, cfg] of Object.entries(file.workspaces)) {
3218
+ table.push([
3219
+ chalk.bold(name),
3220
+ activeSet.has(name) ? chalk.green("✓") : "",
3221
+ name === focus ? chalk.cyan("●") : "",
3222
+ Object.keys(cfg.services).join(", ") || chalk.gray("none"),
3223
+ cfg.directory ? chalk.magenta("feature") : chalk.blue("default"),
3224
+ ]);
3225
+ }
3226
+ console.log(table.toString());
3227
+ break;
3228
+ }
3229
+ case "activate": {
3230
+ const name = args[1];
3231
+ if (!name) {
3232
+ console.log(
3233
+ chalk.red(`Usage: ${product().cli} workspace activate <name>`),
3234
+ );
3235
+ return;
3236
+ }
3237
+ activateWorkspace(name);
3238
+ console.log(
3239
+ chalk.green(`\n ✓ Activated workspace: ${chalk.bold(name)}\n`),
3240
+ );
3241
+ break;
3242
+ }
3243
+ case "deactivate": {
3244
+ const name = args[1];
3245
+ if (!name) {
3246
+ console.log(
3247
+ chalk.red(`Usage: ${product().cli} workspace deactivate <name>`),
3248
+ );
3249
+ return;
3250
+ }
3251
+ deactivateWorkspace(name);
3252
+ // Release AFTER the workspace has left active[] so shared services see
3253
+ // the post-deactivation owner set (design decision 5).
3254
+ const releases = await releaseWorkspaceDaemons(name, (line) =>
3255
+ console.log(chalk.gray(` ${line}`)),
3256
+ );
3257
+ console.log(
3258
+ chalk.green(`\n ✓ Deactivated workspace: ${chalk.bold(name)}`),
3259
+ );
3260
+ for (const release of releases) {
3261
+ switch (release.outcome) {
3262
+ case "detached":
3263
+ console.log(
3264
+ chalk.gray(
3265
+ ` ${release.address} — still running for [${release.remaining.join(", ")}]`,
3266
+ ),
3267
+ );
3268
+ break;
3269
+ case "stopped":
3270
+ console.log(chalk.gray(` ${release.address} — stopped`));
3271
+ break;
3272
+ case "stop-failed":
3273
+ console.log(
3274
+ chalk.yellow(
3275
+ ` ${release.address} — stop failed; run \`${product().cli} down --force ${release.address}\``,
3276
+ ),
3277
+ );
3278
+ break;
3279
+ default: {
3280
+ const _exhaustive: never = release.outcome;
3281
+ throw new Error(`unhandled release outcome: ${_exhaustive}`);
3282
+ }
3283
+ }
3284
+ }
3285
+ console.log("");
3286
+ break;
3287
+ }
3288
+ case "use": {
3289
+ const name = args[1];
3290
+ if (!name) {
3291
+ console.log(
3292
+ chalk.red(`Usage: ${product().cli} workspace use <name>`),
3293
+ );
3294
+ return;
3295
+ }
3296
+ setActiveWorkspace(name);
3297
+ console.log(
3298
+ chalk.green(
3299
+ `\n ✓ Activated and focused workspace: ${chalk.bold(name)}\n`,
3300
+ ),
3301
+ );
3302
+ break;
3303
+ }
3304
+ case "create": {
3305
+ const name =
3306
+ args[1] ??
3307
+ (await cliPrompts.promptForWorkspaceName(
3308
+ Object.keys(file.workspaces),
3309
+ ));
3310
+ if (file.workspaces[name]) {
3311
+ console.log(
3312
+ chalk.yellow(`\n Workspace "${name}" already exists.\n`),
3313
+ );
3314
+ return;
3315
+ }
3316
+ createWorkspace(name, { services: {} });
3317
+ console.log(
3318
+ chalk.green(`\n ✓ Created workspace: ${chalk.bold(name)}`),
3319
+ );
3320
+ console.log(
3321
+ chalk.gray(` Edit config/local/workspaces.yml to add services\n`),
3322
+ );
3323
+ break;
3324
+ }
3325
+ case "delete": {
3326
+ const name = args[1];
3327
+ if (!name) {
3328
+ console.log(
3329
+ chalk.red(`Usage: ${product().cli} workspace delete <name>`),
3330
+ );
3331
+ return;
3332
+ }
3333
+ const confirmed =
3334
+ args.includes("--yes") ||
3335
+ args.includes("-y") ||
3336
+ (await cliPrompts.confirm(
3337
+ `Delete workspace "${name}"? This will remove ${product().workspacesDir}/${name}/`,
3338
+ false,
3339
+ ));
3340
+ if (!confirmed) return;
3341
+ deleteWorkspaceDirectory(name);
3342
+ deleteWorkspaceConfig(name);
3343
+ console.log(chalk.green(`\n ✓ Deleted workspace: ${name}\n`));
3344
+ break;
3345
+ }
3346
+ case "code": {
3347
+ const name = args[1] ?? focus;
3348
+ const wsCfg = file.workspaces[name];
3349
+ if (!wsCfg) {
3350
+ console.log(chalk.red(` Workspace "${name}" not found\n`));
3351
+ return;
3352
+ }
3353
+ const editor = readUserConfig().editor ?? "";
3354
+ const vscodeLike = new Set(["vscode", "cursor"]);
3355
+ const jetbrains = new Set(["webstorm", "idea"]);
3356
+ const catalog = readCatalog();
3357
+
3358
+ if (vscodeLike.has(editor)) {
3359
+ const wsFilePath = generateVSCodeWorkspace(name, wsCfg, catalog);
3360
+ console.log(chalk.green(`\n ✓ ${wsFilePath}\n`));
3361
+ try {
3362
+ execSync(
3363
+ `${editor === "cursor" ? "cursor" : "code"} "${wsFilePath}"`,
3364
+ { stdio: "ignore" },
3365
+ );
3366
+ } catch {
3367
+ console.log(chalk.gray(` Run: code "${wsFilePath}"\n`));
3368
+ }
3369
+ } else if (jetbrains.has(editor)) {
3370
+ syncJetBrainsWorkspaceSymlinks(file);
3371
+ // ws.directory is stored relative to the den root (see
3372
+ // workspace-routes.ts) — resolve against that anchor, never cwd.
3373
+ const wsDir = wsCfg.directory
3374
+ ? nodePath.resolve(getDistRoot(), wsCfg.directory)
3375
+ : nodePath.join(DEN_WORKSPACES_DIR, name);
3376
+ console.log(chalk.green(`\n ✓ ${wsDir}\n`));
3377
+ try {
3378
+ execSync(`${editor} "${wsDir}"`, { stdio: "ignore" });
3379
+ } catch {
3380
+ console.log(chalk.gray(` Run: ${editor} "${wsDir}"\n`));
3381
+ }
3382
+ } else {
3383
+ console.log(
3384
+ chalk.yellow(
3385
+ `\n ⚠ No workspace-level open support for editor "${editor || "(none set)"}".\n` +
3386
+ ` Open individual repos from the graph or: ${product().cli} settings → set editor\n`,
3387
+ ),
3388
+ );
3389
+ }
3390
+ break;
3391
+ }
3392
+ default:
3393
+ console.log(
3394
+ chalk.gray(
3395
+ `Usage: ${product().cli} workspace <list|activate|deactivate|use|create|delete|code> [name]`,
3396
+ ),
3397
+ );
3398
+ }
3399
+ }
3400
+
3401
+ private async cmdSync(args: string[]): Promise<void> {
3402
+ const allServices = getWorkspaceServices();
3403
+ if (allServices.length === 0) {
3404
+ console.log(
3405
+ chalk.yellow("\n No services in active workspace to sync.\n"),
3406
+ );
3407
+ return;
3408
+ }
3409
+
3410
+ // Deduplicate by repoPath — one pull per unique checkout
3411
+ const seen = new Set<string>();
3412
+ const targets = allServices.filter((s) => {
3413
+ if (
3414
+ args.length > 0 &&
3415
+ !args.includes(s.address) &&
3416
+ !args.includes(s.repoName)
3417
+ )
3418
+ return false;
3419
+ if (seen.has(s.repoPath)) return false;
3420
+ seen.add(s.repoPath);
3421
+ return true;
3422
+ });
3423
+
3424
+ // Bootstrap: a managed repo that was never cloned can't be checked,
3425
+ // let alone pulled — and syncing it IS the intent. Clone + set it up first
3426
+ // (marker-aware: a previously failed setup is repaired too). After this,
3427
+ // the normal check below reports it up to date.
3428
+ const missing = [
3429
+ ...new Set(
3430
+ targets
3431
+ .filter((s) => s.mode === "managed" && !fs.existsSync(s.repoPath))
3432
+ .map((s) => s.repoName),
3433
+ ),
3434
+ ];
3435
+ if (missing.length > 0) {
3436
+ console.log(
3437
+ chalk.cyan(
3438
+ `\n Cloning ${missing.length} managed repo(s) not on disk yet: ${missing.join(", ")}`,
3439
+ ),
3440
+ );
3441
+ await this.ensureManagedClonesFor(missing);
3442
+ }
3443
+
3444
+ console.log();
3445
+ for (const svc of targets) {
3446
+ const spinner = ora(`Checking ${chalk.bold(svc.repoName)}...`).start();
3447
+ const behind = checkBehindCount(svc.repoPath);
3448
+ spinner.stop();
3449
+
3450
+ if (behind === -1) {
3451
+ console.log(
3452
+ chalk.gray(` ${svc.repoName}: unable to check (no upstream?)`),
3453
+ );
3454
+ continue;
3455
+ }
3456
+ if (behind === 0) {
3457
+ console.log(chalk.green(` ✓ ${svc.repoName}: up to date`));
3458
+ continue;
3459
+ }
3460
+
3461
+ const label =
3462
+ svc.mode === "dev-managed"
3463
+ ? chalk.blue("[dev-managed]")
3464
+ : chalk.magenta("[managed]");
3465
+ console.log(
3466
+ chalk.yellow(
3467
+ ` ${svc.repoName} ${label}: ${behind} commit${behind === 1 ? "" : "s"} behind`,
3468
+ ),
3469
+ );
3470
+
3471
+ if (svc.mode === "dev-managed") {
3472
+ console.log(chalk.gray(` Dev-managed — pull in your own checkout.`));
3473
+ continue;
3474
+ }
3475
+
3476
+ // Managed checkouts are den's to keep fresh — pull without
3477
+ // prompting (dev-managed repos above are never touched).
3478
+ const pullSpinner = ora(`Pulling ${svc.repoName}...`).start();
3479
+ try {
3480
+ pullRepo(svc.repoPath);
3481
+ pullSpinner.succeed(`Pulled ${svc.repoName}`);
3482
+ } catch (e) {
3483
+ pullSpinner.fail(
3484
+ `Failed to pull ${svc.repoName}: ${(e as Error).message}`,
3485
+ );
3486
+ }
3487
+ }
3488
+ console.log();
3489
+ }
3490
+
3491
+ // ── den validate ────────────────────────────────────────────────────────
3492
+
3493
+ private async cmdValidate(args: string[]): Promise<void> {
3494
+ const target = args[0] ?? getInvokeCwd();
3495
+ const fs = await import("fs");
3496
+ const nodePath = await import("path");
3497
+
3498
+ let input: string;
3499
+ if (target.startsWith("http://") || target.startsWith("https://")) {
3500
+ const { default: https } = await import("https");
3501
+ const { default: http } = await import("http");
3502
+ const fetcher = target.startsWith("https://") ? https : http;
3503
+ input = await new Promise<string>((resolve, reject) => {
3504
+ const req = fetcher.get(target, (res) => {
3505
+ if (res.statusCode && res.statusCode >= 400) {
3506
+ res.resume();
3507
+ reject(new Error(`HTTP ${res.statusCode} fetching ${target}`));
3508
+ return;
3509
+ }
3510
+ let data = "";
3511
+ res.on("data", (c: Buffer) => (data += c));
3512
+ res.on("end", () => resolve(data));
3513
+ res.on("error", reject);
3514
+ });
3515
+ // Connection-level failures (DNS/ECONNREFUSED) fire on the request, not
3516
+ // the response — without this listener they crash the process.
3517
+ req.on("error", reject);
3518
+ });
3519
+ } else {
3520
+ const isDir = fs.existsSync(target) && fs.lstatSync(target).isDirectory();
3521
+ // A repo folder: validate each layer that applies to it (its own or the
3522
+ // held manifest, then manifests/<repo>.override.yml, then the merge).
3523
+ if (isDir) {
3524
+ const layered = validateManifestLayers(nodePath.resolve(target));
3525
+ if (layered.layers.length > 0) {
3526
+ console.log();
3527
+ for (const l of layered.layers)
3528
+ printValidation(
3529
+ chalk.bold(l.file ?? "merged"),
3530
+ l.result,
3531
+ l.layer === "merged" ? "merged" : l.layer,
3532
+ );
3533
+ console.log();
3534
+ if (!layered.valid) process.exit(1);
3535
+ return;
3536
+ }
3537
+ }
3538
+ const filePath = isDir
3539
+ ? nodePath.join(target, `${product().manifestYml}`)
3540
+ : target;
3541
+
3542
+ if (!fs.existsSync(filePath)) {
3543
+ console.error(chalk.red(`\n ✗ File not found: ${filePath}\n`));
3544
+ process.exit(1);
3545
+ }
3546
+ input = fs.readFileSync(filePath, "utf-8") as string;
3547
+ }
3548
+
3549
+ const result = validateManifest(input);
3550
+ const label = chalk.bold(
3551
+ target.startsWith("http") ? target : nodePath.resolve(target),
3552
+ );
3553
+
3554
+ console.log();
3555
+ printValidation(label, result);
3556
+ console.log();
3557
+ if (!result.valid) process.exit(1);
3558
+ }
3559
+
3560
+ // ── den local-dev ───────────────────────────────────────────────────────
3561
+
3562
+ private async cmdLocalDev(arg?: string): Promise<void> {
3563
+ if (arg === "on" || arg === "off") {
3564
+ const enabled = arg === "on";
3565
+ mergeUserConfig({ local_dev: enabled });
3566
+ console.log(
3567
+ chalk.green(
3568
+ `\n ✓ Local dev experience ${enabled ? "enabled" : "disabled"}.\n`,
3569
+ ) +
3570
+ chalk.gray(
3571
+ ` Restart the web server for the change to take effect.\n`,
3572
+ ),
3573
+ );
3574
+ return;
3575
+ }
3576
+
3577
+ // Interactive toggle
3578
+ const current = readUserConfig().local_dev ?? true;
3579
+ console.log(
3580
+ `\n Local dev experience is currently: ${current ? chalk.green("enabled") : chalk.red("disabled")}`,
3581
+ );
3582
+ const answer = await cliPrompts.confirm(
3583
+ ` ${current ? "Disable" : "Enable"} it?`,
3584
+ !current,
3585
+ );
3586
+ if (answer !== current) {
3587
+ mergeUserConfig({ local_dev: answer });
3588
+ console.log(
3589
+ chalk.green(`\n ✓ Local dev ${answer ? "enabled" : "disabled"}.`) +
3590
+ chalk.gray(" Restart web server to apply.\n"),
3591
+ );
3592
+ } else {
3593
+ console.log(chalk.gray("\n No change.\n"));
3594
+ }
3595
+ }
3596
+
3597
+ /**
3598
+ * Display help
3599
+ */
3600
+ private displayHelp(): void {
3601
+ this.displayHelpBanner();
3602
+ console.log("Usage:");
3603
+ console.log(` ${product().cli} [command] [options]`);
3604
+ console.log("\nEnvironment commands:");
3605
+ console.log(
3606
+ " up [repo] [-x] [-n] Start the cwd (or named) repo's derived dependency closure",
3607
+ );
3608
+ console.log(
3609
+ " (default incl self · -x deps-only · -a all systems · -n dry-run · --plan · --manifest)",
3610
+ );
3611
+ console.log(
3612
+ " down [repo] Stop the derived closure cleanly (-a all systems · --manifest for legacy)",
3613
+ );
3614
+ console.log(
3615
+ " restart [service...] Restart services (-A <app> for a whole application)",
3616
+ );
3617
+ console.log(
3618
+ " status Show service health for active workspace (-A <app>)",
3619
+ );
3620
+ console.log(
3621
+ " apps Org > Application > Repo > Services (--json)",
3622
+ );
3623
+ console.log(
3624
+ " logs [repo] Tail a repo's local logs (offers to start it if down)",
3625
+ );
3626
+ console.log(
3627
+ " graph [repo] Derive & show the system dependency graph (--all, --diff, --format=)",
3628
+ );
3629
+ console.log(" services List all registered services");
3630
+ console.log(
3631
+ " use-local [repo] Use your local checkout (dev-managed) — defaults to the cwd repo",
3632
+ );
3633
+ console.log(
3634
+ ` use-managed [repo] Switch back to a managed clone — defaults to the cwd repo`,
3635
+ );
3636
+ console.log(
3637
+ " register [path] Register a repo in the service catalog",
3638
+ );
3639
+ console.log();
3640
+ console.log(" setup [service...] Run make setup for services");
3641
+ console.log(" sync [service...] Check for updates and offer to pull");
3642
+ console.log(
3643
+ " create <name> [dir] Scaffold a new distribution of your own (--title, --cli, --env-prefix)",
3644
+ );
3645
+ console.log(
3646
+ " mcp [--port <n>] MCP server (stdio) for agents, over the running web server",
3647
+ );
3648
+ console.log();
3649
+ console.log(" workspace list List workspaces");
3650
+ console.log(
3651
+ " workspace list List workspaces (active set + focus)",
3652
+ );
3653
+ console.log(" workspace activate <n> Add workspace to the active set");
3654
+ console.log(
3655
+ " workspace deactivate <n> Remove workspace from the active set",
3656
+ );
3657
+ console.log(" workspace use <n> Activate and focus a workspace");
3658
+ console.log(" workspace create [n] Create a new workspace");
3659
+ console.log(" workspace delete <n> Delete a workspace");
3660
+ console.log(
3661
+ " workspace code [n] Open workspace in VS Code (generates .code-workspace)",
3662
+ );
3663
+ console.log("\nTest commands:");
3664
+ console.log(
3665
+ " menu Open the interactive menu (pillar/module/env picker)",
3666
+ );
3667
+ console.log(
3668
+ " run Run tests (interactive or with options)",
3669
+ );
3670
+ console.log(" view-results View test results");
3671
+ console.log(
3672
+ ` web [up|down|restart] Start, stop, or restart ${product().title} web`,
3673
+ );
3674
+ console.log("\nOptions:");
3675
+ console.log(" --help, -h Show this help message");
3676
+ console.log(" --version, -v Show version");
3677
+ console.log(
3678
+ " -w, --workspace <name> Use a workspace for this command only",
3679
+ );
3680
+ console.log(
3681
+ " -t, --tabs Run up/restart commands in terminal tabs",
3682
+ );
3683
+ console.log(" --no-tabs Force daemon mode for up/restart");
3684
+ console.log(
3685
+ " --skip-secrets Skip local secrets preflight for up/restart/logs",
3686
+ );
3687
+ console.log("\nExamples:");
3688
+ console.log(` ${product().cli} --help`);
3689
+ console.log(` ${product().cli} web`);
3690
+ console.log(` ${product().cli} web restart --no-open`);
3691
+ console.log(` ${product().cli} web down`);
3692
+ console.log(` ${product().cli} web --port 8080`);
3693
+ console.log(` ${product().cli} up`);
3694
+ console.log(` ${product().cli} up --skip-secrets`);
3695
+ console.log(` ${product().cli} up -w feat-streaming shop/api`);
3696
+ console.log(` ${product().cli} up --tabs shop/api`);
3697
+ console.log(` ${product().cli} up shop/api tasks-runner`);
3698
+ console.log(` ${product().cli} status`);
3699
+ console.log(` ${product().cli} workspace use feat-streaming`);
3700
+ console.log();
3701
+ }
3702
+
3703
+ /**
3704
+ * Display version
3705
+ */
3706
+ private displayVersion(): void {
3707
+ // Den's own version: the engine's package.json, not the distribution's.
3708
+ const root = getEngineRoot();
3709
+ let packageVersion = "?";
3710
+ try {
3711
+ const pkg = JSON.parse(
3712
+ fs.readFileSync(nodePath.join(root, "package.json"), "utf-8"),
3713
+ ) as { version?: unknown };
3714
+ if (typeof pkg.version === "string") packageVersion = pkg.version;
3715
+ } catch {
3716
+ /* package version unavailable */
3717
+ }
3718
+
3719
+ let gitVersion = `v${packageVersion}`;
3720
+ try {
3721
+ // Only in a Den checkout: an installed package has no .git of its own,
3722
+ // and git would describe the distribution's repo instead.
3723
+ if (!fs.existsSync(nodePath.join(root, ".git"))) throw new Error();
3724
+ gitVersion = execSync("git describe --tags --dirty --always", {
3725
+ cwd: root,
3726
+ stdio: ["ignore", "pipe", "ignore"],
3727
+ encoding: "utf-8",
3728
+ }).trim();
3729
+ } catch {
3730
+ /* git metadata unavailable */
3731
+ }
3732
+
3733
+ console.log(`${product().title} v${packageVersion} (${gitVersion})`);
3734
+ }
3735
+ }
3736
+
3737
+ // Main entry point
3738
+ const cli = new DenCLI();
3739
+ cli.run().catch((error) => {
3740
+ console.error(chalk.red(`\n❌ Fatal error: ${error.message}`));
3741
+ process.exit(1);
3742
+ });