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