@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.
- package/AGENTS.md +94 -2
- package/README.md +133 -2
- package/demos.json +25 -1
- package/dist/cli.js +3176 -330
- package/example-argo-estate/README.md +71 -0
- package/example-argo-estate/app-a/chant.config.ts +18 -0
- package/example-argo-estate/app-a/manifests/app.yaml +54 -0
- package/example-argo-estate/app-a/package.json +13 -0
- package/example-argo-estate/app-a/src/app.ts +35 -0
- package/example-argo-estate/app-a/tsconfig.json +1 -0
- package/example-argo-estate/app-b/chant.config.ts +18 -0
- package/example-argo-estate/app-b/manifests/app.yaml +54 -0
- package/example-argo-estate/app-b/package.json +13 -0
- package/example-argo-estate/app-b/src/app.ts +34 -0
- package/example-argo-estate/app-b/tsconfig.json +1 -0
- package/example-argo-estate/control-plane/chant.config.ts +29 -0
- package/example-argo-estate/control-plane/package.json +13 -0
- package/example-argo-estate/control-plane/src/argo.ts +69 -0
- package/example-argo-estate/control-plane/tsconfig.json +1 -0
- package/example-argo-estate/package-lock.json +1949 -0
- package/example-argo-estate/package.json +10 -0
- package/example-carve/README.md +194 -0
- package/example-carve/app/chant.config.ts +6 -0
- package/example-carve/app/package-lock.json +1075 -0
- package/example-carve/app/package.json +13 -0
- package/example-carve/app/src/carved.ts +30 -0
- package/example-carve/app/tsconfig.json +1 -0
- package/example-carve/carve-report.json +872 -0
- package/example-carve/legacy-tf/cdn.tf +23 -0
- package/example-carve/legacy-tf/compute.tf +63 -0
- package/example-carve/legacy-tf/floci-override.tf.disabled +61 -0
- package/example-carve/legacy-tf/modules/cdn/main.tf +72 -0
- package/example-carve/legacy-tf/naming.tf +10 -0
- package/example-carve/legacy-tf/network.tf +119 -0
- package/example-carve/legacy-tf/observability.tf +18 -0
- package/example-carve/legacy-tf/outputs.tf +16 -0
- package/example-carve/legacy-tf/storage.tf +33 -0
- package/example-carve/legacy-tf/terraform.tfstate +602 -0
- package/example-carve/legacy-tf/versions.tf +40 -0
- package/example-flux-estate/README.md +22 -12
- package/example-flux-estate/app-a/package.json +2 -2
- package/example-flux-estate/app-a/src/app.ts +2 -1
- package/example-flux-estate/app-b/chant.config.ts +4 -3
- package/example-flux-estate/app-b/package.json +2 -2
- package/example-flux-estate/app-b/src/app.ts +5 -3
- package/example-flux-estate/control-plane/package.json +2 -2
- package/example-flux-estate/control-plane/src/flux.ts +10 -2
- package/example-flux-estate/package-lock.json +17 -17
- package/example-k8s/README.md +16 -2
- package/example-k8s/base/deployment.yaml +21 -0
- package/example-k8s/base/kustomization.yaml +3 -0
- package/example-k8s/base/service.yaml +12 -0
- package/example-k8s/chant.config.ts +10 -0
- package/example-k8s/overlays/dev/kustomization.yaml +6 -0
- package/example-k8s/package-lock.json +18 -18
- package/example-k8s/package.json +3 -3
- package/example-writes/package-lock.json +14 -14
- package/example-writes/package.json +3 -3
- package/package.json +9 -6
- package/web/app.js +1027 -42
- package/web/carve-steps.js +610 -0
- package/web/carve-steps.test.js +233 -0
- package/web/demos.js +71 -0
- package/web/demos.test.js +83 -0
- package/web/icons/cncf/argo.svg +1 -0
- package/web/icons/cncf/flux.svg +1 -0
- package/web/icons/cncf/helm.svg +1 -0
- package/web/icons/k8s/c-role.svg +1 -0
- package/web/icons/k8s/cm.svg +1 -0
- package/web/icons/k8s/crb.svg +1 -0
- package/web/icons/k8s/crd.svg +1 -0
- package/web/icons/k8s/cronjob.svg +1 -0
- package/web/icons/k8s/deploy.svg +1 -0
- package/web/icons/k8s/ds.svg +1 -0
- package/web/icons/k8s/ep.svg +1 -0
- package/web/icons/k8s/group.svg +1 -0
- package/web/icons/k8s/hpa.svg +1 -0
- package/web/icons/k8s/ing.svg +1 -0
- package/web/icons/k8s/job.svg +1 -0
- package/web/icons/k8s/limits.svg +1 -0
- package/web/icons/k8s/netpol.svg +1 -0
- package/web/icons/k8s/ns.svg +1 -0
- package/web/icons/k8s/pod.svg +1 -0
- package/web/icons/k8s/psp.svg +1 -0
- package/web/icons/k8s/pv.svg +1 -0
- package/web/icons/k8s/pvc.svg +1 -0
- package/web/icons/k8s/quota.svg +1 -0
- package/web/icons/k8s/rb.svg +1 -0
- package/web/icons/k8s/role.svg +1 -0
- package/web/icons/k8s/rs.svg +1 -0
- package/web/icons/k8s/sa.svg +1 -0
- package/web/icons/k8s/sc.svg +1 -0
- package/web/icons/k8s/secret.svg +1 -0
- package/web/icons/k8s/sts.svg +1 -0
- package/web/icons/k8s/svc.svg +1 -0
- package/web/icons/k8s/user.svg +1 -0
- package/web/icons/k8s/vol.svg +1 -0
- package/web/index.html +285 -85
- package/web/json-view.js +334 -0
- package/web/json-view.test.js +218 -0
- package/web/layout-store.js +278 -0
- package/web/layout-store.test.js +388 -0
- package/web/panel.js +28 -0
- package/web/theme.js +114 -6
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
// The carve walkthrough's stepper (#254, M1.5 of #230) — the panel's Carve tab.
|
|
2
|
+
//
|
|
3
|
+
// Six steps over one estate: advise → pick → emit → bridge → handoff → done.
|
|
4
|
+
// behold runs the two SAFE ones (emit, bridge — both write only into the demo
|
|
5
|
+
// copy, both read-only with respect to your Terraform) and deliberately does
|
|
6
|
+
// NOT run the destructive middle: `terraform state rm` releases Terraform's
|
|
7
|
+
// claim on a live resource, and that stays a human decision made in a human's
|
|
8
|
+
// terminal. Step 5 is a list of commands with copy buttons, and it says why.
|
|
9
|
+
//
|
|
10
|
+
// Same discipline as web/json-view.js and web/layout-store.js: everything the
|
|
11
|
+
// stepper DECIDES lives in a pure function that takes data and returns data, so
|
|
12
|
+
// it is unit-testable next door in carve-steps.test.js; the DOM half below is
|
|
13
|
+
// smoke/ui-smoke.mjs's job.
|
|
14
|
+
//
|
|
15
|
+
// The walkthrough's state is client-side only, exactly as #254 specifies — the
|
|
16
|
+
// server holds no session. Reload and you are back at Advise, with whatever the
|
|
17
|
+
// previous run already wrote still sitting in the demo copy.
|
|
18
|
+
|
|
19
|
+
/** The six steps, in order. `id` is the stable handle (tests, the ⌘K twins). */
|
|
20
|
+
export const CARVE_STEPS = [
|
|
21
|
+
{
|
|
22
|
+
id: "advise",
|
|
23
|
+
label: "Advise",
|
|
24
|
+
blurb: "chant scored every resource in the Terraform half. Green carves cheaply; grey stays where it is.",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "pick",
|
|
28
|
+
label: "Pick",
|
|
29
|
+
blurb: "Click a green card in the graph. The inspect pane shows the arithmetic behind its score.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "emit",
|
|
33
|
+
label: "Emit",
|
|
34
|
+
blurb: "Adopt it from the tfstate into typed chant source. Nothing is applied and nothing is destroyed.",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "bridge",
|
|
38
|
+
label: "Bridge",
|
|
39
|
+
blurb: "Generate the surviving Terraform's patch — a data source, and the references rewired to read it.",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "handoff",
|
|
43
|
+
label: "Handoff",
|
|
44
|
+
blurb: "Two commands you run yourself. behold copies them; it does not press them.",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "done",
|
|
48
|
+
label: "Done",
|
|
49
|
+
blurb: "chant-owned, observe position. `terraform import` reverses all of it.",
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** A fresh walkthrough. */
|
|
54
|
+
export function initialCarveState() {
|
|
55
|
+
return {
|
|
56
|
+
/** Index into CARVE_STEPS. */
|
|
57
|
+
step: 0,
|
|
58
|
+
/** The report resource the user picked, plus its IR node. */
|
|
59
|
+
pick: null,
|
|
60
|
+
/** POST /api/carve/emit's answer. */
|
|
61
|
+
emit: null,
|
|
62
|
+
/** POST /api/carve/bridge's answer. */
|
|
63
|
+
bridge: null,
|
|
64
|
+
/** Set when the user says they ran the handoff commands. */
|
|
65
|
+
handoff: false,
|
|
66
|
+
/** Which step is mid-run ("emit" | "bridge" | null) — the dial's progress. */
|
|
67
|
+
busy: null,
|
|
68
|
+
/** The last structured refusal, keyed by step id. */
|
|
69
|
+
error: null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Has this step's work actually happened? Distinct from "we walked past it". */
|
|
74
|
+
export function completed(state, id) {
|
|
75
|
+
switch (id) {
|
|
76
|
+
case "advise":
|
|
77
|
+
return state.step > 0;
|
|
78
|
+
case "pick":
|
|
79
|
+
return !!state.pick;
|
|
80
|
+
case "emit":
|
|
81
|
+
return !!state.emit;
|
|
82
|
+
case "bridge":
|
|
83
|
+
return !!state.bridge;
|
|
84
|
+
case "handoff":
|
|
85
|
+
return !!state.handoff;
|
|
86
|
+
case "done":
|
|
87
|
+
return !!state.handoff && !!state.bridge;
|
|
88
|
+
default:
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Why this step can't be entered yet, or null. The gates are the real data
|
|
95
|
+
* dependencies, not a wizard's insistence on order: bridge genuinely reads the
|
|
96
|
+
* carve manifest emit leaves in the output directory, and the runbook genuinely
|
|
97
|
+
* does not exist until bridge writes it.
|
|
98
|
+
*/
|
|
99
|
+
export function blockedReason(state, id) {
|
|
100
|
+
switch (id) {
|
|
101
|
+
case "emit":
|
|
102
|
+
return state.pick ? null : "Pick a resource first — Emit carves the one you selected.";
|
|
103
|
+
case "bridge":
|
|
104
|
+
return state.emit ? null : "Run Emit first — bridge reads the carve manifest emit leaves in the output directory.";
|
|
105
|
+
case "handoff":
|
|
106
|
+
return state.bridge ? null : "Run Bridge first — the runbook is the file it writes.";
|
|
107
|
+
case "done":
|
|
108
|
+
return state.bridge ? null : "The carve isn't proposed yet — Emit and Bridge come first.";
|
|
109
|
+
default:
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** How a step's button paints: current | done | blocked | todo. */
|
|
115
|
+
export function stepStatus(state, index) {
|
|
116
|
+
const id = CARVE_STEPS[index] && CARVE_STEPS[index].id;
|
|
117
|
+
if (!id) return "todo";
|
|
118
|
+
if (state.step === index) return "current";
|
|
119
|
+
if (completed(state, id)) return "done";
|
|
120
|
+
return blockedReason(state, id) ? "blocked" : "todo";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Every boundary edge a report resource declares, in either shape chant may
|
|
124
|
+
* publish (a flat list, or split by direction). Mirrors src/carve-lens.ts's
|
|
125
|
+
* `boundaryEdgesOf` — the same leniency, for the same reason. */
|
|
126
|
+
export function boundaryEdgesOf(resource) {
|
|
127
|
+
const b = resource && resource.boundary;
|
|
128
|
+
if (!b) return [];
|
|
129
|
+
const list = Array.isArray(b) ? b : [...(b.inbound || []), ...(b.outbound || [])];
|
|
130
|
+
return list.filter((e) => e && typeof e.survivor === "string" && typeof e.carved === "string");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One boundary edge as a sentence. */
|
|
134
|
+
export function edgeLine(e) {
|
|
135
|
+
const dir = e.direction === "outbound" ? "outbound" : "inbound";
|
|
136
|
+
const attrs = (e.attrs || []).join(", ");
|
|
137
|
+
const via = (e.via || []).join(", ");
|
|
138
|
+
const head =
|
|
139
|
+
dir === "inbound"
|
|
140
|
+
? `${e.survivor} reads ${attrs || "this resource"}`
|
|
141
|
+
: `this resource reads ${attrs || "something"} from ${e.survivor}`;
|
|
142
|
+
const where = via ? ` (in its ${via})` : "";
|
|
143
|
+
const fix = e.bridge === "deferred-input" ? "becomes a deploy-time input" : "becomes a Terraform data source";
|
|
144
|
+
const when = e.required === "at-apply" ? ", deferred until apply" : e.required === "immediately" ? ", needed immediately" : "";
|
|
145
|
+
return `${dir} — ${head}${where} → ${fix}${when}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* What the Pick step can honestly say about the cut this carve would make.
|
|
150
|
+
*
|
|
151
|
+
* `known: true` when the report carries the edge lists (chant#1636) — then the
|
|
152
|
+
* survivors are named. `known: false` on every chant that publishes only the
|
|
153
|
+
* per-resource COUNTS, and then the step says the counts, names the cut from
|
|
154
|
+
* the breakdown, and says plainly that the survivors aren't in this report
|
|
155
|
+
* rather than inventing them. "None" and "not reported" are different claims,
|
|
156
|
+
* and a walkthrough that blurs them teaches the wrong thing about the tool.
|
|
157
|
+
*/
|
|
158
|
+
export function cutSummary(resource, node) {
|
|
159
|
+
const edges = boundaryEdgesOf(resource);
|
|
160
|
+
if (edges.length) {
|
|
161
|
+
return { known: true, items: edges.map(edgeLine), note: null };
|
|
162
|
+
}
|
|
163
|
+
// The counts come off the raw report when it's in hand and off the IR node's
|
|
164
|
+
// own attrs otherwise — the lens puts `inbound`/`outbound` on every card, so
|
|
165
|
+
// the step never has to wait for the extra `/api/carve` fetch to say
|
|
166
|
+
// something true. (`??`, not `||`: a real 0 is an answer.)
|
|
167
|
+
const attrs = (node && node.attrs) || {};
|
|
168
|
+
const b = (resource && resource.breakdown) || {};
|
|
169
|
+
const inbound = b.inbound ?? attrs.inbound ?? 0;
|
|
170
|
+
const outbound = b.outbound ?? attrs.outbound ?? 0;
|
|
171
|
+
const items = [];
|
|
172
|
+
if (inbound) items.push(`${inbound} inbound — a survivor reads this; each needs a Terraform data-source patch, immediately.`);
|
|
173
|
+
if (outbound) items.push(`${outbound} outbound — this reads a survivor; each becomes a deferred deploy-time input.`);
|
|
174
|
+
if (!items.length) items.push("No boundary edges at all — nothing to patch, nothing to defer.");
|
|
175
|
+
return {
|
|
176
|
+
known: false,
|
|
177
|
+
items,
|
|
178
|
+
note:
|
|
179
|
+
inbound || outbound
|
|
180
|
+
? "This report carries boundary COUNTS, not the edge lists (chant#1636), so the survivors can't be named here. " +
|
|
181
|
+
"The Emit step's own boundary report names them."
|
|
182
|
+
: null,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The facts the Pick step shows about the selected resource. `node` is the IR
|
|
187
|
+
* node (which already carries the arithmetic the lens spelled out); `resource`
|
|
188
|
+
* is the raw report entry. Either may be missing. */
|
|
189
|
+
export function pickFacts(node, resource) {
|
|
190
|
+
const attrs = (node && node.attrs) || {};
|
|
191
|
+
const b = (resource && resource.breakdown) || {};
|
|
192
|
+
const facts = [
|
|
193
|
+
["address", (resource && resource.address) || (node && node.id) || "—"],
|
|
194
|
+
["score", `${resource ? resource.score : attrs.score} — ${resource ? resource.band : attrs.band}`],
|
|
195
|
+
];
|
|
196
|
+
if (attrs.arithmetic) facts.push(["arithmetic", String(attrs.arithmetic)]);
|
|
197
|
+
const mapsTo = (resource && resource.mapsTo) || attrs.mapsTo;
|
|
198
|
+
if (mapsTo) facts.push(["maps to", String(mapsTo)]);
|
|
199
|
+
if (b.tier || attrs.tier) facts.push(["tier", String(b.tier ?? attrs.tier)]);
|
|
200
|
+
return facts.map(([label, value]) => ({ label, value: String(value) }));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Is this resource one the walkthrough can actually carve? A grey card with no
|
|
204
|
+
* native mapping scores 0 and `carve emit` refuses it — say so at Pick time
|
|
205
|
+
* rather than letting the Emit button produce chant's error. */
|
|
206
|
+
export function carveable(resource, node) {
|
|
207
|
+
const attrs = (node && node.attrs) || {};
|
|
208
|
+
const tier = resource && resource.breakdown ? resource.breakdown.tier : attrs.tier;
|
|
209
|
+
const mapsTo = (resource && resource.mapsTo) || attrs.mapsTo;
|
|
210
|
+
if (tier === null || tier === "none" || tier === undefined) {
|
|
211
|
+
if (!mapsTo) return "no known native mapping — chant has nothing to carve this into, so Emit would refuse.";
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The runnable commands out of a `carve bridge` runbook, in order, deduped.
|
|
218
|
+
*
|
|
219
|
+
* chant's runbook is markdown: `## <n>. <heading>` sections whose commands are
|
|
220
|
+
* indented four spaces. Annotations (a `#` comment, a parenthetical) sit at the
|
|
221
|
+
* same indent, so they are dropped from the command list and a trailing `# …`
|
|
222
|
+
* is kept as the row's note. Deduped on the command text — the runbook prints
|
|
223
|
+
* `terraform plan` twice on purpose, and two identical copy buttons are noise.
|
|
224
|
+
*/
|
|
225
|
+
export function runbookCommands(markdown) {
|
|
226
|
+
const out = [];
|
|
227
|
+
const seen = new Set();
|
|
228
|
+
let section = "";
|
|
229
|
+
for (const raw of String(markdown || "").split(/\r?\n/)) {
|
|
230
|
+
const heading = /^#{2,3}\s+(.*\S)\s*$/.exec(raw);
|
|
231
|
+
if (heading) {
|
|
232
|
+
section = heading[1];
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const indented = /^ {4}(\S.*?)\s*$/.exec(raw);
|
|
236
|
+
if (!indented) continue;
|
|
237
|
+
const body = indented[1];
|
|
238
|
+
if (body.startsWith("#") || body.startsWith("(")) continue;
|
|
239
|
+
const hash = body.indexOf(" #");
|
|
240
|
+
const command = (hash > 0 ? body.slice(0, hash) : body).trim();
|
|
241
|
+
const note = hash > 0 ? body.slice(hash + 2).trim() : "";
|
|
242
|
+
if (!command || seen.has(command)) continue;
|
|
243
|
+
seen.add(command);
|
|
244
|
+
out.push({ section, command, note });
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** How `chant lint` reads on the Emit panel. */
|
|
250
|
+
export function lintVerdict(lint) {
|
|
251
|
+
if (!lint) return null;
|
|
252
|
+
// chant's own summary line (`⚠ 3 warnings`), not the per-finding lines above
|
|
253
|
+
// it — an unanchored `(\d+) warnings?` happily matches the column number in
|
|
254
|
+
// `5:1 warning …` and reports "1 warning" for a file with three.
|
|
255
|
+
const warnings = /^\s*\S?\s*(\d+)\s+warnings?\b/m.exec(lint.output || "");
|
|
256
|
+
if (lint.ok) {
|
|
257
|
+
return {
|
|
258
|
+
tone: "good",
|
|
259
|
+
text: warnings ? `chant lint: passes, ${warnings[1]} warning(s)` : "chant lint: passes",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return { tone: "bad", text: `chant lint exited ${lint.code} — the emitted source doesn't pass yet` };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// The DOM half. Plain browser JS, no build step, themed entirely by the shared
|
|
267
|
+
// CSS vars — same rules as app.js. `actions` is the host's wiring:
|
|
268
|
+
// { go(index), runEmit(), runBridge(), markHandoff(), reset(), select(id), copy(text, el) }
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
const el = (tag, cls, text) => {
|
|
272
|
+
const n = document.createElement(tag);
|
|
273
|
+
if (cls) n.className = cls;
|
|
274
|
+
if (text !== undefined) n.textContent = text;
|
|
275
|
+
return n;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
function stepTrack(state, actions) {
|
|
279
|
+
const track = el("div", "dial-track");
|
|
280
|
+
CARVE_STEPS.forEach((s, i) => {
|
|
281
|
+
if (i) track.appendChild(el("span", "dial-arrow", "→"));
|
|
282
|
+
const status = stepStatus(state, i);
|
|
283
|
+
const running = state.busy === s.id;
|
|
284
|
+
const b = el("button", `dial-step carve-step ${status}`, running ? `${s.label} · running…` : status === "done" ? `${s.label} ✓` : s.label);
|
|
285
|
+
b.dataset.step = s.id;
|
|
286
|
+
b.dataset.status = status;
|
|
287
|
+
b.title = blockedReason(state, s.id) || s.blurb;
|
|
288
|
+
b.disabled = !!state.busy;
|
|
289
|
+
b.addEventListener("click", () => actions.go(i));
|
|
290
|
+
track.appendChild(b);
|
|
291
|
+
});
|
|
292
|
+
return track;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function factRows(facts) {
|
|
296
|
+
const wrap = el("div");
|
|
297
|
+
for (const f of facts) {
|
|
298
|
+
const row = el("div", "count-row");
|
|
299
|
+
row.appendChild(el("span", "grow", f.label));
|
|
300
|
+
row.appendChild(el("span", "tag", f.value));
|
|
301
|
+
wrap.appendChild(row);
|
|
302
|
+
}
|
|
303
|
+
return wrap;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** A code block. Always textContent — every string here is chant's own output
|
|
307
|
+
* or a file off disk, and none of it is markup. */
|
|
308
|
+
function pre(text, cls) {
|
|
309
|
+
const p = el("pre", `carve-pre${cls ? " " + cls : ""}`);
|
|
310
|
+
p.textContent = String(text || "");
|
|
311
|
+
return p;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function copyButton(label, text, actions) {
|
|
315
|
+
const b = el("button", "act carve-copy", label);
|
|
316
|
+
b.title = "Copy to the clipboard";
|
|
317
|
+
b.addEventListener("click", () => actions.copy(text, b));
|
|
318
|
+
return b;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function artifactBlock(a, actions) {
|
|
322
|
+
const wrap = el("div", "carve-artifact");
|
|
323
|
+
const head = el("div", "prow");
|
|
324
|
+
head.appendChild(el("span", "grow carve-path", a.path));
|
|
325
|
+
head.appendChild(el("span", "tag", `${a.bytes} B${a.truncated ? " (truncated)" : ""}`));
|
|
326
|
+
head.appendChild(copyButton("copy", a.text, actions));
|
|
327
|
+
wrap.append(head, pre(a.text, `lang-${a.kind}`));
|
|
328
|
+
return wrap;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function refusalBlock(err) {
|
|
332
|
+
const wrap = el("div", "carve-refusal");
|
|
333
|
+
wrap.appendChild(el("div", "carve-refusal-title", err.error || "that step didn't run"));
|
|
334
|
+
if (err.remedy) wrap.appendChild(el("p", "panel-muted", err.remedy));
|
|
335
|
+
return wrap;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Render the whole tab. `ctx` carries what the host knows and the stepper does
|
|
340
|
+
* not: the demo info off /api/project, the raw report, the picked IR node, and
|
|
341
|
+
* `renderJson` (web/json-view.js — the boundary report and the bridge plan are
|
|
342
|
+
* JSON, and every JSON value this page shows goes through one renderer).
|
|
343
|
+
*/
|
|
344
|
+
export function renderCarvePanel(host, state, ctx, actions) {
|
|
345
|
+
host.innerHTML = "";
|
|
346
|
+
const demo = ctx.demo || null;
|
|
347
|
+
|
|
348
|
+
host.appendChild(stepTrack(state, actions));
|
|
349
|
+
|
|
350
|
+
if (demo && demo.degraded) {
|
|
351
|
+
const warn = el("div", "carve-refusal");
|
|
352
|
+
warn.appendChild(el("div", "carve-refusal-title", "The demo's own advisor run didn't happen"));
|
|
353
|
+
warn.appendChild(el("p", "panel-muted", demo.degraded));
|
|
354
|
+
host.appendChild(warn);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const step = CARVE_STEPS[state.step] || CARVE_STEPS[0];
|
|
358
|
+
const body = el("div", "carve-body");
|
|
359
|
+
body.dataset.step = step.id;
|
|
360
|
+
host.appendChild(body);
|
|
361
|
+
body.appendChild(el("p", "panel-muted", step.blurb));
|
|
362
|
+
|
|
363
|
+
if (state.error && state.error.step === step.id) body.appendChild(refusalBlock(state.error));
|
|
364
|
+
|
|
365
|
+
const renderers = {
|
|
366
|
+
advise: () => renderAdvise(body, ctx),
|
|
367
|
+
pick: () => renderPick(body, state, ctx, actions),
|
|
368
|
+
emit: () => renderEmit(body, state, ctx, actions),
|
|
369
|
+
bridge: () => renderBridge(body, state, ctx, actions),
|
|
370
|
+
handoff: () => renderHandoff(body, state, actions),
|
|
371
|
+
done: () => renderDone(body, state, ctx, actions),
|
|
372
|
+
};
|
|
373
|
+
renderers[step.id]();
|
|
374
|
+
|
|
375
|
+
// Forward/back, so the whole thing is walkable without aiming at the track.
|
|
376
|
+
const nav = el("div", "prow carve-nav");
|
|
377
|
+
if (state.step > 0) {
|
|
378
|
+
const back = el("button", "act", "← back");
|
|
379
|
+
back.addEventListener("click", () => actions.go(state.step - 1));
|
|
380
|
+
nav.appendChild(back);
|
|
381
|
+
}
|
|
382
|
+
if (state.step < CARVE_STEPS.length - 1) {
|
|
383
|
+
const nextId = CARVE_STEPS[state.step + 1].id;
|
|
384
|
+
const why = blockedReason(state, nextId);
|
|
385
|
+
const next = el("button", "act carve-next", `next: ${CARVE_STEPS[state.step + 1].label} →`);
|
|
386
|
+
next.disabled = !!why || !!state.busy;
|
|
387
|
+
if (why) next.title = why;
|
|
388
|
+
next.addEventListener("click", () => actions.go(state.step + 1));
|
|
389
|
+
nav.appendChild(next);
|
|
390
|
+
}
|
|
391
|
+
host.appendChild(nav);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function renderAdvise(body, ctx) {
|
|
395
|
+
const bands = (ctx.carve && ctx.carve.bands) || {};
|
|
396
|
+
body.appendChild(el("h3", null, "bands"));
|
|
397
|
+
const legend = [
|
|
398
|
+
["clean leaf", "var(--managed)", "carve now — 80-100"],
|
|
399
|
+
["carvable w/ edits", "var(--pending)", "boundary work — 50-79"],
|
|
400
|
+
["leave in Terraform", "var(--muted)", "leave — 0-49"],
|
|
401
|
+
];
|
|
402
|
+
for (const [band, color, meaning] of legend) {
|
|
403
|
+
const row = el("div", "count-row");
|
|
404
|
+
const dot = el("span", "dot");
|
|
405
|
+
dot.style.background = color;
|
|
406
|
+
row.append(dot, el("span", "grow", meaning), el("span", "tag", String(bands[band] ?? 0)));
|
|
407
|
+
body.appendChild(row);
|
|
408
|
+
}
|
|
409
|
+
if (ctx.carve && ctx.carve.advisory) body.appendChild(el("p", "panel-muted", ctx.carve.advisory));
|
|
410
|
+
if (ctx.demo) {
|
|
411
|
+
body.appendChild(
|
|
412
|
+
el(
|
|
413
|
+
"p",
|
|
414
|
+
"panel-muted",
|
|
415
|
+
`Terraform read from ${ctx.demo.fromLabel}/ in your demo copy. Emit and bridge write only into ${ctx.demo.outLabel}/.`,
|
|
416
|
+
),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function renderPick(body, state, ctx, actions) {
|
|
422
|
+
if (!state.pick) {
|
|
423
|
+
body.appendChild(el("p", "panel-muted", "Nothing picked yet. Click a green card in the graph, or one of these:"));
|
|
424
|
+
const greens = (ctx.report && ctx.report.resources ? ctx.report.resources : []).filter((r) => r.score >= 80);
|
|
425
|
+
for (const r of greens) {
|
|
426
|
+
const row = el("div", "node-row");
|
|
427
|
+
const dot = el("span", "dot");
|
|
428
|
+
dot.style.background = "var(--managed)";
|
|
429
|
+
row.append(dot, el("span", "grow", r.address), el("span", "tag", String(r.score)));
|
|
430
|
+
row.addEventListener("click", () => actions.select(r.address));
|
|
431
|
+
body.appendChild(row);
|
|
432
|
+
}
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const { node, resource } = state.pick;
|
|
436
|
+
body.appendChild(el("h3", null, "the arithmetic"));
|
|
437
|
+
body.appendChild(factRows(pickFacts(node, resource)));
|
|
438
|
+
|
|
439
|
+
const cut = cutSummary(resource, node);
|
|
440
|
+
body.appendChild(el("h3", null, cut.known ? "the edges this cut severs" : "the boundary this cut crosses"));
|
|
441
|
+
for (const item of cut.items) body.appendChild(el("p", "panel-muted carve-cut", item));
|
|
442
|
+
if (cut.note) body.appendChild(el("p", "panel-muted carve-honesty", cut.note));
|
|
443
|
+
|
|
444
|
+
const why = carveable(resource, node);
|
|
445
|
+
if (why) body.appendChild(el("p", "panel-muted carve-honesty", why));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function renderEmit(body, state, ctx, actions) {
|
|
449
|
+
const demo = ctx.demo;
|
|
450
|
+
if (!demo || !demo.runnable) {
|
|
451
|
+
body.appendChild(
|
|
452
|
+
el("p", "panel-muted", demo ? demo.reason : "This is a plain `behold carve` — the steps run only inside a `behold demo carve` copy."),
|
|
453
|
+
);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (!state.emit) {
|
|
457
|
+
// Two different "no"s, and they read differently: not picked yet, versus
|
|
458
|
+
// picked something chant has no native mapping for. The second one is
|
|
459
|
+
// chant's own refusal, said here instead of after a round trip.
|
|
460
|
+
const why = blockedReason(state, "emit") || (state.pick ? carveable(state.pick.resource, state.pick.node) : null);
|
|
461
|
+
const run = el("button", "act carve-run", state.busy === "emit" ? "emitting…" : `Emit ${state.pick ? state.pick.node.id : ""} →`);
|
|
462
|
+
run.disabled = !!why || !!state.busy;
|
|
463
|
+
if (why) run.title = why;
|
|
464
|
+
if (why) body.appendChild(el("p", "panel-muted carve-honesty", why));
|
|
465
|
+
run.addEventListener("click", actions.runEmit);
|
|
466
|
+
body.appendChild(run);
|
|
467
|
+
body.appendChild(
|
|
468
|
+
el(
|
|
469
|
+
"p",
|
|
470
|
+
"panel-muted",
|
|
471
|
+
"Runs `chant carve emit --state --select <addr>` in your demo copy. It adopts the resource from the tfstate " +
|
|
472
|
+
"into typed chant source — nothing is applied, nothing is destroyed, and your Terraform is not touched.",
|
|
473
|
+
),
|
|
474
|
+
);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const e = state.emit;
|
|
478
|
+
body.appendChild(pre(e.command, "carve-cmd"));
|
|
479
|
+
body.appendChild(pre(e.output));
|
|
480
|
+
|
|
481
|
+
const verdict = lintVerdict(e.lint);
|
|
482
|
+
if (verdict) {
|
|
483
|
+
const row = el("div", "count-row");
|
|
484
|
+
const dot = el("span", "dot");
|
|
485
|
+
dot.style.background = verdict.tone === "good" ? "var(--managed)" : "var(--degraded)";
|
|
486
|
+
row.append(dot, el("span", "grow", verdict.text), el("span", "tag", e.lint.command));
|
|
487
|
+
body.appendChild(row);
|
|
488
|
+
body.appendChild(pre(e.lint.output));
|
|
489
|
+
}
|
|
490
|
+
// chant#1637, said where it matters rather than only in a README nobody has
|
|
491
|
+
// open: this step shows `lint` and not `build`, and here is why.
|
|
492
|
+
const caveat = el("details", "carve-caveat");
|
|
493
|
+
caveat.appendChild(el("summary", null, "why lint and not build?"));
|
|
494
|
+
caveat.appendChild(el("p", "panel-muted", e.buildCaveat || (ctx.demo && ctx.demo.buildCaveat) || ""));
|
|
495
|
+
body.appendChild(caveat);
|
|
496
|
+
|
|
497
|
+
body.appendChild(el("h3", null, "emitted"));
|
|
498
|
+
for (const a of e.artifacts || []) body.appendChild(artifactBlock(a, actions));
|
|
499
|
+
|
|
500
|
+
if (e.boundary) {
|
|
501
|
+
body.appendChild(el("h3", null, "boundary report"));
|
|
502
|
+
body.appendChild(ctx.renderJson(e.boundary));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function renderBridge(body, state, ctx, actions) {
|
|
507
|
+
const demo = ctx.demo;
|
|
508
|
+
if (!demo || !demo.runnable) {
|
|
509
|
+
body.appendChild(el("p", "panel-muted", demo ? demo.reason : "The steps run only inside a `behold demo carve` copy."));
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (!state.bridge) {
|
|
513
|
+
const why = blockedReason(state, "bridge");
|
|
514
|
+
const run = el("button", "act carve-run", state.busy === "bridge" ? "bridging…" : "Bridge →");
|
|
515
|
+
run.disabled = !!why || !!state.busy;
|
|
516
|
+
if (why) run.title = why;
|
|
517
|
+
run.addEventListener("click", actions.runBridge);
|
|
518
|
+
body.appendChild(run);
|
|
519
|
+
body.appendChild(
|
|
520
|
+
el(
|
|
521
|
+
"p",
|
|
522
|
+
"panel-muted",
|
|
523
|
+
"Runs `chant carve bridge` WITHOUT --apply-rewrites: the rewritten Terraform lands beside the runbook as a " +
|
|
524
|
+
"proposal, and your own `.tf` files are never edited.",
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const b = state.bridge;
|
|
530
|
+
body.appendChild(pre(b.command, "carve-cmd"));
|
|
531
|
+
body.appendChild(pre(b.output));
|
|
532
|
+
body.appendChild(el("h3", null, "proposed"));
|
|
533
|
+
for (const a of b.proposals || []) body.appendChild(artifactBlock(a, actions));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function renderHandoff(body, state, actions) {
|
|
537
|
+
const runbook = state.bridge && state.bridge.runbook;
|
|
538
|
+
if (!runbook) {
|
|
539
|
+
body.appendChild(el("p", "panel-muted", blockedReason(state, "handoff") || "no runbook yet"));
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
// The one place the walkthrough refuses to be a button, stated out loud.
|
|
543
|
+
const why = el("div", "carve-refusal carve-human");
|
|
544
|
+
why.appendChild(el("div", "carve-refusal-title", "These two are not buttons, on purpose."));
|
|
545
|
+
why.appendChild(
|
|
546
|
+
el(
|
|
547
|
+
"p",
|
|
548
|
+
"panel-muted",
|
|
549
|
+
"`terraform state rm` releases Terraform's claim on a live resource, and `terraform apply` writes to your cloud. " +
|
|
550
|
+
"behold triggers delegated work; it does not decide when your estate changes hands. Copy them, read the plan, " +
|
|
551
|
+
"and run them yourself.",
|
|
552
|
+
),
|
|
553
|
+
);
|
|
554
|
+
body.appendChild(why);
|
|
555
|
+
|
|
556
|
+
body.appendChild(el("h3", null, "the runbook"));
|
|
557
|
+
let section = null;
|
|
558
|
+
for (const c of runbookCommands(runbook.text)) {
|
|
559
|
+
if (c.section !== section) {
|
|
560
|
+
section = c.section;
|
|
561
|
+
body.appendChild(el("p", "panel-muted carve-section", section));
|
|
562
|
+
}
|
|
563
|
+
const row = el("div", "prow carve-cmd-row");
|
|
564
|
+
row.appendChild(el("code", "grow carve-cmd-text", c.command));
|
|
565
|
+
row.appendChild(copyButton("copy", c.command, actions));
|
|
566
|
+
body.appendChild(row);
|
|
567
|
+
if (c.note) body.appendChild(el("p", "panel-muted carve-cmd-note", c.note));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const ran = el("button", "act carve-ran", state.handoff ? "✓ marked as run" : "I ran these →");
|
|
571
|
+
ran.title = "Marks the handoff done in this walkthrough. behold has not checked — it can't, and won't pretend to.";
|
|
572
|
+
ran.addEventListener("click", actions.markHandoff);
|
|
573
|
+
body.appendChild(ran);
|
|
574
|
+
|
|
575
|
+
const full = el("details", "carve-caveat");
|
|
576
|
+
full.appendChild(el("summary", null, `the whole runbook (${runbook.path})`));
|
|
577
|
+
full.appendChild(pre(runbook.text));
|
|
578
|
+
body.appendChild(full);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function renderDone(body, state, ctx, actions) {
|
|
582
|
+
const address = state.pick ? state.pick.node.id : "the resource";
|
|
583
|
+
const card = el("div", "carve-endcard");
|
|
584
|
+
card.appendChild(el("div", "carve-endcard-title", `${address} — chant-owned, observe position.`));
|
|
585
|
+
card.appendChild(
|
|
586
|
+
el(
|
|
587
|
+
"p",
|
|
588
|
+
"panel-muted",
|
|
589
|
+
"Nothing was destroyed and nothing was recreated. `terraform import` puts it back under Terraform at any point.",
|
|
590
|
+
),
|
|
591
|
+
);
|
|
592
|
+
body.appendChild(card);
|
|
593
|
+
|
|
594
|
+
if (!state.handoff) {
|
|
595
|
+
body.appendChild(el("p", "panel-muted", "The handoff commands haven't been marked as run — this is the shape of the ending, not a claim about your estate."));
|
|
596
|
+
}
|
|
597
|
+
body.appendChild(
|
|
598
|
+
el(
|
|
599
|
+
"p",
|
|
600
|
+
"panel-muted carve-honesty",
|
|
601
|
+
"Deferred to the follow-up: the card doesn't yet SLIDE out of the Terraform box and into the chant project " +
|
|
602
|
+
"beside last month's carves — it is marked in place. The Floci `--live` tier (a real terraform apply, a real " +
|
|
603
|
+
"plan showing no destroy) is the other half of that follow-up.",
|
|
604
|
+
),
|
|
605
|
+
);
|
|
606
|
+
const again = el("button", "act", "↺ start over");
|
|
607
|
+
again.title = "Back to Advise. Whatever the steps already wrote stays in the demo copy.";
|
|
608
|
+
again.addEventListener("click", actions.reset);
|
|
609
|
+
body.appendChild(again);
|
|
610
|
+
}
|