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 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
- return createHash("sha256").update(content).digest("hex");
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 = createHash("sha256").update(content).digest("hex");
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 { createHash as createHash2 } from "crypto";
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
- @${promptPath}
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 = sha256(file.content);
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
- const currentHash = await hashFile(target);
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 hashFile(target) !== recorded) {
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 rm(target, { force: true });
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 hashFile(target) !== recorded) health.customized.push(relativePath);
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 rm2 } from "fs/promises";
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 rm2(destPath, { force: true });
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 rm3, readdir as readdir4 } from "fs/promises";
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 rm3(await hookFilePath(cwd, hookName), { force: true });
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, rm as rm4 } from "fs/promises";
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
- const currentHash = await hashFile(filePath);
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 rm4(filePath, { force: true });
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 hashFile(join9(targetDir, file)) !== recordedHash;
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 rm5(filePath, { force: true });
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
- await rm5(join11(storeRoot, file), { force: true });
4831
- removed++;
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 head = await runGitTextOrNull(cwd, ["rev-parse", "HEAD"]);
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 closeSha = await getCloseCommit(options.rootDir, frontmatter.completed);
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
- getCast(options.rootDir, baselineSha, closeSha)
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 normalizeLineEndings(content) {
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 || normalizeLineEndings(actual) !== normalizeLineEndings(expected)) {
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 });