diff --git a/README.md b/README.md index 5921d3f..6659897 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Craft, sync and convert AI-coding workspace harnesses — rules, agents, commands, skills, MCP servers — across every client workspace you maintain and every AI coder each team uses. -> Status: **phase 1 in progress** — phase 0, the foundations, is done. `import`, `sync`, `status`, `diff`, `explain`, `ls` work end-to-end for the `claude-code` and `kiro` targets and are validated byte-for-byte against a real workspace (see *Oracle*). The Forge commands `forge variants`, `forge diff` (with a suggested class per hunk) and `forge unify` (`--take base|variant`, `take: param` to turn a value into a `{{key}}` parameter, and `take: section` to turn a client block into a section) and `forge promote` (a profile's variant becomes the base) also work, covered by the unit and golden suites rather than the oracle, and `import` is render-aware: importing into an existing Forge reuses a base whose render equals the workspace file, and edits an existing profile and the recipes it owns in place. Ingredient bodies can hold client-specific **sections** (`` … ``), overridden by a profile's `sections` and a workspace's `overrides.sections`; import infers each client's content and `forge unify` can extract a section from a variant, and a Forge that uses them declares `schema: 2` or above (see *Forge layout*). The read-only catalogue — `recipes`, `ingredients` and `targets` with the capability matrix — works too, and a workspace can name its Forge by git URL with an optional `ref`, read from a per-machine cache, with a lock that records the ref, commit, recipes and targets it was generated from (see *Remote Forge*). `add recipe` / `remove recipe` change a workspace's recipe choice in `craftar.yaml`, refusing an edit that would not resolve. A writing `sync` records each workspace in a per-machine registry, and `craftar workspaces` lists them all with their status (see *Workspace registry*). `craftar forge impact` lists the workspaces registered on this machine that read a Forge (by path, by a git remote of the clone, or through another clone of the same remote) with what their next sync would do against it, and `forge unify` plans them before and after its writes, names the ones its warnings concern, and with `--prune-recipes` deletes a recipe left identical to its sibling once every one of them plans the same. `craftar doctor` reports, one line per finding, what is off on this machine and in this workspace. `craftar cache prune` removes the cache entries and trees nothing uses. An MCP ingredient can declare `authEnv`, the environment variables its server needs (names only, never a value), and `craftar doctor` checks them; with `claude-code`, `sync` writes an example file listing them. A recipe can bring **local services**: a `service` ingredient is a Docker Compose fragment that `sync` writes under `.craftar/`, and `craftar services up | down | status` run Compose on what the lock records (see *Local services*). A profile's `integrations` block can select recipes and give params in a Forge that declares `schema: 3` or above (see *Integrations*), since `0.27.0`. `craftar templates list | install` shows and runs the profile's `templates` block — `install` runs `dotnet new install` per entry into the user's template store; nothing installs implicitly. An MCP `server` takes the params its ingredient declares, in a Forge that declares `schema: 4` (see *Server params*), since `0.28.0`. `craftar mcp` serves a workspace to an AI coder over the Model Context Protocol — seven reading tools with the data of `status`, `diff`, `explain`, `recipes`, `ingredients`, `targets` and `doctor`; started with `--allow-writes`, it also serves `sync`, `add_recipe` and `remove_recipe`, which write only in a workspace where a person typed `craftar mcp allow-writes` and never discard a hand edit. `craftar ui` serves a local, read-only dashboard of every registered workspace — the table of `craftar workspaces`, and for one workspace its file states, a file's diff and explanation, and its Forge's recipes and ingredients — on `127.0.0.1` only. `craftar init --clone` and `craftar repos clone` clone a profile's repositories (its `repos` list) into the workspace, and `craftar repos` lists them. `craftar init --new-profile` creates a profile in a Forge directory (one file, not committed) and starts the workspace on it. With `agents-md.primary`, in a Forge that declares `schema: 5`, an always-on rule lives in `AGENTS.md` alone, and the files that cite it follow it (see *Targets*). +> Status: **phase 1 in progress** — phase 0, the foundations, is done. `import`, `sync`, `status`, `diff`, `explain`, `ls` work end-to-end for the `claude-code` and `kiro` targets and are validated byte-for-byte against a real workspace (see *Oracle*). The Forge commands `forge variants`, `forge diff` (with a suggested class per hunk) and `forge unify` (`--take base|variant`, `take: param` to turn a value into a `{{key}}` parameter, and `take: section` to turn a client block into a section) and `forge promote` (a profile's variant becomes the base) also work, covered by the unit and golden suites rather than the oracle, and `import` is render-aware: importing into an existing Forge reuses a base whose render equals the workspace file, and edits an existing profile and the recipes it owns in place. Ingredient bodies can hold client-specific **sections** (`` … ``), overridden by a profile's `sections` and a workspace's `overrides.sections`; import infers each client's content and `forge unify` can extract a section from a variant, and a Forge that uses them declares `schema: 2` or above (see *Forge layout*). The read-only catalogue — `recipes`, `ingredients` and `targets` with the capability matrix — works too, and a workspace can name its Forge by git URL with an optional `ref`, read from a per-machine cache, with a lock that records the ref, commit, recipes and targets it was generated from (see *Remote Forge*). `add recipe` / `remove recipe` change a workspace's recipe choice in `craftar.yaml`, refusing an edit that would not resolve. A writing `sync` records each workspace in a per-machine registry, and `craftar workspaces` lists them all with their status (see *Workspace registry*). `craftar forge impact` lists the workspaces registered on this machine that read a Forge (by path, by a git remote of the clone, or through another clone of the same remote) with what their next sync would do against it, and `forge unify` plans them before and after its writes, names the ones its warnings concern, and with `--prune-recipes` deletes a recipe left identical to its sibling once every one of them plans the same. `craftar doctor` reports, one line per finding, what is off on this machine and in this workspace. `craftar cache prune` removes the cache entries and trees nothing uses. An MCP ingredient can declare `authEnv`, the environment variables its server needs (names only, never a value), and `craftar doctor` checks them; with `claude-code`, `sync` writes an example file listing them. A recipe can bring **local services**: a `service` ingredient is a Docker Compose fragment that `sync` writes under `.craftar/`, and `craftar services up | down | status` run Compose on what the lock records (see *Local services*). A profile's `integrations` block can select recipes and give params in a Forge that declares `schema: 3` or above (see *Integrations*), since `0.27.0`. `craftar templates list | install` shows and runs the profile's `templates` block — `install` runs `dotnet new install` per entry into the user's template store; nothing installs implicitly. An MCP `server` takes the params its ingredient declares, in a Forge that declares `schema: 4` (see *Server params*), since `0.28.0`. `craftar mcp` serves a workspace to an AI coder over the Model Context Protocol — seven reading tools with the data of `status`, `diff`, `explain`, `recipes`, `ingredients`, `targets` and `doctor`; started with `--allow-writes`, it also serves `sync`, `add_recipe` and `remove_recipe`, which write only in a workspace where a person typed `craftar mcp allow-writes` and never discard a hand edit. `craftar ui` serves a local, read-only dashboard of every registered workspace — the table of `craftar workspaces`, and for one workspace its file states, a file's diff and explanation, and its Forge's recipes and ingredients — on `127.0.0.1` only; started with `--allow-writes`, it also syncs a workspace or discards one hand-edited file, each confirmed in the page and never with `--overwrite-drift`. `craftar init --clone` and `craftar repos clone` clone a profile's repositories (its `repos` list) into the workspace, and `craftar repos` lists them. `craftar init --new-profile` creates a profile in a Forge directory (one file, not committed) and starts the workspace on it. With `agents-md.primary`, in a Forge that declares `schema: 5`, an always-on rule lives in `AGENTS.md` alone, and the files that cite it follow it (see *Targets*). ## The idea in one paragraph @@ -98,7 +98,7 @@ From then on, change a rule in `forge/ingredients/rules//rule.md`, run `cr | `craftar mcp revoke-writes [--workspace ]` | Removes that consent; needs no terminal; takes effect on the server's next call, without a restart. Exits 0 also when there was none (`no consent recorded for `); exits 1 without a `craftar.yaml`, with `CRAFTAR_NO_REGISTRY` set, or when the registry does not read or cannot be written. | | `craftar cache prune [--dry-run] [--workspace ] [--json]` | Removes the Forge cache entries nothing uses — no registered workspace (`missing` ones included until `craftar workspaces prune`) and not the current workspace — entries whose first fetch never completed (even when named), and trees unused for 14 days. Whole entries are kept with a warning when the registry is off or unreadable, or the current `craftar.yaml` does not read. Deletes by default; `--dry-run` shows. Never touches a workspace, a lock or the registry; no network. One removing prune at a time per `$CRAFTAR_HOME` (`forges/prune.lock`). An explicit `--workspace` without `craftar.yaml` exits 1. `--json` is a contract: `{home, dryRun, removed: [{path, kind, reason, bytes}], kept: [{path, reason, namedBy}], freedBytes, warnings}`. | | `craftar workspaces forget ` / `craftar workspaces prune` | `forget` removes one entry (the directory is not touched; an unregistered path exits 1); `prune` removes every `missing` entry and never an `error` one. Nothing else removes an entry. | -| `craftar ui [--port ]` | A local dashboard, in a browser: the table of `craftar workspaces` and, for a registered workspace, what `status`, `diff` and `explain` say about it and what `recipes` and `ingredients` say about its Forge. It prints one address and serves until interrupted (`Ctrl-C` exits 0). **Read-only**: nothing is written to a workspace, a lock, the registry or a Forge — the Forge cache is used as every reader uses it (`.used` stamps, a rebuilt tree; `git` runs as for `craftar workspaces`) — and the page never runs `sync` — it prints the command to copy. **Loopback only**: it listens on `127.0.0.1` (no `--host`; `localhost` is refused), answers `GET` only, and every answer that carries data needs a token generated for that run — it travels in the address's fragment, which the browser does not send, so open the address exactly as printed (pasted, not clicked from a web page; a reload keeps working, a new tab needs the address again); it stays in the terminal's scrollback (and in a file, if the output is redirected) and may stay in the browser's history, and is worth nothing once the command stops. **No network**: a remote Forge is read from the cached copy, never fetched, and the page says so. `--port` (default `4747`; `0` takes a free one); a taken port is refused, never replaced. A Forge URL the registry recorded with credentials is shown as `(a URL with credentials — not shown)`, with no cache key — as `craftar workspaces --json` gives it since `0.28.1` (see *Upgrading › to 0.28.1*). Takes no `--workspace`; exits 1 when the registry is off (`CRAFTAR_NO_REGISTRY`) or cannot be read. The page's routes are not a contract. | +| `craftar ui [--port ] [--allow-writes]` | A local dashboard, in a browser: the table of `craftar workspaces` and, for a registered workspace, what `status`, `diff` and `explain` say about it and what `recipes` and `ingredients` say about its Forge. It prints one address and serves until interrupted (`Ctrl-C` exits 0). **Read-only unless `--allow-writes`**: without the flag nothing is written to a workspace, a lock, the registry or a Forge — the Forge cache is used as every reader uses it (`.used` stamps, a rebuilt tree; `git` runs as for `craftar workspaces`) — and the page never runs `sync` — it prints the command to copy. With `--allow-writes` (typing it is the consent) the page also offers **Sync** for a workspace and **Discard edit** for one hand-edited file (`drift` or `orphan-drift`, from its open diff), each confirmed in the page: a sync that never passes `--overwrite-drift` — every hand edit and collision is skipped and listed — or a `craftar drift discard` of that one path; never for `.claude/settings.craftar.example.json`, never for a workspace whose Forge is remote, and refused when the workspace changed since the page showed it (refresh and confirm again). **Loopback only**: it listens on `127.0.0.1` (no `--host`; `localhost` is refused), answers `GET` only (and, with `--allow-writes`, `POST` to its two writing routes, with an `Origin` of its own address), and every answer that carries data needs a token generated for that run — it travels in the address's fragment, which the browser does not send, so open the address exactly as printed (pasted, not clicked from a web page; a reload keeps working, a new tab needs the address again); it stays in the terminal's scrollback (and in a file, if the output is redirected) and may stay in the browser's history, and is worth nothing once the command stops. **No network**: a remote Forge is read from the cached copy, never fetched, and the page says so. `--port` (default `4747`; `0` takes a free one); a taken port is refused, never replaced. A Forge URL the registry recorded with credentials is shown as `(a URL with credentials — not shown)`, with no cache key — as `craftar workspaces --json` gives it since `0.28.1` (see *Upgrading › to 0.28.1*). Takes no `--workspace`; exits 1 when the registry is off (`CRAFTAR_NO_REGISTRY`) or cannot be read. The page's routes are not a contract. | | `craftar recipes [--forge [--profile ] \| --workspace ] [--json]` | Every recipe of the Forge, sorted by name: slot, the parents it extends, the ingredients it lists itself and the profiles that reach it. In a workspace (or with `--forge --profile `) each recipe says whether it is in use and why — `profile` (listed in `recipes` or selected by the profile's `integrations` block), `workspace` (`recipes.add`), `extends ` — and a recipe `recipes.remove` names reads `removed by this workspace`. A context that does not resolve (two recipes on one slot, an unknown recipe, a cycle) still lists everything, marks nothing and warns. Read-only: it resolves, never plans. `--json` is a contract: `{forge, context, recipes, warnings}`. | | `craftar ingredients [--recipe ] [--type ] [--forge [--profile ] \| --workspace ] [--json]` | Every ingredient of the Forge, grouped by type: output name, targets, the recipes that list it, and whether the context uses it or the workspace disables it; a reference a recipe makes to an ingredient the Forge does not hold is listed under `missing`, never dropped. `--recipe ` lists what choosing that recipe brings, with the parents it extends, parents first; two of them on one slot is a warning. `--type` keeps one type. Read-only. `--json` is a contract: `{forge, context, recipe, ingredients, missing, warnings}`. | | `craftar targets [--workspace ] [--json]` | The capability matrix (see *Targets*): what each target does with each ingredient type, the paths it writes and what changes. Needs no Forge; inside a workspace it marks the targets in use, and when that workspace does not load or resolve it prints the matrix unmarked with a warning. An explicit `--workspace ` without a `craftar.yaml` exits 1; the current directory without one just prints the matrix unmarked. `--json` is a contract: `{ingredientTypes, targets, warnings}`. | @@ -525,7 +525,7 @@ The server answers for the directory it is started in; a client that starts its Phase 0 — foundations (done): schema, import, sync/status/diff/explain, claude-code + kiro + agents-md targets, lock, drift, orphans; `forge variants`, `forge diff` and `forge unify` (`take: param`, `take: section`); sections. -Phase 1 — client profile (in progress). Shipped: the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock) and `craftar cache prune`; the workspace registry and `craftar workspaces`; `craftar add recipe` / `remove recipe`; `craftar init` with flags; `craftar doctor`; `craftar forge impact`, `forge unify --prune-recipes` and `forge promote`; an interactive `craftar init` (choose the Forge and the profile); `authEnv` on MCP ingredients (the environment variables a server needs, by name, and the generated `.claude/settings.craftar.example.json`); `craftar drift show` / `drift discard` / `drift promote` (list the files edited by hand, discard an edit, or carry one into the Forge as a profile value or a variant); a sync refused when a declared parameter has no value; `craftar import --report` (a Markdown report of an import, as paths and line counts) and `import --prefer-kiro` (a rule's source taken from its Kiro steering file); a profile's `integrations` block (PM tool and IDP as recipes selected by the profile); `{{param}}` inside an MCP server; `craftar mcp` (a read-only MCP server: seven reading tools for an AI coder, over stdin and stdout); `craftar ui` (a local, read-only dashboard of the registered workspaces, on `127.0.0.1` only); cloning a profile's repositories (`init --clone`, `craftar repos`); a new profile from `craftar init` (`--new-profile`, `--like`); writing tools for `craftar mcp` (`--allow-writes`: `sync`, `add_recipe`, `remove_recipe`, behind a consent typed per workspace). +Phase 1 — client profile (in progress). Shipped: the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock) and `craftar cache prune`; the workspace registry and `craftar workspaces`; `craftar add recipe` / `remove recipe`; `craftar init` with flags; `craftar doctor`; `craftar forge impact`, `forge unify --prune-recipes` and `forge promote`; an interactive `craftar init` (choose the Forge and the profile); `authEnv` on MCP ingredients (the environment variables a server needs, by name, and the generated `.claude/settings.craftar.example.json`); `craftar drift show` / `drift discard` / `drift promote` (list the files edited by hand, discard an edit, or carry one into the Forge as a profile value or a variant); a sync refused when a declared parameter has no value; `craftar import --report` (a Markdown report of an import, as paths and line counts) and `import --prefer-kiro` (a rule's source taken from its Kiro steering file); a profile's `integrations` block (PM tool and IDP as recipes selected by the profile); `{{param}}` inside an MCP server; `craftar mcp` (a read-only MCP server: seven reading tools for an AI coder, over stdin and stdout); `craftar ui` (a local, read-only dashboard of the registered workspaces, on `127.0.0.1` only); cloning a profile's repositories (`init --clone`, `craftar repos`); a new profile from `craftar init` (`--new-profile`, `--like`); writing tools for `craftar mcp` (`--allow-writes`: `sync`, `add_recipe`, `remove_recipe`, behind a consent typed per workspace); writing actions in `craftar ui` (`--allow-writes`: Sync and Discard edit, confirmed in the page). Phase 2 — environment (in progress). Shipped: local services (`service` ingredients, `.craftar/`, `craftar services up | down | status`); `craftar templates list | install` (`dotnet new` project templates declared in a profile). @@ -533,6 +533,12 @@ Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; ## Upgrading +### to 0.37.0 + +- **New flag: `craftar ui --allow-writes`** (see *Commands*). Without it, `craftar ui` is the read-only dashboard — the page, its script and stylesheet and the three terminal lines byte for byte, and every answer too except one: `GET /api/workspaces` gains the key `writes` (`false`). With it the terminal's first line reads `writes allowed` in place of `read-only`, a second line names the two actions, and the page offers Sync and Discard edit for a workspace with a path Forge. +- **The `ui` command's `--help` description now reads `Reads only unless --allow-writes`.** +- No emitted byte, lock, registry entry, `--json` shape or exit code of an existing command changes. + ### to 0.36.0 - **A Forge can declare `schema: 5`**, which includes 4, 3 and 2. Every craftar before `0.36.0` refuses such a Forge at `craftar.forge.yaml`, so no older craftar writes the three copies of a rule the mode keeps in `AGENTS.md` alone. Upgrade every machine and CI that reads the Forge before setting it. Nothing changes for a Forge at 1 to 4. diff --git a/package-lock.json b/package-lock.json index c66c623..da1348f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.36.0", + "version": "0.37.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.36.0", + "version": "0.37.0", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 044d120..f8513ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.36.0", + "version": "0.37.0", "description": "Craft, sync and convert AI-coding workspace harnesses across clients and tools.", "license": "MIT", "type": "module", diff --git a/src/cli.ts b/src/cli.ts index 6b8f1f6..6b397eb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,7 +25,7 @@ import { canonicalValue } from "./core/sections.js"; import { renderDiff, NO_EOF_NEWLINE_MARKER } from "./core/diff.js"; import { preferredLine, renderImportReport } from "./core/import-report.js"; import { driftList, unmanaged, type DriftRow } from "./core/drift.js"; -import { EXAMPLE_CONTENT_HIDDEN, diffReport, explainFile, explainSkip, forgeLine, nextSyncLine, notProduced, readText, statusPath, statusReport, targetsReport } from "./core/report.js"; +import { EXAMPLE_CONTENT_HIDDEN, diffReport, explainFile, explainSkip, forgeLine, nextSyncLine, notProduced, orphanDriftBody, statusPath, statusReport, targetsReport } from "./core/report.js"; import { diffIngredients, listVariants, profileOf, type Distance, type IngredientDiff } from "./core/variants.js"; import { exists, gitDirty, gitIgnored, gitIsRepo, gitUnheld, loadForge, realpathOfNearest, cannotResolve, type UnheldPath } from "./core/forge.js"; import { applyBasePromotion, planBasePromotion, proveBasePromotion } from "./core/promote.js"; @@ -55,7 +55,7 @@ import { findGit, cloneRepos, listRepos, planClone, type CloneResult, type Desti import { byHand, profileRepos, type RepoEntry } from "./core/repos.js"; import { readlineIo } from "./prompt.js"; import { recipeDiffLine, type RecipeOp } from "./core/recipe-edit.js"; -import { syncWorkspace, editRecipes, confirmWrites } from "./core/write-actions.js"; +import { syncWorkspace, editRecipes, confirmWrites, DISCARDED_ORPHAN } from "./core/write-actions.js"; import { localKeys, readLocalFile } from "./core/workspace-yaml.js"; import { HUNK_CLASSES, INGREDIENT_TYPES, UnifyPlanSchema, type HunkClass, type HunkSuggestion, type IngredientRef, type IngredientType, type Take, type UnifyPlan } from "./schema/index.js"; import { EXAMPLE_SETTINGS } from "./emitters/shared.js"; @@ -76,7 +76,7 @@ process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE const DIFF_EXIT_CODE_FETCH_MODE: FetchMode = "sync"; const program = new Command(); -program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.36.0"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.37.0"); /* ---------------------------------------------------------------- import */ /** The words of one output-path gate (`gateOutsideForge`), declared before the commands that run at load: what is written, by which command, and its two refusals' endings. */ @@ -232,7 +232,7 @@ async function applyAndReport(ws: Workspace, p: Plan, st: FileStatus[], lock: Lo if (o.forgeLine) console.log(o.forgeLine); console.log(` ${verb} ${pc.green(String(o.result.written.length))}, removed ${pc.magenta(String(o.result.removed.length))} orphan(s), skipped ${pc.yellow(String(o.result.skipped.length))}`); for (const f of o.result.written) console.log(` ${pc.green("+")} ${f}`); - for (const f of o.result.removed) console.log(` ${pc.magenta("-")} ${f} (${opts.overwritePaths?.has(f) ? "hand-edited orphan: discarded" : orphanReason(p, st, f)})`); + for (const f of o.result.removed) console.log(` ${pc.magenta("-")} ${f} (${opts.overwritePaths?.has(f) ? DISCARDED_ORPHAN : orphanReason(p, st, f)})`); for (const s of o.result.skipped) console.log(` ${pc.yellow("!")} ${s.path} ${explainSkip(s)}`); for (const w of p.warnings) console.log(` ${pc.yellow("warn")} ${w}`); if (o.registry) console.log(` ${pc.yellow("warn")} registry not updated (${o.registry.file}): ${o.registry.message}`); @@ -913,11 +913,11 @@ drift // The whole file as a removal: what `drift discard` would do to it. console.log(pc.bold(`--- ${named.path} (disk, orphan-drift)`)); console.log(pc.bold(`+++ ${named.path} (forge: no longer produced — \`craftar drift discard\` removes it)`)); - // Spec 27 Ruling 4: the on-disk content of the example file is never printed. - if (named.path === EXAMPLE_SETTINGS) { - console.log(` ${EXAMPLE_CONTENT_HIDDEN}`); + const { body, note } = await orphanDriftBody(ws.root, named); + if (note) { + console.log(` ${note}`); } else { - console.log(renderDiff((await readText(path.join(ws.root, named.path))) ?? "", "", DIFF_PAINT)); + console.log(renderDiff(body ?? "", "", DIFF_PAINT)); } } else await printFileDiff(ws.root, named); if (refusalBlock(unset)) process.exitCode = 1; @@ -1275,10 +1275,11 @@ workspaces program .command("ui") .description( - "A local dashboard: the table of `craftar workspaces` and, for one workspace, its file states, a file's diff and explanation, and its Forge's recipes and ingredients — in a browser, on 127.0.0.1 only, behind an address that works for this run. Reads only — writes no workspace, lock, registry or Forge — and reaches no network; Ctrl-C stops it", + "A local dashboard: the table of `craftar workspaces` and, for one workspace, its file states, a file's diff and explanation, and its Forge's recipes and ingredients — in a browser, on 127.0.0.1 only, behind an address that works for this run. Reads only unless --allow-writes — then a sync or the discard of one hand-edited file, confirmed in the page, never --overwrite-drift — and reaches no network; Ctrl-C stops it", ) .option("--port ", "the port to listen on, 0 to 65535; 0 takes a free one", "4747") - .action(async (o: { port: string }) => { + .option("--allow-writes", "serve Sync and Discard edit on the page — a sync of one workspace, or the discard of one hand-edited file, each confirmed in the page; workspaces with a path Forge only") + .action(async (o: { port: string; allowWrites?: boolean }) => { // Every refusal comes before the bind (spec 36 §4.2). const port = parsePort(o.port); if (port === null) fail("--port takes a whole number from 0 to 65535"); @@ -1290,17 +1291,23 @@ program fail(errorText(e)); } const version = program.version() ?? "unknown"; + const allowWrites = o.allowWrites === true; let ui: UiServer; try { // The one line a failing request leaves in the terminal; nothing is printed per request otherwise. - ui = await startUi({ home: craftarHome(), version, port, onError: (line) => console.error(line) }); + ui = await startUi({ home: craftarHome(), version, port, allowWrites, onError: (line) => console.error(line) }); } catch (e) { // The code only, never the message. const code = (e as NodeJS.ErrnoException).code; if (code === "EADDRINUSE") fail(`port ${port} is in use on 127.0.0.1 — pass --port , or --port 0 for a free one`); fail(`cannot listen on 127.0.0.1:${port}: ${code ?? "unknown"}`); } - console.log(pc.bold(`craftar ui — craftar ${version} · read-only · ${reg.workspaces.length} registered`)); + if (allowWrites) { + console.log(pc.bold(`craftar ui — craftar ${version} · writes allowed · ${reg.workspaces.length} registered`)); + console.log(" writes allowed: sync, discard one hand-edited file"); + } else { + console.log(pc.bold(`craftar ui — craftar ${version} · read-only · ${reg.workspaces.length} registered`)); + } console.log(` open ${ui.url}`); console.log(" the address works for this run only, on this machine — Ctrl-C stops it"); // The first signal closes the listener and lets the process end; a second one, while closing, ends it at once. diff --git a/src/core/report.ts b/src/core/report.ts index 1548fc1..c619edd 100644 --- a/src/core/report.ts +++ b/src/core/report.ts @@ -150,6 +150,17 @@ export async function diffReport(ws: Pick, rows: FileStatus[] return out; } +/** + * What an `orphan-drift` file holds, for a surface that shows it before a discard (spec 40 §6.5, `drift show `): + * the whole body read from disk, or — for EXAMPLE_SETTINGS, whose content is never shown — `{ body: null, note: + * EXAMPLE_CONTENT_HIDDEN }`. The one reader of such a body. + */ +export async function orphanDriftBody(root: string, s: Pick): Promise<{ body: string | null; note: string | null }> { + if (s.state !== "orphan-drift") throw new Error("state must be orphan-drift"); + if (s.path === EXAMPLE_SETTINGS) return { body: null, note: EXAMPLE_CONTENT_HIDDEN }; + return { body: (await readText(path.join(root, s.path))) ?? "", note: null }; +} + /** What `craftar explain` says about one generated file, as data (spec 35 §4.5). */ export interface ExplainedFile { path: string; diff --git a/src/core/write-actions.ts b/src/core/write-actions.ts index 7484925..47842c3 100644 --- a/src/core/write-actions.ts +++ b/src/core/write-actions.ts @@ -38,6 +38,9 @@ import { localKeys } from "./workspace-yaml.js"; import type { Lock } from "../schema/index.js"; import type { Pin } from "./mcp-protocol.js"; +/** The words for the orphan-drift a discard removes (`drift discard` and the page's). */ +export const DISCARDED_ORPHAN = "hand-edited orphan: discarded"; + /* ------------------------------------------------------------------ */ /* syncWorkspace */ /* ------------------------------------------------------------------ */ @@ -206,6 +209,31 @@ export interface SyncToolResult { registry: "updated" | "not updated" | "off"; } +/** The `sync` result (spec 40 §5.6) — one builder, shared by `craftar mcp`'s `sync` tool and `craftar ui`'s write routes. */ +export function syncToolResult( + ws: Workspace, + p: Plan, + statuses: readonly FileStatus[], + outcome: SyncOutcome, + registryOff: boolean, + discarded?: string, +): SyncToolResult { + return { + profile: ws.config.profile, + recipes: [...p.resolution.recipes], + targets: [...p.resolution.targets], + forge: outcome.forgeLine, + written: outcome.result.written, + removed: outcome.result.removed.map((path) => ({ + path, + reason: path === discarded ? DISCARDED_ORPHAN : orphanReason(p, statuses, path), + })), + skipped: outcome.result.skipped.map((s) => ({ path: s.path, state: s.state, reason: explainSkip(s) })), + warnings: p.warnings, + registry: registryOff ? "off" : outcome.registry ? "not updated" : "updated", + }; +} + /** * The `add_recipe` / `remove_recipe` tool result (spec 40 §5.6). */ @@ -296,17 +324,7 @@ export function mcpWriters(home: string, registryOff: boolean): McpWriters { const outcome = await syncWorkspace(ws, p, statuses, lock, { home, registryOff }); - return { - profile: ws.config.profile, - recipes: [...p.resolution.recipes], - targets: [...p.resolution.targets], - forge: outcome.forgeLine, - written: outcome.result.written, - removed: outcome.result.removed.map((path) => ({ path, reason: orphanReason(p, statuses, path) })), - skipped: outcome.result.skipped.map((s) => ({ path: s.path, state: s.state, reason: explainSkip(s) })), - warnings: p.warnings, - registry: registryOff ? "off" : outcome.registry ? "not updated" : "updated", - }; + return syncToolResult(ws, p, statuses, outcome, registryOff); }, async editRecipes( diff --git a/src/ui/page.ts b/src/ui/page.ts index f26e747..aed47fc 100644 --- a/src/ui/page.ts +++ b/src/ui/page.ts @@ -8,6 +8,7 @@ import type { FileState } from "../core/sync.js"; import { diffShows } from "../core/report.js"; +import { EXAMPLE_SETTINGS } from "../emitters/shared.js"; export const PAGE_HTML = ` @@ -63,6 +64,19 @@ ul { margin: 4px 0; padding-left: 18px; } code.cmd { background: var(--soft); border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px; user-select: all; } `; +/** CSS rules for the confirmation overlay, added only when writes is true. */ +const CONFIRM_CSS = ` +.confirm-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; } +.confirm-box { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; max-width: 500px; } +.confirm-box p { margin: 0 0 12px; } +.confirm-box button { margin-right: 8px; } +`; + +/** Builds the page's stylesheet. When `writes` is false, the result is the read-only page; when true, it adds confirmation box styles. */ +export function pageCss(writes: boolean): string { + return writes ? PAGE_CSS + CONFIRM_CSS : PAGE_CSS; +} + /** * How the page lists each file state — a `Record` over every `FileState`, so a new state does not compile until it is placed here, * as `FILE_STATE_ORDER_MAP` does for the CLI: `order` on the page, `syncActs` (sync writes or removes it without a flag, so the page @@ -79,7 +93,99 @@ export const PAGE_STATES: Record v.syncActs).map(([k]) => [k, true])))}; var HAS_DIFF = ${JSON.stringify(Object.fromEntries(Object.entries(PAGE_STATES).map(([k, v]) => [k, v.diff])))}; var app = document.getElementById("app"); - var table = null; + var table = null;${W_WRITES_VAR} var fromHash = location.hash.slice(1); var token = fromHash; @@ -168,7 +274,7 @@ export const PAGE_JS = String.raw`"use strict"; function () { return { status: res.status, body: null }; } ); }); - } + }${W_POST_FN}${W_CONFIRM_FN}${W_RESULT_FN} /* What the page says for an answer that is not 200. asked: the file's path when the request named one. */ function problem(into, answer, asked) { @@ -211,7 +317,7 @@ export const PAGE_JS = String.raw`"use strict"; add(app, head, add(el("div", "bar"), button("Refresh", showWorkspaces)), body); if (!token) return add(clear(body), note(NO_TOKEN, "bad")); load("/api/workspaces", body, undefined, function (doc) { - table = doc; + table = doc;${W_STORE_WRITES} add(head, el("p", "dim", "craftar " + doc.version + " · registry " + doc.registry)); if (!doc.workspaces.length) { add(body, note("no workspace is registered on this machine yet — run craftar sync in one")); @@ -261,7 +367,7 @@ export const PAGE_JS = String.raw`"use strict"; if (kind === "remote") add(into, el("p", "dim", "read from the cached copy of the Forge — not fetched")); } - function showWorkspace(w, tab) { + function showWorkspace(w, tab${W_SHOWWORKSPACE_SAID_PARAM}) { var body = frame(w, tab); if (!token) return add(body, note(NO_TOKEN, "bad")); if (tab === "forge") return showForge(w, body); @@ -275,7 +381,7 @@ export const PAGE_JS = String.raw`"use strict"; ["forge", forgeLine(doc.forge)], ["lock", doc.forge.lockCommit ? "generated from " + short(doc.forge.lockCommit) : "records no commit"] ].forEach(function (pair) { add(facts, el("dt", "", pair[0]), el("dd", "", pair[1])); }); - add(body, facts); + add(body, facts);${W_SHOWWORKSPACE_DRAW_SAID} if (doc.unsetParams.length) { add(body, note(doc.refused, "bad")); add(body, list(doc.unsetParams.map(function (u) { @@ -283,7 +389,7 @@ export const PAGE_JS = String.raw`"use strict"; }))); } if (doc.warnings.length) add(body, el("h3", "", "warnings"), list(doc.warnings)); - if (!doc.statuses.length) add(body, note("the Forge produces no file for this workspace")); + if (!doc.statuses.length) add(body, note("the Forge produces no file for this workspace"));${W_SYNC_BTN} STATES.concat(doc.statuses.map(function (s) { return s.state; }).filter(function (s, i, all) { return STATES.indexOf(s) === -1 && all.indexOf(s) === i; })).forEach(function (state) { var files = doc.statuses.filter(function (s) { return s.state === state; }); if (!files.length) return; @@ -307,7 +413,7 @@ export const PAGE_JS = String.raw`"use strict"; if (HAS_DIFF[f.state] !== false) { add(bar, button("diff", function () { detail.hidden = false; - load("/api/workspaces/" + w.id + "/diff" + query, detail, f.path, function (d) { showDiff(detail, d); }); + load("/api/workspaces/" + w.id + "/diff" + query, detail, f.path, function (d) { showDiff(detail${W_SHOWDIFF_EXTRA_ARGS}, d); }); })); } if (f.state !== "orphan" && f.state !== "orphan-drift") { @@ -320,7 +426,7 @@ export const PAGE_JS = String.raw`"use strict"; return add(box, bar, detail); } - function showDiff(into, d) { + function showDiff(into${W_SHOWDIFF_EXTRA_ARGS}, d) { if (d.from) add(into, el("p", "mono dim", "--- " + d.from)); if (d.to) add(into, el("p", "mono dim", "+++ " + d.to)); if (d.note) add(into, note(d.note, d.hidden ? "warn" : "")); @@ -336,7 +442,7 @@ export const PAGE_JS = String.raw`"use strict"; add(pre, el("span", kind, line), document.createTextNode("\n")); }); add(into, pre); - if (lines.length > MAX_DIFF_LINES) add(into, el("p", "dim", "… " + (lines.length - MAX_DIFF_LINES) + " more lines")); + if (lines.length > MAX_DIFF_LINES) add(into, el("p", "dim", "… " + (lines.length - MAX_DIFF_LINES) + " more lines"));${W_DISCARD_IN_DIFF} } function showExplain(into, x) { @@ -396,3 +502,6 @@ export const PAGE_JS = String.raw`"use strict"; showWorkspaces(); })(); `; +} + +export const PAGE_JS = pageJs(false); diff --git a/src/ui/routes.ts b/src/ui/routes.ts index d62eb0f..c91f64b 100644 --- a/src/ui/routes.ts +++ b/src/ui/routes.ts @@ -3,9 +3,11 @@ import { renderDiff } from "../core/diff.js"; import { oneLine } from "../core/doctor.js"; import { isMissing, listWorkspaces, readRegistry, type WorkspaceRow } from "../core/registry.js"; import { credentialFault, CREDENTIAL_HIDDEN, refHoldsCredentials } from "../core/remote.js"; -import { EXAMPLE_CONTENT_HIDDEN, diffReport, explainFile, statusReport } from "../core/report.js"; +import { EXAMPLE_CONTENT_HIDDEN, diffReport, explainFile, orphanDriftBody, statusReport } from "../core/report.js"; +import { statusDigest, syncToolResult, syncWorkspace } from "../core/write-actions.js"; +import { EXAMPLE_SETTINGS } from "../emitters/shared.js"; import type { Lock } from "../schema/index.js"; -import { loadWorkspace, plan, readLock, status, unsetSummary, type FileStatus, type Plan, type Workspace } from "../core/sync.js"; +import { loadWorkspace, plan, readLock, status, unsetDeclared, unsetSummary, type FileStatus, type Plan, type Workspace } from "../core/sync.js"; import { hashNormalized } from "../core/text.js"; /** @@ -15,11 +17,20 @@ import { hashNormalized } from "../core/text.js"; * never joined into a path and never relayed in an answer. */ +export interface WriteBody { + digest: string; + path?: string; +} + export interface RouteRequest { /** The request target's path, without the query. */ path: string; /** The raw query string, without `?`; "" when there is none. */ query: string; + /** The HTTP method. */ + method: "GET" | "POST"; + /** The parsed body, for write routes. */ + body?: WriteBody; } export interface RouteAnswer { status: 200 | 400 | 404 | 409; @@ -28,12 +39,26 @@ export interface RouteAnswer { export interface RouteDeps { home: string; version: string; + /** Whether `--allow-writes` was passed. Absent or false means no writes. */ + allowWrites?: boolean; } export const NOT_FOUND = { error: "not found" }; export const PATH_REQUIRED = { error: "path is required, once" }; export const MISSING = "the directory or its craftar.yaml is gone — craftar workspaces prune"; +/** The two write route patterns (spec 40 §6.2). */ +export const WRITE_PATTERNS = ["/api/workspaces//sync", "/api/workspaces//discard"] as const; + +/** 409 for a remote Forge (spec 40 §6.4 step 2). */ +export const REMOTE_FORGE = "writes from the page need a path Forge — run craftar sync"; + +/** 409 when the digest does not match (spec 40 §6.4 step 4). */ +export const STALE_DIGEST = "the workspace changed since the page showed it — refresh"; + +/** 409 when the path is not a drift or orphan-drift file, or is the example settings file (spec 40 §6.4 step 5). */ +export const NOT_HAND_EDITED = "not a hand-edited file"; + /** A workspace's id in a URL: 16 hex characters of the hash of its registered path — the path itself never travels. */ export const workspaceId = (registeredPath: string): string => hashNormalized(registeredPath).slice("sha256:".length, "sha256:".length + 16); @@ -45,10 +70,38 @@ export function routeReason(e: unknown): string { /** The route a path names, as its pattern — what the one stderr line of a 500 prints, never the request's text. */ export function routePattern(path: string): string | null { if (path === "/api/workspaces") return path; - const m = /^\/api\/workspaces\/[0-9a-f]{16}(\/(diff|explain|recipes|ingredients))?$/.exec(path); + const m = /^\/api\/workspaces\/[0-9a-f]{16}(\/(diff|explain|recipes|ingredients|sync|discard))?$/.exec(path); return m ? `/api/workspaces/${m[1] ?? ""}` : null; } +/** + * A write route's body, checked strictly: `digest` 64 lowercase hex; `path` a string, required for discard and + * refused for sync; no other key. Null when it does not read. + */ +export function parseWriteBody(action: "sync" | "discard", text: string): WriteBody | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + const obj = parsed as Record; + + const allowed = action === "sync" ? ["digest"] : ["digest", "path"]; + if (Object.keys(obj).some((k) => !allowed.includes(k))) return null; + + if (typeof obj.digest !== "string" || !/^[0-9a-f]{64}$/.test(obj.digest)) return null; + + if (action === "sync") { + if ("path" in obj) return null; + return { digest: obj.digest }; + } else { + if (!("path" in obj) || typeof obj.path !== "string") return null; + return { digest: obj.digest, path: obj.path }; + } +} + /** The query's `path`, given exactly once and decoded once (`+` is not a space) — or null. */ function onePath(query: string): string | null { const given = query.split("&").filter((kv) => kv === "path" || kv.startsWith("path=")); @@ -76,15 +129,28 @@ export function servedRow(r: WorkspaceRow): WorkspaceRow & { id: string } { export async function route(req: RouteRequest, deps: RouteDeps): Promise { const pattern = routePattern(req.path); if (pattern === null) return { status: 404, body: NOT_FOUND }; + + const method = req.method; + const isWritePattern = (WRITE_PATTERNS as readonly string[]).includes(pattern); + + // A POST to a reading route, a GET to a write route, or any POST without allowWrites → 404 (the server refuses earlier with 405) + if (method === "POST" && !isWritePattern) return { status: 404, body: NOT_FOUND }; + if (method === "GET" && isWritePattern) return { status: 404, body: NOT_FOUND }; + if (method === "POST" && !deps.allowWrites) return { status: 404, body: NOT_FOUND }; + + if (isWritePattern && !req.body) return { status: 400, body: { error: "bad request" } }; + if (pattern === "/api/workspaces") { const t = await listWorkspaces(deps.home, { fetch: false, reason: routeReason }); const rows = t.rows.map(servedRow); return { status: 200, - body: { version: deps.version, registry: t.registry, fetch: false, workspaces: rows, warnings: t.warnings }, + body: { version: deps.version, registry: t.registry, fetch: false, writes: deps.allowWrites === true, workspaces: rows, warnings: t.warnings }, }; } + const [, , , id, what] = req.path.split("/"); + // Decided from the request alone, before the registry is read. let wanted = ""; if (what === "diff" || what === "explain") { @@ -92,17 +158,25 @@ export async function route(req: RouteRequest, deps: RouteDeps): Promise workspaceId(e.path) === id); if (!entry) return { status: 404, body: NOT_FOUND }; if (await isMissing(entry)) return { status: 409, body: { error: MISSING, warnings: [] } }; + // What cannot be loaded, planned or read is this workspace's state: 409, in one line. A failure past that point is ours: 500. let ws: Workspace | undefined; let p: Plan; let lock: Lock | null; let st: FileStatus[]; + let digest: string | undefined; try { - ws = await loadWorkspace(entry.path, { mode: "no-fetch", home: deps.home }); + // A write refuses a remote Forge as the configuration names it, before anything is fetched (spec 40 §6.4). + ws = await loadWorkspace(entry.path, { + mode: "no-fetch", + home: deps.home, + ...(isWritePattern ? { refuseRemote: () => REMOTE_FORGE } : {}), + }); if (what === "recipes" || what === "ingredients") { // The catalogue resolves and never plans; it does not read the lock either. const context = catalogueContext(ws.forge, { kind: "workspace", config: ws.config }); @@ -112,15 +186,67 @@ export async function route(req: RouteRequest, deps: RouteDeps): Promise s.path === body.path); + if (!row || (row.state !== "drift" && row.state !== "orphan-drift") || row.path === EXAMPLE_SETTINGS) { + return { status: 409, body: { error: NOT_HAND_EDITED, warnings: [] } }; + } + } + + const unset = unsetDeclared(p); + if (unset.length) { + return { status: 409, body: { error: unsetSummary(unset), warnings: [] } }; + } + + const outcome = await syncWorkspace(ws, p, st, lock, { + home: deps.home, + registryOff: false, + ...(action === "discard" && row ? { overwritePaths: new Set([row.path]) } : {}), + }); + + return { status: 200, body: syncToolResult(ws, p, st, outcome, false, action === "discard" && row ? row.path : undefined) }; + } + if (what === undefined) { const report = statusReport(ws, p, lock, st); - return { - status: 200, - body: { id, path: entry.path, profile: ws.config.profile, recipes: p.resolution.recipes, targets: p.resolution.targets, ...report, refused: report.unsetParams.length ? unsetSummary(report.unsetParams) : null }, + const base = { + id, + path: entry.path, + profile: ws.config.profile, + recipes: p.resolution.recipes, + targets: p.resolution.targets, + ...report, + refused: report.unsetParams.length ? unsetSummary(report.unsetParams) : null, }; + if (deps.allowWrites) { + return { + status: 200, + body: { ...base, digest }, + }; + } + return { status: 200, body: base }; } if (what === "explain") { // Never `notProduced`'s message: it quotes the path asked. @@ -130,6 +256,26 @@ export async function route(req: RouteRequest, deps: RouteDeps): Promise s.path === wanted); if (!row) return { status: 404, body: NOT_FOUND }; + + // For orphan-drift, under allowWrites, return the file's body as a diff (spec 40 §6.5) + if (row.state === "orphan-drift" && deps.allowWrites) { + const { body: fileBody, note } = await orphanDriftBody(ws.root, row); + const from = `${row.path} (disk, ${row.state})`; + const to = `${row.path} (forge: no longer produced — discard removes it)`; + return { + status: 200, + body: { + path: row.path, + state: row.state, + from, + to, + hidden: note !== null, + note, + diff: fileBody === null ? null : renderDiff(fileBody, ""), + }, + }; + } + const [d] = await diffReport(ws, [row], { withholdMcp: false }); if (!d) return { status: 200, body: { path: row.path, state: row.state, from: null, to: null, hidden: false, note: null, diff: null } }; const from = `${row.path} (disk, ${row.state})`; diff --git a/src/ui/server.ts b/src/ui/server.ts index 18dfc99..888c1e2 100644 --- a/src/ui/server.ts +++ b/src/ui/server.ts @@ -2,19 +2,22 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; import http from "node:http"; import type { AddressInfo } from "node:net"; import { errorText } from "../core/schema-fault.js"; -import { PAGE_CSS, PAGE_HTML, PAGE_JS } from "./page.js"; -import { route as defaultRoute, routePattern, type RouteAnswer, type RouteDeps, type RouteRequest } from "./routes.js"; +import { PAGE_CSS, PAGE_HTML, PAGE_JS, pageCss, pageJs } from "./page.js"; +import { parseWriteBody, route as defaultRoute, routePattern, WRITE_PATTERNS, type RouteAnswer, type RouteDeps, type RouteRequest } from "./routes.js"; /** - * The loopback server of `craftar ui` (spec 36 §4.5, §7) — the one file under `src/` that imports `node:http`. + * The loopback server of `craftar ui` (spec 36 §4.5, §7; spec 40 §6.3) — the one file under `src/` that imports `node:http`. * It never prints and never exits: what the terminal must see goes through `opts.onError`. * - * Every request passes the same checks, in this order, and the order is the contract (§7.2): + * Every request passes the same checks, in this order, and the order is the contract (spec 36 §7.2, spec 40 §6.3): * method → length of the target → `Host` → `Origin` and `Sec-Fetch-Site` → the route (a static one answers - * here; an unknown path is 404) → the run token → the queue of one → `route()`. + * here; an unknown path is 404; without `allowWrites` a write pattern is 404) → the run token → (for POST) + * `Content-Type` → the body → the queue of one → `route()`. */ export const MAX_TARGET_BYTES = 2048; +export const MAX_BODY_BYTES = 4096; +export const MAX_DRAIN_BYTES = 1024 * 1024; export const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; @@ -25,6 +28,8 @@ export interface UiOptions { port: number; /** One line for the terminal: `ui: : `. */ onError: (line: string) => void; + /** Whether `--allow-writes` was passed. Absent or false means read-only. */ + allowWrites?: boolean; /** Test seam; absent, 32 random bytes as base64url. */ token?: string; /** Test seam; absent, the data routes. */ @@ -47,11 +52,14 @@ export const originAllowed = (header: string | undefined, port: number): boolean header === undefined || names(port).some((n) => header === `http://${n}`); export const siteAllowed = (header: string | undefined): boolean => header === undefined || header === "same-origin" || header === "none"; -const STATIC: Record = { - "/": ["text/html; charset=utf-8", PAGE_HTML], - "/app.js": ["text/javascript; charset=utf-8", PAGE_JS], - "/app.css": ["text/css; charset=utf-8", PAGE_CSS], -}; +/** Builds the static routes: one HTML, one JS, one CSS. */ +function staticRoutes(allowWrites: boolean): Record { + return { + "/": ["text/html; charset=utf-8", PAGE_HTML], + "/app.js": ["text/javascript; charset=utf-8", allowWrites ? pageJs(true) : PAGE_JS], + "/app.css": ["text/css; charset=utf-8", allowWrites ? pageCss(true) : PAGE_CSS], + }; +} /** * A header as sent, once — read from `headersDistinct`, because Node keeps only the first `Host` or `Authorization` @@ -67,6 +75,11 @@ export function startUi(opts: UiOptions): Promise { const token = opts.token ?? randomBytes(32).toString("base64url"); const want = Buffer.from(`Bearer ${token}`, "utf8"); const run = opts.route ?? defaultRoute; + const allowWrites = opts.allowWrites === true; + const deps: RouteDeps = { home: opts.home, version: opts.version, allowWrites }; + // Built once: the script and stylesheet the page needs for this run. + const statics = staticRoutes(allowWrites); + // One data request at a time: each loads a Forge and plans a workspace. let queue: Promise = Promise.resolve(); let port = opts.port; @@ -88,41 +101,124 @@ export function startUi(opts: UiOptions): Promise { // Every refusal is the same bytes: which check refused is not said. const forbidden = (): void => json(403, { error: "forbidden" }); - if (req.method !== "GET") return json(405, { error: "GET only" }, { Allow: "GET" }); + const enqueue = (request: RouteRequest, pattern: string): void => { + // One request's failure must neither end the process nor stop the queue (see the catch at the end). + queue = queue.then(async () => { + // Work still queued when the server closed is dropped: its connection is gone, and nothing should keep loading. + if (closed) return; + try { + const answer = await run(request, deps); + json(answer.status, answer.body); + } catch (e) { + // The pattern, never the request's text; the browser learns nothing of the message. + opts.onError(`ui: ${pattern}: ${errorText(e).split("\n")[0]}`); + json(500, { error: "internal error" }); + } + }).catch(() => { + // Only the catch above can throw: a failing `onError`, or an answer that can no longer be written. Nothing + // more can be said then; the connection is closed rather than left unanswered, and the queue goes on. + res.destroy(); + }); + }; + + const method = req.method; + if (method !== "GET" && method !== "POST") { + return json(405, { error: allowWrites ? "GET or POST only" : "GET only" }, { Allow: allowWrites ? "GET, POST" : "GET" }); + } + if (method === "POST" && !allowWrites) { + return json(405, { error: "GET only" }, { Allow: "GET" }); + } + const target = req.url ?? "/"; if (Buffer.byteLength(target, "utf8") > MAX_TARGET_BYTES) return json(414, { error: "request too long" }); + if (!hostAllowed(one(req, "host"), port)) return forbidden(); - if (!originAllowed(one(req, "origin"), port) || !siteAllowed(one(req, "sec-fetch-site"))) return forbidden(); + + // A POST needs Origin present and exact; a GET may omit it (a navigation or a same-origin fetch sends none). + const origin = one(req, "origin"); + const site = one(req, "sec-fetch-site"); + if (method === "GET") { + if (!originAllowed(origin, port) || !siteAllowed(site)) return forbidden(); + } else { + if (origin === undefined || !names(port).some((n) => origin === `http://${n}`)) return forbidden(); + if (site !== "same-origin") return forbidden(); + } // The path is split from the query by hand: no URL is built from the request. const q = target.indexOf("?"); const path = q === -1 ? target : target.slice(0, q); - const page = Object.hasOwn(STATIC, path) ? STATIC[path] : undefined; - if (page) return send(200, page[0], page[1], path === "/" ? { "Content-Security-Policy": CSP } : {}); + + const page = Object.hasOwn(statics, path) ? statics[path] : undefined; + if (page) { + if (method === "POST") return json(405, { error: "GET only" }, { Allow: "GET" }); + return send(200, page[0], page[1], path === "/" ? { "Content-Security-Policy": CSP } : {}); + } + const pattern = routePattern(path); - if (pattern === null) return json(404, { error: "not found" }); + const isWritePattern = pattern !== null && (WRITE_PATTERNS as readonly string[]).includes(pattern); + if (pattern === null || (!allowWrites && isWritePattern)) return json(404, { error: "not found" }); + + if (method === "POST" && !isWritePattern) return json(405, { error: "GET only" }, { Allow: "GET" }); + if (method === "GET" && isWritePattern) return json(405, { error: "POST only" }, { Allow: "POST" }); // The token: in the Authorization header and nowhere else, compared in constant time. const got = Buffer.from(one(req, "authorization") ?? "", "utf8"); if (got.length !== want.length || !timingSafeEqual(got, want)) return forbidden(); - // One request's failure must neither end the process nor stop the queue (see the catch at the end). - queue = queue.then(async () => { - // Work still queued when the server closed is dropped: its connection is gone, and nothing should keep loading. - if (closed) return; - try { - const answer = await run({ path, query: q === -1 ? "" : target.slice(q + 1) }, { home: opts.home, version: opts.version }); - json(answer.status, answer.body); - } catch (e) { - // The pattern, never the request's text; the browser learns nothing of the message. - opts.onError(`ui: ${pattern}: ${errorText(e).split("\n")[0]}`); - json(500, { error: "internal error" }); + if (method === "POST") { + const contentType = (one(req, "content-type") ?? "").split(";")[0].trim().toLowerCase(); + if (contentType !== "application/json") { + return json(415, { error: "Content-Type must be application/json" }); } - }).catch(() => { - // Only the catch above can throw: a failing `onError`, or an answer that can no longer be written. Nothing - // more can be said then; the connection is closed rather than left unanswered, and the queue goes on. - res.destroy(); - }); + + // The body is counted as it arrives; nothing is kept past 4096 bytes. It drains to 'end' (up to 1 MiB) so the 413 reaches the client before the connection closes. + const action = pattern === "/api/workspaces//sync" ? "sync" : "discard"; + const chunks: Buffer[] = []; + let bytes = 0; + let drainBytes = 0; + let bodyRejected = false; + let drainCapExceeded = false; + + req.on("data", (chunk: Buffer) => { + bytes += chunk.length; + if (!bodyRejected && bytes > MAX_BODY_BYTES) { + bodyRejected = true; + drainBytes = bytes - MAX_BODY_BYTES; + } else if (bodyRejected) { + drainBytes += chunk.length; + } + if (bodyRejected) { + if (drainBytes > MAX_DRAIN_BYTES && !drainCapExceeded) { + drainCapExceeded = true; + json(413, { error: "body too large" }, { Connection: "close" }); + res.once("finish", () => req.destroy()); + } + return; + } + chunks.push(chunk); + }); + + req.on("end", () => { + if (drainCapExceeded || closed) return; + if (bodyRejected) { + json(413, { error: "body too large" }, { Connection: "close" }); + return; + } + const text = Buffer.concat(chunks).toString("utf8"); + const body = parseWriteBody(action, text); + if (body === null) { + json(400, { error: "bad request" }); + return; + } + enqueue({ path, query: q === -1 ? "" : target.slice(q + 1), method: "POST", body }, pattern); + }); + + // A client that goes away mid-body: nothing to answer, and no unhandled error. + req.on("error", () => {}); + return; + } + + enqueue({ path, query: q === -1 ? "" : target.slice(q + 1), method: "GET" }, pattern); }); server.headersTimeout = 10_000; server.requestTimeout = 30_000; diff --git a/test/helpers/ui.ts b/test/helpers/ui.ts index 10a97ec..4b798b6 100644 --- a/test/helpers/ui.ts +++ b/test/helpers/ui.ts @@ -1,6 +1,6 @@ import { apply, loadWorkspace, plan, readLock, status } from "../../src/core/sync.js"; import { register } from "../../src/core/registry.js"; -import { route, workspaceId, type RouteAnswer } from "../../src/ui/routes.js"; +import { route, workspaceId, type RouteAnswer, type WriteBody } from "../../src/ui/routes.js"; export const HOME = () => process.env.CRAFTAR_HOME!; @@ -14,7 +14,18 @@ export async function syncRegistered(wsRoot: string): Promise<{ id: string; path } /** `GET ` through route(), with no socket: `target` is a path with an optional `?query`. */ -export function get(target: string): Promise { +export function get(target: string, opts?: { allowWrites?: boolean }): Promise { const q = target.indexOf("?"); - return route({ path: q === -1 ? target : target.slice(0, q), query: q === -1 ? "" : target.slice(q + 1) }, { home: HOME(), version: "0.0.0-test" }); + return route( + { path: q === -1 ? target : target.slice(0, q), query: q === -1 ? "" : target.slice(q + 1), method: "GET" }, + { home: HOME(), version: "0.0.0-test", allowWrites: opts?.allowWrites }, + ); +} + +/** `POST ` through route() with allowWrites=true: `target` is a path, `body` is the parsed WriteBody. */ +export function post(target: string, body: WriteBody): Promise { + return route( + { path: target, query: "", method: "POST", body }, + { home: HOME(), version: "0.0.0-test", allowWrites: true }, + ); } diff --git a/test/mcp-consent-cli.test.ts b/test/mcp-consent-cli.test.ts index 3efd17b..fa5a81e 100644 --- a/test/mcp-consent-cli.test.ts +++ b/test/mcp-consent-cli.test.ts @@ -289,4 +289,56 @@ describe("craftar mcp allow-writes / revoke-writes", () => { const reg = await readRegistry(home); expect(reg.writeConsent).toBeUndefined(); }); + + // Case: allow-writes with registry that is not valid JSON: code 1, stderr names the file, nothing quoted, registry unchanged + it("allow-writes --workspace with registry not valid JSON: code 1, file named, registry unchanged", async () => { + const home = await tmpDir("home-"); + cleanups.push(() => fs.rm(home, { recursive: true, force: true })); + const s = kept( + await scenario( + { ingredients: [rule("keep", "# Keep\n")], recipes: [recipe("base", ["rule/keep"])], profiles: [profile("acme", ["base"])] }, + { config: { profile: "acme" } }, + ), + ); + + // Write a malformed registry + const regPath = path.join(home, "registry.json"); + const badContent = "{"; + await fs.writeFile(regPath, badContent); + + const r = runCli(["mcp", "allow-writes", "--workspace", s.wsRoot], { env: { CRAFTAR_HOME: home } }); + expect(r.code).toBe(1); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe(`error: cannot read the registry: cannot read ${regPath}: not valid JSON\n`); + // stderr does not mention "terminal" + expect(r.stderr).not.toContain("terminal"); + + // Registry file unchanged + expect(await fs.readFile(regPath, "utf8")).toBe(badContent); + }); + + // Case: revoke-writes with registry that is not valid JSON: code 1, stderr names the file, nothing quoted, registry unchanged + it("revoke-writes --workspace with registry not valid JSON: code 1, file named, registry unchanged", async () => { + const home = await tmpDir("home-"); + cleanups.push(() => fs.rm(home, { recursive: true, force: true })); + const s = kept( + await scenario( + { ingredients: [rule("keep", "# Keep\n")], recipes: [recipe("base", ["rule/keep"])], profiles: [profile("acme", ["base"])] }, + { config: { profile: "acme" } }, + ), + ); + + // Write a malformed registry + const regPath = path.join(home, "registry.json"); + const badContent = "{"; + await fs.writeFile(regPath, badContent); + + const r = runCli(["mcp", "revoke-writes", "--workspace", s.wsRoot], { env: { CRAFTAR_HOME: home } }); + expect(r.code).toBe(1); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe(`error: consent not removed: cannot read ${regPath}: not valid JSON\n`); + + // Registry file unchanged + expect(await fs.readFile(regPath, "utf8")).toBe(badContent); + }); }); diff --git a/test/mcp-writes.test.ts b/test/mcp-writes.test.ts index 9274e41..e02e8cf 100644 --- a/test/mcp-writes.test.ts +++ b/test/mcp-writes.test.ts @@ -707,4 +707,30 @@ describe("craftar mcp --allow-writes — the writing tools", () => { const after = await tree(s.wsRoot); expect(after).toEqual(before); }); + + // Test 18: openWorldHint on writing tools: false without --fetch, true with --fetch + it("18. openWorldHint on writing tools: false without --fetch, true with --fetch", async () => { + const home = await freshHome(); + const s = kept( + await scenario( + { ingredients: [rule("keep", "# Keep\n")], recipes: [recipe("base", ["rule/keep"])], profiles: [profile("acme", ["base"])] }, + { config: { profile: "acme" } }, + ), + ); + const writers = mcpWriters(home, false); + + // Without --fetch: openWorldHint = false + const { request: reqNoFetch } = await fresh(s.wsRoot, { home, writers, fetch: false }); + const listNoFetch = await reqNoFetch("tools/list"); + for (const t of listNoFetch.result.tools.filter((t: any) => WRITE_TOOL_NAMES.includes(t.name))) { + expect(t.annotations.openWorldHint).toBe(false); + } + + // With --fetch: openWorldHint = true + const { request: reqFetch } = await fresh(s.wsRoot, { home, writers, fetch: true }); + const listFetch = await reqFetch("tools/list"); + for (const t of listFetch.result.tools.filter((t: any) => WRITE_TOOL_NAMES.includes(t.name))) { + expect(t.annotations.openWorldHint).toBe(true); + } + }); }); diff --git a/test/report.test.ts b/test/report.test.ts index 514b987..d55a88f 100644 --- a/test/report.test.ts +++ b/test/report.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { MCP_FILES, eightStates, explained, mcpWorkspace, moveTracker, syncOnce, type Scenario } from "./helpers/states.js"; import { withLoadWarnings } from "../src/core/catalogue.js"; import { readWorkspaceConfig, loadWorkspace, plan, readLock, status, type FileStatus } from "../src/core/sync.js"; -import { EXAMPLE_CONTENT_HIDDEN, MCP_CONTENT_HIDDEN, cachedCopyLine, diffReport, explainFile, explainSkip, notProduced, statusPath, statusReport, targetsReport } from "../src/core/report.js"; +import { EXAMPLE_CONTENT_HIDDEN, MCP_CONTENT_HIDDEN, cachedCopyLine, diffReport, explainFile, explainSkip, notProduced, orphanDriftBody, statusPath, statusReport, targetsReport } from "../src/core/report.js"; // Spec 35 §5.1 (and spec 36 §17): the builders the commands, the MCP server and the dashboard share — tested directly. @@ -181,3 +181,34 @@ describe("cachedCopyLine", () => { expect(cachedCopyLine(ws({ ref: "0123456789abcdef0123456789abcdef01234567" }), false)).toBeNull(); }); }); + +describe("orphanDriftBody", () => { + it("returns the body text for a plain orphan-drift file", async () => { + const s = kept(await eightStates()); + const { st } = await loaded(s); + const orphanDrift = st.find((x) => x.state === "orphan-drift")!; + const result = await orphanDriftBody(s.wsRoot, orphanDrift); + expect(result).toEqual({ body: "# Gone edited\nby hand\n", note: null }); + }); + + it("returns note for .claude/settings.craftar.example.json", async () => { + const s = kept(await mcpWorkspace()); + await syncOnce(s.wsRoot); + // Remove the mcp ingredient from the recipe so example file becomes orphan + await fs.writeFile(path.join(s.forgeRoot, "recipes/base.yaml"), "name: base\ningredients: [rule/keep]\n"); + // Mark the example file as hand-edited + const ex = ".claude/settings.craftar.example.json"; + await fs.writeFile(path.join(s.wsRoot, ex), '{\n "env": {\n "ACME_TRACKER_TOKEN": "secret"\n }\n}\n'); + const { st } = await loaded(s); + const row = st.find((x) => x.path === ex && x.state === "orphan-drift")!; + const result = await orphanDriftBody(s.wsRoot, row); + expect(result).toEqual({ body: null, note: EXAMPLE_CONTENT_HIDDEN }); + }); + + it("throws when state is not orphan-drift", async () => { + const s = kept(await eightStates()); + const { st } = await loaded(s); + const drift = st.find((x) => x.state === "drift")!; + await expect(orphanDriftBody(s.wsRoot, drift)).rejects.toThrow("state must be orphan-drift"); + }); +}); \ No newline at end of file diff --git a/test/ui-cli.test.ts b/test/ui-cli.test.ts index 3a66306..1fda76a 100644 --- a/test/ui-cli.test.ts +++ b/test/ui-cli.test.ts @@ -139,7 +139,7 @@ describe("craftar ui — started (spec 36 §4.3, §4.6)", () => { const [, port, token] = OPEN.exec(lines[1])!; const data = await request(Number(port), "/api/workspaces", { token }); expect(data.status).toBe(200); - expect(JSON.parse(data.body)).toEqual({ version: VERSION, registry: registryFile(HOME()), fetch: false, workspaces: [], warnings: [] }); + expect(JSON.parse(data.body)).toEqual({ version: VERSION, registry: registryFile(HOME()), fetch: false, writes: false, workspaces: [], warnings: [] }); expect((await request(Number(port), "/api/workspaces")).status).toBe(403); const page = await request(Number(port), "/"); expect([page.status, page.type]).toEqual([200, "text/html; charset=utf-8"]); @@ -185,12 +185,26 @@ describe("craftar ui — started (spec 36 §4.3, §4.6)", () => { expect(ui.stderr()).toBe(""); }); - it("10. --help: the port, the description, and none of the options the spec refuses", () => { + it("10. --help: the port, the description, --allow-writes, and none of the options the spec refuses", () => { const help = runCli(["ui", "--help"]).stdout; expect(help).toContain("--port "); + expect(help).toContain("--allow-writes"); expect(help.replace(/\s+/g, " ")).toContain( - "A local dashboard: the table of `craftar workspaces` and, for one workspace, its file states, a file's diff and explanation, and its Forge's recipes and ingredients — in a browser, on 127.0.0.1 only, behind an address that works for this run. Reads only — writes no workspace, lock, registry or Forge — and reaches no network; Ctrl-C stops it", + "Reads only unless --allow-writes — then a sync or the discard of one hand-edited file, confirmed in the page, never --overwrite-drift — and reaches no network", ); for (const refused of ["--host", "--fetch", "--open", "--json", "--workspace"]) expect(help, refused).not.toContain(refused); }); + + it("11. --allow-writes: four lines, writes: true in /api/workspaces", async () => { + const ui = startUiCli(["--port", "0", "--allow-writes"]); + const lines = await ui.firstLines(4); + expect(lines[0]).toBe(`craftar ui — craftar ${VERSION} · writes allowed · 0 registered`); + expect(lines[1]).toBe(" writes allowed: sync, discard one hand-edited file"); + expect(lines[2]).toMatch(OPEN); + expect(lines[3]).toBe(HINT); + const [, port, token] = OPEN.exec(lines[2])!; + const data = await request(Number(port), "/api/workspaces", { token }); + expect(data.status).toBe(200); + expect(JSON.parse(data.body).writes).toBe(true); + }); }); diff --git a/test/ui-page.test.ts b/test/ui-page.test.ts index 65b5fc0..67439c8 100644 --- a/test/ui-page.test.ts +++ b/test/ui-page.test.ts @@ -1,14 +1,21 @@ import { describe, expect, it } from "vitest"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { FILE_STATE_ORDER_MAP } from "../src/core/impact.js"; import { PAGE_STATES } from "../src/ui/page.js"; -import { PAGE_CSS, PAGE_HTML, PAGE_JS } from "../src/ui/page.js"; +import { PAGE_CSS, PAGE_HTML, PAGE_JS, pageCss, pageJs } from "../src/ui/page.js"; +import { EXAMPLE_SETTINGS } from "../src/emitters/shared.js"; // Spec 36 §7.4, §10.3 — a source check of the page. The CSP is what stops inline script even if this is fooled; // what the page DOES with an answer is the by-hand run of spec §10.5 (step 36f). +// Banned APIs for both pageJs(false) and pageJs(true) — spec 40 §6.5 rules: no confirm(, alert(, prompt( +const BANNED_APIS = ["innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "eval(", "new Function", "setAttribute(\"on", "srcdoc", "javascript:", "confirm(", "alert(", "prompt("]; + describe("craftar ui — the page's source", () => { it("the script never builds markup from a string, and never evaluates one", () => { - for (const banned of ["innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "eval(", "new Function", "setAttribute(\"on", "srcdoc", "javascript:"]) + for (const banned of BANNED_APIS) expect(PAGE_JS, banned).not.toContain(banned); expect(PAGE_JS).toContain("textContent"); }); @@ -84,4 +91,534 @@ describe("craftar ui — the page's source", () => { expect(PAGE_JS).not.toContain("sync is refused"); expect(PAGE_JS).toContain("doc.refused"); }); + + it("the source of page.ts uses EXAMPLE_SETTINGS from shared.ts, not a second spelling of the path", async () => { + // Read the source file to ensure there is no literal spelling of the example path + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const source = await fs.readFile(path.join(__dirname, "..", "src", "ui", "page.ts"), "utf8"); + // The literal path should not appear in the source + expect(source).not.toContain("settings.craftar.example.json"); + // And pageJs(true) should contain the path (interpolated from EXAMPLE_SETTINGS) + expect(pageJs(true)).toContain(EXAMPLE_SETTINGS); + }); +}); + +// Spec 40 §6.5 — pageJs(writes) and pageCss(writes) tests +describe("pageJs and pageCss (spec 40 §6.5)", () => { + it("pageJs(false) === PAGE_JS and pageCss(false) === PAGE_CSS", () => { + expect(pageJs(false)).toBe(PAGE_JS); + expect(pageCss(false)).toBe(PAGE_CSS); + }); + + it("pageJs(true) has no banned APIs (including confirm(, alert(, prompt()", () => { + const jsTrue = pageJs(true); + for (const banned of BANNED_APIS) { + expect(jsTrue, banned).not.toContain(banned); + } + expect(jsTrue).toContain("textContent"); + }); + + it("pageJs(false) contains none of POST, /sync, /discard, Discard edit; pageJs(true) contains each", () => { + const jsFalse = pageJs(false); + const jsTrue = pageJs(true); + for (const term of ['"POST"', '"/sync"', '"/discard"', '"Discard edit"']) { + expect(jsFalse, `pageJs(false) should not contain ${term}`).not.toContain(term); + expect(jsTrue, `pageJs(true) should contain ${term}`).toContain(term); + } + }); + + it('"Discard edit" in pageJs(true) appears only inside showDiff, not in fileRow bar-building code', () => { + const jsTrue = pageJs(true); + + // Find the showDiff function's body + const showDiffMatch = jsTrue.match(/function showDiff\([^)]*\)\s*\{/); + expect(showDiffMatch, "showDiff function not found").not.toBeNull(); + const showDiffStart = showDiffMatch!.index!; + + // Find the fileRow function's body + const fileRowMatch = jsTrue.match(/function fileRow\([^)]*\)\s*\{/); + expect(fileRowMatch, "fileRow function not found").not.toBeNull(); + const fileRowStart = fileRowMatch!.index!; + + // The showDiff function comes after fileRow + expect(showDiffStart).toBeGreaterThan(fileRowStart); + + // Find where "Discard edit" appears + const discardIndex = jsTrue.indexOf('"Discard edit"'); + expect(discardIndex, '"Discard edit" not found in pageJs(true)').toBeGreaterThan(-1); + + // "Discard edit" should be after showDiff starts + expect(discardIndex, '"Discard edit" should be inside showDiff function').toBeGreaterThan(showDiffStart); + + // Verify it's not in the bar-building code of fileRow (which is before showDiff) + const fileRowToShowDiff = jsTrue.slice(fileRowStart, showDiffStart); + expect(fileRowToShowDiff, '"Discard edit" should not appear in fileRow bar-building code').not.toContain('"Discard edit"'); + }); + + it("pageJs(true) mentions the example settings file exclusion", () => { + const jsTrue = pageJs(true); + expect(jsTrue).toContain(".claude/settings.craftar.example.json"); + }); + + it("pageCss(true) includes confirmation box styles", () => { + const cssTrue = pageCss(true); + expect(cssTrue).toContain(".confirm-overlay"); + expect(cssTrue).toContain(".confirm-box"); + }); +}); + +// A fake DOM environment for running pageJs in a test. +interface FakeNode { + nodeType: 1; + tagName: string; + className: string; + textContent: string; + hidden: boolean; + type?: string; + parentNode: FakeNode | null; + children: FakeNode[]; + listeners: Map void>>; + appendChild(child: FakeNode): void; + removeChild(child: FakeNode): void; + addEventListener(event: string, handler: () => void): void; + setAttribute(name: string, value: string): void; + firstChild: FakeNode | null; +} + +function fakeDom() { + function createNode(tagName: string): FakeNode { + const node: FakeNode = { + nodeType: 1, + tagName: tagName.toUpperCase(), + className: "", + textContent: "", + hidden: false, + parentNode: null, + children: [], + listeners: new Map(), + appendChild(child: FakeNode) { + child.parentNode = this; + this.children.push(child); + }, + removeChild(child: FakeNode) { + const idx = this.children.indexOf(child); + if (idx !== -1) this.children.splice(idx, 1); + child.parentNode = null; + }, + addEventListener(event: string, handler: () => void) { + if (!this.listeners.has(event)) this.listeners.set(event, []); + this.listeners.get(event)!.push(handler); + }, + setAttribute(name: string, value: string) { + if (name === "type") (this as any).type = value; + }, + get firstChild(): FakeNode | null { + return this.children[0] ?? null; + }, + }; + return node; + } + + const app = createNode("main"); + const body = createNode("body"); + + const doc = { + createElement: (tag: string) => createNode(tag), + createTextNode: (text: string) => { + const n = createNode("#text"); + n.textContent = text; + return n; + }, + getElementById: (id: string) => (id === "app" ? app : null), + body, + }; + + const storage: Record = {}; + const sessionStorage = { + getItem: (key: string) => storage[key] ?? null, + setItem: (key: string, value: string) => { storage[key] = value; }, + }; + + const location = { hash: "#tok", pathname: "/" }; + const history = { replaceState: () => {} }; + + const posts: Array<{ url: string; body: unknown }> = []; + type FetchRoutes = Record; + + function makeFetch(routes: FetchRoutes, debug = false) { + return (url: string, opts?: RequestInit) => { + if (debug) console.log("FETCH:", url, opts?.method || "GET"); + if (opts?.method === "POST") { + posts.push({ url, body: JSON.parse(opts.body as string) }); + const postRoutes = routes as Record; + // Find POST route (store URLs ending with /sync or /discard) + for (const [pattern, response] of Object.entries(postRoutes)) { + if (url.endsWith(pattern.split("*").pop()!)) { + if (debug) console.log(" -> matched POST", pattern); + return Promise.resolve({ + status: 200, + json: () => Promise.resolve(response), + }); + } + } + } + // GET request + for (const [pattern, response] of Object.entries(routes)) { + if (url === pattern || url.startsWith(pattern + "?")) { + if (debug) console.log(" -> matched GET", pattern); + return Promise.resolve({ + status: 200, + json: () => Promise.resolve(response), + }); + } + } + if (debug) console.log(" -> 404"); + return Promise.resolve({ status: 404, json: () => Promise.resolve({ error: "not found" }) }); + }; + } + + // Find button by text content in a node tree + function findButton(root: FakeNode, text: string): FakeNode | null { + if (root.tagName === "BUTTON" && root.textContent === text) return root; + for (const child of root.children) { + const found = findButton(child, text); + if (found) return found; + } + return null; + } + + // Find a diff button that's in a bar containing a specific path + function findDiffButtonForPath(root: FakeNode, path: string): FakeNode | null { + // Find nodes containing the path + function findRowWithPath(node: FakeNode): FakeNode | null { + // A row has className "bar" or contains the path as text in a span + for (const child of node.children) { + if (child.textContent === path) { + // Found the path span, return parent's parent (the box) + return node; + } + const found = findRowWithPath(child); + if (found) return found; + } + return null; + } + const row = findRowWithPath(root); + if (!row) return null; + return findButton(row, "diff"); + } + + // Find any node containing specific text + function findNodeWithText(root: FakeNode, text: string): FakeNode | null { + if (root.textContent.includes(text)) return root; + for (const child of root.children) { + const found = findNodeWithText(child, text); + if (found) return found; + } + return null; + } + + function click(node: FakeNode) { + const handlers = node.listeners.get("click") ?? []; + for (const h of handlers) h(); + } + + return { doc, body, app, location, history, sessionStorage, posts, makeFetch, findButton, findDiffButtonForPath, findNodeWithText, click }; +} + +// Let pending promises run +const tick = () => new Promise((r) => setTimeout(r, 0)); + +describe("pageJs(true) driven in a stubbed DOM (spec 40 §6.5)", () => { + const WS_ID = "0123456789abcdef"; + const DIGEST = "a".repeat(64); + const DRIFT_PATH = ".claude/rules/edited.md"; + + function makeRoutes(writes: boolean) { + return { + "/api/workspaces": { + version: "0.0.0-test", + registry: "/test/registry.json", + fetch: false, + writes, + workspaces: [{ + id: WS_ID, + name: "ws", + path: "/test/ws", + profile: "acme", + recipes: ["base"], + stack: {}, + targets: ["claude-code"], + forge: { kind: "path", source: "../forge", ref: null, commit: null, key: "/test/forge" }, + lastSync: "2025-01-01T00:00:00Z", + forgeMoved: null, + status: "drift", + }], + warnings: [], + }, + [`/api/workspaces/${WS_ID}`]: { + id: WS_ID, + path: "/test/ws", + profile: "acme", + recipes: ["base"], + targets: ["claude-code"], + forge: { kind: "path", source: "../forge", ref: null, commit: null }, + statuses: [ + { path: DRIFT_PATH, state: "drift", target: "claude-code", ingredient: "rule/edited" }, + { path: ".claude/rules/fresh.md", state: "new", target: "claude-code", ingredient: "rule/fresh" }, + ], + warnings: [], + unsetParams: [], + refused: null, + ...(writes ? { digest: DIGEST } : {}), + }, + [`/api/workspaces/${WS_ID}/diff`]: { + path: DRIFT_PATH, + state: "drift", + from: `${DRIFT_PATH} (disk, drift)`, + to: `${DRIFT_PATH} (forge)`, + hidden: false, + note: null, + diff: "- a\n+ b", + }, + // POST routes + [`/api/workspaces/${WS_ID}/discard`]: { + written: [DRIFT_PATH], + removed: [], + skipped: [], + registry: "updated", + }, + [`/api/workspaces/${WS_ID}/sync`]: { + profile: "acme", + recipes: ["base"], + targets: ["claude-code"], + written: [".claude/rules/fresh.md"], + removed: [], + skipped: [{ path: DRIFT_PATH, reason: "drift" }], + registry: "updated", + }, + }; + } + + it("Discard edit: clicking Discard edit then Confirm posts to /discard with correct body", async () => { + const { doc, body, app, location, history, sessionStorage, posts, makeFetch, findButton, findDiffButtonForPath, click } = fakeDom(); + const routes = makeRoutes(true); + const fetch = makeFetch(routes); + + // Run the script + const src = pageJs(true); + new Function("document", "location", "history", "sessionStorage", "fetch", src)(doc, location, history, sessionStorage, fetch); + + // Wait for initial load + await tick(); await tick(); await tick(); + + // Click the workspace link (button with text "ws") + const wsLink = findButton(app, "ws"); + expect(wsLink, "workspace link should exist").not.toBeNull(); + click(wsLink!); + + await tick(); await tick(); await tick(); + + // Click the diff button for the DRIFT file (not just any diff button) + const diffBtn = findDiffButtonForPath(app, DRIFT_PATH); + expect(diffBtn, "diff button for drift file should exist").not.toBeNull(); + click(diffBtn!); + + await tick(); await tick(); await tick(); + + // Click Discard edit (it's added to the detail area inside app) + const discardBtn = findButton(app, "Discard edit"); + expect(discardBtn, "Discard edit button should exist").not.toBeNull(); + click(discardBtn!); + + // Click Confirm in the overlay (added to document.body) + const confirmBtn = findButton(body, "Confirm"); + expect(confirmBtn, "Confirm button should exist").not.toBeNull(); + click(confirmBtn!); + + await tick(); await tick(); await tick(); + + // Check POST was made + const discardPost = posts.find((p) => p.url.includes("/discard")); + expect(discardPost, "should POST to /discard").not.toBeUndefined(); + expect(discardPost!.url).toBe(`/api/workspaces/${WS_ID}/discard`); + expect(discardPost!.body).toEqual({ digest: DIGEST, path: DRIFT_PATH }); + }); + + it("Sync: clicking Sync then Confirm posts to /sync with correct body", async () => { + const { doc, body, app, location, history, sessionStorage, posts, makeFetch, findButton, click } = fakeDom(); + const routes = makeRoutes(true); + const fetch = makeFetch(routes); + + const src = pageJs(true); + new Function("document", "location", "history", "sessionStorage", "fetch", src)(doc, location, history, sessionStorage, fetch); + + await tick(); await tick(); await tick(); + + // Click workspace link + const wsLink = findButton(app, "ws"); + click(wsLink!); + + await tick(); await tick(); await tick(); + + // Click Sync (it's added inside the workspace view inside app) + const syncBtn = findButton(app, "Sync"); + expect(syncBtn, "Sync button should exist").not.toBeNull(); + click(syncBtn!); + + // Click Confirm (in the overlay on document.body) + const confirmBtn = findButton(body, "Confirm"); + click(confirmBtn!); + + await tick(); await tick(); await tick(); + + // Check POST was made + const syncPost = posts.find((p) => p.url.includes("/sync")); + expect(syncPost, "should POST to /sync").not.toBeUndefined(); + expect(syncPost!.url).toBe(`/api/workspaces/${WS_ID}/sync`); + expect(syncPost!.body).toEqual({ digest: DIGEST }); + }); + + it("writes:false — no Sync or Discard edit buttons", async () => { + const { doc, body, app, location, history, sessionStorage, makeFetch, findButton, click } = fakeDom(); + const routes = makeRoutes(false); // writes: false + const fetch = makeFetch(routes); + + // Use pageJs(true) but with writes:false from the API + const src = pageJs(true); + new Function("document", "location", "history", "sessionStorage", "fetch", src)(doc, location, history, sessionStorage, fetch); + + await tick(); await tick(); await tick(); + + // Click workspace link + const wsLink = findButton(app, "ws"); + click(wsLink!); + + await tick(); await tick(); await tick(); + + // Sync button should not exist (no digest in the response) + const syncBtn = findButton(app, "Sync"); + expect(syncBtn, "Sync button should not exist without digest").toBeNull(); + + // Click diff to open detail + const diffBtn = findButton(app, "diff"); + click(diffBtn!); + + await tick(); await tick(); await tick(); + + // Discard edit should not exist (no digest) + const discardBtn = findButton(app, "Discard edit"); + expect(discardBtn, "Discard edit button should not exist without digest").toBeNull(); + }); + + it("result message survives reload after discard", async () => { + const { doc, body, app, location, history, sessionStorage, makeFetch, findButton, findDiffButtonForPath, findNodeWithText, click } = fakeDom(); + const routes = makeRoutes(true); + const fetch = makeFetch(routes); + + const src = pageJs(true); + new Function("document", "location", "history", "sessionStorage", "fetch", src)(doc, location, history, sessionStorage, fetch); + + await tick(); await tick(); await tick(); + + // Click workspace link + const wsLink = findButton(app, "ws"); + click(wsLink!); + + await tick(); await tick(); await tick(); + + // Click diff for the drift file + const diffBtn = findDiffButtonForPath(app, DRIFT_PATH); + expect(diffBtn, "diff button for drift file should exist").not.toBeNull(); + click(diffBtn!); + + await tick(); await tick(); await tick(); + + // Click Discard edit + const discardBtn = findButton(app, "Discard edit"); + expect(discardBtn, "Discard edit button should exist").not.toBeNull(); + click(discardBtn!); + + // Click Confirm + const confirmBtn = findButton(body, "Confirm"); + expect(confirmBtn, "Confirm button should exist").not.toBeNull(); + click(confirmBtn!); + + await tick(); await tick(); await tick(); await tick(); + + // After the reload, the result message should be visible + const resultNode = findNodeWithText(app, "wrote 1, removed 0, skipped 0"); + expect(resultNode, "result message should survive the reload").not.toBeNull(); + }); + + it("remote Forge: no Discard edit button in opened drift diff", async () => { + const { doc, body, app, location, history, sessionStorage, makeFetch, findButton, findDiffButtonForPath, click } = fakeDom(); + // Create routes with a remote Forge + const baseRoutes = makeRoutes(true); + const routes = { + ...baseRoutes, + [`/api/workspaces/${WS_ID}`]: { + ...(baseRoutes[`/api/workspaces/${WS_ID}`] as Record), + forge: { kind: "remote", source: "https://example.com/forge.git", ref: null, commit: null }, + }, + }; + const fetch = makeFetch(routes); + + const src = pageJs(true); + new Function("document", "location", "history", "sessionStorage", "fetch", src)(doc, location, history, sessionStorage, fetch); + + await tick(); await tick(); await tick(); + + // Click workspace link + const wsLink = findButton(app, "ws"); + click(wsLink!); + + await tick(); await tick(); await tick(); + + // Click diff to open detail + const diffBtn = findDiffButtonForPath(app, DRIFT_PATH); + expect(diffBtn, "diff button should exist").not.toBeNull(); + click(diffBtn!); + + await tick(); await tick(); await tick(); + + // Discard edit should not exist (remote Forge) + const discardBtn = findButton(app, "Discard edit"); + expect(discardBtn, "Discard edit button should not exist for remote Forge").toBeNull(); + }); + + it("unset params: no Discard edit button in opened drift diff", async () => { + const { doc, body, app, location, history, sessionStorage, makeFetch, findButton, findDiffButtonForPath, click } = fakeDom(); + // Create routes with an unset param + const baseRoutes = makeRoutes(true); + const routes = { + ...baseRoutes, + [`/api/workspaces/${WS_ID}`]: { + ...(baseRoutes[`/api/workspaces/${WS_ID}`] as Record), + unsetParams: [{ key: "api.port", declaredBy: ["rule/api"], citedBy: ["rule/api"] }], + }, + }; + const fetch = makeFetch(routes); + + const src = pageJs(true); + new Function("document", "location", "history", "sessionStorage", "fetch", src)(doc, location, history, sessionStorage, fetch); + + await tick(); await tick(); await tick(); + + // Click workspace link + const wsLink = findButton(app, "ws"); + click(wsLink!); + + await tick(); await tick(); await tick(); + + // Click diff to open detail + const diffBtn = findDiffButtonForPath(app, DRIFT_PATH); + expect(diffBtn, "diff button should exist").not.toBeNull(); + click(diffBtn!); + + await tick(); await tick(); await tick(); + + // Discard edit should not exist (unset params) + const discardBtn = findButton(app, "Discard edit"); + expect(discardBtn, "Discard edit button should not exist with unset params").toBeNull(); + }); }); diff --git a/test/ui-readme.test.ts b/test/ui-readme.test.ts index 674a29b..9549e28 100644 --- a/test/ui-readme.test.ts +++ b/test/ui-readme.test.ts @@ -9,7 +9,7 @@ const count = (needle: string): number => README.split(needle).length - 1; describe("README — craftar ui", () => { it("one Commands row; loopback and the run token said once", () => { - expect(count("| `craftar ui [--port ]` |")).toBe(1); + expect(count("| `craftar ui [--port ] [--allow-writes]` |")).toBe(1); expect(count("it listens on `127.0.0.1`")).toBe(1); expect(count("needs a token generated for that run")).toBe(1); // Spec 37 moved Phase 1's `Next:` item to its shipped list, so the entry is no longer followed by `. Next:`. @@ -33,8 +33,17 @@ describe("README — init --clone and craftar repos", () => { expect(count("| `craftar repos clone […] [--workspace .]` |")).toBe(1); expect(count("[--clone \\| --no-clone]")).toBe(1); expect(count("**Repositories.** A profile's `repos`")).toBe(1); - expect(count("; cloning a profile's repositories (`init --clone`, `craftar repos`); a new profile from `craftar init` (`--new-profile`, `--like`); writing tools for `craftar mcp` (`--allow-writes`: `sync`, `add_recipe`, `remove_recipe`, behind a consent typed per workspace).")).toBe(1); + expect(count("; cloning a profile's repositories (`init --clone`, `craftar repos`); a new profile from `craftar init` (`--new-profile`, `--like`); writing tools for `craftar mcp` (`--allow-writes`: `sync`, `add_recipe`, `remove_recipe`, behind a consent typed per workspace); writing actions in `craftar ui` (`--allow-writes`: Sync and Discard edit, confirmed in the page).")).toBe(1); expect(count("Next: cloning")).toBe(0); expect(count("Everything else in the spec (cloning a profile's repositories at init) is not built yet.")).toBe(0); }); }); + +// Spec 40 §6: craftar ui --allow-writes +describe("README — craftar ui --allow-writes", () => { + it("the new README text for --allow-writes appears once each", () => { + expect(count("**Read-only unless `--allow-writes`**")).toBe(1); + expect(count("started with `--allow-writes`, it also syncs a workspace or discards one hand-edited file")).toBe(1); + expect(count("### to 0.37.0")).toBe(1); + }); +}); diff --git a/test/ui-routes.test.ts b/test/ui-routes.test.ts index 0681f86..fb8a297 100644 --- a/test/ui-routes.test.ts +++ b/test/ui-routes.test.ts @@ -79,7 +79,7 @@ describe("routeReason", () => { describe("/api/workspaces", () => { it("no registry yet: an empty table", async () => { - expect(await get("/api/workspaces")).toEqual({ status: 200, body: { version: "0.0.0-test", registry: registryFile(HOME()), fetch: false, workspaces: [], warnings: [] } }); + expect(await get("/api/workspaces")).toEqual({ status: 200, body: { version: "0.0.0-test", registry: registryFile(HOME()), fetch: false, writes: false, workspaces: [], warnings: [] } }); }); it("the document of `craftar workspaces --json`, with version first and an id first in every row", async () => { @@ -88,7 +88,7 @@ describe("/api/workspaces", () => { const bid = await syncRegistered(b.wsRoot); const cli = JSON.parse(runCli(["workspaces", "--json"]).stdout); const got = (await get("/api/workspaces")).body as any; - expect(Object.keys(got)).toEqual(["version", "registry", "fetch", "workspaces", "warnings"]); + expect(Object.keys(got)).toEqual(["version", "registry", "fetch", "writes", "workspaces", "warnings"]); expect(got.workspaces.map((r: any) => Object.keys(r)[0])).toEqual(["id", "id"]); expect(got.workspaces.map(({ id, ...row }: any) => row)).toEqual(cli.workspaces); expect([got.registry, got.fetch, got.warnings]).toEqual([cli.registry, false, cli.warnings]); @@ -266,6 +266,54 @@ describe("diff", () => { } } }); + + it("agents-md.primary orphan: the page's diff route keeps its words", async () => { + // Spec 41 §4.8: the page's diff route keeps its words. + const { acmePrimary } = await import("./helpers/agents-md-primary.js"); + const s = kept(await acmePrimary({ schema: 5 })); + const { apply, status, readLock } = await import("../src/core/sync.js"); + + // Sync once to create all the generated files (including .claude/rules/style.md) + const ws1 = await loadWorkspace(s.wsRoot, { home: HOME() }); + const p1 = await plan(ws1); + const lock1 = await readLock(ws1.root); + const st1 = await status(ws1, p1, lock1); + await apply(ws1, p1, st1); + + // Enable agents-md.primary in the workspace's overrides + await fs.writeFile( + path.join(s.wsRoot, "craftar.yaml"), + "forge: ../forge\nprofile: acme\noverrides:\n params:\n agents-md.primary: true\n", + ); + + // The rule/style files become orphans under agents-md.primary + const ws2 = await loadWorkspace(s.wsRoot, { home: HOME() }); + const p2 = await plan(ws2); + const id = workspaceId((await register(HOME(), ws2, p2)).path); + const lock2 = await readLock(ws2.root); + const st2 = await status(ws2, p2, lock2); + + const cRule = ".claude/rules/style.md"; + const cRuleSt = st2.find((r) => r.path === cRule); + expect(cRuleSt, "expected .claude/rules/style.md to be orphan").not.toBeUndefined(); + expect(cRuleSt!.state).toBe("orphan"); + + const kRule = ".kiro/steering/style.md"; + const kRuleSt = st2.find((r) => r.path === kRule); + expect(kRuleSt, "expected .kiro/steering/style.md to be orphan").not.toBeUndefined(); + expect(kRuleSt!.state).toBe("orphan"); + + // The diff route keeps its words (not "written to AGENTS.md only") + const answerC = await D(id, cRule); + expect(answerC.status).toBe(200); + expect((answerC.body as any).state).toBe("orphan"); + expect((answerC.body as any).to).toBe(`${cRule} (forge: no longer produced — sync removes it)`); + + const answerK = await D(id, kRule); + expect(answerK.status).toBe(200); + expect((answerK.body as any).state).toBe("orphan"); + expect((answerK.body as any).to).toBe(`${kRule} (forge: no longer produced — sync removes it)`); + }); }); describe("explain, recipes, ingredients", () => { diff --git a/test/ui-server.test.ts b/test/ui-server.test.ts index a9660b6..e83b064 100644 --- a/test/ui-server.test.ts +++ b/test/ui-server.test.ts @@ -3,7 +3,7 @@ import http from "node:http"; import net from "node:net"; import { readFileSync, readdirSync } from "node:fs"; import path from "node:path"; -import { CSP, hostAllowed, originAllowed, siteAllowed, startUi, type UiOptions, type UiServer } from "../src/ui/server.js"; +import { CSP, hostAllowed, MAX_BODY_BYTES, MAX_DRAIN_BYTES, originAllowed, siteAllowed, startUi, type UiOptions, type UiServer } from "../src/ui/server.js"; // Spec 36 §7.2, §7.4, §7.5, §10.3 — the checks every request passes, on a real loopback socket (port 0). @@ -204,7 +204,7 @@ describe("the route seam", () => { const u = await start({ route: async (req, deps) => (seen.push([req, deps.version]), { status: 409, body: { error: "x", warnings: [] } }) }); const a = await ask(u, "/api/workspaces/0123456789abcdef/diff?path=a%2Fb&path=c"); expect([a.status, a.body]).toEqual([409, '{"error":"x","warnings":[]}']); - expect(seen).toEqual([[{ path: "/api/workspaces/0123456789abcdef/diff", query: "path=a%2Fb&path=c" }, "0.0.0-test"]]); + expect(seen).toEqual([[{ path: "/api/workspaces/0123456789abcdef/diff", query: "path=a%2Fb&path=c", method: "GET" }, "0.0.0-test"]]); }); it("a route that throws is 500 `internal error`; the message goes to onError once, with the pattern and nothing of the request", async () => { @@ -330,3 +330,396 @@ describe("review: a header given twice, and what close() leaves behind", () => { expect(started).toBe(1); }); }); + +// Spec 40 §6.3 — POST under --allow-writes +describe("--allow-writes (spec 40 §6.3)", () => { + const SYNC_PATH = "/api/workspaces/0123456789abcdef/sync"; + const DISCARD_PATH = "/api/workspaces/0123456789abcdef/discard"; + const DIFF_PATH = "/api/workspaces/0123456789abcdef/diff?path=x"; + const VALID_DIGEST = "a".repeat(64); + const VALID_SYNC_BODY = JSON.stringify({ digest: VALID_DIGEST }); + const VALID_DISCARD_BODY = JSON.stringify({ digest: VALID_DIGEST, path: ".claude/rules/x.md" }); + + interface RecordedRequest { + path: string; + query: string; + method: string; + body?: { digest: string; path?: string }; + startedAt: number; + endedAt?: number; + } + const calls: RecordedRequest[] = []; + + async function startWithWrites(routeDelay = 0, over: Partial = {}) { + calls.length = 0; + return start({ + allowWrites: true, + route: async (req) => { + const rec: RecordedRequest = { path: req.path, query: req.query, method: req.method ?? "GET", body: req.body, startedAt: Date.now() }; + calls.push(rec); + if (routeDelay > 0) await new Promise((r) => setTimeout(r, routeDelay)); + rec.endedAt = Date.now(); + return { status: 200, body: { ok: true } }; + }, + ...over, + }); + } + + /** Send a POST request with the given body and headers. */ + function postAsk( + u: UiServer, + target: string, + body: string, + o: { headers?: Record; host?: string | null; auth?: boolean; origin?: string | null; site?: string | null; contentType?: string | null } = {} + ): Promise { + const headers: Record = { + ...(o.host === null ? {} : { Host: o.host ?? `127.0.0.1:${u.port}` }), + ...(o.auth === false ? {} : { Authorization: `Bearer ${TOKEN}` }), + ...(o.origin === null ? {} : { Origin: o.origin ?? `http://127.0.0.1:${u.port}` }), + ...(o.site === null ? {} : { "Sec-Fetch-Site": o.site ?? "same-origin" }), + ...(o.contentType === null ? {} : { "Content-Type": o.contentType ?? "application/json" }), + ...o.headers, + }; + return new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port: u.port, path: target, method: "POST", headers, setHost: false, agent: false }, (res) => { + let bodyData = ""; + res.setEncoding("utf8").on("data", (c) => (bodyData += c)).on("end", () => resolve({ status: res.statusCode!, headers: res.headers, body: bodyData })); + }); + req.on("error", reject); + req.end(body); + }); + } + + // Case 1: Without the flag, a valid-looking POST to /sync → 405, and GET to /sync without token → 404 + it("without the flag, POST to /sync → 405 GET only, GET to /sync without token → 404", async () => { + const u = await start(); // no allowWrites + // POST should return 405 with today's body + const postAnswer = await postAsk(u, SYNC_PATH, VALID_SYNC_BODY); + expect([postAnswer.status, postAnswer.headers.allow, postAnswer.body]).toEqual([405, "GET", '{"error":"GET only"}']); + // GET to /sync without token → 404 (write pattern treated as null, so 404 before token check) + const getAnswer = await ask(u, SYNC_PATH, { auth: false }); + expect([getAnswer.status, getAnswer.body]).toEqual([404, '{"error":"not found"}']); + }); + + // Case 2: With the flag, the valid POST reaches the route once with method: "POST" and body { digest } + it("valid POST reaches the route once with method POST and body", async () => { + const u = await startWithWrites(); + const a = await postAsk(u, SYNC_PATH, VALID_SYNC_BODY); + expect(a.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe("POST"); + expect(calls[0].body).toEqual({ digest: VALID_DIGEST }); + }); + + // Case 3: POST without Origin, with foreign Origin, with Origin: null, without Sec-Fetch-Site, with same-site, with none → each 403 + it("POST without Origin, foreign Origin, null, or wrong Sec-Fetch-Site → 403, same as plain 403", async () => { + const u = await startWithWrites(); + // Get a reference 403 body and headers (GET without token) + const ref403 = await ask(u, DATA, { auth: false }); + expect(ref403.status).toBe(403); + + const cases: Array<{ origin?: string | null; site?: string | null; label: string }> = [ + { origin: null, label: "no Origin" }, + { origin: "http://evil.example", label: "foreign Origin" }, + { origin: "null", label: "Origin: null" }, + { site: null, label: "no Sec-Fetch-Site" }, + { site: "same-site", label: "Sec-Fetch-Site: same-site" }, + { site: "none", label: "Sec-Fetch-Site: none" }, + ]; + + for (const c of cases) { + const a = await postAsk(u, SYNC_PATH, VALID_SYNC_BODY, { origin: c.origin, site: c.site }); + expect([a.status, a.body], c.label).toEqual([ref403.status, ref403.body]); + // Also check specific headers match + expect(a.headers["content-type"], c.label).toBe(ref403.headers["content-type"]); + } + expect(calls).toHaveLength(0); + }); + + // Case 4: Content-Type text/plain → 415; application/json; charset=utf-8 → reaches route + it("Content-Type text/plain → 415; application/json; charset=utf-8 → reaches route", async () => { + const u = await startWithWrites(); + const textPlain = await postAsk(u, SYNC_PATH, VALID_SYNC_BODY, { contentType: "text/plain" }); + expect([textPlain.status, textPlain.body]).toEqual([415, '{"error":"Content-Type must be application/json"}']); + expect(calls).toHaveLength(0); + + const withCharset = await postAsk(u, SYNC_PATH, VALID_SYNC_BODY, { contentType: "application/json; charset=utf-8" }); + expect(withCharset.status).toBe(200); + expect(calls).toHaveLength(1); + }); + + // Case 5: Body of 4097 bytes → 413; exactly 4096 bytes → reaches parsing (400 or route); Content-Length lies + it("body over 4096 bytes → 413 with body received by client; exactly 4096 → parsing; Content-Length lie → never accepted", async () => { + const u = await startWithWrites(); + + // 4097 bytes in one write → client receives 413 and body + const big1 = JSON.stringify({ digest: VALID_DIGEST }) + " ".repeat(4097 - JSON.stringify({ digest: VALID_DIGEST }).length); + expect(Buffer.byteLength(big1)).toBe(4097); + const big1Ans = await postAsk(u, SYNC_PATH, big1); + expect([big1Ans.status, big1Ans.body]).toEqual([413, '{"error":"body too large"}']); + expect(calls).toHaveLength(0); + + // 64 KiB in 16 writes of 4 KiB each → client receives 413 and body + const chunk = "x".repeat(4096); + const big64K = await new Promise((resolve, reject) => { + const headers: Record = { + Host: `127.0.0.1:${u.port}`, + Authorization: `Bearer ${TOKEN}`, + Origin: `http://127.0.0.1:${u.port}`, + "Sec-Fetch-Site": "same-origin", + "Content-Type": "application/json", + }; + const req = http.request({ host: "127.0.0.1", port: u.port, path: SYNC_PATH, method: "POST", headers, setHost: false, agent: false }, (res) => { + let bodyData = ""; + res.setEncoding("utf8").on("data", (c) => (bodyData += c)).on("end", () => resolve({ status: res.statusCode!, headers: res.headers, body: bodyData })); + }); + req.on("error", (e) => resolve({ status: 0, headers: {}, body: String(e) })); + // Send 16 writes of 4 KiB each + for (let i = 0; i < 16; i++) req.write(chunk); + req.end(); + }); + expect([big64K.status, big64K.body]).toEqual([413, '{"error":"body too large"}']); + expect(calls).toHaveLength(0); + + // Exactly 4096 bytes of valid JSON (pad the digest object with spaces) → reaches parsing + const exact4096 = JSON.stringify({ digest: VALID_DIGEST }) + " ".repeat(4096 - JSON.stringify({ digest: VALID_DIGEST }).length); + expect(Buffer.byteLength(exact4096)).toBe(4096); + // This is valid JSON that parseWriteBody will accept + const exact = await postAsk(u, SYNC_PATH, exact4096); + expect(exact.status).toBe(200); // reaches the route + expect(calls).toHaveLength(1); + + // Content-Length says 10 but sends 5000 bytes → never accepted as ≤ 4096 + calls.length = 0; + const lying = await new Promise((resolve, reject) => { + const headers: Record = { + Host: `127.0.0.1:${u.port}`, + Authorization: `Bearer ${TOKEN}`, + Origin: `http://127.0.0.1:${u.port}`, + "Sec-Fetch-Site": "same-origin", + "Content-Type": "application/json", + "Content-Length": "10", + }; + const req = http.request({ host: "127.0.0.1", port: u.port, path: SYNC_PATH, method: "POST", headers, setHost: false, agent: false }, (res) => { + let bodyData = ""; + res.setEncoding("utf8").on("data", (c) => (bodyData += c)).on("end", () => resolve({ status: res.statusCode!, headers: res.headers, body: bodyData })); + }); + req.on("error", (e) => resolve({ status: 0, headers: {}, body: String(e) })); + // Send 5000 bytes + req.write("x".repeat(5000)); + req.end(); + }); + // Either 413 or connection error or 400 (bad json), route never called + expect(calls).toHaveLength(0); + expect(lying.status === 413 || lying.status === 0 || lying.status === 400).toBe(true); + }); + + // Case 5b: Body of MAX_DRAIN_BYTES + 4096 bytes → client gets 413 or connection error (past drain cap) + it("body of MAX_DRAIN_BYTES + 4096 bytes → 413 or connection error; server still answers next request", async () => { + const u = await startWithWrites(); + + // Send MAX_DRAIN_BYTES + 4096 bytes in 64 KiB chunks + const chunkSize = 64 * 1024; + const totalBytes = MAX_DRAIN_BYTES + 4096; + const numChunks = Math.ceil(totalBytes / chunkSize); + const chunk = Buffer.alloc(chunkSize, "x"); + const lastChunkSize = totalBytes % chunkSize || chunkSize; + const lastChunk = Buffer.alloc(lastChunkSize, "x"); + + const hugeBody = await new Promise((resolve) => { + const headers: Record = { + Host: `127.0.0.1:${u.port}`, + Authorization: `Bearer ${TOKEN}`, + Origin: `http://127.0.0.1:${u.port}`, + "Sec-Fetch-Site": "same-origin", + "Content-Type": "application/json", + }; + const req = http.request({ host: "127.0.0.1", port: u.port, path: SYNC_PATH, method: "POST", headers, setHost: false, agent: false }, (res) => { + let bodyData = ""; + res.setEncoding("utf8").on("data", (c) => (bodyData += c)).on("end", () => resolve({ status: res.statusCode!, headers: res.headers, body: bodyData })); + }); + req.on("error", (e) => resolve({ status: 0, headers: {}, body: String(e) })); + for (let i = 0; i < numChunks - 1; i++) req.write(chunk); + req.write(lastChunk); + req.end(); + }); + + // Either 413 with body, or connection error (acceptable past drain cap) + expect(hugeBody.status === 413 || hugeBody.status === 0).toBe(true); + if (hugeBody.status === 413) { + expect(hugeBody.body).toBe('{"error":"body too large"}'); + } + expect(calls).toHaveLength(0); + + // Server still answers a valid request after the huge one + const validAfter = await postAsk(u, SYNC_PATH, VALID_SYNC_BODY); + expect(validAfter.status).toBe(200); + expect(calls).toHaveLength(1); + }); + + // Case 5c: The 413 is answered only after the client ends the body (the order, not just the status) + it("the 413 is answered after the client ends an oversized body (the order, not just the status)", async () => { + const u = await startWithWrites(); + + const order: string[] = []; + const status = await new Promise((resolve) => { + const req = http.request( + { + host: "127.0.0.1", + port: u.port, + path: SYNC_PATH, + method: "POST", + setHost: false, + agent: false, + headers: { + Host: `127.0.0.1:${u.port}`, + Authorization: `Bearer ${TOKEN}`, + Origin: `http://127.0.0.1:${u.port}`, + "Sec-Fetch-Site": "same-origin", + "Content-Type": "application/json", + }, + }, + (res) => { + order.push("response"); + res.resume().on("end", () => resolve(res.statusCode!)); + } + ); + req.on("error", () => resolve(0)); + let i = 0; + const next = () => { + if (i++ < 16) { + req.write("x".repeat(4096)); + setTimeout(next, 5); + } else { + order.push("end"); + req.end(); + } + }; + next(); + }); + + expect([status, order]).toEqual([413, ["end", "response"]]); + expect(calls).toHaveLength(0); + }); + + // Case 6: Bad body shapes → 400 + it('{"digest":"<64a>","x":1} → 400; not JSON → 400; discard without path → 400', async () => { + const u = await startWithWrites(); + + // Extra key + const extraKey = await postAsk(u, SYNC_PATH, JSON.stringify({ digest: VALID_DIGEST, x: 1 })); + expect([extraKey.status, extraKey.body]).toEqual([400, '{"error":"bad request"}']); + + // Not JSON + const notJson = await postAsk(u, SYNC_PATH, "not json"); + expect([notJson.status, notJson.body]).toEqual([400, '{"error":"bad request"}']); + + // discard without path + const noPath = await postAsk(u, DISCARD_PATH, JSON.stringify({ digest: VALID_DIGEST })); + expect([noPath.status, noPath.body]).toEqual([400, '{"error":"bad request"}']); + + expect(calls).toHaveLength(0); + }); + + // Case 7: GET on /discard → 405 Allow: POST; POST on /diff → 405 Allow: GET; POST on / → 405 Allow: GET; PUT → 405 Allow: GET, POST + it("GET on /discard → 405 Allow: POST; POST on /diff → 405 Allow: GET; POST on / → 405 Allow: GET; PUT → 405 Allow: GET, POST", async () => { + const u = await startWithWrites(); + + // GET on write pattern → 405 Allow: POST + const getDiscard = await ask(u, DISCARD_PATH.split("?")[0]); + expect([getDiscard.status, getDiscard.headers.allow, getDiscard.body]).toEqual([405, "POST", '{"error":"POST only"}']); + + // POST on reading pattern → 405 Allow: GET + const postDiff = await postAsk(u, DIFF_PATH, VALID_SYNC_BODY); + expect([postDiff.status, postDiff.headers.allow, postDiff.body]).toEqual([405, "GET", '{"error":"GET only"}']); + + // POST on static route → 405 Allow: GET + const postRoot = await postAsk(u, "/", VALID_SYNC_BODY); + expect([postRoot.status, postRoot.headers.allow, postRoot.body]).toEqual([405, "GET", '{"error":"GET only"}']); + + // PUT → 405 Allow: GET, POST + const put = await new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port: u.port, path: DATA, method: "PUT", headers: { Host: `127.0.0.1:${u.port}` }, setHost: false, agent: false }, (res) => { + let body = ""; + res.setEncoding("utf8").on("data", (c) => (body += c)).on("end", () => resolve({ status: res.statusCode!, headers: res.headers, body })); + }); + req.on("error", reject); + req.end(); + }); + expect([put.status, put.headers.allow, put.body]).toEqual([405, "GET, POST", '{"error":"GET or POST only"}']); + }); + + // Case 8: The token: valid POST without Authorization → 403, route not called + it("valid POST without Authorization → 403, route not called", async () => { + const u = await startWithWrites(); + const a = await postAsk(u, SYNC_PATH, VALID_SYNC_BODY, { auth: false }); + expect([a.status, a.body]).toEqual([403, FORBIDDEN]); + expect(calls).toHaveLength(0); + }); + + // Case 9: Queue: a slow write route and a GET sent right after — GET starts only after the write ends + it("queue: a slow write and a GET right after — GET starts only after write ends", async () => { + const u = await startWithWrites(200); // 200ms delay + const events: string[] = []; + + // Start POST (slow) + const postPromise = postAsk(u, SYNC_PATH, VALID_SYNC_BODY).then((a) => { + events.push("post done"); + return a; + }); + + // Wait a bit then send GET + await new Promise((r) => setTimeout(r, 20)); + const getPromise = ask(u, DATA).then((a) => { + events.push("get done"); + return a; + }); + + const [postAnswer, getAnswer] = await Promise.all([postPromise, getPromise]); + expect(postAnswer.status).toBe(200); + expect(getAnswer.status).toBe(200); + + // Check order: POST started first, ended first, then GET started and ended + expect(calls).toHaveLength(2); + expect(calls[0].method).toBe("POST"); + expect(calls[1].method).toBe("GET"); + expect(calls[0].endedAt!).toBeLessThanOrEqual(calls[1].startedAt); + expect(events).toEqual(["post done", "get done"]); + }); + + // Case 10: A throwing route seam on /sync → 500 and one onError line + it("throwing route on /sync → 500 and one onError line", async () => { + const u = await start({ + allowWrites: true, + route: async (req) => { + if (req.path.includes("sync")) throw new Error("SENTINEL-ERROR first line\nsecond line"); + return OK; + }, + }); + const a = await postAsk(u, SYNC_PATH, VALID_SYNC_BODY); + expect([a.status, a.body]).toEqual([500, '{"error":"internal error"}']); + expect(errors).toEqual(["ui: /api/workspaces//sync: SENTINEL-ERROR first line"]); + }); +}); + +// Spec 40 §6.5 — the served /app.js depends on allowWrites +describe("/app.js served based on allowWrites", () => { + it("with allowWrites: true, /app.js equals pageJs(true)", async () => { + // Import dynamically to avoid issues, since we need the actual compiled module + const { PAGE_JS, pageJs } = await import("../src/ui/page.js"); + ui = await startUi({ home: process.env.CRAFTAR_HOME!, version: "0.0.0-test", port: 0, onError: (l) => errors.push(l), token: TOKEN, allowWrites: true, route: async () => OK }); + const a = await ask(ui, "/app.js", { auth: false }); + expect(a.status).toBe(200); + expect(a.body).toBe(pageJs(true)); + expect(a.body).not.toBe(PAGE_JS); + }); + + it("without allowWrites, /app.js equals PAGE_JS (pageJs(false))", async () => { + const { PAGE_JS, pageJs } = await import("../src/ui/page.js"); + ui = await startUi({ home: process.env.CRAFTAR_HOME!, version: "0.0.0-test", port: 0, onError: (l) => errors.push(l), token: TOKEN, route: async () => OK }); + const a = await ask(ui, "/app.js", { auth: false }); + expect(a.status).toBe(200); + expect(a.body).toBe(PAGE_JS); + expect(a.body).toBe(pageJs(false)); + }); +}); \ No newline at end of file diff --git a/test/ui-writes-pins.test.ts b/test/ui-writes-pins.test.ts new file mode 100644 index 0000000..5c805df --- /dev/null +++ b/test/ui-writes-pins.test.ts @@ -0,0 +1,143 @@ +// Spec 40 §6.1, §12.5: what craftar ui serves without --allow-writes, pinned BEFORE the writing half lands. +// Every test here is a control: it passes on 2f8af6b and must pass unchanged after Part C. + +import { afterEach, describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import http from "node:http"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import YAML from "yaml"; +import { PAGE_HTML, PAGE_JS, PAGE_CSS } from "../src/ui/page.js"; +import { startUi, type UiServer } from "../src/ui/server.js"; +import { eightStates, mcpWorkspace, MCP_FILES, syncOnce, type Scenario } from "./helpers/states.js"; +import { HOME, get, syncRegistered } from "./helpers/ui.js"; +import { loadWorkspace, plan } from "../src/core/sync.js"; +import { register, registryFile } from "../src/core/registry.js"; +import { workspaceId } from "../src/ui/routes.js"; +import { recipe } from "./helpers/forge.js"; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + await fs.rm(registryFile(HOME()), { force: true }); + while (cleanups.length) await cleanups.pop()!(); +}); +const kept = Promise }>(s: T): T => (cleanups.push(s.cleanup), s); + +const sha256 = (text: string): string => createHash("sha256").update(text, "utf8").digest("hex"); + +/** eightStates(), registered as the first sync left it. */ +async function eight() { + const s = kept(await eightStates()); + const ws = await loadWorkspace(s.wsRoot, { home: HOME() }); + const entry = await register(HOME(), ws, await plan(ws)); + return { s, id: workspaceId(entry.path), real: entry.path }; +} + +/** Normalise a JSON body: replace real paths with , id with , registry path with , ISO times with , forge key with . */ +function norm(body: unknown, roots: { wsRoot: string; real: string; id: string }): unknown { + let json = JSON.stringify(body); + const escape = (s: string) => JSON.stringify(s).slice(1, -1).replace(/[\\.*+?^${}()|[\]]/g, "\\$&"); + json = json.replace(new RegExp(escape(roots.real), "g"), ""); + json = json.replace(new RegExp(escape(roots.wsRoot), "g"), ""); + json = json.replace(new RegExp(escape(registryFile(HOME())), "g"), ""); + json = json.replace(new RegExp(roots.id, "g"), ""); + json = json.replace(/"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z"/g, '""'); + // Windows-safe: the key is a native path, so in JSON it ends /forge (POSIX) or \\forge (win32) + json = json.replace(/"key":"[^"]+(?:\/|\\\\)forge"/g, '"key":""'); + return JSON.parse(json); +} + +describe("page constants by hash", () => { + it("PAGE_HTML, PAGE_JS, PAGE_CSS have the expected sha256 and length", () => { + expect(sha256(PAGE_HTML)).toBe("7ca17d7e9ff81e39b9184f6b3a2c7d1c4f42302410a74fdea865663459134de8"); + expect(PAGE_HTML.length).toBe(281); + expect(sha256(PAGE_JS)).toBe("c6b89b4a9bea59c236b33a6c728b95713a3e1da854ffd045b5615a25e32ca8f4"); + expect(PAGE_JS.length).toBe(14734); + expect(sha256(PAGE_CSS)).toBe("faf4ec748ec257d5365371438e2946e04502ae04a41e74cae3fd8be0de951360"); + expect(PAGE_CSS.length).toBe(2747); + }); +}); + +describe("non-GET is 405", () => { + const TOKEN = "t".repeat(43); + let ui: UiServer | undefined; + afterEach(async () => { await ui?.close(); ui = undefined; }); + + interface Answer { status: number; headers: http.IncomingHttpHeaders; body: string } + function ask(u: UiServer, target: string, o: { method?: string; headers?: Record } = {}): Promise { + const headers: Record = { Host: `127.0.0.1:${u.port}`, Authorization: `Bearer ${TOKEN}`, ...o.headers }; + return new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port: u.port, path: target, method: o.method ?? "GET", headers, setHost: false, agent: false }, (res) => { + let body = ""; + res.setEncoding("utf8").on("data", (c) => (body += c)).on("end", () => resolve({ status: res.statusCode!, headers: res.headers, body })); + }); + req.on("error", reject); + if (o.method === "POST") req.write('{"digest":"x"}'); + req.end(); + }); + } + + it("POST, PUT, DELETE on /api/workspaces and POST on / return 405 with exact bytes", async () => { + ui = await startUi({ home: HOME(), version: "0.0.0-test", port: 0, onError: () => {}, token: TOKEN, route: async () => ({ status: 200, body: { ok: true } }) }); + for (const [method, target] of [["POST", "/api/workspaces"], ["PUT", "/api/workspaces"], ["DELETE", "/api/workspaces"], ["POST", "/"]] as const) { + const headers: Record = { Origin: `http://127.0.0.1:${ui.port}`, "Sec-Fetch-Site": "same-origin", "Content-Type": "application/json" }; + const a = await ask(ui, target, { method, headers }); + expect(a.status).toBe(405); + expect(a.headers.allow).toBe("GET"); + expect(a.headers["content-type"]).toBe("application/json; charset=utf-8"); + expect(a.body).toBe('{"error":"GET only"}'); + } + }); +}); + +describe("reading routes JSON", () => { + it("GET /api/workspaces", async () => { + const { s, id, real } = await eight(); + const answer = await get("/api/workspaces"); + expect(answer.status).toBe(200); + expect(norm(answer.body, { wsRoot: s.wsRoot, real, id })).toEqual({"version":"0.0.0-test","registry":"","fetch":false,"writes":false,"workspaces":[{"id":"","name":"ws","path":"","profile":"acme","recipes":["base"],"stack":{},"targets":["claude-code"],"forge":{"kind":"path","source":"../forge","ref":null,"defaultBranch":null,"commit":null,"lockCommit":null,"fetched":false,"fromLocalFile":false,"key":""},"lastSync":"","forgeMoved":null,"status":"drift","files":{"adopt":1,"collision":1,"drift":1,"new":1,"orphan":1,"orphan-drift":1,"unchanged":1,"update":1}}],"warnings":[]}); + }); + + it("GET /api/workspaces/", async () => { + const { s, id, real } = await eight(); + const answer = await get(`/api/workspaces/${id}`); + expect(answer.status).toBe(200); + expect(norm(answer.body, { wsRoot: s.wsRoot, real, id })).toEqual({"id":"","path":"","profile":"acme","recipes":["base"],"targets":["claude-code"],"forge":{"kind":"path","source":"../forge","ref":null,"defaultBranch":null,"commit":null,"lockCommit":null,"fetched":false},"statuses":[{"path":".claude/rules/adopted.md","state":"adopt","target":"claude-code","ingredient":"rule/adopted"},{"path":".claude/rules/clash.md","state":"collision","target":"claude-code","ingredient":"rule/clash"},{"path":".claude/rules/edited.md","state":"drift","target":"claude-code","ingredient":"rule/edited","lock":{"path":".claude/rules/edited.md","hash":"sha256:cf4ab371ad9869241b6ee1942bcdf50611db7581d6b3e9c6847c6b0f003ee773","target":"claude-code","ingredient":"rule/edited"}},{"path":".claude/rules/fresh.md","state":"new","target":"claude-code","ingredient":"rule/fresh"},{"path":".claude/rules/gone-edited.md","state":"orphan-drift","target":"claude-code","ingredient":"rule/gone-edited","lock":{"path":".claude/rules/gone-edited.md","hash":"sha256:e97174db0e15246c338a6acdd2ca26d6ba371ee5553f8fb7136a1b98eb18b6c5","target":"claude-code","ingredient":"rule/gone-edited"}},{"path":".claude/rules/gone.md","state":"orphan","target":"claude-code","ingredient":"rule/gone","lock":{"path":".claude/rules/gone.md","hash":"sha256:179bbd94af11271d3a62e9aae6dd5527305d5950292f8639d66b30958cb8c48b","target":"claude-code","ingredient":"rule/gone"}},{"path":".claude/rules/keep.md","state":"unchanged","target":"claude-code","ingredient":"rule/keep","lock":{"path":".claude/rules/keep.md","hash":"sha256:9a75bf85a4bb172834fe5d5b052612c0dedc2859adda6afbbbeb76005c6c1195","target":"claude-code","ingredient":"rule/keep"}},{"path":".claude/rules/moved.md","state":"update","target":"claude-code","ingredient":"rule/moved","lock":{"path":".claude/rules/moved.md","hash":"sha256:69b6bbadb67faf0860df534ac0799a6d129c6041bdafe44efdaedee0c3babe4a","target":"claude-code","ingredient":"rule/moved"}}],"warnings":[],"unsetParams":[],"refused":null}); + }); + + it("GET /api/workspaces//diff for drift, orphan-drift, update rows", async () => { + const { s, id, real } = await eight(); + const R = ".claude/rules"; + expect(norm((await get(`/api/workspaces/${id}/diff?path=${encodeURIComponent(`${R}/edited.md`)}`)).body, { wsRoot: s.wsRoot, real, id })).toEqual({"path":".claude/rules/edited.md","state":"drift","from":".claude/rules/edited.md (disk, drift)","to":".claude/rules/edited.md (forge)","hidden":false,"note":null,"diff":" # Edited\n- by hand\n+ by the forge"}); + expect(norm((await get(`/api/workspaces/${id}/diff?path=${encodeURIComponent(`${R}/gone-edited.md`)}`)).body, { wsRoot: s.wsRoot, real, id })).toEqual({"path":".claude/rules/gone-edited.md","state":"orphan-drift","from":".claude/rules/gone-edited.md (disk, orphan-drift)","to":null,"hidden":false,"note":"no longer produced by the Forge but hand-edited — kept; `craftar drift discard ` removes it, or delete it yourself","diff":null}); + expect(norm((await get(`/api/workspaces/${id}/diff?path=${encodeURIComponent(`${R}/moved.md`)}`)).body, { wsRoot: s.wsRoot, real, id })).toEqual({"path":".claude/rules/moved.md","state":"update","from":".claude/rules/moved.md (disk, update)","to":".claude/rules/moved.md (forge)","hidden":false,"note":null,"diff":" # Moved\n- old line\n+ new line"}); + }); + + it("GET /api/workspaces//explain for a planned path", async () => { + const { s, id, real } = await eight(); + expect(norm((await get(`/api/workspaces/${id}/explain?path=${encodeURIComponent(".claude/rules/keep.md")}`)).body, { wsRoot: s.wsRoot, real, id })).toEqual({"path":".claude/rules/keep.md","target":"claude-code","ingredient":"rule/keep","origin":null,"via":["base"],"sections":[],"params":[],"profile":"acme","recipes":["base"],"hash":"sha256:9a75bf85a4bb172834fe5d5b052612c0dedc2859adda6afbbbeb76005c6c1195"}); + }); +}); + +describe("orphan-drift example settings file", () => { + it("diff route for orphan-drift .claude/settings.craftar.example.json", async () => { + const EX = MCP_FILES[2]; + const s = kept(await mcpWorkspace()); + await syncOnce(s.wsRoot); + await fs.writeFile(path.join(s.forgeRoot, "recipes/base.yaml"), YAML.stringify(recipe("base", ["rule/keep"]))); + await fs.appendFile(path.join(s.wsRoot, EX), "\n"); + const ws = await loadWorkspace(s.wsRoot, { home: HOME() }); + const entry = await register(HOME(), ws, await plan(ws)); + const id = workspaceId(entry.path); + expect(norm((await get(`/api/workspaces/${id}/diff?path=${encodeURIComponent(EX)}`)).body, { wsRoot: s.wsRoot, real: entry.path, id })).toEqual({"path":".claude/settings.craftar.example.json","state":"orphan-drift","from":".claude/settings.craftar.example.json (disk, orphan-drift)","to":null,"hidden":false,"note":"no longer produced by the Forge but hand-edited — kept; `craftar drift discard ` removes it, or delete it yourself","diff":null}); + }); +}); + +describe("norm() substitutes a Windows path as JSON holds it", () => { + it("replaces a Windows path with backslashes in JSON-serialized output", () => { + const wsRoot = "C:\\Users\\runneradmin\\AppData\\Local\\Temp\\craftar-test-X\\ws"; + const roots = { wsRoot, real: wsRoot, id: "0123456789abcdef" }; + const body = { path: wsRoot, id: "0123456789abcdef" }; + expect(norm(body, roots)).toEqual({ path: "", id: "" }); + }); +}); diff --git a/test/ui-writes.test.ts b/test/ui-writes.test.ts new file mode 100644 index 0000000..bf7b81a --- /dev/null +++ b/test/ui-writes.test.ts @@ -0,0 +1,526 @@ +// Spec 40 §6.2, §6.4, §12.5 — the write routes of craftar ui --allow-writes, through route(). + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import YAML from "yaml"; +import { profile, recipe, rule, scenario, tmpDir, writeFiles } from "./helpers/forge.js"; +import { remoteForge } from "./helpers/remote.js"; +import { eightStates, mcpWorkspace, MCP_FILES, syncOnce, type Scenario } from "./helpers/states.js"; +import { HOME, get, post, syncRegistered } from "./helpers/ui.js"; +import { registryFile } from "../src/core/registry.js"; +import { loadWorkspace, plan, readLock, status, unsetSummary } from "../src/core/sync.js"; +import { statusDigest } from "../src/core/write-actions.js"; +import { register } from "../src/core/registry.js"; +import { renderDiff } from "../src/core/diff.js"; +import { EXAMPLE_CONTENT_HIDDEN } from "../src/core/report.js"; +import { + NOT_FOUND, + NOT_HAND_EDITED, + REMOTE_FORGE, + STALE_DIGEST, + parseWriteBody, + route, + routePattern, + workspaceId, + WRITE_PATTERNS, +} from "../src/ui/routes.js"; + +const cleanups: Array<() => Promise> = []; +beforeEach(async () => { + await fs.rm(registryFile(HOME()), { force: true }); // one registry per test: the home is per file +}); +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +const kept = Promise }>(s: T): T => (cleanups.push(s.cleanup), s); + +const R = ".claude/rules"; +const BASE = { ingredients: [rule("keep", "# Keep\n")], recipes: [recipe("base", ["rule/keep"])], profiles: [profile("acme", ["base"])] }; + +/** eightStates(), registered as the first sync left it (its registry entry is not touched by the later edits). */ +async function eight() { + const s = kept(await eightStates()); + const ws = await loadWorkspace(s.wsRoot, { home: HOME() }); + const entry = await register(HOME(), ws, await plan(ws)); + return { s, id: workspaceId(entry.path), real: entry.path }; +} + +/** Every file under `dir` with its sha256, sorted by path. */ +async function tree(dir: string): Promise> { + const out: Array<[string, string]> = []; + const walk = async (d: string) => { + for (const e of await fs.readdir(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) await walk(p); + else out.push([path.relative(dir, p).replace(/\\/g, "/"), createHash("sha256").update(await fs.readFile(p)).digest("hex")]); + } + }; + await walk(dir); + return out.sort((a, b) => a[0].localeCompare(b[0])); +} + +describe("writes flag and digest", () => { + it("1. writes is false without the flag and true with it; digest absent without, 64 hex with it, matching statusDigest", async () => { + const { s, id } = await eight(); + + // Without allowWrites + const without = await get(`/api/workspaces`); + expect((without.body as any).writes).toBe(false); + const statusWithout = await get(`/api/workspaces/${id}`); + expect((statusWithout.body as any).digest).toBeUndefined(); + + // With allowWrites + const withWrites = await get(`/api/workspaces`, { allowWrites: true }); + expect((withWrites.body as any).writes).toBe(true); + + const statusWithWrites = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const returnedDigest = (statusWithWrites.body as any).digest; + expect(returnedDigest).toMatch(/^[0-9a-f]{64}$/); + + // Compare with statusDigest computed directly + const ws = await loadWorkspace(s.wsRoot, { home: HOME() }); + const p = await plan(ws); + const st = await status(ws, p, await readLock(ws.root)); + const computed = await statusDigest(ws, st); + expect(returnedDigest).toBe(computed); + }); +}); + +describe("sync route", () => { + it("2. sync with current digest → 200, then second GET digest differs; drift, orphan-drift, collision unchanged", async () => { + const { s, id } = await eight(); + + // Get the current digest + const statusBefore = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const digest = (statusBefore.body as any).digest; + + // Record the drift, orphan-drift, collision bytes before + const driftBefore = await fs.readFile(path.join(s.wsRoot, `${R}/edited.md`)); + const orphanDriftBefore = await fs.readFile(path.join(s.wsRoot, `${R}/gone-edited.md`)); + const collisionBefore = await fs.readFile(path.join(s.wsRoot, `${R}/clash.md`)); + + // Sync + const result = await post(`/api/workspaces/${id}/sync`, { digest }); + expect(result.status).toBe(200); + + // Check the result + const body = result.body as any; + expect(body.profile).toBe("acme"); + expect(body.recipes).toEqual(["base"]); + expect(body.targets).toEqual(["claude-code"]); + expect(body.written.sort()).toEqual([".claude/rules/adopted.md", ".claude/rules/fresh.md", ".claude/rules/moved.md"]); + expect(body.removed).toEqual([{ path: ".claude/rules/gone.md", reason: "orphan: no longer produced by the Forge" }]); + expect(body.skipped.map((s: any) => s.path).sort()).toEqual([".claude/rules/clash.md", ".claude/rules/edited.md", ".claude/rules/gone-edited.md"]); + expect(body.registry).toBe("updated"); + + // Second GET: digest should differ + const statusAfter = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const digestAfter = (statusAfter.body as any).digest; + expect(digestAfter).not.toBe(digest); + + // Drift, orphan-drift, collision files unchanged + expect(await fs.readFile(path.join(s.wsRoot, `${R}/edited.md`))).toEqual(driftBefore); + expect(await fs.readFile(path.join(s.wsRoot, `${R}/gone-edited.md`))).toEqual(orphanDriftBefore); + expect(await fs.readFile(path.join(s.wsRoot, `${R}/clash.md`))).toEqual(collisionBefore); + }); + + it("3. sync with stale digest → 409 STALE_DIGEST, tree unchanged", async () => { + const { s, id } = await eight(); + + // Get the current digest + const statusBefore = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const digest = (statusBefore.body as any).digest; + + // Sync once to change the workspace state + await syncRegistered(s.wsRoot); + + const treeBefore = await tree(s.wsRoot); + + // Try to sync with the old digest + const result = await post(`/api/workspaces/${id}/sync`, { digest }); + expect(result.status).toBe(409); + expect((result.body as any).error).toBe(STALE_DIGEST); + + // Tree unchanged + expect(await tree(s.wsRoot)).toEqual(treeBefore); + }); +}); + +describe("discard route", () => { + it("4. discard of drift path → 200, file now equals Forge's bytes, listed in written", async () => { + const { s, id } = await eight(); + + // Get status with digest + const statusBefore = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const digest = (statusBefore.body as any).digest; + + const driftPath = `${R}/edited.md`; + + // Discard + const result = await post(`/api/workspaces/${id}/discard`, { digest, path: driftPath }); + expect(result.status).toBe(200); + + const body = result.body as any; + expect(body.written).toContain(driftPath); + + // File now equals Forge's bytes + const content = await fs.readFile(path.join(s.wsRoot, driftPath), "utf8"); + expect(content).toBe("# Edited\nby the forge\n"); + }); + + it("5. discard of orphan-drift path → 200, file is gone, lock no longer lists it", async () => { + const { s, id } = await eight(); + + // Get status with digest + const statusBefore = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const digest = (statusBefore.body as any).digest; + + const orphanDriftPath = `${R}/gone-edited.md`; + + // Discard + const result = await post(`/api/workspaces/${id}/discard`, { digest, path: orphanDriftPath }); + expect(result.status).toBe(200); + + const body = result.body as any; + expect(body.removed).toContainEqual({ path: orphanDriftPath, reason: "hand-edited orphan: discarded" }); + + // File is gone + await expect(fs.access(path.join(s.wsRoot, orphanDriftPath))).rejects.toThrow(); + + // Lock no longer lists it + const lock = JSON.parse(await fs.readFile(path.join(s.wsRoot, "craftar.lock"), "utf8")); + expect(lock.files.some((f: any) => f.path === orphanDriftPath)).toBe(false); + }); + + it("6. discard of update path → 409 NOT_HAND_EDITED, body does not contain the path, tree unchanged", async () => { + const { s, id } = await eight(); + + // Get status with digest + const statusBefore = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const digest = (statusBefore.body as any).digest; + + const treeBefore = await tree(s.wsRoot); + + const updatePath = `${R}/moved.md`; + + // Try to discard + const result = await post(`/api/workspaces/${id}/discard`, { digest, path: updatePath }); + expect(result.status).toBe(409); + expect((result.body as any).error).toBe(NOT_HAND_EDITED); + expect(JSON.stringify(result.body)).not.toContain(updatePath); + + // Tree unchanged + expect(await tree(s.wsRoot)).toEqual(treeBefore); + }); + + it("7. discard of path that is no row → 409 NOT_HAND_EDITED, answer does not contain it", async () => { + const { s, id } = await eight(); + + // Get status with digest + const statusBefore = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const digest = (statusBefore.body as any).digest; + + const treeBefore = await tree(s.wsRoot); + + const badPath = "../../etc/passwd"; + + // Try to discard + const result = await post(`/api/workspaces/${id}/discard`, { digest, path: badPath }); + expect(result.status).toBe(409); + expect((result.body as any).error).toBe(NOT_HAND_EDITED); + expect(JSON.stringify(result.body)).not.toContain(badPath); + + // Tree unchanged + expect(await tree(s.wsRoot)).toEqual(treeBefore); + }); + + it("8. discard of .claude/settings.craftar.example.json in drift and orphan-drift → 409", async () => { + const EX = MCP_FILES[2]; + + // Test drift state + { + await fs.rm(registryFile(HOME()), { force: true }); + const s = kept(await mcpWorkspace()); + await syncOnce(s.wsRoot); + await fs.appendFile(path.join(s.wsRoot, EX), "\n"); + const ws = await loadWorkspace(s.wsRoot, { home: HOME() }); + const entry = await register(HOME(), ws, await plan(ws)); + const id = workspaceId(entry.path); + + const statusResp = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const digest = (statusResp.body as any).digest; + const treeBefore = await tree(s.wsRoot); + + const result = await post(`/api/workspaces/${id}/discard`, { digest, path: EX }); + expect(result.status).toBe(409); + expect((result.body as any).error).toBe(NOT_HAND_EDITED); + expect(await tree(s.wsRoot)).toEqual(treeBefore); + } + + // Test orphan-drift state + { + await fs.rm(registryFile(HOME()), { force: true }); + const s = kept(await mcpWorkspace()); + await syncOnce(s.wsRoot); + // Remove MCP from recipe so the example file becomes orphan + await fs.writeFile(path.join(s.forgeRoot, "recipes/base.yaml"), YAML.stringify(recipe("base", ["rule/keep"]))); + await fs.appendFile(path.join(s.wsRoot, EX), "\n"); + const ws = await loadWorkspace(s.wsRoot, { home: HOME() }); + const entry = await register(HOME(), ws, await plan(ws)); + const id = workspaceId(entry.path); + + const statusResp = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const digest = (statusResp.body as any).digest; + const treeBefore = await tree(s.wsRoot); + + const result = await post(`/api/workspaces/${id}/discard`, { digest, path: EX }); + expect(result.status).toBe(409); + expect((result.body as any).error).toBe(NOT_HAND_EDITED); + expect(await tree(s.wsRoot)).toEqual(treeBefore); + } + }); +}); + +describe("remote Forge", () => { + it("9. remote Forge → 409 REMOTE_FORGE, tree unchanged", async () => { + // Both the configuration and the registry agree on a URL Forge + const r = kept(await remoteForge(BASE)); + const ws = await tmpDir(); + cleanups.push(() => fs.rm(ws, { recursive: true, force: true })); + await fs.writeFile(path.join(ws, "craftar.yaml"), YAML.stringify({ forge: r.url, profile: "acme" })); + const { id } = await syncRegistered(ws); + + const statusResp = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const digest = (statusResp.body as any).digest; + const treeBefore = await tree(ws); + + // Try sync + const syncResult = await post(`/api/workspaces/${id}/sync`, { digest }); + expect(syncResult.status).toBe(409); + expect((syncResult.body as any).error).toBe(REMOTE_FORGE); + + // Try discard (need to create a drift file first) + await fs.writeFile(path.join(ws, `${R}/keep.md`), "# Keep\nedited\n"); + const statusResp2 = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const digest2 = (statusResp2.body as any).digest; + + const discardResult = await post(`/api/workspaces/${id}/discard`, { digest: digest2, path: `${R}/keep.md` }); + expect(discardResult.status).toBe(409); + expect((discardResult.body as any).error).toBe(REMOTE_FORGE); + + // Tree unchanged (except for the edit we made) + const treeAfter = await tree(ws); + // The keep.md file should still be edited + expect(await fs.readFile(path.join(ws, `${R}/keep.md`), "utf8")).toBe("# Keep\nedited\n"); + }); + + it("9b. registered with a path Forge, then craftar.yaml edited to a URL → 409 REMOTE_FORGE, tree unchanged", async () => { + // Register with a path Forge + const s = kept(await scenario(BASE, { config: { profile: "acme" } })); + const { id } = await syncRegistered(s.wsRoot); + + // Edit craftar.yaml to point to a URL Forge (no network reached — refuseRemote throws before any fetch) + await fs.writeFile(path.join(s.wsRoot, "craftar.yaml"), YAML.stringify({ forge: "https://example.invalid/forge.git", profile: "acme" })); + + // Get digest — the workspace can still be loaded for reading + const statusResp = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const digest = (statusResp.body as any).digest; + const treeBefore = await tree(s.wsRoot); + + // Sync should be refused + const syncResult = await post(`/api/workspaces/${id}/sync`, { digest }); + expect(syncResult.status).toBe(409); + expect(syncResult.body).toEqual({ error: REMOTE_FORGE, warnings: [] }); + + // Discard should also be refused + const discardResult = await post(`/api/workspaces/${id}/discard`, { digest, path: `${R}/keep.md` }); + expect(discardResult.status).toBe(409); + expect(discardResult.body).toEqual({ error: REMOTE_FORGE, warnings: [] }); + + // Tree unchanged + expect(await tree(s.wsRoot)).toEqual(treeBefore); + }); +}); + +describe("unset declared parameter", () => { + it("10. unset declared parameter → 409 with unsetSummary text", async () => { + const s = kept( + await scenario( + { + ingredients: [rule("api", "port {{api.port}}\n", { params: { "api.port": {} } })], + recipes: [recipe("base", ["rule/api"])], + profiles: [profile("acme", ["base"])], + }, + { config: { profile: "acme" } }, + ), + ); + const ws = await loadWorkspace(s.wsRoot, { home: HOME() }); + const entry = await register(HOME(), ws, await plan(ws)); + const id = workspaceId(entry.path); + + const statusResp = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const digest = (statusResp.body as any).digest; + const treeBefore = await tree(s.wsRoot); + + const result = await post(`/api/workspaces/${id}/sync`, { digest }); + expect(result.status).toBe(409); + expect((result.body as any).error).toBe(unsetSummary([{ key: "api.port", declaredBy: ["rule/api"], citedBy: ["rule/api"] }])); + + expect(await tree(s.wsRoot)).toEqual(treeBefore); + }); +}); + +describe("registry not updated", () => { + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)("11. registry cannot be written: 'not updated' in result, no registry path in answer", async () => { + const s = kept( + await scenario(BASE, { config: { profile: "acme" } }), + ); + const { id } = await syncRegistered(s.wsRoot); + + const statusResp = await get(`/api/workspaces/${id}`, { allowWrites: true }); + const digest = (statusResp.body as any).digest; + + // Make home directory read-only to prevent registry writes + const home = HOME(); + const regPath = registryFile(home); + try { + await fs.chmod(home, 0o555); + + const result = await post(`/api/workspaces/${id}/sync`, { digest }); + expect(result.status).toBe(200); + expect((result.body as any).registry).toBe("not updated"); + expect(JSON.stringify(result.body)).not.toContain(regPath); + } finally { + await fs.chmod(home, 0o755); + } + }); +}); + +describe("parseWriteBody", () => { + it("12. valid and invalid bodies", () => { + // Valid sync + expect(parseWriteBody("sync", JSON.stringify({ digest: "a".repeat(64) }))).toEqual({ digest: "a".repeat(64) }); + + // Valid discard + expect(parseWriteBody("discard", JSON.stringify({ digest: "b".repeat(64), path: "some/path.md" }))).toEqual({ + digest: "b".repeat(64), + path: "some/path.md", + }); + + // Extra key + expect(parseWriteBody("sync", JSON.stringify({ digest: "a".repeat(64), extra: "x" }))).toBeNull(); + expect(parseWriteBody("discard", JSON.stringify({ digest: "a".repeat(64), path: "x", extra: "y" }))).toBeNull(); + + // Missing digest + expect(parseWriteBody("sync", JSON.stringify({}))).toBeNull(); + expect(parseWriteBody("discard", JSON.stringify({ path: "x" }))).toBeNull(); + + // Uppercase hex + expect(parseWriteBody("sync", JSON.stringify({ digest: "A".repeat(64) }))).toBeNull(); + + // 63 hex (one short) + expect(parseWriteBody("sync", JSON.stringify({ digest: "a".repeat(63) }))).toBeNull(); + + // path on sync + expect(parseWriteBody("sync", JSON.stringify({ digest: "a".repeat(64), path: "x" }))).toBeNull(); + + // No path on discard + expect(parseWriteBody("discard", JSON.stringify({ digest: "a".repeat(64) }))).toBeNull(); + + // path not a string + expect(parseWriteBody("discard", JSON.stringify({ digest: "a".repeat(64), path: 123 }))).toBeNull(); + + // Array + expect(parseWriteBody("sync", "[]")).toBeNull(); + + // null + expect(parseWriteBody("sync", "null")).toBeNull(); + + // String + expect(parseWriteBody("sync", '"x"')).toBeNull(); + + // Invalid JSON + expect(parseWriteBody("sync", "{ broken")).toBeNull(); + }); +}); + +describe("method and flag checks", () => { + it("13. without flag: POST returns 404; GET to sync/discard returns 404; POST to diff returns 404", async () => { + const { id } = await eight(); + const digest = "a".repeat(64); + + // POST without allowWrites → 404 + const postWithoutFlag = await route( + { path: `/api/workspaces/${id}/sync`, query: "", method: "POST", body: { digest } }, + { home: HOME(), version: "0.0.0-test", allowWrites: false }, + ); + expect(postWithoutFlag.status).toBe(404); + + // GET to sync → 404 + const getSync = await get(`/api/workspaces/${id}/sync`, { allowWrites: true }); + expect(getSync.status).toBe(404); + + // GET to discard → 404 + const getDiscard = await get(`/api/workspaces/${id}/discard`, { allowWrites: true }); + expect(getDiscard.status).toBe(404); + + // POST to diff → 404 (with allowWrites, POST to reading route) + const postDiff = await route( + { path: `/api/workspaces/${id}/diff`, query: "path=x", method: "POST", body: { digest } }, + { home: HOME(), version: "0.0.0-test", allowWrites: true }, + ); + expect(postDiff.status).toBe(404); + }); +}); + +describe("orphan-drift diff under flag", () => { + it("14. under flag, diff route of orphan-drift carries body; example file has diff: null, note: EXAMPLE_CONTENT_HIDDEN", async () => { + const { s, id } = await eight(); + + // Regular orphan-drift file should have body + const orphanDriftPath = `${R}/gone-edited.md`; + const diffResp = await get(`/api/workspaces/${id}/diff?path=${encodeURIComponent(orphanDriftPath)}`, { allowWrites: true }); + expect(diffResp.status).toBe(200); + const body = diffResp.body as any; + expect(body.state).toBe("orphan-drift"); + expect(body.diff).not.toBeNull(); + expect(body.diff).toContain("# Gone edited"); + expect(body.to).toBe(`${orphanDriftPath} (forge: no longer produced — discard removes it)`); + + // Example file in orphan-drift should have no body + { + await fs.rm(registryFile(HOME()), { force: true }); + const EX = MCP_FILES[2]; + const s = kept(await mcpWorkspace()); + await syncOnce(s.wsRoot); + await fs.writeFile(path.join(s.forgeRoot, "recipes/base.yaml"), YAML.stringify(recipe("base", ["rule/keep"]))); + await fs.appendFile(path.join(s.wsRoot, EX), "\n"); + const ws = await loadWorkspace(s.wsRoot, { home: HOME() }); + const entry = await register(HOME(), ws, await plan(ws)); + const id = workspaceId(entry.path); + + const exDiff = await get(`/api/workspaces/${id}/diff?path=${encodeURIComponent(EX)}`, { allowWrites: true }); + expect(exDiff.status).toBe(200); + const exBody = exDiff.body as any; + expect(exBody.state).toBe("orphan-drift"); + expect(exBody.diff).toBeNull(); + expect(exBody.note).toBe(EXAMPLE_CONTENT_HIDDEN); + } + }); +}); + +describe("routePattern and WRITE_PATTERNS", () => { + it("15. routePattern names the two write routes; invalid id is still null", () => { + const id = "0123456789abcdef"; + expect(routePattern(`/api/workspaces/${id}/sync`)).toBe("/api/workspaces//sync"); + expect(routePattern(`/api/workspaces/${id}/discard`)).toBe("/api/workspaces//discard"); + expect(WRITE_PATTERNS).toEqual(["/api/workspaces//sync", "/api/workspaces//discard"]); + + // Invalid id (not 16 hex) + expect(routePattern("/api/workspaces/invalid/sync")).toBeNull(); + expect(routePattern("/api/workspaces/0123456789ABCDEF/sync")).toBeNull(); // uppercase + expect(routePattern("/api/workspaces/0123456789abcde/sync")).toBeNull(); // 15 chars + }); +}); diff --git a/test/write-actions.test.ts b/test/write-actions.test.ts index 473dc14..5bcaea4 100644 --- a/test/write-actions.test.ts +++ b/test/write-actions.test.ts @@ -8,7 +8,7 @@ import path from "node:path"; import YAML from "yaml"; import { loadWorkspace, plan, readLock, status } from "../src/core/sync.js"; import { readRegistry, registryFile } from "../src/core/registry.js"; -import { syncWorkspace, editRecipes, statusDigest, confirmWrites } from "../src/core/write-actions.js"; +import { syncWorkspace, editRecipes, statusDigest, confirmWrites, syncToolResult } from "../src/core/write-actions.js"; import { eightStates, type Scenario } from "./helpers/states.js"; import { tmpDir, scenario, rule, recipe, profile, writeFiles } from "./helpers/forge.js"; @@ -418,4 +418,68 @@ describe("confirmWrites", () => { expect(questions).toEqual([QUESTION, QUESTION]); expect(said).toEqual(["answer yes or no"]); }); +}); + +describe("syncToolResult", () => { + it("builds the sync tool result with skipped reasons and removed reasons", async () => { + const home = await tmpDir("home-"); + cleanups.push(() => fs.rm(home, { recursive: true, force: true })); + const s = kept(await eightStates()); + + const ws = await loadWorkspace(s.wsRoot, { home }); + const p = await plan(ws); + const lock = await readLock(ws.root); + const st = await status(ws, p, lock); + + const outcome = await syncWorkspace(ws, p, st, lock, { + home, + registryOff: true, + }); + + const result = syncToolResult(ws, p, st, outcome, true); + + expect(result).toEqual({ + profile: "acme", + recipes: ["base"], + targets: ["claude-code"], + forge: null, + written: outcome.result.written, + removed: [{ path: ".claude/rules/gone.md", reason: "orphan: no longer produced by the Forge" }], + skipped: [ + { path: ".claude/rules/clash.md", state: "collision", reason: "exists but was never generated by craftar and differs from the Forge — rename it or import it" }, + { path: ".claude/rules/edited.md", state: "drift", reason: "hand-edited since last sync — `craftar drift show `, then `craftar drift discard` or `craftar drift promote`" }, + { path: ".claude/rules/gone-edited.md", state: "orphan-drift", reason: "no longer produced by the Forge but hand-edited — kept; `craftar drift discard ` removes it, or delete it yourself" }, + ], + warnings: p.warnings, + registry: "off", + }); + }); + + it("marks the discarded path with the discard reason; other removed paths keep spec 41's orphanReason", async () => { + const home = await tmpDir("home-"); + cleanups.push(() => fs.rm(home, { recursive: true, force: true })); + const s = kept(await eightStates()); + + const ws = await loadWorkspace(s.wsRoot, { home }); + const p = await plan(ws); + const lock = await readLock(ws.root); + const st = await status(ws, p, lock); + + // Include the orphan-drift path in overwritePaths to simulate drift discard + const orphanDriftPath = ".claude/rules/gone-edited.md"; + const outcome = await syncWorkspace(ws, p, st, lock, { + home, + registryOff: true, + overwritePaths: new Set([orphanDriftPath]), + }); + + // Both orphan (gone.md) and orphan-drift (gone-edited.md) are removed + expect(outcome.result.removed.sort()).toEqual([".claude/rules/gone-edited.md", ".claude/rules/gone.md"]); + + // With discarded: the discarded path gets the special reason + const result = syncToolResult(ws, p, st, outcome, true, orphanDriftPath); + + expect(result.removed).toContainEqual({ path: orphanDriftPath, reason: "hand-edited orphan: discarded" }); + expect(result.removed).toContainEqual({ path: ".claude/rules/gone.md", reason: "orphan: no longer produced by the Forge" }); + }); }); \ No newline at end of file