@chappibunny/repolens 0.6.4 → 0.7.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 CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to RepoLens will be documented in this file.
4
4
 
5
+ ## 0.7.0
6
+
7
+ ### ✨ New Features
8
+ - **Interactive Init Wizard**: `repolens init --interactive` — step-by-step configuration wizard with scan presets (Next.js, Express, generic), publisher selection, AI provider setup, and branch filtering
9
+ - **Watch Mode**: `repolens watch` — watches source directories for changes and regenerates Markdown docs with 500ms debounce (no API calls)
10
+ - **Enhanced Error Messages**: Centralized error catalog with actionable guidance — every error now shows what went wrong, why, and how to fix it
11
+ - **Performance Monitoring**: Scan, render, and publish timing summary printed after every `publish` run
12
+ - **Coverage Scoring Improvements**: New section completeness metric (12 document types tracked), updated health score weights, and `metrics.json` snapshots saved to `.repolens/`
13
+
5
14
  ## 0.6.4
6
15
 
7
16
  ### 🔧 Maintenance
package/README.md CHANGED
@@ -1,7 +1,3 @@
1
- <p align="center">
2
- <img src="Avatar.png" alt="RepoLens" width="120" />
3
- </p>
4
-
5
1
  ```
6
2
  ██████╗ ███████╗██████╗ ██████╗ ██╗ ███████╗███╗ ██╗███████╗
7
3
  ██╔══██╗██╔════╝██╔══██╗██╔═══██╗██║ ██╔════╝████╗ ██║██╔════╝
@@ -9,7 +5,7 @@
9
5
  ██╔══██╗██╔══╝ ██╔═══╝ ██║ ██║██║ ██╔══╝ ██║╚██╗██║╚════██║
10
6
  ██║ ██║███████╗██║ ╚██████╔╝███████╗███████╗██║ ╚████║███████║
11
7
  ╚═╝ ╚═╝╚══════╝╚═╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═══╝╚══════╝
12
- 🔍 Repository Intelligence CLI 📊
8
+ Repository Intelligence CLI
13
9
  ```
14
10
 
15
11
  [![Tests](https://img.shields.io/badge/tests-90%20passing-brightgreen)](https://github.com/CHAPIBUNNY/repolens/actions)
@@ -20,7 +16,7 @@
20
16
 
21
17
  AI-assisted documentation intelligence system that generates architecture docs for engineers AND readable system docs for stakeholders
22
18
 
23
- **Current Status**: v0.6.4User Feedback & Team Features
19
+ **Current Status**: v0.7.0Polish & Reliability
24
20
 
25
21
  RepoLens automatically generates and maintains living architecture documentation by analyzing your repository structure, extracting meaningful insights from your package.json, and creating visual dependency graphs. Run it once, or let it auto-update on every push.
26
22
 
@@ -132,7 +128,11 @@ RepoLens automatically detects:
132
128
  ✅ **Branch-Aware** - Prevent doc conflicts across branches
133
129
  ✅ **GitHub Actions** - Autonomous operation on every push
134
130
  ✅ **Team Notifications** - Discord integration with rich embeds (NEW in v0.6.0)
135
- ✅ **Health Score Tracking** - Monitor documentation quality over time (NEW in v0.6.0)
131
+ ✅ **Health Score Tracking** - Monitor documentation quality over time
132
+ ✅ **Watch Mode** - Auto-regenerate docs on file changes (NEW in v0.7.0)
133
+ ✅ **Interactive Setup** - Step-by-step configuration wizard (NEW in v0.7.0)
134
+ ✅ **Performance Metrics** - Timing breakdown for scan/render/publish (NEW in v0.7.0)
135
+ ✅ **Actionable Errors** - Enhanced error messages with fix guidance (NEW in v0.7.0)
136
136
 
137
137
  ---
138
138
 
@@ -224,7 +224,7 @@ npm link
224
224
  Install from a specific version:
225
225
 
226
226
  ```bash
227
- npm install https://github.com/CHAPIBUNNY/repolens/releases/download/v0.6.4/chappibunny-repolens-0.6.4.tgz
227
+ npm install https://github.com/CHAPIBUNNY/repolens/releases/download/v0.7.0/chappibunny-repolens-0.7.0.tgz
228
228
  ```
229
229
  </details>
230
230
 
@@ -1077,7 +1077,7 @@ Simulates the full user installation experience:
1077
1077
  npm pack
1078
1078
 
1079
1079
  # Install globally from tarball
1080
- npm install -g chappibunny-repolens-0.6.4.tgz
1080
+ npm install -g chappibunny-repolens-0.7.0.tgz
1081
1081
 
1082
1082
  # Verify
1083
1083
  repolens --version
@@ -1091,9 +1091,10 @@ repolens/
1091
1091
  │ └── repolens.js # CLI executable wrapper
1092
1092
  ├── src/
1093
1093
  │ ├── cli.js # Command orchestration + banner
1094
- │ ├── init.js # Scaffolding command
1094
+ │ ├── init.js # Scaffolding command (+ interactive wizard)
1095
1095
  │ ├── doctor.js # Validation command
1096
1096
  │ ├── migrate.js # Workflow migration (legacy → current)
1097
+ │ ├── watch.js # Watch mode for local development
1097
1098
  │ ├── core/
1098
1099
  │ │ ├── config.js # Config loading + validation
1099
1100
  │ │ ├── config-schema.js # Schema version tracking
@@ -1133,7 +1134,8 @@ repolens/
1133
1134
  │ ├── metrics.js # Documentation coverage & health scoring
1134
1135
  │ ├── rate-limit.js # Token bucket rate limiter for APIs
1135
1136
  │ ├── secrets.js # Secret detection & sanitization
1136
- │ ├── telemetry.js # Opt-in error tracking (Sentry)
1137
+ │ ├── telemetry.js # Opt-in error tracking + performance timers
1138
+ │ ├── errors.js # Enhanced error messages with guidance
1137
1139
  │ └── update-check.js # Version update notifications
1138
1140
  ├── tests/ # Vitest test suite (90 tests across 11 files)
1139
1141
  ├── .repolens.yml # Dogfooding config
@@ -1152,13 +1154,13 @@ RepoLens uses automated GitHub Actions releases.
1152
1154
  ### Creating a Release
1153
1155
 
1154
1156
  ```bash
1155
- # Patch version (0.6.4 → 0.6.5) - Bug fixes
1157
+ # Patch version (0.7.0 → 0.7.1) - Bug fixes
1156
1158
  npm run release:patch
1157
1159
 
1158
- # Minor version (0.6.4 → 0.7.0) - New features
1160
+ # Minor version (0.7.0 → 0.8.0) - New features
1159
1161
  npm run release:minor
1160
1162
 
1161
- # Major version (0.6.4 → 1.0.0) - Breaking changes
1163
+ # Major version (0.7.0 → 1.0.0) - Breaking changes
1162
1164
  npm run release:major
1163
1165
 
1164
1166
  # Push the tag to trigger workflow
@@ -1190,11 +1192,11 @@ RepoLens is currently in early access. v1.0 will open for community contribution
1190
1192
 
1191
1193
  ## 🗺️ Roadmap to v1.0
1192
1194
 
1193
- **Current Status:** v0.6.4User Feedback & Team Features
1195
+ **Current Status:** v0.7.0Polish & Reliability
1194
1196
 
1195
1197
  ### Completed ✅
1196
1198
 
1197
- - [x] CLI commands: `init`, `doctor`, `publish`, `migrate`, `feedback`, `version`, `help`
1199
+ - [x] CLI commands: `init`, `doctor`, `publish`, `migrate`, `watch`, `feedback`, `version`, `help`
1198
1200
  - [x] Config schema v1 with validation
1199
1201
  - [x] Auto-discovery of `.repolens.yml`
1200
1202
  - [x] Publishers: Notion + Confluence + Markdown
@@ -1214,14 +1216,17 @@ RepoLens is currently in early access. v1.0 will open for community contribution
1214
1216
  - [x] npm registry publication (`@chappibunny/repolens`)
1215
1217
  - [x] Automated npm releases via GitHub Actions
1216
1218
  - [x] Workflow migration command (`repolens migrate`)
1219
+ - [x] Interactive configuration wizard (`repolens init --interactive`)
1220
+ - [x] Watch mode for local development (`repolens watch`)
1221
+ - [x] Enhanced error messages with actionable guidance
1222
+ - [x] Performance monitoring (scan/render/publish timing)
1223
+ - [x] Improved documentation coverage scoring
1217
1224
 
1218
1225
  ### Planned for v1.0 🎯
1219
1226
 
1220
1227
  - [ ] Plugin system for custom renderers
1221
1228
  - [ ] GraphQL schema detection
1222
1229
  - [ ] TypeScript type graph analysis
1223
- - [ ] Interactive configuration wizard
1224
- - [ ] Watch mode for local development
1225
1230
  - [ ] Additional publishers (GitHub Wiki, Obsidian)
1226
1231
 
1227
1232
  See [ROADMAP.md](./ROADMAP.md) for detailed planning.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chappibunny/repolens",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "description": "AI-assisted documentation intelligence system for technical and non-technical audiences",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.js CHANGED
@@ -20,7 +20,9 @@ import { publishDocs } from "./publishers/index.js";
20
20
  import { upsertPrComment } from "./delivery/comment.js";
21
21
  import { runInit } from "./init.js";
22
22
  import { runMigrate } from "./migrate.js";
23
+ import { runWatch } from "./watch.js";
23
24
  import { info, error } from "./utils/logger.js";
25
+ import { formatError } from "./utils/errors.js";
24
26
  import { checkForUpdates } from "./utils/update-check.js";
25
27
  import { generateDocumentSet } from "./docs/generate-doc-set.js";
26
28
  import { writeDocumentSet } from "./docs/write-doc-set.js";
@@ -32,10 +34,34 @@ import {
32
34
  trackUsage,
33
35
  startTimer,
34
36
  stopTimer,
35
- sendFeedback
37
+ sendFeedback,
38
+ getTimings
36
39
  } from "./utils/telemetry.js";
37
40
  import { createInterface } from "node:readline";
38
41
 
42
+ function formatDuration(ms) {
43
+ if (ms < 1000) return `${ms}ms`;
44
+ return `${(ms / 1000).toFixed(1)}s`;
45
+ }
46
+
47
+ function printPerformanceSummary() {
48
+ const timings = getTimings();
49
+ if (timings.length === 0) return;
50
+
51
+ console.log("\n" + "─".repeat(40));
52
+ console.log(" Phase Duration");
53
+ console.log(" " + "─".repeat(30));
54
+ let total = 0;
55
+ for (const { operation, duration } of timings) {
56
+ const label = operation.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
57
+ console.log(` ${label.padEnd(16)} ${formatDuration(duration)}`);
58
+ total += duration;
59
+ }
60
+ console.log(" " + "─".repeat(30));
61
+ console.log(` ${"Total".padEnd(16)} ${formatDuration(total)}`);
62
+ console.log("─".repeat(40));
63
+ }
64
+
39
65
  async function getPackageVersion() {
40
66
  const __filename = fileURLToPath(import.meta.url);
41
67
  const __dirname = path.dirname(__filename);
@@ -93,8 +119,7 @@ async function findConfig(startDir = process.cwd()) {
93
119
  return rootConfigPath;
94
120
  } catch {
95
121
  throw new Error(
96
- "RepoLens config not found (.repolens.yml)\n" +
97
- "Run 'repolens init' to create one, or use --config to specify a path."
122
+ formatError("CONFIG_NOT_FOUND")
98
123
  );
99
124
  }
100
125
  }
@@ -111,25 +136,30 @@ Commands:
111
136
  doctor Validate your RepoLens setup
112
137
  migrate Upgrade workflow files to v0.4.0 format
113
138
  publish Scan, render, and publish documentation
139
+ watch Watch for file changes and regenerate docs
114
140
  feedback Send feedback to the RepoLens team
115
141
  version Print the current RepoLens version
116
142
 
117
143
  Options:
118
- --config Path to .repolens.yml (auto-discovered if not provided)
119
- --target Target repository path for init/doctor/migrate
120
- --dry-run Preview migration changes without applying them
121
- --force Skip interactive confirmation for migration
122
- --verbose Enable verbose logging
123
- --version Print version
124
- --help Show this help message
144
+ --config Path to .repolens.yml (auto-discovered if not provided)
145
+ --target Target repository path for init/doctor/migrate
146
+ --interactive Run init with step-by-step configuration wizard
147
+ --dry-run Preview migration changes without applying them
148
+ --force Skip interactive confirmation for migration
149
+ --verbose Enable verbose logging
150
+ --version Print version
151
+ --help Show this help message
125
152
 
126
153
  Examples:
154
+ repolens init # Quick setup with auto-detection
155
+ repolens init --interactive # Step-by-step wizard
127
156
  repolens init --target /tmp/my-repo
128
157
  repolens doctor --target /tmp/my-repo
129
158
  repolens migrate # Migrate workflows in current directory
130
159
  repolens migrate --dry-run # Preview changes without applying
131
160
  repolens publish # Auto-discovers .repolens.yml
132
161
  repolens publish --config /path/.repolens.yml # Explicit config path
162
+ repolens watch # Watch mode (Markdown only)
133
163
  repolens --version
134
164
  `);
135
165
  }
@@ -163,11 +193,12 @@ async function main() {
163
193
  if (command === "init") {
164
194
  await printBanner();
165
195
  const targetDir = getArg("--target") || process.cwd();
196
+ const interactive = process.argv.includes("--interactive");
166
197
  info(`Initializing RepoLens in: ${targetDir}`);
167
198
 
168
199
  const timer = startTimer("init");
169
200
  try {
170
- await runInit(targetDir);
201
+ await runInit(targetDir, { interactive });
171
202
  const duration = stopTimer(timer);
172
203
  info("✓ RepoLens initialized successfully");
173
204
 
@@ -239,6 +270,20 @@ async function main() {
239
270
  return;
240
271
  }
241
272
 
273
+ if (command === "watch") {
274
+ await printBanner();
275
+ let configPath;
276
+ try {
277
+ configPath = getArg("--config") || await findConfig();
278
+ info(`Using config: ${configPath}`);
279
+ } catch (err) {
280
+ error(err.message);
281
+ process.exit(2);
282
+ }
283
+ await runWatch(configPath);
284
+ return;
285
+ }
286
+
242
287
  if (command === "publish" || !command || command.startsWith("--")) {
243
288
  await printBanner();
244
289
 
@@ -263,8 +308,7 @@ async function main() {
263
308
  stopTimer(commandTimer);
264
309
  captureError(err, { command: "publish", step: "load-config", configPath });
265
310
  trackUsage("publish", "failure", { step: "config-load" });
266
- error("Failed to load configuration:");
267
- error(err.message);
311
+ error(formatError("CONFIG_VALIDATION_FAILED", err));
268
312
  await closeTelemetry();
269
313
  process.exit(2);
270
314
  }
@@ -279,8 +323,9 @@ async function main() {
279
323
  stopTimer(commandTimer);
280
324
  captureError(err, { command: "publish", step: "scan", patterns: cfg.scan?.patterns });
281
325
  trackUsage("publish", "failure", { step: "scan" });
282
- error("Failed to scan repository:");
283
- error(err.message);
326
+ const code = err.message?.includes("No files") ? "SCAN_NO_FILES"
327
+ : err.message?.includes("limit") ? "SCAN_TOO_MANY_FILES" : null;
328
+ error(code ? formatError(code, err) : `Failed to scan repository:\n ${err.message}`);
284
329
  await closeTelemetry();
285
330
  process.exit(1);
286
331
  }
@@ -316,6 +361,8 @@ async function main() {
316
361
 
317
362
  const totalDuration = stopTimer(commandTimer);
318
363
 
364
+ printPerformanceSummary();
365
+
319
366
  // Track successful publish with comprehensive metrics
320
367
  const publishers = [];
321
368
  if (cfg.publishers?.notion?.enabled !== false) publishers.push("notion");
@@ -388,7 +435,7 @@ async function main() {
388
435
  }
389
436
 
390
437
  error(`Unknown command: ${command}`);
391
- error("Available commands: init, doctor, migrate, publish, feedback, version, help");
438
+ error("Available commands: init, doctor, migrate, publish, watch, feedback, version, help");
392
439
  process.exit(1);
393
440
  }
394
441
 
@@ -402,11 +449,9 @@ main().catch(async (err) => {
402
449
  console.error("\n❌ RepoLens encountered an unexpected error:\n");
403
450
 
404
451
  if (err.code === "ENOENT") {
405
- error(`File not found: ${err.path}`);
406
- error("Check that all required files exist and paths are correct.");
452
+ error(formatError("FILE_NOT_FOUND", err.path));
407
453
  } else if (err.code === "EACCES") {
408
- error(`Permission denied: ${err.path}`);
409
- error("Check file permissions and try again.");
454
+ error(formatError("FILE_PERMISSION_DENIED", err.path));
410
455
  } else if (err.message) {
411
456
  error(err.message);
412
457
  } else {
package/src/init.js CHANGED
@@ -3,6 +3,39 @@ import path from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { info, warn } from "./utils/logger.js";
5
5
 
6
+ const PUBLISHER_CHOICES = ["markdown", "notion", "confluence"];
7
+ const AI_PROVIDERS = ["openai", "anthropic", "azure", "ollama"];
8
+ const SCAN_PRESETS = {
9
+ nextjs: {
10
+ include: [
11
+ "src/**/*.{ts,tsx,js,jsx}",
12
+ "app/**/*.{ts,tsx,js,jsx}",
13
+ "pages/**/*.{ts,tsx,js,jsx}",
14
+ "lib/**/*.{ts,tsx,js,jsx}",
15
+ "components/**/*.{ts,tsx,js,jsx}",
16
+ ],
17
+ roots: ["src", "app", "pages", "lib", "components"],
18
+ },
19
+ express: {
20
+ include: [
21
+ "src/**/*.{ts,js}",
22
+ "routes/**/*.{ts,js}",
23
+ "controllers/**/*.{ts,js}",
24
+ "models/**/*.{ts,js}",
25
+ "middleware/**/*.{ts,js}",
26
+ ],
27
+ roots: ["src", "routes", "controllers", "models"],
28
+ },
29
+ generic: {
30
+ include: [
31
+ "src/**/*.{ts,tsx,js,jsx,md}",
32
+ "app/**/*.{ts,tsx,js,jsx,md}",
33
+ "lib/**/*.{ts,tsx,js,jsx,md}",
34
+ ],
35
+ roots: ["src", "app", "lib"],
36
+ },
37
+ };
38
+
6
39
  const DETECTABLE_ROOTS = [
7
40
  "app",
8
41
  "src/app",
@@ -465,14 +498,183 @@ async function ensureEnvInGitignore(repoRoot) {
465
498
  }
466
499
  }
467
500
 
468
- export async function runInit(targetDir = process.cwd()) {
501
+ /**
502
+ * Run a fully interactive configuration wizard.
503
+ * Returns a structured config that replaces the auto-detected defaults.
504
+ */
505
+ async function runInteractiveWizard(repoRoot) {
506
+ const isCI = process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
507
+ process.env.CIRCLECI || process.env.JENKINS_HOME || process.env.CODEBUILD_BUILD_ID;
508
+ const isTest = process.env.NODE_ENV === "test" || process.env.VITEST;
509
+ if (isCI || isTest) return null;
510
+
511
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
512
+ const ask = (q) => rl.question(q);
513
+
514
+ try {
515
+ info("\n🧙 Interactive Configuration Wizard\n");
516
+
517
+ // 1. Project name
518
+ const defaultName = path.basename(repoRoot) || "my-project";
519
+ const projectName = (await ask(`Project name (${defaultName}): `)).trim() || defaultName;
520
+
521
+ // 2. Publishers
522
+ info("\nSelect publishers (comma-separated numbers):");
523
+ PUBLISHER_CHOICES.forEach((p, i) => info(` ${i + 1}. ${p}`));
524
+ const pubInput = (await ask(`Publishers [1,2,3] (default: 1): `)).trim() || "1";
525
+ const publishers = pubInput
526
+ .split(",")
527
+ .map((n) => parseInt(n.trim(), 10))
528
+ .filter((n) => n >= 1 && n <= PUBLISHER_CHOICES.length)
529
+ .map((n) => PUBLISHER_CHOICES[n - 1]);
530
+ if (publishers.length === 0) publishers.push("markdown");
531
+
532
+ // 3. AI
533
+ const enableAi = (await ask("\nEnable AI-enhanced documentation? (y/N): ")).trim().toLowerCase() === "y";
534
+ let aiProvider = null;
535
+ if (enableAi) {
536
+ info("Select AI provider:");
537
+ AI_PROVIDERS.forEach((p, i) => info(` ${i + 1}. ${p}`));
538
+ const aiInput = (await ask(`Provider [1] (default: 1 openai): `)).trim() || "1";
539
+ const idx = parseInt(aiInput, 10);
540
+ aiProvider = AI_PROVIDERS[(idx >= 1 && idx <= AI_PROVIDERS.length) ? idx - 1 : 0];
541
+ }
542
+
543
+ // 4. Scan preset
544
+ info("\nScan preset:");
545
+ const presetKeys = Object.keys(SCAN_PRESETS);
546
+ presetKeys.forEach((p, i) => info(` ${i + 1}. ${p}`));
547
+ const presetInput = (await ask(`Preset [3] (default: 3 generic): `)).trim() || "3";
548
+ const presetIdx = parseInt(presetInput, 10);
549
+ const presetKey = presetKeys[(presetIdx >= 1 && presetIdx <= presetKeys.length) ? presetIdx - 1 : 2];
550
+ const preset = SCAN_PRESETS[presetKey];
551
+
552
+ // 5. Branch filtering
553
+ const branchInput = (await ask("\nBranches allowed to publish to Notion/Confluence (comma-separated, default: main): ")).trim() || "main";
554
+ const branches = branchInput.split(",").map((b) => b.trim()).filter(Boolean);
555
+
556
+ // 6. Discord
557
+ const enableDiscord = (await ask("\nEnable Discord notifications? (y/N): ")).trim().toLowerCase() === "y";
558
+
559
+ info("\n✓ Wizard complete. Generating config...\n");
560
+ return { projectName, publishers, enableAi, aiProvider, preset, branches, enableDiscord };
561
+ } finally {
562
+ rl.close();
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Build a .repolens.yml from wizard answers.
568
+ */
569
+ function buildWizardConfig(answers) {
570
+ const lines = [
571
+ `configVersion: 1`,
572
+ ``,
573
+ `project:`,
574
+ ` name: "${answers.projectName}"`,
575
+ ` docs_title_prefix: "RepoLens"`,
576
+ ``,
577
+ `publishers:`,
578
+ ];
579
+ for (const p of answers.publishers) {
580
+ lines.push(` - ${p}`);
581
+ }
582
+
583
+ if (answers.publishers.includes("notion") && answers.branches.length) {
584
+ lines.push(``);
585
+ lines.push(`notion:`);
586
+ lines.push(` branches:`);
587
+ for (const b of answers.branches) lines.push(` - ${b}`);
588
+ lines.push(` includeBranchInTitle: false`);
589
+ }
590
+ if (answers.publishers.includes("confluence") && answers.branches.length) {
591
+ lines.push(``);
592
+ lines.push(`confluence:`);
593
+ lines.push(` branches:`);
594
+ for (const b of answers.branches) lines.push(` - ${b}`);
595
+ }
596
+
597
+ if (answers.enableDiscord) {
598
+ lines.push(``);
599
+ lines.push(`discord:`);
600
+ lines.push(` enabled: true`);
601
+ lines.push(` notifyOn: significant`);
602
+ lines.push(` significantThreshold: 10`);
603
+ }
604
+
605
+ if (answers.enableAi) {
606
+ lines.push(``);
607
+ lines.push(`ai:`);
608
+ lines.push(` enabled: true`);
609
+ lines.push(` mode: hybrid`);
610
+ lines.push(` temperature: 0.3`);
611
+ lines.push(``);
612
+ lines.push(`features:`);
613
+ lines.push(` executive_summary: true`);
614
+ lines.push(` business_domains: true`);
615
+ lines.push(` architecture_overview: true`);
616
+ lines.push(` data_flows: true`);
617
+ lines.push(` developer_onboarding: true`);
618
+ lines.push(` change_impact: true`);
619
+ }
620
+
621
+ lines.push(``);
622
+ lines.push(`scan:`);
623
+ lines.push(` include:`);
624
+ for (const p of answers.preset.include) lines.push(` - "${p}"`);
625
+ lines.push(``);
626
+ lines.push(` ignore:`);
627
+ for (const p of buildIgnorePatterns()) lines.push(` - "${p}"`);
628
+
629
+ lines.push(``);
630
+ lines.push(`module_roots:`);
631
+ for (const r of answers.preset.roots) lines.push(` - "${r}"`);
632
+
633
+ lines.push(``);
634
+ lines.push(`outputs:`);
635
+ lines.push(` pages:`);
636
+ lines.push(` - key: "system_overview"`);
637
+ lines.push(` title: "System Overview"`);
638
+ lines.push(` description: "High-level snapshot of the repo and what RepoLens detected."`);
639
+ lines.push(``);
640
+ lines.push(` - key: "module_catalog"`);
641
+ lines.push(` title: "Module Catalog"`);
642
+ lines.push(` description: "Auto-detected modules with file counts."`);
643
+ lines.push(``);
644
+ lines.push(` - key: "api_surface"`);
645
+ lines.push(` title: "API Surface"`);
646
+ lines.push(` description: "Auto-detected API routes/endpoints."`);
647
+ lines.push(``);
648
+ lines.push(` - key: "arch_diff"`);
649
+ lines.push(` title: "Architecture Diff"`);
650
+ lines.push(` description: "Reserved for PR/merge change summaries."`);
651
+ lines.push(``);
652
+ lines.push(` - key: "route_map"`);
653
+ lines.push(` title: "Route Map"`);
654
+ lines.push(` description: "Detected app routes and API routes."`);
655
+ lines.push(``);
656
+ lines.push(` - key: "system_map"`);
657
+ lines.push(` title: "System Map"`);
658
+ lines.push(` description: "Unicode architecture diagram of detected modules."`);
659
+ lines.push(``);
660
+
661
+ return lines.join("\n");
662
+ }
663
+
664
+ export async function runInit(targetDir = process.cwd(), options = {}) {
469
665
  const repoRoot = path.resolve(targetDir);
470
666
 
471
667
  // Ensure target directory exists
472
668
  await fs.mkdir(repoRoot, { recursive: true });
473
669
 
474
- // Prompt for Notion credentials interactively
475
- const notionCredentials = await promptNotionCredentials();
670
+ // Interactive wizard if --interactive flag is set
671
+ let wizardAnswers = null;
672
+ if (options.interactive) {
673
+ wizardAnswers = await runInteractiveWizard(repoRoot);
674
+ }
675
+
676
+ // Prompt for Notion credentials interactively (only in non-wizard mode)
677
+ const notionCredentials = wizardAnswers ? null : await promptNotionCredentials();
476
678
 
477
679
  const repolensConfigPath = path.join(repoRoot, ".repolens.yml");
478
680
  const workflowDir = path.join(repoRoot, ".github", "workflows");
@@ -489,9 +691,11 @@ export async function runInit(targetDir = process.cwd()) {
489
691
  const envExists = await fileExists(envPath);
490
692
  const readmeExists = await fileExists(readmePath);
491
693
 
492
- const projectName = detectProjectName(repoRoot);
493
- const detectedRoots = await detectRepoStructure(repoRoot);
494
- const configContent = buildRepoLensConfig(projectName, detectedRoots);
694
+ const projectName = wizardAnswers?.projectName || detectProjectName(repoRoot);
695
+ const detectedRoots = wizardAnswers ? [] : await detectRepoStructure(repoRoot);
696
+ const configContent = wizardAnswers
697
+ ? buildWizardConfig(wizardAnswers)
698
+ : buildRepoLensConfig(projectName, detectedRoots);
495
699
 
496
700
  info(`Detected project name: ${projectName}`);
497
701
 
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { log, info, warn } from "../utils/logger.js";
5
5
  import { fetchWithRetry } from "../utils/retry.js";
6
6
  import { getCurrentBranch, getBranchQualifiedTitle } from "../utils/branch.js";
7
+ import { createRepoLensError } from "../utils/errors.js";
7
8
 
8
9
  /**
9
10
  * Confluence Publisher for RepoLens
@@ -25,11 +26,7 @@ function confluenceHeaders() {
25
26
  const token = process.env.CONFLUENCE_API_TOKEN;
26
27
 
27
28
  if (!email || !token) {
28
- throw new Error(
29
- "Missing CONFLUENCE_EMAIL or CONFLUENCE_API_TOKEN. " +
30
- "Set these environment variables or GitHub Actions secrets. " +
31
- "Get your API token from: https://id.atlassian.com/manage-profile/security/api-tokens"
32
- );
29
+ throw createRepoLensError("CONFLUENCE_SECRETS_MISSING");
33
30
  }
34
31
 
35
32
  // Basic Auth for Atlassian Cloud: base64(email:api_token)
@@ -4,13 +4,14 @@ import path from "node:path";
4
4
  import { log } from "../utils/logger.js";
5
5
  import { fetchWithRetry } from "../utils/retry.js";
6
6
  import { executeNotionRequest } from "../utils/rate-limit.js";
7
+ import { createRepoLensError } from "../utils/errors.js";
7
8
 
8
9
  function notionHeaders() {
9
10
  const token = process.env.NOTION_TOKEN;
10
11
  const version = process.env.NOTION_VERSION || "2022-06-28";
11
12
 
12
13
  if (!token) {
13
- throw new Error("Missing NOTION_TOKEN in tools/repolens/.env or GitHub Actions secrets");
14
+ throw createRepoLensError("NOTION_TOKEN_MISSING");
14
15
  }
15
16
 
16
17
  return {
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Enhanced error messages with actionable guidance.
3
+ * Each error includes a description, likely cause, and fix.
4
+ */
5
+
6
+ const ERROR_CATALOG = {
7
+ CONFIG_NOT_FOUND: {
8
+ message: "RepoLens config not found (.repolens.yml)",
9
+ cause: "No .repolens.yml file was found in the current directory or any parent directory.",
10
+ fix: "Run 'repolens init' to create one, or use --config to specify a path.",
11
+ },
12
+ CONFIG_PARSE_FAILED: {
13
+ message: "Failed to parse .repolens.yml",
14
+ cause: "The configuration file contains invalid YAML syntax.",
15
+ fix: "Check .repolens.yml for YAML syntax errors (incorrect indentation, missing colons, etc.).",
16
+ },
17
+ CONFIG_VALIDATION_FAILED: {
18
+ message: "Configuration validation failed",
19
+ cause: "The configuration file is missing required fields or contains invalid values.",
20
+ fix: "Run 'repolens doctor' to identify specific issues, or compare with .repolens.example.yml.",
21
+ },
22
+ NOTION_TOKEN_MISSING: {
23
+ message: "NOTION_TOKEN not set",
24
+ cause: "The Notion integration token is not configured.",
25
+ fix: "Add NOTION_TOKEN to your .env file or GitHub Actions secrets.\n → Get a token at https://notion.so/my-integrations",
26
+ },
27
+ NOTION_PAGE_ID_MISSING: {
28
+ message: "NOTION_PARENT_PAGE_ID not set",
29
+ cause: "The Notion parent page ID is not configured.",
30
+ fix: "Add NOTION_PARENT_PAGE_ID to your .env file or GitHub Actions secrets.\n → Open your Notion page, copy the 32-char ID from the URL.",
31
+ },
32
+ NOTION_API_ERROR: {
33
+ message: "Notion API request failed",
34
+ cause: "The Notion API returned an error. Common causes: invalid token, page not shared with integration, rate limit hit.",
35
+ fix: "1. Verify NOTION_TOKEN is correct\n 2. Ensure the parent page is shared with your RepoLens integration\n 3. Check https://status.notion.so for API outages",
36
+ },
37
+ CONFLUENCE_SECRETS_MISSING: {
38
+ message: "Confluence credentials not configured",
39
+ cause: "One or more required Confluence environment variables are missing.",
40
+ fix: "Set these environment variables: CONFLUENCE_URL, CONFLUENCE_EMAIL, CONFLUENCE_API_TOKEN, CONFLUENCE_SPACE_KEY\n → Generate a token at https://id.atlassian.com/manage-profile/security/api-tokens",
41
+ },
42
+ CONFLUENCE_API_ERROR: {
43
+ message: "Confluence API request failed",
44
+ cause: "The Confluence API returned an error. Common causes: invalid credentials, wrong space key, permission denied.",
45
+ fix: "1. Verify CONFLUENCE_EMAIL and CONFLUENCE_API_TOKEN are correct\n 2. Confirm the space key exists\n 3. Check that your account has write access to the space",
46
+ },
47
+ SCAN_NO_FILES: {
48
+ message: "No files matched scan patterns",
49
+ cause: "The scan.include patterns in .repolens.yml didn't match any files.",
50
+ fix: "1. Check that scan.include patterns match your project structure\n 2. Ensure scan.ignore isn't excluding everything\n 3. Run 'repolens doctor' to validate your config",
51
+ },
52
+ SCAN_TOO_MANY_FILES: {
53
+ message: "Repository exceeds file limit (50,000 files)",
54
+ cause: "The scan patterns matched too many files, which would cause performance issues.",
55
+ fix: "Narrow your scan.include patterns or add more entries to scan.ignore.",
56
+ },
57
+ AI_API_KEY_MISSING: {
58
+ message: "AI API key not set",
59
+ cause: "REPOLENS_AI_ENABLED is true but no API key is configured.",
60
+ fix: "Add REPOLENS_AI_API_KEY to your .env file, or disable AI with REPOLENS_AI_ENABLED=false",
61
+ },
62
+ AI_API_ERROR: {
63
+ message: "AI provider returned an error",
64
+ cause: "The AI API request failed. Common causes: invalid key, quota exceeded, model unavailable.",
65
+ fix: "1. Verify your API key is valid and has credits\n 2. Check that the model name is correct\n 3. AI docs will fall back to deterministic mode automatically",
66
+ },
67
+ DISCORD_WEBHOOK_INVALID: {
68
+ message: "Discord webhook URL is invalid",
69
+ cause: "The DISCORD_WEBHOOK_URL environment variable doesn't contain a valid Discord webhook URL.",
70
+ fix: "Set DISCORD_WEBHOOK_URL to a valid webhook URL from Discord (Server Settings → Integrations → Webhooks).",
71
+ },
72
+ FILE_PERMISSION_DENIED: {
73
+ message: "Permission denied",
74
+ cause: "RepoLens doesn't have permission to read or write the specified file.",
75
+ fix: "Check file permissions and ensure the current user has read/write access.",
76
+ },
77
+ FILE_NOT_FOUND: {
78
+ message: "File not found",
79
+ cause: "A required file doesn't exist at the expected path.",
80
+ fix: "Check that all required files exist. Run 'repolens init' to recreate missing files.",
81
+ },
82
+ };
83
+
84
+ /**
85
+ * Create an enhanced error with actionable guidance.
86
+ * @param {string} code - Error code from ERROR_CATALOG
87
+ * @param {string} [detail] - Additional detail/context
88
+ * @returns {Error}
89
+ */
90
+ export function createRepoLensError(code, detail) {
91
+ const entry = ERROR_CATALOG[code];
92
+ if (!entry) {
93
+ const err = new Error(detail || code);
94
+ err.code = code;
95
+ return err;
96
+ }
97
+
98
+ const parts = [entry.message];
99
+ if (detail) parts.push(` ${detail}`);
100
+ parts.push(` → Cause: ${entry.cause}`);
101
+ parts.push(` → Fix: ${entry.fix}`);
102
+
103
+ const err = new Error(parts.join("\n"));
104
+ err.code = code;
105
+ return err;
106
+ }
107
+
108
+ /**
109
+ * Format an error with guidance for display.
110
+ * Falls back to the raw message if the error code isn't recognized.
111
+ * @param {string} code - Error code from ERROR_CATALOG
112
+ * @param {Error|string} [originalError] - The original error or context
113
+ * @returns {string} Formatted error string
114
+ */
115
+ export function formatError(code, originalError) {
116
+ const entry = ERROR_CATALOG[code];
117
+ if (!entry) {
118
+ return typeof originalError === "string" ? originalError : originalError?.message || code;
119
+ }
120
+
121
+ const detail = typeof originalError === "string"
122
+ ? originalError
123
+ : originalError?.message;
124
+
125
+ const lines = [`Error: ${entry.message}`];
126
+ if (detail) lines.push(` ${detail}`);
127
+ lines.push(` → Cause: ${entry.cause}`);
128
+ lines.push(` → Fix: ${entry.fix}`);
129
+ return lines.join("\n");
130
+ }
131
+
132
+ export { ERROR_CATALOG };
@@ -1,12 +1,28 @@
1
1
  /**
2
- * Metrics Collection for RepoLens Dashboard
3
- * Calculates coverage, health scores, staleness, and quality issues
2
+ * Metrics Collection for RepoLens
3
+ * Calculates coverage, health scores, staleness, section completeness, and quality issues
4
4
  */
5
5
 
6
6
  import fs from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { info, warn } from "./logger.js";
9
9
 
10
+ /** All possible document keys that RepoLens can generate */
11
+ const ALL_DOCUMENT_KEYS = [
12
+ "system_overview",
13
+ "module_catalog",
14
+ "api_surface",
15
+ "route_map",
16
+ "system_map",
17
+ "arch_diff",
18
+ "executive_summary",
19
+ "business_domains",
20
+ "architecture_overview",
21
+ "data_flows",
22
+ "change_impact",
23
+ "developer_onboarding",
24
+ ];
25
+
10
26
  /**
11
27
  * Calculate documentation coverage
12
28
  * @param {object} scanResult - Repository scan result
@@ -59,22 +75,19 @@ export function calculateCoverage(scanResult, docs) {
59
75
 
60
76
  /**
61
77
  * Calculate health score (0-100)
78
+ * Weights: coverage 35%, freshness 25%, quality 25%, section completeness 15%
62
79
  * @param {object} metrics - All metrics data
63
80
  * @returns {number} - Health score
64
81
  */
65
82
  export function calculateHealthScore(metrics) {
66
- const { coverage, freshness, quality } = metrics;
67
-
68
- // Coverage weight: 40%
69
- const coverageScore = coverage.overall * 0.4;
83
+ const { coverage, freshness, quality, sectionCompleteness } = metrics;
70
84
 
71
- // Freshness weight: 30%
72
- const freshnessScore = freshness.score * 0.3;
85
+ const coverageScore = coverage.overall * 0.35;
86
+ const freshnessScore = freshness.score * 0.25;
87
+ const qualityScore = quality.score * 0.25;
88
+ const completenessScore = (sectionCompleteness?.percentage || 0) * 0.15;
73
89
 
74
- // Quality weight: 30%
75
- const qualityScore = quality.score * 0.3;
76
-
77
- const healthScore = coverageScore + freshnessScore + qualityScore;
90
+ const healthScore = coverageScore + freshnessScore + qualityScore + completenessScore;
78
91
 
79
92
  return Math.round(Math.min(100, Math.max(0, healthScore)));
80
93
  }
@@ -325,6 +338,27 @@ function calculateTrends(history) {
325
338
  };
326
339
  }
327
340
 
341
+ /**
342
+ * Calculate section completeness — how many of the possible document types were generated.
343
+ * @param {object} docs - Rendered pages map (key → content)
344
+ * @returns {object} - Section completeness metrics
345
+ */
346
+ export function calculateSectionCompleteness(docs) {
347
+ const generated = Object.keys(docs || {}).filter(
348
+ (k) => docs[k] && docs[k].trim().length > 0
349
+ );
350
+ const total = ALL_DOCUMENT_KEYS.length;
351
+ const present = generated.filter((k) => ALL_DOCUMENT_KEYS.includes(k)).length;
352
+ const missing = ALL_DOCUMENT_KEYS.filter((k) => !generated.includes(k));
353
+
354
+ return {
355
+ generated: present,
356
+ total,
357
+ percentage: total > 0 ? Math.round((present / total) * 100) : 0,
358
+ missing,
359
+ };
360
+ }
361
+
328
362
  /**
329
363
  * Collect all metrics
330
364
  * @param {object} scanResult - Repository scan result
@@ -339,11 +373,13 @@ export async function collectMetrics(scanResult, docs, docsPath, historyPath) {
339
373
  const coverage = calculateCoverage(scanResult, docs);
340
374
  const freshness = await detectStaleness(docsPath);
341
375
  const quality = analyzeQuality(scanResult, docs);
376
+ const sectionCompleteness = calculateSectionCompleteness(docs);
342
377
 
343
378
  const metrics = {
344
379
  coverage,
345
380
  freshness,
346
381
  quality,
382
+ sectionCompleteness,
347
383
  timestamp: new Date().toISOString(),
348
384
  };
349
385
 
@@ -354,8 +390,25 @@ export async function collectMetrics(scanResult, docs, docsPath, historyPath) {
354
390
  metrics.history = history;
355
391
  metrics.trends = trends;
356
392
 
393
+ // Save latest metrics snapshot for external tooling
394
+ try {
395
+ const snapshotPath = path.join(docsPath, "metrics.json");
396
+ await fs.mkdir(docsPath, { recursive: true });
397
+ await fs.writeFile(snapshotPath, JSON.stringify({
398
+ healthScore: metrics.healthScore,
399
+ coverage: metrics.coverage,
400
+ sectionCompleteness: metrics.sectionCompleteness,
401
+ quality: { score: metrics.quality.score, summary: metrics.quality.summary },
402
+ freshness: { score: metrics.freshness.score, isStale: metrics.freshness.isStale, daysSinceUpdate: metrics.freshness.daysSinceUpdate },
403
+ timestamp: metrics.timestamp,
404
+ }, null, 2));
405
+ } catch (err) {
406
+ warn(`Failed to save metrics snapshot: ${err.message}`);
407
+ }
408
+
357
409
  info(`✓ Health Score: ${metrics.healthScore}/100`);
358
410
  info(`✓ Coverage: ${metrics.coverage.overall.toFixed(1)}%`);
411
+ info(`✓ Sections: ${sectionCompleteness.generated}/${sectionCompleteness.total} document types generated`);
359
412
 
360
413
  return metrics;
361
414
  }
@@ -175,15 +175,15 @@ export function isTelemetryEnabled() {
175
175
  // ============================================================
176
176
 
177
177
  const performanceTimers = new Map();
178
+ const completedTimings = [];
178
179
 
179
180
  /**
180
- * Start a performance timer for an operation
181
+ * Start a performance timer for an operation.
182
+ * Timers always work locally (even without telemetry) so we can print a summary.
181
183
  * @param {string} operation - Operation name (e.g., "scan", "render", "publish")
182
184
  * @param {object} metadata - Additional context about the operation
183
185
  */
184
186
  export function startTimer(operation, metadata = {}) {
185
- if (!enabled) return;
186
-
187
187
  const key = `${operation}_${Date.now()}`;
188
188
  performanceTimers.set(key, {
189
189
  operation,
@@ -191,15 +191,16 @@ export function startTimer(operation, metadata = {}) {
191
191
  metadata,
192
192
  });
193
193
 
194
- return key; // Return key so caller can stop this specific timer
194
+ return key;
195
195
  }
196
196
 
197
197
  /**
198
198
  * Stop a performance timer and record the metric
199
199
  * @param {string} timerKey - Key returned from startTimer()
200
+ * @returns {number|undefined} Duration in milliseconds
200
201
  */
201
202
  export function stopTimer(timerKey) {
202
- if (!enabled || !timerKey) return;
203
+ if (!timerKey) return;
203
204
 
204
205
  const timer = performanceTimers.get(timerKey);
205
206
  if (!timer) return;
@@ -207,26 +208,46 @@ export function stopTimer(timerKey) {
207
208
  const duration = Date.now() - timer.startTime;
208
209
  performanceTimers.delete(timerKey);
209
210
 
210
- // Send performance metric
211
- try {
212
- Sentry.metrics.distribution(
213
- `operation.duration`,
214
- duration,
215
- {
216
- unit: 'millisecond',
217
- tags: {
218
- operation: timer.operation,
219
- ...timer.metadata,
220
- },
221
- }
222
- );
223
- } catch (e) {
224
- // Silently fail
211
+ // Store locally for summary
212
+ completedTimings.push({ operation: timer.operation, duration });
213
+
214
+ // Send to Sentry if enabled
215
+ if (enabled) {
216
+ try {
217
+ Sentry.metrics.distribution(
218
+ `operation.duration`,
219
+ duration,
220
+ {
221
+ unit: 'millisecond',
222
+ tags: {
223
+ operation: timer.operation,
224
+ ...timer.metadata,
225
+ },
226
+ }
227
+ );
228
+ } catch (e) {
229
+ // Silently fail
230
+ }
225
231
  }
226
232
 
227
233
  return duration;
228
234
  }
229
235
 
236
+ /**
237
+ * Get all completed timings for summary display.
238
+ * @returns {Array<{operation: string, duration: number}>}
239
+ */
240
+ export function getTimings() {
241
+ return [...completedTimings];
242
+ }
243
+
244
+ /**
245
+ * Clear stored timings (useful between runs).
246
+ */
247
+ export function clearTimings() {
248
+ completedTimings.length = 0;
249
+ }
250
+
230
251
  /**
231
252
  * Track a usage event (command execution)
232
253
  * @param {string} command - Command name (init, doctor, migrate, publish)
package/src/watch.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Watch mode for RepoLens — regenerates Markdown docs when source files change.
3
+ * Only publishes to Markdown (no API calls on every save).
4
+ */
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { loadConfig } from "./core/config.js";
9
+ import { scanRepo } from "./core/scan.js";
10
+ import { generateDocumentSet } from "./docs/generate-doc-set.js";
11
+ import { writeDocumentSet } from "./docs/write-doc-set.js";
12
+ import { getGitDiff } from "./core/diff.js";
13
+ import { info, warn, error } from "./utils/logger.js";
14
+
15
+ const DEBOUNCE_MS = 500;
16
+
17
+ /**
18
+ * Run a single rebuild cycle (scan → render → write markdown).
19
+ */
20
+ async function rebuild(configPath) {
21
+ const start = Date.now();
22
+ try {
23
+ const cfg = await loadConfig(configPath);
24
+ const scan = await scanRepo(cfg);
25
+ const rawDiff = getGitDiff("origin/main");
26
+ const docSet = await generateDocumentSet(scan, cfg, rawDiff);
27
+ const result = await writeDocumentSet(docSet, process.cwd());
28
+ const elapsed = Date.now() - start;
29
+ info(`✓ Regenerated ${result.documentCount} docs in ${elapsed}ms`);
30
+ } catch (err) {
31
+ error(`Rebuild failed: ${err.message}`);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Start watch mode.
37
+ * @param {string} configPath - Resolved path to .repolens.yml
38
+ */
39
+ export async function runWatch(configPath) {
40
+ info("Starting watch mode (Markdown only)...");
41
+ info("Press Ctrl+C to stop.\n");
42
+
43
+ // Initial build
44
+ await rebuild(configPath);
45
+
46
+ // Determine directories to watch from config
47
+ const cfg = await loadConfig(configPath);
48
+ const repoRoot = cfg.__repoRoot || process.cwd();
49
+ const moduleRoots = cfg.module_roots || ["src", "app", "lib"];
50
+
51
+ let debounceTimer = null;
52
+
53
+ const onChange = () => {
54
+ if (debounceTimer) clearTimeout(debounceTimer);
55
+ debounceTimer = setTimeout(() => rebuild(configPath), DEBOUNCE_MS);
56
+ };
57
+
58
+ const watchers = [];
59
+ for (const root of moduleRoots) {
60
+ const dirPath = path.resolve(repoRoot, root);
61
+ try {
62
+ fs.accessSync(dirPath);
63
+ const watcher = fs.watch(dirPath, { recursive: true }, (eventType, filename) => {
64
+ if (filename && !filename.includes("node_modules")) {
65
+ info(`Change detected: ${root}/${filename}`);
66
+ onChange();
67
+ }
68
+ });
69
+ watchers.push(watcher);
70
+ info(`Watching: ${root}/`);
71
+ } catch {
72
+ // Directory doesn't exist, skip
73
+ }
74
+ }
75
+
76
+ if (watchers.length === 0) {
77
+ warn("No directories to watch. Check module_roots in .repolens.yml");
78
+ return;
79
+ }
80
+
81
+ info("\nWaiting for changes...\n");
82
+
83
+ // Keep process alive and clean up on exit
84
+ process.on("SIGINT", () => {
85
+ info("\nStopping watch mode...");
86
+ for (const w of watchers) w.close();
87
+ process.exit(0);
88
+ });
89
+
90
+ // Keep alive indefinitely
91
+ await new Promise(() => {});
92
+ }