@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 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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
 
@@ -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} repo(s) registered for testing\n`,
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} repo(s) → config/local/registered-repos.json`,
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} repos):\n`,
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 { ResolvedService, ServiceCatalog } from "../types/index.js";
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
- const v = await composeHealth(compose);
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
- : pingHttp(
714
- healthCfg.url,
715
- env === "local" && plan.placement.runs !== "remote"
716
- ? HEALTH_CHECK_TIMEOUT_MS
717
- : HOSTED_HEALTH_CHECK_TIMEOUT_MS,
718
- plan.expect,
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 && h.type !== "shell" && typeof h.url === "string";
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 || ("type" in health && health.type === "shell")) return null;
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
- if ("type" in healthConfig && healthConfig.type === "shell") {
28
- return null;
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(