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 +11 -9
- package/dist/lib/credentialResolver.js +1 -15
- package/dist/lib/externalPresetFetcher.js +14 -15
- package/dist/lib/gitRemote.js +28 -5
- package/dist/lib/hostRulesConfig.js +18 -13
- package/dist/lib/hostTokens.js +229 -0
- package/dist/lib/inputLimits.js +4 -0
- package/dist/lib/presetFlattenWorker.js +174 -0
- package/dist/lib/presetFlattenWorkerImpl.js +79 -0
- package/dist/lib/presetResolver.js +51 -8
- package/dist/lib/renovateWorker.js +3 -4
- package/dist/lib/repoCopy.js +143 -0
- package/dist/lib/setupCheck.js +83 -60
- package/dist/lib/toolInputs.js +11 -0
- package/dist/lib/version.js +1 -1
- package/dist/tools/annotateDryRun.js +6 -3
- package/dist/tools/dryRun.js +243 -118
- package/dist/tools/explainConfig.js +6 -3
- package/dist/tools/explainPaths.js +6 -3
- package/dist/tools/resolveConfig.js +8 -5
- package/dist/tools/resolveConfigDiff.js +7 -4
- package/dist/tools/testPackageRules.js +6 -3
- package/package.json +1 -1
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) | `
|
|
173
|
-
| Self-hosted Gitea / Forgejo |
|
|
174
|
-
| Bitbucket Cloud | — | (omit) | `
|
|
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
|
|
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
|
|
200
|
-
- "
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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]);
|
package/dist/lib/gitRemote.js
CHANGED
|
@@ -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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
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(
|
|
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: [...] }`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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`
|
|
12
|
-
* var
|
|
13
|
-
* Renovate
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
19
|
-
const
|
|
20
|
-
await fs.writeFile(tmpPath,
|
|
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
|
+
}
|
package/dist/lib/inputLimits.js
CHANGED
|
@@ -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);
|