@chappibunny/repolens 1.12.4 → 1.13.1

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,7 +2,28 @@
2
2
 
3
3
  All notable changes to RepoLens will be documented in this file.
4
4
 
5
- ## Unreleased
5
+ ## 1.13.1
6
+
7
+ ### Docs
8
+
9
+ - Docs alignment patch: README, CHANGELOG and docs/* point the VS Code extension at the `repolensdox.repolensdox` Marketplace listing (https://marketplace.visualstudio.com/items?itemName=repolensdox.repolensdox)
10
+ - npm package README now carries the new extension link
11
+
12
+ ## 1.13.0
13
+
14
+ ### Obsidian vault publisher
15
+
16
+ Publish generated docs into a folder in a local Obsidian vault. No network calls and no credentials.
17
+
18
+ - New publisher: `obsidian` (`src/publishers/obsidian.js`)
19
+ - Notes get YAML frontmatter (`title`, `aliases`, `tags`, `type`, `project`, `branch`) and an index note (`RepoLens.md`) with wikilinks
20
+ - Markdown links to generated pages become `[[wikilinks]]`. External URLs and fenced code blocks are left alone
21
+ - Re-running publish is idempotent: identical notes are not rewritten, and notes RepoLens previously published but no longer generates are removed. Other files in the folder stay
22
+ - `obsidian.folder` must resolve inside `obsidian.vault`. Absolute folders, `..`, and symlinks that leave the vault are rejected
23
+ - Optional config on schema v1: `obsidian.vault`, `obsidian.folder`, `obsidian.branches`, `obsidian.index`, `obsidian.frontmatter`, `obsidian.wikilinks`. Existing configs keep working
24
+ - `repolens init` can select the Obsidian publisher and ask for a vault path
25
+ - `repolens doctor` checks that `obsidian.vault` is set and does not require env vars for it
26
+ - Wikilink rewriting is a linear scan with bounded label and target lengths. The previous link regex was polynomial on hostile markdown (CodeQL)
6
27
 
7
28
  ### Security
8
29
 
@@ -10,8 +31,12 @@ All notable changes to RepoLens will be documented in this file.
10
31
 
11
32
  ### Docs
12
33
 
34
+ - `docs/ROADMAP.md` now summarizes what shipped from v1.1.0 through v1.12.4, and marks the VS Code extension and the Obsidian publisher as shipped
35
+ - README roadmap matches `docs/ROADMAP.md`
13
36
  - The README no longer hard-codes the release version. It points at `package.json`, which is the source of the version
14
37
  - Test totals are no longer hard-coded in the README, development guide, or security guide. The tests badge follows the `test.yml` workflow status
38
+ - Handbook pages (README, `docs/*`, `SECURITY.md`, `CONTRIBUTING.md`, and `.github/copilot-instructions.md`) describe 1.13.0 and the Obsidian publisher
39
+ - Corrected drift against the code: `scan.ignore` (there is no `scan.exclude`), `publish` has no `--dry-run`, CI installs with `npm ci`, the publish workflow is `repolens.yml`, diagrams are Unicode, and local files use two filename schemes. `outputs.pages` stays the frozen allowlist; the generator still emits the full 17-document plan unless `documentation.sections` or `features` filters it
15
40
 
16
41
  ## 1.12.4
17
42
 
package/README.md CHANGED
@@ -9,13 +9,13 @@
9
9
  ```
10
10
 
11
11
  [![npm version](https://img.shields.io/npm/v/@chappibunny/repolens)](https://www.npmjs.com/package/@chappibunny/repolens)
12
- [![VS Code Extension](https://img.shields.io/visual-studio-marketplace/v/CHAPIBUNNY.repolens-architecture?label=VS%20Code)](https://marketplace.visualstudio.com/items?itemName=CHAPIBUNNY.repolens-architecture)
12
+ [![VS Code Extension](https://img.shields.io/visual-studio-marketplace/v/repolensdox.repolensdox?label=VS%20Code)](https://marketplace.visualstudio.com/items?itemName=repolensdox.repolensdox)
13
13
  [![Tests](https://img.shields.io/github/actions/workflow/status/CHAPIBUNNY/repolens/test.yml?label=tests)](https://github.com/CHAPIBUNNY/repolens/actions/workflows/test.yml)
14
14
  [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
15
15
 
16
16
  **Your architecture docs are already outdated.** RepoLens fixes that.
17
17
 
18
- RepoLens scans your repository, generates living architecture documentation, and publishes it to Notion, Confluence, GitHub Wiki, or Markdown — automatically on every push. Engineers get technical docs. Stakeholders get readable system overviews. Nobody writes a word.
18
+ RepoLens scans your repository, generates living architecture documentation, and publishes it to Notion, Confluence, GitHub Wiki, Obsidian, or Markdown — automatically on every push. Engineers get technical docs. Stakeholders get readable system overviews. Nobody writes a word.
19
19
 
20
20
  > Stable as of v1.0 — [API guarantees](docs/STABILITY.md) · [Security hardened](SECURITY.md) · version from [`package.json`](package.json)
21
21
 
@@ -83,7 +83,7 @@ CONFLUENCE_SPACE_KEY=DOCS
83
83
  npx @chappibunny/repolens publish
84
84
  ```
85
85
 
86
- **Done!** Your docs are now live in Notion, Confluence, and/or `.repolens/` directory.
86
+ **Done!** Numbered notes and the Markdown publisher’s files are in `.repolens/`. Notion, Confluence, GitHub Wiki, and Obsidian publish only when you configure them. Obsidian needs `obsidian.vault` in `.repolens.yml` and does not use an API key.
87
87
 
88
88
  **🔄 Upgrading from v0.3.0 or earlier?**
89
89
  Run `npx @chappibunny/repolens migrate` to automatically update your workflow files. See [MIGRATION.md](docs/MIGRATION.md) for details.
@@ -96,9 +96,9 @@ Run `npx @chappibunny/repolens migrate` to automatically update your workflow fi
96
96
 
97
97
  | Audience | Documents |
98
98
  |---|---|
99
- | **Stakeholders** (founders, PMs, ops) | Executive Summary · Business Domains · Data Flows |
100
- | **Everyone** | System Overview · Developer Onboarding · Change Impact · Architecture Drift · Code Health |
101
- | **Engineers** | Architecture Overview · Module Catalog · API Surface · Route Map · System Map · GraphQL Schema · TypeScript Type Graph · Dependency Graph · Security Hotspots |
99
+ | **Stakeholders** | Executive Summary |
100
+ | **Mixed** | System Overview · Business Domains · Route Map · Data Flows · Change Impact · System Map · Architecture Drift · Code Health |
101
+ | **Engineers** | Architecture Overview · Module Catalog · API Surface · Developer Onboarding · GraphQL Schema · TypeScript Type Graph · Dependency Graph · Security Hotspots |
102
102
 
103
103
  **Two modes:** Deterministic (free, fast, always works) or AI-Enhanced (optional — GitHub Models, OpenAI, Anthropic, Google, Azure, Ollama).
104
104
 
@@ -110,7 +110,7 @@ Run `npx @chappibunny/repolens migrate` to automatically update your workflow fi
110
110
  |---|---|
111
111
  | 🤖 **Autonomous** | Runs on every push via GitHub Actions — docs stay evergreen |
112
112
  | 👥 **Multi-Audience** | Technical docs + stakeholder-readable overviews from one scan |
113
- | 📤 **Multi-Publisher** | Notion, Confluence, GitHub Wiki, Markdown — or all four at once |
113
+ | 📤 **Multi-Publisher** | Notion, Confluence, GitHub Wiki, Obsidian, Markdown — together or one at a time |
114
114
  | 🧠 **AI-Assisted** | Optional AI with zero-hallucination policy (structured context only) |
115
115
  | 🔍 **Smart Detection** | Frameworks, domains, data flows, dependencies, drift — all automatic |
116
116
  | 🔌 **Extensible** | Plugin system for custom renderers, publishers, and hooks |
@@ -137,10 +137,10 @@ For alternative methods, see [INSTALLATION.md](docs/INSTALLATION.md).
137
137
 
138
138
  **Install from Marketplace:**
139
139
  ```
140
- ext install CHAPIBUNNY.repolens-architecture
140
+ ext install repolensdox.repolensdox
141
141
  ```
142
142
 
143
- [**→ Get it on Visual Studio Marketplace**](https://marketplace.visualstudio.com/items?itemName=CHAPIBUNNY.repolens-architecture)
143
+ [**→ Get it on Visual Studio Marketplace**](https://marketplace.visualstudio.com/items?itemName=repolensdox.repolensdox)
144
144
 
145
145
  **Features:**
146
146
  - 🏗️ **Architecture Explorer** — Tree view of your system structure
@@ -155,7 +155,7 @@ The extension reads your `.repolens.yml` configuration and provides an interacti
155
155
 
156
156
  ## 🎓 Onboarding Guide
157
157
 
158
- Step-by-step setup for publishers, AI features, Notion, Confluence, GitHub Wiki, Discord, and CI/CD automation.
158
+ Step-by-step setup for publishers, AI features, Notion, Confluence, GitHub Wiki, Obsidian, Discord, and CI/CD automation.
159
159
 
160
160
  **[→ Full Onboarding Guide](docs/ONBOARDING.md)**
161
161
 
@@ -183,28 +183,11 @@ Step-by-step setup for publishers, AI features, Notion, Confluence, GitHub Wiki,
183
183
 
184
184
  ## 📸 Example Output
185
185
 
186
- ### System Map with Dependencies
187
-
188
- ```mermaid
189
- graph LR
190
- CLI[bin/repolens<br/>1 file] --> Core[src/core<br/>4 files]
191
- Publishers[src/publishers<br/>6 files] --> Core
192
- Publishers --> Renderers[src/renderers<br/>4 files]
193
- Publishers --> Utils[src/utils<br/>10 files]
194
- Renderers --> Core
195
- Delivery[src/delivery<br/>1 file] --> Publishers
196
- Tests[tests<br/>15 files] -. tests .-> CLI
197
- Tests -. tests .-> Core
198
- Tests -. tests .-> Publishers
199
-
200
- style CLI fill:#9b59b6,color:#fff
201
- style Core fill:#f39c12,color:#000
202
- style Publishers fill:#27ae60,color:#fff
203
- style Renderers fill:#27ae60,color:#fff
204
- style Delivery fill:#16a085,color:#fff
205
- style Utils fill:#95a5a6,color:#000
206
- style Tests fill:#e67e22,color:#fff
207
- ```
186
+ ### System map
187
+
188
+ Published system maps are Unicode diagrams drawn from import edges. RepoLens does not emit Mermaid.
189
+
190
+ The publisher set in 1.13.0 is Notion, Confluence, GitHub Wiki, Obsidian, and Markdown (`src/publishers`, 7 files). Layout and counts are in [ARCHITECTURE.md](docs/ARCHITECTURE.md).
208
191
 
209
192
  ### System Overview (Technical Profile)
210
193
 
@@ -255,7 +238,7 @@ When you open a pull request, RepoLens posts:
255
238
 
256
239
  | Guide | Description |
257
240
  |---|---|
258
- | [Onboarding Guide](docs/ONBOARDING.md) | Step-by-step setup: publishers, AI, Notion, Confluence, Discord |
241
+ | [Onboarding Guide](docs/ONBOARDING.md) | Step-by-step setup: publishers, AI, Notion, Confluence, GitHub Wiki, Obsidian, Discord |
259
242
  | [Configuration](docs/CONFIGURATION.md) | Complete `.repolens.yml` schema and examples |
260
243
  | [Environment Variables](docs/ENVIRONMENT.md) | All env vars by publisher and feature |
261
244
  | [Architecture](docs/ARCHITECTURE.md) | Pipeline diagram, project structure |
@@ -284,11 +267,17 @@ When you open a pull request, RepoLens posts:
284
267
  v1.0+ features complete — CLI, config schema, and plugin interface are frozen.
285
268
 
286
269
  **Completed:**
287
- - [x] VS Code extension ([available on Marketplace](https://marketplace.visualstudio.com/items?itemName=CHAPIBUNNY.repolens-architecture))
270
+ - [x] VS Code extension ([available on Marketplace](https://marketplace.visualstudio.com/items?itemName=repolensdox.repolensdox))
271
+ - [x] Obsidian vault publisher (local notes with wikilinks and frontmatter)
288
272
 
289
273
  **Next:**
290
- - [ ] Obsidian publisher
291
- - [ ] GitHub App
274
+ - [ ] GitHub App for automated setup and status checks
275
+ - [ ] Slack notifications
276
+ - [ ] Custom webhook support
277
+ - [ ] Cross-repository architecture analysis
278
+ - [ ] Security boundary validation
279
+ - [ ] Interactive HTML documentation output
280
+ - [ ] Architecture decision records (ADR) generation
292
281
 
293
282
  See [ROADMAP.md](docs/ROADMAP.md) for detailed planning.
294
283
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chappibunny/repolens",
3
- "version": "1.12.4",
3
+ "version": "1.13.1",
4
4
  "description": "AI-assisted documentation intelligence system for technical and non-technical audiences",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,6 +36,7 @@
36
36
  "metrics",
37
37
  "team-collaboration",
38
38
  "discord",
39
+ "obsidian",
39
40
  "ai-documentation"
40
41
  ],
41
42
  "repository": {
@@ -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",
package/src/doctor.js CHANGED
@@ -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/init.js CHANGED
@@ -5,7 +5,7 @@ import { spawn } from "node:child_process";
5
5
  import { info, warn } from "./utils/logger.js";
6
6
  import { isNonInteractiveEnvironment } from "./utils/interactive.js";
7
7
 
8
- const PUBLISHER_CHOICES = ["markdown", "notion", "confluence", "github_wiki"];
8
+ const PUBLISHER_CHOICES = ["markdown", "notion", "confluence", "github_wiki", "obsidian"];
9
9
 
10
10
  // ============================================================================
11
11
  // URL PARSING HELPERS
@@ -435,6 +435,10 @@ If you configured Notion credentials during setup, documentation will publish au
435
435
  - \`NOTION_TOKEN\` — Get from https://www.notion.so/my-integrations
436
436
  - \`NOTION_PARENT_PAGE_ID\` — The page where docs will be published
437
437
 
438
+ ### Obsidian and GitHub Wiki
439
+
440
+ Obsidian writes notes into a folder in a local vault. Set \`obsidian.vault\` in \`.repolens.yml\`. No API key and no network. GitHub Wiki needs \`github_wiki\` in \`publishers\` and \`GITHUB_TOKEN\`.
441
+
438
442
  ### Confluence Publishing
439
443
 
440
444
  To publish documentation to Atlassian Confluence:
@@ -472,23 +476,20 @@ The workflow token is \`contents: read\` plus \`pull-requests: write\`. That is
472
476
 
473
477
  ## 📊 Generated Documentation
474
478
 
475
- RepoLens generates documentation in two modes:
479
+ RepoLens generates 17 documents by default. Diagrams are Unicode.
476
480
 
477
- ### Deterministic Mode (Default - Free)
478
- - **System Overview** — Technical profile and stats
479
- - **Module Catalog** — Complete code inventory
480
- - **API Surface** — Internal endpoints + external integrations
481
- - **Route Map** — Application routes
482
- - **System Map** — Unicode architecture diagram
483
- - **Architecture Diff** — Change tracking
481
+ ### Always written
482
+ - **System Overview**, **Module Catalog**, **API Surface**, **Route Map**, **System Map** (Unicode diagram), **Architecture Diff**
483
+ - **GraphQL Schema**, **Type Graph**, **Dependency Graph**, **Architecture Drift**, **Security Hotspots**, **Code Health**
484
484
 
485
- ### AI-Enhanced Mode (Optional - Requires API Key)
486
- Adds 5 natural language documents readable by non-technical audiences:
485
+ ### AI-Enhanced Mode (Optional)
486
+ These documents use AI when it is enabled, and a deterministic fallback when it is not:
487
487
  - **Executive Summary** — Project overview for leadership
488
488
  - **Business Domains** — What the system does by function
489
489
  - **Architecture Overview** — Layered technical analysis
490
- - **Data Flows** — How information moves through system
490
+ - **Data Flows** — How information moves through the system
491
491
  - **Developer Onboarding** — Getting started guide
492
+ - **Change Impact** and the hybrid pages above (module catalog, route map, API surface, system map)
492
493
 
493
494
  ## 🤖 Enabling AI Features
494
495
 
@@ -529,11 +530,7 @@ No API key signup needed. Uses \`gpt-4o-mini\` by default.
529
530
 
530
531
  **Cost estimate**: $0.10-$0.40 per run for typical projects (or free with GitHub Models)
531
532
 
532
- See [AI.md](https://github.com/CHAPIBUNNY/repolens/blob/main/AI.md) for full documentation
533
- - **Module Catalog** — Detected code modules
534
- - **API Surface** — REST API endpoints
535
- - **Route Map** — Application routes
536
- - **System Map** — Architecture diagram (Unicode ASCII art)
533
+ See [AI.md](https://github.com/CHAPIBUNNY/repolens/blob/main/docs/AI.md) for full documentation.
537
534
 
538
535
  ## ⚙️ Configuration
539
536
 
@@ -547,8 +544,8 @@ Edit \`.repolens.yml\` to customize:
547
544
  ## 📚 Learn More
548
545
 
549
546
  - [RepoLens GitHub](https://github.com/CHAPIBUNNY/repolens)
550
- - [Configuration Docs](https://github.com/CHAPIBUNNY/repolens#configuration)
551
- - [Troubleshooting](https://github.com/CHAPIBUNNY/repolens#troubleshooting)
547
+ - [Configuration](https://github.com/CHAPIBUNNY/repolens/blob/main/docs/CONFIGURATION.md)
548
+ - [Troubleshooting](https://github.com/CHAPIBUNNY/repolens/blob/main/docs/TROUBLESHOOTING.md)
552
549
  `;
553
550
 
554
551
  async function fileExists(filePath) {
@@ -754,6 +751,17 @@ function buildRepoLensConfig(projectName, detectedRoots, options = {}) {
754
751
  `# - main # Only publish from main branch`,
755
752
  `# - develop # Or add specific branches`,
756
753
  ``,
754
+ `# Optional: Publish into a local Obsidian vault (no network)`,
755
+ `# publishers also needs: - obsidian`,
756
+ `# obsidian:`,
757
+ `# vault: "/path/to/vault" # absolute, or relative to this repo`,
758
+ `# folder: "RepoLens/my-app" # stays inside the vault`,
759
+ `# branches:`,
760
+ `# - main`,
761
+ `# index: true`,
762
+ `# frontmatter: true`,
763
+ `# wikilinks: true`,
764
+ ``,
757
765
  `# Optional: GitHub integration for SVG diagram hosting`,
758
766
  `# github:`,
759
767
  `# owner: "your-username"`,
@@ -1156,6 +1164,20 @@ async function runInteractiveWizard(repoRoot) {
1156
1164
  githubSecretsNeeded.push("CONFLUENCE_URL", "CONFLUENCE_EMAIL", "CONFLUENCE_API_TOKEN", "CONFLUENCE_SPACE_KEY", "CONFLUENCE_PARENT_PAGE_ID");
1157
1165
  }
1158
1166
 
1167
+ // Obsidian vault (local path only — no credentials)
1168
+ let obsidian = null;
1169
+ if (publishers.includes("obsidian")) {
1170
+ info("\n📝 Obsidian Vault");
1171
+ info(" Notes are written into a folder in your vault. Nothing is uploaded.");
1172
+ const vault = (await ask(" Vault path (absolute, or relative to the repo): ")).trim();
1173
+ const folderDefault = `RepoLens/${projectName}`;
1174
+ const folder = (await ask(` Folder inside the vault (${folderDefault}): `)).trim();
1175
+ obsidian = { vault, folder: folder || folderDefault };
1176
+ if (!vault) {
1177
+ warn(" No vault path yet. Add obsidian.vault to .repolens.yml before publishing.");
1178
+ }
1179
+ }
1180
+
1159
1181
  // GitHub Wiki setup
1160
1182
  if (publishers.includes("github_wiki")) {
1161
1183
  info("\n📝 GitHub Wiki Setup");
@@ -1278,7 +1300,7 @@ async function runInteractiveWizard(repoRoot) {
1278
1300
 
1279
1301
  // 6. Branch filtering
1280
1302
  info("\n🌿 Branch Filtering");
1281
- info(" Limits which branches can publish to Notion/Confluence.");
1303
+ info(" Limits which branches can publish to Notion, Confluence, and Obsidian.");
1282
1304
  const branchInput = (await ask(" Allowed branches (comma-separated, default: main): ")).trim() || "main";
1283
1305
  const branches = branchInput.split(",").map((b) => b.trim()).filter(Boolean);
1284
1306
 
@@ -1336,7 +1358,8 @@ async function runInteractiveWizard(repoRoot) {
1336
1358
  branches,
1337
1359
  enableDiscord,
1338
1360
  credentials,
1339
- githubSecretsNeeded: uniqueSecrets
1361
+ githubSecretsNeeded: uniqueSecrets,
1362
+ obsidian,
1340
1363
  };
1341
1364
  } finally {
1342
1365
  rl.close();
@@ -1348,6 +1371,7 @@ const PUBLISHER_DESCRIPTIONS = {
1348
1371
  notion: "Notion workspace pages",
1349
1372
  confluence: "Atlassian Confluence pages",
1350
1373
  github_wiki: "Repository wiki pages",
1374
+ obsidian: "Local Obsidian vault folder",
1351
1375
  };
1352
1376
 
1353
1377
  /**
@@ -1380,6 +1404,22 @@ function buildWizardConfig(answers) {
1380
1404
  lines.push(` branches:`);
1381
1405
  for (const b of answers.branches) lines.push(` - ${b}`);
1382
1406
  }
1407
+ if (answers.publishers.includes("obsidian")) {
1408
+ lines.push(``);
1409
+ lines.push(`obsidian:`);
1410
+ if (answers.obsidian?.vault) {
1411
+ lines.push(` vault: ${JSON.stringify(answers.obsidian.vault)}`);
1412
+ }
1413
+ const folder = answers.obsidian?.folder || `RepoLens/${answers.projectName}`;
1414
+ lines.push(` folder: ${JSON.stringify(folder)}`);
1415
+ if (answers.branches?.length) {
1416
+ lines.push(` branches:`);
1417
+ for (const b of answers.branches) lines.push(` - ${b}`);
1418
+ }
1419
+ lines.push(` index: true`);
1420
+ lines.push(` frontmatter: true`);
1421
+ lines.push(` wikilinks: true`);
1422
+ }
1383
1423
 
1384
1424
  if (answers.enableDiscord) {
1385
1425
  lines.push(``);
@@ -2,7 +2,8 @@ import { publishToNotion } from "./publish.js";
2
2
  import { publishToMarkdown } from "./markdown.js";
3
3
  import { publishToConfluence, hasConfluenceSecrets } from "./confluence.js";
4
4
  import { publishToGitHubWiki, hasGitHubWikiSecrets } from "./github-wiki.js";
5
- import { shouldPublishToNotion, shouldPublishToConfluence, shouldPublishToGitHubWiki, getCurrentBranch, branchMatches } from "../utils/branch.js";
5
+ import { publishToObsidian } from "./obsidian.js";
6
+ import { shouldPublishToNotion, shouldPublishToConfluence, shouldPublishToGitHubWiki, shouldPublishToObsidian, getCurrentBranch, branchMatches } from "../utils/branch.js";
6
7
  import { resolveOutputDir } from "../utils/safe-path.js";
7
8
  import { info, warn } from "../utils/logger.js";
8
9
  import { trackPublishing } from "../utils/telemetry.js";
@@ -96,6 +97,25 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
96
97
  }
97
98
  }
98
99
 
100
+ // Obsidian vault (local files only — no credentials, no network)
101
+ if (publishers.includes("obsidian")) {
102
+ if (shouldPublishToObsidian(cfg, currentBranch)) {
103
+ info(`Publishing to Obsidian vault from branch: ${currentBranch}`);
104
+ try {
105
+ await publishToObsidian(cfg, renderedPages);
106
+ publishedTo.push("obsidian");
107
+ } catch (err) {
108
+ warn(`Obsidian publish failed: ${err.message}`);
109
+ publishErrors.push({ publisher: "obsidian", error: err });
110
+ publishStatus = "partial";
111
+ }
112
+ } else {
113
+ const allowedBranches = cfg.obsidian?.branches?.join(", ") || "none configured";
114
+ warn(`Skipping Obsidian publish: branch "${currentBranch}" not in allowed list (${allowedBranches})`);
115
+ info("To publish from this branch, add it to obsidian.branches in .repolens.yml");
116
+ }
117
+ }
118
+
99
119
  // GitHub Wiki publishing (opt-in if secrets configured)
100
120
  if (publishers.includes("github_wiki") || hasGitHubWikiSecrets() && publishers.includes("github_wiki")) {
101
121
  if (!hasGitHubWikiSecrets()) {
@@ -0,0 +1,716 @@
1
+ // Obsidian vault publisher for RepoLens.
2
+ //
3
+ // Writes generated notes into a folder inside a local Obsidian vault.
4
+ // Notes use YAML frontmatter and wikilinks. Updates are in-place and
5
+ // idempotent. Every path is contained inside the vault. No network.
6
+ //
7
+ // Config (.repolens.yml), all fields optional except vault at publish time:
8
+ // obsidian:
9
+ // vault: "/path/to/vault" # absolute, or relative to the repo
10
+ // folder: "RepoLens/my-app" # relative directory inside the vault
11
+ // branches: [main] # optional branch filter (enforced by the orchestrator)
12
+ // index: true # map-of-content note (default: true)
13
+ // frontmatter: true # YAML properties (default: true)
14
+ // wikilinks: true # rewrite markdown links (default: true)
15
+
16
+ import fs from "node:fs/promises";
17
+ import path from "node:path";
18
+ import { info, warn } from "../utils/logger.js";
19
+ import { getCurrentBranch } from "../utils/branch.js";
20
+ import { isInsideRoot, safeFileName } from "../utils/safe-path.js";
21
+
22
+ const INDEX_NOTE = "RepoLens";
23
+ const MANIFEST_FILE = ".repolens-obsidian.json";
24
+
25
+ const PAGE_ORDER = [
26
+ "executive_summary",
27
+ "system_overview",
28
+ "business_domains",
29
+ "architecture_overview",
30
+ "module_catalog",
31
+ "route_map",
32
+ "api_surface",
33
+ "data_flows",
34
+ "change_impact",
35
+ "system_map",
36
+ "developer_onboarding",
37
+ "graphql_schema",
38
+ "type_graph",
39
+ "dependency_graph",
40
+ "architecture_drift",
41
+ "arch_diff",
42
+ "security_hotspots",
43
+ "code_health",
44
+ ];
45
+
46
+ const PAGE_TITLES = {
47
+ executive_summary: "Executive Summary",
48
+ system_overview: "System Overview",
49
+ business_domains: "Business Domains",
50
+ architecture_overview: "Architecture Overview",
51
+ module_catalog: "Module Catalog",
52
+ route_map: "Route Map",
53
+ api_surface: "API Surface",
54
+ data_flows: "Data Flows",
55
+ change_impact: "Change Impact",
56
+ system_map: "System Map",
57
+ developer_onboarding: "Developer Onboarding",
58
+ graphql_schema: "GraphQL Schema",
59
+ type_graph: "Type Graph",
60
+ dependency_graph: "Dependency Graph",
61
+ architecture_drift: "Architecture Drift",
62
+ arch_diff: "Architecture Diff",
63
+ security_hotspots: "Security Hotspots",
64
+ code_health: "Code Health",
65
+ };
66
+
67
+ const PAGE_DESCRIPTIONS = {
68
+ executive_summary: "High-level project summary for non-technical readers.",
69
+ system_overview: "Snapshot of stack, scale, structure, and detected capabilities.",
70
+ business_domains: "Functional areas inferred from the repository.",
71
+ architecture_overview: "Layered technical view of the system.",
72
+ module_catalog: "Inventory of modules and responsibilities.",
73
+ route_map: "Frontend routes and page structure.",
74
+ api_surface: "Detected endpoints, handlers, and API structure.",
75
+ data_flows: "How information moves through the system.",
76
+ change_impact: "Architecture changes and likely downstream effects.",
77
+ system_map: "Visual map of system relationships.",
78
+ developer_onboarding: "What a new engineer needs to know first.",
79
+ graphql_schema: "GraphQL types, operations, and schema structure.",
80
+ type_graph: "Type-level relationships across the codebase.",
81
+ dependency_graph: "Module and package dependency relationships.",
82
+ architecture_drift: "Drift between intended and current architecture.",
83
+ arch_diff: "Architecture-level diff across revisions.",
84
+ security_hotspots: "Security anti-patterns with CWE classification.",
85
+ code_health: "Complexity and unified health scores.",
86
+ };
87
+
88
+ const PAGE_TAGS = {
89
+ executive_summary: "stakeholders",
90
+ system_overview: "overview",
91
+ business_domains: "stakeholders",
92
+ architecture_overview: "engineers",
93
+ module_catalog: "engineers",
94
+ route_map: "engineers",
95
+ api_surface: "engineers",
96
+ data_flows: "stakeholders",
97
+ change_impact: "engineers",
98
+ system_map: "overview",
99
+ developer_onboarding: "onboarding",
100
+ graphql_schema: "engineers",
101
+ type_graph: "engineers",
102
+ dependency_graph: "engineers",
103
+ architecture_drift: "engineers",
104
+ arch_diff: "engineers",
105
+ security_hotspots: "security",
106
+ code_health: "engineers",
107
+ };
108
+
109
+ const AUDIENCE_GROUPS = [
110
+ {
111
+ title: "For Stakeholders",
112
+ keys: ["executive_summary", "business_domains", "data_flows"],
113
+ },
114
+ {
115
+ title: "For Engineers",
116
+ keys: [
117
+ "architecture_overview", "module_catalog", "api_surface",
118
+ "route_map", "system_map", "graphql_schema", "type_graph",
119
+ "dependency_graph", "architecture_drift", "security_hotspots", "code_health",
120
+ ],
121
+ },
122
+ {
123
+ title: "For New Contributors",
124
+ keys: ["developer_onboarding", "system_overview"],
125
+ },
126
+ {
127
+ title: "Change Tracking",
128
+ keys: ["change_impact", "arch_diff"],
129
+ },
130
+ ];
131
+
132
+ function displayTitle(key) {
133
+ return PAGE_TITLES[key] || String(key).replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
134
+ }
135
+
136
+ /**
137
+ * Reduce a note title to a single path segment Obsidian and Windows can store.
138
+ */
139
+ export function sanitizeNoteName(name) {
140
+ const cleaned = String(name ?? "")
141
+ .replace(/[<>:"|?*\u0000-\u001f\\/]/g, " ")
142
+ .replace(/\.\.+/g, " ")
143
+ .replace(/\s+/g, " ")
144
+ .trim()
145
+ .replace(/[. ]+$/g, "")
146
+ .replace(/^\.+/, "");
147
+ if (!cleaned || cleaned === "." || cleaned === "..") return "Note";
148
+ return cleaned;
149
+ }
150
+
151
+ function noteStemForKey(key, useWikilinks) {
152
+ if (!useWikilinks) {
153
+ const file = safeFileName(`${key}.md`, "document.md");
154
+ return sanitizeNoteName(file.replace(/\.md$/i, ""));
155
+ }
156
+ return sanitizeNoteName(displayTitle(key));
157
+ }
158
+
159
+ function allocateNotes(keys, useWikilinks) {
160
+ const used = new Set([INDEX_NOTE]);
161
+ const notes = [];
162
+ for (const key of keys) {
163
+ const base = noteStemForKey(key, useWikilinks);
164
+ let name = base;
165
+ let n = 2;
166
+ while (used.has(name)) {
167
+ const suffix = n === 2 ? key : `${key} ${n}`;
168
+ name = sanitizeNoteName(`${base} (${suffix})`);
169
+ n += 1;
170
+ if (n > 50) {
171
+ throw new Error(`Could not allocate a unique Obsidian note name for ${key}`);
172
+ }
173
+ }
174
+ used.add(name);
175
+ notes.push({ key, name, fileName: `${name}.md` });
176
+ }
177
+ return notes;
178
+ }
179
+
180
+ function yamlQuote(value) {
181
+ return JSON.stringify(String(value));
182
+ }
183
+
184
+ function buildFrontmatter(fields) {
185
+ const lines = ["---"];
186
+ for (const [key, value] of Object.entries(fields)) {
187
+ if (value == null) continue;
188
+ if (Array.isArray(value)) {
189
+ const items = value.filter((item) => item != null && String(item).length > 0);
190
+ if (items.length === 0) continue;
191
+ lines.push(`${key}:`);
192
+ for (const item of items) lines.push(` - ${yamlQuote(item)}`);
193
+ } else if (typeof value === "boolean" || typeof value === "number") {
194
+ lines.push(`${key}: ${value}`);
195
+ } else {
196
+ lines.push(`${key}: ${yamlQuote(value)}`);
197
+ }
198
+ }
199
+ lines.push("---", "");
200
+ return lines.join("\n");
201
+ }
202
+
203
+ function ensureTrailingNewline(text) {
204
+ return text.endsWith("\n") ? text : `${text}\n`;
205
+ }
206
+
207
+ const MAX_LINK_LABEL = 500;
208
+ const MAX_LINK_TARGET = 2048;
209
+
210
+ /**
211
+ * Rewrite inline markdown links to Obsidian wikilinks.
212
+ * Fenced code blocks are left untouched. http(s) and other URLs stay as links.
213
+ *
214
+ * The scan is a single forward pass. Label and target lengths are capped so
215
+ * a hostile document cannot trigger polynomial backtracking.
216
+ * @param {string} markdown
217
+ * @param {Map<string, string>} linkMap basename/stem → note name
218
+ */
219
+ export function rewriteMarkdownToWikilinks(markdown, linkMap) {
220
+ const source = String(markdown ?? "");
221
+ const fences = fencedCodeRanges(source);
222
+ const parts = [];
223
+ let i = 0;
224
+ let fenceIndex = 0;
225
+
226
+ while (i < source.length) {
227
+ if (fenceIndex < fences.length && i === fences[fenceIndex][0]) {
228
+ parts.push(source.slice(fences[fenceIndex][0], fences[fenceIndex][1]));
229
+ i = fences[fenceIndex][1];
230
+ fenceIndex += 1;
231
+ continue;
232
+ }
233
+
234
+ const nextFence = fenceIndex < fences.length ? fences[fenceIndex][0] : source.length;
235
+ const link = scanInlineLink(source, i);
236
+ if (link && link.end <= nextFence) {
237
+ const converted = toWikilink(link.embed, link.label, link.target, linkMap);
238
+ parts.push(converted ?? link.raw);
239
+ i = link.end;
240
+ continue;
241
+ }
242
+
243
+ parts.push(source[i]);
244
+ i += 1;
245
+ }
246
+
247
+ return parts.join("");
248
+ }
249
+
250
+ /**
251
+ * Pairs of ``` fences, as [start, end) indexes. An unclosed fence is not a range.
252
+ * Each search starts at the previous match, so this is linear in the input length.
253
+ */
254
+ function fencedCodeRanges(source) {
255
+ const ranges = [];
256
+ let i = 0;
257
+ while (i < source.length) {
258
+ const open = source.indexOf("```", i);
259
+ if (open === -1) break;
260
+ const close = source.indexOf("```", open + 3);
261
+ if (close === -1) break;
262
+ ranges.push([open, close + 3]);
263
+ i = close + 3;
264
+ }
265
+ return ranges;
266
+ }
267
+
268
+ /**
269
+ * Match `![label](target)` or `[label](target)` at `index`.
270
+ * Label: 0–500 chars, no brackets or newlines. Target: 1–2048 chars, no
271
+ * parentheses or whitespace. Returns null when the text at `index` is not a link.
272
+ */
273
+ function scanInlineLink(source, index) {
274
+ let cursor = index;
275
+ let embed = false;
276
+ if (source[cursor] === "!" && source[cursor + 1] === "[") {
277
+ embed = true;
278
+ cursor += 1;
279
+ }
280
+ if (source[cursor] !== "[") return null;
281
+
282
+ const labelStart = cursor + 1;
283
+ const labelEnd = readLinkLabel(source, labelStart);
284
+ if (labelEnd < 0 || source[labelEnd + 1] !== "(") return null;
285
+
286
+ const targetStart = labelEnd + 2;
287
+ const targetEnd = readLinkTarget(source, targetStart);
288
+ if (targetEnd < 0) return null;
289
+
290
+ return {
291
+ embed,
292
+ label: source.slice(labelStart, labelEnd),
293
+ target: source.slice(targetStart, targetEnd),
294
+ raw: source.slice(index, targetEnd + 1),
295
+ end: targetEnd + 1,
296
+ };
297
+ }
298
+
299
+ function readLinkLabel(source, start) {
300
+ const limit = Math.min(source.length, start + MAX_LINK_LABEL);
301
+ let i = start;
302
+ while (i < limit) {
303
+ const ch = source[i];
304
+ if (ch === "]" || ch === "[" || ch === "\n") break;
305
+ i += 1;
306
+ }
307
+ if (i >= source.length || source[i] !== "]") return -1;
308
+ return i;
309
+ }
310
+
311
+ function readLinkTarget(source, start) {
312
+ const limit = Math.min(source.length, start + MAX_LINK_TARGET);
313
+ let i = start;
314
+ while (i < limit) {
315
+ const ch = source[i];
316
+ if (ch === "(" || ch === ")" || ch.charCodeAt(0) <= 32) break;
317
+ i += 1;
318
+ }
319
+ if (i === start || i >= source.length || source[i] !== ")") return -1;
320
+ return i;
321
+ }
322
+
323
+ function toWikilink(isEmbed, label, rawTarget, linkMap) {
324
+ const target = String(rawTarget ?? "").trim();
325
+ if (!target) return null;
326
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) return null;
327
+
328
+ if (target.startsWith("#")) {
329
+ const heading = safeHeading(target.slice(1));
330
+ if (!heading) return null;
331
+ return isEmbed ? `![[#${heading}]]` : `[[#${heading}]]`;
332
+ }
333
+
334
+ const hashAt = target.indexOf("#");
335
+ const pathPart = hashAt === -1 ? target : target.slice(0, hashAt);
336
+ const hash = hashAt === -1 ? "" : target.slice(hashAt + 1);
337
+ let decoded = pathPart;
338
+ try {
339
+ decoded = decodeURIComponent(pathPart);
340
+ } catch {
341
+ decoded = pathPart;
342
+ }
343
+ if (path.isAbsolute(decoded) || /^[A-Za-z]:[\\/]/.test(decoded)) return null;
344
+
345
+ const base = path.posix.basename(decoded.replace(/\\/g, "/"));
346
+ if (!base || base === "." || base === "..") return null;
347
+
348
+ const stem = base.replace(/\.md$/i, "");
349
+ const known = lookupNote(linkMap, base) || lookupNote(linkMap, stem);
350
+ const isMarkdown = /\.md$/i.test(base);
351
+
352
+ if (!known && !isMarkdown && !isEmbed) return null;
353
+ if (!known && isEmbed && !isMarkdown) {
354
+ return `![[${base}]]`;
355
+ }
356
+
357
+ const dest = known || sanitizeNoteName(stem);
358
+ const heading = hash ? safeHeading(hash) : "";
359
+ const targetNote = heading ? `${dest}#${heading}` : dest;
360
+ if (isEmbed) return `![[${targetNote}]]`;
361
+ const alias = String(label ?? "").trim();
362
+ if (!alias || alias === dest || alias === targetNote) return `[[${targetNote}]]`;
363
+ return `[[${targetNote}|${alias.replaceAll("|", "\\|")}]]`;
364
+ }
365
+
366
+ function safeHeading(raw) {
367
+ let decoded = raw;
368
+ try {
369
+ decoded = decodeURIComponent(raw);
370
+ } catch {
371
+ decoded = raw;
372
+ }
373
+ return decoded.replace(/[\]|#^]/g, "").trim();
374
+ }
375
+
376
+ function lookupNote(linkMap, token) {
377
+ if (!token) return null;
378
+ return linkMap.get(token) || linkMap.get(token.toLowerCase()) || null;
379
+ }
380
+
381
+ function buildLinkMap(notes) {
382
+ const map = new Map();
383
+ for (const note of notes) {
384
+ const aliases = [note.name, note.fileName, note.key, `${note.key}.md`, displayTitle(note.key)];
385
+ for (const alias of aliases) {
386
+ map.set(alias, note.name);
387
+ map.set(alias.toLowerCase(), note.name);
388
+ }
389
+ }
390
+ return map;
391
+ }
392
+
393
+ function defaultFolder(cfg) {
394
+ const raw = cfg.project?.name || "project";
395
+ const slug = sanitizeNoteName(String(raw).replace(/^\.+/, "")) || "project";
396
+ return path.join("RepoLens", slug);
397
+ }
398
+
399
+ function assertRelativeFolder(folder) {
400
+ if (typeof folder !== "string" || folder.trim() === "") {
401
+ throw new Error("obsidian.folder must be a non-empty relative path inside the vault");
402
+ }
403
+ if (folder.includes("\0")) {
404
+ throw new Error("obsidian.folder contains a null byte");
405
+ }
406
+ if (path.isAbsolute(folder) || /^[A-Za-z]:[\\/]/.test(folder)) {
407
+ throw new Error(`obsidian.folder must stay inside the vault (received absolute path ${folder})`);
408
+ }
409
+ const parts = path.normalize(folder).split(path.sep).filter((part) => part && part !== ".");
410
+ if (parts.length === 0 || parts.some((part) => part === "..")) {
411
+ throw new Error(`obsidian.folder escapes the vault: ${folder}`);
412
+ }
413
+ return parts.join(path.sep);
414
+ }
415
+
416
+ /**
417
+ * Resolve the vault directory. Relative paths are resolved from the repo root.
418
+ * The directory must already exist. The returned path is its realpath.
419
+ */
420
+ export async function resolveVaultRoot(cfg, vault) {
421
+ if (typeof vault !== "string" || vault.trim() === "") {
422
+ throw new Error(
423
+ "obsidian.vault is required. Set it to your Obsidian vault path in .repolens.yml"
424
+ );
425
+ }
426
+ if (vault.includes("\0")) {
427
+ throw new Error("obsidian.vault contains a null byte");
428
+ }
429
+ const base = cfg.__repoRoot || process.cwd();
430
+ const lexical = path.resolve(base, vault);
431
+ let real;
432
+ try {
433
+ real = await fs.realpath(lexical);
434
+ } catch (err) {
435
+ if (err.code === "ENOENT") {
436
+ throw new Error(`Obsidian vault does not exist: ${lexical}`);
437
+ }
438
+ throw err;
439
+ }
440
+ const stat = await fs.stat(real);
441
+ if (!stat.isDirectory()) {
442
+ throw new Error(`Obsidian vault is not a directory: ${lexical}`);
443
+ }
444
+ return real;
445
+ }
446
+
447
+ /**
448
+ * Resolve a folder inside the vault and create it.
449
+ * Symlinks that point outside the vault are rejected.
450
+ */
451
+ export async function resolveVaultFolder(vaultRoot, folder) {
452
+ const relative = assertRelativeFolder(folder);
453
+ const lexical = path.resolve(vaultRoot, relative);
454
+ if (!isInsideRoot(vaultRoot, lexical)) {
455
+ throw new Error(`obsidian.folder escapes the vault: ${folder}`);
456
+ }
457
+
458
+ let current = vaultRoot;
459
+ for (const part of relative.split(path.sep)) {
460
+ current = path.join(current, part);
461
+ let listed;
462
+ try {
463
+ listed = await fs.lstat(current);
464
+ } catch (err) {
465
+ if (err.code === "ENOENT") break;
466
+ throw err;
467
+ }
468
+ if (listed.isSymbolicLink()) {
469
+ const real = await fs.realpath(current);
470
+ if (!isInsideRoot(vaultRoot, real)) {
471
+ throw new Error(`obsidian.folder escapes the vault through a symlink: ${folder}`);
472
+ }
473
+ } else if (!listed.isDirectory()) {
474
+ throw new Error(`obsidian.folder is not a directory: ${folder}`);
475
+ }
476
+ }
477
+
478
+ await fs.mkdir(lexical, { recursive: true });
479
+ const realFolder = await fs.realpath(lexical);
480
+ if (!isInsideRoot(vaultRoot, realFolder)) {
481
+ throw new Error(`obsidian.folder escapes the vault: ${folder}`);
482
+ }
483
+ return realFolder;
484
+ }
485
+
486
+ function containedFile(root, fileName) {
487
+ const base = safeFileName(fileName, "");
488
+ if (!base || base === MANIFEST_FILE) {
489
+ throw new Error(`Refusing to write an unsafe note name: ${fileName}`);
490
+ }
491
+ const resolved = path.resolve(root, base);
492
+ if (!isInsideRoot(root, resolved) || path.basename(resolved) !== base) {
493
+ throw new Error(`Note path escapes the vault folder: ${fileName}`);
494
+ }
495
+ return resolved;
496
+ }
497
+
498
+ async function writeIfChanged(filePath, content) {
499
+ let listed;
500
+ try {
501
+ listed = await fs.lstat(filePath);
502
+ } catch (err) {
503
+ if (err.code !== "ENOENT") throw err;
504
+ }
505
+ if (listed?.isSymbolicLink()) {
506
+ throw new Error(`Refusing to write through a symlink: ${filePath}`);
507
+ }
508
+ if (listed && !listed.isFile()) {
509
+ throw new Error(`Refusing to overwrite a non-file: ${filePath}`);
510
+ }
511
+ if (listed?.isFile()) {
512
+ const existing = await fs.readFile(filePath, "utf8");
513
+ if (existing === content) return false;
514
+ }
515
+ await fs.writeFile(filePath, content, "utf8");
516
+ return true;
517
+ }
518
+
519
+ async function readManifest(folder) {
520
+ const manifestPath = path.join(folder, MANIFEST_FILE);
521
+ try {
522
+ const raw = await fs.readFile(manifestPath, "utf8");
523
+ const parsed = JSON.parse(raw);
524
+ return Array.isArray(parsed?.files) ? parsed.files.filter((item) => typeof item === "string") : [];
525
+ } catch (err) {
526
+ if (err.code !== "ENOENT") {
527
+ warn(`Ignoring unreadable Obsidian manifest: ${err.message}`);
528
+ }
529
+ return [];
530
+ }
531
+ }
532
+
533
+ function manifestPathFor(folder) {
534
+ const resolved = path.resolve(folder, MANIFEST_FILE);
535
+ if (!isInsideRoot(folder, resolved)) {
536
+ throw new Error("Obsidian manifest path escapes the vault folder");
537
+ }
538
+ return resolved;
539
+ }
540
+
541
+ function removableManagedFile(folder, rel) {
542
+ if (typeof rel !== "string" || rel.includes("\0") || path.isAbsolute(rel)) return null;
543
+ const parts = rel.split(/[\\/]/).filter(Boolean);
544
+ if (parts.length !== 1 || parts[0] === "." || parts[0] === ".." || parts[0] === MANIFEST_FILE) {
545
+ return null;
546
+ }
547
+ const resolved = path.resolve(folder, parts[0]);
548
+ if (!isInsideRoot(folder, resolved)) return null;
549
+ return resolved;
550
+ }
551
+
552
+ async function syncManifest(vaultRoot, folder, fileNames) {
553
+ const previous = await readManifest(folder);
554
+ const next = new Set(fileNames);
555
+ let removed = 0;
556
+ for (const rel of previous) {
557
+ if (next.has(rel)) continue;
558
+ const target = removableManagedFile(folder, rel);
559
+ if (!target || !isInsideRoot(vaultRoot, target)) continue;
560
+ let listed;
561
+ try {
562
+ listed = await fs.lstat(target);
563
+ } catch (err) {
564
+ if (err.code === "ENOENT") continue;
565
+ throw err;
566
+ }
567
+ if (listed.isSymbolicLink() || !listed.isFile()) continue;
568
+ await fs.unlink(target);
569
+ removed += 1;
570
+ }
571
+
572
+ const body = `${JSON.stringify({ version: 1, files: [...fileNames].sort() }, null, 2)}\n`;
573
+ await writeIfChanged(manifestPathFor(folder), body);
574
+ return removed;
575
+ }
576
+
577
+ function renderPage(note, markdown, options) {
578
+ const body = options.useWikilinks
579
+ ? rewriteMarkdownToWikilinks(markdown, options.linkMap)
580
+ : String(markdown);
581
+ const backlink = options.useIndex
582
+ ? (options.useWikilinks ? `← [[${INDEX_NOTE}|Index]]` : `← [Index](${INDEX_NOTE}.md)`)
583
+ : "";
584
+ const parts = [];
585
+ if (options.useFrontmatter) {
586
+ const aliases = [note.key];
587
+ const title = displayTitle(note.key);
588
+ if (title !== note.name) aliases.push(title);
589
+ parts.push(buildFrontmatter({
590
+ title: note.name,
591
+ aliases,
592
+ tags: ["repolens", PAGE_TAGS[note.key]].filter(Boolean),
593
+ type: note.key,
594
+ project: options.projectName,
595
+ branch: options.branch,
596
+ publisher: "repolens",
597
+ }));
598
+ }
599
+ if (backlink) parts.push(`${backlink}\n`);
600
+ parts.push(body.trimEnd());
601
+ return ensureTrailingNewline(parts.join("\n"));
602
+ }
603
+
604
+ function renderIndex(notes, options) {
605
+ const byKey = new Map(notes.map((note) => [note.key, note]));
606
+ const listed = new Set();
607
+ const sections = [];
608
+
609
+ for (const group of AUDIENCE_GROUPS) {
610
+ const items = group.keys.filter((key) => byKey.has(key));
611
+ if (items.length === 0) continue;
612
+ sections.push(`## ${group.title}`, "");
613
+ for (const key of items) {
614
+ listed.add(key);
615
+ sections.push(indexItem(byKey.get(key), options.useWikilinks));
616
+ }
617
+ sections.push("");
618
+ }
619
+
620
+ const custom = notes.filter((note) => !listed.has(note.key));
621
+ if (custom.length > 0) {
622
+ sections.push("## Custom", "");
623
+ for (const note of custom) sections.push(indexItem(note, options.useWikilinks));
624
+ sections.push("");
625
+ }
626
+
627
+ const intro = [
628
+ `# ${options.projectName}`,
629
+ "",
630
+ "Architecture notes published by RepoLens into this Obsidian vault.",
631
+ "Re-running publish updates these notes in place.",
632
+ "",
633
+ ];
634
+
635
+ const parts = [];
636
+ if (options.useFrontmatter) {
637
+ const aliases = options.projectName && options.projectName !== INDEX_NOTE
638
+ ? [options.projectName]
639
+ : [];
640
+ parts.push(buildFrontmatter({
641
+ title: INDEX_NOTE,
642
+ aliases,
643
+ tags: ["repolens", "index"],
644
+ type: "index",
645
+ project: options.projectName,
646
+ branch: options.branch,
647
+ publisher: "repolens",
648
+ }));
649
+ }
650
+ parts.push([...intro, ...sections].join("\n").trimEnd());
651
+ return ensureTrailingNewline(parts.join("\n"));
652
+ }
653
+
654
+ function indexItem(note, useWikilinks) {
655
+ const description = PAGE_DESCRIPTIONS[note.key] || "Generated by RepoLens.";
656
+ const link = useWikilinks ? `[[${note.name}]]` : `[${note.name}](${note.fileName})`;
657
+ return `- ${link} — ${description}`;
658
+ }
659
+
660
+ function orderedKeys(renderedPages) {
661
+ const present = Object.entries(renderedPages)
662
+ .filter(([, markdown]) => typeof markdown === "string" && markdown.trim() !== "")
663
+ .map(([key]) => key);
664
+ const known = PAGE_ORDER.filter((key) => present.includes(key));
665
+ const custom = present.filter((key) => !PAGE_ORDER.includes(key));
666
+ return [...known, ...custom];
667
+ }
668
+
669
+ /**
670
+ * Publish rendered pages into an Obsidian vault folder.
671
+ * @returns {Promise<{ folder: string, files: string[], removed: number, changed: number }>}
672
+ */
673
+ export async function publishToObsidian(cfg, renderedPages) {
674
+ const obsidian = cfg.obsidian || {};
675
+ const vaultRoot = await resolveVaultRoot(cfg, obsidian.vault);
676
+ const folderRel = obsidian.folder == null || obsidian.folder === ""
677
+ ? defaultFolder(cfg)
678
+ : obsidian.folder;
679
+ const folder = await resolveVaultFolder(vaultRoot, folderRel);
680
+
681
+ const useFrontmatter = obsidian.frontmatter !== false;
682
+ const useWikilinks = obsidian.wikilinks !== false;
683
+ const useIndex = obsidian.index !== false;
684
+ const projectName = cfg.project?.name || "RepoLens";
685
+ const branch = getCurrentBranch();
686
+
687
+ const notes = allocateNotes(orderedKeys(renderedPages || {}), useWikilinks);
688
+ const linkMap = buildLinkMap(notes);
689
+ const fileNames = [];
690
+ let changed = 0;
691
+
692
+ for (const note of notes) {
693
+ const filePath = containedFile(folder, note.fileName);
694
+ const content = renderPage(note, renderedPages[note.key], {
695
+ useFrontmatter,
696
+ useWikilinks,
697
+ useIndex,
698
+ linkMap,
699
+ projectName,
700
+ branch,
701
+ });
702
+ if (await writeIfChanged(filePath, content)) changed += 1;
703
+ fileNames.push(note.fileName);
704
+ }
705
+
706
+ if (useIndex) {
707
+ const indexPath = containedFile(folder, `${INDEX_NOTE}.md`);
708
+ const content = renderIndex(notes, { useFrontmatter, useWikilinks, projectName, branch });
709
+ if (await writeIfChanged(indexPath, content)) changed += 1;
710
+ fileNames.push(`${INDEX_NOTE}.md`);
711
+ }
712
+
713
+ const removed = await syncManifest(vaultRoot, folder, fileNames);
714
+ info(`obsidian notes written to ${folder} (${fileNames.length} notes, ${changed} updated, ${removed} removed)`);
715
+ return { folder, files: fileNames, removed, changed };
716
+ }
@@ -118,6 +118,22 @@ export function shouldPublishToGitHubWiki(config, currentBranch = getCurrentBran
118
118
  return matchesAnyBranch(config.github_wiki.branches, currentBranch);
119
119
  }
120
120
 
121
+ /**
122
+ * Check if current branch should publish to an Obsidian vault.
123
+ * Based on config.obsidian.branches setting.
124
+ */
125
+ export function shouldPublishToObsidian(config, currentBranch = getCurrentBranch()) {
126
+ if (!config.obsidian) {
127
+ return true;
128
+ }
129
+
130
+ if (!config.obsidian.branches || config.obsidian.branches.length === 0) {
131
+ return true;
132
+ }
133
+
134
+ return matchesAnyBranch(config.obsidian.branches, currentBranch);
135
+ }
136
+
121
137
  /**
122
138
  * Get branch-qualified page title
123
139
  */
@@ -49,6 +49,12 @@ export function validateConfig(config) {
49
49
  warnings.push(...notionWarnings);
50
50
  }
51
51
 
52
+ // Validate Obsidian configuration (local vault path; no credentials)
53
+ if (config.obsidian) {
54
+ const obsidianErrors = validateObsidianConfig(config.obsidian);
55
+ errors.push(...obsidianErrors);
56
+ }
57
+
52
58
  // Validate domains (if present)
53
59
  if (config.domains) {
54
60
  const domainErrors = validateDomains(config.domains);
@@ -142,7 +148,7 @@ function validatePublishers(publishers) {
142
148
  return errors;
143
149
  }
144
150
 
145
- const validPublishers = ["notion", "markdown", "confluence", "github_wiki"];
151
+ const validPublishers = ["notion", "markdown", "confluence", "github_wiki", "obsidian"];
146
152
 
147
153
  if (Array.isArray(publishers)) {
148
154
  for (const pub of publishers) {
@@ -196,6 +202,62 @@ function validateNotionConfig(notion) {
196
202
  return errors;
197
203
  }
198
204
 
205
+ /**
206
+ * Validate Obsidian vault configuration.
207
+ * The vault may be absolute (it lives outside the repo). The folder must
208
+ * stay inside that vault.
209
+ */
210
+ function validateObsidianConfig(obsidian) {
211
+ const errors = [];
212
+
213
+ if (typeof obsidian !== "object" || Array.isArray(obsidian)) {
214
+ errors.push("obsidian must be an object");
215
+ return errors;
216
+ }
217
+
218
+ if (obsidian.vault !== undefined) {
219
+ if (typeof obsidian.vault !== "string" || obsidian.vault.trim() === "") {
220
+ errors.push("obsidian.vault must be a non-empty string");
221
+ } else if (obsidian.vault.includes("\0")) {
222
+ errors.push("obsidian.vault contains a null byte");
223
+ }
224
+ }
225
+
226
+ if (obsidian.folder !== undefined) {
227
+ if (typeof obsidian.folder !== "string" || obsidian.folder.trim() === "") {
228
+ errors.push("obsidian.folder must be a non-empty string");
229
+ } else if (obsidian.folder.includes("\0")) {
230
+ errors.push("obsidian.folder contains a null byte");
231
+ } else if (
232
+ path.isAbsolute(obsidian.folder) ||
233
+ /^[A-Za-z]:[\\/]/.test(obsidian.folder) ||
234
+ obsidian.folder.split(/[\\/]/).some((part) => part === "..")
235
+ ) {
236
+ errors.push("obsidian.folder must be a relative path inside the vault");
237
+ }
238
+ }
239
+
240
+ if (obsidian.branches !== undefined) {
241
+ if (!Array.isArray(obsidian.branches)) {
242
+ errors.push("obsidian.branches must be an array");
243
+ } else {
244
+ for (const branch of obsidian.branches) {
245
+ if (typeof branch !== "string") {
246
+ errors.push(`Invalid branch name: ${branch} (must be string)`);
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ for (const flag of ["index", "frontmatter", "wikilinks"]) {
253
+ if (obsidian[flag] !== undefined && typeof obsidian[flag] !== "boolean") {
254
+ errors.push(`obsidian.${flag} must be a boolean`);
255
+ }
256
+ }
257
+
258
+ return errors;
259
+ }
260
+
199
261
  /**
200
262
  * Check for Notion configuration warnings
201
263
  */