@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,266 @@
1
+ /**
2
+ * Result Reader
3
+ * Reads and queries test results from JSONL files
4
+ */
5
+
6
+ import { readFile, readdir } from "fs/promises";
7
+ import { resolve, join } from "path";
8
+ import type {
9
+ TestResult,
10
+ TestSummary,
11
+ FlakyTest,
12
+ TrendData,
13
+ Pillar,
14
+ } from "../types/index.js";
15
+
16
+ export interface QueryFilters {
17
+ pillar?: Pillar;
18
+ environment?: string;
19
+ dateFrom?: Date;
20
+ dateTo?: Date;
21
+ status?: "passed" | "failed" | "skipped";
22
+ user?: string;
23
+ }
24
+
25
+ export class ResultReader {
26
+ private resultsDir: string;
27
+
28
+ constructor(resultsDir: string = "./results") {
29
+ this.resultsDir = resolve(resultsDir);
30
+ }
31
+
32
+ /**
33
+ * Query results with filters
34
+ */
35
+ async query(filters: QueryFilters = {}): Promise<TestResult[]> {
36
+ const files = await this.getRelevantFiles(filters);
37
+ const results: TestResult[] = [];
38
+
39
+ for (const file of files) {
40
+ try {
41
+ const fileResults = await this.readJSONLFile(file);
42
+ const filtered = fileResults.filter((r) =>
43
+ this.matchesFilters(r, filters),
44
+ );
45
+ results.push(...filtered);
46
+ } catch (error) {
47
+ console.warn(
48
+ `Failed to read file ${file}: ${(error as Error).message}`,
49
+ );
50
+ }
51
+ }
52
+
53
+ // Sort by timestamp (newest first)
54
+ return results.sort(
55
+ (a, b) =>
56
+ new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Get summary statistics for a pillar/environment
62
+ */
63
+ async getSummary(
64
+ pillar: Pillar,
65
+ environment: string,
66
+ days: number = 7,
67
+ ): Promise<TestSummary> {
68
+ const dateFrom = new Date();
69
+ dateFrom.setDate(dateFrom.getDate() - days);
70
+
71
+ const results = await this.query({ pillar, environment, dateFrom });
72
+
73
+ const total = results.length;
74
+ const passed = results.filter((r) => r.status === "passed").length;
75
+ const failed = results.filter((r) => r.status === "failed").length;
76
+ const skipped = results.filter((r) => r.status === "skipped").length;
77
+ const duration = results.reduce((sum, r) => sum + r.duration, 0);
78
+ const passRate = total > 0 ? (passed / total) * 100 : 0;
79
+
80
+ return {
81
+ pillar,
82
+ environment,
83
+ total,
84
+ passed,
85
+ failed,
86
+ skipped,
87
+ duration,
88
+ passRate,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Find flaky tests (tests that fail inconsistently)
94
+ */
95
+ async getFlakyTests(pillar: Pillar, days: number = 30): Promise<FlakyTest[]> {
96
+ const dateFrom = new Date();
97
+ dateFrom.setDate(dateFrom.getDate() - days);
98
+
99
+ const results = await this.query({ pillar, dateFrom });
100
+
101
+ // Group results by test name
102
+ const testGroups = results.reduce(
103
+ (acc, r) => {
104
+ if (!acc[r.test]) {
105
+ acc[r.test] = [];
106
+ }
107
+ acc[r.test].push(r);
108
+ return acc;
109
+ },
110
+ {} as Record<string, TestResult[]>,
111
+ );
112
+
113
+ // Find tests with both passes and failures
114
+ const flakyTests: FlakyTest[] = [];
115
+
116
+ for (const [testName, testResults] of Object.entries(testGroups)) {
117
+ const runs = testResults.length;
118
+ const failures = testResults.filter((r) => r.status === "failed").length;
119
+
120
+ // Consider a test flaky if it has both passes and failures
121
+ if (failures > 0 && failures < runs && runs >= 3) {
122
+ flakyTests.push({
123
+ test: testName,
124
+ runs,
125
+ failures,
126
+ flakyRate: (failures / runs) * 100,
127
+ });
128
+ }
129
+ }
130
+
131
+ // Sort by flaky rate (most flaky first)
132
+ return flakyTests.sort((a, b) => b.flakyRate - a.flakyRate);
133
+ }
134
+
135
+ /**
136
+ * Get trend data for visualization
137
+ */
138
+ async getTrends(
139
+ pillar: Pillar,
140
+ environment: string,
141
+ days: number = 30,
142
+ ): Promise<TrendData[]> {
143
+ const dateFrom = new Date();
144
+ dateFrom.setDate(dateFrom.getDate() - days);
145
+
146
+ const results = await this.query({ pillar, environment, dateFrom });
147
+
148
+ // Group results by date
149
+ const dateGroups = results.reduce(
150
+ (acc, r) => {
151
+ const date = r.timestamp.split("T")[0];
152
+ if (!acc[date]) {
153
+ acc[date] = [];
154
+ }
155
+ acc[date].push(r);
156
+ return acc;
157
+ },
158
+ {} as Record<string, TestResult[]>,
159
+ );
160
+
161
+ // Calculate metrics for each date
162
+ const trends: TrendData[] = [];
163
+
164
+ for (const [date, dayResults] of Object.entries(dateGroups)) {
165
+ const total = dayResults.length;
166
+ const passed = dayResults.filter((r) => r.status === "passed").length;
167
+ const passRate = total > 0 ? (passed / total) * 100 : 0;
168
+ const avgDuration =
169
+ total > 0
170
+ ? dayResults.reduce((sum, r) => sum + r.duration, 0) / total
171
+ : 0;
172
+
173
+ trends.push({
174
+ date,
175
+ passRate,
176
+ avgDuration,
177
+ totalTests: total,
178
+ });
179
+ }
180
+
181
+ // Sort by date (oldest first)
182
+ return trends.sort((a, b) => a.date.localeCompare(b.date));
183
+ }
184
+
185
+ /**
186
+ * Read JSONL file and parse results
187
+ */
188
+ private async readJSONLFile(filepath: string): Promise<TestResult[]> {
189
+ const content = await readFile(filepath, "utf-8");
190
+ const lines = content
191
+ .trim()
192
+ .split("\n")
193
+ .filter((l) => l.length > 0);
194
+
195
+ return lines
196
+ .map((line) => {
197
+ try {
198
+ return JSON.parse(line) as TestResult;
199
+ } catch (error) {
200
+ console.warn(`Failed to parse line in ${filepath}: ${line}`);
201
+ return null;
202
+ }
203
+ })
204
+ .filter((r): r is TestResult => r !== null);
205
+ }
206
+
207
+ /**
208
+ * Get relevant files based on filters
209
+ */
210
+ private async getRelevantFiles(filters: QueryFilters): Promise<string[]> {
211
+ try {
212
+ const files = await readdir(this.resultsDir);
213
+
214
+ return files
215
+ .filter((f) => f.endsWith(".jsonl"))
216
+ .filter((f) => {
217
+ // Filter by pillar if specified
218
+ if (filters.pillar && !f.startsWith(filters.pillar)) {
219
+ return false;
220
+ }
221
+
222
+ // Filter by environment if specified
223
+ if (filters.environment && !f.includes(`-${filters.environment}-`)) {
224
+ return false;
225
+ }
226
+
227
+ return true;
228
+ })
229
+ .map((f) => join(this.resultsDir, f));
230
+ } catch {
231
+ return [];
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Check if result matches filters
237
+ */
238
+ private matchesFilters(result: TestResult, filters: QueryFilters): boolean {
239
+ if (filters.status && result.status !== filters.status) {
240
+ return false;
241
+ }
242
+
243
+ if (filters.user && result.user !== filters.user) {
244
+ return false;
245
+ }
246
+
247
+ if (filters.dateFrom) {
248
+ const resultDate = new Date(result.timestamp);
249
+ if (resultDate < filters.dateFrom) {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ if (filters.dateTo) {
255
+ const resultDate = new Date(result.timestamp);
256
+ if (resultDate > filters.dateTo) {
257
+ return false;
258
+ }
259
+ }
260
+
261
+ return true;
262
+ }
263
+ }
264
+
265
+ // Export singleton instance
266
+ export const resultReader = new ResultReader();
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Result Writer
3
+ * Writes test results to JSONL (JSON Lines) format
4
+ */
5
+
6
+ import {
7
+ appendFile,
8
+ writeFile,
9
+ readFile,
10
+ mkdir,
11
+ rename,
12
+ unlink,
13
+ } from "fs/promises";
14
+ import { resolve, join } from "path";
15
+ import type { TestResult, Pillar } from "../types/index.js";
16
+
17
+ export class ResultWriter {
18
+ private resultsDir: string;
19
+
20
+ constructor(resultsDir: string = "./results") {
21
+ this.resultsDir = resolve(resultsDir);
22
+ }
23
+
24
+ /**
25
+ * Write a single test result to JSONL file
26
+ */
27
+ async writeResult(result: TestResult): Promise<void> {
28
+ const filename = this.getFilename(result.pillar, result.environment);
29
+
30
+ try {
31
+ // Ensure results directory exists
32
+ await mkdir(this.resultsDir, { recursive: true });
33
+
34
+ // Append result as JSON line
35
+ const line = JSON.stringify(result) + "\n";
36
+ await appendFile(filename, line);
37
+
38
+ // Update index
39
+ await this.updateIndex(result.pillar, result.environment);
40
+ } catch (error) {
41
+ throw new Error(`Failed to write result: ${(error as Error).message}`);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Write multiple test results in batch
47
+ */
48
+ async writeResults(results: TestResult[]): Promise<void> {
49
+ if (results.length === 0) {
50
+ return;
51
+ }
52
+
53
+ // Group results by pillar and environment
54
+ const grouped = this.groupResults(results);
55
+
56
+ for (const [key, groupResults] of Object.entries(grouped)) {
57
+ const [pillar, environment] = key.split(":");
58
+ const filename = this.getFilename(pillar as Pillar, environment);
59
+
60
+ try {
61
+ // Ensure results directory exists
62
+ await mkdir(this.resultsDir, { recursive: true });
63
+
64
+ // Write all results as JSON lines
65
+ const lines =
66
+ groupResults.map((r) => JSON.stringify(r)).join("\n") + "\n";
67
+ await appendFile(filename, lines);
68
+
69
+ // Update index
70
+ await this.updateIndex(pillar as Pillar, environment);
71
+ } catch (error) {
72
+ throw new Error(
73
+ `Failed to write results for ${pillar}/${environment}: ${(error as Error).message}`,
74
+ );
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Update results index for quick lookups
81
+ */
82
+ private async updateIndex(
83
+ pillar: Pillar,
84
+ environment: string,
85
+ ): Promise<void> {
86
+ const indexPath = join(this.resultsDir, "index.json");
87
+
88
+ try {
89
+ let index: Record<string, unknown> = {};
90
+
91
+ // Load existing index. Distinguish "no index yet" (ENOENT → start fresh)
92
+ // from a corrupt/unreadable existing file — clobbering a corrupt index
93
+ // with {} would silently wipe every other pillar/env entry.
94
+ let content: string | undefined;
95
+ try {
96
+ content = await readFile(indexPath, "utf-8");
97
+ } catch (e) {
98
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
99
+ }
100
+ if (content !== undefined) {
101
+ const parsed = JSON.parse(content) as unknown;
102
+ if (
103
+ parsed === null ||
104
+ typeof parsed !== "object" ||
105
+ Array.isArray(parsed)
106
+ ) {
107
+ throw new Error("existing index.json is not an object");
108
+ }
109
+ index = parsed as Record<string, unknown>;
110
+ }
111
+
112
+ const key = `${pillar}:${environment}`;
113
+ const date = new Date().toISOString().split("T")[0];
114
+
115
+ // Update index entry
116
+ index[key] = {
117
+ pillar,
118
+ environment,
119
+ lastUpdated: new Date().toISOString(),
120
+ latestFile: this.getFilename(pillar, environment),
121
+ date,
122
+ };
123
+
124
+ // Write atomically (temp + rename) so a crash/concurrent worker can't
125
+ // leave a half-written index.json.
126
+ const tmp = `${indexPath}.${process.pid}.tmp`;
127
+ try {
128
+ await writeFile(tmp, JSON.stringify(index, null, 2));
129
+ await rename(tmp, indexPath);
130
+ } catch (err) {
131
+ // Don't leave an orphaned temp file behind when the write/rename fails.
132
+ await unlink(tmp).catch(() => {});
133
+ throw err;
134
+ }
135
+ } catch (error) {
136
+ // Index update is non-critical, just log error
137
+ console.warn(`Failed to update index: ${(error as Error).message}`);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Get filename for results
143
+ */
144
+ private getFilename(pillar: string, environment: string): string {
145
+ const date = new Date().toISOString().split("T")[0];
146
+ return join(this.resultsDir, `${pillar}-${environment}-${date}.jsonl`);
147
+ }
148
+
149
+ /**
150
+ * Group results by pillar and environment
151
+ */
152
+ private groupResults(results: TestResult[]): Record<string, TestResult[]> {
153
+ return results.reduce(
154
+ (acc, result) => {
155
+ const key = `${result.pillar}:${result.environment}`;
156
+ if (!acc[key]) {
157
+ acc[key] = [];
158
+ }
159
+ acc[key].push(result);
160
+ return acc;
161
+ },
162
+ {} as Record<string, TestResult[]>,
163
+ );
164
+ }
165
+ }
166
+
167
+ // Export singleton instance
168
+ export const resultWriter = new ResultWriter();
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Doppler strategy — reads/writes flat keys with the `doppler` CLI.
3
+ * Selected with `secrets.provider: doppler` (the default).
4
+ *
5
+ * Project/config come from `secrets.doppler.{project,config}` in
6
+ * config/den.yml, else from the directory's `doppler setup` scope,
7
+ * else from DOPPLER_PROJECT / DOPPLER_CONFIG / DOPPLER_TOKEN.
8
+ */
9
+ import { execFile, spawn } from "child_process";
10
+ import { promisify } from "util";
11
+ import { flatKeyFor } from "./keys.js";
12
+ import type { PreflightResult, SecretRef, SecretsProvider } from "./types.js";
13
+
14
+ import { product } from "../product.js";
15
+ const execFileAsync = promisify(execFile);
16
+
17
+ export interface DopplerOptions {
18
+ project?: string;
19
+ config?: string;
20
+ keys?: Record<string, string>;
21
+ timeoutMs?: number;
22
+ }
23
+
24
+ export class DopplerProvider implements SecretsProvider {
25
+ readonly name = "doppler" as const;
26
+ private readonly timeout: number;
27
+
28
+ constructor(private readonly opts: DopplerOptions = {}) {
29
+ this.timeout = opts.timeoutMs ?? 30_000;
30
+ }
31
+
32
+ /** `--project/--config` flags when configured explicitly. */
33
+ scopeArgs(): string[] {
34
+ const a: string[] = [];
35
+ if (this.opts.project) a.push("--project", this.opts.project);
36
+ if (this.opts.config) a.push("--config", this.opts.config);
37
+ return a;
38
+ }
39
+
40
+ keyFor(ref: SecretRef): string {
41
+ return flatKeyFor(ref, this.opts.keys);
42
+ }
43
+
44
+ async isReady(): Promise<boolean> {
45
+ try {
46
+ await execFileAsync("doppler", ["me", "--json"], {
47
+ timeout: this.timeout,
48
+ });
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ async get(ref: SecretRef): Promise<string> {
56
+ const key = this.keyFor(ref);
57
+ const { stdout } = await execFileAsync(
58
+ "doppler",
59
+ ["secrets", "get", key, "--plain", ...this.scopeArgs()],
60
+ { timeout: this.timeout },
61
+ );
62
+ return String(stdout).trim();
63
+ }
64
+
65
+ async tryGet(ref: SecretRef): Promise<string | null> {
66
+ try {
67
+ const v = await this.get(ref);
68
+ return v.length > 0 ? v : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /** Value goes over stdin so it never appears in the process table. */
75
+ async set(ref: SecretRef, value: string): Promise<void> {
76
+ const key = this.keyFor(ref);
77
+ await new Promise<void>((resolve, reject) => {
78
+ const child = spawn(
79
+ "doppler",
80
+ ["secrets", "set", key, "--silent", ...this.scopeArgs()],
81
+ { stdio: ["pipe", "ignore", "pipe"] },
82
+ );
83
+ let stderr = "";
84
+ child.stderr?.on("data", (d) => (stderr += String(d)));
85
+ child.on("error", reject);
86
+ child.on("close", (code) =>
87
+ code === 0
88
+ ? resolve()
89
+ : reject(
90
+ new Error(`doppler secrets set ${key} failed: ${stderr.trim()}`),
91
+ ),
92
+ );
93
+ child.stdin?.end(value);
94
+ });
95
+ }
96
+
97
+ async preflight(): Promise<PreflightResult> {
98
+ if (!(await this.isReady())) {
99
+ return {
100
+ ok: false,
101
+ message:
102
+ "Doppler CLI is not installed or not logged in.\n" +
103
+ "Install: brew install dopplerhq/cli/doppler then: doppler login",
104
+ };
105
+ }
106
+ try {
107
+ await execFileAsync(
108
+ "doppler",
109
+ ["secrets", "--only-names", "--json", ...this.scopeArgs()],
110
+ { timeout: this.timeout },
111
+ );
112
+ return { ok: true };
113
+ } catch {
114
+ return {
115
+ ok: false,
116
+ message:
117
+ "Doppler is logged in but no project/config is selected.\n" +
118
+ `Run \`doppler setup\` in the repo, or set secrets.doppler.project/config in ${product().configFile}.`,
119
+ };
120
+ }
121
+ }
122
+
123
+ runPrefix(): string[] {
124
+ return ["doppler", "run", ...this.scopeArgs(), "--"];
125
+ }
126
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Environment-variable strategy — reads flat keys from process.env.
3
+ * Selected with `secrets.provider: env`. Intended for CI and for engineers who
4
+ * inject secrets themselves (direnv, `doppler run -- den …`, CI secrets).
5
+ */
6
+ import { flatKeyFor } from "./keys.js";
7
+ import type { PreflightResult, SecretRef, SecretsProvider } from "./types.js";
8
+
9
+ export class EnvProvider implements SecretsProvider {
10
+ readonly name = "env" as const;
11
+
12
+ constructor(
13
+ private readonly keys: Record<string, string> = {},
14
+ private readonly env: NodeJS.ProcessEnv = process.env,
15
+ ) {}
16
+
17
+ keyFor(ref: SecretRef): string {
18
+ return flatKeyFor(ref, this.keys);
19
+ }
20
+
21
+ async isReady(): Promise<boolean> {
22
+ return true;
23
+ }
24
+
25
+ async get(ref: SecretRef): Promise<string> {
26
+ const key = this.keyFor(ref);
27
+ const v = this.env[key];
28
+ if (!v) throw new Error(`Secret ${key} is not set in the environment`);
29
+ return v;
30
+ }
31
+
32
+ async tryGet(ref: SecretRef): Promise<string | null> {
33
+ return this.env[this.keyFor(ref)] || null;
34
+ }
35
+
36
+ async set(ref: SecretRef, value: string): Promise<void> {
37
+ // Process-local only: the env backend has no durable store.
38
+ this.env[this.keyFor(ref)] = value;
39
+ }
40
+
41
+ async preflight(): Promise<PreflightResult> {
42
+ return { ok: true };
43
+ }
44
+
45
+ runPrefix(): string[] {
46
+ return [];
47
+ }
48
+ }