@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 +4 -0
- package/demos.json +30 -0
- package/dist/cli.js +164 -56
- package/example-k8s/README.md +120 -0
- package/example-k8s/chant.config.ts +21 -0
- package/example-k8s/ops/k3d-apply.op.ts +18 -0
- package/example-k8s/package-lock.json +2071 -0
- package/example-k8s/package.json +14 -0
- package/example-k8s/scripts/local/local-down.sh +23 -0
- package/example-k8s/scripts/local/local-up.sh +46 -0
- package/example-k8s/src/config.ts +5 -0
- package/example-k8s/src/web.ts +29 -0
- package/example-k8s/tsconfig.json +1 -0
- package/package.json +5 -1
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
|
|
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
|
-
|
|
3733
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
4715
|
-
import { join as
|
|
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
|
|
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 =
|
|
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(
|
|
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(
|
|
4796
|
-
const html =
|
|
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(
|
|
4842
|
+
writeFileSync2(join12(outDir, "index.html"), html);
|
|
4802
4843
|
for (const f of readdirSync4(webDir())) {
|
|
4803
4844
|
if (f === "index.html") continue;
|
|
4804
|
-
copyFileSync(
|
|
4845
|
+
copyFileSync(join12(webDir(), f), join12(outDir, f));
|
|
4805
4846
|
}
|
|
4806
|
-
writeFileSync2(
|
|
4847
|
+
writeFileSync2(join12(outDir, "README.md"), BUNDLE_README);
|
|
4807
4848
|
const name = workerName(cfg.projectDir, opts.name);
|
|
4808
4849
|
writeFileSync2(
|
|
4809
|
-
|
|
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.
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
|
|
4870
|
-
|
|
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(
|
|
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 === "
|
|
5033
|
-
|
|
5034
|
-
|
|
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
|
|
5045
|
-
if (!
|
|
5046
|
-
process.stderr.write(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
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(
|
|
5062
|
-
process.stdout.write(
|
|
5063
|
-
|
|
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
|
-
|
|
5071
|
-
|
|
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;
|