From 62a155bd4761f7e1a30003050c18042510a1d255 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 15:33:35 -0300 Subject: [PATCH 01/25] test(init): pin gateOutsideForge's resolution lines, sync's fetch tail and init's stdout before spec 37 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written first, as spec 37 §5.2 and §10 ask: realpathOfNearest moves to the core, the load gains a fetchHint, and init gains --clone — none of the three may change these bytes. --- test/init-clone-pins.test.ts | 99 ++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 test/init-clone-pins.test.ts diff --git a/test/init-clone-pins.test.ts b/test/init-clone-pins.test.ts new file mode 100644 index 0000000..2adde19 --- /dev/null +++ b/test/init-clone-pins.test.ts @@ -0,0 +1,99 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { runCli } from "./helpers/cli.js"; +import { makeForge, profile, recipe, rule, tmpDir } from "./helpers/forge.js"; +import { git, remoteForge } from "./helpers/remote.js"; + +// Spec 37 §5.2 and §10: three outputs pinned BEFORE the build touches the code behind them — a pin written +// first is what proves the move (`realpathOfNearest` to the core), the new load option (`fetchHint`) and the new +// flags (`--clone`) leave today's bytes as they are. + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +async function scratch(prefix: string): Promise { + const dir = await fs.realpath(await tmpDir(prefix)); + cleanups.push(() => fs.rm(dir, { recursive: true, force: true })); + return dir; +} + +describe("pin — gateOutsideForge's two resolution messages (before realpathOfNearest moves)", () => { + async function forge(): Promise { + const root = await scratch("craftar-pin-forge-"); + await makeForge(root, { ingredients: [rule("wf", "a\n"), rule("wf--acme", "b\n", { as: "wf" })], recipes: [recipe("base", ["rule/wf"])], profiles: [profile("acme", ["base"])] }); + git(root, "init", "-q", "-b", "main"); + git(root, "add", "-A"); + git(root, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "commit", "-q", "-m", "init"); + return root; + } + + it("a dangling link on the target's path: the whole line, byte for byte", async () => { + const root = await forge(); + const outside = await scratch("craftar-pin-plan-"); + const link = path.join(outside, "dangling"); + await fs.symlink(path.join(root, "not-there-yet"), link, process.platform === "win32" ? "junction" : "dir"); + const target = path.join(link, "plan.yaml"); + const r = runCli(["forge", "unify", "rule/wf", "--profile", "acme", "--save-plan", target, "--forge", root]); + expect(r.code).toBe(1); + const line = (code: string) => `error: refusing to write the plan to ${target}: ${link} exists but cannot be resolved (${code}) — unify cannot prove the target lies outside the Forge\n`; + // A dangling junction's realpath code is the platform's; on Linux a dangling symlink's is ENOENT. + if (process.platform !== "win32") expect(r.stderr).toBe(line("ENOENT")); + else expect(r.stderr).toBe(line(/\(([A-Z]+)\) — unify/.exec(r.stderr)?.[1] ?? "?")); + }); + + it.skipIf(process.platform === "win32")("a symlink loop on the target's path: the whole line, byte for byte (Linux: lstat fails with ELOOP)", async () => { + const root = await forge(); + const outside = await scratch("craftar-pin-plan-"); + await fs.symlink(path.join(outside, "loopb"), path.join(outside, "loopa"), "dir"); + await fs.symlink(path.join(outside, "loopa"), path.join(outside, "loopb"), "dir"); + const target = path.join(outside, "loopa", "plan.yaml"); + const r = runCli(["forge", "unify", "rule/wf", "--profile", "acme", "--save-plan", target, "--forge", root]); + expect(r.code).toBe(1); + expect(r.stderr).toBe( + `error: refusing to write the plan to ${target}: ${path.join(outside, "loopa", "plan.yaml")} cannot be inspected (ELOOP) — unify cannot prove the target lies outside the Forge\n`, + ); + }); +}); + +describe("pin — sync's fetch failure tail (before LoadOptions.fetchHint)", () => { + it("sync, unreachable remote with a cached copy: the whole line, git's reason aside", async () => { + const r = await remoteForge({ ingredients: [rule("a", "# A\n")], recipes: [recipe("base", ["rule/a"])], profiles: [profile("acme", ["base"])] }); + cleanups.push(r.cleanup); + const ws = await scratch("craftar-pin-ws-"); + const home = await scratch("craftar-pin-home-"); + await fs.writeFile(path.join(ws, "craftar.yaml"), `forge: ${r.url}\nprofile: acme\n`); + const run = (args: string[]) => runCli([...args, "--workspace", ws], { env: { CRAFTAR_HOME: home } }); + expect(run(["sync"]).code).toBe(0); + const sha = git(r.src, "rev-parse", "HEAD").slice(0, 8); + await fs.rename(r.bare, r.bare + ".gone"); + const x = run(["sync"]); + expect(x.code).toBe(1); + const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + expect(x.stderr).toMatch(new RegExp(`^error: cannot fetch the Forge ${esc(r.url)}: [^\\n]+ — run with --offline to use the cached copy \\(${sha} fetched [^)\\n]+\\)\\n$`)); + }); +}); + +describe("pin — init with both flags on a profile that lists repositories (before --clone exists)", () => { + it("stdout is byte for byte the stdout of the same profile with repos: [] — nothing asked, nothing said about repos", async () => { + const repos = [ + { name: "portal-api", url: "https://git.example.com/acme/portal-api.git" }, + { name: "portal-web", url: "git@git.example.com:acme/portal-web.git", path: "apps/portal-web", ref: "release" }, + ]; + const out: string[] = []; + for (const list of [repos, []]) { + const parent = await scratch("craftar-pin-init-"); + const forge = path.join(parent, "forge"); + await makeForge(forge, { ingredients: [rule("a", "# A\n")], recipes: [recipe("base", ["rule/a"])], profiles: [profile("acme", ["base"], ["claude-code"], { repos: list })] }); + const ws = path.join(parent, "ws"); + const r = runCli(["init", "--forge", forge, "--profile", "acme", "--workspace", ws]); + expect(r.code, r.stderr).toBe(0); + expect(r.stderr).toBe(""); + out.push(r.stdout.split(parent).join("

")); + } + expect(out[0]).toBe(out[1]); + expect(out[0]).not.toMatch(/repositor|clone|portal-/); + }); +}); From f34474f74951fd7686bee3d65d1c3426a0d7faca Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 15:37:45 -0300 Subject: [PATCH 02/25] =?UTF-8?q?feat(repos):=20profileRepos=20reads=20a?= =?UTF-8?q?=20profile's=20repository=20entries=20=E2=80=94=20one=20reader,?= =?UTF-8?q?=20every=20refusal=20by=20index=20and=20key=20(spec=2037)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/repos.ts | 339 +++++++++++++++++++++++++++++++++++++++++++++ test/repos.test.ts | 220 +++++++++++++++++++++++++++++ 2 files changed, 559 insertions(+) create mode 100644 src/core/repos.ts create mode 100644 test/repos.test.ts diff --git a/src/core/repos.ts b/src/core/repos.ts new file mode 100644 index 0000000..9d07b89 --- /dev/null +++ b/src/core/repos.ts @@ -0,0 +1,339 @@ +/** + * Reads and validates `profile.repos` — the one reader of that field (spec 37 §5.2). + * `ProfileSchema.repos` stays open (§13.1 item 15) — this module checks each entry's + * shape and every cross-entry rule. + * + * PURE: no fs, no process, no console. + */ + +import type { Profile } from "../schema/index.js"; +import { classifyForge, credentialFault } from "./remote.js"; +import { findSecrets } from "./secrets.js"; +import { climbsOut } from "./text.js"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export interface RepoEntry { + name: string; + url: string; + path: string; + ref: string | null; +} + +/* ------------------------------------------------------------------ */ +/* Validation rules */ +/* ------------------------------------------------------------------ */ + +const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const DEVICE_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i; +const URL_CHARS_RE = /^[A-Za-z0-9._~:/@+-]+$/; +const REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/+-]*$/; +const SHA40_RE = /^[0-9a-f]{40}$/i; +const SHA64_RE = /^[0-9a-f]{64}$/i; +const KEY_NAME_RE = /^[A-Za-z0-9_.-]{1,64}$/; + +const CRAFTAR_RESERVED = new Set(["agents.md", "craftar.yaml", "craftar.local.yaml", "craftar.lock"]); +const KNOWN_KEYS = new Set(["name", "url", "path", "ref"]); + +/* ------------------------------------------------------------------ */ +/* Error messages (match test constants exactly) */ +/* ------------------------------------------------------------------ */ + +const NAME_RULE = `name is not a repository name — letters, digits, ".", "_" and "-", starting with a letter or a digit`; +const URL_CHAR = "url holds a character a git URL here may not — letters, digits and . _ ~ : / @ + -"; +const URL_KIND = "url is not an https or ssh git URL"; +const URL_CRED = "url holds credentials — remove them and let git authenticate"; +const PATH_PLAIN = "path is not a relative path of plain names"; +const REF_RULE = "ref is not a branch or tag name"; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function refuse(profile: string, index: number, what: string): never { + throw new Error(`profile ${profile}: repos[${index}]: ${what}`); +} + +function isValidNameSegment(s: string): boolean { + if (!s || !NAME_RE.test(s)) return false; + if (s.endsWith(".")) return false; + if (DEVICE_RE.test(s)) return false; + return true; +} + +function isValidPathSegment(s: string): boolean { + // Same rules as name, plus no ., .., \, :, ~, leading - + if (!s) return false; + if (s === "." || s === "..") return false; + if (s.includes("\\") || s.includes(":") || s.includes("~")) return false; + if (s.startsWith("-")) return false; + if (!NAME_RE.test(s)) return false; + if (s.endsWith(".")) return false; + if (DEVICE_RE.test(s)) return false; + return true; +} + +/** + * Checks if one path is a prefix of the other (or equal) for collision detection. + * Both paths are already lower-cased. + */ +function pathsCollide(a: string, b: string): boolean { + if (a === b) return true; + // Check if one is a prefix of the other followed by / + if (a.startsWith(b + "/") || b.startsWith(a + "/")) return true; + return false; +} + +/* ------------------------------------------------------------------ */ +/* URL validation helpers */ +/* ------------------------------------------------------------------ */ + +function startsWithLetterOrDigit(s: string): boolean { + if (!s) return false; + const c = s[0]; + return /[A-Za-z0-9]/.test(c); +} + +function extractHostAfterLastAt(authorityOrPrefix: string): string { + const atIndex = authorityOrPrefix.lastIndexOf("@"); + return atIndex >= 0 ? authorityOrPrefix.slice(atIndex + 1) : authorityOrPrefix; +} + +function validateUrl(url: unknown, profile: string, index: number): void { + // Missing + if (url === undefined) { + refuse(profile, index, "url is missing"); + } + + // Not a string + if (typeof url !== "string") { + refuse(profile, index, URL_KIND); + } + + // Character check + if (!URL_CHARS_RE.test(url)) { + refuse(profile, index, URL_CHAR); + } + + // classifyForge check + if (classifyForge(url) !== "url") { + refuse(profile, index, URL_KIND); + } + + // Scheme check (only https or ssh when there's ://) + const schemeMatch = url.indexOf("://"); + if (schemeMatch >= 0) { + const scheme = url.slice(0, schemeMatch); + if (scheme !== "https" && scheme !== "ssh") { + refuse(profile, index, URL_KIND); + } + } + + // Credential check + if (credentialFault(url)) { + refuse(profile, index, URL_CRED); + } + + // "Starts with a letter or digit" checks + if (schemeMatch >= 0) { + // Scheme form: check first char after ://, and host's first char + const afterScheme = url.slice(schemeMatch + 3); + if (!startsWithLetterOrDigit(afterScheme)) { + refuse(profile, index, URL_KIND); + } + + // Authority is up to the next / + const slashIndex = afterScheme.indexOf("/"); + const authority = slashIndex >= 0 ? afterScheme.slice(0, slashIndex) : afterScheme; + const host = extractHostAfterLastAt(authority); + if (!startsWithLetterOrDigit(host)) { + refuse(profile, index, URL_KIND); + } + } else { + // SCP form: user@host:path — must have a colon + const colonIndex = url.indexOf(":"); + if (colonIndex < 0) { + // No colon means classifyForge would have caught it as a path + refuse(profile, index, URL_KIND); + } + + // Check URL's first character + if (!startsWithLetterOrDigit(url)) { + refuse(profile, index, URL_KIND); + } + + // Host is the part before the colon, after the last @ + const beforeColon = url.slice(0, colonIndex); + const host = extractHostAfterLastAt(beforeColon); + if (!startsWithLetterOrDigit(host)) { + refuse(profile, index, URL_KIND); + } + + // Check first character after the colon + const afterColon = url.slice(colonIndex + 1); + if (!startsWithLetterOrDigit(afterColon)) { + refuse(profile, index, URL_KIND); + } + } +} + +/* ------------------------------------------------------------------ */ +/* Entry validation */ +/* ------------------------------------------------------------------ */ + +function validateEntry( + entry: Record, + profile: string, + index: number, + seenNames: Map, + seenPaths: Map, +): RepoEntry { + // --- name --- + const name = entry.name; + if (name === undefined) { + refuse(profile, index, "name is missing"); + } + if (typeof name !== "string" || !name || !NAME_RE.test(name)) { + refuse(profile, index, NAME_RULE); + } + if (name.endsWith(".")) { + refuse(profile, index, NAME_RULE); + } + if (DEVICE_RE.test(name)) { + refuse(profile, index, "name is a device name on Windows"); + } + + // --- url --- + validateUrl(entry.url, profile, index); + const url = entry.url as string; + + // --- path (use name if absent) --- + const pathFromName = entry.path === undefined; + const rawPath = pathFromName ? name : entry.path; + const pathLabel = pathFromName ? "path (from name)" : "path"; + + if (typeof rawPath !== "string" || !rawPath) { + refuse(profile, index, PATH_PLAIN); + } + + // Check climbsOut + if (climbsOut(rawPath)) { + refuse(profile, index, PATH_PLAIN); + } + + // Validate each segment + const segments = rawPath.split("/"); + for (const seg of segments) { + if (!isValidPathSegment(seg)) { + refuse(profile, index, PATH_PLAIN); + } + } + + // Check reserved paths + const firstSegmentLower = segments[0].toLowerCase(); + if (CRAFTAR_RESERVED.has(firstSegmentLower)) { + refuse(profile, index, `${pathLabel} is a place craftar writes`); + } + + // --- ref --- + let ref: string | null = null; + if (entry.ref !== undefined) { + const rawRef = entry.ref; + if (typeof rawRef !== "string" || !rawRef || !REF_RE.test(rawRef)) { + refuse(profile, index, REF_RULE); + } + // 40-char or 64-char hex is a commit SHA, not a ref + if (SHA40_RE.test(rawRef) || SHA64_RE.test(rawRef)) { + refuse(profile, index, REF_RULE); + } + ref = rawRef; + } + + // --- unknown keys --- + // This departs from spec 37 §5.1 ("quotes an unrecognized key") on the 0.30.1 integrations + // ruling's model — a key is printed only when shaped like a name. + const keys = Object.keys(entry); + for (let keyIndex = 0; keyIndex < keys.length; keyIndex++) { + const key = keys[keyIndex]; + if (!KNOWN_KEYS.has(key)) { + // Check if key is shaped like a name: matches the regex and has no secrets + if (KEY_NAME_RE.test(key) && findSecrets(key).length === 0) { + refuse(profile, index, `${key} is not a key of a repository entry — name, url, path, ref`); + } else { + // 1-based position among the entry's keys + refuse(profile, index, `key #${keyIndex + 1} (not shown: not shaped like a name) is not a key of a repository entry — name, url, path, ref`); + } + } + } + + // --- cross-entry: name repeat (against earlier entries) --- + const nameLower = name.toLowerCase(); + const existingNameIndex = seenNames.get(nameLower); + if (existingNameIndex !== undefined) { + refuse(profile, index, `name repeats repos[${existingNameIndex}]`); + } + + // --- cross-entry: path collision (against earlier entries) --- + const pathLower = rawPath.toLowerCase(); + for (const [existingPath, existingIndex] of seenPaths) { + if (pathsCollide(pathLower, existingPath)) { + refuse(profile, index, `${pathLabel} collides with repos[${existingIndex}]`); + } + } + + // Record for later entries + seenNames.set(nameLower, index); + seenPaths.set(pathLower, index); + + return { name, url, path: rawPath, ref }; +} + +/* ------------------------------------------------------------------ */ +/* Public API */ +/* ------------------------------------------------------------------ */ + +/** + * The one reader of `profile.repos` (spec 37 §5.2). Validates every entry and + * returns them in the profile's order, with `path` defaulted to `name` and + * `ref` defaulted to `null`. + * + * @throws On the first fault found — entries in order, fields in order within an entry. + */ +export function profileRepos(profile: Pick): RepoEntry[] { + const repos = profile.repos; + const result: RepoEntry[] = []; + const seenNames = new Map(); + const seenPaths = new Map(); + + for (let i = 0; i < repos.length; i++) { + const entry = repos[i] as Record; + result.push(validateEntry(entry, profile.name, i, seenNames, seenPaths)); + } + + return result; +} + +/** + * Returns the arguments for `git clone` (after the program name). + * `--` separates options from the URL to prevent option injection. + */ +export function cloneArgs(entry: RepoEntry): string[] { + const args = ["clone", "--no-recurse-submodules"]; + if (entry.ref !== null) { + args.push(`--branch=${entry.ref}`); + } + args.push("--", entry.url, entry.path); + return args; +} + +/** + * Returns a shell command string for cloning, with URL and path in double quotes. + */ +export function byHand(entry: RepoEntry): string { + const args = cloneArgs(entry); + // Quote the last two arguments (url and path) + const quoted = args.map((a, i) => (i >= args.length - 2 ? `"${a}"` : a)); + return "git " + quoted.join(" "); +} diff --git a/test/repos.test.ts b/test/repos.test.ts new file mode 100644 index 0000000..4847036 --- /dev/null +++ b/test/repos.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vitest"; +import { byHand, cloneArgs, profileRepos } from "../src/core/repos.js"; + +// Spec 37 §5.1 — the one reader of `profile.repos`. Every refusal names the index and the key, never a value: +// each refused value below carries MARK, and no message may hold it. + +const MARK = "zq" + "mark"; +const P = (repos: unknown[]) => ({ name: "acme", repos }) as never; +const fault = (repos: unknown[]): string => { + try { + profileRepos(P(repos)); + } catch (e) { + return (e as Error).message; + } + throw new Error("expected a refusal"); +}; +const OK_URL = "https://git.example.com/acme/a.git"; +const NAME_RULE = `name is not a repository name — letters, digits, ".", "_" and "-", starting with a letter or a digit`; +const URL_CHAR = "url holds a character a git URL here may not — letters, digits and . _ ~ : / @ + -"; +const URL_KIND = "url is not an https or ssh git URL"; +const URL_CRED = "url holds credentials — remove them and let git authenticate"; +const PATH_PLAIN = "path is not a relative path of plain names"; +const REF_RULE = "ref is not a branch or tag name"; + +describe("profileRepos — the entries, in order, path defaulted", () => { + it("no entry: []", () => { + expect(profileRepos(P([]))).toEqual([]); + }); + it("entries in the profile's order; path defaults to name; ref null when absent", () => { + expect( + profileRepos( + P([ + { name: "portal-api", url: "https://git.example.com/acme/portal-api.git" }, + { name: "portal-web", url: "git@git.example.com:acme/portal-web.git", path: "apps/portal-web", ref: "release" }, + { name: "docs", url: "ssh://git@git.example.com:22/acme/docs.git", ref: "v1.2" }, + ]), + ), + ).toEqual([ + { name: "portal-api", url: "https://git.example.com/acme/portal-api.git", path: "portal-api", ref: null }, + { name: "portal-web", url: "git@git.example.com:acme/portal-web.git", path: "apps/portal-web", ref: "release" }, + { name: "docs", url: "ssh://git@git.example.com:22/acme/docs.git", path: "docs", ref: "v1.2" }, + ]); + }); +}); + +describe("profileRepos — name", () => { + it("missing, and not a string", () => { + expect(fault([{ url: OK_URL }])).toBe("profile acme: repos[0]: name is missing"); + expect(fault([{ name: 7, url: OK_URL }])).toBe(`profile acme: repos[0]: ${NAME_RULE}`); + }); + for (const bad of [`-${MARK}`, `.${MARK}`, `${MARK}.`, `${MARK} x`, `${MARK}/x`, `${MARK}é`, `_${MARK}`, ""]) + it(`not a repository name: ${JSON.stringify(bad)}`, () => { + const m = fault([{ name: bad, url: OK_URL }]); + expect(m).toBe(`profile acme: repos[0]: ${NAME_RULE}`); + if (bad.includes(MARK)) expect(m.includes(MARK)).toBe(false); + }); + for (const dev of ["con", "PRN", "Aux", "nul", "com1", "COM9", "lpt1", "LPT9", "nul.txt", "con.tar.gz"]) + it(`a device name on Windows: ${dev}`, () => { + expect(fault([{ name: dev, url: OK_URL }])).toBe("profile acme: repos[0]: name is a device name on Windows"); + }); + it("com0 and lpt10 are not device names", () => { + expect(profileRepos(P([{ name: "com0", url: OK_URL }, { name: "lpt10", url: OK_URL }])).map((e) => e.name)).toEqual(["com0", "lpt10"]); + }); + it("repeated in another letter case names the earlier index", () => { + expect(fault([{ name: "api", url: OK_URL }, { name: "web", url: OK_URL }, { name: "API", url: OK_URL, path: "other" }])).toBe( + "profile acme: repos[2]: name repeats repos[0]", + ); + }); +}); + +describe("profileRepos — url", () => { + it("missing", () => { + expect(fault([{ name: "a" }])).toBe("profile acme: repos[0]: url is missing"); + }); + const charCases: Array<[string, string]> = [ + ["whitespace", `https://git.example.com/${MARK} x.git`], + ["a tab", `https://git.example.com/${MARK}\tx.git`], + ["a control character", `https://git.example.com/${MARK}\u0001.git`], + ["an ANSI escape", `https://git.example.com/${MARK}\u001b[31m.git`], + ["a semicolon", `https://git.example.com/${MARK};x.git`], + ["$(", `https://git.example.com/$(${MARK}).git`], + ["a backtick", `https://git.example.com/\`${MARK}\`.git`], + ["a pipe", `https://git.example.com/${MARK}|x.git`], + ["an ampersand", `https://git.example.com/${MARK}&x.git`], + ["a double quote", `https://git.example.com/"${MARK}".git`], + ["a single quote", `https://git.example.com/'${MARK}'.git`], + ["a percent", `https://git.example.com/%${MARK}%.git`], + ["an equals sign", `a=b@${MARK}:x:~/y`], + ["a backslash", `https:\\\\${MARK}\\x.git`], + ["a non-ASCII look-alike", `https://git.exampІe.com/${MARK}.git`], + ]; + for (const [what, url] of charCases) + it(`a character a git URL here may not hold: ${what}`, () => { + const m = fault([{ name: "a", url }]); + expect(m).toBe(`profile acme: repos[0]: ${URL_CHAR}`); + expect(m.includes(MARK)).toBe(false); + }); + const kindCases: Array<[string, unknown]> = [ + ["not a string", 42], + ["a local path", `/srv/${MARK}/a.git`], + ["a relative path", `../${MARK}/a.git`], + ["file://", `file:///srv/${MARK}/a.git`], + ["git://", `git://git.example.com/${MARK}.git`], + ["http://", `http://git.example.com/${MARK}.git`], + ["ext::", `ext::sh+-c+${MARK}`], + ["an empty authority", `https:///${MARK}.git`], + ["one slash after the scheme", `https:/git.example.com/${MARK}.git`], + ["a user that starts with - (scheme form)", `ssh://-oProxyCommand+${MARK}@git.example.com/x.git`], + ["a host that starts with - (scheme form)", `ssh://git@-${MARK}.example.com/x.git`], + ["a host that starts with - (https)", `https://-${MARK}.example.com/x.git`], + ["a user that starts with - (SCP form)", `-oProxyCommand+${MARK}@git.example.com:x.git`], + ["a host that starts with - (SCP form)", `git@-${MARK}.example.com:x.git`], + ["a path that starts with - after the SCP colon", `git@git.example.com:-${MARK}.git`], + ["the SCP form with no user, which classifyForge reads as a path", `git.example.com:${MARK}/e.git`], + ]; + for (const [what, url] of kindCases) + it(`not an https or ssh git URL: ${what}`, () => { + const m = fault([{ name: "a", url }]); + expect(m).toBe(`profile acme: repos[0]: ${URL_KIND}`); + expect(m.includes(MARK)).toBe(false); + }); + for (const [what, url] of [ + ["a user and a password on https", `https://u:${MARK}@git.example.com/x.git`], + ["a bare user on https", `https://${MARK}@git.example.com/x.git`], + ["a password on ssh", `ssh://git:${MARK}@git.example.com/x.git`], + ] as const) + it(`credentials: ${what}`, () => { + const m = fault([{ name: "a", url }]); + expect(m).toBe(`profile acme: repos[0]: ${URL_CRED}`); + expect(m.includes(MARK)).toBe(false); + }); + it("accepted: https, ssh with a user and a port, the SCP form, ~ in a path", () => { + const urls = [ + "https://git.example.com/acme/a.git", + "ssh://git@git.example.com/acme/b.git", + "ssh://git@git.example.com:2222/acme/c.git", + "git@git.example.com:acme/d.git", + "ssh://git@git.example.com/~acme/f.git", + ]; + expect(profileRepos(P(urls.map((url, i) => ({ name: `r${i}`, url })))).map((e) => e.url)).toEqual(urls); + }); +}); + +describe("profileRepos — path", () => { + for (const bad of [`/${MARK}`, `${MARK}/`, `${MARK}//x`, `../${MARK}`, `${MARK}/../x`, `.${MARK}`, `x/.${MARK}`, `${MARK}\\x`, `C:${MARK}`, `${MARK}~1`, `x/${MARK}.`, `x/con`, `x/NUL.txt`, `-${MARK}`, ""]) + it(`not a relative path of plain names: ${JSON.stringify(bad)}`, () => { + const m = fault([{ name: "a", url: OK_URL, path: bad }]); + expect(m).toBe(`profile acme: repos[0]: ${PATH_PLAIN}`); + if (bad.includes(MARK)) expect(m.includes(MARK)).toBe(false); + }); + it("not a string", () => { + expect(fault([{ name: "a", url: OK_URL, path: 3 }])).toBe(`profile acme: repos[0]: ${PATH_PLAIN}`); + }); + for (const reserved of ["AGENTS.md", "agents.md", "craftar.yaml", "CRAFTAR.YAML", "craftar.local.yaml", "craftar.lock", "craftar.lock/x"]) + it(`a place craftar writes: ${reserved}`, () => { + expect(fault([{ name: "a", url: OK_URL, path: reserved }])).toBe("profile acme: repos[0]: path is a place craftar writes"); + }); + it("the rules run on name when path is absent, and the message says so", () => { + expect(fault([{ name: "craftar.yaml", url: OK_URL }])).toBe("profile acme: repos[0]: path (from name) is a place craftar writes"); + expect(fault([{ name: "Agents.MD", url: OK_URL }])).toBe("profile acme: repos[0]: path (from name) is a place craftar writes"); + }); + it("two destinations that are equal, or nest, in any letter case, collide with the earlier index", () => { + expect(fault([{ name: "apps", url: OK_URL }, { name: "web", url: OK_URL, path: "apps/web" }])).toBe("profile acme: repos[1]: path collides with repos[0]"); + expect(fault([{ name: "web", url: OK_URL, path: "Apps/Web" }, { name: "apps", url: OK_URL }])).toBe("profile acme: repos[1]: path (from name) collides with repos[0]"); + expect(fault([{ name: "a", url: OK_URL, path: "x/y" }, { name: "b", url: OK_URL, path: "X/Y" }])).toBe("profile acme: repos[1]: path collides with repos[0]"); + }); + it("siblings that share a prefix do not collide", () => { + expect(profileRepos(P([{ name: "app", url: OK_URL }, { name: "apps", url: OK_URL }, { name: "w", url: OK_URL, path: "app-web/x" }])).length).toBe(3); + }); +}); + +describe("profileRepos — ref", () => { + for (const bad of [`-${MARK}`, `.${MARK}`, `${MARK} x`, `${MARK};x`, `${MARK}~1`, `${MARK}^`, `${MARK}:x`, `${MARK}@{1}`, `\`${MARK}\``, ""]) + it(`not a branch or tag name: ${JSON.stringify(bad)}`, () => { + const m = fault([{ name: "a", url: OK_URL, ref: bad }]); + expect(m).toBe(`profile acme: repos[0]: ${REF_RULE}`); + if (bad.includes(MARK)) expect(m.includes(MARK)).toBe(false); + }); + it("a 40- and a 64-character hexadecimal commit id is not a ref; 39 and 41 are names", () => { + expect(fault([{ name: "a", url: OK_URL, ref: "a".repeat(40) }])).toBe(`profile acme: repos[0]: ${REF_RULE}`); + expect(fault([{ name: "a", url: OK_URL, ref: "0123456789ABCDEF".repeat(4) }])).toBe(`profile acme: repos[0]: ${REF_RULE}`); + expect(profileRepos(P([{ name: "a", url: OK_URL, ref: "a".repeat(39) }, { name: "b", url: OK_URL, ref: "a".repeat(41) }])).length).toBe(2); + }); + it("not a string", () => { + expect(fault([{ name: "a", url: OK_URL, ref: 1 }])).toBe(`profile acme: repos[0]: ${REF_RULE}`); + }); + it("accepted: release, v1.2, feature/x, a+b", () => { + expect(profileRepos(P(["release", "v1.2", "feature/x", "a+b"].map((ref, i) => ({ name: `r${i}`, url: OK_URL, ref })))).map((e) => e.ref)).toEqual(["release", "v1.2", "feature/x", "a+b"]); + }); +}); + +describe("profileRepos — any other key", () => { + it("a key shaped like a name is named", () => { + expect(fault([{ name: "a", url: OK_URL, depth: 1 }])).toBe("profile acme: repos[0]: depth is not a key of a repository entry — name, url, path, ref"); + }); + it("a key not shaped like a name is named by its position, never printed", () => { + const key = `https://u:${MARK}@x`; + const m = fault([{ name: "a", url: OK_URL, [key]: 1 }]); + expect(m).toBe("profile acme: repos[0]: key #3 (not shown: not shaped like a name) is not a key of a repository entry — name, url, path, ref"); + expect(m.includes(MARK)).toBe(false); + }); + it("the index named is the entry's", () => { + expect(fault([{ name: "a", url: OK_URL }, { name: "b", url: OK_URL, Ref: "x" }])).toBe("profile acme: repos[1]: Ref is not a key of a repository entry — name, url, path, ref"); + }); +}); + +describe("cloneArgs and byHand", () => { + const plain = { name: "portal-api", url: "https://git.example.com/acme/portal-api.git", path: "portal-api", ref: null }; + const refd = { name: "portal-web", url: "git@git.example.com:acme/portal-web.git", path: "apps/portal-web", ref: "release" }; + it("the argument array after the program; -- right before the URL", () => { + expect(cloneArgs(plain)).toEqual(["clone", "--no-recurse-submodules", "--", plain.url, "portal-api"]); + expect(cloneArgs(refd)).toEqual(["clone", "--no-recurse-submodules", "--branch=release", "--", refd.url, "apps/portal-web"]); + for (const a of [cloneArgs(plain), cloneArgs(refd)]) expect(a[a.indexOf("--") + 1]).toBe(a.at(-2)); + }); + it("byHand: git and the same arguments, the URL and the destination in double quotes", () => { + expect(byHand(refd)).toBe('git clone --no-recurse-submodules --branch=release -- "git@git.example.com:acme/portal-web.git" "apps/portal-web"'); + expect(byHand(plain)).toBe('git clone --no-recurse-submodules -- "https://git.example.com/acme/portal-api.git" "portal-api"'); + expect(byHand({ ...plain, path: "1.10", ref: "v1.2" })).toBe('git clone --no-recurse-submodules --branch=v1.2 -- "https://git.example.com/acme/portal-api.git" "1.10"'); + }); +}); From eaad4aa6ee7ffe91b860829b657af86edabc6ae9 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 15:41:13 -0300 Subject: [PATCH 03/25] refactor(forge): realpathOfNearest and cannotResolve move to the core, one copy for the output gate and the clone (spec 37) --- src/cli.ts | 50 +---------------------------------------------- src/core/forge.ts | 48 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 4bbc1a2..731ac6c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,7 +26,7 @@ import { preferredLine, renderImportReport } from "./core/import-report.js"; import { driftList, unmanaged, type DriftRow } from "./core/drift.js"; import { EXAMPLE_CONTENT_HIDDEN, diffReport, explainFile, explainSkip, notProduced, readText, statusPath, statusReport, targetsReport } from "./core/report.js"; import { diffIngredients, listVariants, profileOf, type Distance, type IngredientDiff } from "./core/variants.js"; -import { exists, gitDirty, gitIgnored, gitIsRepo, gitUnheld, loadForge, type UnheldPath } from "./core/forge.js"; +import { exists, gitDirty, gitIgnored, gitIsRepo, gitUnheld, loadForge, realpathOfNearest, cannotResolve, type UnheldPath } from "./core/forge.js"; import { applyBasePromotion, planBasePromotion, proveBasePromotion } from "./core/promote.js"; import { fingerprintDir } from "./core/fingerprint.js"; import { @@ -2737,54 +2737,6 @@ function color(state: string) { return pc.dim; } } - -/** - * The real path of `abs`, resolved through its nearest existing ancestor: `fs.realpath` needs the - * path to exist, and a plan target usually does not yet — so the part that exists is resolved - * (symlinks and all) and the part that does not is appended as written. - * - * It walks up only past a component that truly does not exist (`lstat` says ENOENT/ENOTDIR). Any - * other outcome throws instead. A component `lstat` cannot inspect (a symlink loop or a permission - * error on Linux) "cannot be inspected"; one it can inspect but `realpath` cannot follow (a - * dangling symlink or junction, a loop on Windows) "exists but cannot be resolved". Resolving the - * rest lexically would let a link that points into the Forge pass the containment check, so the gate - * fails closed rather than guess where the target lands. - */ -async function realpathOfNearest(abs: string): Promise { - let head = abs; - const tail: string[] = []; - for (;;) { - try { - return path.join(await fs.realpath(head), ...tail); - } catch (realpathError) { - try { - await fs.lstat(head); - } catch (lstatError) { - const code = (lstatError as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENOTDIR") { - const parent = path.dirname(head); - if (parent === head) return abs; - tail.unshift(path.basename(head)); - head = parent; - continue; - } - throw cannotResolve(head, lstatError, "cannot be inspected"); - } - throw cannotResolve(head, realpathError, "exists but cannot be resolved"); - } - } -} - -/** - * The refusal for a target that cannot be resolved; the caller adds which command cannot prove it. `what` is - * "cannot be inspected" when `lstat` itself failed, so the path is not known to exist, and "exists but cannot be - * resolved" when `lstat` succeeded and `realpath` did not. - */ -function cannotResolve(p: string, e: unknown, what: "cannot be inspected" | "exists but cannot be resolved"): Error { - const code = (e as NodeJS.ErrnoException | null)?.code ?? (e instanceof Error ? e.message : String(e)); - return new Error(`${p} ${what} (${code})`); -} - /** * The gate of a user-supplied output path (Ruling 30 of spec 06; spec 29 §4.2): the file a command writes where the * user says must lie outside the Forge and must not exist. Both sides are compared as real paths, so neither a `..` diff --git a/src/core/forge.ts b/src/core/forge.ts index a6fbe94..0963a48 100644 --- a/src/core/forge.ts +++ b/src/core/forge.ts @@ -385,3 +385,51 @@ export async function assertNoLinkOnPath(forgeDir: string, paths: string[]): Pro } } } + + +/** + * The real path of `abs`, resolved through its nearest existing ancestor: `fs.realpath` needs the + * path to exist, and a plan target usually does not yet — so the part that exists is resolved + * (symlinks and all) and the part that does not is appended as written. + * + * It walks up only past a component that truly does not exist (`lstat` says ENOENT/ENOTDIR). Any + * other outcome throws instead. A component `lstat` cannot inspect (a symlink loop or a permission + * error on Linux) "cannot be inspected"; one it can inspect but `realpath` cannot follow (a + * dangling symlink or junction, a loop on Windows) "exists but cannot be resolved". Resolving the + * rest lexically would let a link that points into the Forge pass the containment check, so the gate + * fails closed rather than guess where the target lands. + */ +export async function realpathOfNearest(abs: string): Promise { + let head = abs; + const tail: string[] = []; + for (;;) { + try { + return path.join(await fs.realpath(head), ...tail); + } catch (realpathError) { + try { + await fs.lstat(head); + } catch (lstatError) { + const code = (lstatError as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + const parent = path.dirname(head); + if (parent === head) return abs; + tail.unshift(path.basename(head)); + head = parent; + continue; + } + throw cannotResolve(head, lstatError, "cannot be inspected"); + } + throw cannotResolve(head, realpathError, "exists but cannot be resolved"); + } + } +} + +/** + * The refusal for a target that cannot be resolved; the caller adds which command cannot prove it. `what` is + * "cannot be inspected" when `lstat` itself failed, so the path is not known to exist, and "exists but cannot be + * resolved" when `lstat` succeeded and `realpath` did not. + */ +export function cannotResolve(p: string, e: unknown, what: "cannot be inspected" | "exists but cannot be resolved"): Error { + const code = (e as NodeJS.ErrnoException | null)?.code ?? (e instanceof Error ? e.message : String(e)); + return new Error(`${p} ${what} (${code})`); +} \ No newline at end of file From 1d9794013f6e2494b3f625467a489bb8f656e0f7 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 15:44:06 -0300 Subject: [PATCH 04/25] feat(clone): destination checks on real paths, planClone and cloneRepos with an injected runner (spec 37) --- src/core/clone.ts | 433 +++++++++++++++++++++++++++++++++++++++++++++ src/core/repos.ts | 9 + test/clone.test.ts | 316 +++++++++++++++++++++++++++++++++ 3 files changed, 758 insertions(+) create mode 100644 src/core/clone.ts create mode 100644 test/clone.test.ts diff --git a/src/core/clone.ts b/src/core/clone.ts new file mode 100644 index 0000000..6f3ab2c --- /dev/null +++ b/src/core/clone.ts @@ -0,0 +1,433 @@ +/** + * Repository cloning for `craftar init --clone` and `craftar repos clone` (spec 37 §5.1, §5.2). + * The destination checks (C5) are stricter than the rest of craftar on purpose: real-path + * containment (§7), so neither a `..` segment nor a symlink can carry a clone outside the workspace + * or into the Forge. The check-then-act gap is known (§7): a destination can appear between the + * plan and the clone; `cloneRepos` re-checks before each runner call, and a failure there is + * reported as one entry's failure. + * + * PURE: no console, no process (env and tty come in as arguments). + */ + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import type { Profile } from "../schema/index.js"; +import { realpathOfNearest } from "./forge.js"; +import { onPath, spawnInherited, type Runner } from "./proc.js"; +import { cloneArgs, isRepoName, profileRepos, type RepoEntry } from "./repos.js"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export interface DestinationContext { + /** Workspace-relative POSIX paths of files `sync` would write (the plan). */ + planned: readonly string[]; + /** Workspace-relative POSIX paths `craftar.lock` records (files sync wrote before). */ + lockPaths: readonly string[]; + /** Absolute path of the Forge directory. */ + forgeDir: string; +} + +export type RepoState = "present" | "missing" | "blocked"; + +export type CloneResult = + | { entry: RepoEntry; outcome: "cloned" } + | { entry: RepoEntry; outcome: "failed"; reason: string }; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +const WIN = process.platform === "win32"; + +/** Case-insensitive comparison on Windows, case-sensitive elsewhere. */ +function eq(a: string, b: string): boolean { + return WIN ? a.toLowerCase() === b.toLowerCase() : a === b; +} + +/** + * Whether `a` is equal to `b` or strictly inside `b`, by `path.relative`. The comparison is + * case-insensitive on win32. + */ +function isInsideOrEqual(a: string, b: string): boolean { + const rel = path.relative(b, a); + if (rel === "") return true; // equal + if (path.isAbsolute(rel)) return false; + // A relative path starting with ".." (but not "..something") is outside + if (rel === ".." || rel.startsWith(".." + path.sep)) return false; + // On win32, compare without case + if (WIN) { + const relLower = path.relative(b.toLowerCase(), a.toLowerCase()); + if (relLower === ".." || relLower.startsWith(".." + path.sep) || path.isAbsolute(relLower)) return false; + } + return true; +} + +/** + * Whether `a` is strictly inside `b` (equal returns false). + */ +function isStrictlyInside(a: string, b: string): boolean { + const rel = path.relative(b, a); + if (rel === "") return false; // equal, not strictly inside + if (path.isAbsolute(rel)) return false; + if (rel === ".." || rel.startsWith(".." + path.sep)) return false; + if (WIN) { + const relLower = path.relative(b.toLowerCase(), a.toLowerCase()); + if (relLower === "" || relLower === ".." || relLower.startsWith(".." + path.sep) || path.isAbsolute(relLower)) return false; + } + return true; +} + +/** Whether `lstat` can see anything at `p` (a file, directory, or dangling link). */ +async function exists(p: string): Promise { + try { + await fs.lstat(p); + return true; + } catch { + return false; + } +} + +/** + * Walk up from `from` toward `stop`, following links via `fs.stat`. Returns the first existing + * path (a directory check), or null if nothing exists. Never goes above `stop`. + */ +async function firstExistingAncestor(from: string, stop: string): Promise<{ path: string; isDir: boolean } | null> { + let current = from; + while (current !== stop && !eq(current, stop)) { + const parent = path.dirname(current); + if (parent === current) break; // reached root + try { + const stat = await fs.stat(parent); + return { path: parent, isDir: stat.isDirectory() }; + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + current = parent; + continue; + } + // Other errors (permission, etc.): stop walking + break; + } + } + return null; +} + +/* ------------------------------------------------------------------ */ +/* destinationFault — C5 in each of its forms */ +/* ------------------------------------------------------------------ */ + +/** + * The reason this destination cannot be used, or null. Checks in §5.1's order (first hit wins): + * 1. already exists (lstat succeeds) + * 2. a file sync writes (planned or lockPaths, with prefix matching) + * 3. resolution error (realpathOfNearest throws) + * 4. inside the Forge + * 5. leaves the workspace through a link + * 6. a parent is not a directory + * + * `root` and `ctx.forgeDir` are absolute; `e.path` is POSIX relative. + */ +export async function destinationFault(root: string, e: RepoEntry, ctx: DestinationContext): Promise { + const abs = path.join(root, ...e.path.split("/")); + const pPath = e.path; // the profile's spelling, POSIX + + // 1. lstat succeeds → already exists + try { + await fs.lstat(abs); + return `${pPath} already exists in the workspace — move it, or leave the clone out`; + } catch (lstatErr) { + const code = (lstatErr as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ENOTDIR") { + // Some other error during lstat — fall through to resolution check + } + } + + // 2. A file sync writes — check planned and lockPaths + const pLower = pPath.toLowerCase(); + for (const planned of ctx.planned) { + const plannedLower = planned.toLowerCase(); + // Equal or e.path is a prefix of planned followed by / + if (pLower === plannedLower || plannedLower.startsWith(pLower + "/")) { + return `${pPath} is a file sync writes`; + } + } + for (const lock of ctx.lockPaths) { + const lockLower = lock.toLowerCase(); + if (pLower === lockLower || lockLower.startsWith(pLower + "/")) { + return `${pPath} is a file sync writes`; + } + } + + // 3. Resolve paths — any error → return the error message + let destReal: string; + let rootReal: string; + let forgeReal: string; + try { + destReal = await realpathOfNearest(abs); + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + try { + rootReal = await realpathOfNearest(root); + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + try { + forgeReal = await realpathOfNearest(ctx.forgeDir); + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + + // 4. Inside the Forge + if (isInsideOrEqual(destReal, forgeReal)) { + return `${pPath} lies inside the Forge`; + } + + // 5. Not strictly inside workspace (leaves through a link) + if (!isStrictlyInside(destReal, rootReal)) { + return `${pPath} leaves the workspace through a link`; + } + + // 6. Walk up from parent toward root: first existing path that is not a directory + const ancestor = await firstExistingAncestor(abs, root); + if (ancestor && !ancestor.isDir) { + // Report the path relative to root, POSIX + const relPath = path.relative(root, ancestor.path).split(path.sep).join("/"); + return `${relPath} is not a directory`; + } + + return null; +} + +/* ------------------------------------------------------------------ */ +/* listRepos — present, missing, blocked */ +/* ------------------------------------------------------------------ */ + +/** + * Classify each entry: `present` (lstat succeeds — present wins over a sync-writes check, §4.7), + * `missing` (no fault), or `blocked` (with the reason). Nothing is written. + */ +export async function listRepos( + root: string, + entries: RepoEntry[], + ctx: DestinationContext, +): Promise> { + const results: Array = []; + for (const entry of entries) { + const abs = path.join(root, ...entry.path.split("/")); + // Present wins (§4.7) + if (await exists(abs)) { + results.push({ ...entry, state: "present", reason: null }); + continue; + } + const fault = await destinationFault(root, entry, ctx); + if (fault === null) { + results.push({ ...entry, state: "missing", reason: null }); + } else { + results.push({ ...entry, state: "blocked", reason: fault }); + } + } + return results; +} + +/* ------------------------------------------------------------------ */ +/* planClone — C3, C4, C5 and R1–R3 */ +/* ------------------------------------------------------------------ */ + +/** + * Plan which entries to clone. This is the one place C3 and C5 (and R1–R3) are decided and C4 + * becomes a refusal (spec 37 §5.2). The real-path containment is stricter than the rest of craftar + * on purpose (§7), and the check-then-act gap is known (§7). + * + * @param root Absolute path of the workspace. + * @param profile The profile (needs `name` and `repos`). + * @param ctx Destination context (planned paths, lock paths, Forge dir). + * @param opts.present How to handle a destination that already exists: "refuse" (init) or "skip" (repos clone). + * @param opts.noEntries The message tail for C3 ("nothing to clone; drop --clone" or similar). + * @param opts.names When given, only these names are considered; compared case-insensitively. + * @returns The entries to clone (in profile order) and the skipped ones (present, unnamed). + * @throws On C3 (no entries), C4 (profileRepos fails), C5 (destination fault), R1 (unknown name), R2 (named present), R3 (name given twice). + */ +export async function planClone( + root: string, + profile: Pick, + ctx: DestinationContext, + opts: { present: "refuse" | "skip"; noEntries: string; names?: string[] }, +): Promise<{ entries: RepoEntry[]; skipped: RepoEntry[] }> { + // C4: let profileRepos throw its own error + const allEntries = profileRepos(profile); + + // C3: no entries + if (allEntries.length === 0) { + throw new Error(`profile ${profile.name} lists no repositories — ${opts.noEntries}`); + } + + // Handle names option: R1, R3 checks, then filter + let entriesToConsider: RepoEntry[]; + const namedSet = new Set(); // lower-cased names that were explicitly given + + if (opts.names && opts.names.length > 0) { + // R1: check all names exist before R3 + const entryNameMap = new Map(); + for (const entry of allEntries) { + entryNameMap.set(entry.name.toLowerCase(), entry); + } + for (const name of opts.names) { + if (!entryNameMap.has(name.toLowerCase())) { + // R1: unknown name — check if it's shaped like a repository name + if (isRepoName(name)) { + throw new Error(`profile ${profile.name} has no repository ${name}`); + } else { + throw new Error(`profile ${profile.name}: a name given is not a repository name`); + } + } + } + + // R3: check for duplicates (after R1) + const seenNames = new Map(); // lower → original + for (const name of opts.names) { + const lower = name.toLowerCase(); + const existing = seenNames.get(lower); + if (existing !== undefined) { + throw new Error(`${existing} is given twice`); + } + seenNames.set(lower, name); + namedSet.add(lower); + } + + // Filter to named entries, in profile order + entriesToConsider = allEntries.filter((e) => namedSet.has(e.name.toLowerCase())); + } else { + entriesToConsider = allEntries; + } + + const entries: RepoEntry[] = []; + const skipped: RepoEntry[] = []; + + for (const entry of entriesToConsider) { + const abs = path.join(root, ...entry.path.split("/")); + const isNamed = namedSet.has(entry.name.toLowerCase()); + const isPresent = await exists(abs); + + if (isPresent) { + if (opts.present === "refuse") { + // C5: refuse with the exists reason + throw new Error(`cannot clone ${entry.name}: ${entry.path} already exists in the workspace — move it, or leave the clone out`); + } else { + // present: skip + if (isNamed) { + // R2: a named entry that is present + throw new Error(`${entry.name} is present at ${entry.path} — nothing to clone`); + } else { + skipped.push(entry); + continue; + } + } + } + + // Check destination fault (C5) + const fault = await destinationFault(root, entry, ctx); + if (fault !== null) { + throw new Error(`cannot clone ${entry.name}: ${fault}`); + } + + entries.push(entry); + } + + return { entries, skipped }; +} + +/* ------------------------------------------------------------------ */ +/* findGit — onPath, nothing else */ +/* ------------------------------------------------------------------ */ + +/** + * Find `git` on the PATH. Returns the absolute path if found, or null. + * Uses `onPath` from `./proc.js` — no other lookup. + */ +export async function findGit(env: NodeJS.ProcessEnv): Promise { + return onPath("git", env); +} + +/* ------------------------------------------------------------------ */ +/* cloneRepos — sequential runner calls */ +/* ------------------------------------------------------------------ */ + +/** + * Clone the entries sequentially, one at a time. Before each clone: + * - If `lstat(abs)` succeeds → failed "already exists", no runner call + * - If the nearest existing ancestor is not strictly inside the workspace → failed "leaves the workspace through a link" + * + * The runner is called with `GIT_ALLOW_PROTOCOL: "https:ssh"` and, when `!opts.stdinIsTty`, + * `GIT_TERMINAL_PROMPT: "0"`. + * + * @returns One result per entry: cloned or failed with a reason. Never throws for one entry. + */ +export async function cloneRepos( + root: string, + entries: RepoEntry[], + opts: { git: string; env: NodeJS.ProcessEnv; stdinIsTty: boolean }, + run: Runner = spawnInherited, +): Promise { + const results: CloneResult[] = []; + + for (const entry of entries) { + const abs = path.join(root, ...entry.path.split("/")); + + // Re-check: if destination now exists, fail + if (await exists(abs)) { + results.push({ entry, outcome: "failed", reason: "already exists" }); + continue; + } + + // Re-check: containment via real paths + let destNearest: string; + let rootReal: string; + try { + destNearest = await realpathOfNearest(abs); + rootReal = await realpathOfNearest(root); + } catch { + results.push({ entry, outcome: "failed", reason: "leaves the workspace through a link" }); + continue; + } + + if (!isStrictlyInside(destNearest, rootReal)) { + results.push({ entry, outcome: "failed", reason: "leaves the workspace through a link" }); + continue; + } + + // Build environment + const env: NodeJS.ProcessEnv = { + ...opts.env, + GIT_ALLOW_PROTOCOL: "https:ssh", + }; + if (!opts.stdinIsTty) { + env.GIT_TERMINAL_PROMPT = "0"; + } + + // Run the clone + try { + const result = await run({ + file: opts.git, + args: cloneArgs(entry), + cwd: root, + env, + stdin: "inherit", + }); + + if (result.code === 0) { + results.push({ entry, outcome: "cloned" }); + } else if (result.code === null) { + results.push({ entry, outcome: "failed", reason: "git was ended by a signal" }); + } else { + results.push({ entry, outcome: "failed", reason: `git exited ${result.code}` }); + } + } catch { + results.push({ entry, outcome: "failed", reason: "git could not be started" }); + } + } + + return results; +} diff --git a/src/core/repos.ts b/src/core/repos.ts index 9d07b89..1f5e059 100644 --- a/src/core/repos.ts +++ b/src/core/repos.ts @@ -63,6 +63,15 @@ function isValidNameSegment(s: string): boolean { return true; } +/** + * Whether `s` is shaped like a repository name: letters, digits, `.`, `_` and `-`, starting with a + * letter or a digit, not ending with `.`, and not a Windows device name. Used by `planClone` to decide + * which R1 message to show for a name that is not an entry. + */ +export function isRepoName(s: string): boolean { + return isValidNameSegment(s); +} + function isValidPathSegment(s: string): boolean { // Same rules as name, plus no ., .., \, :, ~, leading - if (!s) return false; diff --git a/test/clone.test.ts b/test/clone.test.ts new file mode 100644 index 0000000..6860492 --- /dev/null +++ b/test/clone.test.ts @@ -0,0 +1,316 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { cloneRepos, destinationFault, findGit, listRepos, planClone, type DestinationContext } from "../src/core/clone.js"; +import type { RepoEntry } from "../src/core/repos.js"; +import type { RunRequest } from "../src/core/proc.js"; +import { tmpDir } from "./helpers/forge.js"; + +// Spec 37 §5.1 *Destination*, §5.2 and §10 — C5 in each of its forms, the listing's states, planClone's refusals +// (C3–C5, R1–R3), findGit, and cloneRepos with a recording runner. No test runs git: the runner is injected. + +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-clone-"): Promise { + const dir = await fs.realpath(await tmpDir(prefix)); + cleanups.push(() => fs.rm(dir, { recursive: true, force: true })); + return dir; +} +const entry = (name: string, extra: Partial = {}): RepoEntry => ({ name, url: `https://git.example.com/acme/${name}.git`, path: name, ref: null, ...extra }); +/** A workspace and a Forge beside it (not under it), the usual layout. */ +async function layout(): Promise<{ root: string; forge: string; ctx: DestinationContext }> { + const parent = await scratch(); + const root = path.join(parent, "ws"); + const forge = path.join(parent, "forge"); + await fs.mkdir(root); + await fs.mkdir(forge); + return { root, forge, ctx: { planned: [".claude/rules/a.md", "AGENTS.md", "docs/guide.md"], lockPaths: [], forgeDir: forge } }; +} +async function snapshot(dir: string): Promise { + const out: string[] = []; + async function walk(d: string): Promise { + for (const e of await fs.readdir(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + out.push(path.relative(dir, p) + (e.isDirectory() ? "/" : e.isSymbolicLink() ? "@" : "")); + if (e.isDirectory()) await walk(p); + } + } + await walk(dir); + return out.sort(); +} + +describe("destinationFault — C5, in §5.1's order", () => { + it("a destination that does not exist and passes every check: null; a parent that does not exist either", async () => { + const { root, ctx } = await layout(); + expect(await destinationFault(root, entry("api"), ctx)).toBeNull(); + expect(await destinationFault(root, entry("web", { path: "apps/web" }), ctx)).toBeNull(); + }); + it("already exists — a file, an empty directory, a dangling link (lstat, before any resolution)", async () => { + const { root, ctx } = await layout(); + await fs.writeFile(path.join(root, "api"), "x"); + await fs.mkdir(path.join(root, "web")); + await fs.symlink(path.join(root, "nowhere"), path.join(root, "dang"), DIR_LINK); + const before = await snapshot(root); + for (const n of ["api", "web", "dang"]) + expect(await destinationFault(root, entry(n), ctx)).toBe(`${n} already exists in the workspace — move it, or leave the clone out`); + expect(await snapshot(root)).toEqual(before); + }); + it("a file sync writes — equal to a planned path, or holding one, in any letter case; and a path the lock records", async () => { + const { root, ctx } = await layout(); + expect(await destinationFault(root, entry("agents", { path: "AGENTS.md" }), ctx)).toBe("AGENTS.md is a file sync writes"); + expect(await destinationFault(root, entry("docs"), ctx)).toBe("docs is a file sync writes"); + expect(await destinationFault(root, entry("d", { path: "Docs" }), ctx)).toBe("Docs is a file sync writes"); + expect(await destinationFault(root, entry("g", { path: "docs/guide.md" }), ctx)).toBe("docs/guide.md is a file sync writes"); + expect(await destinationFault(root, entry("old"), { ...ctx, lockPaths: ["old/readme.md"] })).toBe("old is a file sync writes"); + expect(await destinationFault(root, entry("doc"), ctx)).toBeNull(); + }); + it("inside the Forge — a Forge directory under the workspace", async () => { + const { root, ctx } = await layout(); + const forge = path.join(root, "forge"); + await fs.mkdir(forge); + expect(await destinationFault(root, entry("x", { path: "forge/x" }), { ...ctx, forgeDir: forge })).toBe("forge/x lies inside the Forge"); + expect(await destinationFault(root, entry("y"), { ...ctx, forgeDir: forge })).toBeNull(); + }); + it("inside the Forge — every destination of a workspace that lies inside its Forge", async () => { + const forge = await scratch(); + const root = path.join(forge, "clients", "acme"); + await fs.mkdir(root, { recursive: true }); + const ctx = { planned: [], lockPaths: [], forgeDir: forge }; + expect(await destinationFault(root, entry("api"), ctx)).toBe("api lies inside the Forge"); + }); + it(`a parent that is a link out of the workspace (${DIR_LINK})`, async () => { + const { root, ctx } = await layout(); + const out = await scratch(); + await fs.symlink(out, path.join(root, "apps"), DIR_LINK); + const before = await snapshot(root); + expect(await destinationFault(root, entry("web", { path: "apps/web" }), ctx)).toBe("apps/web leaves the workspace through a link"); + expect(await snapshot(root)).toEqual(before); + }); + it(`a parent that is a link to a directory inside the workspace (${DIR_LINK}): allowed`, async () => { + const { root, ctx } = await layout(); + await fs.mkdir(path.join(root, "real")); + await fs.symlink(path.join(root, "real"), path.join(root, "apps"), DIR_LINK); + expect(await destinationFault(root, entry("web", { path: "apps/web" }), ctx)).toBeNull(); + }); + it("a parent that is a regular file: not a directory, the ancestor as the profile spells it", async () => { + const { root, ctx } = await layout(); + await fs.writeFile(path.join(root, "apps"), "x"); + expect(await destinationFault(root, entry("web", { path: "apps/web" }), ctx)).toBe("apps is not a directory"); + expect(await destinationFault(root, entry("w", { path: "apps/x/web" }), ctx)).toBe("apps is not a directory"); + }); + it.skipIf(WIN)("a parent that is a link to a file outside the workspace: leaves the workspace (containment first) — not run on Windows", async () => { + const { root, ctx } = await layout(); + const out = await scratch(); + await fs.writeFile(path.join(out, "f"), "x"); + await fs.symlink(path.join(out, "f"), path.join(root, "apps"), "file"); + expect(await destinationFault(root, entry("web", { path: "apps/web" }), ctx)).toBe("apps/web leaves the workspace through a link"); + }); + it(`a parent that is a dangling link (${DIR_LINK}): realpathOfNearest's own message, naming the component`, async () => { + const { root, ctx } = await layout(); + await fs.symlink(path.join(root, "gone"), path.join(root, "apps"), DIR_LINK); + const m = await destinationFault(root, entry("web", { path: "apps/web" }), ctx); + expect(m).toMatch(new RegExp(`^${path.join(root, "apps").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} (exists but cannot be resolved|cannot be inspected) \\([A-Z]+\\)$`)); + }); + it("a workspace directory that does not exist yet: no false C5", async () => { + const parent = await scratch(); + const forge = path.join(parent, "forge"); + await fs.mkdir(forge); + const root = path.join(parent, "new", "ws"); + expect(await destinationFault(root, entry("api"), { planned: [], lockPaths: [], forgeDir: forge })).toBeNull(); + expect(await destinationFault(root, entry("web", { path: "apps/web" }), { planned: [], lockPaths: [], forgeDir: forge })).toBeNull(); + }); + it(`a workspace that does not exist yet under a link elsewhere (${DIR_LINK}): both sides resolve through it, no false C5`, async () => { + const parent = await scratch(); + const elsewhere = await scratch(); + const forge = path.join(parent, "forge"); + await fs.mkdir(forge); + await fs.symlink(elsewhere, path.join(parent, "via"), DIR_LINK); + const root = path.join(parent, "via", "ws"); + expect(await destinationFault(root, entry("api"), { planned: [], lockPaths: [], forgeDir: forge })).toBeNull(); + }); +}); + +describe("listRepos — present, missing, blocked", () => { + it("each entry with its state, in order; blocked carries C5's reason; nothing written", async () => { + const { root, ctx } = await layout(); + await fs.mkdir(path.join(root, "api")); + const entries = [entry("api"), entry("web", { path: "apps/web", ref: "release" }), entry("docs")]; + const before = await snapshot(root); + expect(await listRepos(root, entries, ctx)).toEqual([ + { ...entries[0], state: "present", reason: null }, + { ...entries[1], state: "missing", reason: null }, + { ...entries[2], state: "blocked", reason: "docs is a file sync writes" }, + ]); + expect(await snapshot(root)).toEqual(before); + }); + it("present wins over a file sync writes: a destination that holds sync's files reads present", async () => { + const { root, ctx } = await layout(); + await fs.mkdir(path.join(root, "docs")); + expect((await listRepos(root, [entry("docs")], ctx))[0]).toMatchObject({ state: "present", reason: null }); + }); +}); + +describe("planClone — C3, C4, C5 and R1–R3, before anything", () => { + const profile = (repos: unknown[]) => ({ name: "acme", repos }) as never; + const repo = (name: string, extra: Record = {}) => ({ name, url: `https://git.example.com/acme/${name}.git`, ...extra }); + const NO_ENTRIES = "nothing to clone; drop --clone"; + it("C3: no entry — the caller's wording", async () => { + const { root, ctx } = await layout(); + await expect(planClone(root, profile([]), ctx, { present: "refuse", noEntries: NO_ENTRIES })).rejects.toThrow( + /^profile acme lists no repositories — nothing to clone; drop --clone$/, + ); + await expect(planClone(root, profile([]), ctx, { present: "skip", noEntries: "nothing to clone" })).rejects.toThrow(/^profile acme lists no repositories — nothing to clone$/); + }); + it("C4: the block does not read — profileRepos' message", async () => { + const { root, ctx } = await layout(); + await expect(planClone(root, profile([{ name: "a" }]), ctx, { present: "refuse", noEntries: NO_ENTRIES })).rejects.toThrow(/^profile acme: repos\[0\]: url is missing$/); + }); + it("C5 (init, present: refuse): an existing destination, checked for every entry before anything", async () => { + const { root, ctx } = await layout(); + await fs.mkdir(path.join(root, "web")); + await expect(planClone(root, profile([repo("api"), repo("web")]), ctx, { present: "refuse", noEntries: NO_ENTRIES })).rejects.toThrow( + /^cannot clone web: web already exists in the workspace — move it, or leave the clone out$/, + ); + }); + it("init: every entry passes — the entries, in order, none skipped", async () => { + const { root, ctx } = await layout(); + const r = await planClone(root, profile([repo("api"), repo("web", { path: "apps/web" })]), ctx, { present: "refuse", noEntries: NO_ENTRIES }); + expect(r.entries.map((e) => e.name)).toEqual(["api", "web"]); + expect(r.skipped).toEqual([]); + }); + it("repos clone (present: skip): a present entry is skipped, a missing one cloned", async () => { + const { root, ctx } = await layout(); + await fs.mkdir(path.join(root, "api")); + const r = await planClone(root, profile([repo("api"), repo("web")]), ctx, { present: "skip", noEntries: "nothing to clone" }); + expect(r.entries.map((e) => e.name)).toEqual(["web"]); + expect(r.skipped.map((e) => e.name)).toEqual(["api"]); + }); + it("repos clone: one blocked entry stops the run, named or not (C5)", async () => { + const { root, ctx } = await layout(); + await expect(planClone(root, profile([repo("api"), repo("docs")]), ctx, { present: "skip", noEntries: "nothing to clone" })).rejects.toThrow( + /^cannot clone docs: docs is a file sync writes$/, + ); + }); + it("names: only those, in the profile's order; compared without case", async () => { + const { root, ctx } = await layout(); + const r = await planClone(root, profile([repo("api"), repo("web"), repo("cli")]), ctx, { present: "skip", noEntries: "x", names: ["CLI", "api"] }); + expect(r.entries.map((e) => e.name)).toEqual(["api", "cli"]); + }); + it("R1: a name that is not an entry — printed only when it is shaped like a repository name", async () => { + const { root, ctx } = await layout(); + const p = profile([repo("api")]); + await expect(planClone(root, p, ctx, { present: "skip", noEntries: "x", names: ["web"] })).rejects.toThrow(/^profile acme has no repository web$/); + const MARK = "zq" + "mark"; + const e = await planClone(root, p, ctx, { present: "skip", noEntries: "x", names: [`$(${MARK})`] }).catch((x: Error) => x); + expect((e as Error).message).toBe("profile acme: a name given is not a repository name"); + }); + it("R3: a name given twice, compared without case — after R1", async () => { + const { root, ctx } = await layout(); + await expect(planClone(root, profile([repo("api")]), ctx, { present: "skip", noEntries: "x", names: ["api", "API"] })).rejects.toThrow(/^api is given twice$/); + await expect(planClone(root, profile([repo("api")]), ctx, { present: "skip", noEntries: "x", names: ["nope", "nope"] })).rejects.toThrow(/^profile acme has no repository nope$/); + }); + it("R2: a name given that is present — also under present: skip", async () => { + const { root, ctx } = await layout(); + await fs.mkdir(path.join(root, "apps", "web"), { recursive: true }); + await expect(planClone(root, profile([repo("web", { path: "apps/web" })]), ctx, { present: "skip", noEntries: "x", names: ["web"] })).rejects.toThrow( + /^web is present at apps\/web — nothing to clone$/, + ); + }); +}); + +describe("findGit — onPath, nothing else", () => { + const EXE = WIN ? ".exe" : ""; + it("an absolute path from a PATH that holds one; null from an empty PATH and from a relative entry only", async () => { + const dir = await scratch(); + const git = path.join(dir, "git" + EXE); + await fs.copyFile(process.execPath, git); + await fs.chmod(git, 0o755); + expect(await findGit({ PATH: dir })).toBe(git); + expect(await findGit({ PATH: "" })).toBeNull(); + expect(await findGit({ PATH: "relative-bin" })).toBeNull(); + }); + it("a git reachable only through a relative PATH entry is not found, though the file is there", async () => { + const dir = await scratch(); + await fs.copyFile(process.execPath, path.join(dir, "git" + EXE)); + await fs.chmod(path.join(dir, "git" + EXE), 0o755); + const rel = path.relative(process.cwd(), dir); + expect(path.isAbsolute(rel)).toBe(false); + expect(await findGit({ PATH: rel })).toBeNull(); + expect(await findGit({ PATH: dir })).toBe(path.join(dir, "git" + EXE)); + }); +}); + +describe("cloneRepos — one runner call per entry, in order, sequential", () => { + const GIT = WIN ? "C:\\Program Files\\Git\\cmd\\git.exe" : "/usr/bin/git"; + function recorder(codes: Array, onCall?: (req: RunRequest) => Promise) { + const calls: RunRequest[] = []; + let running = 0; + let overlapped = false; + const run = async (req: RunRequest) => { + if (running > 0) overlapped = true; + running++; + calls.push(req); + await new Promise((r) => setTimeout(r, 5)); + if (onCall) await onCall(req); + running--; + const c = codes[calls.length - 1]; + if (c === "reject") throw Object.assign(new Error("spawn EACCES"), { code: "EACCES" }); + return { code: c === undefined ? 0 : c }; + }; + return { run, calls, overlapped: () => overlapped }; + } + it("program, arguments, working directory and environment; GIT_TERMINAL_PROMPT=0 only off a terminal", async () => { + const { root } = await layout(); + const entries = [entry("api"), entry("web", { path: "apps/web", ref: "release" })]; + for (const stdinIsTty of [false, true]) { + const r = recorder([0, 0]); + const out = await cloneRepos(root, entries, { git: GIT, env: { MARKER_VAR: "kept", PATH: "/usr/bin" }, stdinIsTty }, r.run); + expect(out.map((o) => o.outcome)).toEqual(["cloned", "cloned"]); + expect(r.overlapped()).toBe(false); + expect(r.calls.map((c) => [c.file, c.cwd, c.stdin])).toEqual([ + [GIT, root, "inherit"], + [GIT, root, "inherit"], + ]); + expect(r.calls[0].args).toEqual(["clone", "--no-recurse-submodules", "--", entries[0].url, "api"]); + expect(r.calls[1].args).toEqual(["clone", "--no-recurse-submodules", "--branch=release", "--", entries[1].url, "apps/web"]); + for (const c of r.calls) { + expect(c.env.MARKER_VAR).toBe("kept"); + expect(c.env.GIT_ALLOW_PROTOCOL).toBe("https:ssh"); + expect(c.env.GIT_TERMINAL_PROMPT).toBe(stdinIsTty ? undefined : "0"); + } + } + }); + it("a failure in the middle: cloned, failed (git exited 128), cloned — the next entry still tried", async () => { + const { root } = await layout(); + const r = recorder([0, 128, 0]); + const out = await cloneRepos(root, [entry("a"), entry("b"), entry("c")], { git: GIT, env: {}, stdinIsTty: false }, r.run); + expect(out.map((o) => [o.entry.name, o.outcome, o.outcome === "failed" ? o.reason : null])).toEqual([ + ["a", "cloned", null], + ["b", "failed", "git exited 128"], + ["c", "cloned", null], + ]); + }); + it("a signal (no exit code) and a runner that rejects: failed, worded, the next entry still tried", async () => { + const { root } = await layout(); + const r = recorder([null, "reject", 0]); + const out = await cloneRepos(root, [entry("a"), entry("b"), entry("c")], { git: GIT, env: {}, stdinIsTty: false }, r.run); + expect(out.map((o) => (o.outcome === "failed" ? o.reason : o.outcome))).toEqual(["git was ended by a signal", "git could not be started", "cloned"]); + }); + it("a destination created between the plan and the clone, and a parent replaced by a link out: failed, no runner call", async () => { + const { root } = await layout(); + const out = await scratch(); + const r = recorder([0, 0, 0], async (req) => { + if (req.args.at(-1) === "first") { + await fs.mkdir(path.join(root, "second")); + await fs.symlink(out, path.join(root, "apps"), DIR_LINK); + } + }); + const res = await cloneRepos(root, [entry("first"), entry("second"), entry("third", { path: "apps/third" })], { git: GIT, env: {}, stdinIsTty: false }, r.run); + expect(res.map((o) => (o.outcome === "failed" ? o.reason : o.outcome))).toEqual(["cloned", "already exists", "leaves the workspace through a link"]); + expect(r.calls.length).toBe(1); + }); +}); From 98583e2c474199fbc5c4208d303daed96f75dac9 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 15:47:35 -0300 Subject: [PATCH 05/25] =?UTF-8?q?feat(sync):=20LoadOptions.fetchHint=20?= =?UTF-8?q?=E2=80=94=20a=20sync-mode=20fetch=20failure=20without=20the=20-?= =?UTF-8?q?-offline=20advice=20(spec=2037)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/sync.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/core/sync.ts b/src/core/sync.ts index 2c08db6..190e62c 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -84,6 +84,12 @@ export interface LoadOptions { refuseRemote?: (url: string) => string; /** Test seam: counts or fails the git calls of a remote load; absent, `defaultGit`. */ git?: GitRunner; + /** + * When a sync-mode fetch fails and there is a cached copy, what the error's tail says (spec 37 §5.2): + * - `"offline"` (default): "run with --offline to use the cached copy" + * - `"fresh-copy"`: "repos clone reads the profile's repositories only from a fresh copy" + */ + fetchHint?: "offline" | "fresh-copy"; } /** The result of loading a Forge by its source before a profile is known (spec 28 §5.2). */ @@ -220,6 +226,7 @@ async function remoteTree( const mode = opts.mode ?? "read"; const offline = opts.offline || mode === "no-fetch"; const git = opts.git; + const fetchHint = opts.fetchHint ?? "offline"; const origin = (t: CachedTree): ForgeOrigin => ({ kind: "remote", source: url, @@ -250,8 +257,12 @@ async function remoteTree( throw new Error( `cannot fetch the Forge ${url}: ${e.gitMessage} — ${e.haveCopy ? `and the cached copy cannot resolve ${ref === null ? "the default branch" : refHoldsCredentials(ref) ? shownRef(ref) : ref}` : "and there is no cached copy to fall back on"}`, ); - if (mode === "sync") - throw new Error(`cannot fetch the Forge ${url}: ${e.gitMessage} — run with --offline to use the cached copy (${short(e.cached.commit)} fetched ${e.cached.fetchedAt ?? "never"})`); + if (mode === "sync") { + const tail = fetchHint === "fresh-copy" + ? "repos clone reads the profile's repositories only from a fresh copy" + : `run with --offline to use the cached copy (${short(e.cached.commit)} fetched ${e.cached.fetchedAt ?? "never"})`; + throw new Error(`cannot fetch the Forge ${url}: ${e.gitMessage} — ${tail}`); + } const t = await ensureTree(url, ref, { home, offline: true, git }); return { tree: t.dir, origin: origin(t), warning: `Forge ${url} not fetched (${e.gitMessage}) — using ${used(t)}` }; } From baa1947c1623ae15c78f6e72131d00528f2c3a19 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 15:52:27 -0300 Subject: [PATCH 06/25] feat(init): planInit clones on request, askClone lists the repositories and asks, the confirmation names the clone (spec 37) --- src/core/init-flow.ts | 118 ++++++++++++++++++++- src/core/init.ts | 20 +++- test/init-clone.test.ts | 229 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 361 insertions(+), 6 deletions(-) create mode 100644 test/init-clone.test.ts diff --git a/src/core/init-flow.ts b/src/core/init-flow.ts index f3a0399..434dd32 100644 --- a/src/core/init-flow.ts +++ b/src/core/init-flow.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; import { 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"; @@ -7,6 +8,7 @@ import { planRecipeEdit, slotHeld } from "./recipe-edit.js"; import { readRegistry } from "./registry.js"; import { classifyForge, credentialFault, CREDENTIAL_HIDDEN, refHoldsCredentials } from "./remote.js"; import { profileNotFoundMessage, resolve } from "./resolve.js"; +import { profileRepos, type RepoEntry } from "./repos.js"; import { loadForgeSource, mergeWorkspaceConfig, unsetDeclared, unsetRefused, type LoadedForge, type LoadOptions } from "./sync.js"; import type { LocalKey } from "./workspace-yaml.js"; @@ -70,6 +72,91 @@ export function yesNo(answer: string): boolean | null { return null; } +/** + * The clone question of the interactive flow (spec 37 §4.3): lists the profile's repositories, + * asks whether to clone them, re-asks on errors. Returns the decision and the entries to clone. + * + * @param io The prompt interface. + * @param init The init plan. + * @param opts.root Absolute path of the workspace. + * @param opts.offline Whether --offline was given (no list, no question). + * @param opts.git The git binary path, or null if not found. + * @returns `{clone: false, entries: []}` when nothing to clone or the user said no; + * `{clone: true, entries}` when the user said yes and planClone succeeded; + * `{clone: false, entries: [], refused: true}` when the first sync is refused (unset params); + * `"cancelled"` when the user cancelled. + */ +export async function askClone( + io: InitIo, + init: InitPlan, + opts: { root: string; offline: boolean; git: string | null }, +): Promise<{ clone: boolean; entries: RepoEntry[]; refused?: true } | "cancelled"> { + // --offline → no list, no question + if (opts.offline) { + return { clone: false, entries: [] }; + } + + // profileRepos throws → say one line, no question + const profile = init.ws.forge.profiles.get(init.ws.config.profile)!; + let allEntries: RepoEntry[]; + try { + allEntries = profileRepos(profile); + } catch (e) { + io.say(`repositories: not offered — ${(e as Error).message}`); + return { clone: false, entries: [] }; + } + + // no entry → no list, no question + if (allEntries.length === 0) { + return { clone: false, entries: [] }; + } + + // a plan whose first sync would be refused → no list, no question, refused flag + const unset = unsetDeclared(init.plan); + if (unset.length > 0) { + return { clone: false, entries: [], refused: true }; + } + + // say the list + const maxName = Math.max(...allEntries.map((e) => e.name.length)); + const maxUrl = Math.max(...allEntries.map((e) => e.url.length)); + io.say(`Repositories of ${profile.name}:`); + for (let i = 0; i < allEntries.length; i++) { + const e = allEntries[i]; + const ref = e.ref !== null ? ` @ ${e.ref}` : ""; + io.say(` ${i + 1}) ${e.name.padEnd(maxName)} ${e.url.padEnd(maxUrl)} → ${e.path}${ref}`); + } + + // loop on confirm + for (;;) { + const answer = await io.confirm("Clone them into the workspace [no]: "); + if (answer === null) return "cancelled"; + if (answer === "") return { clone: false, entries: [] }; + const yn = yesNo(answer); + if (yn === false) return { clone: false, entries: [] }; + if (yn === null) { + io.say("answer yes or no"); + continue; + } + + // yes → check git + if (opts.git === null) { + io.say("cloning needs git — install it, or leave the clone out"); + continue; + } + + // yes → planClone + try { + const ctx = { planned: init.plan.files.map((f) => f.path), lockPaths: [] as string[], forgeDir: init.ws.forge.root }; + const clonePlan = await planClone(opts.root, profile, ctx, { present: "refuse", noEntries: "nothing to clone" }); + return { clone: true, entries: clonePlan.entries }; + } catch (e) { + io.say((e as Error).message); + continue; + } + } +} + /** * Says the summary and the `first sync:` line, asks through `io.confirm`, * re-asks anything but yes/no, returns confirmed or cancelled. A plan whose sync @@ -79,7 +166,7 @@ export async function confirmInit( init: InitPlan, root: string, io: InitIo, - opts: { sync: boolean }, + opts: { sync: boolean; clone?: number; cloneRefused?: boolean }, ): Promise<"confirmed" | "cancelled"> { io.say(`craftar init — about to write craftar.yaml in ${root}`); io.say(` ${initLine(init)}`); @@ -95,9 +182,29 @@ export async function confirmInit( io.say(` first sync: ${parts.join(", ")}`); } - const question = opts.sync && unset.length === 0 - ? "Write craftar.yaml and sync [yes]: " - : "Write craftar.yaml [yes]: "; + // When the sync is refused and cloneRefused, say the not-cloned line + if (unset.length > 0 && opts.cloneRefused) { + io.say(" repositories: not cloned — the first sync is refused"); + } + + // Build the question + let question: string; + const clone = opts.clone ?? 0; + if (unset.length > 0) { + // Sync refused → offer only craftar.yaml + question = "Write craftar.yaml [yes]: "; + } else if (clone > 0) { + const repoWord = clone === 1 ? "repository" : "repositories"; + if (opts.sync) { + question = `Write craftar.yaml, sync and clone ${clone} ${repoWord} [yes]: `; + } else { + question = `Write craftar.yaml and clone ${clone} ${repoWord} [yes]: `; + } + } else if (opts.sync) { + question = "Write craftar.yaml and sync [yes]: "; + } else { + question = "Write craftar.yaml [yes]: "; + } for (;;) { const answer = await io.confirm(question); @@ -134,7 +241,7 @@ function maybeQuote(value: string): string { */ export function againLine( answers: InitAnswers, - given: { workspace?: string; sync: boolean; offline: boolean }, + given: { workspace?: string; sync: boolean; offline: boolean; clone?: boolean }, ): string { if (credentialFault(answers.forge)) { throw new Error("againLine: the Forge holds credentials"); @@ -151,6 +258,7 @@ export function againLine( if (given.workspace !== undefined) parts.push(`--workspace ${maybeQuote(given.workspace)}`); if (!given.sync) parts.push("--no-sync"); if (given.offline) parts.push("--offline"); + if (given.clone === true) parts.push("--clone"); return parts.join(" "); } diff --git a/src/core/init.ts b/src/core/init.ts index eeeea28..0e9bd18 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -1,8 +1,10 @@ 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 { planRecipeEdit } from "./recipe-edit.js"; import { classifyForge, credentialFault } from "./remote.js"; +import { type RepoEntry } from "./repos.js"; import { LOCAL_FILE, loadWorkspaceConfig, @@ -10,6 +12,7 @@ import { plan, readLock, status, + unsetDeclared, workspaceOf, type FileStatus, type LoadedForge, @@ -19,6 +22,8 @@ import { } from "./sync.js"; import { readLocalFile, type LocalKey } from "./workspace-yaml.js"; +export type { RepoEntry }; + /** * `craftar init` (spec 23): steps 2–7 of §4.2 — the flag checks, the local-file check, the configuration * in memory, the Forge load, the recipe flags, the plan. Never writes, never prints: the command writes @@ -34,6 +39,8 @@ export interface InitInput { addRecipes?: string[]; removeRecipes?: string[]; replace?: boolean; + /** Clone the profile's repositories into the workspace (spec 37 §4.2). */ + clone?: boolean; /** A Forge already loaded: `planInit` reuses it instead of loading again (spec 28 §5.2). * 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`. */ @@ -51,6 +58,8 @@ export interface InitPlan { notes: string[]; /** Where the targets come from, for the first line of the output (§4.4). */ targetsFrom: "flag" | "local" | "profile"; + /** The repositories to clone, in profile order; empty when `input.clone` is not true (spec 37 §4.2). */ + repos: RepoEntry[]; } /** The flag each local key would replace (N10). */ @@ -215,6 +224,15 @@ export async function planInit(root: string, input: InitInput, opts: InitOptions } if (input.targets !== undefined) doc.targets = input.targets; + // Clone planning (spec 37 §4.2): when input.clone is true, planClone errors propagate. + 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: [] as string[], forgeDir: ws.forge.root }; + const clonePlan = await planClone(root, profile, ctx, { present: "refuse", noEntries: "nothing to clone; drop --clone" }); + repos = clonePlan.entries; + } + const targetsFrom = input.targets !== undefined ? "flag" : local.includes("targets") ? "local" : "profile"; - return { ws, plan: p, lock, statuses, text: YAML.stringify(doc), notes, targetsFrom }; + return { ws, plan: p, lock, statuses, text: YAML.stringify(doc), notes, targetsFrom, repos }; } diff --git a/test/init-clone.test.ts b/test/init-clone.test.ts new file mode 100644 index 0000000..8a0d5ed --- /dev/null +++ b/test/init-clone.test.ts @@ -0,0 +1,229 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { againLine, askClone, confirmInit, type InitAnswers, type InitIo } from "../src/core/init-flow.js"; +import { planInit } from "../src/core/init.js"; +import { loadWorkspace } from "../src/core/sync.js"; +import { makeForge, profile, recipe, rule, tmpDir } from "./helpers/forge.js"; +import { git, remoteForge } from "./helpers/remote.js"; + +// Spec 37 §4.2–§4.3, §5.2 — planInit with clone, askClone, confirmInit's clone questions, the again line, and +// LoadOptions.fetchHint (R4's tail). Nothing here runs git clone. + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +const REPOS = [ + { name: "portal-api", url: "https://git.example.com/acme/portal-api.git" }, + { name: "portal-web", url: "git@git.example.com:acme/portal-web.git", path: "apps/portal-web", ref: "release" }, +]; + +async function setup(opts: { repos?: unknown[]; declared?: boolean } = {}) { + const parent = await fs.realpath(await tmpDir("craftar-init-clone-")); + cleanups.push(() => fs.rm(parent, { recursive: true, force: true })); + const forge = path.join(parent, "forge"); + const extra = opts.declared ? { params: { token: { description: "a value nobody set" } } } : {}; + await makeForge(forge, { + ingredients: [rule("a", opts.declared ? "# A {{token}}\n" : "# A\n", extra)], + recipes: [recipe("base", ["rule/a"])], + profiles: [profile("acme", ["base"], ["claude-code"], { repos: opts.repos ?? REPOS })], + }); + const ws = path.join(parent, "ws"); + return { parent, forge, ws }; +} + +function script(answers: Array) { + const said: string[] = []; + const confirmed: string[] = []; + const io: InitIo = { + ask: async (q) => { + throw new Error(`unexpected ask: ${q}`); + }, + confirm: async (q) => { + confirmed.push(q); + if (!answers.length) throw new Error(`no scripted answer left for: ${q}`); + return answers.shift()!; + }, + say: (l) => void said.push(l), + }; + return { io, said, confirmed, left: () => answers.length }; +} +const GIT = "/usr/bin/git"; + +describe("planInit with clone (C3–C5 through planClone)", () => { + it("clone: the entries, in order, in InitPlan.repos; without it, repos is []", async () => { + const s = await setup(); + const withClone = await planInit(s.ws, { forge: s.forge, profile: "acme", clone: true }); + expect(withClone.repos.map((e) => [e.name, e.path, e.ref])).toEqual([ + ["portal-api", "portal-api", null], + ["portal-web", "apps/portal-web", "release"], + ]); + const without = await planInit(s.ws, { forge: s.forge, profile: "acme" }); + expect(without.repos).toEqual([]); + }); + it("C3: no entry", async () => { + const s = await setup({ repos: [] }); + await expect(planInit(s.ws, { forge: s.forge, profile: "acme", clone: true })).rejects.toThrow( + /^profile acme lists no repositories — nothing to clone; drop --clone$/, + ); + }); + it("C4: a block that does not read — only when cloning is asked for", async () => { + const s = await setup({ repos: [{ name: "a", url: "file:///srv/a.git" }] }); + await expect(planInit(s.ws, { forge: s.forge, profile: "acme", clone: true })).rejects.toThrow(/^profile acme: repos\[0\]: url is not an https or ssh git URL$/); + await expect(planInit(s.ws, { forge: s.forge, profile: "acme" })).resolves.toBeTruthy(); + }); + it("a dot-folder destination (.claude) is refused by profileRepos itself — C4 — before any destination check", async () => { + const s = await setup({ repos: [{ name: "claude", url: "https://git.example.com/acme/c.git", path: "Claude-Thing/x" }, { name: "x", url: "https://git.example.com/acme/x.git", path: ".claude" }] }); + await expect(planInit(s.ws, { forge: s.forge, profile: "acme", clone: true })).rejects.toThrow(/^profile acme: repos\[1\]: path is not a relative path of plain names$/); + }); + it("C5: a destination that exists, in a workspace that exists", async () => { + const s = await setup(); + await fs.mkdir(path.join(s.ws, "portal-api"), { recursive: true }); + await expect(planInit(s.ws, { forge: s.forge, profile: "acme", clone: true })).rejects.toThrow( + /^cannot clone portal-api: portal-api already exists in the workspace — move it, or leave the clone out$/, + ); + }); + it("C5: inside the Forge, for a workspace that lies inside its Forge", async () => { + const s = await setup(); + const ws = path.join(s.forge, "clients", "acme"); + await expect(planInit(ws, { forge: s.forge, profile: "acme", clone: true })).rejects.toThrow(/^cannot clone portal-api: portal-api lies inside the Forge$/); + }); +}); + +describe("askClone — the list and the question (§4.3)", () => { + it("the list (name, URL as written, destination, ref) and the question; Enter is no", async () => { + const s = await setup(); + const init = await planInit(s.ws, { forge: s.forge, profile: "acme" }); + const sc = script([""]); + const r = await askClone(sc.io, init, { root: s.ws, offline: false, git: GIT }); + expect(r).toEqual({ clone: false, entries: [] }); + expect(sc.said).toEqual([ + "Repositories of acme:", + " 1) portal-api https://git.example.com/acme/portal-api.git → portal-api", + " 2) portal-web git@git.example.com:acme/portal-web.git → apps/portal-web @ release", + ]); + expect(sc.confirmed).toEqual(["Clone them into the workspace [no]: "]); + }); + it("yes / y clones; no / n does not; anything else re-asks", async () => { + const s = await setup(); + const init = await planInit(s.ws, { forge: s.forge, profile: "acme" }); + for (const [answers, clone] of [[["y"], true], [["YES"], true], [["n"], false], [["no"], false], [["maybe", "yes"], true]] as const) { + const sc = script([...answers]); + const r = await askClone(sc.io, init, { root: s.ws, offline: false, git: GIT }); + expect(r === "cancelled" ? r : r.clone, answers.join(",")).toBe(clone); + if (r !== "cancelled" && r.clone) expect(r.entries.map((e) => e.name)).toEqual(["portal-api", "portal-web"]); + expect(sc.left()).toBe(0); + if (answers.includes("maybe")) expect(sc.said).toContain("answer yes or no"); + } + }); + it("end of input cancels", async () => { + const s = await setup(); + const init = await planInit(s.ws, { forge: s.forge, profile: "acme" }); + expect(await askClone(script([null]).io, init, { root: s.ws, offline: false, git: GIT })).toBe("cancelled"); + }); + it("no question: a profile with no entry, and --offline", async () => { + const s = await setup({ repos: [] }); + const init = await planInit(s.ws, { forge: s.forge, profile: "acme" }); + const sc = script([]); + expect(await askClone(sc.io, init, { root: s.ws, offline: false, git: GIT })).toEqual({ clone: false, entries: [] }); + const t = await setup(); + const init2 = await planInit(t.ws, { forge: t.forge, profile: "acme" }); + const sc2 = script([]); + expect(await askClone(sc2.io, init2, { root: t.ws, offline: true, git: GIT })).toEqual({ clone: false, entries: [] }); + expect([...sc.said, ...sc2.said, ...sc.confirmed, ...sc2.confirmed]).toEqual([]); + }); + it("a block that does not read: one line, no question", async () => { + const s = await setup({ repos: [{ name: "a" }] }); + const init = await planInit(s.ws, { forge: s.forge, profile: "acme" }); + const sc = script([]); + expect(await askClone(sc.io, init, { root: s.ws, offline: false, git: GIT })).toEqual({ clone: false, entries: [] }); + expect(sc.said).toEqual(["repositories: not offered — profile acme: repos[0]: url is missing"]); + }); + it("a plan whose first sync would be refused: no list, no question (confirmInit says why)", async () => { + const s = await setup({ declared: true }); + const init = await planInit(s.ws, { forge: s.forge, profile: "acme" }); + const sc = script([]); + expect(await askClone(sc.io, init, { root: s.ws, offline: false, git: GIT })).toEqual({ clone: false, entries: [], refused: true }); + expect([...sc.said, ...sc.confirmed]).toEqual([]); + }); + it("yes meeting C6 (no git) or C5 (a destination appeared): said, and the question asked again", async () => { + const s = await setup(); + const init = await planInit(s.ws, { forge: s.forge, profile: "acme" }); + const sc = script(["yes", "no"]); + expect(await askClone(sc.io, init, { root: s.ws, offline: false, git: null })).toEqual({ clone: false, entries: [] }); + expect(sc.said).toContain("cloning needs git — install it, or leave the clone out"); + expect(sc.confirmed.length).toBe(2); + await fs.mkdir(path.join(s.ws, "apps", "portal-web"), { recursive: true }); + const sc2 = script(["yes", ""]); + expect(await askClone(sc2.io, init, { root: s.ws, offline: false, git: GIT })).toEqual({ clone: false, entries: [] }); + expect(sc2.said).toContain("cannot clone portal-web: apps/portal-web already exists in the workspace — move it, or leave the clone out"); + }); +}); + +describe("confirmInit — the question names the clone (§4.3)", () => { + it("sync and clone; --no-sync and clone; one repository; no clone as today", async () => { + const s = await setup(); + const init = await planInit(s.ws, { forge: s.forge, profile: "acme" }); + const ask = async (opts: Parameters[3]) => { + const sc = script([""]); + await confirmInit(init, s.ws, sc.io, opts); + return sc.confirmed[0]; + }; + expect(await ask({ sync: true, clone: 2 })).toBe("Write craftar.yaml, sync and clone 2 repositories [yes]: "); + expect(await ask({ sync: false, clone: 2 })).toBe("Write craftar.yaml and clone 2 repositories [yes]: "); + expect(await ask({ sync: true, clone: 1 })).toBe("Write craftar.yaml, sync and clone 1 repository [yes]: "); + expect(await ask({ sync: true })).toBe("Write craftar.yaml and sync [yes]: "); + expect(await ask({ sync: false })).toBe("Write craftar.yaml [yes]: "); + }); + it("a refused first sync: the not-cloned line before the question, which offers neither sync nor clone", async () => { + const s = await setup({ declared: true }); + const init = await planInit(s.ws, { forge: s.forge, profile: "acme" }); + const sc = script([""]); + await confirmInit(init, s.ws, sc.io, { sync: true, cloneRefused: true }); + expect(sc.confirmed).toEqual(["Write craftar.yaml [yes]: "]); + expect(sc.said.at(-1)).toBe(" repositories: not cloned — the first sync is refused"); + const sc2 = script([""]); + await confirmInit(init, s.ws, sc2.io, { sync: true }); + expect(sc2.said).not.toContain(" repositories: not cloned — the first sync is refused"); + }); +}); + +describe("againLine — --clone", () => { + const answers: InitAnswers = { forge: "../forge", profile: "acme", addRecipes: [], removeRecipes: [], replace: false }; + it("gains --clone when the answer was yes or the flag was given; nothing for a no", () => { + expect(againLine(answers, { sync: true, offline: false, clone: true })).toBe(" again craftar init --forge ../forge --profile acme --clone"); + expect(againLine(answers, { sync: true, offline: false, clone: false })).toBe(" again craftar init --forge ../forge --profile acme"); + expect(againLine(answers, { sync: true, offline: false })).toBe(" again craftar init --forge ../forge --profile acme"); + }); +}); + +describe("LoadOptions.fetchHint — R4's tail (§5.2)", () => { + it("fresh-copy: the sync-mode fetch failure without the --offline advice; the default keeps it", async () => { + const r = await remoteForge({ ingredients: [rule("a", "# A\n")], recipes: [recipe("base", ["rule/a"])], profiles: [profile("acme", ["base"])] }); + cleanups.push(r.cleanup); + const ws = await fs.realpath(await tmpDir("craftar-fetchhint-ws-")); + const home = await fs.realpath(await tmpDir("craftar-fetchhint-home-")); + cleanups.push(() => fs.rm(ws, { recursive: true, force: true }), () => fs.rm(home, { recursive: true, force: true })); + await fs.writeFile(path.join(ws, "craftar.yaml"), `forge: ${r.url}\nprofile: acme\n`); + await loadWorkspace(ws, { mode: "sync", home }); + const sha = git(r.src, "rev-parse", "HEAD").slice(0, 8); + await fs.rename(r.bare, r.bare + ".gone"); + const msg = async (opts: Record) => { + try { + await loadWorkspace(ws, { mode: "sync", home, ...opts }); + } catch (e) { + return (e as Error).message; + } + throw new Error("expected the load to fail"); + }; + const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + expect(await msg({ fetchHint: "fresh-copy" })).toMatch( + new RegExp(`^cannot fetch the Forge ${esc(r.url)}: [^\\n]+ — repos clone reads the profile's repositories only from a fresh copy$`), + ); + expect(await msg({ fetchHint: "fresh-copy" })).not.toContain("--offline"); + expect(await msg({})).toMatch(new RegExp(` — run with --offline to use the cached copy \\(${sha} fetched [^)]+\\)$`)); + expect(await msg({ fetchHint: "offline" })).toMatch(new RegExp(` — run with --offline to use the cached copy \\(${sha} fetched [^)]+\\)$`)); + }); +}); From 1a65d808c497c022a60ec62c971fcd7878789ee6 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 15:58:35 -0300 Subject: [PATCH 07/25] feat(init): --clone clones the profile's repositories after the first sync, reports each, exits 1 on a failed clone (spec 37) --- src/cli.ts | 91 ++++++++++++++++- src/core/init.ts | 1 - test/helpers/fake-git.ts | 70 ++++++++++++++ test/init-clone-cli.test.ts | 188 ++++++++++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 test/helpers/fake-git.ts create mode 100644 test/init-clone-cli.test.ts diff --git a/src/cli.ts b/src/cli.ts index 731ac6c..2c26fc5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -46,7 +46,9 @@ import { } from "./core/unify.js"; import { checkParamWrites, writeParamFile } from "./core/param-writes.js"; import { checkInitFlags, initLine, planInit, type InitInput } from "./core/init.js"; -import { askInit, confirmInit, againLine, type InitAnswers } from "./core/init-flow.js"; +import { askInit, askClone, confirmInit, againLine, type InitAnswers } from "./core/init-flow.js"; +import { findGit, cloneRepos, type CloneResult } from "./core/clone.js"; +import { byHand, type RepoEntry } from "./core/repos.js"; import { readlineIo } from "./prompt.js"; import { editRecipesText, planRecipeEdit, recipeDiffLine, type RecipeOp } from "./core/recipe-edit.js"; import { localKeys, readLocalFile } from "./core/workspace-yaml.js"; @@ -239,11 +241,42 @@ async function applyAndReport(ws: Workspace, p: Plan, st: FileStatus[], lock: Lo return r; } +/** + * The clone step for `craftar init --clone`: clones the entries, prints the report, sets exitCode on failures. + * Calls `again()` at the end. + */ +async function runCloneStep(root: string, profile: string, entries: RepoEntry[], git: string, again: () => void): Promise { + const n = entries.length; + const repoWord = n === 1 ? "repository" : "repositories"; + console.log(pc.bold(`craftar init — cloning ${n} ${repoWord} of ${profile}`)); + const results = await cloneRepos(root, entries, { git, env: process.env, stdinIsTty: process.stdin.isTTY === true }); + const maxName = Math.max(...results.map((r) => r.entry.name.length)); + for (const r of results) { + if (r.outcome === "cloned") { + console.log(` cloned ${r.entry.name.padEnd(maxName)} → ${r.entry.path}`); + } else { + console.log(` failed ${r.entry.name.padEnd(maxName)} → ${r.entry.path} (${r.reason})`); + } + } + const failed = results.filter((r): r is CloneResult & { outcome: "failed" } => r.outcome === "failed"); + if (failed.length > 0) { + const ok = results.length - failed.length; + console.log(` ${ok} of ${n} cloned — clone the rest by hand, in ${root}:`); + for (const f of failed) { + console.log(` ${byHand(f.entry)}`); + } + const failedNames = failed.map((f) => f.entry.name); + console.log(` or, from the workspace: craftar repos clone ${failedNames.join(" ")}`); + process.exitCode = 1; + } + again(); +} + /* ---------------------------------------------------------------- init */ 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; 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 --clone, clone the profile's repositories into the workspace after the sync; 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") @@ -253,6 +286,8 @@ program .option("--remove-recipe ", "remove a recipe of the profile's (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") + .option("--no-clone", "do not clone, and do not ask") .option("--offline", "use the cached copy of a remote Forge, without fetching", false) .option("--workspace

", "workspace root, created when missing", ".") .action( @@ -265,6 +300,7 @@ program removeRecipe: string[]; replace: boolean; sync: boolean; + clone?: boolean; offline: boolean; workspace: string; }, cmd) => { @@ -295,6 +331,19 @@ program addRecipes: o.addRecipe, removeRecipes: o.removeRecipe, }); + // C1: --clone and --no-clone cannot be given together + if (process.argv.includes("--clone") && process.argv.includes("--no-clone")) { + fail("pass --clone or --no-clone, not both"); + } + // C2: --clone with --offline + if (o.clone === true && o.offline) { + fail("--clone needs the network — drop --offline, or clone later with git"); + } + // C6: --clone with no git on PATH + const git = await findGit(process.env); + if (o.clone === true && git === null) { + fail("cloning needs git — install it, or leave the clone out"); + } // Mode: flags when both given; else interactive when both streams are TTYs; else N2 const bothGiven = o.forge !== undefined && o.profile !== undefined; const isTTY = process.stdin.isTTY && process.stdout.isTTY; @@ -341,19 +390,37 @@ program addRecipes: o.addRecipe, removeRecipes: o.removeRecipe, replace: o.replace, + clone: o.clone === true, }; } const init = await planInit(root, input, { ...load(o, "sync"), local }); + // Interactive mode: when o.clone is undefined, ask about cloning between planInit and confirmInit + let cloneEntries: RepoEntry[] = []; + let cloneRefused = false; if (answers !== null) { + if (o.clone === undefined) { + const io = readlineIo(process.stdin, process.stdout, { terminal: true }); + const cloneResult = await askClone(io, init, { root, offline: o.offline, git }); + if (cloneResult === "cancelled") fail("init cancelled — nothing written"); + cloneEntries = cloneResult.entries; + cloneRefused = cloneResult.refused === true; + } const io = readlineIo(process.stdin, process.stdout, { terminal: true }); - const confirm = await confirmInit(init, root, io, { sync: o.sync }); + const confirm = await confirmInit(init, root, io, { + sync: o.sync, + clone: cloneEntries.length || undefined, + cloneRefused: o.clone === true || cloneRefused, + }); if (confirm === "cancelled") fail("init cancelled — nothing written"); + } else if (o.clone === true) { + // Non-interactive mode with --clone: use repos from planInit + cloneEntries = init.repos; } const { ws, plan: p } = init; 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 })); + console.log(againLine(answers, { workspace: workspaceGiven ? o.workspace : undefined, sync: o.sync, offline: o.offline, clone: cloneEntries.length > 0 })); } }; // Every refusal is above: only now does the directory, and craftar.yaml, come to exist @@ -368,9 +435,18 @@ program console.log(` ${initLine(init)}`); for (const n of init.notes) console.log(` note ${n}`); const unset = unsetDeclared(p); + const cloning = cloneEntries.length > 0; + const refused = unset.length > 0; if (!o.sync) { console.log(nextSyncLine(init.statuses, unset)); for (const w of p.warnings) console.log(` ${pc.yellow("warn")} ${w}`); + if (cloning && refused) { + console.log(" repositories: not cloned — the first sync is refused"); + } else if (cloning && !refused) { + // Clone even without sync + await runCloneStep(root, ws.config.profile, cloneEntries, git!, again); + return; + } again(); return; } @@ -378,6 +454,9 @@ program // first sync is not run. exitCode, not fail(): the `again` line still follows the block. if (unset.length) { for (const w of p.warnings) console.log(` ${pc.yellow("warn")} ${w}`); + if (cloning) { + console.log(" repositories: not cloned — the first sync is refused"); + } refusalBlock(unset, { thenSync: true }); again(); process.exitCode = 1; @@ -389,6 +468,10 @@ program console.log( ` ${pc.yellow("warn")} ${collisions} file(s) already in the workspace differ from the Forge and were left as they are — to bring them into the Forge, run craftar import`, ); + if (cloning) { + await runCloneStep(root, ws.config.profile, cloneEntries, git!, again); + return; + } again(); }, ); diff --git a/src/core/init.ts b/src/core/init.ts index 0e9bd18..7da8cff 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -12,7 +12,6 @@ import { plan, readLock, status, - unsetDeclared, workspaceOf, type FileStatus, type LoadedForge, diff --git a/test/helpers/fake-git.ts b/test/helpers/fake-git.ts new file mode 100644 index 0000000..ed9c589 --- /dev/null +++ b/test/helpers/fake-git.ts @@ -0,0 +1,70 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { tmpDir } from "./forge.js"; + +// Spec 37 §10: no test runs a real `git clone` and none reaches the network. The CLI's clone step is exercised with +// a fake git: a copy of node named `git` (`git.exe` on win32) alone on an absolute PATH entry — the trick +// test/local-bin.test.ts uses — and NODE_OPTIONS preloading a hook. The hook does nothing in any process but that +// copy (so the craftar process under test, which inherits NODE_OPTIONS, is untouched). In the copy it reads git's +// argument list from process.argv (node took the first argument, `clone`, as its script) and, for a clone, records +// the call and creates the destination — or exits 128 for a destination named in CRAFTAR_TEST_CLONE_FAIL. Any other +// git call (the Forge load's `git -C rev-parse HEAD`) exits 1: the commit reads null, as on a machine +// without git (`gitHead`). Nothing is written in the workspace but the destinations. + +const EXE = process.platform === "win32" ? ".exe" : ""; + +const HOOK = `"use strict"; +const fs = require("fs"); +const path = require("path"); +if (path.basename(process.execPath).toLowerCase().replace(/\\.exe$/, "") === "git") { + const script = process.argv[1] ? path.basename(process.argv[1]) : ""; + if (script !== "clone") process.exit(1); + const args = ["clone", ...process.argv.slice(2)]; + const log = process.env.CRAFTAR_TEST_CLONE_LOG; + if (log) fs.appendFileSync(log, JSON.stringify({ args, cwd: process.cwd(), allow: process.env.GIT_ALLOW_PROTOCOL ?? null, prompt: process.env.GIT_TERMINAL_PROMPT ?? null }) + "\\n"); + const dest = args[args.length - 1]; + const fail = (process.env.CRAFTAR_TEST_CLONE_FAIL || "").split(",").filter(Boolean); + if (fail.includes(dest)) process.exit(128); + fs.mkdirSync(dest, { recursive: true }); + fs.writeFileSync(path.join(dest, "CLONED"), ""); + process.exit(0); +} +`; + +export interface FakeGit { + dir: string; + git: string; + log: string; + /** The env for runCli: the fake alone on PATH, the hook preloaded, the log named. */ + env: (extra?: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; + cleanup: () => Promise; +} + +export async function fakeGit(): Promise { + const dir = await fs.realpath(await tmpDir("craftar-fake-git-")); + const git = path.join(dir, "git" + EXE); + await fs.copyFile(process.execPath, git); + await fs.chmod(git, 0o755); + const hook = path.join(dir, "hook.cjs"); + await fs.writeFile(hook, HOOK); + const log = path.join(dir, "clone.log"); + return { + dir, + git, + log, + env: (extra = {}) => ({ PATH: dir, Path: dir, NODE_OPTIONS: `--require "${hook}"`, CRAFTAR_TEST_CLONE_LOG: log, ...extra }), + cleanup: () => fs.rm(dir, { recursive: true, force: true }), + }; +} + +export interface CloneCall { + args: string[]; + cwd: string; + allow: string | null; + prompt: string | null; +} + +export async function readCloneLog(log: string): Promise { + const text = await fs.readFile(log, "utf8").catch(() => ""); + return text.split("\n").filter(Boolean).map((l) => JSON.parse(l) as CloneCall); +} diff --git a/test/init-clone-cli.test.ts b/test/init-clone-cli.test.ts new file mode 100644 index 0000000..b385dd5 --- /dev/null +++ b/test/init-clone-cli.test.ts @@ -0,0 +1,188 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { runCli } from "./helpers/cli.js"; +import { fakeGit, readCloneLog, type FakeGit } from "./helpers/fake-git.js"; +import { makeForge, profile, recipe, rule, tmpDir } from "./helpers/forge.js"; + +// Spec 37 §4.1–§4.5 through the command: C1, C2, C6 before anything is loaded; the clone step after the first sync, +// its report and its exit; a refused first sync clones nothing. git is the fake of test/helpers/fake-git.ts — no real +// clone, no network. Every craftar child gets a PATH holding the fake alone (or nothing, for C6). + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +const REPOS = [ + { name: "portal-api", url: "https://git.example.com/acme/portal-api.git" }, + { name: "portal-web", url: "git@git.example.com:acme/portal-web.git", path: "apps/portal-web", ref: "release" }, +]; + +async function setup(opts: { repos?: unknown[]; declared?: boolean } = {}) { + const parent = await fs.realpath(await tmpDir("craftar-init-clone-cli-")); + cleanups.push(() => fs.rm(parent, { recursive: true, force: true })); + const forge = path.join(parent, "forge"); + const extra = opts.declared ? { params: { token: { description: "a value nobody set" } } } : {}; + await makeForge(forge, { + ingredients: [rule("a", opts.declared ? "# A {{token}}\n" : "# A\n", extra)], + recipes: [recipe("base", ["rule/a"])], + profiles: [profile("acme", ["base"], ["claude-code"], { repos: opts.repos ?? REPOS })], + }); + const home = path.join(parent, "home"); + const ws = path.join(parent, "ws"); + const fg = await fakeGit(); + cleanups.push(fg.cleanup); + return { parent, forge, home, ws, fg }; +} +const run = (args: string[], env: NodeJS.ProcessEnv) => runCli(args, { env }); +const withFake = (s: { fg: FakeGit; home: string }, extra: NodeJS.ProcessEnv = {}) => s.fg.env({ CRAFTAR_HOME: s.home, ...extra }); +const exists = (p: string) => fs.lstat(p).then(() => true, () => false); + +describe("refusals read off the arguments — before anything is loaded (C1, C2, C6)", () => { + // A Forge that does not exist: had anything been loaded, the error would be the load's. + const MISSING = path.join(path.sep === "\\" ? "C:\\" : "/", "nonexistent-forge-zq"); + it("C1: --clone and --no-clone, in either order", async () => { + const s = await setup(); + for (const pair of [["--clone", "--no-clone"], ["--no-clone", "--clone"]]) { + const r = run(["init", "--forge", MISSING, "--profile", "acme", "--workspace", s.ws, ...pair], withFake(s)); + expect(r.code).toBe(1); + expect(r.stderr).toBe("error: pass --clone or --no-clone, not both\n"); + expect(await exists(s.ws)).toBe(false); + } + }); + it("C2: --clone with --offline", async () => { + const s = await setup(); + const r = run(["init", "--forge", MISSING, "--profile", "acme", "--workspace", s.ws, "--clone", "--offline"], withFake(s)); + expect(r.code).toBe(1); + expect(r.stderr).toBe("error: --clone needs the network — drop --offline, or clone later with git\n"); + expect(await exists(s.ws)).toBe(false); + }); + it("C6: --clone with no git on PATH — also for a profile whose first sync would be refused", async () => { + for (const declared of [false, true]) { + const s = await setup({ declared }); + const r = run(["init", "--forge", MISSING, "--profile", "acme", "--workspace", s.ws, "--clone"], { CRAFTAR_HOME: s.home, PATH: "", Path: "" }); + expect(r.code).toBe(1); + expect(r.stderr).toBe("error: cloning needs git — install it, or leave the clone out\n"); + expect(await exists(s.ws)).toBe(false); + } + }); +}); + +describe("refusals after the plan, before anything is written (C3, C4, C5)", () => { + it("C3: --clone on a profile with no entry", async () => { + const s = await setup({ repos: [] }); + const r = run(["init", "--forge", s.forge, "--profile", "acme", "--workspace", s.ws, "--clone"], withFake(s)); + expect(r.code).toBe(1); + expect(r.stderr).toBe("error: profile acme lists no repositories — nothing to clone; drop --clone\n"); + expect(await exists(path.join(s.ws, "craftar.yaml"))).toBe(false); + }); + it("C4: a password in a url — refused, and the password is in no byte printed", async () => { + const PW = "zq" + "secret" + "pw"; + const s = await setup({ repos: [{ name: "a", url: `https://u:${PW}@git.example.com/acme/a.git` }] }); + const r = run(["init", "--forge", s.forge, "--profile", "acme", "--workspace", s.ws, "--clone"], withFake(s)); + expect(r.code).toBe(1); + expect(r.stderr).toBe("error: profile acme: repos[0]: url holds credentials — remove them and let git authenticate\n"); + expect(r.stdout + r.stderr).not.toContain(PW); + expect(await readCloneLog(s.fg.log)).toEqual([]); + }); + it("C5: a destination that exists — refused even when the first sync would be refused too", async () => { + for (const declared of [false, true]) { + const s = await setup({ declared }); + await fs.mkdir(path.join(s.ws, "portal-api"), { recursive: true }); + const r = run(["init", "--forge", s.forge, "--profile", "acme", "--workspace", s.ws, "--clone"], withFake(s)); + expect(r.code).toBe(1); + expect(r.stderr).toBe("error: cannot clone portal-api: portal-api already exists in the workspace — move it, or leave the clone out\n"); + expect(await exists(path.join(s.ws, "craftar.yaml"))).toBe(false); + expect(await readCloneLog(s.fg.log)).toEqual([]); + } + }); +}); + +describe("the clone step (§4.4)", () => { + it("every clone succeeds: the report, the calls in order, exit 0; the workspace written, synced, registered", async () => { + const s = await setup(); + const r = run(["init", "--forge", s.forge, "--profile", "acme", "--workspace", s.ws, "--clone"], withFake(s)); + expect(r.code, r.stderr).toBe(0); + const lines = r.stdout.split("\n"); + const at = lines.indexOf("craftar init — cloning 2 repositories of acme"); + expect(at).toBeGreaterThan(0); + expect(lines.slice(at + 1, at + 3)).toEqual([" cloned portal-api → portal-api", " cloned portal-web → apps/portal-web"]); + const calls = await readCloneLog(s.fg.log); + expect(calls.map((c) => c.args)).toEqual([ + ["clone", "--no-recurse-submodules", "--", "https://git.example.com/acme/portal-api.git", "portal-api"], + ["clone", "--no-recurse-submodules", "--branch=release", "--", "git@git.example.com:acme/portal-web.git", "apps/portal-web"], + ]); + for (const c of calls) { + expect(await fs.realpath(c.cwd)).toBe(await fs.realpath(s.ws)); + expect(c.allow).toBe("https:ssh"); + expect(c.prompt).toBe("0"); + } + expect(await exists(path.join(s.ws, "portal-api", "CLONED"))).toBe(true); + expect(await exists(path.join(s.ws, "apps", "portal-web", "CLONED"))).toBe(true); + expect(await exists(path.join(s.ws, "craftar.lock"))).toBe(true); + const reg = JSON.parse(await fs.readFile(path.join(s.home, "registry.json"), "utf8")); + expect(reg.workspaces.map((w: { path: string }) => w.path)).toContain(await fs.realpath(s.ws)); + // The clone is the last thing init prints; the lock records nothing of it. + expect(await fs.readFile(path.join(s.ws, "craftar.lock"), "utf8")).not.toContain("portal-"); + }); + it("one clone fails: exit 1, the others still tried, the by-hand line and the repos clone line; the workspace stays", async () => { + const s = await setup(); + const r = run(["init", "--forge", s.forge, "--profile", "acme", "--workspace", s.ws, "--clone"], withFake(s, { CRAFTAR_TEST_CLONE_FAIL: "portal-api" })); + expect(r.code).toBe(1); + const lines = r.stdout.split("\n"); + const at = lines.indexOf("craftar init — cloning 2 repositories of acme"); + expect(lines.slice(at + 1, at + 6)).toEqual([ + " failed portal-api → portal-api (git exited 128)", + " cloned portal-web → apps/portal-web", + ` 1 of 2 cloned — clone the rest by hand, in ${s.ws}:`, + ' git clone --no-recurse-submodules -- "https://git.example.com/acme/portal-api.git" "portal-api"', + " or, from the workspace: craftar repos clone portal-api", + ]); + expect((await readCloneLog(s.fg.log)).length).toBe(2); + expect(await exists(path.join(s.ws, "craftar.yaml"))).toBe(true); + expect(await exists(path.join(s.ws, "craftar.lock"))).toBe(true); + }); + it("--no-sync --clone: written, not synced, cloned", async () => { + const s = await setup(); + const r = run(["init", "--forge", s.forge, "--profile", "acme", "--workspace", s.ws, "--clone", "--no-sync"], withFake(s)); + expect(r.code, r.stderr).toBe(0); + expect(await exists(path.join(s.ws, "craftar.lock"))).toBe(false); + expect((await readCloneLog(s.fg.log)).length).toBe(2); + }); + it("a workspace directory that init creates: the clones land in it", async () => { + const s = await setup(); + const ws = path.join(s.parent, "new", "ws"); + const r = run(["init", "--forge", s.forge, "--profile", "acme", "--workspace", ws, "--clone"], withFake(s)); + expect(r.code, r.stderr).toBe(0); + expect(await exists(path.join(ws, "apps", "portal-web", "CLONED"))).toBe(true); + }); + it("a first sync that would be refused clones nothing — with and without --no-sync", async () => { + const s = await setup({ declared: true }); + const r = run(["init", "--forge", s.forge, "--profile", "acme", "--workspace", s.ws, "--clone"], withFake(s)); + expect(r.code).toBe(1); + expect(r.stdout).toContain(" repositories: not cloned — the first sync is refused\n"); + expect(r.stdout).not.toContain("cloning"); + expect(await readCloneLog(s.fg.log)).toEqual([]); + const t = await setup({ declared: true }); + const r2 = run(["init", "--forge", t.forge, "--profile", "acme", "--workspace", t.ws, "--clone", "--no-sync"], withFake(t)); + expect(r2.code).toBe(0); + expect(r2.stdout).toContain(" repositories: not cloned — the first sync is refused\n"); + expect(await readCloneLog(t.fg.log)).toEqual([]); + }); +}); + +describe("without --clone (Ruling 10)", () => { + it("--no-clone, and no flag: the same stdout, nothing cloned, nothing said about repositories", async () => { + const outs: string[] = []; + for (const extra of [[], ["--no-clone"]]) { + const s = await setup(); + const r = run(["init", "--forge", s.forge, "--profile", "acme", "--workspace", s.ws, ...extra], withFake(s)); + expect(r.code, r.stderr).toBe(0); + expect(await readCloneLog(s.fg.log)).toEqual([]); + outs.push(r.stdout.split(s.parent).join("

")); + } + expect(outs[0]).toBe(outs[1]); + expect(outs[0]).not.toMatch(/repositor|clone/); + }); +}); From 0da9374f344badd138cbad0a8e7b1129c2d71dbe Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 15:59:39 -0300 Subject: [PATCH 08/25] =?UTF-8?q?fix(init):=20--clone=20typed=20in=20the?= =?UTF-8?q?=20interactive=20mode=20clones=20too=20=E2=80=94=20the=20entrie?= =?UTF-8?q?s=20planInit=20checked=20are=20taken=20in=20either=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive branch kept no entries for a typed --clone, so its confirmation named no clone and nothing was cloned; the suite cannot drive that mode (spec 28's declared gap), so this is read, not run. --- src/cli.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 2c26fc5..88563d7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -395,7 +395,8 @@ program } const init = await planInit(root, input, { ...load(o, "sync"), local }); // Interactive mode: when o.clone is undefined, ask about cloning between planInit and confirmInit - let cloneEntries: RepoEntry[] = []; + // --clone typed: planInit has checked C3–C5 and returned the entries, in either mode + let cloneEntries: RepoEntry[] = o.clone === true ? init.repos : []; let cloneRefused = false; if (answers !== null) { if (o.clone === undefined) { @@ -412,9 +413,6 @@ program cloneRefused: o.clone === true || cloneRefused, }); if (confirm === "cancelled") fail("init cancelled — nothing written"); - } else if (o.clone === true) { - // Non-interactive mode with --clone: use repos from planInit - cloneEntries = init.repos; } const { ws, plan: p } = init; const workspaceGiven = cmd.getOptionValueSource("workspace") === "cli"; From 68e2bd5183242ff854684e327f91ad8b1942ab1c Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:07:27 -0300 Subject: [PATCH 09/25] feat(repos): craftar repos lists the profile's repositories, repos clone clones the missing ones (spec 37) --- src/cli.ts | 214 ++++++++++++++++++++++++++++++--- test/repos-cli.test.ts | 260 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 459 insertions(+), 15 deletions(-) create mode 100644 test/repos-cli.test.ts diff --git a/src/cli.ts b/src/cli.ts index 88563d7..53f8659 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -47,8 +47,8 @@ import { import { checkParamWrites, writeParamFile } from "./core/param-writes.js"; import { checkInitFlags, initLine, planInit, type InitInput } from "./core/init.js"; import { askInit, askClone, confirmInit, againLine, type InitAnswers } from "./core/init-flow.js"; -import { findGit, cloneRepos, type CloneResult } from "./core/clone.js"; -import { byHand, type RepoEntry } from "./core/repos.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"; import { readlineIo } from "./prompt.js"; import { editRecipesText, planRecipeEdit, recipeDiffLine, type RecipeOp } from "./core/recipe-edit.js"; import { localKeys, readLocalFile } from "./core/workspace-yaml.js"; @@ -242,33 +242,84 @@ async function applyAndReport(ws: Workspace, p: Plan, st: FileStatus[], lock: Lo } /** - * The clone step for `craftar init --clone`: clones the entries, prints the report, sets exitCode on failures. - * Calls `again()` at the end. + * Shared clone report for `craftar init --clone` and `craftar repos clone`. + * Prints the header, one row per skipped entry (present state), then one row per result, + * all in the profile's order. Returns whether any clone failed. + * + * @param header The bold header line (without the leading text, which is added here) + * @param results The clone results (cloned or failed) + * @param skipped The entries that were skipped (present state) + * @param root The workspace root, used for the by-hand message + * @param opts.reposCloneLine Whether to print the "or, from the workspace: craftar repos clone ..." line */ -async function runCloneStep(root: string, profile: string, entries: RepoEntry[], git: string, again: () => void): Promise { - const n = entries.length; - const repoWord = n === 1 ? "repository" : "repositories"; - console.log(pc.bold(`craftar init — cloning ${n} ${repoWord} of ${profile}`)); - const results = await cloneRepos(root, entries, { git, env: process.env, stdinIsTty: process.stdin.isTTY === true }); - const maxName = Math.max(...results.map((r) => r.entry.name.length)); +function printCloneReport( + header: string, + results: CloneResult[], + skipped: RepoEntry[], + root: string, + opts: { reposCloneLine: boolean }, +): boolean { + console.log(pc.bold(header)); + + // Build rows: skipped first (as "present"), then results (as "cloned" or "failed") + // All maintain profile order, which is the order of the input arrays + type Row = { state: "present" | "cloned" | "failed"; name: string; path: string; reason?: string }; + const rows: Row[] = []; + for (const e of skipped) { + rows.push({ state: "present", name: e.name, path: e.path }); + } for (const r of results) { if (r.outcome === "cloned") { - console.log(` cloned ${r.entry.name.padEnd(maxName)} → ${r.entry.path}`); + rows.push({ state: "cloned", name: r.entry.name, path: r.entry.path }); } else { - console.log(` failed ${r.entry.name.padEnd(maxName)} → ${r.entry.path} (${r.reason})`); + rows.push({ state: "failed", name: r.entry.name, path: r.entry.path, reason: r.reason }); + } + } + + // Compute column widths: sw = longest state, nw = longest name + const sw = Math.max(...rows.map((r) => r.state.length)); + const nw = Math.max(...rows.map((r) => r.name.length)); + + // Print rows + for (const r of rows) { + let line = ` ${r.state.padEnd(sw)} ${r.name.padEnd(nw)} → ${r.path}`; + if (r.state === "present") { + line += " (not cloned)"; + } else if (r.state === "failed" && r.reason) { + line += ` (${r.reason})`; } + console.log(line); } + + // Failure report const failed = results.filter((r): r is CloneResult & { outcome: "failed" } => r.outcome === "failed"); if (failed.length > 0) { const ok = results.length - failed.length; + const n = results.length; console.log(` ${ok} of ${n} cloned — clone the rest by hand, in ${root}:`); for (const f of failed) { console.log(` ${byHand(f.entry)}`); } - const failedNames = failed.map((f) => f.entry.name); - console.log(` or, from the workspace: craftar repos clone ${failedNames.join(" ")}`); - process.exitCode = 1; + if (opts.reposCloneLine) { + const failedNames = failed.map((f) => f.entry.name); + console.log(` or, from the workspace: craftar repos clone ${failedNames.join(" ")}`); + } } + + return failed.length > 0; +} + +/** + * The clone step for `craftar init --clone`: clones the entries, prints the report, sets exitCode on failures. + * Calls `again()` at the end. + */ +async function runCloneStep(root: string, profile: string, entries: RepoEntry[], git: string, again: () => void): Promise { + const n = entries.length; + const repoWord = n === 1 ? "repository" : "repositories"; + const results = await cloneRepos(root, entries, { git, env: process.env, stdinIsTty: process.stdin.isTTY === true }); + const header = `craftar init — cloning ${n} ${repoWord} of ${profile}`; + const anyFailed = printCloneReport(header, results, [], root, { reposCloneLine: true }); + if (anyFailed) process.exitCode = 1; again(); } @@ -474,6 +525,139 @@ program }, ); +/* ---------------------------------------------------------------- repos */ +const reposCmd = program.command("repos").description("Profile repositories: list them, clone the missing ones"); + +reposCmd + .command("list", { isDefault: true }) + .description("List the profile's repositories (its repos list) with their state in this workspace: present, missing or blocked; starts no program but the Forge load, writes nothing") + .option("--workspace

", "workspace root", ".") + .option("--offline", "use the cached copy of a remote Forge, without fetching", false) + .option("--json", "machine-readable output", false) + .action(async (o) => { + const ws = await loadWorkspace(o.workspace, load(o, "read")); + const p = await plan(ws); + const lock = await readLock(ws.root); + const profile = ws.forge.profiles.get(ws.config.profile)!; + // C4: profileRepos throws for a block that does not read + const entries = profileRepos(profile); + // No entry → one line, exit 0 + if (entries.length === 0) { + if (o.json) { + const out = { + workspace: await fs.realpath(ws.root), + profile: ws.config.profile, + repos: [], + warnings: p.warnings, + }; + console.log(JSON.stringify(out, null, 2)); + } else { + console.log(`profile ${ws.config.profile} lists no repositories`); + } + return; + } + // Build the destination context: planned paths and lock paths + const ctx: DestinationContext = { + planned: p.files.map((f) => f.path), + lockPaths: lock?.files.map((f) => f.path) ?? [], + forgeDir: ws.forge.root, + }; + const list = await listRepos(ws.root, entries, ctx); + const missing = list.filter((e) => e.state === "missing").length; + if (o.json) { + const repos = list.map((e) => ({ + name: e.name, + url: e.url, + path: e.path, + ref: e.ref, + state: e.state, + reason: e.reason, + })); + const out = { + workspace: await fs.realpath(ws.root), + profile: ws.config.profile, + repos, + warnings: p.warnings, + }; + console.log(JSON.stringify(out, null, 2)); + return; + } + // Text output + console.log(`repositories of ${ws.config.profile} — ${list.length} in the profile, ${missing} missing`); + const sw = 7; // "present" is longest, always + const nw = Math.max(...list.map((e) => e.name.length)); + const pw = Math.max(...list.map((e) => e.path.length)); + for (const e of list) { + let line = ` ${e.state.padEnd(sw)} ${e.name.padEnd(nw)} → ${e.path.padEnd(pw)} ${e.url}`; + if (e.ref) line += ` @ ${e.ref}`; + if (e.reason) line += ` (${e.reason})`; + console.log(line); + } + for (const w of p.warnings) console.log(` ${pc.yellow("warn")} ${w}`); + }); + +reposCmd + .command("clone") + .description("Clone the profile's repositories that are missing in this workspace (all, or the names given); typing the command is the consent; needs a fresh copy of a remote Forge") + .argument("[names...]", "repository names to clone (default: all missing)") + .option("--workspace ", "workspace root", ".") + .action(async (names: string[], o) => { + // C6: git is needed before any load + const git = await findGit(process.env); + if (git === null) { + fail("cloning needs git — install it, or leave the clone out"); + } + // R4: fetchHint: "fresh-copy" — repos clone refuses a stale cached copy + const ws = await loadWorkspace(o.workspace, { ...load({ offline: false }, "sync"), fetchHint: "fresh-copy" }); + const p = await plan(ws); + const lock = await readLock(ws.root); + const profile = ws.forge.profiles.get(ws.config.profile)!; + // Build the destination context + const ctx: DestinationContext = { + planned: p.files.map((f) => f.path), + lockPaths: lock?.files.map((f) => f.path) ?? [], + forgeDir: ws.forge.root, + }; + // planClone: C3, C4, C5, R1, R2, R3 come out of it + const clonePlan = await planClone(ws.root, profile, ctx, { + present: "skip", + noEntries: "nothing to clone", + names: names.length ? names : undefined, + }); + // Nothing to clone and no names given: all are present + if (clonePlan.entries.length === 0) { + const all = profileRepos(profile); + const n = all.length; + const repoWord = n === 1 ? "repository" : "repositories"; + console.log(`all ${n} ${repoWord} of ${ws.config.profile} are present — nothing to clone`); + return; + } + // Clone the entries + const results = await cloneRepos(ws.root, clonePlan.entries, { git, env: process.env, stdinIsTty: process.stdin.isTTY === true }); + // Combine skipped and results in profile order for the report + // skipped entries come from planClone, results are in the same order as entries + // To merge them in profile order: iterate through all entries and match + const allEntries = profileRepos(profile); + const skippedNames = new Set(clonePlan.skipped.map((e) => e.name.toLowerCase())); + const resultMap = new Map(results.map((r) => [r.entry.name.toLowerCase(), r])); + const orderedSkipped: RepoEntry[] = []; + const orderedResults: CloneResult[] = []; + for (const e of allEntries) { + const lower = e.name.toLowerCase(); + if (skippedNames.has(lower)) { + orderedSkipped.push(clonePlan.skipped.find((s) => s.name.toLowerCase() === lower)!); + } else if (resultMap.has(lower)) { + orderedResults.push(resultMap.get(lower)!); + } + } + // Report + const n = clonePlan.entries.length; + const repoWord = n === 1 ? "repository" : "repositories"; + const header = `craftar repos clone — cloning ${n} ${repoWord} of ${ws.config.profile}`; + const anyFailed = printCloneReport(header, orderedResults, orderedSkipped, ws.root, { reposCloneLine: false }); + if (anyFailed) process.exitCode = 1; + }); + /* ---------------------------------------------------------------- sync */ program .command("sync") diff --git a/test/repos-cli.test.ts b/test/repos-cli.test.ts new file mode 100644 index 0000000..2f5a6a2 --- /dev/null +++ b/test/repos-cli.test.ts @@ -0,0 +1,260 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { runCli } from "./helpers/cli.js"; +import { fakeGit, readCloneLog } from "./helpers/fake-git.js"; +import { makeForge, profile, recipe, rule, tmpDir } from "./helpers/forge.js"; +import { remoteForge } from "./helpers/remote.js"; + +// Spec 37 §4.7 — `craftar repos` lists, `craftar repos clone […]` clones the missing entries of an existing +// workspace. git is the fake of test/helpers/fake-git.ts wherever a clone could run — no real clone, no network. The +// two remote-Forge cases run the real git against a file:// bare repository (the Forge, never a repository entry), +// and with every entry present, so a regression there could not start a clone either. + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +const REPOS = [ + { name: "portal-api", url: "https://git.example.com/acme/portal-api.git" }, + { name: "portal-web", url: "git@git.example.com:acme/portal-web.git", path: "apps/portal-web", ref: "release" }, + { name: "docs", url: "https://git.example.com/acme/docs.git", path: "notes/docs" }, +]; + +async function setup(opts: { repos?: unknown[]; declared?: boolean } = {}) { + const parent = await fs.realpath(await tmpDir("craftar-repos-cli-")); + cleanups.push(() => fs.rm(parent, { recursive: true, force: true })); + const forge = path.join(parent, "forge"); + const extra = opts.declared ? { params: { token: { description: "a value nobody set" } } } : {}; + await makeForge(forge, { + ingredients: [rule("a", opts.declared ? "# A {{token}}\n" : "# A\n", extra)], + recipes: [recipe("base", ["rule/a"])], + profiles: [profile("acme", ["base"], ["claude-code"], { repos: opts.repos ?? REPOS })], + }); + const home = path.join(parent, "home"); + const ws = path.join(parent, "ws"); + await fs.mkdir(ws); + await fs.writeFile(path.join(ws, "craftar.yaml"), `forge: ${forge.replace(/\\/g, "/")}\nprofile: acme\n`); + const fg = await fakeGit(); + cleanups.push(fg.cleanup); + const env = (extra: NodeJS.ProcessEnv = {}) => fg.env({ CRAFTAR_HOME: home, ...extra }); + const run = (args: string[], extra: NodeJS.ProcessEnv = {}) => runCli([...args, "--workspace", ws], { env: env(extra) }); + return { parent, forge, home, ws, fg, run }; +} +async function snapshot(dir: string): Promise> { + const out: Record = {}; + async function walk(d: string): Promise { + for (const e of await fs.readdir(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) { + out[path.relative(dir, p) + "/"] = ""; + await walk(p); + } else out[path.relative(dir, p)] = await fs.readFile(p, "utf8").catch(() => "?"); + } + } + await walk(dir); + return out; +} + +describe("craftar repos — the listing", () => { + it("present, missing, blocked — with URLs and refs; nothing cloned, nothing written", async () => { + const s = await setup(); + await fs.mkdir(path.join(s.ws, "portal-api")); + await fs.writeFile(path.join(s.ws, "notes"), "a file where a directory should be\n"); + const before = await snapshot(s.ws); + const r = s.run(["repos"]); + expect(r.code, r.stderr).toBe(0); + expect(r.stdout).toBe( + [ + "repositories of acme — 3 in the profile, 1 missing", + " present portal-api → portal-api https://git.example.com/acme/portal-api.git", + " missing portal-web → apps/portal-web git@git.example.com:acme/portal-web.git @ release", + " blocked docs → notes/docs https://git.example.com/acme/docs.git (notes is not a directory)", + "", + ].join("\n"), + ); + expect(await snapshot(s.ws)).toEqual(before); + expect(await readCloneLog(s.fg.log)).toEqual([]); + }); + it("--json: keys in order, null ref and reason, the native real path, the plan's warnings", async () => { + const s = await setup(); + await fs.mkdir(path.join(s.ws, "portal-api")); + const r = s.run(["repos", "--json"]); + expect(r.code, r.stderr).toBe(0); + const j = JSON.parse(r.stdout); + expect(Object.keys(j)).toEqual(["workspace", "profile", "repos", "warnings"]); + expect(j.workspace).toBe(await fs.realpath(s.ws)); + expect(j.profile).toBe("acme"); + expect(j.repos).toEqual([ + { name: "portal-api", url: "https://git.example.com/acme/portal-api.git", path: "portal-api", ref: null, state: "present", reason: null }, + { name: "portal-web", url: "git@git.example.com:acme/portal-web.git", path: "apps/portal-web", ref: "release", state: "missing", reason: null }, + { name: "docs", url: "https://git.example.com/acme/docs.git", path: "notes/docs", ref: null, state: "missing", reason: null }, + ]); + for (const row of j.repos) expect(Object.keys(row)).toEqual(["name", "url", "path", "ref", "state", "reason"]); + expect(Array.isArray(j.warnings)).toBe(true); + }); + it("a path only the lock records is blocked (a file sync writes)", async () => { + const s = await setup({ repos: [{ name: "old", url: "https://git.example.com/acme/old.git" }] }); + expect(s.run(["sync"]).code).toBe(0); + const lockFile = path.join(s.ws, "craftar.lock"); + const lock = JSON.parse(await fs.readFile(lockFile, "utf8")); + lock.files.push({ path: "old/readme.md", hash: "sha256:" + "0".repeat(64), target: "claude-code", ingredient: "rule/a" }); + await fs.writeFile(lockFile, JSON.stringify(lock, null, 2) + "\n"); + const r = s.run(["repos", "--json"]); + expect(r.code, r.stderr).toBe(0); + expect(JSON.parse(r.stdout).repos[0]).toMatchObject({ state: "blocked", reason: "old is a file sync writes" }); + const c = s.run(["repos", "clone"]); + expect(c.code).toBe(1); + expect(c.stderr).toBe("error: cannot clone old: old is a file sync writes\n"); + expect(await readCloneLog(s.fg.log)).toEqual([]); + }); + it("a profile with no entry: one line, exit 0", async () => { + const s = await setup({ repos: [] }); + const r = s.run(["repos"]); + expect(r.code).toBe(0); + expect(r.stdout).toBe("profile acme lists no repositories\n"); + }); + it("C4: the block does not read — exit 1", async () => { + const s = await setup({ repos: [{ name: "a" }] }); + const r = s.run(["repos"]); + expect(r.code).toBe(1); + expect(r.stderr).toBe("error: profile acme: repos[0]: url is missing\n"); + }); + it("a directory without craftar.yaml, and a plan that throws: exit 1, as status says it", async () => { + const s = await setup(); + const bare = path.join(s.parent, "nothing-here"); + await fs.mkdir(bare); + const env = s.fg.env({ CRAFTAR_HOME: s.home }); + const st = runCli(["status", "--workspace", bare], { env }); + const rp = runCli(["repos", "--workspace", bare], { env }); + expect(rp.code).toBe(1); + expect(rp.stderr).toBe(st.stderr); + await fs.writeFile(path.join(s.ws, "craftar.yaml"), `forge: ${s.forge.replace(/\\/g, "/")}\nprofile: acme\nrecipes:\n add: [nope]\n`); + const st2 = s.run(["status"]); + const rp2 = s.run(["repos"]); + expect(st2.code).toBe(1); + expect(rp2.code).toBe(1); + expect(rp2.stderr).toBe(st2.stderr); + }); +}); + +describe("craftar repos clone", () => { + it("no name: the missing entries, in order; a present one skipped with a line; exit 0", async () => { + const s = await setup({ repos: REPOS.slice(0, 2).concat([{ name: "cli", url: "https://git.example.com/acme/cli.git" }]) }); + await fs.mkdir(path.join(s.ws, "portal-api")); + const r = s.run(["repos", "clone"]); + expect(r.code, r.stderr).toBe(0); + expect(r.stdout).toBe( + [ + "craftar repos clone — cloning 2 repositories of acme", + " present portal-api → portal-api (not cloned)", + " cloned portal-web → apps/portal-web", + " cloned cli → cli", + "", + ].join("\n"), + ); + const calls = await readCloneLog(s.fg.log); + expect(calls.map((c) => c.args.at(-1))).toEqual(["apps/portal-web", "cli"]); + for (const c of calls) { + expect(await fs.realpath(c.cwd)).toBe(await fs.realpath(s.ws)); + expect(c.allow).toBe("https:ssh"); + } + }); + it("names: only those, in the profile's order", async () => { + const s = await setup({ repos: REPOS.slice(0, 2).concat([{ name: "cli", url: "https://git.example.com/acme/cli.git" }]) }); + const r = s.run(["repos", "clone", "cli", "portal-api"]); + expect(r.code, r.stderr).toBe(0); + expect((await readCloneLog(s.fg.log)).map((c) => c.args.at(-1))).toEqual(["portal-api", "cli"]); + }); + it("a failed clone: exit 1, the others tried, the by-hand line", async () => { + const s = await setup({ repos: REPOS.slice(0, 2) }); + const r = s.run(["repos", "clone"], { CRAFTAR_TEST_CLONE_FAIL: "apps/portal-web" }); + expect(r.code).toBe(1); + expect(r.stdout).toContain(" failed portal-web → apps/portal-web (git exited 128)\n"); + expect(r.stdout).toContain(` 1 of 2 cloned — clone the rest by hand, in ${s.ws}:\n`); + expect(r.stdout).toContain(' git clone --no-recurse-submodules --branch=release -- "git@git.example.com:acme/portal-web.git" "apps/portal-web"\n'); + expect((await readCloneLog(s.fg.log)).length).toBe(2); + }); + it("every entry present: nothing to clone, exit 0, no clone run", async () => { + const s = await setup({ repos: REPOS.slice(0, 2) }); + await fs.mkdir(path.join(s.ws, "portal-api")); + await fs.mkdir(path.join(s.ws, "apps", "portal-web"), { recursive: true }); + const r = s.run(["repos", "clone"]); + expect(r.code).toBe(0); + expect(r.stdout).toBe("all 2 repositories of acme are present — nothing to clone\n"); + expect(await readCloneLog(s.fg.log)).toEqual([]); + }); + it("refusals before any clone: R1 (printed, and not printed), R2, R3, C3, C4, C5, C6", async () => { + const MARK = "zq" + "mark"; + const cases: Array<[string, { repos?: unknown[]; pre?: (ws: string) => Promise; args: string[]; env?: NodeJS.ProcessEnv }, string]> = [ + ["R1", { args: ["nope"] }, "error: profile acme has no repository nope\n"], + ["R1 unprintable", { args: [`$(${MARK})`] }, "error: profile acme: a name given is not a repository name\n"], + ["R3", { args: ["portal-api", "PORTAL-API"] }, "error: portal-api is given twice\n"], + ["R2", { args: ["portal-api"], pre: (ws) => fs.mkdir(path.join(ws, "portal-api")) }, "error: portal-api is present at portal-api — nothing to clone\n"], + ["C3", { repos: [], args: [] }, "error: profile acme lists no repositories — nothing to clone\n"], + ["C4", { repos: [{ name: "a", url: "file:///srv/a.git" }], args: [] }, "error: profile acme: repos[0]: url is not an https or ssh git URL\n"], + ["C5", { args: [], pre: (ws) => fs.writeFile(path.join(ws, "notes"), "x") }, "error: cannot clone docs: notes is not a directory\n"], + ["C6", { args: [], env: { PATH: "", Path: "" } }, "error: cloning needs git — install it, or leave the clone out\n"], + ]; + for (const [what, c, expected] of cases) { + const s = await setup(c.repos === undefined ? {} : { repos: c.repos }); + if (c.pre) await c.pre(s.ws); + const before = await snapshot(s.ws); + const r = s.run(["repos", "clone", ...c.args], c.env); + expect(r.code, what).toBe(1); + expect(r.stderr, what).toBe(expected); + expect(r.stdout + r.stderr, what).not.toContain(MARK); + expect(await readCloneLog(s.fg.log), what).toEqual([]); + expect(await snapshot(s.ws), what).toEqual(before); + } + }); + it("a workspace whose sync is refused still clones; no lock, registry or craftar.yaml byte changes", async () => { + const s = await setup({ declared: true, repos: REPOS.slice(0, 1) }); + const yaml = await fs.readFile(path.join(s.ws, "craftar.yaml"), "utf8"); + const r = s.run(["repos", "clone"]); + expect(r.code, r.stderr).toBe(0); + expect((await readCloneLog(s.fg.log)).length).toBe(1); + expect(await fs.readFile(path.join(s.ws, "craftar.yaml"), "utf8")).toBe(yaml); + expect(await fs.lstat(path.join(s.ws, "craftar.lock")).then(() => true, () => false)).toBe(false); + expect(await fs.lstat(path.join(s.home, "registry.json")).then(() => true, () => false)).toBe(false); + }); +}); + +describe("a remote Forge whose fetch fails (real git, a file:// bare Forge; every entry present)", () => { + async function remoteSetup() { + const r = await remoteForge({ + ingredients: [rule("a", "# A\n")], + recipes: [recipe("base", ["rule/a"])], + profiles: [profile("acme", ["base"], ["claude-code"], { repos: [{ name: "portal-api", url: "https://git.example.com/acme/portal-api.git" }] })], + }); + cleanups.push(r.cleanup); + const ws = await fs.realpath(await tmpDir("craftar-repos-remote-ws-")); + const home = await fs.realpath(await tmpDir("craftar-repos-remote-home-")); + cleanups.push(() => fs.rm(ws, { recursive: true, force: true }), () => fs.rm(home, { recursive: true, force: true })); + await fs.writeFile(path.join(ws, "craftar.yaml"), `forge: ${r.url}\nprofile: acme\n`); + await fs.mkdir(path.join(ws, "portal-api")); + const run = (args: string[]) => runCli([...args, "--workspace", ws], { env: { CRAFTAR_HOME: home } }); + expect(run(["sync"]).code).toBe(0); + await fs.rename(r.bare, r.bare + ".gone"); + return { r, run }; + } + it("craftar repos lists from the cached copy, with the load's warning in the output and in --json", async () => { + const { r, run } = await remoteSetup(); + const t = run(["repos"]); + expect(t.code, t.stderr).toBe(0); + expect(t.stdout).toContain(" present portal-api"); + expect(t.stdout + t.stderr).toContain(`Forge ${r.url} not fetched (`); + const j = JSON.parse(run(["repos", "--json"]).stdout); + expect(j.warnings[0].startsWith(`Forge ${r.url} not fetched (`)).toBe(true); + }); + it("R4: repos clone refuses, with no --offline advice; sync's message keeps its tail", async () => { + const { r, run } = await remoteSetup(); + const c = run(["repos", "clone"]); + expect(c.code).toBe(1); + const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + expect(c.stderr).toMatch(new RegExp(`^error: cannot fetch the Forge ${esc(r.url)}: [^\\n]+ — repos clone reads the profile's repositories only from a fresh copy\\n$`)); + expect(run(["sync"]).stderr).toContain("— run with --offline to use the cached copy ("); + }); +}); + From 021ffabca215feaea693246679a68ac905f86baf Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:09:08 -0300 Subject: [PATCH 10/25] =?UTF-8?q?fix(repos):=20the=20listing=20is=20crafta?= =?UTF-8?q?r=20repos=20itself=20=E2=80=94=20no=20repos=20list=20subcommand?= =?UTF-8?q?=20(spec=2037=20=C2=A713.3=20item=2029)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repos clone reads --workspace from its parent when commander hands the value there. --- src/cli.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 53f8659..57d6e2e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -526,10 +526,9 @@ program ); /* ---------------------------------------------------------------- repos */ -const reposCmd = program.command("repos").description("Profile repositories: list them, clone the missing ones"); - -reposCmd - .command("list", { isDefault: true }) +// `craftar repos` lists (the command's own action); `craftar repos clone` clones. No `repos list` (spec 37 §13.3 item 29). +const reposCmd = program + .command("repos") .description("List the profile's repositories (its repos list) with their state in this workspace: present, missing or blocked; starts no program but the Forge load, writes nothing") .option("--workspace ", "workspace root", ".") .option("--offline", "use the cached copy of a remote Forge, without fetching", false) @@ -600,15 +599,17 @@ reposCmd .command("clone") .description("Clone the profile's repositories that are missing in this workspace (all, or the names given); typing the command is the consent; needs a fresh copy of a remote Forge") .argument("[names...]", "repository names to clone (default: all missing)") - .option("--workspace ", "workspace root", ".") + // No default here: `repos` holds `--workspace` too, and commander gives the value to the parent when both declare it. + .option("--workspace ", 'workspace root (default: ".")') .action(async (names: string[], o) => { + const workspace: string = o.workspace ?? reposCmd.opts().workspace ?? "."; // C6: git is needed before any load const git = await findGit(process.env); if (git === null) { fail("cloning needs git — install it, or leave the clone out"); } // R4: fetchHint: "fresh-copy" — repos clone refuses a stale cached copy - const ws = await loadWorkspace(o.workspace, { ...load({ offline: false }, "sync"), fetchHint: "fresh-copy" }); + const ws = await loadWorkspace(workspace, { ...load({ offline: false }, "sync"), fetchHint: "fresh-copy" }); const p = await plan(ws); const lock = await readLock(ws.root); const profile = ws.forge.profiles.get(ws.config.profile)!; From 48b29b7e251c90afa10bb4ccf751321ff39a7cc5 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:11:41 -0300 Subject: [PATCH 11/25] docs(readme): init --clone, craftar repos, the repos block, the roadmap and the status line (spec 37) --- README.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bf2d7a3..bdf16ee 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. Everything else in the spec (cloning a profile's repositories at init) is not built yet. +> 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 a profile's repositories (its `repos` list) into the workspace. ## The idea in one paragraph @@ -68,7 +68,9 @@ 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] [--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. | +| `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 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. | | `craftar status` | With a remote Forge, the header's second line names it — `forge @ (default branch)> `, plus `· lock ` when the Forge moved since the last sync — and `--json` carries `forge: {kind, source, ref, defaultBranch, commit, lockCommit, fetched}` for every Forge. Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. A `.json` file not yet in `craftar.lock` that parses to the planned value with its keys in the same order (it differs only in whitespace, escapes, number spelling or duplicate keys) is `adopt`, and the first `sync` rewrites it with the planned bytes — by design. For `claude-code`'s `.mcp.json` that is two-space JSON keeping the file's BOM and line endings; Kiro's JSON files become CRLF without a BOM, like every Kiro text file. A different key order is a `collision` — except for integer-like keys, which `JSON.parse` reorders. A locked JSON file reformatted by hand is `drift`, not `adopt`. `--json` for tooling: `{forge, statuses, warnings, unsetParams}`. Exits 0 whenever it prints its listing, with two exceptions: when a declared parameter a file cites has no value (see `sync`) it prints the listing, then the refusal on stderr, and exits 1; and when `AGENTS.md` is over an explicit budget (see *Targets › agents-md*) it prints the listing, then that block on stderr, and exits 1. In both cases `--json` still prints the whole object — with `unsetParams: [{key, declaredBy, citedBy}]` (`[]` when there is none) and the budget line in `warnings` — and prints no block on stderr. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in an emitted file not every target renders as text (copied as they are). | | `craftar sync` | Renders sections (no rendered body holds a marker; an emitted file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock` (schema 2, or 3 when the workspace has a service — see *Local services*; it records the Forge's source, requested `ref` and commit, the resolved recipes and targets — rewritten only when that content changes). With a remote Forge it fetches first and exits 1 when the fetch fails, `--dry-run` and `--check` included. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync, or when `AGENTS.md` is over an explicit budget (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default); `craftar drift` lists them, discards one, or promotes one to the Forge. A writing `sync` (not `--dry-run`, not `--check`) also records the workspace in `$CRAFTAR_HOME/registry.json` (see *Workspace registry*); a registry it cannot write is a warning, never a failed `sync`. **Refuses when a declared parameter has no value**: a key an ingredient declares under `params` with no `default`, that a file the plan writes cites as `{{key}}`, and that no layer sets (recipe default, profile `params`, `overrides.params` in `craftar.yaml` or `craftar.local.yaml`). The plan's warnings print, then one block on stderr — `error: N declared parameter(s) have no value — nothing written`, one line per key with the ingredients that declare it and the ones that cite it, and the fix — and `sync` exits 1 with nothing written: no file, no lock, no registry entry; `--dry-run` exits 1 the same way, and `--check` prints its listing, then the block, and exits 1 even when every file is `unchanged`. A `{{key}}` no ingredient declares (an Angular `{{ 'X' \| localize }}`, a stray placeholder) is still left verbatim with a warning, and an empty string is a value. (That is a body; a `{{key}}` in an MCP server of a schema 4 Forge follows *Server params*.) | @@ -232,6 +234,21 @@ sections: A value is a string. It replaces the section's lines; a value without a final newline gets one, and `""` empties the section. The blank lines around the markers stay when a section is emptied, so put a separating blank line inside the section when an emptied section should leave none extra. A value may cite `{{param}}`: sections are expanded before params are substituted. +**Repositories.** A profile's `repos` lists the client's repositories, for `craftar init --clone` and `craftar repos`: + +```yaml +# profiles/acme/profile.yaml +repos: + - name: portal-api + url: https://git.example.com/acme/portal-api.git + - name: portal-web + url: git@git.example.com:acme/portal-web.git + path: apps/portal-web # optional, default: the name — relative to the workspace + ref: release # optional: a branch or a tag +``` + +`name` is letters, digits, `.`, `_` and `-`, starting with a letter or a digit, not ending in `.`, not a Windows device name, and unique without case. `url` is an `https://`, `ssh://` or `user@host:path` URL made of `A-Z a-z 0-9 . _ ~ : / @ + -` only, its first character after the scheme, its host and (in the `user@host:path` form) its path each starting with a letter or a digit, and without credentials — let git authenticate. `path` is `/`-separated segments that each follow `name`'s rule (so no dot-folder), is not `AGENTS.md` or a `craftar.*` file, and no two entries share or nest a destination (compared without case). `ref` is a branch or a tag, never a commit id. No other key. The block is checked only when a command reads it — a Forge whose `repos` holds something else still loads and syncs — and a refusal names the entry's index and key, never a value. + **Integrations.** A profile's `integrations` block can select recipes and give params in a Forge that declares `schema: 3` or above: ```yaml @@ -487,7 +504,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). Next: cloning a profile's repositories at init. +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 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). From 5acb826e5b692c127d5db1adc8e5163bb661ded2 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:12:19 -0300 Subject: [PATCH 12/25] docs(readme): escape the pipe in init's --clone | --no-clone synopsis, so the Commands row keeps two cells --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bdf16ee..b8b9461 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,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 ] [--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 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. | From 2dd3988cdd64a2b1cbe94717bb7b5f80163cc343 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:15:00 -0300 Subject: [PATCH 13/25] test(readme): spec 36's README pins follow the roadmap line spec 37 changes, and spec 37's own README pins craftar ui stays listed as shipped and never as unbuilt or under Later; the Next: after it and the "not built yet" sentence are gone by design. --- test/ui-readme.test.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/test/ui-readme.test.ts b/test/ui-readme.test.ts index 0fd5361..def2cf8 100644 --- a/test/ui-readme.test.ts +++ b/test/ui-readme.test.ts @@ -12,17 +12,29 @@ describe("README — craftar ui", () => { expect(count("| `craftar ui [--port ]` |")).toBe(1); expect(count("it listens on `127.0.0.1`")).toBe(1); expect(count("needs a token generated for that run")).toBe(1); - expect(count("`craftar ui` (a local, read-only dashboard of the registered workspaces, on `127.0.0.1` only). Next:")).toBe(1); + // Spec 37 moved Phase 1's `Next:` item to its shipped list, so the entry is no longer followed by `. Next:`. + expect(count("`craftar ui` (a local, read-only dashboard of the registered workspaces, on `127.0.0.1` only)")).toBe(1); expect(count("### to 0.30.0")).toBe(1); }); it("no longer listed as not built, nor under Later", () => { - const unbuilt = README.split("\n").filter((l) => l.includes("is not built yet.")); - expect(unbuilt).toHaveLength(1); - expect(unbuilt[0]).toContain("Everything else in the spec"); - expect(unbuilt[0].slice(unbuilt[0].lastIndexOf("Everything else in the spec"))).not.toContain("craftar ui"); + // Since spec 37 no line says what is not built; whatever line says it again must not name craftar ui. + for (const l of README.split("\n").filter((x) => x.includes("is not built yet."))) expect(l.slice(l.lastIndexOf("Everything else"))).not.toContain("craftar ui"); const later = README.split("\n").filter((l) => l.startsWith("Later:")); expect(later).toHaveLength(1); expect(later[0]).not.toContain("craftar ui"); }); }); + +// Spec 37 §9: the two repos rows, the Repositories paragraph, the roadmap item shipped, the status line's last sentence. +describe("README — init --clone and craftar repos", () => { + it("one row each, one Repositories paragraph, the roadmap item shipped, no `Next: cloning`", () => { + expect(count("| `craftar repos [--workspace .] [--offline] [--json]` |")).toBe(1); + 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("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 0b95d8e669c03c4a9b5d4598f7fba9ea7f675366 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:30:26 -0300 Subject: [PATCH 14/25] =?UTF-8?q?fix(init):=20askInit=20carries=20a=20type?= =?UTF-8?q?d=20--clone=20into=20planInit=20=E2=80=94=20the=20interactive?= =?UTF-8?q?=20mode=20checks=20C3=E2=80=93C5,=20names=20the=20clone=20and?= =?UTF-8?q?=20clones=20(review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli.ts | 3 ++- src/core/init-flow.ts | 2 ++ test/init-clone.test.ts | 36 +++++++++++++++++++++++++++++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 57d6e2e..77fb3d3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -424,6 +424,7 @@ program addRecipes: o.addRecipe, removeRecipes: o.removeRecipe, replace: o.replace, + clone: o.clone, }, local, io, @@ -503,7 +504,7 @@ program // first sync is not run. exitCode, not fail(): the `again` line still follows the block. if (unset.length) { for (const w of p.warnings) console.log(` ${pc.yellow("warn")} ${w}`); - if (cloning) { + if (cloning && answers === null) { console.log(" repositories: not cloned — the first sync is refused"); } refusalBlock(unset, { thenSync: true }); diff --git a/src/core/init-flow.ts b/src/core/init-flow.ts index 434dd32..5d823b9 100644 --- a/src/core/init-flow.ts +++ b/src/core/init-flow.ts @@ -270,6 +270,7 @@ export interface InitGiven { addRecipes: string[]; removeRecipes: string[]; replace: boolean; + clone?: boolean; } export type AskResult = @@ -651,6 +652,7 @@ export async function askInit( 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; const answers: InitAnswers = { forge: answeredForge!, diff --git a/test/init-clone.test.ts b/test/init-clone.test.ts index 8a0d5ed..7b60b6f 100644 --- a/test/init-clone.test.ts +++ b/test/init-clone.test.ts @@ -1,7 +1,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { againLine, askClone, confirmInit, type InitAnswers, type InitIo } from "../src/core/init-flow.js"; +import { againLine, askClone, askInit, confirmInit, type InitAnswers, type InitIo } from "../src/core/init-flow.js"; import { planInit } from "../src/core/init.js"; import { loadWorkspace } from "../src/core/sync.js"; import { makeForge, profile, recipe, rule, tmpDir } from "./helpers/forge.js"; @@ -227,3 +227,37 @@ describe("LoadOptions.fetchHint — R4's tail (§5.2)", () => { expect(await msg({ fetchHint: "offline" })).toMatch(new RegExp(` — run with --offline to use the cached copy \\(${sha} fetched [^)]+\\)$`)); }); }); + +/** Answers in order, then Enter for every later question — never a loop on the same answer. */ +function queued(answers: string[]): InitIo { + const next = async () => (answers.length ? answers.shift()! : ""); + return { ask: next, confirm: next, say: () => {} }; +} + +describe("askInit carries a typed --clone into planInit (review round 1 blocker)", () => { + it("given.clone true: the input planInit gets asks to clone, so C3–C5 are checked and the entries come back", async () => { + const s = await setup(); + const answers = ["acme"]; + const asked: string[] = []; + const next = async (q: string) => (asked.push(q), answers.length ? answers.shift()! : ""); + const io: InitIo = { ask: next, confirm: next, say: () => {} }; + const r = await askInit(s.ws, { forge: s.forge, addRecipes: [], removeRecipes: [], replace: false, clone: true }, { doc: null, keys: [] }, io, { registryOff: true }); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.clone).toBe(true); + const init = await planInit(s.ws, r.input); + expect(init.repos.map((e) => e.name)).toEqual(["portal-api", "portal-web"]); + const t = await setup({ repos: [] }); + const r2 = await askInit(t.ws, { forge: t.forge, addRecipes: [], removeRecipes: [], replace: false, clone: true }, { doc: null, keys: [] }, queued(["acme"]), { registryOff: true }); + if (r2.kind !== "answered") throw new Error("expected answers"); + await expect(planInit(t.ws, r2.input)).rejects.toThrow(/^profile acme lists no repositories — nothing to clone; drop --clone$/); + }); + it("given.clone absent or false: the input does not ask to clone", async () => { + const s = await setup(); + for (const clone of [undefined, false]) { + const io = queued(["acme"]); + const r = await askInit(s.ws, { forge: s.forge, addRecipes: [], removeRecipes: [], replace: false, ...(clone === undefined ? {} : { clone }) }, { doc: null, keys: [] }, io, { registryOff: true }); + if (r.kind !== "answered") throw new Error("expected answers"); + expect(r.input.clone).not.toBe(true); + } + }); +}); From 0f6fdab26bb50ebe0e514a4e89e5b9d4000368ea Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:30:36 -0300 Subject: [PATCH 15/25] =?UTF-8?q?fix(clone):=20GIT=5FDIR=20and=20GIT=5FWOR?= =?UTF-8?q?K=5FTREE=20are=20not=20handed=20to=20the=20clone's=20git=20?= =?UTF-8?q?=E2=80=94=20they=20would=20move=20the=20checkout=20out=20of=20t?= =?UTF-8?q?he=20checked=20destination=20(review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/clone.ts | 6 ++++-- test/clone.test.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/core/clone.ts b/src/core/clone.ts index 6f3ab2c..4a96790 100644 --- a/src/core/clone.ts +++ b/src/core/clone.ts @@ -398,9 +398,11 @@ export async function cloneRepos( continue; } - // Build environment + // Build environment: drop GIT_DIR and GIT_WORK_TREE — they would place the checkout + // outside the checked destination (the workspace may be a worktree, or git hooks may have set them). + const { GIT_DIR: _gitDir, GIT_WORK_TREE: _gitWorkTree, ...inheritedEnv } = opts.env; const env: NodeJS.ProcessEnv = { - ...opts.env, + ...inheritedEnv, GIT_ALLOW_PROTOCOL: "https:ssh", }; if (!opts.stdinIsTty) { diff --git a/test/clone.test.ts b/test/clone.test.ts index 6860492..148969c 100644 --- a/test/clone.test.ts +++ b/test/clone.test.ts @@ -284,6 +284,14 @@ describe("cloneRepos — one runner call per entry, in order, sequential", () => } } }); + it("GIT_DIR and GIT_WORK_TREE inherited from the caller are not handed to git: they would place the checkout elsewhere (review round 1)", async () => { + const { root } = await layout(); + const r = recorder([0]); + await cloneRepos(root, [entry("api")], { git: GIT, env: { GIT_DIR: "/elsewhere/.git", GIT_WORK_TREE: "/elsewhere", MARKER_VAR: "kept" }, stdinIsTty: true }, r.run); + expect(r.calls[0].env.GIT_DIR).toBeUndefined(); + expect(r.calls[0].env.GIT_WORK_TREE).toBeUndefined(); + expect(r.calls[0].env.MARKER_VAR).toBe("kept"); + }); it("a failure in the middle: cloned, failed (git exited 128), cloned — the next entry still tried", async () => { const { root } = await layout(); const r = recorder([0, 128, 0]); From 42bb5525fb530aaddc9a0582a4d39f191554556d Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:32:01 -0300 Subject: [PATCH 16/25] =?UTF-8?q?refactor(repos):=20an=20unknown=20key=20i?= =?UTF-8?q?s=20shown=20through=20integrations'=20nameShaped=20=E2=80=94=20?= =?UTF-8?q?one=20copy=20of=20the=200.30.1=20check=20(review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/integrations.ts | 10 +++++++++- src/core/repos.ts | 7 +++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/core/integrations.ts b/src/core/integrations.ts index 8d0bc1a..f6bd19b 100644 --- a/src/core/integrations.ts +++ b/src/core/integrations.ts @@ -38,13 +38,21 @@ const KIND_RE = /^[a-z0-9][a-z0-9._-]*$/; */ const NAME_LIKE = /^[A-Za-z0-9_.-]{1,64}$/; +/** + * Whether `name` is shaped like a name: matches the regex and is not a token of a known format. + * Used by `shownName` here and by `repos.ts` for unknown keys (the 0.30.1 check in one copy). + */ +export function nameShaped(name: string): boolean { + return NAME_LIKE.test(name) && findSecrets(name).length === 0; +} + /** * Returns the name to show in an error message: JSON.stringify(name) when it looks like a name * and is not a token of a known format (`findSecrets`), otherwise `#${index + 1}` with the * "(not shown: not shaped like a name)" suffix. */ function shownName(name: string, index: number): { shown: string; suffix: string } { - if (NAME_LIKE.test(name) && findSecrets(name).length === 0) { + if (nameShaped(name)) { return { shown: JSON.stringify(name), suffix: "" }; } return { shown: `#${index + 1}`, suffix: " (not shown: not shaped like a name)" }; diff --git a/src/core/repos.ts b/src/core/repos.ts index 1f5e059..f0edb29 100644 --- a/src/core/repos.ts +++ b/src/core/repos.ts @@ -7,8 +7,8 @@ */ import type { Profile } from "../schema/index.js"; +import { nameShaped } from "./integrations.js"; import { classifyForge, credentialFault } from "./remote.js"; -import { findSecrets } from "./secrets.js"; import { climbsOut } from "./text.js"; /* ------------------------------------------------------------------ */ @@ -32,7 +32,6 @@ const URL_CHARS_RE = /^[A-Za-z0-9._~:/@+-]+$/; const REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/+-]*$/; const SHA40_RE = /^[0-9a-f]{40}$/i; const SHA64_RE = /^[0-9a-f]{64}$/i; -const KEY_NAME_RE = /^[A-Za-z0-9_.-]{1,64}$/; const CRAFTAR_RESERVED = new Set(["agents.md", "craftar.yaml", "craftar.local.yaml", "craftar.lock"]); const KNOWN_KEYS = new Set(["name", "url", "path", "ref"]); @@ -267,8 +266,8 @@ function validateEntry( for (let keyIndex = 0; keyIndex < keys.length; keyIndex++) { const key = keys[keyIndex]; if (!KNOWN_KEYS.has(key)) { - // Check if key is shaped like a name: matches the regex and has no secrets - if (KEY_NAME_RE.test(key) && findSecrets(key).length === 0) { + // Check if key is shaped like a name + if (nameShaped(key)) { refuse(profile, index, `${key} is not a key of a repository entry — name, url, path, ref`); } else { // 1-based position among the entry's keys From a41a38d5abd8c3aaf78885d07676d56477261fe3 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:32:55 -0300 Subject: [PATCH 17/25] docs(clone): the comments say where process is read and why two fall-throughs fail closed (review) --- src/core/clone.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/core/clone.ts b/src/core/clone.ts index 4a96790..6f5c8ba 100644 --- a/src/core/clone.ts +++ b/src/core/clone.ts @@ -6,7 +6,7 @@ * plan and the clone; `cloneRepos` re-checks before each runner call, and a failure there is * reported as one entry's failure. * - * PURE: no console, no process (env and tty come in as arguments). + * PURE: no console; `process` read only for `process.platform`. */ import { promises as fs } from "node:fs"; @@ -140,7 +140,8 @@ export async function destinationFault(root: string, e: RepoEntry, ctx: Destinat } catch (lstatErr) { const code = (lstatErr as NodeJS.ErrnoException).code; if (code !== "ENOENT" && code !== "ENOTDIR") { - // Some other error during lstat — fall through to resolution check + // Some other lstat error (EACCES, EIO, …) falls through on purpose: step 3's + // realpathOfNearest fails closed, refusing the destination rather than missing it. } } @@ -382,7 +383,9 @@ export async function cloneRepos( continue; } - // Re-check: containment via real paths + // Re-check: containment via real paths. A realpathOfNearest error (EACCES, a dangling link + // appeared, …) is reported as "leaves the workspace through a link" — fail closed rather than + // letting the clone through. let destNearest: string; let rootReal: string; try { From 145dce1e43885103d0dc909577c2841b6d202dc7 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:34:06 -0300 Subject: [PATCH 18/25] docs(readme): repos lists and repos clone clones; the reserved names and the URL rule said exactly (review) --- README.md | 4 ++-- src/cli.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b8b9461..f932553 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 a profile's repositories (its `repos` list) into the workspace. +> 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. ## The idea in one paragraph @@ -247,7 +247,7 @@ repos: ref: release # optional: a branch or a tag ``` -`name` is letters, digits, `.`, `_` and `-`, starting with a letter or a digit, not ending in `.`, not a Windows device name, and unique without case. `url` is an `https://`, `ssh://` or `user@host:path` URL made of `A-Z a-z 0-9 . _ ~ : / @ + -` only, its first character after the scheme, its host and (in the `user@host:path` form) its path each starting with a letter or a digit, and without credentials — let git authenticate. `path` is `/`-separated segments that each follow `name`'s rule (so no dot-folder), is not `AGENTS.md` or a `craftar.*` file, and no two entries share or nest a destination (compared without case). `ref` is a branch or a tag, never a commit id. No other key. The block is checked only when a command reads it — a Forge whose `repos` holds something else still loads and syncs — and a refusal names the entry's index and key, never a value. +`name` is letters, digits, `.`, `_` and `-`, starting with a letter or a digit, not ending in `.`, not a Windows device name, and unique without case. `url` is an `https://`, `ssh://` or `user@host:path` URL made of `A-Z a-z 0-9 . _ ~ : / @ + -` only, its first character after `://` (or its first character, in the `user@host:path` form), its host and (in that form) its path each starting with a letter or a digit, and without credentials — let git authenticate. `path` is `/`-separated segments that each follow `name`'s rule (so no dot-folder), does not start with `AGENTS.md`, `craftar.yaml`, `craftar.local.yaml` or `craftar.lock`, and no two entries share or nest a destination (compared without case). `ref` is a branch or a tag, never a commit id. No other key. The block is checked only when a command reads it — a Forge whose `repos` holds something else still loads and syncs — and a refusal names the entry's index and key, never a value. **Integrations.** A profile's `integrations` block can select recipes and give params in a Forge that declares `schema: 3` or above: diff --git a/src/cli.ts b/src/cli.ts index 77fb3d3..056a78f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -327,7 +327,7 @@ 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 sync; 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 --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") From 89793c8f5bddeca8997be8b7824920bcc6084f5e Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:46:09 -0300 Subject: [PATCH 19/25] fix(clone): git's repository-local variables (rev-parse --local-env-vars) are not handed to the clone's git (review) --- src/core/clone.ts | 44 +++++++++++++++++++++++++++++++++++++------- test/clone.test.ts | 18 ++++++++++++++---- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/core/clone.ts b/src/core/clone.ts index 6f5c8ba..c4d96ff 100644 --- a/src/core/clone.ts +++ b/src/core/clone.ts @@ -41,6 +41,36 @@ export type CloneResult = const WIN = process.platform === "win32"; +/** + * The repository-local environment variables git itself clears when it moves to another repository. + * This is what `git rev-parse --local-env-vars` lists (git 2.53). + */ +const GIT_LOCAL_ENV = [ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CONFIG", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_COUNT", + "GIT_OBJECT_DIRECTORY", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_IMPLICIT_WORK_TREE", + "GIT_GRAFT_FILE", + "GIT_INDEX_FILE", + "GIT_NO_REPLACE_OBJECTS", + "GIT_REPLACE_REF_BASE", + "GIT_PREFIX", + "GIT_SHALLOW_FILE", + "GIT_COMMON_DIR", +] as const; + +/** Set for O(1) lookup, upper-case for win32. */ +const GIT_LOCAL_SET = new Set(GIT_LOCAL_ENV.map((v) => v.toUpperCase())); + +/** Whether a key is one of git's repository-local variables — case-insensitive on win32, exact elsewhere. */ +function isLocalEnvVar(k: string): boolean { + return WIN ? GIT_LOCAL_SET.has(k.toUpperCase()) : GIT_LOCAL_SET.has(k); +} + /** Case-insensitive comparison on Windows, case-sensitive elsewhere. */ function eq(a: string, b: string): boolean { return WIN ? a.toLowerCase() === b.toLowerCase() : a === b; @@ -401,13 +431,13 @@ export async function cloneRepos( continue; } - // Build environment: drop GIT_DIR and GIT_WORK_TREE — they would place the checkout - // outside the checked destination (the workspace may be a worktree, or git hooks may have set them). - const { GIT_DIR: _gitDir, GIT_WORK_TREE: _gitWorkTree, ...inheritedEnv } = opts.env; - const env: NodeJS.ProcessEnv = { - ...inheritedEnv, - GIT_ALLOW_PROTOCOL: "https:ssh", - }; + // Build environment: drop all repository-local variables — they would place the checkout + // or parts of it outside the checked destination (the workspace may be a worktree, or + // git hooks may have set them). The list is what `git rev-parse --local-env-vars` returns. + const env: NodeJS.ProcessEnv = { GIT_ALLOW_PROTOCOL: "https:ssh" }; + for (const [k, v] of Object.entries(opts.env)) { + if (v !== undefined && !isLocalEnvVar(k)) env[k] = v; + } if (!opts.stdinIsTty) { env.GIT_TERMINAL_PROMPT = "0"; } diff --git a/test/clone.test.ts b/test/clone.test.ts index 148969c..b797a7c 100644 --- a/test/clone.test.ts +++ b/test/clone.test.ts @@ -284,13 +284,23 @@ describe("cloneRepos — one runner call per entry, in order, sequential", () => } } }); - it("GIT_DIR and GIT_WORK_TREE inherited from the caller are not handed to git: they would place the checkout elsewhere (review round 1)", async () => { + it("git's repository-local variables inherited from the caller are not handed to git (git rev-parse --local-env-vars) — they would move the checkout or its parts out of the checked destination (review rounds 1–2)", async () => { const { root } = await layout(); + const LOCAL = ["GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT", "GIT_OBJECT_DIRECTORY", "GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE", "GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS", "GIT_REPLACE_REF_BASE", "GIT_PREFIX", "GIT_SHALLOW_FILE", "GIT_COMMON_DIR"]; + const env: NodeJS.ProcessEnv = { MARKER_VAR: "kept", GIT_SSH_COMMAND: "ssh -i key", GIT_ASKPASS: "/bin/askpass" }; + for (const k of LOCAL) env[k] = "/elsewhere"; const r = recorder([0]); - await cloneRepos(root, [entry("api")], { git: GIT, env: { GIT_DIR: "/elsewhere/.git", GIT_WORK_TREE: "/elsewhere", MARKER_VAR: "kept" }, stdinIsTty: true }, r.run); - expect(r.calls[0].env.GIT_DIR).toBeUndefined(); - expect(r.calls[0].env.GIT_WORK_TREE).toBeUndefined(); + await cloneRepos(root, [entry("api")], { git: GIT, env, stdinIsTty: true }, r.run); + for (const k of LOCAL) expect(r.calls[0].env[k], k).toBeUndefined(); expect(r.calls[0].env.MARKER_VAR).toBe("kept"); + expect(r.calls[0].env.GIT_SSH_COMMAND).toBe("ssh -i key"); + expect(r.calls[0].env.GIT_ASKPASS).toBe("/bin/askpass"); + }); + it.runIf(WIN)("on win32 a repository-local variable is dropped whatever its case (Git_Dir)", async () => { + const { root } = await layout(); + const r = recorder([0]); + await cloneRepos(root, [entry("api")], { git: GIT, env: { Git_Dir: "C:\\elsewhere\\.git" }, stdinIsTty: true }, r.run); + expect(Object.keys(r.calls[0].env).filter((k) => k.toUpperCase() === "GIT_DIR")).toEqual([]); }); it("a failure in the middle: cloned, failed (git exited 128), cloned — the next entry still tried", async () => { const { root } = await layout(); From 26610204b079c9a9c6bc9348ad3b9e1fa68b0d9a Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:47:06 -0300 Subject: [PATCH 20/25] fix(init): a leftover craftar.lock's paths count as files sync writes for a clone's destination (review) --- src/core/init-flow.ts | 2 +- src/core/init.ts | 2 +- test/init-clone.test.ts | 20 ++++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/core/init-flow.ts b/src/core/init-flow.ts index 5d823b9..823e4d3 100644 --- a/src/core/init-flow.ts +++ b/src/core/init-flow.ts @@ -147,7 +147,7 @@ export async function askClone( // yes → planClone try { - const ctx = { planned: init.plan.files.map((f) => f.path), lockPaths: [] as string[], forgeDir: init.ws.forge.root }; + const ctx = { planned: init.plan.files.map((f) => f.path), lockPaths: init.lock?.files.map((f) => f.path) ?? [], forgeDir: init.ws.forge.root }; const clonePlan = await planClone(opts.root, profile, ctx, { present: "refuse", noEntries: "nothing to clone" }); return { clone: true, entries: clonePlan.entries }; } catch (e) { diff --git a/src/core/init.ts b/src/core/init.ts index 7da8cff..0c9e5fb 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -227,7 +227,7 @@ export async function planInit(root: string, input: InitInput, opts: InitOptions 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: [] as string[], forgeDir: ws.forge.root }; + 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; } diff --git a/test/init-clone.test.ts b/test/init-clone.test.ts index 7b60b6f..c31cdf0 100644 --- a/test/init-clone.test.ts +++ b/test/init-clone.test.ts @@ -5,6 +5,7 @@ import { againLine, askClone, askInit, confirmInit, type InitAnswers, type InitI import { planInit } from "../src/core/init.js"; import { loadWorkspace } from "../src/core/sync.js"; import { makeForge, profile, recipe, rule, tmpDir } from "./helpers/forge.js"; +import { runCli } from "./helpers/cli.js"; import { git, remoteForge } from "./helpers/remote.js"; // Spec 37 §4.2–§4.3, §5.2 — planInit with clone, askClone, confirmInit's clone questions, the again line, and @@ -261,3 +262,22 @@ describe("askInit carries a typed --clone into planInit (review round 1 blocker) } }); }); + +describe("a leftover craftar.lock (no craftar.yaml) is read for the destination checks (review round 2)", () => { + it("a path the old lock records is a file sync writes — planInit with clone refuses it, as craftar repos would", async () => { + const s = await setup(); + expect(runCli(["init", "--forge", s.forge, "--profile", "acme", "--workspace", s.ws]).code).toBe(0); + await fs.rm(path.join(s.ws, "craftar.yaml")); + const lockFile = path.join(s.ws, "craftar.lock"); + const lock = JSON.parse(await fs.readFile(lockFile, "utf8")); + lock.files.push({ path: "portal-api/readme.md", hash: "sha256:" + "0".repeat(64), target: "claude-code", ingredient: "rule/a" }); + await fs.writeFile(lockFile, JSON.stringify(lock, null, 2) + "\n"); + await expect(planInit(s.ws, { forge: s.forge, profile: "acme", clone: true })).rejects.toThrow(/^cannot clone portal-api: portal-api is a file sync writes$/); + const init = await planInit(s.ws, { forge: s.forge, profile: "acme" }); + const said: string[] = []; + const answers = ["yes", "no"]; + const io: InitIo = { ask: async () => "", confirm: async () => answers.shift() ?? "no", say: (l) => void said.push(l) }; + expect(await askClone(io, init, { root: s.ws, offline: false, git: GIT })).toEqual({ clone: false, entries: [] }); + expect(said).toContain("cannot clone portal-api: portal-api is a file sync writes"); + }); +}); From e373c23d455b791d70f4437f860ce87334d9572c Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:49:52 -0300 Subject: [PATCH 21/25] fix(init): the not-cloned line said once in the interactive --no-sync run; repos keeps its warnings with no entry (review) --- src/cli.ts | 13 +++++++------ src/core/clone.ts | 5 +++-- src/core/init-flow.ts | 5 ++++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 056a78f..3397a9a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -46,7 +46,7 @@ import { } from "./core/unify.js"; import { checkParamWrites, writeParamFile } from "./core/param-writes.js"; import { checkInitFlags, initLine, planInit, type InitInput } from "./core/init.js"; -import { askInit, askClone, confirmInit, againLine, type InitAnswers } from "./core/init-flow.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"; import { readlineIo } from "./prompt.js"; @@ -243,8 +243,8 @@ async function applyAndReport(ws: Workspace, p: Plan, st: FileStatus[], lock: Lo /** * Shared clone report for `craftar init --clone` and `craftar repos clone`. - * Prints the header, one row per skipped entry (present state), then one row per result, - * all in the profile's order. Returns whether any clone failed. + * Prints the header, the present rows first, then the clone rows (cloned/failed), each group + * in the profile's order. Returns whether any clone failed. * * @param header The bold header line (without the leading text, which is added here) * @param results The clone results (cloned or failed) @@ -490,8 +490,8 @@ program if (!o.sync) { console.log(nextSyncLine(init.statuses, unset)); for (const w of p.warnings) console.log(` ${pc.yellow("warn")} ${w}`); - if (cloning && refused) { - console.log(" repositories: not cloned — the first sync is refused"); + if (cloning && refused && answers === null) { + console.log(NOT_CLONED_REFUSED); } else if (cloning && !refused) { // Clone even without sync await runCloneStep(root, ws.config.profile, cloneEntries, git!, again); @@ -505,7 +505,7 @@ program if (unset.length) { for (const w of p.warnings) console.log(` ${pc.yellow("warn")} ${w}`); if (cloning && answers === null) { - console.log(" repositories: not cloned — the first sync is refused"); + console.log(NOT_CLONED_REFUSED); } refusalBlock(unset, { thenSync: true }); again(); @@ -553,6 +553,7 @@ const reposCmd = program console.log(JSON.stringify(out, null, 2)); } else { console.log(`profile ${ws.config.profile} lists no repositories`); + for (const w of p.warnings) console.log(` ${pc.yellow("warn")} ${w}`); } return; } diff --git a/src/core/clone.ts b/src/core/clone.ts index c4d96ff..45dc4e7 100644 --- a/src/core/clone.ts +++ b/src/core/clone.ts @@ -13,6 +13,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import type { Profile } from "../schema/index.js"; import { realpathOfNearest } from "./forge.js"; +import { nameShaped } from "./integrations.js"; import { onPath, spawnInherited, type Runner } from "./proc.js"; import { cloneArgs, isRepoName, profileRepos, type RepoEntry } from "./repos.js"; @@ -307,8 +308,8 @@ export async function planClone( } for (const name of opts.names) { if (!entryNameMap.has(name.toLowerCase())) { - // R1: unknown name — check if it's shaped like a repository name - if (isRepoName(name)) { + // R1: unknown name — print it only when it is shaped like a repository name + if (isRepoName(name) && nameShaped(name)) { throw new Error(`profile ${profile.name} has no repository ${name}`); } else { throw new Error(`profile ${profile.name}: a name given is not a repository name`); diff --git a/src/core/init-flow.ts b/src/core/init-flow.ts index 823e4d3..b5bbfa4 100644 --- a/src/core/init-flow.ts +++ b/src/core/init-flow.ts @@ -18,6 +18,9 @@ import type { LocalKey } from "./workspace-yaml.js"; * `console` or `readline`, and writes nothing. */ +/** The message printed when cloning is skipped because the first sync is refused. */ +export const NOT_CLONED_REFUSED = " repositories: not cloned — the first sync is refused"; + export interface InitIo { ask(question: string): Promise; confirm(question: string): Promise; @@ -184,7 +187,7 @@ export async function confirmInit( // When the sync is refused and cloneRefused, say the not-cloned line if (unset.length > 0 && opts.cloneRefused) { - io.say(" repositories: not cloned — the first sync is refused"); + io.say(NOT_CLONED_REFUSED); } // Build the question From 802e3f6ac2197d50a9731aff8c6232412f8d4a0c Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:50:57 -0300 Subject: [PATCH 22/25] docs(readme): the reserved first segment said exactly; forge.ts ends with a newline (review) --- README.md | 2 +- src/core/forge.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f932553..f3d7083 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ repos: ref: release # optional: a branch or a tag ``` -`name` is letters, digits, `.`, `_` and `-`, starting with a letter or a digit, not ending in `.`, not a Windows device name, and unique without case. `url` is an `https://`, `ssh://` or `user@host:path` URL made of `A-Z a-z 0-9 . _ ~ : / @ + -` only, its first character after `://` (or its first character, in the `user@host:path` form), its host and (in that form) its path each starting with a letter or a digit, and without credentials — let git authenticate. `path` is `/`-separated segments that each follow `name`'s rule (so no dot-folder), does not start with `AGENTS.md`, `craftar.yaml`, `craftar.local.yaml` or `craftar.lock`, and no two entries share or nest a destination (compared without case). `ref` is a branch or a tag, never a commit id. No other key. The block is checked only when a command reads it — a Forge whose `repos` holds something else still loads and syncs — and a refusal names the entry's index and key, never a value. +`name` is letters, digits, `.`, `_` and `-`, starting with a letter or a digit, not ending in `.`, not a Windows device name, and unique without case. `url` is an `https://`, `ssh://` or `user@host:path` URL made of `A-Z a-z 0-9 . _ ~ : / @ + -` only, its first character after `://` (or its first character, in the `user@host:path` form), its host and (in that form) its path each starting with a letter or a digit, and without credentials — let git authenticate. `path` is `/`-separated segments that each follow `name`'s rule (so no dot-folder), has a first segment that is none of `AGENTS.md`, `craftar.yaml`, `craftar.local.yaml` and `craftar.lock` (compared without case), and no two entries share or nest a destination (compared without case). `ref` is a branch or a tag, never a commit id. No other key. The block is checked only when a command reads it — a Forge whose `repos` holds something else still loads and syncs — and a refusal names the entry's index and key, never a value. **Integrations.** A profile's `integrations` block can select recipes and give params in a Forge that declares `schema: 3` or above: diff --git a/src/core/forge.ts b/src/core/forge.ts index 0963a48..d3a3e05 100644 --- a/src/core/forge.ts +++ b/src/core/forge.ts @@ -432,4 +432,4 @@ export async function realpathOfNearest(abs: string): Promise { export function cannotResolve(p: string, e: unknown, what: "cannot be inspected" | "exists but cannot be resolved"): Error { const code = (e as NodeJS.ErrnoException | null)?.code ?? (e instanceof Error ? e.message : String(e)); return new Error(`${p} ${what} (${code})`); -} \ No newline at end of file +} From 4fa855b1613713eed8a886e298c501456014232a Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 16:59:47 -0300 Subject: [PATCH 23/25] =?UTF-8?q?fix(clone):=20craftar's=20GIT=5FALLOW=5FP?= =?UTF-8?q?ROTOCOL=20is=20set=20last=20=E2=80=94=20an=20inherited=20one=20?= =?UTF-8?q?can=20no=20longer=20widen=20the=20transports=20(review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/clone.ts | 13 ++++++++----- test/clone.test.ts | 15 +++++++++++++++ test/repos-cli.test.ts | 9 +++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/core/clone.ts b/src/core/clone.ts index 45dc4e7..5f7756e 100644 --- a/src/core/clone.ts +++ b/src/core/clone.ts @@ -432,13 +432,16 @@ export async function cloneRepos( continue; } - // Build environment: drop all repository-local variables — they would place the checkout - // or parts of it outside the checked destination (the workspace may be a worktree, or - // git hooks may have set them). The list is what `git rev-parse --local-env-vars` returns. - const env: NodeJS.ProcessEnv = { GIT_ALLOW_PROTOCOL: "https:ssh" }; + // Build environment: drop repository-local variables (they would place the checkout or parts + // of it outside the checked destination) and any inherited GIT_ALLOW_PROTOCOL. Craftar's own + // values are set last, so nothing inherited can widen the transports (spec 37 §4.4, §7). + const env: NodeJS.ProcessEnv = {}; + const isAllowProtocol = (k: string): boolean => + WIN ? k.toUpperCase() === "GIT_ALLOW_PROTOCOL" : k === "GIT_ALLOW_PROTOCOL"; for (const [k, v] of Object.entries(opts.env)) { - if (v !== undefined && !isLocalEnvVar(k)) env[k] = v; + if (v !== undefined && !isLocalEnvVar(k) && !isAllowProtocol(k)) env[k] = v; } + env.GIT_ALLOW_PROTOCOL = "https:ssh"; if (!opts.stdinIsTty) { env.GIT_TERMINAL_PROMPT = "0"; } diff --git a/test/clone.test.ts b/test/clone.test.ts index b797a7c..9b13e51 100644 --- a/test/clone.test.ts +++ b/test/clone.test.ts @@ -296,6 +296,21 @@ describe("cloneRepos — one runner call per entry, in order, sequential", () => expect(r.calls[0].env.GIT_SSH_COMMAND).toBe("ssh -i key"); expect(r.calls[0].env.GIT_ASKPASS).toBe("/bin/askpass"); }); + it("an inherited GIT_ALLOW_PROTOCOL never widens the transports: git always gets https:ssh (review round 3 blocker)", async () => { + const { root } = await layout(); + for (const stdinIsTty of [false, true]) { + const r = recorder([0]); + await cloneRepos(root, [entry("api")], { git: GIT, env: { GIT_ALLOW_PROTOCOL: "ext:file:https:ssh", MARKER_VAR: "kept" }, stdinIsTty }, r.run); + expect(r.calls[0].env.GIT_ALLOW_PROTOCOL).toBe("https:ssh"); + expect(r.calls[0].env.MARKER_VAR).toBe("kept"); + } + }); + it.runIf(WIN)("on win32 an inherited git_allow_protocol in another case does not reach git beside craftar's", async () => { + const { root } = await layout(); + const r = recorder([0]); + await cloneRepos(root, [entry("api")], { git: GIT, env: { git_allow_protocol: "ext:file" }, stdinIsTty: true }, r.run); + expect(Object.entries(r.calls[0].env).filter(([k]) => k.toUpperCase() === "GIT_ALLOW_PROTOCOL")).toEqual([["GIT_ALLOW_PROTOCOL", "https:ssh"]]); + }); it.runIf(WIN)("on win32 a repository-local variable is dropped whatever its case (Git_Dir)", async () => { const { root } = await layout(); const r = recorder([0]); diff --git a/test/repos-cli.test.ts b/test/repos-cli.test.ts index 2f5a6a2..a71711a 100644 --- a/test/repos-cli.test.ts +++ b/test/repos-cli.test.ts @@ -115,6 +115,15 @@ describe("craftar repos — the listing", () => { expect(r.code).toBe(0); expect(r.stdout).toBe("profile acme lists no repositories\n"); }); + it("a profile with no entry whose plan warns: the warn line follows the one line (review round 3)", async () => { + const s = await setup({ repos: [] }); + await fs.writeFile(path.join(s.forge, "ingredients", "rules", "a", "rule.md"), "# A {{nobody.sets.this}}\n"); + const r = s.run(["repos"]); + expect(r.code, r.stderr).toBe(0); + const lines = r.stdout.split("\n"); + expect(lines[0]).toBe("profile acme lists no repositories"); + expect(lines[1]).toMatch(/^ warn .*nobody\.sets\.this/); + }); it("C4: the block does not read — exit 1", async () => { const s = await setup({ repos: [{ name: "a" }] }); const r = s.run(["repos"]); From fce5e80c04d2e4809c041cd1b6220b2485b179ea Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 17:10:21 -0300 Subject: [PATCH 24/25] 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 22183f4..263ec35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.32.1", + "version": "0.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.32.1", + "version": "0.33.0", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 4578d1a..07e85c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.32.1", + "version": "0.33.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 3397a9a..a06f717 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -71,7 +71,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.32.1"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.33.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. */ From f5087fe3079b86bc031cfef0df18f946d8c23579 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 18 Oct 2026 17:30:47 -0300 Subject: [PATCH 25/25] test(clone): the fake git's hook path and the relative-entry test work on Windows CI NODE_OPTIONS read the hook's backslashes as escapes, so the craftar child failed to start; and a temp dir on another drive has no relative spelling. Both were test defects, not product ones. --- test/clone.test.ts | 6 +++++- test/helpers/fake-git.ts | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/test/clone.test.ts b/test/clone.test.ts index 9b13e51..f3ad2d4 100644 --- a/test/clone.test.ts +++ b/test/clone.test.ts @@ -234,7 +234,11 @@ describe("findGit — onPath, nothing else", () => { expect(await findGit({ PATH: "relative-bin" })).toBeNull(); }); it("a git reachable only through a relative PATH entry is not found, though the file is there", async () => { - const dir = await scratch(); + // Under the checkout's node_modules/.cache (ignored by git): on the current directory's drive, so a relative + // spelling exists on win32 too — a temp dir may sit on another drive, where path.relative gives an absolute path. + const dir = path.join(process.cwd(), "node_modules", ".cache", `craftar-findgit-${process.pid}-${Date.now()}`); + await fs.mkdir(dir, { recursive: true }); + cleanups.push(() => fs.rm(dir, { recursive: true, force: true })); await fs.copyFile(process.execPath, path.join(dir, "git" + EXE)); await fs.chmod(path.join(dir, "git" + EXE), 0o755); const rel = path.relative(process.cwd(), dir); diff --git a/test/helpers/fake-git.ts b/test/helpers/fake-git.ts index ed9c589..3eef6ae 100644 --- a/test/helpers/fake-git.ts +++ b/test/helpers/fake-git.ts @@ -52,7 +52,8 @@ export async function fakeGit(): Promise { dir, git, log, - env: (extra = {}) => ({ PATH: dir, Path: dir, NODE_OPTIONS: `--require "${hook}"`, CRAFTAR_TEST_CLONE_LOG: log, ...extra }), + // NODE_OPTIONS reads `\` inside double quotes as an escape: on win32 the hook's path is given with `/`, which node accepts. + env: (extra = {}) => ({ PATH: dir, Path: dir, NODE_OPTIONS: `--require "${hook.replace(/\\/g, "/")}"`, CRAFTAR_TEST_CLONE_LOG: log, ...extra }), cleanup: () => fs.rm(dir, { recursive: true, force: true }), }; }