@intentius/behold 0.7.0 → 0.8.0

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 (76) hide show
  1. package/AGENTS.md +9 -2
  2. package/README.md +12 -2
  3. package/demos.json +8 -0
  4. package/dist/cli.js +750 -132
  5. package/example-argo-estate/README.md +46 -0
  6. package/example-argo-estate/app-a/chant.config.ts +10 -0
  7. package/example-argo-estate/app-a/manifests/app.yaml +54 -0
  8. package/example-argo-estate/app-a/package.json +13 -0
  9. package/example-argo-estate/app-a/src/app.ts +35 -0
  10. package/example-argo-estate/app-a/tsconfig.json +1 -0
  11. package/example-argo-estate/app-b/chant.config.ts +9 -0
  12. package/example-argo-estate/app-b/manifests/app.yaml +54 -0
  13. package/example-argo-estate/app-b/package.json +13 -0
  14. package/example-argo-estate/app-b/src/app.ts +34 -0
  15. package/example-argo-estate/app-b/tsconfig.json +1 -0
  16. package/example-argo-estate/control-plane/chant.config.ts +14 -0
  17. package/example-argo-estate/control-plane/package.json +13 -0
  18. package/example-argo-estate/control-plane/src/argo.ts +69 -0
  19. package/example-argo-estate/control-plane/tsconfig.json +1 -0
  20. package/example-argo-estate/package-lock.json +1949 -0
  21. package/example-argo-estate/package.json +10 -0
  22. package/example-flux-estate/README.md +13 -8
  23. package/example-flux-estate/app-a/package.json +2 -2
  24. package/example-flux-estate/app-b/package.json +2 -2
  25. package/example-flux-estate/control-plane/package.json +2 -2
  26. package/example-flux-estate/control-plane/src/flux.ts +6 -0
  27. package/example-flux-estate/package-lock.json +17 -17
  28. package/example-k8s/README.md +16 -2
  29. package/example-k8s/base/deployment.yaml +21 -0
  30. package/example-k8s/base/kustomization.yaml +3 -0
  31. package/example-k8s/base/service.yaml +12 -0
  32. package/example-k8s/chant.config.ts +10 -0
  33. package/example-k8s/overlays/dev/kustomization.yaml +6 -0
  34. package/example-k8s/package-lock.json +18 -18
  35. package/example-k8s/package.json +3 -3
  36. package/example-writes/package-lock.json +14 -14
  37. package/example-writes/package.json +3 -3
  38. package/package.json +3 -2
  39. package/web/app.js +350 -22
  40. package/web/icons/cncf/argo.svg +1 -0
  41. package/web/icons/cncf/flux.svg +1 -0
  42. package/web/icons/cncf/helm.svg +1 -0
  43. package/web/icons/k8s/c-role.svg +1 -0
  44. package/web/icons/k8s/cm.svg +1 -0
  45. package/web/icons/k8s/crb.svg +1 -0
  46. package/web/icons/k8s/crd.svg +1 -0
  47. package/web/icons/k8s/cronjob.svg +1 -0
  48. package/web/icons/k8s/deploy.svg +1 -0
  49. package/web/icons/k8s/ds.svg +1 -0
  50. package/web/icons/k8s/ep.svg +1 -0
  51. package/web/icons/k8s/group.svg +1 -0
  52. package/web/icons/k8s/hpa.svg +1 -0
  53. package/web/icons/k8s/ing.svg +1 -0
  54. package/web/icons/k8s/job.svg +1 -0
  55. package/web/icons/k8s/limits.svg +1 -0
  56. package/web/icons/k8s/netpol.svg +1 -0
  57. package/web/icons/k8s/ns.svg +1 -0
  58. package/web/icons/k8s/pod.svg +1 -0
  59. package/web/icons/k8s/psp.svg +1 -0
  60. package/web/icons/k8s/pv.svg +1 -0
  61. package/web/icons/k8s/pvc.svg +1 -0
  62. package/web/icons/k8s/quota.svg +1 -0
  63. package/web/icons/k8s/rb.svg +1 -0
  64. package/web/icons/k8s/role.svg +1 -0
  65. package/web/icons/k8s/rs.svg +1 -0
  66. package/web/icons/k8s/sa.svg +1 -0
  67. package/web/icons/k8s/sc.svg +1 -0
  68. package/web/icons/k8s/secret.svg +1 -0
  69. package/web/icons/k8s/sts.svg +1 -0
  70. package/web/icons/k8s/svc.svg +1 -0
  71. package/web/icons/k8s/user.svg +1 -0
  72. package/web/icons/k8s/vol.svg +1 -0
  73. package/web/index.html +192 -84
  74. package/web/layout-store.js +118 -0
  75. package/web/layout-store.test.js +163 -0
  76. package/web/theme.js +58 -6
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // src/cli.ts
2
- import { resolve as resolve3, dirname as dirname5, join as join13, relative as relative2, sep } from "node:path";
2
+ import { resolve as resolve4, dirname as dirname6, join as join14, relative as relative3, sep } from "node:path";
3
3
  import { realpathSync, existsSync as existsSync10, cpSync } from "node:fs";
4
4
  import { spawnSync as spawnSync2 } from "node:child_process";
5
- import { fileURLToPath as fileURLToPath3 } from "node:url";
5
+ import { fileURLToPath as fileURLToPath5 } from "node:url";
6
6
 
7
7
  // src/server.ts
8
8
  import { Hono } from "hono";
@@ -87,11 +87,11 @@ function contextBindsCluster(context, clusterName) {
87
87
  import { streamSSE } from "hono/streaming";
88
88
  import { serveStatic } from "@hono/node-server/serve-static";
89
89
  import { serve } from "@hono/node-server";
90
- import { fileURLToPath } from "node:url";
90
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
91
91
  import { execFile as execFile4 } from "node:child_process";
92
92
  import { promisify as promisify4 } from "node:util";
93
- import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
94
- import { dirname as dirname3, join as join10, relative, resolve as resolve2 } from "node:path";
93
+ import { existsSync as existsSync9, readFileSync as readFileSync9 } from "node:fs";
94
+ import { dirname as dirname4, join as join11, relative, resolve as resolve2 } from "node:path";
95
95
 
96
96
  // src/recents.ts
97
97
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
@@ -125,8 +125,9 @@ function addRecent(dir, now = () => (/* @__PURE__ */ new Date()).toISOString())
125
125
 
126
126
  // src/chant.ts
127
127
  import { spawn } from "node:child_process";
128
- import { existsSync as existsSync3 } from "node:fs";
128
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
129
129
  import { createRequire } from "node:module";
130
+ import { fileURLToPath } from "node:url";
130
131
  import { dirname as dirname2, join as join3, resolve } from "node:path";
131
132
 
132
133
  // src/project.ts
@@ -135,7 +136,7 @@ import { join as join2 } from "node:path";
135
136
  import { pathToFileURL } from "node:url";
136
137
  var CONFIG_NAMES = ["chant.config.ts", "chant.config.mts", "chant.config.js", "chant.config.mjs"];
137
138
  var BEHOLD_CONFIG_NAME = ".behold.json";
138
- function configPath(projectDir) {
139
+ function chantConfigPath(projectDir) {
139
140
  for (const name of CONFIG_NAMES) {
140
141
  const p = join2(projectDir, name);
141
142
  if (existsSync2(p)) return p;
@@ -222,7 +223,7 @@ function parseStringLiteral(content, key) {
222
223
  return m?.[1];
223
224
  }
224
225
  async function detectProject(projectDir) {
225
- const path = configPath(projectDir);
226
+ const path = chantConfigPath(projectDir);
226
227
  if (!path) return { environments: [], lexicons: [] };
227
228
  try {
228
229
  const mod = await import(pathToFileURL(path).href);
@@ -253,11 +254,30 @@ function loadBeholdConfig(projectDir) {
253
254
  try {
254
255
  const raw = JSON.parse(readFileSync2(path, "utf8"));
255
256
  const tiers = readTiers(raw);
256
- return tiers ? { tiers } : {};
257
+ const members = Array.isArray(raw.members) ? raw.members.filter((m) => typeof m === "string" && !!m) : [];
258
+ return { ...tiers ? { tiers } : {}, ...members.length ? { members } : {} };
257
259
  } catch {
258
260
  return {};
259
261
  }
260
262
  }
263
+ function readWorkspaces(projectDir) {
264
+ try {
265
+ const raw = JSON.parse(readFileSync2(join2(projectDir, "package.json"), "utf8"));
266
+ const ws = Array.isArray(raw.workspaces) ? raw.workspaces : raw.workspaces?.packages;
267
+ return Array.isArray(ws) ? ws.filter((w) => typeof w === "string") : [];
268
+ } catch {
269
+ return [];
270
+ }
271
+ }
272
+ function detectProjectShape(projectDir) {
273
+ const configFile = chantConfigPath(projectDir);
274
+ if (configFile) return { kind: "project", configFile };
275
+ const declared = loadBeholdConfig(projectDir).members;
276
+ const from = declared ? "behold-config" : "workspaces";
277
+ const members = (declared ?? readWorkspaces(projectDir)).filter((m) => !!chantConfigPath(join2(projectDir, m)));
278
+ if (members.length) return { kind: "estate", members, membersFrom: from };
279
+ return { kind: "none" };
280
+ }
261
281
 
262
282
  // node_modules/@intentius/chant/src/yaml.ts
263
283
  function parseYAML(content) {
@@ -545,19 +565,18 @@ function graphFlags(opts) {
545
565
  if (opts.overlay) flags.push("--overlay");
546
566
  return flags;
547
567
  }
548
- function chantBinFrom(req) {
568
+ function packageManifestFrom(req, name) {
549
569
  let entry;
550
570
  try {
551
- entry = req.resolve("@intentius/chant");
571
+ entry = req.resolve(name);
552
572
  } catch {
553
573
  return void 0;
554
574
  }
555
575
  let dir = dirname2(entry);
556
576
  for (; ; ) {
557
- const manifest = join3(dir, "package.json");
558
577
  try {
559
- const pkg = createRequire(import.meta.url)(manifest);
560
- if (pkg.name === "@intentius/chant") return join3(dir, pkg.bin?.chant ?? "bin/chant");
578
+ const pkg = createRequire(import.meta.url)(join3(dir, "package.json"));
579
+ if (pkg.name === name) return { root: dir, manifest: pkg };
561
580
  } catch {
562
581
  }
563
582
  const parent = dirname2(dir);
@@ -566,14 +585,52 @@ function chantBinFrom(req) {
566
585
  }
567
586
  return void 0;
568
587
  }
569
- function chantBin(projectDir) {
588
+ function chantPackageFrom(req) {
589
+ const found = packageManifestFrom(req, "@intentius/chant");
590
+ if (!found) return void 0;
591
+ const { root, manifest } = found;
592
+ return { root, bin: join3(root, manifest.bin?.chant ?? "bin/chant"), ...manifest.version ? { version: manifest.version } : {} };
593
+ }
594
+ function resolveLexicons(projectDir, lexicons) {
595
+ const req = createRequire(join3(resolve(projectDir), "noop.js"));
596
+ return lexicons.map((lexicon) => {
597
+ const pkg = `@intentius/chant-lexicon-${lexicon}`;
598
+ const found = packageManifestFrom(req, pkg);
599
+ return { lexicon, pkg, installed: !!found, ...found?.manifest.version ? { version: found.manifest.version } : {} };
600
+ });
601
+ }
602
+ function resolveChant(projectDir) {
603
+ const own = chantPackageFrom(createRequire(import.meta.url));
570
604
  if (projectDir) {
571
- const fromProject = chantBinFrom(createRequire(join3(resolve(projectDir), "noop.js")));
572
- if (fromProject) return fromProject;
605
+ const fromProject = chantPackageFrom(createRequire(join3(resolve(projectDir), "noop.js")));
606
+ if (fromProject) return { source: fromProject.root === own?.root ? "behold" : "project", ...fromProject };
607
+ }
608
+ if (own) return { source: "behold", ...own };
609
+ return { source: "path", bin: "chant" };
610
+ }
611
+ function chantFloor() {
612
+ try {
613
+ const here = dirname2(fileURLToPath(import.meta.url));
614
+ const pkg = JSON.parse(readFileSync3(join3(here, "..", "package.json"), "utf8"));
615
+ return pkg.dependencies?.["@intentius/chant"]?.replace(/^[^0-9]*/, "") || void 0;
616
+ } catch {
617
+ return void 0;
618
+ }
619
+ }
620
+ function meetsFloor(version, floor) {
621
+ if (!version || !floor) return true;
622
+ const parts = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10));
623
+ const a = parts(version);
624
+ const b = parts(floor);
625
+ if (a.some(Number.isNaN) || b.some(Number.isNaN)) return true;
626
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
627
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
628
+ if (d !== 0) return d > 0;
573
629
  }
574
- const own = chantBinFrom(createRequire(import.meta.url));
575
- if (own) return own;
576
- return "chant";
630
+ return true;
631
+ }
632
+ function chantBin(projectDir) {
633
+ return resolveChant(projectDir).bin;
577
634
  }
578
635
  function runChantRaw(args, projectDir, envOverride) {
579
636
  return new Promise((resolvePromise, reject) => {
@@ -798,7 +855,7 @@ function mergeClusterRoot(ir, clusterIr, running) {
798
855
  }
799
856
 
800
857
  // src/helm-releases.ts
801
- import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3, statSync } from "node:fs";
858
+ import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4, statSync } from "node:fs";
802
859
  import { join as join4 } from "node:path";
803
860
  var RELEASE_KEY = /^release\/([^/]+)\/(.+)$/;
804
861
  function declaredChartNames(ir) {
@@ -839,7 +896,7 @@ function discoverReleaseUnits(projectDir) {
839
896
  for (const file of files) {
840
897
  let content;
841
898
  try {
842
- content = readFileSync3(file, "utf8");
899
+ content = readFileSync4(file, "utf8");
843
900
  } catch {
844
901
  continue;
845
902
  }
@@ -892,7 +949,7 @@ function synthesizeHelmReleases(ir, observed, owners) {
892
949
 
893
950
  // src/gh-run.ts
894
951
  import { spawn as spawn2 } from "node:child_process";
895
- import { readdirSync as readdirSync2, readFileSync as readFileSync4 } from "node:fs";
952
+ import { readdirSync as readdirSync2, readFileSync as readFileSync5 } from "node:fs";
896
953
  import { join as join5 } from "node:path";
897
954
 
898
955
  // src/ci-run.ts
@@ -947,19 +1004,19 @@ function finishPipelineProgress(state, exitCode) {
947
1004
  }
948
1005
 
949
1006
  // src/gh-run.ts
950
- var defaultGhExec = (args) => new Promise((resolve4) => {
1007
+ var defaultGhExec = (args) => new Promise((resolve5) => {
951
1008
  let out = "";
952
1009
  let proc;
953
1010
  try {
954
1011
  proc = spawn2("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
955
1012
  } catch {
956
- resolve4({ code: 127, out: "" });
1013
+ resolve5({ code: 127, out: "" });
957
1014
  return;
958
1015
  }
959
1016
  proc.stdout.on("data", (d) => out += d);
960
1017
  proc.stderr.on("data", (d) => out += d);
961
- proc.on("error", () => resolve4({ code: 127, out }));
962
- proc.on("close", (code) => resolve4({ code: code ?? 1, out }));
1018
+ proc.on("error", () => resolve5({ code: 127, out }));
1019
+ proc.on("close", (code) => resolve5({ code: code ?? 1, out }));
963
1020
  });
964
1021
  async function ghReady(exec = defaultGhExec) {
965
1022
  const { code, out } = await exec(["auth", "status"]);
@@ -993,7 +1050,7 @@ function pickWorkflow(projectDir, pipeline) {
993
1050
  for (const f of files) {
994
1051
  let text;
995
1052
  try {
996
- text = readFileSync4(join5(dir, f), "utf8");
1053
+ text = readFileSync5(join5(dir, f), "utf8");
997
1054
  } catch {
998
1055
  continue;
999
1056
  }
@@ -1309,13 +1366,22 @@ var FLUX_SOURCE_KINDS = /* @__PURE__ */ new Set([
1309
1366
  "K8s::Flux::HelmChart",
1310
1367
  "K8s::Flux::Bucket"
1311
1368
  ]);
1312
- function sourceRefOf(refObj, referrer) {
1369
+ function sourceRefOf(refObj, referrer, defaultKind) {
1313
1370
  const ref = rec(refObj);
1314
- const kind = str2(ref?.kind);
1371
+ const kind = str2(ref?.kind) ?? defaultKind;
1315
1372
  const name = str2(ref?.name);
1316
1373
  if (!kind || !name) return void 0;
1317
1374
  return { kind, name, namespace: str2(ref?.namespace) ?? namespaceOf(referrer) };
1318
1375
  }
1376
+ function namedRefOf(refObj, referrer) {
1377
+ const ref = rec(refObj);
1378
+ const name = str2(ref?.name);
1379
+ if (!name) return void 0;
1380
+ return { name, namespace: str2(ref?.namespace) ?? namespaceOf(referrer) };
1381
+ }
1382
+ function list(v) {
1383
+ return Array.isArray(v) ? v : [];
1384
+ }
1319
1385
  function helmReleaseSourceRef(n) {
1320
1386
  return rec(rec(rec(n.attrs?.spec)?.chart)?.spec)?.sourceRef;
1321
1387
  }
@@ -1343,19 +1409,38 @@ function deriveK8sEdges(nodes) {
1343
1409
  if (from !== to) out.push({ from, to, kind: "ref", viaAttr, inferred: true });
1344
1410
  };
1345
1411
  const workloads = k8s.filter((n) => WORKLOAD_KINDS.has(n.kind));
1346
- const servicesByNsName = /* @__PURE__ */ new Map();
1347
- const sourcesByKindNsName = /* @__PURE__ */ new Map();
1412
+ const byKindNsName = /* @__PURE__ */ new Map();
1413
+ const appProjectsByName = /* @__PURE__ */ new Map();
1348
1414
  for (const n of k8s) {
1349
1415
  const name = nameOf(n);
1350
1416
  if (!name) continue;
1351
- if (n.kind === "K8s::Core::Service") servicesByNsName.set(`${namespaceOf(n)}/${name}`, n);
1352
- if (FLUX_SOURCE_KINDS.has(n.kind)) sourcesByKindNsName.set(`${shortKind(n.kind)}/${namespaceOf(n)}/${name}`, n);
1417
+ byKindNsName.set(`${n.kind}\0${namespaceOf(n)}\0${name}`, n);
1418
+ if (n.kind === "K8s::Argo::AppProject") {
1419
+ const same = appProjectsByName.get(name) ?? [];
1420
+ same.push(n);
1421
+ appProjectsByName.set(name, same);
1422
+ }
1353
1423
  }
1354
- const addSourceRef = (n, refObj, viaAttr) => {
1355
- const ref = sourceRefOf(refObj, n);
1424
+ const lookup = (kind, namespace, name) => byKindNsName.get(`${kind}\0${namespace}\0${name}`);
1425
+ const addFluxRef = (n, refObj, viaAttr, allowed, defaultKind) => {
1426
+ const ref = sourceRefOf(refObj, n, defaultKind);
1427
+ if (!ref || ref.name === "*") return;
1428
+ const kind = `K8s::Flux::${ref.kind}`;
1429
+ if (allowed && !allowed.has(kind)) return;
1430
+ const target = lookup(kind, ref.namespace, ref.name);
1431
+ if (target) add(n.id, target.id, viaAttr);
1432
+ };
1433
+ const addSourceRef = (n, refObj, viaAttr, defaultKind) => addFluxRef(n, refObj, viaAttr, FLUX_SOURCE_KINDS, defaultKind);
1434
+ const addNamedRef = (n, refObj, kind, viaAttr) => {
1435
+ const ref = namedRefOf(refObj, n);
1356
1436
  if (!ref) return;
1357
- const source = sourcesByKindNsName.get(`${ref.kind}/${ref.namespace}/${ref.name}`);
1358
- if (source) add(n.id, source.id, viaAttr);
1437
+ const target = lookup(kind, ref.namespace, ref.name);
1438
+ if (target) add(n.id, target.id, viaAttr);
1439
+ };
1440
+ const addProject = (n, project, viaAttr) => {
1441
+ const candidates = project ? appProjectsByName.get(project) ?? [] : [];
1442
+ const target = candidates.length === 1 ? candidates[0] : candidates.find((p) => namespaceOf(p) === namespaceOf(n));
1443
+ if (target) add(n.id, target.id, viaAttr);
1359
1444
  };
1360
1445
  for (const n of k8s) {
1361
1446
  if (n.kind === "K8s::Core::Service") {
@@ -1370,7 +1455,7 @@ function deriveK8sEdges(nodes) {
1370
1455
  }
1371
1456
  if (n.kind === "K8s::Networking::Ingress") {
1372
1457
  for (const svcName of ingressBackendServices(n)) {
1373
- const svc = servicesByNsName.get(`${namespaceOf(n)}/${svcName}`);
1458
+ const svc = lookup("K8s::Core::Service", namespaceOf(n), svcName);
1374
1459
  if (svc) add(n.id, svc.id, "ingress backend");
1375
1460
  }
1376
1461
  continue;
@@ -1388,10 +1473,36 @@ function deriveK8sEdges(nodes) {
1388
1473
  }
1389
1474
  if (n.kind === "K8s::Flux::Kustomization") {
1390
1475
  addSourceRef(n, rec(n.attrs?.spec)?.sourceRef, "sourceRef");
1476
+ for (const dep of list(rec(n.attrs?.spec)?.dependsOn)) {
1477
+ addNamedRef(n, dep, "K8s::Flux::Kustomization", "dependsOn");
1478
+ }
1391
1479
  continue;
1392
1480
  }
1393
1481
  if (n.kind === "K8s::Flux::HelmRelease") {
1394
1482
  addSourceRef(n, helmReleaseSourceRef(n), "chart sourceRef");
1483
+ continue;
1484
+ }
1485
+ if (n.kind === "K8s::Flux::ImagePolicy") {
1486
+ addNamedRef(n, rec(n.attrs?.spec)?.imageRepositoryRef, "K8s::Flux::ImageRepository", "imageRepositoryRef");
1487
+ continue;
1488
+ }
1489
+ if (n.kind === "K8s::Flux::ImageUpdateAutomation") {
1490
+ addSourceRef(n, rec(n.attrs?.spec)?.sourceRef, "sourceRef", "GitRepository");
1491
+ continue;
1492
+ }
1493
+ if (n.kind === "K8s::Flux::Alert") {
1494
+ addNamedRef(n, rec(n.attrs?.spec)?.providerRef, "K8s::Flux::Provider", "providerRef");
1495
+ for (const src of list(rec(n.attrs?.spec)?.eventSources)) {
1496
+ addFluxRef(n, src, "eventSource");
1497
+ }
1498
+ continue;
1499
+ }
1500
+ if (n.kind === "K8s::Argo::Application") {
1501
+ addProject(n, str2(rec(n.attrs?.spec)?.project), "project");
1502
+ continue;
1503
+ }
1504
+ if (n.kind === "K8s::Argo::ApplicationSet") {
1505
+ addProject(n, str2(rec(rec(rec(n.attrs?.spec)?.template)?.spec)?.project), "template project");
1395
1506
  }
1396
1507
  }
1397
1508
  return out;
@@ -1883,6 +1994,13 @@ function metaStr(node, key) {
1883
1994
  const v = meta?.[key];
1884
1995
  return typeof v === "string" ? v : void 0;
1885
1996
  }
1997
+ function destinationNamespace(n) {
1998
+ if (n.kind !== "K8s::Argo::Application") return void 0;
1999
+ const spec = n.attrs?.spec;
2000
+ const dest = spec?.destination;
2001
+ const ns = dest?.namespace;
2002
+ return typeof ns === "string" && ns.length > 0 ? ns : void 0;
2003
+ }
1886
2004
  function declaredNamespaces(nodes) {
1887
2005
  const out = /* @__PURE__ */ new Set();
1888
2006
  for (const n of nodes) {
@@ -1893,6 +2011,8 @@ function declaredNamespaces(nodes) {
1893
2011
  }
1894
2012
  const ns = metaStr(n, "namespace");
1895
2013
  if (ns) out.add(ns);
2014
+ const dest = destinationNamespace(n);
2015
+ if (dest) out.add(dest);
1896
2016
  }
1897
2017
  return out;
1898
2018
  }
@@ -2223,9 +2343,9 @@ function enrichedRefs(ir, aws, byId) {
2223
2343
  const producersByName = /* @__PURE__ */ new Map();
2224
2344
  for (const ex of ir.exports ?? []) {
2225
2345
  if (!ex.node || !byId.has(ex.node)) continue;
2226
- const list = producersByName.get(ex.name) ?? producersByName.set(ex.name, []).get(ex.name);
2227
- if (isExample(byId.get(ex.node))) list.push(ex.node);
2228
- else list.unshift(ex.node);
2346
+ const list3 = producersByName.get(ex.name) ?? producersByName.set(ex.name, []).get(ex.name);
2347
+ if (isExample(byId.get(ex.node))) list3.push(ex.node);
2348
+ else list3.unshift(ex.node);
2229
2349
  }
2230
2350
  const exportNames = [...producersByName.keys()].sort((a, b) => b.length - a.length);
2231
2351
  const bridge = (paramId, producers) => {
@@ -2713,7 +2833,83 @@ function summarizePlan(plan, byComponent, nonResource) {
2713
2833
  }
2714
2834
 
2715
2835
  // src/render.ts
2716
- import { layoutIr, layoutArchitecture, renderSvg, cardSizes } from "@intentius/pinhole";
2836
+ import { layoutIr, layoutArchitecture, renderSvg, cardSizes, registerPack } from "@intentius/pinhole";
2837
+
2838
+ // src/icon-packs.ts
2839
+ import { readFileSync as readFileSync6 } from "node:fs";
2840
+ import { dirname as dirname3, join as join7 } from "node:path";
2841
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
2842
+ var ICON_ROOT = join7(dirname3(fileURLToPath2(import.meta.url)), "..", "web", "icons");
2843
+ var cache = /* @__PURE__ */ new Map();
2844
+ function unprefix(body) {
2845
+ return body.replace(/\s(?:inkscape|sodipodi):[\w-]+\s*=\s*(["'])[\s\S]*?\1/g, "").replace(/\sxlink:href\s*=/g, " href=");
2846
+ }
2847
+ function loadIcon(rel) {
2848
+ const hit = cache.get(rel);
2849
+ if (hit) return hit;
2850
+ const raw = readFileSync6(join7(ICON_ROOT, `${rel}.svg`), "utf8");
2851
+ const open = raw.indexOf("<svg");
2852
+ const openEnd = raw.indexOf(">", open);
2853
+ const close = raw.lastIndexOf("</svg");
2854
+ if (open < 0 || openEnd < 0 || close < 0) throw new Error(`malformed vendored icon: ${rel}.svg`);
2855
+ const viewBox = /\bviewBox\s*=\s*(["'])(.*?)\1/.exec(raw.slice(open, openEnd))?.[2];
2856
+ if (!viewBox) throw new Error(`vendored icon has no viewBox: ${rel}.svg`);
2857
+ const glyph = { body: unprefix(raw.slice(openEnd + 1, close).trim()), colored: true, viewBox };
2858
+ cache.set(rel, glyph);
2859
+ return glyph;
2860
+ }
2861
+ var K8S_ICON_BY_KIND = {
2862
+ "K8s::Apiextensions::CustomResourceDefinition": "crd",
2863
+ "K8s::Apps::DaemonSet": "ds",
2864
+ "K8s::Apps::Deployment": "deploy",
2865
+ "K8s::Apps::ReplicaSet": "rs",
2866
+ "K8s::Apps::StatefulSet": "sts",
2867
+ "K8s::Autoscaling::HorizontalPodAutoscaler": "hpa",
2868
+ "K8s::Batch::CronJob": "cronjob",
2869
+ "K8s::Batch::Job": "job",
2870
+ "K8s::Core::ConfigMap": "cm",
2871
+ "K8s::Core::Endpoints": "ep",
2872
+ "K8s::Core::LimitRange": "limits",
2873
+ "K8s::Core::Namespace": "ns",
2874
+ "K8s::Core::PersistentVolume": "pv",
2875
+ "K8s::Core::PersistentVolumeClaim": "pvc",
2876
+ "K8s::Core::Pod": "pod",
2877
+ "K8s::Core::PodTemplate": "pod",
2878
+ "K8s::Core::ReplicationController": "rs",
2879
+ // the pre-ReplicaSet spelling of the same thing
2880
+ "K8s::Core::ResourceQuota": "quota",
2881
+ "K8s::Core::Secret": "secret",
2882
+ "K8s::Core::Service": "svc",
2883
+ "K8s::Core::ServiceAccount": "sa",
2884
+ "K8s::Core::Volume": "vol",
2885
+ "K8s::Networking::Ingress": "ing",
2886
+ "K8s::Networking::IngressClass": "ing",
2887
+ "K8s::Networking::NetworkPolicy": "netpol",
2888
+ "K8s::Rbac::ClusterRole": "c-role",
2889
+ "K8s::Rbac::ClusterRoleBinding": "crb",
2890
+ "K8s::Rbac::Role": "role",
2891
+ "K8s::Rbac::RoleBinding": "rb",
2892
+ "K8s::Storage::StorageClass": "sc"
2893
+ };
2894
+ var K8S_MARK_BY_PREFIX = [
2895
+ ["K8s::Flux::", "cncf/flux"],
2896
+ ["K8s::Argo::", "cncf/argo"]
2897
+ ];
2898
+ var HELM_MARK_KINDS = /* @__PURE__ */ new Set(["Helm::Chart", "Helm::Release"]);
2899
+ function k8sIconFor(kind) {
2900
+ for (const [prefix, rel] of K8S_MARK_BY_PREFIX) {
2901
+ if (kind.startsWith(prefix)) return loadIcon(rel);
2902
+ }
2903
+ const stem = K8S_ICON_BY_KIND[kind];
2904
+ return stem ? loadIcon(`k8s/${stem}`) : void 0;
2905
+ }
2906
+ function helmIconFor(kind) {
2907
+ return HELM_MARK_KINDS.has(kind) ? loadIcon("cncf/helm") : void 0;
2908
+ }
2909
+
2910
+ // src/render.ts
2911
+ registerPack({ lexicon: "k8s", iconFor: k8sIconFor });
2912
+ registerPack({ lexicon: "helm", iconFor: helmIconFor });
2717
2913
  function renderArchitecture(ir, byContainer, opts = {}) {
2718
2914
  const spread = Math.min(1.5, ir.edges.length / Math.max(ir.nodes.length, 1));
2719
2915
  const layout = layoutArchitecture(ir, byContainer, {
@@ -2909,8 +3105,8 @@ function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
2909
3105
  }
2910
3106
 
2911
3107
  // src/ops.ts
2912
- import { readdirSync as readdirSync3, readFileSync as readFileSync5, existsSync as existsSync6 } from "node:fs";
2913
- import { join as join7 } from "node:path";
3108
+ import { readdirSync as readdirSync3, readFileSync as readFileSync7, existsSync as existsSync6 } from "node:fs";
3109
+ import { join as join8 } from "node:path";
2914
3110
  var APPLY_TARGET_LEXICON = {
2915
3111
  cloudformation: "aws",
2916
3112
  kubectl: "k8s",
@@ -2932,11 +3128,11 @@ function discoverOps(projectDir) {
2932
3128
  const seen = /* @__PURE__ */ new Set();
2933
3129
  const out = [];
2934
3130
  for (const sub of ["ops", "src", "."]) {
2935
- const dir = join7(projectDir, sub);
3131
+ const dir = join8(projectDir, sub);
2936
3132
  if (!existsSync6(dir)) continue;
2937
3133
  for (const f of readdirSync3(dir)) {
2938
3134
  if (!f.endsWith(".op.ts")) continue;
2939
- const content = readFileSync5(join7(dir, f), "utf8");
3135
+ const content = readFileSync7(join8(dir, f), "utf8");
2940
3136
  const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
2941
3137
  if (!name || seen.has(name)) continue;
2942
3138
  seen.add(name);
@@ -3049,6 +3245,117 @@ function classifyHealth(status) {
3049
3245
  if (HEALTHY.test(text)) return "healthy";
3050
3246
  return "unknown";
3051
3247
  }
3248
+ function rec2(v) {
3249
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
3250
+ }
3251
+ function str3(v) {
3252
+ return typeof v === "string" && v.length > 0 ? v : void 0;
3253
+ }
3254
+ function statusTree(o) {
3255
+ const attrs = rec2(o.attributes);
3256
+ if (!attrs) return void 0;
3257
+ return rec2(attrs.status) ?? attrs;
3258
+ }
3259
+ var ARGO_HEALTH = {
3260
+ Healthy: "healthy",
3261
+ Degraded: "degraded",
3262
+ Progressing: "progressing",
3263
+ Suspended: "progressing",
3264
+ Missing: "progressing",
3265
+ Unknown: "unknown"
3266
+ };
3267
+ var ARGO_SYNC = {
3268
+ Synced: "healthy",
3269
+ OutOfSync: "progressing",
3270
+ Unknown: "unknown"
3271
+ };
3272
+ function argoVerdict(o) {
3273
+ if (o.type !== "K8s::Argo::Application") return void 0;
3274
+ const tree = statusTree(o);
3275
+ let health = str3(rec2(tree?.health)?.status);
3276
+ let sync = str3(rec2(tree?.sync)?.status);
3277
+ if (!health && !sync) {
3278
+ const word = str3(o.status);
3279
+ if (word === "READY") {
3280
+ health = "Healthy";
3281
+ sync = "Synced";
3282
+ } else if (word && word in ARGO_HEALTH) health = word;
3283
+ else if (word && word in ARGO_SYNC) sync = word;
3284
+ }
3285
+ const verdicts = [health ? ARGO_HEALTH[health] : void 0, sync ? ARGO_SYNC[sync] : void 0].filter(
3286
+ (v) => v !== void 0
3287
+ );
3288
+ if (verdicts.length === 0) return void 0;
3289
+ const detail = [health ? `health=${health}` : void 0, sync ? `sync=${sync}` : void 0].filter(Boolean).join(", ");
3290
+ for (const rung of ["degraded", "progressing", "unknown"]) {
3291
+ if (verdicts.includes(rung)) return { health: rung, detail };
3292
+ }
3293
+ return { health: "healthy", detail };
3294
+ }
3295
+ var FLUX_PENDING_REASON = /^(progressing|reconciling)/i;
3296
+ function readyFromConditions(tree) {
3297
+ const conditions = tree?.conditions;
3298
+ if (!Array.isArray(conditions)) return void 0;
3299
+ for (const c of conditions) {
3300
+ const cond = rec2(c);
3301
+ if (!cond || cond.type !== "Ready") continue;
3302
+ const status = str3(cond.status);
3303
+ if (status !== "True" && status !== "False" && status !== "Unknown") continue;
3304
+ return { status, reason: str3(cond.reason) };
3305
+ }
3306
+ return void 0;
3307
+ }
3308
+ function readyFromChantConditions(o) {
3309
+ const conditions = rec2(o.attributes)?.conditions;
3310
+ if (!Array.isArray(conditions)) return void 0;
3311
+ for (const c of conditions) {
3312
+ if (typeof c !== "string") continue;
3313
+ const m = /^Ready(?:=([^:]*))?(?::|$)/.exec(c.trim());
3314
+ if (!m) continue;
3315
+ const reason = m[1]?.trim() || void 0;
3316
+ return { status: reason && FLUX_PENDING_REASON.test(reason) ? "Unknown" : "False", reason };
3317
+ }
3318
+ return void 0;
3319
+ }
3320
+ function readyFromStatusWord(o) {
3321
+ const word = str3(o.status);
3322
+ if (!word || word === "PRESENT") return void 0;
3323
+ if (word === "READY") return { status: "True" };
3324
+ if (word === "NOT-READY") return { status: "False" };
3325
+ return { status: FLUX_PENDING_REASON.test(word) ? "Unknown" : "False", reason: word };
3326
+ }
3327
+ function describeReady(ready) {
3328
+ return `Ready=${ready.status}${ready.reason ? ` (${ready.reason})` : ""}`;
3329
+ }
3330
+ function revisionVerdict(tree) {
3331
+ const applied = str3(tree?.lastAppliedRevision);
3332
+ const attempted = str3(tree?.lastAttemptedRevision);
3333
+ if (!applied || !attempted || applied === attempted) return void 0;
3334
+ return { health: "progressing", detail: `applied ${applied}, attempted ${attempted}` };
3335
+ }
3336
+ var FLUX_TYPE_PREFIX = "K8s::Flux::";
3337
+ function fluxVerdict(o) {
3338
+ if (!o.type?.startsWith(FLUX_TYPE_PREFIX)) return void 0;
3339
+ const tree = statusTree(o);
3340
+ const ready = readyFromConditions(tree) ?? readyFromChantConditions(o) ?? readyFromStatusWord(o);
3341
+ if (ready?.status === "False") return { health: "degraded", detail: describeReady(ready) };
3342
+ if (ready?.status === "Unknown") return { health: "progressing", detail: describeReady(ready) };
3343
+ if (ready?.status === "True") return revisionVerdict(tree) ?? { health: "healthy", detail: describeReady(ready) };
3344
+ const conditions = tree?.conditions;
3345
+ const reconciling = Array.isArray(conditions) && conditions.some((c) => {
3346
+ const cond = rec2(c);
3347
+ return cond?.type === "Reconciling" && cond.status === "True";
3348
+ });
3349
+ if (reconciling) return { health: "progressing", detail: "Reconciling=True" };
3350
+ if (!str3(o.status) || o.status === "PRESENT") {
3351
+ return { health: "progressing", detail: "no Ready condition yet" };
3352
+ }
3353
+ return void 0;
3354
+ }
3355
+ function classifyObservedHealth(observed) {
3356
+ if (!observed) return { health: "unknown" };
3357
+ return argoVerdict(observed) ?? fluxVerdict(observed) ?? { health: classifyHealth(observed.status) };
3358
+ }
3052
3359
 
3053
3360
  // src/apply.ts
3054
3361
  var PROGRESS_EVENT_TYPES = /* @__PURE__ */ new Set([
@@ -3335,23 +3642,23 @@ var OpRunner = class {
3335
3642
 
3336
3643
  // src/substrates.ts
3337
3644
  import { spawn as spawn3 } from "node:child_process";
3338
- import { existsSync as existsSync7, readFileSync as readFileSync6 } from "node:fs";
3339
- import { join as join8 } from "node:path";
3645
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "node:fs";
3646
+ import { join as join9 } from "node:path";
3340
3647
  import { platform } from "node:os";
3341
3648
  function probe(cmd, args) {
3342
- return new Promise((resolve4) => {
3649
+ return new Promise((resolve5) => {
3343
3650
  let out = "";
3344
3651
  let proc;
3345
3652
  try {
3346
3653
  proc = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
3347
3654
  } catch {
3348
- resolve4({ code: 127, out: "" });
3655
+ resolve5({ code: 127, out: "" });
3349
3656
  return;
3350
3657
  }
3351
3658
  proc.stdout.on("data", (d) => out += d);
3352
3659
  proc.stderr.on("data", (d) => out += d);
3353
- proc.on("error", () => resolve4({ code: 127, out }));
3354
- proc.on("close", (code) => resolve4({ code: code ?? 1, out }));
3660
+ proc.on("error", () => resolve5({ code: 127, out }));
3661
+ proc.on("close", (code) => resolve5({ code: code ?? 1, out }));
3355
3662
  });
3356
3663
  }
3357
3664
  async function dockerAvailable() {
@@ -3364,11 +3671,11 @@ async function dockerRunning(nameFilter) {
3364
3671
  return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
3365
3672
  }
3366
3673
  function scriptBringUp(projectDir, relPath, label) {
3367
- return existsSync7(join8(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
3674
+ return existsSync7(join9(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
3368
3675
  }
3369
3676
  function projectLexicons(projectDir) {
3370
3677
  try {
3371
- const src = readFileSync6(join8(projectDir, "chant.config.ts"), "utf-8");
3678
+ const src = readFileSync8(join9(projectDir, "chant.config.ts"), "utf-8");
3372
3679
  const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
3373
3680
  if (!m) return [];
3374
3681
  return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
@@ -3418,7 +3725,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3418
3725
  ["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
3419
3726
  ];
3420
3727
  for (const [name, label, marker, script] of forges) {
3421
- if (!existsSync7(join8(projectDir, marker))) continue;
3728
+ if (!existsSync7(join9(projectDir, marker))) continue;
3422
3729
  const c = docker ? await dockerRunning(name) : [];
3423
3730
  const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
3424
3731
  subs.push({
@@ -3448,7 +3755,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3448
3755
  detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
3449
3756
  });
3450
3757
  }
3451
- if (existsSync7(join8(projectDir, ".github", "workflows"))) {
3758
+ if (existsSync7(join9(projectDir, ".github", "workflows"))) {
3452
3759
  const gh = await probe("gh", ["auth", "status"]);
3453
3760
  const ready = gh.code === 0;
3454
3761
  subs.push({
@@ -3461,7 +3768,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3461
3768
  if (lexicons.includes("temporal")) {
3462
3769
  let hasProfiles = false;
3463
3770
  try {
3464
- hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync6(join8(projectDir, "chant.config.ts"), "utf-8"));
3771
+ hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync8(join9(projectDir, "chant.config.ts"), "utf-8"));
3465
3772
  } catch {
3466
3773
  }
3467
3774
  subs.push({
@@ -3578,6 +3885,13 @@ var firstLine = (e) => {
3578
3885
  const m = msg.match(/exited \d+:\s*([\s\S]*)/);
3579
3886
  return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
3580
3887
  };
3888
+ function namespaceRuntimeOwners(name, ir) {
3889
+ const own = new Set(ir.nodes.map((n) => n.id));
3890
+ for (const n of ir.nodes) {
3891
+ if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
3892
+ }
3893
+ return ir;
3894
+ }
3581
3895
  async function composeEstateOverlay(projectDirs, opts, classify) {
3582
3896
  const names = shortStackNames(projectDirs);
3583
3897
  const unobserved = [];
@@ -3587,7 +3901,7 @@ async function composeEstateOverlay(projectDirs, opts, classify) {
3587
3901
  projectDirs.map(async (dir, i) => {
3588
3902
  const name = names[i];
3589
3903
  try {
3590
- stacks[i] = { name, ir: classify(await graphIr(dir, { ...opts, live: true, overlay: true })) };
3904
+ stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, { ...opts, live: true, overlay: true }))) };
3591
3905
  } catch (err) {
3592
3906
  const reason = firstLine(err);
3593
3907
  try {
@@ -3614,7 +3928,7 @@ async function composeEstateOverlay(projectDirs, opts, classify) {
3614
3928
 
3615
3929
  // src/events.ts
3616
3930
  import { watch, existsSync as existsSync8 } from "node:fs";
3617
- import { join as join9 } from "node:path";
3931
+ import { join as join10 } from "node:path";
3618
3932
  var Broadcaster = class {
3619
3933
  listeners = /* @__PURE__ */ new Set();
3620
3934
  subscribe(fn) {
@@ -3632,7 +3946,7 @@ var Broadcaster = class {
3632
3946
  };
3633
3947
  var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
3634
3948
  function watchSource(projectDir, onChange, debounceMs = 200) {
3635
- const dir = existsSync8(join9(projectDir, "src")) ? join9(projectDir, "src") : projectDir;
3949
+ const dir = existsSync8(join10(projectDir, "src")) ? join10(projectDir, "src") : projectDir;
3636
3950
  let timer;
3637
3951
  const watcher = watch(dir, { recursive: true }, (_event, file) => {
3638
3952
  const name = typeof file === "string" ? file : "";
@@ -3740,19 +4054,21 @@ import { renderMorphHtml, layoutIr as layoutIr2 } from "@intentius/pinhole";
3740
4054
  function safeJson(value) {
3741
4055
  return JSON.stringify(value).replace(/</g, "\\u003c");
3742
4056
  }
4057
+ var LANES_MONO = `ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Code", "JetBrains Mono", "IBM Plex Mono", Menlo, Consolas, monospace`;
3743
4058
  var LANES_CSS = `
3744
- body { background: var(--bg, #0d1117); color: var(--fg, #e6edf3); padding-bottom: 150px; }
3745
- #behold-lanes { position: fixed; left: 0; right: 0; bottom: 0; background: var(--bg, #0d1117);
3746
- border-top: 1px solid var(--line, #30363d); padding: 8px 12px 10px; font: 12px ui-sans-serif, system-ui, sans-serif; color: var(--muted, #8b949e); }
4059
+ body { background: var(--bg, #1e1e2e); color: var(--fg, #cdd6f4); padding-bottom: 150px; }
4060
+ #behold-lanes { position: fixed; left: 0; right: 0; bottom: 0; background: var(--bg, #1e1e2e);
4061
+ border-top: 1px solid var(--line, #3b3c4a); padding: 8px 12px 10px; font: 11px/1.5 ${LANES_MONO}; color: var(--muted, #777a8c); }
3747
4062
  #behold-lanes .hd { display: flex; gap: 14px; align-items: baseline; margin-bottom: 4px; }
3748
- #behold-lanes .hd .rt { color: var(--foreign, #d29922); }
3749
- #behold-lanes .hd a { color: var(--pending, #58a6ff); text-decoration: none; margin-left: auto; }
4063
+ #behold-lanes .hd .rt { color: var(--foreign, #f9e2af); }
4064
+ #behold-lanes .hd a { color: var(--pending, #89b4fa); text-decoration: none; margin-left: auto; }
3750
4065
  #behold-lanes canvas { display: block; width: 100%; cursor: pointer; }
3751
4066
  #behold-diff { position: fixed; right: 12px; bottom: 156px; width: 260px; max-height: 40vh; overflow: auto;
3752
- background: var(--panel, #161b22); border: 1px solid var(--line, #30363d); border-radius: 6px; padding: 10px 12px; font: 12px ui-sans-serif, system-ui, sans-serif;
3753
- color: var(--fg, #e6edf3); display: none; }
3754
- #behold-diff h4 { margin: 0 0 6px; font-size: 12px; color: var(--muted, #8b949e); }
3755
- #behold-diff .a { color: var(--managed, #3fb950); } #behold-diff .r { color: var(--degraded, #f85149); } #behold-diff .c { color: var(--foreign, #d29922); }`;
4067
+ background: var(--panel, #2e2f40); border: 1px solid var(--rule, #4e4f64); border-radius: 3px; padding: 10px 12px; font: 11px/1.5 ${LANES_MONO};
4068
+ color: var(--fg, #cdd6f4); display: none; }
4069
+ #behold-diff h4 { margin: 0 0 6px; font: 600 10.5px/1.4 ui-sans-serif, system-ui, sans-serif;
4070
+ text-transform: uppercase; letter-spacing: .07em; color: var(--muted, #777a8c); }
4071
+ #behold-diff .a { color: var(--managed, #a6e3a1); } #behold-diff .r { color: var(--degraded, #f38ba8); } #behold-diff .c { color: var(--foreign, #f9e2af); }`;
3756
4072
  var THEME_BOOT = `<script type="module">import { initTheme, onThemeChange, mountThemePicker } from "/theme.js"; initTheme(); mountThemePicker(document.getElementById("behold-lanes-pickers")); onThemeChange(() => window.dispatchEvent(new Event("behold-theme")));</script>`;
3757
4073
  function laneStripScript(frames) {
3758
4074
  return `<script>
@@ -3778,7 +4094,7 @@ const LF = ${safeJson(frames)};
3778
4094
  const inNow = now !== undefined, inPrev = prev !== undefined;
3779
4095
  return inNow !== inPrev || (inNow && inPrev && now !== prev);
3780
4096
  }
3781
- const color = s => s === "good" ? css("--managed", "#3fb950") : s === "warn" ? css("--foreign", "#d29922") : s === "accent" ? css("--pending", "#58a6ff") : css("--edge", "#6e7681");
4097
+ const color = s => s === "good" ? css("--managed", "#a6e3a1") : s === "warn" ? css("--foreign", "#f9e2af") : s === "accent" ? css("--pending", "#89b4fa") : css("--edge", "#56596d");
3782
4098
 
3783
4099
  function draw() {
3784
4100
  const dpr = window.devicePixelRatio || 1;
@@ -3787,22 +4103,22 @@ const LF = ${safeJson(frames)};
3787
4103
  c.clearRect(0, 0, host.clientWidth, H); c.font = "12px ui-sans-serif, system-ui, sans-serif";
3788
4104
  subs.forEach((s, r) => {
3789
4105
  const y = padT + r * rowH + rowH / 2;
3790
- c.fillStyle = offset[s] ? css("--foreign", "#d29922") : css("--muted", "#8b949e"); c.textAlign = "left"; c.fillText(s, 8, y + 4);
3791
- c.strokeStyle = css("--line", "#21262d"); c.beginPath(); c.moveTo(padL, y); c.lineTo(host.clientWidth - padR, y); c.stroke();
4106
+ c.fillStyle = offset[s] ? css("--foreign", "#f9e2af") : css("--muted", "#777a8c"); c.textAlign = "left"; c.fillText(s, 8, y + 4);
4107
+ c.strokeStyle = css("--line", "#3b3c4a"); c.beginPath(); c.moveTo(padL, y); c.lineTo(host.clientWidth - padR, y); c.stroke();
3792
4108
  LF.forEach((f, i) => {
3793
4109
  if (!f.byLexicon[s]) return;
3794
4110
  // dot per substrate; brighter when a node in this substrate changed at i
3795
4111
  const changed = Object.keys(f.status).some(id => f.lexicon[id] === s && changedAt(id, i));
3796
4112
  const hi = focus && f.lexicon[focus] === s && changedAt(focus, i);
3797
- c.fillStyle = hi ? css("--fg", "#f0f6fc") : changed ? color(mode(f, s)) : css("--line", "#30363d");
4113
+ c.fillStyle = hi ? css("--fg", "#cdd6f4") : changed ? color(mode(f, s)) : css("--line", "#3b3c4a");
3798
4114
  c.beginPath(); c.arc(xOf(i, s), y, i === cur ? 5 : hi ? 4.5 : 3.5, 0, 7); c.fill();
3799
4115
  });
3800
4116
  });
3801
- const px = baseX(cur); c.strokeStyle = css("--pending", "#58a6ff"); c.lineWidth = 1.5;
4117
+ const px = baseX(cur); c.strokeStyle = css("--pending", "#89b4fa"); c.lineWidth = 1.5;
3802
4118
  c.beginPath(); c.moveTo(px, padT - 2); c.lineTo(px, padT + subs.length * rowH); c.stroke();
3803
- if (pair != null) { const qx = baseX(pair); c.strokeStyle = css("--foreign", "#d29922"); c.setLineDash([3,3]);
4119
+ if (pair != null) { const qx = baseX(pair); c.strokeStyle = css("--foreign", "#f9e2af"); c.setLineDash([3,3]);
3804
4120
  c.beginPath(); c.moveTo(qx, padT - 2); c.lineTo(qx, padT + subs.length * rowH); c.stroke(); c.setLineDash([]); }
3805
- c.fillStyle = css("--fg", "#e6edf3"); c.textAlign = "center"; c.fillText(LF[cur].name, px, padT + subs.length * rowH + 16);
4121
+ c.fillStyle = css("--fg", "#cdd6f4"); c.textAlign = "center"; c.fillText(LF[cur].name, px, padT + subs.length * rowH + 16);
3806
4122
  document.getElementById("behold-lanes-meta").textContent =
3807
4123
  LF.length + " frames \xB7 frame " + (cur + 1) + "/" + LF.length + (focus ? " \xB7 focus " + focus : "");
3808
4124
  document.getElementById("behold-lanes-rt").style.display = anyOffset() ? "inline" : "none";
@@ -3908,7 +4224,7 @@ async function emulatorDown(projectDir) {
3908
4224
  }
3909
4225
 
3910
4226
  // src/server.ts
3911
- var webRoot = join10(dirname3(fileURLToPath(import.meta.url)), "..", "web");
4227
+ var webRoot = join11(dirname4(fileURLToPath3(import.meta.url)), "..", "web");
3912
4228
  var execFileP = async (cmd, args) => (await promisify4(execFile4)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
3913
4229
  function optsFromQuery(url, tierEnvVar, projectDir) {
3914
4230
  const q = url.searchParams;
@@ -3957,8 +4273,8 @@ function tierFailure(tier, message) {
3957
4273
  }
3958
4274
  function beholdVersion() {
3959
4275
  try {
3960
- const here = dirname3(fileURLToPath(import.meta.url));
3961
- return JSON.parse(readFileSync7(join10(here, "..", "package.json"), "utf8")).version ?? "unknown";
4276
+ const here = dirname4(fileURLToPath3(import.meta.url));
4277
+ return JSON.parse(readFileSync9(join11(here, "..", "package.json"), "utf8")).version ?? "unknown";
3962
4278
  } catch {
3963
4279
  return "unknown";
3964
4280
  }
@@ -4019,13 +4335,14 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4019
4335
  return void 0;
4020
4336
  }
4021
4337
  };
4338
+ const estateSourceRoots = async (opts) => (await Promise.all((cfg.projectDirs ?? []).map(async (dir) => [await graphPath(dir, opts), dir]))).flat();
4022
4339
  app.get("/healthz", (c) => c.json({ ok: true, projectDir: cfg.projectDir, env: cfg.env ?? null, frames: frames.size }));
4023
4340
  app.get("/api/frames", (c) => c.json({ frames: frames.summaries() }));
4024
4341
  app.get("/lanes", (c) => {
4025
4342
  const all = frames.all();
4026
4343
  if (all.length < 2) {
4027
4344
  return c.html(
4028
- `<!doctype html><meta charset=utf-8><script type="module">import { initTheme } from "/theme.js"; initTheme();</script><body style="font:14px system-ui;background:var(--bg,#0d1117);color:var(--muted,#8b949e);padding:2rem"><h3 style="color:var(--fg,#e6edf3)">deployment lanes</h3><p>${all.length} frame(s) captured \u2014 need at least two to scrub.</p><p>Frames accrue when the estate moves: hit <b style="color:var(--fg,#e6edf3)">\u21BB Refresh</b> (captures the current live state), run a <b style="color:var(--fg,#e6edf3)">Sync</b>/Adopt, edit the source, or serve with <code>--poll</code> against a moving environment. Then reload.</p><p><a href="/" style="color:var(--pending,#58a6ff);text-decoration:none">\u2190 back to the graph</a></p></body>`
4345
+ `<!doctype html><meta charset=utf-8><script type="module">import { initTheme } from "/theme.js"; initTheme();</script><body style="font:14px system-ui;background:var(--bg,#1e1e2e);color:var(--muted,#777a8c);padding:2rem"><h3 style="color:var(--fg,#cdd6f4)">deployment lanes</h3><p>${all.length} frame(s) captured \u2014 need at least two to scrub.</p><p>Frames accrue when the estate moves: hit <b style="color:var(--fg,#cdd6f4)">\u21BB Refresh</b> (captures the current live state), run a <b style="color:var(--fg,#cdd6f4)">Sync</b>/Adopt, edit the source, or serve with <code>--poll</code> against a moving environment. Then reload.</p><p><a href="/" style="color:var(--pending,#58a6ff);text-decoration:none">\u2190 back to the graph</a></p></body>`
4029
4346
  );
4030
4347
  }
4031
4348
  return c.html(renderLanes(all, frames.summaries()));
@@ -4091,8 +4408,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4091
4408
  return c.json({ started: true, name, ran: label });
4092
4409
  });
4093
4410
  app.post("/api/local/reset", (c) => {
4094
- const down = join10(cfg.projectDir, "scripts/local/local-down.sh");
4095
- const up = join10(cfg.projectDir, "scripts/local/local-up.sh");
4411
+ const down = join11(cfg.projectDir, "scripts/local/local-down.sh");
4412
+ const up = join11(cfg.projectDir, "scripts/local/local-up.sh");
4096
4413
  if (!existsSync9(down) || !existsSync9(up)) {
4097
4414
  return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
4098
4415
  }
@@ -4172,7 +4489,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4172
4489
  const body = await c.req.json().catch(() => ({}));
4173
4490
  const dir = typeof body.dir === "string" && body.dir.trim() ? resolve2(body.dir.trim()) : "";
4174
4491
  if (!dir || !existsSync9(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
4175
- if (!existsSync9(join10(dir, "chant.config.ts"))) {
4492
+ if (!existsSync9(join11(dir, "chant.config.ts"))) {
4176
4493
  return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
4177
4494
  }
4178
4495
  addRecent(cfg.projectDir);
@@ -4244,10 +4561,31 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4244
4561
  let mode;
4245
4562
  let metaEnv = cfg.env ?? null;
4246
4563
  if (multi) {
4247
- ir = await composeEstate(cfg.projectDirs, opts);
4564
+ ir = await composeEstate(cfg.projectDirs, logical ? { ...opts, detail: 3 } : opts);
4248
4565
  ir = addValueMatchEdges(ir);
4249
4566
  ir = addK8sDeclaredEdges(ir);
4250
- ir = addClusterAnchorEdges(ir, await boundK8sContext(metaEnv ?? void 0));
4567
+ const estateContext = await boundK8sContext(metaEnv ?? void 0);
4568
+ ir = addClusterAnchorEdges(ir, estateContext);
4569
+ if (logical) {
4570
+ const logicalBefore = ir.nodes.length;
4571
+ const { ir: projected, byContainer } = projectTopology(ir, metaEnv ?? void 0, estateContext, await estateSourceRoots(opts));
4572
+ const { svg: svg2 } = renderArchitecture(projected, byContainer);
4573
+ const logicalNote = notesFor("logical", projected, void 0, logicalBefore);
4574
+ return c.json({
4575
+ ir: projected,
4576
+ svg: svg2,
4577
+ byContainer,
4578
+ meta: {
4579
+ projectDir: cfg.projectDir,
4580
+ env: metaEnv,
4581
+ tier: opts.tier ?? null,
4582
+ target: opts.target ?? null,
4583
+ mode: "logical",
4584
+ estate: cfg.projectDirs.length,
4585
+ ...logicalNote ? { note: logicalNote } : {}
4586
+ }
4587
+ });
4588
+ }
4251
4589
  } else if (components) {
4252
4590
  ir = await componentGraphIr(cfg.projectDir, opts);
4253
4591
  const env = opts.env ?? cfg.env;
@@ -4283,13 +4621,13 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4283
4621
  srcCompositeEdgesAttached = 0;
4284
4622
  }
4285
4623
  }
4286
- if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync9(join10(cfg.projectDir, "chant.config.ts"))) {
4624
+ if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync9(join11(cfg.projectDir, "chant.config.ts"))) {
4287
4625
  return c.json(noProjectError(cfg.projectDir), 404);
4288
4626
  }
4289
4627
  const radial = new URL(c.req.url).searchParams.get("radial") === "1";
4290
4628
  const { svg } = renderGraph(ir, multi ? { boxes: "byStack" } : { radial });
4291
4629
  const srcZoom = components ? "components" : logical ? "logical" : opts.detail === 1 ? "composites" : opts.detail === 3 ? "attributes" : "resources";
4292
- const estateLensNote = multi && (components || logical) ? `the ${components ? "components" : "logical"} lens doesn't apply to a composed estate yet \u2014 showing the composed entity graph` : void 0;
4630
+ const estateLensNote = multi && components ? "the components lens doesn't apply to a composed estate yet \u2014 showing the composed entity graph" : void 0;
4293
4631
  const srcNote = multi ? estateLensNote : notesFor(srcZoom, ir, srcCompositeEdgesAttached);
4294
4632
  return c.json({
4295
4633
  ir,
@@ -4391,22 +4729,36 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4391
4729
  try {
4392
4730
  if (cfg.projectDirs && cfg.projectDirs.length > 1) {
4393
4731
  const runtime = new URL(c.req.url).searchParams.get("runtime") === "1";
4394
- const est = await composeEstateOverlay(cfg.projectDirs, { ...tierTargetOpts(query), detail: query.detail, env }, reclassifyOverlay);
4732
+ const detail = logical ? 3 : query.detail;
4733
+ const est = await composeEstateOverlay(cfg.projectDirs, { ...tierTargetOpts(query), detail, env }, reclassifyOverlay);
4395
4734
  if (est.dropped.length === est.total) {
4396
4735
  return c.json({ error: `no project in the estate could be graphed \u2014 ${est.dropped.map((d) => `${d.name}: ${d.reason}`).join("; ")}` }, 500);
4397
4736
  }
4398
- let ir2 = pruneRuntimeChildren(est.ir);
4399
- if ((query.detail ?? 2) < 3) ir2 = pruneImports(ir2);
4737
+ const composed = est.ir;
4738
+ let ir2 = runtime ? attachRuntimeContainment(composed) : pruneRuntimeChildren(composed);
4739
+ if ((detail ?? 2) < 3) ir2 = pruneImports(ir2);
4400
4740
  ir2 = addValueMatchEdges(ir2);
4401
4741
  ir2 = addK8sDeclaredEdges(ir2);
4402
- ir2 = addClusterAnchorEdges(ir2, await boundK8sContext(env));
4403
- const { svg: svg2 } = renderGraph(ir2, { boxes: "byStack" });
4404
- const lensNote = logical || runtime ? `the ${logical ? "logical" : "runtime"} lens doesn't apply to a composed estate yet \u2014 showing the composed entity overlay` : void 0;
4742
+ const boundContext2 = await boundK8sContext(env);
4743
+ ir2 = addClusterAnchorEdges(ir2, boundContext2);
4405
4744
  const coverNote = est.unobserved.length || est.dropped.length ? `live observe covered ${est.observed} of ${est.total} projects \u2014 ` + [
4406
4745
  ...est.unobserved.map((u) => `${u.name}: ${u.reason} (painted unobserved)`),
4407
4746
  ...est.dropped.map((d) => `${d.name}: dropped (${d.reason})`)
4408
4747
  ].join("; ") : void 0;
4409
- const note2 = [lensNote, coverNote, namespaceMismatchNote(ir2.nodes)].filter(Boolean).join(" \xB7 ");
4748
+ if (logical) {
4749
+ const logicalBefore = ir2.nodes.length;
4750
+ const { ir: projected, byContainer } = projectTopology(ir2, env, boundContext2, await estateSourceRoots(query));
4751
+ const { svg: svg3 } = renderArchitecture(projected, byContainer);
4752
+ const note3 = [notesFor("logical", projected, void 0, logicalBefore), coverNote, namespaceMismatchNote(projected.nodes)].filter(Boolean).join(" \xB7 ");
4753
+ return c.json({
4754
+ ir: projected,
4755
+ svg: svg3,
4756
+ byContainer,
4757
+ meta: { projectDir: cfg.projectDir, env, mode: "logical", estate: est.total, ...note3 ? { note: note3 } : {} }
4758
+ });
4759
+ }
4760
+ const { svg: svg2 } = renderGraph(ir2, { boxes: runtime ? "byContainer" : "byStack" });
4761
+ const note2 = [coverNote, namespaceMismatchNote(ir2.nodes), runtime ? notesFor("runtime", ir2) : void 0].filter(Boolean).join(" \xB7 ");
4410
4762
  return c.json({
4411
4763
  ir: ir2,
4412
4764
  svg: svg2,
@@ -4512,10 +4864,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4512
4864
  const nodes = {};
4513
4865
  for (const id of ids) {
4514
4866
  const observed = nodeObserved(parsed, id);
4867
+ const verdict = classifyObservedHealth(observed);
4515
4868
  nodes[id] = {
4516
4869
  observed,
4517
4870
  diff: nodeDiff(parsed, id),
4518
- health: classifyHealth(observed?.status),
4871
+ health: verdict.health,
4872
+ ...verdict.detail ? { healthDetail: verdict.detail } : {},
4519
4873
  // Field-level (per-manager) drift (#87, chant#1181) — null when no
4520
4874
  // lexicon in this diff carries a `deep` section at all.
4521
4875
  fieldDrift: nodeFieldDrift(parsed, id)
@@ -4539,12 +4893,14 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4539
4893
  return c.json({ error: "diff output was not JSON \u2014 chant may predate --live --json (needs 0.18.7+)" }, 500);
4540
4894
  }
4541
4895
  const observed = nodeObserved(parsed, node);
4896
+ const verdict = classifyObservedHealth(observed);
4542
4897
  return c.json({
4543
4898
  node,
4544
4899
  env,
4545
4900
  diff: nodeDiff(parsed, node),
4546
4901
  observed,
4547
- health: classifyHealth(observed?.status),
4902
+ health: verdict.health,
4903
+ ...verdict.detail ? { healthDetail: verdict.detail } : {},
4548
4904
  // Field-level (per-manager) drift (#87, chant#1181) — null when no
4549
4905
  // lexicon in this diff carries a `deep` section at all.
4550
4906
  fieldDrift: nodeFieldDrift(parsed, node)
@@ -4595,7 +4951,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4595
4951
  });
4596
4952
  const rel = relative(process.cwd(), webRoot) || ".";
4597
4953
  app.use("/*", serveStatic({ root: rel }));
4598
- app.get("/", serveStatic({ path: join10(rel, "index.html") }));
4954
+ app.get("/", serveStatic({ path: join11(rel, "index.html") }));
4599
4955
  return app;
4600
4956
  }
4601
4957
  async function startServer(cfg) {
@@ -4624,7 +4980,7 @@ async function startServer(cfg) {
4624
4980
  }
4625
4981
  }
4626
4982
  for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
4627
- if (existsSync9(join10(dir, "chant.config.ts"))) addRecent(dir);
4983
+ if (existsSync9(join11(dir, "chant.config.ts"))) addRecent(dir);
4628
4984
  }
4629
4985
  const broadcaster = new Broadcaster();
4630
4986
  const frames = new FrameBuffer();
@@ -4730,19 +5086,19 @@ async function startServer(cfg) {
4730
5086
  }
4731
5087
 
4732
5088
  // src/demos.ts
4733
- import { readFileSync as readFileSync8 } from "node:fs";
4734
- import { join as join11 } from "node:path";
5089
+ import { readFileSync as readFileSync10 } from "node:fs";
5090
+ import { join as join12 } from "node:path";
4735
5091
  import { spawnSync } from "node:child_process";
4736
5092
  function loadDemoRegistry(pkgRoot) {
4737
5093
  let raw;
4738
5094
  try {
4739
- raw = JSON.parse(readFileSync8(join11(pkgRoot, "demos.json"), "utf8"));
5095
+ raw = JSON.parse(readFileSync10(join12(pkgRoot, "demos.json"), "utf8"));
4740
5096
  } catch {
4741
5097
  return [];
4742
5098
  }
4743
- const list = raw?.demos;
4744
- if (!Array.isArray(list)) return [];
4745
- return list.filter((e) => {
5099
+ const list3 = raw?.demos;
5100
+ if (!Array.isArray(list3)) return [];
5101
+ return list3.filter((e) => {
4746
5102
  const d = e;
4747
5103
  if (!d || typeof d.name !== "string" || !d.name || typeof d.description !== "string") return false;
4748
5104
  if (d.source === "bundled") {
@@ -4766,9 +5122,9 @@ function missingRequirements(entry) {
4766
5122
  }
4767
5123
 
4768
5124
  // src/export.ts
4769
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync9, readdirSync as readdirSync4 } from "node:fs";
4770
- import { join as join12, dirname as dirname4, basename } from "node:path";
4771
- import { fileURLToPath as fileURLToPath2 } from "node:url";
5125
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync11, readdirSync as readdirSync4 } from "node:fs";
5126
+ import { join as join13, dirname as dirname5, basename } from "node:path";
5127
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
4772
5128
  var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
4773
5129
  function canonicalKey(path, params) {
4774
5130
  const flat = params.get("components") === "1" || params.get("logical") === "1";
@@ -4815,7 +5171,7 @@ function captureKeys(axes) {
4815
5171
  return [...keys];
4816
5172
  }
4817
5173
  function webDir() {
4818
- return join12(dirname4(fileURLToPath2(import.meta.url)), "..", "web");
5174
+ return join13(dirname5(fileURLToPath4(import.meta.url)), "..", "web");
4819
5175
  }
4820
5176
  function workerName(project, override) {
4821
5177
  const raw = override ?? `behold-${basename(project)}`;
@@ -4826,7 +5182,7 @@ async function runExport(cfg, outDir, opts = {}) {
4826
5182
  const app = createApp(cfg);
4827
5183
  const proj = await (await app.request("/api/project")).json();
4828
5184
  const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
4829
- const snapDir = join12(outDir, "snapshots");
5185
+ const snapDir = join13(outDir, "snapshots");
4830
5186
  mkdirSync2(snapDir, { recursive: true });
4831
5187
  const keyToFile = {};
4832
5188
  let ok = 0;
@@ -4835,7 +5191,7 @@ async function runExport(cfg, outDir, opts = {}) {
4835
5191
  const res = await app.request(key);
4836
5192
  const body = await res.text();
4837
5193
  const file = slug(key);
4838
- writeFileSync2(join12(snapDir, file), body);
5194
+ writeFileSync2(join13(snapDir, file), body);
4839
5195
  keyToFile[key] = `snapshots/${file}`;
4840
5196
  if (res.ok) ok++;
4841
5197
  else failed++;
@@ -4847,21 +5203,21 @@ async function runExport(cfg, outDir, opts = {}) {
4847
5203
  axes,
4848
5204
  keyToFile
4849
5205
  };
4850
- writeFileSync2(join12(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
4851
- const html = readFileSync9(join12(webDir(), "index.html"), "utf8").replace(
5206
+ writeFileSync2(join13(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
5207
+ const html = readFileSync11(join13(webDir(), "index.html"), "utf8").replace(
4852
5208
  /<\/head>/i,
4853
5209
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
4854
5210
  </head>`
4855
5211
  );
4856
- writeFileSync2(join12(outDir, "index.html"), html);
5212
+ writeFileSync2(join13(outDir, "index.html"), html);
4857
5213
  for (const f of readdirSync4(webDir())) {
4858
5214
  if (f === "index.html") continue;
4859
- copyFileSync(join12(webDir(), f), join12(outDir, f));
5215
+ copyFileSync(join13(webDir(), f), join13(outDir, f));
4860
5216
  }
4861
- writeFileSync2(join12(outDir, "README.md"), BUNDLE_README);
5217
+ writeFileSync2(join13(outDir, "README.md"), BUNDLE_README);
4862
5218
  const name = workerName(cfg.projectDir, opts.name);
4863
5219
  writeFileSync2(
4864
- join12(outDir, "wrangler.jsonc"),
5220
+ join13(outDir, "wrangler.jsonc"),
4865
5221
  JSON.stringify(
4866
5222
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
4867
5223
  null,
@@ -4908,15 +5264,239 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
4908
5264
  (\`wrangler pages deploy .\`) all work.
4909
5265
  `;
4910
5266
 
5267
+ // src/doctor.ts
5268
+ import { relative as relative2, resolve as resolve3 } from "node:path";
5269
+ var list2 = (xs) => xs.join(", ");
5270
+ var EMPTY_KUBECONFIG = { contexts: /* @__PURE__ */ new Map(), servers: /* @__PURE__ */ new Map() };
5271
+ function summarize(xs, max = 4) {
5272
+ return xs.length <= max ? list2(xs) : `${list2(xs.slice(0, max))}, +${xs.length - max} more`;
5273
+ }
5274
+ function labelFor(root, target, estate) {
5275
+ if (!estate) return "";
5276
+ const rel = relative2(root, target);
5277
+ return `${rel || "."}: `;
5278
+ }
5279
+ function chantCheck(root, targets, estate) {
5280
+ const floor = chantFloor();
5281
+ const resolutions = targets.map((t) => ({ target: t, res: resolveChant(t) }));
5282
+ const missing = resolutions.filter(({ res }) => res.source !== "project");
5283
+ const describe = ({ target, res }) => `${labelFor(root, target, estate)}${res.source === "project" ? `chant ${res.version ?? "(unknown version)"}` : "no chant installed"}`;
5284
+ if (missing.length) {
5285
+ return {
5286
+ name: "chant",
5287
+ status: "fail",
5288
+ detail: `${list2(resolutions.map(describe))} \u2014 behold shells the project's own chant`,
5289
+ fix: `Run \`npm install\` in ${list2(missing.map(({ target }) => relative2(root, target) || target))}`
5290
+ };
5291
+ }
5292
+ const stale = resolutions.filter(({ res }) => !meetsFloor(res.version, floor));
5293
+ const versions = list2(resolutions.map(describe));
5294
+ if (stale.length) {
5295
+ return {
5296
+ name: "chant",
5297
+ status: "warn",
5298
+ detail: `${versions} \u2014 below behold's floor ${floor}`,
5299
+ fix: "Bump @intentius/chant in the project (`npm install @intentius/chant@latest`) \u2014 older versions observe less."
5300
+ };
5301
+ }
5302
+ return { name: "chant", status: "pass", detail: `${versions}${floor ? ` (behold's floor ${floor})` : ""}` };
5303
+ }
5304
+ function lexiconCheck(root, declared, estate) {
5305
+ const missing = [];
5306
+ const found = [];
5307
+ for (const [target, lexicons] of declared) {
5308
+ const label = labelFor(root, target, estate);
5309
+ for (const r of resolveLexicons(target, lexicons)) {
5310
+ (r.installed ? found : missing).push(`${label}${r.lexicon}${r.installed && r.version ? ` ${r.version}` : ""}`);
5311
+ }
5312
+ }
5313
+ if (missing.length) {
5314
+ return {
5315
+ name: "lexicons",
5316
+ status: "fail",
5317
+ detail: `declared but not installed: ${list2(missing)}${found.length ? ` (installed: ${list2(found)})` : ""}`,
5318
+ fix: "Run `npm install` in the project \u2014 chant can't graph source importing a lexicon it can't resolve."
5319
+ };
5320
+ }
5321
+ if (!found.length) {
5322
+ return { name: "lexicons", status: "pass", detail: "none declared" };
5323
+ }
5324
+ return { name: "lexicons", status: "pass", detail: `${found.length} installed: ${list2(found)}` };
5325
+ }
5326
+ function envCheck(envs, serveArg) {
5327
+ if (!envs.length) {
5328
+ return {
5329
+ name: "envs",
5330
+ status: "warn",
5331
+ detail: "no environments declared \u2014 the declared graph serves, but there is no live overlay to pick",
5332
+ fix: "Declare `environments` (or `k8s.profiles`) in chant.config.ts, then serve with `--env <name>`."
5333
+ };
5334
+ }
5335
+ return { name: "envs", status: "pass", detail: `${list2(envs)} \u2014 \`behold serve ${serveArg} --env ${envs[0]}\` for the live overlay` };
5336
+ }
5337
+ function kubeCheck(lexicons, profiles, envs, kubeconfig) {
5338
+ if (!lexicons.includes("k8s") && !lexicons.includes("helm")) {
5339
+ return { name: "kube", status: "pass", detail: "no k8s or helm lexicon \u2014 no cluster binding to check" };
5340
+ }
5341
+ const ambient = kubeconfig.currentContext;
5342
+ if (!kubeconfig.contexts.size) {
5343
+ return {
5344
+ name: "kube",
5345
+ status: "warn",
5346
+ detail: "no kubeconfig readable (kubectl absent, or it has no contexts) \u2014 the declared graph still serves",
5347
+ fix: "Install kubectl and point KUBECONFIG at your cluster, or run `behold demo k8s` for a throwaway k3d one."
5348
+ };
5349
+ }
5350
+ const bound = envs.filter((e) => profiles[e]?.context);
5351
+ if (!bound.length) {
5352
+ return {
5353
+ name: "kube",
5354
+ status: "warn",
5355
+ detail: `no k8s.profiles binding \u2014 chant falls back to the ambient current-context${ambient ? ` (${ambient})` : " (none set)"}`,
5356
+ fix: "Declare `k8s.profiles.<env>.context` in chant.config.ts so the cluster the reads hit is explicit, not ambient."
5357
+ };
5358
+ }
5359
+ const lines = [];
5360
+ const unresolved = [];
5361
+ const mismatched = [];
5362
+ for (const env of bound) {
5363
+ const context = profiles[env].context;
5364
+ const target = resolveK8sTarget(profiles, env, kubeconfig);
5365
+ if (!target) {
5366
+ unresolved.push(`${env}: ${context}`);
5367
+ continue;
5368
+ }
5369
+ lines.push(`${env}: ${target.label} at ${target.endpoint}`);
5370
+ if (ambient && ambient !== target.label) mismatched.push(`${env}: ${target.label}`);
5371
+ }
5372
+ if (unresolved.length) {
5373
+ return {
5374
+ name: "kube",
5375
+ status: "warn",
5376
+ detail: `context not in the kubeconfig \u2014 ${list2(unresolved)}${lines.length ? `; bound: ${list2(lines)}` : ""}`,
5377
+ fix: `Add the context to your kubeconfig, or point k8s.profiles at one you have (${summarize([...kubeconfig.contexts.keys()])}).`
5378
+ };
5379
+ }
5380
+ if (mismatched.length) {
5381
+ return {
5382
+ name: "kube",
5383
+ status: "warn",
5384
+ detail: `bound ${list2(lines)}, but the ambient current-context is ${ambient} \u2014 chant refuses a live read on a mismatch`,
5385
+ fix: `Run \`kubectl config use-context ${mismatched[0].split(": ")[1]}\` before the live overlay.`
5386
+ };
5387
+ }
5388
+ return { name: "kube", status: "pass", detail: `bound ${list2(lines)}` };
5389
+ }
5390
+ function substrateCheck(substrates) {
5391
+ if (!substrates.length) return { name: "substrates", status: "pass", detail: "none needed by this project" };
5392
+ const detail = list2(substrates.map((s) => `${s.label} ${s.status}`));
5393
+ const down = substrates.filter((s) => s.status === "down" || s.status === "blocked");
5394
+ if (!down.length) return { name: "substrates", status: "pass", detail };
5395
+ const named = down.map((s) => `${s.label} (${s.bringUp ? `${s.bringUp.cmd} ${s.bringUp.args.join(" ")}` : s.detail})`);
5396
+ return {
5397
+ name: "substrates",
5398
+ status: "warn",
5399
+ detail,
5400
+ fix: `Bring up: ${list2(named)} \u2014 or \`behold serve <dir> --local\`, which boots the project's own emulators (needs Docker).`
5401
+ };
5402
+ }
5403
+ function opsCheck(root, ops, chantSource, estate) {
5404
+ const mcp = chantSource === "project" ? "chant MCP available (the project's own chant)" : "chant MCP unavailable until the project's chant is installed";
5405
+ if (!ops.length) {
5406
+ return {
5407
+ name: "ops",
5408
+ status: "warn",
5409
+ detail: `no committed Ops (*.op.ts) \u2014 behold can show drift but has nothing to trigger; ${mcp}`,
5410
+ fix: "Add an ApplyOp (code to cloud) or ReconcileOp (cloud to PR) under ops/ \u2014 behold triggers your Ops, it holds no apply creds."
5411
+ };
5412
+ }
5413
+ const names = ops.map((o) => `${labelFor(root, o.dir, estate)}${o.name} (${o.kind})`);
5414
+ return { name: "ops", status: "pass", detail: `${ops.length} committed: ${list2(names)}; ${mcp}` };
5415
+ }
5416
+ async function diagnose(dir, probes = {}) {
5417
+ const root = resolve3(dir);
5418
+ const shape = detectProjectShape(root);
5419
+ const behold = beholdVersion();
5420
+ if (shape.kind === "none") {
5421
+ return {
5422
+ behold,
5423
+ dir: root,
5424
+ kind: "none",
5425
+ ok: false,
5426
+ checks: [
5427
+ {
5428
+ name: "project",
5429
+ status: "fail",
5430
+ detail: `no chant.config.ts here, and no estate members declared (.behold.json \`members\`, or npm workspaces)`,
5431
+ fix: "Point behold at a chant project (`behold doctor <dir>`), or run `behold demo` for a bundled working example."
5432
+ }
5433
+ ]
5434
+ };
5435
+ }
5436
+ const estate = shape.kind === "estate";
5437
+ const members = (shape.members ?? []).map((m) => resolve3(root, m));
5438
+ const targets = estate ? members : [root];
5439
+ const primary = targets[0];
5440
+ const projectCheck = estate ? {
5441
+ name: "project",
5442
+ status: "pass",
5443
+ detail: `estate of ${members.length} projects (${shape.membersFrom === "behold-config" ? ".behold.json members" : "npm workspaces"}): ${list2(
5444
+ members.map((m) => relative2(root, m))
5445
+ )}`
5446
+ } : { name: "project", status: "pass", detail: `chant project (${relative2(root, shape.configFile)})` };
5447
+ const infos = await Promise.all(targets.map(async (t) => ({ target: t, info: await detectProject(t) })));
5448
+ const declared = new Map(infos.map(({ target, info }) => [target, info.lexicons]));
5449
+ const lexicons = [...new Set(infos.flatMap(({ info }) => info.lexicons))];
5450
+ const envs = [...new Set(infos.flatMap(({ info }) => info.environments))];
5451
+ const profiles = {};
5452
+ for (const { info } of infos) for (const [env, p] of Object.entries(info.k8sProfiles ?? {})) profiles[env] ??= p;
5453
+ const kubeconfig = lexicons.includes("k8s") || lexicons.includes("helm") ? await (probes.loadKubeconfig ?? loadKubeconfig)() : EMPTY_KUBECONFIG;
5454
+ const kube = kubeCheck(lexicons, profiles, envs, kubeconfig);
5455
+ const boundContext = resolveK8sTarget(profiles, envs[0], kubeconfig)?.label;
5456
+ const substrates = await (probes.detectSubstrates ?? detectSubstrates)(primary, false, boundContext);
5457
+ const checks = [
5458
+ projectCheck,
5459
+ chantCheck(root, targets, estate),
5460
+ lexiconCheck(root, declared, estate),
5461
+ // An estate root is not itself servable — the hint has to name its members
5462
+ // (`behold serve a b c`, #31), which is what a stranger would otherwise
5463
+ // discover by having the root serve nothing.
5464
+ envCheck(envs, estate ? shape.members.map((m) => `${dir.replace(/\/$/, "")}/${m}`).join(" ") : dir),
5465
+ kube,
5466
+ substrateCheck(substrates),
5467
+ opsCheck(root, discoverEstateOps(targets), resolveChant(primary).source, estate)
5468
+ ];
5469
+ return { behold, dir: root, kind: shape.kind, ok: !checks.some((c) => c.status === "fail"), checks };
5470
+ }
5471
+ function formatReport(report) {
5472
+ const lines = [`behold doctor ${report.behold} \u2014 ${report.dir}`, ""];
5473
+ for (const c of report.checks) {
5474
+ lines.push(` ${c.status.padEnd(4)} ${c.name.padEnd(10)} ${c.detail}`);
5475
+ if (c.fix) lines.push(` ${"fix".padEnd(10)} ${c.fix}`);
5476
+ }
5477
+ const tally = (s) => report.checks.filter((c) => c.status === s).length;
5478
+ lines.push("", ` ${tally("pass")} pass, ${tally("warn")} warn, ${tally("fail")} fail`, "");
5479
+ return lines.join("\n");
5480
+ }
5481
+
4911
5482
  // src/cli.ts
4912
5483
  var USAGE = `behold \u2014 a live control plane on chant (read-only core)
4913
5484
 
4914
5485
  Usage:
4915
5486
  behold demo [name] [target-dir] [--port <n>] [--list]
5487
+ behold doctor [project-dir] [--json]
4916
5488
  behold preview [project-dir] [--port <n>] [--emulator]
4917
5489
  behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
4918
5490
  behold serve <project-dir\u2026> [--port <n>] [--env <name>] [--poll <secs>] [--local]
4919
5491
 
5492
+ doctor Why won't this project serve well? A read-only diagnosis of
5493
+ everything behold needs \u2014 the project's kind, its own chant install
5494
+ and version, declared lexicons, the envs the picker will infer, the
5495
+ kube context chant binds versus the ambient one, substrate
5496
+ readiness, committed Ops \u2014 each line pass/warn/fail with a one-line
5497
+ fix. Defaults to the current directory. Exits 0 iff nothing failed;
5498
+ --json for machines. Starts no server and changes nothing.
5499
+
4920
5500
  demo The five-minute path from npm \u2014 no chant project needed. A catalog
4921
5501
  of demo estates (behold demo --list): bundled ones copy out of the
4922
5502
  package into a directory that's yours to edit; git ones shallow-
@@ -4977,6 +5557,8 @@ Options:
4977
5557
  names, unlike --local; kept separate because Loom's own
4978
5558
  \`scripts/local/local-up.sh\` Floci setup clashes on :4566
4979
5559
  with chant's generic \`chant emulator up\`.
5560
+ --json doctor only: the report as JSON (stable keys) instead of
5561
+ the console lines \u2014 the AGENTS.md audience.
4980
5562
  --out <dir> export only: output directory (default ./behold-export).
4981
5563
  --name <worker> export only: Cloudflare Worker name in the generated
4982
5564
  wrangler.jsonc.
@@ -4997,6 +5579,10 @@ async function run3(argv) {
4997
5579
  await runDemo(rest);
4998
5580
  return;
4999
5581
  }
5582
+ if (cmd === "doctor") {
5583
+ await runDoctor(rest);
5584
+ return;
5585
+ }
5000
5586
  if (cmd === "preview") {
5001
5587
  await runPreview(rest);
5002
5588
  return;
@@ -5060,7 +5646,7 @@ ${USAGE}`);
5060
5646
  process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
5061
5647
  process.exit(2);
5062
5648
  }
5063
- const dirs = projectDirs.map((d) => resolve3(d));
5649
+ const dirs = projectDirs.map((d) => resolve4(d));
5064
5650
  for (const d of dirs) warnIfNotChantProject(d);
5065
5651
  await startServer({
5066
5652
  projectDir: dirs[0],
@@ -5074,15 +5660,47 @@ ${USAGE}`);
5074
5660
  });
5075
5661
  }
5076
5662
  function warnIfNotChantProject(dir) {
5077
- if (existsSync10(join13(dir, "chant.config.ts"))) return;
5663
+ const shape = detectProjectShape(dir);
5664
+ if (shape.kind === "project") return;
5665
+ if (shape.kind === "estate") {
5666
+ process.stderr.write(
5667
+ `behold: warning \u2014 ${dir} is an estate root, not a chant project itself.
5668
+ Serve its members composed: behold serve ${shape.members.map((m) => join14(dir, m)).join(" ")}
5669
+ `
5670
+ );
5671
+ return;
5672
+ }
5078
5673
  process.stderr.write(
5079
5674
  `behold: warning \u2014 ${dir} has no chant.config.ts; this doesn't look like a chant project.
5080
- No project yet? \`behold demo\` serves a bundled working example (needs Docker).
5675
+ \`behold doctor ${dir}\` says what's missing. No project yet? \`behold demo\` serves a bundled working example (needs Docker).
5081
5676
  `
5082
5677
  );
5083
5678
  }
5679
+ async function runDoctor(rest) {
5680
+ let json = false;
5681
+ let dirArg;
5682
+ for (const a of rest) {
5683
+ if (a === "--json") json = true;
5684
+ else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
5685
+ else if (!a.startsWith("-")) dirArg = a;
5686
+ else {
5687
+ process.stderr.write(`behold doctor: unexpected argument '${a}'
5688
+ `);
5689
+ process.exit(2);
5690
+ }
5691
+ }
5692
+ const dir = dirArg ?? ".";
5693
+ if (!existsSync10(resolve4(dir))) {
5694
+ process.stderr.write(`behold doctor: no such directory: ${resolve4(dir)}
5695
+ `);
5696
+ process.exit(2);
5697
+ }
5698
+ const report = await diagnose(dir);
5699
+ process.stdout.write(json ? JSON.stringify(report, null, 2) + "\n" : formatReport(report));
5700
+ if (!report.ok) process.exitCode = 1;
5701
+ }
5084
5702
  async function runDemo(rest) {
5085
- const pkgRoot = join13(dirname5(fileURLToPath3(import.meta.url)), "..");
5703
+ const pkgRoot = join14(dirname6(fileURLToPath5(import.meta.url)), "..");
5086
5704
  const registry = loadDemoRegistry(pkgRoot);
5087
5705
  let port = 4600;
5088
5706
  let name;
@@ -5134,12 +5752,12 @@ async function runDemo(rest) {
5134
5752
  `);
5135
5753
  process.exit(2);
5136
5754
  }
5137
- const target = resolve3(
5138
- dirArg ?? (entry.name === "writes" && existsSync10("behold-demo") ? "behold-demo" : join13("behold-demos", entry.name))
5755
+ const target = resolve4(
5756
+ dirArg ?? (entry.name === "writes" && existsSync10("behold-demo") ? "behold-demo" : join14("behold-demos", entry.name))
5139
5757
  );
5140
5758
  if (!existsSync10(target)) {
5141
5759
  if (entry.source === "bundled") {
5142
- const bundled = join13(pkgRoot, entry.dir);
5760
+ const bundled = join14(pkgRoot, entry.dir);
5143
5761
  if (!existsSync10(bundled)) {
5144
5762
  process.stderr.write(`behold demo ${entry.name}: this install has no bundled ${entry.dir}
5145
5763
  `);
@@ -5149,7 +5767,7 @@ async function runDemo(rest) {
5149
5767
  `);
5150
5768
  cpSync(bundled, target, {
5151
5769
  recursive: true,
5152
- filter: (src) => !relative2(bundled, src).split(sep).includes("node_modules")
5770
+ filter: (src) => !relative3(bundled, src).split(sep).includes("node_modules")
5153
5771
  });
5154
5772
  } else {
5155
5773
  process.stdout.write(`behold demo ${entry.name} \u2192 cloning ${entry.repo} to ${target}
@@ -5165,7 +5783,7 @@ async function runDemo(rest) {
5165
5783
  process.stdout.write(`behold demo ${entry.name} \u2192 reusing ${target}
5166
5784
  `);
5167
5785
  }
5168
- if (existsSync10(join13(target, "package.json")) && !existsSync10(join13(target, "node_modules"))) {
5786
+ if (existsSync10(join14(target, "package.json")) && !existsSync10(join14(target, "node_modules"))) {
5169
5787
  process.stdout.write(`behold demo ${entry.name} \u2192 npm install\u2026
5170
5788
  `);
5171
5789
  const r = spawnSync2("npm", ["install"], { cwd: target, stdio: "inherit", shell: process.platform === "win32" });
@@ -5187,7 +5805,7 @@ async function runDemo(rest) {
5187
5805
  }
5188
5806
  process.stdout.write(`behold demo ${entry.name} \u2192 serving. Blue = declared; Deploy turns it green.
5189
5807
  `);
5190
- const serveDirs = entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join13(target, d)) : [target];
5808
+ const serveDirs = entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join14(target, d)) : [target];
5191
5809
  const serveArgs = ["serve", ...serveDirs, "--port", String(port)];
5192
5810
  if (entry.serve.local) serveArgs.push("--local");
5193
5811
  if (entry.serve.env) serveArgs.push("--env", entry.serve.env);
@@ -5215,7 +5833,7 @@ async function runPreview(rest) {
5215
5833
  process.stderr.write("behold preview: --port must be a number\n");
5216
5834
  process.exit(2);
5217
5835
  }
5218
- const projectDir = resolve3(dirArg ?? process.cwd());
5836
+ const projectDir = resolve4(dirArg ?? process.cwd());
5219
5837
  if (!existsSync10(projectDir)) {
5220
5838
  process.stderr.write(`behold preview: project not found at ${projectDir}
5221
5839
  `);
@@ -5236,21 +5854,21 @@ async function runPreview(rest) {
5236
5854
  await startServer({ projectDir, port, env: "local", previewMode: true });
5237
5855
  }
5238
5856
  async function runExportCmd(rest) {
5239
- let outDir = resolve3("behold-export");
5857
+ let outDir = resolve4("behold-export");
5240
5858
  let env;
5241
5859
  let name;
5242
5860
  let dirArg;
5243
5861
  let emulator = false;
5244
5862
  for (let i = 0; i < rest.length; i++) {
5245
5863
  const a = rest[i];
5246
- if (a === "--out") outDir = resolve3(rest[++i]);
5864
+ if (a === "--out") outDir = resolve4(rest[++i]);
5247
5865
  else if (a === "--env") env = rest[++i];
5248
5866
  else if (a === "--name") name = rest[++i];
5249
5867
  else if (a === "--emulator") emulator = true;
5250
5868
  else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
5251
5869
  else if (!a.startsWith("-")) dirArg = a;
5252
5870
  }
5253
- const projectDir = resolve3(dirArg ?? process.cwd());
5871
+ const projectDir = resolve4(dirArg ?? process.cwd());
5254
5872
  if (emulator) {
5255
5873
  injectEmulatorEnv(env);
5256
5874
  env ??= "local";
@@ -5266,7 +5884,7 @@ function isMainModule() {
5266
5884
  const entry = process.argv[1];
5267
5885
  if (!entry) return false;
5268
5886
  try {
5269
- return realpathSync(entry) === realpathSync(fileURLToPath3(import.meta.url));
5887
+ return realpathSync(entry) === realpathSync(fileURLToPath5(import.meta.url));
5270
5888
  } catch {
5271
5889
  return false;
5272
5890
  }