@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.
- package/CHANGELOG.md +208 -0
- package/docs/distributions.md +64 -16
- package/package.json +1 -1
- package/service/install.sh +4 -2
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +130 -12
- package/src/shared/edge/config.ts +16 -0
- package/src/shared/engine.ts +18 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -87
- package/src/shared/environment-manager/health.ts +229 -30
- package/src/shared/environment-manager/opctl.ts +83 -1
- package/src/shared/environment-manager/service-runner.ts +49 -4
- package/src/shared/git/origin-url.ts +77 -0
- package/src/shared/manifest/read-manifest.ts +39 -15
- package/src/shared/paths.ts +42 -0
- package/src/shared/product.ts +7 -1
- package/src/shared/profile.ts +8 -0
- package/src/shared/read-cache.ts +77 -0
- package/src/shared/stacks/compose-health.ts +150 -0
- package/src/shared/stacks/runtime.ts +71 -24
- package/src/shared/stacks/status.ts +112 -3
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +181 -31
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +25 -7
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +174 -197
- package/src/ui/server/public/changelog-view.js +16 -0
- package/src/ui/server/public/header.js +121 -12
- package/src/ui/server/services-list.ts +210 -0
- package/src/ui/server/single-flight.ts +53 -0
- package/src/ui/server/ui-assets.ts +1 -0
- 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
|
package/docs/distributions.md
CHANGED
|
@@ -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
package/service/install.sh
CHANGED
|
@@ -65,7 +65,9 @@ data = {
|
|
|
65
65
|
"RunAtLoad": True,
|
|
66
66
|
"KeepAlive": True,
|
|
67
67
|
"ThrottleInterval": 10,
|
|
68
|
-
|
|
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
|
package/src/shared/constants.ts
CHANGED
|
@@ -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
|
|
package/src/shared/den-config.ts
CHANGED
|
@@ -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;
|
package/src/shared/engine.ts
CHANGED
|
@@ -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");
|