craftar 0.32.1 → 0.34.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
@@ -1,7 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import pc from "picocolors";
3
3
  import path from "node:path";
4
- import { promises as fs } from "node:fs";
4
+ import { promises as fs, statSync } from "node:fs";
5
5
  import YAML from "yaml";
6
6
  import { importClaudeCode } from "./importers/claude-code.js";
7
7
  import { parsePreferKiro } from "./importers/prefer-kiro.js";
@@ -16,7 +16,7 @@ import { launchRefusal, pinOf } from "./core/mcp-protocol.js";
16
16
  import { serve } from "./mcp.js";
17
17
  import { pruneCache } from "./core/cache.js";
18
18
  import { resolveHome } from "./core/home-lock.js";
19
- import { loadWorkspace, readWorkspaceConfig, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, agentsMdOverBudget, agentsMdRefusal, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
19
+ import { loadWorkspace, loadForgeSource, readWorkspaceConfig, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, agentsMdOverBudget, agentsMdRefusal, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
20
20
  import { sectionKey } from "./core/resolve.js";
21
21
  import { catalogueContext, listRecipes, listIngredients, checkType, withLoadWarnings } from "./core/catalogue.js";
22
22
  import { canonicalValue } from "./core/sections.js";
@@ -25,13 +25,18 @@ import { preferredLine, renderImportReport } from "./core/import-report.js";
25
25
  import { driftList, unmanaged } from "./core/drift.js";
26
26
  import { EXAMPLE_CONTENT_HIDDEN, diffReport, explainFile, explainSkip, notProduced, readText, statusPath, statusReport, targetsReport } from "./core/report.js";
27
27
  import { diffIngredients, listVariants, profileOf } from "./core/variants.js";
28
- import { exists, gitDirty, gitIgnored, gitIsRepo, gitUnheld, loadForge } from "./core/forge.js";
28
+ import { exists, gitDirty, gitIgnored, gitIsRepo, gitUnheld, loadForge, realpathOfNearest } from "./core/forge.js";
29
29
  import { applyBasePromotion, planBasePromotion, proveBasePromotion } from "./core/promote.js";
30
30
  import { fingerprintDir } from "./core/fingerprint.js";
31
31
  import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, pruneCandidates, pruneRecipes, note, } from "./core/unify.js";
32
32
  import { checkParamWrites, writeParamFile } from "./core/param-writes.js";
33
- import { checkInitFlags, initLine, planInit } from "./core/init.js";
34
- import { askInit, confirmInit, againLine } from "./core/init-flow.js";
33
+ import { checkInitFlags, checkLocalKeys, initLine, planInit, forgeSource } from "./core/init.js";
34
+ import { profileName } from "./core/new-profile.js";
35
+ import { newProfileGate } from "./core/new-profile-gate.js";
36
+ import { writeNewProfile, reproveFromDisk, NewProfileRaceLost, NewProfileLateFailure } from "./core/new-profile-write.js";
37
+ import { askInit, askClone, confirmInit, againLine, NOT_CLONED_REFUSED } from "./core/init-flow.js";
38
+ import { findGit, cloneRepos, listRepos, planClone } from "./core/clone.js";
39
+ import { byHand, profileRepos } from "./core/repos.js";
35
40
  import { readlineIo } from "./prompt.js";
36
41
  import { editRecipesText, planRecipeEdit, recipeDiffLine } from "./core/recipe-edit.js";
37
42
  import { localKeys, readLocalFile } from "./core/workspace-yaml.js";
@@ -52,7 +57,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
52
57
  */
53
58
  const DIFF_EXIT_CODE_FETCH_MODE = "sync";
54
59
  const program = new Command();
55
- program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.32.1");
60
+ program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.34.0");
56
61
  const SAVE_PLAN_GATE = { what: "plan", who: "unify", outside: "save plans outside the Forge", never: "unify never overwrites" };
57
62
  const REPORT_GATE = { what: "report", who: "import", outside: "write the report outside the Forge", never: "import never overwrites" };
58
63
  program
@@ -220,24 +225,98 @@ async function applyAndReport(ws, p, st, lock, opts = {}) {
220
225
  console.log(` ${pc.yellow("warn")} ${registryWarning}`);
221
226
  return r;
222
227
  }
228
+ /**
229
+ * Shared clone report for `craftar init --clone` and `craftar repos clone`.
230
+ * Prints the header, the present rows first, then the clone rows (cloned/failed), each group
231
+ * in the profile's order. Returns whether any clone failed.
232
+ *
233
+ * @param header The bold header line (without the leading text, which is added here)
234
+ * @param results The clone results (cloned or failed)
235
+ * @param skipped The entries that were skipped (present state)
236
+ * @param root The workspace root, used for the by-hand message
237
+ * @param opts.reposCloneLine Whether to print the "or, from the workspace: craftar repos clone ..." line
238
+ */
239
+ function printCloneReport(header, results, skipped, root, opts) {
240
+ console.log(pc.bold(header));
241
+ const rows = [];
242
+ for (const e of skipped) {
243
+ rows.push({ state: "present", name: e.name, path: e.path });
244
+ }
245
+ for (const r of results) {
246
+ if (r.outcome === "cloned") {
247
+ rows.push({ state: "cloned", name: r.entry.name, path: r.entry.path });
248
+ }
249
+ else {
250
+ rows.push({ state: "failed", name: r.entry.name, path: r.entry.path, reason: r.reason });
251
+ }
252
+ }
253
+ // Compute column widths: sw = longest state, nw = longest name
254
+ const sw = Math.max(...rows.map((r) => r.state.length));
255
+ const nw = Math.max(...rows.map((r) => r.name.length));
256
+ // Print rows
257
+ for (const r of rows) {
258
+ let line = ` ${r.state.padEnd(sw)} ${r.name.padEnd(nw)} → ${r.path}`;
259
+ if (r.state === "present") {
260
+ line += " (not cloned)";
261
+ }
262
+ else if (r.state === "failed" && r.reason) {
263
+ line += ` (${r.reason})`;
264
+ }
265
+ console.log(line);
266
+ }
267
+ // Failure report
268
+ const failed = results.filter((r) => r.outcome === "failed");
269
+ if (failed.length > 0) {
270
+ const ok = results.length - failed.length;
271
+ const n = results.length;
272
+ console.log(` ${ok} of ${n} cloned — clone the rest by hand, in ${root}:`);
273
+ for (const f of failed) {
274
+ console.log(` ${byHand(f.entry)}`);
275
+ }
276
+ if (opts.reposCloneLine) {
277
+ const failedNames = failed.map((f) => f.entry.name);
278
+ console.log(` or, from the workspace: craftar repos clone ${failedNames.join(" ")}`);
279
+ }
280
+ }
281
+ return failed.length > 0;
282
+ }
283
+ /**
284
+ * The clone step for `craftar init --clone`: clones the entries, prints the report, sets exitCode on failures.
285
+ * Calls `again()` at the end.
286
+ */
287
+ async function runCloneStep(root, profile, entries, git, again) {
288
+ const n = entries.length;
289
+ const repoWord = n === 1 ? "repository" : "repositories";
290
+ const results = await cloneRepos(root, entries, { git, env: process.env, stdinIsTty: process.stdin.isTTY === true });
291
+ const header = `craftar init — cloning ${n} ${repoWord} of ${profile}`;
292
+ const anyFailed = printCloneReport(header, results, [], root, { reposCloneLine: true });
293
+ if (anyFailed)
294
+ process.exitCode = 1;
295
+ again();
296
+ }
223
297
  /* ---------------------------------------------------------------- init */
224
298
  program
225
299
  .command("init")
226
- .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")
300
+ .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 --new-profile, create the profile in the Forge directory first (recipes and targets from --like and the recipe/target flags), then start the workspace on it — nothing is committed; with --clone, clone the profile's repositories into the workspace (after the first sync, or with --no-sync after writing craftar.yaml); in a terminal, asks for the Forge and the profile not given as flags; refused when craftar.yaml already exists")
227
301
  .option("--forge <dir|url>", "the Forge: a directory (written relative to the workspace) or a git URL; asked for in a terminal when omitted")
228
302
  .option("--profile <name>", "the client profile in the Forge; asked for in a terminal when omitted")
303
+ .option("--new-profile <name>", "create this profile in the Forge directory (recipes and targets from --like and the recipe flags), then start the workspace on it; nothing is committed")
304
+ .option("--like <profile>", "with --new-profile: start the new profile's recipes and targets from this profile's — nothing else of it is copied")
229
305
  .option("--ref <ref>", "branch, tag or full SHA of a remote Forge")
230
- .option("--targets <a,b>", "targets to write in craftar.yaml (claude-code, kiro, agents-md); omitted, the workspace follows the profile's")
231
- .option("--add-recipe <name>", "add a recipe to the profile's (repeatable)", collect, [])
232
- .option("--remove-recipe <name>", "remove a recipe of the profile's (repeatable)", collect, [])
306
+ .option("--targets <a,b>", "targets to write in craftar.yaml, or of the new profile with --new-profile (claude-code, kiro, agents-md); omitted, the workspace follows the profile's")
307
+ .option("--add-recipe <name>", "add a recipe to the profile's, or to the new profile's with --new-profile (repeatable)", collect, [])
308
+ .option("--remove-recipe <name>", "remove a recipe from the profile's, or from the new profile's with --new-profile (repeatable)", collect, [])
233
309
  .option("--replace", "let --add-recipe take a slot another recipe holds", false)
234
310
  .option("--no-sync", "write craftar.yaml only, and say what the first sync would do")
311
+ .option("--clone", "after the first sync, clone the profile's repositories (its repos list) into the workspace")
312
+ .option("--no-clone", "do not clone, and do not ask")
235
313
  .option("--offline", "use the cached copy of a remote Forge, without fetching", false)
236
314
  .option("--workspace <dir>", "workspace root, created when missing", ".")
237
315
  .action(async (o, cmd) => {
238
316
  const root = path.resolve(o.workspace);
239
317
  // N1: craftar.yaml already exists
240
- const n1 = () => fail(`${WORKSPACE_FILE} already exists in ${root} — change recipes with craftar add recipe / remove recipe, or edit it`);
318
+ const n1Text = `${WORKSPACE_FILE} already exists in ${root} — change recipes with craftar add recipe / remove recipe, or edit it`;
319
+ const n1 = () => fail(n1Text);
241
320
  const file = path.join(root, WORKSPACE_FILE);
242
321
  if (await exists(file))
243
322
  n1();
@@ -265,15 +344,42 @@ program
265
344
  addRecipes: o.addRecipe,
266
345
  removeRecipes: o.removeRecipe,
267
346
  });
347
+ // C1: --clone and --no-clone cannot be given together
348
+ if (process.argv.includes("--clone") && process.argv.includes("--no-clone")) {
349
+ fail("pass --clone or --no-clone, not both");
350
+ }
351
+ // C2: --clone with --offline
352
+ if (o.clone === true && o.offline) {
353
+ fail("--clone needs the network — drop --offline, or clone later with git");
354
+ }
355
+ // C6: --clone with no git on PATH
356
+ const git = await findGit(process.env);
357
+ if (o.clone === true && git === null) {
358
+ fail("cloning needs git — install it, or leave the clone out");
359
+ }
360
+ // P1–P4: --new-profile checks (before anything is read)
361
+ if (o.newProfile !== undefined && o.profile !== undefined) {
362
+ fail("pass --profile or --new-profile, not both");
363
+ }
364
+ if (o.like !== undefined && o.newProfile === undefined) {
365
+ fail("--like goes with --new-profile");
366
+ }
367
+ if (o.newProfile !== undefined) {
368
+ profileName(o.newProfile);
369
+ }
370
+ if (o.newProfile !== undefined && o.forge !== undefined && classifyForge(o.forge) === "url") {
371
+ fail("a new profile is written into a Forge directory — clone the Forge, pass that directory as --forge, then commit and push the profile");
372
+ }
268
373
  // Mode: flags when both given; else interactive when both streams are TTYs; else N2
269
- const bothGiven = o.forge !== undefined && o.profile !== undefined;
374
+ // --new-profile counts as --profile for bothGiven and N2
375
+ const bothGiven = o.forge !== undefined && (o.profile !== undefined || o.newProfile !== undefined);
270
376
  const isTTY = process.stdin.isTTY && process.stdout.isTTY;
271
377
  if (!bothGiven && !isTTY) {
272
378
  // N2: refuse with our message
273
- if (o.forge === undefined && o.profile === undefined) {
379
+ if (o.forge === undefined && o.profile === undefined && o.newProfile === undefined) {
274
380
  fail("--forge and --profile are required when craftar init does not run in a terminal — pass them, or run craftar init in a terminal to be asked");
275
381
  }
276
- else if (o.profile === undefined) {
382
+ else if (o.profile === undefined && o.newProfile === undefined) {
277
383
  fail("--profile is required when craftar init does not run in a terminal — pass it, or run craftar init in a terminal to be asked");
278
384
  }
279
385
  else {
@@ -289,17 +395,46 @@ program
289
395
  const result = await askInit(root, {
290
396
  forge: o.forge,
291
397
  profile: o.profile,
398
+ newProfile: o.newProfile,
399
+ like: o.like,
292
400
  ref: o.ref,
293
401
  targets: o.targets === undefined ? undefined : o.targets.split(",").map((t) => t.trim()),
294
402
  addRecipes: o.addRecipe,
295
403
  removeRecipes: o.removeRecipe,
296
404
  replace: o.replace,
405
+ clone: o.clone,
297
406
  }, local, io, { ...load(o, "sync"), registryOff: registryOff() });
298
407
  if (result.kind === "cancelled")
299
408
  fail("init cancelled — nothing written");
300
409
  input = result.input;
301
410
  answers = result.answers;
302
411
  }
412
+ else if (o.newProfile !== undefined) {
413
+ // --forge and --new-profile given (flags mode with new profile)
414
+ // N10 (spec 38 §4.2): the one check, before the load — --new-profile counts as --profile there
415
+ checkLocalKeys(local.keys, {
416
+ ref: o.ref !== undefined,
417
+ recipes: o.addRecipe.length > 0 || o.removeRecipe.length > 0,
418
+ targets: o.targets !== undefined,
419
+ });
420
+ // Load the Forge
421
+ const loaded = await loadForgeSource(root, forgeSource(root, o.forge), o.ref ?? null, { ...load(o, "sync") });
422
+ // Gate: P5-P8
423
+ await newProfileGate(loaded.forge, resolveHome(process.env.CRAFTAR_HOME || undefined), o.newProfile);
424
+ // Build input
425
+ input = {
426
+ forge: o.forge,
427
+ profile: o.newProfile,
428
+ newProfile: { name: o.newProfile, like: o.like },
429
+ ref: o.ref,
430
+ targets: o.targets === undefined ? undefined : o.targets.split(",").map((t) => t.trim()),
431
+ addRecipes: o.addRecipe,
432
+ removeRecipes: o.removeRecipe,
433
+ replace: o.replace,
434
+ clone: o.clone === true,
435
+ loaded,
436
+ };
437
+ }
303
438
  else {
304
439
  input = {
305
440
  forge: o.forge,
@@ -309,41 +444,126 @@ program
309
444
  addRecipes: o.addRecipe,
310
445
  removeRecipes: o.removeRecipe,
311
446
  replace: o.replace,
447
+ clone: o.clone === true,
312
448
  };
313
449
  }
314
- const init = await planInit(root, input, { ...load(o, "sync"), local });
450
+ let init = await planInit(root, input, { ...load(o, "sync"), local });
451
+ // Interactive mode: when o.clone is undefined, ask about cloning between planInit and confirmInit
452
+ // --clone typed: planInit has checked C3–C5 and returned the entries, in either mode
453
+ let cloneEntries = o.clone === true ? init.repos : [];
454
+ let cloneRefused = false;
315
455
  if (answers !== null) {
456
+ if (o.clone === undefined) {
457
+ const io = readlineIo(process.stdin, process.stdout, { terminal: true });
458
+ const cloneResult = await askClone(io, init, { root, offline: o.offline, git });
459
+ if (cloneResult === "cancelled")
460
+ fail("init cancelled — nothing written");
461
+ cloneEntries = cloneResult.entries;
462
+ cloneRefused = cloneResult.refused === true;
463
+ }
316
464
  const io = readlineIo(process.stdin, process.stdout, { terminal: true });
317
- const confirm = await confirmInit(init, root, io, { sync: o.sync });
465
+ const confirm = await confirmInit(init, root, io, {
466
+ sync: o.sync,
467
+ clone: cloneEntries.length || undefined,
468
+ cloneRefused: o.clone === true || cloneRefused,
469
+ });
318
470
  if (confirm === "cancelled")
319
471
  fail("init cancelled — nothing written");
320
472
  }
321
- const { ws, plan: p } = init;
473
+ let { ws, plan: p } = init;
474
+ const forgeRoot = ws.forge.root;
322
475
  const workspaceGiven = cmd.getOptionValueSource("workspace") === "cli";
323
476
  const again = () => {
324
477
  if (answers !== null) {
325
- console.log(againLine(answers, { workspace: workspaceGiven ? o.workspace : undefined, sync: o.sync, offline: o.offline }));
478
+ console.log(againLine(answers, { workspace: workspaceGiven ? o.workspace : undefined, sync: o.sync, offline: o.offline, clone: cloneEntries.length > 0 }));
326
479
  }
327
480
  };
481
+ // When init.newProfile: write the profile, then reprove from disk
482
+ let journal = [];
483
+ // Step 1's notes are printed after the initLine line (spec 38), so keep them before reprove
484
+ const step1Notes = init.notes;
485
+ if (init.newProfile) {
486
+ try {
487
+ journal = await writeNewProfile(forgeRoot, init);
488
+ }
489
+ catch (e) {
490
+ if (e instanceof NewProfileRaceLost) {
491
+ // P7's first message, no recover lines
492
+ fail(e.message);
493
+ }
494
+ if (e instanceof NewProfileLateFailure) {
495
+ fail(lateFailure(e, forgeRoot, e.journal, "init"));
496
+ }
497
+ throw e;
498
+ }
499
+ // Reprove from disk (step 4)
500
+ try {
501
+ init = await reproveFromDisk(root, input.forge, init, { ...load(o, "sync") }, journal, input.ref);
502
+ }
503
+ catch (e) {
504
+ if (e instanceof NewProfileLateFailure) {
505
+ fail(lateFailure(e, forgeRoot, e.journal, "init"));
506
+ }
507
+ throw e;
508
+ }
509
+ // Update ws and p from the second init
510
+ ws = init.ws;
511
+ p = init.plan;
512
+ // Print the profile creation lines
513
+ console.log(pc.bold(`craftar init — created profile ${ws.config.profile} in ${forgeRoot}`));
514
+ console.log(` + profiles/${ws.config.profile}/profile.yaml (not committed — commit it in the Forge, on a branch, before anyone else syncs)`);
515
+ }
328
516
  // Every refusal is above: only now does the directory, and craftar.yaml, come to exist
329
- await fs.mkdir(root, { recursive: true });
330
- try {
331
- await fs.writeFile(file, init.text, { flag: "wx" });
517
+ // When journal is not empty: mkdir inside the try, with late failure handling
518
+ if (journal.length > 0) {
519
+ try {
520
+ await fs.mkdir(root, { recursive: true });
521
+ }
522
+ catch (e) {
523
+ fail(lateFailure(e, forgeRoot, journal, "init"));
524
+ }
525
+ try {
526
+ await fs.writeFile(file, init.text, { flag: "wx" });
527
+ }
528
+ catch (e) {
529
+ if (e.code === "EEXIST") {
530
+ // N1's text, then lateFailure
531
+ fail(`${n1Text}\n${lateFailure(e, forgeRoot, journal, "init")}`);
532
+ }
533
+ fail(lateFailure(e, forgeRoot, journal, "init"));
534
+ }
332
535
  }
333
- catch (e) {
334
- if (e.code === "EEXIST")
335
- n1();
336
- throw e;
536
+ else {
537
+ // No journal: existing behaviour unchanged
538
+ await fs.mkdir(root, { recursive: true });
539
+ try {
540
+ await fs.writeFile(file, init.text, { flag: "wx" });
541
+ }
542
+ catch (e) {
543
+ if (e.code === "EEXIST")
544
+ n1();
545
+ throw e;
546
+ }
337
547
  }
338
548
  console.log(pc.bold(`craftar init — wrote ${WORKSPACE_FILE} in ${root}`));
339
549
  console.log(` ${initLine(init)}`);
340
- for (const n of init.notes)
550
+ for (const n of step1Notes)
341
551
  console.log(` note ${n}`);
342
552
  const unset = unsetDeclared(p);
553
+ const cloning = cloneEntries.length > 0;
554
+ const refused = unset.length > 0;
343
555
  if (!o.sync) {
344
556
  console.log(nextSyncLine(init.statuses, unset));
345
557
  for (const w of p.warnings)
346
558
  console.log(` ${pc.yellow("warn")} ${w}`);
559
+ if (cloning && refused && answers === null) {
560
+ console.log(NOT_CLONED_REFUSED);
561
+ }
562
+ else if (cloning && !refused) {
563
+ // Clone even without sync
564
+ await runCloneStep(root, ws.config.profile, cloneEntries, git, again);
565
+ return;
566
+ }
347
567
  again();
348
568
  return;
349
569
  }
@@ -352,6 +572,9 @@ program
352
572
  if (unset.length) {
353
573
  for (const w of p.warnings)
354
574
  console.log(` ${pc.yellow("warn")} ${w}`);
575
+ if (cloning && answers === null) {
576
+ console.log(NOT_CLONED_REFUSED);
577
+ }
355
578
  refusalBlock(unset, { thenSync: true });
356
579
  again();
357
580
  process.exitCode = 1;
@@ -361,8 +584,152 @@ program
361
584
  const collisions = r.skipped.filter((s) => s.state === "collision").length;
362
585
  if (collisions)
363
586
  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`);
587
+ if (cloning) {
588
+ await runCloneStep(root, ws.config.profile, cloneEntries, git, again);
589
+ return;
590
+ }
364
591
  again();
365
592
  });
593
+ /* ---------------------------------------------------------------- repos */
594
+ // `craftar repos` lists (the command's own action); `craftar repos clone` clones. No `repos list` (spec 37 §13.3 item 29).
595
+ const reposCmd = program
596
+ .command("repos")
597
+ .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")
598
+ .option("--workspace <dir>", "workspace root", ".")
599
+ .option("--offline", "use the cached copy of a remote Forge, without fetching", false)
600
+ .option("--json", "machine-readable output", false)
601
+ .action(async (o) => {
602
+ const ws = await loadWorkspace(o.workspace, load(o, "read"));
603
+ const p = await plan(ws);
604
+ const lock = await readLock(ws.root);
605
+ const profile = ws.forge.profiles.get(ws.config.profile);
606
+ // C4: profileRepos throws for a block that does not read
607
+ const entries = profileRepos(profile);
608
+ // No entry → one line, exit 0
609
+ if (entries.length === 0) {
610
+ if (o.json) {
611
+ const out = {
612
+ workspace: await fs.realpath(ws.root),
613
+ profile: ws.config.profile,
614
+ repos: [],
615
+ warnings: p.warnings,
616
+ };
617
+ console.log(JSON.stringify(out, null, 2));
618
+ }
619
+ else {
620
+ console.log(`profile ${ws.config.profile} lists no repositories`);
621
+ for (const w of p.warnings)
622
+ console.log(` ${pc.yellow("warn")} ${w}`);
623
+ }
624
+ return;
625
+ }
626
+ // Build the destination context: planned paths and lock paths
627
+ const ctx = {
628
+ planned: p.files.map((f) => f.path),
629
+ lockPaths: lock?.files.map((f) => f.path) ?? [],
630
+ forgeDir: ws.forge.root,
631
+ };
632
+ const list = await listRepos(ws.root, entries, ctx);
633
+ const missing = list.filter((e) => e.state === "missing").length;
634
+ if (o.json) {
635
+ const repos = list.map((e) => ({
636
+ name: e.name,
637
+ url: e.url,
638
+ path: e.path,
639
+ ref: e.ref,
640
+ state: e.state,
641
+ reason: e.reason,
642
+ }));
643
+ const out = {
644
+ workspace: await fs.realpath(ws.root),
645
+ profile: ws.config.profile,
646
+ repos,
647
+ warnings: p.warnings,
648
+ };
649
+ console.log(JSON.stringify(out, null, 2));
650
+ return;
651
+ }
652
+ // Text output
653
+ console.log(`repositories of ${ws.config.profile} — ${list.length} in the profile, ${missing} missing`);
654
+ const sw = 7; // "present" is longest, always
655
+ const nw = Math.max(...list.map((e) => e.name.length));
656
+ const pw = Math.max(...list.map((e) => e.path.length));
657
+ for (const e of list) {
658
+ let line = ` ${e.state.padEnd(sw)} ${e.name.padEnd(nw)} → ${e.path.padEnd(pw)} ${e.url}`;
659
+ if (e.ref)
660
+ line += ` @ ${e.ref}`;
661
+ if (e.reason)
662
+ line += ` (${e.reason})`;
663
+ console.log(line);
664
+ }
665
+ for (const w of p.warnings)
666
+ console.log(` ${pc.yellow("warn")} ${w}`);
667
+ });
668
+ reposCmd
669
+ .command("clone")
670
+ .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")
671
+ .argument("[names...]", "repository names to clone (default: all missing)")
672
+ // No default here: `repos` holds `--workspace` too, and commander gives the value to the parent when both declare it.
673
+ .option("--workspace <dir>", 'workspace root (default: ".")')
674
+ .action(async (names, o) => {
675
+ const workspace = o.workspace ?? reposCmd.opts().workspace ?? ".";
676
+ // C6: git is needed before any load
677
+ const git = await findGit(process.env);
678
+ if (git === null) {
679
+ fail("cloning needs git — install it, or leave the clone out");
680
+ }
681
+ // R4: fetchHint: "fresh-copy" — repos clone refuses a stale cached copy
682
+ const ws = await loadWorkspace(workspace, { ...load({ offline: false }, "sync"), fetchHint: "fresh-copy" });
683
+ const p = await plan(ws);
684
+ const lock = await readLock(ws.root);
685
+ const profile = ws.forge.profiles.get(ws.config.profile);
686
+ // Build the destination context
687
+ const ctx = {
688
+ planned: p.files.map((f) => f.path),
689
+ lockPaths: lock?.files.map((f) => f.path) ?? [],
690
+ forgeDir: ws.forge.root,
691
+ };
692
+ // planClone: C3, C4, C5, R1, R2, R3 come out of it
693
+ const clonePlan = await planClone(ws.root, profile, ctx, {
694
+ present: "skip",
695
+ noEntries: "nothing to clone",
696
+ names: names.length ? names : undefined,
697
+ });
698
+ // Nothing to clone and no names given: all are present
699
+ if (clonePlan.entries.length === 0) {
700
+ const all = profileRepos(profile);
701
+ const n = all.length;
702
+ const repoWord = n === 1 ? "repository" : "repositories";
703
+ console.log(`all ${n} ${repoWord} of ${ws.config.profile} are present — nothing to clone`);
704
+ return;
705
+ }
706
+ // Clone the entries
707
+ const results = await cloneRepos(ws.root, clonePlan.entries, { git, env: process.env, stdinIsTty: process.stdin.isTTY === true });
708
+ // Combine skipped and results in profile order for the report
709
+ // skipped entries come from planClone, results are in the same order as entries
710
+ // To merge them in profile order: iterate through all entries and match
711
+ const allEntries = profileRepos(profile);
712
+ const skippedNames = new Set(clonePlan.skipped.map((e) => e.name.toLowerCase()));
713
+ const resultMap = new Map(results.map((r) => [r.entry.name.toLowerCase(), r]));
714
+ const orderedSkipped = [];
715
+ const orderedResults = [];
716
+ for (const e of allEntries) {
717
+ const lower = e.name.toLowerCase();
718
+ if (skippedNames.has(lower)) {
719
+ orderedSkipped.push(clonePlan.skipped.find((s) => s.name.toLowerCase() === lower));
720
+ }
721
+ else if (resultMap.has(lower)) {
722
+ orderedResults.push(resultMap.get(lower));
723
+ }
724
+ }
725
+ // Report
726
+ const n = clonePlan.entries.length;
727
+ const repoWord = n === 1 ? "repository" : "repositories";
728
+ const header = `craftar repos clone — cloning ${n} ${repoWord} of ${ws.config.profile}`;
729
+ const anyFailed = printCloneReport(header, orderedResults, orderedSkipped, ws.root, { reposCloneLine: false });
730
+ if (anyFailed)
731
+ process.exitCode = 1;
732
+ });
366
733
  /* ---------------------------------------------------------------- sync */
367
734
  program
368
735
  .command("sync")
@@ -2691,54 +3058,6 @@ function color(state) {
2691
3058
  return pc.dim;
2692
3059
  }
2693
3060
  }
2694
- /**
2695
- * The real path of `abs`, resolved through its nearest existing ancestor: `fs.realpath` needs the
2696
- * path to exist, and a plan target usually does not yet — so the part that exists is resolved
2697
- * (symlinks and all) and the part that does not is appended as written.
2698
- *
2699
- * It walks up only past a component that truly does not exist (`lstat` says ENOENT/ENOTDIR). Any
2700
- * other outcome throws instead. A component `lstat` cannot inspect (a symlink loop or a permission
2701
- * error on Linux) "cannot be inspected"; one it can inspect but `realpath` cannot follow (a
2702
- * dangling symlink or junction, a loop on Windows) "exists but cannot be resolved". Resolving the
2703
- * rest lexically would let a link that points into the Forge pass the containment check, so the gate
2704
- * fails closed rather than guess where the target lands.
2705
- */
2706
- async function realpathOfNearest(abs) {
2707
- let head = abs;
2708
- const tail = [];
2709
- for (;;) {
2710
- try {
2711
- return path.join(await fs.realpath(head), ...tail);
2712
- }
2713
- catch (realpathError) {
2714
- try {
2715
- await fs.lstat(head);
2716
- }
2717
- catch (lstatError) {
2718
- const code = lstatError.code;
2719
- if (code === "ENOENT" || code === "ENOTDIR") {
2720
- const parent = path.dirname(head);
2721
- if (parent === head)
2722
- return abs;
2723
- tail.unshift(path.basename(head));
2724
- head = parent;
2725
- continue;
2726
- }
2727
- throw cannotResolve(head, lstatError, "cannot be inspected");
2728
- }
2729
- throw cannotResolve(head, realpathError, "exists but cannot be resolved");
2730
- }
2731
- }
2732
- }
2733
- /**
2734
- * The refusal for a target that cannot be resolved; the caller adds which command cannot prove it. `what` is
2735
- * "cannot be inspected" when `lstat` itself failed, so the path is not known to exist, and "exists but cannot be
2736
- * resolved" when `lstat` succeeded and `realpath` did not.
2737
- */
2738
- function cannotResolve(p, e, what) {
2739
- const code = e?.code ?? (e instanceof Error ? e.message : String(e));
2740
- return new Error(`${p} ${what} (${code})`);
2741
- }
2742
3061
  /**
2743
3062
  * The gate of a user-supplied output path (Ruling 30 of spec 06; spec 29 §4.2): the file a command writes where the
2744
3063
  * user says must lie outside the Forge and must not exist. Both sides are compared as real paths, so neither a `..`
@@ -2788,6 +3107,7 @@ function unheldMessage(command, root, unheld) {
2788
3107
  * The message for a `forge unify` failure after writing began (Ruling 33): the original error, the
2789
3108
  * Forge-relative paths already touched, and the git commands that undo them — `checkout` for what
2790
3109
  * git tracks, `clean` for files unify created (checkout refuses a path git does not know).
3110
+ * For created paths, directories get `clean -fd`, files (or missing paths) get `clean -f`.
2791
3111
  */
2792
3112
  function lateFailure(e, root, journal, command = "unify") {
2793
3113
  const msg = errorText(e);
@@ -2796,15 +3116,35 @@ function lateFailure(e, root, journal, command = "unify") {
2796
3116
  const rel = (abs) => path.relative(root, abs).split(path.sep).join("/");
2797
3117
  const quote = (p) => (/^[\w./-]+$/.test(p) ? p : `"${p}"`);
2798
3118
  const restore = [...new Set(journal.filter((j) => !j.created).map((j) => rel(j.abs)))];
2799
- const created = [...new Set(journal.filter((j) => j.created).map((j) => rel(j.abs)))];
3119
+ const created = [...new Set(journal.filter((j) => j.created).map((j) => j.abs))];
3120
+ // For created paths, check if they are directories
3121
+ const createdDirs = [];
3122
+ const createdFiles = [];
3123
+ for (const abs of created) {
3124
+ try {
3125
+ const st = statSync(abs);
3126
+ if (st.isDirectory()) {
3127
+ createdDirs.push(rel(abs));
3128
+ }
3129
+ else {
3130
+ createdFiles.push(rel(abs));
3131
+ }
3132
+ }
3133
+ catch {
3134
+ // Path doesn't exist, treat as file
3135
+ createdFiles.push(rel(abs));
3136
+ }
3137
+ }
2800
3138
  const lines = [msg, `${command} had already started changing the Forge (${root}) when this failed:`];
2801
- for (const p of [...restore, ...created])
3139
+ for (const p of [...restore, ...createdDirs, ...createdFiles])
2802
3140
  lines.push(` ${p}`);
2803
3141
  lines.push("recover with:");
2804
3142
  if (restore.length)
2805
3143
  lines.push(` git -C ${quote(root)} checkout -- ${restore.map(quote).join(" ")}`);
2806
- if (created.length)
2807
- lines.push(` git -C ${quote(root)} clean -f -- ${created.map(quote).join(" ")}`);
3144
+ if (createdDirs.length)
3145
+ lines.push(` git -C ${quote(root)} clean -fd -- ${createdDirs.map(quote).join(" ")}`);
3146
+ if (createdFiles.length)
3147
+ lines.push(` git -C ${quote(root)} clean -f -- ${createdFiles.map(quote).join(" ")}`);
2808
3148
  return lines.join("\n");
2809
3149
  }
2810
3150
  /** ` [1 evolution · 2 block]` in the fixed class order, zero counts omitted; empty for a variant without hunks (spec 08 §4.2). */