@monster-orange/den 0.2.6 → 0.2.8

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 (37) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/bin/den +9 -0
  3. package/docs/distributions.md +205 -6
  4. package/package.json +1 -1
  5. package/service/install.sh +41 -2
  6. package/src/shared/agent-actor.ts +38 -3
  7. package/src/shared/config-error.ts +33 -0
  8. package/src/shared/den-config.ts +84 -5
  9. package/src/shared/environment-manager/catalog.ts +61 -3
  10. package/src/shared/environment-manager/default-workspace.ts +53 -14
  11. package/src/shared/environment-manager/git-remote.ts +49 -0
  12. package/src/shared/environment-manager/health.ts +51 -8
  13. package/src/shared/environment-manager/service-runner.ts +51 -5
  14. package/src/shared/environment-manager/workspace-manager.ts +69 -26
  15. package/src/shared/environment-manager/workspace-org.ts +279 -0
  16. package/src/shared/manifest/service-links.ts +20 -8
  17. package/src/shared/org-sources.ts +309 -0
  18. package/src/shared/types/index.ts +19 -2
  19. package/src/ui/cli/clone-plan.ts +374 -0
  20. package/src/ui/cli/flag-parser.ts +57 -3
  21. package/src/ui/cli/index.ts +346 -48
  22. package/src/ui/cli/org-command.ts +172 -0
  23. package/src/ui/cli/results-viewer.ts +5 -5
  24. package/src/ui/cli/table.ts +20 -0
  25. package/src/ui/cli/up-args.ts +15 -0
  26. package/src/ui/cli/validate-all.ts +171 -0
  27. package/src/ui/mcp/scope.ts +277 -0
  28. package/src/ui/mcp/server.ts +94 -9
  29. package/src/ui/mcp/tools.ts +101 -17
  30. package/src/ui/server/audit.ts +90 -3
  31. package/src/ui/server/daemon-reaper.ts +22 -4
  32. package/src/ui/server/github-service.ts +6 -1
  33. package/src/ui/server/index.ts +96 -5
  34. package/src/ui/server/public/panel.js +1 -1
  35. package/src/ui/server/readonly-routes.ts +12 -3
  36. package/src/ui/server/release-service.ts +40 -5
  37. package/src/ui/server/workspace-routes.ts +13 -6
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A distribution config that fails validation (ORG-1d's `orgs:` checks, for
3
+ * now). It is the user's to fix, not a crash: the CLI prints it and exits 1
4
+ * before doing anything, and the web server answers it as
5
+ * `{ ok: false, error, code: "config_invalid" }` with status 422.
6
+ *
7
+ * The message names config keys (`orgs.initech.repos`), never values a
8
+ * secret could hide in.
9
+ */
10
+ export class ConfigError extends Error {
11
+ readonly code = "config_invalid";
12
+
13
+ constructor(message: string) {
14
+ super(message);
15
+ this.name = "ConfigError";
16
+ }
17
+ }
18
+
19
+ export function isConfigError(e: unknown): e is ConfigError {
20
+ return e instanceof ConfigError;
21
+ }
22
+
23
+ /** The web server's status for a ConfigError: the request was fine, the config is not. */
24
+ export const CONFIG_ERROR_STATUS = 422;
25
+
26
+ /** The JSON body for a ConfigError (the `{ok, error, code}` frame R26 extends). */
27
+ export function configErrorBody(e: ConfigError): {
28
+ ok: false;
29
+ error: string;
30
+ code: "config_invalid";
31
+ } {
32
+ return { ok: false, error: e.message, code: e.code };
33
+ }
@@ -16,6 +16,8 @@ import type { Environment, ReleaseSourceConfig } from "./types/index.js";
16
16
  import { getDistRoot } from "./paths.js";
17
17
  import type { SecretsConfig } from "./secrets/index.js";
18
18
  import type { OrgConfig } from "./org.js";
19
+ import { validateOrgSources, type WorkOrgsConfig } from "./org-sources.js";
20
+ import { ConfigError, isConfigError } from "./config-error.js";
19
21
  import type { TestAccountsConfig } from "./auth/test-accounts.js";
20
22
 
21
23
  import { product } from "./product.js";
@@ -194,11 +196,13 @@ export interface DenConfig {
194
196
  /** GitHub org slug used to list org repos in the web UI (e.g. "Globex-Corp"). */
195
197
  github_org?: string;
196
198
  /**
197
- * Display names (and an optional colour) for the GitHub orgs repos belong
198
- * to, keyed by GitHub owner. Orgs are worked out from the catalog; this
199
- * only names them.
199
+ * Work orgs, keyed by org id: a display name and an optional colour. An
200
+ * entry with neither `sources` nor `repos` is the GitHub owner of its own
201
+ * name (ORG-1a). ORG-1d: `sources: [{host, owner|group}]` binds the org to
202
+ * git-host owners, and `repos: [...]` lists repos outright. See
203
+ * src/shared/org-sources.ts.
200
204
  */
201
- orgs?: Record<string, { label?: string; color?: string }>;
205
+ orgs?: WorkOrgsConfig;
202
206
  /** Derived-dependency-graph tuning (aliases + edge overrides). */
203
207
  graph?: GraphConfig;
204
208
  /**
@@ -412,6 +416,41 @@ export function requireGithubOrg(config: DenConfig = readDenConfig()): string {
412
416
  return org;
413
417
  }
414
418
 
419
+ let checked: { key: string; error: ConfigError | null } | null = null;
420
+
421
+ /**
422
+ * Throw the config's validation error (a ConfigError naming its keys), if it
423
+ * has one, without reading anything else. The CLI runs this before any
424
+ * command that touches orgs, the catalog, clones or releases, and the web
425
+ * server before every config-reading route, so a config that fails
426
+ * validation stops them all, not only those that happen to read `orgs:`.
427
+ * A missing file, or one that does not parse, is left to readDenConfig.
428
+ * Memoised on the file's mtime and size: the server checks per request.
429
+ */
430
+ export function assertDenConfigValid(): void {
431
+ let key: string;
432
+ try {
433
+ const st = fs.statSync(DEN_CONFIG_PATH);
434
+ key = `${st.mtimeMs}:${st.size}`;
435
+ } catch {
436
+ return;
437
+ }
438
+ if (checked?.key !== key) {
439
+ let error: ConfigError | null = null;
440
+ try {
441
+ const parsed = yaml.parse(fs.readFileSync(DEN_CONFIG_PATH, "utf-8")) as {
442
+ orgs?: unknown;
443
+ } | null;
444
+ validateOrgSources(parsed?.orgs, product().configFile);
445
+ } catch (e) {
446
+ if (!isConfigError(e)) return;
447
+ error = e;
448
+ }
449
+ checked = { key, error };
450
+ }
451
+ if (checked.error) throw new ConfigError(checked.error.message);
452
+ }
453
+
415
454
  /**
416
455
  * Read the effective den config, merging the shared deployment config with
417
456
  * per-engineer overrides from config/local/user.yml.
@@ -422,6 +461,9 @@ export function readDenConfig(): DenConfig {
422
461
  try {
423
462
  const raw = fs.readFileSync(DEN_CONFIG_PATH, "utf-8");
424
463
  const parsed = yaml.parse(raw) as Partial<DenConfig> | null;
464
+ // ORG-1d keys (orgs.<id>.sources / .repos) are checked here, where a bad
465
+ // value throws naming its key; configs without them are not touched.
466
+ validateOrgSources(parsed?.orgs, product().configFile);
425
467
  // `environments` may be malformed (scalar/object) in a hand-edited file —
426
468
  // fall back to the default set rather than throwing on .filter below.
427
469
  const envs = Array.isArray(parsed?.environments)
@@ -636,6 +678,8 @@ export interface ResolvedWorkspaceBlueprint {
636
678
  branch: string;
637
679
  autostartOff: string[];
638
680
  focus: boolean;
681
+ /** `org:` (ORG-1a): the org this workspace belongs to, when the blueprint names one. */
682
+ org?: string;
639
683
  }
640
684
 
641
685
  /** Which blueprint shape the distribution uses, and its workspaces. */
@@ -644,7 +688,7 @@ export interface WorkspaceBlueprints {
644
688
  workspaces: ResolvedWorkspaceBlueprint[];
645
689
  }
646
690
 
647
- const BLUEPRINT_KEYS = ["branch", "repos", "autostart_off"];
691
+ const BLUEPRINT_KEYS = ["branch", "repos", "autostart_off", "org"];
648
692
 
649
693
  function blueprintStrings(where: string, key: string, v: unknown): string[] {
650
694
  if (v === undefined) return [];
@@ -674,12 +718,15 @@ function resolveBlueprint(
674
718
  throw new Error(`${where}.branch must be a branch name`);
675
719
  if (bp.focus !== undefined && typeof bp.focus !== "boolean")
676
720
  throw new Error(`${where}.focus must be true or false`);
721
+ if (bp.org !== undefined && (typeof bp.org !== "string" || !bp.org.trim()))
722
+ throw new Error(`${where}.org must be an org id (a key of orgs:)`);
677
723
  return {
678
724
  name,
679
725
  repos: blueprintStrings(where, "repos", bp.repos),
680
726
  branch,
681
727
  autostartOff: blueprintStrings(where, "autostart_off", bp.autostart_off),
682
728
  focus: bp.focus === true,
729
+ ...(typeof bp.org === "string" ? { org: bp.org.trim() } : {}),
683
730
  };
684
731
  }
685
732
 
@@ -864,6 +911,38 @@ export function readReleaseConfig(): ReleaseConfig {
864
911
  return cfg;
865
912
  }
866
913
 
914
+ /**
915
+ * `release.org` as configured (den.yml, then user.yml), without the
916
+ * `github_org` fallback readReleaseConfig fills in. An explicit value is the
917
+ * release owner of every repo (REL-1); unset, each repo's own catalog owner is.
918
+ */
919
+ export function readExplicitReleaseOrg(): string | undefined {
920
+ const den = mergeReleaseLayer(
921
+ DEFAULT_RELEASE_CONFIG,
922
+ readDenConfig().release,
923
+ );
924
+ const org = mergeReleaseLayer(den, readUserConfig().release).org;
925
+ return typeof org === "string" && org.trim() ? org.trim() : undefined;
926
+ }
927
+
928
+ /**
929
+ * The GitHub owner a repo is released from (REL-1): an explicit
930
+ * `release.org` wins, then the repo's non-empty catalog owner, then
931
+ * `github_org`. "" when none is known.
932
+ */
933
+ export function releaseOwnerFor(opts: {
934
+ explicitOrg?: string;
935
+ catalogOwner?: string;
936
+ githubOrg?: string;
937
+ }): string {
938
+ return (
939
+ opts.explicitOrg?.trim() ||
940
+ opts.catalogOwner?.trim() ||
941
+ opts.githubOrg?.trim() ||
942
+ ""
943
+ );
944
+ }
945
+
867
946
  /**
868
947
  * Persist a personal release override to config/local/user.yml (gitignored).
869
948
  * Used by the Release tab's wave editor — team defaults stay in den.yml.
@@ -14,7 +14,13 @@ import * as path from "path";
14
14
  import * as yaml from "yaml";
15
15
  import type { ServiceCatalog, ServiceCatalogEntry } from "../types/index.js";
16
16
  import { getDistRoot } from "../paths.js";
17
- import { requireGithubOrg } from "../den-config.js";
17
+ import { readDenConfig, requireGithubOrg } from "../den-config.js";
18
+ import {
19
+ parseRemote,
20
+ repoSource,
21
+ type WorkOrgsConfig,
22
+ } from "../org-sources.js";
23
+ import { checkedRemote, isGitRemote } from "./git-remote.js";
18
24
 
19
25
  /** Fallback GitHub org when `config/den.yml` doesn't set `github_org`. */
20
26
 
@@ -55,7 +61,8 @@ export function upsertCatalogEntry(
55
61
  /**
56
62
  * GitHub slug to clone `repoName` from.
57
63
  *
58
- * The catalog's `gh` when set, else the conventional `<github_org>/<repo>` —
64
+ * The catalog's `gh` when set, else the conventional `<org>/<repo>`, where
65
+ * `<org>` is the workspace's resolved org (`github_org` when none is given) —
59
66
  * the same rule `seedDefaultWorkspace` uses. Entries written by the
60
67
  * dev-managed add flow carry `gh: ""`, so a repo can be IN the catalog and
61
68
  * still have no slug; deriving it (rather than refusing to clone) is what lets
@@ -64,10 +71,61 @@ export function upsertCatalogEntry(
64
71
  export function resolveRepoGhSlug(
65
72
  repoName: string,
66
73
  catalog: ServiceCatalog = readCatalog(),
74
+ /**
75
+ * The owner for a repo with no `gh`: the workspace's resolved org
76
+ * (`() => githubOrgFor(ws)`, ORG-1a). Called only when needed, so a repo
77
+ * with a slug never needs an org. Defaults to `github_org`.
78
+ */
79
+ owner: () => string = () => requireGithubOrg(),
67
80
  ): string {
68
81
  const gh = catalog[repoName]?.gh?.trim();
69
82
  if (gh) return gh;
70
- return `${requireGithubOrg()}/${repoName}`;
83
+ return `${owner()}/${repoName}`;
84
+ }
85
+
86
+ /**
87
+ * What `repoName` clones from (ORG-1d): a GitHub slug or a git URL (see
88
+ * git-remote.ts). The repo's source decides: a GitHub source gives its slug
89
+ * (`gh`, else `<owner>/<repo>`). A repo with no source and a `remote:` gives
90
+ * that remote, through plain git. Anything else is the legacy GitHub slug
91
+ * (resolveRepoGhSlug), so a catalog without `remote:` clones as before.
92
+ */
93
+ export function resolveRepoCloneSource(
94
+ repoName: string,
95
+ catalog: ServiceCatalog = readCatalog(),
96
+ owner: () => string = () => requireGithubOrg(),
97
+ orgs: WorkOrgsConfig | undefined = readDenConfig().orgs,
98
+ ): string {
99
+ const entry = catalog[repoName];
100
+ const gh = entry?.gh?.trim();
101
+ const remote = entry?.remote?.trim();
102
+ const src = repoSource(repoName, catalog, orgs ?? {});
103
+ if (src?.host === "github") {
104
+ if (gh) return gh;
105
+ const fromRemote = remote ? parseRemote(remote) : null;
106
+ return fromRemote?.host === "github"
107
+ ? `${fromRemote.owner}/${repoName}`
108
+ : `${src.owner ?? ""}/${repoName}`;
109
+ }
110
+ if (remote) return checkedRemote(repoName, remote);
111
+ if (src)
112
+ throw new Error(
113
+ `${repoName}: its org's source is on ${src.host}, which Den clones only ` +
114
+ `from a remote: — set ${repoName}.remote in the service catalog`,
115
+ );
116
+ return resolveRepoGhSlug(repoName, catalog, owner);
117
+ }
118
+
119
+ /**
120
+ * Whether a clone source may be written back as the catalog's `gh` (the
121
+ * empty-slug repair): only a GitHub slug, and only for an entry with no
122
+ * `gh` and no `remote` of its own.
123
+ */
124
+ export function needsGhRepair(
125
+ entry: ServiceCatalogEntry | undefined,
126
+ source: string,
127
+ ): boolean {
128
+ return !isGitRemote(source) && !entry?.gh?.trim() && !entry?.remote?.trim();
71
129
  }
72
130
 
73
131
  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,15 @@ 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
+ resolveWorkspaceOrg,
26
+ type OrgContext,
27
+ } from "./workspace-org.js";
28
+ import { orgHasGithub } from "../org-sources.js";
22
29
  import type {
23
30
  ServiceCatalog,
24
31
  WorkspaceConfig,
@@ -47,10 +54,6 @@ export function defaultWorkspaceRepos(): string[] {
47
54
  return singleBlueprint()?.repos ?? [];
48
55
  }
49
56
 
50
- function org(): string {
51
- return requireGithubOrg();
52
- }
53
-
54
57
  function blueprintServices(
55
58
  bp: ResolvedWorkspaceBlueprint,
56
59
  repos: string[] = bp.repos,
@@ -70,14 +73,47 @@ function blueprintServices(
70
73
  return services;
71
74
  }
72
75
 
76
+ /**
77
+ * The clone slug seeding would give each repo the catalog has no `gh` for:
78
+ * `<org>/<repo>` with its workspace's resolved org. A repo in several
79
+ * workspaces takes the first one's. Reads only; throws when a repo needs an
80
+ * org and none resolves (no org anywhere and no `github_org`).
81
+ */
82
+ export function catalogFill(
83
+ workspaces: { name: string; repos: string[] }[],
84
+ catalog: ServiceCatalog = readCatalog(),
85
+ ctx: OrgContext = readOrgContext(),
86
+ ): Map<string, string> {
87
+ const fill = new Map<string, string>();
88
+ for (const ws of workspaces) {
89
+ // ORG-1d: an org with no GitHub source has no slug to give; its repos
90
+ // clone from their catalog `remote:`.
91
+ const org = resolveWorkspaceOrg(ws.name, ctx).org;
92
+ if (org && !orgHasGithub(org, ctx.config.orgs)) continue;
93
+ for (const repo of ws.repos) {
94
+ if (catalog[repo]?.gh || catalog[repo]?.remote || fill.has(repo))
95
+ continue;
96
+ fill.set(repo, `${githubOrgFor(ws.name, ctx)}/${repo}`);
97
+ }
98
+ }
99
+ return fill;
100
+ }
101
+
102
+ /**
103
+ * Give each blueprint repo with no clone URL `<org>/<repo>`, where `<org>` is
104
+ * its workspace's resolved org (ORG-1a: the blueprint's `org:`, else the
105
+ * catalog owners, else `github_org`), so a client workspace never clones
106
+ * from the default org on a fresh machine. The org is resolved only for a
107
+ * repo that needs one, and before anything is written: an unresolvable org
108
+ * throws with the catalog untouched.
109
+ */
73
110
  function fillCatalog(
74
- repos: string[],
111
+ workspaces: { name: string; repos: string[] }[],
75
112
  catalog: ServiceCatalog = readCatalog(),
76
113
  ): void {
77
- const o = org();
78
- for (const repo of repos) {
79
- if (!catalog[repo]?.gh) catalog[repo] = { gh: `${o}/${repo}` };
80
- }
114
+ const fill = catalogFill(workspaces, catalog);
115
+ if (fill.size === 0) return;
116
+ for (const [repo, gh] of fill) catalog[repo] = { gh };
81
117
  writeCatalog(catalog);
82
118
  }
83
119
 
@@ -178,6 +214,9 @@ export function seedDefaultWorkspace(): string[] {
178
214
  if (!bp) return [];
179
215
  const repos = bp.repos;
180
216
 
217
+ // The catalog first: it is what can fail (no github_org), and a failure
218
+ // must leave the workspaces as they were, not reset and uncloneable.
219
+ fillCatalog([{ name: "default", repos }]);
181
220
  const file = readWorkspacesFile();
182
221
  file.workspaces.default = {
183
222
  ...(file.workspaces.default ?? {}),
@@ -189,7 +228,6 @@ export function seedDefaultWorkspace(): string[] {
189
228
  file.focus = "default";
190
229
  }
191
230
  writeWorkspacesFile(file);
192
- fillCatalog(repos);
193
231
  return repos;
194
232
  }
195
233
 
@@ -202,6 +240,7 @@ export function seedDefaultWorkspace(): string[] {
202
240
  export function seedBlueprintWorkspaces(): { name: string; repos: string[] }[] {
203
241
  const bps = blueprintWorkspaces();
204
242
  if (bps.length === 0) return [];
243
+ fillCatalog(bps);
205
244
  const file = readWorkspacesFile();
206
245
  for (const bp of bps) {
207
246
  const prev: WorkspaceConfig | undefined = file.workspaces[bp.name];
@@ -223,7 +262,6 @@ export function seedBlueprintWorkspaces(): { name: string; repos: string[] }[] {
223
262
  file.active = [...names, ...kept.filter((n) => !names.includes(n))];
224
263
  file.focus = (bps.find((b) => b.focus) ?? bps[0]).name;
225
264
  writeWorkspacesFile(file);
226
- fillCatalog([...new Set(bps.flatMap((b) => b.repos))]);
227
265
  return bps.map((b) => ({ name: b.name, repos: [...b.repos] }));
228
266
  }
229
267
 
@@ -277,7 +315,8 @@ export function topUpDefaultWorkspace(): string[] {
277
315
 
278
316
  def.seeded_repos = [...repos];
279
317
  writeWorkspacesFile(file);
280
- if (missing.length > 0) fillCatalog(missing, catalog);
318
+ if (missing.length > 0)
319
+ fillCatalog([{ name: "default", repos: missing }], catalog);
281
320
  return missing;
282
321
  }
283
322
 
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What a managed clone is made from (ORG-1d): a GitHub slug (`owner/repo`,
3
+ * cloned from github.com with the user's gh as credential helper) or a git
4
+ * URL from the catalog's `remote:` (cloned through plain git and the user's
5
+ * own credential helper, never Den's or gh's: SEC-0).
6
+ *
7
+ * Clone commands run through a shell, so a remote must pass REMOTE_RE: a URL
8
+ * with a known transport, scp-style `user@host:path`, or an absolute path,
9
+ * and nothing a shell or git would read as a command (`ext::`, quotes, `$`,
10
+ * spaces, a leading `-`).
11
+ */
12
+
13
+ /** A GitHub slug, as the clone guards accept it. */
14
+ export const SAFE_SLUG_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
15
+
16
+ const REMOTE_RE =
17
+ /^(?:(?:https?|ssh|git|file):\/\/[A-Za-z0-9._~@:%+/-]+|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[A-Za-z0-9._~+/-]+|\/[A-Za-z0-9._~@+/-]+)$/;
18
+
19
+ /** True when `spec` is a git URL rather than a GitHub slug. */
20
+ export function isGitRemote(spec: string): boolean {
21
+ return !SAFE_SLUG_RE.test(spec);
22
+ }
23
+
24
+ /** True when `remote` is a git URL Den will hand to git. */
25
+ export function isSafeRemote(remote: string): boolean {
26
+ return REMOTE_RE.test(remote) && !remote.includes("..");
27
+ }
28
+
29
+ /**
30
+ * `remote` for display: any user info (`https://user:token@host/…`) masked.
31
+ * Remotes can carry a token, so one is never printed as written.
32
+ */
33
+ export function redactRemote(remote: string): string {
34
+ return remote.replace(/^([a-z+]+:\/\/)[^@/]+@/i, "$1***@");
35
+ }
36
+
37
+ /**
38
+ * The checked remote for `repo`, or a throw naming its catalog key (and not
39
+ * its value, which may hold a token).
40
+ */
41
+ export function checkedRemote(repo: string, remote: string): string {
42
+ const r = remote.trim();
43
+ if (!isSafeRemote(r))
44
+ throw new Error(
45
+ `service catalog: ${repo}.remote is not a git URL Den can clone ` +
46
+ `(https://, ssh://, git://, file://, user@host:path or an absolute path)`,
47
+ );
48
+ return r;
49
+ }
@@ -230,6 +230,28 @@ function trustContext(): { context: tls.SecureContext; agent: https.Agent } {
230
230
  return _trust;
231
231
  }
232
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
+
233
255
  /**
234
256
  * HTTPS check. Connects to the c-ares-resolved IP (see above) but keeps the
235
257
  * hostname for SNI and certificate verification; rewriting the URL to the IP,
@@ -276,11 +298,23 @@ async function pingHttps(parsed: URL, timeoutMs: number): Promise<PingResult> {
276
298
  },
277
299
  (res) => {
278
300
  const status = res.statusCode ?? 0;
279
- res.resume(); // discard the body
280
- done({
281
- ok: status < 300 || (status >= 400 && status < 500),
282
- 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();
283
313
  });
314
+ const finish = () => done(statusResult(status, body));
315
+ res.on("end", finish);
316
+ res.on("close", finish);
317
+ res.on("error", finish);
284
318
  },
285
319
  );
286
320
  const timer = setTimeout(() => {
@@ -334,11 +368,20 @@ async function pingOnce(
334
368
  // (e.g. VPN / /etc/hosts) returning 301 on port 80 for services that actually run
335
369
  // on a different port — the previous `< 500` check showed those as green.
336
370
  // 5xx = unhealthy (service errored).
337
- const ok = res.status < 300 || (res.status >= 400 && res.status < 500);
338
- // Discard the body: only the status matters, and unconsumed bodies pin
339
- // 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
+ }
340
383
  void res.body?.cancel().catch(() => {});
341
- return { ok, status: res.status };
384
+ return { ok: statusOk(res.status), status: res.status };
342
385
  } catch (e: unknown) {
343
386
  clearTimeout(timer);
344
387
  const err = e as Error;
@@ -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;