@chappibunny/repolens 1.12.2 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/README.md +25 -36
- package/bin/repolens.js +21 -1
- package/package.json +20 -11
- package/src/ai/provider.js +1 -2
- package/src/analyzers/monorepo-detector.js +8 -20
- package/src/cli-args.js +60 -0
- package/src/cli.js +80 -100
- package/src/core/config-schema.js +47 -1
- package/src/core/config.js +5 -2
- package/src/core/diff.js +43 -20
- package/src/core/scan.js +24 -4
- package/src/delivery/comment.js +5 -2
- package/src/docs/write-doc-set.js +9 -7
- package/src/doctor.js +16 -3
- package/src/help.js +54 -0
- package/src/index.js +13 -0
- package/src/init.js +99 -62
- package/src/integrations/discord.js +16 -2
- package/src/migrate.js +2 -2
- package/src/publishers/confluence.js +17 -3
- package/src/publishers/github-wiki.js +61 -32
- package/src/publishers/index.js +24 -9
- package/src/publishers/markdown.js +3 -2
- package/src/publishers/notion.js +10 -4
- package/src/publishers/obsidian.js +716 -0
- package/src/utils/branch.js +37 -42
- package/src/utils/github-repo.js +8 -0
- package/src/utils/interactive.js +20 -0
- package/src/utils/logger.js +5 -1
- package/src/utils/retry.js +30 -6
- package/src/utils/safe-path.js +50 -0
- package/src/utils/secrets.js +3 -1
- package/src/utils/telemetry.js +34 -6
- package/src/utils/update-check.js +23 -6
- package/src/utils/validate.js +70 -1
- package/src/utils/yaml.js +27 -0
- package/src/watch.js +1 -1
- package/docs/AI.md +0 -329
- package/docs/ARCHITECTURE.md +0 -145
- package/docs/CONFIGURATION.md +0 -121
- package/docs/DEVELOPMENT.md +0 -79
- package/docs/ENVIRONMENT.md +0 -79
- package/docs/INSTALLATION.md +0 -45
- package/docs/KNOWN_ISSUES.md +0 -309
- package/docs/MIGRATION.md +0 -311
- package/docs/ONBOARDING.md +0 -382
- package/docs/PRODUCTION_CHECKLIST.md +0 -470
- package/docs/RELEASE.md +0 -76
- package/docs/ROADMAP.md +0 -151
- package/docs/STABILITY.md +0 -265
- package/docs/TELEMETRY.md +0 -166
- package/docs/TROUBLESHOOTING.md +0 -377
package/src/utils/branch.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Detect the current git branch from various sources
|
|
@@ -22,7 +22,7 @@ export function getCurrentBranch() {
|
|
|
22
22
|
|
|
23
23
|
// 4. Try git command
|
|
24
24
|
try {
|
|
25
|
-
const branch =
|
|
25
|
+
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
26
26
|
encoding: "utf8",
|
|
27
27
|
stdio: ["pipe", "pipe", "ignore"]
|
|
28
28
|
}).trim();
|
|
@@ -48,6 +48,24 @@ export function normalizeBranchName(branch) {
|
|
|
48
48
|
.toLowerCase();
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Match a branch name against an exact value or a simple `*` glob.
|
|
53
|
+
* Regex metacharacters in the pattern are escaped so a bad config cannot
|
|
54
|
+
* throw or widen the match.
|
|
55
|
+
*/
|
|
56
|
+
export function branchMatches(pattern, branch) {
|
|
57
|
+
if (pattern === branch) return true;
|
|
58
|
+
if (typeof pattern !== "string" || typeof branch !== "string" || !pattern.includes("*")) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
62
|
+
return new RegExp(`^${escaped}$`).test(branch);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function matchesAnyBranch(patterns, currentBranch) {
|
|
66
|
+
return patterns.some((pattern) => branchMatches(pattern, currentBranch));
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
/**
|
|
52
70
|
* Check if current branch should publish to Notion
|
|
53
71
|
* Based on config.notion.branches setting
|
|
@@ -63,21 +81,7 @@ export function shouldPublishToNotion(config, currentBranch = getCurrentBranch()
|
|
|
63
81
|
return true;
|
|
64
82
|
}
|
|
65
83
|
|
|
66
|
-
|
|
67
|
-
return config.notion.branches.some(pattern => {
|
|
68
|
-
// Exact match
|
|
69
|
-
if (pattern === currentBranch) {
|
|
70
|
-
return true;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Wildcard pattern (simple glob)
|
|
74
|
-
if (pattern.includes("*")) {
|
|
75
|
-
const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
|
|
76
|
-
return regex.test(currentBranch);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return false;
|
|
80
|
-
});
|
|
84
|
+
return matchesAnyBranch(config.notion.branches, currentBranch);
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
/**
|
|
@@ -95,21 +99,7 @@ export function shouldPublishToConfluence(config, currentBranch = getCurrentBran
|
|
|
95
99
|
return true;
|
|
96
100
|
}
|
|
97
101
|
|
|
98
|
-
|
|
99
|
-
return config.confluence.branches.some(pattern => {
|
|
100
|
-
// Exact match
|
|
101
|
-
if (pattern === currentBranch) {
|
|
102
|
-
return true;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Wildcard pattern (simple glob)
|
|
106
|
-
if (pattern.includes("*")) {
|
|
107
|
-
const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
|
|
108
|
-
return regex.test(currentBranch);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return false;
|
|
112
|
-
});
|
|
102
|
+
return matchesAnyBranch(config.confluence.branches, currentBranch);
|
|
113
103
|
}
|
|
114
104
|
|
|
115
105
|
/**
|
|
@@ -125,18 +115,23 @@ export function shouldPublishToGitHubWiki(config, currentBranch = getCurrentBran
|
|
|
125
115
|
return true;
|
|
126
116
|
}
|
|
127
117
|
|
|
128
|
-
return config.github_wiki.branches
|
|
129
|
-
|
|
130
|
-
return true;
|
|
131
|
-
}
|
|
118
|
+
return matchesAnyBranch(config.github_wiki.branches, currentBranch);
|
|
119
|
+
}
|
|
132
120
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
}
|
|
137
129
|
|
|
138
|
-
|
|
139
|
-
|
|
130
|
+
if (!config.obsidian.branches || config.obsidian.branches.length === 0) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return matchesAnyBranch(config.obsidian.branches, currentBranch);
|
|
140
135
|
}
|
|
141
136
|
|
|
142
137
|
/**
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
2
|
+
|
|
3
|
+
export function assertGitHubRepo(repo) {
|
|
4
|
+
if (typeof repo !== "string" || !GITHUB_REPO_PATTERN.test(repo)) {
|
|
5
|
+
throw new Error(`Invalid GitHub repository "${repo}". Expected owner/name.`);
|
|
6
|
+
}
|
|
7
|
+
return repo;
|
|
8
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions where RepoLens must not wait on a prompt.
|
|
3
|
+
* Covers CI providers, explicit opt-out, the test runner, and pipes.
|
|
4
|
+
*/
|
|
5
|
+
export function isNonInteractiveEnvironment() {
|
|
6
|
+
if (
|
|
7
|
+
process.env.CI ||
|
|
8
|
+
process.env.GITHUB_ACTIONS ||
|
|
9
|
+
process.env.GITLAB_CI ||
|
|
10
|
+
process.env.CIRCLECI ||
|
|
11
|
+
process.env.JENKINS_HOME ||
|
|
12
|
+
process.env.CODEBUILD_BUILD_ID ||
|
|
13
|
+
process.env.REPOLENS_NON_INTERACTIVE ||
|
|
14
|
+
process.env.NODE_ENV === "test" ||
|
|
15
|
+
process.env.VITEST
|
|
16
|
+
) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
return !process.stdin.isTTY;
|
|
20
|
+
}
|
package/src/utils/logger.js
CHANGED
|
@@ -59,7 +59,11 @@ function sanitizeArgs(args) {
|
|
|
59
59
|
return sanitizeSecrets(arg);
|
|
60
60
|
}
|
|
61
61
|
if (typeof arg === "object" && arg !== null) {
|
|
62
|
-
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(sanitizeSecrets(JSON.stringify(arg)));
|
|
64
|
+
} catch {
|
|
65
|
+
return "[unserializable]";
|
|
66
|
+
}
|
|
63
67
|
}
|
|
64
68
|
return arg;
|
|
65
69
|
});
|
package/src/utils/retry.js
CHANGED
|
@@ -8,6 +8,30 @@ function isRetryableStatus(status) {
|
|
|
8
8
|
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
const MAX_RETRY_AFTER_MS = 60_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Honour Retry-After when it is delta-seconds or an HTTP date.
|
|
15
|
+
* Unparseable values fall back to exponential backoff. Delays are capped
|
|
16
|
+
* so a hostile header cannot stall the process.
|
|
17
|
+
*/
|
|
18
|
+
export function retryDelayMs(retryAfterHeader, attempt, baseDelayMs, maxDelayMs) {
|
|
19
|
+
const fallback = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
20
|
+
if (retryAfterHeader == null || retryAfterHeader === "") return fallback;
|
|
21
|
+
|
|
22
|
+
const seconds = Number(retryAfterHeader);
|
|
23
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
24
|
+
return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const when = Date.parse(retryAfterHeader);
|
|
28
|
+
if (Number.isFinite(when)) {
|
|
29
|
+
return Math.min(Math.max(0, when - Date.now()), MAX_RETRY_AFTER_MS);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
11
35
|
export async function fetchWithRetry(url, options = {}, config = {}) {
|
|
12
36
|
const {
|
|
13
37
|
retries = 3,
|
|
@@ -37,12 +61,12 @@ export async function fetchWithRetry(url, options = {}, config = {}) {
|
|
|
37
61
|
return response;
|
|
38
62
|
}
|
|
39
63
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
64
|
+
const delay = retryDelayMs(
|
|
65
|
+
response.headers.get("retry-after"),
|
|
66
|
+
attempt,
|
|
67
|
+
baseDelayMs,
|
|
68
|
+
maxDelayMs
|
|
69
|
+
);
|
|
46
70
|
|
|
47
71
|
warn(`${label} failed with retryable status ${response.status}. Retrying in ${delay}ms (attempt ${attempt + 1}/${retries + 1})`);
|
|
48
72
|
await sleep(delay);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True when `target` is `root` or a path inside it.
|
|
5
|
+
* Comparison uses resolved paths so `..`, absolute segments, and
|
|
6
|
+
* platform separators cannot escape the root.
|
|
7
|
+
*/
|
|
8
|
+
export function isInsideRoot(root, target) {
|
|
9
|
+
const rootResolved = path.resolve(root);
|
|
10
|
+
const targetResolved = path.resolve(target);
|
|
11
|
+
const rel = path.relative(rootResolved, targetResolved);
|
|
12
|
+
return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a documentation output directory and keep it inside `root`.
|
|
17
|
+
* Absolute paths and `..` escapes are rejected. An empty value means `.repolens`.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveOutputDir(root, outputDir = ".repolens") {
|
|
20
|
+
const relative = outputDir == null || outputDir === "" ? ".repolens" : outputDir;
|
|
21
|
+
if (typeof relative !== "string") {
|
|
22
|
+
throw new Error("Output directory must be a string");
|
|
23
|
+
}
|
|
24
|
+
if (relative.includes("\0")) {
|
|
25
|
+
throw new Error("Output directory contains a null byte");
|
|
26
|
+
}
|
|
27
|
+
if (path.isAbsolute(relative)) {
|
|
28
|
+
throw new Error(`Output directory must be a relative path inside the repository (received ${relative})`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const rootResolved = path.resolve(root);
|
|
32
|
+
const resolved = path.resolve(rootResolved, relative);
|
|
33
|
+
if (!isInsideRoot(rootResolved, resolved)) {
|
|
34
|
+
throw new Error(`Output directory escapes the repository: ${relative}`);
|
|
35
|
+
}
|
|
36
|
+
return resolved;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reduce a document or wiki name to a single path segment.
|
|
41
|
+
*/
|
|
42
|
+
export function safeFileName(name, fallback = "document.md") {
|
|
43
|
+
if (typeof name !== "string" || name.length === 0) return fallback;
|
|
44
|
+
const normalized = name.replace(/\\/g, "/");
|
|
45
|
+
const base = path.posix.basename(normalized);
|
|
46
|
+
if (!base || base === "." || base === ".." || base.includes("\0") || base.includes("/") || base.includes("\\")) {
|
|
47
|
+
return fallback;
|
|
48
|
+
}
|
|
49
|
+
return base;
|
|
50
|
+
}
|
package/src/utils/secrets.js
CHANGED
|
@@ -12,7 +12,9 @@ const SECRET_PATTERNS = [
|
|
|
12
12
|
// API Keys
|
|
13
13
|
{ name: "OpenAI API Key", pattern: /sk-[a-zA-Z0-9]{20,}/, severity: "high" },
|
|
14
14
|
{ name: "Anthropic API Key", pattern: /sk-ant-[a-zA-Z0-9_-]{95,}/, severity: "high" },
|
|
15
|
-
{ name: "GitHub Token", pattern: /gh[
|
|
15
|
+
{ name: "GitHub Token", pattern: /gh[pousr]_[a-zA-Z0-9]{36,}/, severity: "critical" },
|
|
16
|
+
{ name: "GitHub Fine-Grained Token", pattern: /github_pat_[a-zA-Z0-9_]{20,}/, severity: "critical" },
|
|
17
|
+
{ name: "npm Token", pattern: /npm_[a-zA-Z0-9]{36,}/, severity: "critical" },
|
|
16
18
|
{ name: "Generic API Key", pattern: /api[_-]?key[_-]?[a-zA-Z0-9]{20,}/i, severity: "high" },
|
|
17
19
|
|
|
18
20
|
// OAuth Tokens
|
package/src/utils/telemetry.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import * as Sentry from "@sentry/node";
|
|
2
1
|
import { readFileSync } from "node:fs";
|
|
3
2
|
import { fileURLToPath } from "node:url";
|
|
4
3
|
import { dirname, join } from "node:path";
|
|
@@ -9,12 +8,32 @@ const __dirname = dirname(__filename);
|
|
|
9
8
|
|
|
10
9
|
let initialized = false;
|
|
11
10
|
let enabled = false;
|
|
11
|
+
let Sentry = null;
|
|
12
|
+
let sentryLoadFailed = false;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load @sentry/node on demand. Telemetry is opt-in and the package is an
|
|
16
|
+
* optional dependency, so a missing install must not crash the CLI.
|
|
17
|
+
*/
|
|
18
|
+
async function loadSentry() {
|
|
19
|
+
if (Sentry) return Sentry;
|
|
20
|
+
if (sentryLoadFailed) return null;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
Sentry = await import("@sentry/node");
|
|
24
|
+
return Sentry;
|
|
25
|
+
} catch {
|
|
26
|
+
sentryLoadFailed = true;
|
|
27
|
+
Sentry = null;
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
12
31
|
|
|
13
32
|
/**
|
|
14
33
|
* Initialize error tracking (Sentry)
|
|
15
34
|
* Only enabled if REPOLENS_TELEMETRY_ENABLED=true
|
|
16
35
|
*/
|
|
17
|
-
export function initTelemetry() {
|
|
36
|
+
export async function initTelemetry() {
|
|
18
37
|
// Skip if already initialized
|
|
19
38
|
if (initialized) return;
|
|
20
39
|
initialized = true;
|
|
@@ -27,6 +46,13 @@ export function initTelemetry() {
|
|
|
27
46
|
return;
|
|
28
47
|
}
|
|
29
48
|
|
|
49
|
+
const sentry = await loadSentry();
|
|
50
|
+
if (!sentry) {
|
|
51
|
+
console.error("Telemetry enabled but optional dependency @sentry/node is not installed. Continuing without telemetry.");
|
|
52
|
+
enabled = false;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
try {
|
|
31
57
|
// Get version from package.json
|
|
32
58
|
const packageJsonPath = join(__dirname, "../../package.json");
|
|
@@ -35,7 +61,7 @@ export function initTelemetry() {
|
|
|
35
61
|
|
|
36
62
|
const dsn = process.env.REPOLENS_SENTRY_DSN || "https://082083dbf5899ed7e65dfd9b8dc72f90@o4511014913703936.ingest.de.sentry.io/4511014919209040";
|
|
37
63
|
|
|
38
|
-
|
|
64
|
+
sentry.init({
|
|
39
65
|
dsn,
|
|
40
66
|
|
|
41
67
|
// Release tracking
|
|
@@ -405,7 +431,9 @@ export function trackMigration(migratedCount, skippedCount) {
|
|
|
405
431
|
* Ensure Sentry is initialized for feedback submission.
|
|
406
432
|
* Feedback always works, even if REPOLENS_TELEMETRY_ENABLED is false.
|
|
407
433
|
*/
|
|
408
|
-
function ensureSentryForFeedback() {
|
|
434
|
+
async function ensureSentryForFeedback() {
|
|
435
|
+
const sentry = await loadSentry();
|
|
436
|
+
if (!sentry) return false;
|
|
409
437
|
if (enabled) return true;
|
|
410
438
|
|
|
411
439
|
// Initialize Sentry in a minimal mode just for feedback
|
|
@@ -416,7 +444,7 @@ function ensureSentryForFeedback() {
|
|
|
416
444
|
|
|
417
445
|
const dsn = process.env.REPOLENS_SENTRY_DSN || "https://082083dbf5899ed7e65dfd9b8dc72f90@o4511014913703936.ingest.de.sentry.io/4511014919209040";
|
|
418
446
|
|
|
419
|
-
|
|
447
|
+
sentry.init({
|
|
420
448
|
dsn,
|
|
421
449
|
release: `repolens@${version}`,
|
|
422
450
|
environment: process.env.NODE_ENV || "production",
|
|
@@ -439,7 +467,7 @@ function ensureSentryForFeedback() {
|
|
|
439
467
|
*/
|
|
440
468
|
export async function sendFeedback(name, email, message) {
|
|
441
469
|
try {
|
|
442
|
-
if (!ensureSentryForFeedback()) {
|
|
470
|
+
if (!(await ensureSentryForFeedback())) {
|
|
443
471
|
return false;
|
|
444
472
|
}
|
|
445
473
|
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import fetch from "node-fetch";
|
|
5
5
|
import { info, warn } from "./logger.js";
|
|
6
6
|
|
|
7
|
+
// The unscoped `repolens` package is a different project. Always query this one.
|
|
8
|
+
export const LATEST_VERSION_URL = "https://registry.npmjs.org/@chappibunny%2Frepolens/latest";
|
|
9
|
+
|
|
7
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
8
11
|
const __dirname = path.dirname(__filename);
|
|
9
12
|
|
|
10
13
|
const CHECK_INTERVAL = 1000 * 60 * 60 * 24; // 24 hours
|
|
11
|
-
|
|
14
|
+
|
|
15
|
+
function cacheFile() {
|
|
16
|
+
try {
|
|
17
|
+
const home = os.homedir();
|
|
18
|
+
if (!home) return null;
|
|
19
|
+
return path.join(home, ".repolens-update-check.json");
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
12
24
|
|
|
13
25
|
async function getCurrentVersion() {
|
|
14
26
|
try {
|
|
@@ -22,8 +34,9 @@ async function getCurrentVersion() {
|
|
|
22
34
|
|
|
23
35
|
async function getLatestVersion() {
|
|
24
36
|
try {
|
|
25
|
-
const response = await fetch(
|
|
26
|
-
|
|
37
|
+
const response = await fetch(LATEST_VERSION_URL, {
|
|
38
|
+
signal: AbortSignal.timeout(3000),
|
|
39
|
+
redirect: "error",
|
|
27
40
|
});
|
|
28
41
|
|
|
29
42
|
if (!response.ok) return null;
|
|
@@ -36,8 +49,10 @@ async function getLatestVersion() {
|
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
async function readCache() {
|
|
52
|
+
const file = cacheFile();
|
|
53
|
+
if (!file) return null;
|
|
39
54
|
try {
|
|
40
|
-
const cache = JSON.parse(await fs.readFile(
|
|
55
|
+
const cache = JSON.parse(await fs.readFile(file, "utf8"));
|
|
41
56
|
return cache;
|
|
42
57
|
} catch {
|
|
43
58
|
return null;
|
|
@@ -45,8 +60,10 @@ async function readCache() {
|
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
async function writeCache(data) {
|
|
63
|
+
const file = cacheFile();
|
|
64
|
+
if (!file) return;
|
|
48
65
|
try {
|
|
49
|
-
await fs.writeFile(
|
|
66
|
+
await fs.writeFile(file, JSON.stringify(data), "utf8");
|
|
50
67
|
} catch {
|
|
51
68
|
// Fail silently - not critical
|
|
52
69
|
}
|
package/src/utils/validate.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Prevents injection attacks, validates types, and enforces constraints.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import path from "node:path";
|
|
8
9
|
import { detectSecrets } from "./secrets.js";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -48,6 +49,12 @@ export function validateConfig(config) {
|
|
|
48
49
|
warnings.push(...notionWarnings);
|
|
49
50
|
}
|
|
50
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
|
+
|
|
51
58
|
// Validate domains (if present)
|
|
52
59
|
if (config.domains) {
|
|
53
60
|
const domainErrors = validateDomains(config.domains);
|
|
@@ -94,6 +101,12 @@ function validateScanConfig(scan) {
|
|
|
94
101
|
if (pattern.includes("..")) {
|
|
95
102
|
errors.push(`Dangerous pattern in scan.include: ${pattern} (contains "..")`);
|
|
96
103
|
}
|
|
104
|
+
if (pattern.includes("\0")) {
|
|
105
|
+
errors.push(`Dangerous pattern in scan.include: null byte`);
|
|
106
|
+
}
|
|
107
|
+
if (path.isAbsolute(pattern) || /^[A-Za-z]:[\\/]/.test(pattern)) {
|
|
108
|
+
errors.push(`Absolute pattern in scan.include: ${pattern}`);
|
|
109
|
+
}
|
|
97
110
|
|
|
98
111
|
// Warn about overly broad patterns
|
|
99
112
|
if (pattern === "**" || pattern === "**/*") {
|
|
@@ -135,7 +148,7 @@ function validatePublishers(publishers) {
|
|
|
135
148
|
return errors;
|
|
136
149
|
}
|
|
137
150
|
|
|
138
|
-
const validPublishers = ["notion", "markdown", "confluence", "github_wiki"];
|
|
151
|
+
const validPublishers = ["notion", "markdown", "confluence", "github_wiki", "obsidian"];
|
|
139
152
|
|
|
140
153
|
if (Array.isArray(publishers)) {
|
|
141
154
|
for (const pub of publishers) {
|
|
@@ -189,6 +202,62 @@ function validateNotionConfig(notion) {
|
|
|
189
202
|
return errors;
|
|
190
203
|
}
|
|
191
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
|
+
|
|
192
261
|
/**
|
|
193
262
|
* Check for Notion configuration warnings
|
|
194
263
|
*/
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import yaml from "js-yaml";
|
|
2
|
+
|
|
3
|
+
const MAX_YAML_CHARS = 1_000_000;
|
|
4
|
+
const MAX_YAML_ALIASES = 2000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse YAML without executing custom tags.
|
|
8
|
+
* js-yaml 4 `load` is already safe (no JS types). This adds a size cap and
|
|
9
|
+
* an alias cap so a config cannot expand into a billion-laughs document.
|
|
10
|
+
* The js-yaml 4 default schema is unchanged (`true`/`false` are booleans;
|
|
11
|
+
* the YAML 1.1 spellings `yes` and `no` stay strings).
|
|
12
|
+
*/
|
|
13
|
+
export function loadYaml(raw) {
|
|
14
|
+
if (typeof raw !== "string") {
|
|
15
|
+
throw new Error("YAML input must be a string");
|
|
16
|
+
}
|
|
17
|
+
if (raw.length > MAX_YAML_CHARS) {
|
|
18
|
+
throw new Error(`YAML document exceeds ${MAX_YAML_CHARS} characters`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const aliasUses = raw.match(/(^|[\s\[\{,])\*[A-Za-z_][\w-]*/g);
|
|
22
|
+
if (aliasUses && aliasUses.length > MAX_YAML_ALIASES) {
|
|
23
|
+
throw new Error(`YAML document has too many aliases (${aliasUses.length})`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return yaml.load(raw);
|
|
27
|
+
}
|
package/src/watch.js
CHANGED
|
@@ -22,7 +22,7 @@ async function rebuild(configPath) {
|
|
|
22
22
|
try {
|
|
23
23
|
const cfg = await loadConfig(configPath);
|
|
24
24
|
const scan = await scanRepo(cfg);
|
|
25
|
-
const rawDiff = getGitDiff("origin/main");
|
|
25
|
+
const rawDiff = getGitDiff("origin/main", cfg.__repoRoot || process.cwd());
|
|
26
26
|
const docSet = await generateDocumentSet(scan, cfg, rawDiff);
|
|
27
27
|
const result = await writeDocumentSet(docSet, process.cwd());
|
|
28
28
|
const elapsed = Date.now() - start;
|