@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,93 @@
1
+ /**
2
+ * Readiness polling for `depends_on: { wait: true }` gates.
3
+ *
4
+ * Wraps `checkServiceHealth` with a poll loop. Used by the workspace `up`
5
+ * runner to block starting a dependent until its flagged dependencies are
6
+ * actually green (not just "started 5 seconds ago").
7
+ */
8
+
9
+ import { READINESS_TIMEOUT_MS, READINESS_INTERVAL_MS } from "../constants.js";
10
+ import { checkServiceHealth } from "./health.js";
11
+ import type { Environment, ResolvedService } from "../types/index.js";
12
+
13
+ export interface WaitForReadyOptions {
14
+ timeoutMs?: number;
15
+ intervalMs?: number;
16
+ /** Called with a short status string on each unsuccessful poll. */
17
+ onTick?: (detail: string) => void;
18
+ /**
19
+ * Polled between each readiness check. When it returns true the loop exits
20
+ * early with `ok: false, detail: "cancelled"`. Used by the SSE runner to
21
+ * stop polling when the client disconnects mid-`up`.
22
+ */
23
+ isCancelled?: () => boolean;
24
+ /** Override for tests. */
25
+ now?: () => number;
26
+ /** Override for tests. */
27
+ sleep?: (ms: number) => Promise<void>;
28
+ }
29
+
30
+ export interface WaitForReadyResult {
31
+ ok: boolean;
32
+ /** Detail from the last check — populated on timeout or fail-open. */
33
+ detail?: string;
34
+ /** True when the dep has no configured health check — we fail open and proceed. */
35
+ skipped?: boolean;
36
+ /** Time (ms) spent waiting. */
37
+ elapsedMs: number;
38
+ }
39
+
40
+ const defaultSleep = (ms: number): Promise<void> =>
41
+ new Promise((r) => setTimeout(r, ms));
42
+
43
+ /**
44
+ * Poll the dep's health check until it returns ok or the timeout expires.
45
+ *
46
+ * Fail-open: if the dep has no `health.<env>` configured, returns immediately
47
+ * with `skipped: true` — we never silently hang on a misconfigured dep.
48
+ */
49
+ export async function waitForReady(
50
+ resolved: ResolvedService,
51
+ env: Environment,
52
+ opts: WaitForReadyOptions = {},
53
+ ): Promise<WaitForReadyResult> {
54
+ const timeoutMs = opts.timeoutMs ?? READINESS_TIMEOUT_MS;
55
+ const intervalMs = opts.intervalMs ?? READINESS_INTERVAL_MS;
56
+ const now = opts.now ?? Date.now;
57
+ const sleep = opts.sleep ?? defaultSleep;
58
+
59
+ const start = now();
60
+ const deadline = start + timeoutMs;
61
+ let last = await checkServiceHealth(resolved, env);
62
+
63
+ // Fail-open: no health config → don't block.
64
+ if (last.health === "unknown") {
65
+ return {
66
+ ok: true,
67
+ skipped: true,
68
+ detail: last.healthDetail,
69
+ elapsedMs: now() - start,
70
+ };
71
+ }
72
+
73
+ while (last.health !== "ok") {
74
+ if (opts.isCancelled?.()) {
75
+ return { ok: false, detail: "cancelled", elapsedMs: now() - start };
76
+ }
77
+ if (now() >= deadline) {
78
+ return {
79
+ ok: false,
80
+ detail: last.healthDetail ?? "timeout",
81
+ elapsedMs: now() - start,
82
+ };
83
+ }
84
+ opts.onTick?.(last.healthDetail ?? "not ready");
85
+ await sleep(intervalMs);
86
+ if (opts.isCancelled?.()) {
87
+ return { ok: false, detail: "cancelled", elapsedMs: now() - start };
88
+ }
89
+ last = await checkServiceHealth(resolved, env);
90
+ }
91
+
92
+ return { ok: true, elapsedMs: now() - start };
93
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `den register <path>`: add an existing checkout to a workspace, with no
3
+ * prompts.
4
+ *
5
+ * The repo is named after its manifest (`name:`), so a checkout can live
6
+ * anywhere on disk (Arbor lives outside the repos root) and still get a clean
7
+ * name. It is added dev-managed at that path: Den runs the repo's own
8
+ * commands there and never clones or switches its branch. One bare entry per
9
+ * repo: the workspace expands it to every service in the manifest.
10
+ */
11
+
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ import { readManifest } from "../manifest/read-manifest.js";
15
+ import { getGitHubCoords } from "../smoke/git-remote.js";
16
+ import { upsertCatalogEntry } from "./catalog.js";
17
+ import { readWorkspacesFile, writeWorkspacesFile } from "./workspaces.js";
18
+
19
+ import { product } from "../product.js";
20
+ export interface RegisteredRepo {
21
+ repoName: string;
22
+ gh: string;
23
+ path: string;
24
+ services: string[];
25
+ }
26
+
27
+ export function registerLocalRepo(
28
+ repoPathArg: string,
29
+ workspaceName: string,
30
+ ): RegisteredRepo {
31
+ const repoPath = path.resolve(repoPathArg);
32
+ if (!fs.existsSync(repoPath))
33
+ throw new Error(`Path does not exist: ${repoPath}`);
34
+ const manifest = readManifest(repoPath);
35
+ if (!manifest)
36
+ throw new Error(
37
+ `No ${product().manifestYml} in ${repoPath} (and none in ${product().title}'s manifests/ for its origin repo)`,
38
+ );
39
+ const repoName =
40
+ typeof manifest.name === "string" && manifest.name
41
+ ? manifest.name
42
+ : path.basename(repoPath);
43
+ const coords = getGitHubCoords(repoPath);
44
+ const gh = coords ? `${coords.owner}/${coords.repo}` : "";
45
+
46
+ const file = readWorkspacesFile();
47
+ const ws = file.workspaces[workspaceName];
48
+ if (!ws) throw new Error(`Unknown workspace "${workspaceName}"`);
49
+
50
+ // Replace every existing entry for this repo, keeping its auto-start toggle.
51
+ let autostartOff = false;
52
+ for (const key of Object.keys(ws.services)) {
53
+ if (key === repoName || key.startsWith(`${repoName}/`)) {
54
+ if (ws.services[key].autostart === false) autostartOff = true;
55
+ delete ws.services[key];
56
+ }
57
+ }
58
+ ws.services[repoName] = {
59
+ mode: "dev-managed",
60
+ path: repoPath,
61
+ ...(autostartOff ? { autostart: false } : {}),
62
+ };
63
+
64
+ upsertCatalogEntry(repoName, { gh });
65
+ writeWorkspacesFile(file);
66
+ return {
67
+ repoName,
68
+ gh,
69
+ path: repoPath,
70
+ services: Object.keys(manifest.services ?? {}),
71
+ };
72
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Local-secrets readiness check.
3
+ *
4
+ * Repos scaffolded by stack that declare `config.local.sidecars[].secrets`
5
+ * ship a COMMITTED, generated `.stack-secrets.example.yml` listing every
6
+ * secret they need, and expect a gitignored `.stack-secrets.yml` beside it
7
+ * with real values. That example file is the whole contract — den reads it
8
+ * generically and needs no per-repo knowledge.
9
+ *
10
+ * Why this gate exists: a sidecar's generated `up.sh` validates only that each
11
+ * secret is PRESENT and NON-EMPTY, so the placeholder value (`replace-me`)
12
+ * sails through. Starting a stack that way is not a harmless no-op — a
13
+ * database container bakes the password it is first started with into its data
14
+ * volume at initdb time, and no later edit of the secrets file changes it. The
15
+ * result is a stack that looks healthy (liveness probes don't authenticate)
16
+ * while every consumer fails to connect. Refusing to start is the only cheap
17
+ * moment to prevent it.
18
+ */
19
+
20
+ import * as fs from "fs";
21
+ import * as path from "path";
22
+ import * as yaml from "yaml";
23
+ import { getDistRoot } from "../paths.js";
24
+ import { getReposRoot } from "../den-config.js";
25
+
26
+ import { product } from "../product.js";
27
+ /** stack's generated placeholder, plus the `<angle-bracket>` fill-me-in convention. */
28
+ const PLACEHOLDER_RE = /^(replace-me|<[^>]*>)$/;
29
+
30
+ const EXAMPLE_FILE = ".stack-secrets.example.yml";
31
+ const SECRETS_FILE = ".stack-secrets.yml";
32
+
33
+ export interface SecretsCheckResult {
34
+ ok: boolean;
35
+ /** Absolute path to the secrets file the engineer needs to fill in. */
36
+ secretsPath: string;
37
+ /** Absolute path to the committed example that declares the contract. */
38
+ examplePath: string;
39
+ /** True when `.stack-secrets.yml` doesn't exist at all. */
40
+ fileMissing: boolean;
41
+ /** Declared keys absent from the secrets file (names only — never values). */
42
+ missingKeys: string[];
43
+ /** Declared keys still holding a placeholder or empty value. */
44
+ placeholderKeys: string[];
45
+ }
46
+
47
+ function parseFlatYaml(file: string): Record<string, unknown> | null {
48
+ try {
49
+ const parsed = yaml.parse(fs.readFileSync(file, "utf-8")) as unknown;
50
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
51
+ return null;
52
+ }
53
+ return parsed as Record<string, unknown>;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Check a repo's local secrets against its declared contract.
61
+ *
62
+ * Returns `null` when the repo declares no contract (no example file) — i.e.
63
+ * most repos — so callers can treat "no contract" as "nothing to enforce".
64
+ */
65
+ export function checkRepoSecrets(repoPath: string): SecretsCheckResult | null {
66
+ const examplePath = path.join(repoPath, EXAMPLE_FILE);
67
+ const secretsPath = path.join(repoPath, SECRETS_FILE);
68
+ if (!fs.existsSync(examplePath)) return null;
69
+
70
+ const declared = parseFlatYaml(examplePath);
71
+ if (!declared) return null; // unparseable contract — don't invent failures
72
+ const keys = Object.keys(declared);
73
+ if (keys.length === 0) return null;
74
+
75
+ if (!fs.existsSync(secretsPath)) {
76
+ return {
77
+ ok: false,
78
+ secretsPath,
79
+ examplePath,
80
+ fileMissing: true,
81
+ missingKeys: keys,
82
+ placeholderKeys: [],
83
+ };
84
+ }
85
+
86
+ const actual = parseFlatYaml(secretsPath) ?? {};
87
+ const missingKeys: string[] = [];
88
+ const placeholderKeys: string[] = [];
89
+ for (const key of keys) {
90
+ if (!(key in actual)) {
91
+ missingKeys.push(key);
92
+ continue;
93
+ }
94
+ const value = actual[key];
95
+ const text = value == null ? "" : String(value).trim();
96
+ if (text === "" || PLACEHOLDER_RE.test(text)) placeholderKeys.push(key);
97
+ }
98
+
99
+ return {
100
+ ok: missingKeys.length === 0 && placeholderKeys.length === 0,
101
+ secretsPath,
102
+ examplePath,
103
+ fileMissing: false,
104
+ missingKeys,
105
+ placeholderKeys,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Other checkouts of the same repo on this machine, in the order
111
+ * `readManifestAnyBranch` already established: other managed branch
112
+ * clones first, then the engineer's own sibling checkout.
113
+ */
114
+ function otherCheckouts(repoName: string, excludePath: string): string[] {
115
+ const out: string[] = [];
116
+ const managedRoot = path.join(
117
+ getDistRoot(),
118
+ `${product().servicesDir}`,
119
+ repoName,
120
+ );
121
+ try {
122
+ for (const entry of fs.readdirSync(managedRoot, { withFileTypes: true })) {
123
+ if (entry.isDirectory()) out.push(path.join(managedRoot, entry.name));
124
+ }
125
+ } catch {
126
+ /* no managed clones */
127
+ }
128
+ out.push(path.join(getReposRoot(), repoName));
129
+ const exclude = path.resolve(excludePath);
130
+ return out.filter((p) => path.resolve(p) !== exclude);
131
+ }
132
+
133
+ export interface SecretsSeedResult {
134
+ /** Keys filled in from the donor (names only — never values). */
135
+ filled: string[];
136
+ /** Checkout the values came from. */
137
+ donorPath: string;
138
+ }
139
+
140
+ /**
141
+ * Fill a freshly cloned repo's secrets file from ANOTHER checkout of the SAME
142
+ * repo on this machine.
143
+ *
144
+ * A managed clone starts with stack's placeholders, which blocks `make
145
+ * setup` and would poison containers if started — while the engineer's own
146
+ * sibling checkout of that same repo usually already holds every value. Copying
147
+ * them is exactly what they would do by hand, so do it for them and say so.
148
+ *
149
+ * Conservative by construction: only keys DECLARED by the contract, only when
150
+ * unset in the target, and only from donor values that are themselves real.
151
+ * Never overwrites an existing value.
152
+ */
153
+ export function seedSecretsFromSibling(
154
+ repoName: string,
155
+ repoPath: string,
156
+ ): SecretsSeedResult | null {
157
+ const check = checkRepoSecrets(repoPath);
158
+ if (!check || check.ok) return null;
159
+
160
+ const wanted = new Set([...check.missingKeys, ...check.placeholderKeys]);
161
+ for (const donorPath of otherCheckouts(repoName, repoPath)) {
162
+ const donorFile = path.join(donorPath, SECRETS_FILE);
163
+ if (!fs.existsSync(donorFile)) continue;
164
+ const donor = parseFlatYaml(donorFile);
165
+ if (!donor) continue;
166
+
167
+ const fill = new Map<string, string>();
168
+ for (const key of wanted) {
169
+ const raw = donor[key];
170
+ const text = raw == null ? "" : String(raw).trim();
171
+ if (text === "" || PLACEHOLDER_RE.test(text)) continue;
172
+ fill.set(key, text);
173
+ }
174
+ if (fill.size === 0) continue;
175
+
176
+ writeSecretValues(check.secretsPath, check.examplePath, fill);
177
+ return { filled: [...fill.keys()].sort(), donorPath };
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * Write values into a flat `KEY: VALUE` secrets file, line-wise so the
184
+ * generated `# Used by:` comments and key order survive. Creates the file from
185
+ * the example when absent.
186
+ */
187
+ function writeSecretValues(
188
+ secretsPath: string,
189
+ examplePath: string,
190
+ values: Map<string, string>,
191
+ ): void {
192
+ const source = fs.existsSync(secretsPath) ? secretsPath : examplePath;
193
+ const lines = fs.readFileSync(source, "utf-8").split("\n");
194
+ const remaining = new Map(values);
195
+
196
+ const updated = lines.map((line) => {
197
+ const m = /^(\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*:\s*)(.*)$/.exec(line);
198
+ if (!m) return line;
199
+ const key = m[2];
200
+ if (!remaining.has(key)) return line;
201
+ const value = remaining.get(key)!;
202
+ remaining.delete(key);
203
+ return `${m[1]}${key}: ${quoteIfNeeded(value)}`;
204
+ });
205
+
206
+ // Declared keys the file never listed (e.g. a hand-trimmed file).
207
+ for (const [key, value] of remaining) {
208
+ updated.push(`${key}: ${quoteIfNeeded(value)}`);
209
+ }
210
+
211
+ fs.writeFileSync(secretsPath, updated.join("\n"), { mode: 0o600 });
212
+ // `mode` above only applies when the file is CREATED — an existing file keeps
213
+ // its old permissions, so tighten explicitly. This file holds live credentials.
214
+ try {
215
+ fs.chmodSync(secretsPath, 0o600);
216
+ } catch {
217
+ /* best-effort — a read-only FS shouldn't fail the seed */
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Quote values the flat-file readers would otherwise mangle. `up.sh`'s awk
223
+ * strips a trailing ` #…` before unquoting, so a value containing " #" cannot
224
+ * round-trip through that parser at all — left as-is rather than silently
225
+ * corrupted, since real secrets don't contain it.
226
+ */
227
+ function quoteIfNeeded(value: string): string {
228
+ if (!/[:#'"]|^\s|\s$/.test(value)) return value;
229
+ return `"${value.replace(/"/g, '\\"')}"`;
230
+ }
231
+
232
+ /**
233
+ * Operator-facing explanation of a failed check. Key names only — never values.
234
+ * Leads with the absolute path because the file lives inside a managed
235
+ * clone, which is not somewhere an engineer would think to look.
236
+ */
237
+ export function describeSecretsCheck(
238
+ repoName: string,
239
+ result: SecretsCheckResult,
240
+ ): string[] {
241
+ const lines: string[] = [];
242
+ const unset = [...result.missingKeys, ...result.placeholderKeys];
243
+ lines.push(
244
+ result.fileMissing
245
+ ? `${repoName}: local secrets file has not been created yet`
246
+ : `${repoName}: ${unset.length} local secret(s) still unset — ${unset.join(", ")}`,
247
+ );
248
+ lines.push(` file: ${result.secretsPath}`);
249
+ lines.push(` declared: ${result.examplePath}`);
250
+ lines.push(
251
+ ` Starting now would initialize containers with placeholder credentials.`,
252
+ );
253
+ lines.push(
254
+ ` A database volume keeps the password it is FIRST created with, so this`,
255
+ );
256
+ lines.push(
257
+ ` is not self-correcting — fill the values in, then run Sync again.`,
258
+ );
259
+ return lines;
260
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Shared environment for service commands.
3
+ *
4
+ * Some repos read shared locations from environment variables (e.g. a
5
+ * shared-infra repo's secrets file, defaulting to a sibling path like
6
+ * `../infra/.secrets.yml`). A sibling default breaks when the consumer is a
7
+ * nested managed clone, because `../infra` no longer points at that repo.
8
+ *
9
+ * Rather than patch each repo, den injects the resolved values into every
10
+ * service command (`up`/`down`/`restart`/`setup`). The mapping comes from the
11
+ * org config's `service_env` (`KEY: "{repo:<name>}/<path>"`); den has no
12
+ * built-in entries.
13
+ */
14
+
15
+ import { readDenConfig } from "../den-config.js";
16
+ import { resolveRepoPath } from "./derivation/derive-graph.js";
17
+
18
+ /**
19
+ * Built-in shared env templates: none. Config (`service_env`) is merged over
20
+ * this; a key set to "" is skipped.
21
+ */
22
+ export const DEFAULT_SERVICE_ENV: Record<string, string> = {};
23
+
24
+ /**
25
+ * Expand `{repo:<name>}` placeholders to the absolute path den resolves
26
+ * `<name>` to (managed clone or dev sibling). Returns null when any placeholder
27
+ * can't be resolved, so the caller skips that variable entirely.
28
+ */
29
+ export function expandServiceEnvTemplate(
30
+ template: string,
31
+ resolve: (repoName: string) => string | null = resolveRepoPath,
32
+ ): string | null {
33
+ const tokens = [...template.matchAll(/\{repo:([^}]+)\}/g)];
34
+ let out = template;
35
+ for (const match of tokens) {
36
+ const repoPath = resolve(match[1]);
37
+ if (!repoPath) return null;
38
+ out = out.split(match[0]).join(repoPath);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * Repos that SUPPLY shared values to other repos, derived from the `{repo:X}`
45
+ * tokens in the (config-merged) service_env templates.
46
+ *
47
+ * These are load-bearing for every consumer: if such a repo's own local config
48
+ * is unusable, starting the repos that read from it produces a fleet configured
49
+ * against placeholder values. Config-derived, so den needs no hardcoded
50
+ * knowledge of which repo happens to own the shared secrets.
51
+ */
52
+ export function serviceEnvProviderRepos(
53
+ readConfig: () =>
54
+ | { service_env?: Record<string, string> }
55
+ | null
56
+ | undefined = readDenConfig,
57
+ ): Set<string> {
58
+ const providers = new Set<string>();
59
+ try {
60
+ const templates: Record<string, string> = {
61
+ ...DEFAULT_SERVICE_ENV,
62
+ ...(readConfig()?.service_env ?? {}),
63
+ };
64
+ for (const template of Object.values(templates)) {
65
+ if (!template) continue; // an empty template is skipped
66
+ for (const match of template.matchAll(/\{repo:([^}]+)\}/g)) {
67
+ providers.add(match[1]);
68
+ }
69
+ }
70
+ } catch {
71
+ /* best-effort — an unreadable config just means no known providers */
72
+ }
73
+ return providers;
74
+ }
75
+
76
+ /**
77
+ * Build the shared environment to inject into a service command. Never overrides
78
+ * a variable already present (and non-empty) in `baseEnv` — an explicit value in
79
+ * the caller's shell wins. Variables whose template can't be resolved are
80
+ * omitted (e.g. the providing repo is not in this workspace).
81
+ */
82
+ export function buildServiceEnv(
83
+ baseEnv: NodeJS.ProcessEnv = process.env,
84
+ resolve: (repoName: string) => string | null = resolveRepoPath,
85
+ readConfig: () =>
86
+ | { service_env?: Record<string, string> }
87
+ | null
88
+ | undefined = readDenConfig,
89
+ ): NodeJS.ProcessEnv {
90
+ // Best-effort: injecting shared env must never break a service command, so any
91
+ // failure to read config / resolve a repo path degrades to "no shared env".
92
+ try {
93
+ const templates: Record<string, string> = {
94
+ ...DEFAULT_SERVICE_ENV,
95
+ ...(readConfig()?.service_env ?? {}),
96
+ };
97
+
98
+ const out: NodeJS.ProcessEnv = {};
99
+ for (const [key, template] of Object.entries(templates)) {
100
+ const existing = baseEnv[key];
101
+ if (existing != null && existing !== "") continue; // respect caller-set value
102
+ if (!template) continue; // an empty template is skipped
103
+ const value = expandServiceEnvTemplate(template, resolve);
104
+ if (value !== null) out[key] = value;
105
+ }
106
+ return out;
107
+ } catch {
108
+ return {};
109
+ }
110
+ }