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.
@@ -1,13 +1,24 @@
1
1
  import { existsSync } from "node:fs";
2
- import { TARGETS } from "../schema/index.js";
2
+ import { ProfileSchema, TARGETS } from "../schema/index.js";
3
+ import { planClone } from "./clone.js";
3
4
  import { countStates } from "./impact.js";
4
5
  import { resolveHome } from "./home-lock.js";
5
6
  import { checkInitFlags, checkLocalKeys, forgeSource, initLine } from "./init.js";
7
+ import { ownedBy, ownedRecipeRefusal, profileName, withProfile } from "./new-profile.js";
8
+ import { newProfileGate } from "./new-profile-gate.js";
6
9
  import { planRecipeEdit, slotHeld } from "./recipe-edit.js";
7
10
  import { readRegistry } from "./registry.js";
8
11
  import { classifyForge, credentialFault, CREDENTIAL_HIDDEN, refHoldsCredentials } from "./remote.js";
9
12
  import { profileNotFoundMessage, resolve } from "./resolve.js";
13
+ import { profileRepos } from "./repos.js";
10
14
  import { loadForgeSource, mergeWorkspaceConfig, unsetDeclared, unsetRefused } from "./sync.js";
15
+ /**
16
+ * The interactive `init` flow (spec 28 §4.2): prompts injected through `InitIo`,
17
+ * so the flow can be tested with scripted answers. This module never touches `process`,
18
+ * `console` or `readline`, and writes nothing.
19
+ */
20
+ /** The message printed when cloning is skipped because the first sync is refused. */
21
+ export const NOT_CLONED_REFUSED = " repositories: not cloned — the first sync is refused";
11
22
  /**
12
23
  * The Forge last used on this machine: the entry with the most recent `lastSync`
13
24
  * whose Forge can be offered — a path entry by its `key` (skipped when `null` or gone),
@@ -47,13 +58,101 @@ export function yesNo(answer) {
47
58
  return false;
48
59
  return null;
49
60
  }
61
+ /**
62
+ * The clone question of the interactive flow (spec 37 §4.3): lists the profile's repositories,
63
+ * asks whether to clone them, re-asks on errors. Returns the decision and the entries to clone.
64
+ *
65
+ * @param io The prompt interface.
66
+ * @param init The init plan.
67
+ * @param opts.root Absolute path of the workspace.
68
+ * @param opts.offline Whether --offline was given (no list, no question).
69
+ * @param opts.git The git binary path, or null if not found.
70
+ * @returns `{clone: false, entries: []}` when nothing to clone or the user said no;
71
+ * `{clone: true, entries}` when the user said yes and planClone succeeded;
72
+ * `{clone: false, entries: [], refused: true}` when the first sync is refused (unset params);
73
+ * `"cancelled"` when the user cancelled.
74
+ */
75
+ export async function askClone(io, init, opts) {
76
+ // --offline → no list, no question
77
+ if (opts.offline) {
78
+ return { clone: false, entries: [] };
79
+ }
80
+ // profileRepos throws → say one line, no question
81
+ const profile = init.ws.forge.profiles.get(init.ws.config.profile);
82
+ let allEntries;
83
+ try {
84
+ allEntries = profileRepos(profile);
85
+ }
86
+ catch (e) {
87
+ io.say(`repositories: not offered — ${e.message}`);
88
+ return { clone: false, entries: [] };
89
+ }
90
+ // no entry → no list, no question
91
+ if (allEntries.length === 0) {
92
+ return { clone: false, entries: [] };
93
+ }
94
+ // a plan whose first sync would be refused → no list, no question, refused flag
95
+ const unset = unsetDeclared(init.plan);
96
+ if (unset.length > 0) {
97
+ return { clone: false, entries: [], refused: true };
98
+ }
99
+ // say the list
100
+ const maxName = Math.max(...allEntries.map((e) => e.name.length));
101
+ const maxUrl = Math.max(...allEntries.map((e) => e.url.length));
102
+ io.say(`Repositories of ${profile.name}:`);
103
+ for (let i = 0; i < allEntries.length; i++) {
104
+ const e = allEntries[i];
105
+ const ref = e.ref !== null ? ` @ ${e.ref}` : "";
106
+ io.say(` ${i + 1}) ${e.name.padEnd(maxName)} ${e.url.padEnd(maxUrl)} → ${e.path}${ref}`);
107
+ }
108
+ // loop on confirm
109
+ for (;;) {
110
+ const answer = await io.confirm("Clone them into the workspace [no]: ");
111
+ if (answer === null)
112
+ return "cancelled";
113
+ if (answer === "")
114
+ return { clone: false, entries: [] };
115
+ const yn = yesNo(answer);
116
+ if (yn === false)
117
+ return { clone: false, entries: [] };
118
+ if (yn === null) {
119
+ io.say("answer yes or no");
120
+ continue;
121
+ }
122
+ // yes → check git
123
+ if (opts.git === null) {
124
+ io.say("cloning needs git — install it, or leave the clone out");
125
+ continue;
126
+ }
127
+ // yes → planClone
128
+ try {
129
+ const ctx = { planned: init.plan.files.map((f) => f.path), lockPaths: init.lock?.files.map((f) => f.path) ?? [], forgeDir: init.ws.forge.root };
130
+ const clonePlan = await planClone(opts.root, profile, ctx, { present: "refuse", noEntries: "nothing to clone" });
131
+ return { clone: true, entries: clonePlan.entries };
132
+ }
133
+ catch (e) {
134
+ io.say(e.message);
135
+ continue;
136
+ }
137
+ }
138
+ }
50
139
  /**
51
140
  * Says the summary and the `first sync:` line, asks through `io.confirm`,
52
141
  * re-asks anything but yes/no, returns confirmed or cancelled. A plan whose sync
53
142
  * is refused (spec 29 §4.1) says so before the question, which then offers no sync.
54
143
  */
55
144
  export async function confirmInit(init, root, io, opts) {
56
- io.say(`craftar init — about to write craftar.yaml in ${root}`);
145
+ // Spec 38 §4.3: blockNote first when there is one
146
+ if (init.newProfile?.blockNote) {
147
+ io.say(` note ${init.newProfile.blockNote}`);
148
+ }
149
+ // Spec 38 §4.3: the first line changes with newProfile
150
+ if (init.newProfile) {
151
+ io.say(`craftar init — about to create profile ${init.ws.config.profile} in ${init.ws.forge.root} and write craftar.yaml in ${root}`);
152
+ }
153
+ else {
154
+ io.say(`craftar init — about to write craftar.yaml in ${root}`);
155
+ }
57
156
  io.say(` ${initLine(init)}`);
58
157
  const unset = unsetDeclared(init.plan);
59
158
  const counts = countStates(init.statuses);
@@ -67,9 +166,42 @@ export async function confirmInit(init, root, io, opts) {
67
166
  const parts = counts.map(([k, n]) => `${n} ${k}`);
68
167
  io.say(` first sync: ${parts.join(", ")}`);
69
168
  }
70
- const question = opts.sync && unset.length === 0
71
- ? "Write craftar.yaml and sync [yes]: "
72
- : "Write craftar.yaml [yes]: ";
169
+ // When the sync is refused and cloneRefused, say the not-cloned line
170
+ if (unset.length > 0 && opts.cloneRefused) {
171
+ io.say(NOT_CLONED_REFUSED);
172
+ }
173
+ // Build the question: spec 38 §4.3 — with newProfile, the question names both writes
174
+ let question;
175
+ const clone = opts.clone ?? 0;
176
+ const profileName = init.newProfile ? init.ws.config.profile : null;
177
+ if (init.newProfile) {
178
+ // New profile: different question forms
179
+ if (unset.length > 0 || !opts.sync) {
180
+ question = `Create profiles/${profileName}/profile.yaml (not committed) and write craftar.yaml [yes]: `;
181
+ }
182
+ else {
183
+ question = `Create profiles/${profileName}/profile.yaml (not committed), write craftar.yaml and sync [yes]: `;
184
+ }
185
+ }
186
+ else if (unset.length > 0) {
187
+ // Sync refused → offer only craftar.yaml
188
+ question = "Write craftar.yaml [yes]: ";
189
+ }
190
+ else if (clone > 0) {
191
+ const repoWord = clone === 1 ? "repository" : "repositories";
192
+ if (opts.sync) {
193
+ question = `Write craftar.yaml, sync and clone ${clone} ${repoWord} [yes]: `;
194
+ }
195
+ else {
196
+ question = `Write craftar.yaml and clone ${clone} ${repoWord} [yes]: `;
197
+ }
198
+ }
199
+ else if (opts.sync) {
200
+ question = "Write craftar.yaml and sync [yes]: ";
201
+ }
202
+ else {
203
+ question = "Write craftar.yaml [yes]: ";
204
+ }
73
205
  for (;;) {
74
206
  const answer = await io.confirm(question);
75
207
  if (answer === null)
@@ -119,6 +251,8 @@ export function againLine(answers, given) {
119
251
  parts.push("--no-sync");
120
252
  if (given.offline)
121
253
  parts.push("--offline");
254
+ if (given.clone === true)
255
+ parts.push("--clone");
122
256
  return parts.join(" ");
123
257
  }
124
258
  /**
@@ -212,6 +346,11 @@ export async function askInit(root, given, local, io, opts) {
212
346
  continue forgeLoop;
213
347
  }
214
348
  }
349
+ // P4 check: with --new-profile typed and Forge asked, a URL answer is refused — say P4 and ask again
350
+ if (given.newProfile !== undefined && classifyForge(forgeValue) === "url") {
351
+ io.say("a new profile is written into a Forge directory — clone the Forge, pass that directory as --forge, then commit and push the profile");
352
+ continue forgeLoop;
353
+ }
215
354
  forgeDefault = forgeValue;
216
355
  }
217
356
  answeredForge = forgeValue;
@@ -281,19 +420,45 @@ export async function askInit(root, given, local, io, opts) {
281
420
  throw e;
282
421
  }
283
422
  }
423
+ // P5/P6 check: with --new-profile typed and Forge asked, check if the Forge can take a new profile
424
+ if (given.newProfile !== undefined && forgeWasAsked) {
425
+ try {
426
+ await newProfileGate(loaded.forge, home);
427
+ }
428
+ catch (e) {
429
+ io.say(e.message);
430
+ forgeDefault = forgeValue;
431
+ refDefault = answeredRef ?? refDefault;
432
+ continue forgeLoop;
433
+ }
434
+ }
435
+ // P5/P6 check: with no profile given and no profiles in the Forge, we'll be forced to create a new profile
436
+ // If the Forge was asked and it can't take a new profile, say the error and ask again
437
+ if (given.profile === undefined && given.newProfile === undefined && loaded.forge.profiles.size === 0 && forgeWasAsked) {
438
+ if (classifyForge(forgeValue) !== "url") {
439
+ try {
440
+ await newProfileGate(loaded.forge, home);
441
+ }
442
+ catch (e) {
443
+ io.say(e.message);
444
+ forgeDefault = forgeValue;
445
+ refDefault = answeredRef ?? refDefault;
446
+ continue forgeLoop;
447
+ }
448
+ }
449
+ }
284
450
  break forgeLoop;
285
451
  }
286
452
  const forge = loaded.forge;
287
- if (forge.profiles.size === 0 && given.profile === undefined) {
288
- throw new Error("the Forge has no profile — add one under profiles/, then re-run init");
289
- }
290
- if (given.profile !== undefined) {
291
- if (!forge.profiles.has(given.profile)) {
292
- throw new Error(profileNotFoundMessage(given.profile, [...forge.profiles.keys()]));
293
- }
294
- answeredProfile = given.profile;
295
- }
296
- else {
453
+ // Track whether we're in the new-profile flow
454
+ let isNewProfile = false;
455
+ let newProfileName;
456
+ let newProfileLike;
457
+ let profileListShown = false;
458
+ // Helper to show the profile list
459
+ const showProfileList = () => {
460
+ if (profileListShown)
461
+ return;
297
462
  const profiles = [...forge.profiles.values()].sort((a, b) => a.name.localeCompare(b.name));
298
463
  io.say(`Profiles in ${answeredForge}:`);
299
464
  const maxLen = Math.max(...profiles.map((p) => p.name.length));
@@ -307,6 +472,68 @@ export async function askInit(root, given, local, io, opts) {
307
472
  io.say(` ${i + 1}) ${p.name}`);
308
473
  }
309
474
  }
475
+ profileListShown = true;
476
+ };
477
+ // Check if the new profile entry should be shown: Forge is a directory that passes P5/P6
478
+ let canOfferNewProfile = false;
479
+ let newProfileGateError = null;
480
+ if (classifyForge(answeredForge) !== "url") {
481
+ try {
482
+ await newProfileGate(forge, home);
483
+ canOfferNewProfile = true;
484
+ }
485
+ catch (e) {
486
+ newProfileGateError = e.message;
487
+ }
488
+ }
489
+ // Handle given.newProfile: validate with P3 and P7/P8 (typed value is not re-asked)
490
+ if (given.newProfile !== undefined) {
491
+ profileName(given.newProfile); // P3: throws on failure
492
+ // P9 up front: a typed --like the Forge does not have is refused before any question
493
+ if (given.like !== undefined && !forge.profiles.has(given.like)) {
494
+ throw new Error("--like: " + profileNotFoundMessage(given.like, [...forge.profiles.keys()]));
495
+ }
496
+ await newProfileGate(forge, home, given.newProfile); // P7/P8: throws on failure
497
+ isNewProfile = true;
498
+ newProfileName = given.newProfile;
499
+ newProfileLike = given.like;
500
+ answeredProfile = given.newProfile;
501
+ }
502
+ else if (given.profile !== undefined) {
503
+ if (!forge.profiles.has(given.profile)) {
504
+ throw new Error(profileNotFoundMessage(given.profile, [...forge.profiles.keys()]));
505
+ }
506
+ answeredProfile = given.profile;
507
+ }
508
+ else if (forge.profiles.size === 0) {
509
+ // Spec 38 §4.3: A Forge directory with no profile goes straight to the new-profile questions
510
+ // (A URL Forge with no profile keeps today's error)
511
+ if (classifyForge(answeredForge) === "url") {
512
+ throw new Error("the Forge has no profile — add one under profiles/, then re-run init");
513
+ }
514
+ // For directory Forge with no profile: check if it can take a new profile
515
+ if (!canOfferNewProfile) {
516
+ throw new Error(newProfileGateError);
517
+ }
518
+ io.say("the Forge has no profile yet — creating the first one");
519
+ isNewProfile = true;
520
+ }
521
+ else {
522
+ // Show the profile list with the new profile entry if applicable
523
+ const profiles = [...forge.profiles.values()].sort((a, b) => a.name.localeCompare(b.name));
524
+ showProfileList();
525
+ // Show the 0) a new profile entry ONLY when the Forge can take a new profile
526
+ // Per spec 38 §4.3: show lines explaining why the entry isn't available
527
+ if (canOfferNewProfile) {
528
+ io.say(" 0) a new profile");
529
+ }
530
+ else if (classifyForge(answeredForge) === "url") {
531
+ io.say("a new profile needs a Forge directory");
532
+ }
533
+ else if (newProfileGateError) {
534
+ // Forge directory that fails P5/P6: show the error as the line under the list
535
+ io.say(newProfileGateError);
536
+ }
310
537
  const defProfile = profiles.length === 1 ? profiles[0].name : null;
311
538
  const profileQ = defProfile !== null ? `Profile [${defProfile}]: ` : "Profile: ";
312
539
  profileLoop: for (;;) {
@@ -322,14 +549,342 @@ export async function askInit(root, given, local, io, opts) {
322
549
  answeredProfile = defProfile;
323
550
  break profileLoop;
324
551
  }
552
+ // pickItem: names first, then (only when the entry is shown) "0" for new profile, then numbers
325
553
  const picked = pickItem(profiles.map((p) => p.name), trimmed);
326
554
  if (picked !== null) {
327
555
  answeredProfile = picked;
328
556
  break profileLoop;
329
557
  }
558
+ // Check for "0" to start new-profile questions (only when the entry is shown)
559
+ if (canOfferNewProfile && trimmed === "0") {
560
+ isNewProfile = true;
561
+ break profileLoop;
562
+ }
330
563
  io.say(profileNotFoundMessage(trimmed, [...forge.profiles.keys()]));
331
564
  }
332
565
  }
566
+ // New-profile questions (§4.3)
567
+ if (isNewProfile) {
568
+ // Name question (skipped when given.newProfile was typed)
569
+ if (newProfileName === undefined) {
570
+ nameLoop: for (;;) {
571
+ const answer = await io.ask("New profile's name: ");
572
+ if (answer === null)
573
+ return { kind: "cancelled" };
574
+ const trimmed = answer.trim();
575
+ if (trimmed === "") {
576
+ io.say('a name is needed — lowercase letters and digits, in groups joined by one "-"');
577
+ continue nameLoop;
578
+ }
579
+ try {
580
+ profileName(trimmed); // P3
581
+ await newProfileGate(forge, home, trimmed); // P7, P8
582
+ newProfileName = trimmed;
583
+ break nameLoop;
584
+ }
585
+ catch (e) {
586
+ io.say(e.message);
587
+ continue nameLoop;
588
+ }
589
+ }
590
+ }
591
+ answeredProfile = newProfileName;
592
+ // Template question: "Start from an existing profile [none]:" — only when the Forge has profiles
593
+ // and when given.like was not provided
594
+ if (newProfileLike === undefined && forge.profiles.size > 0) {
595
+ // Print the profile list if it wasn't printed yet (e.g. --new-profile was typed, --forge asked)
596
+ if (!profileListShown) {
597
+ showProfileList();
598
+ }
599
+ templateLoop: for (;;) {
600
+ const answer = await io.ask("Start from an existing profile [none]: ");
601
+ if (answer === null)
602
+ return { kind: "cancelled" };
603
+ const trimmed = answer.trim();
604
+ if (trimmed === "" || trimmed === "0") {
605
+ // Enter or 0 → none
606
+ break templateLoop;
607
+ }
608
+ const profiles = [...forge.profiles.values()].sort((a, b) => a.name.localeCompare(b.name));
609
+ const picked = pickItem(profiles.map((p) => p.name), trimmed);
610
+ if (picked !== null) {
611
+ newProfileLike = picked;
612
+ break templateLoop;
613
+ }
614
+ // Unknown → say "--like: " + profileNotFoundMessage
615
+ io.say("--like: " + profileNotFoundMessage(trimmed, [...forge.profiles.keys()]));
616
+ continue templateLoop;
617
+ }
618
+ }
619
+ // Recipes question for new profile
620
+ // With a template: show "Recipes of <name> (from <like>): <chain>" then Adjust flow
621
+ // Without a template: show recipes list and ask "Add [none]:" directly
622
+ let finalAddRecipes = [];
623
+ let finalRemoveRecipes = [];
624
+ let finalReplace = false;
625
+ // Typed flags answer their questions (spec 38 §4.2, §4.3)
626
+ const recipesFlagged = given.addRecipes.length > 0 || given.removeRecipes.length > 0;
627
+ if (recipesFlagged) {
628
+ // Use the flags directly, skip all recipe questions
629
+ finalAddRecipes = given.addRecipes;
630
+ finalRemoveRecipes = given.removeRecipes;
631
+ finalReplace = given.replace;
632
+ }
633
+ else if (local.keys.includes("recipes")) {
634
+ // local.keys includes recipes and no recipe flag typed: no recipes question; say the chain from the draft resolved with local recipes
635
+ const templateRecipes = newProfileLike ? forge.profiles.get(newProfileLike).recipes : [];
636
+ const draftProfile = ProfileSchema.parse({ name: newProfileName, recipes: templateRecipes, targets: [] });
637
+ const draftCopy = withProfile(forge, draftProfile);
638
+ const recipeConfig = { ...localConfig, profile: newProfileName };
639
+ const resolution = resolve(draftCopy, recipeConfig);
640
+ io.say(`Recipes: ${resolution.recipes.join(" → ")} (from craftar.local.yaml)`);
641
+ }
642
+ else {
643
+ // Track whether we should start with Remove (after a P10 error)
644
+ let startWithRemove = false;
645
+ recipeLoop: for (;;) {
646
+ // Compute the draft profile in a copy of the Forge for the recipes line
647
+ const template = newProfileLike ? forge.profiles.get(newProfileLike) : undefined;
648
+ const draftRecipes = template?.recipes ?? [];
649
+ if (template && !startWithRemove) {
650
+ // With a template: show the chain resolved from the draft profile
651
+ const draft = ProfileSchema.parse({ name: newProfileName, recipes: draftRecipes, targets: template.targets });
652
+ const copy = withProfile(forge, draft);
653
+ // Use mergeWorkspaceConfig to get a valid config with overrides
654
+ const config = mergeWorkspaceConfig({ forge: forgeSource(root, answeredForge), profile: newProfileName }, null).config;
655
+ const resolution = resolve(copy, config);
656
+ io.say(`Recipes of ${newProfileName} (from ${newProfileLike}): ${resolution.recipes.join(" → ")}`);
657
+ // Adjust the recipes [no]:
658
+ const adjustAns = await io.ask("Adjust the recipes [no]: ");
659
+ if (adjustAns === null)
660
+ return { kind: "cancelled" };
661
+ const yn = yesNo(adjustAns);
662
+ if (adjustAns.trim() !== "" && yn === null) {
663
+ io.say("answer yes or no");
664
+ continue recipeLoop;
665
+ }
666
+ if (yn !== true) {
667
+ // No adjustment: check P10 on the template's recipes before accepting, using ownedBy
668
+ const templateRecipes = template.recipes;
669
+ for (const r of templateRecipes) {
670
+ const owner = ownedBy(forge, r);
671
+ if (owner !== null) {
672
+ io.say(ownedRecipeRefusal(r, owner));
673
+ startWithRemove = true;
674
+ continue recipeLoop;
675
+ }
676
+ }
677
+ // Success: use template's recipes as-is
678
+ break recipeLoop;
679
+ }
680
+ }
681
+ // Show the Forge's recipes list
682
+ io.say(`Recipes in ${answeredForge}:`);
683
+ const allRecipes = [...forge.recipes.values()].sort((a, b) => a.name.localeCompare(b.name));
684
+ for (let i = 0; i < allRecipes.length; i++) {
685
+ const r = allRecipes[i];
686
+ const parts = [` ${i + 1}) ${r.name}`];
687
+ if (draftRecipes.includes(r.name))
688
+ parts.push("· in use");
689
+ if (r.slot)
690
+ parts.push(`· slot ${r.slot}`);
691
+ if (r.description)
692
+ parts.push(`· ${r.description}`);
693
+ io.say(parts.join(" "));
694
+ }
695
+ const names = allRecipes.map((r) => r.name);
696
+ const parseRecipeAnswer = (answer) => answer.split(",").map((s) => s.trim()).filter((s) => s !== "").map((s) => pickItem(names, s) ?? s);
697
+ // Remove question (only with a template, or after P10 error)
698
+ let removeNames = [];
699
+ if (template || startWithRemove) {
700
+ const removeAns = await io.ask("Remove [none]: ");
701
+ if (removeAns === null)
702
+ return { kind: "cancelled" };
703
+ removeNames = parseRecipeAnswer(removeAns);
704
+ }
705
+ // Add question
706
+ const addAns = await io.ask("Add [none]: ");
707
+ if (addAns === null)
708
+ return { kind: "cancelled" };
709
+ let addNames = parseRecipeAnswer(addAns);
710
+ // Reset startWithRemove for the next iteration
711
+ startWithRemove = false;
712
+ if (removeNames.length === 0 && addNames.length === 0) {
713
+ // No changes entered: check P10 on the final list (same as template's recipes)
714
+ if (template) {
715
+ for (const r of template.recipes) {
716
+ const owner = ownedBy(forge, r);
717
+ if (owner !== null) {
718
+ io.say(ownedRecipeRefusal(r, owner));
719
+ startWithRemove = true;
720
+ continue recipeLoop;
721
+ }
722
+ }
723
+ }
724
+ // P10 passed or no template
725
+ break recipeLoop;
726
+ }
727
+ // N6 check: a name both added and removed — say and re-ask
728
+ const both = addNames.find((n) => removeNames.includes(n));
729
+ if (both !== undefined) {
730
+ try {
731
+ checkInitFlags({ addRecipes: addNames, removeRecipes: removeNames });
732
+ }
733
+ catch (e) {
734
+ io.say(e.message);
735
+ continue recipeLoop;
736
+ }
737
+ }
738
+ // Compute the recipe edit for notes and P10 check (mirroring existing flow)
739
+ // We do removes first, then adds — as init.ts and the existing flow do
740
+ const editConfig = mergeWorkspaceConfig({ forge: forgeSource(root, answeredForge), profile: newProfileName }, null).config;
741
+ let tempConfig = { ...editConfig, recipes: { add: [], remove: [] } };
742
+ // Simulate the edits on the template to get notes
743
+ const templateRecipes = newProfileLike ? forge.profiles.get(newProfileLike).recipes : [];
744
+ const draftProfile = ProfileSchema.parse({ name: newProfileName, recipes: templateRecipes, targets: [] });
745
+ const draftCopy = withProfile(forge, draftProfile);
746
+ // The remove edit and add loop inside one outer try: any refusal → say the message and continue recipeLoop
747
+ try {
748
+ // Process removes, say notes
749
+ if (removeNames.length > 0) {
750
+ const removeEdit = planRecipeEdit(draftCopy, tempConfig, "remove", removeNames);
751
+ tempConfig = { ...tempConfig, recipes: removeEdit.recipes };
752
+ if (removeEdit.reasons.length > 0) {
753
+ io.say(` note ${removeEdit.reasons.join(", ")}`);
754
+ }
755
+ }
756
+ // Process adds, say notes, handle slot held
757
+ let doReplace = false;
758
+ addSlotLoop: for (;;) {
759
+ try {
760
+ const addEdit = planRecipeEdit(draftCopy, tempConfig, "add", addNames, { replace: doReplace });
761
+ tempConfig = { ...tempConfig, recipes: addEdit.recipes };
762
+ if (addEdit.reasons.length > 0) {
763
+ io.say(` note ${addEdit.reasons.join(", ")}`);
764
+ }
765
+ break addSlotLoop;
766
+ }
767
+ catch (e) {
768
+ const held = slotHeld(e);
769
+ if (held !== null && !doReplace) {
770
+ const replaceQ = `${held.recipe} takes the slot "${held.slot}" that ${held.holder} holds — replace ${held.holder} [no]: `;
771
+ let replaceYn;
772
+ for (;;) {
773
+ const replaceAns = await io.ask(replaceQ);
774
+ if (replaceAns === null)
775
+ return { kind: "cancelled" };
776
+ replaceYn = replaceAns.trim() === "" ? false : yesNo(replaceAns);
777
+ if (replaceYn !== null)
778
+ break;
779
+ io.say("answer yes or no");
780
+ }
781
+ if (replaceYn) {
782
+ doReplace = true;
783
+ continue addSlotLoop;
784
+ }
785
+ // User said no to replace, re-ask recipes
786
+ continue recipeLoop;
787
+ }
788
+ // Other errors (unknown recipe, etc.): re-throw to outer try
789
+ throw e;
790
+ }
791
+ }
792
+ // Compute the final list: template's recipes minus removes, plus adds
793
+ const finalList = [
794
+ ...templateRecipes.filter((r) => !tempConfig.recipes.remove.includes(r)),
795
+ ...tempConfig.recipes.add,
796
+ ];
797
+ // P10 check on the final list
798
+ for (const r of finalList) {
799
+ const owner = ownedBy(forge, r);
800
+ if (owner !== null) {
801
+ io.say(ownedRecipeRefusal(r, owner));
802
+ startWithRemove = true;
803
+ continue recipeLoop;
804
+ }
805
+ }
806
+ // Success
807
+ finalAddRecipes = addNames;
808
+ finalRemoveRecipes = removeNames;
809
+ finalReplace = doReplace;
810
+ break recipeLoop;
811
+ }
812
+ catch (e) {
813
+ io.say(e.message);
814
+ continue recipeLoop;
815
+ }
816
+ }
817
+ }
818
+ answeredAddRecipes = finalAddRecipes;
819
+ answeredRemoveRecipes = finalRemoveRecipes;
820
+ answeredReplace = finalReplace;
821
+ // Targets question for new profile
822
+ // Typed flags answer their questions (spec 38 §4.2, §4.3)
823
+ if (given.targets !== undefined) {
824
+ answeredTargets = given.targets;
825
+ }
826
+ else if (local.keys.includes("targets")) {
827
+ // local.keys includes targets and no --targets typed: no targets question; say the local value
828
+ const localTargets = localConfig?.targets ?? [];
829
+ io.say(`Targets: ${localTargets.join(", ")} (from craftar.local.yaml)`);
830
+ }
831
+ else {
832
+ // Default: [<template's targets>, from <like>] or [claude-code, default]
833
+ const template = newProfileLike ? forge.profiles.get(newProfileLike) : undefined;
834
+ const defaultTargets = template?.targets ?? ["claude-code"];
835
+ const fromText = template ? `from ${newProfileLike}` : "default";
836
+ const targetsQ = `Targets — ${TARGETS.join(", ")}, separated by commas [${defaultTargets.join(", ")}, ${fromText}]: `;
837
+ targetsLoop: for (;;) {
838
+ const answer = await io.ask(targetsQ);
839
+ if (answer === null)
840
+ return { kind: "cancelled" };
841
+ const trimmed = answer.trim();
842
+ if (trimmed === "")
843
+ break targetsLoop;
844
+ const parsed = trimmed.split(",").map((s) => s.trim());
845
+ try {
846
+ checkInitFlags({ targets: parsed });
847
+ }
848
+ catch (e) {
849
+ io.say(e.message);
850
+ continue targetsLoop;
851
+ }
852
+ answeredTargets = parsed;
853
+ break targetsLoop;
854
+ }
855
+ }
856
+ // Build the result for new profile
857
+ const input = {
858
+ forge: answeredForge,
859
+ profile: newProfileName,
860
+ loaded: loaded,
861
+ newProfile: newProfileLike ? { name: newProfileName, like: newProfileLike } : { name: newProfileName },
862
+ };
863
+ if (answeredRef !== undefined)
864
+ input.ref = answeredRef;
865
+ if (answeredTargets !== undefined)
866
+ input.targets = answeredTargets;
867
+ if (answeredAddRecipes.length > 0)
868
+ input.addRecipes = answeredAddRecipes;
869
+ if (answeredRemoveRecipes.length > 0)
870
+ input.removeRecipes = answeredRemoveRecipes;
871
+ if (answeredReplace)
872
+ input.replace = true;
873
+ if (given.clone === true)
874
+ input.clone = true;
875
+ // For new profile: answers has --profile name (for the again line), no recipe/target flags
876
+ const answers = {
877
+ forge: answeredForge,
878
+ profile: newProfileName,
879
+ addRecipes: [],
880
+ removeRecipes: [],
881
+ replace: false,
882
+ };
883
+ if (answeredRef !== undefined)
884
+ answers.ref = answeredRef;
885
+ return { kind: "answered", input, answers };
886
+ }
887
+ // Existing profile flow (unchanged)
333
888
  const profile = forge.profiles.get(answeredProfile);
334
889
  const recipesFlagged = given.addRecipes.length > 0 || given.removeRecipes.length > 0;
335
890
  if (recipesFlagged) {
@@ -501,6 +1056,8 @@ export async function askInit(root, given, local, io, opts) {
501
1056
  input.removeRecipes = answeredRemoveRecipes;
502
1057
  if (answeredReplace)
503
1058
  input.replace = true;
1059
+ if (given.clone === true)
1060
+ input.clone = true;
504
1061
  const answers = {
505
1062
  forge: answeredForge,
506
1063
  profile: answeredProfile,