@monster-orange/den 0.2.5 → 0.2.7

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 (46) hide show
  1. package/CHANGELOG.md +215 -0
  2. package/bin/den +9 -0
  3. package/docs/distributions.md +202 -22
  4. package/package.json +1 -1
  5. package/service/install.sh +41 -2
  6. package/src/pillars/security/discover.ts +1 -1
  7. package/src/pillars/smoke/setup-global.ts +2 -1
  8. package/src/scripts/configure-aws-profiles.ts +2 -1
  9. package/src/scripts/register-repos.ts +4 -3
  10. package/src/shared/den-config.ts +38 -1
  11. package/src/shared/environment-manager/catalog.ts +9 -2
  12. package/src/shared/environment-manager/default-workspace.ts +92 -14
  13. package/src/shared/environment-manager/health.ts +96 -17
  14. package/src/shared/environment-manager/service-runner.ts +51 -5
  15. package/src/shared/environment-manager/workspace-manager.ts +2 -1
  16. package/src/shared/environment-manager/workspace-org.ts +264 -0
  17. package/src/shared/manifest/placement.ts +1 -1
  18. package/src/shared/manifest/schema.ts +47 -0
  19. package/src/shared/manifest/service-links.ts +1 -1
  20. package/src/shared/manifest/validate-manifest.ts +1 -1
  21. package/src/shared/plural.ts +13 -0
  22. package/src/shared/service-url.ts +2 -5
  23. package/src/shared/stacks/compose-health.ts +106 -14
  24. package/src/shared/stacks/runtime.ts +2 -0
  25. package/src/shared/types/index.ts +20 -1
  26. package/src/ui/cli/clone-plan.ts +243 -0
  27. package/src/ui/cli/create.ts +2 -1
  28. package/src/ui/cli/flag-parser.ts +1 -1
  29. package/src/ui/cli/index.ts +274 -61
  30. package/src/ui/cli/mcp-connect.ts +36 -5
  31. package/src/ui/cli/org-command.ts +162 -0
  32. package/src/ui/cli/results-viewer.ts +5 -5
  33. package/src/ui/cli/server-identity.ts +233 -0
  34. package/src/ui/cli/service-command.ts +136 -12
  35. package/src/ui/cli/spinner.ts +48 -0
  36. package/src/ui/cli/table.ts +20 -0
  37. package/src/ui/cli/validate-all.ts +171 -0
  38. package/src/ui/cli/web-command.ts +76 -25
  39. package/src/ui/mcp/api.ts +94 -1
  40. package/src/ui/mcp/server.ts +30 -2
  41. package/src/ui/mcp/tools.ts +6 -2
  42. package/src/ui/server/daemon-reaper.ts +22 -4
  43. package/src/ui/server/health-row.ts +6 -3
  44. package/src/ui/server/index.ts +63 -18
  45. package/src/ui/server/release-service.ts +16 -3
  46. package/src/ui/server/workspace-routes.ts +4 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,221 @@ 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.7] - 2026-11-05
8
+
9
+ ### Added
10
+
11
+ - **A workspace belongs to an org** (0.2.7-dev.2, ORG-1a). Its org is
12
+ `org:` on the workspace in workspaces.yml, else `org:` on the blueprint
13
+ workspace (`defaults.workspaces.<n>.org`, the one new blueprint key), else
14
+ the one catalog owner of its repos (case-insensitive; `gh: ""` ignored),
15
+ else `github_org`, the default org. It is computed on read, never written
16
+ to workspaces.yml. A config with no `org:` resolves to `github_org` as
17
+ before, unless all of a workspace's repos have one other owner in the
18
+ catalog.
19
+ - **`<cli> org list`** (0.2.7-dev.2, ORG-1a): each org's label, its
20
+ workspaces on this machine, which is the default, and the org warnings.
21
+ `<cli> workspace list` gains an Org column; `--org <id>` filters it.
22
+ - **Org warnings, never errors** (0.2.7-dev.2, ORG-1a, C1): a workspace
23
+ whose repos span several owners, an `org:` naming an owner Den does not
24
+ know, a repo in another owner than its workspace's org, a workspaces.yml
25
+ `org:` that overrides the blueprint's. Shown by `org list` and
26
+ `workspace list`.
27
+ - **`<cli> setup --plan`** (0.2.7-dev.2, ORG-1a): each repo's org and clone
28
+ URL, and whether it is cloned, then stop. On a fresh machine it plans the
29
+ blueprint's workspaces; once seeded, the focused workspace. `<cli> up
30
+ --plan` prints the same lines for its repos. Nothing is cloned, seeded or
31
+ written.
32
+ - **`GET /api/orgs` entries gain `default` and `workspaces`**, and
33
+ `/api/<cli>/config` gains `default_org` (0.2.7-dev.2, ORG-1a). Every
34
+ existing field, entry and order is unchanged; `?repos=1` still maps each
35
+ repo to its owner string.
36
+ - **`<cli> register --org <id>`** (0.2.7-dev.2, ORG-1a) lists another org's
37
+ repos for a managed clone.
38
+
39
+ - **Bare `<cli> validate` checks every manifest** (0.2.7-dev.1, from a
40
+ consumer). In the distribution root, with no argument: each workspace repo
41
+ once per checkout (own or held, plus any override, then the merge), the
42
+ held manifest when there is no checkout, and held manifests no workspace
43
+ uses. One line per repo, details on failure, exit 1 if any fails, nothing
44
+ cloned. `--held` checks `manifests/` alone, independent of the machine. A
45
+ path argument works as before.
46
+
47
+ ### Changed
48
+
49
+ - **A repo is released from its own org** (0.2.7-dev.2, REL-1). With no
50
+ `release.org`, a repo with no `gh:` in `release.repos` released from
51
+ `github_org`, so a client org's repo targeted the wrong owner. Its owner
52
+ is now an explicit `release.org` (unchanged: it still wins for every
53
+ repo), else the repo's non-empty catalog owner, else `github_org`.
54
+ - **`register` lists the focused workspace's org** (0.2.7-dev.2, ORG-1a),
55
+ not `github_org` alone, and no longer falls back to the owner of Den's own
56
+ git remote (SEC-0): with no org it says how to set one.
57
+
58
+ ### Fixed
59
+
60
+ - **A second root's server leaves the first root's services alone**
61
+ (0.2.7-dev.3, dev.4). Every root of a product shares one daemons file.
62
+ A server's two startup sweeps (the stale-daemon prune and the health
63
+ reaper) dropped any record whose address did not resolve in its own
64
+ workspaces, so starting a scratch root's server silently forgot another
65
+ root's running dev server; an interactive Ctrl-C stopped every service in
66
+ the file, and deactivating a workspace released any record with the same
67
+ workspace name. Records are now stamped with the root that started them
68
+ and a server lists, stops, releases and sweeps only its own (other roots'
69
+ records stay in the file untouched). An older unstamped record is never
70
+ dropped just for not resolving; it still goes when its containers are
71
+ verifiably gone or two health checks fail. The reaper now logs what it
72
+ removes.
73
+ - **A service the edge has no route for is no longer healthy** (0.2.7-dev.2).
74
+ A health URL served through the edge answered the edge's own
75
+ `404 page not found` when the service had no route, and Den counted any
76
+ 404 as up. That exact 404 is now down with "no route (edge 404)", over
77
+ HTTP and HTTPS; a 404 from the service itself is still up.
78
+ - **A client workspace no longer clones from the default org** (0.2.7-dev.2,
79
+ ORG-1a). Seeding, `up` and the web sync gave a repo with no clone URL
80
+ `<github_org>/<repo>`; it is now `<org>/<repo>` with its workspace's org.
81
+ - **`<cli> setup` clones a catalog entry with `gh: ""`** from its
82
+ workspace's org (0.2.7-dev.2), as `up` does, instead of failing on an
83
+ empty slug.
84
+ - **A seed without a terminal no longer resets a workspace that has
85
+ entries** (SEED-0). `<cli> init` and `<cli> workspace seed` from a script
86
+ or `make` replaced every entry of the default (or blueprint) workspaces with
87
+ managed clones, your own dev-managed checkouts included, without asking.
88
+ Without a TTY they now refuse (exit 1, nothing written) and name
89
+ `--reset`; on a TTY they still ask, default No. `init --reset` and
90
+ `workspace seed --reset` reset as before.
91
+ - **A failed seed leaves the workspaces as they were** (0.2.7-dev.1, from a
92
+ consumer). Seeding wrote workspaces.yml, then filled the catalog, which
93
+ fails with no `github_org`: the workspace was reset and nothing could be
94
+ cloned. The catalog comes first now, and the org is needed only for a repo
95
+ with no clone URL in the catalog.
96
+ - **A seed refusal names the command that was run** (`init --reset` or
97
+ `workspace seed --reset`).
98
+ - **A `DEN_ROOT` with no den.yml is a one-line error** (exit 2), not a stack
99
+ trace from deep in module loading.
100
+ - **Piped output carries no ANSI from tables** (`status`, `services`, test
101
+ results). cli-table3 coloured borders and head cells itself, ignoring
102
+ NO_COLOR and a pipe.
103
+ - **`<cli> mcp --call` validates its arguments** against the tool's schema,
104
+ strictly: an unknown or misspelt key, or a bad value, exits 2 with the
105
+ problem and the keys the tool takes, and calls nothing.
106
+ - **The web UI service survives a removed nvm version.** Install prefers a
107
+ stable node of the same major version over a version manager's (or uses
108
+ `DEN_SERVICE_NODE`), and warns when it must keep one. `service status`
109
+ exits 1, naming it, when the installed node no longer exists.
110
+
111
+ - **`<cli> init` in the distribution root seeds even through a symlinked
112
+ path** (`/var` → `/private/var`, a symlinked home). It compared paths
113
+ without resolving links and ran the manifest scaffolder instead.
114
+
115
+ ### Migrations
116
+
117
+ - A script that relied on a non-interactive seed resetting a changed
118
+ workspace must pass `--reset`.
119
+ - Optional (ORG-1a): to put a blueprint workspace in an org other than the
120
+ one its catalog owners give, add `org: <id>` to it under
121
+ `defaults.workspaces`. Pin Den 0.2.7 or later first: an older Den rejects
122
+ the key. Nothing else needs changing.
123
+
124
+ ## [0.2.6] - 2026-11-04
125
+
126
+ ### Fixed
127
+
128
+ - **`<cli> web restart` and `web down` act on our server from an older
129
+ Den.** A server from before 0.2.6 (no `root` in `/api/server-info`) run
130
+ from our root is ours, and 0.2.6-dev.0 classified it so, but stopping it
131
+ then looked for our pids by command line only: the entry point of this
132
+ engine, or one under the root. An older Den installed elsewhere, or a
133
+ hand-started server, matched neither, so `web restart` printed "OK ... web
134
+ is already running" and restarted nothing, and `web down` printed "OK ...
135
+ web is not running" while the server stayed up and bound. A server the
136
+ identity check finds ours is now stopped by the pid it reports, when that
137
+ pid listens on the port: `restart` replaces it and `down` frees the port,
138
+ with or without a `web.json` for it. web.json's root is compared after
139
+ resolving symlinks (`/var` and `/private/var` are one root).
140
+ - **The MCP server serves nothing from another distribution's server.**
141
+ It read from whatever answered on its port. Before a tool reads, it now
142
+ checks `/api/server-info` as `web up` does; on another distribution's
143
+ server every tool answers `port <N> is in use by <product> at <root>; not
144
+ this distribution's server` and returns none of its data. A positive
145
+ check is trusted for 30s and made again after a connection error, so a
146
+ server that took over the port is noticed. A server that does not say
147
+ whose it is within 5s is not read from either.
148
+ - **A mistyped `service` flag no longer installs.** `<cli> service install
149
+ --anything` ignored the flag and installed; a `service` subcommand now
150
+ refuses an argument it does not take, before running anything.
151
+ - **`<cli> web up` no longer trusts whatever answers on its port.** With
152
+ `web.port` set to a port where another distribution's Den server ran
153
+ (another root, an older Den), `web up` printed "OK ... web is already
154
+ running" and stopped, and `web down` or `web restart` could SIGTERM that
155
+ server: with a linked engine its command line looks like ours.
156
+ `/api/server-info` now also returns `root` (the distribution root the
157
+ server serves) and `product`. `web up`, `web down` and `web restart`
158
+ compare both with their own, and on another distribution's server exit 1
159
+ with `port <N> is in use by <product> at <root>` and a hint to pick
160
+ another port (`--port`, or `web.port`). A server from before 0.2.6 has
161
+ no `root`: it is ours when it runs from our root (its working
162
+ directory), otherwise `port <N> is in use by another server (Den
163
+ <version>)`; anything else is "a non-Den server". `web down` also no
164
+ longer signals a process running from another root, even when the
165
+ server does not answer. `<cli> mcp connect` refuses to point an agent at
166
+ another distribution's server (and `mcp clients` names it), and `<cli>
167
+ service install` no longer reports "running" when another
168
+ distribution's server answers on the port.
169
+ - **A repeat `<cli> workspace seed` says "already seeded".** Seeding a
170
+ workspace that already holds exactly the blueprint printed "Default
171
+ workspace ready" as if it had seeded again; it now prints `✓ Default
172
+ workspace already seeded — N repos (managed): nothing changed.` and
173
+ writes nothing (multi blueprint: `✓ N workspaces already seeded (a, b)`).
174
+ A seed over a workspace the user changed says it was "reset to the
175
+ blueprint".
176
+ - **Counts read "1 repo", not "1 repos".** CLI output that counts repos,
177
+ workspaces, systems, files, profiles and tools uses one plural helper
178
+ (`src/shared/plural.ts`), also in place of "repo(s)".
179
+ - **Spinners no longer loop forever on a 0-column terminal.** On a pty that
180
+ reports 0 columns (one nobody sized, as agent shells give), ora computed
181
+ an infinite number of lines to clear and never finished. A spinner now
182
+ animates only on a TTY with a known, non-zero width; otherwise it prints
183
+ plain lines (its text on start, then the result).
184
+
185
+ ### Added
186
+
187
+ - **Health type `container`.** `health: { type: container, project: <compose
188
+ project>, service: <compose service> }` checks a compose container by its
189
+ `com.docker.compose.project` / `com.docker.compose.service` labels, or
190
+ `{ type: container, container_name: <name> }` any container by name. It
191
+ is answered from the one `docker ps` per health cycle the compose checks
192
+ already share (its format gains `{{.Names}}`, and it no longer filters to
193
+ compose containers), so N container checks add no docker call, in the
194
+ web health row and in `<cli> status` (22 checks, 17 compose and 5
195
+ container: 1 docker call per cycle). A running container with a
196
+ healthcheck is its health (healthy up, unhealthy down, starting
197
+ pending), one without is up, and no matching container is down. When
198
+ docker gives no listing it says what a compose check says, through the
199
+ same code: a failed `docker ps` is unknown (`ok: null`, the reason in
200
+ `error`) on every cycle, never down; a listing past its budget is
201
+ pending (`pending: true`), down only when that repeats. Of several matches the best counts:
202
+ healthy, then running. `/api/health` rows show it as `check: "container
203
+ <project>/<service>"`. `<cli> validate` requires `project` + `service` or
204
+ `container_name` (not both, not one of the pair alone, no other type's
205
+ keys) and says which. See docs/distributions.md, "Health checks".
206
+ - **`<cli> service install --plan`.** Read-only: prints the label, the
207
+ plist path, the log file, the roots, whose server answers on `web.port`
208
+ (ours, free, or `port <N> is in use by <product> at <root>`) and the
209
+ plist install would write; writes nothing and never calls launchctl.
210
+ Exits 1 when the port is another distribution's. `<cli> service status`
211
+ also names another distribution's server on the port.
212
+
213
+ ### Migrations
214
+
215
+ - None required. Optional: a `type: shell` check that runs `docker ps` to
216
+ find a container (`test -n "$(docker ps -q --filter
217
+ label=com.docker.compose.project=X --filter
218
+ label=com.docker.compose.service=Y)"`) can become `{ type: container,
219
+ project: X, service: Y }`: no process or docker call of its own, and it
220
+ reads the container's healthcheck.
221
+
7
222
  ## [0.2.5] - 2026-11-04
8
223
 
9
224
  ### Fixed
package/bin/den CHANGED
@@ -49,6 +49,15 @@ if [ "${1:-}" = "mcp" ] && ! case "${2:-}" in connect|disconnect|clients|config)
49
49
  built="dist/src/ui/cli/mcp-main.js"
50
50
  fi
51
51
 
52
+ # A DEN_ROOT that names no distribution is a usage error, not a crash: say
53
+ # so here, before any module resolves paths from it at import time.
54
+ # (`create` needs no distribution.)
55
+ if [ -n "${DEN_ROOT:-}" ] && [ "$entry" != "src/ui/cli/create-main.ts" ] && [ ! -f "$DEN_ROOT/den.yml" ]; then
56
+ echo "den: DEN_ROOT is set to \"$DEN_ROOT\" but that directory has no den.yml." >&2
57
+ echo " Point it at your distribution's checkout, or unset it." >&2
58
+ exit 2
59
+ fi
60
+
52
61
  # Escape hatch: force the compiled build (e.g. to test the prod artifact).
53
62
  if [ "${DEN_USE_DIST:-}" = "1" ] && [ -f "$DEN_ENGINE_ROOT/$built" ]; then
54
63
  exec node "$DEN_ENGINE_ROOT/$built" "$@"
@@ -104,6 +104,23 @@ naming the file each error is in, then checks the merged result:
104
104
  ERROR services.api.commands.local.down unknown key "down": not in the base manifest
105
105
  ```
106
106
 
107
+ Run with no argument in the distribution root, `<cli> validate` checks every
108
+ manifest the distribution uses, and never clones:
109
+
110
+ - each repo of every workspace, once per checkout path, with the
111
+ workspaces that use it and where its base comes from (`own`, `held`,
112
+ `+ override`);
113
+ - a repo with no checkout on disk falls back to its held manifest, marked
114
+ `held only, no checkout`. With no held manifest either it is a warning, not
115
+ a failure;
116
+ - each held manifest in `manifests/` that no workspace uses.
117
+
118
+ It prints one line per repo, details only for a layer with errors or
119
+ warnings, and exits 1 if any fails. `<cli> validate --held` checks only
120
+ `manifests/`, as if nothing were checked out, so the result does not depend on
121
+ the machine (use it in a CI or upgrade gate). An override whose base is the
122
+ repo's own manifest is noted there, not checked.
123
+
107
124
  The web UI's Services cards show the layers each manifest was built from
108
125
  (`manifest: repo + override`), and `/api/services` returns them per service as
109
126
  `manifestLayers`, e.g. `["held", "override"]`. `DEN_MANIFESTS_DIR` points the
@@ -126,6 +143,83 @@ is never mistaken for another repo's service. An alias that names more than
126
143
  one service is an error that lists them (`"worker" is ambiguous in workspace
127
144
  "default": one/worker, two/worker`); name one of them.
128
145
 
146
+ ## Health checks
147
+
148
+ A service declares its health per environment under `health`, in one of
149
+ three types:
150
+
151
+ ```yaml
152
+ services:
153
+ api:
154
+ health:
155
+ local: { url: http://shop-api/healthz } # http (the default type)
156
+ db:
157
+ health:
158
+ local: { type: shell, cmd: pg_isready -h localhost } # exit 0 is up
159
+ cache:
160
+ health:
161
+ local: { type: container, project: shop-main, service: cache }
162
+ ```
163
+
164
+ `label` (optional, any type) is shown in the UI instead of the URL or the
165
+ command. A compose repo with no declared check gets Den's own stack check
166
+ (`<cli> stack health`), answered in-process.
167
+
168
+ **`type: container`** asks whether a container is up, from the one
169
+ `docker ps` listing a health cycle already makes for the compose checks: it
170
+ adds no docker call however many container checks there are, in the web
171
+ health row and in `<cli> status`. It matches a compose container by its
172
+ `com.docker.compose.project` and `com.docker.compose.service` labels
173
+ (`project` + `service`), or any container by name (`container_name`, for one
174
+ compose does not run). One or the other, never both:
175
+
176
+ | What the listing shows | Verdict |
177
+ | ----------------------------------------- | ------------------------------------------------------- |
178
+ | running, healthcheck `healthy` | up |
179
+ | running, healthcheck `unhealthy` | down |
180
+ | running, healthcheck `starting` | pending |
181
+ | running, no healthcheck | up |
182
+ | not running (exited, created, restarting) | down |
183
+ | no matching container | down (`<project>/<service>: no container`) |
184
+ | `docker ps` fails (daemon down) | unknown (`ok: null`, the reason in `error`), never down |
185
+ | `docker ps` past its 5s budget | pending (`ok: null`, `pending: true`) |
186
+ | more than one match | the best one: healthy, then running |
187
+
188
+ When docker gives no listing, a container check says what a compose check
189
+ says, through the same code: docker state unknown is not "not running", so
190
+ a failed `docker ps` is `ok: null` however many cycles it lasts, in the
191
+ health row and in `<cli> status`. Only a listing that runs past its budget
192
+ is `pending`, and like any check with no answer it is down when that
193
+ repeats on the next cycle. A container whose healthcheck is still starting
194
+ stays pending. In
195
+ `/api/health` the row's `check` reads `container <project>/<service>` (or
196
+ `container <name>`).
197
+
198
+ Before, a container check was a shell check that ran docker itself, one
199
+ process and one docker call per check per cycle:
200
+
201
+ ```yaml
202
+ health:
203
+ local:
204
+ type: shell
205
+ cmd: test -n "$(docker ps -q --filter label=com.docker.compose.project=shop-main --filter label=com.docker.compose.service=cache)"
206
+ ```
207
+
208
+ After:
209
+
210
+ ```yaml
211
+ health:
212
+ local: { type: container, project: shop-main, service: cache }
213
+ ```
214
+
215
+ which also reads the container's own healthcheck (the shell form only saw
216
+ that a container existed, running or not).
217
+
218
+ **Validation.** `<cli> validate` rejects a container check with neither
219
+ `project` + `service` nor `container_name`, with only one of `project` and
220
+ `service`, with both forms at once, or with a key of another type (`cmd`,
221
+ `url`), and says which.
222
+
129
223
  ## Placement
130
224
 
131
225
  Each service says where it runs, per environment: `local` (on this machine,
@@ -358,8 +452,8 @@ defaults:
358
452
  focus: true
359
453
  ```
360
454
 
361
- Each entry takes `repos`, `branch` and `autostart_off`, the same fields as
362
- `defaults.workspace`, plus `focus`. Seeding creates every named workspace
455
+ Each entry takes `repos`, `branch`, `autostart_off` and `org`, the same
456
+ fields as `defaults.workspace`, plus `focus`. Seeding creates every named workspace
363
457
  with its repos, activates them all, and focuses the one with `focus: true`
364
458
  (else the first). Set `defaults.workspace` or `defaults.workspaces`, not both:
365
459
  both at once is an error, as is more than one `focus: true`.
@@ -375,6 +469,68 @@ Once seeded, a repo that later joins `defaults.workspace.repos` is added to
375
469
  you removed from it or that another workspace holds. With
376
470
  `defaults.workspaces` nothing is topped up.
377
471
 
472
+ To see what a setup would clone before it clones anything, run
473
+ `acme setup --plan`: each repo with its org and clone URL, and whether it
474
+ is cloned yet. On a fresh machine it plans the blueprint (every workspace
475
+ `acme workspace seed` would create); once seeded, the focused workspace.
476
+ `acme up --plan` lists the same lines for the repos it would start. Both
477
+ are read-only: nothing is cloned, seeded or written.
478
+
479
+ ## Orgs
480
+
481
+ A distribution can hold work for more than one org: each workspace belongs
482
+ to one. An org's id is a key of `orgs:` or a GitHub owner, and today an org
483
+ is the GitHub owner of the same name. `github_org` is the default org. A
484
+ distribution that never mentions orgs has one org, the default, and every
485
+ workspace is in it.
486
+
487
+ ```yaml
488
+ github_org: acme
489
+ orgs:
490
+ acme: { label: Acme }
491
+ globex: { label: Globex, color: "#0af" }
492
+ defaults:
493
+ workspaces:
494
+ storefront:
495
+ repos: [shop-web, shop]
496
+ client:
497
+ org: globex # optional: the workspace's org
498
+ repos: [globex-web, globex-api]
499
+ ```
500
+
501
+ A workspace's org is the first of:
502
+
503
+ 1. `org:` on the workspace in `config/local/workspaces.yml` (this machine)
504
+ 2. `org:` on the blueprint workspace of the same name (committed)
505
+ 3. the one owner of its repos in the catalog (`gh: <owner>/<repo>`; owners
506
+ compare case-insensitively, and a `gh: ""` counts as unknown)
507
+ 4. `github_org`
508
+
509
+ It is worked out each time it is read and never written back. A repo with
510
+ no clone URL is cloned from `<org>/<repo>` with its workspace's org: seeding
511
+ the blueprint above gives `globex-web` `gh: globex/globex-web`, not
512
+ `acme/...`. `acme register` picks a managed clone from the focused
513
+ workspace's org, or from `--org <id>`.
514
+
515
+ `acme org list` shows each org's label, its workspaces and which is the
516
+ default. `acme workspace list` has an Org column, and `--org <id>` lists one
517
+ org's workspaces. A workspace whose repos belong to several owners, an
518
+ `org:` naming an owner Den does not know, or a repo in another owner than
519
+ its workspace's org is a warning in both lists, never an error.
520
+ `GET /api/orgs` adds `default` and `workspaces` to each entry, and
521
+ `/api/<cli>/config` adds `default_org`.
522
+
523
+ `org:` in the blueprint needs Den 0.2.7 or later: an older Den rejects the
524
+ unknown key. Pin 0.2.7 before you commit it.
525
+
526
+ ## Releases
527
+
528
+ The Release page dispatches each repo's CD workflow on GitHub. A repo's
529
+ owner there is its own `gh: owner/repo` in `release.repos`, else
530
+ `release.org` when it is set, else the repo's owner in the catalog, else
531
+ `github_org`. Leave `release.org` unset when the repos belong to different
532
+ orgs: each then releases from its own.
533
+
378
534
  ## The bin shim
379
535
 
380
536
  `bin/<cli>` is three lines of sh: it names the distribution root and runs
@@ -449,6 +605,7 @@ it exits:
449
605
 
450
606
  ```bash
451
607
  ./bin/acme service install # install (or refresh) and start it
608
+ ./bin/acme service install --plan # what install would do; writes and loads nothing
452
609
  ./bin/acme service status # launchd's view: state, pid, last exit code
453
610
  ./bin/acme service print # the LaunchAgent plist it would install; installs nothing
454
611
  ./bin/acme service uninstall # stop it and remove the LaunchAgent
@@ -477,6 +634,15 @@ answers empty for a few seconds while it starts). If it has not answered
477
634
  within 30s, it says `installed but not answering yet: check <logs dir>` and
478
635
  exits 1.
479
636
 
637
+ `install --plan` is read-only: it prints the label, the plist path
638
+ (`~/Library/LaunchAgents/<label>.plist`), the log file, the distribution and
639
+ engine roots, whose server answers on `web.port` (this distribution's, free,
640
+ or `port <N> is in use by <product> at <root>; not this distribution's
641
+ server`), and the plist install would write. It writes nothing, never calls
642
+ launchctl, and exits 1 when the port is another distribution's. Any other
643
+ argument to a `service` subcommand is an error, so a mistyped `--plan` never
644
+ installs. `status` also names another distribution's server on the port.
645
+
480
646
  `<cli> service` runs the installer script below, which you can also run
481
647
  yourself, from the distribution root:
482
648
 
@@ -493,10 +659,17 @@ node_modules/@monster-orange/den/service/install.sh print # the plist only
493
659
  | `DEN_ROOT` | required in a distribution |
494
660
  | `DEN_SERVICE_LABEL` | `local.<distribution dir name>.web` |
495
661
  | `DEN_SERVICE_LOGS` | `~/Library/Logs/<distribution dir name>` |
496
-
497
- The service runs the engine's server with the `node` on your `PATH` at install
498
- time, from the distribution root; re-run `install` after changing node versions
499
- or upgrading Den. Keep the label stable: it is how launchd knows the service.
662
+ | `DEN_SERVICE_NODE` | see below |
663
+
664
+ The service runs the engine's server, from the distribution root, with the
665
+ `node` on your `PATH` at install time. A version manager's node (nvm, fnm,
666
+ volta, asdf, mise) lives under a versioned path that disappears when that
667
+ version is removed, so install prefers `/opt/homebrew/bin/node` or
668
+ `/usr/local/bin/node` when one has the same major version, and otherwise
669
+ keeps the version manager's and warns. `DEN_SERVICE_NODE` names the node
670
+ outright. `service status` exits 1 when the installed node no longer exists.
671
+ Re-run `install` after changing node versions or upgrading Den. Keep the
672
+ label stable: it is how launchd knows the service.
500
673
 
501
674
  ## MCP server
502
675
 
@@ -509,6 +682,13 @@ set of guards. It never starts the web server; if it is not running, every
509
682
  tool answers with the command that starts it (`./bin/acme web up`). stdout
510
683
  carries only the protocol; diagnostics go to stderr.
511
684
 
685
+ It reads only from this distribution's server. Before serving a tool it
686
+ checks `/api/server-info` (root and product, or for a server from before
687
+ 0.2.6 its working directory), trusts a positive answer for 30s, and checks
688
+ again after a connection error. When another distribution's server answers
689
+ on the port, every tool says `port <N> is in use by <product> at <root>; not
690
+ this distribution's server` and returns none of its data.
691
+
512
692
  The web server keeps running the Den it started with until it is restarted,
513
693
  so after an upgrade it can be older than the MCP server. When a tool's route
514
694
  answers 404, the MCP server reads the web server's version from
@@ -592,22 +772,22 @@ guard off.
592
772
 
593
773
  Read tools (always registered):
594
774
 
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 |
775
+ | Tool | Web API | What |
776
+ | ------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
777
+ | `status` | `/api/health` | Service health; `url` (HTTP check) or `check` (shell, or `container <project>/<service>`); `ok` true up, false down, null not known (`pending: true` when a check has not answered) |
778
+ | `services` | `/api/services` | Services, their mode (managed or dev-managed) and commands |
779
+ | `applications` | `/api/applications` | Org > Application > Repo > Services |
780
+ | `graph` | `/api/graph` | The derived dependency graph and startup order |
781
+ | `workspaces` | `/api/workspaces` | Workspaces, active and focused |
782
+ | `environments` | `/api/environments` | The enabled environment names (never their values) |
783
+ | `logs_search` | `/api/logs/search` | Search services' full log history |
784
+ | `logs_tail` | `/api/service/taillog` | The last lines of a service's log (bounded, no follow) |
785
+ | `tests_available` | `/api/modules`, `/api/test-sources` | A pillar's modules and the repos they come from |
786
+ | `security_latest` | `/api/security/latest` | The newest security scan per mode |
787
+ | `release_status` | `/api/release/runs`, `/api/release/status` | Recent CD runs, and an env's deployed-version table |
788
+ | `manifest_validate` | `/api/manifest/validate` | Validate a manifest by path or content |
789
+ | `stacks` | `/api/stacks` | What `stack ls/ps/health` show: project, routes, health |
790
+ | `edge_status` | `/api/edge` | The edge: up or down, its routes and cert names |
611
791
 
612
792
  Action tools (only those named in `mcp.actions`): `service_up`,
613
793
  `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.5",
3
+ "version": "0.2.7",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  #
16
16
  # DEN_SERVICE_LABEL launchd label (default local.<dist dir name>.web)
17
17
  # DEN_SERVICE_LOGS log directory (default ~/Library/Logs/<dist dir name>)
18
+ # DEN_SERVICE_NODE the node the agent runs (default: see pick_node)
18
19
  # DEN_SERVICE_READY_CHECK=external install does not print "running": the
19
20
  # caller (`<cli> service install`) checks the server
20
21
  # answers first.
@@ -47,8 +48,36 @@ find_tsx() {
47
48
  done
48
49
  }
49
50
 
51
+ # A version manager's node (nvm, fnm, volta, asdf, mise) lives under a
52
+ # versioned path that disappears when that version is removed, and the agent
53
+ # then fails to start with no clear reason. Prefer a stable install of the
54
+ # same major version; otherwise keep it and say so.
55
+ STABLE_NODES="${DEN_SERVICE_STABLE_NODES:-/opt/homebrew/bin/node /usr/local/bin/node}"
56
+ node_major() { "$1" -p "process.versions.node.split('.')[0]" 2>/dev/null; }
57
+ pick_node() {
58
+ if [ -n "${DEN_SERVICE_NODE:-}" ]; then
59
+ [ -x "$DEN_SERVICE_NODE" ] || { echo "✗ DEN_SERVICE_NODE ($DEN_SERVICE_NODE) is not executable" >&2; exit 1; }
60
+ echo "$DEN_SERVICE_NODE"; return
61
+ fi
62
+ local node; node="$(command -v node)" || { echo "✗ node is not on PATH" >&2; exit 1; }
63
+ case "$node" in
64
+ */.nvm/*|*/fnm/*|*/.fnm/*|*/.volta/*|*/.asdf/*|*/mise/*) ;;
65
+ *) echo "$node"; return ;;
66
+ esac
67
+ local want c; want="$(node_major "$node")"
68
+ for c in $STABLE_NODES; do
69
+ if [ -x "$c" ] && [ "$(node_major "$c")" = "$want" ]; then echo "$c"; return; fi
70
+ done
71
+ # Warn when installing; `print` writes the plist and nothing else.
72
+ if [ "${1:-}" = install ]; then
73
+ echo "⚠ the service will run $node, a version manager's node: it stops starting" >&2
74
+ echo " when that version is removed. Re-run install after switching, or set DEN_SERVICE_NODE." >&2
75
+ fi
76
+ echo "$node"
77
+ }
78
+
50
79
  write_plist() { # $1 = output file
51
- NODE="$(command -v node)" || { echo "✗ node is not on PATH" >&2; exit 1; }
80
+ NODE="$(pick_node "$MODE")"
52
81
  TSX="$(find_tsx)" || { echo "✗ tsx not installed: run npm ci in $DIST" >&2; exit 1; }
53
82
  DOCKER_DIR="$(dirname "$(command -v docker || echo /usr/local/bin/docker)")"
54
83
  SVC_PATH="$(dirname "$NODE"):$DOCKER_DIR:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
@@ -79,7 +108,8 @@ else:
79
108
  PY
80
109
  }
81
110
 
82
- case "${1:-install}" in
111
+ MODE="${1:-install}"
112
+ case "$MODE" in
83
113
  install)
84
114
  mkdir -p "$LOGS" "$(dirname "$PLIST")"
85
115
  write_plist "$PLIST.new"
@@ -115,6 +145,15 @@ case "${1:-install}" in
115
145
  write_plist -
116
146
  ;;
117
147
  status)
148
+ # The node the agent was installed with may be gone (a removed nvm
149
+ # version): launchd then restarts it forever with no useful message.
150
+ if [ -f "$PLIST" ] && command -v plutil >/dev/null 2>&1; then
151
+ svc_node="$(plutil -extract ProgramArguments.0 raw -o - "$PLIST" 2>/dev/null || true)"
152
+ if [ -n "$svc_node" ] && [ ! -x "$svc_node" ]; then
153
+ echo "✗ $LABEL runs $svc_node, which no longer exists: re-run service install" >&2
154
+ exit 1
155
+ fi
156
+ fi
118
157
  # launchctl's own "Could not find service" is noise when it isn't loaded.
119
158
  if out="$(launchctl print "$DOMAIN/$LABEL" 2>/dev/null)"; then
120
159
  printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs|spawn type)'
@@ -107,7 +107,7 @@ export function autoSecurityTargets(
107
107
  if (!runsLocally) continue;
108
108
  const hosts: SecurityServiceConfig["hosts"] = {};
109
109
  for (const [env, h] of Object.entries(s.config?.health ?? {})) {
110
- if (!h || h.type === "shell" || !/^https?:\/\//.test(h.url)) continue;
110
+ if (!h || !("url" in h) || !/^https?:\/\//.test(h.url)) continue;
111
111
  hosts[env as keyof typeof hosts] = [new URL(h.url).origin + "/"];
112
112
  }
113
113
  if (Object.keys(hosts).length === 0) continue;
@@ -11,6 +11,7 @@ import { discoverRepos, resolveRepoPath, resolveService } from "./discover.js";
11
11
  import { stageSmokeTree } from "./staging.js";
12
12
  import type { Environment } from "../../shared/types/index.js";
13
13
  import { getDistRoot } from "../../shared/paths.js";
14
+ import { plural } from "../../shared/plural.js";
14
15
 
15
16
  // Den-root anchored, NOT process.cwd() — bulk writer of .smoke-run (see setup.ts).
16
17
  const PROJECT_ROOT = getDistRoot();
@@ -116,7 +117,7 @@ async function main() {
116
117
  );
117
118
 
118
119
  console.log(
119
- `\n Total: ${total} smoke test file(s) staged across ${repos.length} repo(s)\n`,
120
+ `\n Total: ${plural(total, "smoke test file")} staged across ${plural(repos.length, "repo")}\n`,
120
121
  );
121
122
  }
122
123
 
@@ -17,6 +17,7 @@ import type { Environment } from "../shared/types/index.js";
17
17
  import { getDistRoot } from "../shared/paths.js";
18
18
 
19
19
  import { product } from "../shared/product.js";
20
+ import { plural } from "../shared/plural.js";
20
21
  // Den-root anchored: WRITES config/local/aws-profiles.json, and the reader
21
22
  // (shared/environment/aws-profiles.ts) already anchors at getDistRoot().
22
23
  const ROOT = getDistRoot();
@@ -54,7 +55,7 @@ async function main() {
54
55
  );
55
56
  } else {
56
57
  console.log(
57
- ` Found ${profiles.length} profile(s): ${profiles.join(", ")}\n`,
58
+ ` Found ${plural(profiles.length, "profile")}: ${profiles.join(", ")}\n`,
58
59
  );
59
60
  }
60
61