renovate-mcp 0.2.0 → 0.4.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/README.md +86 -9
- package/dist/index.js +14 -8
- package/dist/lib/configLinter.d.ts +8 -0
- package/dist/lib/configLinter.js +74 -0
- package/dist/lib/customManagerPreview.js +72 -21
- package/dist/lib/externalPresetFetcher.d.ts +8 -1
- package/dist/lib/externalPresetFetcher.js +103 -32
- package/dist/lib/hostRulesConfig.d.ts +24 -0
- package/dist/lib/hostRulesConfig.js +56 -0
- package/dist/lib/lookupProblems.d.ts +19 -0
- package/dist/lib/lookupProblems.js +92 -0
- package/dist/lib/presetResolver.d.ts +39 -0
- package/dist/lib/presetResolver.js +103 -12
- package/dist/lib/setupCheck.d.ts +8 -0
- package/dist/lib/setupCheck.js +33 -0
- package/dist/resources/presets.js +67 -20
- package/dist/tools/dryRun.js +32 -6
- package/dist/tools/lintConfig.d.ts +2 -0
- package/dist/tools/lintConfig.js +65 -0
- package/dist/tools/previewCustomManager.js +1 -1
- package/dist/tools/resolveConfig.js +19 -4
- package/dist/tools/writeConfig.js +27 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -4,26 +4,38 @@ An MCP server for designing [Renovate](https://github.com/renovatebot/renovate)
|
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Eight tools plus a preset reference:
|
|
8
8
|
|
|
9
9
|
| Tool | What it does |
|
|
10
10
|
| --- | --- |
|
|
11
11
|
| `check_setup` | Report Renovate CLI + validator availability, versions, env overrides, and install hints. Also runs at server startup and embeds the result in the server's `instructions` when anything's missing. |
|
|
12
12
|
| `read_config` | Locate and parse `renovate.json` / `renovate.json5` / `.renovaterc*` / `.github/renovate.json` / `.gitlab/renovate.json` / `package.json#renovate` in a repo — in Renovate's priority order. |
|
|
13
|
-
| `resolve_config` | Expand every `extends` preset against the committed catalogue and return the fully resolved config (offline; no `renovate` invocation). Flags unresolvable entries with a reason. Opt in to fetching `github>` / `gitlab>` presets over HTTPS with `externalPresets: true` (auth via `GITHUB_TOKEN` / `GITLAB_TOKEN`
|
|
13
|
+
| `resolve_config` | Expand every `extends` preset against the committed catalogue and return the fully resolved config (offline; no `renovate` invocation). Flags unresolvable entries with a reason. Opt in to fetching `github>` / `gitlab>` presets over HTTPS with `externalPresets: true` (auth via `RENOVATE_TOKEN`, or `GITHUB_TOKEN` / `GITLAB_TOKEN` as platform-specific fallbacks). For GitHub Enterprise or self-hosted GitLab, pass `endpoint` (API base URL) — and `platform` in addition to route `local>` presets through the same host. `bitbucket>`, `gitea>`, and npm presets remain in `presetsUnresolved` regardless. |
|
|
14
14
|
| `preview_custom_manager` | Preview a `customManagers` (regex) entry against a local repo — offline, no `renovate` invocation. Shows which files match `fileMatch`, which lines match each `matchStrings` regex with named capture groups, and what dep info the template fields produce. Intended for fast regex iteration; run `dry_run` afterwards for full-fidelity confirmation. |
|
|
15
15
|
| `validate_config` | Run `renovate-config-validator` against a file or inline object. |
|
|
16
|
-
| `
|
|
16
|
+
| `lint_config` | Semantic lint pass for Renovate-specific footguns schema validation misses — primarily malformed `/…/` regex patterns in `matchPackageNames` / `matchDepNames` / `matchSourceUrls` / `matchCurrentVersion`. Offline; does not shell out. Returns findings with JSON path, offending value, and a stable `ruleId` (`dead-regex-missing-slash`, `unwrapped-regex`) for suppression. Complements `validate_config`. |
|
|
17
|
+
| `dry_run` | Run Renovate with `--platform=local --dry-run`, return the structured JSON report (no PRs, no pushes). Scans Renovate's logs for registry-auth failures (401/403/unauthorized/etc.) and surfaces them under `problems` so an empty-updates result isn't mistaken for "no updates available" when credentials were actually missing. Accepts an optional `hostRules` input for per-invocation private-registry credentials so callers don't have to restart the MCP server with new env vars (written to a mode-0600 temp file, passed via `--config-file`, cleaned up after the run; token/password values are scrubbed from any log output). |
|
|
17
18
|
| `write_config` | Validate, then write a config to disk (temp-file → validate → atomic rename). Refuses to save invalid configs unless `force: true`. |
|
|
18
|
-
| `renovate://presets` (resource) |
|
|
19
|
+
| `renovate://presets` (resource) | Thin markdown index of every namespace (with preset counts) covering all 1000+ built-in presets. Snapshot from the installed `renovate` devDep. |
|
|
20
|
+
| `renovate://presets/{namespace}` (resource template) | Markdown listing of every preset in a single namespace (e.g. `renovate://presets/config`). Fetch this instead of the full index when the LLM only cares about one namespace — cuts token cost by roughly 1/N where N is the number of namespaces. |
|
|
19
21
|
| `renovate://preset/{name}` (resource template) | Expanded JSON body (description, extends, packageRules, …) for any single built-in preset. E.g. `renovate://preset/config:recommended`. |
|
|
20
22
|
|
|
23
|
+
## What this is NOT
|
|
24
|
+
|
|
25
|
+
- **Not a Renovate replacement.** This server doesn't open PRs, run scheduled updates, or execute in CI — it's a design-time companion for a local `renovate.json`. Use the real Renovate for the actual dependency-update pipeline.
|
|
26
|
+
- **`resolve_config` is preview-quality.** Preset expansion runs against a committed snapshot, and template substitution implements only positional `{{argN}}` placeholders — non-positional tokens and Handlebars helpers are flagged in `warnings` and pass through verbatim. For authoritative output, run `dry_run`.
|
|
27
|
+
- **`preview_custom_manager` is a subset of Renovate's regex manager.** It covers `customType: "regex"`, `matchStringsStrategy: "any"`, and `{{groupName}}` template substitution only — other custom types, other strategies, and full Handlebars (helpers, conditionals) are not implemented. Use it for fast regex iteration; confirm with `dry_run`.
|
|
28
|
+
- **`validate_config` / `dry_run` aren't exercised in CI.** CI deliberately doesn't install Renovate, so tools that shell out to it are only covered by unit/integration tests that don't require the binary. Run them locally against the Renovate install you intend to deploy with.
|
|
29
|
+
|
|
21
30
|
## Requirements
|
|
22
31
|
|
|
23
32
|
- Node.js ≥ 24 (aligns with Renovate's own engine requirement).
|
|
24
|
-
- Renovate available on your `PATH` — either a global install (`npm i -g renovate`) or a project-local install that exposes `renovate` and `renovate-config-validator` via `npm exec`. Only needed for `validate_config`, `dry_run`, and `write_config`; the offline tools (`read_config`, `resolve_config`, `preview_custom_manager`) work without it.
|
|
33
|
+
- Renovate available on your `PATH` — either a global install (`npm i -g renovate`) or a project-local install that exposes `renovate` and `renovate-config-validator` via `npm exec`. Only needed for `validate_config`, `dry_run`, and `write_config`; the offline tools (`read_config`, `resolve_config`, `preview_custom_manager`, `lint_config`) work without it.
|
|
25
34
|
- Override binary locations with env vars if needed: `RENOVATE_BIN`, `RENOVATE_CONFIG_VALIDATOR_BIN`.
|
|
26
|
-
-
|
|
35
|
+
- `RENOVATE_MCP_REQUIRE_CLI=false` suppresses the startup "partial availability" notice. Set this if you only intend to use the offline tools (`read_config`, `resolve_config`, `preview_custom_manager`) and don't want the missing-CLI notice in the server's MCP instructions.
|
|
36
|
+
- Optional for `resolve_config` with `externalPresets: true`: `RENOVATE_TOKEN` (preferred — matches Renovate's own convention, where `RENOVATE_TOKEN` is the explicit Renovate-specific override), or `GITHUB_TOKEN` / `GITLAB_TOKEN` as platform-specific fallbacks, for fetching presets from private repos or to avoid rate limits. When both `RENOVATE_TOKEN` and a platform token are set, `RENOVATE_TOKEN` wins. For GitHub Enterprise / self-hosted GitLab, pass the `endpoint` tool input (and `platform` if you also want `local>` presets routed there); `RENOVATE_ENDPOINT` is **not** read.
|
|
37
|
+
- Optional for `dry_run` against private package registries: whatever credentials Renovate itself would need at lookup time — e.g. `COMPOSER_AUTH` for private Packagist / Satis proxies, `NPM_TOKEN` or `.npmrc` for private npm, Docker registry credentials, or a `RENOVATE_HOST_RULES` JSON blob covering multiple hosts. Alternatively, encode these as `hostRules` directly in the Renovate config, or pass them per-call via the `hostRules` input on `dry_run` (no MCP server restart needed — useful while debugging an empty-updates result). Per-call `hostRules` are appended to any the repo's own config already declares. Values reach Renovate through the MCP tool-call transport as JSON, so the calling LLM sees them in its context; if that matters for a given token, use the env route instead. Without any of these Renovate's lookup step often returns 0 updates silently; `dry_run` will scan its logs for auth failures and surface them under `problems` in the output.
|
|
38
|
+
- Any env vars the tools read (tokens, `COMPOSER_AUTH`, `RENOVATE_HOST_RULES`, binary overrides, …) must be set on the MCP server process itself — via the `env` key in `claude_desktop_config.json` / `.mcp.json`, not your shell, since the MCP server runs as a child of Claude and does not inherit shell env.
|
|
27
39
|
|
|
28
40
|
## Install
|
|
29
41
|
|
|
@@ -77,6 +89,65 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
|
77
89
|
}
|
|
78
90
|
```
|
|
79
91
|
|
|
92
|
+
## Example prompts
|
|
93
|
+
|
|
94
|
+
Once the server is wired up, try prompts like these. They're written for Claude but work with any MCP-capable client.
|
|
95
|
+
|
|
96
|
+
**Understanding an existing config**
|
|
97
|
+
|
|
98
|
+
- "Read the Renovate config in this repo and summarize what it actually does — expand every preset so I can see the real effective behavior."
|
|
99
|
+
- "Resolve my config and list anything that landed in `presetsUnresolved`, with the reason for each."
|
|
100
|
+
|
|
101
|
+
**Browsing presets**
|
|
102
|
+
|
|
103
|
+
- "List the presets in the `config` namespace." (uses the `renovate://presets/config` sub-resource — cheaper than pulling the whole index)
|
|
104
|
+
- "What does `config:recommended` actually enable? Show me its expanded JSON."
|
|
105
|
+
- "Find a built-in preset that pins GitHub Actions digests."
|
|
106
|
+
|
|
107
|
+
**Self-hosted GitLab / GitHub Enterprise**
|
|
108
|
+
|
|
109
|
+
- "Resolve my config with external presets enabled, fetching `gitlab>platform/renovate-presets` from our self-hosted GitLab at `https://gitlab.example.com/api/v4`. Route `local>` presets through the same host."
|
|
110
|
+
- "Expand `github>acme/renovate-config//base` from our GitHub Enterprise at `https://github.acme.corp/api/v3`."
|
|
111
|
+
|
|
112
|
+
(Reminder: auth tokens must be set on the MCP server process — via the `env` key in `.mcp.json` / `claude_desktop_config.json`, not your shell.)
|
|
113
|
+
|
|
114
|
+
**Authoring a custom manager (regex)**
|
|
115
|
+
|
|
116
|
+
- "I have `# renovate: datasource=docker depName=...` comments above image tags in my Dockerfiles. Draft a `customManagers` regex entry and preview it against this repo so I can see what it extracts."
|
|
117
|
+
- "Here's a `customManagers` entry — preview it and tell me which files match, which lines hit each `matchStrings` regex, and what dep info gets extracted."
|
|
118
|
+
|
|
119
|
+
**Validating, previewing, saving**
|
|
120
|
+
|
|
121
|
+
- "Validate this proposed config against Renovate's schema without writing it anywhere."
|
|
122
|
+
- "Do a dry run and show me which PRs Renovate would open — no pushes."
|
|
123
|
+
- "Add `:semanticCommits` to my `extends`, validate it, and save back to `renovate.json`."
|
|
124
|
+
|
|
125
|
+
## Example session
|
|
126
|
+
|
|
127
|
+
A transcript-style walkthrough: design a Dockerfile custom manager from scratch, validate it, dry-run, and save. Turns are abbreviated — your client will show the actual tool-call JSON.
|
|
128
|
+
|
|
129
|
+
> **You:** I've got `# renovate: datasource=docker depName=<image>` comments above `FROM` lines in my Dockerfiles. Draft a `customManagers` entry and preview it against this repo.
|
|
130
|
+
>
|
|
131
|
+
> **Claude** calls `preview_custom_manager` with a first-draft `fileMatch` + `matchStrings`.
|
|
132
|
+
> → 4 Dockerfiles matched `fileMatch`, 0 lines matched `matchStrings`. The regex anchored on `ARG`, but the Dockerfiles use `FROM`.
|
|
133
|
+
>
|
|
134
|
+
> **You:** Rewrite `matchStrings` to anchor on the renovate comment, then `FROM <image>:<version>` on the next line.
|
|
135
|
+
>
|
|
136
|
+
> **Claude** calls `preview_custom_manager` again with the fixed regex.
|
|
137
|
+
> → 4 files, 4 line hits. Extracted: `postgres:15.3-alpine`, `redis:7.2`, `nginx:1.25`, `node:20.11`. Named groups `depName` and `currentValue` populated on every hit.
|
|
138
|
+
>
|
|
139
|
+
> **You:** Good. Validate the full config inline, with this `customManagers` entry alongside my existing `extends`.
|
|
140
|
+
>
|
|
141
|
+
> **Claude** calls `validate_config` with the inline config. → valid.
|
|
142
|
+
>
|
|
143
|
+
> **You:** Now dry-run so I can see what Renovate would actually open.
|
|
144
|
+
>
|
|
145
|
+
> **Claude** calls `dry_run`. → 2 updates: `postgres` 15.3-alpine → 15.5-alpine, `redis` 7.2 → 7.4. No entries in `problems`.
|
|
146
|
+
>
|
|
147
|
+
> **You:** Save it.
|
|
148
|
+
>
|
|
149
|
+
> **Claude** calls `write_config` on `renovate.json`. → validated, written atomically.
|
|
150
|
+
|
|
80
151
|
## Development
|
|
81
152
|
|
|
82
153
|
```bash
|
|
@@ -108,8 +179,14 @@ Secrets required on the repo: `RELEASE_PLEASE_TOKEN` (a PAT for release-please).
|
|
|
108
179
|
|
|
109
180
|
## Design notes
|
|
110
181
|
|
|
182
|
+
Scope and non-goals are summarized in [What this is NOT](#what-this-is-not); this section covers implementation decisions behind the scope that *is* supported.
|
|
183
|
+
|
|
111
184
|
- `validate_config`, `dry_run`, and `write_config` shell out to the Renovate CLI rather than importing Renovate as a library — this decouples our Node version from Renovate's (currently Node 24).
|
|
112
|
-
- `resolve_config` and `
|
|
113
|
-
- `
|
|
114
|
-
- `
|
|
185
|
+
- `resolve_config`, `preview_custom_manager`, and `lint_config` are fully in-process and never invoke the Renovate CLI, so they work without a Renovate install.
|
|
186
|
+
- `lint_config` is a semantic lint pass that sits alongside `validate_config` rather than replacing it: schema validation catches structural bugs, the linter catches Renovate-specific footguns that schema validation declares valid — most commonly a pattern like `"matchPackageNames": ["/devops\\/pipelines\\/.+"]` where a trailing `/` is missing and Renovate silently degrades the value to an exact-string match that never hits. The ruleset is intentionally small (two rules: `dead-regex-missing-slash`, `unwrapped-regex`), scoped to the four regex-aware fields named in the proposal, and tuned to avoid false positives on benign exact strings containing a `.`. Rule IDs are stable so findings can be suppressed by callers.
|
|
187
|
+
- `preview_custom_manager` honors `.gitignore` (including nested `.gitignore`s and `.git/info/exclude`) when walking the repo, so generated/vendored directories like `dist/`, `.next/`, `target/`, `__pycache__/` don't crowd out real hits against the `maxFilesScanned` cap. `node_modules/` and `.git/` are always skipped as a safety net even when no `.gitignore` is present.
|
|
188
|
+
- `resolve_config` expands `extends` against a committed snapshot of Renovate's built-in presets (`src/data/presets.generated.ts`). External `github>` / `gitlab>` fetching is opt-in, uses each platform's contents API with a 10 s timeout, and caches results per call. The `endpoint` input swaps in a custom API base for GHE / self-hosted GitLab; `platform` additionally rewrites `local>` presets to be fetched against that endpoint.
|
|
189
|
+
- `resolve_config` merges preset bodies with a close approximation of Renovate's own `mergeChildConfig` — arrays concat, objects recursively merge, scalars overwrite — not a bit-identical port. Rule-specific semantics for `hostRules`, `regexManagers` / `customManagers`, and certain boolean flags aren't modeled here. Every response carries `mergeQuality: "preview"` plus a human-readable `disclaimer` so callers can't miss the limitation; run `dry_run` for authoritative output.
|
|
190
|
+
- When `resolve_config` encounters template tokens outside its supported subset, it records a structured entry in `warnings`: under-argument cases (`{{arg2}}` referenced when only one arg was passed) substitute an empty string, while non-positional tokens (`{{packageRules}}`, Handlebars helpers like `{{#if …}}`) pass through verbatim.
|
|
191
|
+
- `dry_run` uses `--report-type=file` so we get a structured JSON report instead of scraping stdout. When a `hostRules` input is passed it's written to a mode-0600 temp file in `os.tmpdir()`, handed to Renovate via `--config-file=`, and deleted in a `finally` block. Token/password values are scrubbed from both the detected `problems` list and the `logTail` fallback before returning.
|
|
115
192
|
- `write_config` writes to a temp file, validates, then atomically renames — so a failed validation never leaves a broken config on disk.
|
package/dist/index.js
CHANGED
|
@@ -4,12 +4,13 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { registerCheckSetup } from "./tools/checkSetup.js";
|
|
5
5
|
import { registerReadConfig } from "./tools/readConfig.js";
|
|
6
6
|
import { registerValidateConfig } from "./tools/validateConfig.js";
|
|
7
|
+
import { registerLintConfig } from "./tools/lintConfig.js";
|
|
7
8
|
import { registerResolveConfig } from "./tools/resolveConfig.js";
|
|
8
9
|
import { registerPreviewCustomManager } from "./tools/previewCustomManager.js";
|
|
9
10
|
import { registerDryRun } from "./tools/dryRun.js";
|
|
10
11
|
import { registerWriteConfig } from "./tools/writeConfig.js";
|
|
11
12
|
import { registerPresetResources } from "./resources/presets.js";
|
|
12
|
-
import { checkSetup,
|
|
13
|
+
import { checkSetup, startupBanner } from "./lib/setupCheck.js";
|
|
13
14
|
const BASE_INSTRUCTIONS = [
|
|
14
15
|
"Design and debug Renovate configurations interactively.",
|
|
15
16
|
"",
|
|
@@ -18,23 +19,28 @@ const BASE_INSTRUCTIONS = [
|
|
|
18
19
|
" 2. resolve_config — expand built-in presets to see what a config actually becomes (offline)",
|
|
19
20
|
" 3. preview_custom_manager — iterate on a regex-based customManagers entry; shows file/line hits and extracted deps",
|
|
20
21
|
" 4. validate_config — check a proposed config against Renovate's schema",
|
|
21
|
-
" 5.
|
|
22
|
-
" 6.
|
|
22
|
+
" 5. lint_config — semantic lint pass: catches Renovate-specific footguns schema validation misses (e.g. malformed /…/ regex patterns)",
|
|
23
|
+
" 6. dry_run — preview what Renovate would actually do (no PRs)",
|
|
24
|
+
" 7. write_config — save the agreed-upon config (validates first)",
|
|
23
25
|
"",
|
|
24
26
|
"If any tool fails unexpectedly, call check_setup to diagnose CLI availability.",
|
|
25
|
-
"Built-in preset reference
|
|
27
|
+
"Built-in preset reference: renovate://presets (namespace index), renovate://presets/{namespace} (one namespace), renovate://preset/{name} (one preset's expanded JSON).",
|
|
26
28
|
].join("\n");
|
|
27
29
|
const setup = await checkSetup();
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
30
|
+
// `RENOVATE_MCP_REQUIRE_CLI=false` is the opt-out for users who have
|
|
31
|
+
// consciously chosen the offline subset and don't want the startup notice.
|
|
32
|
+
const requireCliRaw = process.env.RENOVATE_MCP_REQUIRE_CLI?.trim().toLowerCase();
|
|
33
|
+
const suppressBanner = requireCliRaw === "false" || requireCliRaw === "0";
|
|
34
|
+
const banner = suppressBanner ? null : startupBanner(setup);
|
|
35
|
+
const instructions = banner ? [BASE_INSTRUCTIONS, "", banner].join("\n") : BASE_INSTRUCTIONS;
|
|
36
|
+
const SERVER_VERSION = "0.4.0"; // x-release-please-version
|
|
32
37
|
const server = new McpServer({ name: "renovate-mcp", version: SERVER_VERSION }, { instructions });
|
|
33
38
|
registerCheckSetup(server);
|
|
34
39
|
registerReadConfig(server);
|
|
35
40
|
registerResolveConfig(server);
|
|
36
41
|
registerPreviewCustomManager(server);
|
|
37
42
|
registerValidateConfig(server);
|
|
43
|
+
registerLintConfig(server);
|
|
38
44
|
registerDryRun(server);
|
|
39
45
|
registerWriteConfig(server);
|
|
40
46
|
registerPresetResources(server);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const REGEX_AWARE_FIELDS = new Set([
|
|
2
|
+
"matchPackageNames",
|
|
3
|
+
"matchDepNames",
|
|
4
|
+
"matchSourceUrls",
|
|
5
|
+
"matchCurrentVersion",
|
|
6
|
+
]);
|
|
7
|
+
export function lintConfig(config) {
|
|
8
|
+
const findings = [];
|
|
9
|
+
walk(config, "", findings);
|
|
10
|
+
return findings;
|
|
11
|
+
}
|
|
12
|
+
function walk(node, pathStr, findings) {
|
|
13
|
+
if (Array.isArray(node)) {
|
|
14
|
+
node.forEach((item, i) => walk(item, `${pathStr}[${i}]`, findings));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (node && typeof node === "object") {
|
|
18
|
+
for (const [key, value] of Object.entries(node)) {
|
|
19
|
+
const childPath = pathStr ? `${pathStr}.${key}` : key;
|
|
20
|
+
if (REGEX_AWARE_FIELDS.has(key)) {
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
value.forEach((entry, i) => {
|
|
23
|
+
if (typeof entry === "string") {
|
|
24
|
+
checkPattern(entry, `${childPath}[${i}]`, findings);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
else if (typeof value === "string") {
|
|
29
|
+
checkPattern(value, childPath, findings);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
walk(value, childPath, findings);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function checkPattern(raw, path, findings) {
|
|
39
|
+
// `!` is Renovate's exclusion prefix; strip it before checking regex wrapping.
|
|
40
|
+
const stripped = raw.startsWith("!") ? raw.slice(1) : raw;
|
|
41
|
+
// Too short to meaningfully be a /…/ regex or trigger a metachar rule.
|
|
42
|
+
if (stripped.length < 2)
|
|
43
|
+
return;
|
|
44
|
+
const startsSlash = stripped.startsWith("/");
|
|
45
|
+
const endsSlash = stripped.endsWith("/");
|
|
46
|
+
if (startsSlash !== endsSlash) {
|
|
47
|
+
findings.push({
|
|
48
|
+
ruleId: "dead-regex-missing-slash",
|
|
49
|
+
path,
|
|
50
|
+
value: raw,
|
|
51
|
+
message: startsSlash
|
|
52
|
+
? "Value starts with '/' but does not end with '/'. Renovate will treat this as an exact-match string, not a regex. Add the trailing '/' if a regex match was intended, otherwise remove the leading '/'."
|
|
53
|
+
: "Value ends with '/' but does not start with '/'. Renovate will treat this as an exact-match string, not a regex. Add the leading '/' if a regex match was intended, otherwise remove the trailing '/'.",
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (startsSlash && endsSlash)
|
|
58
|
+
return;
|
|
59
|
+
if (hasStrongRegexSignal(stripped)) {
|
|
60
|
+
findings.push({
|
|
61
|
+
ruleId: "unwrapped-regex",
|
|
62
|
+
path,
|
|
63
|
+
value: raw,
|
|
64
|
+
message: `Value contains regex metacharacters but is not wrapped in '/…/'. Renovate will treat it as an exact-match string. Wrap it as '/${stripped}/' (or '!/${stripped}/' to negate) if a regex match was intended.`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function hasStrongRegexSignal(s) {
|
|
69
|
+
return (/\\[dwsbDWSB]/.test(s) ||
|
|
70
|
+
/\.[+*?]/.test(s) ||
|
|
71
|
+
/\\\./.test(s) ||
|
|
72
|
+
/\(\?[:!=<]/.test(s) ||
|
|
73
|
+
/\[[^\]]+\]/.test(s));
|
|
74
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import ignore from "ignore";
|
|
3
4
|
const DEFAULT_MAX_FILES_SCANNED = 2000;
|
|
4
5
|
const DEFAULT_MAX_HITS_PER_FILE = 100;
|
|
5
6
|
const SKIP_DIRS = new Set([".git", "node_modules"]);
|
|
@@ -125,29 +126,79 @@ function lineNumberAt(content, index) {
|
|
|
125
126
|
}
|
|
126
127
|
return line;
|
|
127
128
|
}
|
|
128
|
-
async function
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
129
|
+
async function readMaybe(abs) {
|
|
130
|
+
try {
|
|
131
|
+
return await fs.readFile(abs, "utf8");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function loadGitignore(root, relDir, levels) {
|
|
138
|
+
const content = await readMaybe(path.join(root, relDir, ".gitignore"));
|
|
139
|
+
if (!content)
|
|
140
|
+
return levels;
|
|
141
|
+
const posixPrefix = relDir === "" ? "" : `${toPosix(relDir)}/`;
|
|
142
|
+
return [...levels, { prefix: posixPrefix, ig: ignore().add(content) }];
|
|
143
|
+
}
|
|
144
|
+
function isIgnored(relPath, isDir, levels) {
|
|
145
|
+
for (const level of levels) {
|
|
146
|
+
const sub = level.prefix === ""
|
|
147
|
+
? relPath
|
|
148
|
+
: relPath.startsWith(level.prefix)
|
|
149
|
+
? relPath.slice(level.prefix.length)
|
|
150
|
+
: null;
|
|
151
|
+
if (sub === null || sub === "")
|
|
138
152
|
continue;
|
|
153
|
+
// `ignore` treats a trailing slash as "this is a directory", which is what
|
|
154
|
+
// lets patterns like `dist/` match directory paths.
|
|
155
|
+
if (level.ig.ignores(isDir ? `${sub}/` : sub))
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
function toPosix(p) {
|
|
161
|
+
return p.split(path.sep).join("/");
|
|
162
|
+
}
|
|
163
|
+
async function* walk(root) {
|
|
164
|
+
// Seed the root level with `.gitignore` plus `.git/info/exclude` (the
|
|
165
|
+
// per-clone exclude file). Both are optional.
|
|
166
|
+
const rootIg = ignore();
|
|
167
|
+
const rootGitignore = await readMaybe(path.join(root, ".gitignore"));
|
|
168
|
+
if (rootGitignore)
|
|
169
|
+
rootIg.add(rootGitignore);
|
|
170
|
+
const infoExclude = await readMaybe(path.join(root, ".git", "info", "exclude"));
|
|
171
|
+
if (infoExclude)
|
|
172
|
+
rootIg.add(infoExclude);
|
|
173
|
+
const rootLevels = [{ prefix: "", ig: rootIg }];
|
|
174
|
+
yield* walkDir(root, "", rootLevels);
|
|
175
|
+
}
|
|
176
|
+
async function* walkDir(root, relDir, levels) {
|
|
177
|
+
let entries;
|
|
178
|
+
try {
|
|
179
|
+
entries = await fs.readdir(path.join(root, relDir), { withFileTypes: true });
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// A nested `.gitignore` only affects this directory's subtree — add it to
|
|
185
|
+
// the stack on entry. (The root `.gitignore` is already in `levels`.)
|
|
186
|
+
const dirLevels = relDir === "" ? levels : await loadGitignore(root, relDir, levels);
|
|
187
|
+
for (const entry of entries) {
|
|
188
|
+
const relPath = relDir === "" ? entry.name : `${toPosix(relDir)}/${entry.name}`;
|
|
189
|
+
if (entry.isDirectory()) {
|
|
190
|
+
if (SKIP_DIRS.has(entry.name))
|
|
191
|
+
continue;
|
|
192
|
+
if (isIgnored(relPath, true, dirLevels))
|
|
193
|
+
continue;
|
|
194
|
+
yield* walkDir(root, path.join(relDir, entry.name), dirLevels);
|
|
139
195
|
}
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
else if (entry.isFile()) {
|
|
147
|
-
// Always emit POSIX-style paths so users on macOS/Linux/Windows write
|
|
148
|
-
// the same fileMatch regexes.
|
|
149
|
-
yield path.join(relDir, entry.name).split(path.sep).join("/");
|
|
150
|
-
}
|
|
196
|
+
else if (entry.isFile()) {
|
|
197
|
+
if (isIgnored(relPath, false, dirLevels))
|
|
198
|
+
continue;
|
|
199
|
+
// Always emit POSIX-style paths so users on macOS/Linux/Windows write
|
|
200
|
+
// the same fileMatch regexes.
|
|
201
|
+
yield toPosix(relPath);
|
|
151
202
|
}
|
|
152
203
|
}
|
|
153
204
|
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ParsedPreset } from "./presetResolver.js";
|
|
2
2
|
export interface FetchOptions {
|
|
3
3
|
timeoutMs?: number;
|
|
4
4
|
cache?: Map<string, Promise<FetchResult>>;
|
|
5
|
+
/**
|
|
6
|
+
* Override the API base URL. For `github>` presets, defaults to
|
|
7
|
+
* `https://api.github.com` (pass `https://ghe.example.com/api/v3` for GitHub
|
|
8
|
+
* Enterprise). For `gitlab>`, defaults to `https://gitlab.com/api/v4` (pass
|
|
9
|
+
* `https://gitlab.example.com/api/v4` for self-hosted).
|
|
10
|
+
*/
|
|
11
|
+
endpoint?: string;
|
|
5
12
|
/** Injectable for tests. Defaults to globalThis.fetch. */
|
|
6
13
|
fetchImpl?: typeof fetch;
|
|
7
14
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { classifyExternalSource } from "./presetResolver.js";
|
|
1
2
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
3
|
+
const DEFAULT_GITHUB_API_BASE = "https://api.github.com";
|
|
4
|
+
const DEFAULT_GITLAB_API_BASE = "https://gitlab.com/api/v4";
|
|
5
|
+
function trimTrailingSlash(url) {
|
|
6
|
+
return url.replace(/\/+$/, "");
|
|
7
|
+
}
|
|
2
8
|
export async function fetchExternalPreset(parsed, options = {}) {
|
|
3
9
|
const cache = options.cache;
|
|
4
10
|
const cacheKey = parsed.key;
|
|
@@ -10,64 +16,66 @@ export async function fetchExternalPreset(parsed, options = {}) {
|
|
|
10
16
|
return promise;
|
|
11
17
|
}
|
|
12
18
|
function dispatch(parsed, options) {
|
|
19
|
+
if (!parsed.source) {
|
|
20
|
+
return Promise.resolve({ ok: false, reason: "Unknown preset source: (none)" });
|
|
21
|
+
}
|
|
22
|
+
const classification = classifyExternalSource(parsed.source);
|
|
23
|
+
if (!classification.fetchable) {
|
|
24
|
+
return Promise.resolve({ ok: false, reason: classification.reason });
|
|
25
|
+
}
|
|
13
26
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
14
27
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
28
|
+
const endpoint = options.endpoint ? trimTrailingSlash(options.endpoint) : undefined;
|
|
15
29
|
switch (parsed.source) {
|
|
16
30
|
case "github":
|
|
17
|
-
return fetchGitHub(parsed, timeoutMs, fetchImpl);
|
|
31
|
+
return fetchGitHub(parsed, timeoutMs, fetchImpl, endpoint);
|
|
18
32
|
case "gitlab":
|
|
19
|
-
return fetchGitLab(parsed, timeoutMs, fetchImpl);
|
|
20
|
-
case "bitbucket":
|
|
21
|
-
case "gitea":
|
|
22
|
-
return Promise.resolve({
|
|
23
|
-
ok: false,
|
|
24
|
-
reason: `${parsed.source}> presets are not yet supported. Track progress in issue #10.`,
|
|
25
|
-
});
|
|
26
|
-
case "local":
|
|
27
|
-
return Promise.resolve({
|
|
28
|
-
ok: false,
|
|
29
|
-
reason: "local> presets require platform/repo context that resolve_config does not have; out of scope.",
|
|
30
|
-
});
|
|
31
|
-
case "npm":
|
|
32
|
-
return Promise.resolve({
|
|
33
|
-
ok: false,
|
|
34
|
-
reason: "npm-hosted presets are not yet supported. Host the preset on GitHub or GitLab, or track progress in issue #10.",
|
|
35
|
-
});
|
|
33
|
+
return fetchGitLab(parsed, timeoutMs, fetchImpl, endpoint);
|
|
36
34
|
default:
|
|
37
35
|
return Promise.resolve({
|
|
38
36
|
ok: false,
|
|
39
|
-
reason: `Unknown preset source: ${parsed.source
|
|
37
|
+
reason: `Unknown preset source: ${parsed.source}`,
|
|
40
38
|
});
|
|
41
39
|
}
|
|
42
40
|
}
|
|
43
|
-
async function fetchGitHub(parsed, timeoutMs, fetchImpl) {
|
|
41
|
+
async function fetchGitHub(parsed, timeoutMs, fetchImpl, endpoint) {
|
|
44
42
|
if (!parsed.repoPath || !parsed.repoPath.includes("/")) {
|
|
45
43
|
return { ok: false, reason: `Invalid github preset: ${parsed.original}` };
|
|
46
44
|
}
|
|
47
45
|
const file = presetFileName(parsed);
|
|
48
46
|
const ref = parsed.ref ?? "HEAD";
|
|
49
|
-
const
|
|
50
|
-
const
|
|
47
|
+
const apiBase = endpoint ?? DEFAULT_GITHUB_API_BASE;
|
|
48
|
+
const url = `${apiBase}/repos/${parsed.repoPath}/contents/${encodeFilePath(file)}?ref=${encodeURIComponent(ref)}`;
|
|
49
|
+
const credential = resolveCredential(["RENOVATE_TOKEN", "GITHUB_TOKEN"]);
|
|
51
50
|
const headers = {
|
|
52
51
|
Accept: "application/vnd.github.raw",
|
|
53
52
|
"User-Agent": "renovate-mcp",
|
|
54
53
|
};
|
|
55
|
-
if (token)
|
|
56
|
-
headers.Authorization = `Bearer ${token}`;
|
|
57
|
-
return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl);
|
|
54
|
+
if (credential.token)
|
|
55
|
+
headers.Authorization = `Bearer ${credential.token}`;
|
|
56
|
+
return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl, "github", credential);
|
|
58
57
|
}
|
|
59
|
-
async function fetchGitLab(parsed, timeoutMs, fetchImpl) {
|
|
58
|
+
async function fetchGitLab(parsed, timeoutMs, fetchImpl, endpoint) {
|
|
60
59
|
if (!parsed.repoPath) {
|
|
61
60
|
return { ok: false, reason: `Invalid gitlab preset: ${parsed.original}` };
|
|
62
61
|
}
|
|
63
62
|
const file = presetFileName(parsed);
|
|
64
63
|
const ref = parsed.ref ?? "HEAD";
|
|
65
|
-
const
|
|
66
|
-
const
|
|
64
|
+
const apiBase = endpoint ?? DEFAULT_GITLAB_API_BASE;
|
|
65
|
+
const url = `${apiBase}/projects/${encodeURIComponent(parsed.repoPath)}/repository/files/${encodeURIComponent(file)}/raw?ref=${encodeURIComponent(ref)}`;
|
|
66
|
+
const credential = resolveCredential(["RENOVATE_TOKEN", "GITLAB_TOKEN"]);
|
|
67
67
|
const headers = { "User-Agent": "renovate-mcp" };
|
|
68
|
-
if (token)
|
|
69
|
-
headers["PRIVATE-TOKEN"] = token;
|
|
70
|
-
return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl);
|
|
68
|
+
if (credential.token)
|
|
69
|
+
headers["PRIVATE-TOKEN"] = credential.token;
|
|
70
|
+
return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl, "gitlab", credential);
|
|
71
|
+
}
|
|
72
|
+
function resolveCredential(vars) {
|
|
73
|
+
for (const envVar of vars) {
|
|
74
|
+
const value = process.env[envVar];
|
|
75
|
+
if (value)
|
|
76
|
+
return { envVar, token: value, triedVars: vars };
|
|
77
|
+
}
|
|
78
|
+
return { envVar: null, token: undefined, triedVars: vars };
|
|
71
79
|
}
|
|
72
80
|
function presetFileName(parsed) {
|
|
73
81
|
if (parsed.subpath)
|
|
@@ -82,12 +90,48 @@ function ensureJson(name) {
|
|
|
82
90
|
function encodeFilePath(file) {
|
|
83
91
|
return file.split("/").map(encodeURIComponent).join("/");
|
|
84
92
|
}
|
|
85
|
-
|
|
93
|
+
function detectRateLimit(res, platform, presetName) {
|
|
94
|
+
if (platform === "github" && res.status === 403) {
|
|
95
|
+
if (res.headers.get("x-ratelimit-remaining") !== "0")
|
|
96
|
+
return undefined;
|
|
97
|
+
const resetIso = parseEpochHeader(res.headers.get("x-ratelimit-reset"));
|
|
98
|
+
const resetClause = resetIso ? ` (resets at ${resetIso})` : "";
|
|
99
|
+
return `GitHub API rate limit exceeded${resetClause} when fetching ${presetName}. Set GITHUB_TOKEN for an authenticated limit, or wait for reset.`;
|
|
100
|
+
}
|
|
101
|
+
if (platform === "gitlab" && res.status === 429) {
|
|
102
|
+
const resetIso = parseEpochHeader(res.headers.get("ratelimit-reset"));
|
|
103
|
+
const resetClause = resetIso ? ` (resets at ${resetIso})` : "";
|
|
104
|
+
return `GitLab API rate limit exceeded${resetClause} when fetching ${presetName}. Set GITLAB_TOKEN for an authenticated limit, or wait for reset.`;
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
function parseEpochHeader(raw) {
|
|
109
|
+
if (!raw)
|
|
110
|
+
return undefined;
|
|
111
|
+
const seconds = Number.parseInt(raw, 10);
|
|
112
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
113
|
+
return undefined;
|
|
114
|
+
const date = new Date(seconds * 1000);
|
|
115
|
+
if (Number.isNaN(date.getTime()))
|
|
116
|
+
return undefined;
|
|
117
|
+
return date.toISOString();
|
|
118
|
+
}
|
|
119
|
+
async function fetchJson(url, headers, timeoutMs, presetName, fetchImpl, platform, credential) {
|
|
86
120
|
const controller = new AbortController();
|
|
87
121
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
88
122
|
try {
|
|
89
123
|
const res = await fetchImpl(url, { headers, signal: controller.signal });
|
|
90
124
|
if (!res.ok) {
|
|
125
|
+
const rateLimit = detectRateLimit(res, platform, presetName);
|
|
126
|
+
if (rateLimit)
|
|
127
|
+
return { ok: false, reason: rateLimit };
|
|
128
|
+
if (res.status === 401 || res.status === 403) {
|
|
129
|
+
const body = await safeReadText(res);
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
reason: formatAuthFailure(res.status, presetName, url, credential, body),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
91
135
|
return {
|
|
92
136
|
ok: false,
|
|
93
137
|
reason: `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""} when fetching ${presetName}`,
|
|
@@ -128,3 +172,30 @@ async function fetchJson(url, headers, timeoutMs, presetName, fetchImpl) {
|
|
|
128
172
|
clearTimeout(timer);
|
|
129
173
|
}
|
|
130
174
|
}
|
|
175
|
+
const AUTH_BODY_MAX = 500;
|
|
176
|
+
async function safeReadText(res) {
|
|
177
|
+
try {
|
|
178
|
+
return await res.text();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function formatAuthFailure(status, presetName, url, credential, body) {
|
|
185
|
+
const lines = [`HTTP ${status} when fetching ${presetName}`];
|
|
186
|
+
lines.push(` URL: ${url}`);
|
|
187
|
+
lines.push(` Credential: ${formatCredential(credential)}`);
|
|
188
|
+
const trimmed = body.trim();
|
|
189
|
+
if (trimmed) {
|
|
190
|
+
const snippet = trimmed.length > AUTH_BODY_MAX
|
|
191
|
+
? `${trimmed.slice(0, AUTH_BODY_MAX)}… [truncated]`
|
|
192
|
+
: trimmed;
|
|
193
|
+
lines.push(` Response: ${snippet}`);
|
|
194
|
+
}
|
|
195
|
+
return lines.join("\n");
|
|
196
|
+
}
|
|
197
|
+
function formatCredential(credential) {
|
|
198
|
+
if (credential.envVar)
|
|
199
|
+
return `${credential.envVar} (present)`;
|
|
200
|
+
return `none (tried ${credential.triedVars.join(", ")})`;
|
|
201
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type HostRule = Record<string, unknown>;
|
|
2
|
+
/**
|
|
3
|
+
* Write a throwaway Renovate config containing just `{ hostRules: [...] }` to
|
|
4
|
+
* `os.tmpdir()` with mode 0600, and return the absolute path. The caller is
|
|
5
|
+
* responsible for unlinking the file (in a `finally` block) once Renovate has
|
|
6
|
+
* consumed it — we do not keep a reference here.
|
|
7
|
+
*
|
|
8
|
+
* Renovate merges CLI `--config-file` rules with any repo-level `hostRules`
|
|
9
|
+
* (array concatenation, most-specific match wins), so callers can combine
|
|
10
|
+
* per-invocation credentials with whatever the repo itself already declares.
|
|
11
|
+
*/
|
|
12
|
+
export declare function writeHostRulesConfig(hostRules: HostRule[]): Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Pull the token/password values out of the provided hostRules so callers can
|
|
15
|
+
* scrub them from log output. Usernames are intentionally not treated as
|
|
16
|
+
* secrets — Renovate echoes them in diagnostics and they are not typically
|
|
17
|
+
* sensitive on their own.
|
|
18
|
+
*/
|
|
19
|
+
export declare function collectSecrets(hostRules: HostRule[]): string[];
|
|
20
|
+
/**
|
|
21
|
+
* Replace every literal occurrence of any secret in `text` with `[REDACTED]`.
|
|
22
|
+
* No-op when `secrets` is empty.
|
|
23
|
+
*/
|
|
24
|
+
export declare function scrubSecrets(text: string, secrets: string[]): string;
|