diff --git a/README.md b/README.md index f3d7083..197318b 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`; it reads only. `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. +> 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`; it reads only. `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. ## The idea in one paragraph @@ -43,6 +43,15 @@ craftar init --workspace C:/Projects/acme-portal-workspace \ The workspace follows the profile's recipes and targets; `--add-recipe` / `--remove-recipe` adjust the recipes, and `--targets` writes the targets into `craftar.yaml`. When the directory already holds a harness, files equal to the Forge's are adopted and different ones are left as they are and reported — `craftar import` (below) brings them into the Forge. +A new client's first workspace starts its profile too: + +```bash +# Creates profiles/initech/profile.yaml in the Forge directory (not committed), then the workspace on it +craftar init --workspace C:/Projects/initech-portal-workspace \ + --forge C:/Projects/forge \ + --new-profile initech --like acme-portal +``` + ## Quick start: bring an existing workspace into a Forge ```bash @@ -68,7 +77,7 @@ From then on, change a rule in `forge/ingredients/rules//rule.md`, run `cr | Command | What it does | |---|---| -| `craftar init [--forge ] [--profile ] [--ref ] [--targets ] [--add-recipe ]... [--remove-recipe ]... [--replace] [--no-sync] [--clone \| --no-clone] [--offline] [--workspace .]` | Starts a workspace: writes a `craftar.yaml` holding `forge` (a directory as the path from the workspace, POSIX; a URL as given), `ref` (a remote Forge only), `profile`, `recipes` (only the lists the recipe flags leave non-empty) and `targets` (only with `--targets`; otherwise the workspace follows the profile's), then runs the first `sync` — which also registers the workspace. **In a terminal** (stdin and stdout both a TTY), `--forge` and `--profile` are optional: what is missing is asked for — the Forge (offering the one last synced on this machine, from the registry), a remote Forge's ref, the profile (from the Forge's list), then the recipes and the targets the profile gives, to keep or adjust; a flag given is never asked for. One confirmation before anything is written, showing what the first sync would do. Ctrl-C, the end of input, or `no` at the confirmation: nothing written, exit 1 (`init cancelled — nothing written`). The run ends with an `again craftar init …` line: the same run as flags, for CI. Off a terminal (a pipe, CI), a missing flag is refused, exit 1, naming the flag; where the terminal does not report a TTY, the flags are required. With both flags given nothing is asked, in a terminal or not. The file is written only once the configuration loads (a remote Forge is fetched as `sync` fetches it; `--offline` reads the cache), resolves and plans, and the sync applies that same plan. `--remove-recipe` and `--add-recipe` go through `remove recipe` / `add recipe`'s core (removes first, then adds; `--replace` swaps a slot holder); a call whose every name changes nothing prints its reasons as a `note`. `--workspace` is created when missing. Files already on disk and not in a lock are adopted when equal to the plan and skipped as a `collision` otherwise, never overwritten, with one warning pointing at `craftar import`. `--no-sync` writes `craftar.yaml` only and prints what the first `sync` would do. When a declared parameter a file cites has no value (see `sync`), `craftar.yaml` is still written — the workspace's `overrides.params` is a place for the value — but the first `sync` is not run: the refusal is printed (`error: N declared parameter(s) have no value — craftar.yaml written, sync not run`, with `then run craftar sync` in its fix line), exit 1; in a terminal the confirmation says `first sync: refused — …` and asks only `Write craftar.yaml`; `--no-sync` prints `next sync: refused — …` and exits 0. Refused, with nothing written and no directory created: an existing `craftar.yaml`; a `--workspace` that is a file, or under one; credentials in `--forge` (the value is never printed); `--ref` with a directory Forge; an unknown target; a recipe both added and removed, or any refusal of `add recipe` / `remove recipe`; a `craftar.local.yaml` that sets `forge` or `profile`, or a key a given flag would write (`ref`, `recipes`, `targets`) — its value would replace the flag's; a Forge, profile or plan that does not load. **`--clone`** clones every entry of the profile's `repos` (see *Forge layout › Repositories*) into the workspace after the first `sync` — one `git clone --no-recurse-submodules [--branch=] -- ` per entry, one after another, with the workspace root as working directory, `git` found in the absolute entries of `PATH` and `GIT_ALLOW_PROTOCOL=https:ssh`; git's own output and credential prompt go to the terminal, with no timeout. With `--no-sync`, `--clone` still clones. Without `--clone` nothing is cloned: with both `--forge` and `--profile` given nothing is asked and nothing is said about repositories; in a terminal, when a flag is missing, a profile with entries is listed — name, URL, destination, ref — and `Clone them into the workspace [no]:` is asked (Enter is no), except under `--offline` or `--no-clone`. A clone that fails is reported with the command to run it by hand and `craftar repos clone `, the other entries are still tried, and `init` exits 1 with the workspace written, synced and registered. A first sync that would be refused clones nothing (`repositories: not cloned — the first sync is refused`). Refused before anything is written: `--clone` with `--no-clone`, or with `--offline`; `--clone` with no `git` on `PATH`, on a profile with no entry, with an entry that does not read, or with a destination that exists, holds a file `sync` writes, lies inside the Forge, leaves the workspace through a link or sits under a file. A clone is recorded nowhere — not in `craftar.lock`, not in the registry; `sync` never updates or removes it, and craftar does not edit a `.gitignore` (in a workspace that is itself a repository the clones show as untracked directories). | +| `craftar init [--forge ] [--profile ] [--new-profile [--like ]] [--ref ] [--targets ] [--add-recipe ]... [--remove-recipe ]... [--replace] [--no-sync] [--clone \| --no-clone] [--offline] [--workspace .]` | Starts a workspace: writes a `craftar.yaml` holding `forge` (a directory as the path from the workspace, POSIX; a URL as given), `ref` (a remote Forge only), `profile`, `recipes` (only the lists the recipe flags leave non-empty) and `targets` (only with `--targets`; otherwise the workspace follows the profile's), then runs the first `sync` — which also registers the workspace. **In a terminal** (stdin and stdout both a TTY), `--forge` and `--profile` are optional: what is missing is asked for — the Forge (offering the one last synced on this machine, from the registry), a remote Forge's ref, the profile (from the Forge's list), then the recipes and the targets the profile gives, to keep or adjust; a flag given is never asked for. One confirmation before anything is written, showing what the first sync would do. Ctrl-C, the end of input, or `no` at the confirmation: nothing written, exit 1 (`init cancelled — nothing written`). The run ends with an `again craftar init …` line: the same run as flags, for CI. Off a terminal (a pipe, CI), a missing flag is refused, exit 1, naming the flag; where the terminal does not report a TTY, the flags are required. With both flags given nothing is asked, in a terminal or not. The file is written only once the configuration loads (a remote Forge is fetched as `sync` fetches it; `--offline` reads the cache), resolves and plans, and the sync applies that same plan. `--remove-recipe` and `--add-recipe` go through `remove recipe` / `add recipe`'s core (removes first, then adds; `--replace` swaps a slot holder); a call whose every name changes nothing prints its reasons as a `note`. `--workspace` is created when missing. Files already on disk and not in a lock are adopted when equal to the plan and skipped as a `collision` otherwise, never overwritten, with one warning pointing at `craftar import`. `--no-sync` writes `craftar.yaml` only and prints what the first `sync` would do. When a declared parameter a file cites has no value (see `sync`), `craftar.yaml` is still written — the workspace's `overrides.params` is a place for the value — but the first `sync` is not run: the refusal is printed (`error: N declared parameter(s) have no value — craftar.yaml written, sync not run`, with `then run craftar sync` in its fix line), exit 1; in a terminal the confirmation says `first sync: refused — …` and asks only `Write craftar.yaml`; `--no-sync` prints `next sync: refused — …` and exits 0. Refused, with nothing written and no directory created: an existing `craftar.yaml`; a `--workspace` that is a file, or under one; credentials in `--forge` (the value is never printed); `--ref` with a directory Forge; an unknown target; a recipe both added and removed, or any refusal of `add recipe` / `remove recipe`; a `craftar.local.yaml` that sets `forge` or `profile`, or a key a given flag would write (`ref`, `recipes`, `targets`) — its value would replace the flag's; a Forge, profile or plan that does not load. **`--clone`** clones every entry of the profile's `repos` (see *Forge layout › Repositories*) into the workspace after the first `sync` — one `git clone --no-recurse-submodules [--branch=] -- ` per entry, one after another, with the workspace root as working directory, `git` found in the absolute entries of `PATH` and `GIT_ALLOW_PROTOCOL=https:ssh`; git's own output and credential prompt go to the terminal, with no timeout. With `--no-sync`, `--clone` still clones. Without `--clone` nothing is cloned: with both `--forge` and `--profile` given nothing is asked and nothing is said about repositories; in a terminal, when a flag is missing, a profile with entries is listed — name, URL, destination, ref — and `Clone them into the workspace [no]:` is asked (Enter is no), except under `--offline` or `--no-clone`. A clone that fails is reported with the command to run it by hand and `craftar repos clone `, the other entries are still tried, and `init` exits 1 with the workspace written, synced and registered. A first sync that would be refused clones nothing (`repositories: not cloned — the first sync is refused`). Refused before anything is written: `--clone` with `--no-clone`, or with `--offline`; `--clone` with no `git` on `PATH`, on a profile with no entry, with an entry that does not read, or with a destination that exists, holds a file `sync` writes, lies inside the Forge, leaves the workspace through a link or sits under a file. A clone is recorded nowhere — not in `craftar.lock`, not in the registry; `sync` never updates or removes it, and craftar does not edit a `.gitignore` (in a workspace that is itself a repository the clones show as untracked directories). **`--new-profile `** creates the profile first: one new file, `profiles//profile.yaml`, in the Forge's working tree — its `name`, `recipes` and `targets` only — then starts the workspace on it as for a profile that was there. The Forge must be a directory that is a git repository with a commit (git is how the write is undone); nothing is committed, no branch is made and nothing is pushed — commit the file in the Forge, on a branch, before anyone else syncs. With `--new-profile`, `--add-recipe`, `--remove-recipe`, `--replace` and `--targets` shape **the profile**, and `craftar.yaml` gets no `recipes` and no `targets`. `--like ` starts the new profile's `recipes` and `targets` from that profile's written lists — nothing else of it is copied: no `params`, `sections`, `integrations`, `repos`, `templates` or `description`; in a Forge at schema 3 or above a recipe only its `integrations` block selects does not come along, and a `note` line says so. The name is lowercase letters and digits in groups joined by one `-` (no `--`, not a Windows device name). Refused before anything is written: `--new-profile` with `--profile`, `--like` without `--new-profile`, a name of another shape, a Forge URL (clone it and pass the directory), a tree of craftar's Forge cache, a directory that is not a git repository with a commit, a name a profile already carries or a `profiles/` that exists, a path git ignores or that goes through a symbolic link, a recipe named for another profile (`--

`, `

-steering` — add the shared recipe it stands for), and a profile that does not resolve or plan. The Forge is planned in memory, the file written, then the Forge read back from disk must plan the same bytes before `craftar.yaml` is written; a failure after the profile was written exits 1 and prints the `git -C clean -fd -- profiles/` line that removes it. In a terminal the profile list offers `0) a new profile` (for a Forge directory that can take one), and a Forge directory with no profile goes straight to the new profile's questions; the `again` line then names `--profile `, the run a second workspace of that client uses. | | `craftar repos [--workspace .] [--offline] [--json]` | Lists the profile's repositories (its `repos`, see *Forge layout › Repositories*) with their state in the workspace — `present` (the destination exists; craftar does not look inside), `missing`, or `blocked` with the reason a clone would be refused — and each one's destination, URL and ref. It loads the Forge as `status` does (a remote Forge is fetched unless `--offline`; a failed fetch is a warning over the cached copy), starts no other program, contacts no repository's host and writes nothing. A profile with no entry prints `profile

lists no repositories`. Exits 0 whenever the block reads, missing entries included; 1 for a block that does not read, or a workspace that does not load or plan, as `status`. `--json`, a tooling contract: `{workspace, profile, repos: [{name, url, path, ref, state, reason}], warnings}` (`ref` and `reason` `null` when absent). | | `craftar repos clone […] [--workspace .]` | Clones the `missing` entries — every one, or the names given (compared without case) — as `init --clone` clones them; typing the command is the consent, and nothing is asked. A `present` entry is skipped with a line; a named one is refused. Refused before any clone, exit 1: no `git` on `PATH`; a remote Forge that cannot be fetched (it never clones from a cached copy, and has no `--offline`); a profile with no entry; a block that does not read; any `blocked` entry; a name that is not an entry, or is given twice. It clones whatever the workspace's sync state, and exits 1 when a clone fails, with the command to run it by hand. It writes the clones only: no `craftar.yaml`, lock or registry entry. | | `craftar import --from claude-code --forge

--profile [--workspace .] [--write-config] [--report ] [--prefer-kiro ]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering — and, with `--prefer-kiro`, the text of the rules it names). A rule's scope comes, in this order, from: a hand-written `.kiro/steering/.md` whose frontmatter sets `inclusion`; a usable `paths:` in the rule's own frontmatter — a non-empty one-line string or a list of them, quoted or not (`- **/*.ts` is read as Claude Code reads it) — which gives `inclusion: fileMatch` and `fileMatchPattern`; a steering mirror craftar generated (its default banner); any other steering file, as before; and `always`. When `paths:` is usable the rule's frontmatter is kept verbatim apart from its body (`frontmatterRaw`), whichever source set the scope, so `.claude/rules/.md` is written back byte for byte and neither `.kiro/steering/.md` nor `AGENTS.md` carries it — except a file whose closing `---` is not followed by a newline, which keeps its frontmatter in its body and is written back as is; its steering mirror — and `AGENTS.md`, where it embeds the rule — then carry the block. What the workspace sees next, for a rule a craftar before this one read as always-on: its `.kiro/steering/.md`, `AGENTS.md` and the `.kiro/agents/*.json` of agents bound to the rule now differ from the plan — where `craftar.lock` records them unedited, `sync` updates them; with no lock entry for them (a first import), they read `collision` and are left alone: delete them and run `craftar sync`. Four warnings name the rule and its files, never the pattern: the hand-written steering file and `paths:` disagree (the steering file's scope is kept); the generated mirror disagrees with `paths:` (scope taken from `paths:`); the steering file sets no `inclusion` (scope taken from `paths:`); `paths:` is not a string or a list of strings (ignored). With `--prefer-kiro`, a steering body that starts with a frontmatter block (as an earlier craftar wrote a `paths:` rule) is taken with it, as before, and the rule keeps no separate frontmatter. Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml` (a manifest already at 2 or above is left as it is). A reused base takes the place its `--` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `--` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `--`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections ` when a section value filled the render), `inferred`, `sectioned`, `param : → `, `section : → `, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created\|edited\|unchanged` lines. In a Forge at schema 3 or above, what the profile's `integrations` block alone provides is **not imported** — not compared, not written — and is listed (`left to recipe (integrations.): …`, and under `## Left to an integration recipe` in the `--report` file); `craftar status` then shows whether the workspace's copies differ (`drift`, `collision`); move an agent attached to a scoped rule into the integration recipe with its rule, or `base` splits on import. It refuses, with the Forge untouched, a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand), and — in a Forge at schema 3 or above — a profile whose `integrations` block is malformed or, when the block holds a `kind`, that does not resolve. Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. `authEnv` is never inferred; a variant import rewrites keeps the one it declares, a new variant takes its base's, and a declaration a profile loses is reported. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. `--report ` also writes a Markdown report, after the Forge is written: what was created, reused, made a variant or rejected, the import's warnings, and the Kiro side — *Kiro collisions*, every `.kiro/` file that differs from what `sync` would generate for the imported profile (`status`' `collision` state against no lock, so a difference of line endings, BOM or JSON formatting is not one), each with its ingredient and the line counts of the two sides, a rule's steering mirror that outgrew its rule marked `steering bigger than the rule` (not one `--prefer-kiro` took the rule from); and *Unsourced Kiro files*, the files under `.kiro/steering/`, `.kiro/agents/`, `.kiro/skills/` and `.kiro/settings/mcp.json` that the plan for the imported profile does not write — listed, never imported. The report holds **paths and line counts, never content** (a `.kiro/` mirror is not scanned for secrets), and names an inferred parameter's key, not its value; a warning, a rejection reason or the `not computed` line is written with every parameter and section value this run accepted, replaced or inferred as `"…"`, and a variant is named against its base, without its reason — the summary on the terminal keeps them. It describes the profile as imported: of the workspace's own `craftar.yaml` and `craftar.local.yaml` only `overrides.params` and `overrides.sections` are read (as import reads them to decide a reuse) — not its `forge`, `profile`, `targets` or recipes — and `craftar.lock` is not read. The path may be inside the workspace; it is refused, before anything is imported, when it resolves inside the Forge (through symlinks and `..`), when a file already exists there, or when a component of it cannot be resolved. If the Kiro side cannot be computed — the plan fails, or `sync` would refuse it for a declared parameter with no value — its sections read `not computed: …`, the same is an import warning, and the import still exits 0. The report is written after the summary, which then ends with `report `; if that write fails the import has still succeeded and the Forge is written, and the command says so and exits 1. Without `--report` none of this is computed. `--prefer-kiro ` takes the named rules from `.kiro/steering/.md` instead of `.claude/rules/.md` — for a steering mirror that was edited by hand and is the text to keep; a rule left to an integration recipe is warned (`nothing to prefer; its steering file was not used`) and skipped, before that rule's own refusals (no steering file, a form with no certain source, a line that would not come back). The rule's text is built from the steering file — its frontmatter and its opening banner (the default one, or the line rendered by the `kiro.banner` sync would use: the workspace's `overrides.params`, else — for a profile already in the Forge — the profile's own or a default of a recipe it resolves) dropped, `.kiro/steering/` turned back into `.claude/rules/` except in a reference to a hand-written steering file and under `commands/` — and is accepted only when it is **proved to render back** to that steering file's body for Kiro over the workspace's own rules (every rule aimed at every target, as import writes them); it is then scanned for secrets and decided like any source (reused, a variant, or created; a rejection names the steering file's line, and a rejected rule gets no `prefer-kiro` line), its `inclusion` and `fileMatchPattern` still read from the steering frontmatter. Refused, with the Forge untouched: an empty or repeated name; a name with no `.claude/rules/.md` (the flag takes rules; a steering file with no rule is already imported as steering); a rule with no `.kiro/steering/.md`; a steering file holding a form Kiro writes for a rule it does not have (`AGENTS.md (rule: )`, a link to `AGENTS.md`, `rule not in this workspace`), which has no certain source; and a steering file that would not come back from the rule built from it (it names `.claude/rules/` where `sync` writes `.kiro/steering/`) — the last two name the line of the steering file and quote none of it. The ingredient's `origin.path` is the steering file; the summary gains `prefer-kiro — source .kiro/steering/.md (N lines; .claude/rules/.md has M)` per rule — N and M count the two files whole, the steering file's frontmatter and banner included — and `--report` a *Preferred from Kiro* section with the same lines. What the workspace sees next: the Forge rule now differs from `.claude/rules/.md` on disk, so with no lock entry for that file the next `sync` shows it as `collision` and leaves it alone — delete it (and the steering file, if it also collides) and run `craftar sync` to regenerate both; where `craftar.lock` already records the file unedited, `sync` updates it. The summary says so in a `note` for each rule whose text changed (after the warnings, before the `report` line). Until the workspace converges, an import without the flag reads `.claude/rules/.md` again and lands that older text as a `--` variant: keep the flag, or converge first. Rules only: commands, agents and skills are not taken from Kiro. In a schema 4 Forge an MCP base is compared as `sync` renders it (its declared params substituted into its `server`): a workspace whose server equals that rendering reuses the base — unless the base cites a declared key that neither the profile nor the workspace sets while some recipe of the Forge defaults it (import cannot know which value `sync` will render): that one becomes a variant whatever it holds; one whose server differs becomes a variant holding the server as the workspace has it, braces included, with no `params` (it declares nothing, so `sync` writes any braces in it back, with the undeclared-key warning whenever a layer gives that key a value). No value is ever inferred into a profile from a server. | @@ -504,7 +513,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`). +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`). 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). diff --git a/package-lock.json b/package-lock.json index 263ec35..3a16f75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.33.0", + "version": "0.34.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.33.0", + "version": "0.34.0", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 07e85c5..6a4ecfd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.33.0", + "version": "0.34.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 a06f717..f73c746 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import pc from "picocolors"; import path from "node:path"; -import { promises as fs } from "node:fs"; +import { promises as fs, statSync } from "node:fs"; import YAML from "yaml"; import { importClaudeCode } from "./importers/claude-code.js"; import { parsePreferKiro } from "./importers/prefer-kiro.js"; @@ -16,7 +16,7 @@ import { launchRefusal, pinOf } from "./core/mcp-protocol.js"; import { serve } from "./mcp.js"; import { pruneCache, type PruneResult as CachePruneResult } from "./core/cache.js"; import { resolveHome } from "./core/home-lock.js"; -import { loadWorkspace, readWorkspaceConfig, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, agentsMdOverBudget, agentsMdRefusal, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE, type ApplyResult, type FetchMode, type FileState, type FileStatus, type LoadOptions, type MergedConfig, type Plan, type SectionLayer, type UnsetParam, type Workspace } from "./core/sync.js"; +import { loadWorkspace, loadForgeSource, readWorkspaceConfig, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, agentsMdOverBudget, agentsMdRefusal, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE, type ApplyResult, type FetchMode, type FileState, type FileStatus, type LoadOptions, type MergedConfig, type Plan, type SectionLayer, type UnsetParam, type Workspace } from "./core/sync.js"; import type { Lock, Registry } from "./schema/index.js"; import { sectionKey, type ParamLayer } from "./core/resolve.js"; import { catalogueContext, listRecipes, listIngredients, checkType, withLoadWarnings, type CatalogueContext, type ContextSource } from "./core/catalogue.js"; @@ -45,7 +45,10 @@ import { type PruneResult, } from "./core/unify.js"; import { checkParamWrites, writeParamFile } from "./core/param-writes.js"; -import { checkInitFlags, initLine, planInit, type InitInput } from "./core/init.js"; +import { checkInitFlags, checkLocalKeys, initLine, planInit, forgeSource, type InitInput } from "./core/init.js"; +import { profileName } from "./core/new-profile.js"; +import { newProfileGate } from "./core/new-profile-gate.js"; +import { writeNewProfile, reproveFromDisk, NewProfileRaceLost, NewProfileLateFailure } from "./core/new-profile-write.js"; import { askInit, askClone, confirmInit, againLine, NOT_CLONED_REFUSED, type InitAnswers } from "./core/init-flow.js"; import { findGit, cloneRepos, listRepos, planClone, type CloneResult, type DestinationContext, type RepoState } from "./core/clone.js"; import { byHand, profileRepos, type RepoEntry } from "./core/repos.js"; @@ -71,7 +74,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.33.0"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.34.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. */ @@ -327,14 +330,16 @@ async function runCloneStep(root: string, profile: string, entries: RepoEntry[], program .command("init") .description( - "Start a workspace from a Forge and a profile: write a craftar.yaml proved to resolve and plan, then run the first sync; with --clone, clone the profile's repositories into the workspace (after the first sync, or with --no-sync after writing craftar.yaml); in a terminal, asks for the Forge and the profile not given as flags; refused when craftar.yaml already exists", + "Start a workspace from a Forge and a profile: write a craftar.yaml proved to resolve and plan, then run the first sync; with --new-profile, create the profile in the Forge directory first (recipes and targets from --like and the recipe/target flags), then start the workspace on it — nothing is committed; with --clone, clone the profile's repositories into the workspace (after the first sync, or with --no-sync after writing craftar.yaml); in a terminal, asks for the Forge and the profile not given as flags; refused when craftar.yaml already exists", ) .option("--forge ", "the Forge: a directory (written relative to the workspace) or a git URL; asked for in a terminal when omitted") .option("--profile ", "the client profile in the Forge; asked for in a terminal when omitted") + .option("--new-profile ", "create this profile in the Forge directory (recipes and targets from --like and the recipe flags), then start the workspace on it; nothing is committed") + .option("--like ", "with --new-profile: start the new profile's recipes and targets from this profile's — nothing else of it is copied") .option("--ref ", "branch, tag or full SHA of a remote Forge") - .option("--targets ", "targets to write in craftar.yaml (claude-code, kiro, agents-md); omitted, the workspace follows the profile's") - .option("--add-recipe ", "add a recipe to the profile's (repeatable)", collect, []) - .option("--remove-recipe ", "remove a recipe of the profile's (repeatable)", collect, []) + .option("--targets ", "targets to write in craftar.yaml, or of the new profile with --new-profile (claude-code, kiro, agents-md); omitted, the workspace follows the profile's") + .option("--add-recipe ", "add a recipe to the profile's, or to the new profile's with --new-profile (repeatable)", collect, []) + .option("--remove-recipe ", "remove a recipe from the profile's, or from the new profile's with --new-profile (repeatable)", collect, []) .option("--replace", "let --add-recipe take a slot another recipe holds", false) .option("--no-sync", "write craftar.yaml only, and say what the first sync would do") .option("--clone", "after the first sync, clone the profile's repositories (its repos list) into the workspace") @@ -345,6 +350,8 @@ program async (o: { forge?: string; profile?: string; + newProfile?: string; + like?: string; ref?: string; targets?: string; addRecipe: string[]; @@ -357,7 +364,8 @@ program }, cmd) => { const root = path.resolve(o.workspace); // N1: craftar.yaml already exists - const n1 = () => fail(`${WORKSPACE_FILE} already exists in ${root} — change recipes with craftar add recipe / remove recipe, or edit it`); + const n1Text = `${WORKSPACE_FILE} already exists in ${root} — change recipes with craftar add recipe / remove recipe, or edit it`; + const n1 = () => fail(n1Text); const file = path.join(root, WORKSPACE_FILE); if (await exists(file)) n1(); // N9: --workspace is a file, or any ancestor up to an existing path is a file @@ -395,14 +403,28 @@ program if (o.clone === true && git === null) { fail("cloning needs git — install it, or leave the clone out"); } + // P1–P4: --new-profile checks (before anything is read) + if (o.newProfile !== undefined && o.profile !== undefined) { + fail("pass --profile or --new-profile, not both"); + } + if (o.like !== undefined && o.newProfile === undefined) { + fail("--like goes with --new-profile"); + } + if (o.newProfile !== undefined) { + profileName(o.newProfile); + } + if (o.newProfile !== undefined && o.forge !== undefined && classifyForge(o.forge) === "url") { + fail("a new profile is written into a Forge directory — clone the Forge, pass that directory as --forge, then commit and push the profile"); + } // Mode: flags when both given; else interactive when both streams are TTYs; else N2 - const bothGiven = o.forge !== undefined && o.profile !== undefined; + // --new-profile counts as --profile for bothGiven and N2 + const bothGiven = o.forge !== undefined && (o.profile !== undefined || o.newProfile !== undefined); const isTTY = process.stdin.isTTY && process.stdout.isTTY; if (!bothGiven && !isTTY) { // N2: refuse with our message - if (o.forge === undefined && o.profile === undefined) { + if (o.forge === undefined && o.profile === undefined && o.newProfile === undefined) { fail("--forge and --profile are required when craftar init does not run in a terminal — pass them, or run craftar init in a terminal to be asked"); - } else if (o.profile === undefined) { + } else if (o.profile === undefined && o.newProfile === undefined) { fail("--profile is required when craftar init does not run in a terminal — pass it, or run craftar init in a terminal to be asked"); } else { fail("--forge is required when craftar init does not run in a terminal — pass it, or run craftar init in a terminal to be asked"); @@ -419,6 +441,8 @@ program { forge: o.forge, profile: o.profile, + newProfile: o.newProfile, + like: o.like, ref: o.ref, targets: o.targets === undefined ? undefined : o.targets.split(",").map((t) => t.trim()), addRecipes: o.addRecipe, @@ -433,6 +457,31 @@ program if (result.kind === "cancelled") fail("init cancelled — nothing written"); input = result.input; answers = result.answers; + } else if (o.newProfile !== undefined) { + // --forge and --new-profile given (flags mode with new profile) + // N10 (spec 38 §4.2): the one check, before the load — --new-profile counts as --profile there + checkLocalKeys(local.keys, { + ref: o.ref !== undefined, + recipes: o.addRecipe.length > 0 || o.removeRecipe.length > 0, + targets: o.targets !== undefined, + }); + // Load the Forge + const loaded = await loadForgeSource(root, forgeSource(root, o.forge!), o.ref ?? null, { ...load(o, "sync") }); + // Gate: P5-P8 + await newProfileGate(loaded.forge, resolveHome(process.env.CRAFTAR_HOME || undefined), o.newProfile); + // Build input + input = { + forge: o.forge!, + profile: o.newProfile, + newProfile: { name: o.newProfile, like: o.like }, + ref: o.ref, + targets: o.targets === undefined ? undefined : o.targets.split(",").map((t) => t.trim()), + addRecipes: o.addRecipe, + removeRecipes: o.removeRecipe, + replace: o.replace, + clone: o.clone === true, + loaded, + }; } else { input = { forge: o.forge!, @@ -445,7 +494,7 @@ program clone: o.clone === true, }; } - const init = await planInit(root, input, { ...load(o, "sync"), local }); + let init = await planInit(root, input, { ...load(o, "sync"), local }); // Interactive mode: when o.clone is undefined, ask about cloning between planInit and confirmInit // --clone typed: planInit has checked C3–C5 and returned the entries, in either mode let cloneEntries: RepoEntry[] = o.clone === true ? init.repos : []; @@ -466,24 +515,77 @@ program }); if (confirm === "cancelled") fail("init cancelled — nothing written"); } - const { ws, plan: p } = init; + let { ws, plan: p } = init; + const forgeRoot = ws.forge.root; const workspaceGiven = cmd.getOptionValueSource("workspace") === "cli"; const again = () => { if (answers !== null) { console.log(againLine(answers, { workspace: workspaceGiven ? o.workspace : undefined, sync: o.sync, offline: o.offline, clone: cloneEntries.length > 0 })); } }; + // When init.newProfile: write the profile, then reprove from disk + let journal: WriteJournal = []; + // Step 1's notes are printed after the initLine line (spec 38), so keep them before reprove + const step1Notes = init.notes; + if (init.newProfile) { + try { + journal = await writeNewProfile(forgeRoot, init); + } catch (e) { + if (e instanceof NewProfileRaceLost) { + // P7's first message, no recover lines + fail(e.message); + } + if (e instanceof NewProfileLateFailure) { + fail(lateFailure(e, forgeRoot, e.journal, "init")); + } + throw e; + } + // Reprove from disk (step 4) + try { + init = await reproveFromDisk(root, input.forge, init, { ...load(o, "sync") }, journal, input.ref); + } catch (e) { + if (e instanceof NewProfileLateFailure) { + fail(lateFailure(e, forgeRoot, e.journal, "init")); + } + throw e; + } + // Update ws and p from the second init + ws = init.ws; + p = init.plan; + // Print the profile creation lines + console.log(pc.bold(`craftar init — created profile ${ws.config.profile} in ${forgeRoot}`)); + console.log(` + profiles/${ws.config.profile}/profile.yaml (not committed — commit it in the Forge, on a branch, before anyone else syncs)`); + } // Every refusal is above: only now does the directory, and craftar.yaml, come to exist - await fs.mkdir(root, { recursive: true }); - try { - await fs.writeFile(file, init.text, { flag: "wx" }); - } catch (e) { - if ((e as NodeJS.ErrnoException).code === "EEXIST") n1(); - throw e; + // When journal is not empty: mkdir inside the try, with late failure handling + if (journal.length > 0) { + try { + await fs.mkdir(root, { recursive: true }); + } catch (e) { + fail(lateFailure(e, forgeRoot, journal, "init")); + } + try { + await fs.writeFile(file, init.text, { flag: "wx" }); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "EEXIST") { + // N1's text, then lateFailure + fail(`${n1Text}\n${lateFailure(e, forgeRoot, journal, "init")}`); + } + fail(lateFailure(e, forgeRoot, journal, "init")); + } + } else { + // No journal: existing behaviour unchanged + await fs.mkdir(root, { recursive: true }); + try { + await fs.writeFile(file, init.text, { flag: "wx" }); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "EEXIST") n1(); + throw e; + } } console.log(pc.bold(`craftar init — wrote ${WORKSPACE_FILE} in ${root}`)); console.log(` ${initLine(init)}`); - for (const n of init.notes) console.log(` note ${n}`); + for (const n of step1Notes) console.log(` note ${n}`); const unset = unsetDeclared(p); const cloning = cloneEntries.length > 0; const refused = unset.length > 0; @@ -3053,6 +3155,7 @@ function unheldMessage(command: string, root: string, unheld: UnheldPath[]): str * The message for a `forge unify` failure after writing began (Ruling 33): the original error, the * Forge-relative paths already touched, and the git commands that undo them — `checkout` for what * git tracks, `clean` for files unify created (checkout refuses a path git does not know). + * For created paths, directories get `clean -fd`, files (or missing paths) get `clean -f`. */ function lateFailure(e: unknown, root: string, journal: WriteJournal, command = "unify"): string { const msg = errorText(e); @@ -3060,12 +3163,29 @@ function lateFailure(e: unknown, root: string, journal: WriteJournal, command = const rel = (abs: string) => path.relative(root, abs).split(path.sep).join("/"); const quote = (p: string) => (/^[\w./-]+$/.test(p) ? p : `"${p}"`); const restore = [...new Set(journal.filter((j) => !j.created).map((j) => rel(j.abs)))]; - const created = [...new Set(journal.filter((j) => j.created).map((j) => rel(j.abs)))]; + const created = [...new Set(journal.filter((j) => j.created).map((j) => j.abs))]; + // For created paths, check if they are directories + const createdDirs: string[] = []; + const createdFiles: string[] = []; + for (const abs of created) { + try { + const st = statSync(abs); + if (st.isDirectory()) { + createdDirs.push(rel(abs)); + } else { + createdFiles.push(rel(abs)); + } + } catch { + // Path doesn't exist, treat as file + createdFiles.push(rel(abs)); + } + } const lines = [msg, `${command} had already started changing the Forge (${root}) when this failed:`]; - for (const p of [...restore, ...created]) lines.push(` ${p}`); + for (const p of [...restore, ...createdDirs, ...createdFiles]) lines.push(` ${p}`); lines.push("recover with:"); if (restore.length) lines.push(` git -C ${quote(root)} checkout -- ${restore.map(quote).join(" ")}`); - if (created.length) lines.push(` git -C ${quote(root)} clean -f -- ${created.map(quote).join(" ")}`); + if (createdDirs.length) lines.push(` git -C ${quote(root)} clean -fd -- ${createdDirs.map(quote).join(" ")}`); + if (createdFiles.length) lines.push(` git -C ${quote(root)} clean -f -- ${createdFiles.map(quote).join(" ")}`); return lines.join("\n"); } diff --git a/src/core/forge.ts b/src/core/forge.ts index d3a3e05..34efe95 100644 --- a/src/core/forge.ts +++ b/src/core/forge.ts @@ -256,10 +256,10 @@ export async function listFiles(dir: string, rel = ""): Promise { /* ---- the scratch copy a Forge proof runs on, and the link check its writes share (spec 30 `drift promote`, spec 29 `forge promote`) ---- */ -const SYMLINK_REFUSAL = (rel: string) => - `${rel} is a symbolic link in the Forge — promote does not write through a link; replace it with the file and commit`; -const SYMLINK_DIR_REFUSAL = (rel: string) => - `${rel} is a symbolic link in the Forge — promote does not read a linked recipes, ingredients or profiles directory; replace it with the directory and commit`; +const SYMLINK_REFUSAL = (rel: string, command: string) => + `${rel} is a symbolic link in the Forge — ${command} does not write through a link; replace it with the file and commit`; +const SYMLINK_DIR_REFUSAL = (rel: string, command: string) => + `${rel} is a symbolic link in the Forge — ${command} does not read a linked recipes, ingredients or profiles directory; replace it with the directory and commit`; const SYMLINK_OUT_OR_DANGLING = (rel: string) => `${rel} is a symbolic link out of the Forge, or to nothing — promote cannot copy the Forge to prove its edit; fix or remove it`; const NON_REGULAR_FILE_REFUSAL = (rel: string) => @@ -312,7 +312,7 @@ export async function copyForgeForProof(forgeDir: string, scratchDir: string): P // A root directory link (ingredients, recipes, profiles) is refused: loadForge follows it // but the scratch would skip it, so the scratch would not load the same as the real Forge. if (rel === "" && ROOT_DIRS.has(ent.name)) { - throw new Error(SYMLINK_DIR_REFUSAL(ent.name)); + throw new Error(SYMLINK_DIR_REFUSAL(ent.name, "promote")); } // Other directory links are skipped entirely — loadForge doesn't traverse them continue; @@ -357,7 +357,7 @@ export async function copyForgeForProof(forgeDir: string, scratchDir: string): P * Refuse a write through a symbolic link: every path, and every ancestor between it and `forgeDir`, that exists must * not be one. A path that does not exist yet is fine — it will be created. */ -export async function assertNoLinkOnPath(forgeDir: string, paths: string[]): Promise { +export async function assertNoLinkOnPath(forgeDir: string, paths: string[], command = "promote"): Promise { const checked = new Set(); for (const abs of paths) { // The path and every ancestor up to but not including forgeDir @@ -371,9 +371,9 @@ export async function assertNoLinkOnPath(forgeDir: string, paths: string[]): Pro const rel = path.relative(forgeDir, current).split(path.sep).join("/"); // The directory-specific message for a root directory (ingredients, recipes, profiles) if (ROOT_DIRS.has(rel)) { - throw new Error(SYMLINK_DIR_REFUSAL(rel)); + throw new Error(SYMLINK_DIR_REFUSAL(rel, command)); } - throw new Error(SYMLINK_REFUSAL(rel)); + throw new Error(SYMLINK_REFUSAL(rel, command)); } } catch (e) { // ENOENT means the path doesn't exist yet (it will be created) — that's fine diff --git a/src/core/init-flow.ts b/src/core/init-flow.ts index b5bbfa4..7b8c4a9 100644 --- a/src/core/init-flow.ts +++ b/src/core/init-flow.ts @@ -1,9 +1,11 @@ import { existsSync } from "node:fs"; -import { TARGETS, type Registry, type RegistryEntry, type WorkspaceConfig } from "../schema/index.js"; +import { ProfileSchema, TARGETS, type Registry, type RegistryEntry, type WorkspaceConfig } from "../schema/index.js"; import { planClone } from "./clone.js"; import { countStates } from "./impact.js"; import { resolveHome } from "./home-lock.js"; import { checkInitFlags, checkLocalKeys, forgeSource, initLine, type InitInput, type InitPlan } from "./init.js"; +import { newProfile, ownedBy, ownedRecipeRefusal, profileName, withProfile } from "./new-profile.js"; +import { newProfileGate } from "./new-profile-gate.js"; import { planRecipeEdit, slotHeld } from "./recipe-edit.js"; import { readRegistry } from "./registry.js"; import { classifyForge, credentialFault, CREDENTIAL_HIDDEN, refHoldsCredentials } from "./remote.js"; @@ -171,7 +173,17 @@ export async function confirmInit( io: InitIo, opts: { sync: boolean; clone?: number; cloneRefused?: boolean }, ): Promise<"confirmed" | "cancelled"> { - io.say(`craftar init — about to write craftar.yaml in ${root}`); + // Spec 38 §4.3: blockNote first when there is one + if (init.newProfile?.blockNote) { + io.say(` note ${init.newProfile.blockNote}`); + } + + // Spec 38 §4.3: the first line changes with newProfile + if (init.newProfile) { + io.say(`craftar init — about to create profile ${init.ws.config.profile} in ${init.ws.forge.root} and write craftar.yaml in ${root}`); + } else { + io.say(`craftar init — about to write craftar.yaml in ${root}`); + } io.say(` ${initLine(init)}`); const unset = unsetDeclared(init.plan); @@ -190,10 +202,19 @@ export async function confirmInit( io.say(NOT_CLONED_REFUSED); } - // Build the question + // Build the question: spec 38 §4.3 — with newProfile, the question names both writes let question: string; const clone = opts.clone ?? 0; - if (unset.length > 0) { + const profileName = init.newProfile ? init.ws.config.profile : null; + + if (init.newProfile) { + // New profile: different question forms + if (unset.length > 0 || !opts.sync) { + question = `Create profiles/${profileName}/profile.yaml (not committed) and write craftar.yaml [yes]: `; + } else { + question = `Create profiles/${profileName}/profile.yaml (not committed), write craftar.yaml and sync [yes]: `; + } + } else if (unset.length > 0) { // Sync refused → offer only craftar.yaml question = "Write craftar.yaml [yes]: "; } else if (clone > 0) { @@ -274,6 +295,10 @@ export interface InitGiven { removeRecipes: string[]; replace: boolean; clone?: boolean; + /** The new profile's name (spec 38 §4.3). */ + newProfile?: string; + /** The template profile (spec 38 §4.3). */ + like?: string; } export type AskResult = @@ -377,6 +402,12 @@ export async function askInit( continue forgeLoop; } } + + // P4 check: with --new-profile typed and Forge asked, a URL answer is refused — say P4 and ask again + if (given.newProfile !== undefined && classifyForge(forgeValue) === "url") { + io.say("a new profile is written into a Forge directory — clone the Forge, pass that directory as --forge, then commit and push the profile"); + continue forgeLoop; + } forgeDefault = forgeValue; } @@ -442,20 +473,48 @@ export async function askInit( throw e; } } + + // P5/P6 check: with --new-profile typed and Forge asked, check if the Forge can take a new profile + if (given.newProfile !== undefined && forgeWasAsked) { + try { + await newProfileGate(loaded.forge, home); + } catch (e) { + io.say((e as Error).message); + forgeDefault = forgeValue; + refDefault = answeredRef ?? refDefault; + continue forgeLoop; + } + } + + // P5/P6 check: with no profile given and no profiles in the Forge, we'll be forced to create a new profile + // If the Forge was asked and it can't take a new profile, say the error and ask again + if (given.profile === undefined && given.newProfile === undefined && loaded.forge.profiles.size === 0 && forgeWasAsked) { + if (classifyForge(forgeValue) !== "url") { + try { + await newProfileGate(loaded.forge, home); + } catch (e) { + io.say((e as Error).message); + forgeDefault = forgeValue; + refDefault = answeredRef ?? refDefault; + continue forgeLoop; + } + } + } + break forgeLoop; } const forge = loaded!.forge; - if (forge.profiles.size === 0 && given.profile === undefined) { - throw new Error("the Forge has no profile — add one under profiles/, then re-run init"); - } - if (given.profile !== undefined) { - if (!forge.profiles.has(given.profile)) { - throw new Error(profileNotFoundMessage(given.profile, [...forge.profiles.keys()])); - } - answeredProfile = given.profile; - } else { + // Track whether we're in the new-profile flow + let isNewProfile = false; + let newProfileName: string | undefined; + let newProfileLike: string | undefined; + let profileListShown = false; + + // Helper to show the profile list + const showProfileList = () => { + if (profileListShown) return; const profiles = [...forge.profiles.values()].sort((a, b) => a.name.localeCompare(b.name)); io.say(`Profiles in ${answeredForge}:`); const maxLen = Math.max(...profiles.map((p) => p.name.length)); @@ -468,6 +527,66 @@ export async function askInit( io.say(` ${i + 1}) ${p.name}`); } } + profileListShown = true; + }; + + // Check if the new profile entry should be shown: Forge is a directory that passes P5/P6 + let canOfferNewProfile = false; + let newProfileGateError: string | null = null; + if (classifyForge(answeredForge!) !== "url") { + try { + await newProfileGate(forge, home); + canOfferNewProfile = true; + } catch (e) { + newProfileGateError = (e as Error).message; + } + } + + // Handle given.newProfile: validate with P3 and P7/P8 (typed value is not re-asked) + if (given.newProfile !== undefined) { + profileName(given.newProfile); // P3: throws on failure + // P9 up front: a typed --like the Forge does not have is refused before any question + if (given.like !== undefined && !forge.profiles.has(given.like)) { + throw new Error("--like: " + profileNotFoundMessage(given.like, [...forge.profiles.keys()])); + } + await newProfileGate(forge, home, given.newProfile); // P7/P8: throws on failure + isNewProfile = true; + newProfileName = given.newProfile; + newProfileLike = given.like; + answeredProfile = given.newProfile; + } else if (given.profile !== undefined) { + if (!forge.profiles.has(given.profile)) { + throw new Error(profileNotFoundMessage(given.profile, [...forge.profiles.keys()])); + } + answeredProfile = given.profile; + } else if (forge.profiles.size === 0) { + // Spec 38 §4.3: A Forge directory with no profile goes straight to the new-profile questions + // (A URL Forge with no profile keeps today's error) + if (classifyForge(answeredForge!) === "url") { + throw new Error("the Forge has no profile — add one under profiles/, then re-run init"); + } + // For directory Forge with no profile: check if it can take a new profile + if (!canOfferNewProfile) { + throw new Error(newProfileGateError!); + } + io.say("the Forge has no profile yet — creating the first one"); + isNewProfile = true; + } else { + // Show the profile list with the new profile entry if applicable + const profiles = [...forge.profiles.values()].sort((a, b) => a.name.localeCompare(b.name)); + showProfileList(); + + // Show the 0) a new profile entry ONLY when the Forge can take a new profile + // Per spec 38 §4.3: show lines explaining why the entry isn't available + if (canOfferNewProfile) { + io.say(" 0) a new profile"); + } else if (classifyForge(answeredForge!) === "url") { + io.say("a new profile needs a Forge directory"); + } else if (newProfileGateError) { + // Forge directory that fails P5/P6: show the error as the line under the list + io.say(newProfileGateError); + } + const defProfile = profiles.length === 1 ? profiles[0].name : null; const profileQ = defProfile !== null ? `Profile [${defProfile}]: ` : "Profile: "; profileLoop: for (;;) { @@ -482,15 +601,349 @@ export async function askInit( answeredProfile = defProfile; break profileLoop; } + // pickItem: names first, then (only when the entry is shown) "0" for new profile, then numbers const picked = pickItem(profiles.map((p) => p.name), trimmed); if (picked !== null) { answeredProfile = picked; break profileLoop; } + // Check for "0" to start new-profile questions (only when the entry is shown) + if (canOfferNewProfile && trimmed === "0") { + isNewProfile = true; + break profileLoop; + } io.say(profileNotFoundMessage(trimmed, [...forge.profiles.keys()])); } } + // New-profile questions (§4.3) + if (isNewProfile) { + // Name question (skipped when given.newProfile was typed) + if (newProfileName === undefined) { + nameLoop: for (;;) { + const answer = await io.ask("New profile's name: "); + if (answer === null) return { kind: "cancelled" }; + const trimmed = answer.trim(); + if (trimmed === "") { + io.say('a name is needed — lowercase letters and digits, in groups joined by one "-"'); + continue nameLoop; + } + try { + profileName(trimmed); // P3 + await newProfileGate(forge, home, trimmed); // P7, P8 + newProfileName = trimmed; + break nameLoop; + } catch (e) { + io.say((e as Error).message); + continue nameLoop; + } + } + } + answeredProfile = newProfileName; + + // Template question: "Start from an existing profile [none]:" — only when the Forge has profiles + // and when given.like was not provided + if (newProfileLike === undefined && forge.profiles.size > 0) { + // Print the profile list if it wasn't printed yet (e.g. --new-profile was typed, --forge asked) + if (!profileListShown) { + showProfileList(); + } + + templateLoop: for (;;) { + const answer = await io.ask("Start from an existing profile [none]: "); + if (answer === null) return { kind: "cancelled" }; + const trimmed = answer.trim(); + if (trimmed === "" || trimmed === "0") { + // Enter or 0 → none + break templateLoop; + } + const profiles = [...forge.profiles.values()].sort((a, b) => a.name.localeCompare(b.name)); + const picked = pickItem(profiles.map((p) => p.name), trimmed); + if (picked !== null) { + newProfileLike = picked; + break templateLoop; + } + // Unknown → say "--like: " + profileNotFoundMessage + io.say("--like: " + profileNotFoundMessage(trimmed, [...forge.profiles.keys()])); + continue templateLoop; + } + } + + // Recipes question for new profile + // With a template: show "Recipes of (from ): " then Adjust flow + // Without a template: show recipes list and ask "Add [none]:" directly + let finalAddRecipes: string[] = []; + let finalRemoveRecipes: string[] = []; + let finalReplace = false; + + // Typed flags answer their questions (spec 38 §4.2, §4.3) + const recipesFlagged = given.addRecipes.length > 0 || given.removeRecipes.length > 0; + if (recipesFlagged) { + // Use the flags directly, skip all recipe questions + finalAddRecipes = given.addRecipes; + finalRemoveRecipes = given.removeRecipes; + finalReplace = given.replace; + } else if (local.keys.includes("recipes")) { + // local.keys includes recipes and no recipe flag typed: no recipes question; say the chain from the draft resolved with local recipes + const templateRecipes = newProfileLike ? forge.profiles.get(newProfileLike)!.recipes : []; + const draftProfile = ProfileSchema.parse({ name: newProfileName!, recipes: templateRecipes, targets: [] }); + const draftCopy = withProfile(forge, draftProfile); + const recipeConfig = { ...localConfig!, profile: newProfileName! }; + const resolution = resolve(draftCopy, recipeConfig); + io.say(`Recipes: ${resolution.recipes.join(" → ")} (from craftar.local.yaml)`); + } else { + // Track whether we should start with Remove (after a P10 error) + let startWithRemove = false; + + recipeLoop: for (;;) { + // Compute the draft profile in a copy of the Forge for the recipes line + const template = newProfileLike ? forge.profiles.get(newProfileLike) : undefined; + const draftRecipes = template?.recipes ?? []; + + if (template && !startWithRemove) { + // With a template: show the chain resolved from the draft profile + const draft = ProfileSchema.parse({ name: newProfileName!, recipes: draftRecipes, targets: template.targets }); + const copy = withProfile(forge, draft); + // Use mergeWorkspaceConfig to get a valid config with overrides + const config = mergeWorkspaceConfig( + { forge: forgeSource(root, answeredForge!), profile: newProfileName! }, + null + ).config; + const resolution = resolve(copy, config); + io.say(`Recipes of ${newProfileName} (from ${newProfileLike}): ${resolution.recipes.join(" → ")}`); + + // Adjust the recipes [no]: + const adjustAns = await io.ask("Adjust the recipes [no]: "); + if (adjustAns === null) return { kind: "cancelled" }; + const yn = yesNo(adjustAns); + if (adjustAns.trim() !== "" && yn === null) { + io.say("answer yes or no"); + continue recipeLoop; + } + if (yn !== true) { + // No adjustment: check P10 on the template's recipes before accepting, using ownedBy + const templateRecipes = template.recipes; + for (const r of templateRecipes) { + const owner = ownedBy(forge, r); + if (owner !== null) { + io.say(ownedRecipeRefusal(r, owner)); + startWithRemove = true; + continue recipeLoop; + } + } + // Success: use template's recipes as-is + break recipeLoop; + } + } + + // Show the Forge's recipes list + io.say(`Recipes in ${answeredForge}:`); + const allRecipes = [...forge.recipes.values()].sort((a, b) => a.name.localeCompare(b.name)); + for (let i = 0; i < allRecipes.length; i++) { + const r = allRecipes[i]; + const parts = [` ${i + 1}) ${r.name}`]; + if (draftRecipes.includes(r.name)) parts.push("· in use"); + if (r.slot) parts.push(`· slot ${r.slot}`); + if (r.description) parts.push(`· ${r.description}`); + io.say(parts.join(" ")); + } + + const names = allRecipes.map((r) => r.name); + const parseRecipeAnswer = (answer: string): string[] => + answer.split(",").map((s) => s.trim()).filter((s) => s !== "").map((s) => pickItem(names, s) ?? s); + + // Remove question (only with a template, or after P10 error) + let removeNames: string[] = []; + if (template || startWithRemove) { + const removeAns = await io.ask("Remove [none]: "); + if (removeAns === null) return { kind: "cancelled" }; + removeNames = parseRecipeAnswer(removeAns); + } + + // Add question + const addAns = await io.ask("Add [none]: "); + if (addAns === null) return { kind: "cancelled" }; + let addNames = parseRecipeAnswer(addAns); + + // Reset startWithRemove for the next iteration + startWithRemove = false; + + if (removeNames.length === 0 && addNames.length === 0) { + // No changes entered: check P10 on the final list (same as template's recipes) + if (template) { + for (const r of template.recipes) { + const owner = ownedBy(forge, r); + if (owner !== null) { + io.say(ownedRecipeRefusal(r, owner)); + startWithRemove = true; + continue recipeLoop; + } + } + } + // P10 passed or no template + break recipeLoop; + } + + // N6 check: a name both added and removed — say and re-ask + const both = addNames.find((n) => removeNames.includes(n)); + if (both !== undefined) { + try { + checkInitFlags({ addRecipes: addNames, removeRecipes: removeNames }); + } catch (e) { + io.say((e as Error).message); + continue recipeLoop; + } + } + + // Compute the recipe edit for notes and P10 check (mirroring existing flow) + // We do removes first, then adds — as init.ts and the existing flow do + const editConfig = mergeWorkspaceConfig( + { forge: forgeSource(root, answeredForge!), profile: newProfileName! }, + null + ).config; + let tempConfig = { ...editConfig, recipes: { add: [] as string[], remove: [] as string[] } }; + + // Simulate the edits on the template to get notes + const templateRecipes = newProfileLike ? forge.profiles.get(newProfileLike)!.recipes : []; + const draftProfile = ProfileSchema.parse({ name: newProfileName!, recipes: templateRecipes, targets: [] }); + const draftCopy = withProfile(forge, draftProfile); + + // The remove edit and add loop inside one outer try: any refusal → say the message and continue recipeLoop + try { + // Process removes, say notes + if (removeNames.length > 0) { + const removeEdit = planRecipeEdit(draftCopy, tempConfig, "remove", removeNames); + tempConfig = { ...tempConfig, recipes: removeEdit.recipes }; + if (removeEdit.reasons.length > 0) { + io.say(` note ${removeEdit.reasons.join(", ")}`); + } + } + + // Process adds, say notes, handle slot held + let doReplace = false; + addSlotLoop: for (;;) { + try { + const addEdit = planRecipeEdit(draftCopy, tempConfig, "add", addNames, { replace: doReplace }); + tempConfig = { ...tempConfig, recipes: addEdit.recipes }; + if (addEdit.reasons.length > 0) { + io.say(` note ${addEdit.reasons.join(", ")}`); + } + break addSlotLoop; + } catch (e) { + const held = slotHeld(e); + if (held !== null && !doReplace) { + const replaceQ = `${held.recipe} takes the slot "${held.slot}" that ${held.holder} holds — replace ${held.holder} [no]: `; + let replaceYn: boolean | null; + for (;;) { + const replaceAns = await io.ask(replaceQ); + if (replaceAns === null) return { kind: "cancelled" }; + replaceYn = replaceAns.trim() === "" ? false : yesNo(replaceAns); + if (replaceYn !== null) break; + io.say("answer yes or no"); + } + if (replaceYn) { + doReplace = true; + continue addSlotLoop; + } + // User said no to replace, re-ask recipes + continue recipeLoop; + } + // Other errors (unknown recipe, etc.): re-throw to outer try + throw e; + } + } + + // Compute the final list: template's recipes minus removes, plus adds + const finalList = [ + ...templateRecipes.filter((r) => !tempConfig.recipes.remove.includes(r)), + ...tempConfig.recipes.add, + ]; + + // P10 check on the final list + for (const r of finalList) { + const owner = ownedBy(forge, r); + if (owner !== null) { + io.say(ownedRecipeRefusal(r, owner)); + startWithRemove = true; + continue recipeLoop; + } + } + + // Success + finalAddRecipes = addNames; + finalRemoveRecipes = removeNames; + finalReplace = doReplace; + break recipeLoop; + } catch (e) { + io.say((e as Error).message); + continue recipeLoop; + } + } + } + + answeredAddRecipes = finalAddRecipes; + answeredRemoveRecipes = finalRemoveRecipes; + answeredReplace = finalReplace; + + // Targets question for new profile + // Typed flags answer their questions (spec 38 §4.2, §4.3) + if (given.targets !== undefined) { + answeredTargets = given.targets; + } else if (local.keys.includes("targets")) { + // local.keys includes targets and no --targets typed: no targets question; say the local value + const localTargets = localConfig?.targets ?? []; + io.say(`Targets: ${localTargets.join(", ")} (from craftar.local.yaml)`); + } else { + // Default: [, from ] or [claude-code, default] + const template = newProfileLike ? forge.profiles.get(newProfileLike) : undefined; + const defaultTargets = template?.targets ?? ["claude-code"]; + const fromText = template ? `from ${newProfileLike}` : "default"; + const targetsQ = `Targets — ${TARGETS.join(", ")}, separated by commas [${defaultTargets.join(", ")}, ${fromText}]: `; + targetsLoop: for (;;) { + const answer = await io.ask(targetsQ); + if (answer === null) return { kind: "cancelled" }; + const trimmed = answer.trim(); + if (trimmed === "") break targetsLoop; + const parsed = trimmed.split(",").map((s) => s.trim()); + try { + checkInitFlags({ targets: parsed }); + } catch (e) { + io.say((e as Error).message); + continue targetsLoop; + } + answeredTargets = parsed; + break targetsLoop; + } + } + + // Build the result for new profile + const input: InitInput = { + forge: answeredForge!, + profile: newProfileName!, + loaded: loaded, + newProfile: newProfileLike ? { name: newProfileName!, like: newProfileLike } : { name: newProfileName! }, + }; + if (answeredRef !== undefined) input.ref = answeredRef; + if (answeredTargets !== undefined) input.targets = answeredTargets; + if (answeredAddRecipes.length > 0) input.addRecipes = answeredAddRecipes; + if (answeredRemoveRecipes.length > 0) input.removeRecipes = answeredRemoveRecipes; + if (answeredReplace) input.replace = true; + if (given.clone === true) input.clone = true; + + // For new profile: answers has --profile name (for the again line), no recipe/target flags + const answers: InitAnswers = { + forge: answeredForge!, + profile: newProfileName!, + addRecipes: [], + removeRecipes: [], + replace: false, + }; + if (answeredRef !== undefined) answers.ref = answeredRef; + + return { kind: "answered", input, answers }; + } + + // Existing profile flow (unchanged) const profile = forge.profiles.get(answeredProfile!)!; const recipesFlagged = given.addRecipes.length > 0 || given.removeRecipes.length > 0; diff --git a/src/core/init.ts b/src/core/init.ts index 0c9e5fb..c3dbfc7 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -2,6 +2,7 @@ import path from "node:path"; import YAML from "yaml"; import { TARGETS, type Lock, type WorkspaceConfig } from "../schema/index.js"; import { planClone } from "./clone.js"; +import { newProfile, withProfile } from "./new-profile.js"; import { planRecipeEdit } from "./recipe-edit.js"; import { classifyForge, credentialFault } from "./remote.js"; import { type RepoEntry } from "./repos.js"; @@ -44,6 +45,8 @@ export interface InitInput { * A path Forge is loaded with the ref the workspace will have (`loadForgeSource(root, source, ref)`), * so its ignored-ref warning travels in `loaded.warnings`. */ loaded?: LoadedForge; + /** When present, create a new profile in memory (spec 38 §5.2). `profile` holds the new name. */ + newProfile?: { name: string; like?: string }; } export interface InitPlan { @@ -59,6 +62,8 @@ export interface InitPlan { targetsFrom: "flag" | "local" | "profile"; /** The repositories to clone, in profile order; empty when `input.clone` is not true (spec 37 §4.2). */ repos: RepoEntry[]; + /** When `input.newProfile` was given: the file to write (spec 38 §5.2). */ + newProfile?: { path: string; text: string; blockNote: string | null }; } /** The flag each local key would replace (N10). */ @@ -169,6 +174,88 @@ export async function planInit(root: string, input: InitInput, opts: InitOptions }); const forge = forgeSource(root, input.forge); + + // New profile branch (spec 38 §5.2): build the profile in memory, plan on the copy that holds it. + if (input.newProfile) { + // input.loaded is REQUIRED with newProfile — a programming error without it + if (!input.loaded) throw new Error("planInit: newProfile requires a loaded Forge"); + + const base: Record = { forge }; + if (input.ref !== undefined) base.ref = input.ref; + base.profile = input.profile; + // With newProfile, --targets goes into the profile (spec 38 §4.1): neither craftar.yaml nor the workspace layer holds it + + const merged = mergeWorkspaceConfig(base, localDoc); + + // Refuse a `loaded` that is not the configuration's Forge — a programming error + if (input.loaded.origin.source !== merged.config.forge) { + throw new Error(`planInit: the loaded Forge is "${input.loaded.origin.source}", not "${merged.config.forge}"`); + } + // Remote only: ref must match + if (input.loaded.origin.kind === "remote") { + const expectedRef = merged.config.ref ?? null; + if (input.loaded.origin.ref !== expectedRef) { + throw new Error(`planInit: the loaded Forge is at ref ${JSON.stringify(input.loaded.origin.ref)}, not ${JSON.stringify(expectedRef)}`); + } + } + + // Build the new profile: the recipe flags go into the profile (spec 38 §4.4) + const np = newProfile(input.loaded.forge, { + name: input.newProfile.name, + like: input.newProfile.like, + add: adds, + remove: removes, + replace: input.replace, + targets: input.targets, + }); + + // Create a copy of the Forge with the new profile added — the input Forge is not mutated + const copy = withProfile(input.loaded.forge, np.profile); + const loadedCopy: LoadedForge = { ...input.loaded, forge: copy }; + const ws = workspaceOf(root, merged, loadedCopy); + + const p = await plan(ws); + + // P12 is the resolver's and the planner's own refusal: newProfile's recipe edit resolves, and plan() above throws (spec 38 §4.2) + + const lock = await readLock(root); + const statuses = await status(ws, p, lock); + + // craftar.yaml text: only forge, ref, profile — no recipes, no targets (spec 38 §5.2) + const doc: Record = { forge }; + if (input.ref !== undefined) doc.ref = input.ref; + doc.profile = input.profile; + + // Clone planning (spec 37 §4.2) + let repos: RepoEntry[] = []; + if (input.clone === true) { + const profile = ws.forge.profiles.get(ws.config.profile)!; + const ctx = { planned: p.files.map((f) => f.path), lockPaths: lock?.files.map((f) => f.path) ?? [], forgeDir: ws.forge.root }; + const clonePlan = await planClone(root, profile, ctx, { present: "refuse", noEntries: "nothing to clone; drop --clone" }); + repos = clonePlan.entries; + } + + // notes = np.notes then np.blockNote when non-null (spec 38 §5.2) + const notes = [...np.notes]; + if (np.blockNote !== null) notes.push(np.blockNote); + + // targetsFrom: "local" when the local file sets targets, else "profile" (typed targets go into the profile) + const targetsFrom = local.includes("targets") ? "local" : "profile"; + + return { + ws, + plan: p, + lock, + statuses, + text: YAML.stringify(doc), + notes, + targetsFrom, + repos, + newProfile: { path: `profiles/${np.profile.name}/profile.yaml`, text: np.text, blockNote: np.blockNote }, + }; + } + + // Existing profile branch: unchanged const base: Record = { forge }; if (input.ref !== undefined) base.ref = input.ref; base.profile = input.profile; diff --git a/src/core/new-profile-gate.ts b/src/core/new-profile-gate.ts new file mode 100644 index 0000000..4887051 --- /dev/null +++ b/src/core/new-profile-gate.ts @@ -0,0 +1,101 @@ +/** + * Spec 38 §4.2/§5.2 — the gate of the one path `init --new-profile` creates, as `drift promote` + * holds it for a path it creates. Called by the command after the load and before `planInit`, and + * by the flow (P5/P6 when the profile list is built; P7/P8 when the name is answered). + * + * Reads the disk and runs git ONLY through `gitIsRepo`, `gitHasCommit`, `gitIgnored` and + * `assertNoLinkOnPath` — no other git call, no second copy of those checks. + */ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { assertNoLinkOnPath, gitHasCommit, gitIgnored, gitIsRepo, type Forge } from "./forge.js"; + +/** + * The gate of the one path `init --new-profile` creates. + * @param forge The loaded Forge. + * @param home The `$CRAFTAR_HOME` directory. + * @param name The profile name, if known (P7 and P8 are skipped without it). + */ +export async function newProfileGate(forge: Forge, home: string, name?: string): Promise { + const root = forge.root; + + // P5: realpath(root) has realpath(path.join(home, "forges")) as an ancestor (or equal), + // segment-wise, compared as samePath compares — without case on win32; when /forges + // does not exist the check passes. + const forgesDir = path.join(home, "forges"); + let forgesReal: string | null = null; + try { + forgesReal = await fs.realpath(forgesDir); + } catch (e) { + // Only a forges/ that does not exist passes (spec 38 §4.2 P5); any other error fails closed. + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; + } + if (forgesReal !== null) { + const rootReal = await fs.realpath(root); + if (isUnderOrEqual(rootReal, forgesReal)) { + throw new Error( + `${root} is a tree of craftar's Forge cache — nothing is written there; clone the Forge and pass that directory`, + ); + } + } + + // P6: not a git repository, or has no commit + const isRepo = await gitIsRepo(root); + const hasCommit = isRepo && (await gitHasCommit(root)); + if (!isRepo || !hasCommit) { + throw new Error( + `cannot create a profile in ${root}: it is not a git repository with a commit — git is how a Forge write is undone`, + ); + } + + // Without name: return here (P5/P6 only for the flow's profile list question) + if (!name) return; + + // P8 links: assertNoLinkOnPath on the DIRECTORY path, not the file's + const profileDir = path.join(root, "profiles", name); + await assertNoLinkOnPath(root, [profileDir], "init"); + + // P7 by name: forge.profiles.has(name) + if (forge.profiles.has(name)) { + throw new Error(`profile ${name} already exists in the Forge — use --profile ${name}`); + } + + // P7 by directory: list profiles/ (ENOENT → nothing there); an entry whose name lower-cased + // equals name lower-cased → message with the spelling found + const profilesDir = path.join(root, "profiles"); + let entries: string[] = []; + try { + entries = await fs.readdir(profilesDir); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + throw e; + } + // ENOENT: no profiles directory yet, nothing there + } + const nameLower = name.toLowerCase(); + for (const entry of entries) { + if (entry.toLowerCase() === nameLower) { + throw new Error( + `profiles/${entry} already exists in the Forge and holds no profile named ${name} — pick another name, or remove it`, + ); + } + } + + // P8 ignored: gitIgnored(root, `profiles/${name}/profile.yaml`) + // gitIgnored fails closed — keep it so + const rel = `profiles/${name}/profile.yaml`; + if (await gitIgnored(root, rel)) { + throw new Error(`cannot create profiles/${name}/profile.yaml: git ignores it`); + } +} + +/** + * Whether `child` is under `parent` (or equal), segment-wise — `path.relative` compares without case on win32, as `samePath` does. + */ +function isUnderOrEqual(child: string, parent: string): boolean { + const rel = path.relative(parent, child); + if (rel === "") return true; + if (rel === ".." || rel.startsWith(".." + path.sep)) return false; + if (path.isAbsolute(rel)) return false; + return true; +} diff --git a/src/core/new-profile-write.ts b/src/core/new-profile-write.ts new file mode 100644 index 0000000..cbd7903 --- /dev/null +++ b/src/core/new-profile-write.ts @@ -0,0 +1,192 @@ +/** + * Spec 38 §5.3 — the profile write (step 3) and the second proof from disk (step 4). + * No console, no process — these are pure write operations and a second planInit call. + */ + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { planInit, type InitOptions, type InitPlan } from "./init.js"; +import { errorText } from "./schema-fault.js"; +import { note, type WriteJournal } from "./unify.js"; + +export { type WriteJournal }; + +/** + * Thrown when another run (or something else) created the profile's place first. + * P7's first message, with no recover lines — the directory belongs to the other run. + */ +export class NewProfileRaceLost extends Error { + constructor(message: string) { + super(message); + this.name = "NewProfileRaceLost"; + } +} + +/** + * Thrown when the profile write fails (other than EEXIST) or the second proof does not match. + * Carries the write journal so the caller can print the late-failure lines. + */ +export class NewProfileLateFailure extends Error { + journal: WriteJournal; + constructor(message: string, journal: WriteJournal) { + super(message); + this.name = "NewProfileLateFailure"; + this.journal = journal; + } +} + +/** Hooks for testing: injected seams between the steps. */ +export interface WriteHooks { + /** Called after the directory is journalled and before the mkdirs. */ + beforeMkdir?: () => Promise; + /** Called after the mkdirs and before the writeFile. */ + beforeWrite?: () => Promise; +} + +/** + * Step 3 of §5.3: write the profile file into the Forge. + * + * - `profiles/` is the only path journalled (never `profiles`, never the file). + * - The directory is journalled BEFORE the mkdir (note() decides "created" from whether the path exists when called). + * - `profiles` is made with `recursive: true` (EEXIST not an error). + * - `profiles/` is made WITHOUT recursive: EEXIST → throw NewProfileRaceLost. + * - The file is written with `wx`: EEXIST → throw NewProfileRaceLost. + * - Any other error → throw NewProfileLateFailure with the journal. + * + * @param forgeRoot The Forge's root directory. + * @param init The plan from planInit with newProfile set. + * @param hooks Optional hooks for testing. + * @returns The write journal. + */ +export async function writeNewProfile( + forgeRoot: string, + init: InitPlan, + hooks?: WriteHooks, +): Promise { + if (!init.newProfile) throw new Error("writeNewProfile: init.newProfile is required"); + + const name = path.basename(path.dirname(init.newProfile.path)); + const profileDir = path.join(forgeRoot, "profiles", name); + const profileFile = path.join(profileDir, "profile.yaml"); + + const journal: WriteJournal = []; + + // Journal the profile's own directory BEFORE the mkdir + await note(journal, profileDir); + + try { + if (hooks?.beforeMkdir) await hooks.beforeMkdir(); + + // Make profiles/ with recursive: true (EEXIST not an error) + await fs.mkdir(path.join(forgeRoot, "profiles"), { recursive: true }); + + // Make profiles/ WITHOUT recursive: EEXIST means another run won + try { + await fs.mkdir(profileDir); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "EEXIST") { + throw new NewProfileRaceLost(`profile ${name} already exists in the Forge — use --profile ${name}`); + } + throw e; + } + + if (hooks?.beforeWrite) await hooks.beforeWrite(); + + // Write the file with wx: EEXIST is the backstop + try { + await fs.writeFile(profileFile, init.newProfile.text, { flag: "wx" }); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "EEXIST") { + throw new NewProfileRaceLost(`profile ${name} already exists in the Forge — use --profile ${name}`); + } + throw e; + } + + return journal; + } catch (e) { + // NewProfileRaceLost passes through + if (e instanceof NewProfileRaceLost) throw e; + // Any other error: wrap in NewProfileLateFailure with the journal + throw new NewProfileLateFailure(errorText(e), journal); + } +} + +/** + * Step 4 of §5.3: a second planInit from disk, with no newProfile, no loaded, and no recipe or target flag. + * + * The plan must hold the same paths and the same bytes as init1's (Buffer.equals per file, same path set). + * A difference — a file changed between the two steps — stops the run. + * + * @param root The workspace root. + * @param forgeArg The --forge argument (path or URL, relative to root). + * @param init1 The first plan (with newProfile). + * @param opts Options for planInit (without local, which is read fresh). + * @param journal The write journal, for error reporting. + * @param ref The typed ref from the input (not the merged config's ref). + * @returns The second plan. + */ +export async function reproveFromDisk( + root: string, + forgeArg: string, + init1: InitPlan, + opts: InitOptions, + journal: WriteJournal = [], + ref?: string, +): Promise { + if (!init1.newProfile) throw new Error("reproveFromDisk: init1.newProfile is required"); + + const name = path.basename(path.dirname(init1.newProfile.path)); + + // Second planInit: profile exists on disk, no newProfile, no loaded, no recipe or target flag + // Use the typed ref, not init1.ws.config.ref (which is the merged config, including craftar.local.yaml) + let init2: InitPlan; + try { + init2 = await planInit( + root, + { + forge: forgeArg, + profile: name, + ref, + // NO newProfile, NO loaded, NO recipe or target flag + }, + opts, + ); + } catch (e) { + // Wrap EVERY error of the second planInit in NewProfileLateFailure + throw new NewProfileLateFailure(errorText(e), journal); + } + + // Compare: same paths and same bytes + const paths1 = new Set(init1.plan.files.map((f) => f.path)); + const paths2 = new Set(init2.plan.files.map((f) => f.path)); + + // Same path set? + if (paths1.size !== paths2.size) { + throw new NewProfileLateFailure( + "the Forge read back from disk does not plan what init proved in memory — a Forge file changed while init ran", + journal, + ); + } + for (const p of paths1) { + if (!paths2.has(p)) { + throw new NewProfileLateFailure( + "the Forge read back from disk does not plan what init proved in memory — a Forge file changed while init ran", + journal, + ); + } + } + + // Same bytes per file? + const map1 = new Map(init1.plan.files.map((f) => [f.path, f.content])); + for (const f of init2.plan.files) { + const content1 = map1.get(f.path)!; + if (!content1.equals(f.content)) { + throw new NewProfileLateFailure( + "the Forge read back from disk does not plan what init proved in memory — a Forge file changed while init ran", + journal, + ); + } + } + + return init2; +} diff --git a/src/core/new-profile.ts b/src/core/new-profile.ts new file mode 100644 index 0000000..235ef00 --- /dev/null +++ b/src/core/new-profile.ts @@ -0,0 +1,221 @@ +/** + * Pure helpers for building a new profile's file (spec 38 §5.2). + * No fs, no process, no console — all validation and generation is deterministic. + */ + +import YAML from "yaml"; +import { ProfileSchema, WorkspaceConfigSchema, type Profile } from "../schema/index.js"; +import type { Forge } from "./forge.js"; +import { effectiveProfile } from "./integrations.js"; +import { namedForProfile } from "./promote.js"; +import { planRecipeEdit } from "./recipe-edit.js"; +import { profileNotFoundMessage } from "./resolve.js"; +import { parseYamlText } from "./yaml-read.js"; + +/** Windows device names (spec 38 §5.1 P3). */ +const DEVICE_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + +/** Profile name grammar: lowercase letters and digits, in groups joined by one "-". */ +const PROFILE_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +/** + * YAML-safe a list item: when `YAML.stringify(value).trim()` differs from the plain value, + * use the stringified form (so `123`, `true`, `null` are quoted). + */ +function yamlSafe(value: string): string { + const stringified = YAML.stringify(value).trim(); + return stringified !== value ? stringified : value; +} + +/** + * P3: Validates a profile name. Throws with a quoted name on failure. + * @param name The profile name to validate. + * @throws When the name is not valid or is a Windows device name. + */ +export function profileName(name: string): void { + if (!PROFILE_NAME_RE.test(name)) { + throw new Error(`${JSON.stringify(name)} is not a profile name — lowercase letters and digits, in groups joined by one "-"`); + } + if (DEVICE_NAMES.test(name)) { + throw new Error(`${JSON.stringify(name)} is a device name on Windows — pick another`); + } +} + +/** + * Returns the profile of the LOADED Forge that owns the recipe, or null if none. + * When several match, the longest profile name wins (spec 38 §3). + * @param forge The loaded Forge. + * @param recipe The recipe name to check. + * @returns The profile name that owns the recipe, or null. + */ +export function ownedBy(forge: Forge, recipe: string): string | null { + const matches = [...forge.profiles.keys()].filter((q) => namedForProfile(recipe, q)); + if (matches.length === 0) return null; + // When several match, the longest name wins. + return matches.reduce((a, b) => (b.length > a.length ? b : a)); +} + +/** + * Returns a shallow copy of the Forge with the profile added to its profiles Map. + * The input Forge is not mutated. + * @param forge The loaded Forge. + * @param profile The profile to add. + * @returns A new Forge with the profile added. + */ +export function withProfile(forge: Forge, profile: Profile): Forge { + const profiles = new Map(forge.profiles); + profiles.set(profile.name, profile); + return { ...forge, profiles }; +} + +/** P10's one wording (spec 38 §4.2): a recipe named for another profile of the Forge, in a new profile's list. */ +export function ownedRecipeRefusal(recipe: string, owner: string): string { + return `recipe ${recipe} is named for profile ${owner} — a new profile cannot use it; leave it out and add the shared recipe it stands for`; +} + +/** Options for newProfile (spec 38 §4.4). */ +export interface NewProfileOptions { + name: string; + like?: string; + add?: string[]; + remove?: string[]; + replace?: boolean; + targets?: string[]; +} + +/** Result of newProfile (spec 38 §4.4). */ +export interface NewProfileResult { + profile: Profile; + text: string; + notes: string[]; + blockNote: string | null; +} + +/** + * Builds a new profile's file from the template's recipes and targets only (spec 38 §4.4, §5.2). + * Called after the gate (the name is known free). + * + * @param forge The loaded Forge. + * @param o Options: name, like (template), add/remove recipes, replace, targets. + * @returns The profile, its YAML text, notes from recipe edits, and a note about integrations. + * @throws P9 when --like names a profile the Forge does not have. + * @throws P10 when the final list holds an owned recipe. + * @throws P11 for spec 22's own refusals (unknown recipe, held slot without --replace). + */ +export function newProfile(forge: Forge, o: NewProfileOptions): NewProfileResult { + const { name, like, add = [], remove = [], replace = false, targets: targetOverride } = o; + + // 1. template: o.like → forge.profiles.get(o.like) or throw P9 + const template = like ? forge.profiles.get(like) : undefined; + if (like && !template) { + throw new Error("--like: " + profileNotFoundMessage(like, [...forge.profiles.keys()])); + } + + // 2. targets = o.targets ?? template's ?? ["claude-code"] + const targets = targetOverride ?? template?.targets ?? ["claude-code"]; + + // 3. draft = ProfileSchema.parse({ name, recipes: template?.recipes ?? [], targets }); copy = withProfile(forge, draft) + const draft = ProfileSchema.parse({ + name, + recipes: template?.recipes ?? [], + targets, + }); + let copy = withProfile(forge, draft); + + // 4. the edit, as init does (removes first, then adds) + const notes: string[] = []; + let config = WorkspaceConfigSchema.parse({ + forge: ".", + profile: name, + recipes: { add: [], remove: [] }, + }); + + // Process removes first + if (remove.length > 0) { + const removeEdit = planRecipeEdit(copy, config, "remove", remove, { replace }); + config = { ...config, recipes: removeEdit.recipes }; + if (removeEdit.reasons.length > 0) { + notes.push(removeEdit.reasons.join(", ")); + } + } + + // Then adds + if (add.length > 0) { + const addEdit = planRecipeEdit(copy, config, "add", add, { replace }); + config = { ...config, recipes: addEdit.recipes }; + if (addEdit.reasons.length > 0) { + notes.push(addEdit.reasons.join(", ")); + } + } + + // 5. final list = draft.recipes minus the edit's resulting remove list, then plus its add list + const finalRecipes = [ + ...draft.recipes.filter((r) => !config.recipes.remove.includes(r)), + ...config.recipes.add, + ]; + + // 6. P10: the first recipe of the final list with ownedBy(forge, r) non-null (the LOADED forge, not the copy) + for (const r of finalRecipes) { + const owner = ownedBy(forge, r); + if (owner !== null) { + throw new Error(ownedRecipeRefusal(r, owner)); + } + } + + // 7. profile = ProfileSchema.parse({ name, recipes: final, targets }) + const profile = ProfileSchema.parse({ + name, + recipes: finalRecipes, + targets, + }); + + // Generate text: name: \n + recipes block + targets block + const lines: string[] = []; + lines.push(`name: ${yamlSafe(name)}`); + if (finalRecipes.length === 0) { + lines.push("recipes: []"); + } else { + lines.push("recipes:"); + for (const r of finalRecipes) { + lines.push(` - ${yamlSafe(r)}`); + } + } + lines.push("targets:"); + for (const t of targets) { + lines.push(` - ${yamlSafe(t)}`); + } + const text = lines.join("\n") + "\n"; + + // Prove that text parses back to the profile — a programming error otherwise + const parsed = parseYamlText("generated profile", text); + const parsedProfile = ProfileSchema.parse(parsed); + if ( + parsedProfile.name !== profile.name || + JSON.stringify(parsedProfile.recipes) !== JSON.stringify(profile.recipes) || + JSON.stringify(parsedProfile.targets) !== JSON.stringify(profile.targets) + ) { + throw new Error(`newProfile: the generated text does not match the profile (programming error)`); + } + + // 8. blockNote (only with a template) + let blockNote: string | null = null; + if (template) { + try { + const { selected } = effectiveProfile(forge, template); + // n = selected recipes not in the final list + const missing = selected.filter((s) => !finalRecipes.includes(s.recipe)); + if (missing.length >= 1) { + if (missing.length === 1) { + blockNote = `integrations of ${like} not copied — 1 recipe it selects is not in ${name}`; + } else { + blockNote = `integrations of ${like} not copied — ${missing.length} recipes it selects are not in ${name}`; + } + } + } catch { + // The block does not read + blockNote = `integrations of ${like} not copied — the block does not read, so what it selects is not known`; + } + } + + return { profile, text, notes, blockNote }; +} diff --git a/src/core/promote.ts b/src/core/promote.ts index 92e3379..331a5b5 100644 --- a/src/core/promote.ts +++ b/src/core/promote.ts @@ -17,6 +17,13 @@ import { editYamlText } from "./yaml-edit.js"; // base gets it back as its own variant. Three steps a caller runs in order: the edit set (reads only), the slot proof // on a scratch copy (reads the Forge only), the writes through a journal. +/** + * The owned-recipe predicate (spec 38 §3): a recipe named for a profile — `--` or `-steering`. + */ +export function namedForProfile(name: string, q: string): boolean { + return name.endsWith(`--${q}`) || name === `${q}-steering`; +} + const COMMAND = "forge promote"; /** One file the promotion writes; `created` when nothing is there today. */ @@ -204,7 +211,6 @@ export async function planBasePromotion(forge: Forge, ref: string, profile: stri if (f.stem !== name) throw new Error(`${COMMAND}: recipe ${name} is ${label(f.abs)} — ${COMMAND} writes recipes/${name}.yaml`); return f; }; - const owned = (name: string, q: string) => name.endsWith(`--${q}`) || name === `${q}-steering`; const detail: BasePromotion["detail"] = { recipes: [], profiles: [] }; const editedRecipes: PromotionWrite[] = []; const createdRecipes: PromotionWrite[] = []; @@ -233,7 +239,7 @@ export async function planBasePromotion(forge: Forge, ref: string, profile: stri continue; } if (!namesBase) continue; - const owner = Q.find((q) => owned(name, q)); + const owner = Q.find((q) => namedForProfile(name, q)); if (owner) { const f = fileOf(name); editedRecipes.push({ abs: f.abs, content: recipeEdit(f.raw, label(f.abs), name, name, X, variantRef(owner)), created: false }); diff --git a/test/init-flow.test.ts b/test/init-flow.test.ts index 9cf34dd..d78b538 100644 --- a/test/init-flow.test.ts +++ b/test/init-flow.test.ts @@ -454,10 +454,12 @@ describe("askInit", () => { { home: s.home, registryOff: false, exists: existsSync } ); expect(sc.asked).toEqual([QF, QF, QP1, QA, QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc.said).toEqual([ "a Forge is needed — a directory or a git URL", `Profiles in ${s.forge}:`, " 1) acme", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of acme: base → stack-api → front-a", ]); expect(result.kind).toBe("answered"); @@ -638,9 +640,11 @@ describe("askInit", () => { QA, QT, ]); + // Spec 38 §4.3: a URL Forge shows "a new profile needs a Forge directory" under the profile list expect(sc.said).toEqual([ `Profiles in ${r.url}:`, " 1) acme", + "a new profile needs a Forge directory", "Recipes of acme: base → stack-api → front-a", ]); expect(sc.left()).toBe(0); @@ -676,9 +680,11 @@ describe("askInit", () => { QA, QT, ]); + // Spec 38 §4.3: a URL Forge shows "a new profile needs a Forge directory" under the profile list expect(sc2.said).toEqual([ `Profiles in ${r.url}:`, " 1) acme", + "a new profile needs a Forge directory", "Recipes of acme: base → stack-api → front-a", ]); expect(sc2.left()).toBe(0); @@ -721,10 +727,12 @@ describe("askInit", () => { const sc = script([s.forge, "globex", "", ""]); await askInit(s.ws, given, local, sc.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc.asked).toEqual([QF, QP, QA, QTk]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", " 2) globex Globex — services", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of globex: base", ]); expect(sc.left()).toBe(0); @@ -733,10 +741,12 @@ describe("askInit", () => { const sc2 = script([s.forge, "2", "", ""]); const result2 = await askInit(s.ws, given, local, sc2.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc2.asked).toEqual([QF, QP, QA, QTk]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc2.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", " 2) globex Globex — services", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of globex: base", ]); expect(sc2.left()).toBe(0); @@ -748,10 +758,12 @@ describe("askInit", () => { const sc3 = script([s.forge, "globex", "", ""]); const result3 = await askInit(s.ws, given, local, sc3.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc3.asked).toEqual([QF, QP, QA, QTk]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc3.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", " 2) globex Globex — services", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of globex: base", ]); expect(sc3.left()).toBe(0); @@ -763,10 +775,12 @@ describe("askInit", () => { const sc4 = script([s.forge, "", "nope", "acme", "", ""]); const result4 = await askInit(s.ws, given, local, sc4.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc4.asked).toEqual([QF, QP, QP, QP, QA, QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc4.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", " 2) globex Globex — services", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "a profile is needed — a name or a number from the list", 'profile "nope" not found in Forge (acme, globex)', "Recipes of acme: base → stack-api → front-a", @@ -780,10 +794,12 @@ describe("askInit", () => { const sc5 = script([s.forge, "globex", "", ""]); const result5 = await askInit(s.ws, given, local, sc5.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc5.asked).toEqual([QF, QP, QA, QTk]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc5.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", " 2) globex Globex — services", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of globex: base", ]); expect(sc5.left()).toBe(0); @@ -805,10 +821,12 @@ describe("askInit", () => { const sc7 = script([s2.forge, "2", "", ""]); const result7 = await askInit(s2.ws, given, local, sc7.io, { home: s2.home, registryOff: false, exists: existsSync }); expect(sc7.asked).toEqual([QF, QP, QA, QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc7.said).toEqual([ `Profiles in ${s2.forge}:`, " 1) 2", " 2) acme", + `cannot create a profile in ${s2.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of 2: base → stack-api → front-a", ]); expect(sc7.left()).toBe(0); @@ -820,10 +838,12 @@ describe("askInit", () => { const sc8 = script([s2.forge, "1", "", ""]); const result8 = await askInit(s2.ws, given, local, sc8.io, { home: s2.home, registryOff: false, exists: existsSync }); expect(sc8.asked).toEqual([QF, QP, QA, QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc8.said).toEqual([ `Profiles in ${s2.forge}:`, " 1) 2", " 2) acme", + `cannot create a profile in ${s2.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of 2: base → stack-api → front-a", ]); expect(sc8.left()).toBe(0); @@ -841,11 +861,15 @@ describe("askInit", () => { const s = await askSetup(specNoProfile); const given: InitGiven = { addRecipes: [], removeRecipes: [], replace: false }; const local = { doc: null, keys: [] as LocalKey[] }; - const sc = script([s.forge]); - await expect( - askInit(s.ws, given, local, sc.io, { home: s.home, registryOff: false, exists: existsSync }) - ).rejects.toThrow("the Forge has no profile — add one under profiles/, then re-run init"); - expect(sc.asked).toEqual([QF]); + // Spec 38 §4.3: a Forge directory with no profile that fails P6, Forge asked: + // say P6's text ONCE and ask the Forge again. With no --forge given, this continues. + // Provide the bad Forge once, then null to cancel when asked again. + const sc = script([s.forge, null as any]); + const result = await askInit(s.ws, given, local, sc.io, { home: s.home, registryOff: false, exists: existsSync }); + expect(result.kind).toBe("cancelled"); + expect(sc.said).toContain(`cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`); + // Forge was asked once, P6 said, Forge asked again with default, user cancelled + expect(sc.asked.filter(q => q.startsWith("Forge")).length).toBe(2); // given.profile = "nope" - use a Forge WITH a profile (SPEC_DESC) to get proper error const s2 = await askSetup(SPEC_DESC); @@ -1030,9 +1054,11 @@ describe("askInit", () => { const sc = script([s.forge, "", "yes", "", "base", ""]); await askInit(s.ws, given, local, sc.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc.asked).toEqual([QF, QP1, QA, "Remove [none]: ", "Add [none]: ", QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of acme: base → stack-api → front-a", `Recipes in ${s.forge}:`, " 1) base · in use", @@ -1159,9 +1185,11 @@ describe("askInit", () => { const sc = script([s.forge, "", ""]); await askInit(s.ws, given, { doc: { targets: ["kiro"] }, keys: ["targets"] }, sc.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc.asked).toEqual([QF, QP1, QA]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of acme: base → stack-api → front-a", "Targets: kiro (from craftar.local.yaml)", ]); @@ -1171,9 +1199,11 @@ describe("askInit", () => { const sc2 = script([s.forge, "", ""]); await askInit(s.ws, given, { doc: { recipes: { add: ["extra"] } }, keys: ["recipes"] }, sc2.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc2.asked).toEqual([QF, QP1, QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc2.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes: base → stack-api → front-a → extra (from craftar.local.yaml)", ]); expect(sc2.left()).toBe(0); @@ -1183,10 +1213,12 @@ describe("askInit", () => { const sc3 = script([r.url, "", "", ""]); const result3 = await askInit(s.ws, given, { doc: { ref: "main" }, keys: ["ref"] }, sc3.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc3.asked).toEqual([QF, QP1, QA, QT]); + // Spec 38 §4.3: a URL Forge shows "a new profile needs a Forge directory" under the profile list expect(sc3.said).toEqual([ "Ref: main (from craftar.local.yaml)", `Profiles in ${r.url}:`, " 1) acme", + "a new profile needs a Forge directory", "Recipes of acme: base → stack-api → front-a", ]); expect(sc3.left()).toBe(0); @@ -1201,9 +1233,11 @@ describe("askInit", () => { const sc4 = script([s.forge, "", "", ""]); await askInit(s.ws, given, { doc: { ref: "v1" }, keys: ["ref"] }, sc4.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc4.asked).toEqual([QF, QP1, QA, QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc4.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of acme: base → stack-api → front-a", ]); expect(sc4.left()).toBe(0); @@ -1500,10 +1534,12 @@ describe("askInit — answers that are not an item (review round 1)", () => { const sc = script([s.forge, wrong, "acme", "", ""]); const result = await askInit(s.ws, given(), local, sc.io, opts(s.home)); expect(sc.asked).toEqual([QF, QP, QP, QA, QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", " 2) globex Globex — services", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, `profile "${wrong.trim()}" not found in Forge (acme, globex)`, "Recipes of acme: base → stack-api → front-a", ]); @@ -1531,9 +1567,11 @@ describe("askInit — answers that are not an item (review round 1)", () => { // The list: 1) base · in use, 2) api · in use, 3) 2 — so "2" as a position would be api, already in use. const sc = script([s.forge, "", "yes", "", "2", ""]); const result = await askInit(s.ws, given(), local, sc.io, opts(s.home)); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of acme: base → api", `Recipes in ${s.forge}:`, " 1) base · in use", diff --git a/test/init-new-profile-cli.test.ts b/test/init-new-profile-cli.test.ts new file mode 100644 index 0000000..d90b03f --- /dev/null +++ b/test/init-new-profile-cli.test.ts @@ -0,0 +1,226 @@ +import { execFileSync } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { listFiles } from "../src/core/forge.js"; +import { runCli } from "./helpers/cli.js"; +import { makeForge, profile, recipe, rule, tmpDir, type ForgeSpec } from "./helpers/forge.js"; +import YAML from "yaml"; +import { git, gitEnv, remoteForge } from "./helpers/remote.js"; + +// Spec 38 §4.1–§4.6 through the command. Forges are local git repositories with a commit; no network, no push. + +const WIN = process.platform === "win32"; +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +const SPEC: ForgeSpec = { + ingredients: [rule("a", "# A\n"), rule("b", "# B\n"), rule("t", "# T {{token}}\n", { params: { token: { description: "nobody sets it" } } })], + recipes: [recipe("base", ["rule/a"]), recipe("stack-node", ["rule/b"]), recipe("tokened", ["rule/t"])], + profiles: [profile("acme", ["base", "stack-node"], ["claude-code", "kiro"])], +}; +async function setup(spec: ForgeSpec = SPEC) { + const parent = await fs.realpath(await tmpDir("craftar-np-cli-")); + cleanups.push(async () => { + await fs.chmod(parent, 0o755).catch(() => {}); + await fs.rm(parent, { recursive: true, force: true }); + }); + const forge = path.join(parent, "forge"); + await makeForge(forge, spec); + git(forge, "init", "-q", "-b", "main"); + git(forge, "add", "-A"); + git(forge, "commit", "-q", "-m", "init"); + const ws = path.join(parent, "ws"); + const home = path.join(parent, "home"); + const run = (args: string[], env: NodeJS.ProcessEnv = {}) => runCli(args, { env: { CRAFTAR_HOME: home, ...env } }); + return { parent, forge, ws, home, run }; +} +async function tree(dir: string): Promise> { + const out: Record = {}; + for (const rel of await listFiles(dir)) if (!rel.startsWith(".git/")) out[rel] = await fs.readFile(path.join(dir, rel), "utf8"); + return out; +} +const exists = (p: string) => fs.lstat(p).then(() => true, () => false); +const MISSING = path.join(WIN ? "C:\\" : "/", "nonexistent-forge-zq"); + +describe("refusals read off the arguments, before any load (P1–P4, N2)", () => { + it("P1, P2, P3, P4 — nothing read, nothing written", async () => { + const s = await setup(); + const cases: Array<[string[], string]> = [ + [["--forge", MISSING, "--new-profile", "initech", "--profile", "acme"], "pass --profile or --new-profile, not both"], + [["--forge", MISSING, "--profile", "acme", "--like", "acme"], "--like goes with --new-profile"], + [["--forge", MISSING, "--new-profile", "Bad"], '"Bad" is not a profile name — lowercase letters and digits, in groups joined by one "-"'], + [["--forge", MISSING, "--new-profile", "con"], '"con" is a device name on Windows — pick another'], + [["--forge", "https://git.example.com/acme/forge.git", "--new-profile", "initech"], "a new profile is written into a Forge directory — clone the Forge, pass that directory as --forge, then commit and push the profile"], + ]; + for (const [args, msg] of cases) { + const r = s.run(["init", ...args, "--workspace", s.ws]); + expect(r.code, args.join(" ")).toBe(1); + expect(r.stderr, args.join(" ")).toBe(`error: ${msg}\n`); + expect(await exists(s.ws)).toBe(false); + } + }); + it("off a terminal, --new-profile without --forge is N2's --forge line; --forge with --new-profile is not N2", async () => { + const s = await setup(); + const r = s.run(["init", "--new-profile", "initech", "--workspace", s.ws]); + expect(r.code).toBe(1); + expect(r.stderr).toBe("error: --forge is required when craftar init does not run in a terminal — pass it, or run craftar init in a terminal to be asked\n"); + }); + it("a --forge that does not exist is the load's error, not P6; N10 comes before the load", async () => { + const s = await setup(); + const r = s.run(["init", "--forge", MISSING, "--new-profile", "initech", "--workspace", s.ws]); + expect(r.code).toBe(1); + expect(r.stderr).toMatch(/Forge not found/); + await fs.mkdir(s.ws); + await fs.writeFile(path.join(s.ws, "craftar.local.yaml"), "profile: acme\n"); + const n10 = s.run(["init", "--forge", MISSING, "--new-profile", "initech", "--workspace", s.ws]); + expect(n10.code).toBe(1); + expect(n10.stderr).toMatch(/craftar\.local\.yaml/); + expect(n10.stderr).not.toMatch(/Forge not found/); + }); +}); + +describe("the run (§4.4–§4.5)", () => { + it("one new, uncommitted file in the Forge; the workspace synced on it; the output; HEAD and the branch unchanged", async () => { + const s = await setup(); + const before = await tree(s.forge); + const head = git(s.forge, "rev-parse", "HEAD"); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--like", "acme", "--workspace", s.ws]); + expect(r.code, r.stderr).toBe(0); + const after = await tree(s.forge); + const added = Object.keys(after).filter((k) => !(k in before)); + expect(added).toEqual(["profiles/initech/profile.yaml"]); + expect(after["profiles/initech/profile.yaml"]).toBe("name: initech\nrecipes:\n - base\n - stack-node\ntargets:\n - claude-code\n - kiro\n"); + for (const k of Object.keys(before)) expect(after[k], k).toBe(before[k]); + expect(git(s.forge, "status", "--porcelain")).toBe("?? profiles/initech/"); + expect(git(s.forge, "rev-parse", "HEAD")).toBe(head); + expect(git(s.forge, "symbolic-ref", "--short", "HEAD")).toBe("main"); + const lines = r.stdout.split("\n"); + expect(lines.slice(0, 4)).toEqual([ + `craftar init — created profile initech in ${s.forge}`, + " + profiles/initech/profile.yaml (not committed — commit it in the Forge, on a branch, before anyone else syncs)", + `craftar init — wrote craftar.yaml in ${s.ws}`, + " forge ../forge · profile initech · recipes base → stack-node · targets claude-code, kiro (from the profile)", + ]); + expect(r.stdout).not.toMatch(/again craftar init/); + expect(await fs.readFile(path.join(s.ws, "craftar.yaml"), "utf8")).toBe("forge: ../forge\nprofile: initech\n"); + const st = JSON.parse(s.run(["status", "--json", "--workspace", s.ws]).stdout); + expect(st.statuses.every((x: { state: string }) => x.state === "unchanged")).toBe(true); + // a second workspace of the same client starts with --profile + const ws2 = path.join(s.parent, "ws2"); + expect(s.run(["init", "--forge", s.forge, "--profile", "initech", "--workspace", ws2]).code).toBe(0); + }); + it("another workspace of the Forge plans the same after; recipes --json differs only by the new name", async () => { + const s = await setup(); + const other = path.join(s.parent, "other"); + expect(s.run(["init", "--forge", s.forge, "--profile", "acme", "--workspace", other]).code).toBe(0); + const recipesBefore = JSON.parse(s.run(["recipes", "--json", "--workspace", other]).stdout); + expect(s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--like", "acme", "--workspace", s.ws]).code).toBe(0); + const st = JSON.parse(s.run(["status", "--json", "--workspace", other]).stdout); + expect(st.statuses.every((x: { state: string }) => x.state === "unchanged")).toBe(true); + const recipesAfter = JSON.parse(s.run(["recipes", "--json", "--workspace", other]).stdout); + const strip = (j: { recipes: Array<{ name: string; profiles: string[] }> }) => j.recipes.map((r) => ({ ...r, profiles: r.profiles.filter((p) => p !== "initech") })); + expect(strip(recipesAfter)).toEqual(strip(recipesBefore)); + expect(recipesAfter.recipes.find((r: { name: string }) => r.name === "base").profiles).toContain("initech"); + }); + it("--no-sync: both files, next sync printed, exit 0; a refused first sync: exit 1, both files stay", async () => { + const s = await setup(); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--workspace", s.ws, "--no-sync"]); + expect(r.code, r.stderr).toBe(0); + expect(r.stdout).toMatch(/next sync:/); + expect(await exists(path.join(s.ws, "craftar.lock"))).toBe(false); + const t = await setup(); + const r2 = t.run(["init", "--forge", t.forge, "--new-profile", "initech", "--add-recipe", "tokened", "--workspace", t.ws]); + expect(r2.code).toBe(1); + expect(await exists(path.join(t.forge, "profiles", "initech", "profile.yaml"))).toBe(true); + expect(await exists(path.join(t.ws, "craftar.yaml"))).toBe(true); + }); + it("--ref with a Forge directory is N11; --offline is accepted and inert", async () => { + const s = await setup(); + expect(s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--ref", "main", "--workspace", s.ws]).code).toBe(1); + expect(s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--offline", "--workspace", s.ws]).code).toBe(0); + }); + it("P7 at the command: an existing profile", async () => { + const s = await setup(); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "acme", "--workspace", s.ws]); + expect(r.code).toBe(1); + expect(r.stderr).toBe("error: profile acme already exists in the Forge — use --profile acme\n"); + }); + it("--new-profile --clone is spec 37's C3, the Forge untouched", async () => { + const s = await setup(); + const before = await tree(s.forge); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--clone", "--workspace", s.ws]); + expect(r.code).toBe(1); + expect(r.stderr).toBe("error: profile initech lists no repositories — nothing to clone; drop --clone\n"); + expect(await tree(s.forge)).toEqual(before); + }); + it("P5: a tree of craftar's Forge cache passed as --forge", async () => { + const rf = await remoteForge(SPEC); + cleanups.push(rf.cleanup); + const s = await setup(); + const w0 = path.join(s.parent, "w0"); + await fs.mkdir(w0); + await fs.writeFile(path.join(w0, "craftar.yaml"), `forge: ${rf.url}\nprofile: acme\n`); + expect(s.run(["sync", "--workspace", w0]).code).toBe(0); + const forges = path.join(s.home, "forges"); + const [entry] = await fs.readdir(forges); + const trees = path.join(forges, entry, "trees"); + const [t] = (await fs.readdir(trees)).filter((n) => !n.endsWith(".ok")); + const r = s.run(["init", "--forge", path.join(trees, t), "--new-profile", "initech", "--workspace", s.ws]); + expect(r.code).toBe(1); + expect(r.stderr).toMatch(/is a tree of craftar's Forge cache — nothing is written there; clone the Forge and pass that directory\n$/); + }); +}); + +describe("review round 1 (spec 38)", () => { + it("the notes of step 1 are printed after the initLine line: spec 22's and the integrations block's", async () => { + const spec: ForgeSpec = { + ...SPEC, + recipes: [...(SPEC.recipes ?? []), recipe("pm-jira", ["rule/a"])], + profiles: [profile("acme", ["base", "stack-node"], ["claude-code"], { integrations: { pm: { kind: "jira" } } })], + }; + const s = await setup(spec); + await fs.writeFile(path.join(s.forge, "craftar.forge.yaml"), YAML.stringify({ name: "test-forge", schema: 3 })); + git(s.forge, "commit", "-q", "-am", "schema 3"); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--like", "acme", "--add-recipe", "base", "--workspace", s.ws]); + expect(r.code, r.stderr).toBe(0); + const lines = r.stdout.split("\n"); + const at = lines.findIndex((l) => l.startsWith(" forge ../forge · profile initech")); + expect(lines[at + 1]).toMatch(/^ note .*base/); + expect(lines[at + 2]).toBe(" note integrations of acme not copied — 1 recipe it selects is not in initech"); + }); + it("a ref in craftar.local.yaml: init --new-profile behaves as init --profile — it succeeds, and craftar.yaml holds no ref", async () => { + const s = await setup(); + await fs.mkdir(s.ws); + await fs.writeFile(path.join(s.ws, "craftar.local.yaml"), "ref: some-branch\n"); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "globex", "--no-sync", "--workspace", s.ws]); + expect(r.code, r.stderr).toBe(0); + expect(await fs.readFile(path.join(s.ws, "craftar.yaml"), "utf8")).toBe("forge: ../forge\nprofile: globex\n"); + }); +}); + +describe("late failures (§4.6) — not run on Windows (chmod), reported as such", () => { + it.skipIf(WIN || process.getuid?.() === 0)("craftar.yaml cannot be written after the profile was: the late-failure lines with clean -fd; running it restores the Forge", async () => { + const s = await setup(); + await fs.mkdir(s.ws); + await fs.chmod(s.ws, 0o555); + cleanups.push(() => fs.chmod(s.ws, 0o755)); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--workspace", s.ws]); + expect(r.code).toBe(1); + expect(r.stderr).toContain(`init had already started changing the Forge (${s.forge}) when this failed:\n profiles/initech\nrecover with:\n git -C ${s.forge} clean -fd -- profiles/initech\n`); + expect(await exists(path.join(s.forge, "profiles", "initech", "profile.yaml"))).toBe(true); + execFileSync("git", ["-C", s.forge, "clean", "-fd", "--", "profiles/initech"], { env: gitEnv() }); + expect(git(s.forge, "status", "--porcelain")).toBe(""); + }); + it.skipIf(WIN || process.getuid?.() === 0)("the profile write fails otherwise (a read-only profiles/): the late-failure lines, craftar.yaml not written", async () => { + const s = await setup(); + const profiles = path.join(s.forge, "profiles"); + await fs.chmod(profiles, 0o555); + cleanups.push(() => fs.chmod(profiles, 0o755)); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--workspace", s.ws]); + expect(r.code).toBe(1); + expect(r.stderr).toContain("recover with:"); + expect(await exists(path.join(s.ws, "craftar.yaml"))).toBe(false); + }); +}); diff --git a/test/init-new-profile-flow.test.ts b/test/init-new-profile-flow.test.ts new file mode 100644 index 0000000..6daa42c --- /dev/null +++ b/test/init-new-profile-flow.test.ts @@ -0,0 +1,350 @@ +import { existsSync, promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { againLine, askInit, confirmInit, type InitIo } from "../src/core/init-flow.js"; +import { planInit } from "../src/core/init.js"; +import YAML from "yaml"; +import { makeForge, profile, recipe, rule, tmpDir, type ForgeSpec } from "./helpers/forge.js"; +import { git, remoteForge } from "./helpers/remote.js"; + +// Spec 38 §4.3 — the interactive flow, through InitIo with scripted answers (spec 28 §9.2: the suite cannot drive a +// real terminal). Forge directories are git repositories with a commit unless a test says otherwise. + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +const SPEC: ForgeSpec = { + ingredients: [rule("a", "# A\n"), rule("b", "# B\n"), rule("c", "# C\n")], + recipes: [recipe("base", ["rule/a"]), recipe("stack-node", ["rule/b"]), recipe("extra", ["rule/c"]), recipe("base--acme", ["rule/a"])], + profiles: [profile("acme", ["base", "stack-node"], ["claude-code", "kiro"], { description: "ACME — portal and API" }), profile("globex", ["base"], ["kiro"])], +}; +async function setup(opts: { spec?: ForgeSpec; git?: boolean } = {}) { + const parent = await fs.realpath(await tmpDir("craftar-np-flow-")); + cleanups.push(() => fs.rm(parent, { recursive: true, force: true })); + const forge = path.join(parent, "forge"); + await makeForge(forge, opts.spec ?? SPEC); + if (opts.git !== false) { + git(forge, "init", "-q", "-b", "main"); + git(forge, "add", "-A"); + git(forge, "commit", "-q", "-m", "init"); + } + const ws = path.join(parent, "ws"); + await fs.mkdir(ws); + return { parent, forge, ws, home: path.join(parent, "home") }; +} +function script(answers: Array) { + const asked: string[] = []; + const said: string[] = []; + const next = async (q: string) => { + asked.push(q); + if (!answers.length) throw new Error(`no scripted answer left for: ${q}`); + return answers.shift()!; + }; + const io: InitIo = { ask: next, confirm: next, say: (l) => void said.push(l) }; + return { io, asked, said, left: () => answers.length }; +} +const GIVEN = { addRecipes: [] as string[], removeRecipes: [] as string[], replace: false }; +const LOCAL = { doc: null, keys: [] as never[] }; +const ask = (s: { ws: string; home: string }, given: Record, io: InitIo) => + askInit(s.ws, { ...GIVEN, ...given }, LOCAL, io, { home: s.home, registryOff: true, exists: existsSync }); + +describe("the profile list gains `0) a new profile` (§4.3)", () => { + it("a Forge directory that passes P5/P6: the entry; 0 starts the new-profile questions", async () => { + const s = await setup(); + const sc = script(["0", "initech", "acme", "", ""]); + const r = await ask(s, { forge: s.forge }, sc.io); + expect(sc.said).toContain(" 0) a new profile"); + expect(sc.asked).toEqual([ + "Profile: ", + "New profile's name: ", + "Start from an existing profile [none]: ", + "Adjust the recipes [no]: ", + "Targets — claude-code, kiro, agents-md, separated by commas [claude-code, kiro, from acme]: ", + ]); + expect(sc.said).toContain("Recipes of initech (from acme): base → stack-node"); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.profile).toBe("initech"); + expect(r.input.newProfile).toEqual({ name: "initech", like: "acme" }); + }); + it("a Forge directory that is not a git repository: no entry, P6's text as the line under the list", async () => { + const s = await setup({ git: false }); + const sc = script(["acme", "", ""]); + await ask(s, { forge: s.forge }, sc.io); + expect(sc.said.some((l) => l.includes("0) a new profile"))).toBe(false); + expect(sc.said).toContain(`cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`); + }); + it("a URL Forge: no entry, one line", async () => { + const r = await remoteForge(SPEC); + cleanups.push(r.cleanup); + const s = await setup(); + const sc = script(["", "acme", "", ""]); + await ask(s, { forge: r.url }, sc.io); + expect(sc.said.some((l) => l.includes("0) a new profile"))).toBe(false); + expect(sc.said).toContain("a new profile needs a Forge directory"); + }); + it("a profile literally named 0 wins over the entry", async () => { + const s = await setup({ spec: { ...SPEC, profiles: [profile("0", ["base"]), ...(SPEC.profiles ?? [])] } }); + const sc = script(["0", "", ""]); + const r = await ask(s, { forge: s.forge }, sc.io); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.profile).toBe("0"); + expect(r.input.newProfile).toBeUndefined(); + }); +}); + +describe("the new-profile questions (§4.3)", () => { + it("the name is re-asked on an empty answer, P3 and P7", async () => { + const s = await setup(); + const sc = script(["0", "", "Bad", "acme", "initech", "", "", ""]); + await ask(s, { forge: s.forge }, sc.io); + expect(sc.asked.filter((q) => q === "New profile's name: ").length).toBe(4); + expect(sc.said).toContain('"Bad" is not a profile name — lowercase letters and digits, in groups joined by one "-"'); + expect(sc.said).toContain("profile acme already exists in the Forge — use --profile acme"); + }); + it("no template: the Forge's recipes, then `Add [none]: ` directly; the targets' default says `default`", async () => { + const s = await setup(); + const sc = script(["0", "initech", "", "extra", ""]); + const r = await ask(s, { forge: s.forge }, sc.io); + expect(sc.asked).toContain("Add [none]: "); + expect(sc.asked).not.toContain("Adjust the recipes [no]: "); + expect(sc.asked.at(-1)).toBe("Targets — claude-code, kiro, agents-md, separated by commas [claude-code, default]: "); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.newProfile).toEqual({ name: "initech" }); + expect(r.input.addRecipes).toEqual(["extra"]); + }); + it("an unknown template is said and asked again (P9); a number picks from the list", async () => { + const s = await setup(); + const sc = script(["0", "initech", "nope", "1", "", ""]); + const r = await ask(s, { forge: s.forge }, sc.io); + expect(sc.said).toContain('--like: profile "nope" not found in Forge (acme, globex)'); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.newProfile).toEqual({ name: "initech", like: "acme" }); + }); + it("P10: an owned recipe in the list is said, and the recipes question asked again opening on Remove", async () => { + const spec = { ...SPEC, profiles: [profile("acme", ["base--acme", "stack-node"]), profile("globex", ["base"])] }; + const s = await setup({ spec }); + const sc = script(["0", "initech", "acme", "", "base--acme", "base", ""]); + const r = await ask(s, { forge: s.forge }, sc.io); + expect(sc.said).toContain("recipe base--acme is named for profile acme — a new profile cannot use it; leave it out and add the shared recipe it stands for"); + const i = sc.asked.indexOf("Adjust the recipes [no]: "); + expect(sc.asked[i + 1]).toBe("Remove [none]: "); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.removeRecipes).toEqual(["base--acme"]); + expect(r.input.addRecipes).toEqual(["base"]); + }); + it("a Forge directory with no profile goes straight to the questions", async () => { + const s = await setup({ spec: { ...SPEC, profiles: [] } }); + const sc = script(["initech", "", ""]); + const r = await ask(s, { forge: s.forge }, sc.io); + expect(sc.said).toContain("the Forge has no profile yet — creating the first one"); + expect(sc.asked[0]).toBe("New profile's name: "); + expect(sc.asked).not.toContain("Start from an existing profile [none]: "); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.newProfile).toEqual({ name: "initech" }); + }); + it("--new-profile typed, --forge asked: the list is printed before the template question; a typed name failing P7 is a refusal", async () => { + const s = await setup(); + const sc = script([s.forge, "", "", ""]); + const r = await ask(s, { newProfile: "initech" }, sc.io); + expect(sc.asked[0]).toBe("Forge — a directory or a git URL: "); + const listAt = sc.said.indexOf(`Profiles in ${s.forge}:`); + expect(listAt).toBeGreaterThanOrEqual(0); + expect(sc.asked).toContain("Start from an existing profile [none]: "); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.newProfile).toEqual({ name: "initech" }); + const t = await setup(); + await expect(ask(t, { forge: t.forge, newProfile: "acme" }, script([]).io)).rejects.toThrow("profile acme already exists in the Forge — use --profile acme"); + }); +}); + +describe("confirmInit and againLine with a new profile (§4.3)", () => { + it("the confirmation names both writes; the again line is the next workspace's run", async () => { + const s = await setup(); + const { loadForgeSource } = await import("../src/core/sync.js"); + const { forgeSource } = await import("../src/core/init.js"); + const loaded = await loadForgeSource(s.ws, forgeSource(s.ws, s.forge), null, { mode: "sync" }); + const init = await planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech", like: "acme" }, loaded }); + const sc = script([""]); + expect(await confirmInit(init, s.ws, sc.io, { sync: true })).toBe("confirmed"); + expect(sc.said[0]).toBe(`craftar init — about to create profile initech in ${s.forge} and write craftar.yaml in ${s.ws}`); + expect(sc.asked).toEqual(["Create profiles/initech/profile.yaml (not committed), write craftar.yaml and sync [yes]: "]); + const sc2 = script([""]); + await confirmInit(init, s.ws, sc2.io, { sync: false }); + expect(sc2.asked).toEqual(["Create profiles/initech/profile.yaml (not committed) and write craftar.yaml [yes]: "]); + expect( + againLine({ forge: "../forge", profile: "initech", addRecipes: [], removeRecipes: [], replace: false }, { sync: true, offline: false }), + ).toBe(" again craftar init --forge ../forge --profile initech"); + }); +}); + +describe("review round 1 (spec 38 §4.2, §4.3)", () => { + it("a template in a Forge at schema 3: the draft goes through ProfileSchema — no raw TypeError", async () => { + const spec: ForgeSpec = { + ...SPEC, + recipes: [...(SPEC.recipes ?? []), recipe("pm-jira", ["rule/a"])], + profiles: [profile("acme", ["base"], ["claude-code"], { integrations: { pm: { kind: "jira" } } })], + }; + const s = await setup({ spec }); + await fs.writeFile(path.join(s.forge, "craftar.forge.yaml"), YAML.stringify({ name: "test-forge", schema: 3 })); + git(s.forge, "commit", "-q", "-am", "schema 3"); + const sc = script(["", ""]); + const r = await ask(s, { forge: s.forge, newProfile: "initech", like: "acme" }, sc.io); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.newProfile).toEqual({ name: "initech", like: "acme" }); + expect(sc.said).toContain("Recipes of initech (from acme): base"); + }); + it("flags typed with --new-profile answer their questions: nothing is asked for them", async () => { + const s = await setup(); + const sc = script([]); + const r = await ask(s, { forge: s.forge, newProfile: "initech", like: "acme", addRecipes: ["extra"], targets: ["kiro"] }, sc.io); + expect(sc.asked).toEqual([]); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.addRecipes).toEqual(["extra"]); + expect(r.input.targets).toEqual(["kiro"]); + }); + it("a typed --like the Forge does not have is refused before any question (P9)", async () => { + const s = await setup(); + await expect(ask(s, { forge: s.forge, newProfile: "initech", like: "nope" }, script([]).io)).rejects.toThrow('--like: profile "nope" not found in Forge (acme, globex)'); + }); + it("--new-profile typed, the Forge asked: a URL answer is P4, said and asked again — nothing fetched", async () => { + const rf = await remoteForge(SPEC); + cleanups.push(rf.cleanup); + const s = await setup(); + const sc = script([rf.url, s.forge, "", "", ""]); + const r = await ask(s, { newProfile: "initech" }, sc.io); + expect(sc.said).toContain("a new profile is written into a Forge directory — clone the Forge, pass that directory as --forge, then commit and push the profile"); + expect(sc.asked.filter((q) => q.startsWith("Forge")).length).toBe(2); + expect(await fs.lstat(path.join(s.home, "forges")).then(() => true, () => false)).toBe(false); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.forge).toBe(s.forge); + }); + it("--new-profile typed, the Forge asked: a directory that is not a git repository is said and asked again", async () => { + const bad = await setup({ git: false }); + const s = await setup(); + const sc = script([bad.forge, s.forge, "", "", ""]); + const r = await ask(s, { newProfile: "initech" }, sc.io); + const p6 = `cannot create a profile in ${bad.forge}: it is not a git repository with a commit — git is how a Forge write is undone`; + expect(sc.said.filter((l) => l === p6).length).toBe(1); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.forge).toBe(s.forge); + }); + it("a Forge directory with no profile that fails P6, the Forge asked: said once, and the Forge asked again", async () => { + const bad = await setup({ spec: { ...SPEC, profiles: [] }, git: false }); + const s = await setup({ spec: { ...SPEC, profiles: [] } }); + const sc = script([bad.forge, s.forge, "initech", "", ""]); + const r = await ask(s, {}, sc.io); + const p6 = `cannot create a profile in ${bad.forge}: it is not a git repository with a commit — git is how a Forge write is undone`; + expect(sc.said.filter((l) => l === p6).length).toBe(1); + expect(sc.said).toContain("the Forge has no profile yet — creating the first one"); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.newProfile).toEqual({ name: "initech" }); + }); + it("a held slot in the new profile's recipes asks `replace `, as spec 28 does", async () => { + const spec: ForgeSpec = { + ...SPEC, + recipes: [...(SPEC.recipes ?? []), recipe("front-a", ["rule/b"], { slot: "front" }), recipe("front-b", ["rule/c"], { slot: "front" })], + profiles: [profile("acme", ["base", "front-a"], ["claude-code"]), profile("globex", ["base"], ["kiro"])], + }; + const s = await setup({ spec }); + const sc = script(["yes", "", "front-b", "yes", ""]); + const r = await ask(s, { forge: s.forge, newProfile: "initech", like: "acme" }, sc.io); + expect(sc.asked).toContain('front-b takes the slot "front" that front-a holds — replace front-a [no]: '); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.addRecipes).toEqual(["front-b"]); + expect(r.input.replace).toBe(true); + }); +}); + +describe("review round 2 (spec 38 §4.3)", () => { + it("a typed targets answer: the confirmation's line says (from the profile) — the targets went into it", async () => { + const s = await setup(); + const { loadForgeSource } = await import("../src/core/sync.js"); + const { forgeSource } = await import("../src/core/init.js"); + const loaded = await loadForgeSource(s.ws, forgeSource(s.ws, s.forge), null, { mode: "sync" }); + const init = await planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech", like: "acme" }, targets: ["kiro"], loaded }); + const sc = script([""]); + await confirmInit(init, s.ws, sc.io, { sync: true }); + expect(sc.said[1]).toMatch(/targets kiro \(from the profile\)$/); + }); + it("P10 is checked whatever path leaves the recipes question — Adjust yes, then Enter at Remove and Add", async () => { + const spec = { ...SPEC, profiles: [profile("acme", ["base--acme", "stack-node"]), profile("globex", ["base"])] }; + const s = await setup({ spec }); + const sc = script(["yes", "", "", "base--acme", "base", ""]); + const r = await ask(s, { forge: s.forge, newProfile: "initech", like: "acme" }, sc.io); + expect(sc.said).toContain("recipe base--acme is named for profile acme — a new profile cannot use it; leave it out and add the shared recipe it stands for"); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.removeRecipes).toEqual(["base--acme"]); + }); + it("spec 22's notes are said at the recipes question, as the existing flow says them", async () => { + const s = await setup(); + const sc = script(["yes", "", "base", ""]); + await ask(s, { forge: s.forge, newProfile: "initech", like: "acme" }, sc.io); + expect(sc.said.some((l) => l.startsWith(" note ") && l.includes("base"))).toBe(true); + }); + it("the name is re-asked on P8 (git ignores the path)", async () => { + const s = await setup(); + await fs.writeFile(path.join(s.forge, ".gitignore"), "profiles/initech/\n"); + git(s.forge, "add", ".gitignore"); + git(s.forge, "commit", "-q", "-m", "ignore"); + const sc = script(["0", "initech", "other", "", "", ""]); + const r = await ask(s, { forge: s.forge }, sc.io); + expect(sc.said).toContain("cannot create profiles/initech/profile.yaml: git ignores it"); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.newProfile).toEqual({ name: "other" }); + }); + it("after a URL refused by P4 the Forge question offers no default — Enter is not that URL again", async () => { + const rf = await remoteForge(SPEC); + cleanups.push(rf.cleanup); + const s = await setup(); + const sc = script([rf.url, s.forge, "", "", ""]); + await ask(s, { newProfile: "initech" }, sc.io); + expect(sc.asked[1]).toBe("Forge — a directory or a git URL: "); + }); +}); + +describe("review round 3 (spec 38 §4.3: the same refusals re-asked)", () => { + it("an unknown name at Remove is said and the recipes question asked again", async () => { + const s = await setup(); + const sc = script(["yes", "nope", "", "", ""]); + const r = await ask(s, { forge: s.forge, newProfile: "initech", like: "acme" }, sc.io); + expect(sc.said.some((l) => /^recipe "nope" not found in this Forge \(/.test(l))).toBe(true); + expect(sc.asked.filter((q) => q === "Adjust the recipes [no]: ").length).toBe(2); + expect(r.kind).toBe("answered"); + }); + it("a name at both Remove and Add is N6, said at the question and asked again", async () => { + const s = await setup(); + const sc = script(["yes", "extra", "extra", "", ""]); + const r = await ask(s, { forge: s.forge, newProfile: "initech", like: "acme" }, sc.io); + expect(sc.said.some((l) => /both added and removed/.test(l))).toBe(true); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.addRecipes ?? []).toEqual([]); + expect(r.input.removeRecipes ?? []).toEqual([]); + }); + it("a craftar.local.yaml that sets targets: no targets question; the local value is said and stays a workspace layer", async () => { + const s = await setup(); + const sc = script([""]); + const r = await askInit(s.ws, { ...GIVEN, forge: s.forge, newProfile: "initech", like: "acme" }, { doc: { targets: ["kiro"] }, keys: ["targets"] as never[] }, sc.io, { + home: s.home, + registryOff: true, + exists: existsSync, + }); + expect(sc.asked.some((q) => q.startsWith("Targets"))).toBe(false); + expect(sc.said).toContain("Targets: kiro (from craftar.local.yaml)"); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.targets).toBeUndefined(); + }); + it("a craftar.local.yaml that sets recipes: no recipes question; the local chain is said", async () => { + const s = await setup(); + const sc = script([""]); + const r = await askInit(s.ws, { ...GIVEN, forge: s.forge, newProfile: "initech", like: "acme" }, { doc: { recipes: { add: ["extra"] } }, keys: ["recipes"] as never[] }, sc.io, { + home: s.home, + registryOff: true, + exists: existsSync, + }); + expect(sc.asked).not.toContain("Adjust the recipes [no]: "); + expect(sc.said.some((l) => l.startsWith("Recipes: ") && l.endsWith("(from craftar.local.yaml)"))).toBe(true); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.addRecipes ?? []).toEqual([]); + }); +}); diff --git a/test/init-new-profile-pins.test.ts b/test/init-new-profile-pins.test.ts new file mode 100644 index 0000000..1d23d7b --- /dev/null +++ b/test/init-new-profile-pins.test.ts @@ -0,0 +1,74 @@ +import { existsSync, promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { askInit, type InitIo } from "../src/core/init-flow.js"; +import { runCli } from "./helpers/cli.js"; +import { makeForge, profile, recipe, rule, tmpDir } from "./helpers/forge.js"; + +// Spec 38 §10: outputs pinned BEFORE the build touches the code behind them — `init` with both flags (it must keep +// every byte once --new-profile exists), and the profile question's answer `0`, which matches nothing today and +// re-asks (spec 38 tests the new `0)` entry between the names and the numbers). The link refusals of +// assertNoLinkOnPath are already pinned byte for byte by test/drift-promote.test.ts and test/promote.test.ts, and +// forge promote's writes by its golden; measured at 3faa0b4: every Forge writer journals files only, so a +// lateFailure that tells a created directory by `stat` changes none of their lines. + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +async function scratch(): Promise { + const dir = await fs.realpath(await tmpDir("craftar-np-pin-")); + cleanups.push(() => fs.rm(dir, { recursive: true, force: true })); + return dir; +} +const SPEC = { + ingredients: [rule("a", "# A\n"), rule("b", "# B\n")], + recipes: [recipe("base", ["rule/a"]), recipe("extra", ["rule/b"])], + profiles: [profile("acme", ["base"], ["claude-code", "kiro"]), profile("globex", ["base"], ["kiro"])], +}; + +describe("pin — init with --forge and --profile (before --new-profile exists)", () => { + it("stdout byte for byte, a recipe flag included", async () => { + const p = await scratch(); + await makeForge(path.join(p, "forge"), SPEC); + const ws = path.join(p, "ws"); + const r = runCli(["init", "--forge", path.join(p, "forge"), "--profile", "acme", "--add-recipe", "extra", "--workspace", ws]); + expect(r.code, r.stderr).toBe(0); + expect(r.stderr).toBe(""); + expect(r.stdout).toBe( + [ + `craftar init — wrote craftar.yaml in ${ws}`, + " forge ../forge · profile acme · recipes base → extra · targets claude-code, kiro (from the profile)", + "craftar sync — profile acme · recipes base → extra · targets claude-code, kiro", + " wrote 4, removed 0 orphan(s), skipped 0", + " + .claude/rules/a.md", + " + .claude/rules/b.md", + " + .kiro/steering/a.md", + " + .kiro/steering/b.md", + "", + ].join("\n"), + ); + expect(await fs.readFile(path.join(ws, "craftar.yaml"), "utf8")).toBe("forge: ../forge\nprofile: acme\nrecipes:\n add:\n - extra\n"); + }); +}); + +describe("pin — the profile question's answer `0` matches nothing today and re-asks", () => { + it("0, then a name", async () => { + const p = await scratch(); + const forge = path.join(p, "forge"); + await makeForge(forge, SPEC); + const ws = path.join(p, "ws"); + await fs.mkdir(ws); + const answers = ["0", "acme"]; + const asked: string[] = []; + const said: string[] = []; + const next = async (q: string) => (asked.push(q), answers.length ? answers.shift()! : ""); + const io: InitIo = { ask: next, confirm: next, say: (l) => void said.push(l) }; + const r = await askInit(ws, { forge, addRecipes: [], removeRecipes: [], replace: false }, { doc: null, keys: [] }, io, { registryOff: true, exists: existsSync }); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.profile).toBe("acme"); + const profileQuestions = asked.filter((q) => q.startsWith("Profile")); + expect(profileQuestions.length).toBe(2); + expect(said.some((l) => l.includes("0) a new profile"))).toBe(false); + }); +}); diff --git a/test/init-new-profile.test.ts b/test/init-new-profile.test.ts new file mode 100644 index 0000000..985b2eb --- /dev/null +++ b/test/init-new-profile.test.ts @@ -0,0 +1,88 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { forgeSource, planInit } from "../src/core/init.js"; +import { loadForgeSource } from "../src/core/sync.js"; +import { makeForge, profile, recipe, rule, tmpDir, type ForgeSpec } from "./helpers/forge.js"; + +// Spec 38 §5.2 — planInit with `newProfile`: always handed the loaded Forge, plans the workspace on the copy that +// holds the new profile, and writes nothing. The command's writes (§5.3) are tested at the CLI. + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +const SPEC: ForgeSpec = { + ingredients: [rule("a", "# A\n"), rule("b", "# B\n"), rule("c", "# C\n")], + recipes: [recipe("base", ["rule/a"]), recipe("stack-node", ["rule/b"]), recipe("extra", ["rule/c"]), recipe("broken", ["rule/a"], { extends: ["nope"] })], + profiles: [profile("acme", ["base", "stack-node"], ["claude-code", "kiro"], { params: { org: "zqmark" } })], +}; +async function setup(spec: ForgeSpec = SPEC) { + const parent = await fs.realpath(await tmpDir("craftar-np-plan-")); + cleanups.push(() => fs.rm(parent, { recursive: true, force: true })); + const forge = path.join(parent, "forge"); + await makeForge(forge, spec); + const ws = path.join(parent, "ws"); + await fs.mkdir(ws); + const loaded = await loadForgeSource(ws, forgeSource(ws, forge), null, { mode: "sync" }); + return { parent, forge, ws, loaded }; +} +const files = (p: { files: Array<{ path: string; content: Buffer | string }> }) => + Object.fromEntries(p.files.map((f) => [f.path, Buffer.from(f.content as Buffer).toString("base64")])); + +describe("planInit with newProfile", () => { + it("plans what the same profile written by hand plans; craftar.yaml holds no recipes and no targets; the loaded Forge is not mutated", async () => { + const s = await setup(); + const init = await planInit(s.ws, { + forge: s.forge, + profile: "initech", + newProfile: { name: "initech", like: "acme" }, + addRecipes: ["extra"], + targets: ["kiro"], + loaded: s.loaded, + }); + expect(init.newProfile).toEqual({ + path: "profiles/initech/profile.yaml", + text: "name: initech\nrecipes:\n - base\n - stack-node\n - extra\ntargets:\n - kiro\n", + blockNote: null, + }); + expect(init.text).toBe("forge: ../forge\nprofile: initech\n"); + expect(s.loaded.forge.profiles.has("initech")).toBe(false); + expect(init.repos).toEqual([]); + + const t = await setup(); + await fs.mkdir(path.join(t.forge, "profiles", "initech")); + await fs.writeFile(path.join(t.forge, "profiles", "initech", "profile.yaml"), init.newProfile!.text); + const byHand = await planInit(t.ws, { forge: t.forge, profile: "initech" }); + expect(files(init.plan)).toEqual(files(byHand.plan)); + expect(Object.keys(files(init.plan)).sort()).toEqual([".kiro/steering/a.md", ".kiro/steering/b.md", ".kiro/steering/c.md"]); + }); + it("the new profile carries none of the template's params", async () => { + const s = await setup(); + const init = await planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech", like: "acme" }, loaded: s.loaded }); + expect(init.newProfile!.text.includes("zqmark")).toBe(false); + expect(init.ws.forge.profiles.get("initech")!.params).toEqual({}); + }); + it("spec 22's notes reach InitPlan.notes", async () => { + const s = await setup(); + const init = await planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech", like: "acme" }, addRecipes: ["base"], loaded: s.loaded }); + expect(init.notes.length).toBe(1); + expect(init.notes[0]).toMatch(/base/); + }); + it("P12 / P11: a new profile whose recipes do not resolve is refused — nothing written", async () => { + const s = await setup(); + await expect(planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech" }, addRecipes: ["broken"], loaded: s.loaded })).rejects.toThrow(/^recipe "nope" not found/); + expect(await fs.readdir(path.join(s.forge, "profiles"))).toEqual(["acme"]); + expect(await fs.readdir(s.ws)).toEqual([]); + }); + it("N10 unchanged: a local targets with no --targets is merged into the workspace; with --targets it is refused", async () => { + const s = await setup(); + await fs.writeFile(path.join(s.ws, "craftar.local.yaml"), "targets: [claude-code]\n"); + const init = await planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech", like: "acme" }, loaded: s.loaded }); + expect(Object.keys(files(init.plan)).every((p) => p.startsWith(".claude/"))).toBe(true); + expect(init.newProfile!.text).toContain("targets:\n - claude-code\n - kiro\n"); + await expect( + planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech" }, targets: ["kiro"], loaded: s.loaded }), + ).rejects.toThrow(/craftar\.local\.yaml/); + }); +}); diff --git a/test/new-profile-gate.test.ts b/test/new-profile-gate.test.ts new file mode 100644 index 0000000..b3f740e --- /dev/null +++ b/test/new-profile-gate.test.ts @@ -0,0 +1,162 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { assertNoLinkOnPath, loadForge } from "../src/core/forge.js"; +import { newProfileGate } from "../src/core/new-profile-gate.js"; +import { makeForge, profile, recipe, rule, tmpDir, type ForgeSpec } from "./helpers/forge.js"; +import { git } from "./helpers/remote.js"; + +// Spec 38 §4.2 P5–P8 and §5.2 — the gate of the one path `init --new-profile` creates. Local git repositories in +// temp dirs; no network. Directory links are junctions on win32 so those cases run on both platforms. + +const WIN = process.platform === "win32"; +const DIR_LINK = WIN ? "junction" : "dir"; +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +async function scratch(prefix = "craftar-np-gate-"): Promise { + const dir = await fs.realpath(await tmpDir(prefix)); + cleanups.push(() => fs.rm(dir, { recursive: true, force: true })); + return dir; +} +const SPEC: ForgeSpec = { + ingredients: [rule("a", "# A\n")], + recipes: [recipe("base", ["rule/a"])], + profiles: [profile("acme", ["base"])], +}; +/** The Forge as the command loaded it — before the obstacle a test puts on the disk (the gate reads the disk itself). */ +const loaded = new Map>>(); +const L = (root: string) => loaded.get(root)!; +/** A Forge directory that is a git repository with one commit (or not, or with none). */ +async function forgeRepo(opts: { git?: "commit" | "init" | "none"; spec?: ForgeSpec } = {}) { + const root = path.join(await scratch(), "forge"); + await makeForge(root, opts.spec ?? SPEC); + const mode = opts.git ?? "commit"; + if (mode !== "none") git(root, "init", "-q", "-b", "main"); + if (mode === "commit") { + git(root, "add", "-A"); + git(root, "commit", "-q", "-m", "init"); + } + loaded.set(root, await loadForge(root)); + return root; +} +const refusal = async (p: Promise): Promise => { + try { + await p; + } catch (e) { + return (e as Error).message; + } + throw new Error("expected a refusal"); +}; +const LINK_FILE = (rel: string) => `${rel} is a symbolic link in the Forge — init does not write through a link; replace it with the file and commit`; +const LINK_DIR = "profiles is a symbolic link in the Forge — init does not read a linked recipes, ingredients or profiles directory; replace it with the directory and commit"; + +describe("assertNoLinkOnPath takes the writing command's name", () => { + it("init's words; the default keeps promote's bytes", async () => { + const root = await forgeRepo(); + const out = await scratch(); + await fs.symlink(out, path.join(root, "profiles", "initech"), DIR_LINK); + const target = path.join(root, "profiles", "initech"); + expect(await refusal(assertNoLinkOnPath(root, [target], "init"))).toBe(LINK_FILE("profiles/initech")); + expect(await refusal(assertNoLinkOnPath(root, [target]))).toBe( + "profiles/initech is a symbolic link in the Forge — promote does not write through a link; replace it with the file and commit", + ); + }); +}); + +describe("newProfileGate — P5 and P6 (no name: the flow's question)", () => { + it("a clean repository with a commit passes, with and without a name", async () => { + const root = await forgeRepo(); + const forge = await loadForge(root); + const home = await scratch(); + await expect(newProfileGate(forge, home)).resolves.toBeUndefined(); + await expect(newProfileGate(forge, home, "initech")).resolves.toBeUndefined(); + }); + it("a dirty Forge passes (the write overwrites nothing, §6 case 5)", async () => { + const root = await forgeRepo(); + await fs.writeFile(path.join(root, "recipes", "base.yaml"), "name: base\ningredients: [rule/a]\n# edited\n"); + await expect(newProfileGate(await loadForge(root), await scratch(), "initech")).resolves.toBeUndefined(); + }); + it("P6: not a git repository, and a repository with no commit", async () => { + for (const mode of ["none", "init"] as const) { + const root = await forgeRepo({ git: mode }); + expect(await refusal(newProfileGate(await loadForge(root), await scratch()))).toBe( + `cannot create a profile in ${root}: it is not a git repository with a commit — git is how a Forge write is undone`, + ); + } + }); + it("P5: a Forge directory under $CRAFTAR_HOME/forges — refused before git is asked", async () => { + const home = await scratch(); + const root = path.join(home, "forges", "example-0123456789ab", "trees", "abc"); + await makeForge(root, SPEC); + expect(await refusal(newProfileGate(await loadForge(root), home, "initech"))).toBe( + `${root} is a tree of craftar's Forge cache — nothing is written there; clone the Forge and pass that directory`, + ); + // A home whose forges/ does not exist yet: the check passes on to P6 + const other = await forgeRepo(); + await expect(newProfileGate(L(other), path.join(await scratch(), "nothing-yet"), "initech")).resolves.toBeUndefined(); + }); +}); + +describe("newProfileGate — P7 and P8 (a name)", () => { + it("P7: a profile of that name exists — by its name, even in another directory", async () => { + const root = await forgeRepo({ spec: { ...SPEC, profiles: [profile("acme", ["base"])] } }); + const forge = await loadForge(root); + expect(await refusal(newProfileGate(forge, await scratch(), "acme"))).toBe("profile acme already exists in the Forge — use --profile acme"); + const r2 = await forgeRepo({ spec: { ...SPEC, profiles: [{ name: "initech", recipes: ["base"] }] } }); + await fs.rename(path.join(r2, "profiles", "initech"), path.join(r2, "profiles", "old")); + expect(await refusal(newProfileGate(L(r2), await scratch(), "initech"))).toBe("profile initech already exists in the Forge — use --profile initech"); + }); + it("P7: profiles/ exists and holds no such profile — an empty directory, a regular file, another case", async () => { + const msg = (found: string) => `profiles/${found} already exists in the Forge and holds no profile named initech — pick another name, or remove it`; + const r1 = await forgeRepo(); + await fs.mkdir(path.join(r1, "profiles", "initech")); + expect(await refusal(newProfileGate(L(r1), await scratch(), "initech"))).toBe(msg("initech")); + const r2 = await forgeRepo(); + await fs.writeFile(path.join(r2, "profiles", "initech"), "a file\n"); + expect(await refusal(newProfileGate(L(r2), await scratch(), "initech"))).toBe(msg("initech")); + const r3 = await forgeRepo(); + await fs.mkdir(path.join(r3, "profiles", "Initech")); + expect(await refusal(newProfileGate(L(r3), await scratch(), "initech"))).toBe(msg("Initech")); + }); + it("P8: git ignores profiles//profile.yaml", async () => { + const root = await forgeRepo(); + await fs.writeFile(path.join(root, ".gitignore"), "profiles/initech/\n"); + expect(await refusal(newProfileGate(L(root), await scratch(), "initech"))).toBe("cannot create profiles/initech/profile.yaml: git ignores it"); + }); + it(`P8: profiles/ is a link — to a directory, dangling (${DIR_LINK}); links come before P7`, async () => { + const r1 = await forgeRepo(); + const out = await scratch(); + await fs.symlink(out, path.join(r1, "profiles", "initech"), DIR_LINK); + expect(await refusal(newProfileGate(L(r1), await scratch(), "initech"))).toBe(LINK_FILE("profiles/initech")); + const r2 = await forgeRepo(); + await fs.symlink(path.join(r2, "gone"), path.join(r2, "profiles", "initech"), DIR_LINK); + expect(await refusal(newProfileGate(L(r2), await scratch(), "initech"))).toBe(LINK_FILE("profiles/initech")); + }); + it.skipIf(WIN)("P8: profiles/ is a link to a file — not run on Windows", async () => { + const root = await forgeRepo(); + const out = await scratch(); + await fs.writeFile(path.join(out, "f"), "x"); + await fs.symlink(path.join(out, "f"), path.join(root, "profiles", "initech"), "file"); + expect(await refusal(newProfileGate(L(root), await scratch(), "initech"))).toBe(LINK_FILE("profiles/initech")); + }); + it(`P8: profiles itself is a link (${DIR_LINK}) — the directory form, also when its target holds `, async () => { + for (const holds of [false, true]) { + const root = await forgeRepo(); + const forge = await loadForge(root); + const real = path.join(await scratch(), "profiles-real"); + await fs.rename(path.join(root, "profiles"), real); + if (holds) await fs.mkdir(path.join(real, "initech")); + await fs.symlink(real, path.join(root, "profiles"), DIR_LINK); + expect(await refusal(newProfileGate(forge, await scratch(), "initech"))).toBe(LINK_DIR); + } + }); + it.skipIf(WIN)("a link named Initech against initech on a case-sensitive disk: P7, the path as found — not run on Windows", async () => { + const root = await forgeRepo(); + await fs.symlink(await scratch(), path.join(root, "profiles", "Initech"), "dir"); + expect(await refusal(newProfileGate(L(root), await scratch(), "initech"))).toBe( + "profiles/Initech already exists in the Forge and holds no profile named initech — pick another name, or remove it", + ); + }); +}); diff --git a/test/new-profile-write.test.ts b/test/new-profile-write.test.ts new file mode 100644 index 0000000..ee335a0 --- /dev/null +++ b/test/new-profile-write.test.ts @@ -0,0 +1,125 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { forgeSource, planInit } from "../src/core/init.js"; +import { NewProfileLateFailure, NewProfileRaceLost, reproveFromDisk, writeNewProfile } from "../src/core/new-profile-write.js"; +import { loadForgeSource } from "../src/core/sync.js"; +import { makeForge, profile, recipe, rule, tmpDir, type ForgeSpec } from "./helpers/forge.js"; + +// Spec 38 §5.3 steps 3–4 and §4.6 — the profile write and the second proof, with injected hooks standing for a second +// run or an edit between the steps (the seams §10 asks for). In-process; temp dirs only. + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +const SPEC: ForgeSpec = { + ingredients: [rule("a", "# A\n"), rule("b", "# B\n")], + recipes: [recipe("base", ["rule/a"]), recipe("stack-node", ["rule/b"])], + profiles: [profile("acme", ["base", "stack-node"], ["claude-code"])], +}; +async function setup(spec: ForgeSpec = SPEC) { + const parent = await fs.realpath(await tmpDir("craftar-np-write-")); + cleanups.push(() => fs.rm(parent, { recursive: true, force: true })); + const forge = path.join(parent, "forge"); + await makeForge(forge, spec); + const ws = path.join(parent, "ws"); + await fs.mkdir(ws); + const loaded = await loadForgeSource(ws, forgeSource(ws, forge), null, { mode: "sync" }); + // Only use like: "acme" when the spec has the acme profile + const like = spec.profiles?.some((p) => p.name === "acme") ? "acme" : undefined; + const init = await planInit(ws, { forge, profile: "initech", newProfile: { name: "initech", like }, loaded }); + return { parent, forge, ws, init }; +} +const TEXT = "name: initech\nrecipes:\n - base\n - stack-node\ntargets:\n - claude-code\n"; + +describe("writeNewProfile — step 3", () => { + it("writes the file with its text, journals the profile's own directory only, before the mkdir", async () => { + const s = await setup(); + const journal = await writeNewProfile(s.forge, s.init); + expect(await fs.readFile(path.join(s.forge, "profiles", "initech", "profile.yaml"), "utf8")).toBe(TEXT); + expect(journal).toEqual([{ abs: path.join(s.forge, "profiles", "initech"), created: true }]); + }); + it("a Forge with no profiles/: created with the profile's directory; profiles is never journalled", async () => { + const s = await setup({ ...SPEC, profiles: [] }); + const init = await planInit(s.ws, { + forge: s.forge, + profile: "initech", + newProfile: { name: "initech" }, + loaded: await loadForgeSource(s.ws, forgeSource(s.ws, s.forge), null, { mode: "sync" }), + }); + const journal = await writeNewProfile(s.forge, init); + expect(journal.map((j) => path.relative(s.forge, j.abs))).toEqual([path.join("profiles", "initech")]); + expect(await fs.readFile(path.join(s.forge, "profiles", "initech", "profile.yaml"), "utf8")).toBe("name: initech\nrecipes: []\ntargets:\n - claude-code\n"); + }); + it("another run made profiles// first: NewProfileRaceLost with P7's first message; the other run's file untouched", async () => { + const s = await setup(); + const other = path.join(s.forge, "profiles", "initech", "profile.yaml"); + const e = await writeNewProfile(s.forge, s.init, { + beforeMkdir: async () => { + await fs.mkdir(path.dirname(other), { recursive: true }); + await fs.writeFile(other, "name: initech\nrecipes: []\n# the other run\n"); + }, + }).catch((x) => x); + expect(e).toBeInstanceOf(NewProfileRaceLost); + expect((e as Error).message).toBe("profile initech already exists in the Forge — use --profile initech"); + expect(await fs.readFile(other, "utf8")).toBe("name: initech\nrecipes: []\n# the other run\n"); + }); + it("the wx is the backstop: a file put there between the mkdir and the write is the same refusal", async () => { + const s = await setup(); + const file = path.join(s.forge, "profiles", "initech", "profile.yaml"); + const e = await writeNewProfile(s.forge, s.init, { beforeWrite: async () => void (await fs.writeFile(file, "x\n")) }).catch((x) => x); + expect(e).toBeInstanceOf(NewProfileRaceLost); + expect(await fs.readFile(file, "utf8")).toBe("x\n"); + }); + it("profiles/ made by another run, with another profile in it, between the gate and the write: the run goes on", async () => { + const s = await setup({ ...SPEC, profiles: [] }); + const init = await planInit(s.ws, { + forge: s.forge, + profile: "initech", + newProfile: { name: "initech" }, + loaded: await loadForgeSource(s.ws, forgeSource(s.ws, s.forge), null, { mode: "sync" }), + }); + await writeNewProfile(s.forge, init, { + beforeMkdir: async () => { + await fs.mkdir(path.join(s.forge, "profiles", "globex"), { recursive: true }); + await fs.writeFile(path.join(s.forge, "profiles", "globex", "profile.yaml"), "name: globex\n"); + }, + }); + expect((await fs.readdir(path.join(s.forge, "profiles"))).sort()).toEqual(["globex", "initech"]); + }); + it("any other failure is a NewProfileLateFailure carrying the journal", async () => { + const s = await setup(); + const e = await writeNewProfile(s.forge, s.init, { beforeWrite: async () => { throw Object.assign(new Error("EIO: disk"), { code: "EIO" }); } }).catch((x) => x); + expect(e).toBeInstanceOf(NewProfileLateFailure); + expect((e as NewProfileLateFailure).journal.map((j) => j.abs)).toEqual([path.join(s.forge, "profiles", "initech")]); + }); +}); + +describe("reproveFromDisk — step 4", () => { + it("the Forge read back plans the same paths and bytes: the second plan is returned, with no recipe or target flag in craftar.yaml", async () => { + const s = await setup(); + await writeNewProfile(s.forge, s.init); + const second = await reproveFromDisk(s.ws, s.forge, s.init, {}); + expect(second.text).toBe("forge: ../forge\nprofile: initech\n"); + expect(second.plan.files.map((f) => f.path)).toEqual(s.init.plan.files.map((f) => f.path)); + }); + it("an unrelated file written between the steps changes nothing; a recipe edited does — a late failure", async () => { + const s = await setup(); + await writeNewProfile(s.forge, s.init); + await fs.writeFile(path.join(s.forge, "NOTES.md"), "unrelated\n"); + await expect(reproveFromDisk(s.ws, s.forge, s.init, {})).resolves.toBeTruthy(); + await fs.writeFile(path.join(s.forge, "ingredients", "rules", "a", "rule.md"), "# A, edited meanwhile\n"); + const e = await reproveFromDisk(s.ws, s.forge, s.init, {}).catch((x) => x); + expect(e).toBeInstanceOf(NewProfileLateFailure); + expect((e as Error).message).toBe("the Forge read back from disk does not plan what init proved in memory — a Forge file changed while init ran"); + }); + it("an error the second planInit itself throws (a recipe removed between the steps) is a late failure too, with the journal", async () => { + const s = await setup(); + const journal = await writeNewProfile(s.forge, s.init); + await fs.rm(path.join(s.forge, "recipes", "stack-node.yaml")); + const e = await reproveFromDisk(s.ws, s.forge, s.init, {}, journal).catch((x) => x); + expect(e).toBeInstanceOf(NewProfileLateFailure); + expect((e as NewProfileLateFailure).journal).toBe(journal); + }); +}); diff --git a/test/new-profile.test.ts b/test/new-profile.test.ts new file mode 100644 index 0000000..24d9efb --- /dev/null +++ b/test/new-profile.test.ts @@ -0,0 +1,216 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import YAML from "yaml"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadForge } from "../src/core/forge.js"; +import { newProfile, ownedBy, profileName, withProfile } from "../src/core/new-profile.js"; +import { namedForProfile } from "../src/core/promote.js"; +import { ProfileSchema } from "../src/schema/index.js"; +import { makeForge, profile, recipe, rule, tmpDir, type ForgeSpec } from "./helpers/forge.js"; + +// Spec 38 §3, §4.4, §5.1, §5.2, §6 cases 7–8 — the pure part of a new profile. Nothing here writes into a Forge. + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +const MARK = "zq" + "mark"; + +async function forgeOf(spec: ForgeSpec, schema = 1) { + const root = await fs.realpath(await tmpDir("craftar-new-profile-")); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + await makeForge(root, spec); + if (schema !== 1) await fs.writeFile(path.join(root, "craftar.forge.yaml"), YAML.stringify({ name: "test-forge", schema })); + return loadForge(root); +} +const fault = (fn: () => unknown): string => { + try { + fn(); + } catch (e) { + return (e as Error).message; + } + throw new Error("expected a refusal"); +}; + +const BASE_SPEC: ForgeSpec = { + ingredients: ["a", "b", "c", "fa", "fb"].map((n) => rule(n, `# ${n}\n`)), + recipes: [ + recipe("base", ["rule/a"]), + recipe("stack-node", ["rule/b"]), + recipe("extra", ["rule/c"]), + recipe("front-a", ["rule/fa"], { slot: "front" }), + recipe("front-b", ["rule/fb"], { slot: "front" }), + ], + profiles: [ + profile("acme", ["base", "stack-node", "front-a"], ["claude-code", "kiro"], { + description: `ACME ${MARK}`, + params: { "acme.org": `org-${MARK}` }, + sections: { "rule/a": { intro: `intro ${MARK}` } }, + repos: [{ name: "api", url: `https://git.example.com/${MARK}/api.git` }], + scm: { kind: "github", org: MARK }, + }), + profile("globex", ["base"], ["kiro"]), + ], +}; + +describe("profileName — P3 (§5.1)", () => { + it("accepted", () => { + for (const n of ["initech", "acme-portal", "a1", "0"]) expect(() => profileName(n)).not.toThrow(); + }); + it("refused: not a profile name", () => { + for (const n of ["Acme", "acme--x", "-acme", "acme-", "a/b", "a.b", "..", "", "a b", "a_b"]) + expect(fault(() => profileName(n)), n).toBe(`"${n}" is not a profile name — lowercase letters and digits, in groups joined by one "-"`); + }); + it("refused: a device name on Windows, on every platform", () => { + for (const n of ["con", "nul", "com1", "lpt9", "aux", "prn"]) expect(fault(() => profileName(n)), n).toBe(`"${n}" is a device name on Windows — pick another`); + }); +}); + +describe("namedForProfile and ownedBy — the owned-recipe test (§3)", () => { + it("namedForProfile: -- or -steering", () => { + expect(namedForProfile("base--acme", "acme")).toBe(true); + expect(namedForProfile("acme-steering", "acme")).toBe(true); + expect(namedForProfile("base", "acme")).toBe(false); + expect(namedForProfile("base--acmex", "acme")).toBe(false); + }); + it("ownedBy: owned recipes belong to a profile of the Forge; others to no one", async () => { + const forge = await forgeOf(BASE_SPEC); + for (const r of ["base--acme", "stack-api--acme", "acme-steering", "tools--acme"]) expect(ownedBy(forge, r), r).toBe("acme"); + for (const r of ["base", "stack-node", "base--nobody"]) expect(ownedBy(forge, r), r).toBeNull(); + }); + it("ownedBy: two matches — the longer profile name wins", async () => { + const forge = await forgeOf({ ...BASE_SPEC, profiles: [profile("b", ["base"]), profile("x--b", ["base"])] }); + expect(ownedBy(forge, "r--x--b")).toBe("x--b"); + }); + it("withProfile: a copy holding the new profile; the loaded Forge is not mutated", async () => { + const forge = await forgeOf(BASE_SPEC); + const p = ProfileSchema.parse({ name: "initech", recipes: ["base"], targets: ["claude-code"] }); + const copy = withProfile(forge, p); + expect(copy.profiles.get("initech")).toBe(p); + expect(forge.profiles.has("initech")).toBe(false); + expect([...copy.profiles.keys()].sort()).toEqual(["acme", "globex", "initech"]); + }); +}); + +describe("newProfile — the file of §4.4", () => { + it("no template: no recipe, the default target", async () => { + const forge = await forgeOf(BASE_SPEC); + const r = newProfile(forge, { name: "initech" }); + expect(r.text).toBe("name: initech\nrecipes: []\ntargets:\n - claude-code\n"); + expect(r.profile.recipes).toEqual([]); + expect(r.blockNote).toBeNull(); + }); + it("--like: the template's written recipes and targets, plus an add, nothing of its values", async () => { + const forge = await forgeOf(BASE_SPEC); + const r = newProfile(forge, { name: "initech", like: "acme", add: ["extra"] }); + expect(r.text).toBe("name: initech\nrecipes:\n - base\n - stack-node\n - front-a\n - extra\ntargets:\n - claude-code\n - kiro\n"); + expect(r.text.includes(MARK)).toBe(false); + const parsed = ProfileSchema.parse(YAML.parse(r.text)); + expect(parsed.recipes).toEqual(["base", "stack-node", "front-a", "extra"]); + expect(parsed.targets).toEqual(["claude-code", "kiro"]); + expect(Object.keys(YAML.parse(r.text))).toEqual(["name", "recipes", "targets"]); + }); + it("--remove-recipe, --targets", async () => { + const forge = await forgeOf(BASE_SPEC); + const r = newProfile(forge, { name: "initech", like: "acme", remove: ["stack-node"], targets: ["kiro"] }); + expect(r.text).toBe("name: initech\nrecipes:\n - base\n - front-a\ntargets:\n - kiro\n"); + }); + it("--replace on a held slot", async () => { + const forge = await forgeOf(BASE_SPEC); + const r = newProfile(forge, { name: "initech", like: "acme", add: ["front-b"], replace: true }); + expect(r.profile.recipes).toEqual(["base", "stack-node", "front-b"]); + }); + it("P11: spec 22's own refusals — an unknown recipe, a held slot without --replace", async () => { + const forge = await forgeOf(BASE_SPEC); + expect(fault(() => newProfile(forge, { name: "initech", add: ["nope"] }))).toMatch(/^recipe "nope" not found in this Forge \(/); + expect(fault(() => newProfile(forge, { name: "initech", like: "acme", add: ["front-b"] }))).toMatch(/front/); + }); + it("P9: --like a profile the Forge does not have", async () => { + const forge = await forgeOf(BASE_SPEC); + expect(fault(() => newProfile(forge, { name: "initech", like: "nope" }))).toBe('--like: profile "nope" not found in Forge (acme, globex)'); + }); +}); + +describe("newProfile — P10, an owned recipe in the list (§6 case 7)", () => { + const SPEC: ForgeSpec = { + ...BASE_SPEC, + recipes: [...(BASE_SPEC.recipes ?? []), recipe("base--acme", ["rule/a"]), recipe("x--initech", ["rule/a"])], + profiles: [profile("acme", ["base--acme", "stack-node"]), profile("globex", ["base"])], + }; + it("brought by the template, or named by --add-recipe: refused, the recipe and the profile named", async () => { + const forge = await forgeOf(SPEC); + const msg = "recipe base--acme is named for profile acme — a new profile cannot use it; leave it out and add the shared recipe it stands for"; + expect(fault(() => newProfile(forge, { name: "initech", like: "acme" }))).toBe(msg); + expect(fault(() => newProfile(forge, { name: "initech", add: ["base--acme"] }))).toBe(msg); + }); + it("passes once the owned recipe is removed and the shared one added", async () => { + const forge = await forgeOf(SPEC); + const r = newProfile(forge, { name: "initech", like: "acme", remove: ["base--acme"], add: ["base"] }); + expect(r.profile.recipes).toEqual(["stack-node", "base"]); + }); + it("a recipe named for the new profile itself belongs to no one: the loaded Forge decides", async () => { + const forge = await forgeOf(SPEC); + expect(newProfile(forge, { name: "initech", add: ["x--initech"] }).profile.recipes).toEqual(["x--initech"]); + }); +}); + +describe("newProfile — a template's integrations block (§6 case 8, Ruling 3)", () => { + const SPEC = (block: unknown, recipes: string[] = ["base"]): ForgeSpec => ({ + ...BASE_SPEC, + recipes: [...(BASE_SPEC.recipes ?? []), recipe("pm-azure", ["rule/c"]), recipe("idp-backstage", ["rule/b"])], + profiles: [profile("acme", recipes, ["claude-code"], { integrations: block }), profile("globex", ["base"])], + }); + it("schema 3: a recipe only the block selects is not copied — one note with the count, singular and plural", async () => { + const one = await forgeOf(SPEC({ pm: { kind: "azure", org: MARK } }), 3); + const r1 = newProfile(one, { name: "initech", like: "acme" }); + expect(r1.profile.recipes).toEqual(["base"]); + expect(r1.blockNote).toBe("integrations of acme not copied — 1 recipe it selects is not in initech"); + expect(r1.text.includes(MARK) || (r1.blockNote ?? "").includes(MARK)).toBe(false); + const two = await forgeOf(SPEC({ pm: { kind: "azure" }, idp: { kind: "backstage" } }), 3); + expect(newProfile(two, { name: "initech", like: "acme" }).blockNote).toBe("integrations of acme not copied — 2 recipes it selects are not in initech"); + }); + it("no note when every recipe the block selects is in the final list — written by the template, or added", async () => { + const f = await forgeOf(SPEC({ pm: { kind: "azure" } }, ["base", "pm-azure"]), 3); + expect(newProfile(f, { name: "initech", like: "acme" }).blockNote).toBeNull(); + const g = await forgeOf(SPEC({ pm: { kind: "azure" } }), 3); + expect(newProfile(g, { name: "initech", like: "acme", add: ["pm-azure"] }).blockNote).toBeNull(); + }); + it("schema 2: the block is not read — no note", async () => { + const f = await forgeOf(SPEC({ pm: { kind: "azure" } }), 2); + expect(newProfile(f, { name: "initech", like: "acme" }).blockNote).toBeNull(); + }); + it("a block that does not read: the list, the other wording, nothing of the resolver's message", async () => { + const f = await forgeOf(SPEC({ pm: MARK }), 3); + const r = newProfile(f, { name: "initech", like: "acme" }); + expect(r.profile.recipes).toEqual(["base"]); + expect(r.blockNote).toBe("integrations of acme not copied — the block does not read, so what it selects is not known"); + }); +}); + +describe("newProfile — recipe names YAML would misread (review round 2)", () => { + it("a recipe named 123, true or null is written quoted and reads back as that string", async () => { + const forge = await forgeOf({ ...BASE_SPEC, recipes: [...(BASE_SPEC.recipes ?? []), recipe("123", ["rule/a"]), recipe("true", ["rule/b"]), recipe("null", ["rule/c"])] }); + const r = newProfile(forge, { name: "initech", add: ["123", "true", "null"] }); + const back = YAML.parse(r.text); + expect(back.recipes).toEqual(["123", "true", "null"]); + expect(ProfileSchema.parse(back).recipes).toEqual(r.profile.recipes); + expect(r.text).toContain(' - "123"\n'); + }); + it("plain names stay unquoted (the §4.4 bytes)", async () => { + const forge = await forgeOf(BASE_SPEC); + expect(newProfile(forge, { name: "initech", like: "acme" }).text).toContain(" - base\n - stack-node\n"); + }); +}); + +describe("newProfile — a profile name YAML would misread (review round 3)", () => { + it("--new-profile 123, null, true, 1e3: the name line is quoted and reads back as that string", async () => { + const forge = await forgeOf(BASE_SPEC); + for (const name of ["123", "null", "true", "1e3"]) { + const r = newProfile(forge, { name }); + const back = YAML.parse(r.text); + expect(back.name, name).toBe(name); + expect(r.profile.name).toBe(name); + } + expect(newProfile(forge, { name: "initech" }).text.startsWith("name: initech\n")).toBe(true); + }); +}); diff --git a/test/ui-readme.test.ts b/test/ui-readme.test.ts index def2cf8..6ef9e0c 100644 --- a/test/ui-readme.test.ts +++ b/test/ui-readme.test.ts @@ -33,7 +33,7 @@ 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`).")).toBe(1); + expect(count("; cloning a profile's repositories (`init --clone`, `craftar repos`); a new profile from `craftar init` (`--new-profile`, `--like`).")).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); });