@monster-orange/den 0.2.2 → 0.2.4

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 (37) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/docs/distributions.md +51 -0
  3. package/package.json +1 -1
  4. package/scripts/setup-secrets.ts +11 -0
  5. package/service/install.sh +4 -2
  6. package/src/shared/edge/config.ts +16 -0
  7. package/src/shared/environment-manager/health.ts +36 -1
  8. package/src/shared/environment-manager/opctl.ts +83 -1
  9. package/src/shared/environment-manager/service-runner.ts +205 -8
  10. package/src/shared/git/origin-url.ts +77 -0
  11. package/src/shared/manifest/read-manifest.ts +39 -15
  12. package/src/shared/manifest/schema.ts +15 -0
  13. package/src/shared/paths.ts +42 -0
  14. package/src/shared/product.ts +7 -1
  15. package/src/shared/profile.ts +8 -0
  16. package/src/shared/read-cache.ts +77 -0
  17. package/src/shared/secrets/doppler.ts +10 -5
  18. package/src/shared/secrets/index.ts +1 -0
  19. package/src/shared/secrets/keys.ts +2 -0
  20. package/src/shared/secrets/secret-env.ts +149 -0
  21. package/src/shared/secrets/types.ts +10 -0
  22. package/src/shared/stacks/config.ts +6 -0
  23. package/src/shared/stacks/runtime.ts +51 -3
  24. package/src/shared/stacks/status.ts +109 -1
  25. package/src/shared/stacks/types.ts +8 -0
  26. package/src/shared/types/index.ts +15 -0
  27. package/src/ui/cli/index.ts +151 -10
  28. package/src/ui/cli/mcp-connect.ts +77 -15
  29. package/src/ui/cli/stack-command.ts +47 -5
  30. package/src/ui/cli/workspace-notes.ts +43 -0
  31. package/src/ui/mcp/connect.ts +3 -1
  32. package/src/ui/server/graph-order.ts +22 -0
  33. package/src/ui/server/index.ts +101 -183
  34. package/src/ui/server/lifecycle-exec.ts +23 -0
  35. package/src/ui/server/services-list.ts +210 -0
  36. package/src/ui/server/single-flight.ts +53 -0
  37. package/src/ui/server/workspace-routes.ts +272 -181
@@ -27,6 +27,15 @@ import {
27
27
  shellQuote,
28
28
  } from "../terminal/tabs.js";
29
29
  import { readCatalog } from "./catalog.js";
30
+ import { getSecretsProvider } from "../secrets/index.js";
31
+ import {
32
+ assertSecretEnvSpec,
33
+ resolveSecretEnv,
34
+ secretEnvKeys,
35
+ type SecretEnvSpec,
36
+ } from "../secrets/secret-env.js";
37
+ import type { SecretsProvider } from "../secrets/types.js";
38
+ import { composeCommands } from "../stacks/manifest-commands.js";
30
39
  import { buildServiceEnv } from "./service-env.js";
31
40
  import {
32
41
  devOpContainerNames,
@@ -35,6 +44,8 @@ import {
35
44
  inspectOpctlContainer,
36
45
  isOpctlAvailable,
37
46
  listRunningOpctlContainers,
47
+ listRunningOpctlContainersAsync,
48
+ isOpctlAvailableAsync,
38
49
  } from "./opctl.js";
39
50
  import {
40
51
  getActiveWorkspace,
@@ -348,6 +359,24 @@ export function activeDaemonOwners(
348
359
  return (record?.workspaces ?? []).filter((w) => active.has(w));
349
360
  }
350
361
 
362
+ /** Whether workspace `name`'s current config lists `daemon`'s service. */
363
+ function workspaceContainsDaemon(
364
+ name: string,
365
+ daemon: Pick<DaemonRecord, "address" | "repoName">,
366
+ ): boolean {
367
+ const services = getWorkspaceByName(name)?.config.services ?? {};
368
+ return daemon.address in services || daemon.repoName in services;
369
+ }
370
+
371
+ /** Replace a running daemon's owners (re-derived from membership). */
372
+ function setDaemonOwners(address: string, workspaces: string[]): void {
373
+ loadDaemonsFromFile();
374
+ const existing = _daemonRegistry.get(address);
375
+ if (!existing) return;
376
+ _daemonRegistry.set(address, { ...existing, workspaces });
377
+ saveDaemonsFile(getDaemonsFile());
378
+ }
379
+
351
380
  /** Outcome of releasing one daemon during a workspace deactivation. */
352
381
  export interface DaemonRelease {
353
382
  address: string;
@@ -373,15 +402,19 @@ export async function releaseWorkspaceDaemons(
373
402
  const owned = listDaemons().filter((d) =>
374
403
  (d.workspaces ?? []).includes(workspace),
375
404
  );
376
- const active = new Set(getActiveWorkspaces());
405
+ const active = getActiveWorkspaces();
377
406
  const releases: DaemonRelease[] = [];
378
407
 
379
408
  for (const daemon of owned) {
380
- const remaining = activeDaemonOwners(daemon, active).filter(
381
- (w) => w !== workspace,
409
+ // Owners re-derived from the CURRENT membership, not the ones recorded at
410
+ // start: a repo moved into another workspace while its daemon ran belongs
411
+ // to that workspace now, and one moved out no longer belongs to this one.
412
+ // Stopped only when no active workspace still contains it.
413
+ const remaining = active.filter(
414
+ (w) => w !== workspace && workspaceContainsDaemon(w, daemon),
382
415
  );
383
416
  if (remaining.length > 0) {
384
- detachDaemonOwner(daemon.address, workspace);
417
+ setDaemonOwners(daemon.address, remaining);
385
418
  onLine(
386
419
  `detached "${workspace}" from ${daemon.address} — still running for [${remaining.join(", ")}]`,
387
420
  );
@@ -630,6 +663,27 @@ export function pruneDeadDaemons(): string[] {
630
663
  const running = isOpctlAvailable()
631
664
  ? new Set(listRunningOpctlContainers().map((c) => c.base))
632
665
  : null;
666
+ return pruneAgainst(daemons, running);
667
+ }
668
+
669
+ /**
670
+ * pruneDeadDaemons without blocking: the container listing runs as async
671
+ * child processes (the web server's startup, which used to hold the event
672
+ * loop for the `docker ps`).
673
+ */
674
+ export async function pruneDeadDaemonsAsync(): Promise<string[]> {
675
+ const daemons = listDaemons();
676
+ if (daemons.length === 0) return [];
677
+ const running = (await isOpctlAvailableAsync())
678
+ ? new Set((await listRunningOpctlContainersAsync()).map((c) => c.base))
679
+ : null;
680
+ return pruneAgainst(daemons, running);
681
+ }
682
+
683
+ function pruneAgainst(
684
+ daemons: DaemonRecord[],
685
+ running: Set<string> | null,
686
+ ): string[] {
633
687
  const pruned: string[] = [];
634
688
  for (const d of daemons) {
635
689
  try {
@@ -1069,6 +1123,12 @@ export interface RunCommandOptions {
1069
1123
  * the detach window is invisible to fail-fast callers.
1070
1124
  */
1071
1125
  onServiceExit?: (exitCode: number) => void;
1126
+ /**
1127
+ * R16: the service's resolved `secret_env` (see prepareServiceSecretEnv),
1128
+ * set only in the spawned process's env. An up/restart of a service that
1129
+ * declares secret_env is refused unless every key is here.
1130
+ */
1131
+ secretEnv?: Record<string, string>;
1072
1132
  }
1073
1133
 
1074
1134
  export interface RunCommandResult {
@@ -1135,6 +1195,108 @@ function repoStartedFallback(
1135
1195
  };
1136
1196
  }
1137
1197
 
1198
+ // ── secret_env (R16) ─────────────────────────────────────────────────────────
1199
+
1200
+ /** Commands that start a service process, and so get its secret_env. */
1201
+ function startsService(command: string): boolean {
1202
+ return command === "up" || command === "restart";
1203
+ }
1204
+
1205
+ /** A service's declared `services.<svc>.secret_env` (validated), or undefined. */
1206
+ function serviceSecretEnvSpec(
1207
+ resolved: ResolvedService,
1208
+ ): SecretEnvSpec | undefined {
1209
+ const raw = readManifest(resolved.repoPath)?.services?.[resolved.subsystem]
1210
+ ?.secret_env;
1211
+ return assertSecretEnvSpec(
1212
+ raw,
1213
+ `${resolved.address}: services.${resolved.subsystem}.secret_env`,
1214
+ );
1215
+ }
1216
+
1217
+ /**
1218
+ * The address whose command a run of `command` on `address` actually spawns
1219
+ * (a service with no up of its own runs its repo's up), or null when none.
1220
+ */
1221
+ function spawningAddress(
1222
+ address: string,
1223
+ command: string,
1224
+ workspace?: string,
1225
+ ): string | null {
1226
+ const p = previewServiceCommand(address, command, workspace);
1227
+ return p.kind === "run" || p.kind === "via-repo" ? p.address : null;
1228
+ }
1229
+
1230
+ /**
1231
+ * The secret_env key NAMES a run of `command` on `address` would inject,
1232
+ * for a dry run or status: never a value, and the provider is never asked.
1233
+ * `service` is the service's own secret_env (set in its command's env);
1234
+ * `compose` is the repo's compose.secret_env, which Den's synthesized stack
1235
+ * command resolves for the docker compose process.
1236
+ */
1237
+ export function previewServiceSecretEnv(
1238
+ address: string,
1239
+ command: string,
1240
+ workspace?: string,
1241
+ ): { address: string; service: string[]; compose: string[] } | null {
1242
+ if (!startsService(command)) return null;
1243
+ const target = spawningAddress(address, command, workspace);
1244
+ const resolved = target ? resolveService(target, workspace) : null;
1245
+ if (!resolved) return null;
1246
+ const manifest = readManifest(resolved.repoPath);
1247
+ const service = Object.keys(
1248
+ manifest?.services?.[resolved.subsystem]?.secret_env ?? {},
1249
+ ).sort();
1250
+ const declared = manifest?.services?.[resolved.subsystem]?.commands?.local;
1251
+ const synthesized =
1252
+ manifest?.compose && !declared?.[command]
1253
+ ? composeCommands(
1254
+ manifest,
1255
+ resolved.subsystem,
1256
+ resolved.repoName,
1257
+ workspace ?? "default",
1258
+ )[command]
1259
+ : undefined;
1260
+ const compose = synthesized
1261
+ ? Object.keys(manifest?.compose?.secret_env ?? {}).sort()
1262
+ : [];
1263
+ if (!service.length && !compose.length) return null;
1264
+ return { address: resolved.address, service, compose };
1265
+ }
1266
+
1267
+ export type ServiceSecretEnvResult =
1268
+ | { ok: true; secretEnv: Record<string, string> }
1269
+ | { ok: false; message: string };
1270
+
1271
+ /**
1272
+ * Resolve the `secret_env` of the service a run of `command` on `address`
1273
+ * spawns, for runServiceCommand's `secretEnv`. All or nothing; the provider
1274
+ * is only asked when the service declares secret_env, and only for up and
1275
+ * restart. Messages name keys, refs and the provider, never values.
1276
+ */
1277
+ export async function prepareServiceSecretEnv(
1278
+ address: string,
1279
+ command: string,
1280
+ workspace?: string,
1281
+ provider: () => SecretsProvider = getSecretsProvider,
1282
+ ): Promise<ServiceSecretEnvResult> {
1283
+ if (!startsService(command)) return { ok: true, secretEnv: {} };
1284
+ const target = spawningAddress(address, command, workspace);
1285
+ const resolved = target ? resolveService(target, workspace) : null;
1286
+ if (!resolved) return { ok: true, secretEnv: {} };
1287
+ let spec: SecretEnvSpec | undefined;
1288
+ try {
1289
+ spec = serviceSecretEnvSpec(resolved);
1290
+ } catch (e) {
1291
+ return { ok: false, message: (e as Error).message };
1292
+ }
1293
+ if (!secretEnvKeys(spec).length) return { ok: true, secretEnv: {} };
1294
+ const r = await resolveSecretEnv(spec, provider(), resolved.address);
1295
+ return r.ok
1296
+ ? { ok: true, secretEnv: r.env }
1297
+ : { ok: false, message: r.message };
1298
+ }
1299
+
1138
1300
  /** What a run of `command` on `address` would do, without running it. */
1139
1301
  export type ServiceCommandPreview =
1140
1302
  | { kind: "run"; address: string }
@@ -1516,6 +1678,41 @@ export function runServiceCommand(
1516
1678
  onLine,
1517
1679
  );
1518
1680
 
1681
+ // R16: a service that declares secret_env never starts without every key
1682
+ // (a caller that did not resolve them is refused, not run without them).
1683
+ let secretEnv: Record<string, string> = {};
1684
+ if (startsService(command)) {
1685
+ let declared: string[];
1686
+ try {
1687
+ declared = secretEnvKeys(serviceSecretEnvSpec(resolved));
1688
+ } catch (e) {
1689
+ onLine(`Error: ${(e as Error).message}`);
1690
+ closeAsync(1);
1691
+ return dummy();
1692
+ }
1693
+ const missing = declared.filter((k) => !options.secretEnv?.[k]);
1694
+ if (missing.length) {
1695
+ onLine(
1696
+ `Error: ${resolved.address} declares secret_env ${missing.join(", ")}, which were not resolved; not starting it`,
1697
+ );
1698
+ closeAsync(1);
1699
+ return dummy();
1700
+ }
1701
+ secretEnv = Object.fromEntries(
1702
+ declared.map((k) => [k, options.secretEnv?.[k] as string]),
1703
+ );
1704
+ }
1705
+ const hasSecrets = Object.keys(secretEnv).length > 0;
1706
+ // Secrets go only in the spawned process's env. A tab would need them in
1707
+ // its command line (and so its shell history), so such a start runs as a
1708
+ // daemon instead.
1709
+ const runEnv: NodeJS.ProcessEnv = { ...env, ...secretEnv };
1710
+ if (hasSecrets && runtime === "tab" && startsService(command))
1711
+ onLine(
1712
+ `${resolved.address} has secret_env: running as a daemon, not in a tab, to keep the values out of the tab's shell`,
1713
+ );
1714
+ const useTab = runtime === "tab" && !hasSecrets;
1715
+
1519
1716
  // Manifest downs that are just `opctl container down <name>` must not be
1520
1717
  // spawned raw: opctl re-elevates with `sudo -E` and fails on sudoers without
1521
1718
  // SETENV even when there is nothing to stop — which is why files-api/api
@@ -1571,7 +1768,7 @@ export function runServiceCommand(
1571
1768
  // HITL command — inherit the full TTY; onLine is not called.
1572
1769
  const proc = spawnInteractive(
1573
1770
  cmdString,
1574
- { ...process.env, ...env },
1771
+ { ...process.env, ...runEnv },
1575
1772
  onClose,
1576
1773
  repoPath,
1577
1774
  );
@@ -1621,11 +1818,11 @@ export function runServiceCommand(
1621
1818
  }
1622
1819
  };
1623
1820
 
1624
- if (runtime === "tab" && command === "up") {
1821
+ if (useTab && command === "up") {
1625
1822
  return openCommandInTab(cmdString);
1626
1823
  }
1627
1824
 
1628
- if (runtime === "tab" && command === "restart") {
1825
+ if (useTab && command === "restart") {
1629
1826
  closeExistingTab();
1630
1827
  if (requestedCmdDef) return openCommandInTab(cmdString);
1631
1828
 
@@ -1743,7 +1940,7 @@ export function runServiceCommand(
1743
1940
  const proc = spawnStreaming(
1744
1941
  cmdString,
1745
1942
  [],
1746
- { ...process.env, ...env },
1943
+ { ...process.env, ...runEnv },
1747
1944
  onLine,
1748
1945
  wrappedOnClose,
1749
1946
  repoPath,
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A checkout's `origin` URL, read from its git config file instead of by
3
+ * running `git`. Callers on the web server's request path (manifest
4
+ * resolution for every repo without its own manifest) used to spawn
5
+ * `git config --get remote.origin.url` synchronously, one repo after another:
6
+ * ~40-100ms each, which held the event loop for a second on a cold server.
7
+ *
8
+ * Same answer as `git -C <dir> config --get remote.origin.url` for ordinary
9
+ * checkouts and linked worktrees: the repo is found by walking up from `dir`
10
+ * to the first `.git` (a directory, or a `gitdir:` file), and a worktree's
11
+ * config is its common dir's.
12
+ */
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+
16
+ /** The git directory for `start` (walking up), or null outside a repo. */
17
+ export function findGitDir(start: string): string | null {
18
+ let dir = path.resolve(start);
19
+ for (;;) {
20
+ const dotGit = path.join(dir, ".git");
21
+ try {
22
+ const st = fs.statSync(dotGit);
23
+ if (st.isDirectory()) return dotGit;
24
+ if (st.isFile()) {
25
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(
26
+ fs.readFileSync(dotGit, "utf-8"),
27
+ );
28
+ if (m) return path.resolve(dir, m[1]);
29
+ }
30
+ } catch {
31
+ /* no .git here: keep walking */
32
+ }
33
+ const parent = path.dirname(dir);
34
+ if (parent === dir) return null;
35
+ dir = parent;
36
+ }
37
+ }
38
+
39
+ /** The `url` of `[remote "origin"]` in a git config file's text, or null. */
40
+ export function originUrlFromConfig(text: string): string | null {
41
+ let inOrigin = false;
42
+ for (const raw of text.split(/\r?\n/)) {
43
+ const line = raw.trim();
44
+ if (!line || line.startsWith("#") || line.startsWith(";")) continue;
45
+ if (line.startsWith("[")) {
46
+ inOrigin = /^\[\s*remote\s+"origin"\s*\]$/i.test(line);
47
+ continue;
48
+ }
49
+ if (!inOrigin) continue;
50
+ const m = /^url\s*=\s*(.*)$/i.exec(line);
51
+ if (m) {
52
+ const value = m[1].replace(/\s+[#;].*$/, "").trim();
53
+ return value.replace(/^"(.*)"$/, "$1") || null;
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /** `dir`'s origin URL, or null (not a checkout, or no origin). Never spawns. */
60
+ export function readOriginUrl(dir: string): string | null {
61
+ const gitDir = findGitDir(dir);
62
+ if (!gitDir) return null;
63
+ let common = gitDir;
64
+ try {
65
+ const rel = fs.readFileSync(path.join(gitDir, "commondir"), "utf-8").trim();
66
+ if (rel) common = path.resolve(gitDir, rel);
67
+ } catch {
68
+ /* not a linked worktree */
69
+ }
70
+ try {
71
+ return originUrlFromConfig(
72
+ fs.readFileSync(path.join(common, "config"), "utf-8"),
73
+ );
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
@@ -15,7 +15,8 @@
15
15
 
16
16
  import * as fs from "fs";
17
17
  import * as path from "path";
18
- import { execFileSync } from "child_process";
18
+ import { readOriginUrl } from "../git/origin-url.js";
19
+ import { cachedRead } from "../read-cache.js";
19
20
  import { getDistRoot } from "../paths.js";
20
21
  import { parse as parseYaml } from "yaml";
21
22
  import type { RawManifest } from "../types/index.js";
@@ -48,21 +49,15 @@ const originCache = new Map<string, string | null>();
48
49
  /** Repo name of `repoPath`'s git origin (`…/<name>.git`), cached. */
49
50
  export function originRepoName(repoPath: string): string | null {
50
51
  if (originCache.has(repoPath)) return originCache.get(repoPath)!;
51
- let name: string | null = null;
52
- try {
53
- const url = execFileSync(
54
- "git",
55
- ["-C", repoPath, "config", "--get", "remote.origin.url"],
56
- { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 },
57
- ).trim();
58
- name =
59
- url
52
+ // Read from the git config file, never by spawning git: this runs on the
53
+ // web server's request path for every repo without its own manifest.
54
+ const url = readOriginUrl(repoPath);
55
+ const name = url
56
+ ? url
60
57
  .replace(/\.git$/, "")
61
58
  .split(/[/:]/)
62
- .pop() || null;
63
- } catch {
64
- /* not a git checkout, or no origin */
65
- }
59
+ .pop() || null
60
+ : null;
66
61
  originCache.set(repoPath, name);
67
62
  return name;
68
63
  }
@@ -169,6 +164,29 @@ export function parseManifestText(
169
164
  return parsed as Record<string, unknown>;
170
165
  }
171
166
 
167
+ /**
168
+ * Parsed manifests by file, reused while the file's text is unchanged. A
169
+ * single web request reads the same manifests several times (kind, health
170
+ * plan, stack names, edge links), and parsing YAML is the expensive part:
171
+ * it held the event loop ~100-200ms per /api/health run. Callers get their
172
+ * own copy, so mutating a result never leaks into the cache.
173
+ */
174
+ const parsedCache = new Map<
175
+ string,
176
+ { raw: string; parsed: Record<string, unknown> | null }
177
+ >();
178
+
179
+ function parseCached(
180
+ file: string,
181
+ raw: string,
182
+ ): Record<string, unknown> | null {
183
+ const hit = parsedCache.get(file);
184
+ if (hit && hit.raw === raw) return hit.parsed && structuredClone(hit.parsed);
185
+ const parsed = parseManifestText(file, raw);
186
+ parsedCache.set(file, { raw, parsed });
187
+ return parsed && structuredClone(parsed);
188
+ }
189
+
172
190
  /** The message for an override that has no base to apply to. */
173
191
  export function orphanOverrideMessage(): string {
174
192
  return `an override with no base manifest is not a manifest: add a ${product().manifestYml} to the repo, or to manifests/`;
@@ -198,7 +216,7 @@ function combine(
198
216
  ): ManifestLoad {
199
217
  const out: ManifestLoad = { manifest: null, layers: [], ...at };
200
218
  const parse = (file: string, raw: string): Record<string, unknown> => {
201
- const parsed = parseManifestText(file, raw);
219
+ const parsed = parseCached(file, raw);
202
220
  if (!parsed) throw new Error(`${file}: not a map`);
203
221
  return parsed;
204
222
  };
@@ -224,6 +242,12 @@ function combine(
224
242
 
225
243
  /** Resolve, read and merge `repoPath`'s manifest layers. Never throws. */
226
244
  export function loadManifest(repoPath: string): ManifestLoad {
245
+ // The web server resolves the same manifests many times per request; it
246
+ // reuses them for the read cache's TTL (see read-cache.ts).
247
+ return cachedRead(`manifest:${repoPath}`, () => loadManifestFresh(repoPath));
248
+ }
249
+
250
+ function loadManifestFresh(repoPath: string): ManifestLoad {
227
251
  const at = locate(repoPath);
228
252
  try {
229
253
  return combine(
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { z } from "zod";
11
11
  import { journeysAppSchema } from "../../pillars/journeys/app-config.js";
12
+ import { SECRET_ENV_KEY_RE, secretEnvRefError } from "../secrets/secret-env.js";
12
13
 
13
14
  export const CURRENT_SCHEMA_VERSION = 1;
14
15
  export const KNOWN_VERSIONS = [1] as const;
@@ -69,6 +70,16 @@ const logSource = z.union([
69
70
  }),
70
71
  ]);
71
72
 
73
+ // secret_env (R16): ENV_NAME → "<vault>/<item>/<field>", resolved through the
74
+ // secrets provider at up/restart and passed only in the process env.
75
+ const secretEnvConfig = z.record(
76
+ z.string().regex(SECRET_ENV_KEY_RE, "must be a valid env var name"),
77
+ z.string().superRefine((ref, ctx) => {
78
+ const err = secretEnvRefError(ref);
79
+ if (err) ctx.addIssue({ code: z.ZodIssueCode.custom, message: err });
80
+ }),
81
+ );
82
+
72
83
  // ── Service management config ─────────────────────────────────────────────────
73
84
  //
74
85
  // Keys for commands/logs/health are environment names (local, dev, …).
@@ -105,6 +116,7 @@ const serviceTarget = z
105
116
  const serviceManagementConfig = z.object({
106
117
  runs: z.record(z.string(), z.enum(["local", "remote"])).optional(),
107
118
  target: serviceTarget.optional(),
119
+ secret_env: secretEnvConfig.optional(),
108
120
  depends_on: z.array(dependsOnEntry).optional(),
109
121
  commands: z.record(z.string(), z.record(z.string(), commandDef)).optional(),
110
122
  logs: z.record(z.string(), logSource).optional(),
@@ -280,6 +292,9 @@ const composeConfig = z.object({
280
292
  z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
281
293
  )
282
294
  .optional(),
295
+ // Env vars resolved from the secrets provider into the docker compose
296
+ // process's env at up/restart. Never written to the override.
297
+ secret_env: secretEnvConfig.optional(),
283
298
  });
284
299
 
285
300
  // ── v1 manifest schema ────────────────────────────────────────────────────────
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import {
4
4
  loadProfile,
5
+ onProfileCacheReset,
5
6
  PROFILE_FILE,
6
7
  readEnv,
7
8
  type ProductProfile,
@@ -74,6 +75,34 @@ function rootFromEnv(): string | null {
74
75
  * anchor fails loudly.
75
76
  */
76
77
  export function getDistRoot(): string {
78
+ // Resolved once per set of inputs: the walk and its den.yml checks used to
79
+ // run on every call (hundreds per web request, via product()). The inputs
80
+ // are the env vars it reads and the entry script; change any and it
81
+ // re-resolves. A failure is never cached.
82
+ const key = distRootKey();
83
+ if (distRootMemo?.key === key) return distRootMemo.root;
84
+ const root = resolveDistRoot();
85
+ distRootMemo = { key, root };
86
+ return root;
87
+ }
88
+
89
+ let distRootMemo: { key: string; root: string } | null = null;
90
+
91
+ /** Everything getDistRoot's answer depends on, as one string. */
92
+ function distRootKey(): string {
93
+ let key = `${process.argv[1] ?? ""}\0${process.env.DEN_PROFILE ?? ""}`;
94
+ for (const name in process.env)
95
+ if (name.endsWith("_ROOT")) key += `\0${name}=${process.env[name]}`;
96
+ return key;
97
+ }
98
+
99
+ /** Forget the resolved root (tests that rebuild a root in place). */
100
+ export function resetDistRootCache(): void {
101
+ distRootMemo = null;
102
+ }
103
+ onProfileCacheReset(resetDistRootCache);
104
+
105
+ function resolveDistRoot(): string {
77
106
  const fromEnv = rootFromEnv();
78
107
  if (fromEnv) return fromEnv;
79
108
  const start = entryDir();
@@ -128,6 +157,19 @@ function isProductRootVar(dir: string, key: string): boolean {
128
157
  * node_modules/@monster-orange/den; in Den's own checkout, its root.
129
158
  */
130
159
  export function getEngineRoot(): string {
160
+ const key = `${process.env.DEN_ENGINE_ROOT ?? ""}\0${process.argv[1] ?? ""}\0${process.cwd()}`;
161
+ if (engineRootMemo?.key === key) return engineRootMemo.root;
162
+ const root = resolveEngineRoot();
163
+ engineRootMemo = { key, root };
164
+ return root;
165
+ }
166
+
167
+ let engineRootMemo: { key: string; root: string } | null = null;
168
+ onProfileCacheReset(() => {
169
+ engineRootMemo = null;
170
+ });
171
+
172
+ function resolveEngineRoot(): string {
131
173
  const env = process.env.DEN_ENGINE_ROOT?.trim();
132
174
  if (env) return path.resolve(env);
133
175
  for (const start of [entryDir(), process.cwd()]) {
@@ -35,10 +35,16 @@ function withDerived(p: ProductProfile): Product {
35
35
  };
36
36
  }
37
37
 
38
+ let derived: { profile: ProductProfile; product: Product } | null = null;
39
+
38
40
  /** The running product. Never throws. */
39
41
  export function product(): Product {
40
42
  try {
41
- return withDerived(loadProfile(getDistRoot()));
43
+ const profile = loadProfile(getDistRoot());
44
+ // Same profile object (loadProfile caches it): same derived names.
45
+ if (derived?.profile !== profile)
46
+ derived = { profile, product: withDerived(profile) };
47
+ return derived.product;
42
48
  } catch {
43
49
  const pinned = process.env.DEN_PROFILE?.trim();
44
50
  try {
@@ -122,8 +122,16 @@ export function loadProfile(root: string): ProductProfile {
122
122
  return cached.profile;
123
123
  }
124
124
 
125
+ const resetHooks: (() => void)[] = [];
126
+
127
+ /** Run `hook` whenever resetProfileCache runs (caches derived from it). */
128
+ export function onProfileCacheReset(hook: () => void): void {
129
+ resetHooks.push(hook);
130
+ }
131
+
125
132
  export function resetProfileCache(): void {
126
133
  cached = null;
134
+ for (const hook of resetHooks) hook();
127
135
  }
128
136
 
129
137
  /** `${PREFIX}_${key}`: the env-var name this distribution uses for `key`. */
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A short-lived cache for the web server's repeated reads (manifests, the
3
+ * edge config, the derived graph), so a request does not re-stat and
4
+ * re-read the same files dozens of times, and polls a few seconds apart
5
+ * share one read.
6
+ *
7
+ * Off unless the web server enables it: a CLI command and the tests read
8
+ * fresh every time, as before. The server clears it on every mutating
9
+ * request, so a write made through the UI is seen by the very next read;
10
+ * an edit made outside the server (an editor, the CLI) is seen within the
11
+ * TTL. Values are cloned on the way out, so a caller that mutates its copy
12
+ * never changes what the next caller gets.
13
+ */
14
+
15
+ let ttlMs = 0;
16
+ const entries = new Map<string, { at: number; value: unknown }>();
17
+ /** cachedReadStale's values: kept across a clear, only marked stale. */
18
+ const stale = new Map<string, { at: number; value: unknown }>();
19
+ const refreshing = new Set<string>();
20
+
21
+ /** Turn the cache on (web server only). */
22
+ export function enableReadCache(ms: number): void {
23
+ ttlMs = ms;
24
+ }
25
+
26
+ /** Drop every cached read (after a mutation, or in tests). */
27
+ export function clearReadCache(): void {
28
+ entries.clear();
29
+ for (const e of stale.values()) e.at = 0;
30
+ }
31
+
32
+ function copy<T>(v: T): T {
33
+ return v !== null && typeof v === "object" ? structuredClone(v) : v;
34
+ }
35
+
36
+ /** `load()`'s value for `key`, reused for the TTL when the cache is on. */
37
+ export function cachedRead<T>(key: string, load: () => T): T {
38
+ if (ttlMs <= 0) return load();
39
+ const hit = entries.get(key);
40
+ if (hit && Date.now() - hit.at < ttlMs) return copy(hit.value as T);
41
+ const value = load();
42
+ entries.set(key, { at: Date.now(), value });
43
+ return copy(value);
44
+ }
45
+
46
+ /**
47
+ * Like cachedRead, but past the TTL it still answers with the last value at
48
+ * once and recomputes after the current request (setImmediate), so no
49
+ * request waits on the recompute. For expensive, slow-changing results
50
+ * (the derived dependency graph) whose staleness only affects ordering.
51
+ */
52
+ export function cachedReadStale<T>(
53
+ key: string,
54
+ maxAgeMs: number,
55
+ load: () => T,
56
+ ): T {
57
+ if (ttlMs <= 0) return load();
58
+ const hit = stale.get(key);
59
+ if (!hit) {
60
+ const value = load();
61
+ stale.set(key, { at: Date.now(), value });
62
+ return copy(value);
63
+ }
64
+ if (Date.now() - hit.at >= maxAgeMs && !refreshing.has(key)) {
65
+ refreshing.add(key);
66
+ setImmediate(() => {
67
+ try {
68
+ stale.set(key, { at: Date.now(), value: load() });
69
+ } catch {
70
+ /* keep the last good value */
71
+ } finally {
72
+ refreshing.delete(key);
73
+ }
74
+ });
75
+ }
76
+ return copy(hit.value as T);
77
+ }