@chappibunny/repolens 1.12.2 → 1.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +4 -4
  3. package/bin/repolens.js +21 -1
  4. package/package.json +19 -11
  5. package/src/ai/provider.js +1 -2
  6. package/src/analyzers/monorepo-detector.js +8 -20
  7. package/src/cli-args.js +60 -0
  8. package/src/cli.js +80 -100
  9. package/src/core/config.js +5 -2
  10. package/src/core/diff.js +43 -20
  11. package/src/core/scan.js +24 -4
  12. package/src/delivery/comment.js +5 -2
  13. package/src/docs/write-doc-set.js +9 -7
  14. package/src/doctor.js +2 -2
  15. package/src/help.js +54 -0
  16. package/src/index.js +13 -0
  17. package/src/init.js +38 -41
  18. package/src/integrations/discord.js +16 -2
  19. package/src/migrate.js +2 -2
  20. package/src/publishers/confluence.js +17 -3
  21. package/src/publishers/github-wiki.js +61 -32
  22. package/src/publishers/index.js +4 -9
  23. package/src/publishers/markdown.js +3 -2
  24. package/src/publishers/notion.js +10 -4
  25. package/src/utils/branch.js +23 -44
  26. package/src/utils/github-repo.js +8 -0
  27. package/src/utils/interactive.js +20 -0
  28. package/src/utils/logger.js +5 -1
  29. package/src/utils/retry.js +30 -6
  30. package/src/utils/safe-path.js +50 -0
  31. package/src/utils/secrets.js +3 -1
  32. package/src/utils/telemetry.js +34 -6
  33. package/src/utils/update-check.js +23 -6
  34. package/src/utils/validate.js +7 -0
  35. package/src/utils/yaml.js +27 -0
  36. package/src/watch.js +1 -1
  37. package/docs/AI.md +0 -329
  38. package/docs/ARCHITECTURE.md +0 -145
  39. package/docs/CONFIGURATION.md +0 -121
  40. package/docs/DEVELOPMENT.md +0 -79
  41. package/docs/ENVIRONMENT.md +0 -79
  42. package/docs/INSTALLATION.md +0 -45
  43. package/docs/KNOWN_ISSUES.md +0 -309
  44. package/docs/MIGRATION.md +0 -311
  45. package/docs/ONBOARDING.md +0 -382
  46. package/docs/PRODUCTION_CHECKLIST.md +0 -470
  47. package/docs/RELEASE.md +0 -76
  48. package/docs/ROADMAP.md +0 -151
  49. package/docs/STABILITY.md +0 -265
  50. package/docs/TELEMETRY.md +0 -166
  51. package/docs/TROUBLESHOOTING.md +0 -377
package/src/core/diff.js CHANGED
@@ -1,20 +1,49 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
2
  import { warn } from "../utils/logger.js";
3
3
 
4
- export function getGitDiff(baseRef = "origin/main") {
4
+ const SAFE_REF = /^[A-Za-z0-9._~^/@+-]+$/;
5
+
6
+ function emptyDiff() {
7
+ return { added: [], removed: [], modified: [] };
8
+ }
9
+
10
+ /**
11
+ * Classify one `git diff --name-status` line.
12
+ * Renames and copies (`R100`, `C75`) record the destination path.
13
+ */
14
+ export function classifyDiffLine(line) {
15
+ const parts = String(line || "").split("\t");
16
+ const status = parts[0];
17
+ if (!status) return null;
18
+
19
+ const file = status.startsWith("R") || status.startsWith("C")
20
+ ? parts[2] || parts[1]
21
+ : parts[1];
22
+ if (!file) return null;
23
+
24
+ if (status === "A") return { bucket: "added", file };
25
+ if (status === "D") return { bucket: "removed", file };
26
+ return { bucket: "modified", file };
27
+ }
28
+
29
+ export function getGitDiff(baseRef = "origin/main", cwd = process.cwd()) {
30
+ if (typeof baseRef !== "string" || baseRef.startsWith("-") || !SAFE_REF.test(baseRef)) {
31
+ warn("git diff skipped: base ref contains unsupported characters.");
32
+ return emptyDiff();
33
+ }
34
+
5
35
  let output = "";
6
36
 
7
37
  try {
8
- output = execSync(`git diff --name-status ${baseRef}`, {
9
- encoding: "utf8"
38
+ // Pipe stderr so a missing repo or ref does not dump git's usage text.
39
+ output = execFileSync("git", ["diff", "--name-status", baseRef], {
40
+ encoding: "utf8",
41
+ cwd,
42
+ stdio: ["ignore", "pipe", "pipe"],
10
43
  });
11
44
  } catch (error) {
12
45
  warn("git diff failed, returning empty diff.");
13
- return {
14
- added: [],
15
- removed: [],
16
- modified: []
17
- };
46
+ return emptyDiff();
18
47
  }
19
48
 
20
49
  const lines = output.split("\n").filter(Boolean);
@@ -24,17 +53,11 @@ export function getGitDiff(baseRef = "origin/main") {
24
53
  const modified = [];
25
54
 
26
55
  for (const line of lines) {
27
- const [status, file] = line.split("\t");
28
-
29
- if (!file) continue;
30
-
31
- if (status === "A") {
32
- added.push(file);
33
- } else if (status === "D") {
34
- removed.push(file);
35
- } else {
36
- modified.push(file);
37
- }
56
+ const classified = classifyDiffLine(line);
57
+ if (!classified) continue;
58
+ if (classified.bucket === "added") added.push(classified.file);
59
+ else if (classified.bucket === "removed") removed.push(classified.file);
60
+ else modified.push(classified.file);
38
61
  }
39
62
 
40
63
  return {
package/src/core/scan.js CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { info, warn } from "../utils/logger.js";
5
5
  import { trackScan } from "../utils/telemetry.js";
6
6
  import { detectMonorepo } from "../analyzers/monorepo-detector.js";
7
+ import { isInsideRoot } from "../utils/safe-path.js";
7
8
 
8
9
  const norm = (p) => p.replace(/\\/g, "/");
9
10
 
@@ -71,8 +72,20 @@ function isNextPage(file) {
71
72
  return false;
72
73
  }
73
74
 
75
+ const MAX_SOURCE_BYTES = 1_000_000;
76
+
74
77
  async function readFileSafe(file) {
75
78
  try {
79
+ const stat = await fs.stat(file);
80
+ if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return "";
81
+ const fh = await fs.open(file, "r");
82
+ try {
83
+ const sample = Buffer.alloc(Math.min(8192, stat.size));
84
+ await fh.read(sample, 0, sample.length, 0);
85
+ if (sample.includes(0)) return "";
86
+ } finally {
87
+ await fh.close();
88
+ }
76
89
  return await fs.readFile(file, "utf8");
77
90
  } catch {
78
91
  return "";
@@ -466,11 +479,18 @@ function isValidRoutePath(p) {
466
479
  export async function scanRepo(cfg) {
467
480
  const repoRoot = cfg.__repoRoot;
468
481
 
469
- const files = await fg(cfg.scan.include, {
470
- cwd: repoRoot,
482
+ const rootResolved = path.resolve(repoRoot);
483
+ const files = (await fg(cfg.scan.include, {
484
+ cwd: rootResolved,
471
485
  ignore: cfg.scan.ignore,
472
- onlyFiles: true
473
- });
486
+ onlyFiles: true,
487
+ followSymbolicLinks: false,
488
+ absolute: false
489
+ })).filter((file) => isInsideRoot(rootResolved, path.resolve(rootResolved, file)));
490
+
491
+ if (files.length === 0) {
492
+ warn("No files matched the scan patterns. Documentation will be mostly empty. Check scan.include in .repolens.yml.");
493
+ }
474
494
 
475
495
  // Performance guardrails
476
496
  if (files.length > MAX_FILES_LIMIT) {
@@ -1,6 +1,7 @@
1
- import { execSync } from "node:child_process";
2
1
  import { log } from "../utils/logger.js";
3
2
  import { fetchWithRetry } from "../utils/retry.js";
3
+ import { assertGitHubRepo } from "../utils/github-repo.js";
4
+ import { sanitizeSecrets } from "../utils/secrets.js";
4
5
 
5
6
  function safe(text) {
6
7
  return String(text || "").trim();
@@ -72,7 +73,8 @@ async function githubRequest(method, url, body) {
72
73
 
73
74
  if (!res.ok) {
74
75
  const text = await res.text();
75
- throw new Error(`GitHub API error ${res.status}: ${text}`);
76
+ const clipped = sanitizeSecrets(String(text || "").replace(/\s+/g, " ").slice(0, 500));
77
+ throw new Error(`GitHub API error ${res.status}: ${clipped}`);
76
78
  }
77
79
 
78
80
  return await res.json();
@@ -114,6 +116,7 @@ export async function upsertPrComment(diff) {
114
116
  return;
115
117
  }
116
118
 
119
+ assertGitHubRepo(repoFull);
117
120
  const [owner, repo] = repoFull.split("/");
118
121
  const body = buildBody(diff);
119
122
 
@@ -3,13 +3,14 @@
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { info } from "../utils/logger.js";
6
+ import { resolveOutputDir, safeFileName } from "../utils/safe-path.js";
6
7
 
7
8
  export async function writeDocumentSet(docSet, targetDir = process.cwd()) {
8
9
  const { documents, artifacts, config } = docSet;
9
10
 
10
- // Determine output directory
11
- const outputDir = path.join(
12
- targetDir,
11
+ // Determine output directory. Reject absolute paths and `..` escapes.
12
+ const outputDir = resolveOutputDir(
13
+ targetDir,
13
14
  config.documentation?.output_dir || ".repolens"
14
15
  );
15
16
 
@@ -20,9 +21,10 @@ export async function writeDocumentSet(docSet, targetDir = process.cwd()) {
20
21
 
21
22
  // Write each document
22
23
  for (const doc of documents) {
23
- const filePath = path.join(outputDir, doc.filename);
24
+ const fileName = safeFileName(doc.filename);
25
+ const filePath = path.join(outputDir, fileName);
24
26
  await fs.writeFile(filePath, doc.content, "utf8");
25
- info(`✓ Wrote ${doc.filename}`);
27
+ info(`✓ Wrote ${fileName}`);
26
28
  }
27
29
 
28
30
  // Write artifacts if enabled
@@ -59,12 +61,12 @@ export async function writeDocumentSet(docSet, targetDir = process.cwd()) {
59
61
  return {
60
62
  outputDir,
61
63
  documentCount: documents.length,
62
- files: documents.map(d => d.filename)
64
+ files: documents.map(d => safeFileName(d.filename))
63
65
  };
64
66
  }
65
67
 
66
68
  export async function readPreviousDocumentSet(targetDir = process.cwd(), config) {
67
- const outputDir = path.join(
69
+ const outputDir = resolveOutputDir(
68
70
  targetDir,
69
71
  config.documentation?.output_dir || ".repolens"
70
72
  );
package/src/doctor.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 { loadConfig } from "./core/config.js";
5
5
  import { detectConfigTypos } from "./core/config-schema.js";
6
6
  import { info } from "./utils/logger.js";
@@ -118,7 +118,7 @@ export async function runDoctor(targetDir = process.cwd()) {
118
118
  let rawConfig = null;
119
119
  try {
120
120
  const rawYaml = await fs.readFile(repolensConfigPath, "utf8");
121
- rawConfig = yaml.load(rawYaml);
121
+ rawConfig = loadYaml(rawYaml);
122
122
 
123
123
  // Check for config key typos before validation
124
124
  if (rawConfig && typeof rawConfig === "object") {
package/src/help.js ADDED
@@ -0,0 +1,54 @@
1
+ export function printHelp() {
2
+ console.log(`
3
+ RepoLens — Repository Intelligence CLI
4
+
5
+ Usage:
6
+ repolens <command> [options]
7
+
8
+ Commands:
9
+ init Scaffold RepoLens files in your repository
10
+ doctor Validate your RepoLens setup
11
+ migrate Upgrade workflow files to current format
12
+ publish Scan, render, and publish documentation
13
+ demo Generate local docs without API keys (quick preview)
14
+ watch Watch for file changes and regenerate docs
15
+ uninstall Remove RepoLens-generated files from a repository
16
+ feedback Send feedback to the RepoLens team
17
+ version Print the current RepoLens version
18
+ help Show this help message
19
+
20
+ Options:
21
+ --config <path> Path to .repolens.yml (auto-discovered if omitted)
22
+ --target <path> Repository path for init, doctor, migrate, demo, uninstall
23
+ --quick Scaffold without the wizard (also --non-interactive)
24
+ --dry-run Preview migration changes without writing them
25
+ --force Apply migrate or uninstall without a confirmation prompt
26
+ --verbose Print debug logs and full stacks on unexpected errors
27
+ --version, -v Print version and exit
28
+ --help, -h Show this help message
29
+
30
+ Non-interactive sessions (CI, pipes, or REPOLENS_NON_INTERACTIVE):
31
+ init falls back to --quick instead of waiting on the wizard
32
+ yes/no prompts use their default and do not wait (uninstall defaults to no
33
+ on a closed terminal outside CI; CI still removes files and tells you so)
34
+ feedback exits with code 2 because it needs a terminal
35
+
36
+ Exit codes:
37
+ 0 success (including a cancelled wizard)
38
+ 1 runtime error, or an unknown command
39
+ 2 invalid flags, missing config, or a failed doctor check
40
+
41
+ Examples:
42
+ repolens init
43
+ repolens init --quick
44
+ repolens init --target /tmp/my-repo
45
+ repolens doctor --target /tmp/my-repo
46
+ repolens migrate --dry-run
47
+ repolens publish
48
+ repolens publish --config /path/.repolens.yml
49
+ repolens demo
50
+ repolens watch
51
+ repolens uninstall --target /tmp/my-repo
52
+ repolens --version
53
+ `);
54
+ }
package/src/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Programmatic entry for @chappibunny/repolens.
3
+ *
4
+ * Importing this module does not start the CLI. The `repolens` binary
5
+ * lives in bin/repolens.js and calls runCli() from src/cli.js.
6
+ */
7
+
8
+ export { loadConfig } from "./core/config.js";
9
+ export { scanRepo } from "./core/scan.js";
10
+ export { generateDocumentSet } from "./docs/generate-doc-set.js";
11
+ export { writeDocumentSet, readPreviousDocumentSet } from "./docs/write-doc-set.js";
12
+ export { publishDocs } from "./publishers/index.js";
13
+ export { runDoctor } from "./doctor.js";
package/src/init.js CHANGED
@@ -1,8 +1,9 @@
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
8
  const PUBLISHER_CHOICES = ["markdown", "notion", "confluence", "github_wiki"];
8
9
 
@@ -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:
@@ -461,6 +468,8 @@ For automated publishing on every push:
461
468
 
462
469
  3. Push to main branch
463
470
 
471
+ 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.
472
+
464
473
  ## 📊 Generated Documentation
465
474
 
466
475
  RepoLens generates documentation in two modes:
@@ -564,24 +573,27 @@ async function dirExists(dirPath) {
564
573
  * Open a URL in the default browser (cross-platform).
565
574
  */
566
575
  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}"`;
576
+ let parsed;
577
+ try {
578
+ parsed = new URL(url);
579
+ } catch {
580
+ return Promise.resolve(false);
575
581
  }
582
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
583
+ return Promise.resolve(false);
584
+ }
585
+
586
+ const platform = process.platform;
587
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "rundll32" : "xdg-open";
588
+ const args = platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
589
+
576
590
  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
- }
591
+ const child = spawn(cmd, args, { stdio: "ignore" });
592
+ child.on("error", (err) => {
593
+ warn(`Could not open browser: ${err.message}`);
594
+ resolve(false);
584
595
  });
596
+ child.on("close", (code) => resolve(code === 0));
585
597
  });
586
598
  }
587
599
 
@@ -844,23 +856,10 @@ function detectProjectName(repoRoot) {
844
856
  }
845
857
 
846
858
  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
859
+ if (isNonInteractiveEnvironment()) {
860
+ if (process.env.NODE_ENV !== "test" && !process.env.VITEST) {
861
+ info("⏭️ Skipping interactive prompts (non-interactive environment)");
862
+ }
864
863
  return null;
865
864
  }
866
865
 
@@ -943,11 +942,6 @@ async function ensureEnvInGitignore(repoRoot) {
943
942
  * Returns a structured config that replaces the auto-detected defaults.
944
943
  */
945
944
  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
945
  const rl = createInterface({ input: process.stdin, output: process.stdout });
952
946
  const ask = (q) => rl.question(q);
953
947
 
@@ -1515,14 +1509,17 @@ export async function runInit(targetDir = process.cwd(), options = {}) {
1515
1509
  // Ensure target directory exists
1516
1510
  await fs.mkdir(repoRoot, { recursive: true });
1517
1511
 
1518
- // Interactive wizard is now the default (--quick skips it)
1512
+ // Interactive wizard is the default (--quick skips it).
1513
+ // CI, tests, and closed stdin used to return null from the wizard and
1514
+ // abort before any files were written. Fall through to quick setup instead.
1519
1515
  let wizardAnswers = null;
1520
- if (options.interactive) {
1516
+ if (options.interactive && !isNonInteractiveEnvironment()) {
1521
1517
  wizardAnswers = await runInteractiveWizard(repoRoot);
1522
1518
  if (!wizardAnswers) {
1523
- // User cancelled the wizard
1524
1519
  return;
1525
1520
  }
1521
+ } else if (options.interactive) {
1522
+ info("Non-interactive environment detected — running quick setup instead of the wizard.");
1526
1523
  }
1527
1524
 
1528
1525
  // 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