@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/AGENTS.md +64 -0
- package/README.md +25 -1
- package/dist/cli.js +425 -95
- package/example-writes/README.md +88 -0
- package/example-writes/chant.config.ts +12 -0
- package/example-writes/ops/apply.op.ts +7 -0
- package/example-writes/ops/floci.op.ts +20 -0
- package/example-writes/ops/reconcile.op.ts +6 -0
- package/example-writes/package-lock.json +1249 -0
- package/example-writes/package.json +14 -0
- package/example-writes/src/bucket.ts +54 -0
- package/example-writes/tsconfig.json +1 -0
- package/package.json +4 -2
- package/web/app.js +471 -115
- package/web/index.html +143 -91
- package/web/panel.js +163 -0
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/cli.ts
|
|
2
|
-
import { resolve as
|
|
3
|
-
import { realpathSync, existsSync as
|
|
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
|
|
93
|
-
import { dirname as
|
|
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
|
|
128
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
98
129
|
import { createRequire } from "node:module";
|
|
99
|
-
import { dirname, join as
|
|
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 =
|
|
110
|
-
if (
|
|
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:
|
|
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 =
|
|
234
|
+
const content = readFileSync2(path, "utf8");
|
|
173
235
|
const sourceDir = parseStringLiteral(content, "sourceDir");
|
|
236
|
+
const environments = parseEnvironmentNames(content);
|
|
174
237
|
return {
|
|
175
|
-
environments:
|
|
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 =
|
|
189
|
-
if (!
|
|
251
|
+
const path = join2(projectDir, BEHOLD_CONFIG_NAME);
|
|
252
|
+
if (!existsSync2(path)) return {};
|
|
190
253
|
try {
|
|
191
|
-
const raw = JSON.parse(
|
|
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 =
|
|
555
|
+
let dir = dirname2(entry);
|
|
493
556
|
for (; ; ) {
|
|
494
|
-
const manifest =
|
|
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
|
|
560
|
+
if (pkg.name === "@intentius/chant") return join3(dir, pkg.bin?.chant ?? "bin/chant");
|
|
498
561
|
} catch {
|
|
499
562
|
}
|
|
500
|
-
const parent =
|
|
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(
|
|
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 =
|
|
601
|
-
return
|
|
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 (!
|
|
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
|
|
739
|
-
import { join as
|
|
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 = [
|
|
822
|
+
const roots = [join4(projectDir, "src"), join4(projectDir, "ops")];
|
|
760
823
|
const files = [];
|
|
761
824
|
const walk = (dir, depth) => {
|
|
762
|
-
if (depth > 4 || !
|
|
825
|
+
if (depth > 4 || !existsSync4(dir)) return;
|
|
763
826
|
for (const f of readdirSync(dir)) {
|
|
764
|
-
const p =
|
|
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 =
|
|
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
|
|
833
|
-
import { join as
|
|
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((
|
|
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
|
-
|
|
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", () =>
|
|
899
|
-
proc.on("close", (code) =>
|
|
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 =
|
|
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 =
|
|
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
|
|
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(
|
|
1920
|
+
child(CLUSTER_SCOPED2, n.id);
|
|
1858
1921
|
}
|
|
1859
1922
|
}
|
|
1860
|
-
if (clusterScoped) child(CLUSTER_TITLE,
|
|
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
|
|
1908
|
-
import { join as
|
|
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(
|
|
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 =
|
|
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
|
|
2825
|
-
import { join as
|
|
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 =
|
|
2848
|
-
if (!
|
|
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 =
|
|
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
|
|
3243
|
-
import { join as
|
|
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((
|
|
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
|
-
|
|
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", () =>
|
|
3258
|
-
proc.on("close", (code) =>
|
|
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
|
|
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 =
|
|
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 (!
|
|
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 (
|
|
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(
|
|
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
|
|
3483
|
-
import { join as
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
3859
|
-
|
|
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 =
|
|
3942
|
-
const up =
|
|
3943
|
-
if (!
|
|
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
|
|
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:
|
|
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
|
-
|
|
4405
|
-
|
|
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
|
|
4459
|
-
import { join as
|
|
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
|
|
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 =
|
|
4519
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4540
|
-
const html =
|
|
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
|
-
|
|
4801
|
+
writeFileSync2(join11(outDir, "index.html"), html);
|
|
4546
4802
|
for (const f of readdirSync4(webDir())) {
|
|
4547
4803
|
if (f === "index.html") continue;
|
|
4548
|
-
copyFileSync(
|
|
4804
|
+
copyFileSync(join11(webDir(), f), join11(outDir, f));
|
|
4549
4805
|
}
|
|
4550
|
-
|
|
4806
|
+
writeFileSync2(join11(outDir, "README.md"), BUNDLE_README);
|
|
4551
4807
|
const name = workerName(cfg.projectDir, opts.name);
|
|
4552
|
-
|
|
4553
|
-
|
|
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) =>
|
|
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 =
|
|
4767
|
-
if (!
|
|
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 =
|
|
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 =
|
|
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 =
|
|
5130
|
+
const projectDir = resolve3(dirArg ?? process.cwd());
|
|
4801
5131
|
if (emulator) {
|
|
4802
5132
|
injectEmulatorEnv(env);
|
|
4803
5133
|
env ??= "local";
|
|
4804
5134
|
}
|
|
4805
|
-
if (!
|
|
5135
|
+
if (!existsSync10(projectDir)) {
|
|
4806
5136
|
process.stderr.write(`behold export: project not found at ${projectDir}
|
|
4807
5137
|
`);
|
|
4808
5138
|
process.exit(2);
|