renovate-mcp 1.8.2 → 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)**
@@ -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,19 +45,19 @@ 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
63
  /** GitHub, Gitea and Bitbucket address repositories as exactly two path segments (nested groups are a GitLab thing). */
@@ -84,7 +84,7 @@ async function fetchGitHub(parsed, timeoutMs, fetchImpl, endpoint) {
84
84
  const ref = parsed.ref ?? "HEAD";
85
85
  const apiBase = endpoint ?? DEFAULT_API_BASE.github;
86
86
  const url = `${apiBase}/repos/${encodeFilePath(parsed.repoPath)}/contents/${encodeFilePath(file)}?ref=${encodeURIComponent(ref)}`;
87
- const credential = resolveCredential(["RENOVATE_TOKEN", "GITHUB_TOKEN"]);
87
+ const credential = selectToken(url, "github");
88
88
  const headers = {
89
89
  Accept: "application/vnd.github.raw",
90
90
  "User-Agent": "renovate-mcp",
@@ -105,7 +105,7 @@ async function fetchGitLab(parsed, timeoutMs, fetchImpl, endpoint) {
105
105
  const ref = parsed.ref ?? "HEAD";
106
106
  const apiBase = endpoint ?? DEFAULT_API_BASE.gitlab;
107
107
  const url = `${apiBase}/projects/${encodeURIComponent(parsed.repoPath)}/repository/files/${encodeURIComponent(file)}/raw?ref=${encodeURIComponent(ref)}`;
108
- const credential = resolveCredential(["RENOVATE_TOKEN", "GITLAB_TOKEN"]);
108
+ const credential = selectToken(url, "gitlab");
109
109
  const headers = { "User-Agent": "renovate-mcp" };
110
110
  const sendAuth = Boolean(credential.token) && isHttpsUrl(url);
111
111
  if (sendAuth)
@@ -125,10 +125,7 @@ async function fetchGitea(parsed, timeoutMs, fetchImpl, endpoint, platform) {
125
125
  // No `ref` means the repository's default branch, as in Renovate.
126
126
  const refQuery = parsed.ref ? `?ref=${encodeURIComponent(parsed.ref)}` : "";
127
127
  const url = `${apiBase}/repos/${encodeFilePath(parsed.repoPath)}/raw/${encodeFilePath(file)}${refQuery}`;
128
- const credential = resolveCredential([
129
- "RENOVATE_TOKEN",
130
- platform === "gitea" ? "GITEA_TOKEN" : "FORGEJO_TOKEN",
131
- ]);
128
+ const credential = selectToken(url, platform);
132
129
  const headers = { "User-Agent": "renovate-mcp" };
133
130
  const sendAuth = Boolean(credential.token) && isHttpsUrl(url);
134
131
  if (sendAuth)
@@ -160,7 +157,7 @@ async function fetchBitbucket(parsed, timeoutMs, fetchImpl, endpoint) {
160
157
  const apiBase = endpoint ?? DEFAULT_API_BASE.bitbucket;
161
158
  const ref = encodeURIComponent(rawRef);
162
159
  const url = `${apiBase}/2.0/repositories/${encodeFilePath(parsed.repoPath)}/src/${ref}/${encodeFilePath(file)}`;
163
- const credential = resolveCredential(["RENOVATE_TOKEN", "BITBUCKET_TOKEN"]);
160
+ const credential = selectToken(url, "bitbucket");
164
161
  const headers = { "User-Agent": "renovate-mcp" };
165
162
  const sendAuth = Boolean(credential.token) && isHttpsUrl(url);
166
163
  if (sendAuth)
@@ -338,7 +335,7 @@ async function discardBody(res) {
338
335
  function formatAuthFailure(status, presetName, url, credential, body) {
339
336
  const lines = [`HTTP ${status} when fetching ${presetName}`];
340
337
  lines.push(` URL: ${url}`);
341
- lines.push(` Credential: ${formatCredential(credential)}`);
338
+ lines.push(` Credential: ${formatCredential(credential, url)}`);
342
339
  const trimmed = body.trim();
343
340
  if (trimmed) {
344
341
  const snippet = trimmed.length > AUTH_BODY_MAX
@@ -348,9 +345,11 @@ function formatAuthFailure(status, presetName, url, credential, body) {
348
345
  }
349
346
  return lines.join("\n");
350
347
  }
351
- function formatCredential(credential) {
348
+ function formatCredential(credential, url) {
352
349
  if (credential.envVar)
353
350
  return `${credential.envVar} (present)`;
351
+ if (credential.triedVars.length === 0)
352
+ return `none (no token is bound to ${new URL(url).hostname})`;
354
353
  return `none (tried ${credential.triedVars.join(", ")})`;
355
354
  }
356
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
@@ -0,0 +1,229 @@
1
+ import { HOSTED_PLATFORMS } from "./gitRemote.js";
2
+ import { HOST_RULES_MAX_ITEMS } from "./inputLimits.js";
3
+ export const PLATFORM_TOKEN_ENV = {
4
+ github: "GITHUB_TOKEN",
5
+ gitlab: "GITLAB_TOKEN",
6
+ gitea: "GITEA_TOKEN",
7
+ forgejo: "FORGEJO_TOKEN",
8
+ bitbucket: "BITBUCKET_TOKEN",
9
+ };
10
+ /** Each platform's public host; a bare domain also covers its subdomains (api.github.com). */
11
+ export const PUBLIC_HOST = {
12
+ github: "github.com",
13
+ gitlab: "gitlab.com",
14
+ gitea: "gitea.com",
15
+ forgejo: "code.forgejo.org",
16
+ bitbucket: "bitbucket.org",
17
+ };
18
+ export const HOST_TOKENS_ENV = "RENOVATE_MCP_HOST_TOKENS";
19
+ const ENTRY_KEYS = ["hostType", "matchHost", "tokenEnv"];
20
+ const TOKEN_ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
21
+ const HOST_TYPE_RE = /^[a-z0-9-]+$/;
22
+ const BARE_HOST_RE = /^\.?[a-z0-9-]+(\.[a-z0-9-]+)*$/;
23
+ /** Parse `RENOVATE_MCP_HOST_TOKENS`. Any malformed entry rejects the whole var; errors never echo its value. */
24
+ export function parseHostTokens(env = process.env) {
25
+ const raw = env[HOST_TOKENS_ENV]?.trim();
26
+ if (!raw)
27
+ return { ok: true, entries: [] };
28
+ const fail = (what) => ({
29
+ ok: false,
30
+ error: `${HOST_TOKENS_ENV}${what.startsWith("[") ? "" : " "}${what}`,
31
+ });
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(raw);
35
+ }
36
+ catch {
37
+ return fail("is not valid JSON.");
38
+ }
39
+ if (!Array.isArray(parsed)) {
40
+ return fail("must be a JSON array of {matchHost, hostType, tokenEnv} objects.");
41
+ }
42
+ if (parsed.length > HOST_RULES_MAX_ITEMS) {
43
+ return fail(`has ${parsed.length} entries; the limit is ${HOST_RULES_MAX_ITEMS}.`);
44
+ }
45
+ for (const [i, e] of parsed.entries()) {
46
+ const at = `[${i}]`;
47
+ if (!e || typeof e !== "object" || Array.isArray(e)) {
48
+ return fail(`${at} must be an object with exactly the keys matchHost, hostType, tokenEnv.`);
49
+ }
50
+ if (Object.keys(e).sort().join() !== ENTRY_KEYS.join()) {
51
+ return fail(`${at} must be an object with exactly the keys matchHost, hostType, tokenEnv.`);
52
+ }
53
+ const { matchHost, hostType, tokenEnv } = e;
54
+ if (typeof tokenEnv !== "string" || !TOKEN_ENV_RE.test(tokenEnv)) {
55
+ return fail(`${at}.tokenEnv must be an environment variable name (letters, digits, underscore).`);
56
+ }
57
+ if (typeof hostType !== "string" || !HOST_TYPE_RE.test(hostType)) {
58
+ return fail(`${at}.hostType must be a lowercase Renovate hostType such as gitlab or npm.`);
59
+ }
60
+ if (typeof matchHost !== "string" || !isValidMatchHost(matchHost)) {
61
+ return fail(`${at}.matchHost must be a bare lowercase host (gitlab.example.com) or an https:// URL prefix without credentials.`);
62
+ }
63
+ }
64
+ return { ok: true, entries: parsed };
65
+ }
66
+ function isValidMatchHost(matchHost) {
67
+ if (BARE_HOST_RE.test(matchHost))
68
+ return true;
69
+ const url = parseUrl(matchHost);
70
+ return url !== null && url.protocol === "https:" && !url.username && !url.password;
71
+ }
72
+ /** Renovate's `matchesHost`: URL-form `matchHost` is a prefix match, a bare host matches itself and its subdomains. */
73
+ export function matchesHost(matchHost, url) {
74
+ const parsedUrl = parseUrl(url);
75
+ if (!parsedUrl)
76
+ return false;
77
+ const parsedMatch = parseUrl(matchHost);
78
+ if (isHttp(parsedUrl) && parsedMatch && isHttp(parsedMatch)) {
79
+ const { href } = parsedMatch;
80
+ const bare = new URL(parsedUrl.href);
81
+ bare.username = "";
82
+ bare.password = "";
83
+ if (bare.href.startsWith(href))
84
+ return true;
85
+ return `${parsedUrl.origin}${parsedUrl.pathname}`.replace(/\/?$/, "/") === href;
86
+ }
87
+ return domainMatches(parsedUrl.hostname, matchHost);
88
+ }
89
+ /**
90
+ * Resolve the `local>` route (ADR-0008): platform = the tool's `platform`
91
+ * input, else `RENOVATE_PLATFORM`; endpoint = the `endpoint` input, else
92
+ * `RENOVATE_ENDPOINT`, but the env endpoint only when there is no platform
93
+ * input or it equals `RENOVATE_PLATFORM` (an env endpoint belongs to the env
94
+ * platform). Unlike Renovate, a missing platform does not default to github:
95
+ * without a hosted platform there is no scope and `local>` has no route.
96
+ * Routing only: token binding reads the env scope, `homeScope({}, env)`.
97
+ */
98
+ export function homeScope(input, env = process.env) {
99
+ const platform = input.platform || env.RENOVATE_PLATFORM;
100
+ if (!HOSTED_PLATFORMS.includes(platform))
101
+ return {};
102
+ const envEndpointApplies = !input.platform || input.platform === env.RENOVATE_PLATFORM;
103
+ const endpoint = input.endpoint || (envEndpointApplies ? env.RENOVATE_ENDPOINT : undefined);
104
+ return endpoint ? { platform: platform, endpoint } : { platform: platform };
105
+ }
106
+ /** Renovate turns `GITHUB_COM_TOKEN` into this rule itself, after the file config's rules. */
107
+ const GITHUB_COM_RULE = { hostType: "github", matchHost: "github.com", vars: ["GITHUB_COM_TOKEN"] };
108
+ /**
109
+ * The rule list in the order `buildHostTokenRules` writes it: one rule per
110
+ * hosted platform on its bound bare host, then the host-token entries. The
111
+ * env home platform's rule carries `RENOVATE_TOKEN` first, as Renovate's own
112
+ * platform rule does (it deletes `GITLAB_TOKEN` and friends from its env), and
113
+ * its own token as the fallback. Only env vars shape the list: a call input or
114
+ * the repo's origin can never bind a token to another host (ADR-0008).
115
+ */
116
+ function candidateRules(env, entries) {
117
+ const home = homeScope({}, env);
118
+ const rules = [];
119
+ for (const platform of HOSTED_PLATFORMS) {
120
+ const matchHost = boundHost(platform, home);
121
+ if (!matchHost)
122
+ continue;
123
+ const vars = platform === home.platform ? ["RENOVATE_TOKEN", PLATFORM_TOKEN_ENV[platform]] : [PLATFORM_TOKEN_ENV[platform]];
124
+ rules.push({ hostType: platform, matchHost, vars });
125
+ }
126
+ for (const { matchHost, hostType, tokenEnv } of entries)
127
+ rules.push({ hostType, matchHost, vars: [tokenEnv], entry: true });
128
+ return rules;
129
+ }
130
+ /**
131
+ * Pick the token for a request to `url` of `hostType` the way Renovate's
132
+ * `hostRules.find()` does over the exact rule list `buildHostTokenRules`
133
+ * emits, followed by the `GITHUB_COM_TOKEN` rule Renovate appends from env.
134
+ * Every rule here has a hostType and a matchHost, so Renovate's rank ties and
135
+ * the longer raw `matchHost` wins, then the later rule. A rule whose variables
136
+ * are all unset does not exist for Renovate and is skipped. `triedVars` lists
137
+ * the variables of every matching rule, most specific first.
138
+ * `githubComToken: false` leaves the `GITHUB_COM_TOKEN` rule out.
139
+ * A malformed `RENOVATE_MCP_HOST_TOKENS` contributes no entries; callers refuse it first.
140
+ */
141
+ export function selectToken(url, hostType, env = process.env, options = {}) {
142
+ const parsed = parseHostTokens(env);
143
+ const rules = candidateRules(env, parsed.ok ? parsed.entries : []);
144
+ if (options.githubComToken !== false)
145
+ rules.push(GITHUB_COM_RULE);
146
+ const ranked = rules
147
+ .filter((r) => r.hostType === hostType && matchesHost(r.matchHost, url))
148
+ // Renovate sorts ascending (stable) and the last match wins; reversed,
149
+ // that is longest first and, on a tie, the later rule first.
150
+ .sort((a, b) => a.matchHost.length - b.matchHost.length)
151
+ .reverse();
152
+ const triedVars = [...new Set(ranked.flatMap((r) => r.vars))];
153
+ for (const rule of ranked) {
154
+ const envVar = rule.vars.find((v) => env[v]);
155
+ if (envVar)
156
+ return { envVar, token: env[envVar], triedVars };
157
+ }
158
+ return { envVar: null, token: undefined, triedVars };
159
+ }
160
+ /**
161
+ * Host rules for a spawned Renovate, in find() order: each per-platform token
162
+ * on its bound bare host (`RENOVATE_TOKEN` first on the env home platform's
163
+ * host, its own token when that is unset), then every host-token entry, so entries win
164
+ * an equal-length tie. `GITHUB_COM_TOKEN` is left to Renovate, which reads it
165
+ * from env. Rules a `covered` entry (per-call hostRules, the platform rule)
166
+ * must win over are left out. Renovate's `find()` ranks hostType + matchHost
167
+ * over matchHost alone over hostType alone, then the longer matchHost, then
168
+ * the later rule, so an env rule goes when the entry has no other hostType and
169
+ * the env rule sits on the entry's host or below it (a tie the env rule would
170
+ * win or lose to the later entry anyway), or when the entry has no hostType
171
+ * and sits below the env rule's host (the env rule outranks it). An entry
172
+ * without a matchHost covers every host of its hostType. Entries whose
173
+ * variable is unset are skipped with a warning; a malformed var yields `error`
174
+ * and no rules.
175
+ */
176
+ export function buildHostTokenRules(env = process.env, covered = []) {
177
+ const parsed = parseHostTokens(env);
178
+ if (!parsed.ok)
179
+ return { rules: [], secrets: [], warnings: [], error: parsed.error };
180
+ const rules = [];
181
+ const warnings = [];
182
+ /** `host` (bare or URL form) is `matchHost` or falls under it. */
183
+ const under = (host, matchHost) => host === matchHost || matchesHost(matchHost, parseUrl(host) ? host : `https://${host}`);
184
+ const isCovered = (r) => covered.some((c) => {
185
+ if (c.hostType !== undefined && c.hostType !== r.hostType)
186
+ return false;
187
+ if (c.matchHost === undefined)
188
+ return true;
189
+ if (under(r.matchHost, c.matchHost))
190
+ return true;
191
+ return c.hostType === undefined && under(c.matchHost, r.matchHost);
192
+ });
193
+ for (const rule of candidateRules(env, parsed.entries)) {
194
+ const envVar = rule.vars.find((v) => env[v]);
195
+ if (envVar && !isCovered(rule))
196
+ rules.push({ hostType: rule.hostType, matchHost: rule.matchHost, token: env[envVar] });
197
+ else if (!envVar && rule.entry) {
198
+ warnings.push(`${HOST_TOKENS_ENV} entry for ${rule.matchHost} (${rule.hostType}) names ${rule.vars[0]}, which is not set; skipped.`);
199
+ }
200
+ }
201
+ return { rules, secrets: [...new Set(rules.map((r) => r.token))], warnings };
202
+ }
203
+ /** The bare host the env token rule for `platform` is bound to; undefined for an unparseable `RENOVATE_ENDPOINT`. */
204
+ export function tokenHost(platform, env = process.env) {
205
+ return boundHost(platform, homeScope({}, env));
206
+ }
207
+ function boundHost(platform, scope) {
208
+ if (scope.platform === platform && scope.endpoint)
209
+ return parseUrl(scope.endpoint)?.hostname || undefined;
210
+ return PUBLIC_HOST[platform];
211
+ }
212
+ function domainMatches(host, domain) {
213
+ if (!host)
214
+ return false;
215
+ if (host === domain)
216
+ return true;
217
+ return host.endsWith(domain.startsWith(".") ? domain : `.${domain}`);
218
+ }
219
+ function parseUrl(url) {
220
+ try {
221
+ return new URL(url);
222
+ }
223
+ catch {
224
+ return null;
225
+ }
226
+ }
227
+ function isHttp(url) {
228
+ return url.protocol === "http:" || url.protocol === "https:";
229
+ }
@@ -16,6 +16,10 @@ export const HOST_RULES_MAX_ITEMS = 256;
16
16
  export const HOST_RULE_JSON_MAX_BYTES = 64_000;
17
17
  export const BASE_BRANCHES_MAX_ITEMS = 50;
18
18
  export const BRANCH_NAME_MAX_BYTES = 256;
19
+ // dry_run's temporary repo copy for local-preset flattening (ADR-0009):
20
+ // checked against the listing and an lstat sum before anything is copied.
21
+ export const REPO_COPY_MAX_FILES = 50_000;
22
+ export const REPO_COPY_MAX_BYTES = 512 * 1024 * 1024;
19
23
  export const pathString = (description) => z.string().max(PATH_MAX_BYTES).describe(description);
20
24
  export const tokenString = (description) => z.string().max(TOKEN_MAX_BYTES).describe(description);
21
25
  export const endpointString = (description) => z.string().max(ENDPOINT_MAX_BYTES).describe(description);