@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +25 -36
  3. package/bin/repolens.js +21 -1
  4. package/package.json +20 -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-schema.js +47 -1
  10. package/src/core/config.js +5 -2
  11. package/src/core/diff.js +43 -20
  12. package/src/core/scan.js +24 -4
  13. package/src/delivery/comment.js +5 -2
  14. package/src/docs/write-doc-set.js +9 -7
  15. package/src/doctor.js +16 -3
  16. package/src/help.js +54 -0
  17. package/src/index.js +13 -0
  18. package/src/init.js +99 -62
  19. package/src/integrations/discord.js +16 -2
  20. package/src/migrate.js +2 -2
  21. package/src/publishers/confluence.js +17 -3
  22. package/src/publishers/github-wiki.js +61 -32
  23. package/src/publishers/index.js +24 -9
  24. package/src/publishers/markdown.js +3 -2
  25. package/src/publishers/notion.js +10 -4
  26. package/src/publishers/obsidian.js +716 -0
  27. package/src/utils/branch.js +37 -42
  28. package/src/utils/github-repo.js +8 -0
  29. package/src/utils/interactive.js +20 -0
  30. package/src/utils/logger.js +5 -1
  31. package/src/utils/retry.js +30 -6
  32. package/src/utils/safe-path.js +50 -0
  33. package/src/utils/secrets.js +3 -1
  34. package/src/utils/telemetry.js +34 -6
  35. package/src/utils/update-check.js +23 -6
  36. package/src/utils/validate.js +70 -1
  37. package/src/utils/yaml.js +27 -0
  38. package/src/watch.js +1 -1
  39. package/docs/AI.md +0 -329
  40. package/docs/ARCHITECTURE.md +0 -145
  41. package/docs/CONFIGURATION.md +0 -121
  42. package/docs/DEVELOPMENT.md +0 -79
  43. package/docs/ENVIRONMENT.md +0 -79
  44. package/docs/INSTALLATION.md +0 -45
  45. package/docs/KNOWN_ISSUES.md +0 -309
  46. package/docs/MIGRATION.md +0 -311
  47. package/docs/ONBOARDING.md +0 -382
  48. package/docs/PRODUCTION_CHECKLIST.md +0 -470
  49. package/docs/RELEASE.md +0 -76
  50. package/docs/ROADMAP.md +0 -151
  51. package/docs/STABILITY.md +0 -265
  52. package/docs/TELEMETRY.md +0 -166
  53. 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 { exec } from "node:child_process";
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 documentation in two modes:
479
+ RepoLens generates 17 documents by default. Diagrams are Unicode.
467
480
 
468
- ### Deterministic Mode (Default - Free)
469
- - **System Overview** — Technical profile and stats
470
- - **Module Catalog** — Complete code inventory
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 - Requires API Key)
477
- Adds 5 natural language documents readable by non-technical audiences:
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 Docs](https://github.com/CHAPIBUNNY/repolens#configuration)
542
- - [Troubleshooting](https://github.com/CHAPIBUNNY/repolens#troubleshooting)
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
- const platform = process.platform;
568
- let cmd;
569
- if (platform === "darwin") {
570
- cmd = `open "${url}"`;
571
- } else if (platform === "win32") {
572
- cmd = `start "" "${url}"`;
573
- } else {
574
- cmd = `xdg-open "${url}"`;
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
- exec(cmd, (err) => {
578
- if (err) {
579
- warn(`Could not open browser: ${err.message}`);
580
- resolve(false);
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
- // Skip prompts in CI environments or test mode
848
- const isCI = process.env.CI ||
849
- process.env.GITHUB_ACTIONS ||
850
- process.env.GITLAB_CI ||
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/Confluence.");
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 now the default (--quick skips it)
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 yaml from "js-yaml";
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 = yaml.load(raw);
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
- let errorMsg = `Confluence API error ${res.status}: ${text}`;
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 { 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,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 { shouldPublishToNotion, shouldPublishToConfluence, shouldPublishToGitHubWiki, getCurrentBranch } from "../utils/branch.js";
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 = path.join(process.cwd(), cfg.documentation?.output_dir || ".repolens");
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)