@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,147 @@
1
+ /**
2
+ * Size-capped rotation for service logs.
3
+ *
4
+ * Service logs are written by DETACHED child processes that inherited an
5
+ * O_APPEND file descriptor (see spawnDaemon): den opens the file, hands the
6
+ * fd to the child, then closes its own copy. Nothing can make that child
7
+ * reopen — the command is arbitrary shell with no SIGHUP contract — and it
8
+ * outlives den. A chatty service therefore grows the file forever; a
9
+ * localstack tail was measured at 8.6 GB.
10
+ *
11
+ * That single fact rules out the usual strategies, and is why no logging
12
+ * library helps here (they all rotate a stream THEY own):
13
+ *
14
+ * - RENAME (`mv x.log x.log.1`) is silently fatal. The child's fd follows the
15
+ * inode, so it keeps writing into the renamed file while `x.log` stays
16
+ * empty — and every reader tails by name, so live logs stop forever with no
17
+ * error anywhere.
18
+ * - TRUNCATE-HEAD (keep the last N bytes at offset 0) makes `tail -F` notice
19
+ * the file shrank and restart from the beginning, re-flooding every viewer
20
+ * with the whole retained tail. It also shifts every byte offset, so the
21
+ * history pager silently renders the wrong page.
22
+ *
23
+ * What works is copy-then-truncate-to-ZERO: copy the tail into `<name>.log.1`,
24
+ * then truncate the live file in place. The inode never moves, so the child's
25
+ * fd stays valid and O_APPEND puts its next write at the new EOF. Readers cope:
26
+ * `tail -F` restarts at offset 0 and finds one banner line rather than a flood,
27
+ * and a stale history cursor clamps to a tiny file and ends paging cleanly.
28
+ *
29
+ * INVARIANT: the child's fd must stay O_APPEND. With plain O_WRONLY the kernel
30
+ * keeps the old offset and truncation produces a multi-gigabyte sparse hole of
31
+ * NULs. spawn-streaming opens with "a"; a test pins it.
32
+ */
33
+
34
+ import * as fs from "fs";
35
+ import * as path from "path";
36
+ import { MAX_LOG_BYTES, ROTATED_KEEP_BYTES } from "../constants.js";
37
+
38
+ import { product } from "../product.js";
39
+ /** Where a rotated generation is kept — one only, overwritten each time. */
40
+ export function rotatedPathFor(logPath: string): string {
41
+ return `${logPath}.1`;
42
+ }
43
+
44
+ /**
45
+ * Rotate `logPath` when it exceeds `maxBytes`. Returns true when it rotated.
46
+ *
47
+ * Best-effort by design: a log that cannot be rotated must never break the
48
+ * service that writes to it, so every failure degrades to "left as-is".
49
+ */
50
+ export function rotateIfOversized(
51
+ logPath: string,
52
+ maxBytes: number = MAX_LOG_BYTES,
53
+ keepBytes: number = ROTATED_KEEP_BYTES,
54
+ ): boolean {
55
+ let size: number;
56
+ try {
57
+ const st = fs.statSync(logPath);
58
+ if (!st.isFile()) return false;
59
+ size = st.size;
60
+ } catch {
61
+ return false; // no log yet
62
+ }
63
+ if (size <= maxBytes) return false;
64
+
65
+ try {
66
+ copyTail(logPath, rotatedPathFor(logPath), Math.min(keepBytes, size));
67
+ // Truncate to ZERO, not to a retained tail — see the module docstring.
68
+ fs.truncateSync(logPath, 0);
69
+ fs.appendFileSync(
70
+ logPath,
71
+ `\n─── ${product().cli} rotated ${new Date().toISOString()} — earlier output in ${path.basename(rotatedPathFor(logPath))} ───\n`,
72
+ );
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Copy the last `bytes` of `from` into `to`, starting at the first line break
81
+ * so the rotated file doesn't open mid-line. Streams in chunks — the whole
82
+ * point is that these files are too big to hold in memory.
83
+ */
84
+ function copyTail(from: string, to: string, bytes: number): void {
85
+ const src = fs.openSync(from, "r");
86
+ try {
87
+ const size = fs.fstatSync(src).size;
88
+ let start = Math.max(0, size - bytes);
89
+ if (start > 0) {
90
+ // Snap forward to just past the next newline.
91
+ const probe = Buffer.alloc(Math.min(64 * 1024, size - start));
92
+ const read = fs.readSync(src, probe, 0, probe.length, start);
93
+ const nl = probe.subarray(0, read).indexOf(0x0a);
94
+ if (nl !== -1) start += nl + 1;
95
+ }
96
+ const dst = fs.openSync(to, "w");
97
+ try {
98
+ const buf = Buffer.alloc(1024 * 1024);
99
+ let pos = start;
100
+ for (;;) {
101
+ const read = fs.readSync(src, buf, 0, buf.length, pos);
102
+ if (read <= 0) break;
103
+ fs.writeSync(dst, buf, 0, read);
104
+ pos += read;
105
+ }
106
+ } finally {
107
+ fs.closeSync(dst);
108
+ }
109
+ } finally {
110
+ fs.closeSync(src);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Rotate every oversized `*.log` under a log root (`<root>/<repo>/<name>.log`).
116
+ * Returns the paths rotated. Never throws — a sweep failure must not take the
117
+ * server down.
118
+ */
119
+ export function sweepLogs(
120
+ logRoot: string,
121
+ maxBytes: number = MAX_LOG_BYTES,
122
+ ): string[] {
123
+ const rotated: string[] = [];
124
+ let dirs: fs.Dirent[];
125
+ try {
126
+ dirs = fs.readdirSync(logRoot, { withFileTypes: true });
127
+ } catch {
128
+ return rotated; // no logs yet
129
+ }
130
+ for (const dir of dirs) {
131
+ if (!dir.isDirectory()) continue;
132
+ const sub = path.join(logRoot, dir.name);
133
+ let files: string[];
134
+ try {
135
+ files = fs.readdirSync(sub);
136
+ } catch {
137
+ continue;
138
+ }
139
+ for (const file of files) {
140
+ // `.log.1` is the rotated generation — capping it again would discard it.
141
+ if (!file.endsWith(".log")) continue;
142
+ const full = path.join(sub, file);
143
+ if (rotateIfOversized(full, maxBytes)) rotated.push(full);
144
+ }
145
+ }
146
+ return rotated;
147
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Shared streaming process spawner
3
+ *
4
+ * Extracted from src/ui/cli/test-runner.ts so both the CLI test runner
5
+ * and the environment manager can spawn processes without importing from
6
+ * the UI layer.
7
+ */
8
+
9
+ import { spawn } from "child_process";
10
+ import fs from "fs";
11
+ import path from "path";
12
+ import { rotateIfOversized } from "./log-rotation.js";
13
+ import { getStateDir } from "../paths.js";
14
+
15
+ import { product } from "../product.js";
16
+ // ── Log path ──────────────────────────────────────────────────────────────────
17
+
18
+ /**
19
+ * Canonical path for a service's daemon log file.
20
+ * ~/.den/logs/<repoName>/<subsystem>.log
21
+ */
22
+ export function getServiceLogPath(repoName: string, subsystem: string): string {
23
+ const dir = path.join(getServiceLogRoot(), repoName);
24
+ fs.mkdirSync(dir, { recursive: true });
25
+ return path.join(dir, `${subsystem}.log`);
26
+ }
27
+
28
+ /** Root of the per-service log tree: `~/.den/logs/<repo>/<service>.log`. */
29
+ export function getServiceLogRoot(): string {
30
+ return path.join(getStateDir(), "logs");
31
+ }
32
+
33
+ // ── ANSI strip ────────────────────────────────────────────────────────────────
34
+
35
+ function stripAnsi(data: Buffer, onLine: (line: string) => void): void {
36
+ // eslint-disable-next-line no-control-regex
37
+ const text = data.toString().replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
38
+ for (const line of text.split("\n")) {
39
+ const visible = line.split("\r").filter(Boolean).pop() ?? "";
40
+ if (visible.trim()) onLine(visible);
41
+ }
42
+ }
43
+
44
+ // ── Daemon spawner ────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Spawn a long-running service as a true daemon, fully decoupled from den's
48
+ * process space.
49
+ *
50
+ * Why this matters
51
+ * ────────────────
52
+ * `spawnStreaming` with `stdio: pipe` keeps a file-descriptor link between
53
+ * den and the service. When den exits (or the SSE response closes and
54
+ * Node GC's the stream), the pipe read-end closes → the child gets SIGPIPE on
55
+ * its next write → it dies. `proc.unref()` only removes the process from
56
+ * Node's event-loop ref-count; it does NOT sever the FD connection.
57
+ *
58
+ * This function fixes that by:
59
+ * 1. Writing a start-marker to `logPath` then opening it with `O_APPEND`.
60
+ * 2. Spawning the service with `stdio: ['ignore', logFd, logFd]` — no pipe,
61
+ * no connection to den's FD table.
62
+ * 3. Immediately closing the parent's copy of the log fd.
63
+ * 4. Starting a `tail -F` subprocess to stream output back to the caller.
64
+ *
65
+ * The returned process is the `tail` subprocess — kill it when you want to
66
+ * stop streaming without stopping the service. `onClose` fires when tail
67
+ * exits (i.e. when the caller kills it or the SSE detach window expires).
68
+ */
69
+ export function spawnDaemon(
70
+ command: string,
71
+ env: NodeJS.ProcessEnv,
72
+ logPath: string,
73
+ onLine: (line: string) => void,
74
+ onClose: (exitCode: number) => void,
75
+ cwd?: string,
76
+ onServiceExit?: (exitCode: number) => void,
77
+ ): ReturnType<typeof spawn> {
78
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
79
+
80
+ // Cap the log before the next run appends to it. Cheap (one statSync) and it
81
+ // covers restart-heavy services; the periodic sweep in the server catches
82
+ // long-lived ones that never respawn.
83
+ rotateIfOversized(logPath);
84
+
85
+ // Stamp the log so the tail starts reading from a clean boundary.
86
+ const ts = new Date().toISOString();
87
+ fs.appendFileSync(logPath, `\n\n─── ${product().cli} start ${ts} ───\n`);
88
+
89
+ // Open the log fd in the parent so we can hand it to the child.
90
+ // MUST stay "a" (O_APPEND): rotation truncates this file in place, and only
91
+ // O_APPEND recomputes the offset to EOF on every write. With plain O_WRONLY
92
+ // the child would keep its old offset and write a multi-GB sparse hole of
93
+ // NULs. See log-rotation.ts.
94
+ const logFd = fs.openSync(logPath, "a");
95
+
96
+ const svcProc = spawn(command, [], {
97
+ env,
98
+ cwd,
99
+ shell: true,
100
+ detached: true, // own process group → immune to Ctrl+C on den
101
+ stdio: ["ignore", logFd, logFd], // NO pipe to the CLI — survives its shutdown
102
+ });
103
+ // Notify caller when the service process itself exits (e.g. `make up` finishes
104
+ // after starting Docker containers). The service may still be "running" even
105
+ // after this fires — the process just handed off to the container runtime.
106
+ if (onServiceExit) {
107
+ svcProc.on("exit", (code) => onServiceExit(code ?? 0));
108
+ }
109
+ // Without an 'error' listener a spawn failure (e.g. cwd missing, ENOENT)
110
+ // emits an unhandled 'error' that crashes den. Surface it instead.
111
+ svcProc.on("error", (err) => {
112
+ onLine(`Error: ${err.message}`);
113
+ onServiceExit?.(1);
114
+ });
115
+ svcProc.unref(); // den's event loop can exit independently
116
+ fs.closeSync(logFd); // parent closes its copy; child keeps its own fd
117
+
118
+ // Tail the log for streaming back to the caller.
119
+ // -F follows by name (handles log rotation). -n 0 shows only new lines written
120
+ // after tail started (we already stamped the marker above).
121
+ const tailProc = spawn("tail", ["-F", "-n", "0", logPath], {
122
+ stdio: ["ignore", "pipe", "ignore"],
123
+ });
124
+
125
+ tailProc.stdout?.on("data", (data: Buffer) => stripAnsi(data, onLine));
126
+ tailProc.on("close", () => onClose(0));
127
+ tailProc.on("error", () => onClose(0));
128
+
129
+ return tailProc;
130
+ }
131
+
132
+ // ── Streaming spawner ─────────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * Spawn a process and stream stdout/stderr line-by-line via callbacks.
136
+ * ANSI colour codes are stripped so browser/terminal output renders cleanly.
137
+ *
138
+ * When `detached` is true AND `logPath` is provided the process is spawned as
139
+ * a true daemon (see `spawnDaemon`). The returned proc is then the `tail`
140
+ * subprocess used for streaming — kill it to stop streaming without stopping
141
+ * the service.
142
+ *
143
+ * @param command Executable name or shell string (when args is empty, shell mode is used)
144
+ * @param args Argument array — pass [] to invoke command as a raw shell string
145
+ * @param env Process environment
146
+ * @param onLine Called for each non-empty output line
147
+ * @param onClose Called with exit code when the process terminates
148
+ * @param cwd Working directory (defaults to process.cwd())
149
+ * @param detached Put the child in its own process group (default false)
150
+ * @param logPath If provided together with detached=true, run as a daemon writing to this path
151
+ */
152
+ export function spawnStreaming(
153
+ command: string,
154
+ args: string[],
155
+ env: NodeJS.ProcessEnv,
156
+ onLine: (line: string) => void,
157
+ onClose: (exitCode: number) => void,
158
+ cwd?: string,
159
+ detached?: boolean,
160
+ logPath?: string,
161
+ onServiceExit?: (exitCode: number) => void,
162
+ ): ReturnType<typeof spawn> {
163
+ // Daemon mode: service runs independently from den — survives restarts.
164
+ if (detached && logPath) {
165
+ return spawnDaemon(
166
+ command,
167
+ env,
168
+ logPath,
169
+ onLine,
170
+ onClose,
171
+ cwd,
172
+ onServiceExit,
173
+ );
174
+ }
175
+
176
+ // Standard mode: pipe stdout/stderr for streaming, optionally detached.
177
+ const proc = spawn(command, args, {
178
+ env,
179
+ cwd,
180
+ shell: args.length === 0, // shell mode when command is a raw string (service commands)
181
+ detached: detached ?? false,
182
+ stdio: ["ignore", "pipe", "pipe"],
183
+ });
184
+
185
+ proc.stdout?.on("data", (data: Buffer) => stripAnsi(data, onLine));
186
+ proc.stderr?.on("data", (data: Buffer) => stripAnsi(data, onLine));
187
+
188
+ proc.on("close", (code) => onClose(code ?? 1));
189
+ proc.on("error", (err) => {
190
+ onLine(`Error: ${err.message}`);
191
+ onClose(1);
192
+ });
193
+
194
+ if (detached) proc.unref();
195
+
196
+ return proc;
197
+ }
198
+
199
+ // ── Interactive spawner ───────────────────────────────────────────────────────
200
+
201
+ /**
202
+ * Spawn a process with full TTY access — stdin, stdout, and stderr are all
203
+ * inherited from the parent process. Use this for interactive commands that
204
+ * require human input (HITL), such as auth logins, license prompts, etc.
205
+ *
206
+ * No output streaming is performed — the user sees and interacts with the
207
+ * process directly in their terminal.
208
+ */
209
+ export function spawnInteractive(
210
+ command: string,
211
+ env: NodeJS.ProcessEnv,
212
+ onClose: (exitCode: number) => void,
213
+ cwd?: string,
214
+ ): ReturnType<typeof spawn> {
215
+ const proc = spawn(command, [], {
216
+ env,
217
+ cwd,
218
+ shell: true,
219
+ stdio: "inherit",
220
+ });
221
+
222
+ proc.on("close", (code) => onClose(code ?? 1));
223
+ proc.on("error", (err) => {
224
+ void err;
225
+ onClose(1);
226
+ });
227
+
228
+ return proc;
229
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The running product's names, for code that needs them at runtime.
3
+ *
4
+ * Den never hard-codes a product name: the CLI, env-var prefix, config and
5
+ * manifest file names and directories all come from the distribution's
6
+ * profile (den.yml, see profile.ts). This module is the safe accessor: it
7
+ * never throws, falling back to DEN_PROFILE or Den's own profile when no
8
+ * distribution root can be found (e.g. help text printed outside a checkout).
9
+ */
10
+
11
+ import { getDistRoot } from "./paths.js";
12
+ import {
13
+ loadProfile,
14
+ profileFrom,
15
+ readProfileFile,
16
+ type ProductProfile,
17
+ } from "./profile.js";
18
+
19
+ export interface Product extends ProductProfile {
20
+ /** Display name: the name with its first letter upper-cased ("Den"). */
21
+ title: string;
22
+ /** The YAML manifest file name (".den.yml"). */
23
+ manifestYml: string;
24
+ /** The JSON manifest file name (".den.json"). */
25
+ manifestJson: string;
26
+ }
27
+
28
+ function withDerived(p: ProductProfile): Product {
29
+ const yml = p.manifestFiles[0];
30
+ return {
31
+ ...p,
32
+ title: p.name.charAt(0).toUpperCase() + p.name.slice(1),
33
+ manifestYml: yml,
34
+ manifestJson: p.manifestFiles[1] ?? yml.replace(/\.ya?ml$/, "") + ".json",
35
+ };
36
+ }
37
+
38
+ /** The running product. Never throws. */
39
+ export function product(): Product {
40
+ try {
41
+ return withDerived(loadProfile(getDistRoot()));
42
+ } catch {
43
+ const pinned = process.env.DEN_PROFILE?.trim();
44
+ try {
45
+ return withDerived(pinned ? readProfileFile(pinned) : profileFrom());
46
+ } catch {
47
+ return withDerived(profileFrom());
48
+ }
49
+ }
50
+ }
51
+
52
+ /** The env-var name this product uses for `key`: envKey("ROOT") → "DEN_ROOT". */
53
+ export function envKey(key: string): string {
54
+ return `${product().envPrefix}_${key}`;
55
+ }
56
+
57
+ /** The value of this product's `key` env var, or undefined. */
58
+ export function envValue(
59
+ key: string,
60
+ env: NodeJS.ProcessEnv = process.env,
61
+ ): string | undefined {
62
+ return env[envKey(key)];
63
+ }
64
+
65
+ /**
66
+ * Placeholders the web pages use for product names; serveFile fills them in.
67
+ * Pages are static files, so they cannot ask the profile themselves.
68
+ */
69
+ export function productTokens(p: Product = product()): Record<string, string> {
70
+ return {
71
+ __DEN_CLI__: p.cli,
72
+ __DEN_NAME__: p.name,
73
+ __DEN_TITLE__: p.title,
74
+ __DEN_ENV_PREFIX__: p.envPrefix,
75
+ __DEN_CONFIG_FILE__: p.configFile,
76
+ __DEN_MANIFEST_YML__: p.manifestYml,
77
+ __DEN_MANIFEST_JSON__: p.manifestJson,
78
+ __DEN_SERVICES_DIR__: p.servicesDir,
79
+ __DEN_WORKSPACES_DIR__: p.workspacesDir,
80
+ };
81
+ }
82
+
83
+ /** Replace every product placeholder in `text`. */
84
+ export function fillProductTokens(
85
+ text: string,
86
+ p: Product = product(),
87
+ ): string {
88
+ let out = text;
89
+ for (const [token, value] of Object.entries(productTokens(p)))
90
+ out = out.split(token).join(value);
91
+ return out;
92
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The product profile: who this distribution is.
3
+ *
4
+ * Den is one engine; each distribution runs it under its own name. Everything product-named comes from here: the CLI, the env-var
5
+ * prefix, the config and manifest file names, and the state, services and
6
+ * workspaces directories. The profile lives in `den.yml` at the
7
+ * distribution root:
8
+ *
9
+ * product:
10
+ * name: acme # everything below derives from it by default
11
+ * # cli: acme
12
+ * # env_prefix: ACME
13
+ * # config: config/acme.yml
14
+ * # manifest: .acme.yml (.json is also read)
15
+ * # state_dir: ~/.acme
16
+ * # services_dir: .acme-services
17
+ * # workspaces_dir: acme-workspaces
18
+ *
19
+ * `DEN_PROFILE` may point at another profile file (tests use this). With no
20
+ * profile at all, Den runs under its own name.
21
+ */
22
+
23
+ import * as fs from "node:fs";
24
+ import * as os from "node:os";
25
+ import * as path from "node:path";
26
+ import { parse as parseYaml } from "yaml";
27
+
28
+ export const PROFILE_FILE = "den.yml";
29
+
30
+ export interface ProductProfile {
31
+ /** Display and slug name ("acme"). */
32
+ name: string;
33
+ /** The command a person types. */
34
+ cli: string;
35
+ /** Upper-case env-var prefix, without the trailing "_" ("ACME"). */
36
+ envPrefix: string;
37
+ /** Org config, relative to the distribution root. */
38
+ configFile: string;
39
+ /** Manifest file names probed in a repo, first match wins. */
40
+ manifestFiles: string[];
41
+ /** Per-user state directory (absolute, `~` expanded). */
42
+ stateDir: string;
43
+ /** Managed clones, relative to the distribution root. */
44
+ servicesDir: string;
45
+ /** Workspace checkouts, relative to the distribution root. */
46
+ workspacesDir: string;
47
+ }
48
+
49
+ interface RawProduct {
50
+ name?: unknown;
51
+ cli?: unknown;
52
+ env_prefix?: unknown;
53
+ config?: unknown;
54
+ manifest?: unknown;
55
+ state_dir?: unknown;
56
+ services_dir?: unknown;
57
+ workspaces_dir?: unknown;
58
+ }
59
+
60
+ const SLUG = /^[a-z][a-z0-9-]*$/;
61
+ const PREFIX = /^[A-Z][A-Z0-9_]*$/;
62
+
63
+ const str = (v: unknown): string | undefined =>
64
+ typeof v === "string" && v.trim() ? v.trim() : undefined;
65
+
66
+ const expandHome = (p: string): string =>
67
+ p === "~"
68
+ ? os.homedir()
69
+ : p.startsWith("~/")
70
+ ? path.join(os.homedir(), p.slice(2))
71
+ : p;
72
+
73
+ /** A profile from a `product:` block; unset fields derive from the name. */
74
+ export function profileFrom(raw: RawProduct = {}): ProductProfile {
75
+ const name = str(raw.name) ?? "den";
76
+ if (!SLUG.test(name))
77
+ throw new Error(
78
+ `den.yml: product.name "${name}" must be lower-case letters, digits and "-"`,
79
+ );
80
+ const envPrefix =
81
+ str(raw.env_prefix) ?? name.toUpperCase().replace(/-/g, "_");
82
+ if (!PREFIX.test(envPrefix))
83
+ throw new Error(
84
+ `den.yml: product.env_prefix "${envPrefix}" must be upper-case letters, digits and "_"`,
85
+ );
86
+ const manifest = str(raw.manifest) ?? `.${name}.yml`;
87
+ const json = manifest.replace(/\.ya?ml$/, ".json");
88
+ return {
89
+ name,
90
+ cli: str(raw.cli) ?? name,
91
+ envPrefix,
92
+ configFile: str(raw.config) ?? `config/${name}.yml`,
93
+ manifestFiles: json === manifest ? [manifest] : [manifest, json],
94
+ stateDir: path.resolve(expandHome(str(raw.state_dir) ?? `~/.${name}`)),
95
+ servicesDir: str(raw.services_dir) ?? `.${name}-services`,
96
+ workspacesDir: str(raw.workspaces_dir) ?? `${name}-workspaces`,
97
+ };
98
+ }
99
+
100
+ /** Read a profile file; a missing file is Den's own profile. */
101
+ export function readProfileFile(file: string): ProductProfile {
102
+ let raw: { product?: RawProduct } | null;
103
+ try {
104
+ raw = parseYaml(fs.readFileSync(file, "utf-8")) as typeof raw;
105
+ } catch (e) {
106
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return profileFrom();
107
+ throw new Error(`${file}: ${(e as Error).message}`);
108
+ }
109
+ return profileFrom(raw?.product ?? {});
110
+ }
111
+
112
+ let cached: { key: string; profile: ProductProfile } | null = null;
113
+
114
+ /**
115
+ * The profile for a distribution root: `DEN_PROFILE` if set, else
116
+ * `<root>/den.yml`, else Den's own. Cached per file.
117
+ */
118
+ export function loadProfile(root: string): ProductProfile {
119
+ const file = process.env.DEN_PROFILE?.trim() || path.join(root, PROFILE_FILE);
120
+ if (cached?.key !== file)
121
+ cached = { key: file, profile: readProfileFile(file) };
122
+ return cached.profile;
123
+ }
124
+
125
+ export function resetProfileCache(): void {
126
+ cached = null;
127
+ }
128
+
129
+ /** `${PREFIX}_${key}`: the env-var name this distribution uses for `key`. */
130
+ export function envName(profile: ProductProfile, key: string): string {
131
+ return `${profile.envPrefix}_${key}`;
132
+ }
133
+
134
+ /** The value of `${PREFIX}_${key}`, or undefined. */
135
+ export function readEnv(
136
+ profile: ProductProfile,
137
+ key: string,
138
+ env: NodeJS.ProcessEnv = process.env,
139
+ ): string | undefined {
140
+ return env[envName(profile, key)];
141
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Repo kinds: only a `service` has a runtime (up/down/status). A `module`
3
+ * (bolt-on a host invokes) or `library` (imported code) contributes to other
4
+ * repos instead.
5
+ */
6
+ import { readManifest } from "./manifest/read-manifest.js";
7
+ import type { DerivedGraph } from "./environment-manager/derivation/types.js";
8
+ import type { RepoKind } from "./types/index.js";
9
+
10
+ export function repoKind(repoPath: string): RepoKind {
11
+ return readManifest(repoPath)?.kind ?? "service";
12
+ }
13
+
14
+ export function hasRuntime(kind: RepoKind): boolean {
15
+ return kind === "service";
16
+ }
17
+
18
+ /**
19
+ * What a non-service repo contributes to: its declared `contributes_to`, plus
20
+ * every repo that imports it (an SDK-import edge pointing at it).
21
+ */
22
+ export function contributesTo(
23
+ repo: string,
24
+ repoPath: string,
25
+ graph?: DerivedGraph,
26
+ ): string[] {
27
+ const declared = readManifest(repoPath)?.contributes_to ?? [];
28
+ const importers = (graph?.edges ?? [])
29
+ .filter((e) => e.to === repo && e.signal === "http:sdk-import")
30
+ .map((e) => e.from);
31
+ return [...new Set([...declared, ...importers])].filter((r) => r !== repo);
32
+ }