craftar 0.20.0 → 0.21.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 +11 -1
- package/dist/cli.js +62 -24
- package/dist/cli.js.map +1 -1
- package/dist/core/forge.js +5 -3
- package/dist/core/forge.js.map +1 -1
- package/dist/core/frontmatter.js +2 -2
- package/dist/core/frontmatter.js.map +1 -1
- package/dist/core/import-report.js +91 -0
- package/dist/core/import-report.js.map +1 -0
- package/dist/core/manifest-edit.js +4 -5
- package/dist/core/manifest-edit.js.map +1 -1
- package/dist/core/param-writes.js +6 -7
- package/dist/core/param-writes.js.map +1 -1
- package/dist/core/unify.js +6 -5
- package/dist/core/unify.js.map +1 -1
- package/dist/core/workspace-yaml.js +2 -10
- package/dist/core/workspace-yaml.js.map +1 -1
- package/dist/core/yaml-read.js +32 -0
- package/dist/core/yaml-read.js.map +1 -0
- package/dist/importers/claude-code.js +10 -3
- package/dist/importers/claude-code.js.map +1 -1
- package/dist/importers/kiro-report.js +102 -0
- package/dist/importers/kiro-report.js.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
|
|
|
69
69
|
| Command | What it does |
|
|
70
70
|
|---|---|
|
|
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
|
-
| `craftar import --from claude-code --forge <dir> --profile <name> [--workspace .] [--write-config]` | 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. |
|
|
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
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. |
|
|
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. |
|
|
@@ -310,6 +310,16 @@ Later: `service` ingredients with compose fragments (`craftar services up`); `do
|
|
|
310
310
|
|
|
311
311
|
## Upgrading
|
|
312
312
|
|
|
313
|
+
### to 0.21.1
|
|
314
|
+
|
|
315
|
+
- **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.
|
|
316
|
+
- **No byte change**: no emitter and no lock changed. No workspace sees `update`.
|
|
317
|
+
|
|
318
|
+
### to 0.21.0
|
|
319
|
+
|
|
320
|
+
- **`craftar import --report <file.md>` is new** and additive: without the flag, `import` reads, writes and prints what it did before. Nothing to do.
|
|
321
|
+
- **No byte change**: no emitter, no import decision and no lock changed. No workspace sees `update`.
|
|
322
|
+
|
|
313
323
|
### to 0.20.0
|
|
314
324
|
|
|
315
325
|
- **A declared parameter with no value now stops `sync`.** If an ingredient declares a key under `params` with no `default`, a file cites `{{key}}`, and no layer sets it, `craftar sync`, `sync --check`, `status` and `diff --exit-code` exit 1 and `sync` writes nothing. Before, the file was written with `{{key}}` in it and a warning. Run `craftar doctor` to list the keys; set each in the profile's `params` or in `overrides.params`. A `{{key}}` no ingredient declares is left verbatim with its warning, as before.
|
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ import { catalogueContext, listRecipes, listIngredients, checkType } from "./cor
|
|
|
16
16
|
import { listTargets } from "./core/capabilities.js";
|
|
17
17
|
import { canonicalValue } from "./core/sections.js";
|
|
18
18
|
import { renderDiff, NO_EOF_NEWLINE_MARKER } from "./core/diff.js";
|
|
19
|
+
import { renderImportReport } from "./core/import-report.js";
|
|
19
20
|
import { diffIngredients, listVariants, profileOf } from "./core/variants.js";
|
|
20
21
|
import { hashNormalized, toLf, stripBom } from "./core/text.js";
|
|
21
22
|
import { exists, gitDirty, gitIsRepo, gitUnheld, loadForge } from "./core/forge.js";
|
|
@@ -29,6 +30,7 @@ import { editRecipesText, planRecipeEdit, recipeDiffLine } from "./core/recipe-e
|
|
|
29
30
|
import { localKeys, readLocalFile } from "./core/workspace-yaml.js";
|
|
30
31
|
import { HUNK_CLASSES, INGREDIENT_TYPES, UnifyPlanSchema } from "./schema/index.js";
|
|
31
32
|
import { EXAMPLE_SETTINGS } from "./emitters/shared.js";
|
|
33
|
+
import { YamlSyntaxError, parseYamlText } from "./core/yaml-read.js";
|
|
32
34
|
process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
33
35
|
process.exit(0); });
|
|
34
36
|
/**
|
|
@@ -38,8 +40,9 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
|
38
40
|
*/
|
|
39
41
|
const DIFF_EXIT_CODE_FETCH_MODE = "sync";
|
|
40
42
|
const program = new Command();
|
|
41
|
-
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.
|
|
42
|
-
|
|
43
|
+
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.21.1");
|
|
44
|
+
const SAVE_PLAN_GATE = { what: "plan", who: "unify", outside: "save plans outside the Forge", never: "unify never overwrites" };
|
|
45
|
+
const REPORT_GATE = { what: "report", who: "import", outside: "write the report outside the Forge", never: "import never overwrites" };
|
|
43
46
|
program
|
|
44
47
|
.command("import")
|
|
45
48
|
.description("Import an existing workspace harness into a Forge: creates or updates ingredients, recipes and a profile, reusing a templated base when it renders the workspace text or infers it into params or sections")
|
|
@@ -48,13 +51,16 @@ program
|
|
|
48
51
|
.requiredOption("--profile <name>", "client profile to create or update")
|
|
49
52
|
.option("--workspace <dir>", "workspace to import", ".")
|
|
50
53
|
.option("--write-config", "write craftar.yaml into the workspace, merging an existing one (forge, profile, targets)", false)
|
|
54
|
+
.option("--report <file.md>", "also write a Markdown report of the import — what was created, reused, made a variant or rejected, and the .kiro/ files that differ from what Craftar would generate (paths and line counts, never content); refused when the path is inside the Forge or already exists")
|
|
51
55
|
.action(async (o) => {
|
|
52
56
|
if (o.from !== "claude-code")
|
|
53
57
|
fail(`unsupported source "${o.from}" (only claude-code for now)`);
|
|
54
58
|
// import writes a local Forge; a URL would become a directory named after it (spec 13 §4.4).
|
|
55
59
|
if (classifyForge(o.forge) === "url")
|
|
56
60
|
fail("--forge takes a directory; to read a remote Forge, run inside a workspace that names it");
|
|
57
|
-
|
|
61
|
+
// Spec 29 §4.2: the report's path is refused before the import reads anything, so a refusal leaves the Forge untouched.
|
|
62
|
+
const reportAbs = o.report === undefined ? null : await gateOutsideForge(o.report, o.forge, REPORT_GATE);
|
|
63
|
+
const r = await importClaudeCode({ workspaceRoot: o.workspace, forgeRoot: o.forge, profileName: o.profile, writeWorkspaceConfig: o.writeConfig, report: reportAbs !== null });
|
|
58
64
|
console.log(pc.bold(`Imported ${path.resolve(o.workspace)} → ${path.resolve(o.forge)} as profile "${r.profile}"`));
|
|
59
65
|
console.log(` ${pc.green(String(r.created.length))} created, ${pc.cyan(String(r.reused.length))} reused, ${pc.yellow(String(r.variants.length))} variants, ${pc.red(String(r.rejected.length))} rejected`);
|
|
60
66
|
for (const v of r.variants)
|
|
@@ -90,6 +96,19 @@ program
|
|
|
90
96
|
: ` workspace craftar.yaml edited (forge, profile, targets)`);
|
|
91
97
|
for (const w of r.warnings)
|
|
92
98
|
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
99
|
+
if (reportAbs !== null) {
|
|
100
|
+
const text = renderImportReport({ workspace: path.resolve(o.workspace), forge: path.resolve(o.forge), report: r, now: new Date(), version: program.version() });
|
|
101
|
+
// After the summary, so a report that cannot be written never costs the summary of an import that succeeded.
|
|
102
|
+
try {
|
|
103
|
+
await fs.mkdir(path.dirname(reportAbs), { recursive: true });
|
|
104
|
+
await fs.writeFile(reportAbs, text, { flag: "wx" });
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
fail(`The Forge was written in full and the import succeeded; writing the report failed (${e instanceof Error ? e.message : String(e)}) — ` +
|
|
108
|
+
`the summary above stands; re-run with another --report path to get the file`);
|
|
109
|
+
}
|
|
110
|
+
console.log(` report ${o.report}`);
|
|
111
|
+
}
|
|
93
112
|
});
|
|
94
113
|
/* ---------------------------------------------------------------- status */
|
|
95
114
|
program
|
|
@@ -1205,8 +1224,15 @@ forge
|
|
|
1205
1224
|
}
|
|
1206
1225
|
let loadedPlan;
|
|
1207
1226
|
if (o.plan) {
|
|
1227
|
+
let parsed;
|
|
1228
|
+
try {
|
|
1229
|
+
parsed = parseYamlText(o.plan, await fs.readFile(o.plan, "utf8"));
|
|
1230
|
+
}
|
|
1231
|
+
catch (e) {
|
|
1232
|
+
fail(e instanceof YamlSyntaxError ? e.message : `${o.plan}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1233
|
+
}
|
|
1208
1234
|
try {
|
|
1209
|
-
loadedPlan = UnifyPlanSchema.parse(
|
|
1235
|
+
loadedPlan = UnifyPlanSchema.parse(parsed);
|
|
1210
1236
|
}
|
|
1211
1237
|
catch (e) {
|
|
1212
1238
|
fail(`${o.plan}: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -1229,22 +1255,7 @@ forge
|
|
|
1229
1255
|
// Forge — so that is enforced, not assumed, and a plan never overwrites anything. Both
|
|
1230
1256
|
// refusals come before any write; the comparison runs on real paths, so neither a `..`
|
|
1231
1257
|
// segment nor a symlink can carry the target back into the Forge.
|
|
1232
|
-
const savePlanAbs =
|
|
1233
|
-
let targetReal;
|
|
1234
|
-
try {
|
|
1235
|
-
targetReal = await realpathOfNearest(savePlanAbs);
|
|
1236
|
-
}
|
|
1237
|
-
catch (e) {
|
|
1238
|
-
fail(`refusing to write the plan to ${o.savePlan}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1239
|
-
}
|
|
1240
|
-
const rootReal = await fs.realpath(path.resolve(f.root));
|
|
1241
|
-
const rel = path.relative(rootReal, targetReal);
|
|
1242
|
-
if (rel === "" || !(rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))) {
|
|
1243
|
-
fail(`refusing to write the plan to ${o.savePlan}: it resolves inside the Forge (${f.root}) — save plans outside the Forge`);
|
|
1244
|
-
}
|
|
1245
|
-
if (await pathTaken(savePlanAbs)) {
|
|
1246
|
-
fail(`refusing to write the plan to ${o.savePlan}: the file already exists — unify never overwrites; choose a new path`);
|
|
1247
|
-
}
|
|
1258
|
+
const savePlanAbs = await gateOutsideForge(o.savePlan, f.root, SAVE_PLAN_GATE);
|
|
1248
1259
|
const saved = await planFrom(base, variant, diff, o.profile);
|
|
1249
1260
|
await fs.mkdir(path.dirname(savePlanAbs), { recursive: true });
|
|
1250
1261
|
await fs.writeFile(savePlanAbs, YAML.stringify(saved), { flag: "wx" });
|
|
@@ -1939,7 +1950,7 @@ async function readText(p) {
|
|
|
1939
1950
|
* other outcome throws instead. A component `lstat` cannot inspect (a symlink loop or a permission
|
|
1940
1951
|
* error on Linux) "cannot be inspected"; one it can inspect but `realpath` cannot follow (a
|
|
1941
1952
|
* dangling symlink or junction, a loop on Windows) "exists but cannot be resolved". Resolving the
|
|
1942
|
-
* rest lexically would let a link that points into the Forge pass the containment check, so
|
|
1953
|
+
* rest lexically would let a link that points into the Forge pass the containment check, so the gate
|
|
1943
1954
|
* fails closed rather than guess where the target lands.
|
|
1944
1955
|
*/
|
|
1945
1956
|
async function realpathOfNearest(abs) {
|
|
@@ -1970,13 +1981,40 @@ async function realpathOfNearest(abs) {
|
|
|
1970
1981
|
}
|
|
1971
1982
|
}
|
|
1972
1983
|
/**
|
|
1973
|
-
* The refusal for a
|
|
1974
|
-
* when `lstat` itself failed, so the path is not known to exist, and "exists but cannot be
|
|
1984
|
+
* The refusal for a target that cannot be resolved; the caller adds which command cannot prove it. `what` is
|
|
1985
|
+
* "cannot be inspected" when `lstat` itself failed, so the path is not known to exist, and "exists but cannot be
|
|
1975
1986
|
* resolved" when `lstat` succeeded and `realpath` did not.
|
|
1976
1987
|
*/
|
|
1977
1988
|
function cannotResolve(p, e, what) {
|
|
1978
1989
|
const code = e?.code ?? (e instanceof Error ? e.message : String(e));
|
|
1979
|
-
return new Error(`${p} ${what} (${code})
|
|
1990
|
+
return new Error(`${p} ${what} (${code})`);
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* The gate of a user-supplied output path (Ruling 30 of spec 06; spec 29 §4.2): the file a command writes where the
|
|
1994
|
+
* user says must lie outside the Forge and must not exist. Both sides are compared as real paths, so neither a `..`
|
|
1995
|
+
* segment nor a symlink can carry the target back into the Forge; the Forge side goes through its nearest existing
|
|
1996
|
+
* ancestor too, since `import --forge` may name a directory that does not exist yet. Returns the absolute target, or
|
|
1997
|
+
* fails before anything is written.
|
|
1998
|
+
*/
|
|
1999
|
+
async function gateOutsideForge(target, forgeRoot, w) {
|
|
2000
|
+
const refusing = `refusing to write the ${w.what} to ${target}`;
|
|
2001
|
+
const abs = path.resolve(target);
|
|
2002
|
+
let targetReal;
|
|
2003
|
+
let rootReal;
|
|
2004
|
+
try {
|
|
2005
|
+
targetReal = await realpathOfNearest(abs);
|
|
2006
|
+
rootReal = await realpathOfNearest(path.resolve(forgeRoot));
|
|
2007
|
+
}
|
|
2008
|
+
catch (e) {
|
|
2009
|
+
fail(`${refusing}: ${e instanceof Error ? e.message : String(e)} — ${w.who} cannot prove the target lies outside the Forge`);
|
|
2010
|
+
}
|
|
2011
|
+
const rel = path.relative(rootReal, targetReal);
|
|
2012
|
+
if (rel === "" || !(rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))) {
|
|
2013
|
+
fail(`${refusing}: it resolves inside the Forge (${forgeRoot}) — ${w.outside}`);
|
|
2014
|
+
}
|
|
2015
|
+
if (await pathTaken(abs))
|
|
2016
|
+
fail(`${refusing}: the file already exists — ${w.never}; choose a new path`);
|
|
2017
|
+
return abs;
|
|
1980
2018
|
}
|
|
1981
2019
|
/** Whether anything — a file, a directory, even a dangling symlink — already sits at `p`. */
|
|
1982
2020
|
async function pathTaken(p) {
|