@intentius/behold 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/AGENTS.md +94 -2
  2. package/README.md +133 -2
  3. package/demos.json +25 -1
  4. package/dist/cli.js +3176 -330
  5. package/example-argo-estate/README.md +71 -0
  6. package/example-argo-estate/app-a/chant.config.ts +18 -0
  7. package/example-argo-estate/app-a/manifests/app.yaml +54 -0
  8. package/example-argo-estate/app-a/package.json +13 -0
  9. package/example-argo-estate/app-a/src/app.ts +35 -0
  10. package/example-argo-estate/app-a/tsconfig.json +1 -0
  11. package/example-argo-estate/app-b/chant.config.ts +18 -0
  12. package/example-argo-estate/app-b/manifests/app.yaml +54 -0
  13. package/example-argo-estate/app-b/package.json +13 -0
  14. package/example-argo-estate/app-b/src/app.ts +34 -0
  15. package/example-argo-estate/app-b/tsconfig.json +1 -0
  16. package/example-argo-estate/control-plane/chant.config.ts +29 -0
  17. package/example-argo-estate/control-plane/package.json +13 -0
  18. package/example-argo-estate/control-plane/src/argo.ts +69 -0
  19. package/example-argo-estate/control-plane/tsconfig.json +1 -0
  20. package/example-argo-estate/package-lock.json +1949 -0
  21. package/example-argo-estate/package.json +10 -0
  22. package/example-carve/README.md +194 -0
  23. package/example-carve/app/chant.config.ts +6 -0
  24. package/example-carve/app/package-lock.json +1075 -0
  25. package/example-carve/app/package.json +13 -0
  26. package/example-carve/app/src/carved.ts +30 -0
  27. package/example-carve/app/tsconfig.json +1 -0
  28. package/example-carve/carve-report.json +872 -0
  29. package/example-carve/legacy-tf/cdn.tf +23 -0
  30. package/example-carve/legacy-tf/compute.tf +63 -0
  31. package/example-carve/legacy-tf/floci-override.tf.disabled +61 -0
  32. package/example-carve/legacy-tf/modules/cdn/main.tf +72 -0
  33. package/example-carve/legacy-tf/naming.tf +10 -0
  34. package/example-carve/legacy-tf/network.tf +119 -0
  35. package/example-carve/legacy-tf/observability.tf +18 -0
  36. package/example-carve/legacy-tf/outputs.tf +16 -0
  37. package/example-carve/legacy-tf/storage.tf +33 -0
  38. package/example-carve/legacy-tf/terraform.tfstate +602 -0
  39. package/example-carve/legacy-tf/versions.tf +40 -0
  40. package/example-flux-estate/README.md +22 -12
  41. package/example-flux-estate/app-a/package.json +2 -2
  42. package/example-flux-estate/app-a/src/app.ts +2 -1
  43. package/example-flux-estate/app-b/chant.config.ts +4 -3
  44. package/example-flux-estate/app-b/package.json +2 -2
  45. package/example-flux-estate/app-b/src/app.ts +5 -3
  46. package/example-flux-estate/control-plane/package.json +2 -2
  47. package/example-flux-estate/control-plane/src/flux.ts +10 -2
  48. package/example-flux-estate/package-lock.json +17 -17
  49. package/example-k8s/README.md +16 -2
  50. package/example-k8s/base/deployment.yaml +21 -0
  51. package/example-k8s/base/kustomization.yaml +3 -0
  52. package/example-k8s/base/service.yaml +12 -0
  53. package/example-k8s/chant.config.ts +10 -0
  54. package/example-k8s/overlays/dev/kustomization.yaml +6 -0
  55. package/example-k8s/package-lock.json +18 -18
  56. package/example-k8s/package.json +3 -3
  57. package/example-writes/package-lock.json +14 -14
  58. package/example-writes/package.json +3 -3
  59. package/package.json +9 -6
  60. package/web/app.js +1027 -42
  61. package/web/carve-steps.js +610 -0
  62. package/web/carve-steps.test.js +233 -0
  63. package/web/demos.js +71 -0
  64. package/web/demos.test.js +83 -0
  65. package/web/icons/cncf/argo.svg +1 -0
  66. package/web/icons/cncf/flux.svg +1 -0
  67. package/web/icons/cncf/helm.svg +1 -0
  68. package/web/icons/k8s/c-role.svg +1 -0
  69. package/web/icons/k8s/cm.svg +1 -0
  70. package/web/icons/k8s/crb.svg +1 -0
  71. package/web/icons/k8s/crd.svg +1 -0
  72. package/web/icons/k8s/cronjob.svg +1 -0
  73. package/web/icons/k8s/deploy.svg +1 -0
  74. package/web/icons/k8s/ds.svg +1 -0
  75. package/web/icons/k8s/ep.svg +1 -0
  76. package/web/icons/k8s/group.svg +1 -0
  77. package/web/icons/k8s/hpa.svg +1 -0
  78. package/web/icons/k8s/ing.svg +1 -0
  79. package/web/icons/k8s/job.svg +1 -0
  80. package/web/icons/k8s/limits.svg +1 -0
  81. package/web/icons/k8s/netpol.svg +1 -0
  82. package/web/icons/k8s/ns.svg +1 -0
  83. package/web/icons/k8s/pod.svg +1 -0
  84. package/web/icons/k8s/psp.svg +1 -0
  85. package/web/icons/k8s/pv.svg +1 -0
  86. package/web/icons/k8s/pvc.svg +1 -0
  87. package/web/icons/k8s/quota.svg +1 -0
  88. package/web/icons/k8s/rb.svg +1 -0
  89. package/web/icons/k8s/role.svg +1 -0
  90. package/web/icons/k8s/rs.svg +1 -0
  91. package/web/icons/k8s/sa.svg +1 -0
  92. package/web/icons/k8s/sc.svg +1 -0
  93. package/web/icons/k8s/secret.svg +1 -0
  94. package/web/icons/k8s/sts.svg +1 -0
  95. package/web/icons/k8s/svc.svg +1 -0
  96. package/web/icons/k8s/user.svg +1 -0
  97. package/web/icons/k8s/vol.svg +1 -0
  98. package/web/index.html +285 -85
  99. package/web/json-view.js +334 -0
  100. package/web/json-view.test.js +218 -0
  101. package/web/layout-store.js +278 -0
  102. package/web/layout-store.test.js +388 -0
  103. package/web/panel.js +28 -0
  104. package/web/theme.js +114 -6
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 resolve3, dirname as dirname5, join as join13, relative as relative2, sep } from "node:path";
3
- import { realpathSync, existsSync as existsSync10, cpSync } from "node:fs";
4
- import { spawnSync as spawnSync2 } from "node:child_process";
5
- import { fileURLToPath as fileURLToPath3 } from "node:url";
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";
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;
@@ -87,11 +1117,11 @@ function contextBindsCluster(context, clusterName) {
87
1117
  import { streamSSE } from "hono/streaming";
88
1118
  import { serveStatic } from "@hono/node-server/serve-static";
89
1119
  import { serve } from "@hono/node-server";
90
- import { fileURLToPath } 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 readFileSync7 } from "node:fs";
94
- import { dirname as dirname3, join as join10, relative, resolve as resolve2 } from "node:path";
1120
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
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";
@@ -125,8 +1155,9 @@ function addRecent(dir, now = () => (/* @__PURE__ */ new Date()).toISOString())
125
1155
 
126
1156
  // src/chant.ts
127
1157
  import { spawn } from "node:child_process";
128
- import { existsSync as existsSync3 } from "node:fs";
1158
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
129
1159
  import { createRequire } from "node:module";
1160
+ import { fileURLToPath } from "node:url";
130
1161
  import { dirname as dirname2, join as join3, resolve } from "node:path";
131
1162
 
132
1163
  // src/project.ts
@@ -135,7 +1166,7 @@ import { join as join2 } from "node:path";
135
1166
  import { pathToFileURL } from "node:url";
136
1167
  var CONFIG_NAMES = ["chant.config.ts", "chant.config.mts", "chant.config.js", "chant.config.mjs"];
137
1168
  var BEHOLD_CONFIG_NAME = ".behold.json";
138
- function configPath(projectDir) {
1169
+ function chantConfigPath(projectDir) {
139
1170
  for (const name of CONFIG_NAMES) {
140
1171
  const p = join2(projectDir, name);
141
1172
  if (existsSync2(p)) return p;
@@ -222,7 +1253,7 @@ function parseStringLiteral(content, key) {
222
1253
  return m?.[1];
223
1254
  }
224
1255
  async function detectProject(projectDir) {
225
- const path = configPath(projectDir);
1256
+ const path = chantConfigPath(projectDir);
226
1257
  if (!path) return { environments: [], lexicons: [] };
227
1258
  try {
228
1259
  const mod = await import(pathToFileURL(path).href);
@@ -253,11 +1284,30 @@ function loadBeholdConfig(projectDir) {
253
1284
  try {
254
1285
  const raw = JSON.parse(readFileSync2(path, "utf8"));
255
1286
  const tiers = readTiers(raw);
256
- return tiers ? { tiers } : {};
1287
+ const members = Array.isArray(raw.members) ? raw.members.filter((m) => typeof m === "string" && !!m) : [];
1288
+ return { ...tiers ? { tiers } : {}, ...members.length ? { members } : {} };
257
1289
  } catch {
258
1290
  return {};
259
1291
  }
260
1292
  }
1293
+ function readWorkspaces(projectDir) {
1294
+ try {
1295
+ const raw = JSON.parse(readFileSync2(join2(projectDir, "package.json"), "utf8"));
1296
+ const ws = Array.isArray(raw.workspaces) ? raw.workspaces : raw.workspaces?.packages;
1297
+ return Array.isArray(ws) ? ws.filter((w) => typeof w === "string") : [];
1298
+ } catch {
1299
+ return [];
1300
+ }
1301
+ }
1302
+ function detectProjectShape(projectDir) {
1303
+ const configFile = chantConfigPath(projectDir);
1304
+ if (configFile) return { kind: "project", configFile };
1305
+ const declared = loadBeholdConfig(projectDir).members;
1306
+ const from = declared ? "behold-config" : "workspaces";
1307
+ const members = (declared ?? readWorkspaces(projectDir)).filter((m) => !!chantConfigPath(join2(projectDir, m)));
1308
+ if (members.length) return { kind: "estate", members, membersFrom: from };
1309
+ return { kind: "none" };
1310
+ }
261
1311
 
262
1312
  // node_modules/@intentius/chant/src/yaml.ts
263
1313
  function parseYAML(content) {
@@ -543,21 +1593,21 @@ function graphFlags(opts) {
543
1593
  if (opts.env) flags.push("--env", opts.env);
544
1594
  if (opts.live) flags.push("--live");
545
1595
  if (opts.overlay) flags.push("--overlay");
1596
+ if (opts.namespace) flags.push("--namespace", opts.namespace);
546
1597
  return flags;
547
1598
  }
548
- function chantBinFrom(req) {
1599
+ function packageManifestFrom(req, name) {
549
1600
  let entry;
550
1601
  try {
551
- entry = req.resolve("@intentius/chant");
1602
+ entry = req.resolve(name);
552
1603
  } catch {
553
1604
  return void 0;
554
1605
  }
555
1606
  let dir = dirname2(entry);
556
1607
  for (; ; ) {
557
- const manifest = join3(dir, "package.json");
558
1608
  try {
559
- const pkg = createRequire(import.meta.url)(manifest);
560
- if (pkg.name === "@intentius/chant") return join3(dir, pkg.bin?.chant ?? "bin/chant");
1609
+ const pkg = createRequire(import.meta.url)(join3(dir, "package.json"));
1610
+ if (pkg.name === name) return { root: dir, manifest: pkg };
561
1611
  } catch {
562
1612
  }
563
1613
  const parent = dirname2(dir);
@@ -566,14 +1616,52 @@ function chantBinFrom(req) {
566
1616
  }
567
1617
  return void 0;
568
1618
  }
569
- function chantBin(projectDir) {
1619
+ function chantPackageFrom(req) {
1620
+ const found = packageManifestFrom(req, "@intentius/chant");
1621
+ if (!found) return void 0;
1622
+ const { root, manifest } = found;
1623
+ return { root, bin: join3(root, manifest.bin?.chant ?? "bin/chant"), ...manifest.version ? { version: manifest.version } : {} };
1624
+ }
1625
+ function resolveLexicons(projectDir, lexicons) {
1626
+ const req = createRequire(join3(resolve(projectDir), "noop.js"));
1627
+ return lexicons.map((lexicon) => {
1628
+ const pkg = `@intentius/chant-lexicon-${lexicon}`;
1629
+ const found = packageManifestFrom(req, pkg);
1630
+ return { lexicon, pkg, installed: !!found, ...found?.manifest.version ? { version: found.manifest.version } : {} };
1631
+ });
1632
+ }
1633
+ function resolveChant(projectDir) {
1634
+ const own = chantPackageFrom(createRequire(import.meta.url));
570
1635
  if (projectDir) {
571
- const fromProject = chantBinFrom(createRequire(join3(resolve(projectDir), "noop.js")));
572
- if (fromProject) return fromProject;
1636
+ const fromProject = chantPackageFrom(createRequire(join3(resolve(projectDir), "noop.js")));
1637
+ if (fromProject) return { source: fromProject.root === own?.root ? "behold" : "project", ...fromProject };
573
1638
  }
574
- const own = chantBinFrom(createRequire(import.meta.url));
575
- if (own) return own;
576
- return "chant";
1639
+ if (own) return { source: "behold", ...own };
1640
+ return { source: "path", bin: "chant" };
1641
+ }
1642
+ function chantFloor() {
1643
+ try {
1644
+ const here = dirname2(fileURLToPath(import.meta.url));
1645
+ const pkg = JSON.parse(readFileSync3(join3(here, "..", "package.json"), "utf8"));
1646
+ return pkg.dependencies?.["@intentius/chant"]?.replace(/^[^0-9]*/, "") || void 0;
1647
+ } catch {
1648
+ return void 0;
1649
+ }
1650
+ }
1651
+ function meetsFloor(version, floor) {
1652
+ if (!version || !floor) return true;
1653
+ const parts = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10));
1654
+ const a = parts(version);
1655
+ const b = parts(floor);
1656
+ if (a.some(Number.isNaN) || b.some(Number.isNaN)) return true;
1657
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
1658
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
1659
+ if (d !== 0) return d > 0;
1660
+ }
1661
+ return true;
1662
+ }
1663
+ function chantBin(projectDir) {
1664
+ return resolveChant(projectDir).bin;
577
1665
  }
578
1666
  function runChantRaw(args, projectDir, envOverride) {
579
1667
  return new Promise((resolvePromise, reject) => {
@@ -746,10 +1834,10 @@ function applyArgs(target, env) {
746
1834
  }
747
1835
 
748
1836
  // src/cluster-root.ts
749
- import { execFile as execFile2 } from "node:child_process";
750
- import { promisify as promisify2 } from "node:util";
751
- var run2 = promisify2(execFile2);
752
- 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) {
753
1841
  try {
754
1842
  const out = await exec("k3d", ["cluster", "list", "--no-headers"]);
755
1843
  const clusters = /* @__PURE__ */ new Map();
@@ -765,8 +1853,8 @@ async function runningK3dClusters(exec = defaultExec2) {
765
1853
  return void 0;
766
1854
  }
767
1855
  }
768
- async function defaultExec2(cmd, args) {
769
- 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 });
770
1858
  return stdout;
771
1859
  }
772
1860
  function k3dClusterName(node) {
@@ -798,7 +1886,7 @@ function mergeClusterRoot(ir, clusterIr, running) {
798
1886
  }
799
1887
 
800
1888
  // src/helm-releases.ts
801
- import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3, statSync } from "node:fs";
1889
+ import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4, statSync } from "node:fs";
802
1890
  import { join as join4 } from "node:path";
803
1891
  var RELEASE_KEY = /^release\/([^/]+)\/(.+)$/;
804
1892
  function declaredChartNames(ir) {
@@ -839,7 +1927,7 @@ function discoverReleaseUnits(projectDir) {
839
1927
  for (const file of files) {
840
1928
  let content;
841
1929
  try {
842
- content = readFileSync3(file, "utf8");
1930
+ content = readFileSync4(file, "utf8");
843
1931
  } catch {
844
1932
  continue;
845
1933
  }
@@ -892,7 +1980,7 @@ function synthesizeHelmReleases(ir, observed, owners) {
892
1980
 
893
1981
  // src/gh-run.ts
894
1982
  import { spawn as spawn2 } from "node:child_process";
895
- import { readdirSync as readdirSync2, readFileSync as readFileSync4 } from "node:fs";
1983
+ import { readdirSync as readdirSync2, readFileSync as readFileSync5 } from "node:fs";
896
1984
  import { join as join5 } from "node:path";
897
1985
 
898
1986
  // src/ci-run.ts
@@ -947,19 +2035,19 @@ function finishPipelineProgress(state, exitCode) {
947
2035
  }
948
2036
 
949
2037
  // src/gh-run.ts
950
- var defaultGhExec = (args) => new Promise((resolve4) => {
2038
+ var defaultGhExec = (args) => new Promise((resolve8) => {
951
2039
  let out = "";
952
2040
  let proc;
953
2041
  try {
954
2042
  proc = spawn2("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
955
2043
  } catch {
956
- resolve4({ code: 127, out: "" });
2044
+ resolve8({ code: 127, out: "" });
957
2045
  return;
958
2046
  }
959
2047
  proc.stdout.on("data", (d) => out += d);
960
2048
  proc.stderr.on("data", (d) => out += d);
961
- proc.on("error", () => resolve4({ code: 127, out }));
962
- proc.on("close", (code) => resolve4({ code: code ?? 1, out }));
2049
+ proc.on("error", () => resolve8({ code: 127, out }));
2050
+ proc.on("close", (code) => resolve8({ code: code ?? 1, out }));
963
2051
  });
964
2052
  async function ghReady(exec = defaultGhExec) {
965
2053
  const { code, out } = await exec(["auth", "status"]);
@@ -993,7 +2081,7 @@ function pickWorkflow(projectDir, pipeline) {
993
2081
  for (const f of files) {
994
2082
  let text;
995
2083
  try {
996
- text = readFileSync4(join5(dir, f), "utf8");
2084
+ text = readFileSync5(join5(dir, f), "utf8");
997
2085
  } catch {
998
2086
  continue;
999
2087
  }
@@ -1103,11 +2191,11 @@ function joinCiProgress(ir, state) {
1103
2191
  }
1104
2192
  for (const n of ir.nodes) {
1105
2193
  if (n.kind !== "Component") continue;
1106
- const run4 = byComponent.get(n.id);
1107
- if (!run4) continue;
2194
+ const run3 = byComponent.get(n.id);
2195
+ if (!run3) continue;
1108
2196
  const attrs = n.attrs ??= {};
1109
- attrs.ci = run4.status;
1110
- attrs._ciJob = run4.job;
2197
+ attrs.ci = run3.status;
2198
+ attrs._ciJob = run3.job;
1111
2199
  }
1112
2200
  return ir;
1113
2201
  }
@@ -1272,14 +2360,14 @@ var WORKLOAD_KINDS = /* @__PURE__ */ new Set([
1272
2360
  function rec(v) {
1273
2361
  return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
1274
2362
  }
1275
- function str2(v) {
2363
+ function str(v) {
1276
2364
  return typeof v === "string" && v.length > 0 ? v : void 0;
1277
2365
  }
1278
2366
  function namespaceOf(n) {
1279
- return str2(rec(n.attrs?.metadata)?.namespace) ?? "default";
2367
+ return str(rec(n.attrs?.metadata)?.namespace) ?? "default";
1280
2368
  }
1281
2369
  function nameOf(n) {
1282
- return str2(rec(n.attrs?.metadata)?.name);
2370
+ return str(rec(n.attrs?.metadata)?.name);
1283
2371
  }
1284
2372
  function labelRecord(v) {
1285
2373
  const r = rec(v);
@@ -1309,12 +2397,21 @@ var FLUX_SOURCE_KINDS = /* @__PURE__ */ new Set([
1309
2397
  "K8s::Flux::HelmChart",
1310
2398
  "K8s::Flux::Bucket"
1311
2399
  ]);
1312
- function sourceRefOf(refObj, referrer) {
2400
+ function sourceRefOf(refObj, referrer, defaultKind) {
1313
2401
  const ref = rec(refObj);
1314
- const kind = str2(ref?.kind);
1315
- const name = str2(ref?.name);
2402
+ const kind = str(ref?.kind) ?? defaultKind;
2403
+ const name = str(ref?.name);
1316
2404
  if (!kind || !name) return void 0;
1317
- return { kind, name, namespace: str2(ref?.namespace) ?? namespaceOf(referrer) };
2405
+ return { kind, name, namespace: str(ref?.namespace) ?? namespaceOf(referrer) };
2406
+ }
2407
+ function namedRefOf(refObj, referrer) {
2408
+ const ref = rec(refObj);
2409
+ const name = str(ref?.name);
2410
+ if (!name) return void 0;
2411
+ return { name, namespace: str(ref?.namespace) ?? namespaceOf(referrer) };
2412
+ }
2413
+ function list(v) {
2414
+ return Array.isArray(v) ? v : [];
1318
2415
  }
1319
2416
  function helmReleaseSourceRef(n) {
1320
2417
  return rec(rec(rec(n.attrs?.spec)?.chart)?.spec)?.sourceRef;
@@ -1322,7 +2419,7 @@ function helmReleaseSourceRef(n) {
1322
2419
  function ingressBackendServices(n) {
1323
2420
  const spec = rec(n.attrs?.spec);
1324
2421
  const out = [];
1325
- const backendName = (b) => str2(rec(rec(b)?.service)?.name);
2422
+ const backendName = (b) => str(rec(rec(b)?.service)?.name);
1326
2423
  const fromDefault = backendName(spec?.defaultBackend);
1327
2424
  if (fromDefault) out.push(fromDefault);
1328
2425
  const rules = Array.isArray(spec?.rules) ? spec.rules : [];
@@ -1343,19 +2440,38 @@ function deriveK8sEdges(nodes) {
1343
2440
  if (from !== to) out.push({ from, to, kind: "ref", viaAttr, inferred: true });
1344
2441
  };
1345
2442
  const workloads = k8s.filter((n) => WORKLOAD_KINDS.has(n.kind));
1346
- const servicesByNsName = /* @__PURE__ */ new Map();
1347
- const sourcesByKindNsName = /* @__PURE__ */ new Map();
2443
+ const byKindNsName = /* @__PURE__ */ new Map();
2444
+ const appProjectsByName = /* @__PURE__ */ new Map();
1348
2445
  for (const n of k8s) {
1349
2446
  const name = nameOf(n);
1350
2447
  if (!name) continue;
1351
- if (n.kind === "K8s::Core::Service") servicesByNsName.set(`${namespaceOf(n)}/${name}`, n);
1352
- if (FLUX_SOURCE_KINDS.has(n.kind)) sourcesByKindNsName.set(`${shortKind(n.kind)}/${namespaceOf(n)}/${name}`, n);
2448
+ byKindNsName.set(`${n.kind}\0${namespaceOf(n)}\0${name}`, n);
2449
+ if (n.kind === "K8s::Argo::AppProject") {
2450
+ const same = appProjectsByName.get(name) ?? [];
2451
+ same.push(n);
2452
+ appProjectsByName.set(name, same);
2453
+ }
1353
2454
  }
1354
- const addSourceRef = (n, refObj, viaAttr) => {
1355
- const ref = sourceRefOf(refObj, n);
2455
+ const lookup = (kind, namespace, name) => byKindNsName.get(`${kind}\0${namespace}\0${name}`);
2456
+ const addFluxRef = (n, refObj, viaAttr, allowed, defaultKind) => {
2457
+ const ref = sourceRefOf(refObj, n, defaultKind);
2458
+ if (!ref || ref.name === "*") return;
2459
+ const kind = `K8s::Flux::${ref.kind}`;
2460
+ if (allowed && !allowed.has(kind)) return;
2461
+ const target = lookup(kind, ref.namespace, ref.name);
2462
+ if (target) add(n.id, target.id, viaAttr);
2463
+ };
2464
+ const addSourceRef = (n, refObj, viaAttr, defaultKind) => addFluxRef(n, refObj, viaAttr, FLUX_SOURCE_KINDS, defaultKind);
2465
+ const addNamedRef = (n, refObj, kind, viaAttr) => {
2466
+ const ref = namedRefOf(refObj, n);
1356
2467
  if (!ref) return;
1357
- const source = sourcesByKindNsName.get(`${ref.kind}/${ref.namespace}/${ref.name}`);
1358
- if (source) add(n.id, source.id, viaAttr);
2468
+ const target = lookup(kind, ref.namespace, ref.name);
2469
+ if (target) add(n.id, target.id, viaAttr);
2470
+ };
2471
+ const addProject = (n, project, viaAttr) => {
2472
+ const candidates = project ? appProjectsByName.get(project) ?? [] : [];
2473
+ const target = candidates.length === 1 ? candidates[0] : candidates.find((p) => namespaceOf(p) === namespaceOf(n));
2474
+ if (target) add(n.id, target.id, viaAttr);
1359
2475
  };
1360
2476
  for (const n of k8s) {
1361
2477
  if (n.kind === "K8s::Core::Service") {
@@ -1370,15 +2486,15 @@ function deriveK8sEdges(nodes) {
1370
2486
  }
1371
2487
  if (n.kind === "K8s::Networking::Ingress") {
1372
2488
  for (const svcName of ingressBackendServices(n)) {
1373
- const svc = servicesByNsName.get(`${namespaceOf(n)}/${svcName}`);
2489
+ const svc = lookup("K8s::Core::Service", namespaceOf(n), svcName);
1374
2490
  if (svc) add(n.id, svc.id, "ingress backend");
1375
2491
  }
1376
2492
  continue;
1377
2493
  }
1378
2494
  if (n.kind === "K8s::Autoscaling::HorizontalPodAutoscaler") {
1379
2495
  const ref = rec(rec(n.attrs?.spec)?.scaleTargetRef);
1380
- const refKind = str2(ref?.kind);
1381
- const refName = str2(ref?.name);
2496
+ const refKind = str(ref?.kind);
2497
+ const refName = str(ref?.name);
1382
2498
  if (!refKind || !refName) continue;
1383
2499
  for (const w of workloads) {
1384
2500
  if (namespaceOf(w) !== namespaceOf(n)) continue;
@@ -1388,10 +2504,36 @@ function deriveK8sEdges(nodes) {
1388
2504
  }
1389
2505
  if (n.kind === "K8s::Flux::Kustomization") {
1390
2506
  addSourceRef(n, rec(n.attrs?.spec)?.sourceRef, "sourceRef");
2507
+ for (const dep of list(rec(n.attrs?.spec)?.dependsOn)) {
2508
+ addNamedRef(n, dep, "K8s::Flux::Kustomization", "dependsOn");
2509
+ }
1391
2510
  continue;
1392
2511
  }
1393
2512
  if (n.kind === "K8s::Flux::HelmRelease") {
1394
2513
  addSourceRef(n, helmReleaseSourceRef(n), "chart sourceRef");
2514
+ continue;
2515
+ }
2516
+ if (n.kind === "K8s::Flux::ImagePolicy") {
2517
+ addNamedRef(n, rec(n.attrs?.spec)?.imageRepositoryRef, "K8s::Flux::ImageRepository", "imageRepositoryRef");
2518
+ continue;
2519
+ }
2520
+ if (n.kind === "K8s::Flux::ImageUpdateAutomation") {
2521
+ addSourceRef(n, rec(n.attrs?.spec)?.sourceRef, "sourceRef", "GitRepository");
2522
+ continue;
2523
+ }
2524
+ if (n.kind === "K8s::Flux::Alert") {
2525
+ addNamedRef(n, rec(n.attrs?.spec)?.providerRef, "K8s::Flux::Provider", "providerRef");
2526
+ for (const src of list(rec(n.attrs?.spec)?.eventSources)) {
2527
+ addFluxRef(n, src, "eventSource");
2528
+ }
2529
+ continue;
2530
+ }
2531
+ if (n.kind === "K8s::Argo::Application") {
2532
+ addProject(n, str(rec(n.attrs?.spec)?.project), "project");
2533
+ continue;
2534
+ }
2535
+ if (n.kind === "K8s::Argo::ApplicationSet") {
2536
+ addProject(n, str(rec(rec(rec(n.attrs?.spec)?.template)?.spec)?.project), "template project");
1395
2537
  }
1396
2538
  }
1397
2539
  return out;
@@ -1883,6 +3025,13 @@ function metaStr(node, key) {
1883
3025
  const v = meta?.[key];
1884
3026
  return typeof v === "string" ? v : void 0;
1885
3027
  }
3028
+ function destinationNamespace(n) {
3029
+ if (n.kind !== "K8s::Argo::Application") return void 0;
3030
+ const spec = n.attrs?.spec;
3031
+ const dest = spec?.destination;
3032
+ const ns = dest?.namespace;
3033
+ return typeof ns === "string" && ns.length > 0 ? ns : void 0;
3034
+ }
1886
3035
  function declaredNamespaces(nodes) {
1887
3036
  const out = /* @__PURE__ */ new Set();
1888
3037
  for (const n of nodes) {
@@ -1893,6 +3042,8 @@ function declaredNamespaces(nodes) {
1893
3042
  }
1894
3043
  const ns = metaStr(n, "namespace");
1895
3044
  if (ns) out.add(ns);
3045
+ const dest = destinationNamespace(n);
3046
+ if (dest) out.add(dest);
1896
3047
  }
1897
3048
  return out;
1898
3049
  }
@@ -2223,9 +3374,9 @@ function enrichedRefs(ir, aws, byId) {
2223
3374
  const producersByName = /* @__PURE__ */ new Map();
2224
3375
  for (const ex of ir.exports ?? []) {
2225
3376
  if (!ex.node || !byId.has(ex.node)) continue;
2226
- const list = producersByName.get(ex.name) ?? producersByName.set(ex.name, []).get(ex.name);
2227
- if (isExample(byId.get(ex.node))) list.push(ex.node);
2228
- else list.unshift(ex.node);
3377
+ const list3 = producersByName.get(ex.name) ?? producersByName.set(ex.name, []).get(ex.name);
3378
+ if (isExample(byId.get(ex.node))) list3.push(ex.node);
3379
+ else list3.unshift(ex.node);
2229
3380
  }
2230
3381
  const exportNames = [...producersByName.keys()].sort((a, b) => b.length - a.length);
2231
3382
  const bridge = (paramId, producers) => {
@@ -2586,9 +3737,12 @@ function logicalKept(before, after) {
2586
3737
  if (after > 0 && after * 3 >= before) return void 0;
2587
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)`;
2588
3739
  }
2589
- function edgelessNote(zoom, ir) {
3740
+ function edgelessNote(zoom, ir, detail) {
2590
3741
  if (zoom === "components" || zoom === "logical") return void 0;
2591
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
+ }
2592
3746
  return "no edges \u2014 nothing in this estate references anything else";
2593
3747
  }
2594
3748
  var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
@@ -2607,9 +3761,14 @@ function namespaceMismatchNote(nodes) {
2607
3761
  if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
2608
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`;
2609
3763
  }
2610
- 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) {
2611
3770
  const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
2612
- const notes = [primary, edgelessNote(zoom, ir)].filter((n) => n !== void 0);
3771
+ const notes = [primary, edgelessNote(zoom, ir, detail)].filter((n) => n !== void 0);
2613
3772
  return notes.length ? notes.join(" \xB7 ") : void 0;
2614
3773
  }
2615
3774
  function tierMismatchNote(ir, tiers, currentTier) {
@@ -2713,7 +3872,289 @@ function summarizePlan(plan, byComponent, nonResource) {
2713
3872
  }
2714
3873
 
2715
3874
  // src/render.ts
2716
- import { layoutIr, layoutArchitecture, renderSvg, cardSizes } from "@intentius/pinhole";
3875
+ import { layoutIr, layoutArchitecture, renderSvg, cardSizes, registerPack } from "@intentius/pinhole";
3876
+
3877
+ // src/icon-packs.ts
3878
+ import { readFileSync as readFileSync6 } from "node:fs";
3879
+ import { dirname as dirname3, join as join7 } from "node:path";
3880
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
3881
+ var ICON_ROOT = join7(dirname3(fileURLToPath2(import.meta.url)), "..", "web", "icons");
3882
+ var cache = /* @__PURE__ */ new Map();
3883
+ function unprefix(body) {
3884
+ return body.replace(/\s(?:inkscape|sodipodi):[\w-]+\s*=\s*(["'])[\s\S]*?\1/g, "").replace(/\sxlink:href\s*=/g, " href=");
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
+ }
3900
+ function loadIcon(rel) {
3901
+ const hit = cache.get(rel);
3902
+ if (hit) return hit;
3903
+ const raw = readFileSync6(join7(ICON_ROOT, `${rel}.svg`), "utf8");
3904
+ const open = raw.indexOf("<svg");
3905
+ const openEnd = raw.indexOf(">", open);
3906
+ const close = raw.lastIndexOf("</svg");
3907
+ if (open < 0 || openEnd < 0 || close < 0) throw new Error(`malformed vendored icon: ${rel}.svg`);
3908
+ const viewBox = /\bviewBox\s*=\s*(["'])(.*?)\1/.exec(raw.slice(open, openEnd))?.[2];
3909
+ if (!viewBox) throw new Error(`vendored icon has no viewBox: ${rel}.svg`);
3910
+ const bare = { body: unprefix(raw.slice(openEnd + 1, close).trim()), colored: true, viewBox };
3911
+ const glyph = PLATED.has(rel) ? plate(bare) : bare;
3912
+ cache.set(rel, glyph);
3913
+ return glyph;
3914
+ }
3915
+ var K8S_ICON_BY_KIND = {
3916
+ "K8s::Apiextensions::CustomResourceDefinition": "crd",
3917
+ "K8s::Apps::DaemonSet": "ds",
3918
+ "K8s::Apps::Deployment": "deploy",
3919
+ "K8s::Apps::ReplicaSet": "rs",
3920
+ "K8s::Apps::StatefulSet": "sts",
3921
+ "K8s::Autoscaling::HorizontalPodAutoscaler": "hpa",
3922
+ "K8s::Batch::CronJob": "cronjob",
3923
+ "K8s::Batch::Job": "job",
3924
+ "K8s::Core::ConfigMap": "cm",
3925
+ "K8s::Core::Endpoints": "ep",
3926
+ "K8s::Core::LimitRange": "limits",
3927
+ "K8s::Core::Namespace": "ns",
3928
+ "K8s::Core::PersistentVolume": "pv",
3929
+ "K8s::Core::PersistentVolumeClaim": "pvc",
3930
+ "K8s::Core::Pod": "pod",
3931
+ "K8s::Core::PodTemplate": "pod",
3932
+ "K8s::Core::ReplicationController": "rs",
3933
+ // the pre-ReplicaSet spelling of the same thing
3934
+ "K8s::Core::ResourceQuota": "quota",
3935
+ "K8s::Core::Secret": "secret",
3936
+ "K8s::Core::Service": "svc",
3937
+ "K8s::Core::ServiceAccount": "sa",
3938
+ "K8s::Core::Volume": "vol",
3939
+ "K8s::Networking::Ingress": "ing",
3940
+ "K8s::Networking::IngressClass": "ing",
3941
+ "K8s::Networking::NetworkPolicy": "netpol",
3942
+ "K8s::Rbac::ClusterRole": "c-role",
3943
+ "K8s::Rbac::ClusterRoleBinding": "crb",
3944
+ "K8s::Rbac::Role": "role",
3945
+ "K8s::Rbac::RoleBinding": "rb",
3946
+ "K8s::Storage::StorageClass": "sc"
3947
+ };
3948
+ var K8S_MARK_BY_PREFIX = [
3949
+ ["K8s::Flux::", "cncf/flux"],
3950
+ ["K8s::Argo::", "cncf/argo"]
3951
+ ];
3952
+ var HELM_MARK_KINDS = /* @__PURE__ */ new Set(["Helm::Chart", "Helm::Release"]);
3953
+ function k8sIconFor(kind) {
3954
+ for (const [prefix, rel] of K8S_MARK_BY_PREFIX) {
3955
+ if (kind.startsWith(prefix)) return loadIcon(rel);
3956
+ }
3957
+ const stem = K8S_ICON_BY_KIND[kind];
3958
+ return stem ? loadIcon(`k8s/${stem}`) : void 0;
3959
+ }
3960
+ function helmIconFor(kind) {
3961
+ return HELM_MARK_KINDS.has(kind) ? loadIcon("cncf/helm") : void 0;
3962
+ }
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.tier) terms.push(`- 15x${(b.tier ?? 1) - 1} tier${b.tier}`);
4056
+ if (p.dynamic) terms.push(`- 10 dynamic`);
4057
+ if (p.instances) terms.push(`- 3x${(b.instances ?? 1) - 1} instances`);
4058
+ if (!terms.length) return `100 (no penalties) = ${r.score}`;
4059
+ const raw = 100 + Object.values(p).reduce((s, n) => s + (n ?? 0), 0);
4060
+ const clamped = raw !== r.score ? ` (clamped from ${raw})` : "";
4061
+ return `100 ${terms.join(" ")} = ${r.score}${clamped}`;
4062
+ }
4063
+ function boundaryWorkOf(r) {
4064
+ const b = r.breakdown;
4065
+ if (!b || b.tier === null) return "no known native mapping (unsupported provider/type)";
4066
+ const parts = [];
4067
+ if (b.inbound) parts.push(`${b.inbound} inbound (a data-source patch each)`);
4068
+ if (b.outbound) parts.push(`${b.outbound} outbound (a deferred input each)`);
4069
+ if ((b.tier ?? 1) > 1) parts.push(`tier ${b.tier} map`);
4070
+ if (b.hasDynamic) parts.push("count/for_each/data present");
4071
+ if ((b.instances ?? 1) > 1) parts.push(`${b.instances} instances`);
4072
+ return parts.length ? parts.join(", ") : "clean 1:1 native map, no boundary edges";
4073
+ }
4074
+ function carveCardFields(node) {
4075
+ const score = node.attrs.score;
4076
+ const carve = node.attrs.carve;
4077
+ if (typeof score !== "number") return void 0;
4078
+ return [
4079
+ { label: "score", value: String(score) },
4080
+ ...typeof carve === "string" ? [{ label: "carve", value: carve }] : []
4081
+ ];
4082
+ }
4083
+ function carveReportToIr(report) {
4084
+ const resources = report.resources ?? [];
4085
+ const known = new Set(resources.map((r) => r.address));
4086
+ const nodes = resources.map((r) => {
4087
+ const status = statusForBand(r.band, r.score);
4088
+ const b = r.breakdown ?? {};
4089
+ const edges2 = boundaryEdgesOf(r);
4090
+ const patches = edges2.filter((e) => e.carved === r.address && directionOf(e) === "inbound").map((e) => e.survivor);
4091
+ const inputs = edges2.filter((e) => e.carved === r.address && directionOf(e) === "outbound").map((e) => e.survivor);
4092
+ return {
4093
+ id: r.address,
4094
+ kind: tfTypeOf(r.address, r.kind),
4095
+ lexicon: "terraform",
4096
+ attrs: {
4097
+ // The drift palette's channel — `_`-prefixed, so it paints the card and
4098
+ // stays out of both the card's fields and the inspect pane's list.
4099
+ _status: status,
4100
+ score: r.score,
4101
+ carve: verdictForStatus(status),
4102
+ band: r.band,
4103
+ arithmetic: scoreArithmetic(r),
4104
+ boundaryWork: boundaryWorkOf(r),
4105
+ ...r.mapsTo ? { mapsTo: r.mapsTo } : {},
4106
+ tier: b.tier === null || b.tier === void 0 ? "none" : b.tier,
4107
+ inbound: b.inbound ?? 0,
4108
+ outbound: b.outbound ?? 0,
4109
+ instances: b.instances ?? 1,
4110
+ dynamic: b.hasDynamic ?? false,
4111
+ // The predicted diff, when the report carries the edge lists: who needs
4112
+ // a `data` source the moment this is carved, and what becomes a
4113
+ // deploy-time input. Absent (not empty) when the report has no lists —
4114
+ // "none" and "not reported" are different claims.
4115
+ ...patches.length ? { patchOnCarve: patches.join(", ") } : {},
4116
+ ...inputs.length ? { deferredInputs: inputs.join(", ") } : {}
4117
+ }
4118
+ };
4119
+ });
4120
+ const seen = /* @__PURE__ */ new Set();
4121
+ const edges = [];
4122
+ for (const r of resources) {
4123
+ for (const e of boundaryEdgesOf(r)) {
4124
+ const direction = directionOf(e);
4125
+ const from = direction === "inbound" ? e.survivor : e.carved;
4126
+ const to = direction === "inbound" ? e.carved : e.survivor;
4127
+ if (!known.has(from) || !known.has(to)) continue;
4128
+ const key = `${from}|${to}`;
4129
+ if (seen.has(key)) continue;
4130
+ seen.add(key);
4131
+ edges.push({
4132
+ from,
4133
+ to,
4134
+ kind: "ref",
4135
+ viaAttr: direction,
4136
+ ...e.attrs?.length ? { toAttr: e.attrs.join(", ") } : {}
4137
+ });
4138
+ }
4139
+ }
4140
+ const byStack = {};
4141
+ for (const order of ["good", "warn", "neutral"]) {
4142
+ const members = resources.filter((r) => statusForBand(r.band, r.score) === order).map((r) => r.address);
4143
+ if (members.length) byStack[verdictForStatus(order)] = members;
4144
+ }
4145
+ return { nodes, edges, groups: { byStack } };
4146
+ }
4147
+ function carveNote(report, ir) {
4148
+ const counts = Object.entries(report.bands ?? {}).filter(([, n]) => n > 0).map(([band, n]) => `${n} ${band}`).join(" \xB7 ");
4149
+ const head = `chant carve advisory${report.from ? ` for ${report.from}` : ""}${counts ? ` \u2014 ${counts}` : ""}`;
4150
+ if (ir.edges.length) return `${head}. Read-only: nothing is emitted, patched, or applied.`;
4151
+ 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.`;
4152
+ }
4153
+
4154
+ // src/render.ts
4155
+ registerPack({ lexicon: "k8s", iconFor: k8sIconFor });
4156
+ registerPack({ lexicon: "helm", iconFor: helmIconFor });
4157
+ registerPack({ lexicon: "terraform", iconFor: () => void 0, fields: carveCardFields });
2717
4158
  function renderArchitecture(ir, byContainer, opts = {}) {
2718
4159
  const spread = Math.min(1.5, ir.edges.length / Math.max(ir.nodes.length, 1));
2719
4160
  const layout = layoutArchitecture(ir, byContainer, {
@@ -2744,6 +4185,59 @@ function renderGraph(ir, opts = {}) {
2744
4185
  });
2745
4186
  return { svg };
2746
4187
  }
4188
+ function renderBanded(ir, opts = {}) {
4189
+ const bands = ir.groups.byStack ?? {};
4190
+ const size = footprints(ir);
4191
+ const dims = (id) => size.get(id) ?? { w: NODE_W, h: NODE_H };
4192
+ const ids = ir.nodes.map((n) => n.id);
4193
+ const cellW = Math.max(NODE_W, ...ids.map((id) => dims(id).w));
4194
+ const cellH = Math.max(NODE_H, ...ids.map((id) => dims(id).h));
4195
+ const GAP = 28;
4196
+ const PAD = 24;
4197
+ const TITLE = 34;
4198
+ const BAND_GAP = 26;
4199
+ const cols = Math.max(1, Math.round(Math.sqrt(ids.length * (cellH + GAP) * 2 / (cellW + GAP))));
4200
+ const contentW = cols * cellW + (cols - 1) * GAP;
4201
+ const panelW = contentW + PAD * 2;
4202
+ const claimed = new Set(Object.values(bands).flat());
4203
+ const orphans = ids.filter((id) => !claimed.has(id));
4204
+ const panels = [
4205
+ ...Object.entries(bands).map(([title, members]) => [title, members.filter((id) => size.has(id))]),
4206
+ ...orphans.length ? [["unbanded", orphans]] : []
4207
+ ].filter(([, members]) => members.length > 0);
4208
+ const statusOf = new Map(ir.nodes.map((n) => [n.id, n.attrs?._status]));
4209
+ const placed = [];
4210
+ const boxes = [];
4211
+ let top = 0;
4212
+ for (const [title, members] of panels) {
4213
+ const rows = Math.ceil(members.length / cols);
4214
+ const panelH = TITLE + PAD + rows * cellH + (rows - 1) * GAP + PAD;
4215
+ members.forEach((id, i) => {
4216
+ placed.push({
4217
+ id,
4218
+ x: PAD + i % cols * (cellW + GAP) + cellW / 2,
4219
+ y: top + TITLE + PAD + Math.floor(i / cols) * (cellH + GAP) + cellH / 2
4220
+ });
4221
+ });
4222
+ const statuses = new Set(members.map((id) => statusOf.get(id)));
4223
+ const status = statuses.size === 1 ? [...statuses][0] : void 0;
4224
+ boxes.push({ title, x: panelW / 2, y: top + panelH / 2, w: panelW, h: panelH, ...status ? { status } : {} });
4225
+ top += panelH + BAND_GAP;
4226
+ }
4227
+ const height = Math.max(1, top - BAND_GAP);
4228
+ const layout = {
4229
+ width: panelW,
4230
+ height,
4231
+ nodes: placed.map((p) => ({ id: p.id, x: p.x, y: height - p.y }))
4232
+ };
4233
+ const svg = renderSvg(ir, layout, {
4234
+ fit: true,
4235
+ hideTitle: true,
4236
+ groups: boxes.map((b) => ({ ...b, y: height - b.y })),
4237
+ ...opts.theme ? { theme: opts.theme } : {}
4238
+ });
4239
+ return { svg };
4240
+ }
2747
4241
  var NODE_W = 175;
2748
4242
  var NODE_H = 104;
2749
4243
  function footprints(ir) {
@@ -2908,9 +4402,374 @@ function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
2908
4402
  layout.height = Math.max(...nodes.map((n) => n.y + hOf(n) / 2)) + pad;
2909
4403
  }
2910
4404
 
4405
+ // src/carve-actions.ts
4406
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync8, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
4407
+ import { isAbsolute, join as join9, relative, resolve as resolve2, sep } from "node:path";
4408
+
4409
+ // src/layout.ts
4410
+ import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync7, renameSync, rmSync, writeFileSync as writeFileSync2, accessSync, constants } from "node:fs";
4411
+ import { join as join8 } from "node:path";
4412
+ var LAYOUT_DIR = ".behold";
4413
+ var LAYOUT_FILE = "layout.json";
4414
+ var MAX_BODY_BYTES = 64 * 1024;
4415
+ var MAX_FILE_BYTES = 256 * 1024;
4416
+ var MAX_IDS_PER_LENS = 2e3;
4417
+ var MAX_LENSES = 64;
4418
+ var MAX_ID_LENGTH = 512;
4419
+ var MAX_LENS_LENGTH = 128;
4420
+ var NUM = ["dx", "dy", "dw", "dh"];
4421
+ function slug(s) {
4422
+ return String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
4423
+ }
4424
+ function normalizeLens(raw) {
4425
+ if (typeof raw !== "string" || !raw.trim() || raw.length > MAX_LENS_LENGTH) return null;
4426
+ const parts = raw.split("+").map((p) => slug(p)).filter((p) => p && p !== "unknown");
4427
+ if (!parts.length) return null;
4428
+ const key = parts.join("+");
4429
+ return key === "__proto__" || key === "constructor" || key === "prototype" ? null : key;
4430
+ }
4431
+ function normalizeDeltas(raw) {
4432
+ const out = {};
4433
+ if (!raw || typeof raw !== "object") return out;
4434
+ for (const [id, d] of Object.entries(raw)) {
4435
+ if (!id || id.length > MAX_ID_LENGTH || id === "__proto__" || !d || typeof d !== "object") continue;
4436
+ const clean = {};
4437
+ for (const k of NUM) {
4438
+ const v = Number(d[k]);
4439
+ if (Number.isFinite(v) && v !== 0) clean[k] = v;
4440
+ }
4441
+ if (Object.keys(clean).length) out[id] = clean;
4442
+ if (Object.keys(out).length >= MAX_IDS_PER_LENS) break;
4443
+ }
4444
+ return out;
4445
+ }
4446
+ function lensFromQuery(params) {
4447
+ 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";
4448
+ const stack = params.get("stack");
4449
+ return [slug(zoom), params.get("radial") === "1" ? "radial" : null, stack ? slug(`stack-${stack}`) : null].filter(Boolean).join("+");
4450
+ }
4451
+ function layoutPath(projectDir) {
4452
+ return join8(projectDir, LAYOUT_DIR, LAYOUT_FILE);
4453
+ }
4454
+ function unwritableReason(projectDir) {
4455
+ if (!projectDir || !existsSync6(projectDir)) return `no such project directory: ${projectDir || "(none)"}`;
4456
+ try {
4457
+ accessSync(projectDir, constants.W_OK);
4458
+ } catch {
4459
+ return "the served project directory is read-only";
4460
+ }
4461
+ return null;
4462
+ }
4463
+ function readLayoutFile(projectDir) {
4464
+ const empty = { version: 1, lenses: {} };
4465
+ const file = layoutPath(projectDir);
4466
+ try {
4467
+ if (!existsSync6(file)) return empty;
4468
+ const raw = readFileSync7(file, "utf8");
4469
+ if (raw.length > MAX_FILE_BYTES) return empty;
4470
+ const parsed = JSON.parse(raw);
4471
+ if (!parsed || typeof parsed !== "object" || !parsed.lenses || typeof parsed.lenses !== "object") return empty;
4472
+ const lenses = {};
4473
+ for (const [lens, deltas] of Object.entries(parsed.lenses)) {
4474
+ const key = normalizeLens(lens);
4475
+ if (!key) continue;
4476
+ const clean = normalizeDeltas(deltas);
4477
+ if (Object.keys(clean).length) lenses[key] = clean;
4478
+ if (Object.keys(lenses).length >= MAX_LENSES) break;
4479
+ }
4480
+ return { version: 1, lenses };
4481
+ } catch {
4482
+ return empty;
4483
+ }
4484
+ }
4485
+ function readLens(projectDir, lens) {
4486
+ const key = normalizeLens(lens);
4487
+ if (!key) return {};
4488
+ return readLayoutFile(projectDir).lenses[key] ?? {};
4489
+ }
4490
+ var LayoutTooLarge = class extends Error {
4491
+ };
4492
+ function writeLens(projectDir, lens, deltas) {
4493
+ const key = normalizeLens(lens);
4494
+ if (!key) throw new Error(`not a lens key: ${String(lens)}`);
4495
+ const clean = normalizeDeltas(deltas);
4496
+ const current = readLayoutFile(projectDir);
4497
+ const lenses = { ...current.lenses };
4498
+ if (Object.keys(clean).length) lenses[key] = clean;
4499
+ else delete lenses[key];
4500
+ if (Object.keys(lenses).length > MAX_LENSES) throw new LayoutTooLarge(`a layout sidecar holds at most ${MAX_LENSES} lenses`);
4501
+ const file = layoutPath(projectDir);
4502
+ if (!Object.keys(lenses).length) {
4503
+ rmSync(file, { force: true });
4504
+ return { lens: key, deltas: clean, bytes: 0 };
4505
+ }
4506
+ const body = JSON.stringify({ version: 1, lenses }, null, 2) + "\n";
4507
+ if (body.length > MAX_FILE_BYTES) throw new LayoutTooLarge(`a layout sidecar is capped at ${MAX_FILE_BYTES} bytes`);
4508
+ mkdirSync2(join8(projectDir, LAYOUT_DIR), { recursive: true });
4509
+ const tmp = `${file}.tmp`;
4510
+ writeFileSync2(tmp, body, "utf8");
4511
+ renameSync(tmp, file);
4512
+ return { lens: key, deltas: clean, bytes: body.length };
4513
+ }
4514
+ function nodeTransform(base, d) {
4515
+ const dx = d.dx || 0;
4516
+ const dy = d.dy || 0;
4517
+ if (!dx && !dy) return base;
4518
+ return `translate(${dx}, ${dy}) ${base}`.trim();
4519
+ }
4520
+ function pathAnchors(d) {
4521
+ const n = String(d || "").match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi);
4522
+ if (!n || n.length < 4) return null;
4523
+ return { sx: +n[0], sy: +n[1], ex: +n[n.length - 2], ey: +n[n.length - 1] };
4524
+ }
4525
+ function straightEdge(anchors, from, to) {
4526
+ const { sx, sy, ex, ey } = anchors;
4527
+ return `M ${sx + (from && from.dx || 0)} ${sy + (from && from.dy || 0)} L ${ex + (to && to.dx || 0)} ${ey + (to && to.dy || 0)}`;
4528
+ }
4529
+ function unescapeAttr(v) {
4530
+ return v.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
4531
+ }
4532
+ function attrOf(tag, name) {
4533
+ const m = new RegExp(`\\b${name}="([^"]*)"`).exec(tag);
4534
+ return m ? unescapeAttr(m[1]) : null;
4535
+ }
4536
+ function groupEnd(svg, from) {
4537
+ const tok = /<g\b|<\/g\s*>/g;
4538
+ tok.lastIndex = from;
4539
+ let depth = 1;
4540
+ let m;
4541
+ while (m = tok.exec(svg)) {
4542
+ if (m[0][1] === "/") {
4543
+ depth--;
4544
+ if (!depth) return m.index;
4545
+ } else depth++;
4546
+ }
4547
+ return svg.length;
4548
+ }
4549
+ function applyLayoutToSvg(svg, deltas) {
4550
+ const moved = {};
4551
+ for (const [id, d] of Object.entries(normalizeDeltas(deltas))) if (d.dx || d.dy) moved[id] = d;
4552
+ if (!Object.keys(moved).length || typeof svg !== "string" || !svg) return { svg, applied: 0 };
4553
+ const placed = /* @__PURE__ */ new Set();
4554
+ let out = svg.replace(/<g\b([^>]*?)(\/?)>/g, (tag, attrs, selfClose) => {
4555
+ const id = attrOf(attrs, "data-node-id");
4556
+ const d = id ? moved[id] : void 0;
4557
+ if (!id || !d) return tag;
4558
+ placed.add(id);
4559
+ const base = /\btransform="([^"]*)"/.exec(attrs);
4560
+ const next = nodeTransform(base ? base[1] : "", d);
4561
+ if (base) return `<g${attrs.replace(base[0], () => `transform="${next}"`)}${selfClose}>`;
4562
+ return `<g${attrs}${/\s$/.test(attrs) ? "" : " "}transform="${next}"${selfClose}>`;
4563
+ });
4564
+ out = reanchorEdges(out, moved);
4565
+ return { svg: out, applied: placed.size };
4566
+ }
4567
+ function reanchorEdges(svg, moved) {
4568
+ const open = /<g\b([^>]*\bdata-edge-from="[^"]*"[^>]*?)(\/?)>/g;
4569
+ let out = "";
4570
+ let cursor = 0;
4571
+ let m;
4572
+ while (m = open.exec(svg)) {
4573
+ const bodyStart = m.index + m[0].length;
4574
+ if (m[2] === "/") continue;
4575
+ const bodyEnd = groupEnd(svg, bodyStart);
4576
+ open.lastIndex = bodyEnd;
4577
+ const from = moved[attrOf(m[1], "data-edge-from") ?? ""];
4578
+ const to = moved[attrOf(m[1], "data-edge-to") ?? ""];
4579
+ if (!from && !to) continue;
4580
+ const body = svg.slice(bodyStart, bodyEnd);
4581
+ const first = /<path\b[^>]*\bd="([^"]*)"/.exec(body);
4582
+ const anchors = first ? pathAnchors(first[1]) : null;
4583
+ if (!anchors) continue;
4584
+ const d = straightEdge(anchors, from, to);
4585
+ out += svg.slice(cursor, bodyStart) + body.replace(/(<path\b[^>]*\bd=")([^"]*)(")/g, (_t, a, _d, z) => a + d + z);
4586
+ cursor = bodyEnd;
4587
+ }
4588
+ return out + svg.slice(cursor);
4589
+ }
4590
+
4591
+ // src/carve-actions.ts
4592
+ var MAX_ARTIFACT_BYTES = 64 * 1024;
4593
+ var MAX_ARTIFACTS = 24;
4594
+ var MAX_OUTPUT_BYTES = 32 * 1024;
4595
+ var refuse2 = (code, error, remedy) => ({
4596
+ ok: false,
4597
+ refusal: { error, code, remedy }
4598
+ });
4599
+ var BUILD_CAVEAT = 'The gate shown is `chant lint`, not `chant build`. `carve emit` folds the bucket\'s versioning and public-access-block sub-resources into the carve set but does not yet carry them as native props, so `chant build` fails two AWS policy rules on source the advisor scored 88 (chant#1637). example-carve/README.md, "Known rough edge in emit", has the full statement.';
4600
+ function insideDemo(root, p) {
4601
+ const rel = relative(resolve2(root), resolve2(p));
4602
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
4603
+ }
4604
+ function selectFromReport(report, raw) {
4605
+ if (typeof raw !== "string" || !raw || raw.length > 512) return null;
4606
+ return (report?.resources ?? []).some((r) => r.address === raw) ? raw : null;
4607
+ }
4608
+ function carveWriteBlock(demo) {
4609
+ if (!demo) {
4610
+ return "this server isn't running a carve demo \u2014 the carve actions only exist inside a `behold demo carve` copy";
4611
+ }
4612
+ if (!existsSync7(demo.from)) return `the demo copy has no Terraform estate at ${demo.from}`;
4613
+ if (!insideDemo(demo.root, demo.out)) return "the carve output directory is outside the demo copy";
4614
+ return unwritableReason(demo.root);
4615
+ }
4616
+ function readArtifacts(demo, dir, filter) {
4617
+ const out = [];
4618
+ const walk = (d, depth) => {
4619
+ if (out.length >= MAX_ARTIFACTS || depth > 2) return;
4620
+ let entries;
4621
+ try {
4622
+ entries = readdirSync3(d).sort();
4623
+ } catch {
4624
+ return;
4625
+ }
4626
+ for (const name of entries) {
4627
+ if (out.length >= MAX_ARTIFACTS) return;
4628
+ if (name === "node_modules" || name.startsWith(".")) continue;
4629
+ const full = join9(d, name);
4630
+ let st;
4631
+ try {
4632
+ st = statSync2(full);
4633
+ } catch {
4634
+ continue;
4635
+ }
4636
+ if (st.isDirectory()) {
4637
+ walk(full, depth + 1);
4638
+ continue;
4639
+ }
4640
+ const rel = relative(demo.root, full).split(sep).join("/");
4641
+ if (!filter(rel)) continue;
4642
+ let text = "";
4643
+ try {
4644
+ text = readFileSync8(full, "utf8");
4645
+ } catch {
4646
+ continue;
4647
+ }
4648
+ const truncated = text.length > MAX_ARTIFACT_BYTES;
4649
+ out.push({
4650
+ path: rel,
4651
+ kind: (name.split(".").pop() ?? "").toLowerCase(),
4652
+ bytes: st.size,
4653
+ text: truncated ? text.slice(0, MAX_ARTIFACT_BYTES) : text,
4654
+ truncated
4655
+ });
4656
+ }
4657
+ };
4658
+ walk(dir, 0);
4659
+ return out;
4660
+ }
4661
+ function carveSlug(select) {
4662
+ return select.replace(/[^A-Za-z0-9_]+/g, "-");
4663
+ }
4664
+ var clip = (s) => s.length > MAX_OUTPUT_BYTES ? s.slice(0, MAX_OUTPUT_BYTES) + "\n\u2026 (truncated)" : s;
4665
+ function shortenIn(arg, root) {
4666
+ return arg.startsWith(root + sep) ? arg.slice(root.length + 1) : arg === root ? "." : arg;
4667
+ }
4668
+ function merge(r, root) {
4669
+ const joined = stripAnsi([r.stdout, r.stderr].filter((s) => s.trim()).join("\n").trim());
4670
+ return clip(joined.split(root + sep).join("").split(root).join("."));
4671
+ }
4672
+ async function runCarveEmit(demo, select) {
4673
+ const block = carveWriteBlock(demo);
4674
+ if (block) return refuse2("read-only", block, "Start the walkthrough with `behold demo carve`.");
4675
+ mkdirSync3(demo.out, { recursive: true });
4676
+ const reportFile = join9(demo.out, `${carveSlug(select)}-boundary.json`);
4677
+ const args = [
4678
+ "carve",
4679
+ "emit",
4680
+ "--from",
4681
+ demo.from,
4682
+ ...demo.state ? ["--state", demo.state] : [],
4683
+ "--select",
4684
+ select,
4685
+ "--output",
4686
+ demo.out,
4687
+ "--report",
4688
+ reportFile
4689
+ ];
4690
+ const run3 = await runChantRaw(args, demo.project).catch((err) => ({
4691
+ code: 127,
4692
+ stdout: "",
4693
+ stderr: err instanceof Error ? err.message : String(err)
4694
+ }));
4695
+ const output = merge(run3, demo.root);
4696
+ if (run3.code !== 0) {
4697
+ return refuse2(
4698
+ "carve-action",
4699
+ `chant carve emit exited ${run3.code}: ${output || "(no output)"}`,
4700
+ "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`."
4701
+ );
4702
+ }
4703
+ let boundary = null;
4704
+ try {
4705
+ boundary = JSON.parse(readFileSync8(reportFile, "utf8"));
4706
+ } catch {
4707
+ }
4708
+ const lintPath = relative(demo.project, join9(demo.out, "src")).split(sep).join("/");
4709
+ const lintArgs = ["lint", lintPath];
4710
+ const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
4711
+ code: 127,
4712
+ stdout: "",
4713
+ stderr: err instanceof Error ? err.message : String(err)
4714
+ }));
4715
+ return {
4716
+ ok: true,
4717
+ select,
4718
+ command: `chant ${args.map((a) => shortenIn(a, demo.root)).join(" ")}`,
4719
+ output,
4720
+ // Emitted source first, scaffolding after: `src/assets.ts` is the thing the
4721
+ // step is about, and a package.json sorting above it buries the answer.
4722
+ artifacts: readArtifacts(demo, demo.out, (rel) => /\.(ts|json)$/.test(rel) && !rel.endsWith("tsconfig.json")).sort(
4723
+ (a, b) => Number(!/\/src\//.test(a.path)) - Number(!/\/src\//.test(b.path))
4724
+ ),
4725
+ boundary,
4726
+ lint: { ok: lint.code === 0, code: lint.code, command: `chant ${lintArgs.join(" ")}`, output: merge(lint, demo.root) },
4727
+ buildCaveat: BUILD_CAVEAT
4728
+ };
4729
+ }
4730
+ async function runCarveBridge(demo, select) {
4731
+ const block = carveWriteBlock(demo);
4732
+ if (block) return refuse2("read-only", block, "Start the walkthrough with `behold demo carve`.");
4733
+ mkdirSync3(demo.out, { recursive: true });
4734
+ const args = [
4735
+ "carve",
4736
+ "bridge",
4737
+ "--from",
4738
+ demo.from,
4739
+ ...demo.state ? ["--state", demo.state] : [],
4740
+ "--select",
4741
+ select,
4742
+ "--output",
4743
+ demo.out
4744
+ ];
4745
+ const run3 = await runChantRaw(args, demo.project).catch((err) => ({
4746
+ code: 127,
4747
+ stdout: "",
4748
+ stderr: err instanceof Error ? err.message : String(err)
4749
+ }));
4750
+ const output = merge(run3, demo.root);
4751
+ if (run3.code !== 0) {
4752
+ return refuse2(
4753
+ "carve-action",
4754
+ `chant carve bridge exited ${run3.code}: ${output || "(no output)"}`,
4755
+ "Run the Emit step first \u2014 bridge reads the carve manifest emit leaves in the output directory."
4756
+ );
4757
+ }
4758
+ const slug3 = carveSlug(select);
4759
+ const all = readArtifacts(demo, demo.out, (rel) => /\.(tf|md|patch)$/.test(rel));
4760
+ return {
4761
+ ok: true,
4762
+ select,
4763
+ command: `chant ${args.map((a) => shortenIn(a, demo.root)).join(" ")}`,
4764
+ output,
4765
+ runbook: all.find((a) => a.path.endsWith(`${slug3}-runbook.md`)) ?? null,
4766
+ proposals: all.filter((a) => !a.path.endsWith(`${slug3}-runbook.md`))
4767
+ };
4768
+ }
4769
+
2911
4770
  // src/ops.ts
2912
- import { readdirSync as readdirSync3, readFileSync as readFileSync5, existsSync as existsSync6 } from "node:fs";
2913
- import { join as join7 } from "node:path";
4771
+ import { readdirSync as readdirSync4, readFileSync as readFileSync9, existsSync as existsSync8 } from "node:fs";
4772
+ import { join as join10 } from "node:path";
2914
4773
  var APPLY_TARGET_LEXICON = {
2915
4774
  cloudformation: "aws",
2916
4775
  kubectl: "k8s",
@@ -2932,11 +4791,11 @@ function discoverOps(projectDir) {
2932
4791
  const seen = /* @__PURE__ */ new Set();
2933
4792
  const out = [];
2934
4793
  for (const sub of ["ops", "src", "."]) {
2935
- const dir = join7(projectDir, sub);
2936
- if (!existsSync6(dir)) continue;
2937
- for (const f of readdirSync3(dir)) {
4794
+ const dir = join10(projectDir, sub);
4795
+ if (!existsSync8(dir)) continue;
4796
+ for (const f of readdirSync4(dir)) {
2938
4797
  if (!f.endsWith(".op.ts")) continue;
2939
- const content = readFileSync5(join7(dir, f), "utf8");
4798
+ const content = readFileSync9(join10(dir, f), "utf8");
2940
4799
  const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
2941
4800
  if (!name || seen.has(name)) continue;
2942
4801
  seen.add(name);
@@ -3049,6 +4908,133 @@ function classifyHealth(status) {
3049
4908
  if (HEALTHY.test(text)) return "healthy";
3050
4909
  return "unknown";
3051
4910
  }
4911
+ function rec2(v) {
4912
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
4913
+ }
4914
+ function str2(v) {
4915
+ return typeof v === "string" && v.length > 0 ? v : void 0;
4916
+ }
4917
+ function statusTree(o) {
4918
+ const attrs = rec2(o.attributes);
4919
+ if (!attrs) return void 0;
4920
+ return rec2(attrs.status) ?? attrs;
4921
+ }
4922
+ var ARGO_HEALTH = {
4923
+ Healthy: "healthy",
4924
+ Degraded: "degraded",
4925
+ Progressing: "progressing",
4926
+ Suspended: "progressing",
4927
+ Missing: "progressing",
4928
+ Unknown: "unknown"
4929
+ };
4930
+ var ARGO_SYNC = {
4931
+ Synced: "healthy",
4932
+ OutOfSync: "progressing",
4933
+ Unknown: "unknown"
4934
+ };
4935
+ var ARGO_ERROR_CONDITION = /Error$/;
4936
+ function argoErrorMessage(tree) {
4937
+ const conditions = tree?.conditions;
4938
+ if (!Array.isArray(conditions)) return void 0;
4939
+ for (const c of conditions) {
4940
+ const type = str2(rec2(c)?.type);
4941
+ if (!type || !ARGO_ERROR_CONDITION.test(type)) continue;
4942
+ const message = str2(rec2(c)?.message);
4943
+ if (message) return message;
4944
+ }
4945
+ return void 0;
4946
+ }
4947
+ function argoVerdict(o) {
4948
+ if (o.type !== "K8s::Argo::Application") return void 0;
4949
+ const tree = statusTree(o);
4950
+ let health = str2(rec2(tree?.health)?.status);
4951
+ let sync = str2(rec2(tree?.sync)?.status);
4952
+ if (!health && !sync) {
4953
+ const word = str2(o.status);
4954
+ if (word === "READY") {
4955
+ health = "Healthy";
4956
+ sync = "Synced";
4957
+ } else if (word && word in ARGO_SYNC) {
4958
+ sync = word;
4959
+ health = "Healthy";
4960
+ } else if (word && word in ARGO_HEALTH) health = word;
4961
+ }
4962
+ const verdicts = [health ? ARGO_HEALTH[health] : void 0, sync ? ARGO_SYNC[sync] : void 0].filter(
4963
+ (v) => v !== void 0
4964
+ );
4965
+ if (verdicts.length === 0) return void 0;
4966
+ const pair = [health ? `health=${health}` : void 0, sync ? `sync=${sync}` : void 0].filter(Boolean).join(", ");
4967
+ const message = argoErrorMessage(tree);
4968
+ const detail = message ? `${pair}: ${message}` : pair;
4969
+ for (const rung of ["degraded", "progressing", "unknown"]) {
4970
+ if (verdicts.includes(rung)) return { health: rung, detail };
4971
+ }
4972
+ return { health: "healthy", detail };
4973
+ }
4974
+ var FLUX_PENDING_REASON = /^(progressing|reconciling)/i;
4975
+ function readyFromConditions(tree) {
4976
+ const conditions = tree?.conditions;
4977
+ if (!Array.isArray(conditions)) return void 0;
4978
+ for (const c of conditions) {
4979
+ const cond = rec2(c);
4980
+ if (!cond || cond.type !== "Ready") continue;
4981
+ const status = str2(cond.status);
4982
+ if (status !== "True" && status !== "False" && status !== "Unknown") continue;
4983
+ return { status, reason: str2(cond.reason) };
4984
+ }
4985
+ return void 0;
4986
+ }
4987
+ function readyFromChantConditions(o) {
4988
+ const conditions = rec2(o.attributes)?.conditions;
4989
+ if (!Array.isArray(conditions)) return void 0;
4990
+ for (const c of conditions) {
4991
+ if (typeof c !== "string") continue;
4992
+ const m = /^Ready(?:=([^:]*))?(?::|$)/.exec(c.trim());
4993
+ if (!m) continue;
4994
+ const reason = m[1]?.trim() || void 0;
4995
+ return { status: reason && FLUX_PENDING_REASON.test(reason) ? "Unknown" : "False", reason };
4996
+ }
4997
+ return void 0;
4998
+ }
4999
+ function readyFromStatusWord(o) {
5000
+ const word = str2(o.status);
5001
+ if (!word || word === "PRESENT") return void 0;
5002
+ if (word === "READY") return { status: "True" };
5003
+ if (word === "NOT-READY") return { status: "False" };
5004
+ return { status: FLUX_PENDING_REASON.test(word) ? "Unknown" : "False", reason: word };
5005
+ }
5006
+ function describeReady(ready) {
5007
+ return `Ready=${ready.status}${ready.reason ? ` (${ready.reason})` : ""}`;
5008
+ }
5009
+ function revisionVerdict(tree) {
5010
+ const applied = str2(tree?.lastAppliedRevision);
5011
+ const attempted = str2(tree?.lastAttemptedRevision);
5012
+ if (!applied || !attempted || applied === attempted) return void 0;
5013
+ return { health: "progressing", detail: `applied ${applied}, attempted ${attempted}` };
5014
+ }
5015
+ var FLUX_TYPE_PREFIX = "K8s::Flux::";
5016
+ function fluxVerdict(o) {
5017
+ if (!o.type?.startsWith(FLUX_TYPE_PREFIX)) return void 0;
5018
+ const tree = statusTree(o);
5019
+ const ready = readyFromConditions(tree) ?? readyFromChantConditions(o) ?? readyFromStatusWord(o);
5020
+ if (ready?.status === "False") return { health: "degraded", detail: describeReady(ready) };
5021
+ if (ready?.status === "Unknown") return { health: "progressing", detail: describeReady(ready) };
5022
+ if (ready?.status === "True") return revisionVerdict(tree) ?? { health: "healthy", detail: describeReady(ready) };
5023
+ const conditions = tree?.conditions;
5024
+ const reconciling = Array.isArray(conditions) && conditions.some((c) => {
5025
+ const cond = rec2(c);
5026
+ return cond?.type === "Reconciling" && cond.status === "True";
5027
+ });
5028
+ if (reconciling) return { health: "progressing", detail: "Reconciling=True" };
5029
+ if (!str2(o.status) || o.status === "PRESENT") {
5030
+ return { health: "progressing", detail: "no Ready condition yet" };
5031
+ }
5032
+ return void 0;
5033
+ }
5034
+ function classifyObservedHealth(observed) {
5035
+ if (!observed) return { health: "unknown" };
5036
+ return argoVerdict(observed) ?? fluxVerdict(observed) ?? { health: classifyHealth(observed.status) };
5037
+ }
3052
5038
 
3053
5039
  // src/apply.ts
3054
5040
  var PROGRESS_EVENT_TYPES = /* @__PURE__ */ new Set([
@@ -3335,23 +5321,23 @@ var OpRunner = class {
3335
5321
 
3336
5322
  // src/substrates.ts
3337
5323
  import { spawn as spawn3 } from "node:child_process";
3338
- import { existsSync as existsSync7, readFileSync as readFileSync6 } from "node:fs";
3339
- import { join as join8 } from "node:path";
5324
+ import { existsSync as existsSync9, readFileSync as readFileSync10 } from "node:fs";
5325
+ import { join as join11 } from "node:path";
3340
5326
  import { platform } from "node:os";
3341
5327
  function probe(cmd, args) {
3342
- return new Promise((resolve4) => {
5328
+ return new Promise((resolve8) => {
3343
5329
  let out = "";
3344
5330
  let proc;
3345
5331
  try {
3346
5332
  proc = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
3347
5333
  } catch {
3348
- resolve4({ code: 127, out: "" });
5334
+ resolve8({ code: 127, out: "" });
3349
5335
  return;
3350
5336
  }
3351
5337
  proc.stdout.on("data", (d) => out += d);
3352
5338
  proc.stderr.on("data", (d) => out += d);
3353
- proc.on("error", () => resolve4({ code: 127, out }));
3354
- proc.on("close", (code) => resolve4({ code: code ?? 1, out }));
5339
+ proc.on("error", () => resolve8({ code: 127, out }));
5340
+ proc.on("close", (code) => resolve8({ code: code ?? 1, out }));
3355
5341
  });
3356
5342
  }
3357
5343
  async function dockerAvailable() {
@@ -3364,11 +5350,11 @@ async function dockerRunning(nameFilter) {
3364
5350
  return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
3365
5351
  }
3366
5352
  function scriptBringUp(projectDir, relPath, label) {
3367
- return existsSync7(join8(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
5353
+ return existsSync9(join11(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
3368
5354
  }
3369
5355
  function projectLexicons(projectDir) {
3370
5356
  try {
3371
- const src = readFileSync6(join8(projectDir, "chant.config.ts"), "utf-8");
5357
+ const src = readFileSync10(join11(projectDir, "chant.config.ts"), "utf-8");
3372
5358
  const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
3373
5359
  if (!m) return [];
3374
5360
  return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
@@ -3418,7 +5404,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3418
5404
  ["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
3419
5405
  ];
3420
5406
  for (const [name, label, marker, script] of forges) {
3421
- if (!existsSync7(join8(projectDir, marker))) continue;
5407
+ if (!existsSync9(join11(projectDir, marker))) continue;
3422
5408
  const c = docker ? await dockerRunning(name) : [];
3423
5409
  const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
3424
5410
  subs.push({
@@ -3448,7 +5434,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3448
5434
  detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
3449
5435
  });
3450
5436
  }
3451
- if (existsSync7(join8(projectDir, ".github", "workflows"))) {
5437
+ if (existsSync9(join11(projectDir, ".github", "workflows"))) {
3452
5438
  const gh = await probe("gh", ["auth", "status"]);
3453
5439
  const ready = gh.code === 0;
3454
5440
  subs.push({
@@ -3461,7 +5447,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3461
5447
  if (lexicons.includes("temporal")) {
3462
5448
  let hasProfiles = false;
3463
5449
  try {
3464
- hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync6(join8(projectDir, "chant.config.ts"), "utf-8"));
5450
+ hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync10(join11(projectDir, "chant.config.ts"), "utf-8"));
3465
5451
  } catch {
3466
5452
  }
3467
5453
  subs.push({
@@ -3473,8 +5459,8 @@ async function detectSubstrates(projectDir, preview = false, boundContext) {
3473
5459
  }
3474
5460
  if (lexicons.includes("helm")) {
3475
5461
  const helm = await probe("helm", ["version", "--short"]);
3476
- const ambient = helm.code === 0 && !boundContext ? await probe("kubectl", ["config", "current-context"]) : { code: 127, out: "" };
3477
- const ctx = boundContext ?? (ambient.code === 0 ? ambient.out.trim() : "");
5462
+ const ambient = helm.code === 0 && !boundContext ? await ambientContext() : void 0;
5463
+ const ctx = boundContext ?? ambient ?? "";
3478
5464
  subs.push({
3479
5465
  name: "helm",
3480
5466
  label: "Helm",
@@ -3524,9 +5510,9 @@ function pickAutoSyncOps(mode, ops, running, movedLexicons, suspended = /* @__PU
3524
5510
  }
3525
5511
 
3526
5512
  // src/history.ts
3527
- import { execFile as execFile3 } from "node:child_process";
3528
- import { promisify as promisify3 } from "node:util";
3529
- var execFileAsync = promisify3(execFile3);
5513
+ import { execFile as execFile2 } from "node:child_process";
5514
+ import { promisify as promisify2 } from "node:util";
5515
+ var execFileAsync = promisify2(execFile2);
3530
5516
  var SEP = "";
3531
5517
  function parseGitLog(stdout) {
3532
5518
  return stdout.split("\n").map((l) => l.trim()).filter(Boolean).map((line) => {
@@ -3565,6 +5551,8 @@ async function openRollbackBranches(projectDir, env) {
3565
5551
  }
3566
5552
 
3567
5553
  // src/estate.ts
5554
+ import { statSync as statSync3 } from "node:fs";
5555
+ import { join as join12, resolve as resolve3, sep as sep2 } from "node:path";
3568
5556
  import { composeStacks, shortStackNames } from "@intentius/pinhole";
3569
5557
  async function composeEstate(projectDirs, opts = {}) {
3570
5558
  const names = shortStackNames(projectDirs);
@@ -3573,23 +5561,126 @@ async function composeEstate(projectDirs, opts = {}) {
3573
5561
  );
3574
5562
  return composeStacks(stacks);
3575
5563
  }
3576
- var firstLine = (e) => {
5564
+ var firstLine2 = (e) => {
3577
5565
  const msg = e instanceof Error ? e.message : String(e);
3578
5566
  const m = msg.match(/exited \d+:\s*([\s\S]*)/);
3579
5567
  return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
3580
5568
  };
5569
+ function namespaceRuntimeOwners(name, ir) {
5570
+ const own = new Set(ir.nodes.map((n) => n.id));
5571
+ for (const n of ir.nodes) {
5572
+ if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
5573
+ }
5574
+ return ir;
5575
+ }
5576
+ var NAMESPACE_JOIN_FLOOR = "0.44.5";
5577
+ var rec3 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
5578
+ var str3 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
5579
+ var realIsDir = (p) => {
5580
+ try {
5581
+ return statSync3(p).isDirectory();
5582
+ } catch {
5583
+ return false;
5584
+ }
5585
+ };
5586
+ function declaredNamespaceBindings(ir) {
5587
+ const out = [];
5588
+ for (const n of ir.nodes) {
5589
+ if (n.kind !== "K8s::Flux::Kustomization") continue;
5590
+ const spec = rec3(n.attrs?.spec);
5591
+ const namespace = str3(spec?.targetNamespace);
5592
+ const path = str3(spec?.path);
5593
+ if (namespace && path) out.push({ path, namespace });
5594
+ }
5595
+ return out;
5596
+ }
5597
+ function awaitsNamespaceBinding(ir) {
5598
+ const namespaced = ir.nodes.filter((n) => n.lexicon === "k8s" && !CLUSTER_SCOPED.has(n.kind ?? ""));
5599
+ if (namespaced.length === 0) return false;
5600
+ return namespaced.every((n) => !str3(rec3(n.attrs?.metadata)?.namespace));
5601
+ }
5602
+ function declaredSegments(path) {
5603
+ if (path.startsWith("/")) return void 0;
5604
+ const segs = path.split("/").filter((s) => s !== "" && s !== ".");
5605
+ if (segs.length === 0 || segs.includes("..")) return void 0;
5606
+ return segs;
5607
+ }
5608
+ function pathAlignment(declared, dir, isDir = realIsDir) {
5609
+ const want = declaredSegments(declared);
5610
+ if (!want) return 0;
5611
+ const root = resolve3(dir);
5612
+ const have = root.split(sep2).filter(Boolean);
5613
+ for (let k = Math.min(want.length, have.length); k >= 1; k--) {
5614
+ if (!have.slice(-k).every((s, i) => s === want[i])) continue;
5615
+ if (!isDir(join12(root, ...want.slice(k)))) continue;
5616
+ return k;
5617
+ }
5618
+ return 0;
5619
+ }
5620
+ function joinNamespaceBindings(members, isDir = realIsDir) {
5621
+ const bindings = members.flatMap(
5622
+ (m) => m.ir ? declaredNamespaceBindings(m.ir).map((b) => ({ ...b, declaredBy: m.dir })) : []
5623
+ );
5624
+ if (bindings.length === 0) return [];
5625
+ const awaiting = members.filter((m) => m.ir && awaitsNamespaceBinding(m.ir));
5626
+ if (awaiting.length === 0) return [];
5627
+ const best = /* @__PURE__ */ new Map();
5628
+ for (const b of bindings) {
5629
+ let winner;
5630
+ let tied = false;
5631
+ for (const m of awaiting) {
5632
+ if (m.dir === b.declaredBy) continue;
5633
+ const score = pathAlignment(b.path, m.dir, isDir);
5634
+ if (score === 0) continue;
5635
+ if (!winner || score > winner.score) {
5636
+ winner = { dir: m.dir, score };
5637
+ tied = false;
5638
+ } else if (score === winner.score) {
5639
+ tied = true;
5640
+ }
5641
+ }
5642
+ if (!winner || tied) continue;
5643
+ const prev = best.get(winner.dir);
5644
+ if (prev === "ambiguous") continue;
5645
+ const join18 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
5646
+ if (!prev) best.set(winner.dir, { score: winner.score, join: join18 });
5647
+ else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
5648
+ else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join18 });
5649
+ }
5650
+ return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
5651
+ }
5652
+ async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
5653
+ if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
5654
+ const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
5655
+ const irs = await Promise.all(projectDirs.map((dir) => graphIr(dir, { ...src, detail: 3 }).catch(() => void 0)));
5656
+ const scopes = /* @__PURE__ */ new Map();
5657
+ for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
5658
+ if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
5659
+ scopes.set(j.dir, j.namespace);
5660
+ }
5661
+ return scopes;
5662
+ }
5663
+ function withoutJoinedMembers(nodes, joined) {
5664
+ if (joined.length === 0) return [...nodes];
5665
+ return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
5666
+ }
3581
5667
  async function composeEstateOverlay(projectDirs, opts, classify) {
3582
5668
  const names = shortStackNames(projectDirs);
3583
5669
  const unobserved = [];
3584
5670
  const dropped = [];
5671
+ const joined = [];
3585
5672
  const stacks = new Array(projectDirs.length);
5673
+ const scopes = await estateNamespaceScopes(projectDirs, opts);
3586
5674
  await Promise.all(
3587
5675
  projectDirs.map(async (dir, i) => {
3588
5676
  const name = names[i];
5677
+ const namespace = scopes.get(dir);
3589
5678
  try {
3590
- stacks[i] = { name, ir: classify(await graphIr(dir, { ...opts, live: true, overlay: true })) };
5679
+ const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
5680
+ stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
5681
+ if (namespace) joined.push({ name, namespace });
3591
5682
  } catch (err) {
3592
- const reason = firstLine(err);
5683
+ const reason = firstLine2(err);
3593
5684
  try {
3594
5685
  const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
3595
5686
  const src = await graphIr(dir, srcOpts);
@@ -3597,7 +5688,7 @@ async function composeEstateOverlay(projectDirs, opts, classify) {
3597
5688
  stacks[i] = { name, ir: src };
3598
5689
  unobserved.push({ name, reason });
3599
5690
  } catch (err2) {
3600
- dropped.push({ name, reason: firstLine(err2) });
5691
+ dropped.push({ name, reason: firstLine2(err2) });
3601
5692
  }
3602
5693
  }
3603
5694
  })
@@ -3608,13 +5699,16 @@ async function composeEstateOverlay(projectDirs, opts, classify) {
3608
5699
  observed: present.length - unobserved.length,
3609
5700
  total: projectDirs.length,
3610
5701
  unobserved,
3611
- dropped
5702
+ dropped,
5703
+ // Concurrent reads finish in whatever order the clusters answer; the note
5704
+ // this feeds should read the same twice.
5705
+ joined: joined.sort((a, b) => a.name.localeCompare(b.name))
3612
5706
  };
3613
5707
  }
3614
5708
 
3615
5709
  // src/events.ts
3616
- import { watch, existsSync as existsSync8 } from "node:fs";
3617
- import { join as join9 } from "node:path";
5710
+ import { watch, existsSync as existsSync10 } from "node:fs";
5711
+ import { join as join13 } from "node:path";
3618
5712
  var Broadcaster = class {
3619
5713
  listeners = /* @__PURE__ */ new Set();
3620
5714
  subscribe(fn) {
@@ -3632,7 +5726,7 @@ var Broadcaster = class {
3632
5726
  };
3633
5727
  var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
3634
5728
  function watchSource(projectDir, onChange, debounceMs = 200) {
3635
- const dir = existsSync8(join9(projectDir, "src")) ? join9(projectDir, "src") : projectDir;
5729
+ const dir = existsSync10(join13(projectDir, "src")) ? join13(projectDir, "src") : projectDir;
3636
5730
  let timer;
3637
5731
  const watcher = watch(dir, { recursive: true }, (_event, file) => {
3638
5732
  const name = typeof file === "string" ? file : "";
@@ -3740,19 +5834,21 @@ import { renderMorphHtml, layoutIr as layoutIr2 } from "@intentius/pinhole";
3740
5834
  function safeJson(value) {
3741
5835
  return JSON.stringify(value).replace(/</g, "\\u003c");
3742
5836
  }
5837
+ var LANES_MONO = `ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Code", "JetBrains Mono", "IBM Plex Mono", Menlo, Consolas, monospace`;
3743
5838
  var LANES_CSS = `
3744
- body { background: var(--bg, #0d1117); color: var(--fg, #e6edf3); padding-bottom: 150px; }
3745
- #behold-lanes { position: fixed; left: 0; right: 0; bottom: 0; background: var(--bg, #0d1117);
3746
- border-top: 1px solid var(--line, #30363d); padding: 8px 12px 10px; font: 12px ui-sans-serif, system-ui, sans-serif; color: var(--muted, #8b949e); }
5839
+ body { background: var(--bg, #1e1e2e); color: var(--fg, #cdd6f4); padding-bottom: 150px; }
5840
+ #behold-lanes { position: fixed; left: 0; right: 0; bottom: 0; background: var(--bg, #1e1e2e);
5841
+ border-top: 1px solid var(--line, #3b3c4a); padding: 8px 12px 10px; font: 11px/1.5 ${LANES_MONO}; color: var(--muted, #777a8c); }
3747
5842
  #behold-lanes .hd { display: flex; gap: 14px; align-items: baseline; margin-bottom: 4px; }
3748
- #behold-lanes .hd .rt { color: var(--foreign, #d29922); }
3749
- #behold-lanes .hd a { color: var(--pending, #58a6ff); text-decoration: none; margin-left: auto; }
5843
+ #behold-lanes .hd .rt { color: var(--foreign, #f9e2af); }
5844
+ #behold-lanes .hd a { color: var(--pending, #89b4fa); text-decoration: none; margin-left: auto; }
3750
5845
  #behold-lanes canvas { display: block; width: 100%; cursor: pointer; }
3751
5846
  #behold-diff { position: fixed; right: 12px; bottom: 156px; width: 260px; max-height: 40vh; overflow: auto;
3752
- background: var(--panel, #161b22); border: 1px solid var(--line, #30363d); border-radius: 6px; padding: 10px 12px; font: 12px ui-sans-serif, system-ui, sans-serif;
3753
- color: var(--fg, #e6edf3); display: none; }
3754
- #behold-diff h4 { margin: 0 0 6px; font-size: 12px; color: var(--muted, #8b949e); }
3755
- #behold-diff .a { color: var(--managed, #3fb950); } #behold-diff .r { color: var(--degraded, #f85149); } #behold-diff .c { color: var(--foreign, #d29922); }`;
5847
+ background: var(--panel, #2e2f40); border: 1px solid var(--rule, #4e4f64); border-radius: 3px; padding: 10px 12px; font: 11px/1.5 ${LANES_MONO};
5848
+ color: var(--fg, #cdd6f4); display: none; }
5849
+ #behold-diff h4 { margin: 0 0 6px; font: 600 10.5px/1.4 ui-sans-serif, system-ui, sans-serif;
5850
+ text-transform: uppercase; letter-spacing: .07em; color: var(--muted, #777a8c); }
5851
+ #behold-diff .a { color: var(--managed, #a6e3a1); } #behold-diff .r { color: var(--degraded, #f38ba8); } #behold-diff .c { color: var(--foreign, #f9e2af); }`;
3756
5852
  var THEME_BOOT = `<script type="module">import { initTheme, onThemeChange, mountThemePicker } from "/theme.js"; initTheme(); mountThemePicker(document.getElementById("behold-lanes-pickers")); onThemeChange(() => window.dispatchEvent(new Event("behold-theme")));</script>`;
3757
5853
  function laneStripScript(frames) {
3758
5854
  return `<script>
@@ -3778,7 +5874,7 @@ const LF = ${safeJson(frames)};
3778
5874
  const inNow = now !== undefined, inPrev = prev !== undefined;
3779
5875
  return inNow !== inPrev || (inNow && inPrev && now !== prev);
3780
5876
  }
3781
- const color = s => s === "good" ? css("--managed", "#3fb950") : s === "warn" ? css("--foreign", "#d29922") : s === "accent" ? css("--pending", "#58a6ff") : css("--edge", "#6e7681");
5877
+ const color = s => s === "good" ? css("--managed", "#a6e3a1") : s === "warn" ? css("--foreign", "#f9e2af") : s === "accent" ? css("--pending", "#89b4fa") : css("--edge", "#56596d");
3782
5878
 
3783
5879
  function draw() {
3784
5880
  const dpr = window.devicePixelRatio || 1;
@@ -3787,22 +5883,22 @@ const LF = ${safeJson(frames)};
3787
5883
  c.clearRect(0, 0, host.clientWidth, H); c.font = "12px ui-sans-serif, system-ui, sans-serif";
3788
5884
  subs.forEach((s, r) => {
3789
5885
  const y = padT + r * rowH + rowH / 2;
3790
- c.fillStyle = offset[s] ? css("--foreign", "#d29922") : css("--muted", "#8b949e"); c.textAlign = "left"; c.fillText(s, 8, y + 4);
3791
- c.strokeStyle = css("--line", "#21262d"); c.beginPath(); c.moveTo(padL, y); c.lineTo(host.clientWidth - padR, y); c.stroke();
5886
+ c.fillStyle = offset[s] ? css("--foreign", "#f9e2af") : css("--muted", "#777a8c"); c.textAlign = "left"; c.fillText(s, 8, y + 4);
5887
+ c.strokeStyle = css("--line", "#3b3c4a"); c.beginPath(); c.moveTo(padL, y); c.lineTo(host.clientWidth - padR, y); c.stroke();
3792
5888
  LF.forEach((f, i) => {
3793
5889
  if (!f.byLexicon[s]) return;
3794
5890
  // dot per substrate; brighter when a node in this substrate changed at i
3795
5891
  const changed = Object.keys(f.status).some(id => f.lexicon[id] === s && changedAt(id, i));
3796
5892
  const hi = focus && f.lexicon[focus] === s && changedAt(focus, i);
3797
- c.fillStyle = hi ? css("--fg", "#f0f6fc") : changed ? color(mode(f, s)) : css("--line", "#30363d");
5893
+ c.fillStyle = hi ? css("--fg", "#cdd6f4") : changed ? color(mode(f, s)) : css("--line", "#3b3c4a");
3798
5894
  c.beginPath(); c.arc(xOf(i, s), y, i === cur ? 5 : hi ? 4.5 : 3.5, 0, 7); c.fill();
3799
5895
  });
3800
5896
  });
3801
- const px = baseX(cur); c.strokeStyle = css("--pending", "#58a6ff"); c.lineWidth = 1.5;
5897
+ const px = baseX(cur); c.strokeStyle = css("--pending", "#89b4fa"); c.lineWidth = 1.5;
3802
5898
  c.beginPath(); c.moveTo(px, padT - 2); c.lineTo(px, padT + subs.length * rowH); c.stroke();
3803
- if (pair != null) { const qx = baseX(pair); c.strokeStyle = css("--foreign", "#d29922"); c.setLineDash([3,3]);
5899
+ if (pair != null) { const qx = baseX(pair); c.strokeStyle = css("--foreign", "#f9e2af"); c.setLineDash([3,3]);
3804
5900
  c.beginPath(); c.moveTo(qx, padT - 2); c.lineTo(qx, padT + subs.length * rowH); c.stroke(); c.setLineDash([]); }
3805
- c.fillStyle = css("--fg", "#e6edf3"); c.textAlign = "center"; c.fillText(LF[cur].name, px, padT + subs.length * rowH + 16);
5901
+ c.fillStyle = css("--fg", "#cdd6f4"); c.textAlign = "center"; c.fillText(LF[cur].name, px, padT + subs.length * rowH + 16);
3806
5902
  document.getElementById("behold-lanes-meta").textContent =
3807
5903
  LF.length + " frames \xB7 frame " + (cur + 1) + "/" + LF.length + (focus ? " \xB7 focus " + focus : "");
3808
5904
  document.getElementById("behold-lanes-rt").style.display = anyOffset() ? "inline" : "none";
@@ -3907,9 +6003,108 @@ async function emulatorDown(projectDir) {
3907
6003
  await runChantRaw(["emulator", "down"], projectDir);
3908
6004
  }
3909
6005
 
6006
+ // src/demos.ts
6007
+ import { readFileSync as readFileSync11, existsSync as existsSync11, cpSync } from "node:fs";
6008
+ import { join as join14, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
6009
+ import { spawn as spawn4, spawnSync } from "node:child_process";
6010
+ function loadDemoRegistry(pkgRoot2) {
6011
+ let raw;
6012
+ try {
6013
+ raw = JSON.parse(readFileSync11(join14(pkgRoot2, "demos.json"), "utf8"));
6014
+ } catch {
6015
+ return [];
6016
+ }
6017
+ const list3 = raw?.demos;
6018
+ if (!Array.isArray(list3)) return [];
6019
+ return list3.filter((e) => {
6020
+ const d = e;
6021
+ if (!d || typeof d.name !== "string" || !d.name || typeof d.description !== "string") return false;
6022
+ if (d.source === "bundled") {
6023
+ if (typeof d.dir !== "string" || !d.dir) return false;
6024
+ } else if (d.source === "git") {
6025
+ if (typeof d.repo !== "string" || !d.repo) return false;
6026
+ } else {
6027
+ return false;
6028
+ }
6029
+ if (!Array.isArray(d.requires) || d.requires.some((r) => typeof r !== "string")) return false;
6030
+ if (!d.serve || typeof d.serve !== "object") return false;
6031
+ if (d.serve.dirs !== void 0 && (!Array.isArray(d.serve.dirs) || d.serve.dirs.some((x) => typeof x !== "string") || !d.serve.dirs.length))
6032
+ return false;
6033
+ if (d.serve.carve !== void 0) {
6034
+ const c = d.serve.carve;
6035
+ const rel = (v) => typeof v === "string" && !!v && !v.startsWith("/") && !v.split("/").includes("..");
6036
+ if (!c || typeof c !== "object") return false;
6037
+ if (!rel(c.report) || !rel(c.from) || !rel(c.project) || !rel(c.out)) return false;
6038
+ if (c.state !== void 0 && !rel(c.state)) return false;
6039
+ if (!`${c.out}/`.startsWith(`${c.project}/`)) return false;
6040
+ }
6041
+ return true;
6042
+ });
6043
+ }
6044
+ function missingRequirements(entry) {
6045
+ const bins = entry.source === "git" && !entry.requires.includes("git") ? [...entry.requires, "git"] : entry.requires;
6046
+ const finder = process.platform === "win32" ? "where" : "which";
6047
+ return bins.filter((bin) => spawnSync(finder, [bin], { stdio: "ignore" }).status !== 0);
6048
+ }
6049
+ function fetchesFromNetwork(entry) {
6050
+ return entry.source === "git";
6051
+ }
6052
+ function demoTargetDir(entry, cwd = process.cwd()) {
6053
+ const legacy = resolve4(cwd, "behold-demo");
6054
+ if (entry.name === "writes" && existsSync11(legacy)) return legacy;
6055
+ return resolve4(cwd, "behold-demos", entry.name);
6056
+ }
6057
+ async function loadDemo(entry, opts) {
6058
+ const { pkgRoot: pkgRoot2, target } = opts;
6059
+ const say = (line) => opts.log?.(`behold demo ${entry.name} \u2192 ${line}`);
6060
+ if (!existsSync11(target)) {
6061
+ if (entry.source === "bundled") {
6062
+ const bundled = join14(pkgRoot2, entry.dir);
6063
+ if (!existsSync11(bundled)) return { ok: false, error: `this install has no bundled ${entry.dir}` };
6064
+ say(`copying to ${target} (it's yours \u2014 edit it)`);
6065
+ try {
6066
+ cpSync(bundled, target, {
6067
+ recursive: true,
6068
+ filter: (src) => !relative2(bundled, src).split(sep3).includes("node_modules")
6069
+ });
6070
+ } catch (err) {
6071
+ return { ok: false, error: `copy failed: ${err instanceof Error ? err.message : String(err)}` };
6072
+ }
6073
+ } else {
6074
+ say(`cloning ${entry.repo} to ${target}`);
6075
+ if (await runStep("git", ["clone", "--depth", "1", entry.repo, target]) !== 0) {
6076
+ return { ok: false, error: `clone of ${entry.repo} failed` };
6077
+ }
6078
+ }
6079
+ } else {
6080
+ say(`reusing ${target}`);
6081
+ }
6082
+ if (existsSync11(join14(target, "package.json")) && !existsSync11(join14(target, "node_modules"))) {
6083
+ say("npm install\u2026");
6084
+ if (await runStep("npm", ["install"], { cwd: target, shell: process.platform === "win32" }) !== 0) {
6085
+ return { ok: false, error: `npm install failed in ${target}` };
6086
+ }
6087
+ }
6088
+ if (entry.setup) {
6089
+ say(entry.setup);
6090
+ if (await runStep(entry.setup, [], { cwd: target, shell: true }) !== 0) {
6091
+ return { ok: false, error: `setup failed (${entry.setup})` };
6092
+ }
6093
+ }
6094
+ return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join14(target, d)) : [target] };
6095
+ }
6096
+ function runStep(cmd, args, opts = {}) {
6097
+ return new Promise((res) => {
6098
+ const child = spawn4(cmd, args, { stdio: "inherit", cwd: opts.cwd, shell: opts.shell ?? false });
6099
+ child.on("error", () => res(-1));
6100
+ child.on("close", (code) => res(code ?? 1));
6101
+ });
6102
+ }
6103
+
3910
6104
  // src/server.ts
3911
- var webRoot = join10(dirname3(fileURLToPath(import.meta.url)), "..", "web");
3912
- var execFileP = async (cmd, args) => (await promisify4(execFile4)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
6105
+ var pkgRoot = join15(dirname4(fileURLToPath3(import.meta.url)), "..");
6106
+ var webRoot = join15(pkgRoot, "web");
6107
+ var execFileP = async (cmd, args) => (await promisify3(execFile3)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
3913
6108
  function optsFromQuery(url, tierEnvVar, projectDir) {
3914
6109
  const q = url.searchParams;
3915
6110
  const opts = {};
@@ -3957,8 +6152,8 @@ function tierFailure(tier, message) {
3957
6152
  }
3958
6153
  function beholdVersion() {
3959
6154
  try {
3960
- const here = dirname3(fileURLToPath(import.meta.url));
3961
- return JSON.parse(readFileSync7(join10(here, "..", "package.json"), "utf8")).version ?? "unknown";
6155
+ const here = dirname4(fileURLToPath3(import.meta.url));
6156
+ return JSON.parse(readFileSync12(join15(here, "..", "package.json"), "utf8")).version ?? "unknown";
3962
6157
  } catch {
3963
6158
  return "unknown";
3964
6159
  }
@@ -4002,12 +6197,125 @@ async function captureFrame(projectDir, env, frames, broadcaster) {
4002
6197
  return null;
4003
6198
  }
4004
6199
  }
6200
+ function carveRoutes(app, reportPath, demo) {
6201
+ const load = () => readCarveReport(reportPath, (p) => readFileSync12(p, "utf8"));
6202
+ const demoBlock = () => carveWriteBlock(demo);
6203
+ const demoInfo = () => {
6204
+ if (!demo) return null;
6205
+ const block = demoBlock();
6206
+ return {
6207
+ root: demo.root,
6208
+ from: demo.from,
6209
+ state: demo.state ?? null,
6210
+ project: demo.project,
6211
+ out: demo.out,
6212
+ // Paths the UI can print without leaking the operator's whole home dir.
6213
+ outLabel: relative3(demo.root, demo.out).split(sep4).join("/"),
6214
+ fromLabel: relative3(demo.root, demo.from).split(sep4).join("/"),
6215
+ runnable: !block,
6216
+ ...block ? { reason: block } : {},
6217
+ ...demo.degraded ? { degraded: demo.degraded } : {},
6218
+ buildCaveat: BUILD_CAVEAT
6219
+ };
6220
+ };
6221
+ app.get("/api/carve", (c) => {
6222
+ const parsed = load();
6223
+ return parsed.ok ? c.json(parsed.report) : c.json(parsed.refusal, 422);
6224
+ });
6225
+ app.get("/api/graph", (c) => {
6226
+ const parsed = load();
6227
+ if (!parsed.ok) return c.json(parsed.refusal, 422);
6228
+ const ir = carveReportToIr(parsed.report);
6229
+ const { svg } = renderBanded(ir);
6230
+ return c.json({
6231
+ ir,
6232
+ svg,
6233
+ meta: {
6234
+ projectDir: reportPath,
6235
+ env: null,
6236
+ tier: null,
6237
+ target: null,
6238
+ carve: true,
6239
+ // A demo whose own advisor run failed says so on the statusbar, not
6240
+ // only in the terminal the viewer isn't looking at.
6241
+ note: carveNote(parsed.report, ir) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : "")
6242
+ }
6243
+ });
6244
+ });
6245
+ app.get("/api/project", (c) => {
6246
+ const parsed = load();
6247
+ return c.json({
6248
+ projectDir: reportPath,
6249
+ recents: [],
6250
+ // No envs, tiers, stacks or targets: a peelability report is a static
6251
+ // analysis of foreign Terraform, so every picker that would imply a live
6252
+ // axis stays empty and the SPA renders none of them.
6253
+ environments: [],
6254
+ lexicons: ["terraform"],
6255
+ currentEnv: null,
6256
+ targets: [],
6257
+ carve: parsed.ok ? {
6258
+ report: reportPath,
6259
+ from: parsed.report.from ?? null,
6260
+ count: parsed.report.count ?? parsed.report.resources.length,
6261
+ bands: parsed.report.bands ?? {},
6262
+ advisory: parsed.report.advisory ?? null,
6263
+ demo: demoInfo()
6264
+ } : { report: reportPath, demo: demoInfo() }
6265
+ });
6266
+ });
6267
+ const runStep2 = async (c, run3) => {
6268
+ const block = demoBlock();
6269
+ if (block) {
6270
+ return c.json(
6271
+ {
6272
+ error: block,
6273
+ code: "read-only",
6274
+ remedy: "The carve steps run inside a demo copy \u2014 start the walkthrough with `behold demo carve`."
6275
+ },
6276
+ 403
6277
+ );
6278
+ }
6279
+ if (!(c.req.header("content-type") ?? "").includes("application/json")) {
6280
+ return c.json({ error: "send application/json", code: "carve-select", remedy: 'POST {"select": "<terraform address>"}' }, 415);
6281
+ }
6282
+ const text = await c.req.text().catch(() => "");
6283
+ 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);
6284
+ let body;
6285
+ try {
6286
+ body = JSON.parse(text || "null");
6287
+ } catch {
6288
+ return c.json({ error: "body must be JSON", code: "carve-select", remedy: 'POST {"select": "<terraform address>"}' }, 400);
6289
+ }
6290
+ const parsed = load();
6291
+ const select = selectFromReport(parsed.ok ? parsed.report : void 0, body?.select);
6292
+ if (!select) {
6293
+ return c.json(
6294
+ {
6295
+ error: `\`select\` must name a resource this report ranks \u2014 ${JSON.stringify(body?.select ?? null)} isn't one of them.`,
6296
+ code: "carve-select",
6297
+ remedy: "Pick a card in the graph, or read the addresses off GET /api/carve."
6298
+ },
6299
+ 400
6300
+ );
6301
+ }
6302
+ const result = await run3(demo, select);
6303
+ return result.ok ? c.json(result) : c.json(result.refusal, 422);
6304
+ };
6305
+ app.post("/api/carve/emit", (c) => runStep2(c, runCarveEmit));
6306
+ app.post("/api/carve/bridge", (c) => runStep2(c, runCarveBridge));
6307
+ app.get("/api/substrates", (c) => c.json({ substrates: [] }));
6308
+ app.get("/api/history", (c) => c.json({ commits: [] }));
6309
+ app.get("/api/resources", (c) => c.json({ byComponent: {} }));
6310
+ app.get("/api/ci", (c) => c.json({ stages: [], jobs: [], forge: null }));
6311
+ }
4005
6312
  function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffer(), runner = new OpRunner({
4006
6313
  projectDir: cfg.projectDir,
4007
6314
  broadcaster,
4008
6315
  onDone: (opEnv) => captureFrame(cfg.projectDir, opEnv ?? cfg.env, frames, broadcaster)
4009
6316
  })) {
4010
6317
  const app = new Hono();
6318
+ if (cfg.carveReport) carveRoutes(app, cfg.carveReport, cfg.carveDemo);
4011
6319
  let beholdConfig = loadBeholdConfig(cfg.projectDir);
4012
6320
  let tierEnvVar = beholdConfig.tiers?.envVar;
4013
6321
  const boundK8sContext = async (env) => {
@@ -4019,13 +6327,14 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4019
6327
  return void 0;
4020
6328
  }
4021
6329
  };
6330
+ const estateSourceRoots = async (opts) => (await Promise.all((cfg.projectDirs ?? []).map(async (dir) => [await graphPath(dir, opts), dir]))).flat();
4022
6331
  app.get("/healthz", (c) => c.json({ ok: true, projectDir: cfg.projectDir, env: cfg.env ?? null, frames: frames.size }));
4023
6332
  app.get("/api/frames", (c) => c.json({ frames: frames.summaries() }));
4024
6333
  app.get("/lanes", (c) => {
4025
6334
  const all = frames.all();
4026
6335
  if (all.length < 2) {
4027
6336
  return c.html(
4028
- `<!doctype html><meta charset=utf-8><script type="module">import { initTheme } from "/theme.js"; initTheme();</script><body style="font:14px system-ui;background:var(--bg,#0d1117);color:var(--muted,#8b949e);padding:2rem"><h3 style="color:var(--fg,#e6edf3)">deployment lanes</h3><p>${all.length} frame(s) captured \u2014 need at least two to scrub.</p><p>Frames accrue when the estate moves: hit <b style="color:var(--fg,#e6edf3)">\u21BB Refresh</b> (captures the current live state), run a <b style="color:var(--fg,#e6edf3)">Sync</b>/Adopt, edit the source, or serve with <code>--poll</code> against a moving environment. Then reload.</p><p><a href="/" style="color:var(--pending,#58a6ff);text-decoration:none">\u2190 back to the graph</a></p></body>`
6337
+ `<!doctype html><meta charset=utf-8><script type="module">import { initTheme } from "/theme.js"; initTheme();</script><body style="font:14px system-ui;background:var(--bg,#1e1e2e);color:var(--muted,#777a8c);padding:2rem"><h3 style="color:var(--fg,#cdd6f4)">deployment lanes</h3><p>${all.length} frame(s) captured \u2014 need at least two to scrub.</p><p>Frames accrue when the estate moves: hit <b style="color:var(--fg,#cdd6f4)">\u21BB Refresh</b> (captures the current live state), run a <b style="color:var(--fg,#cdd6f4)">Sync</b>/Adopt, edit the source, or serve with <code>--poll</code> against a moving environment. Then reload.</p><p><a href="/" style="color:var(--pending,#58a6ff);text-decoration:none">\u2190 back to the graph</a></p></body>`
4029
6338
  );
4030
6339
  }
4031
6340
  return c.html(renderLanes(all, frames.summaries()));
@@ -4091,9 +6400,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4091
6400
  return c.json({ started: true, name, ran: label });
4092
6401
  });
4093
6402
  app.post("/api/local/reset", (c) => {
4094
- const down = join10(cfg.projectDir, "scripts/local/local-down.sh");
4095
- const up = join10(cfg.projectDir, "scripts/local/local-up.sh");
4096
- if (!existsSync9(down) || !existsSync9(up)) {
6403
+ const down = join15(cfg.projectDir, "scripts/local/local-down.sh");
6404
+ const up = join15(cfg.projectDir, "scripts/local/local-up.sh");
6405
+ if (!existsSync12(down) || !existsSync12(up)) {
4097
6406
  return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
4098
6407
  }
4099
6408
  if (!runner.bringUp("reset local emulator", "bash", ["-c", "bash scripts/local/local-down.sh && bash scripts/local/local-up.sh"], cfg.projectDir)) {
@@ -4170,26 +6479,29 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4170
6479
  app.post("/api/project/open", async (c) => {
4171
6480
  if (cfg.previewMode) return c.json({ error: "switching projects is locked in preview mode" }, 403);
4172
6481
  const body = await c.req.json().catch(() => ({}));
4173
- const dir = typeof body.dir === "string" && body.dir.trim() ? resolve2(body.dir.trim()) : "";
4174
- if (!dir || !existsSync9(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
4175
- if (!existsSync9(join10(dir, "chant.config.ts"))) {
6482
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve5(body.dir.trim()) : "";
6483
+ if (!dir || !existsSync12(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
6484
+ if (!existsSync12(join15(dir, "chant.config.ts"))) {
4176
6485
  return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
4177
6486
  }
6487
+ switchServedProject([dir]);
6488
+ return c.json({ ok: true, projectDir: dir });
6489
+ });
6490
+ const switchServedProject = (dirs, env) => {
4178
6491
  addRecent(cfg.projectDir);
4179
- cfg.projectDir = dir;
4180
- cfg.projectDirs = void 0;
4181
- cfg.env = void 0;
4182
- beholdConfig = loadBeholdConfig(dir);
6492
+ cfg.projectDir = dirs[0];
6493
+ cfg.projectDirs = dirs.length > 1 ? dirs : void 0;
6494
+ cfg.env = env;
6495
+ beholdConfig = loadBeholdConfig(dirs[0]);
4183
6496
  tierEnvVar = beholdConfig.tiers?.envVar;
4184
- runner.retarget(dir);
4185
- addRecent(dir);
4186
- cfg.onProjectSwitch?.(dir);
6497
+ runner.retarget(dirs[0]);
6498
+ for (const d of dirs) addRecent(d);
6499
+ cfg.onProjectSwitch?.(dirs[0]);
4187
6500
  broadcaster.emit("changed");
4188
- return c.json({ ok: true, projectDir: dir });
4189
- });
6501
+ };
4190
6502
  app.post("/api/project/reveal", async (c) => {
4191
6503
  const body = await c.req.json().catch(() => ({}));
4192
- const dir = typeof body.dir === "string" && body.dir.trim() ? resolve2(body.dir.trim()) : cfg.projectDir;
6504
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve5(body.dir.trim()) : cfg.projectDir;
4193
6505
  const known = /* @__PURE__ */ new Set([cfg.projectDir, ...cfg.projectDirs ?? [], ...listRecents().map((r) => r.dir)]);
4194
6506
  if (!known.has(dir)) return c.json({ error: "not a served or recent project directory" }, 400);
4195
6507
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
@@ -4200,6 +6512,144 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4200
6512
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
4201
6513
  }
4202
6514
  });
6515
+ const demoRow = (e) => {
6516
+ const missing = missingRequirements(e);
6517
+ const target = demoTargetDir(e);
6518
+ return {
6519
+ name: e.name,
6520
+ description: e.description,
6521
+ requires: e.requires,
6522
+ source: e.source,
6523
+ // #268's consent half: a git entry is cloned from a public repo, so the
6524
+ // button says so (and names the repo) BEFORE anything is fetched.
6525
+ fetches: fetchesFromNetwork(e),
6526
+ ...e.repo ? { repo: e.repo } : {},
6527
+ target,
6528
+ loaded: existsSync12(target),
6529
+ satisfiable: missing.length === 0,
6530
+ // #254: the carve walkthrough runs fine here — it just isn't a project
6531
+ // to switch INTO. Carve mode claims `/api/graph` and `/api/project` at
6532
+ // app creation (see carveRoutes), so a running server cannot become one;
6533
+ // the row stays in the catalog, disabled, saying what to run instead.
6534
+ ...e.serve.carve ? { switchable: false } : {},
6535
+ ...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}\`` } : {}
6536
+ };
6537
+ };
6538
+ app.get(
6539
+ "/api/demos",
6540
+ (c) => c.json({
6541
+ demos: loadDemoRegistry(pkgRoot).map(demoRow),
6542
+ ...cfg.previewMode ? { locked: "loading a demo is locked in preview mode" } : {}
6543
+ })
6544
+ );
6545
+ let loadingDemo = null;
6546
+ app.post("/api/demos/open", async (c) => {
6547
+ if (cfg.previewMode) return c.json({ error: "loading a demo is locked in preview mode" }, 403);
6548
+ const body = await c.req.json().catch(() => ({}));
6549
+ const name = typeof body.name === "string" ? body.name.trim() : "";
6550
+ const entry = loadDemoRegistry(pkgRoot).find((e) => e.name === name);
6551
+ if (!entry) {
6552
+ return c.json({ error: `no "${name || "(no name given)"}" in this install's demo catalog` }, 400);
6553
+ }
6554
+ const missing = missingRequirements(entry);
6555
+ if (missing.length) return c.json({ error: `${entry.name} needs ${missing.join(", ")} on PATH` }, 400);
6556
+ if (entry.serve.carve) {
6557
+ return c.json(
6558
+ {
6559
+ error: `${entry.name} serves a carve report, not a chant project \u2014 a running server can't switch into carve mode.`,
6560
+ remedy: `Run \`behold demo ${entry.name}\` in a terminal.`
6561
+ },
6562
+ 400
6563
+ );
6564
+ }
6565
+ if (loadingDemo) return c.json({ error: `${loadingDemo} is still loading` }, 409);
6566
+ loadingDemo = entry.name;
6567
+ try {
6568
+ const target = demoTargetDir(entry);
6569
+ const loaded = await loadDemo(entry, { pkgRoot, target, log: (line) => process.stdout.write(line + "\n") });
6570
+ if (!loaded.ok) return c.json({ error: loaded.error }, 500);
6571
+ const dirs = loaded.serveDirs;
6572
+ if (!existsSync12(join15(dirs[0], "chant.config.ts"))) {
6573
+ return c.json({ error: `${entry.name} loaded to ${target} but ${dirs[0]} has no chant.config.ts` }, 500);
6574
+ }
6575
+ if (entry.serve.local) {
6576
+ if (cfg.emulators?.length) await emulatorDown(cfg.projectDir).catch(() => {
6577
+ });
6578
+ cfg.local = true;
6579
+ cfg.emulators = await bootLocalEmulators(dirs[0], `behold demo ${entry.name} --local`);
6580
+ }
6581
+ switchServedProject(dirs, entry.serve.env);
6582
+ return c.json({ ok: true, demo: entry.name, projectDir: dirs[0], projectDirs: dirs, env: entry.serve.env ?? null });
6583
+ } finally {
6584
+ loadingDemo = null;
6585
+ }
6586
+ });
6587
+ const layoutWriteBlock = () => {
6588
+ if (cfg.previewMode) return "the layout sidecar is read-only in preview mode";
6589
+ if (cfg.carveReport) return "a carve report isn't a project \u2014 there's nowhere to keep a hand layout";
6590
+ if (cfg.layoutWrites === false) return "a static export captures a snapshot \u2014 it doesn't write to the project";
6591
+ return unwritableReason(cfg.projectDir);
6592
+ };
6593
+ app.get("/api/layout", (c) => {
6594
+ const block = layoutWriteBlock();
6595
+ const shared = { path: layoutPath(cfg.projectDir), writable: !block, ...block ? { reason: block } : {} };
6596
+ const raw = new URL(c.req.url).searchParams.get("lens");
6597
+ if (raw === null) return c.json({ ...shared, lenses: readLayoutFile(cfg.projectDir).lenses });
6598
+ const lens = normalizeLens(raw);
6599
+ if (!lens) return c.json({ error: `not a lens key: ${raw}`, code: "bad-layout" }, 400);
6600
+ return c.json({ ...shared, lens, deltas: readLens(cfg.projectDir, lens) });
6601
+ });
6602
+ app.post("/api/layout", async (c) => {
6603
+ const block = layoutWriteBlock();
6604
+ if (block) return c.json({ error: block, code: "read-only" }, 403);
6605
+ if (!(c.req.header("content-type") ?? "").includes("application/json")) {
6606
+ return c.json({ error: "send application/json", code: "bad-layout" }, 415);
6607
+ }
6608
+ const declared = Number(c.req.header("content-length") ?? 0);
6609
+ if (declared > MAX_BODY_BYTES) return c.json({ error: `a layout body is capped at ${MAX_BODY_BYTES} bytes`, code: "too-large" }, 413);
6610
+ const text = await c.req.text().catch(() => "");
6611
+ if (text.length > MAX_BODY_BYTES) return c.json({ error: `a layout body is capped at ${MAX_BODY_BYTES} bytes`, code: "too-large" }, 413);
6612
+ let body;
6613
+ try {
6614
+ body = JSON.parse(text || "null");
6615
+ } catch {
6616
+ return c.json({ error: "body must be JSON", code: "bad-layout" }, 400);
6617
+ }
6618
+ const lens = normalizeLens(body?.lens);
6619
+ if (!lens) return c.json({ error: "body needs a `lens` key (the zoom stop, plus +radial / +stack-<name>)", code: "bad-layout" }, 400);
6620
+ if (!body?.deltas || typeof body.deltas !== "object" || Array.isArray(body.deltas)) {
6621
+ return c.json({ error: "body needs `deltas`: {<node id>: {dx,dy,dw,dh}}", code: "bad-layout" }, 400);
6622
+ }
6623
+ try {
6624
+ const stored = writeLens(cfg.projectDir, lens, body.deltas);
6625
+ return c.json({ ok: true, lens: stored.lens, deltas: stored.deltas, count: Object.keys(stored.deltas).length, path: layoutPath(cfg.projectDir) });
6626
+ } catch (err) {
6627
+ if (err instanceof LayoutTooLarge) return c.json({ error: err.message, code: "too-large" }, 413);
6628
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
6629
+ }
6630
+ });
6631
+ const bakeHandLayout = async (c, next) => {
6632
+ await next();
6633
+ const url = new URL(c.req.url);
6634
+ if (url.searchParams.get("layout") !== "1") return;
6635
+ const res = c.res;
6636
+ if (!res || res.status !== 200 || !(res.headers.get("content-type") ?? "").includes("json")) return;
6637
+ const lens = lensFromQuery(url.searchParams);
6638
+ const deltas = readLens(cfg.projectDir, lens);
6639
+ if (!Object.keys(deltas).length) return;
6640
+ let body;
6641
+ try {
6642
+ body = await res.clone().json();
6643
+ } catch {
6644
+ return;
6645
+ }
6646
+ if (typeof body.svg !== "string") return;
6647
+ const { svg, applied } = applyLayoutToSvg(body.svg, deltas);
6648
+ if (!applied) return;
6649
+ c.res = c.json({ ...body, svg, meta: { ...body.meta ?? {}, layout: { lens, applied } } });
6650
+ };
6651
+ app.use("/api/graph", bakeHandLayout);
6652
+ app.use("/api/overlay", bakeHandLayout);
4203
6653
  app.get(
4204
6654
  "/api",
4205
6655
  (c) => c.json({
@@ -4211,8 +6661,29 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4211
6661
  { method: "GET", path: "/api/project", desc: "project info: dir, recents, environments, tiers, targets, stacks, preview lock" },
4212
6662
  { method: "POST", path: "/api/project/open", desc: "switch the served project: JSON body {dir} (validated; preview-locked)" },
4213
6663
  { method: "POST", path: "/api/project/reveal", desc: "open the OS file manager at a served/recent project dir: JSON body {dir?}" },
4214
- { 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" },
6664
+ { method: "GET", path: "/api/demos", desc: "the bundled demo catalog: [{name, description, requires, satisfiable, reason?, fetches, repo?, target, loaded}]" },
6665
+ {
6666
+ method: "POST",
6667
+ path: "/api/demos/open",
6668
+ 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)"
6669
+ },
6670
+ { 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" },
6671
+ ...cfg.carveReport ? [
6672
+ { method: "GET", path: "/api/carve", desc: "carve mode: the raw `chant carve advise --json` peelability report this server is rendering" },
6673
+ {
6674
+ method: "POST",
6675
+ path: "/api/carve/emit",
6676
+ desc: "carve demo only (#254): run `chant carve emit --state --select <addr>` into the demo copy \u2014 JSON body {select}; answers {artifacts, boundary, lint}"
6677
+ },
6678
+ {
6679
+ method: "POST",
6680
+ path: "/api/carve/bridge",
6681
+ desc: "carve demo only (#254): run `chant carve bridge` (never --apply-rewrites) \u2014 JSON body {select}; answers {runbook, proposals}"
6682
+ }
6683
+ ] : [],
4215
6684
  { method: "GET", path: "/api/overlay", desc: "live drift overlay for ?env= \u2014 same shape/params as /api/graph, plus runtime=1" },
6685
+ { method: "GET", path: "/api/layout", desc: "hand-layout sidecar (.behold/layout.json): ?lens=<key> \u2192 {lens, deltas, writable}; no lens \u2192 every lens" },
6686
+ { 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)" },
4216
6687
  { method: "GET", path: "/api/diff", desc: "per-node live diff for ?env= \u2014 {env, nodes: {<id>: {observed, diff, health, fieldDrift}}}" },
4217
6688
  { method: "GET", path: "/api/reconcile", desc: "pending-change summary for ?env=" },
4218
6689
  { method: "GET", path: "/api/resources", desc: "component \u2192 declared resources" },
@@ -4244,10 +6715,31 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4244
6715
  let mode;
4245
6716
  let metaEnv = cfg.env ?? null;
4246
6717
  if (multi) {
4247
- ir = await composeEstate(cfg.projectDirs, opts);
6718
+ ir = await composeEstate(cfg.projectDirs, logical ? { ...opts, detail: 3 } : opts);
4248
6719
  ir = addValueMatchEdges(ir);
4249
6720
  ir = addK8sDeclaredEdges(ir);
4250
- ir = addClusterAnchorEdges(ir, await boundK8sContext(metaEnv ?? void 0));
6721
+ const estateContext = await boundK8sContext(metaEnv ?? void 0);
6722
+ ir = addClusterAnchorEdges(ir, estateContext);
6723
+ if (logical) {
6724
+ const logicalBefore = ir.nodes.length;
6725
+ const { ir: projected, byContainer } = projectTopology(ir, metaEnv ?? void 0, estateContext, await estateSourceRoots(opts));
6726
+ const { svg: svg2 } = renderArchitecture(projected, byContainer);
6727
+ const logicalNote = notesFor("logical", projected, void 0, logicalBefore);
6728
+ return c.json({
6729
+ ir: projected,
6730
+ svg: svg2,
6731
+ byContainer,
6732
+ meta: {
6733
+ projectDir: cfg.projectDir,
6734
+ env: metaEnv,
6735
+ tier: opts.tier ?? null,
6736
+ target: opts.target ?? null,
6737
+ mode: "logical",
6738
+ estate: cfg.projectDirs.length,
6739
+ ...logicalNote ? { note: logicalNote } : {}
6740
+ }
6741
+ });
6742
+ }
4251
6743
  } else if (components) {
4252
6744
  ir = await componentGraphIr(cfg.projectDir, opts);
4253
6745
  const env = opts.env ?? cfg.env;
@@ -4283,13 +6775,13 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4283
6775
  srcCompositeEdgesAttached = 0;
4284
6776
  }
4285
6777
  }
4286
- if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync9(join10(cfg.projectDir, "chant.config.ts"))) {
6778
+ if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync12(join15(cfg.projectDir, "chant.config.ts"))) {
4287
6779
  return c.json(noProjectError(cfg.projectDir), 404);
4288
6780
  }
4289
6781
  const radial = new URL(c.req.url).searchParams.get("radial") === "1";
4290
6782
  const { svg } = renderGraph(ir, multi ? { boxes: "byStack" } : { radial });
4291
6783
  const srcZoom = components ? "components" : logical ? "logical" : opts.detail === 1 ? "composites" : opts.detail === 3 ? "attributes" : "resources";
4292
- const estateLensNote = multi && (components || logical) ? `the ${components ? "components" : "logical"} lens doesn't apply to a composed estate yet \u2014 showing the composed entity graph` : void 0;
6784
+ const estateLensNote = multi && components ? "the components lens doesn't apply to a composed estate yet \u2014 showing the composed entity graph" : void 0;
4293
6785
  const srcNote = multi ? estateLensNote : notesFor(srcZoom, ir, srcCompositeEdgesAttached);
4294
6786
  return c.json({
4295
6787
  ir,
@@ -4388,32 +6880,56 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4388
6880
  return c.json({ error: "overlay needs an environment \u2014 pick one, or start behold with --env <name>" }, 400);
4389
6881
  }
4390
6882
  const logical = new URL(c.req.url).searchParams.get("logical") === "1";
6883
+ const runtime = new URL(c.req.url).searchParams.get("runtime") === "1";
4391
6884
  try {
4392
6885
  if (cfg.projectDirs && cfg.projectDirs.length > 1) {
4393
- const runtime = new URL(c.req.url).searchParams.get("runtime") === "1";
4394
- const est = await composeEstateOverlay(cfg.projectDirs, { ...tierTargetOpts(query), detail: query.detail, env }, reclassifyOverlay);
6886
+ const detail = logical || runtime ? 3 : query.detail;
6887
+ const est = await composeEstateOverlay(cfg.projectDirs, { ...tierTargetOpts(query), detail, env }, reclassifyOverlay);
4395
6888
  if (est.dropped.length === est.total) {
4396
6889
  return c.json({ error: `no project in the estate could be graphed \u2014 ${est.dropped.map((d) => `${d.name}: ${d.reason}`).join("; ")}` }, 500);
4397
6890
  }
4398
- let ir2 = pruneRuntimeChildren(est.ir);
4399
- if ((query.detail ?? 2) < 3) ir2 = pruneImports(ir2);
6891
+ const composed = est.ir;
6892
+ let ir2 = runtime ? attachRuntimeContainment(composed) : pruneRuntimeChildren(composed);
6893
+ if ((detail ?? 2) < 3) ir2 = pruneImports(ir2);
4400
6894
  ir2 = addValueMatchEdges(ir2);
4401
6895
  ir2 = addK8sDeclaredEdges(ir2);
4402
- ir2 = addClusterAnchorEdges(ir2, await boundK8sContext(env));
4403
- const { svg: svg2 } = renderGraph(ir2, { boxes: "byStack" });
4404
- const lensNote = logical || runtime ? `the ${logical ? "logical" : "runtime"} lens doesn't apply to a composed estate yet \u2014 showing the composed entity overlay` : void 0;
6896
+ const boundContext2 = await boundK8sContext(env);
6897
+ ir2 = addClusterAnchorEdges(ir2, boundContext2);
4405
6898
  const coverNote = est.unobserved.length || est.dropped.length ? `live observe covered ${est.observed} of ${est.total} projects \u2014 ` + [
4406
6899
  ...est.unobserved.map((u) => `${u.name}: ${u.reason} (painted unobserved)`),
4407
6900
  ...est.dropped.map((d) => `${d.name}: dropped (${d.reason})`)
4408
6901
  ].join("; ") : void 0;
4409
- const note2 = [lensNote, coverNote, namespaceMismatchNote(ir2.nodes)].filter(Boolean).join(" \xB7 ");
6902
+ if (logical) {
6903
+ const logicalBefore = ir2.nodes.length;
6904
+ const { ir: projected, byContainer } = projectTopology(ir2, env, boundContext2, await estateSourceRoots(query));
6905
+ const { svg: svg3 } = renderArchitecture(projected, byContainer);
6906
+ const note3 = [
6907
+ notesFor("logical", projected, void 0, logicalBefore),
6908
+ coverNote,
6909
+ namespaceJoinNote(est.joined),
6910
+ namespaceMismatchNote(withoutJoinedMembers(projected.nodes, est.joined))
6911
+ ].filter(Boolean).join(" \xB7 ");
6912
+ return c.json({
6913
+ ir: projected,
6914
+ svg: svg3,
6915
+ byContainer,
6916
+ meta: { projectDir: cfg.projectDir, env, mode: "logical", estate: est.total, ...note3 ? { note: note3 } : {} }
6917
+ });
6918
+ }
6919
+ const { svg: svg2 } = renderGraph(ir2, { boxes: runtime ? "byContainer" : "byStack" });
6920
+ const note2 = [
6921
+ coverNote,
6922
+ namespaceJoinNote(est.joined),
6923
+ namespaceMismatchNote(withoutJoinedMembers(ir2.nodes, est.joined)),
6924
+ runtime ? notesFor("runtime", ir2, void 0, void 0, detail) : void 0
6925
+ ].filter(Boolean).join(" \xB7 ");
4410
6926
  return c.json({
4411
6927
  ir: ir2,
4412
6928
  svg: svg2,
4413
6929
  meta: { projectDir: cfg.projectDir, env, mode: "overlay", estate: est.total, ...note2 ? { note: note2 } : {} }
4414
6930
  });
4415
6931
  }
4416
- const opts = { ...query, live: true, overlay: true, env, ...logical ? { detail: 3 } : {} };
6932
+ const opts = { ...query, live: true, overlay: true, env, ...logical || runtime ? { detail: 3 } : {} };
4417
6933
  let ir = reclassifyOverlay(await graphIr(cfg.projectDir, opts));
4418
6934
  const boundContext = await boundK8sContext(env);
4419
6935
  ir = mergeClusterRoot(ir, await clusterRootGraphIr(cfg.projectDir, query), await runningK3dClusters());
@@ -4423,7 +6939,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4423
6939
  applyHelmArtifacts(ir, observed);
4424
6940
  synthesizeHelmReleases(ir, observed, discoverReleaseUnits(cfg.projectDir));
4425
6941
  }
4426
- ir = new URL(c.req.url).searchParams.get("runtime") === "1" ? attachRuntimeContainment(ir) : pruneRuntimeChildren(ir);
6942
+ ir = runtime ? attachRuntimeContainment(ir) : pruneRuntimeChildren(ir);
4427
6943
  if (logical) {
4428
6944
  const logicalBefore = ir.nodes.length;
4429
6945
  const { ir: projected, byContainer } = projectTopology(addK8sDeclaredEdges(addValueMatchEdges(ir)), env, boundContext, [await graphPath(cfg.projectDir, opts), cfg.projectDir]);
@@ -4448,10 +6964,10 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4448
6964
  }
4449
6965
  }
4450
6966
  const { svg } = renderGraph(ir, { boxes: "byContainer", radial: new URL(c.req.url).searchParams.get("radial") === "1" });
4451
- const zoom = new URL(c.req.url).searchParams.get("runtime") === "1" ? "runtime" : query.detail === 1 ? "composites" : query.detail === 3 ? "attributes" : "resources";
6967
+ const zoom = runtime ? "runtime" : query.detail === 1 ? "composites" : query.detail === 3 ? "attributes" : "resources";
4452
6968
  const tierNote = tierMismatchNote(ir, beholdConfig.tiers, query.tier);
4453
6969
  const nsNote = namespaceMismatchNote(ir.nodes);
4454
- const zoomNotes = notesFor(zoom, ir, compositeEdgesAttached);
6970
+ const zoomNotes = notesFor(zoom, ir, compositeEdgesAttached, void 0, opts.detail ?? 2);
4455
6971
  const note = [tierNote, nsNote, zoomNotes].filter(Boolean).join(" \xB7 ");
4456
6972
  return c.json({ ir, svg, meta: { projectDir: cfg.projectDir, env, mode: "overlay", ...note ? { note } : {} } });
4457
6973
  } catch (err) {
@@ -4512,10 +7028,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4512
7028
  const nodes = {};
4513
7029
  for (const id of ids) {
4514
7030
  const observed = nodeObserved(parsed, id);
7031
+ const verdict = classifyObservedHealth(observed);
4515
7032
  nodes[id] = {
4516
7033
  observed,
4517
7034
  diff: nodeDiff(parsed, id),
4518
- health: classifyHealth(observed?.status),
7035
+ health: verdict.health,
7036
+ ...verdict.detail ? { healthDetail: verdict.detail } : {},
4519
7037
  // Field-level (per-manager) drift (#87, chant#1181) — null when no
4520
7038
  // lexicon in this diff carries a `deep` section at all.
4521
7039
  fieldDrift: nodeFieldDrift(parsed, id)
@@ -4539,12 +7057,14 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4539
7057
  return c.json({ error: "diff output was not JSON \u2014 chant may predate --live --json (needs 0.18.7+)" }, 500);
4540
7058
  }
4541
7059
  const observed = nodeObserved(parsed, node);
7060
+ const verdict = classifyObservedHealth(observed);
4542
7061
  return c.json({
4543
7062
  node,
4544
7063
  env,
4545
7064
  diff: nodeDiff(parsed, node),
4546
7065
  observed,
4547
- health: classifyHealth(observed?.status),
7066
+ health: verdict.health,
7067
+ ...verdict.detail ? { healthDetail: verdict.detail } : {},
4548
7068
  // Field-level (per-manager) drift (#87, chant#1181) — null when no
4549
7069
  // lexicon in this diff carries a `deep` section at all.
4550
7070
  fieldDrift: nodeFieldDrift(parsed, node)
@@ -4593,38 +7113,38 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4593
7113
  }
4594
7114
  return c.json({ started: true, component, env });
4595
7115
  });
4596
- const rel = relative(process.cwd(), webRoot) || ".";
7116
+ const rel = relative3(process.cwd(), webRoot) || ".";
4597
7117
  app.use("/*", serveStatic({ root: rel }));
4598
- app.get("/", serveStatic({ path: join10(rel, "index.html") }));
7118
+ app.get("/", serveStatic({ path: join15(rel, "index.html") }));
4599
7119
  return app;
4600
7120
  }
4601
- async function startServer(cfg) {
4602
- if (cfg.local) {
4603
- try {
4604
- const emulators = await emulatorUp(cfg.projectDir);
4605
- cfg.emulators = emulators;
4606
- if (emulators.length === 0) {
4607
- process.stderr.write(
4608
- "behold serve --local: no configured lexicon has a local emulator \u2014 serving without one.\n"
4609
- );
4610
- } else {
4611
- Object.assign(process.env, mergedEnv(emulators));
4612
- for (const e of emulators) {
4613
- process.stdout.write(` local: ${e.lexicon} ${e.name} up on ${e.endpoint}
7121
+ async function bootLocalEmulators(dir, who) {
7122
+ try {
7123
+ const emulators = await emulatorUp(dir);
7124
+ if (emulators.length === 0) {
7125
+ process.stderr.write(`${who}: no configured lexicon has a local emulator \u2014 serving without one.
4614
7126
  `);
4615
- }
4616
- }
4617
- } catch (err) {
4618
- cfg.emulators = [];
4619
- process.stderr.write(
4620
- `behold serve --local: ${err instanceof Error ? err.message : String(err)}
7127
+ return [];
7128
+ }
7129
+ Object.assign(process.env, mergedEnv(emulators));
7130
+ for (const e of emulators) {
7131
+ process.stdout.write(` local: ${e.lexicon} ${e.name} up on ${e.endpoint}
7132
+ `);
7133
+ }
7134
+ return emulators;
7135
+ } catch (err) {
7136
+ process.stderr.write(
7137
+ `${who}: ${err instanceof Error ? err.message : String(err)}
4621
7138
  Serving the source graph without the emulator \u2014 start Docker and restart to enable local deploys.
4622
7139
  `
4623
- );
4624
- }
7140
+ );
7141
+ return [];
4625
7142
  }
7143
+ }
7144
+ async function startServer(cfg) {
7145
+ if (cfg.local) cfg.emulators = await bootLocalEmulators(cfg.projectDir, "behold serve --local");
4626
7146
  for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
4627
- if (existsSync9(join10(dir, "chant.config.ts"))) addRecent(dir);
7147
+ if (existsSync12(join15(dir, "chant.config.ts"))) addRecent(dir);
4628
7148
  }
4629
7149
  const broadcaster = new Broadcaster();
4630
7150
  const frames = new FrameBuffer();
@@ -4666,8 +7186,10 @@ async function startServer(cfg) {
4666
7186
  }
4667
7187
  }
4668
7188
  };
4669
- let stopWatch = watchSource(cfg.projectDir, onEstateChange);
4670
- let stopPoll = cfg.env && cfg.pollSecs ? startDriftPoll({
7189
+ const carve = !!cfg.carveReport;
7190
+ let stopWatch = carve ? () => {
7191
+ } : watchSource(cfg.projectDir, onEstateChange);
7192
+ let stopPoll = !carve && cfg.env && cfg.pollSecs ? startDriftPoll({
4671
7193
  intervalMs: cfg.pollSecs * 1e3,
4672
7194
  query: () => graphIr(cfg.projectDir, { live: true, overlay: true, env: cfg.env }),
4673
7195
  onChange: onPollDrift,
@@ -4685,7 +7207,7 @@ async function startServer(cfg) {
4685
7207
  `);
4686
7208
  void capture();
4687
7209
  };
4688
- void capture();
7210
+ if (!carve) void capture();
4689
7211
  let shuttingDown = false;
4690
7212
  const shutdown = () => {
4691
7213
  if (shuttingDown) return;
@@ -4699,6 +7221,19 @@ async function startServer(cfg) {
4699
7221
  process.on("SIGINT", shutdown);
4700
7222
  process.on("SIGTERM", shutdown);
4701
7223
  const server = serve({ fetch: app.fetch, port: cfg.port }, (info) => {
7224
+ if (carve) {
7225
+ process.stdout.write(
7226
+ `behold \u2192 http://localhost:${info.port}
7227
+ carve report: ${cfg.carveReport}
7228
+ green = carve now, amber = boundary work, grey = leave in Terraform.
7229
+ ` + (cfg.carveDemo ? ` walkthrough: the panel's Carve tab \u2014 advise \u2192 pick \u2192 emit \u2192 bridge \u2192 handoff \u2192 done.
7230
+ Emit and bridge write only into ${cfg.carveDemo.out}; your Terraform is never edited.
7231
+ ` + (cfg.carveDemo.degraded ? ` degraded: ${cfg.carveDemo.degraded}
7232
+ ` : "") : ` Read-only advisory: behold emits nothing and touches no Terraform. Ctrl-C to stop.
7233
+ `)
7234
+ );
7235
+ return;
7236
+ }
4702
7237
  const poll = cfg.env && cfg.pollSecs ? `, polling drift every ${cfg.pollSecs}s` : "";
4703
7238
  const auto = autoSync !== "off" ? ` auto-sync: ${autoSync}` : "";
4704
7239
  const localTag = cfg.emulators && cfg.emulators.length ? ` local: ${cfg.emulators.map((e) => e.name).join(", ")} up (creds-free \u2014 deploys hit the emulator)` : "";
@@ -4729,53 +7264,17 @@ async function startServer(cfg) {
4729
7264
  });
4730
7265
  }
4731
7266
 
4732
- // src/demos.ts
4733
- import { readFileSync as readFileSync8 } from "node:fs";
4734
- import { join as join11 } from "node:path";
4735
- import { spawnSync } from "node:child_process";
4736
- function loadDemoRegistry(pkgRoot) {
4737
- let raw;
4738
- try {
4739
- raw = JSON.parse(readFileSync8(join11(pkgRoot, "demos.json"), "utf8"));
4740
- } catch {
4741
- return [];
4742
- }
4743
- const list = raw?.demos;
4744
- if (!Array.isArray(list)) return [];
4745
- return list.filter((e) => {
4746
- const d = e;
4747
- if (!d || typeof d.name !== "string" || !d.name || typeof d.description !== "string") return false;
4748
- if (d.source === "bundled") {
4749
- if (typeof d.dir !== "string" || !d.dir) return false;
4750
- } else if (d.source === "git") {
4751
- if (typeof d.repo !== "string" || !d.repo) return false;
4752
- } else {
4753
- return false;
4754
- }
4755
- if (!Array.isArray(d.requires) || d.requires.some((r) => typeof r !== "string")) return false;
4756
- if (!d.serve || typeof d.serve !== "object") return false;
4757
- if (d.serve.dirs !== void 0 && (!Array.isArray(d.serve.dirs) || d.serve.dirs.some((x) => typeof x !== "string") || !d.serve.dirs.length))
4758
- return false;
4759
- return true;
4760
- });
4761
- }
4762
- function missingRequirements(entry) {
4763
- const bins = entry.source === "git" && !entry.requires.includes("git") ? [...entry.requires, "git"] : entry.requires;
4764
- const finder = process.platform === "win32" ? "where" : "which";
4765
- return bins.filter((bin) => spawnSync(finder, [bin], { stdio: "ignore" }).status !== 0);
4766
- }
4767
-
4768
7267
  // src/export.ts
4769
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync9, readdirSync as readdirSync4 } from "node:fs";
4770
- import { join as join12, dirname as dirname4, basename } from "node:path";
4771
- import { fileURLToPath as fileURLToPath2 } from "node:url";
7268
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync3, copyFileSync, readFileSync as readFileSync13, readdirSync as readdirSync5 } from "node:fs";
7269
+ import { join as join16, dirname as dirname5, basename } from "node:path";
7270
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
4772
7271
  var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
4773
7272
  function canonicalKey(path, params) {
4774
7273
  const flat = params.get("components") === "1" || params.get("logical") === "1";
4775
7274
  const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial"))).map((k) => `${k}=${params.get(k)}`).join("&");
4776
7275
  return q ? `${path}?${q}` : path;
4777
7276
  }
4778
- function slug(key) {
7277
+ function slug2(key) {
4779
7278
  const base = key.replace(/^\//, "").replace(/[^a-zA-Z0-9=_.-]+/g, "_").slice(0, 120);
4780
7279
  return `${base}.json`;
4781
7280
  }
@@ -4815,7 +7314,7 @@ function captureKeys(axes) {
4815
7314
  return [...keys];
4816
7315
  }
4817
7316
  function webDir() {
4818
- return join12(dirname4(fileURLToPath2(import.meta.url)), "..", "web");
7317
+ return join16(dirname5(fileURLToPath4(import.meta.url)), "..", "web");
4819
7318
  }
4820
7319
  function workerName(project, override) {
4821
7320
  const raw = override ?? `behold-${basename(project)}`;
@@ -4823,19 +7322,19 @@ function workerName(project, override) {
4823
7322
  return name || "behold-export";
4824
7323
  }
4825
7324
  async function runExport(cfg, outDir, opts = {}) {
4826
- const app = createApp(cfg);
7325
+ const app = createApp({ ...cfg, layoutWrites: false });
4827
7326
  const proj = await (await app.request("/api/project")).json();
4828
7327
  const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
4829
- const snapDir = join12(outDir, "snapshots");
4830
- mkdirSync2(snapDir, { recursive: true });
7328
+ const snapDir = join16(outDir, "snapshots");
7329
+ mkdirSync4(snapDir, { recursive: true });
4831
7330
  const keyToFile = {};
4832
7331
  let ok = 0;
4833
7332
  let failed = 0;
4834
7333
  for (const key of captureKeys(axes)) {
4835
- const res = await app.request(key);
7334
+ const res = await app.request(`${key}${key.includes("?") ? "&" : "?"}layout=1`);
4836
7335
  const body = await res.text();
4837
- const file = slug(key);
4838
- writeFileSync2(join12(snapDir, file), body);
7336
+ const file = slug2(key);
7337
+ writeFileSync3(join16(snapDir, file), body);
4839
7338
  keyToFile[key] = `snapshots/${file}`;
4840
7339
  if (res.ok) ok++;
4841
7340
  else failed++;
@@ -4847,21 +7346,21 @@ async function runExport(cfg, outDir, opts = {}) {
4847
7346
  axes,
4848
7347
  keyToFile
4849
7348
  };
4850
- writeFileSync2(join12(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
4851
- const html = readFileSync9(join12(webDir(), "index.html"), "utf8").replace(
7349
+ writeFileSync3(join16(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
7350
+ const html = readFileSync13(join16(webDir(), "index.html"), "utf8").replace(
4852
7351
  /<\/head>/i,
4853
7352
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
4854
7353
  </head>`
4855
7354
  );
4856
- writeFileSync2(join12(outDir, "index.html"), html);
4857
- for (const f of readdirSync4(webDir())) {
7355
+ writeFileSync3(join16(outDir, "index.html"), html);
7356
+ for (const f of readdirSync5(webDir())) {
4858
7357
  if (f === "index.html") continue;
4859
- copyFileSync(join12(webDir(), f), join12(outDir, f));
7358
+ copyFileSync(join16(webDir(), f), join16(outDir, f));
4860
7359
  }
4861
- writeFileSync2(join12(outDir, "README.md"), BUNDLE_README);
7360
+ writeFileSync3(join16(outDir, "README.md"), BUNDLE_README);
4862
7361
  const name = workerName(cfg.projectDir, opts.name);
4863
- writeFileSync2(
4864
- join12(outDir, "wrangler.jsonc"),
7362
+ writeFileSync3(
7363
+ join16(outDir, "wrangler.jsonc"),
4865
7364
  JSON.stringify(
4866
7365
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
4867
7366
  null,
@@ -4908,14 +7407,248 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
4908
7407
  (\`wrangler pages deploy .\`) all work.
4909
7408
  `;
4910
7409
 
7410
+ // src/doctor.ts
7411
+ import { relative as relative4, resolve as resolve6 } from "node:path";
7412
+ var list2 = (xs) => xs.join(", ");
7413
+ var EMPTY_KUBECONFIG = { contexts: /* @__PURE__ */ new Map(), servers: /* @__PURE__ */ new Map() };
7414
+ function summarize(xs, max = 4) {
7415
+ return xs.length <= max ? list2(xs) : `${list2(xs.slice(0, max))}, +${xs.length - max} more`;
7416
+ }
7417
+ function labelFor(root, target, estate) {
7418
+ if (!estate) return "";
7419
+ const rel = relative4(root, target);
7420
+ return `${rel || "."}: `;
7421
+ }
7422
+ function chantCheck(root, targets, estate) {
7423
+ const floor = chantFloor();
7424
+ const resolutions = targets.map((t) => ({ target: t, res: resolveChant(t) }));
7425
+ const missing = resolutions.filter(({ res }) => res.source !== "project");
7426
+ const describe = ({ target, res }) => `${labelFor(root, target, estate)}${res.source === "project" ? `chant ${res.version ?? "(unknown version)"}` : "no chant installed"}`;
7427
+ if (missing.length) {
7428
+ return {
7429
+ name: "chant",
7430
+ status: "fail",
7431
+ detail: `${list2(resolutions.map(describe))} \u2014 behold shells the project's own chant`,
7432
+ fix: `Run \`npm install\` in ${list2(missing.map(({ target }) => relative4(root, target) || target))}`
7433
+ };
7434
+ }
7435
+ const stale = resolutions.filter(({ res }) => !meetsFloor(res.version, floor));
7436
+ const versions = list2(resolutions.map(describe));
7437
+ if (stale.length) {
7438
+ return {
7439
+ name: "chant",
7440
+ status: "warn",
7441
+ detail: `${versions} \u2014 below behold's floor ${floor}`,
7442
+ fix: "Bump @intentius/chant in the project (`npm install @intentius/chant@latest`) \u2014 older versions observe less."
7443
+ };
7444
+ }
7445
+ return { name: "chant", status: "pass", detail: `${versions}${floor ? ` (behold's floor ${floor})` : ""}` };
7446
+ }
7447
+ function lexiconCheck(root, declared, estate) {
7448
+ const missing = [];
7449
+ const found = [];
7450
+ for (const [target, lexicons] of declared) {
7451
+ const label = labelFor(root, target, estate);
7452
+ for (const r of resolveLexicons(target, lexicons)) {
7453
+ (r.installed ? found : missing).push(`${label}${r.lexicon}${r.installed && r.version ? ` ${r.version}` : ""}`);
7454
+ }
7455
+ }
7456
+ if (missing.length) {
7457
+ return {
7458
+ name: "lexicons",
7459
+ status: "fail",
7460
+ detail: `declared but not installed: ${list2(missing)}${found.length ? ` (installed: ${list2(found)})` : ""}`,
7461
+ fix: "Run `npm install` in the project \u2014 chant can't graph source importing a lexicon it can't resolve."
7462
+ };
7463
+ }
7464
+ if (!found.length) {
7465
+ return { name: "lexicons", status: "pass", detail: "none declared" };
7466
+ }
7467
+ return { name: "lexicons", status: "pass", detail: `${found.length} installed: ${list2(found)}` };
7468
+ }
7469
+ function envCheck(envs, serveArg) {
7470
+ if (!envs.length) {
7471
+ return {
7472
+ name: "envs",
7473
+ status: "warn",
7474
+ detail: "no environments declared \u2014 the declared graph serves, but there is no live overlay to pick",
7475
+ fix: "Declare `environments` (or `k8s.profiles`) in chant.config.ts, then serve with `--env <name>`."
7476
+ };
7477
+ }
7478
+ return { name: "envs", status: "pass", detail: `${list2(envs)} \u2014 \`behold serve ${serveArg} --env ${envs[0]}\` for the live overlay` };
7479
+ }
7480
+ function kubeCheck(lexicons, profiles, envs, kubeconfig) {
7481
+ if (!lexicons.includes("k8s") && !lexicons.includes("helm")) {
7482
+ return { name: "kube", status: "pass", detail: "no k8s or helm lexicon \u2014 no cluster binding to check" };
7483
+ }
7484
+ const ambient = kubeconfig.currentContext;
7485
+ if (!kubeconfig.contexts.size) {
7486
+ return {
7487
+ name: "kube",
7488
+ status: "warn",
7489
+ detail: "no kubeconfig readable (none on this machine, or it has no contexts) \u2014 the declared graph still serves",
7490
+ fix: "Point KUBECONFIG at your cluster's kubeconfig (or put one at ~/.kube/config), or run `behold demo k8s` for a throwaway k3d one."
7491
+ };
7492
+ }
7493
+ const bound = envs.filter((e) => profiles[e]?.context);
7494
+ if (!bound.length) {
7495
+ return {
7496
+ name: "kube",
7497
+ status: "warn",
7498
+ detail: `no k8s.profiles binding \u2014 chant falls back to the ambient current-context${ambient ? ` (${ambient})` : " (none set)"}`,
7499
+ fix: "Declare `k8s.profiles.<env>.context` in chant.config.ts so the cluster the reads hit is explicit, not ambient."
7500
+ };
7501
+ }
7502
+ const lines = [];
7503
+ const unresolved = [];
7504
+ const mismatched = [];
7505
+ for (const env of bound) {
7506
+ const context = profiles[env].context;
7507
+ const target = resolveK8sTarget(profiles, env, kubeconfig);
7508
+ if (!target) {
7509
+ unresolved.push(`${env}: ${context}`);
7510
+ continue;
7511
+ }
7512
+ lines.push(`${env}: ${target.label} at ${target.endpoint}`);
7513
+ if (ambient && ambient !== target.label) mismatched.push(`${env}: ${target.label}`);
7514
+ }
7515
+ if (unresolved.length) {
7516
+ return {
7517
+ name: "kube",
7518
+ status: "warn",
7519
+ detail: `context not in the kubeconfig \u2014 ${list2(unresolved)}${lines.length ? `; bound: ${list2(lines)}` : ""}`,
7520
+ fix: `Add the context to your kubeconfig, or point k8s.profiles at one you have (${summarize([...kubeconfig.contexts.keys()])}).`
7521
+ };
7522
+ }
7523
+ if (mismatched.length) {
7524
+ return {
7525
+ name: "kube",
7526
+ status: "warn",
7527
+ detail: `bound ${list2(lines)}, but the ambient current-context is ${ambient} \u2014 chant refuses a live read on a mismatch`,
7528
+ fix: `Run \`kubectl config use-context ${mismatched[0].split(": ")[1]}\` before the live overlay.`
7529
+ };
7530
+ }
7531
+ return { name: "kube", status: "pass", detail: `bound ${list2(lines)}` };
7532
+ }
7533
+ function substrateCheck(substrates) {
7534
+ if (!substrates.length) return { name: "substrates", status: "pass", detail: "none needed by this project" };
7535
+ const detail = list2(substrates.map((s) => `${s.label} ${s.status}`));
7536
+ const down = substrates.filter((s) => s.status === "down" || s.status === "blocked");
7537
+ if (!down.length) return { name: "substrates", status: "pass", detail };
7538
+ const named = down.map((s) => `${s.label} (${s.bringUp ? `${s.bringUp.cmd} ${s.bringUp.args.join(" ")}` : s.detail})`);
7539
+ return {
7540
+ name: "substrates",
7541
+ status: "warn",
7542
+ detail,
7543
+ fix: `Bring up: ${list2(named)} \u2014 or \`behold serve <dir> --local\`, which boots the project's own emulators (needs Docker).`
7544
+ };
7545
+ }
7546
+ function opsCheck(root, ops, chantSource, estate) {
7547
+ const mcp = chantSource === "project" ? "chant MCP available (the project's own chant)" : "chant MCP unavailable until the project's chant is installed";
7548
+ if (!ops.length) {
7549
+ return {
7550
+ name: "ops",
7551
+ status: "warn",
7552
+ detail: `no committed Ops (*.op.ts) \u2014 behold can show drift but has nothing to trigger; ${mcp}`,
7553
+ fix: "Add an ApplyOp (code to cloud) or ReconcileOp (cloud to PR) under ops/ \u2014 behold triggers your Ops, it holds no apply creds."
7554
+ };
7555
+ }
7556
+ const names = ops.map((o) => `${labelFor(root, o.dir, estate)}${o.name} (${o.kind})`);
7557
+ return { name: "ops", status: "pass", detail: `${ops.length} committed: ${list2(names)}; ${mcp}` };
7558
+ }
7559
+ async function diagnose(dir, probes = {}) {
7560
+ const root = resolve6(dir);
7561
+ const shape = detectProjectShape(root);
7562
+ const behold = beholdVersion();
7563
+ if (shape.kind === "none") {
7564
+ return {
7565
+ behold,
7566
+ dir: root,
7567
+ kind: "none",
7568
+ ok: false,
7569
+ checks: [
7570
+ {
7571
+ name: "project",
7572
+ status: "fail",
7573
+ detail: `no chant.config.ts here, and no estate members declared (.behold.json \`members\`, or npm workspaces)`,
7574
+ fix: "Point behold at a chant project (`behold doctor <dir>`), or run `behold demo` for a bundled working example."
7575
+ }
7576
+ ]
7577
+ };
7578
+ }
7579
+ const estate = shape.kind === "estate";
7580
+ const members = (shape.members ?? []).map((m) => resolve6(root, m));
7581
+ const targets = estate ? members : [root];
7582
+ const primary = targets[0];
7583
+ const projectCheck = estate ? {
7584
+ name: "project",
7585
+ status: "pass",
7586
+ detail: `estate of ${members.length} projects (${shape.membersFrom === "behold-config" ? ".behold.json members" : "npm workspaces"}): ${list2(
7587
+ members.map((m) => relative4(root, m))
7588
+ )}`
7589
+ } : { name: "project", status: "pass", detail: `chant project (${relative4(root, shape.configFile)})` };
7590
+ const infos = await Promise.all(targets.map(async (t) => ({ target: t, info: await detectProject(t) })));
7591
+ const declared = new Map(infos.map(({ target, info }) => [target, info.lexicons]));
7592
+ const lexicons = [...new Set(infos.flatMap(({ info }) => info.lexicons))];
7593
+ const envs = [...new Set(infos.flatMap(({ info }) => info.environments))];
7594
+ const profiles = {};
7595
+ for (const { info } of infos) for (const [env, p] of Object.entries(info.k8sProfiles ?? {})) profiles[env] ??= p;
7596
+ const kubeconfig = lexicons.includes("k8s") || lexicons.includes("helm") ? await (probes.loadKubeconfig ?? loadKubeconfig)() : EMPTY_KUBECONFIG;
7597
+ const kube = kubeCheck(lexicons, profiles, envs, kubeconfig);
7598
+ const boundContext = resolveK8sTarget(profiles, envs[0], kubeconfig)?.label;
7599
+ const substrates = await (probes.detectSubstrates ?? detectSubstrates)(primary, false, boundContext);
7600
+ const checks = [
7601
+ projectCheck,
7602
+ chantCheck(root, targets, estate),
7603
+ lexiconCheck(root, declared, estate),
7604
+ // An estate root is not itself servable — the hint has to name its members
7605
+ // (`behold serve a b c`, #31), which is what a stranger would otherwise
7606
+ // discover by having the root serve nothing.
7607
+ envCheck(envs, estate ? shape.members.map((m) => `${dir.replace(/\/$/, "")}/${m}`).join(" ") : dir),
7608
+ kube,
7609
+ substrateCheck(substrates),
7610
+ opsCheck(root, discoverEstateOps(targets), resolveChant(primary).source, estate)
7611
+ ];
7612
+ return { behold, dir: root, kind: shape.kind, ok: !checks.some((c) => c.status === "fail"), checks };
7613
+ }
7614
+ function formatReport(report) {
7615
+ const lines = [`behold doctor ${report.behold} \u2014 ${report.dir}`, ""];
7616
+ for (const c of report.checks) {
7617
+ lines.push(` ${c.status.padEnd(4)} ${c.name.padEnd(10)} ${c.detail}`);
7618
+ if (c.fix) lines.push(` ${"fix".padEnd(10)} ${c.fix}`);
7619
+ }
7620
+ const tally = (s) => report.checks.filter((c) => c.status === s).length;
7621
+ lines.push("", ` ${tally("pass")} pass, ${tally("warn")} warn, ${tally("fail")} fail`, "");
7622
+ return lines.join("\n");
7623
+ }
7624
+
4911
7625
  // src/cli.ts
4912
7626
  var USAGE = `behold \u2014 a live control plane on chant (read-only core)
4913
7627
 
4914
7628
  Usage:
4915
7629
  behold demo [name] [target-dir] [--port <n>] [--list]
7630
+ behold doctor [project-dir] [--json]
4916
7631
  behold preview [project-dir] [--port <n>] [--emulator]
4917
7632
  behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
4918
7633
  behold serve <project-dir\u2026> [--port <n>] [--env <name>] [--poll <secs>] [--local]
7634
+ behold carve <report.json> [--port <n>]
7635
+
7636
+ carve Render a chant Terraform peelability report \u2014 the JSON from
7637
+ \`chant carve advise --from <tf-dir> --json\` \u2014 as a graph. One card
7638
+ per ranked resource, coloured by band: green = carve now, amber =
7639
+ has boundary work, grey = leave in Terraform. Click a card for the
7640
+ score arithmetic behind its rank. Read-only twice over: chant's
7641
+ advisor emits nothing, and behold only draws what it says.
7642
+ \`GET /api/carve\` serves the raw report to agents. behold parses no
7643
+ Terraform and needs no Terraform tooling \u2014 the report is the contract.
7644
+
7645
+ doctor Why won't this project serve well? A read-only diagnosis of
7646
+ everything behold needs \u2014 the project's kind, its own chant install
7647
+ and version, declared lexicons, the envs the picker will infer, the
7648
+ kube context chant binds versus the ambient one, substrate
7649
+ readiness, committed Ops \u2014 each line pass/warn/fail with a one-line
7650
+ fix. Defaults to the current directory. Exits 0 iff nothing failed;
7651
+ --json for machines. Starts no server and changes nothing.
4919
7652
 
4920
7653
  demo The five-minute path from npm \u2014 no chant project needed. A catalog
4921
7654
  of demo estates (behold demo --list): bundled ones copy out of the
@@ -4923,9 +7656,13 @@ Usage:
4923
7656
  clone a public estate. Bare \`behold demo\` is the AWS example \u2014 an
4924
7657
  S3 bucket + policy on a local emulator: blue = declared, click
4925
7658
  Deploy, watch it turn green. \`behold demo k8s\` stands a workload
4926
- up on a throwaway k3d cluster instead. Needs Docker (and per-demo
4927
- tools --list names). Loaded demos land in the panel's recents, so
4928
- switching between them is the Scope tab.
7659
+ up on a throwaway k3d cluster instead. \`behold demo carve\` is the
7660
+ odd one out: no cluster, no Docker, no cloud \u2014 a half-migrated
7661
+ Terraform/chant estate plus the six-step carve walkthrough on the
7662
+ panel's Carve tab. Needs Docker (and per-demo tools --list names).
7663
+ Loaded demos land in the panel's recents, so switching between them
7664
+ is the Scope tab \u2014 which lists this whole catalog too (#268), one
7665
+ click from any served project.
4929
7666
 
4930
7667
  export Capture the live estate into a self-contained, interactive STATIC
4931
7668
  bundle (default ./behold-export) \u2014 every env/tier \xD7 zoom \xD7 radial,
@@ -4946,7 +7683,7 @@ Usage:
4946
7683
  overlay, and rollback act on it).
4947
7684
 
4948
7685
  Options:
4949
- --port <n> Port (default 4600). preview/serve.
7686
+ --port <n> Port (default 4600). preview/serve/carve.
4950
7687
  --env <name> Environment name \u2014 turns on the live drift overlay.
4951
7688
  export/serve.
4952
7689
  --poll <secs> Re-query live drift every <secs> and push updates (needs --env).
@@ -4977,13 +7714,15 @@ Options:
4977
7714
  names, unlike --local; kept separate because Loom's own
4978
7715
  \`scripts/local/local-up.sh\` Floci setup clashes on :4566
4979
7716
  with chant's generic \`chant emulator up\`.
7717
+ --json doctor only: the report as JSON (stable keys) instead of
7718
+ the console lines \u2014 the AGENTS.md audience.
4980
7719
  --out <dir> export only: output directory (default ./behold-export).
4981
7720
  --name <worker> export only: Cloudflare Worker name in the generated
4982
7721
  wrangler.jsonc.
4983
7722
  -h, --help This text.
4984
7723
  -v, --version Print the behold version.
4985
7724
  `;
4986
- async function run3(argv) {
7725
+ async function run2(argv) {
4987
7726
  const [cmd, ...rest] = argv;
4988
7727
  if (!cmd || cmd === "-h" || cmd === "--help") {
4989
7728
  process.stdout.write(USAGE);
@@ -4997,6 +7736,10 @@ async function run3(argv) {
4997
7736
  await runDemo(rest);
4998
7737
  return;
4999
7738
  }
7739
+ if (cmd === "doctor") {
7740
+ await runDoctor(rest);
7741
+ return;
7742
+ }
5000
7743
  if (cmd === "preview") {
5001
7744
  await runPreview(rest);
5002
7745
  return;
@@ -5005,6 +7748,10 @@ async function run3(argv) {
5005
7748
  await runExportCmd(rest);
5006
7749
  return;
5007
7750
  }
7751
+ if (cmd === "carve") {
7752
+ await runCarve(rest);
7753
+ return;
7754
+ }
5008
7755
  if (cmd !== "serve") {
5009
7756
  process.stderr.write(`behold: unknown command '${cmd}'
5010
7757
 
@@ -5060,7 +7807,7 @@ ${USAGE}`);
5060
7807
  process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
5061
7808
  process.exit(2);
5062
7809
  }
5063
- const dirs = projectDirs.map((d) => resolve3(d));
7810
+ const dirs = projectDirs.map((d) => resolve7(d));
5064
7811
  for (const d of dirs) warnIfNotChantProject(d);
5065
7812
  await startServer({
5066
7813
  projectDir: dirs[0],
@@ -5074,16 +7821,86 @@ ${USAGE}`);
5074
7821
  });
5075
7822
  }
5076
7823
  function warnIfNotChantProject(dir) {
5077
- if (existsSync10(join13(dir, "chant.config.ts"))) return;
7824
+ const shape = detectProjectShape(dir);
7825
+ if (shape.kind === "project") return;
7826
+ if (shape.kind === "estate") {
7827
+ process.stderr.write(
7828
+ `behold: warning \u2014 ${dir} is an estate root, not a chant project itself.
7829
+ Serve its members composed: behold serve ${shape.members.map((m) => join17(dir, m)).join(" ")}
7830
+ `
7831
+ );
7832
+ return;
7833
+ }
5078
7834
  process.stderr.write(
5079
7835
  `behold: warning \u2014 ${dir} has no chant.config.ts; this doesn't look like a chant project.
5080
- No project yet? \`behold demo\` serves a bundled working example (needs Docker).
7836
+ \`behold doctor ${dir}\` says what's missing. No project yet? \`behold demo\` serves a bundled working example (needs Docker).
7837
+ `
7838
+ );
7839
+ }
7840
+ async function runCarve(rest) {
7841
+ let port = 4600;
7842
+ let fileArg;
7843
+ for (let i = 0; i < rest.length; i++) {
7844
+ const a = rest[i];
7845
+ if (a === "--port") port = Number(rest[++i]);
7846
+ else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
7847
+ else if (!a.startsWith("-")) fileArg = a;
7848
+ else {
7849
+ process.stderr.write(`behold carve: unexpected argument '${a}'
7850
+ `);
7851
+ process.exit(2);
7852
+ }
7853
+ }
7854
+ if (!Number.isFinite(port)) {
7855
+ process.stderr.write("behold carve: --port must be a number\n");
7856
+ process.exit(2);
7857
+ }
7858
+ if (!fileArg) {
7859
+ process.stderr.write(
7860
+ "behold carve: missing <report.json>\n Generate one: chant carve advise --from <terraform-dir> --report report.json\n"
7861
+ );
7862
+ process.exit(2);
7863
+ }
7864
+ const reportPath = resolve7(fileArg);
7865
+ const parsed = readCarveReport(reportPath, (p) => readFileSync14(p, "utf8"));
7866
+ if (!parsed.ok) {
7867
+ process.stderr.write(`behold carve: ${parsed.refusal.error}
7868
+ ${parsed.refusal.remedy}
7869
+ `);
7870
+ process.exit(2);
7871
+ }
7872
+ process.stdout.write(
7873
+ `behold carve \u2014 ${parsed.report.resources.length} resource(s)/module(s) ranked${parsed.report.from ? ` from ${parsed.report.from}` : ""}
5081
7874
  `
5082
7875
  );
7876
+ await startServer({ projectDir: dirname6(reportPath), carveReport: reportPath, port });
7877
+ }
7878
+ async function runDoctor(rest) {
7879
+ let json = false;
7880
+ let dirArg;
7881
+ for (const a of rest) {
7882
+ if (a === "--json") json = true;
7883
+ else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
7884
+ else if (!a.startsWith("-")) dirArg = a;
7885
+ else {
7886
+ process.stderr.write(`behold doctor: unexpected argument '${a}'
7887
+ `);
7888
+ process.exit(2);
7889
+ }
7890
+ }
7891
+ const dir = dirArg ?? ".";
7892
+ if (!existsSync13(resolve7(dir))) {
7893
+ process.stderr.write(`behold doctor: no such directory: ${resolve7(dir)}
7894
+ `);
7895
+ process.exit(2);
7896
+ }
7897
+ const report = await diagnose(dir);
7898
+ process.stdout.write(json ? JSON.stringify(report, null, 2) + "\n" : formatReport(report));
7899
+ if (!report.ok) process.exitCode = 1;
5083
7900
  }
5084
7901
  async function runDemo(rest) {
5085
- const pkgRoot = join13(dirname5(fileURLToPath3(import.meta.url)), "..");
5086
- const registry = loadDemoRegistry(pkgRoot);
7902
+ const pkgRoot2 = join17(dirname6(fileURLToPath5(import.meta.url)), "..");
7903
+ const registry = loadDemoRegistry(pkgRoot2);
5087
7904
  let port = 4600;
5088
7905
  let name;
5089
7906
  let dirArg;
@@ -5134,64 +7951,93 @@ async function runDemo(rest) {
5134
7951
  `);
5135
7952
  process.exit(2);
5136
7953
  }
5137
- const target = resolve3(
5138
- dirArg ?? (entry.name === "writes" && existsSync10("behold-demo") ? "behold-demo" : join13("behold-demos", entry.name))
5139
- );
5140
- if (!existsSync10(target)) {
5141
- if (entry.source === "bundled") {
5142
- const bundled = join13(pkgRoot, entry.dir);
5143
- if (!existsSync10(bundled)) {
5144
- process.stderr.write(`behold demo ${entry.name}: this install has no bundled ${entry.dir}
7954
+ const target = dirArg ? resolve7(dirArg) : demoTargetDir(entry);
7955
+ const loaded = await loadDemo(entry, { pkgRoot: pkgRoot2, target, log: (line) => process.stdout.write(line + "\n") });
7956
+ if (!loaded.ok) {
7957
+ process.stderr.write(`behold demo ${entry.name}: ${loaded.error}
5145
7958
  `);
5146
- process.exit(2);
5147
- }
5148
- process.stdout.write(`behold demo ${entry.name} \u2192 copying to ${target} (it's yours \u2014 edit it)
7959
+ process.exit(1);
7960
+ }
7961
+ if (entry.serve.carve) {
7962
+ await serveCarveDemo(target, entry.serve.carve, port);
7963
+ return;
7964
+ }
7965
+ process.stdout.write(`behold demo ${entry.name} \u2192 serving. Blue = declared; Deploy turns it green.
5149
7966
  `);
5150
- cpSync(bundled, target, {
5151
- recursive: true,
5152
- filter: (src) => !relative2(bundled, src).split(sep).includes("node_modules")
5153
- });
5154
- } else {
5155
- process.stdout.write(`behold demo ${entry.name} \u2192 cloning ${entry.repo} to ${target}
7967
+ const serveArgs = ["serve", ...loaded.serveDirs, "--port", String(port)];
7968
+ if (entry.serve.local) serveArgs.push("--local");
7969
+ if (entry.serve.env) serveArgs.push("--env", entry.serve.env);
7970
+ await run2(serveArgs);
7971
+ }
7972
+ function spawnStep(cmd, args, cwd) {
7973
+ return new Promise((res) => {
7974
+ const child = spawn5(cmd, args, { stdio: "inherit", cwd, shell: process.platform === "win32" });
7975
+ child.on("error", () => res(-1));
7976
+ child.on("close", (code) => res(code ?? 1));
7977
+ });
7978
+ }
7979
+ async function serveCarveDemo(target, carve, port) {
7980
+ const at = (rel) => resolve7(target, rel);
7981
+ const project = at(carve.project);
7982
+ const from = at(carve.from);
7983
+ const state = carve.state ? at(carve.state) : void 0;
7984
+ const committed = at(carve.report);
7985
+ if (existsSync13(join17(project, "package.json")) && !existsSync13(join17(project, "node_modules"))) {
7986
+ process.stdout.write(`behold demo carve \u2192 npm install in ${carve.project}/ (the chant this walkthrough shells)\u2026
5156
7987
  `);
5157
- const r = spawnSync2("git", ["clone", "--depth", "1", entry.repo, target], { stdio: "inherit" });
5158
- if (r.status !== 0) {
5159
- process.stderr.write(`behold demo ${entry.name}: clone failed
7988
+ const code = await spawnStep("npm", ["install"], project);
7989
+ if (code !== 0) {
7990
+ process.stderr.write(`behold demo carve: npm install failed in ${project}
5160
7991
  `);
5161
- process.exit(r.status ?? 1);
5162
- }
7992
+ process.exit(code || 1);
5163
7993
  }
5164
- } else {
5165
- process.stdout.write(`behold demo ${entry.name} \u2192 reusing ${target}
5166
- `);
5167
7994
  }
5168
- if (existsSync10(join13(target, "package.json")) && !existsSync10(join13(target, "node_modules"))) {
5169
- process.stdout.write(`behold demo ${entry.name} \u2192 npm install\u2026
5170
- `);
5171
- const r = spawnSync2("npm", ["install"], { cwd: target, stdio: "inherit", shell: process.platform === "win32" });
5172
- if (r.status !== 0) {
5173
- process.stderr.write(`behold demo: npm install failed in ${target}${r.error ? ` (${r.error.message})` : ""}
5174
- `);
5175
- process.exit(r.status ?? 1);
5176
- }
7995
+ let degraded;
7996
+ if (!existsSync13(join17(target, "node_modules", "@cdktf", "hcl2json"))) {
7997
+ process.stdout.write("behold demo carve \u2192 npm install @cdktf/hcl2json (chant's HCL parser, ~2MB, once)\u2026\n");
7998
+ const code = await spawnStep("npm", ["install", "--no-save", "--no-package-lock", "@cdktf/hcl2json"], target);
7999
+ if (code !== 0) degraded = "couldn't install @cdktf/hcl2json (chant's HCL parser) \u2014 no network?";
8000
+ }
8001
+ const report = at("carve-report.json");
8002
+ if (!degraded) {
8003
+ const bin = resolveChant(project).bin;
8004
+ process.stdout.write("behold demo carve \u2192 chant carve advise (read-only; emits nothing)\u2026\n");
8005
+ const code = await spawnStep(
8006
+ bin,
8007
+ ["carve", "advise", "--from", carve.from, ...carve.state ? ["--state", carve.state] : [], "--report", report],
8008
+ target
8009
+ );
8010
+ if (code !== 0) degraded = `chant carve advise exited ${code}`;
5177
8011
  }
5178
- if (entry.setup) {
5179
- process.stdout.write(`behold demo ${entry.name} \u2192 ${entry.setup}
5180
- `);
5181
- const r = spawnSync2(entry.setup, { cwd: target, stdio: "inherit", shell: true });
5182
- if (r.status !== 0) {
5183
- process.stderr.write(`behold demo ${entry.name}: setup failed (${entry.setup})
5184
- `);
5185
- process.exit(r.status ?? 1);
5186
- }
8012
+ const serving = !degraded && existsSync13(report) ? report : committed;
8013
+ if (degraded) {
8014
+ process.stderr.write(
8015
+ `behold demo carve: ${degraded}
8016
+ Serving the committed report (${carve.report}) instead \u2014 the bands are real, just not regenerated here.
8017
+ `
8018
+ );
5187
8019
  }
5188
- process.stdout.write(`behold demo ${entry.name} \u2192 serving. Blue = declared; Deploy turns it green.
8020
+ if (!existsSync13(serving)) {
8021
+ process.stderr.write(`behold demo carve: no carve report at ${serving}
5189
8022
  `);
5190
- const serveDirs = entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join13(target, d)) : [target];
5191
- const serveArgs = ["serve", ...serveDirs, "--port", String(port)];
5192
- if (entry.serve.local) serveArgs.push("--local");
5193
- if (entry.serve.env) serveArgs.push("--env", entry.serve.env);
5194
- await run3(serveArgs);
8023
+ process.exit(2);
8024
+ }
8025
+ process.stdout.write(
8026
+ "behold demo carve \u2192 serving the walkthrough. Green = carve now; the Carve tab walks the six steps.\n"
8027
+ );
8028
+ await startServer({
8029
+ projectDir: target,
8030
+ carveReport: serving,
8031
+ carveDemo: {
8032
+ root: target,
8033
+ from,
8034
+ ...state ? { state } : {},
8035
+ project,
8036
+ out: at(carve.out),
8037
+ ...degraded ? { degraded: `${degraded} \u2014 showing the committed report shipped with the demo.` } : {}
8038
+ },
8039
+ port
8040
+ });
5195
8041
  }
5196
8042
  function injectEmulatorEnv(env) {
5197
8043
  process.env.LOOM_ENV ??= env ?? "local";
@@ -5215,8 +8061,8 @@ async function runPreview(rest) {
5215
8061
  process.stderr.write("behold preview: --port must be a number\n");
5216
8062
  process.exit(2);
5217
8063
  }
5218
- const projectDir = resolve3(dirArg ?? process.cwd());
5219
- if (!existsSync10(projectDir)) {
8064
+ const projectDir = resolve7(dirArg ?? process.cwd());
8065
+ if (!existsSync13(projectDir)) {
5220
8066
  process.stderr.write(`behold preview: project not found at ${projectDir}
5221
8067
  `);
5222
8068
  process.exit(2);
@@ -5236,26 +8082,26 @@ async function runPreview(rest) {
5236
8082
  await startServer({ projectDir, port, env: "local", previewMode: true });
5237
8083
  }
5238
8084
  async function runExportCmd(rest) {
5239
- let outDir = resolve3("behold-export");
8085
+ let outDir = resolve7("behold-export");
5240
8086
  let env;
5241
8087
  let name;
5242
8088
  let dirArg;
5243
8089
  let emulator = false;
5244
8090
  for (let i = 0; i < rest.length; i++) {
5245
8091
  const a = rest[i];
5246
- if (a === "--out") outDir = resolve3(rest[++i]);
8092
+ if (a === "--out") outDir = resolve7(rest[++i]);
5247
8093
  else if (a === "--env") env = rest[++i];
5248
8094
  else if (a === "--name") name = rest[++i];
5249
8095
  else if (a === "--emulator") emulator = true;
5250
8096
  else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
5251
8097
  else if (!a.startsWith("-")) dirArg = a;
5252
8098
  }
5253
- const projectDir = resolve3(dirArg ?? process.cwd());
8099
+ const projectDir = resolve7(dirArg ?? process.cwd());
5254
8100
  if (emulator) {
5255
8101
  injectEmulatorEnv(env);
5256
8102
  env ??= "local";
5257
8103
  }
5258
- if (!existsSync10(projectDir)) {
8104
+ if (!existsSync13(projectDir)) {
5259
8105
  process.stderr.write(`behold export: project not found at ${projectDir}
5260
8106
  `);
5261
8107
  process.exit(2);
@@ -5266,18 +8112,18 @@ function isMainModule() {
5266
8112
  const entry = process.argv[1];
5267
8113
  if (!entry) return false;
5268
8114
  try {
5269
- return realpathSync(entry) === realpathSync(fileURLToPath3(import.meta.url));
8115
+ return realpathSync(entry) === realpathSync(fileURLToPath5(import.meta.url));
5270
8116
  } catch {
5271
8117
  return false;
5272
8118
  }
5273
8119
  }
5274
8120
  if (isMainModule()) {
5275
- run3(process.argv.slice(2)).catch((err) => {
8121
+ run2(process.argv.slice(2)).catch((err) => {
5276
8122
  process.stderr.write(`behold: fatal: ${err?.message ?? err}
5277
8123
  `);
5278
8124
  process.exit(3);
5279
8125
  });
5280
8126
  }
5281
8127
  export {
5282
- run3 as run
8128
+ run2 as run
5283
8129
  };