From a7914d5061df2ecdb3784919c8f2fb7e97d1564d Mon Sep 17 00:00:00 2001 From: bittoby <218712309+bittoby@users.noreply.github.com> Date: Thu, 28 May 2026 23:41:46 +0000 Subject: [PATCH] feat(mcp): detect stale installs and API compatibility in doctor and status --- CHANGELOG.md | 2 + packages/gittensory-mcp/CHANGELOG.md | 8 + packages/gittensory-mcp/bin/gittensory-mcp.js | 207 ++++++++++++++++-- test/unit/mcp-cli.test.ts | 192 +++++++++++++++- 4 files changed, 385 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2e2755b98..828e9a7dde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ - Add deterministic base-agent orchestrator (#14) +- Detect stale installs and API compatibility in doctor and status + ### Fixes diff --git a/packages/gittensory-mcp/CHANGELOG.md b/packages/gittensory-mcp/CHANGELOG.md index 5add7774d6..383c1146af 100644 --- a/packages/gittensory-mcp/CHANGELOG.md +++ b/packages/gittensory-mcp/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + + +### Features + +- Detect stale installs and API compatibility in doctor and status + + ## mcp-v0.2.0 - 2026-05-28 diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index c28ba783c3..0163f00d00 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -11,6 +11,9 @@ const defaultApiUrl = "https://gittensory-api.aethereal.dev"; const legacyDefaultApiUrls = new Set(["https://gittensory-api.zeronode.workers.dev"]); const packageName = "@jsonbored/gittensory-mcp"; const packageVersion = "0.2.0"; +const npmRegistryUrl = (process.env.GITTENSORY_NPM_REGISTRY_URL ?? "https://registry.npmjs.org").replace(/\/+$/, ""); +const upgradeCommand = `npm install -g ${packageName}@latest`; +const npxFallbackCommand = `npx ${packageName}@latest `; const changelogPath = new URL("../CHANGELOG.md", import.meta.url); const configPath = process.env.GITTENSORY_CONFIG_PATH ?? @@ -678,46 +681,46 @@ async function whoami(options) { async function status(options) { let auth = { status: getApiToken() ? "token_configured" : "unauthenticated" }; let health = null; - let latest = null; if (getApiToken()) { try { auth = await apiGet("/v1/auth/session"); } catch (error) { - auth = { status: "token_configured", session: "unverified", error: error instanceof Error ? error.message : "status_failed" }; + auth = { status: "token_configured", session: "unverified", error: sanitizeDiagnosticText(error instanceof Error ? error.message : "status_failed") }; } } try { health = await apiFetch("/health", { method: "GET" }, { auth: false, timeoutMs: 5000 }); } catch (error) { - health = { status: "unreachable", error: error instanceof Error ? error.message : "health_check_failed" }; - } - try { - latest = await fetchLatestPackageVersion(); - } catch (error) { - latest = { status: "unavailable", error: error instanceof Error ? error.message : "npm_version_check_failed" }; + health = { status: "unreachable", error: sanitizeDiagnosticText(error instanceof Error ? error.message : "health_check_failed") }; } + const pkg = await inspectInstallVersion(); + const apiCompatibility = evaluateApiCompatibility(health); const payload = { apiUrl, - package: { - name: packageName, - version: packageVersion, - latestVersion: latest?.version ?? null, - updateAvailable: typeof latest?.version === "string" && latest.version !== packageVersion, - latestStatus: latest?.status ?? "ok", - }, + package: pkg, + apiCompatibility, api: health, auth, - configPath, + config: { configured: existsSync(configPath) }, sourceUploadDefault: false, sourceUploadSupported: false, }; if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); else { - process.stdout.write(`${packageName}: ${packageVersion}${payload.package.latestVersion ? ` (latest ${payload.package.latestVersion})` : ""}\n`); + process.stdout.write(`${packageName}: ${packageVersion}${pkg.latestVersion ? ` (latest ${pkg.latestVersion})` : ""}\n`); process.stdout.write(`API: ${apiUrl}\n`); process.stdout.write(`API health: ${health?.status ?? "unknown"}\n`); process.stdout.write(`Auth: ${auth.status}${auth.login ? ` (${auth.login})` : ""}\n`); process.stdout.write("Source upload: disabled\n"); + if (pkg.state === "stale") { + process.stdout.write(`Update available: ${packageVersion} -> ${pkg.latestVersion}. Upgrade with:\n ${pkg.upgradeCommand}\n`); + process.stdout.write(`Or run without installing:\n ${pkg.npxFallback}\n`); + } else if (pkg.state === "unavailable") { + process.stdout.write("Version check: npm registry was unavailable; skipping update check.\n"); + } + if (apiCompatibility.status === "incompatible") { + process.stdout.write(`API requires at least ${packageName}@${apiCompatibility.minVersion}. Upgrade with:\n ${apiCompatibility.upgradeCommand}\n`); + } } } @@ -736,15 +739,53 @@ async function changelog(options) { async function doctor(options) { const checks = []; - const add = (name, statusValue, detail, remediation) => checks.push(stripUndefined({ name, status: statusValue, detail, remediation })); + const add = (name, statusValue, detail, remediation) => + checks.push( + stripUndefined({ + name, + status: statusValue, + detail: sanitizeDiagnosticText(detail, [options.cwd]), + remediation: sanitizeDiagnosticText(remediation, [options.cwd]), + }), + ); + let health = null; try { - const health = await apiFetch("/health", { method: "GET" }, { auth: false }); + health = await apiFetch("/health", { method: "GET" }, { auth: false }); add("api_health", health.status === "ok" ? "pass" : "warn", `API responded from ${apiUrl}.`); } catch (error) { + health = { status: "unreachable" }; add("api_health", "fail", error instanceof Error ? error.message : "health_check_failed", "Check GITTENSORY_API_URL or network access."); } + const pkg = await inspectInstallVersion(); + if (pkg.state === "stale") { + add("version", "warn", `Installed ${packageVersion} is behind npm latest ${pkg.latestVersion}.`, `${pkg.upgradeCommand} (no-install fallback: ${pkg.npxFallback})`); + } else if (pkg.state === "unavailable") { + add("version", "warn", "Could not reach the npm registry to check for updates.", `Retry when online, or run the no-install fallback: ${npxFallbackCommand}`); + } else if (pkg.state === "unknown") { + add("version", "warn", `Could not compare local ${packageVersion} against npm latest ${pkg.latestVersion ?? "unknown"}.`); + } else if (pkg.state === "ahead") { + add("version", "pass", `Installed ${packageVersion} is ahead of npm latest ${pkg.latestVersion}.`); + } else if (pkg.state === "skipped") { + add("version", "pass", "npm version check was skipped (GITTENSORY_SKIP_NPM_VERSION_CHECK)."); + } else { + add("version", "pass", `Installed ${packageVersion} matches npm latest ${pkg.latestVersion}.`); + } + + const apiCompatibility = evaluateApiCompatibility(health); + if (apiCompatibility.status === "incompatible") { + add("api_compatibility", "fail", `API requires at least ${packageName}@${apiCompatibility.minVersion}; local is ${packageVersion}.`, apiCompatibility.upgradeCommand); + } else if (apiCompatibility.status === "compatible") { + add("api_compatibility", "pass", `Local ${packageVersion} meets the API minimum ${apiCompatibility.minVersion}.`); + } else if (apiCompatibility.reason === "api_unreachable") { + add("api_compatibility", "warn", "API compatibility check was unavailable because API health was unreachable."); + } else if (apiCompatibility.status === "unknown") { + add("api_compatibility", "warn", `API reported an unsupported minimum client version (${apiCompatibility.minVersion}).`); + } else { + add("api_compatibility", "pass", "API did not report a minimum client version; compatibility check skipped."); + } + const token = getApiToken(); if (!token) { add("auth", "fail", "No Gittensory API/session token is configured.", "Run `gittensory-mcp login`."); @@ -776,13 +817,13 @@ async function doctor(options) { } const commandPath = findExecutable("gittensory-mcp"); - if (commandPath) add("client_path", "pass", `gittensory-mcp is visible on PATH at ${commandPath}.`); + if (commandPath) add("client_path", "pass", "gittensory-mcp is visible on PATH."); else add("client_path", "warn", "gittensory-mcp was not found on PATH.", "Use an absolute command path in Codex, Claude, or Cursor config."); const payload = { status: checks.some((check) => check.status === "fail") ? "needs_attention" : checks.some((check) => check.status === "warn") ? "warnings" : "ok", apiUrl, - configPath, + config: { configured: existsSync(configPath) }, sourceUploadSupported: false, checks, }; @@ -887,6 +928,32 @@ function findExecutable(name) { return null; } +function sanitizeDiagnosticText(value, extraPaths = []) { + if (value === undefined || value === null) return value; + let text = String(value); + const sensitiveValues = [ + process.env.GITTENSORY_API_TOKEN, + process.env.GITTENSORY_MCP_TOKEN, + process.env.GITTENSORY_TOKEN, + config.session?.token, + ].filter((candidate) => typeof candidate === "string" && candidate.length > 0); + for (const token of sensitiveValues) { + text = text.split(token).join("[redacted]"); + } + const localPaths = [ + configPath, + process.env.GITTENSORY_CONFIG_PATH, + process.env.GITTENSORY_CONFIG_DIR, + process.cwd(), + homedir(), + ...extraPaths, + ].filter((candidate) => typeof candidate === "string" && candidate.length > 1); + for (const localPath of localPaths.sort((left, right) => right.length - left.length)) { + text = text.split(localPath).join("[local-path]"); + } + return text; +} + function loadConfig() { if (!existsSync(configPath)) return {}; try { @@ -941,7 +1008,7 @@ async function fetchLatestPackageVersion() { if (/^(1|true|yes)$/i.test(process.env.GITTENSORY_SKIP_NPM_VERSION_CHECK ?? "false")) return { status: "skipped" }; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); - const response = await fetch("https://registry.npmjs.org/@jsonbored%2fgittensory-mcp/latest", { + const response = await fetch(`${npmRegistryUrl}/@jsonbored%2fgittensory-mcp/latest`, { signal: controller.signal, headers: { accept: "application/json" }, }).finally(() => clearTimeout(timeout)); @@ -950,6 +1017,102 @@ async function fetchLatestPackageVersion() { return { status: "ok", version: payload.version }; } +function parseSemver(version) { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(version ?? "").trim()); + if (!match) return null; + return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4] ?? null }; +} + +// Compares two dot-separated semver prerelease strings per the semver spec: +// numeric identifiers compare numerically, others lexically, numeric < non-numeric, +// and a shorter set of identifiers has lower precedence when all earlier ones match. +function comparePrerelease(a, b) { + const left = a.split("."); + const right = b.split("."); + for (let index = 0; index < Math.max(left.length, right.length); index += 1) { + const leftId = left[index]; + const rightId = right[index]; + if (leftId === undefined) return -1; + if (rightId === undefined) return 1; + const leftNumeric = /^\d+$/.test(leftId); + const rightNumeric = /^\d+$/.test(rightId); + if (leftNumeric && rightNumeric) { + if (Number(leftId) !== Number(rightId)) return Number(leftId) < Number(rightId) ? -1 : 1; + } else if (leftNumeric !== rightNumeric) { + return leftNumeric ? -1 : 1; + } else if (leftId !== rightId) { + return leftId < rightId ? -1 : 1; + } + } + return 0; +} + +// Returns -1 if a < b, 1 if a > b, 0 if equal, or null when either side is unparseable. +function compareSemver(a, b) { + const left = parseSemver(a); + const right = parseSemver(b); + if (!left || !right) return null; + for (const part of ["major", "minor", "patch"]) { + if (left[part] !== right[part]) return left[part] < right[part] ? -1 : 1; + } + if (left.prerelease === right.prerelease) return 0; + // A release version has higher precedence than any prerelease of the same core. + if (left.prerelease === null) return 1; + if (right.prerelease === null) return -1; + return comparePrerelease(left.prerelease, right.prerelease); +} + +// Maps a raw npm-latest lookup into a single install state. `comparison` is the result of +// compareSemver(local, latest): negative means local is behind (stale), positive means ahead. +function classifyVersionState(latestStatus, latestVersion, comparison) { + if (latestStatus === "skipped") return "skipped"; + if (!latestVersion) return "unavailable"; + if (comparison === null) return "unknown"; + if (comparison < 0) return "stale"; + if (comparison > 0) return "ahead"; + return "current"; +} + +// Shared by `status` and `doctor`: compares the local install against npm latest and +// produces deterministic upgrade guidance. Never throws and never returns sensitive data. +async function inspectInstallVersion() { + let latest; + try { + latest = await fetchLatestPackageVersion(); + } catch (error) { + latest = { status: "unavailable", error: sanitizeDiagnosticText(error instanceof Error ? error.message : "npm_version_check_failed") }; + } + const latestVersion = typeof latest.version === "string" ? latest.version : null; + const comparison = latestVersion ? compareSemver(packageVersion, latestVersion) : null; + const state = classifyVersionState(latest.status, latestVersion, comparison); + const stale = state === "stale"; + return stripUndefined({ + name: packageName, + version: packageVersion, + latestVersion, + latestStatus: latest.status ?? "ok", + state, + updateAvailable: stale, + upgradeCommand: stale ? upgradeCommand : undefined, + npxFallback: stale ? npxFallbackCommand : undefined, + detail: latest.error, + }); +} + +// Optional API compatibility check. The backend MAY advertise a minimum supported client +// version via `minMcpVersion`/`minClientVersion` on /health; when it does not, this degrades +// gracefully to "unavailable" instead of failing. +function evaluateApiCompatibility(health) { + if (!health || health.status === "unreachable") return { status: "unavailable", reason: "api_unreachable" }; + const minVersion = + typeof health.minMcpVersion === "string" ? health.minMcpVersion : typeof health.minClientVersion === "string" ? health.minClientVersion : null; + if (!minVersion) return { status: "unavailable", reason: "not_reported" }; + const comparison = compareSemver(packageVersion, minVersion); + if (comparison === null) return { status: "unknown", minVersion }; + if (comparison < 0) return stripUndefined({ status: "incompatible", minVersion, upgradeCommand }); + return { status: "compatible", minVersion }; +} + async function analyzeCurrentBranch(input) { const payload = buildBranchAnalysisPayload(input); const { localScorerStatus, ...body } = payload; diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index cc0d625254..91955bdc09 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -36,6 +36,7 @@ describe("gittensory-mcp CLI", () => { GITTENSORY_API_URL: url, GITTENSORY_TOKEN: "session-token", GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", }), ) as { status: string; checks: Array<{ name: string; status: string; detail: string }> }; @@ -46,10 +47,188 @@ describe("gittensory-mcp CLI", () => { expect.objectContaining({ name: "auth", status: "pass", detail: expect.stringContaining("JSONbored") }), expect.objectContaining({ name: "source_upload", status: "pass" }), expect.objectContaining({ name: "git_metadata", status: "pass" }), + expect.objectContaining({ name: "version", status: "pass" }), + expect.objectContaining({ name: "api_compatibility", status: "pass" }), + ]), + ); + }); + + it("reports a stale global install with an exact upgrade command and npx fallback", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer({ latestVersion: "9.9.9" }); + const payload = JSON.parse( + await runAsync(["status", "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_NPM_REGISTRY_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }), + ) as { package: { state: string; latestVersion: string; updateAvailable: boolean; upgradeCommand: string; npxFallback: string } }; + + expect(payload.package).toMatchObject({ + state: "stale", + latestVersion: "9.9.9", + updateAvailable: true, + upgradeCommand: "npm install -g @jsonbored/gittensory-mcp@latest", + }); + expect(payload.package.npxFallback).toContain("npx @jsonbored/gittensory-mcp@latest"); + }); + + it("reports a current install without upgrade guidance", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer({ latestVersion: "0.2.0" }); + const payload = JSON.parse( + await runAsync(["status", "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_NPM_REGISTRY_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }), + ) as { package: { state: string; updateAvailable: boolean; upgradeCommand?: string } }; + + expect(payload.package.state).toBe("current"); + expect(payload.package.updateAvailable).toBe(false); + expect(payload.package.upgradeCommand).toBeUndefined(); + }); + + it("orders prerelease npm versions correctly (release outranks prerelease of the same core)", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + // Local 0.2.0 (release) vs latest 0.2.0-rc.1 (prerelease) -> local is ahead, not stale. + const aheadUrl = await startFixtureServer({ latestVersion: "0.2.0-rc.1" }); + const ahead = JSON.parse( + await runAsync(["status", "--json"], { + GITTENSORY_API_URL: aheadUrl, + GITTENSORY_NPM_REGISTRY_URL: aheadUrl, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }), + ) as { package: { state: string; updateAvailable: boolean } }; + expect(ahead.package).toMatchObject({ state: "ahead", updateAvailable: false }); + await new Promise((resolve) => server?.close(() => resolve())); + + // Local 0.2.0 vs a higher-core prerelease 0.3.0-rc.1 -> stale. + const staleUrl = await startFixtureServer({ latestVersion: "0.3.0-rc.1" }); + const stale = JSON.parse( + await runAsync(["status", "--json"], { + GITTENSORY_API_URL: staleUrl, + GITTENSORY_NPM_REGISTRY_URL: staleUrl, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }), + ) as { package: { state: string } }; + expect(stale.package.state).toBe("stale"); + }); + + it("treats an unavailable npm registry as a warning, not a hard failure", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer({ npmStatus: 500 }); + const status = JSON.parse( + await runAsync(["status", "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_NPM_REGISTRY_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }), + ) as { package: { state: string; updateAvailable: boolean } }; + expect(status.package.state).toBe("unavailable"); + expect(status.package.updateAvailable).toBe(false); + + const doctor = JSON.parse( + await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_NPM_REGISTRY_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }), + ) as { status: string; checks: Array<{ name: string; status: string; remediation?: string }> }; + expect(doctor.status).not.toBe("needs_attention"); + expect(doctor.checks).toEqual(expect.arrayContaining([expect.objectContaining({ name: "version", status: "warn" })])); + }); + + it("flags a stale install in doctor with upgrade remediation", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer({ latestVersion: "1.0.0" }); + const payload = JSON.parse( + await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_NPM_REGISTRY_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }), + ) as { checks: Array<{ name: string; status: string; remediation?: string }> }; + const version = payload.checks.find((check) => check.name === "version"); + expect(version).toMatchObject({ status: "warn" }); + expect(version?.remediation).toContain("npm install -g @jsonbored/gittensory-mcp@latest"); + expect(version?.remediation).toContain("npx @jsonbored/gittensory-mcp@latest"); + }); + + it("reports API compatibility as unavailable when the API does not advertise a minimum version", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + const payload = JSON.parse( + await runAsync(["status", "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }), + ) as { apiCompatibility: { status: string } }; + expect(payload.apiCompatibility.status).toBe("unavailable"); + }); + + it("flags API compatibility mismatches with upgrade guidance", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer({ minMcpVersion: "9.0.0" }); + const env = { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }; + const status = JSON.parse(await runAsync(["status", "--json"], env)) as { apiCompatibility: { status: string; minVersion: string; upgradeCommand: string } }; + expect(status.apiCompatibility).toMatchObject({ + status: "incompatible", + minVersion: "9.0.0", + upgradeCommand: "npm install -g @jsonbored/gittensory-mcp@latest", + }); + + const doctor = JSON.parse(await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], env)) as { + checks: Array<{ name: string; status: string; remediation?: string }>; + }; + expect(doctor.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "api_compatibility", + status: "fail", + remediation: "npm install -g @jsonbored/gittensory-mcp@latest", + }), ]), ); }); + it("does not print configured tokens or local absolute paths in status or doctor output", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer({ latestVersion: "9.9.9", minMcpVersion: "9.0.0" }); + const env = { + GITTENSORY_API_URL: url, + GITTENSORY_NPM_REGISTRY_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }; + const statusOutput = await runAsync(["status"], env); + const statusJsonOutput = await runAsync(["status", "--json"], env); + const doctorOutput = await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory"], env); + const doctorJsonOutput = await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], env); + for (const output of [statusOutput, statusJsonOutput, doctorOutput, doctorJsonOutput]) { + expect(output).not.toContain("session-token"); + expect(output).not.toContain(tempDir); + expect(output).not.toMatch(/"configPath"/); + } + expect(statusOutput).not.toContain("session-token"); + // Sanity: upgrade guidance still surfaces in human-readable output. + expect(statusOutput).toContain("npm install -g @jsonbored/gittensory-mcp@latest"); + }); + it("reports package status and prints the packaged changelog", async () => { tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); const url = await startFixtureServer(); @@ -137,11 +316,20 @@ function runAsync(args: string[], env: Record = {}) { }); } -async function startFixtureServer() { +async function startFixtureServer(options: { latestVersion?: string; minMcpVersion?: string; npmStatus?: number } = {}) { server = createServer((request, response) => { response.setHeader("content-type", "application/json"); + if (request.url && request.url.includes("gittensory-mcp/latest")) { + if (options.npmStatus && options.npmStatus >= 400) { + response.statusCode = options.npmStatus; + response.end(JSON.stringify({ error: "registry_error" })); + return; + } + response.end(JSON.stringify({ version: options.latestVersion ?? "0.2.0" })); + return; + } if (request.url === "/health") { - response.end(JSON.stringify({ status: "ok", service: "gittensory-api" })); + response.end(JSON.stringify({ status: "ok", service: "gittensory-api", ...(options.minMcpVersion ? { minMcpVersion: options.minMcpVersion } : {}) })); return; } if (request.url === "/v1/auth/session" && request.headers.authorization === "Bearer session-token") {