renovate-mcp 1.8.1 → 2.0.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
@@ -45,7 +45,7 @@ Nineteen tools, seven resources, and four workflow prompts. Each tool name below
45
45
  | [`preview_custom_manager`](docs/tools.md#preview_custom_manager) | Preview a `customManagers` entry (regex or JSONata) against a local repo. Offline. |
46
46
  | [`validate_config`](docs/tools.md#validate_config) | Run `renovate-config-validator` against a file or inline object. |
47
47
  | [`lint_config`](docs/tools.md#lint_config) | Semantic lint pass for Renovate-specific footguns the schema validator declares valid. Offline. |
48
- | [`dry_run`](docs/tools.md#dry_run) | Run Renovate with `--dry-run` and return the structured JSON report. Local-by-default; remote with `platform` + `endpoint` + `token` + `repository`. No PRs, no pushes. |
48
+ | [`dry_run`](docs/tools.md#dry_run) | Run Renovate with `--dry-run` and return the structured JSON report. Local-by-default, flattening `local>` presets with Renovate's own resolver into a temporary copy first; remote with `platform` + `endpoint` + `token` + `repository`. No PRs, no pushes. |
49
49
  | [`dry_run_diff`](docs/tools.md#dry_run_diff) | Stateless semantic diff between two `dry_run` reports — added / removed / changed updates. |
50
50
  | [`annotate_dry_run`](docs/tools.md#annotate_dry_run) | Attribute each proposed update in a `dry_run` report to the `packageRules` that caused it; flags rules that never matched and report fields the matchers needed but the report lacked. Stateless + offline. |
51
51
  | [`explain_dependency`](docs/tools.md#explain_dependency) | Answer "why was/wasn't dependency X updated?" from a `dry_run` report: per-occurrence `skipReason`, proposed `updates`, `warnings`, a one-line verdict and remedy hints; optionally the `packageRules` that matched it. Stateless + offline. |
@@ -81,7 +81,8 @@ Renovate ships bundled — the `renovate` package is a runtime dependency, so `v
81
81
 
82
82
  - `RENOVATE_BIN` / `RENOVATE_CONFIG_VALIDATOR_BIN` — override the bundled binaries. When set, the override always wins.
83
83
  - `RENOVATE_MCP_REQUIRE_CLI=false` — suppress the startup "partial availability" notice when you only intend to use the offline tools.
84
- - Platform + token env vars — see [`docs/platform-setup.md`](docs/platform-setup.md) for the per-platform matrix.
84
+ - Platform + token env vars — see [`docs/platform-setup.md`](docs/platform-setup.md) for the per-platform matrix. Preset fetching and `dry_run` bind every token to its own host; `RENOVATE_TOKEN` only covers the home host named by `RENOVATE_PLATFORM` / `RENOVATE_ENDPOINT`. A `platform` / `endpoint` passed on a call routes requests but never moves a token.
85
+ - `RENOVATE_MCP_HOST_TOKENS` — JSON array of `{matchHost, hostType, tokenEnv}` naming the env var that holds the token for an extra host (never the value itself). See [`docs/security.md`](docs/security.md#token-resolution).
85
86
  - `GITHUB_COM_TOKEN` — authenticates github.com *datasource* lookups (release notes, `github-tags` / `github-releases` / `github-actions`). Distinct from the platform token and never auto-derived from it; set it when github.com-hosted deps are resolved under the default `dry_run` `platform: local`. See [`docs/security.md`](docs/security.md#github_com_token--a-separate-role).
86
87
 
87
88
  > Heads up: MCP servers do **not** inherit your shell env. Set every env var via the `env` key in `.mcp.json` / `claude_desktop_config.json`. See [`docs/security.md`](docs/security.md#where-env-vars-must-live).
@@ -139,8 +140,9 @@ The offline tools need nothing. Add env vars only for what you use:
139
140
  | Local `dry_run` on a repo with github.com dependencies | `GITHUB_COM_TOKEN` (read-only PAT, avoids anonymous rate limits) |
140
141
  | `dry_run` against GitHub, or private `github>` presets | `RENOVATE_PLATFORM=github` + `GITHUB_TOKEN` |
141
142
  | `dry_run` against GitLab, or private `gitlab>` presets | `RENOVATE_PLATFORM=gitlab` + `GITLAB_TOKEN` |
143
+ | Private presets on another host | `RENOVATE_MCP_HOST_TOKENS` + the variable it names |
142
144
 
143
- Self-hosted GitLab and GitHub Enterprise also need `RENOVATE_ENDPOINT` — see [Platform setup](#platform-setup).
145
+ Self-hosted GitLab and GitHub Enterprise also need `RENOVATE_ENDPOINT` — see [Platform setup](#platform-setup). Each token is only sent to its own platform's host.
144
146
 
145
147
  In Claude Code, re-register with `-e`:
146
148
 
@@ -169,11 +171,11 @@ For `dry_run` against a remote platform or `resolve_config` with `externalPreset
169
171
  | GitHub Enterprise | `github` | `https://github.example.com/api/v3/` | `RENOVATE_TOKEN` *or* `GITHUB_TOKEN` |
170
172
  | gitlab.com | `gitlab` | (omit) | `RENOVATE_TOKEN` *or* `GITLAB_TOKEN` |
171
173
  | Self-hosted GitLab | `gitlab` | `https://gitlab.example.com/api/v4/` | `RENOVATE_TOKEN` *or* `GITLAB_TOKEN` |
172
- | gitea.com / Forgejo (code.forgejo.org) | — | (omit) | `RENOVATE_TOKEN` *or* `GITEA_TOKEN` / `FORGEJO_TOKEN` |
173
- | Self-hosted Gitea / Forgejo | — | `https://forgejo.example.com/api/v1/` | `RENOVATE_TOKEN` *or* `GITEA_TOKEN` / `FORGEJO_TOKEN` |
174
- | Bitbucket Cloud | — | (omit) | `RENOVATE_TOKEN` *or* `BITBUCKET_TOKEN` |
174
+ | gitea.com / Forgejo (code.forgejo.org) | — | (omit) | `GITEA_TOKEN` / `FORGEJO_TOKEN` |
175
+ | Self-hosted Gitea / Forgejo | `gitea` / `forgejo` | `https://forgejo.example.com/api/v1` | `RENOVATE_TOKEN` *or* `GITEA_TOKEN` / `FORGEJO_TOKEN` |
176
+ | Bitbucket Cloud | — | (omit) | `BITBUCKET_TOKEN` |
175
177
 
176
- The Gitea / Forgejo / Bitbucket Cloud rows apply to the `resolve_config` family only: `dry_run` runs Renovate as `local` / `github` / `gitlab` and never reads those tokens. Pass `platform` (`gitea` / `forgejo` / `bitbucket`) and `endpoint` as tool inputs instead of `RENOVATE_PLATFORM` / `RENOVATE_ENDPOINT`.
178
+ The Gitea / Forgejo / Bitbucket Cloud rows apply to the `resolve_config` family only: `dry_run` runs Renovate as `local` / `github` / `gitlab` and only passes those tokens on as host rules for their hosts. `platform` (`gitea` / `forgejo` / `bitbucket`) and `endpoint` can also be passed as tool inputs, but they only route `local>` presets: a token goes only to its platform's public host or to the env home (`RENOVATE_PLATFORM` at `RENOVATE_ENDPOINT`, where `RENOVATE_TOKEN` comes first), so an endpoint passed only per call needs a `RENOVATE_MCP_HOST_TOKENS` entry. `endpoint` routes `local>` presets only, as in Renovate.
177
179
 
178
180
  See [`docs/platform-setup.md`](docs/platform-setup.md) for a worked self-hosted GitLab example, `local>` preset handling, and private-registry credentials.
179
181
 
@@ -196,8 +198,8 @@ Once the server is wired up, try prompts like these. Written for Claude but work
196
198
 
197
199
  **Self-hosted GitLab / GitHub Enterprise** (env set per [Platform setup](#platform-setup))
198
200
 
199
- - "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."
200
- - "Expand `github>acme/renovate-config//base` from our GitHub Enterprise at `https://github.acme.corp/api/v3`."
201
+ - "Resolve my config with external presets enabled. Its `local>platform/renovate-presets` presets live on our self-hosted GitLab at `https://gitlab.example.com/api/v4`."
202
+ - "Resolve my config with external presets enabled. Its `local>acme/renovate-config//base` preset lives on our GitHub Enterprise at `https://github.acme.corp/api/v3`."
201
203
  - "Dry-run `infrastructure/kubernetes/our-platform` (a nested-group GitLab project) so Renovate can fetch the `local>` presets the config extends."
202
204
 
203
205
  **Authoring a custom manager (regex or JSONata)**
@@ -22049,4 +22049,120 @@ export const PRESETS = {
22049
22049
  }
22050
22050
  }
22051
22051
  };
22052
+ /**
22053
+ * Renovate's `removedPresets` map: renamed built-in presets map to their new
22054
+ * name, removed ones map to null (Renovate resolves them to an empty config).
22055
+ */
22056
+ export const REMOVED_PRESETS = {
22057
+ ":app": "config:js-app",
22058
+ ":autodetectPinVersions": null,
22059
+ ":autodetectRangeStrategy": null,
22060
+ ":automergeBranchMergeCommit": ":automergeBranch",
22061
+ ":automergeBranchPush": ":automergeBranch",
22062
+ ":base": "config:recommended",
22063
+ ":disableLockFiles": ":skipArtifactsUpdate",
22064
+ ":enableGradleLite": null,
22065
+ ":js-app": "config:js-app",
22066
+ ":library": "config:js-lib",
22067
+ ":masterIssue": ":dependencyDashboard",
22068
+ ":masterIssueApproval": ":dependencyDashboardApproval",
22069
+ ":switchToGradleLite": null,
22070
+ ":unpublishSafe": "npm:unpublishSafe",
22071
+ "compatibility:additionalBranchPrefix": null,
22072
+ "config:application": "config:js-app",
22073
+ "config:base": "config:recommended",
22074
+ "config:base-js": "config:recommended",
22075
+ "config:library": "config:js-lib",
22076
+ "default:app": "config:js-app",
22077
+ "default:automergeBranchMergeCommit": ":automergeBranch",
22078
+ "default:automergeBranchPush": ":automergeBranch",
22079
+ "default:base": "config:recommended",
22080
+ "default:js-app": "config:js-app",
22081
+ "default:library": "config:js-lib",
22082
+ "default:onlyNpm": null,
22083
+ "default:unpublishSafe": "npm:unpublishSafe",
22084
+ "github>whitesource/merge-confidence:beta": "mergeConfidence:all-badges",
22085
+ "group:FontsourceMonorepo": "group:fontsourceMonorepo",
22086
+ "group:HangfireMonorepo": "group:hangfireMonorepo",
22087
+ "group:HotChocolateMonorepo": "group:hotchocolateMonorepo",
22088
+ "group:MassTransitMonorepo": "group:masstransitMonorepo",
22089
+ "group:OpenTelemetryDotnetMonorepo": "group:opentelemetry-dotnetMonorepo",
22090
+ "group:SteeltoeMonorepo": "group:steeltoeMonorepo",
22091
+ "group:SwashbuckleMonorepo": "group:swashbuckle-aspnetcoreMonorepo",
22092
+ "group:System.IO.AbstractionsMonorepo": "group:system.io.abstractionsMonorepo",
22093
+ "group:angular1Monorepo": "group:angularjsMonorepo",
22094
+ "group:angularcliMonorepo": "group:angular-cliMonorepo",
22095
+ "group:arcus background-jobsMonorepo": "group:arcus.background-jobsMonorepo",
22096
+ "group:arcus event-gridMonorepo": "group:arcus.event-gridMonorepo",
22097
+ "group:arcus messagingMonorepo": "group:arcus.messagingMonorepo",
22098
+ "group:arcus observabilityMonorepo": "group:arcus.observabilityMonorepo",
22099
+ "group:arcus securityMonorepo": "group:arcus.securityMonorepo",
22100
+ "group:arcus webapiMonorepo": "group:arcus.webapiMonorepo",
22101
+ "group:aspnet AspNetWebStackMonorepo": "group:aspnet aspnetwebstackMonorepo",
22102
+ "group:aspnet ExtensionsMonorepo": "group:aspnet extensionsMonorepo",
22103
+ "group:hamcrestMonorepo": "group:javahamcrestMonorepo",
22104
+ "group:infrastructureMonorepo": "group:infrastructure-uiMonorepo",
22105
+ "group:jsTestMonMajor": "group:jsTestNonMajor",
22106
+ "group:junit5Monorepo": "group:junit-frameworkMonorepo",
22107
+ "group:k8s-ioMonorepo": "group:kubernetesMonorepo",
22108
+ "group:kubernetes": "group:kubernetesMonorepo",
22109
+ "group:linguiMonorepo": "group:linguijsMonorepo",
22110
+ "group:materialMonorepo": "group:material-components-webMonorepo",
22111
+ "group:muiMonorepo": "group:material-uiMonorepo",
22112
+ "group:nrwlMonorepo": "group:nxMonorepo",
22113
+ "group:openfeignMonorepo": "group:feignMonorepo",
22114
+ "group:opentelemetryMonorepo": "group:opentelemetry-jsMonorepo",
22115
+ "group:picassoMonorepo": "group:picassojsMonorepo",
22116
+ "group:reactrouterMonorepo": "group:react-routerMonorepo",
22117
+ "group:sentryMonorepo": "group:sentry-javascriptMonorepo",
22118
+ "group:strykerMonorepo": "group:stryker-jsMonorepo",
22119
+ "helpers:oddIsUnstable": null,
22120
+ "helpers:oddIsUnstablePackages": null,
22121
+ "monorepo:Fontsource": "monorepo:fontsource",
22122
+ "monorepo:Hangfire": "monorepo:hangfire",
22123
+ "monorepo:HotChocolate": "monorepo:hotchocolate",
22124
+ "monorepo:MassTransit": "monorepo:masstransit",
22125
+ "monorepo:OpenTelemetryDotnet": "monorepo:opentelemetry-dotnet",
22126
+ "monorepo:Steeltoe": "monorepo:steeltoe",
22127
+ "monorepo:Swashbuckle": "monorepo:swashbuckle-aspnetcore",
22128
+ "monorepo:System.IO.Abstractions": "monorepo:system.io.abstractions",
22129
+ "monorepo:angular1": "monorepo:angularjs",
22130
+ "monorepo:angularcli": "monorepo:angular-cli",
22131
+ "monorepo:arcus background-jobs": "monorepo:arcus.background-jobs",
22132
+ "monorepo:arcus event-grid": "monorepo:arcus.event-grid",
22133
+ "monorepo:arcus messaging": "monorepo:arcus.messaging",
22134
+ "monorepo:arcus observability": "monorepo:arcus.observability",
22135
+ "monorepo:arcus security": "monorepo:arcus.security",
22136
+ "monorepo:arcus webapi": "monorepo:arcus.webapi",
22137
+ "monorepo:aspnet AspNetWebStack": "monorepo:aspnet aspnetwebstack",
22138
+ "monorepo:aspnet Extensions": "monorepo:aspnet extensions",
22139
+ "monorepo:hamcrest": "monorepo:javahamcrest",
22140
+ "monorepo:infrastructure": "monorepo:infrastructure-ui",
22141
+ "monorepo:junit5": "monorepo:junit-framework",
22142
+ "monorepo:k8s-io": "monorepo:kubernetes",
22143
+ "monorepo:lingui": "monorepo:linguijs",
22144
+ "monorepo:material": "monorepo:material-components-web",
22145
+ "monorepo:mui": "monorepo:material-ui",
22146
+ "monorepo:nrwl": "monorepo:nx",
22147
+ "monorepo:openfeign": "monorepo:feign",
22148
+ "monorepo:opentelemetry": "monorepo:opentelemetry-js",
22149
+ "monorepo:picasso": "monorepo:picassojs",
22150
+ "monorepo:reactrouter": "monorepo:react-router",
22151
+ "monorepo:sentry": "monorepo:sentry-javascript",
22152
+ "monorepo:stryker": "monorepo:stryker-js",
22153
+ "npm:unpublishSafe": "security:minimumReleaseAgeNpm",
22154
+ "regexManagers:azurePipelinesVersions": "customManagers:azurePipelinesVersions",
22155
+ "regexManagers:biomeVersions": "customManagers:biomeVersions",
22156
+ "regexManagers:bitbucketPipelinesVersions": "customManagers:bitbucketPipelinesVersions",
22157
+ "regexManagers:dockerfileVersions": "customManagers:dockerfileVersions",
22158
+ "regexManagers:githubActionsVersions": "customManagers:githubActionsVersions",
22159
+ "regexManagers:gitlabPipelineVersions": "customManagers:gitlabPipelineVersions",
22160
+ "regexManagers:helmChartYamlAppVersions": "customManagers:helmChartYamlAppVersions",
22161
+ "regexManagers:mavenPropertyVersions": "customManagers:mavenPropertyVersions",
22162
+ "regexManagers:tfvarsVersions": "customManagers:tfvarsVersions",
22163
+ "regexManagers:tsconfigNodeVersions": "customManagers:tsconfigNodeVersions",
22164
+ "replacements:messageFormat-{{package}}-to-@messageformat/{{package}}": "replacements:messageFormat-to-scoped",
22165
+ "replacements:ojdbc10-to-ojdbc11": "replacements:ojdbc-to-ojdbc11",
22166
+ "workarounds:reduceRepologyServerLoad": null
22167
+ };
22052
22168
  export const PRESET_NAMES = Object.freeze(Object.keys(PRESETS));
@@ -1,15 +1 @@
1
- /**
2
- * Resolve a credential by checking each env var name in order and returning
3
- * the first one that's set. Used by both `resolve_config` (for `github>` /
4
- * `gitlab>` preset fetches) and `dry_run` (when picking which token to export
5
- * as `RENOVATE_TOKEN` to the spawned Renovate CLI). Keeping the precedence in
6
- * one place ensures the two tools cannot drift out of sync.
7
- */
8
- export function resolveCredential(vars) {
9
- for (const envVar of vars) {
10
- const value = process.env[envVar];
11
- if (value)
12
- return { envVar, token: value, triedVars: vars };
13
- }
14
- return { envVar: null, token: undefined, triedVars: vars };
15
- }
1
+ export {};
@@ -1,6 +1,6 @@
1
1
  import JSON5 from "json5";
2
- import { resolveCredential } from "./credentialResolver.js";
3
2
  import { validateEndpoint } from "./endpointValidator.js";
3
+ import { selectToken } from "./hostTokens.js";
4
4
  import { classifyExternalSource, } from "./presetResolver.js";
5
5
  const DEFAULT_TIMEOUT_MS = 10_000;
6
6
  const DEFAULT_API_BASE = {
@@ -45,30 +45,46 @@ function dispatch(parsed, options) {
45
45
  return Promise.resolve({ ok: false, reason: blocked });
46
46
  endpoint = trimTrailingSlash(options.endpoint);
47
47
  }
48
- switch (parsed.source) {
48
+ // `classifyExternalSource` refused local / npm above, so only the hosted
49
+ // platforms remain and the switch is exhaustive.
50
+ const source = parsed.source;
51
+ switch (source) {
49
52
  case "github":
50
53
  return fetchGitHub(parsed, timeoutMs, fetchImpl, endpoint);
51
54
  case "gitlab":
52
55
  return fetchGitLab(parsed, timeoutMs, fetchImpl, endpoint);
53
56
  case "gitea":
54
57
  case "forgejo":
55
- return fetchGitea(parsed, timeoutMs, fetchImpl, endpoint, parsed.source);
58
+ return fetchGitea(parsed, timeoutMs, fetchImpl, endpoint, source);
56
59
  case "bitbucket":
57
60
  return fetchBitbucket(parsed, timeoutMs, fetchImpl, endpoint);
58
- default:
59
- // Unreachable: `classifyExternalSource` already refused local / npm.
60
- return Promise.resolve({ ok: false, reason: `Unknown preset source: ${parsed.source}` });
61
61
  }
62
62
  }
63
+ /** GitHub, Gitea and Bitbucket address repositories as exactly two path segments (nested groups are a GitLab thing). */
64
+ const OWNER_REPO_RE = /^[^/]+\/[^/]+$/;
65
+ /** GitLab projects live under a namespace that may itself be nested (`group/sub/project`). */
66
+ const GITLAB_PROJECT_RE = /^[^/]+(\/[^/]+)+$/;
67
+ /**
68
+ * Rejects empty, `.` and `..` segments. URL parsing collapses dot segments
69
+ * (also when spelled `%2e`), and `encodeURIComponent` leaves `.` alone, so
70
+ * without this a preset string could steer a token-bearing request to any
71
+ * path on the API host.
72
+ */
73
+ function isSafePath(path) {
74
+ return path.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
75
+ }
63
76
  async function fetchGitHub(parsed, timeoutMs, fetchImpl, endpoint) {
64
- if (!parsed.repoPath || !parsed.repoPath.includes("/")) {
77
+ const file = presetFileName(parsed);
78
+ if (!parsed.repoPath ||
79
+ !OWNER_REPO_RE.test(parsed.repoPath) ||
80
+ !isSafePath(parsed.repoPath) ||
81
+ !isSafePath(file)) {
65
82
  return { ok: false, reason: `Invalid github preset: ${parsed.original}` };
66
83
  }
67
- const file = presetFileName(parsed);
68
84
  const ref = parsed.ref ?? "HEAD";
69
85
  const apiBase = endpoint ?? DEFAULT_API_BASE.github;
70
- const url = `${apiBase}/repos/${parsed.repoPath}/contents/${encodeFilePath(file)}?ref=${encodeURIComponent(ref)}`;
71
- const credential = resolveCredential(["RENOVATE_TOKEN", "GITHUB_TOKEN"]);
86
+ const url = `${apiBase}/repos/${encodeFilePath(parsed.repoPath)}/contents/${encodeFilePath(file)}?ref=${encodeURIComponent(ref)}`;
87
+ const credential = selectToken(url, "github");
72
88
  const headers = {
73
89
  Accept: "application/vnd.github.raw",
74
90
  "User-Agent": "renovate-mcp",
@@ -79,36 +95,37 @@ async function fetchGitHub(parsed, timeoutMs, fetchImpl, endpoint) {
79
95
  return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl, "github", sendAuth ? credential : suppressCredential(credential));
80
96
  }
81
97
  async function fetchGitLab(parsed, timeoutMs, fetchImpl, endpoint) {
82
- if (!parsed.repoPath) {
98
+ const file = presetFileName(parsed);
99
+ if (!parsed.repoPath ||
100
+ !GITLAB_PROJECT_RE.test(parsed.repoPath) ||
101
+ !isSafePath(parsed.repoPath) ||
102
+ !isSafePath(file)) {
83
103
  return { ok: false, reason: `Invalid gitlab preset: ${parsed.original}` };
84
104
  }
85
- const file = presetFileName(parsed);
86
105
  const ref = parsed.ref ?? "HEAD";
87
106
  const apiBase = endpoint ?? DEFAULT_API_BASE.gitlab;
88
107
  const url = `${apiBase}/projects/${encodeURIComponent(parsed.repoPath)}/repository/files/${encodeURIComponent(file)}/raw?ref=${encodeURIComponent(ref)}`;
89
- const credential = resolveCredential(["RENOVATE_TOKEN", "GITLAB_TOKEN"]);
108
+ const credential = selectToken(url, "gitlab");
90
109
  const headers = { "User-Agent": "renovate-mcp" };
91
110
  const sendAuth = Boolean(credential.token) && isHttpsUrl(url);
92
111
  if (sendAuth)
93
112
  headers["PRIVATE-TOKEN"] = credential.token;
94
113
  return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl, "gitlab", sendAuth ? credential : suppressCredential(credential));
95
114
  }
96
- /** Gitea and Bitbucket address repositories as exactly two path segments (nested groups are a GitLab thing). */
97
- const OWNER_REPO_RE = /^[^/]+\/[^/]+$/;
98
115
  /** Gitea's raw-contents API, which Forgejo serves unchanged; the two differ only in default host and token var. */
99
116
  async function fetchGitea(parsed, timeoutMs, fetchImpl, endpoint, platform) {
100
- if (!parsed.repoPath || !OWNER_REPO_RE.test(parsed.repoPath)) {
117
+ const file = presetFileName(parsed);
118
+ if (!parsed.repoPath ||
119
+ !OWNER_REPO_RE.test(parsed.repoPath) ||
120
+ !isSafePath(parsed.repoPath) ||
121
+ !isSafePath(file)) {
101
122
  return { ok: false, reason: `Invalid ${platform} preset: ${parsed.original}` };
102
123
  }
103
- const file = presetFileName(parsed);
104
124
  const apiBase = endpoint ?? DEFAULT_API_BASE[platform];
105
125
  // No `ref` means the repository's default branch, as in Renovate.
106
126
  const refQuery = parsed.ref ? `?ref=${encodeURIComponent(parsed.ref)}` : "";
107
127
  const url = `${apiBase}/repos/${encodeFilePath(parsed.repoPath)}/raw/${encodeFilePath(file)}${refQuery}`;
108
- const credential = resolveCredential([
109
- "RENOVATE_TOKEN",
110
- platform === "gitea" ? "GITEA_TOKEN" : "FORGEJO_TOKEN",
111
- ]);
128
+ const credential = selectToken(url, platform);
112
129
  const headers = { "User-Agent": "renovate-mcp" };
113
130
  const sendAuth = Boolean(credential.token) && isHttpsUrl(url);
114
131
  if (sendAuth)
@@ -124,14 +141,23 @@ async function fetchGitea(parsed, timeoutMs, fetchImpl, endpoint, platform) {
124
141
  * if anyone needs it.
125
142
  */
126
143
  async function fetchBitbucket(parsed, timeoutMs, fetchImpl, endpoint) {
127
- if (!parsed.repoPath || !OWNER_REPO_RE.test(parsed.repoPath)) {
144
+ const file = presetFileName(parsed);
145
+ // The ref is a single encoded path segment here (a query parameter elsewhere), so only an
146
+ // empty or dot ref can shape the path.
147
+ const rawRef = parsed.ref ?? "HEAD";
148
+ if (!parsed.repoPath ||
149
+ !OWNER_REPO_RE.test(parsed.repoPath) ||
150
+ !isSafePath(parsed.repoPath) ||
151
+ !isSafePath(file) ||
152
+ rawRef === "" ||
153
+ rawRef === "." ||
154
+ rawRef === "..") {
128
155
  return { ok: false, reason: `Invalid bitbucket preset: ${parsed.original}` };
129
156
  }
130
- const file = presetFileName(parsed);
131
157
  const apiBase = endpoint ?? DEFAULT_API_BASE.bitbucket;
132
- const ref = encodeURIComponent(parsed.ref ?? "HEAD");
158
+ const ref = encodeURIComponent(rawRef);
133
159
  const url = `${apiBase}/2.0/repositories/${encodeFilePath(parsed.repoPath)}/src/${ref}/${encodeFilePath(file)}`;
134
- const credential = resolveCredential(["RENOVATE_TOKEN", "BITBUCKET_TOKEN"]);
160
+ const credential = selectToken(url, "bitbucket");
135
161
  const headers = { "User-Agent": "renovate-mcp" };
136
162
  const sendAuth = Boolean(credential.token) && isHttpsUrl(url);
137
163
  if (sendAuth)
@@ -309,7 +335,7 @@ async function discardBody(res) {
309
335
  function formatAuthFailure(status, presetName, url, credential, body) {
310
336
  const lines = [`HTTP ${status} when fetching ${presetName}`];
311
337
  lines.push(` URL: ${url}`);
312
- lines.push(` Credential: ${formatCredential(credential)}`);
338
+ lines.push(` Credential: ${formatCredential(credential, url)}`);
313
339
  const trimmed = body.trim();
314
340
  if (trimmed) {
315
341
  const snippet = trimmed.length > AUTH_BODY_MAX
@@ -319,9 +345,11 @@ function formatAuthFailure(status, presetName, url, credential, body) {
319
345
  }
320
346
  return lines.join("\n");
321
347
  }
322
- function formatCredential(credential) {
348
+ function formatCredential(credential, url) {
323
349
  if (credential.envVar)
324
350
  return `${credential.envVar} (present)`;
351
+ if (credential.triedVars.length === 0)
352
+ return `none (no token is bound to ${new URL(url).hostname})`;
325
353
  return `none (tried ${credential.triedVars.join(", ")})`;
326
354
  }
327
355
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
@@ -40,15 +40,38 @@ export function parseRemoteUrl(url) {
40
40
  return { host, scheme: "ssh", owner, repo };
41
41
  }
42
42
  /**
43
- * Read the `[remote "origin"] url = …` field from a repo's `.git/config`
44
- * without spawning `git`. Returns `null` for non-git directories, missing
45
- * config, missing origin remote, or unreadable files (no exceptions thrown).
43
+ * Read the `[remote "origin"] url = …` field from a repo's git config without
44
+ * spawning `git`. A `.git` file (worktree, submodule) is followed through its
45
+ * `gitdir:` line, and a worktree's `commondir` to the shared config. The
46
+ * gitdir must look like one git writes (`…/.git`, `…/.git/worktrees/<name>`
47
+ * or `…/.git/modules/…`), and a worktree's common dir must be a `…/.git`;
48
+ * any other target is no origin, so a crafted `.git` file cannot point the
49
+ * origin at an arbitrary config. Returns `null` for
50
+ * non-git directories, missing config, missing origin remote, or unreadable
51
+ * files (no exceptions thrown).
46
52
  */
53
+ /** A `.git` dir, a worktree's `.git/worktrees/<name>`, or a submodule's `.git/modules/<path>`. */
54
+ const GITDIR_SHAPE = /\/\.git(?:\/worktrees\/[^/]+|\/modules\/.+)?$/;
47
55
  export async function readOriginRemote(repoPath) {
48
- const configPath = path.join(repoPath, ".git", "config");
56
+ const dotGit = path.join(repoPath, ".git");
57
+ // Reading a `.git` directory fails (EISDIR): then it is the git dir itself.
58
+ const pointer = await fs.readFile(dotGit, "utf8").catch(() => "");
59
+ const gitdirLine = /^gitdir:\s*(.+)$/m.exec(pointer);
60
+ let gitDir = dotGit;
61
+ if (gitdirLine) {
62
+ gitDir = path.resolve(repoPath, gitdirLine[1].trim());
63
+ if (!GITDIR_SHAPE.test(gitDir.split(path.sep).join("/")))
64
+ return null;
65
+ const commonDir = await fs.readFile(path.join(gitDir, "commondir"), "utf8").catch(() => null);
66
+ if (commonDir) {
67
+ gitDir = path.resolve(gitDir, commonDir.trim());
68
+ if (!/\/\.git$/.test(gitDir.split(path.sep).join("/")))
69
+ return null;
70
+ }
71
+ }
49
72
  let raw;
50
73
  try {
51
- raw = await fs.readFile(configPath, "utf8");
74
+ raw = await fs.readFile(path.join(gitDir, "config"), "utf8");
52
75
  }
53
76
  catch {
54
77
  return null;
@@ -1,25 +1,30 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { tmpdir } from "node:os";
4
- import { randomUUID } from "node:crypto";
5
4
  /**
6
- * Write a throwaway Renovate config containing just `{ hostRules: [...] }` to
7
- * `os.tmpdir()` with mode 0600, and return the absolute path. The caller is
8
- * responsible for unlinking the file (in a `finally` block) once Renovate has
9
- * consumed it — we do not keep a reference here.
5
+ * Write a throwaway Renovate config containing just `{ hostRules: [...] }` and
6
+ * return its absolute path. The file sits alone in a fresh `mkdtemp`
7
+ * directory (mode 0700) under `os.tmpdir()` and is created with `wx` and mode
8
+ * 0600: it holds env tokens on most runs, so no other local user may read it
9
+ * or plant a file at the path first. The caller removes it with
10
+ * `removeHostRulesConfig` (in a `finally` block) once Renovate has consumed it.
10
11
  *
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
15
- * per-invocation credentials with whatever the repo itself already declares.
12
+ * The caller points Renovate at this file via the `RENOVATE_CONFIG_FILE` (or
13
+ * `RENOVATE_ADDITIONAL_CONFIG_FILE`) env var, not a CLI flag — `--config-file`
14
+ * is not a real Renovate option. Renovate concatenates its rules with any
15
+ * other `hostRules`, so callers can combine per-invocation credentials with
16
+ * whatever the repo itself already declares.
16
17
  */
17
18
  export async function writeHostRulesConfig(hostRules) {
18
- const tmpPath = path.join(tmpdir(), `renovate-mcp-hostrules-${randomUUID()}.json`);
19
- const body = JSON.stringify({ hostRules }, null, 2);
20
- await fs.writeFile(tmpPath, body, { mode: 0o600 });
19
+ const dir = await fs.mkdtemp(path.join(tmpdir(), "renovate-mcp-hostrules-"));
20
+ const tmpPath = path.join(dir, "config.json");
21
+ await fs.writeFile(tmpPath, JSON.stringify({ hostRules }, null, 2), { flag: "wx", mode: 0o600 });
21
22
  return tmpPath;
22
23
  }
24
+ /** Remove a file written by `writeHostRulesConfig` together with its directory. */
25
+ export async function removeHostRulesConfig(filePath) {
26
+ await fs.rm(path.dirname(filePath), { recursive: true, force: true });
27
+ }
23
28
  const SECRET_KEYS = ["token", "password"];
24
29
  /**
25
30
  * Pull the token/password values out of the provided hostRules so callers can