renovate-mcp 0.4.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 +68 -46
- package/dist/index.js +1 -1
- 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/hostRulesConfig.js +13 -8
- package/dist/lib/presetResolver.d.ts +6 -0
- package/dist/lib/presetResolver.js +13 -3
- package/dist/lib/renovateCli.d.ts +8 -0
- package/dist/lib/renovateCli.js +41 -2
- package/dist/tools/dryRun.js +50 -1
- package/dist/tools/previewCustomManager.js +28 -4
- package/dist/tools/writeConfig.js +42 -36
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,24 +1,43 @@
|
|
|
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
|
-
| `lint_config` | Semantic lint
|
|
17
|
-
| `dry_run` | Run Renovate with `--platform=local --dry-run
|
|
18
|
-
| `write_config` | Validate, then write a config to disk
|
|
19
|
-
| `renovate://presets` (resource) |
|
|
20
|
-
| `renovate://presets/{namespace}` (resource template) | Markdown listing
|
|
21
|
-
| `renovate://preset/{name}` (resource template) | Expanded JSON body
|
|
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).
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
Config lives in `.mcp.json` (project-scoped) or `~/.claude.json` (user-scoped).
|
|
78
92
|
|
|
79
|
-
|
|
93
|
+
### Claude Desktop
|
|
80
94
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
|
@@ -157,12 +174,13 @@ npm run build # compile to dist/
|
|
|
157
174
|
npm start # run the built server over stdio
|
|
158
175
|
npm test # vitest run (builds first)
|
|
159
176
|
npm run test:watch # vitest watch mode
|
|
177
|
+
npm run test:coverage # vitest run --coverage (writes coverage/ report)
|
|
160
178
|
npm run generate:presets # regenerate src/data/presets.generated.ts from the renovate devDep
|
|
161
179
|
```
|
|
162
180
|
|
|
163
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.
|
|
164
182
|
|
|
165
|
-
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.
|
|
166
184
|
|
|
167
185
|
## Release flow
|
|
168
186
|
|
|
@@ -184,9 +202,13 @@ Scope and non-goals are summarized in [What this is NOT](#what-this-is-not); thi
|
|
|
184
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).
|
|
185
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.
|
|
186
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.
|
|
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 `
|
|
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.
|
|
188
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.
|
|
189
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.
|
|
190
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.
|
|
191
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.
|
|
192
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
|
@@ -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.
|
|
36
|
+
const SERVER_VERSION = "0.5.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
|
-
|
|
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");
|
|
@@ -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
|
+
}
|
|
@@ -39,18 +39,23 @@ export function collectSecrets(hostRules) {
|
|
|
39
39
|
}
|
|
40
40
|
return [...out];
|
|
41
41
|
}
|
|
42
|
+
function escapeRegex(s) {
|
|
43
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
44
|
+
}
|
|
42
45
|
/**
|
|
43
46
|
* Replace every literal occurrence of any secret in `text` with `[REDACTED]`.
|
|
44
47
|
* No-op when `secrets` is empty.
|
|
45
48
|
*/
|
|
46
49
|
export function scrubSecrets(text, secrets) {
|
|
47
|
-
|
|
50
|
+
// Sort by length descending so that when one secret is a substring of
|
|
51
|
+
// another, the longer one wins the greedy regex alternation instead of
|
|
52
|
+
// being partially chewed up by the shorter one.
|
|
53
|
+
const alternatives = secrets
|
|
54
|
+
.filter((s) => s.length > 0)
|
|
55
|
+
.sort((a, b) => b.length - a.length)
|
|
56
|
+
.map(escapeRegex);
|
|
57
|
+
if (!alternatives.length)
|
|
48
58
|
return text;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (!secret)
|
|
52
|
-
continue;
|
|
53
|
-
result = result.split(secret).join("[REDACTED]");
|
|
54
|
-
}
|
|
55
|
-
return result;
|
|
59
|
+
const pattern = new RegExp(alternatives.join("|"), "g");
|
|
60
|
+
return text.replace(pattern, "[REDACTED]");
|
|
56
61
|
}
|
|
@@ -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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 —
|
package/dist/lib/renovateCli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/tools/dryRun.js
CHANGED
|
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
|
|
6
6
|
import { run, resolveRenovateTool, formatMissingBinaryError } from "../lib/renovateCli.js";
|
|
7
7
|
import { detectLookupProblems } from "../lib/lookupProblems.js";
|
|
8
8
|
import { collectSecrets, scrubSecrets, writeHostRulesConfig, } from "../lib/hostRulesConfig.js";
|
|
9
|
+
import { extractRenovateLogMsg, buildDryRunHeartbeatMessage, } from "../lib/dryRunProgress.js";
|
|
9
10
|
const hostRuleSchema = z
|
|
10
11
|
.record(z.string(), z.unknown())
|
|
11
12
|
.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.");
|
|
@@ -32,12 +33,38 @@ export function registerDryRun(server) {
|
|
|
32
33
|
.optional()
|
|
33
34
|
.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."),
|
|
34
35
|
},
|
|
35
|
-
}, async ({ repoPath, dryRunMode, logLevel, timeoutMs, hostRules }) => {
|
|
36
|
+
}, async ({ repoPath, dryRunMode, logLevel, timeoutMs, hostRules }, extra) => {
|
|
36
37
|
const reportPath = path.join(tmpdir(), `renovate-mcp-report-${randomUUID()}.json`);
|
|
37
38
|
const bin = resolveRenovateTool("renovate");
|
|
38
39
|
const ruleList = hostRules ?? [];
|
|
39
40
|
const secrets = collectSecrets(ruleList);
|
|
40
41
|
let hostRulesConfigPath;
|
|
42
|
+
// Hybrid progress reporter: a heartbeat tick every HEARTBEAT_MS keeps
|
|
43
|
+
// MCP clients informed during long runs, and each tick enriches its
|
|
44
|
+
// message with the latest Renovate JSON-log `msg` we saw (best-effort —
|
|
45
|
+
// we don't couple tightly to Renovate's log schema). Skips all work if
|
|
46
|
+
// the caller didn't supply a progressToken.
|
|
47
|
+
const progressToken = extra?._meta?.progressToken;
|
|
48
|
+
const sendNotification = extra?.sendNotification;
|
|
49
|
+
const progressEnabled = progressToken !== undefined && typeof sendNotification === "function";
|
|
50
|
+
const HEARTBEAT_MS = 5_000;
|
|
51
|
+
const startedAt = Date.now();
|
|
52
|
+
let progressCounter = 0;
|
|
53
|
+
let lastLogMsg;
|
|
54
|
+
let heartbeat;
|
|
55
|
+
const emit = (message) => {
|
|
56
|
+
if (!progressEnabled)
|
|
57
|
+
return;
|
|
58
|
+
progressCounter += 1;
|
|
59
|
+
void sendNotification({
|
|
60
|
+
method: "notifications/progress",
|
|
61
|
+
params: {
|
|
62
|
+
progressToken,
|
|
63
|
+
progress: progressCounter,
|
|
64
|
+
message: scrubSecrets(message, secrets),
|
|
65
|
+
},
|
|
66
|
+
}).catch(() => undefined);
|
|
67
|
+
};
|
|
41
68
|
try {
|
|
42
69
|
const args = [
|
|
43
70
|
"--platform=local",
|
|
@@ -49,11 +76,26 @@ export function registerDryRun(server) {
|
|
|
49
76
|
hostRulesConfigPath = await writeHostRulesConfig(ruleList);
|
|
50
77
|
args.push(`--config-file=${hostRulesConfigPath}`);
|
|
51
78
|
}
|
|
79
|
+
if (progressEnabled) {
|
|
80
|
+
emit(`Starting Renovate dry-run (${dryRunMode})`);
|
|
81
|
+
heartbeat = setInterval(() => emit(buildDryRunHeartbeatMessage(Date.now() - startedAt, lastLogMsg)), HEARTBEAT_MS);
|
|
82
|
+
}
|
|
52
83
|
const result = await run(bin, args, {
|
|
53
84
|
cwd: repoPath,
|
|
54
85
|
env: { LOG_LEVEL: logLevel, LOG_FORMAT: "json" },
|
|
55
86
|
timeoutMs,
|
|
87
|
+
onStdoutLine: progressEnabled
|
|
88
|
+
? (line) => {
|
|
89
|
+
const msg = extractRenovateLogMsg(line);
|
|
90
|
+
if (msg)
|
|
91
|
+
lastLogMsg = msg;
|
|
92
|
+
}
|
|
93
|
+
: undefined,
|
|
56
94
|
});
|
|
95
|
+
if (heartbeat) {
|
|
96
|
+
clearInterval(heartbeat);
|
|
97
|
+
heartbeat = undefined;
|
|
98
|
+
}
|
|
57
99
|
let report = null;
|
|
58
100
|
try {
|
|
59
101
|
const raw = await fs.readFile(reportPath, "utf8");
|
|
@@ -83,6 +125,11 @@ export function registerDryRun(server) {
|
|
|
83
125
|
.join("\n");
|
|
84
126
|
summary.logTail = tail;
|
|
85
127
|
}
|
|
128
|
+
if (progressEnabled) {
|
|
129
|
+
emit(report
|
|
130
|
+
? `Dry-run complete (exit ${result.exitCode})`
|
|
131
|
+
: `Dry-run finished without a report (exit ${result.exitCode})`);
|
|
132
|
+
}
|
|
86
133
|
return {
|
|
87
134
|
content: [
|
|
88
135
|
{
|
|
@@ -105,6 +152,8 @@ export function registerDryRun(server) {
|
|
|
105
152
|
};
|
|
106
153
|
}
|
|
107
154
|
finally {
|
|
155
|
+
if (heartbeat)
|
|
156
|
+
clearInterval(heartbeat);
|
|
108
157
|
await fs.unlink(reportPath).catch(() => undefined);
|
|
109
158
|
if (hostRulesConfigPath) {
|
|
110
159
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.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"
|