craftar 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -22,7 +22,8 @@ import { preferredLine, renderImportReport } from "./core/import-report.js";
22
22
  import { driftList, unmanaged } from "./core/drift.js";
23
23
  import { diffIngredients, listVariants, profileOf } from "./core/variants.js";
24
24
  import { hashNormalized, toLf, stripBom } from "./core/text.js";
25
- import { exists, gitDirty, gitIsRepo, gitUnheld, loadForge } from "./core/forge.js";
25
+ import { exists, gitDirty, gitIgnored, gitIsRepo, gitUnheld, loadForge } from "./core/forge.js";
26
+ import { applyBasePromotion, planBasePromotion, proveBasePromotion } from "./core/promote.js";
26
27
  import { fingerprintDir } from "./core/fingerprint.js";
27
28
  import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, pruneCandidates, pruneRecipes, note, } from "./core/unify.js";
28
29
  import { checkParamWrites, writeParamFile } from "./core/param-writes.js";
@@ -34,6 +35,8 @@ import { localKeys, readLocalFile } from "./core/workspace-yaml.js";
34
35
  import { HUNK_CLASSES, INGREDIENT_TYPES, UnifyPlanSchema } from "./schema/index.js";
35
36
  import { EXAMPLE_SETTINGS } from "./emitters/shared.js";
36
37
  import { YamlSyntaxError, parseYamlText } from "./core/yaml-read.js";
38
+ import { spawn } from "node:child_process";
39
+ import { recordedServices, pendingLine, upRefusal, composeArgv, downArgv, readPs, dockerVersion, dockerFault, dockerRunner, projectName, statusReport } from "./core/services.js";
37
40
  import { errorText } from "./core/schema-fault.js";
38
41
  process.stdout.on("error", (e) => { if (e.code === "EPIPE")
39
42
  process.exit(0); });
@@ -44,7 +47,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
44
47
  */
45
48
  const DIFF_EXIT_CODE_FETCH_MODE = "sync";
46
49
  const program = new Command();
47
- program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.23.0");
50
+ program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.25.0");
48
51
  const SAVE_PLAN_GATE = { what: "plan", who: "unify", outside: "save plans outside the Forge", never: "unify never overwrites" };
49
52
  const REPORT_GATE = { what: "report", who: "import", outside: "write the report outside the Forge", never: "import never overwrites" };
50
53
  program
@@ -1142,7 +1145,7 @@ program
1142
1145
  for (const ing of result.ingredients) {
1143
1146
  const col1 = ing.outputName !== ing.name ? `${ing.name} → ${ing.outputName}` : ing.name;
1144
1147
  allCol1.push(col1);
1145
- allCol2.push(`targets ${ing.targets === "*" ? "*" : ing.targets.join(", ")}`);
1148
+ allCol2.push(`targets ${ing.targets === "*" ? "*" : (ing.targets.join(", ") || "none")}`);
1146
1149
  allCol3.push(`in ${ing.recipes.length ? ing.recipes.join(", ") : "no recipe"}`);
1147
1150
  }
1148
1151
  for (const m of result.missing) {
@@ -1164,7 +1167,7 @@ program
1164
1167
  console.log(type);
1165
1168
  for (const ing of ings) {
1166
1169
  const col1 = ing.outputName !== ing.name ? `${ing.name} → ${ing.outputName}` : ing.name;
1167
- const col2 = `targets ${ing.targets === "*" ? "*" : ing.targets.join(", ")}`;
1170
+ const col2 = `targets ${ing.targets === "*" ? "*" : (ing.targets.join(", ") || "none")}`;
1168
1171
  const col3 = `in ${ing.recipes.length ? ing.recipes.join(", ") : "no recipe"}`;
1169
1172
  const desc = ing.description ? `— ${ing.description}` : "";
1170
1173
  const disabled = ing.disabled ? "(disabled by this workspace)" : "";
@@ -1299,6 +1302,9 @@ program
1299
1302
  for (const line of pathLines) {
1300
1303
  console.log(` ${line.label.padEnd(maxLabelLen)} ${line.content}`);
1301
1304
  }
1305
+ console.log();
1306
+ // Service line — text mode only (spec 31 §4.7: no JSON key, one line in the paths block)
1307
+ console.log(" service: written by sync under .craftar/ for no target; see craftar services");
1302
1308
  for (const w of warnings)
1303
1309
  console.log(` ${pc.yellow("warn")} ${w}`);
1304
1310
  });
@@ -1955,24 +1961,7 @@ forge
1955
1961
  console.log(` ${pc.yellow("warn")} ${w}`);
1956
1962
  // Spec 25 §4.2: impact lines, one per workspace, with suffix format
1957
1963
  for (let i = 0; i < impactResults.length; i++) {
1958
- const ws = impactEntries[i];
1959
- const r = impactResults[i];
1960
- let stateStr;
1961
- if (r.state === "no-effect") {
1962
- stateStr = "no effect";
1963
- }
1964
- else if (r.state === "changed") {
1965
- const n = r.files.length;
1966
- stateStr = n === 1 ? `1 file changes — ${r.files[0]}` : `${n} files change — ${r.files.join(", ")}`;
1967
- }
1968
- else if (r.state === "missing") {
1969
- stateStr = "missing";
1970
- }
1971
- else {
1972
- stateStr = `error: ${r.error}`;
1973
- }
1974
- const impactSuffix = matchSuffix(ws);
1975
- console.log(` impact: ${ws.entry.path} (${ws.entry.profile}) ${stateStr}${impactSuffix}`);
1964
+ console.log(` ${impactLine(impactEntries[i], impactResults[i])}`);
1976
1965
  }
1977
1966
  // Spec 25 §4.2: the next: hint is printed only when no workspace was checked
1978
1967
  // (every entry missing/stage-config error, or no entries, or --no-impact, or unreadable)
@@ -1981,6 +1970,269 @@ forge
1981
1970
  console.log(` next: run \`craftar status --workspace <dir>\` in a workspace on profile ${o.profile} to see what moved`);
1982
1971
  }
1983
1972
  });
1973
+ forge
1974
+ .command("promote")
1975
+ .description("Make a profile's variant the base: the base takes the variant's files, every other profile that resolves the old base gets it back as its own variant through a recipe it owns, and every profile is proved to resolve the same ingredients before anything is written. Writes to the Forge")
1976
+ .argument("<type/name>", "base ingredient (rule/workflow)")
1977
+ .requiredOption("--profile <p>", "the profile whose variant becomes the base")
1978
+ .option("--forge <dir>", "Forge directory (instead of --workspace)")
1979
+ .option("--workspace <dir>", "workspace whose craftar.yaml names the Forge (default: .)")
1980
+ .option("--no-impact", "skip planning the registered workspaces of this Forge before and after the writes")
1981
+ .option("--dry-run", "run every check and the first impact pass, print what would be written, and write nothing", false)
1982
+ .option("--json", "machine-readable output", false)
1983
+ .action(async (ref, o) => {
1984
+ // Never through the cache, as `forge unify` (spec 13 §4.4).
1985
+ const f = await resolveForge({
1986
+ forge: o.forge,
1987
+ workspace: o.workspace,
1988
+ refuseRemote: (url) => `the Forge of this workspace is remote (${url}) — clone it and pass --forge <dir>`,
1989
+ });
1990
+ // P2 (spec 29 §4.4): git is the undo, so it must be there, hold a commit and be clean — before anything is planned.
1991
+ if (f.commit === null) {
1992
+ if (await gitIsRepo(f.root))
1993
+ fail(`${f.root} has no commits yet — forge promote writes to the Forge and needs git as the undo`);
1994
+ fail(`${f.root} is not a git repository — forge promote writes to the Forge and needs git as the undo`);
1995
+ }
1996
+ if (await gitDirty(f.root))
1997
+ fail(`${f.root} is not a clean git checkout — commit or stash your changes first`);
1998
+ const promotion = await planBasePromotion(f, ref, o.profile);
1999
+ const forgeRel = (abs) => path.relative(f.root, abs).split(path.sep).join("/");
2000
+ // Every path overwritten or deleted must be held by git; a path created must not be one git ignores, or `git clean` would not undo it.
2001
+ const unheld = await gitUnheld(f.root, promotion.mustHold);
2002
+ for (const w of promotion.writes) {
2003
+ if (w.created && (await gitIgnored(f.root, forgeRel(w.abs))))
2004
+ unheld.push({ path: forgeRel(w.abs), reason: "ignored" });
2005
+ }
2006
+ if (unheld.length)
2007
+ fail(unheldMessage("forge promote", f.root, unheld));
2008
+ await proveBasePromotion(f, promotion);
2009
+ let impactState = "skipped";
2010
+ let impactEntries = [];
2011
+ let impactBefore = [];
2012
+ const warnings = [...promotion.warnings];
2013
+ if (o.impact) {
2014
+ try {
2015
+ const fw = await forgeWorkspaces(craftarHome(), f.root);
2016
+ impactEntries = fw.workspaces;
2017
+ warnings.push(...fw.warnings);
2018
+ impactBefore = await planAll(fw.workspaces, f);
2019
+ impactState = refineRegistryState(fw.state, impactBefore);
2020
+ }
2021
+ catch (e) {
2022
+ impactState = "unreadable";
2023
+ warnings.push(`the registry could not be read (${e instanceof Error ? e.message : String(e)}) — no workspace was checked`);
2024
+ }
2025
+ if (impactState === "off")
2026
+ warnings.push("the registry is off — no workspace was checked");
2027
+ }
2028
+ // The references the Forge cannot see (spec 29 §4.4): a workspace adding a shared recipe that names the base, removing
2029
+ // one this run copies for its profile, or disabling the base or the variant by name, follows the Forge at its next sync, not this promotion.
2030
+ const shared = [...f.recipes.values()].filter((r) => r.ingredients.includes(promotion.base) && !promotion.recipes.edited.includes(r.name)).map((r) => r.name);
2031
+ const split = promotion.recipes.created.map((r) => r.from);
2032
+ const byName = (doc) => {
2033
+ const d = (typeof doc === "object" && doc !== null ? doc : {});
2034
+ const add = Array.isArray(d.recipes?.add) ? d.recipes.add : [];
2035
+ const remove = Array.isArray(d.recipes?.remove) ? d.recipes.remove : [];
2036
+ const disable = Array.isArray(d.overrides?.ingredients?.disable) ? d.overrides.ingredients.disable : [];
2037
+ // A removed recipe the profile no longer names (it names the copy) stops removing anything.
2038
+ return add.some((r) => shared.includes(r)) || remove.some((r) => split.includes(r)) || disable.some((r) => r === promotion.base || r === promotion.removed);
2039
+ };
2040
+ for (const c of concerned(impactBefore, impactEntries, byName).concerned) {
2041
+ warnings.push(`${c.path} (${c.file}) adds or removes a recipe that names ${promotion.base}, or disables ${promotion.base} or ${promotion.removed}, by name — its next sync follows the Forge, not this promotion`);
2042
+ }
2043
+ const journal = [];
2044
+ if (!o.dryRun) {
2045
+ try {
2046
+ await applyBasePromotion(promotion, journal);
2047
+ }
2048
+ catch (e) {
2049
+ // After writing began: name what was touched and how git undoes it, and still exit 1.
2050
+ fail(lateFailure(e, f.root, journal, "forge promote"));
2051
+ }
2052
+ }
2053
+ const checked = impactState !== "skipped" && impactState !== "unreadable" && impactState !== "off";
2054
+ let impactResults = [];
2055
+ if (!o.dryRun && checked && impactEntries.length > 0) {
2056
+ let impactAfter;
2057
+ try {
2058
+ impactAfter = await planAll(impactEntries, await loadForge(f.root));
2059
+ }
2060
+ catch (e) {
2061
+ const message = e instanceof Error ? e.message : String(e);
2062
+ impactAfter = impactEntries.map(() => ({ kind: "error", stage: "plan", message }));
2063
+ }
2064
+ impactResults = impactEntries.map((_, i) => impactOf(impactBefore[i], impactAfter[i]));
2065
+ }
2066
+ if (o.json) {
2067
+ return console.log(JSON.stringify({
2068
+ base: promotion.base,
2069
+ profile: promotion.profile,
2070
+ dryRun: o.dryRun,
2071
+ removed: promotion.removed,
2072
+ // Every key is always present, `[]` when empty.
2073
+ created: promotion.created,
2074
+ recipes: promotion.recipes,
2075
+ profiles: promotion.profiles,
2076
+ impact: impactResults.map((r, i) => ({
2077
+ path: impactEntries[i].entry.path,
2078
+ profile: impactEntries[i].entry.profile,
2079
+ match: impactEntries[i].match,
2080
+ via: impactEntries[i].via,
2081
+ ref: impactEntries[i].ref,
2082
+ state: r.state,
2083
+ files: r.files,
2084
+ error: r.error,
2085
+ })),
2086
+ warnings,
2087
+ }, null, 2));
2088
+ }
2089
+ const n = (count) => `${count} workspace(s)`;
2090
+ console.log(pc.bold(`craftar forge promote ${promotion.base} ← ${promotion.profile}${o.dryRun ? " (dry run — nothing written)" : ""}`));
2091
+ console.log(` base ${promotion.base} ${o.dryRun ? "would hold" : "now holds"} ${promotion.profile}'s text (was ${promotion.removed})`);
2092
+ for (const c of promotion.created)
2093
+ console.log(` variant ${c} ${o.dryRun ? "would be created" : "created"} (the old base)`);
2094
+ if (promotion.discarded)
2095
+ console.log(` the old base is resolved by no profile — discarded (git holds it)`);
2096
+ const recipeLines = [...promotion.detail.recipes.filter((r) => r.from === null), ...promotion.detail.recipes.filter((r) => r.from !== null)];
2097
+ for (const r of recipeLines)
2098
+ console.log(` recipe ${r.name}${r.from === null ? "" : ` ${o.dryRun ? "would be created" : "created"} from ${r.from}`}: ${r.old} → ${r.new}`);
2099
+ for (const p of promotion.detail.profiles)
2100
+ console.log(` profile ${p.file}: ${p.swaps.map((s) => `${s.old} → ${s.new}`).join(", ")}`);
2101
+ for (const w of warnings)
2102
+ console.log(` ${pc.yellow("warn")} ${w}`);
2103
+ if (checked) {
2104
+ if (impactEntries.length === 0)
2105
+ console.log(` impact no workspace of this Forge is registered on this machine`);
2106
+ else if (o.dryRun)
2107
+ console.log(` impact ${n(impactEntries.length)} planned: not written, not compared`);
2108
+ else if (impactResults.every((r) => r.state === "no-effect"))
2109
+ console.log(` impact ${n(impactResults.length)} planned before and after: no effect`);
2110
+ else
2111
+ for (let i = 0; i < impactResults.length; i++)
2112
+ console.log(` ${impactLine(impactEntries[i], impactResults[i])}`);
2113
+ }
2114
+ if (promotion.created.length)
2115
+ console.log(` next craftar forge variants — the old base now shows as ${promotion.created.length} variant(s) to unify`);
2116
+ });
2117
+ /* ---------------------------------------------------------------- services ---------------------------------------------------------------- */
2118
+ const services = program.command("services").description("Local services of this workspace: run Docker Compose on what craftar.lock records under .craftar/");
2119
+ /** The workspace's lock, read without the Forge: these commands need no plan and no network (spec 31 §4.2). */
2120
+ async function servicesContext(dir) {
2121
+ const { root, real } = await servicesBase(dir);
2122
+ return { root, real, recorded: await recordedServices(root, await readLock(root)) };
2123
+ }
2124
+ /** The base workspace info without the lock read — `down` catches the lock read separately. */
2125
+ async function servicesBase(dir) {
2126
+ const root = path.resolve(dir);
2127
+ if (!(await exists(path.join(root, WORKSPACE_FILE))))
2128
+ fail(`no ${WORKSPACE_FILE} in ${root} — run craftar services inside a workspace, or pass --workspace <dir>`);
2129
+ return { root, real: await fs.realpath(root) };
2130
+ }
2131
+ /** Helper to get the .env file path if it exists, or null. */
2132
+ async function envFileOrNull(root) {
2133
+ const env = path.join(root, ".env");
2134
+ return (await exists(env)) ? env : null;
2135
+ }
2136
+ /** Runs `docker compose` with the terminal's own streams and resolves with its exit code. No shell. */
2137
+ function runCompose(argv, real) {
2138
+ return new Promise((resolve, reject) => {
2139
+ const child = spawn("docker", argv, { stdio: "inherit", env: { ...process.env, CRAFTAR_WORKSPACE: real } });
2140
+ child.on("error", reject);
2141
+ child.on("close", (code) => resolve(code ?? 1));
2142
+ });
2143
+ }
2144
+ services
2145
+ .command("up")
2146
+ .description("Start the services (docker compose up -d) — refused when a recorded file is missing or edited")
2147
+ .argument("[service...]", "Compose service names to start (default: all)")
2148
+ .option("--wait", "wait until the services are running or healthy", false)
2149
+ .option("--workspace <dir>", "workspace root", ".")
2150
+ .action(async (names, o) => {
2151
+ try {
2152
+ const { root, real, recorded } = await servicesContext(o.workspace);
2153
+ const refusal = upRefusal(recorded, names);
2154
+ if (refusal.length)
2155
+ fail(refusal.join("\n"));
2156
+ const fault = dockerFault(await dockerVersion(dockerRunner), true);
2157
+ if (fault !== null)
2158
+ fail(fault);
2159
+ const argv = composeArgv("up", { project: projectName(real), stack: path.join(root, ".craftar", "compose.yaml"), envFile: await envFileOrNull(root), wait: o.wait, services: names });
2160
+ process.exitCode = await runCompose(argv, real);
2161
+ }
2162
+ catch (e) {
2163
+ fail(e instanceof Error ? e.message : String(e));
2164
+ }
2165
+ });
2166
+ services
2167
+ .command("down")
2168
+ .description("Stop the services (docker compose down) — never refused on the files")
2169
+ .option("--volumes", "also remove the volumes the stack declares", false)
2170
+ .option("--workspace <dir>", "workspace root", ".")
2171
+ .action(async (o) => {
2172
+ try {
2173
+ const { root, real } = await servicesBase(o.workspace);
2174
+ // Try to read the lock — if it fails, warn and stop by name only
2175
+ let recorded = null;
2176
+ try {
2177
+ recorded = await recordedServices(root, await readLock(root));
2178
+ }
2179
+ catch (e) {
2180
+ console.error(`${pc.yellow("warn")} ${errorText(e)}`);
2181
+ }
2182
+ const docker = await dockerVersion(dockerRunner);
2183
+ const fault = dockerFault(docker, false);
2184
+ if (fault !== null)
2185
+ fail(fault);
2186
+ // Use downArgv for the full decision — `-f` and `--env-file` only when lock was read and stack is usable
2187
+ const argv = downArgv(projectName(real), recorded, docker, path.join(root, ".craftar", "compose.yaml"), await envFileOrNull(root), o.volumes);
2188
+ process.exitCode = await runCompose(argv, real);
2189
+ }
2190
+ catch (e) {
2191
+ fail(e instanceof Error ? e.message : String(e));
2192
+ }
2193
+ });
2194
+ services
2195
+ .command("status")
2196
+ .description("The declared services and their containers — reports, never refused on the files")
2197
+ .option("--json", "machine-readable output", false)
2198
+ .option("--workspace <dir>", "workspace root", ".")
2199
+ .action(async (o) => {
2200
+ try {
2201
+ const { real, recorded } = await servicesContext(o.workspace);
2202
+ const docker = await dockerVersion(dockerRunner);
2203
+ let ps = null;
2204
+ let psFault = dockerFault(docker, false);
2205
+ if (psFault === null) {
2206
+ const r = await dockerRunner("docker", composeArgv("ps", { project: projectName(real), stack: null, envFile: null }));
2207
+ if (r.code === 0)
2208
+ ps = readPs(r.stdout);
2209
+ else
2210
+ psFault = `docker compose ps failed — ${r.stderr.trim().split("\n")[0] || `exit ${r.code}`}`;
2211
+ }
2212
+ const report = statusReport(real, recorded, docker, ps, psFault);
2213
+ if (o.json)
2214
+ console.log(JSON.stringify(report, null, 2));
2215
+ else {
2216
+ console.log(pc.bold(`craftar services — project ${report.project}`));
2217
+ if (report.services.length === 0)
2218
+ console.log(" no service declared");
2219
+ const w = Math.max(0, ...report.services.map((s) => s.name.length));
2220
+ for (const s of report.services) {
2221
+ const ports = s.ports.map((p) => `${p.host}:${p.container}/${p.protocol}`).join(", ");
2222
+ console.log(` ${s.name.padEnd(w)} ${s.ingredient ?? "(not declared)"} ${s.state}${s.health ? ` (${s.health})` : ""}${ports ? ` ${ports}` : ""}`);
2223
+ }
2224
+ for (const p of report.pending)
2225
+ console.log(` ${pc.yellow(pendingLine(p))}`);
2226
+ for (const x of report.warnings)
2227
+ console.error(`${pc.yellow("warn")} ${x}`);
2228
+ }
2229
+ if (psFault !== null)
2230
+ process.exitCode = 1;
2231
+ }
2232
+ catch (e) {
2233
+ fail(e instanceof Error ? e.message : String(e));
2234
+ }
2235
+ });
1984
2236
  /* ---------------------------------------------------------------- cache ---------------------------------------------------------------- */
1985
2237
  const cache = program.command("cache").description("The per-machine Forge cache in $CRAFTAR_HOME/forges/");
1986
2238
  cache
@@ -2146,6 +2398,19 @@ function warnStderr(warnings) {
2146
2398
  for (const w of warnings)
2147
2399
  console.error(`${pc.yellow("warn")} ${w}`);
2148
2400
  }
2401
+ /** One workspace's line after a Forge write was planned before and after (spec 25 §4.2) — `forge unify` and `forge promote` print the same. */
2402
+ function impactLine(ws, r) {
2403
+ let state;
2404
+ if (r.state === "no-effect")
2405
+ state = "no effect";
2406
+ else if (r.state === "changed")
2407
+ state = r.files.length === 1 ? `1 file changes — ${r.files[0]}` : `${r.files.length} files change — ${r.files.join(", ")}`;
2408
+ else if (r.state === "missing")
2409
+ state = "missing";
2410
+ else
2411
+ state = `error: ${r.error}`;
2412
+ return `impact: ${ws.entry.path} (${ws.entry.profile}) ${state}${matchSuffix(ws)}`;
2413
+ }
2149
2414
  /**
2150
2415
  * Spec 25 §4.1–§4.2: the suffix format for a workspace that is not a path match.
2151
2416
  * Used by both `forge impact` and `forge unify` so the format is shared.