rulesync 8.31.0 → 9.0.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/dist/cli/index.js CHANGED
@@ -16,7 +16,6 @@ import {
16
16
  ConsoleLogger,
17
17
  ErrorCodes,
18
18
  FETCH_CONCURRENCY_LIMIT,
19
- GITIGNORE_DESTINATION_KEY,
20
19
  HooksProcessor,
21
20
  HooksProcessorToolTargetSchema,
22
21
  IgnoreProcessor,
@@ -83,7 +82,6 @@ import {
83
82
  getHomeDirectory,
84
83
  getLocalSkillDirNames,
85
84
  importFromTool,
86
- isFeatureValueEnabled,
87
85
  isSymlink,
88
86
  listDirectoryFiles,
89
87
  readFileContent,
@@ -101,7 +99,7 @@ import {
101
99
  toolSkillFactories,
102
100
  toolSubagentFactories,
103
101
  writeFileContent
104
- } from "../chunk-NAO27T2K.js";
102
+ } from "../chunk-JT4BB5RE.js";
105
103
 
106
104
  // src/cli/index.ts
107
105
  import { Command } from "commander";
@@ -1132,15 +1130,6 @@ async function fetchCommand(logger5, options) {
1132
1130
  }
1133
1131
 
1134
1132
  // src/cli/commands/generate.ts
1135
- function sameDirSets(a, b) {
1136
- const aSet = new Set(a);
1137
- const bSet = new Set(b);
1138
- if (aSet.size !== bSet.size) return false;
1139
- for (const v of aSet) {
1140
- if (!bSet.has(v)) return false;
1141
- }
1142
- return true;
1143
- }
1144
1133
  function logFeatureResult(logger5, params) {
1145
1134
  const { count, paths, featureName, isPreview, modePrefix } = params;
1146
1135
  if (count > 0) {
@@ -1154,23 +1143,50 @@ function logFeatureResult(logger5, params) {
1154
1143
  }
1155
1144
  }
1156
1145
  }
1157
- async function generateCommand(logger5, options) {
1158
- const { baseDir, outputRoots, ...rest } = options;
1159
- if (baseDir !== void 0) {
1160
- logger5.warn(
1161
- "--base-dir is deprecated; use --output-roots instead. It will be removed in a future major release."
1162
- );
1163
- }
1164
- const outputRootsResolved = outputRoots !== void 0 && outputRoots.length > 0 ? outputRoots : baseDir;
1165
- if (baseDir !== void 0 && outputRoots !== void 0 && baseDir.length > 0 && outputRoots.length > 0 && !sameDirSets(outputRoots, baseDir)) {
1166
- logger5.warn(
1167
- `Both '--output-roots' and '--base-dir' were provided with differing values; using '--output-roots' (${JSON.stringify(outputRoots)}) and ignoring '--base-dir' (${JSON.stringify(baseDir)}).`
1168
- );
1146
+ var FEATURE_DEBUG_MESSAGES = {
1147
+ ignore: "Generating ignore files...",
1148
+ mcp: "Generating MCP files...",
1149
+ commands: "Generating command files...",
1150
+ subagents: "Generating subagent files...",
1151
+ skills: "Generating skill files...",
1152
+ hooks: "Generating hooks...",
1153
+ rules: "Generating rule files..."
1154
+ };
1155
+ var FEATURE_DEBUG_ORDER = [
1156
+ "ignore",
1157
+ "mcp",
1158
+ "commands",
1159
+ "subagents",
1160
+ "skills",
1161
+ "hooks",
1162
+ "rules"
1163
+ ];
1164
+ function logFeatureDebugMessages(logger5, features) {
1165
+ for (const feature of FEATURE_DEBUG_ORDER) {
1166
+ if (features.includes(feature)) {
1167
+ logger5.debug(FEATURE_DEBUG_MESSAGES[feature] ?? "");
1168
+ }
1169
+ }
1170
+ }
1171
+ function buildSummaryParts(result) {
1172
+ const summarySpecs = [
1173
+ { count: result.rulesCount, label: "rules" },
1174
+ { count: result.ignoreCount, label: "ignore files" },
1175
+ { count: result.mcpCount, label: "MCP files" },
1176
+ { count: result.commandsCount, label: "commands" },
1177
+ { count: result.subagentsCount, label: "subagents" },
1178
+ { count: result.skillsCount, label: "skills" },
1179
+ { count: result.hooksCount, label: "hooks" },
1180
+ { count: result.permissionsCount, label: "permissions" }
1181
+ ];
1182
+ const parts = [];
1183
+ for (const { count, label } of summarySpecs) {
1184
+ if (count > 0) parts.push(`${count} ${label}`);
1169
1185
  }
1170
- const config = await ConfigResolver.resolve(
1171
- { ...rest, outputRoots: outputRootsResolved },
1172
- { logger: logger5 }
1173
- );
1186
+ return parts;
1187
+ }
1188
+ async function generateCommand(logger5, options) {
1189
+ const config = await ConfigResolver.resolve(options, { logger: logger5 });
1174
1190
  const check = config.getCheck();
1175
1191
  const isPreview = config.isPreviewMode();
1176
1192
  const modePrefix = isPreview ? "[DRY RUN]" : "";
@@ -1183,27 +1199,7 @@ async function generateCommand(logger5, options) {
1183
1199
  }
1184
1200
  logger5.debug(`Output roots: ${config.getOutputRoots().join(", ")}`);
1185
1201
  const features = config.getFeatures();
1186
- if (features.includes("ignore")) {
1187
- logger5.debug("Generating ignore files...");
1188
- }
1189
- if (features.includes("mcp")) {
1190
- logger5.debug("Generating MCP files...");
1191
- }
1192
- if (features.includes("commands")) {
1193
- logger5.debug("Generating command files...");
1194
- }
1195
- if (features.includes("subagents")) {
1196
- logger5.debug("Generating subagent files...");
1197
- }
1198
- if (features.includes("skills")) {
1199
- logger5.debug("Generating skill files...");
1200
- }
1201
- if (features.includes("hooks")) {
1202
- logger5.debug("Generating hooks...");
1203
- }
1204
- if (features.includes("rules")) {
1205
- logger5.debug("Generating rule files...");
1206
- }
1202
+ logFeatureDebugMessages(logger5, features);
1207
1203
  const result = await generate({ config, logger: logger5 });
1208
1204
  const totalGenerated = calculateTotalCount(result);
1209
1205
  const featureResults = {
@@ -1256,15 +1252,7 @@ async function generateCommand(logger5, options) {
1256
1252
  logger5.info(`\u2713 All files are up to date (${enabledFeatures})`);
1257
1253
  return;
1258
1254
  }
1259
- const parts = [];
1260
- if (result.rulesCount > 0) parts.push(`${result.rulesCount} rules`);
1261
- if (result.ignoreCount > 0) parts.push(`${result.ignoreCount} ignore files`);
1262
- if (result.mcpCount > 0) parts.push(`${result.mcpCount} MCP files`);
1263
- if (result.commandsCount > 0) parts.push(`${result.commandsCount} commands`);
1264
- if (result.subagentsCount > 0) parts.push(`${result.subagentsCount} subagents`);
1265
- if (result.skillsCount > 0) parts.push(`${result.skillsCount} skills`);
1266
- if (result.hooksCount > 0) parts.push(`${result.hooksCount} hooks`);
1267
- if (result.permissionsCount > 0) parts.push(`${result.permissionsCount} permissions`);
1255
+ const parts = buildSummaryParts(result);
1268
1256
  if (isPreview) {
1269
1257
  logger5.info(`${modePrefix} Would write ${totalGenerated} file(s) total (${parts.join(" + ")})`);
1270
1258
  } else {
@@ -1337,7 +1325,6 @@ var getProcessorRegistryEntry = (feature) => {
1337
1325
  // src/cli/commands/gitignore-derive.ts
1338
1326
  var TARGETS_NOT_DERIVED = /* @__PURE__ */ new Set([
1339
1327
  "agentsskills",
1340
- "antigravity",
1341
1328
  "augmentcode-legacy",
1342
1329
  "claudecode-legacy"
1343
1330
  ]);
@@ -1525,28 +1512,12 @@ var getSelectedGitignoreEntryTargets = (target, selectedTargets) => {
1525
1512
  }
1526
1513
  return targets.filter((candidate) => selectedTargets.includes(candidate));
1527
1514
  };
1528
- var isFeatureSelectedForTarget = (feature, target, features) => {
1515
+ var isFeatureSelected = (feature, features) => {
1529
1516
  if (feature === "general") return true;
1530
1517
  if (!features) return true;
1531
- if (Array.isArray(features)) {
1532
- if (features.length === 0) return true;
1533
- if (features.includes("*")) return true;
1534
- return features.includes(feature);
1535
- }
1536
- if (target === "common") return true;
1537
- const targetFeatures = features[target];
1538
- if (!targetFeatures) return true;
1539
- if (Array.isArray(targetFeatures)) {
1540
- if (targetFeatures.includes("*")) return true;
1541
- return targetFeatures.includes(feature);
1542
- }
1543
- if (isFeatureValueEnabled(targetFeatures["*"])) return true;
1544
- return isFeatureValueEnabled(targetFeatures[feature]);
1545
- };
1546
- var isFeatureSelected = (feature, target, features) => {
1547
- return normalizeGitignoreEntryTargets(target).some(
1548
- (candidate) => isFeatureSelectedForTarget(feature, candidate, features)
1549
- );
1518
+ if (features.length === 0) return true;
1519
+ if (features.includes("*")) return true;
1520
+ return features.includes(feature);
1550
1521
  };
1551
1522
  var warnInvalidTargets = (targets, logger5) => {
1552
1523
  const validTargets = new Set(ALL_TOOL_TARGETS_WITH_WILDCARD);
@@ -1561,32 +1532,13 @@ var warnInvalidTargets = (targets, logger5) => {
1561
1532
  var warnInvalidFeatures = (features, logger5) => {
1562
1533
  const validFeatures = new Set(ALL_FEATURES_WITH_WILDCARD);
1563
1534
  const warned = /* @__PURE__ */ new Set();
1564
- const warnOnce = (feature) => {
1535
+ for (const feature of features) {
1565
1536
  if (!validFeatures.has(feature) && !warned.has(feature)) {
1566
1537
  warned.add(feature);
1567
1538
  logger5?.warn(
1568
1539
  `Unknown feature '${feature}'. Valid features: ${ALL_FEATURES_WITH_WILDCARD.join(", ")}`
1569
1540
  );
1570
1541
  }
1571
- };
1572
- if (Array.isArray(features)) {
1573
- for (const feature of features) {
1574
- warnOnce(feature);
1575
- }
1576
- } else {
1577
- for (const targetFeatures of Object.values(features)) {
1578
- if (!targetFeatures) continue;
1579
- if (Array.isArray(targetFeatures)) {
1580
- for (const feature of targetFeatures) {
1581
- warnOnce(feature);
1582
- }
1583
- } else {
1584
- for (const feature of Object.keys(targetFeatures)) {
1585
- if (feature === GITIGNORE_DESTINATION_KEY) continue;
1586
- warnOnce(feature);
1587
- }
1588
- }
1589
- }
1590
1542
  }
1591
1543
  };
1592
1544
  var resolveGitignoreEntries = (params) => {
@@ -1602,7 +1554,7 @@ var resolveGitignoreEntries = (params) => {
1602
1554
  for (const tag of GITIGNORE_ENTRY_REGISTRY) {
1603
1555
  if (!isTargetSelected(tag.target, targets)) continue;
1604
1556
  const selectedTagTargets = getSelectedGitignoreEntryTargets(tag.target, targets);
1605
- if (!isFeatureSelected(tag.feature, selectedTagTargets, features)) continue;
1557
+ if (!isFeatureSelected(tag.feature, features)) continue;
1606
1558
  if (seen.has(tag.entry)) continue;
1607
1559
  seen.add(tag.entry);
1608
1560
  result.push({
@@ -1794,9 +1746,9 @@ ${rulesyncBlock}
1794
1746
  "\u{1F4A1} If you're using Google Antigravity, note that rules, workflows, and skills won't load if they're gitignored."
1795
1747
  );
1796
1748
  logger5.info(" You can add the following to .git/info/exclude instead:");
1797
- logger5.info(" **/.agent/rules/");
1798
- logger5.info(" **/.agent/workflows/");
1799
- logger5.info(" **/.agent/skills/");
1749
+ logger5.info(" **/.agents/rules/");
1750
+ logger5.info(" **/.agents/workflows/");
1751
+ logger5.info(" **/.agents/skills/");
1800
1752
  logger5.info(" For more details: https://github.com/dyoshikawa/rulesync/issues/981");
1801
1753
  };
1802
1754
 
@@ -2211,7 +2163,7 @@ function findApmLockDependency(lock, repoUrl) {
2211
2163
 
2212
2164
  // src/lib/apm/apm-manifest.ts
2213
2165
  import { join as join5 } from "path";
2214
- import { dump as dump2, load as load2 } from "js-yaml";
2166
+ import { load as load2 } from "js-yaml";
2215
2167
  import { optional as optional2, z as z5 } from "zod/mini";
2216
2168
  var APM_MANIFEST_FILE_NAME = "apm.yml";
2217
2169
  var ApmObjectDependencySchema = z5.looseObject({
@@ -2419,34 +2371,7 @@ async function installApm(params) {
2419
2371
  }
2420
2372
  const existingLock = await readApmLock(projectRoot);
2421
2373
  if (options.frozen) {
2422
- if (!existingLock) {
2423
- throw new Error(
2424
- "Frozen install failed: rulesync-apm.lock.yaml is missing. Run 'rulesync install --mode apm' to create it."
2425
- );
2426
- }
2427
- const missing = manifest.dependencies.filter(
2428
- (dep) => !findApmLockDependency(existingLock, canonicalRepoUrl(dep))
2429
- );
2430
- if (missing.length > 0) {
2431
- const names = missing.map((d) => d.gitUrl).join(", ");
2432
- throw new Error(
2433
- `Frozen install failed: rulesync-apm.lock.yaml is missing entries for: ${names}. Run 'rulesync install --mode apm' to update the lockfile.`
2434
- );
2435
- }
2436
- const drifted = manifest.dependencies.filter((dep) => {
2437
- if (dep.ref === void 0) return false;
2438
- const locked = findApmLockDependency(existingLock, canonicalRepoUrl(dep));
2439
- return locked?.resolved_ref !== void 0 && locked.resolved_ref !== dep.ref;
2440
- });
2441
- if (drifted.length > 0) {
2442
- const names = drifted.map((d) => {
2443
- const locked = findApmLockDependency(existingLock, canonicalRepoUrl(d));
2444
- return `${d.gitUrl} (manifest=${d.ref}, lock=${locked?.resolved_ref})`;
2445
- }).join(", ");
2446
- throw new Error(
2447
- `Frozen install failed: manifest ref does not match rulesync-apm.lock.yaml for: ${names}. Run 'rulesync install --mode apm' to update the lockfile.`
2448
- );
2449
- }
2374
+ assertFrozenLockCoversManifest({ existingLock, dependencies: manifest.dependencies });
2450
2375
  }
2451
2376
  const token = GitHubClient.resolveToken(options.token);
2452
2377
  const client = new GitHubClient({ token });
@@ -2501,30 +2426,7 @@ async function installApm(params) {
2501
2426
  }
2502
2427
  }
2503
2428
  if (existingLock) {
2504
- const newDeployedFiles = new Set(newLock.dependencies.flatMap((d) => d.deployed_files));
2505
- const toDelete = [];
2506
- for (const prev of existingLock.dependencies) {
2507
- for (const deployed of prev.deployed_files) {
2508
- if (!newDeployedFiles.has(deployed)) {
2509
- toDelete.push(deployed);
2510
- }
2511
- }
2512
- }
2513
- for (const relativePath of toDelete) {
2514
- if (posix2.isAbsolute(relativePath) || relativePath.split(/[/\\]/).includes("..")) {
2515
- logger5.warn(`Refusing to remove stale apm file with suspicious path: "${relativePath}".`);
2516
- continue;
2517
- }
2518
- try {
2519
- checkPathTraversal({ relativePath, intendedRootDir: projectRoot });
2520
- } catch {
2521
- logger5.warn(`Refusing to remove stale apm file outside projectRoot: "${relativePath}".`);
2522
- continue;
2523
- }
2524
- const absolute = join6(projectRoot, relativePath);
2525
- await removeFile(absolute);
2526
- logger5.debug(`Removed stale apm file: ${relativePath}`);
2527
- }
2429
+ await removeStaleApmFiles({ existingLock, newLock, projectRoot, logger: logger5 });
2528
2430
  }
2529
2431
  if (!frozen) {
2530
2432
  newLock.generated_at = (/* @__PURE__ */ new Date()).toISOString();
@@ -2543,6 +2445,64 @@ async function installApm(params) {
2543
2445
  failedDependencyCount: failedCount
2544
2446
  };
2545
2447
  }
2448
+ function assertFrozenLockCoversManifest(params) {
2449
+ const { existingLock, dependencies } = params;
2450
+ if (!existingLock) {
2451
+ throw new Error(
2452
+ "Frozen install failed: rulesync-apm.lock.yaml is missing. Run 'rulesync install --mode apm' to create it."
2453
+ );
2454
+ }
2455
+ const missing = dependencies.filter(
2456
+ (dep) => !findApmLockDependency(existingLock, canonicalRepoUrl(dep))
2457
+ );
2458
+ if (missing.length > 0) {
2459
+ const names = missing.map((d) => d.gitUrl).join(", ");
2460
+ throw new Error(
2461
+ `Frozen install failed: rulesync-apm.lock.yaml is missing entries for: ${names}. Run 'rulesync install --mode apm' to update the lockfile.`
2462
+ );
2463
+ }
2464
+ const drifted = dependencies.filter((dep) => {
2465
+ if (dep.ref === void 0) return false;
2466
+ const locked = findApmLockDependency(existingLock, canonicalRepoUrl(dep));
2467
+ return locked?.resolved_ref !== void 0 && locked.resolved_ref !== dep.ref;
2468
+ });
2469
+ if (drifted.length > 0) {
2470
+ const names = drifted.map((d) => {
2471
+ const locked = findApmLockDependency(existingLock, canonicalRepoUrl(d));
2472
+ return `${d.gitUrl} (manifest=${d.ref}, lock=${locked?.resolved_ref})`;
2473
+ }).join(", ");
2474
+ throw new Error(
2475
+ `Frozen install failed: manifest ref does not match rulesync-apm.lock.yaml for: ${names}. Run 'rulesync install --mode apm' to update the lockfile.`
2476
+ );
2477
+ }
2478
+ }
2479
+ async function removeStaleApmFiles(params) {
2480
+ const { existingLock, newLock, projectRoot, logger: logger5 } = params;
2481
+ const newDeployedFiles = new Set(newLock.dependencies.flatMap((d) => d.deployed_files));
2482
+ const toDelete = [];
2483
+ for (const prev of existingLock.dependencies) {
2484
+ for (const deployed of prev.deployed_files) {
2485
+ if (!newDeployedFiles.has(deployed)) {
2486
+ toDelete.push(deployed);
2487
+ }
2488
+ }
2489
+ }
2490
+ for (const relativePath of toDelete) {
2491
+ if (posix2.isAbsolute(relativePath) || relativePath.split(/[/\\]/).includes("..")) {
2492
+ logger5.warn(`Refusing to remove stale apm file with suspicious path: "${relativePath}".`);
2493
+ continue;
2494
+ }
2495
+ try {
2496
+ checkPathTraversal({ relativePath, intendedRootDir: projectRoot });
2497
+ } catch {
2498
+ logger5.warn(`Refusing to remove stale apm file outside projectRoot: "${relativePath}".`);
2499
+ continue;
2500
+ }
2501
+ const absolute = join6(projectRoot, relativePath);
2502
+ await removeFile(absolute);
2503
+ logger5.debug(`Removed stale apm file: ${relativePath}`);
2504
+ }
2505
+ }
2546
2506
  async function installDependency(params) {
2547
2507
  const { dep, client, semaphore, projectRoot, existingLock, frozen, update, logger: logger5 } = params;
2548
2508
  const repoUrl = canonicalRepoUrl(dep);
@@ -2571,58 +2531,25 @@ async function installDependency(params) {
2571
2531
  logger: logger5
2572
2532
  });
2573
2533
  if (files.length === 0) continue;
2574
- for (const file of files) {
2575
- if (file.size > MAX_FILE_SIZE) {
2576
- logger5.warn(
2577
- `Skipping "${file.path}" from ${repoUrl}: ${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
2578
- );
2579
- continue;
2580
- }
2581
- const relativeToBase = posix2.relative(remoteBase, toPosixPath(file.path));
2582
- if (!relativeToBase || relativeToBase.startsWith("..") || posix2.isAbsolute(relativeToBase)) {
2583
- logger5.warn(
2584
- `Skipping "${file.path}" from ${repoUrl}: resolved outside of "${remoteBase}".`
2585
- );
2586
- continue;
2587
- }
2588
- const deployRelative = toPosixPath(join6(primitive.deployDir, relativeToBase));
2589
- checkPathTraversal({
2590
- relativePath: deployRelative,
2591
- intendedRootDir: projectRoot
2592
- });
2593
- const content = await withSemaphore(
2594
- semaphore,
2595
- () => client.getFileContent(dep.owner, dep.repo, file.path, resolvedSha)
2596
- );
2597
- const byteLength = Buffer.byteLength(content, "utf8");
2598
- if (byteLength > MAX_FILE_SIZE) {
2599
- logger5.warn(
2600
- `Skipping "${file.path}" from ${repoUrl}: fetched ${(byteLength / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
2601
- );
2602
- continue;
2603
- }
2604
- deployed.push({ path: deployRelative, content });
2605
- if (!frozen) {
2606
- await writeFileContent(join6(projectRoot, deployRelative), content);
2607
- }
2608
- }
2534
+ await collectPrimitiveDeployments({
2535
+ dep,
2536
+ client,
2537
+ semaphore,
2538
+ projectRoot,
2539
+ primitive,
2540
+ remoteBase,
2541
+ files,
2542
+ resolvedSha,
2543
+ repoUrl,
2544
+ frozen,
2545
+ deployed,
2546
+ logger: logger5
2547
+ });
2609
2548
  }
2610
2549
  deployed.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
2611
2550
  const deployedFiles = deployed.map((d) => d.path);
2612
2551
  const contentHash = computeContentHash(deployed);
2613
- if (frozen && locked?.content_hash) {
2614
- if (RULESYNC_CONTENT_HASH_REGEX.test(locked.content_hash)) {
2615
- if (locked.content_hash !== contentHash) {
2616
- throw new Error(
2617
- `content_hash mismatch for ${repoUrl}: lock=${locked.content_hash} computed=${contentHash}. Refuse to trust the deployment under --frozen.`
2618
- );
2619
- }
2620
- } else {
2621
- logger5.debug(
2622
- `Skipping content_hash integrity check for ${repoUrl}: recorded hash "${locked.content_hash}" was not written by rulesync.`
2623
- );
2624
- }
2625
- }
2552
+ assertFrozenContentHashMatches({ frozen, locked, contentHash, repoUrl, logger: logger5 });
2626
2553
  if (frozen) {
2627
2554
  for (const { path: deployRelative, content } of deployed) {
2628
2555
  await writeFileContent(join6(projectRoot, deployRelative), content);
@@ -2643,6 +2570,71 @@ async function installDependency(params) {
2643
2570
  logger5.info(`Installed ${deployedFiles.length} file(s) from ${repoUrl}@${shortSha(resolvedSha)}`);
2644
2571
  return { lockEntry, deployedFiles };
2645
2572
  }
2573
+ async function collectPrimitiveDeployments(params) {
2574
+ const {
2575
+ dep,
2576
+ client,
2577
+ semaphore,
2578
+ projectRoot,
2579
+ primitive,
2580
+ remoteBase,
2581
+ files,
2582
+ resolvedSha,
2583
+ repoUrl,
2584
+ frozen,
2585
+ deployed,
2586
+ logger: logger5
2587
+ } = params;
2588
+ for (const file of files) {
2589
+ if (file.size > MAX_FILE_SIZE) {
2590
+ logger5.warn(
2591
+ `Skipping "${file.path}" from ${repoUrl}: ${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
2592
+ );
2593
+ continue;
2594
+ }
2595
+ const relativeToBase = posix2.relative(remoteBase, toPosixPath(file.path));
2596
+ if (!relativeToBase || relativeToBase.startsWith("..") || posix2.isAbsolute(relativeToBase)) {
2597
+ logger5.warn(`Skipping "${file.path}" from ${repoUrl}: resolved outside of "${remoteBase}".`);
2598
+ continue;
2599
+ }
2600
+ const deployRelative = toPosixPath(join6(primitive.deployDir, relativeToBase));
2601
+ checkPathTraversal({
2602
+ relativePath: deployRelative,
2603
+ intendedRootDir: projectRoot
2604
+ });
2605
+ const content = await withSemaphore(
2606
+ semaphore,
2607
+ () => client.getFileContent(dep.owner, dep.repo, file.path, resolvedSha)
2608
+ );
2609
+ const byteLength = Buffer.byteLength(content, "utf8");
2610
+ if (byteLength > MAX_FILE_SIZE) {
2611
+ logger5.warn(
2612
+ `Skipping "${file.path}" from ${repoUrl}: fetched ${(byteLength / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
2613
+ );
2614
+ continue;
2615
+ }
2616
+ deployed.push({ path: deployRelative, content });
2617
+ if (!frozen) {
2618
+ await writeFileContent(join6(projectRoot, deployRelative), content);
2619
+ }
2620
+ }
2621
+ }
2622
+ function assertFrozenContentHashMatches(params) {
2623
+ const { frozen, locked, contentHash, repoUrl, logger: logger5 } = params;
2624
+ if (frozen && locked?.content_hash) {
2625
+ if (RULESYNC_CONTENT_HASH_REGEX.test(locked.content_hash)) {
2626
+ if (locked.content_hash !== contentHash) {
2627
+ throw new Error(
2628
+ `content_hash mismatch for ${repoUrl}: lock=${locked.content_hash} computed=${contentHash}. Refuse to trust the deployment under --frozen.`
2629
+ );
2630
+ }
2631
+ } else {
2632
+ logger5.debug(
2633
+ `Skipping content_hash integrity check for ${repoUrl}: recorded hash "${locked.content_hash}" was not written by rulesync.`
2634
+ );
2635
+ }
2636
+ }
2637
+ }
2646
2638
  function computeContentHash(files) {
2647
2639
  const hash = createHash("sha256");
2648
2640
  for (const { path: path2, content } of files) {
@@ -2685,7 +2677,7 @@ import { basename, join as join9, posix as posix3 } from "path";
2685
2677
  import { Semaphore as Semaphore3 } from "es-toolkit/promise";
2686
2678
 
2687
2679
  // src/lib/gh/gh-frontmatter.ts
2688
- import { dump as dump3, load as load3 } from "js-yaml";
2680
+ import { dump as dump2, load as load3 } from "js-yaml";
2689
2681
  var FRONTMATTER_FENCE = "---";
2690
2682
  function injectSourceMetadata(params) {
2691
2683
  const { content, source, repository, ref } = params;
@@ -2700,7 +2692,7 @@ function injectSourceMetadata(params) {
2700
2692
  } else if (content === FRONTMATTER_FENCE) {
2701
2693
  openFenceLen = 3;
2702
2694
  } else {
2703
- const yaml2 = dump3(provenance, { noRefs: true, lineWidth: -1, sortKeys: false });
2695
+ const yaml2 = dump2(provenance, { noRefs: true, lineWidth: -1, sortKeys: false });
2704
2696
  return `${FRONTMATTER_FENCE}
2705
2697
  ${yaml2}${FRONTMATTER_FENCE}
2706
2698
  ${content}`;
@@ -2727,7 +2719,7 @@ ${content}`;
2727
2719
  throw new Error("invalid frontmatter");
2728
2720
  }
2729
2721
  if (loaded === null || loaded === void 0) {
2730
- const yaml2 = dump3(provenance, { noRefs: true, lineWidth: -1, sortKeys: false });
2722
+ const yaml2 = dump2(provenance, { noRefs: true, lineWidth: -1, sortKeys: false });
2731
2723
  return `${FRONTMATTER_FENCE}
2732
2724
  ${yaml2}${FRONTMATTER_FENCE}
2733
2725
  ${rest}`;
@@ -2740,7 +2732,7 @@ ${rest}`;
2740
2732
  ...existing,
2741
2733
  ...provenance
2742
2734
  };
2743
- const yaml = dump3(merged, { noRefs: true, lineWidth: -1, sortKeys: false });
2735
+ const yaml = dump2(merged, { noRefs: true, lineWidth: -1, sortKeys: false });
2744
2736
  return `${FRONTMATTER_FENCE}
2745
2737
  ${yaml}${FRONTMATTER_FENCE}
2746
2738
  ${rest}`;
@@ -2748,7 +2740,7 @@ ${rest}`;
2748
2740
 
2749
2741
  // src/lib/gh/gh-lock.ts
2750
2742
  import { join as join7 } from "path";
2751
- import { dump as dump4, load as load4 } from "js-yaml";
2743
+ import { dump as dump3, load as load4 } from "js-yaml";
2752
2744
  import { optional as optional3, refine as refine2, z as z6 } from "zod/mini";
2753
2745
  var GH_LOCKFILE_FILE_NAME = "rulesync-gh.lock.yaml";
2754
2746
  var GH_LOCKFILE_VERSION = "1";
@@ -2820,7 +2812,7 @@ async function writeGhLock(params) {
2820
2812
  await writeFileContent(path2, content);
2821
2813
  }
2822
2814
  function serializeGhLock(lock) {
2823
- return dump4(lock, { noRefs: true, lineWidth: -1, sortKeys: false });
2815
+ return dump3(lock, { noRefs: true, lineWidth: -1, sortKeys: false });
2824
2816
  }
2825
2817
  function findGhLockInstallation(lock, params) {
2826
2818
  const target = params.source.toLowerCase();
@@ -2871,39 +2863,7 @@ async function installGh(params) {
2871
2863
  if (sources.length === 0) {
2872
2864
  return { sourcesProcessed: 0, installedSkillCount: 0, failedSourceCount: 0 };
2873
2865
  }
2874
- const resolvedSources = sources.map((entry) => {
2875
- const parsed = parseSource(entry.source);
2876
- if (parsed.provider !== "github") {
2877
- throw new Error(
2878
- `--mode gh only supports GitHub sources. "${entry.source}" resolves to provider "${parsed.provider}".`
2879
- );
2880
- }
2881
- if (entry.transport !== void 0 && entry.transport !== "github") {
2882
- throw new Error(
2883
- `--mode gh: field "transport" is not supported (got "${entry.transport}" for source "${entry.source}"). Drop the field or switch to --mode rulesync.`
2884
- );
2885
- }
2886
- if (entry.path !== void 0) {
2887
- throw new Error(
2888
- `--mode gh: field "path" is not supported for source "${entry.source}". The remote layout is fixed to "skills/<name>/SKILL.md".`
2889
- );
2890
- }
2891
- const agent = entry.agent ?? "github-copilot";
2892
- if (!GH_AGENTS.includes(agent)) {
2893
- throw new Error(
2894
- `--mode gh: unknown agent "${agent}" for source "${entry.source}". Valid agents: ${GH_AGENTS.join(", ")}.`
2895
- );
2896
- }
2897
- const scope = entry.scope ?? "project";
2898
- return {
2899
- entry,
2900
- owner: parsed.owner,
2901
- repo: parsed.repo,
2902
- ref: entry.ref ?? parsed.ref,
2903
- agent,
2904
- scope
2905
- };
2906
- });
2866
+ const resolvedSources = sources.map(resolveGhSource);
2907
2867
  const existingLock = await readGhLock(projectRoot);
2908
2868
  const frozen = options.frozen ?? false;
2909
2869
  const update = options.update ?? false;
@@ -2913,38 +2873,7 @@ async function installGh(params) {
2913
2873
  );
2914
2874
  }
2915
2875
  if (frozen && existingLock) {
2916
- const uncovered = [];
2917
- for (const rs of resolvedSources) {
2918
- const hasAny = existingLock.installations.some(
2919
- (i) => i.source.toLowerCase() === rs.entry.source.toLowerCase() && i.agent === rs.agent && i.scope === rs.scope
2920
- );
2921
- if (!hasAny) {
2922
- uncovered.push(`${rs.entry.source} (agent=${rs.agent}, scope=${rs.scope})`);
2923
- }
2924
- }
2925
- if (uncovered.length > 0) {
2926
- throw new Error(
2927
- `Frozen install failed: rulesync-gh.lock.yaml is missing entries for: ${uncovered.join(", ")}. Run 'rulesync install --mode gh' to update the lockfile.`
2928
- );
2929
- }
2930
- const drifted = [];
2931
- for (const rs of resolvedSources) {
2932
- if (!rs.ref) continue;
2933
- const matches = existingLock.installations.filter(
2934
- (i) => i.source.toLowerCase() === rs.entry.source.toLowerCase()
2935
- );
2936
- for (const m of matches) {
2937
- if (m.requested_ref !== void 0 && m.requested_ref !== rs.ref) {
2938
- drifted.push(`${rs.entry.source} (manifest=${rs.ref}, lock=${m.requested_ref})`);
2939
- break;
2940
- }
2941
- }
2942
- }
2943
- if (drifted.length > 0) {
2944
- throw new Error(
2945
- `Frozen install failed: manifest ref does not match rulesync-gh.lock.yaml for: ${drifted.join(", ")}. Run 'rulesync install --mode gh' to update the lockfile.`
2946
- );
2947
- }
2876
+ assertFrozenLockCoversSources({ existingLock, resolvedSources });
2948
2877
  }
2949
2878
  const token = GitHubClient.resolveToken(options.token);
2950
2879
  const client = new GitHubClient({ token });
@@ -2980,49 +2909,11 @@ async function installGh(params) {
2980
2909
  })
2981
2910
  );
2982
2911
  if (frozen) {
2983
- for (const result of results) {
2984
- if (result.status !== "ok") continue;
2985
- for (const inst of result.installations) {
2986
- for (const d of inst.deployed) {
2987
- await writeFileContent(d.absolutePath, d.content);
2988
- }
2989
- }
2990
- }
2991
- }
2992
- let totalInstalled = 0;
2993
- let failedCount = 0;
2994
- for (const result of results) {
2995
- if (result.status === "ok") {
2996
- for (const inst of result.installations) {
2997
- newLock.installations.push(inst.installation);
2998
- }
2999
- totalInstalled += result.installations.length;
3000
- } else {
3001
- failedCount += 1;
3002
- for (const preserved of result.preserved) {
3003
- newLock.installations.push(preserved);
3004
- }
3005
- }
2912
+ await writeDeferredFrozenFiles(results);
3006
2913
  }
2914
+ const { totalInstalled, failedCount } = aggregateSourceResults({ results, newLock });
3007
2915
  if (existingLock) {
3008
- const newDeployed = /* @__PURE__ */ new Set();
3009
- for (const inst of newLock.installations) {
3010
- for (const file of inst.deployed_files) {
3011
- newDeployed.add(`${inst.scope}::${file}`);
3012
- }
3013
- }
3014
- for (const prev of existingLock.installations) {
3015
- for (const deployed of prev.deployed_files) {
3016
- const key = `${prev.scope}::${deployed}`;
3017
- if (newDeployed.has(key)) continue;
3018
- await removeStaleFile({
3019
- relativePath: deployed,
3020
- scope: prev.scope === "user" ? "user" : "project",
3021
- projectRoot,
3022
- logger: logger5
3023
- });
3024
- }
3025
- }
2916
+ await removeStaleGhFiles({ existingLock, newLock, projectRoot, logger: logger5 });
3026
2917
  }
3027
2918
  if (!frozen) {
3028
2919
  newLock.generated_at = (/* @__PURE__ */ new Date()).toISOString();
@@ -3041,80 +2932,152 @@ async function installGh(params) {
3041
2932
  failedSourceCount: failedCount
3042
2933
  };
3043
2934
  }
3044
- async function installSource(params) {
3045
- const { rs, client, semaphore, projectRoot, existingLock, frozen, update, logger: logger5 } = params;
3046
- const { entry, owner, repo, agent, scope } = rs;
3047
- const sourceKey = entry.source;
3048
- let resolvedRef;
3049
- let usedTag = false;
3050
- if (rs.ref) {
3051
- resolvedRef = rs.ref;
3052
- } else {
3053
- try {
3054
- const release = await client.getLatestRelease(owner, repo);
3055
- resolvedRef = release.tag_name;
3056
- usedTag = true;
3057
- } catch (error) {
3058
- if (is404(error)) {
3059
- resolvedRef = await client.getDefaultBranch(owner, repo);
3060
- } else {
3061
- throw error;
3062
- }
3063
- }
2935
+ function resolveGhSource(entry) {
2936
+ const parsed = parseSource(entry.source);
2937
+ if (parsed.provider !== "github") {
2938
+ throw new Error(
2939
+ `--mode gh only supports GitHub sources. "${entry.source}" resolves to provider "${parsed.provider}".`
2940
+ );
3064
2941
  }
3065
- const resolvedSha = await client.resolveRefToSha(owner, repo, resolvedRef);
3066
- logger5.debug(`Resolved ${sourceKey} -> ref=${resolvedRef} sha=${resolvedSha}`);
3067
- let topLevel;
3068
- try {
3069
- topLevel = await client.listDirectory(owner, repo, SKILLS_REMOTE_DIR, resolvedSha);
3070
- } catch (error) {
3071
- if (is404(error)) {
3072
- logger5.warn(`No skills/ directory found in ${sourceKey}. Skipping.`);
3073
- return [];
2942
+ if (entry.transport !== void 0 && entry.transport !== "github") {
2943
+ throw new Error(
2944
+ `--mode gh: field "transport" is not supported (got "${entry.transport}" for source "${entry.source}"). Drop the field or switch to --mode rulesync.`
2945
+ );
2946
+ }
2947
+ if (entry.path !== void 0) {
2948
+ throw new Error(
2949
+ `--mode gh: field "path" is not supported for source "${entry.source}". The remote layout is fixed to "skills/<name>/SKILL.md".`
2950
+ );
2951
+ }
2952
+ const agent = entry.agent ?? "github-copilot";
2953
+ if (!GH_AGENTS.includes(agent)) {
2954
+ throw new Error(
2955
+ `--mode gh: unknown agent "${agent}" for source "${entry.source}". Valid agents: ${GH_AGENTS.join(", ")}.`
2956
+ );
2957
+ }
2958
+ const scope = entry.scope ?? "project";
2959
+ return {
2960
+ entry,
2961
+ owner: parsed.owner,
2962
+ repo: parsed.repo,
2963
+ ref: entry.ref ?? parsed.ref,
2964
+ agent,
2965
+ scope
2966
+ };
2967
+ }
2968
+ function assertFrozenLockCoversSources(params) {
2969
+ const { existingLock, resolvedSources } = params;
2970
+ const uncovered = [];
2971
+ for (const rs of resolvedSources) {
2972
+ const hasAny = existingLock.installations.some(
2973
+ (i) => i.source.toLowerCase() === rs.entry.source.toLowerCase() && i.agent === rs.agent && i.scope === rs.scope
2974
+ );
2975
+ if (!hasAny) {
2976
+ uncovered.push(`${rs.entry.source} (agent=${rs.agent}, scope=${rs.scope})`);
3074
2977
  }
3075
- throw error;
3076
2978
  }
3077
- const skillDirs = topLevel.filter((e) => e.type === "dir").map((e) => ({ name: e.name, path: e.path }));
3078
- const validatedSkills = [];
3079
- for (const sk of skillDirs) {
3080
- const info = await withSemaphore(
3081
- semaphore,
3082
- () => client.getFileInfo(owner, repo, posix3.join(sk.path, SKILL_FILE_NAME2), resolvedSha)
2979
+ if (uncovered.length > 0) {
2980
+ throw new Error(
2981
+ `Frozen install failed: rulesync-gh.lock.yaml is missing entries for: ${uncovered.join(", ")}. Run 'rulesync install --mode gh' to update the lockfile.`
3083
2982
  );
3084
- if (info) {
3085
- validatedSkills.push(sk);
2983
+ }
2984
+ const drifted = [];
2985
+ for (const rs of resolvedSources) {
2986
+ if (!rs.ref) continue;
2987
+ const matches = existingLock.installations.filter(
2988
+ (i) => i.source.toLowerCase() === rs.entry.source.toLowerCase()
2989
+ );
2990
+ for (const m of matches) {
2991
+ if (m.requested_ref !== void 0 && m.requested_ref !== rs.ref) {
2992
+ drifted.push(`${rs.entry.source} (manifest=${rs.ref}, lock=${m.requested_ref})`);
2993
+ break;
2994
+ }
3086
2995
  }
3087
2996
  }
3088
- let selected = validatedSkills;
3089
- if (entry.skills && entry.skills.length > 0) {
3090
- const requested = new Set(entry.skills);
3091
- selected = validatedSkills.filter((s) => requested.has(s.name));
3092
- const presentNames = new Set(validatedSkills.map((s) => s.name));
3093
- for (const want of entry.skills) {
3094
- if (!presentNames.has(want)) {
3095
- logger5.warn(`Requested skill "${want}" not found in ${sourceKey} under skills/. Skipping.`);
2997
+ if (drifted.length > 0) {
2998
+ throw new Error(
2999
+ `Frozen install failed: manifest ref does not match rulesync-gh.lock.yaml for: ${drifted.join(", ")}. Run 'rulesync install --mode gh' to update the lockfile.`
3000
+ );
3001
+ }
3002
+ }
3003
+ async function writeDeferredFrozenFiles(results) {
3004
+ for (const result of results) {
3005
+ if (result.status !== "ok") continue;
3006
+ for (const inst of result.installations) {
3007
+ for (const d of inst.deployed) {
3008
+ await writeFileContent(d.absolutePath, d.content);
3096
3009
  }
3097
3010
  }
3098
3011
  }
3099
- if (frozen && existingLock) {
3100
- const missing = [];
3101
- for (const sk of selected) {
3102
- const locked = findGhLockInstallation(existingLock, {
3103
- source: sourceKey,
3104
- agent,
3105
- scope,
3106
- skill: sk.name
3107
- });
3108
- if (!locked) {
3109
- missing.push(sk.name);
3012
+ }
3013
+ function aggregateSourceResults(params) {
3014
+ const { results, newLock } = params;
3015
+ let totalInstalled = 0;
3016
+ let failedCount = 0;
3017
+ for (const result of results) {
3018
+ if (result.status === "ok") {
3019
+ for (const inst of result.installations) {
3020
+ newLock.installations.push(inst.installation);
3021
+ }
3022
+ totalInstalled += result.installations.length;
3023
+ } else {
3024
+ failedCount += 1;
3025
+ for (const preserved of result.preserved) {
3026
+ newLock.installations.push(preserved);
3110
3027
  }
3111
3028
  }
3112
- if (missing.length > 0) {
3113
- throw new Error(
3114
- `Frozen install failed: rulesync-gh.lock.yaml is missing entries for ${sourceKey} (agent=${agent}, scope=${scope}) skills: ${missing.join(", ")}. Run 'rulesync install --mode gh' to update the lockfile.`
3115
- );
3029
+ }
3030
+ return { totalInstalled, failedCount };
3031
+ }
3032
+ async function removeStaleGhFiles(params) {
3033
+ const { existingLock, newLock, projectRoot, logger: logger5 } = params;
3034
+ const newDeployed = /* @__PURE__ */ new Set();
3035
+ for (const inst of newLock.installations) {
3036
+ for (const file of inst.deployed_files) {
3037
+ newDeployed.add(`${inst.scope}::${file}`);
3038
+ }
3039
+ }
3040
+ for (const prev of existingLock.installations) {
3041
+ for (const deployed of prev.deployed_files) {
3042
+ const key = `${prev.scope}::${deployed}`;
3043
+ if (newDeployed.has(key)) continue;
3044
+ await removeStaleFile({
3045
+ relativePath: deployed,
3046
+ scope: prev.scope === "user" ? "user" : "project",
3047
+ projectRoot,
3048
+ logger: logger5
3049
+ });
3116
3050
  }
3117
3051
  }
3052
+ }
3053
+ async function installSource(params) {
3054
+ const { rs, client, semaphore, projectRoot, existingLock, frozen, update, logger: logger5 } = params;
3055
+ const { entry, owner, repo, agent, scope } = rs;
3056
+ const sourceKey = entry.source;
3057
+ const { resolvedRef, resolvedSha, usedTag } = await resolveGhRef({
3058
+ rs,
3059
+ client,
3060
+ owner,
3061
+ repo,
3062
+ sourceKey,
3063
+ logger: logger5
3064
+ });
3065
+ const validatedSkills = await discoverValidatedSkills({
3066
+ client,
3067
+ semaphore,
3068
+ owner,
3069
+ repo,
3070
+ resolvedSha,
3071
+ sourceKey,
3072
+ logger: logger5
3073
+ });
3074
+ if (validatedSkills === null) {
3075
+ return [];
3076
+ }
3077
+ const selected = selectSkills({ validatedSkills, entry, sourceKey, logger: logger5 });
3078
+ if (frozen && existingLock) {
3079
+ assertFrozenSkillCoverage({ selected, existingLock, sourceKey, agent, scope });
3080
+ }
3118
3081
  const results = [];
3119
3082
  const installRelDir = relativeInstallDirFor({ agent, scope });
3120
3083
  const scopeRoot = scope === "user" ? getHomeDirectory() : projectRoot;
@@ -3136,79 +3099,38 @@ async function installSource(params) {
3136
3099
  ref: resolvedSha,
3137
3100
  semaphore
3138
3101
  });
3139
- const deployed = [];
3140
- for (const file of allFiles) {
3141
- if (file.size > MAX_FILE_SIZE) {
3142
- logger5.warn(
3143
- `Skipping "${file.path}" from ${sourceKey}: ${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
3144
- );
3145
- continue;
3146
- }
3147
- const relativeToSkill = posix3.relative(sk.path, toPosixPath(file.path));
3148
- if (!relativeToSkill || relativeToSkill.startsWith("..") || posix3.isAbsolute(relativeToSkill)) {
3149
- logger5.warn(`Skipping "${file.path}" from ${sourceKey}: resolved outside of "${sk.path}".`);
3150
- continue;
3151
- }
3152
- const deployRelative = toPosixPath(join9(installRelDir, sk.name, relativeToSkill));
3153
- checkPathTraversal({ relativePath: deployRelative, intendedRootDir: scopeRoot });
3154
- const installAbs = join9(scopeRoot, installRelDir);
3155
- const withinInstallDir = toPosixPath(join9(sk.name, relativeToSkill));
3156
- checkPathTraversal({ relativePath: withinInstallDir, intendedRootDir: installAbs });
3157
- let content = await withSemaphore(
3158
- semaphore,
3159
- () => client.getFileContent(owner, repo, file.path, resolvedSha)
3160
- );
3161
- const byteLength = Buffer.byteLength(content, "utf8");
3162
- if (byteLength > MAX_FILE_SIZE) {
3163
- logger5.warn(
3164
- `Skipping "${file.path}" from ${sourceKey}: fetched ${(byteLength / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
3165
- );
3166
- continue;
3167
- }
3168
- if (basename(file.path) === SKILL_FILE_NAME2) {
3169
- try {
3170
- content = injectSourceMetadata({
3171
- content,
3172
- source: sourceUrl,
3173
- repository,
3174
- ref: provenanceRef
3175
- });
3176
- } catch {
3177
- logger5.warn(
3178
- `Frontmatter in ${file.path} (${sourceKey}) is invalid. Prepending a fresh provenance block.`
3179
- );
3180
- content = `---
3181
- source: ${sourceUrl}
3182
- repository: ${repository}
3183
- ref: ${provenanceRef}
3184
- ---
3185
- ${content}`;
3186
- }
3187
- }
3188
- const absolutePath = join9(scopeRoot, deployRelative);
3189
- deployed.push({ relativeToScopeRoot: deployRelative, absolutePath, content });
3190
- if (!frozen) {
3191
- await writeFileContent(absolutePath, content);
3192
- }
3193
- }
3102
+ const deployed = await buildSkillDeployment({
3103
+ sk,
3104
+ allFiles,
3105
+ client,
3106
+ semaphore,
3107
+ owner,
3108
+ repo,
3109
+ resolvedSha,
3110
+ installRelDir,
3111
+ scopeRoot,
3112
+ sourceUrl,
3113
+ repository,
3114
+ provenanceRef,
3115
+ sourceKey,
3116
+ frozen,
3117
+ logger: logger5
3118
+ });
3194
3119
  deployed.sort(
3195
3120
  (a, b) => a.relativeToScopeRoot < b.relativeToScopeRoot ? -1 : a.relativeToScopeRoot > b.relativeToScopeRoot ? 1 : 0
3196
3121
  );
3197
3122
  const deployedFiles = deployed.map((d) => d.relativeToScopeRoot);
3198
3123
  const contentHash = computeContentHash2(deployed);
3199
- if (frozen && locked?.content_hash) {
3200
- if (RULESYNC_CONTENT_HASH_REGEX2.test(locked.content_hash)) {
3201
- if (locked.content_hash !== contentHash) {
3202
- throw new Error(
3203
- `content_hash mismatch for ${sourceKey} skill "${sk.name}" (agent=${agent}, scope=${scope}): lock=${locked.content_hash} computed=${contentHash}. Refuse to trust the deployment under --frozen.`
3204
- );
3205
- }
3206
- } else {
3207
- logger5.debug(
3208
- `Skipping content_hash integrity check for ${sourceKey} skill "${sk.name}": recorded hash "${locked.content_hash}" was not written by rulesync.`
3209
- );
3210
- }
3211
- }
3124
+ assertFrozenSkillIntegrity({
3125
+ frozen,
3126
+ locked,
3127
+ contentHash,
3128
+ sourceKey,
3129
+ skillName: sk.name,
3130
+ agent,
3131
+ scope,
3132
+ logger: logger5
3133
+ });
3212
3134
  const installation = {
3213
3135
  source: sourceKey,
3214
3136
  owner,
@@ -3232,6 +3154,180 @@ ${content}`;
3232
3154
  }
3233
3155
  return results;
3234
3156
  }
3157
+ async function resolveGhRef(params) {
3158
+ const { rs, client, owner, repo, sourceKey, logger: logger5 } = params;
3159
+ let resolvedRef;
3160
+ let usedTag = false;
3161
+ if (rs.ref) {
3162
+ resolvedRef = rs.ref;
3163
+ } else {
3164
+ try {
3165
+ const release = await client.getLatestRelease(owner, repo);
3166
+ resolvedRef = release.tag_name;
3167
+ usedTag = true;
3168
+ } catch (error) {
3169
+ if (is404(error)) {
3170
+ resolvedRef = await client.getDefaultBranch(owner, repo);
3171
+ } else {
3172
+ throw error;
3173
+ }
3174
+ }
3175
+ }
3176
+ const resolvedSha = await client.resolveRefToSha(owner, repo, resolvedRef);
3177
+ logger5.debug(`Resolved ${sourceKey} -> ref=${resolvedRef} sha=${resolvedSha}`);
3178
+ return { resolvedRef, resolvedSha, usedTag };
3179
+ }
3180
+ async function discoverValidatedSkills(params) {
3181
+ const { client, semaphore, owner, repo, resolvedSha, sourceKey, logger: logger5 } = params;
3182
+ let topLevel;
3183
+ try {
3184
+ topLevel = await client.listDirectory(owner, repo, SKILLS_REMOTE_DIR, resolvedSha);
3185
+ } catch (error) {
3186
+ if (is404(error)) {
3187
+ logger5.warn(`No skills/ directory found in ${sourceKey}. Skipping.`);
3188
+ return null;
3189
+ }
3190
+ throw error;
3191
+ }
3192
+ const skillDirs = topLevel.filter((e) => e.type === "dir").map((e) => ({ name: e.name, path: e.path }));
3193
+ const validatedSkills = [];
3194
+ for (const sk of skillDirs) {
3195
+ const info = await withSemaphore(
3196
+ semaphore,
3197
+ () => client.getFileInfo(owner, repo, posix3.join(sk.path, SKILL_FILE_NAME2), resolvedSha)
3198
+ );
3199
+ if (info) {
3200
+ validatedSkills.push(sk);
3201
+ }
3202
+ }
3203
+ return validatedSkills;
3204
+ }
3205
+ function selectSkills(params) {
3206
+ const { validatedSkills, entry, sourceKey, logger: logger5 } = params;
3207
+ if (!entry.skills || entry.skills.length === 0) {
3208
+ return validatedSkills;
3209
+ }
3210
+ const requested = new Set(entry.skills);
3211
+ const selected = validatedSkills.filter((s) => requested.has(s.name));
3212
+ const presentNames = new Set(validatedSkills.map((s) => s.name));
3213
+ for (const want of entry.skills) {
3214
+ if (!presentNames.has(want)) {
3215
+ logger5.warn(`Requested skill "${want}" not found in ${sourceKey} under skills/. Skipping.`);
3216
+ }
3217
+ }
3218
+ return selected;
3219
+ }
3220
+ function assertFrozenSkillCoverage(params) {
3221
+ const { selected, existingLock, sourceKey, agent, scope } = params;
3222
+ const missing = [];
3223
+ for (const sk of selected) {
3224
+ const locked = findGhLockInstallation(existingLock, {
3225
+ source: sourceKey,
3226
+ agent,
3227
+ scope,
3228
+ skill: sk.name
3229
+ });
3230
+ if (!locked) {
3231
+ missing.push(sk.name);
3232
+ }
3233
+ }
3234
+ if (missing.length > 0) {
3235
+ throw new Error(
3236
+ `Frozen install failed: rulesync-gh.lock.yaml is missing entries for ${sourceKey} (agent=${agent}, scope=${scope}) skills: ${missing.join(", ")}. Run 'rulesync install --mode gh' to update the lockfile.`
3237
+ );
3238
+ }
3239
+ }
3240
+ async function buildSkillDeployment(params) {
3241
+ const {
3242
+ sk,
3243
+ allFiles,
3244
+ client,
3245
+ semaphore,
3246
+ owner,
3247
+ repo,
3248
+ resolvedSha,
3249
+ installRelDir,
3250
+ scopeRoot,
3251
+ sourceUrl,
3252
+ repository,
3253
+ provenanceRef,
3254
+ sourceKey,
3255
+ frozen,
3256
+ logger: logger5
3257
+ } = params;
3258
+ const deployed = [];
3259
+ for (const file of allFiles) {
3260
+ if (file.size > MAX_FILE_SIZE) {
3261
+ logger5.warn(
3262
+ `Skipping "${file.path}" from ${sourceKey}: ${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
3263
+ );
3264
+ continue;
3265
+ }
3266
+ const relativeToSkill = posix3.relative(sk.path, toPosixPath(file.path));
3267
+ if (!relativeToSkill || relativeToSkill.startsWith("..") || posix3.isAbsolute(relativeToSkill)) {
3268
+ logger5.warn(`Skipping "${file.path}" from ${sourceKey}: resolved outside of "${sk.path}".`);
3269
+ continue;
3270
+ }
3271
+ const deployRelative = toPosixPath(join9(installRelDir, sk.name, relativeToSkill));
3272
+ checkPathTraversal({ relativePath: deployRelative, intendedRootDir: scopeRoot });
3273
+ const installAbs = join9(scopeRoot, installRelDir);
3274
+ const withinInstallDir = toPosixPath(join9(sk.name, relativeToSkill));
3275
+ checkPathTraversal({ relativePath: withinInstallDir, intendedRootDir: installAbs });
3276
+ let content = await withSemaphore(
3277
+ semaphore,
3278
+ () => client.getFileContent(owner, repo, file.path, resolvedSha)
3279
+ );
3280
+ const byteLength = Buffer.byteLength(content, "utf8");
3281
+ if (byteLength > MAX_FILE_SIZE) {
3282
+ logger5.warn(
3283
+ `Skipping "${file.path}" from ${sourceKey}: fetched ${(byteLength / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
3284
+ );
3285
+ continue;
3286
+ }
3287
+ if (basename(file.path) === SKILL_FILE_NAME2) {
3288
+ try {
3289
+ content = injectSourceMetadata({
3290
+ content,
3291
+ source: sourceUrl,
3292
+ repository,
3293
+ ref: provenanceRef
3294
+ });
3295
+ } catch {
3296
+ logger5.warn(
3297
+ `Frontmatter in ${file.path} (${sourceKey}) is invalid. Prepending a fresh provenance block.`
3298
+ );
3299
+ content = `---
3300
+ source: ${sourceUrl}
3301
+ repository: ${repository}
3302
+ ref: ${provenanceRef}
3303
+ ---
3304
+ ${content}`;
3305
+ }
3306
+ }
3307
+ const absolutePath = join9(scopeRoot, deployRelative);
3308
+ deployed.push({ relativeToScopeRoot: deployRelative, absolutePath, content });
3309
+ if (!frozen) {
3310
+ await writeFileContent(absolutePath, content);
3311
+ }
3312
+ }
3313
+ return deployed;
3314
+ }
3315
+ function assertFrozenSkillIntegrity(params) {
3316
+ const { frozen, locked, contentHash, sourceKey, skillName, agent, scope, logger: logger5 } = params;
3317
+ if (frozen && locked?.content_hash) {
3318
+ if (RULESYNC_CONTENT_HASH_REGEX2.test(locked.content_hash)) {
3319
+ if (locked.content_hash !== contentHash) {
3320
+ throw new Error(
3321
+ `content_hash mismatch for ${sourceKey} skill "${skillName}" (agent=${agent}, scope=${scope}): lock=${locked.content_hash} computed=${contentHash}. Refuse to trust the deployment under --frozen.`
3322
+ );
3323
+ }
3324
+ } else {
3325
+ logger5.debug(
3326
+ `Skipping content_hash integrity check for ${sourceKey} skill "${skillName}": recorded hash "${locked.content_hash}" was not written by rulesync.`
3327
+ );
3328
+ }
3329
+ }
3330
+ }
3235
3331
  async function removeStaleFile(params) {
3236
3332
  const { relativePath, scope, projectRoot, logger: logger5 } = params;
3237
3333
  if (posix3.isAbsolute(relativePath) || relativePath.split(/[/\\]/).includes("..")) {
@@ -3623,18 +3719,7 @@ async function resolveAndFetchSources(params) {
3623
3719
  }
3624
3720
  let lock = options.updateSources ? createEmptyLock() : await readLockFile({ projectRoot, logger: logger5 });
3625
3721
  if (options.frozen) {
3626
- const missingKeys = [];
3627
- for (const source of sources) {
3628
- const locked = getLockedSource(lock, source.source);
3629
- if (!locked) {
3630
- missingKeys.push(source.source);
3631
- }
3632
- }
3633
- if (missingKeys.length > 0) {
3634
- throw new Error(
3635
- `Frozen install failed: lockfile is missing entries for: ${missingKeys.join(", ")}. Run 'rulesync install' to update the lockfile.`
3636
- );
3637
- }
3722
+ assertFrozenLockCoversSources2({ lock, sources });
3638
3723
  }
3639
3724
  const originalLockJson = JSON.stringify(lock);
3640
3725
  const token = GitHubClient.resolveToken(options.token);
@@ -3644,31 +3729,17 @@ async function resolveAndFetchSources(params) {
3644
3729
  const allFetchedSkillNames = /* @__PURE__ */ new Set();
3645
3730
  for (const sourceEntry of sources) {
3646
3731
  try {
3647
- const transport = sourceEntry.transport ?? "github";
3648
- let result;
3649
- if (transport === "git") {
3650
- result = await fetchSourceViaGit({
3651
- sourceEntry,
3652
- projectRoot,
3653
- lock,
3654
- localSkillNames,
3655
- alreadyFetchedSkillNames: allFetchedSkillNames,
3656
- updateSources: options.updateSources ?? false,
3657
- frozen: options.frozen ?? false,
3658
- logger: logger5
3659
- });
3660
- } else {
3661
- result = await fetchSource({
3662
- sourceEntry,
3663
- client,
3664
- projectRoot,
3665
- lock,
3666
- localSkillNames,
3667
- alreadyFetchedSkillNames: allFetchedSkillNames,
3668
- updateSources: options.updateSources ?? false,
3669
- logger: logger5
3670
- });
3671
- }
3732
+ const result = await fetchSourceByTransport({
3733
+ sourceEntry,
3734
+ client,
3735
+ projectRoot,
3736
+ lock,
3737
+ localSkillNames,
3738
+ alreadyFetchedSkillNames: allFetchedSkillNames,
3739
+ updateSources: options.updateSources ?? false,
3740
+ frozen: options.frozen ?? false,
3741
+ logger: logger5
3742
+ });
3672
3743
  const { skillCount, fetchedSkillNames, updatedLock } = result;
3673
3744
  lock = updatedLock;
3674
3745
  totalSkillCount += skillCount;
@@ -3684,16 +3755,7 @@ async function resolveAndFetchSources(params) {
3684
3755
  }
3685
3756
  }
3686
3757
  }
3687
- const sourceKeys = new Set(sources.map((s) => normalizeSourceKey(s.source)));
3688
- const prunedSources = {};
3689
- for (const [key, value] of Object.entries(lock.sources)) {
3690
- if (sourceKeys.has(normalizeSourceKey(key))) {
3691
- prunedSources[key] = value;
3692
- } else {
3693
- logger5.debug(`Pruned stale lockfile entry: ${key}`);
3694
- }
3695
- }
3696
- lock = { lockfileVersion: lock.lockfileVersion, sources: prunedSources };
3758
+ lock = pruneStaleLockEntries({ lock, sources, logger: logger5 });
3697
3759
  if (!options.frozen && JSON.stringify(lock) !== originalLockJson) {
3698
3760
  await writeLockFile({ projectRoot, lock, logger: logger5 });
3699
3761
  } else {
@@ -3709,6 +3771,70 @@ function logGitClientHints(params) {
3709
3771
  logger5.info("Hint: Check your git credentials (SSH keys, credential helper, or access token).");
3710
3772
  }
3711
3773
  }
3774
+ function assertFrozenLockCoversSources2(params) {
3775
+ const { lock, sources } = params;
3776
+ const missingKeys = [];
3777
+ for (const source of sources) {
3778
+ const locked = getLockedSource(lock, source.source);
3779
+ if (!locked) {
3780
+ missingKeys.push(source.source);
3781
+ }
3782
+ }
3783
+ if (missingKeys.length > 0) {
3784
+ throw new Error(
3785
+ `Frozen install failed: lockfile is missing entries for: ${missingKeys.join(", ")}. Run 'rulesync install' to update the lockfile.`
3786
+ );
3787
+ }
3788
+ }
3789
+ async function fetchSourceByTransport(params) {
3790
+ const {
3791
+ sourceEntry,
3792
+ client,
3793
+ projectRoot,
3794
+ lock,
3795
+ localSkillNames,
3796
+ alreadyFetchedSkillNames,
3797
+ updateSources,
3798
+ frozen,
3799
+ logger: logger5
3800
+ } = params;
3801
+ const transport = sourceEntry.transport ?? "github";
3802
+ if (transport === "git") {
3803
+ return fetchSourceViaGit({
3804
+ sourceEntry,
3805
+ projectRoot,
3806
+ lock,
3807
+ localSkillNames,
3808
+ alreadyFetchedSkillNames,
3809
+ updateSources,
3810
+ frozen,
3811
+ logger: logger5
3812
+ });
3813
+ }
3814
+ return fetchSource({
3815
+ sourceEntry,
3816
+ client,
3817
+ projectRoot,
3818
+ lock,
3819
+ localSkillNames,
3820
+ alreadyFetchedSkillNames,
3821
+ updateSources,
3822
+ logger: logger5
3823
+ });
3824
+ }
3825
+ function pruneStaleLockEntries(params) {
3826
+ const { lock, sources, logger: logger5 } = params;
3827
+ const sourceKeys = new Set(sources.map((s) => normalizeSourceKey(s.source)));
3828
+ const prunedSources = {};
3829
+ for (const [key, value] of Object.entries(lock.sources)) {
3830
+ if (sourceKeys.has(normalizeSourceKey(key))) {
3831
+ prunedSources[key] = value;
3832
+ } else {
3833
+ logger5.debug(`Pruned stale lockfile entry: ${key}`);
3834
+ }
3835
+ }
3836
+ return { lockfileVersion: lock.lockfileVersion, sources: prunedSources };
3837
+ }
3712
3838
  async function checkLockedSkillsExist(curatedDir, skillNames) {
3713
3839
  if (skillNames.length === 0) return true;
3714
3840
  for (const name of skillNames) {
@@ -3840,7 +3966,189 @@ function groupRemoteFilesBySkillRoot(params) {
3840
3966
  grouped.set(singleSkillName, rootLevelFiles);
3841
3967
  }
3842
3968
  }
3843
- return grouped;
3969
+ return grouped;
3970
+ }
3971
+ async function resolveGithubFetchRef(params) {
3972
+ const { parsed, locked, updateSources, sourceKey, client, logger: logger5 } = params;
3973
+ if (locked && !updateSources) {
3974
+ logger5.debug(`Using locked ref for ${sourceKey}: ${locked.resolvedRef}`);
3975
+ return {
3976
+ ref: locked.resolvedRef,
3977
+ resolvedSha: locked.resolvedRef,
3978
+ requestedRef: locked.requestedRef
3979
+ };
3980
+ }
3981
+ const requestedRef = parsed.ref ?? await client.getDefaultBranch(parsed.owner, parsed.repo);
3982
+ const resolvedSha = await client.resolveRefToSha(parsed.owner, parsed.repo, requestedRef);
3983
+ logger5.debug(`Resolved ${sourceKey} ref "${requestedRef}" to SHA: ${resolvedSha}`);
3984
+ return { ref: resolvedSha, resolvedSha, requestedRef };
3985
+ }
3986
+ async function fetchRootLevelFallbackSkill(params) {
3987
+ const {
3988
+ entries,
3989
+ parsed,
3990
+ ref,
3991
+ resolvedSha,
3992
+ skillFilter,
3993
+ isWildcard,
3994
+ curatedDir,
3995
+ locked,
3996
+ sourceKey,
3997
+ localSkillNames,
3998
+ alreadyFetchedSkillNames,
3999
+ client,
4000
+ semaphore,
4001
+ fetchedSkills,
4002
+ logger: logger5
4003
+ } = params;
4004
+ const rootFiles = entries.filter((entry) => entry.type === "file");
4005
+ const rootSkillFiles = [];
4006
+ for (const file of rootFiles) {
4007
+ if (file.size > MAX_FILE_SIZE) {
4008
+ logger5.warn(
4009
+ `Skipping file "${file.path}" (${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit).`
4010
+ );
4011
+ continue;
4012
+ }
4013
+ const content = await withSemaphore(
4014
+ semaphore,
4015
+ () => client.getFileContent(parsed.owner, parsed.repo, file.path, ref)
4016
+ );
4017
+ rootSkillFiles.push({ relativePath: file.name, content });
4018
+ }
4019
+ const groupedRootFiles = groupRemoteFilesBySkillRoot({
4020
+ remoteFiles: rootSkillFiles,
4021
+ skillFilter,
4022
+ isWildcard
4023
+ });
4024
+ const [fallbackSkillName] = groupedRootFiles.keys();
4025
+ if (fallbackSkillName === void 0) {
4026
+ return { handled: false, remoteSkillNames: [] };
4027
+ }
4028
+ if (!shouldSkipSkill({
4029
+ skillName: fallbackSkillName,
4030
+ sourceKey,
4031
+ localSkillNames,
4032
+ alreadyFetchedSkillNames,
4033
+ logger: logger5
4034
+ })) {
4035
+ fetchedSkills[fallbackSkillName] = await writeSkillAndComputeIntegrity({
4036
+ skillName: fallbackSkillName,
4037
+ files: groupedRootFiles.get(fallbackSkillName) ?? [],
4038
+ curatedDir,
4039
+ locked,
4040
+ resolvedSha,
4041
+ sourceKey,
4042
+ logger: logger5
4043
+ });
4044
+ logger5.debug(`Fetched skill "${fallbackSkillName}" from ${sourceKey}`);
4045
+ }
4046
+ return { handled: true, remoteSkillNames: [fallbackSkillName] };
4047
+ }
4048
+ async function fetchGithubSkillDir(params) {
4049
+ const {
4050
+ skillDir,
4051
+ parsed,
4052
+ ref,
4053
+ resolvedSha,
4054
+ curatedDir,
4055
+ locked,
4056
+ sourceKey,
4057
+ client,
4058
+ semaphore,
4059
+ logger: logger5
4060
+ } = params;
4061
+ const allFiles = await listDirectoryRecursive({
4062
+ client,
4063
+ owner: parsed.owner,
4064
+ repo: parsed.repo,
4065
+ path: skillDir.path,
4066
+ ref,
4067
+ semaphore
4068
+ });
4069
+ const files = allFiles.filter((file) => {
4070
+ if (file.size > MAX_FILE_SIZE) {
4071
+ logger5.warn(
4072
+ `Skipping file "${file.path}" (${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit).`
4073
+ );
4074
+ return false;
4075
+ }
4076
+ return true;
4077
+ });
4078
+ const skillFiles = [];
4079
+ for (const file of files) {
4080
+ const relativeToSkill = file.path.substring(skillDir.path.length + 1);
4081
+ const content = await withSemaphore(
4082
+ semaphore,
4083
+ () => client.getFileContent(parsed.owner, parsed.repo, file.path, ref)
4084
+ );
4085
+ skillFiles.push({ relativePath: relativeToSkill, content });
4086
+ }
4087
+ return writeSkillAndComputeIntegrity({
4088
+ skillName: skillDir.name,
4089
+ files: skillFiles,
4090
+ curatedDir,
4091
+ locked,
4092
+ resolvedSha,
4093
+ sourceKey,
4094
+ logger: logger5
4095
+ });
4096
+ }
4097
+ async function discoverGithubSkillDirs(params) {
4098
+ const {
4099
+ parsed,
4100
+ ref,
4101
+ resolvedSha,
4102
+ skillFilter,
4103
+ isWildcard,
4104
+ curatedDir,
4105
+ locked,
4106
+ sourceKey,
4107
+ localSkillNames,
4108
+ alreadyFetchedSkillNames,
4109
+ client,
4110
+ semaphore,
4111
+ fetchedSkills,
4112
+ logger: logger5
4113
+ } = params;
4114
+ const skillsBasePath = parsed.path ?? "skills";
4115
+ try {
4116
+ const entries = await client.listDirectory(parsed.owner, parsed.repo, skillsBasePath, ref);
4117
+ const remoteSkillDirs = entries.filter((e) => e.type === "dir").map((e) => ({ name: e.name, path: e.path }));
4118
+ if (remoteSkillDirs.length === 0 && !isWildcard && skillFilter.length === 1) {
4119
+ const fallback = await fetchRootLevelFallbackSkill({
4120
+ entries,
4121
+ parsed,
4122
+ ref,
4123
+ resolvedSha,
4124
+ skillFilter,
4125
+ isWildcard,
4126
+ curatedDir,
4127
+ locked,
4128
+ sourceKey,
4129
+ localSkillNames,
4130
+ alreadyFetchedSkillNames,
4131
+ client,
4132
+ semaphore,
4133
+ fetchedSkills,
4134
+ logger: logger5
4135
+ });
4136
+ if (fallback.handled) {
4137
+ return {
4138
+ status: "ok",
4139
+ remoteSkillDirs,
4140
+ fallbackHandled: true,
4141
+ remoteSkillNames: fallback.remoteSkillNames
4142
+ };
4143
+ }
4144
+ }
4145
+ return { status: "ok", remoteSkillDirs, fallbackHandled: false, remoteSkillNames: [] };
4146
+ } catch (error) {
4147
+ if (error instanceof GitHubClientError && error.statusCode === 404) {
4148
+ return { status: "notFound" };
4149
+ }
4150
+ throw error;
4151
+ }
3844
4152
  }
3845
4153
  async function fetchSource(params) {
3846
4154
  const {
@@ -3861,20 +4169,14 @@ async function fetchSource(params) {
3861
4169
  const sourceKey = sourceEntry.source;
3862
4170
  const locked = getLockedSource(lock, sourceKey);
3863
4171
  const lockedSkillNames = locked ? getLockedSkillNames(locked) : [];
3864
- let ref;
3865
- let resolvedSha;
3866
- let requestedRef;
3867
- if (locked && !updateSources) {
3868
- ref = locked.resolvedRef;
3869
- resolvedSha = locked.resolvedRef;
3870
- requestedRef = locked.requestedRef;
3871
- logger5.debug(`Using locked ref for ${sourceKey}: ${resolvedSha}`);
3872
- } else {
3873
- requestedRef = parsed.ref ?? await client.getDefaultBranch(parsed.owner, parsed.repo);
3874
- resolvedSha = await client.resolveRefToSha(parsed.owner, parsed.repo, requestedRef);
3875
- ref = resolvedSha;
3876
- logger5.debug(`Resolved ${sourceKey} ref "${requestedRef}" to SHA: ${resolvedSha}`);
3877
- }
4172
+ const { ref, resolvedSha, requestedRef } = await resolveGithubFetchRef({
4173
+ parsed,
4174
+ locked,
4175
+ updateSources,
4176
+ sourceKey,
4177
+ client,
4178
+ logger: logger5
4179
+ });
3878
4180
  const curatedDir = join12(projectRoot, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH);
3879
4181
  if (locked && resolvedSha === locked.resolvedRef && !updateSources) {
3880
4182
  const allExist = await checkLockedSkillsExist(curatedDir, lockedSkillNames);
@@ -3891,69 +4193,29 @@ async function fetchSource(params) {
3891
4193
  const isWildcard = skillFilter.length === 1 && skillFilter[0] === "*";
3892
4194
  const semaphore = new Semaphore4(FETCH_CONCURRENCY_LIMIT);
3893
4195
  const fetchedSkills = {};
3894
- const skillsBasePath = parsed.path ?? "skills";
3895
- let remoteSkillDirs;
3896
- let remoteSkillNames = [];
3897
- let fallbackHandled = false;
3898
- try {
3899
- const entries = await client.listDirectory(parsed.owner, parsed.repo, skillsBasePath, ref);
3900
- remoteSkillDirs = entries.filter((e) => e.type === "dir").map((e) => ({ name: e.name, path: e.path }));
3901
- if (remoteSkillDirs.length === 0 && !isWildcard && skillFilter.length === 1) {
3902
- const rootFiles = entries.filter((entry) => entry.type === "file");
3903
- const rootSkillFiles = [];
3904
- for (const file of rootFiles) {
3905
- if (file.size > MAX_FILE_SIZE) {
3906
- logger5.warn(
3907
- `Skipping file "${file.path}" (${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit).`
3908
- );
3909
- continue;
3910
- }
3911
- const content = await withSemaphore(
3912
- semaphore,
3913
- () => client.getFileContent(parsed.owner, parsed.repo, file.path, ref)
3914
- );
3915
- rootSkillFiles.push({ relativePath: file.name, content });
3916
- }
3917
- const groupedRootFiles = groupRemoteFilesBySkillRoot({
3918
- remoteFiles: rootSkillFiles,
3919
- skillFilter,
3920
- isWildcard
3921
- });
3922
- const [fallbackSkillName] = groupedRootFiles.keys();
3923
- if (fallbackSkillName !== void 0) {
3924
- fallbackHandled = true;
3925
- remoteSkillNames = [fallbackSkillName];
3926
- if (!shouldSkipSkill({
3927
- skillName: fallbackSkillName,
3928
- sourceKey,
3929
- localSkillNames,
3930
- alreadyFetchedSkillNames,
3931
- logger: logger5
3932
- })) {
3933
- fetchedSkills[fallbackSkillName] = await writeSkillAndComputeIntegrity({
3934
- skillName: fallbackSkillName,
3935
- files: groupedRootFiles.get(fallbackSkillName) ?? [],
3936
- curatedDir,
3937
- locked,
3938
- resolvedSha,
3939
- sourceKey,
3940
- logger: logger5
3941
- });
3942
- logger5.debug(`Fetched skill "${fallbackSkillName}" from ${sourceKey}`);
3943
- }
3944
- }
3945
- }
3946
- } catch (error) {
3947
- if (error instanceof GitHubClientError && error.statusCode === 404) {
3948
- logger5.warn(`No skills/ directory found in ${sourceKey}. Skipping.`);
3949
- return { skillCount: 0, fetchedSkillNames: [], updatedLock: lock };
3950
- }
3951
- throw error;
4196
+ const discovery = await discoverGithubSkillDirs({
4197
+ parsed,
4198
+ ref,
4199
+ resolvedSha,
4200
+ skillFilter,
4201
+ isWildcard,
4202
+ curatedDir,
4203
+ locked,
4204
+ sourceKey,
4205
+ localSkillNames,
4206
+ alreadyFetchedSkillNames,
4207
+ client,
4208
+ semaphore,
4209
+ fetchedSkills,
4210
+ logger: logger5
4211
+ });
4212
+ if (discovery.status === "notFound") {
4213
+ logger5.warn(`No skills/ directory found in ${sourceKey}. Skipping.`);
4214
+ return { skillCount: 0, fetchedSkillNames: [], updatedLock: lock };
3952
4215
  }
4216
+ const { remoteSkillDirs, fallbackHandled, remoteSkillNames: fallbackSkillNames } = discovery;
3953
4217
  const filteredDirs = isWildcard ? remoteSkillDirs : remoteSkillDirs.filter((d) => skillFilter.includes(d.name));
3954
- if (!fallbackHandled) {
3955
- remoteSkillNames = filteredDirs.map((d) => d.name);
3956
- }
4218
+ const remoteSkillNames = fallbackHandled ? fallbackSkillNames : filteredDirs.map((d) => d.name);
3957
4219
  if (locked) {
3958
4220
  await cleanPreviousCuratedSkills({ curatedDir, lockedSkillNames, logger: logger5 });
3959
4221
  }
@@ -3967,39 +4229,16 @@ async function fetchSource(params) {
3967
4229
  })) {
3968
4230
  continue;
3969
4231
  }
3970
- const allFiles = await listDirectoryRecursive({
3971
- client,
3972
- owner: parsed.owner,
3973
- repo: parsed.repo,
3974
- path: skillDir.path,
4232
+ fetchedSkills[skillDir.name] = await fetchGithubSkillDir({
4233
+ skillDir,
4234
+ parsed,
3975
4235
  ref,
3976
- semaphore
3977
- });
3978
- const files = allFiles.filter((file) => {
3979
- if (file.size > MAX_FILE_SIZE) {
3980
- logger5.warn(
3981
- `Skipping file "${file.path}" (${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit).`
3982
- );
3983
- return false;
3984
- }
3985
- return true;
3986
- });
3987
- const skillFiles = [];
3988
- for (const file of files) {
3989
- const relativeToSkill = file.path.substring(skillDir.path.length + 1);
3990
- const content = await withSemaphore(
3991
- semaphore,
3992
- () => client.getFileContent(parsed.owner, parsed.repo, file.path, ref)
3993
- );
3994
- skillFiles.push({ relativePath: relativeToSkill, content });
3995
- }
3996
- fetchedSkills[skillDir.name] = await writeSkillAndComputeIntegrity({
3997
- skillName: skillDir.name,
3998
- files: skillFiles,
4236
+ resolvedSha,
3999
4237
  curatedDir,
4000
4238
  locked,
4001
- resolvedSha,
4002
4239
  sourceKey,
4240
+ client,
4241
+ semaphore,
4003
4242
  logger: logger5
4004
4243
  });
4005
4244
  logger5.debug(`Fetched skill "${skillDir.name}" from ${sourceKey}`);
@@ -5908,6 +6147,174 @@ function ensureBody({ body, feature, operation }) {
5908
6147
  }
5909
6148
  return body;
5910
6149
  }
6150
+ function requireContent({
6151
+ content,
6152
+ feature
6153
+ }) {
6154
+ if (!content) {
6155
+ throw new Error(`content is required for ${feature} put operation`);
6156
+ }
6157
+ return content;
6158
+ }
6159
+ function executeRule(parsed) {
6160
+ if (parsed.operation === "list") {
6161
+ return ruleTools.listRules.execute();
6162
+ }
6163
+ if (parsed.operation === "get") {
6164
+ return ruleTools.getRule.execute({ relativePathFromCwd: requireTargetPath(parsed) });
6165
+ }
6166
+ if (parsed.operation === "put") {
6167
+ return ruleTools.putRule.execute({
6168
+ relativePathFromCwd: requireTargetPath(parsed),
6169
+ frontmatter: parseFrontmatter({
6170
+ feature: "rule",
6171
+ frontmatter: parsed.frontmatter ?? {}
6172
+ }),
6173
+ body: ensureBody(parsed)
6174
+ });
6175
+ }
6176
+ return ruleTools.deleteRule.execute({ relativePathFromCwd: requireTargetPath(parsed) });
6177
+ }
6178
+ function executeCommand(parsed) {
6179
+ if (parsed.operation === "list") {
6180
+ return commandTools.listCommands.execute();
6181
+ }
6182
+ if (parsed.operation === "get") {
6183
+ return commandTools.getCommand.execute({
6184
+ relativePathFromCwd: requireTargetPath(parsed)
6185
+ });
6186
+ }
6187
+ if (parsed.operation === "put") {
6188
+ return commandTools.putCommand.execute({
6189
+ relativePathFromCwd: requireTargetPath(parsed),
6190
+ frontmatter: parseFrontmatter({
6191
+ feature: "command",
6192
+ frontmatter: parsed.frontmatter ?? {}
6193
+ }),
6194
+ body: ensureBody(parsed)
6195
+ });
6196
+ }
6197
+ return commandTools.deleteCommand.execute({
6198
+ relativePathFromCwd: requireTargetPath(parsed)
6199
+ });
6200
+ }
6201
+ function executeSubagent(parsed) {
6202
+ if (parsed.operation === "list") {
6203
+ return subagentTools.listSubagents.execute();
6204
+ }
6205
+ if (parsed.operation === "get") {
6206
+ return subagentTools.getSubagent.execute({
6207
+ relativePathFromCwd: requireTargetPath(parsed)
6208
+ });
6209
+ }
6210
+ if (parsed.operation === "put") {
6211
+ return subagentTools.putSubagent.execute({
6212
+ relativePathFromCwd: requireTargetPath(parsed),
6213
+ frontmatter: parseFrontmatter({
6214
+ feature: "subagent",
6215
+ frontmatter: parsed.frontmatter ?? {}
6216
+ }),
6217
+ body: ensureBody(parsed)
6218
+ });
6219
+ }
6220
+ return subagentTools.deleteSubagent.execute({
6221
+ relativePathFromCwd: requireTargetPath(parsed)
6222
+ });
6223
+ }
6224
+ function executeSkill(parsed) {
6225
+ if (parsed.operation === "list") {
6226
+ return skillTools.listSkills.execute();
6227
+ }
6228
+ if (parsed.operation === "get") {
6229
+ return skillTools.getSkill.execute({ relativeDirPathFromCwd: requireTargetPath(parsed) });
6230
+ }
6231
+ if (parsed.operation === "put") {
6232
+ return skillTools.putSkill.execute({
6233
+ relativeDirPathFromCwd: requireTargetPath(parsed),
6234
+ frontmatter: parseFrontmatter({
6235
+ feature: "skill",
6236
+ frontmatter: parsed.frontmatter ?? {}
6237
+ }),
6238
+ body: ensureBody(parsed),
6239
+ otherFiles: parsed.otherFiles ?? []
6240
+ });
6241
+ }
6242
+ return skillTools.deleteSkill.execute({
6243
+ relativeDirPathFromCwd: requireTargetPath(parsed)
6244
+ });
6245
+ }
6246
+ function executeIgnore(parsed) {
6247
+ if (parsed.operation === "get") {
6248
+ return ignoreTools.getIgnoreFile.execute();
6249
+ }
6250
+ if (parsed.operation === "put") {
6251
+ return ignoreTools.putIgnoreFile.execute({
6252
+ content: requireContent({ content: parsed.content, feature: "ignore" })
6253
+ });
6254
+ }
6255
+ return ignoreTools.deleteIgnoreFile.execute();
6256
+ }
6257
+ function executeMcp(parsed) {
6258
+ if (parsed.operation === "get") {
6259
+ return mcpTools.getMcpFile.execute();
6260
+ }
6261
+ if (parsed.operation === "put") {
6262
+ return mcpTools.putMcpFile.execute({
6263
+ content: requireContent({ content: parsed.content, feature: "mcp" })
6264
+ });
6265
+ }
6266
+ return mcpTools.deleteMcpFile.execute();
6267
+ }
6268
+ function executePermissions(parsed) {
6269
+ if (parsed.operation === "get") {
6270
+ return permissionsTools.getPermissionsFile.execute();
6271
+ }
6272
+ if (parsed.operation === "put") {
6273
+ return permissionsTools.putPermissionsFile.execute({
6274
+ content: requireContent({ content: parsed.content, feature: "permissions" })
6275
+ });
6276
+ }
6277
+ return permissionsTools.deletePermissionsFile.execute();
6278
+ }
6279
+ function executeHooks(parsed) {
6280
+ if (parsed.operation === "get") {
6281
+ return hooksTools.getHooksFile.execute();
6282
+ }
6283
+ if (parsed.operation === "put") {
6284
+ return hooksTools.putHooksFile.execute({
6285
+ content: requireContent({ content: parsed.content, feature: "hooks" })
6286
+ });
6287
+ }
6288
+ return hooksTools.deleteHooksFile.execute();
6289
+ }
6290
+ function executeGenerate2(parsed) {
6291
+ return generateTools.executeGenerate.execute(parsed.generateOptions ?? {});
6292
+ }
6293
+ function executeImport2(parsed) {
6294
+ if (!parsed.importOptions) {
6295
+ throw new Error("importOptions is required for import feature");
6296
+ }
6297
+ return importTools.executeImport.execute(parsed.importOptions);
6298
+ }
6299
+ function executeConvert2(parsed) {
6300
+ if (!parsed.convertOptions) {
6301
+ throw new Error("convertOptions is required for convert feature");
6302
+ }
6303
+ return convertTools.executeConvert.execute(parsed.convertOptions);
6304
+ }
6305
+ var featureExecutors = {
6306
+ rule: executeRule,
6307
+ command: executeCommand,
6308
+ subagent: executeSubagent,
6309
+ skill: executeSkill,
6310
+ ignore: executeIgnore,
6311
+ mcp: executeMcp,
6312
+ permissions: executePermissions,
6313
+ hooks: executeHooks,
6314
+ generate: executeGenerate2,
6315
+ import: executeImport2,
6316
+ convert: executeConvert2
6317
+ };
5911
6318
  var rulesyncTool = {
5912
6319
  name: "rulesyncTool",
5913
6320
  description: "Manage Rulesync files through a single MCP tool. Features: rule/command/subagent/skill support list/get/put/delete; ignore/mcp/permissions/hooks support get/put/delete only; generate supports run only; import supports run only; convert supports run only. Parameters: list requires no targetPathFromCwd (lists all items); get/delete require targetPathFromCwd; put requires targetPathFromCwd, frontmatter, and body (or content for ignore/mcp/permissions/hooks); generate/run uses generateOptions to configure generation; import/run uses importOptions to configure import; convert/run uses convertOptions to configure conversion.",
@@ -5915,161 +6322,11 @@ var rulesyncTool = {
5915
6322
  execute: async (args) => {
5916
6323
  const parsed = rulesyncToolSchema.parse(args);
5917
6324
  assertSupported({ feature: parsed.feature, operation: parsed.operation });
5918
- switch (parsed.feature) {
5919
- case "rule": {
5920
- if (parsed.operation === "list") {
5921
- return ruleTools.listRules.execute();
5922
- }
5923
- if (parsed.operation === "get") {
5924
- return ruleTools.getRule.execute({ relativePathFromCwd: requireTargetPath(parsed) });
5925
- }
5926
- if (parsed.operation === "put") {
5927
- return ruleTools.putRule.execute({
5928
- relativePathFromCwd: requireTargetPath(parsed),
5929
- frontmatter: parseFrontmatter({
5930
- feature: "rule",
5931
- frontmatter: parsed.frontmatter ?? {}
5932
- }),
5933
- body: ensureBody(parsed)
5934
- });
5935
- }
5936
- return ruleTools.deleteRule.execute({ relativePathFromCwd: requireTargetPath(parsed) });
5937
- }
5938
- case "command": {
5939
- if (parsed.operation === "list") {
5940
- return commandTools.listCommands.execute();
5941
- }
5942
- if (parsed.operation === "get") {
5943
- return commandTools.getCommand.execute({
5944
- relativePathFromCwd: requireTargetPath(parsed)
5945
- });
5946
- }
5947
- if (parsed.operation === "put") {
5948
- return commandTools.putCommand.execute({
5949
- relativePathFromCwd: requireTargetPath(parsed),
5950
- frontmatter: parseFrontmatter({
5951
- feature: "command",
5952
- frontmatter: parsed.frontmatter ?? {}
5953
- }),
5954
- body: ensureBody(parsed)
5955
- });
5956
- }
5957
- return commandTools.deleteCommand.execute({
5958
- relativePathFromCwd: requireTargetPath(parsed)
5959
- });
5960
- }
5961
- case "subagent": {
5962
- if (parsed.operation === "list") {
5963
- return subagentTools.listSubagents.execute();
5964
- }
5965
- if (parsed.operation === "get") {
5966
- return subagentTools.getSubagent.execute({
5967
- relativePathFromCwd: requireTargetPath(parsed)
5968
- });
5969
- }
5970
- if (parsed.operation === "put") {
5971
- return subagentTools.putSubagent.execute({
5972
- relativePathFromCwd: requireTargetPath(parsed),
5973
- frontmatter: parseFrontmatter({
5974
- feature: "subagent",
5975
- frontmatter: parsed.frontmatter ?? {}
5976
- }),
5977
- body: ensureBody(parsed)
5978
- });
5979
- }
5980
- return subagentTools.deleteSubagent.execute({
5981
- relativePathFromCwd: requireTargetPath(parsed)
5982
- });
5983
- }
5984
- case "skill": {
5985
- if (parsed.operation === "list") {
5986
- return skillTools.listSkills.execute();
5987
- }
5988
- if (parsed.operation === "get") {
5989
- return skillTools.getSkill.execute({ relativeDirPathFromCwd: requireTargetPath(parsed) });
5990
- }
5991
- if (parsed.operation === "put") {
5992
- return skillTools.putSkill.execute({
5993
- relativeDirPathFromCwd: requireTargetPath(parsed),
5994
- frontmatter: parseFrontmatter({
5995
- feature: "skill",
5996
- frontmatter: parsed.frontmatter ?? {}
5997
- }),
5998
- body: ensureBody(parsed),
5999
- otherFiles: parsed.otherFiles ?? []
6000
- });
6001
- }
6002
- return skillTools.deleteSkill.execute({
6003
- relativeDirPathFromCwd: requireTargetPath(parsed)
6004
- });
6005
- }
6006
- case "ignore": {
6007
- if (parsed.operation === "get") {
6008
- return ignoreTools.getIgnoreFile.execute();
6009
- }
6010
- if (parsed.operation === "put") {
6011
- if (!parsed.content) {
6012
- throw new Error("content is required for ignore put operation");
6013
- }
6014
- return ignoreTools.putIgnoreFile.execute({ content: parsed.content });
6015
- }
6016
- return ignoreTools.deleteIgnoreFile.execute();
6017
- }
6018
- case "mcp": {
6019
- if (parsed.operation === "get") {
6020
- return mcpTools.getMcpFile.execute();
6021
- }
6022
- if (parsed.operation === "put") {
6023
- if (!parsed.content) {
6024
- throw new Error("content is required for mcp put operation");
6025
- }
6026
- return mcpTools.putMcpFile.execute({ content: parsed.content });
6027
- }
6028
- return mcpTools.deleteMcpFile.execute();
6029
- }
6030
- case "permissions": {
6031
- if (parsed.operation === "get") {
6032
- return permissionsTools.getPermissionsFile.execute();
6033
- }
6034
- if (parsed.operation === "put") {
6035
- if (!parsed.content) {
6036
- throw new Error("content is required for permissions put operation");
6037
- }
6038
- return permissionsTools.putPermissionsFile.execute({ content: parsed.content });
6039
- }
6040
- return permissionsTools.deletePermissionsFile.execute();
6041
- }
6042
- case "hooks": {
6043
- if (parsed.operation === "get") {
6044
- return hooksTools.getHooksFile.execute();
6045
- }
6046
- if (parsed.operation === "put") {
6047
- if (!parsed.content) {
6048
- throw new Error("content is required for hooks put operation");
6049
- }
6050
- return hooksTools.putHooksFile.execute({ content: parsed.content });
6051
- }
6052
- return hooksTools.deleteHooksFile.execute();
6053
- }
6054
- case "generate": {
6055
- return generateTools.executeGenerate.execute(parsed.generateOptions ?? {});
6056
- }
6057
- case "import": {
6058
- if (!parsed.importOptions) {
6059
- throw new Error("importOptions is required for import feature");
6060
- }
6061
- return importTools.executeImport.execute(parsed.importOptions);
6062
- }
6063
- case "convert": {
6064
- if (!parsed.convertOptions) {
6065
- throw new Error("convertOptions is required for convert feature");
6066
- }
6067
- return convertTools.executeConvert.execute(parsed.convertOptions);
6068
- }
6069
- default: {
6070
- throw new Error(`Unknown feature: ${parsed.feature}`);
6071
- }
6325
+ const executor = featureExecutors[parsed.feature];
6326
+ if (!executor) {
6327
+ throw new Error(`Unknown feature: ${parsed.feature}`);
6072
6328
  }
6329
+ return executor(parsed);
6073
6330
  }
6074
6331
  };
6075
6332
 
@@ -6288,103 +6545,150 @@ function parseSha256Sums(content) {
6288
6545
  }
6289
6546
  return result;
6290
6547
  }
6291
- async function performBinaryUpdate(currentVersion, options = {}) {
6292
- const { force = false, token } = options;
6293
- const updateCheck = await checkForUpdate(currentVersion, token);
6294
- if (!updateCheck.hasUpdate && !force) {
6295
- return `Already at the latest version (${currentVersion})`;
6296
- }
6548
+ function resolveUpdateAssets(release) {
6297
6549
  const assetName = getPlatformAssetName();
6298
6550
  if (!assetName) {
6299
6551
  throw new Error(
6300
6552
  `Unsupported platform: ${os.platform()} ${os.arch()}. Please download manually from ${RELEASES_URL}`
6301
6553
  );
6302
6554
  }
6303
- const binaryAsset = findAsset(updateCheck.release, assetName);
6555
+ const binaryAsset = findAsset(release, assetName);
6304
6556
  if (!binaryAsset) {
6305
6557
  throw new Error(
6306
6558
  `Binary for ${assetName} not found in release. Please download manually from ${RELEASES_URL}`
6307
6559
  );
6308
6560
  }
6309
- const checksumAsset = findAsset(updateCheck.release, "SHA256SUMS");
6561
+ const checksumAsset = findAsset(release, "SHA256SUMS");
6310
6562
  if (!checksumAsset) {
6311
6563
  throw new Error(
6312
6564
  `SHA256SUMS not found in release. Cannot verify download integrity. Please download manually from ${RELEASES_URL}`
6313
6565
  );
6314
6566
  }
6315
- const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "rulesync-update-"));
6316
- let restoreFailed = false;
6567
+ return { assetName, binaryAsset, checksumAsset };
6568
+ }
6569
+ async function downloadAndVerifyBinary(params) {
6570
+ const { tempDir, assetName, binaryAsset, checksumAsset } = params;
6571
+ const tempBinaryPath = path.join(tempDir, assetName);
6572
+ await downloadFile(binaryAsset.browser_download_url, tempBinaryPath);
6573
+ const checksumsPath = path.join(tempDir, "SHA256SUMS");
6574
+ await downloadFile(checksumAsset.browser_download_url, checksumsPath);
6575
+ const checksumsContent = await fs.promises.readFile(checksumsPath, "utf-8");
6576
+ const checksums = parseSha256Sums(checksumsContent);
6577
+ const expectedChecksum = checksums.get(assetName);
6578
+ if (!expectedChecksum) {
6579
+ throw new Error(
6580
+ `Checksum entry for "${assetName}" not found in SHA256SUMS. Cannot verify download integrity.`
6581
+ );
6582
+ }
6583
+ const actualChecksum = await calculateSha256(tempBinaryPath);
6584
+ if (actualChecksum !== expectedChecksum) {
6585
+ throw new Error(
6586
+ `Checksum verification failed. Expected: ${expectedChecksum}, Got: ${actualChecksum}. The download may be corrupted.`
6587
+ );
6588
+ }
6589
+ return tempBinaryPath;
6590
+ }
6591
+ async function replaceCurrentBinary(params) {
6592
+ const { tempBinaryPath, currentExePath, currentDir } = params;
6593
+ const tempInPlace = path.join(currentDir, `.rulesync-update-${crypto.randomUUID()}`);
6317
6594
  try {
6595
+ await fs.promises.copyFile(tempBinaryPath, tempInPlace);
6318
6596
  if (os.platform() !== "win32") {
6319
- await fs.promises.chmod(tempDir, 448);
6597
+ await fs.promises.chmod(tempInPlace, 493);
6320
6598
  }
6321
- const tempBinaryPath = path.join(tempDir, assetName);
6322
- await downloadFile(binaryAsset.browser_download_url, tempBinaryPath);
6323
- const checksumsPath = path.join(tempDir, "SHA256SUMS");
6324
- await downloadFile(checksumAsset.browser_download_url, checksumsPath);
6325
- const checksumsContent = await fs.promises.readFile(checksumsPath, "utf-8");
6326
- const checksums = parseSha256Sums(checksumsContent);
6327
- const expectedChecksum = checksums.get(assetName);
6328
- if (!expectedChecksum) {
6329
- throw new Error(
6330
- `Checksum entry for "${assetName}" not found in SHA256SUMS. Cannot verify download integrity.`
6331
- );
6599
+ await fs.promises.rename(tempInPlace, currentExePath);
6600
+ } catch {
6601
+ try {
6602
+ await fs.promises.unlink(tempInPlace);
6603
+ } catch {
6332
6604
  }
6333
- const actualChecksum = await calculateSha256(tempBinaryPath);
6334
- if (actualChecksum !== expectedChecksum) {
6335
- throw new Error(
6336
- `Checksum verification failed. Expected: ${expectedChecksum}, Got: ${actualChecksum}. The download may be corrupted.`
6337
- );
6605
+ await fs.promises.copyFile(tempBinaryPath, currentExePath);
6606
+ if (os.platform() !== "win32") {
6607
+ await fs.promises.chmod(currentExePath, 493);
6338
6608
  }
6339
- const currentExePath = await fs.promises.realpath(process.execPath);
6340
- const currentDir = path.dirname(currentExePath);
6341
- const backupPath = path.join(tempDir, "rulesync.backup");
6342
- try {
6343
- await fs.promises.copyFile(currentExePath, backupPath);
6344
- } catch (error) {
6345
- if (isPermissionError(error)) {
6346
- throw new UpdatePermissionError(
6347
- `Permission denied: Cannot read ${currentExePath}. Try running with sudo.`
6348
- );
6349
- }
6350
- throw error;
6609
+ }
6610
+ }
6611
+ async function installVerifiedBinary(params) {
6612
+ const { tempDir, tempBinaryPath, currentVersion, latestVersion } = params;
6613
+ const currentExePath = await fs.promises.realpath(process.execPath);
6614
+ const currentDir = path.dirname(currentExePath);
6615
+ const backupPath = path.join(tempDir, "rulesync.backup");
6616
+ try {
6617
+ await fs.promises.copyFile(currentExePath, backupPath);
6618
+ } catch (error) {
6619
+ if (isPermissionError(error)) {
6620
+ throw new UpdatePermissionError(
6621
+ `Permission denied: Cannot read ${currentExePath}. Try running with sudo.`
6622
+ );
6351
6623
  }
6624
+ throw error;
6625
+ }
6626
+ try {
6627
+ await replaceCurrentBinary({ tempBinaryPath, currentExePath, currentDir });
6628
+ return {
6629
+ message: `Successfully updated from ${currentVersion} to ${latestVersion}`,
6630
+ restoreFailed: false
6631
+ };
6632
+ } catch (error) {
6352
6633
  try {
6353
- const tempInPlace = path.join(currentDir, `.rulesync-update-${crypto.randomUUID()}`);
6354
- try {
6355
- await fs.promises.copyFile(tempBinaryPath, tempInPlace);
6356
- if (os.platform() !== "win32") {
6357
- await fs.promises.chmod(tempInPlace, 493);
6358
- }
6359
- await fs.promises.rename(tempInPlace, currentExePath);
6360
- } catch {
6361
- try {
6362
- await fs.promises.unlink(tempInPlace);
6363
- } catch {
6364
- }
6365
- await fs.promises.copyFile(tempBinaryPath, currentExePath);
6366
- if (os.platform() !== "win32") {
6367
- await fs.promises.chmod(currentExePath, 493);
6368
- }
6369
- }
6370
- return `Successfully updated from ${currentVersion} to ${updateCheck.latestVersion}`;
6371
- } catch (error) {
6372
- try {
6373
- await fs.promises.copyFile(backupPath, currentExePath);
6374
- } catch {
6375
- restoreFailed = true;
6376
- throw new Error(
6634
+ await fs.promises.copyFile(backupPath, currentExePath);
6635
+ } catch {
6636
+ throw new RestoreFailedError(
6637
+ new Error(
6377
6638
  `Failed to replace binary and restore failed. Backup is preserved at: ${backupPath} (in ${tempDir}). Please manually copy it to ${currentExePath}. Original error: ${error instanceof Error ? error.message : String(error)}`,
6378
6639
  { cause: error }
6379
- );
6380
- }
6381
- if (isPermissionError(error)) {
6382
- throw new UpdatePermissionError(
6383
- `Permission denied: Cannot write to ${path.dirname(currentExePath)}. Try running with sudo.`
6384
- );
6385
- }
6386
- throw error;
6640
+ )
6641
+ );
6642
+ }
6643
+ if (isPermissionError(error)) {
6644
+ throw new UpdatePermissionError(
6645
+ `Permission denied: Cannot write to ${path.dirname(currentExePath)}. Try running with sudo.`
6646
+ );
6647
+ }
6648
+ throw error;
6649
+ }
6650
+ }
6651
+ var RestoreFailedError = class extends Error {
6652
+ cause;
6653
+ constructor(cause) {
6654
+ super(cause.message);
6655
+ this.name = "RestoreFailedError";
6656
+ this.cause = cause;
6657
+ }
6658
+ };
6659
+ async function performBinaryUpdate(currentVersion, options = {}) {
6660
+ const { force = false, token } = options;
6661
+ const updateCheck = await checkForUpdate(currentVersion, token);
6662
+ if (!updateCheck.hasUpdate && !force) {
6663
+ return `Already at the latest version (${currentVersion})`;
6664
+ }
6665
+ const { assetName, binaryAsset, checksumAsset } = resolveUpdateAssets(updateCheck.release);
6666
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "rulesync-update-"));
6667
+ let restoreFailed = false;
6668
+ try {
6669
+ if (os.platform() !== "win32") {
6670
+ await fs.promises.chmod(tempDir, 448);
6671
+ }
6672
+ const tempBinaryPath = await downloadAndVerifyBinary({
6673
+ tempDir,
6674
+ assetName,
6675
+ binaryAsset,
6676
+ checksumAsset
6677
+ });
6678
+ const installed = await installVerifiedBinary({
6679
+ tempDir,
6680
+ tempBinaryPath,
6681
+ currentVersion,
6682
+ latestVersion: updateCheck.latestVersion
6683
+ });
6684
+ restoreFailed = installed.restoreFailed;
6685
+ return installed.message;
6686
+ } catch (error) {
6687
+ if (error instanceof RestoreFailedError) {
6688
+ restoreFailed = true;
6689
+ throw error.cause;
6387
6690
  }
6691
+ throw error;
6388
6692
  } finally {
6389
6693
  if (!restoreFailed) {
6390
6694
  try {
@@ -6515,7 +6819,7 @@ function wrapCommand({
6515
6819
  }
6516
6820
 
6517
6821
  // src/cli/index.ts
6518
- var getVersion = () => "8.31.0";
6822
+ var getVersion = () => "9.0.0";
6519
6823
  function wrapCommand2(name, errorCode, handler) {
6520
6824
  return wrapCommand({ name, errorCode, handler, getVersion });
6521
6825
  }
@@ -6629,10 +6933,6 @@ var main = async () => {
6629
6933
  "-o, --output-roots <paths>",
6630
6934
  "Output root directories to generate files into (comma-separated for multiple paths)",
6631
6935
  parseCommaSeparatedList
6632
- ).option(
6633
- "-b, --base-dir <paths>",
6634
- "[Deprecated] Use --output-roots instead. Output root directories (comma-separated for multiple paths)",
6635
- parseCommaSeparatedList
6636
6936
  ).option("-V, --verbose", "Verbose output").option("-s, --silent", "Suppress all output").option("-c, --config <path>", "Path to configuration file").option("-g, --global", "Generate for global(user scope) configuration files").option(
6637
6937
  "--simulate-commands",
6638
6938
  "Generate simulated commands. This feature is only available for copilot, cursor and codexcli."