renovate-mcp 0.4.0 → 0.6.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 CHANGED
@@ -1,24 +1,43 @@
1
1
  # renovate-mcp
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/renovate-mcp.svg)](https://www.npmjs.com/package/renovate-mcp)
4
+ [![CI](https://github.com/tibuntu/renovate-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/tibuntu/renovate-mcp/actions/workflows/ci.yml)
5
+ [![Node ≥ 24](https://img.shields.io/node/v/renovate-mcp.svg)](https://nodejs.org/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./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 | What it does |
26
+ | Tool | Purpose |
10
27
  | --- | --- |
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
- | `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 `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
- | `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. |
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
- | `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). |
18
- | `write_config` | Validate, then write a config to disk (temp-file → validate → atomic rename). Refuses to save invalid configs unless `force: true`. |
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. |
21
- | `renovate://preset/{name}` (resource template) | Expanded JSON body (description, extends, packageRules, …) for any single built-in preset. E.g. `renovate://preset/config:recommended`. |
33
+ | `lint_config` | Semantic lint for Renovate-specific footguns schema validation misses (mostly malformed `/…/` regex patterns). Offline. |
34
+ | `dry_run` | Run Renovate with `--dry-run` and return the structured JSON report. Defaults to `--platform=local` against `repoPath`; pass `platform` + `endpoint` + `token` + `repository` to run as a real GitHub/GitLab client (needed when the config extends `local>` presets on a private host). 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).
22
41
 
23
42
  ## What this is NOT
24
43
 
@@ -29,26 +48,30 @@ Eight tools plus a preset reference:
29
48
 
30
49
  ## Requirements
31
50
 
51
+ **Required**
52
+
32
53
  - Node.js ≥ 24 (aligns with Renovate's own engine requirement).
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.
34
- - Override binary locations with env vars if needed: `RENOVATE_BIN`, `RENOVATE_CONFIG_VALIDATOR_BIN`.
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.
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.
39
64
 
40
65
  ## Install
41
66
 
42
- Once published to npm, no install is needed — `npx` will fetch it on demand. For local development, clone and build:
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:
43
68
 
44
69
  ```bash
45
70
  npm install
46
71
  npm run build
47
72
  ```
48
73
 
49
- ## Use with Claude Code
50
-
51
- Add to `.mcp.json` (project) or `~/.claude.json` (user):
74
+ Add the following `mcpServers` entry to your client's config file:
52
75
 
53
76
  ```json
54
77
  {
@@ -61,33 +84,27 @@ Add to `.mcp.json` (project) or `~/.claude.json` (user):
61
84
  }
62
85
  ```
63
86
 
64
- For local development, swap to:
87
+ For local development, swap to `"command": "node"` with `"args": ["/absolute/path/to/renovate-mcp/dist/index.js"]`.
65
88
 
66
- ```json
67
- {
68
- "mcpServers": {
69
- "renovate": {
70
- "command": "node",
71
- "args": ["/absolute/path/to/renovate-mcp/dist/index.js"]
72
- }
73
- }
74
- }
75
- ```
89
+ ### Claude Code
76
90
 
77
- ## Use with Claude Desktop
91
+ Config lives in `.mcp.json` (project-scoped) or `~/.claude.json` (user-scoped).
78
92
 
79
- Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
93
+ ### Claude Desktop
80
94
 
81
- ```json
82
- {
83
- "mcpServers": {
84
- "renovate": {
85
- "command": "npx",
86
- "args": ["-y", "renovate-mcp"]
87
- }
88
- }
89
- }
90
- ```
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.
91
108
 
92
109
  ## Example prompts
93
110
 
@@ -108,6 +125,7 @@ Once the server is wired up, try prompts like these. They're written for Claude
108
125
 
109
126
  - "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
127
  - "Expand `github>acme/renovate-config//base` from our GitHub Enterprise at `https://github.acme.corp/api/v3`."
128
+ - "Dry-run `devops/gitops` against our self-hosted GitLab at `https://gitlab.example.com/api/v4` — use `platform=gitlab` with my token so Renovate can fetch the `local>` presets the config extends."
111
129
 
112
130
  (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
131
 
@@ -157,12 +175,13 @@ npm run build # compile to dist/
157
175
  npm start # run the built server over stdio
158
176
  npm test # vitest run (builds first)
159
177
  npm run test:watch # vitest watch mode
178
+ npm run test:coverage # vitest run --coverage (writes coverage/ report)
160
179
  npm run generate:presets # regenerate src/data/presets.generated.ts from the renovate devDep
161
180
  ```
162
181
 
163
182
  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.
164
183
 
165
- CI runs `typecheck`, `build`, and `test` on Node 24 for every PR and push to `main` (see `.github/workflows/ci.yml`).
184
+ 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.
166
185
 
167
186
  ## Release flow
168
187
 
@@ -184,9 +203,15 @@ Scope and non-goals are summarized in [What this is NOT](#what-this-is-not); thi
184
203
  - `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).
185
204
  - `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
205
  - `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.
206
+ - `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.
207
+ - `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.
208
+ - `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.
209
+ - `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.
188
210
  - `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
211
  - `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
212
  - 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.
213
+ - `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 the `RENOVATE_CONFIG_FILE` env var (the CLI has no `--config-file` flag), and deleted in a `finally` block. Token/password values — including the platform `token` input — are scrubbed from the detected `problems` list and the `logTail` fallback before returning.
214
+ - `dry_run` defaults to `--platform=local` so no host token is required, but that mode can't resolve `local>` presets (they have no platform context to expand against) and silently hides non-default-host GitHub/GitLab setups. Passing `platform` + `endpoint` + `token` + `repository` switches the run to a real platform client so the preset-fetch path works end-to-end; `--dry-run` is still set, so no PRs are opened. As a guard against the silent-failure mode, `dry_run` preflight-checks the repo's config: if it extends any `local>…` preset while the effective platform is `local`, the tool fails fast with specific remediation rather than spawning a Renovate run that would opaquely report `config-validation`.
215
+ - `dry_run` returns a top-level `ok` boolean that is `false` whenever the CLI exited non-zero OR the structured report contains a validation/error-level problem (`level >= 40`, `message === "config-validation"`, or a non-empty `validationError` field). Renovate frequently writes exit-code 0 alongside report-level failures — trusting the exit code alone hides runs that did nothing.
216
+ - `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.
192
217
  - `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
@@ -33,7 +33,7 @@ const requireCliRaw = process.env.RENOVATE_MCP_REQUIRE_CLI?.trim().toLowerCase()
33
33
  const suppressBanner = requireCliRaw === "false" || requireCliRaw === "0";
34
34
  const banner = suppressBanner ? null : startupBanner(setup);
35
35
  const instructions = banner ? [BASE_INSTRUCTIONS, "", banner].join("\n") : BASE_INSTRUCTIONS;
36
- const SERVER_VERSION = "0.4.0"; // x-release-please-version
36
+ const SERVER_VERSION = "0.6.0"; // x-release-please-version
37
37
  const server = new McpServer({ name: "renovate-mcp", version: SERVER_VERSION }, { instructions });
38
38
  registerCheckSetup(server);
39
39
  registerReadConfig(server);
@@ -36,11 +36,14 @@ export interface ExtractedDep {
36
36
  autoReplaceString?: string;
37
37
  }
38
38
  export interface PreviewOptions {
39
- maxFilesScanned?: number;
39
+ maxFilesWalked?: number;
40
+ maxFilesMatched?: number;
40
41
  maxHitsPerFile?: number;
42
+ matchTimeoutMs?: number;
43
+ maxFileBytes?: number;
41
44
  }
42
45
  export interface PreviewResult {
43
- filesScanned: number;
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 DEFAULT_MAX_FILES_SCANNED = 2000;
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 maxFilesScanned = options.maxFilesScanned ?? DEFAULT_MAX_FILES_SCANNED;
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
- const fileMatchRes = manager.fileMatch.map((s) => compileRegex(s));
27
- const matchStringRes = manager.matchStrings.map((s) => compileRegex(s, "gm"));
28
- const filesMatched = [];
29
- let filesScanned = 0;
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 (filesScanned >= maxFilesScanned) {
32
- warnings.push(`Stopped after scanning ${maxFilesScanned} files. Increase maxFilesScanned to widen the search.`);
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
- filesScanned++;
36
- if (fileMatchRes.some((re) => re.test(rel))) {
37
- filesMatched.push(rel);
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 < matchStringRes.length; i++) {
54
- const re = matchStringRes[i];
55
- // RegExp objects with the `g` flag are stateful — reset before iterating
56
- // each file so that previous calls don't shift lastIndex.
57
- re.lastIndex = 0;
58
- for (const m of content.matchAll(re)) {
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 groups = (m.groups ?? {});
64
- const line = lineNumberAt(content, m.index ?? 0);
65
- const matchStr = m[0];
66
- hits.push({ file: rel, matchStringIndex: i, line, match: matchStr, groups });
67
- extractedDeps.push(buildExtractedDep(rel, line, groups, manager));
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 { filesScanned, filesMatched, hits, extractedDeps, warnings };
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 compileRegex(source, extraFlags = "") {
153
+ function validateRegex(source) {
106
154
  try {
107
- // De-duplicate flags in case the source already had `g`/`m`.
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");
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Pure helpers for the dry_run tool's progress-notification pipeline. Kept in
3
+ * their own module so the parsing + message-building logic can be unit-tested
4
+ * without spawning an MCP server.
5
+ *
6
+ * The mapping from Renovate's JSON log output to progress messages is
7
+ * intentionally best-effort — Renovate's log schema is not a stable API, so we
8
+ * only read the `msg` field when present and fall back to an elapsed-time
9
+ * message otherwise.
10
+ */
11
+ /** Maximum length of a Renovate log `msg` copied into a progress update. */
12
+ export declare const DEFAULT_MAX_LOG_MSG_LEN = 160;
13
+ /**
14
+ * Parse a single stdout line as a Renovate JSON log entry and return its
15
+ * `msg` (trimmed and truncated) when present. Returns `undefined` for
16
+ * non-JSON lines, lines without a string `msg`, or empty messages.
17
+ */
18
+ export declare function extractRenovateLogMsg(line: string, maxLen?: number): string | undefined;
19
+ /**
20
+ * Build the message payload for a heartbeat-tick progress notification.
21
+ * Includes elapsed time and, when available, the most recently observed
22
+ * Renovate log `msg` so callers see *something* beyond "still running".
23
+ */
24
+ export declare function buildDryRunHeartbeatMessage(elapsedMs: number, lastLogMsg: string | undefined): string;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Pure helpers for the dry_run tool's progress-notification pipeline. Kept in
3
+ * their own module so the parsing + message-building logic can be unit-tested
4
+ * without spawning an MCP server.
5
+ *
6
+ * The mapping from Renovate's JSON log output to progress messages is
7
+ * intentionally best-effort — Renovate's log schema is not a stable API, so we
8
+ * only read the `msg` field when present and fall back to an elapsed-time
9
+ * message otherwise.
10
+ */
11
+ /** Maximum length of a Renovate log `msg` copied into a progress update. */
12
+ export const DEFAULT_MAX_LOG_MSG_LEN = 160;
13
+ /**
14
+ * Parse a single stdout line as a Renovate JSON log entry and return its
15
+ * `msg` (trimmed and truncated) when present. Returns `undefined` for
16
+ * non-JSON lines, lines without a string `msg`, or empty messages.
17
+ */
18
+ export function extractRenovateLogMsg(line, maxLen = DEFAULT_MAX_LOG_MSG_LEN) {
19
+ if (!line)
20
+ return undefined;
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(line);
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ if (!parsed || typeof parsed !== "object")
29
+ return undefined;
30
+ const msgVal = parsed.msg;
31
+ if (typeof msgVal !== "string")
32
+ return undefined;
33
+ const trimmed = msgVal.trim();
34
+ if (!trimmed)
35
+ return undefined;
36
+ return trimmed.slice(0, maxLen);
37
+ }
38
+ /**
39
+ * Build the message payload for a heartbeat-tick progress notification.
40
+ * Includes elapsed time and, when available, the most recently observed
41
+ * Renovate log `msg` so callers see *something* beyond "still running".
42
+ */
43
+ export function buildDryRunHeartbeatMessage(elapsedMs, lastLogMsg) {
44
+ const seconds = Math.max(0, Math.round(elapsedMs / 1000));
45
+ return lastLogMsg
46
+ ? `Dry-run in progress (${seconds}s) — ${lastLogMsg}`
47
+ : `Dry-run in progress (${seconds}s elapsed)`;
48
+ }
@@ -5,8 +5,10 @@ export type HostRule = Record<string, unknown>;
5
5
  * responsible for unlinking the file (in a `finally` block) once Renovate has
6
6
  * consumed it — we do not keep a reference here.
7
7
  *
8
- * Renovate merges CLI `--config-file` rules with any repo-level `hostRules`
9
- * (array concatenation, most-specific match wins), so callers can combine
8
+ * The caller points Renovate at this file via the `RENOVATE_CONFIG_FILE` env
9
+ * var (not a CLI flag — `--config-file` is not a real Renovate option).
10
+ * Renovate merges its rules with any repo-level `hostRules` via array
11
+ * concatenation (most-specific match wins), so callers can combine
10
12
  * per-invocation credentials with whatever the repo itself already declares.
11
13
  */
12
14
  export declare function writeHostRulesConfig(hostRules: HostRule[]): Promise<string>;
@@ -8,8 +8,10 @@ import { randomUUID } from "node:crypto";
8
8
  * responsible for unlinking the file (in a `finally` block) once Renovate has
9
9
  * consumed it — we do not keep a reference here.
10
10
  *
11
- * Renovate merges CLI `--config-file` rules with any repo-level `hostRules`
12
- * (array concatenation, most-specific match wins), so callers can combine
11
+ * The caller points Renovate at this file via the `RENOVATE_CONFIG_FILE` env
12
+ * var (not a CLI flag — `--config-file` is not a real Renovate option).
13
+ * Renovate merges its rules with any repo-level `hostRules` via array
14
+ * concatenation (most-specific match wins), so callers can combine
13
15
  * per-invocation credentials with whatever the repo itself already declares.
14
16
  */
15
17
  export async function writeHostRulesConfig(hostRules) {
@@ -39,18 +41,23 @@ export function collectSecrets(hostRules) {
39
41
  }
40
42
  return [...out];
41
43
  }
44
+ function escapeRegex(s) {
45
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ }
42
47
  /**
43
48
  * Replace every literal occurrence of any secret in `text` with `[REDACTED]`.
44
49
  * No-op when `secrets` is empty.
45
50
  */
46
51
  export function scrubSecrets(text, secrets) {
47
- if (!secrets.length)
52
+ // Sort by length descending so that when one secret is a substring of
53
+ // another, the longer one wins the greedy regex alternation instead of
54
+ // being partially chewed up by the shorter one.
55
+ const alternatives = secrets
56
+ .filter((s) => s.length > 0)
57
+ .sort((a, b) => b.length - a.length)
58
+ .map(escapeRegex);
59
+ if (!alternatives.length)
48
60
  return text;
49
- let result = text;
50
- for (const secret of secrets) {
51
- if (!secret)
52
- continue;
53
- result = result.split(secret).join("[REDACTED]");
54
- }
55
- return result;
61
+ const pattern = new RegExp(alternatives.join("|"), "g");
62
+ return text.replace(pattern, "[REDACTED]");
56
63
  }
@@ -69,6 +69,12 @@ export interface ParsedPreset {
69
69
  subpath?: string;
70
70
  /** Optional `#ref` (branch/tag/commit) fragment. */
71
71
  ref?: string;
72
+ /**
73
+ * Set when parsing already determined the preset cannot be resolved — e.g.
74
+ * an unknown source prefix like `xyz>foo/bar`. `loadPresetBody` short-circuits
75
+ * to the unresolved list with this reason.
76
+ */
77
+ unresolvableReason?: string;
72
78
  }
73
79
  /**
74
80
  * Resolve a Renovate config by expanding every preset in `extends`. Built-in
@@ -118,6 +118,10 @@ function recordTemplateWarnings(entry, suppliedArgs, missingArgs, unknownTemplat
118
118
  }
119
119
  }
120
120
  async function loadPresetBody(parsed, ctx, unresolvedList) {
121
+ if (parsed.unresolvableReason) {
122
+ unresolvedList.push({ preset: parsed.original, reason: parsed.unresolvableReason });
123
+ return null;
124
+ }
121
125
  if (!parsed.source) {
122
126
  const preset = PRESETS[parsed.key];
123
127
  if (!preset) {
@@ -199,9 +203,15 @@ export function parsePreset(raw) {
199
203
  return { key, original, args };
200
204
  }
201
205
  function parseExternal(rest, original, args, sourceRaw) {
202
- const source = (KNOWN_SOURCES.has(sourceRaw)
203
- ? sourceRaw
204
- : sourceRaw);
206
+ if (!KNOWN_SOURCES.has(sourceRaw)) {
207
+ return {
208
+ key: original,
209
+ original,
210
+ args,
211
+ unresolvableReason: `Unknown preset source: ${sourceRaw}`,
212
+ };
213
+ }
214
+ const source = sourceRaw;
205
215
  let spec = rest.slice(sourceRaw.length + 1);
206
216
  let ref;
207
217
  const hashIdx = spec.indexOf("#");
@@ -8,6 +8,14 @@ export interface RunOptions {
8
8
  env?: NodeJS.ProcessEnv;
9
9
  timeoutMs?: number;
10
10
  stdin?: string;
11
+ /**
12
+ * Optional line-oriented observers. Invoked once per complete line (newline
13
+ * stripped) as data arrives, and once more on process close for any trailing
14
+ * non-empty fragment. Exceptions thrown by the callbacks are swallowed so a
15
+ * buggy observer can't crash the child-process pipeline.
16
+ */
17
+ onStdoutLine?: (line: string) => void;
18
+ onStderrLine?: (line: string) => void;
11
19
  }
12
20
  /**
13
21
  * Spawn a command and capture stdout/stderr. Never throws on non-zero exit —
@@ -13,6 +13,8 @@ export function run(cmd, args, opts = {}) {
13
13
  });
14
14
  let stdout = "";
15
15
  let stderr = "";
16
+ let stdoutLineBuf = "";
17
+ let stderrLineBuf = "";
16
18
  let timer;
17
19
  let killed = false;
18
20
  if (opts.timeoutMs) {
@@ -21,11 +23,31 @@ export function run(cmd, args, opts = {}) {
21
23
  child.kill("SIGKILL");
22
24
  }, opts.timeoutMs);
23
25
  }
26
+ const emitLines = (chunk, buf, cb) => {
27
+ if (!cb)
28
+ return "";
29
+ const combined = buf + chunk;
30
+ const parts = combined.split(/\r?\n/);
31
+ const trailing = parts.pop() ?? "";
32
+ for (const line of parts) {
33
+ try {
34
+ cb(line);
35
+ }
36
+ catch {
37
+ // never let an observer crash the pipeline
38
+ }
39
+ }
40
+ return trailing;
41
+ };
24
42
  child.stdout.on("data", (d) => {
25
- stdout += d.toString();
43
+ const chunk = d.toString();
44
+ stdout += chunk;
45
+ stdoutLineBuf = emitLines(chunk, stdoutLineBuf, opts.onStdoutLine);
26
46
  });
27
47
  child.stderr.on("data", (d) => {
28
- stderr += d.toString();
48
+ const chunk = d.toString();
49
+ stderr += chunk;
50
+ stderrLineBuf = emitLines(chunk, stderrLineBuf, opts.onStderrLine);
29
51
  });
30
52
  child.on("error", (err) => {
31
53
  if (timer)
@@ -35,6 +57,23 @@ export function run(cmd, args, opts = {}) {
35
57
  child.on("close", (code) => {
36
58
  if (timer)
37
59
  clearTimeout(timer);
60
+ // Flush trailing partial lines (output that didn't end with a newline).
61
+ if (stdoutLineBuf && opts.onStdoutLine) {
62
+ try {
63
+ opts.onStdoutLine(stdoutLineBuf);
64
+ }
65
+ catch {
66
+ // ignore
67
+ }
68
+ }
69
+ if (stderrLineBuf && opts.onStderrLine) {
70
+ try {
71
+ opts.onStderrLine(stderrLineBuf);
72
+ }
73
+ catch {
74
+ // ignore
75
+ }
76
+ }
38
77
  if (killed) {
39
78
  reject(new Error(`Command timed out after ${opts.timeoutMs}ms: ${cmd} ${args.join(" ")}`));
40
79
  return;
@@ -4,17 +4,94 @@ import { promises as fs } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { run, resolveRenovateTool, formatMissingBinaryError } from "../lib/renovateCli.js";
7
+ import { locateConfig } from "../lib/configLocations.js";
7
8
  import { detectLookupProblems } from "../lib/lookupProblems.js";
8
9
  import { collectSecrets, scrubSecrets, writeHostRulesConfig, } from "../lib/hostRulesConfig.js";
10
+ import { extractRenovateLogMsg, buildDryRunHeartbeatMessage, } from "../lib/dryRunProgress.js";
9
11
  const hostRuleSchema = z
10
12
  .record(z.string(), z.unknown())
11
13
  .describe("A single Renovate hostRule (matchHost, hostType, username, password, token, …). Structure is not validated here — Renovate's own config loader checks it when the temp file is read.");
14
+ /**
15
+ * Walk Renovate's JSON report looking for `problems` arrays and collect the
16
+ * ones that represent failed runs — not informational notices. Criteria:
17
+ * - `level >= 40` (Renovate's bunyan-derived ERROR/FATAL bands), or
18
+ * - `message === "config-validation"` (Renovate's canonical config-failure
19
+ * marker — it can appear with lower levels but still means the run was
20
+ * effectively a no-op), or
21
+ * - the problem object has a `validationError` field (preset/extends
22
+ * resolution failures carry their explanation here).
23
+ *
24
+ * The report shape isn't a stable Renovate API, so we walk defensively: any
25
+ * nested `problems` array anywhere in the tree is inspected.
26
+ */
27
+ function collectReportErrors(report) {
28
+ const out = [];
29
+ const seen = new Set();
30
+ const visit = (node) => {
31
+ if (node === null || typeof node !== "object")
32
+ return;
33
+ if (seen.has(node))
34
+ return;
35
+ seen.add(node);
36
+ if (Array.isArray(node)) {
37
+ for (const entry of node)
38
+ visit(entry);
39
+ return;
40
+ }
41
+ const obj = node;
42
+ const problems = obj.problems;
43
+ if (Array.isArray(problems)) {
44
+ for (const raw of problems) {
45
+ if (!raw || typeof raw !== "object")
46
+ continue;
47
+ const p = raw;
48
+ const level = typeof p.level === "number" ? p.level : 0;
49
+ const isErrorLevel = level >= 40;
50
+ const isConfigValidation = p.message === "config-validation";
51
+ const hasValidationError = typeof p.validationError === "string" && p.validationError.length > 0;
52
+ if (isErrorLevel || isConfigValidation || hasValidationError) {
53
+ out.push(p);
54
+ }
55
+ }
56
+ }
57
+ for (const value of Object.values(obj))
58
+ visit(value);
59
+ };
60
+ visit(report);
61
+ return out;
62
+ }
63
+ /**
64
+ * Scan the repo's Renovate config for `extends` entries of the form
65
+ * `local>owner/repo[:preset]` and return them along with the config-file path
66
+ * they were found in. Returns `null` if no config is found (Renovate will
67
+ * handle that case itself with its usual error). Silently returns `null` on
68
+ * parse errors too — we don't want preflight to be stricter than Renovate's
69
+ * own config loader; any real problems will surface when Renovate runs.
70
+ */
71
+ async function detectUnresolvableLocalPresets(repoPath) {
72
+ let located;
73
+ try {
74
+ located = await locateConfig(repoPath);
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ if (!located)
80
+ return null;
81
+ const extendsRaw = located.config.extends;
82
+ if (!Array.isArray(extendsRaw))
83
+ return null;
84
+ const presets = extendsRaw.filter((entry) => typeof entry === "string" && entry.startsWith("local>"));
85
+ return { relPath: located.relPath, presets };
86
+ }
12
87
  export function registerDryRun(server) {
13
88
  server.registerTool("dry_run", {
14
89
  title: "Dry-run Renovate",
15
- description: "Run Renovate in dry-run mode against a local repository to preview what it would do — no PRs opened, no git pushes. Uses --platform=local so no host token is required. Emits a structured JSON report of the updates Renovate would create.\n\nCredentials for private registries (e.g. `COMPOSER_AUTH` for Packagist/Satis proxies, `NPM_TOKEN` / `.npmrc` for npm, Docker registry creds, `RENOVATE_HOST_RULES` for anything else) 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. Alternatively, encode credentials as `hostRules` in the Renovate config, or pass them per-call via the `hostRules` input on this tool (written to a mode-0600 temp file that is cleaned up after the run; token/password values are scrubbed from `logTail` and `problems`). Per-call `hostRules` are appended to any the repo's own config already declares. If a lookup can't auth to a registry, Renovate often reports 0 updates without a loud error; when that happens this tool surfaces detected auth failures under `problems` in the output so callers can distinguish a genuine \"no updates\" from a silent registry-auth failure.",
90
+ description: "Run Renovate in dry-run mode to preview what it would do — no PRs opened, no git pushes. Returns a structured JSON report plus a top-level `ok` boolean (false when the CLI failed OR the report records a validation/error-level problem, even if the exit code was 0).\n\nDefault mode runs `--platform=local` against the filesystem at `repoPath`. If your config extends `local>…` presets (or any preset hosted on a non-default GitHub/GitLab), pass `platform` (`github` or `gitlab`), `endpoint` (API base URL), `token`, and `repository` (`owner/repo`) to run as a real platform client that can actually fetch those presets — Renovate still runs with `--dry-run`, so no PRs are opened. The tool preflight-checks for `local>` presets under `--platform=local` and fails fast with remediation guidance rather than spawning a Renovate run that would fail opaquely with `config-validation`.\n\nCredentials for private registries (e.g. `COMPOSER_AUTH` for Packagist/Satis proxies, `NPM_TOKEN` / `.npmrc` for npm, Docker registry creds, `RENOVATE_HOST_RULES` for anything else) 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. Alternatively, encode credentials as `hostRules` in the Renovate config, or pass them per-call via the `hostRules` input on this tool (written to a mode-0600 temp file that is cleaned up after the run; token/password values — including the platform `token` input — are scrubbed from `logTail` and `problems`). Per-call `hostRules` are appended to any the repo's own config already declares. If a lookup can't auth to a registry, Renovate often reports 0 updates without a loud error; when that happens this tool surfaces detected auth failures under `problems` in the output so callers can distinguish a genuine \"no updates\" from a silent registry-auth failure.",
16
91
  inputSchema: {
17
- repoPath: z.string().describe("Absolute path to the repository root"),
92
+ repoPath: z
93
+ .string()
94
+ .describe("Absolute path to the repository root. Required for the default `platform=local` mode. When `platform` is overridden (e.g. `gitlab` / `github`), Renovate runs against the remote `repository` instead — `repoPath` is still used as the child's working directory but its manifest files are ignored."),
18
95
  dryRunMode: z
19
96
  .enum(["extract", "lookup", "full"])
20
97
  .default("full")
@@ -27,33 +104,143 @@ export function registerDryRun(server) {
27
104
  .max(15 * 60_000)
28
105
  .default(5 * 60_000)
29
106
  .describe("Max wait time in ms (default 5 minutes, capped at 15)"),
107
+ platform: z
108
+ .enum(["local", "github", "gitlab"])
109
+ .optional()
110
+ .describe("Renovate platform to run as. Default `local` runs against the filesystem at `repoPath`. Set `github` or `gitlab` (with `endpoint` + `token` + `repository`) to run a full remote dry-run — this is what you need when your config extends `local>` presets that live on a private GitHub Enterprise / self-hosted GitLab. Still no PRs opened because `--dry-run` is always set."),
111
+ endpoint: z
112
+ .string()
113
+ .optional()
114
+ .describe("API base URL for the chosen platform (e.g. `https://gitlab.example.com/api/v4/` or `https://ghe.example.com/api/v3/`). Required with non-default GitHub/GitLab hosts. Ignored when `platform=local`."),
115
+ token: z
116
+ .string()
117
+ .optional()
118
+ .describe("Platform auth token. Scrubbed from any log output this tool returns. Ignored when `platform=local`."),
119
+ repository: z
120
+ .string()
121
+ .optional()
122
+ .describe("`owner/repo` identifier of the repository Renovate should operate on. Required when `platform` is not `local`. Ignored when `platform=local`."),
30
123
  hostRules: z
31
124
  .array(hostRuleSchema)
32
125
  .optional()
33
- .describe("Optional per-invocation Renovate hostRules for private registry auth. Written to a mode-0600 temp file that is passed via --config-file and deleted after the run. Token/password values are scrubbed from any log output this tool returns. Appended to (not replacing) any hostRules declared in the repo's own config."),
126
+ .describe("Optional per-invocation Renovate hostRules for private registry auth. Written to a mode-0600 temp file that is passed via the RENOVATE_CONFIG_FILE env var and deleted after the run. Token/password values are scrubbed from any log output this tool returns. Appended to (not replacing) any hostRules declared in the repo's own config."),
34
127
  },
35
- }, async ({ repoPath, dryRunMode, logLevel, timeoutMs, hostRules }) => {
128
+ }, async ({ repoPath, dryRunMode, logLevel, timeoutMs, platform, endpoint, token, repository, hostRules }, extra) => {
36
129
  const reportPath = path.join(tmpdir(), `renovate-mcp-report-${randomUUID()}.json`);
37
130
  const bin = resolveRenovateTool("renovate");
38
131
  const ruleList = hostRules ?? [];
132
+ const effectivePlatform = platform ?? "local";
133
+ const isRemotePlatform = effectivePlatform !== "local";
39
134
  const secrets = collectSecrets(ruleList);
135
+ if (token)
136
+ secrets.push(token);
40
137
  let hostRulesConfigPath;
138
+ if (isRemotePlatform && !repository) {
139
+ return {
140
+ isError: true,
141
+ content: [
142
+ {
143
+ type: "text",
144
+ text: `\`repository\` is required when \`platform\` is \`${effectivePlatform}\`. Pass the target repo as \`owner/repo\`, or unset \`platform\` to run against the local filesystem at \`repoPath\`.`,
145
+ },
146
+ ],
147
+ };
148
+ }
149
+ // Preflight: if the repo's config extends any `local>…` preset and we're
150
+ // about to run with `--platform=local`, Renovate will fail opaquely with
151
+ // a generic "config-validation" error, because `local>` has no platform
152
+ // context to resolve against in local mode. Detect this up front and
153
+ // return an actionable message instead of spawning Renovate.
154
+ if (!isRemotePlatform) {
155
+ const unresolvable = await detectUnresolvableLocalPresets(repoPath);
156
+ if (unresolvable && unresolvable.presets.length > 0) {
157
+ const sample = unresolvable.presets.slice(0, 3).join(", ");
158
+ const more = unresolvable.presets.length > 3
159
+ ? ` (+${unresolvable.presets.length - 3} more)`
160
+ : "";
161
+ return {
162
+ isError: true,
163
+ content: [
164
+ {
165
+ type: "text",
166
+ text: `\`${unresolvable.relPath}\` extends \`local>\` presets (${sample}${more}) that cannot be resolved under \`--platform=local\` — Renovate has no platform context to expand them against, so the run would fail with an opaque \`config-validation\` error.\n\n` +
167
+ `To fix: pass \`platform\` (e.g. \`gitlab\` or \`github\`), \`endpoint\`, \`token\`, and \`repository\` so Renovate runs as a real platform client and can fetch the preset. Alternatively, rewrite the \`extends\` entries from \`local>…\` to \`gitlab>…\` / \`github>…\` in your config.`,
168
+ },
169
+ ],
170
+ };
171
+ }
172
+ }
173
+ // Hybrid progress reporter: a heartbeat tick every HEARTBEAT_MS keeps
174
+ // MCP clients informed during long runs, and each tick enriches its
175
+ // message with the latest Renovate JSON-log `msg` we saw (best-effort —
176
+ // we don't couple tightly to Renovate's log schema). Skips all work if
177
+ // the caller didn't supply a progressToken.
178
+ const progressToken = extra?._meta?.progressToken;
179
+ const sendNotification = extra?.sendNotification;
180
+ const progressEnabled = progressToken !== undefined && typeof sendNotification === "function";
181
+ const HEARTBEAT_MS = 5_000;
182
+ const startedAt = Date.now();
183
+ let progressCounter = 0;
184
+ let lastLogMsg;
185
+ let heartbeat;
186
+ const emit = (message) => {
187
+ if (!progressEnabled)
188
+ return;
189
+ progressCounter += 1;
190
+ void sendNotification({
191
+ method: "notifications/progress",
192
+ params: {
193
+ progressToken,
194
+ progress: progressCounter,
195
+ message: scrubSecrets(message, secrets),
196
+ },
197
+ }).catch(() => undefined);
198
+ };
41
199
  try {
42
200
  const args = [
43
- "--platform=local",
201
+ `--platform=${effectivePlatform}`,
44
202
  `--dry-run=${dryRunMode}`,
45
203
  "--report-type=file",
46
204
  `--report-path=${reportPath}`,
47
205
  ];
206
+ const childEnv = {
207
+ LOG_LEVEL: logLevel,
208
+ LOG_FORMAT: "json",
209
+ };
210
+ if (isRemotePlatform) {
211
+ if (endpoint)
212
+ args.push(`--endpoint=${endpoint}`);
213
+ args.push(`--repository=${repository}`);
214
+ if (token)
215
+ childEnv.RENOVATE_TOKEN = token;
216
+ }
48
217
  if (ruleList.length > 0) {
49
218
  hostRulesConfigPath = await writeHostRulesConfig(ruleList);
50
- args.push(`--config-file=${hostRulesConfigPath}`);
219
+ // Renovate reads this config file via the RENOVATE_CONFIG_FILE env
220
+ // var, not a CLI flag — `--config-file` is not a real Renovate flag
221
+ // and passing it crashes the CLI with "unknown option".
222
+ childEnv.RENOVATE_CONFIG_FILE = hostRulesConfigPath;
223
+ }
224
+ if (progressEnabled) {
225
+ emit(`Starting Renovate dry-run (${dryRunMode})`);
226
+ heartbeat = setInterval(() => emit(buildDryRunHeartbeatMessage(Date.now() - startedAt, lastLogMsg)), HEARTBEAT_MS);
51
227
  }
52
228
  const result = await run(bin, args, {
53
229
  cwd: repoPath,
54
- env: { LOG_LEVEL: logLevel, LOG_FORMAT: "json" },
230
+ env: childEnv,
55
231
  timeoutMs,
232
+ onStdoutLine: progressEnabled
233
+ ? (line) => {
234
+ const msg = extractRenovateLogMsg(line);
235
+ if (msg)
236
+ lastLogMsg = msg;
237
+ }
238
+ : undefined,
56
239
  });
240
+ if (heartbeat) {
241
+ clearInterval(heartbeat);
242
+ heartbeat = undefined;
243
+ }
57
244
  let report = null;
58
245
  try {
59
246
  const raw = await fs.readFile(reportPath, "utf8");
@@ -62,7 +249,15 @@ export function registerDryRun(server) {
62
249
  catch {
63
250
  // no report produced (e.g., renovate errored before writing)
64
251
  }
252
+ const reportErrors = collectReportErrors(report);
65
253
  const summary = {
254
+ // `ok` collapses the three failure modes (spawn error, non-zero
255
+ // exit, in-report validation/error-level problems) into a single
256
+ // field so callers don't need to know which one fired. Renovate
257
+ // frequently writes a report with exitCode=0 while its `problems`
258
+ // array records a validation/config failure — judging only by
259
+ // exitCode hides those runs.
260
+ ok: result.exitCode === 0 && reportErrors.length === 0,
66
261
  exitCode: result.exitCode,
67
262
  hasReport: report !== null,
68
263
  report,
@@ -73,6 +268,9 @@ export function registerDryRun(server) {
73
268
  if (problems.length > 0) {
74
269
  summary.problems = problems;
75
270
  }
271
+ if (reportErrors.length > 0) {
272
+ summary.reportErrors = reportErrors;
273
+ }
76
274
  // If no structured report, surface the last bit of stderr so Claude can
77
275
  // debug without blowing up the context with Renovate's verbose logs.
78
276
  if (!report) {
@@ -83,6 +281,11 @@ export function registerDryRun(server) {
83
281
  .join("\n");
84
282
  summary.logTail = tail;
85
283
  }
284
+ if (progressEnabled) {
285
+ emit(report
286
+ ? `Dry-run complete (exit ${result.exitCode})`
287
+ : `Dry-run finished without a report (exit ${result.exitCode})`);
288
+ }
86
289
  return {
87
290
  content: [
88
291
  {
@@ -90,7 +293,7 @@ export function registerDryRun(server) {
90
293
  text: JSON.stringify(summary, null, 2),
91
294
  },
92
295
  ],
93
- isError: result.exitCode !== 0,
296
+ isError: !summary.ok,
94
297
  };
95
298
  }
96
299
  catch (err) {
@@ -105,6 +308,8 @@ export function registerDryRun(server) {
105
308
  };
106
309
  }
107
310
  finally {
311
+ if (heartbeat)
312
+ clearInterval(heartbeat);
108
313
  await fs.unlink(reportPath).catch(() => undefined);
109
314
  if (hostRulesConfigPath) {
110
315
  await fs.unlink(hostRulesConfigPath).catch(() => undefined);
@@ -35,13 +35,20 @@ export function registerPreviewCustomManager(server) {
35
35
  inputSchema: {
36
36
  repoPath: z.string().describe("Absolute path to the repository root"),
37
37
  manager: managerSchema.describe("A single Renovate customManagers entry. NOTE: `fileMatch` is an array of REGEX strings matched against POSIX-style relative paths (not globs)."),
38
- maxFilesScanned: z
38
+ maxFilesWalked: z
39
39
  .number()
40
40
  .int()
41
41
  .positive()
42
42
  .max(100_000)
43
43
  .optional()
44
- .describe("Safety cap on files walked (default 2000)"),
44
+ .describe("Safety cap on files visited during the directory walk before any fileMatch testing (default 2000). Raise this when the repo is large; prefer narrowing via `.gitignore` first."),
45
+ maxFilesMatched: z
46
+ .number()
47
+ .int()
48
+ .positive()
49
+ .max(100_000)
50
+ .optional()
51
+ .describe("Safety cap on the number of files included in the result after fileMatch is applied (default 500). Raise this only if the fileMatch regex is intentionally broad."),
45
52
  maxHitsPerFile: z
46
53
  .number()
47
54
  .int()
@@ -49,8 +56,22 @@ export function registerPreviewCustomManager(server) {
49
56
  .max(10_000)
50
57
  .optional()
51
58
  .describe("Safety cap on matches per file (default 100)"),
59
+ matchTimeoutMs: z
60
+ .number()
61
+ .int()
62
+ .positive()
63
+ .max(60_000)
64
+ .optional()
65
+ .describe("Wall-clock budget per regex operation, in milliseconds (default 2000). User-supplied patterns run in a worker thread so catastrophic backtracking can't pin the server; this cap decides how long we wait before aborting a single pattern and emitting a warning."),
66
+ maxFileBytes: z
67
+ .number()
68
+ .int()
69
+ .positive()
70
+ .max(1024 * 1024 * 1024)
71
+ .optional()
72
+ .describe("Per-file size cap in bytes (default 5242880 = 5 MiB). Files whose size exceeds this are skipped with a warning instead of being read into memory — protects against OOM when fileMatch catches a lockfile, generated artifact, or other oversized file."),
52
73
  },
53
- }, async ({ repoPath, manager, maxFilesScanned, maxHitsPerFile }) => {
74
+ }, async ({ repoPath, manager, maxFilesWalked, maxFilesMatched, maxHitsPerFile, matchTimeoutMs, maxFileBytes, }) => {
54
75
  if (manager.customType !== "regex") {
55
76
  return {
56
77
  isError: true,
@@ -64,8 +85,11 @@ export function registerPreviewCustomManager(server) {
64
85
  }
65
86
  try {
66
87
  const result = await previewCustomManager(repoPath, manager, {
67
- maxFilesScanned,
88
+ maxFilesWalked,
89
+ maxFilesMatched,
68
90
  maxHitsPerFile,
91
+ matchTimeoutMs,
92
+ maxFileBytes,
69
93
  });
70
94
  return {
71
95
  content: [
@@ -58,52 +58,58 @@ export function registerWriteConfig(server) {
58
58
  const tmp = `${target}.renovate-mcp-tmp`;
59
59
  await fs.mkdir(path.dirname(target), { recursive: true });
60
60
  await fs.writeFile(tmp, payload);
61
- let valid = false;
62
- let validationOutput = "";
63
- let validatorMissing = false;
64
61
  try {
65
- const bin = resolveRenovateTool("renovate-config-validator");
66
- const v = await run(bin, [tmp], { timeoutMs: 30_000 });
67
- validationOutput = (v.stdout + v.stderr).trim();
68
- valid = v.exitCode === 0;
69
- }
70
- catch (err) {
71
- validatorMissing = true;
72
- validationOutput = formatMissingBinaryError("renovate-config-validator", err);
73
- }
74
- if (!valid && !force) {
75
- await fs.unlink(tmp).catch(() => undefined);
62
+ let valid = false;
63
+ let validationOutput = "";
64
+ let validatorMissing = false;
65
+ try {
66
+ const bin = resolveRenovateTool("renovate-config-validator");
67
+ const v = await run(bin, [tmp], { timeoutMs: 30_000 });
68
+ validationOutput = (v.stdout + v.stderr).trim();
69
+ valid = v.exitCode === 0;
70
+ }
71
+ catch (err) {
72
+ validatorMissing = true;
73
+ validationOutput = formatMissingBinaryError("renovate-config-validator", err);
74
+ }
75
+ if (!valid && !force) {
76
+ return {
77
+ isError: true,
78
+ content: [
79
+ {
80
+ type: "text",
81
+ text: JSON.stringify({
82
+ wrote: false,
83
+ reason: validatorMissing ? "validator-unavailable" : "validation-failed",
84
+ validationOutput,
85
+ hint: validatorMissing
86
+ ? "Install renovate-config-validator (or set RENOVATE_CONFIG_VALIDATOR_BIN), then retry. Pass force=true to skip validation entirely."
87
+ : "Pass force=true to write anyway.",
88
+ }, null, 2),
89
+ },
90
+ ],
91
+ };
92
+ }
93
+ await fs.rename(tmp, target);
76
94
  return {
77
- isError: true,
78
95
  content: [
79
96
  {
80
97
  type: "text",
81
98
  text: JSON.stringify({
82
- wrote: false,
83
- reason: validatorMissing ? "validator-unavailable" : "validation-failed",
84
- validationOutput,
85
- hint: validatorMissing
86
- ? "Install renovate-config-validator (or set RENOVATE_CONFIG_VALIDATOR_BIN), then retry. Pass force=true to skip validation entirely."
87
- : "Pass force=true to write anyway.",
99
+ wrote: true,
100
+ path: rel,
101
+ bytes: payload.length,
102
+ valid,
103
+ validationOutput: valid ? undefined : validationOutput,
88
104
  }, null, 2),
89
105
  },
90
106
  ],
91
107
  };
92
108
  }
93
- await fs.rename(tmp, target);
94
- return {
95
- content: [
96
- {
97
- type: "text",
98
- text: JSON.stringify({
99
- wrote: true,
100
- path: rel,
101
- bytes: payload.length,
102
- valid,
103
- validationOutput: valid ? undefined : validationOutput,
104
- }, null, 2),
105
- },
106
- ],
107
- };
109
+ finally {
110
+ // No-op when rename already moved tmp (ENOENT); otherwise cleans up on
111
+ // validation failure or a mid-flight rename error (see #57).
112
+ await fs.unlink(tmp).catch(() => undefined);
113
+ }
108
114
  });
109
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "renovate-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "MCP server for designing Renovate configurations interactively.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,8 @@
20
20
  "pretest": "npm run build",
21
21
  "test": "vitest run",
22
22
  "test:watch": "vitest",
23
+ "pretest:coverage": "npm run build",
24
+ "test:coverage": "vitest run --coverage",
23
25
  "generate:presets": "node scripts/generate-presets.mjs",
24
26
  "prepack": "npm run build && chmod +x dist/index.js"
25
27
  },
@@ -56,6 +58,7 @@
56
58
  },
57
59
  "devDependencies": {
58
60
  "@types/node": "^24.0.0",
61
+ "@vitest/coverage-v8": "^4.1.0",
59
62
  "renovate": "^43.139.5",
60
63
  "typescript": "^6.0.0",
61
64
  "vitest": "^4.1.0"