renovate-mcp 0.3.0 → 0.5.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 +109 -45
- package/dist/index.js +13 -7
- package/dist/lib/configLinter.d.ts +8 -0
- package/dist/lib/configLinter.js +74 -0
- package/dist/lib/customManagerPreview.d.ts +5 -2
- package/dist/lib/customManagerPreview.js +149 -33
- package/dist/lib/dryRunProgress.d.ts +24 -0
- package/dist/lib/dryRunProgress.js +48 -0
- package/dist/lib/externalPresetFetcher.js +80 -9
- package/dist/lib/hostRulesConfig.d.ts +24 -0
- package/dist/lib/hostRulesConfig.js +61 -0
- package/dist/lib/lookupProblems.d.ts +19 -0
- package/dist/lib/lookupProblems.js +92 -0
- package/dist/lib/presetResolver.d.ts +11 -0
- package/dist/lib/presetResolver.js +60 -13
- package/dist/lib/renovateCli.d.ts +8 -0
- package/dist/lib/renovateCli.js +41 -2
- package/dist/lib/setupCheck.d.ts +8 -0
- package/dist/lib/setupCheck.js +33 -0
- package/dist/tools/dryRun.js +81 -6
- package/dist/tools/lintConfig.d.ts +2 -0
- package/dist/tools/lintConfig.js +65 -0
- package/dist/tools/previewCustomManager.js +28 -4
- package/dist/tools/resolveConfig.js +8 -3
- package/dist/tools/writeConfig.js +42 -36
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,43 +1,77 @@
|
|
|
1
1
|
# renovate-mcp
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/renovate-mcp)
|
|
4
|
+
[](https://github.com/tibuntu/renovate-mcp/actions/workflows/ci.yml)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
3
8
|
An MCP server for designing [Renovate](https://github.com/renovatebot/renovate) configurations interactively. Point it at a local repo and let an LLM help you read, validate, preview, and save `renovate.json`.
|
|
4
9
|
|
|
10
|
+
## Contents
|
|
11
|
+
|
|
12
|
+
- [What it does](#what-it-does)
|
|
13
|
+
- [What this is NOT](#what-this-is-not)
|
|
14
|
+
- [Requirements](#requirements)
|
|
15
|
+
- [Install](#install)
|
|
16
|
+
- [Example prompts](#example-prompts)
|
|
17
|
+
- [Example session](#example-session)
|
|
18
|
+
- [Development](#development)
|
|
19
|
+
- [Release flow](#release-flow)
|
|
20
|
+
- [Design notes](#design-notes)
|
|
21
|
+
|
|
5
22
|
## What it does
|
|
6
23
|
|
|
7
|
-
|
|
24
|
+
Eight tools plus a preset reference:
|
|
8
25
|
|
|
9
|
-
| Tool |
|
|
26
|
+
| Tool | Purpose |
|
|
10
27
|
| --- | --- |
|
|
11
|
-
| `check_setup` | Report Renovate CLI + validator availability, versions,
|
|
12
|
-
| `read_config` | Locate and parse `renovate.json
|
|
13
|
-
| `resolve_config` | Expand every `extends` preset
|
|
14
|
-
| `preview_custom_manager` | Preview a `customManagers` (regex) entry against a local repo —
|
|
28
|
+
| `check_setup` | Report Renovate CLI + validator availability, versions, and install hints. Also runs at startup. |
|
|
29
|
+
| `read_config` | Locate and parse a repo's Renovate config (`renovate.json`, `renovate.json5`, `.renovaterc*`, `package.json#renovate`, …) in priority order. |
|
|
30
|
+
| `resolve_config` | Expand every `extends` preset offline. Opt in to fetching `github>` / `gitlab>` presets over HTTPS with `externalPresets: true`. |
|
|
31
|
+
| `preview_custom_manager` | Preview a `customManagers` (regex) entry against a local repo — shows file/line hits and extracted dep info. Offline. |
|
|
15
32
|
| `validate_config` | Run `renovate-config-validator` against a file or inline object. |
|
|
16
|
-
| `
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `renovate://presets
|
|
20
|
-
| `renovate://
|
|
33
|
+
| `lint_config` | Semantic lint for Renovate-specific footguns schema validation misses (mostly malformed `/…/` regex patterns). Offline. |
|
|
34
|
+
| `dry_run` | Run Renovate with `--platform=local --dry-run` and return the structured JSON report. No PRs, no pushes. Emits MCP progress notifications during the run when the caller supplies a `progressToken`. |
|
|
35
|
+
| `write_config` | Validate, then atomically write a config to disk. Refuses to save invalid configs unless `force: true`. |
|
|
36
|
+
| `renovate://presets` (resource) | Markdown index of all 1000+ built-in presets grouped by namespace. |
|
|
37
|
+
| `renovate://presets/{namespace}` (resource template) | Markdown listing for a single namespace (e.g. `renovate://presets/config`) — cheaper than the full index. |
|
|
38
|
+
| `renovate://preset/{name}` (resource template) | Expanded JSON body for one preset (e.g. `renovate://preset/config:recommended`). |
|
|
39
|
+
|
|
40
|
+
See [Design notes](#design-notes) for implementation details (timeouts, safety caps, auth scrubbing, merge semantics).
|
|
41
|
+
|
|
42
|
+
## What this is NOT
|
|
43
|
+
|
|
44
|
+
- **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.
|
|
45
|
+
- **`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`.
|
|
46
|
+
- **`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`.
|
|
47
|
+
- **`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.
|
|
21
48
|
|
|
22
49
|
## Requirements
|
|
23
50
|
|
|
51
|
+
**Required**
|
|
52
|
+
|
|
24
53
|
- Node.js ≥ 24 (aligns with Renovate's own engine requirement).
|
|
25
|
-
- Renovate
|
|
26
|
-
|
|
27
|
-
|
|
54
|
+
- Renovate on your `PATH` — either a global install (`npm i -g renovate`) or a project-local install that exposes `renovate` + `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.
|
|
55
|
+
|
|
56
|
+
**Optional**
|
|
57
|
+
|
|
58
|
+
- `RENOVATE_BIN` / `RENOVATE_CONFIG_VALIDATOR_BIN` — override binary locations.
|
|
59
|
+
- `RENOVATE_MCP_REQUIRE_CLI=false` — suppress the startup "partial availability" notice when you only intend to use the offline tools.
|
|
60
|
+
- `RENOVATE_TOKEN` (preferred) or `GITHUB_TOKEN` / `GITLAB_TOKEN` as platform-specific fallbacks — only needed by `resolve_config` with `externalPresets: true` for fetching presets from private repos or avoiding rate limits. `RENOVATE_TOKEN` wins when both are set. For GitHub Enterprise / self-hosted GitLab, pass the `endpoint` tool input (and `platform` to route `local>` presets through the same host); `RENOVATE_ENDPOINT` is **not** read.
|
|
61
|
+
- Private-registry credentials for `dry_run` — whatever Renovate itself would need at lookup time (`COMPOSER_AUTH`, `NPM_TOKEN` / `.npmrc`, Docker registry creds, or a `RENOVATE_HOST_RULES` JSON blob). Alternatively encode these as `hostRules` in the Renovate config, or pass them per-call via the `hostRules` input on `dry_run` (no MCP restart needed). Per-call `hostRules` are appended to whatever the repo's own config declares. Values reach Renovate as JSON through the tool-call transport, so the calling LLM sees them in its context — prefer the env route if that matters. Without any of these, Renovate's lookup often returns 0 updates silently; `dry_run` scans its logs for auth failures and surfaces them under `problems`.
|
|
62
|
+
|
|
63
|
+
> **Note:** all env vars (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 the client and does not inherit shell env.
|
|
28
64
|
|
|
29
65
|
## Install
|
|
30
66
|
|
|
31
|
-
|
|
67
|
+
`npx` fetches the [published package](https://www.npmjs.com/package/renovate-mcp) on demand — no manual install needed. For local development, clone and build first:
|
|
32
68
|
|
|
33
69
|
```bash
|
|
34
70
|
npm install
|
|
35
71
|
npm run build
|
|
36
72
|
```
|
|
37
73
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
Add to `.mcp.json` (project) or `~/.claude.json` (user):
|
|
74
|
+
Add the following `mcpServers` entry to your client's config file:
|
|
41
75
|
|
|
42
76
|
```json
|
|
43
77
|
{
|
|
@@ -50,33 +84,27 @@ Add to `.mcp.json` (project) or `~/.claude.json` (user):
|
|
|
50
84
|
}
|
|
51
85
|
```
|
|
52
86
|
|
|
53
|
-
For local development, swap to:
|
|
87
|
+
For local development, swap to `"command": "node"` with `"args": ["/absolute/path/to/renovate-mcp/dist/index.js"]`.
|
|
54
88
|
|
|
55
|
-
|
|
56
|
-
{
|
|
57
|
-
"mcpServers": {
|
|
58
|
-
"renovate": {
|
|
59
|
-
"command": "node",
|
|
60
|
-
"args": ["/absolute/path/to/renovate-mcp/dist/index.js"]
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
```
|
|
89
|
+
### Claude Code
|
|
65
90
|
|
|
66
|
-
|
|
91
|
+
Config lives in `.mcp.json` (project-scoped) or `~/.claude.json` (user-scoped).
|
|
67
92
|
|
|
68
|
-
|
|
93
|
+
### Claude Desktop
|
|
69
94
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
95
|
+
Config lives in `~/Library/Application Support/Claude/claude_desktop_config.json`.
|
|
96
|
+
|
|
97
|
+
### Other MCP clients
|
|
98
|
+
|
|
99
|
+
Any client that can launch a stdio MCP server works — point it at the same command shown above.
|
|
100
|
+
|
|
101
|
+
### Verify the wiring
|
|
102
|
+
|
|
103
|
+
Restart your client and prompt it:
|
|
104
|
+
|
|
105
|
+
> List the namespaces available under `renovate://presets`.
|
|
106
|
+
|
|
107
|
+
A response listing namespaces like `config`, `docker`, `npm`, `helpers`, … confirms the server is reachable and the resource is exposed. If the client reports the tool or resource as unavailable, re-check the config file path and command.
|
|
80
108
|
|
|
81
109
|
## Example prompts
|
|
82
110
|
|
|
@@ -111,6 +139,32 @@ Once the server is wired up, try prompts like these. They're written for Claude
|
|
|
111
139
|
- "Do a dry run and show me which PRs Renovate would open — no pushes."
|
|
112
140
|
- "Add `:semanticCommits` to my `extends`, validate it, and save back to `renovate.json`."
|
|
113
141
|
|
|
142
|
+
## Example session
|
|
143
|
+
|
|
144
|
+
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.
|
|
145
|
+
|
|
146
|
+
> **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.
|
|
147
|
+
>
|
|
148
|
+
> **Claude** calls `preview_custom_manager` with a first-draft `fileMatch` + `matchStrings`.
|
|
149
|
+
> → 4 Dockerfiles matched `fileMatch`, 0 lines matched `matchStrings`. The regex anchored on `ARG`, but the Dockerfiles use `FROM`.
|
|
150
|
+
>
|
|
151
|
+
> **You:** Rewrite `matchStrings` to anchor on the renovate comment, then `FROM <image>:<version>` on the next line.
|
|
152
|
+
>
|
|
153
|
+
> **Claude** calls `preview_custom_manager` again with the fixed regex.
|
|
154
|
+
> → 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.
|
|
155
|
+
>
|
|
156
|
+
> **You:** Good. Validate the full config inline, with this `customManagers` entry alongside my existing `extends`.
|
|
157
|
+
>
|
|
158
|
+
> **Claude** calls `validate_config` with the inline config. → valid.
|
|
159
|
+
>
|
|
160
|
+
> **You:** Now dry-run so I can see what Renovate would actually open.
|
|
161
|
+
>
|
|
162
|
+
> **Claude** calls `dry_run`. → 2 updates: `postgres` 15.3-alpine → 15.5-alpine, `redis` 7.2 → 7.4. No entries in `problems`.
|
|
163
|
+
>
|
|
164
|
+
> **You:** Save it.
|
|
165
|
+
>
|
|
166
|
+
> **Claude** calls `write_config` on `renovate.json`. → validated, written atomically.
|
|
167
|
+
|
|
114
168
|
## Development
|
|
115
169
|
|
|
116
170
|
```bash
|
|
@@ -120,12 +174,13 @@ npm run build # compile to dist/
|
|
|
120
174
|
npm start # run the built server over stdio
|
|
121
175
|
npm test # vitest run (builds first)
|
|
122
176
|
npm run test:watch # vitest watch mode
|
|
177
|
+
npm run test:coverage # vitest run --coverage (writes coverage/ report)
|
|
123
178
|
npm run generate:presets # regenerate src/data/presets.generated.ts from the renovate devDep
|
|
124
179
|
```
|
|
125
180
|
|
|
126
181
|
The preset catalogue at `src/data/presets.generated.ts` is a committed snapshot of Renovate's built-in presets. Runtime code never imports the `renovate` package — only `scripts/generate-presets.mjs` does. Regenerate after bumping the `renovate` devDependency.
|
|
127
182
|
|
|
128
|
-
CI runs `typecheck`, `build`, and `test` on Node 24 for every PR and push to `main` (see `.github/workflows/ci.yml`).
|
|
183
|
+
CI runs `typecheck`, `build`, and `test:coverage` on Node 24 for every PR and push to `main` (see `.github/workflows/ci.yml`). Coverage is uploaded as a per-run artifact; no threshold is enforced yet.
|
|
129
184
|
|
|
130
185
|
## Release flow
|
|
131
186
|
|
|
@@ -142,9 +197,18 @@ Secrets required on the repo: `RELEASE_PLEASE_TOKEN` (a PAT for release-please).
|
|
|
142
197
|
|
|
143
198
|
## Design notes
|
|
144
199
|
|
|
200
|
+
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.
|
|
201
|
+
|
|
145
202
|
- `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).
|
|
146
|
-
- `resolve_config` and `
|
|
147
|
-
- `
|
|
203
|
+
- `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.
|
|
204
|
+
- `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.
|
|
205
|
+
- `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 `maxFilesWalked` cap. `node_modules/` and `.git/` are always skipped as a safety net even when no `.gitignore` is present.
|
|
206
|
+
- `preview_custom_manager` exposes two separate safety caps so the warning text can name which one tripped: `maxFilesWalked` (default 2000) bounds the directory walk before any `fileMatch` testing, and `maxFilesMatched` (default 500) bounds the result set after `fileMatch` is applied. Previously a single `maxFilesScanned` conflated the two, leaving the user unable to tell whether to narrow `fileMatch` or widen the walk.
|
|
207
|
+
- `preview_custom_manager` runs every user-supplied regex on a `worker_threads` worker with a wall-clock budget per operation (default 2 s, configurable via `matchTimeoutMs`). Catastrophic backtracking — e.g. `^(a+)+b$` against `aaaa…c`, or `(.*)*=` against a modestly sized file — would otherwise pin the MCP server's event loop indefinitely. On timeout the worker is terminated and a warning is appended identifying which `fileMatch[i]` or `matchStrings[i]` was aborted, so the user can simplify the pattern or raise the budget.
|
|
208
|
+
- `preview_custom_manager` also stats each matched file before reading it and skips anything larger than `maxFileBytes` (default 5 MiB) with a warning, so a stray lockfile, generated artifact, or SQL dump caught by a loose `fileMatch` can't OOM the server. `maxHitsPerFile` already bounds output size; this guards input size.
|
|
148
209
|
- `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.
|
|
149
|
-
- `
|
|
210
|
+
- `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.
|
|
211
|
+
- 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.
|
|
212
|
+
- `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.
|
|
213
|
+
- `dry_run` emits MCP progress notifications only when the caller's `tools/call` includes `_meta.progressToken` — no-op otherwise, so legacy clients see zero overhead. A 5-second heartbeat ticks while the child runs; each tick's message is best-effort enriched with the latest Renovate JSON-log `msg` seen on stdout. Notifications are also emitted at start and completion. We deliberately don't couple to Renovate's log schema beyond reading `msg`, since that schema isn't a stable API.
|
|
150
214
|
- `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
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.5.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
|
+
}
|
|
@@ -36,11 +36,14 @@ export interface ExtractedDep {
|
|
|
36
36
|
autoReplaceString?: string;
|
|
37
37
|
}
|
|
38
38
|
export interface PreviewOptions {
|
|
39
|
-
|
|
39
|
+
maxFilesWalked?: number;
|
|
40
|
+
maxFilesMatched?: number;
|
|
40
41
|
maxHitsPerFile?: number;
|
|
42
|
+
matchTimeoutMs?: number;
|
|
43
|
+
maxFileBytes?: number;
|
|
41
44
|
}
|
|
42
45
|
export interface PreviewResult {
|
|
43
|
-
|
|
46
|
+
filesWalked: number;
|
|
44
47
|
filesMatched: string[];
|
|
45
48
|
hits: PreviewHit[];
|
|
46
49
|
extractedDeps: ExtractedDep[];
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { Worker } from "node:worker_threads";
|
|
3
4
|
import ignore from "ignore";
|
|
4
|
-
const
|
|
5
|
+
const DEFAULT_MAX_FILES_WALKED = 2000;
|
|
6
|
+
const DEFAULT_MAX_FILES_MATCHED = 500;
|
|
5
7
|
const DEFAULT_MAX_HITS_PER_FILE = 100;
|
|
8
|
+
const DEFAULT_MATCH_TIMEOUT_MS = 2000;
|
|
9
|
+
const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
6
10
|
const SKIP_DIRS = new Set([".git", "node_modules"]);
|
|
7
11
|
const TEMPLATE_FIELD_MAP = [
|
|
8
12
|
["depNameTemplate", "depName"],
|
|
@@ -18,29 +22,67 @@ const TEMPLATE_FIELD_MAP = [
|
|
|
18
22
|
];
|
|
19
23
|
export async function previewCustomManager(repoPath, manager, options = {}) {
|
|
20
24
|
const warnings = [];
|
|
21
|
-
const
|
|
25
|
+
const maxFilesWalked = options.maxFilesWalked ?? DEFAULT_MAX_FILES_WALKED;
|
|
26
|
+
const maxFilesMatched = options.maxFilesMatched ?? DEFAULT_MAX_FILES_MATCHED;
|
|
22
27
|
const maxHitsPerFile = options.maxHitsPerFile ?? DEFAULT_MAX_HITS_PER_FILE;
|
|
28
|
+
const matchTimeoutMs = options.matchTimeoutMs ?? DEFAULT_MATCH_TIMEOUT_MS;
|
|
29
|
+
const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
|
|
23
30
|
if (manager.matchStringsStrategy && manager.matchStringsStrategy !== "any") {
|
|
24
31
|
warnings.push(`matchStringsStrategy='${manager.matchStringsStrategy}' is not yet supported by this preview tool; treating as 'any'. Run dry_run for full-fidelity behavior.`);
|
|
25
32
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
33
|
+
// Surface malformed user regexes eagerly, before we do any filesystem work.
|
|
34
|
+
// The Worker path otherwise reports these as generic worker errors.
|
|
35
|
+
for (const src of manager.fileMatch)
|
|
36
|
+
validateRegex(src);
|
|
37
|
+
for (const src of manager.matchStrings)
|
|
38
|
+
validateRegex(src);
|
|
39
|
+
// Walk first, then run fileMatch regexes in a worker so a pathological
|
|
40
|
+
// pattern can't pin the event loop on the path-testing phase.
|
|
41
|
+
const allPaths = [];
|
|
30
42
|
for await (const rel of walk(repoPath)) {
|
|
31
|
-
if (
|
|
32
|
-
warnings.push(`Stopped after
|
|
43
|
+
if (allPaths.length >= maxFilesWalked) {
|
|
44
|
+
warnings.push(`Stopped walking the repo after ${maxFilesWalked} files; remaining files were never tested against fileMatch. Add ignores (or a .gitignore) to prune irrelevant directories, or raise maxFilesWalked.`);
|
|
33
45
|
break;
|
|
34
46
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
47
|
+
allPaths.push(rel);
|
|
48
|
+
}
|
|
49
|
+
const filesWalked = allPaths.length;
|
|
50
|
+
const matchedSet = new Set();
|
|
51
|
+
for (let i = 0; i < manager.fileMatch.length; i++) {
|
|
52
|
+
const source = manager.fileMatch[i];
|
|
53
|
+
const res = await runTestInWorker(source, "", allPaths, matchTimeoutMs);
|
|
54
|
+
if (res.timedOut) {
|
|
55
|
+
warnings.push(`fileMatch[${i}] /${source}/ exceeded ${matchTimeoutMs}ms and was aborted; no paths were matched against this pattern. Simplify the regex (e.g. avoid nested quantifiers like (a+)+) or raise matchTimeoutMs.`);
|
|
56
|
+
continue;
|
|
38
57
|
}
|
|
58
|
+
for (const p of res.paths)
|
|
59
|
+
matchedSet.add(p);
|
|
60
|
+
}
|
|
61
|
+
// Preserve walk order so output is stable.
|
|
62
|
+
const allFilesMatched = allPaths.filter((p) => matchedSet.has(p));
|
|
63
|
+
// Distinct from the walk cap: this caps the *result set*. A broad fileMatch
|
|
64
|
+
// regex over a large repo can produce thousands of hits; truncate with a
|
|
65
|
+
// dedicated warning so the user can tell which cap tripped.
|
|
66
|
+
const filesMatched = allFilesMatched.slice(0, maxFilesMatched);
|
|
67
|
+
if (allFilesMatched.length > maxFilesMatched) {
|
|
68
|
+
warnings.push(`fileMatch matched ${allFilesMatched.length} files; capped result set at maxFilesMatched=${maxFilesMatched}. Narrow fileMatch to target the intended paths, or raise maxFilesMatched.`);
|
|
39
69
|
}
|
|
40
70
|
const hits = [];
|
|
41
71
|
const extractedDeps = [];
|
|
42
72
|
for (const rel of filesMatched) {
|
|
43
73
|
const abs = path.join(repoPath, rel);
|
|
74
|
+
let stat;
|
|
75
|
+
try {
|
|
76
|
+
stat = await fs.stat(abs);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
warnings.push(`Could not stat ${rel}: ${err.message}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (stat.size > maxFileBytes) {
|
|
83
|
+
warnings.push(`${rel}: skipped, ${stat.size} bytes exceeds maxFileBytes=${maxFileBytes}. Tighten fileMatch to exclude it, or raise maxFileBytes.`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
44
86
|
let content;
|
|
45
87
|
try {
|
|
46
88
|
content = await fs.readFile(abs, "utf8");
|
|
@@ -50,28 +92,34 @@ export async function previewCustomManager(repoPath, manager, options = {}) {
|
|
|
50
92
|
continue;
|
|
51
93
|
}
|
|
52
94
|
let perFileHits = 0;
|
|
53
|
-
for (let i = 0; i <
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
95
|
+
for (let i = 0; i < manager.matchStrings.length; i++) {
|
|
96
|
+
const source = manager.matchStrings[i];
|
|
97
|
+
const res = await runMatchAllInWorker(source, "gm", content, matchTimeoutMs);
|
|
98
|
+
if (res.timedOut) {
|
|
99
|
+
warnings.push(`${rel}: matchStrings[${i}] /${source}/ exceeded ${matchTimeoutMs}ms and was aborted; any matches in this file for this pattern were skipped. Simplify the regex (e.g. avoid nested quantifiers like (.*)*) or raise matchTimeoutMs.`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const m of res.matches) {
|
|
59
103
|
if (perFileHits >= maxHitsPerFile) {
|
|
60
104
|
warnings.push(`${rel}: capped at ${maxHitsPerFile} hits. Increase maxHitsPerFile to see more.`);
|
|
61
105
|
break;
|
|
62
106
|
}
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
107
|
+
const line = lineNumberAt(content, m.index);
|
|
108
|
+
hits.push({
|
|
109
|
+
file: rel,
|
|
110
|
+
matchStringIndex: i,
|
|
111
|
+
line,
|
|
112
|
+
match: m.match,
|
|
113
|
+
groups: m.groups,
|
|
114
|
+
});
|
|
115
|
+
extractedDeps.push(buildExtractedDep(rel, line, m.groups, manager));
|
|
68
116
|
perFileHits++;
|
|
69
117
|
}
|
|
70
118
|
if (perFileHits >= maxHitsPerFile)
|
|
71
119
|
break;
|
|
72
120
|
}
|
|
73
121
|
}
|
|
74
|
-
return {
|
|
122
|
+
return { filesWalked, filesMatched, hits, extractedDeps, warnings };
|
|
75
123
|
}
|
|
76
124
|
function buildExtractedDep(file, line, groups, manager) {
|
|
77
125
|
const dep = { file, line };
|
|
@@ -102,17 +150,9 @@ function buildExtractedDep(file, line, groups, manager) {
|
|
|
102
150
|
function applyTemplate(tmpl, groups) {
|
|
103
151
|
return tmpl.replace(/\{\{\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\}\}/g, (_, name) => groups[name] ?? "");
|
|
104
152
|
}
|
|
105
|
-
function
|
|
153
|
+
function validateRegex(source) {
|
|
106
154
|
try {
|
|
107
|
-
|
|
108
|
-
const seen = new Set();
|
|
109
|
-
const flags = (extraFlags).split("").filter((c) => {
|
|
110
|
-
if (seen.has(c))
|
|
111
|
-
return false;
|
|
112
|
-
seen.add(c);
|
|
113
|
-
return true;
|
|
114
|
-
}).join("");
|
|
115
|
-
return new RegExp(source, flags);
|
|
155
|
+
new RegExp(source);
|
|
116
156
|
}
|
|
117
157
|
catch (err) {
|
|
118
158
|
throw new Error(`Invalid regex /${source}/: ${err.message}`);
|
|
@@ -126,6 +166,82 @@ function lineNumberAt(content, index) {
|
|
|
126
166
|
}
|
|
127
167
|
return line;
|
|
128
168
|
}
|
|
169
|
+
const WORKER_SOURCE = `
|
|
170
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
171
|
+
try {
|
|
172
|
+
const { mode, pattern, flags } = workerData;
|
|
173
|
+
const re = new RegExp(pattern, flags);
|
|
174
|
+
if (mode === 'test') {
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const p of workerData.paths) {
|
|
177
|
+
re.lastIndex = 0;
|
|
178
|
+
if (re.test(p)) out.push(p);
|
|
179
|
+
}
|
|
180
|
+
parentPort.postMessage({ ok: true, mode: 'test', paths: out });
|
|
181
|
+
} else {
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const m of workerData.content.matchAll(re)) {
|
|
184
|
+
out.push({
|
|
185
|
+
index: m.index == null ? 0 : m.index,
|
|
186
|
+
match: m[0],
|
|
187
|
+
groups: m.groups == null ? {} : Object.assign({}, m.groups),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
parentPort.postMessage({ ok: true, mode: 'matchAll', matches: out });
|
|
191
|
+
}
|
|
192
|
+
} catch (err) {
|
|
193
|
+
parentPort.postMessage({ ok: false, error: err && err.message ? err.message : String(err) });
|
|
194
|
+
}
|
|
195
|
+
`;
|
|
196
|
+
async function runWorker(request, timeoutMs) {
|
|
197
|
+
const worker = new Worker(WORKER_SOURCE, {
|
|
198
|
+
eval: true,
|
|
199
|
+
workerData: request,
|
|
200
|
+
});
|
|
201
|
+
let timer;
|
|
202
|
+
try {
|
|
203
|
+
return await new Promise((resolve, reject) => {
|
|
204
|
+
timer = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
205
|
+
worker.once("message", (msg) => resolve(msg));
|
|
206
|
+
worker.once("error", (err) => reject(err));
|
|
207
|
+
worker.once("exit", (code) => {
|
|
208
|
+
if (code !== 0 && code !== 1) {
|
|
209
|
+
// code 1 is the normal exit after terminate(); anything else is a
|
|
210
|
+
// crash we haven't already captured via 'error'.
|
|
211
|
+
reject(new Error(`Regex worker exited unexpectedly with code ${code}`));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
if (timer)
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
// terminate() is idempotent and safe to call after the worker already exited.
|
|
220
|
+
await worker.terminate().catch(() => { });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async function runTestInWorker(pattern, flags, paths, timeoutMs) {
|
|
224
|
+
const response = await runWorker({ mode: "test", pattern, flags, paths }, timeoutMs);
|
|
225
|
+
if (response === "timeout")
|
|
226
|
+
return { timedOut: true };
|
|
227
|
+
if (!response.ok)
|
|
228
|
+
throw new Error(`Regex worker error: ${response.error}`);
|
|
229
|
+
if (response.mode !== "test") {
|
|
230
|
+
throw new Error(`Regex worker returned wrong mode: ${response.mode}`);
|
|
231
|
+
}
|
|
232
|
+
return { timedOut: false, paths: response.paths };
|
|
233
|
+
}
|
|
234
|
+
async function runMatchAllInWorker(pattern, flags, content, timeoutMs) {
|
|
235
|
+
const response = await runWorker({ mode: "matchAll", pattern, flags, content }, timeoutMs);
|
|
236
|
+
if (response === "timeout")
|
|
237
|
+
return { timedOut: true };
|
|
238
|
+
if (!response.ok)
|
|
239
|
+
throw new Error(`Regex worker error: ${response.error}`);
|
|
240
|
+
if (response.mode !== "matchAll") {
|
|
241
|
+
throw new Error(`Regex worker returned wrong mode: ${response.mode}`);
|
|
242
|
+
}
|
|
243
|
+
return { timedOut: false, matches: response.matches };
|
|
244
|
+
}
|
|
129
245
|
async function readMaybe(abs) {
|
|
130
246
|
try {
|
|
131
247
|
return await fs.readFile(abs, "utf8");
|