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/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 {
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
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) =>
|
|
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, ...
|
|
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 (
|
|
2807
|
-
lines.push(` git -C ${quote(root)} clean -
|
|
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). */
|