@intentius/behold 0.2.3 → 0.3.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
  }
@@ -169,7 +200,7 @@ async function detectProject(projectDir) {
169
200
  if (info.environments.length || info.lexicons.length || info.sourceDir || info.stacks?.length) return info;
170
201
  } catch {
171
202
  }
172
- const content = readFileSync(path, "utf8");
203
+ const content = readFileSync2(path, "utf8");
173
204
  const sourceDir = parseStringLiteral(content, "sourceDir");
174
205
  return {
175
206
  environments: parseEnvironmentNames(content),
@@ -185,10 +216,10 @@ function readTiers(cfg) {
185
216
  return { envVar: tiers.envVar, values };
186
217
  }
187
218
  function loadBeholdConfig(projectDir) {
188
- const path = join(projectDir, BEHOLD_CONFIG_NAME);
189
- if (!existsSync(path)) return {};
219
+ const path = join2(projectDir, BEHOLD_CONFIG_NAME);
220
+ if (!existsSync2(path)) return {};
190
221
  try {
191
- const raw = JSON.parse(readFileSync(path, "utf8"));
222
+ const raw = JSON.parse(readFileSync2(path, "utf8"));
192
223
  const tiers = readTiers(raw);
193
224
  return tiers ? { tiers } : {};
194
225
  } catch {
@@ -489,15 +520,15 @@ function chantBinFrom(req) {
489
520
  } catch {
490
521
  return void 0;
491
522
  }
492
- let dir = dirname(entry);
523
+ let dir = dirname2(entry);
493
524
  for (; ; ) {
494
- const manifest = join2(dir, "package.json");
525
+ const manifest = join3(dir, "package.json");
495
526
  try {
496
527
  const pkg = createRequire(import.meta.url)(manifest);
497
- if (pkg.name === "@intentius/chant") return join2(dir, pkg.bin?.chant ?? "bin/chant");
528
+ if (pkg.name === "@intentius/chant") return join3(dir, pkg.bin?.chant ?? "bin/chant");
498
529
  } catch {
499
530
  }
500
- const parent = dirname(dir);
531
+ const parent = dirname2(dir);
501
532
  if (parent === dir) break;
502
533
  dir = parent;
503
534
  }
@@ -505,7 +536,7 @@ function chantBinFrom(req) {
505
536
  }
506
537
  function chantBin(projectDir) {
507
538
  if (projectDir) {
508
- const fromProject = chantBinFrom(createRequire(join2(resolve(projectDir), "noop.js")));
539
+ const fromProject = chantBinFrom(createRequire(join3(resolve(projectDir), "noop.js")));
509
540
  if (fromProject) return fromProject;
510
541
  }
511
542
  const own = chantBinFrom(createRequire(import.meta.url));
@@ -597,8 +628,8 @@ function runCommandStream(cmd, args, cwd, onLine) {
597
628
  return { pid: proc.pid ?? -1, kill: () => proc.kill(), done };
598
629
  }
599
630
  function legacyGraphPath(projectDir) {
600
- const src = join2(projectDir, "src");
601
- return existsSync2(src) ? src : projectDir;
631
+ const src = join3(projectDir, "src");
632
+ return existsSync3(src) ? src : projectDir;
602
633
  }
603
634
  async function graphPath(projectDir, opts = {}) {
604
635
  let info;
@@ -622,7 +653,7 @@ async function graphIr(projectDir, opts = {}) {
622
653
  return runChantJson(graphArgs(src, "ir", opts, false), projectDir, envOverridesFor(opts));
623
654
  }
624
655
  async function clusterRootGraphIr(projectDir, opts = {}) {
625
- if (!existsSync2(join2(projectDir, "cluster"))) return void 0;
656
+ if (!existsSync3(join3(projectDir, "cluster"))) return void 0;
626
657
  const { live: _live, overlay: _overlay, env: _env, ...sourceOnly } = opts;
627
658
  try {
628
659
  return await runChantJson(graphArgs("cluster", "ir", sourceOnly, false), projectDir, envOverridesFor(sourceOnly));
@@ -735,8 +766,8 @@ function mergeClusterRoot(ir, clusterIr, running) {
735
766
  }
736
767
 
737
768
  // 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";
769
+ import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3, statSync } from "node:fs";
770
+ import { join as join4 } from "node:path";
740
771
  var RELEASE_KEY = /^release\/([^/]+)\/(.+)$/;
741
772
  function declaredChartNames(ir) {
742
773
  const names = /* @__PURE__ */ new Set();
@@ -756,12 +787,12 @@ function matchesDeclaredChart(chart, declared) {
756
787
  }
757
788
  function discoverReleaseUnits(projectDir) {
758
789
  const owners = /* @__PURE__ */ new Map();
759
- const roots = [join3(projectDir, "src"), join3(projectDir, "ops")];
790
+ const roots = [join4(projectDir, "src"), join4(projectDir, "ops")];
760
791
  const files = [];
761
792
  const walk = (dir, depth) => {
762
- if (depth > 4 || !existsSync3(dir)) return;
793
+ if (depth > 4 || !existsSync4(dir)) return;
763
794
  for (const f of readdirSync(dir)) {
764
- const p = join3(dir, f);
795
+ const p = join4(dir, f);
765
796
  let s;
766
797
  try {
767
798
  s = statSync(p);
@@ -776,7 +807,7 @@ function discoverReleaseUnits(projectDir) {
776
807
  for (const file of files) {
777
808
  let content;
778
809
  try {
779
- content = readFileSync2(file, "utf8");
810
+ content = readFileSync3(file, "utf8");
780
811
  } catch {
781
812
  continue;
782
813
  }
@@ -829,8 +860,8 @@ function synthesizeHelmReleases(ir, observed, owners) {
829
860
 
830
861
  // src/gh-run.ts
831
862
  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";
863
+ import { readdirSync as readdirSync2, readFileSync as readFileSync4 } from "node:fs";
864
+ import { join as join5 } from "node:path";
834
865
 
835
866
  // src/ci-run.ts
836
867
  function pipelineProgress(pipeline) {
@@ -884,19 +915,19 @@ function finishPipelineProgress(state, exitCode) {
884
915
  }
885
916
 
886
917
  // src/gh-run.ts
887
- var defaultGhExec = (args) => new Promise((resolve3) => {
918
+ var defaultGhExec = (args) => new Promise((resolve4) => {
888
919
  let out = "";
889
920
  let proc;
890
921
  try {
891
922
  proc = spawn2("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
892
923
  } catch {
893
- resolve3({ code: 127, out: "" });
924
+ resolve4({ code: 127, out: "" });
894
925
  return;
895
926
  }
896
927
  proc.stdout.on("data", (d) => out += d);
897
928
  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 }));
929
+ proc.on("error", () => resolve4({ code: 127, out }));
930
+ proc.on("close", (code) => resolve4({ code: code ?? 1, out }));
900
931
  });
901
932
  async function ghReady(exec = defaultGhExec) {
902
933
  const { code, out } = await exec(["auth", "status"]);
@@ -918,7 +949,7 @@ function parseWorkflow(file, text) {
918
949
  return { file, jobIds: jobs, dispatchable };
919
950
  }
920
951
  function pickWorkflow(projectDir, pipeline) {
921
- const dir = join4(projectDir, ".github", "workflows");
952
+ const dir = join5(projectDir, ".github", "workflows");
922
953
  let files;
923
954
  try {
924
955
  files = readdirSync2(dir).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
@@ -930,7 +961,7 @@ function pickWorkflow(projectDir, pipeline) {
930
961
  for (const f of files) {
931
962
  let text;
932
963
  try {
933
- text = readFileSync3(join4(dir, f), "utf8");
964
+ text = readFileSync4(join5(dir, f), "utf8");
934
965
  } catch {
935
966
  continue;
936
967
  }
@@ -1904,8 +1935,8 @@ function projectHelmLogical(ir) {
1904
1935
  }
1905
1936
 
1906
1937
  // src/logical-kustomize.ts
1907
- import { existsSync as existsSync4 } from "node:fs";
1908
- import { join as join5 } from "node:path";
1938
+ import { existsSync as existsSync5 } from "node:fs";
1939
+ import { join as join6 } from "node:path";
1909
1940
  function overlayBoxTitle(name) {
1910
1941
  return `overlay ${name}`;
1911
1942
  }
@@ -1919,7 +1950,7 @@ function kustomizationRoot(bases, dir, exists) {
1919
1950
  let current = dir;
1920
1951
  for (let i = 0; i < 4 && current; i++) {
1921
1952
  for (const base of bases) {
1922
- if (exists(join5(base, current, "kustomization.yaml")) || exists(join5(base, current, "kustomization.yml"))) {
1953
+ if (exists(join6(base, current, "kustomization.yaml")) || exists(join6(base, current, "kustomization.yml"))) {
1923
1954
  return current;
1924
1955
  }
1925
1956
  }
@@ -1928,7 +1959,7 @@ function kustomizationRoot(bases, dir, exists) {
1928
1959
  }
1929
1960
  return void 0;
1930
1961
  }
1931
- function projectKustomizeLogical(ir, sourceRoots, exists = existsSync4) {
1962
+ function projectKustomizeLogical(ir, sourceRoots, exists = existsSync5) {
1932
1963
  const bases = Array.isArray(sourceRoots) ? sourceRoots : [sourceRoots];
1933
1964
  const k8s = ir.nodes.filter((n) => n.lexicon === "k8s");
1934
1965
  if (k8s.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
@@ -2821,8 +2852,8 @@ function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
2821
2852
  }
2822
2853
 
2823
2854
  // 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";
2855
+ import { readdirSync as readdirSync3, readFileSync as readFileSync5, existsSync as existsSync6 } from "node:fs";
2856
+ import { join as join7 } from "node:path";
2826
2857
  var APPLY_TARGET_LEXICON = {
2827
2858
  cloudformation: "aws",
2828
2859
  kubectl: "k8s",
@@ -2844,11 +2875,11 @@ function discoverOps(projectDir) {
2844
2875
  const seen = /* @__PURE__ */ new Set();
2845
2876
  const out = [];
2846
2877
  for (const sub of ["ops", "src", "."]) {
2847
- const dir = join6(projectDir, sub);
2848
- if (!existsSync5(dir)) continue;
2878
+ const dir = join7(projectDir, sub);
2879
+ if (!existsSync6(dir)) continue;
2849
2880
  for (const f of readdirSync3(dir)) {
2850
2881
  if (!f.endsWith(".op.ts")) continue;
2851
- const content = readFileSync4(join6(dir, f), "utf8");
2882
+ const content = readFileSync5(join7(dir, f), "utf8");
2852
2883
  const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
2853
2884
  if (!name || seen.has(name)) continue;
2854
2885
  seen.add(name);
@@ -3069,6 +3100,11 @@ var OpRunner = class {
3069
3100
  * before the first event lands so a reload between trigger and first event
3070
3101
  * doesn't show the PREVIOUS run's stale terminal state). */
3071
3102
  lastApplyProgress = initialApplyProgress;
3103
+ /** #195: re-point delegated writes at a switched project. The runner reads
3104
+ * `deps.projectDir` at trigger time, so this is the whole retarget. */
3105
+ retarget(projectDir) {
3106
+ this.deps.projectDir = projectDir;
3107
+ }
3072
3108
  /** Name of the running op, or null. */
3073
3109
  get running() {
3074
3110
  return this.current;
@@ -3239,23 +3275,23 @@ var OpRunner = class {
3239
3275
 
3240
3276
  // src/substrates.ts
3241
3277
  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";
3278
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "node:fs";
3279
+ import { join as join8 } from "node:path";
3244
3280
  import { platform } from "node:os";
3245
3281
  function probe(cmd, args) {
3246
- return new Promise((resolve3) => {
3282
+ return new Promise((resolve4) => {
3247
3283
  let out = "";
3248
3284
  let proc;
3249
3285
  try {
3250
3286
  proc = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
3251
3287
  } catch {
3252
- resolve3({ code: 127, out: "" });
3288
+ resolve4({ code: 127, out: "" });
3253
3289
  return;
3254
3290
  }
3255
3291
  proc.stdout.on("data", (d) => out += d);
3256
3292
  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 }));
3293
+ proc.on("error", () => resolve4({ code: 127, out }));
3294
+ proc.on("close", (code) => resolve4({ code: code ?? 1, out }));
3259
3295
  });
3260
3296
  }
3261
3297
  async function dockerAvailable() {
@@ -3268,11 +3304,11 @@ async function dockerRunning(nameFilter) {
3268
3304
  return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
3269
3305
  }
3270
3306
  function scriptBringUp(projectDir, relPath, label) {
3271
- return existsSync6(join7(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
3307
+ return existsSync7(join8(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
3272
3308
  }
3273
3309
  function projectLexicons(projectDir) {
3274
3310
  try {
3275
- const src = readFileSync5(join7(projectDir, "chant.config.ts"), "utf-8");
3311
+ const src = readFileSync6(join8(projectDir, "chant.config.ts"), "utf-8");
3276
3312
  const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
3277
3313
  if (!m) return [];
3278
3314
  return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
@@ -3322,7 +3358,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3322
3358
  ["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
3323
3359
  ];
3324
3360
  for (const [name, label, marker, script] of forges) {
3325
- if (!existsSync6(join7(projectDir, marker))) continue;
3361
+ if (!existsSync7(join8(projectDir, marker))) continue;
3326
3362
  const c = docker ? await dockerRunning(name) : [];
3327
3363
  const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
3328
3364
  subs.push({
@@ -3352,7 +3388,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3352
3388
  detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
3353
3389
  });
3354
3390
  }
3355
- if (existsSync6(join7(projectDir, ".github", "workflows"))) {
3391
+ if (existsSync7(join8(projectDir, ".github", "workflows"))) {
3356
3392
  const gh = await probe("gh", ["auth", "status"]);
3357
3393
  const ready = gh.code === 0;
3358
3394
  subs.push({
@@ -3365,7 +3401,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3365
3401
  if (lexicons.includes("temporal")) {
3366
3402
  let hasProfiles = false;
3367
3403
  try {
3368
- hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync5(join7(projectDir, "chant.config.ts"), "utf-8"));
3404
+ hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync6(join8(projectDir, "chant.config.ts"), "utf-8"));
3369
3405
  } catch {
3370
3406
  }
3371
3407
  subs.push({
@@ -3479,8 +3515,8 @@ async function composeEstate(projectDirs, opts = {}) {
3479
3515
  }
3480
3516
 
3481
3517
  // src/events.ts
3482
- import { watch, existsSync as existsSync7 } from "node:fs";
3483
- import { join as join8 } from "node:path";
3518
+ import { watch, existsSync as existsSync8 } from "node:fs";
3519
+ import { join as join9 } from "node:path";
3484
3520
  var Broadcaster = class {
3485
3521
  listeners = /* @__PURE__ */ new Set();
3486
3522
  subscribe(fn) {
@@ -3498,7 +3534,7 @@ var Broadcaster = class {
3498
3534
  };
3499
3535
  var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
3500
3536
  function watchSource(projectDir, onChange, debounceMs = 200) {
3501
- const dir = existsSync7(join8(projectDir, "src")) ? join8(projectDir, "src") : projectDir;
3537
+ const dir = existsSync8(join9(projectDir, "src")) ? join9(projectDir, "src") : projectDir;
3502
3538
  let timer;
3503
3539
  const watcher = watch(dir, { recursive: true }, (_event, file) => {
3504
3540
  const name = typeof file === "string" ? file : "";
@@ -3770,7 +3806,7 @@ async function emulatorDown(projectDir) {
3770
3806
  }
3771
3807
 
3772
3808
  // src/server.ts
3773
- var webRoot = join9(dirname2(fileURLToPath(import.meta.url)), "..", "web");
3809
+ var webRoot = join10(dirname3(fileURLToPath(import.meta.url)), "..", "web");
3774
3810
  var execFileP = async (cmd, args) => (await promisify4(execFile4)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
3775
3811
  function optsFromQuery(url, tierEnvVar, projectDir) {
3776
3812
  const q = url.searchParams;
@@ -3817,6 +3853,21 @@ function tierFailure(tier, message) {
3817
3853
  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
3854
  };
3819
3855
  }
3856
+ function beholdVersion() {
3857
+ try {
3858
+ const here = dirname3(fileURLToPath(import.meta.url));
3859
+ return JSON.parse(readFileSync7(join10(here, "..", "package.json"), "utf8")).version ?? "unknown";
3860
+ } catch {
3861
+ return "unknown";
3862
+ }
3863
+ }
3864
+ function noProjectError(projectDir) {
3865
+ return {
3866
+ code: "no-project",
3867
+ error: `${projectDir} doesn't look like a chant project \u2014 no chant.config.ts here, and the graph came back empty.`,
3868
+ 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)."
3869
+ };
3870
+ }
3820
3871
  function errorResponse(c, opts, err) {
3821
3872
  const message = err instanceof Error ? err.message : String(err);
3822
3873
  const failure = err instanceof ChantCliError ? err.failure : classifyChantFailure(message);
@@ -3855,8 +3906,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
3855
3906
  onDone: (opEnv) => captureFrame(cfg.projectDir, opEnv ?? cfg.env, frames, broadcaster)
3856
3907
  })) {
3857
3908
  const app = new Hono();
3858
- const beholdConfig = loadBeholdConfig(cfg.projectDir);
3859
- const tierEnvVar = beholdConfig.tiers?.envVar;
3909
+ let beholdConfig = loadBeholdConfig(cfg.projectDir);
3910
+ let tierEnvVar = beholdConfig.tiers?.envVar;
3860
3911
  const boundK8sContext = async (env) => {
3861
3912
  try {
3862
3913
  const { lexicons, k8sProfiles } = await detectProject(cfg.projectDir);
@@ -3938,9 +3989,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
3938
3989
  return c.json({ started: true, name, ran: label });
3939
3990
  });
3940
3991
  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)) {
3992
+ const down = join10(cfg.projectDir, "scripts/local/local-down.sh");
3993
+ const up = join10(cfg.projectDir, "scripts/local/local-up.sh");
3994
+ if (!existsSync9(down) || !existsSync9(up)) {
3944
3995
  return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
3945
3996
  }
3946
3997
  if (!runner.bringUp("reset local emulator", "bash", ["-c", "bash scripts/local/local-down.sh && bash scripts/local/local-up.sh"], cfg.projectDir)) {
@@ -3976,6 +4027,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
3976
4027
  const axes = deployAxes(tierEnvVar, lexicons, k8sTarget);
3977
4028
  return c.json({
3978
4029
  projectDir: cfg.projectDir,
4030
+ // #195: the full estate composition (multi-project serves) and the
4031
+ // switcher's recents, so the SPA's project section can show what's
4032
+ // loaded and offer where to go. Recents exclude nothing here — the SPA
4033
+ // filters out the currently-loaded dir itself.
4034
+ ...cfg.projectDirs && cfg.projectDirs.length > 1 ? { projectDirs: cfg.projectDirs } : {},
4035
+ recents: listRecents().map((r) => r.dir),
3979
4036
  environments,
3980
4037
  lexicons,
3981
4038
  currentEnv: cfg.env ?? null,
@@ -4003,6 +4060,72 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4003
4060
  ...axes
4004
4061
  });
4005
4062
  });
4063
+ app.post("/api/project/open", async (c) => {
4064
+ if (cfg.previewMode) return c.json({ error: "switching projects is locked in preview mode" }, 403);
4065
+ const body = await c.req.json().catch(() => ({}));
4066
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve2(body.dir.trim()) : "";
4067
+ if (!dir || !existsSync9(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
4068
+ if (!existsSync9(join10(dir, "chant.config.ts"))) {
4069
+ return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
4070
+ }
4071
+ addRecent(cfg.projectDir);
4072
+ cfg.projectDir = dir;
4073
+ cfg.projectDirs = void 0;
4074
+ cfg.env = void 0;
4075
+ beholdConfig = loadBeholdConfig(dir);
4076
+ tierEnvVar = beholdConfig.tiers?.envVar;
4077
+ runner.retarget(dir);
4078
+ addRecent(dir);
4079
+ cfg.onProjectSwitch?.(dir);
4080
+ broadcaster.emit("changed");
4081
+ return c.json({ ok: true, projectDir: dir });
4082
+ });
4083
+ app.post("/api/project/reveal", async (c) => {
4084
+ const body = await c.req.json().catch(() => ({}));
4085
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve2(body.dir.trim()) : cfg.projectDir;
4086
+ const known = /* @__PURE__ */ new Set([cfg.projectDir, ...cfg.projectDirs ?? [], ...listRecents().map((r) => r.dir)]);
4087
+ if (!known.has(dir)) return c.json({ error: "not a served or recent project directory" }, 400);
4088
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
4089
+ try {
4090
+ await execFileP(opener, [dir]);
4091
+ return c.json({ ok: true });
4092
+ } catch (err) {
4093
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
4094
+ }
4095
+ });
4096
+ app.get(
4097
+ "/api",
4098
+ (c) => c.json({
4099
+ name: "behold",
4100
+ version: beholdVersion(),
4101
+ agentsGuide: "https://github.com/INTENTIUS/behold/blob/main/AGENTS.md",
4102
+ routes: [
4103
+ { method: "GET", path: "/api", desc: "this index" },
4104
+ { method: "GET", path: "/api/project", desc: "project info: dir, recents, environments, tiers, targets, stacks, preview lock" },
4105
+ { method: "POST", path: "/api/project/open", desc: "switch the served project: JSON body {dir} (validated; preview-locked)" },
4106
+ { method: "POST", path: "/api/project/reveal", desc: "open the OS file manager at a served/recent project dir: JSON body {dir?}" },
4107
+ { 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" },
4108
+ { method: "GET", path: "/api/overlay", desc: "live drift overlay for ?env= \u2014 same shape/params as /api/graph, plus runtime=1" },
4109
+ { method: "GET", path: "/api/diff", desc: "per-node live diff for ?env= \u2014 {env, nodes: {<id>: {observed, diff, health, fieldDrift}}}" },
4110
+ { method: "GET", path: "/api/reconcile", desc: "pending-change summary for ?env=" },
4111
+ { method: "GET", path: "/api/resources", desc: "component \u2192 declared resources" },
4112
+ { method: "GET", path: "/api/ci", desc: "generated CI pipeline projection {stages, jobs, forge}" },
4113
+ { method: "GET", path: "/api/substrates", desc: "substrate readiness {substrates: [{name, label, status, detail, bringUp?}]}" },
4114
+ { method: "GET", path: "/api/ops", desc: "committed Ops + adopt lexicons + apply progress" },
4115
+ { method: "GET", path: "/api/history", desc: "recent source commits (rollback targets)" },
4116
+ { method: "GET", path: "/api/frames", desc: "captured lanes frames" },
4117
+ { method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / pr" },
4118
+ { method: "POST", path: "/api/refresh", desc: "re-observe live now (?env=) \u2014 returns the fresh graph" },
4119
+ { method: "POST", path: "/api/apply", desc: "delegated apply: ?env=&component=<name|all> (guarded, preview-locked)" },
4120
+ { method: "POST", path: "/api/ops/:name/run", desc: "run a committed Op (delegated write)" },
4121
+ { method: "POST", path: "/api/ops/:name/signal/:gate", desc: "approve an Op's gate" },
4122
+ { method: "POST", path: "/api/rollback", desc: "open a rollback PR: ?to=<sha>" },
4123
+ { method: "POST", path: "/api/substrates/:name/up", desc: "bring a substrate up" },
4124
+ { method: "POST", path: "/api/local/reset", desc: "reset the local emulator" },
4125
+ { method: "POST", path: "/api/ci/dispatch", desc: "dispatch the GitHub Actions pipeline via the operator's gh" }
4126
+ ]
4127
+ })
4128
+ );
4006
4129
  app.get("/api/graph", async (c) => {
4007
4130
  const url = new URL(c.req.url);
4008
4131
  const opts = optsFromQuery(url, tierEnvVar, cfg.projectDir);
@@ -4050,6 +4173,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4050
4173
  srcCompositeEdgesAttached = 0;
4051
4174
  }
4052
4175
  }
4176
+ if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync9(join10(cfg.projectDir, "chant.config.ts"))) {
4177
+ return c.json(noProjectError(cfg.projectDir), 404);
4178
+ }
4053
4179
  const radial = new URL(c.req.url).searchParams.get("radial") === "1";
4054
4180
  const { svg } = renderGraph(ir, multi ? { boxes: "byStack" } : { radial });
4055
4181
  const srcZoom = components ? "components" : logical ? "logical" : opts.detail === 1 ? "composites" : opts.detail === 3 ? "attributes" : "resources";
@@ -4333,7 +4459,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4333
4459
  });
4334
4460
  const rel = relative(process.cwd(), webRoot) || ".";
4335
4461
  app.use("/*", serveStatic({ root: rel }));
4336
- app.get("/", serveStatic({ path: join9(rel, "index.html") }));
4462
+ app.get("/", serveStatic({ path: join10(rel, "index.html") }));
4337
4463
  return app;
4338
4464
  }
4339
4465
  async function startServer(cfg) {
@@ -4401,8 +4527,8 @@ async function startServer(cfg) {
4401
4527
  }
4402
4528
  }
4403
4529
  };
4404
- const stopWatch = watchSource(cfg.projectDir, onEstateChange);
4405
- const stopPoll = cfg.env && cfg.pollSecs ? startDriftPoll({
4530
+ let stopWatch = watchSource(cfg.projectDir, onEstateChange);
4531
+ let stopPoll = cfg.env && cfg.pollSecs ? startDriftPoll({
4406
4532
  intervalMs: cfg.pollSecs * 1e3,
4407
4533
  query: () => graphIr(cfg.projectDir, { live: true, overlay: true, env: cfg.env }),
4408
4534
  onChange: onPollDrift,
@@ -4410,6 +4536,16 @@ async function startServer(cfg) {
4410
4536
  `)
4411
4537
  }) : () => {
4412
4538
  };
4539
+ cfg.onProjectSwitch = (dir) => {
4540
+ stopWatch();
4541
+ stopWatch = watchSource(dir, onEstateChange);
4542
+ stopPoll();
4543
+ stopPoll = () => {
4544
+ };
4545
+ process.stdout.write(` switched \u2192 ${dir}
4546
+ `);
4547
+ void capture();
4548
+ };
4413
4549
  void capture();
4414
4550
  let shuttingDown = false;
4415
4551
  const shutdown = () => {
@@ -4455,8 +4591,8 @@ async function startServer(cfg) {
4455
4591
  }
4456
4592
 
4457
4593
  // 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";
4594
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync8, readdirSync as readdirSync4 } from "node:fs";
4595
+ import { join as join11, dirname as dirname4, basename } from "node:path";
4460
4596
  import { fileURLToPath as fileURLToPath2 } from "node:url";
4461
4597
  var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
4462
4598
  function canonicalKey(path, params) {
@@ -4504,7 +4640,7 @@ function captureKeys(axes) {
4504
4640
  return [...keys];
4505
4641
  }
4506
4642
  function webDir() {
4507
- return join10(dirname3(fileURLToPath2(import.meta.url)), "..", "web");
4643
+ return join11(dirname4(fileURLToPath2(import.meta.url)), "..", "web");
4508
4644
  }
4509
4645
  function workerName(project, override) {
4510
4646
  const raw = override ?? `behold-${basename(project)}`;
@@ -4515,8 +4651,8 @@ async function runExport(cfg, outDir, opts = {}) {
4515
4651
  const app = createApp(cfg);
4516
4652
  const proj = await (await app.request("/api/project")).json();
4517
4653
  const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
4518
- const snapDir = join10(outDir, "snapshots");
4519
- mkdirSync(snapDir, { recursive: true });
4654
+ const snapDir = join11(outDir, "snapshots");
4655
+ mkdirSync2(snapDir, { recursive: true });
4520
4656
  const keyToFile = {};
4521
4657
  let ok = 0;
4522
4658
  let failed = 0;
@@ -4524,7 +4660,7 @@ async function runExport(cfg, outDir, opts = {}) {
4524
4660
  const res = await app.request(key);
4525
4661
  const body = await res.text();
4526
4662
  const file = slug(key);
4527
- writeFileSync(join10(snapDir, file), body);
4663
+ writeFileSync2(join11(snapDir, file), body);
4528
4664
  keyToFile[key] = `snapshots/${file}`;
4529
4665
  if (res.ok) ok++;
4530
4666
  else failed++;
@@ -4536,21 +4672,21 @@ async function runExport(cfg, outDir, opts = {}) {
4536
4672
  axes,
4537
4673
  keyToFile
4538
4674
  };
4539
- writeFileSync(join10(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
4540
- const html = readFileSync6(join10(webDir(), "index.html"), "utf8").replace(
4675
+ writeFileSync2(join11(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
4676
+ const html = readFileSync8(join11(webDir(), "index.html"), "utf8").replace(
4541
4677
  /<\/head>/i,
4542
4678
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
4543
4679
  </head>`
4544
4680
  );
4545
- writeFileSync(join10(outDir, "index.html"), html);
4681
+ writeFileSync2(join11(outDir, "index.html"), html);
4546
4682
  for (const f of readdirSync4(webDir())) {
4547
4683
  if (f === "index.html") continue;
4548
- copyFileSync(join10(webDir(), f), join10(outDir, f));
4684
+ copyFileSync(join11(webDir(), f), join11(outDir, f));
4549
4685
  }
4550
- writeFileSync(join10(outDir, "README.md"), BUNDLE_README);
4686
+ writeFileSync2(join11(outDir, "README.md"), BUNDLE_README);
4551
4687
  const name = workerName(cfg.projectDir, opts.name);
4552
- writeFileSync(
4553
- join10(outDir, "wrangler.jsonc"),
4688
+ writeFileSync2(
4689
+ join11(outDir, "wrangler.jsonc"),
4554
4690
  JSON.stringify(
4555
4691
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
4556
4692
  null,
@@ -4601,10 +4737,18 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
4601
4737
  var USAGE = `behold \u2014 a live control plane on chant (read-only core)
4602
4738
 
4603
4739
  Usage:
4740
+ behold demo [target-dir] [--port <n>]
4604
4741
  behold preview [project-dir] [--port <n>] [--emulator]
4605
4742
  behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
4606
4743
  behold serve <project-dir\u2026> [--port <n>] [--env <name>] [--poll <secs>] [--local]
4607
4744
 
4745
+ demo The five-minute path from npm \u2014 no chant project needed. Copies the
4746
+ bundled example (an S3 bucket + policy) into ./behold-demo (or
4747
+ [target-dir]), installs its dependencies, and serves it against a
4748
+ local emulator: blue = declared, click Deploy, watch it turn green.
4749
+ Needs Docker. The copy is yours \u2014 edit its source and watch the
4750
+ graph change live.
4751
+
4608
4752
  export Capture the live estate into a self-contained, interactive STATIC
4609
4753
  bundle (default ./behold-export) \u2014 every env/tier \xD7 zoom \xD7 radial,
4610
4754
  replayable with no backend. Host it anywhere (Cloudflare Pages/Workers,
@@ -4659,6 +4803,7 @@ Options:
4659
4803
  --name <worker> export only: Cloudflare Worker name in the generated
4660
4804
  wrangler.jsonc.
4661
4805
  -h, --help This text.
4806
+ -v, --version Print the behold version.
4662
4807
  `;
4663
4808
  async function run3(argv) {
4664
4809
  const [cmd, ...rest] = argv;
@@ -4666,6 +4811,14 @@ async function run3(argv) {
4666
4811
  process.stdout.write(USAGE);
4667
4812
  return;
4668
4813
  }
4814
+ if (cmd === "-v" || cmd === "--version") {
4815
+ process.stdout.write(beholdVersion() + "\n");
4816
+ return;
4817
+ }
4818
+ if (cmd === "demo") {
4819
+ await runDemo(rest);
4820
+ return;
4821
+ }
4669
4822
  if (cmd === "preview") {
4670
4823
  await runPreview(rest);
4671
4824
  return;
@@ -4729,7 +4882,8 @@ ${USAGE}`);
4729
4882
  process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
4730
4883
  process.exit(2);
4731
4884
  }
4732
- const dirs = projectDirs.map((d) => resolve2(d));
4885
+ const dirs = projectDirs.map((d) => resolve3(d));
4886
+ for (const d of dirs) warnIfNotChantProject(d);
4733
4887
  await startServer({
4734
4888
  projectDir: dirs[0],
4735
4889
  // primary — ops/overlay/rollback act on it
@@ -4741,6 +4895,61 @@ ${USAGE}`);
4741
4895
  ...local ? { local: true } : {}
4742
4896
  });
4743
4897
  }
4898
+ function warnIfNotChantProject(dir) {
4899
+ if (existsSync10(join12(dir, "chant.config.ts"))) return;
4900
+ process.stderr.write(
4901
+ `behold: warning \u2014 ${dir} has no chant.config.ts; this doesn't look like a chant project.
4902
+ No project yet? \`behold demo\` serves a bundled working example (needs Docker).
4903
+ `
4904
+ );
4905
+ }
4906
+ async function runDemo(rest) {
4907
+ let port = 4600;
4908
+ let dirArg;
4909
+ for (let i = 0; i < rest.length; i++) {
4910
+ const a = rest[i];
4911
+ if (a === "--port") port = Number(rest[++i]);
4912
+ else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
4913
+ else if (!a.startsWith("-")) dirArg = a;
4914
+ else {
4915
+ process.stderr.write(`behold demo: unexpected argument '${a}'
4916
+ `);
4917
+ process.exit(2);
4918
+ }
4919
+ }
4920
+ if (!Number.isFinite(port)) {
4921
+ process.stderr.write("behold demo: --port must be a number\n");
4922
+ process.exit(2);
4923
+ }
4924
+ const bundled = join12(dirname5(fileURLToPath3(import.meta.url)), "..", "example-writes");
4925
+ if (!existsSync10(bundled)) {
4926
+ process.stderr.write("behold demo: this install has no bundled example project (example-writes)\n");
4927
+ process.exit(2);
4928
+ }
4929
+ const target = resolve3(dirArg ?? "behold-demo");
4930
+ if (!existsSync10(target)) {
4931
+ process.stdout.write(`behold demo \u2192 copying the example project to ${target} (it's yours \u2014 edit it)
4932
+ `);
4933
+ cpSync(bundled, target, {
4934
+ recursive: true,
4935
+ filter: (src) => !relative2(bundled, src).split(sep).includes("node_modules")
4936
+ });
4937
+ } else {
4938
+ process.stdout.write(`behold demo \u2192 reusing ${target}
4939
+ `);
4940
+ }
4941
+ if (!existsSync10(join12(target, "node_modules"))) {
4942
+ process.stdout.write("behold demo \u2192 npm install (the example's own chant + lexicons)\u2026\n");
4943
+ const r = spawnSync("npm", ["install"], { cwd: target, stdio: "inherit", shell: process.platform === "win32" });
4944
+ if (r.status !== 0) {
4945
+ process.stderr.write(`behold demo: npm install failed in ${target}${r.error ? ` (${r.error.message})` : ""}
4946
+ `);
4947
+ process.exit(r.status ?? 1);
4948
+ }
4949
+ }
4950
+ process.stdout.write("behold demo \u2192 serving with a local emulator (Docker). Blue = declared; Deploy turns it green.\n");
4951
+ await run3(["serve", target, "--local", "--env", "prod", "--port", String(port)]);
4952
+ }
4744
4953
  function injectEmulatorEnv(env) {
4745
4954
  process.env.LOOM_ENV ??= env ?? "local";
4746
4955
  process.env.AWS_ENDPOINT_URL ??= "http://localhost:4566";
@@ -4763,13 +4972,14 @@ async function runPreview(rest) {
4763
4972
  process.stderr.write("behold preview: --port must be a number\n");
4764
4973
  process.exit(2);
4765
4974
  }
4766
- const projectDir = resolve2(dirArg ?? process.cwd());
4767
- if (!existsSync9(projectDir)) {
4975
+ const projectDir = resolve3(dirArg ?? process.cwd());
4976
+ if (!existsSync10(projectDir)) {
4768
4977
  process.stderr.write(`behold preview: project not found at ${projectDir}
4769
4978
  `);
4770
4979
  process.exit(2);
4771
4980
  }
4772
4981
  if (!emulator) {
4982
+ warnIfNotChantProject(projectDir);
4773
4983
  await startServer({ projectDir, port });
4774
4984
  return;
4775
4985
  }
@@ -4783,26 +4993,26 @@ async function runPreview(rest) {
4783
4993
  await startServer({ projectDir, port, env: "local", previewMode: true });
4784
4994
  }
4785
4995
  async function runExportCmd(rest) {
4786
- let outDir = resolve2("behold-export");
4996
+ let outDir = resolve3("behold-export");
4787
4997
  let env;
4788
4998
  let name;
4789
4999
  let dirArg;
4790
5000
  let emulator = false;
4791
5001
  for (let i = 0; i < rest.length; i++) {
4792
5002
  const a = rest[i];
4793
- if (a === "--out") outDir = resolve2(rest[++i]);
5003
+ if (a === "--out") outDir = resolve3(rest[++i]);
4794
5004
  else if (a === "--env") env = rest[++i];
4795
5005
  else if (a === "--name") name = rest[++i];
4796
5006
  else if (a === "--emulator") emulator = true;
4797
5007
  else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
4798
5008
  else if (!a.startsWith("-")) dirArg = a;
4799
5009
  }
4800
- const projectDir = resolve2(dirArg ?? process.cwd());
5010
+ const projectDir = resolve3(dirArg ?? process.cwd());
4801
5011
  if (emulator) {
4802
5012
  injectEmulatorEnv(env);
4803
5013
  env ??= "local";
4804
5014
  }
4805
- if (!existsSync9(projectDir)) {
5015
+ if (!existsSync10(projectDir)) {
4806
5016
  process.stderr.write(`behold export: project not found at ${projectDir}
4807
5017
  `);
4808
5018
  process.exit(2);