@intentius/behold 0.3.0 → 0.4.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 (3) hide show
  1. package/dist/cli.js +127 -7
  2. package/package.json +1 -1
  3. package/web/app.js +5 -2
package/dist/cli.js CHANGED
@@ -164,8 +164,9 @@ function readInfo(cfg) {
164
164
  const envNames = (v) => Array.isArray(v) ? v.map((x) => typeof x === "string" ? x : typeof x?.name === "string" ? x.name : void 0).filter((x) => !!x) : [];
165
165
  const stacks = readStacks(cfg?.stacks);
166
166
  const k8sProfiles = readK8sProfiles(cfg?.k8s);
167
+ const environments = envNames(cfg?.environments);
167
168
  return {
168
- environments: envNames(cfg?.environments),
169
+ environments: environments.length ? environments : Object.keys(k8sProfiles ?? {}),
169
170
  lexicons: arr(cfg?.lexicons),
170
171
  ...k8sProfiles ? { k8sProfiles } : {},
171
172
  ...typeof cfg?.sourceDir === "string" ? { sourceDir: cfg.sourceDir } : {},
@@ -186,6 +187,36 @@ function parseEnvironmentNames(content) {
186
187
  }
187
188
  return [...body.matchAll(/["'`]([^"'`]+)["'`]/g)].map((m) => m[1]);
188
189
  }
190
+ function parseK8sProfileNames(content) {
191
+ const m = content.match(/\bprofiles\s*:\s*\{/);
192
+ if (!m || m.index === void 0) return [];
193
+ const start = m.index + m[0].length;
194
+ let depth = 1;
195
+ let end = start;
196
+ for (; end < content.length && depth > 0; end++) {
197
+ if (content[end] === "{") depth++;
198
+ else if (content[end] === "}") depth--;
199
+ }
200
+ const body = content.slice(start, end - 1);
201
+ const names = [];
202
+ let d = 0;
203
+ let segment = "";
204
+ for (const ch of body) {
205
+ if (ch === "{") {
206
+ if (d === 0) {
207
+ const key = segment.match(/(["'`]?)([\w.-]+)\1\s*:\s*$/);
208
+ if (key) names.push(key[2]);
209
+ segment = "";
210
+ }
211
+ d++;
212
+ } else if (ch === "}") {
213
+ d--;
214
+ } else if (d === 0) {
215
+ segment += ch;
216
+ }
217
+ }
218
+ return names;
219
+ }
189
220
  function parseStringLiteral(content, key) {
190
221
  const m = content.match(new RegExp(`\\b${key}\\s*:\\s*["'\`]([^"'\`]+)["'\`]`));
191
222
  return m?.[1];
@@ -202,8 +233,9 @@ async function detectProject(projectDir) {
202
233
  }
203
234
  const content = readFileSync2(path, "utf8");
204
235
  const sourceDir = parseStringLiteral(content, "sourceDir");
236
+ const environments = parseEnvironmentNames(content);
205
237
  return {
206
- environments: parseEnvironmentNames(content),
238
+ environments: environments.length ? environments : parseK8sProfileNames(content),
207
239
  lexicons: parseStringArray(content, "lexicons"),
208
240
  ...sourceDir ? { sourceDir } : {}
209
241
  };
@@ -1869,7 +1901,7 @@ function projectK8sLogical(ir, env, boundContext) {
1869
1901
  if (k8s.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
1870
1902
  const cluster = boundManagedCluster(ir.nodes, boundContext);
1871
1903
  const CLUSTER_TITLE = cluster ? cluster.id : env ? `cluster ${env}` : "cluster";
1872
- const CLUSTER_SCOPED = "cluster-scoped";
1904
+ const CLUSTER_SCOPED2 = "cluster-scoped";
1873
1905
  const namespaceTitle = (ns) => `namespace ${ns}`;
1874
1906
  const byContainer = {};
1875
1907
  const child = (parent, c) => {
@@ -1885,10 +1917,10 @@ function projectK8sLogical(ir, env, boundContext) {
1885
1917
  child(namespaceTitle(ns), n.id);
1886
1918
  } else {
1887
1919
  clusterScoped = true;
1888
- child(CLUSTER_SCOPED, n.id);
1920
+ child(CLUSTER_SCOPED2, n.id);
1889
1921
  }
1890
1922
  }
1891
- if (clusterScoped) child(CLUSTER_TITLE, CLUSTER_SCOPED);
1923
+ if (clusterScoped) child(CLUSTER_TITLE, CLUSTER_SCOPED2);
1892
1924
  const kept = new Set(headline.map((n) => n.id));
1893
1925
  const edges = ir.edges.filter((e) => kept.has(e.from) && kept.has(e.to));
1894
1926
  return { ir: { nodes: headline, edges, groups: {} }, byContainer };
@@ -2550,6 +2582,22 @@ function edgelessNote(zoom, ir) {
2550
2582
  if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
2551
2583
  return "no edges \u2014 nothing in this estate references anything else";
2552
2584
  }
2585
+ var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
2586
+ "K8s::Core::Namespace",
2587
+ "K8s::Rbac::ClusterRole",
2588
+ "K8s::Rbac::ClusterRoleBinding",
2589
+ "K8s::Storage::StorageClass",
2590
+ "K8s::Apiextensions::CustomResourceDefinition"
2591
+ ]);
2592
+ function namespaceMismatchNote(nodes) {
2593
+ const k8s = nodes.filter((n) => n?.lexicon === "k8s");
2594
+ if (k8s.length === 0) return void 0;
2595
+ const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
2596
+ if (pending.length < 3) return void 0;
2597
+ if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
2598
+ if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
2599
+ return `${pending.length} pending k8s objects declare no metadata.namespace \u2014 if a controller stamps it at apply time (e.g. Flux's targetNamespace), the live read looked in "default", not where they run`;
2600
+ }
2553
2601
  function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore) {
2554
2602
  const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
2555
2603
  const notes = [primary, edgelessNote(zoom, ir)].filter((n) => n !== void 0);
@@ -3513,6 +3561,44 @@ async function composeEstate(projectDirs, opts = {}) {
3513
3561
  );
3514
3562
  return composeStacks(stacks);
3515
3563
  }
3564
+ var firstLine = (e) => {
3565
+ const msg = e instanceof Error ? e.message : String(e);
3566
+ const m = msg.match(/exited \d+:\s*([\s\S]*)/);
3567
+ return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
3568
+ };
3569
+ async function composeEstateOverlay(projectDirs, opts, classify) {
3570
+ const names = shortStackNames(projectDirs);
3571
+ const unobserved = [];
3572
+ const dropped = [];
3573
+ const stacks = new Array(projectDirs.length);
3574
+ await Promise.all(
3575
+ projectDirs.map(async (dir, i) => {
3576
+ const name = names[i];
3577
+ try {
3578
+ stacks[i] = { name, ir: classify(await graphIr(dir, { ...opts, live: true, overlay: true })) };
3579
+ } catch (err) {
3580
+ const reason = firstLine(err);
3581
+ try {
3582
+ const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
3583
+ const src = await graphIr(dir, srcOpts);
3584
+ for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
3585
+ stacks[i] = { name, ir: src };
3586
+ unobserved.push({ name, reason });
3587
+ } catch (err2) {
3588
+ dropped.push({ name, reason: firstLine(err2) });
3589
+ }
3590
+ }
3591
+ })
3592
+ );
3593
+ const present = stacks.filter((s) => !!s);
3594
+ return {
3595
+ ir: composeStacks(present),
3596
+ observed: present.length - unobserved.length,
3597
+ total: projectDirs.length,
3598
+ unobserved,
3599
+ dropped
3600
+ };
3601
+ }
3516
3602
 
3517
3603
  // src/events.ts
3518
3604
  import { watch, existsSync as existsSync8 } from "node:fs";
@@ -4034,6 +4120,11 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4034
4120
  ...cfg.projectDirs && cfg.projectDirs.length > 1 ? { projectDirs: cfg.projectDirs } : {},
4035
4121
  recents: listRecents().map((r) => r.dir),
4036
4122
  environments,
4123
+ // #191: the cluster each k8s env is bound to (`k8s.profiles.<env>.
4124
+ // context`) — the SPA's env picker shows it (`home → home-cloud`) so a
4125
+ // wrong-cluster pick is visible BEFORE the read, not after (the failure
4126
+ // chant#1100 exists to prevent, and the one behind #192's red herring).
4127
+ ...k8sProfiles ? { k8sContexts: Object.fromEntries(Object.entries(k8sProfiles).flatMap(([e, p]) => p.context ? [[e, p.context]] : [])) } : {},
4037
4128
  lexicons,
4038
4129
  currentEnv: cfg.env ?? null,
4039
4130
  // v0.1.0 preview: the SPA hides git/PR ops + arbitrary-project affordances.
@@ -4138,6 +4229,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4138
4229
  let metaEnv = cfg.env ?? null;
4139
4230
  if (multi) {
4140
4231
  ir = await composeEstate(cfg.projectDirs, opts);
4232
+ ir = addValueMatchEdges(ir);
4233
+ ir = addK8sDeclaredEdges(ir);
4234
+ ir = addClusterAnchorEdges(ir, await boundK8sContext(metaEnv ?? void 0));
4141
4235
  } else if (components) {
4142
4236
  ir = await componentGraphIr(cfg.projectDir, opts);
4143
4237
  const env = opts.env ?? cfg.env;
@@ -4179,7 +4273,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4179
4273
  const radial = new URL(c.req.url).searchParams.get("radial") === "1";
4180
4274
  const { svg } = renderGraph(ir, multi ? { boxes: "byStack" } : { radial });
4181
4275
  const srcZoom = components ? "components" : logical ? "logical" : opts.detail === 1 ? "composites" : opts.detail === 3 ? "attributes" : "resources";
4182
- const srcNote = multi ? void 0 : notesFor(srcZoom, ir, srcCompositeEdgesAttached);
4276
+ 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;
4277
+ const srcNote = multi ? estateLensNote : notesFor(srcZoom, ir, srcCompositeEdgesAttached);
4183
4278
  return c.json({
4184
4279
  ir,
4185
4280
  svg,
@@ -4278,6 +4373,30 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4278
4373
  }
4279
4374
  const logical = new URL(c.req.url).searchParams.get("logical") === "1";
4280
4375
  try {
4376
+ if (cfg.projectDirs && cfg.projectDirs.length > 1) {
4377
+ const runtime = new URL(c.req.url).searchParams.get("runtime") === "1";
4378
+ const est = await composeEstateOverlay(cfg.projectDirs, { ...tierTargetOpts(query), detail: query.detail, env }, reclassifyOverlay);
4379
+ if (est.dropped.length === est.total) {
4380
+ return c.json({ error: `no project in the estate could be graphed \u2014 ${est.dropped.map((d) => `${d.name}: ${d.reason}`).join("; ")}` }, 500);
4381
+ }
4382
+ let ir2 = pruneRuntimeChildren(est.ir);
4383
+ if ((query.detail ?? 2) < 3) ir2 = pruneImports(ir2);
4384
+ ir2 = addValueMatchEdges(ir2);
4385
+ ir2 = addK8sDeclaredEdges(ir2);
4386
+ ir2 = addClusterAnchorEdges(ir2, await boundK8sContext(env));
4387
+ const { svg: svg2 } = renderGraph(ir2, { boxes: "byStack" });
4388
+ 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;
4389
+ const coverNote = est.unobserved.length || est.dropped.length ? `live observe covered ${est.observed} of ${est.total} projects \u2014 ` + [
4390
+ ...est.unobserved.map((u) => `${u.name}: ${u.reason} (painted unobserved)`),
4391
+ ...est.dropped.map((d) => `${d.name}: dropped (${d.reason})`)
4392
+ ].join("; ") : void 0;
4393
+ const note2 = [lensNote, coverNote, namespaceMismatchNote(ir2.nodes)].filter(Boolean).join(" \xB7 ");
4394
+ return c.json({
4395
+ ir: ir2,
4396
+ svg: svg2,
4397
+ meta: { projectDir: cfg.projectDir, env, mode: "overlay", estate: est.total, ...note2 ? { note: note2 } : {} }
4398
+ });
4399
+ }
4281
4400
  const opts = { ...query, live: true, overlay: true, env, ...logical ? { detail: 3 } : {} };
4282
4401
  let ir = reclassifyOverlay(await graphIr(cfg.projectDir, opts));
4283
4402
  const boundContext = await boundK8sContext(env);
@@ -4315,8 +4434,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4315
4434
  const { svg } = renderGraph(ir, { boxes: "byContainer", radial: new URL(c.req.url).searchParams.get("radial") === "1" });
4316
4435
  const zoom = new URL(c.req.url).searchParams.get("runtime") === "1" ? "runtime" : query.detail === 1 ? "composites" : query.detail === 3 ? "attributes" : "resources";
4317
4436
  const tierNote = tierMismatchNote(ir, beholdConfig.tiers, query.tier);
4437
+ const nsNote = namespaceMismatchNote(ir.nodes);
4318
4438
  const zoomNotes = notesFor(zoom, ir, compositeEdgesAttached);
4319
- const note = [tierNote, zoomNotes].filter(Boolean).join(" \xB7 ");
4439
+ const note = [tierNote, nsNote, zoomNotes].filter(Boolean).join(" \xB7 ");
4320
4440
  return c.json({ ir, svg, meta: { projectDir: cfg.projectDir, env, mode: "overlay", ...note ? { note } : {} } });
4321
4441
  } catch (err) {
4322
4442
  return errorResponse(c, query, err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
6
6
  "bin": {
package/web/app.js CHANGED
@@ -838,13 +838,16 @@ function renderPanelScope() {
838
838
  }, "The declared source graph — no live overlay"),
839
839
  );
840
840
  for (const e of environments) {
841
+ // #191: a k8s env shows the cluster it's bound to (`home → home-cloud`),
842
+ // so a wrong-cluster pick is visible before the read, not after.
843
+ const ctx = projectInfo && projectInfo.k8sContexts && projectInfo.k8sContexts[e];
841
844
  host.appendChild(
842
- panelOpt(e, view.env === e, () => {
845
+ panelOpt(ctx ? `${e} → ${ctx}` : e, view.env === e, () => {
843
846
  view.env = e;
844
847
  resetDialCaches();
845
848
  renderStatusbar();
846
849
  load();
847
- }, `Live overlay for ${e}`),
850
+ }, ctx ? `Live overlay for ${e} — bound to kubeconfig context ${ctx}` : `Live overlay for ${e}`),
848
851
  );
849
852
  }
850
853
  if (!environments.length) host.appendChild(panelMuted("no environments declared"));