@monster-orange/den 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +61 -0
  5. package/docs/distributions.md +256 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +99 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +406 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +108 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +729 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/applications.ts +138 -0
  91. package/src/shared/auth/api-token.ts +416 -0
  92. package/src/shared/auth/auth0-helper.ts +284 -0
  93. package/src/shared/auth/headless-login.ts +265 -0
  94. package/src/shared/auth/jwt-helper.ts +186 -0
  95. package/src/shared/auth/sudo-vault.ts +69 -0
  96. package/src/shared/auth/test-accounts.ts +98 -0
  97. package/src/shared/auth/token-injection.ts +184 -0
  98. package/src/shared/auth/token-providers/auth0.ts +80 -0
  99. package/src/shared/auth/token-providers/index.ts +182 -0
  100. package/src/shared/auth/token-refresh.ts +273 -0
  101. package/src/shared/banner.ts +15 -0
  102. package/src/shared/brand.ts +79 -0
  103. package/src/shared/cli-tabs.ts +131 -0
  104. package/src/shared/constants.ts +128 -0
  105. package/src/shared/den-config.ts +566 -0
  106. package/src/shared/dev-metrics/emit.ts +354 -0
  107. package/src/shared/edge/config.ts +229 -0
  108. package/src/shared/edge/render.ts +221 -0
  109. package/src/shared/edge/runtime.ts +268 -0
  110. package/src/shared/edge/types.ts +62 -0
  111. package/src/shared/engine.ts +67 -0
  112. package/src/shared/environment/aws-profiles.ts +49 -0
  113. package/src/shared/environment/env-selector.ts +161 -0
  114. package/src/shared/environment/playwright-env.ts +82 -0
  115. package/src/shared/environment-manager/catalog.ts +77 -0
  116. package/src/shared/environment-manager/clone-guard.ts +163 -0
  117. package/src/shared/environment-manager/dag.ts +286 -0
  118. package/src/shared/environment-manager/default-workspace.ts +182 -0
  119. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  120. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  121. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  122. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  123. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  124. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  125. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  126. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  127. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  128. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  129. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  130. package/src/shared/environment-manager/derivation/text.ts +40 -0
  131. package/src/shared/environment-manager/derivation/types.ts +69 -0
  132. package/src/shared/environment-manager/health.ts +589 -0
  133. package/src/shared/environment-manager/index.ts +6 -0
  134. package/src/shared/environment-manager/load-applications.ts +78 -0
  135. package/src/shared/environment-manager/log-tail.ts +212 -0
  136. package/src/shared/environment-manager/make-targets.ts +78 -0
  137. package/src/shared/environment-manager/opctl.ts +334 -0
  138. package/src/shared/environment-manager/readiness.ts +93 -0
  139. package/src/shared/environment-manager/register-local.ts +72 -0
  140. package/src/shared/environment-manager/secrets-check.ts +260 -0
  141. package/src/shared/environment-manager/service-env.ts +110 -0
  142. package/src/shared/environment-manager/service-runner.ts +1588 -0
  143. package/src/shared/environment-manager/up-plan.ts +302 -0
  144. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  145. package/src/shared/environment-manager/workspaces.ts +860 -0
  146. package/src/shared/manifest/deps.ts +27 -0
  147. package/src/shared/manifest/migrate-manifest.ts +101 -0
  148. package/src/shared/manifest/read-manifest.ts +160 -0
  149. package/src/shared/manifest/schema.ts +299 -0
  150. package/src/shared/manifest/service-links.ts +93 -0
  151. package/src/shared/manifest/validate-manifest.ts +140 -0
  152. package/src/shared/manifest/workspace-manifests.ts +92 -0
  153. package/src/shared/open-url.ts +122 -0
  154. package/src/shared/org.ts +44 -0
  155. package/src/shared/paths.ts +178 -0
  156. package/src/shared/perf/optional-fields.ts +105 -0
  157. package/src/shared/perf/report-normalizer.ts +404 -0
  158. package/src/shared/perf/report-paths.ts +19 -0
  159. package/src/shared/perf/report-store.ts +110 -0
  160. package/src/shared/perf/report-types.ts +70 -0
  161. package/src/shared/perf/test-paths.ts +25 -0
  162. package/src/shared/process/log-rotation.ts +147 -0
  163. package/src/shared/process/spawn-streaming.ts +229 -0
  164. package/src/shared/product.ts +92 -0
  165. package/src/shared/profile.ts +141 -0
  166. package/src/shared/repo-kind.ts +32 -0
  167. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  168. package/src/shared/reporting/slack-reporter.ts +340 -0
  169. package/src/shared/results/result-reader.ts +266 -0
  170. package/src/shared/results/result-writer.ts +168 -0
  171. package/src/shared/secrets/doppler.ts +126 -0
  172. package/src/shared/secrets/env.ts +48 -0
  173. package/src/shared/secrets/index.ts +197 -0
  174. package/src/shared/secrets/keys.ts +33 -0
  175. package/src/shared/secrets/onepassword.ts +75 -0
  176. package/src/shared/secrets/types.ts +52 -0
  177. package/src/shared/service-url.ts +119 -0
  178. package/src/shared/smoke/branch-resolver.ts +141 -0
  179. package/src/shared/smoke/git-remote.ts +70 -0
  180. package/src/shared/smoke/repo-cloner.ts +151 -0
  181. package/src/shared/stacks/config.ts +137 -0
  182. package/src/shared/stacks/manifest-commands.ts +88 -0
  183. package/src/shared/stacks/render.ts +253 -0
  184. package/src/shared/stacks/runtime.ts +334 -0
  185. package/src/shared/stacks/types.ts +53 -0
  186. package/src/shared/terminal/tabs.ts +212 -0
  187. package/src/shared/test-discovery/module-scanner.ts +569 -0
  188. package/src/shared/types/index.ts +678 -0
  189. package/src/shared/ui-marker.ts +19 -0
  190. package/src/ui/cli/applications-command.ts +116 -0
  191. package/src/ui/cli/config-command.ts +68 -0
  192. package/src/ui/cli/create-main.ts +9 -0
  193. package/src/ui/cli/create.ts +360 -0
  194. package/src/ui/cli/edge-command.ts +91 -0
  195. package/src/ui/cli/flag-parser.ts +246 -0
  196. package/src/ui/cli/graph-command.ts +524 -0
  197. package/src/ui/cli/health-display.ts +63 -0
  198. package/src/ui/cli/index.ts +3656 -0
  199. package/src/ui/cli/init.ts +855 -0
  200. package/src/ui/cli/invocation-target.ts +175 -0
  201. package/src/ui/cli/logs-command.ts +104 -0
  202. package/src/ui/cli/module-selection.ts +31 -0
  203. package/src/ui/cli/prompts.ts +767 -0
  204. package/src/ui/cli/results-viewer.ts +436 -0
  205. package/src/ui/cli/secrets-preflight.ts +91 -0
  206. package/src/ui/cli/stack-command.ts +144 -0
  207. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  208. package/src/ui/cli/test-runner.ts +1312 -0
  209. package/src/ui/cli/web-command.ts +1073 -0
  210. package/src/ui/server/changelog-parser.ts +94 -0
  211. package/src/ui/server/daemon-reaper.ts +129 -0
  212. package/src/ui/server/editor-manager.ts +301 -0
  213. package/src/ui/server/github-service.ts +223 -0
  214. package/src/ui/server/http-helpers.ts +98 -0
  215. package/src/ui/server/index.ts +3236 -0
  216. package/src/ui/server/log-history.ts +452 -0
  217. package/src/ui/server/perf-report.ts +42 -0
  218. package/src/ui/server/public/environments.html +494 -0
  219. package/src/ui/server/public/favicon.ico +0 -0
  220. package/src/ui/server/public/header.js +1371 -0
  221. package/src/ui/server/public/index.html +2595 -0
  222. package/src/ui/server/public/local-dev.html +7405 -0
  223. package/src/ui/server/public/logs.html +1489 -0
  224. package/src/ui/server/public/perf-report.js +359 -0
  225. package/src/ui/server/public/release.html +2111 -0
  226. package/src/ui/server/public/services.html +1098 -0
  227. package/src/ui/server/public/workspace-picker.js +69 -0
  228. package/src/ui/server/release-deploy-session.ts +246 -0
  229. package/src/ui/server/release-service.ts +1125 -0
  230. package/src/ui/server/sudo-store.ts +111 -0
  231. package/src/ui/server/workspace-routes.ts +2154 -0
@@ -0,0 +1,524 @@
1
+ /**
2
+ * `den graph` — derived system dependency analysis.
3
+ *
4
+ * Builds the dependency graph by deriving real signals from each repo (SDK
5
+ * imports, service URLs, IaC stack refs, EventBridge rules) rather than trusting
6
+ * each repo's `.den.yml` `depends_on`, and renders / diffs / annotates it.
7
+ *
8
+ * Usage:
9
+ * den graph [repo] graph for repo's dependency closure (defaults to cwd repo, else --all)
10
+ * den graph --all the full known-systems graph
11
+ * den graph --format=json|dot|text
12
+ * den graph --source=derived|declared|union
13
+ * den graph --kind=all|hard|infra|http|event
14
+ * den graph --diff compare derived deps vs .den.yml depends_on
15
+ * den graph --refresh ignore the per-repo cache
16
+ */
17
+
18
+ import chalk from "chalk";
19
+ import {
20
+ parseAddress,
21
+ getActiveWorkspace,
22
+ noAutostartRepos,
23
+ } from "../../shared/environment-manager/workspaces.js";
24
+ import { readCatalog } from "../../shared/environment-manager/catalog.js";
25
+ import { readManifest } from "../../shared/manifest/read-manifest.js";
26
+ import type { AliasResolver } from "../../shared/environment-manager/derivation/alias.js";
27
+ import { loadAliasResolver } from "../../shared/environment-manager/derivation/load-alias.js";
28
+ import {
29
+ deriveGraph,
30
+ resolveRepoPath,
31
+ repoSource,
32
+ buildContext,
33
+ } from "../../shared/environment-manager/derivation/derive-graph.js";
34
+ import { listMakeTargets } from "../../shared/environment-manager/make-targets.js";
35
+ import {
36
+ orderForUp,
37
+ orderForDown,
38
+ } from "../../shared/environment-manager/derivation/derived-up.js";
39
+ import type {
40
+ DerivedEdge,
41
+ DerivedGraph,
42
+ } from "../../shared/environment-manager/derivation/types.js";
43
+ import { findInvocationRepoRoot, resolveCwdRepo } from "./invocation-target.js";
44
+ import { getDistRoot } from "../../shared/paths.js";
45
+ import * as path from "path";
46
+ import * as os from "os";
47
+
48
+ import { product } from "../../shared/product.js";
49
+ type Source = "derived" | "declared" | "union";
50
+ type KindFilter = "all" | "hard" | "infra" | "http" | "event";
51
+ type Format = "text" | "json" | "dot";
52
+ type Order = "up" | "down";
53
+
54
+ export interface GraphFlags {
55
+ repo?: string;
56
+ all: boolean;
57
+ source: Source;
58
+ kind: KindFilter;
59
+ format: Format;
60
+ diff: boolean;
61
+ refresh: boolean;
62
+ order: Order;
63
+ }
64
+
65
+ const REQUIRED_TARGETS = ["setup", "up", "down"];
66
+
67
+ // ── pure helpers (unit-tested) ───────────────────────────────────────────────
68
+
69
+ export function edgeMatchesKind(edge: DerivedEdge, kind: KindFilter): boolean {
70
+ switch (kind) {
71
+ case "all":
72
+ return true;
73
+ case "hard":
74
+ return edge.blocking === "hard";
75
+ case "infra":
76
+ return edge.kind === "infra";
77
+ case "http":
78
+ return edge.kind === "http" || edge.kind === "jwks";
79
+ case "event":
80
+ return edge.kind === "event";
81
+ }
82
+ }
83
+
84
+ /** Cross-service `depends_on` edges declared in each node's `.den.yml`. */
85
+ export function collectDeclaredEdges(
86
+ nodes: string[],
87
+ alias: AliasResolver,
88
+ ): DerivedEdge[] {
89
+ const catalog = readCatalog();
90
+ const out: DerivedEdge[] = [];
91
+ for (const node of nodes) {
92
+ const repoPath = resolveRepoPath(node);
93
+ if (!repoPath) continue;
94
+ const manifest = readManifest(repoPath);
95
+ for (const svc of Object.values(manifest?.services ?? {})) {
96
+ for (const dep of svc.depends_on ?? []) {
97
+ const service = typeof dep === "string" ? dep : dep.service;
98
+ const wait = typeof dep === "string" ? false : dep.wait === true;
99
+ const repoName = parseAddress(service, catalog).repoName;
100
+ const to = alias.resolve(repoName);
101
+ if (!to || to === node || !nodes.includes(to)) continue;
102
+ out.push({
103
+ from: node,
104
+ to,
105
+ kind: "http",
106
+ blocking: wait ? "hard" : "soft",
107
+ signal: "declared:depends_on",
108
+ evidence: `${product().manifestYml} depends_on: ${service}`,
109
+ confidence: "high",
110
+ });
111
+ }
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
117
+ const edgeKey = (e: DerivedEdge): string => `${e.from}->${e.to}`;
118
+
119
+ export interface GraphDiff {
120
+ repo: string;
121
+ /** Derived (real) but not declared in `.den.yml`. */
122
+ undeclared: string[];
123
+ /** Declared in `.den.yml` but no derived signal found. */
124
+ stale: string[];
125
+ }
126
+
127
+ export function diffEdges(
128
+ derived: DerivedEdge[],
129
+ declared: DerivedEdge[],
130
+ nodes: string[],
131
+ ): GraphDiff[] {
132
+ // Compare on synchronous edges only (events are never declared as depends_on).
133
+ const sync = (e: DerivedEdge) => e.kind !== "event";
134
+ const derivedByRepo = new Map<string, Set<string>>();
135
+ const declaredByRepo = new Map<string, Set<string>>();
136
+ for (const e of derived.filter(sync))
137
+ (
138
+ derivedByRepo.get(e.from) ??
139
+ derivedByRepo.set(e.from, new Set()).get(e.from)!
140
+ ).add(e.to);
141
+ for (const e of declared)
142
+ (
143
+ declaredByRepo.get(e.from) ??
144
+ declaredByRepo.set(e.from, new Set()).get(e.from)!
145
+ ).add(e.to);
146
+
147
+ const out: GraphDiff[] = [];
148
+ for (const repo of nodes) {
149
+ const d = derivedByRepo.get(repo) ?? new Set();
150
+ const c = declaredByRepo.get(repo) ?? new Set();
151
+ const undeclared = [...d].filter((t) => !c.has(t)).sort();
152
+ const stale = [...c].filter((t) => !d.has(t)).sort();
153
+ if (undeclared.length || stale.length)
154
+ out.push({ repo, undeclared, stale });
155
+ }
156
+ return out;
157
+ }
158
+
159
+ const ARROW: Record<DerivedEdge["kind"], string> = {
160
+ infra: "→",
161
+ http: "→",
162
+ jwks: "→",
163
+ event: "⇢",
164
+ };
165
+
166
+ export function renderText(
167
+ graph: DerivedGraph,
168
+ opts: {
169
+ showEvidence?: boolean;
170
+ annotations?: Map<string, string>;
171
+ /** Node for the repo the command was run from — marked with a leading `*`. */
172
+ currentRepo?: string;
173
+ } = {},
174
+ ): string {
175
+ const showEvidence = opts.showEvidence ?? true;
176
+ const byFrom = new Map<string, DerivedEdge[]>();
177
+ for (const e of graph.edges)
178
+ (byFrom.get(e.from) ?? byFrom.set(e.from, []).get(e.from)!).push(e);
179
+
180
+ // Pad node names to the longest so the annotations (den managed / sibling
181
+ // repo …) line up in a single column. Pad the PLAIN name then colorize, so the
182
+ // ANSI codes don't throw off the width.
183
+ const nameWidth = graph.nodes.reduce((m, n) => Math.max(m, n.length), 0);
184
+ // Pad the `[kind/blocking]` tag to the widest so the evidence ("reason")
185
+ // column lines up across all bullets.
186
+ const tagText = (e: DerivedEdge): string =>
187
+ e.kind === "event" ? "[event]" : `[${e.kind}/${e.blocking}]`;
188
+ const tagWidth = graph.edges.reduce(
189
+ (m, e) => Math.max(m, tagText(e).length),
190
+ 0,
191
+ );
192
+
193
+ const lines: string[] = [];
194
+ for (const node of graph.nodes) {
195
+ const ann = opts.annotations?.get(node);
196
+ // System (node) rows: auto-start-off ones in yellow, the rest in light
197
+ // green — both bold so they stand out from the plain-white edge bullets.
198
+ const isOff = !!ann && ann.includes("auto-start off");
199
+ const paint = isOff ? chalk.yellow : chalk.greenBright;
200
+ const styledAnn = ann ? " " + paint(ann) : "";
201
+ // The repo the command was run from gets a leading `▸`; others a 2-space
202
+ // gutter so node names stay aligned. A single-width glyph (not an emoji,
203
+ // which is double-width + terminal-dependent) keeps the column stable.
204
+ const gutter =
205
+ node === opts.currentRepo ? chalk.bold.yellow.bgBlue("▸ ") : " ";
206
+ const pad = " ".repeat(nameWidth - node.length);
207
+ lines.push(`${gutter}${paint.bold(node)}${pad}${styledAnn}`);
208
+ for (const e of byFrom.get(node) ?? []) {
209
+ const tagPlain = tagText(e);
210
+ const tagColored =
211
+ e.kind === "event"
212
+ ? chalk.magenta(tagPlain)
213
+ : e.blocking === "hard"
214
+ ? chalk.red(tagPlain)
215
+ : chalk.white(tagPlain);
216
+ const tagPad = " ".repeat(tagWidth - tagPlain.length);
217
+ const ev = showEvidence ? ` ${e.evidence}` : "";
218
+ // Bullet rows use plain white (not dim/gray) so they stay readable — they
219
+ // recede from the bold colored system headers by weight, not by dimming.
220
+ // Tag is padded (plain, then colorized) so the evidence column aligns.
221
+ lines.push(
222
+ chalk.white(` ${ARROW[e.kind]} ${e.to.padEnd(18)} `) +
223
+ tagColored +
224
+ tagPad +
225
+ chalk.white(ev),
226
+ );
227
+ }
228
+ }
229
+ return lines.join("\n");
230
+ }
231
+
232
+ export function renderDot(graph: DerivedGraph): string {
233
+ const lines = [`digraph ${product().cli} {`, " rankdir=LR;"];
234
+ for (const node of graph.nodes) lines.push(` "${node}";`);
235
+ for (const e of graph.edges) {
236
+ const style = e.kind === "event" ? "dashed" : "solid";
237
+ const color = e.blocking === "hard" ? "red" : "black";
238
+ lines.push(
239
+ ` "${e.from}" -> "${e.to}" [style=${style}, color=${color}, label="${e.kind}"];`,
240
+ );
241
+ }
242
+ lines.push("}");
243
+ return lines.join("\n");
244
+ }
245
+
246
+ export function renderDiff(diffs: GraphDiff[]): string {
247
+ if (diffs.length === 0) {
248
+ return `✓ Derived graph matches declared ${product().manifestYml} depends_on.`;
249
+ }
250
+ const lines: string[] = [];
251
+ for (const d of diffs) {
252
+ lines.push(d.repo);
253
+ for (const t of d.undeclared)
254
+ lines.push(` + ${t} (derived, not declared)`);
255
+ for (const t of d.stale)
256
+ lines.push(` - ${t} (declared, no derived signal)`);
257
+ }
258
+ return lines.join("\n");
259
+ }
260
+
261
+ // ── flag parsing ─────────────────────────────────────────────────────────────
262
+
263
+ const SOURCES: readonly Source[] = ["derived", "declared", "union"];
264
+ const KINDS: readonly KindFilter[] = ["all", "hard", "infra", "http", "event"];
265
+ const FORMATS: readonly Format[] = ["text", "json", "dot"];
266
+
267
+ /** Value of `--<flag>=<value>` validated against its allowed set — a typo must
268
+ * error out loudly, not silently degrade to the default rendering. */
269
+ function parseChoice<T extends string>(
270
+ flag: string,
271
+ value: string,
272
+ allowed: readonly T[],
273
+ ): T {
274
+ if (!(allowed as readonly string[]).includes(value)) {
275
+ throw new Error(
276
+ `Invalid value for ${flag}: "${value}" (valid: ${allowed.join(" | ")})`,
277
+ );
278
+ }
279
+ return value as T;
280
+ }
281
+
282
+ export function parseGraphFlags(args: string[]): GraphFlags {
283
+ const flags: GraphFlags = {
284
+ all: false,
285
+ source: "derived",
286
+ kind: "all",
287
+ format: "text",
288
+ diff: false,
289
+ refresh: false,
290
+ order: "up",
291
+ };
292
+ for (const arg of args) {
293
+ if (arg === "--all") flags.all = true;
294
+ else if (arg === "--diff") flags.diff = true;
295
+ else if (arg === "--refresh" || arg === "--no-cache") flags.refresh = true;
296
+ else if (arg === "-u" || arg === "--up") flags.order = "up";
297
+ else if (arg === "-d" || arg === "--down") flags.order = "down";
298
+ else if (arg.startsWith("--source="))
299
+ flags.source = parseChoice("--source", arg.slice(9), SOURCES);
300
+ else if (arg.startsWith("--kind="))
301
+ flags.kind = parseChoice("--kind", arg.slice(7), KINDS);
302
+ else if (arg.startsWith("--format="))
303
+ flags.format = parseChoice("--format", arg.slice(9), FORMATS);
304
+ else if (!arg.startsWith("-")) flags.repo = arg;
305
+ }
306
+ return flags;
307
+ }
308
+
309
+ /** Resolve the seed repo from an explicit arg, else the cwd repo. */
310
+ function resolveSeed(explicit: string | undefined): string | undefined {
311
+ if (explicit) return explicit;
312
+ const root = findInvocationRepoRoot();
313
+ return root ? path.basename(root) : undefined;
314
+ }
315
+
316
+ /**
317
+ * The connected component containing `seed`, traversed UNDIRECTED over ALL edge
318
+ * kinds (including events). Unlike the startup closure (`den up`, sync + outgoing
319
+ * only), this surfaces the full neighborhood — event producers/consumers like
320
+ * control-plane and jobs-runtime included — which is what you want when reading
321
+ * the graph.
322
+ */
323
+ export function connectedComponent(
324
+ graph: DerivedGraph,
325
+ seed: string,
326
+ ): DerivedGraph {
327
+ const adj = new Map<string, Set<string>>();
328
+ const link = (a: string, b: string) =>
329
+ (adj.get(a) ?? adj.set(a, new Set()).get(a)!).add(b);
330
+ for (const e of graph.edges) {
331
+ link(e.from, e.to);
332
+ link(e.to, e.from);
333
+ }
334
+ const seen = new Set([seed]);
335
+ const queue = [seed];
336
+ while (queue.length > 0) {
337
+ const cur = queue.shift()!;
338
+ for (const n of adj.get(cur) ?? []) {
339
+ if (!seen.has(n)) {
340
+ seen.add(n);
341
+ queue.push(n);
342
+ }
343
+ }
344
+ }
345
+ return {
346
+ nodes: graph.nodes.filter((n) => seen.has(n)),
347
+ edges: graph.edges.filter((e) => seen.has(e.from) && seen.has(e.to)),
348
+ };
349
+ }
350
+
351
+ /** Per-node "start: den|sibling (path)" annotations showing the boot location. */
352
+ export function bootAnnotations(nodes: string[]): Map<string, string> {
353
+ const distRoot = getDistRoot();
354
+ const home = os.homedir();
355
+ // Systems with auto-start toggled OFF in the active workspace — workspace-wide
356
+ // up won't start these; toggle in the web ⋮ menu or start explicitly.
357
+ const ws = getActiveWorkspace();
358
+ const noAutostart = ws
359
+ ? noAutostartRepos(ws.config, readCatalog())
360
+ : new Set<string>();
361
+ // Build the derive context once and reuse it for every node — repoSource()
362
+ // otherwise rebuilds it per call, re-reading/parsing den.yml + catalog +
363
+ // workspaces from disk N times for an N-node graph.
364
+ const ctx = buildContext();
365
+ const ann = new Map<string, string>();
366
+ for (const node of nodes) {
367
+ const opt = noAutostart.has(node) ? " ⏸ auto-start off" : "";
368
+ const src = repoSource(node, ctx);
369
+ if (!src) {
370
+ ann.set(node, `(not found on disk)${opt}`);
371
+ continue;
372
+ }
373
+ // Managed clones live under the den repo → home-relative (~/…); sibling
374
+ // repos → relative to the den dir (../…). Full path otherwise.
375
+ const rel = src.managed
376
+ ? src.path.startsWith(home + path.sep)
377
+ ? `~${src.path.slice(home.length)}`
378
+ : src.path
379
+ : path.relative(distRoot, src.path) || src.path;
380
+ // A managed repo may be configured but not cloned yet — flag it so the
381
+ // annotation reflects the configured mode, not a silent sibling fallback.
382
+ const note = !src.exists
383
+ ? src.managed
384
+ ? " — not cloned"
385
+ : " — missing"
386
+ : "";
387
+ ann.set(
388
+ node,
389
+ `${src.managed ? `${product().cli} managed` : "sibling repo"} (${rel}${note})${opt}`,
390
+ );
391
+ }
392
+ return ann;
393
+ }
394
+
395
+ // ── command ──────────────────────────────────────────────────────────────────
396
+
397
+ export async function cmdGraph(args: string[]): Promise<void> {
398
+ const flags = parseGraphFlags(args);
399
+ const alias = loadAliasResolver();
400
+
401
+ const seed = flags.all ? undefined : resolveSeed(flags.repo);
402
+ const scoped = seed !== undefined && alias.knownRepos.has(seed);
403
+
404
+ // Always derive the full graph; for a seed, narrow to its connected component
405
+ // (full neighborhood, both directions, incl. events) — NOT the strict startup
406
+ // closure that `den up` uses.
407
+ const full = deriveGraph({ scope: "all", refresh: flags.refresh });
408
+
409
+ // The repo the command is run from (independent of --all / explicit arg) —
410
+ // marked with a leading `*`. Identified by repo IDENTITY (manifest name /
411
+ // folder name), so it resolves from a sibling checkout or a managed clone.
412
+ const cwd = resolveCwdRepo(alias);
413
+ const currentRepo = cwd?.repo;
414
+ const graph = scoped ? connectedComponent(full, seed) : full;
415
+
416
+ if (flags.diff) {
417
+ const declared = collectDeclaredEdges(graph.nodes, alias);
418
+ const diffs = diffEdges(graph.edges, declared, graph.nodes);
419
+ console.log(renderDiff(diffs));
420
+ return;
421
+ }
422
+
423
+ // Choose the edge set per --source, then filter by --kind.
424
+ let edges = graph.edges;
425
+ if (flags.source !== "derived") {
426
+ const declared = collectDeclaredEdges(graph.nodes, alias);
427
+ edges =
428
+ flags.source === "declared"
429
+ ? declared
430
+ : dedupeUnion(graph.edges, declared);
431
+ }
432
+ // Render nodes in the order `den up` (deps-first) or `down` (reverse) runs.
433
+ const orderedNodes =
434
+ flags.order === "down" ? orderForDown(graph) : orderForUp(graph);
435
+ const filtered: DerivedGraph = {
436
+ nodes: orderedNodes,
437
+ edges: edges.filter((e) => edgeMatchesKind(e, flags.kind)),
438
+ };
439
+
440
+ if (flags.format === "json") {
441
+ console.log(JSON.stringify(filtered, null, 2));
442
+ return;
443
+ }
444
+ if (flags.format === "dot") {
445
+ console.log(renderDot(filtered));
446
+ return;
447
+ }
448
+
449
+ const scopeLabel = scoped ? `neighborhood: ${seed}` : "all";
450
+ const cwdNote =
451
+ currentRepo && filtered.nodes.includes(currentRepo)
452
+ ? ` · ${chalk.bold.yellow.bgBlue("▸")} = current repo`
453
+ : "";
454
+ console.log(
455
+ `${product().cli} dependency graph (source: ${flags.source} · ${scopeLabel} · order: ${flags.order})${cwdNote}\n`,
456
+ );
457
+ console.log(
458
+ renderText(filtered, {
459
+ annotations: bootAnnotations(graph.nodes),
460
+ currentRepo,
461
+ }),
462
+ );
463
+ console.log("\n" + renderTargetWarnings(graph.nodes));
464
+
465
+ // Non-intrusive hint (stderr, pipe-safe): you're in a sibling checkout of a
466
+ // managed repo, so den boots a different clone than the code you're
467
+ // editing. Suggest switching — don't prompt or mutate from a read-only command.
468
+ if (currentRepo && cwd) {
469
+ const src = repoSource(currentRepo);
470
+ if (src?.managed && path.resolve(src.path) !== path.resolve(cwd.root)) {
471
+ console.error(
472
+ chalk.gray(
473
+ `\nℹ ${chalk.bold(currentRepo)} is managed (boots from ${src.path}),\n` +
474
+ ` but you're in a sibling checkout (${cwd.root}).\n` +
475
+ ` Run ${chalk.cyan(`${product().cli} use-local`)} to point ${product().cli} at this checkout` +
476
+ ` (or ${chalk.cyan(`${product().cli} use-managed`)} to keep the managed clone).`,
477
+ ),
478
+ );
479
+ }
480
+ }
481
+ }
482
+
483
+ function dedupeUnion(
484
+ derived: DerivedEdge[],
485
+ declared: DerivedEdge[],
486
+ ): DerivedEdge[] {
487
+ const seen = new Set(derived.map((e) => `${edgeKey(e)}:${e.kind}`));
488
+ const extra = declared.filter((e) => !seen.has(`${edgeKey(e)}:${e.kind}`));
489
+ return [...derived, ...extra];
490
+ }
491
+
492
+ /** Commands a repo exposes: Makefile targets ∪ `.den.yml` commands.local keys. */
493
+ function availableCommands(repoPath: string): Set<string> {
494
+ const cmds = new Set(listMakeTargets(repoPath).targets);
495
+ const manifest = readManifest(repoPath);
496
+ for (const svc of Object.values(manifest?.services ?? {})) {
497
+ for (const env of Object.values(svc.commands ?? {})) {
498
+ for (const name of Object.keys(env ?? {})) cmds.add(name);
499
+ }
500
+ }
501
+ return cmds;
502
+ }
503
+
504
+ /**
505
+ * Flag any node missing a den-driven command. A command counts as available
506
+ * if it's a Makefile target OR declared in `.den.yml` commands.local (so
507
+ * `down: opctl kill` satisfies "down" without a `make down` target).
508
+ */
509
+ export function renderTargetWarnings(nodes: string[]): string {
510
+ const warnings: string[] = [];
511
+ for (const node of nodes) {
512
+ const repoPath = resolveRepoPath(node);
513
+ if (!repoPath) continue;
514
+ const have = availableCommands(repoPath);
515
+ const missing = REQUIRED_TARGETS.filter((t) => !have.has(t));
516
+ if (missing.length)
517
+ warnings.push(
518
+ ` ${node}: missing ${missing.join(", ")} (no make target or ${product().manifestYml} command)`,
519
+ );
520
+ }
521
+ return warnings.length
522
+ ? `command gaps:\n${warnings.join("\n")}`
523
+ : "commands: all nodes expose setup/up/down ✓";
524
+ }
@@ -0,0 +1,63 @@
1
+ import chalk from "chalk";
2
+ import type { ServiceHealthResult } from "../../shared/environment-manager/health.js";
3
+
4
+ import { product } from "../../shared/product.js";
5
+ type HealthState = ServiceHealthResult["health"];
6
+ type HealthReasonInput = Pick<ServiceHealthResult, "health" | "healthDetail">;
7
+
8
+ export function healthLabelText(health: HealthState): string {
9
+ if (health === "error") return "down";
10
+ return health;
11
+ }
12
+
13
+ export function formatHealthLabel(health: HealthState): string {
14
+ if (health === "ok") return chalk.green("● ok");
15
+ if (health === "error") return chalk.red("● down");
16
+ return chalk.gray("● unknown");
17
+ }
18
+
19
+ export function formatHealthDot(health: HealthState): string {
20
+ if (health === "ok") return chalk.green("●");
21
+ if (health === "error") return chalk.red("●");
22
+ return chalk.gray("●");
23
+ }
24
+
25
+ export function healthReasonText(result: HealthReasonInput): string {
26
+ if (result.health === "ok") return "";
27
+
28
+ const detail = result.healthDetail?.trim();
29
+ if (!detail) return result.health === "error" ? "not running" : "";
30
+
31
+ if (detail === "no health check configured") return "missing health check";
32
+ if (detail === `no ${product().manifestYml}`)
33
+ return `missing ${product().manifestYml}`;
34
+ if (detail.startsWith("repo path does not exist")) return "repo path missing";
35
+ if (detail === "timeout") return "health URL timeout";
36
+
37
+ if (
38
+ detail === "fetch failed" ||
39
+ /^(connect )?ECONNREFUSED\b/i.test(detail) ||
40
+ /^ENOTFOUND\b/i.test(detail) ||
41
+ /^EAI_AGAIN\b/i.test(detail)
42
+ ) {
43
+ return "health URL unreachable";
44
+ }
45
+
46
+ const httpMatch = detail.match(/^http (\d{3})$/i);
47
+ if (httpMatch) return `health URL returned HTTP ${httpMatch[1]}`;
48
+
49
+ const exitMatch = detail.match(/^exit (\d+|null)$/);
50
+ if (exitMatch) {
51
+ if (exitMatch[1] === "127") {
52
+ return "health command missing dependency (exit 127)";
53
+ }
54
+ return `health command failed: exit ${exitMatch[1]}`;
55
+ }
56
+
57
+ return detail;
58
+ }
59
+
60
+ export function formatHealthReason(result: HealthReasonInput): string {
61
+ const reason = healthReasonText(result);
62
+ return reason ? chalk.gray(reason.slice(0, 48)) : "";
63
+ }