arcane-cli 1.1.0 → 1.2.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 +18 -1
- package/dist/assets/.arcane/governance/portable-bootstrap.md +1 -1
- package/dist/index.js +280 -61
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -19,13 +19,38 @@ import {
|
|
|
19
19
|
access,
|
|
20
20
|
readdir,
|
|
21
21
|
readFile,
|
|
22
|
+
rm,
|
|
23
|
+
rmdir,
|
|
22
24
|
writeFile
|
|
23
25
|
} from "fs/promises";
|
|
24
26
|
import { createHash } from "crypto";
|
|
25
27
|
import path from "path";
|
|
28
|
+
function normalizeLineEndings(content) {
|
|
29
|
+
if (!content.includes(13)) return content;
|
|
30
|
+
const out = Buffer.allocUnsafe(content.length);
|
|
31
|
+
let o = 0;
|
|
32
|
+
for (let i = 0; i < content.length; i++) {
|
|
33
|
+
const byte = content[i];
|
|
34
|
+
if (byte === 13) {
|
|
35
|
+
out[o++] = 10;
|
|
36
|
+
if (content[i + 1] === 10) i++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
out[o++] = byte;
|
|
40
|
+
}
|
|
41
|
+
return out.subarray(0, o);
|
|
42
|
+
}
|
|
43
|
+
function hashContent(content) {
|
|
44
|
+
const bytes = typeof content === "string" ? Buffer.from(content, "utf-8") : content;
|
|
45
|
+
return createHash("sha256").update(normalizeLineEndings(bytes)).digest("hex");
|
|
46
|
+
}
|
|
26
47
|
async function hashFile(filePath) {
|
|
48
|
+
return hashContent(await readFile(filePath));
|
|
49
|
+
}
|
|
50
|
+
async function fileMatchesHash(filePath, recordedHash) {
|
|
27
51
|
const content = await readFile(filePath);
|
|
28
|
-
|
|
52
|
+
if (hashContent(content) === recordedHash) return true;
|
|
53
|
+
return createHash("sha256").update(content).digest("hex") === recordedHash;
|
|
29
54
|
}
|
|
30
55
|
function validateTargetPath(targetDir, relativePath) {
|
|
31
56
|
const resolvedBase = path.resolve(targetDir);
|
|
@@ -57,10 +82,28 @@ async function copyFile(srcPath, targetDir, relativePath, opts = {}) {
|
|
|
57
82
|
);
|
|
58
83
|
}
|
|
59
84
|
const content = await readFile(srcPath);
|
|
60
|
-
const hash =
|
|
85
|
+
const hash = hashContent(content);
|
|
61
86
|
await writeFile(destPath, content);
|
|
62
87
|
return hash;
|
|
63
88
|
}
|
|
89
|
+
async function removeWithin(targetDir, relativePath) {
|
|
90
|
+
validateTargetPath(targetDir, relativePath);
|
|
91
|
+
await rm(path.resolve(targetDir, relativePath), { force: true });
|
|
92
|
+
}
|
|
93
|
+
async function removeEmptyAncestors(targetDir, relativePath) {
|
|
94
|
+
validateTargetPath(targetDir, relativePath);
|
|
95
|
+
const base = path.resolve(targetDir);
|
|
96
|
+
let dir = path.dirname(path.resolve(targetDir, relativePath));
|
|
97
|
+
while (dir !== base && dir.startsWith(base + path.sep)) {
|
|
98
|
+
try {
|
|
99
|
+
if ((await readdir(dir)).length > 0) return;
|
|
100
|
+
await rmdir(dir);
|
|
101
|
+
} catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
dir = path.dirname(dir);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
64
107
|
async function copyDirectory(srcDir, targetDir, relativeDir, opts = {}) {
|
|
65
108
|
validateTargetPath(targetDir, relativeDir);
|
|
66
109
|
const copiedFiles = [];
|
|
@@ -173,6 +216,9 @@ function validateTrackingFields(manifest, filePath) {
|
|
|
173
216
|
if (manifest.scope !== void 0 && !VALID_INSTALL_SCOPES.includes(manifest.scope)) {
|
|
174
217
|
throw new ManifestInvalidFieldError(filePath, "scope", manifest.scope);
|
|
175
218
|
}
|
|
219
|
+
if (manifest.spell_scope !== void 0 && !VALID_INSTALL_SCOPES.includes(manifest.spell_scope)) {
|
|
220
|
+
throw new ManifestInvalidFieldError(filePath, "spell_scope", manifest.spell_scope);
|
|
221
|
+
}
|
|
176
222
|
if (manifest.fanout !== void 0 && !isValidFanoutRecord(manifest.fanout)) {
|
|
177
223
|
throw new ManifestInvalidFieldError(filePath, "fanout", manifest.fanout);
|
|
178
224
|
}
|
|
@@ -838,8 +884,7 @@ function listProfiles() {
|
|
|
838
884
|
}
|
|
839
885
|
|
|
840
886
|
// src/modules/user-tier.ts
|
|
841
|
-
import {
|
|
842
|
-
import { lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rm, rmdir, writeFile as writeFile3 } from "fs/promises";
|
|
887
|
+
import { lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rm as rm2, rmdir as rmdir2, writeFile as writeFile3 } from "fs/promises";
|
|
843
888
|
import { homedir } from "os";
|
|
844
889
|
import { dirname, join, resolve } from "path";
|
|
845
890
|
|
|
@@ -902,6 +947,7 @@ function renderClaudeCommandStub(id, frontmatter, canonicalRef = canonicalSpellP
|
|
|
902
947
|
const title = deriveStubTitle(frontmatter.name);
|
|
903
948
|
const description = frontmatter.claudeDescription ?? frontmatter.description;
|
|
904
949
|
const promptPath = canonicalRef;
|
|
950
|
+
const includeRef = /\s/.test(promptPath) ? `"${promptPath}"` : promptPath;
|
|
905
951
|
return `---
|
|
906
952
|
description: ${description}
|
|
907
953
|
---
|
|
@@ -914,7 +960,7 @@ See the full prompt at \`${promptPath}\` for the complete workflow definition.
|
|
|
914
960
|
|
|
915
961
|
---
|
|
916
962
|
|
|
917
|
-
@${
|
|
963
|
+
@${includeRef}
|
|
918
964
|
`;
|
|
919
965
|
}
|
|
920
966
|
var SKILL_NAME_PATTERN = /^[a-z0-9-]+$/;
|
|
@@ -971,6 +1017,14 @@ function componentForScope(component, scope) {
|
|
|
971
1017
|
}
|
|
972
1018
|
return { name: component.name, description: component.description, files, sourceOverrides };
|
|
973
1019
|
}
|
|
1020
|
+
function componentForSpellScope(component, spellScope) {
|
|
1021
|
+
if (spellScope === "repo") return component;
|
|
1022
|
+
if (!SPELL_COMPONENT_NAMES.includes(component.name)) return component;
|
|
1023
|
+
return { ...component, files: [] };
|
|
1024
|
+
}
|
|
1025
|
+
function effectiveSpellScope(manifest) {
|
|
1026
|
+
return manifest.spell_scope ?? "repo";
|
|
1027
|
+
}
|
|
974
1028
|
function spellIdsInStore(components) {
|
|
975
1029
|
const ids = /* @__PURE__ */ new Set();
|
|
976
1030
|
for (const component of components) {
|
|
@@ -1041,9 +1095,6 @@ async function classifyTarget(path4) {
|
|
|
1041
1095
|
throw err;
|
|
1042
1096
|
}
|
|
1043
1097
|
}
|
|
1044
|
-
function sha256(content) {
|
|
1045
|
-
return createHash2("sha256").update(content, "utf-8").digest("hex");
|
|
1046
|
-
}
|
|
1047
1098
|
function perSpellDirectory(homeDir, relativePath) {
|
|
1048
1099
|
if (clientOfFanoutPath(relativePath) !== "codex") return void 0;
|
|
1049
1100
|
return dirname(join(homeDir, relativePath));
|
|
@@ -1051,7 +1102,7 @@ function perSpellDirectory(homeDir, relativePath) {
|
|
|
1051
1102
|
async function removeDirectoryIfEmpty(dir) {
|
|
1052
1103
|
try {
|
|
1053
1104
|
const entries = await readdir2(dir);
|
|
1054
|
-
if (entries.length === 0) await
|
|
1105
|
+
if (entries.length === 0) await rmdir2(dir);
|
|
1055
1106
|
} catch {
|
|
1056
1107
|
}
|
|
1057
1108
|
}
|
|
@@ -1072,9 +1123,10 @@ async function syncUserTierFanout(options) {
|
|
|
1072
1123
|
}
|
|
1073
1124
|
}
|
|
1074
1125
|
for (const file of desired) {
|
|
1126
|
+
validateTargetPath(options.homeDir, file.relativePath);
|
|
1075
1127
|
const target = join(options.homeDir, file.relativePath);
|
|
1076
1128
|
const recorded = previous[file.relativePath];
|
|
1077
|
-
const newHash =
|
|
1129
|
+
const newHash = hashContent(file.content);
|
|
1078
1130
|
const state = await classifyTarget(target);
|
|
1079
1131
|
if (state === "absent") {
|
|
1080
1132
|
if (!dryRun) {
|
|
@@ -1089,12 +1141,12 @@ async function syncUserTierFanout(options) {
|
|
|
1089
1141
|
outcomes.push({ relativePath: file.relativePath, status: "collision" });
|
|
1090
1142
|
continue;
|
|
1091
1143
|
}
|
|
1092
|
-
|
|
1093
|
-
if (currentHash !== recorded) {
|
|
1144
|
+
if (!await fileMatchesHash(target, recorded)) {
|
|
1094
1145
|
record[file.relativePath] = recorded;
|
|
1095
1146
|
outcomes.push({ relativePath: file.relativePath, status: "customized" });
|
|
1096
1147
|
continue;
|
|
1097
1148
|
}
|
|
1149
|
+
const currentHash = await hashFile(target);
|
|
1098
1150
|
if (currentHash === newHash) {
|
|
1099
1151
|
record[file.relativePath] = newHash;
|
|
1100
1152
|
outcomes.push({ relativePath: file.relativePath, status: "unchanged" });
|
|
@@ -1106,19 +1158,20 @@ async function syncUserTierFanout(options) {
|
|
|
1106
1158
|
}
|
|
1107
1159
|
for (const [relativePath, recorded] of Object.entries(previous)) {
|
|
1108
1160
|
if (desiredPaths.has(relativePath)) continue;
|
|
1161
|
+
validateTargetPath(options.homeDir, relativePath);
|
|
1109
1162
|
const target = join(options.homeDir, relativePath);
|
|
1110
1163
|
const state = await classifyTarget(target);
|
|
1111
1164
|
if (state === "absent") {
|
|
1112
1165
|
outcomes.push({ relativePath, status: "gone" });
|
|
1113
1166
|
continue;
|
|
1114
1167
|
}
|
|
1115
|
-
if (state === "other" || await
|
|
1168
|
+
if (state === "other" || !await fileMatchesHash(target, recorded)) {
|
|
1116
1169
|
record[relativePath] = recorded;
|
|
1117
1170
|
outcomes.push({ relativePath, status: "kept-edited" });
|
|
1118
1171
|
continue;
|
|
1119
1172
|
}
|
|
1120
1173
|
if (!dryRun) {
|
|
1121
|
-
await
|
|
1174
|
+
await rm2(target, { force: true });
|
|
1122
1175
|
const spellDir = perSpellDirectory(options.homeDir, relativePath);
|
|
1123
1176
|
if (spellDir !== void 0) await removeDirectoryIfEmpty(spellDir);
|
|
1124
1177
|
}
|
|
@@ -1209,7 +1262,7 @@ async function inspectUserTierFanout(homeDir, record) {
|
|
|
1209
1262
|
health.missing.push(relativePath);
|
|
1210
1263
|
continue;
|
|
1211
1264
|
}
|
|
1212
|
-
if (state === "other" || await
|
|
1265
|
+
if (state === "other" || !await fileMatchesHash(target, recorded)) health.customized.push(relativePath);
|
|
1213
1266
|
}
|
|
1214
1267
|
return health;
|
|
1215
1268
|
}
|
|
@@ -1834,7 +1887,7 @@ import { join as join3 } from "path";
|
|
|
1834
1887
|
import { homedir as homedir2 } from "os";
|
|
1835
1888
|
|
|
1836
1889
|
// src/modules/merger.ts
|
|
1837
|
-
import { readFile as readFile5, writeFile as writeFile4, rm as
|
|
1890
|
+
import { readFile as readFile5, writeFile as writeFile4, rm as rm3 } from "fs/promises";
|
|
1838
1891
|
import path3 from "path";
|
|
1839
1892
|
var MARKER_START = "<!-- arcane:start -->";
|
|
1840
1893
|
var MARKER_END = "<!-- arcane:end -->";
|
|
@@ -1920,7 +1973,7 @@ async function stripMarkerSection(targetDir, relativePath, opts = {}) {
|
|
|
1920
1973
|
const stripped = (before + after).replace(/\n{3,}/g, "\n\n").trim();
|
|
1921
1974
|
if (!opts.dryRun) {
|
|
1922
1975
|
if (stripped.length === 0) {
|
|
1923
|
-
await
|
|
1976
|
+
await rm3(destPath, { force: true });
|
|
1924
1977
|
} else {
|
|
1925
1978
|
await writeFile4(destPath, stripped + "\n", "utf8");
|
|
1926
1979
|
}
|
|
@@ -2426,7 +2479,7 @@ async function runAgentsList(targetDir) {
|
|
|
2426
2479
|
}
|
|
2427
2480
|
|
|
2428
2481
|
// src/modules/push-safety.ts
|
|
2429
|
-
import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7, chmod, access as access3, rm as
|
|
2482
|
+
import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7, chmod, access as access3, rm as rm4, readdir as readdir4 } from "fs/promises";
|
|
2430
2483
|
import { constants } from "fs";
|
|
2431
2484
|
import { join as join5, resolve as resolve2, dirname as dirname2, basename } from "path";
|
|
2432
2485
|
|
|
@@ -2843,7 +2896,7 @@ async function removeHook(cwd, hookName) {
|
|
|
2843
2896
|
if (!isArcaneHooksPath(root, existing.value)) return;
|
|
2844
2897
|
if (existing.scope !== "local" && existing.scope !== "worktree") return;
|
|
2845
2898
|
try {
|
|
2846
|
-
await
|
|
2899
|
+
await rm4(await hookFilePath(cwd, hookName), { force: true });
|
|
2847
2900
|
} catch {
|
|
2848
2901
|
}
|
|
2849
2902
|
const hooksDir = join5(root, ARCANE_HOOKS_DIR);
|
|
@@ -3247,16 +3300,35 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3247
3300
|
profile = await select2({
|
|
3248
3301
|
message: "Select an installation profile:\n",
|
|
3249
3302
|
choices: profiles.map((p) => {
|
|
3250
|
-
const
|
|
3303
|
+
const profileComponents2 = getProfile(p.id);
|
|
3251
3304
|
return {
|
|
3252
3305
|
value: p.id,
|
|
3253
3306
|
name: `${PROFILE_ICONS[p.id] ?? "\u{1F4CB}"} ${p.displayName} \u2014 ${p.description}`,
|
|
3254
|
-
description: buildProfileDescription(
|
|
3307
|
+
description: buildProfileDescription(profileComponents2, p.id)
|
|
3255
3308
|
};
|
|
3256
3309
|
})
|
|
3257
3310
|
});
|
|
3258
3311
|
}
|
|
3259
|
-
const
|
|
3312
|
+
const profileComponents = getProfile(profile);
|
|
3313
|
+
let spell_scope;
|
|
3314
|
+
if (!options.profile && !options.dryRun) {
|
|
3315
|
+
let storeVersion;
|
|
3316
|
+
try {
|
|
3317
|
+
storeVersion = (await readManifest(userTierRoot())).version;
|
|
3318
|
+
} catch {
|
|
3319
|
+
}
|
|
3320
|
+
if (storeVersion !== void 0) {
|
|
3321
|
+
console.log();
|
|
3322
|
+
const useTier = await confirm({
|
|
3323
|
+
message: `This machine has an Arcane user tier (v${storeVersion}). Use it for this repository's spells instead of installing copies here?`,
|
|
3324
|
+
default: false
|
|
3325
|
+
});
|
|
3326
|
+
spell_scope = useTier ? "user" : "repo";
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
const components = profileComponents.map(
|
|
3330
|
+
(component) => componentForSpellScope(component, spell_scope ?? "repo")
|
|
3331
|
+
);
|
|
3260
3332
|
const groups = categorizeComponents(components);
|
|
3261
3333
|
console.log();
|
|
3262
3334
|
printSection(`\u{1F4E6} Profile: ${profile}`);
|
|
@@ -3443,6 +3515,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3443
3515
|
profile,
|
|
3444
3516
|
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3445
3517
|
components: installedComponents,
|
|
3518
|
+
...spell_scope ? { spell_scope } : {},
|
|
3446
3519
|
...role ? { role } : {},
|
|
3447
3520
|
...tracking_mode ? { tracking_mode, external_provider } : {},
|
|
3448
3521
|
...subject_root !== void 0 ? { subject_root } : {},
|
|
@@ -3757,7 +3830,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
3757
3830
|
|
|
3758
3831
|
// src/commands/update.ts
|
|
3759
3832
|
import { dirname as dirname4, join as join9 } from "path";
|
|
3760
|
-
import { readFile as readFile7, writeFile as writeFile9
|
|
3833
|
+
import { readFile as readFile7, writeFile as writeFile9 } from "fs/promises";
|
|
3761
3834
|
|
|
3762
3835
|
// src/modules/hub.ts
|
|
3763
3836
|
import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
|
|
@@ -4110,6 +4183,12 @@ function migrateLegacyComponents(components) {
|
|
|
4110
4183
|
return out;
|
|
4111
4184
|
}
|
|
4112
4185
|
async function resolveOrphan(targetDir, file, recordedHash, prune) {
|
|
4186
|
+
try {
|
|
4187
|
+
validateTargetPath(targetDir, file);
|
|
4188
|
+
} catch {
|
|
4189
|
+
console.log(` ! Refused (path escapes the target directory): ${file}`);
|
|
4190
|
+
return "reported";
|
|
4191
|
+
}
|
|
4113
4192
|
const filePath = join9(targetDir, file);
|
|
4114
4193
|
if (!await fileExists(filePath)) return "not-found";
|
|
4115
4194
|
if (!prune) return "reported";
|
|
@@ -4117,32 +4196,62 @@ async function resolveOrphan(targetDir, file, recordedHash, prune) {
|
|
|
4117
4196
|
console.log(` ! Orphaned, not pruned (no recorded hash to verify it's untouched): ${file}`);
|
|
4118
4197
|
return "reported";
|
|
4119
4198
|
}
|
|
4120
|
-
|
|
4121
|
-
if (currentHash !== recordedHash) {
|
|
4199
|
+
if (!await fileMatchesHash(filePath, recordedHash)) {
|
|
4122
4200
|
console.log(` ! Orphaned but edited since install \u2014 not pruning: ${file}`);
|
|
4123
4201
|
return "reported";
|
|
4124
4202
|
}
|
|
4125
|
-
await
|
|
4203
|
+
await removeWithin(targetDir, file);
|
|
4204
|
+
await removeEmptyAncestors(targetDir, file);
|
|
4126
4205
|
console.log(` Pruned orphaned file: ${file}`);
|
|
4127
4206
|
return "pruned";
|
|
4128
4207
|
}
|
|
4129
|
-
|
|
4208
|
+
function findReadoptedSpellComponents(components, scope = "repo", spellScope = "repo") {
|
|
4209
|
+
if (scope !== "repo" || spellScope !== "repo") return /* @__PURE__ */ new Set();
|
|
4210
|
+
return new Set(
|
|
4211
|
+
components.filter((c) => SPELL_COMPONENT_NAMES.includes(c.name) && c.files.length === 0).map((c) => c.name)
|
|
4212
|
+
);
|
|
4213
|
+
}
|
|
4214
|
+
async function findMissingTrackedFiles(targetDir, components, scope = "repo", spellScope = "repo", readopted = /* @__PURE__ */ new Set()) {
|
|
4130
4215
|
const missing = [];
|
|
4131
4216
|
for (const installed of components) {
|
|
4132
4217
|
let component;
|
|
4133
4218
|
try {
|
|
4134
|
-
component =
|
|
4219
|
+
component = componentForSpellScope(
|
|
4220
|
+
componentForScope(getComponent(installed.name), scope),
|
|
4221
|
+
scope === "repo" ? spellScope : "repo"
|
|
4222
|
+
);
|
|
4135
4223
|
} catch {
|
|
4136
4224
|
continue;
|
|
4137
4225
|
}
|
|
4138
4226
|
if (component.initOnly || component.skipExisting) continue;
|
|
4139
|
-
|
|
4227
|
+
const candidates = readopted.has(installed.name) ? component.files : installed.files;
|
|
4228
|
+
for (const file of candidates) {
|
|
4140
4229
|
if (!component.files.includes(file)) continue;
|
|
4141
4230
|
if (!await fileExists(join9(targetDir, file))) missing.push(file);
|
|
4142
4231
|
}
|
|
4143
4232
|
}
|
|
4144
4233
|
return missing;
|
|
4145
4234
|
}
|
|
4235
|
+
function findUnmanagedTrackedFiles(components, scope = "repo", spellScope = "repo") {
|
|
4236
|
+
const unmanaged = [];
|
|
4237
|
+
for (const installed of components) {
|
|
4238
|
+
let component;
|
|
4239
|
+
try {
|
|
4240
|
+
component = componentForSpellScope(
|
|
4241
|
+
componentForScope(getComponent(installed.name), scope),
|
|
4242
|
+
scope === "repo" ? spellScope : "repo"
|
|
4243
|
+
);
|
|
4244
|
+
} catch {
|
|
4245
|
+
unmanaged.push(...installed.files);
|
|
4246
|
+
continue;
|
|
4247
|
+
}
|
|
4248
|
+
const shipped = new Set(component.files);
|
|
4249
|
+
for (const file of installed.files) {
|
|
4250
|
+
if (!shipped.has(file)) unmanaged.push(file);
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
return unmanaged;
|
|
4254
|
+
}
|
|
4146
4255
|
async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
4147
4256
|
let manifest;
|
|
4148
4257
|
try {
|
|
@@ -4164,6 +4273,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4164
4273
|
return;
|
|
4165
4274
|
}
|
|
4166
4275
|
const scope = resolvedScope.scope;
|
|
4276
|
+
const spellScope = effectiveSpellScope(manifest);
|
|
4277
|
+
const readopted = findReadoptedSpellComponents(manifest.components, scope, spellScope);
|
|
4167
4278
|
const homeDir = dirname4(targetDir);
|
|
4168
4279
|
if (scope === "repo") {
|
|
4169
4280
|
console.warn(
|
|
@@ -4197,8 +4308,9 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4197
4308
|
}
|
|
4198
4309
|
let sameVersionRestore = false;
|
|
4199
4310
|
if (manifest.version === packageVersion && manifest.components.length > 0) {
|
|
4200
|
-
const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope);
|
|
4201
|
-
|
|
4311
|
+
const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope, spellScope, readopted);
|
|
4312
|
+
const unmanaged = findUnmanagedTrackedFiles(manifest.components, scope, spellScope);
|
|
4313
|
+
if (missing.length === 0 && unmanaged.length === 0) {
|
|
4202
4314
|
console.log("Already up to date.");
|
|
4203
4315
|
if (scope === "user") {
|
|
4204
4316
|
const fanout = await syncUserTierFanout({
|
|
@@ -4217,9 +4329,16 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4217
4329
|
return;
|
|
4218
4330
|
}
|
|
4219
4331
|
sameVersionRestore = true;
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4332
|
+
const prefix = options.dryRun ? "[dry-run] " : "";
|
|
4333
|
+
if (missing.length > 0) {
|
|
4334
|
+
console.log(
|
|
4335
|
+
`${prefix}Already at v${packageVersion}, but ${missing.length} tracked file${missing.length === 1 ? " is" : "s are"} missing \u2014 restoring.`
|
|
4336
|
+
);
|
|
4337
|
+
} else {
|
|
4338
|
+
console.log(
|
|
4339
|
+
`${prefix}Already at v${packageVersion}, but ${unmanaged.length} tracked file${unmanaged.length === 1 ? " is" : "s are"} no longer managed here \u2014 reviewing.`
|
|
4340
|
+
);
|
|
4341
|
+
}
|
|
4223
4342
|
}
|
|
4224
4343
|
if (manifest.components.length === 0) {
|
|
4225
4344
|
console.log("No components installed. Nothing to update.");
|
|
@@ -4234,7 +4353,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4234
4353
|
for (const installed of componentsToUpdate) {
|
|
4235
4354
|
let component;
|
|
4236
4355
|
try {
|
|
4237
|
-
component =
|
|
4356
|
+
component = componentForSpellScope(
|
|
4357
|
+
componentForScope(getComponent(installed.name), scope),
|
|
4358
|
+
scope === "repo" ? spellScope : "repo"
|
|
4359
|
+
);
|
|
4238
4360
|
} catch (err) {
|
|
4239
4361
|
if (err instanceof ComponentNotFoundError) {
|
|
4240
4362
|
console.log(` ! ${installed.name} not in registry \u2014 skipping.`);
|
|
@@ -4269,7 +4391,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4269
4391
|
continue;
|
|
4270
4392
|
}
|
|
4271
4393
|
if (sameVersionRestore) {
|
|
4272
|
-
const tracked = installed.files.includes(file);
|
|
4394
|
+
const tracked = installed.files.includes(file) || readopted.has(installed.name);
|
|
4273
4395
|
const restorable = tracked && !targetExists && !component.skipExisting;
|
|
4274
4396
|
if (!restorable) {
|
|
4275
4397
|
if (tracked) {
|
|
@@ -4279,7 +4401,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4279
4401
|
continue;
|
|
4280
4402
|
}
|
|
4281
4403
|
}
|
|
4282
|
-
const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && await
|
|
4404
|
+
const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && !await fileMatchesHash(join9(targetDir, file), recordedHash);
|
|
4283
4405
|
if (preserveExisting) {
|
|
4284
4406
|
console.log(` ${options.dryRun ? "[dry-run] Would preserve" : "Preserved"}: ${file}`);
|
|
4285
4407
|
} else if (editedByOperator && isClientShimPath(file)) {
|
|
@@ -4346,13 +4468,24 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4346
4468
|
}
|
|
4347
4469
|
for (const file of installed.files) {
|
|
4348
4470
|
if (updatedFiles.includes(file)) continue;
|
|
4471
|
+
const recordedHash = installed.fileHashes?.[file];
|
|
4349
4472
|
const status = await resolveOrphan(
|
|
4350
4473
|
targetDir,
|
|
4351
4474
|
file,
|
|
4352
|
-
|
|
4475
|
+
recordedHash,
|
|
4353
4476
|
Boolean(options.prune) && !options.dryRun
|
|
4354
4477
|
);
|
|
4355
4478
|
orphanReport.push({ file, status });
|
|
4479
|
+
if (status === "reported") {
|
|
4480
|
+
try {
|
|
4481
|
+
validateTargetPath(targetDir, file);
|
|
4482
|
+
if (await fileExists(join9(targetDir, file))) {
|
|
4483
|
+
updatedFiles.push(file);
|
|
4484
|
+
if (recordedHash !== void 0) fileHashes[file] = recordedHash;
|
|
4485
|
+
}
|
|
4486
|
+
} catch {
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
4356
4489
|
}
|
|
4357
4490
|
updatedComponents.push({
|
|
4358
4491
|
...installed,
|
|
@@ -4372,6 +4505,11 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
|
|
|
4372
4505
|
const marker = status === "pruned" ? "pruned" : options.prune ? "kept (see reason above)" : "not removed";
|
|
4373
4506
|
console.log(` ${file} \u2014 ${marker}`);
|
|
4374
4507
|
}
|
|
4508
|
+
if (scope === "repo" && spellScope === "user") {
|
|
4509
|
+
console.log(
|
|
4510
|
+
' This repository takes its spells from the user tier (spell_scope: "user"), so the files above are no longer managed here \u2014 every client reads ~/.arcane instead.'
|
|
4511
|
+
);
|
|
4512
|
+
}
|
|
4375
4513
|
if (!options.prune) {
|
|
4376
4514
|
console.log(" Run `spell update --prune` to remove the ones that are safe to delete.");
|
|
4377
4515
|
}
|
|
@@ -4638,12 +4776,18 @@ async function runStatus(targetDir, packageVersion, options = {}) {
|
|
|
4638
4776
|
);
|
|
4639
4777
|
}
|
|
4640
4778
|
} else {
|
|
4641
|
-
|
|
4779
|
+
const spellScope = effectiveSpellScope(manifest);
|
|
4780
|
+
console.log(spellScope === "user" ? " Scope: repo (spells: user tier)" : " Scope: repo");
|
|
4781
|
+
const store = userTierRoot();
|
|
4642
4782
|
try {
|
|
4643
|
-
const store = userTierRoot();
|
|
4644
4783
|
const userManifest = await readManifest(store);
|
|
4645
4784
|
console.log(` User tier: v${userManifest.version} at ${store} (spell status --user)`);
|
|
4646
4785
|
} catch {
|
|
4786
|
+
if (spellScope === "user") {
|
|
4787
|
+
console.log(
|
|
4788
|
+
` ! User tier: not installed at ${store} \u2014 this repository expects it for every spell. Run \`spell init --user\` (\`spell doctor\` explains).`
|
|
4789
|
+
);
|
|
4790
|
+
}
|
|
4647
4791
|
}
|
|
4648
4792
|
}
|
|
4649
4793
|
console.log("");
|
|
@@ -4755,11 +4899,13 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
|
|
|
4755
4899
|
let removed = 0;
|
|
4756
4900
|
for (const component of manifest.components) {
|
|
4757
4901
|
for (const file of component.files) {
|
|
4758
|
-
const filePath = join11(targetDir, file);
|
|
4759
4902
|
try {
|
|
4760
|
-
await
|
|
4903
|
+
await removeWithin(targetDir, file);
|
|
4761
4904
|
removed++;
|
|
4762
|
-
} catch {
|
|
4905
|
+
} catch (err) {
|
|
4906
|
+
console.log(
|
|
4907
|
+
` ! Skipped ${file}: ${err instanceof Error ? err.message : String(err)}`
|
|
4908
|
+
);
|
|
4763
4909
|
}
|
|
4764
4910
|
}
|
|
4765
4911
|
}
|
|
@@ -4827,8 +4973,12 @@ async function uninstallUserTier(options, storeRoot, manifest) {
|
|
|
4827
4973
|
});
|
|
4828
4974
|
let removed = 0;
|
|
4829
4975
|
for (const file of storeFiles) {
|
|
4830
|
-
|
|
4831
|
-
|
|
4976
|
+
try {
|
|
4977
|
+
await removeWithin(storeRoot, file);
|
|
4978
|
+
removed++;
|
|
4979
|
+
} catch (err) {
|
|
4980
|
+
console.log(` ! Skipped ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
4981
|
+
}
|
|
4832
4982
|
}
|
|
4833
4983
|
await rm5(join11(storeRoot, ".arcane.json"), { force: true });
|
|
4834
4984
|
await removeDirectoryIfEmpty(join11(storeRoot, USER_TIER_SPELLS_DIR));
|
|
@@ -4843,7 +4993,7 @@ async function uninstallUserTier(options, storeRoot, manifest) {
|
|
|
4843
4993
|
}
|
|
4844
4994
|
|
|
4845
4995
|
// src/commands/doctor.ts
|
|
4846
|
-
import { access as access4, readFile as readFile10, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
|
|
4996
|
+
import { access as access4, readdir as readdir7, readFile as readFile10, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
|
|
4847
4997
|
import { join as join13, dirname as dirname8 } from "path";
|
|
4848
4998
|
import { execFile as execFile3 } from "child_process";
|
|
4849
4999
|
import { promisify as promisify2 } from "util";
|
|
@@ -5183,8 +5333,8 @@ async function checkVSCodeExtension(id, label) {
|
|
|
5183
5333
|
];
|
|
5184
5334
|
for (const dir of extDirs) {
|
|
5185
5335
|
try {
|
|
5186
|
-
const { readdir:
|
|
5187
|
-
const entries = await
|
|
5336
|
+
const { readdir: readdir10 } = await import("fs/promises");
|
|
5337
|
+
const entries = await readdir10(dir);
|
|
5188
5338
|
const prefix = id.toLowerCase() + "-";
|
|
5189
5339
|
const found = entries.some(
|
|
5190
5340
|
(e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
|
|
@@ -5608,6 +5758,66 @@ async function checkPlatformBranchPolicy(targetDir) {
|
|
|
5608
5758
|
message: "remote is neither GitHub nor Azure DevOps \u2014 platform policy verification not available for this provider"
|
|
5609
5759
|
};
|
|
5610
5760
|
}
|
|
5761
|
+
async function checkSpellScope(targetDir, homeDir = resolveHomeDir()) {
|
|
5762
|
+
const name = "Spell scope (ARC-045)";
|
|
5763
|
+
let manifest;
|
|
5764
|
+
try {
|
|
5765
|
+
manifest = await readManifest(targetDir);
|
|
5766
|
+
} catch {
|
|
5767
|
+
return { name, passed: true, blocking: false, message: "no .arcane.json \u2014 nothing to check" };
|
|
5768
|
+
}
|
|
5769
|
+
if (effectiveSpellScope(manifest) === "repo") {
|
|
5770
|
+
return {
|
|
5771
|
+
name,
|
|
5772
|
+
passed: true,
|
|
5773
|
+
blocking: false,
|
|
5774
|
+
message: "repo \u2014 this repository carries its own spells (the default)"
|
|
5775
|
+
};
|
|
5776
|
+
}
|
|
5777
|
+
const storeRoot = userTierRoot(homeDir);
|
|
5778
|
+
let store;
|
|
5779
|
+
try {
|
|
5780
|
+
store = await readManifest(storeRoot);
|
|
5781
|
+
} catch (err) {
|
|
5782
|
+
return {
|
|
5783
|
+
name,
|
|
5784
|
+
passed: false,
|
|
5785
|
+
message: `this repository takes its spells from the user tier (spell_scope: "user"), but ${storeRoot} ${err instanceof ManifestNotFoundError ? "is not installed" : `could not be read (${err instanceof Error ? err.message : String(err)})`} \u2014 no client can reach a spell here. Run \`spell init --user\`, or set spell_scope back to "repo" and run \`spell update\`.`
|
|
5786
|
+
};
|
|
5787
|
+
}
|
|
5788
|
+
let spellCount = 0;
|
|
5789
|
+
let probed = false;
|
|
5790
|
+
const probe = "spell-status.md";
|
|
5791
|
+
try {
|
|
5792
|
+
const entries = await readdir7(join13(storeRoot, USER_TIER_SPELLS_DIR));
|
|
5793
|
+
const spells = entries.filter((e) => e.startsWith("spell-") && e.endsWith(".md"));
|
|
5794
|
+
spellCount = spells.length;
|
|
5795
|
+
probed = spells.includes(probe);
|
|
5796
|
+
} catch {
|
|
5797
|
+
}
|
|
5798
|
+
if (spellCount === 0 || !probed) {
|
|
5799
|
+
return {
|
|
5800
|
+
name,
|
|
5801
|
+
passed: false,
|
|
5802
|
+
message: `the user tier at ${storeRoot} holds ${spellCount} spell(s)${spellCount > 0 && !probed ? ` and not ${probe}` : ""} \u2014 this repository expects it to supply every spell. Run \`spell update --user\`.`
|
|
5803
|
+
};
|
|
5804
|
+
}
|
|
5805
|
+
const majorMinor = (v) => v.split(".").slice(0, 2).join(".");
|
|
5806
|
+
if (typeof manifest.version === "string" && majorMinor(store.version) !== majorMinor(manifest.version)) {
|
|
5807
|
+
return {
|
|
5808
|
+
name,
|
|
5809
|
+
passed: false,
|
|
5810
|
+
blocking: false,
|
|
5811
|
+
message: `this repository is at v${manifest.version} but the user tier it uses is at v${store.version} \u2014 run \`spell update --user\` (and \`spell update\` here) to bring them together.`
|
|
5812
|
+
};
|
|
5813
|
+
}
|
|
5814
|
+
return {
|
|
5815
|
+
name,
|
|
5816
|
+
passed: true,
|
|
5817
|
+
blocking: false,
|
|
5818
|
+
message: `user \u2014 ${spellCount} spell(s) from ${storeRoot} (v${store.version}), ${probe} present`
|
|
5819
|
+
};
|
|
5820
|
+
}
|
|
5611
5821
|
async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
5612
5822
|
console.log("\nspell doctor \u2014 checking your Arcane environment\n");
|
|
5613
5823
|
if (options.leaks) {
|
|
@@ -5633,7 +5843,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
5633
5843
|
checkPlatformBranchPolicy(targetDir),
|
|
5634
5844
|
checkDelegations(targetDir),
|
|
5635
5845
|
checkMcpConfig(targetDir),
|
|
5636
|
-
checkUserTier(resolveHomeDir(), options.packageVersion)
|
|
5846
|
+
checkUserTier(resolveHomeDir(), options.packageVersion),
|
|
5847
|
+
checkSpellScope(targetDir)
|
|
5637
5848
|
]);
|
|
5638
5849
|
const sessionResults = await checkSessionContinuity(targetDir);
|
|
5639
5850
|
results.push(...sessionResults);
|
|
@@ -5681,7 +5892,7 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
5681
5892
|
import { readFile as readFile12 } from "fs/promises";
|
|
5682
5893
|
|
|
5683
5894
|
// src/modules/ward.ts
|
|
5684
|
-
import { readdir as
|
|
5895
|
+
import { readdir as readdir8, readFile as readFile11 } from "fs/promises";
|
|
5685
5896
|
import { join as join14, relative as relative2 } from "path";
|
|
5686
5897
|
async function scanFileAllRules(absolutePath, displayPath, rules) {
|
|
5687
5898
|
const findings = [];
|
|
@@ -5740,7 +5951,7 @@ async function scanFilenames(root, rules, repoRoot = root) {
|
|
|
5740
5951
|
const findings = [];
|
|
5741
5952
|
let entries;
|
|
5742
5953
|
try {
|
|
5743
|
-
entries = await
|
|
5954
|
+
entries = await readdir8(root, { withFileTypes: true });
|
|
5744
5955
|
} catch {
|
|
5745
5956
|
return findings;
|
|
5746
5957
|
}
|
|
@@ -5962,7 +6173,7 @@ import { access as access5 } from "fs/promises";
|
|
|
5962
6173
|
import { join as join17 } from "path";
|
|
5963
6174
|
|
|
5964
6175
|
// src/modules/show-report/generate.ts
|
|
5965
|
-
import { mkdir as mkdir8, readdir as
|
|
6176
|
+
import { mkdir as mkdir8, readdir as readdir9, readFile as readFile14, writeFile as writeFile11 } from "fs/promises";
|
|
5966
6177
|
import { basename as basename3, dirname as dirname9, isAbsolute, join as join16, relative as relative3, resolve as resolve4 } from "path";
|
|
5967
6178
|
|
|
5968
6179
|
// src/modules/show-report/model.ts
|
|
@@ -6199,9 +6410,10 @@ async function getVersionAtRef(cwd, ref) {
|
|
|
6199
6410
|
return null;
|
|
6200
6411
|
}
|
|
6201
6412
|
}
|
|
6202
|
-
async function getCloseCommit(cwd, completedDate) {
|
|
6413
|
+
async function getCloseCommit(cwd, completedDate, generatedOutputs = []) {
|
|
6203
6414
|
if (!completedDate) {
|
|
6204
|
-
const
|
|
6415
|
+
const args = generatedOutputs.length === 0 ? ["rev-parse", "HEAD"] : ["log", "-1", "--format=%H", "HEAD", "--", ".", ...generatedOutputs.map((p) => `:(exclude)${p}`)];
|
|
6416
|
+
const head = await runGitTextOrNull(cwd, args);
|
|
6205
6417
|
return head === null || head === "" ? null : head;
|
|
6206
6418
|
}
|
|
6207
6419
|
const log = await runGitTextOrNull(cwd, ["log", "--format=%H%x09%cs", "HEAD"]);
|
|
@@ -6212,13 +6424,14 @@ async function getCloseCommit(cwd, completedDate) {
|
|
|
6212
6424
|
}
|
|
6213
6425
|
return null;
|
|
6214
6426
|
}
|
|
6215
|
-
async function getCast(cwd, fromRef, toRef) {
|
|
6427
|
+
async function getCast(cwd, fromRef, toRef, generatedOutputs = []) {
|
|
6216
6428
|
const ETX = "";
|
|
6217
6429
|
const log = await runGitTextOrNull(cwd, [
|
|
6218
6430
|
"log",
|
|
6219
6431
|
"--no-merges",
|
|
6220
6432
|
`--format=%B${ETX}`,
|
|
6221
|
-
`${fromRef}..${toRef}
|
|
6433
|
+
`${fromRef}..${toRef}`,
|
|
6434
|
+
...generatedOutputs.length > 0 ? ["--", ".", ...generatedOutputs.map((p) => `:(exclude)${p}`)] : []
|
|
6222
6435
|
]);
|
|
6223
6436
|
const cast = /* @__PURE__ */ new Map();
|
|
6224
6437
|
if (log === null || log === "") return cast;
|
|
@@ -6357,12 +6570,18 @@ async function buildShowReportModel(options) {
|
|
|
6357
6570
|
const cast = /* @__PURE__ */ new Map();
|
|
6358
6571
|
if (frontmatter.baseline) {
|
|
6359
6572
|
const baselineSha = extractSha(frontmatter.baseline);
|
|
6360
|
-
const
|
|
6573
|
+
const reportDir = options.planRelPath.split("/").slice(0, -1).join("/");
|
|
6574
|
+
const generatedOutputs = ["show-report.json", "show-report.html"].map(
|
|
6575
|
+
(f) => reportDir ? `${reportDir}/${f}` : f
|
|
6576
|
+
);
|
|
6577
|
+
const closeSha = await getCloseCommit(options.rootDir, frontmatter.completed, generatedOutputs);
|
|
6361
6578
|
if (closeSha) {
|
|
6362
6579
|
const [from, to, castMap] = await Promise.all([
|
|
6363
6580
|
getVersionAtRef(options.rootDir, baselineSha),
|
|
6364
6581
|
getVersionAtRef(options.rootDir, closeSha),
|
|
6365
|
-
|
|
6582
|
+
// Regeneration-only commits are never part of the cast, for a closed
|
|
6583
|
+
// program as much as an active one.
|
|
6584
|
+
getCast(options.rootDir, baselineSha, closeSha, generatedOutputs)
|
|
6366
6585
|
]);
|
|
6367
6586
|
if (from && to) versionSpan = { from, to };
|
|
6368
6587
|
for (const [name, count] of castMap) cast.set(name, count);
|
|
@@ -6521,7 +6740,7 @@ async function discoverPrograms(rootDir) {
|
|
|
6521
6740
|
const plansDir = join16(rootDir, "docs", "plans");
|
|
6522
6741
|
let entries;
|
|
6523
6742
|
try {
|
|
6524
|
-
entries = (await
|
|
6743
|
+
entries = (await readdir9(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
|
|
6525
6744
|
} catch (error) {
|
|
6526
6745
|
if (error.code === "ENOENT") return { programs: [], skipped: [] };
|
|
6527
6746
|
throw error;
|
|
@@ -6540,7 +6759,7 @@ async function programFromPlanPath(rootDir, planPath) {
|
|
|
6540
6759
|
const planRelPath = toPosix(relative3(rootDir, planAbs));
|
|
6541
6760
|
return programFromPlan(rootDir, basename3(dirname9(planAbs)), planRelPath);
|
|
6542
6761
|
}
|
|
6543
|
-
function
|
|
6762
|
+
function normalizeLineEndings2(content) {
|
|
6544
6763
|
return content.replace(/\r\n?/g, "\n");
|
|
6545
6764
|
}
|
|
6546
6765
|
async function readOptionalFile(path4) {
|
|
@@ -6586,7 +6805,7 @@ async function runReportGeneration(options) {
|
|
|
6586
6805
|
];
|
|
6587
6806
|
for (const [relPath, expected] of outputs) {
|
|
6588
6807
|
const actual = await readOptionalFile(join16(options.rootDir, relPath));
|
|
6589
|
-
if (actual === null ||
|
|
6808
|
+
if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
|
|
6590
6809
|
drifted.push(`${relPath} does not match a regeneration from its sources.`);
|
|
6591
6810
|
if (options.mode === "write") {
|
|
6592
6811
|
await mkdir8(outAbs, { recursive: true });
|