arcane-cli 1.9.2 → 1.10.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 +12 -0
- package/dist/index.js +298 -75
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -218,8 +218,20 @@ Install everything, or just the slice you want:
|
|
|
218
218
|
```bash
|
|
219
219
|
spell init --profile full
|
|
220
220
|
spell add agent-policies # or install any component à la carte
|
|
221
|
+
spell add spells-build compliance-standards # or several, in order
|
|
221
222
|
```
|
|
222
223
|
|
|
224
|
+
`spell add` takes one or more names. It checks each component's files before writing any of them: if one already exists, it prints one line naming the file and `--force`, exits 1, and writes nothing from that component. With several names it stops at the first failure and says which were added and which were not attempted; a name that is already installed is skipped. A component that keeps your own file (`.mcp.json`) leaves an existing one alone and creates a missing one. `--dry-run` lists each file as identical, differing or missing, or as one that would be kept or refused, and writes nothing.
|
|
225
|
+
|
|
226
|
+
### What `spell update` installs
|
|
227
|
+
|
|
228
|
+
Beyond refreshing what you have, `spell update` installs two things, and reports each by name ([ARC-052](DECISIONS.md#arc-052--spell-update-installs-the-requires-prerequisites-of-installed-components)):
|
|
229
|
+
|
|
230
|
+
- **Missing prerequisites.** A component that cites another one in `requires` (`spells-build` cites the standards documents) gets it installed when it is missing. It is tracked with hashes like any other component. If a file at its destination already exists, or it is an `initOnly` component, `update` leaves it alone and keeps it in the list with the reason.
|
|
231
|
+
- **Newly available components, only when you ask.** In a terminal, `spell update` offers them as a checklist with nothing ticked. Without one, pass `spell update --add-new`. With neither, nothing is installed and the list prints as before. `--add-new` is for repositories, not the user tier.
|
|
232
|
+
|
|
233
|
+
`spell update` never installs an `initOnly` component (`line-ending-baseline`, `docs-baseline`): adding one changes how Git treats files you already have, so it stays a `spell add` you run yourself. `--dry-run` names exactly the components a real run would install. `spell doctor` installs nothing; it names the command.
|
|
234
|
+
|
|
223
235
|
### Setup questions, answered by flags
|
|
224
236
|
|
|
225
237
|
`spell init` asks a few questions about the repository and records the answers in `.arcane.json`; `spell update` asks any that an older install predates. Each question has a flag, on both commands, so a script or an agent harness can answer without a terminal:
|
package/dist/index.js
CHANGED
|
@@ -121,6 +121,15 @@ async function copyDirectory(srcDir, targetDir, relativeDir, opts = {}) {
|
|
|
121
121
|
}
|
|
122
122
|
return copiedFiles;
|
|
123
123
|
}
|
|
124
|
+
async function listDirectoryFiles(srcDir, relativeDir) {
|
|
125
|
+
const files = [];
|
|
126
|
+
for (const entry of await readdir(srcDir, { withFileTypes: true })) {
|
|
127
|
+
const rel = `${relativeDir}/${entry.name}`;
|
|
128
|
+
if (entry.isDirectory()) files.push(...await listDirectoryFiles(path.join(srcDir, entry.name), rel));
|
|
129
|
+
else files.push(rel);
|
|
130
|
+
}
|
|
131
|
+
return files;
|
|
132
|
+
}
|
|
124
133
|
|
|
125
134
|
// src/modules/manifest.ts
|
|
126
135
|
import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
@@ -1470,7 +1479,7 @@ async function printWelcome(version, cwd) {
|
|
|
1470
1479
|
}
|
|
1471
1480
|
printFooter(version);
|
|
1472
1481
|
console.log();
|
|
1473
|
-
const
|
|
1482
|
+
const label2 = (s) => chalk.hex(STEEL_DIM)(s.padEnd(11));
|
|
1474
1483
|
const dot = chalk.hex(FAINT)("\xB7");
|
|
1475
1484
|
let repoLine;
|
|
1476
1485
|
try {
|
|
@@ -1481,11 +1490,11 @@ async function printWelcome(version, cwd) {
|
|
|
1481
1490
|
} catch {
|
|
1482
1491
|
repoLine = `${chalk.hex(STEEL_DIM)("\u25CB")} not initialized ${chalk.hex(FAINT)("\u2014")} run ${chalk.hex(STEEL).inverse(" spell init ")} ${chalk.hex(STEEL_DIM)("to scaffold")}`;
|
|
1483
1492
|
}
|
|
1484
|
-
console.log(` ${
|
|
1493
|
+
console.log(` ${label2("This repo")}${repoLine}`);
|
|
1485
1494
|
const phases = ["plan", "architect", "implement", "test", "review", "ship"].join(
|
|
1486
1495
|
chalk.hex(STEEL_DEEP)(" \u2192 ")
|
|
1487
1496
|
);
|
|
1488
|
-
console.log(` ${
|
|
1497
|
+
console.log(` ${label2("Spell Loop")}${chalk.hex("#dbe3f7")(phases)}`);
|
|
1489
1498
|
const flat = ["plan", "architect", "implement", "test", "review", "ship"].join(" \u2192 ");
|
|
1490
1499
|
const loopStart = 13 + flat.indexOf("implement");
|
|
1491
1500
|
const loopEnd = 13 + flat.indexOf("review") + 5;
|
|
@@ -2469,7 +2478,7 @@ async function runAgentsInit(options, targetDir, assetsDir) {
|
|
|
2469
2478
|
const scope = options.scope ?? "repo";
|
|
2470
2479
|
if (scope === "user") await requireUserStore(targetDir);
|
|
2471
2480
|
const baseDir = agentsBaseDir(targetDir, scope);
|
|
2472
|
-
const
|
|
2481
|
+
const label2 = scope === "user" ? "~/.arcane" : ".arcane";
|
|
2473
2482
|
if (!options.force && await rosterExists(targetDir, scope)) {
|
|
2474
2483
|
console.log(
|
|
2475
2484
|
'Agent roster already exists at .arcane/agents.yaml. Run "spell agents sync" to regenerate outputs, or use --force to reinitialize.'
|
|
@@ -2569,7 +2578,7 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
|
|
|
2569
2578
|
await mkdir4(arcaneDir, { recursive: true });
|
|
2570
2579
|
await writeFile6(join4(baseDir, "agents.yaml"), stringify(roster), "utf8");
|
|
2571
2580
|
} else {
|
|
2572
|
-
console.log(` [dry-run] Would write: ${
|
|
2581
|
+
console.log(` [dry-run] Would write: ${label2}/agents.yaml`);
|
|
2573
2582
|
}
|
|
2574
2583
|
for (const roleId of selectedRoleIds) {
|
|
2575
2584
|
const src = join4(assetsDir, "agents", `${roleId}.yaml`);
|
|
@@ -2581,7 +2590,7 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
|
|
|
2581
2590
|
console.warn(` ! Could not copy definition for "${roleId}" \u2014 skipping`);
|
|
2582
2591
|
}
|
|
2583
2592
|
} else {
|
|
2584
|
-
console.log(` [dry-run] Would copy: ${
|
|
2593
|
+
console.log(` [dry-run] Would copy: ${label2}/agents/${roleId}.yaml`);
|
|
2585
2594
|
}
|
|
2586
2595
|
}
|
|
2587
2596
|
if (options.dryRun) {
|
|
@@ -2608,7 +2617,7 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
|
|
|
2608
2617
|
}
|
|
2609
2618
|
console.log();
|
|
2610
2619
|
console.log(
|
|
2611
|
-
` \u{1F4A1} Customize: edit ${
|
|
2620
|
+
` \u{1F4A1} Customize: edit ${label2}/agents.yaml \u2192 spell agents sync${scope === "user" ? " --user" : ""}`
|
|
2612
2621
|
);
|
|
2613
2622
|
console.log();
|
|
2614
2623
|
if (hasUnresolvedRoles) {
|
|
@@ -4407,30 +4416,77 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
|
|
|
4407
4416
|
console.log();
|
|
4408
4417
|
}
|
|
4409
4418
|
|
|
4410
|
-
// src/
|
|
4419
|
+
// src/modules/component-install.ts
|
|
4420
|
+
import { stat } from "fs/promises";
|
|
4411
4421
|
import { join as join8 } from "path";
|
|
4412
|
-
async function
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
if (
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4422
|
+
async function planComponentInstall(component, targetDir, assetsDir, opts = {}) {
|
|
4423
|
+
const files = [];
|
|
4424
|
+
const stateOf = async (file, src) => {
|
|
4425
|
+
const dest = join8(targetDir, file);
|
|
4426
|
+
if (!await fileExists(dest)) return { file, src, state: "missing" };
|
|
4427
|
+
if ((await stat(dest)).isDirectory()) return { file, src, state: "differs" };
|
|
4428
|
+
const same = await hashFile(dest) === await hashFile(src);
|
|
4429
|
+
return { file, src, state: same ? "identical" : "differs" };
|
|
4430
|
+
};
|
|
4431
|
+
for (const file of component.files) {
|
|
4432
|
+
files.push(await stateOf(file, join8(assetsDir, component.sourceOverrides?.[file] ?? file)));
|
|
4433
|
+
}
|
|
4434
|
+
for (const dir of component.directories ?? []) {
|
|
4435
|
+
const srcDir = join8(assetsDir, dir);
|
|
4436
|
+
for (const file of await listDirectoryFiles(srcDir, dir)) {
|
|
4437
|
+
files.push(await stateOf(file, join8(assetsDir, file)));
|
|
4423
4438
|
}
|
|
4424
|
-
throw err;
|
|
4425
4439
|
}
|
|
4440
|
+
const present = files.filter((f) => f.state !== "missing");
|
|
4441
|
+
const missing = files.filter((f) => f.state === "missing");
|
|
4442
|
+
if (opts.force) return { component, files, toCopy: files, kept: [], conflicts: [] };
|
|
4443
|
+
return component.skipExisting ? { component, files, toCopy: missing, kept: present, conflicts: [] } : { component, files, toCopy: missing, kept: [], conflicts: present };
|
|
4444
|
+
}
|
|
4445
|
+
async function executeInstallPlan(plan, targetDir) {
|
|
4446
|
+
const files = [];
|
|
4447
|
+
const fileHashes = {};
|
|
4448
|
+
for (const { file, src } of plan.toCopy) {
|
|
4449
|
+
fileHashes[file] = await copyFile(src, targetDir, file, { force: true });
|
|
4450
|
+
files.push(file);
|
|
4451
|
+
}
|
|
4452
|
+
return { files, fileHashes };
|
|
4453
|
+
}
|
|
4454
|
+
|
|
4455
|
+
// src/commands/add.ts
|
|
4456
|
+
function describeExisting(f) {
|
|
4457
|
+
return f.state === "identical" ? "identical to the packaged file" : "differs from the packaged file";
|
|
4458
|
+
}
|
|
4459
|
+
function printDryRun2(name, plan, options) {
|
|
4460
|
+
for (const f of plan.files) {
|
|
4461
|
+
if (f.state === "missing") {
|
|
4462
|
+
console.log(` [dry-run] Would copy: ${f.file}`);
|
|
4463
|
+
} else if (options.force) {
|
|
4464
|
+
console.log(` [dry-run] Would overwrite: ${f.file} (exists, ${describeExisting(f)})`);
|
|
4465
|
+
} else if (plan.component.skipExisting) {
|
|
4466
|
+
console.log(` [dry-run] Would keep: ${f.file} (exists, yours, ${describeExisting(f)})`);
|
|
4467
|
+
} else {
|
|
4468
|
+
console.log(` [dry-run] Would refuse: ${f.file} (exists, ${describeExisting(f)}) \u2014 needs --force`);
|
|
4469
|
+
}
|
|
4470
|
+
}
|
|
4471
|
+
if (plan.conflicts.length > 0) {
|
|
4472
|
+
console.log(
|
|
4473
|
+
`
|
|
4474
|
+
[dry-run] Would refuse component "${name}": ${plan.conflicts.length} existing file${plan.conflicts.length === 1 ? "" : "s"}. Re-run with --force to overwrite.`
|
|
4475
|
+
);
|
|
4476
|
+
return;
|
|
4477
|
+
}
|
|
4478
|
+
console.log(`
|
|
4479
|
+
[dry-run] Would add component "${name}" \u2014 ${plan.toCopy.length} files`);
|
|
4480
|
+
}
|
|
4481
|
+
async function addOne(name, options, targetDir, assetsDir, packageVersion) {
|
|
4482
|
+
const manifest = await readManifest(targetDir);
|
|
4426
4483
|
let component;
|
|
4427
4484
|
try {
|
|
4428
4485
|
component = getComponent(name);
|
|
4429
4486
|
} catch (err) {
|
|
4430
4487
|
if (err instanceof ComponentNotFoundError) {
|
|
4431
4488
|
console.error(err.message);
|
|
4432
|
-
|
|
4433
|
-
return;
|
|
4489
|
+
return "failed";
|
|
4434
4490
|
}
|
|
4435
4491
|
throw err;
|
|
4436
4492
|
}
|
|
@@ -4438,53 +4494,69 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
4438
4494
|
console.log(
|
|
4439
4495
|
`Component "${name}" is already installed. Use "spell update" to update it.`
|
|
4440
4496
|
);
|
|
4441
|
-
return;
|
|
4442
|
-
}
|
|
4443
|
-
let fileCount = 0;
|
|
4444
|
-
const installedFiles = [];
|
|
4445
|
-
const fileHashes = {};
|
|
4446
|
-
for (const file of component.files) {
|
|
4447
|
-
const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
4448
|
-
if (options.dryRun) {
|
|
4449
|
-
console.log(` [dry-run] Would copy: ${file}`);
|
|
4450
|
-
} else {
|
|
4451
|
-
fileHashes[file] = await copyFile(srcPath, targetDir, file, { force: options.force });
|
|
4452
|
-
}
|
|
4453
|
-
installedFiles.push(file);
|
|
4454
|
-
fileCount++;
|
|
4455
|
-
}
|
|
4456
|
-
for (const dir of component.directories ?? []) {
|
|
4457
|
-
const srcDirPath = join8(assetsDir, dir);
|
|
4458
|
-
if (options.dryRun) {
|
|
4459
|
-
console.log(` [dry-run] Would copy directory: ${dir}/`);
|
|
4460
|
-
} else {
|
|
4461
|
-
const copied = await copyDirectory(srcDirPath, targetDir, dir, { force: options.force });
|
|
4462
|
-
for (const { path: copiedPath, hash } of copied) {
|
|
4463
|
-
installedFiles.push(copiedPath);
|
|
4464
|
-
fileHashes[copiedPath] = hash;
|
|
4465
|
-
}
|
|
4466
|
-
fileCount += copied.length;
|
|
4467
|
-
}
|
|
4497
|
+
return "skipped";
|
|
4468
4498
|
}
|
|
4499
|
+
const plan = await planComponentInstall(component, targetDir, assetsDir, { force: options.force });
|
|
4469
4500
|
if (options.dryRun) {
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4501
|
+
printDryRun2(name, plan, options);
|
|
4502
|
+
return plan.conflicts.length > 0 ? "failed" : "added";
|
|
4503
|
+
}
|
|
4504
|
+
if (plan.conflicts.length > 0) {
|
|
4505
|
+
const others = plan.conflicts.length - 1;
|
|
4506
|
+
console.error(
|
|
4507
|
+
`Cannot add "${name}": "${plan.conflicts[0].file}" already exists${others > 0 ? ` (and ${others} more)` : ""}. Nothing was written. Use --force to overwrite.`
|
|
4473
4508
|
);
|
|
4474
|
-
return;
|
|
4509
|
+
return "failed";
|
|
4475
4510
|
}
|
|
4511
|
+
const { files, fileHashes } = await executeInstallPlan(plan, targetDir);
|
|
4512
|
+
for (const f of plan.kept) console.log(` Kept existing (yours): ${f.file}`);
|
|
4476
4513
|
const newComponent = {
|
|
4477
4514
|
name,
|
|
4478
|
-
files
|
|
4515
|
+
files,
|
|
4479
4516
|
installedVersion: packageVersion,
|
|
4480
4517
|
fileHashes
|
|
4481
4518
|
};
|
|
4482
|
-
|
|
4483
|
-
await writeManifest(targetDir, updated);
|
|
4519
|
+
await writeManifest(targetDir, addComponent(manifest, newComponent));
|
|
4484
4520
|
console.log(
|
|
4485
4521
|
`
|
|
4486
|
-
\u2713 Added component "${name}" \u2014 ${
|
|
4522
|
+
\u2713 Added component "${name}" \u2014 ${files.length} files copied${plan.kept.length > 0 ? `, ${plan.kept.length} kept` : ""}`
|
|
4487
4523
|
);
|
|
4524
|
+
return "added";
|
|
4525
|
+
}
|
|
4526
|
+
async function runAdd(names, options, targetDir, assetsDir, packageVersion) {
|
|
4527
|
+
try {
|
|
4528
|
+
await readManifest(targetDir);
|
|
4529
|
+
} catch (err) {
|
|
4530
|
+
if (err instanceof ManifestNotFoundError) {
|
|
4531
|
+
console.error(
|
|
4532
|
+
'Not initialized. Run "spell init" first before adding components.'
|
|
4533
|
+
);
|
|
4534
|
+
process.exit(1);
|
|
4535
|
+
return;
|
|
4536
|
+
}
|
|
4537
|
+
throw err;
|
|
4538
|
+
}
|
|
4539
|
+
const added = [];
|
|
4540
|
+
const refused = [];
|
|
4541
|
+
for (const [index, name] of names.entries()) {
|
|
4542
|
+
const outcome = await addOne(name, options, targetDir, assetsDir, packageVersion);
|
|
4543
|
+
if (outcome === "added") added.push(name);
|
|
4544
|
+
if (outcome !== "failed") continue;
|
|
4545
|
+
refused.push(name);
|
|
4546
|
+
if (options.dryRun) continue;
|
|
4547
|
+
if (names.length > 1) {
|
|
4548
|
+
const notAttempted = names.slice(index + 1);
|
|
4549
|
+
console.error(
|
|
4550
|
+
`Added: ${added.length > 0 ? added.join(", ") : "none"}.${notAttempted.length > 0 ? ` Not attempted: ${notAttempted.join(", ")}.` : ""}`
|
|
4551
|
+
);
|
|
4552
|
+
}
|
|
4553
|
+
process.exit(1);
|
|
4554
|
+
return;
|
|
4555
|
+
}
|
|
4556
|
+
if (refused.length > 0) {
|
|
4557
|
+
console.error(`[dry-run] Would refuse: ${refused.join(", ")}.`);
|
|
4558
|
+
process.exit(1);
|
|
4559
|
+
}
|
|
4488
4560
|
}
|
|
4489
4561
|
|
|
4490
4562
|
// src/commands/update.ts
|
|
@@ -4693,27 +4765,105 @@ function formatRegistryChanges(changes) {
|
|
|
4693
4765
|
}
|
|
4694
4766
|
if (newlyAvailable.length > 0) {
|
|
4695
4767
|
const profile = changes.profile ? ` "${changes.profile}"` : "";
|
|
4696
|
-
lines.push(
|
|
4768
|
+
lines.push(
|
|
4769
|
+
changes.installedByUpdate ? ` Newly available for your${profile} profile, not installed by this run:` : ` Newly available for your${profile} profile, not installed \u2014 nothing was added:`
|
|
4770
|
+
);
|
|
4697
4771
|
for (const component of newlyAvailable) {
|
|
4698
4772
|
lines.push(` ${component.name} \u2014 ${component.description}`);
|
|
4699
4773
|
lines.push(` spell add ${component.name}`);
|
|
4700
4774
|
if (component.alreadyPresent.length > 0) {
|
|
4701
4775
|
lines.push(
|
|
4702
|
-
` (already here and yours: ${component.alreadyPresent.join(", ")} \u2014 \`spell add\`
|
|
4776
|
+
` (already here and yours: ${component.alreadyPresent.join(", ")} \u2014 \`spell add\` keeps it rather than overwrite it)`
|
|
4703
4777
|
);
|
|
4704
4778
|
}
|
|
4705
4779
|
}
|
|
4780
|
+
if (!changes.addNewUsed) lines.push(" To install them in one step: `spell update --add-new`");
|
|
4706
4781
|
}
|
|
4707
4782
|
if (missingRequires.length > 0) {
|
|
4708
4783
|
lines.push(" Missing prerequisites \u2014 installed components cite these, and they are not installed:");
|
|
4709
|
-
for (const { name, requiredBy } of missingRequires) {
|
|
4784
|
+
for (const { name, requiredBy, leftAloneReason } of missingRequires) {
|
|
4710
4785
|
lines.push(` ${name} (required by ${requiredBy.join(", ")})`);
|
|
4711
4786
|
lines.push(` spell add ${name}`);
|
|
4787
|
+
if (leftAloneReason) lines.push(` (not installed by update: ${leftAloneReason})`);
|
|
4712
4788
|
}
|
|
4713
4789
|
}
|
|
4714
4790
|
return lines;
|
|
4715
4791
|
}
|
|
4716
4792
|
|
|
4793
|
+
// src/modules/update-installs.ts
|
|
4794
|
+
import { checkbox as checkbox2 } from "@inquirer/prompts";
|
|
4795
|
+
function offerableNewComponents(newlyAvailable, missingRequires) {
|
|
4796
|
+
return newlyAvailable.filter(
|
|
4797
|
+
(c) => !getComponent(c.name).initOnly && !missingRequires.some((m) => m.name === c.name)
|
|
4798
|
+
);
|
|
4799
|
+
}
|
|
4800
|
+
async function selectNewComponents(candidates, mode) {
|
|
4801
|
+
if (candidates.length === 0) return [];
|
|
4802
|
+
if (mode.addNew) return candidates;
|
|
4803
|
+
if (!mode.interactive || mode.dryRun) return [];
|
|
4804
|
+
const chosen = await checkbox2({
|
|
4805
|
+
message: "Newly available components. Space to select, Enter to confirm (none selected installs nothing):",
|
|
4806
|
+
choices: candidates.map((c) => ({ name: `${c.name} \u2014 ${c.description}`, value: c.name, checked: false }))
|
|
4807
|
+
});
|
|
4808
|
+
return candidates.filter((c) => chosen.includes(c.name));
|
|
4809
|
+
}
|
|
4810
|
+
async function planUpdateInstalls(args) {
|
|
4811
|
+
const items = [];
|
|
4812
|
+
const leftAlone = [];
|
|
4813
|
+
for (const { name, requiredBy } of args.missingRequires) {
|
|
4814
|
+
const component = componentForSpellScope(getComponent(name), args.spellScope);
|
|
4815
|
+
if (component.initOnly) {
|
|
4816
|
+
leftAlone.push({ name, kind: "prerequisite", reason: "it is an initOnly component: adding it mid-life changes how Git treats existing files, so it is your call" });
|
|
4817
|
+
continue;
|
|
4818
|
+
}
|
|
4819
|
+
if (component.files.length === 0) continue;
|
|
4820
|
+
const plan = await planComponentInstall(component, args.targetDir, args.assetsDir, {});
|
|
4821
|
+
if (plan.conflicts.length > 0) {
|
|
4822
|
+
leftAlone.push({ name, kind: "prerequisite", reason: `${plan.conflicts[0].file} already exists${plan.conflicts.length > 1 ? ` (and ${plan.conflicts.length - 1} more)` : ""}, and update does not overwrite` });
|
|
4823
|
+
continue;
|
|
4824
|
+
}
|
|
4825
|
+
items.push({ plan, kind: "prerequisite", requiredBy });
|
|
4826
|
+
}
|
|
4827
|
+
for (const candidate of args.selectedNew) {
|
|
4828
|
+
const component = componentForSpellScope(getComponent(candidate.name), args.spellScope);
|
|
4829
|
+
if (component.initOnly || component.files.length === 0) continue;
|
|
4830
|
+
const plan = await planComponentInstall(component, args.targetDir, args.assetsDir, {});
|
|
4831
|
+
if (plan.conflicts.length > 0) {
|
|
4832
|
+
leftAlone.push({ name: candidate.name, kind: "new", reason: `${plan.conflicts[0].file} already exists, and update does not overwrite` });
|
|
4833
|
+
continue;
|
|
4834
|
+
}
|
|
4835
|
+
items.push({ plan, kind: "new", requiredBy: [] });
|
|
4836
|
+
}
|
|
4837
|
+
return { items, leftAlone };
|
|
4838
|
+
}
|
|
4839
|
+
function label(item) {
|
|
4840
|
+
return item.kind === "prerequisite" ? `prerequisite of ${item.requiredBy.join(", ")}` : "newly available";
|
|
4841
|
+
}
|
|
4842
|
+
function describeInstallPlan(plan) {
|
|
4843
|
+
const lines = [];
|
|
4844
|
+
for (const item of plan.items) {
|
|
4845
|
+
lines.push(` [dry-run] Would install: ${item.plan.component.name} (${label(item)}) \u2014 ${item.plan.toCopy.length} files`);
|
|
4846
|
+
for (const f of item.plan.kept) lines.push(` [dry-run] Would keep: ${f.file} (exists, yours)`);
|
|
4847
|
+
}
|
|
4848
|
+
return lines;
|
|
4849
|
+
}
|
|
4850
|
+
async function executeUpdateInstalls(plan, targetDir, packageVersion) {
|
|
4851
|
+
const installed = [];
|
|
4852
|
+
const lines = [];
|
|
4853
|
+
for (const item of plan.items) {
|
|
4854
|
+
const { files, fileHashes } = await executeInstallPlan(item.plan, targetDir);
|
|
4855
|
+
installed.push({
|
|
4856
|
+
name: item.plan.component.name,
|
|
4857
|
+
files,
|
|
4858
|
+
installedVersion: packageVersion,
|
|
4859
|
+
fileHashes
|
|
4860
|
+
});
|
|
4861
|
+
lines.push(` Installed: ${item.plan.component.name} (${label(item)}) \u2014 ${files.length} files`);
|
|
4862
|
+
for (const f of item.plan.kept) lines.push(` Kept existing (yours): ${f.file}`);
|
|
4863
|
+
}
|
|
4864
|
+
return { installed, lines };
|
|
4865
|
+
}
|
|
4866
|
+
|
|
4717
4867
|
// src/modules/npm-registry.ts
|
|
4718
4868
|
var NPM_PACKAGE_NAME = "arcane-cli";
|
|
4719
4869
|
async function fetchPublishedFile(version, relativeAssetPath) {
|
|
@@ -4947,6 +5097,13 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4947
5097
|
return;
|
|
4948
5098
|
}
|
|
4949
5099
|
const scope = resolvedScope.scope;
|
|
5100
|
+
if (options.addNew && scope !== "repo") {
|
|
5101
|
+
console.error(
|
|
5102
|
+
"\n \u2717 --add-new offers the components a repository's profile gained; the user tier has no profile. Run it without --user, inside a repository.\n"
|
|
5103
|
+
);
|
|
5104
|
+
process.exit(1);
|
|
5105
|
+
return;
|
|
5106
|
+
}
|
|
4950
5107
|
let flagPatch = {};
|
|
4951
5108
|
if (hasManifestFlags(options.manifestFlags)) {
|
|
4952
5109
|
if (scope !== "repo") {
|
|
@@ -5015,19 +5172,49 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
5015
5172
|
missingRequires: findMissingRequires(manifest.components, manifest.profile)
|
|
5016
5173
|
} : {}
|
|
5017
5174
|
};
|
|
5175
|
+
let installPlan = { items: [], leftAlone: [] };
|
|
5176
|
+
if (scope === "repo" && manifest.components.length > 0) {
|
|
5177
|
+
const missingRequires = registryChanges.missingRequires ?? [];
|
|
5178
|
+
const selectedNew = await selectNewComponents(
|
|
5179
|
+
offerableNewComponents(registryChanges.newlyAvailable ?? [], missingRequires),
|
|
5180
|
+
{ addNew: Boolean(options.addNew), interactive: Boolean(process.stdin.isTTY), dryRun: Boolean(options.dryRun) }
|
|
5181
|
+
);
|
|
5182
|
+
installPlan = await planUpdateInstalls({ targetDir, assetsDir, spellScope, missingRequires, selectedNew });
|
|
5183
|
+
const installing = new Set(installPlan.items.map((i) => i.plan.component.name));
|
|
5184
|
+
registryChanges.installedByUpdate = installing.size > 0;
|
|
5185
|
+
registryChanges.addNewUsed = Boolean(options.addNew);
|
|
5186
|
+
registryChanges.newlyAvailable = (registryChanges.newlyAvailable ?? []).filter((c) => !installing.has(c.name));
|
|
5187
|
+
registryChanges.missingRequires = missingRequires.filter((m) => !installing.has(m.name)).map((m) => ({ ...m, leftAloneReason: installPlan.leftAlone.find((l) => l.name === m.name)?.reason }));
|
|
5188
|
+
}
|
|
5018
5189
|
const printRegistryChanges = () => {
|
|
5019
5190
|
for (const line of formatRegistryChanges(registryChanges)) console.log(line);
|
|
5020
5191
|
};
|
|
5192
|
+
const applyInstalls = async () => {
|
|
5193
|
+
const declined = installPlan.leftAlone.filter((l) => l.kind === "new");
|
|
5194
|
+
if (installPlan.items.length === 0 && declined.length === 0) return [];
|
|
5195
|
+
console.log(options.dryRun ? "\n[dry-run] Components this update would install (ARC-052):" : "\nComponents installed by this update (ARC-052):");
|
|
5196
|
+
for (const l of declined) console.log(` ! Not installed: ${l.name} \u2014 ${l.reason}`);
|
|
5197
|
+
if (options.dryRun) {
|
|
5198
|
+
for (const line of describeInstallPlan(installPlan)) console.log(line);
|
|
5199
|
+
return [];
|
|
5200
|
+
}
|
|
5201
|
+
const { installed, lines } = await executeUpdateInstalls(installPlan, targetDir, packageVersion);
|
|
5202
|
+
for (const line of lines) console.log(line);
|
|
5203
|
+
return installed;
|
|
5204
|
+
};
|
|
5021
5205
|
let sameVersionRestore = false;
|
|
5022
5206
|
if (manifest.version === packageVersion && manifest.components.length > 0) {
|
|
5023
5207
|
const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope, spellScope, readopted);
|
|
5024
5208
|
const unmanaged = findUnmanagedTrackedFiles(manifest.components, scope, spellScope);
|
|
5025
5209
|
if (missing.length === 0 && unmanaged.length === 0) {
|
|
5026
|
-
console.log("Already up to date.");
|
|
5210
|
+
console.log(installPlan.items.length === 0 ? "Already up to date." : "Installed components are at the current version.");
|
|
5211
|
+
const newlyInstalled = await applyInstalls();
|
|
5027
5212
|
printRegistryChanges();
|
|
5028
|
-
if (scope === "repo" && Object.keys(flagPatch).length > 0) {
|
|
5029
|
-
if (!options.dryRun)
|
|
5030
|
-
|
|
5213
|
+
if (scope === "repo" && (Object.keys(flagPatch).length > 0 || newlyInstalled.length > 0)) {
|
|
5214
|
+
if (!options.dryRun) {
|
|
5215
|
+
await writeManifest(targetDir, { ...answered, components: [...answered.components, ...newlyInstalled] });
|
|
5216
|
+
}
|
|
5217
|
+
if (Object.keys(flagPatch).length > 0) reportFlagPatch(flagPatch, manifest, answered, options.dryRun);
|
|
5031
5218
|
}
|
|
5032
5219
|
if (scope === "user") {
|
|
5033
5220
|
const fanout = await syncUserTierFanout({
|
|
@@ -5214,6 +5401,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
5214
5401
|
fileHashes
|
|
5215
5402
|
});
|
|
5216
5403
|
}
|
|
5404
|
+
updatedComponents.push(...await applyInstalls());
|
|
5217
5405
|
const claimed = new Set(updatedComponents.flatMap((c) => c.files));
|
|
5218
5406
|
const resolvedOrphans = /* @__PURE__ */ new Set();
|
|
5219
5407
|
for (const { owner, file, recordedHash } of orphanCandidates) {
|
|
@@ -5510,11 +5698,11 @@ async function runStatus(targetDir, packageVersion, options = {}) {
|
|
|
5510
5698
|
` ${"-".repeat(nameWidth)} ${"-".repeat(filesWidth)} ${"-".repeat(versionWidth)}`
|
|
5511
5699
|
);
|
|
5512
5700
|
for (const component of manifest.components) {
|
|
5513
|
-
const
|
|
5701
|
+
const label2 = component.name;
|
|
5514
5702
|
const fileCount = component.files.length.toString();
|
|
5515
5703
|
const displayVersion = component.installedVersion;
|
|
5516
5704
|
console.log(
|
|
5517
|
-
` ${pad(
|
|
5705
|
+
` ${pad(label2, nameWidth)} ${rpad(fileCount, filesWidth)} ${displayVersion}`
|
|
5518
5706
|
);
|
|
5519
5707
|
}
|
|
5520
5708
|
console.log("");
|
|
@@ -6276,8 +6464,8 @@ async function checkNodeVersion() {
|
|
|
6276
6464
|
message: `${raw} \u2014 Node.js 18 or later is required`
|
|
6277
6465
|
};
|
|
6278
6466
|
}
|
|
6279
|
-
async function checkVSCodeExtension(id,
|
|
6280
|
-
const name = `VS Code extension: ${
|
|
6467
|
+
async function checkVSCodeExtension(id, label2, homeDir = resolveHomeDir()) {
|
|
6468
|
+
const name = `VS Code extension: ${label2}`;
|
|
6281
6469
|
const flavours = [
|
|
6282
6470
|
{ dir: join15(homeDir, ".vscode", "extensions"), bin: "code" },
|
|
6283
6471
|
{ dir: join15(homeDir, ".vscode-insiders", "extensions"), bin: "code-insiders" }
|
|
@@ -6619,9 +6807,39 @@ async function checkComponentRequires(targetDir) {
|
|
|
6619
6807
|
name,
|
|
6620
6808
|
passed: false,
|
|
6621
6809
|
blocking: false,
|
|
6622
|
-
message: missing.map(({ name: required, requiredBy }) => `${requiredBy.join(", ")} cites ${required}, which is not installed \u2014 \`spell add ${required}\``).join("; ")
|
|
6810
|
+
message: missing.map(({ name: required, requiredBy }) => `${requiredBy.join(", ")} cites ${required}, which is not installed \u2014 ${getComponent(required).initOnly ? "" : "`spell update` installs it, or "}\`spell add ${required}\``).join("; ")
|
|
6623
6811
|
};
|
|
6624
6812
|
}
|
|
6813
|
+
async function checkNewlyAvailableComponents(targetDir) {
|
|
6814
|
+
const name = "Newly available components";
|
|
6815
|
+
let manifest;
|
|
6816
|
+
try {
|
|
6817
|
+
manifest = await readManifest(targetDir);
|
|
6818
|
+
} catch {
|
|
6819
|
+
return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no readable .arcane.json" };
|
|
6820
|
+
}
|
|
6821
|
+
if (manifest.scope === "user" || !Array.isArray(manifest.components)) {
|
|
6822
|
+
return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no repository install to compare" };
|
|
6823
|
+
}
|
|
6824
|
+
const installed = manifest.components.flatMap(
|
|
6825
|
+
(c) => (LEGACY_COMPONENT_MIGRATIONS[c.name] ?? [c.name]).map((n) => ({ ...c, name: n }))
|
|
6826
|
+
);
|
|
6827
|
+
const prerequisites = findMissingRequires(manifest.components, manifest.profile).map((m) => m.name);
|
|
6828
|
+
const fresh = (await findNewlyAvailable(targetDir, manifest.profile, installed, effectiveSpellScope(manifest))).filter((c) => !prerequisites.includes(c.name));
|
|
6829
|
+
if (fresh.length === 0) {
|
|
6830
|
+
return { name, passed: true, blocking: false, message: "the profile has nothing this install lacks" };
|
|
6831
|
+
}
|
|
6832
|
+
const offerable = fresh.filter((c) => !getComponent(c.name).initOnly).map((c) => c.name);
|
|
6833
|
+
const byHand = fresh.filter((c) => getComponent(c.name).initOnly).map((c) => c.name);
|
|
6834
|
+
const parts = [];
|
|
6835
|
+
if (offerable.length > 0) {
|
|
6836
|
+
parts.push(`${offerable.join(", ")} \u2014 \`spell update --add-new\` installs ${offerable.length === 1 ? "it" : "them"}`);
|
|
6837
|
+
}
|
|
6838
|
+
if (byHand.length > 0) {
|
|
6839
|
+
parts.push(`${byHand.join(", ")} \u2014 initOnly, so only ${byHand.map((n) => `\`spell add ${n}\``).join(", ")}`);
|
|
6840
|
+
}
|
|
6841
|
+
return { name, passed: false, blocking: false, message: `newly available for your profile, not installed: ${parts.join("; ")}` };
|
|
6842
|
+
}
|
|
6625
6843
|
async function checkMcpConfig(targetDir) {
|
|
6626
6844
|
const name = "MCP config (per-server timeout, I12)";
|
|
6627
6845
|
const filePath = join15(targetDir, ".mcp.json");
|
|
@@ -6874,7 +7092,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
6874
7092
|
checkUserTier(resolveHomeDir(), options.packageVersion),
|
|
6875
7093
|
checkSpellScope(targetDir),
|
|
6876
7094
|
checkGovernancePlaceholders(targetDir),
|
|
6877
|
-
checkComponentRequires(targetDir)
|
|
7095
|
+
checkComponentRequires(targetDir),
|
|
7096
|
+
checkNewlyAvailableComponents(targetDir)
|
|
6878
7097
|
]);
|
|
6879
7098
|
const sessionResults = await checkSessionContinuity(targetDir);
|
|
6880
7099
|
results.push(...sessionResults);
|
|
@@ -6882,8 +7101,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
6882
7101
|
for (const result of results) {
|
|
6883
7102
|
const skipped = result.passed && result.skipped === true;
|
|
6884
7103
|
const icon = skipped ? "\u2013" : result.passed ? "\u2713" : result.blocking === false ? "\u26A0" : "\u2717";
|
|
6885
|
-
const
|
|
6886
|
-
console.log(` ${icon} [${
|
|
7104
|
+
const label2 = skipped ? "skip" : result.passed ? "pass" : result.blocking === false ? "warn" : "FAIL";
|
|
7105
|
+
console.log(` ${icon} [${label2}] ${result.name}`);
|
|
6887
7106
|
if (!result.passed || skipped) {
|
|
6888
7107
|
console.log(` ${result.message}`);
|
|
6889
7108
|
}
|
|
@@ -8084,10 +8303,10 @@ withManifestFlags(
|
|
|
8084
8303
|
);
|
|
8085
8304
|
}
|
|
8086
8305
|
);
|
|
8087
|
-
program.command("add <
|
|
8088
|
-
async (
|
|
8306
|
+
program.command("add <components...>").description("Add one or more components to an existing Arcane installation, in order; stops at the first failure").option("--force", "Overwrite existing files without error").option("--dry-run", "Preview what would be added without making changes").action(
|
|
8307
|
+
async (components, opts) => {
|
|
8089
8308
|
await runAdd(
|
|
8090
|
-
|
|
8309
|
+
components,
|
|
8091
8310
|
{ force: opts.force, dryRun: opts.dryRun },
|
|
8092
8311
|
process.cwd(),
|
|
8093
8312
|
ASSETS_DIR,
|
|
@@ -8100,6 +8319,9 @@ withManifestFlags(
|
|
|
8100
8319
|
"--prune",
|
|
8101
8320
|
"Also delete orphaned managed files (tracked before this update, no longer part of any current component). Hash-checked: an edited file is reported, never silently deleted"
|
|
8102
8321
|
).option("--user", USER_FLAG_DESCRIPTION).option(
|
|
8322
|
+
"--add-new",
|
|
8323
|
+
"Also install the components your profile gained since your install (never an initOnly component, never over an existing file). Without it a terminal asks and a script installs nothing"
|
|
8324
|
+
).option(
|
|
8103
8325
|
"--default-scope <scope>",
|
|
8104
8326
|
"With --user: remember repo | user as the answer `spell init` pre-selects for NEW repositories on this machine. Never rewrites an existing repository, and the question is still asked"
|
|
8105
8327
|
)
|
|
@@ -8123,6 +8345,7 @@ withManifestFlags(
|
|
|
8123
8345
|
dryRun: opts.dryRun,
|
|
8124
8346
|
prune: opts.prune,
|
|
8125
8347
|
user: opts.user,
|
|
8348
|
+
addNew: opts.addNew,
|
|
8126
8349
|
manifestFlags: manifestFlagsFrom(opts),
|
|
8127
8350
|
...opts.defaultScope === void 0 ? {} : { defaultScope: opts.defaultScope }
|
|
8128
8351
|
},
|