@monster-orange/den 0.2.5 → 0.2.6
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 +98 -0
- package/docs/distributions.md +110 -16
- package/package.json +1 -1
- package/src/pillars/security/discover.ts +1 -1
- package/src/pillars/smoke/setup-global.ts +2 -1
- package/src/scripts/configure-aws-profiles.ts +2 -1
- package/src/scripts/register-repos.ts +4 -3
- package/src/shared/environment-manager/default-workspace.ts +47 -0
- package/src/shared/environment-manager/health.ts +45 -9
- package/src/shared/manifest/placement.ts +1 -1
- package/src/shared/manifest/schema.ts +47 -0
- package/src/shared/manifest/service-links.ts +1 -1
- package/src/shared/manifest/validate-manifest.ts +1 -1
- package/src/shared/plural.ts +13 -0
- package/src/shared/service-url.ts +2 -5
- package/src/shared/stacks/compose-health.ts +106 -14
- package/src/shared/stacks/runtime.ts +2 -0
- package/src/shared/types/index.ts +13 -1
- package/src/ui/cli/create.ts +2 -1
- package/src/ui/cli/index.ts +25 -21
- package/src/ui/cli/mcp-connect.ts +36 -5
- package/src/ui/cli/server-identity.ts +233 -0
- package/src/ui/cli/service-command.ts +136 -12
- package/src/ui/cli/spinner.ts +48 -0
- package/src/ui/cli/web-command.ts +76 -25
- package/src/ui/mcp/api.ts +94 -1
- package/src/ui/mcp/server.ts +6 -1
- package/src/ui/mcp/tools.ts +6 -2
- package/src/ui/server/health-row.ts +6 -3
- package/src/ui/server/index.ts +32 -14
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,104 @@ 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.6] - 2026-11-04
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`<cli> web restart` and `web down` act on our server from an older
|
|
12
|
+
Den.** A server from before 0.2.6 (no `root` in `/api/server-info`) run
|
|
13
|
+
from our root is ours, and 0.2.6-dev.0 classified it so, but stopping it
|
|
14
|
+
then looked for our pids by command line only: the entry point of this
|
|
15
|
+
engine, or one under the root. An older Den installed elsewhere, or a
|
|
16
|
+
hand-started server, matched neither, so `web restart` printed "OK ... web
|
|
17
|
+
is already running" and restarted nothing, and `web down` printed "OK ...
|
|
18
|
+
web is not running" while the server stayed up and bound. A server the
|
|
19
|
+
identity check finds ours is now stopped by the pid it reports, when that
|
|
20
|
+
pid listens on the port: `restart` replaces it and `down` frees the port,
|
|
21
|
+
with or without a `web.json` for it. web.json's root is compared after
|
|
22
|
+
resolving symlinks (`/var` and `/private/var` are one root).
|
|
23
|
+
- **The MCP server serves nothing from another distribution's server.**
|
|
24
|
+
It read from whatever answered on its port. Before a tool reads, it now
|
|
25
|
+
checks `/api/server-info` as `web up` does; on another distribution's
|
|
26
|
+
server every tool answers `port <N> is in use by <product> at <root>; not
|
|
27
|
+
this distribution's server` and returns none of its data. A positive
|
|
28
|
+
check is trusted for 30s and made again after a connection error, so a
|
|
29
|
+
server that took over the port is noticed. A server that does not say
|
|
30
|
+
whose it is within 5s is not read from either.
|
|
31
|
+
- **A mistyped `service` flag no longer installs.** `<cli> service install
|
|
32
|
+
--anything` ignored the flag and installed; a `service` subcommand now
|
|
33
|
+
refuses an argument it does not take, before running anything.
|
|
34
|
+
- **`<cli> web up` no longer trusts whatever answers on its port.** With
|
|
35
|
+
`web.port` set to a port where another distribution's Den server ran
|
|
36
|
+
(another root, an older Den), `web up` printed "OK ... web is already
|
|
37
|
+
running" and stopped, and `web down` or `web restart` could SIGTERM that
|
|
38
|
+
server: with a linked engine its command line looks like ours.
|
|
39
|
+
`/api/server-info` now also returns `root` (the distribution root the
|
|
40
|
+
server serves) and `product`. `web up`, `web down` and `web restart`
|
|
41
|
+
compare both with their own, and on another distribution's server exit 1
|
|
42
|
+
with `port <N> is in use by <product> at <root>` and a hint to pick
|
|
43
|
+
another port (`--port`, or `web.port`). A server from before 0.2.6 has
|
|
44
|
+
no `root`: it is ours when it runs from our root (its working
|
|
45
|
+
directory), otherwise `port <N> is in use by another server (Den
|
|
46
|
+
<version>)`; anything else is "a non-Den server". `web down` also no
|
|
47
|
+
longer signals a process running from another root, even when the
|
|
48
|
+
server does not answer. `<cli> mcp connect` refuses to point an agent at
|
|
49
|
+
another distribution's server (and `mcp clients` names it), and `<cli>
|
|
50
|
+
service install` no longer reports "running" when another
|
|
51
|
+
distribution's server answers on the port.
|
|
52
|
+
- **A repeat `<cli> workspace seed` says "already seeded".** Seeding a
|
|
53
|
+
workspace that already holds exactly the blueprint printed "Default
|
|
54
|
+
workspace ready" as if it had seeded again; it now prints `✓ Default
|
|
55
|
+
workspace already seeded — N repos (managed): nothing changed.` and
|
|
56
|
+
writes nothing (multi blueprint: `✓ N workspaces already seeded (a, b)`).
|
|
57
|
+
A seed over a workspace the user changed says it was "reset to the
|
|
58
|
+
blueprint".
|
|
59
|
+
- **Counts read "1 repo", not "1 repos".** CLI output that counts repos,
|
|
60
|
+
workspaces, systems, files, profiles and tools uses one plural helper
|
|
61
|
+
(`src/shared/plural.ts`), also in place of "repo(s)".
|
|
62
|
+
- **Spinners no longer loop forever on a 0-column terminal.** On a pty that
|
|
63
|
+
reports 0 columns (one nobody sized, as agent shells give), ora computed
|
|
64
|
+
an infinite number of lines to clear and never finished. A spinner now
|
|
65
|
+
animates only on a TTY with a known, non-zero width; otherwise it prints
|
|
66
|
+
plain lines (its text on start, then the result).
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
|
|
70
|
+
- **Health type `container`.** `health: { type: container, project: <compose
|
|
71
|
+
project>, service: <compose service> }` checks a compose container by its
|
|
72
|
+
`com.docker.compose.project` / `com.docker.compose.service` labels, or
|
|
73
|
+
`{ type: container, container_name: <name> }` any container by name. It
|
|
74
|
+
is answered from the one `docker ps` per health cycle the compose checks
|
|
75
|
+
already share (its format gains `{{.Names}}`, and it no longer filters to
|
|
76
|
+
compose containers), so N container checks add no docker call, in the
|
|
77
|
+
web health row and in `<cli> status` (22 checks, 17 compose and 5
|
|
78
|
+
container: 1 docker call per cycle). A running container with a
|
|
79
|
+
healthcheck is its health (healthy up, unhealthy down, starting
|
|
80
|
+
pending), one without is up, and no matching container is down. When
|
|
81
|
+
docker gives no listing it says what a compose check says, through the
|
|
82
|
+
same code: a failed `docker ps` is unknown (`ok: null`, the reason in
|
|
83
|
+
`error`) on every cycle, never down; a listing past its budget is
|
|
84
|
+
pending (`pending: true`), down only when that repeats. Of several matches the best counts:
|
|
85
|
+
healthy, then running. `/api/health` rows show it as `check: "container
|
|
86
|
+
<project>/<service>"`. `<cli> validate` requires `project` + `service` or
|
|
87
|
+
`container_name` (not both, not one of the pair alone, no other type's
|
|
88
|
+
keys) and says which. See docs/distributions.md, "Health checks".
|
|
89
|
+
- **`<cli> service install --plan`.** Read-only: prints the label, the
|
|
90
|
+
plist path, the log file, the roots, whose server answers on `web.port`
|
|
91
|
+
(ours, free, or `port <N> is in use by <product> at <root>`) and the
|
|
92
|
+
plist install would write; writes nothing and never calls launchctl.
|
|
93
|
+
Exits 1 when the port is another distribution's. `<cli> service status`
|
|
94
|
+
also names another distribution's server on the port.
|
|
95
|
+
|
|
96
|
+
### Migrations
|
|
97
|
+
|
|
98
|
+
- None required. Optional: a `type: shell` check that runs `docker ps` to
|
|
99
|
+
find a container (`test -n "$(docker ps -q --filter
|
|
100
|
+
label=com.docker.compose.project=X --filter
|
|
101
|
+
label=com.docker.compose.service=Y)"`) can become `{ type: container,
|
|
102
|
+
project: X, service: Y }`: no process or docker call of its own, and it
|
|
103
|
+
reads the container's healthcheck.
|
|
104
|
+
|
|
7
105
|
## [0.2.5] - 2026-11-04
|
|
8
106
|
|
|
9
107
|
### Fixed
|
package/docs/distributions.md
CHANGED
|
@@ -126,6 +126,83 @@ is never mistaken for another repo's service. An alias that names more than
|
|
|
126
126
|
one service is an error that lists them (`"worker" is ambiguous in workspace
|
|
127
127
|
"default": one/worker, two/worker`); name one of them.
|
|
128
128
|
|
|
129
|
+
## Health checks
|
|
130
|
+
|
|
131
|
+
A service declares its health per environment under `health`, in one of
|
|
132
|
+
three types:
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
services:
|
|
136
|
+
api:
|
|
137
|
+
health:
|
|
138
|
+
local: { url: http://shop-api/healthz } # http (the default type)
|
|
139
|
+
db:
|
|
140
|
+
health:
|
|
141
|
+
local: { type: shell, cmd: pg_isready -h localhost } # exit 0 is up
|
|
142
|
+
cache:
|
|
143
|
+
health:
|
|
144
|
+
local: { type: container, project: shop-main, service: cache }
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`label` (optional, any type) is shown in the UI instead of the URL or the
|
|
148
|
+
command. A compose repo with no declared check gets Den's own stack check
|
|
149
|
+
(`<cli> stack health`), answered in-process.
|
|
150
|
+
|
|
151
|
+
**`type: container`** asks whether a container is up, from the one
|
|
152
|
+
`docker ps` listing a health cycle already makes for the compose checks: it
|
|
153
|
+
adds no docker call however many container checks there are, in the web
|
|
154
|
+
health row and in `<cli> status`. It matches a compose container by its
|
|
155
|
+
`com.docker.compose.project` and `com.docker.compose.service` labels
|
|
156
|
+
(`project` + `service`), or any container by name (`container_name`, for one
|
|
157
|
+
compose does not run). One or the other, never both:
|
|
158
|
+
|
|
159
|
+
| What the listing shows | Verdict |
|
|
160
|
+
| ----------------------------------------- | ------------------------------------------------------- |
|
|
161
|
+
| running, healthcheck `healthy` | up |
|
|
162
|
+
| running, healthcheck `unhealthy` | down |
|
|
163
|
+
| running, healthcheck `starting` | pending |
|
|
164
|
+
| running, no healthcheck | up |
|
|
165
|
+
| not running (exited, created, restarting) | down |
|
|
166
|
+
| no matching container | down (`<project>/<service>: no container`) |
|
|
167
|
+
| `docker ps` fails (daemon down) | unknown (`ok: null`, the reason in `error`), never down |
|
|
168
|
+
| `docker ps` past its 5s budget | pending (`ok: null`, `pending: true`) |
|
|
169
|
+
| more than one match | the best one: healthy, then running |
|
|
170
|
+
|
|
171
|
+
When docker gives no listing, a container check says what a compose check
|
|
172
|
+
says, through the same code: docker state unknown is not "not running", so
|
|
173
|
+
a failed `docker ps` is `ok: null` however many cycles it lasts, in the
|
|
174
|
+
health row and in `<cli> status`. Only a listing that runs past its budget
|
|
175
|
+
is `pending`, and like any check with no answer it is down when that
|
|
176
|
+
repeats on the next cycle. A container whose healthcheck is still starting
|
|
177
|
+
stays pending. In
|
|
178
|
+
`/api/health` the row's `check` reads `container <project>/<service>` (or
|
|
179
|
+
`container <name>`).
|
|
180
|
+
|
|
181
|
+
Before, a container check was a shell check that ran docker itself, one
|
|
182
|
+
process and one docker call per check per cycle:
|
|
183
|
+
|
|
184
|
+
```yaml
|
|
185
|
+
health:
|
|
186
|
+
local:
|
|
187
|
+
type: shell
|
|
188
|
+
cmd: test -n "$(docker ps -q --filter label=com.docker.compose.project=shop-main --filter label=com.docker.compose.service=cache)"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
After:
|
|
192
|
+
|
|
193
|
+
```yaml
|
|
194
|
+
health:
|
|
195
|
+
local: { type: container, project: shop-main, service: cache }
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
which also reads the container's own healthcheck (the shell form only saw
|
|
199
|
+
that a container existed, running or not).
|
|
200
|
+
|
|
201
|
+
**Validation.** `<cli> validate` rejects a container check with neither
|
|
202
|
+
`project` + `service` nor `container_name`, with only one of `project` and
|
|
203
|
+
`service`, with both forms at once, or with a key of another type (`cmd`,
|
|
204
|
+
`url`), and says which.
|
|
205
|
+
|
|
129
206
|
## Placement
|
|
130
207
|
|
|
131
208
|
Each service says where it runs, per environment: `local` (on this machine,
|
|
@@ -449,6 +526,7 @@ it exits:
|
|
|
449
526
|
|
|
450
527
|
```bash
|
|
451
528
|
./bin/acme service install # install (or refresh) and start it
|
|
529
|
+
./bin/acme service install --plan # what install would do; writes and loads nothing
|
|
452
530
|
./bin/acme service status # launchd's view: state, pid, last exit code
|
|
453
531
|
./bin/acme service print # the LaunchAgent plist it would install; installs nothing
|
|
454
532
|
./bin/acme service uninstall # stop it and remove the LaunchAgent
|
|
@@ -477,6 +555,15 @@ answers empty for a few seconds while it starts). If it has not answered
|
|
|
477
555
|
within 30s, it says `installed but not answering yet: check <logs dir>` and
|
|
478
556
|
exits 1.
|
|
479
557
|
|
|
558
|
+
`install --plan` is read-only: it prints the label, the plist path
|
|
559
|
+
(`~/Library/LaunchAgents/<label>.plist`), the log file, the distribution and
|
|
560
|
+
engine roots, whose server answers on `web.port` (this distribution's, free,
|
|
561
|
+
or `port <N> is in use by <product> at <root>; not this distribution's
|
|
562
|
+
server`), and the plist install would write. It writes nothing, never calls
|
|
563
|
+
launchctl, and exits 1 when the port is another distribution's. Any other
|
|
564
|
+
argument to a `service` subcommand is an error, so a mistyped `--plan` never
|
|
565
|
+
installs. `status` also names another distribution's server on the port.
|
|
566
|
+
|
|
480
567
|
`<cli> service` runs the installer script below, which you can also run
|
|
481
568
|
yourself, from the distribution root:
|
|
482
569
|
|
|
@@ -509,6 +596,13 @@ set of guards. It never starts the web server; if it is not running, every
|
|
|
509
596
|
tool answers with the command that starts it (`./bin/acme web up`). stdout
|
|
510
597
|
carries only the protocol; diagnostics go to stderr.
|
|
511
598
|
|
|
599
|
+
It reads only from this distribution's server. Before serving a tool it
|
|
600
|
+
checks `/api/server-info` (root and product, or for a server from before
|
|
601
|
+
0.2.6 its working directory), trusts a positive answer for 30s, and checks
|
|
602
|
+
again after a connection error. When another distribution's server answers
|
|
603
|
+
on the port, every tool says `port <N> is in use by <product> at <root>; not
|
|
604
|
+
this distribution's server` and returns none of its data.
|
|
605
|
+
|
|
512
606
|
The web server keeps running the Den it started with until it is restarted,
|
|
513
607
|
so after an upgrade it can be older than the MCP server. When a tool's route
|
|
514
608
|
answers 404, the MCP server reads the web server's version from
|
|
@@ -592,22 +686,22 @@ guard off.
|
|
|
592
686
|
|
|
593
687
|
Read tools (always registered):
|
|
594
688
|
|
|
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
|
|
689
|
+
| Tool | Web API | What |
|
|
690
|
+
| ------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
691
|
+
| `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) |
|
|
692
|
+
| `services` | `/api/services` | Services, their mode (managed or dev-managed) and commands |
|
|
693
|
+
| `applications` | `/api/applications` | Org > Application > Repo > Services |
|
|
694
|
+
| `graph` | `/api/graph` | The derived dependency graph and startup order |
|
|
695
|
+
| `workspaces` | `/api/workspaces` | Workspaces, active and focused |
|
|
696
|
+
| `environments` | `/api/environments` | The enabled environment names (never their values) |
|
|
697
|
+
| `logs_search` | `/api/logs/search` | Search services' full log history |
|
|
698
|
+
| `logs_tail` | `/api/service/taillog` | The last lines of a service's log (bounded, no follow) |
|
|
699
|
+
| `tests_available` | `/api/modules`, `/api/test-sources` | A pillar's modules and the repos they come from |
|
|
700
|
+
| `security_latest` | `/api/security/latest` | The newest security scan per mode |
|
|
701
|
+
| `release_status` | `/api/release/runs`, `/api/release/status` | Recent CD runs, and an env's deployed-version table |
|
|
702
|
+
| `manifest_validate` | `/api/manifest/validate` | Validate a manifest by path or content |
|
|
703
|
+
| `stacks` | `/api/stacks` | What `stack ls/ps/health` show: project, routes, health |
|
|
704
|
+
| `edge_status` | `/api/edge` | The edge: up or down, its routes and cert names |
|
|
611
705
|
|
|
612
706
|
Action tools (only those named in `mcp.actions`): `service_up`,
|
|
613
707
|
`service_down`, `service_restart` (through `/api/service/exec`),
|
package/package.json
CHANGED
|
@@ -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 ||
|
|
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
|
|
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
|
|
58
|
+
` Found ${plural(profiles.length, "profile")}: ${profiles.join(", ")}\n`,
|
|
58
59
|
);
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "../shared/environment-manager/default-workspace.js";
|
|
30
30
|
|
|
31
31
|
import { product } from "../shared/product.js";
|
|
32
|
+
import { plural } from "../shared/plural.js";
|
|
32
33
|
// Anchored to the den root, NOT process.cwd() — this script WRITES
|
|
33
34
|
// config/local/* and a cwd-derived root once put den files in another repo.
|
|
34
35
|
const ROOT = getDistRoot();
|
|
@@ -179,7 +180,7 @@ async function legacyScanAndSelect(): Promise<void> {
|
|
|
179
180
|
fs.readFileSync(REGISTERED_FILE, "utf-8"),
|
|
180
181
|
) as string[];
|
|
181
182
|
console.log(
|
|
182
|
-
` Current: ${existing.length
|
|
183
|
+
` Current: ${plural(existing.length, "repo")} registered for testing\n`,
|
|
183
184
|
);
|
|
184
185
|
} catch {
|
|
185
186
|
/* corrupt file — start fresh */
|
|
@@ -223,7 +224,7 @@ async function legacyScanAndSelect(): Promise<void> {
|
|
|
223
224
|
fs.writeFileSync(REGISTERED_FILE, JSON.stringify(selected, null, 2) + "\n");
|
|
224
225
|
|
|
225
226
|
console.log(
|
|
226
|
-
`\n Registered ${selected.length
|
|
227
|
+
`\n Registered ${plural(selected.length, "repo")} → config/local/registered-repos.json`,
|
|
227
228
|
);
|
|
228
229
|
console.log(" Run `make register-repos` again at any time to update.\n");
|
|
229
230
|
}
|
|
@@ -282,7 +283,7 @@ async function main(): Promise<void> {
|
|
|
282
283
|
console.log(
|
|
283
284
|
" Local Dev is enabled but no workspace exists yet. Your first" +
|
|
284
285
|
` \`${product().cli}\` command\n seeds the default workspace with every standard` +
|
|
285
|
-
` system repo, all managed\n out of the box (${plan.repos.length
|
|
286
|
+
` system repo, all managed\n out of the box (${plural(plan.repos.length, "repo")}):\n`,
|
|
286
287
|
);
|
|
287
288
|
console.log(` ${plan.repos.join(", ")}\n`);
|
|
288
289
|
console.log(
|
|
@@ -117,6 +117,53 @@ export function defaultWorkspaceHasServices(): boolean {
|
|
|
117
117
|
);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
function sameEntry(a: unknown, b: unknown): boolean {
|
|
121
|
+
if (a === b) return true;
|
|
122
|
+
if (typeof a !== "object" || typeof b !== "object" || !a || !b) return false;
|
|
123
|
+
const ka = Object.keys(a).sort();
|
|
124
|
+
const kb = Object.keys(b).sort();
|
|
125
|
+
return (
|
|
126
|
+
ka.length === kb.length &&
|
|
127
|
+
ka.every(
|
|
128
|
+
(k, i) =>
|
|
129
|
+
k === kb[i] &&
|
|
130
|
+
sameEntry(
|
|
131
|
+
(a as Record<string, unknown>)[k],
|
|
132
|
+
(b as Record<string, unknown>)[k],
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether seeding now would change nothing: every workspace the blueprint
|
|
140
|
+
* seeds holds exactly what seeding writes (same repos, all managed on the
|
|
141
|
+
* blueprint's branch, same autostart defaults), records the blueprint as
|
|
142
|
+
* applied, and (multi blueprint) is active. A workspace the user changed
|
|
143
|
+
* since (a repo switched to a local checkout, one added or removed) is not
|
|
144
|
+
* "already seeded": seeding would reset it.
|
|
145
|
+
*/
|
|
146
|
+
export function blueprintAlreadySeeded(
|
|
147
|
+
file: WorkspacesFile = readWorkspacesFile(),
|
|
148
|
+
): boolean {
|
|
149
|
+
const bps = blueprintWorkspaces();
|
|
150
|
+
if (bps.length === 0) return false;
|
|
151
|
+
const multi = isMultiBlueprint();
|
|
152
|
+
return bps.every((bp) => {
|
|
153
|
+
const name = multi ? bp.name : "default";
|
|
154
|
+
const ws = file.workspaces[name];
|
|
155
|
+
if (!ws) return false;
|
|
156
|
+
if (multi && !(file.active ?? []).includes(name)) return false;
|
|
157
|
+
const applied = ws.seeded_repos ?? [];
|
|
158
|
+
if (
|
|
159
|
+
applied.length !== bp.repos.length ||
|
|
160
|
+
bp.repos.some((r, i) => applied[i] !== r)
|
|
161
|
+
)
|
|
162
|
+
return false;
|
|
163
|
+
return sameEntry(ws.services ?? {}, blueprintServices(bp));
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
120
167
|
/**
|
|
121
168
|
* Seed (or reset) the `default` workspace from the blueprint — every repo as a
|
|
122
169
|
* bare, first-class managed entry on `branch` — and ensure the catalog has
|
|
@@ -12,7 +12,10 @@ import { readEdgeConfig } from "../edge/config.js";
|
|
|
12
12
|
import {
|
|
13
13
|
composeHealth,
|
|
14
14
|
composeHealthTarget,
|
|
15
|
+
containerHealth,
|
|
15
16
|
type ComposeHealthTarget,
|
|
17
|
+
type ComposeVerdict,
|
|
18
|
+
type ContainerHealthTarget,
|
|
16
19
|
} from "../stacks/compose-health.js";
|
|
17
20
|
import * as fs from "fs";
|
|
18
21
|
import * as os from "os";
|
|
@@ -38,7 +41,11 @@ import {
|
|
|
38
41
|
listWorkspaceServices,
|
|
39
42
|
readManifestAnyBranch,
|
|
40
43
|
} from "./workspaces.js";
|
|
41
|
-
import type {
|
|
44
|
+
import type {
|
|
45
|
+
ResolvedService,
|
|
46
|
+
ServiceCatalog,
|
|
47
|
+
ServiceHealthConfig,
|
|
48
|
+
} from "../types/index.js";
|
|
42
49
|
import type { Environment } from "../types/index.js";
|
|
43
50
|
|
|
44
51
|
import { product } from "../product.js";
|
|
@@ -595,12 +602,39 @@ export async function pingShellCheck(
|
|
|
595
602
|
compose: ComposeHealthTarget | null,
|
|
596
603
|
): Promise<PingResult> {
|
|
597
604
|
if (!compose || cmd !== compose.cmd) return pingShell(cmd, extraEnv, cwd);
|
|
598
|
-
|
|
605
|
+
return verdictPing(await composeHealth(compose));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* A compose or container verdict as a ping. One conversion for both, so a
|
|
610
|
+
* docker that could not be asked reads the same: `ok: null`, pending (and so
|
|
611
|
+
* down on a repeat) only when the listing ran past its budget.
|
|
612
|
+
*/
|
|
613
|
+
function verdictPing(v: ComposeVerdict): PingResult {
|
|
599
614
|
if (v.ok === true) return { ok: true };
|
|
600
615
|
if (v.ok === false) return { ok: false, error: v.detail };
|
|
601
616
|
return { ok: null, error: v.detail, ...(v.pending ? { pending: true } : {}) };
|
|
602
617
|
}
|
|
603
618
|
|
|
619
|
+
/**
|
|
620
|
+
* Run a `type: container` check: answered in-process from the shared docker
|
|
621
|
+
* listing (the one Den's compose checks use), never a docker call of its own.
|
|
622
|
+
*/
|
|
623
|
+
export async function pingContainerCheck(
|
|
624
|
+
target: ContainerHealthTarget,
|
|
625
|
+
): Promise<PingResult> {
|
|
626
|
+
return verdictPing(await containerHealth(target));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** The container a `type: container` health config names. */
|
|
630
|
+
export function containerTarget(
|
|
631
|
+
h: Extract<ServiceHealthConfig, { type: "container" }>,
|
|
632
|
+
): ContainerHealthTarget {
|
|
633
|
+
return h.container_name !== undefined
|
|
634
|
+
? { container_name: h.container_name }
|
|
635
|
+
: { project: h.project, service: h.service };
|
|
636
|
+
}
|
|
637
|
+
|
|
604
638
|
/** A ping result as a ServiceHealthResult health state. */
|
|
605
639
|
export function healthState(r: PingResult): ServiceHealthResult["health"] {
|
|
606
640
|
return r.ok === true ? "ok" : r.ok === false ? "error" : "pending";
|
|
@@ -710,13 +744,15 @@ export async function checkServiceHealth(
|
|
|
710
744
|
const result = await pingTwiceOnTimeout(() =>
|
|
711
745
|
healthCfg.type === "shell"
|
|
712
746
|
? pingShellCheck(healthCfg.cmd, {}, resolved.repoPath, compose)
|
|
713
|
-
:
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
747
|
+
: healthCfg.type === "container"
|
|
748
|
+
? pingContainerCheck(containerTarget(healthCfg))
|
|
749
|
+
: pingHttp(
|
|
750
|
+
healthCfg.url,
|
|
751
|
+
env === "local" && plan.placement.runs !== "remote"
|
|
752
|
+
? HEALTH_CHECK_TIMEOUT_MS
|
|
753
|
+
: HOSTED_HEALTH_CHECK_TIMEOUT_MS,
|
|
754
|
+
plan.expect,
|
|
755
|
+
),
|
|
720
756
|
);
|
|
721
757
|
|
|
722
758
|
return {
|
|
@@ -52,7 +52,7 @@ export type IsProduction = (env: string) => boolean;
|
|
|
52
52
|
const LOCAL = "local";
|
|
53
53
|
|
|
54
54
|
function isHttp(h: ServiceHealthConfig | undefined): h is { url: string } {
|
|
55
|
-
return !!h &&
|
|
55
|
+
return !!h && "url" in h && typeof h.url === "string";
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function hasCommands(
|
|
@@ -32,7 +32,54 @@ const commandDef = z.union([
|
|
|
32
32
|
// { url: "http://..." } — implicit HTTP
|
|
33
33
|
// { type: "http", url: "http://..." } — explicit HTTP
|
|
34
34
|
// { type: "shell", cmd: "pg_isready ..." } — shell
|
|
35
|
+
// { type: "container", project, service } — a compose container
|
|
36
|
+
// { type: "container", container_name } — a container by name
|
|
37
|
+
const CONTAINER_HEALTH_KEYS = new Set([
|
|
38
|
+
"type",
|
|
39
|
+
"project",
|
|
40
|
+
"service",
|
|
41
|
+
"container_name",
|
|
42
|
+
"label",
|
|
43
|
+
]);
|
|
44
|
+
export const CONTAINER_HEALTH_SHAPE =
|
|
45
|
+
'container checks need "project" and "service" (compose labels), or "container_name"';
|
|
46
|
+
const containerHealth = z
|
|
47
|
+
.object({
|
|
48
|
+
type: z.literal("container"),
|
|
49
|
+
project: z.string().min(1).optional(),
|
|
50
|
+
service: z.string().min(1).optional(),
|
|
51
|
+
container_name: z.string().min(1).optional(),
|
|
52
|
+
label: z.string().optional(),
|
|
53
|
+
})
|
|
54
|
+
.passthrough()
|
|
55
|
+
.superRefine((h, ctx) => {
|
|
56
|
+
const unknown = Object.keys(h).filter((k) => !CONTAINER_HEALTH_KEYS.has(k));
|
|
57
|
+
if (unknown.length)
|
|
58
|
+
ctx.addIssue({
|
|
59
|
+
code: z.ZodIssueCode.custom,
|
|
60
|
+
message: `unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} in a container check: ${CONTAINER_HEALTH_SHAPE}`,
|
|
61
|
+
});
|
|
62
|
+
const byLabels = h.project !== undefined || h.service !== undefined;
|
|
63
|
+
const byName = h.container_name !== undefined;
|
|
64
|
+
if (byLabels && byName)
|
|
65
|
+
ctx.addIssue({
|
|
66
|
+
code: z.ZodIssueCode.custom,
|
|
67
|
+
message: `a container check matches by "project"/"service" or by "container_name", not both`,
|
|
68
|
+
});
|
|
69
|
+
else if (byLabels && (h.project === undefined || h.service === undefined))
|
|
70
|
+
ctx.addIssue({
|
|
71
|
+
code: z.ZodIssueCode.custom,
|
|
72
|
+
message: `a container check by compose labels needs both "project" and "service"`,
|
|
73
|
+
});
|
|
74
|
+
else if (!byLabels && !byName)
|
|
75
|
+
ctx.addIssue({
|
|
76
|
+
code: z.ZodIssueCode.custom,
|
|
77
|
+
message: CONTAINER_HEALTH_SHAPE,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
35
81
|
const healthConfig = z.union([
|
|
82
|
+
containerHealth,
|
|
36
83
|
// Shell health check — must have cmd
|
|
37
84
|
z.object({
|
|
38
85
|
type: z.literal("shell"),
|
|
@@ -47,7 +47,7 @@ export function openUrlFor(
|
|
|
47
47
|
const declared = httpUrl(svc.urls?.[env]);
|
|
48
48
|
if (declared) return declared;
|
|
49
49
|
const health = svc.health?.[env];
|
|
50
|
-
if (!health || ("
|
|
50
|
+
if (!health || !("url" in health)) return null;
|
|
51
51
|
return httpOrigin((health as { url?: string }).url);
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -57,7 +57,7 @@ function suggestionFor(issue: z.ZodIssue): string | undefined {
|
|
|
57
57
|
|
|
58
58
|
// health with missing url/cmd
|
|
59
59
|
if (pathStr.includes("health")) {
|
|
60
|
-
return 'HTTP checks need "url"; shell checks need "type: shell" and "cmd"';
|
|
60
|
+
return 'HTTP checks need "url"; shell checks need "type: shell" and "cmd"; container checks need "type: container" and "project" + "service", or "container_name"';
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
return undefined;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counts in output: "1 repo", "3 repos", never "1 repos" or "repo(s)".
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** `word` in the form `n` takes: "repo" for 1, "repos" (or `many`) otherwise. */
|
|
6
|
+
export function noun(n: number, word: string, many = `${word}s`): string {
|
|
7
|
+
return n === 1 ? word : many;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** `n` and its noun: plural(1, "repo") → "1 repo", plural(2, "repo") → "2 repos". */
|
|
11
|
+
export function plural(n: number, word: string, many?: string): string {
|
|
12
|
+
return `${n} ${noun(n, word, many)}`;
|
|
13
|
+
}
|
|
@@ -24,11 +24,8 @@ function healthUrlForConfig(healthConfig?: ServiceHealthConfig): string | null {
|
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return healthConfig.url;
|
|
27
|
+
// Shell and container checks have no URL.
|
|
28
|
+
return "url" in healthConfig ? healthConfig.url : null;
|
|
32
29
|
}
|
|
33
30
|
|
|
34
31
|
function normalizeServiceBaseUrl(
|