@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,119 @@
1
+ import type {
2
+ Environment,
3
+ RawManifest,
4
+ ServiceHealthConfig,
5
+ } from "./types/index.js";
6
+
7
+ export interface ResolveManifestBaseUrlOptions {
8
+ manifest: RawManifest;
9
+ repoName: string;
10
+ serviceName: string;
11
+ environment: Environment;
12
+ baseUrlKey: "api" | "web";
13
+ }
14
+
15
+ export interface ResolveServiceBaseUrlOptions {
16
+ baseUrlKey: "api" | "web";
17
+ configuredUrl: string;
18
+ manifestUrl?: string | null;
19
+ overrideUrl?: string | null;
20
+ }
21
+
22
+ function healthUrlForConfig(healthConfig?: ServiceHealthConfig): string | null {
23
+ if (!healthConfig) {
24
+ return null;
25
+ }
26
+
27
+ if ("type" in healthConfig && healthConfig.type === "shell") {
28
+ return null;
29
+ }
30
+
31
+ return healthConfig.url;
32
+ }
33
+
34
+ function normalizeServiceBaseUrl(
35
+ healthUrl: string,
36
+ baseUrlKey: "api" | "web",
37
+ ): string {
38
+ if (baseUrlKey !== "api") {
39
+ return healthUrl;
40
+ }
41
+
42
+ const url = new URL(healthUrl);
43
+ url.pathname =
44
+ url.pathname.replace(/\/(healthz|readyz|version)\/?$/, "") || "/";
45
+
46
+ const normalized = url.toString();
47
+ return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
48
+ }
49
+
50
+ /**
51
+ * Derive a base URL from the manifest's `services.*.health.<env>` entry.
52
+ *
53
+ * Lookup order: `services[serviceName]`, then `services[repoName]`, then the
54
+ * manifest's declared `entrypoint`.
55
+ *
56
+ * The entrypoint step matters because `serviceName` here is the *pillar module*
57
+ * name (smoke passes `svcName` from `smoke.<name>`, perf passes
58
+ * `moduleConfig.service`), which need not match any `services:` key. A repo
59
+ * whose modules are named for what they test rather than for the service —
60
+ * `tier1`, `search`, `ingest` — matched neither of the first two lookups, so
61
+ * this returned null and callers silently fell back to
62
+ * `config/environments/<env>.json -> baseUrls.api`, i.e. shop's API. Repos
63
+ * appeared to work only when a module happened to share the service's name.
64
+ *
65
+ * `entrypoint` is precisely the manifest's statement of which service is the
66
+ * front door, and `service-runner.ts` already resolves an ambiguous subsystem
67
+ * the same way.
68
+ *
69
+ * For `baseUrlKey === "api"`, strips trailing `/healthz`, `/readyz`, or
70
+ * `/version` so the health endpoint becomes a usable API root.
71
+ */
72
+ export function resolveManifestBaseUrl({
73
+ manifest,
74
+ repoName,
75
+ serviceName,
76
+ environment,
77
+ baseUrlKey,
78
+ }: ResolveManifestBaseUrlOptions): string | null {
79
+ const entrypointConfig = manifest.entrypoint
80
+ ? manifest.services?.[manifest.entrypoint]
81
+ : undefined;
82
+ const serviceConfig =
83
+ manifest.services?.[serviceName] ??
84
+ manifest.services?.[repoName] ??
85
+ entrypointConfig;
86
+ const healthConfig = serviceConfig?.health?.[environment];
87
+ const healthUrl = healthUrlForConfig(healthConfig);
88
+ if (!healthUrl) {
89
+ return null;
90
+ }
91
+
92
+ return normalizeServiceBaseUrl(healthUrl, baseUrlKey);
93
+ }
94
+
95
+ /**
96
+ * Precedence-based URL resolver shared across pillars.
97
+ *
98
+ * 1. Explicit override (`BASE_API_URL` env, only for `baseUrlKey === "api"`)
99
+ * 2. Manifest-derived URL from `services.*.health.<env>`
100
+ * 3. Configured URL from `config/environments/<env>.json`
101
+ */
102
+ export function resolveServiceBaseUrl({
103
+ baseUrlKey,
104
+ configuredUrl,
105
+ manifestUrl,
106
+ overrideUrl,
107
+ }: ResolveServiceBaseUrlOptions): string {
108
+ const trimmedOverride = baseUrlKey === "api" ? overrideUrl?.trim() : "";
109
+ if (trimmedOverride) {
110
+ return trimmedOverride;
111
+ }
112
+
113
+ const trimmedManifestUrl = manifestUrl?.trim();
114
+ if (trimmedManifestUrl) {
115
+ return trimmedManifestUrl;
116
+ }
117
+
118
+ return configuredUrl;
119
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Deployed branch/SHA resolver
3
+ *
4
+ * Uses the GitHub Deployments API (via `gh`) to find the exact commit SHA
5
+ * that is currently deployed to a given environment. This is the authoritative
6
+ * source of truth — more precise than a branch name, and independent of what
7
+ * happens to be on the local machine.
8
+ *
9
+ * Flow:
10
+ * 1. GET /repos/{owner}/{repo}/deployments?environment={env}&per_page=20
11
+ * 2. Walk deployments newest-first, fetch each one's statuses
12
+ * 3. Return the first deployment whose latest status is "success"
13
+ *
14
+ * Why not just take deployment[0]? A deployment record is created when a
15
+ * deploy *starts*, not when it *succeeds*. If the most recent deploy is still
16
+ * in progress or failed, we want the last known-good one.
17
+ *
18
+ * Requires: `gh` CLI authenticated (`gh auth login`).
19
+ */
20
+
21
+ import { execSync } from "child_process";
22
+
23
+ export interface DeployedRef {
24
+ /** Exact commit SHA deployed to this environment. */
25
+ sha: string;
26
+ /** Branch the commit was deployed from (informational). */
27
+ ref: string;
28
+ /** GitHub environment name. */
29
+ environment: string;
30
+ /** ISO timestamp of the deployment. */
31
+ deployedAt: string;
32
+ /** GitHub deployment ID. */
33
+ deploymentId: number;
34
+ }
35
+
36
+ interface GhDeployment {
37
+ id: number;
38
+ sha: string;
39
+ ref: string;
40
+ environment: string;
41
+ created_at: string;
42
+ statuses_url: string;
43
+ }
44
+
45
+ interface GhDeploymentStatus {
46
+ state: string; // "success" | "failure" | "error" | "pending" | "in_progress" | "queued" | "inactive"
47
+ }
48
+
49
+ function gh(args: string): string {
50
+ try {
51
+ return execSync(`gh ${args}`, {
52
+ stdio: ["ignore", "pipe", "pipe"],
53
+ encoding: "utf-8",
54
+ }).trim();
55
+ } catch (err) {
56
+ const msg = (err as { stderr?: string }).stderr?.trim() ?? String(err);
57
+ throw new Error(`gh command failed: ${msg}`);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Fetch the latest deployment status state for a deployment.
63
+ * Returns null if no statuses exist yet (deployment just created).
64
+ */
65
+ function getLatestDeploymentState(
66
+ owner: string,
67
+ repo: string,
68
+ deploymentId: number,
69
+ ): string | null {
70
+ try {
71
+ const raw = gh(
72
+ `api "/repos/${owner}/${repo}/deployments/${deploymentId}/statuses?per_page=1"`,
73
+ );
74
+ const statuses: GhDeploymentStatus[] = JSON.parse(raw);
75
+ return statuses[0]?.state ?? null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Resolve the latest *successfully* deployed ref for a repo+environment.
83
+ *
84
+ * @param owner GitHub org/user (e.g. "acme")
85
+ * @param repo GitHub repo name (e.g. "api")
86
+ * @param environment GitHub environment name (e.g. "staging")
87
+ * @param limit Max deployments to inspect before giving up (default 20)
88
+ */
89
+ export async function resolveDeployedRef(
90
+ owner: string,
91
+ repo: string,
92
+ environment: string,
93
+ limit = 20,
94
+ ): Promise<DeployedRef> {
95
+ // Check gh is available and authenticated
96
+ try {
97
+ gh("auth status --active");
98
+ } catch {
99
+ throw new Error("GitHub CLI is not authenticated. Run: gh auth login");
100
+ }
101
+
102
+ const raw = gh(
103
+ `api "/repos/${owner}/${repo}/deployments?environment=${environment}&per_page=${limit}"`,
104
+ );
105
+ const deployments: GhDeployment[] = JSON.parse(raw);
106
+
107
+ if (!deployments.length) {
108
+ throw new Error(
109
+ `No deployments found for ${owner}/${repo} on environment "${environment}". ` +
110
+ `Has it been deployed via GitHub Actions?`,
111
+ );
112
+ }
113
+
114
+ // Walk newest-first, find the last successful one
115
+ for (const d of deployments) {
116
+ const state = getLatestDeploymentState(owner, repo, d.id);
117
+ if (state === "success") {
118
+ return {
119
+ sha: d.sha,
120
+ ref: d.ref,
121
+ environment: d.environment,
122
+ deployedAt: d.created_at,
123
+ deploymentId: d.id,
124
+ };
125
+ }
126
+ }
127
+
128
+ // No successful deployment found in the window — warn and use most recent
129
+ const latest = deployments[0];
130
+ console.warn(
131
+ ` ⚠ No successful deployment found for ${owner}/${repo}@${environment} ` +
132
+ `in the last ${limit} deploys. Using most recent (${latest.sha.slice(0, 8)}).`,
133
+ );
134
+ return {
135
+ sha: latest.sha,
136
+ ref: latest.ref,
137
+ environment: latest.environment,
138
+ deployedAt: latest.created_at,
139
+ deploymentId: latest.id,
140
+ };
141
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Git remote URL utilities
3
+ *
4
+ * Extracts the GitHub owner/repo from a git remote URL so the rest of the
5
+ * smoke pipeline can call the GitHub API without requiring this info to be
6
+ * hardcoded in .den.yml.
7
+ *
8
+ * Supported URL formats:
9
+ * https://github.com/acme/api.git
10
+ * https://github.com/acme/api
11
+ * git@github.com:acme/api.git
12
+ */
13
+
14
+ import { execSync } from "child_process";
15
+
16
+ export interface GitHubCoords {
17
+ owner: string;
18
+ repo: string;
19
+ }
20
+
21
+ /**
22
+ * Run `git remote get-url origin` inside the given directory.
23
+ * Returns the raw remote URL string, or null if the directory is not a git
24
+ * repo or has no origin remote.
25
+ */
26
+ export function getGitRemoteOrigin(repoPath: string): string | null {
27
+ try {
28
+ return execSync("git remote get-url origin", {
29
+ cwd: repoPath,
30
+ stdio: ["ignore", "pipe", "ignore"],
31
+ encoding: "utf-8",
32
+ }).trim();
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Parse GitHub owner and repo from a remote URL.
40
+ * Returns null if the URL is not a recognisable GitHub URL.
41
+ */
42
+ export function parseGitHubOwnerRepo(remoteUrl: string): GitHubCoords | null {
43
+ // HTTPS: https://github.com/owner/repo[.git]
44
+ const httpsMatch = remoteUrl.match(
45
+ /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/,
46
+ );
47
+ if (httpsMatch) {
48
+ return { owner: httpsMatch[1], repo: httpsMatch[2] };
49
+ }
50
+
51
+ // SSH: git@github.com:owner/repo[.git]
52
+ const sshMatch = remoteUrl.match(
53
+ /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/,
54
+ );
55
+ if (sshMatch) {
56
+ return { owner: sshMatch[1], repo: sshMatch[2] };
57
+ }
58
+
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Convenience: get GitHub coords for a local repo path.
64
+ * Returns null if the remote is absent or not a GitHub remote.
65
+ */
66
+ export function getGitHubCoords(repoPath: string): GitHubCoords | null {
67
+ const origin = getGitRemoteOrigin(repoPath);
68
+ if (!origin) return null;
69
+ return parseGitHubOwnerRepo(origin);
70
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Smoke test repo cloner
3
+ *
4
+ * Manages .smoke-clones/{repoName}/{environment}/ — shallow clones of
5
+ * registered repos checked out at the exact SHA deployed to each environment.
6
+ *
7
+ * Directory layout:
8
+ * .smoke-clones/
9
+ * api/
10
+ * staging/ ← shallow clone at deployed SHA
11
+ * .deployed-sha ← cached SHA (avoids redundant re-clones)
12
+ *
13
+ * Freshness strategy:
14
+ * - Read .deployed-sha from disk
15
+ * - If it matches the target SHA → already up to date, return path immediately
16
+ * - If it differs (or file absent) → fetch + checkout + update stamp
17
+ *
18
+ * Git authentication:
19
+ * Uses `gh auth git-credential` as the git credential helper so the
20
+ * existing `gh` session covers private repos without separate SSH setup.
21
+ */
22
+
23
+ import { execSync } from "child_process";
24
+ import * as fs from "fs";
25
+ import * as path from "path";
26
+ import type { DeployedRef } from "./branch-resolver.js";
27
+ import { getDistRoot } from "../paths.js";
28
+ import { assertSafeCloneDestination } from "../environment-manager/clone-guard.js";
29
+
30
+ // Anchored to the den root, NOT process.cwd() — this module CLONES repos,
31
+ // and a cwd-derived destination once bulk-cloned into an unrelated checkout.
32
+ const CLONES_DIR = path.resolve(getDistRoot(), ".smoke-clones");
33
+ const DEPLOYED_SHA_FILE = ".deployed-sha";
34
+
35
+ /** Absolute path to the clone directory for a repo+environment pair. */
36
+ export function cloneDirFor(repoName: string, environment: string): string {
37
+ return path.join(CLONES_DIR, repoName, environment);
38
+ }
39
+
40
+ function run(cmd: string, cwd?: string): void {
41
+ execSync(cmd, {
42
+ cwd,
43
+ stdio: "inherit",
44
+ env: {
45
+ ...process.env,
46
+ // Route git credentials through `gh` so private repos work without SSH
47
+ GIT_CREDENTIAL_HELPER: "!gh auth git-credential",
48
+ },
49
+ });
50
+ }
51
+
52
+ function runCapture(cmd: string, cwd?: string): string {
53
+ return execSync(cmd, {
54
+ cwd,
55
+ stdio: ["ignore", "pipe", "pipe"],
56
+ encoding: "utf-8",
57
+ env: {
58
+ ...process.env,
59
+ GIT_CREDENTIAL_HELPER: "!gh auth git-credential",
60
+ },
61
+ }).trim();
62
+ }
63
+
64
+ /**
65
+ * Ensure the repo is cloned at the given SHA.
66
+ * Returns the absolute path to the clone root.
67
+ *
68
+ * @param repoName Short name (matches .den.yml `name` field, e.g. "api")
69
+ * @param environment Den environment key (e.g. "staging") — used for path only
70
+ * @param originUrl Git remote URL to clone from
71
+ * @param ref Deployed ref from branch-resolver
72
+ */
73
+ export function ensureRepoAtSha(
74
+ repoName: string,
75
+ environment: string,
76
+ originUrl: string,
77
+ ref: DeployedRef,
78
+ ): string {
79
+ const cloneDir = cloneDirFor(repoName, environment);
80
+ const shaFile = path.join(cloneDir, DEPLOYED_SHA_FILE);
81
+
82
+ // Fast path: already at the right SHA
83
+ if (fs.existsSync(shaFile)) {
84
+ const cached = fs.readFileSync(shaFile, "utf-8").trim();
85
+ if (cached === ref.sha) {
86
+ console.log(
87
+ ` ✓ ${repoName}@${environment}: already at ${ref.sha.slice(0, 8)} (${ref.ref})`,
88
+ );
89
+ return cloneDir;
90
+ }
91
+ console.log(
92
+ ` ↻ ${repoName}@${environment}: updating ${cached.slice(0, 8)} → ${ref.sha.slice(0, 8)} (${ref.ref})`,
93
+ );
94
+ } else {
95
+ console.log(
96
+ ` ⬇ ${repoName}@${environment}: cloning at ${ref.sha.slice(0, 8)} (${ref.ref})`,
97
+ );
98
+ }
99
+
100
+ if (!fs.existsSync(cloneDir)) {
101
+ assertSafeCloneDestination(cloneDir, repoName);
102
+ // Initial clone — shallow, blobless (history not needed, just the tree)
103
+ fs.mkdirSync(cloneDir, { recursive: true });
104
+ try {
105
+ run(
106
+ `git clone --filter=blob:none --depth=1 --branch "${ref.ref}" ` +
107
+ `-c credential.helper="!gh auth git-credential" ` +
108
+ `"${originUrl}" "${cloneDir}"`,
109
+ );
110
+ } catch {
111
+ // Branch may not exist by that name (e.g. detached SHA) — try bare clone
112
+ fs.rmSync(cloneDir, { recursive: true, force: true });
113
+ fs.mkdirSync(cloneDir, { recursive: true });
114
+ run(
115
+ `git clone --filter=blob:none --depth=1 ` +
116
+ `-c credential.helper="!gh auth git-credential" ` +
117
+ `"${originUrl}" "${cloneDir}"`,
118
+ );
119
+ }
120
+ }
121
+
122
+ // Fetch the exact SHA (in case shallow clone landed on a different commit)
123
+ const currentSha = runCapture("git rev-parse HEAD", cloneDir);
124
+ if (currentSha !== ref.sha) {
125
+ try {
126
+ run(
127
+ `git -c credential.helper="!gh auth git-credential" ` +
128
+ `fetch --depth=1 origin "${ref.sha}"`,
129
+ cloneDir,
130
+ );
131
+ run(`git checkout "${ref.sha}"`, cloneDir);
132
+ } catch {
133
+ // Some git servers don't allow fetching by SHA directly — fall back to
134
+ // fetching the branch and checking out the SHA
135
+ run(
136
+ `git -c credential.helper="!gh auth git-credential" ` +
137
+ `fetch --depth=1 origin "${ref.ref}"`,
138
+ cloneDir,
139
+ );
140
+ run(`git checkout "${ref.sha}"`, cloneDir);
141
+ }
142
+ }
143
+
144
+ // Write stamp file
145
+ fs.writeFileSync(shaFile, ref.sha);
146
+
147
+ console.log(
148
+ ` ✓ ${repoName}@${environment}: ready at ${ref.sha.slice(0, 8)}`,
149
+ );
150
+ return cloneDir;
151
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Stacks come from two places:
3
+ * - a repo's own `.den.yml` `compose:` section (preferred), for every
4
+ * repo in the focused workspace;
5
+ * - `stacks:` in config/den.yml, for repos that don't carry a manifest.
6
+ * A manifest stack wins over a config stack of the same name.
7
+ */
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ import * as yaml from "yaml";
11
+ import { getDistRoot } from "../paths.js";
12
+ import { readCatalog } from "../environment-manager/catalog.js";
13
+ import {
14
+ getActiveWorkspace,
15
+ getWorkspaceByName,
16
+ listWorkspaceServices,
17
+ } from "../environment-manager/workspaces.js";
18
+ import { readManifest } from "../manifest/read-manifest.js";
19
+ import type { ManifestComposeConfig } from "../types/index.js";
20
+ import { assertStackName, parseTarget } from "./render.js";
21
+ import type { StackConfig } from "./types.js";
22
+
23
+ import { product } from "../product.js";
24
+ type Raw = Partial<Omit<StackConfig, "name">>;
25
+
26
+ function list(v: unknown, what: string): string[] {
27
+ if (v === undefined) return [];
28
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string"))
29
+ throw new Error(`${what} must be a list of strings`);
30
+ return v as string[];
31
+ }
32
+
33
+ function toStack(
34
+ name: string,
35
+ raw: Raw | ManifestComposeConfig,
36
+ repo: string,
37
+ repoPath: string | undefined,
38
+ where: string,
39
+ ): StackConfig {
40
+ assertStackName(name);
41
+ const routes = raw.routes ?? {};
42
+ Object.values(routes).forEach(parseTarget);
43
+ const files = list(raw.files, `${where}.files`);
44
+ return {
45
+ name,
46
+ repo,
47
+ ...(repoPath ? { repoPath } : {}),
48
+ dir: raw.dir,
49
+ files: files.length ? files : ["docker-compose.yml"],
50
+ routes,
51
+ generate: list(raw.generate, `${where}.generate`),
52
+ uses: list(raw.uses, `${where}.uses`),
53
+ env: Object.fromEntries(
54
+ Object.entries(raw.env ?? {}).map(([k, v]) => [k, String(v)]),
55
+ ),
56
+ profiles: list(raw.profiles, `${where}.profiles`),
57
+ ...("healthchecks" in raw && raw.healthchecks
58
+ ? { healthchecks: raw.healthchecks }
59
+ : {}),
60
+ ...("service_env" in raw && raw.service_env
61
+ ? {
62
+ serviceEnv: Object.fromEntries(
63
+ Object.entries(raw.service_env).map(([svc, vars]) => [
64
+ svc,
65
+ Object.fromEntries(
66
+ Object.entries(vars).map(([k, v]) => [k, String(v)]),
67
+ ),
68
+ ]),
69
+ ),
70
+ }
71
+ : {}),
72
+ };
73
+ }
74
+
75
+ /** Stack name for a repo's manifest compose section. */
76
+ export function manifestStackName(
77
+ manifest: { name: string; compose?: ManifestComposeConfig },
78
+ repoName: string,
79
+ ): string {
80
+ return manifest.compose?.name ?? manifest.name ?? repoName;
81
+ }
82
+
83
+ /** `compose:` stacks declared by repos in a workspace (default: focused). */
84
+ export function readManifestStacks(
85
+ workspace?: string,
86
+ ): Record<string, StackConfig> {
87
+ const ws = workspace ? getWorkspaceByName(workspace) : getActiveWorkspace();
88
+ if (!ws) return {};
89
+ const out: Record<string, StackConfig> = {};
90
+ const seen = new Set<string>();
91
+ for (const svc of listWorkspaceServices(ws.config, readCatalog(), ws.name)) {
92
+ if (seen.has(svc.repoPath) || !fs.existsSync(svc.repoPath)) continue;
93
+ seen.add(svc.repoPath);
94
+ const m = readManifest(svc.repoPath);
95
+ if (!m?.compose) continue;
96
+ const name = manifestStackName(m, svc.repoName);
97
+ out[name] = toStack(
98
+ name,
99
+ m.compose,
100
+ svc.repoName,
101
+ svc.repoPath,
102
+ `${svc.repoName}/${product().manifestYml} compose`,
103
+ );
104
+ }
105
+ return out;
106
+ }
107
+
108
+ export function readStacks(
109
+ root: string = getDistRoot(),
110
+ opts: { workspace?: string; manifests?: boolean } = {},
111
+ ): Record<string, StackConfig> {
112
+ let parsed: { stacks?: Record<string, Raw> } | null = null;
113
+ try {
114
+ parsed = yaml.parse(
115
+ fs.readFileSync(path.join(root, product().configFile), "utf-8"),
116
+ );
117
+ } catch (err) {
118
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
119
+ }
120
+ const out: Record<string, StackConfig> = {};
121
+ for (const [name, raw] of Object.entries(parsed?.stacks ?? {})) {
122
+ if (!raw?.repo) throw new Error(`stacks.${name}.repo is required`);
123
+ out[name] = toStack(name, raw, raw.repo, undefined, `stacks.${name}`);
124
+ }
125
+ if (opts.manifests !== false)
126
+ Object.assign(out, readManifestStacks(opts.workspace));
127
+
128
+ // `uses` may name a stack or the repo that declares it.
129
+ const byRepo = new Map(Object.values(out).map((s) => [s.repo, s.name]));
130
+ for (const s of Object.values(out)) {
131
+ s.uses = s.uses.map((u) => (out[u] ? u : (byRepo.get(u) ?? u)));
132
+ for (const u of s.uses)
133
+ if (!out[u])
134
+ throw new Error(`stack ${s.name}: uses names unknown stack "${u}"`);
135
+ }
136
+ return out;
137
+ }