@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,855 @@
1
+ /**
2
+ * den-init — interactive wizard to scaffold a new .den.yml.
3
+ *
4
+ * Exported pure functions (buildInitManifest, detectStackConfig,
5
+ * formatManifestYaml) are unit-tested independently of the interactive flow.
6
+ * The runInit() function drives the interactive CLI prompts.
7
+ */
8
+
9
+ import * as fs from "fs";
10
+ import * as path from "path";
11
+ import * as readline from "readline";
12
+ import { execSync } from "child_process";
13
+ import * as yaml from "yaml";
14
+ import type { RawManifest, Environment } from "../../shared/types/index.js";
15
+ import { listMakeTargets } from "../../shared/environment-manager/make-targets.js";
16
+ import { readDenConfig } from "../../shared/den-config.js";
17
+ import { getInvokeCwd } from "../../shared/paths.js";
18
+ import { loadAwsProfiles } from "../../shared/environment/aws-profiles.js";
19
+
20
+ import { product } from "../../shared/product.js";
21
+ // ── Types ─────────────────────────────────────────────────────────────────────
22
+
23
+ export interface ServiceInitConfig {
24
+ name: string;
25
+ healthUrl?: string;
26
+ healthCmd?: string;
27
+ }
28
+
29
+ export interface InitOptions {
30
+ name: string;
31
+ services: ServiceInitConfig[];
32
+ entrypoint?: string;
33
+ /**
34
+ * Standard make targets actually present in the repo root Makefile. When given,
35
+ * `commands.local` reflects the real targets (adding `setup`, dropping absent
36
+ * ones) instead of a blind up/down/restart default.
37
+ */
38
+ makeTargets?: string[];
39
+ /**
40
+ * Non-local health-URL templates (config `env_health_urls`), `{repo}`
41
+ * substituted with `name`. Applied to the PRIMARY service only (the single
42
+ * service, or the entrypoint of a multi-service repo) — deployed ALBs exist
43
+ * per system, not per sidecar. These feed the TEST scope: smoke/perf resolve
44
+ * each env's base URL from the manifest's `health.<env>.url`.
45
+ */
46
+ envHealthUrls?: Record<string, string>;
47
+ /**
48
+ * CloudWatch log groups detected per deployed env (aws describe-log-groups).
49
+ * Applied to the PRIMARY service only; feeds the /logs page's env dropdown.
50
+ */
51
+ envLogGroups?: Record<string, string>;
52
+ }
53
+
54
+ /**
55
+ * The manifest shape this wizard writes: a RawManifest plus the explicit
56
+ * `version` header field (serialised first by formatManifestYaml). RawManifest
57
+ * itself has no `version` — the parser treats absence as version 1.
58
+ */
59
+ export type InitManifest = RawManifest & { version: number };
60
+
61
+ export interface StackDetectionResult {
62
+ services: string[];
63
+ ports?: Record<string, number>;
64
+ /**
65
+ * Repo-level local port from the stack v2 `config.local.PORT` (falling back to
66
+ * `config.defaults.PORT`). This is the port the service listens on inside its
67
+ * opctl container in local dev — the basis for the local health-check URL.
68
+ */
69
+ localPort?: number;
70
+ /** Project language (e.g. "go", "python"). From stack.yml v2 `language` field. */
71
+ language?: string;
72
+ }
73
+
74
+ // ── detectStackConfig ─────────────────────────────────────────────────────────
75
+
76
+ /**
77
+ * Check if the target directory has an stack.yml (or .stack.yml) file and
78
+ * extract service info + optional language field.
79
+ * Checks: stack.yml → .stack.yml → .stack.yaml (first found wins).
80
+ * Returns null when no usable file is found.
81
+ */
82
+ export function detectStackConfig(
83
+ repoPath: string,
84
+ ): StackDetectionResult | null {
85
+ const candidates = [
86
+ path.join(repoPath, "stack.yml"),
87
+ path.join(repoPath, ".stack.yml"),
88
+ path.join(repoPath, ".stack.yaml"),
89
+ ];
90
+
91
+ const found = candidates.find((p) => fs.existsSync(p));
92
+ if (!found) return null;
93
+
94
+ try {
95
+ const raw = fs.readFileSync(found, "utf-8") as string;
96
+ const parsed = yaml.parse(raw) as Record<string, unknown> | null;
97
+ if (!parsed || typeof parsed !== "object") return null;
98
+
99
+ // stack v1 shape: a top-level `services:` map with per-service `port`.
100
+ let serviceNames: string[] = [];
101
+ let ports: Record<string, number> | undefined;
102
+ if (parsed.services && typeof parsed.services === "object") {
103
+ const servicesBlock = parsed.services as Record<
104
+ string,
105
+ Record<string, unknown>
106
+ >;
107
+ serviceNames = Object.keys(servicesBlock);
108
+ const collected: Record<string, number> = {};
109
+ for (const [svcName, svcConfig] of Object.entries(servicesBlock)) {
110
+ if (typeof svcConfig?.port === "number") {
111
+ collected[svcName] = svcConfig.port;
112
+ }
113
+ }
114
+ if (Object.keys(collected).length > 0) ports = collected;
115
+ }
116
+
117
+ // stack v2 shape: the local port lives under `config.local.PORT` (a repo-level
118
+ // env var), falling back to `config.defaults.PORT`. yaml.parse does NOT resolve
119
+ // the `<<: *defaults` merge, so we read both explicitly.
120
+ const localPort = extractLocalPort(parsed);
121
+
122
+ const language =
123
+ typeof parsed.language === "string" ? parsed.language : undefined;
124
+
125
+ // Nothing usable found → behave as before (no detection).
126
+ if (
127
+ serviceNames.length === 0 &&
128
+ localPort === undefined &&
129
+ language === undefined
130
+ ) {
131
+ return null;
132
+ }
133
+
134
+ return {
135
+ services: serviceNames,
136
+ ports,
137
+ localPort,
138
+ language,
139
+ };
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Extract the local listen port from an stack v2 config block. Reads
147
+ * `config.local.PORT` first, then `config.defaults.PORT` (yaml.parse does not
148
+ * resolve the `<<: *defaults` merge, so an env that inherits PORT from defaults
149
+ * has no explicit `config.local.PORT`). PORT may be a number or a string.
150
+ */
151
+ function extractLocalPort(parsed: Record<string, unknown>): number | undefined {
152
+ const config = parsed.config as Record<string, unknown> | undefined;
153
+ if (!config || typeof config !== "object") return undefined;
154
+ const local = config.local as Record<string, unknown> | undefined;
155
+ const defaults = config.defaults as Record<string, unknown> | undefined;
156
+ const raw = local && local.PORT !== undefined ? local.PORT : defaults?.PORT;
157
+ return coerceNumber(raw);
158
+ }
159
+
160
+ function coerceNumber(value: unknown): number | undefined {
161
+ if (typeof value === "number" && Number.isFinite(value)) return value;
162
+ if (typeof value === "string" && value.trim() !== "") {
163
+ const n = Number(value);
164
+ if (Number.isFinite(n)) return n;
165
+ }
166
+ return undefined;
167
+ }
168
+
169
+ /**
170
+ * Default local health-check URL following the opctl DNS convention: opctl
171
+ * exposes each service on the host under a DNS name derived from stack.yml's
172
+ * `name`, routed to port 80. So when the local port is 80 (the opctl default)
173
+ * we use the bare-name form (`http://<name>/healthz`); a non-80 port is appended
174
+ * explicitly (`http://<name>:<port>/healthz`). This is what den's host-side
175
+ * health check actually reaches — NOT `http://localhost:<port>`, which only works
176
+ * if the dev op publishes a host port (most do not).
177
+ */
178
+ export function defaultLocalHealthUrl(
179
+ repoName: string,
180
+ localPort?: number,
181
+ ): string {
182
+ const port = localPort ?? 80;
183
+ const host = port === 80 ? repoName : `${repoName}:${port}`;
184
+ return `http://${host}/healthz`;
185
+ }
186
+
187
+ // ── Deployment evidence (pulumi) ──────────────────────────────────────────────
188
+
189
+ export interface PulumiEnvInfo {
190
+ /** Public app subdomain (e.g. "app" → app.dev.example.dev). */
191
+ appSubdomain?: string;
192
+ /** Internal-ALB marker (e.g. "control-plane-dev"). */
193
+ albSubdomain?: string;
194
+ /** The repo's OWN declared health path (alb_health_check_path). */
195
+ healthPath?: string;
196
+ }
197
+
198
+ /**
199
+ * Read deployment evidence from the repo's pulumi stack configs instead of
200
+ * assuming: `alb_health_check_path` is the repo's own declared health path;
201
+ * `app_subdomain` marks a PUBLIC app (app.<env>.example.dev); `alb_subdomain`
202
+ * without an app subdomain marks an internal-ALB service. Keys are namespaced
203
+ * ("PlatformApp:app_subdomain"), so we match on suffix.
204
+ */
205
+ export function detectPulumiDeployment(
206
+ targetDir: string,
207
+ ): Record<string, PulumiEnvInfo> {
208
+ const files: Record<string, string> = {
209
+ dev: "Pulumi.dev.yaml",
210
+ sandbox: "Pulumi.sandbox.yaml",
211
+ staging: "Pulumi.staging.yaml",
212
+ production: "Pulumi.prod.yaml",
213
+ };
214
+ const envs: Record<string, PulumiEnvInfo> = {};
215
+ for (const [env, file] of Object.entries(files)) {
216
+ const p = path.join(targetDir, "pulumi", file);
217
+ try {
218
+ if (!fs.existsSync(p)) continue;
219
+ const parsed = yaml.parse(fs.readFileSync(p, "utf-8")) as Record<
220
+ string,
221
+ unknown
222
+ > | null;
223
+ const cfg = (
224
+ parsed && typeof parsed.config === "object" ? parsed.config : parsed
225
+ ) as Record<string, unknown> | null;
226
+ if (!cfg) continue;
227
+ const bySuffix = (suffix: string): string | undefined => {
228
+ for (const [k, v] of Object.entries(cfg)) {
229
+ if (k.endsWith(`:${suffix}`) && typeof v === "string") return v;
230
+ }
231
+ return undefined;
232
+ };
233
+ const info: PulumiEnvInfo = {
234
+ appSubdomain: bySuffix("app_subdomain"),
235
+ albSubdomain: bySuffix("alb_subdomain"),
236
+ healthPath: bySuffix("alb_health_check_path"),
237
+ };
238
+ if (info.appSubdomain || info.albSubdomain || info.healthPath) {
239
+ envs[env] = info;
240
+ }
241
+ } catch {
242
+ /* unparseable stack file — no evidence for this env */
243
+ }
244
+ }
245
+ return envs;
246
+ }
247
+
248
+ /** "https://{repo}.internal.dev.example.dev/healthz" → "dev.example.dev" (env domain). */
249
+ function envDomainFromTemplate(template: string): string | null {
250
+ try {
251
+ const host = new URL(template.replaceAll("{repo}", "x")).hostname;
252
+ const i = host.indexOf(".internal.");
253
+ return i >= 0 ? host.slice(i + ".internal.".length) : null;
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Build deployed-env health URLs from EVIDENCE only — envs with no pulumi
261
+ * markers get nothing (no fabricated hosts). Public apps (`app_subdomain`) get
262
+ * `https://<sub>.<env-domain><path>`; internal-ALB services get the config
263
+ * template with the declared health path. The declared path wins; else the
264
+ * locally detected path; else /healthz.
265
+ */
266
+ export function buildEnvHealthUrls(
267
+ name: string,
268
+ pulumiEnvs: Record<string, PulumiEnvInfo>,
269
+ templates: Record<string, string> | undefined,
270
+ localPathHint?: string,
271
+ ): Record<string, string> {
272
+ const urls: Record<string, string> = {};
273
+ for (const [env, info] of Object.entries(pulumiEnvs)) {
274
+ const template = templates?.[env];
275
+ const healthPath = info.healthPath ?? localPathHint ?? "/healthz";
276
+ if (info.appSubdomain) {
277
+ const domain = template ? envDomainFromTemplate(template) : null;
278
+ if (domain)
279
+ urls[env] = `https://${info.appSubdomain}.${domain}${healthPath}`;
280
+ } else if (info.albSubdomain && template) {
281
+ const u = new URL(template.replaceAll("{repo}", name));
282
+ u.pathname = healthPath;
283
+ urls[env] = u.toString();
284
+ }
285
+ }
286
+ return urls;
287
+ }
288
+
289
+ // ── CloudWatch log-group detection (deployed envs) ────────────────────────────
290
+
291
+ /**
292
+ * Detect each deployed env's CloudWatch log group by ASKING AWS
293
+ * (`aws logs describe-log-groups`) rather than assuming a naming convention —
294
+ * observed conventions differ per repo (`/jobs_runtime/dev`,
295
+ * `/aws/ecs/search-api/dev`, `/aws/ec2/platform__api_instance__dev`). Candidate
296
+ * prefixes are derived from the repo name; a group only counts on an EXACT
297
+ * match of `<candidate>` or `<candidate>/<envToken>` (so
298
+ * `/jobs_runtime_coldstart_test/dev` never shadows `/jobs_runtime/dev`).
299
+ * Envs without a working AWS session are skipped silently — these blocks only
300
+ * feed the /logs page's env dropdown, so absence just means "add later".
301
+ */
302
+ export function detectCloudwatchLogGroups(
303
+ repoName: string,
304
+ awsProfiles: Record<string, string>,
305
+ opts: {
306
+ region?: string;
307
+ execImpl?: (cmd: string) => string;
308
+ } = {},
309
+ ): Record<string, string> {
310
+ const region = opts.region ?? "us-east-1";
311
+ const exec =
312
+ opts.execImpl ??
313
+ ((cmd: string): string =>
314
+ execSync(cmd, {
315
+ encoding: "utf-8",
316
+ stdio: ["ignore", "pipe", "ignore"],
317
+ timeout: 5_000,
318
+ }));
319
+ const snake = repoName.replaceAll("-", "_");
320
+ const candidates = [
321
+ `/${snake}`,
322
+ `/${repoName}`,
323
+ `/aws/ecs/${repoName}`,
324
+ `/aws/ecs/${snake}`,
325
+ `/aws/ec2/${snake}`,
326
+ ];
327
+ const found: Record<string, string> = {};
328
+ for (const [env, profile] of Object.entries(awsProfiles)) {
329
+ const envToken = env === "production" ? "prod" : env;
330
+ for (const prefix of candidates) {
331
+ try {
332
+ const raw = exec(
333
+ `aws logs describe-log-groups --log-group-name-prefix "${prefix}" ` +
334
+ `--profile "${profile}" --region "${region}" ` +
335
+ `--query 'logGroups[].logGroupName' --output json`,
336
+ );
337
+ const names = JSON.parse(raw) as string[];
338
+ const hit = names.find(
339
+ (n) => n === prefix || n === `${prefix}/${envToken}`,
340
+ );
341
+ if (hit) {
342
+ found[env] = hit;
343
+ break;
344
+ }
345
+ } catch {
346
+ // No session / expired / no such groups — try next candidate; a hard
347
+ // auth failure will fail every candidate and the env is skipped.
348
+ }
349
+ }
350
+ }
351
+ return found;
352
+ }
353
+
354
+ // ── Live local health detection ───────────────────────────────────────────────
355
+
356
+ /**
357
+ * Detect the repo's real local health endpoint by probing it (opctl DNS),
358
+ * instead of assuming /healthz: the pulumi-declared path first, then common
359
+ * conventions. Only a 2xx counts. Returns null when the service isn't
360
+ * reachable (first network error aborts — no point trying more paths) or
361
+ * nothing responds 2xx.
362
+ */
363
+ export async function detectLiveHealthPath(
364
+ repoName: string,
365
+ opts: {
366
+ port?: number;
367
+ declaredPath?: string;
368
+ fetchImpl?: typeof fetch;
369
+ } = {},
370
+ ): Promise<{ path: string; status: number } | null> {
371
+ const f = opts.fetchImpl ?? fetch;
372
+ const host =
373
+ opts.port && opts.port !== 80 ? `${repoName}:${opts.port}` : repoName;
374
+ const candidates = [
375
+ ...new Set(
376
+ [
377
+ opts.declaredPath,
378
+ "/healthz",
379
+ "/api/health",
380
+ "/health",
381
+ "/livez",
382
+ "/readyz",
383
+ ].filter((p): p is string => !!p),
384
+ ),
385
+ ];
386
+ for (const p of candidates) {
387
+ try {
388
+ const ctl = new AbortController();
389
+ const t = setTimeout(() => ctl.abort(), 1500);
390
+ const res = await f(`http://${host}${p}`, {
391
+ signal: ctl.signal,
392
+ redirect: "manual",
393
+ });
394
+ clearTimeout(t);
395
+ if (res.status >= 200 && res.status < 300)
396
+ return { path: p, status: res.status };
397
+ } catch {
398
+ return null; // host unreachable — service not running, stop probing
399
+ }
400
+ }
401
+ return null;
402
+ }
403
+
404
+ // ── buildInitManifest ─────────────────────────────────────────────────────────
405
+
406
+ /**
407
+ * Build a RawManifest from the user's init choices.
408
+ * Pure function — no I/O, easily testable.
409
+ */
410
+ export function buildInitManifest(opts: InitOptions): InitManifest {
411
+ const manifest: InitManifest = {
412
+ version: 1,
413
+ name: opts.name,
414
+ };
415
+
416
+ // Only set entrypoint for multi-service repos with an explicit choice
417
+ if (opts.services.length > 1 && opts.entrypoint) {
418
+ manifest.entrypoint = opts.entrypoint;
419
+ }
420
+
421
+ // Prefer the real make targets when detected (adds `setup`, drops absent ones);
422
+ // otherwise fall back to the conventional up/down/restart scaffold.
423
+ const STANDARD = ["up", "down", "setup", "restart"];
424
+ const detected = opts.makeTargets
425
+ ? STANDARD.filter((t) => opts.makeTargets!.includes(t))
426
+ : [];
427
+ const cmdNames = detected.length > 0 ? detected : ["up", "down", "restart"];
428
+ const localCommands: Record<string, string> = {};
429
+ for (const t of cmdNames) localCommands[t] = `make ${t}`;
430
+
431
+ if (opts.services.length > 0) {
432
+ manifest.services = {};
433
+ for (const svc of opts.services) {
434
+ const serviceEntry: NonNullable<RawManifest["services"]>[string] = {
435
+ commands: {
436
+ local: { ...localCommands },
437
+ },
438
+ };
439
+
440
+ if (svc.healthUrl) {
441
+ serviceEntry.health = { local: { url: svc.healthUrl } };
442
+ } else if (svc.healthCmd) {
443
+ serviceEntry.health = {
444
+ local: { type: "shell", cmd: svc.healthCmd },
445
+ };
446
+ }
447
+
448
+ // Scaffold deployed-env health URLs on the primary service (single
449
+ // service, or the multi-service entrypoint) from the config templates.
450
+ const isPrimary =
451
+ opts.services.length === 1 || svc.name === opts.entrypoint;
452
+ if (isPrimary && opts.envHealthUrls) {
453
+ serviceEntry.health = serviceEntry.health ?? {};
454
+ for (const [env, template] of Object.entries(opts.envHealthUrls)) {
455
+ if (env === "local") continue; // local comes from the user's answer
456
+ serviceEntry.health[env as Environment] = {
457
+ url: template.replaceAll("{repo}", opts.name),
458
+ };
459
+ }
460
+ }
461
+
462
+ if (isPrimary && opts.envLogGroups) {
463
+ for (const [env, logGroup] of Object.entries(opts.envLogGroups)) {
464
+ if (env === "local") continue;
465
+ serviceEntry.logs = serviceEntry.logs ?? {};
466
+ serviceEntry.logs[env as Environment] = {
467
+ type: "cloudwatch",
468
+ logGroup,
469
+ };
470
+ }
471
+ }
472
+
473
+ manifest.services![svc.name] = serviceEntry;
474
+ }
475
+ }
476
+
477
+ return manifest;
478
+ }
479
+
480
+ // ── mergeManifestPreserving ───────────────────────────────────────────────────
481
+
482
+ /**
483
+ * Merge a freshly generated init manifest over an existing one WITHOUT losing
484
+ * hand-authored content. Init only OWNS what it generates; everything else in
485
+ * the existing manifest survives regeneration:
486
+ * - top-level sections init doesn't produce (smoke, performance, synthetics,
487
+ * github, unknown future keys) carry over verbatim;
488
+ * - `commands`: per-env merge, existing entries win (extra targets like
489
+ * preflight/seed/migrate and hand-tuned cmds survive the make scaffold);
490
+ * - `health`: the generated `local` wins (the user just answered the prompt);
491
+ * for non-local envs, EXISTING entries win over generated defaults (e.g.
492
+ * shop's public api.dev.example.dev vs the internal-ALB template);
493
+ * - `logs`: per-env merge, existing entries win (cloudwatch groups etc.);
494
+ * - `depends_on`: preserved (init never generates it).
495
+ * A single existing service matches a single generated service even when the
496
+ * key was renamed by detection (services.shop-web → services.app).
497
+ * Services only in the existing manifest are NOT resurrected — the new service
498
+ * set is the user's explicit choice; runInit warns about the drop.
499
+ * Pure function — easily testable.
500
+ */
501
+ export function mergeManifestPreserving(
502
+ existing: RawManifest | null,
503
+ generated: RawManifest,
504
+ ): RawManifest {
505
+ if (!existing) return generated;
506
+
507
+ // Top-level sections init doesn't generate (smoke, performance,
508
+ // synthetics, github, and anything a future schema adds) are
509
+ // hand-authored — carry them over verbatim.
510
+ const OWNED_KEYS = new Set(["version", "name", "entrypoint", "services"]);
511
+ const existingRec = existing as unknown as Record<string, unknown>;
512
+ const generatedRec = generated as unknown as Record<string, unknown>;
513
+ for (const [key, value] of Object.entries(existingRec)) {
514
+ if (OWNED_KEYS.has(key) || value === undefined) continue;
515
+ if (generatedRec[key] === undefined) generatedRec[key] = value;
516
+ }
517
+
518
+ if (!existing.services || !generated.services) return generated;
519
+
520
+ const oldKeys = Object.keys(existing.services);
521
+ const newKeys = Object.keys(generated.services);
522
+
523
+ for (const [name, genSvc] of Object.entries(generated.services)) {
524
+ // Single-service repos: match across a detection-driven key rename
525
+ // (e.g. services.shop-web regenerated as services.app) — otherwise the
526
+ // rename silently discards every hand-authored block.
527
+ const oldSvc =
528
+ existing.services[name] ??
529
+ (oldKeys.length === 1 && newKeys.length === 1
530
+ ? existing.services[oldKeys[0]]
531
+ : undefined);
532
+ if (!oldSvc) continue;
533
+
534
+ // commands: per-env merge — hand-authored commands win over the make
535
+ // scaffold and extra targets (preflight, seed, migrate, …) survive.
536
+ if (oldSvc.commands) {
537
+ genSvc.commands = genSvc.commands ?? {};
538
+ for (const [env, cmds] of Object.entries(oldSvc.commands)) {
539
+ if (!cmds) continue;
540
+ genSvc.commands[env as Environment] = {
541
+ ...(genSvc.commands[env as Environment] ?? {}),
542
+ ...cmds,
543
+ };
544
+ }
545
+ }
546
+
547
+ if (oldSvc.health) {
548
+ const merged = { ...(genSvc.health ?? {}) };
549
+ for (const [env, cfg] of Object.entries(oldSvc.health)) {
550
+ if (env === "local" || !cfg) continue; // freshly answered — generated wins
551
+ merged[env as Environment] = cfg; // hand-authored env URL wins over the template
552
+ }
553
+ if (Object.keys(merged).length > 0) genSvc.health = merged;
554
+ }
555
+
556
+ if (oldSvc.logs) {
557
+ // Per-env merge, existing hand-authored entries win over detected ones.
558
+ genSvc.logs = { ...(genSvc.logs ?? {}), ...oldSvc.logs };
559
+ }
560
+
561
+ // depends_on is never generated — preserve explicit wait-gates etc.
562
+ if (oldSvc.depends_on && !genSvc.depends_on) {
563
+ genSvc.depends_on = oldSvc.depends_on;
564
+ }
565
+ }
566
+ return generated;
567
+ }
568
+
569
+ // ── formatManifestYaml ────────────────────────────────────────────────────────
570
+
571
+ /**
572
+ * Serialise a manifest to a pretty YAML string with a header comment.
573
+ * Ensures `version` appears first in the output.
574
+ */
575
+ export function formatManifestYaml(manifest: Record<string, unknown>): string {
576
+ const header = [
577
+ `# ${product().manifestYml} — service manifest (format: see the Den README)`,
578
+ "#",
579
+ "# Dependency ORDER is derived automatically from real signals (SDK imports,",
580
+ "# *_URL env vars, IaC refs, EventBridge rules) — you do NOT need depends_on.",
581
+ "# Add depends_on only for an explicit `wait: true` readiness gate.",
582
+ "",
583
+ ].join("\n");
584
+
585
+ // Hoist version and name to the top of the document
586
+ const { version, name, ...rest } = manifest;
587
+ const ordered: Record<string, unknown> = {};
588
+ if (version !== undefined) ordered.version = version;
589
+ if (name !== undefined) ordered.name = name;
590
+ Object.assign(ordered, rest);
591
+
592
+ return header + yaml.stringify(ordered, { lineWidth: 100 });
593
+ }
594
+
595
+ // ── Interactive wizard ────────────────────────────────────────────────────────
596
+ // The functions below are not unit-tested — they drive readline I/O.
597
+
598
+ /* istanbul ignore next */
599
+ function prompt(rl: readline.Interface, question: string): Promise<string> {
600
+ return new Promise((resolve) => rl.question(question, resolve));
601
+ }
602
+
603
+ /**
604
+ * Interactive den-init wizard. Writes a .den.yml to the target directory.
605
+ */
606
+ /* istanbul ignore next */
607
+ export async function runInit(
608
+ // cwd-appropriate BY DESIGN: init scaffolds .den.yml where the user is
609
+ // standing — but honor the canonical invoke-cwd signal (bin/den exports
610
+ // DEN_INVOKE_CWD) in case a spawned process repointed its own cwd.
611
+ targetDir: string = getInvokeCwd(),
612
+ ): Promise<void> {
613
+ const rl = readline.createInterface({
614
+ input: process.stdin,
615
+ output: process.stdout,
616
+ });
617
+
618
+ try {
619
+ const denYmlPath = path.join(targetDir, `${product().manifestYml}`);
620
+
621
+ // Guard: don't silently overwrite
622
+ if (fs.existsSync(denYmlPath)) {
623
+ const answer = await prompt(
624
+ rl,
625
+ `${product().manifestYml} already exists. Overwrite? (y/N): `,
626
+ );
627
+ if (!answer.match(/^y$/i)) {
628
+ console.log("Aborted.");
629
+ return;
630
+ }
631
+ }
632
+
633
+ // Read the existing manifest (if any) BEFORE overwriting — hand-authored
634
+ // env-specific blocks (deployed health URLs, cloudwatch logs) are merged
635
+ // back into the regenerated manifest so re-running init never loses them.
636
+ let existingManifest: RawManifest | null = null;
637
+ try {
638
+ existingManifest = yaml.parse(
639
+ fs.readFileSync(denYmlPath, "utf-8"),
640
+ ) as RawManifest;
641
+ } catch {
642
+ /* no existing manifest / unparseable — nothing to preserve */
643
+ }
644
+
645
+ console.log(
646
+ `\n ${product().cli} init — scaffold a new ${product().manifestYml}\n`,
647
+ );
648
+
649
+ // Detect repo name from directory name
650
+ const dirName = path.basename(targetDir);
651
+
652
+ // Check for .stack.yml
653
+ const stackDetection = detectStackConfig(targetDir);
654
+ let serviceNames: string[] = [];
655
+
656
+ if (stackDetection?.localPort !== undefined) {
657
+ console.log(
658
+ ` Detected local port ${stackDetection.localPort} (stack.yml config.local.PORT)`,
659
+ );
660
+ }
661
+ if (stackDetection && stackDetection.services.length > 0) {
662
+ console.log(
663
+ ` Detected .stack.yml with services: ${stackDetection.services.join(", ")}`,
664
+ );
665
+ const useStack = await prompt(
666
+ rl,
667
+ " Import service config from .stack.yml? (Y/n): ",
668
+ );
669
+ if (!useStack.match(/^n$/i)) {
670
+ serviceNames = stackDetection.services;
671
+ }
672
+ }
673
+
674
+ const name = await prompt(rl, ` Repo name [${dirName}]: `);
675
+ const resolvedName = name.trim() || dirName;
676
+
677
+ if (serviceNames.length === 0) {
678
+ const svcsInput = await prompt(
679
+ rl,
680
+ " Service names (comma-separated, e.g. api,worker) [api]: ",
681
+ );
682
+ serviceNames = svcsInput.trim()
683
+ ? svcsInput
684
+ .split(",")
685
+ .map((s) => s.trim())
686
+ .filter(Boolean)
687
+ : ["api"];
688
+ }
689
+
690
+ // Entrypoint for multi-service repos
691
+ let entrypoint: string | undefined;
692
+ if (serviceNames.length > 1) {
693
+ const ep = await prompt(
694
+ rl,
695
+ ` Front-door service (entrypoint) [${serviceNames[0]}]: `,
696
+ );
697
+ entrypoint = ep.trim() || serviceNames[0];
698
+ }
699
+
700
+ // Health evidence — detect, don't assume:
701
+ // 1. pulumi alb_health_check_path = the repo's OWN declared health path
702
+ // 2. live probe of the running service via opctl DNS (2xx wins)
703
+ // 3. /healthz convention only as the last resort
704
+ const pulumiEnvs = detectPulumiDeployment(targetDir);
705
+ const declaredPath = Object.values(pulumiEnvs).find(
706
+ (e) => e.healthPath,
707
+ )?.healthPath;
708
+ const live = await detectLiveHealthPath(resolvedName, {
709
+ port: stackDetection?.localPort,
710
+ declaredPath,
711
+ });
712
+ if (live) {
713
+ console.log(
714
+ ` Detected live health endpoint: ${live.path} (HTTP ${live.status})`,
715
+ );
716
+ } else if (declaredPath) {
717
+ console.log(
718
+ ` Using pulumi-declared health path: ${declaredPath} (service not running — unverified)`,
719
+ );
720
+ }
721
+
722
+ // Health check per service. The primary service (single, or the entrypoint)
723
+ // defaults to the DETECTED path; sidecars fall back to the opctl DNS
724
+ // convention (http://<name>/healthz, port 80) using the stack.yml port.
725
+ const services: ServiceInitConfig[] = [];
726
+ for (const svcName of serviceNames) {
727
+ const svcPort =
728
+ stackDetection?.ports?.[svcName] ?? stackDetection?.localPort;
729
+ const isPrimary =
730
+ serviceNames.length === 1 || svcName === (entrypoint ?? "");
731
+ const detectedPath = live?.path ?? declaredPath;
732
+ const port = svcPort ?? 80;
733
+ const host = port === 80 ? resolvedName : `${resolvedName}:${port}`;
734
+ const defaultUrl =
735
+ isPrimary && detectedPath
736
+ ? `http://${host}${detectedPath}`
737
+ : defaultLocalHealthUrl(resolvedName, svcPort);
738
+ const health = await prompt(
739
+ rl,
740
+ ` Health check for "${svcName}" — URL or shell cmd [${defaultUrl}]: `,
741
+ );
742
+ const resolved = health.trim() || defaultUrl;
743
+ if (resolved.startsWith("http")) {
744
+ services.push({ name: svcName, healthUrl: resolved });
745
+ } else {
746
+ services.push({ name: svcName, healthCmd: resolved });
747
+ }
748
+ }
749
+
750
+ // Reflect the repo's actual make targets in commands.local (adds setup,
751
+ // drops targets that don't exist) instead of a blind up/down/restart default.
752
+ const makeTargets = listMakeTargets(targetDir).targets;
753
+ if (makeTargets.length > 0) {
754
+ const std = ["up", "down", "setup", "restart"].filter((t) =>
755
+ makeTargets.includes(t),
756
+ );
757
+ if (std.length > 0) {
758
+ console.log(` Detected make targets: ${std.join(", ")}`);
759
+ }
760
+ }
761
+
762
+ // Deployed-env health URLs from EVIDENCE (pulumi app_subdomain /
763
+ // alb_subdomain / alb_health_check_path) — the TEST scope resolves per-env
764
+ // base URLs from these manifest entries. Envs with no evidence get nothing
765
+ // (no fabricated hosts); hand edits survive re-init via the merge below.
766
+ const envHealthUrls = buildEnvHealthUrls(
767
+ resolvedName,
768
+ pulumiEnvs,
769
+ readDenConfig().env_health_urls,
770
+ live?.path ?? declaredPath,
771
+ );
772
+ if (Object.keys(envHealthUrls).length > 0) {
773
+ const kind = Object.values(pulumiEnvs).some((e) => e.appSubdomain)
774
+ ? "public app host"
775
+ : "internal ALB";
776
+ console.log(
777
+ ` Deployed health URLs (${kind}, from pulumi): ${Object.keys(envHealthUrls).join(", ")}`,
778
+ );
779
+ } else {
780
+ console.log(
781
+ " No deployed-host evidence in pulumi/ — skipping health.<env> URLs (add by hand; they survive re-init).",
782
+ );
783
+ }
784
+
785
+ // CloudWatch log groups per deployed env — asked of AWS directly (exact
786
+ // matches only), skipped silently for envs without a live session. These
787
+ // feed the /logs page's env dropdown.
788
+ const envLogGroups = detectCloudwatchLogGroups(
789
+ resolvedName,
790
+ loadAwsProfiles() as Record<string, string>,
791
+ );
792
+ if (Object.keys(envLogGroups).length > 0) {
793
+ console.log(
794
+ ` Detected CloudWatch log groups: ${Object.entries(envLogGroups)
795
+ .map(([e, g]) => `${e}=${g}`)
796
+ .join(" ")}`,
797
+ );
798
+ } else {
799
+ console.log(
800
+ " No CloudWatch log groups detected (no live AWS session or no matching groups) — add logs.<env> by hand if needed.",
801
+ );
802
+ }
803
+
804
+ const manifest = mergeManifestPreserving(
805
+ existingManifest,
806
+ buildInitManifest({
807
+ name: resolvedName,
808
+ services,
809
+ entrypoint,
810
+ makeTargets,
811
+ envHealthUrls:
812
+ Object.keys(envHealthUrls).length > 0 ? envHealthUrls : undefined,
813
+ envLogGroups:
814
+ Object.keys(envLogGroups).length > 0 ? envLogGroups : undefined,
815
+ }),
816
+ );
817
+ const yamlContent = formatManifestYaml(
818
+ manifest as unknown as Record<string, unknown>,
819
+ );
820
+
821
+ // Regeneration transparency: name every existing service that is not in
822
+ // the new manifest (renamed = settings carried over; dropped = gone).
823
+ if (existingManifest?.services && manifest.services) {
824
+ const keptKeys = Object.keys(manifest.services);
825
+ const missing = Object.keys(existingManifest.services).filter(
826
+ (k) => !keptKeys.includes(k),
827
+ );
828
+ const renamed =
829
+ missing.length === 1 &&
830
+ Object.keys(existingManifest.services).length === 1 &&
831
+ keptKeys.length === 1;
832
+ if (renamed) {
833
+ console.log(
834
+ ` ⚠ service key renamed: ${missing[0]} → ${keptKeys[0]} (existing settings carried over)`,
835
+ );
836
+ } else if (missing.length > 0) {
837
+ console.log(
838
+ ` ⚠ services dropped from the manifest: ${missing.join(", ")} — restore from git if unintended`,
839
+ );
840
+ }
841
+ }
842
+
843
+ fs.writeFileSync(denYmlPath, yamlContent, "utf-8");
844
+ console.log(`\n ✓ Wrote ${denYmlPath}\n`);
845
+ console.log(" Next steps:");
846
+ console.log(
847
+ ` make ${product().cli}-register — add this repo to your service catalog`,
848
+ );
849
+ console.log(
850
+ ` make ${product().cli}-validate — validate the manifest\n`,
851
+ );
852
+ } finally {
853
+ rl.close();
854
+ }
855
+ }