@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,221 @@
1
+ /**
2
+ * Pure renderers for the edge's Traefik configuration. No I/O, so routing is
3
+ * unit-testable; runtime.ts writes the results.
4
+ */
5
+ import * as yaml from "yaml";
6
+ import type { EdgeConfig } from "./types.js";
7
+
8
+ const DOMAIN_RE =
9
+ /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
10
+
11
+ export function assertDomain(d: string): void {
12
+ if (!DOMAIN_RE.test(d)) throw new Error(`"${d}" is not a valid domain`);
13
+ }
14
+
15
+ function slug(s: string): string {
16
+ return s.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
17
+ }
18
+
19
+ export function edgeNames(org: string) {
20
+ const s = slug(org);
21
+ return { container: `${s}-edge`, network: `${s}-edge`, project: `${s}-edge` };
22
+ }
23
+
24
+ /** Hostname for a route: `<name>.<domain>`. */
25
+ export function routeHost(name: string, domain: string): string {
26
+ return `${name}.${domain}`;
27
+ }
28
+
29
+ /** Every apex domain the edge serves: the primary first, then aliases. */
30
+ export function edgeDomains(cfg: EdgeConfig): string[] {
31
+ return [...new Set([cfg.domain, ...(cfg.aliases ?? [])])];
32
+ }
33
+
34
+ /** `Host()` rule matching `<name>` under every edge domain. */
35
+ function hostRule(name: string, cfg: EdgeConfig): string {
36
+ return edgeDomains(cfg)
37
+ .map((d) => `Host(\`${routeHost(name, d)}\`)`)
38
+ .join(" || ");
39
+ }
40
+
41
+ /** Public URL for a route, including the port when it isn't 443. */
42
+ export function routeUrl(
43
+ name: string,
44
+ cfg: EdgeConfig,
45
+ domain = cfg.domain,
46
+ ): string {
47
+ const p = cfg.ports?.https ?? 443;
48
+ return `https://${routeHost(name, domain)}${p === 443 ? "" : `:${p}`}`;
49
+ }
50
+
51
+ /** Names that go on the org's certificate. */
52
+ export function certNames(cfg: EdgeConfig): string[] {
53
+ const zones = [...cfg.workspaces, ...(cfg.subzones ?? [])];
54
+ // A dotted route (`app.shop`) is served at app.shop.<domain>, which `*.<domain>`
55
+ // does not cover: add its zone (`shop`) unless a workspace or subzone has it.
56
+ const routeZones = Object.keys(cfg.routes)
57
+ .filter((n) => n.includes("."))
58
+ .map((n) => n.slice(n.indexOf(".") + 1))
59
+ .filter((z, i, all) => !zones.includes(z) && all.indexOf(z) === i);
60
+ return edgeDomains(cfg).flatMap((d) => [
61
+ d,
62
+ `*.${d}`,
63
+ ...[...zones, ...routeZones].map((z) => `*.${z}.${d}`),
64
+ ]);
65
+ }
66
+
67
+ /** Dynamic config: TLS cert, HTTP→HTTPS redirect, dashboard, routes. */
68
+ export function renderEdgeDynamic(cfg: EdgeConfig): string {
69
+ const httpsPort = cfg.ports?.https ?? 443;
70
+ const redirectPort = httpsPort !== 443 ? { port: String(httpsPort) } : {};
71
+ const routers: Record<string, unknown> = {
72
+ ...(cfg.httpsRedirect
73
+ ? {
74
+ "to-https": {
75
+ entryPoints: ["web"],
76
+ rule: "PathPrefix(`/`)",
77
+ priority: 1,
78
+ middlewares: ["to-https"],
79
+ service: "noop@internal",
80
+ },
81
+ }
82
+ : {}),
83
+ dashboard: {
84
+ entryPoints: ["websecure"],
85
+ rule: hostRule("edge", cfg),
86
+ service: "api@internal",
87
+ tls: {},
88
+ },
89
+ };
90
+ const services: Record<string, unknown> = {};
91
+ for (const [name, url] of Object.entries(cfg.routes).sort()) {
92
+ if (name === "edge")
93
+ throw new Error(`route name "edge" is reserved for the dashboard`);
94
+ if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(name))
95
+ throw new Error(
96
+ `route name "${name}" must be lowercase letters, digits, dots or dashes`,
97
+ );
98
+ const key = `route-${slug(name)}`;
99
+ routers[key] = {
100
+ entryPoints: ["websecure"],
101
+ rule: hostRule(name, cfg),
102
+ service: key,
103
+ tls: {},
104
+ };
105
+ services[key] = { loadBalancer: { servers: [{ url }] } };
106
+ }
107
+ const cert = {
108
+ certFile: "/etc/edge/certs/edge.pem",
109
+ keyFile: "/etc/edge/certs/edge-key.pem",
110
+ };
111
+ return (
112
+ "# Generated by `edge up` — edit config, not this file.\n" +
113
+ yaml.stringify({
114
+ http: {
115
+ routers,
116
+ ...(cfg.httpsRedirect
117
+ ? {
118
+ middlewares: {
119
+ "to-https": {
120
+ redirectScheme: {
121
+ scheme: "https",
122
+ permanent: false,
123
+ ...redirectPort,
124
+ },
125
+ },
126
+ },
127
+ }
128
+ : {}),
129
+ ...(Object.keys(services).length ? { services } : {}),
130
+ },
131
+ ...(cfg.issueCert
132
+ ? {
133
+ tls: {
134
+ stores: { default: { defaultCertificate: cert } },
135
+ certificates: [cert],
136
+ },
137
+ }
138
+ : {}),
139
+ })
140
+ );
141
+ }
142
+
143
+ /** The socket proxy's container (and its private network) for docker_labels. */
144
+ export function dockerProxyName(org: string): string {
145
+ return `${edgeNames(org).container}-docker`;
146
+ }
147
+
148
+ /** Compose file for the edge; publishes host ports only when configured. */
149
+ export function renderEdgeCompose(cfg: EdgeConfig): string {
150
+ const n = edgeNames(cfg.org);
151
+ const proxy = dockerProxyName(cfg.org);
152
+ const labels = cfg.dockerLabels;
153
+ const external = [
154
+ ...new Set([...(labels ? [labels.network] : []), ...cfg.networks]),
155
+ ].filter((x) => x !== n.network && x !== proxy);
156
+ const edgeNets = [n.network, ...(labels ? [proxy] : []), ...external];
157
+ const services: Record<string, unknown> = {
158
+ edge: {
159
+ image: cfg.image,
160
+ container_name: n.container,
161
+ restart: "unless-stopped",
162
+ ...(labels ? { depends_on: ["docker-proxy"] } : {}),
163
+ command: [
164
+ "--entrypoints.web.address=:80",
165
+ "--entrypoints.websecure.address=:443",
166
+ "--providers.file.directory=/etc/edge/dynamic",
167
+ "--providers.file.watch=true",
168
+ ...(labels
169
+ ? [
170
+ "--providers.docker=true",
171
+ `--providers.docker.endpoint=tcp://${proxy}:2375`,
172
+ "--providers.docker.exposedbydefault=false",
173
+ `--providers.docker.network=${labels.network}`,
174
+ ]
175
+ : []),
176
+ "--api.dashboard=true",
177
+ "--ping=true",
178
+ "--log.level=WARN",
179
+ ...cfg.args,
180
+ ],
181
+ ...(cfg.ports
182
+ ? { ports: [`${cfg.ports.http}:80`, `${cfg.ports.https}:443`] }
183
+ : {}),
184
+ extra_hosts: ["host.docker.internal:host-gateway"],
185
+ volumes: [
186
+ "./dynamic:/etc/edge/dynamic:ro",
187
+ ...(cfg.issueCert ? ["./certs:/etc/edge/certs:ro"] : []),
188
+ ...Object.entries(cfg.mounts)
189
+ .sort(([a], [b]) => a.localeCompare(b))
190
+ .map(([host, target]) => `${host}:${target}:ro`),
191
+ ],
192
+ networks: edgeNets,
193
+ },
194
+ };
195
+ if (labels)
196
+ // Read-only Docker API: container listing and events, nothing that writes.
197
+ services["docker-proxy"] = {
198
+ image: "tecnativa/docker-socket-proxy:0.3.0",
199
+ container_name: proxy,
200
+ restart: "unless-stopped",
201
+ environment: {
202
+ CONTAINERS: "1",
203
+ NETWORKS: "1",
204
+ EVENTS: "1",
205
+ PING: "1",
206
+ VERSION: "1",
207
+ POST: "0",
208
+ },
209
+ volumes: ["/var/run/docker.sock:/var/run/docker.sock:ro"],
210
+ networks: [proxy],
211
+ };
212
+ return yaml.stringify({
213
+ name: n.project,
214
+ services,
215
+ networks: {
216
+ [n.network]: { external: true },
217
+ ...(labels ? { [proxy]: { internal: true } } : {}),
218
+ ...Object.fromEntries(external.map((x) => [x, { external: true }])),
219
+ },
220
+ });
221
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Side effects for the edge: docker, mkcert, files. Thin on purpose — every
3
+ * routing decision lives in render.ts.
4
+ */
5
+ import { spawnSync } from "child_process";
6
+ import * as fs from "fs";
7
+ import * as net from "net";
8
+ import * as path from "path";
9
+ import {
10
+ certNames,
11
+ edgeNames,
12
+ renderEdgeCompose,
13
+ renderEdgeDynamic,
14
+ } from "./render.js";
15
+ import type { EdgeConfig } from "./types.js";
16
+
17
+ function sh(
18
+ cmd: string,
19
+ args: string[],
20
+ opts: { env?: NodeJS.ProcessEnv; inherit?: boolean } = {},
21
+ ) {
22
+ const r = spawnSync(cmd, args, {
23
+ encoding: "utf-8",
24
+ env: { ...process.env, ...opts.env },
25
+ stdio: opts.inherit ? "inherit" : "pipe",
26
+ });
27
+ if (r.error) throw new Error(`${cmd} is not installed (${r.error.message})`);
28
+ return {
29
+ code: r.status ?? 1,
30
+ out: (r.stdout ?? "").trim(),
31
+ err: (r.stderr ?? "").trim(),
32
+ };
33
+ }
34
+
35
+ function must(
36
+ cmd: string,
37
+ args: string[],
38
+ opts: { env?: NodeJS.ProcessEnv } = {},
39
+ ): string {
40
+ const r = sh(cmd, args, opts);
41
+ if (r.code !== 0)
42
+ throw new Error(`${cmd} ${args.join(" ")} failed:\n${r.err || r.out}`);
43
+ return r.out;
44
+ }
45
+
46
+ function write(file: string, content: string): void {
47
+ fs.mkdirSync(path.dirname(file), { recursive: true });
48
+ fs.writeFileSync(file, content, "utf-8");
49
+ }
50
+
51
+ function ensureNetwork(name: string): void {
52
+ if (sh("docker", ["network", "inspect", name]).code !== 0)
53
+ must("docker", ["network", "create", name]);
54
+ }
55
+
56
+ export function containerState(name: string): string {
57
+ const r = sh("docker", ["inspect", "-f", "{{.State.Status}}", name]);
58
+ return r.code === 0 ? r.out : "absent";
59
+ }
60
+
61
+ /** True when something on 127.0.0.1 accepts connections on `port`. */
62
+ export function portInUse(port: number): Promise<boolean> {
63
+ return new Promise((resolve) => {
64
+ const s = net.connect({ host: "127.0.0.1", port });
65
+ s.once("connect", () => (s.destroy(), resolve(true)));
66
+ s.once("error", () => resolve(false));
67
+ s.setTimeout(500, () => (s.destroy(), resolve(false)));
68
+ });
69
+ }
70
+
71
+ // ── Certificates (org-scoped CA) ─────────────────────────────────────────────
72
+
73
+ export function caDir(cfg: EdgeConfig): string {
74
+ return cfg.caroot ?? path.join(cfg.stateDir, "ca");
75
+ }
76
+
77
+ /** True when the edge uses an existing CA (`caroot`) rather than its own. */
78
+ export function sharedCa(cfg: EdgeConfig): boolean {
79
+ return cfg.caroot !== null;
80
+ }
81
+
82
+ /**
83
+ * A shared CA must already exist with its key: mkcert silently creates a new
84
+ * CA in an empty CAROOT, and devices that trust the shared one would not
85
+ * trust that. The org's own CA may be created on first use.
86
+ */
87
+ export function assertCaUsable(cfg: EdgeConfig): void {
88
+ if (!sharedCa(cfg)) return;
89
+ const dir = caDir(cfg);
90
+ if (!fs.existsSync(dir))
91
+ throw new Error(
92
+ `edge.caroot ${dir} does not exist. The edge never creates a CA there; ` +
93
+ `point caroot at the existing CA (\`mkcert -CAROOT\`).`,
94
+ );
95
+ for (const f of ["rootCA.pem", "rootCA-key.pem"])
96
+ if (!fs.existsSync(path.join(dir, f)))
97
+ throw new Error(
98
+ `edge.caroot ${dir} has no ${f}. Refusing to run: mkcert would mint a ` +
99
+ `new CA that other devices don't trust.`,
100
+ );
101
+ }
102
+
103
+ function mkcertEnv(cfg: EdgeConfig): NodeJS.ProcessEnv {
104
+ return { CAROOT: caDir(cfg) };
105
+ }
106
+
107
+ /** Issue (or re-issue when names changed) the edge cert from the org's own CA. */
108
+ export function ensureCerts(cfg: EdgeConfig): {
109
+ issued: boolean;
110
+ names: string[];
111
+ } {
112
+ const names = certNames(cfg);
113
+ if (!cfg.issueCert) return { issued: false, names: [] };
114
+ assertCaUsable(cfg);
115
+ const dir = path.join(cfg.stateDir, "certs");
116
+ const stamp = path.join(dir, "names.txt");
117
+ const want = names.join("\n");
118
+ if (
119
+ fs.existsSync(path.join(dir, "edge.pem")) &&
120
+ fs.existsSync(stamp) &&
121
+ fs.readFileSync(stamp, "utf-8") === want
122
+ )
123
+ return { issued: false, names };
124
+ fs.mkdirSync(dir, { recursive: true });
125
+ if (!sharedCa(cfg))
126
+ fs.mkdirSync(caDir(cfg), { recursive: true, mode: 0o700 });
127
+ must(
128
+ "mkcert",
129
+ [
130
+ "-cert-file",
131
+ path.join(dir, "edge.pem"),
132
+ "-key-file",
133
+ path.join(dir, "edge-key.pem"),
134
+ ...names,
135
+ ],
136
+ { env: mkcertEnv(cfg) },
137
+ );
138
+ write(stamp, want);
139
+ return { issued: true, names };
140
+ }
141
+
142
+ /** Install the org CA into the system + browser trust stores (prompts for a password). */
143
+ export function trustCa(cfg: EdgeConfig): number {
144
+ assertCaUsable(cfg);
145
+ if (!sharedCa(cfg))
146
+ fs.mkdirSync(caDir(cfg), { recursive: true, mode: 0o700 });
147
+ return sh("mkcert", ["-install"], { env: mkcertEnv(cfg), inherit: true })
148
+ .code;
149
+ }
150
+
151
+ export function untrustCa(cfg: EdgeConfig): number {
152
+ if (sharedCa(cfg))
153
+ throw new Error(
154
+ `edge.caroot is a shared CA (${caDir(cfg)}); other tools and devices ` +
155
+ `rely on it being trusted, so the edge never uninstalls it.`,
156
+ );
157
+ return sh("mkcert", ["-uninstall"], { env: mkcertEnv(cfg), inherit: true })
158
+ .code;
159
+ }
160
+
161
+ /** Whether the org CA is in the macOS system keychain (null: can't tell). */
162
+ export function caTrusted(cfg: EdgeConfig): boolean | null {
163
+ const pem = path.join(caDir(cfg), "rootCA.pem");
164
+ if (!fs.existsSync(pem)) return false;
165
+ if (process.platform !== "darwin") return null;
166
+ return sh("security", ["verify-cert", "-c", pem]).code === 0;
167
+ }
168
+
169
+ // ── Included dynamic config ─────────────────────────────────────────────────
170
+
171
+ const GENERATED = "edge.yml";
172
+
173
+ /** The dynamic-config files in `dir` to copy in (top level, YAML only). */
174
+ export function includeFiles(dir: string | null): string[] {
175
+ if (!dir) return [];
176
+ return fs
177
+ .readdirSync(dir, { withFileTypes: true })
178
+ .filter(
179
+ (e) => e.isFile() && /\.ya?ml$/.test(e.name) && e.name !== GENERATED,
180
+ )
181
+ .map((e) => e.name)
182
+ .sort();
183
+ }
184
+
185
+ /**
186
+ * Names the last sync copied in. Kept in the state dir, outside dynamic/,
187
+ * so the watching Traefik never reads it.
188
+ */
189
+ const INCLUDED_LEDGER = "included.json";
190
+
191
+ /** Files other writers own in the dynamic directory: never copied over or pruned. */
192
+ const isOwnedElsewhere = (f: string): boolean =>
193
+ f === GENERATED || /^stack-.*\.ya?ml$/.test(f);
194
+
195
+ function readLedger(stateDir: string): string[] {
196
+ try {
197
+ const v: unknown = JSON.parse(
198
+ fs.readFileSync(path.join(stateDir, INCLUDED_LEDGER), "utf-8"),
199
+ );
200
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
201
+ } catch {
202
+ return []; // no ledger yet: nothing is known to be ours, so prune nothing
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Copy the included files beside the generated one, then drop copies whose
208
+ * source is gone. Only files a previous sync copied in (its ledger) are ever
209
+ * removed: stacks write their own route files (stack-*.yml) into the same
210
+ * directory, and those belong to the running stacks. New files land before
211
+ * stale ones go, so the watching Traefik never sees a gap.
212
+ */
213
+ export function syncIncluded(cfg: EdgeConfig): string[] {
214
+ const dyn = path.join(cfg.stateDir, "dynamic");
215
+ fs.mkdirSync(dyn, { recursive: true });
216
+ const files = includeFiles(cfg.include).filter((f) => !isOwnedElsewhere(f));
217
+ for (const f of files)
218
+ fs.copyFileSync(path.join(cfg.include!, f), path.join(dyn, f));
219
+ for (const f of readLedger(cfg.stateDir))
220
+ if (!files.includes(f) && !isOwnedElsewhere(f) && !f.includes("/"))
221
+ fs.rmSync(path.join(dyn, f), { force: true });
222
+ fs.writeFileSync(
223
+ path.join(cfg.stateDir, INCLUDED_LEDGER),
224
+ JSON.stringify(files, null, 2) + "\n",
225
+ );
226
+ return files;
227
+ }
228
+
229
+ // ── Edge ─────────────────────────────────────────────────────────────────────
230
+
231
+ export async function edgeUp(cfg: EdgeConfig) {
232
+ const n = edgeNames(cfg.org);
233
+ if (cfg.ports && containerState(n.container) !== "running") {
234
+ for (const p of [cfg.ports.http, cfg.ports.https])
235
+ if (await portInUse(p))
236
+ throw new Error(
237
+ `port ${p} is already in use. Free it, pick other ports ` +
238
+ `(edge.http_port / edge.https_port in config/local/user.yml), or ` +
239
+ `set edge.publish: false if another proxy forwards to ${n.container}.`,
240
+ );
241
+ }
242
+ assertCaUsable(cfg);
243
+ ensureNetwork(n.network);
244
+ for (const net of [
245
+ ...(cfg.dockerLabels ? [cfg.dockerLabels.network] : []),
246
+ ...cfg.networks,
247
+ ])
248
+ if (sh("docker", ["network", "inspect", net]).code !== 0)
249
+ throw new Error(
250
+ `Docker network ${net} does not exist; the edge joins it but never creates it.`,
251
+ );
252
+ for (const host of Object.keys(cfg.mounts))
253
+ if (!fs.existsSync(host))
254
+ throw new Error(`edge.mounts: ${host} does not exist on this machine`);
255
+ const certs = ensureCerts(cfg);
256
+ const included = syncIncluded(cfg);
257
+ write(path.join(cfg.stateDir, "dynamic", GENERATED), renderEdgeDynamic(cfg));
258
+ const compose = path.join(cfg.stateDir, "compose.yml");
259
+ write(compose, renderEdgeCompose(cfg));
260
+ must("docker", ["compose", "-f", compose, "up", "-d", "--remove-orphans"]);
261
+ return { certs, included };
262
+ }
263
+
264
+ export function edgeDown(cfg: EdgeConfig): void {
265
+ const compose = path.join(cfg.stateDir, "compose.yml");
266
+ if (fs.existsSync(compose))
267
+ must("docker", ["compose", "-f", compose, "down"]);
268
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The org's HTTPS edge: one Traefik that terminates TLS with the org's own CA
3
+ * (mkcert, separate CAROOT) and routes `<name>.<domain>` to local services.
4
+ * File-provider routes, plus Docker labels when `docker_labels` opts in
5
+ * (through a read-only socket proxy; the edge never mounts the socket).
6
+ *
7
+ * Standalone by default: the edge publishes host :80/:443 itself. Set
8
+ * `edge.publish: false` when something else on the machine owns those ports
9
+ * and forwards to the edge over its Docker network (`<org>-edge`); the edge
10
+ * neither knows nor cares what that is.
11
+ */
12
+
13
+ export interface EdgePorts {
14
+ http: number;
15
+ https: number;
16
+ }
17
+
18
+ export interface EdgeConfig {
19
+ /** Org slug — names the edge container and its Docker network. */
20
+ org: string;
21
+ /** Apex domain; every subdomain is served. */
22
+ domain: string;
23
+ /** More apex domains served identically (e.g. a tailnet-resolvable one). */
24
+ aliases: string[];
25
+ /** Pinned Traefik image. */
26
+ image: string;
27
+ /** Workspaces that get a wildcard cert (`*.<ws>.<domain>`). */
28
+ workspaces: string[];
29
+ /** Extra wildcard labels on the cert (`*.<label>.<domain>`), e.g. stack names. */
30
+ subzones?: string[];
31
+ /** `<name>` → upstream URL, served at `https://<name>.<domain>`. */
32
+ routes: Record<string, string>;
33
+ /** Host ports to publish, or null when another proxy fronts the edge. */
34
+ ports: EdgePorts | null;
35
+ /** Where the org's CA, certs and generated config live. */
36
+ stateDir: string;
37
+ /**
38
+ * An existing mkcert CA to use instead of the org's own under `stateDir`
39
+ * (e.g. a CA other devices already trust). The edge refuses to run if its
40
+ * key is missing, so mkcert can never mint a replacement, and it never
41
+ * uninstalls a shared CA.
42
+ */
43
+ caroot: string | null;
44
+ /** Issue the edge's own wildcard cert. Off when included config holds the certs. */
45
+ issueCert: boolean;
46
+ /** Catch-all HTTP→HTTPS redirect. Off when included config decides per name. */
47
+ httpsRedirect: boolean;
48
+ /**
49
+ * Opt-in: also route by `traefik.*` labels on containers, read through a
50
+ * read-only socket proxy. Nothing is exposed unless labelled; `network` is
51
+ * the Docker network the edge reaches labelled containers on.
52
+ */
53
+ dockerLabels: { network: string } | null;
54
+ /** A directory of Traefik dynamic-config files copied in beside the generated one. */
55
+ include: string | null;
56
+ /** Extra read-only bind mounts: host path → container path. */
57
+ mounts: Record<string, string>;
58
+ /** Extra external Docker networks the edge joins. */
59
+ networks: string[];
60
+ /** Extra Traefik static-config arguments. */
61
+ args: string[];
62
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Den's own files, wherever Den is installed.
3
+ *
4
+ * Two roots, never confused:
5
+ * - the engine root (getEngineRoot): Den's package — src/, scripts/, make/.
6
+ * In a distribution that is node_modules/@monster-orange/den.
7
+ * - the distribution root (getDistRoot): den.yml, config/, manifests/,
8
+ * tests/pillars/, reports/, results/.
9
+ * In Den's own checkout they are the same directory.
10
+ *
11
+ * Anything that names an engine file or runs an engine script goes through
12
+ * here instead of joining "src/…" onto the dist root or relying on the cwd.
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import { getDistRoot, getEngineRoot } from "./paths.js";
17
+
18
+ /** An absolute path inside Den's own package. */
19
+ export function enginePath(...parts: string[]): string {
20
+ return path.join(getEngineRoot(), ...parts);
21
+ }
22
+
23
+ /**
24
+ * A binary Den depends on (tsx, playwright, …): the nearest
25
+ * node_modules/.bin/<name> at or above the engine root. npm hoists Den's
26
+ * dependencies into the distribution's node_modules, so the walk covers both
27
+ * a Den checkout and a packaged install. Falls back to the bare name (PATH).
28
+ */
29
+ export function engineBin(name: string, engineRoot = getEngineRoot()): string {
30
+ let dir = path.resolve(engineRoot);
31
+ for (;;) {
32
+ const candidate = path.join(dir, "node_modules", ".bin", name);
33
+ if (fs.existsSync(candidate)) return candidate;
34
+ const parent = path.dirname(dir);
35
+ if (parent === dir) return name;
36
+ dir = parent;
37
+ }
38
+ }
39
+
40
+ /** The tsx binary that runs Den's TypeScript sources. */
41
+ export function tsxBin(): string {
42
+ return engineBin("tsx");
43
+ }
44
+
45
+ /** A command that runs one of Den's scripts with tsx. */
46
+ export interface EngineCommand {
47
+ command: string;
48
+ args: string[];
49
+ }
50
+
51
+ /** `tsx <engine>/<script> ...args`. `script` is relative to the engine root. */
52
+ export function tsxCommand(script: string, ...args: string[]): EngineCommand {
53
+ return { command: tsxBin(), args: [enginePath(script), ...args] };
54
+ }
55
+
56
+ /**
57
+ * An absolute path inside the distribution (reports, results, staged tests,
58
+ * config): the counterpart of enginePath. Same directory in Den's checkout.
59
+ */
60
+ export function distPath(...parts: string[]): string {
61
+ return path.join(getDistRoot(), ...parts);
62
+ }
63
+
64
+ /** `playwright ...args` from Den's own @playwright/test. */
65
+ export function playwrightCommand(...args: string[]): EngineCommand {
66
+ return { command: engineBin("playwright"), args };
67
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * AWS Profile loader
3
+ *
4
+ * Reads config/local/aws-profiles.json — a gitignored per-user file that maps
5
+ * den environments to AWS CLI profile names (set via `make aws-configure`).
6
+ *
7
+ * Returns empty object if the file doesn't exist (local-only usage).
8
+ */
9
+
10
+ import * as fs from "fs";
11
+ import * as path from "path";
12
+ import type { Environment } from "../types/index.js";
13
+ import { getDistRoot } from "../paths.js";
14
+
15
+ // The profiles file lives under the den repo root, not the caller's cwd —
16
+ // den is frequently invoked from inside other repos.
17
+ const PROFILES_FILE = path.resolve(
18
+ getDistRoot(),
19
+ "config",
20
+ "local",
21
+ "aws-profiles.json",
22
+ );
23
+
24
+ let cache: Partial<Record<Environment, string>> | null = null;
25
+
26
+ export function loadAwsProfiles(): Partial<Record<Environment, string>> {
27
+ if (cache) return cache;
28
+ if (!fs.existsSync(PROFILES_FILE)) return (cache = {});
29
+ try {
30
+ cache = JSON.parse(fs.readFileSync(PROFILES_FILE, "utf-8"));
31
+ return cache!;
32
+ } catch {
33
+ return (cache = {});
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Returns { AWS_PROFILE: "..." } for the given env if a profile is configured,
39
+ * otherwise returns {}. Spread into process.env when spawning AWS CLI commands.
40
+ */
41
+ export function awsProfileEnv(env: Environment): Record<string, string> {
42
+ const profile = loadAwsProfiles()[env];
43
+ return profile ? { AWS_PROFILE: profile } : {};
44
+ }
45
+
46
+ /** Invalidate cache (used in tests or after aws-configure runs). */
47
+ export function clearAwsProfileCache(): void {
48
+ cache = null;
49
+ }