@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.
- package/CHANGELOG.md +82 -0
- package/README.md +4 -4
- package/bin/repolens.js +21 -1
- package/package.json +19 -11
- package/src/ai/provider.js +1 -2
- package/src/analyzers/monorepo-detector.js +8 -20
- package/src/cli-args.js +60 -0
- package/src/cli.js +80 -100
- package/src/core/config.js +5 -2
- package/src/core/diff.js +43 -20
- package/src/core/scan.js +24 -4
- package/src/delivery/comment.js +5 -2
- package/src/docs/write-doc-set.js +9 -7
- package/src/doctor.js +2 -2
- package/src/help.js +54 -0
- package/src/index.js +13 -0
- package/src/init.js +38 -41
- package/src/integrations/discord.js +16 -2
- package/src/migrate.js +2 -2
- package/src/publishers/confluence.js +17 -3
- package/src/publishers/github-wiki.js +61 -32
- package/src/publishers/index.js +4 -9
- package/src/publishers/markdown.js +3 -2
- package/src/publishers/notion.js +10 -4
- package/src/utils/branch.js +23 -44
- package/src/utils/github-repo.js +8 -0
- package/src/utils/interactive.js +20 -0
- package/src/utils/logger.js +5 -1
- package/src/utils/retry.js +30 -6
- package/src/utils/safe-path.js +50 -0
- package/src/utils/secrets.js +3 -1
- package/src/utils/telemetry.js +34 -6
- package/src/utils/update-check.js +23 -6
- package/src/utils/validate.js +7 -0
- package/src/utils/yaml.js +27 -0
- package/src/watch.js +1 -1
- package/docs/AI.md +0 -329
- package/docs/ARCHITECTURE.md +0 -145
- package/docs/CONFIGURATION.md +0 -121
- package/docs/DEVELOPMENT.md +0 -79
- package/docs/ENVIRONMENT.md +0 -79
- package/docs/INSTALLATION.md +0 -45
- package/docs/KNOWN_ISSUES.md +0 -309
- package/docs/MIGRATION.md +0 -311
- package/docs/ONBOARDING.md +0 -382
- package/docs/PRODUCTION_CHECKLIST.md +0 -470
- package/docs/RELEASE.md +0 -76
- package/docs/ROADMAP.md +0 -151
- package/docs/STABILITY.md +0 -265
- package/docs/TELEMETRY.md +0 -166
- 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 {
|
|
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
|
|
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 =
|
|
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
|
-
*
|
|
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
|
|
200
|
-
return `https://
|
|
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, "-")
|
|
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
|
-
*
|
|
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
|
|
375
|
+
return execFileSync("git", args, {
|
|
354
376
|
cwd,
|
|
355
377
|
encoding: "utf8",
|
|
356
378
|
stdio: ["pipe", "pipe", "pipe"],
|
|
357
|
-
env:
|
|
379
|
+
env: gitEnv(token),
|
|
358
380
|
});
|
|
359
381
|
} catch (err) {
|
|
360
|
-
const sanitized = (err.stderr || err.message || "")
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
382
|
-
if (!
|
|
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
|
|
427
|
+
const cloneUrl = buildWikiCloneUrl(repo);
|
|
399
428
|
|
|
400
429
|
try {
|
|
401
|
-
git(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
465
|
+
await writeWikiFile(tmpDir, "_Footer.md", footer);
|
|
437
466
|
}
|
|
438
467
|
|
|
439
468
|
// Stage, commit, push
|
|
440
|
-
git("config user.name
|
|
441
|
-
git("config user.email
|
|
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
|
-
|
|
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(
|
|
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 {
|
package/src/publishers/index.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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) {
|
package/src/publishers/notion.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
68
|
-
|
|
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) {
|
package/src/utils/branch.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/utils/logger.js
CHANGED
|
@@ -59,7 +59,11 @@ function sanitizeArgs(args) {
|
|
|
59
59
|
return sanitizeSecrets(arg);
|
|
60
60
|
}
|
|
61
61
|
if (typeof arg === "object" && arg !== null) {
|
|
62
|
-
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(sanitizeSecrets(JSON.stringify(arg)));
|
|
64
|
+
} catch {
|
|
65
|
+
return "[unserializable]";
|
|
66
|
+
}
|
|
63
67
|
}
|
|
64
68
|
return arg;
|
|
65
69
|
});
|
package/src/utils/retry.js
CHANGED
|
@@ -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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|
package/src/utils/secrets.js
CHANGED
|
@@ -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[
|
|
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
|
package/src/utils/telemetry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|