@intentius/behold 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/cli.ts
2
- import { resolve as resolve2 } from "node:path";
3
- import { realpathSync, existsSync as existsSync9 } from "node:fs";
2
+ import { resolve as resolve3, dirname as dirname5, join as join12, relative as relative2, sep } from "node:path";
3
+ import { realpathSync, existsSync as existsSync10, cpSync } from "node:fs";
4
+ import { spawnSync } from "node:child_process";
4
5
  import { fileURLToPath as fileURLToPath3 } from "node:url";
5
6
 
6
7
  // src/server.ts
@@ -89,25 +90,55 @@ import { serve } from "@hono/node-server";
89
90
  import { fileURLToPath } from "node:url";
90
91
  import { execFile as execFile4 } from "node:child_process";
91
92
  import { promisify as promisify4 } from "node:util";
92
- import { existsSync as existsSync8 } from "node:fs";
93
- import { dirname as dirname2, join as join9, relative } from "node:path";
93
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
94
+ import { dirname as dirname3, join as join10, relative, resolve as resolve2 } from "node:path";
95
+
96
+ // src/recents.ts
97
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
98
+ import { homedir } from "node:os";
99
+ import { join, dirname } from "node:path";
100
+ var MAX_RECENTS = 20;
101
+ function recentsFile() {
102
+ return process.env.BEHOLD_RECENTS_FILE || join(homedir(), ".behold", "recents.json");
103
+ }
104
+ function listRecents() {
105
+ try {
106
+ const rows = JSON.parse(readFileSync(recentsFile(), "utf8"));
107
+ if (!Array.isArray(rows)) return [];
108
+ return rows.filter(
109
+ (r) => !!r && typeof r.dir === "string" && existsSync(join(r.dir, "chant.config.ts"))
110
+ );
111
+ } catch {
112
+ return [];
113
+ }
114
+ }
115
+ function addRecent(dir, now = () => (/* @__PURE__ */ new Date()).toISOString()) {
116
+ const rows = listRecents().filter((r) => r.dir !== dir);
117
+ rows.unshift({ dir, lastOpened: now() });
118
+ const file = recentsFile();
119
+ try {
120
+ mkdirSync(dirname(file), { recursive: true });
121
+ writeFileSync(file, JSON.stringify(rows.slice(0, MAX_RECENTS), null, 2) + "\n");
122
+ } catch {
123
+ }
124
+ }
94
125
 
95
126
  // src/chant.ts
96
127
  import { spawn } from "node:child_process";
97
- import { existsSync as existsSync2 } from "node:fs";
128
+ import { existsSync as existsSync3 } from "node:fs";
98
129
  import { createRequire } from "node:module";
99
- import { dirname, join as join2, resolve } from "node:path";
130
+ import { dirname as dirname2, join as join3, resolve } from "node:path";
100
131
 
101
132
  // src/project.ts
102
- import { readFileSync, existsSync } from "node:fs";
103
- import { join } from "node:path";
133
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
134
+ import { join as join2 } from "node:path";
104
135
  import { pathToFileURL } from "node:url";
105
136
  var CONFIG_NAMES = ["chant.config.ts", "chant.config.mts", "chant.config.js", "chant.config.mjs"];
106
137
  var BEHOLD_CONFIG_NAME = ".behold.json";
107
138
  function configPath(projectDir) {
108
139
  for (const name of CONFIG_NAMES) {
109
- const p = join(projectDir, name);
110
- if (existsSync(p)) return p;
140
+ const p = join2(projectDir, name);
141
+ if (existsSync2(p)) return p;
111
142
  }
112
143
  return void 0;
113
144
  }
@@ -133,8 +164,9 @@ function readInfo(cfg) {
133
164
  const envNames = (v) => Array.isArray(v) ? v.map((x) => typeof x === "string" ? x : typeof x?.name === "string" ? x.name : void 0).filter((x) => !!x) : [];
134
165
  const stacks = readStacks(cfg?.stacks);
135
166
  const k8sProfiles = readK8sProfiles(cfg?.k8s);
167
+ const environments = envNames(cfg?.environments);
136
168
  return {
137
- environments: envNames(cfg?.environments),
169
+ environments: environments.length ? environments : Object.keys(k8sProfiles ?? {}),
138
170
  lexicons: arr(cfg?.lexicons),
139
171
  ...k8sProfiles ? { k8sProfiles } : {},
140
172
  ...typeof cfg?.sourceDir === "string" ? { sourceDir: cfg.sourceDir } : {},
@@ -155,6 +187,36 @@ function parseEnvironmentNames(content) {
155
187
  }
156
188
  return [...body.matchAll(/["'`]([^"'`]+)["'`]/g)].map((m) => m[1]);
157
189
  }
190
+ function parseK8sProfileNames(content) {
191
+ const m = content.match(/\bprofiles\s*:\s*\{/);
192
+ if (!m || m.index === void 0) return [];
193
+ const start = m.index + m[0].length;
194
+ let depth = 1;
195
+ let end = start;
196
+ for (; end < content.length && depth > 0; end++) {
197
+ if (content[end] === "{") depth++;
198
+ else if (content[end] === "}") depth--;
199
+ }
200
+ const body = content.slice(start, end - 1);
201
+ const names = [];
202
+ let d = 0;
203
+ let segment = "";
204
+ for (const ch of body) {
205
+ if (ch === "{") {
206
+ if (d === 0) {
207
+ const key = segment.match(/(["'`]?)([\w.-]+)\1\s*:\s*$/);
208
+ if (key) names.push(key[2]);
209
+ segment = "";
210
+ }
211
+ d++;
212
+ } else if (ch === "}") {
213
+ d--;
214
+ } else if (d === 0) {
215
+ segment += ch;
216
+ }
217
+ }
218
+ return names;
219
+ }
158
220
  function parseStringLiteral(content, key) {
159
221
  const m = content.match(new RegExp(`\\b${key}\\s*:\\s*["'\`]([^"'\`]+)["'\`]`));
160
222
  return m?.[1];
@@ -169,10 +231,11 @@ async function detectProject(projectDir) {
169
231
  if (info.environments.length || info.lexicons.length || info.sourceDir || info.stacks?.length) return info;
170
232
  } catch {
171
233
  }
172
- const content = readFileSync(path, "utf8");
234
+ const content = readFileSync2(path, "utf8");
173
235
  const sourceDir = parseStringLiteral(content, "sourceDir");
236
+ const environments = parseEnvironmentNames(content);
174
237
  return {
175
- environments: parseEnvironmentNames(content),
238
+ environments: environments.length ? environments : parseK8sProfileNames(content),
176
239
  lexicons: parseStringArray(content, "lexicons"),
177
240
  ...sourceDir ? { sourceDir } : {}
178
241
  };
@@ -185,10 +248,10 @@ function readTiers(cfg) {
185
248
  return { envVar: tiers.envVar, values };
186
249
  }
187
250
  function loadBeholdConfig(projectDir) {
188
- const path = join(projectDir, BEHOLD_CONFIG_NAME);
189
- if (!existsSync(path)) return {};
251
+ const path = join2(projectDir, BEHOLD_CONFIG_NAME);
252
+ if (!existsSync2(path)) return {};
190
253
  try {
191
- const raw = JSON.parse(readFileSync(path, "utf8"));
254
+ const raw = JSON.parse(readFileSync2(path, "utf8"));
192
255
  const tiers = readTiers(raw);
193
256
  return tiers ? { tiers } : {};
194
257
  } catch {
@@ -489,15 +552,15 @@ function chantBinFrom(req) {
489
552
  } catch {
490
553
  return void 0;
491
554
  }
492
- let dir = dirname(entry);
555
+ let dir = dirname2(entry);
493
556
  for (; ; ) {
494
- const manifest = join2(dir, "package.json");
557
+ const manifest = join3(dir, "package.json");
495
558
  try {
496
559
  const pkg = createRequire(import.meta.url)(manifest);
497
- if (pkg.name === "@intentius/chant") return join2(dir, pkg.bin?.chant ?? "bin/chant");
560
+ if (pkg.name === "@intentius/chant") return join3(dir, pkg.bin?.chant ?? "bin/chant");
498
561
  } catch {
499
562
  }
500
- const parent = dirname(dir);
563
+ const parent = dirname2(dir);
501
564
  if (parent === dir) break;
502
565
  dir = parent;
503
566
  }
@@ -505,7 +568,7 @@ function chantBinFrom(req) {
505
568
  }
506
569
  function chantBin(projectDir) {
507
570
  if (projectDir) {
508
- const fromProject = chantBinFrom(createRequire(join2(resolve(projectDir), "noop.js")));
571
+ const fromProject = chantBinFrom(createRequire(join3(resolve(projectDir), "noop.js")));
509
572
  if (fromProject) return fromProject;
510
573
  }
511
574
  const own = chantBinFrom(createRequire(import.meta.url));
@@ -597,8 +660,8 @@ function runCommandStream(cmd, args, cwd, onLine) {
597
660
  return { pid: proc.pid ?? -1, kill: () => proc.kill(), done };
598
661
  }
599
662
  function legacyGraphPath(projectDir) {
600
- const src = join2(projectDir, "src");
601
- return existsSync2(src) ? src : projectDir;
663
+ const src = join3(projectDir, "src");
664
+ return existsSync3(src) ? src : projectDir;
602
665
  }
603
666
  async function graphPath(projectDir, opts = {}) {
604
667
  let info;
@@ -622,7 +685,7 @@ async function graphIr(projectDir, opts = {}) {
622
685
  return runChantJson(graphArgs(src, "ir", opts, false), projectDir, envOverridesFor(opts));
623
686
  }
624
687
  async function clusterRootGraphIr(projectDir, opts = {}) {
625
- if (!existsSync2(join2(projectDir, "cluster"))) return void 0;
688
+ if (!existsSync3(join3(projectDir, "cluster"))) return void 0;
626
689
  const { live: _live, overlay: _overlay, env: _env, ...sourceOnly } = opts;
627
690
  try {
628
691
  return await runChantJson(graphArgs("cluster", "ir", sourceOnly, false), projectDir, envOverridesFor(sourceOnly));
@@ -735,8 +798,8 @@ function mergeClusterRoot(ir, clusterIr, running) {
735
798
  }
736
799
 
737
800
  // src/helm-releases.ts
738
- import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync2, statSync } from "node:fs";
739
- import { join as join3 } from "node:path";
801
+ import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3, statSync } from "node:fs";
802
+ import { join as join4 } from "node:path";
740
803
  var RELEASE_KEY = /^release\/([^/]+)\/(.+)$/;
741
804
  function declaredChartNames(ir) {
742
805
  const names = /* @__PURE__ */ new Set();
@@ -756,12 +819,12 @@ function matchesDeclaredChart(chart, declared) {
756
819
  }
757
820
  function discoverReleaseUnits(projectDir) {
758
821
  const owners = /* @__PURE__ */ new Map();
759
- const roots = [join3(projectDir, "src"), join3(projectDir, "ops")];
822
+ const roots = [join4(projectDir, "src"), join4(projectDir, "ops")];
760
823
  const files = [];
761
824
  const walk = (dir, depth) => {
762
- if (depth > 4 || !existsSync3(dir)) return;
825
+ if (depth > 4 || !existsSync4(dir)) return;
763
826
  for (const f of readdirSync(dir)) {
764
- const p = join3(dir, f);
827
+ const p = join4(dir, f);
765
828
  let s;
766
829
  try {
767
830
  s = statSync(p);
@@ -776,7 +839,7 @@ function discoverReleaseUnits(projectDir) {
776
839
  for (const file of files) {
777
840
  let content;
778
841
  try {
779
- content = readFileSync2(file, "utf8");
842
+ content = readFileSync3(file, "utf8");
780
843
  } catch {
781
844
  continue;
782
845
  }
@@ -829,8 +892,8 @@ function synthesizeHelmReleases(ir, observed, owners) {
829
892
 
830
893
  // src/gh-run.ts
831
894
  import { spawn as spawn2 } from "node:child_process";
832
- import { readdirSync as readdirSync2, readFileSync as readFileSync3 } from "node:fs";
833
- import { join as join4 } from "node:path";
895
+ import { readdirSync as readdirSync2, readFileSync as readFileSync4 } from "node:fs";
896
+ import { join as join5 } from "node:path";
834
897
 
835
898
  // src/ci-run.ts
836
899
  function pipelineProgress(pipeline) {
@@ -884,19 +947,19 @@ function finishPipelineProgress(state, exitCode) {
884
947
  }
885
948
 
886
949
  // src/gh-run.ts
887
- var defaultGhExec = (args) => new Promise((resolve3) => {
950
+ var defaultGhExec = (args) => new Promise((resolve4) => {
888
951
  let out = "";
889
952
  let proc;
890
953
  try {
891
954
  proc = spawn2("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
892
955
  } catch {
893
- resolve3({ code: 127, out: "" });
956
+ resolve4({ code: 127, out: "" });
894
957
  return;
895
958
  }
896
959
  proc.stdout.on("data", (d) => out += d);
897
960
  proc.stderr.on("data", (d) => out += d);
898
- proc.on("error", () => resolve3({ code: 127, out }));
899
- proc.on("close", (code) => resolve3({ code: code ?? 1, out }));
961
+ proc.on("error", () => resolve4({ code: 127, out }));
962
+ proc.on("close", (code) => resolve4({ code: code ?? 1, out }));
900
963
  });
901
964
  async function ghReady(exec = defaultGhExec) {
902
965
  const { code, out } = await exec(["auth", "status"]);
@@ -918,7 +981,7 @@ function parseWorkflow(file, text) {
918
981
  return { file, jobIds: jobs, dispatchable };
919
982
  }
920
983
  function pickWorkflow(projectDir, pipeline) {
921
- const dir = join4(projectDir, ".github", "workflows");
984
+ const dir = join5(projectDir, ".github", "workflows");
922
985
  let files;
923
986
  try {
924
987
  files = readdirSync2(dir).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
@@ -930,7 +993,7 @@ function pickWorkflow(projectDir, pipeline) {
930
993
  for (const f of files) {
931
994
  let text;
932
995
  try {
933
- text = readFileSync3(join4(dir, f), "utf8");
996
+ text = readFileSync4(join5(dir, f), "utf8");
934
997
  } catch {
935
998
  continue;
936
999
  }
@@ -1838,7 +1901,7 @@ function projectK8sLogical(ir, env, boundContext) {
1838
1901
  if (k8s.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
1839
1902
  const cluster = boundManagedCluster(ir.nodes, boundContext);
1840
1903
  const CLUSTER_TITLE = cluster ? cluster.id : env ? `cluster ${env}` : "cluster";
1841
- const CLUSTER_SCOPED = "cluster-scoped";
1904
+ const CLUSTER_SCOPED2 = "cluster-scoped";
1842
1905
  const namespaceTitle = (ns) => `namespace ${ns}`;
1843
1906
  const byContainer = {};
1844
1907
  const child = (parent, c) => {
@@ -1854,10 +1917,10 @@ function projectK8sLogical(ir, env, boundContext) {
1854
1917
  child(namespaceTitle(ns), n.id);
1855
1918
  } else {
1856
1919
  clusterScoped = true;
1857
- child(CLUSTER_SCOPED, n.id);
1920
+ child(CLUSTER_SCOPED2, n.id);
1858
1921
  }
1859
1922
  }
1860
- if (clusterScoped) child(CLUSTER_TITLE, CLUSTER_SCOPED);
1923
+ if (clusterScoped) child(CLUSTER_TITLE, CLUSTER_SCOPED2);
1861
1924
  const kept = new Set(headline.map((n) => n.id));
1862
1925
  const edges = ir.edges.filter((e) => kept.has(e.from) && kept.has(e.to));
1863
1926
  return { ir: { nodes: headline, edges, groups: {} }, byContainer };
@@ -1904,8 +1967,8 @@ function projectHelmLogical(ir) {
1904
1967
  }
1905
1968
 
1906
1969
  // src/logical-kustomize.ts
1907
- import { existsSync as existsSync4 } from "node:fs";
1908
- import { join as join5 } from "node:path";
1970
+ import { existsSync as existsSync5 } from "node:fs";
1971
+ import { join as join6 } from "node:path";
1909
1972
  function overlayBoxTitle(name) {
1910
1973
  return `overlay ${name}`;
1911
1974
  }
@@ -1919,7 +1982,7 @@ function kustomizationRoot(bases, dir, exists) {
1919
1982
  let current = dir;
1920
1983
  for (let i = 0; i < 4 && current; i++) {
1921
1984
  for (const base of bases) {
1922
- if (exists(join5(base, current, "kustomization.yaml")) || exists(join5(base, current, "kustomization.yml"))) {
1985
+ if (exists(join6(base, current, "kustomization.yaml")) || exists(join6(base, current, "kustomization.yml"))) {
1923
1986
  return current;
1924
1987
  }
1925
1988
  }
@@ -1928,7 +1991,7 @@ function kustomizationRoot(bases, dir, exists) {
1928
1991
  }
1929
1992
  return void 0;
1930
1993
  }
1931
- function projectKustomizeLogical(ir, sourceRoots, exists = existsSync4) {
1994
+ function projectKustomizeLogical(ir, sourceRoots, exists = existsSync5) {
1932
1995
  const bases = Array.isArray(sourceRoots) ? sourceRoots : [sourceRoots];
1933
1996
  const k8s = ir.nodes.filter((n) => n.lexicon === "k8s");
1934
1997
  if (k8s.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
@@ -2519,6 +2582,22 @@ function edgelessNote(zoom, ir) {
2519
2582
  if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
2520
2583
  return "no edges \u2014 nothing in this estate references anything else";
2521
2584
  }
2585
+ var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
2586
+ "K8s::Core::Namespace",
2587
+ "K8s::Rbac::ClusterRole",
2588
+ "K8s::Rbac::ClusterRoleBinding",
2589
+ "K8s::Storage::StorageClass",
2590
+ "K8s::Apiextensions::CustomResourceDefinition"
2591
+ ]);
2592
+ function namespaceMismatchNote(nodes) {
2593
+ const k8s = nodes.filter((n) => n?.lexicon === "k8s");
2594
+ if (k8s.length === 0) return void 0;
2595
+ const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
2596
+ if (pending.length < 3) return void 0;
2597
+ if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
2598
+ if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
2599
+ return `${pending.length} pending k8s objects declare no metadata.namespace \u2014 if a controller stamps it at apply time (e.g. Flux's targetNamespace), the live read looked in "default", not where they run`;
2600
+ }
2522
2601
  function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore) {
2523
2602
  const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
2524
2603
  const notes = [primary, edgelessNote(zoom, ir)].filter((n) => n !== void 0);
@@ -2821,8 +2900,8 @@ function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
2821
2900
  }
2822
2901
 
2823
2902
  // src/ops.ts
2824
- import { readdirSync as readdirSync3, readFileSync as readFileSync4, existsSync as existsSync5 } from "node:fs";
2825
- import { join as join6 } from "node:path";
2903
+ import { readdirSync as readdirSync3, readFileSync as readFileSync5, existsSync as existsSync6 } from "node:fs";
2904
+ import { join as join7 } from "node:path";
2826
2905
  var APPLY_TARGET_LEXICON = {
2827
2906
  cloudformation: "aws",
2828
2907
  kubectl: "k8s",
@@ -2844,11 +2923,11 @@ function discoverOps(projectDir) {
2844
2923
  const seen = /* @__PURE__ */ new Set();
2845
2924
  const out = [];
2846
2925
  for (const sub of ["ops", "src", "."]) {
2847
- const dir = join6(projectDir, sub);
2848
- if (!existsSync5(dir)) continue;
2926
+ const dir = join7(projectDir, sub);
2927
+ if (!existsSync6(dir)) continue;
2849
2928
  for (const f of readdirSync3(dir)) {
2850
2929
  if (!f.endsWith(".op.ts")) continue;
2851
- const content = readFileSync4(join6(dir, f), "utf8");
2930
+ const content = readFileSync5(join7(dir, f), "utf8");
2852
2931
  const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
2853
2932
  if (!name || seen.has(name)) continue;
2854
2933
  seen.add(name);
@@ -3069,6 +3148,11 @@ var OpRunner = class {
3069
3148
  * before the first event lands so a reload between trigger and first event
3070
3149
  * doesn't show the PREVIOUS run's stale terminal state). */
3071
3150
  lastApplyProgress = initialApplyProgress;
3151
+ /** #195: re-point delegated writes at a switched project. The runner reads
3152
+ * `deps.projectDir` at trigger time, so this is the whole retarget. */
3153
+ retarget(projectDir) {
3154
+ this.deps.projectDir = projectDir;
3155
+ }
3072
3156
  /** Name of the running op, or null. */
3073
3157
  get running() {
3074
3158
  return this.current;
@@ -3239,23 +3323,23 @@ var OpRunner = class {
3239
3323
 
3240
3324
  // src/substrates.ts
3241
3325
  import { spawn as spawn3 } from "node:child_process";
3242
- import { existsSync as existsSync6, readFileSync as readFileSync5 } from "node:fs";
3243
- import { join as join7 } from "node:path";
3326
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "node:fs";
3327
+ import { join as join8 } from "node:path";
3244
3328
  import { platform } from "node:os";
3245
3329
  function probe(cmd, args) {
3246
- return new Promise((resolve3) => {
3330
+ return new Promise((resolve4) => {
3247
3331
  let out = "";
3248
3332
  let proc;
3249
3333
  try {
3250
3334
  proc = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
3251
3335
  } catch {
3252
- resolve3({ code: 127, out: "" });
3336
+ resolve4({ code: 127, out: "" });
3253
3337
  return;
3254
3338
  }
3255
3339
  proc.stdout.on("data", (d) => out += d);
3256
3340
  proc.stderr.on("data", (d) => out += d);
3257
- proc.on("error", () => resolve3({ code: 127, out }));
3258
- proc.on("close", (code) => resolve3({ code: code ?? 1, out }));
3341
+ proc.on("error", () => resolve4({ code: 127, out }));
3342
+ proc.on("close", (code) => resolve4({ code: code ?? 1, out }));
3259
3343
  });
3260
3344
  }
3261
3345
  async function dockerAvailable() {
@@ -3268,11 +3352,11 @@ async function dockerRunning(nameFilter) {
3268
3352
  return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
3269
3353
  }
3270
3354
  function scriptBringUp(projectDir, relPath, label) {
3271
- return existsSync6(join7(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
3355
+ return existsSync7(join8(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
3272
3356
  }
3273
3357
  function projectLexicons(projectDir) {
3274
3358
  try {
3275
- const src = readFileSync5(join7(projectDir, "chant.config.ts"), "utf-8");
3359
+ const src = readFileSync6(join8(projectDir, "chant.config.ts"), "utf-8");
3276
3360
  const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
3277
3361
  if (!m) return [];
3278
3362
  return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
@@ -3322,7 +3406,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3322
3406
  ["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
3323
3407
  ];
3324
3408
  for (const [name, label, marker, script] of forges) {
3325
- if (!existsSync6(join7(projectDir, marker))) continue;
3409
+ if (!existsSync7(join8(projectDir, marker))) continue;
3326
3410
  const c = docker ? await dockerRunning(name) : [];
3327
3411
  const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
3328
3412
  subs.push({
@@ -3352,7 +3436,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3352
3436
  detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
3353
3437
  });
3354
3438
  }
3355
- if (existsSync6(join7(projectDir, ".github", "workflows"))) {
3439
+ if (existsSync7(join8(projectDir, ".github", "workflows"))) {
3356
3440
  const gh = await probe("gh", ["auth", "status"]);
3357
3441
  const ready = gh.code === 0;
3358
3442
  subs.push({
@@ -3365,7 +3449,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3365
3449
  if (lexicons.includes("temporal")) {
3366
3450
  let hasProfiles = false;
3367
3451
  try {
3368
- hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync5(join7(projectDir, "chant.config.ts"), "utf-8"));
3452
+ hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync6(join8(projectDir, "chant.config.ts"), "utf-8"));
3369
3453
  } catch {
3370
3454
  }
3371
3455
  subs.push({
@@ -3477,10 +3561,48 @@ async function composeEstate(projectDirs, opts = {}) {
3477
3561
  );
3478
3562
  return composeStacks(stacks);
3479
3563
  }
3564
+ var firstLine = (e) => {
3565
+ const msg = e instanceof Error ? e.message : String(e);
3566
+ const m = msg.match(/exited \d+:\s*([\s\S]*)/);
3567
+ return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
3568
+ };
3569
+ async function composeEstateOverlay(projectDirs, opts, classify) {
3570
+ const names = shortStackNames(projectDirs);
3571
+ const unobserved = [];
3572
+ const dropped = [];
3573
+ const stacks = new Array(projectDirs.length);
3574
+ await Promise.all(
3575
+ projectDirs.map(async (dir, i) => {
3576
+ const name = names[i];
3577
+ try {
3578
+ stacks[i] = { name, ir: classify(await graphIr(dir, { ...opts, live: true, overlay: true })) };
3579
+ } catch (err) {
3580
+ const reason = firstLine(err);
3581
+ try {
3582
+ const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
3583
+ const src = await graphIr(dir, srcOpts);
3584
+ for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
3585
+ stacks[i] = { name, ir: src };
3586
+ unobserved.push({ name, reason });
3587
+ } catch (err2) {
3588
+ dropped.push({ name, reason: firstLine(err2) });
3589
+ }
3590
+ }
3591
+ })
3592
+ );
3593
+ const present = stacks.filter((s) => !!s);
3594
+ return {
3595
+ ir: composeStacks(present),
3596
+ observed: present.length - unobserved.length,
3597
+ total: projectDirs.length,
3598
+ unobserved,
3599
+ dropped
3600
+ };
3601
+ }
3480
3602
 
3481
3603
  // src/events.ts
3482
- import { watch, existsSync as existsSync7 } from "node:fs";
3483
- import { join as join8 } from "node:path";
3604
+ import { watch, existsSync as existsSync8 } from "node:fs";
3605
+ import { join as join9 } from "node:path";
3484
3606
  var Broadcaster = class {
3485
3607
  listeners = /* @__PURE__ */ new Set();
3486
3608
  subscribe(fn) {
@@ -3498,7 +3620,7 @@ var Broadcaster = class {
3498
3620
  };
3499
3621
  var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
3500
3622
  function watchSource(projectDir, onChange, debounceMs = 200) {
3501
- const dir = existsSync7(join8(projectDir, "src")) ? join8(projectDir, "src") : projectDir;
3623
+ const dir = existsSync8(join9(projectDir, "src")) ? join9(projectDir, "src") : projectDir;
3502
3624
  let timer;
3503
3625
  const watcher = watch(dir, { recursive: true }, (_event, file) => {
3504
3626
  const name = typeof file === "string" ? file : "";
@@ -3770,7 +3892,7 @@ async function emulatorDown(projectDir) {
3770
3892
  }
3771
3893
 
3772
3894
  // src/server.ts
3773
- var webRoot = join9(dirname2(fileURLToPath(import.meta.url)), "..", "web");
3895
+ var webRoot = join10(dirname3(fileURLToPath(import.meta.url)), "..", "web");
3774
3896
  var execFileP = async (cmd, args) => (await promisify4(execFile4)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
3775
3897
  function optsFromQuery(url, tierEnvVar, projectDir) {
3776
3898
  const q = url.searchParams;
@@ -3817,6 +3939,21 @@ function tierFailure(tier, message) {
3817
3939
  remedy: `A non-default tier (e.g. a production-only one) can need parameters \u2014 real credentials, a different target \u2014 this environment doesn't have. Pick a different tier to see its graph.`
3818
3940
  };
3819
3941
  }
3942
+ function beholdVersion() {
3943
+ try {
3944
+ const here = dirname3(fileURLToPath(import.meta.url));
3945
+ return JSON.parse(readFileSync7(join10(here, "..", "package.json"), "utf8")).version ?? "unknown";
3946
+ } catch {
3947
+ return "unknown";
3948
+ }
3949
+ }
3950
+ function noProjectError(projectDir) {
3951
+ return {
3952
+ code: "no-project",
3953
+ error: `${projectDir} doesn't look like a chant project \u2014 no chant.config.ts here, and the graph came back empty.`,
3954
+ remedy: "Run behold from inside a chant project (or pass its path: behold preview <dir>). No project yet? `behold demo` serves a bundled working example against a local emulator (needs Docker)."
3955
+ };
3956
+ }
3820
3957
  function errorResponse(c, opts, err) {
3821
3958
  const message = err instanceof Error ? err.message : String(err);
3822
3959
  const failure = err instanceof ChantCliError ? err.failure : classifyChantFailure(message);
@@ -3855,8 +3992,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
3855
3992
  onDone: (opEnv) => captureFrame(cfg.projectDir, opEnv ?? cfg.env, frames, broadcaster)
3856
3993
  })) {
3857
3994
  const app = new Hono();
3858
- const beholdConfig = loadBeholdConfig(cfg.projectDir);
3859
- const tierEnvVar = beholdConfig.tiers?.envVar;
3995
+ let beholdConfig = loadBeholdConfig(cfg.projectDir);
3996
+ let tierEnvVar = beholdConfig.tiers?.envVar;
3860
3997
  const boundK8sContext = async (env) => {
3861
3998
  try {
3862
3999
  const { lexicons, k8sProfiles } = await detectProject(cfg.projectDir);
@@ -3938,9 +4075,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
3938
4075
  return c.json({ started: true, name, ran: label });
3939
4076
  });
3940
4077
  app.post("/api/local/reset", (c) => {
3941
- const down = join9(cfg.projectDir, "scripts/local/local-down.sh");
3942
- const up = join9(cfg.projectDir, "scripts/local/local-up.sh");
3943
- if (!existsSync8(down) || !existsSync8(up)) {
4078
+ const down = join10(cfg.projectDir, "scripts/local/local-down.sh");
4079
+ const up = join10(cfg.projectDir, "scripts/local/local-up.sh");
4080
+ if (!existsSync9(down) || !existsSync9(up)) {
3944
4081
  return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
3945
4082
  }
3946
4083
  if (!runner.bringUp("reset local emulator", "bash", ["-c", "bash scripts/local/local-down.sh && bash scripts/local/local-up.sh"], cfg.projectDir)) {
@@ -3976,7 +4113,18 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
3976
4113
  const axes = deployAxes(tierEnvVar, lexicons, k8sTarget);
3977
4114
  return c.json({
3978
4115
  projectDir: cfg.projectDir,
4116
+ // #195: the full estate composition (multi-project serves) and the
4117
+ // switcher's recents, so the SPA's project section can show what's
4118
+ // loaded and offer where to go. Recents exclude nothing here — the SPA
4119
+ // filters out the currently-loaded dir itself.
4120
+ ...cfg.projectDirs && cfg.projectDirs.length > 1 ? { projectDirs: cfg.projectDirs } : {},
4121
+ recents: listRecents().map((r) => r.dir),
3979
4122
  environments,
4123
+ // #191: the cluster each k8s env is bound to (`k8s.profiles.<env>.
4124
+ // context`) — the SPA's env picker shows it (`home → home-cloud`) so a
4125
+ // wrong-cluster pick is visible BEFORE the read, not after (the failure
4126
+ // chant#1100 exists to prevent, and the one behind #192's red herring).
4127
+ ...k8sProfiles ? { k8sContexts: Object.fromEntries(Object.entries(k8sProfiles).flatMap(([e, p]) => p.context ? [[e, p.context]] : [])) } : {},
3980
4128
  lexicons,
3981
4129
  currentEnv: cfg.env ?? null,
3982
4130
  // v0.1.0 preview: the SPA hides git/PR ops + arbitrary-project affordances.
@@ -4003,6 +4151,72 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4003
4151
  ...axes
4004
4152
  });
4005
4153
  });
4154
+ app.post("/api/project/open", async (c) => {
4155
+ if (cfg.previewMode) return c.json({ error: "switching projects is locked in preview mode" }, 403);
4156
+ const body = await c.req.json().catch(() => ({}));
4157
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve2(body.dir.trim()) : "";
4158
+ if (!dir || !existsSync9(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
4159
+ if (!existsSync9(join10(dir, "chant.config.ts"))) {
4160
+ return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
4161
+ }
4162
+ addRecent(cfg.projectDir);
4163
+ cfg.projectDir = dir;
4164
+ cfg.projectDirs = void 0;
4165
+ cfg.env = void 0;
4166
+ beholdConfig = loadBeholdConfig(dir);
4167
+ tierEnvVar = beholdConfig.tiers?.envVar;
4168
+ runner.retarget(dir);
4169
+ addRecent(dir);
4170
+ cfg.onProjectSwitch?.(dir);
4171
+ broadcaster.emit("changed");
4172
+ return c.json({ ok: true, projectDir: dir });
4173
+ });
4174
+ app.post("/api/project/reveal", async (c) => {
4175
+ const body = await c.req.json().catch(() => ({}));
4176
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve2(body.dir.trim()) : cfg.projectDir;
4177
+ const known = /* @__PURE__ */ new Set([cfg.projectDir, ...cfg.projectDirs ?? [], ...listRecents().map((r) => r.dir)]);
4178
+ if (!known.has(dir)) return c.json({ error: "not a served or recent project directory" }, 400);
4179
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
4180
+ try {
4181
+ await execFileP(opener, [dir]);
4182
+ return c.json({ ok: true });
4183
+ } catch (err) {
4184
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
4185
+ }
4186
+ });
4187
+ app.get(
4188
+ "/api",
4189
+ (c) => c.json({
4190
+ name: "behold",
4191
+ version: beholdVersion(),
4192
+ agentsGuide: "https://github.com/INTENTIUS/behold/blob/main/AGENTS.md",
4193
+ routes: [
4194
+ { method: "GET", path: "/api", desc: "this index" },
4195
+ { method: "GET", path: "/api/project", desc: "project info: dir, recents, environments, tiers, targets, stacks, preview lock" },
4196
+ { method: "POST", path: "/api/project/open", desc: "switch the served project: JSON body {dir} (validated; preview-locked)" },
4197
+ { method: "POST", path: "/api/project/reveal", desc: "open the OS file manager at a served/recent project dir: JSON body {dir?}" },
4198
+ { method: "GET", path: "/api/graph", desc: "the graph {ir, svg, meta} \u2014 params: detail=0..3, components=1, logical=1, env, stack, tier, target, lens, up=1, down=1, radial=1" },
4199
+ { method: "GET", path: "/api/overlay", desc: "live drift overlay for ?env= \u2014 same shape/params as /api/graph, plus runtime=1" },
4200
+ { method: "GET", path: "/api/diff", desc: "per-node live diff for ?env= \u2014 {env, nodes: {<id>: {observed, diff, health, fieldDrift}}}" },
4201
+ { method: "GET", path: "/api/reconcile", desc: "pending-change summary for ?env=" },
4202
+ { method: "GET", path: "/api/resources", desc: "component \u2192 declared resources" },
4203
+ { method: "GET", path: "/api/ci", desc: "generated CI pipeline projection {stages, jobs, forge}" },
4204
+ { method: "GET", path: "/api/substrates", desc: "substrate readiness {substrates: [{name, label, status, detail, bringUp?}]}" },
4205
+ { method: "GET", path: "/api/ops", desc: "committed Ops + adopt lexicons + apply progress" },
4206
+ { method: "GET", path: "/api/history", desc: "recent source commits (rollback targets)" },
4207
+ { method: "GET", path: "/api/frames", desc: "captured lanes frames" },
4208
+ { method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / pr" },
4209
+ { method: "POST", path: "/api/refresh", desc: "re-observe live now (?env=) \u2014 returns the fresh graph" },
4210
+ { method: "POST", path: "/api/apply", desc: "delegated apply: ?env=&component=<name|all> (guarded, preview-locked)" },
4211
+ { method: "POST", path: "/api/ops/:name/run", desc: "run a committed Op (delegated write)" },
4212
+ { method: "POST", path: "/api/ops/:name/signal/:gate", desc: "approve an Op's gate" },
4213
+ { method: "POST", path: "/api/rollback", desc: "open a rollback PR: ?to=<sha>" },
4214
+ { method: "POST", path: "/api/substrates/:name/up", desc: "bring a substrate up" },
4215
+ { method: "POST", path: "/api/local/reset", desc: "reset the local emulator" },
4216
+ { method: "POST", path: "/api/ci/dispatch", desc: "dispatch the GitHub Actions pipeline via the operator's gh" }
4217
+ ]
4218
+ })
4219
+ );
4006
4220
  app.get("/api/graph", async (c) => {
4007
4221
  const url = new URL(c.req.url);
4008
4222
  const opts = optsFromQuery(url, tierEnvVar, cfg.projectDir);
@@ -4015,6 +4229,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4015
4229
  let metaEnv = cfg.env ?? null;
4016
4230
  if (multi) {
4017
4231
  ir = await composeEstate(cfg.projectDirs, opts);
4232
+ ir = addValueMatchEdges(ir);
4233
+ ir = addK8sDeclaredEdges(ir);
4234
+ ir = addClusterAnchorEdges(ir, await boundK8sContext(metaEnv ?? void 0));
4018
4235
  } else if (components) {
4019
4236
  ir = await componentGraphIr(cfg.projectDir, opts);
4020
4237
  const env = opts.env ?? cfg.env;
@@ -4050,10 +4267,14 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4050
4267
  srcCompositeEdgesAttached = 0;
4051
4268
  }
4052
4269
  }
4270
+ if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync9(join10(cfg.projectDir, "chant.config.ts"))) {
4271
+ return c.json(noProjectError(cfg.projectDir), 404);
4272
+ }
4053
4273
  const radial = new URL(c.req.url).searchParams.get("radial") === "1";
4054
4274
  const { svg } = renderGraph(ir, multi ? { boxes: "byStack" } : { radial });
4055
4275
  const srcZoom = components ? "components" : logical ? "logical" : opts.detail === 1 ? "composites" : opts.detail === 3 ? "attributes" : "resources";
4056
- const srcNote = multi ? void 0 : notesFor(srcZoom, ir, srcCompositeEdgesAttached);
4276
+ const estateLensNote = multi && (components || logical) ? `the ${components ? "components" : "logical"} lens doesn't apply to a composed estate yet \u2014 showing the composed entity graph` : void 0;
4277
+ const srcNote = multi ? estateLensNote : notesFor(srcZoom, ir, srcCompositeEdgesAttached);
4057
4278
  return c.json({
4058
4279
  ir,
4059
4280
  svg,
@@ -4152,6 +4373,30 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4152
4373
  }
4153
4374
  const logical = new URL(c.req.url).searchParams.get("logical") === "1";
4154
4375
  try {
4376
+ if (cfg.projectDirs && cfg.projectDirs.length > 1) {
4377
+ const runtime = new URL(c.req.url).searchParams.get("runtime") === "1";
4378
+ const est = await composeEstateOverlay(cfg.projectDirs, { ...tierTargetOpts(query), detail: query.detail, env }, reclassifyOverlay);
4379
+ if (est.dropped.length === est.total) {
4380
+ return c.json({ error: `no project in the estate could be graphed \u2014 ${est.dropped.map((d) => `${d.name}: ${d.reason}`).join("; ")}` }, 500);
4381
+ }
4382
+ let ir2 = pruneRuntimeChildren(est.ir);
4383
+ if ((query.detail ?? 2) < 3) ir2 = pruneImports(ir2);
4384
+ ir2 = addValueMatchEdges(ir2);
4385
+ ir2 = addK8sDeclaredEdges(ir2);
4386
+ ir2 = addClusterAnchorEdges(ir2, await boundK8sContext(env));
4387
+ const { svg: svg2 } = renderGraph(ir2, { boxes: "byStack" });
4388
+ const lensNote = logical || runtime ? `the ${logical ? "logical" : "runtime"} lens doesn't apply to a composed estate yet \u2014 showing the composed entity overlay` : void 0;
4389
+ const coverNote = est.unobserved.length || est.dropped.length ? `live observe covered ${est.observed} of ${est.total} projects \u2014 ` + [
4390
+ ...est.unobserved.map((u) => `${u.name}: ${u.reason} (painted unobserved)`),
4391
+ ...est.dropped.map((d) => `${d.name}: dropped (${d.reason})`)
4392
+ ].join("; ") : void 0;
4393
+ const note2 = [lensNote, coverNote, namespaceMismatchNote(ir2.nodes)].filter(Boolean).join(" \xB7 ");
4394
+ return c.json({
4395
+ ir: ir2,
4396
+ svg: svg2,
4397
+ meta: { projectDir: cfg.projectDir, env, mode: "overlay", estate: est.total, ...note2 ? { note: note2 } : {} }
4398
+ });
4399
+ }
4155
4400
  const opts = { ...query, live: true, overlay: true, env, ...logical ? { detail: 3 } : {} };
4156
4401
  let ir = reclassifyOverlay(await graphIr(cfg.projectDir, opts));
4157
4402
  const boundContext = await boundK8sContext(env);
@@ -4189,8 +4434,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4189
4434
  const { svg } = renderGraph(ir, { boxes: "byContainer", radial: new URL(c.req.url).searchParams.get("radial") === "1" });
4190
4435
  const zoom = new URL(c.req.url).searchParams.get("runtime") === "1" ? "runtime" : query.detail === 1 ? "composites" : query.detail === 3 ? "attributes" : "resources";
4191
4436
  const tierNote = tierMismatchNote(ir, beholdConfig.tiers, query.tier);
4437
+ const nsNote = namespaceMismatchNote(ir.nodes);
4192
4438
  const zoomNotes = notesFor(zoom, ir, compositeEdgesAttached);
4193
- const note = [tierNote, zoomNotes].filter(Boolean).join(" \xB7 ");
4439
+ const note = [tierNote, nsNote, zoomNotes].filter(Boolean).join(" \xB7 ");
4194
4440
  return c.json({ ir, svg, meta: { projectDir: cfg.projectDir, env, mode: "overlay", ...note ? { note } : {} } });
4195
4441
  } catch (err) {
4196
4442
  return errorResponse(c, query, err);
@@ -4333,7 +4579,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4333
4579
  });
4334
4580
  const rel = relative(process.cwd(), webRoot) || ".";
4335
4581
  app.use("/*", serveStatic({ root: rel }));
4336
- app.get("/", serveStatic({ path: join9(rel, "index.html") }));
4582
+ app.get("/", serveStatic({ path: join10(rel, "index.html") }));
4337
4583
  return app;
4338
4584
  }
4339
4585
  async function startServer(cfg) {
@@ -4401,8 +4647,8 @@ async function startServer(cfg) {
4401
4647
  }
4402
4648
  }
4403
4649
  };
4404
- const stopWatch = watchSource(cfg.projectDir, onEstateChange);
4405
- const stopPoll = cfg.env && cfg.pollSecs ? startDriftPoll({
4650
+ let stopWatch = watchSource(cfg.projectDir, onEstateChange);
4651
+ let stopPoll = cfg.env && cfg.pollSecs ? startDriftPoll({
4406
4652
  intervalMs: cfg.pollSecs * 1e3,
4407
4653
  query: () => graphIr(cfg.projectDir, { live: true, overlay: true, env: cfg.env }),
4408
4654
  onChange: onPollDrift,
@@ -4410,6 +4656,16 @@ async function startServer(cfg) {
4410
4656
  `)
4411
4657
  }) : () => {
4412
4658
  };
4659
+ cfg.onProjectSwitch = (dir) => {
4660
+ stopWatch();
4661
+ stopWatch = watchSource(dir, onEstateChange);
4662
+ stopPoll();
4663
+ stopPoll = () => {
4664
+ };
4665
+ process.stdout.write(` switched \u2192 ${dir}
4666
+ `);
4667
+ void capture();
4668
+ };
4413
4669
  void capture();
4414
4670
  let shuttingDown = false;
4415
4671
  const shutdown = () => {
@@ -4455,8 +4711,8 @@ async function startServer(cfg) {
4455
4711
  }
4456
4712
 
4457
4713
  // src/export.ts
4458
- import { mkdirSync, writeFileSync, copyFileSync, readFileSync as readFileSync6, readdirSync as readdirSync4 } from "node:fs";
4459
- import { join as join10, dirname as dirname3, basename } from "node:path";
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";
4460
4716
  import { fileURLToPath as fileURLToPath2 } from "node:url";
4461
4717
  var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
4462
4718
  function canonicalKey(path, params) {
@@ -4504,7 +4760,7 @@ function captureKeys(axes) {
4504
4760
  return [...keys];
4505
4761
  }
4506
4762
  function webDir() {
4507
- return join10(dirname3(fileURLToPath2(import.meta.url)), "..", "web");
4763
+ return join11(dirname4(fileURLToPath2(import.meta.url)), "..", "web");
4508
4764
  }
4509
4765
  function workerName(project, override) {
4510
4766
  const raw = override ?? `behold-${basename(project)}`;
@@ -4515,8 +4771,8 @@ async function runExport(cfg, outDir, opts = {}) {
4515
4771
  const app = createApp(cfg);
4516
4772
  const proj = await (await app.request("/api/project")).json();
4517
4773
  const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
4518
- const snapDir = join10(outDir, "snapshots");
4519
- mkdirSync(snapDir, { recursive: true });
4774
+ const snapDir = join11(outDir, "snapshots");
4775
+ mkdirSync2(snapDir, { recursive: true });
4520
4776
  const keyToFile = {};
4521
4777
  let ok = 0;
4522
4778
  let failed = 0;
@@ -4524,7 +4780,7 @@ async function runExport(cfg, outDir, opts = {}) {
4524
4780
  const res = await app.request(key);
4525
4781
  const body = await res.text();
4526
4782
  const file = slug(key);
4527
- writeFileSync(join10(snapDir, file), body);
4783
+ writeFileSync2(join11(snapDir, file), body);
4528
4784
  keyToFile[key] = `snapshots/${file}`;
4529
4785
  if (res.ok) ok++;
4530
4786
  else failed++;
@@ -4536,21 +4792,21 @@ async function runExport(cfg, outDir, opts = {}) {
4536
4792
  axes,
4537
4793
  keyToFile
4538
4794
  };
4539
- writeFileSync(join10(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
4540
- const html = readFileSync6(join10(webDir(), "index.html"), "utf8").replace(
4795
+ writeFileSync2(join11(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
4796
+ const html = readFileSync8(join11(webDir(), "index.html"), "utf8").replace(
4541
4797
  /<\/head>/i,
4542
4798
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
4543
4799
  </head>`
4544
4800
  );
4545
- writeFileSync(join10(outDir, "index.html"), html);
4801
+ writeFileSync2(join11(outDir, "index.html"), html);
4546
4802
  for (const f of readdirSync4(webDir())) {
4547
4803
  if (f === "index.html") continue;
4548
- copyFileSync(join10(webDir(), f), join10(outDir, f));
4804
+ copyFileSync(join11(webDir(), f), join11(outDir, f));
4549
4805
  }
4550
- writeFileSync(join10(outDir, "README.md"), BUNDLE_README);
4806
+ writeFileSync2(join11(outDir, "README.md"), BUNDLE_README);
4551
4807
  const name = workerName(cfg.projectDir, opts.name);
4552
- writeFileSync(
4553
- join10(outDir, "wrangler.jsonc"),
4808
+ writeFileSync2(
4809
+ join11(outDir, "wrangler.jsonc"),
4554
4810
  JSON.stringify(
4555
4811
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
4556
4812
  null,
@@ -4601,10 +4857,18 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
4601
4857
  var USAGE = `behold \u2014 a live control plane on chant (read-only core)
4602
4858
 
4603
4859
  Usage:
4860
+ behold demo [target-dir] [--port <n>]
4604
4861
  behold preview [project-dir] [--port <n>] [--emulator]
4605
4862
  behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
4606
4863
  behold serve <project-dir\u2026> [--port <n>] [--env <name>] [--poll <secs>] [--local]
4607
4864
 
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.
4871
+
4608
4872
  export Capture the live estate into a self-contained, interactive STATIC
4609
4873
  bundle (default ./behold-export) \u2014 every env/tier \xD7 zoom \xD7 radial,
4610
4874
  replayable with no backend. Host it anywhere (Cloudflare Pages/Workers,
@@ -4659,6 +4923,7 @@ Options:
4659
4923
  --name <worker> export only: Cloudflare Worker name in the generated
4660
4924
  wrangler.jsonc.
4661
4925
  -h, --help This text.
4926
+ -v, --version Print the behold version.
4662
4927
  `;
4663
4928
  async function run3(argv) {
4664
4929
  const [cmd, ...rest] = argv;
@@ -4666,6 +4931,14 @@ async function run3(argv) {
4666
4931
  process.stdout.write(USAGE);
4667
4932
  return;
4668
4933
  }
4934
+ if (cmd === "-v" || cmd === "--version") {
4935
+ process.stdout.write(beholdVersion() + "\n");
4936
+ return;
4937
+ }
4938
+ if (cmd === "demo") {
4939
+ await runDemo(rest);
4940
+ return;
4941
+ }
4669
4942
  if (cmd === "preview") {
4670
4943
  await runPreview(rest);
4671
4944
  return;
@@ -4729,7 +5002,8 @@ ${USAGE}`);
4729
5002
  process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
4730
5003
  process.exit(2);
4731
5004
  }
4732
- const dirs = projectDirs.map((d) => resolve2(d));
5005
+ const dirs = projectDirs.map((d) => resolve3(d));
5006
+ for (const d of dirs) warnIfNotChantProject(d);
4733
5007
  await startServer({
4734
5008
  projectDir: dirs[0],
4735
5009
  // primary — ops/overlay/rollback act on it
@@ -4741,6 +5015,61 @@ ${USAGE}`);
4741
5015
  ...local ? { local: true } : {}
4742
5016
  });
4743
5017
  }
5018
+ function warnIfNotChantProject(dir) {
5019
+ if (existsSync10(join12(dir, "chant.config.ts"))) return;
5020
+ process.stderr.write(
5021
+ `behold: warning \u2014 ${dir} has no chant.config.ts; this doesn't look like a chant project.
5022
+ No project yet? \`behold demo\` serves a bundled working example (needs Docker).
5023
+ `
5024
+ );
5025
+ }
5026
+ async function runDemo(rest) {
5027
+ let port = 4600;
5028
+ let dirArg;
5029
+ for (let i = 0; i < rest.length; i++) {
5030
+ const a = rest[i];
5031
+ 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 {
5035
+ process.stderr.write(`behold demo: unexpected argument '${a}'
5036
+ `);
5037
+ process.exit(2);
5038
+ }
5039
+ }
5040
+ if (!Number.isFinite(port)) {
5041
+ process.stderr.write("behold demo: --port must be a number\n");
5042
+ process.exit(2);
5043
+ }
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");
5047
+ process.exit(2);
5048
+ }
5049
+ const target = resolve3(dirArg ?? "behold-demo");
5050
+ if (!existsSync10(target)) {
5051
+ process.stdout.write(`behold demo \u2192 copying the example project to ${target} (it's yours \u2014 edit it)
5052
+ `);
5053
+ cpSync(bundled, target, {
5054
+ recursive: true,
5055
+ filter: (src) => !relative2(bundled, src).split(sep).includes("node_modules")
5056
+ });
5057
+ } else {
5058
+ process.stdout.write(`behold demo \u2192 reusing ${target}
5059
+ `);
5060
+ }
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" });
5064
+ if (r.status !== 0) {
5065
+ process.stderr.write(`behold demo: npm install failed in ${target}${r.error ? ` (${r.error.message})` : ""}
5066
+ `);
5067
+ process.exit(r.status ?? 1);
5068
+ }
5069
+ }
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)]);
5072
+ }
4744
5073
  function injectEmulatorEnv(env) {
4745
5074
  process.env.LOOM_ENV ??= env ?? "local";
4746
5075
  process.env.AWS_ENDPOINT_URL ??= "http://localhost:4566";
@@ -4763,13 +5092,14 @@ async function runPreview(rest) {
4763
5092
  process.stderr.write("behold preview: --port must be a number\n");
4764
5093
  process.exit(2);
4765
5094
  }
4766
- const projectDir = resolve2(dirArg ?? process.cwd());
4767
- if (!existsSync9(projectDir)) {
5095
+ const projectDir = resolve3(dirArg ?? process.cwd());
5096
+ if (!existsSync10(projectDir)) {
4768
5097
  process.stderr.write(`behold preview: project not found at ${projectDir}
4769
5098
  `);
4770
5099
  process.exit(2);
4771
5100
  }
4772
5101
  if (!emulator) {
5102
+ warnIfNotChantProject(projectDir);
4773
5103
  await startServer({ projectDir, port });
4774
5104
  return;
4775
5105
  }
@@ -4783,26 +5113,26 @@ async function runPreview(rest) {
4783
5113
  await startServer({ projectDir, port, env: "local", previewMode: true });
4784
5114
  }
4785
5115
  async function runExportCmd(rest) {
4786
- let outDir = resolve2("behold-export");
5116
+ let outDir = resolve3("behold-export");
4787
5117
  let env;
4788
5118
  let name;
4789
5119
  let dirArg;
4790
5120
  let emulator = false;
4791
5121
  for (let i = 0; i < rest.length; i++) {
4792
5122
  const a = rest[i];
4793
- if (a === "--out") outDir = resolve2(rest[++i]);
5123
+ if (a === "--out") outDir = resolve3(rest[++i]);
4794
5124
  else if (a === "--env") env = rest[++i];
4795
5125
  else if (a === "--name") name = rest[++i];
4796
5126
  else if (a === "--emulator") emulator = true;
4797
5127
  else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
4798
5128
  else if (!a.startsWith("-")) dirArg = a;
4799
5129
  }
4800
- const projectDir = resolve2(dirArg ?? process.cwd());
5130
+ const projectDir = resolve3(dirArg ?? process.cwd());
4801
5131
  if (emulator) {
4802
5132
  injectEmulatorEnv(env);
4803
5133
  env ??= "local";
4804
5134
  }
4805
- if (!existsSync9(projectDir)) {
5135
+ if (!existsSync10(projectDir)) {
4806
5136
  process.stderr.write(`behold export: project not found at ${projectDir}
4807
5137
  `);
4808
5138
  process.exit(2);