@intentius/behold 0.6.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 +769 -139
  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 +3 -3
  24. package/example-flux-estate/app-b/package.json +3 -3
  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 +43 -40
  35. package/example-k8s/package.json +4 -4
  36. package/example-writes/package-lock.json +18 -16
  37. package/example-writes/package.json +3 -3
  38. package/package.json +3 -2
  39. package/web/app.js +359 -21
  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
  }
@@ -1972,6 +2092,12 @@ import { join as join6 } from "node:path";
1972
2092
  function overlayBoxTitle(name) {
1973
2093
  return `overlay ${name}`;
1974
2094
  }
2095
+ var ROOT_ANNOTATION = "chant.intentius.io/kustomize-root";
2096
+ function annotatedRoot(node) {
2097
+ const metadata2 = node.attrs?.metadata;
2098
+ const v = metadata2?.annotations?.[ROOT_ANNOTATION];
2099
+ return typeof v === "string" && v ? v : void 0;
2100
+ }
1975
2101
  function dirOf2(node) {
1976
2102
  const file = node.sourceLoc?.file;
1977
2103
  if (typeof file !== "string") return "";
@@ -2002,12 +2128,15 @@ function projectKustomizeLogical(ir, sourceRoots, exists = existsSync5) {
2002
2128
  };
2003
2129
  const rootCache = /* @__PURE__ */ new Map();
2004
2130
  for (const n of k8s) {
2005
- const dir = dirOf2(n);
2006
- if (dir === "") continue;
2007
- if (!rootCache.has(dir)) rootCache.set(dir, kustomizationRoot(bases, dir, exists));
2008
- const root = rootCache.get(dir);
2131
+ let root = annotatedRoot(n);
2132
+ if (root === void 0) {
2133
+ const dir = dirOf2(n);
2134
+ if (dir === "") continue;
2135
+ if (!rootCache.has(dir)) rootCache.set(dir, kustomizationRoot(bases, dir, exists));
2136
+ root = rootCache.get(dir);
2137
+ }
2009
2138
  if (root === void 0) continue;
2010
- const name = root.split("/").pop() || root;
2139
+ const name = root.replace(/\/+$/, "").split("/").pop() || root;
2011
2140
  child(overlayBoxTitle(name), n.id);
2012
2141
  }
2013
2142
  return { ir: { nodes: [], edges: [], groups: {} }, byContainer };
@@ -2214,9 +2343,9 @@ function enrichedRefs(ir, aws, byId) {
2214
2343
  const producersByName = /* @__PURE__ */ new Map();
2215
2344
  for (const ex of ir.exports ?? []) {
2216
2345
  if (!ex.node || !byId.has(ex.node)) continue;
2217
- const list = producersByName.get(ex.name) ?? producersByName.set(ex.name, []).get(ex.name);
2218
- if (isExample(byId.get(ex.node))) list.push(ex.node);
2219
- 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);
2220
2349
  }
2221
2350
  const exportNames = [...producersByName.keys()].sort((a, b) => b.length - a.length);
2222
2351
  const bridge = (paramId, producers) => {
@@ -2704,7 +2833,83 @@ function summarizePlan(plan, byComponent, nonResource) {
2704
2833
  }
2705
2834
 
2706
2835
  // src/render.ts
2707
- 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 });
2708
2913
  function renderArchitecture(ir, byContainer, opts = {}) {
2709
2914
  const spread = Math.min(1.5, ir.edges.length / Math.max(ir.nodes.length, 1));
2710
2915
  const layout = layoutArchitecture(ir, byContainer, {
@@ -2900,8 +3105,8 @@ function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
2900
3105
  }
2901
3106
 
2902
3107
  // src/ops.ts
2903
- import { readdirSync as readdirSync3, readFileSync as readFileSync5, existsSync as existsSync6 } from "node:fs";
2904
- 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";
2905
3110
  var APPLY_TARGET_LEXICON = {
2906
3111
  cloudformation: "aws",
2907
3112
  kubectl: "k8s",
@@ -2923,11 +3128,11 @@ function discoverOps(projectDir) {
2923
3128
  const seen = /* @__PURE__ */ new Set();
2924
3129
  const out = [];
2925
3130
  for (const sub of ["ops", "src", "."]) {
2926
- const dir = join7(projectDir, sub);
3131
+ const dir = join8(projectDir, sub);
2927
3132
  if (!existsSync6(dir)) continue;
2928
3133
  for (const f of readdirSync3(dir)) {
2929
3134
  if (!f.endsWith(".op.ts")) continue;
2930
- const content = readFileSync5(join7(dir, f), "utf8");
3135
+ const content = readFileSync7(join8(dir, f), "utf8");
2931
3136
  const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
2932
3137
  if (!name || seen.has(name)) continue;
2933
3138
  seen.add(name);
@@ -2984,16 +3189,19 @@ function nodeDiff(json, nodeId) {
2984
3189
  if (drift) return { category: "drifted", changes: drift.changes ?? [] };
2985
3190
  const unobserved = r.unobserved?.find((u) => u.name === nodeId);
2986
3191
  if (unobserved) {
3192
+ const queried = unobserved.queried ?? r.queried?.[nodeId];
2987
3193
  return {
2988
3194
  category: "unobserved",
2989
3195
  changes: [],
2990
3196
  unobservedReason: unobserved.reason,
2991
- ...unobserved.detail ? { unobservedDetail: unobserved.detail } : {}
3197
+ ...unobserved.detail ? { unobservedDetail: unobserved.detail } : {},
3198
+ ...queried ? { queried } : {}
2992
3199
  };
2993
3200
  }
2994
3201
  const runtimeChild = r.runtimeChildren?.find((rc) => rc.name === nodeId);
2995
3202
  if (runtimeChild) return { category: "runtime", changes: [], runtimeOwner: runtimeChild.owner };
2996
- if (r.missing?.includes(nodeId)) return { category: "missing", changes: [] };
3203
+ if (r.missing?.includes(nodeId))
3204
+ return { category: "missing", changes: [], ...r.queried?.[nodeId] ? { queried: r.queried[nodeId] } : {} };
2997
3205
  if (r.orphan?.includes(nodeId)) return { category: "orphan", changes: [] };
2998
3206
  if (r.disappeared?.includes(nodeId)) return { category: "disappeared", changes: [] };
2999
3207
  if (r.newlyObserved?.includes(nodeId)) return { category: "newlyObserved", changes: [] };
@@ -3037,6 +3245,117 @@ function classifyHealth(status) {
3037
3245
  if (HEALTHY.test(text)) return "healthy";
3038
3246
  return "unknown";
3039
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
+ }
3040
3359
 
3041
3360
  // src/apply.ts
3042
3361
  var PROGRESS_EVENT_TYPES = /* @__PURE__ */ new Set([
@@ -3323,23 +3642,23 @@ var OpRunner = class {
3323
3642
 
3324
3643
  // src/substrates.ts
3325
3644
  import { spawn as spawn3 } from "node:child_process";
3326
- import { existsSync as existsSync7, readFileSync as readFileSync6 } from "node:fs";
3327
- 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";
3328
3647
  import { platform } from "node:os";
3329
3648
  function probe(cmd, args) {
3330
- return new Promise((resolve4) => {
3649
+ return new Promise((resolve5) => {
3331
3650
  let out = "";
3332
3651
  let proc;
3333
3652
  try {
3334
3653
  proc = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
3335
3654
  } catch {
3336
- resolve4({ code: 127, out: "" });
3655
+ resolve5({ code: 127, out: "" });
3337
3656
  return;
3338
3657
  }
3339
3658
  proc.stdout.on("data", (d) => out += d);
3340
3659
  proc.stderr.on("data", (d) => out += d);
3341
- proc.on("error", () => resolve4({ code: 127, out }));
3342
- 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 }));
3343
3662
  });
3344
3663
  }
3345
3664
  async function dockerAvailable() {
@@ -3352,11 +3671,11 @@ async function dockerRunning(nameFilter) {
3352
3671
  return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
3353
3672
  }
3354
3673
  function scriptBringUp(projectDir, relPath, label) {
3355
- 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;
3356
3675
  }
3357
3676
  function projectLexicons(projectDir) {
3358
3677
  try {
3359
- const src = readFileSync6(join8(projectDir, "chant.config.ts"), "utf-8");
3678
+ const src = readFileSync8(join9(projectDir, "chant.config.ts"), "utf-8");
3360
3679
  const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
3361
3680
  if (!m) return [];
3362
3681
  return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
@@ -3406,7 +3725,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3406
3725
  ["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
3407
3726
  ];
3408
3727
  for (const [name, label, marker, script] of forges) {
3409
- if (!existsSync7(join8(projectDir, marker))) continue;
3728
+ if (!existsSync7(join9(projectDir, marker))) continue;
3410
3729
  const c = docker ? await dockerRunning(name) : [];
3411
3730
  const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
3412
3731
  subs.push({
@@ -3436,7 +3755,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3436
3755
  detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
3437
3756
  });
3438
3757
  }
3439
- if (existsSync7(join8(projectDir, ".github", "workflows"))) {
3758
+ if (existsSync7(join9(projectDir, ".github", "workflows"))) {
3440
3759
  const gh = await probe("gh", ["auth", "status"]);
3441
3760
  const ready = gh.code === 0;
3442
3761
  subs.push({
@@ -3449,7 +3768,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3449
3768
  if (lexicons.includes("temporal")) {
3450
3769
  let hasProfiles = false;
3451
3770
  try {
3452
- 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"));
3453
3772
  } catch {
3454
3773
  }
3455
3774
  subs.push({
@@ -3566,6 +3885,13 @@ var firstLine = (e) => {
3566
3885
  const m = msg.match(/exited \d+:\s*([\s\S]*)/);
3567
3886
  return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
3568
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
+ }
3569
3895
  async function composeEstateOverlay(projectDirs, opts, classify) {
3570
3896
  const names = shortStackNames(projectDirs);
3571
3897
  const unobserved = [];
@@ -3575,7 +3901,7 @@ async function composeEstateOverlay(projectDirs, opts, classify) {
3575
3901
  projectDirs.map(async (dir, i) => {
3576
3902
  const name = names[i];
3577
3903
  try {
3578
- 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 }))) };
3579
3905
  } catch (err) {
3580
3906
  const reason = firstLine(err);
3581
3907
  try {
@@ -3602,7 +3928,7 @@ async function composeEstateOverlay(projectDirs, opts, classify) {
3602
3928
 
3603
3929
  // src/events.ts
3604
3930
  import { watch, existsSync as existsSync8 } from "node:fs";
3605
- import { join as join9 } from "node:path";
3931
+ import { join as join10 } from "node:path";
3606
3932
  var Broadcaster = class {
3607
3933
  listeners = /* @__PURE__ */ new Set();
3608
3934
  subscribe(fn) {
@@ -3620,7 +3946,7 @@ var Broadcaster = class {
3620
3946
  };
3621
3947
  var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
3622
3948
  function watchSource(projectDir, onChange, debounceMs = 200) {
3623
- const dir = existsSync8(join9(projectDir, "src")) ? join9(projectDir, "src") : projectDir;
3949
+ const dir = existsSync8(join10(projectDir, "src")) ? join10(projectDir, "src") : projectDir;
3624
3950
  let timer;
3625
3951
  const watcher = watch(dir, { recursive: true }, (_event, file) => {
3626
3952
  const name = typeof file === "string" ? file : "";
@@ -3728,19 +4054,21 @@ import { renderMorphHtml, layoutIr as layoutIr2 } from "@intentius/pinhole";
3728
4054
  function safeJson(value) {
3729
4055
  return JSON.stringify(value).replace(/</g, "\\u003c");
3730
4056
  }
4057
+ var LANES_MONO = `ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Code", "JetBrains Mono", "IBM Plex Mono", Menlo, Consolas, monospace`;
3731
4058
  var LANES_CSS = `
3732
- body { background: var(--bg, #0d1117); color: var(--fg, #e6edf3); padding-bottom: 150px; }
3733
- #behold-lanes { position: fixed; left: 0; right: 0; bottom: 0; background: var(--bg, #0d1117);
3734
- 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); }
3735
4062
  #behold-lanes .hd { display: flex; gap: 14px; align-items: baseline; margin-bottom: 4px; }
3736
- #behold-lanes .hd .rt { color: var(--foreign, #d29922); }
3737
- #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; }
3738
4065
  #behold-lanes canvas { display: block; width: 100%; cursor: pointer; }
3739
4066
  #behold-diff { position: fixed; right: 12px; bottom: 156px; width: 260px; max-height: 40vh; overflow: auto;
3740
- 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;
3741
- color: var(--fg, #e6edf3); display: none; }
3742
- #behold-diff h4 { margin: 0 0 6px; font-size: 12px; color: var(--muted, #8b949e); }
3743
- #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); }`;
3744
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>`;
3745
4073
  function laneStripScript(frames) {
3746
4074
  return `<script>
@@ -3766,7 +4094,7 @@ const LF = ${safeJson(frames)};
3766
4094
  const inNow = now !== undefined, inPrev = prev !== undefined;
3767
4095
  return inNow !== inPrev || (inNow && inPrev && now !== prev);
3768
4096
  }
3769
- 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");
3770
4098
 
3771
4099
  function draw() {
3772
4100
  const dpr = window.devicePixelRatio || 1;
@@ -3775,22 +4103,22 @@ const LF = ${safeJson(frames)};
3775
4103
  c.clearRect(0, 0, host.clientWidth, H); c.font = "12px ui-sans-serif, system-ui, sans-serif";
3776
4104
  subs.forEach((s, r) => {
3777
4105
  const y = padT + r * rowH + rowH / 2;
3778
- c.fillStyle = offset[s] ? css("--foreign", "#d29922") : css("--muted", "#8b949e"); c.textAlign = "left"; c.fillText(s, 8, y + 4);
3779
- 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();
3780
4108
  LF.forEach((f, i) => {
3781
4109
  if (!f.byLexicon[s]) return;
3782
4110
  // dot per substrate; brighter when a node in this substrate changed at i
3783
4111
  const changed = Object.keys(f.status).some(id => f.lexicon[id] === s && changedAt(id, i));
3784
4112
  const hi = focus && f.lexicon[focus] === s && changedAt(focus, i);
3785
- 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");
3786
4114
  c.beginPath(); c.arc(xOf(i, s), y, i === cur ? 5 : hi ? 4.5 : 3.5, 0, 7); c.fill();
3787
4115
  });
3788
4116
  });
3789
- 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;
3790
4118
  c.beginPath(); c.moveTo(px, padT - 2); c.lineTo(px, padT + subs.length * rowH); c.stroke();
3791
- 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]);
3792
4120
  c.beginPath(); c.moveTo(qx, padT - 2); c.lineTo(qx, padT + subs.length * rowH); c.stroke(); c.setLineDash([]); }
3793
- 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);
3794
4122
  document.getElementById("behold-lanes-meta").textContent =
3795
4123
  LF.length + " frames \xB7 frame " + (cur + 1) + "/" + LF.length + (focus ? " \xB7 focus " + focus : "");
3796
4124
  document.getElementById("behold-lanes-rt").style.display = anyOffset() ? "inline" : "none";
@@ -3896,7 +4224,7 @@ async function emulatorDown(projectDir) {
3896
4224
  }
3897
4225
 
3898
4226
  // src/server.ts
3899
- var webRoot = join10(dirname3(fileURLToPath(import.meta.url)), "..", "web");
4227
+ var webRoot = join11(dirname4(fileURLToPath3(import.meta.url)), "..", "web");
3900
4228
  var execFileP = async (cmd, args) => (await promisify4(execFile4)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
3901
4229
  function optsFromQuery(url, tierEnvVar, projectDir) {
3902
4230
  const q = url.searchParams;
@@ -3945,8 +4273,8 @@ function tierFailure(tier, message) {
3945
4273
  }
3946
4274
  function beholdVersion() {
3947
4275
  try {
3948
- const here = dirname3(fileURLToPath(import.meta.url));
3949
- 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";
3950
4278
  } catch {
3951
4279
  return "unknown";
3952
4280
  }
@@ -4007,13 +4335,14 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4007
4335
  return void 0;
4008
4336
  }
4009
4337
  };
4338
+ const estateSourceRoots = async (opts) => (await Promise.all((cfg.projectDirs ?? []).map(async (dir) => [await graphPath(dir, opts), dir]))).flat();
4010
4339
  app.get("/healthz", (c) => c.json({ ok: true, projectDir: cfg.projectDir, env: cfg.env ?? null, frames: frames.size }));
4011
4340
  app.get("/api/frames", (c) => c.json({ frames: frames.summaries() }));
4012
4341
  app.get("/lanes", (c) => {
4013
4342
  const all = frames.all();
4014
4343
  if (all.length < 2) {
4015
4344
  return c.html(
4016
- `<!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>`
4017
4346
  );
4018
4347
  }
4019
4348
  return c.html(renderLanes(all, frames.summaries()));
@@ -4079,8 +4408,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4079
4408
  return c.json({ started: true, name, ran: label });
4080
4409
  });
4081
4410
  app.post("/api/local/reset", (c) => {
4082
- const down = join10(cfg.projectDir, "scripts/local/local-down.sh");
4083
- 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");
4084
4413
  if (!existsSync9(down) || !existsSync9(up)) {
4085
4414
  return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
4086
4415
  }
@@ -4160,7 +4489,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4160
4489
  const body = await c.req.json().catch(() => ({}));
4161
4490
  const dir = typeof body.dir === "string" && body.dir.trim() ? resolve2(body.dir.trim()) : "";
4162
4491
  if (!dir || !existsSync9(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
4163
- if (!existsSync9(join10(dir, "chant.config.ts"))) {
4492
+ if (!existsSync9(join11(dir, "chant.config.ts"))) {
4164
4493
  return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
4165
4494
  }
4166
4495
  addRecent(cfg.projectDir);
@@ -4232,10 +4561,31 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4232
4561
  let mode;
4233
4562
  let metaEnv = cfg.env ?? null;
4234
4563
  if (multi) {
4235
- ir = await composeEstate(cfg.projectDirs, opts);
4564
+ ir = await composeEstate(cfg.projectDirs, logical ? { ...opts, detail: 3 } : opts);
4236
4565
  ir = addValueMatchEdges(ir);
4237
4566
  ir = addK8sDeclaredEdges(ir);
4238
- 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
+ }
4239
4589
  } else if (components) {
4240
4590
  ir = await componentGraphIr(cfg.projectDir, opts);
4241
4591
  const env = opts.env ?? cfg.env;
@@ -4271,13 +4621,13 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4271
4621
  srcCompositeEdgesAttached = 0;
4272
4622
  }
4273
4623
  }
4274
- 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"))) {
4275
4625
  return c.json(noProjectError(cfg.projectDir), 404);
4276
4626
  }
4277
4627
  const radial = new URL(c.req.url).searchParams.get("radial") === "1";
4278
4628
  const { svg } = renderGraph(ir, multi ? { boxes: "byStack" } : { radial });
4279
4629
  const srcZoom = components ? "components" : logical ? "logical" : opts.detail === 1 ? "composites" : opts.detail === 3 ? "attributes" : "resources";
4280
- 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;
4281
4631
  const srcNote = multi ? estateLensNote : notesFor(srcZoom, ir, srcCompositeEdgesAttached);
4282
4632
  return c.json({
4283
4633
  ir,
@@ -4379,22 +4729,36 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4379
4729
  try {
4380
4730
  if (cfg.projectDirs && cfg.projectDirs.length > 1) {
4381
4731
  const runtime = new URL(c.req.url).searchParams.get("runtime") === "1";
4382
- 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);
4383
4734
  if (est.dropped.length === est.total) {
4384
4735
  return c.json({ error: `no project in the estate could be graphed \u2014 ${est.dropped.map((d) => `${d.name}: ${d.reason}`).join("; ")}` }, 500);
4385
4736
  }
4386
- let ir2 = pruneRuntimeChildren(est.ir);
4387
- 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);
4388
4740
  ir2 = addValueMatchEdges(ir2);
4389
4741
  ir2 = addK8sDeclaredEdges(ir2);
4390
- ir2 = addClusterAnchorEdges(ir2, await boundK8sContext(env));
4391
- const { svg: svg2 } = renderGraph(ir2, { boxes: "byStack" });
4392
- 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);
4393
4744
  const coverNote = est.unobserved.length || est.dropped.length ? `live observe covered ${est.observed} of ${est.total} projects \u2014 ` + [
4394
4745
  ...est.unobserved.map((u) => `${u.name}: ${u.reason} (painted unobserved)`),
4395
4746
  ...est.dropped.map((d) => `${d.name}: dropped (${d.reason})`)
4396
4747
  ].join("; ") : void 0;
4397
- 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 ");
4398
4762
  return c.json({
4399
4763
  ir: ir2,
4400
4764
  svg: svg2,
@@ -4500,10 +4864,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4500
4864
  const nodes = {};
4501
4865
  for (const id of ids) {
4502
4866
  const observed = nodeObserved(parsed, id);
4867
+ const verdict = classifyObservedHealth(observed);
4503
4868
  nodes[id] = {
4504
4869
  observed,
4505
4870
  diff: nodeDiff(parsed, id),
4506
- health: classifyHealth(observed?.status),
4871
+ health: verdict.health,
4872
+ ...verdict.detail ? { healthDetail: verdict.detail } : {},
4507
4873
  // Field-level (per-manager) drift (#87, chant#1181) — null when no
4508
4874
  // lexicon in this diff carries a `deep` section at all.
4509
4875
  fieldDrift: nodeFieldDrift(parsed, id)
@@ -4527,12 +4893,14 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4527
4893
  return c.json({ error: "diff output was not JSON \u2014 chant may predate --live --json (needs 0.18.7+)" }, 500);
4528
4894
  }
4529
4895
  const observed = nodeObserved(parsed, node);
4896
+ const verdict = classifyObservedHealth(observed);
4530
4897
  return c.json({
4531
4898
  node,
4532
4899
  env,
4533
4900
  diff: nodeDiff(parsed, node),
4534
4901
  observed,
4535
- health: classifyHealth(observed?.status),
4902
+ health: verdict.health,
4903
+ ...verdict.detail ? { healthDetail: verdict.detail } : {},
4536
4904
  // Field-level (per-manager) drift (#87, chant#1181) — null when no
4537
4905
  // lexicon in this diff carries a `deep` section at all.
4538
4906
  fieldDrift: nodeFieldDrift(parsed, node)
@@ -4583,7 +4951,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4583
4951
  });
4584
4952
  const rel = relative(process.cwd(), webRoot) || ".";
4585
4953
  app.use("/*", serveStatic({ root: rel }));
4586
- app.get("/", serveStatic({ path: join10(rel, "index.html") }));
4954
+ app.get("/", serveStatic({ path: join11(rel, "index.html") }));
4587
4955
  return app;
4588
4956
  }
4589
4957
  async function startServer(cfg) {
@@ -4612,7 +4980,7 @@ async function startServer(cfg) {
4612
4980
  }
4613
4981
  }
4614
4982
  for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
4615
- if (existsSync9(join10(dir, "chant.config.ts"))) addRecent(dir);
4983
+ if (existsSync9(join11(dir, "chant.config.ts"))) addRecent(dir);
4616
4984
  }
4617
4985
  const broadcaster = new Broadcaster();
4618
4986
  const frames = new FrameBuffer();
@@ -4718,19 +5086,19 @@ async function startServer(cfg) {
4718
5086
  }
4719
5087
 
4720
5088
  // src/demos.ts
4721
- import { readFileSync as readFileSync8 } from "node:fs";
4722
- import { join as join11 } from "node:path";
5089
+ import { readFileSync as readFileSync10 } from "node:fs";
5090
+ import { join as join12 } from "node:path";
4723
5091
  import { spawnSync } from "node:child_process";
4724
5092
  function loadDemoRegistry(pkgRoot) {
4725
5093
  let raw;
4726
5094
  try {
4727
- raw = JSON.parse(readFileSync8(join11(pkgRoot, "demos.json"), "utf8"));
5095
+ raw = JSON.parse(readFileSync10(join12(pkgRoot, "demos.json"), "utf8"));
4728
5096
  } catch {
4729
5097
  return [];
4730
5098
  }
4731
- const list = raw?.demos;
4732
- if (!Array.isArray(list)) return [];
4733
- return list.filter((e) => {
5099
+ const list3 = raw?.demos;
5100
+ if (!Array.isArray(list3)) return [];
5101
+ return list3.filter((e) => {
4734
5102
  const d = e;
4735
5103
  if (!d || typeof d.name !== "string" || !d.name || typeof d.description !== "string") return false;
4736
5104
  if (d.source === "bundled") {
@@ -4754,9 +5122,9 @@ function missingRequirements(entry) {
4754
5122
  }
4755
5123
 
4756
5124
  // src/export.ts
4757
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync9, readdirSync as readdirSync4 } from "node:fs";
4758
- import { join as join12, dirname as dirname4, basename } from "node:path";
4759
- 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";
4760
5128
  var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
4761
5129
  function canonicalKey(path, params) {
4762
5130
  const flat = params.get("components") === "1" || params.get("logical") === "1";
@@ -4803,7 +5171,7 @@ function captureKeys(axes) {
4803
5171
  return [...keys];
4804
5172
  }
4805
5173
  function webDir() {
4806
- return join12(dirname4(fileURLToPath2(import.meta.url)), "..", "web");
5174
+ return join13(dirname5(fileURLToPath4(import.meta.url)), "..", "web");
4807
5175
  }
4808
5176
  function workerName(project, override) {
4809
5177
  const raw = override ?? `behold-${basename(project)}`;
@@ -4814,7 +5182,7 @@ async function runExport(cfg, outDir, opts = {}) {
4814
5182
  const app = createApp(cfg);
4815
5183
  const proj = await (await app.request("/api/project")).json();
4816
5184
  const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
4817
- const snapDir = join12(outDir, "snapshots");
5185
+ const snapDir = join13(outDir, "snapshots");
4818
5186
  mkdirSync2(snapDir, { recursive: true });
4819
5187
  const keyToFile = {};
4820
5188
  let ok = 0;
@@ -4823,7 +5191,7 @@ async function runExport(cfg, outDir, opts = {}) {
4823
5191
  const res = await app.request(key);
4824
5192
  const body = await res.text();
4825
5193
  const file = slug(key);
4826
- writeFileSync2(join12(snapDir, file), body);
5194
+ writeFileSync2(join13(snapDir, file), body);
4827
5195
  keyToFile[key] = `snapshots/${file}`;
4828
5196
  if (res.ok) ok++;
4829
5197
  else failed++;
@@ -4835,21 +5203,21 @@ async function runExport(cfg, outDir, opts = {}) {
4835
5203
  axes,
4836
5204
  keyToFile
4837
5205
  };
4838
- writeFileSync2(join12(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
4839
- 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(
4840
5208
  /<\/head>/i,
4841
5209
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
4842
5210
  </head>`
4843
5211
  );
4844
- writeFileSync2(join12(outDir, "index.html"), html);
5212
+ writeFileSync2(join13(outDir, "index.html"), html);
4845
5213
  for (const f of readdirSync4(webDir())) {
4846
5214
  if (f === "index.html") continue;
4847
- copyFileSync(join12(webDir(), f), join12(outDir, f));
5215
+ copyFileSync(join13(webDir(), f), join13(outDir, f));
4848
5216
  }
4849
- writeFileSync2(join12(outDir, "README.md"), BUNDLE_README);
5217
+ writeFileSync2(join13(outDir, "README.md"), BUNDLE_README);
4850
5218
  const name = workerName(cfg.projectDir, opts.name);
4851
5219
  writeFileSync2(
4852
- join12(outDir, "wrangler.jsonc"),
5220
+ join13(outDir, "wrangler.jsonc"),
4853
5221
  JSON.stringify(
4854
5222
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
4855
5223
  null,
@@ -4896,15 +5264,239 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
4896
5264
  (\`wrangler pages deploy .\`) all work.
4897
5265
  `;
4898
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
+
4899
5482
  // src/cli.ts
4900
5483
  var USAGE = `behold \u2014 a live control plane on chant (read-only core)
4901
5484
 
4902
5485
  Usage:
4903
5486
  behold demo [name] [target-dir] [--port <n>] [--list]
5487
+ behold doctor [project-dir] [--json]
4904
5488
  behold preview [project-dir] [--port <n>] [--emulator]
4905
5489
  behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
4906
5490
  behold serve <project-dir\u2026> [--port <n>] [--env <name>] [--poll <secs>] [--local]
4907
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
+
4908
5500
  demo The five-minute path from npm \u2014 no chant project needed. A catalog
4909
5501
  of demo estates (behold demo --list): bundled ones copy out of the
4910
5502
  package into a directory that's yours to edit; git ones shallow-
@@ -4965,6 +5557,8 @@ Options:
4965
5557
  names, unlike --local; kept separate because Loom's own
4966
5558
  \`scripts/local/local-up.sh\` Floci setup clashes on :4566
4967
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.
4968
5562
  --out <dir> export only: output directory (default ./behold-export).
4969
5563
  --name <worker> export only: Cloudflare Worker name in the generated
4970
5564
  wrangler.jsonc.
@@ -4985,6 +5579,10 @@ async function run3(argv) {
4985
5579
  await runDemo(rest);
4986
5580
  return;
4987
5581
  }
5582
+ if (cmd === "doctor") {
5583
+ await runDoctor(rest);
5584
+ return;
5585
+ }
4988
5586
  if (cmd === "preview") {
4989
5587
  await runPreview(rest);
4990
5588
  return;
@@ -5048,7 +5646,7 @@ ${USAGE}`);
5048
5646
  process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
5049
5647
  process.exit(2);
5050
5648
  }
5051
- const dirs = projectDirs.map((d) => resolve3(d));
5649
+ const dirs = projectDirs.map((d) => resolve4(d));
5052
5650
  for (const d of dirs) warnIfNotChantProject(d);
5053
5651
  await startServer({
5054
5652
  projectDir: dirs[0],
@@ -5062,15 +5660,47 @@ ${USAGE}`);
5062
5660
  });
5063
5661
  }
5064
5662
  function warnIfNotChantProject(dir) {
5065
- 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
+ }
5066
5673
  process.stderr.write(
5067
5674
  `behold: warning \u2014 ${dir} has no chant.config.ts; this doesn't look like a chant project.
5068
- 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).
5069
5676
  `
5070
5677
  );
5071
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
+ }
5072
5702
  async function runDemo(rest) {
5073
- const pkgRoot = join13(dirname5(fileURLToPath3(import.meta.url)), "..");
5703
+ const pkgRoot = join14(dirname6(fileURLToPath5(import.meta.url)), "..");
5074
5704
  const registry = loadDemoRegistry(pkgRoot);
5075
5705
  let port = 4600;
5076
5706
  let name;
@@ -5122,12 +5752,12 @@ async function runDemo(rest) {
5122
5752
  `);
5123
5753
  process.exit(2);
5124
5754
  }
5125
- const target = resolve3(
5126
- 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))
5127
5757
  );
5128
5758
  if (!existsSync10(target)) {
5129
5759
  if (entry.source === "bundled") {
5130
- const bundled = join13(pkgRoot, entry.dir);
5760
+ const bundled = join14(pkgRoot, entry.dir);
5131
5761
  if (!existsSync10(bundled)) {
5132
5762
  process.stderr.write(`behold demo ${entry.name}: this install has no bundled ${entry.dir}
5133
5763
  `);
@@ -5137,7 +5767,7 @@ async function runDemo(rest) {
5137
5767
  `);
5138
5768
  cpSync(bundled, target, {
5139
5769
  recursive: true,
5140
- filter: (src) => !relative2(bundled, src).split(sep).includes("node_modules")
5770
+ filter: (src) => !relative3(bundled, src).split(sep).includes("node_modules")
5141
5771
  });
5142
5772
  } else {
5143
5773
  process.stdout.write(`behold demo ${entry.name} \u2192 cloning ${entry.repo} to ${target}
@@ -5153,7 +5783,7 @@ async function runDemo(rest) {
5153
5783
  process.stdout.write(`behold demo ${entry.name} \u2192 reusing ${target}
5154
5784
  `);
5155
5785
  }
5156
- if (existsSync10(join13(target, "package.json")) && !existsSync10(join13(target, "node_modules"))) {
5786
+ if (existsSync10(join14(target, "package.json")) && !existsSync10(join14(target, "node_modules"))) {
5157
5787
  process.stdout.write(`behold demo ${entry.name} \u2192 npm install\u2026
5158
5788
  `);
5159
5789
  const r = spawnSync2("npm", ["install"], { cwd: target, stdio: "inherit", shell: process.platform === "win32" });
@@ -5175,7 +5805,7 @@ async function runDemo(rest) {
5175
5805
  }
5176
5806
  process.stdout.write(`behold demo ${entry.name} \u2192 serving. Blue = declared; Deploy turns it green.
5177
5807
  `);
5178
- 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];
5179
5809
  const serveArgs = ["serve", ...serveDirs, "--port", String(port)];
5180
5810
  if (entry.serve.local) serveArgs.push("--local");
5181
5811
  if (entry.serve.env) serveArgs.push("--env", entry.serve.env);
@@ -5203,7 +5833,7 @@ async function runPreview(rest) {
5203
5833
  process.stderr.write("behold preview: --port must be a number\n");
5204
5834
  process.exit(2);
5205
5835
  }
5206
- const projectDir = resolve3(dirArg ?? process.cwd());
5836
+ const projectDir = resolve4(dirArg ?? process.cwd());
5207
5837
  if (!existsSync10(projectDir)) {
5208
5838
  process.stderr.write(`behold preview: project not found at ${projectDir}
5209
5839
  `);
@@ -5224,21 +5854,21 @@ async function runPreview(rest) {
5224
5854
  await startServer({ projectDir, port, env: "local", previewMode: true });
5225
5855
  }
5226
5856
  async function runExportCmd(rest) {
5227
- let outDir = resolve3("behold-export");
5857
+ let outDir = resolve4("behold-export");
5228
5858
  let env;
5229
5859
  let name;
5230
5860
  let dirArg;
5231
5861
  let emulator = false;
5232
5862
  for (let i = 0; i < rest.length; i++) {
5233
5863
  const a = rest[i];
5234
- if (a === "--out") outDir = resolve3(rest[++i]);
5864
+ if (a === "--out") outDir = resolve4(rest[++i]);
5235
5865
  else if (a === "--env") env = rest[++i];
5236
5866
  else if (a === "--name") name = rest[++i];
5237
5867
  else if (a === "--emulator") emulator = true;
5238
5868
  else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
5239
5869
  else if (!a.startsWith("-")) dirArg = a;
5240
5870
  }
5241
- const projectDir = resolve3(dirArg ?? process.cwd());
5871
+ const projectDir = resolve4(dirArg ?? process.cwd());
5242
5872
  if (emulator) {
5243
5873
  injectEmulatorEnv(env);
5244
5874
  env ??= "local";
@@ -5254,7 +5884,7 @@ function isMainModule() {
5254
5884
  const entry = process.argv[1];
5255
5885
  if (!entry) return false;
5256
5886
  try {
5257
- return realpathSync(entry) === realpathSync(fileURLToPath3(import.meta.url));
5887
+ return realpathSync(entry) === realpathSync(fileURLToPath5(import.meta.url));
5258
5888
  } catch {
5259
5889
  return false;
5260
5890
  }