rulesync 7.15.1 → 7.16.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
@@ -47,6 +47,7 @@ import {
47
47
  directoryExists,
48
48
  ensureDir,
49
49
  fileExists,
50
+ findControlCharacter,
50
51
  findFilesByGlobs,
51
52
  formatError,
52
53
  generate,
@@ -62,7 +63,7 @@ import {
62
63
  removeTempDirectory,
63
64
  stringifyFrontmatter,
64
65
  writeFileContent
65
- } from "../chunk-L5AQUWUM.js";
66
+ } from "../chunk-E5YWRHGW.js";
66
67
 
67
68
  // src/cli/index.ts
68
69
  import { Command } from "commander";
@@ -1618,21 +1619,12 @@ import { Semaphore as Semaphore2 } from "es-toolkit/promise";
1618
1619
 
1619
1620
  // src/lib/git-client.ts
1620
1621
  import { execFile } from "child_process";
1621
- import { join as join4 } from "path";
1622
+ import { isAbsolute, join as join4, relative } from "path";
1622
1623
  import { promisify } from "util";
1623
1624
  var execFileAsync = promisify(execFile);
1624
1625
  var GIT_TIMEOUT_MS = 6e4;
1625
- var ALLOWED_URL_SCHEMES = /^(https?:\/\/|ssh:\/\/|git:\/\/|file:\/\/\/|[a-zA-Z0-9_.+-]+@[a-zA-Z0-9.-]+:[a-zA-Z0-9_.+/~-]+)/;
1626
+ var ALLOWED_URL_SCHEMES = /^(https?:\/\/|ssh:\/\/|git:\/\/|file:\/\/\/).+$|^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9.-]+:[a-zA-Z0-9_.+/~-]+$/;
1626
1627
  var INSECURE_URL_SCHEMES = /^(git:\/\/|http:\/\/)/;
1627
- function findControlCharacter(value) {
1628
- for (let i = 0; i < value.length; i++) {
1629
- const code = value.charCodeAt(i);
1630
- if (code >= 0 && code <= 31 || code === 127) {
1631
- return { position: i, hex: `0x${code.toString(16).padStart(2, "0")}` };
1632
- }
1633
- }
1634
- return null;
1635
- }
1636
1628
  var GitClientError = class extends Error {
1637
1629
  constructor(message, cause) {
1638
1630
  super(message, { cause });
@@ -1688,6 +1680,7 @@ async function resolveDefaultRef(url) {
1688
1680
  const ref = stdout.match(/^ref: refs\/heads\/(.+)\tHEAD$/m)?.[1];
1689
1681
  const sha = stdout.match(/^([0-9a-f]{40})\tHEAD$/m)?.[1];
1690
1682
  if (!ref || !sha) throw new GitClientError(`Could not parse default branch from: ${url}`);
1683
+ validateRef(ref);
1691
1684
  return { ref, sha };
1692
1685
  } catch (error) {
1693
1686
  if (error instanceof GitClientError) throw error;
@@ -1714,6 +1707,17 @@ async function fetchSkillFiles(params) {
1714
1707
  const { url, ref, skillsPath } = params;
1715
1708
  validateGitUrl(url);
1716
1709
  validateRef(ref);
1710
+ if (skillsPath.split(/[/\\]/).includes("..") || isAbsolute(skillsPath)) {
1711
+ throw new GitClientError(
1712
+ `Invalid skillsPath "${skillsPath}": must be a relative path without ".."`
1713
+ );
1714
+ }
1715
+ const ctrl = findControlCharacter(skillsPath);
1716
+ if (ctrl) {
1717
+ throw new GitClientError(
1718
+ `skillsPath contains control character ${ctrl.hex} at position ${ctrl.position}`
1719
+ );
1720
+ }
1717
1721
  await checkGitAvailable();
1718
1722
  const tmpDir = await createTempDirectory("rulesync-git-");
1719
1723
  try {
@@ -1748,7 +1752,9 @@ async function fetchSkillFiles(params) {
1748
1752
  }
1749
1753
  }
1750
1754
  var MAX_WALK_DEPTH = 20;
1751
- async function walkDirectory(dir, baseDir, depth = 0) {
1755
+ var MAX_TOTAL_FILES = 1e4;
1756
+ var MAX_TOTAL_SIZE = 100 * 1024 * 1024;
1757
+ async function walkDirectory(dir, baseDir, depth = 0, ctx = { totalFiles: 0, totalSize: 0 }) {
1752
1758
  if (depth > MAX_WALK_DEPTH) {
1753
1759
  throw new GitClientError(
1754
1760
  `Directory tree exceeds max depth of ${MAX_WALK_DEPTH}: "${dir}". Aborting to prevent resource exhaustion.`
@@ -1763,7 +1769,7 @@ async function walkDirectory(dir, baseDir, depth = 0) {
1763
1769
  continue;
1764
1770
  }
1765
1771
  if (await directoryExists(fullPath)) {
1766
- results.push(...await walkDirectory(fullPath, baseDir, depth + 1));
1772
+ results.push(...await walkDirectory(fullPath, baseDir, depth + 1, ctx));
1767
1773
  } else {
1768
1774
  const size = await getFileSize(fullPath);
1769
1775
  if (size > MAX_FILE_SIZE) {
@@ -1772,8 +1778,20 @@ async function walkDirectory(dir, baseDir, depth = 0) {
1772
1778
  );
1773
1779
  continue;
1774
1780
  }
1781
+ ctx.totalFiles++;
1782
+ ctx.totalSize += size;
1783
+ if (ctx.totalFiles >= MAX_TOTAL_FILES) {
1784
+ throw new GitClientError(
1785
+ `Repository exceeds max file count of ${MAX_TOTAL_FILES}. Aborting to prevent resource exhaustion.`
1786
+ );
1787
+ }
1788
+ if (ctx.totalSize >= MAX_TOTAL_SIZE) {
1789
+ throw new GitClientError(
1790
+ `Repository exceeds max total size of ${MAX_TOTAL_SIZE / 1024 / 1024}MB. Aborting to prevent resource exhaustion.`
1791
+ );
1792
+ }
1775
1793
  const content = await readFileContent(fullPath);
1776
- results.push({ relativePath: fullPath.substring(baseDir.length + 1), content, size });
1794
+ results.push({ relativePath: relative(baseDir, fullPath), content, size });
1777
1795
  }
1778
1796
  }
1779
1797
  return results;
@@ -1782,14 +1800,14 @@ async function walkDirectory(dir, baseDir, depth = 0) {
1782
1800
  // src/lib/sources-lock.ts
1783
1801
  import { createHash } from "crypto";
1784
1802
  import { join as join5 } from "path";
1785
- import { optional, z as z4 } from "zod/mini";
1803
+ import { optional, refine, z as z4 } from "zod/mini";
1786
1804
  var LOCKFILE_VERSION = 1;
1787
1805
  var LockedSkillSchema = z4.object({
1788
1806
  integrity: z4.string()
1789
1807
  });
1790
1808
  var LockedSourceSchema = z4.object({
1791
1809
  requestedRef: optional(z4.string()),
1792
- resolvedRef: z4.string(),
1810
+ resolvedRef: z4.string().check(refine((v) => /^[0-9a-f]{40}$/.test(v), "resolvedRef must be a 40-character hex SHA")),
1793
1811
  resolvedAt: optional(z4.string()),
1794
1812
  skills: z4.record(z4.string(), LockedSkillSchema)
1795
1813
  });
@@ -1992,6 +2010,8 @@ async function resolveAndFetchSources(params) {
1992
2010
  logger.error(`Failed to fetch source "${sourceEntry.source}": ${formatError(error)}`);
1993
2011
  if (error instanceof GitHubClientError) {
1994
2012
  logGitHubAuthHints(error);
2013
+ } else if (error instanceof GitClientError) {
2014
+ logGitClientHints(error);
1995
2015
  }
1996
2016
  }
1997
2017
  }
@@ -2012,6 +2032,13 @@ async function resolveAndFetchSources(params) {
2012
2032
  }
2013
2033
  return { fetchedSkillCount: totalSkillCount, sourcesProcessed: sources.length };
2014
2034
  }
2035
+ function logGitClientHints(error) {
2036
+ if (error.message.includes("not installed")) {
2037
+ logger.info("Hint: Install git and ensure it is available on your PATH.");
2038
+ } else {
2039
+ logger.info("Hint: Check your git credentials (SSH keys, credential helper, or access token).");
2040
+ }
2041
+ }
2015
2042
  async function checkLockedSkillsExist(curatedDir, skillNames) {
2016
2043
  if (skillNames.length === 0) return true;
2017
2044
  for (const name of skillNames) {
@@ -2021,9 +2048,88 @@ async function checkLockedSkillsExist(curatedDir, skillNames) {
2021
2048
  }
2022
2049
  return true;
2023
2050
  }
2051
+ async function cleanPreviousCuratedSkills(curatedDir, lockedSkillNames) {
2052
+ const resolvedCuratedDir = resolve(curatedDir);
2053
+ for (const prevSkill of lockedSkillNames) {
2054
+ const prevDir = join6(curatedDir, prevSkill);
2055
+ if (!resolve(prevDir).startsWith(resolvedCuratedDir + sep)) {
2056
+ logger.warn(
2057
+ `Skipping removal of "${prevSkill}": resolved path is outside the curated directory.`
2058
+ );
2059
+ continue;
2060
+ }
2061
+ if (await directoryExists(prevDir)) {
2062
+ await removeDirectory(prevDir);
2063
+ }
2064
+ }
2065
+ }
2066
+ function shouldSkipSkill(params) {
2067
+ const { skillName, sourceKey, localSkillNames, alreadyFetchedSkillNames } = params;
2068
+ if (skillName.includes("..") || skillName.includes("/") || skillName.includes("\\")) {
2069
+ logger.warn(
2070
+ `Skipping skill with invalid name "${skillName}" from ${sourceKey}: contains path traversal characters.`
2071
+ );
2072
+ return true;
2073
+ }
2074
+ if (localSkillNames.has(skillName)) {
2075
+ logger.debug(
2076
+ `Skipping remote skill "${skillName}" from ${sourceKey}: local skill takes precedence.`
2077
+ );
2078
+ return true;
2079
+ }
2080
+ if (alreadyFetchedSkillNames.has(skillName)) {
2081
+ logger.warn(
2082
+ `Skipping duplicate skill "${skillName}" from ${sourceKey}: already fetched from another source.`
2083
+ );
2084
+ return true;
2085
+ }
2086
+ return false;
2087
+ }
2088
+ async function writeSkillAndComputeIntegrity(params) {
2089
+ const { skillName, files, curatedDir, locked, resolvedSha, sourceKey } = params;
2090
+ const written = [];
2091
+ for (const file of files) {
2092
+ checkPathTraversal({
2093
+ relativePath: file.relativePath,
2094
+ intendedRootDir: join6(curatedDir, skillName)
2095
+ });
2096
+ await writeFileContent(join6(curatedDir, skillName, file.relativePath), file.content);
2097
+ written.push({ path: file.relativePath, content: file.content });
2098
+ }
2099
+ const integrity = computeSkillIntegrity(written);
2100
+ const lockedSkillEntry = locked?.skills[skillName];
2101
+ if (lockedSkillEntry?.integrity && lockedSkillEntry.integrity !== integrity && resolvedSha === locked?.resolvedRef) {
2102
+ logger.warn(
2103
+ `Integrity mismatch for skill "${skillName}" from ${sourceKey}: expected "${lockedSkillEntry.integrity}", got "${integrity}". Content may have been tampered with.`
2104
+ );
2105
+ }
2106
+ return { integrity };
2107
+ }
2108
+ function buildLockUpdate(params) {
2109
+ const { lock, sourceKey, fetchedSkills, locked, requestedRef, resolvedSha } = params;
2110
+ const fetchedNames = Object.keys(fetchedSkills);
2111
+ const mergedSkills = { ...fetchedSkills };
2112
+ if (locked) {
2113
+ for (const [skillName, skillEntry] of Object.entries(locked.skills)) {
2114
+ if (!(skillName in mergedSkills)) {
2115
+ mergedSkills[skillName] = skillEntry;
2116
+ }
2117
+ }
2118
+ }
2119
+ const updatedLock = setLockedSource(lock, sourceKey, {
2120
+ requestedRef,
2121
+ resolvedRef: resolvedSha,
2122
+ resolvedAt: (/* @__PURE__ */ new Date()).toISOString(),
2123
+ skills: mergedSkills
2124
+ });
2125
+ logger.info(
2126
+ `Fetched ${fetchedNames.length} skill(s) from ${sourceKey}: ${fetchedNames.join(", ") || "(none)"}`
2127
+ );
2128
+ return { updatedLock, fetchedNames };
2129
+ }
2024
2130
  async function fetchSource(params) {
2025
2131
  const { sourceEntry, client, baseDir, localSkillNames, alreadyFetchedSkillNames, updateSources } = params;
2026
- let { lock } = params;
2132
+ const { lock } = params;
2027
2133
  const parsed = parseSource(sourceEntry.source);
2028
2134
  if (parsed.provider === "gitlab") {
2029
2135
  logger.warn(`GitLab sources are not yet supported. Skipping "${sourceEntry.source}".`);
@@ -2076,37 +2182,15 @@ async function fetchSource(params) {
2076
2182
  const semaphore = new Semaphore2(FETCH_CONCURRENCY_LIMIT);
2077
2183
  const fetchedSkills = {};
2078
2184
  if (locked) {
2079
- const resolvedCuratedDir = resolve(curatedDir);
2080
- for (const prevSkill of lockedSkillNames) {
2081
- const prevDir = join6(curatedDir, prevSkill);
2082
- if (!resolve(prevDir).startsWith(resolvedCuratedDir + sep)) {
2083
- logger.warn(
2084
- `Skipping removal of "${prevSkill}": resolved path is outside the curated directory.`
2085
- );
2086
- continue;
2087
- }
2088
- if (await directoryExists(prevDir)) {
2089
- await removeDirectory(prevDir);
2090
- }
2091
- }
2185
+ await cleanPreviousCuratedSkills(curatedDir, lockedSkillNames);
2092
2186
  }
2093
2187
  for (const skillDir of filteredDirs) {
2094
- if (skillDir.name.includes("..") || skillDir.name.includes("/") || skillDir.name.includes("\\")) {
2095
- logger.warn(
2096
- `Skipping skill with invalid name "${skillDir.name}" from ${sourceKey}: contains path traversal characters.`
2097
- );
2098
- continue;
2099
- }
2100
- if (localSkillNames.has(skillDir.name)) {
2101
- logger.debug(
2102
- `Skipping remote skill "${skillDir.name}" from ${sourceKey}: local skill takes precedence.`
2103
- );
2104
- continue;
2105
- }
2106
- if (alreadyFetchedSkillNames.has(skillDir.name)) {
2107
- logger.warn(
2108
- `Skipping duplicate skill "${skillDir.name}" from ${sourceKey}: already fetched from another source.`
2109
- );
2188
+ if (shouldSkipSkill({
2189
+ skillName: skillDir.name,
2190
+ sourceKey,
2191
+ localSkillNames,
2192
+ alreadyFetchedSkillNames
2193
+ })) {
2110
2194
  continue;
2111
2195
  }
2112
2196
  const allFiles = await listDirectoryRecursive({
@@ -2129,55 +2213,39 @@ async function fetchSource(params) {
2129
2213
  const skillFiles = [];
2130
2214
  for (const file of files) {
2131
2215
  const relativeToSkill = file.path.substring(skillDir.path.length + 1);
2132
- const localFilePath = join6(curatedDir, skillDir.name, relativeToSkill);
2133
- checkPathTraversal({
2134
- relativePath: relativeToSkill,
2135
- intendedRootDir: join6(curatedDir, skillDir.name)
2136
- });
2137
2216
  const content = await withSemaphore(
2138
2217
  semaphore,
2139
2218
  () => client.getFileContent(parsed.owner, parsed.repo, file.path, ref)
2140
2219
  );
2141
- await writeFileContent(localFilePath, content);
2142
- skillFiles.push({ path: relativeToSkill, content });
2143
- }
2144
- const integrity = computeSkillIntegrity(skillFiles);
2145
- const lockedSkillEntry = locked?.skills[skillDir.name];
2146
- if (lockedSkillEntry && lockedSkillEntry.integrity && lockedSkillEntry.integrity !== integrity && resolvedSha === locked?.resolvedRef) {
2147
- logger.warn(
2148
- `Integrity mismatch for skill "${skillDir.name}" from ${sourceKey}: expected "${lockedSkillEntry.integrity}", got "${integrity}". Content may have been tampered with.`
2149
- );
2150
- }
2151
- fetchedSkills[skillDir.name] = { integrity };
2220
+ skillFiles.push({ relativePath: relativeToSkill, content });
2221
+ }
2222
+ fetchedSkills[skillDir.name] = await writeSkillAndComputeIntegrity({
2223
+ skillName: skillDir.name,
2224
+ files: skillFiles,
2225
+ curatedDir,
2226
+ locked,
2227
+ resolvedSha,
2228
+ sourceKey
2229
+ });
2152
2230
  logger.debug(`Fetched skill "${skillDir.name}" from ${sourceKey}`);
2153
2231
  }
2154
- const fetchedNames = Object.keys(fetchedSkills);
2155
- const mergedSkills = { ...fetchedSkills };
2156
- if (locked) {
2157
- for (const [skillName, skillEntry] of Object.entries(locked.skills)) {
2158
- if (!(skillName in mergedSkills)) {
2159
- mergedSkills[skillName] = skillEntry;
2160
- }
2161
- }
2162
- }
2163
- lock = setLockedSource(lock, sourceKey, {
2232
+ const result = buildLockUpdate({
2233
+ lock,
2234
+ sourceKey,
2235
+ fetchedSkills,
2236
+ locked,
2164
2237
  requestedRef,
2165
- resolvedRef: resolvedSha,
2166
- resolvedAt: (/* @__PURE__ */ new Date()).toISOString(),
2167
- skills: mergedSkills
2238
+ resolvedSha
2168
2239
  });
2169
- logger.info(
2170
- `Fetched ${fetchedNames.length} skill(s) from ${sourceKey}: ${fetchedNames.join(", ") || "(none)"}`
2171
- );
2172
2240
  return {
2173
- skillCount: fetchedNames.length,
2174
- fetchedSkillNames: fetchedNames,
2175
- updatedLock: lock
2241
+ skillCount: result.fetchedNames.length,
2242
+ fetchedSkillNames: result.fetchedNames,
2243
+ updatedLock: result.updatedLock
2176
2244
  };
2177
2245
  }
2178
2246
  async function fetchSourceViaGit(params) {
2179
2247
  const { sourceEntry, baseDir, localSkillNames, alreadyFetchedSkillNames, updateSources, frozen } = params;
2180
- let { lock } = params;
2248
+ const { lock } = params;
2181
2249
  const url = sourceEntry.source;
2182
2250
  const locked = getLockedSource(lock, url);
2183
2251
  const lockedSkillNames = locked ? getLockedSkillNames(locked) : [];
@@ -2233,68 +2301,35 @@ async function fetchSourceViaGit(params) {
2233
2301
  const allNames = [...skillFileMap.keys()];
2234
2302
  const filteredNames = isWildcard ? allNames : allNames.filter((n) => skillFilter.includes(n));
2235
2303
  if (locked) {
2236
- const base = resolve(curatedDir);
2237
- for (const prev of lockedSkillNames) {
2238
- const dir = join6(curatedDir, prev);
2239
- if (resolve(dir).startsWith(base + sep) && await directoryExists(dir)) {
2240
- await removeDirectory(dir);
2241
- }
2242
- }
2304
+ await cleanPreviousCuratedSkills(curatedDir, lockedSkillNames);
2243
2305
  }
2244
2306
  const fetchedSkills = {};
2245
2307
  for (const skillName of filteredNames) {
2246
- if (skillName.includes("..") || skillName.includes("/") || skillName.includes("\\")) {
2247
- logger.warn(
2248
- `Skipping skill with invalid name "${skillName}" from ${url}: contains path traversal characters.`
2249
- );
2250
- continue;
2251
- }
2252
- if (localSkillNames.has(skillName)) {
2253
- logger.debug(
2254
- `Skipping remote skill "${skillName}" from ${url}: local skill takes precedence.`
2255
- );
2256
- continue;
2257
- }
2258
- if (alreadyFetchedSkillNames.has(skillName)) {
2259
- logger.warn(
2260
- `Skipping duplicate skill "${skillName}" from ${url}: already fetched from another source.`
2261
- );
2308
+ if (shouldSkipSkill({ skillName, sourceKey: url, localSkillNames, alreadyFetchedSkillNames })) {
2262
2309
  continue;
2263
2310
  }
2264
- const files = skillFileMap.get(skillName) ?? [];
2265
- const written = [];
2266
- for (const file of files) {
2267
- checkPathTraversal({
2268
- relativePath: file.relativePath,
2269
- intendedRootDir: join6(curatedDir, skillName)
2270
- });
2271
- await writeFileContent(join6(curatedDir, skillName, file.relativePath), file.content);
2272
- written.push({ path: file.relativePath, content: file.content });
2273
- }
2274
- const integrity = computeSkillIntegrity(written);
2275
- const lockedSkillEntry = locked?.skills[skillName];
2276
- if (lockedSkillEntry?.integrity && lockedSkillEntry.integrity !== integrity && resolvedSha === locked?.resolvedRef) {
2277
- logger.warn(`Integrity mismatch for skill "${skillName}" from ${url}.`);
2278
- }
2279
- fetchedSkills[skillName] = { integrity };
2280
- }
2281
- const fetchedNames = Object.keys(fetchedSkills);
2282
- const mergedSkills = { ...fetchedSkills };
2283
- if (locked) {
2284
- for (const [k, v] of Object.entries(locked.skills)) {
2285
- if (!(k in mergedSkills)) mergedSkills[k] = v;
2286
- }
2311
+ fetchedSkills[skillName] = await writeSkillAndComputeIntegrity({
2312
+ skillName,
2313
+ files: skillFileMap.get(skillName) ?? [],
2314
+ curatedDir,
2315
+ locked,
2316
+ resolvedSha,
2317
+ sourceKey: url
2318
+ });
2287
2319
  }
2288
- lock = setLockedSource(lock, url, {
2320
+ const result = buildLockUpdate({
2321
+ lock,
2322
+ sourceKey: url,
2323
+ fetchedSkills,
2324
+ locked,
2289
2325
  requestedRef,
2290
- resolvedRef: resolvedSha,
2291
- resolvedAt: (/* @__PURE__ */ new Date()).toISOString(),
2292
- skills: mergedSkills
2326
+ resolvedSha
2293
2327
  });
2294
- logger.info(
2295
- `Fetched ${fetchedNames.length} skill(s) from ${url}: ${fetchedNames.join(", ") || "(none)"}`
2296
- );
2297
- return { skillCount: fetchedNames.length, fetchedSkillNames: fetchedNames, updatedLock: lock };
2328
+ return {
2329
+ skillCount: result.fetchedNames.length,
2330
+ fetchedSkillNames: result.fetchedNames,
2331
+ updatedLock: result.updatedLock
2332
+ };
2298
2333
  }
2299
2334
 
2300
2335
  // src/cli/commands/install.ts
@@ -4110,7 +4145,7 @@ async function updateCommand(currentVersion, options) {
4110
4145
  }
4111
4146
 
4112
4147
  // src/cli/index.ts
4113
- var getVersion = () => "7.15.1";
4148
+ var getVersion = () => "7.16.0";
4114
4149
  var main = async () => {
4115
4150
  const program = new Command();
4116
4151
  const version = getVersion();