@monster-orange/den 0.0.0-stage → 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 +166 -2
  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 +104 -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,161 @@
1
+ /**
2
+ * Environment Selector
3
+ * Loads and validates environment configurations
4
+ */
5
+
6
+ import { readFile } from "fs/promises";
7
+ import { resolve, join } from "path";
8
+ import type { Environment, EnvironmentConfig } from "../types/index.js";
9
+
10
+ export class EnvironmentSelector {
11
+ private configDir: string;
12
+ private cache: Map<string, EnvironmentConfig> = new Map();
13
+
14
+ constructor(configDir: string = "./config/environments") {
15
+ this.configDir = resolve(configDir);
16
+ }
17
+
18
+ /**
19
+ * Load environment configuration
20
+ */
21
+ async loadEnvironment(env: Environment): Promise<EnvironmentConfig> {
22
+ // Check cache first
23
+ if (this.cache.has(env)) {
24
+ return this.cache.get(env)!;
25
+ }
26
+
27
+ const configPath = join(this.configDir, `${env}.json`);
28
+
29
+ try {
30
+ const content = await readFile(configPath, "utf-8");
31
+ const config: EnvironmentConfig = JSON.parse(content);
32
+
33
+ // Validate config
34
+ this.validateConfig(config, env);
35
+
36
+ // Cache and return
37
+ this.cache.set(env, config);
38
+ return config;
39
+ } catch (error) {
40
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
41
+ throw new Error(
42
+ `Environment configuration not found: ${env}\n` +
43
+ `Expected file: ${configPath}\n` +
44
+ `Available environments: ${(await this.getAvailableEnvironments()).join(", ")}`,
45
+ );
46
+ }
47
+ throw new Error(
48
+ `Failed to load environment config for "${env}": ${(error as Error).message}`,
49
+ );
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Get list of available environments
55
+ */
56
+ async getAvailableEnvironments(): Promise<Environment[]> {
57
+ const { readdir } = await import("fs/promises");
58
+ const preferredOrder: Environment[] = [
59
+ "local",
60
+ "dev",
61
+ "sandbox",
62
+ "staging",
63
+ "beta",
64
+ "prod",
65
+ "production",
66
+ ];
67
+
68
+ try {
69
+ const files = await readdir(this.configDir);
70
+ const available = files
71
+ .filter((f) => f.endsWith(".json"))
72
+ .map((f) => f.replace(".json", "") as Environment)
73
+ .filter((e) => preferredOrder.includes(e));
74
+
75
+ // Sort by preferred order
76
+ return available.sort(
77
+ (a, b) => preferredOrder.indexOf(a) - preferredOrder.indexOf(b),
78
+ );
79
+ } catch {
80
+ return preferredOrder;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Check if environment exists
86
+ */
87
+ async environmentExists(env: Environment): Promise<boolean> {
88
+ const { access } = await import("fs/promises");
89
+ const configPath = join(this.configDir, `${env}.json`);
90
+ try {
91
+ await access(configPath);
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Validate environment configuration
100
+ */
101
+ private validateConfig(config: EnvironmentConfig, env: Environment): void {
102
+ if (!config.name) {
103
+ throw new Error(`Invalid config for ${env}: missing "name" field`);
104
+ }
105
+
106
+ if (config.name !== env) {
107
+ throw new Error(
108
+ `Invalid config for ${env}: "name" field is "${config.name}" but should be "${env}"`,
109
+ );
110
+ }
111
+
112
+ if (!config.baseUrls) {
113
+ throw new Error(`Invalid config for ${env}: missing "baseUrls" field`);
114
+ }
115
+
116
+ if (!config.baseUrls.web || !config.baseUrls.api) {
117
+ throw new Error(
118
+ `Invalid config for ${env}: "baseUrls" must contain "web" and "api"`,
119
+ );
120
+ }
121
+
122
+ const provider = config.auth?.provider ?? (config.auth0 ? "auth0" : "none");
123
+ if (
124
+ provider === "auth0" &&
125
+ (!config.auth0 || !config.auth0.domain || !config.auth0.clientId)
126
+ ) {
127
+ throw new Error(
128
+ `Invalid config for ${env}: missing or incomplete "auth0" field`,
129
+ );
130
+ }
131
+ if (
132
+ provider === "cognito" &&
133
+ (!config.auth?.cognito?.region || !config.auth.cognito.clientId)
134
+ ) {
135
+ throw new Error(
136
+ `Invalid config for ${env}: auth.provider "cognito" needs auth.cognito.region and auth.cognito.clientId`,
137
+ );
138
+ }
139
+ if (provider === "local-jwt" && !config.auth?.localJwt?.secretKey) {
140
+ throw new Error(
141
+ `Invalid config for ${env}: auth.provider "local-jwt" needs auth.localJwt.secretKey`,
142
+ );
143
+ }
144
+
145
+ if (!config.timeouts || typeof config.timeouts.api !== "number") {
146
+ throw new Error(
147
+ `Invalid config for ${env}: missing or invalid "timeouts" field`,
148
+ );
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Clear cache
154
+ */
155
+ clearCache(): void {
156
+ this.cache.clear();
157
+ }
158
+ }
159
+
160
+ // Export singleton instance
161
+ export const envSelector = new EnvironmentSelector();
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Playwright Config Helpers — environment resolution for sync `defineConfig` callers.
3
+ *
4
+ * The async `envSelector` (see env-selector.ts) is the canonical loader at
5
+ * runtime, but Playwright's `defineConfig` is synchronous and runs before
6
+ * any test fixtures, so it cannot await. These helpers centralize the small
7
+ * amount of env-resolution logic Playwright configs need at config-load
8
+ * time:
9
+ *
10
+ * - resolveEnv → which `Environment` is this run targeting?
11
+ * - resolveAuthPath → where is the saved browser storageState for {role,env}?
12
+ * - resolveBrowserBaseUrl → canonical web URL from config/environments/<env>.json
13
+ *
14
+ * `resolveBrowserBaseUrl` reads the env JSON synchronously (readFileSync)
15
+ * because it runs at module-eval time inside the Playwright config. This is
16
+ * the same source of truth `envSelector.loadEnvironment` consumes — just the
17
+ * sync flavour.
18
+ */
19
+ import { readFileSync } from "fs";
20
+ import { resolve } from "path";
21
+ import type { Environment, EnvironmentConfig } from "../types/index.js";
22
+ import { getDistRoot } from "../paths.js";
23
+
24
+ export type Role = "admin" | "user" | "beta";
25
+
26
+ /**
27
+ * Resolve the target environment from `E2E_ENVIRONMENT`, falling back to
28
+ * `defaultEnv` (typically "local").
29
+ */
30
+ export function resolveEnv(defaultEnv: Environment = "local"): Environment {
31
+ const fromEnv = process.env.E2E_ENVIRONMENT;
32
+ return (fromEnv && fromEnv.length > 0 ? fromEnv : defaultEnv) as Environment;
33
+ }
34
+
35
+ /**
36
+ * Absolute path to the saved browser storageState file for a given role+env.
37
+ *
38
+ * Convention: `<repoRoot>/playwright/.auth/<role>-<env>.json`. Mirrors the
39
+ * pattern used in `pillars/browser/global-setup.ts` when sessions are minted.
40
+ */
41
+ export function resolveAuthPath(
42
+ role: Role,
43
+ env: Environment,
44
+ repoRoot: string,
45
+ ): string {
46
+ return resolve(repoRoot, `playwright/.auth/${role}-${env}.json`);
47
+ }
48
+
49
+ /**
50
+ * Canonical browser-facing base URL for `env`, read synchronously from
51
+ * `config/environments/<env>.json`. Same source of truth as the async
52
+ * `envSelector.loadEnvironment(env).baseUrls.web`.
53
+ *
54
+ * Throws if the env config is missing/malformed — fail fast at config-load
55
+ * rather than letting Playwright proceed with a bogus baseURL.
56
+ */
57
+ export function resolveBrowserBaseUrl(env: Environment): string {
58
+ // read-only fail-fast lookup, den-root anchored for determinism
59
+ const configPath = resolve(getDistRoot(), `config/environments/${env}.json`);
60
+ let raw: string;
61
+ try {
62
+ raw = readFileSync(configPath, "utf-8");
63
+ } catch (error) {
64
+ throw new Error(
65
+ `[playwright-env] Cannot read environment config for "${env}" at ${configPath}: ${(error as Error).message}`,
66
+ );
67
+ }
68
+ let cfg: EnvironmentConfig;
69
+ try {
70
+ cfg = JSON.parse(raw) as EnvironmentConfig;
71
+ } catch (error) {
72
+ throw new Error(
73
+ `[playwright-env] Invalid JSON in ${configPath}: ${(error as Error).message}`,
74
+ );
75
+ }
76
+ if (!cfg.baseUrls || !cfg.baseUrls.web) {
77
+ throw new Error(
78
+ `[playwright-env] Env config ${configPath} is missing baseUrls.web`,
79
+ );
80
+ }
81
+ return cfg.baseUrls.web;
82
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Service Catalog
3
+ *
4
+ * Manages config/local/service-catalog.yml — the per-engineer registry of
5
+ * repos known to den, whether linked (developer's own checkout) or managed
6
+ * (den clones and updates them).
7
+ *
8
+ * Gracefully returns an empty catalog when the file does not exist so that
9
+ * all callers work on a fresh machine without requiring setup first.
10
+ */
11
+
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ import * as yaml from "yaml";
15
+ import type { ServiceCatalog, ServiceCatalogEntry } from "../types/index.js";
16
+ import { getDistRoot } from "../paths.js";
17
+ import { requireGithubOrg } from "../den-config.js";
18
+
19
+ /** Fallback GitHub org when `config/den.yml` doesn't set `github_org`. */
20
+
21
+ const ROOT = getDistRoot();
22
+ const CATALOG_PATH = path.join(ROOT, "config", "local", "service-catalog.yml");
23
+
24
+ export function readCatalog(): ServiceCatalog {
25
+ try {
26
+ const raw = fs.readFileSync(CATALOG_PATH, "utf-8");
27
+ const parsed = yaml.parse(raw) as unknown;
28
+ if (parsed == null) return {}; // empty file
29
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
30
+ throw new Error(
31
+ `Malformed service catalog at ${CATALOG_PATH}: expected a mapping`,
32
+ );
33
+ }
34
+ return parsed as ServiceCatalog;
35
+ } catch (err: unknown) {
36
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
37
+ throw err;
38
+ }
39
+ }
40
+
41
+ export function writeCatalog(catalog: ServiceCatalog): void {
42
+ fs.mkdirSync(path.dirname(CATALOG_PATH), { recursive: true });
43
+ fs.writeFileSync(CATALOG_PATH, yaml.stringify(catalog), "utf-8");
44
+ }
45
+
46
+ export function upsertCatalogEntry(
47
+ repoName: string,
48
+ entry: ServiceCatalogEntry,
49
+ ): void {
50
+ const catalog = readCatalog();
51
+ catalog[repoName] = entry;
52
+ writeCatalog(catalog);
53
+ }
54
+
55
+ /**
56
+ * GitHub slug to clone `repoName` from.
57
+ *
58
+ * The catalog's `gh` when set, else the conventional `<github_org>/<repo>` —
59
+ * the same rule `seedDefaultWorkspace` uses. Entries written by the
60
+ * dev-managed add flow carry `gh: ""`, so a repo can be IN the catalog and
61
+ * still have no slug; deriving it (rather than refusing to clone) is what lets
62
+ * a repo switched to managed actually be cloned.
63
+ */
64
+ export function resolveRepoGhSlug(
65
+ repoName: string,
66
+ catalog: ServiceCatalog = readCatalog(),
67
+ ): string {
68
+ const gh = catalog[repoName]?.gh?.trim();
69
+ if (gh) return gh;
70
+ return `${requireGithubOrg()}/${repoName}`;
71
+ }
72
+
73
+ export function removeCatalogEntry(repoName: string): void {
74
+ const catalog = readCatalog();
75
+ delete catalog[repoName];
76
+ writeCatalog(catalog);
77
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Clone-destination preflight (defense in depth for the Jun 29 incident).
3
+ *
4
+ * Path anchoring (getDistRoot) is the first line of defense; this guard is
5
+ * the second: even if some entry point regresses and computes a destination
6
+ * from the wrong anchor, refuse to clone anywhere that could damage another
7
+ * repo. Refusal conditions:
8
+ *
9
+ * 1. OUTSIDE DEN-OWNED LOCATIONS — destinations must live under the
10
+ * repos root, den-workspaces, .den-services, or .smoke-clones.
11
+ *
12
+ * 2. SELF-NESTING — the destination is inside a checkout of the same repo
13
+ * being cloned (e.g. ~/projects/infra-stack/infra-stack).
14
+ *
15
+ * 3. FOREIGN WORKING TREE — the destination sits inside an existing git
16
+ * working tree that den does not own. (Den's own tree is expected:
17
+ * .den-services/ and .smoke-clones/ live inside the den checkout.
18
+ * A tree that already enclosed the allowed root itself — e.g. a
19
+ * home-directory dotfiles repo above ~/projects — is the user's chosen
20
+ * layout and is allowed.)
21
+ *
22
+ * Refusals throw with the computed destination, cwd, and resolved den root
23
+ * so a misconfiguration is diagnosable in one glance.
24
+ */
25
+
26
+ import { execFileSync } from "child_process";
27
+ import * as fs from "fs";
28
+ import * as path from "path";
29
+ import { getDistRoot } from "../paths.js";
30
+ import { getReposRoot } from "../den-config.js";
31
+ import { DEN_SERVICES_DIR, DEN_WORKSPACES_DIR } from "./workspaces.js";
32
+
33
+ import { envKey, product } from "../product.js";
34
+ export class CloneDestinationError extends Error {}
35
+
36
+ /**
37
+ * Split `p` into the realpath of its nearest EXISTING ancestor plus the
38
+ * not-yet-existing suffix. Never collapses the suffix — a collapsed path
39
+ * compared against the allowlist would over- or under-permit.
40
+ */
41
+ function splitExisting(p: string): { real: string; suffix: string } {
42
+ let probe = path.resolve(p);
43
+ let suffix = "";
44
+ for (;;) {
45
+ if (fs.existsSync(probe)) {
46
+ let real = probe;
47
+ try {
48
+ real = fs.realpathSync(probe);
49
+ } catch {
50
+ /* keep unresolved */
51
+ }
52
+ return { real, suffix };
53
+ }
54
+ suffix = suffix
55
+ ? path.join(path.basename(probe), suffix)
56
+ : path.basename(probe);
57
+ const parent = path.dirname(probe);
58
+ if (parent === probe) return { real: probe, suffix };
59
+ probe = parent;
60
+ }
61
+ }
62
+
63
+ /** `p` with symlinks resolved on its existing prefix (macOS /tmp → /private/tmp). */
64
+ function realFull(p: string): string {
65
+ const { real, suffix } = splitExisting(p);
66
+ return suffix ? path.join(real, suffix) : real;
67
+ }
68
+
69
+ /** Toplevel of the git working tree containing existing dir `dir`, or null. */
70
+ function gitToplevelOf(dir: string): string | null {
71
+ try {
72
+ const out = execFileSync(
73
+ "git",
74
+ ["-C", dir, "rev-parse", "--show-toplevel"],
75
+ {
76
+ encoding: "utf-8",
77
+ stdio: ["ignore", "pipe", "ignore"],
78
+ },
79
+ ).trim();
80
+ return out ? realFull(out) : null;
81
+ } catch {
82
+ return null; // not inside a git working tree (or git unavailable)
83
+ }
84
+ }
85
+
86
+ function isWithin(child: string, parent: string): boolean {
87
+ const rel = path.relative(parent, child);
88
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
89
+ }
90
+
91
+ function refuse(
92
+ reason: string,
93
+ dest: string,
94
+ extra: Record<string, string> = {},
95
+ ): never {
96
+ const lines = [
97
+ `${product().cli}: REFUSING to clone — ${reason}`,
98
+ ` destination: ${dest}`,
99
+ ` process cwd: ${process.cwd()}`,
100
+ ` ${product().cli} root: ${getDistRoot()}`,
101
+ ];
102
+ for (const [k, v] of Object.entries(extra)) lines.push(` ${k}: ${v}`);
103
+ lines.push(
104
+ ` If the destination is wrong, fix the anchor (${envKey("ROOT")} / ` +
105
+ `repos_root in ${product().configFile}) rather than cloning by hand.`,
106
+ );
107
+ throw new CloneDestinationError(lines.join("\n"));
108
+ }
109
+
110
+ /**
111
+ * Throw (CloneDestinationError) unless `dest` is a safe place to create a
112
+ * fresh clone of `repoName`. Call BEFORE any mkdir/clone side effect.
113
+ */
114
+ export function assertSafeCloneDestination(
115
+ dest: string,
116
+ repoName: string,
117
+ ): void {
118
+ const absDest = path.resolve(dest);
119
+ const distRoot = realFull(getDistRoot());
120
+
121
+ const allowedRoots = [
122
+ realFull(getReposRoot()),
123
+ realFull(DEN_WORKSPACES_DIR),
124
+ realFull(DEN_SERVICES_DIR),
125
+ realFull(path.join(distRoot, ".smoke-clones")),
126
+ ];
127
+
128
+ const destReal = realFull(absDest);
129
+ const containingRoot = allowedRoots.find((r) => isWithin(destReal, r));
130
+ if (!containingRoot) {
131
+ refuse(
132
+ `destination is outside every ${product().cli}-owned location`,
133
+ absDest,
134
+ {
135
+ "allowed under": allowedRoots.join(" | "),
136
+ },
137
+ );
138
+ }
139
+
140
+ const enclosingTree = gitToplevelOf(
141
+ splitExisting(path.dirname(absDest)).real,
142
+ );
143
+ if (enclosingTree) {
144
+ if (path.basename(enclosingTree) === repoName) {
145
+ refuse(
146
+ `destination would nest ${repoName} inside a checkout of itself`,
147
+ absDest,
148
+ { "enclosing git working tree": enclosingTree },
149
+ );
150
+ }
151
+ const isDenTree = enclosingTree === distRoot;
152
+ // A tree that encloses the allowed root itself (repos root under a
153
+ // dotfiles-home repo, etc.) predates den's write — user's layout.
154
+ const enclosesAllowedRoot = isWithin(containingRoot, enclosingTree);
155
+ if (!isDenTree && !enclosesAllowedRoot) {
156
+ refuse(
157
+ `destination is inside a git working tree ${product().cli} does not own`,
158
+ absDest,
159
+ { "enclosing git working tree": enclosingTree },
160
+ );
161
+ }
162
+ }
163
+ }