@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
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Which work org a workspace belongs to (ORG-1a, ORG-SPEC §2).
3
+ *
4
+ * An org is a key of `orgs:` or a GitHub owner. ORG-1a keeps today's model:
5
+ * an org with no `sources` is the GitHub owner of the same name, and
6
+ * `github_org` is the default org. A workspace's org resolves through this
7
+ * chain, first match wins:
8
+ *
9
+ * 1. `org:` on the workspace in workspaces.yml (per machine)
10
+ * 2. `org:` on the same-named blueprint workspace (`defaults.workspaces`)
11
+ * 3. the single non-empty catalog owner of its repos (case-insensitive)
12
+ * 4. `github_org`
13
+ *
14
+ * Computed on read and never written back to workspaces.yml, so an inferred
15
+ * org never looks like an explicit one. Every rule broken here is a warning,
16
+ * never an error (C1): an old config resolves as before, to `github_org`.
17
+ */
18
+ import {
19
+ readDenConfig,
20
+ readWorkspaceBlueprints,
21
+ requireGithubOrg,
22
+ type DenConfig,
23
+ type ResolvedWorkspaceBlueprint,
24
+ } from "../den-config.js";
25
+ import { readCatalog } from "./catalog.js";
26
+ import { parseAddress, readWorkspacesFile } from "./workspaces.js";
27
+ import { orgOf } from "../manifest/service-links.js";
28
+ import { cachedRead } from "../read-cache.js";
29
+ import type {
30
+ ServiceCatalog,
31
+ WorkspaceConfig,
32
+ WorkspacesFile,
33
+ } from "../types/index.js";
34
+
35
+ /** Which step of the chain gave the org. */
36
+ export type OrgSource = "workspace" | "blueprint" | "catalog" | "default";
37
+
38
+ export interface WorkspaceOrg {
39
+ workspace: string;
40
+ /** The resolved org id (the `orgs:` key's spelling when one matches), or null. */
41
+ org: string | null;
42
+ source: OrgSource | null;
43
+ /** Each broken org rule, as a sentence (C1: warnings, never errors). */
44
+ warnings: string[];
45
+ }
46
+
47
+ export interface OrgContext {
48
+ config: DenConfig;
49
+ catalog: ServiceCatalog;
50
+ file: WorkspacesFile;
51
+ blueprints: ResolvedWorkspaceBlueprint[];
52
+ }
53
+
54
+ const lower = (s: string): string => s.toLowerCase();
55
+
56
+ /** The current config, catalog, workspaces.yml and blueprint. */
57
+ export function readOrgContext(): OrgContext {
58
+ const config = readDenConfig();
59
+ let blueprints: ResolvedWorkspaceBlueprint[] = [];
60
+ try {
61
+ blueprints = readWorkspaceBlueprints(config).workspaces;
62
+ } catch {
63
+ // A malformed blueprint is reported where seeding reads it; it must not
64
+ // turn an org listing into a failure (C1).
65
+ }
66
+ return {
67
+ config,
68
+ catalog: readCatalog(),
69
+ file: readWorkspacesFile(),
70
+ blueprints,
71
+ };
72
+ }
73
+
74
+ /** `id` spelled as its `orgs:` key when one matches case-insensitively. */
75
+ export function canonicalOrg(id: string, config: DenConfig): string {
76
+ const key = Object.keys(config.orgs ?? {}).find(
77
+ (k) => lower(k) === lower(id),
78
+ );
79
+ return key ?? id;
80
+ }
81
+
82
+ /** The default org: `github_org`, or null when unset. */
83
+ export function defaultOrg(config: DenConfig): string | null {
84
+ const d = config.github_org?.trim();
85
+ return d ? canonicalOrg(d, config) : null;
86
+ }
87
+
88
+ /** The repos a workspace holds; a workspace not set up yet holds its blueprint's. */
89
+ function workspaceRepos(
90
+ ws: WorkspaceConfig | undefined,
91
+ bp: ResolvedWorkspaceBlueprint | undefined,
92
+ catalog: ServiceCatalog,
93
+ ): string[] {
94
+ const held = [
95
+ ...Object.keys(ws?.services ?? {}).map(
96
+ (k) => parseAddress(k, catalog).repoName,
97
+ ),
98
+ ...Object.keys(ws?.repos ?? {}),
99
+ ];
100
+ return [...new Set(held.length > 0 ? held : (bp?.repos ?? []))];
101
+ }
102
+
103
+ /** Owners Den knows of: `orgs:` keys, catalog owners and `github_org`. */
104
+ function knownOrgs(ctx: OrgContext): Set<string> {
105
+ const ids = new Set(Object.keys(ctx.config.orgs ?? {}).map(lower));
106
+ for (const repo of Object.keys(ctx.catalog)) {
107
+ const o = orgOf(repo, ctx.catalog);
108
+ if (o) ids.add(lower(o));
109
+ }
110
+ const d = ctx.config.github_org?.trim();
111
+ if (d) ids.add(lower(d));
112
+ return ids;
113
+ }
114
+
115
+ /** Resolve one workspace's org through the chain. */
116
+ export function resolveWorkspaceOrg(
117
+ name: string,
118
+ ctx: OrgContext,
119
+ ): WorkspaceOrg {
120
+ const warnings: string[] = [];
121
+ const ws = ctx.file.workspaces[name];
122
+ const bp = ctx.blueprints.find((b) => b.name === name);
123
+ const rawWsOrg = ws?.org as unknown;
124
+ const wsOrg =
125
+ typeof rawWsOrg === "string" && rawWsOrg.trim()
126
+ ? rawWsOrg.trim()
127
+ : undefined;
128
+ if (rawWsOrg !== undefined && rawWsOrg !== null && !wsOrg)
129
+ warnings.push(
130
+ `workspace ${name}: org in workspaces.yml is not an org id; ignored`,
131
+ );
132
+
133
+ const repos = workspaceRepos(ws, bp, ctx.catalog);
134
+ const owners = new Map<string, string>(); // lowercased → first spelling
135
+ for (const repo of repos) {
136
+ const o = orgOf(repo, ctx.catalog);
137
+ if (o && !owners.has(lower(o))) owners.set(lower(o), o);
138
+ }
139
+
140
+ let org: string | null = null;
141
+ let source: OrgSource | null = null;
142
+ if (wsOrg) {
143
+ org = wsOrg;
144
+ source = "workspace";
145
+ if (bp?.org && lower(bp.org) !== lower(wsOrg))
146
+ warnings.push(
147
+ `workspace ${name}: org ${wsOrg} in workspaces.yml overrides the blueprint's org ${bp.org}`,
148
+ );
149
+ } else if (bp?.org) {
150
+ org = bp.org;
151
+ source = "blueprint";
152
+ } else if (owners.size === 1) {
153
+ org = [...owners.values()][0];
154
+ source = "catalog";
155
+ } else {
156
+ org = ctx.config.github_org?.trim() || null;
157
+ source = org ? "default" : null;
158
+ if (owners.size > 1)
159
+ warnings.push(
160
+ `workspace ${name}: its repos belong to several owners (${[...owners.values()].join(", ")}); ` +
161
+ `using ${org ?? "no org"}. Set org: on the workspace to choose one`,
162
+ );
163
+ }
164
+ if (org) org = canonicalOrg(org, ctx.config);
165
+
166
+ if (
167
+ org &&
168
+ (source === "workspace" || source === "blueprint") &&
169
+ !knownOrgs(ctx).has(lower(org))
170
+ )
171
+ warnings.push(
172
+ `workspace ${name}: org ${org} is not in orgs:, the catalog or github_org`,
173
+ );
174
+
175
+ // A repo in another owner than its workspace's org. A mixed workspace with
176
+ // no org of its own already said so above.
177
+ if (
178
+ org &&
179
+ (owners.size <= 1 || source === "workspace" || source === "blueprint")
180
+ )
181
+ for (const repo of repos) {
182
+ const o = orgOf(repo, ctx.catalog);
183
+ if (o && lower(o) !== lower(org))
184
+ warnings.push(
185
+ `workspace ${name}: repo ${repo} belongs to ${o}, not the workspace's org ${org}`,
186
+ );
187
+ }
188
+
189
+ return { workspace: name, org, source, warnings };
190
+ }
191
+
192
+ /** Every workspace on this machine, then blueprint workspaces not set up yet. */
193
+ function workspaceNames(ctx: OrgContext): string[] {
194
+ return [
195
+ ...new Set([
196
+ ...Object.keys(ctx.file.workspaces),
197
+ ...ctx.blueprints.map((b) => b.name),
198
+ ]),
199
+ ];
200
+ }
201
+
202
+ /** Every workspace's resolved org (cached for the web server's read TTL). */
203
+ export function resolveAllWorkspaceOrgs(ctx?: OrgContext): WorkspaceOrg[] {
204
+ const compute = (c: OrgContext) =>
205
+ workspaceNames(c).map((n) => resolveWorkspaceOrg(n, c));
206
+ if (ctx) return compute(ctx);
207
+ return cachedRead("workspace-orgs", () => compute(readOrgContext()));
208
+ }
209
+
210
+ /**
211
+ * The GitHub owner to clone and register a workspace's repos under: its
212
+ * resolved org. With none at all (no org anywhere and no `github_org`) this
213
+ * throws exactly as requireGithubOrg does, so an old config fails as before.
214
+ */
215
+ export function githubOrgFor(workspace: string, ctx?: OrgContext): string {
216
+ const hit = ctx
217
+ ? resolveWorkspaceOrg(workspace, ctx)
218
+ : (resolveAllWorkspaceOrgs().find((w) => w.workspace === workspace) ??
219
+ resolveWorkspaceOrg(workspace, readOrgContext()));
220
+ if (hit.org) return hit.org;
221
+ return requireGithubOrg(ctx?.config);
222
+ }
223
+
224
+ export interface OrgEntry {
225
+ id: string;
226
+ label: string;
227
+ color?: string;
228
+ }
229
+
230
+ /**
231
+ * The org listing with `default` and `workspaces` added to each entry and
232
+ * nothing else changed (C3): same entries, same order, same fields. Only
233
+ * workspaces on this machine (workspaces.yml) are listed.
234
+ */
235
+ export function withOrgMembership<T extends OrgEntry>(
236
+ orgs: T[],
237
+ resolved: WorkspaceOrg[],
238
+ file: WorkspacesFile,
239
+ config: DenConfig,
240
+ ): Array<T & { default: boolean; workspaces: string[] }> {
241
+ // The listing can hold one owner twice in different case (catalog owners
242
+ // are listed as spelled); an org lands on the entry spelled exactly like
243
+ // it, else the first that matches case-insensitively.
244
+ const entryFor = (id: string): number => {
245
+ const exact = orgs.findIndex((o) => o.id === id);
246
+ return exact !== -1
247
+ ? exact
248
+ : orgs.findIndex((o) => lower(o.id) === lower(id));
249
+ };
250
+ const d = defaultOrg(config);
251
+ const defaultAt = d === null ? -1 : entryFor(d);
252
+ const members = orgs.map((): string[] => []);
253
+ const local = new Set(Object.keys(file.workspaces));
254
+ for (const w of resolved) {
255
+ if (!w.org || !local.has(w.workspace)) continue;
256
+ const at = entryFor(w.org);
257
+ if (at !== -1) members[at].push(w.workspace);
258
+ }
259
+ return orgs.map((o, i) => ({
260
+ ...o,
261
+ default: i === defaultAt,
262
+ workspaces: members[i],
263
+ }));
264
+ }
@@ -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(
@@ -58,13 +58,19 @@ export function composeHealthTarget(
58
58
  /** Budget for the shared listing; past it the checks are pending. */
59
59
  export const COMPOSE_LISTING_TIMEOUT_MS = 5_000;
60
60
 
61
- type Listing = { rows: ComposeRow[] } | { error: string; pending?: boolean };
61
+ /** The shared listing: every container, or why docker could not answer. */
62
+ export type ComposeListing =
63
+ | { rows: ComposeRow[] }
64
+ | { error: string; pending?: boolean };
65
+ type Listing = ComposeListing;
62
66
 
63
67
  let inFlight: Promise<Listing> | null = null;
64
68
 
65
69
  /**
66
- * Every compose container, from one `docker ps -a`. Concurrent callers share
67
- * the call in flight, so a health cycle lists once however many checks it has.
70
+ * Every container, from one `docker ps -a`. Concurrent callers share the
71
+ * call in flight, so a health cycle lists once however many checks it has
72
+ * (compose checks and `type: container` checks alike). Not filtered to
73
+ * compose containers: a container check may name a plain container.
68
74
  */
69
75
  export function composeListing(): Promise<Listing> {
70
76
  return (inFlight ??= listOnce().finally(() => {
@@ -96,10 +102,8 @@ function listOnce(): Promise<Listing> {
96
102
  [
97
103
  "ps",
98
104
  "-a",
99
- "--filter",
100
- "label=com.docker.compose.project",
101
105
  "--format",
102
- '{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
106
+ '{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}\t{{.Names}}',
103
107
  ],
104
108
  {
105
109
  encoding: "utf-8",
@@ -125,9 +129,14 @@ function listOnce(): Promise<Listing> {
125
129
  .split("\n")
126
130
  .filter(Boolean)
127
131
  .map((line) => {
128
- const [project = "", service = "", state = "", status = ""] =
129
- line.split("\t");
130
- return { project, service, state, status };
132
+ const [
133
+ project = "",
134
+ service = "",
135
+ state = "",
136
+ status = "",
137
+ name = "",
138
+ ] = line.split("\t");
139
+ return { project, service, state, status, name };
131
140
  });
132
141
  resolve({ rows });
133
142
  },
@@ -135,16 +144,99 @@ function listOnce(): Promise<Listing> {
135
144
  });
136
145
  }
137
146
 
147
+ /**
148
+ * The verdict when docker gave no listing, shared by compose and container
149
+ * checks so the two cannot drift: container state unknown is not "not
150
+ * running" (`ok: null`, never down, however often it repeats). Only a
151
+ * listing past its budget is `pending` (no answer in time), which, like any
152
+ * timeout, is down when it repeats on the next cycle.
153
+ */
154
+ export function unknownListingVerdict(listing: {
155
+ error: string;
156
+ pending?: boolean;
157
+ }): ComposeVerdict {
158
+ return {
159
+ ok: null,
160
+ detail: `container state unknown: ${listing.error}`,
161
+ ...(listing.pending ? { pending: true } : {}),
162
+ };
163
+ }
164
+
138
165
  /** The health of one Den-generated compose check, from the shared listing. */
139
166
  export async function composeHealth(
140
167
  target: ComposeHealthTarget,
141
168
  ): Promise<ComposeVerdict> {
142
169
  const listing = await composeListing();
143
- if ("error" in listing)
170
+ if ("error" in listing) return unknownListingVerdict(listing);
171
+ return judgeStackRows(listing.rows, target.project, target.service);
172
+ }
173
+
174
+ // ── `health: { type: container }` ───────────────────────────────────────────
175
+
176
+ /**
177
+ * A declared container check: a compose container by its project and service
178
+ * labels, or any container by name (`container_name`).
179
+ */
180
+ export type ContainerHealthTarget =
181
+ | { project: string; service: string; container_name?: undefined }
182
+ | { container_name: string; project?: undefined; service?: undefined };
183
+
184
+ /** What a container check shows as its check: `container <project>/<service>`. */
185
+ export function containerCheckLabel(t: ContainerHealthTarget): string {
186
+ return `container ${containerTargetName(t)}`;
187
+ }
188
+
189
+ function containerTargetName(t: ContainerHealthTarget): string {
190
+ return t.container_name ?? `${t.project}/${t.service}`;
191
+ }
192
+
193
+ function matchesTarget(r: ComposeRow, t: ContainerHealthTarget): boolean {
194
+ if (t.container_name)
195
+ return (r.name ?? "")
196
+ .split(",")
197
+ .map((n) => n.trim().replace(/^\//, ""))
198
+ .includes(t.container_name);
199
+ return r.project === t.project && r.service === t.service;
200
+ }
201
+
202
+ /** One container's verdict: its healthcheck when it has one, else running. */
203
+ function rowVerdict(r: ComposeRow): ComposeVerdict & { rank: number } {
204
+ const what = `${r.name || `${r.project}/${r.service}`}\t${r.state}\t${r.status}`;
205
+ if (r.state !== "running") return { ok: false, detail: what, rank: 4 };
206
+ if (/\(unhealthy\)/.test(r.status))
207
+ return { ok: false, detail: what, rank: 3 };
208
+ // Starting is pending, but not "no answer": it never turns down on repeat.
209
+ if (/\(health: starting\)/.test(r.status))
210
+ return { ok: null, detail: what, rank: 2 };
211
+ if (/\(healthy\)/.test(r.status)) return { ok: true, detail: what, rank: 0 };
212
+ return { ok: true, detail: what, rank: 1 };
213
+ }
214
+
215
+ /**
216
+ * The verdict of a container check over the shared listing. More than one
217
+ * match: the best one (healthy, then running). No match: down. No listing:
218
+ * as a compose check (unknownListingVerdict), the reason in `detail`.
219
+ */
220
+ export function judgeContainerListing(
221
+ listing: ComposeListing,
222
+ target: ContainerHealthTarget,
223
+ ): ComposeVerdict {
224
+ if ("error" in listing) return unknownListingVerdict(listing);
225
+ const best = listing.rows
226
+ .filter((r) => matchesTarget(r, target))
227
+ .map(rowVerdict)
228
+ .sort((a, b) => a.rank - b.rank)[0];
229
+ if (!best)
144
230
  return {
145
- ok: null,
146
- detail: `container state unknown: ${listing.error}`,
147
- ...(listing.pending ? { pending: true } : {}),
231
+ ok: false,
232
+ detail: `${containerTargetName(target)}: no container`,
148
233
  };
149
- return judgeStackRows(listing.rows, target.project, target.service);
234
+ return { ok: best.ok, detail: best.detail };
235
+ }
236
+
237
+ /** The health of a declared container check, from the shared listing. */
238
+ export async function containerHealth(
239
+ target: ContainerHealthTarget,
240
+ ): Promise<ComposeVerdict> {
241
+ return judgeContainerListing(await composeListing(), target);
150
242
  }
@@ -413,6 +413,8 @@ export interface ComposeRow {
413
413
  service: string;
414
414
  state: string;
415
415
  status: string;
416
+ /** The container's name(s), comma-separated as docker lists them. */
417
+ name?: string;
416
418
  }
417
419
 
418
420
  /**
@@ -210,10 +210,22 @@ export type LogSource =
210
210
  * type: shell
211
211
  * cmd: "docker exec $(docker ps --filter ancestor=postgres:16 --format '{{.ID}}' | head -1) pg_isready -U postgres"
212
212
  * label: "postgres" # optional — shown in UI instead of cmd
213
+ *
214
+ * Container (answered from the one `docker ps` a health cycle shares):
215
+ * type: container
216
+ * project: shop-main # com.docker.compose.project label
217
+ * service: db # com.docker.compose.service label
218
+ * -- or, for a container not run by compose --
219
+ * type: container
220
+ * container_name: shop-db
213
221
  */
214
222
  export type ServiceHealthConfig =
215
223
  | { type?: "http"; url: string; label?: string }
216
- | { type: "shell"; cmd: string; label?: string };
224
+ | { type: "shell"; cmd: string; label?: string }
225
+ | ({ type: "container"; label?: string } & (
226
+ | { project: string; service: string; container_name?: undefined }
227
+ | { container_name: string; project?: undefined; service?: undefined }
228
+ ));
217
229
 
218
230
  /**
219
231
  * A command can be a plain shell string (detached by default) or an object
@@ -674,6 +686,13 @@ export interface WorkspaceConfig {
674
686
  * newly JOIN the blueprint are added.
675
687
  */
676
688
  seeded_repos?: string[];
689
+ /**
690
+ * The org this workspace belongs to (ORG-1a), set per machine. Wins over
691
+ * the blueprint's `org:`; when neither is set the org is inferred from the
692
+ * repos' catalog owners, then `github_org`. Never written by Den: the
693
+ * resolved org is computed on read (see workspace-org.ts).
694
+ */
695
+ org?: string;
677
696
  }
678
697
 
679
698
  export interface WorkspacesFile {