@monster-orange/den 0.2.3 → 0.2.5

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 (35) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/docs/distributions.md +64 -16
  3. package/package.json +1 -1
  4. package/service/install.sh +4 -2
  5. package/src/shared/constants.ts +7 -0
  6. package/src/shared/den-config.ts +130 -12
  7. package/src/shared/edge/config.ts +16 -0
  8. package/src/shared/engine.ts +18 -0
  9. package/src/shared/environment-manager/default-workspace.ts +182 -87
  10. package/src/shared/environment-manager/health.ts +229 -30
  11. package/src/shared/environment-manager/opctl.ts +83 -1
  12. package/src/shared/environment-manager/service-runner.ts +49 -4
  13. package/src/shared/git/origin-url.ts +77 -0
  14. package/src/shared/manifest/read-manifest.ts +39 -15
  15. package/src/shared/paths.ts +42 -0
  16. package/src/shared/product.ts +7 -1
  17. package/src/shared/profile.ts +8 -0
  18. package/src/shared/read-cache.ts +77 -0
  19. package/src/shared/stacks/compose-health.ts +150 -0
  20. package/src/shared/stacks/runtime.ts +71 -24
  21. package/src/shared/stacks/status.ts +112 -3
  22. package/src/ui/cli/health-display.ts +1 -0
  23. package/src/ui/cli/index.ts +181 -31
  24. package/src/ui/cli/mcp-connect.ts +77 -15
  25. package/src/ui/cli/stack-command.ts +40 -12
  26. package/src/ui/cli/web-command.ts +25 -7
  27. package/src/ui/cli/workspace-notes.ts +43 -0
  28. package/src/ui/server/graph-order.ts +22 -0
  29. package/src/ui/server/index.ts +174 -197
  30. package/src/ui/server/public/changelog-view.js +16 -0
  31. package/src/ui/server/public/header.js +121 -12
  32. package/src/ui/server/services-list.ts +210 -0
  33. package/src/ui/server/single-flight.ts +53 -0
  34. package/src/ui/server/ui-assets.ts +1 -0
  35. package/src/ui/server/workspace-routes.ts +247 -181
package/CHANGELOG.md CHANGED
@@ -4,6 +4,214 @@ 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.5] - 2026-11-04
8
+
9
+ ### Fixed
10
+
11
+ - **`<cli> web down` (any SIGTERM) shuts the web server down cleanly
12
+ under load.** `<cli> web up` ran the server under the `tsx` CLI, a
13
+ parent process that relays a signal to the server and, if the server has
14
+ not acknowledged it within about 60ms, SIGKILLs it and exits 143. On a
15
+ loaded machine the server missed that window, so it was killed without
16
+ its shutdown. The server now runs as `node --import <tsx loader>`, one
17
+ process that gets the signal itself, as the launchd service already did.
18
+ - **Compose-stack health no longer starts a CLI per check.** Den's own
19
+ check for a compose repo was a `<cli> stack health <stack> --service X`
20
+ process per manifest service, each a full CLI start running its own
21
+ `docker ps`: 17-19 processes per health cycle for three stacks. On a
22
+ loaded machine (load 35-55) they ran past their budget, and two such
23
+ cycles in a row read every stack down. These checks are now answered
24
+ in the web server and in `<cli> status` from one `docker ps` per cycle,
25
+ shared by every check in it (17 checks: 17 CLI starts and 17 docker
26
+ calls per cycle before, 0 and 1 now). The verdict is the one `stack
27
+ health` gives. A distribution's own shell checks still run as shell
28
+ commands, at most max(2, cpus/2) at a time; a queued check's budget
29
+ starts when it starts.
30
+ - **The health row no longer shows healthy repos down on a cold poll.** A
31
+ check that got no answer within its budget was sent as a finished
32
+ `ok: false`: a compose stack's check (`<cli> stack health`, a full CLI
33
+ start per service, 15-20 of them at once) past its 5s budget on a loaded
34
+ machine, or an HTTP check past its 3s timeout. The row painted it red
35
+ and the 8s health cache replayed that to the next page load, until the
36
+ next poll corrected it. Now a check that has not answered is **pending**
37
+ (`ok: null`, `pending: true`, the reason in `error`), never down. Only a
38
+ check that completed and failed is `ok: false`. A timeout is down only
39
+ when it repeats on the next cycle (`timeout (twice in a row)`), so a hung
40
+ service still shows. `<cli> status` agrees: on a timeout it probes once
41
+ more, and is down only when both probes time out. Health results have a
42
+ new `pending` state (`health: "pending"`), which readiness waits on
43
+ rather than skipping.
44
+ - **"Docker state unknown" is not "not running".** When docker does not
45
+ answer, the health row and `<cli> status` report a compose stack as
46
+ unknown (`ok: null`), not down, and so does `/api/stacks`. `<cli> stack
47
+ health` exits 3 when it could not tell (docker did not answer, the stack
48
+ config could not be read), 1 only when a stack is not healthy.
49
+ - **A health stream and a poll at once run each check once.** A stream
50
+ started its own full run next to an in-flight `/api/health` (or another
51
+ stream), doubling the shell checks' processes. It now joins the run in
52
+ flight, replaying what has landed and then following it.
53
+ - The web header's dot for a multi-service repo without an entrypoint
54
+ turned red when none of its checks were up yet, even with none down. It
55
+ is red now only when a check completed and failed.
56
+
57
+ - **The default workspace is seeded only on a genuine fresh setup.** Any
58
+ command that reads workspaces (`status`, `up`, `graph`, `logs`, …) seeded
59
+ `default` from `defaults.workspace.repos` whenever `default` was empty,
60
+ so moving every repo into per-project workspaces and deactivating
61
+ `default` got it refilled with managed entries on the next command, ready
62
+ to clone duplicates of repos you work on locally. Seeding now requires
63
+ that no workspace but `default` exists and that `default` was never
64
+ seeded (no `seeded_repos`). Declining the offer records that too, so it
65
+ is no longer asked again.
66
+ - **No silent seeding without a TTY.** On a fresh setup a command without
67
+ a TTY now prints a one-line hint to run `<cli> workspace seed` and
68
+ changes nothing; on a TTY the offer stays. New: `<cli>
69
+ workspace seed` seeds explicitly (as `<cli> init` in the root does).
70
+ - **Top-up no longer re-adds repos you moved or removed.** Top-up (adding
71
+ repos that joined the blueprint after seeding) never runs once you have
72
+ other workspaces, and skips a repo any workspace holds. Removing a
73
+ blueprint repo from `default` in the web UI records it, so a `default`
74
+ from before `seeded_repos` no longer gets it back on its one-time
75
+ migration.
76
+
77
+ ### Added
78
+
79
+ - **The "What's new" pop-up shows the newest release only** (from
80
+ 0.3.0-dev.16). A "Show all versions (N earlier)" button in its footer
81
+ lists every earlier version in the same pop-up, and "Show latest only"
82
+ collapses it again. It always opens collapsed, at the top: the scroll is
83
+ reset once the pop-up is shown (a reset made while it was hidden did not
84
+ stick, so a reopened pop-up kept its old scroll position). It is an
85
+ accessible modal dialog (`role="dialog"`, `aria-modal`, labelled by its
86
+ title). Opening moves focus to its heading, so a screen reader starts at
87
+ the latest notes. Tab stays inside, and closing returns focus to what had
88
+ it, which is the version pill after the first-visit auto-open. Each
89
+ version is a heading (h4) for heading navigation, and the icon buttons
90
+ have spoken names ("Close", "Copy v0.2.5 as Markdown").
91
+ - **The version pill names the running build** (`v0.2.5-dev.2`), not the
92
+ newest changelog heading, so a prerelease under test says what it is.
93
+ - **Multi-workspace blueprint: `defaults.workspaces`.** `{ <name>: {
94
+ repos, branch?, autostart_off?, focus? } }` in the org config. A fresh
95
+ setup creates each workspace with its repos (managed), activates them
96
+ all and focuses the one with `focus: true` (else the first). Setting
97
+ both `defaults.workspace` and `defaults.workspaces` is an error, as is a
98
+ malformed entry. See docs/distributions.md, "Workspace blueprint".
99
+
100
+ ### Migrations
101
+
102
+ - None required.
103
+
104
+ ## [0.2.4] - 2026-11-04
105
+
106
+ ### Fixed
107
+
108
+ - **`GET /api/services` no longer blocks the web server.** It looked up
109
+ each workspace checkout's branch with a synchronous `git rev-parse`, one
110
+ after another (~40ms each), so with a dozen repos every request held the
111
+ event loop for 0.5-1.5s and everything else waited behind it: the UI,
112
+ the MCP server's reads, and `/api/server-info`, which `<cli> mcp connect`
113
+ probes with a 1.5s timeout. The lookups now run as async child processes
114
+ in parallel; concurrent requests (one per open tab) share one build, and
115
+ a settled build is reused for 2s (`?refresh=1` forces a new one; any
116
+ mutating request clears it). Concurrent `/api/health` polls also share
117
+ one run instead of each starting its own.
118
+ - **`<cli> mcp connect` / `mcp clients` tell a slow web server from a
119
+ stopped one.** A server that accepts the connection but does not answer
120
+ within 1.5s is reported as "not answering within 1.5s", not "not
121
+ running". When the org config sets `web.service` (the web UI runs as a
122
+ launchd service), the advice is `<cli> service status` instead of
123
+ `<cli> web up`.
124
+ - **Web server shutdown no longer leaks an unhandled rejection, or hangs.**
125
+ On SIGTERM/SIGINT the server reads the running-services registry, whose
126
+ path resolves the distribution root again; if that walk fails (the dist's
127
+ den.yml moved or removed while the server ran), the async shutdown
128
+ rejected unhandled ("unhandled rejection (continuing): Error: cannot
129
+ locate the distribution root: walked up from
130
+ <dist>/node_modules/@monster-orange/den/src/ui/server") and, its signal
131
+ guard already set, the process never exited, so a service manager had to
132
+ wait out its timeout and kill it. The shutdown now logs the failure and
133
+ exits.
134
+ - **Deactivating a workspace no longer stops a service that moved to
135
+ another workspace.** A daemon's owners were the workspaces recorded when
136
+ it started, so a repo moved from `default` into `ws-b` while its daemon
137
+ ran was still "owned" only by `default`, and `workspace deactivate
138
+ default` stopped it. Release now re-derives the owners from the current
139
+ workspaces.yml membership: a daemon is stopped only when no active
140
+ workspace still lists its repo, and otherwise its recorded owners become
141
+ the active workspaces that do.
142
+ - **`<cli> workspace --help` lists every subcommand**: activate,
143
+ deactivate and focus were missing (and `workspace focus <name>` now
144
+ exists in the CLI, as it did in the web UI). Deactivate's help and output
145
+ say that it releases only the services Den tracks: compose stacks of the
146
+ workspace's repos keep running, and the output names the running ones
147
+ with `<cli> down <repo>` for each (from one `docker ps`; a generic line
148
+ when docker can't be asked).
149
+ - **The first `/api/health/stream` (and `/api/health`) cycle no longer
150
+ blocks the web server for a second.** Resolving the manifest of a repo
151
+ without its own (a held manifest under `manifests/`) looked up the
152
+ checkout's origin with a synchronous `git config --get
153
+ remote.origin.url`, one repo after another (~40-100ms each), so a
154
+ server's first health run held the event loop for 1-1.5s. The origin is
155
+ now read from the checkout's git config file (worktrees and
156
+ subdirectories included), never by spawning git. Parsed manifests are
157
+ also reused while the file is unchanged: one request read the same
158
+ manifests several times, and parsing them held the loop ~100-200ms per
159
+ health run.
160
+ - **`GET /api/workspaces/available-repos` no longer blocks the web
161
+ server.** It ran `git remote get-url origin` (and, for managed clones,
162
+ `git rev-parse`) synchronously per checkout; the lookups are now async
163
+ and in parallel, and concurrent requests share one walk (reused for 2s;
164
+ `?refresh=1` forces a new one, any mutating request clears it).
165
+ - **`workspace deactivate` names the compose stacks a repo runs itself.**
166
+ It only knew Den stacks (a manifest's `compose:` section, project
167
+ `<stack>-<workspace>`), so a distribution whose repos start their own
168
+ compose projects (`make up`) got no line at all. A running project now
169
+ counts when Compose started it from inside one of the workspace's
170
+ checkouts (its `com.docker.compose.project.working_dir` label), as well
171
+ as when it is a Den stack's project.
172
+ - **`<cli> workspace <sub> --help`** prints that subcommand's help, not the
173
+ whole `workspace` help.
174
+ - **No synchronous directory walks or spawns on the web server's hot
175
+ request paths** (`/api/workspaces/available-repos`, the `/api/health`
176
+ stream). On a loaded machine (load average ~50) the dev.1 fixes still
177
+ left stalls of 1-5s; a CPU profile showed why:
178
+ - available-repos recursed into every directory without a manifest file
179
+ of its own, so in a distribution whose repos use held manifests it
180
+ walked each repo's whole tree (to depth 4) with `readdirSync` /
181
+ `existsSync`: seconds per call. It now never descends into a git
182
+ checkout or `node_modules`, and the walk is `fs.promises`.
183
+ - The distribution root (and so `product()`) was re-resolved on every
184
+ call, a den.yml `existsSync` per `*_ROOT` variable each time, hundreds
185
+ of times per request. It is now resolved once per set of inputs (the
186
+ env vars and entry script it reads); the engine root likewise.
187
+ - Manifests, the edge config and the derived graph order are reused
188
+ across a request and the polls after it (5s; any mutating request
189
+ clears them), and the graph order is recomputed after a request, never
190
+ inside one.
191
+ - HTTPS health checks built a TLS context from ~150 root certificates on
192
+ every check; one context is built once, at startup.
193
+ - The startup sweep of stale daemon records ran `docker ps` / `opctl
194
+ container ls` synchronously; it is async now (concurrent callers share
195
+ one call), and `/api/service/daemons` waits for it.
196
+ A new test runs the server with a spy on every synchronous fs and
197
+ child_process call and fails if either route makes a sync spawn or
198
+ directory walk, or any sync call outside a short allowlist of single
199
+ config-file reads.
200
+ - **The web UI service no longer runs at background priority.**
201
+ `service install` wrote `ProcessType: Background` into the LaunchAgent,
202
+ so macOS ran the UI server on efficiency cores with throttled I/O: on a
203
+ busy machine its first requests stalled for 5-11s. It is now
204
+ `Interactive`, and `service status` shows launchd's spawn type.
205
+ - **`workspace deactivate` never suggests stopping a stack another active
206
+ workspace still holds.** Such a stack is listed as "in use by another
207
+ workspace: <project> (still in <ws>)" instead of with a `down <repo>`
208
+ command, and a repo with a stack still in use gets no `down` suggestion.
209
+
210
+ ### Migrations
211
+
212
+ - Re-run `<cli> service install` to pick up the new LaunchAgent priority
213
+ (`<cli> service status` should show `spawn type = interactive`).
214
+
7
215
  ## [0.2.3] - 2026-11-04
8
216
 
9
217
  ### Added
@@ -327,6 +327,54 @@ brand:
327
327
  Runtime names (env vars, files, directories) always come from `den.yml`,
328
328
  never from the brand.
329
329
 
330
+ ## Workspace blueprint
331
+
332
+ The workspaces a new machine starts with. Read from `defaults:` in the org
333
+ config. Every repo is seeded as a managed clone (`mode: managed`) on the
334
+ blueprint's branch; nothing is cloned until a command needs it. Switch a repo
335
+ to a local checkout afterwards with `acme use-local <repo>`.
336
+
337
+ One workspace, `default`:
338
+
339
+ ```yaml
340
+ defaults:
341
+ workspace:
342
+ branch: main # default: main
343
+ repos: [shop-web, shop, infra-stack]
344
+ autostart_off: [shop] # seeded with autostart: false
345
+ entry: shop-web # where the setup script's dependency walk starts
346
+ ```
347
+
348
+ Several named workspaces:
349
+
350
+ ```yaml
351
+ defaults:
352
+ workspaces:
353
+ storefront:
354
+ repos: [shop-web, shop]
355
+ data:
356
+ repos: [warehouse, infra-stack]
357
+ branch: develop
358
+ focus: true
359
+ ```
360
+
361
+ Each entry takes `repos`, `branch` and `autostart_off`, the same fields as
362
+ `defaults.workspace`, plus `focus`. Seeding creates every named workspace
363
+ with its repos, activates them all, and focuses the one with `focus: true`
364
+ (else the first). Set `defaults.workspace` or `defaults.workspaces`, not both:
365
+ both at once is an error, as is more than one `focus: true`.
366
+
367
+ The blueprint applies only to a genuine fresh setup: no workspace except an
368
+ empty `default` that was never seeded. On a TTY the first command that needs
369
+ workspaces offers to seed. Without a TTY it prints a one-line hint and changes
370
+ nothing. `acme workspace seed` (or `acme init` in the distribution root) seeds
371
+ explicitly, and resets the blueprint's workspaces if they already hold repos.
372
+
373
+ Once seeded, a repo that later joins `defaults.workspace.repos` is added to
374
+ `default`, but only while `default` is your only workspace, and never a repo
375
+ you removed from it or that another workspace holds. With
376
+ `defaults.workspaces` nothing is topped up.
377
+
330
378
  ## The bin shim
331
379
 
332
380
  `bin/<cli>` is three lines of sh: it names the distribution root and runs
@@ -544,22 +592,22 @@ guard off.
544
592
 
545
593
  Read tools (always registered):
546
594
 
547
- | Tool | Web API | What |
548
- | ------------------- | ------------------------------------------ | ---------------------------------------------------------- |
549
- | `status` | `/api/health` | Service health; `url` (HTTP check) or `check` (shell) |
550
- | `services` | `/api/services` | Services, their mode (managed or dev-managed) and commands |
551
- | `applications` | `/api/applications` | Org > Application > Repo > Services |
552
- | `graph` | `/api/graph` | The derived dependency graph and startup order |
553
- | `workspaces` | `/api/workspaces` | Workspaces, active and focused |
554
- | `environments` | `/api/environments` | The enabled environment names (never their values) |
555
- | `logs_search` | `/api/logs/search` | Search services' full log history |
556
- | `logs_tail` | `/api/service/taillog` | The last lines of a service's log (bounded, no follow) |
557
- | `tests_available` | `/api/modules`, `/api/test-sources` | A pillar's modules and the repos they come from |
558
- | `security_latest` | `/api/security/latest` | The newest security scan per mode |
559
- | `release_status` | `/api/release/runs`, `/api/release/status` | Recent CD runs, and an env's deployed-version table |
560
- | `manifest_validate` | `/api/manifest/validate` | Validate a manifest by path or content |
561
- | `stacks` | `/api/stacks` | What `stack ls/ps/health` show: project, routes, health |
562
- | `edge_status` | `/api/edge` | The edge: up or down, its routes and cert names |
595
+ | Tool | Web API | What |
596
+ | ------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
597
+ | `status` | `/api/health` | Service health; `url` (HTTP check) or `check` (shell); `ok` true up, false down, null not known (`pending: true` when a check has not answered) |
598
+ | `services` | `/api/services` | Services, their mode (managed or dev-managed) and commands |
599
+ | `applications` | `/api/applications` | Org > Application > Repo > Services |
600
+ | `graph` | `/api/graph` | The derived dependency graph and startup order |
601
+ | `workspaces` | `/api/workspaces` | Workspaces, active and focused |
602
+ | `environments` | `/api/environments` | The enabled environment names (never their values) |
603
+ | `logs_search` | `/api/logs/search` | Search services' full log history |
604
+ | `logs_tail` | `/api/service/taillog` | The last lines of a service's log (bounded, no follow) |
605
+ | `tests_available` | `/api/modules`, `/api/test-sources` | A pillar's modules and the repos they come from |
606
+ | `security_latest` | `/api/security/latest` | The newest security scan per mode |
607
+ | `release_status` | `/api/release/runs`, `/api/release/status` | Recent CD runs, and an env's deployed-version table |
608
+ | `manifest_validate` | `/api/manifest/validate` | Validate a manifest by path or content |
609
+ | `stacks` | `/api/stacks` | What `stack ls/ps/health` show: project, routes, health |
610
+ | `edge_status` | `/api/edge` | The edge: up or down, its routes and cert names |
563
611
 
564
612
  Action tools (only those named in `mcp.actions`): `service_up`,
565
613
  `service_down`, `service_restart` (through `/api/service/exec`),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
@@ -48,6 +48,13 @@ export const HOSTED_HEALTH_CHECK_TIMEOUT_MS = 10_000;
48
48
  /** Timeout for spawning short-lived CLI subprocesses (open, sudo, git). */
49
49
  export const CLI_SPAWN_TIMEOUT_MS = 5_000;
50
50
 
51
+ /**
52
+ * Exit code of `<cli> stack health` when it could not tell (docker did not
53
+ * answer, the stack config could not be read): the check is unknown, not
54
+ * down. Any other non-zero exit means the containers are not healthy.
55
+ */
56
+ export const STACK_HEALTH_UNKNOWN_EXIT = 3;
57
+
51
58
  /** Timeout for `gh api` calls (branch listing, repo search). */
52
59
  export const GH_API_TIMEOUT_MS = 15_000;
53
60
 
@@ -255,24 +255,35 @@ export interface DenConfig {
255
255
  * `default-workspace.ts`.
256
256
  */
257
257
  defaults?: {
258
- workspace?: {
259
- /** Branch managed clones track (default "main"). */
260
- branch?: string;
261
- /** Repo-system names to seed, all as managed clones. */
262
- repos?: string[];
258
+ workspace?: WorkspaceBlueprint & {
263
259
  /** The entry repo the seed's dependency walk starts from (listed first). */
264
260
  entry?: string;
265
- /**
266
- * Seeded repos that get `autostart: false` stamped on their workspace
267
- * entry — the team default for systems a fresh clone should not start
268
- * with workspace up (e.g. billing-api's `make up` opens an interactive
269
- * Stripe tab). Personal thereafter: toggle per system in the web ⋮ menu.
270
- */
271
- autostart_off?: string[];
272
261
  };
262
+ /**
263
+ * Multi-workspace blueprint: `{ <name>: { repos, focus?, branch?,
264
+ * autostart_off? } }`. On a genuine fresh setup each workspace is created
265
+ * with its repos (managed), all are activated, and the one marked
266
+ * `focus: true` (else the first) is focused. Exclusive with `workspace`.
267
+ */
268
+ workspaces?: Record<string, WorkspaceBlueprint & { focus?: boolean }>;
273
269
  };
274
270
  }
275
271
 
272
+ /** One workspace's blueprint: the repo entries a fresh setup seeds it with. */
273
+ export interface WorkspaceBlueprint {
274
+ /** Branch managed clones track (default "main"). */
275
+ branch?: string;
276
+ /** Repo-system names to seed, all as managed clones. */
277
+ repos?: string[];
278
+ /**
279
+ * Seeded repos that get `autostart: false` stamped on their workspace
280
+ * entry — the team default for systems a fresh clone should not start
281
+ * with workspace up (e.g. billing-api's `make up` opens an interactive
282
+ * Stripe tab). Personal thereafter: toggle per system in the web ⋮ menu.
283
+ */
284
+ autostart_off?: string[];
285
+ }
286
+
276
287
  /**
277
288
  * Per-engineer preferences stored in config/local/user.yml (gitignored).
278
289
  * Only affects settings that should differ between engineers on the same team.
@@ -618,6 +629,113 @@ export interface WebConfig {
618
629
  service?: { label?: string; logs?: string };
619
630
  }
620
631
 
632
+ /** A validated workspace blueprint with its defaults applied. */
633
+ export interface ResolvedWorkspaceBlueprint {
634
+ name: string;
635
+ repos: string[];
636
+ branch: string;
637
+ autostartOff: string[];
638
+ focus: boolean;
639
+ }
640
+
641
+ /** Which blueprint shape the distribution uses, and its workspaces. */
642
+ export interface WorkspaceBlueprints {
643
+ shape: "none" | "single" | "multi";
644
+ workspaces: ResolvedWorkspaceBlueprint[];
645
+ }
646
+
647
+ const BLUEPRINT_KEYS = ["branch", "repos", "autostart_off"];
648
+
649
+ function blueprintStrings(where: string, key: string, v: unknown): string[] {
650
+ if (v === undefined) return [];
651
+ if (!Array.isArray(v) || v.some((r) => typeof r !== "string" || !r.trim()))
652
+ throw new Error(`${where}.${key} must be a list of repo names`);
653
+ return v as string[];
654
+ }
655
+
656
+ function resolveBlueprint(
657
+ where: string,
658
+ name: string,
659
+ raw: unknown,
660
+ /** Extra keys allowed; null skips the unknown-key check (legacy shape). */
661
+ extraKeys: string[] | null,
662
+ ): ResolvedWorkspaceBlueprint {
663
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
664
+ throw new Error(`${where} must be a map: { repos: [...] }`);
665
+ const bp = raw as Record<string, unknown>;
666
+ const known = [...BLUEPRINT_KEYS, ...(extraKeys ?? [])];
667
+ for (const key of Object.keys(bp))
668
+ if (extraKeys && !known.includes(key))
669
+ throw new Error(
670
+ `${where}: unknown key "${key}" (known: ${known.join(", ")})`,
671
+ );
672
+ const branch = bp.branch ?? "main";
673
+ if (typeof branch !== "string" || !branch.trim())
674
+ throw new Error(`${where}.branch must be a branch name`);
675
+ if (bp.focus !== undefined && typeof bp.focus !== "boolean")
676
+ throw new Error(`${where}.focus must be true or false`);
677
+ return {
678
+ name,
679
+ repos: blueprintStrings(where, "repos", bp.repos),
680
+ branch,
681
+ autostartOff: blueprintStrings(where, "autostart_off", bp.autostart_off),
682
+ focus: bp.focus === true,
683
+ };
684
+ }
685
+
686
+ /**
687
+ * The distribution's workspace blueprint, validated: `defaults.workspace`
688
+ * (one workspace, `default`) or `defaults.workspaces` (named workspaces).
689
+ * Empty when neither is set. Both at once, or a malformed value, throws.
690
+ */
691
+ export function readWorkspaceBlueprints(
692
+ config: DenConfig = readDenConfig(),
693
+ ): WorkspaceBlueprints {
694
+ const none: WorkspaceBlueprints = { shape: "none", workspaces: [] };
695
+ const defaults: unknown = config.defaults;
696
+ if (defaults === undefined || defaults === null) return none;
697
+ if (typeof defaults !== "object" || Array.isArray(defaults))
698
+ throw new Error(`defaults in ${DEN_CONFIG_PATH} must be a map`);
699
+ const { workspace, workspaces } = defaults as Record<string, unknown>;
700
+ if (workspace != null && workspaces != null)
701
+ throw new Error(
702
+ `${DEN_CONFIG_PATH} sets both defaults.workspace and defaults.workspaces: ` +
703
+ "use defaults.workspaces alone (name the default workspace `default` in it), " +
704
+ "or defaults.workspace alone for a single default workspace",
705
+ );
706
+ if (workspaces != null) {
707
+ if (typeof workspaces !== "object" || Array.isArray(workspaces))
708
+ throw new Error(
709
+ "defaults.workspaces must be a map: { <name>: { repos: [...], focus?: true } }",
710
+ );
711
+ const resolved = Object.entries(workspaces as Record<string, unknown>).map(
712
+ ([name, raw]) => {
713
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
714
+ throw new Error(
715
+ `defaults.workspaces: "${name}" is not a valid workspace name (letters, digits, dots, dashes, underscores)`,
716
+ );
717
+ return resolveBlueprint(`defaults.workspaces.${name}`, name, raw, [
718
+ "focus",
719
+ ]);
720
+ },
721
+ );
722
+ const focused = resolved.filter((b) => b.focus);
723
+ if (focused.length > 1)
724
+ throw new Error(
725
+ `defaults.workspaces: only one workspace may set focus: true (got ${focused.map((b) => b.name).join(", ")})`,
726
+ );
727
+ return { shape: "multi", workspaces: resolved };
728
+ }
729
+ if (workspace != null)
730
+ return {
731
+ shape: "single",
732
+ workspaces: [
733
+ resolveBlueprint("defaults.workspace", "default", workspace, null),
734
+ ],
735
+ };
736
+ return none;
737
+ }
738
+
621
739
  /** `web.service` with its defaults applied. */
622
740
  export interface ResolvedWebService {
623
741
  /** launchd label; also the plist's name in ~/Library/LaunchAgents. */
@@ -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;
@@ -37,6 +37,24 @@ export function engineBin(name: string, engineRoot = getEngineRoot()): string {
37
37
  }
38
38
  }
39
39
 
40
+ /**
41
+ * A file inside a package Den depends on (e.g. "tsx/dist/loader.mjs"): the
42
+ * nearest node_modules/<rel> at or above the engine root, or null.
43
+ */
44
+ export function engineModuleFile(
45
+ rel: string,
46
+ engineRoot = getEngineRoot(),
47
+ ): string | null {
48
+ let dir = path.resolve(engineRoot);
49
+ for (;;) {
50
+ const candidate = path.join(dir, "node_modules", rel);
51
+ if (fs.existsSync(candidate)) return candidate;
52
+ const parent = path.dirname(dir);
53
+ if (parent === dir) return null;
54
+ dir = parent;
55
+ }
56
+ }
57
+
40
58
  /** The tsx binary that runs Den's TypeScript sources. */
41
59
  export function tsxBin(): string {
42
60
  return engineBin("tsx");