@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,334 @@
1
+ /** Side effects for stacks: docker compose, link networks, edge routes. */
2
+ import { randomBytes } from "crypto";
3
+ import { spawnSync } from "child_process";
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import { getReposRoot } from "../den-config.js";
7
+ import { edgeNames } from "../edge/render.js";
8
+ import type { EdgeConfig } from "../edge/types.js";
9
+ import {
10
+ containerName,
11
+ expandEnv,
12
+ linkNetwork,
13
+ projectName,
14
+ providerServices,
15
+ renderOverride,
16
+ renderStackRoutes,
17
+ routedNetworks,
18
+ } from "./render.js";
19
+ import type { ResolvedProject, StackConfig } from "./types.js";
20
+
21
+ function run(
22
+ cmd: string,
23
+ args: string[],
24
+ opts: { cwd?: string; env?: NodeJS.ProcessEnv; inherit?: boolean } = {},
25
+ ) {
26
+ const r = spawnSync(cmd, args, {
27
+ cwd: opts.cwd,
28
+ encoding: "utf-8",
29
+ env: { ...process.env, ...opts.env },
30
+ stdio: opts.inherit ? "inherit" : "pipe",
31
+ maxBuffer: 64 * 1024 * 1024,
32
+ });
33
+ if (r.error) throw new Error(`${cmd}: ${r.error.message}`);
34
+ return {
35
+ code: r.status ?? 1,
36
+ out: (r.stdout ?? "").trim(),
37
+ err: (r.stderr ?? "").trim(),
38
+ };
39
+ }
40
+
41
+ function must(
42
+ cmd: string,
43
+ args: string[],
44
+ opts: Parameters<typeof run>[2] = {},
45
+ ): string {
46
+ const r = run(cmd, args, opts);
47
+ if (r.code !== 0)
48
+ throw new Error(
49
+ `${cmd} ${args.slice(0, 6).join(" ")}… failed:\n${r.err || r.out}`,
50
+ );
51
+ return r.out;
52
+ }
53
+
54
+ export interface StackContext {
55
+ edge: EdgeConfig;
56
+ stacks: Record<string, StackConfig>;
57
+ workspace: string;
58
+ }
59
+
60
+ function stackDir(ctx: StackContext, s: StackConfig): string {
61
+ return path.join(
62
+ ctx.edge.stateDir,
63
+ "stacks",
64
+ projectName(s.name, ctx.workspace),
65
+ );
66
+ }
67
+
68
+ export function repoDir(s: StackConfig): string {
69
+ const dir = path.join(
70
+ s.repoPath ?? path.join(getReposRoot(), s.repo),
71
+ s.dir ?? "",
72
+ );
73
+ if (!fs.existsSync(dir))
74
+ throw new Error(
75
+ `stack ${s.name}: ${dir} does not exist (clone ${s.repo} first)`,
76
+ );
77
+ return dir;
78
+ }
79
+
80
+ /** Generated local-only secrets, created once per stack + workspace. */
81
+ function generatedEnv(
82
+ ctx: StackContext,
83
+ s: StackConfig,
84
+ ): Record<string, string> {
85
+ const file = path.join(stackDir(ctx, s), "generated.env");
86
+ const have: Record<string, string> = {};
87
+ if (fs.existsSync(file))
88
+ for (const line of fs.readFileSync(file, "utf-8").split("\n")) {
89
+ const i = line.indexOf("=");
90
+ if (i > 0) have[line.slice(0, i)] = line.slice(i + 1);
91
+ }
92
+ let changed = false;
93
+ for (const k of s.generate)
94
+ if (!have[k]) {
95
+ have[k] = randomBytes(32).toString("base64url");
96
+ changed = true;
97
+ }
98
+ if (changed) {
99
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
100
+ fs.writeFileSync(
101
+ file,
102
+ Object.entries(have)
103
+ .map(([k, v]) => `${k}=${v}`)
104
+ .join("\n") + "\n",
105
+ { mode: 0o600 },
106
+ );
107
+ }
108
+ return have;
109
+ }
110
+
111
+ function composeEnv(ctx: StackContext, s: StackConfig): NodeJS.ProcessEnv {
112
+ return {
113
+ ...generatedEnv(ctx, s),
114
+ ...expandEnv(s.env, ctx.workspace, ctx.stacks),
115
+ COMPOSE_PROJECT_NAME: projectName(s.name, ctx.workspace),
116
+ ...(s.profiles.length ? { COMPOSE_PROFILES: s.profiles.join(",") } : {}),
117
+ };
118
+ }
119
+
120
+ function baseArgs(ctx: StackContext, s: StackConfig): string[] {
121
+ return [
122
+ "compose",
123
+ "-p",
124
+ projectName(s.name, ctx.workspace),
125
+ ...s.files.flatMap((f) => ["-f", f]),
126
+ ];
127
+ }
128
+
129
+ function resolve(ctx: StackContext, s: StackConfig): ResolvedProject {
130
+ const out = must(
131
+ "docker",
132
+ [...baseArgs(ctx, s), "config", "--format", "json"],
133
+ {
134
+ cwd: repoDir(s),
135
+ env: composeEnv(ctx, s),
136
+ },
137
+ );
138
+ return JSON.parse(out) as ResolvedProject;
139
+ }
140
+
141
+ function overrideArgs(ctx: StackContext, s: StackConfig): string[] {
142
+ return [
143
+ ...baseArgs(ctx, s),
144
+ "-f",
145
+ path.join(stackDir(ctx, s), "override.yml"),
146
+ ];
147
+ }
148
+
149
+ function ensureNetwork(name: string): void {
150
+ if (run("docker", ["network", "inspect", name]).code !== 0)
151
+ must("docker", ["network", "create", name]);
152
+ }
153
+
154
+ /** `docker network connect`, ignoring "already connected". Container-name alias only. */
155
+ function connect(network: string, container: string): void {
156
+ const r = run("docker", ["network", "connect", network, container]);
157
+ if (r.code !== 0 && !/already exists|already connected/i.test(r.err))
158
+ throw new Error(`docker network connect ${network} ${container}: ${r.err}`);
159
+ }
160
+
161
+ function running(container: string): boolean {
162
+ return (
163
+ run("docker", ["inspect", "-f", "{{.State.Running}}", container]).out ===
164
+ "true"
165
+ );
166
+ }
167
+
168
+ export function stackUp(
169
+ ctx: StackContext,
170
+ s: StackConfig,
171
+ opts: { build?: boolean } = {},
172
+ ) {
173
+ const resolved = resolve(ctx, s);
174
+ // Provider side of each link: its referenced services join a private network.
175
+ const needs = providerServices(s);
176
+ for (const p of s.uses) {
177
+ const net = linkNetwork(s.name, p, ctx.workspace);
178
+ ensureNetwork(net);
179
+ for (const svc of needs[p] ?? []) {
180
+ const c = containerName(projectName(p, ctx.workspace), svc);
181
+ if (!running(c))
182
+ throw new Error(
183
+ `stack ${s.name} needs ${p}'s ${svc} (${c}), which is not running`,
184
+ );
185
+ connect(net, c);
186
+ }
187
+ }
188
+ const dir = stackDir(ctx, s);
189
+ fs.mkdirSync(dir, { recursive: true });
190
+ fs.writeFileSync(
191
+ path.join(dir, "override.yml"),
192
+ renderOverride(s, resolved, ctx.workspace),
193
+ );
194
+ must(
195
+ "docker",
196
+ [
197
+ ...overrideArgs(ctx, s),
198
+ "up",
199
+ "-d",
200
+ ...(opts.build ? ["--build"] : []),
201
+ "--remove-orphans",
202
+ ],
203
+ { cwd: repoDir(s), env: composeEnv(ctx, s), inherit: true },
204
+ );
205
+ // This stack as a provider: consumers already running keep their link
206
+ // network, but a restarted provider's new containers must rejoin it.
207
+ for (const c of Object.values(ctx.stacks)) {
208
+ if (!c.uses.includes(s.name)) continue;
209
+ const net = linkNetwork(c.name, s.name, ctx.workspace);
210
+ if (run("docker", ["network", "inspect", net]).code !== 0) continue;
211
+ for (const svc of providerServices(c)[s.name] ?? [])
212
+ connect(net, containerName(projectName(s.name, ctx.workspace), svc));
213
+ }
214
+ const edge = edgeNames(ctx.edge.org).container;
215
+ const nets = routedNetworks(s, resolved);
216
+ for (const n of nets) connect(n, edge);
217
+ fs.writeFileSync(
218
+ path.join(
219
+ ctx.edge.stateDir,
220
+ "dynamic",
221
+ `stack-${projectName(s.name, ctx.workspace)}.yml`,
222
+ ),
223
+ renderStackRoutes(ctx.edge, s, resolved),
224
+ );
225
+ return { project: resolved.name, networks: nets };
226
+ }
227
+
228
+ export function stackDown(ctx: StackContext, s: StackConfig): void {
229
+ fs.rmSync(
230
+ path.join(
231
+ ctx.edge.stateDir,
232
+ "dynamic",
233
+ `stack-${projectName(s.name, ctx.workspace)}.yml`,
234
+ ),
235
+ { force: true },
236
+ );
237
+ const edge = edgeNames(ctx.edge.org).container;
238
+ const dir = repoDir(s);
239
+ const override = path.join(stackDir(ctx, s), "override.yml");
240
+ const args = fs.existsSync(override)
241
+ ? overrideArgs(ctx, s)
242
+ : baseArgs(ctx, s);
243
+ // The edge holds a connection to the project network; let go so `down` can remove it.
244
+ try {
245
+ for (const n of routedNetworks(s, resolve(ctx, s)))
246
+ run("docker", ["network", "disconnect", n, edge]);
247
+ } catch {
248
+ /* compose config failed — nothing to disconnect */
249
+ }
250
+ must("docker", [...args, "down"], {
251
+ cwd: dir,
252
+ env: composeEnv(ctx, s),
253
+ inherit: true,
254
+ });
255
+ const needs = providerServices(s);
256
+ for (const p of s.uses) {
257
+ const net = linkNetwork(s.name, p, ctx.workspace);
258
+ for (const svc of needs[p] ?? [])
259
+ run("docker", [
260
+ "network",
261
+ "disconnect",
262
+ net,
263
+ containerName(projectName(p, ctx.workspace), svc),
264
+ ]);
265
+ run("docker", ["network", "rm", net]);
266
+ }
267
+ }
268
+
269
+ export function stackPs(ctx: StackContext, s: StackConfig): string {
270
+ return run(
271
+ "docker",
272
+ [
273
+ ...baseArgs(ctx, s),
274
+ "ps",
275
+ "--format",
276
+ "table {{.Service}}\t{{.State}}\t{{.Status}}",
277
+ ],
278
+ {
279
+ cwd: repoDir(s),
280
+ env: composeEnv(ctx, s),
281
+ },
282
+ ).out;
283
+ }
284
+
285
+ export function stackLogs(
286
+ ctx: StackContext,
287
+ s: StackConfig,
288
+ args: string[],
289
+ ): number {
290
+ return run("docker", [...baseArgs(ctx, s), "logs", ...args], {
291
+ cwd: repoDir(s),
292
+ env: composeEnv(ctx, s),
293
+ inherit: true,
294
+ }).code;
295
+ }
296
+
297
+ /**
298
+ * Health of a running stack: ok when it has containers, all are running, and
299
+ * none reports a failing or pending Docker healthcheck.
300
+ */
301
+ export function stackHealth(
302
+ ctx: StackContext,
303
+ s: StackConfig,
304
+ service?: string,
305
+ ): { ok: boolean; detail: string } {
306
+ const project = projectName(s.name, ctx.workspace);
307
+ const r = run("docker", [
308
+ "ps",
309
+ "-a",
310
+ "--filter",
311
+ `label=com.docker.compose.project=${project}`,
312
+ ...(service
313
+ ? ["--filter", `label=com.docker.compose.service=${service}`]
314
+ : []),
315
+ "--format",
316
+ '{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
317
+ ]);
318
+ if (r.code !== 0) return { ok: false, detail: r.err || "docker ps failed" };
319
+ const rows = r.out.split("\n").filter(Boolean);
320
+ if (!rows.length)
321
+ return {
322
+ ok: false,
323
+ detail: `${project}${service ? `/${service}` : ""}: not running`,
324
+ };
325
+ const bad = rows.filter((row) => {
326
+ const [, state, status] = row.split("\t");
327
+ return (
328
+ state !== "running" || /\((unhealthy|health: starting)\)/.test(status)
329
+ );
330
+ });
331
+ return bad.length
332
+ ? { ok: false, detail: bad.join("; ") }
333
+ : { ok: true, detail: `${rows.length} container(s) running` };
334
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A stack = one repo's Docker Compose project, run behind the edge.
3
+ *
4
+ * stacks:
5
+ * webapp:
6
+ * repo: sample-webapp
7
+ * files: [docker-compose.yml] # relative to repo/dir
8
+ * routes: { app: frontend:80 } # https://app.webapp.<domain>
9
+ * generate: [SECRET_KEY] # random once, kept in local state
10
+ * uses: [ragstack] # start first + private link network
11
+ * env: # compose ${VAR} substitution
12
+ * RAG_PROXY_TARGET: "http://{ragstack.ragstack-server}:80"
13
+ *
14
+ * `{stack.service}` expands to that service's container name.
15
+ */
16
+ export interface StackConfig {
17
+ name: string;
18
+ repo: string;
19
+ /** Absolute checkout path; when unset, `<repos_root>/<repo>`. */
20
+ repoPath?: string;
21
+ /** Subdirectory of the repo holding the compose files. */
22
+ dir?: string;
23
+ files: string[];
24
+ /** `<route>` → `<service>:<container port>`. */
25
+ routes: Record<string, string>;
26
+ generate: string[];
27
+ uses: string[];
28
+ env: Record<string, string>;
29
+ /** Compose profiles to enable. */
30
+ profiles: string[];
31
+ /** service → replacement healthcheck `test`, for a repo's broken check. */
32
+ healthchecks?: Record<string, string[]>;
33
+ /**
34
+ * service → environment variables set on that service (override wins by
35
+ * key). For values a repo's compose file hard-codes, which `env` (compose
36
+ * `${VAR}` substitution) cannot change.
37
+ */
38
+ serviceEnv?: Record<string, Record<string, string>>;
39
+ }
40
+
41
+ /** What `docker compose config --format json` tells us about a service. */
42
+ export interface ResolvedService {
43
+ container_name?: string;
44
+ ports?: unknown[];
45
+ networks?: Record<string, unknown> | null;
46
+ environment?: Record<string, string | null> | null;
47
+ }
48
+
49
+ export interface ResolvedProject {
50
+ name: string;
51
+ services: Record<string, ResolvedService>;
52
+ networks?: Record<string, { name?: string }>;
53
+ }
@@ -0,0 +1,212 @@
1
+ import * as fs from "fs";
2
+ import { execFileSync } from "child_process";
3
+
4
+ import { product } from "../product.js";
5
+ export type TerminalApp = "iterm2" | "terminal";
6
+
7
+ export interface OpenTabOptions {
8
+ cmd: string;
9
+ cwd: string;
10
+ label?: string;
11
+ terminalApp?: TerminalApp;
12
+ /**
13
+ * Variables to export in the tab before running `cmd`. A tab is a fresh
14
+ * login shell, so den-resolved values (the org config's service_env) are only
15
+ * present if we put them there — without them a consumer repo silently falls
16
+ * back to its `../<repo>` sibling default and fails in a nested checkout.
17
+ *
18
+ * Keep this to non-secret values: it lands in the tab's shell history.
19
+ */
20
+ env?: Record<string, string>;
21
+ }
22
+
23
+ export interface OpenedTab {
24
+ terminalApp: TerminalApp;
25
+ tabId: string;
26
+ }
27
+
28
+ const APPLE_TERMINAL_APP = fs.existsSync(
29
+ "/System/Applications/Utilities/Terminal.app",
30
+ )
31
+ ? "/System/Applications/Utilities/Terminal.app"
32
+ : "Terminal";
33
+
34
+ export function shellQuote(value: string): string {
35
+ return `'${value.replace(/'/g, `'\\''`)}'`;
36
+ }
37
+
38
+ function appleScriptString(value: string): string {
39
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
40
+ }
41
+
42
+ function automationHelp(appName: string): string {
43
+ return (
44
+ `Could not automate ${appName}. ` +
45
+ `Allow ${product().title}/your terminal to control Terminal, iTerm, and System Events in ` +
46
+ "System Settings > Privacy & Security > Automation."
47
+ );
48
+ }
49
+
50
+ function runAppleScript(script: string, appName: string): string {
51
+ try {
52
+ return execFileSync("osascript", ["-e", script], {
53
+ encoding: "utf-8",
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ }).trim();
56
+ } catch (error) {
57
+ const stderr =
58
+ typeof (error as { stderr?: unknown }).stderr === "string"
59
+ ? ((error as { stderr: string }).stderr ?? "")
60
+ : "";
61
+ const message = stderr.trim() || (error as Error).message;
62
+ throw new Error(`${automationHelp(appName)} ${message}`.trim());
63
+ }
64
+ }
65
+
66
+ function buildTabCommand(opts: OpenTabOptions): string {
67
+ const exports = Object.entries(opts.env ?? {})
68
+ .filter(([key, value]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && value)
69
+ .map(([key, value]) => `export ${key}=${shellQuote(value)}`);
70
+ const prefix = exports.length > 0 ? `${exports.join(" ")} && ` : "";
71
+ return `${prefix}cd ${shellQuote(opts.cwd)} && ${opts.cmd}`;
72
+ }
73
+
74
+ function openITermTab(command: string): OpenedTab {
75
+ const script = `
76
+ tell application "iTerm"
77
+ activate
78
+ if (count of windows) = 0 then
79
+ set newWindow to (create window with default profile)
80
+ set newTab to current tab of newWindow
81
+ else
82
+ tell current window
83
+ set newTab to (create tab with default profile)
84
+ end tell
85
+ end if
86
+ tell current session of newTab
87
+ write text ${appleScriptString(command)}
88
+ end tell
89
+ return (id of current session of newTab as text)
90
+ end tell
91
+ `;
92
+ return { terminalApp: "iterm2", tabId: runAppleScript(script, "iTerm2") };
93
+ }
94
+
95
+ function openAppleTerminalTab(command: string): OpenedTab {
96
+ const script = `
97
+ tell application ${appleScriptString(APPLE_TERMINAL_APP)}
98
+ activate
99
+ if not (exists front window) then
100
+ do script ""
101
+ end if
102
+ end tell
103
+ delay 0.1
104
+ tell application "System Events"
105
+ tell process "Terminal"
106
+ keystroke "t" using command down
107
+ end tell
108
+ end tell
109
+ delay 0.1
110
+ tell application ${appleScriptString(APPLE_TERMINAL_APP)}
111
+ do script ${appleScriptString(command)} in selected tab of front window
112
+ return tty of selected tab of front window
113
+ end tell
114
+ `;
115
+ return {
116
+ terminalApp: "terminal",
117
+ tabId: runAppleScript(script, "Apple Terminal"),
118
+ };
119
+ }
120
+
121
+ export function openTab(opts: OpenTabOptions): OpenedTab {
122
+ const terminalApp = opts.terminalApp ?? detectDefaultTerminalApp();
123
+ const command = buildTabCommand(opts);
124
+ if (terminalApp === "iterm2") return openITermTab(command);
125
+ return openAppleTerminalTab(command);
126
+ }
127
+
128
+ export function closeTab(tabId: string, terminalApp: TerminalApp): void {
129
+ if (terminalApp === "iterm2") {
130
+ const script = `
131
+ tell application "iTerm"
132
+ repeat with w in windows
133
+ repeat with t in tabs of w
134
+ if (id of current session of t as text) is ${appleScriptString(tabId)} then
135
+ close t
136
+ return "closed"
137
+ end if
138
+ end repeat
139
+ end repeat
140
+ end tell
141
+ return "missing"
142
+ `;
143
+ runAppleScript(script, "iTerm2");
144
+ return;
145
+ }
146
+
147
+ const script = `
148
+ tell application ${appleScriptString(APPLE_TERMINAL_APP)}
149
+ repeat with w in windows
150
+ repeat with t in tabs of w
151
+ try
152
+ if (tty of t) is ${appleScriptString(tabId)} then
153
+ close t
154
+ return "closed"
155
+ end if
156
+ end try
157
+ end repeat
158
+ end repeat
159
+ end tell
160
+ return "missing"
161
+ `;
162
+ runAppleScript(script, "Apple Terminal");
163
+ }
164
+
165
+ export function tabExists(tabId: string, terminalApp: TerminalApp): boolean {
166
+ if (terminalApp === "iterm2") {
167
+ const script = `
168
+ tell application "iTerm"
169
+ repeat with w in windows
170
+ repeat with t in tabs of w
171
+ if (id of current session of t as text) is ${appleScriptString(tabId)} then return "yes"
172
+ end repeat
173
+ end repeat
174
+ end tell
175
+ return "no"
176
+ `;
177
+ return runAppleScript(script, "iTerm2") === "yes";
178
+ }
179
+
180
+ const script = `
181
+ tell application ${appleScriptString(APPLE_TERMINAL_APP)}
182
+ repeat with w in windows
183
+ repeat with t in tabs of w
184
+ try
185
+ if (tty of t) is ${appleScriptString(tabId)} then return "yes"
186
+ end try
187
+ end repeat
188
+ end repeat
189
+ end tell
190
+ return "no"
191
+ `;
192
+ return runAppleScript(script, "Apple Terminal") === "yes";
193
+ }
194
+
195
+ export function resolveTerminalApp(key: string | undefined): TerminalApp {
196
+ if (!key) return detectDefaultTerminalApp();
197
+ if (key === "iterm2") return "iterm2";
198
+ if (key === "terminal") return "terminal";
199
+ if (key === "ghostty") {
200
+ throw new Error(
201
+ `Tab mode does not support Ghostty yet. Set \`${product().cli} config terminal=iterm2\`, \`${product().cli} config terminal=terminal\`, or run with \`--no-tabs\`.`,
202
+ );
203
+ }
204
+ throw new Error(
205
+ `Tab mode only supports iTerm2 and Apple Terminal. Configured terminal "${key}" is not supported; run with --no-tabs to use daemon mode.`,
206
+ );
207
+ }
208
+
209
+ function detectDefaultTerminalApp(): TerminalApp {
210
+ if (fs.existsSync("/Applications/iTerm.app")) return "iterm2";
211
+ return "terminal";
212
+ }