@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,278 @@
1
+ /**
2
+ * Manifest reader — supports .den.yml (preferred) and .den.json (fallback).
3
+ *
4
+ * Import this helper instead of calling fs.readFileSync('.den.json') directly
5
+ * so all callers automatically get YAML support.
6
+ *
7
+ * A repo's own manifest always wins. When a checkout has none, Den's own
8
+ * copy is used: `manifests/<github-repo>.den.yml` in the Den repo, keyed by
9
+ * the checkout's `origin` repo name. That covers repos whose manifest is not
10
+ * merged yet and repos Den should not commit to.
11
+ *
12
+ * Whichever base won, `manifests/<github-repo>.override.yml` (when present)
13
+ * is deep-merged on top: see merge-override.ts for the rules.
14
+ */
15
+
16
+ import * as fs from "fs";
17
+ import * as path from "path";
18
+ import { execFileSync } from "child_process";
19
+ import { getDistRoot } from "../paths.js";
20
+ import { parse as parseYaml } from "yaml";
21
+ import type { RawManifest } from "../types/index.js";
22
+
23
+ import { product } from "../product.js";
24
+ import { mergeOverride } from "./merge-override.js";
25
+ /** Names probed in order. First match wins. */
26
+ const MANIFEST_NAMES = [
27
+ `${product().manifestYml}`,
28
+ `${product().manifestJson}`,
29
+ ] as const;
30
+
31
+ /** The repo's own manifest file, or null. */
32
+ export function repoManifestPath(repoPath: string): string | null {
33
+ for (const name of MANIFEST_NAMES) {
34
+ const p = path.join(repoPath, name);
35
+ if (fs.existsSync(p)) return p;
36
+ }
37
+ return null;
38
+ }
39
+
40
+ // ── Den-held manifests ───────────────────────────────────────────────────
41
+ // For a repo that doesn't carry a manifest yet (one we don't commit to, or
42
+ // whose PR hasn't merged), den keeps one in manifests/<repo>.den.yml,
43
+ // keyed by the git origin's repo name (falling back to the folder name). The
44
+ // repo's own file always wins.
45
+
46
+ const originCache = new Map<string, string | null>();
47
+
48
+ /** Repo name of `repoPath`'s git origin (`…/<name>.git`), cached. */
49
+ export function originRepoName(repoPath: string): string | null {
50
+ if (originCache.has(repoPath)) return originCache.get(repoPath)!;
51
+ let name: string | null = null;
52
+ try {
53
+ const url = execFileSync(
54
+ "git",
55
+ ["-C", repoPath, "config", "--get", "remote.origin.url"],
56
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 },
57
+ ).trim();
58
+ name =
59
+ url
60
+ .replace(/\.git$/, "")
61
+ .split(/[/:]/)
62
+ .pop() || null;
63
+ } catch {
64
+ /* not a git checkout, or no origin */
65
+ }
66
+ originCache.set(repoPath, name);
67
+ return name;
68
+ }
69
+
70
+ /** Directory of den-held manifests (override: DEN_MANIFESTS_DIR). */
71
+ export function heldManifestsDir(): string {
72
+ return process.env.DEN_MANIFESTS_DIR ?? path.join(getDistRoot(), "manifests");
73
+ }
74
+
75
+ /** The repo name of a `.den-services/<repo>/<branch>` path, else null. */
76
+ function managedCloneRepo(repoPath: string): string | null {
77
+ const parent = path.dirname(repoPath);
78
+ return path.basename(path.dirname(parent)) === `${product().servicesDir}`
79
+ ? path.basename(parent)
80
+ : null;
81
+ }
82
+
83
+ /** The names `repoPath` is known by: origin repo, managed clone, folder. */
84
+ function repoKeys(repoPath: string): string[] {
85
+ return [
86
+ originRepoName(repoPath),
87
+ // A managed clone lives at .den-services/<repo>/<branch>,
88
+ // and need not be cloned yet.
89
+ managedCloneRepo(repoPath),
90
+ path.basename(repoPath),
91
+ ].filter((k): k is string => !!k);
92
+ }
93
+
94
+ /** The first `manifests/<key><suffix>` that exists, or null. */
95
+ function heldFile(repoPath: string, suffix: string): string | null {
96
+ for (const key of repoKeys(repoPath)) {
97
+ const p = path.join(heldManifestsDir(), `${key}${suffix}`);
98
+ if (fs.existsSync(p)) return p;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /** The den-held manifest for `repoPath`, or null. */
104
+ export function heldManifestPath(repoPath: string): string | null {
105
+ return heldFile(repoPath, `${product().manifestYml}`);
106
+ }
107
+
108
+ /**
109
+ * The override suffix: `.override` plus the manifest's extension, so a
110
+ * profile whose manifest is `.acme.yml` reads `manifests/<repo>.override.yml`.
111
+ */
112
+ export function overrideSuffix(): string {
113
+ return `.override${path.extname(product().manifestYml) || ".yml"}`;
114
+ }
115
+
116
+ /**
117
+ * The override for `repoPath` (`manifests/<repo>.override.yml`), or null.
118
+ * Matched by the same names, in the same order, as the held manifest.
119
+ */
120
+ export function overrideManifestPath(repoPath: string): string | null {
121
+ return heldFile(repoPath, overrideSuffix());
122
+ }
123
+
124
+ /**
125
+ * Resolve the base manifest for `repoPath`: the repo's own .den.yml (or
126
+ * .json), else the one den holds for it. Returns null if neither exists.
127
+ * An override is layered on top by readManifest; it is never a base.
128
+ */
129
+ export function resolveManifestPath(repoPath: string): string | null {
130
+ return repoManifestPath(repoPath) ?? heldManifestPath(repoPath);
131
+ }
132
+
133
+ /** Where `repoPath`'s base manifest comes from. */
134
+ export function manifestSource(repoPath: string): "repo" | "held" | null {
135
+ if (repoManifestPath(repoPath)) return "repo";
136
+ return heldManifestPath(repoPath) ? "held" : null;
137
+ }
138
+
139
+ // ── Layers ───────────────────────────────────────────────────────────────
140
+
141
+ /** A layer of a resolved manifest. The base comes first. */
142
+ export type ManifestLayer = "repo" | "held" | "override";
143
+
144
+ export interface ManifestLoad {
145
+ /** The merged manifest, or null when none is usable. */
146
+ manifest: RawManifest | null;
147
+ /** The layers that applied, base first: e.g. ["repo", "override"]. */
148
+ layers: ManifestLayer[];
149
+ /** The base manifest file (repo or held), or null. */
150
+ baseFile: string | null;
151
+ /** The base's layer, or null when there is no base. */
152
+ baseLayer: "repo" | "held" | null;
153
+ /** The override file, or null. */
154
+ overrideFile: string | null;
155
+ /** Why no manifest is usable, when files exist but cannot be used. */
156
+ error?: string;
157
+ }
158
+
159
+ /** Parse a manifest file's text: a map, or null for anything else. */
160
+ export function parseManifestText(
161
+ filePath: string,
162
+ raw: string,
163
+ ): Record<string, unknown> | null {
164
+ const parsed: unknown = filePath.endsWith(".json")
165
+ ? JSON.parse(raw)
166
+ : parseYaml(raw);
167
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
168
+ return null; // scalar/array/empty manifest is not usable
169
+ return parsed as Record<string, unknown>;
170
+ }
171
+
172
+ /** The message for an override that has no base to apply to. */
173
+ export function orphanOverrideMessage(): string {
174
+ return `an override with no base manifest is not a manifest: add a ${product().manifestYml} to the repo, or to manifests/`;
175
+ }
176
+
177
+ interface Located {
178
+ baseFile: string | null;
179
+ baseLayer: "repo" | "held" | null;
180
+ overrideFile: string | null;
181
+ }
182
+
183
+ function locate(repoPath: string): Located {
184
+ const own = repoManifestPath(repoPath);
185
+ const held = own ? null : heldManifestPath(repoPath);
186
+ return {
187
+ baseFile: own ?? held,
188
+ baseLayer: own ? "repo" : held ? "held" : null,
189
+ overrideFile: overrideManifestPath(repoPath),
190
+ };
191
+ }
192
+
193
+ /** Parse and merge the located files' texts. Never throws. */
194
+ function combine(
195
+ at: Located,
196
+ baseRaw: string | null,
197
+ overrideRaw: string | null,
198
+ ): ManifestLoad {
199
+ const out: ManifestLoad = { manifest: null, layers: [], ...at };
200
+ const parse = (file: string, raw: string): Record<string, unknown> => {
201
+ const parsed = parseManifestText(file, raw);
202
+ if (!parsed) throw new Error(`${file}: not a map`);
203
+ return parsed;
204
+ };
205
+ if (!at.baseFile || !at.baseLayer || baseRaw === null) {
206
+ if (at.overrideFile)
207
+ out.error = `${at.overrideFile}: ${orphanOverrideMessage()}`;
208
+ return out;
209
+ }
210
+ try {
211
+ let manifest = parse(at.baseFile, baseRaw);
212
+ const layers: ManifestLayer[] = [at.baseLayer];
213
+ if (at.overrideFile && overrideRaw !== null) {
214
+ manifest = mergeOverride(manifest, parse(at.overrideFile, overrideRaw));
215
+ layers.push("override");
216
+ }
217
+ out.manifest = manifest as unknown as RawManifest;
218
+ out.layers = layers;
219
+ } catch (e) {
220
+ out.error = (e as Error).message;
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /** Resolve, read and merge `repoPath`'s manifest layers. Never throws. */
226
+ export function loadManifest(repoPath: string): ManifestLoad {
227
+ const at = locate(repoPath);
228
+ try {
229
+ return combine(
230
+ at,
231
+ at.baseFile && fs.readFileSync(at.baseFile, "utf-8"),
232
+ at.overrideFile && fs.readFileSync(at.overrideFile, "utf-8"),
233
+ );
234
+ } catch (e) {
235
+ return { manifest: null, layers: [], ...at, error: (e as Error).message };
236
+ }
237
+ }
238
+
239
+ /** The layers of `repoPath`'s manifest that applied, e.g. ["held", "override"]. */
240
+ export function manifestLayers(repoPath: string): ManifestLayer[] {
241
+ return loadManifest(repoPath).layers;
242
+ }
243
+
244
+ /** "repo", "held + override", … ("none" when no manifest applied). */
245
+ export function manifestLayersLabel(layers: readonly ManifestLayer[]): string {
246
+ return layers.length > 0 ? layers.join(" + ") : "none";
247
+ }
248
+
249
+ /**
250
+ * Read and parse the manifest from `repoPath`, its override merged in.
251
+ * Supports both YAML and JSON.
252
+ * Returns null if no manifest file exists or parsing fails.
253
+ */
254
+ export function readManifest(repoPath: string): RawManifest | null {
255
+ return loadManifest(repoPath).manifest;
256
+ }
257
+
258
+ /**
259
+ * Async version of readManifest.
260
+ */
261
+ export async function readManifestAsync(
262
+ repoPath: string,
263
+ ): Promise<RawManifest | null> {
264
+ const at = locate(repoPath);
265
+ try {
266
+ const { readFile } = await import("fs/promises");
267
+ return combine(
268
+ at,
269
+ at.baseFile && (await readFile(at.baseFile, "utf-8")),
270
+ at.overrideFile && (await readFile(at.overrideFile, "utf-8")),
271
+ ).manifest;
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ /** The canonical manifest filename for display/error messages. */
278
+ export const MANIFEST_DISPLAY_NAME = `${product().manifestYml}`;
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Zod schemas for .den.yml manifests.
3
+ *
4
+ * Each schema version is defined separately so migrations can validate
5
+ * inputs and outputs at their respective version boundaries.
6
+ *
7
+ * Current version: 1 (all existing manifests without a version field are v1)
8
+ */
9
+
10
+ import { z } from "zod";
11
+ import { journeysAppSchema } from "../../pillars/journeys/app-config.js";
12
+
13
+ export const CURRENT_SCHEMA_VERSION = 1;
14
+ export const KNOWN_VERSIONS = [1] as const;
15
+
16
+ // ── Shared primitives ─────────────────────────────────────────────────────────
17
+
18
+ // Command definition: plain string or object with options
19
+ const commandDef = z.union([
20
+ z.string(),
21
+ z.object({
22
+ cmd: z.string(),
23
+ detachable: z.boolean().optional(),
24
+ interactive: z.boolean().optional(),
25
+ }),
26
+ ]);
27
+
28
+ // Health check: HTTP (with or without explicit type) or shell
29
+ //
30
+ // Valid shapes:
31
+ // { url: "http://..." } — implicit HTTP
32
+ // { type: "http", url: "http://..." } — explicit HTTP
33
+ // { type: "shell", cmd: "pg_isready ..." } — shell
34
+ const healthConfig = z.union([
35
+ // Shell health check — must have cmd
36
+ z.object({
37
+ type: z.literal("shell"),
38
+ cmd: z.string({
39
+ required_error: '"cmd" is required for shell health checks',
40
+ }),
41
+ label: z.string().optional(),
42
+ }),
43
+ // HTTP health check (explicit or implicit type) — must have url
44
+ z.object({
45
+ type: z.union([z.literal("http"), z.undefined()]).optional(),
46
+ url: z.string({
47
+ required_error: '"url" is required for HTTP health checks',
48
+ }),
49
+ label: z.string().optional(),
50
+ }),
51
+ ]);
52
+
53
+ // `distinct: true` marks a SUBSYSTEM's log as its own listed source in the
54
+ // /logs view (e.g. shop/db — shop runs its own postgres, not infra-stack's).
55
+ // Without it, only a system's primary service log is listed there.
56
+ const logSource = z.union([
57
+ z.object({ type: z.literal("process"), distinct: z.boolean().optional() }),
58
+ z.object({
59
+ type: z.literal("shell"),
60
+ cmd: z.string(),
61
+ distinct: z.boolean().optional(),
62
+ }),
63
+ z.object({
64
+ type: z.literal("cloudwatch"),
65
+ logGroup: z.string(),
66
+ logStream: z.string().optional(),
67
+ region: z.string().optional(),
68
+ distinct: z.boolean().optional(),
69
+ }),
70
+ ]);
71
+
72
+ // ── Service management config ─────────────────────────────────────────────────
73
+ //
74
+ // Keys for commands/logs/health are environment names (local, dev, …).
75
+ // We accept any string key here — Zod's z.record(z.enum(…)) requires all
76
+ // enum keys to be present (exhaustive), which is too strict for optional env blocks.
77
+
78
+ // depends_on entry: plain string or object with a `service` field and optional
79
+ // `wait` flag (opts this service's startup into blocking on the dep's health
80
+ // check — otherwise default fire-and-forget / detach-window behavior applies).
81
+ const dependsOnEntry = z.union([
82
+ z.string(),
83
+ z.object({
84
+ service: z.string(),
85
+ wait: z.boolean().optional(),
86
+ }),
87
+ ]);
88
+
89
+ const httpUrlString = z
90
+ .string()
91
+ .regex(/^https?:\/\/\S+$/, "must be an http(s) URL");
92
+
93
+ // Placement (R13): where a service runs per env, and where a remote one is.
94
+ // Env names are checked against the org's environments in placement.ts.
95
+ const statusCode = z.number().int().min(100).max(599);
96
+ const serviceTarget = z
97
+ .object({
98
+ env: z.string().min(1),
99
+ account: z.string().min(1).optional(),
100
+ url: httpUrlString.optional(),
101
+ expect: z.union([statusCode, z.array(statusCode).min(1)]).optional(),
102
+ })
103
+ .strict();
104
+
105
+ const serviceManagementConfig = z.object({
106
+ runs: z.record(z.string(), z.enum(["local", "remote"])).optional(),
107
+ target: serviceTarget.optional(),
108
+ depends_on: z.array(dependsOnEntry).optional(),
109
+ commands: z.record(z.string(), z.record(z.string(), commandDef)).optional(),
110
+ logs: z.record(z.string(), logSource).optional(),
111
+ health: z.record(z.string(), healthConfig).optional(),
112
+ // Open-in-new-tab: env → app URL, plus named extras. http(s) only.
113
+ urls: z.record(z.string(), httpUrlString).optional(),
114
+ links: z
115
+ .array(
116
+ z.object({
117
+ label: z.string().min(1),
118
+ urls: z.record(z.string(), httpUrlString),
119
+ }),
120
+ )
121
+ .optional(),
122
+ });
123
+
124
+ // ── Smoke / perf / synthetics ─────────────────────────────────────────────────
125
+
126
+ const validAuthRoles = ["admin", "user", "beta"] as const;
127
+
128
+ const smokeServiceConfig = z.object({
129
+ testDir: z.string(),
130
+ testMatch: z.string(),
131
+ baseUrlKey: z.enum(["api", "web"]),
132
+ auth: z.array(z.enum(validAuthRoles)),
133
+ fixtures: z.record(z.string(), z.unknown()).optional(),
134
+ });
135
+
136
+ const performanceServiceConfig = z.object({
137
+ enabled: z.boolean().optional(),
138
+ testDir: z.string(),
139
+ testMatch: z.string(),
140
+ baseUrlKey: z.enum(["api", "web"]),
141
+ service: z.string(),
142
+ tags: z.array(z.string()).optional(),
143
+ fixtures: z.record(z.string(), z.unknown()).optional(),
144
+ });
145
+
146
+ const syntheticsConfig = z.object({
147
+ testDir: z.string(),
148
+ testMatch: z.string(),
149
+ baseUrlKey: z.enum(["api", "web"]),
150
+ auth: z.array(z.enum(validAuthRoles)),
151
+ });
152
+
153
+ // Journeys (agent-driven E2E) — top-level like `synthetics:`, one spec home
154
+ // per repo. Deliberately thinner than syntheticsConfig: no baseUrlKey (the
155
+ // journeys runner resolves both web and api base URLs from env config) and
156
+ // no auth array (each journey spec's persona.role declares its own role).
157
+ // `app` describes the app under test (resources, session token, web-origin
158
+ // API feed): see src/pillars/journeys/app-config.ts.
159
+ const journeysConfig = z.object({
160
+ testDir: z.string(),
161
+ testMatch: z.string(),
162
+ app: journeysAppSchema.optional(),
163
+ });
164
+
165
+ // Security (ZAP scanning) — top-level record keyed by service name, like
166
+ // `smoke:`/`performance:`. Production may only ever list the passive
167
+ // `baseline` mode: the pillar caps it at runtime too, but a manifest that
168
+ // asks for more is rejected up front so the mistake is visible at validation.
169
+ const securityEnvironments = [
170
+ "local",
171
+ "dev",
172
+ "sandbox",
173
+ "staging",
174
+ "production",
175
+ ] as const;
176
+ const securityModes = ["baseline", "api", "full"] as const;
177
+
178
+ const securityServiceConfig = z
179
+ .object({
180
+ baseUrlKey: z.enum(["api", "web"]).optional(),
181
+ hosts: z
182
+ .partialRecord(
183
+ z.enum(securityEnvironments),
184
+ z.array(
185
+ z
186
+ .string()
187
+ .url()
188
+ .refine((u) => /^https?:\/\//.test(u), {
189
+ message: "host must be an http(s) URL",
190
+ }),
191
+ ),
192
+ )
193
+ .optional(),
194
+ openapi: z.string().min(1).optional(),
195
+ auth: z.array(z.enum(validAuthRoles)).optional(),
196
+ // Passed to ZAP's `-z` option, which splits on whitespace — a regex with
197
+ // a literal space would silently become two options, so reject it.
198
+ exclude: z
199
+ .array(
200
+ z
201
+ .string()
202
+ .min(1)
203
+ .refine((r) => !/\s/.test(r), {
204
+ message: "exclude regex must not contain whitespace (use \\s)",
205
+ })
206
+ .refine(
207
+ (r) => {
208
+ try {
209
+ new RegExp(r);
210
+ return true;
211
+ } catch {
212
+ return false;
213
+ }
214
+ },
215
+ { message: "exclude entry is not a valid regular expression" },
216
+ ),
217
+ )
218
+ .optional(),
219
+ modes: z
220
+ .partialRecord(
221
+ z.enum(securityEnvironments),
222
+ z.array(z.enum(securityModes)),
223
+ )
224
+ .optional(),
225
+ })
226
+ .refine((c) => c.baseUrlKey !== undefined || c.hosts !== undefined, {
227
+ message: 'one of "baseUrlKey" or "hosts" is required',
228
+ })
229
+ .refine(
230
+ (c) => (c.modes?.production ?? ["baseline"]).every((m) => m === "baseline"),
231
+ {
232
+ message: 'production may only allow the passive "baseline" mode',
233
+ path: ["modes", "production"],
234
+ },
235
+ );
236
+ // What is deployed where. Per Den environment, the first source that answers
237
+ // wins: `version` (a JSON endpoint; `field` names the commit SHA, default
238
+ // "build"), then `deployment` (a GitHub environment's deployments, made by
239
+ // `workflow`), then the newest successful `workflow` run on main.
240
+ const releaseSource = z.object({
241
+ version: z.string().optional(),
242
+ field: z.string().optional(),
243
+ deployment: z.string().optional(),
244
+ });
245
+ const releaseConfig = z.object({
246
+ workflow: z.string().optional(),
247
+ environments: z.record(z.string(), releaseSource).optional(),
248
+ });
249
+
250
+ // The repo's own test suites, run from its checkout. Local only unless
251
+ // `environments` says otherwise; the command gets DEN_ENV and BASE_URL.
252
+ const testSuite = z.object({
253
+ cmd: z.string(),
254
+ cwd: z.string().optional(),
255
+ label: z.string().optional(),
256
+ environments: z.array(z.string()).optional(),
257
+ });
258
+
259
+ const githubManifestConfig = z.object({
260
+ environments: z.record(z.string(), z.string()).optional(),
261
+ });
262
+
263
+ // Docker Compose repos run behind the edge (see src/shared/stacks/types.ts).
264
+ const composeConfig = z.object({
265
+ name: z.string().optional(),
266
+ dir: z.string().optional(),
267
+ files: z.array(z.string()).optional(),
268
+ routes: z.record(z.string(), z.string()).optional(),
269
+ generate: z.array(z.string()).optional(),
270
+ uses: z.array(z.string()).optional(),
271
+ env: z
272
+ .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
273
+ .optional(),
274
+ profiles: z.array(z.string()).optional(),
275
+ healthchecks: z.record(z.string(), z.array(z.string()).min(1)).optional(),
276
+ // service → env vars set on that service in the override (wins by key).
277
+ service_env: z
278
+ .record(
279
+ z.string(),
280
+ z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
281
+ )
282
+ .optional(),
283
+ });
284
+
285
+ // ── v1 manifest schema ────────────────────────────────────────────────────────
286
+
287
+ export const v1ManifestSchema = z.object({
288
+ // Accept any integer version — the validator handles known/unknown version
289
+ // warnings separately so that future versions don't fail schema validation.
290
+ version: z.number().int().positive().optional(),
291
+ name: z.string({ required_error: "name is required" }),
292
+ entrypoint: z.string().optional(),
293
+ services: z.record(z.string(), serviceManagementConfig).optional(),
294
+ smoke: z.record(z.string(), smokeServiceConfig).optional(),
295
+ performance: z.record(z.string(), performanceServiceConfig).optional(),
296
+ synthetics: syntheticsConfig.optional(),
297
+ journeys: journeysConfig.optional(),
298
+ security: z.record(z.string(), securityServiceConfig).optional(),
299
+ github: githubManifestConfig.optional(),
300
+ compose: composeConfig.optional(),
301
+ application: z.string().optional(),
302
+ kind: z.enum(["service", "module", "library"]).optional(),
303
+ contributes_to: z.array(z.string()).optional(),
304
+ release: releaseConfig.optional(),
305
+ tests: z.record(z.string(), testSuite).optional(),
306
+ });
307
+
308
+ export type V1Manifest = z.infer<typeof v1ManifestSchema>;
309
+
310
+ /** Map from version number to its Zod schema. */
311
+ export const schemaByVersion: Record<number, z.ZodTypeAny> = {
312
+ 1: v1ManifestSchema,
313
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Where a person opens a service, and which org a repo belongs to.
3
+ *
4
+ * A service's open URL for an environment is its manifest `urls.<env>`, else
5
+ * the origin of that environment's HTTP health URL (health checks point at
6
+ * /readyz or /api/health, never the app, so only the origin is kept). Shell
7
+ * health checks and services with no HTTP check have no URL.
8
+ */
9
+
10
+ import type {
11
+ Environment,
12
+ ServiceCatalog,
13
+ ServiceManagementConfig,
14
+ } from "../types/index.js";
15
+
16
+ export interface OpenLink {
17
+ label: string;
18
+ url: string;
19
+ }
20
+
21
+ function httpOrigin(raw: string | undefined): string | null {
22
+ if (!raw) return null;
23
+ try {
24
+ const u = new URL(raw);
25
+ if (u.protocol !== "https:" && u.protocol !== "http:") return null;
26
+ return `${u.origin}/`;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function httpUrl(raw: string | undefined): string | null {
33
+ if (!raw) return null;
34
+ try {
35
+ const u = new URL(raw);
36
+ return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function openUrlFor(
43
+ svc: ServiceManagementConfig | undefined,
44
+ env: Environment,
45
+ ): string | null {
46
+ if (!svc) return null;
47
+ const declared = httpUrl(svc.urls?.[env]);
48
+ if (declared) return declared;
49
+ const health = svc.health?.[env];
50
+ if (!health || ("type" in health && health.type === "shell")) return null;
51
+ return httpOrigin((health as { url?: string }).url);
52
+ }
53
+
54
+ export function linksFor(
55
+ svc: ServiceManagementConfig | undefined,
56
+ env: Environment,
57
+ ): OpenLink[] {
58
+ const out: OpenLink[] = [];
59
+ for (const l of svc?.links ?? []) {
60
+ const url = httpUrl(l.urls?.[env]);
61
+ if (url && l.label) out.push({ label: l.label, url });
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** The GitHub owner a repo is catalogued under ("acme"), or "". */
67
+ export function orgOf(repo: string, catalog: ServiceCatalog): string {
68
+ const gh = catalog[repo]?.gh ?? "";
69
+ const slash = gh.indexOf("/");
70
+ return slash > 0 ? gh.slice(0, slash) : "";
71
+ }
72
+
73
+ export interface OrgDisplay {
74
+ label?: string;
75
+ color?: string;
76
+ }
77
+
78
+ /** Every org in the catalog with its display name, configured ones first. */
79
+ export function listOrgs(
80
+ catalog: ServiceCatalog,
81
+ configured: Record<string, OrgDisplay> = {},
82
+ ): Array<{ id: string; label: string; color?: string }> {
83
+ const ids = new Set<string>(Object.keys(configured));
84
+ for (const repo of Object.keys(catalog)) {
85
+ const o = orgOf(repo, catalog);
86
+ if (o) ids.add(o);
87
+ }
88
+ return [...ids].map((id) => ({
89
+ id,
90
+ label: configured[id]?.label ?? id,
91
+ ...(configured[id]?.color ? { color: configured[id].color } : {}),
92
+ }));
93
+ }