@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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,157 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.2.4] - 2026-11-04
8
+
9
+ ### Fixed
10
+
11
+ - **`GET /api/services` no longer blocks the web server.** It looked up
12
+ each workspace checkout's branch with a synchronous `git rev-parse`, one
13
+ after another (~40ms each), so with a dozen repos every request held the
14
+ event loop for 0.5-1.5s and everything else waited behind it: the UI,
15
+ the MCP server's reads, and `/api/server-info`, which `<cli> mcp connect`
16
+ probes with a 1.5s timeout. The lookups now run as async child processes
17
+ in parallel; concurrent requests (one per open tab) share one build, and
18
+ a settled build is reused for 2s (`?refresh=1` forces a new one; any
19
+ mutating request clears it). Concurrent `/api/health` polls also share
20
+ one run instead of each starting its own.
21
+ - **`<cli> mcp connect` / `mcp clients` tell a slow web server from a
22
+ stopped one.** A server that accepts the connection but does not answer
23
+ within 1.5s is reported as "not answering within 1.5s", not "not
24
+ running". When the org config sets `web.service` (the web UI runs as a
25
+ launchd service), the advice is `<cli> service status` instead of
26
+ `<cli> web up`.
27
+ - **Web server shutdown no longer leaks an unhandled rejection, or hangs.**
28
+ On SIGTERM/SIGINT the server reads the running-services registry, whose
29
+ path resolves the distribution root again; if that walk fails (the dist's
30
+ den.yml moved or removed while the server ran), the async shutdown
31
+ rejected unhandled ("unhandled rejection (continuing): Error: cannot
32
+ locate the distribution root: walked up from
33
+ <dist>/node_modules/@monster-orange/den/src/ui/server") and, its signal
34
+ guard already set, the process never exited, so a service manager had to
35
+ wait out its timeout and kill it. The shutdown now logs the failure and
36
+ exits.
37
+ - **Deactivating a workspace no longer stops a service that moved to
38
+ another workspace.** A daemon's owners were the workspaces recorded when
39
+ it started, so a repo moved from `default` into `ws-b` while its daemon
40
+ ran was still "owned" only by `default`, and `workspace deactivate
41
+ default` stopped it. Release now re-derives the owners from the current
42
+ workspaces.yml membership: a daemon is stopped only when no active
43
+ workspace still lists its repo, and otherwise its recorded owners become
44
+ the active workspaces that do.
45
+ - **`<cli> workspace --help` lists every subcommand**: activate,
46
+ deactivate and focus were missing (and `workspace focus <name>` now
47
+ exists in the CLI, as it did in the web UI). Deactivate's help and output
48
+ say that it releases only the services Den tracks: compose stacks of the
49
+ workspace's repos keep running, and the output names the running ones
50
+ with `<cli> down <repo>` for each (from one `docker ps`; a generic line
51
+ when docker can't be asked).
52
+ - **The first `/api/health/stream` (and `/api/health`) cycle no longer
53
+ blocks the web server for a second.** Resolving the manifest of a repo
54
+ without its own (a held manifest under `manifests/`) looked up the
55
+ checkout's origin with a synchronous `git config --get
56
+ remote.origin.url`, one repo after another (~40-100ms each), so a
57
+ server's first health run held the event loop for 1-1.5s. The origin is
58
+ now read from the checkout's git config file (worktrees and
59
+ subdirectories included), never by spawning git. Parsed manifests are
60
+ also reused while the file is unchanged: one request read the same
61
+ manifests several times, and parsing them held the loop ~100-200ms per
62
+ health run.
63
+ - **`GET /api/workspaces/available-repos` no longer blocks the web
64
+ server.** It ran `git remote get-url origin` (and, for managed clones,
65
+ `git rev-parse`) synchronously per checkout; the lookups are now async
66
+ and in parallel, and concurrent requests share one walk (reused for 2s;
67
+ `?refresh=1` forces a new one, any mutating request clears it).
68
+ - **`workspace deactivate` names the compose stacks a repo runs itself.**
69
+ It only knew Den stacks (a manifest's `compose:` section, project
70
+ `<stack>-<workspace>`), so a distribution whose repos start their own
71
+ compose projects (`make up`) got no line at all. A running project now
72
+ counts when Compose started it from inside one of the workspace's
73
+ checkouts (its `com.docker.compose.project.working_dir` label), as well
74
+ as when it is a Den stack's project.
75
+ - **`<cli> workspace <sub> --help`** prints that subcommand's help, not the
76
+ whole `workspace` help.
77
+ - **No synchronous directory walks or spawns on the web server's hot
78
+ request paths** (`/api/workspaces/available-repos`, the `/api/health`
79
+ stream). On a loaded machine (load average ~50) the dev.1 fixes still
80
+ left stalls of 1-5s; a CPU profile showed why:
81
+ - available-repos recursed into every directory without a manifest file
82
+ of its own, so in a distribution whose repos use held manifests it
83
+ walked each repo's whole tree (to depth 4) with `readdirSync` /
84
+ `existsSync`: seconds per call. It now never descends into a git
85
+ checkout or `node_modules`, and the walk is `fs.promises`.
86
+ - The distribution root (and so `product()`) was re-resolved on every
87
+ call, a den.yml `existsSync` per `*_ROOT` variable each time, hundreds
88
+ of times per request. It is now resolved once per set of inputs (the
89
+ env vars and entry script it reads); the engine root likewise.
90
+ - Manifests, the edge config and the derived graph order are reused
91
+ across a request and the polls after it (5s; any mutating request
92
+ clears them), and the graph order is recomputed after a request, never
93
+ inside one.
94
+ - HTTPS health checks built a TLS context from ~150 root certificates on
95
+ every check; one context is built once, at startup.
96
+ - The startup sweep of stale daemon records ran `docker ps` / `opctl
97
+ container ls` synchronously; it is async now (concurrent callers share
98
+ one call), and `/api/service/daemons` waits for it.
99
+ A new test runs the server with a spy on every synchronous fs and
100
+ child_process call and fails if either route makes a sync spawn or
101
+ directory walk, or any sync call outside a short allowlist of single
102
+ config-file reads.
103
+ - **The web UI service no longer runs at background priority.**
104
+ `service install` wrote `ProcessType: Background` into the LaunchAgent,
105
+ so macOS ran the UI server on efficiency cores with throttled I/O: on a
106
+ busy machine its first requests stalled for 5-11s. It is now
107
+ `Interactive`, and `service status` shows launchd's spawn type.
108
+ - **`workspace deactivate` never suggests stopping a stack another active
109
+ workspace still holds.** Such a stack is listed as "in use by another
110
+ workspace: <project> (still in <ws>)" instead of with a `down <repo>`
111
+ command, and a repo with a stack still in use gets no `down` suggestion.
112
+
113
+ ### Migrations
114
+
115
+ - Re-run `<cli> service install` to pick up the new LaunchAgent priority
116
+ (`<cli> service status` should show `spawn type = interactive`).
117
+
118
+ ## [0.2.3] - 2026-11-04
119
+
120
+ ### Added
121
+
122
+ - **`secret_env`: secrets resolved by Den into a stack's or a service's env**
123
+ (R16), so a consumer no longer wraps `<provider> run -- <cli> restart
124
+ <repo>` by hand, and a plain restart no longer drops a key. In a manifest,
125
+ `compose.secret_env` (for the docker compose process: the compose file's
126
+ `${VAR}` or pass-through `environment:` entries) and
127
+ `services.<svc>.secret_env` (for that service's own up/restart command)
128
+ map an env var to a fully qualified ref, `<vault>/<item>/<field>`. For
129
+ Doppler that is `<project>/<config>/<key>`; for 1Password vault / item /
130
+ field; for `env` the field is the env var to read. Existing refs (the
131
+ engine's named secrets, test users) are unchanged. Resolved through the
132
+ configured `secrets.provider` at `up` and `restart` (also the in-place
133
+ restart of one compose service), all or nothing, before anything stops or
134
+ starts. A missing key fails that target, exit 1, naming the key, ref and
135
+ provider. Values go only into the spawned process's env: never into the
136
+ compose override, logs, activity records, MCP output or messages. A dry
137
+ run (`-n`) never asks the provider; it lists the key names it would
138
+ inject. A CLI-tabs start of a service with `secret_env` runs as a daemon,
139
+ so the values stay out of the tab's shell. `validate` rejects a ref that
140
+ is not exactly three non-empty parts, and a key that is not an env var
141
+ name. See "Secrets in a target's env" in `docs/distributions.md`.
142
+
143
+ ### Fixed
144
+
145
+ - **`<cli> mcp connect` for Claude Code failed whenever the server has env**
146
+ ("Invalid environment variable format: <name>"): `claude mcp add`'s `-e`
147
+ takes several values, so the server name after the env pairs was read as
148
+ one more. The name now comes right after `add`.
149
+ - **The up/restart secrets check said the backend was "not ready" when it
150
+ was signed in with no default scope.** `secret_env` refs name their own
151
+ scope, so they still resolve; it now says "signed in, no default scope
152
+ set" instead of warning.
153
+
154
+ ### Migrations
155
+
156
+ - None required.
157
+
7
158
  ## [0.2.2] - 2026-11-04
8
159
 
9
160
  ### Added
@@ -213,6 +213,57 @@ ones (`⇄ hybrid`).
213
213
  production_envs: [prod] # default: [prod, production]
214
214
  ```
215
215
 
216
+ ## Secrets in a target's env
217
+
218
+ `secret_env` puts secrets from the configured secrets provider
219
+ (`secrets.provider`: `doppler`, `1password` or `env`) into the env of what
220
+ Den starts, instead of wrapping `<provider> run -- <cli> restart <repo>` by
221
+ hand:
222
+
223
+ ```yaml
224
+ # .acme.yml
225
+ compose:
226
+ secret_env: # the docker compose process's env, at stack up / restart
227
+ OPENAI_API_KEY: shop/dev_personal/OPENAI_API_KEY
228
+ services:
229
+ worker:
230
+ secret_env: # the env of this service's own up / restart command
231
+ STRIPE_KEY: Engineering/Stripe Test/secret key
232
+ ```
233
+
234
+ Each value is a fully qualified ref, `<vault>/<item>/<field>`:
235
+
236
+ | Provider | `a/b/c` means |
237
+ | ----------- | -------------------------------------------------------------------------- |
238
+ | `doppler` | `--project a --config b`, key `c` (`secrets.doppler` is not consulted) |
239
+ | `1password` | vault `a`, item `b`, field `c` |
240
+ | `env` | the env var `c` of the process running Den (`secrets.keys` is not applied) |
241
+
242
+ A `compose.secret_env` key reaches a container only through the compose
243
+ file: `${OPENAI_API_KEY}` in it, or a pass-through `environment:
244
+ [OPENAI_API_KEY]`.
245
+
246
+ - **When.** `up` and `restart`, including `restart <repo>/<service>` of one
247
+ compose service (`stack up`, `stack restart`, `stack restart --service`),
248
+ from the CLI, the web UI and MCP. Every key is resolved before anything
249
+ stops or starts.
250
+ - **Missing.** A key the provider cannot resolve (unset, empty, no access)
251
+ fails that target, exit 1, naming the key, the ref and the provider. The
252
+ rest of the set is not used: nothing starts with a partial set, and a
253
+ restart leaves the old instance running.
254
+ - **Where values go.** Only into the spawned process's env (the docker
255
+ compose process, or the service's command). Never into the generated
256
+ compose override, logs, activity records, MCP output, dry runs or
257
+ messages. A CLI-tabs (`cli_tabs`) start of a service with `secret_env`
258
+ runs as a daemon, since a tab would put them in its shell history.
259
+ - **Dry run.** `-n`/`--dry-run` never asks the provider. It lists the key
260
+ names it would inject: `shop: would inject secret_env compose:
261
+ OPENAI_API_KEY (names only; not resolved in a dry run)`.
262
+ - **Validation.** `<cli> validate` rejects a ref that is not exactly three
263
+ non-empty parts and a key that is not an env var name
264
+ (`[A-Za-z_][A-Za-z0-9_]*`). A `stacks:` entry in the org config takes
265
+ `secret_env` too.
266
+
216
267
  ## den.yml: the product profile
217
268
 
218
269
  Every runtime name derives from `product.name`. Set only what you want to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -72,6 +72,17 @@ async function main(): Promise<number> {
72
72
  const r = await provider.preflight();
73
73
  if (r.ok) return 0;
74
74
  if (gate && !cfg.required) {
75
+ // Signed in but no default scope: secret_env refs name their own
76
+ // (<project>/<config>/<key>), so only the engine's own secrets lack one.
77
+ if (await provider.isReady()) {
78
+ console.error(
79
+ chalk.gray(
80
+ ` ${label}: signed in, no default scope set (secret_env refs still resolve;` +
81
+ ` test logins and the saved sudo password need one: \`make ${product().cli}-setup-secrets\`).`,
82
+ ),
83
+ );
84
+ return 0;
85
+ }
75
86
  console.error(
76
87
  chalk.yellow(
77
88
  `⚠️ ${label} is not ready — continuing.\n` +
@@ -65,7 +65,9 @@ data = {
65
65
  "RunAtLoad": True,
66
66
  "KeepAlive": True,
67
67
  "ThrottleInterval": 10,
68
- "ProcessType": "Background",
68
+ # Interactive, not Background: macOS gives Background agents efficiency
69
+ # cores and throttled I/O, and the UI stalled for seconds under load.
70
+ "ProcessType": "Interactive",
69
71
  "StandardOutPath": f"{logs}/web.log",
70
72
  "StandardErrorPath": f"{logs}/web.log",
71
73
  }
@@ -115,7 +117,7 @@ case "${1:-install}" in
115
117
  status)
116
118
  # launchctl's own "Could not find service" is noise when it isn't loaded.
117
119
  if out="$(launchctl print "$DOMAIN/$LABEL" 2>/dev/null)"; then
118
- printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs)'
120
+ printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs|spawn type)'
119
121
  else
120
122
  echo "$LABEL is not loaded"
121
123
  fi
@@ -31,6 +31,7 @@ import * as yaml from "yaml";
31
31
  import { brandSlug, defaultBrand } from "../brand.js";
32
32
  import { DEFAULT_PORT } from "../constants.js";
33
33
  import { getDistRoot } from "../paths.js";
34
+ import { cachedRead } from "../read-cache.js";
34
35
  import { readManifestStacks } from "../stacks/config.js";
35
36
  import { getActiveWorkspaces } from "../environment-manager/workspaces.js";
36
37
  import { assertDomain } from "./render.js";
@@ -154,6 +155,21 @@ function manifestStackNames(): string[] {
154
155
  }
155
156
 
156
157
  export function readEdgeConfig(root: string = getDistRoot()): EdgeConfig {
158
+ // The web server reads this on every request (open links, health rows);
159
+ // reused for the read cache's TTL there, read fresh everywhere else. An
160
+ // error is cached as such: most instances have no edge block at all.
161
+ const r = cachedRead(`edge-config:${root}`, () => {
162
+ try {
163
+ return { config: computeEdgeConfig(root) };
164
+ } catch (e) {
165
+ return { error: (e as Error).message };
166
+ }
167
+ });
168
+ if ("error" in r) throw new Error(r.error);
169
+ return r.config;
170
+ }
171
+
172
+ function computeEdgeConfig(root: string): EdgeConfig {
157
173
  const { team: t, user: u } = layers(root);
158
174
  const team = (t.edge ?? {}) as RawEdge;
159
175
  const user = (u.edge ?? {}) as RawEdge;
@@ -175,6 +175,36 @@ export function __setExtraCaForTests(pems: string[] | null): void {
175
175
  _extraCa = pems;
176
176
  }
177
177
 
178
+ /**
179
+ * One TLS context with the public roots plus extraCa(), built once. Passing
180
+ * `ca` per request made Node parse ~150 root certificates on every HTTPS
181
+ * health check, synchronously: tens of ms per check, per poll.
182
+ */
183
+ /** Build the HTTPS checks' trust context now (web server startup). */
184
+ export function prewarmHealthTls(): void {
185
+ trustContext();
186
+ }
187
+
188
+ let _trust: {
189
+ ca: string[];
190
+ context: tls.SecureContext;
191
+ agent: https.Agent;
192
+ } | null = null;
193
+ function trustContext(): { context: tls.SecureContext; agent: https.Agent } {
194
+ const extra = extraCa();
195
+ if (_trust?.ca !== extra) {
196
+ _trust?.agent.destroy();
197
+ _trust = {
198
+ ca: extra,
199
+ context: tls.createSecureContext({
200
+ ca: [...tls.rootCertificates, ...extra],
201
+ }),
202
+ agent: new https.Agent({ keepAlive: false }),
203
+ };
204
+ }
205
+ return _trust;
206
+ }
207
+
178
208
  /**
179
209
  * HTTPS check. Connects to the c-ares-resolved IP (see above) but keeps the
180
210
  * hostname for SNI and certificate verification; rewriting the URL to the IP,
@@ -201,7 +231,12 @@ async function pingHttps(parsed: URL, timeoutMs: number): Promise<PingResult> {
201
231
  {
202
232
  method: "GET",
203
233
  servername: isIpLiteral(host) ? undefined : host,
204
- ca: [...tls.rootCertificates, ...extraCa()],
234
+ // tls.connect's option, passed through by the agent (not in the
235
+ // https.RequestOptions type).
236
+ ...({ secureContext: trustContext().context } as https.RequestOptions),
237
+ // Its own agent: a socket kept alive under one trust set must never
238
+ // be reused for a check under another.
239
+ agent: trustContext().agent,
205
240
  lookup: (_h, opts, cb) => {
206
241
  // Node may ask for all addresses (autoSelectFamily).
207
242
  if ((opts as { all?: boolean }).all)
@@ -10,7 +10,7 @@
10
10
  * repo's dev/up op, captured at spawn time and/or parsed on demand.
11
11
  */
12
12
 
13
- import { execFileSync, execSync } from "child_process";
13
+ import { execFile, execFileSync, execSync } from "child_process";
14
14
  import * as fs from "fs";
15
15
  import * as path from "path";
16
16
  import { parse as parseYaml } from "yaml";
@@ -333,3 +333,85 @@ export function diffSpawnedContainers(
333
333
  for (const c of after) if (!had.has(c.base)) fresh.add(c.base);
334
334
  return [...fresh];
335
335
  }
336
+
337
+ // ── Async variants (the web server) ──────────────────────────────────────────
338
+ // The sync calls above block the event loop for the whole `docker ps` /
339
+ // `opctl container ls` (hundreds of ms on a loaded machine). The server uses
340
+ // these instead; concurrent callers share one in-flight call.
341
+
342
+ function execOut(cmd: string, args: string[]): Promise<string | null> {
343
+ return new Promise((resolve) => {
344
+ execFile(
345
+ cmd,
346
+ args,
347
+ { encoding: "utf-8", timeout: 5_000, maxBuffer: 16 * 1024 * 1024 },
348
+ (err, stdout) => resolve(err ? null : stdout),
349
+ );
350
+ });
351
+ }
352
+
353
+ function shared<T>(run: () => Promise<T>): () => Promise<T> {
354
+ let inFlight: Promise<T> | null = null;
355
+ return () =>
356
+ (inFlight ??= run().finally(() => {
357
+ inFlight = null;
358
+ }));
359
+ }
360
+
361
+ /** Whether `opctl` is on PATH, without spawning a shell. Cached. */
362
+ export async function isOpctlAvailableAsync(): Promise<boolean> {
363
+ if (opctlAvailableCache !== null) return opctlAvailableCache;
364
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
365
+ if (!dir) continue;
366
+ try {
367
+ await fs.promises.access(path.join(dir, "opctl"), fs.constants.X_OK);
368
+ return (opctlAvailableCache = true);
369
+ } catch {
370
+ /* next */
371
+ }
372
+ }
373
+ return (opctlAvailableCache = false);
374
+ }
375
+
376
+ /** runningDockerNames, async; null when docker is unavailable. */
377
+ export const runningDockerNamesAsync = shared(
378
+ async (): Promise<string[] | null> => {
379
+ const out = await execOut("docker", ["ps", "--format", "{{.Names}}"]);
380
+ return out === null
381
+ ? null
382
+ : out
383
+ .split("\n")
384
+ .map((l) => l.trim())
385
+ .filter(Boolean);
386
+ },
387
+ );
388
+
389
+ /** listRunningOpctlContainers, async. */
390
+ export const listRunningOpctlContainersAsync = shared(
391
+ async (): Promise<{ name: string; base: string }[]> => {
392
+ const byBase = new Map<string, { name: string; base: string }>();
393
+ const [opctlOut, dockerNames] = await Promise.all([
394
+ isOpctlAvailableAsync().then((ok) =>
395
+ ok ? execOut("opctl", ["container", "ls"]) : null,
396
+ ),
397
+ runningDockerNamesAsync(),
398
+ ]);
399
+ if (opctlOut !== null) {
400
+ const lines = opctlOut.split("\n");
401
+ const firstToken = lines[0]?.trim().split(/\s+/)[0];
402
+ const body =
403
+ firstToken === "NAME" || firstToken === "ID" ? lines.slice(1) : lines;
404
+ for (const line of body) {
405
+ const name = line.trim().split(/\s+/)[0];
406
+ if (!name) continue;
407
+ const base = name.replace(/-[0-9a-f]{8}$/i, "");
408
+ byBase.set(base, { name, base });
409
+ }
410
+ }
411
+ for (const name of dockerNames ?? []) {
412
+ const m = name.match(DOCKER_OPCTL_NAME);
413
+ if (m && !byBase.has(m[1])) byBase.set(m[1], { name, base: m[1] });
414
+ }
415
+ return [...byBase.values()];
416
+ },
417
+ );