@chappibunny/repolens 1.12.2 → 1.13.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/CHANGELOG.md +100 -0
- package/README.md +25 -36
- package/bin/repolens.js +21 -1
- package/package.json +20 -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-schema.js +47 -1
- 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 +16 -3
- package/src/help.js +54 -0
- package/src/index.js +13 -0
- package/src/init.js +99 -62
- 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 +24 -9
- package/src/publishers/markdown.js +3 -2
- package/src/publishers/notion.js +10 -4
- package/src/publishers/obsidian.js +716 -0
- package/src/utils/branch.js +37 -42
- 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 +70 -1
- 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
package/src/init.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
|
-
import {
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
5
|
import { info, warn } from "./utils/logger.js";
|
|
6
|
+
import { isNonInteractiveEnvironment } from "./utils/interactive.js";
|
|
6
7
|
|
|
7
|
-
const PUBLISHER_CHOICES = ["markdown", "notion", "confluence", "github_wiki"];
|
|
8
|
+
const PUBLISHER_CHOICES = ["markdown", "notion", "confluence", "github_wiki", "obsidian"];
|
|
8
9
|
|
|
9
10
|
// ============================================================================
|
|
10
11
|
// URL PARSING HELPERS
|
|
@@ -337,8 +338,14 @@ on:
|
|
|
337
338
|
- main
|
|
338
339
|
pull_request:
|
|
339
340
|
|
|
341
|
+
# contents: read checks out the repository.
|
|
342
|
+
# pull-requests: write creates and updates the Architecture Diff
|
|
343
|
+
# comment on pull requests (Issues comments API). Repository write
|
|
344
|
+
# is not required for that comment. Add it only on a job that
|
|
345
|
+
# publishes the GitHub Wiki.
|
|
340
346
|
permissions:
|
|
341
347
|
contents: read
|
|
348
|
+
pull-requests: write
|
|
342
349
|
|
|
343
350
|
jobs:
|
|
344
351
|
publish:
|
|
@@ -428,6 +435,10 @@ If you configured Notion credentials during setup, documentation will publish au
|
|
|
428
435
|
- \`NOTION_TOKEN\` — Get from https://www.notion.so/my-integrations
|
|
429
436
|
- \`NOTION_PARENT_PAGE_ID\` — The page where docs will be published
|
|
430
437
|
|
|
438
|
+
### Obsidian and GitHub Wiki
|
|
439
|
+
|
|
440
|
+
Obsidian writes notes into a folder in a local vault. Set \`obsidian.vault\` in \`.repolens.yml\`. No API key and no network. GitHub Wiki needs \`github_wiki\` in \`publishers\` and \`GITHUB_TOKEN\`.
|
|
441
|
+
|
|
431
442
|
### Confluence Publishing
|
|
432
443
|
|
|
433
444
|
To publish documentation to Atlassian Confluence:
|
|
@@ -461,25 +472,24 @@ For automated publishing on every push:
|
|
|
461
472
|
|
|
462
473
|
3. Push to main branch
|
|
463
474
|
|
|
475
|
+
The workflow token is \`contents: read\` plus \`pull-requests: write\`. That is enough for checkout and the Architecture Diff comment. It cannot push commits. GitHub Wiki publishing pushes a separate wiki repository, so add \`contents: write\` on that job only when you enable the wiki publisher.
|
|
476
|
+
|
|
464
477
|
## 📊 Generated Documentation
|
|
465
478
|
|
|
466
|
-
RepoLens generates
|
|
479
|
+
RepoLens generates 17 documents by default. Diagrams are Unicode.
|
|
467
480
|
|
|
468
|
-
###
|
|
469
|
-
- **System Overview**
|
|
470
|
-
- **
|
|
471
|
-
- **API Surface** — Internal endpoints + external integrations
|
|
472
|
-
- **Route Map** — Application routes
|
|
473
|
-
- **System Map** — Unicode architecture diagram
|
|
474
|
-
- **Architecture Diff** — Change tracking
|
|
481
|
+
### Always written
|
|
482
|
+
- **System Overview**, **Module Catalog**, **API Surface**, **Route Map**, **System Map** (Unicode diagram), **Architecture Diff**
|
|
483
|
+
- **GraphQL Schema**, **Type Graph**, **Dependency Graph**, **Architecture Drift**, **Security Hotspots**, **Code Health**
|
|
475
484
|
|
|
476
|
-
### AI-Enhanced Mode (Optional
|
|
477
|
-
|
|
485
|
+
### AI-Enhanced Mode (Optional)
|
|
486
|
+
These documents use AI when it is enabled, and a deterministic fallback when it is not:
|
|
478
487
|
- **Executive Summary** — Project overview for leadership
|
|
479
488
|
- **Business Domains** — What the system does by function
|
|
480
489
|
- **Architecture Overview** — Layered technical analysis
|
|
481
|
-
- **Data Flows** — How information moves through system
|
|
490
|
+
- **Data Flows** — How information moves through the system
|
|
482
491
|
- **Developer Onboarding** — Getting started guide
|
|
492
|
+
- **Change Impact** and the hybrid pages above (module catalog, route map, API surface, system map)
|
|
483
493
|
|
|
484
494
|
## 🤖 Enabling AI Features
|
|
485
495
|
|
|
@@ -520,11 +530,7 @@ No API key signup needed. Uses \`gpt-4o-mini\` by default.
|
|
|
520
530
|
|
|
521
531
|
**Cost estimate**: $0.10-$0.40 per run for typical projects (or free with GitHub Models)
|
|
522
532
|
|
|
523
|
-
See [AI.md](https://github.com/CHAPIBUNNY/repolens/blob/main/AI.md) for full documentation
|
|
524
|
-
- **Module Catalog** — Detected code modules
|
|
525
|
-
- **API Surface** — REST API endpoints
|
|
526
|
-
- **Route Map** — Application routes
|
|
527
|
-
- **System Map** — Architecture diagram (Unicode ASCII art)
|
|
533
|
+
See [AI.md](https://github.com/CHAPIBUNNY/repolens/blob/main/docs/AI.md) for full documentation.
|
|
528
534
|
|
|
529
535
|
## ⚙️ Configuration
|
|
530
536
|
|
|
@@ -538,8 +544,8 @@ Edit \`.repolens.yml\` to customize:
|
|
|
538
544
|
## 📚 Learn More
|
|
539
545
|
|
|
540
546
|
- [RepoLens GitHub](https://github.com/CHAPIBUNNY/repolens)
|
|
541
|
-
- [Configuration
|
|
542
|
-
- [Troubleshooting](https://github.com/CHAPIBUNNY/repolens
|
|
547
|
+
- [Configuration](https://github.com/CHAPIBUNNY/repolens/blob/main/docs/CONFIGURATION.md)
|
|
548
|
+
- [Troubleshooting](https://github.com/CHAPIBUNNY/repolens/blob/main/docs/TROUBLESHOOTING.md)
|
|
543
549
|
`;
|
|
544
550
|
|
|
545
551
|
async function fileExists(filePath) {
|
|
@@ -564,24 +570,27 @@ async function dirExists(dirPath) {
|
|
|
564
570
|
* Open a URL in the default browser (cross-platform).
|
|
565
571
|
*/
|
|
566
572
|
function openUrl(url) {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
573
|
+
let parsed;
|
|
574
|
+
try {
|
|
575
|
+
parsed = new URL(url);
|
|
576
|
+
} catch {
|
|
577
|
+
return Promise.resolve(false);
|
|
578
|
+
}
|
|
579
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
580
|
+
return Promise.resolve(false);
|
|
575
581
|
}
|
|
582
|
+
|
|
583
|
+
const platform = process.platform;
|
|
584
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "rundll32" : "xdg-open";
|
|
585
|
+
const args = platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
586
|
+
|
|
576
587
|
return new Promise((resolve) => {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
} else {
|
|
582
|
-
resolve(true);
|
|
583
|
-
}
|
|
588
|
+
const child = spawn(cmd, args, { stdio: "ignore" });
|
|
589
|
+
child.on("error", (err) => {
|
|
590
|
+
warn(`Could not open browser: ${err.message}`);
|
|
591
|
+
resolve(false);
|
|
584
592
|
});
|
|
593
|
+
child.on("close", (code) => resolve(code === 0));
|
|
585
594
|
});
|
|
586
595
|
}
|
|
587
596
|
|
|
@@ -742,6 +751,17 @@ function buildRepoLensConfig(projectName, detectedRoots, options = {}) {
|
|
|
742
751
|
`# - main # Only publish from main branch`,
|
|
743
752
|
`# - develop # Or add specific branches`,
|
|
744
753
|
``,
|
|
754
|
+
`# Optional: Publish into a local Obsidian vault (no network)`,
|
|
755
|
+
`# publishers also needs: - obsidian`,
|
|
756
|
+
`# obsidian:`,
|
|
757
|
+
`# vault: "/path/to/vault" # absolute, or relative to this repo`,
|
|
758
|
+
`# folder: "RepoLens/my-app" # stays inside the vault`,
|
|
759
|
+
`# branches:`,
|
|
760
|
+
`# - main`,
|
|
761
|
+
`# index: true`,
|
|
762
|
+
`# frontmatter: true`,
|
|
763
|
+
`# wikilinks: true`,
|
|
764
|
+
``,
|
|
745
765
|
`# Optional: GitHub integration for SVG diagram hosting`,
|
|
746
766
|
`# github:`,
|
|
747
767
|
`# owner: "your-username"`,
|
|
@@ -844,23 +864,10 @@ function detectProjectName(repoRoot) {
|
|
|
844
864
|
}
|
|
845
865
|
|
|
846
866
|
async function promptNotionCredentials() {
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
process.env.CIRCLECI ||
|
|
852
|
-
process.env.JENKINS_HOME ||
|
|
853
|
-
process.env.CODEBUILD_BUILD_ID;
|
|
854
|
-
|
|
855
|
-
const isTest = process.env.NODE_ENV === 'test' || process.env.VITEST;
|
|
856
|
-
|
|
857
|
-
if (isCI) {
|
|
858
|
-
info("⏭️ Skipping interactive prompts (CI environment detected)");
|
|
859
|
-
return null;
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
if (isTest) {
|
|
863
|
-
// Skip silently in test mode
|
|
867
|
+
if (isNonInteractiveEnvironment()) {
|
|
868
|
+
if (process.env.NODE_ENV !== "test" && !process.env.VITEST) {
|
|
869
|
+
info("⏭️ Skipping interactive prompts (non-interactive environment)");
|
|
870
|
+
}
|
|
864
871
|
return null;
|
|
865
872
|
}
|
|
866
873
|
|
|
@@ -943,11 +950,6 @@ async function ensureEnvInGitignore(repoRoot) {
|
|
|
943
950
|
* Returns a structured config that replaces the auto-detected defaults.
|
|
944
951
|
*/
|
|
945
952
|
async function runInteractiveWizard(repoRoot) {
|
|
946
|
-
const isCI = process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
|
|
947
|
-
process.env.CIRCLECI || process.env.JENKINS_HOME || process.env.CODEBUILD_BUILD_ID;
|
|
948
|
-
const isTest = process.env.NODE_ENV === "test" || process.env.VITEST;
|
|
949
|
-
if (isCI || isTest) return null;
|
|
950
|
-
|
|
951
953
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
952
954
|
const ask = (q) => rl.question(q);
|
|
953
955
|
|
|
@@ -1162,6 +1164,20 @@ async function runInteractiveWizard(repoRoot) {
|
|
|
1162
1164
|
githubSecretsNeeded.push("CONFLUENCE_URL", "CONFLUENCE_EMAIL", "CONFLUENCE_API_TOKEN", "CONFLUENCE_SPACE_KEY", "CONFLUENCE_PARENT_PAGE_ID");
|
|
1163
1165
|
}
|
|
1164
1166
|
|
|
1167
|
+
// Obsidian vault (local path only — no credentials)
|
|
1168
|
+
let obsidian = null;
|
|
1169
|
+
if (publishers.includes("obsidian")) {
|
|
1170
|
+
info("\n📝 Obsidian Vault");
|
|
1171
|
+
info(" Notes are written into a folder in your vault. Nothing is uploaded.");
|
|
1172
|
+
const vault = (await ask(" Vault path (absolute, or relative to the repo): ")).trim();
|
|
1173
|
+
const folderDefault = `RepoLens/${projectName}`;
|
|
1174
|
+
const folder = (await ask(` Folder inside the vault (${folderDefault}): `)).trim();
|
|
1175
|
+
obsidian = { vault, folder: folder || folderDefault };
|
|
1176
|
+
if (!vault) {
|
|
1177
|
+
warn(" No vault path yet. Add obsidian.vault to .repolens.yml before publishing.");
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1165
1181
|
// GitHub Wiki setup
|
|
1166
1182
|
if (publishers.includes("github_wiki")) {
|
|
1167
1183
|
info("\n📝 GitHub Wiki Setup");
|
|
@@ -1284,7 +1300,7 @@ async function runInteractiveWizard(repoRoot) {
|
|
|
1284
1300
|
|
|
1285
1301
|
// 6. Branch filtering
|
|
1286
1302
|
info("\n🌿 Branch Filtering");
|
|
1287
|
-
info(" Limits which branches can publish to Notion
|
|
1303
|
+
info(" Limits which branches can publish to Notion, Confluence, and Obsidian.");
|
|
1288
1304
|
const branchInput = (await ask(" Allowed branches (comma-separated, default: main): ")).trim() || "main";
|
|
1289
1305
|
const branches = branchInput.split(",").map((b) => b.trim()).filter(Boolean);
|
|
1290
1306
|
|
|
@@ -1342,7 +1358,8 @@ async function runInteractiveWizard(repoRoot) {
|
|
|
1342
1358
|
branches,
|
|
1343
1359
|
enableDiscord,
|
|
1344
1360
|
credentials,
|
|
1345
|
-
githubSecretsNeeded: uniqueSecrets
|
|
1361
|
+
githubSecretsNeeded: uniqueSecrets,
|
|
1362
|
+
obsidian,
|
|
1346
1363
|
};
|
|
1347
1364
|
} finally {
|
|
1348
1365
|
rl.close();
|
|
@@ -1354,6 +1371,7 @@ const PUBLISHER_DESCRIPTIONS = {
|
|
|
1354
1371
|
notion: "Notion workspace pages",
|
|
1355
1372
|
confluence: "Atlassian Confluence pages",
|
|
1356
1373
|
github_wiki: "Repository wiki pages",
|
|
1374
|
+
obsidian: "Local Obsidian vault folder",
|
|
1357
1375
|
};
|
|
1358
1376
|
|
|
1359
1377
|
/**
|
|
@@ -1386,6 +1404,22 @@ function buildWizardConfig(answers) {
|
|
|
1386
1404
|
lines.push(` branches:`);
|
|
1387
1405
|
for (const b of answers.branches) lines.push(` - ${b}`);
|
|
1388
1406
|
}
|
|
1407
|
+
if (answers.publishers.includes("obsidian")) {
|
|
1408
|
+
lines.push(``);
|
|
1409
|
+
lines.push(`obsidian:`);
|
|
1410
|
+
if (answers.obsidian?.vault) {
|
|
1411
|
+
lines.push(` vault: ${JSON.stringify(answers.obsidian.vault)}`);
|
|
1412
|
+
}
|
|
1413
|
+
const folder = answers.obsidian?.folder || `RepoLens/${answers.projectName}`;
|
|
1414
|
+
lines.push(` folder: ${JSON.stringify(folder)}`);
|
|
1415
|
+
if (answers.branches?.length) {
|
|
1416
|
+
lines.push(` branches:`);
|
|
1417
|
+
for (const b of answers.branches) lines.push(` - ${b}`);
|
|
1418
|
+
}
|
|
1419
|
+
lines.push(` index: true`);
|
|
1420
|
+
lines.push(` frontmatter: true`);
|
|
1421
|
+
lines.push(` wikilinks: true`);
|
|
1422
|
+
}
|
|
1389
1423
|
|
|
1390
1424
|
if (answers.enableDiscord) {
|
|
1391
1425
|
lines.push(``);
|
|
@@ -1515,14 +1549,17 @@ export async function runInit(targetDir = process.cwd(), options = {}) {
|
|
|
1515
1549
|
// Ensure target directory exists
|
|
1516
1550
|
await fs.mkdir(repoRoot, { recursive: true });
|
|
1517
1551
|
|
|
1518
|
-
// Interactive wizard is
|
|
1552
|
+
// Interactive wizard is the default (--quick skips it).
|
|
1553
|
+
// CI, tests, and closed stdin used to return null from the wizard and
|
|
1554
|
+
// abort before any files were written. Fall through to quick setup instead.
|
|
1519
1555
|
let wizardAnswers = null;
|
|
1520
|
-
if (options.interactive) {
|
|
1556
|
+
if (options.interactive && !isNonInteractiveEnvironment()) {
|
|
1521
1557
|
wizardAnswers = await runInteractiveWizard(repoRoot);
|
|
1522
1558
|
if (!wizardAnswers) {
|
|
1523
|
-
// User cancelled the wizard
|
|
1524
1559
|
return;
|
|
1525
1560
|
}
|
|
1561
|
+
} else if (options.interactive) {
|
|
1562
|
+
info("Non-interactive environment detected — running quick setup instead of the wizard.");
|
|
1526
1563
|
}
|
|
1527
1564
|
|
|
1528
1565
|
// Prompt for Notion credentials interactively (only in non-wizard mode)
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* Sends rich embed notifications to Discord channels via webhooks
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import fetch from "node-fetch";
|
|
7
6
|
import { info, warn, error } from "../utils/logger.js";
|
|
7
|
+
import { sanitizeSecrets } from "../utils/secrets.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Send a notification to Discord webhook
|
|
@@ -26,15 +26,29 @@ export async function sendDiscordNotification(webhookUrl, payload) {
|
|
|
26
26
|
embeds: [embed],
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
let webhook;
|
|
30
|
+
try {
|
|
31
|
+
webhook = new URL(webhookUrl);
|
|
32
|
+
} catch {
|
|
33
|
+
warn("Discord webhook URL is not a valid URL, skipping notification");
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (webhook.protocol !== "https:") {
|
|
37
|
+
warn("Discord webhook URL must use https, skipping notification");
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
const response = await fetch(webhookUrl, {
|
|
30
42
|
method: "POST",
|
|
31
43
|
headers: { "Content-Type": "application/json" },
|
|
32
44
|
body: JSON.stringify(body),
|
|
45
|
+
signal: AbortSignal.timeout(15000),
|
|
46
|
+
redirect: "error",
|
|
33
47
|
});
|
|
34
48
|
|
|
35
49
|
if (!response.ok) {
|
|
36
50
|
const text = await response.text();
|
|
37
|
-
error(`Discord webhook failed (${response.status}): ${text}`);
|
|
51
|
+
error(`Discord webhook failed (${response.status}): ${sanitizeSecrets(String(text).slice(0, 300))}`);
|
|
38
52
|
return false;
|
|
39
53
|
}
|
|
40
54
|
|
package/src/migrate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import
|
|
3
|
+
import { loadYaml } from "./utils/yaml.js";
|
|
4
4
|
import { info, warn, error as logError } from "./utils/logger.js";
|
|
5
5
|
import { trackMigration } from "./utils/telemetry.js";
|
|
6
6
|
|
|
@@ -141,7 +141,7 @@ async function migrateConfigFile(targetDir, options = {}) {
|
|
|
141
141
|
const raw = await fs.readFile(configPath, "utf8");
|
|
142
142
|
let parsed;
|
|
143
143
|
try {
|
|
144
|
-
parsed =
|
|
144
|
+
parsed = loadYaml(raw);
|
|
145
145
|
} catch {
|
|
146
146
|
warn(` ⚠️ Could not parse ${filename} — skipping config migration`);
|
|
147
147
|
return { migrated: false, filePath: configPath, reason: "parse-error" };
|
|
@@ -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, info, warn } from "../utils/logger.js";
|
|
5
4
|
import { fetchWithRetry } from "../utils/retry.js";
|
|
6
5
|
import { getCurrentBranch, getBranchQualifiedTitle } from "../utils/branch.js";
|
|
7
6
|
import { createRepoLensError } from "../utils/errors.js";
|
|
7
|
+
import { sanitizeSecrets } from "../utils/secrets.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Confluence Publisher for RepoLens
|
|
@@ -50,6 +50,19 @@ function getConfluenceBaseUrl() {
|
|
|
50
50
|
);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = new URL(url);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new Error("CONFLUENCE_URL is not a valid URL");
|
|
58
|
+
}
|
|
59
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
60
|
+
throw new Error("CONFLUENCE_URL must use http or https");
|
|
61
|
+
}
|
|
62
|
+
if (parsed.username || parsed.password) {
|
|
63
|
+
throw new Error("CONFLUENCE_URL must not include embedded credentials. Use CONFLUENCE_EMAIL and CONFLUENCE_API_TOKEN.");
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
// Normalize URL (remove trailing slash, ensure /wiki for Cloud)
|
|
54
67
|
let normalized = url.replace(/\/+$/, "");
|
|
55
68
|
|
|
@@ -84,7 +97,8 @@ async function confluenceRequest(method, endpoint, body = null) {
|
|
|
84
97
|
|
|
85
98
|
if (!res.ok) {
|
|
86
99
|
const text = await res.text();
|
|
87
|
-
|
|
100
|
+
const clipped = sanitizeSecrets(String(text || "").replace(/\s+/g, " ").slice(0, 500));
|
|
101
|
+
let errorMsg = `Confluence API error ${res.status}: ${clipped}`;
|
|
88
102
|
|
|
89
103
|
// Provide helpful error messages
|
|
90
104
|
if (res.status === 401) {
|
|
@@ -114,7 +128,7 @@ async function confluenceRequest(method, endpoint, body = null) {
|
|
|
114
128
|
const CACHE_DIR = path.join(process.cwd(), ".cache");
|
|
115
129
|
|
|
116
130
|
function getCacheFile() {
|
|
117
|
-
const branch = getCurrentBranch();
|
|
131
|
+
const branch = getCurrentBranch().replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80) || "unknown";
|
|
118
132
|
return path.join(CACHE_DIR, `confluence-pages-${branch}.json`);
|
|
119
133
|
}
|
|
120
134
|
|
|
@@ -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,9 @@ 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 {
|
|
5
|
+
import { publishToObsidian } from "./obsidian.js";
|
|
6
|
+
import { shouldPublishToNotion, shouldPublishToConfluence, shouldPublishToGitHubWiki, shouldPublishToObsidian, getCurrentBranch, branchMatches } from "../utils/branch.js";
|
|
7
|
+
import { resolveOutputDir } from "../utils/safe-path.js";
|
|
6
8
|
import { info, warn } from "../utils/logger.js";
|
|
7
9
|
import { trackPublishing } from "../utils/telemetry.js";
|
|
8
10
|
import { collectMetrics } from "../utils/metrics.js";
|
|
@@ -27,7 +29,7 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
|
|
|
27
29
|
let notionUrl = null;
|
|
28
30
|
|
|
29
31
|
// --- Hash-based caching: skip unchanged documents ---
|
|
30
|
-
const cacheDir =
|
|
32
|
+
const cacheDir = resolveOutputDir(process.cwd(), cfg.documentation?.output_dir || ".repolens");
|
|
31
33
|
const { cache: previousCache, age: cacheAge } = await loadDocCache(cacheDir);
|
|
32
34
|
const { changedPages, unchangedKeys, newCache } = filterChangedDocs(renderedPages, previousCache);
|
|
33
35
|
logCacheStats(Object.keys(changedPages).length, unchangedKeys.length, cacheAge);
|
|
@@ -95,6 +97,25 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
|
|
|
95
97
|
}
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
// Obsidian vault (local files only — no credentials, no network)
|
|
101
|
+
if (publishers.includes("obsidian")) {
|
|
102
|
+
if (shouldPublishToObsidian(cfg, currentBranch)) {
|
|
103
|
+
info(`Publishing to Obsidian vault from branch: ${currentBranch}`);
|
|
104
|
+
try {
|
|
105
|
+
await publishToObsidian(cfg, renderedPages);
|
|
106
|
+
publishedTo.push("obsidian");
|
|
107
|
+
} catch (err) {
|
|
108
|
+
warn(`Obsidian publish failed: ${err.message}`);
|
|
109
|
+
publishErrors.push({ publisher: "obsidian", error: err });
|
|
110
|
+
publishStatus = "partial";
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
const allowedBranches = cfg.obsidian?.branches?.join(", ") || "none configured";
|
|
114
|
+
warn(`Skipping Obsidian publish: branch "${currentBranch}" not in allowed list (${allowedBranches})`);
|
|
115
|
+
info("To publish from this branch, add it to obsidian.branches in .repolens.yml");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
98
119
|
// GitHub Wiki publishing (opt-in if secrets configured)
|
|
99
120
|
if (publishers.includes("github_wiki") || hasGitHubWikiSecrets() && publishers.includes("github_wiki")) {
|
|
100
121
|
if (!hasGitHubWikiSecrets()) {
|
|
@@ -154,13 +175,7 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
|
|
|
154
175
|
|
|
155
176
|
// Check if we should send notification for this branch
|
|
156
177
|
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
|
-
});
|
|
178
|
+
const branchAllowed = allowedBranches.some((pattern) => branchMatches(pattern, currentBranch));
|
|
164
179
|
|
|
165
180
|
if (webhookUrl && discordEnabled && branchAllowed) {
|
|
166
181
|
// Calculate change percent (if we have history)
|