@intentius/behold 0.4.1 → 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
@@ -4611,6 +4611,9 @@ async function startServer(cfg) {
4611
4611
  );
4612
4612
  }
4613
4613
  }
4614
+ for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
4615
+ if (existsSync9(join10(dir, "chant.config.ts"))) addRecent(dir);
4616
+ }
4614
4617
  const broadcaster = new Broadcaster();
4615
4618
  const frames = new FrameBuffer();
4616
4619
  const runner = new OpRunner({
@@ -4714,9 +4717,43 @@ async function startServer(cfg) {
4714
4717
  });
4715
4718
  }
4716
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
+
4717
4754
  // src/export.ts
4718
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync8, readdirSync as readdirSync4 } from "node:fs";
4719
- 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";
4720
4757
  import { fileURLToPath as fileURLToPath2 } from "node:url";
4721
4758
  var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
4722
4759
  function canonicalKey(path, params) {
@@ -4764,7 +4801,7 @@ function captureKeys(axes) {
4764
4801
  return [...keys];
4765
4802
  }
4766
4803
  function webDir() {
4767
- return join11(dirname4(fileURLToPath2(import.meta.url)), "..", "web");
4804
+ return join12(dirname4(fileURLToPath2(import.meta.url)), "..", "web");
4768
4805
  }
4769
4806
  function workerName(project, override) {
4770
4807
  const raw = override ?? `behold-${basename(project)}`;
@@ -4775,7 +4812,7 @@ async function runExport(cfg, outDir, opts = {}) {
4775
4812
  const app = createApp(cfg);
4776
4813
  const proj = await (await app.request("/api/project")).json();
4777
4814
  const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
4778
- const snapDir = join11(outDir, "snapshots");
4815
+ const snapDir = join12(outDir, "snapshots");
4779
4816
  mkdirSync2(snapDir, { recursive: true });
4780
4817
  const keyToFile = {};
4781
4818
  let ok = 0;
@@ -4784,7 +4821,7 @@ async function runExport(cfg, outDir, opts = {}) {
4784
4821
  const res = await app.request(key);
4785
4822
  const body = await res.text();
4786
4823
  const file = slug(key);
4787
- writeFileSync2(join11(snapDir, file), body);
4824
+ writeFileSync2(join12(snapDir, file), body);
4788
4825
  keyToFile[key] = `snapshots/${file}`;
4789
4826
  if (res.ok) ok++;
4790
4827
  else failed++;
@@ -4796,21 +4833,21 @@ async function runExport(cfg, outDir, opts = {}) {
4796
4833
  axes,
4797
4834
  keyToFile
4798
4835
  };
4799
- writeFileSync2(join11(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
4800
- 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(
4801
4838
  /<\/head>/i,
4802
4839
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
4803
4840
  </head>`
4804
4841
  );
4805
- writeFileSync2(join11(outDir, "index.html"), html);
4842
+ writeFileSync2(join12(outDir, "index.html"), html);
4806
4843
  for (const f of readdirSync4(webDir())) {
4807
4844
  if (f === "index.html") continue;
4808
- copyFileSync(join11(webDir(), f), join11(outDir, f));
4845
+ copyFileSync(join12(webDir(), f), join12(outDir, f));
4809
4846
  }
4810
- writeFileSync2(join11(outDir, "README.md"), BUNDLE_README);
4847
+ writeFileSync2(join12(outDir, "README.md"), BUNDLE_README);
4811
4848
  const name = workerName(cfg.projectDir, opts.name);
4812
4849
  writeFileSync2(
4813
- join11(outDir, "wrangler.jsonc"),
4850
+ join12(outDir, "wrangler.jsonc"),
4814
4851
  JSON.stringify(
4815
4852
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
4816
4853
  null,
@@ -4861,17 +4898,20 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
4861
4898
  var USAGE = `behold \u2014 a live control plane on chant (read-only core)
4862
4899
 
4863
4900
  Usage:
4864
- behold demo [target-dir] [--port <n>]
4901
+ behold demo [name] [target-dir] [--port <n>] [--list]
4865
4902
  behold preview [project-dir] [--port <n>] [--emulator]
4866
4903
  behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
4867
4904
  behold serve <project-dir\u2026> [--port <n>] [--env <name>] [--poll <secs>] [--local]
4868
4905
 
4869
- demo The five-minute path from npm \u2014 no chant project needed. Copies the
4870
- bundled example (an S3 bucket + policy) into ./behold-demo (or
4871
- [target-dir]), installs its dependencies, and serves it against a
4872
- local emulator: blue = declared, click Deploy, watch it turn green.
4873
- Needs Docker. The copy is yours \u2014 edit its source and watch the
4874
- 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.
4875
4915
 
4876
4916
  export Capture the live estate into a self-contained, interactive STATIC
4877
4917
  bundle (default ./behold-export) \u2014 every env/tier \xD7 zoom \xD7 radial,
@@ -5020,7 +5060,7 @@ ${USAGE}`);
5020
5060
  });
5021
5061
  }
5022
5062
  function warnIfNotChantProject(dir) {
5023
- if (existsSync10(join12(dir, "chant.config.ts"))) return;
5063
+ if (existsSync10(join13(dir, "chant.config.ts"))) return;
5024
5064
  process.stderr.write(
5025
5065
  `behold: warning \u2014 ${dir} has no chant.config.ts; this doesn't look like a chant project.
5026
5066
  No project yet? \`behold demo\` serves a bundled working example (needs Docker).
@@ -5028,14 +5068,37 @@ function warnIfNotChantProject(dir) {
5028
5068
  );
5029
5069
  }
5030
5070
  async function runDemo(rest) {
5071
+ const pkgRoot = join13(dirname5(fileURLToPath3(import.meta.url)), "..");
5072
+ const registry = loadDemoRegistry(pkgRoot);
5031
5073
  let port = 4600;
5074
+ let name;
5032
5075
  let dirArg;
5033
5076
  for (let i = 0; i < rest.length; i++) {
5034
5077
  const a = rest[i];
5035
5078
  if (a === "--port") port = Number(rest[++i]);
5036
- else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
5037
- else if (!a.startsWith("-")) dirArg = a;
5038
- 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 {
5039
5102
  process.stderr.write(`behold demo: unexpected argument '${a}'
5040
5103
  `);
5041
5104
  process.exit(2);
@@ -5045,34 +5108,75 @@ async function runDemo(rest) {
5045
5108
  process.stderr.write("behold demo: --port must be a number\n");
5046
5109
  process.exit(2);
5047
5110
  }
5048
- const bundled = join12(dirname5(fileURLToPath3(import.meta.url)), "..", "example-writes");
5049
- if (!existsSync10(bundled)) {
5050
- 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
+ `);
5051
5121
  process.exit(2);
5052
5122
  }
5053
- 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
+ );
5054
5126
  if (!existsSync10(target)) {
5055
- 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}
5056
5131
  `);
5057
- cpSync(bundled, target, {
5058
- recursive: true,
5059
- filter: (src) => !relative2(bundled, src).split(sep).includes("node_modules")
5060
- });
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
+ }
5061
5150
  } else {
5062
- process.stdout.write(`behold demo \u2192 reusing ${target}
5151
+ process.stdout.write(`behold demo ${entry.name} \u2192 reusing ${target}
5063
5152
  `);
5064
5153
  }
5065
- if (!existsSync10(join12(target, "node_modules"))) {
5066
- process.stdout.write("behold demo \u2192 npm install (the example's own chant + lexicons)\u2026\n");
5067
- 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" });
5068
5158
  if (r.status !== 0) {
5069
5159
  process.stderr.write(`behold demo: npm install failed in ${target}${r.error ? ` (${r.error.message})` : ""}
5070
5160
  `);
5071
5161
  process.exit(r.status ?? 1);
5072
5162
  }
5073
5163
  }
5074
- process.stdout.write("behold demo \u2192 serving with a local emulator (Docker). Blue = declared; Deploy turns it green.\n");
5075
- 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);
5076
5180
  }
5077
5181
  function injectEmulatorEnv(env) {
5078
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;