@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,27 @@
1
+ /**
2
+ * `depends_on` entry normalization.
3
+ *
4
+ * A dep can be:
5
+ * - a string: "shop/db"
6
+ * - an object: { service: "shop/db", wait: true }
7
+ *
8
+ * The object form lets a service opt into blocking startup until the dep's
9
+ * health check is green (default behavior is the existing 5-second detach
10
+ * window — no change for unflagged deps).
11
+ */
12
+
13
+ import type { DependsOnEntry } from "../types/index.js";
14
+
15
+ export interface NormalizedDep {
16
+ service: string;
17
+ wait: boolean;
18
+ }
19
+
20
+ export function depService(dep: DependsOnEntry): string {
21
+ return typeof dep === "string" ? dep : dep.service;
22
+ }
23
+
24
+ export function normalizeDep(dep: DependsOnEntry): NormalizedDep {
25
+ if (typeof dep === "string") return { service: dep, wait: false };
26
+ return { service: dep.service, wait: dep.wait ?? false };
27
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * .den.yml schema migration engine.
3
+ *
4
+ * Walks the migration chain from the manifest's current version up to
5
+ * the requested target version (default: CURRENT_SCHEMA_VERSION).
6
+ *
7
+ * Each step is a pure function: migrate_N_to_M(manifest) → manifest
8
+ * Adding a new version means:
9
+ * 1. Bump CURRENT_SCHEMA_VERSION
10
+ * 2. Add migrate_<old>_to_<new> to the MIGRATION_STEPS map
11
+ * 3. Add the new version to schema.ts KNOWN_VERSIONS + schemaByVersion
12
+ */
13
+
14
+ import type { RawManifest, MigrationResult } from "../types/index.js";
15
+ import { CURRENT_SCHEMA_VERSION } from "./schema.js";
16
+
17
+ // ── Migration step registry ───────────────────────────────────────────────────
18
+
19
+ type MigrationFn = (manifest: RawManifest) => {
20
+ manifest: RawManifest;
21
+ change: string;
22
+ };
23
+
24
+ /**
25
+ * Migration steps keyed by "fromVersion".
26
+ * migrate_1_to_2 lives at key 1, migrate_2_to_3 at key 2, etc.
27
+ *
28
+ * Currently empty — v1 is the only version.
29
+ * When v2 is introduced, add:
30
+ * [1]: migrate_1_to_2,
31
+ */
32
+ const MIGRATION_STEPS: Record<number, MigrationFn> = {
33
+ // Future migrations go here, e.g.:
34
+ // [1]: migrate_1_to_2,
35
+ };
36
+
37
+ // ── Public API ────────────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Migrate a manifest to the target schema version.
41
+ *
42
+ * @param input - A parsed manifest object (or any plain object).
43
+ * @param toVersion - Target version. Defaults to CURRENT_SCHEMA_VERSION.
44
+ * @returns MigrationResult with the migrated manifest and a log of changes.
45
+ * @throws When toVersion is unknown / unsupported.
46
+ */
47
+ export function migrateManifest(
48
+ input: Record<string, unknown> | RawManifest,
49
+ toVersion: number = CURRENT_SCHEMA_VERSION,
50
+ ): MigrationResult {
51
+ // Validate target version
52
+ if (toVersion > CURRENT_SCHEMA_VERSION) {
53
+ throw new Error(
54
+ `Cannot migrate to unsupported schema version ${toVersion}. ` +
55
+ `Latest known version is ${CURRENT_SCHEMA_VERSION}.`,
56
+ );
57
+ }
58
+
59
+ const fromVersion =
60
+ typeof (input as Record<string, unknown>).version === "number"
61
+ ? ((input as Record<string, unknown>).version as number)
62
+ : 1;
63
+
64
+ // Never silently downgrade: re-stamping a lower version onto newer-schema
65
+ // content corrupts the manifest. Fail fast, mirroring the forward guard above.
66
+ if (fromVersion > toVersion) {
67
+ throw new Error(
68
+ `Cannot downgrade manifest from v${fromVersion} to v${toVersion}.`,
69
+ );
70
+ }
71
+
72
+ const changes: string[] = [];
73
+ let current: RawManifest = { ...(input as RawManifest) };
74
+
75
+ // Walk the chain: fromVersion → fromVersion+1 → … → toVersion
76
+ for (let v = fromVersion; v < toVersion; v++) {
77
+ const step = MIGRATION_STEPS[v];
78
+ if (!step) {
79
+ throw new Error(
80
+ `No migration step found from v${v} to v${v + 1}. ` +
81
+ `This is a bug — please file an issue.`,
82
+ );
83
+ }
84
+ const { manifest: next, change } = step(current);
85
+ current = next;
86
+ changes.push(change);
87
+ }
88
+
89
+ // Stamp the output with the target version. RawManifest has no `version`
90
+ // field (absence means v1), so the stamp is applied as an extra property.
91
+ current = { ...current, version: toVersion } as RawManifest;
92
+
93
+ return {
94
+ manifest: current,
95
+ fromVersion,
96
+ toVersion,
97
+ changes,
98
+ };
99
+ }
100
+
101
+ export { CURRENT_SCHEMA_VERSION };
@@ -0,0 +1,160 @@
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
+
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+ import { execFileSync } from "child_process";
16
+ import { getDistRoot } from "../paths.js";
17
+ import { parse as parseYaml } from "yaml";
18
+ import type { RawManifest } from "../types/index.js";
19
+
20
+ import { product } from "../product.js";
21
+ /** Names probed in order. First match wins. */
22
+ const MANIFEST_NAMES = [
23
+ `${product().manifestYml}`,
24
+ `${product().manifestJson}`,
25
+ ] as const;
26
+
27
+ /** The repo's own manifest file, or null. */
28
+ export function repoManifestPath(repoPath: string): string | null {
29
+ for (const name of MANIFEST_NAMES) {
30
+ const p = path.join(repoPath, name);
31
+ if (fs.existsSync(p)) return p;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ // ── Den-held manifests ───────────────────────────────────────────────────
37
+ // For a repo that doesn't carry a manifest yet (one we don't commit to, or
38
+ // whose PR hasn't merged), den keeps one in manifests/<repo>.den.yml,
39
+ // keyed by the git origin's repo name (falling back to the folder name). The
40
+ // repo's own file always wins.
41
+
42
+ const originCache = new Map<string, string | null>();
43
+
44
+ /** Repo name of `repoPath`'s git origin (`…/<name>.git`), cached. */
45
+ export function originRepoName(repoPath: string): string | null {
46
+ if (originCache.has(repoPath)) return originCache.get(repoPath)!;
47
+ let name: string | null = null;
48
+ try {
49
+ const url = execFileSync(
50
+ "git",
51
+ ["-C", repoPath, "config", "--get", "remote.origin.url"],
52
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 },
53
+ ).trim();
54
+ name =
55
+ url
56
+ .replace(/\.git$/, "")
57
+ .split(/[/:]/)
58
+ .pop() || null;
59
+ } catch {
60
+ /* not a git checkout, or no origin */
61
+ }
62
+ originCache.set(repoPath, name);
63
+ return name;
64
+ }
65
+
66
+ /** Directory of den-held manifests (override: DEN_MANIFESTS_DIR). */
67
+ export function heldManifestsDir(): string {
68
+ return process.env.DEN_MANIFESTS_DIR ?? path.join(getDistRoot(), "manifests");
69
+ }
70
+
71
+ /** The repo name of a `.den-services/<repo>/<branch>` path, else null. */
72
+ function managedCloneRepo(repoPath: string): string | null {
73
+ const parent = path.dirname(repoPath);
74
+ return path.basename(path.dirname(parent)) === `${product().servicesDir}`
75
+ ? path.basename(parent)
76
+ : null;
77
+ }
78
+
79
+ /** The den-held manifest for `repoPath`, or null. */
80
+ export function heldManifestPath(repoPath: string): string | null {
81
+ const keys = [
82
+ originRepoName(repoPath),
83
+ // A managed clone lives at .den-services/<repo>/<branch>,
84
+ // and need not be cloned yet.
85
+ managedCloneRepo(repoPath),
86
+ path.basename(repoPath),
87
+ ];
88
+ for (const key of keys) {
89
+ if (!key) continue;
90
+ const p = path.join(heldManifestsDir(), `${key}${product().manifestYml}`);
91
+ if (fs.existsSync(p)) return p;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Resolve the manifest for `repoPath`: the repo's own .den.yml (or
98
+ * .json), else the one den holds for it. Returns null if neither exists.
99
+ */
100
+ export function resolveManifestPath(repoPath: string): string | null {
101
+ return repoManifestPath(repoPath) ?? heldManifestPath(repoPath);
102
+ }
103
+
104
+ /** Where `repoPath`'s manifest comes from. */
105
+ export function manifestSource(repoPath: string): "repo" | "held" | null {
106
+ if (repoManifestPath(repoPath)) return "repo";
107
+ return heldManifestPath(repoPath) ? "held" : null;
108
+ }
109
+
110
+ /**
111
+ * Read and parse the manifest from `repoPath`.
112
+ * Supports both YAML and JSON.
113
+ * Returns null if no manifest file exists or parsing fails.
114
+ */
115
+ export function readManifest(repoPath: string): RawManifest | null {
116
+ const filePath = resolveManifestPath(repoPath);
117
+ if (!filePath) return null;
118
+ try {
119
+ const raw = fs.readFileSync(filePath, "utf-8");
120
+ const parsed = filePath.endsWith(".yml") ? parseYaml(raw) : JSON.parse(raw);
121
+ if (
122
+ parsed === null ||
123
+ typeof parsed !== "object" ||
124
+ Array.isArray(parsed)
125
+ ) {
126
+ return null; // scalar/array/empty manifest is not usable
127
+ }
128
+ return parsed as RawManifest;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Async version of readManifest.
136
+ */
137
+ export async function readManifestAsync(
138
+ repoPath: string,
139
+ ): Promise<RawManifest | null> {
140
+ const filePath = resolveManifestPath(repoPath);
141
+ if (!filePath) return null;
142
+ try {
143
+ const { readFile } = await import("fs/promises");
144
+ const raw = await readFile(filePath, "utf-8");
145
+ const parsed = filePath.endsWith(".yml") ? parseYaml(raw) : JSON.parse(raw);
146
+ if (
147
+ parsed === null ||
148
+ typeof parsed !== "object" ||
149
+ Array.isArray(parsed)
150
+ ) {
151
+ return null; // scalar/array/empty manifest is not usable
152
+ }
153
+ return parsed as RawManifest;
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /** The canonical manifest filename for display/error messages. */
160
+ export const MANIFEST_DISPLAY_NAME = `${product().manifestYml}`;
@@ -0,0 +1,299 @@
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
+ const serviceManagementConfig = z.object({
94
+ depends_on: z.array(dependsOnEntry).optional(),
95
+ commands: z.record(z.string(), z.record(z.string(), commandDef)).optional(),
96
+ logs: z.record(z.string(), logSource).optional(),
97
+ health: z.record(z.string(), healthConfig).optional(),
98
+ // Open-in-new-tab: env → app URL, plus named extras. http(s) only.
99
+ urls: z.record(z.string(), httpUrlString).optional(),
100
+ links: z
101
+ .array(
102
+ z.object({
103
+ label: z.string().min(1),
104
+ urls: z.record(z.string(), httpUrlString),
105
+ }),
106
+ )
107
+ .optional(),
108
+ });
109
+
110
+ // ── Smoke / perf / synthetics ─────────────────────────────────────────────────
111
+
112
+ const validAuthRoles = ["admin", "user", "beta"] as const;
113
+
114
+ const smokeServiceConfig = z.object({
115
+ testDir: z.string(),
116
+ testMatch: z.string(),
117
+ baseUrlKey: z.enum(["api", "web"]),
118
+ auth: z.array(z.enum(validAuthRoles)),
119
+ fixtures: z.record(z.string(), z.unknown()).optional(),
120
+ });
121
+
122
+ const performanceServiceConfig = z.object({
123
+ enabled: z.boolean().optional(),
124
+ testDir: z.string(),
125
+ testMatch: z.string(),
126
+ baseUrlKey: z.enum(["api", "web"]),
127
+ service: z.string(),
128
+ tags: z.array(z.string()).optional(),
129
+ fixtures: z.record(z.string(), z.unknown()).optional(),
130
+ });
131
+
132
+ const syntheticsConfig = z.object({
133
+ testDir: z.string(),
134
+ testMatch: z.string(),
135
+ baseUrlKey: z.enum(["api", "web"]),
136
+ auth: z.array(z.enum(validAuthRoles)),
137
+ });
138
+
139
+ // Journeys (agent-driven E2E) — top-level like `synthetics:`, one spec home
140
+ // per repo. Deliberately thinner than syntheticsConfig: no baseUrlKey (the
141
+ // journeys runner resolves both web and api base URLs from env config) and
142
+ // no auth array (each journey spec's persona.role declares its own role).
143
+ // `app` describes the app under test (resources, session token, web-origin
144
+ // API feed): see src/pillars/journeys/app-config.ts.
145
+ const journeysConfig = z.object({
146
+ testDir: z.string(),
147
+ testMatch: z.string(),
148
+ app: journeysAppSchema.optional(),
149
+ });
150
+
151
+ // Security (ZAP scanning) — top-level record keyed by service name, like
152
+ // `smoke:`/`performance:`. Production may only ever list the passive
153
+ // `baseline` mode: the pillar caps it at runtime too, but a manifest that
154
+ // asks for more is rejected up front so the mistake is visible at validation.
155
+ const securityEnvironments = [
156
+ "local",
157
+ "dev",
158
+ "sandbox",
159
+ "staging",
160
+ "production",
161
+ ] as const;
162
+ const securityModes = ["baseline", "api", "full"] as const;
163
+
164
+ const securityServiceConfig = z
165
+ .object({
166
+ baseUrlKey: z.enum(["api", "web"]).optional(),
167
+ hosts: z
168
+ .partialRecord(
169
+ z.enum(securityEnvironments),
170
+ z.array(
171
+ z
172
+ .string()
173
+ .url()
174
+ .refine((u) => /^https?:\/\//.test(u), {
175
+ message: "host must be an http(s) URL",
176
+ }),
177
+ ),
178
+ )
179
+ .optional(),
180
+ openapi: z.string().min(1).optional(),
181
+ auth: z.array(z.enum(validAuthRoles)).optional(),
182
+ // Passed to ZAP's `-z` option, which splits on whitespace — a regex with
183
+ // a literal space would silently become two options, so reject it.
184
+ exclude: z
185
+ .array(
186
+ z
187
+ .string()
188
+ .min(1)
189
+ .refine((r) => !/\s/.test(r), {
190
+ message: "exclude regex must not contain whitespace (use \\s)",
191
+ })
192
+ .refine(
193
+ (r) => {
194
+ try {
195
+ new RegExp(r);
196
+ return true;
197
+ } catch {
198
+ return false;
199
+ }
200
+ },
201
+ { message: "exclude entry is not a valid regular expression" },
202
+ ),
203
+ )
204
+ .optional(),
205
+ modes: z
206
+ .partialRecord(
207
+ z.enum(securityEnvironments),
208
+ z.array(z.enum(securityModes)),
209
+ )
210
+ .optional(),
211
+ })
212
+ .refine((c) => c.baseUrlKey !== undefined || c.hosts !== undefined, {
213
+ message: 'one of "baseUrlKey" or "hosts" is required',
214
+ })
215
+ .refine(
216
+ (c) => (c.modes?.production ?? ["baseline"]).every((m) => m === "baseline"),
217
+ {
218
+ message: 'production may only allow the passive "baseline" mode',
219
+ path: ["modes", "production"],
220
+ },
221
+ );
222
+ // What is deployed where. Per Den environment, the first source that answers
223
+ // wins: `version` (a JSON endpoint; `field` names the commit SHA, default
224
+ // "build"), then `deployment` (a GitHub environment's deployments, made by
225
+ // `workflow`), then the newest successful `workflow` run on main.
226
+ const releaseSource = z.object({
227
+ version: z.string().optional(),
228
+ field: z.string().optional(),
229
+ deployment: z.string().optional(),
230
+ });
231
+ const releaseConfig = z.object({
232
+ workflow: z.string().optional(),
233
+ environments: z.record(z.string(), releaseSource).optional(),
234
+ });
235
+
236
+ // The repo's own test suites, run from its checkout. Local only unless
237
+ // `environments` says otherwise; the command gets DEN_ENV and BASE_URL.
238
+ const testSuite = z.object({
239
+ cmd: z.string(),
240
+ cwd: z.string().optional(),
241
+ label: z.string().optional(),
242
+ environments: z.array(z.string()).optional(),
243
+ });
244
+
245
+ const githubManifestConfig = z.object({
246
+ environments: z.record(z.string(), z.string()).optional(),
247
+ });
248
+
249
+ // Docker Compose repos run behind the edge (see src/shared/stacks/types.ts).
250
+ const composeConfig = z.object({
251
+ name: z.string().optional(),
252
+ dir: z.string().optional(),
253
+ files: z.array(z.string()).optional(),
254
+ routes: z.record(z.string(), z.string()).optional(),
255
+ generate: z.array(z.string()).optional(),
256
+ uses: z.array(z.string()).optional(),
257
+ env: z
258
+ .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
259
+ .optional(),
260
+ profiles: z.array(z.string()).optional(),
261
+ healthchecks: z.record(z.string(), z.array(z.string()).min(1)).optional(),
262
+ // service → env vars set on that service in the override (wins by key).
263
+ service_env: z
264
+ .record(
265
+ z.string(),
266
+ z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
267
+ )
268
+ .optional(),
269
+ });
270
+
271
+ // ── v1 manifest schema ────────────────────────────────────────────────────────
272
+
273
+ export const v1ManifestSchema = z.object({
274
+ // Accept any integer version — the validator handles known/unknown version
275
+ // warnings separately so that future versions don't fail schema validation.
276
+ version: z.number().int().positive().optional(),
277
+ name: z.string({ required_error: "name is required" }),
278
+ entrypoint: z.string().optional(),
279
+ services: z.record(z.string(), serviceManagementConfig).optional(),
280
+ smoke: z.record(z.string(), smokeServiceConfig).optional(),
281
+ performance: z.record(z.string(), performanceServiceConfig).optional(),
282
+ synthetics: syntheticsConfig.optional(),
283
+ journeys: journeysConfig.optional(),
284
+ security: z.record(z.string(), securityServiceConfig).optional(),
285
+ github: githubManifestConfig.optional(),
286
+ compose: composeConfig.optional(),
287
+ application: z.string().optional(),
288
+ kind: z.enum(["service", "module", "library"]).optional(),
289
+ contributes_to: z.array(z.string()).optional(),
290
+ release: releaseConfig.optional(),
291
+ tests: z.record(z.string(), testSuite).optional(),
292
+ });
293
+
294
+ export type V1Manifest = z.infer<typeof v1ManifestSchema>;
295
+
296
+ /** Map from version number to its Zod schema. */
297
+ export const schemaByVersion: Record<number, z.ZodTypeAny> = {
298
+ 1: v1ManifestSchema,
299
+ };
@@ -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
+ }