@intentius/behold 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +85 -0
  2. package/README.md +124 -2
  3. package/demos.json +17 -1
  4. package/dist/cli.js +2553 -315
  5. package/example-argo-estate/README.md +43 -18
  6. package/example-argo-estate/app-a/chant.config.ts +10 -2
  7. package/example-argo-estate/app-a/package.json +2 -2
  8. package/example-argo-estate/app-b/chant.config.ts +11 -2
  9. package/example-argo-estate/app-b/package.json +2 -2
  10. package/example-argo-estate/control-plane/chant.config.ts +20 -5
  11. package/example-argo-estate/control-plane/package.json +2 -2
  12. package/example-argo-estate/package-lock.json +20 -20
  13. package/example-carve/README.md +221 -0
  14. package/example-carve/app/chant.config.ts +6 -0
  15. package/example-carve/app/package-lock.json +1075 -0
  16. package/example-carve/app/package.json +13 -0
  17. package/example-carve/app/src/carved.ts +30 -0
  18. package/example-carve/app/tsconfig.json +1 -0
  19. package/example-carve/carve-report.json +962 -0
  20. package/example-carve/legacy-tf/cdn.tf +23 -0
  21. package/example-carve/legacy-tf/compute.tf +63 -0
  22. package/example-carve/legacy-tf/floci-override.tf.disabled +61 -0
  23. package/example-carve/legacy-tf/modules/cdn/main.tf +72 -0
  24. package/example-carve/legacy-tf/naming.tf +10 -0
  25. package/example-carve/legacy-tf/network.tf +119 -0
  26. package/example-carve/legacy-tf/observability.tf +18 -0
  27. package/example-carve/legacy-tf/outputs.tf +21 -0
  28. package/example-carve/legacy-tf/storage.tf +33 -0
  29. package/example-carve/legacy-tf/terraform.tfstate +602 -0
  30. package/example-carve/legacy-tf/versions.tf +40 -0
  31. package/example-flux-estate/README.md +9 -4
  32. package/example-flux-estate/app-a/package.json +2 -2
  33. package/example-flux-estate/app-a/src/app.ts +2 -1
  34. package/example-flux-estate/app-b/chant.config.ts +4 -3
  35. package/example-flux-estate/app-b/package.json +2 -2
  36. package/example-flux-estate/app-b/src/app.ts +5 -3
  37. package/example-flux-estate/control-plane/package.json +2 -2
  38. package/example-flux-estate/control-plane/src/flux.ts +4 -2
  39. package/example-flux-estate/package-lock.json +20 -20
  40. package/example-k8s/package-lock.json +21 -21
  41. package/example-k8s/package.json +3 -3
  42. package/example-writes/package-lock.json +14 -14
  43. package/example-writes/package.json +3 -3
  44. package/package.json +8 -6
  45. package/web/app.js +714 -57
  46. package/web/carve-steps.js +621 -0
  47. package/web/carve-steps.test.js +248 -0
  48. package/web/demos.js +71 -0
  49. package/web/demos.test.js +83 -0
  50. package/web/index.html +93 -1
  51. package/web/json-view.js +334 -0
  52. package/web/json-view.test.js +218 -0
  53. package/web/layout-store.js +164 -4
  54. package/web/layout-store.test.js +226 -1
  55. package/web/panel.js +28 -0
  56. package/web/theme.js +57 -1
package/AGENTS.md CHANGED
@@ -25,6 +25,16 @@ it against a local emulator (Docker). A directory that is not a chant project
25
25
  gets a structured `{code: "no-project"}` error from `/api/graph`, not a blank
26
26
  graph.
27
27
 
28
+ A running server offers the same catalog over HTTP (#268): `GET /api/demos`
29
+ lists every bundled demo with `{name, description, requires, satisfiable,
30
+ reason?, fetches, repo?, target, loaded}` — `satisfiable` is doctor's PATH probe
31
+ for that demo's `requires`, and `fetches` marks the one kind of entry that
32
+ reaches the network (a git demo is cloned). `POST /api/demos/open` with
33
+ `{name}` loads it and switches the served project to it: the same copy/clone →
34
+ install → setup the CLI runs, then an in-place switch. It takes a catalog
35
+ **name** and never a path, so it cannot be aimed outside the install; it is
36
+ preview-locked, and one load runs at a time (409 otherwise).
37
+
28
38
  ## The read loop
29
39
 
30
40
  0. **discover** — GET `/api` lists every route with a one-line description,
@@ -50,6 +60,49 @@ graph.
50
60
  3. **inspect** — a node's `sourceLoc.file` is the typed source that declared it;
51
61
  edit there to change the estate (chant is the source of truth, not behold).
52
62
 
63
+ ## The carve loop (Terraform → chant, #230)
64
+
65
+ `behold carve <report.json>` serves a `chant carve advise --json` peelability
66
+ report instead of a chant project. Same SPA, same `/api/graph` shape; `attrs.
67
+ _status` carries the band (`good` = carve now, `warn` = has boundary work,
68
+ `neutral` = leave in Terraform) and `attrs` carries the score arithmetic
69
+ (`score`, `arithmetic`, `inbound`, `outbound`, `tier`, `mapsTo`).
70
+ `GET /api/carve` returns the raw report verbatim.
71
+
72
+ To move a resource: confirm its band on `/api/carve`, then run chant's own
73
+ `carve emit --state` and `carve bridge` in the project. `terraform state rm` and
74
+ applying the generated survivor rewrites stay a human gate — behold has no
75
+ endpoint that writes Terraform, and adding one would break the invariant below.
76
+
77
+ ### The walkthrough (`behold demo carve`, #254)
78
+
79
+ `behold demo carve` copies a bundled half-migrated estate (a chant project
80
+ beside a Terraform one), runs the advisor over the copy, and serves the same
81
+ carve view plus a six-step stepper on the panel's Carve tab: advise → pick →
82
+ emit → bridge → handoff → done.
83
+
84
+ Two of those steps are POST routes, and they exist **only** in a demo copy:
85
+
86
+ - `POST /api/carve/emit` — body `{select}`; runs `chant carve emit --state
87
+ --select <addr> --output <copy>/app/carveout` and then `chant lint` on the
88
+ result. Answers `{select, command, output, artifacts[], boundary, lint,
89
+ buildCaveat}`.
90
+ - `POST /api/carve/bridge` — body `{select}`; runs `chant carve bridge`
91
+ **without** `--apply-rewrites`. Answers `{select, command, output, runbook,
92
+ proposals[]}`.
93
+
94
+ `GET /api/project`'s `carve.demo` says whether they can act (`runnable`, plus a
95
+ `reason` when not). `select` must name a resource the served report ranks;
96
+ anything else is a 400. Outside a demo copy both routes answer 403
97
+ `{code: "read-only"}`, and on an ordinary project serve they don't exist at all.
98
+
99
+ The gate the Emit step reports is `chant lint`, not `chant build` — chant#1637
100
+ means `build` fails on the emitted bucket. Don't read a lint pass as a build
101
+ pass.
102
+
103
+ There is still **no** endpoint that runs `terraform`. The handoff step hands
104
+ back the runbook's commands as text.
105
+
53
106
  ## The act loop (delegated, never direct)
54
107
 
55
108
  behold does not apply. To change the estate:
@@ -69,3 +122,35 @@ and saga rollback. There is no behold endpoint that mutates the cloud.
69
122
  If a request would have behold write to a cloud or to source directly, it's wrong.
70
123
  behold shows truth and triggers Ops. Authority stays in the committed source and the
71
124
  executor.
125
+
126
+ ### The exceptions, and their exact size
127
+
128
+ `POST /api/layout` (#228) writes **one** file in the served project:
129
+ `.behold/layout.json` — the hand-layout sidecar, `{version, lenses: {<lens>:
130
+ {<node id>: {dx,dy,dw,dh}}}}`. That is the whole of behold's write surface
131
+ inside a project, and it does not weaken the invariant above:
132
+
133
+ - It is **workspace metadata**, not estate truth. Deltas describe how *you* want
134
+ the picture arranged on top of dagre's layout; the graph underneath stays
135
+ chant's, and a delta for a node that left the estate is dropped on read.
136
+ - It **never touches the cloud and never touches your source**. No `.ts`, no
137
+ `chant.config.ts`, no `.behold.json`. The path is `cfg.projectDir` + two
138
+ constants — nothing from the request reaches the filesystem.
139
+ - It refuses politely when it shouldn't write: preview mode, a static-export
140
+ capture, a read-only project directory, an oversized or malformed body.
141
+ - It is **per-user state**, unlike `.behold.json` (config, meant to be tracked).
142
+ Projects should gitignore `.behold/`.
143
+
144
+ `GET /api/layout` reads it back; `GET /api/graph?layout=1` (and `/api/overlay`)
145
+ render with the deltas baked into the SVG, which is how `behold export` and
146
+ static snapshots honour a hand layout.
147
+
148
+ The second exception is the carve walkthrough's two steps above (#254), and it
149
+ is narrower still: they exist only when behold booted a `behold demo carve`
150
+ copy, they write only into `<copy>/app/carveout/`, and the directory they write
151
+ into is a scratch dir behold created inside a directory it copied for you a
152
+ minute earlier. `carve bridge` runs without `--apply-rewrites`, so the demo's
153
+ own Terraform is not edited either. The only request-derived value is `select`,
154
+ and it must be an address the served report already ranks — the value that
155
+ reaches the spawn's argv comes from a closed set read off disk. No cloud write,
156
+ no Terraform mutation, no edit to anyone's chant source.
package/README.md CHANGED
@@ -31,8 +31,14 @@ The copied project is yours: edit its source and watch the graph change live.
31
31
  There's a whole catalog — `behold demo --list` names the rest (`behold demo
32
32
  k8s` stands the same loop up on a throwaway k3d cluster: runtime Pods, field
33
33
  ownership; `behold demo argo-estate` needs nothing at all — a three-project
34
- Argo CD estate, declared only, so it runs where Docker doesn't). Every loaded
35
- demo lands in the panel's recents, so switching between them is the Scope tab.
34
+ Argo CD estate, declared only, so it runs where Docker doesn't; `behold demo
35
+ carve` is the Terraform peel walkthrough, below). Every loaded demo lands in
36
+ the panel's recents, so switching between them is the Scope tab.
37
+
38
+ The catalog is in the panel too (#268): the Scope tab's switcher lists every
39
+ bundled demo under your recents, one click to copy, install and serve it —
40
+ demos whose prerequisites are missing stay visible, disabled, saying what to
41
+ install, and one that would clone from the network says so on the button.
36
42
 
37
43
  Already have a chant project?
38
44
 
@@ -266,6 +272,60 @@ behold shells the **project's own** chant (resolved from the project's
266
272
  `@intentius/chant ^0.18.1` or later for the live overlay (`graph --live` observed
267
273
  nothing before that fix).
268
274
 
275
+ ## Terraform carve-out: `behold carve <report.json>`
276
+
277
+ `chant carve advise` ranks a Terraform estate by **peelability** — how cleanly
278
+ each resource could be carved into native chant source. `behold carve` draws
279
+ that ranking: one card per resource, three panels (carve now / boundary work /
280
+ leave in Terraform) on the same `attrs._status` drift palette every other view
281
+ uses, and the score arithmetic behind each rank in the inspect pane.
282
+
283
+ ```sh
284
+ chant carve advise --from ./terraform --report carve.json
285
+ behold carve carve.json # → http://localhost:4600
286
+ curl localhost:4600/api/carve # the raw report, for agents
287
+ ```
288
+
289
+ behold parses no HCL and needs no Terraform tooling: the report **is** the
290
+ contract. A file that isn't a peelability report is refused with a structured
291
+ `{error, code: "carve-report", remedy}` — in the terminal, and from the routes —
292
+ never a blank graph. See `docs/using/carve` and issue #230 for the roadmap
293
+ (the post-emit diff, then Terraform as an estate member).
294
+
295
+ ### The walkthrough: `behold demo carve`
296
+
297
+ ```sh
298
+ npx @intentius/behold demo carve # no Docker, no cloud, no terraform binary
299
+ ```
300
+
301
+ Copies a half-migrated estate — a small chant project beside a Terraform one,
302
+ both describing the same AWS account — installs the chant it will shell, runs
303
+ `chant carve advise` over the copy, and opens the banded graph with a six-step
304
+ stepper on the panel's **Carve** tab:
305
+
306
+ 1. **Advise** — the bands, with what each one means.
307
+ 2. **Pick** — click a green card. The inspect pane shows the score arithmetic;
308
+ the step names the boundary the cut crosses.
309
+ 3. **Emit** — runs `chant carve emit --state --select <addr>` into
310
+ `app/carveout/` in the copy, then shows the emitted chant source and the
311
+ `chant lint` result.
312
+ 4. **Bridge** — runs `chant carve bridge` (never `--apply-rewrites`) and renders
313
+ the proposed data source, the rewired survivors and the patch.
314
+ 5. **Handoff** — the runbook's commands with copy buttons, and **not** a button:
315
+ `terraform state rm` and `terraform apply` change who owns a live resource,
316
+ so they stay yours to run. The panel says so.
317
+ 6. **Done** — the card is marked chant-owned at the observe position;
318
+ `terraform import` reverses all of it.
319
+
320
+ The Emit step reports `chant lint`, not `chant build`: `build` fails on the
321
+ emitted bucket (scored 84 by the advisor) on one rule — WAW042, a TLS-deny
322
+ bucket policy the source Terraform never declared — and the panel links the
323
+ reason. `example-carve/README.md` has the estate's full story, the band table,
324
+ and the offline/`--live` split.
325
+
326
+ behold writes only into the demo copy it made — `app/carveout/`, and nothing
327
+ else. Your Terraform is never edited; see AGENTS.md, "Invariant".
328
+
269
329
  ## Configuration — `.behold.json`
270
330
 
271
331
  An optional `.behold.json` in the served project's root is **behold's own**
@@ -294,6 +354,34 @@ render and the graph loads with no tier selected — the default for any project
294
354
  that doesn't opt in. There's no other tier config surface (not
295
355
  `chant.config.ts`, not an env var behold guesses the name of).
296
356
 
357
+ ### The hand-layout sidecar — `.behold/layout.json`
358
+
359
+ dagre places your nodes; you can move them. Drag a card, resize a containment
360
+ box, and the offsets are remembered per project + lens — in `localStorage`
361
+ first, and (when the served project is writable) in a `.behold/layout.json`
362
+ sidecar beside it, so a layout is shareable, reviewable in a diff, and honoured
363
+ by `behold export`:
364
+
365
+ ```json
366
+ { "version": 1, "lenses": { "components": { "src/api#Component": { "dx": 40, "dy": -25 } } } }
367
+ ```
368
+
369
+ This is the **only** file behold writes inside a served project. It stores
370
+ deltas, never absolute positions — the graph stays chant's and your layout sits
371
+ on top of it — and a delta whose node has left the estate is dropped silently.
372
+ `POST /api/layout` refuses politely in preview mode, during a static-export
373
+ capture, on a read-only directory, and above its size caps. `↺ layout` in the
374
+ graph clears the current lens on both tiers.
375
+
376
+ **Gitignore it.** `.behold.json` (above) is config and belongs in the repo;
377
+ `.behold/` is per-user state — one person's arrangement of the picture — so add
378
+ it to the served project's `.gitignore` unless you actually want to share and
379
+ review a layout:
380
+
381
+ ```gitignore
382
+ .behold/
383
+ ```
384
+
297
385
  ## Layout
298
386
 
299
387
  ```
@@ -319,6 +407,40 @@ and paints it with `layoutIr` + `renderSvg` (`src/render.ts`); the SPA inlines t
319
407
  SVG and wires click-inspect by `data-node-id` against the IR. pinhole's layout is
320
408
  dagre — pure JS, no native dependency.
321
409
 
410
+ Where an official mark exists, a node paints it instead of a generic glyph — a
411
+ Deployment gets the Kubernetes wheel-and-helm heptagon, a Kustomization the
412
+ Flux mark, a `Helm::Release` the Helm wheel. The corpus is vendored under
413
+ `web/icons/` (30 kubernetes/community SVGs, 3 cncf/artwork marks for
414
+ Flux/Argo/Helm, licensing in `THIRD_PARTY.md`) and mapped kind by kind in
415
+ `src/icon-packs.ts`; a kind with no official icon falls through to pinhole's
416
+ keyword heuristic rather than a wrong picture.
417
+
418
+ dagre's layout is a good first draft, not a final one: drag a card to move
419
+ it, grab a containment box's corner to resize it, and both survive a reload.
420
+ What persists is a delta — `{dx,dy}` for a card, `{dw,dh}` for a box, never
421
+ an absolute position — keyed by `behold.layout.<project>.<lens>`
422
+ (`web/layout-store.js`), so the graph stays chant's and the arrangement on
423
+ top of it is yours. `↺ layout` sits beside `⤢ fit` and shows up only once
424
+ something on the current lens is hand-placed.
425
+
426
+ Every JSON value the UI shows goes through one renderer (`web/json-view.js`):
427
+ 2-space pretty printed, objects and arrays collapsible (the first tier open,
428
+ anything deeper or wider than a dozen entries folded), long strings truncated
429
+ with an expander, and a `copy` on every node that yields that subtree's raw
430
+ JSON. Enter or Space toggles the focused node. It paints with `--fg` and
431
+ `--muted` and nothing else, so all 552 palettes keep it readable. That covers
432
+ the inspect pane's declared attributes, observed live state, drift pairs and
433
+ field ownership, the op log's JSON lines, and the payload behind an `/api`
434
+ error card.
435
+
436
+ Type splits by purpose: mono (system stacks — ui-monospace, SF Mono,
437
+ Cascadia, JetBrains, IBM Plex) carries node ids, ARNs, statuses and counts;
438
+ sans carries labels only. Colour comes from 552 Ghostty terminal palettes run
439
+ through an OKLCH-derived token pipeline (`src/theme.ts`), so a theme switch
440
+ re-derives the whole chrome, not just the graph — and a node whose drift
441
+ status just changed pulses once in the colour it became, off under
442
+ `prefers-reduced-motion`.
443
+
322
444
  ## Local development
323
445
 
324
446
  `just` lists everything. The core loop:
package/demos.json CHANGED
@@ -19,7 +19,7 @@
19
19
  },
20
20
  {
21
21
  "name": "flux-estate",
22
- "description": "A GitOps estate: a Flux control plane (GitRepository + Kustomizations) plus two app projects, served as one composed estate — cross-stack sourceRef edges, estate-wide drift, and the namespace-mismatch note on purpose (app-b uses targetNamespace). k3d + Flux, no cloud. scripts/estate-down.sh removes the cluster.",
22
+ "description": "A GitOps estate: a Flux control plane (GitRepository + Kustomizations) plus two app projects, served as one composed estate — cross-stack sourceRef edges, estate-wide drift, and the namespace join that reads app-b where the control plane's targetNamespace puts it. k3d + Flux, no cloud. scripts/estate-down.sh removes the cluster.",
23
23
  "source": "bundled",
24
24
  "dir": "example-flux-estate",
25
25
  "requires": ["docker", "k3d", "kubectl"],
@@ -34,6 +34,22 @@
34
34
  "requires": [],
35
35
  "serve": { "dirs": ["control-plane", "app-a", "app-b"] }
36
36
  },
37
+ {
38
+ "name": "carve",
39
+ "description": "The peel walkthrough: a half-migrated estate — a chant project beside a Terraform one — scored by `chant carve advise`, then a six-step stepper that carves one S3 bucket across the line on camera (advise, pick, emit, bridge, handoff, done). Emit and bridge really run, into the demo copy; the destructive terraform commands stay yours to paste. No cloud, no Docker, no terraform binary.",
40
+ "source": "bundled",
41
+ "dir": "example-carve",
42
+ "requires": [],
43
+ "serve": {
44
+ "carve": {
45
+ "report": "carve-report.json",
46
+ "from": "legacy-tf",
47
+ "state": "legacy-tf/terraform.tfstate",
48
+ "project": "app",
49
+ "out": "app/carveout"
50
+ }
51
+ }
52
+ },
37
53
  {
38
54
  "name": "fountain",
39
55
  "description": "The mature estate: self-hosted fountain (a real app) on a throwaway k3d cluster — tiers (try the ha tier picker), seams, drift, runtime Pods. Clones INTENTIUS/fountain-ops; ~5 minutes, mostly image pulls. `just down` in the clone removes the cluster.",