@chappibunny/repolens 1.12.2 → 1.12.4

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +4 -4
  3. package/bin/repolens.js +21 -1
  4. package/package.json +19 -11
  5. package/src/ai/provider.js +1 -2
  6. package/src/analyzers/monorepo-detector.js +8 -20
  7. package/src/cli-args.js +60 -0
  8. package/src/cli.js +80 -100
  9. package/src/core/config.js +5 -2
  10. package/src/core/diff.js +43 -20
  11. package/src/core/scan.js +24 -4
  12. package/src/delivery/comment.js +5 -2
  13. package/src/docs/write-doc-set.js +9 -7
  14. package/src/doctor.js +2 -2
  15. package/src/help.js +54 -0
  16. package/src/index.js +13 -0
  17. package/src/init.js +38 -41
  18. package/src/integrations/discord.js +16 -2
  19. package/src/migrate.js +2 -2
  20. package/src/publishers/confluence.js +17 -3
  21. package/src/publishers/github-wiki.js +61 -32
  22. package/src/publishers/index.js +4 -9
  23. package/src/publishers/markdown.js +3 -2
  24. package/src/publishers/notion.js +10 -4
  25. package/src/utils/branch.js +23 -44
  26. package/src/utils/github-repo.js +8 -0
  27. package/src/utils/interactive.js +20 -0
  28. package/src/utils/logger.js +5 -1
  29. package/src/utils/retry.js +30 -6
  30. package/src/utils/safe-path.js +50 -0
  31. package/src/utils/secrets.js +3 -1
  32. package/src/utils/telemetry.js +34 -6
  33. package/src/utils/update-check.js +23 -6
  34. package/src/utils/validate.js +7 -0
  35. package/src/utils/yaml.js +27 -0
  36. package/src/watch.js +1 -1
  37. package/docs/AI.md +0 -329
  38. package/docs/ARCHITECTURE.md +0 -145
  39. package/docs/CONFIGURATION.md +0 -121
  40. package/docs/DEVELOPMENT.md +0 -79
  41. package/docs/ENVIRONMENT.md +0 -79
  42. package/docs/INSTALLATION.md +0 -45
  43. package/docs/KNOWN_ISSUES.md +0 -309
  44. package/docs/MIGRATION.md +0 -311
  45. package/docs/ONBOARDING.md +0 -382
  46. package/docs/PRODUCTION_CHECKLIST.md +0 -470
  47. package/docs/RELEASE.md +0 -76
  48. package/docs/ROADMAP.md +0 -151
  49. package/docs/STABILITY.md +0 -265
  50. package/docs/TELEMETRY.md +0 -166
  51. package/docs/TROUBLESHOOTING.md +0 -377
@@ -13,12 +13,14 @@
13
13
  // sidebar: true # Generate _Sidebar.md (default: true)
14
14
  // footer: true # Generate _Footer.md (default: true)
15
15
 
16
- import { execSync } from "node:child_process";
16
+ import { execFileSync } from "node:child_process";
17
17
  import fs from "node:fs/promises";
18
18
  import path from "node:path";
19
19
  import os from "node:os";
20
20
  import { info, warn } from "../utils/logger.js";
21
- import { getCurrentBranch, getBranchQualifiedTitle, normalizeBranchName } from "../utils/branch.js";
21
+ import { getCurrentBranch, getBranchQualifiedTitle } from "../utils/branch.js";
22
+ import { assertGitHubRepo } from "../utils/github-repo.js";
23
+ import { isInsideRoot, safeFileName } from "../utils/safe-path.js";
22
24
 
23
25
  const PAGE_ORDER = [
24
26
  "executive_summary",
@@ -176,7 +178,7 @@ function detectGitHubRepo() {
176
178
  }
177
179
 
178
180
  try {
179
- const remoteUrl = execSync("git remote get-url origin", {
181
+ const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
180
182
  encoding: "utf8",
181
183
  stdio: ["pipe", "pipe", "ignore"],
182
184
  }).trim();
@@ -194,10 +196,30 @@ function detectGitHubRepo() {
194
196
  }
195
197
 
196
198
  /**
197
- * Build the authenticated wiki clone URL.
199
+ * Wiki clone URL. The token is passed to git through http.extraheader,
200
+ * not embedded in the URL, so it does not show up in process arguments
201
+ * or in the temporary repo's origin remote.
198
202
  */
199
- function buildWikiCloneUrl(repo, token) {
200
- return `https://x-access-token:${token}@github.com/${repo}.wiki.git`;
203
+ function buildWikiCloneUrl(repo) {
204
+ return `https://github.com/${repo}.wiki.git`;
205
+ }
206
+
207
+ function gitEnv(token) {
208
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
209
+ if (token) {
210
+ env.GIT_CONFIG_COUNT = "1";
211
+ env.GIT_CONFIG_KEY_0 = "http.https://github.com/.extraheader";
212
+ env.GIT_CONFIG_VALUE_0 = `AUTHORIZATION: bearer ${token}`;
213
+ }
214
+ return env;
215
+ }
216
+
217
+ export function sanitizeGitError(text) {
218
+ return String(text || "")
219
+ .replace(/x-access-token:[^\s@]+/gi, "x-access-token:***")
220
+ .replace(/bearer\s+[A-Za-z0-9._\-]+/gi, "bearer ***")
221
+ .replace(/github_pat_[A-Za-z0-9_]+/g, "github_pat_***")
222
+ .replace(/gh[pousr]_[A-Za-z0-9_]+/g, "gh*_***");
201
223
  }
202
224
 
203
225
  /**
@@ -205,7 +227,7 @@ function buildWikiCloneUrl(repo, token) {
205
227
  */
206
228
  function pageFileName(key) {
207
229
  const title = getPageDisplayTitle(key);
208
- return title.replace(/\s+/g, "-") + ".md";
230
+ return safeFileName(`${title.replace(/\s+/g, "-")}.md`);
209
231
  }
210
232
 
211
233
  /**
@@ -346,23 +368,29 @@ function pageHeader(key, branch) {
346
368
 
347
369
  /**
348
370
  * Run a git command in the specified directory.
349
- * Token is never exposed in error output.
371
+ * `args` is an argument array. The token stays in the environment, not argv.
350
372
  */
351
- function git(args, cwd) {
373
+ function git(args, cwd, token) {
352
374
  try {
353
- return execSync(`git ${args}`, {
375
+ return execFileSync("git", args, {
354
376
  cwd,
355
377
  encoding: "utf8",
356
378
  stdio: ["pipe", "pipe", "pipe"],
357
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
379
+ env: gitEnv(token),
358
380
  });
359
381
  } catch (err) {
360
- const sanitized = (err.stderr || err.message || "").replace(
361
- /x-access-token:[^\s@]+/g,
362
- "x-access-token:***"
363
- );
364
- throw new Error(`Git command failed: git ${args.split(" ")[0]} — ${sanitized}`);
382
+ const sanitized = sanitizeGitError(err.stderr || err.message || "");
383
+ throw new Error(`Git command failed: git ${args[0]} — ${sanitized}`);
384
+ }
385
+ }
386
+
387
+ async function writeWikiFile(tmpDir, fileName, content) {
388
+ const safe = safeFileName(fileName);
389
+ const full = path.resolve(tmpDir, safe);
390
+ if (!isInsideRoot(tmpDir, full)) {
391
+ throw new Error(`Refusing to write wiki file outside the workspace: ${fileName}`);
365
392
  }
393
+ await fs.writeFile(full, content, "utf8");
366
394
  }
367
395
 
368
396
  /**
@@ -378,13 +406,14 @@ export async function publishToGitHubWiki(cfg, renderedPages) {
378
406
  );
379
407
  }
380
408
 
381
- const repo = detectGitHubRepo();
382
- if (!repo) {
409
+ const detected = detectGitHubRepo();
410
+ if (!detected) {
383
411
  throw new Error(
384
412
  "Could not detect GitHub repository. Set GITHUB_REPOSITORY=owner/repo\n" +
385
413
  "or ensure git remote 'origin' points to a GitHub URL."
386
414
  );
387
415
  }
416
+ const repo = assertGitHubRepo(detected);
388
417
 
389
418
  const branch = getCurrentBranch();
390
419
  const wikiConfig = cfg.github_wiki || {};
@@ -395,16 +424,16 @@ export async function publishToGitHubWiki(cfg, renderedPages) {
395
424
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "repolens-wiki-"));
396
425
 
397
426
  try {
398
- const cloneUrl = buildWikiCloneUrl(repo, token);
427
+ const cloneUrl = buildWikiCloneUrl(repo);
399
428
 
400
429
  try {
401
- git(`clone --depth 1 ${cloneUrl} .`, tmpDir);
430
+ git(["clone", "--depth", "1", cloneUrl, "."], tmpDir, token);
402
431
  } catch {
403
432
  warn("Wiki repository not found — initializing. Enable the wiki tab in GitHub repo settings.");
404
433
  // GitHub Wiki uses 'master' as default branch; ensure compatibility
405
434
  // regardless of the local git init.defaultBranch setting
406
- git("init -b master", tmpDir);
407
- git(`remote add origin ${cloneUrl}`, tmpDir);
435
+ git(["init", "-b", "master"], tmpDir, token);
436
+ git(["remote", "add", "origin", cloneUrl], tmpDir, token);
408
437
  }
409
438
 
410
439
  // Only include pages that have actual content
@@ -415,34 +444,34 @@ export async function publishToGitHubWiki(cfg, renderedPages) {
415
444
 
416
445
  // Write Home.md
417
446
  const home = generateHome(pageKeys, projectName, branch, repo);
418
- await fs.writeFile(path.join(tmpDir, "Home.md"), home, "utf8");
447
+ await writeWikiFile(tmpDir, "Home.md", home);
419
448
 
420
449
  // Write each rendered page with metadata header
421
450
  for (const [key, markdown] of populatedPages) {
422
451
  const fileName = pageFileName(key);
423
452
  const header = pageHeader(key, branch);
424
- await fs.writeFile(path.join(tmpDir, fileName), header + markdown, "utf8");
453
+ await writeWikiFile(tmpDir, fileName, header + markdown);
425
454
  }
426
455
 
427
456
  // Generate sidebar
428
457
  if (includeSidebar) {
429
458
  const sidebar = generateSidebar(pageKeys, projectName, branch);
430
- await fs.writeFile(path.join(tmpDir, "_Sidebar.md"), sidebar, "utf8");
459
+ await writeWikiFile(tmpDir, "_Sidebar.md", sidebar);
431
460
  }
432
461
 
433
462
  // Generate footer
434
463
  if (includeFooter) {
435
464
  const footer = generateFooter(branch);
436
- await fs.writeFile(path.join(tmpDir, "_Footer.md"), footer, "utf8");
465
+ await writeWikiFile(tmpDir, "_Footer.md", footer);
437
466
  }
438
467
 
439
468
  // Stage, commit, push
440
- git("config user.name \"RepoLens Bot\"", tmpDir);
441
- git("config user.email \"repolens@users.noreply.github.com\"", tmpDir);
442
- git("add -A", tmpDir);
469
+ git(["config", "user.name", "RepoLens Bot"], tmpDir, token);
470
+ git(["config", "user.email", "repolens@users.noreply.github.com"], tmpDir, token);
471
+ git(["add", "-A"], tmpDir, token);
443
472
 
444
473
  try {
445
- execSync("git diff --cached --quiet", { cwd: tmpDir, stdio: "pipe" });
474
+ execFileSync("git", ["diff", "--cached", "--quiet"], { cwd: tmpDir, stdio: "pipe" });
446
475
  info("GitHub Wiki is already up to date — no changes to push.");
447
476
  return;
448
477
  } catch {
@@ -451,10 +480,10 @@ export async function publishToGitHubWiki(cfg, renderedPages) {
451
480
 
452
481
  const branchLabel = branch !== "main" && branch !== "master" ? ` [${branch}]` : "";
453
482
  const commitMsg = `docs: update RepoLens documentation${branchLabel}`;
454
- git(`commit -m "${commitMsg}"`, tmpDir);
483
+ git(["commit", "-m", commitMsg], tmpDir, token);
455
484
 
456
485
  // GitHub Wiki serves from 'master'; push explicitly to master
457
- git("push origin HEAD:refs/heads/master", tmpDir);
486
+ git(["push", "origin", "HEAD:refs/heads/master"], tmpDir, token);
458
487
  info(`GitHub Wiki published (${pageKeys.length} pages): https://github.com/${repo}/wiki`);
459
488
 
460
489
  } finally {
@@ -2,7 +2,8 @@ import { publishToNotion } from "./publish.js";
2
2
  import { publishToMarkdown } from "./markdown.js";
3
3
  import { publishToConfluence, hasConfluenceSecrets } from "./confluence.js";
4
4
  import { publishToGitHubWiki, hasGitHubWikiSecrets } from "./github-wiki.js";
5
- import { shouldPublishToNotion, shouldPublishToConfluence, shouldPublishToGitHubWiki, getCurrentBranch } from "../utils/branch.js";
5
+ import { shouldPublishToNotion, shouldPublishToConfluence, shouldPublishToGitHubWiki, getCurrentBranch, branchMatches } from "../utils/branch.js";
6
+ import { resolveOutputDir } from "../utils/safe-path.js";
6
7
  import { info, warn } from "../utils/logger.js";
7
8
  import { trackPublishing } from "../utils/telemetry.js";
8
9
  import { collectMetrics } from "../utils/metrics.js";
@@ -27,7 +28,7 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
27
28
  let notionUrl = null;
28
29
 
29
30
  // --- Hash-based caching: skip unchanged documents ---
30
- const cacheDir = path.join(process.cwd(), cfg.documentation?.output_dir || ".repolens");
31
+ const cacheDir = resolveOutputDir(process.cwd(), cfg.documentation?.output_dir || ".repolens");
31
32
  const { cache: previousCache, age: cacheAge } = await loadDocCache(cacheDir);
32
33
  const { changedPages, unchangedKeys, newCache } = filterChangedDocs(renderedPages, previousCache);
33
34
  logCacheStats(Object.keys(changedPages).length, unchangedKeys.length, cacheAge);
@@ -154,13 +155,7 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
154
155
 
155
156
  // Check if we should send notification for this branch
156
157
  const allowedBranches = discordConfig.branches || [currentBranch]; // Default to current branch
157
- const branchAllowed = allowedBranches.some(pattern => {
158
- if (pattern.includes("*")) {
159
- const regex = new RegExp(`^${pattern.replace(/\*/g, ".*")}$`);
160
- return regex.test(currentBranch);
161
- }
162
- return pattern === currentBranch;
163
- });
158
+ const branchAllowed = allowedBranches.some((pattern) => branchMatches(pattern, currentBranch));
164
159
 
165
160
  if (webhookUrl && discordEnabled && branchAllowed) {
166
161
  // Calculate change percent (if we have history)
@@ -1,9 +1,10 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { info } from "../utils/logger.js";
4
+ import { resolveOutputDir, safeFileName } from "../utils/safe-path.js";
4
5
 
5
6
  function outputDir(cfg) {
6
- return path.join(cfg.__repoRoot, ".repolens");
7
+ return resolveOutputDir(cfg.__repoRoot, ".repolens");
7
8
  }
8
9
 
9
10
  function pageFileName(key) {
@@ -27,7 +28,7 @@ function pageFileName(key) {
27
28
  code_health: "code_health.md"
28
29
  };
29
30
 
30
- return mapping[key] || `${key}.md`;
31
+ return safeFileName(mapping[key] || `${key}.md`);
31
32
  }
32
33
 
33
34
  export async function publishToMarkdown(cfg, renderedPages) {
@@ -1,10 +1,10 @@
1
- import fetch from "node-fetch";
2
1
  import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
3
  import { log, warn } from "../utils/logger.js";
5
4
  import { fetchWithRetry } from "../utils/retry.js";
6
5
  import { executeNotionRequest } from "../utils/rate-limit.js";
7
6
  import { createRepoLensError } from "../utils/errors.js";
7
+ import { sanitizeSecrets } from "../utils/secrets.js";
8
8
 
9
9
  function notionHeaders() {
10
10
  const token = process.env.NOTION_TOKEN;
@@ -36,7 +36,8 @@ async function notionRequest(method, url, body) {
36
36
 
37
37
  if (!res.ok) {
38
38
  const text = await res.text();
39
- throw new Error(`Notion API error ${res.status}: ${text}`);
39
+ const clipped = sanitizeSecrets(String(text || "").replace(/\s+/g, " ").slice(0, 500));
40
+ throw new Error(`Notion API error ${res.status}: ${clipped}`);
40
41
  }
41
42
 
42
43
  return await res.json();
@@ -62,10 +63,15 @@ async function writeCache(cache) {
62
63
 
63
64
  async function getChildBlocks(blockId) {
64
65
  const results = [];
66
+ const seenCursors = new Set();
65
67
  let cursor = undefined;
66
68
 
67
- while (true) {
68
- const suffix = cursor ? `?start_cursor=${cursor}` : "";
69
+ while (results.length < 10000) {
70
+ if (cursor) {
71
+ if (seenCursors.has(cursor)) break;
72
+ seenCursors.add(cursor);
73
+ }
74
+ const suffix = cursor ? `?start_cursor=${encodeURIComponent(cursor)}` : "";
69
75
  const response = await notionRequest("GET", `/blocks/${blockId}/children${suffix}`);
70
76
 
71
77
  if (response.results?.length) {
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
2
 
3
3
  /**
4
4
  * Detect the current git branch from various sources
@@ -22,7 +22,7 @@ export function getCurrentBranch() {
22
22
 
23
23
  // 4. Try git command
24
24
  try {
25
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
25
+ const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
26
26
  encoding: "utf8",
27
27
  stdio: ["pipe", "pipe", "ignore"]
28
28
  }).trim();
@@ -48,6 +48,24 @@ export function normalizeBranchName(branch) {
48
48
  .toLowerCase();
49
49
  }
50
50
 
51
+ /**
52
+ * Match a branch name against an exact value or a simple `*` glob.
53
+ * Regex metacharacters in the pattern are escaped so a bad config cannot
54
+ * throw or widen the match.
55
+ */
56
+ export function branchMatches(pattern, branch) {
57
+ if (pattern === branch) return true;
58
+ if (typeof pattern !== "string" || typeof branch !== "string" || !pattern.includes("*")) {
59
+ return false;
60
+ }
61
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
62
+ return new RegExp(`^${escaped}$`).test(branch);
63
+ }
64
+
65
+ function matchesAnyBranch(patterns, currentBranch) {
66
+ return patterns.some((pattern) => branchMatches(pattern, currentBranch));
67
+ }
68
+
51
69
  /**
52
70
  * Check if current branch should publish to Notion
53
71
  * Based on config.notion.branches setting
@@ -63,21 +81,7 @@ export function shouldPublishToNotion(config, currentBranch = getCurrentBranch()
63
81
  return true;
64
82
  }
65
83
 
66
- // Check if current branch matches any allowed pattern
67
- return config.notion.branches.some(pattern => {
68
- // Exact match
69
- if (pattern === currentBranch) {
70
- return true;
71
- }
72
-
73
- // Wildcard pattern (simple glob)
74
- if (pattern.includes("*")) {
75
- const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
76
- return regex.test(currentBranch);
77
- }
78
-
79
- return false;
80
- });
84
+ return matchesAnyBranch(config.notion.branches, currentBranch);
81
85
  }
82
86
 
83
87
  /**
@@ -95,21 +99,7 @@ export function shouldPublishToConfluence(config, currentBranch = getCurrentBran
95
99
  return true;
96
100
  }
97
101
 
98
- // Check if current branch matches any allowed pattern
99
- return config.confluence.branches.some(pattern => {
100
- // Exact match
101
- if (pattern === currentBranch) {
102
- return true;
103
- }
104
-
105
- // Wildcard pattern (simple glob)
106
- if (pattern.includes("*")) {
107
- const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
108
- return regex.test(currentBranch);
109
- }
110
-
111
- return false;
112
- });
102
+ return matchesAnyBranch(config.confluence.branches, currentBranch);
113
103
  }
114
104
 
115
105
  /**
@@ -125,18 +115,7 @@ export function shouldPublishToGitHubWiki(config, currentBranch = getCurrentBran
125
115
  return true;
126
116
  }
127
117
 
128
- return config.github_wiki.branches.some(pattern => {
129
- if (pattern === currentBranch) {
130
- return true;
131
- }
132
-
133
- if (pattern.includes("*")) {
134
- const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
135
- return regex.test(currentBranch);
136
- }
137
-
138
- return false;
139
- });
118
+ return matchesAnyBranch(config.github_wiki.branches, currentBranch);
140
119
  }
141
120
 
142
121
  /**
@@ -0,0 +1,8 @@
1
+ export const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
2
+
3
+ export function assertGitHubRepo(repo) {
4
+ if (typeof repo !== "string" || !GITHUB_REPO_PATTERN.test(repo)) {
5
+ throw new Error(`Invalid GitHub repository "${repo}". Expected owner/name.`);
6
+ }
7
+ return repo;
8
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Sessions where RepoLens must not wait on a prompt.
3
+ * Covers CI providers, explicit opt-out, the test runner, and pipes.
4
+ */
5
+ export function isNonInteractiveEnvironment() {
6
+ if (
7
+ process.env.CI ||
8
+ process.env.GITHUB_ACTIONS ||
9
+ process.env.GITLAB_CI ||
10
+ process.env.CIRCLECI ||
11
+ process.env.JENKINS_HOME ||
12
+ process.env.CODEBUILD_BUILD_ID ||
13
+ process.env.REPOLENS_NON_INTERACTIVE ||
14
+ process.env.NODE_ENV === "test" ||
15
+ process.env.VITEST
16
+ ) {
17
+ return true;
18
+ }
19
+ return !process.stdin.isTTY;
20
+ }
@@ -59,7 +59,11 @@ function sanitizeArgs(args) {
59
59
  return sanitizeSecrets(arg);
60
60
  }
61
61
  if (typeof arg === "object" && arg !== null) {
62
- return JSON.parse(sanitizeSecrets(JSON.stringify(arg)));
62
+ try {
63
+ return JSON.parse(sanitizeSecrets(JSON.stringify(arg)));
64
+ } catch {
65
+ return "[unserializable]";
66
+ }
63
67
  }
64
68
  return arg;
65
69
  });
@@ -8,6 +8,30 @@ function isRetryableStatus(status) {
8
8
  return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
9
9
  }
10
10
 
11
+ const MAX_RETRY_AFTER_MS = 60_000;
12
+
13
+ /**
14
+ * Honour Retry-After when it is delta-seconds or an HTTP date.
15
+ * Unparseable values fall back to exponential backoff. Delays are capped
16
+ * so a hostile header cannot stall the process.
17
+ */
18
+ export function retryDelayMs(retryAfterHeader, attempt, baseDelayMs, maxDelayMs) {
19
+ const fallback = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
20
+ if (retryAfterHeader == null || retryAfterHeader === "") return fallback;
21
+
22
+ const seconds = Number(retryAfterHeader);
23
+ if (Number.isFinite(seconds) && seconds >= 0) {
24
+ return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS);
25
+ }
26
+
27
+ const when = Date.parse(retryAfterHeader);
28
+ if (Number.isFinite(when)) {
29
+ return Math.min(Math.max(0, when - Date.now()), MAX_RETRY_AFTER_MS);
30
+ }
31
+
32
+ return fallback;
33
+ }
34
+
11
35
  export async function fetchWithRetry(url, options = {}, config = {}) {
12
36
  const {
13
37
  retries = 3,
@@ -37,12 +61,12 @@ export async function fetchWithRetry(url, options = {}, config = {}) {
37
61
  return response;
38
62
  }
39
63
 
40
- const retryAfterHeader = response.headers.get("retry-after");
41
- const retryAfterMs = retryAfterHeader != null
42
- ? Number(retryAfterHeader) * 1000
43
- : null;
44
-
45
- const delay = retryAfterMs != null ? retryAfterMs : Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
64
+ const delay = retryDelayMs(
65
+ response.headers.get("retry-after"),
66
+ attempt,
67
+ baseDelayMs,
68
+ maxDelayMs
69
+ );
46
70
 
47
71
  warn(`${label} failed with retryable status ${response.status}. Retrying in ${delay}ms (attempt ${attempt + 1}/${retries + 1})`);
48
72
  await sleep(delay);
@@ -0,0 +1,50 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * True when `target` is `root` or a path inside it.
5
+ * Comparison uses resolved paths so `..`, absolute segments, and
6
+ * platform separators cannot escape the root.
7
+ */
8
+ export function isInsideRoot(root, target) {
9
+ const rootResolved = path.resolve(root);
10
+ const targetResolved = path.resolve(target);
11
+ const rel = path.relative(rootResolved, targetResolved);
12
+ return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel));
13
+ }
14
+
15
+ /**
16
+ * Resolve a documentation output directory and keep it inside `root`.
17
+ * Absolute paths and `..` escapes are rejected. An empty value means `.repolens`.
18
+ */
19
+ export function resolveOutputDir(root, outputDir = ".repolens") {
20
+ const relative = outputDir == null || outputDir === "" ? ".repolens" : outputDir;
21
+ if (typeof relative !== "string") {
22
+ throw new Error("Output directory must be a string");
23
+ }
24
+ if (relative.includes("\0")) {
25
+ throw new Error("Output directory contains a null byte");
26
+ }
27
+ if (path.isAbsolute(relative)) {
28
+ throw new Error(`Output directory must be a relative path inside the repository (received ${relative})`);
29
+ }
30
+
31
+ const rootResolved = path.resolve(root);
32
+ const resolved = path.resolve(rootResolved, relative);
33
+ if (!isInsideRoot(rootResolved, resolved)) {
34
+ throw new Error(`Output directory escapes the repository: ${relative}`);
35
+ }
36
+ return resolved;
37
+ }
38
+
39
+ /**
40
+ * Reduce a document or wiki name to a single path segment.
41
+ */
42
+ export function safeFileName(name, fallback = "document.md") {
43
+ if (typeof name !== "string" || name.length === 0) return fallback;
44
+ const normalized = name.replace(/\\/g, "/");
45
+ const base = path.posix.basename(normalized);
46
+ if (!base || base === "." || base === ".." || base.includes("\0") || base.includes("/") || base.includes("\\")) {
47
+ return fallback;
48
+ }
49
+ return base;
50
+ }
@@ -12,7 +12,9 @@ const SECRET_PATTERNS = [
12
12
  // API Keys
13
13
  { name: "OpenAI API Key", pattern: /sk-[a-zA-Z0-9]{20,}/, severity: "high" },
14
14
  { name: "Anthropic API Key", pattern: /sk-ant-[a-zA-Z0-9_-]{95,}/, severity: "high" },
15
- { name: "GitHub Token", pattern: /gh[ps]_[a-zA-Z0-9]{36,}/, severity: "critical" },
15
+ { name: "GitHub Token", pattern: /gh[pousr]_[a-zA-Z0-9]{36,}/, severity: "critical" },
16
+ { name: "GitHub Fine-Grained Token", pattern: /github_pat_[a-zA-Z0-9_]{20,}/, severity: "critical" },
17
+ { name: "npm Token", pattern: /npm_[a-zA-Z0-9]{36,}/, severity: "critical" },
16
18
  { name: "Generic API Key", pattern: /api[_-]?key[_-]?[a-zA-Z0-9]{20,}/i, severity: "high" },
17
19
 
18
20
  // OAuth Tokens
@@ -1,4 +1,3 @@
1
- import * as Sentry from "@sentry/node";
2
1
  import { readFileSync } from "node:fs";
3
2
  import { fileURLToPath } from "node:url";
4
3
  import { dirname, join } from "node:path";
@@ -9,12 +8,32 @@ const __dirname = dirname(__filename);
9
8
 
10
9
  let initialized = false;
11
10
  let enabled = false;
11
+ let Sentry = null;
12
+ let sentryLoadFailed = false;
13
+
14
+ /**
15
+ * Load @sentry/node on demand. Telemetry is opt-in and the package is an
16
+ * optional dependency, so a missing install must not crash the CLI.
17
+ */
18
+ async function loadSentry() {
19
+ if (Sentry) return Sentry;
20
+ if (sentryLoadFailed) return null;
21
+
22
+ try {
23
+ Sentry = await import("@sentry/node");
24
+ return Sentry;
25
+ } catch {
26
+ sentryLoadFailed = true;
27
+ Sentry = null;
28
+ return null;
29
+ }
30
+ }
12
31
 
13
32
  /**
14
33
  * Initialize error tracking (Sentry)
15
34
  * Only enabled if REPOLENS_TELEMETRY_ENABLED=true
16
35
  */
17
- export function initTelemetry() {
36
+ export async function initTelemetry() {
18
37
  // Skip if already initialized
19
38
  if (initialized) return;
20
39
  initialized = true;
@@ -27,6 +46,13 @@ export function initTelemetry() {
27
46
  return;
28
47
  }
29
48
 
49
+ const sentry = await loadSentry();
50
+ if (!sentry) {
51
+ console.error("Telemetry enabled but optional dependency @sentry/node is not installed. Continuing without telemetry.");
52
+ enabled = false;
53
+ return;
54
+ }
55
+
30
56
  try {
31
57
  // Get version from package.json
32
58
  const packageJsonPath = join(__dirname, "../../package.json");
@@ -35,7 +61,7 @@ export function initTelemetry() {
35
61
 
36
62
  const dsn = process.env.REPOLENS_SENTRY_DSN || "https://082083dbf5899ed7e65dfd9b8dc72f90@o4511014913703936.ingest.de.sentry.io/4511014919209040";
37
63
 
38
- Sentry.init({
64
+ sentry.init({
39
65
  dsn,
40
66
 
41
67
  // Release tracking
@@ -405,7 +431,9 @@ export function trackMigration(migratedCount, skippedCount) {
405
431
  * Ensure Sentry is initialized for feedback submission.
406
432
  * Feedback always works, even if REPOLENS_TELEMETRY_ENABLED is false.
407
433
  */
408
- function ensureSentryForFeedback() {
434
+ async function ensureSentryForFeedback() {
435
+ const sentry = await loadSentry();
436
+ if (!sentry) return false;
409
437
  if (enabled) return true;
410
438
 
411
439
  // Initialize Sentry in a minimal mode just for feedback
@@ -416,7 +444,7 @@ function ensureSentryForFeedback() {
416
444
 
417
445
  const dsn = process.env.REPOLENS_SENTRY_DSN || "https://082083dbf5899ed7e65dfd9b8dc72f90@o4511014913703936.ingest.de.sentry.io/4511014919209040";
418
446
 
419
- Sentry.init({
447
+ sentry.init({
420
448
  dsn,
421
449
  release: `repolens@${version}`,
422
450
  environment: process.env.NODE_ENV || "production",
@@ -439,7 +467,7 @@ function ensureSentryForFeedback() {
439
467
  */
440
468
  export async function sendFeedback(name, email, message) {
441
469
  try {
442
- if (!ensureSentryForFeedback()) {
470
+ if (!(await ensureSentryForFeedback())) {
443
471
  return false;
444
472
  }
445
473