@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,589 @@
1
+ /**
2
+ * Shared health-check primitives.
3
+ *
4
+ * Single source of truth for HTTP and shell pings — used by both the web
5
+ * server's /api/health endpoint and the CLI's status command.
6
+ */
7
+
8
+ import { hasRuntime, repoKind } from "../repo-kind.js";
9
+ import * as https from "https";
10
+ import * as tls from "tls";
11
+ import { readEdgeConfig } from "../edge/config.js";
12
+ import { composeHealthCmd } from "../stacks/manifest-commands.js";
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+ import * as dns from "dns";
16
+ import { spawn } from "child_process";
17
+ import { HEALTH_CHECK_TIMEOUT_MS, CLI_SPAWN_TIMEOUT_MS } from "../constants.js";
18
+ import { readManifest } from "../manifest/read-manifest.js";
19
+ import { readCatalog } from "./catalog.js";
20
+ import {
21
+ getActiveWorkspace,
22
+ getFocusedWorkspaceName,
23
+ listWorkspaceServices,
24
+ readManifestAnyBranch,
25
+ } from "./workspaces.js";
26
+ import type { ResolvedService, ServiceCatalog } from "../types/index.js";
27
+ import type { Environment } from "../types/index.js";
28
+
29
+ import { product } from "../product.js";
30
+ export interface PingResult {
31
+ ok: boolean;
32
+ status?: number;
33
+ error?: string;
34
+ }
35
+
36
+ export interface ServiceHealthResult {
37
+ address: string;
38
+ repoName: string;
39
+ subsystem: string;
40
+ repoPath: string;
41
+ mode: "dev-managed" | "managed";
42
+ branch: string | null;
43
+ health: "ok" | "error" | "unknown";
44
+ healthDetail?: string;
45
+ }
46
+
47
+ // ── Name resolution ───────────────────────────────────────────────────────────
48
+ // Health checks resolve names with c-ares and connect by IP instead of letting
49
+ // fetch() call getaddrinfo.
50
+ //
51
+ // getaddrinfo runs on the 4-thread libuv pool and its requests are
52
+ // UNCANCELLABLE — aborting the fetch does not cancel the lookup. One slow
53
+ // resolver episode strands a lookup on every pool thread, and every later
54
+ // lookup in the process then queues behind them forever while each poll adds
55
+ // ~21 more. Observed in the long-lived web server: 84k pending
56
+ // GetAddrInfoReqWrap, ~1 GB RSS, and every HTTP check reporting a false
57
+ // "timeout" while the very same service answered by IP in 11ms. Only a restart
58
+ // cleared it, and it re-wedged within 16 minutes. c-ares runs on the event
59
+ // loop, honours its own timeout, and cannot wedge the pool.
60
+ //
61
+ // Bare names (shop-api) resolve through opctl's per-service /etc/resolver
62
+ // files. c-ares must be pointed at that resolver explicitly because, unlike
63
+ // getaddrinfo, it does not read /etc/resolver. Aiming there also skips those
64
+ // files' `nameserver 8.8.8.8` fallback, so a missed lookup can no longer hang
65
+ // on Google trying to resolve a private name.
66
+ //
67
+ // Deliberately NOT cached: opctl containers take dynamic IPs (no IPAMConfig),
68
+ // so a restarted service can move. Resolution costs ~0ms, so every check
69
+ // re-resolves and DNS stays authoritative — strictly fresher than getaddrinfo,
70
+ // whose answers the OS caches for the record TTL (60s here).
71
+ const DNS_TIMEOUT_MS = 1_500;
72
+ const LOCAL_RESOLVER = "127.0.0.1";
73
+
74
+ function isIpLiteral(host: string): boolean {
75
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":");
76
+ }
77
+
78
+ let _bareResolver: dns.Resolver | null = null;
79
+ let _systemResolver: dns.Resolver | null = null;
80
+
81
+ function resolverFor(hostname: string): dns.Resolver {
82
+ // Bare names are opctl's and live on the local resolver; dotted names are
83
+ // real domains and use the system's configured servers.
84
+ if (!hostname.includes(".")) {
85
+ if (!_bareResolver) {
86
+ _bareResolver = new dns.Resolver({ timeout: DNS_TIMEOUT_MS, tries: 1 });
87
+ _bareResolver.setServers([LOCAL_RESOLVER]);
88
+ }
89
+ return _bareResolver;
90
+ }
91
+ if (!_systemResolver) {
92
+ _systemResolver = new dns.Resolver({ timeout: DNS_TIMEOUT_MS, tries: 1 });
93
+ }
94
+ return _systemResolver;
95
+ }
96
+
97
+ /**
98
+ * Test seam. Unit tests mock `fetch` and assert on the URL it receives; without
99
+ * this they would perform real DNS and assert against whatever IP this machine
100
+ * happens to hand out. Inject an identity resolver to keep them hermetic.
101
+ */
102
+ let _resolveHost: (hostname: string) => Promise<string | null> =
103
+ resolveHostViaCares;
104
+ export function __setResolveHostForTests(
105
+ fn: ((hostname: string) => Promise<string | null>) | null,
106
+ ): void {
107
+ _resolveHost = fn ?? resolveHostViaCares;
108
+ }
109
+
110
+ /** Resolve to an IPv4 address via c-ares. null = could not resolve. */
111
+ function resolveHostViaCares(hostname: string): Promise<string | null> {
112
+ return new Promise((resolve) => {
113
+ let settled = false;
114
+ const done = (v: string | null): void => {
115
+ if (settled) return;
116
+ settled = true;
117
+ resolve(v);
118
+ };
119
+ // Backstop: c-ares' own timeout should fire first, but a health check must
120
+ // never wait on resolution longer than its own budget.
121
+ const guard = setTimeout(() => done(null), DNS_TIMEOUT_MS + 250);
122
+ try {
123
+ resolverFor(hostname).resolve4(hostname, (err, addrs) => {
124
+ clearTimeout(guard);
125
+ done(err || !addrs || addrs.length === 0 ? null : addrs[0]);
126
+ });
127
+ } catch {
128
+ clearTimeout(guard);
129
+ done(null);
130
+ }
131
+ });
132
+ }
133
+
134
+ /** `localhost` and `*.localhost` are this machine (RFC 6761); else c-ares. */
135
+ async function resolveName(host: string): Promise<string | null> {
136
+ if (host === "localhost" || host.endsWith(".localhost")) return "127.0.0.1";
137
+ return _resolveHost(host);
138
+ }
139
+
140
+ /**
141
+ * Extra CAs trusted by HTTPS checks: the edge's own CA (its routes are signed
142
+ * by it), read once. Test seam: __setExtraCaForTests.
143
+ */
144
+ let _extraCa: string[] | null = null;
145
+ function extraCa(): string[] {
146
+ if (_extraCa) return _extraCa;
147
+ _extraCa = [];
148
+ try {
149
+ // The edge's own CA, and the CA it was pointed at (`edge.caroot`, e.g. the
150
+ // laptop's mkcert CA that signs the projects' certificates).
151
+ const cfg = readEdgeConfig();
152
+ const pems = [path.join(cfg.stateDir, "ca", "rootCA.pem")];
153
+ if (cfg.caroot) pems.push(path.join(cfg.caroot, "rootCA.pem"));
154
+ for (const pem of pems)
155
+ if (fs.existsSync(pem)) _extraCa.push(fs.readFileSync(pem, "utf-8"));
156
+ } catch {
157
+ /* no edge config — public roots only */
158
+ }
159
+ return _extraCa;
160
+ }
161
+ export function __setExtraCaForTests(pems: string[] | null): void {
162
+ _extraCa = pems;
163
+ }
164
+
165
+ /**
166
+ * HTTPS check. Connects to the c-ares-resolved IP (see above) but keeps the
167
+ * hostname for SNI and certificate verification; rewriting the URL to the IP,
168
+ * as plain HTTP does, makes every TLS check fail.
169
+ */
170
+ async function pingHttps(parsed: URL, timeoutMs: number): Promise<PingResult> {
171
+ const host = parsed.hostname;
172
+ let ip = host;
173
+ if (!isIpLiteral(host)) {
174
+ const r = await resolveName(host);
175
+ if (!r) return { ok: false, error: "dns" };
176
+ ip = r;
177
+ }
178
+ return new Promise((resolve) => {
179
+ let settled = false;
180
+ const done = (v: PingResult) => {
181
+ if (settled) return;
182
+ settled = true;
183
+ clearTimeout(timer);
184
+ resolve(v);
185
+ };
186
+ const req = https.request(
187
+ parsed,
188
+ {
189
+ method: "GET",
190
+ servername: isIpLiteral(host) ? undefined : host,
191
+ ca: [...tls.rootCertificates, ...extraCa()],
192
+ lookup: (_h, opts, cb) => {
193
+ // Node may ask for all addresses (autoSelectFamily).
194
+ if ((opts as { all?: boolean }).all)
195
+ (
196
+ cb as unknown as (
197
+ e: null,
198
+ a: { address: string; family: number }[],
199
+ ) => void
200
+ )(null, [{ address: ip, family: ip.includes(":") ? 6 : 4 }]);
201
+ else cb(null, ip, ip.includes(":") ? 6 : 4);
202
+ },
203
+ },
204
+ (res) => {
205
+ const status = res.statusCode ?? 0;
206
+ res.resume(); // discard the body
207
+ done({
208
+ ok: status < 300 || (status >= 400 && status < 500),
209
+ status,
210
+ });
211
+ },
212
+ );
213
+ const timer = setTimeout(() => {
214
+ req.destroy();
215
+ done({ ok: false, error: "timeout" });
216
+ }, timeoutMs);
217
+ req.on("error", (e) =>
218
+ done({ ok: false, error: e.message || "unreachable" }),
219
+ );
220
+ req.end();
221
+ });
222
+ }
223
+
224
+ // ── Ping primitives ───────────────────────────────────────────────────────────
225
+
226
+ async function pingOnce(
227
+ url: string,
228
+ timeoutMs: number = HEALTH_CHECK_TIMEOUT_MS,
229
+ ): Promise<PingResult> {
230
+ let target = url;
231
+ let hostHeader: string | undefined;
232
+ try {
233
+ const parsed = new URL(url);
234
+ const host = parsed.hostname;
235
+ if (parsed.protocol === "https:") return await pingHttps(parsed, timeoutMs);
236
+ if (host !== "localhost" && !isIpLiteral(host)) {
237
+ const ip = await resolveName(host);
238
+ // A name that will not resolve is down — say so in 1ms instead of
239
+ // burning the full timeout and calling it "timeout", which read as a
240
+ // slow service when it was really a missing DNS record.
241
+ if (!ip) return { ok: false, error: "dns" };
242
+ hostHeader = parsed.host; // name[:port] — preserves vhost routing
243
+ parsed.hostname = ip;
244
+ target = parsed.toString();
245
+ }
246
+ } catch {
247
+ /* unparseable URL — leave it alone and let fetch report the failure */
248
+ }
249
+ const controller = new AbortController();
250
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
251
+ try {
252
+ const res = await fetch(target, {
253
+ signal: controller.signal,
254
+ redirect: "manual",
255
+ ...(hostHeader ? { headers: { Host: hostHeader } } : {}),
256
+ });
257
+ clearTimeout(timer);
258
+ // 2xx = healthy. 4xx = healthy (service is up; auth/route 4xx still proves it's responding).
259
+ // 3xx = unhealthy: with redirect:"manual", a redirect means "go verify somewhere else"
260
+ // and we haven't confirmed anything. Guards against hostname-to-AWS-ALB resolution
261
+ // (e.g. VPN / /etc/hosts) returning 301 on port 80 for services that actually run
262
+ // on a different port — the previous `< 500` check showed those as green.
263
+ // 5xx = unhealthy (service errored).
264
+ const ok = res.status < 300 || (res.status >= 400 && res.status < 500);
265
+ // Discard the body: only the status matters, and unconsumed bodies pin
266
+ // sockets in the long-lived web server (one leak per poll adds up).
267
+ void res.body?.cancel().catch(() => {});
268
+ return { ok, status: res.status };
269
+ } catch (e: unknown) {
270
+ clearTimeout(timer);
271
+ const err = e as Error;
272
+ return {
273
+ ok: false,
274
+ error:
275
+ err.name === "AbortError" ? "timeout" : err.message || "unreachable",
276
+ };
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Ping an HTTP/HTTPS URL. For Docker-style hostnames (no dots, not localhost)
282
+ * automatically retries against localhost on the same port as a fallback —
283
+ * these hostnames only resolve inside Docker itself.
284
+ */
285
+ /** Fast timeout for the localhost fallback ping — localhost should respond in ms. */
286
+ const LOCALHOST_FALLBACK_TIMEOUT_MS = 1_500;
287
+
288
+ export async function pingHttp(
289
+ url: string,
290
+ timeoutMs: number = HEALTH_CHECK_TIMEOUT_MS,
291
+ ): Promise<PingResult> {
292
+ const result = await pingOnce(url, timeoutMs);
293
+ if (!result.ok) {
294
+ // Fall back to localhost for Docker-style hostnames regardless of how the
295
+ // first ping failed. Timeouts on the primary are legitimate candidates for
296
+ // retry: on macOS, VPN/`/etc/hosts` can resolve a Docker hostname to an
297
+ // AWS ALB on the wrong port, which hangs until abort — skipping the retry
298
+ // showed the service red even when the local container was up. We use a
299
+ // short timeout for the fallback since localhost should respond in ms.
300
+ try {
301
+ const parsed = new URL(url);
302
+ // NO fallback for default-port-80 URLs: opctl DNS resolves bare names
303
+ // (http://<repo>/healthz) on the host directly, so a network failure
304
+ // there means the service is genuinely down — and localhost:80 belongs
305
+ // to whichever OTHER container happens to publish host port 80 (e.g.
306
+ // shop-web), which both spams that service's logs with foreign
307
+ // /healthz probes every poll and risks reporting the wrong service.
308
+ const explicitPort = parsed.port !== "" && parsed.port !== "80";
309
+ if (
310
+ explicitPort &&
311
+ parsed.hostname !== "localhost" &&
312
+ !parsed.hostname.includes(".")
313
+ ) {
314
+ // Rewrite only the hostname so the port, path and query are preserved
315
+ // (string-concatenating protocol+port dropped /healthz and emitted a
316
+ // stray trailing colon for default-port URLs like http://shop-api/healthz).
317
+ parsed.hostname = "localhost";
318
+ const fb = await pingOnce(
319
+ parsed.toString(),
320
+ LOCALHOST_FALLBACK_TIMEOUT_MS,
321
+ );
322
+ // On the fallback, only 2xx proves the service is up: localhost:<port>
323
+ // may be a DIFFERENT service, and a 4xx from a stranger (which counts
324
+ // as "up" on a direct ping) must not green-light this one.
325
+ if (fb.ok && fb.status !== undefined && fb.status >= 400) {
326
+ return {
327
+ ok: false,
328
+ status: fb.status,
329
+ error: "fallback got non-2xx",
330
+ };
331
+ }
332
+ return fb;
333
+ }
334
+ } catch {
335
+ /* ignore */
336
+ }
337
+ }
338
+ return result;
339
+ }
340
+
341
+ /**
342
+ * Run a shell command health check. Exit code 0 = ok.
343
+ * Pass extraEnv for AWS profile vars etc.
344
+ *
345
+ * `cwd` must be the service's repo path: health commands in .den.yml are
346
+ * repo-relative (e.g. `./scripts/orchestration/health.sh ddb`). Without it the
347
+ * relative path resolves against den's own directory and the shell exits 127
348
+ * (command not found) — which is exactly how a service whose local resources
349
+ * moved to infra-stack shows up red even when it's healthy.
350
+ */
351
+ export async function pingShell(
352
+ cmd: string,
353
+ extraEnv: Record<string, string> = {},
354
+ cwd?: string,
355
+ ): Promise<PingResult> {
356
+ return new Promise((resolve) => {
357
+ let settled = false;
358
+ const settle = (r: PingResult): void => {
359
+ if (settled) return;
360
+ settled = true;
361
+ clearTimeout(guard);
362
+ resolve(r);
363
+ };
364
+ const proc = spawn(cmd, [], {
365
+ shell: true,
366
+ timeout: CLI_SPAWN_TIMEOUT_MS,
367
+ env: { ...process.env, ...extraEnv },
368
+ cwd,
369
+ // Output is never read: piped stdio would block a chatty script once the
370
+ // 64KB buffer fills, and 'close' would wait on grandchildren holding the
371
+ // pipes open. Nothing consumes it — don't create it.
372
+ stdio: "ignore",
373
+ });
374
+ // 'exit', NOT 'close': close waits for stdio to drain, which a killed
375
+ // shell's surviving grandchildren can hold open forever — one wedged
376
+ // health check then hangs every /api/health poll for good. The guard
377
+ // timer backstops even a spawn timeout that never fires.
378
+ const guard = setTimeout(
379
+ () => settle({ ok: false, error: "health check timed out" }),
380
+ CLI_SPAWN_TIMEOUT_MS + 1_000,
381
+ );
382
+ proc.on("exit", (code) =>
383
+ settle(code === 0 ? { ok: true } : { ok: false, error: `exit ${code}` }),
384
+ );
385
+ proc.on("error", (err) => settle({ ok: false, error: err.message }));
386
+ });
387
+ }
388
+
389
+ // ── Per-service health check ──────────────────────────────────────────────────
390
+
391
+ /**
392
+ * Check health for a single resolved service (reads its manifest).
393
+ * `env` defaults to "local".
394
+ */
395
+ export async function checkServiceHealth(
396
+ resolved: ResolvedService,
397
+ env: Environment = "local",
398
+ ): Promise<ServiceHealthResult> {
399
+ const base: ServiceHealthResult = {
400
+ address: resolved.address,
401
+ repoName: resolved.repoName,
402
+ subsystem: resolved.subsystem,
403
+ repoPath: resolved.repoPath,
404
+ mode: resolved.mode,
405
+ branch: resolved.branch,
406
+ health: "unknown",
407
+ };
408
+
409
+ if (!fs.existsSync(resolved.repoPath)) {
410
+ return {
411
+ ...base,
412
+ health: "error",
413
+ healthDetail: `repo path does not exist — run ${product().cli} setup`,
414
+ };
415
+ }
416
+
417
+ const manifest = readManifest(resolved.repoPath);
418
+ if (!manifest) {
419
+ return {
420
+ ...base,
421
+ health: "unknown",
422
+ healthDetail: `no ${product().manifestYml}`,
423
+ };
424
+ }
425
+
426
+ const composeHealth =
427
+ env === "local"
428
+ ? composeHealthCmd(
429
+ manifest,
430
+ resolved.subsystem,
431
+ resolved.repoName,
432
+ getFocusedWorkspaceName() ?? "default",
433
+ )
434
+ : null;
435
+ const healthCfg =
436
+ manifest.services?.[resolved.subsystem]?.health?.[env] ??
437
+ (composeHealth
438
+ ? { type: "shell" as const, cmd: composeHealth }
439
+ : undefined);
440
+ if (!healthCfg) {
441
+ // Conventional local discovery: opctl exposes each system on the host at
442
+ // http://<repo> (port 80), and stack services serve /healthz. When the
443
+ // PRIMARY service (entrypoint, or a repo's only service) declares no local
444
+ // health check, probe the convention — a repo like jobs-runtime serves
445
+ // /healthz without listing it in the manifest. Success → "ok"; failure
446
+ // stays "unknown" (we can't distinguish "down" from "no endpoint").
447
+ const serviceKeys = Object.keys(manifest.services ?? {});
448
+ const isPrimary = manifest.entrypoint
449
+ ? resolved.subsystem === manifest.entrypoint
450
+ : serviceKeys.length === 1;
451
+ if (env === "local" && isPrimary) {
452
+ const conventional = `http://${resolved.repoName}/healthz`;
453
+ const probe = await pingHttp(conventional);
454
+ // Convention-GUESSED endpoint: only 2xx proves this service answered.
455
+ // pingHttp counts 4xx as ok (right for DECLARED endpoints, where 401/403
456
+ // still prove the service is up) — but on the bare port-80 convention,
457
+ // host DNS may hand the request to a different container or an ALB, and
458
+ // a stranger's 404 must not report "ok" (it would feed up's skip-healthy
459
+ // decision and skip starting a genuinely-down service).
460
+ if (probe.ok && probe.status !== undefined && probe.status < 300) {
461
+ return {
462
+ ...base,
463
+ health: "ok",
464
+ healthDetail: `conventional ${conventional}`,
465
+ };
466
+ }
467
+ }
468
+ return {
469
+ ...base,
470
+ health: "unknown",
471
+ healthDetail: "no health check configured",
472
+ };
473
+ }
474
+
475
+ const result =
476
+ healthCfg.type === "shell"
477
+ ? await pingShell(healthCfg.cmd, {}, resolved.repoPath)
478
+ : await pingHttp(healthCfg.url);
479
+
480
+ return {
481
+ ...base,
482
+ health: result.ok ? "ok" : "error",
483
+ healthDetail:
484
+ result.error ??
485
+ (!result.ok && result.status !== undefined
486
+ ? `http ${result.status}`
487
+ : undefined),
488
+ };
489
+ }
490
+
491
+ // ── Catalog-wide health scan ──────────────────────────────────────────────────
492
+
493
+ /**
494
+ * Check health for every service visible in the active workspace.
495
+ * Uses listWorkspaceServices (which expands mono-repo entries and resolves
496
+ * paths from workspace entries) so CLI and server stay consistent.
497
+ *
498
+ * Falls back to iterating the catalog directly when no workspace is active
499
+ * (e.g. first-run before any workspace is configured).
500
+ */
501
+ export async function checkCatalogHealth(
502
+ catalog: ServiceCatalog = readCatalog(),
503
+ env: Environment = "local",
504
+ ): Promise<ServiceHealthResult[]> {
505
+ const ws = getActiveWorkspace();
506
+
507
+ if (!ws) return [];
508
+ // Modules and libraries have no runtime, so no health of their own.
509
+ const resolved = listWorkspaceServices(ws.config, catalog, ws.name).filter(
510
+ (r) => hasRuntime(repoKind(r.repoPath)),
511
+ );
512
+
513
+ return Promise.all(resolved.map((r) => checkServiceHealth(r, env)));
514
+ }
515
+
516
+ /**
517
+ * Collapse a multi-service repo's per-subsystem health rows into a single
518
+ * repo-level row keyed on the manifest `entrypoint` (the front-door app).
519
+ *
520
+ * Stack app-repos declare their shared-infra sidecars (pg/redis/sqs/ddb/s3) as
521
+ * manifest `services:`, and those sidecars health-check the shared infra-stack
522
+ * stack — so they read "ok" whenever infra-stack is up, making a down app look
523
+ * partially running. Rolling up to the entrypoint answers the real question
524
+ * ("is this repo's service up?") instead.
525
+ *
526
+ * A repo is collapsed only when it has multiple rows AND its manifest declares an
527
+ * `entrypoint` present among them. Repos with no entrypoint (e.g. the
528
+ * infra-stack infra provider, whose subsystems ARE the meaningful services)
529
+ * keep their subsystem breakdown. Single-row repos pass through unchanged.
530
+ */
531
+ export function rollupRepoHealth(
532
+ results: ServiceHealthResult[],
533
+ getEntrypoint: (repoName: string, repoPath: string) => string | undefined = (
534
+ repoName,
535
+ repoPath,
536
+ ) => readManifestAnyBranch(repoName, repoPath)?.entrypoint ?? undefined,
537
+ ): ServiceHealthResult[] {
538
+ // Group by repo, preserving first-seen order.
539
+ const groups = new Map<string, ServiceHealthResult[]>();
540
+ for (const r of results) {
541
+ const g = groups.get(r.repoName);
542
+ if (g) g.push(r);
543
+ else groups.set(r.repoName, [r]);
544
+ }
545
+
546
+ const out: ServiceHealthResult[] = [];
547
+ for (const [repoName, rows] of groups) {
548
+ if (rows.length <= 1) {
549
+ out.push(...rows);
550
+ continue;
551
+ }
552
+ const entrypoint = getEntrypoint(repoName, rows[0].repoPath);
553
+ const epRow = entrypoint
554
+ ? rows.find((r) => r.subsystem === entrypoint)
555
+ : undefined;
556
+ if (epRow) {
557
+ // Show the entrypoint's health under the clean repo-name address.
558
+ out.push({ ...epRow, address: repoName });
559
+ } else {
560
+ // No entrypoint → infra provider; keep the subsystem breakdown.
561
+ out.push(...rows);
562
+ }
563
+ }
564
+ return out;
565
+ }
566
+
567
+ /**
568
+ * Order health rows to match the derived graph's top-down flow — entry repo
569
+ * first, foundation last (e.g. shop-web … infra-stack), the same direction the
570
+ * web graph uses. Pass the repo-name order (typically `orderForDown(graph)`).
571
+ *
572
+ * Stable within a repo, so a multi-service repo's subsystems stay grouped in
573
+ * their original sub-order. Rows whose repo isn't in the graph sort to the end,
574
+ * preserving their relative order.
575
+ */
576
+ export function sortByRepoOrder(
577
+ results: ServiceHealthResult[],
578
+ order: string[],
579
+ ): ServiceHealthResult[] {
580
+ const rank = new Map(order.map((repo, i) => [repo, i]));
581
+ return results
582
+ .map((r, i) => ({ r, i }))
583
+ .sort((a, b) => {
584
+ const ra = rank.get(a.r.repoName) ?? Number.POSITIVE_INFINITY;
585
+ const rb = rank.get(b.r.repoName) ?? Number.POSITIVE_INFINITY;
586
+ return ra - rb || a.i - b.i;
587
+ })
588
+ .map((x) => x.r);
589
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./catalog.js";
2
+ export * from "./workspaces.js";
3
+ export * from "./workspace-manager.js";
4
+ export * from "./health.js";
5
+ export * from "./service-runner.js";
6
+ export * from "./dag.js";
@@ -0,0 +1,78 @@
1
+ /** Applications for a workspace, from config and repo manifests. */
2
+ import { readDenConfig } from "../den-config.js";
3
+ import { resolveApplications, type Application } from "../applications.js";
4
+ import { readManifest } from "../manifest/read-manifest.js";
5
+ import { contributesTo, repoKind } from "../repo-kind.js";
6
+ import type { RepoKind } from "../types/index.js";
7
+ import { deriveGraph } from "./derivation/derive-graph.js";
8
+ import type { DerivedGraph } from "./derivation/types.js";
9
+ import { getWorkspaceServices } from "./service-runner.js";
10
+ import { readOrg, type Org } from "../org.js";
11
+
12
+ export function loadApplications(workspace?: string): Application[] {
13
+ const services = getWorkspaceServices(workspace);
14
+ const repos = [...new Set(services.map((s) => s.repoName))];
15
+ const manifestApplications: Record<string, string> = {};
16
+ const kinds: Record<string, RepoKind> = {};
17
+ const contributes: Record<string, string[]> = {};
18
+ // The graph only tells a module/library where it ships; derive it lazily.
19
+ let graph: DerivedGraph | undefined;
20
+ for (const s of services) {
21
+ const a = readManifest(s.repoPath)?.application;
22
+ if (a) manifestApplications[s.repoName] = a;
23
+ const kind = repoKind(s.repoPath);
24
+ if (kind !== "service") {
25
+ graph ??= deriveGraph({
26
+ scope: "all",
27
+ ...(workspace ? { workspace } : {}),
28
+ });
29
+ kinds[s.repoName] = kind;
30
+ contributes[s.repoName] = contributesTo(s.repoName, s.repoPath, graph);
31
+ }
32
+ }
33
+ return resolveApplications({
34
+ repos,
35
+ config: readDenConfig().applications,
36
+ manifestApplications,
37
+ kinds,
38
+ contributesTo: contributes,
39
+ });
40
+ }
41
+
42
+ /** An application with what the workspace runs of it. */
43
+ export interface ApplicationView extends Application {
44
+ /** repo → its service addresses in the workspace ("repo" or "repo/sub"). */
45
+ services: Record<string, string[]>;
46
+ /** Every service address in the application, for app-wide up/down. */
47
+ addresses: string[];
48
+ /** Repos the team config lists for it that are not in this workspace. */
49
+ absent: string[];
50
+ }
51
+
52
+ /** Org > Application > Repo > Services for a workspace (CLI `apps`, web UI). */
53
+ export function loadApplicationTree(workspace?: string): {
54
+ org?: Org;
55
+ applications: ApplicationView[];
56
+ } {
57
+ const byRepo = new Map<string, string[]>();
58
+ for (const s of getWorkspaceServices(workspace)) {
59
+ if (!byRepo.has(s.repoName)) byRepo.set(s.repoName, []);
60
+ byRepo.get(s.repoName)!.push(s.address);
61
+ }
62
+ const config = readDenConfig().applications ?? {};
63
+ const org = readOrg();
64
+ return {
65
+ ...(org ? { org } : {}),
66
+ applications: loadApplications(workspace).map((a) => {
67
+ const services = Object.fromEntries(
68
+ a.repos.map((r) => [r, byRepo.get(r) ?? []]),
69
+ );
70
+ return {
71
+ ...a,
72
+ services,
73
+ addresses: Object.values(services).flat(),
74
+ absent: (config[a.name]?.repos ?? []).filter((r) => !byRepo.has(r)),
75
+ };
76
+ }),
77
+ };
78
+ }