@monster-orange/den 0.0.0 → 0.2.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 (255) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +68 -0
  5. package/docs/distributions.md +499 -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 +101 -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 +403 -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 +124 -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 +757 -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/agent-actor.ts +77 -0
  91. package/src/shared/applications.ts +138 -0
  92. package/src/shared/auth/api-token.ts +416 -0
  93. package/src/shared/auth/auth0-helper.ts +284 -0
  94. package/src/shared/auth/headless-login.ts +265 -0
  95. package/src/shared/auth/jwt-helper.ts +186 -0
  96. package/src/shared/auth/sudo-vault.ts +69 -0
  97. package/src/shared/auth/test-accounts.ts +98 -0
  98. package/src/shared/auth/token-injection.ts +184 -0
  99. package/src/shared/auth/token-providers/auth0.ts +80 -0
  100. package/src/shared/auth/token-providers/index.ts +182 -0
  101. package/src/shared/auth/token-refresh.ts +273 -0
  102. package/src/shared/banner.ts +15 -0
  103. package/src/shared/brand.ts +79 -0
  104. package/src/shared/cli-tabs.ts +131 -0
  105. package/src/shared/constants.ts +128 -0
  106. package/src/shared/den-config.ts +688 -0
  107. package/src/shared/dev-metrics/emit.ts +354 -0
  108. package/src/shared/edge/config.ts +229 -0
  109. package/src/shared/edge/render.ts +221 -0
  110. package/src/shared/edge/runtime.ts +268 -0
  111. package/src/shared/edge/status.ts +74 -0
  112. package/src/shared/edge/types.ts +62 -0
  113. package/src/shared/engine.ts +67 -0
  114. package/src/shared/environment/aws-profiles.ts +49 -0
  115. package/src/shared/environment/env-selector.ts +161 -0
  116. package/src/shared/environment/playwright-env.ts +82 -0
  117. package/src/shared/environment-manager/catalog.ts +77 -0
  118. package/src/shared/environment-manager/clone-guard.ts +163 -0
  119. package/src/shared/environment-manager/dag.ts +286 -0
  120. package/src/shared/environment-manager/default-workspace.ts +182 -0
  121. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  122. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  123. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  124. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  125. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  126. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  127. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  128. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  129. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  130. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  131. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  132. package/src/shared/environment-manager/derivation/text.ts +40 -0
  133. package/src/shared/environment-manager/derivation/types.ts +69 -0
  134. package/src/shared/environment-manager/health.ts +635 -0
  135. package/src/shared/environment-manager/index.ts +6 -0
  136. package/src/shared/environment-manager/load-applications.ts +108 -0
  137. package/src/shared/environment-manager/log-tail.ts +213 -0
  138. package/src/shared/environment-manager/make-targets.ts +78 -0
  139. package/src/shared/environment-manager/opctl.ts +335 -0
  140. package/src/shared/environment-manager/readiness.ts +93 -0
  141. package/src/shared/environment-manager/register-local.ts +72 -0
  142. package/src/shared/environment-manager/secrets-check.ts +260 -0
  143. package/src/shared/environment-manager/service-container.ts +47 -0
  144. package/src/shared/environment-manager/service-env.ts +110 -0
  145. package/src/shared/environment-manager/service-runner.ts +1603 -0
  146. package/src/shared/environment-manager/sync-targets.ts +66 -0
  147. package/src/shared/environment-manager/up-plan.ts +326 -0
  148. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  149. package/src/shared/environment-manager/workspaces.ts +860 -0
  150. package/src/shared/manifest/deps.ts +27 -0
  151. package/src/shared/manifest/merge-override.ts +75 -0
  152. package/src/shared/manifest/migrate-manifest.ts +101 -0
  153. package/src/shared/manifest/placement.ts +321 -0
  154. package/src/shared/manifest/read-manifest.ts +278 -0
  155. package/src/shared/manifest/schema.ts +313 -0
  156. package/src/shared/manifest/service-links.ts +93 -0
  157. package/src/shared/manifest/validate-layers.ts +229 -0
  158. package/src/shared/manifest/validate-manifest.ts +200 -0
  159. package/src/shared/manifest/workspace-manifests.ts +92 -0
  160. package/src/shared/open-url.ts +122 -0
  161. package/src/shared/org.ts +44 -0
  162. package/src/shared/paths.ts +190 -0
  163. package/src/shared/perf/optional-fields.ts +105 -0
  164. package/src/shared/perf/report-normalizer.ts +404 -0
  165. package/src/shared/perf/report-paths.ts +19 -0
  166. package/src/shared/perf/report-store.ts +110 -0
  167. package/src/shared/perf/report-types.ts +70 -0
  168. package/src/shared/perf/test-paths.ts +25 -0
  169. package/src/shared/process/log-rotation.ts +147 -0
  170. package/src/shared/process/spawn-streaming.ts +229 -0
  171. package/src/shared/product.ts +92 -0
  172. package/src/shared/profile.ts +141 -0
  173. package/src/shared/redact.ts +107 -0
  174. package/src/shared/repo-kind.ts +32 -0
  175. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  176. package/src/shared/reporting/slack-reporter.ts +340 -0
  177. package/src/shared/results/result-reader.ts +266 -0
  178. package/src/shared/results/result-writer.ts +168 -0
  179. package/src/shared/secrets/doppler.ts +126 -0
  180. package/src/shared/secrets/env.ts +48 -0
  181. package/src/shared/secrets/index.ts +197 -0
  182. package/src/shared/secrets/keys.ts +33 -0
  183. package/src/shared/secrets/onepassword.ts +75 -0
  184. package/src/shared/secrets/types.ts +52 -0
  185. package/src/shared/service-url.ts +119 -0
  186. package/src/shared/smoke/branch-resolver.ts +141 -0
  187. package/src/shared/smoke/git-remote.ts +70 -0
  188. package/src/shared/smoke/repo-cloner.ts +151 -0
  189. package/src/shared/stacks/config.ts +137 -0
  190. package/src/shared/stacks/manifest-commands.ts +118 -0
  191. package/src/shared/stacks/render.ts +253 -0
  192. package/src/shared/stacks/runtime.ts +334 -0
  193. package/src/shared/stacks/status.ts +99 -0
  194. package/src/shared/stacks/types.ts +53 -0
  195. package/src/shared/terminal/tabs.ts +212 -0
  196. package/src/shared/test-discovery/module-scanner.ts +569 -0
  197. package/src/shared/types/index.ts +701 -0
  198. package/src/shared/ui-marker.ts +19 -0
  199. package/src/ui/cli/applications-command.ts +148 -0
  200. package/src/ui/cli/config-command.ts +68 -0
  201. package/src/ui/cli/create-main.ts +9 -0
  202. package/src/ui/cli/create.ts +360 -0
  203. package/src/ui/cli/edge-command.ts +81 -0
  204. package/src/ui/cli/flag-parser.ts +246 -0
  205. package/src/ui/cli/graph-command.ts +570 -0
  206. package/src/ui/cli/health-display.ts +63 -0
  207. package/src/ui/cli/index.ts +3742 -0
  208. package/src/ui/cli/init.ts +855 -0
  209. package/src/ui/cli/invocation-target.ts +175 -0
  210. package/src/ui/cli/logs-command.ts +104 -0
  211. package/src/ui/cli/mcp-main.ts +13 -0
  212. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  213. package/src/ui/cli/module-selection.ts +31 -0
  214. package/src/ui/cli/prompts.ts +767 -0
  215. package/src/ui/cli/results-viewer.ts +436 -0
  216. package/src/ui/cli/secrets-preflight.ts +91 -0
  217. package/src/ui/cli/stack-command.ts +117 -0
  218. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  219. package/src/ui/cli/test-runner.ts +1319 -0
  220. package/src/ui/cli/web-command.ts +1073 -0
  221. package/src/ui/mcp/api.ts +284 -0
  222. package/src/ui/mcp/guards.ts +141 -0
  223. package/src/ui/mcp/server.ts +147 -0
  224. package/src/ui/mcp/tools.ts +565 -0
  225. package/src/ui/server/audit.ts +71 -0
  226. package/src/ui/server/changelog-parser.ts +105 -0
  227. package/src/ui/server/daemon-reaper.ts +129 -0
  228. package/src/ui/server/editor-manager.ts +301 -0
  229. package/src/ui/server/github-service.ts +223 -0
  230. package/src/ui/server/graph-prefs.ts +89 -0
  231. package/src/ui/server/health-row.ts +15 -0
  232. package/src/ui/server/http-helpers.ts +98 -0
  233. package/src/ui/server/index.ts +3350 -0
  234. package/src/ui/server/log-history.ts +453 -0
  235. package/src/ui/server/perf-report.ts +42 -0
  236. package/src/ui/server/public/environments.html +496 -0
  237. package/src/ui/server/public/favicon.ico +0 -0
  238. package/src/ui/server/public/graph-model.js +416 -0
  239. package/src/ui/server/public/graph.css +976 -0
  240. package/src/ui/server/public/graph.js +986 -0
  241. package/src/ui/server/public/header.js +1373 -0
  242. package/src/ui/server/public/index.html +2595 -0
  243. package/src/ui/server/public/local-dev.html +5344 -0
  244. package/src/ui/server/public/logs.html +1496 -0
  245. package/src/ui/server/public/panel.js +787 -0
  246. package/src/ui/server/public/perf-report.js +359 -0
  247. package/src/ui/server/public/release.html +2111 -0
  248. package/src/ui/server/public/services.html +1103 -0
  249. package/src/ui/server/public/workspace-picker.js +69 -0
  250. package/src/ui/server/readonly-routes.ts +200 -0
  251. package/src/ui/server/release-deploy-session.ts +246 -0
  252. package/src/ui/server/release-service.ts +1125 -0
  253. package/src/ui/server/sudo-store.ts +111 -0
  254. package/src/ui/server/ui-assets.ts +73 -0
  255. package/src/ui/server/workspace-routes.ts +2185 -0
@@ -0,0 +1,335 @@
1
+ /**
2
+ * opctl runtime helpers.
3
+ *
4
+ * opctl, when installed, is a container control plane; Den leans on it for
5
+ * the parts that would otherwise need stale per-repo
6
+ * declarations:
7
+ * - shutting a service down → `opctl container down <name>` (invariant: every
8
+ * service's containers can be cleanly stopped this way)
9
+ * - identifying a service's containers → the `container.name` declared in the
10
+ * repo's dev/up op, captured at spawn time and/or parsed on demand.
11
+ */
12
+
13
+ import { execFileSync, execSync } from "child_process";
14
+ import * as fs from "fs";
15
+ import * as path from "path";
16
+ import { parse as parseYaml } from "yaml";
17
+
18
+ /** Candidate dev/up op manifests for a repo, in resolution order. */
19
+ function devOpManifestPaths(repoPath: string): string[] {
20
+ return [
21
+ path.join(repoPath, ".opspec", "dev", "op.yml"),
22
+ path.join(repoPath, ".opspec", "up", "op.yml"),
23
+ path.join(repoPath, ".opspec", "local", "up", "op.yml"), // infra-stack
24
+ path.join(repoPath, "apps", "backend", ".opspec", "up", "op.yml"), // monorepo (shop)
25
+ path.join(repoPath, "apps", "backend", ".opspec", "dev", "op.yml"),
26
+ ];
27
+ }
28
+
29
+ /** Recursively collect every `container.name` string in an opctl op tree. */
30
+ function collectContainerNames(node: unknown, out: Set<string>): void {
31
+ if (!node || typeof node !== "object") return;
32
+ if (Array.isArray(node)) {
33
+ for (const item of node) collectContainerNames(item, out);
34
+ return;
35
+ }
36
+ const obj = node as Record<string, unknown>;
37
+ const container = obj.container;
38
+ if (container && typeof container === "object" && !Array.isArray(container)) {
39
+ const name = (container as Record<string, unknown>).name;
40
+ if (typeof name === "string" && name.trim()) out.add(name.trim());
41
+ }
42
+ for (const value of Object.values(obj)) collectContainerNames(value, out);
43
+ }
44
+
45
+ /**
46
+ * The opctl container names a repo's dev/up op declares (e.g. ["links-api"]).
47
+ * These are the short `container.name` labels `opctl container down` matches on.
48
+ * Returns [] when no op manifest is found or it can't be parsed.
49
+ */
50
+ export function devOpContainerNames(repoPath: string): string[] {
51
+ for (const manifestPath of devOpManifestPaths(repoPath)) {
52
+ try {
53
+ if (!fs.existsSync(manifestPath)) continue;
54
+ const op = parseYaml(fs.readFileSync(manifestPath, "utf-8"));
55
+ const names = new Set<string>();
56
+ collectContainerNames(op, names);
57
+ if (names.size > 0) return [...names];
58
+ } catch {
59
+ /* unparseable — try the next candidate */
60
+ }
61
+ }
62
+ return [];
63
+ }
64
+
65
+ let opctlAvailableCache: boolean | null = null;
66
+
67
+ /** Whether the `opctl` CLI is on PATH (cached for the process lifetime). */
68
+ export function isOpctlAvailable(): boolean {
69
+ if (opctlAvailableCache !== null) return opctlAvailableCache;
70
+ try {
71
+ execSync("command -v opctl", { stdio: "ignore" });
72
+ opctlAvailableCache = true;
73
+ } catch {
74
+ opctlAvailableCache = false;
75
+ }
76
+ return opctlAvailableCache;
77
+ }
78
+
79
+ /**
80
+ * Docker names opctl-spawned containers `opctl_<base>_<8-hex>`. Capture the
81
+ * base so down can find them even when `opctl container ls` is empty or
82
+ * opctl itself can't elevate (`sudo -E` rejected).
83
+ */
84
+ const DOCKER_OPCTL_NAME = /^opctl_(.+)_([0-9a-f]{8})$/i;
85
+
86
+ /**
87
+ * Whether a docker container name is opctl's container for exactly `base`.
88
+ *
89
+ * A bare `startsWith("opctl_<base>_")` test is ambiguous whenever one base is a
90
+ * prefix of another that contains an underscore — base `api` would claim
91
+ * `opctl_api_v2_<hash>` — and these names feed `docker rm -f`, so a wrong match
92
+ * tears down someone else's service. Requiring the remainder to be a single
93
+ * trailing segment (opctl's hash never contains `_`) makes the base comparison
94
+ * exact without depending on the hash's length or alphabet.
95
+ */
96
+ export function isOpctlContainerFor(name: string, base: string): boolean {
97
+ const prefix = `opctl_${base}_`;
98
+ if (!name.startsWith(prefix)) return false;
99
+ const suffix = name.slice(prefix.length);
100
+ return suffix.length > 0 && !suffix.includes("_");
101
+ }
102
+
103
+ /** Names of the running docker containers (throws when docker is unavailable). */
104
+ export function runningDockerNames(): string[] {
105
+ return execSync('docker ps --format "{{.Names}}"', {
106
+ encoding: "utf-8",
107
+ stdio: ["ignore", "pipe", "ignore"],
108
+ timeout: 5_000,
109
+ })
110
+ .split("\n")
111
+ .map((l) => l.trim())
112
+ .filter(Boolean);
113
+ }
114
+
115
+ /**
116
+ * Running opctl containers discovered via the docker socket (no sudo).
117
+ * `name` is the full docker name; `base` is the opctl `container.name`.
118
+ */
119
+ export function listRunningDockerOpctlContainers(): {
120
+ name: string;
121
+ base: string;
122
+ }[] {
123
+ try {
124
+ const rows: { name: string; base: string }[] = [];
125
+ for (const name of runningDockerNames()) {
126
+ const m = name.match(DOCKER_OPCTL_NAME);
127
+ if (!m) continue;
128
+ rows.push({ name, base: m[1] });
129
+ }
130
+ return rows;
131
+ } catch {
132
+ return [];
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Names of currently-running opctl containers.
138
+ *
139
+ * Prefers `opctl container ls` (NAME column, trailing `-<hash>` stripped to
140
+ * the base `container.name`), and unions in anything docker still shows as
141
+ * `opctl_<base>_<hash>`. Docker is the source of truth when opctl can't
142
+ * elevate or its ls is empty while containers keep running — exactly the
143
+ * failure mode that made links-api / notes-api look "already stopped"
144
+ * (or report a successful scripted down) while their containers stayed up.
145
+ */
146
+ export function listRunningOpctlContainers(): { name: string; base: string }[] {
147
+ const byBase = new Map<string, { name: string; base: string }>();
148
+
149
+ if (isOpctlAvailable()) {
150
+ try {
151
+ const out = execSync("opctl container ls", {
152
+ encoding: "utf-8",
153
+ stdio: ["ignore", "pipe", "ignore"],
154
+ });
155
+ const lines = out.split("\n");
156
+ // Skip the first line only when it is actually a header row.
157
+ const firstToken = lines[0]?.trim().split(/\s+/)[0];
158
+ const body =
159
+ firstToken === "NAME" || firstToken === "ID" ? lines.slice(1) : lines;
160
+ for (const line of body) {
161
+ const name = line.trim().split(/\s+/)[0];
162
+ if (!name) continue;
163
+ const base = name.replace(/-[0-9a-f]{8}$/i, "");
164
+ byBase.set(base, { name, base });
165
+ }
166
+ } catch {
167
+ /* fall through to docker */
168
+ }
169
+ }
170
+
171
+ for (const c of listRunningDockerOpctlContainers()) {
172
+ if (!byBase.has(c.base)) byBase.set(c.base, c);
173
+ }
174
+ return [...byBase.values()];
175
+ }
176
+
177
+ /**
178
+ * Resolve a service's RUNNING docker container purely by opctl naming
179
+ * convention — docker names opctl-spawned containers `opctl_<base>_<hash>`.
180
+ *
181
+ * Candidates for `<base>`, in order: the repo's op-manifest container name
182
+ * matching the subsystem, any op-manifest container name, then the repo name
183
+ * itself. Returns the full docker container name, or null when nothing
184
+ * matches (not running, not containerized, or docker unavailable).
185
+ */
186
+ export function findConventionalContainer(
187
+ repoName: string,
188
+ repoPath?: string,
189
+ subsystem?: string,
190
+ ): string | null {
191
+ let running: string[];
192
+ try {
193
+ running = runningDockerNames();
194
+ } catch {
195
+ return null;
196
+ }
197
+ const declared = repoPath ? devOpContainerNames(repoPath) : [];
198
+ // Only candidates with NAME AFFINITY to the request — a blind scan of the
199
+ // declared list would hand a sidecar (e.g. shop-postgres) to an api
200
+ // request. The full declared list is only trusted when it's unambiguous.
201
+ const candidates = [
202
+ ...(subsystem
203
+ ? declared.filter(
204
+ (n) =>
205
+ n === subsystem ||
206
+ n === `${repoName}-${subsystem}` ||
207
+ n.endsWith(`-${subsystem}`),
208
+ )
209
+ : []),
210
+ repoName,
211
+ ...(declared.length === 1 ? declared : []),
212
+ ];
213
+ for (const base of candidates) {
214
+ const hit = running.find((n) => isOpctlContainerFor(n, base));
215
+ if (hit) return hit;
216
+ }
217
+ return null;
218
+ }
219
+
220
+ /** The running opctl containers for one `container.name`, and their origin. */
221
+ export interface OpctlContainerInfo {
222
+ /** Full docker names (`opctl_<base>_<hash>`) — usually one, see below. */
223
+ names: string[];
224
+ /** Host paths bind-mounted into any of those containers. */
225
+ mountSources: string[];
226
+ /** Earliest container start time, or null when docker didn't report one. */
227
+ startedAt: Date | null;
228
+ }
229
+
230
+ /**
231
+ * Inspect the running containers for an opctl `container.name`.
232
+ *
233
+ * Bind-mount sources are the only trail back to the checkout a container was
234
+ * started from — opctl's labels record the container name, not the source repo.
235
+ * That distinction is what lets a caller tell "my own checkout is already
236
+ * running, untracked" apart from "another checkout occupies this name".
237
+ *
238
+ * A base normally has exactly one container, but a leaked container from an
239
+ * earlier run can coexist with the current one under a different hash. All of
240
+ * them are inspected and their mounts unioned: deciding adoption from whichever
241
+ * one `docker ps` happened to list first would make the answer depend on
242
+ * container age. Returns null when nothing runs or docker can't be reached.
243
+ */
244
+ export function inspectOpctlContainer(base: string): OpctlContainerInfo | null {
245
+ try {
246
+ const names = runningDockerNames().filter((n) =>
247
+ isOpctlContainerFor(n, base),
248
+ );
249
+ if (names.length === 0) return null;
250
+
251
+ const mountSources = new Set<string>();
252
+ let startedAt: Date | null = null;
253
+ for (const name of names) {
254
+ const raw = execFileSync("docker", ["inspect", name], {
255
+ encoding: "utf-8",
256
+ stdio: ["ignore", "pipe", "ignore"],
257
+ timeout: 5_000,
258
+ });
259
+ const entry = (JSON.parse(raw) as unknown[])[0] as
260
+ | {
261
+ Mounts?: { Source?: unknown }[];
262
+ State?: { StartedAt?: unknown };
263
+ }
264
+ | undefined;
265
+ for (const m of entry?.Mounts ?? []) {
266
+ if (typeof m.Source === "string" && m.Source)
267
+ mountSources.add(m.Source);
268
+ }
269
+ const startedRaw = entry?.State?.StartedAt;
270
+ const started =
271
+ typeof startedRaw === "string" ? new Date(startedRaw) : null;
272
+ if (started && !Number.isNaN(started.getTime())) {
273
+ if (!startedAt || started < startedAt) startedAt = started;
274
+ }
275
+ }
276
+ return { names, mountSources: [...mountSources], startedAt };
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Force-stop docker containers for opctl `container.name` bases when
284
+ * `opctl container down` cannot elevate (typical: sudoers grants NOPASSWD for
285
+ * opctl but not SETENV, so opctl's `sudo -E` is rejected).
286
+ *
287
+ * Docker names opctl containers `opctl_<base>_<hash>`; `docker rm -f` stops and
288
+ * removes them without needing sudo when the user can talk to the docker socket.
289
+ */
290
+ export function forceStopOpctlContainers(bases: string[]): {
291
+ stopped: string[];
292
+ failed: string[];
293
+ } {
294
+ const stopped: string[] = [];
295
+ const failed: string[] = [];
296
+ if (bases.length === 0) return { stopped, failed };
297
+
298
+ let running: string[];
299
+ try {
300
+ running = runningDockerNames();
301
+ } catch {
302
+ return { stopped, failed: [...bases] };
303
+ }
304
+
305
+ const names = running.filter((n) =>
306
+ bases.some((base) => isOpctlContainerFor(n, base)),
307
+ );
308
+ for (const name of names) {
309
+ try {
310
+ execFileSync("docker", ["rm", "-f", name], {
311
+ encoding: "utf-8",
312
+ stdio: ["ignore", "pipe", "ignore"],
313
+ timeout: 30_000,
314
+ });
315
+ stopped.push(name);
316
+ } catch {
317
+ failed.push(name);
318
+ }
319
+ }
320
+ return { stopped, failed };
321
+ }
322
+
323
+ /**
324
+ * Base container names that newly appeared between two `opctl container ls`
325
+ * snapshots — i.e. the containers an `up` just spawned.
326
+ */
327
+ export function diffSpawnedContainers(
328
+ before: { base: string }[],
329
+ after: { base: string }[],
330
+ ): string[] {
331
+ const had = new Set(before.map((c) => c.base));
332
+ const fresh = new Set<string>();
333
+ for (const c of after) if (!had.has(c.base)) fresh.add(c.base);
334
+ return [...fresh];
335
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Readiness polling for `depends_on: { wait: true }` gates.
3
+ *
4
+ * Wraps `checkServiceHealth` with a poll loop. Used by the workspace `up`
5
+ * runner to block starting a dependent until its flagged dependencies are
6
+ * actually green (not just "started 5 seconds ago").
7
+ */
8
+
9
+ import { READINESS_TIMEOUT_MS, READINESS_INTERVAL_MS } from "../constants.js";
10
+ import { checkServiceHealth } from "./health.js";
11
+ import type { Environment, ResolvedService } from "../types/index.js";
12
+
13
+ export interface WaitForReadyOptions {
14
+ timeoutMs?: number;
15
+ intervalMs?: number;
16
+ /** Called with a short status string on each unsuccessful poll. */
17
+ onTick?: (detail: string) => void;
18
+ /**
19
+ * Polled between each readiness check. When it returns true the loop exits
20
+ * early with `ok: false, detail: "cancelled"`. Used by the SSE runner to
21
+ * stop polling when the client disconnects mid-`up`.
22
+ */
23
+ isCancelled?: () => boolean;
24
+ /** Override for tests. */
25
+ now?: () => number;
26
+ /** Override for tests. */
27
+ sleep?: (ms: number) => Promise<void>;
28
+ }
29
+
30
+ export interface WaitForReadyResult {
31
+ ok: boolean;
32
+ /** Detail from the last check — populated on timeout or fail-open. */
33
+ detail?: string;
34
+ /** True when the dep has no configured health check — we fail open and proceed. */
35
+ skipped?: boolean;
36
+ /** Time (ms) spent waiting. */
37
+ elapsedMs: number;
38
+ }
39
+
40
+ const defaultSleep = (ms: number): Promise<void> =>
41
+ new Promise((r) => setTimeout(r, ms));
42
+
43
+ /**
44
+ * Poll the dep's health check until it returns ok or the timeout expires.
45
+ *
46
+ * Fail-open: if the dep has no `health.<env>` configured, returns immediately
47
+ * with `skipped: true` — we never silently hang on a misconfigured dep.
48
+ */
49
+ export async function waitForReady(
50
+ resolved: ResolvedService,
51
+ env: Environment,
52
+ opts: WaitForReadyOptions = {},
53
+ ): Promise<WaitForReadyResult> {
54
+ const timeoutMs = opts.timeoutMs ?? READINESS_TIMEOUT_MS;
55
+ const intervalMs = opts.intervalMs ?? READINESS_INTERVAL_MS;
56
+ const now = opts.now ?? Date.now;
57
+ const sleep = opts.sleep ?? defaultSleep;
58
+
59
+ const start = now();
60
+ const deadline = start + timeoutMs;
61
+ let last = await checkServiceHealth(resolved, env);
62
+
63
+ // Fail-open: no health config → don't block.
64
+ if (last.health === "unknown") {
65
+ return {
66
+ ok: true,
67
+ skipped: true,
68
+ detail: last.healthDetail,
69
+ elapsedMs: now() - start,
70
+ };
71
+ }
72
+
73
+ while (last.health !== "ok") {
74
+ if (opts.isCancelled?.()) {
75
+ return { ok: false, detail: "cancelled", elapsedMs: now() - start };
76
+ }
77
+ if (now() >= deadline) {
78
+ return {
79
+ ok: false,
80
+ detail: last.healthDetail ?? "timeout",
81
+ elapsedMs: now() - start,
82
+ };
83
+ }
84
+ opts.onTick?.(last.healthDetail ?? "not ready");
85
+ await sleep(intervalMs);
86
+ if (opts.isCancelled?.()) {
87
+ return { ok: false, detail: "cancelled", elapsedMs: now() - start };
88
+ }
89
+ last = await checkServiceHealth(resolved, env);
90
+ }
91
+
92
+ return { ok: true, elapsedMs: now() - start };
93
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `den register <path>`: add an existing checkout to a workspace, with no
3
+ * prompts.
4
+ *
5
+ * The repo is named after its manifest (`name:`), so a checkout can live
6
+ * anywhere on disk (Arbor lives outside the repos root) and still get a clean
7
+ * name. It is added dev-managed at that path: Den runs the repo's own
8
+ * commands there and never clones or switches its branch. One bare entry per
9
+ * repo: the workspace expands it to every service in the manifest.
10
+ */
11
+
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ import { readManifest } from "../manifest/read-manifest.js";
15
+ import { getGitHubCoords } from "../smoke/git-remote.js";
16
+ import { upsertCatalogEntry } from "./catalog.js";
17
+ import { readWorkspacesFile, writeWorkspacesFile } from "./workspaces.js";
18
+
19
+ import { product } from "../product.js";
20
+ export interface RegisteredRepo {
21
+ repoName: string;
22
+ gh: string;
23
+ path: string;
24
+ services: string[];
25
+ }
26
+
27
+ export function registerLocalRepo(
28
+ repoPathArg: string,
29
+ workspaceName: string,
30
+ ): RegisteredRepo {
31
+ const repoPath = path.resolve(repoPathArg);
32
+ if (!fs.existsSync(repoPath))
33
+ throw new Error(`Path does not exist: ${repoPath}`);
34
+ const manifest = readManifest(repoPath);
35
+ if (!manifest)
36
+ throw new Error(
37
+ `No ${product().manifestYml} in ${repoPath} (and none in ${product().title}'s manifests/ for its origin repo)`,
38
+ );
39
+ const repoName =
40
+ typeof manifest.name === "string" && manifest.name
41
+ ? manifest.name
42
+ : path.basename(repoPath);
43
+ const coords = getGitHubCoords(repoPath);
44
+ const gh = coords ? `${coords.owner}/${coords.repo}` : "";
45
+
46
+ const file = readWorkspacesFile();
47
+ const ws = file.workspaces[workspaceName];
48
+ if (!ws) throw new Error(`Unknown workspace "${workspaceName}"`);
49
+
50
+ // Replace every existing entry for this repo, keeping its auto-start toggle.
51
+ let autostartOff = false;
52
+ for (const key of Object.keys(ws.services)) {
53
+ if (key === repoName || key.startsWith(`${repoName}/`)) {
54
+ if (ws.services[key].autostart === false) autostartOff = true;
55
+ delete ws.services[key];
56
+ }
57
+ }
58
+ ws.services[repoName] = {
59
+ mode: "dev-managed",
60
+ path: repoPath,
61
+ ...(autostartOff ? { autostart: false } : {}),
62
+ };
63
+
64
+ upsertCatalogEntry(repoName, { gh });
65
+ writeWorkspacesFile(file);
66
+ return {
67
+ repoName,
68
+ gh,
69
+ path: repoPath,
70
+ services: Object.keys(manifest.services ?? {}),
71
+ };
72
+ }