@intentius/behold 0.8.0 → 0.9.1

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.
Files changed (56) hide show
  1. package/AGENTS.md +85 -0
  2. package/README.md +124 -2
  3. package/demos.json +17 -1
  4. package/dist/cli.js +2553 -315
  5. package/example-argo-estate/README.md +43 -18
  6. package/example-argo-estate/app-a/chant.config.ts +10 -2
  7. package/example-argo-estate/app-a/package.json +2 -2
  8. package/example-argo-estate/app-b/chant.config.ts +11 -2
  9. package/example-argo-estate/app-b/package.json +2 -2
  10. package/example-argo-estate/control-plane/chant.config.ts +20 -5
  11. package/example-argo-estate/control-plane/package.json +2 -2
  12. package/example-argo-estate/package-lock.json +20 -20
  13. package/example-carve/README.md +221 -0
  14. package/example-carve/app/chant.config.ts +6 -0
  15. package/example-carve/app/package-lock.json +1075 -0
  16. package/example-carve/app/package.json +13 -0
  17. package/example-carve/app/src/carved.ts +30 -0
  18. package/example-carve/app/tsconfig.json +1 -0
  19. package/example-carve/carve-report.json +962 -0
  20. package/example-carve/legacy-tf/cdn.tf +23 -0
  21. package/example-carve/legacy-tf/compute.tf +63 -0
  22. package/example-carve/legacy-tf/floci-override.tf.disabled +61 -0
  23. package/example-carve/legacy-tf/modules/cdn/main.tf +72 -0
  24. package/example-carve/legacy-tf/naming.tf +10 -0
  25. package/example-carve/legacy-tf/network.tf +119 -0
  26. package/example-carve/legacy-tf/observability.tf +18 -0
  27. package/example-carve/legacy-tf/outputs.tf +21 -0
  28. package/example-carve/legacy-tf/storage.tf +33 -0
  29. package/example-carve/legacy-tf/terraform.tfstate +602 -0
  30. package/example-carve/legacy-tf/versions.tf +40 -0
  31. package/example-flux-estate/README.md +9 -4
  32. package/example-flux-estate/app-a/package.json +2 -2
  33. package/example-flux-estate/app-a/src/app.ts +2 -1
  34. package/example-flux-estate/app-b/chant.config.ts +4 -3
  35. package/example-flux-estate/app-b/package.json +2 -2
  36. package/example-flux-estate/app-b/src/app.ts +5 -3
  37. package/example-flux-estate/control-plane/package.json +2 -2
  38. package/example-flux-estate/control-plane/src/flux.ts +4 -2
  39. package/example-flux-estate/package-lock.json +20 -20
  40. package/example-k8s/package-lock.json +21 -21
  41. package/example-k8s/package.json +3 -3
  42. package/example-writes/package-lock.json +14 -14
  43. package/example-writes/package.json +3 -3
  44. package/package.json +8 -6
  45. package/web/app.js +714 -57
  46. package/web/carve-steps.js +621 -0
  47. package/web/carve-steps.test.js +248 -0
  48. package/web/demos.js +71 -0
  49. package/web/demos.test.js +83 -0
  50. package/web/index.html +93 -1
  51. package/web/json-view.js +334 -0
  52. package/web/json-view.test.js +218 -0
  53. package/web/layout-store.js +164 -4
  54. package/web/layout-store.test.js +226 -1
  55. package/web/panel.js +28 -0
  56. package/web/theme.js +57 -1
package/dist/cli.js CHANGED
@@ -1,8 +1,1047 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res, err) => function __init() {
4
+ if (err) throw err[0];
5
+ try {
6
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
7
+ } catch (e) {
8
+ throw err = [e], e;
9
+ }
10
+ };
11
+ var __export = (target, all) => {
12
+ for (var name in all)
13
+ __defProp(target, name, { get: all[name], enumerable: true });
14
+ };
15
+
16
+ // node_modules/@intentius/chant-k8s-client/src/errors.ts
17
+ function firstLine(text, max = 300) {
18
+ const line = text.split("\n").find((l) => l.trim().length > 0)?.trim();
19
+ if (!line) return void 0;
20
+ return line.length > max ? `${line.slice(0, max - 3)}...` : line;
21
+ }
22
+ var K8sApiError, K8sTransportError, K8sClientUnavailableError, ExecCredentialNotAllowedError, FieldManagerError, KubeConfigError, UnknownResourceError;
23
+ var init_errors = __esm({
24
+ "node_modules/@intentius/chant-k8s-client/src/errors.ts"() {
25
+ K8sApiError = class _K8sApiError extends Error {
26
+ constructor(statusCode, reason, apiMessage, target, status) {
27
+ super(
28
+ `${target ? `${target}: ` : ""}${apiMessage || "request failed"} (HTTP ${statusCode}${reason ? `, ${reason}` : ""})`
29
+ );
30
+ this.statusCode = statusCode;
31
+ this.reason = reason;
32
+ this.apiMessage = apiMessage;
33
+ this.target = target;
34
+ this.status = status;
35
+ this.name = "K8sApiError";
36
+ }
37
+ statusCode;
38
+ reason;
39
+ apiMessage;
40
+ target;
41
+ status;
42
+ /**
43
+ * Which cluster the failing read actually talked to, e.g.
44
+ * `context "k3d-fountain-local" (bound by k8s.profiles.local.context)`.
45
+ * Stamped by the client that issued the request (chant #1488) — a
46
+ * `read-failed` that does not name the cluster it read cost an afternoon on
47
+ * a laptop with two k3d clusters, so the failure carries it from birth.
48
+ */
49
+ contextNote;
50
+ /** The object is not there. The only failure that establishes absence. */
51
+ get notFound() {
52
+ return this.statusCode === 404 || this.reason === "NotFound";
53
+ }
54
+ /** RBAC denied the read. Proves nothing about whether the object exists. */
55
+ get forbidden() {
56
+ return this.statusCode === 403 || this.reason === "Forbidden";
57
+ }
58
+ /** No usable credentials for this cluster. */
59
+ get unauthorized() {
60
+ return this.statusCode === 401 || this.reason === "Unauthorized";
61
+ }
62
+ /**
63
+ * Server-side-apply field-ownership conflict. `./conflict.ts`'s
64
+ * {@link import("./conflict").FieldManagerConflictError} is the presented
65
+ * form (chant #1075); this predicate still answers for both.
66
+ */
67
+ get conflict() {
68
+ return this.statusCode === 409 || this.reason === "Conflict";
69
+ }
70
+ /**
71
+ * Build from a raw response body, which is a `Status` on every well-behaved
72
+ * Kubernetes error and occasionally plain text from a proxy in front of one.
73
+ */
74
+ static fromResponse(statusCode, body, target) {
75
+ let status;
76
+ try {
77
+ const parsed = JSON.parse(body);
78
+ if (parsed && typeof parsed === "object" && parsed.kind === "Status") {
79
+ status = parsed;
80
+ }
81
+ } catch {
82
+ }
83
+ const message = status?.message ?? firstLine(body) ?? "";
84
+ return new _K8sApiError(statusCode, status?.reason, message, target, status);
85
+ }
86
+ };
87
+ K8sTransportError = class extends Error {
88
+ constructor(message, target, options) {
89
+ super(target ? `${target}: ${message}` : message);
90
+ this.target = target;
91
+ this.name = "K8sTransportError";
92
+ if (options && "cause" in options) this.cause = options.cause;
93
+ }
94
+ target;
95
+ /** Which cluster context the failed request was aimed at — see {@link K8sApiError.contextNote}. */
96
+ contextNote;
97
+ };
98
+ K8sClientUnavailableError = class extends Error {
99
+ constructor(cause) {
100
+ super(
101
+ "the Kubernetes API client is unavailable \u2014 @kubernetes/client-node could not be loaded. Install it with `npm i @intentius/chant-k8s-client` (it is an optional dependency of @intentius/chant-lexicon-k8s, so `--omit=optional` installs skip it)."
102
+ );
103
+ this.name = "K8sClientUnavailableError";
104
+ if (cause !== void 0) this.cause = cause;
105
+ }
106
+ };
107
+ ExecCredentialNotAllowedError = class extends Error {
108
+ constructor(command, allowed) {
109
+ super(
110
+ `k8s: the kubeconfig for this context authenticates with the exec credential plugin "${command}", which is not on chant's allowlist (${allowed.join(", ")}). An exec plugin is an arbitrary binary named in a file chant did not write. If "${command}" is expected, add it to k8s.execCredentialPlugins in chant.config.ts.`
111
+ );
112
+ this.command = command;
113
+ this.allowed = allowed;
114
+ this.name = "ExecCredentialNotAllowedError";
115
+ }
116
+ command;
117
+ allowed;
118
+ };
119
+ FieldManagerError = class extends Error {
120
+ constructor(message) {
121
+ super(`k8s: ${message}`);
122
+ this.name = "FieldManagerError";
123
+ }
124
+ };
125
+ KubeConfigError = class extends Error {
126
+ constructor(message) {
127
+ super(`k8s: ${message}`);
128
+ this.name = "KubeConfigError";
129
+ }
130
+ };
131
+ UnknownResourceError = class extends Error {
132
+ constructor(selectorText2, message) {
133
+ super(message ?? `k8s: the cluster's API discovery reports no resource matching "${selectorText2}"`);
134
+ this.selectorText = selectorText2;
135
+ this.name = "UnknownResourceError";
136
+ }
137
+ selectorText;
138
+ };
139
+ }
140
+ });
141
+
142
+ // node_modules/@intentius/chant-k8s-client/src/credentials.ts
143
+ function execConfigOf(user) {
144
+ if (!user) return void 0;
145
+ if (user.exec) return user.exec;
146
+ const providerExec = user.authProvider?.config?.exec;
147
+ if (providerExec) return providerExec;
148
+ return void 0;
149
+ }
150
+ function execCommandName(command) {
151
+ const base = command.split(/[\\/]/).pop() ?? command;
152
+ return base.replace(/\.(exe|cmd|bat)$/i, "");
153
+ }
154
+ function assertExecCredentialAllowed(user, allowlist = DEFAULT_EXEC_ALLOWLIST) {
155
+ const exec = execConfigOf(user);
156
+ if (!exec?.command) return;
157
+ const name = execCommandName(exec.command);
158
+ if (allowlist.some((entry) => execCommandName(entry) === name)) return;
159
+ throw new ExecCredentialNotAllowedError(exec.command, allowlist);
160
+ }
161
+ function credentialPathOf(user) {
162
+ const exec = execConfigOf(user);
163
+ if (exec?.command) return { credential: "exec-plugin", execCommand: exec.command };
164
+ if (user?.authProvider?.name) return { credential: "auth-provider" };
165
+ if (user?.token) return { credential: "token" };
166
+ if (user?.certData || user?.certFile) return { credential: "client-certificate" };
167
+ if (user?.username) return { credential: "basic-auth" };
168
+ return { credential: "none" };
169
+ }
170
+ var DEFAULT_EXEC_ALLOWLIST;
171
+ var init_credentials = __esm({
172
+ "node_modules/@intentius/chant-k8s-client/src/credentials.ts"() {
173
+ init_errors();
174
+ DEFAULT_EXEC_ALLOWLIST = [
175
+ "aws",
176
+ // EKS — `aws eks get-token`
177
+ "aws-iam-authenticator",
178
+ // EKS, pre-`aws eks get-token`
179
+ "gke-gcloud-auth-plugin",
180
+ // GKE
181
+ "kubelogin",
182
+ // AKS
183
+ "kubectl"
184
+ // OIDC via `kubectl oidc-login`, and k3d/kind setups
185
+ ];
186
+ }
187
+ });
188
+
189
+ // node_modules/@intentius/chant-k8s-client/src/conflict.ts
190
+ function renderConflictReport(report) {
191
+ const { conflicts, fieldManager, target } = report;
192
+ const subject = target ? `${target}` : "this object";
193
+ if (conflicts.length === 0) {
194
+ return `k8s: server-side apply of ${subject} was refused with a field-ownership conflict, but the API server reported no field causes${report.apiMessage ? ` \u2014 it said: ${report.apiMessage}` : ""}. chant applied as field manager "${fieldManager}".`;
195
+ }
196
+ const grouped = /* @__PURE__ */ new Map();
197
+ for (const conflict of conflicts) {
198
+ const list3 = grouped.get(conflict.manager) ?? [];
199
+ if (!list3.includes(conflict.field)) list3.push(conflict.field);
200
+ grouped.set(conflict.manager, list3);
201
+ }
202
+ const count = new Set(conflicts.map((c) => c.field)).size;
203
+ const lines = [
204
+ `k8s: server-side apply of ${subject} was refused \u2014 ${count} ${count === 1 ? "field is" : "fields are"} owned by another field manager.`,
205
+ ""
206
+ ];
207
+ for (const [manager, fields] of [...grouped].sort(([a], [b]) => a.localeCompare(b))) {
208
+ lines.push(` "${manager}" owns:`);
209
+ for (const field of [...fields].sort()) lines.push(` ${field}`);
210
+ }
211
+ lines.push(
212
+ "",
213
+ `chant applied as field manager "${fieldManager}". Taking these fields means the managers above`,
214
+ `stop owning them, and will contest them again on their next apply.`,
215
+ "",
216
+ "chant does not force this for you. Either:",
217
+ " - remove the contested fields from your chant source, leaving them to their current owner; or",
218
+ " - re-run this apply with force-conflicts on, deliberately (the `force: true` activity argument,",
219
+ " or `forceConflicts: true` on ApplyOp), which transfers ownership to chant."
220
+ );
221
+ return lines.join("\n");
222
+ }
223
+ function parseFieldConflicts(status, message) {
224
+ const fromCauses = causesOf(status);
225
+ if (fromCauses.length > 0) return fromCauses;
226
+ return parseConflictMessage(message ?? status?.message ?? "");
227
+ }
228
+ function causesOf(status) {
229
+ const details = status?.details;
230
+ if (!details || typeof details !== "object") return [];
231
+ const causes = details.causes;
232
+ if (!Array.isArray(causes)) return [];
233
+ const out = [];
234
+ for (const raw of causes) {
235
+ if (!raw || typeof raw !== "object") continue;
236
+ if (raw.type !== void 0 && raw.type !== "FieldManagerConflict") continue;
237
+ const field = typeof raw.field === "string" ? raw.field : void 0;
238
+ if (!field) continue;
239
+ const { manager, apiVersion } = parseCauseMessage(raw.message ?? "");
240
+ out.push({
241
+ manager: manager ?? "an unnamed manager",
242
+ field,
243
+ ...apiVersion ? { apiVersion } : {}
244
+ });
245
+ }
246
+ return out;
247
+ }
248
+ function parseCauseMessage(message) {
249
+ const manager = /conflicts? with "([^"]+)"/.exec(message)?.[1];
250
+ const apiVersion = /\busing ([^\s:]+)\b/.exec(message)?.[1];
251
+ return { ...manager ? { manager } : {}, ...apiVersion ? { apiVersion } : {} };
252
+ }
253
+ function parseConflictMessage(message) {
254
+ if (!message) return [];
255
+ const out = [];
256
+ let manager;
257
+ let apiVersion;
258
+ for (const rawLine of message.split("\n")) {
259
+ const line = rawLine.trim();
260
+ if (line.length === 0) continue;
261
+ const header = /conflicts? with "([^"]+)"(?: using ([^\s:]+))?/.exec(line);
262
+ if (header) {
263
+ manager = header[1];
264
+ apiVersion = header[2];
265
+ const inline = /:\s*(\.[^\s]+)$/.exec(line);
266
+ if (inline) out.push({ manager, field: inline[1], ...apiVersion ? { apiVersion } : {} });
267
+ continue;
268
+ }
269
+ const bullet = /^-\s*(\S.*)$/.exec(line);
270
+ if (bullet && manager) {
271
+ out.push({ manager, field: bullet[1].trim(), ...apiVersion ? { apiVersion } : {} });
272
+ }
273
+ }
274
+ return out;
275
+ }
276
+ function asFieldManagerConflict(error, fieldManager) {
277
+ if (!(error instanceof K8sApiError) || !error.conflict) return error;
278
+ if (error instanceof FieldManagerConflictError) return error;
279
+ return new FieldManagerConflictError(
280
+ error.statusCode,
281
+ error.apiMessage,
282
+ parseFieldConflicts(error.status, error.apiMessage),
283
+ fieldManager,
284
+ error.target,
285
+ error.status
286
+ );
287
+ }
288
+ var FieldManagerConflictError;
289
+ var init_conflict = __esm({
290
+ "node_modules/@intentius/chant-k8s-client/src/conflict.ts"() {
291
+ init_errors();
292
+ FieldManagerConflictError = class extends K8sApiError {
293
+ /** Every contested field, in the order the server reported them. */
294
+ conflicts;
295
+ /** The field manager chant applied as, and which was refused. */
296
+ fieldManager;
297
+ constructor(statusCode, apiMessage, conflicts, fieldManager, target, status) {
298
+ super(statusCode, status?.reason ?? "Conflict", apiMessage, target, status);
299
+ this.name = "FieldManagerConflictError";
300
+ this.conflicts = conflicts;
301
+ this.fieldManager = fieldManager;
302
+ this.message = renderConflictReport({ conflicts, fieldManager, target, apiMessage });
303
+ }
304
+ /** Contested paths grouped by the manager that owns them, managers sorted. */
305
+ get byManager() {
306
+ const grouped = {};
307
+ for (const conflict of this.conflicts) {
308
+ (grouped[conflict.manager] ??= []).push(conflict.field);
309
+ }
310
+ return Object.fromEntries(
311
+ Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([manager, fields]) => [manager, [...fields].sort()])
312
+ );
313
+ }
314
+ /** The competing managers, sorted. */
315
+ get managers() {
316
+ return Object.keys(this.byManager);
317
+ }
318
+ /** The contested paths, sorted and deduplicated. */
319
+ get fields() {
320
+ return [...new Set(this.conflicts.map((c) => c.field))].sort();
321
+ }
322
+ };
323
+ }
324
+ });
325
+
326
+ // node_modules/@intentius/chant-k8s-client/src/field-manager.ts
327
+ function fieldManagerFor(identity) {
328
+ const stack = identity?.stack?.trim();
329
+ if (!stack) return CHANT_FIELD_MANAGER;
330
+ const manager = `${CHANT_FIELD_MANAGER}${FIELD_MANAGER_SEPARATOR}${stack}`;
331
+ assertValidFieldManager(manager, stack);
332
+ return manager;
333
+ }
334
+ function assertValidFieldManager(manager, stack) {
335
+ const source = stack === void 0 ? `field manager "${manager}"` : `ownership.stack "${stack}"`;
336
+ if (manager.length === 0) {
337
+ throw new FieldManagerError(`${source} produces an empty field manager, which the API server rejects`);
338
+ }
339
+ if (manager.length > FIELD_MANAGER_MAX_LENGTH) {
340
+ throw new FieldManagerError(
341
+ `${source} produces the field manager "${manager}" (${manager.length} characters), over the API server's ${FIELD_MANAGER_MAX_LENGTH}-character limit`
342
+ );
343
+ }
344
+ const badIndex = [...manager].findIndex((ch) => {
345
+ const code = ch.codePointAt(0) ?? 0;
346
+ return code <= 32 || code === 127;
347
+ });
348
+ if (badIndex !== -1) {
349
+ throw new FieldManagerError(
350
+ `${source} produces the field manager "${manager}", which contains whitespace or a control character at position ${badIndex}. A field manager is an identity recorded on every object chant applies; keep it to printable, space-free text.`
351
+ );
352
+ }
353
+ }
354
+ function isChantFieldManager(manager) {
355
+ if (!manager) return false;
356
+ return manager === CHANT_FIELD_MANAGER || manager.startsWith(`${CHANT_FIELD_MANAGER}${FIELD_MANAGER_SEPARATOR}`);
357
+ }
358
+ function chantStackOf(manager) {
359
+ if (!manager || !isChantFieldManager(manager)) return void 0;
360
+ const stack = manager.slice(CHANT_FIELD_MANAGER.length + FIELD_MANAGER_SEPARATOR.length);
361
+ return stack.length > 0 ? stack : void 0;
362
+ }
363
+ var CHANT_FIELD_MANAGER, FIELD_MANAGER_SEPARATOR, FIELD_MANAGER_MAX_LENGTH;
364
+ var init_field_manager = __esm({
365
+ "node_modules/@intentius/chant-k8s-client/src/field-manager.ts"() {
366
+ init_errors();
367
+ CHANT_FIELD_MANAGER = "chant";
368
+ FIELD_MANAGER_SEPARATOR = ":";
369
+ FIELD_MANAGER_MAX_LENGTH = 128;
370
+ }
371
+ });
372
+
373
+ // node_modules/@intentius/chant-k8s-client/src/concurrency.ts
374
+ async function mapConcurrent(items, fn, limit = DEFAULT_CONCURRENCY) {
375
+ const width = Math.max(1, Math.min(limit, items.length));
376
+ const results = new Array(items.length);
377
+ let next = 0;
378
+ async function worker() {
379
+ while (true) {
380
+ const index = next++;
381
+ if (index >= items.length) return;
382
+ results[index] = await fn(items[index], index);
383
+ }
384
+ }
385
+ await Promise.all(Array.from({ length: width }, () => worker()));
386
+ return results;
387
+ }
388
+ var DEFAULT_CONCURRENCY;
389
+ var init_concurrency = __esm({
390
+ "node_modules/@intentius/chant-k8s-client/src/concurrency.ts"() {
391
+ DEFAULT_CONCURRENCY = 8;
392
+ }
393
+ });
394
+
395
+ // node_modules/@intentius/chant-k8s-client/src/kubeconfig.ts
396
+ import { delimiter } from "node:path";
397
+ function kubeconfigPathList() {
398
+ const raw = process.env.KUBECONFIG;
399
+ if (!raw) return [];
400
+ return raw.split(delimiter).filter((f) => f.length > 0);
401
+ }
402
+ function mergeKubeconfigFiles(mod, files) {
403
+ const clusters = [];
404
+ const users = [];
405
+ const contexts = [];
406
+ const seenClusters = /* @__PURE__ */ new Set();
407
+ const seenUsers = /* @__PURE__ */ new Set();
408
+ const seenContexts = /* @__PURE__ */ new Set();
409
+ let currentContext;
410
+ for (const file of files) {
411
+ const part = new mod.KubeConfig();
412
+ try {
413
+ part.loadFromFile(file);
414
+ } catch {
415
+ continue;
416
+ }
417
+ for (const cluster of part.getClusters()) {
418
+ if (seenClusters.has(cluster.name)) continue;
419
+ seenClusters.add(cluster.name);
420
+ clusters.push(cluster);
421
+ }
422
+ for (const user of part.getUsers()) {
423
+ if (seenUsers.has(user.name)) continue;
424
+ seenUsers.add(user.name);
425
+ users.push(user);
426
+ }
427
+ for (const context of part.getContexts()) {
428
+ if (seenContexts.has(context.name)) continue;
429
+ seenContexts.add(context.name);
430
+ contexts.push(context);
431
+ }
432
+ if (currentContext === void 0) {
433
+ const current = part.getCurrentContext();
434
+ if (current) currentContext = current;
435
+ }
436
+ }
437
+ const kc = new mod.KubeConfig();
438
+ kc.loadFromOptions({ clusters, users, contexts, currentContext });
439
+ return kc;
440
+ }
441
+ function loadKubeConfig(mod, options = {}) {
442
+ if (options.kubeconfig !== void 0) {
443
+ const kc2 = new mod.KubeConfig();
444
+ kc2.loadFromString(options.kubeconfig);
445
+ return { kc: kc2, source: "explicit-string" };
446
+ }
447
+ if (options.kubeconfigPath !== void 0) {
448
+ const kc2 = new mod.KubeConfig();
449
+ kc2.loadFromFile(options.kubeconfigPath);
450
+ return { kc: kc2, source: "explicit-path" };
451
+ }
452
+ const files = kubeconfigPathList();
453
+ if (files.length > 1) return { kc: mergeKubeconfigFiles(mod, files), source: "default" };
454
+ const kc = new mod.KubeConfig();
455
+ kc.loadFromDefault(void 0, true);
456
+ return { kc, source: kc.getCurrentContext() === "inCluster" ? "in-cluster" : "default" };
457
+ }
458
+ var init_kubeconfig = __esm({
459
+ "node_modules/@intentius/chant-k8s-client/src/kubeconfig.ts"() {
460
+ }
461
+ });
462
+
463
+ // node_modules/@intentius/chant-k8s-client/src/client.ts
464
+ async function loadClientNode() {
465
+ if (!clientNodeModule) {
466
+ clientNodeModule = import("@kubernetes/client-node").catch((err) => {
467
+ clientNodeModule = void 0;
468
+ throw new K8sClientUnavailableError(err);
469
+ });
470
+ }
471
+ return clientNodeModule;
472
+ }
473
+ async function isK8sClientAvailable() {
474
+ try {
475
+ await loadClientNode();
476
+ return true;
477
+ } catch {
478
+ return false;
479
+ }
480
+ }
481
+ async function readAmbientContext(options = {}) {
482
+ try {
483
+ const { kc } = loadKubeConfig(await loadClientNode(), options);
484
+ return kc.getCurrentContext() || void 0;
485
+ } catch {
486
+ return void 0;
487
+ }
488
+ }
489
+ async function readKubeconfigView(options = {}) {
490
+ let kc;
491
+ try {
492
+ ({ kc } = loadKubeConfig(await loadClientNode(), options));
493
+ } catch {
494
+ return { contexts: [] };
495
+ }
496
+ try {
497
+ const servers = /* @__PURE__ */ new Map();
498
+ for (const cluster of kc.getClusters()) servers.set(cluster.name, cluster.server || void 0);
499
+ const contexts = kc.getContexts().map((context) => {
500
+ const server = servers.get(context.cluster);
501
+ return {
502
+ name: context.name,
503
+ cluster: context.cluster ?? "",
504
+ ...context.user ? { user: context.user } : {},
505
+ ...context.namespace ? { namespace: context.namespace } : {},
506
+ ...server ? { server } : {}
507
+ };
508
+ });
509
+ return { contexts, ...kc.getCurrentContext() ? { currentContext: kc.getCurrentContext() } : {} };
510
+ } catch {
511
+ return { contexts: [] };
512
+ }
513
+ }
514
+ async function createK8sClient(options = {}) {
515
+ const mod = await loadClientNode();
516
+ const { kc, source: kubeconfigSource } = loadKubeConfig(mod, options);
517
+ if (options.context !== void 0) {
518
+ if (!kc.getContextObject(options.context)) {
519
+ const known = kc.getContexts().map((c) => c.name);
520
+ throw new KubeConfigError(
521
+ `the kubeconfig has no context named "${options.context}" (it has ${known.length > 0 ? known.map((n) => `"${n}"`).join(", ") : "no contexts at all"}). This is the context the environment is bound to via k8s.profiles.<env>.context.`
522
+ );
523
+ }
524
+ kc.setCurrentContext(options.context);
525
+ }
526
+ const cluster = kc.getCurrentCluster();
527
+ if (!cluster) {
528
+ throw new KubeConfigError(
529
+ `the kubeconfig resolves to no cluster for context "${kc.getCurrentContext() || "(unset)"}"`
530
+ );
531
+ }
532
+ const user = kc.getCurrentUser();
533
+ assertExecCredentialAllowed(user, options.execAllowlist ?? DEFAULT_EXEC_ALLOWLIST);
534
+ const provenance = {
535
+ server: cluster.server,
536
+ context: kc.getCurrentContext() || void 0,
537
+ contextSource: options.contextSource ?? "ambient",
538
+ kubeconfigSource,
539
+ ...credentialPathOf(user)
540
+ };
541
+ const configuration = mod.createConfiguration({
542
+ baseServer: new mod.ServerConfiguration(cluster.server, {}),
543
+ authMethods: { default: kc },
544
+ ...options.requestLayer ? {
545
+ httpApi: mod.wrapHttpLibrary({
546
+ send: (request) => Promise.resolve(
547
+ options.requestLayer.send(request)
548
+ )
549
+ })
550
+ } : {}
551
+ });
552
+ const defaultNamespace = kc.getContextObject(kc.getCurrentContext())?.namespace || "default";
553
+ const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
554
+ const contextNote = `context "${provenance.context ?? "(unset)"}" (${options.contextLabel ?? provenance.contextSource})`;
555
+ function noted(err) {
556
+ err.contextNote = contextNote;
557
+ return err;
558
+ }
559
+ const discoveryCache = /* @__PURE__ */ new Map();
560
+ let rootDiscoveryCache;
561
+ async function send(path, method, opts = {}) {
562
+ const ctx = configuration.baseServer.makeRequestContext(path, method);
563
+ ctx.setHeaderParam("Accept", "application/json");
564
+ for (const [key, value] of Object.entries(opts.query ?? {})) ctx.setQueryParam(key, value);
565
+ if (opts.body !== void 0) {
566
+ ctx.setHeaderParam("Content-Type", opts.contentType ?? "application/json");
567
+ ctx.setBody(opts.body);
568
+ }
569
+ if (opts.signal) ctx.setSignal(opts.signal);
570
+ await kc.applySecurityAuthentication(ctx);
571
+ let response;
572
+ try {
573
+ response = await configuration.httpApi.send(ctx).toPromise();
574
+ } catch (err) {
575
+ throw noted(
576
+ new K8sTransportError(err instanceof Error ? err.message : String(err), opts.target ?? `${method} ${path}`, {
577
+ cause: err
578
+ })
579
+ );
580
+ }
581
+ let text;
582
+ try {
583
+ text = await response.body.text();
584
+ } catch (err) {
585
+ throw noted(
586
+ new K8sTransportError(
587
+ `response body could not be read: ${err instanceof Error ? err.message : String(err)}`,
588
+ opts.target ?? `${method} ${path}`,
589
+ { cause: err }
590
+ )
591
+ );
592
+ }
593
+ return { status: response.httpStatusCode, body: text };
594
+ }
595
+ async function sendJson(path, method, opts = {}) {
596
+ const { status, body } = await send(path, method, opts);
597
+ if (status < 200 || status > 299) {
598
+ throw noted(K8sApiError.fromResponse(status, body, opts.target));
599
+ }
600
+ try {
601
+ return JSON.parse(body);
602
+ } catch (err) {
603
+ throw noted(
604
+ new K8sTransportError(
605
+ `the API server returned HTTP ${status} with a body that is not JSON`,
606
+ opts.target ?? `${method} ${path}`,
607
+ { cause: err }
608
+ )
609
+ );
610
+ }
611
+ }
612
+ async function apiResourceList(apiVersion, signal) {
613
+ const cached = discoveryCache.get(apiVersion);
614
+ if (cached) return cached;
615
+ const pending = (async () => {
616
+ try {
617
+ return await sendJson(apiVersionPath(apiVersion), "GET", {
618
+ signal,
619
+ target: `discovery ${apiVersion}`
620
+ });
621
+ } catch (err) {
622
+ if (err instanceof K8sApiError && err.notFound) return null;
623
+ discoveryCache.delete(apiVersion);
624
+ throw err;
625
+ }
626
+ })();
627
+ discoveryCache.set(apiVersion, pending);
628
+ return pending;
629
+ }
630
+ async function rootDiscovery(signal) {
631
+ if (rootDiscoveryCache) return rootDiscoveryCache;
632
+ rootDiscoveryCache = (async () => {
633
+ const core = await sendJson("/api", "GET", {
634
+ signal,
635
+ target: "discovery /api"
636
+ });
637
+ const groups = await sendJson("/apis", "GET", { signal, target: "discovery /apis" });
638
+ return {
639
+ coreVersions: core.versions ?? ["v1"],
640
+ groups: (groups.groups ?? []).map((group) => ({
641
+ preferred: group.preferredVersion?.groupVersion,
642
+ versions: (group.versions ?? []).map((v) => v.groupVersion).filter((gv) => typeof gv === "string" && gv.length > 0)
643
+ }))
644
+ };
645
+ })().catch((err) => {
646
+ rootDiscoveryCache = void 0;
647
+ throw err;
648
+ });
649
+ return rootDiscoveryCache;
650
+ }
651
+ async function servedGroupVersions(signal) {
652
+ const root = await rootDiscovery(signal);
653
+ const out = [...root.coreVersions];
654
+ for (const group of root.groups) {
655
+ if (group.preferred) out.push(group.preferred);
656
+ for (const gv of group.versions) {
657
+ if (gv !== group.preferred) out.push(gv);
658
+ }
659
+ }
660
+ return [...new Set(out)];
661
+ }
662
+ async function preferredGroupVersions(signal) {
663
+ const root = await rootDiscovery(signal);
664
+ const core = root.coreVersions.includes("v1") ? "v1" : root.coreVersions[0];
665
+ const out = core ? [core] : [];
666
+ for (const group of root.groups) {
667
+ const gv = group.preferred ?? group.versions[0];
668
+ if (gv) out.push(gv);
669
+ }
670
+ return [...new Set(out)];
671
+ }
672
+ function toInfo(apiVersion, entry) {
673
+ const [group, version] = splitApiVersion(apiVersion);
674
+ return {
675
+ name: entry.name ?? "",
676
+ singularName: entry.singularName || void 0,
677
+ kind: entry.kind ?? "",
678
+ namespaced: entry.namespaced === true,
679
+ verbs: entry.verbs ?? [],
680
+ shortNames: entry.shortNames,
681
+ group,
682
+ version,
683
+ apiVersion
684
+ };
685
+ }
686
+ async function resolveByGvk(apiVersion, kind, signal) {
687
+ const list4 = await apiResourceList(apiVersion, signal);
688
+ if (!list4) return void 0;
689
+ const entry = (list4.resources ?? []).find((r) => r.kind === kind && !(r.name ?? "").includes("/"));
690
+ return entry ? toInfo(apiVersion, entry) : void 0;
691
+ }
692
+ async function resolveByResourceString(resource, group, signal) {
693
+ const dot = resource.indexOf(".");
694
+ const bare = dot === -1 ? resource : resource.slice(0, dot);
695
+ const fromString = dot === -1 ? void 0 : resource.slice(dot + 1);
696
+ const wantedGroup = fromString ?? group;
697
+ const needle = bare.toLowerCase();
698
+ const all = await servedGroupVersions(signal);
699
+ const candidates = wantedGroup === void 0 ? all : all.filter((gv) => splitApiVersion(gv)[0] === (wantedGroup === "" ? "" : wantedGroup));
700
+ const lists = await mapConcurrent(
701
+ candidates,
702
+ async (gv) => ({ gv, list: await apiResourceList(gv, signal).catch(() => null) }),
703
+ concurrency
704
+ );
705
+ const matchers = [
706
+ (r) => (r.name ?? "").toLowerCase() === needle,
707
+ (r) => (r.singularName ?? "").toLowerCase() === needle,
708
+ (r) => (r.kind ?? "").toLowerCase() === needle,
709
+ (r) => (r.shortNames ?? []).some((s) => s.toLowerCase() === needle)
710
+ ];
711
+ for (const matches of matchers) {
712
+ for (const { gv, list: list4 } of lists) {
713
+ const entry = (list4?.resources ?? []).find((r) => !(r.name ?? "").includes("/") && matches(r));
714
+ if (entry) return toInfo(gv, entry);
715
+ }
716
+ }
717
+ return void 0;
718
+ }
719
+ async function resolve8(selector, signal) {
720
+ return "apiVersion" in selector ? resolveByGvk(selector.apiVersion, selector.kind, signal) : resolveByResourceString(selector.resource, selector.group, signal);
721
+ }
722
+ async function resolveOrThrow(selector, signal) {
723
+ const info = await resolve8(selector, signal);
724
+ if (!info) throw new UnknownResourceError(selectorText(selector));
725
+ return info;
726
+ }
727
+ function objectPath(info, name, namespace) {
728
+ const parts = [apiVersionPath(info.apiVersion)];
729
+ if (info.namespaced) parts.push("namespaces", encodeURIComponent(namespace || defaultNamespace));
730
+ parts.push(info.name);
731
+ if (name) parts.push(encodeURIComponent(name));
732
+ return parts.join("/");
733
+ }
734
+ async function pathFor(ref, signal) {
735
+ const info = await resolve8({ apiVersion: ref.apiVersion, kind: ref.kind }, signal);
736
+ if (!info) return void 0;
737
+ return objectPath(info, ref.name, ref.namespace);
738
+ }
739
+ async function read(ref, opts = {}) {
740
+ const info = await resolveOrThrow({ apiVersion: ref.apiVersion, kind: ref.kind }, opts.signal);
741
+ return sendJson(objectPath(info, ref.name, ref.namespace), "GET", {
742
+ signal: opts.signal,
743
+ target: refText(ref)
744
+ });
745
+ }
746
+ async function readIfPresent(ref, opts = {}) {
747
+ try {
748
+ return await read(ref, opts);
749
+ } catch (err) {
750
+ if (err instanceof K8sApiError && err.notFound) return void 0;
751
+ throw err;
752
+ }
753
+ }
754
+ async function list3(selector, opts = {}) {
755
+ const info = await resolveOrThrow(selector, opts.signal);
756
+ const items = [];
757
+ let cont;
758
+ do {
759
+ const query = {};
760
+ if (cont) query.continue = cont;
761
+ if (opts.labelSelector) query.labelSelector = opts.labelSelector;
762
+ const page = await sendJson(
763
+ // Omitting the namespace segment lists across all namespaces, which is
764
+ // what `kubectl get <kind> -A` does and what the import path wants.
765
+ opts.namespace ? objectPath(info, void 0, opts.namespace) : `${apiVersionPath(info.apiVersion)}/${info.name}`,
766
+ "GET",
767
+ {
768
+ signal: opts.signal,
769
+ query: Object.keys(query).length > 0 ? query : void 0,
770
+ target: `list ${selectorText(selector)}`
771
+ }
772
+ );
773
+ items.push(...page.items ?? []);
774
+ cont = page.metadata?.continue || void 0;
775
+ } while (cont);
776
+ return items;
777
+ }
778
+ async function apply(object, opts = {}) {
779
+ const apiVersion = object.apiVersion;
780
+ const kind = object.kind;
781
+ if (!apiVersion || !kind) {
782
+ throw new KubeConfigError(
783
+ `cannot apply an object without both apiVersion and kind (got apiVersion=${String(apiVersion)}, kind=${String(kind)})`
784
+ );
785
+ }
786
+ const name = object.metadata?.name;
787
+ if (!name) {
788
+ throw new KubeConfigError(`cannot apply a ${apiVersion} ${kind} without metadata.name`);
789
+ }
790
+ const fieldManager = opts.fieldManager ?? CHANT_FIELD_MANAGER;
791
+ assertValidFieldManager(fieldManager);
792
+ const info = await resolveOrThrow({ apiVersion, kind }, opts.signal);
793
+ const query = {
794
+ fieldManager,
795
+ force: String(opts.force ?? false)
796
+ };
797
+ if (opts.dryRun) query.dryRun = "All";
798
+ try {
799
+ return await sendJson(objectPath(info, name, object.metadata?.namespace), "PATCH", {
800
+ // Server-side apply. JSON is valid YAML, so the JSON body is accepted
801
+ // under the apply-patch content type without a YAML round trip.
802
+ contentType: "application/apply-patch+yaml",
803
+ body: JSON.stringify(object),
804
+ query,
805
+ signal: opts.signal,
806
+ target: refText({ apiVersion, kind, name, namespace: object.metadata?.namespace })
807
+ });
808
+ } catch (err) {
809
+ throw asFieldManagerConflict(err, fieldManager);
810
+ }
811
+ }
812
+ async function readLog(ref, opts = {}) {
813
+ const info = await resolveOrThrow({ apiVersion: ref.apiVersion, kind: ref.kind }, opts.signal);
814
+ const query = {};
815
+ if (opts.container) query.container = opts.container;
816
+ if (opts.previous) query.previous = "true";
817
+ if (opts.tailLines !== void 0) query.tailLines = String(opts.tailLines);
818
+ if (opts.sinceSeconds !== void 0) query.sinceSeconds = String(opts.sinceSeconds);
819
+ if (opts.timestamps) query.timestamps = "true";
820
+ const target = `${refText(ref)} logs`;
821
+ const { status, body } = await send(`${objectPath(info, ref.name, ref.namespace)}/log`, "GET", {
822
+ query: Object.keys(query).length > 0 ? query : void 0,
823
+ signal: opts.signal,
824
+ target
825
+ });
826
+ if (status < 200 || status > 299) {
827
+ throw noted(K8sApiError.fromResponse(status, body, target));
828
+ }
829
+ return body;
830
+ }
831
+ async function remove(ref, opts = {}) {
832
+ const info = await resolveOrThrow({ apiVersion: ref.apiVersion, kind: ref.kind }, opts.signal);
833
+ const query = {};
834
+ if (opts.propagationPolicy) query.propagationPolicy = opts.propagationPolicy;
835
+ if (opts.dryRun) query.dryRun = "All";
836
+ await sendJson(objectPath(info, ref.name, ref.namespace), "DELETE", {
837
+ signal: opts.signal,
838
+ query: Object.keys(query).length > 0 ? query : void 0,
839
+ target: refText(ref)
840
+ });
841
+ }
842
+ async function resourcesOf(apiVersion, signal) {
843
+ const list4 = await apiResourceList(apiVersion, signal);
844
+ if (!list4) return [];
845
+ return (list4.resources ?? []).filter((r) => !(r.name ?? "").includes("/")).map((entry) => toInfo(apiVersion, entry));
846
+ }
847
+ return {
848
+ provenance,
849
+ defaultNamespace,
850
+ resolve: resolve8,
851
+ read,
852
+ pathFor,
853
+ readIfPresent,
854
+ list: list3,
855
+ readLog,
856
+ apply,
857
+ delete: remove,
858
+ concurrently: (items, fn) => mapConcurrent(items, fn, concurrency),
859
+ resources: resourcesOf,
860
+ preferredGroupVersions,
861
+ discoveryCacheKeys: () => [...discoveryCache.keys()].sort()
862
+ };
863
+ }
864
+ function apiVersionPath(apiVersion) {
865
+ return apiVersion.includes("/") ? `/apis/${apiVersion}` : `/api/${apiVersion}`;
866
+ }
867
+ function splitApiVersion(apiVersion) {
868
+ const slash = apiVersion.indexOf("/");
869
+ return slash === -1 ? ["", apiVersion] : [apiVersion.slice(0, slash), apiVersion.slice(slash + 1)];
870
+ }
871
+ function selectorText(selector) {
872
+ return "apiVersion" in selector ? `${selector.apiVersion} ${selector.kind}` : selector.group ? `${selector.resource}.${selector.group}` : selector.resource;
873
+ }
874
+ function refText(ref) {
875
+ return `${ref.apiVersion} ${ref.kind} ${ref.namespace ? `${ref.namespace}/` : ""}${ref.name}`;
876
+ }
877
+ var clientNodeModule;
878
+ var init_client = __esm({
879
+ "node_modules/@intentius/chant-k8s-client/src/client.ts"() {
880
+ init_errors();
881
+ init_credentials();
882
+ init_conflict();
883
+ init_field_manager();
884
+ init_concurrency();
885
+ init_kubeconfig();
886
+ }
887
+ });
888
+
889
+ // node_modules/@intentius/chant-k8s-client/src/managed-fields.ts
890
+ function managedFieldsOf(object) {
891
+ const entries = object?.metadata?.managedFields;
892
+ if (!Array.isArray(entries)) return [];
893
+ return entries.filter((e) => e !== null && typeof e === "object").map((e) => e);
894
+ }
895
+ function fieldSetsOf(object) {
896
+ return managedFieldsOf(object).filter((entry) => typeof entry.manager === "string" && entry.manager.length > 0).map((entry) => ({
897
+ manager: entry.manager,
898
+ operation: entry.operation ?? "Update",
899
+ ...entry.apiVersion !== void 0 ? { apiVersion: entry.apiVersion } : {},
900
+ ...entry.subresource !== void 0 ? { subresource: entry.subresource } : {},
901
+ ...entry.time !== void 0 ? { time: entry.time } : {},
902
+ fields: fieldPathsOf(entry.fieldsV1)
903
+ }));
904
+ }
905
+ function managersOf(object) {
906
+ const seen = /* @__PURE__ */ new Set();
907
+ for (const entry of managedFieldsOf(object)) {
908
+ if (typeof entry.manager === "string" && entry.manager.length > 0) seen.add(entry.manager);
909
+ }
910
+ return [...seen];
911
+ }
912
+ function fieldsOwnedBy(object, manager, options = {}) {
913
+ const matches = typeof manager === "function" ? manager : (m) => m === manager;
914
+ const paths = /* @__PURE__ */ new Set();
915
+ for (const set of fieldSetsOf(object)) {
916
+ if (!matches(set.manager)) continue;
917
+ if (set.subresource !== void 0 && options.includeSubresources !== true) continue;
918
+ for (const path of set.fields) paths.add(path);
919
+ }
920
+ return [...paths].sort();
921
+ }
922
+ function chantOwnedFields(object, options = {}) {
923
+ return fieldsOwnedBy(object, isChantFieldManager, options);
924
+ }
925
+ function fieldOwners(object, options = {}) {
926
+ const owners = /* @__PURE__ */ new Map();
927
+ for (const set of fieldSetsOf(object)) {
928
+ if (set.subresource !== void 0 && options.includeSubresources !== true) continue;
929
+ for (const path of set.fields) {
930
+ const list3 = owners.get(path) ?? [];
931
+ if (!list3.includes(set.manager)) list3.push(set.manager);
932
+ owners.set(path, list3);
933
+ }
934
+ }
935
+ return owners;
936
+ }
937
+ function fieldPathsOf(fieldsV1, prefix = "") {
938
+ return [...collect(fieldsV1, prefix, /* @__PURE__ */ new Set())].sort();
939
+ }
940
+ function collect(node, prefix, into) {
941
+ if (node === null || typeof node !== "object" || Array.isArray(node)) return into;
942
+ for (const [key, child] of Object.entries(node)) {
943
+ if (key === ".") {
944
+ if (prefix !== "") into.add(prefix);
945
+ continue;
946
+ }
947
+ const segment = renderSegment(key);
948
+ if (segment === void 0) continue;
949
+ const path = `${prefix}${segment}`;
950
+ into.add(path);
951
+ collect(child, path, into);
952
+ }
953
+ return into;
954
+ }
955
+ function renderSegment(key) {
956
+ if (key.startsWith("f:")) return `.${key.slice(2)}`;
957
+ if (key.startsWith("i:")) return `[${key.slice(2)}]`;
958
+ if (key.startsWith("v:")) return `[=${key.slice(2)}]`;
959
+ if (key.startsWith("k:")) return renderKeySegment(key.slice(2));
960
+ return void 0;
961
+ }
962
+ function renderKeySegment(json) {
963
+ let parsed;
964
+ try {
965
+ parsed = JSON.parse(json);
966
+ } catch {
967
+ return `[${json}]`;
968
+ }
969
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return `[${json}]`;
970
+ const parts = Object.entries(parsed).map(
971
+ ([name, value]) => `${name}=${JSON.stringify(value)}`
972
+ );
973
+ return `[${parts.join(",")}]`;
974
+ }
975
+ var init_managed_fields = __esm({
976
+ "node_modules/@intentius/chant-k8s-client/src/managed-fields.ts"() {
977
+ init_field_manager();
978
+ }
979
+ });
980
+
981
+ // node_modules/@intentius/chant-k8s-client/src/index.ts
982
+ var src_exports = {};
983
+ __export(src_exports, {
984
+ CHANT_FIELD_MANAGER: () => CHANT_FIELD_MANAGER,
985
+ DEFAULT_CONCURRENCY: () => DEFAULT_CONCURRENCY,
986
+ DEFAULT_EXEC_ALLOWLIST: () => DEFAULT_EXEC_ALLOWLIST,
987
+ ExecCredentialNotAllowedError: () => ExecCredentialNotAllowedError,
988
+ FIELD_MANAGER_MAX_LENGTH: () => FIELD_MANAGER_MAX_LENGTH,
989
+ FIELD_MANAGER_SEPARATOR: () => FIELD_MANAGER_SEPARATOR,
990
+ FieldManagerConflictError: () => FieldManagerConflictError,
991
+ FieldManagerError: () => FieldManagerError,
992
+ K8sApiError: () => K8sApiError,
993
+ K8sClientUnavailableError: () => K8sClientUnavailableError,
994
+ K8sTransportError: () => K8sTransportError,
995
+ KubeConfigError: () => KubeConfigError,
996
+ UnknownResourceError: () => UnknownResourceError,
997
+ apiVersionPath: () => apiVersionPath,
998
+ asFieldManagerConflict: () => asFieldManagerConflict,
999
+ assertExecCredentialAllowed: () => assertExecCredentialAllowed,
1000
+ assertValidFieldManager: () => assertValidFieldManager,
1001
+ chantOwnedFields: () => chantOwnedFields,
1002
+ chantStackOf: () => chantStackOf,
1003
+ createK8sClient: () => createK8sClient,
1004
+ credentialPathOf: () => credentialPathOf,
1005
+ execCommandName: () => execCommandName,
1006
+ execConfigOf: () => execConfigOf,
1007
+ fieldManagerFor: () => fieldManagerFor,
1008
+ fieldOwners: () => fieldOwners,
1009
+ fieldPathsOf: () => fieldPathsOf,
1010
+ fieldSetsOf: () => fieldSetsOf,
1011
+ fieldsOwnedBy: () => fieldsOwnedBy,
1012
+ isChantFieldManager: () => isChantFieldManager,
1013
+ isK8sClientAvailable: () => isK8sClientAvailable,
1014
+ loadClientNode: () => loadClientNode,
1015
+ managedFieldsOf: () => managedFieldsOf,
1016
+ managersOf: () => managersOf,
1017
+ mapConcurrent: () => mapConcurrent,
1018
+ parseConflictMessage: () => parseConflictMessage,
1019
+ parseFieldConflicts: () => parseFieldConflicts,
1020
+ readAmbientContext: () => readAmbientContext,
1021
+ readKubeconfigView: () => readKubeconfigView,
1022
+ refText: () => refText,
1023
+ renderConflictReport: () => renderConflictReport,
1024
+ renderSegment: () => renderSegment,
1025
+ selectorText: () => selectorText,
1026
+ splitApiVersion: () => splitApiVersion
1027
+ });
1028
+ var init_src = __esm({
1029
+ "node_modules/@intentius/chant-k8s-client/src/index.ts"() {
1030
+ init_client();
1031
+ init_errors();
1032
+ init_field_manager();
1033
+ init_conflict();
1034
+ init_managed_fields();
1035
+ init_credentials();
1036
+ init_concurrency();
1037
+ }
1038
+ });
1039
+
1
1040
  // src/cli.ts
2
- import { resolve as resolve4, dirname as dirname6, join as join14, relative as relative3, sep } from "node:path";
3
- import { realpathSync, existsSync as existsSync10, cpSync } from "node:fs";
4
- import { spawnSync as spawnSync2 } from "node:child_process";
1041
+ import { resolve as resolve7, dirname as dirname6, join as join17 } from "node:path";
1042
+ import { realpathSync, existsSync as existsSync13, readFileSync as readFileSync14 } from "node:fs";
5
1043
  import { fileURLToPath as fileURLToPath5 } from "node:url";
1044
+ import { spawn as spawn5 } from "node:child_process";
6
1045
 
7
1046
  // src/server.ts
8
1047
  import { Hono } from "hono";
@@ -33,41 +1072,32 @@ function targetEnvOverrides(targets, chosen) {
33
1072
  }
34
1073
 
35
1074
  // src/k8s-target.ts
36
- import { execFile } from "node:child_process";
37
- import { promisify } from "node:util";
38
- var run = promisify(execFile);
39
1075
  var EMPTY = { contexts: /* @__PURE__ */ new Map(), servers: /* @__PURE__ */ new Map() };
40
- function str(v) {
41
- return typeof v === "string" && v.length > 0 ? v : void 0;
42
- }
43
- function readKubeconfigJson(json) {
1076
+ function kubeconfigFromView(view) {
44
1077
  const contexts = /* @__PURE__ */ new Map();
45
- for (const entry of json.contexts ?? []) {
46
- const name = str(entry?.name);
47
- const cluster = str(entry?.context?.cluster);
48
- if (name && cluster) contexts.set(name, cluster);
49
- }
50
1078
  const servers = /* @__PURE__ */ new Map();
51
- for (const entry of json.clusters ?? []) {
52
- const name = str(entry?.name);
53
- const server = str(entry?.cluster?.server);
54
- if (name && server) servers.set(name, server);
1079
+ for (const entry of view.contexts) {
1080
+ if (entry.name && entry.cluster) contexts.set(entry.name, entry.cluster);
1081
+ if (entry.cluster && entry.server) servers.set(entry.cluster, entry.server);
55
1082
  }
56
- const current = str(json["current-context"]);
57
- return { contexts, servers, ...current ? { currentContext: current } : {} };
1083
+ return { contexts, servers, ...view.currentContext ? { currentContext: view.currentContext } : {} };
1084
+ }
1085
+ async function loadKubeconfig(options = {}) {
1086
+ const client = await k8sClient();
1087
+ if (!client) return EMPTY;
1088
+ return kubeconfigFromView(await client.readKubeconfigView(options));
1089
+ }
1090
+ async function ambientContext(options = {}) {
1091
+ const client = await k8sClient();
1092
+ return client ? await client.readAmbientContext(options) : void 0;
58
1093
  }
59
- async function loadKubeconfig(exec = defaultExec) {
1094
+ async function k8sClient() {
60
1095
  try {
61
- const stdout = await exec("kubectl", ["config", "view", "--raw", "-o", "json"]);
62
- return readKubeconfigJson(JSON.parse(stdout));
1096
+ return await Promise.resolve().then(() => (init_src(), src_exports));
63
1097
  } catch {
64
- return EMPTY;
1098
+ return void 0;
65
1099
  }
66
1100
  }
67
- async function defaultExec(cmd, args) {
68
- const { stdout } = await run(cmd, args, { encoding: "utf8", timeout: 1e4 });
69
- return stdout;
70
- }
71
1101
  function resolveK8sTarget(profiles, env, kubeconfig) {
72
1102
  const declared = env ? profiles?.[env]?.context : void 0;
73
1103
  const context = declared ?? kubeconfig.currentContext;
@@ -88,10 +1118,10 @@ import { streamSSE } from "hono/streaming";
88
1118
  import { serveStatic } from "@hono/node-server/serve-static";
89
1119
  import { serve } from "@hono/node-server";
90
1120
  import { fileURLToPath as fileURLToPath3 } from "node:url";
91
- import { execFile as execFile4 } from "node:child_process";
92
- import { promisify as promisify4 } from "node:util";
93
- import { existsSync as existsSync9, readFileSync as readFileSync9 } from "node:fs";
94
- import { dirname as dirname4, join as join11, relative, resolve as resolve2 } from "node:path";
1121
+ import { execFile as execFile3 } from "node:child_process";
1122
+ import { promisify as promisify3 } from "node:util";
1123
+ import { existsSync as existsSync12, readFileSync as readFileSync12 } from "node:fs";
1124
+ import { dirname as dirname4, join as join15, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
95
1125
 
96
1126
  // src/recents.ts
97
1127
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
@@ -563,6 +1593,7 @@ function graphFlags(opts) {
563
1593
  if (opts.env) flags.push("--env", opts.env);
564
1594
  if (opts.live) flags.push("--live");
565
1595
  if (opts.overlay) flags.push("--overlay");
1596
+ if (opts.namespace) flags.push("--namespace", opts.namespace);
566
1597
  return flags;
567
1598
  }
568
1599
  function packageManifestFrom(req, name) {
@@ -803,10 +1834,10 @@ function applyArgs(target, env) {
803
1834
  }
804
1835
 
805
1836
  // src/cluster-root.ts
806
- import { execFile as execFile2 } from "node:child_process";
807
- import { promisify as promisify2 } from "node:util";
808
- var run2 = promisify2(execFile2);
809
- async function runningK3dClusters(exec = defaultExec2) {
1837
+ import { execFile } from "node:child_process";
1838
+ import { promisify } from "node:util";
1839
+ var run = promisify(execFile);
1840
+ async function runningK3dClusters(exec = defaultExec) {
810
1841
  try {
811
1842
  const out = await exec("k3d", ["cluster", "list", "--no-headers"]);
812
1843
  const clusters = /* @__PURE__ */ new Map();
@@ -822,8 +1853,8 @@ async function runningK3dClusters(exec = defaultExec2) {
822
1853
  return void 0;
823
1854
  }
824
1855
  }
825
- async function defaultExec2(cmd, args) {
826
- const { stdout } = await run2(cmd, args, { encoding: "utf8", timeout: 1e4 });
1856
+ async function defaultExec(cmd, args) {
1857
+ const { stdout } = await run(cmd, args, { encoding: "utf8", timeout: 1e4 });
827
1858
  return stdout;
828
1859
  }
829
1860
  function k3dClusterName(node) {
@@ -1004,19 +2035,19 @@ function finishPipelineProgress(state, exitCode) {
1004
2035
  }
1005
2036
 
1006
2037
  // src/gh-run.ts
1007
- var defaultGhExec = (args) => new Promise((resolve5) => {
2038
+ var defaultGhExec = (args) => new Promise((resolve8) => {
1008
2039
  let out = "";
1009
2040
  let proc;
1010
2041
  try {
1011
2042
  proc = spawn2("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
1012
2043
  } catch {
1013
- resolve5({ code: 127, out: "" });
2044
+ resolve8({ code: 127, out: "" });
1014
2045
  return;
1015
2046
  }
1016
2047
  proc.stdout.on("data", (d) => out += d);
1017
2048
  proc.stderr.on("data", (d) => out += d);
1018
- proc.on("error", () => resolve5({ code: 127, out }));
1019
- proc.on("close", (code) => resolve5({ code: code ?? 1, out }));
2049
+ proc.on("error", () => resolve8({ code: 127, out }));
2050
+ proc.on("close", (code) => resolve8({ code: code ?? 1, out }));
1020
2051
  });
1021
2052
  async function ghReady(exec = defaultGhExec) {
1022
2053
  const { code, out } = await exec(["auth", "status"]);
@@ -1160,11 +2191,11 @@ function joinCiProgress(ir, state) {
1160
2191
  }
1161
2192
  for (const n of ir.nodes) {
1162
2193
  if (n.kind !== "Component") continue;
1163
- const run4 = byComponent.get(n.id);
1164
- if (!run4) continue;
2194
+ const run3 = byComponent.get(n.id);
2195
+ if (!run3) continue;
1165
2196
  const attrs = n.attrs ??= {};
1166
- attrs.ci = run4.status;
1167
- attrs._ciJob = run4.job;
2197
+ attrs.ci = run3.status;
2198
+ attrs._ciJob = run3.job;
1168
2199
  }
1169
2200
  return ir;
1170
2201
  }
@@ -1329,14 +2360,14 @@ var WORKLOAD_KINDS = /* @__PURE__ */ new Set([
1329
2360
  function rec(v) {
1330
2361
  return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
1331
2362
  }
1332
- function str2(v) {
2363
+ function str(v) {
1333
2364
  return typeof v === "string" && v.length > 0 ? v : void 0;
1334
2365
  }
1335
2366
  function namespaceOf(n) {
1336
- return str2(rec(n.attrs?.metadata)?.namespace) ?? "default";
2367
+ return str(rec(n.attrs?.metadata)?.namespace) ?? "default";
1337
2368
  }
1338
2369
  function nameOf(n) {
1339
- return str2(rec(n.attrs?.metadata)?.name);
2370
+ return str(rec(n.attrs?.metadata)?.name);
1340
2371
  }
1341
2372
  function labelRecord(v) {
1342
2373
  const r = rec(v);
@@ -1368,16 +2399,16 @@ var FLUX_SOURCE_KINDS = /* @__PURE__ */ new Set([
1368
2399
  ]);
1369
2400
  function sourceRefOf(refObj, referrer, defaultKind) {
1370
2401
  const ref = rec(refObj);
1371
- const kind = str2(ref?.kind) ?? defaultKind;
1372
- const name = str2(ref?.name);
2402
+ const kind = str(ref?.kind) ?? defaultKind;
2403
+ const name = str(ref?.name);
1373
2404
  if (!kind || !name) return void 0;
1374
- return { kind, name, namespace: str2(ref?.namespace) ?? namespaceOf(referrer) };
2405
+ return { kind, name, namespace: str(ref?.namespace) ?? namespaceOf(referrer) };
1375
2406
  }
1376
2407
  function namedRefOf(refObj, referrer) {
1377
2408
  const ref = rec(refObj);
1378
- const name = str2(ref?.name);
2409
+ const name = str(ref?.name);
1379
2410
  if (!name) return void 0;
1380
- return { name, namespace: str2(ref?.namespace) ?? namespaceOf(referrer) };
2411
+ return { name, namespace: str(ref?.namespace) ?? namespaceOf(referrer) };
1381
2412
  }
1382
2413
  function list(v) {
1383
2414
  return Array.isArray(v) ? v : [];
@@ -1388,7 +2419,7 @@ function helmReleaseSourceRef(n) {
1388
2419
  function ingressBackendServices(n) {
1389
2420
  const spec = rec(n.attrs?.spec);
1390
2421
  const out = [];
1391
- const backendName = (b) => str2(rec(rec(b)?.service)?.name);
2422
+ const backendName = (b) => str(rec(rec(b)?.service)?.name);
1392
2423
  const fromDefault = backendName(spec?.defaultBackend);
1393
2424
  if (fromDefault) out.push(fromDefault);
1394
2425
  const rules = Array.isArray(spec?.rules) ? spec.rules : [];
@@ -1462,8 +2493,8 @@ function deriveK8sEdges(nodes) {
1462
2493
  }
1463
2494
  if (n.kind === "K8s::Autoscaling::HorizontalPodAutoscaler") {
1464
2495
  const ref = rec(rec(n.attrs?.spec)?.scaleTargetRef);
1465
- const refKind = str2(ref?.kind);
1466
- const refName = str2(ref?.name);
2496
+ const refKind = str(ref?.kind);
2497
+ const refName = str(ref?.name);
1467
2498
  if (!refKind || !refName) continue;
1468
2499
  for (const w of workloads) {
1469
2500
  if (namespaceOf(w) !== namespaceOf(n)) continue;
@@ -1498,11 +2529,11 @@ function deriveK8sEdges(nodes) {
1498
2529
  continue;
1499
2530
  }
1500
2531
  if (n.kind === "K8s::Argo::Application") {
1501
- addProject(n, str2(rec(n.attrs?.spec)?.project), "project");
2532
+ addProject(n, str(rec(n.attrs?.spec)?.project), "project");
1502
2533
  continue;
1503
2534
  }
1504
2535
  if (n.kind === "K8s::Argo::ApplicationSet") {
1505
- addProject(n, str2(rec(rec(rec(n.attrs?.spec)?.template)?.spec)?.project), "template project");
2536
+ addProject(n, str(rec(rec(rec(n.attrs?.spec)?.template)?.spec)?.project), "template project");
1506
2537
  }
1507
2538
  }
1508
2539
  return out;
@@ -2706,9 +3737,12 @@ function logicalKept(before, after) {
2706
3737
  if (after > 0 && after * 3 >= before) return void 0;
2707
3738
  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)`;
2708
3739
  }
2709
- function edgelessNote(zoom, ir) {
3740
+ function edgelessNote(zoom, ir, detail) {
2710
3741
  if (zoom === "components" || zoom === "logical") return void 0;
2711
3742
  if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
3743
+ if (detail !== void 0 && detail < 3) {
3744
+ return "no edges at this detail \u2014 sourceRef/dependsOn and other attrs-derived references only appear at detail 3 (\u2318K \u2192 attributes, or add &detail=3)";
3745
+ }
2712
3746
  return "no edges \u2014 nothing in this estate references anything else";
2713
3747
  }
2714
3748
  var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
@@ -2727,9 +3761,14 @@ function namespaceMismatchNote(nodes) {
2727
3761
  if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
2728
3762
  return `${pending.length} pending k8s objects declare no metadata.namespace \u2014 if a controller stamps it at apply time (e.g. Flux's targetNamespace), the live read looked in "default", not where they run`;
2729
3763
  }
2730
- function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore) {
3764
+ function namespaceJoinNote(joined) {
3765
+ if (joined.length === 0) return void 0;
3766
+ const each = joined.map((j) => `${j.name} in "${j.namespace}"`).join(", ");
3767
+ return `read ${each} \u2014 the namespace the estate's own Kustomization targetNamespace binds, not the app project's own declaration`;
3768
+ }
3769
+ function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore, detail) {
2731
3770
  const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
2732
- const notes = [primary, edgelessNote(zoom, ir)].filter((n) => n !== void 0);
3771
+ const notes = [primary, edgelessNote(zoom, ir, detail)].filter((n) => n !== void 0);
2733
3772
  return notes.length ? notes.join(" \xB7 ") : void 0;
2734
3773
  }
2735
3774
  function tierMismatchNote(ir, tiers, currentTier) {
@@ -2844,6 +3883,20 @@ var cache = /* @__PURE__ */ new Map();
2844
3883
  function unprefix(body) {
2845
3884
  return body.replace(/\s(?:inkscape|sodipodi):[\w-]+\s*=\s*(["'])[\s\S]*?\1/g, "").replace(/\sxlink:href\s*=/g, " href=");
2846
3885
  }
3886
+ var PLATE_FILL = "#ffffff";
3887
+ var PLATE_INSET = 0.1;
3888
+ var PLATED = /* @__PURE__ */ new Set(["cncf/helm"]);
3889
+ function plate(glyph) {
3890
+ const [minX, minY, w, h] = glyph.viewBox.trim().split(/[\s,]+/).map(Number);
3891
+ const side = Math.max(w, h);
3892
+ const cx = minX + w / 2;
3893
+ const cy = minY + h / 2;
3894
+ const k = 1 - 2 * PLATE_INSET;
3895
+ const round = (n) => Math.round(n * 1e4) / 1e4;
3896
+ const ground = `<rect x="${round(cx - side / 2)}" y="${round(cy - side / 2)}" width="${round(side)}" height="${round(side)}" rx="${round(side * 0.18)}" fill="${PLATE_FILL}"/>`;
3897
+ const inset = `translate(${round(cx * (1 - k))} ${round(cy * (1 - k))}) scale(${k})`;
3898
+ return { ...glyph, body: `${ground}<g transform="${inset}">${glyph.body}</g>` };
3899
+ }
2847
3900
  function loadIcon(rel) {
2848
3901
  const hit = cache.get(rel);
2849
3902
  if (hit) return hit;
@@ -2854,7 +3907,8 @@ function loadIcon(rel) {
2854
3907
  if (open < 0 || openEnd < 0 || close < 0) throw new Error(`malformed vendored icon: ${rel}.svg`);
2855
3908
  const viewBox = /\bviewBox\s*=\s*(["'])(.*?)\1/.exec(raw.slice(open, openEnd))?.[2];
2856
3909
  if (!viewBox) throw new Error(`vendored icon has no viewBox: ${rel}.svg`);
2857
- const glyph = { body: unprefix(raw.slice(openEnd + 1, close).trim()), colored: true, viewBox };
3910
+ const bare = { body: unprefix(raw.slice(openEnd + 1, close).trim()), colored: true, viewBox };
3911
+ const glyph = PLATED.has(rel) ? plate(bare) : bare;
2858
3912
  cache.set(rel, glyph);
2859
3913
  return glyph;
2860
3914
  }
@@ -2907,9 +3961,203 @@ function helmIconFor(kind) {
2907
3961
  return HELM_MARK_KINDS.has(kind) ? loadIcon("cncf/helm") : void 0;
2908
3962
  }
2909
3963
 
3964
+ // src/carve-lens.ts
3965
+ var SUPPORTED_MAJOR = 1;
3966
+ var refuse = (error, remedy) => ({ ok: false, refusal: { error, code: "carve-report", remedy } });
3967
+ var HOW_TO_GET_ONE = "Generate one with `chant carve advise --from <terraform-dir> --report report.json` (chant's read-only Terraform peelability advisor), then `behold carve report.json`.";
3968
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3969
+ function parseCarveReport(value) {
3970
+ if (!isRecord(value)) {
3971
+ return refuse("That file isn't a carve report \u2014 its top level isn't a JSON object.", HOW_TO_GET_ONE);
3972
+ }
3973
+ if (!Array.isArray(value.resources)) {
3974
+ return refuse(
3975
+ "That JSON doesn't look like a carve report \u2014 it has no `resources` array.",
3976
+ HOW_TO_GET_ONE
3977
+ );
3978
+ }
3979
+ const bad = value.resources.findIndex(
3980
+ (r) => !isRecord(r) || typeof r.address !== "string" || typeof r.score !== "number" || typeof r.band !== "string"
3981
+ );
3982
+ if (bad >= 0) {
3983
+ return refuse(
3984
+ `That JSON has a \`resources\` array, but entry ${bad} isn't a scored resource (needs a string \`address\`, a numeric \`score\` and a string \`band\`).`,
3985
+ HOW_TO_GET_ONE
3986
+ );
3987
+ }
3988
+ const version = value.version ?? value.schemaVersion;
3989
+ if (version !== void 0) {
3990
+ const major = Number(String(version).split(".")[0]);
3991
+ if (!Number.isFinite(major) || major !== SUPPORTED_MAJOR) {
3992
+ return refuse(
3993
+ `This carve report declares schema version ${String(version)}; behold reads version ${SUPPORTED_MAJOR}.`,
3994
+ "Upgrade behold, or render the report with a behold that speaks its version."
3995
+ );
3996
+ }
3997
+ }
3998
+ return { ok: true, report: value };
3999
+ }
4000
+ function readCarveReport(path, readFile) {
4001
+ let text;
4002
+ try {
4003
+ text = readFile(path);
4004
+ } catch (err) {
4005
+ return refuse(
4006
+ `Couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`,
4007
+ HOW_TO_GET_ONE
4008
+ );
4009
+ }
4010
+ let json;
4011
+ try {
4012
+ json = JSON.parse(text);
4013
+ } catch (err) {
4014
+ return refuse(
4015
+ `${path} isn't valid JSON: ${err instanceof Error ? err.message : String(err)}`,
4016
+ HOW_TO_GET_ONE
4017
+ );
4018
+ }
4019
+ return parseCarveReport(json);
4020
+ }
4021
+ function statusForBand(band, score) {
4022
+ switch (band) {
4023
+ case "clean leaf":
4024
+ return "good";
4025
+ case "carvable w/ edits":
4026
+ return "warn";
4027
+ case "leave in Terraform":
4028
+ return "neutral";
4029
+ default:
4030
+ return score >= 80 ? "good" : score >= 50 ? "warn" : "neutral";
4031
+ }
4032
+ }
4033
+ function verdictForStatus(status) {
4034
+ return status === "good" ? "carve now" : status === "warn" ? "boundary work" : "leave in Terraform";
4035
+ }
4036
+ function tfTypeOf(address, kind) {
4037
+ if (kind === "module" || address.startsWith("module.")) return "module";
4038
+ const dot = address.indexOf(".");
4039
+ return dot > 0 ? address.slice(0, dot) : address;
4040
+ }
4041
+ var directionOf = (e) => e.direction ?? "inbound";
4042
+ function boundaryEdgesOf(r) {
4043
+ const b = r.boundary;
4044
+ if (!b) return [];
4045
+ const list3 = Array.isArray(b) ? b : [...b.inbound ?? [], ...b.outbound ?? []];
4046
+ return list3.filter((e) => isRecord(e) && typeof e.survivor === "string" && typeof e.carved === "string");
4047
+ }
4048
+ function scoreArithmetic(r) {
4049
+ const b = r.breakdown;
4050
+ if (!b || b.tier === null) return `${r.score} (no known native mapping \u2014 nothing to carve into)`;
4051
+ const p = b.penalties ?? {};
4052
+ const terms = [];
4053
+ if (p.inbound) terms.push(`- 12x${b.inbound ?? 0} inbound`);
4054
+ if (p.outbound) terms.push(`- 4x${b.outbound ?? 0} outbound`);
4055
+ if (p.outputs) terms.push(`- 4x${b.outputs ?? 0} output`);
4056
+ if (p.tier) terms.push(`- 15x${(b.tier ?? 1) - 1} tier${b.tier}`);
4057
+ if (p.dynamic) terms.push(`- 10 dynamic`);
4058
+ if (p.instances) terms.push(`- 3x${(b.instances ?? 1) - 1} instances`);
4059
+ if (!terms.length) return `100 (no penalties) = ${r.score}`;
4060
+ const raw = 100 + Object.values(p).reduce((s, n) => s + (n ?? 0), 0);
4061
+ const clamped = raw !== r.score ? ` (clamped from ${raw})` : "";
4062
+ return `100 ${terms.join(" ")} = ${r.score}${clamped}`;
4063
+ }
4064
+ function boundaryWorkOf(r) {
4065
+ const b = r.breakdown;
4066
+ if (!b || b.tier === null) return "no known native mapping (unsupported provider/type)";
4067
+ const parts = [];
4068
+ if (b.inbound) parts.push(`${b.inbound} inbound (a data-source patch each)`);
4069
+ if (b.outbound) parts.push(`${b.outbound} outbound (a deferred input each)`);
4070
+ if (b.outputs) parts.push(`${b.outputs} output block(s) reading it (a one-line rewrite each)`);
4071
+ if ((b.tier ?? 1) > 1) parts.push(`tier ${b.tier} map`);
4072
+ if (b.hasDynamic) parts.push("count/for_each/data present");
4073
+ if ((b.instances ?? 1) > 1) parts.push(`${b.instances} instances`);
4074
+ return parts.length ? parts.join(", ") : "clean 1:1 native map, no boundary edges";
4075
+ }
4076
+ function carveCardFields(node) {
4077
+ const score = node.attrs.score;
4078
+ const carve = node.attrs.carve;
4079
+ if (typeof score !== "number") return void 0;
4080
+ return [
4081
+ { label: "score", value: String(score) },
4082
+ ...typeof carve === "string" ? [{ label: "carve", value: carve }] : []
4083
+ ];
4084
+ }
4085
+ function carveReportToIr(report) {
4086
+ const resources = report.resources ?? [];
4087
+ const known = new Set(resources.map((r) => r.address));
4088
+ const nodes = resources.map((r) => {
4089
+ const status = statusForBand(r.band, r.score);
4090
+ const b = r.breakdown ?? {};
4091
+ const edges2 = boundaryEdgesOf(r);
4092
+ const patches = edges2.filter((e) => e.carved === r.address && directionOf(e) === "inbound").map((e) => e.survivor);
4093
+ const inputs = edges2.filter((e) => e.carved === r.address && directionOf(e) === "outbound").map((e) => e.survivor);
4094
+ return {
4095
+ id: r.address,
4096
+ kind: tfTypeOf(r.address, r.kind),
4097
+ lexicon: "terraform",
4098
+ attrs: {
4099
+ // The drift palette's channel — `_`-prefixed, so it paints the card and
4100
+ // stays out of both the card's fields and the inspect pane's list.
4101
+ _status: status,
4102
+ score: r.score,
4103
+ carve: verdictForStatus(status),
4104
+ band: r.band,
4105
+ arithmetic: scoreArithmetic(r),
4106
+ boundaryWork: boundaryWorkOf(r),
4107
+ ...r.mapsTo ? { mapsTo: r.mapsTo } : {},
4108
+ tier: b.tier === null || b.tier === void 0 ? "none" : b.tier,
4109
+ inbound: b.inbound ?? 0,
4110
+ outbound: b.outbound ?? 0,
4111
+ outputs: b.outputs ?? 0,
4112
+ instances: b.instances ?? 1,
4113
+ dynamic: b.hasDynamic ?? false,
4114
+ // The predicted diff, when the report carries the edge lists: who needs
4115
+ // a `data` source the moment this is carved, and what becomes a
4116
+ // deploy-time input. Absent (not empty) when the report has no lists —
4117
+ // "none" and "not reported" are different claims.
4118
+ ...patches.length ? { patchOnCarve: patches.join(", ") } : {},
4119
+ ...inputs.length ? { deferredInputs: inputs.join(", ") } : {}
4120
+ }
4121
+ };
4122
+ });
4123
+ const seen = /* @__PURE__ */ new Set();
4124
+ const edges = [];
4125
+ for (const r of resources) {
4126
+ for (const e of boundaryEdgesOf(r)) {
4127
+ const direction = directionOf(e);
4128
+ const from = direction === "inbound" ? e.survivor : e.carved;
4129
+ const to = direction === "inbound" ? e.carved : e.survivor;
4130
+ if (!known.has(from) || !known.has(to)) continue;
4131
+ const key = `${from}|${to}`;
4132
+ if (seen.has(key)) continue;
4133
+ seen.add(key);
4134
+ edges.push({
4135
+ from,
4136
+ to,
4137
+ kind: "ref",
4138
+ viaAttr: direction,
4139
+ ...e.attrs?.length ? { toAttr: e.attrs.join(", ") } : {}
4140
+ });
4141
+ }
4142
+ }
4143
+ const byStack = {};
4144
+ for (const order of ["good", "warn", "neutral"]) {
4145
+ const members = resources.filter((r) => statusForBand(r.band, r.score) === order).map((r) => r.address);
4146
+ if (members.length) byStack[verdictForStatus(order)] = members;
4147
+ }
4148
+ return { nodes, edges, groups: { byStack } };
4149
+ }
4150
+ function carveNote(report, ir) {
4151
+ const counts = Object.entries(report.bands ?? {}).filter(([, n]) => n > 0).map(([band, n]) => `${n} ${band}`).join(" \xB7 ");
4152
+ const head = `chant carve advisory${report.from ? ` for ${report.from}` : ""}${counts ? ` \u2014 ${counts}` : ""}`;
4153
+ if (ir.edges.length) return `${head}. Read-only: nothing is emitted, patched, or applied.`;
4154
+ return `${head}. This report carries per-resource boundary COUNTS but no edge lists, so no boundary edges are drawn (chant#1636). Read-only: nothing is emitted, patched, or applied.`;
4155
+ }
4156
+
2910
4157
  // src/render.ts
2911
4158
  registerPack({ lexicon: "k8s", iconFor: k8sIconFor });
2912
4159
  registerPack({ lexicon: "helm", iconFor: helmIconFor });
4160
+ registerPack({ lexicon: "terraform", iconFor: () => void 0, fields: carveCardFields });
2913
4161
  function renderArchitecture(ir, byContainer, opts = {}) {
2914
4162
  const spread = Math.min(1.5, ir.edges.length / Math.max(ir.nodes.length, 1));
2915
4163
  const layout = layoutArchitecture(ir, byContainer, {
@@ -2940,6 +4188,59 @@ function renderGraph(ir, opts = {}) {
2940
4188
  });
2941
4189
  return { svg };
2942
4190
  }
4191
+ function renderBanded(ir, opts = {}) {
4192
+ const bands = ir.groups.byStack ?? {};
4193
+ const size = footprints(ir);
4194
+ const dims = (id) => size.get(id) ?? { w: NODE_W, h: NODE_H };
4195
+ const ids = ir.nodes.map((n) => n.id);
4196
+ const cellW = Math.max(NODE_W, ...ids.map((id) => dims(id).w));
4197
+ const cellH = Math.max(NODE_H, ...ids.map((id) => dims(id).h));
4198
+ const GAP = 28;
4199
+ const PAD = 24;
4200
+ const TITLE = 34;
4201
+ const BAND_GAP = 26;
4202
+ const cols = Math.max(1, Math.round(Math.sqrt(ids.length * (cellH + GAP) * 2 / (cellW + GAP))));
4203
+ const contentW = cols * cellW + (cols - 1) * GAP;
4204
+ const panelW = contentW + PAD * 2;
4205
+ const claimed = new Set(Object.values(bands).flat());
4206
+ const orphans = ids.filter((id) => !claimed.has(id));
4207
+ const panels = [
4208
+ ...Object.entries(bands).map(([title, members]) => [title, members.filter((id) => size.has(id))]),
4209
+ ...orphans.length ? [["unbanded", orphans]] : []
4210
+ ].filter(([, members]) => members.length > 0);
4211
+ const statusOf = new Map(ir.nodes.map((n) => [n.id, n.attrs?._status]));
4212
+ const placed = [];
4213
+ const boxes = [];
4214
+ let top = 0;
4215
+ for (const [title, members] of panels) {
4216
+ const rows = Math.ceil(members.length / cols);
4217
+ const panelH = TITLE + PAD + rows * cellH + (rows - 1) * GAP + PAD;
4218
+ members.forEach((id, i) => {
4219
+ placed.push({
4220
+ id,
4221
+ x: PAD + i % cols * (cellW + GAP) + cellW / 2,
4222
+ y: top + TITLE + PAD + Math.floor(i / cols) * (cellH + GAP) + cellH / 2
4223
+ });
4224
+ });
4225
+ const statuses = new Set(members.map((id) => statusOf.get(id)));
4226
+ const status = statuses.size === 1 ? [...statuses][0] : void 0;
4227
+ boxes.push({ title, x: panelW / 2, y: top + panelH / 2, w: panelW, h: panelH, ...status ? { status } : {} });
4228
+ top += panelH + BAND_GAP;
4229
+ }
4230
+ const height = Math.max(1, top - BAND_GAP);
4231
+ const layout = {
4232
+ width: panelW,
4233
+ height,
4234
+ nodes: placed.map((p) => ({ id: p.id, x: p.x, y: height - p.y }))
4235
+ };
4236
+ const svg = renderSvg(ir, layout, {
4237
+ fit: true,
4238
+ hideTitle: true,
4239
+ groups: boxes.map((b) => ({ ...b, y: height - b.y })),
4240
+ ...opts.theme ? { theme: opts.theme } : {}
4241
+ });
4242
+ return { svg };
4243
+ }
2943
4244
  var NODE_W = 175;
2944
4245
  var NODE_H = 104;
2945
4246
  function footprints(ir) {
@@ -3104,9 +4405,374 @@ function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
3104
4405
  layout.height = Math.max(...nodes.map((n) => n.y + hOf(n) / 2)) + pad;
3105
4406
  }
3106
4407
 
3107
- // src/ops.ts
3108
- import { readdirSync as readdirSync3, readFileSync as readFileSync7, existsSync as existsSync6 } from "node:fs";
4408
+ // src/carve-actions.ts
4409
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync8, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
4410
+ import { isAbsolute, join as join9, relative, resolve as resolve2, sep } from "node:path";
4411
+
4412
+ // src/layout.ts
4413
+ import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync7, renameSync, rmSync, writeFileSync as writeFileSync2, accessSync, constants } from "node:fs";
3109
4414
  import { join as join8 } from "node:path";
4415
+ var LAYOUT_DIR = ".behold";
4416
+ var LAYOUT_FILE = "layout.json";
4417
+ var MAX_BODY_BYTES = 64 * 1024;
4418
+ var MAX_FILE_BYTES = 256 * 1024;
4419
+ var MAX_IDS_PER_LENS = 2e3;
4420
+ var MAX_LENSES = 64;
4421
+ var MAX_ID_LENGTH = 512;
4422
+ var MAX_LENS_LENGTH = 128;
4423
+ var NUM = ["dx", "dy", "dw", "dh"];
4424
+ function slug(s) {
4425
+ return String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
4426
+ }
4427
+ function normalizeLens(raw) {
4428
+ if (typeof raw !== "string" || !raw.trim() || raw.length > MAX_LENS_LENGTH) return null;
4429
+ const parts = raw.split("+").map((p) => slug(p)).filter((p) => p && p !== "unknown");
4430
+ if (!parts.length) return null;
4431
+ const key = parts.join("+");
4432
+ return key === "__proto__" || key === "constructor" || key === "prototype" ? null : key;
4433
+ }
4434
+ function normalizeDeltas(raw) {
4435
+ const out = {};
4436
+ if (!raw || typeof raw !== "object") return out;
4437
+ for (const [id, d] of Object.entries(raw)) {
4438
+ if (!id || id.length > MAX_ID_LENGTH || id === "__proto__" || !d || typeof d !== "object") continue;
4439
+ const clean = {};
4440
+ for (const k of NUM) {
4441
+ const v = Number(d[k]);
4442
+ if (Number.isFinite(v) && v !== 0) clean[k] = v;
4443
+ }
4444
+ if (Object.keys(clean).length) out[id] = clean;
4445
+ if (Object.keys(out).length >= MAX_IDS_PER_LENS) break;
4446
+ }
4447
+ return out;
4448
+ }
4449
+ function lensFromQuery(params) {
4450
+ const zoom = params.get("components") === "1" ? "components" : params.get("logical") === "1" ? "logical" : params.get("runtime") === "1" ? "runtime" : { 1: "composites", 2: "resources", 3: "attributes" }[Number(params.get("detail") ?? 2)] ?? "resources";
4451
+ const stack = params.get("stack");
4452
+ return [slug(zoom), params.get("radial") === "1" ? "radial" : null, stack ? slug(`stack-${stack}`) : null].filter(Boolean).join("+");
4453
+ }
4454
+ function layoutPath(projectDir) {
4455
+ return join8(projectDir, LAYOUT_DIR, LAYOUT_FILE);
4456
+ }
4457
+ function unwritableReason(projectDir) {
4458
+ if (!projectDir || !existsSync6(projectDir)) return `no such project directory: ${projectDir || "(none)"}`;
4459
+ try {
4460
+ accessSync(projectDir, constants.W_OK);
4461
+ } catch {
4462
+ return "the served project directory is read-only";
4463
+ }
4464
+ return null;
4465
+ }
4466
+ function readLayoutFile(projectDir) {
4467
+ const empty = { version: 1, lenses: {} };
4468
+ const file = layoutPath(projectDir);
4469
+ try {
4470
+ if (!existsSync6(file)) return empty;
4471
+ const raw = readFileSync7(file, "utf8");
4472
+ if (raw.length > MAX_FILE_BYTES) return empty;
4473
+ const parsed = JSON.parse(raw);
4474
+ if (!parsed || typeof parsed !== "object" || !parsed.lenses || typeof parsed.lenses !== "object") return empty;
4475
+ const lenses = {};
4476
+ for (const [lens, deltas] of Object.entries(parsed.lenses)) {
4477
+ const key = normalizeLens(lens);
4478
+ if (!key) continue;
4479
+ const clean = normalizeDeltas(deltas);
4480
+ if (Object.keys(clean).length) lenses[key] = clean;
4481
+ if (Object.keys(lenses).length >= MAX_LENSES) break;
4482
+ }
4483
+ return { version: 1, lenses };
4484
+ } catch {
4485
+ return empty;
4486
+ }
4487
+ }
4488
+ function readLens(projectDir, lens) {
4489
+ const key = normalizeLens(lens);
4490
+ if (!key) return {};
4491
+ return readLayoutFile(projectDir).lenses[key] ?? {};
4492
+ }
4493
+ var LayoutTooLarge = class extends Error {
4494
+ };
4495
+ function writeLens(projectDir, lens, deltas) {
4496
+ const key = normalizeLens(lens);
4497
+ if (!key) throw new Error(`not a lens key: ${String(lens)}`);
4498
+ const clean = normalizeDeltas(deltas);
4499
+ const current = readLayoutFile(projectDir);
4500
+ const lenses = { ...current.lenses };
4501
+ if (Object.keys(clean).length) lenses[key] = clean;
4502
+ else delete lenses[key];
4503
+ if (Object.keys(lenses).length > MAX_LENSES) throw new LayoutTooLarge(`a layout sidecar holds at most ${MAX_LENSES} lenses`);
4504
+ const file = layoutPath(projectDir);
4505
+ if (!Object.keys(lenses).length) {
4506
+ rmSync(file, { force: true });
4507
+ return { lens: key, deltas: clean, bytes: 0 };
4508
+ }
4509
+ const body = JSON.stringify({ version: 1, lenses }, null, 2) + "\n";
4510
+ if (body.length > MAX_FILE_BYTES) throw new LayoutTooLarge(`a layout sidecar is capped at ${MAX_FILE_BYTES} bytes`);
4511
+ mkdirSync2(join8(projectDir, LAYOUT_DIR), { recursive: true });
4512
+ const tmp = `${file}.tmp`;
4513
+ writeFileSync2(tmp, body, "utf8");
4514
+ renameSync(tmp, file);
4515
+ return { lens: key, deltas: clean, bytes: body.length };
4516
+ }
4517
+ function nodeTransform(base, d) {
4518
+ const dx = d.dx || 0;
4519
+ const dy = d.dy || 0;
4520
+ if (!dx && !dy) return base;
4521
+ return `translate(${dx}, ${dy}) ${base}`.trim();
4522
+ }
4523
+ function pathAnchors(d) {
4524
+ const n = String(d || "").match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi);
4525
+ if (!n || n.length < 4) return null;
4526
+ return { sx: +n[0], sy: +n[1], ex: +n[n.length - 2], ey: +n[n.length - 1] };
4527
+ }
4528
+ function straightEdge(anchors, from, to) {
4529
+ const { sx, sy, ex, ey } = anchors;
4530
+ return `M ${sx + (from && from.dx || 0)} ${sy + (from && from.dy || 0)} L ${ex + (to && to.dx || 0)} ${ey + (to && to.dy || 0)}`;
4531
+ }
4532
+ function unescapeAttr(v) {
4533
+ return v.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
4534
+ }
4535
+ function attrOf(tag, name) {
4536
+ const m = new RegExp(`\\b${name}="([^"]*)"`).exec(tag);
4537
+ return m ? unescapeAttr(m[1]) : null;
4538
+ }
4539
+ function groupEnd(svg, from) {
4540
+ const tok = /<g\b|<\/g\s*>/g;
4541
+ tok.lastIndex = from;
4542
+ let depth = 1;
4543
+ let m;
4544
+ while (m = tok.exec(svg)) {
4545
+ if (m[0][1] === "/") {
4546
+ depth--;
4547
+ if (!depth) return m.index;
4548
+ } else depth++;
4549
+ }
4550
+ return svg.length;
4551
+ }
4552
+ function applyLayoutToSvg(svg, deltas) {
4553
+ const moved = {};
4554
+ for (const [id, d] of Object.entries(normalizeDeltas(deltas))) if (d.dx || d.dy) moved[id] = d;
4555
+ if (!Object.keys(moved).length || typeof svg !== "string" || !svg) return { svg, applied: 0 };
4556
+ const placed = /* @__PURE__ */ new Set();
4557
+ let out = svg.replace(/<g\b([^>]*?)(\/?)>/g, (tag, attrs, selfClose) => {
4558
+ const id = attrOf(attrs, "data-node-id");
4559
+ const d = id ? moved[id] : void 0;
4560
+ if (!id || !d) return tag;
4561
+ placed.add(id);
4562
+ const base = /\btransform="([^"]*)"/.exec(attrs);
4563
+ const next = nodeTransform(base ? base[1] : "", d);
4564
+ if (base) return `<g${attrs.replace(base[0], () => `transform="${next}"`)}${selfClose}>`;
4565
+ return `<g${attrs}${/\s$/.test(attrs) ? "" : " "}transform="${next}"${selfClose}>`;
4566
+ });
4567
+ out = reanchorEdges(out, moved);
4568
+ return { svg: out, applied: placed.size };
4569
+ }
4570
+ function reanchorEdges(svg, moved) {
4571
+ const open = /<g\b([^>]*\bdata-edge-from="[^"]*"[^>]*?)(\/?)>/g;
4572
+ let out = "";
4573
+ let cursor = 0;
4574
+ let m;
4575
+ while (m = open.exec(svg)) {
4576
+ const bodyStart = m.index + m[0].length;
4577
+ if (m[2] === "/") continue;
4578
+ const bodyEnd = groupEnd(svg, bodyStart);
4579
+ open.lastIndex = bodyEnd;
4580
+ const from = moved[attrOf(m[1], "data-edge-from") ?? ""];
4581
+ const to = moved[attrOf(m[1], "data-edge-to") ?? ""];
4582
+ if (!from && !to) continue;
4583
+ const body = svg.slice(bodyStart, bodyEnd);
4584
+ const first = /<path\b[^>]*\bd="([^"]*)"/.exec(body);
4585
+ const anchors = first ? pathAnchors(first[1]) : null;
4586
+ if (!anchors) continue;
4587
+ const d = straightEdge(anchors, from, to);
4588
+ out += svg.slice(cursor, bodyStart) + body.replace(/(<path\b[^>]*\bd=")([^"]*)(")/g, (_t, a, _d, z) => a + d + z);
4589
+ cursor = bodyEnd;
4590
+ }
4591
+ return out + svg.slice(cursor);
4592
+ }
4593
+
4594
+ // src/carve-actions.ts
4595
+ var MAX_ARTIFACT_BYTES = 64 * 1024;
4596
+ var MAX_ARTIFACTS = 24;
4597
+ var MAX_OUTPUT_BYTES = 32 * 1024;
4598
+ var refuse2 = (code, error, remedy) => ({
4599
+ ok: false,
4600
+ refusal: { error, code, remedy }
4601
+ });
4602
+ var BUILD_CAVEAT = "The gate shown is `chant lint`, not `chant build`. chant#1637's fold is applied now: the bucket's versioning and public-access-block sub-resources come out as native props, not just folded-in mentions. `chant build` still fails, but on one rule only \u2014 WAW042, a TLS-deny bucket policy the source Terraform never declared. That's real drift the estate inherited, not a gap in the carve. example-carve/README.md, \"Why build still fails\", has the full statement.";
4603
+ function insideDemo(root, p) {
4604
+ const rel = relative(resolve2(root), resolve2(p));
4605
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
4606
+ }
4607
+ function selectFromReport(report, raw) {
4608
+ if (typeof raw !== "string" || !raw || raw.length > 512) return null;
4609
+ return (report?.resources ?? []).some((r) => r.address === raw) ? raw : null;
4610
+ }
4611
+ function carveWriteBlock(demo) {
4612
+ if (!demo) {
4613
+ return "this server isn't running a carve demo \u2014 the carve actions only exist inside a `behold demo carve` copy";
4614
+ }
4615
+ if (!existsSync7(demo.from)) return `the demo copy has no Terraform estate at ${demo.from}`;
4616
+ if (!insideDemo(demo.root, demo.out)) return "the carve output directory is outside the demo copy";
4617
+ return unwritableReason(demo.root);
4618
+ }
4619
+ function readArtifacts(demo, dir, filter) {
4620
+ const out = [];
4621
+ const walk = (d, depth) => {
4622
+ if (out.length >= MAX_ARTIFACTS || depth > 2) return;
4623
+ let entries;
4624
+ try {
4625
+ entries = readdirSync3(d).sort();
4626
+ } catch {
4627
+ return;
4628
+ }
4629
+ for (const name of entries) {
4630
+ if (out.length >= MAX_ARTIFACTS) return;
4631
+ if (name === "node_modules" || name.startsWith(".")) continue;
4632
+ const full = join9(d, name);
4633
+ let st;
4634
+ try {
4635
+ st = statSync2(full);
4636
+ } catch {
4637
+ continue;
4638
+ }
4639
+ if (st.isDirectory()) {
4640
+ walk(full, depth + 1);
4641
+ continue;
4642
+ }
4643
+ const rel = relative(demo.root, full).split(sep).join("/");
4644
+ if (!filter(rel)) continue;
4645
+ let text = "";
4646
+ try {
4647
+ text = readFileSync8(full, "utf8");
4648
+ } catch {
4649
+ continue;
4650
+ }
4651
+ const truncated = text.length > MAX_ARTIFACT_BYTES;
4652
+ out.push({
4653
+ path: rel,
4654
+ kind: (name.split(".").pop() ?? "").toLowerCase(),
4655
+ bytes: st.size,
4656
+ text: truncated ? text.slice(0, MAX_ARTIFACT_BYTES) : text,
4657
+ truncated
4658
+ });
4659
+ }
4660
+ };
4661
+ walk(dir, 0);
4662
+ return out;
4663
+ }
4664
+ function carveSlug(select) {
4665
+ return select.replace(/[^A-Za-z0-9_]+/g, "-");
4666
+ }
4667
+ var clip = (s) => s.length > MAX_OUTPUT_BYTES ? s.slice(0, MAX_OUTPUT_BYTES) + "\n\u2026 (truncated)" : s;
4668
+ function shortenIn(arg, root) {
4669
+ return arg.startsWith(root + sep) ? arg.slice(root.length + 1) : arg === root ? "." : arg;
4670
+ }
4671
+ function merge(r, root) {
4672
+ const joined = stripAnsi([r.stdout, r.stderr].filter((s) => s.trim()).join("\n").trim());
4673
+ return clip(joined.split(root + sep).join("").split(root).join("."));
4674
+ }
4675
+ async function runCarveEmit(demo, select) {
4676
+ const block = carveWriteBlock(demo);
4677
+ if (block) return refuse2("read-only", block, "Start the walkthrough with `behold demo carve`.");
4678
+ mkdirSync3(demo.out, { recursive: true });
4679
+ const reportFile = join9(demo.out, `${carveSlug(select)}-boundary.json`);
4680
+ const args = [
4681
+ "carve",
4682
+ "emit",
4683
+ "--from",
4684
+ demo.from,
4685
+ ...demo.state ? ["--state", demo.state] : [],
4686
+ "--select",
4687
+ select,
4688
+ "--output",
4689
+ demo.out,
4690
+ "--report",
4691
+ reportFile
4692
+ ];
4693
+ const run3 = await runChantRaw(args, demo.project).catch((err) => ({
4694
+ code: 127,
4695
+ stdout: "",
4696
+ stderr: err instanceof Error ? err.message : String(err)
4697
+ }));
4698
+ const output = merge(run3, demo.root);
4699
+ if (run3.code !== 0) {
4700
+ return refuse2(
4701
+ "carve-action",
4702
+ `chant carve emit exited ${run3.code}: ${output || "(no output)"}`,
4703
+ "The offline emit needs `@cdktf/hcl2json` in the demo copy and the demo's own chant install \u2014 `npm install` in the copy's `app/` and re-run `behold demo carve`."
4704
+ );
4705
+ }
4706
+ let boundary = null;
4707
+ try {
4708
+ boundary = JSON.parse(readFileSync8(reportFile, "utf8"));
4709
+ } catch {
4710
+ }
4711
+ const lintPath = relative(demo.project, join9(demo.out, "src")).split(sep).join("/");
4712
+ const lintArgs = ["lint", lintPath];
4713
+ const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
4714
+ code: 127,
4715
+ stdout: "",
4716
+ stderr: err instanceof Error ? err.message : String(err)
4717
+ }));
4718
+ return {
4719
+ ok: true,
4720
+ select,
4721
+ command: `chant ${args.map((a) => shortenIn(a, demo.root)).join(" ")}`,
4722
+ output,
4723
+ // Emitted source first, scaffolding after: `src/assets.ts` is the thing the
4724
+ // step is about, and a package.json sorting above it buries the answer.
4725
+ artifacts: readArtifacts(demo, demo.out, (rel) => /\.(ts|json)$/.test(rel) && !rel.endsWith("tsconfig.json")).sort(
4726
+ (a, b) => Number(!/\/src\//.test(a.path)) - Number(!/\/src\//.test(b.path))
4727
+ ),
4728
+ boundary,
4729
+ lint: { ok: lint.code === 0, code: lint.code, command: `chant ${lintArgs.join(" ")}`, output: merge(lint, demo.root) },
4730
+ buildCaveat: BUILD_CAVEAT
4731
+ };
4732
+ }
4733
+ async function runCarveBridge(demo, select) {
4734
+ const block = carveWriteBlock(demo);
4735
+ if (block) return refuse2("read-only", block, "Start the walkthrough with `behold demo carve`.");
4736
+ mkdirSync3(demo.out, { recursive: true });
4737
+ const args = [
4738
+ "carve",
4739
+ "bridge",
4740
+ "--from",
4741
+ demo.from,
4742
+ ...demo.state ? ["--state", demo.state] : [],
4743
+ "--select",
4744
+ select,
4745
+ "--output",
4746
+ demo.out
4747
+ ];
4748
+ const run3 = await runChantRaw(args, demo.project).catch((err) => ({
4749
+ code: 127,
4750
+ stdout: "",
4751
+ stderr: err instanceof Error ? err.message : String(err)
4752
+ }));
4753
+ const output = merge(run3, demo.root);
4754
+ if (run3.code !== 0) {
4755
+ return refuse2(
4756
+ "carve-action",
4757
+ `chant carve bridge exited ${run3.code}: ${output || "(no output)"}`,
4758
+ "Run the Emit step first \u2014 bridge reads the carve manifest emit leaves in the output directory."
4759
+ );
4760
+ }
4761
+ const slug3 = carveSlug(select);
4762
+ const all = readArtifacts(demo, demo.out, (rel) => /\.(tf|md|patch)$/.test(rel));
4763
+ return {
4764
+ ok: true,
4765
+ select,
4766
+ command: `chant ${args.map((a) => shortenIn(a, demo.root)).join(" ")}`,
4767
+ output,
4768
+ runbook: all.find((a) => a.path.endsWith(`${slug3}-runbook.md`)) ?? null,
4769
+ proposals: all.filter((a) => !a.path.endsWith(`${slug3}-runbook.md`))
4770
+ };
4771
+ }
4772
+
4773
+ // src/ops.ts
4774
+ import { readdirSync as readdirSync4, readFileSync as readFileSync9, existsSync as existsSync8 } from "node:fs";
4775
+ import { join as join10 } from "node:path";
3110
4776
  var APPLY_TARGET_LEXICON = {
3111
4777
  cloudformation: "aws",
3112
4778
  kubectl: "k8s",
@@ -3128,11 +4794,11 @@ function discoverOps(projectDir) {
3128
4794
  const seen = /* @__PURE__ */ new Set();
3129
4795
  const out = [];
3130
4796
  for (const sub of ["ops", "src", "."]) {
3131
- const dir = join8(projectDir, sub);
3132
- if (!existsSync6(dir)) continue;
3133
- for (const f of readdirSync3(dir)) {
4797
+ const dir = join10(projectDir, sub);
4798
+ if (!existsSync8(dir)) continue;
4799
+ for (const f of readdirSync4(dir)) {
3134
4800
  if (!f.endsWith(".op.ts")) continue;
3135
- const content = readFileSync7(join8(dir, f), "utf8");
4801
+ const content = readFileSync9(join10(dir, f), "utf8");
3136
4802
  const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
3137
4803
  if (!name || seen.has(name)) continue;
3138
4804
  seen.add(name);
@@ -3248,7 +4914,7 @@ function classifyHealth(status) {
3248
4914
  function rec2(v) {
3249
4915
  return typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
3250
4916
  }
3251
- function str3(v) {
4917
+ function str2(v) {
3252
4918
  return typeof v === "string" && v.length > 0 ? v : void 0;
3253
4919
  }
3254
4920
  function statusTree(o) {
@@ -3269,24 +4935,40 @@ var ARGO_SYNC = {
3269
4935
  OutOfSync: "progressing",
3270
4936
  Unknown: "unknown"
3271
4937
  };
4938
+ var ARGO_ERROR_CONDITION = /Error$/;
4939
+ function argoErrorMessage(tree) {
4940
+ const conditions = tree?.conditions;
4941
+ if (!Array.isArray(conditions)) return void 0;
4942
+ for (const c of conditions) {
4943
+ const type = str2(rec2(c)?.type);
4944
+ if (!type || !ARGO_ERROR_CONDITION.test(type)) continue;
4945
+ const message = str2(rec2(c)?.message);
4946
+ if (message) return message;
4947
+ }
4948
+ return void 0;
4949
+ }
3272
4950
  function argoVerdict(o) {
3273
4951
  if (o.type !== "K8s::Argo::Application") return void 0;
3274
4952
  const tree = statusTree(o);
3275
- let health = str3(rec2(tree?.health)?.status);
3276
- let sync = str3(rec2(tree?.sync)?.status);
4953
+ let health = str2(rec2(tree?.health)?.status);
4954
+ let sync = str2(rec2(tree?.sync)?.status);
3277
4955
  if (!health && !sync) {
3278
- const word = str3(o.status);
4956
+ const word = str2(o.status);
3279
4957
  if (word === "READY") {
3280
4958
  health = "Healthy";
3281
4959
  sync = "Synced";
4960
+ } else if (word && word in ARGO_SYNC) {
4961
+ sync = word;
4962
+ health = "Healthy";
3282
4963
  } else if (word && word in ARGO_HEALTH) health = word;
3283
- else if (word && word in ARGO_SYNC) sync = word;
3284
4964
  }
3285
4965
  const verdicts = [health ? ARGO_HEALTH[health] : void 0, sync ? ARGO_SYNC[sync] : void 0].filter(
3286
4966
  (v) => v !== void 0
3287
4967
  );
3288
4968
  if (verdicts.length === 0) return void 0;
3289
- const detail = [health ? `health=${health}` : void 0, sync ? `sync=${sync}` : void 0].filter(Boolean).join(", ");
4969
+ const pair = [health ? `health=${health}` : void 0, sync ? `sync=${sync}` : void 0].filter(Boolean).join(", ");
4970
+ const message = argoErrorMessage(tree);
4971
+ const detail = message ? `${pair}: ${message}` : pair;
3290
4972
  for (const rung of ["degraded", "progressing", "unknown"]) {
3291
4973
  if (verdicts.includes(rung)) return { health: rung, detail };
3292
4974
  }
@@ -3299,9 +4981,9 @@ function readyFromConditions(tree) {
3299
4981
  for (const c of conditions) {
3300
4982
  const cond = rec2(c);
3301
4983
  if (!cond || cond.type !== "Ready") continue;
3302
- const status = str3(cond.status);
4984
+ const status = str2(cond.status);
3303
4985
  if (status !== "True" && status !== "False" && status !== "Unknown") continue;
3304
- return { status, reason: str3(cond.reason) };
4986
+ return { status, reason: str2(cond.reason) };
3305
4987
  }
3306
4988
  return void 0;
3307
4989
  }
@@ -3318,7 +5000,7 @@ function readyFromChantConditions(o) {
3318
5000
  return void 0;
3319
5001
  }
3320
5002
  function readyFromStatusWord(o) {
3321
- const word = str3(o.status);
5003
+ const word = str2(o.status);
3322
5004
  if (!word || word === "PRESENT") return void 0;
3323
5005
  if (word === "READY") return { status: "True" };
3324
5006
  if (word === "NOT-READY") return { status: "False" };
@@ -3328,8 +5010,8 @@ function describeReady(ready) {
3328
5010
  return `Ready=${ready.status}${ready.reason ? ` (${ready.reason})` : ""}`;
3329
5011
  }
3330
5012
  function revisionVerdict(tree) {
3331
- const applied = str3(tree?.lastAppliedRevision);
3332
- const attempted = str3(tree?.lastAttemptedRevision);
5013
+ const applied = str2(tree?.lastAppliedRevision);
5014
+ const attempted = str2(tree?.lastAttemptedRevision);
3333
5015
  if (!applied || !attempted || applied === attempted) return void 0;
3334
5016
  return { health: "progressing", detail: `applied ${applied}, attempted ${attempted}` };
3335
5017
  }
@@ -3347,7 +5029,7 @@ function fluxVerdict(o) {
3347
5029
  return cond?.type === "Reconciling" && cond.status === "True";
3348
5030
  });
3349
5031
  if (reconciling) return { health: "progressing", detail: "Reconciling=True" };
3350
- if (!str3(o.status) || o.status === "PRESENT") {
5032
+ if (!str2(o.status) || o.status === "PRESENT") {
3351
5033
  return { health: "progressing", detail: "no Ready condition yet" };
3352
5034
  }
3353
5035
  return void 0;
@@ -3642,40 +5324,41 @@ var OpRunner = class {
3642
5324
 
3643
5325
  // src/substrates.ts
3644
5326
  import { spawn as spawn3 } from "node:child_process";
3645
- import { existsSync as existsSync7, readFileSync as readFileSync8 } from "node:fs";
3646
- import { join as join9 } from "node:path";
5327
+ import { existsSync as existsSync9, readFileSync as readFileSync10 } from "node:fs";
5328
+ import { join as join11 } from "node:path";
3647
5329
  import { platform } from "node:os";
5330
+ var DOCKER_DOWN_DETAIL = "docker is down";
3648
5331
  function probe(cmd, args) {
3649
- return new Promise((resolve5) => {
5332
+ return new Promise((resolve8) => {
3650
5333
  let out = "";
3651
5334
  let proc;
3652
5335
  try {
3653
5336
  proc = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
3654
5337
  } catch {
3655
- resolve5({ code: 127, out: "" });
5338
+ resolve8({ code: 127, out: "" });
3656
5339
  return;
3657
5340
  }
3658
5341
  proc.stdout.on("data", (d) => out += d);
3659
5342
  proc.stderr.on("data", (d) => out += d);
3660
- proc.on("error", () => resolve5({ code: 127, out }));
3661
- proc.on("close", (code) => resolve5({ code: code ?? 1, out }));
5343
+ proc.on("error", () => resolve8({ code: 127, out }));
5344
+ proc.on("close", (code) => resolve8({ code: code ?? 1, out }));
3662
5345
  });
3663
5346
  }
3664
- async function dockerAvailable() {
3665
- const { code } = await probe("docker", ["info", "--format", "{{.ServerVersion}}"]);
5347
+ async function dockerAvailable(run3) {
5348
+ const { code } = await run3("docker", ["info", "--format", "{{.ServerVersion}}"]);
3666
5349
  return code === 0;
3667
5350
  }
3668
- async function dockerRunning(nameFilter) {
3669
- const { code, out } = await probe("docker", ["ps", "--filter", `name=${nameFilter}`, "--format", "{{.Names}}"]);
5351
+ async function dockerRunning(run3, nameFilter) {
5352
+ const { code, out } = await run3("docker", ["ps", "--filter", `name=${nameFilter}`, "--format", "{{.Names}}"]);
3670
5353
  if (code !== 0) return [];
3671
5354
  return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
3672
5355
  }
3673
5356
  function scriptBringUp(projectDir, relPath, label) {
3674
- return existsSync7(join9(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
5357
+ return existsSync9(join11(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
3675
5358
  }
3676
5359
  function projectLexicons(projectDir) {
3677
5360
  try {
3678
- const src = readFileSync8(join9(projectDir, "chant.config.ts"), "utf-8");
5361
+ const src = readFileSync10(join11(projectDir, "chant.config.ts"), "utf-8");
3679
5362
  const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
3680
5363
  if (!m) return [];
3681
5364
  return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
@@ -3683,10 +5366,11 @@ function projectLexicons(projectDir) {
3683
5366
  return [];
3684
5367
  }
3685
5368
  }
3686
- async function detectSubstrates(projectDir, preview = false, boundContext) {
5369
+ async function detectSubstrates(projectDir, preview = false, boundContext, probes = {}) {
5370
+ const run3 = probes.probe ?? probe;
3687
5371
  const subs = [];
3688
5372
  const lexicons = projectLexicons(projectDir);
3689
- const docker = await dockerAvailable();
5373
+ const docker = await dockerAvailable(run3);
3690
5374
  subs.push({
3691
5375
  name: "docker",
3692
5376
  label: "Docker",
@@ -3694,9 +5378,10 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3694
5378
  detail: docker ? "daemon running" : "daemon not running",
3695
5379
  bringUp: docker || platform() !== "darwin" ? void 0 : { label: "open -a Docker", cmd: "open", args: ["-a", "Docker"] }
3696
5380
  });
3697
- 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 };
5381
+ const DOCKER_BLOCKED = { status: "blocked", detail: DOCKER_DOWN_DETAIL };
5382
+ const dep = (up, upDetail, offDetail) => !docker ? DOCKER_BLOCKED : { status: up ? "up" : offDetail === "on-demand (pipeline run)" ? "on-demand" : "down", detail: up ? upDetail : offDetail };
3698
5383
  if (lexicons.includes("aws")) {
3699
- const floci = docker ? await dockerRunning("^floci$|^chant-floci$") : [];
5384
+ const floci = docker ? await dockerRunning(run3, "^floci$|^chant-floci$") : [];
3700
5385
  const d = dep(floci.length > 0, "container up on :4566", "not running");
3701
5386
  subs.push({
3702
5387
  name: "floci",
@@ -3711,7 +5396,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3711
5396
  ];
3712
5397
  for (const [lexicon, name, label, pattern, port] of emulators) {
3713
5398
  if (!lexicons.includes(lexicon)) continue;
3714
- const running = docker ? await dockerRunning(pattern) : [];
5399
+ const running = docker ? await dockerRunning(run3, pattern) : [];
3715
5400
  subs.push({
3716
5401
  name,
3717
5402
  label,
@@ -3725,8 +5410,8 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3725
5410
  ["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
3726
5411
  ];
3727
5412
  for (const [name, label, marker, script] of forges) {
3728
- if (!existsSync7(join9(projectDir, marker))) continue;
3729
- const c = docker ? await dockerRunning(name) : [];
5413
+ if (!existsSync9(join11(projectDir, marker))) continue;
5414
+ const c = docker ? await dockerRunning(run3, name) : [];
3730
5415
  const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
3731
5416
  subs.push({
3732
5417
  name,
@@ -3736,15 +5421,19 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3736
5421
  });
3737
5422
  }
3738
5423
  if (lexicons.includes("k8s")) {
3739
- const k3d = await probe("k3d", ["cluster", "list", "--no-headers"]);
3740
- const clusters = k3d.code === 0 ? k3d.out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : [];
3741
- subs.push({
3742
- name: "k3d",
3743
- label: "k3d",
3744
- status: k3d.code === 127 ? "unknown" : clusters.length ? "up" : "down",
3745
- detail: k3d.code === 127 ? "k3d not installed" : clusters.length ? `${clusters.length} cluster(s)` : "no clusters",
3746
- bringUp: docker && k3d.code !== 127 && !clusters.length ? scriptBringUp(projectDir, "scripts/local/local-up.sh", "local-up") : void 0
3747
- });
5424
+ if (!docker) {
5425
+ subs.push({ name: "k3d", label: "k3d", ...DOCKER_BLOCKED });
5426
+ } else {
5427
+ const k3d = await run3("k3d", ["cluster", "list", "--no-headers"]);
5428
+ const clusters = k3d.code === 0 ? k3d.out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : [];
5429
+ subs.push({
5430
+ name: "k3d",
5431
+ label: "k3d",
5432
+ status: k3d.code === 127 ? "unknown" : clusters.length ? "up" : "down",
5433
+ detail: k3d.code === 127 ? "k3d not installed" : clusters.length ? `${clusters.length} cluster(s)` : "no clusters",
5434
+ bringUp: k3d.code !== 127 && !clusters.length ? scriptBringUp(projectDir, "scripts/local/local-up.sh", "local-up") : void 0
5435
+ });
5436
+ }
3748
5437
  }
3749
5438
  if (lexicons.includes("fly")) {
3750
5439
  const endpoint = process.env.FLY_FLAPS_BASE_URL;
@@ -3755,8 +5444,8 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3755
5444
  detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
3756
5445
  });
3757
5446
  }
3758
- if (existsSync7(join9(projectDir, ".github", "workflows"))) {
3759
- const gh = await probe("gh", ["auth", "status"]);
5447
+ if (existsSync9(join11(projectDir, ".github", "workflows"))) {
5448
+ const gh = await run3("gh", ["auth", "status"]);
3760
5449
  const ready = gh.code === 0;
3761
5450
  subs.push({
3762
5451
  name: "github",
@@ -3768,7 +5457,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3768
5457
  if (lexicons.includes("temporal")) {
3769
5458
  let hasProfiles = false;
3770
5459
  try {
3771
- hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync8(join9(projectDir, "chant.config.ts"), "utf-8"));
5460
+ hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync10(join11(projectDir, "chant.config.ts"), "utf-8"));
3772
5461
  } catch {
3773
5462
  }
3774
5463
  subs.push({
@@ -3779,9 +5468,9 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3779
5468
  });
3780
5469
  }
3781
5470
  if (lexicons.includes("helm")) {
3782
- const helm = await probe("helm", ["version", "--short"]);
3783
- const ambient = helm.code === 0 && !boundContext ? await probe("kubectl", ["config", "current-context"]) : { code: 127, out: "" };
3784
- const ctx = boundContext ?? (ambient.code === 0 ? ambient.out.trim() : "");
5471
+ const helm = await run3("helm", ["version", "--short"]);
5472
+ const ambient = helm.code === 0 && !boundContext ? await ambientContext() : void 0;
5473
+ const ctx = boundContext ?? ambient ?? "";
3785
5474
  subs.push({
3786
5475
  name: "helm",
3787
5476
  label: "Helm",
@@ -3831,9 +5520,9 @@ function pickAutoSyncOps(mode, ops, running, movedLexicons, suspended = /* @__PU
3831
5520
  }
3832
5521
 
3833
5522
  // src/history.ts
3834
- import { execFile as execFile3 } from "node:child_process";
3835
- import { promisify as promisify3 } from "node:util";
3836
- var execFileAsync = promisify3(execFile3);
5523
+ import { execFile as execFile2 } from "node:child_process";
5524
+ import { promisify as promisify2 } from "node:util";
5525
+ var execFileAsync = promisify2(execFile2);
3837
5526
  var SEP = "";
3838
5527
  function parseGitLog(stdout) {
3839
5528
  return stdout.split("\n").map((l) => l.trim()).filter(Boolean).map((line) => {
@@ -3872,6 +5561,8 @@ async function openRollbackBranches(projectDir, env) {
3872
5561
  }
3873
5562
 
3874
5563
  // src/estate.ts
5564
+ import { statSync as statSync3 } from "node:fs";
5565
+ import { join as join12, resolve as resolve3, sep as sep2 } from "node:path";
3875
5566
  import { composeStacks, shortStackNames } from "@intentius/pinhole";
3876
5567
  async function composeEstate(projectDirs, opts = {}) {
3877
5568
  const names = shortStackNames(projectDirs);
@@ -3880,7 +5571,7 @@ async function composeEstate(projectDirs, opts = {}) {
3880
5571
  );
3881
5572
  return composeStacks(stacks);
3882
5573
  }
3883
- var firstLine = (e) => {
5574
+ var firstLine2 = (e) => {
3884
5575
  const msg = e instanceof Error ? e.message : String(e);
3885
5576
  const m = msg.match(/exited \d+:\s*([\s\S]*)/);
3886
5577
  return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
@@ -3890,20 +5581,116 @@ function namespaceRuntimeOwners(name, ir) {
3890
5581
  for (const n of ir.nodes) {
3891
5582
  if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
3892
5583
  }
3893
- return ir;
5584
+ return ir;
5585
+ }
5586
+ var NAMESPACE_JOIN_FLOOR = "0.44.5";
5587
+ var rec3 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
5588
+ var str3 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
5589
+ var realIsDir = (p) => {
5590
+ try {
5591
+ return statSync3(p).isDirectory();
5592
+ } catch {
5593
+ return false;
5594
+ }
5595
+ };
5596
+ function declaredNamespaceBindings(ir) {
5597
+ const out = [];
5598
+ for (const n of ir.nodes) {
5599
+ if (n.kind !== "K8s::Flux::Kustomization") continue;
5600
+ const spec = rec3(n.attrs?.spec);
5601
+ const namespace = str3(spec?.targetNamespace);
5602
+ const path = str3(spec?.path);
5603
+ if (namespace && path) out.push({ path, namespace });
5604
+ }
5605
+ return out;
5606
+ }
5607
+ function awaitsNamespaceBinding(ir) {
5608
+ const namespaced = ir.nodes.filter((n) => n.lexicon === "k8s" && !CLUSTER_SCOPED.has(n.kind ?? ""));
5609
+ if (namespaced.length === 0) return false;
5610
+ return namespaced.every((n) => !str3(rec3(n.attrs?.metadata)?.namespace));
5611
+ }
5612
+ function declaredSegments(path) {
5613
+ if (path.startsWith("/")) return void 0;
5614
+ const segs = path.split("/").filter((s) => s !== "" && s !== ".");
5615
+ if (segs.length === 0 || segs.includes("..")) return void 0;
5616
+ return segs;
5617
+ }
5618
+ function pathAlignment(declared, dir, isDir = realIsDir) {
5619
+ const want = declaredSegments(declared);
5620
+ if (!want) return 0;
5621
+ const root = resolve3(dir);
5622
+ const have = root.split(sep2).filter(Boolean);
5623
+ for (let k = Math.min(want.length, have.length); k >= 1; k--) {
5624
+ if (!have.slice(-k).every((s, i) => s === want[i])) continue;
5625
+ if (!isDir(join12(root, ...want.slice(k)))) continue;
5626
+ return k;
5627
+ }
5628
+ return 0;
5629
+ }
5630
+ function joinNamespaceBindings(members, isDir = realIsDir) {
5631
+ const bindings = members.flatMap(
5632
+ (m) => m.ir ? declaredNamespaceBindings(m.ir).map((b) => ({ ...b, declaredBy: m.dir })) : []
5633
+ );
5634
+ if (bindings.length === 0) return [];
5635
+ const awaiting = members.filter((m) => m.ir && awaitsNamespaceBinding(m.ir));
5636
+ if (awaiting.length === 0) return [];
5637
+ const best = /* @__PURE__ */ new Map();
5638
+ for (const b of bindings) {
5639
+ let winner;
5640
+ let tied = false;
5641
+ for (const m of awaiting) {
5642
+ if (m.dir === b.declaredBy) continue;
5643
+ const score = pathAlignment(b.path, m.dir, isDir);
5644
+ if (score === 0) continue;
5645
+ if (!winner || score > winner.score) {
5646
+ winner = { dir: m.dir, score };
5647
+ tied = false;
5648
+ } else if (score === winner.score) {
5649
+ tied = true;
5650
+ }
5651
+ }
5652
+ if (!winner || tied) continue;
5653
+ const prev = best.get(winner.dir);
5654
+ if (prev === "ambiguous") continue;
5655
+ const join18 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
5656
+ if (!prev) best.set(winner.dir, { score: winner.score, join: join18 });
5657
+ else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
5658
+ else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join18 });
5659
+ }
5660
+ return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
5661
+ }
5662
+ async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
5663
+ if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
5664
+ const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
5665
+ const irs = await Promise.all(projectDirs.map((dir) => graphIr(dir, { ...src, detail: 3 }).catch(() => void 0)));
5666
+ const scopes = /* @__PURE__ */ new Map();
5667
+ for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
5668
+ if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
5669
+ scopes.set(j.dir, j.namespace);
5670
+ }
5671
+ return scopes;
5672
+ }
5673
+ function withoutJoinedMembers(nodes, joined) {
5674
+ if (joined.length === 0) return [...nodes];
5675
+ return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
3894
5676
  }
3895
5677
  async function composeEstateOverlay(projectDirs, opts, classify) {
3896
5678
  const names = shortStackNames(projectDirs);
3897
5679
  const unobserved = [];
3898
5680
  const dropped = [];
5681
+ const joined = [];
3899
5682
  const stacks = new Array(projectDirs.length);
5683
+ const scopes = await estateNamespaceScopes(projectDirs, opts);
3900
5684
  await Promise.all(
3901
5685
  projectDirs.map(async (dir, i) => {
3902
5686
  const name = names[i];
5687
+ const namespace = scopes.get(dir);
3903
5688
  try {
3904
- stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, { ...opts, live: true, overlay: true }))) };
5689
+ const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
5690
+ stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
5691
+ if (namespace) joined.push({ name, namespace });
3905
5692
  } catch (err) {
3906
- const reason = firstLine(err);
5693
+ const reason = firstLine2(err);
3907
5694
  try {
3908
5695
  const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
3909
5696
  const src = await graphIr(dir, srcOpts);
@@ -3911,7 +5698,7 @@ async function composeEstateOverlay(projectDirs, opts, classify) {
3911
5698
  stacks[i] = { name, ir: src };
3912
5699
  unobserved.push({ name, reason });
3913
5700
  } catch (err2) {
3914
- dropped.push({ name, reason: firstLine(err2) });
5701
+ dropped.push({ name, reason: firstLine2(err2) });
3915
5702
  }
3916
5703
  }
3917
5704
  })
@@ -3922,13 +5709,16 @@ async function composeEstateOverlay(projectDirs, opts, classify) {
3922
5709
  observed: present.length - unobserved.length,
3923
5710
  total: projectDirs.length,
3924
5711
  unobserved,
3925
- dropped
5712
+ dropped,
5713
+ // Concurrent reads finish in whatever order the clusters answer; the note
5714
+ // this feeds should read the same twice.
5715
+ joined: joined.sort((a, b) => a.name.localeCompare(b.name))
3926
5716
  };
3927
5717
  }
3928
5718
 
3929
5719
  // src/events.ts
3930
- import { watch, existsSync as existsSync8 } from "node:fs";
3931
- import { join as join10 } from "node:path";
5720
+ import { watch, existsSync as existsSync10 } from "node:fs";
5721
+ import { join as join13 } from "node:path";
3932
5722
  var Broadcaster = class {
3933
5723
  listeners = /* @__PURE__ */ new Set();
3934
5724
  subscribe(fn) {
@@ -3946,7 +5736,7 @@ var Broadcaster = class {
3946
5736
  };
3947
5737
  var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
3948
5738
  function watchSource(projectDir, onChange, debounceMs = 200) {
3949
- const dir = existsSync8(join10(projectDir, "src")) ? join10(projectDir, "src") : projectDir;
5739
+ const dir = existsSync10(join13(projectDir, "src")) ? join13(projectDir, "src") : projectDir;
3950
5740
  let timer;
3951
5741
  const watcher = watch(dir, { recursive: true }, (_event, file) => {
3952
5742
  const name = typeof file === "string" ? file : "";
@@ -4223,9 +6013,108 @@ async function emulatorDown(projectDir) {
4223
6013
  await runChantRaw(["emulator", "down"], projectDir);
4224
6014
  }
4225
6015
 
6016
+ // src/demos.ts
6017
+ import { readFileSync as readFileSync11, existsSync as existsSync11, cpSync } from "node:fs";
6018
+ import { join as join14, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
6019
+ import { spawn as spawn4, spawnSync } from "node:child_process";
6020
+ function loadDemoRegistry(pkgRoot2) {
6021
+ let raw;
6022
+ try {
6023
+ raw = JSON.parse(readFileSync11(join14(pkgRoot2, "demos.json"), "utf8"));
6024
+ } catch {
6025
+ return [];
6026
+ }
6027
+ const list3 = raw?.demos;
6028
+ if (!Array.isArray(list3)) return [];
6029
+ return list3.filter((e) => {
6030
+ const d = e;
6031
+ if (!d || typeof d.name !== "string" || !d.name || typeof d.description !== "string") return false;
6032
+ if (d.source === "bundled") {
6033
+ if (typeof d.dir !== "string" || !d.dir) return false;
6034
+ } else if (d.source === "git") {
6035
+ if (typeof d.repo !== "string" || !d.repo) return false;
6036
+ } else {
6037
+ return false;
6038
+ }
6039
+ if (!Array.isArray(d.requires) || d.requires.some((r) => typeof r !== "string")) return false;
6040
+ if (!d.serve || typeof d.serve !== "object") return false;
6041
+ if (d.serve.dirs !== void 0 && (!Array.isArray(d.serve.dirs) || d.serve.dirs.some((x) => typeof x !== "string") || !d.serve.dirs.length))
6042
+ return false;
6043
+ if (d.serve.carve !== void 0) {
6044
+ const c = d.serve.carve;
6045
+ const rel = (v) => typeof v === "string" && !!v && !v.startsWith("/") && !v.split("/").includes("..");
6046
+ if (!c || typeof c !== "object") return false;
6047
+ if (!rel(c.report) || !rel(c.from) || !rel(c.project) || !rel(c.out)) return false;
6048
+ if (c.state !== void 0 && !rel(c.state)) return false;
6049
+ if (!`${c.out}/`.startsWith(`${c.project}/`)) return false;
6050
+ }
6051
+ return true;
6052
+ });
6053
+ }
6054
+ function missingRequirements(entry) {
6055
+ const bins = entry.source === "git" && !entry.requires.includes("git") ? [...entry.requires, "git"] : entry.requires;
6056
+ const finder = process.platform === "win32" ? "where" : "which";
6057
+ return bins.filter((bin) => spawnSync(finder, [bin], { stdio: "ignore" }).status !== 0);
6058
+ }
6059
+ function fetchesFromNetwork(entry) {
6060
+ return entry.source === "git";
6061
+ }
6062
+ function demoTargetDir(entry, cwd = process.cwd()) {
6063
+ const legacy = resolve4(cwd, "behold-demo");
6064
+ if (entry.name === "writes" && existsSync11(legacy)) return legacy;
6065
+ return resolve4(cwd, "behold-demos", entry.name);
6066
+ }
6067
+ async function loadDemo(entry, opts) {
6068
+ const { pkgRoot: pkgRoot2, target } = opts;
6069
+ const say = (line) => opts.log?.(`behold demo ${entry.name} \u2192 ${line}`);
6070
+ if (!existsSync11(target)) {
6071
+ if (entry.source === "bundled") {
6072
+ const bundled = join14(pkgRoot2, entry.dir);
6073
+ if (!existsSync11(bundled)) return { ok: false, error: `this install has no bundled ${entry.dir}` };
6074
+ say(`copying to ${target} (it's yours \u2014 edit it)`);
6075
+ try {
6076
+ cpSync(bundled, target, {
6077
+ recursive: true,
6078
+ filter: (src) => !relative2(bundled, src).split(sep3).includes("node_modules")
6079
+ });
6080
+ } catch (err) {
6081
+ return { ok: false, error: `copy failed: ${err instanceof Error ? err.message : String(err)}` };
6082
+ }
6083
+ } else {
6084
+ say(`cloning ${entry.repo} to ${target}`);
6085
+ if (await runStep("git", ["clone", "--depth", "1", entry.repo, target]) !== 0) {
6086
+ return { ok: false, error: `clone of ${entry.repo} failed` };
6087
+ }
6088
+ }
6089
+ } else {
6090
+ say(`reusing ${target}`);
6091
+ }
6092
+ if (existsSync11(join14(target, "package.json")) && !existsSync11(join14(target, "node_modules"))) {
6093
+ say("npm install\u2026");
6094
+ if (await runStep("npm", ["install"], { cwd: target, shell: process.platform === "win32" }) !== 0) {
6095
+ return { ok: false, error: `npm install failed in ${target}` };
6096
+ }
6097
+ }
6098
+ if (entry.setup) {
6099
+ say(entry.setup);
6100
+ if (await runStep(entry.setup, [], { cwd: target, shell: true }) !== 0) {
6101
+ return { ok: false, error: `setup failed (${entry.setup})` };
6102
+ }
6103
+ }
6104
+ return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join14(target, d)) : [target] };
6105
+ }
6106
+ function runStep(cmd, args, opts = {}) {
6107
+ return new Promise((res) => {
6108
+ const child = spawn4(cmd, args, { stdio: "inherit", cwd: opts.cwd, shell: opts.shell ?? false });
6109
+ child.on("error", () => res(-1));
6110
+ child.on("close", (code) => res(code ?? 1));
6111
+ });
6112
+ }
6113
+
4226
6114
  // src/server.ts
4227
- var webRoot = join11(dirname4(fileURLToPath3(import.meta.url)), "..", "web");
4228
- var execFileP = async (cmd, args) => (await promisify4(execFile4)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
6115
+ var pkgRoot = join15(dirname4(fileURLToPath3(import.meta.url)), "..");
6116
+ var webRoot = join15(pkgRoot, "web");
6117
+ var execFileP = async (cmd, args) => (await promisify3(execFile3)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
4229
6118
  function optsFromQuery(url, tierEnvVar, projectDir) {
4230
6119
  const q = url.searchParams;
4231
6120
  const opts = {};
@@ -4274,7 +6163,7 @@ function tierFailure(tier, message) {
4274
6163
  function beholdVersion() {
4275
6164
  try {
4276
6165
  const here = dirname4(fileURLToPath3(import.meta.url));
4277
- return JSON.parse(readFileSync9(join11(here, "..", "package.json"), "utf8")).version ?? "unknown";
6166
+ return JSON.parse(readFileSync12(join15(here, "..", "package.json"), "utf8")).version ?? "unknown";
4278
6167
  } catch {
4279
6168
  return "unknown";
4280
6169
  }
@@ -4318,12 +6207,125 @@ async function captureFrame(projectDir, env, frames, broadcaster) {
4318
6207
  return null;
4319
6208
  }
4320
6209
  }
6210
+ function carveRoutes(app, reportPath, demo) {
6211
+ const load = () => readCarveReport(reportPath, (p) => readFileSync12(p, "utf8"));
6212
+ const demoBlock = () => carveWriteBlock(demo);
6213
+ const demoInfo = () => {
6214
+ if (!demo) return null;
6215
+ const block = demoBlock();
6216
+ return {
6217
+ root: demo.root,
6218
+ from: demo.from,
6219
+ state: demo.state ?? null,
6220
+ project: demo.project,
6221
+ out: demo.out,
6222
+ // Paths the UI can print without leaking the operator's whole home dir.
6223
+ outLabel: relative3(demo.root, demo.out).split(sep4).join("/"),
6224
+ fromLabel: relative3(demo.root, demo.from).split(sep4).join("/"),
6225
+ runnable: !block,
6226
+ ...block ? { reason: block } : {},
6227
+ ...demo.degraded ? { degraded: demo.degraded } : {},
6228
+ buildCaveat: BUILD_CAVEAT
6229
+ };
6230
+ };
6231
+ app.get("/api/carve", (c) => {
6232
+ const parsed = load();
6233
+ return parsed.ok ? c.json(parsed.report) : c.json(parsed.refusal, 422);
6234
+ });
6235
+ app.get("/api/graph", (c) => {
6236
+ const parsed = load();
6237
+ if (!parsed.ok) return c.json(parsed.refusal, 422);
6238
+ const ir = carveReportToIr(parsed.report);
6239
+ const { svg } = renderBanded(ir);
6240
+ return c.json({
6241
+ ir,
6242
+ svg,
6243
+ meta: {
6244
+ projectDir: reportPath,
6245
+ env: null,
6246
+ tier: null,
6247
+ target: null,
6248
+ carve: true,
6249
+ // A demo whose own advisor run failed says so on the statusbar, not
6250
+ // only in the terminal the viewer isn't looking at.
6251
+ note: carveNote(parsed.report, ir) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : "")
6252
+ }
6253
+ });
6254
+ });
6255
+ app.get("/api/project", (c) => {
6256
+ const parsed = load();
6257
+ return c.json({
6258
+ projectDir: reportPath,
6259
+ recents: [],
6260
+ // No envs, tiers, stacks or targets: a peelability report is a static
6261
+ // analysis of foreign Terraform, so every picker that would imply a live
6262
+ // axis stays empty and the SPA renders none of them.
6263
+ environments: [],
6264
+ lexicons: ["terraform"],
6265
+ currentEnv: null,
6266
+ targets: [],
6267
+ carve: parsed.ok ? {
6268
+ report: reportPath,
6269
+ from: parsed.report.from ?? null,
6270
+ count: parsed.report.count ?? parsed.report.resources.length,
6271
+ bands: parsed.report.bands ?? {},
6272
+ advisory: parsed.report.advisory ?? null,
6273
+ demo: demoInfo()
6274
+ } : { report: reportPath, demo: demoInfo() }
6275
+ });
6276
+ });
6277
+ const runStep2 = async (c, run3) => {
6278
+ const block = demoBlock();
6279
+ if (block) {
6280
+ return c.json(
6281
+ {
6282
+ error: block,
6283
+ code: "read-only",
6284
+ remedy: "The carve steps run inside a demo copy \u2014 start the walkthrough with `behold demo carve`."
6285
+ },
6286
+ 403
6287
+ );
6288
+ }
6289
+ if (!(c.req.header("content-type") ?? "").includes("application/json")) {
6290
+ return c.json({ error: "send application/json", code: "carve-select", remedy: 'POST {"select": "<terraform address>"}' }, 415);
6291
+ }
6292
+ const text = await c.req.text().catch(() => "");
6293
+ if (text.length > 4096) return c.json({ error: "a carve step body is capped at 4096 bytes", code: "carve-select", remedy: 'POST {"select": "\u2026"}' }, 413);
6294
+ let body;
6295
+ try {
6296
+ body = JSON.parse(text || "null");
6297
+ } catch {
6298
+ return c.json({ error: "body must be JSON", code: "carve-select", remedy: 'POST {"select": "<terraform address>"}' }, 400);
6299
+ }
6300
+ const parsed = load();
6301
+ const select = selectFromReport(parsed.ok ? parsed.report : void 0, body?.select);
6302
+ if (!select) {
6303
+ return c.json(
6304
+ {
6305
+ error: `\`select\` must name a resource this report ranks \u2014 ${JSON.stringify(body?.select ?? null)} isn't one of them.`,
6306
+ code: "carve-select",
6307
+ remedy: "Pick a card in the graph, or read the addresses off GET /api/carve."
6308
+ },
6309
+ 400
6310
+ );
6311
+ }
6312
+ const result = await run3(demo, select);
6313
+ return result.ok ? c.json(result) : c.json(result.refusal, 422);
6314
+ };
6315
+ app.post("/api/carve/emit", (c) => runStep2(c, runCarveEmit));
6316
+ app.post("/api/carve/bridge", (c) => runStep2(c, runCarveBridge));
6317
+ app.get("/api/substrates", (c) => c.json({ substrates: [] }));
6318
+ app.get("/api/history", (c) => c.json({ commits: [] }));
6319
+ app.get("/api/resources", (c) => c.json({ byComponent: {} }));
6320
+ app.get("/api/ci", (c) => c.json({ stages: [], jobs: [], forge: null }));
6321
+ }
4321
6322
  function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffer(), runner = new OpRunner({
4322
6323
  projectDir: cfg.projectDir,
4323
6324
  broadcaster,
4324
6325
  onDone: (opEnv) => captureFrame(cfg.projectDir, opEnv ?? cfg.env, frames, broadcaster)
4325
6326
  })) {
4326
6327
  const app = new Hono();
6328
+ if (cfg.carveReport) carveRoutes(app, cfg.carveReport, cfg.carveDemo);
4327
6329
  let beholdConfig = loadBeholdConfig(cfg.projectDir);
4328
6330
  let tierEnvVar = beholdConfig.tiers?.envVar;
4329
6331
  const boundK8sContext = async (env) => {
@@ -4408,9 +6410,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4408
6410
  return c.json({ started: true, name, ran: label });
4409
6411
  });
4410
6412
  app.post("/api/local/reset", (c) => {
4411
- const down = join11(cfg.projectDir, "scripts/local/local-down.sh");
4412
- const up = join11(cfg.projectDir, "scripts/local/local-up.sh");
4413
- if (!existsSync9(down) || !existsSync9(up)) {
6413
+ const down = join15(cfg.projectDir, "scripts/local/local-down.sh");
6414
+ const up = join15(cfg.projectDir, "scripts/local/local-up.sh");
6415
+ if (!existsSync12(down) || !existsSync12(up)) {
4414
6416
  return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
4415
6417
  }
4416
6418
  if (!runner.bringUp("reset local emulator", "bash", ["-c", "bash scripts/local/local-down.sh && bash scripts/local/local-up.sh"], cfg.projectDir)) {
@@ -4487,26 +6489,29 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4487
6489
  app.post("/api/project/open", async (c) => {
4488
6490
  if (cfg.previewMode) return c.json({ error: "switching projects is locked in preview mode" }, 403);
4489
6491
  const body = await c.req.json().catch(() => ({}));
4490
- const dir = typeof body.dir === "string" && body.dir.trim() ? resolve2(body.dir.trim()) : "";
4491
- if (!dir || !existsSync9(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
4492
- if (!existsSync9(join11(dir, "chant.config.ts"))) {
6492
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve5(body.dir.trim()) : "";
6493
+ if (!dir || !existsSync12(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
6494
+ if (!existsSync12(join15(dir, "chant.config.ts"))) {
4493
6495
  return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
4494
6496
  }
6497
+ switchServedProject([dir]);
6498
+ return c.json({ ok: true, projectDir: dir });
6499
+ });
6500
+ const switchServedProject = (dirs, env) => {
4495
6501
  addRecent(cfg.projectDir);
4496
- cfg.projectDir = dir;
4497
- cfg.projectDirs = void 0;
4498
- cfg.env = void 0;
4499
- beholdConfig = loadBeholdConfig(dir);
6502
+ cfg.projectDir = dirs[0];
6503
+ cfg.projectDirs = dirs.length > 1 ? dirs : void 0;
6504
+ cfg.env = env;
6505
+ beholdConfig = loadBeholdConfig(dirs[0]);
4500
6506
  tierEnvVar = beholdConfig.tiers?.envVar;
4501
- runner.retarget(dir);
4502
- addRecent(dir);
4503
- cfg.onProjectSwitch?.(dir);
6507
+ runner.retarget(dirs[0]);
6508
+ for (const d of dirs) addRecent(d);
6509
+ cfg.onProjectSwitch?.(dirs[0]);
4504
6510
  broadcaster.emit("changed");
4505
- return c.json({ ok: true, projectDir: dir });
4506
- });
6511
+ };
4507
6512
  app.post("/api/project/reveal", async (c) => {
4508
6513
  const body = await c.req.json().catch(() => ({}));
4509
- const dir = typeof body.dir === "string" && body.dir.trim() ? resolve2(body.dir.trim()) : cfg.projectDir;
6514
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve5(body.dir.trim()) : cfg.projectDir;
4510
6515
  const known = /* @__PURE__ */ new Set([cfg.projectDir, ...cfg.projectDirs ?? [], ...listRecents().map((r) => r.dir)]);
4511
6516
  if (!known.has(dir)) return c.json({ error: "not a served or recent project directory" }, 400);
4512
6517
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
@@ -4517,6 +6522,144 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4517
6522
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
4518
6523
  }
4519
6524
  });
6525
+ const demoRow = (e) => {
6526
+ const missing = missingRequirements(e);
6527
+ const target = demoTargetDir(e);
6528
+ return {
6529
+ name: e.name,
6530
+ description: e.description,
6531
+ requires: e.requires,
6532
+ source: e.source,
6533
+ // #268's consent half: a git entry is cloned from a public repo, so the
6534
+ // button says so (and names the repo) BEFORE anything is fetched.
6535
+ fetches: fetchesFromNetwork(e),
6536
+ ...e.repo ? { repo: e.repo } : {},
6537
+ target,
6538
+ loaded: existsSync12(target),
6539
+ satisfiable: missing.length === 0,
6540
+ // #254: the carve walkthrough runs fine here — it just isn't a project
6541
+ // to switch INTO. Carve mode claims `/api/graph` and `/api/project` at
6542
+ // app creation (see carveRoutes), so a running server cannot become one;
6543
+ // the row stays in the catalog, disabled, saying what to run instead.
6544
+ ...e.serve.carve ? { switchable: false } : {},
6545
+ ...missing.length ? { reason: `needs ${missing.join(", ")} on PATH` } : e.serve.carve ? { reason: `serves a carve report, not a project \u2014 run \`behold demo ${e.name}\`` } : {}
6546
+ };
6547
+ };
6548
+ app.get(
6549
+ "/api/demos",
6550
+ (c) => c.json({
6551
+ demos: loadDemoRegistry(pkgRoot).map(demoRow),
6552
+ ...cfg.previewMode ? { locked: "loading a demo is locked in preview mode" } : {}
6553
+ })
6554
+ );
6555
+ let loadingDemo = null;
6556
+ app.post("/api/demos/open", async (c) => {
6557
+ if (cfg.previewMode) return c.json({ error: "loading a demo is locked in preview mode" }, 403);
6558
+ const body = await c.req.json().catch(() => ({}));
6559
+ const name = typeof body.name === "string" ? body.name.trim() : "";
6560
+ const entry = loadDemoRegistry(pkgRoot).find((e) => e.name === name);
6561
+ if (!entry) {
6562
+ return c.json({ error: `no "${name || "(no name given)"}" in this install's demo catalog` }, 400);
6563
+ }
6564
+ const missing = missingRequirements(entry);
6565
+ if (missing.length) return c.json({ error: `${entry.name} needs ${missing.join(", ")} on PATH` }, 400);
6566
+ if (entry.serve.carve) {
6567
+ return c.json(
6568
+ {
6569
+ error: `${entry.name} serves a carve report, not a chant project \u2014 a running server can't switch into carve mode.`,
6570
+ remedy: `Run \`behold demo ${entry.name}\` in a terminal.`
6571
+ },
6572
+ 400
6573
+ );
6574
+ }
6575
+ if (loadingDemo) return c.json({ error: `${loadingDemo} is still loading` }, 409);
6576
+ loadingDemo = entry.name;
6577
+ try {
6578
+ const target = demoTargetDir(entry);
6579
+ const loaded = await loadDemo(entry, { pkgRoot, target, log: (line) => process.stdout.write(line + "\n") });
6580
+ if (!loaded.ok) return c.json({ error: loaded.error }, 500);
6581
+ const dirs = loaded.serveDirs;
6582
+ if (!existsSync12(join15(dirs[0], "chant.config.ts"))) {
6583
+ return c.json({ error: `${entry.name} loaded to ${target} but ${dirs[0]} has no chant.config.ts` }, 500);
6584
+ }
6585
+ if (entry.serve.local) {
6586
+ if (cfg.emulators?.length) await emulatorDown(cfg.projectDir).catch(() => {
6587
+ });
6588
+ cfg.local = true;
6589
+ cfg.emulators = await bootLocalEmulators(dirs[0], `behold demo ${entry.name} --local`);
6590
+ }
6591
+ switchServedProject(dirs, entry.serve.env);
6592
+ return c.json({ ok: true, demo: entry.name, projectDir: dirs[0], projectDirs: dirs, env: entry.serve.env ?? null });
6593
+ } finally {
6594
+ loadingDemo = null;
6595
+ }
6596
+ });
6597
+ const layoutWriteBlock = () => {
6598
+ if (cfg.previewMode) return "the layout sidecar is read-only in preview mode";
6599
+ if (cfg.carveReport) return "a carve report isn't a project \u2014 there's nowhere to keep a hand layout";
6600
+ if (cfg.layoutWrites === false) return "a static export captures a snapshot \u2014 it doesn't write to the project";
6601
+ return unwritableReason(cfg.projectDir);
6602
+ };
6603
+ app.get("/api/layout", (c) => {
6604
+ const block = layoutWriteBlock();
6605
+ const shared = { path: layoutPath(cfg.projectDir), writable: !block, ...block ? { reason: block } : {} };
6606
+ const raw = new URL(c.req.url).searchParams.get("lens");
6607
+ if (raw === null) return c.json({ ...shared, lenses: readLayoutFile(cfg.projectDir).lenses });
6608
+ const lens = normalizeLens(raw);
6609
+ if (!lens) return c.json({ error: `not a lens key: ${raw}`, code: "bad-layout" }, 400);
6610
+ return c.json({ ...shared, lens, deltas: readLens(cfg.projectDir, lens) });
6611
+ });
6612
+ app.post("/api/layout", async (c) => {
6613
+ const block = layoutWriteBlock();
6614
+ if (block) return c.json({ error: block, code: "read-only" }, 403);
6615
+ if (!(c.req.header("content-type") ?? "").includes("application/json")) {
6616
+ return c.json({ error: "send application/json", code: "bad-layout" }, 415);
6617
+ }
6618
+ const declared = Number(c.req.header("content-length") ?? 0);
6619
+ if (declared > MAX_BODY_BYTES) return c.json({ error: `a layout body is capped at ${MAX_BODY_BYTES} bytes`, code: "too-large" }, 413);
6620
+ const text = await c.req.text().catch(() => "");
6621
+ if (text.length > MAX_BODY_BYTES) return c.json({ error: `a layout body is capped at ${MAX_BODY_BYTES} bytes`, code: "too-large" }, 413);
6622
+ let body;
6623
+ try {
6624
+ body = JSON.parse(text || "null");
6625
+ } catch {
6626
+ return c.json({ error: "body must be JSON", code: "bad-layout" }, 400);
6627
+ }
6628
+ const lens = normalizeLens(body?.lens);
6629
+ if (!lens) return c.json({ error: "body needs a `lens` key (the zoom stop, plus +radial / +stack-<name>)", code: "bad-layout" }, 400);
6630
+ if (!body?.deltas || typeof body.deltas !== "object" || Array.isArray(body.deltas)) {
6631
+ return c.json({ error: "body needs `deltas`: {<node id>: {dx,dy,dw,dh}}", code: "bad-layout" }, 400);
6632
+ }
6633
+ try {
6634
+ const stored = writeLens(cfg.projectDir, lens, body.deltas);
6635
+ return c.json({ ok: true, lens: stored.lens, deltas: stored.deltas, count: Object.keys(stored.deltas).length, path: layoutPath(cfg.projectDir) });
6636
+ } catch (err) {
6637
+ if (err instanceof LayoutTooLarge) return c.json({ error: err.message, code: "too-large" }, 413);
6638
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
6639
+ }
6640
+ });
6641
+ const bakeHandLayout = async (c, next) => {
6642
+ await next();
6643
+ const url = new URL(c.req.url);
6644
+ if (url.searchParams.get("layout") !== "1") return;
6645
+ const res = c.res;
6646
+ if (!res || res.status !== 200 || !(res.headers.get("content-type") ?? "").includes("json")) return;
6647
+ const lens = lensFromQuery(url.searchParams);
6648
+ const deltas = readLens(cfg.projectDir, lens);
6649
+ if (!Object.keys(deltas).length) return;
6650
+ let body;
6651
+ try {
6652
+ body = await res.clone().json();
6653
+ } catch {
6654
+ return;
6655
+ }
6656
+ if (typeof body.svg !== "string") return;
6657
+ const { svg, applied } = applyLayoutToSvg(body.svg, deltas);
6658
+ if (!applied) return;
6659
+ c.res = c.json({ ...body, svg, meta: { ...body.meta ?? {}, layout: { lens, applied } } });
6660
+ };
6661
+ app.use("/api/graph", bakeHandLayout);
6662
+ app.use("/api/overlay", bakeHandLayout);
4520
6663
  app.get(
4521
6664
  "/api",
4522
6665
  (c) => c.json({
@@ -4528,8 +6671,29 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4528
6671
  { method: "GET", path: "/api/project", desc: "project info: dir, recents, environments, tiers, targets, stacks, preview lock" },
4529
6672
  { method: "POST", path: "/api/project/open", desc: "switch the served project: JSON body {dir} (validated; preview-locked)" },
4530
6673
  { method: "POST", path: "/api/project/reveal", desc: "open the OS file manager at a served/recent project dir: JSON body {dir?}" },
4531
- { method: "GET", path: "/api/graph", desc: "the graph {ir, svg, meta} \u2014 params: detail=0..3, components=1, logical=1, env, stack, tier, target, lens, up=1, down=1, radial=1" },
6674
+ { method: "GET", path: "/api/demos", desc: "the bundled demo catalog: [{name, description, requires, satisfiable, reason?, fetches, repo?, target, loaded}]" },
6675
+ {
6676
+ method: "POST",
6677
+ path: "/api/demos/open",
6678
+ desc: "load a demo and serve it: JSON body {name} (a catalog name, never a path) \u2014 copies/clones, installs, runs its setup, then switches (preview-locked)"
6679
+ },
6680
+ { method: "GET", path: "/api/graph", desc: "the graph {ir, svg, meta} \u2014 params: detail=0..3, components=1, logical=1, env, stack, tier, target, lens, up=1, down=1, radial=1, layout=1" },
6681
+ ...cfg.carveReport ? [
6682
+ { method: "GET", path: "/api/carve", desc: "carve mode: the raw `chant carve advise --json` peelability report this server is rendering" },
6683
+ {
6684
+ method: "POST",
6685
+ path: "/api/carve/emit",
6686
+ desc: "carve demo only (#254): run `chant carve emit --state --select <addr>` into the demo copy \u2014 JSON body {select}; answers {artifacts, boundary, lint}"
6687
+ },
6688
+ {
6689
+ method: "POST",
6690
+ path: "/api/carve/bridge",
6691
+ desc: "carve demo only (#254): run `chant carve bridge` (never --apply-rewrites) \u2014 JSON body {select}; answers {runbook, proposals}"
6692
+ }
6693
+ ] : [],
4532
6694
  { method: "GET", path: "/api/overlay", desc: "live drift overlay for ?env= \u2014 same shape/params as /api/graph, plus runtime=1" },
6695
+ { method: "GET", path: "/api/layout", desc: "hand-layout sidecar (.behold/layout.json): ?lens=<key> \u2192 {lens, deltas, writable}; no lens \u2192 every lens" },
6696
+ { method: "POST", path: "/api/layout", desc: "store one lens's deltas: JSON body {lens, deltas: {<node id>: {dx,dy,dw,dh}}} (the only file behold writes in your project)" },
4533
6697
  { method: "GET", path: "/api/diff", desc: "per-node live diff for ?env= \u2014 {env, nodes: {<id>: {observed, diff, health, fieldDrift}}}" },
4534
6698
  { method: "GET", path: "/api/reconcile", desc: "pending-change summary for ?env=" },
4535
6699
  { method: "GET", path: "/api/resources", desc: "component \u2192 declared resources" },
@@ -4621,7 +6785,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4621
6785
  srcCompositeEdgesAttached = 0;
4622
6786
  }
4623
6787
  }
4624
- if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync9(join11(cfg.projectDir, "chant.config.ts"))) {
6788
+ if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync12(join15(cfg.projectDir, "chant.config.ts"))) {
4625
6789
  return c.json(noProjectError(cfg.projectDir), 404);
4626
6790
  }
4627
6791
  const radial = new URL(c.req.url).searchParams.get("radial") === "1";
@@ -4726,10 +6890,10 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4726
6890
  return c.json({ error: "overlay needs an environment \u2014 pick one, or start behold with --env <name>" }, 400);
4727
6891
  }
4728
6892
  const logical = new URL(c.req.url).searchParams.get("logical") === "1";
6893
+ const runtime = new URL(c.req.url).searchParams.get("runtime") === "1";
4729
6894
  try {
4730
6895
  if (cfg.projectDirs && cfg.projectDirs.length > 1) {
4731
- const runtime = new URL(c.req.url).searchParams.get("runtime") === "1";
4732
- const detail = logical ? 3 : query.detail;
6896
+ const detail = logical || runtime ? 3 : query.detail;
4733
6897
  const est = await composeEstateOverlay(cfg.projectDirs, { ...tierTargetOpts(query), detail, env }, reclassifyOverlay);
4734
6898
  if (est.dropped.length === est.total) {
4735
6899
  return c.json({ error: `no project in the estate could be graphed \u2014 ${est.dropped.map((d) => `${d.name}: ${d.reason}`).join("; ")}` }, 500);
@@ -4749,7 +6913,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4749
6913
  const logicalBefore = ir2.nodes.length;
4750
6914
  const { ir: projected, byContainer } = projectTopology(ir2, env, boundContext2, await estateSourceRoots(query));
4751
6915
  const { svg: svg3 } = renderArchitecture(projected, byContainer);
4752
- const note3 = [notesFor("logical", projected, void 0, logicalBefore), coverNote, namespaceMismatchNote(projected.nodes)].filter(Boolean).join(" \xB7 ");
6916
+ const note3 = [
6917
+ notesFor("logical", projected, void 0, logicalBefore),
6918
+ coverNote,
6919
+ namespaceJoinNote(est.joined),
6920
+ namespaceMismatchNote(withoutJoinedMembers(projected.nodes, est.joined))
6921
+ ].filter(Boolean).join(" \xB7 ");
4753
6922
  return c.json({
4754
6923
  ir: projected,
4755
6924
  svg: svg3,
@@ -4758,14 +6927,19 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4758
6927
  });
4759
6928
  }
4760
6929
  const { svg: svg2 } = renderGraph(ir2, { boxes: runtime ? "byContainer" : "byStack" });
4761
- const note2 = [coverNote, namespaceMismatchNote(ir2.nodes), runtime ? notesFor("runtime", ir2) : void 0].filter(Boolean).join(" \xB7 ");
6930
+ const note2 = [
6931
+ coverNote,
6932
+ namespaceJoinNote(est.joined),
6933
+ namespaceMismatchNote(withoutJoinedMembers(ir2.nodes, est.joined)),
6934
+ runtime ? notesFor("runtime", ir2, void 0, void 0, detail) : void 0
6935
+ ].filter(Boolean).join(" \xB7 ");
4762
6936
  return c.json({
4763
6937
  ir: ir2,
4764
6938
  svg: svg2,
4765
6939
  meta: { projectDir: cfg.projectDir, env, mode: "overlay", estate: est.total, ...note2 ? { note: note2 } : {} }
4766
6940
  });
4767
6941
  }
4768
- const opts = { ...query, live: true, overlay: true, env, ...logical ? { detail: 3 } : {} };
6942
+ const opts = { ...query, live: true, overlay: true, env, ...logical || runtime ? { detail: 3 } : {} };
4769
6943
  let ir = reclassifyOverlay(await graphIr(cfg.projectDir, opts));
4770
6944
  const boundContext = await boundK8sContext(env);
4771
6945
  ir = mergeClusterRoot(ir, await clusterRootGraphIr(cfg.projectDir, query), await runningK3dClusters());
@@ -4775,7 +6949,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4775
6949
  applyHelmArtifacts(ir, observed);
4776
6950
  synthesizeHelmReleases(ir, observed, discoverReleaseUnits(cfg.projectDir));
4777
6951
  }
4778
- ir = new URL(c.req.url).searchParams.get("runtime") === "1" ? attachRuntimeContainment(ir) : pruneRuntimeChildren(ir);
6952
+ ir = runtime ? attachRuntimeContainment(ir) : pruneRuntimeChildren(ir);
4779
6953
  if (logical) {
4780
6954
  const logicalBefore = ir.nodes.length;
4781
6955
  const { ir: projected, byContainer } = projectTopology(addK8sDeclaredEdges(addValueMatchEdges(ir)), env, boundContext, [await graphPath(cfg.projectDir, opts), cfg.projectDir]);
@@ -4800,10 +6974,10 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4800
6974
  }
4801
6975
  }
4802
6976
  const { svg } = renderGraph(ir, { boxes: "byContainer", radial: new URL(c.req.url).searchParams.get("radial") === "1" });
4803
- const zoom = new URL(c.req.url).searchParams.get("runtime") === "1" ? "runtime" : query.detail === 1 ? "composites" : query.detail === 3 ? "attributes" : "resources";
6977
+ const zoom = runtime ? "runtime" : query.detail === 1 ? "composites" : query.detail === 3 ? "attributes" : "resources";
4804
6978
  const tierNote = tierMismatchNote(ir, beholdConfig.tiers, query.tier);
4805
6979
  const nsNote = namespaceMismatchNote(ir.nodes);
4806
- const zoomNotes = notesFor(zoom, ir, compositeEdgesAttached);
6980
+ const zoomNotes = notesFor(zoom, ir, compositeEdgesAttached, void 0, opts.detail ?? 2);
4807
6981
  const note = [tierNote, nsNote, zoomNotes].filter(Boolean).join(" \xB7 ");
4808
6982
  return c.json({ ir, svg, meta: { projectDir: cfg.projectDir, env, mode: "overlay", ...note ? { note } : {} } });
4809
6983
  } catch (err) {
@@ -4949,38 +7123,38 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4949
7123
  }
4950
7124
  return c.json({ started: true, component, env });
4951
7125
  });
4952
- const rel = relative(process.cwd(), webRoot) || ".";
7126
+ const rel = relative3(process.cwd(), webRoot) || ".";
4953
7127
  app.use("/*", serveStatic({ root: rel }));
4954
- app.get("/", serveStatic({ path: join11(rel, "index.html") }));
7128
+ app.get("/", serveStatic({ path: join15(rel, "index.html") }));
4955
7129
  return app;
4956
7130
  }
4957
- async function startServer(cfg) {
4958
- if (cfg.local) {
4959
- try {
4960
- const emulators = await emulatorUp(cfg.projectDir);
4961
- cfg.emulators = emulators;
4962
- if (emulators.length === 0) {
4963
- process.stderr.write(
4964
- "behold serve --local: no configured lexicon has a local emulator \u2014 serving without one.\n"
4965
- );
4966
- } else {
4967
- Object.assign(process.env, mergedEnv(emulators));
4968
- for (const e of emulators) {
4969
- process.stdout.write(` local: ${e.lexicon} ${e.name} up on ${e.endpoint}
7131
+ async function bootLocalEmulators(dir, who) {
7132
+ try {
7133
+ const emulators = await emulatorUp(dir);
7134
+ if (emulators.length === 0) {
7135
+ process.stderr.write(`${who}: no configured lexicon has a local emulator \u2014 serving without one.
4970
7136
  `);
4971
- }
4972
- }
4973
- } catch (err) {
4974
- cfg.emulators = [];
4975
- process.stderr.write(
4976
- `behold serve --local: ${err instanceof Error ? err.message : String(err)}
7137
+ return [];
7138
+ }
7139
+ Object.assign(process.env, mergedEnv(emulators));
7140
+ for (const e of emulators) {
7141
+ process.stdout.write(` local: ${e.lexicon} ${e.name} up on ${e.endpoint}
7142
+ `);
7143
+ }
7144
+ return emulators;
7145
+ } catch (err) {
7146
+ process.stderr.write(
7147
+ `${who}: ${err instanceof Error ? err.message : String(err)}
4977
7148
  Serving the source graph without the emulator \u2014 start Docker and restart to enable local deploys.
4978
7149
  `
4979
- );
4980
- }
7150
+ );
7151
+ return [];
4981
7152
  }
7153
+ }
7154
+ async function startServer(cfg) {
7155
+ if (cfg.local) cfg.emulators = await bootLocalEmulators(cfg.projectDir, "behold serve --local");
4982
7156
  for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
4983
- if (existsSync9(join11(dir, "chant.config.ts"))) addRecent(dir);
7157
+ if (existsSync12(join15(dir, "chant.config.ts"))) addRecent(dir);
4984
7158
  }
4985
7159
  const broadcaster = new Broadcaster();
4986
7160
  const frames = new FrameBuffer();
@@ -5022,8 +7196,10 @@ async function startServer(cfg) {
5022
7196
  }
5023
7197
  }
5024
7198
  };
5025
- let stopWatch = watchSource(cfg.projectDir, onEstateChange);
5026
- let stopPoll = cfg.env && cfg.pollSecs ? startDriftPoll({
7199
+ const carve = !!cfg.carveReport;
7200
+ let stopWatch = carve ? () => {
7201
+ } : watchSource(cfg.projectDir, onEstateChange);
7202
+ let stopPoll = !carve && cfg.env && cfg.pollSecs ? startDriftPoll({
5027
7203
  intervalMs: cfg.pollSecs * 1e3,
5028
7204
  query: () => graphIr(cfg.projectDir, { live: true, overlay: true, env: cfg.env }),
5029
7205
  onChange: onPollDrift,
@@ -5041,7 +7217,7 @@ async function startServer(cfg) {
5041
7217
  `);
5042
7218
  void capture();
5043
7219
  };
5044
- void capture();
7220
+ if (!carve) void capture();
5045
7221
  let shuttingDown = false;
5046
7222
  const shutdown = () => {
5047
7223
  if (shuttingDown) return;
@@ -5055,6 +7231,19 @@ async function startServer(cfg) {
5055
7231
  process.on("SIGINT", shutdown);
5056
7232
  process.on("SIGTERM", shutdown);
5057
7233
  const server = serve({ fetch: app.fetch, port: cfg.port }, (info) => {
7234
+ if (carve) {
7235
+ process.stdout.write(
7236
+ `behold \u2192 http://localhost:${info.port}
7237
+ carve report: ${cfg.carveReport}
7238
+ green = carve now, amber = boundary work, grey = leave in Terraform.
7239
+ ` + (cfg.carveDemo ? ` walkthrough: the panel's Carve tab \u2014 advise \u2192 pick \u2192 emit \u2192 bridge \u2192 handoff \u2192 done.
7240
+ Emit and bridge write only into ${cfg.carveDemo.out}; your Terraform is never edited.
7241
+ ` + (cfg.carveDemo.degraded ? ` degraded: ${cfg.carveDemo.degraded}
7242
+ ` : "") : ` Read-only advisory: behold emits nothing and touches no Terraform. Ctrl-C to stop.
7243
+ `)
7244
+ );
7245
+ return;
7246
+ }
5058
7247
  const poll = cfg.env && cfg.pollSecs ? `, polling drift every ${cfg.pollSecs}s` : "";
5059
7248
  const auto = autoSync !== "off" ? ` auto-sync: ${autoSync}` : "";
5060
7249
  const localTag = cfg.emulators && cfg.emulators.length ? ` local: ${cfg.emulators.map((e) => e.name).join(", ")} up (creds-free \u2014 deploys hit the emulator)` : "";
@@ -5085,45 +7274,9 @@ async function startServer(cfg) {
5085
7274
  });
5086
7275
  }
5087
7276
 
5088
- // src/demos.ts
5089
- import { readFileSync as readFileSync10 } from "node:fs";
5090
- import { join as join12 } from "node:path";
5091
- import { spawnSync } from "node:child_process";
5092
- function loadDemoRegistry(pkgRoot) {
5093
- let raw;
5094
- try {
5095
- raw = JSON.parse(readFileSync10(join12(pkgRoot, "demos.json"), "utf8"));
5096
- } catch {
5097
- return [];
5098
- }
5099
- const list3 = raw?.demos;
5100
- if (!Array.isArray(list3)) return [];
5101
- return list3.filter((e) => {
5102
- const d = e;
5103
- if (!d || typeof d.name !== "string" || !d.name || typeof d.description !== "string") return false;
5104
- if (d.source === "bundled") {
5105
- if (typeof d.dir !== "string" || !d.dir) return false;
5106
- } else if (d.source === "git") {
5107
- if (typeof d.repo !== "string" || !d.repo) return false;
5108
- } else {
5109
- return false;
5110
- }
5111
- if (!Array.isArray(d.requires) || d.requires.some((r) => typeof r !== "string")) return false;
5112
- if (!d.serve || typeof d.serve !== "object") return false;
5113
- if (d.serve.dirs !== void 0 && (!Array.isArray(d.serve.dirs) || d.serve.dirs.some((x) => typeof x !== "string") || !d.serve.dirs.length))
5114
- return false;
5115
- return true;
5116
- });
5117
- }
5118
- function missingRequirements(entry) {
5119
- const bins = entry.source === "git" && !entry.requires.includes("git") ? [...entry.requires, "git"] : entry.requires;
5120
- const finder = process.platform === "win32" ? "where" : "which";
5121
- return bins.filter((bin) => spawnSync(finder, [bin], { stdio: "ignore" }).status !== 0);
5122
- }
5123
-
5124
7277
  // src/export.ts
5125
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync11, readdirSync as readdirSync4 } from "node:fs";
5126
- import { join as join13, dirname as dirname5, basename } from "node:path";
7278
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync3, copyFileSync, readFileSync as readFileSync13, readdirSync as readdirSync5 } from "node:fs";
7279
+ import { join as join16, dirname as dirname5, basename } from "node:path";
5127
7280
  import { fileURLToPath as fileURLToPath4 } from "node:url";
5128
7281
  var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
5129
7282
  function canonicalKey(path, params) {
@@ -5131,7 +7284,7 @@ function canonicalKey(path, params) {
5131
7284
  const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial"))).map((k) => `${k}=${params.get(k)}`).join("&");
5132
7285
  return q ? `${path}?${q}` : path;
5133
7286
  }
5134
- function slug(key) {
7287
+ function slug2(key) {
5135
7288
  const base = key.replace(/^\//, "").replace(/[^a-zA-Z0-9=_.-]+/g, "_").slice(0, 120);
5136
7289
  return `${base}.json`;
5137
7290
  }
@@ -5171,7 +7324,7 @@ function captureKeys(axes) {
5171
7324
  return [...keys];
5172
7325
  }
5173
7326
  function webDir() {
5174
- return join13(dirname5(fileURLToPath4(import.meta.url)), "..", "web");
7327
+ return join16(dirname5(fileURLToPath4(import.meta.url)), "..", "web");
5175
7328
  }
5176
7329
  function workerName(project, override) {
5177
7330
  const raw = override ?? `behold-${basename(project)}`;
@@ -5179,19 +7332,19 @@ function workerName(project, override) {
5179
7332
  return name || "behold-export";
5180
7333
  }
5181
7334
  async function runExport(cfg, outDir, opts = {}) {
5182
- const app = createApp(cfg);
7335
+ const app = createApp({ ...cfg, layoutWrites: false });
5183
7336
  const proj = await (await app.request("/api/project")).json();
5184
7337
  const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
5185
- const snapDir = join13(outDir, "snapshots");
5186
- mkdirSync2(snapDir, { recursive: true });
7338
+ const snapDir = join16(outDir, "snapshots");
7339
+ mkdirSync4(snapDir, { recursive: true });
5187
7340
  const keyToFile = {};
5188
7341
  let ok = 0;
5189
7342
  let failed = 0;
5190
7343
  for (const key of captureKeys(axes)) {
5191
- const res = await app.request(key);
7344
+ const res = await app.request(`${key}${key.includes("?") ? "&" : "?"}layout=1`);
5192
7345
  const body = await res.text();
5193
- const file = slug(key);
5194
- writeFileSync2(join13(snapDir, file), body);
7346
+ const file = slug2(key);
7347
+ writeFileSync3(join16(snapDir, file), body);
5195
7348
  keyToFile[key] = `snapshots/${file}`;
5196
7349
  if (res.ok) ok++;
5197
7350
  else failed++;
@@ -5203,21 +7356,21 @@ async function runExport(cfg, outDir, opts = {}) {
5203
7356
  axes,
5204
7357
  keyToFile
5205
7358
  };
5206
- writeFileSync2(join13(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
5207
- const html = readFileSync11(join13(webDir(), "index.html"), "utf8").replace(
7359
+ writeFileSync3(join16(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
7360
+ const html = readFileSync13(join16(webDir(), "index.html"), "utf8").replace(
5208
7361
  /<\/head>/i,
5209
7362
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
5210
7363
  </head>`
5211
7364
  );
5212
- writeFileSync2(join13(outDir, "index.html"), html);
5213
- for (const f of readdirSync4(webDir())) {
7365
+ writeFileSync3(join16(outDir, "index.html"), html);
7366
+ for (const f of readdirSync5(webDir())) {
5214
7367
  if (f === "index.html") continue;
5215
- copyFileSync(join13(webDir(), f), join13(outDir, f));
7368
+ copyFileSync(join16(webDir(), f), join16(outDir, f));
5216
7369
  }
5217
- writeFileSync2(join13(outDir, "README.md"), BUNDLE_README);
7370
+ writeFileSync3(join16(outDir, "README.md"), BUNDLE_README);
5218
7371
  const name = workerName(cfg.projectDir, opts.name);
5219
- writeFileSync2(
5220
- join13(outDir, "wrangler.jsonc"),
7372
+ writeFileSync3(
7373
+ join16(outDir, "wrangler.jsonc"),
5221
7374
  JSON.stringify(
5222
7375
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
5223
7376
  null,
@@ -5265,7 +7418,7 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
5265
7418
  `;
5266
7419
 
5267
7420
  // src/doctor.ts
5268
- import { relative as relative2, resolve as resolve3 } from "node:path";
7421
+ import { relative as relative4, resolve as resolve6 } from "node:path";
5269
7422
  var list2 = (xs) => xs.join(", ");
5270
7423
  var EMPTY_KUBECONFIG = { contexts: /* @__PURE__ */ new Map(), servers: /* @__PURE__ */ new Map() };
5271
7424
  function summarize(xs, max = 4) {
@@ -5273,7 +7426,7 @@ function summarize(xs, max = 4) {
5273
7426
  }
5274
7427
  function labelFor(root, target, estate) {
5275
7428
  if (!estate) return "";
5276
- const rel = relative2(root, target);
7429
+ const rel = relative4(root, target);
5277
7430
  return `${rel || "."}: `;
5278
7431
  }
5279
7432
  function chantCheck(root, targets, estate) {
@@ -5286,7 +7439,7 @@ function chantCheck(root, targets, estate) {
5286
7439
  name: "chant",
5287
7440
  status: "fail",
5288
7441
  detail: `${list2(resolutions.map(describe))} \u2014 behold shells the project's own chant`,
5289
- fix: `Run \`npm install\` in ${list2(missing.map(({ target }) => relative2(root, target) || target))}`
7442
+ fix: `Run \`npm install\` in ${list2(missing.map(({ target }) => relative4(root, target) || target))}`
5290
7443
  };
5291
7444
  }
5292
7445
  const stale = resolutions.filter(({ res }) => !meetsFloor(res.version, floor));
@@ -5343,8 +7496,8 @@ function kubeCheck(lexicons, profiles, envs, kubeconfig) {
5343
7496
  return {
5344
7497
  name: "kube",
5345
7498
  status: "warn",
5346
- detail: "no kubeconfig readable (kubectl absent, or it has no contexts) \u2014 the declared graph still serves",
5347
- fix: "Install kubectl and point KUBECONFIG at your cluster, or run `behold demo k8s` for a throwaway k3d one."
7499
+ detail: "no kubeconfig readable (none on this machine, or it has no contexts) \u2014 the declared graph still serves",
7500
+ fix: "Point KUBECONFIG at your cluster's kubeconfig (or put one at ~/.kube/config), or run `behold demo k8s` for a throwaway k3d one."
5348
7501
  };
5349
7502
  }
5350
7503
  const bound = envs.filter((e) => profiles[e]?.context);
@@ -5414,7 +7567,7 @@ function opsCheck(root, ops, chantSource, estate) {
5414
7567
  return { name: "ops", status: "pass", detail: `${ops.length} committed: ${list2(names)}; ${mcp}` };
5415
7568
  }
5416
7569
  async function diagnose(dir, probes = {}) {
5417
- const root = resolve3(dir);
7570
+ const root = resolve6(dir);
5418
7571
  const shape = detectProjectShape(root);
5419
7572
  const behold = beholdVersion();
5420
7573
  if (shape.kind === "none") {
@@ -5434,16 +7587,16 @@ async function diagnose(dir, probes = {}) {
5434
7587
  };
5435
7588
  }
5436
7589
  const estate = shape.kind === "estate";
5437
- const members = (shape.members ?? []).map((m) => resolve3(root, m));
7590
+ const members = (shape.members ?? []).map((m) => resolve6(root, m));
5438
7591
  const targets = estate ? members : [root];
5439
7592
  const primary = targets[0];
5440
7593
  const projectCheck = estate ? {
5441
7594
  name: "project",
5442
7595
  status: "pass",
5443
7596
  detail: `estate of ${members.length} projects (${shape.membersFrom === "behold-config" ? ".behold.json members" : "npm workspaces"}): ${list2(
5444
- members.map((m) => relative2(root, m))
7597
+ members.map((m) => relative4(root, m))
5445
7598
  )}`
5446
- } : { name: "project", status: "pass", detail: `chant project (${relative2(root, shape.configFile)})` };
7599
+ } : { name: "project", status: "pass", detail: `chant project (${relative4(root, shape.configFile)})` };
5447
7600
  const infos = await Promise.all(targets.map(async (t) => ({ target: t, info: await detectProject(t) })));
5448
7601
  const declared = new Map(infos.map(({ target, info }) => [target, info.lexicons]));
5449
7602
  const lexicons = [...new Set(infos.flatMap(({ info }) => info.lexicons))];
@@ -5488,6 +7641,16 @@ Usage:
5488
7641
  behold preview [project-dir] [--port <n>] [--emulator]
5489
7642
  behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
5490
7643
  behold serve <project-dir\u2026> [--port <n>] [--env <name>] [--poll <secs>] [--local]
7644
+ behold carve <report.json> [--port <n>]
7645
+
7646
+ carve Render a chant Terraform peelability report \u2014 the JSON from
7647
+ \`chant carve advise --from <tf-dir> --json\` \u2014 as a graph. One card
7648
+ per ranked resource, coloured by band: green = carve now, amber =
7649
+ has boundary work, grey = leave in Terraform. Click a card for the
7650
+ score arithmetic behind its rank. Read-only twice over: chant's
7651
+ advisor emits nothing, and behold only draws what it says.
7652
+ \`GET /api/carve\` serves the raw report to agents. behold parses no
7653
+ Terraform and needs no Terraform tooling \u2014 the report is the contract.
5491
7654
 
5492
7655
  doctor Why won't this project serve well? A read-only diagnosis of
5493
7656
  everything behold needs \u2014 the project's kind, its own chant install
@@ -5503,9 +7666,13 @@ Usage:
5503
7666
  clone a public estate. Bare \`behold demo\` is the AWS example \u2014 an
5504
7667
  S3 bucket + policy on a local emulator: blue = declared, click
5505
7668
  Deploy, watch it turn green. \`behold demo k8s\` stands a workload
5506
- up on a throwaway k3d cluster instead. Needs Docker (and per-demo
5507
- tools --list names). Loaded demos land in the panel's recents, so
5508
- switching between them is the Scope tab.
7669
+ up on a throwaway k3d cluster instead. \`behold demo carve\` is the
7670
+ odd one out: no cluster, no Docker, no cloud \u2014 a half-migrated
7671
+ Terraform/chant estate plus the six-step carve walkthrough on the
7672
+ panel's Carve tab. Needs Docker (and per-demo tools --list names).
7673
+ Loaded demos land in the panel's recents, so switching between them
7674
+ is the Scope tab \u2014 which lists this whole catalog too (#268), one
7675
+ click from any served project.
5509
7676
 
5510
7677
  export Capture the live estate into a self-contained, interactive STATIC
5511
7678
  bundle (default ./behold-export) \u2014 every env/tier \xD7 zoom \xD7 radial,
@@ -5526,7 +7693,7 @@ Usage:
5526
7693
  overlay, and rollback act on it).
5527
7694
 
5528
7695
  Options:
5529
- --port <n> Port (default 4600). preview/serve.
7696
+ --port <n> Port (default 4600). preview/serve/carve.
5530
7697
  --env <name> Environment name \u2014 turns on the live drift overlay.
5531
7698
  export/serve.
5532
7699
  --poll <secs> Re-query live drift every <secs> and push updates (needs --env).
@@ -5565,7 +7732,7 @@ Options:
5565
7732
  -h, --help This text.
5566
7733
  -v, --version Print the behold version.
5567
7734
  `;
5568
- async function run3(argv) {
7735
+ async function run2(argv) {
5569
7736
  const [cmd, ...rest] = argv;
5570
7737
  if (!cmd || cmd === "-h" || cmd === "--help") {
5571
7738
  process.stdout.write(USAGE);
@@ -5591,6 +7758,10 @@ async function run3(argv) {
5591
7758
  await runExportCmd(rest);
5592
7759
  return;
5593
7760
  }
7761
+ if (cmd === "carve") {
7762
+ await runCarve(rest);
7763
+ return;
7764
+ }
5594
7765
  if (cmd !== "serve") {
5595
7766
  process.stderr.write(`behold: unknown command '${cmd}'
5596
7767
 
@@ -5646,7 +7817,7 @@ ${USAGE}`);
5646
7817
  process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
5647
7818
  process.exit(2);
5648
7819
  }
5649
- const dirs = projectDirs.map((d) => resolve4(d));
7820
+ const dirs = projectDirs.map((d) => resolve7(d));
5650
7821
  for (const d of dirs) warnIfNotChantProject(d);
5651
7822
  await startServer({
5652
7823
  projectDir: dirs[0],
@@ -5665,7 +7836,7 @@ function warnIfNotChantProject(dir) {
5665
7836
  if (shape.kind === "estate") {
5666
7837
  process.stderr.write(
5667
7838
  `behold: warning \u2014 ${dir} is an estate root, not a chant project itself.
5668
- Serve its members composed: behold serve ${shape.members.map((m) => join14(dir, m)).join(" ")}
7839
+ Serve its members composed: behold serve ${shape.members.map((m) => join17(dir, m)).join(" ")}
5669
7840
  `
5670
7841
  );
5671
7842
  return;
@@ -5676,6 +7847,44 @@ function warnIfNotChantProject(dir) {
5676
7847
  `
5677
7848
  );
5678
7849
  }
7850
+ async function runCarve(rest) {
7851
+ let port = 4600;
7852
+ let fileArg;
7853
+ for (let i = 0; i < rest.length; i++) {
7854
+ const a = rest[i];
7855
+ if (a === "--port") port = Number(rest[++i]);
7856
+ else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
7857
+ else if (!a.startsWith("-")) fileArg = a;
7858
+ else {
7859
+ process.stderr.write(`behold carve: unexpected argument '${a}'
7860
+ `);
7861
+ process.exit(2);
7862
+ }
7863
+ }
7864
+ if (!Number.isFinite(port)) {
7865
+ process.stderr.write("behold carve: --port must be a number\n");
7866
+ process.exit(2);
7867
+ }
7868
+ if (!fileArg) {
7869
+ process.stderr.write(
7870
+ "behold carve: missing <report.json>\n Generate one: chant carve advise --from <terraform-dir> --report report.json\n"
7871
+ );
7872
+ process.exit(2);
7873
+ }
7874
+ const reportPath = resolve7(fileArg);
7875
+ const parsed = readCarveReport(reportPath, (p) => readFileSync14(p, "utf8"));
7876
+ if (!parsed.ok) {
7877
+ process.stderr.write(`behold carve: ${parsed.refusal.error}
7878
+ ${parsed.refusal.remedy}
7879
+ `);
7880
+ process.exit(2);
7881
+ }
7882
+ process.stdout.write(
7883
+ `behold carve \u2014 ${parsed.report.resources.length} resource(s)/module(s) ranked${parsed.report.from ? ` from ${parsed.report.from}` : ""}
7884
+ `
7885
+ );
7886
+ await startServer({ projectDir: dirname6(reportPath), carveReport: reportPath, port });
7887
+ }
5679
7888
  async function runDoctor(rest) {
5680
7889
  let json = false;
5681
7890
  let dirArg;
@@ -5690,8 +7899,8 @@ async function runDoctor(rest) {
5690
7899
  }
5691
7900
  }
5692
7901
  const dir = dirArg ?? ".";
5693
- if (!existsSync10(resolve4(dir))) {
5694
- process.stderr.write(`behold doctor: no such directory: ${resolve4(dir)}
7902
+ if (!existsSync13(resolve7(dir))) {
7903
+ process.stderr.write(`behold doctor: no such directory: ${resolve7(dir)}
5695
7904
  `);
5696
7905
  process.exit(2);
5697
7906
  }
@@ -5700,8 +7909,8 @@ async function runDoctor(rest) {
5700
7909
  if (!report.ok) process.exitCode = 1;
5701
7910
  }
5702
7911
  async function runDemo(rest) {
5703
- const pkgRoot = join14(dirname6(fileURLToPath5(import.meta.url)), "..");
5704
- const registry = loadDemoRegistry(pkgRoot);
7912
+ const pkgRoot2 = join17(dirname6(fileURLToPath5(import.meta.url)), "..");
7913
+ const registry = loadDemoRegistry(pkgRoot2);
5705
7914
  let port = 4600;
5706
7915
  let name;
5707
7916
  let dirArg;
@@ -5752,64 +7961,93 @@ async function runDemo(rest) {
5752
7961
  `);
5753
7962
  process.exit(2);
5754
7963
  }
5755
- const target = resolve4(
5756
- dirArg ?? (entry.name === "writes" && existsSync10("behold-demo") ? "behold-demo" : join14("behold-demos", entry.name))
5757
- );
5758
- if (!existsSync10(target)) {
5759
- if (entry.source === "bundled") {
5760
- const bundled = join14(pkgRoot, entry.dir);
5761
- if (!existsSync10(bundled)) {
5762
- process.stderr.write(`behold demo ${entry.name}: this install has no bundled ${entry.dir}
7964
+ const target = dirArg ? resolve7(dirArg) : demoTargetDir(entry);
7965
+ const loaded = await loadDemo(entry, { pkgRoot: pkgRoot2, target, log: (line) => process.stdout.write(line + "\n") });
7966
+ if (!loaded.ok) {
7967
+ process.stderr.write(`behold demo ${entry.name}: ${loaded.error}
5763
7968
  `);
5764
- process.exit(2);
5765
- }
5766
- process.stdout.write(`behold demo ${entry.name} \u2192 copying to ${target} (it's yours \u2014 edit it)
7969
+ process.exit(1);
7970
+ }
7971
+ if (entry.serve.carve) {
7972
+ await serveCarveDemo(target, entry.serve.carve, port);
7973
+ return;
7974
+ }
7975
+ process.stdout.write(`behold demo ${entry.name} \u2192 serving. Blue = declared; Deploy turns it green.
5767
7976
  `);
5768
- cpSync(bundled, target, {
5769
- recursive: true,
5770
- filter: (src) => !relative3(bundled, src).split(sep).includes("node_modules")
5771
- });
5772
- } else {
5773
- process.stdout.write(`behold demo ${entry.name} \u2192 cloning ${entry.repo} to ${target}
7977
+ const serveArgs = ["serve", ...loaded.serveDirs, "--port", String(port)];
7978
+ if (entry.serve.local) serveArgs.push("--local");
7979
+ if (entry.serve.env) serveArgs.push("--env", entry.serve.env);
7980
+ await run2(serveArgs);
7981
+ }
7982
+ function spawnStep(cmd, args, cwd) {
7983
+ return new Promise((res) => {
7984
+ const child = spawn5(cmd, args, { stdio: "inherit", cwd, shell: process.platform === "win32" });
7985
+ child.on("error", () => res(-1));
7986
+ child.on("close", (code) => res(code ?? 1));
7987
+ });
7988
+ }
7989
+ async function serveCarveDemo(target, carve, port) {
7990
+ const at = (rel) => resolve7(target, rel);
7991
+ const project = at(carve.project);
7992
+ const from = at(carve.from);
7993
+ const state = carve.state ? at(carve.state) : void 0;
7994
+ const committed = at(carve.report);
7995
+ if (existsSync13(join17(project, "package.json")) && !existsSync13(join17(project, "node_modules"))) {
7996
+ process.stdout.write(`behold demo carve \u2192 npm install in ${carve.project}/ (the chant this walkthrough shells)\u2026
5774
7997
  `);
5775
- const r = spawnSync2("git", ["clone", "--depth", "1", entry.repo, target], { stdio: "inherit" });
5776
- if (r.status !== 0) {
5777
- process.stderr.write(`behold demo ${entry.name}: clone failed
7998
+ const code = await spawnStep("npm", ["install"], project);
7999
+ if (code !== 0) {
8000
+ process.stderr.write(`behold demo carve: npm install failed in ${project}
5778
8001
  `);
5779
- process.exit(r.status ?? 1);
5780
- }
8002
+ process.exit(code || 1);
5781
8003
  }
5782
- } else {
5783
- process.stdout.write(`behold demo ${entry.name} \u2192 reusing ${target}
5784
- `);
5785
8004
  }
5786
- if (existsSync10(join14(target, "package.json")) && !existsSync10(join14(target, "node_modules"))) {
5787
- process.stdout.write(`behold demo ${entry.name} \u2192 npm install\u2026
5788
- `);
5789
- const r = spawnSync2("npm", ["install"], { cwd: target, stdio: "inherit", shell: process.platform === "win32" });
5790
- if (r.status !== 0) {
5791
- process.stderr.write(`behold demo: npm install failed in ${target}${r.error ? ` (${r.error.message})` : ""}
5792
- `);
5793
- process.exit(r.status ?? 1);
5794
- }
8005
+ let degraded;
8006
+ if (!existsSync13(join17(target, "node_modules", "@cdktf", "hcl2json"))) {
8007
+ process.stdout.write("behold demo carve \u2192 npm install @cdktf/hcl2json (chant's HCL parser, ~2MB, once)\u2026\n");
8008
+ const code = await spawnStep("npm", ["install", "--no-save", "--no-package-lock", "@cdktf/hcl2json"], target);
8009
+ if (code !== 0) degraded = "couldn't install @cdktf/hcl2json (chant's HCL parser) \u2014 no network?";
8010
+ }
8011
+ const report = at("carve-report.json");
8012
+ if (!degraded) {
8013
+ const bin = resolveChant(project).bin;
8014
+ process.stdout.write("behold demo carve \u2192 chant carve advise (read-only; emits nothing)\u2026\n");
8015
+ const code = await spawnStep(
8016
+ bin,
8017
+ ["carve", "advise", "--from", carve.from, ...carve.state ? ["--state", carve.state] : [], "--report", report],
8018
+ target
8019
+ );
8020
+ if (code !== 0) degraded = `chant carve advise exited ${code}`;
5795
8021
  }
5796
- if (entry.setup) {
5797
- process.stdout.write(`behold demo ${entry.name} \u2192 ${entry.setup}
5798
- `);
5799
- const r = spawnSync2(entry.setup, { cwd: target, stdio: "inherit", shell: true });
5800
- if (r.status !== 0) {
5801
- process.stderr.write(`behold demo ${entry.name}: setup failed (${entry.setup})
5802
- `);
5803
- process.exit(r.status ?? 1);
5804
- }
8022
+ const serving = !degraded && existsSync13(report) ? report : committed;
8023
+ if (degraded) {
8024
+ process.stderr.write(
8025
+ `behold demo carve: ${degraded}
8026
+ Serving the committed report (${carve.report}) instead \u2014 the bands are real, just not regenerated here.
8027
+ `
8028
+ );
5805
8029
  }
5806
- process.stdout.write(`behold demo ${entry.name} \u2192 serving. Blue = declared; Deploy turns it green.
8030
+ if (!existsSync13(serving)) {
8031
+ process.stderr.write(`behold demo carve: no carve report at ${serving}
5807
8032
  `);
5808
- const serveDirs = entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join14(target, d)) : [target];
5809
- const serveArgs = ["serve", ...serveDirs, "--port", String(port)];
5810
- if (entry.serve.local) serveArgs.push("--local");
5811
- if (entry.serve.env) serveArgs.push("--env", entry.serve.env);
5812
- await run3(serveArgs);
8033
+ process.exit(2);
8034
+ }
8035
+ process.stdout.write(
8036
+ "behold demo carve \u2192 serving the walkthrough. Green = carve now; the Carve tab walks the six steps.\n"
8037
+ );
8038
+ await startServer({
8039
+ projectDir: target,
8040
+ carveReport: serving,
8041
+ carveDemo: {
8042
+ root: target,
8043
+ from,
8044
+ ...state ? { state } : {},
8045
+ project,
8046
+ out: at(carve.out),
8047
+ ...degraded ? { degraded: `${degraded} \u2014 showing the committed report shipped with the demo.` } : {}
8048
+ },
8049
+ port
8050
+ });
5813
8051
  }
5814
8052
  function injectEmulatorEnv(env) {
5815
8053
  process.env.LOOM_ENV ??= env ?? "local";
@@ -5833,8 +8071,8 @@ async function runPreview(rest) {
5833
8071
  process.stderr.write("behold preview: --port must be a number\n");
5834
8072
  process.exit(2);
5835
8073
  }
5836
- const projectDir = resolve4(dirArg ?? process.cwd());
5837
- if (!existsSync10(projectDir)) {
8074
+ const projectDir = resolve7(dirArg ?? process.cwd());
8075
+ if (!existsSync13(projectDir)) {
5838
8076
  process.stderr.write(`behold preview: project not found at ${projectDir}
5839
8077
  `);
5840
8078
  process.exit(2);
@@ -5854,26 +8092,26 @@ async function runPreview(rest) {
5854
8092
  await startServer({ projectDir, port, env: "local", previewMode: true });
5855
8093
  }
5856
8094
  async function runExportCmd(rest) {
5857
- let outDir = resolve4("behold-export");
8095
+ let outDir = resolve7("behold-export");
5858
8096
  let env;
5859
8097
  let name;
5860
8098
  let dirArg;
5861
8099
  let emulator = false;
5862
8100
  for (let i = 0; i < rest.length; i++) {
5863
8101
  const a = rest[i];
5864
- if (a === "--out") outDir = resolve4(rest[++i]);
8102
+ if (a === "--out") outDir = resolve7(rest[++i]);
5865
8103
  else if (a === "--env") env = rest[++i];
5866
8104
  else if (a === "--name") name = rest[++i];
5867
8105
  else if (a === "--emulator") emulator = true;
5868
8106
  else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
5869
8107
  else if (!a.startsWith("-")) dirArg = a;
5870
8108
  }
5871
- const projectDir = resolve4(dirArg ?? process.cwd());
8109
+ const projectDir = resolve7(dirArg ?? process.cwd());
5872
8110
  if (emulator) {
5873
8111
  injectEmulatorEnv(env);
5874
8112
  env ??= "local";
5875
8113
  }
5876
- if (!existsSync10(projectDir)) {
8114
+ if (!existsSync13(projectDir)) {
5877
8115
  process.stderr.write(`behold export: project not found at ${projectDir}
5878
8116
  `);
5879
8117
  process.exit(2);
@@ -5890,12 +8128,12 @@ function isMainModule() {
5890
8128
  }
5891
8129
  }
5892
8130
  if (isMainModule()) {
5893
- run3(process.argv.slice(2)).catch((err) => {
8131
+ run2(process.argv.slice(2)).catch((err) => {
5894
8132
  process.stderr.write(`behold: fatal: ${err?.message ?? err}
5895
8133
  `);
5896
8134
  process.exit(3);
5897
8135
  });
5898
8136
  }
5899
8137
  export {
5900
- run3 as run
8138
+ run2 as run
5901
8139
  };