@zereight/mcp-gitlab 2.1.53 → 2.1.55
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.ko.md +1 -1
- package/README.md +5 -1
- package/README.zh-CN.md +1 -1
- package/build/index.js +92 -5
- package/build/test/remote-auth-simple-test.js +16 -30
- package/build/test/streamable-http-unauthenticated-discovery.test.js +20 -2
- package/build/test/utils/version-check.test.js +198 -11
- package/build/utils/version-check.js +189 -7
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -98,7 +98,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
98
98
|
|
|
99
99
|
예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
|
|
100
100
|
|
|
101
|
-
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.
|
|
101
|
+
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.54`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
|
|
102
102
|
|
|
103
103
|
#### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
|
|
104
104
|
|
package/README.md
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# GitLab MCP Server
|
|
2
2
|
|
|
3
|
+
[](https://github.com/zereight/gitlab-mcp)
|
|
4
|
+
[](https://www.npmjs.com/package/@zereight/mcp-gitlab)
|
|
3
5
|
[](https://www.npmjs.com/package/@zereight/mcp-gitlab)
|
|
6
|
+
[](https://github.com/zereight/gitlab-mcp/blob/main/LICENSE)
|
|
4
7
|
[](vscode:mcp/install?%7B%22name%22%3A%22zereight.gitlab-mcp%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40zereight%2Fmcp-gitlab%40latest%22%5D%2C%22env%22%3A%7B%22GITLAB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agitlab-token%7D%22%2C%22GITLAB_API_URL%22%3A%22https%3A%2F%2Fgitlab.com%2Fapi%2Fv4%22%2C%22GITLAB_PERMISSION_MODE%22%3A%22full%22%7D%7D)
|
|
8
|
+
[](https://deepwiki.com/zereight/gitlab-mcp)
|
|
5
9
|
[](https://mcptoplist.com/server/io.github.zereight%2Fgitlab-mcp) [](https://mcpindex.ai/server/io-github-zereight-gitlab-mcp)
|
|
6
10
|
|
|
7
11
|
[English](./README.md) | [한국어](./README.ko.md) | [简体中文](./README.zh-CN.md)
|
|
@@ -99,7 +103,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
99
103
|
|
|
100
104
|
The examples use `zereight-mcp-gitlab`, a less collision-prone alias for the legacy `mcp-gitlab` binary. If your MCP client cannot find it, use the absolute path from `which zereight-mcp-gitlab`.
|
|
101
105
|
|
|
102
|
-
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.
|
|
106
|
+
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.54`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
|
|
103
107
|
|
|
104
108
|
#### Using CLI Arguments (for clients with env var issues)
|
|
105
109
|
|
package/README.zh-CN.md
CHANGED
|
@@ -98,7 +98,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
98
98
|
|
|
99
99
|
示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
|
|
100
100
|
|
|
101
|
-
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.
|
|
101
|
+
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.54`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
|
|
102
102
|
|
|
103
103
|
#### 使用 CLI 参数(适用于环境变量有问题的客户端)
|
|
104
104
|
|
package/build/index.js
CHANGED
|
@@ -62,7 +62,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
62
62
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
63
63
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
64
64
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
65
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
65
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
66
66
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
67
67
|
import express from "express";
|
|
68
68
|
import fetchCookie from "fetch-cookie";
|
|
@@ -116,6 +116,53 @@ import { randomUUID, timingSafeEqual, } from "node:crypto";
|
|
|
116
116
|
import { createLogger } from "./utils/logger.js";
|
|
117
117
|
const logger = createLogger();
|
|
118
118
|
const SERVER_NAME = process.env.MCP_SERVER_NAME?.trim() || "zereight-gitlab-mcp-server";
|
|
119
|
+
/**
|
|
120
|
+
* Static MCP prompt catalog. These wrap the multi-step tool workflows documented in
|
|
121
|
+
* skills/gitlab-mcp/ so MCP clients that surface prompts (e.g. slash commands) can
|
|
122
|
+
* trigger them without the agent needing to read the Skill first. Kept inline (not
|
|
123
|
+
* loaded from skills/gitlab-mcp/*.md) since only "build" ships in the npm package.
|
|
124
|
+
*/
|
|
125
|
+
const MCP_PROMPTS = [
|
|
126
|
+
{
|
|
127
|
+
name: "review_merge_request",
|
|
128
|
+
description: "Review a merge request's diff in batches and leave threaded comments.",
|
|
129
|
+
arguments: [
|
|
130
|
+
{ name: "project_id", description: "Numeric project ID or URL-encoded path", required: true },
|
|
131
|
+
{ name: "merge_request_iid", description: "Merge request IID", required: true },
|
|
132
|
+
],
|
|
133
|
+
render: (args) => `Review merge request !${args.merge_request_iid ?? "<iid>"} in project ${args.project_id ?? "<project_id>"}:\n` +
|
|
134
|
+
`1. Call list_merge_request_changed_files to get the changed file list (use excluded_file_patterns for lockfiles/dist/generated files).\n` +
|
|
135
|
+
`2. Call get_merge_request_file_diff in batches of 3-5 files, prioritizing source over config/tests.\n` +
|
|
136
|
+
`3. Leave findings with create_merge_request_thread (inline, position: new_path/new_line) or create_merge_request_note (general).\n` +
|
|
137
|
+
`4. Summarize the review verdict in a final top-level note.`,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "triage_pipeline_failure",
|
|
141
|
+
description: "Find the failing job in a pipeline and pull its log tail for diagnosis.",
|
|
142
|
+
arguments: [
|
|
143
|
+
{ name: "project_id", description: "Numeric project ID or URL-encoded path", required: true },
|
|
144
|
+
{ name: "pipeline_id", description: "Pipeline ID", required: true },
|
|
145
|
+
],
|
|
146
|
+
render: (args) => `Diagnose pipeline ${args.pipeline_id ?? "<pipeline_id>"} in project ${args.project_id ?? "<project_id>"}:\n` +
|
|
147
|
+
`1. Call list_pipeline_jobs to find jobs with status "failed".\n` +
|
|
148
|
+
`2. Call get_pipeline_job_output (or the job trace tool) on each failed job, focusing on the tail of the log.\n` +
|
|
149
|
+
`3. Identify the root cause (compile error, test failure, timeout, runner issue) and quote the relevant log lines.\n` +
|
|
150
|
+
`4. Suggest the fix or the next diagnostic step.`,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "triage_vulnerabilities",
|
|
154
|
+
description: "List a project's open vulnerabilities and propose a remediation priority order.",
|
|
155
|
+
arguments: [
|
|
156
|
+
{ name: "project_id", description: "Numeric project ID or URL-encoded path", required: true },
|
|
157
|
+
],
|
|
158
|
+
render: (args) => `Triage vulnerabilities for project ${args.project_id ?? "<project_id>"}:\n` +
|
|
159
|
+
`1. Call list_project_vulnerabilities once with state=detected and once with state=confirmed ` +
|
|
160
|
+
`(the tool only accepts one state per call). Follow pageInfo.endCursor with the "after" ` +
|
|
161
|
+
`param while pageInfo.hasNextPage is true, and aggregate both states' results.\n` +
|
|
162
|
+
`2. Group the aggregated results by severity (critical > high > medium > low) and by whether a fix is already available.\n` +
|
|
163
|
+
`3. Propose a remediation order, calling out any critical/high items with no available fix.`,
|
|
164
|
+
},
|
|
165
|
+
];
|
|
119
166
|
/**
|
|
120
167
|
* Create a new MCP Server instance with request handlers registered.
|
|
121
168
|
* Each transport connection gets its own Server instance to prevent
|
|
@@ -168,8 +215,35 @@ function createServer() {
|
|
|
168
215
|
tools: {
|
|
169
216
|
listChanged: true,
|
|
170
217
|
},
|
|
218
|
+
prompts: {},
|
|
171
219
|
},
|
|
172
220
|
});
|
|
221
|
+
mcpServer.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
222
|
+
prompts: MCP_PROMPTS.map(({ name, description, arguments: args }) => ({
|
|
223
|
+
name,
|
|
224
|
+
description,
|
|
225
|
+
arguments: args,
|
|
226
|
+
})),
|
|
227
|
+
}));
|
|
228
|
+
mcpServer.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
229
|
+
const prompt = MCP_PROMPTS.find(p => p.name === request.params.name);
|
|
230
|
+
if (!prompt) {
|
|
231
|
+
throw new Error(`Unknown prompt: ${request.params.name}`);
|
|
232
|
+
}
|
|
233
|
+
const args = (request.params.arguments ?? {});
|
|
234
|
+
return {
|
|
235
|
+
description: prompt.description,
|
|
236
|
+
messages: [
|
|
237
|
+
{
|
|
238
|
+
role: "user",
|
|
239
|
+
content: {
|
|
240
|
+
type: "text",
|
|
241
|
+
text: prompt.render(args),
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
};
|
|
246
|
+
});
|
|
173
247
|
mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
174
248
|
// Step 6: Gemini $schema cleanup + annotations (only dynamic step per request)
|
|
175
249
|
// <<< START: Remove $schema for Gemini compatibility >>>
|
|
@@ -10038,7 +10112,9 @@ async function startSSEServer() {
|
|
|
10038
10112
|
*/
|
|
10039
10113
|
async function startStreamableHTTPServer() {
|
|
10040
10114
|
const app = express();
|
|
10041
|
-
|
|
10115
|
+
// Session IDs are untrusted map keys. A null prototype prevents inherited
|
|
10116
|
+
// names such as "constructor" from masquerading as live transports.
|
|
10117
|
+
const streamableTransports = Object.create(null);
|
|
10042
10118
|
const authTimeouts = {};
|
|
10043
10119
|
// Configuration and limits
|
|
10044
10120
|
const MAX_SESSIONS = Number.parseInt(process.env.MAX_SESSIONS || "1000", 10);
|
|
@@ -10639,8 +10715,19 @@ async function startStreamableHTTPServer() {
|
|
|
10639
10715
|
: STREAMABLE_HTTP_AUTH_TOKEN && !REMOTE_AUTHORIZATION
|
|
10640
10716
|
? staticMcpBearerAuth
|
|
10641
10717
|
: (_req, _res, next) => next();
|
|
10718
|
+
const rejectUnknownStatefulSession = (req, res, next) => {
|
|
10719
|
+
const usesStatelessSessions = OAUTH_STATELESS_MODE &&
|
|
10720
|
+
STATELESS_MATERIAL &&
|
|
10721
|
+
(REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH);
|
|
10722
|
+
const sessionId = readMcpSessionIdHeader(req);
|
|
10723
|
+
if (!usesStatelessSessions && sessionId && !streamableTransports[sessionId]) {
|
|
10724
|
+
res.status(404).json({ error: "Session not found" });
|
|
10725
|
+
return;
|
|
10726
|
+
}
|
|
10727
|
+
next();
|
|
10728
|
+
};
|
|
10642
10729
|
// Streamable HTTP endpoint - handles both session creation and message handling
|
|
10643
|
-
app.post("/mcp", mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
|
|
10730
|
+
app.post("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
|
|
10644
10731
|
const sessionId = readMcpSessionIdHeader(req);
|
|
10645
10732
|
const publicBaseUrl = getForwardedPublicBaseUrl(req, MCP_TRUST_PROXY);
|
|
10646
10733
|
// Track request
|
|
@@ -10910,7 +10997,7 @@ async function startStreamableHTTPServer() {
|
|
|
10910
10997
|
}
|
|
10911
10998
|
});
|
|
10912
10999
|
// Streamable HTTP GET endpoint for listening to server-sent events (SSE)
|
|
10913
|
-
app.get("/mcp", mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
|
|
11000
|
+
app.get("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
|
|
10914
11001
|
const sessionId = readMcpSessionIdHeader(req);
|
|
10915
11002
|
const acceptHeader = readAcceptHeader(req);
|
|
10916
11003
|
if (!acceptHeader.includes("text/event-stream")) {
|
|
@@ -11030,7 +11117,7 @@ async function startStreamableHTTPServer() {
|
|
|
11030
11117
|
});
|
|
11031
11118
|
});
|
|
11032
11119
|
// to delete a mcp server session explicitly
|
|
11033
|
-
app.delete("/mcp", mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
|
|
11120
|
+
app.delete("/mcp", rejectUnknownStatefulSession, mcpRequestRateLimit, mcpBearerAuth, async (req, res) => {
|
|
11034
11121
|
const sessionId = readMcpSessionIdHeader(req);
|
|
11035
11122
|
if (!sessionId) {
|
|
11036
11123
|
res.status(400).json({ error: "mcp-session-id header is required" });
|
|
@@ -255,36 +255,22 @@ describe('Remote Authorization', { concurrency: 1 }, () => {
|
|
|
255
255
|
await new Promise(resolve => setTimeout(resolve, TIMEOUT_TEST_WAIT_MS));
|
|
256
256
|
await waitForSessionDecrease(mcpUrl, beforeTimeout);
|
|
257
257
|
console.log(' ✓ Timeout closed transport and released active session slot');
|
|
258
|
-
// Step 3:
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
console.log(` ✓ Request correctly failed with status ${response.status}`);
|
|
275
|
-
const body = await response.text();
|
|
276
|
-
console.log(` ℹ️ Error: ${body.substring(0, 100)}...`);
|
|
277
|
-
}
|
|
278
|
-
else {
|
|
279
|
-
assert.fail('Should have failed due to expired auth token');
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
catch (error) {
|
|
283
|
-
// Network errors are also acceptable
|
|
284
|
-
assert.ok(error instanceof Error, 'Should throw error');
|
|
285
|
-
console.log(' ✓ Request correctly failed after timeout');
|
|
286
|
-
console.log(` ℹ️ Error: ${error.message.substring(0, 100)}...`);
|
|
287
|
-
}
|
|
258
|
+
// Step 3: A stale stateful session must return the MCP re-initialization signal.
|
|
259
|
+
const response = await fetch(mcpUrl, {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
headers: {
|
|
262
|
+
'Content-Type': 'application/json',
|
|
263
|
+
'mcp-session-id': sessionId,
|
|
264
|
+
},
|
|
265
|
+
body: JSON.stringify({
|
|
266
|
+
jsonrpc: '2.0',
|
|
267
|
+
id: 1,
|
|
268
|
+
method: 'tools/list'
|
|
269
|
+
})
|
|
270
|
+
});
|
|
271
|
+
assert.strictEqual(response.status, 404, 'Should return 404 Session Not Found');
|
|
272
|
+
assert.deepStrictEqual(await response.json(), { error: 'Session not found' });
|
|
273
|
+
console.log(' ✓ Expired session returned 404 so the client can re-initialize');
|
|
288
274
|
await clientWithAuth.disconnect();
|
|
289
275
|
});
|
|
290
276
|
});
|
|
@@ -110,17 +110,35 @@ describe("Streamable HTTP unauthenticated tool discovery", { timeout: 20_000 },
|
|
|
110
110
|
const { server, mcpUrl } = await launchRemoteAuthServer({
|
|
111
111
|
GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
|
|
112
112
|
MAX_SESSIONS: "1",
|
|
113
|
+
MAX_REQUESTS_PER_MINUTE: "1",
|
|
114
|
+
MCP_TRUST_PROXY: "true",
|
|
113
115
|
SESSION_TIMEOUT_SECONDS: "1",
|
|
114
116
|
});
|
|
115
117
|
servers.push(server);
|
|
116
|
-
await initialize(mcpUrl);
|
|
118
|
+
const expiredSessionId = await initialize(mcpUrl);
|
|
117
119
|
await new Promise(resolve => setTimeout(resolve, 2_000));
|
|
120
|
+
const expiredResponse = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, { "mcp-session-id": expiredSessionId });
|
|
121
|
+
assert.strictEqual(expiredResponse.status, 404, expiredResponse.text);
|
|
122
|
+
assert.deepStrictEqual(expiredResponse.data, { error: "Session not found" });
|
|
118
123
|
const healthResponse = await fetch(mcpUrl.replace("/mcp", "/health"));
|
|
119
124
|
assert.strictEqual(healthResponse.status, 200);
|
|
120
|
-
const nextSession = await rawMcpRequest(mcpUrl, initializeBody
|
|
125
|
+
const nextSession = await rawMcpRequest(mcpUrl, initializeBody, {
|
|
126
|
+
"x-forwarded-for": "192.0.2.10",
|
|
127
|
+
});
|
|
121
128
|
assert.strictEqual(nextSession.status, 200, nextSession.text);
|
|
122
129
|
assert.ok(nextSession.sessionId, "expired discovery session should free capacity");
|
|
123
130
|
});
|
|
131
|
+
test("rejects inherited object-property names as unknown session IDs", async () => {
|
|
132
|
+
const { server, mcpUrl } = await launchRemoteAuthServer({
|
|
133
|
+
GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
|
|
134
|
+
});
|
|
135
|
+
servers.push(server);
|
|
136
|
+
for (const sessionId of ["constructor", "toString", "__proto__"]) {
|
|
137
|
+
const response = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, { "mcp-session-id": sessionId });
|
|
138
|
+
assert.strictEqual(response.status, 404, `${sessionId}: ${response.text}`);
|
|
139
|
+
assert.deepStrictEqual(response.data, { error: "Session not found" });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
124
142
|
test("still blocks unauthenticated tools/call when discovery is enabled", async () => {
|
|
125
143
|
const { server, mcpUrl } = await launchRemoteAuthServer({
|
|
126
144
|
GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
|
|
@@ -1,8 +1,50 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import { describe, test } from "node:test";
|
|
3
|
-
import { checkForNewVersion, isNewerVersion } from "../../utils/version-check.js";
|
|
6
|
+
import { checkForNewVersion, fetchLatestVersion, isNewerVersion } from "../../utils/version-check.js";
|
|
4
7
|
function fetchReturning(status, body) {
|
|
5
|
-
return
|
|
8
|
+
return async () => new Response(JSON.stringify(body), { status });
|
|
9
|
+
}
|
|
10
|
+
function fetchCapturing(calls, status, body) {
|
|
11
|
+
return async (url, init) => {
|
|
12
|
+
const headers = new Headers(init?.headers);
|
|
13
|
+
calls.push({
|
|
14
|
+
url: url.toString(),
|
|
15
|
+
authorization: headers.get("authorization"),
|
|
16
|
+
redirect: init?.redirect,
|
|
17
|
+
});
|
|
18
|
+
return new Response(JSON.stringify(body), { status });
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function throwingResolver() {
|
|
22
|
+
throw new Error("npm not found");
|
|
23
|
+
}
|
|
24
|
+
async function withNpmrc(contents, run, globalContents = "") {
|
|
25
|
+
const configDir = mkdtempSync(join(tmpdir(), "gitlab-mcp-npmrc-"));
|
|
26
|
+
const userconfigPath = join(configDir, ".npmrc");
|
|
27
|
+
const globalconfigPath = join(configDir, "global.npmrc");
|
|
28
|
+
writeFileSync(userconfigPath, contents);
|
|
29
|
+
writeFileSync(globalconfigPath, globalContents);
|
|
30
|
+
const previousUserconfig = process.env.NPM_CONFIG_USERCONFIG;
|
|
31
|
+
const previousGlobalconfig = process.env.NPM_CONFIG_GLOBALCONFIG;
|
|
32
|
+
process.env.NPM_CONFIG_USERCONFIG = userconfigPath;
|
|
33
|
+
process.env.NPM_CONFIG_GLOBALCONFIG = globalconfigPath;
|
|
34
|
+
try {
|
|
35
|
+
await run();
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
if (previousUserconfig === undefined)
|
|
39
|
+
delete process.env.NPM_CONFIG_USERCONFIG;
|
|
40
|
+
else
|
|
41
|
+
process.env.NPM_CONFIG_USERCONFIG = previousUserconfig;
|
|
42
|
+
if (previousGlobalconfig === undefined)
|
|
43
|
+
delete process.env.NPM_CONFIG_GLOBALCONFIG;
|
|
44
|
+
else
|
|
45
|
+
process.env.NPM_CONFIG_GLOBALCONFIG = previousGlobalconfig;
|
|
46
|
+
rmSync(configDir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
6
48
|
}
|
|
7
49
|
describe("When isNewerVersion compares versions", () => {
|
|
8
50
|
test("should detect newer patch, minor, and major versions", () => {
|
|
@@ -30,18 +72,14 @@ describe("When checkForNewVersion runs", () => {
|
|
|
30
72
|
assert.equal(latest, null);
|
|
31
73
|
});
|
|
32
74
|
test("should return null without fetching when current version is unknown", async () => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return new Response("{}", { status: 200 });
|
|
37
|
-
});
|
|
38
|
-
assert.equal(await checkForNewVersion("unknown", spyFetch), null);
|
|
39
|
-
assert.equal(called, false);
|
|
75
|
+
const calls = [];
|
|
76
|
+
assert.equal(await checkForNewVersion("unknown", fetchCapturing(calls, 200, {})), null);
|
|
77
|
+
assert.equal(calls.length, 0);
|
|
40
78
|
});
|
|
41
79
|
test("should return null on registry errors instead of throwing", async () => {
|
|
42
|
-
const failingFetch =
|
|
80
|
+
const failingFetch = async () => {
|
|
43
81
|
throw new Error("network down");
|
|
44
|
-
}
|
|
82
|
+
};
|
|
45
83
|
assert.equal(await checkForNewVersion("2.1.29", failingFetch), null);
|
|
46
84
|
assert.equal(await checkForNewVersion("2.1.29", fetchReturning(500, {})), null);
|
|
47
85
|
});
|
|
@@ -50,3 +88,152 @@ describe("When checkForNewVersion runs", () => {
|
|
|
50
88
|
assert.equal(latest, null);
|
|
51
89
|
});
|
|
52
90
|
});
|
|
91
|
+
describe("When fetchLatestVersion resolves the registry", () => {
|
|
92
|
+
test("should fetch from the resolved registry instead of a hardcoded one", async () => {
|
|
93
|
+
const calls = [];
|
|
94
|
+
const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }), 3000, () => "https://npm.internal.example/");
|
|
95
|
+
assert.equal(latest, "2.1.31");
|
|
96
|
+
assert.equal(calls[0]?.url, "https://npm.internal.example/@zereight/mcp-gitlab/latest");
|
|
97
|
+
});
|
|
98
|
+
test("should fall back to the public registry when the resolver throws", async () => {
|
|
99
|
+
const calls = [];
|
|
100
|
+
const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }), 3000, throwingResolver);
|
|
101
|
+
assert.equal(latest, null);
|
|
102
|
+
assert.equal(calls.length, 0);
|
|
103
|
+
});
|
|
104
|
+
test("should fall back to the public registry when the resolved value has no hostname", async () => {
|
|
105
|
+
const calls = [];
|
|
106
|
+
const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }), 3000, () => "https:///");
|
|
107
|
+
assert.equal(latest, "2.1.31");
|
|
108
|
+
assert.equal(calls[0]?.url, "https://registry.npmjs.org/@zereight/mcp-gitlab/latest");
|
|
109
|
+
});
|
|
110
|
+
test("should fall back to the public registry when the resolved value has a query string", async () => {
|
|
111
|
+
const calls = [];
|
|
112
|
+
const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }), 3000, () => "https://registry.example/npm?tenant=a");
|
|
113
|
+
assert.equal(latest, "2.1.31");
|
|
114
|
+
assert.equal(calls[0]?.url, "https://registry.npmjs.org/@zereight/mcp-gitlab/latest");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe("When resolving the registry from npm config", () => {
|
|
118
|
+
describe("with a scoped @zereight:registry", () => {
|
|
119
|
+
test("should prefer the scoped registry over the unscoped one", async () => {
|
|
120
|
+
await withNpmrc("registry=https://unscoped.example/\n@zereight:registry=https://scoped.example/\n", async () => {
|
|
121
|
+
const calls = [];
|
|
122
|
+
const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
|
|
123
|
+
assert.equal(latest, "2.1.31");
|
|
124
|
+
assert.equal(calls[0]?.url, "https://scoped.example/@zereight/mcp-gitlab/latest");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe("When fetchLatestVersion authenticates against a protected registry", () => {
|
|
130
|
+
describe("with a scoped _authToken in .npmrc", () => {
|
|
131
|
+
test("should send a Bearer token only to that HTTPS registry", async () => {
|
|
132
|
+
await withNpmrc([
|
|
133
|
+
"registry=https://unscoped.example/",
|
|
134
|
+
"@zereight:registry=https://scoped.example/",
|
|
135
|
+
"//scoped.example/:_authToken=scoped-registry-token",
|
|
136
|
+
].join("\n"), async () => {
|
|
137
|
+
const calls = [];
|
|
138
|
+
const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
|
|
139
|
+
assert.equal(latest, "2.1.31");
|
|
140
|
+
assert.equal(calls[0]?.url, "https://scoped.example/@zereight/mcp-gitlab/latest");
|
|
141
|
+
assert.equal(calls[0]?.authorization, "Bearer scoped-registry-token");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
test("should refuse to follow redirects when a token is attached", async () => {
|
|
145
|
+
await withNpmrc([
|
|
146
|
+
"@zereight:registry=https://scoped.example/",
|
|
147
|
+
"//scoped.example/:_authToken=scoped-registry-token",
|
|
148
|
+
].join("\n"), async () => {
|
|
149
|
+
const calls = [];
|
|
150
|
+
await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
|
|
151
|
+
assert.equal(calls[0]?.redirect, "error");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe("without a matching token", () => {
|
|
156
|
+
test("should omit Authorization", async () => {
|
|
157
|
+
await withNpmrc("@zereight:registry=https://scoped.example/\n", async () => {
|
|
158
|
+
const calls = [];
|
|
159
|
+
await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
|
|
160
|
+
assert.equal(calls[0]?.authorization, null);
|
|
161
|
+
assert.equal(calls[0]?.redirect, undefined);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
describe("when the token is on a parent registry path", () => {
|
|
166
|
+
test("should fall back to the host-level _authToken", async () => {
|
|
167
|
+
await withNpmrc([
|
|
168
|
+
"@zereight:registry=https://scoped.example/npm",
|
|
169
|
+
"//scoped.example/:_authToken=host-level-token",
|
|
170
|
+
].join("\n"), async () => {
|
|
171
|
+
const calls = [];
|
|
172
|
+
const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
|
|
173
|
+
assert.equal(latest, "2.1.31");
|
|
174
|
+
assert.equal(calls[0]?.url, "https://scoped.example/npm/@zereight/mcp-gitlab/latest");
|
|
175
|
+
assert.equal(calls[0]?.authorization, "Bearer host-level-token");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
describe("when both a path token and a host token exist", () => {
|
|
180
|
+
test("should prefer the more specific path token", async () => {
|
|
181
|
+
await withNpmrc([
|
|
182
|
+
"@zereight:registry=https://scoped.example/npm",
|
|
183
|
+
"//scoped.example/npm/:_authToken=path-token",
|
|
184
|
+
"//scoped.example/:_authToken=host-level-token",
|
|
185
|
+
].join("\n"), async () => {
|
|
186
|
+
const calls = [];
|
|
187
|
+
await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
|
|
188
|
+
assert.equal(calls[0]?.authorization, "Bearer path-token");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
describe("when the token is only in the global npmrc", () => {
|
|
193
|
+
test("should send a Bearer token from globalconfig", async () => {
|
|
194
|
+
await withNpmrc("@zereight:registry=https://scoped.example/\n", async () => {
|
|
195
|
+
const calls = [];
|
|
196
|
+
await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
|
|
197
|
+
assert.equal(calls[0]?.authorization, "Bearer global-registry-token");
|
|
198
|
+
}, "//scoped.example/:_authToken=global-registry-token\n");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
describe("when the registry path contains underscores", () => {
|
|
202
|
+
test("should send a Bearer token for an Azure Artifacts-style registry path", async () => {
|
|
203
|
+
const registry = "https://pkgs.dev.azure.com/org/_packaging/feed/npm/registry";
|
|
204
|
+
const authKey = "//pkgs.dev.azure.com/org/_packaging/feed/npm/registry/:_authToken";
|
|
205
|
+
await withNpmrc([`@zereight:registry=${registry}`, `${authKey}=azure-feed-token`].join("\n"), async () => {
|
|
206
|
+
const calls = [];
|
|
207
|
+
const latest = await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
|
|
208
|
+
assert.equal(latest, "2.1.31");
|
|
209
|
+
assert.equal(calls[0]?.url, `${registry}/@zereight/mcp-gitlab/latest`);
|
|
210
|
+
assert.equal(calls[0]?.authorization, "Bearer azure-feed-token");
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
describe("with a token for a different host", () => {
|
|
215
|
+
test("should omit Authorization", async () => {
|
|
216
|
+
await withNpmrc([
|
|
217
|
+
"@zereight:registry=https://scoped.example/",
|
|
218
|
+
"//other.example/:_authToken=other-host-token",
|
|
219
|
+
].join("\n"), async () => {
|
|
220
|
+
const calls = [];
|
|
221
|
+
await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
|
|
222
|
+
assert.equal(calls[0]?.authorization, null);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
describe("when the registry is HTTP", () => {
|
|
227
|
+
test("should omit Authorization even if a token is configured", async () => {
|
|
228
|
+
await withNpmrc([
|
|
229
|
+
"@zereight:registry=http://insecure.example/",
|
|
230
|
+
"//insecure.example/:_authToken=http-registry-token",
|
|
231
|
+
].join("\n"), async () => {
|
|
232
|
+
const calls = [];
|
|
233
|
+
await fetchLatestVersion(fetchCapturing(calls, 200, { version: "2.1.31" }));
|
|
234
|
+
assert.equal(calls[0]?.url, "http://insecure.example/@zereight/mcp-gitlab/latest");
|
|
235
|
+
assert.equal(calls[0]?.authorization, null);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -1,5 +1,176 @@
|
|
|
1
|
-
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
8
|
+
const PACKAGE_LATEST_PATH = "@zereight/mcp-gitlab/latest";
|
|
2
9
|
const RELEASE_VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
10
|
+
const AUTH_TOKEN_KEY_PATTERN = /^\/\/[-A-Za-z0-9.]+(?::\d+)?(?:\/[-A-Za-z0-9._]+)*\/:_authToken$/;
|
|
11
|
+
const NPMRC_ENV_PATTERN = /\$\{([A-Za-z_]\w*)\}/g;
|
|
12
|
+
/**
|
|
13
|
+
* A configured registry is only used when it parses as a plain HTTP(S) URL
|
|
14
|
+
* with a hostname. A query or fragment is rejected too: the package path is
|
|
15
|
+
* appended to this value with plain string concatenation below, so a query
|
|
16
|
+
* string like "?tenant=a" would swallow the appended path instead of the
|
|
17
|
+
* request reaching the intended endpoint.
|
|
18
|
+
*/
|
|
19
|
+
function isUsableRegistryUrl(registry) {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = new URL(registry);
|
|
22
|
+
return ((parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
|
23
|
+
parsed.hostname !== "" &&
|
|
24
|
+
parsed.search === "" &&
|
|
25
|
+
parsed.hash === "");
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Runs `npm config get <key>` and returns the trimmed value, or null if unavailable/unset. */
|
|
32
|
+
async function npmConfigGet(key) {
|
|
33
|
+
try {
|
|
34
|
+
// On Windows "npm" resolves to npm.cmd, which execFile can't launch
|
|
35
|
+
// directly — route through cmd.exe instead. Command and args are fixed
|
|
36
|
+
// literals (only `key` varies, and only with our own constant inputs), so
|
|
37
|
+
// this doesn't open a shell-injection surface.
|
|
38
|
+
const isWindows = process.platform === "win32";
|
|
39
|
+
const { stdout } = await execFileAsync(isWindows ? "cmd.exe" : "npm", isWindows ? ["/d", "/s", "/c", "npm", "config", "get", key] : ["config", "get", key], { timeout: 5000, windowsHide: true });
|
|
40
|
+
const value = stdout.trim().replace(/\/$/, "");
|
|
41
|
+
return value && value !== "undefined" ? value : null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Resolves the npm registry to use, honoring `.npmrc` / `npm_config_registry`.
|
|
49
|
+
* Package-scoped config (`@zereight:registry`) takes precedence, matching how
|
|
50
|
+
* npm itself resolves the registry for a scoped package like this one.
|
|
51
|
+
*/
|
|
52
|
+
async function resolveConfiguredRegistry() {
|
|
53
|
+
const scoped = await npmConfigGet("@zereight:registry");
|
|
54
|
+
if (scoped)
|
|
55
|
+
return scoped;
|
|
56
|
+
const unscoped = await npmConfigGet("registry");
|
|
57
|
+
if (unscoped)
|
|
58
|
+
return unscoped;
|
|
59
|
+
return DEFAULT_NPM_REGISTRY;
|
|
60
|
+
}
|
|
61
|
+
function authTokenConfigKeys(registry) {
|
|
62
|
+
if (!isUsableRegistryUrl(registry))
|
|
63
|
+
return [];
|
|
64
|
+
const parsed = new URL(registry);
|
|
65
|
+
if (parsed.protocol !== "https:")
|
|
66
|
+
return [];
|
|
67
|
+
const keys = [];
|
|
68
|
+
let path = parsed.pathname.replace(/\/$/, "");
|
|
69
|
+
while (true) {
|
|
70
|
+
const key = `//${parsed.host}${path}/:_authToken`;
|
|
71
|
+
if (AUTH_TOKEN_KEY_PATTERN.test(key))
|
|
72
|
+
keys.push(key);
|
|
73
|
+
if (path === "")
|
|
74
|
+
break;
|
|
75
|
+
const slash = path.lastIndexOf("/");
|
|
76
|
+
path = slash <= 0 ? "" : path.slice(0, slash);
|
|
77
|
+
}
|
|
78
|
+
return keys;
|
|
79
|
+
}
|
|
80
|
+
async function npmrcCandidatePaths() {
|
|
81
|
+
const paths = [];
|
|
82
|
+
const configuredGlobal = process.env.NPM_CONFIG_GLOBALCONFIG;
|
|
83
|
+
const globalconfig = configuredGlobal || (await npmConfigGet("globalconfig"));
|
|
84
|
+
if (globalconfig)
|
|
85
|
+
paths.push(globalconfig);
|
|
86
|
+
paths.push(process.env.NPM_CONFIG_USERCONFIG ?? join(homedir(), ".npmrc"));
|
|
87
|
+
paths.push(join(process.cwd(), ".npmrc"));
|
|
88
|
+
return paths;
|
|
89
|
+
}
|
|
90
|
+
function unquoteNpmrcValue(value) {
|
|
91
|
+
if (value.length >= 2) {
|
|
92
|
+
const start = value[0];
|
|
93
|
+
const end = value[value.length - 1];
|
|
94
|
+
if ((start === '"' && end === '"') || (start === "'" && end === "'")) {
|
|
95
|
+
return value.slice(1, -1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
function expandNpmrcEnv(value) {
|
|
101
|
+
return value.replace(NPMRC_ENV_PATTERN, (_match, name) => process.env[name] ?? "");
|
|
102
|
+
}
|
|
103
|
+
function parseNpmrc(content) {
|
|
104
|
+
const entries = new Map();
|
|
105
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
106
|
+
const line = rawLine.trim();
|
|
107
|
+
if (!line || line.startsWith("#") || line.startsWith(";"))
|
|
108
|
+
continue;
|
|
109
|
+
const separator = line.indexOf("=");
|
|
110
|
+
if (separator <= 0)
|
|
111
|
+
continue;
|
|
112
|
+
const key = line.slice(0, separator).trim();
|
|
113
|
+
const value = expandNpmrcEnv(unquoteNpmrcValue(line.slice(separator + 1).trim()));
|
|
114
|
+
entries.set(key, value);
|
|
115
|
+
}
|
|
116
|
+
return entries;
|
|
117
|
+
}
|
|
118
|
+
async function readNpmrcFile(path) {
|
|
119
|
+
try {
|
|
120
|
+
return parseNpmrc(await readFile(path, "utf8"));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return new Map();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Reads `//host[:port][/path]/:_authToken` from npmrc files, then walks up
|
|
128
|
+
* parent paths to `//host/:_authToken`, matching npm-registry-fetch.
|
|
129
|
+
* `npm config get` refuses to print protected auth keys, so this parses the
|
|
130
|
+
* files npm itself would consult instead of asking the CLI.
|
|
131
|
+
*/
|
|
132
|
+
async function resolveRegistryAuthToken(registry) {
|
|
133
|
+
const keys = authTokenConfigKeys(registry);
|
|
134
|
+
if (keys.length === 0)
|
|
135
|
+
return null;
|
|
136
|
+
const merged = new Map();
|
|
137
|
+
for (const path of await npmrcCandidatePaths()) {
|
|
138
|
+
const entries = await readNpmrcFile(path);
|
|
139
|
+
for (const [entryKey, value] of entries)
|
|
140
|
+
merged.set(entryKey, value);
|
|
141
|
+
}
|
|
142
|
+
for (const key of keys) {
|
|
143
|
+
const token = merged.get(key);
|
|
144
|
+
if (token)
|
|
145
|
+
return token;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
function requestIsUnderRegistry(registry, requestUrl) {
|
|
150
|
+
try {
|
|
151
|
+
const parsedRegistry = new URL(registry);
|
|
152
|
+
const parsedRequest = new URL(requestUrl);
|
|
153
|
+
if (parsedRegistry.protocol !== "https:" || parsedRequest.protocol !== "https:")
|
|
154
|
+
return false;
|
|
155
|
+
if (parsedRegistry.origin !== parsedRequest.origin)
|
|
156
|
+
return false;
|
|
157
|
+
const basePath = parsedRegistry.pathname.replace(/\/$/, "");
|
|
158
|
+
if (basePath === "")
|
|
159
|
+
return true;
|
|
160
|
+
return parsedRequest.pathname === basePath || parsedRequest.pathname.startsWith(`${basePath}/`);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function readVersionField(data) {
|
|
167
|
+
if (typeof data !== "object" || data === null)
|
|
168
|
+
return null;
|
|
169
|
+
if (!("version" in data))
|
|
170
|
+
return null;
|
|
171
|
+
const version = data.version;
|
|
172
|
+
return typeof version === "string" ? version : null;
|
|
173
|
+
}
|
|
3
174
|
export function isNewerVersion(candidate, current) {
|
|
4
175
|
const parse = (version) => version.split(".").map(part => Number.parseInt(part, 10));
|
|
5
176
|
const a = parse(candidate);
|
|
@@ -12,16 +183,27 @@ export function isNewerVersion(candidate, current) {
|
|
|
12
183
|
}
|
|
13
184
|
return false;
|
|
14
185
|
}
|
|
15
|
-
export async function fetchLatestVersion(fetchFn = fetch, timeoutMs = 3000) {
|
|
186
|
+
export async function fetchLatestVersion(fetchFn = fetch, timeoutMs = 3000, resolveRegistry = resolveConfiguredRegistry) {
|
|
16
187
|
try {
|
|
17
|
-
const
|
|
188
|
+
const resolved = (await resolveRegistry()).replace(/\/$/, "");
|
|
189
|
+
const registry = isUsableRegistryUrl(resolved) ? resolved : DEFAULT_NPM_REGISTRY;
|
|
190
|
+
const registryLatestUrl = `${registry}/${PACKAGE_LATEST_PATH}`;
|
|
191
|
+
const headers = { accept: "application/json" };
|
|
192
|
+
const init = {
|
|
18
193
|
signal: AbortSignal.timeout(timeoutMs),
|
|
19
|
-
headers
|
|
20
|
-
}
|
|
194
|
+
headers,
|
|
195
|
+
};
|
|
196
|
+
const token = await resolveRegistryAuthToken(registry).catch(() => null);
|
|
197
|
+
if (token && requestIsUnderRegistry(registry, registryLatestUrl)) {
|
|
198
|
+
headers.authorization = `Bearer ${token}`;
|
|
199
|
+
// Never follow redirects with a token attached — fetch would otherwise
|
|
200
|
+
// forward Authorization to a different origin.
|
|
201
|
+
init.redirect = "error";
|
|
202
|
+
}
|
|
203
|
+
const response = await fetchFn(registryLatestUrl, init);
|
|
21
204
|
if (!response.ok)
|
|
22
205
|
return null;
|
|
23
|
-
|
|
24
|
-
return typeof data.version === "string" ? data.version : null;
|
|
206
|
+
return readVersionField(await response.json());
|
|
25
207
|
}
|
|
26
208
|
catch {
|
|
27
209
|
// Fail silent: the update check must never break or delay server startup
|
package/package.json
CHANGED