arcane-cli 1.1.0 → 1.1.1
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/dist/index.js +82 -36
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -19,13 +19,37 @@ import {
|
|
|
19
19
|
access,
|
|
20
20
|
readdir,
|
|
21
21
|
readFile,
|
|
22
|
+
rm,
|
|
22
23
|
writeFile
|
|
23
24
|
} from "fs/promises";
|
|
24
25
|
import { createHash } from "crypto";
|
|
25
26
|
import path from "path";
|
|
27
|
+
function normalizeLineEndings(content) {
|
|
28
|
+
if (!content.includes(13)) return content;
|
|
29
|
+
const out = Buffer.allocUnsafe(content.length);
|
|
30
|
+
let o = 0;
|
|
31
|
+
for (let i = 0; i < content.length; i++) {
|
|
32
|
+
const byte = content[i];
|
|
33
|
+
if (byte === 13) {
|
|
34
|
+
out[o++] = 10;
|
|
35
|
+
if (content[i + 1] === 10) i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
out[o++] = byte;
|
|
39
|
+
}
|
|
40
|
+
return out.subarray(0, o);
|
|
41
|
+
}
|
|
42
|
+
function hashContent(content) {
|
|
43
|
+
const bytes = typeof content === "string" ? Buffer.from(content, "utf-8") : content;
|
|
44
|
+
return createHash("sha256").update(normalizeLineEndings(bytes)).digest("hex");
|
|
45
|
+
}
|
|
26
46
|
async function hashFile(filePath) {
|
|
47
|
+
return hashContent(await readFile(filePath));
|
|
48
|
+
}
|
|
49
|
+
async function fileMatchesHash(filePath, recordedHash) {
|
|
27
50
|
const content = await readFile(filePath);
|
|
28
|
-
|
|
51
|
+
if (hashContent(content) === recordedHash) return true;
|
|
52
|
+
return createHash("sha256").update(content).digest("hex") === recordedHash;
|
|
29
53
|
}
|
|
30
54
|
function validateTargetPath(targetDir, relativePath) {
|
|
31
55
|
const resolvedBase = path.resolve(targetDir);
|
|
@@ -57,10 +81,14 @@ async function copyFile(srcPath, targetDir, relativePath, opts = {}) {
|
|
|
57
81
|
);
|
|
58
82
|
}
|
|
59
83
|
const content = await readFile(srcPath);
|
|
60
|
-
const hash =
|
|
84
|
+
const hash = hashContent(content);
|
|
61
85
|
await writeFile(destPath, content);
|
|
62
86
|
return hash;
|
|
63
87
|
}
|
|
88
|
+
async function removeWithin(targetDir, relativePath) {
|
|
89
|
+
validateTargetPath(targetDir, relativePath);
|
|
90
|
+
await rm(path.resolve(targetDir, relativePath), { force: true });
|
|
91
|
+
}
|
|
64
92
|
async function copyDirectory(srcDir, targetDir, relativeDir, opts = {}) {
|
|
65
93
|
validateTargetPath(targetDir, relativeDir);
|
|
66
94
|
const copiedFiles = [];
|
|
@@ -838,8 +866,7 @@ function listProfiles() {
|
|
|
838
866
|
}
|
|
839
867
|
|
|
840
868
|
// 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";
|
|
869
|
+
import { lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rm as rm2, rmdir, writeFile as writeFile3 } from "fs/promises";
|
|
843
870
|
import { homedir } from "os";
|
|
844
871
|
import { dirname, join, resolve } from "path";
|
|
845
872
|
|
|
@@ -902,6 +929,7 @@ function renderClaudeCommandStub(id, frontmatter, canonicalRef = canonicalSpellP
|
|
|
902
929
|
const title = deriveStubTitle(frontmatter.name);
|
|
903
930
|
const description = frontmatter.claudeDescription ?? frontmatter.description;
|
|
904
931
|
const promptPath = canonicalRef;
|
|
932
|
+
const includeRef = /\s/.test(promptPath) ? `"${promptPath}"` : promptPath;
|
|
905
933
|
return `---
|
|
906
934
|
description: ${description}
|
|
907
935
|
---
|
|
@@ -914,7 +942,7 @@ See the full prompt at \`${promptPath}\` for the complete workflow definition.
|
|
|
914
942
|
|
|
915
943
|
---
|
|
916
944
|
|
|
917
|
-
@${
|
|
945
|
+
@${includeRef}
|
|
918
946
|
`;
|
|
919
947
|
}
|
|
920
948
|
var SKILL_NAME_PATTERN = /^[a-z0-9-]+$/;
|
|
@@ -1041,9 +1069,6 @@ async function classifyTarget(path4) {
|
|
|
1041
1069
|
throw err;
|
|
1042
1070
|
}
|
|
1043
1071
|
}
|
|
1044
|
-
function sha256(content) {
|
|
1045
|
-
return createHash2("sha256").update(content, "utf-8").digest("hex");
|
|
1046
|
-
}
|
|
1047
1072
|
function perSpellDirectory(homeDir, relativePath) {
|
|
1048
1073
|
if (clientOfFanoutPath(relativePath) !== "codex") return void 0;
|
|
1049
1074
|
return dirname(join(homeDir, relativePath));
|
|
@@ -1072,9 +1097,10 @@ async function syncUserTierFanout(options) {
|
|
|
1072
1097
|
}
|
|
1073
1098
|
}
|
|
1074
1099
|
for (const file of desired) {
|
|
1100
|
+
validateTargetPath(options.homeDir, file.relativePath);
|
|
1075
1101
|
const target = join(options.homeDir, file.relativePath);
|
|
1076
1102
|
const recorded = previous[file.relativePath];
|
|
1077
|
-
const newHash =
|
|
1103
|
+
const newHash = hashContent(file.content);
|
|
1078
1104
|
const state = await classifyTarget(target);
|
|
1079
1105
|
if (state === "absent") {
|
|
1080
1106
|
if (!dryRun) {
|
|
@@ -1089,12 +1115,12 @@ async function syncUserTierFanout(options) {
|
|
|
1089
1115
|
outcomes.push({ relativePath: file.relativePath, status: "collision" });
|
|
1090
1116
|
continue;
|
|
1091
1117
|
}
|
|
1092
|
-
|
|
1093
|
-
if (currentHash !== recorded) {
|
|
1118
|
+
if (!await fileMatchesHash(target, recorded)) {
|
|
1094
1119
|
record[file.relativePath] = recorded;
|
|
1095
1120
|
outcomes.push({ relativePath: file.relativePath, status: "customized" });
|
|
1096
1121
|
continue;
|
|
1097
1122
|
}
|
|
1123
|
+
const currentHash = await hashFile(target);
|
|
1098
1124
|
if (currentHash === newHash) {
|
|
1099
1125
|
record[file.relativePath] = newHash;
|
|
1100
1126
|
outcomes.push({ relativePath: file.relativePath, status: "unchanged" });
|
|
@@ -1106,19 +1132,20 @@ async function syncUserTierFanout(options) {
|
|
|
1106
1132
|
}
|
|
1107
1133
|
for (const [relativePath, recorded] of Object.entries(previous)) {
|
|
1108
1134
|
if (desiredPaths.has(relativePath)) continue;
|
|
1135
|
+
validateTargetPath(options.homeDir, relativePath);
|
|
1109
1136
|
const target = join(options.homeDir, relativePath);
|
|
1110
1137
|
const state = await classifyTarget(target);
|
|
1111
1138
|
if (state === "absent") {
|
|
1112
1139
|
outcomes.push({ relativePath, status: "gone" });
|
|
1113
1140
|
continue;
|
|
1114
1141
|
}
|
|
1115
|
-
if (state === "other" || await
|
|
1142
|
+
if (state === "other" || !await fileMatchesHash(target, recorded)) {
|
|
1116
1143
|
record[relativePath] = recorded;
|
|
1117
1144
|
outcomes.push({ relativePath, status: "kept-edited" });
|
|
1118
1145
|
continue;
|
|
1119
1146
|
}
|
|
1120
1147
|
if (!dryRun) {
|
|
1121
|
-
await
|
|
1148
|
+
await rm2(target, { force: true });
|
|
1122
1149
|
const spellDir = perSpellDirectory(options.homeDir, relativePath);
|
|
1123
1150
|
if (spellDir !== void 0) await removeDirectoryIfEmpty(spellDir);
|
|
1124
1151
|
}
|
|
@@ -1209,7 +1236,7 @@ async function inspectUserTierFanout(homeDir, record) {
|
|
|
1209
1236
|
health.missing.push(relativePath);
|
|
1210
1237
|
continue;
|
|
1211
1238
|
}
|
|
1212
|
-
if (state === "other" || await
|
|
1239
|
+
if (state === "other" || !await fileMatchesHash(target, recorded)) health.customized.push(relativePath);
|
|
1213
1240
|
}
|
|
1214
1241
|
return health;
|
|
1215
1242
|
}
|
|
@@ -1834,7 +1861,7 @@ import { join as join3 } from "path";
|
|
|
1834
1861
|
import { homedir as homedir2 } from "os";
|
|
1835
1862
|
|
|
1836
1863
|
// src/modules/merger.ts
|
|
1837
|
-
import { readFile as readFile5, writeFile as writeFile4, rm as
|
|
1864
|
+
import { readFile as readFile5, writeFile as writeFile4, rm as rm3 } from "fs/promises";
|
|
1838
1865
|
import path3 from "path";
|
|
1839
1866
|
var MARKER_START = "<!-- arcane:start -->";
|
|
1840
1867
|
var MARKER_END = "<!-- arcane:end -->";
|
|
@@ -1920,7 +1947,7 @@ async function stripMarkerSection(targetDir, relativePath, opts = {}) {
|
|
|
1920
1947
|
const stripped = (before + after).replace(/\n{3,}/g, "\n\n").trim();
|
|
1921
1948
|
if (!opts.dryRun) {
|
|
1922
1949
|
if (stripped.length === 0) {
|
|
1923
|
-
await
|
|
1950
|
+
await rm3(destPath, { force: true });
|
|
1924
1951
|
} else {
|
|
1925
1952
|
await writeFile4(destPath, stripped + "\n", "utf8");
|
|
1926
1953
|
}
|
|
@@ -2426,7 +2453,7 @@ async function runAgentsList(targetDir) {
|
|
|
2426
2453
|
}
|
|
2427
2454
|
|
|
2428
2455
|
// src/modules/push-safety.ts
|
|
2429
|
-
import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7, chmod, access as access3, rm as
|
|
2456
|
+
import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7, chmod, access as access3, rm as rm4, readdir as readdir4 } from "fs/promises";
|
|
2430
2457
|
import { constants } from "fs";
|
|
2431
2458
|
import { join as join5, resolve as resolve2, dirname as dirname2, basename } from "path";
|
|
2432
2459
|
|
|
@@ -2843,7 +2870,7 @@ async function removeHook(cwd, hookName) {
|
|
|
2843
2870
|
if (!isArcaneHooksPath(root, existing.value)) return;
|
|
2844
2871
|
if (existing.scope !== "local" && existing.scope !== "worktree") return;
|
|
2845
2872
|
try {
|
|
2846
|
-
await
|
|
2873
|
+
await rm4(await hookFilePath(cwd, hookName), { force: true });
|
|
2847
2874
|
} catch {
|
|
2848
2875
|
}
|
|
2849
2876
|
const hooksDir = join5(root, ARCANE_HOOKS_DIR);
|
|
@@ -3757,7 +3784,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
3757
3784
|
|
|
3758
3785
|
// src/commands/update.ts
|
|
3759
3786
|
import { dirname as dirname4, join as join9 } from "path";
|
|
3760
|
-
import { readFile as readFile7, writeFile as writeFile9
|
|
3787
|
+
import { readFile as readFile7, writeFile as writeFile9 } from "fs/promises";
|
|
3761
3788
|
|
|
3762
3789
|
// src/modules/hub.ts
|
|
3763
3790
|
import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
|
|
@@ -4110,6 +4137,12 @@ function migrateLegacyComponents(components) {
|
|
|
4110
4137
|
return out;
|
|
4111
4138
|
}
|
|
4112
4139
|
async function resolveOrphan(targetDir, file, recordedHash, prune) {
|
|
4140
|
+
try {
|
|
4141
|
+
validateTargetPath(targetDir, file);
|
|
4142
|
+
} catch {
|
|
4143
|
+
console.log(` ! Refused (path escapes the target directory): ${file}`);
|
|
4144
|
+
return "reported";
|
|
4145
|
+
}
|
|
4113
4146
|
const filePath = join9(targetDir, file);
|
|
4114
4147
|
if (!await fileExists(filePath)) return "not-found";
|
|
4115
4148
|
if (!prune) return "reported";
|
|
@@ -4117,12 +4150,11 @@ async function resolveOrphan(targetDir, file, recordedHash, prune) {
|
|
|
4117
4150
|
console.log(` ! Orphaned, not pruned (no recorded hash to verify it's untouched): ${file}`);
|
|
4118
4151
|
return "reported";
|
|
4119
4152
|
}
|
|
4120
|
-
|
|
4121
|
-
if (currentHash !== recordedHash) {
|
|
4153
|
+
if (!await fileMatchesHash(filePath, recordedHash)) {
|
|
4122
4154
|
console.log(` ! Orphaned but edited since install \u2014 not pruning: ${file}`);
|
|
4123
4155
|
return "reported";
|
|
4124
4156
|
}
|
|
4125
|
-
await
|
|
4157
|
+
await removeWithin(targetDir, file);
|
|
4126
4158
|
console.log(` Pruned orphaned file: ${file}`);
|
|
4127
4159
|
return "pruned";
|
|
4128
4160
|
}
|
|
@@ -4279,7 +4311,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4279
4311
|
continue;
|
|
4280
4312
|
}
|
|
4281
4313
|
}
|
|
4282
|
-
const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && await
|
|
4314
|
+
const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && !await fileMatchesHash(join9(targetDir, file), recordedHash);
|
|
4283
4315
|
if (preserveExisting) {
|
|
4284
4316
|
console.log(` ${options.dryRun ? "[dry-run] Would preserve" : "Preserved"}: ${file}`);
|
|
4285
4317
|
} else if (editedByOperator && isClientShimPath(file)) {
|
|
@@ -4755,11 +4787,13 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
|
|
|
4755
4787
|
let removed = 0;
|
|
4756
4788
|
for (const component of manifest.components) {
|
|
4757
4789
|
for (const file of component.files) {
|
|
4758
|
-
const filePath = join11(targetDir, file);
|
|
4759
4790
|
try {
|
|
4760
|
-
await
|
|
4791
|
+
await removeWithin(targetDir, file);
|
|
4761
4792
|
removed++;
|
|
4762
|
-
} catch {
|
|
4793
|
+
} catch (err) {
|
|
4794
|
+
console.log(
|
|
4795
|
+
` ! Skipped ${file}: ${err instanceof Error ? err.message : String(err)}`
|
|
4796
|
+
);
|
|
4763
4797
|
}
|
|
4764
4798
|
}
|
|
4765
4799
|
}
|
|
@@ -4827,8 +4861,12 @@ async function uninstallUserTier(options, storeRoot, manifest) {
|
|
|
4827
4861
|
});
|
|
4828
4862
|
let removed = 0;
|
|
4829
4863
|
for (const file of storeFiles) {
|
|
4830
|
-
|
|
4831
|
-
|
|
4864
|
+
try {
|
|
4865
|
+
await removeWithin(storeRoot, file);
|
|
4866
|
+
removed++;
|
|
4867
|
+
} catch (err) {
|
|
4868
|
+
console.log(` ! Skipped ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
4869
|
+
}
|
|
4832
4870
|
}
|
|
4833
4871
|
await rm5(join11(storeRoot, ".arcane.json"), { force: true });
|
|
4834
4872
|
await removeDirectoryIfEmpty(join11(storeRoot, USER_TIER_SPELLS_DIR));
|
|
@@ -6199,9 +6237,10 @@ async function getVersionAtRef(cwd, ref) {
|
|
|
6199
6237
|
return null;
|
|
6200
6238
|
}
|
|
6201
6239
|
}
|
|
6202
|
-
async function getCloseCommit(cwd, completedDate) {
|
|
6240
|
+
async function getCloseCommit(cwd, completedDate, generatedOutputs = []) {
|
|
6203
6241
|
if (!completedDate) {
|
|
6204
|
-
const
|
|
6242
|
+
const args = generatedOutputs.length === 0 ? ["rev-parse", "HEAD"] : ["log", "-1", "--format=%H", "HEAD", "--", ".", ...generatedOutputs.map((p) => `:(exclude)${p}`)];
|
|
6243
|
+
const head = await runGitTextOrNull(cwd, args);
|
|
6205
6244
|
return head === null || head === "" ? null : head;
|
|
6206
6245
|
}
|
|
6207
6246
|
const log = await runGitTextOrNull(cwd, ["log", "--format=%H%x09%cs", "HEAD"]);
|
|
@@ -6212,13 +6251,14 @@ async function getCloseCommit(cwd, completedDate) {
|
|
|
6212
6251
|
}
|
|
6213
6252
|
return null;
|
|
6214
6253
|
}
|
|
6215
|
-
async function getCast(cwd, fromRef, toRef) {
|
|
6254
|
+
async function getCast(cwd, fromRef, toRef, generatedOutputs = []) {
|
|
6216
6255
|
const ETX = "";
|
|
6217
6256
|
const log = await runGitTextOrNull(cwd, [
|
|
6218
6257
|
"log",
|
|
6219
6258
|
"--no-merges",
|
|
6220
6259
|
`--format=%B${ETX}`,
|
|
6221
|
-
`${fromRef}..${toRef}
|
|
6260
|
+
`${fromRef}..${toRef}`,
|
|
6261
|
+
...generatedOutputs.length > 0 ? ["--", ".", ...generatedOutputs.map((p) => `:(exclude)${p}`)] : []
|
|
6222
6262
|
]);
|
|
6223
6263
|
const cast = /* @__PURE__ */ new Map();
|
|
6224
6264
|
if (log === null || log === "") return cast;
|
|
@@ -6357,12 +6397,18 @@ async function buildShowReportModel(options) {
|
|
|
6357
6397
|
const cast = /* @__PURE__ */ new Map();
|
|
6358
6398
|
if (frontmatter.baseline) {
|
|
6359
6399
|
const baselineSha = extractSha(frontmatter.baseline);
|
|
6360
|
-
const
|
|
6400
|
+
const reportDir = options.planRelPath.split("/").slice(0, -1).join("/");
|
|
6401
|
+
const generatedOutputs = ["show-report.json", "show-report.html"].map(
|
|
6402
|
+
(f) => reportDir ? `${reportDir}/${f}` : f
|
|
6403
|
+
);
|
|
6404
|
+
const closeSha = await getCloseCommit(options.rootDir, frontmatter.completed, generatedOutputs);
|
|
6361
6405
|
if (closeSha) {
|
|
6362
6406
|
const [from, to, castMap] = await Promise.all([
|
|
6363
6407
|
getVersionAtRef(options.rootDir, baselineSha),
|
|
6364
6408
|
getVersionAtRef(options.rootDir, closeSha),
|
|
6365
|
-
|
|
6409
|
+
// Regeneration-only commits are never part of the cast, for a closed
|
|
6410
|
+
// program as much as an active one.
|
|
6411
|
+
getCast(options.rootDir, baselineSha, closeSha, generatedOutputs)
|
|
6366
6412
|
]);
|
|
6367
6413
|
if (from && to) versionSpan = { from, to };
|
|
6368
6414
|
for (const [name, count] of castMap) cast.set(name, count);
|
|
@@ -6540,7 +6586,7 @@ async function programFromPlanPath(rootDir, planPath) {
|
|
|
6540
6586
|
const planRelPath = toPosix(relative3(rootDir, planAbs));
|
|
6541
6587
|
return programFromPlan(rootDir, basename3(dirname9(planAbs)), planRelPath);
|
|
6542
6588
|
}
|
|
6543
|
-
function
|
|
6589
|
+
function normalizeLineEndings2(content) {
|
|
6544
6590
|
return content.replace(/\r\n?/g, "\n");
|
|
6545
6591
|
}
|
|
6546
6592
|
async function readOptionalFile(path4) {
|
|
@@ -6586,7 +6632,7 @@ async function runReportGeneration(options) {
|
|
|
6586
6632
|
];
|
|
6587
6633
|
for (const [relPath, expected] of outputs) {
|
|
6588
6634
|
const actual = await readOptionalFile(join16(options.rootDir, relPath));
|
|
6589
|
-
if (actual === null ||
|
|
6635
|
+
if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
|
|
6590
6636
|
drifted.push(`${relPath} does not match a regeneration from its sources.`);
|
|
6591
6637
|
if (options.mode === "write") {
|
|
6592
6638
|
await mkdir8(outAbs, { recursive: true });
|