From 80578a5b239f9f1671b401b8cb9dbf4fbc2aa7fe Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:10:19 -0600 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20behold=20demo=20carve=20=E2=80=94?= =?UTF-8?q?=20the=20recordable=20walkthrough,=20offline=20tier=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One command from npm to a banded Terraform estate and a six-step stepper that carves a bucket across the line on camera: advise → pick → emit → bridge → handoff → done. No Docker, no cloud, no terraform binary. `behold demo carve` copies example-carve (#262), npm-installs the chant every step shells, drops @cdktf/hcl2json where chant's lazy import actually resolves it (the copy's ROOT node_modules — chant resolves from its own install upward, not from the .tf files), and runs `carve advise --report` over the copy. A boot that fails any of that serves the committed report and says so on the statusbar rather than drawing a blank graph. The stepper lives on a Carve tab mounted at runtime, so no other project grows a dead tab. Its two ACTION steps really run: POST /api/carve/emit shells `carve emit --state --select ` and then `chant lint`, and renders the emitted source, chant's own boundary report and the lint verdict; POST /api/carve/bridge shells `carve bridge` WITHOUT --apply-rewrites and renders the proposed data source, the rewired survivors and the patch. Handoff is copy buttons and a paragraph about why it is not a button. Done marks the card chant-owned at the observe position. The write boundary (src/carve-actions.ts): the routes exist only in carve mode and act only when this server booted a demo copy; one directory is written (`/app/carveout`, inside the chant project so the emitted source's lexicon import resolves); the only request-derived value is `select`, and it must be an address the served report already ranks, so the argv element comes from a closed set read off disk. No cloud write, no Terraform mutation — verified by diffing the copy's legacy-tf after a full run. Emit reports `chant lint`, never `chant build` (chant#1637): the emitted bucket folds its sub-resources into the carve set without carrying them as native props, so build fails two policy rules on source scored 88. The step links the reason; the README and docs say it too. Tests: the actions and their refusals against a fake project-local chant in a temp copy (argv, the mkdir chant needs before --report, the artifact read-back, never --apply-rewrites), the route guards, the stepper's pure state machine, and a headless six-step walk in the UI smoke against a carve-mode stub serving the real committed report through the real lens. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 41 +- README.md | 38 +- demos.json | 16 + docs/src/content/docs/using/carve.mdx | 23 + example-carve/README.md | 32 +- example-carve/app/.gitignore | 3 + package.json | 1 + smoke/stub.mjs | 173 +++++++- smoke/ui-smoke.mjs | 139 ++++++ src/carve-actions.test.ts | 357 +++++++++++++++ src/carve-actions.ts | 368 ++++++++++++++++ src/cli.ts | 118 ++++- src/demos.test.ts | 39 ++ src/demos.ts | 38 ++ src/server.ts | 129 +++++- web/app.js | 219 +++++++++- web/carve-steps.js | 605 ++++++++++++++++++++++++++ web/carve-steps.test.js | 218 ++++++++++ web/index.html | 40 ++ web/panel.js | 28 ++ 20 files changed, 2599 insertions(+), 26 deletions(-) create mode 100644 src/carve-actions.test.ts create mode 100644 src/carve-actions.ts create mode 100644 web/carve-steps.js create mode 100644 web/carve-steps.test.js diff --git a/AGENTS.md b/AGENTS.md index 2828600..a1eae74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,35 @@ To move a resource: confirm its band on `/api/carve`, then run chant's own applying the generated survivor rewrites stay a human gate — behold has no endpoint that writes Terraform, and adding one would break the invariant below. +### The walkthrough (`behold demo carve`, #254) + +`behold demo carve` copies a bundled half-migrated estate (a chant project +beside a Terraform one), runs the advisor over the copy, and serves the same +carve view plus a six-step stepper on the panel's Carve tab: advise → pick → +emit → bridge → handoff → done. + +Two of those steps are POST routes, and they exist **only** in a demo copy: + +- `POST /api/carve/emit` — body `{select}`; runs `chant carve emit --state + --select --output /app/carveout` and then `chant lint` on the + result. Answers `{select, command, output, artifacts[], boundary, lint, + buildCaveat}`. +- `POST /api/carve/bridge` — body `{select}`; runs `chant carve bridge` + **without** `--apply-rewrites`. Answers `{select, command, output, runbook, + proposals[]}`. + +`GET /api/project`'s `carve.demo` says whether they can act (`runnable`, plus a +`reason` when not). `select` must name a resource the served report ranks; +anything else is a 400. Outside a demo copy both routes answer 403 +`{code: "read-only"}`, and on an ordinary project serve they don't exist at all. + +The gate the Emit step reports is `chant lint`, not `chant build` — chant#1637 +means `build` fails on the emitted bucket. Don't read a lint pass as a build +pass. + +There is still **no** endpoint that runs `terraform`. The handoff step hands +back the runbook's commands as text. + ## The act loop (delegated, never direct) behold does not apply. To change the estate: @@ -84,7 +113,7 @@ If a request would have behold write to a cloud or to source directly, it's wron behold shows truth and triggers Ops. Authority stays in the committed source and the executor. -### The one exception, and its exact size +### The exceptions, and their exact size `POST /api/layout` (#228) writes **one** file in the served project: `.behold/layout.json` — the hand-layout sidecar, `{version, lenses: {: @@ -105,3 +134,13 @@ inside a project, and it does not weaken the invariant above: `GET /api/layout` reads it back; `GET /api/graph?layout=1` (and `/api/overlay`) render with the deltas baked into the SVG, which is how `behold export` and static snapshots honour a hand layout. + +The second exception is the carve walkthrough's two steps above (#254), and it +is narrower still: they exist only when behold booted a `behold demo carve` +copy, they write only into `/app/carveout/`, and the directory they write +into is a scratch dir behold created inside a directory it copied for you a +minute earlier. `carve bridge` runs without `--apply-rewrites`, so the demo's +own Terraform is not edited either. The only request-derived value is `select`, +and it must be an address the served report already ranks — the value that +reaches the spawn's argv comes from a closed set read off disk. No cloud write, +no Terraform mutation, no edit to anyone's chant source. diff --git a/README.md b/README.md index 39da800..6e86ada 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,9 @@ The copied project is yours: edit its source and watch the graph change live. There's a whole catalog — `behold demo --list` names the rest (`behold demo k8s` stands the same loop up on a throwaway k3d cluster: runtime Pods, field ownership; `behold demo argo-estate` needs nothing at all — a three-project -Argo CD estate, declared only, so it runs where Docker doesn't). Every loaded -demo lands in the panel's recents, so switching between them is the Scope tab. +Argo CD estate, declared only, so it runs where Docker doesn't; `behold demo +carve` is the Terraform peel walkthrough, below). Every loaded demo lands in +the panel's recents, so switching between them is the Scope tab. Already have a chant project? @@ -286,6 +287,39 @@ contract. A file that isn't a peelability report is refused with a structured never a blank graph. See `docs/using/carve` and issue #230 for the roadmap (the post-emit diff, then Terraform as an estate member). +### The walkthrough: `behold demo carve` + +```sh +npx @intentius/behold demo carve # no Docker, no cloud, no terraform binary +``` + +Copies a half-migrated estate — a small chant project beside a Terraform one, +both describing the same AWS account — installs the chant it will shell, runs +`chant carve advise` over the copy, and opens the banded graph with a six-step +stepper on the panel's **Carve** tab: + +1. **Advise** — the bands, with what each one means. +2. **Pick** — click a green card. The inspect pane shows the score arithmetic; + the step names the boundary the cut crosses. +3. **Emit** — runs `chant carve emit --state --select ` into + `app/carveout/` in the copy, then shows the emitted chant source and the + `chant lint` result. +4. **Bridge** — runs `chant carve bridge` (never `--apply-rewrites`) and renders + the proposed data source, the rewired survivors and the patch. +5. **Handoff** — the runbook's commands with copy buttons, and **not** a button: + `terraform state rm` and `terraform apply` change who owns a live resource, + so they stay yours to run. The panel says so. +6. **Done** — the card is marked chant-owned at the observe position; + `terraform import` reverses all of it. + +The Emit step reports `chant lint`, not `chant build`: chant#1637 means `build` +fails on the emitted bucket even though the advisor scored it 88, and the panel +links the reason. `example-carve/README.md` has the estate's full story, the +band table, and the offline/`--live` split. + +behold writes only into the demo copy it made — `app/carveout/`, and nothing +else. Your Terraform is never edited; see AGENTS.md, "Invariant". + ## Configuration — `.behold.json` An optional `.behold.json` in the served project's root is **behold's own** diff --git a/demos.json b/demos.json index 6102b53..2b77961 100644 --- a/demos.json +++ b/demos.json @@ -34,6 +34,22 @@ "requires": [], "serve": { "dirs": ["control-plane", "app-a", "app-b"] } }, + { + "name": "carve", + "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.", + "source": "bundled", + "dir": "example-carve", + "requires": [], + "serve": { + "carve": { + "report": "carve-report.json", + "from": "legacy-tf", + "state": "legacy-tf/terraform.tfstate", + "project": "app", + "out": "app/carveout" + } + } + }, { "name": "fountain", "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.", diff --git a/docs/src/content/docs/using/carve.mdx b/docs/src/content/docs/using/carve.mdx index 72b36a4..80df175 100644 --- a/docs/src/content/docs/using/carve.mdx +++ b/docs/src/content/docs/using/carve.mdx @@ -28,13 +28,36 @@ Click a card. The inspect pane shows the score spelled out — `100 - 12x3 inbou Terraform sub-resources that inline into a parent — `aws_s3_bucket_versioning` into its bucket — are not ranked separately. They carve with the parent, so they never appear as their own card. +## The walkthrough: `behold demo carve` + +`npx behold demo carve` copies a bundled half-migrated estate — a small chant project beside a Terraform one, both describing the same AWS account — installs the chant it will shell, runs the advisor over the copy, and opens the banded graph with a six-step stepper on the panel's **Carve** tab. No Docker, no cloud account, no `terraform` binary. + +1. **Advise** — the bands, with what each one means and how many landed in it. +2. **Pick** — click a green card. The inspect pane shows the score arithmetic; the step names the boundary the cut crosses (and says plainly when the report carries counts rather than edge lists). +3. **Emit** — runs `chant carve emit --state --select
` into `app/carveout/` in the copy, then shows the emitted chant source, chant's own boundary report for the carve, and the `chant lint` result. +4. **Bridge** — runs `chant carve bridge`, never with `--apply-rewrites`. The proposed data source, the rewired survivors and a git-applyable patch land beside the runbook; the demo's own `.tf` files are not edited. +5. **Handoff** — the runbook's commands, each with a copy button, and deliberately **not** a run button. `terraform state rm` releases Terraform's claim on a live resource and `terraform apply` writes to your cloud; behold triggers delegated work, it does not decide when an estate changes hands. The panel says exactly that. +6. **Done** — the card is marked chant-owned at the observe position. `terraform import` reverses all of it. + +The Emit step reports **`chant lint`, not `chant build`**. chant#1637: `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 `build` fails two AWS policy rules on source the advisor scored 88. The step links the reason rather than letting a lint pass read as a build pass. + +behold writes only into the copy it just made, and only into `app/carveout/` inside it. Both steps refuse outside a demo copy; a plain `behold carve report.json` shows the same six steps with the runs greyed out and a reason. + +Deferred to a follow-up: the Floci `--live` tier (`terraform apply` into a scratch emulator, live observe beats, a real `terraform plan` showing no destroy) and the morph animation that slides the carved card out of the Terraform boundary and into the chant project. + ## For agents ```sh curl localhost:4600/api/carve # the raw report, verbatim curl localhost:4600/api/graph # the same thing as {ir, svg, meta} + +# demo only (behold demo carve): the walkthrough's two safe steps +curl -X POST localhost:4600/api/carve/emit -H 'content-type: application/json' -d '{"select":"aws_s3_bucket.assets"}' +curl -X POST localhost:4600/api/carve/bridge -H 'content-type: application/json' -d '{"select":"aws_s3_bucket.assets"}' ``` +`select` must name a resource the served report ranks; anything else is a 400. Outside a demo copy both POSTs answer 403 `{code: "read-only"}`, and on an ordinary project serve they aren't registered at all. `GET /api/project`'s `carve.demo.runnable` is how a client knows which it is. + A file that isn't a peelability report gets a structured `{error, code: "carve-report", remedy}` — in the terminal before the server starts, and from the routes if it goes bad later. It never renders as a blank graph. ## What is not here yet diff --git a/example-carve/README.md b/example-carve/README.md index 441ef3d..a9ba66e 100644 --- a/example-carve/README.md +++ b/example-carve/README.md @@ -4,12 +4,24 @@ A migration caught half-done. `app/` is chant; `legacy-tf/` is Terraform; both describe one AWS account, and the walkthrough carves one more resource across the line while the camera is running. -This directory is the fixture only. The stepper, the server wiring and the -`demos.json` entry land with the walkthrough PR (behold #254 part 2). +Run it: + +```sh +behold demo carve # copies this directory, installs, advises, serves +``` + +The boot does three things before a port opens: `npm install` in `app/` (whose +chant every step of the walkthrough shells), `@cdktf/hcl2json` into the copy's +root `node_modules` (chant lazy-loads the HCL parser from its own install +upward, so `/node_modules` is where it resolves — not beside the `.tf` +files), and `chant carve advise --report` over the copy's own Terraform. If any +of that fails, the committed `carve-report.json` is served instead and the +reason is on screen; a blank graph is the one outcome that's never allowed. ``` example-carve/ app/ a small chant project — the pieces already carved + carveout/ where Emit and Bridge write, in a copy (gitignored) legacy-tf/ the Terraform half, still Terraform-owned terraform.tfstate synthetic state (fake account, fake ARNs) floci-override.tf.disabled provider endpoints for the --live tier, inert @@ -17,6 +29,13 @@ example-carve/ carve-report.json the committed `carve advise` output ``` +`carveout/` sits INSIDE `app/` rather than beside it, and that is load-bearing: +the emitted source imports `@intentius/chant-lexicon-aws`, and Node resolves +that from the file's own directory upward. From `app/carveout/src/assets.ts` it +reaches `app/node_modules`; from a sibling `carveout/` it would reach nothing, +and the Emit step's `chant lint` would fail on an install problem rather than +on the source. + ## The estate is mixed from the first frame `app/src/carved.ts` holds a CloudWatch log group and an SSM parameter that came @@ -92,6 +111,9 @@ from the repository root, so the `from` field stays a relative path. ## The six beats +These are the six steps on the panel's Carve tab. behold runs beats 4 and 5 +itself, into `app/carveout/` in the copy; beat 6 is copy buttons, on purpose. + 1. **The green star.** The estate view, banded. Two resources are green; the 100 is a log group nobody will miss, so the eye lands on the 88 next to it. 2. **The arithmetic.** Open `aws_s3_bucket.assets`: 100 minus 12 for one @@ -150,8 +172,10 @@ passes with warnings, but `chant build` fails on two AWS policy rules (`PublicAccessBlockConfiguration` missing, no TLS-deny bucket policy) even though the Terraform declared the first of those. -Verified against chant 0.44.4. The walkthrough's Emit beat should show `lint`, -not `build`, until the fold is applied as well as reported. +Verified against chant 0.44.4. The walkthrough's Emit beat shows `lint`, not +`build`, until the fold is applied as well as reported — the step's "why lint +and not build?" note says the same thing on screen, so nobody reads a lint pass +as a build pass. That is chant#1637. ## Values diff --git a/example-carve/app/.gitignore b/example-carve/app/.gitignore index eebea1f..427218b 100644 --- a/example-carve/app/.gitignore +++ b/example-carve/app/.gitignore @@ -1,3 +1,6 @@ node_modules # chant build artifacts — regenerated by `chant build` template.json +# #254: where `behold demo carve`'s Emit and Bridge steps write. Generated per +# run, inside the demo copy — never committed. +carveout/ diff --git a/package.json b/package.json index eeac65b..3a1733e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "example-k8s", "example-flux-estate", "example-argo-estate", + "example-carve", "demos.json", "AGENTS.md" ], diff --git a/smoke/stub.mjs b/smoke/stub.mjs index 77000b6..37f8130 100644 --- a/smoke/stub.mjs +++ b/smoke/stub.mjs @@ -5,6 +5,7 @@ // g[data-node-id] groups, var(--pin-*) fills, a status-bar rect per node. import http from "node:http"; import { readFile } from "node:fs/promises"; +import { readFileSync } from "node:fs"; import { join, extname, dirname } from "node:path"; import { fileURLToPath } from "node:url"; // #246: the REAL Helm mark, straight out of the pack behold registers with @@ -12,6 +13,9 @@ import { fileURLToPath } from "node:url"; // this one is not, because the check it feeds is about that exact artwork. // (`npm run smoke:ui` runs under tsx, which is what lets a .mjs reach a .ts.) import { helmIconFor } from "../src/icon-packs.ts"; +// #254: the carve lens itself, so carve mode's stub graph is the real +// conversion of the real committed report — see the carve block below. +import { carveReportToIr } from "../src/carve-lens.ts"; const WEB = join(dirname(fileURLToPath(import.meta.url)), "..", "web"); @@ -153,25 +157,179 @@ export const JSON_FIXTURE = { declaredSpec, observedSpec, deepLabels: DEEP_LABEL const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".svg": "image/svg+xml" }; -/** Start the stub on `port`; resolves to the http.Server (close() to stop). */ -export function startStub(port) { +// --------------------------------------------------------------------------- +// #254: carve mode, for the walkthrough half of the smoke. +// +// The report is the REAL committed one (example-carve/carve-report.json) run +// through the REAL lens (src/carve-lens.ts) — so the smoke drives the same IR a +// `behold demo carve` serves, bands and all, rather than a hand-cut stand-in. +// The two POST steps answer canned results shaped exactly like +// src/carve-actions.ts's, which is what makes the six-step walk deterministic: +// no chant, no @cdktf/hcl2json, no npm install in CI. +// --------------------------------------------------------------------------- +const CARVE_REPORT = JSON.parse(readFileSync(join(WEB, "..", "example-carve", "carve-report.json"), "utf8")); +const carveIr = carveReportToIr(CARVE_REPORT); +const carveSvg = ` + + ${carveIr.nodes + .map((n, i) => { + const tone = { good: "good", warn: "warn", neutral: "neutral" }[n.attrs._status] || "neutral"; + return ` + + + ${n.id} + score: ${n.attrs.score} · ${n.attrs.carve} + `; + }) + .join("\n ")} +`; + +const CARVE_DEMO = { + root: "/demos/carve", + from: "/demos/carve/legacy-tf", + state: "/demos/carve/legacy-tf/terraform.tfstate", + project: "/demos/carve/app", + out: "/demos/carve/app/carveout", + outLabel: "app/carveout", + fromLabel: "legacy-tf", + runnable: true, + buildCaveat: "The gate shown is `chant lint`, not `chant build` (chant#1637).", +}; + +/** What POST /api/carve/emit answers — src/carve-actions.ts's CarveEmitResult. */ +export const CARVE_EMIT = { + ok: true, + select: "aws_s3_bucket.assets", + command: + "chant carve emit --from legacy-tf --state legacy-tf/terraform.tfstate --select aws_s3_bucket.assets --output app/carveout", + output: "Carved aws_s3_bucket.assets (peelability 88) — observe position, reversible.\n Emitted: app/carveout/src/assets.ts", + artifacts: [ + { + path: "app/carveout/src/assets.ts", + kind: "ts", + bytes: 142, + truncated: false, + text: 'import { Bucket } from "@intentius/chant-lexicon-aws";\n\nexport const assets = new Bucket({\n BucketName: "acme-platform-assets-prod",\n});\n', + }, + ], + boundary: { + target: "aws_s3_bucket.assets", + inbound: [ + { direction: "inbound", survivor: "aws_lambda_function.api", carved: "aws_s3_bucket.assets", attrs: ["bucket"], bridge: "tf-data-source" }, + ], + outbound: [], + }, + lint: { ok: true, code: 0, command: "chant lint carveout/src", output: " 5:1 warning never referenced COR004\n\n⚠ 3 warnings" }, + buildCaveat: CARVE_DEMO.buildCaveat, +}; + +/** What POST /api/carve/bridge answers — CarveBridgeResult. */ +export const CARVE_BRIDGE = { + ok: true, + select: "aws_s3_bucket.assets", + command: "chant carve bridge --from legacy-tf --select aws_s3_bucket.assets --output app/carveout", + output: "Wrote proposals to app/carveout/ — review, then apply. Nothing in your Terraform changed.", + runbook: { + path: "app/carveout/aws_s3_bucket-assets-runbook.md", + kind: "md", + bytes: 210, + truncated: false, + text: + "# Carve-out: aws_s3_bucket.assets → chant\n\n" + + "## 2. Stop Terraform managing the resource (does NOT destroy it)\n" + + " terraform state rm aws_s3_bucket.assets\n\n" + + "## 3. Confirm no destroy, then patch the survivors\n" + + " terraform plan # expect 0 to destroy\n" + + " terraform apply\n", + }, + proposals: [ + { + path: "app/carveout/aws_s3_bucket-assets-datasources.tf", + kind: "tf", + bytes: 64, + truncated: false, + text: 'data "aws_s3_bucket" "assets" {\n bucket = "acme-platform-assets-prod"\n}\n', + }, + ], +}; + +/** + * Start the stub on `port`; resolves to the http.Server (close() to stop). + * + * `{ carve: true }` serves the #254 walkthrough instead of the project estate: + * the carve graph, `/api/carve`, and the two POST steps. `server.carvePosts` + * records what the page sent, so the smoke can assert the wire contract + * (a JSON body carrying the picked address) and not just the pixels. + */ +export function startStub(port, { carve = false } = {}) { // #228: the hand-layout sidecar, in memory instead of `.behold/layout.json` // — the SAME wire contract src/server.ts serves (lens-keyed deltas, a // `writable` flag on the read), so the smoke drives the client's whole sync // layer without a project on disk. `server.layout` lets the test read and // seed it as if it were the file. const layout = new Map(); + const carvePosts = []; + const readBody = (req) => + new Promise((r) => { + let s = ""; + req.on("data", (c) => (s += c)); + req.on("end", () => r(s || "{}")); + }); const server = http.createServer(async (req, res) => { const url = new URL(req.url, "http://x"); const path = url.pathname; + if (carve) { + const json = (body, code = 200) => { + res.writeHead(code, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); + }; + if (path === "/api/carve") return json(CARVE_REPORT); + if (path === "/api/carve/emit" || path === "/api/carve/bridge") { + const body = JSON.parse(await readBody(req)); + carvePosts.push({ path, method: req.method, contentType: req.headers["content-type"], body }); + // Same refusal shape src/server.ts sends for a select the report + // doesn't rank, so the client's error path is exercisable too. + if (!CARVE_REPORT.resources.some((r) => r.address === body.select)) { + return json({ error: `\`select\` must name a resource this report ranks — ${JSON.stringify(body.select)} isn't one.`, code: "carve-select", remedy: "Pick a card in the graph." }, 400); + } + return json(path.endsWith("emit") ? CARVE_EMIT : CARVE_BRIDGE); + } + if (path === "/api/project") { + return json({ + projectDir: "/demos/carve/carve-report.json", + recents: [], + environments: [], + lexicons: ["terraform"], + currentEnv: null, + targets: [], + carve: { + report: "/demos/carve/carve-report.json", + from: CARVE_REPORT.from, + count: CARVE_REPORT.count, + bands: CARVE_REPORT.bands, + advisory: CARVE_REPORT.advisory, + demo: CARVE_DEMO, + }, + }); + } + if (path === "/api/graph") { + return json({ + ir: carveIr, + svg: carveSvg, + meta: { projectDir: "/demos/carve/carve-report.json", env: null, tier: null, target: null, carve: true, note: "chant carve advisory for legacy-tf" }, + }); + } + if (path === "/api/substrates") return json({ substrates: [] }); + if (path === "/api/history") return json({ commits: [] }); + if (path === "/api/resources") return json({ byComponent: {} }); + if (path === "/api/ci") return json({ stages: [], jobs: [], forge: null }); + if (path === "/api/ops") return json({ ops: [], adoptLexicons: [], autoSync: "off" }); + if (path === "/api/layout") return json({ lens: url.searchParams.get("lens"), writable: false, reason: "a carve report isn't a project", deltas: {} }); + } if (path === "/api/layout") { res.writeHead(200, { "content-type": "application/json" }); if (req.method === "POST") { - const body = JSON.parse(await new Promise((r) => { - let s = ""; - req.on("data", (c) => (s += c)); - req.on("end", () => r(s || "{}")); - })); + const body = JSON.parse(await readBody(req)); if (Object.keys(body.deltas || {}).length) layout.set(body.lens, body.deltas); else layout.delete(body.lens); return res.end(JSON.stringify({ ok: true, lens: body.lens, deltas: body.deltas || {} })); @@ -215,5 +373,6 @@ export function startStub(port) { } }); server.layout = layout; // the sidecar, for the smoke to read and seed (#228) + server.carvePosts = carvePosts; // what the stepper actually sent (#254) return new Promise((resolve) => server.listen(port, () => resolve(server))); } diff --git a/smoke/ui-smoke.mjs b/smoke/ui-smoke.mjs index 9bcf433..ea92a08 100644 --- a/smoke/ui-smoke.mjs +++ b/smoke/ui-smoke.mjs @@ -570,6 +570,145 @@ try { await page.waitForTimeout(100); check("⤢ fit still resets the view", (await viewBox()) === vbBeforePan); + // ---- #254: the carve walkthrough, driven end to end ----------------------- + // A second stub in carve mode (smoke/stub.mjs `{carve: true}`) serving the + // REAL committed report through the REAL lens, and the two POST steps canned + // — so the six steps are deterministic in CI with no chant, no HCL parser and + // no npm install. What's asserted is the walkthrough's own contract: the tab + // exists only here, the gates hold in order, a run posts the picked address, + // Emit shows lint and never build, Handoff is copy buttons rather than a run + // button, and Done leaves a marker on the card. + const carveServer = await startStub(PORT + 1, { carve: true }); + const carvePage = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + carvePage.on("pageerror", (e) => pageErrors.push("carve: " + String(e))); + carvePage.on("console", (m) => { + if (m.type() === "error" && !/favicon/i.test(m.text())) pageErrors.push("carve: " + m.text()); + }); + try { + check("the Carve tab is absent on an ordinary project", (await page.locator('#panel-tabs button[data-tab="carve"]').count()) === 0); + + await carvePage.goto(`http://localhost:${PORT + 1}/`); + await carvePage.waitForSelector("#graph svg [data-node-id]", { timeout: 20000 }); + await carvePage.waitForSelector('#panel-tabs button[data-tab="carve"]', { timeout: 10000 }); + check("carve mode mounts the Carve tab", (await carvePage.locator('#panel-tabs button[data-tab="carve"]').count()) === 1); + await carvePage.click('#panel-tabs button[data-tab="carve"]'); + await carvePage.waitForTimeout(150); + + const step = (id) => carvePage.locator(`#tab-carve .carve-step[data-step="${id}"]`); + const stepState = (id) => step(id).getAttribute("data-status"); + const bodyStep = () => carvePage.locator("#tab-carve .carve-body").getAttribute("data-step"); + const carveText = () => carvePage.locator("#tab-carve").innerText(); + + // 1. Advise — the band legend, with the real report's counts. + check("six steps on the track", (await carvePage.locator("#tab-carve .carve-step").count()) === 6); + check("boots on Advise", (await bodyStep()) === "advise"); + const advise = await carveText(); + check("Advise shows the band legend with real counts", advise.includes("carve now") && advise.includes("boundary work") && /\b6\b/.test(advise)); + check("Advise names where the runs will write", advise.includes("app/carveout")); + check("emit is blocked before anything is picked", (await stepState("emit")) === "blocked"); + check("…and the blocked step says why", (await step("emit").getAttribute("title")).includes("Pick a resource first")); + + // 2. Pick — a click on the green star, and the honest cut summary. + await carvePage.click('#graph [data-node-id="aws_s3_bucket.assets"]'); + await carvePage.waitForTimeout(200); + check("a graph click IS the Pick step", (await bodyStep()) === "pick"); + const pick = await carveText(); + check("Pick shows the score arithmetic", pick.includes("100 - 12x1 inbound = 88")); + check("Pick names the cut from the breakdown's counts", pick.includes("1 inbound")); + check("…and says plainly that the survivors aren't in this report (chant#1636)", pick.includes("chant#1636")); + check("the inspect pane opened on the same node", (await carvePage.locator("#inspect").innerText()).includes("aws_s3_bucket.assets")); + check("emit unblocks once something is picked", (await stepState("emit")) !== "blocked"); + check("bridge stays blocked until emit has run", (await stepState("bridge")) === "blocked"); + + // 3. Emit — a real POST, then the emitted source and the lint verdict. + await step("emit").click(); + await carvePage.click("#tab-carve button.carve-run"); + await carvePage.waitForSelector('#tab-carve .carve-body[data-step="emit"] .carve-artifact', { timeout: 15000 }); + const emitPost = carveServer.carvePosts.find((p) => p.path === "/api/carve/emit"); + check("Emit posted the picked address as JSON", !!emitPost && emitPost.body.select === "aws_s3_bucket.assets"); + check("…as an application/json body (so a cross-origin page preflights)", !!emitPost && emitPost.contentType.includes("application/json")); + const emit = await carveText(); + check("Emit shows the emitted source file", emit.includes("app/carveout/src/assets.ts") && emit.includes("new Bucket")); + check("Emit shows the chant lint verdict", emit.includes("chant lint: passes")); + check("Emit counts warnings off chant's summary line, not a column number", emit.includes("3 warning(s)")); + check("Emit never claims a chant build", !/chant build/.test(emit) || emit.includes("not `chant build`")); + check("Emit links the build caveat (chant#1637)", (await carvePage.locator("#tab-carve .carve-caveat").innerText()).includes("why lint and not build")); + check("the boundary report renders as a JSON tree, not a blob", (await carvePage.locator("#tab-carve .jsonv").count()) >= 1); + check("a finished run does NOT skip past its own result", (await bodyStep()) === "emit"); + check("bridge unblocks once emit has run", (await stepState("bridge")) !== "blocked"); + await carvePage.screenshot({ path: join(SHOTS, "8-carve-emit.png"), fullPage: true }); + + // 4. Bridge — proposals only, never --apply-rewrites. + await step("bridge").click(); + await carvePage.click("#tab-carve button.carve-run"); + await carvePage.waitForSelector('#tab-carve .carve-body[data-step="bridge"] .carve-artifact', { timeout: 15000 }); + const bridge = await carveText(); + check("Bridge posted the same address", carveServer.carvePosts.some((p) => p.path === "/api/carve/bridge" && p.body.select === "aws_s3_bucket.assets")); + check("Bridge renders the proposed data source", bridge.includes("aws_s3_bucket-assets-datasources.tf") && bridge.includes('data "aws_s3_bucket"')); + check("Bridge says your Terraform wasn't touched", bridge.includes("Nothing in your Terraform changed")); + + // 5. Handoff — copy buttons, and the refusal to be a button. + await step("handoff").click(); + await carvePage.waitForTimeout(150); + const handoff = await carveText(); + check("Handoff renders the runbook's commands", handoff.includes("terraform state rm aws_s3_bucket.assets")); + check("…deduped (the runbook prints `terraform plan` twice)", (handoff.match(/terraform plan/g) || []).length === 1); + check("Handoff has a copy button per command", (await carvePage.locator("#tab-carve .carve-cmd-row .carve-copy").count()) === 3); + check("Handoff has NO run button — the destructive middle stays human", (await carvePage.locator("#tab-carve button.carve-run").count()) === 0); + check("…and the UI says why", handoff.includes("not buttons, on purpose") && handoff.includes("terraform state rm")); + const copyCtl = carvePage.locator("#tab-carve .carve-cmd-row .carve-copy").first(); + await copyCtl.click(); + await carvePage.waitForTimeout(120); + check("clicking copy reports back on the control", (await copyCtl.getAttribute("data-copied")) === "1"); + let carveClip = null; + try { + await carvePage.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: `http://localhost:${PORT + 1}` }); + carveClip = await carvePage.evaluate(() => navigator.clipboard.readText()); + } catch { + /* no readable clipboard here — the control's own report is the assertion */ + } + if (carveClip !== null) check("the clipboard holds the command verbatim", carveClip.startsWith("terraform state rm")); + await carvePage.screenshot({ path: join(SHOTS, "9-carve-handoff.png"), fullPage: true }); + + // 6. Done — the marker on the card, and the end card. + await carvePage.click("#tab-carve button.carve-ran"); + await carvePage.waitForTimeout(200); + check("acknowledging the handoff lands on Done", (await bodyStep()) === "done"); + const done = await carveText(); + check("the end card is #254's line", done.includes("chant-owned, observe position") && done.includes("terraform import")); + check("the follow-up is named, not implied", done.includes("SLIDE") && done.includes("--live")); + const marker = carvePage.locator('#graph [data-node-id="aws_s3_bucket.assets"] [data-carved="1"]'); + check("the carved card carries a marker", (await marker.count()) === 1); + check("…inside the card's own box, not at the group's origin", await marker.evaluate((t) => Number(t.getAttribute("x")) > 0 && Number(t.getAttribute("y")) > 0)); + check("every step now reads done", (await carvePage.locator('#tab-carve .carve-step[data-status="done"]').count()) === 5); + await carvePage.screenshot({ path: join(SHOTS, "10-carve-done.png"), fullPage: true }); + + // Picking a different resource invalidates the runs that were about the old + // one — showing one resource's emitted source under another's name is the + // one way this panel could actively lie. + await carvePage.click('#graph [data-node-id="aws_cloudwatch_log_group.worker"]'); + await carvePage.waitForTimeout(200); + check("a new pick drops the previous runs", (await bodyStep()) === "pick" && (await stepState("bridge")) === "blocked"); + + // A resource with no native mapping is refused here, not after a round trip + // to chant — the advisor already scored it 0 and said why. + await carvePage.click('#graph [data-node-id="random_pet.suffix"]'); + await carvePage.waitForTimeout(200); + check("an unmappable resource says so at Pick time", (await carveText()).includes("no known native mapping")); + await step("emit").click(); + await carvePage.waitForTimeout(100); + check("…and its Emit button is disabled rather than dead-ending in chant's error", await carvePage.locator("#tab-carve button.carve-run").isDisabled()); + + // The track is a state machine, not six tabs: a blocked step refuses the + // click rather than showing an empty panel. + await step("done").click(); + await carvePage.waitForTimeout(100); + check("a blocked step refuses navigation", (await bodyStep()) === "emit"); + } finally { + await carvePage.close(); + carveServer.close(); + } + check("no page errors", pageErrors.length === 0); if (pageErrors.length) console.error("page errors:", pageErrors); } finally { diff --git a/src/carve-actions.test.ts b/src/carve-actions.test.ts new file mode 100644 index 0000000..4714778 --- /dev/null +++ b/src/carve-actions.test.ts @@ -0,0 +1,357 @@ +// #254: the carve walkthrough's two local actions and the write boundary +// around them. +// +// The successful runs are driven against a FAKE chant installed into a temp +// demo copy — a package.json + a bin script that records its argv and writes +// what real chant writes. That is the point: what is being tested here is +// behold's half of the contract (the argv it builds, the directory it creates +// before chant needs it, what it reads back, what it refuses), not chant's. +// The real chant runs in the walkthrough itself, and `example-carve/README.md` +// records the version those outputs were verified against. +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "./server.ts"; +import { Broadcaster } from "./events.ts"; +import { FrameBuffer } from "./frames.ts"; +import { OpRunner } from "./op-runner.ts"; +import { + carveSlug, + carveWriteBlock, + insideDemo, + readArtifacts, + runCarveBridge, + runCarveEmit, + selectFromReport, + shortenIn, + type CarveDemo, +} from "./carve-actions.ts"; +import type { CarveReport } from "./carve-lens.ts"; + +const REPORT: CarveReport = { + from: "legacy-tf", + count: 2, + bands: { "clean leaf": 1, "leave in Terraform": 1 }, + resources: [ + { address: "aws_s3_bucket.assets", score: 88, band: "clean leaf", mapsTo: "AWS::S3::Bucket", breakdown: { inbound: 1, outbound: 0, tier: 1 } }, + { address: "random_pet.suffix", score: 0, band: "leave in Terraform", breakdown: { tier: null } }, + ], +}; + +/** + * A demo copy with a fake project-local chant. + * + * The bin script appends its argv to `argv.log` and then does what the real + * command does to the filesystem, so the assertions below can be about paths + * and flags rather than about mocks. + */ +function fakeDemo(): { demo: CarveDemo; argv: () => string[][]; root: string } { + const root = mkdtempSync(join(tmpdir(), "behold-carve-demo-")); + mkdirSync(join(root, "legacy-tf"), { recursive: true }); + writeFileSync(join(root, "legacy-tf", "storage.tf"), 'resource "aws_s3_bucket" "assets" {}\n'); + writeFileSync(join(root, "legacy-tf", "terraform.tfstate"), "{}\n"); + writeFileSync(join(root, "carve-report.json"), JSON.stringify(REPORT)); + + const chantRoot = join(root, "app", "node_modules", "@intentius", "chant"); + mkdirSync(join(chantRoot, "bin"), { recursive: true }); + writeFileSync( + join(chantRoot, "package.json"), + JSON.stringify({ name: "@intentius/chant", version: "9.9.9", main: "index.js", bin: { chant: "bin/chant.mjs" } }), + ); + writeFileSync(join(chantRoot, "index.js"), "export default {};\n"); + const bin = join(chantRoot, "bin", "chant.mjs"); + writeFileSync( + bin, + `#!/usr/bin/env node +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +const argv = process.argv.slice(2); +appendFileSync(${JSON.stringify(join(root, "argv.log"))}, JSON.stringify(argv) + "\\n"); +const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; }; +if (argv[0] === "carve" && argv[1] === "emit") { + const out = flag("--output"); + const report = flag("--report"); + // Real chant writes --report BEFORE it creates --output (verified against + // 0.44.4), so this throws unless behold made the directory first. + writeFileSync(report, JSON.stringify({ target: flag("--select"), inbound: [{ direction: "inbound", survivor: "aws_lambda_function.api", carved: flag("--select") }], outbound: [] }, null, 2)); + mkdirSync(join(out, "src"), { recursive: true }); + writeFileSync(join(out, "src", "assets.ts"), "export const assets = new Bucket({});\\n"); + writeFileSync(join(out, "package.json"), '{"name":"chant-carveout"}'); + writeFileSync(join(out, "tsconfig.json"), "{}"); + console.log("Carved " + flag("--select") + " (peelability 88) — observe position, reversible."); + console.log(" Emitted: " + join(out, "src", "assets.ts")); + process.exit(0); +} +if (argv[0] === "carve" && argv[1] === "bridge") { + if (argv.includes("--apply-rewrites")) { console.error("behold must never pass --apply-rewrites"); process.exit(3); } + const out = flag("--output"); + const slug = String(flag("--select")).replace(/[^A-Za-z0-9_]+/g, "-"); + mkdirSync(out, { recursive: true }); + writeFileSync(join(out, slug + "-runbook.md"), "# Carve-out\\n\\n## 2. Stop Terraform managing it\\n terraform state rm " + flag("--select") + "\\n"); + writeFileSync(join(out, slug + "-datasources.tf"), 'data "aws_s3_bucket" "assets" {}\\n'); + writeFileSync(join(out, slug + "-bridge.patch"), "diff --git a/storage.tf b/storage.tf\\n"); + console.log("Wrote proposals to " + out); + process.exit(0); +} +if (argv[0] === "lint") { + console.log(" 5:1 warning Exported declarable 'assets' is never referenced. COR004"); + console.log("\\u26a0 1 warnings"); + process.exit(0); +} +console.error("fake chant: unexpected " + argv.join(" ")); +process.exit(2); +`, + ); + chmodSync(bin, 0o755); + + return { + root, + demo: { + root, + from: join(root, "legacy-tf"), + state: join(root, "legacy-tf", "terraform.tfstate"), + project: join(root, "app"), + out: join(root, "app", "carveout"), + }, + argv: () => + readFileSync(join(root, "argv.log"), "utf8") + .split("\n") + .filter(Boolean) + .map((l) => JSON.parse(l) as string[]), + }; +} + +describe("insideDemo — the containment check", () => { + it("accepts a path under the root and refuses everything else", () => { + expect(insideDemo("/demo", "/demo/app/carveout")).toBe(true); + expect(insideDemo("/demo", "/demo")).toBe(false); // the root itself is not "inside" it + expect(insideDemo("/demo", "/demo/../elsewhere")).toBe(false); + expect(insideDemo("/demo", "/etc/passwd")).toBe(false); + }); +}); + +describe("selectFromReport — membership, not syntax", () => { + it("accepts an address the report ranks", () => { + expect(selectFromReport(REPORT, "aws_s3_bucket.assets")).toBe("aws_s3_bucket.assets"); + }); + it("refuses anything the report doesn't rank, however well-formed", () => { + expect(selectFromReport(REPORT, "aws_s3_bucket.other")).toBeNull(); + expect(selectFromReport(REPORT, "; rm -rf /")).toBeNull(); + expect(selectFromReport(REPORT, "../../etc/passwd")).toBeNull(); + expect(selectFromReport(REPORT, 42)).toBeNull(); + expect(selectFromReport(undefined, "aws_s3_bucket.assets")).toBeNull(); + }); +}); + +describe("carveWriteBlock", () => { + it("refuses with no demo at all — the actions don't exist outside one", () => { + expect(carveWriteBlock(undefined)).toContain("behold demo carve"); + }); + it("refuses an output directory outside the copy", () => { + const d = fakeDemo(); + expect(carveWriteBlock({ ...d.demo, out: "/tmp/somewhere-else" })).toContain("outside the demo copy"); + rmSync(d.root, { recursive: true, force: true }); + }); + it("passes on a real copy", () => { + const d = fakeDemo(); + expect(carveWriteBlock(d.demo)).toBeNull(); + rmSync(d.root, { recursive: true, force: true }); + }); +}); + +describe("carveSlug / shortenIn", () => { + it("matches chant's own artifact naming", () => { + expect(carveSlug("aws_s3_bucket.assets")).toBe("aws_s3_bucket-assets"); + expect(carveSlug("module.cdn")).toBe("module-cdn"); + }); + it("takes the copy's prefix off an echoed path, and leaves plain flags alone", () => { + expect(shortenIn("/demo/legacy-tf", "/demo")).toBe("legacy-tf"); + expect(shortenIn("/demo", "/demo")).toBe("."); + expect(shortenIn("--select", "/demo")).toBe("--select"); + expect(shortenIn("/elsewhere/tf", "/demo")).toBe("/elsewhere/tf"); + }); +}); + +describe("readArtifacts", () => { + it("reads relative to the copy, skips node_modules and dotfiles, and honours the filter", () => { + const d = fakeDemo(); + mkdirSync(join(d.demo.out, "src"), { recursive: true }); + mkdirSync(join(d.demo.out, "node_modules"), { recursive: true }); + writeFileSync(join(d.demo.out, "src", "a.ts"), "a"); + writeFileSync(join(d.demo.out, ".hidden"), "h"); + writeFileSync(join(d.demo.out, "node_modules", "b.ts"), "b"); + writeFileSync(join(d.demo.out, "notes.md"), "m"); + const found = readArtifacts(d.demo, d.demo.out, (rel) => rel.endsWith(".ts")); + expect(found.map((a) => a.path)).toEqual(["app/carveout/src/a.ts"]); + expect(found[0].kind).toBe("ts"); + expect(found[0].truncated).toBe(false); + rmSync(d.root, { recursive: true, force: true }); + }); +}); + +describe("runCarveEmit — against a fake project-local chant", () => { + let d: ReturnType; + beforeAll(async () => { + d = fakeDemo(); + }); + afterAll(() => rmSync(d.root, { recursive: true, force: true })); + + it("builds the offline argv, creates the output dir first, and reads the result back", async () => { + const result = await runCarveEmit(d.demo, "aws_s3_bucket.assets"); + expect("ok" in result && result.ok).toBe(true); + if (!("ok" in result) || !result.ok) return; + + const [emitArgv, lintArgv] = d.argv(); + expect(emitArgv.slice(0, 2)).toEqual(["carve", "emit"]); + expect(emitArgv).toContain("--state"); // offline: the tfstate, never --env + expect(emitArgv).not.toContain("--env"); + expect(emitArgv[emitArgv.indexOf("--select") + 1]).toBe("aws_s3_bucket.assets"); + expect(emitArgv[emitArgv.indexOf("--output") + 1]).toBe(d.demo.out); + + // #254 clause 4: the gate shown is lint, and chant build is never run. + expect(lintArgv).toEqual(["lint", "carveout/src"]); + expect(d.argv().some((a) => a[0] === "build")).toBe(false); + expect(result.lint.ok).toBe(true); + expect(result.buildCaveat).toContain("chant#1637"); + + // The echoed command is retypeable — no absolute host paths in it. + expect(result.command).toContain("--output app/carveout"); + expect(result.command).not.toContain(d.root); + expect(result.output).not.toContain(d.root); + + expect(result.artifacts.map((a) => a.path)).toContain("app/carveout/src/assets.ts"); + // Emitted source sorts above the scaffolding. + expect(result.artifacts[0].path).toBe("app/carveout/src/assets.ts"); + expect((result.boundary as { target: string }).target).toBe("aws_s3_bucket.assets"); + }); +}); + +describe("runCarveBridge — against a fake project-local chant", () => { + it("never passes --apply-rewrites, and splits the runbook out from the proposals", async () => { + const d = fakeDemo(); + const result = await runCarveBridge(d.demo, "aws_s3_bucket.assets"); + expect("ok" in result && result.ok).toBe(true); + if (!("ok" in result) || !result.ok) return; + const [argv] = d.argv(); + expect(argv.slice(0, 2)).toEqual(["carve", "bridge"]); + // The fake exits 3 if it ever sees the flag, so an ok result is itself the + // proof — asserted directly too, because this is the whole safety claim. + expect(argv).not.toContain("--apply-rewrites"); + expect(result.runbook?.path).toBe("app/carveout/aws_s3_bucket-assets-runbook.md"); + expect(result.proposals.map((a) => a.path).sort()).toEqual([ + "app/carveout/aws_s3_bucket-assets-bridge.patch", + "app/carveout/aws_s3_bucket-assets-datasources.tf", + ]); + rmSync(d.root, { recursive: true, force: true }); + }); + + it("refuses politely when chant fails, in #193's shape", async () => { + const d = fakeDemo(); + const result = await runCarveBridge(d.demo, "random_pet.suffix"); + // The fake writes for any select; make it fail by pointing `from` at a + // directory that isn't there — the same class of failure a missing HCL + // parser produces. + const broken = await runCarveBridge({ ...d.demo, from: join(d.root, "nope") }, "random_pet.suffix"); + expect("ok" in result && result.ok).toBe(true); + expect("ok" in broken && broken.ok).toBe(false); + if ("ok" in broken && !broken.ok) { + expect(broken.refusal.code).toBe("read-only"); + expect(broken.refusal.error).toContain("no Terraform estate"); + } + rmSync(d.root, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// The HTTP half: the two routes exist only in carve mode, act only inside a +// demo, and refuse everything else politely. +// --------------------------------------------------------------------------- +function appFor(demo?: CarveDemo, reportPath?: string) { + const dir = demo ? demo.root : mkdtempSync(join(tmpdir(), "behold-carve-plain-")); + const report = reportPath ?? join(dir, "carve-report.json"); + writeFileSync(report, JSON.stringify(REPORT)); + const broadcaster = new Broadcaster(); + return createApp( + { projectDir: dir, carveReport: report, ...(demo ? { carveDemo: demo } : {}), port: 0 }, + broadcaster, + new FrameBuffer(), + new OpRunner({ projectDir: dir, broadcaster, onDone: () => {} }), + ); +} + +const post = (app: ReturnType, path: string, body: unknown, headers: Record = { "content-type": "application/json" }) => + app.request(path, { method: "POST", headers, body: typeof body === "string" ? body : JSON.stringify(body) }); + +describe("POST /api/carve/{emit,bridge} — the write boundary over HTTP", () => { + it("is absent from an ordinary project serve", async () => { + const broadcaster = new Broadcaster(); + const app = createApp({ projectDir: "/work/loomster", port: 0 }, broadcaster, new FrameBuffer(), new OpRunner({ projectDir: "/work/loomster", broadcaster, onDone: () => {} })); + expect((await post(app, "/api/carve/emit", { select: "aws_s3_bucket.assets" })).status).toBe(404); + }); + + it("refuses in plain carve mode — the steps exist only inside a demo copy", async () => { + const app = appFor(); + const res = await post(app, "/api/carve/emit", { select: "aws_s3_bucket.assets" }); + expect(res.status).toBe(403); + const body = (await res.json()) as { code: string; error: string; remedy: string }; + expect(body.code).toBe("read-only"); + expect(body.remedy).toContain("behold demo carve"); + }); + + it("refuses a select the report doesn't rank", async () => { + const d = fakeDemo(); + const res = await post(appFor(d.demo), "/api/carve/emit", { select: "aws_s3_bucket.somebody_elses" }); + expect(res.status).toBe(400); + expect(((await res.json()) as { code: string }).code).toBe("carve-select"); + rmSync(d.root, { recursive: true, force: true }); + }); + + it("refuses a non-JSON body and an oversized one", async () => { + const d = fakeDemo(); + const app = appFor(d.demo); + expect((await post(app, "/api/carve/bridge", "select=x", { "content-type": "text/plain" })).status).toBe(415); + expect((await post(app, "/api/carve/bridge", JSON.stringify({ select: "x".repeat(5000) }))).status).toBe(413); + rmSync(d.root, { recursive: true, force: true }); + }); + + it("runs the step and answers the artifacts when the demo is real", async () => { + const d = fakeDemo(); + const res = await post(appFor(d.demo), "/api/carve/emit", { select: "aws_s3_bucket.assets" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { select: string; artifacts: Array<{ path: string }>; lint: { ok: boolean } }; + expect(body.select).toBe("aws_s3_bucket.assets"); + expect(body.artifacts.map((a) => a.path)).toContain("app/carveout/src/assets.ts"); + expect(body.lint.ok).toBe(true); + rmSync(d.root, { recursive: true, force: true }); + }); +}); + +describe("GET /api/project — the demo block the stepper gates its buttons on", () => { + it("says runnable, with copy-relative labels, inside a demo", async () => { + const d = fakeDemo(); + const body = (await (await appFor(d.demo).request("/api/project")).json()) as { + carve: { demo: { runnable: boolean; outLabel: string; fromLabel: string; buildCaveat: string } }; + }; + expect(body.carve.demo.runnable).toBe(true); + expect(body.carve.demo.outLabel).toBe("app/carveout"); + expect(body.carve.demo.fromLabel).toBe("legacy-tf"); + expect(body.carve.demo.buildCaveat).toContain("chant#1637"); + rmSync(d.root, { recursive: true, force: true }); + }); + + it("says nothing at all outside one, so the stepper greys its runs out", async () => { + const body = (await (await appFor().request("/api/project")).json()) as { carve: { demo: unknown } }; + expect(body.carve.demo).toBeNull(); + }); + + it("carries the degraded note when the boot's own advisor run failed", async () => { + const d = fakeDemo(); + const app = appFor({ ...d.demo, degraded: "chant carve advise exited 1 — showing the committed report." }); + const project = (await (await app.request("/api/project")).json()) as { carve: { demo: { degraded: string } } }; + expect(project.carve.demo.degraded).toContain("committed report"); + const graph = (await (await app.request("/api/graph")).json()) as { meta: { note: string } }; + expect(graph.meta.note).toContain("Degraded:"); + rmSync(d.root, { recursive: true, force: true }); + }); +}); diff --git a/src/carve-actions.ts b/src/carve-actions.ts new file mode 100644 index 0000000..dc4b1ce --- /dev/null +++ b/src/carve-actions.ts @@ -0,0 +1,368 @@ +/** + * The carve walkthrough's two local actions (#254, M1.5 of #230) — `chant carve + * emit` and `chant carve bridge`, run inside a `behold demo carve` copy. + * + * THE WRITE BOUNDARY. This is behold's second write (src/layout.ts is the + * first), and it is drawn tighter than that one: + * + * * It exists ONLY inside a demo copy. `CarveDemo` is built by `behold demo + * carve` from the directory it just copied — never from a request, never + * from a query param. A plain `behold carve report.json` has no demo, and + * the two routes refuse; a project `serve` never registers them at all. + * * ONE directory is written: `` (`app/carveout` in the copy). Both + * chant runs are handed `--output `, and `carve bridge` is run + * WITHOUT `--apply-rewrites`, so the surviving Terraform is never edited — + * the rewritten `.tf` lands beside the runbook as a proposal. + * * The only value off the wire is `select`, and it must be an address the + * report already ranks (`selectFromReport`). Nothing string-shaped from a + * request reaches a path or a shell — the args are an argv array, and the + * one request-derived element is drawn from a closed set. + * * Everything read back is read from inside ``, capped, and + * truncated rather than streamed whole. + * + * The invariant is untouched: no cloud write, no Terraform mutation, no edit to + * anyone's chant source. The demo copy is the user's own scratch directory, and + * a carve emits into it exactly the way the CLI would. + * + * Emit shows `chant lint`, never `chant build` — chant#1637: the emitted bucket + * folds its versioning/public-access-block sub-resources into the carve set but + * does not yet carry them as native props, so `build` fails two AWS policy rules + * on source the advisor called clean. `lint` is the honest gate today, and + * example-carve/README.md carries the full statement of it. + */ +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { runChantRaw, stripAnsi } from "./chant.ts"; +import { unwritableReason } from "./layout.ts"; +import type { CarveReport } from "./carve-lens.ts"; + +/** + * A booted `behold demo carve` copy — the only context in which the carve + * actions exist. Every path is absolute and inside `root`; `runDemo` + * (src/cli.ts) is the only thing that builds one. + */ +export interface CarveDemo { + /** The demo copy's root. The containment check's ceiling. */ + root: string; + /** The Terraform estate to carve from (`legacy-tf/`). */ + from: string; + /** The synthetic `.tfstate` — what makes emit work offline. */ + state?: string; + /** The chant project whose own chant + lexicon the runs use (`app/`). */ + project: string; + /** Where emitted source, proposals and the runbook land (`app/carveout/`). */ + out: string; + /** Set when the boot's own `carve advise` failed and the committed report is + * being served instead — surfaced in the UI rather than swallowed. */ + degraded?: string; +} + +/** Caps on what comes back through the wire. A carve emits one source file and + * a handful of proposals; anything past these is a mistake, and the answer is a + * truncated read, never an unbounded one. */ +export const MAX_ARTIFACT_BYTES = 64 * 1024; +export const MAX_ARTIFACTS = 24; +export const MAX_OUTPUT_BYTES = 32 * 1024; + +/** One file the run wrote, as the client renders it. */ +export interface CarveArtifact { + /** Path relative to the demo copy's root — never an absolute host path. */ + path: string; + /** File extension without the dot (`ts`, `tf`, `md`, `patch`, `json`). */ + kind: string; + bytes: number; + text: string; + /** True when `text` is only the first {@link MAX_ARTIFACT_BYTES} of the file. */ + truncated: boolean; +} + +/** A refusal, in #193's structured shape — what the stepper renders in place of + * the step's result. */ +export interface CarveActionRefusal { + error: string; + code: "carve-action" | "carve-select" | "read-only"; + remedy: string; +} + +export interface CarveEmitResult { + ok: true; + select: string; + /** The argv actually run, for the UI to show verbatim. */ + command: string; + output: string; + artifacts: CarveArtifact[]; + /** chant's boundary report for this carve — the edges the cut severs, named. + * Present whether or not `carve advise` published edge lists (chant#1636): + * `carve emit --report` writes them per-carve regardless. */ + boundary: unknown; + lint: { ok: boolean; code: number; command: string; output: string }; + /** chant#1637 — why the gate is lint and not build. */ + buildCaveat: string; +} + +export interface CarveBridgeResult { + ok: true; + select: string; + command: string; + output: string; + /** The handoff runbook, split into its own field: step 5 is built from it. */ + runbook: CarveArtifact | null; + /** The proposed survivor edits — data sources, rewritten `.tf`, the patch. */ + proposals: CarveArtifact[]; +} + +export type CarveActionResult = T | { ok: false; refusal: CarveActionRefusal }; + +const refuse = (code: CarveActionRefusal["code"], error: string, remedy: string): { ok: false; refusal: CarveActionRefusal } => ({ + ok: false, + refusal: { error, code, remedy }, +}); + +/** chant#1637, stated once. */ +export const 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."; + +/** Is `p` inside `root`? Lexical containment on resolved paths — both come from + * this process (the demo copy and a path built from it), so there is no symlink + * game to play here; this is the belt to `CarveDemo`'s braces. */ +export function insideDemo(root: string, p: string): boolean { + const rel = relative(resolve(root), resolve(p)); + return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); +} + +/** + * The address a request asked to carve, or null. + * + * The check is membership, not syntax: `select` must be an address the report + * in hand already ranks. That is the tightest possible validation — the value + * that reaches the spawn's argv is drawn from a closed set the server read off + * disk, so no request can name anything chant wasn't already going to talk + * about. A syntactic regex would be a weaker claim about the same argument. + */ +export function selectFromReport(report: CarveReport | undefined, raw: unknown): string | null { + if (typeof raw !== "string" || !raw || raw.length > 512) return null; + return (report?.resources ?? []).some((r) => r.address === raw) ? raw : null; +} + +/** Why this demo can't take a carve run, or null when it can. */ +export function carveWriteBlock(demo: CarveDemo | undefined): string | null { + if (!demo) { + return ( + "this server isn't running a carve demo — the carve actions only exist inside a `behold demo carve` copy" + ); + } + if (!existsSync(demo.from)) return `the demo copy has no Terraform estate at ${demo.from}`; + if (!insideDemo(demo.root, demo.out)) return "the carve output directory is outside the demo copy"; + return unwritableReason(demo.root); +} + +/** Every file under `dir` (one level of nesting is enough for a carveout), as + * artifacts relative to the demo root. Capped, sorted, and read defensively — + * an unreadable file is skipped, never fatal. */ +export function readArtifacts(demo: CarveDemo, dir: string, filter: (rel: string) => boolean): CarveArtifact[] { + const out: CarveArtifact[] = []; + const walk = (d: string, depth: number): void => { + if (out.length >= MAX_ARTIFACTS || depth > 2) return; + let entries: string[]; + try { + entries = readdirSync(d).sort(); + } catch { + return; + } + for (const name of entries) { + if (out.length >= MAX_ARTIFACTS) return; + if (name === "node_modules" || name.startsWith(".")) continue; + const full = join(d, name); + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (st.isDirectory()) { + walk(full, depth + 1); + continue; + } + const rel = relative(demo.root, full).split(sep).join("/"); + if (!filter(rel)) continue; + let text = ""; + try { + text = readFileSync(full, "utf8"); + } catch { + continue; + } + const truncated = text.length > MAX_ARTIFACT_BYTES; + out.push({ + path: rel, + kind: (name.split(".").pop() ?? "").toLowerCase(), + bytes: st.size, + text: truncated ? text.slice(0, MAX_ARTIFACT_BYTES) : text, + truncated, + }); + } + }; + walk(dir, 0); + return out; +} + +/** chant's own slug for a carve target (`aws_s3_bucket.assets` → + * `aws_s3_bucket-assets`), which is how its artifacts are named. Mirrors + * carve-bridge.ts's `select.replace(/[^A-Za-z0-9_]+/g, "-")`. */ +export function carveSlug(select: string): string { + return select.replace(/[^A-Za-z0-9_]+/g, "-"); +} + +const clip = (s: string): string => (s.length > MAX_OUTPUT_BYTES ? s.slice(0, MAX_OUTPUT_BYTES) + "\n… (truncated)" : s); + +/** One argv element with the demo copy's absolute prefix taken off, so the + * echoed command line is the one a viewer could retype. */ +export function shortenIn(arg: string, root: string): string { + return arg.startsWith(root + sep) ? arg.slice(root.length + 1) : arg === root ? "." : arg; +} + +/** + * stdout + stderr as one block, the way the CLI prints it — minus chant's ANSI + * colours (the spawn's stdout isn't a TTY but chant colours it anyway; see + * chant.ts's note on `isTTY`), and minus the demo copy's absolute prefix. + * + * The path rewrite is presentation, not redaction: chant echoes the absolute + * `--from`/`--output` it was handed, and a card that reads + * `/private/tmp/.../demorun/app/carveout/src/assets.ts` teaches a viewer + * nothing except how long the operator's tmpdir is. `app/carveout/src/assets.ts` + * is the same fact at the scale the picture is about. + */ +function merge(r: { stdout: string; stderr: string }, root: string): string { + const joined = stripAnsi([r.stdout, r.stderr].filter((s) => s.trim()).join("\n").trim()); + return clip(joined.split(root + sep).join("").split(root).join(".")); +} + +/** + * Step 3 — `chant carve emit --state --select --output `, then + * `chant lint` on what it wrote. + * + * chant writes `--report` before it creates `--output`, so the directory is + * made here first; without that the run exits having written nothing but an + * ENOENT (verified against chant 0.44.4). + */ +export async function runCarveEmit(demo: CarveDemo, select: string): Promise> { + const block = carveWriteBlock(demo); + if (block) return refuse("read-only", block, "Start the walkthrough with `behold demo carve`."); + + mkdirSync(demo.out, { recursive: true }); + const reportFile = join(demo.out, `${carveSlug(select)}-boundary.json`); + const args = [ + "carve", + "emit", + "--from", + demo.from, + ...(demo.state ? ["--state", demo.state] : []), + "--select", + select, + "--output", + demo.out, + "--report", + reportFile, + ]; + const run = await runChantRaw(args, demo.project).catch((err: unknown) => ({ + code: 127, + stdout: "", + stderr: err instanceof Error ? err.message : String(err), + })); + const output = merge(run, demo.root); + if (run.code !== 0) { + return refuse( + "carve-action", + `chant carve emit exited ${run.code}: ${output || "(no output)"}`, + "The offline emit needs `@cdktf/hcl2json` in the demo copy and the demo's own chant install — " + + "`npm install` in the copy's `app/` and re-run `behold demo carve`.", + ); + } + + let boundary: unknown = null; + try { + boundary = JSON.parse(readFileSync(reportFile, "utf8")); + } catch { + /* the run said ok; a missing report is a chant that stopped writing one */ + } + + // Lint from the chant project, by a path relative to it: the emitted source + // imports the AWS lexicon, and Node resolves that from where the FILE sits. + // `app/carveout/src` resolves through `app/node_modules`; a carveout beside + // `app/` would not resolve at all. + const lintPath = relative(demo.project, join(demo.out, "src")).split(sep).join("/"); + const lintArgs = ["lint", lintPath]; + const lint = await runChantRaw(lintArgs, demo.project).catch((err: unknown) => ({ + code: 127, + stdout: "", + stderr: err instanceof Error ? err.message : String(err), + })); + + return { + ok: true, + select, + command: `chant ${args.map((a) => shortenIn(a, demo.root)).join(" ")}`, + output, + // Emitted source first, scaffolding after: `src/assets.ts` is the thing the + // step is about, and a package.json sorting above it buries the answer. + artifacts: readArtifacts(demo, demo.out, (rel) => /\.(ts|json)$/.test(rel) && !rel.endsWith("tsconfig.json")).sort( + (a, b) => Number(!/\/src\//.test(a.path)) - Number(!/\/src\//.test(b.path)), + ), + boundary, + lint: { ok: lint.code === 0, code: lint.code, command: `chant ${lintArgs.join(" ")}`, output: merge(lint, demo.root) }, + buildCaveat: BUILD_CAVEAT, + }; +} + +/** + * Step 4 — `chant carve bridge --from --output `, deliberately + * WITHOUT `--apply-rewrites`. + * + * `--select` is passed explicitly even though the manifest emit left in `out` + * would supply it: the step should mean the same thing whether or not emit ran + * in this session, and a bridge for a resource the user did not pick would be a + * surprising thing to compute. + */ +export async function runCarveBridge(demo: CarveDemo, select: string): Promise> { + const block = carveWriteBlock(demo); + if (block) return refuse("read-only", block, "Start the walkthrough with `behold demo carve`."); + + mkdirSync(demo.out, { recursive: true }); + const args = [ + "carve", + "bridge", + "--from", + demo.from, + ...(demo.state ? ["--state", demo.state] : []), + "--select", + select, + "--output", + demo.out, + ]; + const run = await runChantRaw(args, demo.project).catch((err: unknown) => ({ + code: 127, + stdout: "", + stderr: err instanceof Error ? err.message : String(err), + })); + const output = merge(run, demo.root); + if (run.code !== 0) { + return refuse( + "carve-action", + `chant carve bridge exited ${run.code}: ${output || "(no output)"}`, + "Run the Emit step first — bridge reads the carve manifest emit leaves in the output directory.", + ); + } + + const slug = carveSlug(select); + const all = readArtifacts(demo, demo.out, (rel) => /\.(tf|md|patch)$/.test(rel)); + return { + ok: true, + select, + command: `chant ${args.map((a) => shortenIn(a, demo.root)).join(" ")}`, + output, + runbook: all.find((a) => a.path.endsWith(`${slug}-runbook.md`)) ?? null, + proposals: all.filter((a) => !a.path.endsWith(`${slug}-runbook.md`)), + }; +} diff --git a/src/cli.ts b/src/cli.ts index 55e1f57..3e80820 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,7 +8,8 @@ import { realpathSync, existsSync, cpSync, readFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { startServer, beholdVersion } from "./server.ts"; -import { loadDemoRegistry, missingRequirements } from "./demos.ts"; +import { loadDemoRegistry, missingRequirements, type DemoCarve } from "./demos.ts"; +import { resolveChant } from "./chant.ts"; import { runExport } from "./export.ts"; import { diagnose, formatReport } from "./doctor.ts"; import { isAutoSyncMode, type AutoSyncMode } from "./autosync.ts"; @@ -48,9 +49,12 @@ Usage: clone a public estate. Bare \`behold demo\` is the AWS example — an S3 bucket + policy on a local emulator: blue = declared, click Deploy, watch it turn green. \`behold demo k8s\` stands a workload - up on a throwaway k3d cluster instead. Needs Docker (and per-demo - tools --list names). Loaded demos land in the panel's recents, so - switching between them is the Scope tab. + up on a throwaway k3d cluster instead. \`behold demo carve\` is the + odd one out: no cluster, no Docker, no cloud — a half-migrated + Terraform/chant estate plus the six-step carve walkthrough on the + panel's Carve tab. Needs Docker (and per-demo tools --list names). + Loaded demos land in the panel's recents, so switching between them + is the Scope tab. export Capture the live estate into a self-contained, interactive STATIC bundle (default ./behold-export) — every env/tier × zoom × radial, @@ -428,6 +432,12 @@ async function runDemo(rest: string[]): Promise { process.exit(r.status ?? 1); } } + // #254: a carve demo isn't a project serve at all — it boots the advisor and + // hands the walkthrough its estate context. + if (entry.serve.carve) { + await serveCarveDemo(target, entry.serve.carve, port); + return; + } process.stdout.write(`behold demo ${entry.name} → serving. Blue = declared; Deploy turns it green.\n`); // #211: an estate demo serves several member projects composed; the first // listed is the primary, same as `behold serve a b c…`. @@ -438,6 +448,106 @@ async function runDemo(rest: string[]): Promise { await run(serveArgs); } +/** + * `behold demo carve`'s boot (#254, M1.5 of #230) — the offline tier. + * + * Three things happen before a port opens, and each is allowed to fail into + * something visible rather than into a blank page: + * + * 1. `npm install` in the copy's chant project (`app/`). Its chant is the one + * every step of the walkthrough shells — the project decides the version, + * same rule as every other behold shell-out. + * 2. `@cdktf/hcl2json` into the copy's ROOT `node_modules`. chant lazy-loads + * the HCL parser with a bare `import`, resolved from chant's OWN install + * upward — `/app/node_modules/@intentius/chant/…` reaches + * `/node_modules`, which is why the parser goes there and not into + * the Terraform directory beside the `.tf` files. `--no-save + * --no-package-lock` so the copy never grows a package.json it didn't ship + * with. + * 3. `chant carve advise --report` over the copy's own Terraform, so the + * picture is generated on the spot rather than replayed. If it fails — + * no network for the parser, a chant too old, a broken install — the + * committed `carve-report.json` is served instead and the reason rides all + * the way to the UI (`CarveDemo.degraded`). The one thing that must never + * happen is an empty graph with no explanation. + * + * The Floci `--live` tier (the issue's second comment) is a follow-up: real + * `terraform apply` into a scratch emulator, live observe beats, a real + * `terraform plan` on camera. Nothing here reaches for Docker or terraform. + */ +async function serveCarveDemo(target: string, carve: DemoCarve, port: number): Promise { + const at = (rel: string): string => resolve(target, rel); + const project = at(carve.project); + const from = at(carve.from); + const state = carve.state ? at(carve.state) : undefined; + const committed = at(carve.report); + + if (existsSync(join(project, "package.json")) && !existsSync(join(project, "node_modules"))) { + process.stdout.write(`behold demo carve → npm install in ${carve.project}/ (the chant this walkthrough shells)…\n`); + const r = spawnSync("npm", ["install"], { cwd: project, stdio: "inherit", shell: process.platform === "win32" }); + if (r.status !== 0) { + process.stderr.write(`behold demo carve: npm install failed in ${project}${r.error ? ` (${r.error.message})` : ""}\n`); + process.exit(r.status ?? 1); + } + } + + let degraded: string | undefined; + if (!existsSync(join(target, "node_modules", "@cdktf", "hcl2json"))) { + process.stdout.write("behold demo carve → npm install @cdktf/hcl2json (chant's HCL parser, ~2MB, once)…\n"); + const r = spawnSync("npm", ["install", "--no-save", "--no-package-lock", "@cdktf/hcl2json"], { + cwd: target, + stdio: "inherit", + shell: process.platform === "win32", + }); + if (r.status !== 0) { + degraded = "couldn't install @cdktf/hcl2json (chant's HCL parser) — no network?"; + } + } + + const report = at("carve-report.json"); + if (!degraded) { + const bin = resolveChant(project).bin; + process.stdout.write("behold demo carve → chant carve advise (read-only; emits nothing)…\n"); + const r = spawnSync( + bin, + ["carve", "advise", "--from", carve.from, ...(carve.state ? ["--state", carve.state] : []), "--report", report], + { cwd: target, stdio: "inherit" }, + ); + if (r.status !== 0) degraded = `chant carve advise exited ${r.status ?? "on a spawn error"}`; + } + // Fall back to the committed report rather than to nothing — the walkthrough's + // first frame is the banded graph, and a blank one teaches the viewer that + // behold breaks. Say so on screen; don't paper over it. + const serving = !degraded && existsSync(report) ? report : committed; + if (degraded) { + process.stderr.write( + `behold demo carve: ${degraded}\n` + + ` Serving the committed report (${carve.report}) instead — the bands are real, just not regenerated here.\n`, + ); + } + if (!existsSync(serving)) { + process.stderr.write(`behold demo carve: no carve report at ${serving}\n`); + process.exit(2); + } + + process.stdout.write( + "behold demo carve → serving the walkthrough. Green = carve now; the Carve tab walks the six steps.\n", + ); + await startServer({ + projectDir: target, + carveReport: serving, + carveDemo: { + root: target, + from, + ...(state ? { state } : {}), + project, + out: at(carve.out), + ...(degraded ? { degraded: `${degraded} — showing the committed report shipped with the demo.` } : {}), + }, + port, + }); +} + /** Turnkey Loom-on-Floci env (#69): the AWS SDK creds Floci ignores the value of, * plus the endpoint + LOOM_ENV that Loom's own `chant.config.ts` and `scripts/ * local/local-up.sh` expect. Hardcoded to Loom's env-var names — this is the diff --git a/src/demos.test.ts b/src/demos.test.ts index f86b70b..42b8355 100644 --- a/src/demos.test.ts +++ b/src/demos.test.ts @@ -27,6 +27,27 @@ describe("demos.json — the committed catalog (#209)", () => { } expect(files).toContain("demos.json"); }); + + // #254: the walkthrough is a catalog entry like any other, and every path it + // names has to exist in the directory that gets copied — a typo here is a + // demo that boots to an Emit button with nowhere to write. + it("the carve entry names four real paths inside the bundled estate", () => { + const carve = registry.find((e) => e.name === "carve"); + expect(carve, "no carve entry in demos.json").toBeTruthy(); + expect(carve!.requires).toEqual([]); // offline tier: no Docker, no terraform + const c = carve!.serve.carve!; + expect(c).toBeTruthy(); + for (const p of [c.report, c.from, c.state!, c.project, c.out]) { + expect(p.startsWith("/"), `${p} must be relative to the copy`).toBe(false); + } + for (const p of [c.report, c.from, c.state!, c.project]) { + expect(existsSync(join(REPO, carve!.dir!, p)), `${p} missing from ${carve!.dir}`).toBe(true); + } + // The emitted source imports the project's lexicon and Node resolves that + // from the file's own directory upward — an output dir outside the project + // would never lint. `out` is generated, so it is not expected to exist yet. + expect(`${c.out}/`.startsWith(`${c.project}/`)).toBe(true); + }); }); describe("loadDemoRegistry — malformed input degrades, never throws", () => { @@ -62,6 +83,24 @@ describe("loadDemoRegistry — malformed input degrades, never throws", () => { expect(loadDemoRegistry(dir).map((e) => e.name)).toEqual(["ok", "git-ok"]); rmSync(dir, { recursive: true, force: true }); }); + + it("a half-wired carve entry drops rather than serving a walkthrough that can't act (#254)", () => { + const carve = (c: unknown) => ({ name: "c", description: "d", source: "bundled", dir: "x", requires: [], serve: { carve: c } }); + const good = { report: "r.json", from: "tf", state: "tf/s.tfstate", project: "app", out: "app/carveout" }; + const dir = tmpRoot( + JSON.stringify({ + demos: [ + { ...carve(good), name: "good" }, + { ...carve({ ...good, out: undefined }), name: "no-out" }, + { ...carve({ ...good, from: "/etc" }), name: "absolute" }, + { ...carve({ ...good, report: "../../secrets.json" }), name: "escapes" }, + { ...carve({ ...good, out: "elsewhere/carveout" }), name: "out-outside-project" }, + ], + }), + ); + expect(loadDemoRegistry(dir).map((e) => e.name)).toEqual(["good"]); + rmSync(dir, { recursive: true, force: true }); + }); }); describe("missingRequirements", () => { diff --git a/src/demos.ts b/src/demos.ts index a7d8fed..8b1c435 100644 --- a/src/demos.ts +++ b/src/demos.ts @@ -9,6 +9,27 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; +/** + * #254: serve this demo as a carve walkthrough rather than a chant project — + * `behold carve`'s mode, plus the estate context the stepper acts on. Every + * path is relative to the copied demo root, and the copy is the only thing the + * walkthrough's two write actions may touch (src/carve-actions.ts). + */ +export interface DemoCarve { + /** The committed `carve advise --report` output, served if a fresh run fails. */ + report: string; + /** The Terraform estate the advisor reads (`legacy-tf`). */ + from: string; + /** The `.tfstate` that makes `carve advise`/`carve emit` work offline. */ + state?: string; + /** The chant project whose own chant + lexicon run the steps (`app`). */ + project: string; + /** Where emitted source and bridge proposals land. Must sit INSIDE `project` + * — the emitted source imports the lexicon, and Node resolves that from + * where the file sits, not from the cwd. */ + out: string; +} + export interface DemoServe { /** Serve with --local (boot the project's own emulators). */ local?: boolean; @@ -17,6 +38,8 @@ export interface DemoServe { /** #211: serve these subdirectories of the target as a composed estate * (`serve a b c…`) instead of the target itself. First is the primary. */ dirs?: string[]; + /** #254: serve the carve walkthrough instead of a project graph. */ + carve?: DemoCarve; } export interface DemoEntry { @@ -60,6 +83,21 @@ export function loadDemoRegistry(pkgRoot: string): DemoEntry[] { if (!d.serve || typeof d.serve !== "object") return false; if (d.serve.dirs !== undefined && (!Array.isArray(d.serve.dirs) || d.serve.dirs.some((x) => typeof x !== "string") || !d.serve.dirs.length)) return false; + // #254: a carve entry names four relative paths, and a missing one would + // mean a walkthrough whose Emit step has nowhere to write — drop the entry + // rather than serve a half-wired demo. Every path stays relative: it is + // joined onto the COPY, and an absolute one there would escape it. + if (d.serve.carve !== undefined) { + const c = d.serve.carve as Partial | null; + const rel = (v: unknown): boolean => typeof v === "string" && !!v && !v.startsWith("/") && !v.split("/").includes(".."); + if (!c || typeof c !== "object") return false; + if (!rel(c.report) || !rel(c.from) || !rel(c.project) || !rel(c.out)) return false; + if (c.state !== undefined && !rel(c.state)) return false; + // The emitted source imports the project's lexicon; Node resolves that + // from the file's own directory upward, so an output dir outside the + // project would never lint. + if (!`${c.out}/`.startsWith(`${c.project}/`)) return false; + } return true; }); } diff --git a/src/server.ts b/src/server.ts index 04e3f54..25ef453 100644 --- a/src/server.ts +++ b/src/server.ts @@ -21,7 +21,7 @@ import { fileURLToPath } from "node:url"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { existsSync, readFileSync } from "node:fs"; -import { dirname, join, relative, resolve } from "node:path"; +import { dirname, join, relative, resolve, sep } from "node:path"; import { listRecents, addRecent } from "./recents.ts"; import type { GraphIR } from "@intentius/chant"; import { @@ -57,6 +57,14 @@ import { resourcesByComponent, nonResourceEntities } from "./resources.ts"; import { summarizePlan } from "./reconcile.ts"; import { renderGraph, renderArchitecture, renderBanded } from "./render.ts"; import { readCarveReport, carveReportToIr, carveNote } from "./carve-lens.ts"; +import { + carveWriteBlock, + runCarveBridge, + runCarveEmit, + selectFromReport, + BUILD_CAVEAT, + type CarveDemo, +} from "./carve-actions.ts"; import { discoverEstateOps } from "./ops.ts"; import { LIVE_IMPORT_LEXICONS } from "./adopt.ts"; import { detectProject, loadBeholdConfig } from "./project.ts"; @@ -122,6 +130,11 @@ export interface ServerOptions { * is shelled, no source is watched and no live state exists. See * `carveRoutes` below. */ carveReport?: string; + /** #254: the `behold demo carve` copy this server booted, when it did. The + * ONLY context in which the two carve write actions exist — built by + * `runDemo` from the directory it just copied, never from a request. See + * src/carve-actions.ts for the whole write boundary. */ + carveDemo?: CarveDemo; /** #228: may this server write the hand-layout sidecar (`.behold/layout.json` * in the served project)? Default true for a live `serve`. `runExport` sets * it false: a static capture reads the sidecar (to bake it into the snapshot @@ -386,9 +399,33 @@ async function captureFrame( * blank graph. `behold carve` also validates once up front, so a bad path is * refused in the terminal before a server ever starts. */ -function carveRoutes(app: Hono, reportPath: string): void { +function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void { const load = () => readCarveReport(reportPath, (p) => readFileSync(p, "utf8")); + // #254's stepper needs to know, before it draws a button, whether this server + // can actually run a step — a `behold carve report.json` looks identical + // otherwise, and offering an Emit button that always 403s would be a lie in + // the shape of a control. + const demoBlock = () => carveWriteBlock(demo); + const demoInfo = (): Record | null => { + if (!demo) return null; + const block = demoBlock(); + return { + root: demo.root, + from: demo.from, + state: demo.state ?? null, + project: demo.project, + out: demo.out, + // Paths the UI can print without leaking the operator's whole home dir. + outLabel: relative(demo.root, demo.out).split(sep).join("/"), + fromLabel: relative(demo.root, demo.from).split(sep).join("/"), + runnable: !block, + ...(block ? { reason: block } : {}), + ...(demo.degraded ? { degraded: demo.degraded } : {}), + buildCaveat: BUILD_CAVEAT, + }; + }; + // The raw report, for agents (#230's M4 workflow reads this to confirm a // band before running `carve emit`). Verbatim — behold adds nothing to it. app.get("/api/carve", (c) => { @@ -413,7 +450,9 @@ function carveRoutes(app: Hono, reportPath: string): void { tier: null, target: null, carve: true, - note: carveNote(parsed.report, ir), + // A demo whose own advisor run failed says so on the statusbar, not + // only in the terminal the viewer isn't looking at. + note: carveNote(parsed.report, ir) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : ""), }, }); }); @@ -436,16 +475,76 @@ function carveRoutes(app: Hono, reportPath: string): void { from: parsed.report.from ?? null, count: parsed.report.count ?? parsed.report.resources.length, bands: parsed.report.bands ?? {}, + advisory: parsed.report.advisory ?? null, + demo: demoInfo(), } - : { report: reportPath }, + : { report: reportPath, demo: demoInfo() }, }); }); + // --- The walkthrough's two local actions (#254) ------------------------- + // Registered only in carve mode, and they refuse unless this server booted a + // demo copy (`carveWriteBlock`). src/carve-actions.ts states the boundary in + // full; the shape here is #228's: a JSON body (so a hostile page preflights + // rather than blind-fires), a capped read, a polite structured refusal, and + // an echo of what actually happened rather than of what was asked for. + const runStep = async ( + c: Context, + run: (demo: CarveDemo, select: string) => Promise<{ ok: true } | { ok: false; refusal: { error: string; code: string; remedy: string } }>, + ): Promise => { + const block = demoBlock(); + if (block) { + return c.json( + { + error: block, + code: "read-only", + remedy: "The carve steps run inside a demo copy — start the walkthrough with `behold demo carve`.", + }, + 403, + ); + } + if (!(c.req.header("content-type") ?? "").includes("application/json")) { + return c.json({ error: "send application/json", code: "carve-select", remedy: 'POST {"select": ""}' }, 415); + } + const text = await c.req.text().catch(() => ""); + if (text.length > 4096) return c.json({ error: "a carve step body is capped at 4096 bytes", code: "carve-select", remedy: 'POST {"select": "…"}' }, 413); + let body: { select?: unknown }; + try { + body = JSON.parse(text || "null") as { select?: unknown }; + } catch { + return c.json({ error: "body must be JSON", code: "carve-select", remedy: 'POST {"select": ""}' }, 400); + } + const parsed = load(); + const select = selectFromReport(parsed.ok ? parsed.report : undefined, body?.select); + if (!select) { + return c.json( + { + error: `\`select\` must name a resource this report ranks — ${JSON.stringify(body?.select ?? null)} isn't one of them.`, + code: "carve-select", + remedy: "Pick a card in the graph, or read the addresses off GET /api/carve.", + }, + 400, + ); + } + const result = await run(demo!, select); + return result.ok ? c.json(result) : c.json(result.refusal, 422); + }; + + app.post("/api/carve/emit", (c) => runStep(c, runCarveEmit)); + app.post("/api/carve/bridge", (c) => runStep(c, runCarveBridge)); + // A static report has no substrates to probe and no git history that means // anything (the file may sit anywhere). Answer empty instead of running a // Docker probe and a `git log` for a picture that cannot use either. app.get("/api/substrates", (c) => c.json({ substrates: [] })); app.get("/api/history", (c) => c.json({ commits: [] })); + // …and neither does it have a chant project to build a resource rollup or a + // CI projection from. The SPA asks for both on every load; without these the + // project-shaped handlers shell chant at a directory that was never a chant + // project and hand back a 500 the page then logs (#254 — surfaced by driving + // the walkthrough in a browser, where `/api/resources` 500'd on every boot). + app.get("/api/resources", (c) => c.json({ byComponent: {} })); + app.get("/api/ci", (c) => c.json({ stages: [], jobs: [], forge: null })); } export function createApp( @@ -462,7 +561,7 @@ export function createApp( // Carve mode (#252) claims /api/graph, /api/project and friends before the // project-shaped handlers are registered — see carveRoutes. - if (cfg.carveReport) carveRoutes(app, cfg.carveReport); + if (cfg.carveReport) carveRoutes(app, cfg.carveReport, cfg.carveDemo); // behold's own project-root config (#70) — `.behold.json`'s `tiers` block, // if any (src/project.ts `loadBeholdConfig`). Read once at app creation @@ -906,7 +1005,19 @@ export function createApp( { method: "POST", path: "/api/project/reveal", desc: "open the OS file manager at a served/recent project dir: JSON body {dir?}" }, { method: "GET", path: "/api/graph", desc: "the graph {ir, svg, meta} — params: detail=0..3, components=1, logical=1, env, stack, tier, target, lens, up=1, down=1, radial=1, layout=1" }, ...(cfg.carveReport - ? [{ method: "GET", path: "/api/carve", desc: "carve mode: the raw `chant carve advise --json` peelability report this server is rendering" }] + ? [ + { method: "GET", path: "/api/carve", desc: "carve mode: the raw `chant carve advise --json` peelability report this server is rendering" }, + { + method: "POST", + path: "/api/carve/emit", + desc: "carve demo only (#254): run `chant carve emit --state --select ` into the demo copy — JSON body {select}; answers {artifacts, boundary, lint}", + }, + { + method: "POST", + path: "/api/carve/bridge", + desc: "carve demo only (#254): run `chant carve bridge` (never --apply-rewrites) — JSON body {select}; answers {runbook, proposals}", + }, + ] : []), { method: "GET", path: "/api/overlay", desc: "live drift overlay for ?env= — same shape/params as /api/graph, plus runtime=1" }, { method: "GET", path: "/api/layout", desc: "hand-layout sidecar (.behold/layout.json): ?lens= → {lens, deltas, writable}; no lens → every lens" }, @@ -1959,7 +2070,11 @@ export async function startServer(cfg: ServerOptions): Promise { `behold → http://localhost:${info.port}\n` + ` carve report: ${cfg.carveReport}\n` + ` green = carve now, amber = boundary work, grey = leave in Terraform.\n` + - ` Read-only advisory: behold emits nothing and touches no Terraform. Ctrl-C to stop.\n`, + (cfg.carveDemo + ? ` walkthrough: the panel's Carve tab — advise → pick → emit → bridge → handoff → done.\n` + + ` Emit and bridge write only into ${cfg.carveDemo.out}; your Terraform is never edited.\n` + + (cfg.carveDemo.degraded ? ` degraded: ${cfg.carveDemo.degraded}\n` : "") + : ` Read-only advisory: behold emits nothing and touches no Terraform. Ctrl-C to stop.\n`), ); return; } diff --git a/web/app.js b/web/app.js index 9c66789..5486e31 100644 --- a/web/app.js +++ b/web/app.js @@ -9,7 +9,11 @@ // position), and the theme picker into the panel's View-tab slot (a stable element // renderPanelView never rewrites, so the select mounts once and survives re-renders). import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens } from "./theme.js"; -import { initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js"; +import { addPanelTab, initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js"; +// #254: the carve walkthrough's stepper — everything it DECIDES is a pure +// function in there; this file owns the fetches, the graph selection, and the +// "carved" marker the last step leaves on a card. +import { CARVE_STEPS, blockedReason, initialCarveState, renderCarvePanel } from "./carve-steps.js"; // #228: the hand-layout delta store — everything about WHAT gets remembered and // under which key. The pointer work and the SVG surgery stay here (see the // "Hand layout" section below). @@ -702,6 +706,10 @@ function wire(ir) { host.querySelectorAll(".sel").forEach((n) => n.classList.remove("sel")); g.classList.add("sel"); inspect(node); + // #254: in carve mode a click is also the walkthrough's Pick step — the + // inspect pane already shows the score arithmetic the lens spelled out, + // and the stepper picks up the same node. + carvePick(node); }); } } @@ -1063,6 +1071,7 @@ function selectNode(id) { const g = host.querySelector(`[data-node-id="${CSS.escape(id)}"]`); if (g) g.classList.add("sel"); inspect(node); + carvePick(node); } function renderPanelModel() { @@ -1128,10 +1137,194 @@ function renderPanelModel() { } } +// --------------------------------------------------------------------------- +// The carve walkthrough (#254, M1.5 of #230) +// +// A Carve tab appears only when /api/project says this server is in carve mode, +// and its two ACTION steps light up only when it also says a demo copy is +// behind it (`carve.demo.runnable`) — a plain `behold carve report.json` gets +// the same six steps with the runs honestly greyed out, rather than buttons +// that 403. +// +// The walkthrough's state lives here and nowhere else: no session on the +// server, exactly as #254 asks. Reload and you're back at Advise, with whatever +// the previous run wrote still sitting in the demo copy. +// --------------------------------------------------------------------------- +let carveInfo = null; // /api/project's `carve` block (report meta + demo, or null) +let carveReport = null; // the raw report off /api/carve — the boundary lists live here +let carveState = initialCarveState(); +let carveHost = null; // the panel section, mounted on first sight of carve mode +const carvedIds = new Set(); // addresses the walkthrough has taken all the way through + +function carveMode() { + return !!carveInfo; +} + +/** The stepper's wiring. Everything that talks to the network or the graph. */ +const carveActions = { + go(index) { + const id = CARVE_STEPS[index] && CARVE_STEPS[index].id; + if (!id) return; + if (blockedReason(carveState, id)) return; + carveState.step = index; + carveState.error = null; + renderPanelCarve(); + }, + select(address) { + selectNode(address); // the same path a graph click takes — inspect included + }, + reset() { + carveState = initialCarveState(); + renderPanelCarve(); + }, + markHandoff() { + carveState.handoff = true; + if (carveState.pick) { + carvedIds.add(carveState.pick.node.id); + markCarvedCards(); + } + carveState.step = CARVE_STEPS.findIndex((s) => s.id === "done"); + renderPanelCarve(); + }, + runEmit: () => runCarveStep("emit"), + runBridge: () => runCarveStep("bridge"), + copy(text, el) { + const done = () => { + el.dataset.copied = "1"; + const was = el.textContent; + el.textContent = "copied ✓"; + setTimeout(() => { + el.textContent = was; + }, 1200); + }; + if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, done); + else done(); + }, +}; + +/** Run one of the two safe steps. Both are POSTs with a `{select}` body; both + * answer either their result or #193's `{error, code, remedy}`. */ +async function runCarveStep(which) { + if (carveState.busy || !carveState.pick) return; + carveState.busy = which; + carveState.error = null; + renderPanelCarve(); + try { + const res = await fetch(`/api/carve/${which}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ select: carveState.pick.node.id }), + }); + const body = await res.json().catch(() => ({ error: `${which} returned an unreadable body`, remedy: "" })); + if (!res.ok || body.error) { + carveState.error = { step: which, ...body }; + showToast(`✗ carve ${which}: ${body.error || res.status}`, false); + } else { + carveState[which] = body; + // Deliberately does NOT advance. The result IS the step — the emitted + // source and the lint verdict, the proposed patch — and skipping past it + // to the next button would hide the thing the run was for. The "next" + // control unlocks; pressing it stays the viewer's move. + showToast(`✓ carve ${which} — wrote into ${(carveInfo.demo && carveInfo.demo.outLabel) || "the demo copy"}`, true); + } + } catch (err) { + carveState.error = { step: which, error: String((err && err.message) || err), remedy: "Is the behold server still running?" }; + } finally { + carveState.busy = null; + renderPanelCarve(); + } +} + +/** A picked card becomes the walkthrough's subject. Called from the graph's + * click handler and from selectNode(), so the panel rows and the cards agree. */ +function carvePick(node) { + if (!carveMode()) return; + const resource = (carveReport && carveReport.resources ? carveReport.resources : []).find((r) => r.address === node.id) || null; + const pickStep = CARVE_STEPS.findIndex((s) => s.id === "pick"); + // A NEW pick invalidates the runs that were about the old one — showing one + // resource's emitted source under another's name is the one way this panel + // could actively lie — and drops the walkthrough back to Pick, wherever it + // had got to. Re-clicking the SAME card is just a re-select and moves + // nothing, so reading a card mid-walkthrough costs no progress. + if (!carveState.pick || carveState.pick.node.id !== node.id) { + carveState.emit = null; + carveState.bridge = null; + carveState.handoff = false; + carveState.error = null; + carveState.step = pickStep; + } else if (carveState.step < pickStep) { + carveState.step = pickStep; + } + carveState.pick = { node, resource }; + renderPanelCarve(); +} + +/** The "carved" marker the last step leaves on the card — a class the CSS + * paints plus a small label. Re-applied after every render, because the SVG is + * replaced wholesale on each load. The full morph (the box sliding out of the + * Terraform boundary and into the chant project beside last month's carves) is + * the follow-up; this is the honest still frame of it. */ +function markCarvedCards() { + const svg = document.querySelector("#graph svg"); + if (!svg || !carvedIds.size) return; + for (const g of svg.querySelectorAll("[data-node-id]")) { + const id = g.getAttribute("data-node-id"); + if (!carvedIds.has(id) || g.querySelector('[data-carved="1"]')) continue; + g.classList.add("carved"); + // Measured, not read off attributes: pinhole sizes the card from its + // content and doesn't always stamp width/height, and a missing attribute + // read as 0 parks the label at the group's origin — which is off the card + // entirely (seen in the browser before this was measured instead). + const rect = g.querySelector("rect"); + const box = rect && rect.getBBox ? rect.getBBox() : null; + const tag = document.createElementNS("http://www.w3.org/2000/svg", "text"); + tag.setAttribute("data-carved", "1"); + // Inside the card's own box, bottom-right — the one corner the terraform + // presentation pack leaves empty. + tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10)); + tag.setAttribute("y", String((box ? box.y + box.height : 60) - 9)); + tag.setAttribute("text-anchor", "end"); + tag.setAttribute("font-size", "11"); + tag.setAttribute("font-weight", "600"); + tag.setAttribute("fill", "var(--managed)"); + tag.textContent = "✓ carved → chant"; + g.appendChild(tag); + } +} + +function renderPanelCarve() { + if (!carveMode()) return; + if (!carveHost) { + carveHost = addPanelTab("carve", "Carve", "The peel walkthrough: advise → pick → emit → bridge → handoff → done."); + if (!carveHost) return; + carveHost.id = "tab-carve"; + } + renderCarvePanel( + carveHost, + carveState, + { carve: carveInfo, demo: carveInfo && carveInfo.demo, report: carveReport, renderJson }, + carveActions, + ); +} + +/** Carve mode's one extra fetch: the raw report, for the per-resource boundary + * lists the graph IR deliberately doesn't carry. Best-effort — the stepper + * degrades to the counts in the IR's own attrs. */ +async function loadCarveReport() { + if (!carveMode()) return; + try { + carveReport = await apiFetch("/api/carve").then((r) => r.json()); + } catch { + carveReport = null; + } + renderPanelCarve(); +} + function renderPanel() { renderPanelView(); renderPanelScope(); renderPanelModel(); + if (carveMode()) renderPanelCarve(); } function renderStatusbar() { @@ -1800,6 +1993,7 @@ function render(ir, svg, m) { ensureBackToInfra(g); wire(ir); if (view.radial && !view.components && !view.logical) addRadialLabels(ir); + markCarvedCards(); // #254: the SVG is replaced per render — re-stamp the marker applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass renderDial(); } @@ -2544,6 +2738,13 @@ async function initPickers() { // with none leaves `view.stack` (and the picker + status tag) null. stacks = info.stacks || []; view.stack = stacks[0] || null; + // #254: carve mode declares itself here. The Carve tab is mounted at runtime + // (panel.js's addPanelTab), so nothing else grows a dead tab. + carveInfo = info.carve || null; + if (carveMode()) { + renderPanelCarve(); + loadCarveReport(); + } axes = { tier: info.tier || null, target: info.target || null }; environments = info.environments || []; tiers = info.tiers || []; @@ -3090,6 +3291,22 @@ function paletteCommands() { for (const b of document.querySelectorAll("#panel-tabs button[data-tab]")) { c.push([`Panel: ${b.textContent}`, () => setPanelTab(b.dataset.tab)]); } + // #254: the walkthrough's steps get palette twins like every other control. + // Blocked steps are listed with their reason rather than hidden — "why can't + // I do that yet" is the question the palette should be able to answer. + if (carveMode()) { + CARVE_STEPS.forEach((s, i) => { + const why = blockedReason(carveState, s.id); + c.push([ + `Carve: ${i + 1}. ${s.label}${why ? ` — ${why}` : ""}`, + () => { + if (why) return showToast(why, false); + setPanelTab("carve"); + carveActions.go(i); + }, + ]); + }); + } // Lens/zoom switches (#56, #63) — replaces the old header zoom picker. for (const [label, v] of ZOOM_OPTS) { diff --git a/web/carve-steps.js b/web/carve-steps.js new file mode 100644 index 0000000..22a92ed --- /dev/null +++ b/web/carve-steps.js @@ -0,0 +1,605 @@ +// The carve walkthrough's stepper (#254, M1.5 of #230) — the panel's Carve tab. +// +// Six steps over one estate: advise → pick → emit → bridge → handoff → done. +// behold runs the two SAFE ones (emit, bridge — both write only into the demo +// copy, both read-only with respect to your Terraform) and deliberately does +// NOT run the destructive middle: `terraform state rm` releases Terraform's +// claim on a live resource, and that stays a human decision made in a human's +// terminal. Step 5 is a list of commands with copy buttons, and it says why. +// +// Same discipline as web/json-view.js and web/layout-store.js: everything the +// stepper DECIDES lives in a pure function that takes data and returns data, so +// it is unit-testable next door in carve-steps.test.js; the DOM half below is +// smoke/ui-smoke.mjs's job. +// +// The walkthrough's state is client-side only, exactly as #254 specifies — the +// server holds no session. Reload and you are back at Advise, with whatever the +// previous run already wrote still sitting in the demo copy. + +/** The six steps, in order. `id` is the stable handle (tests, the ⌘K twins). */ +export const CARVE_STEPS = [ + { + id: "advise", + label: "Advise", + blurb: "chant scored every resource in the Terraform half. Green carves cheaply; grey stays where it is.", + }, + { + id: "pick", + label: "Pick", + blurb: "Click a green card in the graph. The inspect pane shows the arithmetic behind its score.", + }, + { + id: "emit", + label: "Emit", + blurb: "Adopt it from the tfstate into typed chant source. Nothing is applied and nothing is destroyed.", + }, + { + id: "bridge", + label: "Bridge", + blurb: "Generate the surviving Terraform's patch — a data source, and the references rewired to read it.", + }, + { + id: "handoff", + label: "Handoff", + blurb: "Two commands you run yourself. behold copies them; it does not press them.", + }, + { + id: "done", + label: "Done", + blurb: "chant-owned, observe position. `terraform import` reverses all of it.", + }, +]; + +/** A fresh walkthrough. */ +export function initialCarveState() { + return { + /** Index into CARVE_STEPS. */ + step: 0, + /** The report resource the user picked, plus its IR node. */ + pick: null, + /** POST /api/carve/emit's answer. */ + emit: null, + /** POST /api/carve/bridge's answer. */ + bridge: null, + /** Set when the user says they ran the handoff commands. */ + handoff: false, + /** Which step is mid-run ("emit" | "bridge" | null) — the dial's progress. */ + busy: null, + /** The last structured refusal, keyed by step id. */ + error: null, + }; +} + +/** Has this step's work actually happened? Distinct from "we walked past it". */ +export function completed(state, id) { + switch (id) { + case "advise": + return state.step > 0; + case "pick": + return !!state.pick; + case "emit": + return !!state.emit; + case "bridge": + return !!state.bridge; + case "handoff": + return !!state.handoff; + case "done": + return !!state.handoff && !!state.bridge; + default: + return false; + } +} + +/** + * Why this step can't be entered yet, or null. The gates are the real data + * dependencies, not a wizard's insistence on order: bridge genuinely reads the + * carve manifest emit leaves in the output directory, and the runbook genuinely + * does not exist until bridge writes it. + */ +export function blockedReason(state, id) { + switch (id) { + case "emit": + return state.pick ? null : "Pick a resource first — Emit carves the one you selected."; + case "bridge": + return state.emit ? null : "Run Emit first — bridge reads the carve manifest emit leaves in the output directory."; + case "handoff": + return state.bridge ? null : "Run Bridge first — the runbook is the file it writes."; + case "done": + return state.bridge ? null : "The carve isn't proposed yet — Emit and Bridge come first."; + default: + return null; + } +} + +/** How a step's button paints: current | done | blocked | todo. */ +export function stepStatus(state, index) { + const id = CARVE_STEPS[index] && CARVE_STEPS[index].id; + if (!id) return "todo"; + if (state.step === index) return "current"; + if (completed(state, id)) return "done"; + return blockedReason(state, id) ? "blocked" : "todo"; +} + +/** Every boundary edge a report resource declares, in either shape chant may + * publish (a flat list, or split by direction). Mirrors src/carve-lens.ts's + * `boundaryEdgesOf` — the same leniency, for the same reason. */ +export function boundaryEdgesOf(resource) { + const b = resource && resource.boundary; + if (!b) return []; + const list = Array.isArray(b) ? b : [...(b.inbound || []), ...(b.outbound || [])]; + return list.filter((e) => e && typeof e.survivor === "string" && typeof e.carved === "string"); +} + +/** One boundary edge as a sentence. */ +export function edgeLine(e) { + const dir = e.direction === "outbound" ? "outbound" : "inbound"; + const attrs = (e.attrs || []).join(", "); + const via = (e.via || []).join(", "); + const head = + dir === "inbound" + ? `${e.survivor} reads ${attrs || "this resource"}` + : `this resource reads ${attrs || "something"} from ${e.survivor}`; + const where = via ? ` (in its ${via})` : ""; + const fix = e.bridge === "deferred-input" ? "becomes a deploy-time input" : "becomes a Terraform data source"; + const when = e.required === "at-apply" ? ", deferred until apply" : e.required === "immediately" ? ", needed immediately" : ""; + return `${dir} — ${head}${where} → ${fix}${when}`; +} + +/** + * What the Pick step can honestly say about the cut this carve would make. + * + * `known: true` when the report carries the edge lists (chant#1636) — then the + * survivors are named. `known: false` on every chant that publishes only the + * per-resource COUNTS, and then the step says the counts, names the cut from + * the breakdown, and says plainly that the survivors aren't in this report + * rather than inventing them. "None" and "not reported" are different claims, + * and a walkthrough that blurs them teaches the wrong thing about the tool. + */ +export function cutSummary(resource) { + const edges = boundaryEdgesOf(resource); + if (edges.length) { + return { known: true, items: edges.map(edgeLine), note: null }; + } + const b = (resource && resource.breakdown) || {}; + const inbound = b.inbound || 0; + const outbound = b.outbound || 0; + const items = []; + if (inbound) items.push(`${inbound} inbound — a survivor reads this; each needs a Terraform data-source patch, immediately.`); + if (outbound) items.push(`${outbound} outbound — this reads a survivor; each becomes a deferred deploy-time input.`); + if (!items.length) items.push("No boundary edges at all — nothing to patch, nothing to defer."); + return { + known: false, + items, + note: + inbound || outbound + ? "This report carries boundary COUNTS, not the edge lists (chant#1636), so the survivors can't be named here. " + + "The Emit step's own boundary report names them." + : null, + }; +} + +/** The facts the Pick step shows about the selected resource. `node` is the IR + * node (which already carries the arithmetic the lens spelled out); `resource` + * is the raw report entry. Either may be missing. */ +export function pickFacts(node, resource) { + const attrs = (node && node.attrs) || {}; + const b = (resource && resource.breakdown) || {}; + const facts = [ + ["address", (resource && resource.address) || (node && node.id) || "—"], + ["score", `${resource ? resource.score : attrs.score} — ${resource ? resource.band : attrs.band}`], + ]; + if (attrs.arithmetic) facts.push(["arithmetic", String(attrs.arithmetic)]); + const mapsTo = (resource && resource.mapsTo) || attrs.mapsTo; + if (mapsTo) facts.push(["maps to", String(mapsTo)]); + if (b.tier || attrs.tier) facts.push(["tier", String(b.tier ?? attrs.tier)]); + return facts.map(([label, value]) => ({ label, value: String(value) })); +} + +/** Is this resource one the walkthrough can actually carve? A grey card with no + * native mapping scores 0 and `carve emit` refuses it — say so at Pick time + * rather than letting the Emit button produce chant's error. */ +export function carveable(resource, node) { + const attrs = (node && node.attrs) || {}; + const tier = resource && resource.breakdown ? resource.breakdown.tier : attrs.tier; + const mapsTo = (resource && resource.mapsTo) || attrs.mapsTo; + if (tier === null || tier === "none" || tier === undefined) { + if (!mapsTo) return "no known native mapping — chant has nothing to carve this into, so Emit would refuse."; + } + return null; +} + +/** + * The runnable commands out of a `carve bridge` runbook, in order, deduped. + * + * chant's runbook is markdown: `## . ` sections whose commands are + * indented four spaces. Annotations (a `#` comment, a parenthetical) sit at the + * same indent, so they are dropped from the command list and a trailing `# …` + * is kept as the row's note. Deduped on the command text — the runbook prints + * `terraform plan` twice on purpose, and two identical copy buttons are noise. + */ +export function runbookCommands(markdown) { + const out = []; + const seen = new Set(); + let section = ""; + for (const raw of String(markdown || "").split(/\r?\n/)) { + const heading = /^#{2,3}\s+(.*\S)\s*$/.exec(raw); + if (heading) { + section = heading[1]; + continue; + } + const indented = /^ {4}(\S.*?)\s*$/.exec(raw); + if (!indented) continue; + const body = indented[1]; + if (body.startsWith("#") || body.startsWith("(")) continue; + const hash = body.indexOf(" #"); + const command = (hash > 0 ? body.slice(0, hash) : body).trim(); + const note = hash > 0 ? body.slice(hash + 2).trim() : ""; + if (!command || seen.has(command)) continue; + seen.add(command); + out.push({ section, command, note }); + } + return out; +} + +/** How `chant lint` reads on the Emit panel. */ +export function lintVerdict(lint) { + if (!lint) return null; + // chant's own summary line (`⚠ 3 warnings`), not the per-finding lines above + // it — an unanchored `(\d+) warnings?` happily matches the column number in + // `5:1 warning …` and reports "1 warning" for a file with three. + const warnings = /^\s*\S?\s*(\d+)\s+warnings?\b/m.exec(lint.output || ""); + if (lint.ok) { + return { + tone: "good", + text: warnings ? `chant lint: passes, ${warnings[1]} warning(s)` : "chant lint: passes", + }; + } + return { tone: "bad", text: `chant lint exited ${lint.code} — the emitted source doesn't pass yet` }; +} + +// --------------------------------------------------------------------------- +// The DOM half. Plain browser JS, no build step, themed entirely by the shared +// CSS vars — same rules as app.js. `actions` is the host's wiring: +// { go(index), runEmit(), runBridge(), markHandoff(), reset(), select(id), copy(text, el) } +// --------------------------------------------------------------------------- + +const el = (tag, cls, text) => { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text !== undefined) n.textContent = text; + return n; +}; + +function stepTrack(state, actions) { + const track = el("div", "dial-track"); + CARVE_STEPS.forEach((s, i) => { + if (i) track.appendChild(el("span", "dial-arrow", "→")); + const status = stepStatus(state, i); + const running = state.busy === s.id; + const b = el("button", `dial-step carve-step ${status}`, running ? `${s.label} · running…` : status === "done" ? `${s.label} ✓` : s.label); + b.dataset.step = s.id; + b.dataset.status = status; + b.title = blockedReason(state, s.id) || s.blurb; + b.disabled = !!state.busy; + b.addEventListener("click", () => actions.go(i)); + track.appendChild(b); + }); + return track; +} + +function factRows(facts) { + const wrap = el("div"); + for (const f of facts) { + const row = el("div", "count-row"); + row.appendChild(el("span", "grow", f.label)); + row.appendChild(el("span", "tag", f.value)); + wrap.appendChild(row); + } + return wrap; +} + +/** A code block. Always textContent — every string here is chant's own output + * or a file off disk, and none of it is markup. */ +function pre(text, cls) { + const p = el("pre", `carve-pre${cls ? " " + cls : ""}`); + p.textContent = String(text || ""); + return p; +} + +function copyButton(label, text, actions) { + const b = el("button", "act carve-copy", label); + b.title = "Copy to the clipboard"; + b.addEventListener("click", () => actions.copy(text, b)); + return b; +} + +function artifactBlock(a, actions) { + const wrap = el("div", "carve-artifact"); + const head = el("div", "prow"); + head.appendChild(el("span", "grow carve-path", a.path)); + head.appendChild(el("span", "tag", `${a.bytes} B${a.truncated ? " (truncated)" : ""}`)); + head.appendChild(copyButton("copy", a.text, actions)); + wrap.append(head, pre(a.text, `lang-${a.kind}`)); + return wrap; +} + +function refusalBlock(err) { + const wrap = el("div", "carve-refusal"); + wrap.appendChild(el("div", "carve-refusal-title", err.error || "that step didn't run")); + if (err.remedy) wrap.appendChild(el("p", "panel-muted", err.remedy)); + return wrap; +} + +/** + * Render the whole tab. `ctx` carries what the host knows and the stepper does + * not: the demo info off /api/project, the raw report, the picked IR node, and + * `renderJson` (web/json-view.js — the boundary report and the bridge plan are + * JSON, and every JSON value this page shows goes through one renderer). + */ +export function renderCarvePanel(host, state, ctx, actions) { + host.innerHTML = ""; + const demo = ctx.demo || null; + + host.appendChild(stepTrack(state, actions)); + + if (demo && demo.degraded) { + const warn = el("div", "carve-refusal"); + warn.appendChild(el("div", "carve-refusal-title", "The demo's own advisor run didn't happen")); + warn.appendChild(el("p", "panel-muted", demo.degraded)); + host.appendChild(warn); + } + + const step = CARVE_STEPS[state.step] || CARVE_STEPS[0]; + const body = el("div", "carve-body"); + body.dataset.step = step.id; + host.appendChild(body); + body.appendChild(el("p", "panel-muted", step.blurb)); + + if (state.error && state.error.step === step.id) body.appendChild(refusalBlock(state.error)); + + const renderers = { + advise: () => renderAdvise(body, ctx), + pick: () => renderPick(body, state, ctx, actions), + emit: () => renderEmit(body, state, ctx, actions), + bridge: () => renderBridge(body, state, ctx, actions), + handoff: () => renderHandoff(body, state, actions), + done: () => renderDone(body, state, ctx, actions), + }; + renderers[step.id](); + + // Forward/back, so the whole thing is walkable without aiming at the track. + const nav = el("div", "prow carve-nav"); + if (state.step > 0) { + const back = el("button", "act", "← back"); + back.addEventListener("click", () => actions.go(state.step - 1)); + nav.appendChild(back); + } + if (state.step < CARVE_STEPS.length - 1) { + const nextId = CARVE_STEPS[state.step + 1].id; + const why = blockedReason(state, nextId); + const next = el("button", "act carve-next", `next: ${CARVE_STEPS[state.step + 1].label} →`); + next.disabled = !!why || !!state.busy; + if (why) next.title = why; + next.addEventListener("click", () => actions.go(state.step + 1)); + nav.appendChild(next); + } + host.appendChild(nav); +} + +function renderAdvise(body, ctx) { + const bands = (ctx.carve && ctx.carve.bands) || {}; + body.appendChild(el("h3", null, "bands")); + const legend = [ + ["clean leaf", "var(--managed)", "carve now — 80-100"], + ["carvable w/ edits", "var(--pending)", "boundary work — 50-79"], + ["leave in Terraform", "var(--muted)", "leave — 0-49"], + ]; + for (const [band, color, meaning] of legend) { + const row = el("div", "count-row"); + const dot = el("span", "dot"); + dot.style.background = color; + row.append(dot, el("span", "grow", meaning), el("span", "tag", String(bands[band] ?? 0))); + body.appendChild(row); + } + if (ctx.carve && ctx.carve.advisory) body.appendChild(el("p", "panel-muted", ctx.carve.advisory)); + if (ctx.demo) { + body.appendChild( + el( + "p", + "panel-muted", + `Terraform read from ${ctx.demo.fromLabel}/ in your demo copy. Emit and bridge write only into ${ctx.demo.outLabel}/.`, + ), + ); + } +} + +function renderPick(body, state, ctx, actions) { + if (!state.pick) { + body.appendChild(el("p", "panel-muted", "Nothing picked yet. Click a green card in the graph, or one of these:")); + const greens = (ctx.report && ctx.report.resources ? ctx.report.resources : []).filter((r) => r.score >= 80); + for (const r of greens) { + const row = el("div", "node-row"); + const dot = el("span", "dot"); + dot.style.background = "var(--managed)"; + row.append(dot, el("span", "grow", r.address), el("span", "tag", String(r.score))); + row.addEventListener("click", () => actions.select(r.address)); + body.appendChild(row); + } + return; + } + const { node, resource } = state.pick; + body.appendChild(el("h3", null, "the arithmetic")); + body.appendChild(factRows(pickFacts(node, resource))); + + const cut = cutSummary(resource); + body.appendChild(el("h3", null, cut.known ? "the edges this cut severs" : "the boundary this cut crosses")); + for (const item of cut.items) body.appendChild(el("p", "panel-muted carve-cut", item)); + if (cut.note) body.appendChild(el("p", "panel-muted carve-honesty", cut.note)); + + const why = carveable(resource, node); + if (why) body.appendChild(el("p", "panel-muted carve-honesty", why)); +} + +function renderEmit(body, state, ctx, actions) { + const demo = ctx.demo; + if (!demo || !demo.runnable) { + body.appendChild( + el("p", "panel-muted", demo ? demo.reason : "This is a plain `behold carve` — the steps run only inside a `behold demo carve` copy."), + ); + return; + } + if (!state.emit) { + // Two different "no"s, and they read differently: not picked yet, versus + // picked something chant has no native mapping for. The second one is + // chant's own refusal, said here instead of after a round trip. + const why = blockedReason(state, "emit") || (state.pick ? carveable(state.pick.resource, state.pick.node) : null); + const run = el("button", "act carve-run", state.busy === "emit" ? "emitting…" : `Emit ${state.pick ? state.pick.node.id : ""} →`); + run.disabled = !!why || !!state.busy; + if (why) run.title = why; + if (why) body.appendChild(el("p", "panel-muted carve-honesty", why)); + run.addEventListener("click", actions.runEmit); + body.appendChild(run); + body.appendChild( + el( + "p", + "panel-muted", + "Runs `chant carve emit --state --select ` in your demo copy. It adopts the resource from the tfstate " + + "into typed chant source — nothing is applied, nothing is destroyed, and your Terraform is not touched.", + ), + ); + return; + } + const e = state.emit; + body.appendChild(pre(e.command, "carve-cmd")); + body.appendChild(pre(e.output)); + + const verdict = lintVerdict(e.lint); + if (verdict) { + const row = el("div", "count-row"); + const dot = el("span", "dot"); + dot.style.background = verdict.tone === "good" ? "var(--managed)" : "var(--degraded)"; + row.append(dot, el("span", "grow", verdict.text), el("span", "tag", e.lint.command)); + body.appendChild(row); + body.appendChild(pre(e.lint.output)); + } + // chant#1637, said where it matters rather than only in a README nobody has + // open: this step shows `lint` and not `build`, and here is why. + const caveat = el("details", "carve-caveat"); + caveat.appendChild(el("summary", null, "why lint and not build?")); + caveat.appendChild(el("p", "panel-muted", e.buildCaveat || (ctx.demo && ctx.demo.buildCaveat) || "")); + body.appendChild(caveat); + + body.appendChild(el("h3", null, "emitted")); + for (const a of e.artifacts || []) body.appendChild(artifactBlock(a, actions)); + + if (e.boundary) { + body.appendChild(el("h3", null, "boundary report")); + body.appendChild(ctx.renderJson(e.boundary)); + } +} + +function renderBridge(body, state, ctx, actions) { + const demo = ctx.demo; + if (!demo || !demo.runnable) { + body.appendChild(el("p", "panel-muted", demo ? demo.reason : "The steps run only inside a `behold demo carve` copy.")); + return; + } + if (!state.bridge) { + const why = blockedReason(state, "bridge"); + const run = el("button", "act carve-run", state.busy === "bridge" ? "bridging…" : "Bridge →"); + run.disabled = !!why || !!state.busy; + if (why) run.title = why; + run.addEventListener("click", actions.runBridge); + body.appendChild(run); + body.appendChild( + el( + "p", + "panel-muted", + "Runs `chant carve bridge` WITHOUT --apply-rewrites: the rewritten Terraform lands beside the runbook as a " + + "proposal, and your own `.tf` files are never edited.", + ), + ); + return; + } + const b = state.bridge; + body.appendChild(pre(b.command, "carve-cmd")); + body.appendChild(pre(b.output)); + body.appendChild(el("h3", null, "proposed")); + for (const a of b.proposals || []) body.appendChild(artifactBlock(a, actions)); +} + +function renderHandoff(body, state, actions) { + const runbook = state.bridge && state.bridge.runbook; + if (!runbook) { + body.appendChild(el("p", "panel-muted", blockedReason(state, "handoff") || "no runbook yet")); + return; + } + // The one place the walkthrough refuses to be a button, stated out loud. + const why = el("div", "carve-refusal carve-human"); + why.appendChild(el("div", "carve-refusal-title", "These two are not buttons, on purpose.")); + why.appendChild( + el( + "p", + "panel-muted", + "`terraform state rm` releases Terraform's claim on a live resource, and `terraform apply` writes to your cloud. " + + "behold triggers delegated work; it does not decide when your estate changes hands. Copy them, read the plan, " + + "and run them yourself.", + ), + ); + body.appendChild(why); + + body.appendChild(el("h3", null, "the runbook")); + let section = null; + for (const c of runbookCommands(runbook.text)) { + if (c.section !== section) { + section = c.section; + body.appendChild(el("p", "panel-muted carve-section", section)); + } + const row = el("div", "prow carve-cmd-row"); + row.appendChild(el("code", "grow carve-cmd-text", c.command)); + row.appendChild(copyButton("copy", c.command, actions)); + body.appendChild(row); + if (c.note) body.appendChild(el("p", "panel-muted carve-cmd-note", c.note)); + } + + const ran = el("button", "act carve-ran", state.handoff ? "✓ marked as run" : "I ran these →"); + ran.title = "Marks the handoff done in this walkthrough. behold has not checked — it can't, and won't pretend to."; + ran.addEventListener("click", actions.markHandoff); + body.appendChild(ran); + + const full = el("details", "carve-caveat"); + full.appendChild(el("summary", null, `the whole runbook (${runbook.path})`)); + full.appendChild(pre(runbook.text)); + body.appendChild(full); +} + +function renderDone(body, state, ctx, actions) { + const address = state.pick ? state.pick.node.id : "the resource"; + const card = el("div", "carve-endcard"); + card.appendChild(el("div", "carve-endcard-title", `${address} — chant-owned, observe position.`)); + card.appendChild( + el( + "p", + "panel-muted", + "Nothing was destroyed and nothing was recreated. `terraform import` puts it back under Terraform at any point.", + ), + ); + body.appendChild(card); + + if (!state.handoff) { + 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.")); + } + body.appendChild( + el( + "p", + "panel-muted carve-honesty", + "Deferred to the follow-up: the card doesn't yet SLIDE out of the Terraform box and into the chant project " + + "beside last month's carves — it is marked in place. The Floci `--live` tier (a real terraform apply, a real " + + "plan showing no destroy) is the other half of that follow-up.", + ), + ); + const again = el("button", "act", "↺ start over"); + again.title = "Back to Advise. Whatever the steps already wrote stays in the demo copy."; + again.addEventListener("click", actions.reset); + body.appendChild(again); +} diff --git a/web/carve-steps.test.js b/web/carve-steps.test.js new file mode 100644 index 0000000..0290772 --- /dev/null +++ b/web/carve-steps.test.js @@ -0,0 +1,218 @@ +// #254: the pure half of web/carve-steps.js, tested the way web/json-view.js +// (#259) and web/layout-store.js (#245) are — no DOM, no jsdom, no browser. +// Everything the stepper DECIDES (which step is reachable, what the Pick step +// can honestly claim about the cut, which runbook lines are commands, what +// `chant lint` reads as) is a function from data to data, so it is checkable +// here; the DOM half — the buttons, the copy controls, the six-step walk — is +// smoke/ui-smoke.mjs's job. +import { describe, it, expect } from "vitest"; +import { + CARVE_STEPS, + blockedReason, + carveable, + completed, + cutSummary, + edgeLine, + initialCarveState, + lintVerdict, + pickFacts, + runbookCommands, + stepStatus, +} from "./carve-steps.js"; + +const BUCKET = { + address: "aws_s3_bucket.assets", + score: 88, + band: "clean leaf", + mapsTo: "AWS::S3::Bucket", + breakdown: { inbound: 1, outbound: 0, tier: 1 }, +}; +const NODE = { id: "aws_s3_bucket.assets", attrs: { arithmetic: "100 - 12x1 inbound = 88", score: 88, band: "clean leaf", tier: 1 } }; + +const picked = () => ({ ...initialCarveState(), step: 1, pick: { node: NODE, resource: BUCKET } }); + +describe("the six steps", () => { + it("are the six #254 names, in order", () => { + expect(CARVE_STEPS.map((s) => s.id)).toEqual(["advise", "pick", "emit", "bridge", "handoff", "done"]); + }); +}); + +describe("blockedReason — the gates are real data dependencies", () => { + it("blocks emit until something is picked", () => { + expect(blockedReason(initialCarveState(), "emit")).toContain("Pick a resource first"); + expect(blockedReason(picked(), "emit")).toBeNull(); + }); + + it("blocks bridge until emit ran, and says why: the manifest emit leaves behind", () => { + expect(blockedReason(picked(), "bridge")).toContain("carve manifest"); + expect(blockedReason({ ...picked(), emit: { ok: true } }, "bridge")).toBeNull(); + }); + + it("blocks handoff until bridge wrote the runbook", () => { + const s = { ...picked(), emit: { ok: true } }; + expect(blockedReason(s, "handoff")).toContain("Run Bridge first"); + expect(blockedReason({ ...s, bridge: { ok: true } }, "handoff")).toBeNull(); + }); + + it("never blocks advise or pick — the walkthrough always has a first frame", () => { + expect(blockedReason(initialCarveState(), "advise")).toBeNull(); + expect(blockedReason(initialCarveState(), "pick")).toBeNull(); + }); +}); + +describe("completed / stepStatus", () => { + it("a fresh walkthrough is on advise, with everything past pick blocked", () => { + const s = initialCarveState(); + expect(stepStatus(s, 0)).toBe("current"); + expect(stepStatus(s, 1)).toBe("todo"); + expect(stepStatus(s, 2)).toBe("blocked"); + expect(stepStatus(s, 5)).toBe("blocked"); + }); + + it("a step reads done from its RESULT, not from having walked past it", () => { + const s = { ...picked(), step: 3 }; + expect(completed(s, "emit")).toBe(false); + expect(stepStatus(s, 2)).toBe("todo"); // stepped past emit without running it + expect(completed({ ...s, emit: { ok: true } }, "emit")).toBe(true); + }); + + it("done needs the handoff acknowledged AND the bridge run", () => { + const s = { ...picked(), emit: { ok: true }, bridge: { ok: true } }; + expect(completed(s, "done")).toBe(false); + expect(completed({ ...s, handoff: true }, "done")).toBe(true); + }); +}); + +describe("cutSummary — counts and edge lists are different claims", () => { + it("names the survivors when the report carries the edge lists (chant#1636)", () => { + const withEdges = { + ...BUCKET, + boundary: { + inbound: [ + { + direction: "inbound", + survivor: "aws_lambda_function.api", + carved: "aws_s3_bucket.assets", + attrs: ["bucket"], + via: ["environment"], + bridge: "tf-data-source", + required: "immediately", + }, + ], + outbound: [], + }, + }; + const cut = cutSummary(withEdges); + expect(cut.known).toBe(true); + expect(cut.items[0]).toContain("aws_lambda_function.api"); + expect(cut.items[0]).toContain("data source"); + expect(cut.note).toBeNull(); + }); + + it("falls back to the counts, and SAYS the survivors aren't in this report", () => { + const cut = cutSummary(BUCKET); + expect(cut.known).toBe(false); + expect(cut.items[0]).toContain("1 inbound"); + expect(cut.note).toContain("chant#1636"); + }); + + it("distinguishes 'no edges' from 'not reported'", () => { + const clean = cutSummary({ ...BUCKET, breakdown: { inbound: 0, outbound: 0, tier: 1 } }); + expect(clean.items[0]).toContain("No boundary edges at all"); + expect(clean.note).toBeNull(); + }); + + it("reads either shape of the boundary field", () => { + const flat = cutSummary({ ...BUCKET, boundary: [{ survivor: "a", carved: "b", direction: "outbound" }] }); + expect(flat.known).toBe(true); + }); +}); + +describe("edgeLine", () => { + it("reads an inbound edge as the survivor's problem and an outbound as ours", () => { + expect(edgeLine({ direction: "inbound", survivor: "lambda", carved: "bucket", attrs: ["bucket"] })).toContain("lambda reads bucket"); + expect(edgeLine({ direction: "outbound", survivor: "vpc", carved: "sg", attrs: ["id"] })).toContain("from vpc"); + expect(edgeLine({ direction: "outbound", survivor: "vpc", carved: "sg", bridge: "deferred-input" })).toContain("deploy-time input"); + }); +}); + +describe("pickFacts / carveable", () => { + it("shows the arithmetic the lens already spelled out", () => { + const facts = pickFacts(NODE, BUCKET); + expect(facts.map((f) => f.label)).toEqual(["address", "score", "arithmetic", "maps to", "tier"]); + expect(facts[2].value).toBe("100 - 12x1 inbound = 88"); + }); + + it("says up front when a resource has no native mapping to carve into", () => { + expect(carveable(BUCKET, NODE)).toBeNull(); + const unmappable = { address: "random_pet.suffix", score: 0, band: "leave in Terraform", breakdown: { tier: null } }; + expect(carveable(unmappable, { id: "random_pet.suffix", attrs: { tier: "none" } })).toContain("no known native mapping"); + }); +}); + +describe("runbookCommands", () => { + // chant's own runbook shape (carve bridge, 0.44.4), trimmed. + const RUNBOOK = `# Carve-out: aws_s3_bucket.assets → chant [observe-first, reversible] + +## 1. Review the emitted chant source + (produced by \`chant carve emit\` — confirm it builds to a spec-true template) + +## 2. Stop Terraform managing the resource (does NOT destroy it) + terraform state rm aws_s3_bucket.assets aws_s3_bucket_versioning.assets + +## 3. Confirm no destroy, then patch the survivors + terraform plan # expect 0 to destroy + # the generated bridge patch removes the carved block(s) + terraform plan # expect: in-place updates to the survivors only + terraform apply + +## Rollback (any time before apply-graduation) + terraform import aws_s3_bucket.assets +`; + + it("takes the indented commands, in order, under their own headings", () => { + const cmds = runbookCommands(RUNBOOK); + expect(cmds.map((c) => c.command)).toEqual([ + "terraform state rm aws_s3_bucket.assets aws_s3_bucket_versioning.assets", + "terraform plan", + "terraform apply", + "terraform import aws_s3_bucket.assets ", + ]); + expect(cmds[0].section).toContain("Stop Terraform managing"); + }); + + it("drops annotations, keeps a trailing comment as the row's note, and dedupes", () => { + const cmds = runbookCommands(RUNBOOK); + expect(cmds.some((c) => c.command.startsWith("("))).toBe(false); + expect(cmds.some((c) => c.command.startsWith("#"))).toBe(false); + expect(cmds.filter((c) => c.command === "terraform plan")).toHaveLength(1); + expect(cmds[1].note).toBe("expect 0 to destroy"); + }); + + it("survives an empty or missing runbook", () => { + expect(runbookCommands("")).toEqual([]); + expect(runbookCommands(null)).toEqual([]); + }); +}); + +describe("lintVerdict", () => { + it("counts warnings off chant's SUMMARY line, not the column numbers above it", () => { + const output = + " 5:1 warning Exported declarable 'assets' is never referenced. COR004\n" + + " 5:23 warning S3 Bucket created without encryption configuration. WAW006\n" + + " 7:9 warning Inline object in Declarable constructor. COR001\n\n" + + "⚠ 3 warnings"; + expect(lintVerdict({ ok: true, code: 0, output }).text).toBe("chant lint: passes, 3 warning(s)"); + }); + + it("reads a clean run and a failing one", () => { + expect(lintVerdict({ ok: true, code: 0, output: "" }).text).toBe("chant lint: passes"); + const bad = lintVerdict({ ok: false, code: 1, output: "✖ 2 errors" }); + expect(bad.tone).toBe("bad"); + expect(bad.text).toContain("exited 1"); + }); + + it("is null with no lint at all — nothing to claim", () => { + expect(lintVerdict(null)).toBeNull(); + }); +}); diff --git a/web/index.html b/web/index.html index 28fc172..dd1f6fe 100644 --- a/web/index.html +++ b/web/index.html @@ -344,6 +344,46 @@ .dial-detail { display: flex; flex-wrap: wrap; gap: 4px 12px; color: var(--muted); font: var(--t-caption)/1.6 var(--font-mono); } + /* The carve walkthrough (#254) — the panel's Carve tab. Six steps on the + same track the deploy dial uses, so "where am I in this" reads the same + way in both places; below it, the step's own panel. Every colour here is + a shared token, so all 552 palettes get it for free. */ + #tab-carve { display: flex; flex-direction: column; gap: 10px; } + #tab-carve .dial-track { gap: 4px; } + .carve-step { padding: 3px 8px; font-size: var(--t-caption); } + .carve-step.done { color: var(--managed); border-color: color-mix(in srgb, var(--managed) 45%, var(--rule)); } + .carve-step.blocked { opacity: .55; } + .carve-body { display: flex; flex-direction: column; gap: 6px; } + /* Data — chant's output, a file, a command — is mono and boxed; prose + about it stays in the panel's own voice. */ + .carve-pre { margin: 0; background: var(--well); border: 1px solid var(--line); + border-radius: var(--r-ctl); padding: 6px 8px; max-height: 240px; overflow: auto; + font: var(--t-caption)/1.5 var(--font-mono); color: var(--fg); white-space: pre-wrap; + overflow-wrap: anywhere; } + .carve-pre.carve-cmd { color: var(--muted); } + .carve-artifact { display: flex; flex-direction: column; gap: 3px; } + .carve-path { font: var(--t-caption)/1.5 var(--font-mono); overflow-wrap: anywhere; } + .carve-cut, .carve-honesty { margin: 2px 0; } + /* The one thing the walkthrough will not do for you, and the refusals it + hands back — same card, because they are the same kind of statement. */ + .carve-refusal { border: 1px solid var(--degraded); border-left: 3px solid var(--degraded); + border-radius: var(--r-ctl); padding: 6px 8px; background: var(--well); } + .carve-refusal.carve-human { border-color: var(--pending); border-left-color: var(--pending); } + .carve-refusal-title { font-size: var(--t-body); margin-bottom: 2px; } + .carve-cmd-row { align-items: center; gap: 6px; } + .carve-cmd-text { font: var(--t-caption)/1.6 var(--font-mono); background: var(--well); + border: 1px solid var(--line); border-radius: var(--r-ctl); padding: 3px 6px; + overflow-wrap: anywhere; } + .carve-cmd-note, .carve-section { margin: 2px 0; } + .carve-endcard { border: 1px solid var(--managed); border-left: 3px solid var(--managed); + border-radius: var(--r-ctl); padding: 6px 8px; background: var(--well); } + .carve-endcard-title { font-size: var(--t-body); margin-bottom: 2px; } + .carve-caveat > summary { cursor: pointer; color: var(--muted); + font: var(--t-caption)/1.6 var(--font-mono); } + /* The still frame of the morph the follow-up animates: a carved card + wears the managed edge its new owner paints everything with. */ + #graph [data-node-id].carved rect:first-of-type { stroke: var(--managed); stroke-width: 2; } + #nowline { grid-column: 1 / 3; grid-row: 2; margin: 0; max-height: 160px; overflow: auto; display: none; background: var(--well); border-top: 1px solid var(--rule); padding: 8px 12px; font: var(--t-caption)/1.6 var(--font-mono); color: var(--fg); white-space: pre-wrap; } diff --git a/web/panel.js b/web/panel.js index 4f3f1c8..55b8c78 100644 --- a/web/panel.js +++ b/web/panel.js @@ -122,6 +122,34 @@ export function setPanelTab(tab, { expand = true } = {}) { } } +/** + * #254: add a tab the markup doesn't ship — the Carve walkthrough, which only + * exists when the server is serving one. Mounted at runtime rather than hidden + * in index.html so no other project grows a dead tab (and so a persisted + * `tab: "carve"` can never strand someone on an empty section: the tab isn't + * there to be restored). Returns the section element to render into; calling it + * twice is a no-op that returns the same one. + */ +export function addPanelTab(id, label, title) { + if (!panel) return null; + const existing = panel.querySelector(`#panel-body section[data-tab="${id}"]`); + if (existing) return existing; + const b = document.createElement("button"); + b.dataset.tab = id; + b.textContent = label; + if (title) b.title = title; + b.addEventListener("click", () => setPanelTab(id)); + // Before the ⌘K pill + collapse chevron, which are not tabs. + document.getElementById("panel-tabs").insertBefore(b, document.getElementById("hintk")); + const section = document.createElement("section"); + section.dataset.tab = id; + document.getElementById("panel-body").appendChild(section); + // The new button starts inactive; the section starts hidden. Re-applying the + // current tab keeps the two in step whatever it happens to be. + setPanelTab(state.tab, { expand: false }); + return section; +} + function setCollapsed(on) { state.collapsed = !!on; panel.classList.toggle("collapsed", state.collapsed); From be6a2f30608ca6106743b5e3d1d9823ccdb1bb55 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:14:03 -0600 Subject: [PATCH 2/4] fix: the Pick step reads the IR's own boundary counts, not only the raw report (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught a race the local run never lost: `/api/carve` is a second fetch (it exists for the edge lists chant#1636 will publish), and a click that lands before it does left `cutSummary` with no resource — which made it claim "no boundary edges at all" for a bucket that has one. A different, wrong statement about the same resource. The counts are already on every card: the lens puts `inbound`/`outbound` in `attrs`. Read them there when the raw report isn't in hand, with `??` so a genuine 0 stays 0. The panel no longer waits for the fetch to say something true, and the smoke waits on a readiness marker so it tests the panel rather than the network. Co-Authored-By: Claude Fable 5 --- smoke/ui-smoke.mjs | 5 ++++- web/app.js | 5 +++++ web/carve-steps.js | 13 +++++++++---- web/carve-steps.test.js | 15 +++++++++++++++ 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/smoke/ui-smoke.mjs b/smoke/ui-smoke.mjs index ea92a08..8cf5c7a 100644 --- a/smoke/ui-smoke.mjs +++ b/smoke/ui-smoke.mjs @@ -592,7 +592,10 @@ try { await carvePage.waitForSelector('#panel-tabs button[data-tab="carve"]', { timeout: 10000 }); check("carve mode mounts the Carve tab", (await carvePage.locator('#panel-tabs button[data-tab="carve"]').count()) === 1); await carvePage.click('#panel-tabs button[data-tab="carve"]'); - await carvePage.waitForTimeout(150); + // The raw report is a second fetch (`/api/carve`, for the boundary lists); + // wait for it rather than racing it, so what follows tests the panel and + // not the network. The panel renders without it either way. + await carvePage.waitForSelector('#tab-carve[data-report="1"]', { timeout: 10000 }); const step = (id) => carvePage.locator(`#tab-carve .carve-step[data-step="${id}"]`); const stepState = (id) => step(id).getAttribute("data-status"); diff --git a/web/app.js b/web/app.js index 5486e31..56af736 100644 --- a/web/app.js +++ b/web/app.js @@ -1318,6 +1318,11 @@ async function loadCarveReport() { carveReport = null; } renderPanelCarve(); + // A readiness marker, so a test can wait for the extra fetch instead of + // racing it. The panel itself never waits: `cutSummary` falls back to the + // counts the IR node already carries, which is what the report would have + // told it anyway on a chant that publishes no edge lists. + if (carveHost) carveHost.dataset.report = carveReport ? "1" : "0"; } function renderPanel() { diff --git a/web/carve-steps.js b/web/carve-steps.js index 22a92ed..7b114ed 100644 --- a/web/carve-steps.js +++ b/web/carve-steps.js @@ -155,14 +155,19 @@ export function edgeLine(e) { * rather than inventing them. "None" and "not reported" are different claims, * and a walkthrough that blurs them teaches the wrong thing about the tool. */ -export function cutSummary(resource) { +export function cutSummary(resource, node) { const edges = boundaryEdgesOf(resource); if (edges.length) { return { known: true, items: edges.map(edgeLine), note: null }; } + // The counts come off the raw report when it's in hand and off the IR node's + // own attrs otherwise — the lens puts `inbound`/`outbound` on every card, so + // the step never has to wait for the extra `/api/carve` fetch to say + // something true. (`??`, not `||`: a real 0 is an answer.) + const attrs = (node && node.attrs) || {}; const b = (resource && resource.breakdown) || {}; - const inbound = b.inbound || 0; - const outbound = b.outbound || 0; + const inbound = b.inbound ?? attrs.inbound ?? 0; + const outbound = b.outbound ?? attrs.outbound ?? 0; const items = []; if (inbound) items.push(`${inbound} inbound — a survivor reads this; each needs a Terraform data-source patch, immediately.`); if (outbound) items.push(`${outbound} outbound — this reads a survivor; each becomes a deferred deploy-time input.`); @@ -431,7 +436,7 @@ function renderPick(body, state, ctx, actions) { body.appendChild(el("h3", null, "the arithmetic")); body.appendChild(factRows(pickFacts(node, resource))); - const cut = cutSummary(resource); + const cut = cutSummary(resource, node); body.appendChild(el("h3", null, cut.known ? "the edges this cut severs" : "the boundary this cut crosses")); for (const item of cut.items) body.appendChild(el("p", "panel-muted carve-cut", item)); if (cut.note) body.appendChild(el("p", "panel-muted carve-honesty", cut.note)); diff --git a/web/carve-steps.test.js b/web/carve-steps.test.js index 0290772..3414a77 100644 --- a/web/carve-steps.test.js +++ b/web/carve-steps.test.js @@ -126,6 +126,21 @@ describe("cutSummary — counts and edge lists are different claims", () => { const flat = cutSummary({ ...BUCKET, boundary: [{ survivor: "a", carved: "b", direction: "outbound" }] }); expect(flat.known).toBe(true); }); + + it("falls back to the IR node's own counts when the raw report isn't in hand", () => { + // The lens puts inbound/outbound on every card, so a pick made before the + // extra /api/carve fetch lands still says something true — rather than + // claiming "no boundary edges at all", which would be a different (and + // wrong) statement about the same resource. + const cut = cutSummary(null, { id: "aws_s3_bucket.assets", attrs: { inbound: 1, outbound: 0 } }); + expect(cut.items[0]).toContain("1 inbound"); + expect(cut.note).toContain("chant#1636"); + }); + + it("still reads a genuine zero as zero, not as missing", () => { + const cut = cutSummary(null, { id: "x", attrs: { inbound: 0, outbound: 0 } }); + expect(cut.items[0]).toContain("No boundary edges at all"); + }); }); describe("edgeLine", () => { From 399ec1d075d82127bf90e0e827e1facb3262e32c Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:17:37 -0600 Subject: [PATCH 3/4] test: read the Pick step's cut summary off its own elements, not the tab's innerText (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arithmetic line carries "1 inbound" too, so a substring test over the whole tab passed whether or not the cut summary rendered — and CI failing only the note next to it had no way to say which. Assert on .carve-cut and .carve-honesty directly, and print both when the note is missing. Co-Authored-By: Claude Fable 5 --- smoke/ui-smoke.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/smoke/ui-smoke.mjs b/smoke/ui-smoke.mjs index 8cf5c7a..8bbe705 100644 --- a/smoke/ui-smoke.mjs +++ b/smoke/ui-smoke.mjs @@ -617,8 +617,15 @@ try { check("a graph click IS the Pick step", (await bodyStep()) === "pick"); const pick = await carveText(); check("Pick shows the score arithmetic", pick.includes("100 - 12x1 inbound = 88")); - check("Pick names the cut from the breakdown's counts", pick.includes("1 inbound")); - check("…and says plainly that the survivors aren't in this report (chant#1636)", pick.includes("chant#1636")); + // Read off the elements, not off the panel's innerText: the arithmetic line + // ("100 - 12x1 inbound = 88") contains "1 inbound" too, so a substring test + // over the whole tab would pass whether or not the cut summary rendered at + // all — which is exactly the confusion that hid a failure here once. + const cutText = (await carvePage.locator("#tab-carve .carve-cut").allTextContents()).join("\n"); + const honestyText = (await carvePage.locator("#tab-carve .carve-honesty").allTextContents()).join("\n"); + check("Pick names the cut from the breakdown's counts", cutText.includes("1 inbound")); + check("…and says plainly that the survivors aren't in this report (chant#1636)", honestyText.includes("chant#1636")); + if (!honestyText.includes("chant#1636")) console.error(" cut:", JSON.stringify(cutText), "honesty:", JSON.stringify(honestyText)); check("the inspect pane opened on the same node", (await carvePage.locator("#inspect").innerText()).includes("aws_s3_bucket.assets")); check("emit unblocks once something is picked", (await stepState("emit")) !== "blocked"); check("bridge stays blocked until emit has run", (await stepState("bridge")) === "blocked"); From 58715177abab93d114a14f468a1517f2f2e8da13 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:23:31 -0600 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20the=20carve=20demo=20after=20main's?= =?UTF-8?q?=20#268=20=E2=80=94=20async=20boot=20steps,=20and=20an=20unswit?= =?UTF-8?q?chable=20catalog=20row=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main moved the demo loader to an async `spawn` and grew a one-click catalog in the switcher. Two consequences for the walkthrough: - the carve boot's three steps (npm install, the HCL parser, `carve advise`) use the same non-blocking spawn, so cli.ts keeps no `spawnSync`; - `behold demo carve` is runnable anywhere but is not a project to switch INTO — carve mode claims /api/graph and /api/project at app creation, so a running server cannot become one. The row stays in the catalog marked `switchable: false` with the command that works, and POST /api/demos/open refuses it before the copy rather than after the install. Also: main's committed carve-report.json is now a chant#1636 one, so the smoke asserts the KNOWN path (the survivor named) and the counts-only fallback moves to the unit tests, which no longer depend on a fixture this repo ships. Co-Authored-By: Claude Fable 5 --- src/cli.ts | 37 ++++++++++++++++++++++--------------- src/demos-route.test.ts | 26 ++++++++++++++++++++++++++ web/demos.test.js | 10 ++++++++++ 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 4fdad25..698bc74 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import { resolve, dirname, join } from "node:path"; import { realpathSync, existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; import { startServer, beholdVersion } from "./server.ts"; import { loadDemoRegistry, missingRequirements, demoTargetDir, loadDemo, type DemoCarve } from "./demos.ts"; import { resolveChant } from "./chant.ts"; @@ -405,6 +406,18 @@ async function runDemo(rest: string[]): Promise { await run(serveArgs); } +/** One child step of the carve boot, output inherited so npm and chant narrate + * themselves into behold's own terminal. Async (not `spawnSync`) to match the + * shape demos.ts moved to in #268; a spawn error comes back as -1, never a + * rejection. */ +function spawnStep(cmd: string, args: string[], cwd: string): Promise { + return new Promise((res) => { + const child = spawn(cmd, args, { stdio: "inherit", cwd, shell: process.platform === "win32" }); + child.on("error", () => res(-1)); + child.on("close", (code) => res(code ?? 1)); + }); +} + /** * `behold demo carve`'s boot (#254, M1.5 of #230) — the offline tier. * @@ -441,36 +454,30 @@ async function serveCarveDemo(target: string, carve: DemoCarve, port: number): P if (existsSync(join(project, "package.json")) && !existsSync(join(project, "node_modules"))) { process.stdout.write(`behold demo carve → npm install in ${carve.project}/ (the chant this walkthrough shells)…\n`); - const r = spawnSync("npm", ["install"], { cwd: project, stdio: "inherit", shell: process.platform === "win32" }); - if (r.status !== 0) { - process.stderr.write(`behold demo carve: npm install failed in ${project}${r.error ? ` (${r.error.message})` : ""}\n`); - process.exit(r.status ?? 1); + const code = await spawnStep("npm", ["install"], project); + if (code !== 0) { + process.stderr.write(`behold demo carve: npm install failed in ${project}\n`); + process.exit(code || 1); } } let degraded: string | undefined; if (!existsSync(join(target, "node_modules", "@cdktf", "hcl2json"))) { process.stdout.write("behold demo carve → npm install @cdktf/hcl2json (chant's HCL parser, ~2MB, once)…\n"); - const r = spawnSync("npm", ["install", "--no-save", "--no-package-lock", "@cdktf/hcl2json"], { - cwd: target, - stdio: "inherit", - shell: process.platform === "win32", - }); - if (r.status !== 0) { - degraded = "couldn't install @cdktf/hcl2json (chant's HCL parser) — no network?"; - } + const code = await spawnStep("npm", ["install", "--no-save", "--no-package-lock", "@cdktf/hcl2json"], target); + if (code !== 0) degraded = "couldn't install @cdktf/hcl2json (chant's HCL parser) — no network?"; } const report = at("carve-report.json"); if (!degraded) { const bin = resolveChant(project).bin; process.stdout.write("behold demo carve → chant carve advise (read-only; emits nothing)…\n"); - const r = spawnSync( + const code = await spawnStep( bin, ["carve", "advise", "--from", carve.from, ...(carve.state ? ["--state", carve.state] : []), "--report", report], - { cwd: target, stdio: "inherit" }, + target, ); - if (r.status !== 0) degraded = `chant carve advise exited ${r.status ?? "on a spawn error"}`; + if (code !== 0) degraded = `chant carve advise exited ${code}`; } // Fall back to the committed report rather than to nothing — the walkthrough's // first frame is the banded graph, and a blank one teaches the viewer that diff --git a/src/demos-route.test.ts b/src/demos-route.test.ts index d781c3a..64aa361 100644 --- a/src/demos-route.test.ts +++ b/src/demos-route.test.ts @@ -138,6 +138,21 @@ describe("GET /api/demos — the catalog, with per-entry satisfiability (#268)", const body = (await (await makeApp({ previewMode: true }).request("/api/demos")).json()) as { locked?: string }; expect(body.locked).toMatch(/preview mode/); }); + + // #254: the carve walkthrough runs fine on any machine, but it serves a + // report rather than a project — and carve mode claims /api/graph and + // /api/project at app creation, so a running server cannot become one. It + // stays in the catalog, marked unswitchable, naming the command that works. + it("marks the carve walkthrough runnable but not switchable", async () => { + const { demos } = (await (await makeApp().request("/api/demos")).json()) as { demos: Array }; + const carve = demos.find((d) => d.name === "carve")!; + expect(carve).toBeTruthy(); + expect(carve.satisfiable).toBe(true); + expect(carve.switchable).toBe(false); + expect(carve.reason).toMatch(/behold demo carve/); + // The flag is absent on everything else, not false. + expect(demos.filter((d) => d.switchable === false).map((d) => d.name)).toEqual(["carve"]); + }); }); describe("POST /api/demos/open — a catalog name, never a path (#268)", () => { @@ -187,6 +202,17 @@ describe("POST /api/demos/open — a catalog name, never a path (#268)", () => { expect(loadDemoMock).not.toHaveBeenCalled(); }); + it("400s the carve walkthrough BEFORE copying anything (#254)", async () => { + const res = await post(makeApp(), { name: "carve" }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string; remedy: string }; + expect(body.error).toMatch(/carve report, not a chant project/); + expect(body.remedy).toMatch(/behold demo carve/); + // The point of refusing early: no copy, no npm install, no minutes spent + // on a load that had nowhere to go. + expect(loadDemoMock).not.toHaveBeenCalled(); + }); + it("403s in preview mode — the demo build's no-arbitrary-switching contract", async () => { const res = await post(makeApp({ previewMode: true }), { name: "argo-estate" }); expect(res.status).toBe(403); diff --git a/web/demos.test.js b/web/demos.test.js index 667dafc..026f4b4 100644 --- a/web/demos.test.js +++ b/web/demos.test.js @@ -48,6 +48,16 @@ describe("the demo button's face", () => { expect(demoTitle(row())).toContain("Copies the bundled example to /w/behold-demos/k8s"); }); + it("a runnable demo that isn't a project switch says which command IS (#254)", () => { + const carve = row({ name: "carve", switchable: false, reason: "serves a carve report, not a project — run `behold demo carve`" }); + expect(demoLabel(carve)).toBe("carve · serves a carve report, not a project — run `behold demo carve`"); + expect(demoTitle(carve)).toContain("Not a project switch"); + // Still satisfiable — the row is disabled for a different reason, and + // conflating the two would tell someone to install something they have. + expect(demoTitle(carve)).not.toContain("Can't run here"); + expect(demoNote(row({ switchable: false }))).toBe("run it from a terminal"); + }); + it("shortRepo drops the scheme and the .git suffix", () => { expect(shortRepo("https://github.com/INTENTIUS/fountain-ops.git")).toBe("github.com/INTENTIUS/fountain-ops"); expect(shortRepo(undefined)).toBe("");