@intentius/behold 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -28,6 +28,10 @@ npx @intentius/behold demo # copies the example to ./behold-demo, ins
28
28
  ```
29
29
 
30
30
  The copied project is yours: edit its source and watch the graph change live.
31
+ There's a whole catalog — `behold demo --list` names the rest (`behold demo
32
+ k8s` stands the same loop up on a throwaway k3d cluster: runtime Pods, field
33
+ ownership). Every loaded demo lands in the panel's recents, so switching
34
+ between them is the Scope tab.
31
35
 
32
36
  Already have a chant project?
33
37
 
package/demos.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "demos": [
3
+ {
4
+ "name": "writes",
5
+ "description": "One S3 bucket + policy on a local Floci emulator — blue to green in five minutes. The default.",
6
+ "source": "bundled",
7
+ "dir": "example-writes",
8
+ "requires": ["docker"],
9
+ "serve": { "local": true, "env": "prod" }
10
+ },
11
+ {
12
+ "name": "k8s",
13
+ "description": "An nginx Deployment + Service on a throwaway k3d cluster — runtime children (Pods), field ownership, honest unobserved. Cluster persists across runs; scripts/local/local-down.sh removes it.",
14
+ "source": "bundled",
15
+ "dir": "example-k8s",
16
+ "requires": ["docker", "k3d", "kubectl"],
17
+ "setup": "bash scripts/local/local-up.sh",
18
+ "serve": { "local": true, "env": "local" }
19
+ },
20
+ {
21
+ "name": "fountain",
22
+ "description": "The mature estate: self-hosted fountain (a real app) on a throwaway k3d cluster — tiers (try the ha tier picker), seams, drift, runtime Pods. Clones INTENTIUS/fountain-ops; ~5 minutes, mostly image pulls. `just down` in the clone removes the cluster.",
23
+ "source": "git",
24
+ "repo": "https://github.com/INTENTIUS/fountain-ops",
25
+ "requires": ["docker", "k3d", "kubectl", "jq", "just"],
26
+ "setup": "just up",
27
+ "serve": { "env": "local" }
28
+ }
29
+ ]
30
+ }
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/cli.ts
2
- import { resolve as resolve3, dirname as dirname5, join as join12, relative as relative2, sep } from "node:path";
2
+ import { resolve as resolve3, dirname as dirname5, join as join13, relative as relative2, sep } from "node:path";
3
3
  import { realpathSync, existsSync as existsSync10, cpSync } from "node:fs";
4
- import { spawnSync } from "node:child_process";
4
+ import { spawnSync as spawnSync2 } from "node:child_process";
5
5
  import { fileURLToPath as fileURLToPath3 } from "node:url";
6
6
 
7
7
  // src/server.ts
@@ -3729,17 +3729,19 @@ function safeJson(value) {
3729
3729
  return JSON.stringify(value).replace(/</g, "\\u003c");
3730
3730
  }
3731
3731
  var LANES_CSS = `
3732
- #behold-lanes { position: fixed; left: 0; right: 0; bottom: 0; background: #0d1117;
3733
- border-top: 1px solid #30363d; padding: 8px 12px 10px; font: 12px ui-sans-serif, system-ui, sans-serif; color: #8b949e; }
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); }
3734
3735
  #behold-lanes .hd { display: flex; gap: 14px; align-items: baseline; margin-bottom: 4px; }
3735
- #behold-lanes .hd .rt { color: #d29922; }
3736
+ #behold-lanes .hd .rt { color: var(--foreign, #d29922); }
3737
+ #behold-lanes .hd a { color: var(--pending, #58a6ff); text-decoration: none; margin-left: auto; }
3736
3738
  #behold-lanes canvas { display: block; width: 100%; cursor: pointer; }
3737
3739
  #behold-diff { position: fixed; right: 12px; bottom: 156px; width: 260px; max-height: 40vh; overflow: auto;
3738
- background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 10px 12px; font: 12px ui-sans-serif, system-ui, sans-serif;
3739
- color: #e6edf3; display: none; }
3740
- #behold-diff h4 { margin: 0 0 6px; font-size: 12px; color: #8b949e; }
3741
- #behold-diff .a { color: #3fb950; } #behold-diff .r { color: #f85149; } #behold-diff .c { color: #d29922; }
3742
- body { padding-bottom: 150px; }`;
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); }`;
3744
+ 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>`;
3743
3745
  function laneStripScript(frames) {
3744
3746
  return `<script>
3745
3747
  const LF = ${safeJson(frames)};
@@ -3750,6 +3752,7 @@ const LF = ${safeJson(frames)};
3750
3752
  const rowH = 22, padL = 96, padR = 16, padT = 6;
3751
3753
  const H = padT + subs.length * rowH + 24;
3752
3754
  const t0 = LF[0].t, tN = LF[LF.length - 1].t, span = Math.max(1, tN - t0);
3755
+ const css = (n, fb) => (getComputedStyle(document.documentElement).getPropertyValue(n).trim() || fb);
3753
3756
  const offset = {}; // per-substrate time offset (graph-inert)
3754
3757
  let cur = LF.length - 1, focus = null, pair = null;
3755
3758
 
@@ -3763,7 +3766,7 @@ const LF = ${safeJson(frames)};
3763
3766
  const inNow = now !== undefined, inPrev = prev !== undefined;
3764
3767
  return inNow !== inPrev || (inNow && inPrev && now !== prev);
3765
3768
  }
3766
- const color = s => s === "good" ? "#3fb950" : s === "warn" ? "#d29922" : s === "accent" ? "#58a6ff" : "#6e7681";
3769
+ const color = s => s === "good" ? css("--managed", "#3fb950") : s === "warn" ? css("--foreign", "#d29922") : s === "accent" ? css("--pending", "#58a6ff") : css("--edge", "#6e7681");
3767
3770
 
3768
3771
  function draw() {
3769
3772
  const dpr = window.devicePixelRatio || 1;
@@ -3772,22 +3775,22 @@ const LF = ${safeJson(frames)};
3772
3775
  c.clearRect(0, 0, host.clientWidth, H); c.font = "12px ui-sans-serif, system-ui, sans-serif";
3773
3776
  subs.forEach((s, r) => {
3774
3777
  const y = padT + r * rowH + rowH / 2;
3775
- c.fillStyle = offset[s] ? "#d29922" : "#8b949e"; c.textAlign = "left"; c.fillText(s, 8, y + 4);
3776
- c.strokeStyle = "#21262d"; c.beginPath(); c.moveTo(padL, y); c.lineTo(host.clientWidth - padR, y); c.stroke();
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();
3777
3780
  LF.forEach((f, i) => {
3778
3781
  if (!f.byLexicon[s]) return;
3779
3782
  // dot per substrate; brighter when a node in this substrate changed at i
3780
3783
  const changed = Object.keys(f.status).some(id => f.lexicon[id] === s && changedAt(id, i));
3781
3784
  const hi = focus && f.lexicon[focus] === s && changedAt(focus, i);
3782
- c.fillStyle = hi ? "#f0f6fc" : changed ? color(mode(f, s)) : "#30363d";
3785
+ c.fillStyle = hi ? css("--fg", "#f0f6fc") : changed ? color(mode(f, s)) : css("--line", "#30363d");
3783
3786
  c.beginPath(); c.arc(xOf(i, s), y, i === cur ? 5 : hi ? 4.5 : 3.5, 0, 7); c.fill();
3784
3787
  });
3785
3788
  });
3786
- const px = baseX(cur); c.strokeStyle = "#58a6ff"; c.lineWidth = 1.5;
3789
+ const px = baseX(cur); c.strokeStyle = css("--pending", "#58a6ff"); c.lineWidth = 1.5;
3787
3790
  c.beginPath(); c.moveTo(px, padT - 2); c.lineTo(px, padT + subs.length * rowH); c.stroke();
3788
- if (pair != null) { const qx = baseX(pair); c.strokeStyle = "#d29922"; c.setLineDash([3,3]);
3791
+ if (pair != null) { const qx = baseX(pair); c.strokeStyle = css("--foreign", "#d29922"); c.setLineDash([3,3]);
3789
3792
  c.beginPath(); c.moveTo(qx, padT - 2); c.lineTo(qx, padT + subs.length * rowH); c.stroke(); c.setLineDash([]); }
3790
- c.fillStyle = "#e6edf3"; c.textAlign = "center"; c.fillText(LF[cur].name, px, padT + subs.length * rowH + 16);
3793
+ c.fillStyle = css("--fg", "#e6edf3"); c.textAlign = "center"; c.fillText(LF[cur].name, px, padT + subs.length * rowH + 16);
3791
3794
  document.getElementById("behold-lanes-meta").textContent =
3792
3795
  LF.length + " frames \xB7 frame " + (cur + 1) + "/" + LF.length + (focus ? " \xB7 focus " + focus : "");
3793
3796
  document.getElementById("behold-lanes-rt").style.display = anyOffset() ? "inline" : "none";
@@ -3828,6 +3831,7 @@ const LF = ${safeJson(frames)};
3828
3831
  window.addEventListener("keydown", (e) => { if (e.key === "ArrowLeft") go(cur - 1); if (e.key === "ArrowRight") go(cur + 1);
3829
3832
  if (e.key === "Escape") { focus = null; pair = null; showDiff(); draw(); } });
3830
3833
  window.addEventListener("resize", draw);
3834
+ window.addEventListener("behold-theme", draw); // #198: theme flip repaints the strip
3831
3835
 
3832
3836
  // focus cursor: click a graph node \u2192 highlight where it changed (graph \u2192 lanes)
3833
3837
  function wireNodes() { document.querySelectorAll("[data-node-id]").forEach(el => {
@@ -3853,7 +3857,7 @@ function renderLanes(frames, summaries) {
3853
3857
  status: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.attrs?._status ?? ""])),
3854
3858
  lexicon: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.lexicon]))
3855
3859
  }));
3856
- const strip = `<style>${LANES_CSS}</style><div id="behold-diff"></div><div id="behold-lanes"><div class="hd"><span>deployment lanes</span><span id="behold-lanes-meta"></span><span class="rt" id="behold-lanes-rt" style="display:none">offset \u2014 graph shows real time</span></div><canvas id="behold-lanes-canvas"></canvas></div>` + laneStripScript(laneFrames);
3860
+ const strip = `<style>${LANES_CSS}</style>` + THEME_BOOT + `<div id="behold-diff"></div><div id="behold-lanes"><div class="hd"><span>deployment lanes</span><span id="behold-lanes-meta"></span><span class="rt" id="behold-lanes-rt" style="display:none">offset \u2014 graph shows real time</span><a href="/">\u2190 graph</a><span id="behold-lanes-pickers"></span></div><canvas id="behold-lanes-canvas"></canvas></div>` + laneStripScript(laneFrames);
3857
3861
  return doc.includes("</body>") ? doc.replace("</body>", `${strip}</body>`) : doc + strip;
3858
3862
  }
3859
3863
 
@@ -4009,7 +4013,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4009
4013
  const all = frames.all();
4010
4014
  if (all.length < 2) {
4011
4015
  return c.html(
4012
- `<!doctype html><meta charset=utf-8><body style="font:14px system-ui;background:#0d1117;color:#8b949e;padding:2rem"><h3 style="color:#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:#e6edf3">\u21BB Refresh</b> (captures the current live state), run a <b style="color:#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:#58a6ff;text-decoration:none">\u2190 back to the graph</a></p></body>`
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>`
4013
4017
  );
4014
4018
  }
4015
4019
  return c.html(renderLanes(all, frames.summaries()));
@@ -4607,6 +4611,9 @@ async function startServer(cfg) {
4607
4611
  );
4608
4612
  }
4609
4613
  }
4614
+ for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
4615
+ if (existsSync9(join10(dir, "chant.config.ts"))) addRecent(dir);
4616
+ }
4610
4617
  const broadcaster = new Broadcaster();
4611
4618
  const frames = new FrameBuffer();
4612
4619
  const runner = new OpRunner({
@@ -4710,9 +4717,43 @@ async function startServer(cfg) {
4710
4717
  });
4711
4718
  }
4712
4719
 
4720
+ // src/demos.ts
4721
+ import { readFileSync as readFileSync8 } from "node:fs";
4722
+ import { join as join11 } from "node:path";
4723
+ import { spawnSync } from "node:child_process";
4724
+ function loadDemoRegistry(pkgRoot) {
4725
+ let raw;
4726
+ try {
4727
+ raw = JSON.parse(readFileSync8(join11(pkgRoot, "demos.json"), "utf8"));
4728
+ } catch {
4729
+ return [];
4730
+ }
4731
+ const list = raw?.demos;
4732
+ if (!Array.isArray(list)) return [];
4733
+ return list.filter((e) => {
4734
+ const d = e;
4735
+ if (!d || typeof d.name !== "string" || !d.name || typeof d.description !== "string") return false;
4736
+ if (d.source === "bundled") {
4737
+ if (typeof d.dir !== "string" || !d.dir) return false;
4738
+ } else if (d.source === "git") {
4739
+ if (typeof d.repo !== "string" || !d.repo) return false;
4740
+ } else {
4741
+ return false;
4742
+ }
4743
+ if (!Array.isArray(d.requires) || d.requires.some((r) => typeof r !== "string")) return false;
4744
+ if (!d.serve || typeof d.serve !== "object") return false;
4745
+ return true;
4746
+ });
4747
+ }
4748
+ function missingRequirements(entry) {
4749
+ const bins = entry.source === "git" && !entry.requires.includes("git") ? [...entry.requires, "git"] : entry.requires;
4750
+ const finder = process.platform === "win32" ? "where" : "which";
4751
+ return bins.filter((bin) => spawnSync(finder, [bin], { stdio: "ignore" }).status !== 0);
4752
+ }
4753
+
4713
4754
  // src/export.ts
4714
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync8, readdirSync as readdirSync4 } from "node:fs";
4715
- import { join as join11, dirname as dirname4, basename } from "node:path";
4755
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync9, readdirSync as readdirSync4 } from "node:fs";
4756
+ import { join as join12, dirname as dirname4, basename } from "node:path";
4716
4757
  import { fileURLToPath as fileURLToPath2 } from "node:url";
4717
4758
  var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
4718
4759
  function canonicalKey(path, params) {
@@ -4760,7 +4801,7 @@ function captureKeys(axes) {
4760
4801
  return [...keys];
4761
4802
  }
4762
4803
  function webDir() {
4763
- return join11(dirname4(fileURLToPath2(import.meta.url)), "..", "web");
4804
+ return join12(dirname4(fileURLToPath2(import.meta.url)), "..", "web");
4764
4805
  }
4765
4806
  function workerName(project, override) {
4766
4807
  const raw = override ?? `behold-${basename(project)}`;
@@ -4771,7 +4812,7 @@ async function runExport(cfg, outDir, opts = {}) {
4771
4812
  const app = createApp(cfg);
4772
4813
  const proj = await (await app.request("/api/project")).json();
4773
4814
  const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
4774
- const snapDir = join11(outDir, "snapshots");
4815
+ const snapDir = join12(outDir, "snapshots");
4775
4816
  mkdirSync2(snapDir, { recursive: true });
4776
4817
  const keyToFile = {};
4777
4818
  let ok = 0;
@@ -4780,7 +4821,7 @@ async function runExport(cfg, outDir, opts = {}) {
4780
4821
  const res = await app.request(key);
4781
4822
  const body = await res.text();
4782
4823
  const file = slug(key);
4783
- writeFileSync2(join11(snapDir, file), body);
4824
+ writeFileSync2(join12(snapDir, file), body);
4784
4825
  keyToFile[key] = `snapshots/${file}`;
4785
4826
  if (res.ok) ok++;
4786
4827
  else failed++;
@@ -4792,21 +4833,21 @@ async function runExport(cfg, outDir, opts = {}) {
4792
4833
  axes,
4793
4834
  keyToFile
4794
4835
  };
4795
- writeFileSync2(join11(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
4796
- const html = readFileSync8(join11(webDir(), "index.html"), "utf8").replace(
4836
+ writeFileSync2(join12(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
4837
+ const html = readFileSync9(join12(webDir(), "index.html"), "utf8").replace(
4797
4838
  /<\/head>/i,
4798
4839
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
4799
4840
  </head>`
4800
4841
  );
4801
- writeFileSync2(join11(outDir, "index.html"), html);
4842
+ writeFileSync2(join12(outDir, "index.html"), html);
4802
4843
  for (const f of readdirSync4(webDir())) {
4803
4844
  if (f === "index.html") continue;
4804
- copyFileSync(join11(webDir(), f), join11(outDir, f));
4845
+ copyFileSync(join12(webDir(), f), join12(outDir, f));
4805
4846
  }
4806
- writeFileSync2(join11(outDir, "README.md"), BUNDLE_README);
4847
+ writeFileSync2(join12(outDir, "README.md"), BUNDLE_README);
4807
4848
  const name = workerName(cfg.projectDir, opts.name);
4808
4849
  writeFileSync2(
4809
- join11(outDir, "wrangler.jsonc"),
4850
+ join12(outDir, "wrangler.jsonc"),
4810
4851
  JSON.stringify(
4811
4852
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
4812
4853
  null,
@@ -4857,17 +4898,20 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
4857
4898
  var USAGE = `behold \u2014 a live control plane on chant (read-only core)
4858
4899
 
4859
4900
  Usage:
4860
- behold demo [target-dir] [--port <n>]
4901
+ behold demo [name] [target-dir] [--port <n>] [--list]
4861
4902
  behold preview [project-dir] [--port <n>] [--emulator]
4862
4903
  behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
4863
4904
  behold serve <project-dir\u2026> [--port <n>] [--env <name>] [--poll <secs>] [--local]
4864
4905
 
4865
- demo The five-minute path from npm \u2014 no chant project needed. Copies the
4866
- bundled example (an S3 bucket + policy) into ./behold-demo (or
4867
- [target-dir]), installs its dependencies, and serves it against a
4868
- local emulator: blue = declared, click Deploy, watch it turn green.
4869
- Needs Docker. The copy is yours \u2014 edit its source and watch the
4870
- graph change live.
4906
+ demo The five-minute path from npm \u2014 no chant project needed. A catalog
4907
+ of demo estates (behold demo --list): bundled ones copy out of the
4908
+ package into a directory that's yours to edit; git ones shallow-
4909
+ clone a public estate. Bare \`behold demo\` is the AWS example \u2014 an
4910
+ S3 bucket + policy on a local emulator: blue = declared, click
4911
+ Deploy, watch it turn green. \`behold demo k8s\` stands a workload
4912
+ up on a throwaway k3d cluster instead. Needs Docker (and per-demo
4913
+ tools --list names). Loaded demos land in the panel's recents, so
4914
+ switching between them is the Scope tab.
4871
4915
 
4872
4916
  export Capture the live estate into a self-contained, interactive STATIC
4873
4917
  bundle (default ./behold-export) \u2014 every env/tier \xD7 zoom \xD7 radial,
@@ -5016,7 +5060,7 @@ ${USAGE}`);
5016
5060
  });
5017
5061
  }
5018
5062
  function warnIfNotChantProject(dir) {
5019
- if (existsSync10(join12(dir, "chant.config.ts"))) return;
5063
+ if (existsSync10(join13(dir, "chant.config.ts"))) return;
5020
5064
  process.stderr.write(
5021
5065
  `behold: warning \u2014 ${dir} has no chant.config.ts; this doesn't look like a chant project.
5022
5066
  No project yet? \`behold demo\` serves a bundled working example (needs Docker).
@@ -5024,14 +5068,37 @@ function warnIfNotChantProject(dir) {
5024
5068
  );
5025
5069
  }
5026
5070
  async function runDemo(rest) {
5071
+ const pkgRoot = join13(dirname5(fileURLToPath3(import.meta.url)), "..");
5072
+ const registry = loadDemoRegistry(pkgRoot);
5027
5073
  let port = 4600;
5074
+ let name;
5028
5075
  let dirArg;
5029
5076
  for (let i = 0; i < rest.length; i++) {
5030
5077
  const a = rest[i];
5031
5078
  if (a === "--port") port = Number(rest[++i]);
5032
- else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
5033
- else if (!a.startsWith("-")) dirArg = a;
5034
- else {
5079
+ else if (a === "--list") {
5080
+ if (!registry.length) {
5081
+ process.stdout.write("behold demo: no catalog in this install (demos.json missing)\n");
5082
+ return;
5083
+ }
5084
+ for (const e of registry) {
5085
+ const missing2 = missingRequirements(e);
5086
+ const ready = missing2.length ? `needs ${missing2.join(", ")}` : "ready";
5087
+ process.stdout.write(` ${e.name.padEnd(14)} ${ready.padEnd(20)} ${e.description}
5088
+ `);
5089
+ }
5090
+ process.stdout.write("\nRun one: behold demo <name> (bare `behold demo` = writes)\n");
5091
+ return;
5092
+ } else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
5093
+ else if (!a.startsWith("-")) {
5094
+ if (!name && registry.some((e) => e.name === a)) name = a;
5095
+ else if (!dirArg) dirArg = a;
5096
+ else {
5097
+ process.stderr.write(`behold demo: unexpected argument '${a}'
5098
+ `);
5099
+ process.exit(2);
5100
+ }
5101
+ } else {
5035
5102
  process.stderr.write(`behold demo: unexpected argument '${a}'
5036
5103
  `);
5037
5104
  process.exit(2);
@@ -5041,34 +5108,75 @@ async function runDemo(rest) {
5041
5108
  process.stderr.write("behold demo: --port must be a number\n");
5042
5109
  process.exit(2);
5043
5110
  }
5044
- const bundled = join12(dirname5(fileURLToPath3(import.meta.url)), "..", "example-writes");
5045
- if (!existsSync10(bundled)) {
5046
- process.stderr.write("behold demo: this install has no bundled example project (example-writes)\n");
5111
+ const entry = registry.find((e) => e.name === (name ?? "writes"));
5112
+ if (!entry) {
5113
+ process.stderr.write(`behold demo: no "${name ?? "writes"}" in this install's catalog \u2014 behold demo --list
5114
+ `);
5115
+ process.exit(2);
5116
+ }
5117
+ const missing = missingRequirements(entry);
5118
+ if (missing.length) {
5119
+ process.stderr.write(`behold demo ${entry.name}: missing ${missing.join(", ")} \u2014 install and re-run.
5120
+ `);
5047
5121
  process.exit(2);
5048
5122
  }
5049
- const target = resolve3(dirArg ?? "behold-demo");
5123
+ const target = resolve3(
5124
+ dirArg ?? (entry.name === "writes" && existsSync10("behold-demo") ? "behold-demo" : join13("behold-demos", entry.name))
5125
+ );
5050
5126
  if (!existsSync10(target)) {
5051
- process.stdout.write(`behold demo \u2192 copying the example project to ${target} (it's yours \u2014 edit it)
5127
+ if (entry.source === "bundled") {
5128
+ const bundled = join13(pkgRoot, entry.dir);
5129
+ if (!existsSync10(bundled)) {
5130
+ process.stderr.write(`behold demo ${entry.name}: this install has no bundled ${entry.dir}
5052
5131
  `);
5053
- cpSync(bundled, target, {
5054
- recursive: true,
5055
- filter: (src) => !relative2(bundled, src).split(sep).includes("node_modules")
5056
- });
5132
+ process.exit(2);
5133
+ }
5134
+ process.stdout.write(`behold demo ${entry.name} \u2192 copying to ${target} (it's yours \u2014 edit it)
5135
+ `);
5136
+ cpSync(bundled, target, {
5137
+ recursive: true,
5138
+ filter: (src) => !relative2(bundled, src).split(sep).includes("node_modules")
5139
+ });
5140
+ } else {
5141
+ process.stdout.write(`behold demo ${entry.name} \u2192 cloning ${entry.repo} to ${target}
5142
+ `);
5143
+ const r = spawnSync2("git", ["clone", "--depth", "1", entry.repo, target], { stdio: "inherit" });
5144
+ if (r.status !== 0) {
5145
+ process.stderr.write(`behold demo ${entry.name}: clone failed
5146
+ `);
5147
+ process.exit(r.status ?? 1);
5148
+ }
5149
+ }
5057
5150
  } else {
5058
- process.stdout.write(`behold demo \u2192 reusing ${target}
5151
+ process.stdout.write(`behold demo ${entry.name} \u2192 reusing ${target}
5059
5152
  `);
5060
5153
  }
5061
- if (!existsSync10(join12(target, "node_modules"))) {
5062
- process.stdout.write("behold demo \u2192 npm install (the example's own chant + lexicons)\u2026\n");
5063
- const r = spawnSync("npm", ["install"], { cwd: target, stdio: "inherit", shell: process.platform === "win32" });
5154
+ if (existsSync10(join13(target, "package.json")) && !existsSync10(join13(target, "node_modules"))) {
5155
+ process.stdout.write(`behold demo ${entry.name} \u2192 npm install\u2026
5156
+ `);
5157
+ const r = spawnSync2("npm", ["install"], { cwd: target, stdio: "inherit", shell: process.platform === "win32" });
5064
5158
  if (r.status !== 0) {
5065
5159
  process.stderr.write(`behold demo: npm install failed in ${target}${r.error ? ` (${r.error.message})` : ""}
5066
5160
  `);
5067
5161
  process.exit(r.status ?? 1);
5068
5162
  }
5069
5163
  }
5070
- process.stdout.write("behold demo \u2192 serving with a local emulator (Docker). Blue = declared; Deploy turns it green.\n");
5071
- await run3(["serve", target, "--local", "--env", "prod", "--port", String(port)]);
5164
+ if (entry.setup) {
5165
+ process.stdout.write(`behold demo ${entry.name} \u2192 ${entry.setup}
5166
+ `);
5167
+ const r = spawnSync2(entry.setup, { cwd: target, stdio: "inherit", shell: true });
5168
+ if (r.status !== 0) {
5169
+ process.stderr.write(`behold demo ${entry.name}: setup failed (${entry.setup})
5170
+ `);
5171
+ process.exit(r.status ?? 1);
5172
+ }
5173
+ }
5174
+ process.stdout.write(`behold demo ${entry.name} \u2192 serving. Blue = declared; Deploy turns it green.
5175
+ `);
5176
+ const serveArgs = ["serve", target, "--port", String(port)];
5177
+ if (entry.serve.local) serveArgs.push("--local");
5178
+ if (entry.serve.env) serveArgs.push("--env", entry.serve.env);
5179
+ await run3(serveArgs);
5072
5180
  }
5073
5181
  function injectEmulatorEnv(env) {
5074
5182
  process.env.LOOM_ENV ??= env ?? "local";
@@ -0,0 +1,120 @@
1
+ # The k3d turnkey demo — behold's Kubernetes counterpart to Loom-on-Floci
2
+
3
+ The smallest real thing on Kubernetes: one `nginx` Deployment + Service (+ a
4
+ PodDisruptionBudget), deployed from the browser with the **▶ Deploy
5
+ (k3d-apply)** header button, against a local, single-node
6
+ [k3d](https://k3d.io) cluster — no cloud account, no credentials.
7
+
8
+ ```
9
+ src/config.ts static config — app name, pinned image tag
10
+ src/web.ts WebApp composite → Deployment + Service + PodDisruptionBudget
11
+ ops/k3d-apply.op.ts ApplyOp "k3d-apply" — code → local k3d, server-side apply
12
+ chant.config.ts lexicons [k8s, temporal], k8s.profiles.local bound to k3d-behold-k3d-demo
13
+ scripts/local/ local-up.sh / local-down.sh — the k3d cluster's own lifecycle
14
+ ```
15
+
16
+ ## Run it
17
+
18
+ From the **behold** repo root:
19
+
20
+ ```sh
21
+ npm run demo:k8s
22
+ # → brings up a single-node k3d cluster ("behold-k3d-demo"),
23
+ # then serves this project at http://localhost:4600
24
+ ```
25
+
26
+ That one command does two things: `scripts/local/local-up.sh` creates the
27
+ cluster (idempotent — reuses one already up), then `behold serve --local`
28
+ points its live overlay at it. `--local`'s usual boot step (`chant emulator
29
+ up`) is a no-op here — chant's k8s lexicon has no local-emulator capability
30
+ the way Floci does for aws — so the cluster comes up via the same generic
31
+ substrate **Bring up** mechanism the Floci demo's `scripts/local/local-up.sh`
32
+ convention already uses (see `src/substrates.ts`), just run up front instead
33
+ of from a click. Ctrl-C stops `behold serve` and tears the cluster back down;
34
+ Docker or k3d missing degrades to the source graph with a clear message
35
+ instead of crashing.
36
+
37
+ 1. Open **http://localhost:4600**. The graph shows the Deployment, Service and
38
+ PodDisruptionBudget — **blue** (declared, not yet deployed).
39
+ 2. Click **▶ Deploy (k3d-apply)** in the header (or ⌘K → "Deploy: Sync"). The
40
+ now-line streams Build → Plan (a live
41
+ diff) → Apply: a Kubernetes **server-side apply**, field manager
42
+ `chant:behold-k3d-demo` (chant#1074/#1075) — deletes are **owned-only**, a
43
+ marker-scoped prune that only ever touches what chant itself applied.
44
+ 3. The three nodes flip **blue → green (managed)**. Zoom in on the Deployment
45
+ and its two Pods appear **nested underneath it** — the runtime tier
46
+ (chant#1077/behold#86): live children a Deployment's controller created,
47
+ never declared here, never classified as drift or an orphan.
48
+
49
+ ## The four things this proves live (epic #84)
50
+
51
+ behold's k8s parity (#85–#87) shipped against fixtures; this is it against a
52
+ real cluster.
53
+
54
+ **Declared, not yet deployed.** Before step 2, `/api/overlay` reports all
55
+ three nodes `_status: accent` (pending) — chant knows about them, the cluster
56
+ doesn't yet.
57
+
58
+ **Runtime children.** After apply, `/api/overlay`'s `ir.groups.byContainer`
59
+ nests the Deployment's Pods under it, each `_status: runtime` — a tier below
60
+ what's declared, sourced from the cluster's own `ownerReferences`, that
61
+ behold's zoom dial can descend into.
62
+
63
+ **Managed-fields drift.** Scale or label the Deployment out of band —
64
+ `kubectl scale deployment/web --replicas=3`, bypassing chant entirely — and
65
+ `/api/diff`'s `fieldDrift` reports `spec.replicas: { kind: "changed", declared:
66
+ 2, live: 3 }`: chant's own SSA-tracked field, now diverged from a competing
67
+ field manager (`kubectl`, visible in `kubectl get deploy/web -o json`'s
68
+ `metadata.managedFields`). Re-running `k3d-apply` at that point doesn't
69
+ silently overwrite it: chant's server-side apply refuses —
70
+
71
+ ```
72
+ k8s: server-side apply of apps/v1 Deployment web was refused — 1 field is owned by another field manager.
73
+
74
+ "kubectl" owns:
75
+ .spec.replicas
76
+
77
+ chant applied as field manager "chant:behold-k3d-demo". Taking these fields means the managers above
78
+ stop owning them, and will contest them again on their next apply.
79
+ ```
80
+
81
+ — naming the contested path and the competing manager, and leaving the field
82
+ alone rather than force-resolving it. (One honest gap found running this
83
+ live: a **new** out-of-band key with no chant-declared counterpart at all —
84
+ `kubectl label deployment/web team=platform`, which has no corresponding
85
+ `web.spec` field in `src/web.ts` — doesn't currently surface in `fieldDrift`,
86
+ only a value change to a field chant *does* declare does. Worth a follow-up
87
+ issue on the chant side; noted here rather than papered over.)
88
+
89
+ **Unobserved.** `chant.config.ts` binds environment `local` to kubectl context
90
+ `k3d-behold-k3d-demo` (chant#1100) — every read/apply checks that binding
91
+ against whatever context is actually ambient, and refuses rather than reading
92
+ the wrong cluster. Switch away from it for a moment —
93
+
94
+ ```sh
95
+ kubectl config use-context <anything-else>
96
+ ```
97
+
98
+ — and `/api/overlay` flips every node to `_status: neutral` with
99
+ `unobservedReason: "read-failed"`, Pods and all: an honest "did not look",
100
+ never a false "these are all gone." Switch back
101
+ (`kubectl config use-context k3d-behold-k3d-demo`) and the next refresh
102
+ recovers cleanly.
103
+
104
+ ## Cleanup
105
+
106
+ `Ctrl-C` on `npm run demo:k8s` runs `scripts/local/local-down.sh`
107
+ automatically: deletes the `behold-k3d-demo` cluster and restores whatever
108
+ kubectl context was current before `local-up.sh` ran. Safe to run by hand too:
109
+
110
+ ```sh
111
+ bash scripts/local/local-down.sh
112
+ ```
113
+
114
+ ## Note for chant#1179
115
+
116
+ This Op's `delete: "owned-only"` exercises the same typed, marker-scoped
117
+ prune path chant#1179 wants a live k3d E2E for. This demo isn't that test —
118
+ it's a manual walkthrough, not an automated assertion of "exactly the orphan
119
+ and nothing else was deleted" — but it does confirm the path runs against a
120
+ real cluster, not just `fakeCluster`.
@@ -0,0 +1,21 @@
1
+ import type { ChantConfig } from "@intentius/chant";
2
+ import type { K8sChantConfig } from "@intentius/chant-lexicon-k8s";
3
+
4
+ // Bound to the demo's own cluster (scripts/local/local-up.sh creates it, k3d
5
+ // names the context "k3d-behold-k3d-demo") rather than whatever kubectl
6
+ // context happens to be ambient — chant#1100. A declared binding is checked
7
+ // against the ambient context on every live read/apply; a mismatch refuses
8
+ // loudly instead of silently reading the wrong cluster. The walkthrough's
9
+ // "unobserved" step (README.md) is exactly that refusal, produced on purpose
10
+ // by switching away from this context for a moment.
11
+ export default {
12
+ lexicons: ["k8s", "temporal"],
13
+ sourceDir: "src",
14
+ environments: ["local"],
15
+ ownership: { stack: "behold-k3d-demo", env: "local" },
16
+ k8s: {
17
+ profiles: {
18
+ local: { context: "k3d-behold-k3d-demo" },
19
+ },
20
+ } satisfies K8sChantConfig,
21
+ } satisfies ChantConfig;
@@ -0,0 +1,18 @@
1
+ import { ApplyOp } from "@intentius/chant-lexicon-temporal";
2
+
3
+ // code → local k3d (chant#704), no cloud account. scripts/local/local-up.sh
4
+ // (run by `npm run demo:k8s` before behold starts serving) brings the cluster
5
+ // up; behold's Run button on this Op deploys the declared app to it —
6
+ // build → plan (live diff) → server-side apply, field manager
7
+ // "chant:behold-k3d-demo" (chant#1074/#1075). Deletes are owned-only: a
8
+ // marker-scoped prune that never touches anything chant didn't declare — the
9
+ // same path chant#1179 wants a live E2E for; this Op exercises it, but isn't
10
+ // that test.
11
+ const { op } = ApplyOp({
12
+ name: "k3d-apply",
13
+ env: "local",
14
+ target: "kubectl",
15
+ output: "app.yaml",
16
+ delete: "owned-only",
17
+ });
18
+ export default op;