craftar 0.21.1 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/dist/cli.js +341 -44
- package/dist/cli.js.map +1 -1
- package/dist/core/doctor.js +12 -30
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/drift.js +29 -0
- package/dist/core/drift.js.map +1 -0
- package/dist/core/forge.js +33 -1
- package/dist/core/forge.js.map +1 -1
- package/dist/core/impact.js +14 -11
- package/dist/core/impact.js.map +1 -1
- package/dist/core/param-writes.js +2 -1
- package/dist/core/param-writes.js.map +1 -1
- package/dist/core/recipe-edit.js +2 -1
- package/dist/core/recipe-edit.js.map +1 -1
- package/dist/core/registry.js +4 -3
- package/dist/core/registry.js.map +1 -1
- package/dist/core/schema-fault.js +50 -0
- package/dist/core/schema-fault.js.map +1 -0
- package/dist/core/sync.js +27 -9
- package/dist/core/sync.js.map +1 -1
- package/dist/core/unify.js +4 -3
- package/dist/core/unify.js.map +1 -1
- package/dist/importers/claude-code.js +191 -94
- package/dist/importers/claude-code.js.map +1 -1
- package/dist/importers/decide.js +5 -4
- package/dist/importers/decide.js.map +1 -1
- package/dist/importers/drift-promote.js +1218 -0
- package/dist/importers/drift-promote.js.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,8 +71,11 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
|
|
|
71
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. |
|
|
72
72
|
| `craftar import --from claude-code --forge <dir> --profile <name> [--workspace .] [--write-config] [--report <file.md>]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering). 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 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. 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, and a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand). 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`; 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. |
|
|
73
73
|
| `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 one exception: when a declared parameter a file cites has no value (see `sync`) it prints the listing, then the refusal on stderr, and exits 1 — `--json` still prints the whole object, with `unsetParams: [{key, declaredBy, citedBy}]` (`[]` when there is none). 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
|
-
| `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: 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 (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default). 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. |
|
|
74
|
+
| `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: 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 (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. |
|
|
75
75
|
| `craftar diff [path] [--exit-code]` | Line diff between disk and what the Forge would generate, for every file `sync --check` refuses: `update`, `drift`, `collision`, `new`, an `orphan` (shown as the removal of the whole file — the next `sync` deletes it) and an `orphan-drift` (a header and one line: `sync` keeps it). For `.claude/settings.craftar.example.json`, when it has drifted or collides, `diff` prints the headers and `content not shown: an example file may hold a value typed by hand`. `[path]` limits it to the one file whose workspace-relative path, as `status` prints it, equals it. Exits 0 by default, differences or not. `--exit-code` exits 1 when there are differences — exactly when `sync --check` would — and makes a `[path]` that names no file craftar manages an error (exit 1, nothing on stdout); without the flag such a path prints `no differences`. With a remote Forge, `--exit-code` also exits 1 when the fetch fails, as `sync --check` does; plain `diff` reads the cached copy with a warning. When a declared parameter a file cites has no value (see `sync`), the refusal is printed on stderr after the diff: plain `diff` still exits 0, `--exit-code` with no `[path]` exits 1 even with `no differences` — as `sync --check` does — and with a `[path]` its exit code answers for that file only. |
|
|
76
|
+
| `craftar drift [show] [path] [--workspace <dir>] [--offline] [--json]` | The hand-edited files (`drift`, `orphan-drift`), whether the Forge also changed each, whether it can be promoted; with a path, its diff. Read-only. `--json` is a contract: `{workspace, profile, files: [{path, state, target, ingredient, forge, promotable, reason}], warnings, unsetParams}`. Exits 0 whenever it prints its listing, with one exception: when a declared parameter a file cites has no value (see `sync`) it prints the listing, then the refusal on stderr, and exits 1 — `--json` still prints the whole object, with `unsetParams: [{key, declaredBy, citedBy}]` (`[]` when there is none). |
|
|
77
|
+
| `craftar drift discard <path...> [--workspace <dir>] [--dry-run] [--offline]` | A `sync` that regenerates only the named hand-edited files and removes a named hand-edited orphan; everything else `sync` would do happens too. A path that is not `drift` or `orphan-drift` refuses the whole run before any write. `--dry-run`. |
|
|
78
|
+
| `craftar drift promote <path> [--forge <dir>] [--workspace <dir>] [--dry-run] [--json]` | Carries one hand edit of a `.claude/` file into the Forge's working tree: a value in the profile's `params` / `sections` when the edit is only that, otherwise a variant for the workspace's profile. Gated on git, proved before the first write, never syncs, never commits. With a remote Forge, `--forge <dir>` names the clone to write. `--dry-run`, `--json`. `--json` is a contract: `{workspace, forge, profile, dryRun, path, ingredient, outcome, promoted, written: [{path, action}], params, sections, flattened, dependents, nextSync, impact, warnings}`. |
|
|
76
79
|
| `craftar explain <path>` | Which ingredient, recipe chain, target and origin produced a file and, for an ingredient with sections, which layer filled each one: `sections flavors (profile globex), extra (default)` (`default`, `profile <p>` or `workspace`). For an ingredient whose files cite parameters, each cited key with the layer that filled it for this ingredient: `params org (profile globex), port (default), root (unset)` — `default` (the ingredient's own), `recipe <name>`, `profile <p>`, `workspace` (either workspace file) or `unset`. `AGENTS.md` gets neither line: it is not one ingredient. |
|
|
77
80
|
| `craftar ls` | Recipes and ingredients resolved for this workspace. |
|
|
78
81
|
| `craftar workspaces [--fetch] [--json]` | Every workspace a writing `sync` registered on this machine, sorted by path: name, path, profile, stack (`slot=recipe`), targets, the Forge, the last sync, and a status computed now — `up to date`, `outdated` (`new`, `update` or `orphan` pending), `drift` (a `drift`, `orphan-drift` or `collision`), `no lock`, `missing` (directory or `craftar.yaml` gone) or `error` (it no longer loads, plans or reads its lock, or its `sync` would refuse for a declared parameter with no value — the reason is one of the row's warnings: `sync refused: declared parameter(s) with no value: <keys>`), plus `forge moved` when the Forge's commit differs from the lock's. Read without the network: a remote Forge comes from the cache; `--fetch` fetches first. Each row's warnings print after the table, prefixed with its path. Times are UTC. Writes nothing. Exits 0 whenever the table prints. Takes no `--workspace`. `--json` is a contract: `{registry, fetch, workspaces, warnings}`. |
|
|
@@ -310,6 +313,18 @@ Later: `service` ingredients with compose fragments (`craftar services up`); `do
|
|
|
310
313
|
|
|
311
314
|
## Upgrading
|
|
312
315
|
|
|
316
|
+
### to 0.22.1
|
|
317
|
+
|
|
318
|
+
- **A file the schema refuses is reported on one line, and never with a value it holds.** `invalid <file>: <key>: <what was expected>; <key>: …` replaces zod's JSON list of issues, which quoted the refused value — so a credential written where one of a fixed set of words is expected reached the terminal. A key is still named, as a path (`overrides.sections.<key>`) or as an unrecognized key, and so is the *type* received (`Expected string, received number`) — never the value. It holds for a Forge file, `craftar.yaml` (merged with `craftar.local.yaml`), the registry (`invalid <file>: …`), a `forge unify --plan` file (`<file>: <key>: …`, with no `invalid`), and the refusals of `import` and `drift promote` (`does not fit the ingredient schema: …`, `does not load (…)`), `forge unify` (`the edit no longer loads: …`), and `add recipe`, `remove recipe` and `import --write-config` (`it no longer loads: …`).
|
|
319
|
+
- **A `craftar.lock` or a registry that does not read is named, and nothing of it is quoted.** `craftar.lock is not valid JSON` and `craftar.lock is not a valid lock: <key>: …` replace the raw parser and zod messages; a registry that is not valid JSON reads `cannot read <file>: not valid JSON`. `declares schema <n>, which this craftar does not read` prints the schema only when it is a whole number, and `(not a whole number)` otherwise.
|
|
320
|
+
- **`craftar doctor`**: the `config`, `lock` and `registry` messages carry the same text — `<what> (<key>: <what was expected>, and N more)` keeps its shape, without `received '<value>'`; `craftar.lock is not valid JSON` no longer ends with the parser's message in parentheses.
|
|
321
|
+
- **No exit code and no `--json` key changes**; the text of an error string inside `--json` follows the new wording — among them `doctor --json` `checks[].message`; the `warnings` of `workspaces --json`, `targets --json`, `forge unify --json` and `drift promote --json`; `forge impact --json` `error`; `impact[].error` of `forge unify --json` and `impact.workspaces[].error` of `drift promote --json`; and `forge unify --json` `recipes.kept[].reason`.
|
|
322
|
+
- **No byte change**: no emitter and no lock changed. No workspace sees `update`.
|
|
323
|
+
|
|
324
|
+
### to 0.22.0
|
|
325
|
+
|
|
326
|
+
- **New commands `craftar drift show`, `craftar drift discard` and `craftar drift promote`**: list hand-edited files, discard their edits, or carry one into the Forge. `drift show --json` and `drift promote --json` are new tooling contracts. The `sync` skip lines for `drift` and `orphan-drift` were reworded (prose, not a contract).
|
|
327
|
+
|
|
313
328
|
### to 0.21.1
|
|
314
329
|
|
|
315
330
|
- **A YAML syntax error no longer prints the offending lines.** In a Forge file — a manifest, a recipe, a profile, an `ingredient.yaml` — the message is now `invalid <file>: <code> at line N, column M`, or `invalid <file>: YAML syntax error` when the parser gives no code (it was the yaml parser's message, source lines included, so a credential on a broken line reached the terminal). yaml warnings — an unresolved tag, a collection used as a key — are no longer printed either, frontmatter included. `forge unify --plan` reports a plan that is not valid YAML the same way; a plan that cannot be read or does not match its schema still reads `<file>: <error>`. No exit code and no `--json` shape changes; no generated byte changes.
|
package/dist/cli.js
CHANGED
|
@@ -4,9 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
import { promises as fs } from "node:fs";
|
|
5
5
|
import YAML from "yaml";
|
|
6
6
|
import { importClaudeCode } from "./importers/claude-code.js";
|
|
7
|
+
import { planPromote, applyPromote, UnheldError } from "./importers/drift-promote.js";
|
|
7
8
|
import { classifyForge } from "./core/remote.js";
|
|
8
9
|
import { forget, listWorkspaces, prune, register, registryFile } from "./core/registry.js";
|
|
9
|
-
import { forgeWorkspaces, planAll, nextSync, impactOf, concerned, refineRegistryState, countStates, NEXT_SYNC_STATES } from "./core/impact.js";
|
|
10
|
+
import { forgeWorkspaces, planAll, nextSync, impactOf, concerned, refineRegistryState, countStates, countStatesObject, NEXT_SYNC_STATES } from "./core/impact.js";
|
|
10
11
|
import { runDoctor } from "./core/doctor.js";
|
|
11
12
|
import { pruneCache } from "./core/cache.js";
|
|
12
13
|
import { resolveHome } from "./core/home-lock.js";
|
|
@@ -17,6 +18,7 @@ import { listTargets } from "./core/capabilities.js";
|
|
|
17
18
|
import { canonicalValue } from "./core/sections.js";
|
|
18
19
|
import { renderDiff, NO_EOF_NEWLINE_MARKER } from "./core/diff.js";
|
|
19
20
|
import { renderImportReport } from "./core/import-report.js";
|
|
21
|
+
import { driftList, unmanaged } from "./core/drift.js";
|
|
20
22
|
import { diffIngredients, listVariants, profileOf } from "./core/variants.js";
|
|
21
23
|
import { hashNormalized, toLf, stripBom } from "./core/text.js";
|
|
22
24
|
import { exists, gitDirty, gitIsRepo, gitUnheld, loadForge } from "./core/forge.js";
|
|
@@ -31,6 +33,7 @@ import { localKeys, readLocalFile } from "./core/workspace-yaml.js";
|
|
|
31
33
|
import { HUNK_CLASSES, INGREDIENT_TYPES, UnifyPlanSchema } from "./schema/index.js";
|
|
32
34
|
import { EXAMPLE_SETTINGS } from "./emitters/shared.js";
|
|
33
35
|
import { YamlSyntaxError, parseYamlText } from "./core/yaml-read.js";
|
|
36
|
+
import { errorText } from "./core/schema-fault.js";
|
|
34
37
|
process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
35
38
|
process.exit(0); });
|
|
36
39
|
/**
|
|
@@ -40,7 +43,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
|
40
43
|
*/
|
|
41
44
|
const DIFF_EXIT_CODE_FETCH_MODE = "sync";
|
|
42
45
|
const program = new Command();
|
|
43
|
-
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.
|
|
46
|
+
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.22.1");
|
|
44
47
|
const SAVE_PLAN_GATE = { what: "plan", who: "unify", outside: "save plans outside the Forge", never: "unify never overwrites" };
|
|
45
48
|
const REPORT_GATE = { what: "report", who: "import", outside: "write the report outside the Forge", never: "import never overwrites" };
|
|
46
49
|
program
|
|
@@ -169,7 +172,7 @@ async function applyAndReport(ws, p, st, lock, opts = {}) {
|
|
|
169
172
|
for (const f of r.written)
|
|
170
173
|
console.log(` ${pc.green("+")} ${f}`);
|
|
171
174
|
for (const f of r.removed)
|
|
172
|
-
console.log(` ${pc.magenta("-")} ${f} (orphan: no longer produced by the Forge)`);
|
|
175
|
+
console.log(` ${pc.magenta("-")} ${f} (${opts.overwritePaths?.has(f) ? "hand-edited orphan: discarded" : "orphan: no longer produced by the Forge"})`);
|
|
173
176
|
for (const s of r.skipped)
|
|
174
177
|
console.log(` ${pc.yellow("!")} ${s.path} ${explainSkip(s)}`);
|
|
175
178
|
for (const w of p.warnings)
|
|
@@ -375,9 +378,8 @@ program
|
|
|
375
378
|
const st = await status(ws, p, await readLock(ws.root));
|
|
376
379
|
// Spec 19 §3.3: under --exit-code a path nothing matches must not read as "clean" to a script
|
|
377
380
|
if (o.exitCode && only && !st.some((s) => s.path === only))
|
|
378
|
-
fail(
|
|
381
|
+
fail(unmanaged(only));
|
|
379
382
|
let shown = 0;
|
|
380
|
-
const render = { paint: { same: pc.dim, del: pc.red, add: pc.green } };
|
|
381
383
|
for (const s of st) {
|
|
382
384
|
if (only && s.path !== only)
|
|
383
385
|
continue;
|
|
@@ -385,31 +387,7 @@ program
|
|
|
385
387
|
if (["unchanged", "adopt"].includes(s.state))
|
|
386
388
|
continue;
|
|
387
389
|
shown++;
|
|
388
|
-
|
|
389
|
-
// Header and one line, no body: sync keeps this file
|
|
390
|
-
console.log(pc.bold(`--- ${s.path} (disk, orphan-drift)`));
|
|
391
|
-
console.log(` ${explainSkip(s)}`);
|
|
392
|
-
continue;
|
|
393
|
-
}
|
|
394
|
-
const disk = await readText(path.join(ws.root, s.path));
|
|
395
|
-
if (s.state === "orphan") {
|
|
396
|
-
// A removal: the file exists on disk but the Forge no longer produces it
|
|
397
|
-
console.log(pc.bold(`--- ${s.path} (disk, orphan)`));
|
|
398
|
-
console.log(pc.bold(`+++ ${s.path} (forge: no longer produced — sync removes it)`));
|
|
399
|
-
console.log(renderDiff(disk ?? "", "", render));
|
|
400
|
-
}
|
|
401
|
-
else {
|
|
402
|
-
const next = s.planned ? toLf(stripBom(s.planned.content.toString("utf8"))) : "";
|
|
403
|
-
console.log(pc.bold(`--- ${s.path} (disk, ${s.state})`));
|
|
404
|
-
console.log(pc.bold(`+++ ${s.path} (forge)`));
|
|
405
|
-
// Spec 27 §4.2, Ruling 4: do not print on-disk content of the example file for drift/collision
|
|
406
|
-
if (s.path === EXAMPLE_SETTINGS && (s.state === "drift" || s.state === "collision")) {
|
|
407
|
-
console.log(" content not shown: an example file may hold a value typed by hand");
|
|
408
|
-
}
|
|
409
|
-
else {
|
|
410
|
-
console.log(renderDiff(disk ?? "", next, render));
|
|
411
|
-
}
|
|
412
|
-
}
|
|
390
|
+
await printFileDiff(ws.root, s);
|
|
413
391
|
}
|
|
414
392
|
if (!shown)
|
|
415
393
|
console.log(pc.green("no differences"));
|
|
@@ -420,6 +398,322 @@ program
|
|
|
420
398
|
if (refusalBlock(unsetDeclared(p)) && o.exitCode && !only)
|
|
421
399
|
process.exitCode = 1;
|
|
422
400
|
});
|
|
401
|
+
const DIFF_PAINT = { paint: { same: pc.dim, del: pc.red, add: pc.green } };
|
|
402
|
+
/** What `craftar diff` prints for one file `sync --check` refuses; `drift show <path>` prints the same for a drifted one. */
|
|
403
|
+
async function printFileDiff(root, s) {
|
|
404
|
+
if (s.state === "orphan-drift") {
|
|
405
|
+
// Header and one line, no body: sync keeps this file
|
|
406
|
+
console.log(pc.bold(`--- ${s.path} (disk, orphan-drift)`));
|
|
407
|
+
console.log(` ${explainSkip(s)}`);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const disk = await readText(path.join(root, s.path));
|
|
411
|
+
if (s.state === "orphan") {
|
|
412
|
+
// A removal: the file exists on disk but the Forge no longer produces it
|
|
413
|
+
console.log(pc.bold(`--- ${s.path} (disk, orphan)`));
|
|
414
|
+
console.log(pc.bold(`+++ ${s.path} (forge: no longer produced — sync removes it)`));
|
|
415
|
+
console.log(renderDiff(disk ?? "", "", DIFF_PAINT));
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
const next = s.planned ? toLf(stripBom(s.planned.content.toString("utf8"))) : "";
|
|
419
|
+
console.log(pc.bold(`--- ${s.path} (disk, ${s.state})`));
|
|
420
|
+
console.log(pc.bold(`+++ ${s.path} (forge)`));
|
|
421
|
+
// Spec 27 §4.2, Ruling 4: do not print on-disk content of the example file for drift/collision
|
|
422
|
+
if (s.path === EXAMPLE_SETTINGS && (s.state === "drift" || s.state === "collision")) {
|
|
423
|
+
console.log(EXAMPLE_CONTENT_HIDDEN);
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
console.log(renderDiff(disk ?? "", next, DIFF_PAINT));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/** Spec 27 Ruling 4: the on-disk content of the example file is never printed. */
|
|
431
|
+
const EXAMPLE_CONTENT_HIDDEN = " content not shown: an example file may hold a value typed by hand";
|
|
432
|
+
/** A path as the user may type it, to the spelling `status` prints: forward slashes, no leading `./`. */
|
|
433
|
+
const statusPath = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
434
|
+
/* ---------------------------------------------------------------- drift */
|
|
435
|
+
const drift = program.command("drift").description("Hand-edited generated files: show them, discard the edit, or promote it to the Forge");
|
|
436
|
+
drift
|
|
437
|
+
.command("show", { isDefault: true })
|
|
438
|
+
.description("List the files hand-edited since the last sync, with the Forge's side and whether the edit can be promoted; with a path, that file's row and its diff. Writes nothing")
|
|
439
|
+
.argument("[path]", "limit to one file, and show its diff")
|
|
440
|
+
.option("--workspace <dir>", "workspace root", ".")
|
|
441
|
+
.option("--json", "machine-readable output", false)
|
|
442
|
+
.option("--offline", "use the cached copy of a remote Forge, without fetching", false)
|
|
443
|
+
.action(async (given, o) => {
|
|
444
|
+
const ws = await loadWorkspace(o.workspace, load(o, "read"));
|
|
445
|
+
warnStderr(ws.warnings);
|
|
446
|
+
const p = await plan(ws);
|
|
447
|
+
const st = await status(ws, p, await readLock(ws.root));
|
|
448
|
+
const only = given === undefined ? null : statusPath(given);
|
|
449
|
+
const named = only === null ? null : st.find((s) => s.path === only);
|
|
450
|
+
if (only !== null && !named)
|
|
451
|
+
fail(unmanaged(only));
|
|
452
|
+
const rows = driftList(named ? [named] : st);
|
|
453
|
+
// Spec 30r5: drift show decides a refused sync as status does — unsetParams in --json, the refusal block on stderr, exit 1.
|
|
454
|
+
const unset = unsetDeclared(p);
|
|
455
|
+
if (o.json) {
|
|
456
|
+
console.log(JSON.stringify({ workspace: await fs.realpath(ws.root), profile: ws.config.profile, files: rows, warnings: p.warnings, unsetParams: unset }, null, 2));
|
|
457
|
+
if (unset.length)
|
|
458
|
+
process.exitCode = 1;
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (named && !rows.length) {
|
|
462
|
+
console.log(`${named.path} is not drifted (${named.state})`);
|
|
463
|
+
if (refusalBlock(unset))
|
|
464
|
+
process.exitCode = 1;
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (!rows.length) {
|
|
468
|
+
console.log("no drift");
|
|
469
|
+
if (refusalBlock(unset))
|
|
470
|
+
process.exitCode = 1;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
// Columns as wide as their longest value, as the spec's example lines them up (§4.2).
|
|
474
|
+
const wide = (pick) => Math.max(...rows.map((r) => pick(r).length));
|
|
475
|
+
const [pathW, ingW, forgeW] = [wide((r) => r.path), wide((r) => r.ingredient), wide((r) => `forge: ${r.forge}`)];
|
|
476
|
+
for (const r of rows)
|
|
477
|
+
console.log(`${color(r.state)(r.state.padEnd(13))} ${r.path.padEnd(pathW)} ${pc.dim(r.ingredient.padEnd(ingW))} ${`forge: ${r.forge}`.padEnd(forgeW)} promote: ${r.promotable ? "yes" : `no (${r.reason})`}`);
|
|
478
|
+
if (!named) {
|
|
479
|
+
if (refusalBlock(unset))
|
|
480
|
+
process.exitCode = 1;
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (named.state === "orphan-drift") {
|
|
484
|
+
// The whole file as a removal: what `drift discard` would do to it.
|
|
485
|
+
console.log(pc.bold(`--- ${named.path} (disk, orphan-drift)`));
|
|
486
|
+
console.log(pc.bold(`+++ ${named.path} (forge: no longer produced — \`craftar drift discard\` removes it)`));
|
|
487
|
+
// Spec 27 Ruling 4: the on-disk content of the example file is never printed.
|
|
488
|
+
if (named.path === EXAMPLE_SETTINGS) {
|
|
489
|
+
console.log(EXAMPLE_CONTENT_HIDDEN);
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
console.log(renderDiff((await readText(path.join(ws.root, named.path))) ?? "", "", DIFF_PAINT));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
else
|
|
496
|
+
await printFileDiff(ws.root, named);
|
|
497
|
+
if (refusalBlock(unset))
|
|
498
|
+
process.exitCode = 1;
|
|
499
|
+
});
|
|
500
|
+
drift
|
|
501
|
+
.command("discard")
|
|
502
|
+
.description("Regenerate the named hand-edited files from the Forge (their edits are lost) and remove a named hand-edited file the Forge no longer produces; otherwise a sync like any other")
|
|
503
|
+
.argument("<path...>", "the hand-edited files, as `craftar status` prints them")
|
|
504
|
+
.option("--workspace <dir>", "workspace root", ".")
|
|
505
|
+
.option("--dry-run", "show the plan, write nothing", false)
|
|
506
|
+
.option("--offline", "use the cached copy of a remote Forge, without fetching", false)
|
|
507
|
+
.action(async (given, o) => {
|
|
508
|
+
const ws = await loadWorkspace(o.workspace, load(o, "sync"));
|
|
509
|
+
const p = await plan(ws);
|
|
510
|
+
const lock = await readLock(ws.root);
|
|
511
|
+
const st = await status(ws, p, lock);
|
|
512
|
+
const paths = [...new Set(given.map(statusPath))];
|
|
513
|
+
// Every refusal comes before the first write: one path that is not a hand edit stops the whole run.
|
|
514
|
+
const stateOf = new Map(st.map((s) => [s.path, s.state]));
|
|
515
|
+
const not = paths.filter((x) => stateOf.get(x) !== "drift" && stateOf.get(x) !== "orphan-drift");
|
|
516
|
+
if (not.length)
|
|
517
|
+
fail(`not drifted: ${not.map((x) => `${x} (${stateOf.get(x) ?? "not managed"})`).join(", ")} — nothing was written`);
|
|
518
|
+
// Check for unset params BEFORE any output (spec 29 §4.1, spec 30r1 commit 3 issue 1)
|
|
519
|
+
// Print plan warnings before the refusal, as sync does (spec 30r3 commit 3 issue 6)
|
|
520
|
+
const unset = unsetDeclared(p);
|
|
521
|
+
if (unset.length) {
|
|
522
|
+
for (const w of p.warnings)
|
|
523
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
524
|
+
fail(unsetRefusal(unset));
|
|
525
|
+
}
|
|
526
|
+
const overwritePaths = new Set(paths);
|
|
527
|
+
console.log(`discarding ${paths.length} hand edit(s): ${st.filter((s) => overwritePaths.has(s.path)).map((s) => s.path).join(", ")}`);
|
|
528
|
+
await applyAndReport(ws, p, st, lock, { dryRun: o.dryRun, overwritePaths });
|
|
529
|
+
});
|
|
530
|
+
drift
|
|
531
|
+
.command("promote")
|
|
532
|
+
.description("Carry a hand-edited file to the Forge as a profile variant, behind git's gate, proved before any write; never writes the lock, the registry or the cache")
|
|
533
|
+
.argument("<path>", "the hand-edited file, as `craftar status` prints it")
|
|
534
|
+
.option("--workspace <dir>", "workspace root", ".")
|
|
535
|
+
.option("--forge <dir>", "the Forge directory to write (required for a URL Forge)")
|
|
536
|
+
.option("--dry-run", "prove and show the plan, write nothing", false)
|
|
537
|
+
.option("--json", "machine-readable output", false)
|
|
538
|
+
.action(async (givenPath, o) => {
|
|
539
|
+
const home = craftarHome();
|
|
540
|
+
const normalizedPath = statusPath(givenPath);
|
|
541
|
+
let promotePlan;
|
|
542
|
+
try {
|
|
543
|
+
promotePlan = await planPromote({
|
|
544
|
+
workspaceRoot: o.workspace,
|
|
545
|
+
forgeDir: o.forge ?? null,
|
|
546
|
+
path: normalizedPath,
|
|
547
|
+
home,
|
|
548
|
+
env: process.env,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
catch (e) {
|
|
552
|
+
// Handle UnheldError
|
|
553
|
+
if (e instanceof UnheldError) {
|
|
554
|
+
fail(unheldMessage("drift promote", e.forgeRoot, e.unheld));
|
|
555
|
+
}
|
|
556
|
+
throw e;
|
|
557
|
+
}
|
|
558
|
+
// --json output
|
|
559
|
+
if (o.json) {
|
|
560
|
+
// Apply first if not dry-run (spec 30r1 §3.2: JSON follows the write)
|
|
561
|
+
if (!o.dryRun) {
|
|
562
|
+
const journal = [];
|
|
563
|
+
try {
|
|
564
|
+
await applyPromote(promotePlan, journal);
|
|
565
|
+
}
|
|
566
|
+
catch (e) {
|
|
567
|
+
// Late failure: stdout stays "", stderr is lateFailure
|
|
568
|
+
fail(lateFailure(e, promotePlan.forgeRoot, journal, "drift promote"));
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// Build the JSON object in spec §4.7 order
|
|
572
|
+
const jsonObj = {
|
|
573
|
+
workspace: promotePlan.workspaceRealPath,
|
|
574
|
+
forge: promotePlan.forgeRoot,
|
|
575
|
+
profile: promotePlan.profile,
|
|
576
|
+
dryRun: o.dryRun,
|
|
577
|
+
path: promotePlan.inputPath,
|
|
578
|
+
ingredient: promotePlan.ingredientRef,
|
|
579
|
+
outcome: promotePlan.outcome,
|
|
580
|
+
promoted: promotePlan.promoted,
|
|
581
|
+
written: promotePlan.entries.map((e) => ({
|
|
582
|
+
path: e.rel,
|
|
583
|
+
action: e.created ? "created" : "edited",
|
|
584
|
+
})),
|
|
585
|
+
params: promotePlan.params.map((p) => ({ key: p.key, old: p.old, value: p.value })),
|
|
586
|
+
sections: promotePlan.sections.map((s) => ({ key: s.key, name: s.name })),
|
|
587
|
+
flattened: promotePlan.flattened,
|
|
588
|
+
dependents: promotePlan.dependents,
|
|
589
|
+
nextSync: { counts: countStatesObject(promotePlan.nextSync) },
|
|
590
|
+
impact: {
|
|
591
|
+
registry: promotePlan.impact.registry,
|
|
592
|
+
workspaces: promotePlan.impact.workspaces,
|
|
593
|
+
},
|
|
594
|
+
warnings: promotePlan.warnings,
|
|
595
|
+
};
|
|
596
|
+
console.log(JSON.stringify(jsonObj, null, 2));
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
// Apply first (unless dry-run), print after — spec 30r3 §3.4: on late failure stdout is ""
|
|
600
|
+
const journal = [];
|
|
601
|
+
if (!o.dryRun) {
|
|
602
|
+
try {
|
|
603
|
+
await applyPromote(promotePlan, journal);
|
|
604
|
+
}
|
|
605
|
+
catch (e) {
|
|
606
|
+
fail(lateFailure(e, promotePlan.forgeRoot, journal, "drift promote"));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// Print the report
|
|
610
|
+
const writeVerb = o.dryRun ? "would write" : "wrote ";
|
|
611
|
+
const editVerb = o.dryRun ? "would edit" : "edited";
|
|
612
|
+
// First line depends on outcome
|
|
613
|
+
if (promotePlan.outcome === "params" || promotePlan.outcome === "sections") {
|
|
614
|
+
console.log(`promote ${promotePlan.inputPath} → profile ${promotePlan.profile} (${promotePlan.outcome})`);
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
console.log(`promote ${promotePlan.inputPath} → ${promotePlan.promoted} (${promotePlan.outcome}, profile ${promotePlan.profile})`);
|
|
618
|
+
}
|
|
619
|
+
// Skip entries that are also in editedKeys (they'll be printed with keys below)
|
|
620
|
+
const editedKeyFiles = new Set(promotePlan.editedKeys.keys());
|
|
621
|
+
for (const e of promotePlan.entries) {
|
|
622
|
+
if (editedKeyFiles.has(e.rel))
|
|
623
|
+
continue;
|
|
624
|
+
if (e.created) {
|
|
625
|
+
console.log(` ${writeVerb} ${e.rel}`);
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
console.log(` ${editVerb} ${e.rel}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Print early warnings (e.g., unmanaged skill files) — pinned by test 10
|
|
632
|
+
const unusedSectionPrefix = "profile ";
|
|
633
|
+
for (const w of promotePlan.warnings) {
|
|
634
|
+
if (!w.startsWith(unusedSectionPrefix) || !w.includes(" still sets section ")) {
|
|
635
|
+
// Skip registry warnings here — they come later
|
|
636
|
+
if (!w.includes(": missing") && !w.includes(": error:")) {
|
|
637
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
for (const [file, keys] of promotePlan.editedKeys) {
|
|
642
|
+
console.log(` ${editVerb} ${file} (${keys.join(", ")})`);
|
|
643
|
+
}
|
|
644
|
+
// Print param changes
|
|
645
|
+
for (const p of promotePlan.params) {
|
|
646
|
+
console.log(` param ${p.key}: ${p.old === null ? "(unset)" : JSON.stringify(p.old)} → ${JSON.stringify(p.value)}`);
|
|
647
|
+
}
|
|
648
|
+
// Print section changes (line count, never content)
|
|
649
|
+
for (const s of promotePlan.sections) {
|
|
650
|
+
console.log(` section ${s.key} ${s.name}: ${s.lines} line(s)`);
|
|
651
|
+
}
|
|
652
|
+
// Print flattened (only for variant outcomes)
|
|
653
|
+
const flatParts = [];
|
|
654
|
+
if (promotePlan.flattened.params.length > 0) {
|
|
655
|
+
flatParts.push(`${promotePlan.flattened.params.length} param(s) (${promotePlan.flattened.params.join(", ")})`);
|
|
656
|
+
}
|
|
657
|
+
if (promotePlan.flattened.sections.length > 0) {
|
|
658
|
+
flatParts.push(`${promotePlan.flattened.sections.length} section(s) (${promotePlan.flattened.sections.join(", ")})`);
|
|
659
|
+
}
|
|
660
|
+
if (flatParts.length > 0) {
|
|
661
|
+
console.log(` flattened: ${flatParts.join(", ")}`);
|
|
662
|
+
}
|
|
663
|
+
// Print late warnings (unused section value) after flattened — step 30g
|
|
664
|
+
for (const w of promotePlan.warnings) {
|
|
665
|
+
if (w.startsWith(unusedSectionPrefix) && w.includes(" still sets section ")) {
|
|
666
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const otherCount = promotePlan.otherFilesUnchanged;
|
|
670
|
+
console.log(` proved: this workspace plans the file on disk; ${otherCount} other file(s) unchanged`);
|
|
671
|
+
if (promotePlan.dependents.length > 0) {
|
|
672
|
+
console.log(` also changes ${promotePlan.dependents.join(", ")}`);
|
|
673
|
+
}
|
|
674
|
+
console.log(` ${nextSyncLine(promotePlan.nextSync, promotePlan.nextUnset)}`);
|
|
675
|
+
// Impact: print other workspaces or registry warning
|
|
676
|
+
const impact = promotePlan.impact;
|
|
677
|
+
if (impact.registry !== "read") {
|
|
678
|
+
// none, partial, or off — spec 30 §4.6 item 6
|
|
679
|
+
console.log(` ${pc.yellow("warn")} other workspaces could not be checked (registry: ${impact.registry})`);
|
|
680
|
+
}
|
|
681
|
+
else if (impact.workspaces.length === 0) {
|
|
682
|
+
console.log(" no other registered workspace reads this Forge");
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
// Group by profile and print
|
|
686
|
+
const sameProfile = impact.workspaces.filter((ws) => ws.profile === promotePlan.profile && ws.state !== "missing" && ws.state !== "error");
|
|
687
|
+
if (sameProfile.length > 0) {
|
|
688
|
+
const countParts = sameProfile.map((ws) => {
|
|
689
|
+
const c = NEXT_SYNC_STATES.map((k) => (ws.counts[k] ? `${ws.counts[k]} ${k}` : "")).filter(Boolean);
|
|
690
|
+
return `${ws.path} — ${c.length ? c.join(", ") : "unchanged"}`;
|
|
691
|
+
});
|
|
692
|
+
console.log(` ${sameProfile.length} other workspace(s) of profile ${promotePlan.profile} read this Forge: ${countParts.join("; ")}`);
|
|
693
|
+
}
|
|
694
|
+
else if (impact.workspaces.filter((ws) => ws.state !== "missing" && ws.state !== "error").length === 0) {
|
|
695
|
+
console.log(" no other registered workspace reads this Forge");
|
|
696
|
+
}
|
|
697
|
+
// Print missing/error as warnings
|
|
698
|
+
for (const ws of impact.workspaces) {
|
|
699
|
+
if (ws.state === "missing") {
|
|
700
|
+
console.log(` ${pc.yellow("warn")} ${ws.path}: missing`);
|
|
701
|
+
}
|
|
702
|
+
else if (ws.state === "error") {
|
|
703
|
+
console.log(` ${pc.yellow("warn")} ${ws.path}: error: ${ws.error}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (o.dryRun) {
|
|
708
|
+
console.log(" dry run — the Forge was not written");
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
console.log(" the Forge is not committed — review with git, then commit and push it");
|
|
712
|
+
if (promotePlan.isUrlForge) {
|
|
713
|
+
console.log(" push the Forge for sync to see this");
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
});
|
|
423
717
|
/* ---------------------------------------------------------------- add / remove recipe */
|
|
424
718
|
/**
|
|
425
719
|
* Spec 22's line: what the next sync would do (also `init --no-sync`, spec 23 §4.4).
|
|
@@ -1229,13 +1523,13 @@ forge
|
|
|
1229
1523
|
parsed = parseYamlText(o.plan, await fs.readFile(o.plan, "utf8"));
|
|
1230
1524
|
}
|
|
1231
1525
|
catch (e) {
|
|
1232
|
-
fail(e instanceof YamlSyntaxError ? e.message : `${o.plan}: ${
|
|
1526
|
+
fail(e instanceof YamlSyntaxError ? e.message : `${o.plan}: ${errorText(e)}`);
|
|
1233
1527
|
}
|
|
1234
1528
|
try {
|
|
1235
1529
|
loadedPlan = UnifyPlanSchema.parse(parsed);
|
|
1236
1530
|
}
|
|
1237
1531
|
catch (e) {
|
|
1238
|
-
fail(`${o.plan}: ${
|
|
1532
|
+
fail(`${o.plan}: ${errorText(e)}`);
|
|
1239
1533
|
}
|
|
1240
1534
|
// The plan must be for this exact invocation (Ruling 23) — checked before staleness, so a
|
|
1241
1535
|
// right-ingredient-wrong-profile plan is named for what it is rather than misdiagnosed as
|
|
@@ -1311,12 +1605,7 @@ forge
|
|
|
1311
1605
|
const mustHold = [base.dir, ...(result.resolved ? [variant.dir, ...cascadeFiles] : []), ...(paramWrites?.mustHold ?? []), ...candidateProfiles];
|
|
1312
1606
|
const unheld = await gitUnheld(f.root, mustHold);
|
|
1313
1607
|
if (unheld.length) {
|
|
1314
|
-
|
|
1315
|
-
const SHOWN = 10;
|
|
1316
|
-
const lines = unheld.slice(0, SHOWN).map((u) => ` ${u.path} is not held by git (${u.reason})`);
|
|
1317
|
-
if (unheld.length > SHOWN)
|
|
1318
|
-
lines.push(` … and ${unheld.length - SHOWN} more`);
|
|
1319
|
-
fail(`unify can only change files git can restore — ${unheld.length} path(s) under ${f.root} are not:\n${lines.join("\n")}`);
|
|
1608
|
+
fail(unheldMessage("unify", f.root, unheld));
|
|
1320
1609
|
}
|
|
1321
1610
|
let impactState = "skipped";
|
|
1322
1611
|
let impactEntries = [];
|
|
@@ -1706,7 +1995,7 @@ cache
|
|
|
1706
1995
|
else
|
|
1707
1996
|
printPrune(result);
|
|
1708
1997
|
});
|
|
1709
|
-
program.parseAsync().catch((e) => fail(
|
|
1998
|
+
program.parseAsync().catch((e) => fail(errorText(e)));
|
|
1710
1999
|
/* ---------------------------------------------------------------- helpers */
|
|
1711
2000
|
/** The network rule of a command, `--offline`, and `$CRAFTAR_HOME` (spec 13 §4.1, §6.3). */
|
|
1712
2001
|
function load(o, mode) {
|
|
@@ -1924,11 +2213,11 @@ function color(state) {
|
|
|
1924
2213
|
function explainSkip(s) {
|
|
1925
2214
|
switch (s.state) {
|
|
1926
2215
|
case "drift":
|
|
1927
|
-
return "hand-edited since last sync —
|
|
2216
|
+
return "hand-edited since last sync — `craftar drift show <path>`, then `craftar drift discard` or `craftar drift promote`";
|
|
1928
2217
|
case "collision":
|
|
1929
2218
|
return "exists but was never generated by craftar and differs from the Forge — rename it or import it";
|
|
1930
2219
|
case "orphan-drift":
|
|
1931
|
-
return "no longer produced by the Forge but hand-edited — kept; delete it yourself
|
|
2220
|
+
return "no longer produced by the Forge but hand-edited — kept; `craftar drift discard <path>` removes it, or delete it yourself";
|
|
1932
2221
|
default:
|
|
1933
2222
|
return "";
|
|
1934
2223
|
}
|
|
@@ -2026,20 +2315,28 @@ async function pathTaken(p) {
|
|
|
2026
2315
|
return false;
|
|
2027
2316
|
}
|
|
2028
2317
|
}
|
|
2318
|
+
/** The refusal message for paths git cannot restore (Ruling 37): the command name and every unheld path. */
|
|
2319
|
+
function unheldMessage(command, root, unheld) {
|
|
2320
|
+
const SHOWN = 10;
|
|
2321
|
+
const lines = unheld.slice(0, SHOWN).map((u) => ` ${u.path} is not held by git (${u.reason})`);
|
|
2322
|
+
if (unheld.length > SHOWN)
|
|
2323
|
+
lines.push(` … and ${unheld.length - SHOWN} more`);
|
|
2324
|
+
return `${command} can only change files git can restore — ${unheld.length} path(s) under ${root} are not:\n${lines.join("\n")}`;
|
|
2325
|
+
}
|
|
2029
2326
|
/**
|
|
2030
2327
|
* The message for a `forge unify` failure after writing began (Ruling 33): the original error, the
|
|
2031
2328
|
* Forge-relative paths already touched, and the git commands that undo them — `checkout` for what
|
|
2032
2329
|
* git tracks, `clean` for files unify created (checkout refuses a path git does not know).
|
|
2033
2330
|
*/
|
|
2034
|
-
function lateFailure(e, root, journal) {
|
|
2035
|
-
const msg =
|
|
2331
|
+
function lateFailure(e, root, journal, command = "unify") {
|
|
2332
|
+
const msg = errorText(e);
|
|
2036
2333
|
if (journal.length === 0)
|
|
2037
2334
|
return msg;
|
|
2038
2335
|
const rel = (abs) => path.relative(root, abs).split(path.sep).join("/");
|
|
2039
2336
|
const quote = (p) => (/^[\w./-]+$/.test(p) ? p : `"${p}"`);
|
|
2040
2337
|
const restore = [...new Set(journal.filter((j) => !j.created).map((j) => rel(j.abs)))];
|
|
2041
2338
|
const created = [...new Set(journal.filter((j) => j.created).map((j) => rel(j.abs)))];
|
|
2042
|
-
const lines = [msg,
|
|
2339
|
+
const lines = [msg, `${command} had already started changing the Forge (${root}) when this failed:`];
|
|
2043
2340
|
for (const p of [...restore, ...created])
|
|
2044
2341
|
lines.push(` ${p}`);
|
|
2045
2342
|
lines.push("recover with:");
|