@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
@@ -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(
@@ -636,6 +636,8 @@ export interface ResolvedWorkspaceBlueprint {
636
636
  branch: string;
637
637
  autostartOff: string[];
638
638
  focus: boolean;
639
+ /** `org:` (ORG-1a): the org this workspace belongs to, when the blueprint names one. */
640
+ org?: string;
639
641
  }
640
642
 
641
643
  /** Which blueprint shape the distribution uses, and its workspaces. */
@@ -644,7 +646,7 @@ export interface WorkspaceBlueprints {
644
646
  workspaces: ResolvedWorkspaceBlueprint[];
645
647
  }
646
648
 
647
- const BLUEPRINT_KEYS = ["branch", "repos", "autostart_off"];
649
+ const BLUEPRINT_KEYS = ["branch", "repos", "autostart_off", "org"];
648
650
 
649
651
  function blueprintStrings(where: string, key: string, v: unknown): string[] {
650
652
  if (v === undefined) return [];
@@ -674,12 +676,15 @@ function resolveBlueprint(
674
676
  throw new Error(`${where}.branch must be a branch name`);
675
677
  if (bp.focus !== undefined && typeof bp.focus !== "boolean")
676
678
  throw new Error(`${where}.focus must be true or false`);
679
+ if (bp.org !== undefined && (typeof bp.org !== "string" || !bp.org.trim()))
680
+ throw new Error(`${where}.org must be an org id (a key of orgs:)`);
677
681
  return {
678
682
  name,
679
683
  repos: blueprintStrings(where, "repos", bp.repos),
680
684
  branch,
681
685
  autostartOff: blueprintStrings(where, "autostart_off", bp.autostart_off),
682
686
  focus: bp.focus === true,
687
+ ...(typeof bp.org === "string" ? { org: bp.org.trim() } : {}),
683
688
  };
684
689
  }
685
690
 
@@ -864,6 +869,38 @@ export function readReleaseConfig(): ReleaseConfig {
864
869
  return cfg;
865
870
  }
866
871
 
872
+ /**
873
+ * `release.org` as configured (den.yml, then user.yml), without the
874
+ * `github_org` fallback readReleaseConfig fills in. An explicit value is the
875
+ * release owner of every repo (REL-1); unset, each repo's own catalog owner is.
876
+ */
877
+ export function readExplicitReleaseOrg(): string | undefined {
878
+ const den = mergeReleaseLayer(
879
+ DEFAULT_RELEASE_CONFIG,
880
+ readDenConfig().release,
881
+ );
882
+ const org = mergeReleaseLayer(den, readUserConfig().release).org;
883
+ return typeof org === "string" && org.trim() ? org.trim() : undefined;
884
+ }
885
+
886
+ /**
887
+ * The GitHub owner a repo is released from (REL-1): an explicit
888
+ * `release.org` wins, then the repo's non-empty catalog owner, then
889
+ * `github_org`. "" when none is known.
890
+ */
891
+ export function releaseOwnerFor(opts: {
892
+ explicitOrg?: string;
893
+ catalogOwner?: string;
894
+ githubOrg?: string;
895
+ }): string {
896
+ return (
897
+ opts.explicitOrg?.trim() ||
898
+ opts.catalogOwner?.trim() ||
899
+ opts.githubOrg?.trim() ||
900
+ ""
901
+ );
902
+ }
903
+
867
904
  /**
868
905
  * Persist a personal release override to config/local/user.yml (gitignored).
869
906
  * Used by the Release tab's wave editor — team defaults stay in den.yml.
@@ -55,7 +55,8 @@ export function upsertCatalogEntry(
55
55
  /**
56
56
  * GitHub slug to clone `repoName` from.
57
57
  *
58
- * The catalog's `gh` when set, else the conventional `<github_org>/<repo>` —
58
+ * The catalog's `gh` when set, else the conventional `<org>/<repo>`, where
59
+ * `<org>` is the workspace's resolved org (`github_org` when none is given) —
59
60
  * the same rule `seedDefaultWorkspace` uses. Entries written by the
60
61
  * dev-managed add flow carry `gh: ""`, so a repo can be IN the catalog and
61
62
  * still have no slug; deriving it (rather than refusing to clone) is what lets
@@ -64,10 +65,16 @@ export function upsertCatalogEntry(
64
65
  export function resolveRepoGhSlug(
65
66
  repoName: string,
66
67
  catalog: ServiceCatalog = readCatalog(),
68
+ /**
69
+ * The owner for a repo with no `gh`: the workspace's resolved org
70
+ * (`() => githubOrgFor(ws)`, ORG-1a). Called only when needed, so a repo
71
+ * with a slug never needs an org. Defaults to `github_org`.
72
+ */
73
+ owner: () => string = () => requireGithubOrg(),
67
74
  ): string {
68
75
  const gh = catalog[repoName]?.gh?.trim();
69
76
  if (gh) return gh;
70
- return `${requireGithubOrg()}/${repoName}`;
77
+ return `${owner()}/${repoName}`;
71
78
  }
72
79
 
73
80
  export function removeCatalogEntry(repoName: string): void {
@@ -5,7 +5,8 @@
5
5
  * all managed. On a fresh clone (no local workspace) den offers — once —
6
6
  * to seed the `default` workspace from it so `den graph` / `den up` show the
7
7
  * full system graph immediately; `den init` (run in the den root) re-seeds.
8
- * Catalog clone URLs derive as `<github_org>/<repo>`. Switch any repo to a local
8
+ * Catalog clone URLs derive as `<org>/<repo>`, with the workspace's resolved org
9
+ * (workspace-org.ts; `github_org` unless the blueprint or catalog says otherwise). Switch any repo to a local
9
10
  * checkout with `den use-local <repo>`.
10
11
  */
11
12
  import {
@@ -16,9 +17,13 @@ import {
16
17
  import { readCatalog, writeCatalog } from "./catalog.js";
17
18
  import {
18
19
  readWorkspaceBlueprints,
19
- requireGithubOrg,
20
20
  type ResolvedWorkspaceBlueprint,
21
21
  } from "../den-config.js";
22
+ import {
23
+ githubOrgFor,
24
+ readOrgContext,
25
+ type OrgContext,
26
+ } from "./workspace-org.js";
22
27
  import type {
23
28
  ServiceCatalog,
24
29
  WorkspaceConfig,
@@ -47,10 +52,6 @@ export function defaultWorkspaceRepos(): string[] {
47
52
  return singleBlueprint()?.repos ?? [];
48
53
  }
49
54
 
50
- function org(): string {
51
- return requireGithubOrg();
52
- }
53
-
54
55
  function blueprintServices(
55
56
  bp: ResolvedWorkspaceBlueprint,
56
57
  repos: string[] = bp.repos,
@@ -70,14 +71,41 @@ function blueprintServices(
70
71
  return services;
71
72
  }
72
73
 
74
+ /**
75
+ * The clone slug seeding would give each repo the catalog has no `gh` for:
76
+ * `<org>/<repo>` with its workspace's resolved org. A repo in several
77
+ * workspaces takes the first one's. Reads only; throws when a repo needs an
78
+ * org and none resolves (no org anywhere and no `github_org`).
79
+ */
80
+ export function catalogFill(
81
+ workspaces: { name: string; repos: string[] }[],
82
+ catalog: ServiceCatalog = readCatalog(),
83
+ ctx: OrgContext = readOrgContext(),
84
+ ): Map<string, string> {
85
+ const fill = new Map<string, string>();
86
+ for (const ws of workspaces)
87
+ for (const repo of ws.repos) {
88
+ if (catalog[repo]?.gh || fill.has(repo)) continue;
89
+ fill.set(repo, `${githubOrgFor(ws.name, ctx)}/${repo}`);
90
+ }
91
+ return fill;
92
+ }
93
+
94
+ /**
95
+ * Give each blueprint repo with no clone URL `<org>/<repo>`, where `<org>` is
96
+ * its workspace's resolved org (ORG-1a: the blueprint's `org:`, else the
97
+ * catalog owners, else `github_org`), so a client workspace never clones
98
+ * from the default org on a fresh machine. The org is resolved only for a
99
+ * repo that needs one, and before anything is written: an unresolvable org
100
+ * throws with the catalog untouched.
101
+ */
73
102
  function fillCatalog(
74
- repos: string[],
103
+ workspaces: { name: string; repos: string[] }[],
75
104
  catalog: ServiceCatalog = readCatalog(),
76
105
  ): void {
77
- const o = org();
78
- for (const repo of repos) {
79
- if (!catalog[repo]?.gh) catalog[repo] = { gh: `${o}/${repo}` };
80
- }
106
+ const fill = catalogFill(workspaces, catalog);
107
+ if (fill.size === 0) return;
108
+ for (const [repo, gh] of fill) catalog[repo] = { gh };
81
109
  writeCatalog(catalog);
82
110
  }
83
111
 
@@ -117,6 +145,53 @@ export function defaultWorkspaceHasServices(): boolean {
117
145
  );
118
146
  }
119
147
 
148
+ function sameEntry(a: unknown, b: unknown): boolean {
149
+ if (a === b) return true;
150
+ if (typeof a !== "object" || typeof b !== "object" || !a || !b) return false;
151
+ const ka = Object.keys(a).sort();
152
+ const kb = Object.keys(b).sort();
153
+ return (
154
+ ka.length === kb.length &&
155
+ ka.every(
156
+ (k, i) =>
157
+ k === kb[i] &&
158
+ sameEntry(
159
+ (a as Record<string, unknown>)[k],
160
+ (b as Record<string, unknown>)[k],
161
+ ),
162
+ )
163
+ );
164
+ }
165
+
166
+ /**
167
+ * Whether seeding now would change nothing: every workspace the blueprint
168
+ * seeds holds exactly what seeding writes (same repos, all managed on the
169
+ * blueprint's branch, same autostart defaults), records the blueprint as
170
+ * applied, and (multi blueprint) is active. A workspace the user changed
171
+ * since (a repo switched to a local checkout, one added or removed) is not
172
+ * "already seeded": seeding would reset it.
173
+ */
174
+ export function blueprintAlreadySeeded(
175
+ file: WorkspacesFile = readWorkspacesFile(),
176
+ ): boolean {
177
+ const bps = blueprintWorkspaces();
178
+ if (bps.length === 0) return false;
179
+ const multi = isMultiBlueprint();
180
+ return bps.every((bp) => {
181
+ const name = multi ? bp.name : "default";
182
+ const ws = file.workspaces[name];
183
+ if (!ws) return false;
184
+ if (multi && !(file.active ?? []).includes(name)) return false;
185
+ const applied = ws.seeded_repos ?? [];
186
+ if (
187
+ applied.length !== bp.repos.length ||
188
+ bp.repos.some((r, i) => applied[i] !== r)
189
+ )
190
+ return false;
191
+ return sameEntry(ws.services ?? {}, blueprintServices(bp));
192
+ });
193
+ }
194
+
120
195
  /**
121
196
  * Seed (or reset) the `default` workspace from the blueprint — every repo as a
122
197
  * bare, first-class managed entry on `branch` — and ensure the catalog has
@@ -131,6 +206,9 @@ export function seedDefaultWorkspace(): string[] {
131
206
  if (!bp) return [];
132
207
  const repos = bp.repos;
133
208
 
209
+ // The catalog first: it is what can fail (no github_org), and a failure
210
+ // must leave the workspaces as they were, not reset and uncloneable.
211
+ fillCatalog([{ name: "default", repos }]);
134
212
  const file = readWorkspacesFile();
135
213
  file.workspaces.default = {
136
214
  ...(file.workspaces.default ?? {}),
@@ -142,7 +220,6 @@ export function seedDefaultWorkspace(): string[] {
142
220
  file.focus = "default";
143
221
  }
144
222
  writeWorkspacesFile(file);
145
- fillCatalog(repos);
146
223
  return repos;
147
224
  }
148
225
 
@@ -155,6 +232,7 @@ export function seedDefaultWorkspace(): string[] {
155
232
  export function seedBlueprintWorkspaces(): { name: string; repos: string[] }[] {
156
233
  const bps = blueprintWorkspaces();
157
234
  if (bps.length === 0) return [];
235
+ fillCatalog(bps);
158
236
  const file = readWorkspacesFile();
159
237
  for (const bp of bps) {
160
238
  const prev: WorkspaceConfig | undefined = file.workspaces[bp.name];
@@ -176,7 +254,6 @@ export function seedBlueprintWorkspaces(): { name: string; repos: string[] }[] {
176
254
  file.active = [...names, ...kept.filter((n) => !names.includes(n))];
177
255
  file.focus = (bps.find((b) => b.focus) ?? bps[0]).name;
178
256
  writeWorkspacesFile(file);
179
- fillCatalog([...new Set(bps.flatMap((b) => b.repos))]);
180
257
  return bps.map((b) => ({ name: b.name, repos: [...b.repos] }));
181
258
  }
182
259
 
@@ -230,7 +307,8 @@ export function topUpDefaultWorkspace(): string[] {
230
307
 
231
308
  def.seeded_repos = [...repos];
232
309
  writeWorkspacesFile(file);
233
- if (missing.length > 0) fillCatalog(missing, catalog);
310
+ if (missing.length > 0)
311
+ fillCatalog([{ name: "default", repos: missing }], catalog);
234
312
  return missing;
235
313
  }
236
314
 
@@ -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";
@@ -223,6 +230,28 @@ function trustContext(): { context: tls.SecureContext; agent: https.Agent } {
223
230
  return _trust;
224
231
  }
225
232
 
233
+ /**
234
+ * The body a reverse proxy sends for a host it has no route for: Traefik's
235
+ * default 404 (Go's http.NotFound). Behind a shared edge a stopped service
236
+ * answers with this instead of refusing the connection, so a 4xx-means-up
237
+ * rule would show it healthy. A service's own 404 carries its own body.
238
+ */
239
+ const EDGE_NO_ROUTE_BODY = "404 page not found";
240
+
241
+ /** Up: 2xx, or a 4xx from the service itself (auth/route proves it answers). */
242
+ function statusOk(status: number, body404?: string): boolean {
243
+ if (status === 404 && body404?.trim() === EDGE_NO_ROUTE_BODY) return false;
244
+ return status < 300 || (status >= 400 && status < 500);
245
+ }
246
+
247
+ /** The PingResult for `status`, naming an edge 404 as "no route". */
248
+ function statusResult(status: number, body404?: string): PingResult {
249
+ const ok = statusOk(status, body404);
250
+ return ok || status !== 404
251
+ ? { ok, status }
252
+ : { ok, status, error: "no route (edge 404)" };
253
+ }
254
+
226
255
  /**
227
256
  * HTTPS check. Connects to the c-ares-resolved IP (see above) but keeps the
228
257
  * hostname for SNI and certificate verification; rewriting the URL to the IP,
@@ -269,11 +298,23 @@ async function pingHttps(parsed: URL, timeoutMs: number): Promise<PingResult> {
269
298
  },
270
299
  (res) => {
271
300
  const status = res.statusCode ?? 0;
272
- res.resume(); // discard the body
273
- done({
274
- ok: status < 300 || (status >= 400 && status < 500),
275
- status,
301
+ if (status !== 404) {
302
+ res.resume(); // discard the body
303
+ done({ ok: statusOk(status), status });
304
+ return;
305
+ }
306
+ // A 404: read a little of the body to tell the edge's own "no
307
+ // route" from the service's 404.
308
+ let body = "";
309
+ res.setEncoding("utf8");
310
+ res.on("data", (c: string) => {
311
+ if (body.length < 64) body += c;
312
+ if (body.length >= 64) res.destroy();
276
313
  });
314
+ const finish = () => done(statusResult(status, body));
315
+ res.on("end", finish);
316
+ res.on("close", finish);
317
+ res.on("error", finish);
277
318
  },
278
319
  );
279
320
  const timer = setTimeout(() => {
@@ -327,11 +368,20 @@ async function pingOnce(
327
368
  // (e.g. VPN / /etc/hosts) returning 301 on port 80 for services that actually run
328
369
  // on a different port — the previous `< 500` check showed those as green.
329
370
  // 5xx = unhealthy (service errored).
330
- const ok = res.status < 300 || (res.status >= 400 && res.status < 500);
331
- // Discard the body: only the status matters, and unconsumed bodies pin
332
- // sockets in the long-lived web server (one leak per poll adds up).
371
+ // A 404 may be the edge's own "no route" (see EDGE_NO_ROUTE_BODY): read
372
+ // a little of it. Otherwise discard the body: only the status matters,
373
+ // and unconsumed bodies pin sockets in the long-lived web server.
374
+ if (res.status === 404) {
375
+ let body = "";
376
+ try {
377
+ body = (await res.text()).slice(0, 64);
378
+ } catch {
379
+ /* unreadable body: judge by the status alone */
380
+ }
381
+ return statusResult(res.status, body);
382
+ }
333
383
  void res.body?.cancel().catch(() => {});
334
- return { ok, status: res.status };
384
+ return { ok: statusOk(res.status), status: res.status };
335
385
  } catch (e: unknown) {
336
386
  clearTimeout(timer);
337
387
  const err = e as Error;
@@ -595,12 +645,39 @@ export async function pingShellCheck(
595
645
  compose: ComposeHealthTarget | null,
596
646
  ): Promise<PingResult> {
597
647
  if (!compose || cmd !== compose.cmd) return pingShell(cmd, extraEnv, cwd);
598
- const v = await composeHealth(compose);
648
+ return verdictPing(await composeHealth(compose));
649
+ }
650
+
651
+ /**
652
+ * A compose or container verdict as a ping. One conversion for both, so a
653
+ * docker that could not be asked reads the same: `ok: null`, pending (and so
654
+ * down on a repeat) only when the listing ran past its budget.
655
+ */
656
+ function verdictPing(v: ComposeVerdict): PingResult {
599
657
  if (v.ok === true) return { ok: true };
600
658
  if (v.ok === false) return { ok: false, error: v.detail };
601
659
  return { ok: null, error: v.detail, ...(v.pending ? { pending: true } : {}) };
602
660
  }
603
661
 
662
+ /**
663
+ * Run a `type: container` check: answered in-process from the shared docker
664
+ * listing (the one Den's compose checks use), never a docker call of its own.
665
+ */
666
+ export async function pingContainerCheck(
667
+ target: ContainerHealthTarget,
668
+ ): Promise<PingResult> {
669
+ return verdictPing(await containerHealth(target));
670
+ }
671
+
672
+ /** The container a `type: container` health config names. */
673
+ export function containerTarget(
674
+ h: Extract<ServiceHealthConfig, { type: "container" }>,
675
+ ): ContainerHealthTarget {
676
+ return h.container_name !== undefined
677
+ ? { container_name: h.container_name }
678
+ : { project: h.project, service: h.service };
679
+ }
680
+
604
681
  /** A ping result as a ServiceHealthResult health state. */
605
682
  export function healthState(r: PingResult): ServiceHealthResult["health"] {
606
683
  return r.ok === true ? "ok" : r.ok === false ? "error" : "pending";
@@ -710,13 +787,15 @@ export async function checkServiceHealth(
710
787
  const result = await pingTwiceOnTimeout(() =>
711
788
  healthCfg.type === "shell"
712
789
  ? 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
- ),
790
+ : healthCfg.type === "container"
791
+ ? pingContainerCheck(containerTarget(healthCfg))
792
+ : pingHttp(
793
+ healthCfg.url,
794
+ env === "local" && plan.placement.runs !== "remote"
795
+ ? HEALTH_CHECK_TIMEOUT_MS
796
+ : HOSTED_HEALTH_CHECK_TIMEOUT_MS,
797
+ plan.expect,
798
+ ),
720
799
  );
721
800
 
722
801
  return {
@@ -69,7 +69,7 @@ import type {
69
69
  WorkspaceConfig,
70
70
  } from "../types/index.js";
71
71
  import type { ChildProcess } from "child_process";
72
- import { getStateDir } from "../paths.js";
72
+ import { getDistRoot, getStateDir } from "../paths.js";
73
73
 
74
74
  import { product } from "../product.js";
75
75
  // Re-export so existing callers don't need to change their import path.
@@ -101,6 +101,33 @@ export interface DaemonRecord {
101
101
  * command. Optional for backward-compat with records written before this.
102
102
  */
103
103
  containers?: string[];
104
+ /**
105
+ * The distribution root (realpath) whose server or CLI registered this
106
+ * daemon. Every root of a product shares one daemons file, so startup
107
+ * pruning skips records stamped by another root. Missing on records
108
+ * written before 0.2.7.
109
+ */
110
+ root?: string;
111
+ }
112
+
113
+ /** This process's distribution root, as stamped on its daemon records. */
114
+ function daemonRoot(): string | null {
115
+ try {
116
+ return fs.realpathSync(getDistRoot());
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Whose a daemon record is: this root's, another root's (all roots of a
124
+ * product share one daemons file), or unstamped (written before 0.2.7).
125
+ */
126
+ export function daemonRootOf(
127
+ d: Pick<DaemonRecord, "root">,
128
+ ): "here" | "other" | "unstamped" {
129
+ if (!d.root) return "unstamped";
130
+ return d.root === daemonRoot() ? "here" : "other";
104
131
  }
105
132
 
106
133
  export type ServiceConflictReason = "identity_mismatch" | "orphan_containers";
@@ -263,8 +290,10 @@ export function registerDaemon(record: DaemonRecord): void {
263
290
  ? (existing?.workspaces ?? [])
264
291
  : [];
265
292
  const workspaces = [...new Set([...inherited, ...(record.workspaces ?? [])])];
293
+ const root = daemonRoot() ?? record.root;
266
294
  _daemonRegistry.set(record.address, {
267
295
  ...record,
296
+ ...(root ? { root } : {}),
268
297
  workspaces,
269
298
  branch: normalizeBranch(record.branch),
270
299
  });
@@ -296,9 +325,17 @@ export function updateDaemonContainers(
296
325
  saveDaemonsFile(getDaemonsFile());
297
326
  }
298
327
 
328
+ /**
329
+ * This root's daemons (and unstamped legacy ones). Every root of a product
330
+ * shares one daemons file; another root's records stay in it untouched but
331
+ * are not listed, so this server never shows, stops, releases or sweeps
332
+ * them. Lookups by address (findDaemonByAddress) still see every record.
333
+ */
299
334
  export function listDaemons(): DaemonRecord[] {
300
335
  loadDaemonsFromFile();
301
- return Array.from(_daemonRegistry.values());
336
+ return Array.from(_daemonRegistry.values()).filter(
337
+ (d) => daemonRootOf(d) !== "other",
338
+ );
302
339
  }
303
340
 
304
341
  export function findDaemonByAddress(address: string): DaemonRecord | undefined {
@@ -646,8 +683,11 @@ export function isDaemonVerifiablyDead(
646
683
  * clear stale entries from previous sessions. Otherwise they reopen as tabs in
647
684
  * the web UI and clutter the shutdown prompt.
648
685
  *
649
- * A daemon is dropped when:
650
- * - its address no longer resolves to a workspace/catalog service, OR
686
+ * A daemon stamped by another root (all roots of a product share the file)
687
+ * is never touched. Otherwise it is dropped when:
688
+ * - it was stamped by this root and its address no longer resolves to a
689
+ * workspace/catalog service (an unstamped legacy record might belong to
690
+ * another root, so this alone never drops one), OR
651
691
  * - it is opctl-based (has captured containers or a discoverable dev-op) and
652
692
  * NONE of its candidate containers are currently running.
653
693
  *
@@ -687,7 +727,13 @@ function pruneAgainst(
687
727
  const pruned: string[] = [];
688
728
  for (const d of daemons) {
689
729
  try {
690
- if (!canResolveDaemonAddress(d)) {
730
+ const root = daemonRootOf(d);
731
+ // Another root's daemon: its services resolve in its workspaces, not
732
+ // ours, and its liveness is its own server's call.
733
+ if (root === "other") continue;
734
+ // An unresolvable address is only proof of staleness for a record
735
+ // this root wrote; an unstamped one may belong to another root.
736
+ if (root === "here" && !canResolveDaemonAddress(d)) {
691
737
  unregisterDaemon(d.address);
692
738
  pruned.push(d.address);
693
739
  continue;
@@ -91,7 +91,8 @@ function gitCapture(cmd: string, cwd?: string): string {
91
91
  }).trim();
92
92
  }
93
93
 
94
- function ghCloneUrl(ghSlug: string): string {
94
+ /** The HTTPS URL a managed clone of `ghSlug` is made from. */
95
+ export function ghCloneUrl(ghSlug: string): string {
95
96
  return `https://github.com/${ghSlug}.git`;
96
97
  }
97
98