renovate-mcp 1.8.1 → 1.8.2

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.
@@ -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));
@@ -60,14 +60,30 @@ function dispatch(parsed, options) {
60
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)}`;
86
+ const url = `${apiBase}/repos/${encodeFilePath(parsed.repoPath)}/contents/${encodeFilePath(file)}?ref=${encodeURIComponent(ref)}`;
71
87
  const credential = resolveCredential(["RENOVATE_TOKEN", "GITHUB_TOKEN"]);
72
88
  const headers = {
73
89
  Accept: "application/vnd.github.raw",
@@ -79,10 +95,13 @@ 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)}`;
@@ -93,14 +112,15 @@ async function fetchGitLab(parsed, timeoutMs, fetchImpl, endpoint) {
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)}` : "";
@@ -124,12 +144,21 @@ async function fetchGitea(parsed, timeoutMs, fetchImpl, endpoint, platform) {
124
144
  * if anyone needs it.
125
145
  */
126
146
  async function fetchBitbucket(parsed, timeoutMs, fetchImpl, endpoint) {
127
- if (!parsed.repoPath || !OWNER_REPO_RE.test(parsed.repoPath)) {
147
+ const file = presetFileName(parsed);
148
+ // The ref is a single encoded path segment here (a query parameter elsewhere), so only an
149
+ // empty or dot ref can shape the path.
150
+ const rawRef = parsed.ref ?? "HEAD";
151
+ if (!parsed.repoPath ||
152
+ !OWNER_REPO_RE.test(parsed.repoPath) ||
153
+ !isSafePath(parsed.repoPath) ||
154
+ !isSafePath(file) ||
155
+ rawRef === "" ||
156
+ rawRef === "." ||
157
+ rawRef === "..") {
128
158
  return { ok: false, reason: `Invalid bitbucket preset: ${parsed.original}` };
129
159
  }
130
- const file = presetFileName(parsed);
131
160
  const apiBase = endpoint ?? DEFAULT_API_BASE.bitbucket;
132
- const ref = encodeURIComponent(parsed.ref ?? "HEAD");
161
+ const ref = encodeURIComponent(rawRef);
133
162
  const url = `${apiBase}/2.0/repositories/${encodeFilePath(parsed.repoPath)}/src/${ref}/${encodeFilePath(file)}`;
134
163
  const credential = resolveCredential(["RENOVATE_TOKEN", "BITBUCKET_TOKEN"]);
135
164
  const headers = { "User-Agent": "renovate-mcp" };
@@ -1,4 +1,4 @@
1
- import { PRESETS } from "../data/presets.generated.js";
1
+ import { PRESETS, REMOVED_PRESETS } from "../data/presets.generated.js";
2
2
  import { fetchExternalPreset } from "./externalPresetFetcher.js";
3
3
  import { HOSTED_PLATFORMS } from "./gitRemote.js";
4
4
  import { runMerge } from "./mergeWorker.js";
@@ -130,7 +130,29 @@ async function collectSteps(input, ownerName, viaChain, ctx) {
130
130
  });
131
131
  continue;
132
132
  }
133
- const parsed = parsePreset(entry);
133
+ const redirect = followRemovedPresets(entry);
134
+ if (redirect.kind === "cycle") {
135
+ ctx.presetsUnresolved.push({
136
+ preset: entry,
137
+ reason: `Cycle detected in Renovate's removed/renamed preset map: ${redirect.chain.join(" → ")}`,
138
+ });
139
+ continue;
140
+ }
141
+ if (redirect.kind === "removed") {
142
+ ctx.warnings.push({
143
+ preset: entry,
144
+ message: `Preset "${entry}" was removed in Renovate and is ignored (it resolves to nothing); remove it from \`extends\` (migrate_config does this).`,
145
+ });
146
+ ctx.presetsResolved.push(entry);
147
+ continue;
148
+ }
149
+ const renamedTo = redirect.kind === "renamed" ? redirect.target : null;
150
+ // The target carries no args, so the original entry's args never apply
151
+ // (same as Renovate). `original` stays what the user wrote so unresolved
152
+ // records and labels name the entry as written.
153
+ const parsed = renamedTo === null
154
+ ? parsePreset(entry)
155
+ : { ...parsePreset(renamedTo), original: entry };
134
156
  if (ctx.stack.includes(parsed.key)) {
135
157
  ctx.presetsUnresolved.push({
136
158
  preset: entry,
@@ -138,7 +160,21 @@ async function collectSteps(input, ownerName, viaChain, ctx) {
138
160
  });
139
161
  continue;
140
162
  }
163
+ const unresolvedBefore = ctx.presetsUnresolved.length;
141
164
  const body = await loadPresetBody(parsed, ctx);
165
+ if (renamedTo !== null) {
166
+ if (!body) {
167
+ for (const u of ctx.presetsUnresolved.slice(unresolvedBefore)) {
168
+ u.reason += ` (Renovate renamed "${entry}" to "${renamedTo}".)`;
169
+ }
170
+ }
171
+ else {
172
+ ctx.warnings.push({
173
+ preset: entry,
174
+ message: `Preset "${entry}" was renamed in Renovate to "${renamedTo}" and was resolved as that preset; update \`extends\` (migrate_config does this).`,
175
+ });
176
+ }
177
+ }
142
178
  if (!body)
143
179
  continue;
144
180
  const { value, missingArgs, unknownTemplates } = applyArgs(body, parsed.args);
@@ -160,6 +196,30 @@ async function collectSteps(input, ownerName, viaChain, ctx) {
160
196
  });
161
197
  }
162
198
  }
199
+ /**
200
+ * Follow Renovate's `removedPresets` map on the exact raw entry (args included,
201
+ * no trimming), repeatedly like Renovate's recursive lookup. A string value is a
202
+ * rename, null means Renovate drops the preset. Bounded by a visited set.
203
+ */
204
+ function followRemovedPresets(entry) {
205
+ const lookup = (name) => Object.hasOwn(REMOVED_PRESETS, name) ? REMOVED_PRESETS[name] : undefined;
206
+ const seen = [entry];
207
+ let current = entry;
208
+ for (;;) {
209
+ const next = lookup(current);
210
+ // Renovate treats an empty-string value as falsy, i.e. not a redirect.
211
+ if (next === undefined || next === "") {
212
+ return current === entry ? { kind: "none" } : { kind: "renamed", target: current };
213
+ }
214
+ if (next === null) {
215
+ return { kind: "removed" };
216
+ }
217
+ if (seen.includes(next))
218
+ return { kind: "cycle", chain: [...seen, next] };
219
+ seen.push(next);
220
+ current = next;
221
+ }
222
+ }
163
223
  export function recordTemplateWarnings(entry, suppliedArgs, missingArgs, unknownTemplates, warningsList) {
164
224
  for (const idx of [...missingArgs].sort((a, b) => a - b)) {
165
225
  warningsList.push({
@@ -203,7 +263,13 @@ export async function loadPresetBody(parsed, ctx) {
203
263
  : parsed;
204
264
  const classification = classifyExternalSource(effective.source);
205
265
  if (!classification.fetchable) {
206
- unresolvedList.push({ preset: parsed.original, reason: classification.reason });
266
+ const bareNote = parsed.source === "local" && !/^local>/i.test(parsed.original.trim())
267
+ ? ` Renovate treats a prefix-less \`owner/repo\` entry as \`local>owner/repo\`.`
268
+ : "";
269
+ unresolvedList.push({
270
+ preset: parsed.original,
271
+ reason: classification.reason + bareNote,
272
+ });
207
273
  return null;
208
274
  }
209
275
  if (!ctx.fetchExternal) {
@@ -235,6 +301,35 @@ const KNOWN_SOURCES = new Set([
235
301
  "local",
236
302
  "npm",
237
303
  ]);
304
+ // Renovate's internal preset namespaces, verbatim from its parser
305
+ // (`config/presets/parse.ts`). It checks these before treating a
306
+ // slash-containing entry as `local>`, so e.g. `group:foo/bar` stays a built-in
307
+ // lookup. Hard-coded rather than derived from the catalogue: `compatibility`
308
+ // and `npm` have no presets but are still internal to the parser.
309
+ const BUILTIN_NAMESPACES = new Set([
310
+ "abandonments",
311
+ "compatibility",
312
+ "config",
313
+ "customManagers",
314
+ "default",
315
+ "docker",
316
+ "global",
317
+ "group",
318
+ "helpers",
319
+ "mergeConfidence",
320
+ "monorepo",
321
+ "npm",
322
+ "packages",
323
+ "preview",
324
+ "replacements",
325
+ "schedule",
326
+ "security",
327
+ "workarounds",
328
+ ]);
329
+ /** True when Renovate would treat `entry` as a `local>` preset (prefixed or bare `owner/repo`). */
330
+ export function isLocalPreset(entry) {
331
+ return parsePreset(entry).source === "local";
332
+ }
238
333
  export function parsePreset(raw) {
239
334
  const original = raw;
240
335
  let rest = raw.trim();
@@ -252,9 +347,39 @@ export function parsePreset(raw) {
252
347
  if (srcMatch) {
253
348
  return parseExternal(rest, original, args, srcMatch[1].toLowerCase());
254
349
  }
350
+ // Entries without a `<source>>` prefix follow Renovate's preset parser for
351
+ // the source split: relative path, then http(s) URL, then `:`-prefixed
352
+ // built-in, then `@scope/pkg` npm, then anything else containing `/` (outside
353
+ // a built-in namespace) as a `local>` preset. The rest deliberately differs:
354
+ // any other `foo:bar` is looked up as a built-in (Renovate would try the npm
355
+ // package `renovate-config-foo`), which gives a clearer typo message.
356
+ if (/^\.{0,2}\//.test(rest)) {
357
+ return {
358
+ key: original,
359
+ original,
360
+ args,
361
+ unresolvableReason: "Relative presets are not supported by resolve_config; run dry_run for full-fidelity resolution.",
362
+ };
363
+ }
364
+ if (/^https?:\/\//i.test(rest)) {
365
+ return {
366
+ key: original,
367
+ original,
368
+ args,
369
+ unresolvableReason: "URL (http) presets are not supported by resolve_config; run dry_run for full-fidelity resolution.",
370
+ };
371
+ }
372
+ if (!rest.startsWith(":")) {
373
+ if (rest.startsWith("@")) {
374
+ return parseExternal(`npm>${rest}`, original, args, "npm");
375
+ }
376
+ if (rest.includes("/") && !BUILTIN_NAMESPACES.has(rest.split(":", 1)[0])) {
377
+ return parseExternal(`local>${rest}`, original, args, "local");
378
+ }
379
+ }
255
380
  // Built-in: `:foo` is shorthand for `default:foo`.
256
381
  const key = rest.startsWith(":") ? `default${rest}` : rest;
257
- // An entry with no `>` prefix and no `:` namespace is an npm preset.
382
+ // Otherwise an entry with no `:` namespace is an npm preset.
258
383
  if (!key.includes(":")) {
259
384
  return {
260
385
  key: `npm>${key}`,
@@ -1,5 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
- export const SERVER_VERSION = "1.8.1"; // x-release-please-version
2
+ export const SERVER_VERSION = "1.8.2"; // x-release-please-version
3
3
  // "released" = the server is running from a `node_modules/` install (global
4
4
  // `npm i -g`, project-local `npm i`, or `npx renovate-mcp`). Anything else —
5
5
  // most commonly an MCP config of `{"command": "node", "args": [".../dist/index.js"]}`
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
6
6
  import { run, resolveRenovateTool, formatMissingBinaryError, formatTimeoutError, CommandTimeoutError, } from "../lib/renovateCli.js";
7
7
  import { resolveCredential } from "../lib/credentialResolver.js";
8
8
  import { validateEndpoint } from "../lib/endpointValidator.js";
9
+ import { isLocalPreset } from "../lib/presetResolver.js";
9
10
  import { locateConfig } from "../lib/configLocations.js";
10
11
  import { assertRepoDir, NETWORK_READ_ONLY_ANNOTATIONS } from "../lib/toolInputs.js";
11
12
  import { detectLookupProblems } from "../lib/lookupProblems.js";
@@ -80,7 +81,8 @@ function collectReportProblems(report) {
80
81
  }
81
82
  /**
82
83
  * Scan the repo's Renovate config for `extends` entries of the form
83
- * `local>owner/repo[:preset]` and return them along with the config-file path
84
+ * `local>owner/repo[:preset]` (or the bare `owner/repo[:preset]` spelling
85
+ * Renovate also treats as local) and return them along with the config-file path
84
86
  * they were found in. Returns `null` if no config is found (Renovate will
85
87
  * handle that case itself with its usual error). Silently returns `null` on
86
88
  * parse errors too — we don't want preflight to be stricter than Renovate's
@@ -99,7 +101,7 @@ async function detectUnresolvableLocalPresets(repoPath) {
99
101
  const extendsRaw = located.config.extends;
100
102
  if (!Array.isArray(extendsRaw))
101
103
  return null;
102
- const presets = extendsRaw.filter((entry) => typeof entry === "string" && entry.startsWith("local>"));
104
+ const presets = extendsRaw.filter((entry) => typeof entry === "string" && isLocalPreset(entry));
103
105
  return { relPath: located.relPath, presets };
104
106
  }
105
107
  function countRepositories(report) {
@@ -137,8 +137,41 @@ export function registerWriteConfig(server) {
137
137
  ? `${target}.renovate-mcp-slice-${randomUUID()}.json`
138
138
  : undefined;
139
139
  await fs.mkdir(path.dirname(target), { recursive: true });
140
- await fs.writeFile(tmp, payload, { flag: "wx", mode: 0o600 });
140
+ // rename() keeps tmp's inode, so tmp's mode becomes the target's. Capture
141
+ // an existing regular file's permission bits so an overwrite does not
142
+ // silently tighten e.g. 0644 to 0600. `stat` (not `lstat`): `target` is
143
+ // already a realpath, and a dangling link must read as ENOENT (new file).
144
+ // A concurrent chmod between this stat and the rename is lost; benign.
145
+ let targetMode = undefined;
141
146
  try {
147
+ const st = await fs.stat(target);
148
+ if (st.isFile())
149
+ targetMode = st.mode & 0o777;
150
+ }
151
+ catch (err) {
152
+ if (err.code !== "ENOENT")
153
+ throw err;
154
+ }
155
+ // Create tmp 0o600 when copying a mode over it (owner-only until the
156
+ // fchmod below); for a new file ask for 0o666 so the kernel applies the
157
+ // process umask, avoiding the deprecated no-arg process.umask(). While the
158
+ // validator runs, tmp is as readable as the written file would be (also
159
+ // when validation then refuses the write); it never exceeds that. The
160
+ // mode is set through the open handle (fchmod), never `fs.chmod(tmp)`,
161
+ // which would re-resolve the path and follow a symlink swapped in at
162
+ // that name. The open stays outside the
163
+ // try: on EEXIST the entry at tmp is not ours, so `finally` must not
164
+ // unlink it.
165
+ const fh = await fs.open(tmp, "wx", targetMode === undefined ? 0o666 : 0o600);
166
+ try {
167
+ try {
168
+ await fh.writeFile(payload);
169
+ if (targetMode !== undefined)
170
+ await fh.chmod(targetMode);
171
+ }
172
+ finally {
173
+ await fh.close();
174
+ }
142
175
  if (sliceTmp) {
143
176
  await fs.writeFile(sliceTmp, JSON.stringify(config, null, 2) + "\n", {
144
177
  flag: "wx",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "renovate-mcp",
3
- "version": "1.8.1",
3
+ "version": "1.8.2",
4
4
  "description": "MCP server for designing Renovate configurations interactively.",
5
5
  "type": "module",
6
6
  "bin": {