@intentius/behold 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +338 -0
- package/bin/behold.js +15 -0
- package/dist/cli.js +4830 -0
- package/package.json +48 -0
- package/web/app.js +2352 -0
- package/web/index.html +248 -0
- package/web/theme.js +188 -0
- package/web/themes.js +559 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,4830 @@
|
|
|
1
|
+
// src/cli.ts
|
|
2
|
+
import { resolve as resolve2 } from "node:path";
|
|
3
|
+
import { realpathSync, existsSync as existsSync9 } from "node:fs";
|
|
4
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
5
|
+
|
|
6
|
+
// src/server.ts
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
|
|
9
|
+
// src/targets.ts
|
|
10
|
+
var SUBSTRATE_TARGET_VARS = [
|
|
11
|
+
{ lexicon: "aws", label: "Floci", envVar: "AWS_ENDPOINT_URL" },
|
|
12
|
+
{ lexicon: "fly", label: "Fly", envVar: "FLY_FLAPS_BASE_URL" },
|
|
13
|
+
{ lexicon: "azure", label: "floci-az", envVar: "AZURE_ENDPOINT_URL" },
|
|
14
|
+
{ lexicon: "gcp", label: "floci-gcp", envVar: "GCP_ENDPOINT_URL" }
|
|
15
|
+
];
|
|
16
|
+
function resolveSubstrateTargets(lexicons, env = process.env) {
|
|
17
|
+
const targets = [];
|
|
18
|
+
for (const { lexicon, label, envVar } of SUBSTRATE_TARGET_VARS) {
|
|
19
|
+
if (!lexicons.includes(lexicon)) continue;
|
|
20
|
+
const endpoint = env[envVar];
|
|
21
|
+
if (!endpoint) continue;
|
|
22
|
+
targets.push({ name: lexicon, label, endpoint, envVar });
|
|
23
|
+
}
|
|
24
|
+
return targets;
|
|
25
|
+
}
|
|
26
|
+
function targetEnvOverrides(targets, chosen) {
|
|
27
|
+
const overrides = {};
|
|
28
|
+
for (const t of targets) {
|
|
29
|
+
overrides[t.envVar] = chosen ?? t.endpoint;
|
|
30
|
+
}
|
|
31
|
+
return overrides;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/k8s-target.ts
|
|
35
|
+
import { execFile } from "node:child_process";
|
|
36
|
+
import { promisify } from "node:util";
|
|
37
|
+
var run = promisify(execFile);
|
|
38
|
+
var EMPTY = { contexts: /* @__PURE__ */ new Map(), servers: /* @__PURE__ */ new Map() };
|
|
39
|
+
function str(v) {
|
|
40
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
41
|
+
}
|
|
42
|
+
function readKubeconfigJson(json) {
|
|
43
|
+
const contexts = /* @__PURE__ */ new Map();
|
|
44
|
+
for (const entry of json.contexts ?? []) {
|
|
45
|
+
const name = str(entry?.name);
|
|
46
|
+
const cluster = str(entry?.context?.cluster);
|
|
47
|
+
if (name && cluster) contexts.set(name, cluster);
|
|
48
|
+
}
|
|
49
|
+
const servers = /* @__PURE__ */ new Map();
|
|
50
|
+
for (const entry of json.clusters ?? []) {
|
|
51
|
+
const name = str(entry?.name);
|
|
52
|
+
const server = str(entry?.cluster?.server);
|
|
53
|
+
if (name && server) servers.set(name, server);
|
|
54
|
+
}
|
|
55
|
+
const current = str(json["current-context"]);
|
|
56
|
+
return { contexts, servers, ...current ? { currentContext: current } : {} };
|
|
57
|
+
}
|
|
58
|
+
async function loadKubeconfig(exec = defaultExec) {
|
|
59
|
+
try {
|
|
60
|
+
const stdout = await exec("kubectl", ["config", "view", "--raw", "-o", "json"]);
|
|
61
|
+
return readKubeconfigJson(JSON.parse(stdout));
|
|
62
|
+
} catch {
|
|
63
|
+
return EMPTY;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function defaultExec(cmd, args) {
|
|
67
|
+
const { stdout } = await run(cmd, args, { encoding: "utf8", timeout: 1e4 });
|
|
68
|
+
return stdout;
|
|
69
|
+
}
|
|
70
|
+
function resolveK8sTarget(profiles, env, kubeconfig) {
|
|
71
|
+
const declared = env ? profiles?.[env]?.context : void 0;
|
|
72
|
+
const context = declared ?? kubeconfig.currentContext;
|
|
73
|
+
if (!context) return void 0;
|
|
74
|
+
const cluster = kubeconfig.contexts.get(context);
|
|
75
|
+
const endpoint = cluster ? kubeconfig.servers.get(cluster) : void 0;
|
|
76
|
+
if (!endpoint) return void 0;
|
|
77
|
+
return { name: "k8s", label: context, endpoint, source: declared ? "profile" : "current-context" };
|
|
78
|
+
}
|
|
79
|
+
function contextBindsCluster(context, clusterName) {
|
|
80
|
+
if (!clusterName) return void 0;
|
|
81
|
+
if (context === `k3d-${clusterName}`) return true;
|
|
82
|
+
return context.split(/[/_:@]/).includes(clusterName);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/server.ts
|
|
86
|
+
import { streamSSE } from "hono/streaming";
|
|
87
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
88
|
+
import { serve } from "@hono/node-server";
|
|
89
|
+
import { fileURLToPath } from "node:url";
|
|
90
|
+
import { execFile as execFile4 } from "node:child_process";
|
|
91
|
+
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";
|
|
94
|
+
|
|
95
|
+
// src/chant.ts
|
|
96
|
+
import { spawn } from "node:child_process";
|
|
97
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
98
|
+
import { createRequire } from "node:module";
|
|
99
|
+
import { dirname, join as join2, resolve } from "node:path";
|
|
100
|
+
|
|
101
|
+
// src/project.ts
|
|
102
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
103
|
+
import { join } from "node:path";
|
|
104
|
+
import { pathToFileURL } from "node:url";
|
|
105
|
+
var CONFIG_NAMES = ["chant.config.ts", "chant.config.mts", "chant.config.js", "chant.config.mjs"];
|
|
106
|
+
var BEHOLD_CONFIG_NAME = ".behold.json";
|
|
107
|
+
function configPath(projectDir) {
|
|
108
|
+
for (const name of CONFIG_NAMES) {
|
|
109
|
+
const p = join(projectDir, name);
|
|
110
|
+
if (existsSync(p)) return p;
|
|
111
|
+
}
|
|
112
|
+
return void 0;
|
|
113
|
+
}
|
|
114
|
+
function readStacks(v) {
|
|
115
|
+
if (!Array.isArray(v)) return void 0;
|
|
116
|
+
const stacks = v.filter(
|
|
117
|
+
(s) => !!s && typeof s === "object" && typeof s.name === "string" && typeof s.src === "string"
|
|
118
|
+
);
|
|
119
|
+
return stacks.length ? stacks : void 0;
|
|
120
|
+
}
|
|
121
|
+
function readK8sProfiles(k8s) {
|
|
122
|
+
const profiles = k8s?.profiles;
|
|
123
|
+
if (!profiles || typeof profiles !== "object" || Array.isArray(profiles)) return void 0;
|
|
124
|
+
const out = {};
|
|
125
|
+
for (const [env, value] of Object.entries(profiles)) {
|
|
126
|
+
const context = value?.context;
|
|
127
|
+
if (typeof context === "string" && context.length > 0) out[env] = { context };
|
|
128
|
+
}
|
|
129
|
+
return Object.keys(out).length ? out : void 0;
|
|
130
|
+
}
|
|
131
|
+
function readInfo(cfg) {
|
|
132
|
+
const arr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
133
|
+
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
|
+
const stacks = readStacks(cfg?.stacks);
|
|
135
|
+
const k8sProfiles = readK8sProfiles(cfg?.k8s);
|
|
136
|
+
return {
|
|
137
|
+
environments: envNames(cfg?.environments),
|
|
138
|
+
lexicons: arr(cfg?.lexicons),
|
|
139
|
+
...k8sProfiles ? { k8sProfiles } : {},
|
|
140
|
+
...typeof cfg?.sourceDir === "string" ? { sourceDir: cfg.sourceDir } : {},
|
|
141
|
+
...stacks ? { stacks } : {}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function parseStringArray(content, key) {
|
|
145
|
+
const arr = content.match(new RegExp(`\\b${key}\\s*:\\s*\\[([^\\]]*)\\]`));
|
|
146
|
+
if (!arr) return [];
|
|
147
|
+
return [...arr[1].matchAll(/["'`]([^"'`]+)["'`]/g)].map((m) => m[1]);
|
|
148
|
+
}
|
|
149
|
+
function parseEnvironmentNames(content) {
|
|
150
|
+
const arr = content.match(/\benvironments\s*:\s*\[([^\]]*)\]/);
|
|
151
|
+
if (!arr) return [];
|
|
152
|
+
const body = arr[1];
|
|
153
|
+
if (body.includes("{")) {
|
|
154
|
+
return [...body.matchAll(/\bname\s*:\s*["'`]([^"'`]+)["'`]/g)].map((m) => m[1]);
|
|
155
|
+
}
|
|
156
|
+
return [...body.matchAll(/["'`]([^"'`]+)["'`]/g)].map((m) => m[1]);
|
|
157
|
+
}
|
|
158
|
+
function parseStringLiteral(content, key) {
|
|
159
|
+
const m = content.match(new RegExp(`\\b${key}\\s*:\\s*["'\`]([^"'\`]+)["'\`]`));
|
|
160
|
+
return m?.[1];
|
|
161
|
+
}
|
|
162
|
+
async function detectProject(projectDir) {
|
|
163
|
+
const path = configPath(projectDir);
|
|
164
|
+
if (!path) return { environments: [], lexicons: [] };
|
|
165
|
+
try {
|
|
166
|
+
const mod = await import(pathToFileURL(path).href);
|
|
167
|
+
const cfg = mod.default ?? mod.config ?? mod;
|
|
168
|
+
const info = readInfo(cfg);
|
|
169
|
+
if (info.environments.length || info.lexicons.length || info.sourceDir || info.stacks?.length) return info;
|
|
170
|
+
} catch {
|
|
171
|
+
}
|
|
172
|
+
const content = readFileSync(path, "utf8");
|
|
173
|
+
const sourceDir = parseStringLiteral(content, "sourceDir");
|
|
174
|
+
return {
|
|
175
|
+
environments: parseEnvironmentNames(content),
|
|
176
|
+
lexicons: parseStringArray(content, "lexicons"),
|
|
177
|
+
...sourceDir ? { sourceDir } : {}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function readTiers(cfg) {
|
|
181
|
+
const tiers = cfg?.tiers;
|
|
182
|
+
if (!tiers || typeof tiers.envVar !== "string" || !tiers.envVar) return void 0;
|
|
183
|
+
const values = Array.isArray(tiers.values) ? tiers.values.filter((v) => typeof v === "string") : [];
|
|
184
|
+
if (!values.length) return void 0;
|
|
185
|
+
return { envVar: tiers.envVar, values };
|
|
186
|
+
}
|
|
187
|
+
function loadBeholdConfig(projectDir) {
|
|
188
|
+
const path = join(projectDir, BEHOLD_CONFIG_NAME);
|
|
189
|
+
if (!existsSync(path)) return {};
|
|
190
|
+
try {
|
|
191
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
192
|
+
const tiers = readTiers(raw);
|
|
193
|
+
return tiers ? { tiers } : {};
|
|
194
|
+
} catch {
|
|
195
|
+
return {};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// node_modules/@intentius/chant/src/yaml.ts
|
|
200
|
+
function parseYAML(content) {
|
|
201
|
+
try {
|
|
202
|
+
return JSON.parse(content);
|
|
203
|
+
} catch {
|
|
204
|
+
}
|
|
205
|
+
const lines = content.replace(/\r\n?/g, "\n").split("\n");
|
|
206
|
+
return parseYAMLLines(lines, 0, 0).value;
|
|
207
|
+
}
|
|
208
|
+
function blockScalarHeader(inline) {
|
|
209
|
+
const m = inline.match(/^([|>])([+-]?)(?:\s+#.*)?$/);
|
|
210
|
+
if (!m) return null;
|
|
211
|
+
return {
|
|
212
|
+
style: m[1] === "|" ? "literal" : "folded",
|
|
213
|
+
chomp: m[2] === "-" ? "strip" : m[2] === "+" ? "keep" : "clip"
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function parseBlockScalar(lines, startIndex, parentIndent, header) {
|
|
217
|
+
const raw = [];
|
|
218
|
+
let blockIndent = -1;
|
|
219
|
+
let i = startIndex;
|
|
220
|
+
for (; i < lines.length; i++) {
|
|
221
|
+
const line = lines[i];
|
|
222
|
+
if (line.trim() === "") {
|
|
223
|
+
raw.push("");
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const ni = line.search(/\S/);
|
|
227
|
+
if (ni <= parentIndent) break;
|
|
228
|
+
if (blockIndent === -1) blockIndent = ni;
|
|
229
|
+
if (ni < blockIndent) break;
|
|
230
|
+
raw.push(line.slice(blockIndent));
|
|
231
|
+
}
|
|
232
|
+
let text;
|
|
233
|
+
if (header.style === "folded") {
|
|
234
|
+
text = "";
|
|
235
|
+
let buf = [];
|
|
236
|
+
let blankRun = 0;
|
|
237
|
+
let wrote = false;
|
|
238
|
+
const flush = () => {
|
|
239
|
+
if (buf.length === 0) return;
|
|
240
|
+
if (wrote) text += "\n".repeat(blankRun);
|
|
241
|
+
text += buf.join(" ");
|
|
242
|
+
buf = [];
|
|
243
|
+
blankRun = 0;
|
|
244
|
+
wrote = true;
|
|
245
|
+
};
|
|
246
|
+
for (const l of raw) {
|
|
247
|
+
if (l === "") {
|
|
248
|
+
flush();
|
|
249
|
+
blankRun++;
|
|
250
|
+
} else buf.push(l);
|
|
251
|
+
}
|
|
252
|
+
flush();
|
|
253
|
+
} else {
|
|
254
|
+
text = raw.join("\n");
|
|
255
|
+
}
|
|
256
|
+
const trailing = text.match(/\n*$/)?.[0].length ?? 0;
|
|
257
|
+
const stripped = text.replace(/\n+$/, "");
|
|
258
|
+
if (header.chomp === "strip") text = stripped;
|
|
259
|
+
else if (header.chomp === "keep") text = stripped + "\n".repeat(Math.max(trailing, raw.length > 0 ? 1 : 0));
|
|
260
|
+
else text = stripped === "" ? "" : stripped + "\n";
|
|
261
|
+
return { value: text, endIndex: i };
|
|
262
|
+
}
|
|
263
|
+
function parseYAMLLines(lines, startIndex, baseIndent) {
|
|
264
|
+
const result = {};
|
|
265
|
+
let i = startIndex;
|
|
266
|
+
while (i < lines.length) {
|
|
267
|
+
const line = lines[i];
|
|
268
|
+
if (line.trim() === "" || line.trim().startsWith("#")) {
|
|
269
|
+
i++;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const indent = line.search(/\S/);
|
|
273
|
+
if (indent < baseIndent) break;
|
|
274
|
+
if (indent > baseIndent && startIndex > 0) break;
|
|
275
|
+
const keyMatch = line.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
|
|
276
|
+
if (keyMatch) {
|
|
277
|
+
const key = keyMatch[2].trim();
|
|
278
|
+
const inlineValue = keyMatch[3].trim();
|
|
279
|
+
if (inlineValue === "" || inlineValue.startsWith("#")) {
|
|
280
|
+
if (i + 1 < lines.length) {
|
|
281
|
+
const nextLine = lines[i + 1];
|
|
282
|
+
const nextIndent = nextLine.search(/\S/);
|
|
283
|
+
if (nextLine.trimStart().startsWith("- ") && nextIndent >= indent) {
|
|
284
|
+
const arr = parseYAMLArray(lines, i + 1, nextIndent);
|
|
285
|
+
result[key] = arr.value;
|
|
286
|
+
i = arr.endIndex;
|
|
287
|
+
continue;
|
|
288
|
+
} else if (nextIndent > indent) {
|
|
289
|
+
const nested = parseYAMLLines(lines, i + 1, nextIndent);
|
|
290
|
+
result[key] = nested.value;
|
|
291
|
+
i = nested.endIndex;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
result[key] = null;
|
|
296
|
+
i++;
|
|
297
|
+
} else if (inlineValue.startsWith("[")) {
|
|
298
|
+
try {
|
|
299
|
+
result[key] = JSON.parse(inlineValue);
|
|
300
|
+
} catch {
|
|
301
|
+
result[key] = inlineValue;
|
|
302
|
+
}
|
|
303
|
+
i++;
|
|
304
|
+
} else if (inlineValue.startsWith("{")) {
|
|
305
|
+
try {
|
|
306
|
+
result[key] = JSON.parse(inlineValue);
|
|
307
|
+
} catch {
|
|
308
|
+
result[key] = inlineValue;
|
|
309
|
+
}
|
|
310
|
+
i++;
|
|
311
|
+
} else {
|
|
312
|
+
const header = blockScalarHeader(inlineValue);
|
|
313
|
+
if (header) {
|
|
314
|
+
const block = parseBlockScalar(lines, i + 1, indent, header);
|
|
315
|
+
result[key] = block.value;
|
|
316
|
+
i = block.endIndex;
|
|
317
|
+
} else {
|
|
318
|
+
result[key] = parseScalar(inlineValue);
|
|
319
|
+
i++;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} else if (line.trimStart().startsWith("- ")) {
|
|
323
|
+
break;
|
|
324
|
+
} else {
|
|
325
|
+
i++;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { value: result, endIndex: i };
|
|
329
|
+
}
|
|
330
|
+
function parseArrayItemValue(inlineValue, lines, currentIndex, keyIndent) {
|
|
331
|
+
if (inlineValue !== "" && !inlineValue.startsWith("#")) {
|
|
332
|
+
const header = blockScalarHeader(inlineValue);
|
|
333
|
+
if (header) {
|
|
334
|
+
const dash = lines[currentIndex].match(/^(\s*)- /);
|
|
335
|
+
const keyIndent2 = dash ? dash[1].length + 2 : lines[currentIndex].search(/\S/);
|
|
336
|
+
return parseBlockScalar(lines, currentIndex + 1, keyIndent2, header).value;
|
|
337
|
+
}
|
|
338
|
+
if (inlineValue.startsWith("[")) {
|
|
339
|
+
try {
|
|
340
|
+
return JSON.parse(inlineValue);
|
|
341
|
+
} catch {
|
|
342
|
+
return inlineValue;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (inlineValue.startsWith("{")) {
|
|
346
|
+
try {
|
|
347
|
+
return JSON.parse(inlineValue);
|
|
348
|
+
} catch {
|
|
349
|
+
return inlineValue;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return parseScalar(inlineValue);
|
|
353
|
+
}
|
|
354
|
+
const nextIdx = currentIndex + 1;
|
|
355
|
+
if (nextIdx < lines.length) {
|
|
356
|
+
const nextLine = lines[nextIdx];
|
|
357
|
+
if (nextLine.trim() !== "" && !nextLine.trim().startsWith("#")) {
|
|
358
|
+
const ni = nextLine.search(/\S/);
|
|
359
|
+
if (nextLine.trimStart().startsWith("- ")) {
|
|
360
|
+
if (ni >= keyIndent) return parseYAMLArray(lines, nextIdx, ni).value;
|
|
361
|
+
} else if (ni > keyIndent) {
|
|
362
|
+
return parseYAMLLines(lines, nextIdx, ni).value;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
function skipValueBlock(lines, startIndex, keyIndent) {
|
|
369
|
+
let k = startIndex;
|
|
370
|
+
while (k < lines.length && (lines[k].trim() === "" || lines[k].trim().startsWith("#"))) k++;
|
|
371
|
+
if (k < lines.length) {
|
|
372
|
+
const ni = lines[k].search(/\S/);
|
|
373
|
+
if (ni >= keyIndent && lines[k].trimStart().startsWith("- ")) {
|
|
374
|
+
return parseYAMLArray(lines, k, ni).endIndex;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return skipNestedBlock(lines, startIndex, keyIndent + 1);
|
|
378
|
+
}
|
|
379
|
+
function skipNestedBlock(lines, startIndex, childIndent) {
|
|
380
|
+
let j = startIndex;
|
|
381
|
+
while (j < lines.length) {
|
|
382
|
+
const l = lines[j];
|
|
383
|
+
if (l.trim() === "" || l.trim().startsWith("#")) {
|
|
384
|
+
j++;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const ni = l.search(/\S/);
|
|
388
|
+
if (ni < childIndent) break;
|
|
389
|
+
j++;
|
|
390
|
+
}
|
|
391
|
+
return j;
|
|
392
|
+
}
|
|
393
|
+
function parseYAMLArray(lines, startIndex, baseIndent) {
|
|
394
|
+
const result = [];
|
|
395
|
+
let i = startIndex;
|
|
396
|
+
while (i < lines.length) {
|
|
397
|
+
const line = lines[i];
|
|
398
|
+
if (line.trim() === "" || line.trim().startsWith("#")) {
|
|
399
|
+
i++;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const indent = line.search(/\S/);
|
|
403
|
+
if (indent < baseIndent) break;
|
|
404
|
+
const itemMatch = line.match(/^(\s*)- (.*)$/);
|
|
405
|
+
if (itemMatch && indent === baseIndent) {
|
|
406
|
+
const itemValue = itemMatch[2].trim();
|
|
407
|
+
const isQuotedScalar = itemValue.startsWith('"') && itemValue.endsWith('"') || itemValue.startsWith("'") && itemValue.endsWith("'");
|
|
408
|
+
const kvMatch = !isQuotedScalar && itemValue.match(/^([^\s:][^:]*?):\s*(.*)$/);
|
|
409
|
+
if (kvMatch) {
|
|
410
|
+
const obj = {};
|
|
411
|
+
obj[kvMatch[1].trim()] = parseArrayItemValue(kvMatch[2].trim(), lines, i, indent + 2);
|
|
412
|
+
const nextIndent = indent + 2;
|
|
413
|
+
const firstVal = kvMatch[2].trim();
|
|
414
|
+
let j = firstVal === "" || firstVal.startsWith("#") ? skipValueBlock(lines, i + 1, nextIndent) : blockScalarHeader(firstVal) ? skipNestedBlock(lines, i + 1, nextIndent + 1) : i + 1;
|
|
415
|
+
while (j < lines.length) {
|
|
416
|
+
const nextLine = lines[j];
|
|
417
|
+
if (nextLine.trim() === "" || nextLine.trim().startsWith("#")) {
|
|
418
|
+
j++;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const ni = nextLine.search(/\S/);
|
|
422
|
+
if (ni < nextIndent) break;
|
|
423
|
+
if (ni > nextIndent) break;
|
|
424
|
+
const nextKV = nextLine.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
|
|
425
|
+
if (nextKV) {
|
|
426
|
+
const nextVal = nextKV[3].trim();
|
|
427
|
+
obj[nextKV[2].trim()] = parseArrayItemValue(nextVal, lines, j, ni);
|
|
428
|
+
if (nextVal === "" || nextVal.startsWith("#")) {
|
|
429
|
+
j = skipValueBlock(lines, j + 1, ni);
|
|
430
|
+
} else if (blockScalarHeader(nextVal)) {
|
|
431
|
+
j = skipNestedBlock(lines, j + 1, ni + 1);
|
|
432
|
+
} else {
|
|
433
|
+
j++;
|
|
434
|
+
}
|
|
435
|
+
} else {
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
result.push(obj);
|
|
440
|
+
i = j;
|
|
441
|
+
} else {
|
|
442
|
+
result.push(parseScalar(itemValue));
|
|
443
|
+
i++;
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return { value: result, endIndex: i };
|
|
450
|
+
}
|
|
451
|
+
function parseScalar(value) {
|
|
452
|
+
if (value === "" || value === "~" || value === "null") return null;
|
|
453
|
+
if (value === "true" || value === "yes") return true;
|
|
454
|
+
if (value === "false" || value === "no") return false;
|
|
455
|
+
if (value.startsWith("'") && value.endsWith("'") || value.startsWith('"') && value.endsWith('"')) {
|
|
456
|
+
return value.slice(1, -1);
|
|
457
|
+
}
|
|
458
|
+
const num = Number(value);
|
|
459
|
+
if (!isNaN(num) && value !== "") return num;
|
|
460
|
+
return value;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// src/chant.ts
|
|
464
|
+
function envOverridesFor(opts) {
|
|
465
|
+
const overrides = {};
|
|
466
|
+
if (opts.tier && opts.tierEnvVar) overrides[opts.tierEnvVar] = opts.tier;
|
|
467
|
+
if (opts.substrateTargets?.length) {
|
|
468
|
+
Object.assign(overrides, targetEnvOverrides(opts.substrateTargets, opts.target));
|
|
469
|
+
} else if (opts.target) {
|
|
470
|
+
overrides.AWS_ENDPOINT_URL = opts.target;
|
|
471
|
+
}
|
|
472
|
+
return Object.keys(overrides).length ? overrides : void 0;
|
|
473
|
+
}
|
|
474
|
+
function graphFlags(opts) {
|
|
475
|
+
const flags = [];
|
|
476
|
+
if (opts.detail !== void 0) flags.push("--detail", String(opts.detail));
|
|
477
|
+
if (opts.lens) flags.push("--lens", opts.lens);
|
|
478
|
+
if (opts.up) flags.push("--up");
|
|
479
|
+
if (opts.down) flags.push("--down");
|
|
480
|
+
if (opts.env) flags.push("--env", opts.env);
|
|
481
|
+
if (opts.live) flags.push("--live");
|
|
482
|
+
if (opts.overlay) flags.push("--overlay");
|
|
483
|
+
return flags;
|
|
484
|
+
}
|
|
485
|
+
function chantBinFrom(req) {
|
|
486
|
+
let entry;
|
|
487
|
+
try {
|
|
488
|
+
entry = req.resolve("@intentius/chant");
|
|
489
|
+
} catch {
|
|
490
|
+
return void 0;
|
|
491
|
+
}
|
|
492
|
+
let dir = dirname(entry);
|
|
493
|
+
for (; ; ) {
|
|
494
|
+
const manifest = join2(dir, "package.json");
|
|
495
|
+
try {
|
|
496
|
+
const pkg = createRequire(import.meta.url)(manifest);
|
|
497
|
+
if (pkg.name === "@intentius/chant") return join2(dir, pkg.bin?.chant ?? "bin/chant");
|
|
498
|
+
} catch {
|
|
499
|
+
}
|
|
500
|
+
const parent = dirname(dir);
|
|
501
|
+
if (parent === dir) break;
|
|
502
|
+
dir = parent;
|
|
503
|
+
}
|
|
504
|
+
return void 0;
|
|
505
|
+
}
|
|
506
|
+
function chantBin(projectDir) {
|
|
507
|
+
if (projectDir) {
|
|
508
|
+
const fromProject = chantBinFrom(createRequire(join2(resolve(projectDir), "noop.js")));
|
|
509
|
+
if (fromProject) return fromProject;
|
|
510
|
+
}
|
|
511
|
+
const own = chantBinFrom(createRequire(import.meta.url));
|
|
512
|
+
if (own) return own;
|
|
513
|
+
return "chant";
|
|
514
|
+
}
|
|
515
|
+
function runChantRaw(args, projectDir, envOverride) {
|
|
516
|
+
return new Promise((resolvePromise, reject) => {
|
|
517
|
+
const proc = spawn(chantBin(projectDir), args, {
|
|
518
|
+
...projectDir ? { cwd: projectDir } : {},
|
|
519
|
+
...envOverride ? { env: { ...process.env, ...envOverride } } : {},
|
|
520
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
521
|
+
});
|
|
522
|
+
const outChunks = [];
|
|
523
|
+
const errChunks = [];
|
|
524
|
+
proc.stdout.on("data", (d) => outChunks.push(d));
|
|
525
|
+
proc.stderr.on("data", (d) => errChunks.push(d));
|
|
526
|
+
proc.on("error", reject);
|
|
527
|
+
proc.on(
|
|
528
|
+
"close",
|
|
529
|
+
(code) => resolvePromise({
|
|
530
|
+
code: code ?? 1,
|
|
531
|
+
stdout: Buffer.concat(outChunks).toString("utf8"),
|
|
532
|
+
stderr: Buffer.concat(errChunks).toString("utf8")
|
|
533
|
+
})
|
|
534
|
+
);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
var ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
538
|
+
function stripAnsi(text) {
|
|
539
|
+
return text.replace(ANSI_RE, "");
|
|
540
|
+
}
|
|
541
|
+
function classifyChantFailure(stderr) {
|
|
542
|
+
const clean = stripAnsi(stderr).trim();
|
|
543
|
+
if (/refusing to emit graph: source has lint errors/i.test(clean)) {
|
|
544
|
+
return {
|
|
545
|
+
code: "lint",
|
|
546
|
+
message: clean || "chant refused to emit the graph: the project has lint errors.",
|
|
547
|
+
remedy: "Run `chant lint` in the project to see the errors, fix them, then reload."
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
if (/cannot find (package|module)\s+['"]/i.test(clean)) {
|
|
551
|
+
return {
|
|
552
|
+
code: "not-installed",
|
|
553
|
+
message: clean || "chant could not resolve a package the project's source imports.",
|
|
554
|
+
remedy: "Run `npm install && chant typegen` in the project directory, then reload."
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
code: "eval",
|
|
559
|
+
message: clean || "chant failed to evaluate the project.",
|
|
560
|
+
remedy: "Check the project's chant source and environment \u2014 see the message above for chant's own error."
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
var ChantCliError = class extends Error {
|
|
564
|
+
failure;
|
|
565
|
+
constructor(args, code, stderr) {
|
|
566
|
+
super(`chant ${args.join(" ")} exited ${code}: ${stderr.trim()}`);
|
|
567
|
+
this.name = "ChantCliError";
|
|
568
|
+
this.failure = classifyChantFailure(stderr);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
async function runChantJson(args, projectDir, envOverride) {
|
|
572
|
+
const { code, stdout, stderr } = await runChantRaw(args, projectDir, envOverride);
|
|
573
|
+
if (code !== 0) throw new ChantCliError(args, code, stderr);
|
|
574
|
+
return JSON.parse(stdout);
|
|
575
|
+
}
|
|
576
|
+
function runChantStream(args, projectDir, onLine) {
|
|
577
|
+
const proc = spawn(chantBin(projectDir), args, { cwd: projectDir, stdio: ["ignore", "pipe", "pipe"] });
|
|
578
|
+
const feed = (buf) => {
|
|
579
|
+
for (const line of String(buf).split(/\r?\n/)) if (line.trim()) onLine(line);
|
|
580
|
+
};
|
|
581
|
+
proc.stdout.on("data", feed);
|
|
582
|
+
proc.stderr.on("data", feed);
|
|
583
|
+
const done = new Promise((res) => proc.on("close", (c) => res(c ?? 1)));
|
|
584
|
+
return { pid: proc.pid ?? -1, kill: () => proc.kill(), done };
|
|
585
|
+
}
|
|
586
|
+
function runCommandStream(cmd, args, cwd, onLine) {
|
|
587
|
+
const proc = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
588
|
+
const feed = (buf) => {
|
|
589
|
+
for (const line of String(buf).split(/\r?\n/)) if (line.trim()) onLine(line);
|
|
590
|
+
};
|
|
591
|
+
proc.stdout.on("data", feed);
|
|
592
|
+
proc.stderr.on("data", feed);
|
|
593
|
+
const done = new Promise((res) => {
|
|
594
|
+
proc.on("error", () => res(127));
|
|
595
|
+
proc.on("close", (c) => res(c ?? 1));
|
|
596
|
+
});
|
|
597
|
+
return { pid: proc.pid ?? -1, kill: () => proc.kill(), done };
|
|
598
|
+
}
|
|
599
|
+
function legacyGraphPath(projectDir) {
|
|
600
|
+
const src = join2(projectDir, "src");
|
|
601
|
+
return existsSync2(src) ? src : projectDir;
|
|
602
|
+
}
|
|
603
|
+
async function graphPath(projectDir, opts = {}) {
|
|
604
|
+
let info;
|
|
605
|
+
try {
|
|
606
|
+
info = await detectProject(projectDir);
|
|
607
|
+
} catch {
|
|
608
|
+
return legacyGraphPath(projectDir);
|
|
609
|
+
}
|
|
610
|
+
if (info.stacks?.length) {
|
|
611
|
+
const picked = opts.stack ? info.stacks.find((s) => s.name === opts.stack) : void 0;
|
|
612
|
+
return resolve(projectDir, (picked ?? info.stacks[0]).src);
|
|
613
|
+
}
|
|
614
|
+
if (info.sourceDir) return resolve(projectDir, info.sourceDir);
|
|
615
|
+
return legacyGraphPath(projectDir);
|
|
616
|
+
}
|
|
617
|
+
function graphArgs(src, format, opts, components) {
|
|
618
|
+
return ["graph", src, ...components ? ["--components"] : [], "--format", format, ...graphFlags(opts)];
|
|
619
|
+
}
|
|
620
|
+
async function graphIr(projectDir, opts = {}) {
|
|
621
|
+
const src = await graphPath(projectDir, opts);
|
|
622
|
+
return runChantJson(graphArgs(src, "ir", opts, false), projectDir, envOverridesFor(opts));
|
|
623
|
+
}
|
|
624
|
+
async function clusterRootGraphIr(projectDir, opts = {}) {
|
|
625
|
+
if (!existsSync2(join2(projectDir, "cluster"))) return void 0;
|
|
626
|
+
const { live: _live, overlay: _overlay, env: _env, ...sourceOnly } = opts;
|
|
627
|
+
try {
|
|
628
|
+
return await runChantJson(graphArgs("cluster", "ir", sourceOnly, false), projectDir, envOverridesFor(sourceOnly));
|
|
629
|
+
} catch {
|
|
630
|
+
return void 0;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async function componentGraphIr(projectDir, opts = {}) {
|
|
634
|
+
const src = await graphPath(projectDir, opts);
|
|
635
|
+
return runChantJson(graphArgs(src, "ir", opts, true), projectDir, envOverridesFor(opts));
|
|
636
|
+
}
|
|
637
|
+
function componentStatusArgs(env) {
|
|
638
|
+
return ["components", "status", env, "--live", "--json"];
|
|
639
|
+
}
|
|
640
|
+
function componentStatus(projectDir, env, opts = {}) {
|
|
641
|
+
return runChantJson(componentStatusArgs(env), projectDir, envOverridesFor(opts));
|
|
642
|
+
}
|
|
643
|
+
var CI_FORGES = ["gitlab", "github", "forgejo"];
|
|
644
|
+
function ciForgeFor(lexicons) {
|
|
645
|
+
return CI_FORGES.find((forge) => lexicons.includes(forge));
|
|
646
|
+
}
|
|
647
|
+
function ciPipelineArgs(opts = {}, forge = "gitlab") {
|
|
648
|
+
const args = ["build", "--components", "--generate", forge, "--format", "json"];
|
|
649
|
+
if (opts.env) args.push("--env", opts.env);
|
|
650
|
+
return args;
|
|
651
|
+
}
|
|
652
|
+
function parseCiPipeline(stdout) {
|
|
653
|
+
const parsed = JSON.parse(stdout);
|
|
654
|
+
const doc = parseYAML(parsed.yaml);
|
|
655
|
+
const jobs = parsed.jobs.map((j) => {
|
|
656
|
+
const props = doc[j.jobName];
|
|
657
|
+
const script = Array.isArray(props?.script) ? props.script.map(String) : [];
|
|
658
|
+
return { ...j, script };
|
|
659
|
+
});
|
|
660
|
+
return { stages: parsed.stages, jobs };
|
|
661
|
+
}
|
|
662
|
+
function ciPipeline(projectDir, opts = {}, forge = "gitlab") {
|
|
663
|
+
const args = ciPipelineArgs(opts, forge);
|
|
664
|
+
return runChantRaw(args, projectDir, envOverridesFor(opts)).then(({ code, stdout, stderr }) => {
|
|
665
|
+
if (code !== 0) throw new ChantCliError(args, code, stderr);
|
|
666
|
+
return parseCiPipeline(stdout);
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
function lifecyclePlanArgs(env) {
|
|
670
|
+
return ["lifecycle", "plan", env, "--live", "--json"];
|
|
671
|
+
}
|
|
672
|
+
function lifecyclePlan(projectDir, env, opts = {}) {
|
|
673
|
+
return runChantJson(lifecyclePlanArgs(env), projectDir, envOverridesFor(opts));
|
|
674
|
+
}
|
|
675
|
+
function lifecycleDiffArgs(env) {
|
|
676
|
+
return ["lifecycle", "diff", env, "--live", "--json"];
|
|
677
|
+
}
|
|
678
|
+
function lifecycleDiffLive(projectDir, env, opts = {}) {
|
|
679
|
+
return runChantJson(lifecycleDiffArgs(env), projectDir, envOverridesFor(opts));
|
|
680
|
+
}
|
|
681
|
+
function applyArgs(target, env) {
|
|
682
|
+
return ["run", target, "--components", "--env", env, "--progress-json"];
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// src/cluster-root.ts
|
|
686
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
687
|
+
import { promisify as promisify2 } from "node:util";
|
|
688
|
+
var run2 = promisify2(execFile2);
|
|
689
|
+
async function runningK3dClusters(exec = defaultExec2) {
|
|
690
|
+
try {
|
|
691
|
+
const out = await exec("k3d", ["cluster", "list", "--no-headers"]);
|
|
692
|
+
const clusters = /* @__PURE__ */ new Map();
|
|
693
|
+
for (const line of out.split(/\r?\n/)) {
|
|
694
|
+
const cols = line.trim().split(/\s+/);
|
|
695
|
+
if (!cols[0]) continue;
|
|
696
|
+
const servers = cols[1] ?? "";
|
|
697
|
+
const m = /^(\d+)\/(\d+)$/.exec(servers);
|
|
698
|
+
clusters.set(cols[0], m ? Number(m[1]) > 0 : true);
|
|
699
|
+
}
|
|
700
|
+
return clusters;
|
|
701
|
+
} catch {
|
|
702
|
+
return void 0;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
async function defaultExec2(cmd, args) {
|
|
706
|
+
const { stdout } = await run2(cmd, args, { encoding: "utf8", timeout: 1e4 });
|
|
707
|
+
return stdout;
|
|
708
|
+
}
|
|
709
|
+
function k3dClusterName(node) {
|
|
710
|
+
const meta = node.attrs?.metadata;
|
|
711
|
+
const name = meta?.name;
|
|
712
|
+
return typeof name === "string" && name.length > 0 ? name : node.id;
|
|
713
|
+
}
|
|
714
|
+
function mergeClusterRoot(ir, clusterIr, running) {
|
|
715
|
+
if (!clusterIr) return ir;
|
|
716
|
+
const have = new Set(ir.nodes.map((n) => n.id));
|
|
717
|
+
for (const node of clusterIr.nodes) {
|
|
718
|
+
if (have.has(node.id)) continue;
|
|
719
|
+
if (node.kind === "K3d::Cluster" && running !== void 0) {
|
|
720
|
+
const attrs = node.attrs ??= {};
|
|
721
|
+
const up = running.get(k3dClusterName(node));
|
|
722
|
+
attrs._status = up ? "good" : "accent";
|
|
723
|
+
}
|
|
724
|
+
ir.nodes.push(node);
|
|
725
|
+
have.add(node.id);
|
|
726
|
+
}
|
|
727
|
+
const haveEdges = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
728
|
+
for (const edge of clusterIr.edges) {
|
|
729
|
+
const key = `${edge.from}\0${edge.to}`;
|
|
730
|
+
if (haveEdges.has(key)) continue;
|
|
731
|
+
haveEdges.add(key);
|
|
732
|
+
ir.edges.push(edge);
|
|
733
|
+
}
|
|
734
|
+
return ir;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// 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";
|
|
740
|
+
var RELEASE_KEY = /^release\/([^/]+)\/(.+)$/;
|
|
741
|
+
function declaredChartNames(ir) {
|
|
742
|
+
const names = /* @__PURE__ */ new Set();
|
|
743
|
+
for (const n of ir.nodes) {
|
|
744
|
+
if (n.lexicon !== "helm" || n.kind !== "Helm::Chart") continue;
|
|
745
|
+
const name = n.attrs?.name;
|
|
746
|
+
if (typeof name === "string" && name.length > 0) names.add(name);
|
|
747
|
+
}
|
|
748
|
+
return names;
|
|
749
|
+
}
|
|
750
|
+
function matchesDeclaredChart(chart, declared) {
|
|
751
|
+
if (typeof chart !== "string" || chart.length === 0) return false;
|
|
752
|
+
for (const name of declared) {
|
|
753
|
+
if (chart === name || chart.startsWith(`${name}-`)) return true;
|
|
754
|
+
}
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
function discoverReleaseUnits(projectDir) {
|
|
758
|
+
const owners = /* @__PURE__ */ new Map();
|
|
759
|
+
const roots = [join3(projectDir, "src"), join3(projectDir, "ops")];
|
|
760
|
+
const files = [];
|
|
761
|
+
const walk = (dir, depth) => {
|
|
762
|
+
if (depth > 4 || !existsSync3(dir)) return;
|
|
763
|
+
for (const f of readdirSync(dir)) {
|
|
764
|
+
const p = join3(dir, f);
|
|
765
|
+
let s;
|
|
766
|
+
try {
|
|
767
|
+
s = statSync(p);
|
|
768
|
+
} catch {
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (s.isDirectory()) walk(p, depth + 1);
|
|
772
|
+
else if (f.endsWith(".component.ts")) files.push(p);
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
for (const root of roots) walk(root, 0);
|
|
776
|
+
for (const file of files) {
|
|
777
|
+
let content;
|
|
778
|
+
try {
|
|
779
|
+
content = readFileSync2(file, "utf8");
|
|
780
|
+
} catch {
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
const component = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
784
|
+
if (!component) continue;
|
|
785
|
+
for (const m of content.matchAll(/helmUpgrade\(\s*\{[^}]*?\brelease:\s*["'`]([^"'`]+)["'`]/gs)) {
|
|
786
|
+
if (!owners.has(m[1])) owners.set(m[1], component);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return owners;
|
|
790
|
+
}
|
|
791
|
+
function synthesizeHelmReleases(ir, observed, owners) {
|
|
792
|
+
if (!observed) return 0;
|
|
793
|
+
const declared = declaredChartNames(ir);
|
|
794
|
+
const have = new Set(ir.nodes.map((n) => n.id));
|
|
795
|
+
let added = 0;
|
|
796
|
+
for (const [key, o] of Object.entries(observed)) {
|
|
797
|
+
const m = RELEASE_KEY.exec(key);
|
|
798
|
+
if (!m || have.has(key)) continue;
|
|
799
|
+
const [, namespace, name] = m;
|
|
800
|
+
if (matchesDeclaredChart(o.attributes?.chart, declared)) continue;
|
|
801
|
+
const component = owners.get(name);
|
|
802
|
+
const deployed = o.status === "deployed";
|
|
803
|
+
const node = {
|
|
804
|
+
id: key,
|
|
805
|
+
kind: "Helm::Release",
|
|
806
|
+
lexicon: "helm",
|
|
807
|
+
attrs: {
|
|
808
|
+
name,
|
|
809
|
+
namespace,
|
|
810
|
+
...typeof o.attributes?.chart === "string" ? { chart: o.attributes.chart } : {},
|
|
811
|
+
...typeof o.attributes?.revision === "string" ? { revision: o.attributes.revision } : {},
|
|
812
|
+
...component ? { component } : {},
|
|
813
|
+
_status: deployed && component ? "good" : "warn",
|
|
814
|
+
_artifact: {
|
|
815
|
+
release: `${namespace}/${name}`,
|
|
816
|
+
...o.status ? { status: o.status } : {},
|
|
817
|
+
...typeof o.attributes?.revision === "string" ? { revision: o.attributes.revision } : {},
|
|
818
|
+
...typeof o.attributes?.chart === "string" ? { chart: o.attributes.chart } : {}
|
|
819
|
+
},
|
|
820
|
+
...component ? {} : { _foreignRelease: true }
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
ir.nodes.push(node);
|
|
824
|
+
have.add(key);
|
|
825
|
+
added++;
|
|
826
|
+
}
|
|
827
|
+
return added;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// src/gh-run.ts
|
|
831
|
+
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";
|
|
834
|
+
|
|
835
|
+
// src/ci-run.ts
|
|
836
|
+
function pipelineProgress(pipeline) {
|
|
837
|
+
const stageIndex = new Map(pipeline.stages.map((s, i) => [s, i + 1]));
|
|
838
|
+
const components = pipeline.jobs.map((j) => ({
|
|
839
|
+
component: j.jobName,
|
|
840
|
+
wave: stageIndex.get(j.stage) ?? 0,
|
|
841
|
+
status: "pending",
|
|
842
|
+
phase: j.component
|
|
843
|
+
}));
|
|
844
|
+
const waves = pipeline.stages.map((stage, i) => ({
|
|
845
|
+
wave: i + 1,
|
|
846
|
+
components: pipeline.jobs.filter((j) => j.stage === stage).map((j) => j.jobName),
|
|
847
|
+
status: "pending"
|
|
848
|
+
}));
|
|
849
|
+
return { kind: "pipeline", status: "running", waves, components };
|
|
850
|
+
}
|
|
851
|
+
function withJobStatus(state, job, status) {
|
|
852
|
+
const components = state.components.map((c) => {
|
|
853
|
+
if (c.component !== job) return c;
|
|
854
|
+
if ((c.status === "ok" || c.status === "failed") && status === "running") return c;
|
|
855
|
+
return { ...c, status };
|
|
856
|
+
});
|
|
857
|
+
const waves = state.waves.map((w) => {
|
|
858
|
+
const members = components.filter((c) => w.components.includes(c.component));
|
|
859
|
+
const status2 = members.some((c) => c.status === "failed") ? "failed" : members.every((c) => c.status === "ok") ? "ok" : members.some((c) => c.status !== "pending") ? "running" : "pending";
|
|
860
|
+
return { ...w, status: status2 };
|
|
861
|
+
});
|
|
862
|
+
return { ...state, components, waves };
|
|
863
|
+
}
|
|
864
|
+
function foldPipelineLine(state, line) {
|
|
865
|
+
let out = state;
|
|
866
|
+
for (const c of state.components) {
|
|
867
|
+
const job = c.component;
|
|
868
|
+
const escaped = job.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
869
|
+
const mentioned = new RegExp(`(^|[^\\w-])${escaped}($|[^\\w-])`).test(line);
|
|
870
|
+
if (!mentioned) continue;
|
|
871
|
+
if (/\b(FAIL(?:ED)?|ERROR)\b|✘|✗/i.test(line)) out = withJobStatus(out, job, "failed");
|
|
872
|
+
else if (/\b(PASS(?:ED)?|OK|SUCCESS(?:FUL)?)\b|✔|✓/i.test(line)) out = withJobStatus(out, job, "ok");
|
|
873
|
+
else if (c.status === "pending") out = withJobStatus(out, job, "running");
|
|
874
|
+
}
|
|
875
|
+
return out;
|
|
876
|
+
}
|
|
877
|
+
function finishPipelineProgress(state, exitCode) {
|
|
878
|
+
const terminal = exitCode === 0 ? "ok" : "failed";
|
|
879
|
+
let out = state;
|
|
880
|
+
for (const c of state.components) {
|
|
881
|
+
if (c.status === "pending" || c.status === "running") out = withJobStatus(out, c.component, terminal);
|
|
882
|
+
}
|
|
883
|
+
return { ...out, status: exitCode === 0 ? "ok" : "failed" };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// src/gh-run.ts
|
|
887
|
+
var defaultGhExec = (args) => new Promise((resolve3) => {
|
|
888
|
+
let out = "";
|
|
889
|
+
let proc;
|
|
890
|
+
try {
|
|
891
|
+
proc = spawn2("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
892
|
+
} catch {
|
|
893
|
+
resolve3({ code: 127, out: "" });
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
proc.stdout.on("data", (d) => out += d);
|
|
897
|
+
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 }));
|
|
900
|
+
});
|
|
901
|
+
async function ghReady(exec = defaultGhExec) {
|
|
902
|
+
const { code, out } = await exec(["auth", "status"]);
|
|
903
|
+
if (code === 127) return { ok: false, reason: "gh not installed \u2014 the trigger runs through YOUR gh login" };
|
|
904
|
+
if (code !== 0) return { ok: false, reason: `gh not authenticated \u2014 run \`gh auth login\` (${out.trim().split("\n")[0] ?? ""})` };
|
|
905
|
+
return { ok: true };
|
|
906
|
+
}
|
|
907
|
+
function parseWorkflow(file, text) {
|
|
908
|
+
let doc;
|
|
909
|
+
try {
|
|
910
|
+
doc = parseYAML(text);
|
|
911
|
+
} catch {
|
|
912
|
+
return { file, jobIds: [], dispatchable: false };
|
|
913
|
+
}
|
|
914
|
+
const d = doc ?? {};
|
|
915
|
+
const jobs = d.jobs && typeof d.jobs === "object" && !Array.isArray(d.jobs) ? Object.keys(d.jobs) : [];
|
|
916
|
+
const on = d.on ?? d[true];
|
|
917
|
+
const dispatchable = on === "workflow_dispatch" || Array.isArray(on) && on.includes("workflow_dispatch") || !!on && typeof on === "object" && "workflow_dispatch" in on || /^\s{2,}workflow_dispatch\s*:?\s*$/m.test(text);
|
|
918
|
+
return { file, jobIds: jobs, dispatchable };
|
|
919
|
+
}
|
|
920
|
+
function pickWorkflow(projectDir, pipeline) {
|
|
921
|
+
const dir = join4(projectDir, ".github", "workflows");
|
|
922
|
+
let files;
|
|
923
|
+
try {
|
|
924
|
+
files = readdirSync2(dir).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
|
|
925
|
+
} catch {
|
|
926
|
+
return void 0;
|
|
927
|
+
}
|
|
928
|
+
const wanted = new Set(pipeline.jobs.map((j) => j.jobName));
|
|
929
|
+
let best;
|
|
930
|
+
for (const f of files) {
|
|
931
|
+
let text;
|
|
932
|
+
try {
|
|
933
|
+
text = readFileSync3(join4(dir, f), "utf8");
|
|
934
|
+
} catch {
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
const info = parseWorkflow(f, text);
|
|
938
|
+
if (!info.dispatchable) continue;
|
|
939
|
+
const overlap = info.jobIds.filter((id) => wanted.has(id)).length;
|
|
940
|
+
if (overlap > 0 && (!best || overlap > best.overlap)) best = { info, overlap };
|
|
941
|
+
}
|
|
942
|
+
return best?.info;
|
|
943
|
+
}
|
|
944
|
+
async function latestRunId(workflowFile, exec = defaultGhExec) {
|
|
945
|
+
const { code, out } = await exec(["run", "list", "--workflow", workflowFile, "--limit", "1", "--json", "databaseId"]);
|
|
946
|
+
if (code !== 0) return void 0;
|
|
947
|
+
try {
|
|
948
|
+
const rows = JSON.parse(out);
|
|
949
|
+
return typeof rows[0]?.databaseId === "number" ? rows[0].databaseId : void 0;
|
|
950
|
+
} catch {
|
|
951
|
+
return void 0;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
function ghJobStatus(job) {
|
|
955
|
+
if (job.status !== "completed") return job.status === "queued" || job.status === void 0 ? "pending" : "running";
|
|
956
|
+
return job.conclusion === "success" ? "ok" : "failed";
|
|
957
|
+
}
|
|
958
|
+
function runViewToProgress(pipeline, view) {
|
|
959
|
+
let state = pipelineProgress(pipeline);
|
|
960
|
+
const byName = new Map((view.jobs ?? []).map((j) => [j.name, j]));
|
|
961
|
+
state = {
|
|
962
|
+
...state,
|
|
963
|
+
components: state.components.map((c) => {
|
|
964
|
+
const job = byName.get(c.component);
|
|
965
|
+
return job ? { ...c, status: ghJobStatus(job) } : c;
|
|
966
|
+
})
|
|
967
|
+
};
|
|
968
|
+
state = {
|
|
969
|
+
...state,
|
|
970
|
+
waves: state.waves.map((w) => {
|
|
971
|
+
const members = state.components.filter((c) => w.components.includes(c.component));
|
|
972
|
+
const status = members.some((c) => c.status === "failed") ? "failed" : members.every((c) => c.status === "ok") ? "ok" : members.some((c) => c.status !== "pending") ? "running" : "pending";
|
|
973
|
+
return { ...w, status };
|
|
974
|
+
})
|
|
975
|
+
};
|
|
976
|
+
if (view.status === "completed") {
|
|
977
|
+
return { ...state, status: view.conclusion === "success" ? "ok" : "failed" };
|
|
978
|
+
}
|
|
979
|
+
return state;
|
|
980
|
+
}
|
|
981
|
+
async function dispatchAndFollow(workflow, pipeline, ref, deps) {
|
|
982
|
+
const exec = deps.exec ?? defaultGhExec;
|
|
983
|
+
const pollMs = deps.pollMs ?? 5e3;
|
|
984
|
+
const appearTimeoutMs = deps.appearTimeoutMs ?? 6e4;
|
|
985
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
986
|
+
const watermark = await latestRunId(workflow.file, exec);
|
|
987
|
+
deps.onLine(`\u25B6 gh workflow run ${workflow.file} --ref ${ref}`);
|
|
988
|
+
const dispatched = await exec(["workflow", "run", workflow.file, "--ref", ref]);
|
|
989
|
+
if (dispatched.code !== 0) {
|
|
990
|
+
deps.onLine(`\u2717 dispatch failed: ${dispatched.out.trim().split("\n")[0] ?? `exit ${dispatched.code}`}`);
|
|
991
|
+
return dispatched.code || 1;
|
|
992
|
+
}
|
|
993
|
+
deps.onProgress(pipelineProgress(pipeline));
|
|
994
|
+
let runId;
|
|
995
|
+
const deadline = Date.now() + appearTimeoutMs;
|
|
996
|
+
while (Date.now() < deadline) {
|
|
997
|
+
await sleep(Math.min(pollMs, 3e3));
|
|
998
|
+
const id = await latestRunId(workflow.file, exec);
|
|
999
|
+
if (id !== void 0 && id !== watermark) {
|
|
1000
|
+
runId = id;
|
|
1001
|
+
break;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
if (runId === void 0) {
|
|
1005
|
+
deps.onLine("\u2717 dispatched, but the run never appeared in `gh run list` \u2014 check the Actions tab");
|
|
1006
|
+
return 1;
|
|
1007
|
+
}
|
|
1008
|
+
deps.onLine(`\u25CF run ${runId} started \u2014 following`);
|
|
1009
|
+
for (; ; ) {
|
|
1010
|
+
const { code, out } = await exec(["run", "view", String(runId), "--json", "status,conclusion,jobs"]);
|
|
1011
|
+
if (code === 0) {
|
|
1012
|
+
let view;
|
|
1013
|
+
try {
|
|
1014
|
+
view = JSON.parse(out);
|
|
1015
|
+
} catch {
|
|
1016
|
+
view = void 0;
|
|
1017
|
+
}
|
|
1018
|
+
if (view) {
|
|
1019
|
+
deps.onProgress(runViewToProgress(pipeline, view));
|
|
1020
|
+
if (view.status === "completed") {
|
|
1021
|
+
deps.onLine(`\u25A0 run ${runId} ${view.conclusion ?? "completed"}`);
|
|
1022
|
+
return view.conclusion === "success" ? 0 : 1;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
} else {
|
|
1026
|
+
deps.onLine(`\u26A0 gh run view ${runId} failed (exit ${code}) \u2014 retrying`);
|
|
1027
|
+
}
|
|
1028
|
+
await sleep(pollMs);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function joinCiProgress(ir, state) {
|
|
1032
|
+
if (!state || state.kind !== "pipeline" || !state.components?.length) return ir;
|
|
1033
|
+
const byComponent = /* @__PURE__ */ new Map();
|
|
1034
|
+
for (const entry of state.components) {
|
|
1035
|
+
if (!entry.phase) continue;
|
|
1036
|
+
const current = byComponent.get(entry.phase);
|
|
1037
|
+
if (!current || entry.status === "failed" || entry.status === "running" && current.status === "ok") {
|
|
1038
|
+
byComponent.set(entry.phase, { job: entry.component, status: entry.status });
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
for (const n of ir.nodes) {
|
|
1042
|
+
if (n.kind !== "Component") continue;
|
|
1043
|
+
const run4 = byComponent.get(n.id);
|
|
1044
|
+
if (!run4) continue;
|
|
1045
|
+
const attrs = n.attrs ??= {};
|
|
1046
|
+
attrs.ci = run4.status;
|
|
1047
|
+
attrs._ciJob = run4.job;
|
|
1048
|
+
}
|
|
1049
|
+
return ir;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// src/component-status.ts
|
|
1053
|
+
function componentStatusColor(row) {
|
|
1054
|
+
if (row.stack && row.stack.healthy === false) {
|
|
1055
|
+
if (row.stack.status && /ROLLBACK|FAILED/i.test(row.stack.status)) return "warn";
|
|
1056
|
+
return "accent";
|
|
1057
|
+
}
|
|
1058
|
+
const rollup = row.resources;
|
|
1059
|
+
if (rollup && rollup.total > 0) {
|
|
1060
|
+
if (rollup.unobserved > 0) return "accent";
|
|
1061
|
+
if (rollup.present === rollup.total) return "good";
|
|
1062
|
+
if (rollup.present === 0) return "neutral";
|
|
1063
|
+
return "warn";
|
|
1064
|
+
}
|
|
1065
|
+
if (row.live !== void 0) return row.live ? "good" : "neutral";
|
|
1066
|
+
switch (row.reconciliation) {
|
|
1067
|
+
case "reconciled":
|
|
1068
|
+
return "good";
|
|
1069
|
+
case "unrecorded":
|
|
1070
|
+
return row.detail.startsWith("live") ? "good" : "neutral";
|
|
1071
|
+
case "stale":
|
|
1072
|
+
case "drifted":
|
|
1073
|
+
return "warn";
|
|
1074
|
+
case "unknown":
|
|
1075
|
+
default:
|
|
1076
|
+
return "neutral";
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
function joinComponentStatus(ir, rows) {
|
|
1080
|
+
const byComponent = new Map(rows.map((r) => [r.component, r]));
|
|
1081
|
+
return {
|
|
1082
|
+
...ir,
|
|
1083
|
+
nodes: ir.nodes.map((n) => {
|
|
1084
|
+
const row = byComponent.get(n.id);
|
|
1085
|
+
if (!row) return n;
|
|
1086
|
+
return {
|
|
1087
|
+
...n,
|
|
1088
|
+
attrs: {
|
|
1089
|
+
...n.attrs,
|
|
1090
|
+
_status: componentStatusColor(row),
|
|
1091
|
+
_liveStatus: {
|
|
1092
|
+
reconciliation: row.reconciliation,
|
|
1093
|
+
detail: row.detail,
|
|
1094
|
+
...row.live !== void 0 ? { live: row.live } : {},
|
|
1095
|
+
...row.stack ? { stack: row.stack } : {},
|
|
1096
|
+
...row.resources ? { resources: row.resources } : {}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
})
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// src/overlay.ts
|
|
1105
|
+
function pruneImports(ir) {
|
|
1106
|
+
const drop = new Set((ir.imports ?? []).map((i) => i.node));
|
|
1107
|
+
if (drop.size === 0) return ir;
|
|
1108
|
+
ir.nodes = ir.nodes.filter((n) => !drop.has(n.id));
|
|
1109
|
+
if (ir.edges) ir.edges = ir.edges.filter((e) => !drop.has(e.from) && !drop.has(e.to));
|
|
1110
|
+
return ir;
|
|
1111
|
+
}
|
|
1112
|
+
function componentDir(node) {
|
|
1113
|
+
const parts = node.sourceLoc?.file?.split("/") ?? [];
|
|
1114
|
+
if (parts[0] !== "src" || parts[1] === "examples" || parts.length < 3) return void 0;
|
|
1115
|
+
return parts[1];
|
|
1116
|
+
}
|
|
1117
|
+
function reclassifyOverlay(ir) {
|
|
1118
|
+
const deployedComponents = /* @__PURE__ */ new Set();
|
|
1119
|
+
for (const n of ir.nodes) {
|
|
1120
|
+
if (n.attrs?._status === "good") {
|
|
1121
|
+
const c = componentDir(n);
|
|
1122
|
+
if (c) deployedComponents.add(c);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
for (const n of ir.nodes) {
|
|
1126
|
+
const file = n.sourceLoc?.file ?? "";
|
|
1127
|
+
if (file.startsWith("src/examples/")) {
|
|
1128
|
+
n.attrs = { ...n.attrs, _byo: true };
|
|
1129
|
+
delete n.attrs._status;
|
|
1130
|
+
} else if (n.kind === "AWS::CloudFormation::Parameter") {
|
|
1131
|
+
const c = componentDir(n);
|
|
1132
|
+
if (c && deployedComponents.has(c)) {
|
|
1133
|
+
n.attrs = { ...n.attrs, _status: "good" };
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return ir;
|
|
1138
|
+
}
|
|
1139
|
+
function pruneRuntimeChildren(ir) {
|
|
1140
|
+
const runtime = new Set(ir.nodes.filter((n) => n.runtimeOwner).map((n) => n.id));
|
|
1141
|
+
if (runtime.size === 0) return ir;
|
|
1142
|
+
ir.nodes = ir.nodes.filter((n) => !runtime.has(n.id));
|
|
1143
|
+
ir.edges = ir.edges.filter((e) => !runtime.has(e.from) && !runtime.has(e.to));
|
|
1144
|
+
return ir;
|
|
1145
|
+
}
|
|
1146
|
+
function attachRuntimeContainment(ir) {
|
|
1147
|
+
const byOwner = /* @__PURE__ */ new Map();
|
|
1148
|
+
for (const n of ir.nodes) {
|
|
1149
|
+
if (!n.runtimeOwner) continue;
|
|
1150
|
+
if (!byOwner.has(n.runtimeOwner)) byOwner.set(n.runtimeOwner, /* @__PURE__ */ new Set());
|
|
1151
|
+
byOwner.get(n.runtimeOwner).add(n.id);
|
|
1152
|
+
}
|
|
1153
|
+
if (byOwner.size === 0) return ir;
|
|
1154
|
+
const byContainer = { ...ir.groups.byContainer ?? {} };
|
|
1155
|
+
for (const [owner, children] of byOwner) {
|
|
1156
|
+
byContainer[owner] = [.../* @__PURE__ */ new Set([owner, ...byContainer[owner] ?? [], ...children])].sort();
|
|
1157
|
+
}
|
|
1158
|
+
ir.groups.byContainer = byContainer;
|
|
1159
|
+
return ir;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// src/value-match.ts
|
|
1163
|
+
function typeSegment(kind) {
|
|
1164
|
+
const parts = kind.split(kind.includes("::") ? "::" : "/");
|
|
1165
|
+
return parts[parts.length - 1] ?? kind;
|
|
1166
|
+
}
|
|
1167
|
+
function isOwnNameAttr(kind, key) {
|
|
1168
|
+
if (!/name$/i.test(key)) return false;
|
|
1169
|
+
const prefix = key.slice(0, -4).toLowerCase();
|
|
1170
|
+
if (prefix === "") return true;
|
|
1171
|
+
return typeSegment(kind).toLowerCase().includes(prefix);
|
|
1172
|
+
}
|
|
1173
|
+
var MIN_NAME_LEN = 6;
|
|
1174
|
+
function addValueMatchEdges(ir) {
|
|
1175
|
+
const nodes = ir.nodes;
|
|
1176
|
+
const owner = /* @__PURE__ */ new Map();
|
|
1177
|
+
for (const n of nodes) {
|
|
1178
|
+
for (const [k, v] of Object.entries(n.attrs ?? {})) {
|
|
1179
|
+
if (typeof v !== "string" || v.length < MIN_NAME_LEN) continue;
|
|
1180
|
+
if (!isOwnNameAttr(n.kind, k)) continue;
|
|
1181
|
+
owner.set(v, owner.has(v) ? null : n.id);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (owner.size === 0) return ir;
|
|
1185
|
+
const declared = /* @__PURE__ */ new Set();
|
|
1186
|
+
for (const e of ir.edges) declared.add(`${e.from}\0${e.to}`);
|
|
1187
|
+
const added = /* @__PURE__ */ new Set();
|
|
1188
|
+
for (const a of nodes) {
|
|
1189
|
+
for (const [k, v] of Object.entries(a.attrs ?? {})) {
|
|
1190
|
+
if (typeof v !== "string" || v.length < MIN_NAME_LEN) continue;
|
|
1191
|
+
const b = owner.get(v);
|
|
1192
|
+
if (!b || b === a.id) continue;
|
|
1193
|
+
const key = `${a.id}\0${b}`;
|
|
1194
|
+
if (declared.has(key) || added.has(key)) continue;
|
|
1195
|
+
added.add(key);
|
|
1196
|
+
ir.edges.push({ from: a.id, to: b, kind: "ref", viaAttr: k, inferred: true });
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return ir;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// src/k8s-edges.ts
|
|
1203
|
+
var WORKLOAD_KINDS = /* @__PURE__ */ new Set([
|
|
1204
|
+
"K8s::Apps::Deployment",
|
|
1205
|
+
"K8s::Apps::StatefulSet",
|
|
1206
|
+
"K8s::Apps::DaemonSet",
|
|
1207
|
+
"K8s::Apps::ReplicaSet"
|
|
1208
|
+
]);
|
|
1209
|
+
function rec(v) {
|
|
1210
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
1211
|
+
}
|
|
1212
|
+
function str2(v) {
|
|
1213
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
1214
|
+
}
|
|
1215
|
+
function namespaceOf(n) {
|
|
1216
|
+
return str2(rec(n.attrs?.metadata)?.namespace) ?? "default";
|
|
1217
|
+
}
|
|
1218
|
+
function nameOf(n) {
|
|
1219
|
+
return str2(rec(n.attrs?.metadata)?.name);
|
|
1220
|
+
}
|
|
1221
|
+
function labelRecord(v) {
|
|
1222
|
+
const r = rec(v);
|
|
1223
|
+
if (!r) return void 0;
|
|
1224
|
+
const out = {};
|
|
1225
|
+
for (const [k, val] of Object.entries(r)) {
|
|
1226
|
+
if (typeof val !== "string") return void 0;
|
|
1227
|
+
out[k] = val;
|
|
1228
|
+
}
|
|
1229
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
1230
|
+
}
|
|
1231
|
+
function selects(selector, labels) {
|
|
1232
|
+
return Object.entries(selector).every(([k, v]) => labels[k] === v);
|
|
1233
|
+
}
|
|
1234
|
+
function templateLabels(n) {
|
|
1235
|
+
const template = rec(rec(n.attrs?.spec)?.template);
|
|
1236
|
+
return labelRecord(rec(template?.metadata)?.labels);
|
|
1237
|
+
}
|
|
1238
|
+
function shortKind(kind) {
|
|
1239
|
+
const parts = kind.split("::");
|
|
1240
|
+
return parts[parts.length - 1] ?? kind;
|
|
1241
|
+
}
|
|
1242
|
+
var FLUX_SOURCE_KINDS = /* @__PURE__ */ new Set([
|
|
1243
|
+
"K8s::Flux::GitRepository",
|
|
1244
|
+
"K8s::Flux::OCIRepository",
|
|
1245
|
+
"K8s::Flux::HelmRepository",
|
|
1246
|
+
"K8s::Flux::HelmChart",
|
|
1247
|
+
"K8s::Flux::Bucket"
|
|
1248
|
+
]);
|
|
1249
|
+
function sourceRefOf(refObj, referrer) {
|
|
1250
|
+
const ref = rec(refObj);
|
|
1251
|
+
const kind = str2(ref?.kind);
|
|
1252
|
+
const name = str2(ref?.name);
|
|
1253
|
+
if (!kind || !name) return void 0;
|
|
1254
|
+
return { kind, name, namespace: str2(ref?.namespace) ?? namespaceOf(referrer) };
|
|
1255
|
+
}
|
|
1256
|
+
function helmReleaseSourceRef(n) {
|
|
1257
|
+
return rec(rec(rec(n.attrs?.spec)?.chart)?.spec)?.sourceRef;
|
|
1258
|
+
}
|
|
1259
|
+
function ingressBackendServices(n) {
|
|
1260
|
+
const spec = rec(n.attrs?.spec);
|
|
1261
|
+
const out = [];
|
|
1262
|
+
const backendName = (b) => str2(rec(rec(b)?.service)?.name);
|
|
1263
|
+
const fromDefault = backendName(spec?.defaultBackend);
|
|
1264
|
+
if (fromDefault) out.push(fromDefault);
|
|
1265
|
+
const rules = Array.isArray(spec?.rules) ? spec.rules : [];
|
|
1266
|
+
for (const rule of rules) {
|
|
1267
|
+
const paths = rec(rec(rule)?.http)?.paths;
|
|
1268
|
+
for (const p of Array.isArray(paths) ? paths : []) {
|
|
1269
|
+
const name = backendName(rec(p)?.backend);
|
|
1270
|
+
if (name) out.push(name);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return out;
|
|
1274
|
+
}
|
|
1275
|
+
function deriveK8sEdges(nodes) {
|
|
1276
|
+
const k8s = nodes.filter((n) => n.lexicon === "k8s");
|
|
1277
|
+
if (k8s.length === 0) return [];
|
|
1278
|
+
const out = [];
|
|
1279
|
+
const add = (from, to, viaAttr) => {
|
|
1280
|
+
if (from !== to) out.push({ from, to, kind: "ref", viaAttr, inferred: true });
|
|
1281
|
+
};
|
|
1282
|
+
const workloads = k8s.filter((n) => WORKLOAD_KINDS.has(n.kind));
|
|
1283
|
+
const servicesByNsName = /* @__PURE__ */ new Map();
|
|
1284
|
+
const sourcesByKindNsName = /* @__PURE__ */ new Map();
|
|
1285
|
+
for (const n of k8s) {
|
|
1286
|
+
const name = nameOf(n);
|
|
1287
|
+
if (!name) continue;
|
|
1288
|
+
if (n.kind === "K8s::Core::Service") servicesByNsName.set(`${namespaceOf(n)}/${name}`, n);
|
|
1289
|
+
if (FLUX_SOURCE_KINDS.has(n.kind)) sourcesByKindNsName.set(`${shortKind(n.kind)}/${namespaceOf(n)}/${name}`, n);
|
|
1290
|
+
}
|
|
1291
|
+
const addSourceRef = (n, refObj, viaAttr) => {
|
|
1292
|
+
const ref = sourceRefOf(refObj, n);
|
|
1293
|
+
if (!ref) return;
|
|
1294
|
+
const source = sourcesByKindNsName.get(`${ref.kind}/${ref.namespace}/${ref.name}`);
|
|
1295
|
+
if (source) add(n.id, source.id, viaAttr);
|
|
1296
|
+
};
|
|
1297
|
+
for (const n of k8s) {
|
|
1298
|
+
if (n.kind === "K8s::Core::Service") {
|
|
1299
|
+
const selector = labelRecord(rec(n.attrs?.spec)?.selector);
|
|
1300
|
+
if (!selector) continue;
|
|
1301
|
+
for (const w of workloads) {
|
|
1302
|
+
if (namespaceOf(w) !== namespaceOf(n)) continue;
|
|
1303
|
+
const labels = templateLabels(w);
|
|
1304
|
+
if (labels && selects(selector, labels)) add(n.id, w.id, "selector");
|
|
1305
|
+
}
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
if (n.kind === "K8s::Networking::Ingress") {
|
|
1309
|
+
for (const svcName of ingressBackendServices(n)) {
|
|
1310
|
+
const svc = servicesByNsName.get(`${namespaceOf(n)}/${svcName}`);
|
|
1311
|
+
if (svc) add(n.id, svc.id, "ingress backend");
|
|
1312
|
+
}
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
if (n.kind === "K8s::Autoscaling::HorizontalPodAutoscaler") {
|
|
1316
|
+
const ref = rec(rec(n.attrs?.spec)?.scaleTargetRef);
|
|
1317
|
+
const refKind = str2(ref?.kind);
|
|
1318
|
+
const refName = str2(ref?.name);
|
|
1319
|
+
if (!refKind || !refName) continue;
|
|
1320
|
+
for (const w of workloads) {
|
|
1321
|
+
if (namespaceOf(w) !== namespaceOf(n)) continue;
|
|
1322
|
+
if (shortKind(w.kind) === refKind && nameOf(w) === refName) add(n.id, w.id, "scaleTargetRef");
|
|
1323
|
+
}
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
if (n.kind === "K8s::Flux::Kustomization") {
|
|
1327
|
+
addSourceRef(n, rec(n.attrs?.spec)?.sourceRef, "sourceRef");
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
if (n.kind === "K8s::Flux::HelmRelease") {
|
|
1331
|
+
addSourceRef(n, helmReleaseSourceRef(n), "chart sourceRef");
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
return out;
|
|
1335
|
+
}
|
|
1336
|
+
function addK8sDeclaredEdges(ir) {
|
|
1337
|
+
const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
1338
|
+
for (const e of deriveK8sEdges(ir.nodes)) {
|
|
1339
|
+
const key = `${e.from}\0${e.to}`;
|
|
1340
|
+
if (existing.has(key)) continue;
|
|
1341
|
+
existing.add(key);
|
|
1342
|
+
ir.edges.push(e);
|
|
1343
|
+
}
|
|
1344
|
+
return ir;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// src/helm-artifacts.ts
|
|
1348
|
+
function chartNameOf(node) {
|
|
1349
|
+
const name = node.attrs?.name;
|
|
1350
|
+
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
1351
|
+
}
|
|
1352
|
+
function releaseMatchesChart(observed, chartName) {
|
|
1353
|
+
const chart = observed.attributes?.chart;
|
|
1354
|
+
if (typeof chart !== "string") return false;
|
|
1355
|
+
return chart === chartName || chart.startsWith(`${chartName}-`);
|
|
1356
|
+
}
|
|
1357
|
+
function applyHelmArtifacts(ir, observed) {
|
|
1358
|
+
let charts = 0;
|
|
1359
|
+
let installed = 0;
|
|
1360
|
+
for (const node of ir.nodes) {
|
|
1361
|
+
if (node.lexicon !== "helm" || node.kind !== "Helm::Chart") continue;
|
|
1362
|
+
charts++;
|
|
1363
|
+
const attrs = node.attrs ??= {};
|
|
1364
|
+
if (observed === void 0) {
|
|
1365
|
+
attrs._status = "neutral";
|
|
1366
|
+
attrs._unobserved = "artifact-observation-unavailable";
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
const chartName = chartNameOf(node);
|
|
1370
|
+
const entry = chartName ? Object.entries(observed).find(([key2, o2]) => key2.startsWith("release/") && releaseMatchesChart(o2, chartName)) : void 0;
|
|
1371
|
+
if (!entry) {
|
|
1372
|
+
attrs._status = "accent";
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
const [key, o] = entry;
|
|
1376
|
+
const match = {
|
|
1377
|
+
release: key.replace(/^release\//, ""),
|
|
1378
|
+
...o.status ? { status: o.status } : {},
|
|
1379
|
+
...typeof o.attributes?.revision === "string" ? { revision: o.attributes.revision } : {},
|
|
1380
|
+
...typeof o.attributes?.chart === "string" ? { chart: o.attributes.chart } : {}
|
|
1381
|
+
};
|
|
1382
|
+
attrs._artifact = match;
|
|
1383
|
+
attrs._status = o.status === "deployed" ? "good" : "warn";
|
|
1384
|
+
if (o.status === "deployed") installed++;
|
|
1385
|
+
}
|
|
1386
|
+
return { charts, installed };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// src/cluster-anchor.ts
|
|
1390
|
+
var MANAGED_CLUSTER_KINDS = /* @__PURE__ */ new Set([
|
|
1391
|
+
"AWS::EKS::Cluster",
|
|
1392
|
+
"GCP::Container::Cluster",
|
|
1393
|
+
"Microsoft.ContainerService/managedClusters",
|
|
1394
|
+
"K3d::Cluster"
|
|
1395
|
+
]);
|
|
1396
|
+
var ANCHOR_VIA = "runs-on";
|
|
1397
|
+
function metadata(node) {
|
|
1398
|
+
const m = node.attrs?.metadata;
|
|
1399
|
+
return m && typeof m === "object" && !Array.isArray(m) ? m : void 0;
|
|
1400
|
+
}
|
|
1401
|
+
function metaString(node, key) {
|
|
1402
|
+
const v = metadata(node)?.[key];
|
|
1403
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
1404
|
+
}
|
|
1405
|
+
function isK8s(node) {
|
|
1406
|
+
return node.lexicon === "k8s";
|
|
1407
|
+
}
|
|
1408
|
+
function isNamespace(node) {
|
|
1409
|
+
return node.kind === "K8s::Core::Namespace";
|
|
1410
|
+
}
|
|
1411
|
+
function managedClusterName(node) {
|
|
1412
|
+
const metaName = metaString(node, "name");
|
|
1413
|
+
if (metaName) return metaName;
|
|
1414
|
+
for (const key of ["name", "clusterName"]) {
|
|
1415
|
+
const v = node.attrs?.[key];
|
|
1416
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
1417
|
+
}
|
|
1418
|
+
return node.id;
|
|
1419
|
+
}
|
|
1420
|
+
function boundManagedCluster(nodes, boundContext) {
|
|
1421
|
+
const clusters = nodes.filter((n) => MANAGED_CLUSTER_KINDS.has(n.kind));
|
|
1422
|
+
if (clusters.length === 1) return clusters[0];
|
|
1423
|
+
if (clusters.length === 0 || !boundContext) return void 0;
|
|
1424
|
+
const bound = clusters.filter((n) => contextBindsCluster(boundContext, managedClusterName(n)));
|
|
1425
|
+
return bound.length === 1 ? bound[0] : void 0;
|
|
1426
|
+
}
|
|
1427
|
+
function addClusterAnchorEdges(ir, boundContext) {
|
|
1428
|
+
const nodes = ir.nodes;
|
|
1429
|
+
const cluster = boundManagedCluster(ir.nodes, boundContext);
|
|
1430
|
+
if (!cluster) return ir;
|
|
1431
|
+
const k8sNodes = nodes.filter(isK8s);
|
|
1432
|
+
if (k8sNodes.length === 0) return ir;
|
|
1433
|
+
const namespaceByName = /* @__PURE__ */ new Map();
|
|
1434
|
+
for (const n of k8sNodes) {
|
|
1435
|
+
if (!isNamespace(n)) continue;
|
|
1436
|
+
const name = metaString(n, "name");
|
|
1437
|
+
if (name && !namespaceByName.has(name)) namespaceByName.set(name, n.id);
|
|
1438
|
+
}
|
|
1439
|
+
const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
1440
|
+
const add = (from, to) => {
|
|
1441
|
+
if (from === to) return;
|
|
1442
|
+
const key = `${from}\0${to}`;
|
|
1443
|
+
if (existing.has(key)) return;
|
|
1444
|
+
existing.add(key);
|
|
1445
|
+
ir.edges.push({ from, to, kind: "ref", viaAttr: ANCHOR_VIA, inferred: true });
|
|
1446
|
+
};
|
|
1447
|
+
for (const n of k8sNodes) {
|
|
1448
|
+
if (isNamespace(n)) {
|
|
1449
|
+
add(cluster.id, n.id);
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
const ns = metaString(n, "namespace");
|
|
1453
|
+
const nsNode = ns ? namespaceByName.get(ns) : void 0;
|
|
1454
|
+
add(nsNode ?? cluster.id, n.id);
|
|
1455
|
+
}
|
|
1456
|
+
return ir;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// src/logical-azure.ts
|
|
1460
|
+
var AZURE_HEADLINE_KINDS = /* @__PURE__ */ new Set([
|
|
1461
|
+
// compute / workloads
|
|
1462
|
+
"Microsoft.Compute/virtualMachines",
|
|
1463
|
+
"Microsoft.Compute/virtualMachineScaleSets",
|
|
1464
|
+
"Microsoft.ContainerService/managedClusters",
|
|
1465
|
+
"Microsoft.ContainerInstance/containerGroups",
|
|
1466
|
+
"Microsoft.Web/sites",
|
|
1467
|
+
// network edge
|
|
1468
|
+
"Microsoft.Network/publicIPAddresses",
|
|
1469
|
+
"Microsoft.Network/loadBalancers",
|
|
1470
|
+
"Microsoft.Network/applicationGateways",
|
|
1471
|
+
"Microsoft.Network/natGateways",
|
|
1472
|
+
"Microsoft.Network/dnsZones",
|
|
1473
|
+
// data stores
|
|
1474
|
+
"Microsoft.Storage/storageAccounts",
|
|
1475
|
+
"Microsoft.Sql/servers",
|
|
1476
|
+
"Microsoft.DocumentDB/databaseAccounts",
|
|
1477
|
+
"Microsoft.Cache/redis",
|
|
1478
|
+
"Microsoft.DBforPostgreSQL/flexibleServers",
|
|
1479
|
+
// registry / identity edge
|
|
1480
|
+
"Microsoft.ContainerRegistry/registries"
|
|
1481
|
+
]);
|
|
1482
|
+
var AZURE_FABRIC_KINDS = /* @__PURE__ */ new Set([
|
|
1483
|
+
"Microsoft.Network/virtualNetworks",
|
|
1484
|
+
"Microsoft.Network/virtualNetworks_subnets",
|
|
1485
|
+
"Microsoft.Network/networkSecurityGroups",
|
|
1486
|
+
"Microsoft.Network/routeTables",
|
|
1487
|
+
"Microsoft.Network/networkInterfaces",
|
|
1488
|
+
// identity/authorization hubs — a role assignment or managed identity is
|
|
1489
|
+
// referenced by half the estate, so contracting through one invents edges
|
|
1490
|
+
// between unrelated resources.
|
|
1491
|
+
"Microsoft.Authorization/roleAssignments",
|
|
1492
|
+
"Microsoft.ManagedIdentity/userAssignedIdentities",
|
|
1493
|
+
"Microsoft.KeyVault/vaults"
|
|
1494
|
+
]);
|
|
1495
|
+
var VNET_KIND = "Microsoft.Network/virtualNetworks";
|
|
1496
|
+
var SUBNET_KIND = "Microsoft.Network/virtualNetworks_subnets";
|
|
1497
|
+
var NSG_KIND = "Microsoft.Network/networkSecurityGroups";
|
|
1498
|
+
var RESOURCE_ID_RE = /resourceId\(\s*([^)]*)\)/g;
|
|
1499
|
+
var QUOTED_RE = /'([^']*)'/g;
|
|
1500
|
+
function resourceIdNames(value) {
|
|
1501
|
+
if (typeof value !== "string" || !value.includes("resourceId(")) return [];
|
|
1502
|
+
const out = [];
|
|
1503
|
+
for (const call of value.matchAll(RESOURCE_ID_RE)) {
|
|
1504
|
+
const args = [...call[1].matchAll(QUOTED_RE)].map((m) => m[1]);
|
|
1505
|
+
if (args.length >= 2) out.push(args[args.length - 1]);
|
|
1506
|
+
}
|
|
1507
|
+
return out;
|
|
1508
|
+
}
|
|
1509
|
+
function collectAzureRefs(value, out) {
|
|
1510
|
+
if (value === null || typeof value !== "object") {
|
|
1511
|
+
for (const name of resourceIdNames(value)) out.add(name);
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
const ref = value.$ref;
|
|
1515
|
+
if (typeof ref === "string") out.add(ref.split(".")[0]);
|
|
1516
|
+
for (const v of Object.values(value)) collectAzureRefs(v, out);
|
|
1517
|
+
}
|
|
1518
|
+
function strAttr(node, key) {
|
|
1519
|
+
const v = node.attrs?.[key];
|
|
1520
|
+
return typeof v === "string" ? v : void 0;
|
|
1521
|
+
}
|
|
1522
|
+
function resourceName(node) {
|
|
1523
|
+
return strAttr(node, "name");
|
|
1524
|
+
}
|
|
1525
|
+
function subnetVnetName(node) {
|
|
1526
|
+
const name = resourceName(node);
|
|
1527
|
+
if (!name || !name.includes("/")) return void 0;
|
|
1528
|
+
return name.split("/")[0];
|
|
1529
|
+
}
|
|
1530
|
+
function subnetShortName(node) {
|
|
1531
|
+
const name = resourceName(node);
|
|
1532
|
+
return name?.includes("/") ? name.split("/").slice(1).join("/") : name;
|
|
1533
|
+
}
|
|
1534
|
+
function vnetCidr(node) {
|
|
1535
|
+
const space = node.attrs?.addressSpace;
|
|
1536
|
+
const prefixes = space?.addressPrefixes;
|
|
1537
|
+
return Array.isArray(prefixes) && typeof prefixes[0] === "string" ? prefixes[0] : void 0;
|
|
1538
|
+
}
|
|
1539
|
+
function projectAzureLogical(ir, env) {
|
|
1540
|
+
const azure = ir.nodes.filter((n) => n.lexicon === "azure");
|
|
1541
|
+
if (azure.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
|
|
1542
|
+
const componentOf = componentNamer(azure);
|
|
1543
|
+
const byId = new Map(azure.map((n) => [n.id, n]));
|
|
1544
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1545
|
+
for (const n of azure) {
|
|
1546
|
+
const name = resourceName(n);
|
|
1547
|
+
if (name && !byName.has(name)) byName.set(name, n);
|
|
1548
|
+
}
|
|
1549
|
+
const vnets = azure.filter((n) => n.kind === VNET_KIND);
|
|
1550
|
+
const subnets = azure.filter((n) => n.kind === SUBNET_KIND);
|
|
1551
|
+
const vnetByName = new Map(vnets.map((v) => [resourceName(v) ?? v.id, v]));
|
|
1552
|
+
const refOut = /* @__PURE__ */ new Map();
|
|
1553
|
+
for (const n of azure) {
|
|
1554
|
+
const names = /* @__PURE__ */ new Set();
|
|
1555
|
+
collectAzureRefs(n.attrs, names);
|
|
1556
|
+
const targets = /* @__PURE__ */ new Set();
|
|
1557
|
+
for (const name of names) {
|
|
1558
|
+
const target = byName.get(name) ?? byId.get(name);
|
|
1559
|
+
if (target && target.id !== n.id) targets.add(target.id);
|
|
1560
|
+
}
|
|
1561
|
+
if (targets.size) refOut.set(n.id, targets);
|
|
1562
|
+
}
|
|
1563
|
+
for (const e of ir.edges) {
|
|
1564
|
+
if (!byId.has(e.from) || !byId.has(e.to)) continue;
|
|
1565
|
+
(refOut.get(e.from) ?? refOut.set(e.from, /* @__PURE__ */ new Set()).get(e.from)).add(e.to);
|
|
1566
|
+
}
|
|
1567
|
+
const RG_TITLE = env ? `resource group ${env}` : "resource group";
|
|
1568
|
+
const vnetTitle = (v) => `VNet ${vnetCidr(v) ?? resourceName(v) ?? v.id}`;
|
|
1569
|
+
const subnetTitle = (s) => `subnet ${strAttr(s, "addressPrefix") ?? subnetShortName(s) ?? s.id}`;
|
|
1570
|
+
const guardedBy = /* @__PURE__ */ new Map();
|
|
1571
|
+
for (const s of subnets) {
|
|
1572
|
+
const names = /* @__PURE__ */ new Set();
|
|
1573
|
+
collectAzureRefs(s.attrs?.networkSecurityGroup, names);
|
|
1574
|
+
for (const name of names) {
|
|
1575
|
+
if (byName.get(name)?.kind === NSG_KIND) guardedBy.set(s.id, name);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
const headline = azure.filter((n) => AZURE_HEADLINE_KINDS.has(n.kind));
|
|
1579
|
+
const subnetIds = new Set(subnets.map((s) => s.id));
|
|
1580
|
+
const vnetIds = new Set(vnets.map((v) => v.id));
|
|
1581
|
+
const nearest = (start, want, maxDepth = 6) => {
|
|
1582
|
+
const seen = /* @__PURE__ */ new Set([start]);
|
|
1583
|
+
let frontier = [...refOut.get(start) ?? []];
|
|
1584
|
+
for (let depth = 0; depth < maxDepth && frontier.length; depth++) {
|
|
1585
|
+
const next = [];
|
|
1586
|
+
for (const id of frontier) {
|
|
1587
|
+
if (seen.has(id)) continue;
|
|
1588
|
+
seen.add(id);
|
|
1589
|
+
if (want.has(id)) return id;
|
|
1590
|
+
for (const r of refOut.get(id) ?? []) if (!seen.has(r)) next.push(r);
|
|
1591
|
+
}
|
|
1592
|
+
frontier = next;
|
|
1593
|
+
}
|
|
1594
|
+
return void 0;
|
|
1595
|
+
};
|
|
1596
|
+
const byContainer = {};
|
|
1597
|
+
const child = (parent, c) => {
|
|
1598
|
+
const arr = byContainer[parent] ?? (byContainer[parent] = []);
|
|
1599
|
+
if (!arr.includes(c)) arr.push(c);
|
|
1600
|
+
};
|
|
1601
|
+
for (const v of vnets) child(RG_TITLE, vnetTitle(v));
|
|
1602
|
+
for (const s of subnets) {
|
|
1603
|
+
const parentVnet = subnetVnetName(s);
|
|
1604
|
+
const v = parentVnet ? vnetByName.get(parentVnet) : void 0;
|
|
1605
|
+
child(v ? vnetTitle(v) : RG_TITLE, subnetTitle(s));
|
|
1606
|
+
}
|
|
1607
|
+
const placeOf = /* @__PURE__ */ new Map();
|
|
1608
|
+
for (const n of headline) {
|
|
1609
|
+
const subnetId = nearest(n.id, subnetIds);
|
|
1610
|
+
if (subnetId) {
|
|
1611
|
+
placeOf.set(n.id, subnetTitle(byId.get(subnetId)));
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
const vnetId = nearest(n.id, vnetIds);
|
|
1615
|
+
placeOf.set(n.id, vnetId ? vnetTitle(byId.get(vnetId)) : RG_TITLE);
|
|
1616
|
+
}
|
|
1617
|
+
const byComponent = /* @__PURE__ */ new Map();
|
|
1618
|
+
for (const n of headline) {
|
|
1619
|
+
const c = componentOf(n);
|
|
1620
|
+
(byComponent.get(c) ?? byComponent.set(c, []).get(c)).push(n);
|
|
1621
|
+
}
|
|
1622
|
+
for (const [component, members] of byComponent) {
|
|
1623
|
+
const places = new Set(members.map((m) => placeOf.get(m.id)));
|
|
1624
|
+
child(places.size === 1 ? [...places][0] : RG_TITLE, component);
|
|
1625
|
+
for (const m of members) child(component, m.id);
|
|
1626
|
+
}
|
|
1627
|
+
const kept = new Set(headline.map((n) => n.id));
|
|
1628
|
+
const adj = /* @__PURE__ */ new Map();
|
|
1629
|
+
const link = (a, b) => {
|
|
1630
|
+
if (a === b) return;
|
|
1631
|
+
(adj.get(a) ?? adj.set(a, /* @__PURE__ */ new Set()).get(a)).add(b);
|
|
1632
|
+
(adj.get(b) ?? adj.set(b, /* @__PURE__ */ new Set()).get(b)).add(a);
|
|
1633
|
+
};
|
|
1634
|
+
for (const [from, tos] of refOut) for (const to of tos) link(from, to);
|
|
1635
|
+
const edges = [];
|
|
1636
|
+
const seenEdge = /* @__PURE__ */ new Set();
|
|
1637
|
+
const addEdge = (a, b) => {
|
|
1638
|
+
if (a === b || !kept.has(a) || !kept.has(b)) return;
|
|
1639
|
+
const key = a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
1640
|
+
if (seenEdge.has(key)) return;
|
|
1641
|
+
seenEdge.add(key);
|
|
1642
|
+
edges.push({ from: a, to: b, kind: "ref" });
|
|
1643
|
+
};
|
|
1644
|
+
const contractable = (id) => !kept.has(id) && !AZURE_FABRIC_KINDS.has(byId.get(id)?.kind ?? "");
|
|
1645
|
+
for (const start of kept) {
|
|
1646
|
+
const seen = /* @__PURE__ */ new Set([start]);
|
|
1647
|
+
let frontier = [...adj.get(start) ?? []];
|
|
1648
|
+
for (let depth = 0; depth < 6 && frontier.length; depth++) {
|
|
1649
|
+
const next = [];
|
|
1650
|
+
for (const id of frontier) {
|
|
1651
|
+
if (seen.has(id)) continue;
|
|
1652
|
+
seen.add(id);
|
|
1653
|
+
if (kept.has(id)) {
|
|
1654
|
+
addEdge(start, id);
|
|
1655
|
+
continue;
|
|
1656
|
+
}
|
|
1657
|
+
if (!contractable(id)) continue;
|
|
1658
|
+
for (const nb of adj.get(id) ?? []) if (!seen.has(nb)) next.push(nb);
|
|
1659
|
+
}
|
|
1660
|
+
frontier = next;
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
const nodes = headline.map((n) => {
|
|
1664
|
+
const place = placeOf.get(n.id);
|
|
1665
|
+
const guardedSubnet = subnets.find((s) => subnetTitle(s) === place);
|
|
1666
|
+
const nsg = guardedSubnet ? guardedBy.get(guardedSubnet.id) : void 0;
|
|
1667
|
+
return nsg ? { ...n, attrs: { ...n.attrs, _nsg: nsg } } : n;
|
|
1668
|
+
});
|
|
1669
|
+
return { ir: { nodes, edges, groups: {} }, byContainer };
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// src/logical-gcp.ts
|
|
1673
|
+
var GCP_HEADLINE_KINDS = /* @__PURE__ */ new Set([
|
|
1674
|
+
"GCP::Container::Cluster",
|
|
1675
|
+
"GCP::Run::Service",
|
|
1676
|
+
"GCP::Sql::Instance",
|
|
1677
|
+
"GCP::Storage::Bucket",
|
|
1678
|
+
"GCP::Compute::Instance",
|
|
1679
|
+
"GCP::Dns::ManagedZone"
|
|
1680
|
+
]);
|
|
1681
|
+
var GCP_FABRIC_KINDS = /* @__PURE__ */ new Set([
|
|
1682
|
+
// networking — declared, not emulated
|
|
1683
|
+
"GCP::Compute::Network",
|
|
1684
|
+
"GCP::Compute::Subnetwork",
|
|
1685
|
+
"GCP::Compute::Firewall",
|
|
1686
|
+
"GCP::Compute::Router",
|
|
1687
|
+
"GCP::Compute::RouterNAT",
|
|
1688
|
+
// identity hubs
|
|
1689
|
+
"GCP::Iam::ServiceAccount",
|
|
1690
|
+
"GCP::Iam::PolicyMember",
|
|
1691
|
+
"GCP::Iam::CustomRole",
|
|
1692
|
+
// capacity, not a workload host
|
|
1693
|
+
"GCP::Container::NodePool",
|
|
1694
|
+
// chant's own annotation carrier
|
|
1695
|
+
"chant:gcp:defaultAnnotations"
|
|
1696
|
+
]);
|
|
1697
|
+
var PROJECT_ID_ANNOTATION = "cnrm.cloud.google.com/project-id";
|
|
1698
|
+
function strAttr2(node, key) {
|
|
1699
|
+
const v = node.attrs?.[key];
|
|
1700
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
1701
|
+
}
|
|
1702
|
+
function declaredProject(nodes) {
|
|
1703
|
+
for (const n of nodes) {
|
|
1704
|
+
const annotations = n.attrs?.annotations ?? n.attrs?.metadata?.annotations;
|
|
1705
|
+
const value = annotations?.[PROJECT_ID_ANNOTATION];
|
|
1706
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
1707
|
+
}
|
|
1708
|
+
return void 0;
|
|
1709
|
+
}
|
|
1710
|
+
function collectRefs(v, out) {
|
|
1711
|
+
if (!v || typeof v !== "object") return;
|
|
1712
|
+
const ref = v.$ref;
|
|
1713
|
+
if (typeof ref === "string") out.add(ref.split(".")[0]);
|
|
1714
|
+
for (const key of Object.keys(v)) collectRefs(v[key], out);
|
|
1715
|
+
}
|
|
1716
|
+
function projectGcpLogical(ir, env) {
|
|
1717
|
+
const gcp = ir.nodes.filter((n) => n.lexicon === "gcp");
|
|
1718
|
+
if (gcp.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
|
|
1719
|
+
const componentOf = componentNamer(gcp);
|
|
1720
|
+
const byId = new Map(gcp.map((n) => [n.id, n]));
|
|
1721
|
+
const project = declaredProject(gcp) ?? env;
|
|
1722
|
+
const PROJECT_TITLE = project ? `project ${project}` : "project";
|
|
1723
|
+
const GLOBAL = "global";
|
|
1724
|
+
const locationTitle = (loc) => `location ${loc}`;
|
|
1725
|
+
const refOut = /* @__PURE__ */ new Map();
|
|
1726
|
+
for (const n of gcp) {
|
|
1727
|
+
const refs = /* @__PURE__ */ new Set();
|
|
1728
|
+
collectRefs(n.attrs, refs);
|
|
1729
|
+
const targets = /* @__PURE__ */ new Set();
|
|
1730
|
+
for (const r of refs) if (byId.has(r) && r !== n.id) targets.add(r);
|
|
1731
|
+
if (targets.size) refOut.set(n.id, targets);
|
|
1732
|
+
}
|
|
1733
|
+
for (const e of ir.edges) {
|
|
1734
|
+
if (!byId.has(e.from) || !byId.has(e.to)) continue;
|
|
1735
|
+
(refOut.get(e.from) ?? refOut.set(e.from, /* @__PURE__ */ new Set()).get(e.from)).add(e.to);
|
|
1736
|
+
}
|
|
1737
|
+
const headline = gcp.filter((n) => GCP_HEADLINE_KINDS.has(n.kind));
|
|
1738
|
+
const byContainer = {};
|
|
1739
|
+
const child = (parent, c) => {
|
|
1740
|
+
const arr = byContainer[parent] ?? (byContainer[parent] = []);
|
|
1741
|
+
if (!arr.includes(c)) arr.push(c);
|
|
1742
|
+
};
|
|
1743
|
+
const locations = /* @__PURE__ */ new Set();
|
|
1744
|
+
for (const n of gcp) {
|
|
1745
|
+
const loc = strAttr2(n, "location");
|
|
1746
|
+
if (loc) locations.add(loc);
|
|
1747
|
+
}
|
|
1748
|
+
for (const loc of [...locations].sort()) child(PROJECT_TITLE, locationTitle(loc));
|
|
1749
|
+
const placeOf = /* @__PURE__ */ new Map();
|
|
1750
|
+
for (const n of headline) {
|
|
1751
|
+
const loc = strAttr2(n, "location");
|
|
1752
|
+
placeOf.set(n.id, loc ? locationTitle(loc) : GLOBAL);
|
|
1753
|
+
}
|
|
1754
|
+
const byComponent = /* @__PURE__ */ new Map();
|
|
1755
|
+
for (const n of headline) {
|
|
1756
|
+
const c = componentOf(n);
|
|
1757
|
+
(byComponent.get(c) ?? byComponent.set(c, []).get(c)).push(n);
|
|
1758
|
+
}
|
|
1759
|
+
for (const [component, members] of byComponent) {
|
|
1760
|
+
const places = new Set(members.map((m) => placeOf.get(m.id)));
|
|
1761
|
+
const parent = places.size === 1 ? [...places][0] : PROJECT_TITLE;
|
|
1762
|
+
if (parent === GLOBAL) child(PROJECT_TITLE, GLOBAL);
|
|
1763
|
+
child(parent, component);
|
|
1764
|
+
for (const m of members) child(component, m.id);
|
|
1765
|
+
}
|
|
1766
|
+
const kept = new Set(headline.map((n) => n.id));
|
|
1767
|
+
const adj = /* @__PURE__ */ new Map();
|
|
1768
|
+
const link = (a, b) => {
|
|
1769
|
+
if (a === b) return;
|
|
1770
|
+
(adj.get(a) ?? adj.set(a, /* @__PURE__ */ new Set()).get(a)).add(b);
|
|
1771
|
+
(adj.get(b) ?? adj.set(b, /* @__PURE__ */ new Set()).get(b)).add(a);
|
|
1772
|
+
};
|
|
1773
|
+
for (const [from, tos] of refOut) for (const to of tos) link(from, to);
|
|
1774
|
+
const edges = [];
|
|
1775
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1776
|
+
const addEdge = (a, b) => {
|
|
1777
|
+
if (a === b || !kept.has(a) || !kept.has(b)) return;
|
|
1778
|
+
const key = a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
1779
|
+
if (seen.has(key)) return;
|
|
1780
|
+
seen.add(key);
|
|
1781
|
+
edges.push({ from: a, to: b, kind: "ref" });
|
|
1782
|
+
};
|
|
1783
|
+
const contractable = (id) => !kept.has(id) && !GCP_FABRIC_KINDS.has(byId.get(id)?.kind ?? "");
|
|
1784
|
+
for (const start of kept) {
|
|
1785
|
+
const walked = /* @__PURE__ */ new Set([start]);
|
|
1786
|
+
let frontier = [...adj.get(start) ?? []];
|
|
1787
|
+
for (let depth = 0; depth < 6 && frontier.length; depth++) {
|
|
1788
|
+
const next = [];
|
|
1789
|
+
for (const id of frontier) {
|
|
1790
|
+
if (walked.has(id)) continue;
|
|
1791
|
+
walked.add(id);
|
|
1792
|
+
if (kept.has(id)) {
|
|
1793
|
+
addEdge(start, id);
|
|
1794
|
+
continue;
|
|
1795
|
+
}
|
|
1796
|
+
if (!contractable(id)) continue;
|
|
1797
|
+
for (const nb of adj.get(id) ?? []) if (!walked.has(nb)) next.push(nb);
|
|
1798
|
+
}
|
|
1799
|
+
frontier = next;
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
return { ir: { nodes: headline, edges, groups: {} }, byContainer };
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// src/logical-k8s.ts
|
|
1806
|
+
var K8S_PLUMBING_KINDS = /* @__PURE__ */ new Set([
|
|
1807
|
+
"K8s::Core::Namespace",
|
|
1808
|
+
"K8s::Core::ConfigMap",
|
|
1809
|
+
"K8s::Core::Secret",
|
|
1810
|
+
"K8s::Core::ServiceAccount",
|
|
1811
|
+
"K8s::Core::ResourceQuota",
|
|
1812
|
+
"K8s::Core::LimitRange",
|
|
1813
|
+
"K8s::Rbac::Role",
|
|
1814
|
+
"K8s::Rbac::RoleBinding",
|
|
1815
|
+
"K8s::Rbac::ClusterRole",
|
|
1816
|
+
"K8s::Rbac::ClusterRoleBinding"
|
|
1817
|
+
]);
|
|
1818
|
+
function metaStr(node, key) {
|
|
1819
|
+
const meta = node.attrs?.metadata;
|
|
1820
|
+
const v = meta?.[key];
|
|
1821
|
+
return typeof v === "string" ? v : void 0;
|
|
1822
|
+
}
|
|
1823
|
+
function declaredNamespaces(nodes) {
|
|
1824
|
+
const out = /* @__PURE__ */ new Set();
|
|
1825
|
+
for (const n of nodes) {
|
|
1826
|
+
if (n.kind === "K8s::Core::Namespace") {
|
|
1827
|
+
const own = metaStr(n, "name");
|
|
1828
|
+
if (own) out.add(own);
|
|
1829
|
+
continue;
|
|
1830
|
+
}
|
|
1831
|
+
const ns = metaStr(n, "namespace");
|
|
1832
|
+
if (ns) out.add(ns);
|
|
1833
|
+
}
|
|
1834
|
+
return out;
|
|
1835
|
+
}
|
|
1836
|
+
function projectK8sLogical(ir, env, boundContext) {
|
|
1837
|
+
const k8s = ir.nodes.filter((n) => n.lexicon === "k8s");
|
|
1838
|
+
if (k8s.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
|
|
1839
|
+
const cluster = boundManagedCluster(ir.nodes, boundContext);
|
|
1840
|
+
const CLUSTER_TITLE = cluster ? cluster.id : env ? `cluster ${env}` : "cluster";
|
|
1841
|
+
const CLUSTER_SCOPED = "cluster-scoped";
|
|
1842
|
+
const namespaceTitle = (ns) => `namespace ${ns}`;
|
|
1843
|
+
const byContainer = {};
|
|
1844
|
+
const child = (parent, c) => {
|
|
1845
|
+
const arr = byContainer[parent] ?? (byContainer[parent] = []);
|
|
1846
|
+
if (!arr.includes(c)) arr.push(c);
|
|
1847
|
+
};
|
|
1848
|
+
for (const ns of [...declaredNamespaces(k8s)].sort()) child(CLUSTER_TITLE, namespaceTitle(ns));
|
|
1849
|
+
const headline = k8s.filter((n) => !K8S_PLUMBING_KINDS.has(n.kind));
|
|
1850
|
+
let clusterScoped = false;
|
|
1851
|
+
for (const n of headline) {
|
|
1852
|
+
const ns = metaStr(n, "namespace");
|
|
1853
|
+
if (ns) {
|
|
1854
|
+
child(namespaceTitle(ns), n.id);
|
|
1855
|
+
} else {
|
|
1856
|
+
clusterScoped = true;
|
|
1857
|
+
child(CLUSTER_SCOPED, n.id);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
if (clusterScoped) child(CLUSTER_TITLE, CLUSTER_SCOPED);
|
|
1861
|
+
const kept = new Set(headline.map((n) => n.id));
|
|
1862
|
+
const edges = ir.edges.filter((e) => kept.has(e.from) && kept.has(e.to));
|
|
1863
|
+
return { ir: { nodes: headline, edges, groups: {} }, byContainer };
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// src/logical-helm.ts
|
|
1867
|
+
function releaseBoxTitle(chartName) {
|
|
1868
|
+
return `release ${chartName}`;
|
|
1869
|
+
}
|
|
1870
|
+
function chartNameOf2(node) {
|
|
1871
|
+
const name = node.attrs?.name;
|
|
1872
|
+
return typeof name === "string" && name.length > 0 ? name : node.id;
|
|
1873
|
+
}
|
|
1874
|
+
function dirOf(node) {
|
|
1875
|
+
const file = node.sourceLoc?.file;
|
|
1876
|
+
if (typeof file !== "string") return "";
|
|
1877
|
+
const slash = file.lastIndexOf("/");
|
|
1878
|
+
return slash >= 0 ? file.slice(0, slash) : "";
|
|
1879
|
+
}
|
|
1880
|
+
function projectHelmLogical(ir) {
|
|
1881
|
+
const charts = ir.nodes.filter((n) => n.lexicon === "helm" && n.kind === "Helm::Chart");
|
|
1882
|
+
const releases = ir.nodes.filter((n) => n.lexicon === "helm" && n.kind === "Helm::Release");
|
|
1883
|
+
if (charts.length === 0 && releases.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
|
|
1884
|
+
const byContainer = {};
|
|
1885
|
+
const child = (parent, c) => {
|
|
1886
|
+
const arr = byContainer[parent] ?? (byContainer[parent] = []);
|
|
1887
|
+
if (!arr.includes(c)) arr.push(c);
|
|
1888
|
+
};
|
|
1889
|
+
const k8s = ir.nodes.filter((n) => n.lexicon === "k8s");
|
|
1890
|
+
for (const chart of charts) {
|
|
1891
|
+
const box = releaseBoxTitle(chartNameOf2(chart));
|
|
1892
|
+
child(box, chart.id);
|
|
1893
|
+
const chartDir = dirOf(chart);
|
|
1894
|
+
if (chartDir === "") continue;
|
|
1895
|
+
for (const n of k8s) {
|
|
1896
|
+
const d = dirOf(n);
|
|
1897
|
+
if (d === chartDir || d.startsWith(`${chartDir}/`)) child(box, n.id);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
const cards = [...charts, ...releases];
|
|
1901
|
+
const kept = new Set(cards.map((n) => n.id));
|
|
1902
|
+
const edges = ir.edges.filter((e) => kept.has(e.from) && kept.has(e.to));
|
|
1903
|
+
return { ir: { nodes: cards, edges, groups: {} }, byContainer };
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// src/logical-kustomize.ts
|
|
1907
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
1908
|
+
import { join as join5 } from "node:path";
|
|
1909
|
+
function overlayBoxTitle(name) {
|
|
1910
|
+
return `overlay ${name}`;
|
|
1911
|
+
}
|
|
1912
|
+
function dirOf2(node) {
|
|
1913
|
+
const file = node.sourceLoc?.file;
|
|
1914
|
+
if (typeof file !== "string") return "";
|
|
1915
|
+
const slash = file.lastIndexOf("/");
|
|
1916
|
+
return slash >= 0 ? file.slice(0, slash) : "";
|
|
1917
|
+
}
|
|
1918
|
+
function kustomizationRoot(bases, dir, exists) {
|
|
1919
|
+
let current = dir;
|
|
1920
|
+
for (let i = 0; i < 4 && current; i++) {
|
|
1921
|
+
for (const base of bases) {
|
|
1922
|
+
if (exists(join5(base, current, "kustomization.yaml")) || exists(join5(base, current, "kustomization.yml"))) {
|
|
1923
|
+
return current;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
const slash = current.lastIndexOf("/");
|
|
1927
|
+
current = slash >= 0 ? current.slice(0, slash) : "";
|
|
1928
|
+
}
|
|
1929
|
+
return void 0;
|
|
1930
|
+
}
|
|
1931
|
+
function projectKustomizeLogical(ir, sourceRoots, exists = existsSync4) {
|
|
1932
|
+
const bases = Array.isArray(sourceRoots) ? sourceRoots : [sourceRoots];
|
|
1933
|
+
const k8s = ir.nodes.filter((n) => n.lexicon === "k8s");
|
|
1934
|
+
if (k8s.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
|
|
1935
|
+
const byContainer = {};
|
|
1936
|
+
const child = (parent, c) => {
|
|
1937
|
+
const arr = byContainer[parent] ?? (byContainer[parent] = []);
|
|
1938
|
+
if (!arr.includes(c)) arr.push(c);
|
|
1939
|
+
};
|
|
1940
|
+
const rootCache = /* @__PURE__ */ new Map();
|
|
1941
|
+
for (const n of k8s) {
|
|
1942
|
+
const dir = dirOf2(n);
|
|
1943
|
+
if (dir === "") continue;
|
|
1944
|
+
if (!rootCache.has(dir)) rootCache.set(dir, kustomizationRoot(bases, dir, exists));
|
|
1945
|
+
const root = rootCache.get(dir);
|
|
1946
|
+
if (root === void 0) continue;
|
|
1947
|
+
const name = root.split("/").pop() || root;
|
|
1948
|
+
child(overlayBoxTitle(name), n.id);
|
|
1949
|
+
}
|
|
1950
|
+
return { ir: { nodes: [], edges: [], groups: {} }, byContainer };
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// src/logical-fly.ts
|
|
1954
|
+
function appBoxTitle(appName) {
|
|
1955
|
+
return `app ${appName}`;
|
|
1956
|
+
}
|
|
1957
|
+
var APP_KIND = "Fly::Machines::App";
|
|
1958
|
+
var FLY_CARD_KINDS = /* @__PURE__ */ new Set([
|
|
1959
|
+
APP_KIND,
|
|
1960
|
+
"Fly::Machines::Machine",
|
|
1961
|
+
"Fly::Machines::Volume",
|
|
1962
|
+
"Fly::Machines::IPAddress",
|
|
1963
|
+
"Fly::Machines::Certificate"
|
|
1964
|
+
]);
|
|
1965
|
+
function appNameOf(node) {
|
|
1966
|
+
const name = node.attrs?.name;
|
|
1967
|
+
return typeof name === "string" && name.length > 0 ? name : node.id;
|
|
1968
|
+
}
|
|
1969
|
+
function collectRefs2(v, out) {
|
|
1970
|
+
if (!v || typeof v !== "object") return;
|
|
1971
|
+
const ref = v.$ref;
|
|
1972
|
+
if (typeof ref === "string") out.add(ref.split(".")[0]);
|
|
1973
|
+
for (const key of Object.keys(v)) collectRefs2(v[key], out);
|
|
1974
|
+
}
|
|
1975
|
+
function projectFlyLogical(ir) {
|
|
1976
|
+
const fly = ir.nodes.filter((n) => n.lexicon === "fly");
|
|
1977
|
+
if (fly.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
|
|
1978
|
+
const cards = fly.filter((n) => FLY_CARD_KINDS.has(n.kind));
|
|
1979
|
+
const apps = cards.filter((n) => n.kind === APP_KIND);
|
|
1980
|
+
const appIds = new Set(apps.map((n) => n.id));
|
|
1981
|
+
const kept = new Set(cards.map((n) => n.id));
|
|
1982
|
+
const refOut = /* @__PURE__ */ new Map();
|
|
1983
|
+
const addRef = (from, to) => {
|
|
1984
|
+
if (from === to) return;
|
|
1985
|
+
(refOut.get(from) ?? refOut.set(from, /* @__PURE__ */ new Set()).get(from)).add(to);
|
|
1986
|
+
};
|
|
1987
|
+
for (const n of fly) {
|
|
1988
|
+
const refs = /* @__PURE__ */ new Set();
|
|
1989
|
+
collectRefs2(n.attrs, refs);
|
|
1990
|
+
for (const r of refs) addRef(n.id, r);
|
|
1991
|
+
}
|
|
1992
|
+
for (const e of ir.edges) addRef(e.from, e.to);
|
|
1993
|
+
const byContainer = {};
|
|
1994
|
+
const child = (parent, c) => {
|
|
1995
|
+
const arr = byContainer[parent] ?? (byContainer[parent] = []);
|
|
1996
|
+
if (!arr.includes(c)) arr.push(c);
|
|
1997
|
+
};
|
|
1998
|
+
const boxOf = /* @__PURE__ */ new Map();
|
|
1999
|
+
for (const app of apps) {
|
|
2000
|
+
const box = appBoxTitle(appNameOf(app));
|
|
2001
|
+
boxOf.set(app.id, box);
|
|
2002
|
+
child(box, app.id);
|
|
2003
|
+
}
|
|
2004
|
+
const soleApp = apps.length === 1 ? apps[0].id : void 0;
|
|
2005
|
+
for (const n of cards) {
|
|
2006
|
+
if (appIds.has(n.id)) continue;
|
|
2007
|
+
const referenced = [...refOut.get(n.id) ?? []].find((r) => appIds.has(r));
|
|
2008
|
+
const owner = referenced ?? soleApp;
|
|
2009
|
+
if (owner) child(boxOf.get(owner), n.id);
|
|
2010
|
+
}
|
|
2011
|
+
const edges = [];
|
|
2012
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2013
|
+
for (const [from, tos] of refOut) {
|
|
2014
|
+
if (!kept.has(from)) continue;
|
|
2015
|
+
for (const to of tos) {
|
|
2016
|
+
if (!kept.has(to) || appIds.has(from) || appIds.has(to)) continue;
|
|
2017
|
+
const key = from < to ? `${from}|${to}` : `${to}|${from}`;
|
|
2018
|
+
if (seen.has(key)) continue;
|
|
2019
|
+
seen.add(key);
|
|
2020
|
+
edges.push({ from, to, kind: "ref" });
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
return { ir: { nodes: cards, edges, groups: {} }, byContainer };
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// src/logical.ts
|
|
2027
|
+
var HEADLINE_KINDS = /* @__PURE__ */ new Set([
|
|
2028
|
+
// network edge / gateways
|
|
2029
|
+
"AWS::ElasticLoadBalancingV2::LoadBalancer",
|
|
2030
|
+
"AWS::EC2::InternetGateway",
|
|
2031
|
+
"AWS::EC2::NatGateway",
|
|
2032
|
+
"AWS::EC2::VPCEndpoint",
|
|
2033
|
+
"AWS::EC2::VPCEndpointService",
|
|
2034
|
+
"AWS::Route53::RecordSet",
|
|
2035
|
+
"AWS::CloudFront::Distribution",
|
|
2036
|
+
"AWS::ApiGatewayV2::Api",
|
|
2037
|
+
"AWS::ApiGateway::RestApi",
|
|
2038
|
+
// compute / workloads
|
|
2039
|
+
"AWS::ECS::Service",
|
|
2040
|
+
"AWS::EC2::Instance",
|
|
2041
|
+
"AWS::Lambda::Function",
|
|
2042
|
+
"AWS::EKS::Cluster",
|
|
2043
|
+
"AWS::BedrockAgentCore::Runtime",
|
|
2044
|
+
// data stores
|
|
2045
|
+
"AWS::RDS::DBInstance",
|
|
2046
|
+
"AWS::RDS::DBCluster",
|
|
2047
|
+
"AWS::ElastiCache::CacheCluster",
|
|
2048
|
+
"AWS::ElastiCache::ReplicationGroup",
|
|
2049
|
+
"AWS::DynamoDB::Table",
|
|
2050
|
+
"AWS::EFS::FileSystem",
|
|
2051
|
+
"AWS::OpenSearchService::Domain",
|
|
2052
|
+
"AWS::S3::Bucket",
|
|
2053
|
+
// identity / edge
|
|
2054
|
+
"AWS::Cognito::UserPool"
|
|
2055
|
+
]);
|
|
2056
|
+
var WORKLOAD_KINDS2 = /* @__PURE__ */ new Set([
|
|
2057
|
+
"AWS::ECS::Service",
|
|
2058
|
+
"AWS::EC2::Instance",
|
|
2059
|
+
"AWS::Lambda::Function",
|
|
2060
|
+
"AWS::EKS::Cluster",
|
|
2061
|
+
"AWS::BedrockAgentCore::Runtime"
|
|
2062
|
+
]);
|
|
2063
|
+
var DATA_STORE_KINDS = /* @__PURE__ */ new Set([
|
|
2064
|
+
"AWS::RDS::DBInstance",
|
|
2065
|
+
"AWS::RDS::DBCluster",
|
|
2066
|
+
"AWS::S3::Bucket",
|
|
2067
|
+
"AWS::DynamoDB::Table",
|
|
2068
|
+
"AWS::ElastiCache::CacheCluster",
|
|
2069
|
+
"AWS::ElastiCache::ReplicationGroup",
|
|
2070
|
+
"AWS::EFS::FileSystem",
|
|
2071
|
+
"AWS::OpenSearchService::Domain"
|
|
2072
|
+
]);
|
|
2073
|
+
var NO_CONTRACT_KINDS = /* @__PURE__ */ new Set([
|
|
2074
|
+
// property hubs
|
|
2075
|
+
"AWS::IAM::Role",
|
|
2076
|
+
"AWS::IAM::Policy",
|
|
2077
|
+
"AWS::IAM::ManagedPolicy",
|
|
2078
|
+
"AWS::IAM::InstanceProfile",
|
|
2079
|
+
"AWS::KMS::Key",
|
|
2080
|
+
"AWS::KMS::Alias",
|
|
2081
|
+
"AWS::Logs::LogGroup",
|
|
2082
|
+
"AWS::SecretsManager::Secret",
|
|
2083
|
+
"AWS::SSM::Parameter",
|
|
2084
|
+
// containment fabric
|
|
2085
|
+
"AWS::EC2::VPC",
|
|
2086
|
+
"AWS::EC2::Subnet",
|
|
2087
|
+
"AWS::EC2::RouteTable",
|
|
2088
|
+
"AWS::EC2::Route",
|
|
2089
|
+
"AWS::EC2::SubnetRouteTableAssociation",
|
|
2090
|
+
"AWS::EC2::VPCGatewayAttachment",
|
|
2091
|
+
// security groups — membership isn't traffic (two workloads sharing an SG
|
|
2092
|
+
// don't necessarily talk); the directional ingress rules ARE traffic and are
|
|
2093
|
+
// derived explicitly below, so contraction must not also bridge through them.
|
|
2094
|
+
"AWS::EC2::SecurityGroup",
|
|
2095
|
+
"AWS::EC2::SecurityGroupIngress"
|
|
2096
|
+
]);
|
|
2097
|
+
var REGION_RE = /\b([a-z]{2}-[a-z]+-\d)\b/;
|
|
2098
|
+
var WORD_RE = /[A-Za-z][A-Za-z0-9]*/g;
|
|
2099
|
+
function isExample(node) {
|
|
2100
|
+
return (node.sourceLoc?.file ?? "").includes("examples/");
|
|
2101
|
+
}
|
|
2102
|
+
function dirSegments(node) {
|
|
2103
|
+
const parts = (node.sourceLoc?.file ?? "").split("/");
|
|
2104
|
+
return parts.slice(0, -1).filter((s) => s.length > 0);
|
|
2105
|
+
}
|
|
2106
|
+
function componentNamer(nodes) {
|
|
2107
|
+
const all = nodes.map(dirSegments).filter((d) => d.length > 0);
|
|
2108
|
+
let shared = 0;
|
|
2109
|
+
if (all.length > 0) {
|
|
2110
|
+
const first = all[0];
|
|
2111
|
+
outer: for (; shared < first.length; shared++) {
|
|
2112
|
+
for (const d of all) {
|
|
2113
|
+
if (d.length <= shared || d[shared] !== first[shared]) break outer;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
return (node) => {
|
|
2118
|
+
const dirs = dirSegments(node);
|
|
2119
|
+
return dirs[shared] ?? dirs[dirs.length - 1] ?? "other";
|
|
2120
|
+
};
|
|
2121
|
+
}
|
|
2122
|
+
function collectRefs3(v, out) {
|
|
2123
|
+
if (!v || typeof v !== "object") return;
|
|
2124
|
+
const ref = v.$ref;
|
|
2125
|
+
if (typeof ref === "string") out.add(ref.split(".")[0]);
|
|
2126
|
+
for (const key of Object.keys(v)) collectRefs3(v[key], out);
|
|
2127
|
+
}
|
|
2128
|
+
function regionOf(node) {
|
|
2129
|
+
for (const v of Object.values(node.attrs ?? {})) {
|
|
2130
|
+
const m = JSON.stringify(v).match(REGION_RE);
|
|
2131
|
+
if (m) return m[1];
|
|
2132
|
+
}
|
|
2133
|
+
return void 0;
|
|
2134
|
+
}
|
|
2135
|
+
function strAttr3(node, key) {
|
|
2136
|
+
const v = node.attrs?.[key];
|
|
2137
|
+
return typeof v === "string" ? v : void 0;
|
|
2138
|
+
}
|
|
2139
|
+
function enrichedRefs(ir, aws, byId) {
|
|
2140
|
+
const refOut = /* @__PURE__ */ new Map();
|
|
2141
|
+
const add = (from, to) => {
|
|
2142
|
+
if (!byId.has(from) || !byId.has(to) || from === to) return;
|
|
2143
|
+
(refOut.get(from) ?? refOut.set(from, /* @__PURE__ */ new Set()).get(from)).add(to);
|
|
2144
|
+
};
|
|
2145
|
+
for (const n of aws) {
|
|
2146
|
+
const refs = /* @__PURE__ */ new Set();
|
|
2147
|
+
collectRefs3(n.attrs, refs);
|
|
2148
|
+
for (const r of refs) add(n.id, r);
|
|
2149
|
+
}
|
|
2150
|
+
for (const e of ir.edges) add(e.from, e.to);
|
|
2151
|
+
const producersByName = /* @__PURE__ */ new Map();
|
|
2152
|
+
for (const ex of ir.exports ?? []) {
|
|
2153
|
+
if (!ex.node || !byId.has(ex.node)) continue;
|
|
2154
|
+
const list = producersByName.get(ex.name) ?? producersByName.set(ex.name, []).get(ex.name);
|
|
2155
|
+
if (isExample(byId.get(ex.node))) list.push(ex.node);
|
|
2156
|
+
else list.unshift(ex.node);
|
|
2157
|
+
}
|
|
2158
|
+
const exportNames = [...producersByName.keys()].sort((a, b) => b.length - a.length);
|
|
2159
|
+
const bridge = (paramId, producers) => {
|
|
2160
|
+
if (producers && producers.length) add(paramId, producers[0]);
|
|
2161
|
+
};
|
|
2162
|
+
for (const imp of ir.imports ?? []) {
|
|
2163
|
+
const handle = imp.name.toLowerCase();
|
|
2164
|
+
bridge(imp.node, producersByName.get(imp.name) ?? [...producersByName].find(([k]) => k.toLowerCase() === handle || k.toLowerCase() === "o" + handle)?.[1]);
|
|
2165
|
+
}
|
|
2166
|
+
for (const n of aws) {
|
|
2167
|
+
if (n.kind !== "AWS::CloudFormation::Parameter") continue;
|
|
2168
|
+
const desc = strAttr3(n, "description");
|
|
2169
|
+
if (!desc) continue;
|
|
2170
|
+
const toks = new Set(desc.match(WORD_RE) ?? []);
|
|
2171
|
+
const hit = exportNames.find((name) => toks.has(name));
|
|
2172
|
+
if (hit) bridge(n.id, producersByName.get(hit));
|
|
2173
|
+
}
|
|
2174
|
+
return refOut;
|
|
2175
|
+
}
|
|
2176
|
+
function nearestByRef(start, want, refOut, maxDepth = 8) {
|
|
2177
|
+
const seen = /* @__PURE__ */ new Set([start]);
|
|
2178
|
+
let frontier = [...refOut.get(start) ?? []];
|
|
2179
|
+
for (let depth = 0; depth < maxDepth && frontier.length; depth++) {
|
|
2180
|
+
const next = [];
|
|
2181
|
+
for (const id of frontier) {
|
|
2182
|
+
if (seen.has(id)) continue;
|
|
2183
|
+
seen.add(id);
|
|
2184
|
+
if (want.has(id)) return id;
|
|
2185
|
+
for (const r of refOut.get(id) ?? []) if (!seen.has(r)) next.push(r);
|
|
2186
|
+
}
|
|
2187
|
+
frontier = next;
|
|
2188
|
+
}
|
|
2189
|
+
return void 0;
|
|
2190
|
+
}
|
|
2191
|
+
function projectTopology(ir, env, boundContext, sourceRoots) {
|
|
2192
|
+
const projections = [
|
|
2193
|
+
projectLogical(ir),
|
|
2194
|
+
projectAzureLogical(ir, env),
|
|
2195
|
+
projectGcpLogical(ir, env),
|
|
2196
|
+
projectK8sLogical(ir, env, boundContext),
|
|
2197
|
+
projectHelmLogical(ir),
|
|
2198
|
+
projectFlyLogical(ir),
|
|
2199
|
+
// The kustomize lens probes for kustomization roots relative to whatever
|
|
2200
|
+
// base `sourceLoc.file` was reported against — the graphed root on the
|
|
2201
|
+
// declared path, the project dir on the live overlay path (see the lens's
|
|
2202
|
+
// doc); a caller without one (tests, composition paths) just skips it.
|
|
2203
|
+
...sourceRoots && (!Array.isArray(sourceRoots) || sourceRoots.length) ? [projectKustomizeLogical(ir, sourceRoots)] : []
|
|
2204
|
+
];
|
|
2205
|
+
const nodes = [];
|
|
2206
|
+
const edges = [];
|
|
2207
|
+
const byContainer = {};
|
|
2208
|
+
for (const p of projections) {
|
|
2209
|
+
nodes.push(...p.ir.nodes);
|
|
2210
|
+
edges.push(...p.ir.edges);
|
|
2211
|
+
for (const [parent, children] of Object.entries(p.byContainer)) {
|
|
2212
|
+
const arr = byContainer[parent] ?? (byContainer[parent] = []);
|
|
2213
|
+
for (const c of children) if (!arr.includes(c)) arr.push(c);
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
placeHelmReleases(nodes, byContainer, ir, env, boundContext);
|
|
2217
|
+
nestReleaseBoxes(byContainer);
|
|
2218
|
+
return { ir: { nodes, edges, groups: {} }, byContainer };
|
|
2219
|
+
}
|
|
2220
|
+
function placeHelmReleases(nodes, byContainer, ir, env, boundContext) {
|
|
2221
|
+
const releases = nodes.filter((n) => n.kind === "Helm::Release");
|
|
2222
|
+
if (releases.length === 0) return;
|
|
2223
|
+
const child = (parent, c) => {
|
|
2224
|
+
const arr = byContainer[parent] ?? (byContainer[parent] = []);
|
|
2225
|
+
if (!arr.includes(c)) arr.push(c);
|
|
2226
|
+
};
|
|
2227
|
+
const cluster = boundManagedCluster(ir.nodes, boundContext);
|
|
2228
|
+
const clusterTitle = cluster ? cluster.id : env ? `cluster ${env}` : "cluster";
|
|
2229
|
+
const clusterBoxExists = byContainer[clusterTitle] !== void 0;
|
|
2230
|
+
for (const r of releases) {
|
|
2231
|
+
const ns = r.attrs?.namespace;
|
|
2232
|
+
if (typeof ns !== "string" || ns.length === 0) continue;
|
|
2233
|
+
const nsBox = `namespace ${ns}`;
|
|
2234
|
+
if (byContainer[nsBox] === void 0 && !clusterBoxExists) continue;
|
|
2235
|
+
if (byContainer[nsBox] === void 0) child(clusterTitle, nsBox);
|
|
2236
|
+
child(nsBox, r.id);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
function nestReleaseBoxes(byContainer) {
|
|
2240
|
+
for (const [box, members] of Object.entries(byContainer)) {
|
|
2241
|
+
if (!box.startsWith("release ") && !box.startsWith("overlay ")) continue;
|
|
2242
|
+
const claimed = members;
|
|
2243
|
+
const parents = /* @__PURE__ */ new Set();
|
|
2244
|
+
for (const [parent2, children2] of Object.entries(byContainer)) {
|
|
2245
|
+
if (parent2 === box) continue;
|
|
2246
|
+
if (claimed.some((m) => children2.includes(m))) parents.add(parent2);
|
|
2247
|
+
}
|
|
2248
|
+
if (parents.size !== 1) continue;
|
|
2249
|
+
const [parent] = parents;
|
|
2250
|
+
const children = byContainer[parent];
|
|
2251
|
+
byContainer[parent] = children.filter((c) => !claimed.includes(c));
|
|
2252
|
+
if (!byContainer[parent].includes(box)) byContainer[parent].push(box);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
function projectLogical(ir) {
|
|
2256
|
+
const aws = ir.nodes.filter((n) => n.lexicon === "aws" && !isExample(n));
|
|
2257
|
+
const componentOf = componentNamer(aws);
|
|
2258
|
+
const byId = new Map(aws.map((n) => [n.id, n]));
|
|
2259
|
+
const refOut = enrichedRefs(ir, aws, byId);
|
|
2260
|
+
const vpcIds = new Set(aws.filter((n) => n.kind === "AWS::EC2::VPC").map((n) => n.id));
|
|
2261
|
+
const subnetIds = new Set(aws.filter((n) => n.kind === "AWS::EC2::Subnet").map((n) => n.id));
|
|
2262
|
+
const igwIds = new Set(aws.filter((n) => n.kind === "AWS::EC2::InternetGateway").map((n) => n.id));
|
|
2263
|
+
const igwToVpc = /* @__PURE__ */ new Map();
|
|
2264
|
+
for (const n of aws) {
|
|
2265
|
+
if (n.kind !== "AWS::EC2::VPCGatewayAttachment") continue;
|
|
2266
|
+
const refs = refOut.get(n.id) ?? /* @__PURE__ */ new Set();
|
|
2267
|
+
const igw = [...refs].find((r) => igwIds.has(r));
|
|
2268
|
+
const vpc = [...refs].find((r) => vpcIds.has(r));
|
|
2269
|
+
if (igw && vpc) igwToVpc.set(igw, vpc);
|
|
2270
|
+
}
|
|
2271
|
+
const publicRouteTables = /* @__PURE__ */ new Set();
|
|
2272
|
+
for (const n of aws) {
|
|
2273
|
+
if (n.kind !== "AWS::EC2::Route") continue;
|
|
2274
|
+
const refs = refOut.get(n.id) ?? /* @__PURE__ */ new Set();
|
|
2275
|
+
if (![...refs].some((r) => igwIds.has(r))) continue;
|
|
2276
|
+
for (const r of refs) if (byId.get(r)?.kind === "AWS::EC2::RouteTable") publicRouteTables.add(r);
|
|
2277
|
+
}
|
|
2278
|
+
const publicSubnets = /* @__PURE__ */ new Set();
|
|
2279
|
+
for (const n of aws) {
|
|
2280
|
+
if (n.kind !== "AWS::EC2::SubnetRouteTableAssociation") continue;
|
|
2281
|
+
const refs = refOut.get(n.id) ?? /* @__PURE__ */ new Set();
|
|
2282
|
+
const subnet = [...refs].find((r) => subnetIds.has(r));
|
|
2283
|
+
const rt = [...refs].find((r) => byId.get(r)?.kind === "AWS::EC2::RouteTable");
|
|
2284
|
+
if (subnet && rt && publicRouteTables.has(rt)) publicSubnets.add(subnet);
|
|
2285
|
+
}
|
|
2286
|
+
const subnetScope = (id) => publicSubnets.has(id) || /public/i.test(id) ? "public" : /private/i.test(id) ? "private" : "subnet";
|
|
2287
|
+
const defaultRegion = aws.map(regionOf).find(Boolean);
|
|
2288
|
+
const regionForVpc = (vpcId) => byId.has(vpcId) ? regionOf(byId.get(vpcId)) ?? defaultRegion : defaultRegion;
|
|
2289
|
+
const vpcTitle = (vpcId) => {
|
|
2290
|
+
const region = regionForVpc(vpcId);
|
|
2291
|
+
return `${region ? region + " \xB7 " : ""}VPC ${strAttr3(byId.get(vpcId), "CidrBlock") ?? vpcId}`;
|
|
2292
|
+
};
|
|
2293
|
+
const subnetTitle = (subnetId) => `${subnetScope(subnetId)} subnet ${strAttr3(byId.get(subnetId), "CidrBlock") ?? subnetId}`;
|
|
2294
|
+
const GLOBAL = "regional & global";
|
|
2295
|
+
const headline = aws.filter((n) => HEADLINE_KINDS.has(n.kind));
|
|
2296
|
+
const placeOf = /* @__PURE__ */ new Map();
|
|
2297
|
+
for (const n of headline) {
|
|
2298
|
+
const subnet = nearestByRef(n.id, subnetIds, refOut);
|
|
2299
|
+
if (subnet) placeOf.set(n.id, { kind: "subnet", subnet, vpc: nearestByRef(subnet, vpcIds, refOut) });
|
|
2300
|
+
else {
|
|
2301
|
+
const vpc = nearestByRef(n.id, vpcIds, refOut) ?? igwToVpc.get(n.id);
|
|
2302
|
+
placeOf.set(n.id, vpc ? { kind: "vpc", vpc } : { kind: "global" });
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
const byContainer = {};
|
|
2306
|
+
const child = (parent, c) => {
|
|
2307
|
+
const arr = byContainer[parent] ?? (byContainer[parent] = []);
|
|
2308
|
+
if (!arr.includes(c)) arr.push(c);
|
|
2309
|
+
};
|
|
2310
|
+
const ensureSubnet = (subnetId, vpcId) => {
|
|
2311
|
+
const t = subnetTitle(subnetId);
|
|
2312
|
+
if (vpcId) child(vpcTitle(vpcId), t);
|
|
2313
|
+
return t;
|
|
2314
|
+
};
|
|
2315
|
+
const byComponent = /* @__PURE__ */ new Map();
|
|
2316
|
+
for (const n of headline) (byComponent.get(componentOf(n)) ?? byComponent.set(componentOf(n), []).get(componentOf(n))).push(n);
|
|
2317
|
+
for (const [component, members] of byComponent) {
|
|
2318
|
+
const places = members.map((m) => placeOf.get(m.id));
|
|
2319
|
+
const subnets = new Set(places.map((p) => p.kind === "subnet" ? p.subnet : ""));
|
|
2320
|
+
const vpcs = new Set(places.map((p) => p.kind === "subnet" ? p.vpc : p.kind === "vpc" ? p.vpc : void 0).filter((v) => !!v));
|
|
2321
|
+
let parent;
|
|
2322
|
+
if (subnets.size === 1 && !subnets.has("")) {
|
|
2323
|
+
const p = places[0];
|
|
2324
|
+
parent = ensureSubnet(p.subnet, p.vpc);
|
|
2325
|
+
} else if (vpcs.size >= 1) {
|
|
2326
|
+
parent = vpcTitle([...vpcs][0]);
|
|
2327
|
+
} else {
|
|
2328
|
+
parent = GLOBAL;
|
|
2329
|
+
}
|
|
2330
|
+
child(parent, component);
|
|
2331
|
+
for (const m of members) child(component, m.id);
|
|
2332
|
+
}
|
|
2333
|
+
const keptSet = new Set(headline.map((n) => n.id));
|
|
2334
|
+
const adj = /* @__PURE__ */ new Map();
|
|
2335
|
+
const link = (a, b) => {
|
|
2336
|
+
if (a === b) return;
|
|
2337
|
+
(adj.get(a) ?? adj.set(a, /* @__PURE__ */ new Set()).get(a)).add(b);
|
|
2338
|
+
(adj.get(b) ?? adj.set(b, /* @__PURE__ */ new Set()).get(b)).add(a);
|
|
2339
|
+
};
|
|
2340
|
+
for (const [from, tos] of refOut) for (const to of tos) link(from, to);
|
|
2341
|
+
const edges = [];
|
|
2342
|
+
const seenEdge = /* @__PURE__ */ new Set();
|
|
2343
|
+
const addEdge = (a, b, via) => {
|
|
2344
|
+
if (a === b || !keptSet.has(a) || !keptSet.has(b)) return;
|
|
2345
|
+
const key = a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
2346
|
+
if (seenEdge.has(key)) return;
|
|
2347
|
+
seenEdge.add(key);
|
|
2348
|
+
edges.push({ from: a, to: b, kind: "ref", ...via ? { viaAttr: via } : {} });
|
|
2349
|
+
};
|
|
2350
|
+
const isSg = (id) => byId.get(id)?.kind === "AWS::EC2::SecurityGroup";
|
|
2351
|
+
const firstRef = (v) => {
|
|
2352
|
+
const s = /* @__PURE__ */ new Set();
|
|
2353
|
+
collectRefs3(v, s);
|
|
2354
|
+
return [...s].find((id) => byId.has(id));
|
|
2355
|
+
};
|
|
2356
|
+
const sgsOf = (start) => {
|
|
2357
|
+
const out = /* @__PURE__ */ new Set();
|
|
2358
|
+
const seen = /* @__PURE__ */ new Set([start]);
|
|
2359
|
+
let frontier = [...refOut.get(start) ?? []];
|
|
2360
|
+
for (let depth = 0; depth < 3 && frontier.length; depth++) {
|
|
2361
|
+
const next = [];
|
|
2362
|
+
for (const id of frontier) {
|
|
2363
|
+
if (seen.has(id)) continue;
|
|
2364
|
+
seen.add(id);
|
|
2365
|
+
if (isSg(id)) out.add(id);
|
|
2366
|
+
else if (byId.get(id)?.kind === "AWS::CloudFormation::Parameter") for (const r of refOut.get(id) ?? []) next.push(r);
|
|
2367
|
+
}
|
|
2368
|
+
frontier = next;
|
|
2369
|
+
}
|
|
2370
|
+
return out;
|
|
2371
|
+
};
|
|
2372
|
+
const sgMembers = /* @__PURE__ */ new Map();
|
|
2373
|
+
for (const n of headline) for (const sg of sgsOf(n.id)) (sgMembers.get(sg) ?? sgMembers.set(sg, /* @__PURE__ */ new Set()).get(sg)).add(n.id);
|
|
2374
|
+
const ingress = [];
|
|
2375
|
+
for (const n of aws) {
|
|
2376
|
+
if (n.kind === "AWS::EC2::SecurityGroup") {
|
|
2377
|
+
const rules = n.attrs?.SecurityGroupIngress;
|
|
2378
|
+
for (const r of Array.isArray(rules) ? rules : rules ? [rules] : []) {
|
|
2379
|
+
const src = firstRef(r?.SourceSecurityGroupId);
|
|
2380
|
+
if (src && isSg(src)) ingress.push([src, n.id]);
|
|
2381
|
+
}
|
|
2382
|
+
} else if (n.kind === "AWS::EC2::SecurityGroupIngress") {
|
|
2383
|
+
const src = firstRef(n.attrs?.SourceSecurityGroupId);
|
|
2384
|
+
const tgt = firstRef(n.attrs?.GroupId);
|
|
2385
|
+
if (src && tgt && isSg(src) && isSg(tgt)) ingress.push([src, tgt]);
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
for (const [src, tgt] of ingress)
|
|
2389
|
+
for (const rs of sgMembers.get(src) ?? []) for (const rt of sgMembers.get(tgt) ?? []) addEdge(rs, rt, "security-group ingress");
|
|
2390
|
+
const contractable = (id) => !keptSet.has(id) && !NO_CONTRACT_KINDS.has(byId.get(id)?.kind ?? "");
|
|
2391
|
+
for (const start of keptSet) {
|
|
2392
|
+
const seen = /* @__PURE__ */ new Set([start]);
|
|
2393
|
+
let frontier = [...adj.get(start) ?? []];
|
|
2394
|
+
for (let depth = 0; depth < 8 && frontier.length; depth++) {
|
|
2395
|
+
const next = [];
|
|
2396
|
+
for (const id of frontier) {
|
|
2397
|
+
if (seen.has(id)) continue;
|
|
2398
|
+
seen.add(id);
|
|
2399
|
+
if (keptSet.has(id)) {
|
|
2400
|
+
addEdge(start, id);
|
|
2401
|
+
continue;
|
|
2402
|
+
}
|
|
2403
|
+
if (!contractable(id)) continue;
|
|
2404
|
+
for (const nb of adj.get(id) ?? []) if (!seen.has(nb)) next.push(nb);
|
|
2405
|
+
}
|
|
2406
|
+
frontier = next;
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
for (const w of headline) {
|
|
2410
|
+
if (!WORKLOAD_KINDS2.has(w.kind)) continue;
|
|
2411
|
+
const seen = /* @__PURE__ */ new Set([w.id]);
|
|
2412
|
+
let frontier = [...adj.get(w.id) ?? []];
|
|
2413
|
+
for (let depth = 0; depth < 6 && frontier.length; depth++) {
|
|
2414
|
+
const next = [];
|
|
2415
|
+
for (const id of frontier) {
|
|
2416
|
+
if (seen.has(id)) continue;
|
|
2417
|
+
seen.add(id);
|
|
2418
|
+
if (keptSet.has(id)) {
|
|
2419
|
+
if (DATA_STORE_KINDS.has(byId.get(id)?.kind ?? "")) addEdge(w.id, id, "data dependency");
|
|
2420
|
+
continue;
|
|
2421
|
+
}
|
|
2422
|
+
for (const nb of adj.get(id) ?? []) if (!seen.has(nb)) next.push(nb);
|
|
2423
|
+
}
|
|
2424
|
+
frontier = next;
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
return { ir: { nodes: headline, edges, groups: {} }, byContainer };
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
// src/composite-deps.ts
|
|
2431
|
+
function kebabKind(kind) {
|
|
2432
|
+
return kind.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
2433
|
+
}
|
|
2434
|
+
function componentRepresentatives(ir, dag) {
|
|
2435
|
+
const nodeById = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
2436
|
+
const knownComponents2 = new Set((dag.nodes ?? []).map((c) => c.id));
|
|
2437
|
+
const compToNode = /* @__PURE__ */ new Map();
|
|
2438
|
+
const sourceComponent = (id) => {
|
|
2439
|
+
const parts = nodeById.get(id)?.sourceLoc?.file?.split("/") ?? [];
|
|
2440
|
+
return parts.slice(0, -1).find((p) => knownComponents2.has(p));
|
|
2441
|
+
};
|
|
2442
|
+
for (const comp of dag.nodes ?? []) {
|
|
2443
|
+
const liveNames = comp.attrs?.liveNames;
|
|
2444
|
+
if (!Array.isArray(liveNames)) continue;
|
|
2445
|
+
const fallbackShaped = liveNames.length === 1 && liveNames[0] === comp.id;
|
|
2446
|
+
const owned = liveNames.filter((n) => {
|
|
2447
|
+
if (typeof n !== "string" || !nodeById.has(n)) return false;
|
|
2448
|
+
const src = sourceComponent(n);
|
|
2449
|
+
return fallbackShaped ? src === comp.id : src === void 0 || src === comp.id;
|
|
2450
|
+
});
|
|
2451
|
+
if (owned.length === 0) continue;
|
|
2452
|
+
compToNode.set(comp.id, owned.find((n) => !n.startsWith("byo")) ?? owned[0]);
|
|
2453
|
+
}
|
|
2454
|
+
for (const n of ir.nodes) {
|
|
2455
|
+
const component = n.attrs?.component;
|
|
2456
|
+
if (typeof component !== "string" || !knownComponents2.has(component) || compToNode.has(component)) continue;
|
|
2457
|
+
compToNode.set(component, n.id);
|
|
2458
|
+
}
|
|
2459
|
+
const unmapped = /* @__PURE__ */ new Set();
|
|
2460
|
+
for (const e of dag.edges) {
|
|
2461
|
+
if (!compToNode.has(e.from)) unmapped.add(e.from);
|
|
2462
|
+
if (!compToNode.has(e.to)) unmapped.add(e.to);
|
|
2463
|
+
}
|
|
2464
|
+
if (unmapped.size > 0) {
|
|
2465
|
+
for (const n of ir.nodes) {
|
|
2466
|
+
if (n.kind.includes("::")) continue;
|
|
2467
|
+
const comp = kebabKind(n.kind);
|
|
2468
|
+
if (!unmapped.has(comp)) continue;
|
|
2469
|
+
const current = compToNode.get(comp);
|
|
2470
|
+
if (!current || current.startsWith("byo") && !n.id.startsWith("byo")) compToNode.set(comp, n.id);
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
return compToNode;
|
|
2474
|
+
}
|
|
2475
|
+
function addCompositeDeps(ir, dag) {
|
|
2476
|
+
const compToNode = componentRepresentatives(ir, dag);
|
|
2477
|
+
const declared = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
2478
|
+
for (const e of dag.edges) {
|
|
2479
|
+
const from = compToNode.get(e.from);
|
|
2480
|
+
const to = compToNode.get(e.to);
|
|
2481
|
+
if (!from || !to || from === to) continue;
|
|
2482
|
+
const key = `${from}\0${to}`;
|
|
2483
|
+
if (declared.has(key)) continue;
|
|
2484
|
+
declared.add(key);
|
|
2485
|
+
ir.edges.push({ from, to, kind: "ref", viaAttr: "dependsOn", inferred: true });
|
|
2486
|
+
}
|
|
2487
|
+
return ir;
|
|
2488
|
+
}
|
|
2489
|
+
function addCompositeDepsCounted(ir, dag) {
|
|
2490
|
+
const before = ir.edges.length;
|
|
2491
|
+
const out = addCompositeDeps(ir, dag);
|
|
2492
|
+
return { ir: out, attached: out.edges.length - before };
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
// src/zoom-notes.ts
|
|
2496
|
+
function zoomNote(zoom, ir, compositeEdgesAttached) {
|
|
2497
|
+
const empty = ir.nodes.length === 0;
|
|
2498
|
+
if (zoom === "components") {
|
|
2499
|
+
return empty ? "no components discovered \u2014 nothing declares one in this project" : void 0;
|
|
2500
|
+
}
|
|
2501
|
+
if (zoom === "logical") {
|
|
2502
|
+
return empty ? "logical is an AWS projection \u2014 no AWS resources in this estate" : void 0;
|
|
2503
|
+
}
|
|
2504
|
+
if (zoom === "composites") {
|
|
2505
|
+
return compositeEdgesAttached === 0 ? "no component ownership to join \u2014 showing the resource graph unchanged" : void 0;
|
|
2506
|
+
}
|
|
2507
|
+
if (zoom === "runtime") {
|
|
2508
|
+
const children = ir.nodes.filter((n) => n.runtimeOwner).length;
|
|
2509
|
+
return children === 0 ? "nothing below the declaration boundary \u2014 no owner-referenced children on this substrate" : void 0;
|
|
2510
|
+
}
|
|
2511
|
+
return void 0;
|
|
2512
|
+
}
|
|
2513
|
+
function logicalKept(before, after) {
|
|
2514
|
+
if (after > 0 && after * 3 >= before) return void 0;
|
|
2515
|
+
return after === 0 ? `logical projected nothing from ${before} resources \u2014 it is a cloud-topology lens, and this estate declares none of the kinds it nests (behold#74)` : `logical kept ${after} of ${before} resources \u2014 it is a cloud-topology lens, and the rest are kinds it does not nest (behold#74)`;
|
|
2516
|
+
}
|
|
2517
|
+
function edgelessNote(zoom, ir) {
|
|
2518
|
+
if (zoom === "components" || zoom === "logical") return void 0;
|
|
2519
|
+
if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
|
|
2520
|
+
return "no edges \u2014 nothing in this estate references anything else";
|
|
2521
|
+
}
|
|
2522
|
+
function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore) {
|
|
2523
|
+
const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
|
|
2524
|
+
const notes = [primary, edgelessNote(zoom, ir)].filter((n) => n !== void 0);
|
|
2525
|
+
return notes.length ? notes.join(" \xB7 ") : void 0;
|
|
2526
|
+
}
|
|
2527
|
+
function tierMismatchNote(ir, tiers, currentTier) {
|
|
2528
|
+
const values = tiers?.values ?? [];
|
|
2529
|
+
if (values.length < 2) return void 0;
|
|
2530
|
+
const byLexicon = /* @__PURE__ */ new Map();
|
|
2531
|
+
for (const n of ir.nodes) {
|
|
2532
|
+
const status = n.attrs?._status;
|
|
2533
|
+
if (status !== "good" && status !== "accent") continue;
|
|
2534
|
+
const lexicon = n.lexicon ?? "?";
|
|
2535
|
+
const c = byLexicon.get(lexicon) ?? { good: 0, accent: 0 };
|
|
2536
|
+
c[status === "good" ? "good" : "accent"]++;
|
|
2537
|
+
byLexicon.set(lexicon, c);
|
|
2538
|
+
}
|
|
2539
|
+
for (const [lexicon, { good, accent }] of [...byLexicon].sort()) {
|
|
2540
|
+
if (accent < 2 || accent * 2 < good + accent) continue;
|
|
2541
|
+
const here = currentTier ? `tier "${currentTier}"` : "the default tier";
|
|
2542
|
+
const others = values.filter((v) => v !== currentTier).join(", ");
|
|
2543
|
+
return `${accent} of ${good + accent} ${lexicon} resources read "declared, not deployed" at ${here} \u2014 if the estate runs another declared tier (${others}), pick it (\u2318K \u2192 tier)`;
|
|
2544
|
+
}
|
|
2545
|
+
return void 0;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// src/resources.ts
|
|
2549
|
+
var NON_RESOURCE_KINDS = /* @__PURE__ */ new Set(["AWS::CloudFormation::Parameter", "chant:output"]);
|
|
2550
|
+
function nonResourceEntities(ir) {
|
|
2551
|
+
const out = /* @__PURE__ */ new Set();
|
|
2552
|
+
for (const n of ir.nodes) if (NON_RESOURCE_KINDS.has(n.kind)) out.add(n.id);
|
|
2553
|
+
return out;
|
|
2554
|
+
}
|
|
2555
|
+
function componentSegment(parts, known) {
|
|
2556
|
+
if (known) {
|
|
2557
|
+
return parts.slice(0, -1).find((p) => known.has(p));
|
|
2558
|
+
}
|
|
2559
|
+
return parts[0] === "src" && parts.length >= 3 ? parts[1] : void 0;
|
|
2560
|
+
}
|
|
2561
|
+
function resourcesByComponent(ir, known) {
|
|
2562
|
+
const byComponent = {};
|
|
2563
|
+
for (const n of ir.nodes) {
|
|
2564
|
+
const parts = n.sourceLoc?.file?.split("/") ?? [];
|
|
2565
|
+
const component = componentSegment(parts, known);
|
|
2566
|
+
if (!component) continue;
|
|
2567
|
+
(byComponent[component] ??= []).push({
|
|
2568
|
+
id: n.id,
|
|
2569
|
+
kind: n.kind,
|
|
2570
|
+
lexicon: n.lexicon,
|
|
2571
|
+
physicalId: n.physicalId,
|
|
2572
|
+
ownership: n.ownership
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
return byComponent;
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
// src/reconcile.ts
|
|
2579
|
+
function summarizePlan(plan, byComponent, nonResource) {
|
|
2580
|
+
const componentByEntity = /* @__PURE__ */ new Map();
|
|
2581
|
+
for (const [component, resources] of Object.entries(byComponent)) {
|
|
2582
|
+
for (const r of resources) componentByEntity.set(r.id, component);
|
|
2583
|
+
}
|
|
2584
|
+
const counts = {};
|
|
2585
|
+
const unobservedCounts = {};
|
|
2586
|
+
const runtimeCounts = {};
|
|
2587
|
+
let uncorrelated = 0;
|
|
2588
|
+
let unobservedUncorrelated = 0;
|
|
2589
|
+
let runtimeUncorrelated = 0;
|
|
2590
|
+
let total = 0;
|
|
2591
|
+
let unobserved = 0;
|
|
2592
|
+
let runtime = 0;
|
|
2593
|
+
for (const entry of plan.entries) {
|
|
2594
|
+
if (entry.action === "noop") continue;
|
|
2595
|
+
if (nonResource?.has(entry.name)) continue;
|
|
2596
|
+
const component = componentByEntity.get(entry.name);
|
|
2597
|
+
if (entry.action === "unobserved") {
|
|
2598
|
+
unobserved++;
|
|
2599
|
+
if (component) unobservedCounts[component] = (unobservedCounts[component] ?? 0) + 1;
|
|
2600
|
+
else unobservedUncorrelated++;
|
|
2601
|
+
continue;
|
|
2602
|
+
}
|
|
2603
|
+
if (entry.action === "runtime") {
|
|
2604
|
+
runtime++;
|
|
2605
|
+
if (component) runtimeCounts[component] = (runtimeCounts[component] ?? 0) + 1;
|
|
2606
|
+
else runtimeUncorrelated++;
|
|
2607
|
+
continue;
|
|
2608
|
+
}
|
|
2609
|
+
total++;
|
|
2610
|
+
if (component) counts[component] = (counts[component] ?? 0) + 1;
|
|
2611
|
+
else uncorrelated++;
|
|
2612
|
+
}
|
|
2613
|
+
return {
|
|
2614
|
+
env: plan.env,
|
|
2615
|
+
total,
|
|
2616
|
+
byComponent: counts,
|
|
2617
|
+
uncorrelated,
|
|
2618
|
+
unobserved,
|
|
2619
|
+
unobservedByComponent: unobservedCounts,
|
|
2620
|
+
unobservedUncorrelated,
|
|
2621
|
+
runtime,
|
|
2622
|
+
runtimeByComponent: runtimeCounts,
|
|
2623
|
+
runtimeUncorrelated
|
|
2624
|
+
};
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
// src/render.ts
|
|
2628
|
+
import { layoutIr, layoutArchitecture, renderSvg, cardSizes } from "@intentius/pinhole";
|
|
2629
|
+
function renderArchitecture(ir, byContainer, opts = {}) {
|
|
2630
|
+
const spread = Math.min(1.5, ir.edges.length / Math.max(ir.nodes.length, 1));
|
|
2631
|
+
const layout = layoutArchitecture(ir, byContainer, {
|
|
2632
|
+
fit: true,
|
|
2633
|
+
nodesep: Math.round(48 + spread * 48),
|
|
2634
|
+
ranksep: Math.round(60 + spread * 56)
|
|
2635
|
+
});
|
|
2636
|
+
const svg = renderSvg(ir, layout, {
|
|
2637
|
+
fit: true,
|
|
2638
|
+
hideTitle: true,
|
|
2639
|
+
groups: layout.groups,
|
|
2640
|
+
...opts.theme ? { theme: opts.theme } : {}
|
|
2641
|
+
});
|
|
2642
|
+
return { svg };
|
|
2643
|
+
}
|
|
2644
|
+
function renderGraph(ir, opts = {}) {
|
|
2645
|
+
const groups = ir.groups;
|
|
2646
|
+
const boxKey = groups.byWave ? "byWave" : opts.boxes;
|
|
2647
|
+
const boxes = boxKey ? groups[boxKey] : void 0;
|
|
2648
|
+
const layout = layoutIr(ir, { fit: true, ...boxes ? { groups: boxes } : {} });
|
|
2649
|
+
if (opts.radial && !boxes) radializeLayout(layout, groupKeyByNode(ir), footprints(ir));
|
|
2650
|
+
else if (!boxes) packComponents(layout, ir);
|
|
2651
|
+
const svg = renderSvg(ir, layout, {
|
|
2652
|
+
fit: true,
|
|
2653
|
+
hideTitle: true,
|
|
2654
|
+
...boxes ? { groups: layout.groups } : {},
|
|
2655
|
+
...opts.theme ? { theme: opts.theme } : {}
|
|
2656
|
+
});
|
|
2657
|
+
return { svg };
|
|
2658
|
+
}
|
|
2659
|
+
var NODE_W = 175;
|
|
2660
|
+
var NODE_H = 104;
|
|
2661
|
+
function footprints(ir) {
|
|
2662
|
+
const sizes = cardSizes(ir, { fit: true });
|
|
2663
|
+
return new Map(Object.entries(sizes));
|
|
2664
|
+
}
|
|
2665
|
+
function packComponents(layout, ir) {
|
|
2666
|
+
const nodes = layout.nodes;
|
|
2667
|
+
if (!Array.isArray(nodes) || nodes.length < 2) return;
|
|
2668
|
+
const idxOf = new Map(nodes.map((n, i) => [n.id, i]));
|
|
2669
|
+
const parent = nodes.map((_, i) => i);
|
|
2670
|
+
const find = (x) => {
|
|
2671
|
+
while (parent[x] !== x) x = parent[x] = parent[parent[x]];
|
|
2672
|
+
return x;
|
|
2673
|
+
};
|
|
2674
|
+
for (const e of ir.edges) {
|
|
2675
|
+
const a = idxOf.get(e.from);
|
|
2676
|
+
const b = idxOf.get(e.to);
|
|
2677
|
+
if (a != null && b != null) parent[find(a)] = find(b);
|
|
2678
|
+
}
|
|
2679
|
+
const comps = /* @__PURE__ */ new Map();
|
|
2680
|
+
nodes.forEach((n, i) => (comps.get(find(i)) ?? comps.set(find(i), []).get(find(i))).push(n));
|
|
2681
|
+
if (comps.size < 2) return;
|
|
2682
|
+
const size = footprints(ir);
|
|
2683
|
+
const halfW = (n) => (size.get(n.id)?.w ?? NODE_W) / 2;
|
|
2684
|
+
const halfH = (n) => (size.get(n.id)?.h ?? NODE_H) / 2;
|
|
2685
|
+
const boxes = [...comps.values()].map((ns) => {
|
|
2686
|
+
const minX2 = Math.min(...ns.map((n) => n.x - halfW(n)));
|
|
2687
|
+
const minY2 = Math.min(...ns.map((n) => n.y - halfH(n)));
|
|
2688
|
+
return {
|
|
2689
|
+
ns,
|
|
2690
|
+
minX: minX2,
|
|
2691
|
+
minY: minY2,
|
|
2692
|
+
w: Math.max(...ns.map((n) => n.x + halfW(n))) - minX2,
|
|
2693
|
+
h: Math.max(...ns.map((n) => n.y + halfH(n))) - minY2
|
|
2694
|
+
};
|
|
2695
|
+
});
|
|
2696
|
+
boxes.sort((a, b) => b.w * b.h - a.w * a.h);
|
|
2697
|
+
const gap = 56;
|
|
2698
|
+
const totalArea = boxes.reduce((s, b) => s + (b.w + gap) * (b.h + gap), 0);
|
|
2699
|
+
const targetW = Math.max(boxes[0].w, Math.sqrt(totalArea) * 1.3);
|
|
2700
|
+
let shelfX = 0;
|
|
2701
|
+
let shelfY = 0;
|
|
2702
|
+
let shelfH = 0;
|
|
2703
|
+
for (const b of boxes) {
|
|
2704
|
+
if (shelfX > 0 && shelfX + b.w > targetW) {
|
|
2705
|
+
shelfX = 0;
|
|
2706
|
+
shelfY += shelfH + gap;
|
|
2707
|
+
shelfH = 0;
|
|
2708
|
+
}
|
|
2709
|
+
const dx = shelfX - b.minX;
|
|
2710
|
+
const dy = shelfY - b.minY;
|
|
2711
|
+
for (const n of b.ns) {
|
|
2712
|
+
n.x += dx;
|
|
2713
|
+
n.y += dy;
|
|
2714
|
+
}
|
|
2715
|
+
shelfX += b.w + gap;
|
|
2716
|
+
shelfH = Math.max(shelfH, b.h);
|
|
2717
|
+
}
|
|
2718
|
+
const minX = Math.min(...nodes.map((n) => n.x - halfW(n)));
|
|
2719
|
+
const minY = Math.min(...nodes.map((n) => n.y - halfH(n)));
|
|
2720
|
+
const pad = 60;
|
|
2721
|
+
for (const n of nodes) {
|
|
2722
|
+
n.x = n.x - minX + pad;
|
|
2723
|
+
n.y = n.y - minY + pad;
|
|
2724
|
+
}
|
|
2725
|
+
layout.width = Math.max(...nodes.map((n) => n.x + halfW(n))) + pad;
|
|
2726
|
+
layout.height = Math.max(...nodes.map((n) => n.y + halfH(n))) + pad;
|
|
2727
|
+
}
|
|
2728
|
+
function groupKeyByNode(ir) {
|
|
2729
|
+
const out = /* @__PURE__ */ new Map();
|
|
2730
|
+
for (const n of ir.nodes) {
|
|
2731
|
+
const parts = (n.sourceLoc?.file ?? "").split("/");
|
|
2732
|
+
let key;
|
|
2733
|
+
if (parts[0] === "src" && parts[1] === "examples") key = "examples";
|
|
2734
|
+
else if (parts[0] === "src" && parts.length >= 3) key = parts[1];
|
|
2735
|
+
else key = n.lexicon || "other";
|
|
2736
|
+
out.set(n.id, key);
|
|
2737
|
+
}
|
|
2738
|
+
return out;
|
|
2739
|
+
}
|
|
2740
|
+
function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
|
|
2741
|
+
const nodes = layout.nodes;
|
|
2742
|
+
if (!Array.isArray(nodes) || nodes.length < 3) return;
|
|
2743
|
+
const wOf = (n) => size.get(n.id)?.w ?? NODE_W;
|
|
2744
|
+
const hOf = (n) => size.get(n.id)?.h ?? NODE_H;
|
|
2745
|
+
const bucket = (y) => Math.round(y / 8) * 8;
|
|
2746
|
+
const levels = [...new Set(nodes.map((n) => bucket(n.y)))].sort((a, b) => a - b);
|
|
2747
|
+
const rankOf = new Map(levels.map((y, i) => [y, i]));
|
|
2748
|
+
const r0 = 380;
|
|
2749
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2750
|
+
for (const n of nodes) {
|
|
2751
|
+
const k = groupOf.get(n.id) ?? "other";
|
|
2752
|
+
(groups.get(k) ?? groups.set(k, []).get(k)).push(n);
|
|
2753
|
+
}
|
|
2754
|
+
const order = [...groups.keys()].sort();
|
|
2755
|
+
const gapAngle = 0.08;
|
|
2756
|
+
const usable = 2 * Math.PI - gapAngle * order.length;
|
|
2757
|
+
const minWidth = Math.min(0.4, usable / order.length * 0.7);
|
|
2758
|
+
const flexible = usable - minWidth * order.length;
|
|
2759
|
+
const gap = 55;
|
|
2760
|
+
const rowStep = 190;
|
|
2761
|
+
let angle = -Math.PI / 2;
|
|
2762
|
+
for (const key of order) {
|
|
2763
|
+
const gnodes = groups.get(key).slice().sort((a, b) => (rankOf.get(bucket(a.y)) ?? 0) - (rankOf.get(bucket(b.y)) ?? 0) || a.x - b.x);
|
|
2764
|
+
const width = minWidth + flexible * (gnodes.length / nodes.length);
|
|
2765
|
+
const start = angle;
|
|
2766
|
+
let radius = r0;
|
|
2767
|
+
let i = 0;
|
|
2768
|
+
while (i < gnodes.length) {
|
|
2769
|
+
const capacity = Math.max(1, Math.floor(radius * width / (NODE_W + gap)));
|
|
2770
|
+
const arc = gnodes.slice(i, i + capacity);
|
|
2771
|
+
const c = arc.length;
|
|
2772
|
+
arc.forEach((n, j) => {
|
|
2773
|
+
const a = c === 1 ? start + width / 2 : start + width * ((j + 0.5) / c);
|
|
2774
|
+
n.x = radius * Math.cos(a);
|
|
2775
|
+
n.y = radius * Math.sin(a);
|
|
2776
|
+
});
|
|
2777
|
+
radius += rowStep;
|
|
2778
|
+
i += capacity;
|
|
2779
|
+
}
|
|
2780
|
+
angle = start + width + gapAngle;
|
|
2781
|
+
}
|
|
2782
|
+
const MARGIN_W = 45;
|
|
2783
|
+
const MARGIN_H = 12;
|
|
2784
|
+
nodes.forEach((n, k) => {
|
|
2785
|
+
n.x += (k * 13 % 7 - 3) * 0.4;
|
|
2786
|
+
n.y += (k * 7 % 5 - 2) * 0.4;
|
|
2787
|
+
});
|
|
2788
|
+
for (let iter = 0; iter < 2500; iter++) {
|
|
2789
|
+
let overlaps = 0;
|
|
2790
|
+
for (let a = 0; a < nodes.length; a++) {
|
|
2791
|
+
for (let b = a + 1; b < nodes.length; b++) {
|
|
2792
|
+
const dx = nodes[b].x - nodes[a].x;
|
|
2793
|
+
const dy = nodes[b].y - nodes[a].y;
|
|
2794
|
+
const sepW = (wOf(nodes[a]) + wOf(nodes[b])) / 2 + MARGIN_W;
|
|
2795
|
+
const sepH = (hOf(nodes[a]) + hOf(nodes[b])) / 2 + MARGIN_H;
|
|
2796
|
+
const ox = sepW - Math.abs(dx);
|
|
2797
|
+
const oy = sepH - Math.abs(dy);
|
|
2798
|
+
if (ox <= 0 || oy <= 0) continue;
|
|
2799
|
+
overlaps++;
|
|
2800
|
+
const d = Math.hypot(dx, dy) || 0.01;
|
|
2801
|
+
const push = Math.min(ox, oy) * 0.75 + 2;
|
|
2802
|
+
const ux = dx / d;
|
|
2803
|
+
const uy = dy / d;
|
|
2804
|
+
nodes[a].x -= ux * push;
|
|
2805
|
+
nodes[a].y -= uy * push;
|
|
2806
|
+
nodes[b].x += ux * push;
|
|
2807
|
+
nodes[b].y += uy * push;
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
if (overlaps === 0) break;
|
|
2811
|
+
}
|
|
2812
|
+
const minX = Math.min(...nodes.map((n) => n.x - wOf(n) / 2));
|
|
2813
|
+
const minY = Math.min(...nodes.map((n) => n.y - hOf(n) / 2));
|
|
2814
|
+
const pad = 60;
|
|
2815
|
+
for (const n of nodes) {
|
|
2816
|
+
n.x = n.x - minX + pad;
|
|
2817
|
+
n.y = n.y - minY + pad;
|
|
2818
|
+
}
|
|
2819
|
+
layout.width = Math.max(...nodes.map((n) => n.x + wOf(n) / 2)) + pad;
|
|
2820
|
+
layout.height = Math.max(...nodes.map((n) => n.y + hOf(n) / 2)) + pad;
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
// 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";
|
|
2826
|
+
var APPLY_TARGET_LEXICON = {
|
|
2827
|
+
cloudformation: "aws",
|
|
2828
|
+
kubectl: "k8s",
|
|
2829
|
+
// kustomize renders then applies through the same k8s pipeline
|
|
2830
|
+
// (chant#1548), so it scopes to the same lexicon. Note the auto-sync
|
|
2831
|
+
// wrinkle this creates on purpose: a kustomize Op AND a kubectl Op in one
|
|
2832
|
+
// project both exact-match `k8s`, and two exact matches decline — two Ops
|
|
2833
|
+
// claiming the k8s half genuinely is ambiguous.
|
|
2834
|
+
kustomize: "k8s",
|
|
2835
|
+
arm: "azure"
|
|
2836
|
+
};
|
|
2837
|
+
function kindOf(content) {
|
|
2838
|
+
if (/\bApplyOp\b/.test(content)) return "apply";
|
|
2839
|
+
if (/\bReconcileOp\b/.test(content)) return "reconcile";
|
|
2840
|
+
if (/AuditOp\b/.test(content)) return "audit";
|
|
2841
|
+
return "op";
|
|
2842
|
+
}
|
|
2843
|
+
function discoverOps(projectDir) {
|
|
2844
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2845
|
+
const out = [];
|
|
2846
|
+
for (const sub of ["ops", "src", "."]) {
|
|
2847
|
+
const dir = join6(projectDir, sub);
|
|
2848
|
+
if (!existsSync5(dir)) continue;
|
|
2849
|
+
for (const f of readdirSync3(dir)) {
|
|
2850
|
+
if (!f.endsWith(".op.ts")) continue;
|
|
2851
|
+
const content = readFileSync4(join6(dir, f), "utf8");
|
|
2852
|
+
const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
2853
|
+
if (!name || seen.has(name)) continue;
|
|
2854
|
+
seen.add(name);
|
|
2855
|
+
const gate = content.match(/signalName:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
2856
|
+
const env = content.match(/\benv:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
2857
|
+
const target = content.match(/\btarget:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
2858
|
+
const substrate = target ? APPLY_TARGET_LEXICON[target] : void 0;
|
|
2859
|
+
out.push({
|
|
2860
|
+
name,
|
|
2861
|
+
kind: kindOf(content),
|
|
2862
|
+
dir: projectDir,
|
|
2863
|
+
...gate ? { gate } : {},
|
|
2864
|
+
...env ? { env } : {},
|
|
2865
|
+
...substrate ? { substrate } : {}
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
2870
|
+
}
|
|
2871
|
+
function discoverEstateOps(projectDirs) {
|
|
2872
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2873
|
+
const out = [];
|
|
2874
|
+
for (const dir of projectDirs) {
|
|
2875
|
+
for (const op of discoverOps(dir)) {
|
|
2876
|
+
if (seen.has(op.name)) continue;
|
|
2877
|
+
seen.add(op.name);
|
|
2878
|
+
out.push(op);
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
// src/adopt.ts
|
|
2885
|
+
var LIVE_IMPORT_LEXICONS = ["aws", "azure", "gcp", "k8s"];
|
|
2886
|
+
var LIVE = new Set(LIVE_IMPORT_LEXICONS);
|
|
2887
|
+
function extractPrUrl(line) {
|
|
2888
|
+
const m = line.match(/https?:\/\/[^\s"'<>]+?\/(?:pull|pulls|merge_requests)\/\d+/);
|
|
2889
|
+
return m ? m[0] : void 0;
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
// src/diff.ts
|
|
2893
|
+
function nodeObserved(json, nodeId) {
|
|
2894
|
+
for (const lex of Object.values(json.lexicons ?? {})) {
|
|
2895
|
+
const o = lex.observed?.[nodeId];
|
|
2896
|
+
if (o) return o;
|
|
2897
|
+
}
|
|
2898
|
+
return null;
|
|
2899
|
+
}
|
|
2900
|
+
function nodeDiff(json, nodeId) {
|
|
2901
|
+
for (const lex of Object.values(json.lexicons ?? {})) {
|
|
2902
|
+
const r = lex.resources;
|
|
2903
|
+
if (!r) continue;
|
|
2904
|
+
const drift = r.driftedSinceSnapshot?.find((d) => d.name === nodeId);
|
|
2905
|
+
if (drift) return { category: "drifted", changes: drift.changes ?? [] };
|
|
2906
|
+
const unobserved = r.unobserved?.find((u) => u.name === nodeId);
|
|
2907
|
+
if (unobserved) {
|
|
2908
|
+
return {
|
|
2909
|
+
category: "unobserved",
|
|
2910
|
+
changes: [],
|
|
2911
|
+
unobservedReason: unobserved.reason,
|
|
2912
|
+
...unobserved.detail ? { unobservedDetail: unobserved.detail } : {}
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
const runtimeChild = r.runtimeChildren?.find((rc) => rc.name === nodeId);
|
|
2916
|
+
if (runtimeChild) return { category: "runtime", changes: [], runtimeOwner: runtimeChild.owner };
|
|
2917
|
+
if (r.missing?.includes(nodeId)) return { category: "missing", changes: [] };
|
|
2918
|
+
if (r.orphan?.includes(nodeId)) return { category: "orphan", changes: [] };
|
|
2919
|
+
if (r.disappeared?.includes(nodeId)) return { category: "disappeared", changes: [] };
|
|
2920
|
+
if (r.newlyObserved?.includes(nodeId)) return { category: "newlyObserved", changes: [] };
|
|
2921
|
+
if (r.unchanged?.includes(nodeId)) return { category: "unchanged", changes: [] };
|
|
2922
|
+
}
|
|
2923
|
+
return null;
|
|
2924
|
+
}
|
|
2925
|
+
function nodeFieldDrift(json, nodeId) {
|
|
2926
|
+
let sawDeep = false;
|
|
2927
|
+
for (const lex of Object.values(json.lexicons ?? {})) {
|
|
2928
|
+
const deep = lex.deep;
|
|
2929
|
+
if (!deep) continue;
|
|
2930
|
+
sawDeep = true;
|
|
2931
|
+
const drifted = deep.drifted.find((e) => e.name === nodeId)?.changes;
|
|
2932
|
+
const accepted = deep.accepted.find((e) => e.name === nodeId)?.changes;
|
|
2933
|
+
if (drifted || accepted) return { drifted: drifted ?? [], accepted: accepted ?? [] };
|
|
2934
|
+
if (deep.unchanged.includes(nodeId)) return { drifted: [], accepted: [] };
|
|
2935
|
+
}
|
|
2936
|
+
return sawDeep ? { drifted: [], accepted: [] } : null;
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
// src/health.ts
|
|
2940
|
+
var DEGRADED = /fail|error|rollback|crash|backoff|degraded|unhealthy|terminat|delete|denied|timeout|evicted|imagepull|cancel|unschedulable/i;
|
|
2941
|
+
var PROGRESSING = /in[_-]?progress|pending|creating|updating|provisioning|initializ|deploying|scaling|waiting|containercreating|accepted/i;
|
|
2942
|
+
var HEALTHY = /complete|running|active|ready|available|succeed|healthy|\bok\b|bound|synced|current|present/i;
|
|
2943
|
+
function neutralizeNegations(lower) {
|
|
2944
|
+
let negated = false;
|
|
2945
|
+
const mark = () => {
|
|
2946
|
+
negated = true;
|
|
2947
|
+
return " ";
|
|
2948
|
+
};
|
|
2949
|
+
const text = lower.replace(/\bnot[_\-\s]?[a-z]+\b/g, mark).replace(/\b[a-z]+\s*[=:]\s*false\b/g, mark);
|
|
2950
|
+
return { text, negated };
|
|
2951
|
+
}
|
|
2952
|
+
function classifyHealth(status) {
|
|
2953
|
+
if (!status) return "unknown";
|
|
2954
|
+
const { text, negated } = neutralizeNegations(status.toLowerCase());
|
|
2955
|
+
if (DEGRADED.test(text)) return "degraded";
|
|
2956
|
+
if (PROGRESSING.test(text)) return "progressing";
|
|
2957
|
+
if (negated) return "degraded";
|
|
2958
|
+
if (HEALTHY.test(text)) return "healthy";
|
|
2959
|
+
return "unknown";
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
// src/apply.ts
|
|
2963
|
+
var PROGRESS_EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
2964
|
+
"run-start",
|
|
2965
|
+
"wave-start",
|
|
2966
|
+
"component-start",
|
|
2967
|
+
"phase-start",
|
|
2968
|
+
"step",
|
|
2969
|
+
"phase-done",
|
|
2970
|
+
"component-done",
|
|
2971
|
+
"wave-done",
|
|
2972
|
+
"run-done"
|
|
2973
|
+
]);
|
|
2974
|
+
function parseProgressLine(line) {
|
|
2975
|
+
let parsed;
|
|
2976
|
+
try {
|
|
2977
|
+
parsed = JSON.parse(line);
|
|
2978
|
+
} catch {
|
|
2979
|
+
return null;
|
|
2980
|
+
}
|
|
2981
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
2982
|
+
const type = parsed.type;
|
|
2983
|
+
if (typeof type !== "string" || !PROGRESS_EVENT_TYPES.has(type)) return null;
|
|
2984
|
+
return parsed;
|
|
2985
|
+
}
|
|
2986
|
+
var initialApplyProgress = { status: "idle", waves: [], components: [] };
|
|
2987
|
+
function applyProgressReducer(state, event) {
|
|
2988
|
+
switch (event.type) {
|
|
2989
|
+
case "run-start": {
|
|
2990
|
+
const waves = event.waves.map((components2, i) => ({
|
|
2991
|
+
wave: i + 1,
|
|
2992
|
+
components: components2,
|
|
2993
|
+
status: "pending"
|
|
2994
|
+
}));
|
|
2995
|
+
const components = waves.flatMap(
|
|
2996
|
+
(w) => w.components.map((component) => ({ component, wave: w.wave, status: "pending" }))
|
|
2997
|
+
);
|
|
2998
|
+
return { status: "running", waves, components };
|
|
2999
|
+
}
|
|
3000
|
+
case "wave-start":
|
|
3001
|
+
return {
|
|
3002
|
+
...state,
|
|
3003
|
+
waves: state.waves.map((w) => w.wave === event.wave ? { ...w, status: "running" } : w)
|
|
3004
|
+
};
|
|
3005
|
+
case "component-start":
|
|
3006
|
+
return {
|
|
3007
|
+
...state,
|
|
3008
|
+
components: state.components.map(
|
|
3009
|
+
(c) => c.component === event.component ? { ...c, status: "running" } : c
|
|
3010
|
+
)
|
|
3011
|
+
};
|
|
3012
|
+
case "phase-start":
|
|
3013
|
+
return {
|
|
3014
|
+
...state,
|
|
3015
|
+
components: state.components.map(
|
|
3016
|
+
(c) => c.component === event.component ? { ...c, phase: event.phase, step: void 0 } : c
|
|
3017
|
+
)
|
|
3018
|
+
};
|
|
3019
|
+
case "step":
|
|
3020
|
+
return {
|
|
3021
|
+
...state,
|
|
3022
|
+
components: state.components.map(
|
|
3023
|
+
(c) => c.component === event.component ? {
|
|
3024
|
+
...c,
|
|
3025
|
+
phase: event.phase,
|
|
3026
|
+
step: event.step,
|
|
3027
|
+
...event.status === "failed" ? { status: "failed", error: event.error } : {}
|
|
3028
|
+
} : c
|
|
3029
|
+
)
|
|
3030
|
+
};
|
|
3031
|
+
case "phase-done":
|
|
3032
|
+
return {
|
|
3033
|
+
...state,
|
|
3034
|
+
components: state.components.map(
|
|
3035
|
+
(c) => c.component === event.component && event.status === "failed" ? { ...c, status: "failed" } : c
|
|
3036
|
+
)
|
|
3037
|
+
};
|
|
3038
|
+
case "component-done":
|
|
3039
|
+
return {
|
|
3040
|
+
...state,
|
|
3041
|
+
components: state.components.map(
|
|
3042
|
+
(c) => c.component === event.component ? { ...c, status: event.status } : c
|
|
3043
|
+
)
|
|
3044
|
+
};
|
|
3045
|
+
case "wave-done":
|
|
3046
|
+
return {
|
|
3047
|
+
...state,
|
|
3048
|
+
waves: state.waves.map((w) => w.wave === event.wave ? { ...w, status: event.status } : w)
|
|
3049
|
+
};
|
|
3050
|
+
case "run-done":
|
|
3051
|
+
return { ...state, status: event.status };
|
|
3052
|
+
default:
|
|
3053
|
+
return state;
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
// src/op-runner.ts
|
|
3058
|
+
var OpRunner = class {
|
|
3059
|
+
constructor(deps) {
|
|
3060
|
+
this.deps = deps;
|
|
3061
|
+
}
|
|
3062
|
+
deps;
|
|
3063
|
+
current = null;
|
|
3064
|
+
/** The last known apply progress (M3): kept around after the run ends (and
|
|
3065
|
+
* across dial re-renders) so a client that opens `/api/ops` mid-run — or
|
|
3066
|
+
* after a page reload — can hydrate the structured view instead of starting
|
|
3067
|
+
* blank. Reset to a fresh idle state at the start of each new `apply()`
|
|
3068
|
+
* call (the reducer would clear it on `run-start` anyway; this seeds it
|
|
3069
|
+
* before the first event lands so a reload between trigger and first event
|
|
3070
|
+
* doesn't show the PREVIOUS run's stale terminal state). */
|
|
3071
|
+
lastApplyProgress = initialApplyProgress;
|
|
3072
|
+
/** Name of the running op, or null. */
|
|
3073
|
+
get running() {
|
|
3074
|
+
return this.current;
|
|
3075
|
+
}
|
|
3076
|
+
/** The last known apply progress model (M3) — `initialApplyProgress` if no
|
|
3077
|
+
* apply has run yet this session. */
|
|
3078
|
+
get applyProgress() {
|
|
3079
|
+
return this.lastApplyProgress;
|
|
3080
|
+
}
|
|
3081
|
+
/**
|
|
3082
|
+
* Start `chant run <name>` unless one is already running (the Sync/Adopt/auto-
|
|
3083
|
+
* sync path). `cwd` is the Op's own project dir (#31 multi-estate); defaults to
|
|
3084
|
+
* the primary. Returns true if it started, false if busy.
|
|
3085
|
+
*/
|
|
3086
|
+
trigger(name, opEnv, cwd) {
|
|
3087
|
+
return this.start(["run", name], name, opEnv, cwd);
|
|
3088
|
+
}
|
|
3089
|
+
/**
|
|
3090
|
+
* Run an arbitrary `chant` invocation through the same guard/stream/PR/capture
|
|
3091
|
+
* path — used by the delegated rollback command (#28), which is a lifecycle
|
|
3092
|
+
* command, not an Op. `label` is the display name (the running-guard key).
|
|
3093
|
+
*/
|
|
3094
|
+
run(args, label, opEnv) {
|
|
3095
|
+
return this.start(args, label, opEnv);
|
|
3096
|
+
}
|
|
3097
|
+
/**
|
|
3098
|
+
* Delegated write (M3, #54's apply step): `chant run <target> --components
|
|
3099
|
+
* --env <env> --progress-json`, guarded the same way as `trigger`/`run` —
|
|
3100
|
+
* only one write in flight at a time; returns false (the caller answers
|
|
3101
|
+
* 409) when something else is already running. `target` is a component
|
|
3102
|
+
* name or `"all"`.
|
|
3103
|
+
*
|
|
3104
|
+
* Each streamed line is checked with `parseProgressLine`: a recognized
|
|
3105
|
+
* `RunProgressEvent` folds into the structured progress model
|
|
3106
|
+
* (`applyProgressReducer`) and broadcasts as an `apply` SSE event — the
|
|
3107
|
+
* primary surface the SPA renders (web/app.js's live wave/phase view).
|
|
3108
|
+
* Everything else (chant's human-readable driver summary, a warning, a
|
|
3109
|
+
* release-record line) still reaches the `op` channel as a raw-log
|
|
3110
|
+
* fallback, exactly like any other Op — `start()`'s default behaviour,
|
|
3111
|
+
* skipped only for the lines this consumes.
|
|
3112
|
+
*/
|
|
3113
|
+
apply(target, env) {
|
|
3114
|
+
if (this.current) return false;
|
|
3115
|
+
this.lastApplyProgress = initialApplyProgress;
|
|
3116
|
+
return this.start(applyArgs(target, env), `apply ${target}`, env, void 0, (line) => {
|
|
3117
|
+
const event = parseProgressLine(line);
|
|
3118
|
+
if (!event) return false;
|
|
3119
|
+
this.lastApplyProgress = applyProgressReducer(this.lastApplyProgress, event);
|
|
3120
|
+
this.deps.broadcaster.emit("apply", JSON.stringify(this.lastApplyProgress));
|
|
3121
|
+
return true;
|
|
3122
|
+
});
|
|
3123
|
+
}
|
|
3124
|
+
/**
|
|
3125
|
+
* A local pipeline run as a first-class action (#163, the first slice of
|
|
3126
|
+
* #61): the same guarded/streamed shell-out as `bringUp`, but the run's
|
|
3127
|
+
* structure is KNOWN — `pipeline` is `/api/ci`'s parsed stages/jobs — so
|
|
3128
|
+
* progress renders on the dial exactly like an apply: stages as waves, jobs
|
|
3129
|
+
* correlated to their components (src/ci-run.ts). Every line still reaches
|
|
3130
|
+
* the `op` now-line (a pipeline log is worth reading raw); the classifier
|
|
3131
|
+
* only decides whether the structured model ALSO moved. The exit code, not
|
|
3132
|
+
* the log, settles the verdict.
|
|
3133
|
+
*/
|
|
3134
|
+
pipeline(label, cmd, args, cwd, pipeline) {
|
|
3135
|
+
if (this.current) return false;
|
|
3136
|
+
const { broadcaster } = this.deps;
|
|
3137
|
+
let state = pipelineProgress(pipeline);
|
|
3138
|
+
this.lastApplyProgress = state;
|
|
3139
|
+
broadcaster.emit("op", `\u25B6 ${cmd} ${args.join(" ")}`);
|
|
3140
|
+
broadcaster.emit("apply", JSON.stringify(state));
|
|
3141
|
+
const op = runCommandStream(cmd, args, cwd, (line) => {
|
|
3142
|
+
broadcaster.emit("op", line);
|
|
3143
|
+
const next = foldPipelineLine(state, line);
|
|
3144
|
+
if (next === state) return;
|
|
3145
|
+
state = next;
|
|
3146
|
+
this.lastApplyProgress = state;
|
|
3147
|
+
broadcaster.emit("apply", JSON.stringify(state));
|
|
3148
|
+
});
|
|
3149
|
+
this.current = label;
|
|
3150
|
+
void op.done.then((code) => {
|
|
3151
|
+
state = finishPipelineProgress(state, code);
|
|
3152
|
+
this.lastApplyProgress = state;
|
|
3153
|
+
broadcaster.emit("apply", JSON.stringify(state));
|
|
3154
|
+
broadcaster.emit("op", `\u25A0 ${label} exited ${code}`);
|
|
3155
|
+
this.current = null;
|
|
3156
|
+
Promise.resolve(this.deps.onDone(void 0)).then(() => broadcaster.emit("changed")).catch((err) => broadcaster.emit("op", `\u26A0 post-op capture: ${err instanceof Error ? err.message : String(err)}`));
|
|
3157
|
+
});
|
|
3158
|
+
return true;
|
|
3159
|
+
}
|
|
3160
|
+
/**
|
|
3161
|
+
* A tracked async action (#164): holds the same single-writer guard while a
|
|
3162
|
+
* code-driven loop (not a child process) does the work — the GitHub Actions
|
|
3163
|
+
* dispatch-and-follow, whose "stream" is polled structured JSON rather than
|
|
3164
|
+
* a process's stdout. The task reports lines for the now-line and
|
|
3165
|
+
* apply-shaped progress for the dial, and resolves to an exit code. The
|
|
3166
|
+
* guard releases the moment the task settles; a thrown task reads exit 1.
|
|
3167
|
+
*/
|
|
3168
|
+
track(label, task) {
|
|
3169
|
+
if (this.current) return false;
|
|
3170
|
+
const { broadcaster } = this.deps;
|
|
3171
|
+
this.current = label;
|
|
3172
|
+
this.lastApplyProgress = initialApplyProgress;
|
|
3173
|
+
const io = {
|
|
3174
|
+
line: (s) => broadcaster.emit("op", s),
|
|
3175
|
+
progress: (s) => {
|
|
3176
|
+
this.lastApplyProgress = s;
|
|
3177
|
+
broadcaster.emit("apply", JSON.stringify(s));
|
|
3178
|
+
}
|
|
3179
|
+
};
|
|
3180
|
+
void task(io).catch((err) => {
|
|
3181
|
+
io.line(`\u2717 ${label}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3182
|
+
return 1;
|
|
3183
|
+
}).then((code) => {
|
|
3184
|
+
broadcaster.emit("op", `\u25A0 ${label} exited ${code}`);
|
|
3185
|
+
this.current = null;
|
|
3186
|
+
Promise.resolve(this.deps.onDone(void 0)).then(() => broadcaster.emit("changed")).catch((err) => broadcaster.emit("op", `\u26A0 post-op capture: ${err instanceof Error ? err.message : String(err)}`));
|
|
3187
|
+
});
|
|
3188
|
+
return true;
|
|
3189
|
+
}
|
|
3190
|
+
/**
|
|
3191
|
+
* Substrate bring-up (M5, #54): run a project's local bring-up script (e.g.
|
|
3192
|
+
* `bash scripts/local/local-up.sh`, `test/gitlab-runtime-e2e.sh`) through the
|
|
3193
|
+
* SAME running-guard + stream + post-run capture as an Op — behold triggers,
|
|
3194
|
+
* the script does the work, its output streams to the `op` channel, and the
|
|
3195
|
+
* post-run `changed` re-checks the graph (and lets the readiness strip
|
|
3196
|
+
* re-detect). Returns false (→ 409) when something is already running. `cwd`
|
|
3197
|
+
* is the served project's dir.
|
|
3198
|
+
*/
|
|
3199
|
+
bringUp(label, cmd, args, cwd) {
|
|
3200
|
+
if (this.current) return false;
|
|
3201
|
+
const { broadcaster } = this.deps;
|
|
3202
|
+
broadcaster.emit("op", `\u25B6 ${cmd} ${args.join(" ")}`);
|
|
3203
|
+
const op = runCommandStream(cmd, args, cwd, (line) => broadcaster.emit("op", line));
|
|
3204
|
+
this.current = label;
|
|
3205
|
+
void op.done.then((code) => {
|
|
3206
|
+
broadcaster.emit("op", `\u25A0 ${label} exited ${code}`);
|
|
3207
|
+
this.current = null;
|
|
3208
|
+
Promise.resolve(this.deps.onDone(void 0)).then(() => broadcaster.emit("changed")).catch((err) => broadcaster.emit("op", `\u26A0 post-op capture: ${err instanceof Error ? err.message : String(err)}`));
|
|
3209
|
+
});
|
|
3210
|
+
return true;
|
|
3211
|
+
}
|
|
3212
|
+
/**
|
|
3213
|
+
* Shared runner: guard on a single in-flight invocation, stream output as `op`
|
|
3214
|
+
* events, lift a PR URL to a `pr` event, and on completion capture a frame and
|
|
3215
|
+
* emit `changed`. `onLine`, when given a streamed line, returns true if it
|
|
3216
|
+
* fully handled that line (apply()'s progress-JSON parsing) — `start()` then
|
|
3217
|
+
* skips its own `op`/`pr` broadcast for that one line, leaving every other
|
|
3218
|
+
* line's raw-log fallback untouched.
|
|
3219
|
+
*/
|
|
3220
|
+
start(args, label, opEnv, cwd, onLine) {
|
|
3221
|
+
if (this.current) return false;
|
|
3222
|
+
const { projectDir, broadcaster } = this.deps;
|
|
3223
|
+
broadcaster.emit("op", `\u25B6 chant ${args.join(" ")}`);
|
|
3224
|
+
const op = runChantStream(args, cwd ?? projectDir, (line) => {
|
|
3225
|
+
if (onLine?.(line)) return;
|
|
3226
|
+
broadcaster.emit("op", line);
|
|
3227
|
+
const pr = extractPrUrl(line);
|
|
3228
|
+
if (pr) broadcaster.emit("pr", pr);
|
|
3229
|
+
});
|
|
3230
|
+
this.current = label;
|
|
3231
|
+
void op.done.then((code) => {
|
|
3232
|
+
broadcaster.emit("op", `\u25A0 ${label} exited ${code}`);
|
|
3233
|
+
this.current = null;
|
|
3234
|
+
Promise.resolve(this.deps.onDone(opEnv)).then(() => broadcaster.emit("changed")).catch((err) => broadcaster.emit("op", `\u26A0 post-op capture: ${err instanceof Error ? err.message : String(err)}`));
|
|
3235
|
+
});
|
|
3236
|
+
return true;
|
|
3237
|
+
}
|
|
3238
|
+
};
|
|
3239
|
+
|
|
3240
|
+
// src/substrates.ts
|
|
3241
|
+
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";
|
|
3244
|
+
import { platform } from "node:os";
|
|
3245
|
+
function probe(cmd, args) {
|
|
3246
|
+
return new Promise((resolve3) => {
|
|
3247
|
+
let out = "";
|
|
3248
|
+
let proc;
|
|
3249
|
+
try {
|
|
3250
|
+
proc = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
3251
|
+
} catch {
|
|
3252
|
+
resolve3({ code: 127, out: "" });
|
|
3253
|
+
return;
|
|
3254
|
+
}
|
|
3255
|
+
proc.stdout.on("data", (d) => out += d);
|
|
3256
|
+
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 }));
|
|
3259
|
+
});
|
|
3260
|
+
}
|
|
3261
|
+
async function dockerAvailable() {
|
|
3262
|
+
const { code } = await probe("docker", ["info", "--format", "{{.ServerVersion}}"]);
|
|
3263
|
+
return code === 0;
|
|
3264
|
+
}
|
|
3265
|
+
async function dockerRunning(nameFilter) {
|
|
3266
|
+
const { code, out } = await probe("docker", ["ps", "--filter", `name=${nameFilter}`, "--format", "{{.Names}}"]);
|
|
3267
|
+
if (code !== 0) return [];
|
|
3268
|
+
return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
3269
|
+
}
|
|
3270
|
+
function scriptBringUp(projectDir, relPath, label) {
|
|
3271
|
+
return existsSync6(join7(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
|
|
3272
|
+
}
|
|
3273
|
+
function projectLexicons(projectDir) {
|
|
3274
|
+
try {
|
|
3275
|
+
const src = readFileSync5(join7(projectDir, "chant.config.ts"), "utf-8");
|
|
3276
|
+
const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
|
|
3277
|
+
if (!m) return [];
|
|
3278
|
+
return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
|
|
3279
|
+
} catch {
|
|
3280
|
+
return [];
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
async function detectSubstrates(projectDir, preview = false, boundContext) {
|
|
3284
|
+
const subs = [];
|
|
3285
|
+
const lexicons = projectLexicons(projectDir);
|
|
3286
|
+
const docker = await dockerAvailable();
|
|
3287
|
+
subs.push({
|
|
3288
|
+
name: "docker",
|
|
3289
|
+
label: "Docker",
|
|
3290
|
+
status: docker ? "up" : "down",
|
|
3291
|
+
detail: docker ? "daemon running" : "daemon not running",
|
|
3292
|
+
bringUp: docker || platform() !== "darwin" ? void 0 : { label: "open -a Docker", cmd: "open", args: ["-a", "Docker"] }
|
|
3293
|
+
});
|
|
3294
|
+
const dep = (up, upDetail, offDetail) => !docker ? { status: "blocked", detail: "waiting on Docker" } : { status: up ? "up" : offDetail === "on-demand (pipeline run)" ? "on-demand" : "down", detail: up ? upDetail : offDetail };
|
|
3295
|
+
if (lexicons.includes("aws")) {
|
|
3296
|
+
const floci = docker ? await dockerRunning("^floci$|^chant-floci$") : [];
|
|
3297
|
+
const d = dep(floci.length > 0, "container up on :4566", "not running");
|
|
3298
|
+
subs.push({
|
|
3299
|
+
name: "floci",
|
|
3300
|
+
label: "Floci",
|
|
3301
|
+
...d,
|
|
3302
|
+
bringUp: docker && !floci.length ? scriptBringUp(projectDir, "scripts/local/local-up.sh", "local-up") : void 0
|
|
3303
|
+
});
|
|
3304
|
+
}
|
|
3305
|
+
const emulators = [
|
|
3306
|
+
["azure", "floci-az", "floci-az", "^chant-floci-az$", 4577],
|
|
3307
|
+
["gcp", "floci-gcp", "floci-gcp", "^chant-floci-gcp$", 4588]
|
|
3308
|
+
];
|
|
3309
|
+
for (const [lexicon, name, label, pattern, port] of emulators) {
|
|
3310
|
+
if (!lexicons.includes(lexicon)) continue;
|
|
3311
|
+
const running = docker ? await dockerRunning(pattern) : [];
|
|
3312
|
+
subs.push({
|
|
3313
|
+
name,
|
|
3314
|
+
label,
|
|
3315
|
+
...dep(running.length > 0, `container up on :${port}`, "not running"),
|
|
3316
|
+
bringUp: docker && !running.length ? scriptBringUp(projectDir, "scripts/local/local-up.sh", "local-up") : void 0
|
|
3317
|
+
});
|
|
3318
|
+
}
|
|
3319
|
+
if (preview) return subs;
|
|
3320
|
+
const forges = [
|
|
3321
|
+
["gitlab-ci", "GitLab CI", ".gitlab", "test/gitlab-runtime-e2e.sh"],
|
|
3322
|
+
["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
|
|
3323
|
+
];
|
|
3324
|
+
for (const [name, label, marker, script] of forges) {
|
|
3325
|
+
if (!existsSync6(join7(projectDir, marker))) continue;
|
|
3326
|
+
const c = docker ? await dockerRunning(name) : [];
|
|
3327
|
+
const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
|
|
3328
|
+
subs.push({
|
|
3329
|
+
name,
|
|
3330
|
+
label,
|
|
3331
|
+
...d,
|
|
3332
|
+
bringUp: docker ? scriptBringUp(projectDir, script, `run ${label} pipeline`) : void 0
|
|
3333
|
+
});
|
|
3334
|
+
}
|
|
3335
|
+
if (lexicons.includes("k8s")) {
|
|
3336
|
+
const k3d = await probe("k3d", ["cluster", "list", "--no-headers"]);
|
|
3337
|
+
const clusters = k3d.code === 0 ? k3d.out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : [];
|
|
3338
|
+
subs.push({
|
|
3339
|
+
name: "k3d",
|
|
3340
|
+
label: "k3d",
|
|
3341
|
+
status: k3d.code === 127 ? "unknown" : clusters.length ? "up" : "down",
|
|
3342
|
+
detail: k3d.code === 127 ? "k3d not installed" : clusters.length ? `${clusters.length} cluster(s)` : "no clusters",
|
|
3343
|
+
bringUp: docker && k3d.code !== 127 && !clusters.length ? scriptBringUp(projectDir, "scripts/local/local-up.sh", "local-up") : void 0
|
|
3344
|
+
});
|
|
3345
|
+
}
|
|
3346
|
+
if (lexicons.includes("fly")) {
|
|
3347
|
+
const endpoint = process.env.FLY_FLAPS_BASE_URL;
|
|
3348
|
+
subs.push({
|
|
3349
|
+
name: "fly",
|
|
3350
|
+
label: "Fly",
|
|
3351
|
+
status: "on-demand",
|
|
3352
|
+
detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
|
|
3353
|
+
});
|
|
3354
|
+
}
|
|
3355
|
+
if (existsSync6(join7(projectDir, ".github", "workflows"))) {
|
|
3356
|
+
const gh = await probe("gh", ["auth", "status"]);
|
|
3357
|
+
const ready = gh.code === 0;
|
|
3358
|
+
subs.push({
|
|
3359
|
+
name: "github",
|
|
3360
|
+
label: "GitHub Actions",
|
|
3361
|
+
status: ready ? "on-demand" : "blocked",
|
|
3362
|
+
detail: ready ? "workflows committed \u2014 dispatch via your gh login (\u2318K)" : gh.code === 127 ? "gh not installed \u2014 the dispatch runs through YOUR gh login" : "gh not authenticated \u2014 run `gh auth login`"
|
|
3363
|
+
});
|
|
3364
|
+
}
|
|
3365
|
+
if (lexicons.includes("temporal")) {
|
|
3366
|
+
let hasProfiles = false;
|
|
3367
|
+
try {
|
|
3368
|
+
hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync5(join7(projectDir, "chant.config.ts"), "utf-8"));
|
|
3369
|
+
} catch {
|
|
3370
|
+
}
|
|
3371
|
+
subs.push({
|
|
3372
|
+
name: "temporal",
|
|
3373
|
+
label: "Temporal",
|
|
3374
|
+
status: hasProfiles ? "on-demand" : "blocked",
|
|
3375
|
+
detail: hasProfiles ? "profiles declared \u2014 Ops run on the bound Temporal" : "no temporal.profiles in chant.config.ts \u2014 chant run will refuse"
|
|
3376
|
+
});
|
|
3377
|
+
}
|
|
3378
|
+
if (lexicons.includes("helm")) {
|
|
3379
|
+
const helm = await probe("helm", ["version", "--short"]);
|
|
3380
|
+
const ambient = helm.code === 0 && !boundContext ? await probe("kubectl", ["config", "current-context"]) : { code: 127, out: "" };
|
|
3381
|
+
const ctx = boundContext ?? (ambient.code === 0 ? ambient.out.trim() : "");
|
|
3382
|
+
subs.push({
|
|
3383
|
+
name: "helm",
|
|
3384
|
+
label: "Helm",
|
|
3385
|
+
status: helm.code === 127 ? "unknown" : ctx ? "on-demand" : "blocked",
|
|
3386
|
+
detail: helm.code === 127 ? "helm not installed" : ctx ? `${helm.out.trim()} \xB7 context ${ctx}${boundContext ? " (bound)" : ""}` : "no kube context"
|
|
3387
|
+
});
|
|
3388
|
+
}
|
|
3389
|
+
return subs;
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
// src/autosync.ts
|
|
3393
|
+
var AUTO_SYNC_MODES = ["off", "apply", "pull-request"];
|
|
3394
|
+
function isAutoSyncMode(v) {
|
|
3395
|
+
return AUTO_SYNC_MODES.includes(v);
|
|
3396
|
+
}
|
|
3397
|
+
function suspendedByRollback(rollbackBranches, movedLexicons) {
|
|
3398
|
+
return rollbackBranches.length ? new Set(movedLexicons) : /* @__PURE__ */ new Set();
|
|
3399
|
+
}
|
|
3400
|
+
function pickAutoSyncOps(mode, ops, running, movedLexicons, suspended = /* @__PURE__ */ new Set()) {
|
|
3401
|
+
if (mode === "off" || running) return { picks: [], declined: [] };
|
|
3402
|
+
const kind = mode === "apply" ? "apply" : "reconcile";
|
|
3403
|
+
const candidates = ops.filter((o) => o.kind === kind);
|
|
3404
|
+
const declined = [];
|
|
3405
|
+
const byOp = /* @__PURE__ */ new Map();
|
|
3406
|
+
for (const lexicon of [...movedLexicons].sort()) {
|
|
3407
|
+
if (mode === "pull-request" && suspended.has(lexicon)) {
|
|
3408
|
+
declined.push({ lexicon, reason: "a rollback is open for this substrate" });
|
|
3409
|
+
continue;
|
|
3410
|
+
}
|
|
3411
|
+
const exact = candidates.filter((o) => o.substrate === lexicon);
|
|
3412
|
+
const pool = exact.length ? exact : candidates.filter((o) => !o.substrate);
|
|
3413
|
+
if (pool.length === 0) {
|
|
3414
|
+
declined.push({ lexicon, reason: `no ${kind} Op declares this substrate` });
|
|
3415
|
+
continue;
|
|
3416
|
+
}
|
|
3417
|
+
if (pool.length > 1) {
|
|
3418
|
+
const names = pool.map((o) => o.name).join(", ");
|
|
3419
|
+
declined.push({ lexicon, reason: `${pool.length} ${kind} Ops match (${names})` });
|
|
3420
|
+
continue;
|
|
3421
|
+
}
|
|
3422
|
+
const op = pool[0];
|
|
3423
|
+
const pick = byOp.get(op.name);
|
|
3424
|
+
if (pick) pick.lexicons.push(lexicon);
|
|
3425
|
+
else byOp.set(op.name, { op, lexicons: [lexicon] });
|
|
3426
|
+
}
|
|
3427
|
+
return { picks: [...byOp.values()], declined };
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
// src/history.ts
|
|
3431
|
+
import { execFile as execFile3 } from "node:child_process";
|
|
3432
|
+
import { promisify as promisify3 } from "node:util";
|
|
3433
|
+
var execFileAsync = promisify3(execFile3);
|
|
3434
|
+
var SEP = "";
|
|
3435
|
+
function parseGitLog(stdout) {
|
|
3436
|
+
return stdout.split("\n").map((l) => l.trim()).filter(Boolean).map((line) => {
|
|
3437
|
+
const [sha, subject, date, author] = line.split(SEP);
|
|
3438
|
+
return { sha, subject: subject ?? "", date: date ?? "", author: author ?? "" };
|
|
3439
|
+
}).filter((c) => c.sha);
|
|
3440
|
+
}
|
|
3441
|
+
async function sourceCommits(projectDir, limit = 20) {
|
|
3442
|
+
try {
|
|
3443
|
+
const { stdout } = await execFileAsync(
|
|
3444
|
+
"git",
|
|
3445
|
+
["log", `-n${limit}`, `--format=%h${SEP}%s${SEP}%cs${SEP}%an`],
|
|
3446
|
+
{ cwd: projectDir }
|
|
3447
|
+
);
|
|
3448
|
+
return parseGitLog(stdout);
|
|
3449
|
+
} catch {
|
|
3450
|
+
return [];
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
function parseRollbackBranches(stdout, env) {
|
|
3454
|
+
const prefix = env ? `chant/rollback-${env}-` : "chant/rollback-";
|
|
3455
|
+
return stdout.split("\n").map((l) => l.replace(/^[*+]?\s*/, "").trim()).filter((l) => l.startsWith(prefix)).sort();
|
|
3456
|
+
}
|
|
3457
|
+
async function openRollbackBranches(projectDir, env) {
|
|
3458
|
+
try {
|
|
3459
|
+
const { stdout } = await execFileAsync(
|
|
3460
|
+
"git",
|
|
3461
|
+
["branch", "--list", "--all", "--format=%(refname:short)"],
|
|
3462
|
+
{ cwd: projectDir }
|
|
3463
|
+
);
|
|
3464
|
+
const stripped = stdout.split("\n").map((l) => l.trim().replace(/^[^/]+\/(?=chant\/rollback-)/, "")).join("\n");
|
|
3465
|
+
return [...new Set(parseRollbackBranches(stripped, env))];
|
|
3466
|
+
} catch {
|
|
3467
|
+
return [];
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
// src/estate.ts
|
|
3472
|
+
import { composeStacks, shortStackNames } from "@intentius/pinhole";
|
|
3473
|
+
async function composeEstate(projectDirs, opts = {}) {
|
|
3474
|
+
const names = shortStackNames(projectDirs);
|
|
3475
|
+
const stacks = await Promise.all(
|
|
3476
|
+
projectDirs.map(async (dir, i) => ({ name: names[i], ir: await graphIr(dir, opts) }))
|
|
3477
|
+
);
|
|
3478
|
+
return composeStacks(stacks);
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
// src/events.ts
|
|
3482
|
+
import { watch, existsSync as existsSync7 } from "node:fs";
|
|
3483
|
+
import { join as join8 } from "node:path";
|
|
3484
|
+
var Broadcaster = class {
|
|
3485
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3486
|
+
subscribe(fn) {
|
|
3487
|
+
this.listeners.add(fn);
|
|
3488
|
+
return () => {
|
|
3489
|
+
this.listeners.delete(fn);
|
|
3490
|
+
};
|
|
3491
|
+
}
|
|
3492
|
+
emit(type, data = "") {
|
|
3493
|
+
for (const fn of [...this.listeners]) fn(type, data);
|
|
3494
|
+
}
|
|
3495
|
+
get size() {
|
|
3496
|
+
return this.listeners.size;
|
|
3497
|
+
}
|
|
3498
|
+
};
|
|
3499
|
+
var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
|
|
3500
|
+
function watchSource(projectDir, onChange, debounceMs = 200) {
|
|
3501
|
+
const dir = existsSync7(join8(projectDir, "src")) ? join8(projectDir, "src") : projectDir;
|
|
3502
|
+
let timer;
|
|
3503
|
+
const watcher = watch(dir, { recursive: true }, (_event, file) => {
|
|
3504
|
+
const name = typeof file === "string" ? file : "";
|
|
3505
|
+
if (!name || IGNORE.test(name) || !name.endsWith(".ts")) return;
|
|
3506
|
+
clearTimeout(timer);
|
|
3507
|
+
timer = setTimeout(onChange, debounceMs);
|
|
3508
|
+
});
|
|
3509
|
+
return () => {
|
|
3510
|
+
clearTimeout(timer);
|
|
3511
|
+
watcher.close();
|
|
3512
|
+
};
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
// src/poll.ts
|
|
3516
|
+
function digestOf(nodes) {
|
|
3517
|
+
return nodes.map((n) => `${n.id}=${n.attrs?._status ?? ""}`).sort().join("\n");
|
|
3518
|
+
}
|
|
3519
|
+
var UNKNOWN_LEXICON = "";
|
|
3520
|
+
function driftDigestsByLexicon(ir) {
|
|
3521
|
+
const byLexicon = /* @__PURE__ */ new Map();
|
|
3522
|
+
for (const n of ir.nodes) {
|
|
3523
|
+
const key = n.lexicon ?? UNKNOWN_LEXICON;
|
|
3524
|
+
const bucket = byLexicon.get(key);
|
|
3525
|
+
if (bucket) bucket.push(n);
|
|
3526
|
+
else byLexicon.set(key, [n]);
|
|
3527
|
+
}
|
|
3528
|
+
const out = {};
|
|
3529
|
+
for (const [lexicon, nodes] of byLexicon) out[lexicon] = digestOf(nodes);
|
|
3530
|
+
return out;
|
|
3531
|
+
}
|
|
3532
|
+
function changedLexicons(prev, next) {
|
|
3533
|
+
const moved = /* @__PURE__ */ new Set();
|
|
3534
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(prev), ...Object.keys(next)])) {
|
|
3535
|
+
if (prev[key] !== next[key]) moved.add(key);
|
|
3536
|
+
}
|
|
3537
|
+
return [...moved].sort();
|
|
3538
|
+
}
|
|
3539
|
+
function startDriftPoll(opts) {
|
|
3540
|
+
let stopped = false;
|
|
3541
|
+
let last;
|
|
3542
|
+
let timer;
|
|
3543
|
+
const tick = async () => {
|
|
3544
|
+
try {
|
|
3545
|
+
const digests = driftDigestsByLexicon(await opts.query());
|
|
3546
|
+
if (last !== void 0) {
|
|
3547
|
+
const moved = changedLexicons(last, digests);
|
|
3548
|
+
if (moved.length) opts.onChange(moved);
|
|
3549
|
+
}
|
|
3550
|
+
last = digests;
|
|
3551
|
+
} catch (err) {
|
|
3552
|
+
opts.onError?.(err);
|
|
3553
|
+
}
|
|
3554
|
+
if (!stopped) timer = setTimeout(tick, opts.intervalMs);
|
|
3555
|
+
};
|
|
3556
|
+
timer = setTimeout(tick, opts.intervalMs);
|
|
3557
|
+
return () => {
|
|
3558
|
+
stopped = true;
|
|
3559
|
+
clearTimeout(timer);
|
|
3560
|
+
};
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
// src/frames.ts
|
|
3564
|
+
function frameDigest(ir) {
|
|
3565
|
+
const nodes = ir.nodes.map((n) => `${n.id}:${n.kind}:${n.attrs?._status ?? ""}`).sort();
|
|
3566
|
+
const edges = ir.edges.map((e) => `${e.from}>${e.to}`).sort();
|
|
3567
|
+
return `${nodes.join("|")}#${edges.join("|")}`;
|
|
3568
|
+
}
|
|
3569
|
+
var FrameBuffer = class {
|
|
3570
|
+
constructor(max = 100, now = () => Date.now()) {
|
|
3571
|
+
this.max = max;
|
|
3572
|
+
this.now = now;
|
|
3573
|
+
}
|
|
3574
|
+
max;
|
|
3575
|
+
now;
|
|
3576
|
+
frames = [];
|
|
3577
|
+
/** Capture `ir` as a frame. Skips (returns null) if identical to the last
|
|
3578
|
+
* frame's digest — only real state changes become keyframes. */
|
|
3579
|
+
capture(ir) {
|
|
3580
|
+
const digest = frameDigest(ir);
|
|
3581
|
+
const last = this.frames[this.frames.length - 1];
|
|
3582
|
+
if (last && last.digest === digest) return null;
|
|
3583
|
+
const frame = { id: String(this.seq++), t: this.now(), digest, ir };
|
|
3584
|
+
this.frames.push(frame);
|
|
3585
|
+
if (this.frames.length > this.max) this.frames.shift();
|
|
3586
|
+
return frame;
|
|
3587
|
+
}
|
|
3588
|
+
seq = 0;
|
|
3589
|
+
all() {
|
|
3590
|
+
return this.frames;
|
|
3591
|
+
}
|
|
3592
|
+
get size() {
|
|
3593
|
+
return this.frames.length;
|
|
3594
|
+
}
|
|
3595
|
+
summaries() {
|
|
3596
|
+
return this.frames.map((f) => {
|
|
3597
|
+
const byLexicon = {};
|
|
3598
|
+
for (const n of f.ir.nodes) byLexicon[n.lexicon] = (byLexicon[n.lexicon] ?? 0) + 1;
|
|
3599
|
+
return { id: f.id, t: f.t, nodes: f.ir.nodes.length, edges: f.ir.edges.length, byLexicon };
|
|
3600
|
+
});
|
|
3601
|
+
}
|
|
3602
|
+
};
|
|
3603
|
+
|
|
3604
|
+
// src/lanes.ts
|
|
3605
|
+
import { renderMorphHtml, layoutIr as layoutIr2 } from "@intentius/pinhole";
|
|
3606
|
+
function safeJson(value) {
|
|
3607
|
+
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
3608
|
+
}
|
|
3609
|
+
var LANES_CSS = `
|
|
3610
|
+
#behold-lanes { position: fixed; left: 0; right: 0; bottom: 0; background: #0d1117;
|
|
3611
|
+
border-top: 1px solid #30363d; padding: 8px 12px 10px; font: 12px ui-sans-serif, system-ui, sans-serif; color: #8b949e; }
|
|
3612
|
+
#behold-lanes .hd { display: flex; gap: 14px; align-items: baseline; margin-bottom: 4px; }
|
|
3613
|
+
#behold-lanes .hd .rt { color: #d29922; }
|
|
3614
|
+
#behold-lanes canvas { display: block; width: 100%; cursor: pointer; }
|
|
3615
|
+
#behold-diff { position: fixed; right: 12px; bottom: 156px; width: 260px; max-height: 40vh; overflow: auto;
|
|
3616
|
+
background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 10px 12px; font: 12px ui-sans-serif, system-ui, sans-serif;
|
|
3617
|
+
color: #e6edf3; display: none; }
|
|
3618
|
+
#behold-diff h4 { margin: 0 0 6px; font-size: 12px; color: #8b949e; }
|
|
3619
|
+
#behold-diff .a { color: #3fb950; } #behold-diff .r { color: #f85149; } #behold-diff .c { color: #d29922; }
|
|
3620
|
+
body { padding-bottom: 150px; }`;
|
|
3621
|
+
function laneStripScript(frames) {
|
|
3622
|
+
return `<script>
|
|
3623
|
+
const LF = ${safeJson(frames)};
|
|
3624
|
+
(function () {
|
|
3625
|
+
const host = document.getElementById("behold-lanes-canvas");
|
|
3626
|
+
if (!host || LF.length < 1) return;
|
|
3627
|
+
const subs = [...new Set(LF.flatMap(f => Object.keys(f.byLexicon)))].sort();
|
|
3628
|
+
const rowH = 22, padL = 96, padR = 16, padT = 6;
|
|
3629
|
+
const H = padT + subs.length * rowH + 24;
|
|
3630
|
+
const t0 = LF[0].t, tN = LF[LF.length - 1].t, span = Math.max(1, tN - t0);
|
|
3631
|
+
const offset = {}; // per-substrate time offset (graph-inert)
|
|
3632
|
+
let cur = LF.length - 1, focus = null, pair = null;
|
|
3633
|
+
|
|
3634
|
+
function baseX(i) { const W = host.clientWidth - padL - padR;
|
|
3635
|
+
const frac = span > 1 ? (LF[i].t - t0) / span : (LF.length > 1 ? i / (LF.length - 1) : 0); return padL + frac * W; }
|
|
3636
|
+
function xOf(i, sub) { const W = host.clientWidth - padL - padR; return baseX(i) + ((offset[sub] || 0) / span) * W; }
|
|
3637
|
+
const anyOffset = () => subs.some(s => offset[s]);
|
|
3638
|
+
|
|
3639
|
+
function changedAt(id, i) { // node changed vs previous frame (appear/vanish/status)
|
|
3640
|
+
const now = LF[i].status[id], prev = i > 0 ? LF[i-1].status[id] : undefined;
|
|
3641
|
+
const inNow = now !== undefined, inPrev = prev !== undefined;
|
|
3642
|
+
return inNow !== inPrev || (inNow && inPrev && now !== prev);
|
|
3643
|
+
}
|
|
3644
|
+
const color = s => s === "good" ? "#3fb950" : s === "warn" ? "#d29922" : s === "accent" ? "#58a6ff" : "#6e7681";
|
|
3645
|
+
|
|
3646
|
+
function draw() {
|
|
3647
|
+
const dpr = window.devicePixelRatio || 1;
|
|
3648
|
+
host.width = host.clientWidth * dpr; host.height = H * dpr; host.style.height = H + "px";
|
|
3649
|
+
const c = host.getContext("2d"); c.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
3650
|
+
c.clearRect(0, 0, host.clientWidth, H); c.font = "12px ui-sans-serif, system-ui, sans-serif";
|
|
3651
|
+
subs.forEach((s, r) => {
|
|
3652
|
+
const y = padT + r * rowH + rowH / 2;
|
|
3653
|
+
c.fillStyle = offset[s] ? "#d29922" : "#8b949e"; c.textAlign = "left"; c.fillText(s, 8, y + 4);
|
|
3654
|
+
c.strokeStyle = "#21262d"; c.beginPath(); c.moveTo(padL, y); c.lineTo(host.clientWidth - padR, y); c.stroke();
|
|
3655
|
+
LF.forEach((f, i) => {
|
|
3656
|
+
if (!f.byLexicon[s]) return;
|
|
3657
|
+
// dot per substrate; brighter when a node in this substrate changed at i
|
|
3658
|
+
const changed = Object.keys(f.status).some(id => f.lexicon[id] === s && changedAt(id, i));
|
|
3659
|
+
const hi = focus && f.lexicon[focus] === s && changedAt(focus, i);
|
|
3660
|
+
c.fillStyle = hi ? "#f0f6fc" : changed ? color(mode(f, s)) : "#30363d";
|
|
3661
|
+
c.beginPath(); c.arc(xOf(i, s), y, i === cur ? 5 : hi ? 4.5 : 3.5, 0, 7); c.fill();
|
|
3662
|
+
});
|
|
3663
|
+
});
|
|
3664
|
+
const px = baseX(cur); c.strokeStyle = "#58a6ff"; c.lineWidth = 1.5;
|
|
3665
|
+
c.beginPath(); c.moveTo(px, padT - 2); c.lineTo(px, padT + subs.length * rowH); c.stroke();
|
|
3666
|
+
if (pair != null) { const qx = baseX(pair); c.strokeStyle = "#d29922"; c.setLineDash([3,3]);
|
|
3667
|
+
c.beginPath(); c.moveTo(qx, padT - 2); c.lineTo(qx, padT + subs.length * rowH); c.stroke(); c.setLineDash([]); }
|
|
3668
|
+
c.fillStyle = "#e6edf3"; c.textAlign = "center"; c.fillText(LF[cur].name, px, padT + subs.length * rowH + 16);
|
|
3669
|
+
document.getElementById("behold-lanes-meta").textContent =
|
|
3670
|
+
LF.length + " frames \xB7 frame " + (cur + 1) + "/" + LF.length + (focus ? " \xB7 focus " + focus : "");
|
|
3671
|
+
document.getElementById("behold-lanes-rt").style.display = anyOffset() ? "inline" : "none";
|
|
3672
|
+
}
|
|
3673
|
+
function mode(f, s) { for (const id in f.status) if (f.lexicon[id] === s) return f.status[id]; return ""; }
|
|
3674
|
+
|
|
3675
|
+
function nearest(clientX) { const rect = host.getBoundingClientRect(); const x = clientX - rect.left;
|
|
3676
|
+
let best = 0, bd = Infinity; for (let i = 0; i < LF.length; i++) { const d = Math.abs(baseX(i) - x); if (d < bd) { bd = d; best = i; } } return best; }
|
|
3677
|
+
function rowAt(clientY) { const rect = host.getBoundingClientRect(); const r = Math.floor((clientY - rect.top - padT) / rowH); return subs[r]; }
|
|
3678
|
+
function go(i) { cur = Math.max(0, Math.min(LF.length - 1, i)); if (window.applyView) window.applyView(cur); draw(); }
|
|
3679
|
+
|
|
3680
|
+
function showDiff() {
|
|
3681
|
+
const panel = document.getElementById("behold-diff");
|
|
3682
|
+
if (pair == null) { panel.style.display = "none"; return; }
|
|
3683
|
+
const a = LF[Math.min(cur, pair)].status, b = LF[Math.max(cur, pair)].status;
|
|
3684
|
+
const added = [], removed = [], changed = [];
|
|
3685
|
+
for (const id of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
3686
|
+
if (!(id in a)) added.push(id); else if (!(id in b)) removed.push(id); else if (a[id] !== b[id]) changed.push(id);
|
|
3687
|
+
}
|
|
3688
|
+
panel.innerHTML = "<h4>frame diff " + (Math.min(cur,pair)+1) + " \u2192 " + (Math.max(cur,pair)+1) + "</h4>" +
|
|
3689
|
+
added.map(x => '<div class="a">+ ' + x + '</div>').join("") +
|
|
3690
|
+
removed.map(x => '<div class="r">- ' + x + '</div>').join("") +
|
|
3691
|
+
changed.map(x => '<div class="c">~ ' + x + '</div>').join("") ||
|
|
3692
|
+
"<h4>frame diff</h4><div>no change</div>";
|
|
3693
|
+
panel.style.display = "block";
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
// playhead (time cursor) + shift-click = pair diff + drag a row = offset (graph-inert)
|
|
3697
|
+
let dragRow = null;
|
|
3698
|
+
host.addEventListener("mousedown", (e) => {
|
|
3699
|
+
if (e.clientX - host.getBoundingClientRect().left < padL) { dragRow = rowAt(e.clientY); return; } // label gutter \u2192 offset drag
|
|
3700
|
+
if (e.shiftKey) { pair = nearest(e.clientX); showDiff(); draw(); }
|
|
3701
|
+
else { pair = null; showDiff(); go(nearest(e.clientX)); }
|
|
3702
|
+
});
|
|
3703
|
+
window.addEventListener("mousemove", (e) => { if (dragRow) { const W = host.clientWidth - padL - padR;
|
|
3704
|
+
offset[dragRow] = ((e.movementX) / W) * span + (offset[dragRow] || 0); draw(); } });
|
|
3705
|
+
window.addEventListener("mouseup", () => (dragRow = null));
|
|
3706
|
+
window.addEventListener("keydown", (e) => { if (e.key === "ArrowLeft") go(cur - 1); if (e.key === "ArrowRight") go(cur + 1);
|
|
3707
|
+
if (e.key === "Escape") { focus = null; pair = null; showDiff(); draw(); } });
|
|
3708
|
+
window.addEventListener("resize", draw);
|
|
3709
|
+
|
|
3710
|
+
// focus cursor: click a graph node \u2192 highlight where it changed (graph \u2192 lanes)
|
|
3711
|
+
function wireNodes() { document.querySelectorAll("[data-node-id]").forEach(el => {
|
|
3712
|
+
el.style.cursor = "pointer";
|
|
3713
|
+
el.addEventListener("click", () => { focus = el.getAttribute("data-node-id"); draw(); }, true); }); }
|
|
3714
|
+
wireNodes();
|
|
3715
|
+
|
|
3716
|
+
go(LF.length - 1);
|
|
3717
|
+
})();
|
|
3718
|
+
</script>`;
|
|
3719
|
+
}
|
|
3720
|
+
function renderLanes(frames, summaries) {
|
|
3721
|
+
const views = frames.map((f, i) => ({
|
|
3722
|
+
name: summaries[i]?.t ? new Date(summaries[i].t).toISOString().slice(11, 19) : `t${i}`,
|
|
3723
|
+
ir: f.ir,
|
|
3724
|
+
layout: layoutIr2(f.ir)
|
|
3725
|
+
}));
|
|
3726
|
+
const doc = renderMorphHtml(views, { title: "Deployment lanes" });
|
|
3727
|
+
const laneFrames = frames.map((f, i) => ({
|
|
3728
|
+
t: summaries[i].t,
|
|
3729
|
+
name: new Date(summaries[i].t).toISOString().slice(11, 19),
|
|
3730
|
+
byLexicon: summaries[i].byLexicon,
|
|
3731
|
+
status: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.attrs?._status ?? ""])),
|
|
3732
|
+
lexicon: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.lexicon]))
|
|
3733
|
+
}));
|
|
3734
|
+
const strip = `<style>${LANES_CSS}</style><div id="behold-diff"></div><div id="behold-lanes"><div class="hd"><span>deployment lanes</span><span id="behold-lanes-meta"></span><span class="rt" id="behold-lanes-rt" style="display:none">offset \u2014 graph shows real time</span></div><canvas id="behold-lanes-canvas"></canvas></div>` + laneStripScript(laneFrames);
|
|
3735
|
+
return doc.includes("</body>") ? doc.replace("</body>", `${strip}</body>`) : doc + strip;
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
// src/emulator.ts
|
|
3739
|
+
function parseEmulators(stdout) {
|
|
3740
|
+
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
3741
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
3742
|
+
if (!lines[i].startsWith("{")) continue;
|
|
3743
|
+
try {
|
|
3744
|
+
const parsed = JSON.parse(lines[i]);
|
|
3745
|
+
if (Array.isArray(parsed.emulators)) return parsed.emulators;
|
|
3746
|
+
} catch {
|
|
3747
|
+
}
|
|
3748
|
+
}
|
|
3749
|
+
return [];
|
|
3750
|
+
}
|
|
3751
|
+
function mergedEnv(emulators) {
|
|
3752
|
+
return Object.assign({}, ...emulators.map((e) => e.env));
|
|
3753
|
+
}
|
|
3754
|
+
function dockerHint(stderr) {
|
|
3755
|
+
const s = stderr.toLowerCase();
|
|
3756
|
+
if (s.includes("docker") || s.includes("enoent") || s.includes("cannot connect") || s.includes("command not found")) {
|
|
3757
|
+
return "behold serve --local needs Docker running (the emulator is a container). Start Docker and retry.";
|
|
3758
|
+
}
|
|
3759
|
+
return void 0;
|
|
3760
|
+
}
|
|
3761
|
+
async function emulatorUp(projectDir) {
|
|
3762
|
+
const { code, stdout, stderr } = await runChantRaw(["emulator", "up", "--json"], projectDir);
|
|
3763
|
+
if (code !== 0) {
|
|
3764
|
+
throw new Error(dockerHint(stderr) ?? `chant emulator up failed (exit ${code}): ${stderr.trim() || "no output"}`);
|
|
3765
|
+
}
|
|
3766
|
+
return parseEmulators(stdout);
|
|
3767
|
+
}
|
|
3768
|
+
async function emulatorDown(projectDir) {
|
|
3769
|
+
await runChantRaw(["emulator", "down"], projectDir);
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3772
|
+
// src/server.ts
|
|
3773
|
+
var webRoot = join9(dirname2(fileURLToPath(import.meta.url)), "..", "web");
|
|
3774
|
+
var execFileP = async (cmd, args) => (await promisify4(execFile4)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
|
|
3775
|
+
function optsFromQuery(url, tierEnvVar, projectDir) {
|
|
3776
|
+
const q = url.searchParams;
|
|
3777
|
+
const opts = {};
|
|
3778
|
+
const detail = q.get("detail");
|
|
3779
|
+
if (detail !== null) opts.detail = Number(detail);
|
|
3780
|
+
const lens = q.get("lens");
|
|
3781
|
+
if (lens) opts.lens = lens;
|
|
3782
|
+
if (q.get("up") === "1") opts.up = true;
|
|
3783
|
+
if (q.get("down") === "1") opts.down = true;
|
|
3784
|
+
const env = q.get("env");
|
|
3785
|
+
if (env) opts.env = env;
|
|
3786
|
+
const stack = q.get("stack");
|
|
3787
|
+
if (stack) opts.stack = stack;
|
|
3788
|
+
const tier = q.get("tier");
|
|
3789
|
+
if (tier) opts.tier = tier;
|
|
3790
|
+
if (tierEnvVar) opts.tierEnvVar = tierEnvVar;
|
|
3791
|
+
const target = q.get("target");
|
|
3792
|
+
if (target) {
|
|
3793
|
+
opts.target = target;
|
|
3794
|
+
if (projectDir) opts.substrateTargets = resolveSubstrateTargets(projectLexicons(projectDir));
|
|
3795
|
+
}
|
|
3796
|
+
return opts;
|
|
3797
|
+
}
|
|
3798
|
+
function tierTargetOpts(opts) {
|
|
3799
|
+
const out = {};
|
|
3800
|
+
if (opts.tier) out.tier = opts.tier;
|
|
3801
|
+
if (opts.tierEnvVar) out.tierEnvVar = opts.tierEnvVar;
|
|
3802
|
+
if (opts.target) out.target = opts.target;
|
|
3803
|
+
return out;
|
|
3804
|
+
}
|
|
3805
|
+
async function knownComponents(projectDir, opts) {
|
|
3806
|
+
try {
|
|
3807
|
+
const ir = await componentGraphIr(projectDir, { env: opts.env, ...tierTargetOpts(opts) });
|
|
3808
|
+
return new Set(ir.nodes.map((n) => n.id));
|
|
3809
|
+
} catch {
|
|
3810
|
+
return void 0;
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
function tierFailure(tier, message) {
|
|
3814
|
+
return {
|
|
3815
|
+
code: "tier",
|
|
3816
|
+
error: `chant couldn't evaluate the "${tier}" tier here: ${message}`,
|
|
3817
|
+
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
|
+
};
|
|
3819
|
+
}
|
|
3820
|
+
function errorResponse(c, opts, err) {
|
|
3821
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3822
|
+
const failure = err instanceof ChantCliError ? err.failure : classifyChantFailure(message);
|
|
3823
|
+
const routeError = opts.tier && failure.code !== "not-installed" ? tierFailure(opts.tier, failure.message) : { error: failure.message, code: failure.code, remedy: failure.remedy };
|
|
3824
|
+
return c.json(routeError, 500);
|
|
3825
|
+
}
|
|
3826
|
+
function deployAxes(tierEnvVar, lexicons = [], k8sTarget) {
|
|
3827
|
+
const axes = {};
|
|
3828
|
+
if (tierEnvVar && process.env[tierEnvVar]) axes.tier = process.env[tierEnvVar];
|
|
3829
|
+
const targets = [...resolveSubstrateTargets(lexicons), ...k8sTarget ? [k8sTarget] : []];
|
|
3830
|
+
if (targets.length === 1) axes.target = targets[0].endpoint;
|
|
3831
|
+
else if (targets.length > 1) axes.target = targets.map((t) => `${t.label}=${t.endpoint}`).join(" ");
|
|
3832
|
+
return axes;
|
|
3833
|
+
}
|
|
3834
|
+
function deployTargets(lexicons = [], k8sTarget) {
|
|
3835
|
+
return [...resolveSubstrateTargets(lexicons), ...k8sTarget ? [k8sTarget] : []].map((t) => ({
|
|
3836
|
+
name: t.label,
|
|
3837
|
+
endpoint: t.endpoint
|
|
3838
|
+
}));
|
|
3839
|
+
}
|
|
3840
|
+
async function captureFrame(projectDir, env, frames, broadcaster) {
|
|
3841
|
+
try {
|
|
3842
|
+
const ir = await graphIr(projectDir, env ? { live: true, overlay: true, env } : {});
|
|
3843
|
+
const captured = frames.capture(ir) !== null;
|
|
3844
|
+
if (captured) broadcaster.emit("frames");
|
|
3845
|
+
return { ir, captured };
|
|
3846
|
+
} catch (err) {
|
|
3847
|
+
process.stderr.write(`frame capture: ${err instanceof Error ? err.message : String(err)}
|
|
3848
|
+
`);
|
|
3849
|
+
return null;
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffer(), runner = new OpRunner({
|
|
3853
|
+
projectDir: cfg.projectDir,
|
|
3854
|
+
broadcaster,
|
|
3855
|
+
onDone: (opEnv) => captureFrame(cfg.projectDir, opEnv ?? cfg.env, frames, broadcaster)
|
|
3856
|
+
})) {
|
|
3857
|
+
const app = new Hono();
|
|
3858
|
+
const beholdConfig = loadBeholdConfig(cfg.projectDir);
|
|
3859
|
+
const tierEnvVar = beholdConfig.tiers?.envVar;
|
|
3860
|
+
const boundK8sContext = async (env) => {
|
|
3861
|
+
try {
|
|
3862
|
+
const { lexicons, k8sProfiles } = await detectProject(cfg.projectDir);
|
|
3863
|
+
if (!lexicons.includes("k8s")) return void 0;
|
|
3864
|
+
return resolveK8sTarget(k8sProfiles, env ?? cfg.env, await loadKubeconfig())?.label;
|
|
3865
|
+
} catch {
|
|
3866
|
+
return void 0;
|
|
3867
|
+
}
|
|
3868
|
+
};
|
|
3869
|
+
app.get("/healthz", (c) => c.json({ ok: true, projectDir: cfg.projectDir, env: cfg.env ?? null, frames: frames.size }));
|
|
3870
|
+
app.get("/api/frames", (c) => c.json({ frames: frames.summaries() }));
|
|
3871
|
+
app.get("/lanes", (c) => {
|
|
3872
|
+
const all = frames.all();
|
|
3873
|
+
if (all.length < 2) {
|
|
3874
|
+
return c.html(
|
|
3875
|
+
`<!doctype html><meta charset=utf-8><body style="font:14px system-ui;background:#0d1117;color:#8b949e;padding:2rem"><h3 style="color:#e6edf3">deployment lanes</h3><p>${all.length} frame(s) captured \u2014 need at least two to scrub.</p><p>Frames accrue when the estate moves: hit <b style="color:#e6edf3">\u21BB Refresh</b> (captures the current live state), run a <b style="color:#e6edf3">Sync</b>/Adopt, edit the source, or serve with <code>--poll</code> against a moving environment. Then reload.</p><p><a href="/" style="color:#58a6ff;text-decoration:none">\u2190 back to the graph</a></p></body>`
|
|
3876
|
+
);
|
|
3877
|
+
}
|
|
3878
|
+
return c.html(renderLanes(all, frames.summaries()));
|
|
3879
|
+
});
|
|
3880
|
+
const estateDirs = cfg.projectDirs ?? [cfg.projectDir];
|
|
3881
|
+
const estateOps = () => discoverEstateOps(estateDirs);
|
|
3882
|
+
app.get(
|
|
3883
|
+
"/api/ops",
|
|
3884
|
+
(c) => c.json({
|
|
3885
|
+
ops: estateOps(),
|
|
3886
|
+
running: runner.running,
|
|
3887
|
+
// The substrates Adopt is offered on — the SPA gates the per-node button on
|
|
3888
|
+
// this so the "which lexicons live-import" truth stays server-side.
|
|
3889
|
+
adoptLexicons: LIVE_IMPORT_LEXICONS,
|
|
3890
|
+
// Auto-sync mode (#29), so the SPA can show the banner.
|
|
3891
|
+
autoSync: cfg.autoSync ?? "off",
|
|
3892
|
+
// Local mode (#46): the booted emulators, so the SPA shows a "local · up"
|
|
3893
|
+
// banner. null when not in --local (or nothing to boot).
|
|
3894
|
+
local: cfg.emulators && cfg.emulators.length ? { emulators: cfg.emulators.map((e) => ({ lexicon: e.lexicon, name: e.name, endpoint: e.endpoint })) } : null,
|
|
3895
|
+
// M3 (#54): the last known apply progress model, so a client that opens
|
|
3896
|
+
// (or reloads) mid-apply hydrates the structured wave/phase view instead
|
|
3897
|
+
// of starting blank — the `apply` SSE event (below) carries every update
|
|
3898
|
+
// after that. `status: "idle"` (initialApplyProgress) when nothing has
|
|
3899
|
+
// applied yet this session.
|
|
3900
|
+
applyProgress: runner.applyProgress
|
|
3901
|
+
})
|
|
3902
|
+
);
|
|
3903
|
+
app.post("/api/ops/:name/run", (c) => {
|
|
3904
|
+
if (cfg.previewMode) return c.json({ error: "disabled in preview mode" }, 403);
|
|
3905
|
+
const name = c.req.param("name");
|
|
3906
|
+
const info = estateOps().find((o) => o.name === name);
|
|
3907
|
+
if (!info) {
|
|
3908
|
+
return c.json({ error: `no Op named "${name}" in the estate` }, 404);
|
|
3909
|
+
}
|
|
3910
|
+
if (!runner.trigger(name, info.env, info.dir)) {
|
|
3911
|
+
return c.json({ error: `an Op is already running (${runner.running})` }, 409);
|
|
3912
|
+
}
|
|
3913
|
+
return c.json({ started: true, name });
|
|
3914
|
+
});
|
|
3915
|
+
app.get("/api/substrates", async (c) => {
|
|
3916
|
+
return c.json({ substrates: await detectSubstrates(cfg.projectDir, cfg.previewMode, await boundK8sContext(cfg.env)) });
|
|
3917
|
+
});
|
|
3918
|
+
app.post("/api/substrates/:name/up", async (c) => {
|
|
3919
|
+
const name = c.req.param("name");
|
|
3920
|
+
const sub = (await detectSubstrates(cfg.projectDir, cfg.previewMode)).find((s) => s.name === name);
|
|
3921
|
+
if (!sub) return c.json({ error: `unknown substrate "${name}"` }, 404);
|
|
3922
|
+
if (!sub.bringUp) return c.json({ error: `no bring-up available for "${name}"` }, 400);
|
|
3923
|
+
const { label, cmd, args } = sub.bringUp;
|
|
3924
|
+
const PIPELINE_FORGES = { "gitlab-ci": "gitlab", forgejo: "forgejo" };
|
|
3925
|
+
const forge = PIPELINE_FORGES[name];
|
|
3926
|
+
if (forge) {
|
|
3927
|
+
const parsed = await ciPipeline(cfg.projectDir, { env: cfg.env }, forge).catch(() => void 0);
|
|
3928
|
+
if (parsed && parsed.jobs.length > 0) {
|
|
3929
|
+
if (!runner.pipeline(`${sub.label} pipeline`, cmd, args, cfg.projectDir, parsed)) {
|
|
3930
|
+
return c.json({ error: `busy \u2014 ${runner.running} is running` }, 409);
|
|
3931
|
+
}
|
|
3932
|
+
return c.json({ started: true, name, ran: label, pipeline: { stages: parsed.stages.length, jobs: parsed.jobs.length } });
|
|
3933
|
+
}
|
|
3934
|
+
}
|
|
3935
|
+
if (!runner.bringUp(`bring up ${sub.label}`, cmd, args, cfg.projectDir)) {
|
|
3936
|
+
return c.json({ error: `busy \u2014 ${runner.running} is running` }, 409);
|
|
3937
|
+
}
|
|
3938
|
+
return c.json({ started: true, name, ran: label });
|
|
3939
|
+
});
|
|
3940
|
+
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)) {
|
|
3944
|
+
return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
|
|
3945
|
+
}
|
|
3946
|
+
if (!runner.bringUp("reset local emulator", "bash", ["-c", "bash scripts/local/local-down.sh && bash scripts/local/local-up.sh"], cfg.projectDir)) {
|
|
3947
|
+
return c.json({ error: `busy \u2014 ${runner.running} is running` }, 409);
|
|
3948
|
+
}
|
|
3949
|
+
return c.json({ started: true, ran: "local-down + local-up" });
|
|
3950
|
+
});
|
|
3951
|
+
app.post("/api/ops/:name/signal/:gate", async (c) => {
|
|
3952
|
+
const { name, gate } = c.req.param();
|
|
3953
|
+
const info = estateOps().find((o) => o.name === name);
|
|
3954
|
+
broadcaster.emit("op", `\u270E signal ${name} ${gate}`);
|
|
3955
|
+
const { code, stderr } = await runChantRaw(["run", "signal", name, gate], info?.dir ?? cfg.projectDir);
|
|
3956
|
+
if (code !== 0) return c.json({ error: stderr.trim() || `signal exited ${code}` }, 500);
|
|
3957
|
+
return c.json({ signalled: true });
|
|
3958
|
+
});
|
|
3959
|
+
app.get(
|
|
3960
|
+
"/api/events",
|
|
3961
|
+
(c) => streamSSE(c, async (stream) => {
|
|
3962
|
+
const unsubscribe = broadcaster.subscribe((type, data) => {
|
|
3963
|
+
void stream.writeSSE({ event: type, data: data || String(Date.now()) });
|
|
3964
|
+
});
|
|
3965
|
+
stream.onAbort(unsubscribe);
|
|
3966
|
+
while (!stream.aborted) {
|
|
3967
|
+
await stream.writeSSE({ event: "ping", data: "" });
|
|
3968
|
+
await stream.sleep(3e4);
|
|
3969
|
+
}
|
|
3970
|
+
unsubscribe();
|
|
3971
|
+
})
|
|
3972
|
+
);
|
|
3973
|
+
app.get("/api/project", async (c) => {
|
|
3974
|
+
const { environments, lexicons, stacks, k8sProfiles } = await detectProject(cfg.projectDir);
|
|
3975
|
+
const k8sTarget = lexicons.includes("k8s") ? resolveK8sTarget(k8sProfiles, cfg.env, await loadKubeconfig()) : void 0;
|
|
3976
|
+
const axes = deployAxes(tierEnvVar, lexicons, k8sTarget);
|
|
3977
|
+
return c.json({
|
|
3978
|
+
projectDir: cfg.projectDir,
|
|
3979
|
+
environments,
|
|
3980
|
+
lexicons,
|
|
3981
|
+
currentEnv: cfg.env ?? null,
|
|
3982
|
+
// v0.1.0 preview: the SPA hides git/PR ops + arbitrary-project affordances.
|
|
3983
|
+
...cfg.previewMode ? { previewMode: true } : {},
|
|
3984
|
+
// The tier picker's options (M2 #54, sourced #70): gated on the served
|
|
3985
|
+
// project's `.behold.json` declaring a `tiers` block at all — NOT on
|
|
3986
|
+
// whether its env var happens to be set in behold's own launch env
|
|
3987
|
+
// (that's `axes.tier`, the *current* value, below). No `.behold.json` (or
|
|
3988
|
+
// no `tiers` key) → no `tiers` field → the SPA's picker doesn't render
|
|
3989
|
+
// and the graph loads with no tier selected (web/app.js `initPickers`).
|
|
3990
|
+
...beholdConfig.tiers ? { tiers: beholdConfig.tiers.values } : {},
|
|
3991
|
+
// The stack picker's options (#76, follow-up to #71): gated exactly like
|
|
3992
|
+
// `tiers` above — only present when `chant.config.ts` declares `stacks[]`
|
|
3993
|
+
// at all, so a single-stack/sourceDir-only/legacy project's SPA renders no
|
|
3994
|
+
// picker (web/app.js `initPickers` mirrors the `info.tiers` gate). Just
|
|
3995
|
+
// the names — `graphPath` (chant.ts) resolves a picked name to its `src`
|
|
3996
|
+
// server-side; the SPA never needs the path.
|
|
3997
|
+
...stacks?.length ? { stacks: stacks.map((s) => s.name) } : {},
|
|
3998
|
+
targets: deployTargets(lexicons, k8sTarget),
|
|
3999
|
+
// Where the k8s binding came from, so the SPA never implies behold chose
|
|
4000
|
+
// it (#106). Absent for a project with no k8s lexicon or no resolvable
|
|
4001
|
+
// context.
|
|
4002
|
+
...k8sTarget ? { k8sBinding: { context: k8sTarget.label, endpoint: k8sTarget.endpoint, source: k8sTarget.source } } : {},
|
|
4003
|
+
...axes
|
|
4004
|
+
});
|
|
4005
|
+
});
|
|
4006
|
+
app.get("/api/graph", async (c) => {
|
|
4007
|
+
const url = new URL(c.req.url);
|
|
4008
|
+
const opts = optsFromQuery(url, tierEnvVar, cfg.projectDir);
|
|
4009
|
+
try {
|
|
4010
|
+
const components = url.searchParams.get("components") === "1";
|
|
4011
|
+
const logical = url.searchParams.get("logical") === "1";
|
|
4012
|
+
const multi = cfg.projectDirs && cfg.projectDirs.length > 1;
|
|
4013
|
+
let ir;
|
|
4014
|
+
let mode;
|
|
4015
|
+
let metaEnv = cfg.env ?? null;
|
|
4016
|
+
if (multi) {
|
|
4017
|
+
ir = await composeEstate(cfg.projectDirs, opts);
|
|
4018
|
+
} else if (components) {
|
|
4019
|
+
ir = await componentGraphIr(cfg.projectDir, opts);
|
|
4020
|
+
const env = opts.env ?? cfg.env;
|
|
4021
|
+
if (env) {
|
|
4022
|
+
const rows = await componentStatus(cfg.projectDir, env, opts);
|
|
4023
|
+
ir = joinComponentStatus(ir, rows);
|
|
4024
|
+
mode = "component-status";
|
|
4025
|
+
metaEnv = env;
|
|
4026
|
+
}
|
|
4027
|
+
ir = joinCiProgress(ir, runner.applyProgress);
|
|
4028
|
+
} else if (logical) {
|
|
4029
|
+
const raw = mergeClusterRoot(await graphIr(cfg.projectDir, { ...opts, detail: 3 }), await clusterRootGraphIr(cfg.projectDir, opts));
|
|
4030
|
+
const base = addK8sDeclaredEdges(addValueMatchEdges(raw));
|
|
4031
|
+
const { ir: projected, byContainer } = projectTopology(base, metaEnv ?? void 0, await boundK8sContext(metaEnv ?? void 0), [await graphPath(cfg.projectDir, opts), cfg.projectDir]);
|
|
4032
|
+
const { svg: svg2 } = renderArchitecture(projected, byContainer);
|
|
4033
|
+
const logicalNote = notesFor("logical", projected, void 0, base.nodes.length);
|
|
4034
|
+
return c.json({ ir: projected, svg: svg2, byContainer, meta: { projectDir: cfg.projectDir, env: metaEnv, tier: opts.tier ?? null, target: opts.target ?? null, mode: "logical", ...logicalNote ? { note: logicalNote } : {} } });
|
|
4035
|
+
} else {
|
|
4036
|
+
ir = mergeClusterRoot(await graphIr(cfg.projectDir, opts), await clusterRootGraphIr(cfg.projectDir, opts));
|
|
4037
|
+
if ((opts.detail ?? 2) < 3) ir = pruneImports(ir);
|
|
4038
|
+
ir = addValueMatchEdges(ir);
|
|
4039
|
+
ir = addK8sDeclaredEdges(ir);
|
|
4040
|
+
ir = addClusterAnchorEdges(ir, await boundK8sContext(metaEnv ?? void 0));
|
|
4041
|
+
}
|
|
4042
|
+
let srcCompositeEdgesAttached;
|
|
4043
|
+
if (!multi && !components && opts.detail === 1) {
|
|
4044
|
+
try {
|
|
4045
|
+
const dag = await componentGraphIr(cfg.projectDir, tierTargetOpts(opts));
|
|
4046
|
+
const counted = addCompositeDepsCounted(ir, dag);
|
|
4047
|
+
ir = counted.ir;
|
|
4048
|
+
srcCompositeEdgesAttached = counted.attached;
|
|
4049
|
+
} catch {
|
|
4050
|
+
srcCompositeEdgesAttached = 0;
|
|
4051
|
+
}
|
|
4052
|
+
}
|
|
4053
|
+
const radial = new URL(c.req.url).searchParams.get("radial") === "1";
|
|
4054
|
+
const { svg } = renderGraph(ir, multi ? { boxes: "byStack" } : { radial });
|
|
4055
|
+
const srcZoom = components ? "components" : logical ? "logical" : opts.detail === 1 ? "composites" : opts.detail === 3 ? "attributes" : "resources";
|
|
4056
|
+
const srcNote = multi ? void 0 : notesFor(srcZoom, ir, srcCompositeEdgesAttached);
|
|
4057
|
+
return c.json({
|
|
4058
|
+
ir,
|
|
4059
|
+
svg,
|
|
4060
|
+
meta: {
|
|
4061
|
+
projectDir: cfg.projectDir,
|
|
4062
|
+
env: metaEnv,
|
|
4063
|
+
...srcNote ? { note: srcNote } : {},
|
|
4064
|
+
// The picked tier/target (M2, #54), echoed back so the SPA can keep
|
|
4065
|
+
// its header's axes display in sync with what it's actually looking
|
|
4066
|
+
// at, not just the launch-time value. null when neither was picked.
|
|
4067
|
+
tier: opts.tier ?? null,
|
|
4068
|
+
target: opts.target ?? null,
|
|
4069
|
+
...multi ? { estate: cfg.projectDirs.length } : {},
|
|
4070
|
+
...!multi && components ? { components: true } : {},
|
|
4071
|
+
...mode ? { mode } : {}
|
|
4072
|
+
}
|
|
4073
|
+
});
|
|
4074
|
+
} catch (err) {
|
|
4075
|
+
return errorResponse(c, opts, err);
|
|
4076
|
+
}
|
|
4077
|
+
});
|
|
4078
|
+
app.get("/api/ci", async (c) => {
|
|
4079
|
+
const opts = optsFromQuery(new URL(c.req.url), tierEnvVar, cfg.projectDir);
|
|
4080
|
+
const env = opts.env ?? cfg.env;
|
|
4081
|
+
const { lexicons } = await detectProject(cfg.projectDir);
|
|
4082
|
+
const forge = ciForgeFor(lexicons);
|
|
4083
|
+
if (!forge) return c.json({ stages: [], jobs: [], forge: null });
|
|
4084
|
+
try {
|
|
4085
|
+
const { stages, jobs } = await ciPipeline(
|
|
4086
|
+
cfg.projectDir,
|
|
4087
|
+
{ ...tierTargetOpts(opts), ...env ? { env } : {} },
|
|
4088
|
+
forge
|
|
4089
|
+
);
|
|
4090
|
+
return c.json({ stages, jobs, forge });
|
|
4091
|
+
} catch (err) {
|
|
4092
|
+
return errorResponse(c, opts, err);
|
|
4093
|
+
}
|
|
4094
|
+
});
|
|
4095
|
+
app.post("/api/ci/dispatch", async (c) => {
|
|
4096
|
+
const ready = await ghReady();
|
|
4097
|
+
if (!ready.ok) return c.json({ error: ready.reason }, 400);
|
|
4098
|
+
const pipeline = await ciPipeline(cfg.projectDir, cfg.env ? { env: cfg.env } : {}, "github").catch(() => void 0);
|
|
4099
|
+
if (!pipeline || pipeline.jobs.length === 0) {
|
|
4100
|
+
return c.json({ error: "no generated GitHub pipeline \u2014 `chant build --components --generate github` produced no jobs" }, 400);
|
|
4101
|
+
}
|
|
4102
|
+
const workflow = pickWorkflow(cfg.projectDir, pipeline);
|
|
4103
|
+
if (!workflow) {
|
|
4104
|
+
return c.json({ error: "no committed workflow_dispatch workflow whose jobs match the generated pipeline \u2014 commit one (or add `workflow_dispatch:` to its `on:` block)" }, 400);
|
|
4105
|
+
}
|
|
4106
|
+
const ref = await execFileP("git", ["-C", cfg.projectDir, "branch", "--show-current"]).then((out) => out.trim() || "main").catch(() => "main");
|
|
4107
|
+
const started = runner.track(
|
|
4108
|
+
`GitHub Actions ${workflow.file}`,
|
|
4109
|
+
(io) => dispatchAndFollow(workflow, pipeline, ref, { onLine: io.line, onProgress: io.progress })
|
|
4110
|
+
);
|
|
4111
|
+
if (!started) return c.json({ error: `busy \u2014 ${runner.running} is running` }, 409);
|
|
4112
|
+
return c.json({ started: true, workflow: workflow.file, ref, jobs: pipeline.jobs.length });
|
|
4113
|
+
});
|
|
4114
|
+
app.get("/api/resources", async (c) => {
|
|
4115
|
+
const opts = optsFromQuery(new URL(c.req.url), tierEnvVar, cfg.projectDir);
|
|
4116
|
+
const env = opts.env ?? cfg.env;
|
|
4117
|
+
try {
|
|
4118
|
+
const [ir, known] = await Promise.all([
|
|
4119
|
+
graphIr(
|
|
4120
|
+
cfg.projectDir,
|
|
4121
|
+
env ? { live: true, overlay: true, env, ...tierTargetOpts(opts) } : tierTargetOpts(opts)
|
|
4122
|
+
),
|
|
4123
|
+
knownComponents(cfg.projectDir, opts)
|
|
4124
|
+
]);
|
|
4125
|
+
return c.json({ byComponent: resourcesByComponent(ir, known) });
|
|
4126
|
+
} catch (err) {
|
|
4127
|
+
return errorResponse(c, opts, err);
|
|
4128
|
+
}
|
|
4129
|
+
});
|
|
4130
|
+
app.get("/api/reconcile", async (c) => {
|
|
4131
|
+
const opts = optsFromQuery(new URL(c.req.url), tierEnvVar, cfg.projectDir);
|
|
4132
|
+
const env = opts.env ?? cfg.env;
|
|
4133
|
+
if (!env) {
|
|
4134
|
+
return c.json({ error: "reconcile needs an environment \u2014 pick one, or start behold with --env <name>" }, 400);
|
|
4135
|
+
}
|
|
4136
|
+
try {
|
|
4137
|
+
const [plan, ir, known] = await Promise.all([
|
|
4138
|
+
lifecyclePlan(cfg.projectDir, env, opts),
|
|
4139
|
+
graphIr(cfg.projectDir, { live: true, overlay: true, env, ...tierTargetOpts(opts) }),
|
|
4140
|
+
knownComponents(cfg.projectDir, opts)
|
|
4141
|
+
]);
|
|
4142
|
+
return c.json(summarizePlan(plan, resourcesByComponent(ir, known), nonResourceEntities(ir)));
|
|
4143
|
+
} catch (err) {
|
|
4144
|
+
return errorResponse(c, opts, err);
|
|
4145
|
+
}
|
|
4146
|
+
});
|
|
4147
|
+
app.get("/api/overlay", async (c) => {
|
|
4148
|
+
const query = optsFromQuery(new URL(c.req.url), tierEnvVar, cfg.projectDir);
|
|
4149
|
+
const env = query.env ?? cfg.env;
|
|
4150
|
+
if (!env) {
|
|
4151
|
+
return c.json({ error: "overlay needs an environment \u2014 pick one, or start behold with --env <name>" }, 400);
|
|
4152
|
+
}
|
|
4153
|
+
const logical = new URL(c.req.url).searchParams.get("logical") === "1";
|
|
4154
|
+
try {
|
|
4155
|
+
const opts = { ...query, live: true, overlay: true, env, ...logical ? { detail: 3 } : {} };
|
|
4156
|
+
let ir = reclassifyOverlay(await graphIr(cfg.projectDir, opts));
|
|
4157
|
+
const boundContext = await boundK8sContext(env);
|
|
4158
|
+
ir = mergeClusterRoot(ir, await clusterRootGraphIr(cfg.projectDir, query), await runningK3dClusters());
|
|
4159
|
+
const declaresHelm = await detectProject(cfg.projectDir).then((p) => p.lexicons.includes("helm")).catch(() => false);
|
|
4160
|
+
if (declaresHelm || ir.nodes.some((n) => n.lexicon === "helm" && n.kind === "Helm::Chart")) {
|
|
4161
|
+
const observed = await lifecycleDiffLive(cfg.projectDir, env, query).then((d) => d.lexicons?.helm?.observedArtifacts).catch(() => void 0);
|
|
4162
|
+
applyHelmArtifacts(ir, observed);
|
|
4163
|
+
synthesizeHelmReleases(ir, observed, discoverReleaseUnits(cfg.projectDir));
|
|
4164
|
+
}
|
|
4165
|
+
ir = new URL(c.req.url).searchParams.get("runtime") === "1" ? attachRuntimeContainment(ir) : pruneRuntimeChildren(ir);
|
|
4166
|
+
if (logical) {
|
|
4167
|
+
const logicalBefore = ir.nodes.length;
|
|
4168
|
+
const { ir: projected, byContainer } = projectTopology(addK8sDeclaredEdges(addValueMatchEdges(ir)), env, boundContext, [await graphPath(cfg.projectDir, opts), cfg.projectDir]);
|
|
4169
|
+
const { svg: svg2 } = renderArchitecture(projected, byContainer);
|
|
4170
|
+
const logicalTierNote = tierMismatchNote(projected, beholdConfig.tiers, query.tier);
|
|
4171
|
+
const logicalNote = [logicalTierNote, notesFor("logical", projected, void 0, logicalBefore)].filter(Boolean).join(" \xB7 ");
|
|
4172
|
+
return c.json({ ir: projected, svg: svg2, byContainer, meta: { projectDir: cfg.projectDir, env, mode: "logical", ...logicalNote ? { note: logicalNote } : {} } });
|
|
4173
|
+
}
|
|
4174
|
+
if ((query.detail ?? 2) < 3) ir = pruneImports(ir);
|
|
4175
|
+
ir = addValueMatchEdges(ir);
|
|
4176
|
+
ir = addK8sDeclaredEdges(ir);
|
|
4177
|
+
ir = addClusterAnchorEdges(ir, boundContext);
|
|
4178
|
+
let compositeEdgesAttached;
|
|
4179
|
+
if (query.detail === 1) {
|
|
4180
|
+
try {
|
|
4181
|
+
const dag = await componentGraphIr(cfg.projectDir, { env, ...tierTargetOpts(query) });
|
|
4182
|
+
const counted = addCompositeDepsCounted(ir, dag);
|
|
4183
|
+
ir = counted.ir;
|
|
4184
|
+
compositeEdgesAttached = counted.attached;
|
|
4185
|
+
} catch {
|
|
4186
|
+
compositeEdgesAttached = 0;
|
|
4187
|
+
}
|
|
4188
|
+
}
|
|
4189
|
+
const { svg } = renderGraph(ir, { boxes: "byContainer", radial: new URL(c.req.url).searchParams.get("radial") === "1" });
|
|
4190
|
+
const zoom = new URL(c.req.url).searchParams.get("runtime") === "1" ? "runtime" : query.detail === 1 ? "composites" : query.detail === 3 ? "attributes" : "resources";
|
|
4191
|
+
const tierNote = tierMismatchNote(ir, beholdConfig.tiers, query.tier);
|
|
4192
|
+
const zoomNotes = notesFor(zoom, ir, compositeEdgesAttached);
|
|
4193
|
+
const note = [tierNote, zoomNotes].filter(Boolean).join(" \xB7 ");
|
|
4194
|
+
return c.json({ ir, svg, meta: { projectDir: cfg.projectDir, env, mode: "overlay", ...note ? { note } : {} } });
|
|
4195
|
+
} catch (err) {
|
|
4196
|
+
return errorResponse(c, query, err);
|
|
4197
|
+
}
|
|
4198
|
+
});
|
|
4199
|
+
app.post("/api/refresh", async (c) => {
|
|
4200
|
+
const env = optsFromQuery(new URL(c.req.url)).env ?? cfg.env;
|
|
4201
|
+
const result = await captureFrame(cfg.projectDir, env, frames, broadcaster);
|
|
4202
|
+
if (!result) return c.json({ error: "refresh failed \u2014 see server log" }, 500);
|
|
4203
|
+
if (env) reclassifyOverlay(result.ir);
|
|
4204
|
+
mergeClusterRoot(result.ir, await clusterRootGraphIr(cfg.projectDir), env ? await runningK3dClusters() : void 0);
|
|
4205
|
+
if (env) {
|
|
4206
|
+
const declaresHelm = await detectProject(cfg.projectDir).then((p) => p.lexicons.includes("helm")).catch(() => false);
|
|
4207
|
+
if (declaresHelm || result.ir.nodes.some((n) => n.lexicon === "helm" && n.kind === "Helm::Chart")) {
|
|
4208
|
+
const observed = await lifecycleDiffLive(cfg.projectDir, env).then((d) => d.lexicons?.helm?.observedArtifacts).catch(() => void 0);
|
|
4209
|
+
applyHelmArtifacts(result.ir, observed);
|
|
4210
|
+
synthesizeHelmReleases(result.ir, observed, discoverReleaseUnits(cfg.projectDir));
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4213
|
+
if (env) {
|
|
4214
|
+
if (new URL(c.req.url).searchParams.get("runtime") === "1") attachRuntimeContainment(result.ir);
|
|
4215
|
+
else pruneRuntimeChildren(result.ir);
|
|
4216
|
+
}
|
|
4217
|
+
if ((optsFromQuery(new URL(c.req.url)).detail ?? 2) < 3) pruneImports(result.ir);
|
|
4218
|
+
addValueMatchEdges(result.ir);
|
|
4219
|
+
addK8sDeclaredEdges(result.ir);
|
|
4220
|
+
addClusterAnchorEdges(result.ir, await boundK8sContext(env));
|
|
4221
|
+
const { svg } = renderGraph(result.ir, { boxes: "byContainer" });
|
|
4222
|
+
return c.json({
|
|
4223
|
+
ir: result.ir,
|
|
4224
|
+
svg,
|
|
4225
|
+
meta: { projectDir: cfg.projectDir, env: env ?? null, ...env ? { mode: "overlay" } : {} },
|
|
4226
|
+
captured: result.captured
|
|
4227
|
+
});
|
|
4228
|
+
});
|
|
4229
|
+
app.get("/api/diff", async (c) => {
|
|
4230
|
+
const env = optsFromQuery(new URL(c.req.url)).env ?? cfg.env;
|
|
4231
|
+
if (!env) return c.json({ error: "diff needs an environment \u2014 pick one, or start with --env" }, 400);
|
|
4232
|
+
const { code, stdout, stderr } = await runChantRaw(["lifecycle", "diff", env, "--live", "--json"], cfg.projectDir);
|
|
4233
|
+
if (code !== 0) return c.json({ error: stderr.trim() || `diff exited ${code}` }, 500);
|
|
4234
|
+
let parsed;
|
|
4235
|
+
try {
|
|
4236
|
+
parsed = JSON.parse(stdout);
|
|
4237
|
+
} catch {
|
|
4238
|
+
return c.json({ error: "diff output was not JSON \u2014 chant may predate --live --json (needs 0.18.7+)" }, 500);
|
|
4239
|
+
}
|
|
4240
|
+
const ids = /* @__PURE__ */ new Set();
|
|
4241
|
+
for (const lex of Object.values(parsed.lexicons ?? {})) {
|
|
4242
|
+
for (const k of Object.keys(lex.observed ?? {})) ids.add(k);
|
|
4243
|
+
const r = lex.resources;
|
|
4244
|
+
if (!r) continue;
|
|
4245
|
+
for (const arr of [r.missing, r.orphan, r.disappeared, r.newlyObserved, r.unchanged]) for (const n of arr ?? []) ids.add(n);
|
|
4246
|
+
for (const d of r.driftedSinceSnapshot ?? []) ids.add(d.name);
|
|
4247
|
+
for (const u of r.unobserved ?? []) ids.add(u.name);
|
|
4248
|
+
for (const rc of r.runtimeChildren ?? []) ids.add(rc.name);
|
|
4249
|
+
}
|
|
4250
|
+
const nodes = {};
|
|
4251
|
+
for (const id of ids) {
|
|
4252
|
+
const observed = nodeObserved(parsed, id);
|
|
4253
|
+
nodes[id] = {
|
|
4254
|
+
observed,
|
|
4255
|
+
diff: nodeDiff(parsed, id),
|
|
4256
|
+
health: classifyHealth(observed?.status),
|
|
4257
|
+
// Field-level (per-manager) drift (#87, chant#1181) — null when no
|
|
4258
|
+
// lexicon in this diff carries a `deep` section at all.
|
|
4259
|
+
fieldDrift: nodeFieldDrift(parsed, id)
|
|
4260
|
+
};
|
|
4261
|
+
}
|
|
4262
|
+
return c.json({ env, nodes });
|
|
4263
|
+
});
|
|
4264
|
+
app.get("/api/diff/:node", async (c) => {
|
|
4265
|
+
const node = c.req.param("node");
|
|
4266
|
+
const env = optsFromQuery(new URL(c.req.url)).env ?? cfg.env;
|
|
4267
|
+
if (!env) return c.json({ error: "diff needs an environment \u2014 pick one, or start with --env" }, 400);
|
|
4268
|
+
const { code, stdout, stderr } = await runChantRaw(
|
|
4269
|
+
["lifecycle", "diff", env, "--live", "--json"],
|
|
4270
|
+
cfg.projectDir
|
|
4271
|
+
);
|
|
4272
|
+
if (code !== 0) return c.json({ error: stderr.trim() || `diff exited ${code}` }, 500);
|
|
4273
|
+
let parsed;
|
|
4274
|
+
try {
|
|
4275
|
+
parsed = JSON.parse(stdout);
|
|
4276
|
+
} catch {
|
|
4277
|
+
return c.json({ error: "diff output was not JSON \u2014 chant may predate --live --json (needs 0.18.7+)" }, 500);
|
|
4278
|
+
}
|
|
4279
|
+
const observed = nodeObserved(parsed, node);
|
|
4280
|
+
return c.json({
|
|
4281
|
+
node,
|
|
4282
|
+
env,
|
|
4283
|
+
diff: nodeDiff(parsed, node),
|
|
4284
|
+
observed,
|
|
4285
|
+
health: classifyHealth(observed?.status),
|
|
4286
|
+
// Field-level (per-manager) drift (#87, chant#1181) — null when no
|
|
4287
|
+
// lexicon in this diff carries a `deep` section at all.
|
|
4288
|
+
fieldDrift: nodeFieldDrift(parsed, node)
|
|
4289
|
+
});
|
|
4290
|
+
});
|
|
4291
|
+
app.get("/api/history", async (c) => c.json({ commits: await sourceCommits(cfg.projectDir) }));
|
|
4292
|
+
app.post("/api/rollback", (c) => {
|
|
4293
|
+
if (cfg.previewMode) return c.json({ error: "disabled in preview mode" }, 403);
|
|
4294
|
+
const to = new URL(c.req.url).searchParams.get("to");
|
|
4295
|
+
if (!to) return c.json({ error: "rollback needs ?to=<git-ref>" }, 400);
|
|
4296
|
+
const args = ["lifecycle", "rollback", ...cfg.env ? [cfg.env] : [], "--to", to];
|
|
4297
|
+
if (!runner.run(args, `rollback \u2192 ${to}`, cfg.env)) {
|
|
4298
|
+
return c.json({ error: `busy \u2014 ${runner.running} is running` }, 409);
|
|
4299
|
+
}
|
|
4300
|
+
return c.json({ started: true, to });
|
|
4301
|
+
});
|
|
4302
|
+
app.post("/api/apply", async (c) => {
|
|
4303
|
+
const url = new URL(c.req.url);
|
|
4304
|
+
const env = url.searchParams.get("env") ?? cfg.env;
|
|
4305
|
+
if (!env) {
|
|
4306
|
+
return c.json({ error: "apply needs an environment \u2014 pick one, or start behold with --env <name>" }, 400);
|
|
4307
|
+
}
|
|
4308
|
+
const component = url.searchParams.get("component") || "all";
|
|
4309
|
+
const force = url.searchParams.get("force") === "1";
|
|
4310
|
+
const flociTargeted = !!process.env.AWS_ENDPOINT_URL && await detectProject(cfg.projectDir).then((p) => p.lexicons.includes("aws")).catch(() => false);
|
|
4311
|
+
if (!force && flociTargeted) {
|
|
4312
|
+
try {
|
|
4313
|
+
const rows = await componentStatus(cfg.projectDir, env);
|
|
4314
|
+
const deployed = rows.filter((r) => componentStatusColor(r) !== "neutral").map((r) => r.component);
|
|
4315
|
+
const blocked = component === "all" ? deployed : deployed.includes(component) ? [component] : [];
|
|
4316
|
+
if (blocked.length) {
|
|
4317
|
+
const who = component === "all" ? `${blocked.length} component(s) are` : `"${component}" is`;
|
|
4318
|
+
return c.json(
|
|
4319
|
+
{
|
|
4320
|
+
error: `${who} already deployed \u2014 re-applying collides on the local emulator (Floci #16, github.com/lex00/floci/issues/16). Use Reset (reboots + redeploys clean), or retry with ?force=1.`,
|
|
4321
|
+
blocked
|
|
4322
|
+
},
|
|
4323
|
+
409
|
|
4324
|
+
);
|
|
4325
|
+
}
|
|
4326
|
+
} catch {
|
|
4327
|
+
}
|
|
4328
|
+
}
|
|
4329
|
+
if (!runner.apply(component, env)) {
|
|
4330
|
+
return c.json({ error: `busy \u2014 ${runner.running} is running` }, 409);
|
|
4331
|
+
}
|
|
4332
|
+
return c.json({ started: true, component, env });
|
|
4333
|
+
});
|
|
4334
|
+
const rel = relative(process.cwd(), webRoot) || ".";
|
|
4335
|
+
app.use("/*", serveStatic({ root: rel }));
|
|
4336
|
+
app.get("/", serveStatic({ path: join9(rel, "index.html") }));
|
|
4337
|
+
return app;
|
|
4338
|
+
}
|
|
4339
|
+
async function startServer(cfg) {
|
|
4340
|
+
if (cfg.local) {
|
|
4341
|
+
try {
|
|
4342
|
+
const emulators = await emulatorUp(cfg.projectDir);
|
|
4343
|
+
cfg.emulators = emulators;
|
|
4344
|
+
if (emulators.length === 0) {
|
|
4345
|
+
process.stderr.write(
|
|
4346
|
+
"behold serve --local: no configured lexicon has a local emulator \u2014 serving without one.\n"
|
|
4347
|
+
);
|
|
4348
|
+
} else {
|
|
4349
|
+
Object.assign(process.env, mergedEnv(emulators));
|
|
4350
|
+
for (const e of emulators) {
|
|
4351
|
+
process.stdout.write(` local: ${e.lexicon} ${e.name} up on ${e.endpoint}
|
|
4352
|
+
`);
|
|
4353
|
+
}
|
|
4354
|
+
}
|
|
4355
|
+
} catch (err) {
|
|
4356
|
+
cfg.emulators = [];
|
|
4357
|
+
process.stderr.write(
|
|
4358
|
+
`behold serve --local: ${err instanceof Error ? err.message : String(err)}
|
|
4359
|
+
Serving the source graph without the emulator \u2014 start Docker and restart to enable local deploys.
|
|
4360
|
+
`
|
|
4361
|
+
);
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
const broadcaster = new Broadcaster();
|
|
4365
|
+
const frames = new FrameBuffer();
|
|
4366
|
+
const runner = new OpRunner({
|
|
4367
|
+
projectDir: cfg.projectDir,
|
|
4368
|
+
broadcaster,
|
|
4369
|
+
onDone: (opEnv) => captureFrame(cfg.projectDir, opEnv ?? cfg.env, frames, broadcaster)
|
|
4370
|
+
});
|
|
4371
|
+
const app = createApp(cfg, broadcaster, frames, runner);
|
|
4372
|
+
const autoSync = cfg.autoSync ?? "off";
|
|
4373
|
+
const capture = () => captureFrame(cfg.projectDir, cfg.env, frames, broadcaster);
|
|
4374
|
+
const onEstateChange = () => {
|
|
4375
|
+
broadcaster.emit("changed");
|
|
4376
|
+
void capture();
|
|
4377
|
+
};
|
|
4378
|
+
const onPollDrift = (movedLexicons) => {
|
|
4379
|
+
onEstateChange();
|
|
4380
|
+
if (autoSync === "off") return;
|
|
4381
|
+
void routeAutoSync(movedLexicons);
|
|
4382
|
+
};
|
|
4383
|
+
const routeAutoSync = async (movedLexicons) => {
|
|
4384
|
+
const suspended = autoSync === "pull-request" ? suspendedByRollback(await openRollbackBranches(cfg.projectDir, cfg.env), movedLexicons) : /* @__PURE__ */ new Set();
|
|
4385
|
+
const { picks, declined } = pickAutoSyncOps(
|
|
4386
|
+
autoSync,
|
|
4387
|
+
discoverEstateOps(cfg.projectDirs ?? [cfg.projectDir]),
|
|
4388
|
+
runner.running,
|
|
4389
|
+
movedLexicons,
|
|
4390
|
+
suspended
|
|
4391
|
+
);
|
|
4392
|
+
for (const d of declined) {
|
|
4393
|
+
broadcaster.emit("op", `\u27F3 auto-sync (${autoSync}) declined ${d.lexicon}: ${d.reason}`);
|
|
4394
|
+
}
|
|
4395
|
+
for (const { op, lexicons } of picks) {
|
|
4396
|
+
const scope = lexicons.join("+");
|
|
4397
|
+
if (runner.trigger(op.name, op.env, op.dir)) {
|
|
4398
|
+
broadcaster.emit("op", `\u27F3 auto-sync (${autoSync}) ${scope} \u2192 ${op.name}`);
|
|
4399
|
+
} else {
|
|
4400
|
+
broadcaster.emit("op", `\u27F3 auto-sync (${autoSync}) ${scope} \u2192 ${op.name} waiting \u2014 ${runner.running} is running`);
|
|
4401
|
+
}
|
|
4402
|
+
}
|
|
4403
|
+
};
|
|
4404
|
+
const stopWatch = watchSource(cfg.projectDir, onEstateChange);
|
|
4405
|
+
const stopPoll = cfg.env && cfg.pollSecs ? startDriftPoll({
|
|
4406
|
+
intervalMs: cfg.pollSecs * 1e3,
|
|
4407
|
+
query: () => graphIr(cfg.projectDir, { live: true, overlay: true, env: cfg.env }),
|
|
4408
|
+
onChange: onPollDrift,
|
|
4409
|
+
onError: (err) => process.stderr.write(`poll: ${err instanceof Error ? err.message : String(err)}
|
|
4410
|
+
`)
|
|
4411
|
+
}) : () => {
|
|
4412
|
+
};
|
|
4413
|
+
void capture();
|
|
4414
|
+
let shuttingDown = false;
|
|
4415
|
+
const shutdown = () => {
|
|
4416
|
+
if (shuttingDown) return;
|
|
4417
|
+
shuttingDown = true;
|
|
4418
|
+
stopWatch();
|
|
4419
|
+
stopPoll();
|
|
4420
|
+
const done = cfg.local && cfg.emulators && cfg.emulators.length ? emulatorDown(cfg.projectDir).catch((err) => process.stderr.write(`emulator down: ${err instanceof Error ? err.message : String(err)}
|
|
4421
|
+
`)) : Promise.resolve();
|
|
4422
|
+
void done.finally(() => process.exit(0));
|
|
4423
|
+
};
|
|
4424
|
+
process.on("SIGINT", shutdown);
|
|
4425
|
+
process.on("SIGTERM", shutdown);
|
|
4426
|
+
const server = serve({ fetch: app.fetch, port: cfg.port }, (info) => {
|
|
4427
|
+
const poll = cfg.env && cfg.pollSecs ? `, polling drift every ${cfg.pollSecs}s` : "";
|
|
4428
|
+
const auto = autoSync !== "off" ? ` auto-sync: ${autoSync}` : "";
|
|
4429
|
+
const localTag = cfg.emulators && cfg.emulators.length ? ` local: ${cfg.emulators.map((e) => e.name).join(", ")} up (creds-free \u2014 deploys hit the emulator)` : "";
|
|
4430
|
+
process.stdout.write(
|
|
4431
|
+
`behold \u2192 http://localhost:${info.port}
|
|
4432
|
+
project: ${cfg.projectDir}${cfg.env ? ` env: ${cfg.env}` : ""}${auto}${localTag}
|
|
4433
|
+
read-only, watching for edits${poll}. lanes: /lanes. Ctrl-C to stop.
|
|
4434
|
+
`
|
|
4435
|
+
);
|
|
4436
|
+
void detectProject(cfg.projectDir).then(({ environments, lexicons }) => {
|
|
4437
|
+
const envs = environments.length ? environments.join(", ") : "(none declared \u2014 env picker shows only source)";
|
|
4438
|
+
process.stdout.write(` detected: environments [${envs}] lexicons [${lexicons.join(", ")}]
|
|
4439
|
+
`);
|
|
4440
|
+
});
|
|
4441
|
+
});
|
|
4442
|
+
server.on("error", (err) => {
|
|
4443
|
+
if (err.code === "EADDRINUSE") {
|
|
4444
|
+
process.stderr.write(
|
|
4445
|
+
`behold: port ${cfg.port} is already in use \u2014 another behold is probably running there.
|
|
4446
|
+
Stop it (\`lsof -nP -iTCP:${cfg.port} -sTCP:LISTEN\` to find it), or pass --port <n>.
|
|
4447
|
+
`
|
|
4448
|
+
);
|
|
4449
|
+
} else {
|
|
4450
|
+
process.stderr.write(`behold: server error: ${err.message}
|
|
4451
|
+
`);
|
|
4452
|
+
}
|
|
4453
|
+
process.exit(1);
|
|
4454
|
+
});
|
|
4455
|
+
}
|
|
4456
|
+
|
|
4457
|
+
// 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";
|
|
4460
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
4461
|
+
var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
|
|
4462
|
+
function canonicalKey(path, params) {
|
|
4463
|
+
const flat = params.get("components") === "1" || params.get("logical") === "1";
|
|
4464
|
+
const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial"))).map((k) => `${k}=${params.get(k)}`).join("&");
|
|
4465
|
+
return q ? `${path}?${q}` : path;
|
|
4466
|
+
}
|
|
4467
|
+
function slug(key) {
|
|
4468
|
+
const base = key.replace(/^\//, "").replace(/[^a-zA-Z0-9=_.-]+/g, "_").slice(0, 120);
|
|
4469
|
+
return `${base}.json`;
|
|
4470
|
+
}
|
|
4471
|
+
function captureKeys(axes) {
|
|
4472
|
+
const keys = /* @__PURE__ */ new Set();
|
|
4473
|
+
const add = (path, p) => keys.add(canonicalKey(path, new URLSearchParams(p)));
|
|
4474
|
+
add("/api/project", {});
|
|
4475
|
+
add("/api/substrates", {});
|
|
4476
|
+
add("/api/ops", {});
|
|
4477
|
+
const tiers = axes.tiers && axes.tiers.length ? axes.tiers : [""];
|
|
4478
|
+
const envs = ["", ...axes.environments];
|
|
4479
|
+
for (const env of envs) {
|
|
4480
|
+
for (const tier of tiers) {
|
|
4481
|
+
const lens = (extra) => {
|
|
4482
|
+
const p = { ...extra };
|
|
4483
|
+
if (env) p.env = env;
|
|
4484
|
+
if (tier) p.tier = tier;
|
|
4485
|
+
return p;
|
|
4486
|
+
};
|
|
4487
|
+
add("/api/graph", lens({ components: "1" }));
|
|
4488
|
+
add(env ? "/api/overlay" : "/api/graph", lens({ logical: "1" }));
|
|
4489
|
+
add("/api/ci", lens({}));
|
|
4490
|
+
if (env) {
|
|
4491
|
+
add("/api/reconcile", lens({}));
|
|
4492
|
+
add("/api/resources", lens({}));
|
|
4493
|
+
add("/api/diff", lens({}));
|
|
4494
|
+
}
|
|
4495
|
+
for (const detail of ["1", "2", "3"]) {
|
|
4496
|
+
for (const radial of ["0", "1"]) {
|
|
4497
|
+
const p = lens({ detail });
|
|
4498
|
+
if (radial === "1") p.radial = "1";
|
|
4499
|
+
add(env ? "/api/overlay" : "/api/graph", p);
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
4504
|
+
return [...keys];
|
|
4505
|
+
}
|
|
4506
|
+
function webDir() {
|
|
4507
|
+
return join10(dirname3(fileURLToPath2(import.meta.url)), "..", "web");
|
|
4508
|
+
}
|
|
4509
|
+
function workerName(project, override) {
|
|
4510
|
+
const raw = override ?? `behold-${basename(project)}`;
|
|
4511
|
+
const name = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
|
4512
|
+
return name || "behold-export";
|
|
4513
|
+
}
|
|
4514
|
+
async function runExport(cfg, outDir, opts = {}) {
|
|
4515
|
+
const app = createApp(cfg);
|
|
4516
|
+
const proj = await (await app.request("/api/project")).json();
|
|
4517
|
+
const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
|
|
4518
|
+
const snapDir = join10(outDir, "snapshots");
|
|
4519
|
+
mkdirSync(snapDir, { recursive: true });
|
|
4520
|
+
const keyToFile = {};
|
|
4521
|
+
let ok = 0;
|
|
4522
|
+
let failed = 0;
|
|
4523
|
+
for (const key of captureKeys(axes)) {
|
|
4524
|
+
const res = await app.request(key);
|
|
4525
|
+
const body = await res.text();
|
|
4526
|
+
const file = slug(key);
|
|
4527
|
+
writeFileSync(join10(snapDir, file), body);
|
|
4528
|
+
keyToFile[key] = `snapshots/${file}`;
|
|
4529
|
+
if (res.ok) ok++;
|
|
4530
|
+
else failed++;
|
|
4531
|
+
}
|
|
4532
|
+
const manifest = {
|
|
4533
|
+
static: true,
|
|
4534
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4535
|
+
projectDir: cfg.projectDir,
|
|
4536
|
+
axes,
|
|
4537
|
+
keyToFile
|
|
4538
|
+
};
|
|
4539
|
+
writeFileSync(join10(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
4540
|
+
const html = readFileSync6(join10(webDir(), "index.html"), "utf8").replace(
|
|
4541
|
+
/<\/head>/i,
|
|
4542
|
+
` <script>window.__BEHOLD_STATIC__ = true;</script>
|
|
4543
|
+
</head>`
|
|
4544
|
+
);
|
|
4545
|
+
writeFileSync(join10(outDir, "index.html"), html);
|
|
4546
|
+
for (const f of readdirSync4(webDir())) {
|
|
4547
|
+
if (f === "index.html") continue;
|
|
4548
|
+
copyFileSync(join10(webDir(), f), join10(outDir, f));
|
|
4549
|
+
}
|
|
4550
|
+
writeFileSync(join10(outDir, "README.md"), BUNDLE_README);
|
|
4551
|
+
const name = workerName(cfg.projectDir, opts.name);
|
|
4552
|
+
writeFileSync(
|
|
4553
|
+
join10(outDir, "wrangler.jsonc"),
|
|
4554
|
+
JSON.stringify(
|
|
4555
|
+
{ $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
|
|
4556
|
+
null,
|
|
4557
|
+
2
|
|
4558
|
+
) + "\n"
|
|
4559
|
+
);
|
|
4560
|
+
process.stdout.write(
|
|
4561
|
+
`behold export \u2192 ${outDir}
|
|
4562
|
+
${ok} snapshots${failed ? ` (${failed} endpoint error(s) captured as-is)` : ""}
|
|
4563
|
+
View: npx serve ${outDir}
|
|
4564
|
+
Deploy: cd ${outDir} && npx wrangler deploy \u2192 https://${name}.<your-account>.workers.dev
|
|
4565
|
+
`
|
|
4566
|
+
);
|
|
4567
|
+
}
|
|
4568
|
+
var BUNDLE_README = `# behold \u2014 static export
|
|
4569
|
+
|
|
4570
|
+
An interactive, read-only snapshot of an estate captured by \`behold export\`.
|
|
4571
|
+
No server or backend \u2014 everything runs client-side from the bundled snapshots.
|
|
4572
|
+
Pan/zoom, the zoom dial, radial layout, the inspect pane, and the env/tier
|
|
4573
|
+
pickers all work; there's no live observe or deploy.
|
|
4574
|
+
|
|
4575
|
+
## View it locally
|
|
4576
|
+
It must be served over http (not opened as a \`file://\` \u2014 browsers block the
|
|
4577
|
+
snapshot fetches on that protocol):
|
|
4578
|
+
\`\`\`sh
|
|
4579
|
+
npx serve .
|
|
4580
|
+
# or
|
|
4581
|
+
python3 -m http.server 8000
|
|
4582
|
+
\`\`\`
|
|
4583
|
+
|
|
4584
|
+
## Deploy to Cloudflare (Workers Static Assets)
|
|
4585
|
+
This folder is deploy-ready \u2014 an assets-only \`wrangler.jsonc\` is included (no
|
|
4586
|
+
server code; the bundle is pure static). With [wrangler](https://developers.cloudflare.com/workers/wrangler/)
|
|
4587
|
+
installed and Cloudflare auth set:
|
|
4588
|
+
\`\`\`sh
|
|
4589
|
+
npx wrangler deploy
|
|
4590
|
+
# \u2192 https://<name>.<your-account>.workers.dev
|
|
4591
|
+
\`\`\`
|
|
4592
|
+
Auth: run \`npx wrangler login\`, or set \`CLOUDFLARE_API_TOKEN\` +
|
|
4593
|
+
\`CLOUDFLARE_ACCOUNT_ID\`. Rename by editing \`"name"\` in \`wrangler.jsonc\`.
|
|
4594
|
+
|
|
4595
|
+
## Other static hosts
|
|
4596
|
+
It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
|
|
4597
|
+
(\`wrangler pages deploy .\`) all work.
|
|
4598
|
+
`;
|
|
4599
|
+
|
|
4600
|
+
// src/cli.ts
|
|
4601
|
+
var USAGE = `behold \u2014 a live control plane on chant (read-only core)
|
|
4602
|
+
|
|
4603
|
+
Usage:
|
|
4604
|
+
behold preview [project-dir] [--port <n>] [--emulator]
|
|
4605
|
+
behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
|
|
4606
|
+
behold serve <project-dir\u2026> [--port <n>] [--env <name>] [--poll <secs>] [--local]
|
|
4607
|
+
|
|
4608
|
+
export Capture the live estate into a self-contained, interactive STATIC
|
|
4609
|
+
bundle (default ./behold-export) \u2014 every env/tier \xD7 zoom \xD7 radial,
|
|
4610
|
+
replayable with no backend. Host it anywhere (Cloudflare Pages/Workers,
|
|
4611
|
+
any static server). Read-only: no live observe, no deploy. Defaults the
|
|
4612
|
+
project to the current directory; pass a dir for someone else's, and
|
|
4613
|
+
--env for that project's live overlay. Bring the estate up first (e.g.
|
|
4614
|
+
behold preview, or your creds/env) so the snapshot reflects live state.
|
|
4615
|
+
|
|
4616
|
+
preview Serve one project's graph in a browser at a single port \u2014 the quick
|
|
4617
|
+
way to look at a chant project. Defaults the project to the current
|
|
4618
|
+
directory; pass a path to look at another one.
|
|
4619
|
+
|
|
4620
|
+
serve Start the server: the mixed-substrate graph of <project-dir> in a
|
|
4621
|
+
browser, coloured by drift. Read-only \u2014 never mutates. Pass several
|
|
4622
|
+
project dirs to compose them into one estate (#31): per-project
|
|
4623
|
+
boundary boxes + cross-stack edges. The first is the primary (ops,
|
|
4624
|
+
overlay, and rollback act on it).
|
|
4625
|
+
|
|
4626
|
+
Options:
|
|
4627
|
+
--port <n> Port (default 4600). preview/serve.
|
|
4628
|
+
--env <name> Environment name \u2014 turns on the live drift overlay.
|
|
4629
|
+
export/serve.
|
|
4630
|
+
--poll <secs> Re-query live drift every <secs> and push updates (needs --env).
|
|
4631
|
+
serve only.
|
|
4632
|
+
--auto-sync <mode> On a polled drift, trigger a committed Op (needs --env + --poll).
|
|
4633
|
+
off (default) | apply (heal via ApplyOp) | pull-request
|
|
4634
|
+
(adopt via ReconcileOp). Gated applies still wait for Approve.
|
|
4635
|
+
Routes per substrate (#117): the substrate that drifted
|
|
4636
|
+
picks the Op declaring that target, and declines out loud
|
|
4637
|
+
rather than guessing when several match or none does.
|
|
4638
|
+
serve only.
|
|
4639
|
+
--local serve only: boot the *served project's own* local
|
|
4640
|
+
emulator(s) via chant (\`chant emulator up\`, chant #920)
|
|
4641
|
+
and observe them \u2014 the creds-free first apply. Deploys
|
|
4642
|
+
(Run/Sync) hit the emulator; torn down on exit. Needs
|
|
4643
|
+
Docker. Generic \u2014 works for any emulator-backed lexicon,
|
|
4644
|
+
not Loom-specific. Not the same thing as --emulator below.
|
|
4645
|
+
--emulator preview/export only: turnkey Loom-on-Floci demo (v0.1.0).
|
|
4646
|
+
Injects the env Loom's own Floci setup expects
|
|
4647
|
+
(AWS_ENDPOINT_URL=http://localhost:4566, dummy AWS creds,
|
|
4648
|
+
LOOM_ENV=local) and, for preview, locks the UI into
|
|
4649
|
+
previewMode (git/PR ops hidden, substrate strip scoped to
|
|
4650
|
+
Docker+Floci). Off by default \u2014 without it, preview/export
|
|
4651
|
+
just read the given project's declared source graph (plus
|
|
4652
|
+
--env's live overlay, for export). Reproduces the old
|
|
4653
|
+
default behavior on request: \`behold preview ../loomster
|
|
4654
|
+
--emulator\`. Needs Docker. Hardcoded to Loom's env-var
|
|
4655
|
+
names, unlike --local; kept separate because Loom's own
|
|
4656
|
+
\`scripts/local/local-up.sh\` Floci setup clashes on :4566
|
|
4657
|
+
with chant's generic \`chant emulator up\`.
|
|
4658
|
+
--out <dir> export only: output directory (default ./behold-export).
|
|
4659
|
+
--name <worker> export only: Cloudflare Worker name in the generated
|
|
4660
|
+
wrangler.jsonc.
|
|
4661
|
+
-h, --help This text.
|
|
4662
|
+
`;
|
|
4663
|
+
async function run3(argv) {
|
|
4664
|
+
const [cmd, ...rest] = argv;
|
|
4665
|
+
if (!cmd || cmd === "-h" || cmd === "--help") {
|
|
4666
|
+
process.stdout.write(USAGE);
|
|
4667
|
+
return;
|
|
4668
|
+
}
|
|
4669
|
+
if (cmd === "preview") {
|
|
4670
|
+
await runPreview(rest);
|
|
4671
|
+
return;
|
|
4672
|
+
}
|
|
4673
|
+
if (cmd === "export") {
|
|
4674
|
+
await runExportCmd(rest);
|
|
4675
|
+
return;
|
|
4676
|
+
}
|
|
4677
|
+
if (cmd !== "serve") {
|
|
4678
|
+
process.stderr.write(`behold: unknown command '${cmd}'
|
|
4679
|
+
|
|
4680
|
+
${USAGE}`);
|
|
4681
|
+
process.exit(2);
|
|
4682
|
+
}
|
|
4683
|
+
const projectDirs = [];
|
|
4684
|
+
let port = 4600;
|
|
4685
|
+
let env;
|
|
4686
|
+
let pollSecs;
|
|
4687
|
+
let autoSync = "off";
|
|
4688
|
+
let local = false;
|
|
4689
|
+
for (let i = 0; i < rest.length; i++) {
|
|
4690
|
+
const a = rest[i];
|
|
4691
|
+
if (a === "--port") port = Number(rest[++i]);
|
|
4692
|
+
else if (a === "--env") env = rest[++i];
|
|
4693
|
+
else if (a === "--poll") pollSecs = Number(rest[++i]);
|
|
4694
|
+
else if (a === "--local") local = true;
|
|
4695
|
+
else if (a === "--auto-sync") {
|
|
4696
|
+
const m = rest[++i];
|
|
4697
|
+
if (!m || !isAutoSyncMode(m)) {
|
|
4698
|
+
process.stderr.write("behold serve: --auto-sync must be off | apply | pull-request\n");
|
|
4699
|
+
process.exit(2);
|
|
4700
|
+
}
|
|
4701
|
+
autoSync = m;
|
|
4702
|
+
} else if (a === "-h" || a === "--help") {
|
|
4703
|
+
process.stdout.write(USAGE);
|
|
4704
|
+
return;
|
|
4705
|
+
} else if (!a.startsWith("-")) projectDirs.push(a);
|
|
4706
|
+
else {
|
|
4707
|
+
process.stderr.write(`behold: unexpected argument '${a}'
|
|
4708
|
+
`);
|
|
4709
|
+
process.exit(2);
|
|
4710
|
+
}
|
|
4711
|
+
}
|
|
4712
|
+
if (projectDirs.length === 0) {
|
|
4713
|
+
process.stderr.write("behold serve: missing <project-dir>\n\n" + USAGE);
|
|
4714
|
+
process.exit(2);
|
|
4715
|
+
}
|
|
4716
|
+
if (!Number.isFinite(port)) {
|
|
4717
|
+
process.stderr.write("behold serve: --port must be a number\n");
|
|
4718
|
+
process.exit(2);
|
|
4719
|
+
}
|
|
4720
|
+
if (pollSecs !== void 0 && (!Number.isFinite(pollSecs) || pollSecs <= 0)) {
|
|
4721
|
+
process.stderr.write("behold serve: --poll must be a positive number of seconds\n");
|
|
4722
|
+
process.exit(2);
|
|
4723
|
+
}
|
|
4724
|
+
if (pollSecs !== void 0 && !env) {
|
|
4725
|
+
process.stderr.write("behold serve: --poll needs --env (it polls the live overlay)\n");
|
|
4726
|
+
process.exit(2);
|
|
4727
|
+
}
|
|
4728
|
+
if (autoSync !== "off" && (!env || pollSecs === void 0)) {
|
|
4729
|
+
process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
|
|
4730
|
+
process.exit(2);
|
|
4731
|
+
}
|
|
4732
|
+
const dirs = projectDirs.map((d) => resolve2(d));
|
|
4733
|
+
await startServer({
|
|
4734
|
+
projectDir: dirs[0],
|
|
4735
|
+
// primary — ops/overlay/rollback act on it
|
|
4736
|
+
...dirs.length > 1 ? { projectDirs: dirs } : {},
|
|
4737
|
+
port,
|
|
4738
|
+
...env ? { env } : {},
|
|
4739
|
+
...pollSecs !== void 0 ? { pollSecs } : {},
|
|
4740
|
+
...autoSync !== "off" ? { autoSync } : {},
|
|
4741
|
+
...local ? { local: true } : {}
|
|
4742
|
+
});
|
|
4743
|
+
}
|
|
4744
|
+
function injectEmulatorEnv(env) {
|
|
4745
|
+
process.env.LOOM_ENV ??= env ?? "local";
|
|
4746
|
+
process.env.AWS_ENDPOINT_URL ??= "http://localhost:4566";
|
|
4747
|
+
process.env.AWS_ACCESS_KEY_ID ??= "test";
|
|
4748
|
+
process.env.AWS_SECRET_ACCESS_KEY ??= "test";
|
|
4749
|
+
process.env.AWS_REGION ??= "us-east-1";
|
|
4750
|
+
}
|
|
4751
|
+
async function runPreview(rest) {
|
|
4752
|
+
let port = 4600;
|
|
4753
|
+
let dirArg;
|
|
4754
|
+
let emulator = false;
|
|
4755
|
+
for (let i = 0; i < rest.length; i++) {
|
|
4756
|
+
const a = rest[i];
|
|
4757
|
+
if (a === "--port") port = Number(rest[++i]);
|
|
4758
|
+
else if (a === "--emulator") emulator = true;
|
|
4759
|
+
else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
|
|
4760
|
+
else if (!a.startsWith("-")) dirArg = a;
|
|
4761
|
+
}
|
|
4762
|
+
if (!Number.isFinite(port)) {
|
|
4763
|
+
process.stderr.write("behold preview: --port must be a number\n");
|
|
4764
|
+
process.exit(2);
|
|
4765
|
+
}
|
|
4766
|
+
const projectDir = resolve2(dirArg ?? process.cwd());
|
|
4767
|
+
if (!existsSync9(projectDir)) {
|
|
4768
|
+
process.stderr.write(`behold preview: project not found at ${projectDir}
|
|
4769
|
+
`);
|
|
4770
|
+
process.exit(2);
|
|
4771
|
+
}
|
|
4772
|
+
if (!emulator) {
|
|
4773
|
+
await startServer({ projectDir, port });
|
|
4774
|
+
return;
|
|
4775
|
+
}
|
|
4776
|
+
injectEmulatorEnv("local");
|
|
4777
|
+
process.stdout.write(
|
|
4778
|
+
`behold preview --emulator \u2014 Loom on the local Floci emulator (read + local deploy only)
|
|
4779
|
+
project: ${projectDir}
|
|
4780
|
+
If Floci isn't up yet, use "Bring up" on the Floci substrate pill (boots + deploys Loom).
|
|
4781
|
+
`
|
|
4782
|
+
);
|
|
4783
|
+
await startServer({ projectDir, port, env: "local", previewMode: true });
|
|
4784
|
+
}
|
|
4785
|
+
async function runExportCmd(rest) {
|
|
4786
|
+
let outDir = resolve2("behold-export");
|
|
4787
|
+
let env;
|
|
4788
|
+
let name;
|
|
4789
|
+
let dirArg;
|
|
4790
|
+
let emulator = false;
|
|
4791
|
+
for (let i = 0; i < rest.length; i++) {
|
|
4792
|
+
const a = rest[i];
|
|
4793
|
+
if (a === "--out") outDir = resolve2(rest[++i]);
|
|
4794
|
+
else if (a === "--env") env = rest[++i];
|
|
4795
|
+
else if (a === "--name") name = rest[++i];
|
|
4796
|
+
else if (a === "--emulator") emulator = true;
|
|
4797
|
+
else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
|
|
4798
|
+
else if (!a.startsWith("-")) dirArg = a;
|
|
4799
|
+
}
|
|
4800
|
+
const projectDir = resolve2(dirArg ?? process.cwd());
|
|
4801
|
+
if (emulator) {
|
|
4802
|
+
injectEmulatorEnv(env);
|
|
4803
|
+
env ??= "local";
|
|
4804
|
+
}
|
|
4805
|
+
if (!existsSync9(projectDir)) {
|
|
4806
|
+
process.stderr.write(`behold export: project not found at ${projectDir}
|
|
4807
|
+
`);
|
|
4808
|
+
process.exit(2);
|
|
4809
|
+
}
|
|
4810
|
+
await runExport({ projectDir, port: 0, ...env ? { env } : {} }, outDir, name ? { name } : {});
|
|
4811
|
+
}
|
|
4812
|
+
function isMainModule() {
|
|
4813
|
+
const entry = process.argv[1];
|
|
4814
|
+
if (!entry) return false;
|
|
4815
|
+
try {
|
|
4816
|
+
return realpathSync(entry) === realpathSync(fileURLToPath3(import.meta.url));
|
|
4817
|
+
} catch {
|
|
4818
|
+
return false;
|
|
4819
|
+
}
|
|
4820
|
+
}
|
|
4821
|
+
if (isMainModule()) {
|
|
4822
|
+
run3(process.argv.slice(2)).catch((err) => {
|
|
4823
|
+
process.stderr.write(`behold: fatal: ${err?.message ?? err}
|
|
4824
|
+
`);
|
|
4825
|
+
process.exit(3);
|
|
4826
|
+
});
|
|
4827
|
+
}
|
|
4828
|
+
export {
|
|
4829
|
+
run3 as run
|
|
4830
|
+
};
|