craftar 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -5
- package/dist/cli.js +267 -54
- package/dist/cli.js.map +1 -1
- package/dist/core/cache.js +57 -7
- package/dist/core/cache.js.map +1 -1
- package/dist/core/clone.js +416 -0
- package/dist/core/clone.js.map +1 -0
- package/dist/core/doctor.js +22 -9
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/forge.js +48 -0
- package/dist/core/forge.js.map +1 -1
- package/dist/core/init-flow.js +121 -3
- package/dist/core/init-flow.js.map +1 -1
- package/dist/core/init.js +10 -1
- package/dist/core/init.js.map +1 -1
- package/dist/core/integrations.js +8 -1
- package/dist/core/integrations.js.map +1 -1
- package/dist/core/registry.js +123 -4
- package/dist/core/registry.js.map +1 -1
- package/dist/core/remote.js +31 -15
- package/dist/core/remote.js.map +1 -1
- package/dist/core/repos.js +299 -0
- package/dist/core/repos.js.map +1 -0
- package/dist/core/sync.js +7 -2
- package/dist/core/sync.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
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.
|
|
4
4
|
|
|
5
|
-
> 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** (`<!-- craftar:section <name> -->` … `<!-- /craftar:section -->`), 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.
|
|
5
|
+
> 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** (`<!-- craftar:section <name> -->` … `<!-- /craftar:section -->`), 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.
|
|
6
6
|
|
|
7
7
|
## The idea in one paragraph
|
|
8
8
|
|
|
@@ -68,7 +68,9 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
|
|
|
68
68
|
|
|
69
69
|
| Command | What it does |
|
|
70
70
|
|---|---|
|
|
71
|
-
| `craftar init [--forge <dir\|url>] [--profile <name>] [--ref <ref>] [--targets <a,b>] [--add-recipe <name>]... [--remove-recipe <name>]... [--replace] [--no-sync] [--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. |
|
|
71
|
+
| `craftar init [--forge <dir\|url>] [--profile <name>] [--ref <ref>] [--targets <a,b>] [--add-recipe <name>]... [--remove-recipe <name>]... [--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=<ref>] -- <url> <path>` 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 <names>`, 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). |
|
|
72
|
+
| `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 <p> 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). |
|
|
73
|
+
| `craftar repos clone [<name>…] [--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. |
|
|
72
74
|
| `craftar import --from claude-code --forge <dir> --profile <name> [--workspace .] [--write-config] [--report <file.md>] [--prefer-kiro <rule,…>]` | 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/<name>.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/<name>.md` is written back byte for byte and neither `.kiro/steering/<name>.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/<name>.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, `<profile>-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 `<name>--<profile>` 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 `<name>--<profile>` 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 `<recipe>--<profile>`. 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 <names>` when a section value filled the render), `inferred`, `sectioned`, `param <key>: <old> → <new>`, `section <key> <name>: <old> → <new>`, `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 <r> (integrations.<slot>): …`, 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 <file.md>` 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 <file.md>`; 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 <rule,…>` takes the named rules from `.kiro/steering/<name>.md` instead of `.claude/rules/<name>.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/<name>.md` (the flag takes rules; a steering file with no rule is already imported as steering); a rule with no `.kiro/steering/<name>.md`; a steering file holding a form Kiro writes for a rule it does not have (`AGENTS.md (rule: <x>)`, 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 <ref> — source .kiro/steering/<name>.md (N lines; .claude/rules/<name>.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/<name>.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/<name>.md` again and lands that older text as a `<name>--<profile>` 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. |
|
|
73
75
|
| `craftar status` | With a remote Forge, the header's second line names it — `forge <url> @ <ref \| <branch> (default branch)> <commit>`, plus `· lock <commit>` when the Forge moved since the last sync — and `--json` carries `forge: {kind, source, ref, defaultBranch, commit, lockCommit, fetched}` for every Forge. Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. A `.json` file not yet in `craftar.lock` that parses to the planned value with its keys in the same order (it differs only in whitespace, escapes, number spelling or duplicate keys) is `adopt`, and the first `sync` rewrites it with the planned bytes — by design. For `claude-code`'s `.mcp.json` that is two-space JSON keeping the file's BOM and line endings; Kiro's JSON files become CRLF without a BOM, like every Kiro text file. A different key order is a `collision` — except for integer-like keys, which `JSON.parse` reorders. A locked JSON file reformatted by hand is `drift`, not `adopt`. `--json` for tooling: `{forge, statuses, warnings, unsetParams}`. Exits 0 whenever it prints its listing, with two exceptions: when a declared parameter a file cites has no value (see `sync`) it prints the listing, then the refusal on stderr, and exits 1; and when `AGENTS.md` is over an explicit budget (see *Targets › agents-md*) it prints the listing, then that block on stderr, and exits 1. In both cases `--json` still prints the whole object — with `unsetParams: [{key, declaredBy, citedBy}]` (`[]` when there is none) and the budget line in `warnings` — and prints no block on stderr. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in an emitted file not every target renders as text (copied as they are). |
|
|
74
76
|
| `craftar sync` | Renders sections (no rendered body holds a marker; an emitted file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock` (schema 2, or 3 when the workspace has a service — see *Local services*; it records the Forge's source, requested `ref` and commit, the resolved recipes and targets — rewritten only when that content changes). With a remote Forge it fetches first and exits 1 when the fetch fails, `--dry-run` and `--check` included. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync, or when `AGENTS.md` is over an explicit budget (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default); `craftar drift` lists them, discards one, or promotes one to the Forge. A writing `sync` (not `--dry-run`, not `--check`) also records the workspace in `$CRAFTAR_HOME/registry.json` (see *Workspace registry*); a registry it cannot write is a warning, never a failed `sync`. **Refuses when a declared parameter has no value**: a key an ingredient declares under `params` with no `default`, that a file the plan writes cites as `{{key}}`, and that no layer sets (recipe default, profile `params`, `overrides.params` in `craftar.yaml` or `craftar.local.yaml`). The plan's warnings print, then one block on stderr — `error: N declared parameter(s) have no value — nothing written`, one line per key with the ingredients that declare it and the ones that cite it, and the fix — and `sync` exits 1 with nothing written: no file, no lock, no registry entry; `--dry-run` exits 1 the same way, and `--check` prints its listing, then the block, and exits 1 even when every file is `unchanged`. A `{{key}}` no ingredient declares (an Angular `{{ 'X' \| localize }}`, a stray placeholder) is still left verbatim with a warning, and an empty string is a value. (That is a body; a `{{key}}` in an MCP server of a schema 4 Forge follows *Server params*.) |
|
|
@@ -195,7 +197,7 @@ What `sync` does with a `{{key}}` in a server, at schema 4:
|
|
|
195
197
|
|
|
196
198
|
The message names the ingredient, the field and the kind — `mcp/idp: the value rendered into server.headers.Authorization looks like a credential (github-token) — …` — and never the value. The way out is to keep the secret out of a param: have the server read it from the environment, and declare the variable in `authEnv` so `doctor` and the example settings file name it. A URL whose userinfo is made only of `${NAME}` references, such as `https://${IDP_USER}:${IDP_TOKEN}@idp.example.com/`, is let through. `doctor`'s `mcp-env` checks the names it finds in the rendered server's `env` and in `authEnv`; it reads no other field.
|
|
197
199
|
|
|
198
|
-
The check narrows the road; it does not close it. **Still passes, though it is a secret:** a token of an unknown format (outside `env` and `args` nothing looks for it; inside them it can only warn, and only when the whole value is letters, digits and `+ / = _ -`) — so `Bearer <token>`, `KEY: <token>` and a `…;Password=…` connection string pass when the token's format is not a known one; a secret in a URL's path or query (`https://idp.example.com/mcp?api_key=<token>`); a URL userinfo holding `/`, `?`, `#`, a quote, `,` or `;`, or white space other than a tab or a line break directly before the `@` — so a token followed by a space (`https://<token> @host/`) passes, and so does a tab inside the userinfo; a quote, `,` or `;` directly after the `://`; a
|
|
200
|
+
The check narrows the road; it does not close it. **Still passes, though it is a secret:** a token of an unknown format (outside `env` and `args` nothing looks for it; inside them it can only warn, and only when the whole value is letters, digits and `+ / = _ -`) — so `Bearer <token>`, `KEY: <token>` and a `…;Password=…` connection string pass when the token's format is not a known one; a secret in a URL's path or query (`https://idp.example.com/mcp?api_key=<token>`); a URL userinfo holding `/`, `?`, `#`, a quote, `,` or `;`, or white space other than a tab or a line break directly before the `@` — so a token followed by a space (`https://<token> @host/`) passes, and so does a tab inside the userinfo; a quote, `,` or `;` directly after the `://`; a user-only token whose scheme is broken by a space (`ht<SPACE>tps://<token>@host/` — it no longer reads as `http(s)`; with a password it is still refused; a tab or a line break inside the scheme or its `://` is read past since 0.32.1); credentials with no `://` (`user:<secret>@host`); a user-only token on a scheme that is not `http(s)`, `ws(s)` or `<x>+` one of them (`ssh://<token>@host/`); anything that is neither a URL with a userinfo nor a token of a known format. One more way a value can show: a param value written as `${<something>}` and rendered under `env` reads, to `doctor`'s `mcp-env`, as a variable reference: it prints `<something>` as the name of a variable it expects when that has the shape of a name and is not set — a known token format inside the braces is refused before that, an unknown one is not. **Refused, though it is not a secret:** a URL on `http(s)`, `ws(s)` or `<x>+` one of them with a user and no password (`https://acme@dev.azure.com/acme/Portal`); a userinfo that is not made only of plain references — a literal mixed with one (`postgres://acme:${DB_PASS}@db.example.com/app`; write `${DB_USER}:${DB_PASS}`) or a reference with a default (`https://${A:-x}@host/`); a URL with no path followed, with none of `/ ? #`, white space, a quote, `,` or `;` between, by text holding an `@` (`https://a.example.com&cc=ops@example.com`); a URL on any scheme but `file` written with an empty authority — extra `/`, `\` or white space after the `://` — when what follows then reads as a userinfo (`sqlite:///a:b@c.db`; on `http(s)`, `ws(s)` or `<x>+` one of them an `@` alone is enough: `https:///ops@example.com`; in prose, `see :// a:b@c`); a URL with no path, query or fragment followed by a tab or by line breaks — a blank line between too — and then an `@` (`https://registry.npmjs.org`, then `@acme/pkg` on the next line; a markdown link or an unquoted CSS `url(…)` followed by a line that starts with `@`), since a tab, a line feed or a carriage return directly before an `@` is read past — on a scheme that is not `http(s)`, `ws(s)` or `<x>+` one of them, only when the text before the `@` holds a `:`, such as a port; a standalone run of 52 lowercase letters and digits that holds a digit. The only way round a refusal is to keep the value out of a param: write it literally in the ingredient, or have the server read the environment. A literal the Forge author wrote in a server, with no placeholder in it, is **not** checked, at any schema: it is emitted as written, and `craftar import`'s scan stays the guard on what enters a Forge.
|
|
199
201
|
|
|
200
202
|
**Sections.** A body (its **body files**: the `file` of a rule, agent, command or steering — `rule.md` etc. by default; `SKILL.md` of a file-layout skill; every `.md`, `.txt`, `.json`, `.yaml` or `.yml` file of a dir-layout skill; the text files listed in `files` of a script or hook — a body file stays inside the ingredient directory; one declared outside it, or behind a symlinked directory, or spelled differently from the file on disk (letter case, on a case-insensitive file system), fails `sync`, `status`, `diff`, `explain` and `ls`, naming it) can hold blocks a profile or a workspace replaces. A file no target emits (a `notes.md` beside `rule.md`) is ignored — for sections and for `{{param}}` citations alike; a file a target emits but does not render as text is copied (see below). A block sits between two marker lines, and its content is the default:
|
|
201
203
|
|
|
@@ -232,6 +234,21 @@ sections:
|
|
|
232
234
|
|
|
233
235
|
A value is a string. It replaces the section's lines; a value without a final newline gets one, and `""` empties the section. The blank lines around the markers stay when a section is emptied, so put a separating blank line inside the section when an emptied section should leave none extra. A value may cite `{{param}}`: sections are expanded before params are substituted.
|
|
234
236
|
|
|
237
|
+
**Repositories.** A profile's `repos` lists the client's repositories, for `craftar init --clone` and `craftar repos`:
|
|
238
|
+
|
|
239
|
+
```yaml
|
|
240
|
+
# profiles/acme/profile.yaml
|
|
241
|
+
repos:
|
|
242
|
+
- name: portal-api
|
|
243
|
+
url: https://git.example.com/acme/portal-api.git
|
|
244
|
+
- name: portal-web
|
|
245
|
+
url: git@git.example.com:acme/portal-web.git
|
|
246
|
+
path: apps/portal-web # optional, default: the name — relative to the workspace
|
|
247
|
+
ref: release # optional: a branch or a tag
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`name` is letters, digits, `.`, `_` and `-`, starting with a letter or a digit, not ending in `.`, not a Windows device name, and unique without case. `url` is an `https://`, `ssh://` or `user@host:path` URL made of `A-Z a-z 0-9 . _ ~ : / @ + -` only, its first character after `://` (or its first character, in the `user@host:path` form), its host and (in that form) its path each starting with a letter or a digit, and without credentials — let git authenticate. `path` is `/`-separated segments that each follow `name`'s rule (so no dot-folder), has a first segment that is none of `AGENTS.md`, `craftar.yaml`, `craftar.local.yaml` and `craftar.lock` (compared without case), and no two entries share or nest a destination (compared without case). `ref` is a branch or a tag, never a commit id. No other key. The block is checked only when a command reads it — a Forge whose `repos` holds something else still loads and syncs — and a refusal names the entry's index and key, never a value.
|
|
251
|
+
|
|
235
252
|
**Integrations.** A profile's `integrations` block can select recipes and give params in a Forge that declares `schema: 3` or above:
|
|
236
253
|
|
|
237
254
|
```yaml
|
|
@@ -487,7 +504,7 @@ The server answers for the directory it is started in; a client that starts its
|
|
|
487
504
|
|
|
488
505
|
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.
|
|
489
506
|
|
|
490
|
-
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)
|
|
507
|
+
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`).
|
|
491
508
|
|
|
492
509
|
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).
|
|
493
510
|
|
|
@@ -495,6 +512,13 @@ Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`;
|
|
|
495
512
|
|
|
496
513
|
## Upgrading
|
|
497
514
|
|
|
515
|
+
### to 0.32.1
|
|
516
|
+
|
|
517
|
+
- **A cache entry no clean registry row vouches for is printed by a label.** An entry an earlier release named after a URL with credentials keeps that name on disk, and once no registry row names it — right after a re-sync without the credentials — `craftar doctor` and `craftar cache prune` printed it, the user and the password with it. Now a cache entry's name is printed only when a registry row with a clean URL, or the checked workspace, names it; every other one — an orphan, and every entry when `CRAFTAR_NO_REGISTRY` is set or the registry cannot be read — reads `(a cache entry name — not shown)`: `doctor`'s `nothing names it` and `a fetch never completed` lines, and `cache prune`'s rows, its `--json` `path` (`forges/(a cache entry name — not shown)`, `forges/~removing-(a cache entry name — not shown)-<pid>`) and its warnings. Two such rows read the same; each keeps its kind, reason and size, and nothing on disk is renamed. An entry a registry row with credentials names keeps `(a URL with credentials — not shown)`, as since 0.28.1. Where there is no snapshot of the cache — `doctor`'s `cannot read the Forge cache: …` and `cache prune`'s `cannot read …/forges: …` — the name that follows `forges/` in the error is that label whoever names it, a key labelled `(a URL with credentials — not shown)` since 0.28.1 included.
|
|
518
|
+
- **More `forge:` spellings are refused as a URL with credentials**: a scheme followed by a single `/` (`https:/<user>:<password>@host/…`, or `https:/<user>@host/…`), and a tab, a line feed or a carriage return inside the scheme or its `://` (`https:<TAB>//…`, `ht<TAB>tps://<user>@…`) — read before as a path, and printed whole by `Forge not found at …`. They now get `… › forge holds credentials in the URL — remove them and let git authenticate`, in `craftar.yaml` and `craftar.local.yaml`, `init --forge` and its questions, `import --write-config` — which replaced such a value with the clone's path and exited 0, and now exits 1 and leaves it — a remote of a Forge clone and a template `source`; the exit code is 1 as before — and 1, where it was 0, when a directory of that name existed and loaded. A `ref:` spelled that way is named by the label and refused as an answer of the interactive `craftar init`, which also no longer offers a Forge recorded as a path whose spelling holds a credential. A `missing` or `error` row the registry recorded with such a spelling now reads `(a URL with credentials — not shown)` as its `forge.source`, with `forge.key: null`, in `craftar workspaces --json` and in the `craftar ui` table. A `forge:` that is a drive path holding `@` and a second `:` (`C:/x:y@z`) is refused too (fail-closed).
|
|
519
|
+
- **In a Forge at `schema: 4` or above, a server param whose rendered value holds a credential behind a tab or a line break inside the scheme or its `://` now fails the plan**, as one behind a plain `://` did: a workspace that synced with such a value stops syncing until the value leaves the param — a refusal, no byte written.
|
|
520
|
+
- No emitted byte, lock, registry entry or cache layout changes, and no `--json` key is added or removed.
|
|
521
|
+
|
|
498
522
|
### to 0.32.0
|
|
499
523
|
|
|
500
524
|
- **A workspace whose `AGENTS.md` is above 32768 bytes now gets a warning on every plan.** `sync --check`, `status` and `diff --exit-code` fail only when `agents-md.maxBytes` is set explicitly; a writing `sync` still writes.
|
|
@@ -545,7 +569,7 @@ Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`;
|
|
|
545
569
|
### to 0.28.1
|
|
546
570
|
|
|
547
571
|
- **A Forge URL with credentials that an earlier release recorded is no longer printed from the registry.** A release before `0.27.1` could record such a URL (see *to 0.27.1*). For a `missing` or an `error` row whose recorded URL holds credentials, `craftar workspaces --json` now gives `(a URL with credentials — not shown)` as `forge.source` and `null` as `forge.key`. `craftar cache prune` — its lines and, under `--json`, `kept[].path`, `removed[].path` and `warnings` — and the `cache` check of `craftar doctor` put the same label where the name of the cache entry such a row names would be: `path` then reads `forges/(a URL with credentials — not shown)`, `forges/(a URL with credentials — not shown)/trees/<commit>` or `forges/~removing-(a URL with credentials — not shown)-<pid>`, which is not a path, and two such entries read the same; a failure either command reports with a path inside such an entry names it the same way. No `--json` key is added, removed or renamed, and no type changes: `forge.key` could already be `null` (a path Forge that cannot be resolved); a `remote` row can now carry it too. A row that loads gives what `craftar.yaml` holds, as before.
|
|
548
|
-
- **A cache entry that no registered workspace names any more is still printed by its name
|
|
572
|
+
- **A cache entry that no registered workspace names any more is still printed by its name** — until 0.32.1, which prints it by a label (see *to 0.32.1*). Once every workspace that named the URL has synced again without the credentials, the registry no longer records it, and nothing in the cache says which entry was named after a refused URL. `craftar doctor` (`<name> — nothing names it`, `<name> — a fetch never completed`) and `craftar cache prune` (the line of the entry it removes or keeps, of a tree of one it keeps, and a warning about it) then print that name, which holds the user and the password. With `CRAFTAR_NO_REGISTRY` set or a registry that cannot be read, `cache prune` names every entry that way, and `doctor` every one whose fetch never completed. Run `craftar cache prune` right after the re-sync, in a terminal rather than where the output is kept (a CI log, a file) — and rotate the credential either way.
|
|
549
573
|
- **A `forge:` that holds credentials behind a `\` in place of the `//` is refused** — `https:\\user:token@host\…`, or any run of `/` and `\` after the scheme that holds a `\`. It was read as a path, and `Forge not found at …` printed it whole. It is now refused wherever a URL with credentials is (`… › forge holds credentials in the URL — remove them and let git authenticate`): `craftar.yaml` and `craftar.local.yaml`, `init --forge` and its questions, `import --write-config`, a remote of a Forge clone. As with `://`, what counts is any user on `http(s)` and a `user:password` on another scheme, and `file:` is left alone. A drive path (`C:\…`) is read as before — a name there cannot hold the `:` of a `user:password` — and so is such a spelling with no user and no password.
|
|
550
574
|
- **A `ref:` that holds a URL with credentials is not quoted.** The messages that name a ref — `ref "…" is ignored: the Forge is a path`, `ref "…" not found in <url>`, `ref "…" is both a branch and a tag` (which then gives no `write refs/heads/…` hint) and `… the cached copy cannot resolve …` — read `ref (a URL with credentials — not shown)` for such a value, as the whole of it or inside it. The interactive `craftar init` says `Ref: (a URL with credentials — not shown) (from craftar.local.yaml)` for one it reads there, and refuses one typed as an answer (`the answer holds credentials in a URL — a ref is a branch, a tag or a full SHA`), where it offered it back as the next default. Every other ref is printed as before.
|
|
551
575
|
- **No byte change**: no emitter changed, no lock changes and no workspace sees `update`. No exit code changes for a workspace whose `forge:` is still accepted. In one whose `forge:` is spelled with the `\` and holds credentials, the commands that load it failed before (`Forge not found at …`) and fail now with the refusal; `craftar import --write-config`, which replaced the value, now exits 1 with the Forge untouched, where it exited 0; `craftar cache prune` run there, or with `--workspace` naming it, keeps every whole entry (`unchecked`: the checked `craftar.yaml` does not read) where it pruned; and the `cache` check of `craftar doctor` there no longer reports an orphan — its line, or lines, end `(orphans not checked: the checked craftar.yaml does not read)`.
|
package/dist/cli.js
CHANGED
|
@@ -25,13 +25,15 @@ import { preferredLine, renderImportReport } from "./core/import-report.js";
|
|
|
25
25
|
import { driftList, unmanaged } from "./core/drift.js";
|
|
26
26
|
import { EXAMPLE_CONTENT_HIDDEN, diffReport, explainFile, explainSkip, notProduced, readText, statusPath, statusReport, targetsReport } from "./core/report.js";
|
|
27
27
|
import { diffIngredients, listVariants, profileOf } from "./core/variants.js";
|
|
28
|
-
import { exists, gitDirty, gitIgnored, gitIsRepo, gitUnheld, loadForge } from "./core/forge.js";
|
|
28
|
+
import { exists, gitDirty, gitIgnored, gitIsRepo, gitUnheld, loadForge, realpathOfNearest } from "./core/forge.js";
|
|
29
29
|
import { applyBasePromotion, planBasePromotion, proveBasePromotion } from "./core/promote.js";
|
|
30
30
|
import { fingerprintDir } from "./core/fingerprint.js";
|
|
31
31
|
import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, pruneCandidates, pruneRecipes, note, } from "./core/unify.js";
|
|
32
32
|
import { checkParamWrites, writeParamFile } from "./core/param-writes.js";
|
|
33
33
|
import { checkInitFlags, initLine, planInit } from "./core/init.js";
|
|
34
|
-
import { askInit, confirmInit, againLine } from "./core/init-flow.js";
|
|
34
|
+
import { askInit, askClone, confirmInit, againLine, NOT_CLONED_REFUSED } from "./core/init-flow.js";
|
|
35
|
+
import { findGit, cloneRepos, listRepos, planClone } from "./core/clone.js";
|
|
36
|
+
import { byHand, profileRepos } from "./core/repos.js";
|
|
35
37
|
import { readlineIo } from "./prompt.js";
|
|
36
38
|
import { editRecipesText, planRecipeEdit, recipeDiffLine } from "./core/recipe-edit.js";
|
|
37
39
|
import { localKeys, readLocalFile } from "./core/workspace-yaml.js";
|
|
@@ -52,7 +54,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
|
52
54
|
*/
|
|
53
55
|
const DIFF_EXIT_CODE_FETCH_MODE = "sync";
|
|
54
56
|
const program = new Command();
|
|
55
|
-
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.
|
|
57
|
+
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.33.0");
|
|
56
58
|
const SAVE_PLAN_GATE = { what: "plan", who: "unify", outside: "save plans outside the Forge", never: "unify never overwrites" };
|
|
57
59
|
const REPORT_GATE = { what: "report", who: "import", outside: "write the report outside the Forge", never: "import never overwrites" };
|
|
58
60
|
program
|
|
@@ -220,10 +222,79 @@ async function applyAndReport(ws, p, st, lock, opts = {}) {
|
|
|
220
222
|
console.log(` ${pc.yellow("warn")} ${registryWarning}`);
|
|
221
223
|
return r;
|
|
222
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Shared clone report for `craftar init --clone` and `craftar repos clone`.
|
|
227
|
+
* Prints the header, the present rows first, then the clone rows (cloned/failed), each group
|
|
228
|
+
* in the profile's order. Returns whether any clone failed.
|
|
229
|
+
*
|
|
230
|
+
* @param header The bold header line (without the leading text, which is added here)
|
|
231
|
+
* @param results The clone results (cloned or failed)
|
|
232
|
+
* @param skipped The entries that were skipped (present state)
|
|
233
|
+
* @param root The workspace root, used for the by-hand message
|
|
234
|
+
* @param opts.reposCloneLine Whether to print the "or, from the workspace: craftar repos clone ..." line
|
|
235
|
+
*/
|
|
236
|
+
function printCloneReport(header, results, skipped, root, opts) {
|
|
237
|
+
console.log(pc.bold(header));
|
|
238
|
+
const rows = [];
|
|
239
|
+
for (const e of skipped) {
|
|
240
|
+
rows.push({ state: "present", name: e.name, path: e.path });
|
|
241
|
+
}
|
|
242
|
+
for (const r of results) {
|
|
243
|
+
if (r.outcome === "cloned") {
|
|
244
|
+
rows.push({ state: "cloned", name: r.entry.name, path: r.entry.path });
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
rows.push({ state: "failed", name: r.entry.name, path: r.entry.path, reason: r.reason });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Compute column widths: sw = longest state, nw = longest name
|
|
251
|
+
const sw = Math.max(...rows.map((r) => r.state.length));
|
|
252
|
+
const nw = Math.max(...rows.map((r) => r.name.length));
|
|
253
|
+
// Print rows
|
|
254
|
+
for (const r of rows) {
|
|
255
|
+
let line = ` ${r.state.padEnd(sw)} ${r.name.padEnd(nw)} → ${r.path}`;
|
|
256
|
+
if (r.state === "present") {
|
|
257
|
+
line += " (not cloned)";
|
|
258
|
+
}
|
|
259
|
+
else if (r.state === "failed" && r.reason) {
|
|
260
|
+
line += ` (${r.reason})`;
|
|
261
|
+
}
|
|
262
|
+
console.log(line);
|
|
263
|
+
}
|
|
264
|
+
// Failure report
|
|
265
|
+
const failed = results.filter((r) => r.outcome === "failed");
|
|
266
|
+
if (failed.length > 0) {
|
|
267
|
+
const ok = results.length - failed.length;
|
|
268
|
+
const n = results.length;
|
|
269
|
+
console.log(` ${ok} of ${n} cloned — clone the rest by hand, in ${root}:`);
|
|
270
|
+
for (const f of failed) {
|
|
271
|
+
console.log(` ${byHand(f.entry)}`);
|
|
272
|
+
}
|
|
273
|
+
if (opts.reposCloneLine) {
|
|
274
|
+
const failedNames = failed.map((f) => f.entry.name);
|
|
275
|
+
console.log(` or, from the workspace: craftar repos clone ${failedNames.join(" ")}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return failed.length > 0;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The clone step for `craftar init --clone`: clones the entries, prints the report, sets exitCode on failures.
|
|
282
|
+
* Calls `again()` at the end.
|
|
283
|
+
*/
|
|
284
|
+
async function runCloneStep(root, profile, entries, git, again) {
|
|
285
|
+
const n = entries.length;
|
|
286
|
+
const repoWord = n === 1 ? "repository" : "repositories";
|
|
287
|
+
const results = await cloneRepos(root, entries, { git, env: process.env, stdinIsTty: process.stdin.isTTY === true });
|
|
288
|
+
const header = `craftar init — cloning ${n} ${repoWord} of ${profile}`;
|
|
289
|
+
const anyFailed = printCloneReport(header, results, [], root, { reposCloneLine: true });
|
|
290
|
+
if (anyFailed)
|
|
291
|
+
process.exitCode = 1;
|
|
292
|
+
again();
|
|
293
|
+
}
|
|
223
294
|
/* ---------------------------------------------------------------- init */
|
|
224
295
|
program
|
|
225
296
|
.command("init")
|
|
226
|
-
.description("Start a workspace from a Forge and a profile: write a craftar.yaml proved to resolve and plan, then run the first sync; in a terminal, asks for the Forge and the profile not given as flags; refused when craftar.yaml already exists")
|
|
297
|
+
.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")
|
|
227
298
|
.option("--forge <dir|url>", "the Forge: a directory (written relative to the workspace) or a git URL; asked for in a terminal when omitted")
|
|
228
299
|
.option("--profile <name>", "the client profile in the Forge; asked for in a terminal when omitted")
|
|
229
300
|
.option("--ref <ref>", "branch, tag or full SHA of a remote Forge")
|
|
@@ -232,6 +303,8 @@ program
|
|
|
232
303
|
.option("--remove-recipe <name>", "remove a recipe of the profile's (repeatable)", collect, [])
|
|
233
304
|
.option("--replace", "let --add-recipe take a slot another recipe holds", false)
|
|
234
305
|
.option("--no-sync", "write craftar.yaml only, and say what the first sync would do")
|
|
306
|
+
.option("--clone", "after the first sync, clone the profile's repositories (its repos list) into the workspace")
|
|
307
|
+
.option("--no-clone", "do not clone, and do not ask")
|
|
235
308
|
.option("--offline", "use the cached copy of a remote Forge, without fetching", false)
|
|
236
309
|
.option("--workspace <dir>", "workspace root, created when missing", ".")
|
|
237
310
|
.action(async (o, cmd) => {
|
|
@@ -265,6 +338,19 @@ program
|
|
|
265
338
|
addRecipes: o.addRecipe,
|
|
266
339
|
removeRecipes: o.removeRecipe,
|
|
267
340
|
});
|
|
341
|
+
// C1: --clone and --no-clone cannot be given together
|
|
342
|
+
if (process.argv.includes("--clone") && process.argv.includes("--no-clone")) {
|
|
343
|
+
fail("pass --clone or --no-clone, not both");
|
|
344
|
+
}
|
|
345
|
+
// C2: --clone with --offline
|
|
346
|
+
if (o.clone === true && o.offline) {
|
|
347
|
+
fail("--clone needs the network — drop --offline, or clone later with git");
|
|
348
|
+
}
|
|
349
|
+
// C6: --clone with no git on PATH
|
|
350
|
+
const git = await findGit(process.env);
|
|
351
|
+
if (o.clone === true && git === null) {
|
|
352
|
+
fail("cloning needs git — install it, or leave the clone out");
|
|
353
|
+
}
|
|
268
354
|
// Mode: flags when both given; else interactive when both streams are TTYs; else N2
|
|
269
355
|
const bothGiven = o.forge !== undefined && o.profile !== undefined;
|
|
270
356
|
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
@@ -294,6 +380,7 @@ program
|
|
|
294
380
|
addRecipes: o.addRecipe,
|
|
295
381
|
removeRecipes: o.removeRecipe,
|
|
296
382
|
replace: o.replace,
|
|
383
|
+
clone: o.clone,
|
|
297
384
|
}, local, io, { ...load(o, "sync"), registryOff: registryOff() });
|
|
298
385
|
if (result.kind === "cancelled")
|
|
299
386
|
fail("init cancelled — nothing written");
|
|
@@ -309,12 +396,29 @@ program
|
|
|
309
396
|
addRecipes: o.addRecipe,
|
|
310
397
|
removeRecipes: o.removeRecipe,
|
|
311
398
|
replace: o.replace,
|
|
399
|
+
clone: o.clone === true,
|
|
312
400
|
};
|
|
313
401
|
}
|
|
314
402
|
const init = await planInit(root, input, { ...load(o, "sync"), local });
|
|
403
|
+
// Interactive mode: when o.clone is undefined, ask about cloning between planInit and confirmInit
|
|
404
|
+
// --clone typed: planInit has checked C3–C5 and returned the entries, in either mode
|
|
405
|
+
let cloneEntries = o.clone === true ? init.repos : [];
|
|
406
|
+
let cloneRefused = false;
|
|
315
407
|
if (answers !== null) {
|
|
408
|
+
if (o.clone === undefined) {
|
|
409
|
+
const io = readlineIo(process.stdin, process.stdout, { terminal: true });
|
|
410
|
+
const cloneResult = await askClone(io, init, { root, offline: o.offline, git });
|
|
411
|
+
if (cloneResult === "cancelled")
|
|
412
|
+
fail("init cancelled — nothing written");
|
|
413
|
+
cloneEntries = cloneResult.entries;
|
|
414
|
+
cloneRefused = cloneResult.refused === true;
|
|
415
|
+
}
|
|
316
416
|
const io = readlineIo(process.stdin, process.stdout, { terminal: true });
|
|
317
|
-
const confirm = await confirmInit(init, root, io, {
|
|
417
|
+
const confirm = await confirmInit(init, root, io, {
|
|
418
|
+
sync: o.sync,
|
|
419
|
+
clone: cloneEntries.length || undefined,
|
|
420
|
+
cloneRefused: o.clone === true || cloneRefused,
|
|
421
|
+
});
|
|
318
422
|
if (confirm === "cancelled")
|
|
319
423
|
fail("init cancelled — nothing written");
|
|
320
424
|
}
|
|
@@ -322,7 +426,7 @@ program
|
|
|
322
426
|
const workspaceGiven = cmd.getOptionValueSource("workspace") === "cli";
|
|
323
427
|
const again = () => {
|
|
324
428
|
if (answers !== null) {
|
|
325
|
-
console.log(againLine(answers, { workspace: workspaceGiven ? o.workspace : undefined, sync: o.sync, offline: o.offline }));
|
|
429
|
+
console.log(againLine(answers, { workspace: workspaceGiven ? o.workspace : undefined, sync: o.sync, offline: o.offline, clone: cloneEntries.length > 0 }));
|
|
326
430
|
}
|
|
327
431
|
};
|
|
328
432
|
// Every refusal is above: only now does the directory, and craftar.yaml, come to exist
|
|
@@ -340,10 +444,20 @@ program
|
|
|
340
444
|
for (const n of init.notes)
|
|
341
445
|
console.log(` note ${n}`);
|
|
342
446
|
const unset = unsetDeclared(p);
|
|
447
|
+
const cloning = cloneEntries.length > 0;
|
|
448
|
+
const refused = unset.length > 0;
|
|
343
449
|
if (!o.sync) {
|
|
344
450
|
console.log(nextSyncLine(init.statuses, unset));
|
|
345
451
|
for (const w of p.warnings)
|
|
346
452
|
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
453
|
+
if (cloning && refused && answers === null) {
|
|
454
|
+
console.log(NOT_CLONED_REFUSED);
|
|
455
|
+
}
|
|
456
|
+
else if (cloning && !refused) {
|
|
457
|
+
// Clone even without sync
|
|
458
|
+
await runCloneStep(root, ws.config.profile, cloneEntries, git, again);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
347
461
|
again();
|
|
348
462
|
return;
|
|
349
463
|
}
|
|
@@ -352,6 +466,9 @@ program
|
|
|
352
466
|
if (unset.length) {
|
|
353
467
|
for (const w of p.warnings)
|
|
354
468
|
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
469
|
+
if (cloning && answers === null) {
|
|
470
|
+
console.log(NOT_CLONED_REFUSED);
|
|
471
|
+
}
|
|
355
472
|
refusalBlock(unset, { thenSync: true });
|
|
356
473
|
again();
|
|
357
474
|
process.exitCode = 1;
|
|
@@ -361,8 +478,152 @@ program
|
|
|
361
478
|
const collisions = r.skipped.filter((s) => s.state === "collision").length;
|
|
362
479
|
if (collisions)
|
|
363
480
|
console.log(` ${pc.yellow("warn")} ${collisions} file(s) already in the workspace differ from the Forge and were left as they are — to bring them into the Forge, run craftar import`);
|
|
481
|
+
if (cloning) {
|
|
482
|
+
await runCloneStep(root, ws.config.profile, cloneEntries, git, again);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
364
485
|
again();
|
|
365
486
|
});
|
|
487
|
+
/* ---------------------------------------------------------------- repos */
|
|
488
|
+
// `craftar repos` lists (the command's own action); `craftar repos clone` clones. No `repos list` (spec 37 §13.3 item 29).
|
|
489
|
+
const reposCmd = program
|
|
490
|
+
.command("repos")
|
|
491
|
+
.description("List the profile's repositories (its repos list) with their state in this workspace: present, missing or blocked; starts no program but the Forge load, writes nothing")
|
|
492
|
+
.option("--workspace <dir>", "workspace root", ".")
|
|
493
|
+
.option("--offline", "use the cached copy of a remote Forge, without fetching", false)
|
|
494
|
+
.option("--json", "machine-readable output", false)
|
|
495
|
+
.action(async (o) => {
|
|
496
|
+
const ws = await loadWorkspace(o.workspace, load(o, "read"));
|
|
497
|
+
const p = await plan(ws);
|
|
498
|
+
const lock = await readLock(ws.root);
|
|
499
|
+
const profile = ws.forge.profiles.get(ws.config.profile);
|
|
500
|
+
// C4: profileRepos throws for a block that does not read
|
|
501
|
+
const entries = profileRepos(profile);
|
|
502
|
+
// No entry → one line, exit 0
|
|
503
|
+
if (entries.length === 0) {
|
|
504
|
+
if (o.json) {
|
|
505
|
+
const out = {
|
|
506
|
+
workspace: await fs.realpath(ws.root),
|
|
507
|
+
profile: ws.config.profile,
|
|
508
|
+
repos: [],
|
|
509
|
+
warnings: p.warnings,
|
|
510
|
+
};
|
|
511
|
+
console.log(JSON.stringify(out, null, 2));
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
console.log(`profile ${ws.config.profile} lists no repositories`);
|
|
515
|
+
for (const w of p.warnings)
|
|
516
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
517
|
+
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
// Build the destination context: planned paths and lock paths
|
|
521
|
+
const ctx = {
|
|
522
|
+
planned: p.files.map((f) => f.path),
|
|
523
|
+
lockPaths: lock?.files.map((f) => f.path) ?? [],
|
|
524
|
+
forgeDir: ws.forge.root,
|
|
525
|
+
};
|
|
526
|
+
const list = await listRepos(ws.root, entries, ctx);
|
|
527
|
+
const missing = list.filter((e) => e.state === "missing").length;
|
|
528
|
+
if (o.json) {
|
|
529
|
+
const repos = list.map((e) => ({
|
|
530
|
+
name: e.name,
|
|
531
|
+
url: e.url,
|
|
532
|
+
path: e.path,
|
|
533
|
+
ref: e.ref,
|
|
534
|
+
state: e.state,
|
|
535
|
+
reason: e.reason,
|
|
536
|
+
}));
|
|
537
|
+
const out = {
|
|
538
|
+
workspace: await fs.realpath(ws.root),
|
|
539
|
+
profile: ws.config.profile,
|
|
540
|
+
repos,
|
|
541
|
+
warnings: p.warnings,
|
|
542
|
+
};
|
|
543
|
+
console.log(JSON.stringify(out, null, 2));
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
// Text output
|
|
547
|
+
console.log(`repositories of ${ws.config.profile} — ${list.length} in the profile, ${missing} missing`);
|
|
548
|
+
const sw = 7; // "present" is longest, always
|
|
549
|
+
const nw = Math.max(...list.map((e) => e.name.length));
|
|
550
|
+
const pw = Math.max(...list.map((e) => e.path.length));
|
|
551
|
+
for (const e of list) {
|
|
552
|
+
let line = ` ${e.state.padEnd(sw)} ${e.name.padEnd(nw)} → ${e.path.padEnd(pw)} ${e.url}`;
|
|
553
|
+
if (e.ref)
|
|
554
|
+
line += ` @ ${e.ref}`;
|
|
555
|
+
if (e.reason)
|
|
556
|
+
line += ` (${e.reason})`;
|
|
557
|
+
console.log(line);
|
|
558
|
+
}
|
|
559
|
+
for (const w of p.warnings)
|
|
560
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
561
|
+
});
|
|
562
|
+
reposCmd
|
|
563
|
+
.command("clone")
|
|
564
|
+
.description("Clone the profile's repositories that are missing in this workspace (all, or the names given); typing the command is the consent; needs a fresh copy of a remote Forge")
|
|
565
|
+
.argument("[names...]", "repository names to clone (default: all missing)")
|
|
566
|
+
// No default here: `repos` holds `--workspace` too, and commander gives the value to the parent when both declare it.
|
|
567
|
+
.option("--workspace <dir>", 'workspace root (default: ".")')
|
|
568
|
+
.action(async (names, o) => {
|
|
569
|
+
const workspace = o.workspace ?? reposCmd.opts().workspace ?? ".";
|
|
570
|
+
// C6: git is needed before any load
|
|
571
|
+
const git = await findGit(process.env);
|
|
572
|
+
if (git === null) {
|
|
573
|
+
fail("cloning needs git — install it, or leave the clone out");
|
|
574
|
+
}
|
|
575
|
+
// R4: fetchHint: "fresh-copy" — repos clone refuses a stale cached copy
|
|
576
|
+
const ws = await loadWorkspace(workspace, { ...load({ offline: false }, "sync"), fetchHint: "fresh-copy" });
|
|
577
|
+
const p = await plan(ws);
|
|
578
|
+
const lock = await readLock(ws.root);
|
|
579
|
+
const profile = ws.forge.profiles.get(ws.config.profile);
|
|
580
|
+
// Build the destination context
|
|
581
|
+
const ctx = {
|
|
582
|
+
planned: p.files.map((f) => f.path),
|
|
583
|
+
lockPaths: lock?.files.map((f) => f.path) ?? [],
|
|
584
|
+
forgeDir: ws.forge.root,
|
|
585
|
+
};
|
|
586
|
+
// planClone: C3, C4, C5, R1, R2, R3 come out of it
|
|
587
|
+
const clonePlan = await planClone(ws.root, profile, ctx, {
|
|
588
|
+
present: "skip",
|
|
589
|
+
noEntries: "nothing to clone",
|
|
590
|
+
names: names.length ? names : undefined,
|
|
591
|
+
});
|
|
592
|
+
// Nothing to clone and no names given: all are present
|
|
593
|
+
if (clonePlan.entries.length === 0) {
|
|
594
|
+
const all = profileRepos(profile);
|
|
595
|
+
const n = all.length;
|
|
596
|
+
const repoWord = n === 1 ? "repository" : "repositories";
|
|
597
|
+
console.log(`all ${n} ${repoWord} of ${ws.config.profile} are present — nothing to clone`);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
// Clone the entries
|
|
601
|
+
const results = await cloneRepos(ws.root, clonePlan.entries, { git, env: process.env, stdinIsTty: process.stdin.isTTY === true });
|
|
602
|
+
// Combine skipped and results in profile order for the report
|
|
603
|
+
// skipped entries come from planClone, results are in the same order as entries
|
|
604
|
+
// To merge them in profile order: iterate through all entries and match
|
|
605
|
+
const allEntries = profileRepos(profile);
|
|
606
|
+
const skippedNames = new Set(clonePlan.skipped.map((e) => e.name.toLowerCase()));
|
|
607
|
+
const resultMap = new Map(results.map((r) => [r.entry.name.toLowerCase(), r]));
|
|
608
|
+
const orderedSkipped = [];
|
|
609
|
+
const orderedResults = [];
|
|
610
|
+
for (const e of allEntries) {
|
|
611
|
+
const lower = e.name.toLowerCase();
|
|
612
|
+
if (skippedNames.has(lower)) {
|
|
613
|
+
orderedSkipped.push(clonePlan.skipped.find((s) => s.name.toLowerCase() === lower));
|
|
614
|
+
}
|
|
615
|
+
else if (resultMap.has(lower)) {
|
|
616
|
+
orderedResults.push(resultMap.get(lower));
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
// Report
|
|
620
|
+
const n = clonePlan.entries.length;
|
|
621
|
+
const repoWord = n === 1 ? "repository" : "repositories";
|
|
622
|
+
const header = `craftar repos clone — cloning ${n} ${repoWord} of ${ws.config.profile}`;
|
|
623
|
+
const anyFailed = printCloneReport(header, orderedResults, orderedSkipped, ws.root, { reposCloneLine: false });
|
|
624
|
+
if (anyFailed)
|
|
625
|
+
process.exitCode = 1;
|
|
626
|
+
});
|
|
366
627
|
/* ---------------------------------------------------------------- sync */
|
|
367
628
|
program
|
|
368
629
|
.command("sync")
|
|
@@ -2691,54 +2952,6 @@ function color(state) {
|
|
|
2691
2952
|
return pc.dim;
|
|
2692
2953
|
}
|
|
2693
2954
|
}
|
|
2694
|
-
/**
|
|
2695
|
-
* The real path of `abs`, resolved through its nearest existing ancestor: `fs.realpath` needs the
|
|
2696
|
-
* path to exist, and a plan target usually does not yet — so the part that exists is resolved
|
|
2697
|
-
* (symlinks and all) and the part that does not is appended as written.
|
|
2698
|
-
*
|
|
2699
|
-
* It walks up only past a component that truly does not exist (`lstat` says ENOENT/ENOTDIR). Any
|
|
2700
|
-
* other outcome throws instead. A component `lstat` cannot inspect (a symlink loop or a permission
|
|
2701
|
-
* error on Linux) "cannot be inspected"; one it can inspect but `realpath` cannot follow (a
|
|
2702
|
-
* dangling symlink or junction, a loop on Windows) "exists but cannot be resolved". Resolving the
|
|
2703
|
-
* rest lexically would let a link that points into the Forge pass the containment check, so the gate
|
|
2704
|
-
* fails closed rather than guess where the target lands.
|
|
2705
|
-
*/
|
|
2706
|
-
async function realpathOfNearest(abs) {
|
|
2707
|
-
let head = abs;
|
|
2708
|
-
const tail = [];
|
|
2709
|
-
for (;;) {
|
|
2710
|
-
try {
|
|
2711
|
-
return path.join(await fs.realpath(head), ...tail);
|
|
2712
|
-
}
|
|
2713
|
-
catch (realpathError) {
|
|
2714
|
-
try {
|
|
2715
|
-
await fs.lstat(head);
|
|
2716
|
-
}
|
|
2717
|
-
catch (lstatError) {
|
|
2718
|
-
const code = lstatError.code;
|
|
2719
|
-
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
2720
|
-
const parent = path.dirname(head);
|
|
2721
|
-
if (parent === head)
|
|
2722
|
-
return abs;
|
|
2723
|
-
tail.unshift(path.basename(head));
|
|
2724
|
-
head = parent;
|
|
2725
|
-
continue;
|
|
2726
|
-
}
|
|
2727
|
-
throw cannotResolve(head, lstatError, "cannot be inspected");
|
|
2728
|
-
}
|
|
2729
|
-
throw cannotResolve(head, realpathError, "exists but cannot be resolved");
|
|
2730
|
-
}
|
|
2731
|
-
}
|
|
2732
|
-
}
|
|
2733
|
-
/**
|
|
2734
|
-
* The refusal for a target that cannot be resolved; the caller adds which command cannot prove it. `what` is
|
|
2735
|
-
* "cannot be inspected" when `lstat` itself failed, so the path is not known to exist, and "exists but cannot be
|
|
2736
|
-
* resolved" when `lstat` succeeded and `realpath` did not.
|
|
2737
|
-
*/
|
|
2738
|
-
function cannotResolve(p, e, what) {
|
|
2739
|
-
const code = e?.code ?? (e instanceof Error ? e.message : String(e));
|
|
2740
|
-
return new Error(`${p} ${what} (${code})`);
|
|
2741
|
-
}
|
|
2742
2955
|
/**
|
|
2743
2956
|
* The gate of a user-supplied output path (Ruling 30 of spec 06; spec 29 §4.2): the file a command writes where the
|
|
2744
2957
|
* user says must lie outside the Forge and must not exist. Both sides are compared as real paths, so neither a `..`
|