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/README.md +29 -3
- package/dist/cli.js +418 -78
- package/dist/cli.js.map +1 -1
- package/dist/core/clone.js +416 -0
- package/dist/core/clone.js.map +1 -0
- package/dist/core/forge.js +54 -6
- package/dist/core/forge.js.map +1 -1
- package/dist/core/init-flow.js +572 -15
- package/dist/core/init-flow.js.map +1 -1
- package/dist/core/init.js +82 -1
- package/dist/core/init.js.map +1 -1
- package/dist/core/integrations.js +8 -1
- package/dist/core/integrations.js.map +1 -1
- package/dist/core/new-profile-gate.js +94 -0
- package/dist/core/new-profile-gate.js.map +1 -0
- package/dist/core/new-profile-write.js +148 -0
- package/dist/core/new-profile-write.js.map +1 -0
- package/dist/core/new-profile.js +183 -0
- package/dist/core/new-profile.js.map +1 -0
- package/dist/core/promote.js +7 -2
- package/dist/core/promote.js.map +1 -1
- package/dist/core/repos.js +299 -0
- package/dist/core/repos.js.map +1 -0
- package/dist/core/sync.js +7 -2
- package/dist/core/sync.js.map +1 -1
- package/package.json +1 -1
package/dist/core/init-flow.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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,
|