From 9387c535725e75ad31cca84953fba0e528cea9d0 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 19:43:39 -0300 Subject: [PATCH 01/26] test(init): pin init's both-flags stdout and the profile question's `0` re-ask before spec 38 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written first, as spec 38 §10 asks: --new-profile and the `0)` entry must leave both as they are. --- test/init-new-profile-pins.test.ts | 74 ++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 test/init-new-profile-pins.test.ts diff --git a/test/init-new-profile-pins.test.ts b/test/init-new-profile-pins.test.ts new file mode 100644 index 0000000..1d23d7b --- /dev/null +++ b/test/init-new-profile-pins.test.ts @@ -0,0 +1,74 @@ +import { existsSync, promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { askInit, type InitIo } from "../src/core/init-flow.js"; +import { runCli } from "./helpers/cli.js"; +import { makeForge, profile, recipe, rule, tmpDir } from "./helpers/forge.js"; + +// Spec 38 §10: outputs pinned BEFORE the build touches the code behind them — `init` with both flags (it must keep +// every byte once --new-profile exists), and the profile question's answer `0`, which matches nothing today and +// re-asks (spec 38 tests the new `0)` entry between the names and the numbers). The link refusals of +// assertNoLinkOnPath are already pinned byte for byte by test/drift-promote.test.ts and test/promote.test.ts, and +// forge promote's writes by its golden; measured at 3faa0b4: every Forge writer journals files only, so a +// lateFailure that tells a created directory by `stat` changes none of their lines. + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +async function scratch(): Promise { + const dir = await fs.realpath(await tmpDir("craftar-np-pin-")); + cleanups.push(() => fs.rm(dir, { recursive: true, force: true })); + return dir; +} +const SPEC = { + ingredients: [rule("a", "# A\n"), rule("b", "# B\n")], + recipes: [recipe("base", ["rule/a"]), recipe("extra", ["rule/b"])], + profiles: [profile("acme", ["base"], ["claude-code", "kiro"]), profile("globex", ["base"], ["kiro"])], +}; + +describe("pin — init with --forge and --profile (before --new-profile exists)", () => { + it("stdout byte for byte, a recipe flag included", async () => { + const p = await scratch(); + await makeForge(path.join(p, "forge"), SPEC); + const ws = path.join(p, "ws"); + const r = runCli(["init", "--forge", path.join(p, "forge"), "--profile", "acme", "--add-recipe", "extra", "--workspace", ws]); + expect(r.code, r.stderr).toBe(0); + expect(r.stderr).toBe(""); + expect(r.stdout).toBe( + [ + `craftar init — wrote craftar.yaml in ${ws}`, + " forge ../forge · profile acme · recipes base → extra · targets claude-code, kiro (from the profile)", + "craftar sync — profile acme · recipes base → extra · targets claude-code, kiro", + " wrote 4, removed 0 orphan(s), skipped 0", + " + .claude/rules/a.md", + " + .claude/rules/b.md", + " + .kiro/steering/a.md", + " + .kiro/steering/b.md", + "", + ].join("\n"), + ); + expect(await fs.readFile(path.join(ws, "craftar.yaml"), "utf8")).toBe("forge: ../forge\nprofile: acme\nrecipes:\n add:\n - extra\n"); + }); +}); + +describe("pin — the profile question's answer `0` matches nothing today and re-asks", () => { + it("0, then a name", async () => { + const p = await scratch(); + const forge = path.join(p, "forge"); + await makeForge(forge, SPEC); + const ws = path.join(p, "ws"); + await fs.mkdir(ws); + const answers = ["0", "acme"]; + const asked: string[] = []; + const said: string[] = []; + const next = async (q: string) => (asked.push(q), answers.length ? answers.shift()! : ""); + const io: InitIo = { ask: next, confirm: next, say: (l) => void said.push(l) }; + const r = await askInit(ws, { forge, addRecipes: [], removeRecipes: [], replace: false }, { doc: null, keys: [] }, io, { registryOff: true, exists: existsSync }); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.profile).toBe("acme"); + const profileQuestions = asked.filter((q) => q.startsWith("Profile")); + expect(profileQuestions.length).toBe(2); + expect(said.some((l) => l.includes("0) a new profile"))).toBe(false); + }); +}); From eba15c64717f9e9c333c696d830e06e79d97d5e0 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 19:48:13 -0300 Subject: [PATCH 02/26] refactor(promote): the owned-recipe predicate is exported once, namedForProfile (spec 38) --- src/core/promote.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/core/promote.ts b/src/core/promote.ts index 92e3379..331a5b5 100644 --- a/src/core/promote.ts +++ b/src/core/promote.ts @@ -17,6 +17,13 @@ import { editYamlText } from "./yaml-edit.js"; // base gets it back as its own variant. Three steps a caller runs in order: the edit set (reads only), the slot proof // on a scratch copy (reads the Forge only), the writes through a journal. +/** + * The owned-recipe predicate (spec 38 §3): a recipe named for a profile — `--` or `-steering`. + */ +export function namedForProfile(name: string, q: string): boolean { + return name.endsWith(`--${q}`) || name === `${q}-steering`; +} + const COMMAND = "forge promote"; /** One file the promotion writes; `created` when nothing is there today. */ @@ -204,7 +211,6 @@ export async function planBasePromotion(forge: Forge, ref: string, profile: stri if (f.stem !== name) throw new Error(`${COMMAND}: recipe ${name} is ${label(f.abs)} — ${COMMAND} writes recipes/${name}.yaml`); return f; }; - const owned = (name: string, q: string) => name.endsWith(`--${q}`) || name === `${q}-steering`; const detail: BasePromotion["detail"] = { recipes: [], profiles: [] }; const editedRecipes: PromotionWrite[] = []; const createdRecipes: PromotionWrite[] = []; @@ -233,7 +239,7 @@ export async function planBasePromotion(forge: Forge, ref: string, profile: stri continue; } if (!namesBase) continue; - const owner = Q.find((q) => owned(name, q)); + const owner = Q.find((q) => namedForProfile(name, q)); if (owner) { const f = fileOf(name); editedRecipes.push({ abs: f.abs, content: recipeEdit(f.raw, label(f.abs), name, name, X, variantRef(owner)), created: false }); From aee74c3a3af802819f6a981f84368c10bd1cb05b Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 19:48:18 -0300 Subject: [PATCH 03/26] =?UTF-8?q?feat(init):=20newProfile=20builds=20a=20n?= =?UTF-8?q?ew=20profile's=20file=20=E2=80=94=20the=20template's=20recipes?= =?UTF-8?q?=20and=20targets=20only,=20P9=E2=80=93P11=20(spec=2038)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/new-profile.ts | 196 +++++++++++++++++++++++++++++++++++++++ test/new-profile.test.ts | 188 +++++++++++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 src/core/new-profile.ts create mode 100644 test/new-profile.test.ts diff --git a/src/core/new-profile.ts b/src/core/new-profile.ts new file mode 100644 index 0000000..8398db4 --- /dev/null +++ b/src/core/new-profile.ts @@ -0,0 +1,196 @@ +/** + * Pure helpers for building a new profile's file (spec 38 §5.2). + * No fs, no process, no console — all validation and generation is deterministic. + */ + +import { ProfileSchema, WorkspaceConfigSchema, type Profile } from "../schema/index.js"; +import type { Forge } from "./forge.js"; +import { effectiveProfile } from "./integrations.js"; +import { namedForProfile } from "./promote.js"; +import { planRecipeEdit } from "./recipe-edit.js"; +import { profileNotFoundMessage } from "./resolve.js"; + +/** Windows device names (spec 38 §5.1 P3). */ +const DEVICE_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + +/** Profile name grammar: lowercase letters and digits, in groups joined by one "-". */ +const PROFILE_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +/** + * P3: Validates a profile name. Throws with a quoted name on failure. + * @param name The profile name to validate. + * @throws When the name is not valid or is a Windows device name. + */ +export function profileName(name: string): void { + if (!PROFILE_NAME_RE.test(name)) { + throw new Error(`${JSON.stringify(name)} is not a profile name — lowercase letters and digits, in groups joined by one "-"`); + } + if (DEVICE_NAMES.test(name)) { + throw new Error(`${JSON.stringify(name)} is a device name on Windows — pick another`); + } +} + +/** + * Returns the profile of the LOADED Forge that owns the recipe, or null if none. + * When several match, the longest profile name wins (spec 38 §3). + * @param forge The loaded Forge. + * @param recipe The recipe name to check. + * @returns The profile name that owns the recipe, or null. + */ +export function ownedBy(forge: Forge, recipe: string): string | null { + const matches = [...forge.profiles.keys()].filter((q) => namedForProfile(recipe, q)); + if (matches.length === 0) return null; + // When several match, the longest name wins. + return matches.reduce((a, b) => (b.length > a.length ? b : a)); +} + +/** + * Returns a shallow copy of the Forge with the profile added to its profiles Map. + * The input Forge is not mutated. + * @param forge The loaded Forge. + * @param profile The profile to add. + * @returns A new Forge with the profile added. + */ +export function withProfile(forge: Forge, profile: Profile): Forge { + const profiles = new Map(forge.profiles); + profiles.set(profile.name, profile); + return { ...forge, profiles }; +} + +/** Options for newProfile (spec 38 §4.4). */ +export interface NewProfileOptions { + name: string; + like?: string; + add?: string[]; + remove?: string[]; + replace?: boolean; + targets?: string[]; +} + +/** Result of newProfile (spec 38 §4.4). */ +export interface NewProfileResult { + profile: Profile; + text: string; + notes: string[]; + blockNote: string | null; +} + +/** + * Builds a new profile's file from the template's recipes and targets only (spec 38 §4.4, §5.2). + * Called after the gate (the name is known free). + * + * @param forge The loaded Forge. + * @param o Options: name, like (template), add/remove recipes, replace, targets. + * @returns The profile, its YAML text, notes from recipe edits, and a note about integrations. + * @throws P9 when --like names a profile the Forge does not have. + * @throws P10 when the final list holds an owned recipe. + * @throws P11 for spec 22's own refusals (unknown recipe, held slot without --replace). + */ +export function newProfile(forge: Forge, o: NewProfileOptions): NewProfileResult { + const { name, like, add = [], remove = [], replace = false, targets: targetOverride } = o; + + // 1. template: o.like → forge.profiles.get(o.like) or throw P9 + const template = like ? forge.profiles.get(like) : undefined; + if (like && !template) { + throw new Error("--like: " + profileNotFoundMessage(like, [...forge.profiles.keys()])); + } + + // 2. targets = o.targets ?? template's ?? ["claude-code"] + const targets = targetOverride ?? template?.recipes !== undefined ? (targetOverride ?? template?.targets ?? ["claude-code"]) : ["claude-code"]; + + // 3. draft = ProfileSchema.parse({ name, recipes: template?.recipes ?? [], targets }); copy = withProfile(forge, draft) + const draft = ProfileSchema.parse({ + name, + recipes: template?.recipes ?? [], + targets, + }); + let copy = withProfile(forge, draft); + + // 4. the edit, as init does (removes first, then adds) + const notes: string[] = []; + let config = WorkspaceConfigSchema.parse({ + forge: ".", + profile: name, + recipes: { add: [], remove: [] }, + }); + + // Process removes first + if (remove.length > 0) { + const removeEdit = planRecipeEdit(copy, config, "remove", remove, { replace }); + config = { ...config, recipes: removeEdit.recipes }; + if (removeEdit.reasons.length > 0) { + notes.push(removeEdit.reasons.join(", ")); + } + } + + // Then adds + if (add.length > 0) { + const addEdit = planRecipeEdit(copy, config, "add", add, { replace }); + config = { ...config, recipes: addEdit.recipes }; + if (addEdit.reasons.length > 0) { + notes.push(addEdit.reasons.join(", ")); + } + } + + // 5. final list = draft.recipes minus the edit's resulting remove list, then plus its add list + const finalRecipes = [ + ...draft.recipes.filter((r) => !config.recipes.remove.includes(r)), + ...config.recipes.add, + ]; + + // 6. P10: the first recipe of the final list with ownedBy(forge, r) non-null (the LOADED forge, not the copy) + for (const r of finalRecipes) { + const owner = ownedBy(forge, r); + if (owner !== null) { + throw new Error( + `recipe ${r} is named for profile ${owner} — a new profile cannot use it; leave it out and add the shared recipe it stands for`, + ); + } + } + + // 7. profile = ProfileSchema.parse({ name, recipes: final, targets }) + const profile = ProfileSchema.parse({ + name, + recipes: finalRecipes, + targets, + }); + + // Generate text: name: \n + recipes block + targets block + const lines: string[] = []; + lines.push(`name: ${name}`); + if (finalRecipes.length === 0) { + lines.push("recipes: []"); + } else { + lines.push("recipes:"); + for (const r of finalRecipes) { + lines.push(` - ${r}`); + } + } + lines.push("targets:"); + for (const t of targets) { + lines.push(` - ${t}`); + } + const text = lines.join("\n") + "\n"; + + // 8. blockNote (only with a template) + let blockNote: string | null = null; + if (template) { + try { + const { selected } = effectiveProfile(forge, template); + // n = selected recipes not in the final list + const missing = selected.filter((s) => !finalRecipes.includes(s.recipe)); + if (missing.length >= 1) { + if (missing.length === 1) { + blockNote = `integrations of ${like} not copied — 1 recipe it selects is not in ${name}`; + } else { + blockNote = `integrations of ${like} not copied — ${missing.length} recipes it selects are not in ${name}`; + } + } + } catch { + // The block does not read + blockNote = `integrations of ${like} not copied — the block does not read, so what it selects is not known`; + } + } + + return { profile, text, notes, blockNote }; +} diff --git a/test/new-profile.test.ts b/test/new-profile.test.ts new file mode 100644 index 0000000..af3d264 --- /dev/null +++ b/test/new-profile.test.ts @@ -0,0 +1,188 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import YAML from "yaml"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadForge } from "../src/core/forge.js"; +import { newProfile, ownedBy, profileName, withProfile } from "../src/core/new-profile.js"; +import { namedForProfile } from "../src/core/promote.js"; +import { ProfileSchema } from "../src/schema/index.js"; +import { makeForge, profile, recipe, rule, tmpDir, type ForgeSpec } from "./helpers/forge.js"; + +// Spec 38 §3, §4.4, §5.1, §5.2, §6 cases 7–8 — the pure part of a new profile. Nothing here writes into a Forge. + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +const MARK = "zq" + "mark"; + +async function forgeOf(spec: ForgeSpec, schema = 1) { + const root = await fs.realpath(await tmpDir("craftar-new-profile-")); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + await makeForge(root, spec); + if (schema !== 1) await fs.writeFile(path.join(root, "craftar.forge.yaml"), YAML.stringify({ name: "test-forge", schema })); + return loadForge(root); +} +const fault = (fn: () => unknown): string => { + try { + fn(); + } catch (e) { + return (e as Error).message; + } + throw new Error("expected a refusal"); +}; + +const BASE_SPEC: ForgeSpec = { + ingredients: ["a", "b", "c", "fa", "fb"].map((n) => rule(n, `# ${n}\n`)), + recipes: [ + recipe("base", ["rule/a"]), + recipe("stack-node", ["rule/b"]), + recipe("extra", ["rule/c"]), + recipe("front-a", ["rule/fa"], { slot: "front" }), + recipe("front-b", ["rule/fb"], { slot: "front" }), + ], + profiles: [ + profile("acme", ["base", "stack-node", "front-a"], ["claude-code", "kiro"], { + description: `ACME ${MARK}`, + params: { "acme.org": `org-${MARK}` }, + sections: { "rule/a": { intro: `intro ${MARK}` } }, + repos: [{ name: "api", url: `https://git.example.com/${MARK}/api.git` }], + scm: { kind: "github", org: MARK }, + }), + profile("globex", ["base"], ["kiro"]), + ], +}; + +describe("profileName — P3 (§5.1)", () => { + it("accepted", () => { + for (const n of ["initech", "acme-portal", "a1", "0"]) expect(() => profileName(n)).not.toThrow(); + }); + it("refused: not a profile name", () => { + for (const n of ["Acme", "acme--x", "-acme", "acme-", "a/b", "a.b", "..", "", "a b", "a_b"]) + expect(fault(() => profileName(n)), n).toBe(`"${n}" is not a profile name — lowercase letters and digits, in groups joined by one "-"`); + }); + it("refused: a device name on Windows, on every platform", () => { + for (const n of ["con", "nul", "com1", "lpt9", "aux", "prn"]) expect(fault(() => profileName(n)), n).toBe(`"${n}" is a device name on Windows — pick another`); + }); +}); + +describe("namedForProfile and ownedBy — the owned-recipe test (§3)", () => { + it("namedForProfile: -- or -steering", () => { + expect(namedForProfile("base--acme", "acme")).toBe(true); + expect(namedForProfile("acme-steering", "acme")).toBe(true); + expect(namedForProfile("base", "acme")).toBe(false); + expect(namedForProfile("base--acmex", "acme")).toBe(false); + }); + it("ownedBy: owned recipes belong to a profile of the Forge; others to no one", async () => { + const forge = await forgeOf(BASE_SPEC); + for (const r of ["base--acme", "stack-api--acme", "acme-steering", "tools--acme"]) expect(ownedBy(forge, r), r).toBe("acme"); + for (const r of ["base", "stack-node", "base--nobody"]) expect(ownedBy(forge, r), r).toBeNull(); + }); + it("ownedBy: two matches — the longer profile name wins", async () => { + const forge = await forgeOf({ ...BASE_SPEC, profiles: [profile("b", ["base"]), profile("x--b", ["base"])] }); + expect(ownedBy(forge, "r--x--b")).toBe("x--b"); + }); + it("withProfile: a copy holding the new profile; the loaded Forge is not mutated", async () => { + const forge = await forgeOf(BASE_SPEC); + const p = ProfileSchema.parse({ name: "initech", recipes: ["base"], targets: ["claude-code"] }); + const copy = withProfile(forge, p); + expect(copy.profiles.get("initech")).toBe(p); + expect(forge.profiles.has("initech")).toBe(false); + expect([...copy.profiles.keys()].sort()).toEqual(["acme", "globex", "initech"]); + }); +}); + +describe("newProfile — the file of §4.4", () => { + it("no template: no recipe, the default target", async () => { + const forge = await forgeOf(BASE_SPEC); + const r = newProfile(forge, { name: "initech" }); + expect(r.text).toBe("name: initech\nrecipes: []\ntargets:\n - claude-code\n"); + expect(r.profile.recipes).toEqual([]); + expect(r.blockNote).toBeNull(); + }); + it("--like: the template's written recipes and targets, plus an add, nothing of its values", async () => { + const forge = await forgeOf(BASE_SPEC); + const r = newProfile(forge, { name: "initech", like: "acme", add: ["extra"] }); + expect(r.text).toBe("name: initech\nrecipes:\n - base\n - stack-node\n - front-a\n - extra\ntargets:\n - claude-code\n - kiro\n"); + expect(r.text.includes(MARK)).toBe(false); + const parsed = ProfileSchema.parse(YAML.parse(r.text)); + expect(parsed.recipes).toEqual(["base", "stack-node", "front-a", "extra"]); + expect(parsed.targets).toEqual(["claude-code", "kiro"]); + expect(Object.keys(YAML.parse(r.text))).toEqual(["name", "recipes", "targets"]); + }); + it("--remove-recipe, --targets", async () => { + const forge = await forgeOf(BASE_SPEC); + const r = newProfile(forge, { name: "initech", like: "acme", remove: ["stack-node"], targets: ["kiro"] }); + expect(r.text).toBe("name: initech\nrecipes:\n - base\n - front-a\ntargets:\n - kiro\n"); + }); + it("--replace on a held slot", async () => { + const forge = await forgeOf(BASE_SPEC); + const r = newProfile(forge, { name: "initech", like: "acme", add: ["front-b"], replace: true }); + expect(r.profile.recipes).toEqual(["base", "stack-node", "front-b"]); + }); + it("P11: spec 22's own refusals — an unknown recipe, a held slot without --replace", async () => { + const forge = await forgeOf(BASE_SPEC); + expect(fault(() => newProfile(forge, { name: "initech", add: ["nope"] }))).toMatch(/^recipe "nope" not found in this Forge \(/); + expect(fault(() => newProfile(forge, { name: "initech", like: "acme", add: ["front-b"] }))).toMatch(/front/); + }); + it("P9: --like a profile the Forge does not have", async () => { + const forge = await forgeOf(BASE_SPEC); + expect(fault(() => newProfile(forge, { name: "initech", like: "nope" }))).toBe('--like: profile "nope" not found in Forge (acme, globex)'); + }); +}); + +describe("newProfile — P10, an owned recipe in the list (§6 case 7)", () => { + const SPEC: ForgeSpec = { + ...BASE_SPEC, + recipes: [...(BASE_SPEC.recipes ?? []), recipe("base--acme", ["rule/a"]), recipe("x--initech", ["rule/a"])], + profiles: [profile("acme", ["base--acme", "stack-node"]), profile("globex", ["base"])], + }; + it("brought by the template, or named by --add-recipe: refused, the recipe and the profile named", async () => { + const forge = await forgeOf(SPEC); + const msg = "recipe base--acme is named for profile acme — a new profile cannot use it; leave it out and add the shared recipe it stands for"; + expect(fault(() => newProfile(forge, { name: "initech", like: "acme" }))).toBe(msg); + expect(fault(() => newProfile(forge, { name: "initech", add: ["base--acme"] }))).toBe(msg); + }); + it("passes once the owned recipe is removed and the shared one added", async () => { + const forge = await forgeOf(SPEC); + const r = newProfile(forge, { name: "initech", like: "acme", remove: ["base--acme"], add: ["base"] }); + expect(r.profile.recipes).toEqual(["stack-node", "base"]); + }); + it("a recipe named for the new profile itself belongs to no one: the loaded Forge decides", async () => { + const forge = await forgeOf(SPEC); + expect(newProfile(forge, { name: "initech", add: ["x--initech"] }).profile.recipes).toEqual(["x--initech"]); + }); +}); + +describe("newProfile — a template's integrations block (§6 case 8, Ruling 3)", () => { + const SPEC = (block: unknown, recipes: string[] = ["base"]): ForgeSpec => ({ + ...BASE_SPEC, + recipes: [...(BASE_SPEC.recipes ?? []), recipe("pm-azure", ["rule/c"]), recipe("idp-backstage", ["rule/b"])], + profiles: [profile("acme", recipes, ["claude-code"], { integrations: block }), profile("globex", ["base"])], + }); + it("schema 3: a recipe only the block selects is not copied — one note with the count, singular and plural", async () => { + const one = await forgeOf(SPEC({ pm: { kind: "azure", org: MARK } }), 3); + const r1 = newProfile(one, { name: "initech", like: "acme" }); + expect(r1.profile.recipes).toEqual(["base"]); + expect(r1.blockNote).toBe("integrations of acme not copied — 1 recipe it selects is not in initech"); + expect(r1.text.includes(MARK) || (r1.blockNote ?? "").includes(MARK)).toBe(false); + const two = await forgeOf(SPEC({ pm: { kind: "azure" }, idp: { kind: "backstage" } }), 3); + expect(newProfile(two, { name: "initech", like: "acme" }).blockNote).toBe("integrations of acme not copied — 2 recipes it selects are not in initech"); + }); + it("no note when every recipe the block selects is in the final list — written by the template, or added", async () => { + const f = await forgeOf(SPEC({ pm: { kind: "azure" } }, ["base", "pm-azure"]), 3); + expect(newProfile(f, { name: "initech", like: "acme" }).blockNote).toBeNull(); + const g = await forgeOf(SPEC({ pm: { kind: "azure" } }), 3); + expect(newProfile(g, { name: "initech", like: "acme", add: ["pm-azure"] }).blockNote).toBeNull(); + }); + it("schema 2: the block is not read — no note", async () => { + const f = await forgeOf(SPEC({ pm: { kind: "azure" } }), 2); + expect(newProfile(f, { name: "initech", like: "acme" }).blockNote).toBeNull(); + }); + it("a block that does not read: the list, the other wording, nothing of the resolver's message", async () => { + const f = await forgeOf(SPEC({ pm: MARK }), 3); + const r = newProfile(f, { name: "initech", like: "acme" }); + expect(r.profile.recipes).toEqual(["base"]); + expect(r.blockNote).toBe("integrations of acme not copied — the block does not read, so what it selects is not known"); + }); +}); From 026025ab19a91d02a18a2566acdb89ade0e19540 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 19:49:01 -0300 Subject: [PATCH 04/26] =?UTF-8?q?refactor(init):=20newProfile's=20targets?= =?UTF-8?q?=20read=20as=20written=20=E2=80=94=20the=20flag,=20else=20the?= =?UTF-8?q?=20template's,=20else=20claude-code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/new-profile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/new-profile.ts b/src/core/new-profile.ts index 8398db4..f21601a 100644 --- a/src/core/new-profile.ts +++ b/src/core/new-profile.ts @@ -96,7 +96,7 @@ export function newProfile(forge: Forge, o: NewProfileOptions): NewProfileResult } // 2. targets = o.targets ?? template's ?? ["claude-code"] - const targets = targetOverride ?? template?.recipes !== undefined ? (targetOverride ?? template?.targets ?? ["claude-code"]) : ["claude-code"]; + const targets = targetOverride ?? template?.targets ?? ["claude-code"]; // 3. draft = ProfileSchema.parse({ name, recipes: template?.recipes ?? [], targets }); copy = withProfile(forge, draft) const draft = ProfileSchema.parse({ From c80f1f6dc1995a22741373caf755f6d4a2b6dedf Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 19:53:08 -0300 Subject: [PATCH 05/26] refactor(forge): assertNoLinkOnPath names the writing command in its two messages (spec 38) --- src/core/forge.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/core/forge.ts b/src/core/forge.ts index d3a3e05..34efe95 100644 --- a/src/core/forge.ts +++ b/src/core/forge.ts @@ -256,10 +256,10 @@ export async function listFiles(dir: string, rel = ""): Promise { /* ---- the scratch copy a Forge proof runs on, and the link check its writes share (spec 30 `drift promote`, spec 29 `forge promote`) ---- */ -const SYMLINK_REFUSAL = (rel: string) => - `${rel} is a symbolic link in the Forge — promote does not write through a link; replace it with the file and commit`; -const SYMLINK_DIR_REFUSAL = (rel: string) => - `${rel} is a symbolic link in the Forge — promote does not read a linked recipes, ingredients or profiles directory; replace it with the directory and commit`; +const SYMLINK_REFUSAL = (rel: string, command: string) => + `${rel} is a symbolic link in the Forge — ${command} does not write through a link; replace it with the file and commit`; +const SYMLINK_DIR_REFUSAL = (rel: string, command: string) => + `${rel} is a symbolic link in the Forge — ${command} does not read a linked recipes, ingredients or profiles directory; replace it with the directory and commit`; const SYMLINK_OUT_OR_DANGLING = (rel: string) => `${rel} is a symbolic link out of the Forge, or to nothing — promote cannot copy the Forge to prove its edit; fix or remove it`; const NON_REGULAR_FILE_REFUSAL = (rel: string) => @@ -312,7 +312,7 @@ export async function copyForgeForProof(forgeDir: string, scratchDir: string): P // A root directory link (ingredients, recipes, profiles) is refused: loadForge follows it // but the scratch would skip it, so the scratch would not load the same as the real Forge. if (rel === "" && ROOT_DIRS.has(ent.name)) { - throw new Error(SYMLINK_DIR_REFUSAL(ent.name)); + throw new Error(SYMLINK_DIR_REFUSAL(ent.name, "promote")); } // Other directory links are skipped entirely — loadForge doesn't traverse them continue; @@ -357,7 +357,7 @@ export async function copyForgeForProof(forgeDir: string, scratchDir: string): P * Refuse a write through a symbolic link: every path, and every ancestor between it and `forgeDir`, that exists must * not be one. A path that does not exist yet is fine — it will be created. */ -export async function assertNoLinkOnPath(forgeDir: string, paths: string[]): Promise { +export async function assertNoLinkOnPath(forgeDir: string, paths: string[], command = "promote"): Promise { const checked = new Set(); for (const abs of paths) { // The path and every ancestor up to but not including forgeDir @@ -371,9 +371,9 @@ export async function assertNoLinkOnPath(forgeDir: string, paths: string[]): Pro const rel = path.relative(forgeDir, current).split(path.sep).join("/"); // The directory-specific message for a root directory (ingredients, recipes, profiles) if (ROOT_DIRS.has(rel)) { - throw new Error(SYMLINK_DIR_REFUSAL(rel)); + throw new Error(SYMLINK_DIR_REFUSAL(rel, command)); } - throw new Error(SYMLINK_REFUSAL(rel)); + throw new Error(SYMLINK_REFUSAL(rel, command)); } } catch (e) { // ENOENT means the path doesn't exist yet (it will be created) — that's fine From fe93647ab919611b4d7045f67c02ae60d46cdedc Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 19:54:44 -0300 Subject: [PATCH 06/26] =?UTF-8?q?feat(init):=20newProfileGate=20=E2=80=94?= =?UTF-8?q?=20P5=E2=80=93P8=20for=20the=20one=20path=20a=20new=20profile?= =?UTF-8?q?=20creates=20(spec=2038)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/new-profile-gate.ts | 107 ++++++++++++++++++++++ test/new-profile-gate.test.ts | 162 ++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 src/core/new-profile-gate.ts create mode 100644 test/new-profile-gate.test.ts diff --git a/src/core/new-profile-gate.ts b/src/core/new-profile-gate.ts new file mode 100644 index 0000000..2224fe3 --- /dev/null +++ b/src/core/new-profile-gate.ts @@ -0,0 +1,107 @@ +/** + * Spec 38 §4.2/§5.2 — the gate of the one path `init --new-profile` creates, as `drift promote` + * holds it for a path it creates. Called by the command after the load and before `planInit`, and + * by the flow (P5/P6 when the profile list is built; P7/P8 when the name is answered). + * + * Reads the disk and runs git ONLY through `gitIsRepo`, `gitHasCommit`, `gitIgnored` and + * `assertNoLinkOnPath` — no other git call, no second copy of those checks. + */ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { assertNoLinkOnPath, gitHasCommit, gitIgnored, gitIsRepo, type Forge } from "./forge.js"; +import { samePath } from "./registry.js"; + +/** + * The gate of the one path `init --new-profile` creates. + * @param forge The loaded Forge. + * @param home The `$CRAFTAR_HOME` directory. + * @param name The profile name, if known (P7 and P8 are skipped without it). + */ +export async function newProfileGate(forge: Forge, home: string, name?: string): Promise { + const root = forge.root; + + // P5: realpath(root) has realpath(path.join(home, "forges")) as an ancestor (or equal), + // segment-wise, compared as samePath compares — without case on win32; when /forges + // does not exist the check passes. + const forgesDir = path.join(home, "forges"); + let forgesReal: string | null = null; + try { + forgesReal = await fs.realpath(forgesDir); + } catch { + // ENOENT or other error — forges/ does not exist, check passes + } + if (forgesReal !== null) { + const rootReal = await fs.realpath(root); + if (isUnderOrEqual(rootReal, forgesReal)) { + throw new Error( + `${root} is a tree of craftar's Forge cache — nothing is written there; clone the Forge and pass that directory`, + ); + } + } + + // P6: not a git repository, or has no commit + const isRepo = await gitIsRepo(root); + const hasCommit = isRepo && (await gitHasCommit(root)); + if (!isRepo || !hasCommit) { + throw new Error( + `cannot create a profile in ${root}: it is not a git repository with a commit — git is how a Forge write is undone`, + ); + } + + // Without name: return here (P5/P6 only for the flow's profile list question) + if (!name) return; + + // P8 links: assertNoLinkOnPath on the DIRECTORY path, not the file's + const profileDir = path.join(root, "profiles", name); + await assertNoLinkOnPath(root, [profileDir], "init"); + + // P7 by name: forge.profiles.has(name) + if (forge.profiles.has(name)) { + throw new Error(`profile ${name} already exists in the Forge — use --profile ${name}`); + } + + // P7 by directory: list profiles/ (ENOENT → nothing there); an entry whose name lower-cased + // equals name lower-cased → message with the spelling found + const profilesDir = path.join(root, "profiles"); + let entries: string[] = []; + try { + entries = await fs.readdir(profilesDir); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + throw e; + } + // ENOENT: no profiles directory yet, nothing there + } + const nameLower = name.toLowerCase(); + for (const entry of entries) { + if (entry.toLowerCase() === nameLower) { + throw new Error( + `profiles/${entry} already exists in the Forge and holds no profile named ${name} — pick another name, or remove it`, + ); + } + } + + // P8 ignored: gitIgnored(root, `profiles/${name}/profile.yaml`) + // gitIgnored fails closed — keep it so + const rel = `profiles/${name}/profile.yaml`; + if (await gitIgnored(root, rel)) { + throw new Error(`cannot create profiles/${name}/profile.yaml: git ignores it`); + } +} + +/** + * Whether `child` is under `parent` (or equal), segment-wise, using samePath for comparison. + */ +function isUnderOrEqual(child: string, parent: string): boolean { + // Get the relative path from parent to child + const rel = path.relative(parent, child); + // If relative path is empty, they are equal + if (rel === "") return true; + // If relative path starts with "..", child is not under parent + if (rel === ".." || rel.startsWith(".." + path.sep)) return false; + // If relative path is absolute, they are on different drives (Windows) + if (path.isAbsolute(rel)) return false; + // Otherwise, child is under parent — but we need to compare with samePath + // The relative path not starting with ".." means it's under parent + return true; +} diff --git a/test/new-profile-gate.test.ts b/test/new-profile-gate.test.ts new file mode 100644 index 0000000..b3f740e --- /dev/null +++ b/test/new-profile-gate.test.ts @@ -0,0 +1,162 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { assertNoLinkOnPath, loadForge } from "../src/core/forge.js"; +import { newProfileGate } from "../src/core/new-profile-gate.js"; +import { makeForge, profile, recipe, rule, tmpDir, type ForgeSpec } from "./helpers/forge.js"; +import { git } from "./helpers/remote.js"; + +// Spec 38 §4.2 P5–P8 and §5.2 — the gate of the one path `init --new-profile` creates. Local git repositories in +// temp dirs; no network. Directory links are junctions on win32 so those cases run on both platforms. + +const WIN = process.platform === "win32"; +const DIR_LINK = WIN ? "junction" : "dir"; +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +async function scratch(prefix = "craftar-np-gate-"): Promise { + const dir = await fs.realpath(await tmpDir(prefix)); + cleanups.push(() => fs.rm(dir, { recursive: true, force: true })); + return dir; +} +const SPEC: ForgeSpec = { + ingredients: [rule("a", "# A\n")], + recipes: [recipe("base", ["rule/a"])], + profiles: [profile("acme", ["base"])], +}; +/** The Forge as the command loaded it — before the obstacle a test puts on the disk (the gate reads the disk itself). */ +const loaded = new Map>>(); +const L = (root: string) => loaded.get(root)!; +/** A Forge directory that is a git repository with one commit (or not, or with none). */ +async function forgeRepo(opts: { git?: "commit" | "init" | "none"; spec?: ForgeSpec } = {}) { + const root = path.join(await scratch(), "forge"); + await makeForge(root, opts.spec ?? SPEC); + const mode = opts.git ?? "commit"; + if (mode !== "none") git(root, "init", "-q", "-b", "main"); + if (mode === "commit") { + git(root, "add", "-A"); + git(root, "commit", "-q", "-m", "init"); + } + loaded.set(root, await loadForge(root)); + return root; +} +const refusal = async (p: Promise): Promise => { + try { + await p; + } catch (e) { + return (e as Error).message; + } + throw new Error("expected a refusal"); +}; +const LINK_FILE = (rel: string) => `${rel} is a symbolic link in the Forge — init does not write through a link; replace it with the file and commit`; +const LINK_DIR = "profiles is a symbolic link in the Forge — init does not read a linked recipes, ingredients or profiles directory; replace it with the directory and commit"; + +describe("assertNoLinkOnPath takes the writing command's name", () => { + it("init's words; the default keeps promote's bytes", async () => { + const root = await forgeRepo(); + const out = await scratch(); + await fs.symlink(out, path.join(root, "profiles", "initech"), DIR_LINK); + const target = path.join(root, "profiles", "initech"); + expect(await refusal(assertNoLinkOnPath(root, [target], "init"))).toBe(LINK_FILE("profiles/initech")); + expect(await refusal(assertNoLinkOnPath(root, [target]))).toBe( + "profiles/initech is a symbolic link in the Forge — promote does not write through a link; replace it with the file and commit", + ); + }); +}); + +describe("newProfileGate — P5 and P6 (no name: the flow's question)", () => { + it("a clean repository with a commit passes, with and without a name", async () => { + const root = await forgeRepo(); + const forge = await loadForge(root); + const home = await scratch(); + await expect(newProfileGate(forge, home)).resolves.toBeUndefined(); + await expect(newProfileGate(forge, home, "initech")).resolves.toBeUndefined(); + }); + it("a dirty Forge passes (the write overwrites nothing, §6 case 5)", async () => { + const root = await forgeRepo(); + await fs.writeFile(path.join(root, "recipes", "base.yaml"), "name: base\ningredients: [rule/a]\n# edited\n"); + await expect(newProfileGate(await loadForge(root), await scratch(), "initech")).resolves.toBeUndefined(); + }); + it("P6: not a git repository, and a repository with no commit", async () => { + for (const mode of ["none", "init"] as const) { + const root = await forgeRepo({ git: mode }); + expect(await refusal(newProfileGate(await loadForge(root), await scratch()))).toBe( + `cannot create a profile in ${root}: it is not a git repository with a commit — git is how a Forge write is undone`, + ); + } + }); + it("P5: a Forge directory under $CRAFTAR_HOME/forges — refused before git is asked", async () => { + const home = await scratch(); + const root = path.join(home, "forges", "example-0123456789ab", "trees", "abc"); + await makeForge(root, SPEC); + expect(await refusal(newProfileGate(await loadForge(root), home, "initech"))).toBe( + `${root} is a tree of craftar's Forge cache — nothing is written there; clone the Forge and pass that directory`, + ); + // A home whose forges/ does not exist yet: the check passes on to P6 + const other = await forgeRepo(); + await expect(newProfileGate(L(other), path.join(await scratch(), "nothing-yet"), "initech")).resolves.toBeUndefined(); + }); +}); + +describe("newProfileGate — P7 and P8 (a name)", () => { + it("P7: a profile of that name exists — by its name, even in another directory", async () => { + const root = await forgeRepo({ spec: { ...SPEC, profiles: [profile("acme", ["base"])] } }); + const forge = await loadForge(root); + expect(await refusal(newProfileGate(forge, await scratch(), "acme"))).toBe("profile acme already exists in the Forge — use --profile acme"); + const r2 = await forgeRepo({ spec: { ...SPEC, profiles: [{ name: "initech", recipes: ["base"] }] } }); + await fs.rename(path.join(r2, "profiles", "initech"), path.join(r2, "profiles", "old")); + expect(await refusal(newProfileGate(L(r2), await scratch(), "initech"))).toBe("profile initech already exists in the Forge — use --profile initech"); + }); + it("P7: profiles/ exists and holds no such profile — an empty directory, a regular file, another case", async () => { + const msg = (found: string) => `profiles/${found} already exists in the Forge and holds no profile named initech — pick another name, or remove it`; + const r1 = await forgeRepo(); + await fs.mkdir(path.join(r1, "profiles", "initech")); + expect(await refusal(newProfileGate(L(r1), await scratch(), "initech"))).toBe(msg("initech")); + const r2 = await forgeRepo(); + await fs.writeFile(path.join(r2, "profiles", "initech"), "a file\n"); + expect(await refusal(newProfileGate(L(r2), await scratch(), "initech"))).toBe(msg("initech")); + const r3 = await forgeRepo(); + await fs.mkdir(path.join(r3, "profiles", "Initech")); + expect(await refusal(newProfileGate(L(r3), await scratch(), "initech"))).toBe(msg("Initech")); + }); + it("P8: git ignores profiles//profile.yaml", async () => { + const root = await forgeRepo(); + await fs.writeFile(path.join(root, ".gitignore"), "profiles/initech/\n"); + expect(await refusal(newProfileGate(L(root), await scratch(), "initech"))).toBe("cannot create profiles/initech/profile.yaml: git ignores it"); + }); + it(`P8: profiles/ is a link — to a directory, dangling (${DIR_LINK}); links come before P7`, async () => { + const r1 = await forgeRepo(); + const out = await scratch(); + await fs.symlink(out, path.join(r1, "profiles", "initech"), DIR_LINK); + expect(await refusal(newProfileGate(L(r1), await scratch(), "initech"))).toBe(LINK_FILE("profiles/initech")); + const r2 = await forgeRepo(); + await fs.symlink(path.join(r2, "gone"), path.join(r2, "profiles", "initech"), DIR_LINK); + expect(await refusal(newProfileGate(L(r2), await scratch(), "initech"))).toBe(LINK_FILE("profiles/initech")); + }); + it.skipIf(WIN)("P8: profiles/ is a link to a file — not run on Windows", async () => { + const root = await forgeRepo(); + const out = await scratch(); + await fs.writeFile(path.join(out, "f"), "x"); + await fs.symlink(path.join(out, "f"), path.join(root, "profiles", "initech"), "file"); + expect(await refusal(newProfileGate(L(root), await scratch(), "initech"))).toBe(LINK_FILE("profiles/initech")); + }); + it(`P8: profiles itself is a link (${DIR_LINK}) — the directory form, also when its target holds `, async () => { + for (const holds of [false, true]) { + const root = await forgeRepo(); + const forge = await loadForge(root); + const real = path.join(await scratch(), "profiles-real"); + await fs.rename(path.join(root, "profiles"), real); + if (holds) await fs.mkdir(path.join(real, "initech")); + await fs.symlink(real, path.join(root, "profiles"), DIR_LINK); + expect(await refusal(newProfileGate(forge, await scratch(), "initech"))).toBe(LINK_DIR); + } + }); + it.skipIf(WIN)("a link named Initech against initech on a case-sensitive disk: P7, the path as found — not run on Windows", async () => { + const root = await forgeRepo(); + await fs.symlink(await scratch(), path.join(root, "profiles", "Initech"), "dir"); + expect(await refusal(newProfileGate(L(root), await scratch(), "initech"))).toBe( + "profiles/Initech already exists in the Forge and holds no profile named initech — pick another name, or remove it", + ); + }); +}); From e8e29f92eaa57f19a2ce65b7add223c4a92bf2aa Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 19:57:09 -0300 Subject: [PATCH 07/26] fix(init): the cache-tree check fails closed on any error but a missing forges/ (spec 38 P5) --- src/core/new-profile-gate.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/new-profile-gate.ts b/src/core/new-profile-gate.ts index 2224fe3..dd71f7c 100644 --- a/src/core/new-profile-gate.ts +++ b/src/core/new-profile-gate.ts @@ -9,7 +9,6 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { assertNoLinkOnPath, gitHasCommit, gitIgnored, gitIsRepo, type Forge } from "./forge.js"; -import { samePath } from "./registry.js"; /** * The gate of the one path `init --new-profile` creates. @@ -27,8 +26,9 @@ export async function newProfileGate(forge: Forge, home: string, name?: string): let forgesReal: string | null = null; try { forgesReal = await fs.realpath(forgesDir); - } catch { - // ENOENT or other error — forges/ does not exist, check passes + } catch (e) { + // Only a forges/ that does not exist passes (spec 38 §4.2 P5); any other error fails closed. + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; } if (forgesReal !== null) { const rootReal = await fs.realpath(root); @@ -90,7 +90,7 @@ export async function newProfileGate(forge: Forge, home: string, name?: string): } /** - * Whether `child` is under `parent` (or equal), segment-wise, using samePath for comparison. + * Whether `child` is under `parent` (or equal), segment-wise — `path.relative` compares without case on win32, as `samePath` does. */ function isUnderOrEqual(child: string, parent: string): boolean { // Get the relative path from parent to child From fee0959d0a452835a09758a5f90f6ecedfd08777 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 20:01:40 -0300 Subject: [PATCH 08/26] =?UTF-8?q?feat(init):=20planInit=20plans=20a=20new?= =?UTF-8?q?=20profile=20in=20memory=20=E2=80=94=20the=20copy=20that=20hold?= =?UTF-8?q?s=20it,=20no=20recipes=20or=20targets=20in=20craftar.yaml=20(sp?= =?UTF-8?q?ec=2038)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/init.ts | 88 +++++++++++++++++++++++++++++++++++ test/init-new-profile.test.ts | 88 +++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 test/init-new-profile.test.ts diff --git a/src/core/init.ts b/src/core/init.ts index 0c9e5fb..0b63572 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -2,6 +2,7 @@ import path from "node:path"; import YAML from "yaml"; import { TARGETS, type Lock, type WorkspaceConfig } from "../schema/index.js"; import { planClone } from "./clone.js"; +import { newProfile, withProfile } from "./new-profile.js"; import { planRecipeEdit } from "./recipe-edit.js"; import { classifyForge, credentialFault } from "./remote.js"; import { type RepoEntry } from "./repos.js"; @@ -44,6 +45,8 @@ export interface InitInput { * A path Forge is loaded with the ref the workspace will have (`loadForgeSource(root, source, ref)`), * so its ignored-ref warning travels in `loaded.warnings`. */ loaded?: LoadedForge; + /** When present, create a new profile in memory (spec 38 §5.2). `profile` holds the new name. */ + newProfile?: { name: string; like?: string }; } export interface InitPlan { @@ -59,6 +62,8 @@ export interface InitPlan { targetsFrom: "flag" | "local" | "profile"; /** The repositories to clone, in profile order; empty when `input.clone` is not true (spec 37 §4.2). */ repos: RepoEntry[]; + /** When `input.newProfile` was given: the file to write (spec 38 §5.2). */ + newProfile?: { path: string; text: string; blockNote: string | null }; } /** The flag each local key would replace (N10). */ @@ -169,6 +174,89 @@ export async function planInit(root: string, input: InitInput, opts: InitOptions }); const forge = forgeSource(root, input.forge); + + // New profile branch (spec 38 §5.2): build the profile in memory, plan on the copy that holds it. + if (input.newProfile) { + // input.loaded is REQUIRED with newProfile — a programming error without it + if (!input.loaded) throw new Error("planInit: newProfile requires a loaded Forge"); + + const base: Record = { forge }; + if (input.ref !== undefined) base.ref = input.ref; + base.profile = input.profile; + // With newProfile, targets go into the profile, not craftar.yaml — but local targets are still merged + if (input.targets !== undefined) base.targets = input.targets; + + const merged = mergeWorkspaceConfig(base, localDoc); + + // Refuse a `loaded` that is not the configuration's Forge — a programming error + if (input.loaded.origin.source !== merged.config.forge) { + throw new Error(`planInit: the loaded Forge is "${input.loaded.origin.source}", not "${merged.config.forge}"`); + } + // Remote only: ref must match + if (input.loaded.origin.kind === "remote") { + const expectedRef = merged.config.ref ?? null; + if (input.loaded.origin.ref !== expectedRef) { + throw new Error(`planInit: the loaded Forge is at ref ${JSON.stringify(input.loaded.origin.ref)}, not ${JSON.stringify(expectedRef)}`); + } + } + + // Build the new profile: the recipe flags go into the profile (spec 38 §4.4) + const np = newProfile(input.loaded.forge, { + name: input.newProfile.name, + like: input.newProfile.like, + add: adds, + remove: removes, + replace: input.replace, + targets: input.targets, + }); + + // Create a copy of the Forge with the new profile added — the input Forge is not mutated + const copy = withProfile(input.loaded.forge, np.profile); + const loadedCopy: LoadedForge = { ...input.loaded, forge: copy }; + const ws = workspaceOf(root, merged, loadedCopy); + + const p = await plan(ws); + + // P12: a new profile whose recipes do not resolve is refused (spec 38 §4.2). + // Missing ingredient references are warnings for existing profiles but failures for new ones. + const missingIngredient = p.resolution.warnings.find((w) => w.includes("missing ingredient")); + if (missingIngredient) throw new Error(missingIngredient); + + const lock = await readLock(root); + const statuses = await status(ws, p, lock); + + // craftar.yaml text: only forge, ref, profile — no recipes, no targets (spec 38 §5.2) + const doc: Record = { forge }; + if (input.ref !== undefined) doc.ref = input.ref; + doc.profile = input.profile; + + // Clone planning (spec 37 §4.2) + let repos: RepoEntry[] = []; + if (input.clone === true) { + const profile = ws.forge.profiles.get(ws.config.profile)!; + const ctx = { planned: p.files.map((f) => f.path), lockPaths: lock?.files.map((f) => f.path) ?? [], forgeDir: ws.forge.root }; + const clonePlan = await planClone(root, profile, ctx, { present: "refuse", noEntries: "nothing to clone; drop --clone" }); + repos = clonePlan.entries; + } + + // notes = np.notes then np.blockNote when non-null (spec 38 §5.2) + const notes = [...np.notes]; + if (np.blockNote !== null) notes.push(np.blockNote); + + return { + ws, + plan: p, + lock, + statuses, + text: YAML.stringify(doc), + notes, + targetsFrom: "profile", + repos, + newProfile: { path: `profiles/${np.profile.name}/profile.yaml`, text: np.text, blockNote: np.blockNote }, + }; + } + + // Existing profile branch: unchanged const base: Record = { forge }; if (input.ref !== undefined) base.ref = input.ref; base.profile = input.profile; diff --git a/test/init-new-profile.test.ts b/test/init-new-profile.test.ts new file mode 100644 index 0000000..61e8b86 --- /dev/null +++ b/test/init-new-profile.test.ts @@ -0,0 +1,88 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { forgeSource, planInit } from "../src/core/init.js"; +import { loadForgeSource } from "../src/core/sync.js"; +import { makeForge, profile, recipe, rule, tmpDir, type ForgeSpec } from "./helpers/forge.js"; + +// Spec 38 §5.2 — planInit with `newProfile`: always handed the loaded Forge, plans the workspace on the copy that +// holds the new profile, and writes nothing. The command's writes (§5.3) are tested at the CLI. + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +const SPEC: ForgeSpec = { + ingredients: [rule("a", "# A\n"), rule("b", "# B\n"), rule("c", "# C\n")], + recipes: [recipe("base", ["rule/a"]), recipe("stack-node", ["rule/b"]), recipe("extra", ["rule/c"]), recipe("broken", ["rule/missing"])], + profiles: [profile("acme", ["base", "stack-node"], ["claude-code", "kiro"], { params: { org: "zqmark" } })], +}; +async function setup(spec: ForgeSpec = SPEC) { + const parent = await fs.realpath(await tmpDir("craftar-np-plan-")); + cleanups.push(() => fs.rm(parent, { recursive: true, force: true })); + const forge = path.join(parent, "forge"); + await makeForge(forge, spec); + const ws = path.join(parent, "ws"); + await fs.mkdir(ws); + const loaded = await loadForgeSource(ws, forgeSource(ws, forge), null, { mode: "sync" }); + return { parent, forge, ws, loaded }; +} +const files = (p: { files: Array<{ path: string; content: Buffer | string }> }) => + Object.fromEntries(p.files.map((f) => [f.path, Buffer.from(f.content as Buffer).toString("base64")])); + +describe("planInit with newProfile", () => { + it("plans what the same profile written by hand plans; craftar.yaml holds no recipes and no targets; the loaded Forge is not mutated", async () => { + const s = await setup(); + const init = await planInit(s.ws, { + forge: s.forge, + profile: "initech", + newProfile: { name: "initech", like: "acme" }, + addRecipes: ["extra"], + targets: ["kiro"], + loaded: s.loaded, + }); + expect(init.newProfile).toEqual({ + path: "profiles/initech/profile.yaml", + text: "name: initech\nrecipes:\n - base\n - stack-node\n - extra\ntargets:\n - kiro\n", + blockNote: null, + }); + expect(init.text).toBe("forge: ../forge\nprofile: initech\n"); + expect(s.loaded.forge.profiles.has("initech")).toBe(false); + expect(init.repos).toEqual([]); + + const t = await setup(); + await fs.mkdir(path.join(t.forge, "profiles", "initech")); + await fs.writeFile(path.join(t.forge, "profiles", "initech", "profile.yaml"), init.newProfile!.text); + const byHand = await planInit(t.ws, { forge: t.forge, profile: "initech" }); + expect(files(init.plan)).toEqual(files(byHand.plan)); + expect(Object.keys(files(init.plan)).sort()).toEqual([".kiro/steering/a.md", ".kiro/steering/b.md", ".kiro/steering/c.md"]); + }); + it("the new profile carries none of the template's params", async () => { + const s = await setup(); + const init = await planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech", like: "acme" }, loaded: s.loaded }); + expect(init.newProfile!.text.includes("zqmark")).toBe(false); + expect(init.ws.forge.profiles.get("initech")!.params).toEqual({}); + }); + it("spec 22's notes reach InitPlan.notes", async () => { + const s = await setup(); + const init = await planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech", like: "acme" }, addRecipes: ["base"], loaded: s.loaded }); + expect(init.notes.length).toBe(1); + expect(init.notes[0]).toMatch(/base/); + }); + it("P12 / P11: a new profile whose recipes do not resolve is refused — nothing written", async () => { + const s = await setup(); + await expect(planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech" }, addRecipes: ["broken"], loaded: s.loaded })).rejects.toThrow(/missing/); + expect(await fs.readdir(path.join(s.forge, "profiles"))).toEqual(["acme"]); + expect(await fs.readdir(s.ws)).toEqual([]); + }); + it("N10 unchanged: a local targets with no --targets is merged into the workspace; with --targets it is refused", async () => { + const s = await setup(); + await fs.writeFile(path.join(s.ws, "craftar.local.yaml"), "targets: [claude-code]\n"); + const init = await planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech", like: "acme" }, loaded: s.loaded }); + expect(Object.keys(files(init.plan)).every((p) => p.startsWith(".claude/"))).toBe(true); + expect(init.newProfile!.text).toContain("targets:\n - claude-code\n - kiro\n"); + await expect( + planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech" }, targets: ["kiro"], loaded: s.loaded }), + ).rejects.toThrow(/craftar\.local\.yaml/); + }); +}); From c1c934dd83e96369cf140c8ed590e7195467c29a Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 20:02:45 -0300 Subject: [PATCH 09/26] =?UTF-8?q?fix(init):=20P12=20is=20the=20resolver's?= =?UTF-8?q?=20own=20refusal=20=E2=80=94=20no=20warning=20turned=20into=20o?= =?UTF-8?q?ne;=20--targets=20stays=20out=20of=20the=20workspace=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A missing ingredient is a warning for every profile; the test now uses a recipe extending one the Forge does not have, which the resolver refuses. --targets shapes the new profile only (spec 38 §4.1). --- src/core/init.ts | 8 ++------ test/init-new-profile.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index 0b63572..6029326 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -183,8 +183,7 @@ export async function planInit(root: string, input: InitInput, opts: InitOptions const base: Record = { forge }; if (input.ref !== undefined) base.ref = input.ref; base.profile = input.profile; - // With newProfile, targets go into the profile, not craftar.yaml — but local targets are still merged - if (input.targets !== undefined) base.targets = input.targets; + // With newProfile, --targets goes into the profile (spec 38 §4.1): neither craftar.yaml nor the workspace layer holds it const merged = mergeWorkspaceConfig(base, localDoc); @@ -217,10 +216,7 @@ export async function planInit(root: string, input: InitInput, opts: InitOptions const p = await plan(ws); - // P12: a new profile whose recipes do not resolve is refused (spec 38 §4.2). - // Missing ingredient references are warnings for existing profiles but failures for new ones. - const missingIngredient = p.resolution.warnings.find((w) => w.includes("missing ingredient")); - if (missingIngredient) throw new Error(missingIngredient); + // P12 is the resolver's and the planner's own refusal: newProfile's recipe edit resolves, and plan() above throws (spec 38 §4.2) const lock = await readLock(root); const statuses = await status(ws, p, lock); diff --git a/test/init-new-profile.test.ts b/test/init-new-profile.test.ts index 61e8b86..985b2eb 100644 --- a/test/init-new-profile.test.ts +++ b/test/init-new-profile.test.ts @@ -14,7 +14,7 @@ afterEach(async () => { }); const SPEC: ForgeSpec = { ingredients: [rule("a", "# A\n"), rule("b", "# B\n"), rule("c", "# C\n")], - recipes: [recipe("base", ["rule/a"]), recipe("stack-node", ["rule/b"]), recipe("extra", ["rule/c"]), recipe("broken", ["rule/missing"])], + recipes: [recipe("base", ["rule/a"]), recipe("stack-node", ["rule/b"]), recipe("extra", ["rule/c"]), recipe("broken", ["rule/a"], { extends: ["nope"] })], profiles: [profile("acme", ["base", "stack-node"], ["claude-code", "kiro"], { params: { org: "zqmark" } })], }; async function setup(spec: ForgeSpec = SPEC) { @@ -71,7 +71,7 @@ describe("planInit with newProfile", () => { }); it("P12 / P11: a new profile whose recipes do not resolve is refused — nothing written", async () => { const s = await setup(); - await expect(planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech" }, addRecipes: ["broken"], loaded: s.loaded })).rejects.toThrow(/missing/); + await expect(planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech" }, addRecipes: ["broken"], loaded: s.loaded })).rejects.toThrow(/^recipe "nope" not found/); expect(await fs.readdir(path.join(s.forge, "profiles"))).toEqual(["acme"]); expect(await fs.readdir(s.ws)).toEqual([]); }); From a1e241071b9e0f96733b90ff278a94419e668979 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 20:16:07 -0300 Subject: [PATCH 10/26] =?UTF-8?q?feat(init):=20the=20interactive=20init=20?= =?UTF-8?q?offers=20a=20new=20profile=20=E2=80=94=20the=200)=20entry,=20it?= =?UTF-8?q?s=20questions,=20an=20empty=20Forge=20directory,=20the=20confir?= =?UTF-8?q?mation=20(spec=2038)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/init-flow.ts | 355 ++++++++++++++++++++++++++++- test/init-flow.test.ts | 40 +++- test/init-new-profile-flow.test.ts | 178 +++++++++++++++ 3 files changed, 560 insertions(+), 13 deletions(-) create mode 100644 test/init-new-profile-flow.test.ts diff --git a/src/core/init-flow.ts b/src/core/init-flow.ts index b5bbfa4..fc4dd6d 100644 --- a/src/core/init-flow.ts +++ b/src/core/init-flow.ts @@ -4,6 +4,8 @@ import { planClone } from "./clone.js"; import { countStates } from "./impact.js"; import { resolveHome } from "./home-lock.js"; import { checkInitFlags, checkLocalKeys, forgeSource, initLine, type InitInput, type InitPlan } from "./init.js"; +import { newProfile, ownedBy, profileName, withProfile } from "./new-profile.js"; +import { newProfileGate } from "./new-profile-gate.js"; import { planRecipeEdit, slotHeld } from "./recipe-edit.js"; import { readRegistry } from "./registry.js"; import { classifyForge, credentialFault, CREDENTIAL_HIDDEN, refHoldsCredentials } from "./remote.js"; @@ -171,7 +173,17 @@ export async function confirmInit( io: InitIo, opts: { sync: boolean; clone?: number; cloneRefused?: boolean }, ): Promise<"confirmed" | "cancelled"> { - io.say(`craftar init — about to write craftar.yaml in ${root}`); + // Spec 38 §4.3: blockNote first when there is one + if (init.newProfile?.blockNote) { + io.say(` note ${init.newProfile.blockNote}`); + } + + // Spec 38 §4.3: the first line changes with newProfile + if (init.newProfile) { + io.say(`craftar init — about to create profile ${init.ws.config.profile} in ${init.ws.forge.root} and write craftar.yaml in ${root}`); + } else { + io.say(`craftar init — about to write craftar.yaml in ${root}`); + } io.say(` ${initLine(init)}`); const unset = unsetDeclared(init.plan); @@ -190,10 +202,19 @@ export async function confirmInit( io.say(NOT_CLONED_REFUSED); } - // Build the question + // Build the question: spec 38 §4.3 — with newProfile, the question names both writes let question: string; const clone = opts.clone ?? 0; - if (unset.length > 0) { + const profileName = init.newProfile ? init.ws.config.profile : null; + + if (init.newProfile) { + // New profile: different question forms + if (unset.length > 0 || !opts.sync) { + question = `Create profiles/${profileName}/profile.yaml (not committed) and write craftar.yaml [yes]: `; + } else { + question = `Create profiles/${profileName}/profile.yaml (not committed), write craftar.yaml and sync [yes]: `; + } + } else if (unset.length > 0) { // Sync refused → offer only craftar.yaml question = "Write craftar.yaml [yes]: "; } else if (clone > 0) { @@ -274,6 +295,10 @@ export interface InitGiven { removeRecipes: string[]; replace: boolean; clone?: boolean; + /** The new profile's name (spec 38 §4.3). */ + newProfile?: string; + /** The template profile (spec 38 §4.3). */ + like?: string; } export type AskResult = @@ -447,15 +472,15 @@ export async function askInit( const forge = loaded!.forge; - if (forge.profiles.size === 0 && given.profile === undefined) { - throw new Error("the Forge has no profile — add one under profiles/, then re-run init"); - } - if (given.profile !== undefined) { - if (!forge.profiles.has(given.profile)) { - throw new Error(profileNotFoundMessage(given.profile, [...forge.profiles.keys()])); - } - answeredProfile = given.profile; - } else { + // Track whether we're in the new-profile flow + let isNewProfile = false; + let newProfileName: string | undefined; + let newProfileLike: string | undefined; + let profileListShown = false; + + // Helper to show the profile list + const showProfileList = () => { + if (profileListShown) return; const profiles = [...forge.profiles.values()].sort((a, b) => a.name.localeCompare(b.name)); io.say(`Profiles in ${answeredForge}:`); const maxLen = Math.max(...profiles.map((p) => p.name.length)); @@ -468,6 +493,70 @@ export async function askInit( io.say(` ${i + 1}) ${p.name}`); } } + profileListShown = true; + }; + + // Check if the new profile entry should be shown: Forge is a directory that passes P5/P6 + let canOfferNewProfile = false; + let newProfileGateError: string | null = null; + if (classifyForge(answeredForge!) !== "url") { + try { + await newProfileGate(forge, home); + canOfferNewProfile = true; + } catch (e) { + newProfileGateError = (e as Error).message; + } + } + + // Handle given.newProfile: validate with P3 and P7/P8 (typed value is not re-asked) + if (given.newProfile !== undefined) { + profileName(given.newProfile); // P3: throws on failure + await newProfileGate(forge, home, given.newProfile); // P7/P8: throws on failure + isNewProfile = true; + newProfileName = given.newProfile; + newProfileLike = given.like; + answeredProfile = given.newProfile; + } else if (given.profile !== undefined) { + if (!forge.profiles.has(given.profile)) { + throw new Error(profileNotFoundMessage(given.profile, [...forge.profiles.keys()])); + } + answeredProfile = given.profile; + } else if (forge.profiles.size === 0) { + // Spec 38 §4.3: A Forge directory with no profile goes straight to the new-profile questions + // (A URL Forge with no profile keeps today's error) + if (classifyForge(answeredForge!) === "url") { + throw new Error("the Forge has no profile — add one under profiles/, then re-run init"); + } + // For directory Forge with no profile: check if it can take a new profile + if (!canOfferNewProfile) { + // The Forge was asked → say the error and ask for the Forge again + // --forge was typed → throw + if (forgeWasAsked) { + io.say(newProfileGateError!); + // Can't ask forge again from here - need to restructure or throw + throw new Error(newProfileGateError!); + } else { + throw new Error(newProfileGateError!); + } + } + io.say("the Forge has no profile yet — creating the first one"); + isNewProfile = true; + } else { + // Show the profile list with the new profile entry if applicable + const profiles = [...forge.profiles.values()].sort((a, b) => a.name.localeCompare(b.name)); + showProfileList(); + + // Show the 0) a new profile entry ONLY when the Forge can take a new profile + // Per spec 38 §4.3: show lines explaining why the entry isn't available + if (canOfferNewProfile) { + io.say(" 0) a new profile"); + } else if (classifyForge(answeredForge!) === "url") { + io.say("a new profile needs a Forge directory"); + } else if (newProfileGateError) { + // Forge directory that fails P5/P6: show the error as the line under the list + io.say(newProfileGateError); + } + const defProfile = profiles.length === 1 ? profiles[0].name : null; const profileQ = defProfile !== null ? `Profile [${defProfile}]: ` : "Profile: "; profileLoop: for (;;) { @@ -482,15 +571,257 @@ export async function askInit( answeredProfile = defProfile; break profileLoop; } + // pickItem: names first, then (only when the entry is shown) "0" for new profile, then numbers const picked = pickItem(profiles.map((p) => p.name), trimmed); if (picked !== null) { answeredProfile = picked; break profileLoop; } + // Check for "0" to start new-profile questions (only when the entry is shown) + if (canOfferNewProfile && trimmed === "0") { + isNewProfile = true; + break profileLoop; + } io.say(profileNotFoundMessage(trimmed, [...forge.profiles.keys()])); } } + // New-profile questions (§4.3) + if (isNewProfile) { + // Name question (skipped when given.newProfile was typed) + if (newProfileName === undefined) { + nameLoop: for (;;) { + const answer = await io.ask("New profile's name: "); + if (answer === null) return { kind: "cancelled" }; + const trimmed = answer.trim(); + if (trimmed === "") { + io.say('a name is needed — lowercase letters and digits, in groups joined by one "-"'); + continue nameLoop; + } + try { + profileName(trimmed); // P3 + await newProfileGate(forge, home, trimmed); // P7, P8 + newProfileName = trimmed; + break nameLoop; + } catch (e) { + io.say((e as Error).message); + continue nameLoop; + } + } + } + answeredProfile = newProfileName; + + // Template question: "Start from an existing profile [none]:" — only when the Forge has profiles + // and when given.like was not provided + if (newProfileLike === undefined && forge.profiles.size > 0) { + // Print the profile list if it wasn't printed yet (e.g. --new-profile was typed, --forge asked) + if (!profileListShown) { + showProfileList(); + } + + templateLoop: for (;;) { + const answer = await io.ask("Start from an existing profile [none]: "); + if (answer === null) return { kind: "cancelled" }; + const trimmed = answer.trim(); + if (trimmed === "" || trimmed === "0") { + // Enter or 0 → none + break templateLoop; + } + const profiles = [...forge.profiles.values()].sort((a, b) => a.name.localeCompare(b.name)); + const picked = pickItem(profiles.map((p) => p.name), trimmed); + if (picked !== null) { + newProfileLike = picked; + break templateLoop; + } + // Unknown → say "--like: " + profileNotFoundMessage + io.say("--like: " + profileNotFoundMessage(trimmed, [...forge.profiles.keys()])); + continue templateLoop; + } + } + + // Recipes question for new profile + // With a template: show "Recipes of (from ): " then Adjust flow + // Without a template: show recipes list and ask "Add [none]:" directly + let finalAddRecipes: string[] = []; + let finalRemoveRecipes: string[] = []; + let finalReplace = false; + + // Track whether we should start with Remove (after a P10 error) + let startWithRemove = false; + + recipeLoop: for (;;) { + // Compute the draft profile in a copy of the Forge for the recipes line + const template = newProfileLike ? forge.profiles.get(newProfileLike) : undefined; + const draftRecipes = template?.recipes ?? []; + + if (template && !startWithRemove) { + // With a template: show the chain resolved from the draft profile + const draft = { name: newProfileName!, recipes: draftRecipes, targets: template.targets }; + const copy = withProfile(forge, draft as any); + // Use mergeWorkspaceConfig to get a valid config with overrides + const config = mergeWorkspaceConfig( + { forge: forgeSource(root, answeredForge!), profile: newProfileName! }, + null + ).config; + const resolution = resolve(copy, config); + io.say(`Recipes of ${newProfileName} (from ${newProfileLike}): ${resolution.recipes.join(" → ")}`); + + // Adjust the recipes [no]: + const adjustAns = await io.ask("Adjust the recipes [no]: "); + if (adjustAns === null) return { kind: "cancelled" }; + const yn = yesNo(adjustAns); + if (adjustAns.trim() !== "" && yn === null) { + io.say("answer yes or no"); + continue recipeLoop; + } + if (yn !== true) { + // No adjustment: check P10 on the template's recipes before accepting + try { + newProfile(forge, { + name: newProfileName!, + like: newProfileLike, + add: [], + remove: [], + }); + // Success: use template's recipes as-is + break recipeLoop; + } catch (e) { + const msg = (e as Error).message; + // P10: say it and re-run recipes step opening on Remove + if (msg.includes("is named for profile")) { + io.say(msg); + startWithRemove = true; + continue recipeLoop; + } + // Other errors + io.say(msg); + continue recipeLoop; + } + } + } + + // Show the Forge's recipes list + io.say(`Recipes in ${answeredForge}:`); + const allRecipes = [...forge.recipes.values()].sort((a, b) => a.name.localeCompare(b.name)); + for (let i = 0; i < allRecipes.length; i++) { + const r = allRecipes[i]; + const parts = [` ${i + 1}) ${r.name}`]; + if (draftRecipes.includes(r.name)) parts.push("· in use"); + if (r.slot) parts.push(`· slot ${r.slot}`); + if (r.description) parts.push(`· ${r.description}`); + io.say(parts.join(" ")); + } + + const names = allRecipes.map((r) => r.name); + const parseRecipeAnswer = (answer: string): string[] => + answer.split(",").map((s) => s.trim()).filter((s) => s !== "").map((s) => pickItem(names, s) ?? s); + + // Remove question (only with a template, or after P10 error) + let removeNames: string[] = []; + if (template || startWithRemove) { + const removeAns = await io.ask("Remove [none]: "); + if (removeAns === null) return { kind: "cancelled" }; + removeNames = parseRecipeAnswer(removeAns); + } + + // Add question + const addAns = await io.ask("Add [none]: "); + if (addAns === null) return { kind: "cancelled" }; + const addNames = parseRecipeAnswer(addAns); + + // Reset startWithRemove for the next iteration + startWithRemove = false; + + if (removeNames.length === 0 && addNames.length === 0) { + // If no changes, check P10 one more time (for no template case) + if (!template) { + break recipeLoop; + } + // With template and no changes: P10 was already checked above when we declined to adjust + break recipeLoop; + } + + // Check for P10: compute newProfile and catch owned recipe errors + try { + newProfile(forge, { + name: newProfileName!, + like: newProfileLike, + add: addNames, + remove: removeNames, + replace: finalReplace, + }); + // Success: save the answers + finalAddRecipes = addNames; + finalRemoveRecipes = removeNames; + break recipeLoop; + } catch (e) { + const msg = (e as Error).message; + // P10: "recipe ... is named for profile ..." → say it and re-run recipes step opening on Remove + if (msg.includes("is named for profile")) { + io.say(msg); + startWithRemove = true; + continue recipeLoop; + } + // Other errors: say and re-run + io.say(msg); + continue recipeLoop; + } + } + + answeredAddRecipes = finalAddRecipes; + answeredRemoveRecipes = finalRemoveRecipes; + answeredReplace = finalReplace; + + // Targets question for new profile + // Default: [, from ] or [claude-code, default] + const template = newProfileLike ? forge.profiles.get(newProfileLike) : undefined; + const defaultTargets = template?.targets ?? ["claude-code"]; + const fromText = template ? `from ${newProfileLike}` : "default"; + const targetsQ = `Targets — ${TARGETS.join(", ")}, separated by commas [${defaultTargets.join(", ")}, ${fromText}]: `; + targetsLoop: for (;;) { + const answer = await io.ask(targetsQ); + if (answer === null) return { kind: "cancelled" }; + const trimmed = answer.trim(); + if (trimmed === "") break targetsLoop; + const parsed = trimmed.split(",").map((s) => s.trim()); + try { + checkInitFlags({ targets: parsed }); + } catch (e) { + io.say((e as Error).message); + continue targetsLoop; + } + answeredTargets = parsed; + break targetsLoop; + } + + // Build the result for new profile + const input: InitInput = { + forge: answeredForge!, + profile: newProfileName!, + loaded: loaded, + newProfile: newProfileLike ? { name: newProfileName!, like: newProfileLike } : { name: newProfileName! }, + }; + if (answeredRef !== undefined) input.ref = answeredRef; + if (answeredTargets !== undefined) input.targets = answeredTargets; + if (answeredAddRecipes.length > 0) input.addRecipes = answeredAddRecipes; + if (answeredRemoveRecipes.length > 0) input.removeRecipes = answeredRemoveRecipes; + if (answeredReplace) input.replace = true; + if (given.clone === true) input.clone = true; + + // For new profile: answers has --profile name (for the again line), no recipe/target flags + const answers: InitAnswers = { + forge: answeredForge!, + profile: newProfileName!, + addRecipes: [], + removeRecipes: [], + replace: false, + }; + if (answeredRef !== undefined) answers.ref = answeredRef; + + return { kind: "answered", input, answers }; + } + + // Existing profile flow (unchanged) const profile = forge.profiles.get(answeredProfile!)!; const recipesFlagged = given.addRecipes.length > 0 || given.removeRecipes.length > 0; diff --git a/test/init-flow.test.ts b/test/init-flow.test.ts index 9cf34dd..1cf7c9d 100644 --- a/test/init-flow.test.ts +++ b/test/init-flow.test.ts @@ -454,10 +454,12 @@ describe("askInit", () => { { home: s.home, registryOff: false, exists: existsSync } ); expect(sc.asked).toEqual([QF, QF, QP1, QA, QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc.said).toEqual([ "a Forge is needed — a directory or a git URL", `Profiles in ${s.forge}:`, " 1) acme", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of acme: base → stack-api → front-a", ]); expect(result.kind).toBe("answered"); @@ -638,9 +640,11 @@ describe("askInit", () => { QA, QT, ]); + // Spec 38 §4.3: a URL Forge shows "a new profile needs a Forge directory" under the profile list expect(sc.said).toEqual([ `Profiles in ${r.url}:`, " 1) acme", + "a new profile needs a Forge directory", "Recipes of acme: base → stack-api → front-a", ]); expect(sc.left()).toBe(0); @@ -676,9 +680,11 @@ describe("askInit", () => { QA, QT, ]); + // Spec 38 §4.3: a URL Forge shows "a new profile needs a Forge directory" under the profile list expect(sc2.said).toEqual([ `Profiles in ${r.url}:`, " 1) acme", + "a new profile needs a Forge directory", "Recipes of acme: base → stack-api → front-a", ]); expect(sc2.left()).toBe(0); @@ -721,10 +727,12 @@ describe("askInit", () => { const sc = script([s.forge, "globex", "", ""]); await askInit(s.ws, given, local, sc.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc.asked).toEqual([QF, QP, QA, QTk]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", " 2) globex Globex — services", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of globex: base", ]); expect(sc.left()).toBe(0); @@ -733,10 +741,12 @@ describe("askInit", () => { const sc2 = script([s.forge, "2", "", ""]); const result2 = await askInit(s.ws, given, local, sc2.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc2.asked).toEqual([QF, QP, QA, QTk]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc2.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", " 2) globex Globex — services", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of globex: base", ]); expect(sc2.left()).toBe(0); @@ -748,10 +758,12 @@ describe("askInit", () => { const sc3 = script([s.forge, "globex", "", ""]); const result3 = await askInit(s.ws, given, local, sc3.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc3.asked).toEqual([QF, QP, QA, QTk]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc3.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", " 2) globex Globex — services", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of globex: base", ]); expect(sc3.left()).toBe(0); @@ -763,10 +775,12 @@ describe("askInit", () => { const sc4 = script([s.forge, "", "nope", "acme", "", ""]); const result4 = await askInit(s.ws, given, local, sc4.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc4.asked).toEqual([QF, QP, QP, QP, QA, QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc4.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", " 2) globex Globex — services", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "a profile is needed — a name or a number from the list", 'profile "nope" not found in Forge (acme, globex)', "Recipes of acme: base → stack-api → front-a", @@ -780,10 +794,12 @@ describe("askInit", () => { const sc5 = script([s.forge, "globex", "", ""]); const result5 = await askInit(s.ws, given, local, sc5.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc5.asked).toEqual([QF, QP, QA, QTk]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc5.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", " 2) globex Globex — services", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of globex: base", ]); expect(sc5.left()).toBe(0); @@ -805,10 +821,12 @@ describe("askInit", () => { const sc7 = script([s2.forge, "2", "", ""]); const result7 = await askInit(s2.ws, given, local, sc7.io, { home: s2.home, registryOff: false, exists: existsSync }); expect(sc7.asked).toEqual([QF, QP, QA, QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc7.said).toEqual([ `Profiles in ${s2.forge}:`, " 1) 2", " 2) acme", + `cannot create a profile in ${s2.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of 2: base → stack-api → front-a", ]); expect(sc7.left()).toBe(0); @@ -820,10 +838,12 @@ describe("askInit", () => { const sc8 = script([s2.forge, "1", "", ""]); const result8 = await askInit(s2.ws, given, local, sc8.io, { home: s2.home, registryOff: false, exists: existsSync }); expect(sc8.asked).toEqual([QF, QP, QA, QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc8.said).toEqual([ `Profiles in ${s2.forge}:`, " 1) 2", " 2) acme", + `cannot create a profile in ${s2.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of 2: base → stack-api → front-a", ]); expect(sc8.left()).toBe(0); @@ -842,9 +862,13 @@ describe("askInit", () => { const given: InitGiven = { addRecipes: [], removeRecipes: [], replace: false }; const local = { doc: null, keys: [] as LocalKey[] }; const sc = script([s.forge]); + // Spec 38 §4.3, §8: a Forge directory with no profile that is NOT a git repository + // now throws P6's text instead of the old "the Forge has no profile" message. + // This is because a Forge directory with no profile goes to the new-profile flow, + // which requires the Forge to be a git repository (P6). await expect( askInit(s.ws, given, local, sc.io, { home: s.home, registryOff: false, exists: existsSync }) - ).rejects.toThrow("the Forge has no profile — add one under profiles/, then re-run init"); + ).rejects.toThrow(`cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`); expect(sc.asked).toEqual([QF]); // given.profile = "nope" - use a Forge WITH a profile (SPEC_DESC) to get proper error @@ -1030,9 +1054,11 @@ describe("askInit", () => { const sc = script([s.forge, "", "yes", "", "base", ""]); await askInit(s.ws, given, local, sc.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc.asked).toEqual([QF, QP1, QA, "Remove [none]: ", "Add [none]: ", QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of acme: base → stack-api → front-a", `Recipes in ${s.forge}:`, " 1) base · in use", @@ -1159,9 +1185,11 @@ describe("askInit", () => { const sc = script([s.forge, "", ""]); await askInit(s.ws, given, { doc: { targets: ["kiro"] }, keys: ["targets"] }, sc.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc.asked).toEqual([QF, QP1, QA]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of acme: base → stack-api → front-a", "Targets: kiro (from craftar.local.yaml)", ]); @@ -1171,9 +1199,11 @@ describe("askInit", () => { const sc2 = script([s.forge, "", ""]); await askInit(s.ws, given, { doc: { recipes: { add: ["extra"] } }, keys: ["recipes"] }, sc2.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc2.asked).toEqual([QF, QP1, QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc2.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes: base → stack-api → front-a → extra (from craftar.local.yaml)", ]); expect(sc2.left()).toBe(0); @@ -1183,10 +1213,12 @@ describe("askInit", () => { const sc3 = script([r.url, "", "", ""]); const result3 = await askInit(s.ws, given, { doc: { ref: "main" }, keys: ["ref"] }, sc3.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc3.asked).toEqual([QF, QP1, QA, QT]); + // Spec 38 §4.3: a URL Forge shows "a new profile needs a Forge directory" under the profile list expect(sc3.said).toEqual([ "Ref: main (from craftar.local.yaml)", `Profiles in ${r.url}:`, " 1) acme", + "a new profile needs a Forge directory", "Recipes of acme: base → stack-api → front-a", ]); expect(sc3.left()).toBe(0); @@ -1201,9 +1233,11 @@ describe("askInit", () => { const sc4 = script([s.forge, "", "", ""]); await askInit(s.ws, given, { doc: { ref: "v1" }, keys: ["ref"] }, sc4.io, { home: s.home, registryOff: false, exists: existsSync }); expect(sc4.asked).toEqual([QF, QP1, QA, QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc4.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of acme: base → stack-api → front-a", ]); expect(sc4.left()).toBe(0); @@ -1500,10 +1534,12 @@ describe("askInit — answers that are not an item (review round 1)", () => { const sc = script([s.forge, wrong, "acme", "", ""]); const result = await askInit(s.ws, given(), local, sc.io, opts(s.home)); expect(sc.asked).toEqual([QF, QP, QP, QA, QT]); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", " 2) globex Globex — services", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, `profile "${wrong.trim()}" not found in Forge (acme, globex)`, "Recipes of acme: base → stack-api → front-a", ]); @@ -1531,9 +1567,11 @@ describe("askInit — answers that are not an item (review round 1)", () => { // The list: 1) base · in use, 2) api · in use, 3) 2 — so "2" as a position would be api, already in use. const sc = script([s.forge, "", "yes", "", "2", ""]); const result = await askInit(s.ws, given(), local, sc.io, opts(s.home)); + // Spec 38 §4.3: a Forge directory that is not a git repo shows P6's text under the profile list expect(sc.said).toEqual([ `Profiles in ${s.forge}:`, " 1) acme", + `cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`, "Recipes of acme: base → api", `Recipes in ${s.forge}:`, " 1) base · in use", diff --git a/test/init-new-profile-flow.test.ts b/test/init-new-profile-flow.test.ts new file mode 100644 index 0000000..dd6acbb --- /dev/null +++ b/test/init-new-profile-flow.test.ts @@ -0,0 +1,178 @@ +import { existsSync, promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { againLine, askInit, confirmInit, type InitIo } from "../src/core/init-flow.js"; +import { planInit } from "../src/core/init.js"; +import { makeForge, profile, recipe, rule, tmpDir, type ForgeSpec } from "./helpers/forge.js"; +import { git, remoteForge } from "./helpers/remote.js"; + +// Spec 38 §4.3 — the interactive flow, through InitIo with scripted answers (spec 28 §9.2: the suite cannot drive a +// real terminal). Forge directories are git repositories with a commit unless a test says otherwise. + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +const SPEC: ForgeSpec = { + ingredients: [rule("a", "# A\n"), rule("b", "# B\n"), rule("c", "# C\n")], + recipes: [recipe("base", ["rule/a"]), recipe("stack-node", ["rule/b"]), recipe("extra", ["rule/c"]), recipe("base--acme", ["rule/a"])], + profiles: [profile("acme", ["base", "stack-node"], ["claude-code", "kiro"], { description: "ACME — portal and API" }), profile("globex", ["base"], ["kiro"])], +}; +async function setup(opts: { spec?: ForgeSpec; git?: boolean } = {}) { + const parent = await fs.realpath(await tmpDir("craftar-np-flow-")); + cleanups.push(() => fs.rm(parent, { recursive: true, force: true })); + const forge = path.join(parent, "forge"); + await makeForge(forge, opts.spec ?? SPEC); + if (opts.git !== false) { + git(forge, "init", "-q", "-b", "main"); + git(forge, "add", "-A"); + git(forge, "commit", "-q", "-m", "init"); + } + const ws = path.join(parent, "ws"); + await fs.mkdir(ws); + return { parent, forge, ws, home: path.join(parent, "home") }; +} +function script(answers: Array) { + const asked: string[] = []; + const said: string[] = []; + const next = async (q: string) => { + asked.push(q); + if (!answers.length) throw new Error(`no scripted answer left for: ${q}`); + return answers.shift()!; + }; + const io: InitIo = { ask: next, confirm: next, say: (l) => void said.push(l) }; + return { io, asked, said, left: () => answers.length }; +} +const GIVEN = { addRecipes: [] as string[], removeRecipes: [] as string[], replace: false }; +const LOCAL = { doc: null, keys: [] as never[] }; +const ask = (s: { ws: string; home: string }, given: Record, io: InitIo) => + askInit(s.ws, { ...GIVEN, ...given }, LOCAL, io, { home: s.home, registryOff: true, exists: existsSync }); + +describe("the profile list gains `0) a new profile` (§4.3)", () => { + it("a Forge directory that passes P5/P6: the entry; 0 starts the new-profile questions", async () => { + const s = await setup(); + const sc = script(["0", "initech", "acme", "", ""]); + const r = await ask(s, { forge: s.forge }, sc.io); + expect(sc.said).toContain(" 0) a new profile"); + expect(sc.asked).toEqual([ + "Profile: ", + "New profile's name: ", + "Start from an existing profile [none]: ", + "Adjust the recipes [no]: ", + "Targets — claude-code, kiro, agents-md, separated by commas [claude-code, kiro, from acme]: ", + ]); + expect(sc.said).toContain("Recipes of initech (from acme): base → stack-node"); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.profile).toBe("initech"); + expect(r.input.newProfile).toEqual({ name: "initech", like: "acme" }); + }); + it("a Forge directory that is not a git repository: no entry, P6's text as the line under the list", async () => { + const s = await setup({ git: false }); + const sc = script(["acme", "", ""]); + await ask(s, { forge: s.forge }, sc.io); + expect(sc.said.some((l) => l.includes("0) a new profile"))).toBe(false); + expect(sc.said).toContain(`cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`); + }); + it("a URL Forge: no entry, one line", async () => { + const r = await remoteForge(SPEC); + cleanups.push(r.cleanup); + const s = await setup(); + const sc = script(["", "acme", "", ""]); + await ask(s, { forge: r.url }, sc.io); + expect(sc.said.some((l) => l.includes("0) a new profile"))).toBe(false); + expect(sc.said).toContain("a new profile needs a Forge directory"); + }); + it("a profile literally named 0 wins over the entry", async () => { + const s = await setup({ spec: { ...SPEC, profiles: [profile("0", ["base"]), ...(SPEC.profiles ?? [])] } }); + const sc = script(["0", "", ""]); + const r = await ask(s, { forge: s.forge }, sc.io); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.profile).toBe("0"); + expect(r.input.newProfile).toBeUndefined(); + }); +}); + +describe("the new-profile questions (§4.3)", () => { + it("the name is re-asked on an empty answer, P3 and P7", async () => { + const s = await setup(); + const sc = script(["0", "", "Bad", "acme", "initech", "", "", ""]); + await ask(s, { forge: s.forge }, sc.io); + expect(sc.asked.filter((q) => q === "New profile's name: ").length).toBe(4); + expect(sc.said).toContain('"Bad" is not a profile name — lowercase letters and digits, in groups joined by one "-"'); + expect(sc.said).toContain("profile acme already exists in the Forge — use --profile acme"); + }); + it("no template: the Forge's recipes, then `Add [none]: ` directly; the targets' default says `default`", async () => { + const s = await setup(); + const sc = script(["0", "initech", "", "extra", ""]); + const r = await ask(s, { forge: s.forge }, sc.io); + expect(sc.asked).toContain("Add [none]: "); + expect(sc.asked).not.toContain("Adjust the recipes [no]: "); + expect(sc.asked.at(-1)).toBe("Targets — claude-code, kiro, agents-md, separated by commas [claude-code, default]: "); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.newProfile).toEqual({ name: "initech" }); + expect(r.input.addRecipes).toEqual(["extra"]); + }); + it("an unknown template is said and asked again (P9); a number picks from the list", async () => { + const s = await setup(); + const sc = script(["0", "initech", "nope", "1", "", ""]); + const r = await ask(s, { forge: s.forge }, sc.io); + expect(sc.said).toContain('--like: profile "nope" not found in Forge (acme, globex)'); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.newProfile).toEqual({ name: "initech", like: "acme" }); + }); + it("P10: an owned recipe in the list is said, and the recipes question asked again opening on Remove", async () => { + const spec = { ...SPEC, profiles: [profile("acme", ["base--acme", "stack-node"]), profile("globex", ["base"])] }; + const s = await setup({ spec }); + const sc = script(["0", "initech", "acme", "", "base--acme", "base", ""]); + const r = await ask(s, { forge: s.forge }, sc.io); + expect(sc.said).toContain("recipe base--acme is named for profile acme — a new profile cannot use it; leave it out and add the shared recipe it stands for"); + const i = sc.asked.indexOf("Adjust the recipes [no]: "); + expect(sc.asked[i + 1]).toBe("Remove [none]: "); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.removeRecipes).toEqual(["base--acme"]); + expect(r.input.addRecipes).toEqual(["base"]); + }); + it("a Forge directory with no profile goes straight to the questions", async () => { + const s = await setup({ spec: { ...SPEC, profiles: [] } }); + const sc = script(["initech", "", ""]); + const r = await ask(s, { forge: s.forge }, sc.io); + expect(sc.said).toContain("the Forge has no profile yet — creating the first one"); + expect(sc.asked[0]).toBe("New profile's name: "); + expect(sc.asked).not.toContain("Start from an existing profile [none]: "); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.newProfile).toEqual({ name: "initech" }); + }); + it("--new-profile typed, --forge asked: the list is printed before the template question; a typed name failing P7 is a refusal", async () => { + const s = await setup(); + const sc = script([s.forge, "", "", ""]); + const r = await ask(s, { newProfile: "initech" }, sc.io); + expect(sc.asked[0]).toBe("Forge — a directory or a git URL: "); + const listAt = sc.said.indexOf(`Profiles in ${s.forge}:`); + expect(listAt).toBeGreaterThanOrEqual(0); + expect(sc.asked).toContain("Start from an existing profile [none]: "); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.newProfile).toEqual({ name: "initech" }); + const t = await setup(); + await expect(ask(t, { forge: t.forge, newProfile: "acme" }, script([]).io)).rejects.toThrow("profile acme already exists in the Forge — use --profile acme"); + }); +}); + +describe("confirmInit and againLine with a new profile (§4.3)", () => { + it("the confirmation names both writes; the again line is the next workspace's run", async () => { + const s = await setup(); + const { loadForgeSource } = await import("../src/core/sync.js"); + const { forgeSource } = await import("../src/core/init.js"); + const loaded = await loadForgeSource(s.ws, forgeSource(s.ws, s.forge), null, { mode: "sync" }); + const init = await planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech", like: "acme" }, loaded }); + const sc = script([""]); + expect(await confirmInit(init, s.ws, sc.io, { sync: true })).toBe("confirmed"); + expect(sc.said[0]).toBe(`craftar init — about to create profile initech in ${s.forge} and write craftar.yaml in ${s.ws}`); + expect(sc.asked).toEqual(["Create profiles/initech/profile.yaml (not committed), write craftar.yaml and sync [yes]: "]); + const sc2 = script([""]); + await confirmInit(init, s.ws, sc2.io, { sync: false }); + expect(sc2.asked).toEqual(["Create profiles/initech/profile.yaml (not committed) and write craftar.yaml [yes]: "]); + expect( + againLine({ forge: "../forge", profile: "initech", addRecipes: [], removeRecipes: [], replace: false }, { sync: true, offline: false }), + ).toBe(" again craftar init --forge ../forge --profile initech"); + }); +}); From 76dc7c92c40bff55d86114411dce3279ff19e073 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 20:27:34 -0300 Subject: [PATCH 11/26] =?UTF-8?q?feat(init):=20the=20profile=20write=20and?= =?UTF-8?q?=20the=20second=20proof=20from=20disk,=20with=20injected=20seam?= =?UTF-8?q?s=20(spec=2038=20=C2=A75.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/new-profile-write.ts | 186 +++++++++++++++++++++++++++++++++ test/new-profile-write.test.ts | 117 +++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 src/core/new-profile-write.ts create mode 100644 test/new-profile-write.test.ts diff --git a/src/core/new-profile-write.ts b/src/core/new-profile-write.ts new file mode 100644 index 0000000..f1a88c7 --- /dev/null +++ b/src/core/new-profile-write.ts @@ -0,0 +1,186 @@ +/** + * Spec 38 §5.3 — the profile write (step 3) and the second proof from disk (step 4). + * No console, no process — these are pure write operations and a second planInit call. + */ + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { forgeSource, planInit, type InitOptions, type InitPlan } from "./init.js"; +import { note, type WriteJournal } from "./unify.js"; + +export { type WriteJournal }; + +/** + * Thrown when another run (or something else) created the profile's place first. + * P7's first message, with no recover lines — the directory belongs to the other run. + */ +export class NewProfileRaceLost extends Error { + constructor(message: string) { + super(message); + this.name = "NewProfileRaceLost"; + } +} + +/** + * Thrown when the profile write fails (other than EEXIST) or the second proof does not match. + * Carries the write journal so the caller can print the late-failure lines. + */ +export class NewProfileLateFailure extends Error { + journal: WriteJournal; + constructor(message: string, journal: WriteJournal) { + super(message); + this.name = "NewProfileLateFailure"; + this.journal = journal; + } +} + +/** Hooks for testing: injected seams between the steps. */ +export interface WriteHooks { + /** Called after the directory is journalled and before the mkdirs. */ + beforeMkdir?: () => Promise; + /** Called after the mkdirs and before the writeFile. */ + beforeWrite?: () => Promise; +} + +/** + * Step 3 of §5.3: write the profile file into the Forge. + * + * - `profiles/` is the only path journalled (never `profiles`, never the file). + * - The directory is journalled BEFORE the mkdir (note() decides "created" from whether the path exists when called). + * - `profiles` is made with `recursive: true` (EEXIST not an error). + * - `profiles/` is made WITHOUT recursive: EEXIST → throw NewProfileRaceLost. + * - The file is written with `wx`: EEXIST → throw NewProfileRaceLost. + * - Any other error → throw NewProfileLateFailure with the journal. + * + * @param forgeRoot The Forge's root directory. + * @param init The plan from planInit with newProfile set. + * @param hooks Optional hooks for testing. + * @returns The write journal. + */ +export async function writeNewProfile( + forgeRoot: string, + init: InitPlan, + hooks?: WriteHooks, +): Promise { + if (!init.newProfile) throw new Error("writeNewProfile: init.newProfile is required"); + + const name = path.basename(path.dirname(init.newProfile.path)); + const profileDir = path.join(forgeRoot, "profiles", name); + const profileFile = path.join(profileDir, "profile.yaml"); + + const journal: WriteJournal = []; + + // Journal the profile's own directory BEFORE the mkdir + await note(journal, profileDir); + + try { + if (hooks?.beforeMkdir) await hooks.beforeMkdir(); + + // Make profiles/ with recursive: true (EEXIST not an error) + await fs.mkdir(path.join(forgeRoot, "profiles"), { recursive: true }); + + // Make profiles/ WITHOUT recursive: EEXIST means another run won + try { + await fs.mkdir(profileDir); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "EEXIST") { + throw new NewProfileRaceLost(`profile ${name} already exists in the Forge — use --profile ${name}`); + } + throw e; + } + + if (hooks?.beforeWrite) await hooks.beforeWrite(); + + // Write the file with wx: EEXIST is the backstop + try { + await fs.writeFile(profileFile, init.newProfile.text, { flag: "wx" }); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "EEXIST") { + throw new NewProfileRaceLost(`profile ${name} already exists in the Forge — use --profile ${name}`); + } + throw e; + } + + return journal; + } catch (e) { + // NewProfileRaceLost passes through + if (e instanceof NewProfileRaceLost) throw e; + // Any other error: wrap in NewProfileLateFailure with the journal + const msg = e instanceof Error ? e.message : String(e); + throw new NewProfileLateFailure(msg, journal); + } +} + +/** + * Step 4 of §5.3: a second planInit from disk, with no newProfile, no loaded, and no recipe or target flag. + * + * The plan must hold the same paths and the same bytes as init1's (Buffer.equals per file, same path set). + * A difference — a file changed between the two steps — stops the run. + * + * @param root The workspace root. + * @param forgeArg The --forge argument (path or URL, relative to root). + * @param init1 The first plan (with newProfile). + * @param opts Options for planInit (without local, which is read fresh). + * @param journal The write journal, for error reporting. + * @returns The second plan. + */ +export async function reproveFromDisk( + root: string, + forgeArg: string, + init1: InitPlan, + opts: InitOptions, + journal: WriteJournal = [], +): Promise { + if (!init1.newProfile) throw new Error("reproveFromDisk: init1.newProfile is required"); + + const name = path.basename(path.dirname(init1.newProfile.path)); + + // Build the ref from init1 if it had one + const ref = init1.ws.config.ref; + + // Second planInit: profile exists on disk, no newProfile, no loaded, no recipe or target flag + const init2 = await planInit( + root, + { + forge: forgeArg, + profile: name, + ref, + // NO newProfile, NO loaded, NO recipe or target flag + }, + opts, + ); + + // Compare: same paths and same bytes + const paths1 = new Set(init1.plan.files.map((f) => f.path)); + const paths2 = new Set(init2.plan.files.map((f) => f.path)); + + // Same path set? + if (paths1.size !== paths2.size) { + throw new NewProfileLateFailure( + "the Forge read back from disk does not plan what init proved in memory — a Forge file changed while init ran", + journal, + ); + } + for (const p of paths1) { + if (!paths2.has(p)) { + throw new NewProfileLateFailure( + "the Forge read back from disk does not plan what init proved in memory — a Forge file changed while init ran", + journal, + ); + } + } + + // Same bytes per file? + const map1 = new Map(init1.plan.files.map((f) => [f.path, f.content])); + for (const f of init2.plan.files) { + const content1 = map1.get(f.path)!; + if (!content1.equals(f.content)) { + throw new NewProfileLateFailure( + "the Forge read back from disk does not plan what init proved in memory — a Forge file changed while init ran", + journal, + ); + } + } + + return init2; +} diff --git a/test/new-profile-write.test.ts b/test/new-profile-write.test.ts new file mode 100644 index 0000000..f6b6df1 --- /dev/null +++ b/test/new-profile-write.test.ts @@ -0,0 +1,117 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { forgeSource, planInit } from "../src/core/init.js"; +import { NewProfileLateFailure, NewProfileRaceLost, reproveFromDisk, writeNewProfile } from "../src/core/new-profile-write.js"; +import { loadForgeSource } from "../src/core/sync.js"; +import { makeForge, profile, recipe, rule, tmpDir, type ForgeSpec } from "./helpers/forge.js"; + +// Spec 38 §5.3 steps 3–4 and §4.6 — the profile write and the second proof, with injected hooks standing for a second +// run or an edit between the steps (the seams §10 asks for). In-process; temp dirs only. + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +const SPEC: ForgeSpec = { + ingredients: [rule("a", "# A\n"), rule("b", "# B\n")], + recipes: [recipe("base", ["rule/a"]), recipe("stack-node", ["rule/b"])], + profiles: [profile("acme", ["base", "stack-node"], ["claude-code"])], +}; +async function setup(spec: ForgeSpec = SPEC) { + const parent = await fs.realpath(await tmpDir("craftar-np-write-")); + cleanups.push(() => fs.rm(parent, { recursive: true, force: true })); + const forge = path.join(parent, "forge"); + await makeForge(forge, spec); + const ws = path.join(parent, "ws"); + await fs.mkdir(ws); + const loaded = await loadForgeSource(ws, forgeSource(ws, forge), null, { mode: "sync" }); + // Only use like: "acme" when the spec has the acme profile + const like = spec.profiles?.some((p) => p.name === "acme") ? "acme" : undefined; + const init = await planInit(ws, { forge, profile: "initech", newProfile: { name: "initech", like }, loaded }); + return { parent, forge, ws, init }; +} +const TEXT = "name: initech\nrecipes:\n - base\n - stack-node\ntargets:\n - claude-code\n"; + +describe("writeNewProfile — step 3", () => { + it("writes the file with its text, journals the profile's own directory only, before the mkdir", async () => { + const s = await setup(); + const journal = await writeNewProfile(s.forge, s.init); + expect(await fs.readFile(path.join(s.forge, "profiles", "initech", "profile.yaml"), "utf8")).toBe(TEXT); + expect(journal).toEqual([{ abs: path.join(s.forge, "profiles", "initech"), created: true }]); + }); + it("a Forge with no profiles/: created with the profile's directory; profiles is never journalled", async () => { + const s = await setup({ ...SPEC, profiles: [] }); + const init = await planInit(s.ws, { + forge: s.forge, + profile: "initech", + newProfile: { name: "initech" }, + loaded: await loadForgeSource(s.ws, forgeSource(s.ws, s.forge), null, { mode: "sync" }), + }); + const journal = await writeNewProfile(s.forge, init); + expect(journal.map((j) => path.relative(s.forge, j.abs))).toEqual([path.join("profiles", "initech")]); + expect(await fs.readFile(path.join(s.forge, "profiles", "initech", "profile.yaml"), "utf8")).toBe("name: initech\nrecipes: []\ntargets:\n - claude-code\n"); + }); + it("another run made profiles// first: NewProfileRaceLost with P7's first message; the other run's file untouched", async () => { + const s = await setup(); + const other = path.join(s.forge, "profiles", "initech", "profile.yaml"); + const e = await writeNewProfile(s.forge, s.init, { + beforeMkdir: async () => { + await fs.mkdir(path.dirname(other), { recursive: true }); + await fs.writeFile(other, "name: initech\nrecipes: []\n# the other run\n"); + }, + }).catch((x) => x); + expect(e).toBeInstanceOf(NewProfileRaceLost); + expect((e as Error).message).toBe("profile initech already exists in the Forge — use --profile initech"); + expect(await fs.readFile(other, "utf8")).toBe("name: initech\nrecipes: []\n# the other run\n"); + }); + it("the wx is the backstop: a file put there between the mkdir and the write is the same refusal", async () => { + const s = await setup(); + const file = path.join(s.forge, "profiles", "initech", "profile.yaml"); + const e = await writeNewProfile(s.forge, s.init, { beforeWrite: async () => void (await fs.writeFile(file, "x\n")) }).catch((x) => x); + expect(e).toBeInstanceOf(NewProfileRaceLost); + expect(await fs.readFile(file, "utf8")).toBe("x\n"); + }); + it("profiles/ made by another run, with another profile in it, between the gate and the write: the run goes on", async () => { + const s = await setup({ ...SPEC, profiles: [] }); + const init = await planInit(s.ws, { + forge: s.forge, + profile: "initech", + newProfile: { name: "initech" }, + loaded: await loadForgeSource(s.ws, forgeSource(s.ws, s.forge), null, { mode: "sync" }), + }); + await writeNewProfile(s.forge, init, { + beforeMkdir: async () => { + await fs.mkdir(path.join(s.forge, "profiles", "globex"), { recursive: true }); + await fs.writeFile(path.join(s.forge, "profiles", "globex", "profile.yaml"), "name: globex\n"); + }, + }); + expect((await fs.readdir(path.join(s.forge, "profiles"))).sort()).toEqual(["globex", "initech"]); + }); + it("any other failure is a NewProfileLateFailure carrying the journal", async () => { + const s = await setup(); + const e = await writeNewProfile(s.forge, s.init, { beforeWrite: async () => { throw Object.assign(new Error("EIO: disk"), { code: "EIO" }); } }).catch((x) => x); + expect(e).toBeInstanceOf(NewProfileLateFailure); + expect((e as NewProfileLateFailure).journal.map((j) => j.abs)).toEqual([path.join(s.forge, "profiles", "initech")]); + }); +}); + +describe("reproveFromDisk — step 4", () => { + it("the Forge read back plans the same paths and bytes: the second plan is returned, with no recipe or target flag in craftar.yaml", async () => { + const s = await setup(); + await writeNewProfile(s.forge, s.init); + const second = await reproveFromDisk(s.ws, s.forge, s.init, {}); + expect(second.text).toBe("forge: ../forge\nprofile: initech\n"); + expect(second.plan.files.map((f) => f.path)).toEqual(s.init.plan.files.map((f) => f.path)); + }); + it("an unrelated file written between the steps changes nothing; a recipe edited does — a late failure", async () => { + const s = await setup(); + await writeNewProfile(s.forge, s.init); + await fs.writeFile(path.join(s.forge, "NOTES.md"), "unrelated\n"); + await expect(reproveFromDisk(s.ws, s.forge, s.init, {})).resolves.toBeTruthy(); + await fs.writeFile(path.join(s.forge, "ingredients", "rules", "a", "rule.md"), "# A, edited meanwhile\n"); + const e = await reproveFromDisk(s.ws, s.forge, s.init, {}).catch((x) => x); + expect(e).toBeInstanceOf(NewProfileLateFailure); + expect((e as Error).message).toBe("the Forge read back from disk does not plan what init proved in memory — a Forge file changed while init ran"); + }); +}); From 5ad98395cd1bb72c95a0fbb6d7c263724d693603 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 20:27:40 -0300 Subject: [PATCH 12/26] =?UTF-8?q?feat(init):=20--new-profile=20and=20--lik?= =?UTF-8?q?e=20create=20a=20profile=20in=20the=20Forge=20directory,=20then?= =?UTF-8?q?=20start=20the=20workspace=20on=20it=20=E2=80=94=20nothing=20co?= =?UTF-8?q?mmitted=20(spec=2038)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli.ts | 143 ++++++++++++++++++--- test/init-new-profile-cli.test.ts | 198 ++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+), 15 deletions(-) create mode 100644 test/init-new-profile-cli.test.ts diff --git a/src/cli.ts b/src/cli.ts index a06f717..c64e619 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import pc from "picocolors"; import path from "node:path"; -import { promises as fs } from "node:fs"; +import { promises as fs, statSync } from "node:fs"; import YAML from "yaml"; import { importClaudeCode } from "./importers/claude-code.js"; import { parsePreferKiro } from "./importers/prefer-kiro.js"; @@ -16,7 +16,7 @@ import { launchRefusal, pinOf } from "./core/mcp-protocol.js"; import { serve } from "./mcp.js"; import { pruneCache, type PruneResult as CachePruneResult } from "./core/cache.js"; import { resolveHome } from "./core/home-lock.js"; -import { loadWorkspace, readWorkspaceConfig, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, agentsMdOverBudget, agentsMdRefusal, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE, type ApplyResult, type FetchMode, type FileState, type FileStatus, type LoadOptions, type MergedConfig, type Plan, type SectionLayer, type UnsetParam, type Workspace } from "./core/sync.js"; +import { loadWorkspace, loadForgeSource, readWorkspaceConfig, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, agentsMdOverBudget, agentsMdRefusal, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE, type ApplyResult, type FetchMode, type FileState, type FileStatus, type LoadOptions, type MergedConfig, type Plan, type SectionLayer, type UnsetParam, type Workspace } from "./core/sync.js"; import type { Lock, Registry } from "./schema/index.js"; import { sectionKey, type ParamLayer } from "./core/resolve.js"; import { catalogueContext, listRecipes, listIngredients, checkType, withLoadWarnings, type CatalogueContext, type ContextSource } from "./core/catalogue.js"; @@ -45,7 +45,10 @@ import { type PruneResult, } from "./core/unify.js"; import { checkParamWrites, writeParamFile } from "./core/param-writes.js"; -import { checkInitFlags, initLine, planInit, type InitInput } from "./core/init.js"; +import { checkInitFlags, initLine, planInit, forgeSource, type InitInput } from "./core/init.js"; +import { profileName } from "./core/new-profile.js"; +import { newProfileGate } from "./core/new-profile-gate.js"; +import { writeNewProfile, reproveFromDisk, NewProfileRaceLost, NewProfileLateFailure } from "./core/new-profile-write.js"; import { askInit, askClone, confirmInit, againLine, NOT_CLONED_REFUSED, type InitAnswers } from "./core/init-flow.js"; import { findGit, cloneRepos, listRepos, planClone, type CloneResult, type DestinationContext, type RepoState } from "./core/clone.js"; import { byHand, profileRepos, type RepoEntry } from "./core/repos.js"; @@ -327,14 +330,16 @@ async function runCloneStep(root: string, profile: string, entries: RepoEntry[], program .command("init") .description( - "Start a workspace from a Forge and a profile: write a craftar.yaml proved to resolve and plan, then run the first sync; with --clone, clone the profile's repositories into the workspace (after the first sync, or with --no-sync after writing craftar.yaml); in a terminal, asks for the Forge and the profile not given as flags; refused when craftar.yaml already exists", + "Start a workspace from a Forge and a profile: write a craftar.yaml proved to resolve and plan, then run the first sync; with --new-profile, create the profile in the Forge directory first (recipes and targets from --like and the recipe/target flags), then start the workspace on it — nothing is committed; with --clone, clone the profile's repositories into the workspace (after the first sync, or with --no-sync after writing craftar.yaml); in a terminal, asks for the Forge and the profile not given as flags; refused when craftar.yaml already exists", ) .option("--forge ", "the Forge: a directory (written relative to the workspace) or a git URL; asked for in a terminal when omitted") .option("--profile ", "the client profile in the Forge; asked for in a terminal when omitted") + .option("--new-profile ", "create this profile in the Forge directory (recipes and targets from --like and the recipe flags), then start the workspace on it; nothing is committed") + .option("--like ", "with --new-profile: start the new profile's recipes and targets from this profile's — nothing else of it is copied") .option("--ref ", "branch, tag or full SHA of a remote Forge") - .option("--targets ", "targets to write in craftar.yaml (claude-code, kiro, agents-md); omitted, the workspace follows the profile's") - .option("--add-recipe ", "add a recipe to the profile's (repeatable)", collect, []) - .option("--remove-recipe ", "remove a recipe of the profile's (repeatable)", collect, []) + .option("--targets ", "targets to write in craftar.yaml, or of the new profile with --new-profile (claude-code, kiro, agents-md); omitted, the workspace follows the profile's") + .option("--add-recipe ", "add a recipe to the profile's, or of the new profile with --new-profile (repeatable)", collect, []) + .option("--remove-recipe ", "remove a recipe of the profile's, or of the new profile with --new-profile (repeatable)", collect, []) .option("--replace", "let --add-recipe take a slot another recipe holds", false) .option("--no-sync", "write craftar.yaml only, and say what the first sync would do") .option("--clone", "after the first sync, clone the profile's repositories (its repos list) into the workspace") @@ -345,6 +350,8 @@ program async (o: { forge?: string; profile?: string; + newProfile?: string; + like?: string; ref?: string; targets?: string; addRecipe: string[]; @@ -395,14 +402,28 @@ program if (o.clone === true && git === null) { fail("cloning needs git — install it, or leave the clone out"); } + // P1–P4: --new-profile checks (before anything is read) + if (o.newProfile !== undefined && o.profile !== undefined) { + fail("pass --profile or --new-profile, not both"); + } + if (o.like !== undefined && o.newProfile === undefined) { + fail("--like goes with --new-profile"); + } + if (o.newProfile !== undefined) { + profileName(o.newProfile); + } + if (o.newProfile !== undefined && o.forge !== undefined && classifyForge(o.forge) === "url") { + fail("a new profile is written into a Forge directory — clone the Forge, pass that directory as --forge, then commit and push the profile"); + } // Mode: flags when both given; else interactive when both streams are TTYs; else N2 - const bothGiven = o.forge !== undefined && o.profile !== undefined; + // --new-profile counts as --profile for bothGiven and N2 + const bothGiven = o.forge !== undefined && (o.profile !== undefined || o.newProfile !== undefined); const isTTY = process.stdin.isTTY && process.stdout.isTTY; if (!bothGiven && !isTTY) { // N2: refuse with our message - if (o.forge === undefined && o.profile === undefined) { + if (o.forge === undefined && o.profile === undefined && o.newProfile === undefined) { fail("--forge and --profile are required when craftar init does not run in a terminal — pass them, or run craftar init in a terminal to be asked"); - } else if (o.profile === undefined) { + } else if (o.profile === undefined && o.newProfile === undefined) { fail("--profile is required when craftar init does not run in a terminal — pass it, or run craftar init in a terminal to be asked"); } else { fail("--forge is required when craftar init does not run in a terminal — pass it, or run craftar init in a terminal to be asked"); @@ -419,6 +440,8 @@ program { forge: o.forge, profile: o.profile, + newProfile: o.newProfile, + like: o.like, ref: o.ref, targets: o.targets === undefined ? undefined : o.targets.split(",").map((t) => t.trim()), addRecipes: o.addRecipe, @@ -433,6 +456,42 @@ program if (result.kind === "cancelled") fail("init cancelled — nothing written"); input = result.input; answers = result.answers; + } else if (o.newProfile !== undefined) { + // --forge and --new-profile given (flags mode with new profile) + // N10: checkLocalKeys with --new-profile counting as --profile + const clash = local.keys.find((k) => + k === "forge" || k === "profile" || + (k === "ref" && o.ref !== undefined) || + (k === "recipes" && (o.addRecipe.length > 0 || o.removeRecipe.length > 0)) || + (k === "targets" && o.targets !== undefined) + ); + if (clash) { + const flag: Record = { + forge: "--forge", + ref: "--ref", + profile: "--profile", + recipes: "--add-recipe / --remove-recipe", + targets: "--targets", + }; + fail(`${LOCAL_FILE} sets ${clash}, which would replace ${flag[clash]} — move it aside and re-run init`); + } + // Load the Forge + const loaded = await loadForgeSource(root, forgeSource(root, o.forge!), o.ref ?? null, { ...load(o, "sync") }); + // Gate: P5-P8 + await newProfileGate(loaded.forge, resolveHome(process.env.CRAFTAR_HOME || undefined), o.newProfile); + // Build input + input = { + forge: o.forge!, + profile: o.newProfile, + newProfile: { name: o.newProfile, like: o.like }, + ref: o.ref, + targets: o.targets === undefined ? undefined : o.targets.split(",").map((t) => t.trim()), + addRecipes: o.addRecipe, + removeRecipes: o.removeRecipe, + replace: o.replace, + clone: o.clone === true, + loaded, + }; } else { input = { forge: o.forge!, @@ -445,7 +504,7 @@ program clone: o.clone === true, }; } - const init = await planInit(root, input, { ...load(o, "sync"), local }); + let init = await planInit(root, input, { ...load(o, "sync"), local }); // Interactive mode: when o.clone is undefined, ask about cloning between planInit and confirmInit // --clone typed: planInit has checked C3–C5 and returned the entries, in either mode let cloneEntries: RepoEntry[] = o.clone === true ? init.repos : []; @@ -466,19 +525,55 @@ program }); if (confirm === "cancelled") fail("init cancelled — nothing written"); } - const { ws, plan: p } = init; + let { ws, plan: p } = init; + const forgeRoot = ws.forge.root; const workspaceGiven = cmd.getOptionValueSource("workspace") === "cli"; const again = () => { if (answers !== null) { console.log(againLine(answers, { workspace: workspaceGiven ? o.workspace : undefined, sync: o.sync, offline: o.offline, clone: cloneEntries.length > 0 })); } }; + // When init.newProfile: write the profile, then reprove from disk + let journal: WriteJournal = []; + if (init.newProfile) { + try { + journal = await writeNewProfile(forgeRoot, init); + } catch (e) { + if (e instanceof NewProfileRaceLost) { + // P7's first message, no recover lines + fail(e.message); + } + if (e instanceof NewProfileLateFailure) { + fail(lateFailure(e, forgeRoot, e.journal, "init")); + } + throw e; + } + // Reprove from disk (step 4) + try { + init = await reproveFromDisk(root, input.forge, init, { ...load(o, "sync") }, journal); + } catch (e) { + if (e instanceof NewProfileLateFailure) { + fail(lateFailure(e, forgeRoot, e.journal, "init")); + } + throw e; + } + // Update ws and p from the second init + ws = init.ws; + p = init.plan; + // Print the profile creation lines + console.log(pc.bold(`craftar init — created profile ${ws.config.profile} in ${forgeRoot}`)); + console.log(` + profiles/${ws.config.profile}/profile.yaml (not committed — commit it in the Forge, on a branch, before anyone else syncs)`); + } // Every refusal is above: only now does the directory, and craftar.yaml, come to exist await fs.mkdir(root, { recursive: true }); try { await fs.writeFile(file, init.text, { flag: "wx" }); } catch (e) { if ((e as NodeJS.ErrnoException).code === "EEXIST") n1(); + // Any other error after the profile was written: late failure (journal.length > 0 means a profile was written) + if (journal.length > 0) { + fail(lateFailure(e, forgeRoot, journal, "init")); + } throw e; } console.log(pc.bold(`craftar init — wrote ${WORKSPACE_FILE} in ${root}`)); @@ -3053,6 +3148,7 @@ function unheldMessage(command: string, root: string, unheld: UnheldPath[]): str * The message for a `forge unify` failure after writing began (Ruling 33): the original error, the * Forge-relative paths already touched, and the git commands that undo them — `checkout` for what * git tracks, `clean` for files unify created (checkout refuses a path git does not know). + * For created paths, directories get `clean -fd`, files (or missing paths) get `clean -f`. */ function lateFailure(e: unknown, root: string, journal: WriteJournal, command = "unify"): string { const msg = errorText(e); @@ -3060,12 +3156,29 @@ function lateFailure(e: unknown, root: string, journal: WriteJournal, command = const rel = (abs: string) => path.relative(root, abs).split(path.sep).join("/"); const quote = (p: string) => (/^[\w./-]+$/.test(p) ? p : `"${p}"`); const restore = [...new Set(journal.filter((j) => !j.created).map((j) => rel(j.abs)))]; - const created = [...new Set(journal.filter((j) => j.created).map((j) => rel(j.abs)))]; + const created = [...new Set(journal.filter((j) => j.created).map((j) => j.abs))]; + // For created paths, check if they are directories + const createdDirs: string[] = []; + const createdFiles: string[] = []; + for (const abs of created) { + try { + const st = statSync(abs); + if (st.isDirectory()) { + createdDirs.push(rel(abs)); + } else { + createdFiles.push(rel(abs)); + } + } catch { + // Path doesn't exist, treat as file + createdFiles.push(rel(abs)); + } + } const lines = [msg, `${command} had already started changing the Forge (${root}) when this failed:`]; - for (const p of [...restore, ...created]) lines.push(` ${p}`); + for (const p of [...restore, ...createdDirs, ...createdFiles]) lines.push(` ${p}`); lines.push("recover with:"); if (restore.length) lines.push(` git -C ${quote(root)} checkout -- ${restore.map(quote).join(" ")}`); - if (created.length) lines.push(` git -C ${quote(root)} clean -f -- ${created.map(quote).join(" ")}`); + if (createdDirs.length) lines.push(` git -C ${quote(root)} clean -fd -- ${createdDirs.map(quote).join(" ")}`); + if (createdFiles.length) lines.push(` git -C ${quote(root)} clean -f -- ${createdFiles.map(quote).join(" ")}`); return lines.join("\n"); } diff --git a/test/init-new-profile-cli.test.ts b/test/init-new-profile-cli.test.ts new file mode 100644 index 0000000..b82d26b --- /dev/null +++ b/test/init-new-profile-cli.test.ts @@ -0,0 +1,198 @@ +import { execFileSync } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { listFiles } from "../src/core/forge.js"; +import { runCli } from "./helpers/cli.js"; +import { makeForge, profile, recipe, rule, tmpDir, type ForgeSpec } from "./helpers/forge.js"; +import { git, gitEnv, remoteForge } from "./helpers/remote.js"; + +// Spec 38 §4.1–§4.6 through the command. Forges are local git repositories with a commit; no network, no push. + +const WIN = process.platform === "win32"; +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +const SPEC: ForgeSpec = { + ingredients: [rule("a", "# A\n"), rule("b", "# B\n"), rule("t", "# T {{token}}\n", { params: { token: { description: "nobody sets it" } } })], + recipes: [recipe("base", ["rule/a"]), recipe("stack-node", ["rule/b"]), recipe("tokened", ["rule/t"])], + profiles: [profile("acme", ["base", "stack-node"], ["claude-code", "kiro"])], +}; +async function setup(spec: ForgeSpec = SPEC) { + const parent = await fs.realpath(await tmpDir("craftar-np-cli-")); + cleanups.push(async () => { + await fs.chmod(parent, 0o755).catch(() => {}); + await fs.rm(parent, { recursive: true, force: true }); + }); + const forge = path.join(parent, "forge"); + await makeForge(forge, spec); + git(forge, "init", "-q", "-b", "main"); + git(forge, "add", "-A"); + git(forge, "commit", "-q", "-m", "init"); + const ws = path.join(parent, "ws"); + const home = path.join(parent, "home"); + const run = (args: string[], env: NodeJS.ProcessEnv = {}) => runCli(args, { env: { CRAFTAR_HOME: home, ...env } }); + return { parent, forge, ws, home, run }; +} +async function tree(dir: string): Promise> { + const out: Record = {}; + for (const rel of await listFiles(dir)) if (!rel.startsWith(".git/")) out[rel] = await fs.readFile(path.join(dir, rel), "utf8"); + return out; +} +const exists = (p: string) => fs.lstat(p).then(() => true, () => false); +const MISSING = path.join(WIN ? "C:\\" : "/", "nonexistent-forge-zq"); + +describe("refusals read off the arguments, before any load (P1–P4, N2)", () => { + it("P1, P2, P3, P4 — nothing read, nothing written", async () => { + const s = await setup(); + const cases: Array<[string[], string]> = [ + [["--forge", MISSING, "--new-profile", "initech", "--profile", "acme"], "pass --profile or --new-profile, not both"], + [["--forge", MISSING, "--profile", "acme", "--like", "acme"], "--like goes with --new-profile"], + [["--forge", MISSING, "--new-profile", "Bad"], '"Bad" is not a profile name — lowercase letters and digits, in groups joined by one "-"'], + [["--forge", MISSING, "--new-profile", "con"], '"con" is a device name on Windows — pick another'], + [["--forge", "https://git.example.com/acme/forge.git", "--new-profile", "initech"], "a new profile is written into a Forge directory — clone the Forge, pass that directory as --forge, then commit and push the profile"], + ]; + for (const [args, msg] of cases) { + const r = s.run(["init", ...args, "--workspace", s.ws]); + expect(r.code, args.join(" ")).toBe(1); + expect(r.stderr, args.join(" ")).toBe(`error: ${msg}\n`); + expect(await exists(s.ws)).toBe(false); + } + }); + it("off a terminal, --new-profile without --forge is N2's --forge line; --forge with --new-profile is not N2", async () => { + const s = await setup(); + const r = s.run(["init", "--new-profile", "initech", "--workspace", s.ws]); + expect(r.code).toBe(1); + expect(r.stderr).toBe("error: --forge is required when craftar init does not run in a terminal — pass it, or run craftar init in a terminal to be asked\n"); + }); + it("a --forge that does not exist is the load's error, not P6; N10 comes before the load", async () => { + const s = await setup(); + const r = s.run(["init", "--forge", MISSING, "--new-profile", "initech", "--workspace", s.ws]); + expect(r.code).toBe(1); + expect(r.stderr).toMatch(/Forge not found/); + await fs.mkdir(s.ws); + await fs.writeFile(path.join(s.ws, "craftar.local.yaml"), "profile: acme\n"); + const n10 = s.run(["init", "--forge", MISSING, "--new-profile", "initech", "--workspace", s.ws]); + expect(n10.code).toBe(1); + expect(n10.stderr).toMatch(/craftar\.local\.yaml/); + expect(n10.stderr).not.toMatch(/Forge not found/); + }); +}); + +describe("the run (§4.4–§4.5)", () => { + it("one new, uncommitted file in the Forge; the workspace synced on it; the output; HEAD and the branch unchanged", async () => { + const s = await setup(); + const before = await tree(s.forge); + const head = git(s.forge, "rev-parse", "HEAD"); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--like", "acme", "--workspace", s.ws]); + expect(r.code, r.stderr).toBe(0); + const after = await tree(s.forge); + const added = Object.keys(after).filter((k) => !(k in before)); + expect(added).toEqual(["profiles/initech/profile.yaml"]); + expect(after["profiles/initech/profile.yaml"]).toBe("name: initech\nrecipes:\n - base\n - stack-node\ntargets:\n - claude-code\n - kiro\n"); + for (const k of Object.keys(before)) expect(after[k], k).toBe(before[k]); + expect(git(s.forge, "status", "--porcelain")).toBe("?? profiles/initech/"); + expect(git(s.forge, "rev-parse", "HEAD")).toBe(head); + expect(git(s.forge, "symbolic-ref", "--short", "HEAD")).toBe("main"); + const lines = r.stdout.split("\n"); + expect(lines.slice(0, 4)).toEqual([ + `craftar init — created profile initech in ${s.forge}`, + " + profiles/initech/profile.yaml (not committed — commit it in the Forge, on a branch, before anyone else syncs)", + `craftar init — wrote craftar.yaml in ${s.ws}`, + " forge ../forge · profile initech · recipes base → stack-node · targets claude-code, kiro (from the profile)", + ]); + expect(r.stdout).not.toMatch(/again craftar init/); + expect(await fs.readFile(path.join(s.ws, "craftar.yaml"), "utf8")).toBe("forge: ../forge\nprofile: initech\n"); + const st = JSON.parse(s.run(["status", "--json", "--workspace", s.ws]).stdout); + expect(st.statuses.every((x: { state: string }) => x.state === "unchanged")).toBe(true); + // a second workspace of the same client starts with --profile + const ws2 = path.join(s.parent, "ws2"); + expect(s.run(["init", "--forge", s.forge, "--profile", "initech", "--workspace", ws2]).code).toBe(0); + }); + it("another workspace of the Forge plans the same after; recipes --json differs only by the new name", async () => { + const s = await setup(); + const other = path.join(s.parent, "other"); + expect(s.run(["init", "--forge", s.forge, "--profile", "acme", "--workspace", other]).code).toBe(0); + const recipesBefore = JSON.parse(s.run(["recipes", "--json", "--workspace", other]).stdout); + expect(s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--like", "acme", "--workspace", s.ws]).code).toBe(0); + const st = JSON.parse(s.run(["status", "--json", "--workspace", other]).stdout); + expect(st.statuses.every((x: { state: string }) => x.state === "unchanged")).toBe(true); + const recipesAfter = JSON.parse(s.run(["recipes", "--json", "--workspace", other]).stdout); + const strip = (j: { recipes: Array<{ name: string; profiles: string[] }> }) => j.recipes.map((r) => ({ ...r, profiles: r.profiles.filter((p) => p !== "initech") })); + expect(strip(recipesAfter)).toEqual(strip(recipesBefore)); + expect(recipesAfter.recipes.find((r: { name: string }) => r.name === "base").profiles).toContain("initech"); + }); + it("--no-sync: both files, next sync printed, exit 0; a refused first sync: exit 1, both files stay", async () => { + const s = await setup(); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--workspace", s.ws, "--no-sync"]); + expect(r.code, r.stderr).toBe(0); + expect(r.stdout).toMatch(/next sync:/); + expect(await exists(path.join(s.ws, "craftar.lock"))).toBe(false); + const t = await setup(); + const r2 = t.run(["init", "--forge", t.forge, "--new-profile", "initech", "--add-recipe", "tokened", "--workspace", t.ws]); + expect(r2.code).toBe(1); + expect(await exists(path.join(t.forge, "profiles", "initech", "profile.yaml"))).toBe(true); + expect(await exists(path.join(t.ws, "craftar.yaml"))).toBe(true); + }); + it("--ref with a Forge directory is N11; --offline is accepted and inert", async () => { + const s = await setup(); + expect(s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--ref", "main", "--workspace", s.ws]).code).toBe(1); + expect(s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--offline", "--workspace", s.ws]).code).toBe(0); + }); + it("P7 at the command: an existing profile", async () => { + const s = await setup(); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "acme", "--workspace", s.ws]); + expect(r.code).toBe(1); + expect(r.stderr).toBe("error: profile acme already exists in the Forge — use --profile acme\n"); + }); + it("--new-profile --clone is spec 37's C3, the Forge untouched", async () => { + const s = await setup(); + const before = await tree(s.forge); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--clone", "--workspace", s.ws]); + expect(r.code).toBe(1); + expect(r.stderr).toBe("error: profile initech lists no repositories — nothing to clone; drop --clone\n"); + expect(await tree(s.forge)).toEqual(before); + }); + it("P5: a tree of craftar's Forge cache passed as --forge", async () => { + const rf = await remoteForge(SPEC); + cleanups.push(rf.cleanup); + const s = await setup(); + const w0 = path.join(s.parent, "w0"); + await fs.mkdir(w0); + await fs.writeFile(path.join(w0, "craftar.yaml"), `forge: ${rf.url}\nprofile: acme\n`); + expect(s.run(["sync", "--workspace", w0]).code).toBe(0); + const forges = path.join(s.home, "forges"); + const [entry] = await fs.readdir(forges); + const trees = path.join(forges, entry, "trees"); + const [t] = (await fs.readdir(trees)).filter((n) => !n.endsWith(".ok")); + const r = s.run(["init", "--forge", path.join(trees, t), "--new-profile", "initech", "--workspace", s.ws]); + expect(r.code).toBe(1); + expect(r.stderr).toMatch(/is a tree of craftar's Forge cache — nothing is written there; clone the Forge and pass that directory\n$/); + }); +}); + +describe("late failures (§4.6) — not run on Windows (chmod), reported as such", () => { + it.skipIf(WIN || process.getuid?.() === 0)("craftar.yaml cannot be written after the profile was: the late-failure lines with clean -fd; running it restores the Forge", async () => { + const s = await setup(); + await fs.mkdir(s.ws); + await fs.chmod(s.ws, 0o555); + cleanups.push(() => fs.chmod(s.ws, 0o755)); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--workspace", s.ws]); + expect(r.code).toBe(1); + expect(r.stderr).toContain(`init had already started changing the Forge (${s.forge}) when this failed:\n profiles/initech\nrecover with:\n git -C ${s.forge} clean -fd -- profiles/initech\n`); + expect(await exists(path.join(s.forge, "profiles", "initech", "profile.yaml"))).toBe(true); + execFileSync("git", ["-C", s.forge, "clean", "-fd", "--", "profiles/initech"], { env: gitEnv() }); + expect(git(s.forge, "status", "--porcelain")).toBe(""); + }); + it.skipIf(WIN || process.getuid?.() === 0)("the profile write fails otherwise (a read-only profiles/): the late-failure lines, craftar.yaml not written", async () => { + const s = await setup(); + const profiles = path.join(s.forge, "profiles"); + await fs.chmod(profiles, 0o555); + cleanups.push(() => fs.chmod(profiles, 0o755)); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--workspace", s.ws]); + expect(r.code).toBe(1); + expect(r.stderr).toContain("recover with:"); + expect(await exists(path.join(s.ws, "craftar.yaml"))).toBe(false); + }); +}); From 58f57eb472506894bc6ef386ec7ea773a762a2ca Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 20:29:48 -0300 Subject: [PATCH 13/26] =?UTF-8?q?refactor(init):=20--new-profile's=20N10?= =?UTF-8?q?=20goes=20through=20checkLocalKeys=20=E2=80=94=20one=20copy=20o?= =?UTF-8?q?f=20the=20check=20and=20its=20message=20(spec=2038=20=C2=A74.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli.ts | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index c64e619..7705df5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,7 +45,7 @@ import { type PruneResult, } from "./core/unify.js"; import { checkParamWrites, writeParamFile } from "./core/param-writes.js"; -import { checkInitFlags, initLine, planInit, forgeSource, type InitInput } from "./core/init.js"; +import { checkInitFlags, checkLocalKeys, initLine, planInit, forgeSource, type InitInput } from "./core/init.js"; import { profileName } from "./core/new-profile.js"; import { newProfileGate } from "./core/new-profile-gate.js"; import { writeNewProfile, reproveFromDisk, NewProfileRaceLost, NewProfileLateFailure } from "./core/new-profile-write.js"; @@ -458,23 +458,12 @@ program answers = result.answers; } else if (o.newProfile !== undefined) { // --forge and --new-profile given (flags mode with new profile) - // N10: checkLocalKeys with --new-profile counting as --profile - const clash = local.keys.find((k) => - k === "forge" || k === "profile" || - (k === "ref" && o.ref !== undefined) || - (k === "recipes" && (o.addRecipe.length > 0 || o.removeRecipe.length > 0)) || - (k === "targets" && o.targets !== undefined) - ); - if (clash) { - const flag: Record = { - forge: "--forge", - ref: "--ref", - profile: "--profile", - recipes: "--add-recipe / --remove-recipe", - targets: "--targets", - }; - fail(`${LOCAL_FILE} sets ${clash}, which would replace ${flag[clash]} — move it aside and re-run init`); - } + // N10 (spec 38 §4.2): the one check, before the load — --new-profile counts as --profile there + checkLocalKeys(local.keys, { + ref: o.ref !== undefined, + recipes: o.addRecipe.length > 0 || o.removeRecipe.length > 0, + targets: o.targets !== undefined, + }); // Load the Forge const loaded = await loadForgeSource(root, forgeSource(root, o.forge!), o.ref ?? null, { ...load(o, "sync") }); // Gate: P5-P8 From 1e2b5f478aba0b9bf29865c1b54ad3fb6f5e3ac6 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 20:32:05 -0300 Subject: [PATCH 14/26] docs(readme): init --new-profile and --like, a new client's quick start, the roadmap (spec 38) --- README.md | 13 +++++++++++-- test/ui-readme.test.ts | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f3d7083..5837e2b 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,15 @@ craftar init --workspace C:/Projects/acme-portal-workspace \ The workspace follows the profile's recipes and targets; `--add-recipe` / `--remove-recipe` adjust the recipes, and `--targets` writes the targets into `craftar.yaml`. When the directory already holds a harness, files equal to the Forge's are adopted and different ones are left as they are and reported — `craftar import` (below) brings them into the Forge. +A new client's first workspace starts its profile too: + +```bash +# Creates profiles/initech/profile.yaml in the Forge directory (not committed), then the workspace on it +craftar init --workspace C:/Projects/initech-portal-workspace \ + --forge C:/Projects/forge \ + --new-profile initech --like acme-portal +``` + ## Quick start: bring an existing workspace into a Forge ```bash @@ -68,7 +77,7 @@ From then on, change a rule in `forge/ingredients/rules//rule.md`, run `cr | Command | What it does | |---|---| -| `craftar init [--forge ] [--profile ] [--ref ] [--targets ] [--add-recipe ]... [--remove-recipe ]... [--replace] [--no-sync] [--clone \| --no-clone] [--offline] [--workspace .]` | Starts a workspace: writes a `craftar.yaml` holding `forge` (a directory as the path from the workspace, POSIX; a URL as given), `ref` (a remote Forge only), `profile`, `recipes` (only the lists the recipe flags leave non-empty) and `targets` (only with `--targets`; otherwise the workspace follows the profile's), then runs the first `sync` — which also registers the workspace. **In a terminal** (stdin and stdout both a TTY), `--forge` and `--profile` are optional: what is missing is asked for — the Forge (offering the one last synced on this machine, from the registry), a remote Forge's ref, the profile (from the Forge's list), then the recipes and the targets the profile gives, to keep or adjust; a flag given is never asked for. One confirmation before anything is written, showing what the first sync would do. Ctrl-C, the end of input, or `no` at the confirmation: nothing written, exit 1 (`init cancelled — nothing written`). The run ends with an `again craftar init …` line: the same run as flags, for CI. Off a terminal (a pipe, CI), a missing flag is refused, exit 1, naming the flag; where the terminal does not report a TTY, the flags are required. With both flags given nothing is asked, in a terminal or not. The file is written only once the configuration loads (a remote Forge is fetched as `sync` fetches it; `--offline` reads the cache), resolves and plans, and the sync applies that same plan. `--remove-recipe` and `--add-recipe` go through `remove recipe` / `add recipe`'s core (removes first, then adds; `--replace` swaps a slot holder); a call whose every name changes nothing prints its reasons as a `note`. `--workspace` is created when missing. Files already on disk and not in a lock are adopted when equal to the plan and skipped as a `collision` otherwise, never overwritten, with one warning pointing at `craftar import`. `--no-sync` writes `craftar.yaml` only and prints what the first `sync` would do. When a declared parameter a file cites has no value (see `sync`), `craftar.yaml` is still written — the workspace's `overrides.params` is a place for the value — but the first `sync` is not run: the refusal is printed (`error: N declared parameter(s) have no value — craftar.yaml written, sync not run`, with `then run craftar sync` in its fix line), exit 1; in a terminal the confirmation says `first sync: refused — …` and asks only `Write craftar.yaml`; `--no-sync` prints `next sync: refused — …` and exits 0. Refused, with nothing written and no directory created: an existing `craftar.yaml`; a `--workspace` that is a file, or under one; credentials in `--forge` (the value is never printed); `--ref` with a directory Forge; an unknown target; a recipe both added and removed, or any refusal of `add recipe` / `remove recipe`; a `craftar.local.yaml` that sets `forge` or `profile`, or a key a given flag would write (`ref`, `recipes`, `targets`) — its value would replace the flag's; a Forge, profile or plan that does not load. **`--clone`** clones every entry of the profile's `repos` (see *Forge layout › Repositories*) into the workspace after the first `sync` — one `git clone --no-recurse-submodules [--branch=] -- ` per entry, one after another, with the workspace root as working directory, `git` found in the absolute entries of `PATH` and `GIT_ALLOW_PROTOCOL=https:ssh`; git's own output and credential prompt go to the terminal, with no timeout. With `--no-sync`, `--clone` still clones. Without `--clone` nothing is cloned: with both `--forge` and `--profile` given nothing is asked and nothing is said about repositories; in a terminal, when a flag is missing, a profile with entries is listed — name, URL, destination, ref — and `Clone them into the workspace [no]:` is asked (Enter is no), except under `--offline` or `--no-clone`. A clone that fails is reported with the command to run it by hand and `craftar repos clone `, the other entries are still tried, and `init` exits 1 with the workspace written, synced and registered. A first sync that would be refused clones nothing (`repositories: not cloned — the first sync is refused`). Refused before anything is written: `--clone` with `--no-clone`, or with `--offline`; `--clone` with no `git` on `PATH`, on a profile with no entry, with an entry that does not read, or with a destination that exists, holds a file `sync` writes, lies inside the Forge, leaves the workspace through a link or sits under a file. A clone is recorded nowhere — not in `craftar.lock`, not in the registry; `sync` never updates or removes it, and craftar does not edit a `.gitignore` (in a workspace that is itself a repository the clones show as untracked directories). | +| `craftar init [--forge ] [--profile ] [--new-profile [--like ]] [--ref ] [--targets ] [--add-recipe ]... [--remove-recipe ]... [--replace] [--no-sync] [--clone \| --no-clone] [--offline] [--workspace .]` | Starts a workspace: writes a `craftar.yaml` holding `forge` (a directory as the path from the workspace, POSIX; a URL as given), `ref` (a remote Forge only), `profile`, `recipes` (only the lists the recipe flags leave non-empty) and `targets` (only with `--targets`; otherwise the workspace follows the profile's), then runs the first `sync` — which also registers the workspace. **In a terminal** (stdin and stdout both a TTY), `--forge` and `--profile` are optional: what is missing is asked for — the Forge (offering the one last synced on this machine, from the registry), a remote Forge's ref, the profile (from the Forge's list), then the recipes and the targets the profile gives, to keep or adjust; a flag given is never asked for. One confirmation before anything is written, showing what the first sync would do. Ctrl-C, the end of input, or `no` at the confirmation: nothing written, exit 1 (`init cancelled — nothing written`). The run ends with an `again craftar init …` line: the same run as flags, for CI. Off a terminal (a pipe, CI), a missing flag is refused, exit 1, naming the flag; where the terminal does not report a TTY, the flags are required. With both flags given nothing is asked, in a terminal or not. The file is written only once the configuration loads (a remote Forge is fetched as `sync` fetches it; `--offline` reads the cache), resolves and plans, and the sync applies that same plan. `--remove-recipe` and `--add-recipe` go through `remove recipe` / `add recipe`'s core (removes first, then adds; `--replace` swaps a slot holder); a call whose every name changes nothing prints its reasons as a `note`. `--workspace` is created when missing. Files already on disk and not in a lock are adopted when equal to the plan and skipped as a `collision` otherwise, never overwritten, with one warning pointing at `craftar import`. `--no-sync` writes `craftar.yaml` only and prints what the first `sync` would do. When a declared parameter a file cites has no value (see `sync`), `craftar.yaml` is still written — the workspace's `overrides.params` is a place for the value — but the first `sync` is not run: the refusal is printed (`error: N declared parameter(s) have no value — craftar.yaml written, sync not run`, with `then run craftar sync` in its fix line), exit 1; in a terminal the confirmation says `first sync: refused — …` and asks only `Write craftar.yaml`; `--no-sync` prints `next sync: refused — …` and exits 0. Refused, with nothing written and no directory created: an existing `craftar.yaml`; a `--workspace` that is a file, or under one; credentials in `--forge` (the value is never printed); `--ref` with a directory Forge; an unknown target; a recipe both added and removed, or any refusal of `add recipe` / `remove recipe`; a `craftar.local.yaml` that sets `forge` or `profile`, or a key a given flag would write (`ref`, `recipes`, `targets`) — its value would replace the flag's; a Forge, profile or plan that does not load. **`--clone`** clones every entry of the profile's `repos` (see *Forge layout › Repositories*) into the workspace after the first `sync` — one `git clone --no-recurse-submodules [--branch=] -- ` per entry, one after another, with the workspace root as working directory, `git` found in the absolute entries of `PATH` and `GIT_ALLOW_PROTOCOL=https:ssh`; git's own output and credential prompt go to the terminal, with no timeout. With `--no-sync`, `--clone` still clones. Without `--clone` nothing is cloned: with both `--forge` and `--profile` given nothing is asked and nothing is said about repositories; in a terminal, when a flag is missing, a profile with entries is listed — name, URL, destination, ref — and `Clone them into the workspace [no]:` is asked (Enter is no), except under `--offline` or `--no-clone`. A clone that fails is reported with the command to run it by hand and `craftar repos clone `, the other entries are still tried, and `init` exits 1 with the workspace written, synced and registered. A first sync that would be refused clones nothing (`repositories: not cloned — the first sync is refused`). Refused before anything is written: `--clone` with `--no-clone`, or with `--offline`; `--clone` with no `git` on `PATH`, on a profile with no entry, with an entry that does not read, or with a destination that exists, holds a file `sync` writes, lies inside the Forge, leaves the workspace through a link or sits under a file. A clone is recorded nowhere — not in `craftar.lock`, not in the registry; `sync` never updates or removes it, and craftar does not edit a `.gitignore` (in a workspace that is itself a repository the clones show as untracked directories). **`--new-profile `** creates the profile first: one new file, `profiles//profile.yaml`, in the Forge's working tree — its `name`, `recipes` and `targets` only — then starts the workspace on it as for a profile that was there. The Forge must be a directory that is a git repository with a commit (git is how the write is undone); nothing is committed, no branch is made and nothing is pushed — commit the file in the Forge, on a branch, before anyone else syncs. With `--new-profile`, `--add-recipe`, `--remove-recipe`, `--replace` and `--targets` shape **the profile**, and `craftar.yaml` gets no `recipes` and no `targets`. `--like ` starts the new profile's `recipes` and `targets` from that profile's written lists — nothing else of it is copied: no `params`, `sections`, `integrations`, `repos`, `templates` or `description`; in a Forge at schema 3 or above a recipe only its `integrations` block selects does not come along, and a `note` line says so. The name is lowercase letters and digits in groups joined by one `-` (no `--`, not a Windows device name). Refused before anything is written: `--new-profile` with `--profile`, `--like` without `--new-profile`, a name of another shape, a Forge URL (clone it and pass the directory), a tree of craftar's Forge cache, a directory that is not a git repository with a commit, a name a profile already carries or a `profiles/` that exists, a path git ignores or that goes through a symbolic link, a recipe named for another profile (`--

`, `

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

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

--profile [--workspace .] [--write-config] [--report ] [--prefer-kiro ]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering — and, with `--prefer-kiro`, the text of the rules it names). A rule's scope comes, in this order, from: a hand-written `.kiro/steering/.md` whose frontmatter sets `inclusion`; a usable `paths:` in the rule's own frontmatter — a non-empty one-line string or a list of them, quoted or not (`- **/*.ts` is read as Claude Code reads it) — which gives `inclusion: fileMatch` and `fileMatchPattern`; a steering mirror craftar generated (its default banner); any other steering file, as before; and `always`. When `paths:` is usable the rule's frontmatter is kept verbatim apart from its body (`frontmatterRaw`), whichever source set the scope, so `.claude/rules/.md` is written back byte for byte and neither `.kiro/steering/.md` nor `AGENTS.md` carries it — except a file whose closing `---` is not followed by a newline, which keeps its frontmatter in its body and is written back as is; its steering mirror — and `AGENTS.md`, where it embeds the rule — then carry the block. What the workspace sees next, for a rule a craftar before this one read as always-on: its `.kiro/steering/.md`, `AGENTS.md` and the `.kiro/agents/*.json` of agents bound to the rule now differ from the plan — where `craftar.lock` records them unedited, `sync` updates them; with no lock entry for them (a first import), they read `collision` and are left alone: delete them and run `craftar sync`. Four warnings name the rule and its files, never the pattern: the hand-written steering file and `paths:` disagree (the steering file's scope is kept); the generated mirror disagrees with `paths:` (scope taken from `paths:`); the steering file sets no `inclusion` (scope taken from `paths:`); `paths:` is not a string or a list of strings (ignored). With `--prefer-kiro`, a steering body that starts with a frontmatter block (as an earlier craftar wrote a `paths:` rule) is taken with it, as before, and the rule keeps no separate frontmatter. Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml` (a manifest already at 2 or above is left as it is). A reused base takes the place its `--` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `--` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `--`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections ` when a section value filled the render), `inferred`, `sectioned`, `param : → `, `section : → `, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created\|edited\|unchanged` lines. In a Forge at schema 3 or above, what the profile's `integrations` block alone provides is **not imported** — not compared, not written — and is listed (`left to recipe (integrations.): …`, and under `## Left to an integration recipe` in the `--report` file); `craftar status` then shows whether the workspace's copies differ (`drift`, `collision`); move an agent attached to a scoped rule into the integration recipe with its rule, or `base` splits on import. It refuses, with the Forge untouched, a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand), and — in a Forge at schema 3 or above — a profile whose `integrations` block is malformed or, when the block holds a `kind`, that does not resolve. Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. `authEnv` is never inferred; a variant import rewrites keeps the one it declares, a new variant takes its base's, and a declaration a profile loses is reported. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. `--report ` also writes a Markdown report, after the Forge is written: what was created, reused, made a variant or rejected, the import's warnings, and the Kiro side — *Kiro collisions*, every `.kiro/` file that differs from what `sync` would generate for the imported profile (`status`' `collision` state against no lock, so a difference of line endings, BOM or JSON formatting is not one), each with its ingredient and the line counts of the two sides, a rule's steering mirror that outgrew its rule marked `steering bigger than the rule` (not one `--prefer-kiro` took the rule from); and *Unsourced Kiro files*, the files under `.kiro/steering/`, `.kiro/agents/`, `.kiro/skills/` and `.kiro/settings/mcp.json` that the plan for the imported profile does not write — listed, never imported. The report holds **paths and line counts, never content** (a `.kiro/` mirror is not scanned for secrets), and names an inferred parameter's key, not its value; a warning, a rejection reason or the `not computed` line is written with every parameter and section value this run accepted, replaced or inferred as `"…"`, and a variant is named against its base, without its reason — the summary on the terminal keeps them. It describes the profile as imported: of the workspace's own `craftar.yaml` and `craftar.local.yaml` only `overrides.params` and `overrides.sections` are read (as import reads them to decide a reuse) — not its `forge`, `profile`, `targets` or recipes — and `craftar.lock` is not read. The path may be inside the workspace; it is refused, before anything is imported, when it resolves inside the Forge (through symlinks and `..`), when a file already exists there, or when a component of it cannot be resolved. If the Kiro side cannot be computed — the plan fails, or `sync` would refuse it for a declared parameter with no value — its sections read `not computed: …`, the same is an import warning, and the import still exits 0. The report is written after the summary, which then ends with `report `; if that write fails the import has still succeeded and the Forge is written, and the command says so and exits 1. Without `--report` none of this is computed. `--prefer-kiro ` takes the named rules from `.kiro/steering/.md` instead of `.claude/rules/.md` — for a steering mirror that was edited by hand and is the text to keep; a rule left to an integration recipe is warned (`nothing to prefer; its steering file was not used`) and skipped, before that rule's own refusals (no steering file, a form with no certain source, a line that would not come back). The rule's text is built from the steering file — its frontmatter and its opening banner (the default one, or the line rendered by the `kiro.banner` sync would use: the workspace's `overrides.params`, else — for a profile already in the Forge — the profile's own or a default of a recipe it resolves) dropped, `.kiro/steering/` turned back into `.claude/rules/` except in a reference to a hand-written steering file and under `commands/` — and is accepted only when it is **proved to render back** to that steering file's body for Kiro over the workspace's own rules (every rule aimed at every target, as import writes them); it is then scanned for secrets and decided like any source (reused, a variant, or created; a rejection names the steering file's line, and a rejected rule gets no `prefer-kiro` line), its `inclusion` and `fileMatchPattern` still read from the steering frontmatter. Refused, with the Forge untouched: an empty or repeated name; a name with no `.claude/rules/.md` (the flag takes rules; a steering file with no rule is already imported as steering); a rule with no `.kiro/steering/.md`; a steering file holding a form Kiro writes for a rule it does not have (`AGENTS.md (rule: )`, a link to `AGENTS.md`, `rule not in this workspace`), which has no certain source; and a steering file that would not come back from the rule built from it (it names `.claude/rules/` where `sync` writes `.kiro/steering/`) — the last two name the line of the steering file and quote none of it. The ingredient's `origin.path` is the steering file; the summary gains `prefer-kiro — source .kiro/steering/.md (N lines; .claude/rules/.md has M)` per rule — N and M count the two files whole, the steering file's frontmatter and banner included — and `--report` a *Preferred from Kiro* section with the same lines. What the workspace sees next: the Forge rule now differs from `.claude/rules/.md` on disk, so with no lock entry for that file the next `sync` shows it as `collision` and leaves it alone — delete it (and the steering file, if it also collides) and run `craftar sync` to regenerate both; where `craftar.lock` already records the file unedited, `sync` updates it. The summary says so in a `note` for each rule whose text changed (after the warnings, before the `report` line). Until the workspace converges, an import without the flag reads `.claude/rules/.md` again and lands that older text as a `--` variant: keep the flag, or converge first. Rules only: commands, agents and skills are not taken from Kiro. In a schema 4 Forge an MCP base is compared as `sync` renders it (its declared params substituted into its `server`): a workspace whose server equals that rendering reuses the base — unless the base cites a declared key that neither the profile nor the workspace sets while some recipe of the Forge defaults it (import cannot know which value `sync` will render): that one becomes a variant whatever it holds; one whose server differs becomes a variant holding the server as the workspace has it, braces included, with no `params` (it declares nothing, so `sync` writes any braces in it back, with the undeclared-key warning whenever a layer gives that key a value). No value is ever inferred into a profile from a server. | @@ -504,7 +513,7 @@ The server answers for the directory it is started in; a client that starts its Phase 0 — foundations (done): schema, import, sync/status/diff/explain, claude-code + kiro + agents-md targets, lock, drift, orphans; `forge variants`, `forge diff` and `forge unify` (`take: param`, `take: section`); sections. -Phase 1 — client profile (in progress). Shipped: the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock) and `craftar cache prune`; the workspace registry and `craftar workspaces`; `craftar add recipe` / `remove recipe`; `craftar init` with flags; `craftar doctor`; `craftar forge impact`, `forge unify --prune-recipes` and `forge promote`; an interactive `craftar init` (choose the Forge and the profile); `authEnv` on MCP ingredients (the environment variables a server needs, by name, and the generated `.claude/settings.craftar.example.json`); `craftar drift show` / `drift discard` / `drift promote` (list the files edited by hand, discard an edit, or carry one into the Forge as a profile value or a variant); a sync refused when a declared parameter has no value; `craftar import --report` (a Markdown report of an import, as paths and line counts) and `import --prefer-kiro` (a rule's source taken from its Kiro steering file); a profile's `integrations` block (PM tool and IDP as recipes selected by the profile); `{{param}}` inside an MCP server; `craftar mcp` (a read-only MCP server: seven reading tools for an AI coder, over stdin and stdout); `craftar ui` (a local, read-only dashboard of the registered workspaces, on `127.0.0.1` only); cloning a profile's repositories (`init --clone`, `craftar repos`). +Phase 1 — client profile (in progress). Shipped: the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock) and `craftar cache prune`; the workspace registry and `craftar workspaces`; `craftar add recipe` / `remove recipe`; `craftar init` with flags; `craftar doctor`; `craftar forge impact`, `forge unify --prune-recipes` and `forge promote`; an interactive `craftar init` (choose the Forge and the profile); `authEnv` on MCP ingredients (the environment variables a server needs, by name, and the generated `.claude/settings.craftar.example.json`); `craftar drift show` / `drift discard` / `drift promote` (list the files edited by hand, discard an edit, or carry one into the Forge as a profile value or a variant); a sync refused when a declared parameter has no value; `craftar import --report` (a Markdown report of an import, as paths and line counts) and `import --prefer-kiro` (a rule's source taken from its Kiro steering file); a profile's `integrations` block (PM tool and IDP as recipes selected by the profile); `{{param}}` inside an MCP server; `craftar mcp` (a read-only MCP server: seven reading tools for an AI coder, over stdin and stdout); `craftar ui` (a local, read-only dashboard of the registered workspaces, on `127.0.0.1` only); cloning a profile's repositories (`init --clone`, `craftar repos`); a new profile from `craftar init` (`--new-profile`, `--like`). Phase 2 — environment (in progress). Shipped: local services (`service` ingredients, `.craftar/`, `craftar services up | down | status`); `craftar templates list | install` (`dotnet new` project templates declared in a profile). diff --git a/test/ui-readme.test.ts b/test/ui-readme.test.ts index def2cf8..6ef9e0c 100644 --- a/test/ui-readme.test.ts +++ b/test/ui-readme.test.ts @@ -33,7 +33,7 @@ describe("README — init --clone and craftar repos", () => { expect(count("| `craftar repos clone […] [--workspace .]` |")).toBe(1); expect(count("[--clone \\| --no-clone]")).toBe(1); expect(count("**Repositories.** A profile's `repos`")).toBe(1); - expect(count("; cloning a profile's repositories (`init --clone`, `craftar repos`).")).toBe(1); + expect(count("; cloning a profile's repositories (`init --clone`, `craftar repos`); a new profile from `craftar init` (`--new-profile`, `--like`).")).toBe(1); expect(count("Next: cloning")).toBe(0); expect(count("Everything else in the spec (cloning a profile's repositories at init) is not built yet.")).toBe(0); }); From 43a08384dc8a497fdf7d6eae5fc66fcb88952022 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 20:59:52 -0300 Subject: [PATCH 15/26] fix(init): the second proof uses the typed ref, and any failure of it is a late failure with the undo (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merged ref held a craftar.local.yaml ref: a path Forge then refused it after the profile was written, with no recover lines — and a local-only ref would have reached craftar.yaml. --- src/core/new-profile-write.ts | 35 ++++++++++++++++++++-------------- test/new-profile-write.test.ts | 8 ++++++++ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/core/new-profile-write.ts b/src/core/new-profile-write.ts index f1a88c7..e45abcf 100644 --- a/src/core/new-profile-write.ts +++ b/src/core/new-profile-write.ts @@ -5,7 +5,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; -import { forgeSource, planInit, type InitOptions, type InitPlan } from "./init.js"; +import { planInit, type InitOptions, type InitPlan } from "./init.js"; import { note, type WriteJournal } from "./unify.js"; export { type WriteJournal }; @@ -122,6 +122,7 @@ export async function writeNewProfile( * @param init1 The first plan (with newProfile). * @param opts Options for planInit (without local, which is read fresh). * @param journal The write journal, for error reporting. + * @param ref The typed ref from the input (not the merged config's ref). * @returns The second plan. */ export async function reproveFromDisk( @@ -130,25 +131,31 @@ export async function reproveFromDisk( init1: InitPlan, opts: InitOptions, journal: WriteJournal = [], + ref?: string, ): Promise { if (!init1.newProfile) throw new Error("reproveFromDisk: init1.newProfile is required"); const name = path.basename(path.dirname(init1.newProfile.path)); - // Build the ref from init1 if it had one - const ref = init1.ws.config.ref; - // Second planInit: profile exists on disk, no newProfile, no loaded, no recipe or target flag - const init2 = await planInit( - root, - { - forge: forgeArg, - profile: name, - ref, - // NO newProfile, NO loaded, NO recipe or target flag - }, - opts, - ); + // Use the typed ref, not init1.ws.config.ref (which is the merged config, including craftar.local.yaml) + let init2: InitPlan; + try { + init2 = await planInit( + root, + { + forge: forgeArg, + profile: name, + ref, + // NO newProfile, NO loaded, NO recipe or target flag + }, + opts, + ); + } catch (e) { + // Wrap EVERY error of the second planInit in NewProfileLateFailure + const msg = e instanceof Error ? e.message : String(e); + throw new NewProfileLateFailure(msg, journal); + } // Compare: same paths and same bytes const paths1 = new Set(init1.plan.files.map((f) => f.path)); diff --git a/test/new-profile-write.test.ts b/test/new-profile-write.test.ts index f6b6df1..ee335a0 100644 --- a/test/new-profile-write.test.ts +++ b/test/new-profile-write.test.ts @@ -114,4 +114,12 @@ describe("reproveFromDisk — step 4", () => { expect(e).toBeInstanceOf(NewProfileLateFailure); expect((e as Error).message).toBe("the Forge read back from disk does not plan what init proved in memory — a Forge file changed while init ran"); }); + it("an error the second planInit itself throws (a recipe removed between the steps) is a late failure too, with the journal", async () => { + const s = await setup(); + const journal = await writeNewProfile(s.forge, s.init); + await fs.rm(path.join(s.forge, "recipes", "stack-node.yaml")); + const e = await reproveFromDisk(s.ws, s.forge, s.init, {}, journal).catch((x) => x); + expect(e).toBeInstanceOf(NewProfileLateFailure); + expect((e as NewProfileLateFailure).journal).toBe(journal); + }); }); From aeb8e6aa227391abc0b16bd1525b5a8918ce72ac Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 20:59:52 -0300 Subject: [PATCH 16/26] fix(init): a new profile's notes come from step 1, craftar.yaml failures after the profile print the undo (review) The second plan has no recipe flag or template, so its notes were always empty; spec 22's notes and the integrations note were lost. N1's text now has one spelling, and EEXIST/mkdir after the profile write print N1 then the late-failure lines. The --add/--remove-recipe help reads as the spec words it. --- src/cli.ts | 44 ++++++++++++++++++++++--------- test/init-new-profile-cli.test.ts | 28 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 7705df5..0c7ae9d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -338,8 +338,8 @@ program .option("--like ", "with --new-profile: start the new profile's recipes and targets from this profile's — nothing else of it is copied") .option("--ref ", "branch, tag or full SHA of a remote Forge") .option("--targets ", "targets to write in craftar.yaml, or of the new profile with --new-profile (claude-code, kiro, agents-md); omitted, the workspace follows the profile's") - .option("--add-recipe ", "add a recipe to the profile's, or of the new profile with --new-profile (repeatable)", collect, []) - .option("--remove-recipe ", "remove a recipe of the profile's, or of the new profile with --new-profile (repeatable)", collect, []) + .option("--add-recipe ", "add a recipe to the profile's, or to the new profile's with --new-profile (repeatable)", collect, []) + .option("--remove-recipe ", "remove a recipe from the profile's, or from the new profile's with --new-profile (repeatable)", collect, []) .option("--replace", "let --add-recipe take a slot another recipe holds", false) .option("--no-sync", "write craftar.yaml only, and say what the first sync would do") .option("--clone", "after the first sync, clone the profile's repositories (its repos list) into the workspace") @@ -364,7 +364,8 @@ program }, cmd) => { const root = path.resolve(o.workspace); // N1: craftar.yaml already exists - const n1 = () => fail(`${WORKSPACE_FILE} already exists in ${root} — change recipes with craftar add recipe / remove recipe, or edit it`); + const n1Text = `${WORKSPACE_FILE} already exists in ${root} — change recipes with craftar add recipe / remove recipe, or edit it`; + const n1 = () => fail(n1Text); const file = path.join(root, WORKSPACE_FILE); if (await exists(file)) n1(); // N9: --workspace is a file, or any ancestor up to an existing path is a file @@ -524,6 +525,8 @@ program }; // When init.newProfile: write the profile, then reprove from disk let journal: WriteJournal = []; + // Step 1's notes are printed after the initLine line (spec 38), so keep them before reprove + const step1Notes = init.notes; if (init.newProfile) { try { journal = await writeNewProfile(forgeRoot, init); @@ -539,7 +542,7 @@ program } // Reprove from disk (step 4) try { - init = await reproveFromDisk(root, input.forge, init, { ...load(o, "sync") }, journal); + init = await reproveFromDisk(root, input.forge, init, { ...load(o, "sync") }, journal, input.ref); } catch (e) { if (e instanceof NewProfileLateFailure) { fail(lateFailure(e, forgeRoot, e.journal, "init")); @@ -554,20 +557,35 @@ program console.log(` + profiles/${ws.config.profile}/profile.yaml (not committed — commit it in the Forge, on a branch, before anyone else syncs)`); } // Every refusal is above: only now does the directory, and craftar.yaml, come to exist - await fs.mkdir(root, { recursive: true }); - try { - await fs.writeFile(file, init.text, { flag: "wx" }); - } catch (e) { - if ((e as NodeJS.ErrnoException).code === "EEXIST") n1(); - // Any other error after the profile was written: late failure (journal.length > 0 means a profile was written) - if (journal.length > 0) { + // When journal is not empty: mkdir inside the try, with late failure handling + if (journal.length > 0) { + try { + await fs.mkdir(root, { recursive: true }); + } catch (e) { + fail(lateFailure(e, forgeRoot, journal, "init")); + } + try { + await fs.writeFile(file, init.text, { flag: "wx" }); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "EEXIST") { + // N1's text, then lateFailure + fail(`${n1Text}\n${lateFailure(e, forgeRoot, journal, "init")}`); + } fail(lateFailure(e, forgeRoot, journal, "init")); } - throw e; + } else { + // No journal: existing behaviour unchanged + await fs.mkdir(root, { recursive: true }); + try { + await fs.writeFile(file, init.text, { flag: "wx" }); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "EEXIST") n1(); + throw e; + } } console.log(pc.bold(`craftar init — wrote ${WORKSPACE_FILE} in ${root}`)); console.log(` ${initLine(init)}`); - for (const n of init.notes) console.log(` note ${n}`); + for (const n of step1Notes) console.log(` note ${n}`); const unset = unsetDeclared(p); const cloning = cloneEntries.length > 0; const refused = unset.length > 0; diff --git a/test/init-new-profile-cli.test.ts b/test/init-new-profile-cli.test.ts index b82d26b..d90b03f 100644 --- a/test/init-new-profile-cli.test.ts +++ b/test/init-new-profile-cli.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { listFiles } from "../src/core/forge.js"; import { runCli } from "./helpers/cli.js"; import { makeForge, profile, recipe, rule, tmpDir, type ForgeSpec } from "./helpers/forge.js"; +import YAML from "yaml"; import { git, gitEnv, remoteForge } from "./helpers/remote.js"; // Spec 38 §4.1–§4.6 through the command. Forges are local git repositories with a commit; no network, no push. @@ -172,6 +173,33 @@ describe("the run (§4.4–§4.5)", () => { }); }); +describe("review round 1 (spec 38)", () => { + it("the notes of step 1 are printed after the initLine line: spec 22's and the integrations block's", async () => { + const spec: ForgeSpec = { + ...SPEC, + recipes: [...(SPEC.recipes ?? []), recipe("pm-jira", ["rule/a"])], + profiles: [profile("acme", ["base", "stack-node"], ["claude-code"], { integrations: { pm: { kind: "jira" } } })], + }; + const s = await setup(spec); + await fs.writeFile(path.join(s.forge, "craftar.forge.yaml"), YAML.stringify({ name: "test-forge", schema: 3 })); + git(s.forge, "commit", "-q", "-am", "schema 3"); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "initech", "--like", "acme", "--add-recipe", "base", "--workspace", s.ws]); + expect(r.code, r.stderr).toBe(0); + const lines = r.stdout.split("\n"); + const at = lines.findIndex((l) => l.startsWith(" forge ../forge · profile initech")); + expect(lines[at + 1]).toMatch(/^ note .*base/); + expect(lines[at + 2]).toBe(" note integrations of acme not copied — 1 recipe it selects is not in initech"); + }); + it("a ref in craftar.local.yaml: init --new-profile behaves as init --profile — it succeeds, and craftar.yaml holds no ref", async () => { + const s = await setup(); + await fs.mkdir(s.ws); + await fs.writeFile(path.join(s.ws, "craftar.local.yaml"), "ref: some-branch\n"); + const r = s.run(["init", "--forge", s.forge, "--new-profile", "globex", "--no-sync", "--workspace", s.ws]); + expect(r.code, r.stderr).toBe(0); + expect(await fs.readFile(path.join(s.ws, "craftar.yaml"), "utf8")).toBe("forge: ../forge\nprofile: globex\n"); + }); +}); + describe("late failures (§4.6) — not run on Windows (chmod), reported as such", () => { it.skipIf(WIN || process.getuid?.() === 0)("craftar.yaml cannot be written after the profile was: the late-failure lines with clean -fd; running it restores the Forge", async () => { const s = await setup(); From 59095a2ded0f413ae05be38542cfd7994fc96a87 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 20:59:52 -0300 Subject: [PATCH 17/26] fix(init): the interactive new profile honours typed flags, re-asks a Forge it cannot write, asks replace, refuses an unknown --like up front (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draft goes through ProfileSchema (a schema 3 Forge no longer throws a raw TypeError); P10 is found with ownedBy and worded once (ownedRecipeRefusal). test/init-flow.test.ts case 9 now expects the re-ask spec 38 §4.3 asks for. --- src/core/init-flow.ts | 311 ++++++++++++++++++----------- src/core/new-profile.ts | 9 +- test/init-flow.test.ts | 18 +- test/init-new-profile-flow.test.ts | 79 ++++++++ 4 files changed, 285 insertions(+), 132 deletions(-) diff --git a/src/core/init-flow.ts b/src/core/init-flow.ts index fc4dd6d..ec5b19f 100644 --- a/src/core/init-flow.ts +++ b/src/core/init-flow.ts @@ -1,10 +1,10 @@ import { existsSync } from "node:fs"; -import { TARGETS, type Registry, type RegistryEntry, type WorkspaceConfig } from "../schema/index.js"; +import { ProfileSchema, TARGETS, type Registry, type RegistryEntry, type WorkspaceConfig } from "../schema/index.js"; import { planClone } from "./clone.js"; import { countStates } from "./impact.js"; import { resolveHome } from "./home-lock.js"; import { checkInitFlags, checkLocalKeys, forgeSource, initLine, type InitInput, type InitPlan } from "./init.js"; -import { newProfile, ownedBy, profileName, withProfile } from "./new-profile.js"; +import { newProfile, ownedBy, ownedRecipeRefusal, profileName, withProfile } from "./new-profile.js"; import { newProfileGate } from "./new-profile-gate.js"; import { planRecipeEdit, slotHeld } from "./recipe-edit.js"; import { readRegistry } from "./registry.js"; @@ -403,6 +403,12 @@ export async function askInit( } } forgeDefault = forgeValue; + + // P4 check: with --new-profile typed and Forge asked, a URL answer is refused — say P4 and ask again + if (given.newProfile !== undefined && classifyForge(forgeValue) === "url") { + io.say("a new profile is written into a Forge directory — clone the Forge, pass that directory as --forge, then commit and push the profile"); + continue forgeLoop; + } } answeredForge = forgeValue; @@ -467,6 +473,34 @@ export async function askInit( throw e; } } + + // P5/P6 check: with --new-profile typed and Forge asked, check if the Forge can take a new profile + if (given.newProfile !== undefined && forgeWasAsked) { + try { + await newProfileGate(loaded.forge, home); + } catch (e) { + io.say((e as Error).message); + forgeDefault = forgeValue; + refDefault = answeredRef ?? refDefault; + continue forgeLoop; + } + } + + // P5/P6 check: with no profile given and no profiles in the Forge, we'll be forced to create a new profile + // If the Forge was asked and it can't take a new profile, say the error and ask again + if (given.profile === undefined && given.newProfile === undefined && loaded.forge.profiles.size === 0 && forgeWasAsked) { + if (classifyForge(forgeValue) !== "url") { + try { + await newProfileGate(loaded.forge, home); + } catch (e) { + io.say((e as Error).message); + forgeDefault = forgeValue; + refDefault = answeredRef ?? refDefault; + continue forgeLoop; + } + } + } + break forgeLoop; } @@ -511,6 +545,10 @@ export async function askInit( // Handle given.newProfile: validate with P3 and P7/P8 (typed value is not re-asked) if (given.newProfile !== undefined) { profileName(given.newProfile); // P3: throws on failure + // P9 up front: a typed --like the Forge does not have is refused before any question + if (given.like !== undefined && !forge.profiles.has(given.like)) { + throw new Error("--like: " + profileNotFoundMessage(given.like, [...forge.profiles.keys()])); + } await newProfileGate(forge, home, given.newProfile); // P7/P8: throws on failure isNewProfile = true; newProfileName = given.newProfile; @@ -646,125 +684,153 @@ export async function askInit( let finalRemoveRecipes: string[] = []; let finalReplace = false; - // Track whether we should start with Remove (after a P10 error) - let startWithRemove = false; + // Typed flags answer their questions (spec 38 §4.2, §4.3) + const recipesFlagged = given.addRecipes.length > 0 || given.removeRecipes.length > 0 || given.replace; + if (recipesFlagged) { + // Use the flags directly, skip all recipe questions + finalAddRecipes = given.addRecipes; + finalRemoveRecipes = given.removeRecipes; + finalReplace = given.replace; + } else { + // Track whether we should start with Remove (after a P10 error) + let startWithRemove = false; - recipeLoop: for (;;) { - // Compute the draft profile in a copy of the Forge for the recipes line - const template = newProfileLike ? forge.profiles.get(newProfileLike) : undefined; - const draftRecipes = template?.recipes ?? []; - - if (template && !startWithRemove) { - // With a template: show the chain resolved from the draft profile - const draft = { name: newProfileName!, recipes: draftRecipes, targets: template.targets }; - const copy = withProfile(forge, draft as any); - // Use mergeWorkspaceConfig to get a valid config with overrides - const config = mergeWorkspaceConfig( - { forge: forgeSource(root, answeredForge!), profile: newProfileName! }, - null - ).config; - const resolution = resolve(copy, config); - io.say(`Recipes of ${newProfileName} (from ${newProfileLike}): ${resolution.recipes.join(" → ")}`); + recipeLoop: for (;;) { + // Compute the draft profile in a copy of the Forge for the recipes line + const template = newProfileLike ? forge.profiles.get(newProfileLike) : undefined; + const draftRecipes = template?.recipes ?? []; - // Adjust the recipes [no]: - const adjustAns = await io.ask("Adjust the recipes [no]: "); - if (adjustAns === null) return { kind: "cancelled" }; - const yn = yesNo(adjustAns); - if (adjustAns.trim() !== "" && yn === null) { - io.say("answer yes or no"); - continue recipeLoop; - } - if (yn !== true) { - // No adjustment: check P10 on the template's recipes before accepting - try { - newProfile(forge, { - name: newProfileName!, - like: newProfileLike, - add: [], - remove: [], - }); + if (template && !startWithRemove) { + // With a template: show the chain resolved from the draft profile + const draft = ProfileSchema.parse({ name: newProfileName!, recipes: draftRecipes, targets: template.targets }); + const copy = withProfile(forge, draft); + // Use mergeWorkspaceConfig to get a valid config with overrides + const config = mergeWorkspaceConfig( + { forge: forgeSource(root, answeredForge!), profile: newProfileName! }, + null + ).config; + const resolution = resolve(copy, config); + io.say(`Recipes of ${newProfileName} (from ${newProfileLike}): ${resolution.recipes.join(" → ")}`); + + // Adjust the recipes [no]: + const adjustAns = await io.ask("Adjust the recipes [no]: "); + if (adjustAns === null) return { kind: "cancelled" }; + const yn = yesNo(adjustAns); + if (adjustAns.trim() !== "" && yn === null) { + io.say("answer yes or no"); + continue recipeLoop; + } + if (yn !== true) { + // No adjustment: check P10 on the template's recipes before accepting, using ownedBy + const templateRecipes = template.recipes; + for (const r of templateRecipes) { + const owner = ownedBy(forge, r); + if (owner !== null) { + io.say(ownedRecipeRefusal(r, owner)); + startWithRemove = true; + continue recipeLoop; + } + } // Success: use template's recipes as-is break recipeLoop; - } catch (e) { - const msg = (e as Error).message; - // P10: say it and re-run recipes step opening on Remove - if (msg.includes("is named for profile")) { - io.say(msg); - startWithRemove = true; - continue recipeLoop; - } - // Other errors - io.say(msg); - continue recipeLoop; } } - } - // Show the Forge's recipes list - io.say(`Recipes in ${answeredForge}:`); - const allRecipes = [...forge.recipes.values()].sort((a, b) => a.name.localeCompare(b.name)); - for (let i = 0; i < allRecipes.length; i++) { - const r = allRecipes[i]; - const parts = [` ${i + 1}) ${r.name}`]; - if (draftRecipes.includes(r.name)) parts.push("· in use"); - if (r.slot) parts.push(`· slot ${r.slot}`); - if (r.description) parts.push(`· ${r.description}`); - io.say(parts.join(" ")); - } + // Show the Forge's recipes list + io.say(`Recipes in ${answeredForge}:`); + const allRecipes = [...forge.recipes.values()].sort((a, b) => a.name.localeCompare(b.name)); + for (let i = 0; i < allRecipes.length; i++) { + const r = allRecipes[i]; + const parts = [` ${i + 1}) ${r.name}`]; + if (draftRecipes.includes(r.name)) parts.push("· in use"); + if (r.slot) parts.push(`· slot ${r.slot}`); + if (r.description) parts.push(`· ${r.description}`); + io.say(parts.join(" ")); + } - const names = allRecipes.map((r) => r.name); - const parseRecipeAnswer = (answer: string): string[] => - answer.split(",").map((s) => s.trim()).filter((s) => s !== "").map((s) => pickItem(names, s) ?? s); + const names = allRecipes.map((r) => r.name); + const parseRecipeAnswer = (answer: string): string[] => + answer.split(",").map((s) => s.trim()).filter((s) => s !== "").map((s) => pickItem(names, s) ?? s); - // Remove question (only with a template, or after P10 error) - let removeNames: string[] = []; - if (template || startWithRemove) { - const removeAns = await io.ask("Remove [none]: "); - if (removeAns === null) return { kind: "cancelled" }; - removeNames = parseRecipeAnswer(removeAns); - } + // Remove question (only with a template, or after P10 error) + let removeNames: string[] = []; + if (template || startWithRemove) { + const removeAns = await io.ask("Remove [none]: "); + if (removeAns === null) return { kind: "cancelled" }; + removeNames = parseRecipeAnswer(removeAns); + } - // Add question - const addAns = await io.ask("Add [none]: "); - if (addAns === null) return { kind: "cancelled" }; - const addNames = parseRecipeAnswer(addAns); + // Add question + const addAns = await io.ask("Add [none]: "); + if (addAns === null) return { kind: "cancelled" }; + let addNames = parseRecipeAnswer(addAns); - // Reset startWithRemove for the next iteration - startWithRemove = false; + // Reset startWithRemove for the next iteration + startWithRemove = false; - if (removeNames.length === 0 && addNames.length === 0) { - // If no changes, check P10 one more time (for no template case) - if (!template) { + if (removeNames.length === 0 && addNames.length === 0) { + // If no changes, check P10 one more time (for no template case), using ownedBy + if (!template) { + break recipeLoop; + } + // With template and no changes: P10 was already checked above when we declined to adjust break recipeLoop; } - // With template and no changes: P10 was already checked above when we declined to adjust - break recipeLoop; - } - // Check for P10: compute newProfile and catch owned recipe errors - try { - newProfile(forge, { - name: newProfileName!, - like: newProfileLike, - add: addNames, - remove: removeNames, - replace: finalReplace, - }); - // Success: save the answers - finalAddRecipes = addNames; - finalRemoveRecipes = removeNames; - break recipeLoop; - } catch (e) { - const msg = (e as Error).message; - // P10: "recipe ... is named for profile ..." → say it and re-run recipes step opening on Remove - if (msg.includes("is named for profile")) { - io.say(msg); - startWithRemove = true; - continue recipeLoop; + // Check for held slot and ask replace question (spec 28's held-slot question) + let doReplace = false; + addSlotLoop: for (;;) { + try { + newProfile(forge, { + name: newProfileName!, + like: newProfileLike, + add: addNames, + remove: removeNames, + replace: doReplace, + }); + // Success + finalAddRecipes = addNames; + finalRemoveRecipes = removeNames; + finalReplace = doReplace; + break recipeLoop; + } catch (e) { + // Check for P10 using ownedBy on the final list + const finalList = [ + ...(newProfileLike ? forge.profiles.get(newProfileLike)!.recipes : []).filter((r) => !removeNames.includes(r)), + ...addNames, + ]; + for (const r of finalList) { + const owner = ownedBy(forge, r); + if (owner !== null) { + io.say(ownedRecipeRefusal(r, owner)); + startWithRemove = true; + continue recipeLoop; + } + } + // Check for held slot + const held = slotHeld(e); + if (held !== null && !doReplace) { + const replaceQ = `${held.recipe} takes the slot "${held.slot}" that ${held.holder} holds — replace ${held.holder} [no]: `; + let replaceYn: boolean | null; + for (;;) { + const replaceAns = await io.ask(replaceQ); + if (replaceAns === null) return { kind: "cancelled" }; + replaceYn = replaceAns.trim() === "" ? false : yesNo(replaceAns); + if (replaceYn !== null) break; + io.say("answer yes or no"); + } + if (replaceYn) { + doReplace = true; + continue addSlotLoop; + } + // User said no to replace, re-ask recipes + continue recipeLoop; + } + // Other errors: say and re-run + io.say((e as Error).message); + continue recipeLoop; + } } - // Other errors: say and re-run - io.say(msg); - continue recipeLoop; } } @@ -773,25 +839,30 @@ export async function askInit( answeredReplace = finalReplace; // Targets question for new profile - // Default: [, from ] or [claude-code, default] - const template = newProfileLike ? forge.profiles.get(newProfileLike) : undefined; - const defaultTargets = template?.targets ?? ["claude-code"]; - const fromText = template ? `from ${newProfileLike}` : "default"; - const targetsQ = `Targets — ${TARGETS.join(", ")}, separated by commas [${defaultTargets.join(", ")}, ${fromText}]: `; - targetsLoop: for (;;) { - const answer = await io.ask(targetsQ); - if (answer === null) return { kind: "cancelled" }; - const trimmed = answer.trim(); - if (trimmed === "") break targetsLoop; - const parsed = trimmed.split(",").map((s) => s.trim()); - try { - checkInitFlags({ targets: parsed }); - } catch (e) { - io.say((e as Error).message); + // Typed flags answer their questions (spec 38 §4.2, §4.3) + if (given.targets !== undefined) { + answeredTargets = given.targets; + } else { + // Default: [, from ] or [claude-code, default] + const template = newProfileLike ? forge.profiles.get(newProfileLike) : undefined; + const defaultTargets = template?.targets ?? ["claude-code"]; + const fromText = template ? `from ${newProfileLike}` : "default"; + const targetsQ = `Targets — ${TARGETS.join(", ")}, separated by commas [${defaultTargets.join(", ")}, ${fromText}]: `; + targetsLoop: for (;;) { + const answer = await io.ask(targetsQ); + if (answer === null) return { kind: "cancelled" }; + const trimmed = answer.trim(); + if (trimmed === "") break targetsLoop; + const parsed = trimmed.split(",").map((s) => s.trim()); + try { + checkInitFlags({ targets: parsed }); + } catch (e) { + io.say((e as Error).message); continue targetsLoop; } answeredTargets = parsed; break targetsLoop; + } } // Build the result for new profile diff --git a/src/core/new-profile.ts b/src/core/new-profile.ts index f21601a..26fc58c 100644 --- a/src/core/new-profile.ts +++ b/src/core/new-profile.ts @@ -57,6 +57,11 @@ export function withProfile(forge: Forge, profile: Profile): Forge { return { ...forge, profiles }; } +/** P10's one wording (spec 38 §4.2): a recipe named for another profile of the Forge, in a new profile's list. */ +export function ownedRecipeRefusal(recipe: string, owner: string): string { + return `recipe ${recipe} is named for profile ${owner} — a new profile cannot use it; leave it out and add the shared recipe it stands for`; +} + /** Options for newProfile (spec 38 §4.4). */ export interface NewProfileOptions { name: string; @@ -142,9 +147,7 @@ export function newProfile(forge: Forge, o: NewProfileOptions): NewProfileResult for (const r of finalRecipes) { const owner = ownedBy(forge, r); if (owner !== null) { - throw new Error( - `recipe ${r} is named for profile ${owner} — a new profile cannot use it; leave it out and add the shared recipe it stands for`, - ); + throw new Error(ownedRecipeRefusal(r, owner)); } } diff --git a/test/init-flow.test.ts b/test/init-flow.test.ts index 1cf7c9d..d78b538 100644 --- a/test/init-flow.test.ts +++ b/test/init-flow.test.ts @@ -861,15 +861,15 @@ describe("askInit", () => { const s = await askSetup(specNoProfile); const given: InitGiven = { addRecipes: [], removeRecipes: [], replace: false }; const local = { doc: null, keys: [] as LocalKey[] }; - const sc = script([s.forge]); - // Spec 38 §4.3, §8: a Forge directory with no profile that is NOT a git repository - // now throws P6's text instead of the old "the Forge has no profile" message. - // This is because a Forge directory with no profile goes to the new-profile flow, - // which requires the Forge to be a git repository (P6). - await expect( - askInit(s.ws, given, local, sc.io, { home: s.home, registryOff: false, exists: existsSync }) - ).rejects.toThrow(`cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`); - expect(sc.asked).toEqual([QF]); + // Spec 38 §4.3: a Forge directory with no profile that fails P6, Forge asked: + // say P6's text ONCE and ask the Forge again. With no --forge given, this continues. + // Provide the bad Forge once, then null to cancel when asked again. + const sc = script([s.forge, null as any]); + const result = await askInit(s.ws, given, local, sc.io, { home: s.home, registryOff: false, exists: existsSync }); + expect(result.kind).toBe("cancelled"); + expect(sc.said).toContain(`cannot create a profile in ${s.forge}: it is not a git repository with a commit — git is how a Forge write is undone`); + // Forge was asked once, P6 said, Forge asked again with default, user cancelled + expect(sc.asked.filter(q => q.startsWith("Forge")).length).toBe(2); // given.profile = "nope" - use a Forge WITH a profile (SPEC_DESC) to get proper error const s2 = await askSetup(SPEC_DESC); diff --git a/test/init-new-profile-flow.test.ts b/test/init-new-profile-flow.test.ts index dd6acbb..6f19d3c 100644 --- a/test/init-new-profile-flow.test.ts +++ b/test/init-new-profile-flow.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { againLine, askInit, confirmInit, type InitIo } from "../src/core/init-flow.js"; import { planInit } from "../src/core/init.js"; +import YAML from "yaml"; import { makeForge, profile, recipe, rule, tmpDir, type ForgeSpec } from "./helpers/forge.js"; import { git, remoteForge } from "./helpers/remote.js"; @@ -176,3 +177,81 @@ describe("confirmInit and againLine with a new profile (§4.3)", () => { ).toBe(" again craftar init --forge ../forge --profile initech"); }); }); + +describe("review round 1 (spec 38 §4.2, §4.3)", () => { + it("a template in a Forge at schema 3: the draft goes through ProfileSchema — no raw TypeError", async () => { + const spec: ForgeSpec = { + ...SPEC, + recipes: [...(SPEC.recipes ?? []), recipe("pm-jira", ["rule/a"])], + profiles: [profile("acme", ["base"], ["claude-code"], { integrations: { pm: { kind: "jira" } } })], + }; + const s = await setup({ spec }); + await fs.writeFile(path.join(s.forge, "craftar.forge.yaml"), YAML.stringify({ name: "test-forge", schema: 3 })); + git(s.forge, "commit", "-q", "-am", "schema 3"); + const sc = script(["", ""]); + const r = await ask(s, { forge: s.forge, newProfile: "initech", like: "acme" }, sc.io); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.newProfile).toEqual({ name: "initech", like: "acme" }); + expect(sc.said).toContain("Recipes of initech (from acme): base"); + }); + it("flags typed with --new-profile answer their questions: nothing is asked for them", async () => { + const s = await setup(); + const sc = script([]); + const r = await ask(s, { forge: s.forge, newProfile: "initech", like: "acme", addRecipes: ["extra"], targets: ["kiro"] }, sc.io); + expect(sc.asked).toEqual([]); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.addRecipes).toEqual(["extra"]); + expect(r.input.targets).toEqual(["kiro"]); + }); + it("a typed --like the Forge does not have is refused before any question (P9)", async () => { + const s = await setup(); + await expect(ask(s, { forge: s.forge, newProfile: "initech", like: "nope" }, script([]).io)).rejects.toThrow('--like: profile "nope" not found in Forge (acme, globex)'); + }); + it("--new-profile typed, the Forge asked: a URL answer is P4, said and asked again — nothing fetched", async () => { + const rf = await remoteForge(SPEC); + cleanups.push(rf.cleanup); + const s = await setup(); + const sc = script([rf.url, s.forge, "", "", ""]); + const r = await ask(s, { newProfile: "initech" }, sc.io); + expect(sc.said).toContain("a new profile is written into a Forge directory — clone the Forge, pass that directory as --forge, then commit and push the profile"); + expect(sc.asked.filter((q) => q.startsWith("Forge")).length).toBe(2); + expect(await fs.lstat(path.join(s.home, "forges")).then(() => true, () => false)).toBe(false); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.forge).toBe(s.forge); + }); + it("--new-profile typed, the Forge asked: a directory that is not a git repository is said and asked again", async () => { + const bad = await setup({ git: false }); + const s = await setup(); + const sc = script([bad.forge, s.forge, "", "", ""]); + const r = await ask(s, { newProfile: "initech" }, sc.io); + const p6 = `cannot create a profile in ${bad.forge}: it is not a git repository with a commit — git is how a Forge write is undone`; + expect(sc.said.filter((l) => l === p6).length).toBe(1); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.forge).toBe(s.forge); + }); + it("a Forge directory with no profile that fails P6, the Forge asked: said once, and the Forge asked again", async () => { + const bad = await setup({ spec: { ...SPEC, profiles: [] }, git: false }); + const s = await setup({ spec: { ...SPEC, profiles: [] } }); + const sc = script([bad.forge, s.forge, "initech", "", ""]); + const r = await ask(s, {}, sc.io); + const p6 = `cannot create a profile in ${bad.forge}: it is not a git repository with a commit — git is how a Forge write is undone`; + expect(sc.said.filter((l) => l === p6).length).toBe(1); + expect(sc.said).toContain("the Forge has no profile yet — creating the first one"); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.newProfile).toEqual({ name: "initech" }); + }); + it("a held slot in the new profile's recipes asks `replace `, as spec 28 does", async () => { + const spec: ForgeSpec = { + ...SPEC, + recipes: [...(SPEC.recipes ?? []), recipe("front-a", ["rule/b"], { slot: "front" }), recipe("front-b", ["rule/c"], { slot: "front" })], + profiles: [profile("acme", ["base", "front-a"], ["claude-code"]), profile("globex", ["base"], ["kiro"])], + }; + const s = await setup({ spec }); + const sc = script(["yes", "", "front-b", "yes", ""]); + const r = await ask(s, { forge: s.forge, newProfile: "initech", like: "acme" }, sc.io); + expect(sc.asked).toContain('front-b takes the slot "front" that front-a holds — replace front-a [no]: '); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.addRecipes).toEqual(["front-b"]); + expect(r.input.replace).toBe(true); + }); +}); From e6d448e26b60140823face90029db5c77aaeeb52 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 20:59:52 -0300 Subject: [PATCH 18/26] fix(init): targetsFrom for a local targets, the gate's comments, the README status line names --new-profile (review) --- README.md | 2 +- src/core/init.ts | 5 ++++- src/core/new-profile-gate.ts | 6 ------ 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5837e2b..197318b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Craft, sync and convert AI-coding workspace harnesses — rules, agents, commands, skills, MCP servers — across every client workspace you maintain and every AI coder each team uses. -> Status: **phase 1 in progress** — phase 0, the foundations, is done. `import`, `sync`, `status`, `diff`, `explain`, `ls` work end-to-end for the `claude-code` and `kiro` targets and are validated byte-for-byte against a real workspace (see *Oracle*). The Forge commands `forge variants`, `forge diff` (with a suggested class per hunk) and `forge unify` (`--take base|variant`, `take: param` to turn a value into a `{{key}}` parameter, and `take: section` to turn a client block into a section) and `forge promote` (a profile's variant becomes the base) also work, covered by the unit and golden suites rather than the oracle, and `import` is render-aware: importing into an existing Forge reuses a base whose render equals the workspace file, and edits an existing profile and the recipes it owns in place. Ingredient bodies can hold client-specific **sections** (`` … ``), overridden by a profile's `sections` and a workspace's `overrides.sections`; import infers each client's content and `forge unify` can extract a section from a variant, and a Forge that uses them declares `schema: 2` or above (see *Forge layout*). The read-only catalogue — `recipes`, `ingredients` and `targets` with the capability matrix — works too, and a workspace can name its Forge by git URL with an optional `ref`, read from a per-machine cache, with a lock that records the ref, commit, recipes and targets it was generated from (see *Remote Forge*). `add recipe` / `remove recipe` change a workspace's recipe choice in `craftar.yaml`, refusing an edit that would not resolve. A writing `sync` records each workspace in a per-machine registry, and `craftar workspaces` lists them all with their status (see *Workspace registry*). `craftar forge impact` lists the workspaces registered on this machine that read a Forge (by path, by a git remote of the clone, or through another clone of the same remote) with what their next sync would do against it, and `forge unify` plans them before and after its writes, names the ones its warnings concern, and with `--prune-recipes` deletes a recipe left identical to its sibling once every one of them plans the same. `craftar doctor` reports, one line per finding, what is off on this machine and in this workspace. `craftar cache prune` removes the cache entries and trees nothing uses. An MCP ingredient can declare `authEnv`, the environment variables its server needs (names only, never a value), and `craftar doctor` checks them; with `claude-code`, `sync` writes an example file listing them. A recipe can bring **local services**: a `service` ingredient is a Docker Compose fragment that `sync` writes under `.craftar/`, and `craftar services up | down | status` run Compose on what the lock records (see *Local services*). A profile's `integrations` block can select recipes and give params in a Forge that declares `schema: 3` or above (see *Integrations*), since `0.27.0`. `craftar templates list | install` shows and runs the profile's `templates` block — `install` runs `dotnet new install` per entry into the user's template store; nothing installs implicitly. An MCP `server` takes the params its ingredient declares, in a Forge that declares `schema: 4` (see *Server params*), since `0.28.0`. `craftar mcp` serves a workspace to an AI coder over the Model Context Protocol — seven reading tools with the data of `status`, `diff`, `explain`, `recipes`, `ingredients`, `targets` and `doctor`; it reads only. `craftar ui` serves a local, read-only dashboard of every registered workspace — the table of `craftar workspaces`, and for one workspace its file states, a file's diff and explanation, and its Forge's recipes and ingredients — on `127.0.0.1` only. `craftar init --clone` and `craftar repos clone` clone a profile's repositories (its `repos` list) into the workspace, and `craftar repos` lists them. +> Status: **phase 1 in progress** — phase 0, the foundations, is done. `import`, `sync`, `status`, `diff`, `explain`, `ls` work end-to-end for the `claude-code` and `kiro` targets and are validated byte-for-byte against a real workspace (see *Oracle*). The Forge commands `forge variants`, `forge diff` (with a suggested class per hunk) and `forge unify` (`--take base|variant`, `take: param` to turn a value into a `{{key}}` parameter, and `take: section` to turn a client block into a section) and `forge promote` (a profile's variant becomes the base) also work, covered by the unit and golden suites rather than the oracle, and `import` is render-aware: importing into an existing Forge reuses a base whose render equals the workspace file, and edits an existing profile and the recipes it owns in place. Ingredient bodies can hold client-specific **sections** (`` … ``), overridden by a profile's `sections` and a workspace's `overrides.sections`; import infers each client's content and `forge unify` can extract a section from a variant, and a Forge that uses them declares `schema: 2` or above (see *Forge layout*). The read-only catalogue — `recipes`, `ingredients` and `targets` with the capability matrix — works too, and a workspace can name its Forge by git URL with an optional `ref`, read from a per-machine cache, with a lock that records the ref, commit, recipes and targets it was generated from (see *Remote Forge*). `add recipe` / `remove recipe` change a workspace's recipe choice in `craftar.yaml`, refusing an edit that would not resolve. A writing `sync` records each workspace in a per-machine registry, and `craftar workspaces` lists them all with their status (see *Workspace registry*). `craftar forge impact` lists the workspaces registered on this machine that read a Forge (by path, by a git remote of the clone, or through another clone of the same remote) with what their next sync would do against it, and `forge unify` plans them before and after its writes, names the ones its warnings concern, and with `--prune-recipes` deletes a recipe left identical to its sibling once every one of them plans the same. `craftar doctor` reports, one line per finding, what is off on this machine and in this workspace. `craftar cache prune` removes the cache entries and trees nothing uses. An MCP ingredient can declare `authEnv`, the environment variables its server needs (names only, never a value), and `craftar doctor` checks them; with `claude-code`, `sync` writes an example file listing them. A recipe can bring **local services**: a `service` ingredient is a Docker Compose fragment that `sync` writes under `.craftar/`, and `craftar services up | down | status` run Compose on what the lock records (see *Local services*). A profile's `integrations` block can select recipes and give params in a Forge that declares `schema: 3` or above (see *Integrations*), since `0.27.0`. `craftar templates list | install` shows and runs the profile's `templates` block — `install` runs `dotnet new install` per entry into the user's template store; nothing installs implicitly. An MCP `server` takes the params its ingredient declares, in a Forge that declares `schema: 4` (see *Server params*), since `0.28.0`. `craftar mcp` serves a workspace to an AI coder over the Model Context Protocol — seven reading tools with the data of `status`, `diff`, `explain`, `recipes`, `ingredients`, `targets` and `doctor`; it reads only. `craftar ui` serves a local, read-only dashboard of every registered workspace — the table of `craftar workspaces`, and for one workspace its file states, a file's diff and explanation, and its Forge's recipes and ingredients — on `127.0.0.1` only. `craftar init --clone` and `craftar repos clone` clone a profile's repositories (its `repos` list) into the workspace, and `craftar repos` lists them. `craftar init --new-profile` creates a profile in a Forge directory (one file, not committed) and starts the workspace on it. ## The idea in one paragraph diff --git a/src/core/init.ts b/src/core/init.ts index 6029326..997de0d 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -239,6 +239,9 @@ export async function planInit(root: string, input: InitInput, opts: InitOptions const notes = [...np.notes]; if (np.blockNote !== null) notes.push(np.blockNote); + // targetsFrom: "local" when the local file sets targets (no flag), else "profile" + const targetsFrom = input.targets !== undefined ? "flag" : local.includes("targets") ? "local" : "profile"; + return { ws, plan: p, @@ -246,7 +249,7 @@ export async function planInit(root: string, input: InitInput, opts: InitOptions statuses, text: YAML.stringify(doc), notes, - targetsFrom: "profile", + targetsFrom, repos, newProfile: { path: `profiles/${np.profile.name}/profile.yaml`, text: np.text, blockNote: np.blockNote }, }; diff --git a/src/core/new-profile-gate.ts b/src/core/new-profile-gate.ts index dd71f7c..4887051 100644 --- a/src/core/new-profile-gate.ts +++ b/src/core/new-profile-gate.ts @@ -93,15 +93,9 @@ export async function newProfileGate(forge: Forge, home: string, name?: string): * Whether `child` is under `parent` (or equal), segment-wise — `path.relative` compares without case on win32, as `samePath` does. */ function isUnderOrEqual(child: string, parent: string): boolean { - // Get the relative path from parent to child const rel = path.relative(parent, child); - // If relative path is empty, they are equal if (rel === "") return true; - // If relative path starts with "..", child is not under parent if (rel === ".." || rel.startsWith(".." + path.sep)) return false; - // If relative path is absolute, they are on different drives (Windows) if (path.isAbsolute(rel)) return false; - // Otherwise, child is under parent — but we need to compare with samePath - // The relative path not starting with ".." means it's under parent return true; } From ac199d6f23b85d7cfccf797d1a020c3a4cfca276 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 21:10:08 -0300 Subject: [PATCH 19/26] fix(init): a new profile's targets read "from the profile" in the confirmation too (review) --- src/core/init.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index 997de0d..c3dbfc7 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -239,8 +239,8 @@ export async function planInit(root: string, input: InitInput, opts: InitOptions const notes = [...np.notes]; if (np.blockNote !== null) notes.push(np.blockNote); - // targetsFrom: "local" when the local file sets targets (no flag), else "profile" - const targetsFrom = input.targets !== undefined ? "flag" : local.includes("targets") ? "local" : "profile"; + // targetsFrom: "local" when the local file sets targets, else "profile" (typed targets go into the profile) + const targetsFrom = local.includes("targets") ? "local" : "profile"; return { ws, From b69ece8aef6776b792198d221721d007d690fd22 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 21:11:16 -0300 Subject: [PATCH 20/26] =?UTF-8?q?fix(init):=20a=20new=20profile's=20file?= =?UTF-8?q?=20reads=20back=20as=20the=20profile=20proved=20=E2=80=94=20nam?= =?UTF-8?q?es=20YAML=20would=20misread=20are=20quoted=20(review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/new-profile.ts | 26 ++++++++++++++++++++++++-- test/new-profile.test.ts | 15 +++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/core/new-profile.ts b/src/core/new-profile.ts index 26fc58c..9aa57df 100644 --- a/src/core/new-profile.ts +++ b/src/core/new-profile.ts @@ -3,12 +3,14 @@ * No fs, no process, no console — all validation and generation is deterministic. */ +import YAML from "yaml"; import { ProfileSchema, WorkspaceConfigSchema, type Profile } from "../schema/index.js"; import type { Forge } from "./forge.js"; import { effectiveProfile } from "./integrations.js"; import { namedForProfile } from "./promote.js"; import { planRecipeEdit } from "./recipe-edit.js"; import { profileNotFoundMessage } from "./resolve.js"; +import { parseYamlText } from "./yaml-read.js"; /** Windows device names (spec 38 §5.1 P3). */ const DEVICE_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; @@ -16,6 +18,15 @@ const DEVICE_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; /** Profile name grammar: lowercase letters and digits, in groups joined by one "-". */ const PROFILE_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; +/** + * YAML-safe a list item: when `YAML.stringify(value).trim()` differs from the plain value, + * use the stringified form (so `123`, `true`, `null` are quoted). + */ +function yamlSafe(value: string): string { + const stringified = YAML.stringify(value).trim(); + return stringified !== value ? stringified : value; +} + /** * P3: Validates a profile name. Throws with a quoted name on failure. * @param name The profile name to validate. @@ -166,15 +177,26 @@ export function newProfile(forge: Forge, o: NewProfileOptions): NewProfileResult } else { lines.push("recipes:"); for (const r of finalRecipes) { - lines.push(` - ${r}`); + lines.push(` - ${yamlSafe(r)}`); } } lines.push("targets:"); for (const t of targets) { - lines.push(` - ${t}`); + lines.push(` - ${yamlSafe(t)}`); } const text = lines.join("\n") + "\n"; + // Prove that text parses back to the profile — a programming error otherwise + const parsed = parseYamlText("generated profile", text); + const parsedProfile = ProfileSchema.parse(parsed); + if ( + parsedProfile.name !== profile.name || + JSON.stringify(parsedProfile.recipes) !== JSON.stringify(profile.recipes) || + JSON.stringify(parsedProfile.targets) !== JSON.stringify(profile.targets) + ) { + throw new Error(`newProfile: the generated text does not match the profile (programming error)`); + } + // 8. blockNote (only with a template) let blockNote: string | null = null; if (template) { diff --git a/test/new-profile.test.ts b/test/new-profile.test.ts index af3d264..967910c 100644 --- a/test/new-profile.test.ts +++ b/test/new-profile.test.ts @@ -186,3 +186,18 @@ describe("newProfile — a template's integrations block (§6 case 8, Ruling 3)" expect(r.blockNote).toBe("integrations of acme not copied — the block does not read, so what it selects is not known"); }); }); + +describe("newProfile — recipe names YAML would misread (review round 2)", () => { + it("a recipe named 123, true or null is written quoted and reads back as that string", async () => { + const forge = await forgeOf({ ...BASE_SPEC, recipes: [...(BASE_SPEC.recipes ?? []), recipe("123", ["rule/a"]), recipe("true", ["rule/b"]), recipe("null", ["rule/c"])] }); + const r = newProfile(forge, { name: "initech", add: ["123", "true", "null"] }); + const back = YAML.parse(r.text); + expect(back.recipes).toEqual(["123", "true", "null"]); + expect(ProfileSchema.parse(back).recipes).toEqual(r.profile.recipes); + expect(r.text).toContain(' - "123"\n'); + }); + it("plain names stay unquoted (the §4.4 bytes)", async () => { + const forge = await forgeOf(BASE_SPEC); + expect(newProfile(forge, { name: "initech", like: "acme" }).text).toContain(" - base\n - stack-node\n"); + }); +}); From 27ad68ae18066c31f789b8d12489680d04d48d2d Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 21:13:35 -0300 Subject: [PATCH 21/26] fix(init): the new profile's recipes step checks P10 on every exit and says spec 22's notes; a refused URL is not offered again (review) --- src/core/init-flow.ts | 115 +++++++++++++++++------------ test/init-new-profile-flow.test.ts | 47 ++++++++++++ 2 files changed, 116 insertions(+), 46 deletions(-) diff --git a/src/core/init-flow.ts b/src/core/init-flow.ts index ec5b19f..61ef0bb 100644 --- a/src/core/init-flow.ts +++ b/src/core/init-flow.ts @@ -402,13 +402,13 @@ export async function askInit( continue forgeLoop; } } - forgeDefault = forgeValue; // P4 check: with --new-profile typed and Forge asked, a URL answer is refused — say P4 and ask again if (given.newProfile !== undefined && classifyForge(forgeValue) === "url") { io.say("a new profile is written into a Forge directory — clone the Forge, pass that directory as --forge, then commit and push the profile"); continue forgeLoop; } + forgeDefault = forgeValue; } answeredForge = forgeValue; @@ -567,15 +567,7 @@ export async function askInit( } // For directory Forge with no profile: check if it can take a new profile if (!canOfferNewProfile) { - // The Forge was asked → say the error and ask for the Forge again - // --forge was typed → throw - if (forgeWasAsked) { - io.say(newProfileGateError!); - // Can't ask forge again from here - need to restructure or throw - throw new Error(newProfileGateError!); - } else { - throw new Error(newProfileGateError!); - } + throw new Error(newProfileGateError!); } io.say("the Forge has no profile yet — creating the first one"); isNewProfile = true; @@ -685,7 +677,7 @@ export async function askInit( let finalReplace = false; // Typed flags answer their questions (spec 38 §4.2, §4.3) - const recipesFlagged = given.addRecipes.length > 0 || given.removeRecipes.length > 0 || given.replace; + const recipesFlagged = given.addRecipes.length > 0 || given.removeRecipes.length > 0; if (recipesFlagged) { // Use the flags directly, skip all recipe questions finalAddRecipes = given.addRecipes; @@ -769,45 +761,54 @@ export async function askInit( startWithRemove = false; if (removeNames.length === 0 && addNames.length === 0) { - // If no changes, check P10 one more time (for no template case), using ownedBy - if (!template) { - break recipeLoop; + // No changes entered: check P10 on the final list (same as template's recipes) + if (template) { + for (const r of template.recipes) { + const owner = ownedBy(forge, r); + if (owner !== null) { + io.say(ownedRecipeRefusal(r, owner)); + startWithRemove = true; + continue recipeLoop; + } + } } - // With template and no changes: P10 was already checked above when we declined to adjust + // P10 passed or no template break recipeLoop; } - // Check for held slot and ask replace question (spec 28's held-slot question) + // Compute the recipe edit for notes and P10 check (mirroring existing flow) + // We do removes first, then adds — as init.ts and the existing flow do + const editConfig = mergeWorkspaceConfig( + { forge: forgeSource(root, answeredForge!), profile: newProfileName! }, + null + ).config; + let tempConfig = { ...editConfig, recipes: { add: [], remove: [] } }; + + // Simulate the edits on the template to get notes + const templateRecipes = newProfileLike ? forge.profiles.get(newProfileLike)!.recipes : []; + const draftProfile = ProfileSchema.parse({ name: newProfileName!, recipes: templateRecipes, targets: [] }); + const draftCopy = withProfile(forge, draftProfile); + + // Process removes, say notes + if (removeNames.length > 0) { + const removeEdit = planRecipeEdit(draftCopy, tempConfig, "remove", removeNames); + tempConfig = { ...tempConfig, recipes: removeEdit.recipes }; + if (removeEdit.reasons.length > 0) { + io.say(` note ${removeEdit.reasons.join(", ")}`); + } + } + + // Process adds, say notes, handle slot held let doReplace = false; addSlotLoop: for (;;) { try { - newProfile(forge, { - name: newProfileName!, - like: newProfileLike, - add: addNames, - remove: removeNames, - replace: doReplace, - }); - // Success - finalAddRecipes = addNames; - finalRemoveRecipes = removeNames; - finalReplace = doReplace; - break recipeLoop; - } catch (e) { - // Check for P10 using ownedBy on the final list - const finalList = [ - ...(newProfileLike ? forge.profiles.get(newProfileLike)!.recipes : []).filter((r) => !removeNames.includes(r)), - ...addNames, - ]; - for (const r of finalList) { - const owner = ownedBy(forge, r); - if (owner !== null) { - io.say(ownedRecipeRefusal(r, owner)); - startWithRemove = true; - continue recipeLoop; - } + const addEdit = planRecipeEdit(draftCopy, tempConfig, "add", addNames, { replace: doReplace }); + tempConfig = { ...tempConfig, recipes: addEdit.recipes }; + if (addEdit.reasons.length > 0) { + io.say(` note ${addEdit.reasons.join(", ")}`); } - // Check for held slot + break addSlotLoop; + } catch (e) { const held = slotHeld(e); if (held !== null && !doReplace) { const replaceQ = `${held.recipe} takes the slot "${held.slot}" that ${held.holder} holds — replace ${held.holder} [no]: `; @@ -831,6 +832,28 @@ export async function askInit( continue recipeLoop; } } + + // Compute the final list: template's recipes minus removes, plus adds + const finalList = [ + ...templateRecipes.filter((r) => !tempConfig.recipes.remove.includes(r)), + ...tempConfig.recipes.add, + ]; + + // P10 check on the final list + for (const r of finalList) { + const owner = ownedBy(forge, r); + if (owner !== null) { + io.say(ownedRecipeRefusal(r, owner)); + startWithRemove = true; + continue recipeLoop; + } + } + + // Success + finalAddRecipes = addNames; + finalRemoveRecipes = removeNames; + finalReplace = doReplace; + break recipeLoop; } } @@ -858,10 +881,10 @@ export async function askInit( checkInitFlags({ targets: parsed }); } catch (e) { io.say((e as Error).message); - continue targetsLoop; - } - answeredTargets = parsed; - break targetsLoop; + continue targetsLoop; + } + answeredTargets = parsed; + break targetsLoop; } } diff --git a/test/init-new-profile-flow.test.ts b/test/init-new-profile-flow.test.ts index 6f19d3c..6807a9d 100644 --- a/test/init-new-profile-flow.test.ts +++ b/test/init-new-profile-flow.test.ts @@ -255,3 +255,50 @@ describe("review round 1 (spec 38 §4.2, §4.3)", () => { expect(r.input.replace).toBe(true); }); }); + +describe("review round 2 (spec 38 §4.3)", () => { + it("a typed targets answer: the confirmation's line says (from the profile) — the targets went into it", async () => { + const s = await setup(); + const { loadForgeSource } = await import("../src/core/sync.js"); + const { forgeSource } = await import("../src/core/init.js"); + const loaded = await loadForgeSource(s.ws, forgeSource(s.ws, s.forge), null, { mode: "sync" }); + const init = await planInit(s.ws, { forge: s.forge, profile: "initech", newProfile: { name: "initech", like: "acme" }, targets: ["kiro"], loaded }); + const sc = script([""]); + await confirmInit(init, s.ws, sc.io, { sync: true }); + expect(sc.said[1]).toMatch(/targets kiro \(from the profile\)$/); + }); + it("P10 is checked whatever path leaves the recipes question — Adjust yes, then Enter at Remove and Add", async () => { + const spec = { ...SPEC, profiles: [profile("acme", ["base--acme", "stack-node"]), profile("globex", ["base"])] }; + const s = await setup({ spec }); + const sc = script(["yes", "", "", "base--acme", "base", ""]); + const r = await ask(s, { forge: s.forge, newProfile: "initech", like: "acme" }, sc.io); + expect(sc.said).toContain("recipe base--acme is named for profile acme — a new profile cannot use it; leave it out and add the shared recipe it stands for"); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.removeRecipes).toEqual(["base--acme"]); + }); + it("spec 22's notes are said at the recipes question, as the existing flow says them", async () => { + const s = await setup(); + const sc = script(["yes", "", "base", ""]); + await ask(s, { forge: s.forge, newProfile: "initech", like: "acme" }, sc.io); + expect(sc.said.some((l) => l.startsWith(" note ") && l.includes("base"))).toBe(true); + }); + it("the name is re-asked on P8 (git ignores the path)", async () => { + const s = await setup(); + await fs.writeFile(path.join(s.forge, ".gitignore"), "profiles/initech/\n"); + git(s.forge, "add", ".gitignore"); + git(s.forge, "commit", "-q", "-m", "ignore"); + const sc = script(["0", "initech", "other", "", "", ""]); + const r = await ask(s, { forge: s.forge }, sc.io); + expect(sc.said).toContain("cannot create profiles/initech/profile.yaml: git ignores it"); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.newProfile).toEqual({ name: "other" }); + }); + it("after a URL refused by P4 the Forge question offers no default — Enter is not that URL again", async () => { + const rf = await remoteForge(SPEC); + cleanups.push(rf.cleanup); + const s = await setup(); + const sc = script([rf.url, s.forge, "", "", ""]); + await ask(s, { newProfile: "initech" }, sc.io); + expect(sc.asked[1]).toBe("Forge — a directory or a git URL: "); + }); +}); From 11a66b18471f8635cd9d39c5631486c92862c609 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 21:14:11 -0300 Subject: [PATCH 22/26] refactor(init): the late-failure wraps word a raw error through errorText (review) --- src/core/new-profile-write.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/core/new-profile-write.ts b/src/core/new-profile-write.ts index e45abcf..cbd7903 100644 --- a/src/core/new-profile-write.ts +++ b/src/core/new-profile-write.ts @@ -6,6 +6,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { planInit, type InitOptions, type InitPlan } from "./init.js"; +import { errorText } from "./schema-fault.js"; import { note, type WriteJournal } from "./unify.js"; export { type WriteJournal }; @@ -106,8 +107,7 @@ export async function writeNewProfile( // NewProfileRaceLost passes through if (e instanceof NewProfileRaceLost) throw e; // Any other error: wrap in NewProfileLateFailure with the journal - const msg = e instanceof Error ? e.message : String(e); - throw new NewProfileLateFailure(msg, journal); + throw new NewProfileLateFailure(errorText(e), journal); } } @@ -153,8 +153,7 @@ export async function reproveFromDisk( ); } catch (e) { // Wrap EVERY error of the second planInit in NewProfileLateFailure - const msg = e instanceof Error ? e.message : String(e); - throw new NewProfileLateFailure(msg, journal); + throw new NewProfileLateFailure(errorText(e), journal); } // Compare: same paths and same bytes From a2fbf502346474fbb0676081d114fe364c998323 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 21:15:19 -0300 Subject: [PATCH 23/26] fix(init): type annotation for tempConfig.recipes (review) --- src/core/init-flow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/init-flow.ts b/src/core/init-flow.ts index 61ef0bb..5095e76 100644 --- a/src/core/init-flow.ts +++ b/src/core/init-flow.ts @@ -782,7 +782,7 @@ export async function askInit( { forge: forgeSource(root, answeredForge!), profile: newProfileName! }, null ).config; - let tempConfig = { ...editConfig, recipes: { add: [], remove: [] } }; + let tempConfig = { ...editConfig, recipes: { add: [] as string[], remove: [] as string[] } }; // Simulate the edits on the template to get notes const templateRecipes = newProfileLike ? forge.profiles.get(newProfileLike)!.recipes : []; From f2e59c7425135133237f1c8259439bcd00b80bb6 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 21:24:02 -0300 Subject: [PATCH 24/26] =?UTF-8?q?fix(init):=20a=20new=20profile's=20name?= =?UTF-8?q?=20line=20is=20YAML-safe=20too=20=E2=80=94=20123=20or=20null=20?= =?UTF-8?q?read=20back=20as=20the=20name=20(review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/new-profile.ts | 2 +- test/new-profile.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/core/new-profile.ts b/src/core/new-profile.ts index 9aa57df..235ef00 100644 --- a/src/core/new-profile.ts +++ b/src/core/new-profile.ts @@ -171,7 +171,7 @@ export function newProfile(forge: Forge, o: NewProfileOptions): NewProfileResult // Generate text: name: \n + recipes block + targets block const lines: string[] = []; - lines.push(`name: ${name}`); + lines.push(`name: ${yamlSafe(name)}`); if (finalRecipes.length === 0) { lines.push("recipes: []"); } else { diff --git a/test/new-profile.test.ts b/test/new-profile.test.ts index 967910c..24d9efb 100644 --- a/test/new-profile.test.ts +++ b/test/new-profile.test.ts @@ -201,3 +201,16 @@ describe("newProfile — recipe names YAML would misread (review round 2)", () = expect(newProfile(forge, { name: "initech", like: "acme" }).text).toContain(" - base\n - stack-node\n"); }); }); + +describe("newProfile — a profile name YAML would misread (review round 3)", () => { + it("--new-profile 123, null, true, 1e3: the name line is quoted and reads back as that string", async () => { + const forge = await forgeOf(BASE_SPEC); + for (const name of ["123", "null", "true", "1e3"]) { + const r = newProfile(forge, { name }); + const back = YAML.parse(r.text); + expect(back.name, name).toBe(name); + expect(r.profile.name).toBe(name); + } + expect(newProfile(forge, { name: "initech" }).text.startsWith("name: initech\n")).toBe(true); + }); +}); From 0a4d9839fd1b41a9fdeba8af125bc8520464524d Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 21:26:48 -0300 Subject: [PATCH 25/26] fix(init): the new profile's recipes step re-asks every refusal, N6 at the question; a local recipes or targets is said, not asked (review) --- src/core/init-flow.ts | 140 +++++++++++++++++------------ test/init-new-profile-flow.test.ts | 46 ++++++++++ 2 files changed, 130 insertions(+), 56 deletions(-) diff --git a/src/core/init-flow.ts b/src/core/init-flow.ts index 5095e76..7b8c4a9 100644 --- a/src/core/init-flow.ts +++ b/src/core/init-flow.ts @@ -683,6 +683,14 @@ export async function askInit( finalAddRecipes = given.addRecipes; finalRemoveRecipes = given.removeRecipes; finalReplace = given.replace; + } else if (local.keys.includes("recipes")) { + // local.keys includes recipes and no recipe flag typed: no recipes question; say the chain from the draft resolved with local recipes + const templateRecipes = newProfileLike ? forge.profiles.get(newProfileLike)!.recipes : []; + const draftProfile = ProfileSchema.parse({ name: newProfileName!, recipes: templateRecipes, targets: [] }); + const draftCopy = withProfile(forge, draftProfile); + const recipeConfig = { ...localConfig!, profile: newProfileName! }; + const resolution = resolve(draftCopy, recipeConfig); + io.say(`Recipes: ${resolution.recipes.join(" → ")} (from craftar.local.yaml)`); } else { // Track whether we should start with Remove (after a P10 error) let startWithRemove = false; @@ -776,6 +784,17 @@ export async function askInit( break recipeLoop; } + // N6 check: a name both added and removed — say and re-ask + const both = addNames.find((n) => removeNames.includes(n)); + if (both !== undefined) { + try { + checkInitFlags({ addRecipes: addNames, removeRecipes: removeNames }); + } catch (e) { + io.say((e as Error).message); + continue recipeLoop; + } + } + // Compute the recipe edit for notes and P10 check (mirroring existing flow) // We do removes first, then adds — as init.ts and the existing flow do const editConfig = mergeWorkspaceConfig( @@ -789,71 +808,76 @@ export async function askInit( const draftProfile = ProfileSchema.parse({ name: newProfileName!, recipes: templateRecipes, targets: [] }); const draftCopy = withProfile(forge, draftProfile); - // Process removes, say notes - if (removeNames.length > 0) { - const removeEdit = planRecipeEdit(draftCopy, tempConfig, "remove", removeNames); - tempConfig = { ...tempConfig, recipes: removeEdit.recipes }; - if (removeEdit.reasons.length > 0) { - io.say(` note ${removeEdit.reasons.join(", ")}`); + // The remove edit and add loop inside one outer try: any refusal → say the message and continue recipeLoop + try { + // Process removes, say notes + if (removeNames.length > 0) { + const removeEdit = planRecipeEdit(draftCopy, tempConfig, "remove", removeNames); + tempConfig = { ...tempConfig, recipes: removeEdit.recipes }; + if (removeEdit.reasons.length > 0) { + io.say(` note ${removeEdit.reasons.join(", ")}`); + } } - } - // Process adds, say notes, handle slot held - let doReplace = false; - addSlotLoop: for (;;) { - try { - const addEdit = planRecipeEdit(draftCopy, tempConfig, "add", addNames, { replace: doReplace }); - tempConfig = { ...tempConfig, recipes: addEdit.recipes }; - if (addEdit.reasons.length > 0) { - io.say(` note ${addEdit.reasons.join(", ")}`); - } - break addSlotLoop; - } catch (e) { - const held = slotHeld(e); - if (held !== null && !doReplace) { - const replaceQ = `${held.recipe} takes the slot "${held.slot}" that ${held.holder} holds — replace ${held.holder} [no]: `; - let replaceYn: boolean | null; - for (;;) { - const replaceAns = await io.ask(replaceQ); - if (replaceAns === null) return { kind: "cancelled" }; - replaceYn = replaceAns.trim() === "" ? false : yesNo(replaceAns); - if (replaceYn !== null) break; - io.say("answer yes or no"); + // Process adds, say notes, handle slot held + let doReplace = false; + addSlotLoop: for (;;) { + try { + const addEdit = planRecipeEdit(draftCopy, tempConfig, "add", addNames, { replace: doReplace }); + tempConfig = { ...tempConfig, recipes: addEdit.recipes }; + if (addEdit.reasons.length > 0) { + io.say(` note ${addEdit.reasons.join(", ")}`); } - if (replaceYn) { - doReplace = true; - continue addSlotLoop; + break addSlotLoop; + } catch (e) { + const held = slotHeld(e); + if (held !== null && !doReplace) { + const replaceQ = `${held.recipe} takes the slot "${held.slot}" that ${held.holder} holds — replace ${held.holder} [no]: `; + let replaceYn: boolean | null; + for (;;) { + const replaceAns = await io.ask(replaceQ); + if (replaceAns === null) return { kind: "cancelled" }; + replaceYn = replaceAns.trim() === "" ? false : yesNo(replaceAns); + if (replaceYn !== null) break; + io.say("answer yes or no"); + } + if (replaceYn) { + doReplace = true; + continue addSlotLoop; + } + // User said no to replace, re-ask recipes + continue recipeLoop; } - // User said no to replace, re-ask recipes - continue recipeLoop; + // Other errors (unknown recipe, etc.): re-throw to outer try + throw e; } - // Other errors: say and re-run - io.say((e as Error).message); - continue recipeLoop; } - } - // Compute the final list: template's recipes minus removes, plus adds - const finalList = [ - ...templateRecipes.filter((r) => !tempConfig.recipes.remove.includes(r)), - ...tempConfig.recipes.add, - ]; - - // P10 check on the final list - for (const r of finalList) { - const owner = ownedBy(forge, r); - if (owner !== null) { - io.say(ownedRecipeRefusal(r, owner)); - startWithRemove = true; - continue recipeLoop; + // Compute the final list: template's recipes minus removes, plus adds + const finalList = [ + ...templateRecipes.filter((r) => !tempConfig.recipes.remove.includes(r)), + ...tempConfig.recipes.add, + ]; + + // P10 check on the final list + for (const r of finalList) { + const owner = ownedBy(forge, r); + if (owner !== null) { + io.say(ownedRecipeRefusal(r, owner)); + startWithRemove = true; + continue recipeLoop; + } } - } - // Success - finalAddRecipes = addNames; - finalRemoveRecipes = removeNames; - finalReplace = doReplace; - break recipeLoop; + // Success + finalAddRecipes = addNames; + finalRemoveRecipes = removeNames; + finalReplace = doReplace; + break recipeLoop; + } catch (e) { + io.say((e as Error).message); + continue recipeLoop; + } } } @@ -865,6 +889,10 @@ export async function askInit( // Typed flags answer their questions (spec 38 §4.2, §4.3) if (given.targets !== undefined) { answeredTargets = given.targets; + } else if (local.keys.includes("targets")) { + // local.keys includes targets and no --targets typed: no targets question; say the local value + const localTargets = localConfig?.targets ?? []; + io.say(`Targets: ${localTargets.join(", ")} (from craftar.local.yaml)`); } else { // Default: [, from ] or [claude-code, default] const template = newProfileLike ? forge.profiles.get(newProfileLike) : undefined; diff --git a/test/init-new-profile-flow.test.ts b/test/init-new-profile-flow.test.ts index 6807a9d..6daa42c 100644 --- a/test/init-new-profile-flow.test.ts +++ b/test/init-new-profile-flow.test.ts @@ -302,3 +302,49 @@ describe("review round 2 (spec 38 §4.3)", () => { expect(sc.asked[1]).toBe("Forge — a directory or a git URL: "); }); }); + +describe("review round 3 (spec 38 §4.3: the same refusals re-asked)", () => { + it("an unknown name at Remove is said and the recipes question asked again", async () => { + const s = await setup(); + const sc = script(["yes", "nope", "", "", ""]); + const r = await ask(s, { forge: s.forge, newProfile: "initech", like: "acme" }, sc.io); + expect(sc.said.some((l) => /^recipe "nope" not found in this Forge \(/.test(l))).toBe(true); + expect(sc.asked.filter((q) => q === "Adjust the recipes [no]: ").length).toBe(2); + expect(r.kind).toBe("answered"); + }); + it("a name at both Remove and Add is N6, said at the question and asked again", async () => { + const s = await setup(); + const sc = script(["yes", "extra", "extra", "", ""]); + const r = await ask(s, { forge: s.forge, newProfile: "initech", like: "acme" }, sc.io); + expect(sc.said.some((l) => /both added and removed/.test(l))).toBe(true); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.addRecipes ?? []).toEqual([]); + expect(r.input.removeRecipes ?? []).toEqual([]); + }); + it("a craftar.local.yaml that sets targets: no targets question; the local value is said and stays a workspace layer", async () => { + const s = await setup(); + const sc = script([""]); + const r = await askInit(s.ws, { ...GIVEN, forge: s.forge, newProfile: "initech", like: "acme" }, { doc: { targets: ["kiro"] }, keys: ["targets"] as never[] }, sc.io, { + home: s.home, + registryOff: true, + exists: existsSync, + }); + expect(sc.asked.some((q) => q.startsWith("Targets"))).toBe(false); + expect(sc.said).toContain("Targets: kiro (from craftar.local.yaml)"); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.targets).toBeUndefined(); + }); + it("a craftar.local.yaml that sets recipes: no recipes question; the local chain is said", async () => { + const s = await setup(); + const sc = script([""]); + const r = await askInit(s.ws, { ...GIVEN, forge: s.forge, newProfile: "initech", like: "acme" }, { doc: { recipes: { add: ["extra"] } }, keys: ["recipes"] as never[] }, sc.io, { + home: s.home, + registryOff: true, + exists: existsSync, + }); + expect(sc.asked).not.toContain("Adjust the recipes [no]: "); + expect(sc.said.some((l) => l.startsWith("Recipes: ") && l.endsWith("(from craftar.local.yaml)"))).toBe(true); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.addRecipes ?? []).toEqual([]); + }); +}); From 0f7e031cd60ed7be3932da8d827e94f29629c0c7 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 21:35:01 -0300 Subject: [PATCH 26/26] chore: update version --- package-lock.json | 4 ++-- package.json | 2 +- src/cli.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 263ec35..3a16f75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.33.0", + "version": "0.34.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.33.0", + "version": "0.34.0", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 07e85c5..6a4ecfd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.33.0", + "version": "0.34.0", "description": "Craft, sync and convert AI-coding workspace harnesses across clients and tools.", "license": "MIT", "type": "module", diff --git a/src/cli.ts b/src/cli.ts index 0c7ae9d..f73c746 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -74,7 +74,7 @@ process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE const DIFF_EXIT_CODE_FETCH_MODE: FetchMode = "sync"; const program = new Command(); -program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.33.0"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.34.0"); /* ---------------------------------------------------------------- import */ /** The words of one output-path gate (`gateOutsideForge`), declared before the commands that run at load: what is written, by which command, and its two refusals' endings. */