@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/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import "dotenv/config";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { fileURLToPath } from "node:url";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { loadConfig } from "./core/config.js";
7
7
  import { scanRepo } from "./core/scan.js";
@@ -29,6 +29,9 @@ import { generateDocumentSet } from "./docs/generate-doc-set.js";
29
29
  import { writeDocumentSet } from "./docs/write-doc-set.js";
30
30
  import { loadPlugins } from "./plugins/loader.js";
31
31
  import { PluginManager } from "./plugins/manager.js";
32
+ import { printHelp } from "./help.js";
33
+ import { parseCliArgs } from "./cli-args.js";
34
+ import { isNonInteractiveEnvironment } from "./utils/interactive.js";
32
35
  import {
33
36
  initTelemetry,
34
37
  captureError,
@@ -219,56 +222,33 @@ async function findConfig(startDir = process.cwd()) {
219
222
  }
220
223
  }
221
224
 
222
- function printHelp() {
223
- console.log(`
224
- RepoLens — Repository Intelligence CLI
225
-
226
- Usage:
227
- repolens <command> [options]
228
-
229
- Commands:
230
- init Scaffold RepoLens files in your repository
231
- doctor Validate your RepoLens setup
232
- migrate Upgrade workflow files to current format
233
- publish Scan, render, and publish documentation
234
- demo Generate local docs without API keys (quick preview)
235
- watch Watch for file changes and regenerate docs
236
- uninstall Remove all RepoLens-generated files from your repository
237
- feedback Send feedback to the RepoLens team
238
- version Print the current RepoLens version
239
-
240
- Options:
241
- --config Path to .repolens.yml (auto-discovered if not provided)
242
- --target Target repository path for init/doctor/migrate
243
- --quick Run init with minimal prompts (skip wizard)
244
- --dry-run Preview migration changes without applying them
245
- --force Skip interactive confirmation for migration
246
- --verbose Enable verbose logging
247
- --version Print version
248
- --help Show this help message
249
-
250
- Examples:
251
- repolens init # Full interactive wizard (recommended)
252
- repolens init --quick # Minimal setup, skip wizard
253
- repolens init --target /tmp/my-repo
254
- repolens doctor --target /tmp/my-repo
255
- repolens migrate # Migrate workflows in current directory
256
- repolens migrate --dry-run # Preview changes without applying
257
- repolens publish # Auto-discovers .repolens.yml
258
- repolens publish --config /path/.repolens.yml # Explicit config path
259
- repolens demo # Quick local preview (no API keys)
260
- repolens watch # Watch mode (Markdown only)
261
- repolens uninstall # Remove all RepoLens files
262
- repolens uninstall --target /tmp/my-repo # Uninstall from specific repo
263
- repolens --version
264
- `);
265
- }
266
-
267
225
  async function main() {
268
- const command = process.argv[2];
226
+ const parsed = parseCliArgs(process.argv.slice(2));
227
+
228
+ // Help and version return before telemetry and the update check.
229
+ // bin/repolens.js answers --version/--help without loading this module.
230
+ if (parsed.help) {
231
+ printHelp();
232
+ return;
233
+ }
269
234
 
270
- // Initialize telemetry (opt-in via REPOLENS_TELEMETRY_ENABLED=true)
271
- initTelemetry();
235
+ if (parsed.version) {
236
+ const version = await getPackageVersion();
237
+ console.log(version);
238
+ return;
239
+ }
240
+
241
+ if (parsed.errors.length > 0) {
242
+ for (const message of parsed.errors) error(message);
243
+ error("Run 'repolens help' for usage information.");
244
+ process.exit(EXIT_VALIDATION);
245
+ }
246
+
247
+ const command = parsed.command;
248
+
249
+ // Initialize telemetry (opt-in via REPOLENS_TELEMETRY_ENABLED=true).
250
+ // @sentry/node is optional; init is a no-op when it is not installed.
251
+ await initTelemetry();
272
252
  setContext("cli", {
273
253
  command: command || "publish",
274
254
  args: process.argv.slice(2),
@@ -279,17 +259,6 @@ async function main() {
279
259
  // Check for updates (non-blocking, runs in background)
280
260
  checkForUpdates().catch(() => {/* Silently fail */});
281
261
 
282
- if (command === "--help" || command === "-h" || command === "help") {
283
- printHelp();
284
- return;
285
- }
286
-
287
- if (command === "--version" || command === "-v" || command === "version") {
288
- const version = await getPackageVersion();
289
- console.log(version);
290
- return;
291
- }
292
-
293
262
  if (command === "init") {
294
263
  await printBanner();
295
264
  const targetDir = getArg("--target") || process.cwd();
@@ -392,13 +361,6 @@ async function main() {
392
361
  return;
393
362
  }
394
363
 
395
- // Reject unknown flags/commands before falling through to publish
396
- if (command && command.startsWith("--") && !["--help", "-h", "--version", "-v"].includes(command)) {
397
- error(`Unknown option: ${command}`);
398
- error("Run 'repolens help' for usage information.");
399
- process.exit(EXIT_ERROR);
400
- }
401
-
402
364
  if (command === "publish" || !command) {
403
365
  await printBanner();
404
366
 
@@ -461,7 +423,7 @@ async function main() {
461
423
  process.exit(EXIT_ERROR);
462
424
  }
463
425
 
464
- const rawDiff = getGitDiff("origin/main");
426
+ const rawDiff = getGitDiff("origin/main", cfg.__repoRoot || process.cwd());
465
427
  const diffData = buildArchitectureDiffData(rawDiff);
466
428
 
467
429
  try {
@@ -534,10 +496,12 @@ async function main() {
534
496
  const commandTimer = startTimer("demo");
535
497
  const targetDir = getArg("--target") || process.cwd();
536
498
 
537
- // Try to load existing config, otherwise use sensible defaults
499
+ // Try to load existing config, otherwise use sensible defaults.
500
+ // Start at --target so a demo of another directory does not pick up
501
+ // the config in the shell's current directory.
538
502
  let cfg;
539
503
  try {
540
- const configPath = getArg("--config") || await findConfig();
504
+ const configPath = getArg("--config") || await findConfig(targetDir);
541
505
  info(`Using config: ${configPath}`);
542
506
  cfg = await loadConfig(configPath);
543
507
  } catch {
@@ -580,7 +544,7 @@ async function main() {
580
544
  stopTimer(scanTimer);
581
545
  info(`Detected ${scan.modules?.length || 0} modules, ${scan.filesCount || 0} files`);
582
546
 
583
- const rawDiff = getGitDiff("origin/main");
547
+ const rawDiff = getGitDiff("origin/main", cfg.__repoRoot || targetDir);
584
548
 
585
549
  info("Generating documentation set...");
586
550
  const renderTimer = startTimer("render");
@@ -675,7 +639,7 @@ async function main() {
675
639
  const isTest = process.env.NODE_ENV === "test" || process.env.VITEST;
676
640
 
677
641
  if (isCI || isTest) {
678
- info("\nCI/test environment detected — skipping confirmation (use --force to suppress this message).");
642
+ info("\nNon-interactive environment: removing RepoLens files without a prompt. Pass --force to hide this notice.");
679
643
  } else {
680
644
  const confirmed = await promptYesNo(`\nRemove ${found.length} item${found.length === 1 ? "" : "s"}? This cannot be undone.`, false);
681
645
 
@@ -717,6 +681,11 @@ async function main() {
717
681
 
718
682
  if (command === "feedback") {
719
683
  await printBanner();
684
+ if (isNonInteractiveEnvironment()) {
685
+ error("feedback needs an interactive terminal.");
686
+ await closeTelemetry();
687
+ process.exit(EXIT_VALIDATION);
688
+ }
720
689
  info("Send feedback to the RepoLens team");
721
690
  info("Your feedback is anonymous and helps us improve RepoLens.\n");
722
691
 
@@ -754,6 +723,8 @@ async function main() {
754
723
  captureError(err, { command: "feedback" });
755
724
  error("Failed to send feedback:");
756
725
  error(err.message);
726
+ await closeTelemetry();
727
+ process.exit(EXIT_ERROR);
757
728
  }
758
729
 
759
730
  await closeTelemetry();
@@ -765,32 +736,41 @@ async function main() {
765
736
  process.exit(EXIT_ERROR);
766
737
  }
767
738
 
768
- main().catch(async (err) => {
769
- // Capture unexpected errors
770
- captureError(err, {
771
- type: "uncaught",
772
- command: process.argv[2] || "unknown"
739
+ export function runCli() {
740
+ return main().catch(async (err) => {
741
+ // Capture unexpected errors
742
+ captureError(err, {
743
+ type: "uncaught",
744
+ command: process.argv[2] || "unknown"
745
+ });
746
+
747
+ console.error("\n❌ RepoLens encountered an unexpected error:\n");
748
+
749
+ if (err.code === "ENOENT") {
750
+ error(formatError("FILE_NOT_FOUND", err.path));
751
+ } else if (err.code === "EACCES") {
752
+ error(formatError("FILE_PERMISSION_DENIED", err.path));
753
+ } else if (err.message) {
754
+ error(err.message);
755
+ } else {
756
+ error(err);
757
+ }
758
+
759
+ if (process.env.VERBOSE || process.argv.includes("--verbose")) {
760
+ console.error("\nStack trace:");
761
+ console.error(err.stack);
762
+ } else {
763
+ console.error("\nRun with --verbose for full error details.");
764
+ }
765
+
766
+ await closeTelemetry();
767
+ process.exit(EXIT_ERROR);
773
768
  });
774
-
775
- console.error("\n❌ RepoLens encountered an unexpected error:\n");
776
-
777
- if (err.code === "ENOENT") {
778
- error(formatError("FILE_NOT_FOUND", err.path));
779
- } else if (err.code === "EACCES") {
780
- error(formatError("FILE_PERMISSION_DENIED", err.path));
781
- } else if (err.message) {
782
- error(err.message);
783
- } else {
784
- error(err);
785
- }
786
-
787
- if (process.env.VERBOSE || process.argv.includes("--verbose")) {
788
- console.error("\nStack trace:");
789
- console.error(err.stack);
790
- } else {
791
- console.error("\nRun with --verbose for full error details.");
792
- }
793
-
794
- await closeTelemetry();
795
- process.exit(EXIT_ERROR);
796
- });
769
+ }
770
+
771
+ // Run only when this file is the process entry (node src/cli.js).
772
+ // bin/repolens.js imports runCli() explicitly so a package import does not start the CLI.
773
+ const entryArg = process.argv[1];
774
+ if (entryArg && pathToFileURL(path.resolve(entryArg)).href === import.meta.url) {
775
+ runCli();
776
+ }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  const CURRENT_SCHEMA_VERSION = 1;
11
- const SUPPORTED_PUBLISHERS = ["notion", "markdown", "confluence", "github_wiki"];
11
+ const SUPPORTED_PUBLISHERS = ["notion", "markdown", "confluence", "github_wiki", "obsidian"];
12
12
  const SUPPORTED_PAGE_KEYS = [
13
13
  "system_overview",
14
14
  "module_catalog",
@@ -218,6 +218,51 @@ export function validateConfig(config) {
218
218
  }
219
219
  }
220
220
 
221
+ // Validate Obsidian configuration (optional, local vault — no network)
222
+ if (config.obsidian !== undefined) {
223
+ if (typeof config.obsidian !== "object" || Array.isArray(config.obsidian) || config.obsidian === null) {
224
+ errors.push("obsidian must be an object");
225
+ } else {
226
+ if (config.obsidian.vault !== undefined) {
227
+ if (typeof config.obsidian.vault !== "string" || config.obsidian.vault.trim() === "") {
228
+ errors.push("obsidian.vault must be a non-empty string");
229
+ } else if (config.obsidian.vault.includes("\0")) {
230
+ errors.push("obsidian.vault contains a null byte");
231
+ }
232
+ }
233
+ if (config.obsidian.folder !== undefined) {
234
+ if (typeof config.obsidian.folder !== "string" || config.obsidian.folder.trim() === "") {
235
+ errors.push("obsidian.folder must be a non-empty string");
236
+ } else if (config.obsidian.folder.includes("\0")) {
237
+ errors.push("obsidian.folder contains a null byte");
238
+ } else if (
239
+ config.obsidian.folder.startsWith("/") ||
240
+ config.obsidian.folder.startsWith("\\") ||
241
+ /^[A-Za-z]:[\\/]/.test(config.obsidian.folder) ||
242
+ config.obsidian.folder.split(/[\\/]/).some((part) => part === "..")
243
+ ) {
244
+ errors.push("obsidian.folder must be a relative path inside the vault");
245
+ }
246
+ }
247
+ if (config.obsidian.branches !== undefined) {
248
+ if (!Array.isArray(config.obsidian.branches)) {
249
+ errors.push("obsidian.branches must be an array");
250
+ } else {
251
+ config.obsidian.branches.forEach((branch, idx) => {
252
+ if (typeof branch !== "string") {
253
+ errors.push(`obsidian.branches[${idx}] must be a string`);
254
+ }
255
+ });
256
+ }
257
+ }
258
+ for (const flag of ["index", "frontmatter", "wikilinks"]) {
259
+ if (config.obsidian[flag] !== undefined && typeof config.obsidian[flag] !== "boolean") {
260
+ errors.push(`obsidian.${flag} must be a boolean`);
261
+ }
262
+ }
263
+ }
264
+ }
265
+
221
266
  // Validate Discord configuration (optional)
222
267
  if (config.discord !== undefined) {
223
268
  if (typeof config.discord !== "object" || Array.isArray(config.discord)) {
@@ -379,6 +424,7 @@ const VALID_CONFIG_KEYS = [
379
424
  "notion",
380
425
  "confluence",
381
426
  "github_wiki",
427
+ "obsidian",
382
428
  "discord",
383
429
  "features",
384
430
  "ai",
@@ -1,14 +1,17 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import yaml from "js-yaml";
4
3
  import { validateConfig as validateSchema } from "./config-schema.js";
5
4
  import { validateConfig } from "../utils/validate.js";
6
5
  import { warn, error } from "../utils/logger.js";
6
+ import { loadYaml } from "../utils/yaml.js";
7
7
 
8
8
  export async function loadConfig(configPath) {
9
9
  const absoluteConfigPath = path.resolve(configPath);
10
10
  const raw = await fs.readFile(absoluteConfigPath, "utf8");
11
- const cfg = yaml.load(raw);
11
+ const cfg = loadYaml(raw);
12
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) {
13
+ throw new Error("Configuration must be a YAML mapping");
14
+ }
12
15
 
13
16
  // Validate config against schema (structure)
14
17
  validateSchema(cfg);
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") {
@@ -204,6 +204,14 @@ export async function runDoctor(targetDir = process.cwd()) {
204
204
  );
205
205
  }
206
206
 
207
+ if (cfg.publishers.includes("obsidian")) {
208
+ if (typeof cfg.obsidian?.vault === "string" && cfg.obsidian.vault.trim() !== "") {
209
+ ok(`Obsidian vault configured (${cfg.obsidian.vault}) — local publisher, no credentials`);
210
+ } else {
211
+ warn("obsidian.vault is not set — required to publish into an Obsidian vault");
212
+ }
213
+ }
214
+
207
215
  if (cfg.ai?.enabled || process.env.REPOLENS_AI_ENABLED === "true") {
208
216
  const aiProvider = process.env.REPOLENS_AI_PROVIDER || "openai_compatible";
209
217
  if (aiProvider === "github") {
@@ -218,7 +226,12 @@ export async function runDoctor(targetDir = process.cwd()) {
218
226
  }
219
227
 
220
228
  if (envChecks.length === 0) {
221
- ok("No publisher-specific env vars required (Markdown only)");
229
+ const localPublishers = [];
230
+ if (cfg.publishers.includes("markdown")) localPublishers.push("Markdown");
231
+ if (cfg.publishers.includes("obsidian")) localPublishers.push("Obsidian");
232
+ ok(localPublishers.length > 0
233
+ ? `No publisher-specific env vars required (${localPublishers.join(" and ")} only)`
234
+ : "No publisher-specific env vars required");
222
235
  }
223
236
 
224
237
  for (const check of envChecks) {
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";