From f02829410933b8404203ff6bea0602851cecb89f Mon Sep 17 00:00:00 2001 From: bowersjames <162869040+bowersjames@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:38:20 +0800 Subject: [PATCH] Improve doctor reachability diagnostics --- package.json | 2 +- src/cli.ts | 20 ++++++++++++++++++-- src/health.test.ts | 19 +++++++++++++++++++ src/health.ts | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 src/health.test.ts create mode 100644 src/health.ts diff --git a/package.json b/package.json index 7962ae98..f92b1922 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/health.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli.ts b/src/cli.ts index 87ba662d..6d79a3d2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ import { type DevspaceUserConfig, } from "./user-config.js"; import { expandHomePath } from "./roots.js"; +import { checkEndpoint, formatEndpointHealth } from "./health.js"; type Command = "serve" | "init" | "doctor" | "config" | "help" | "version"; const require = createRequire(import.meta.url); @@ -219,10 +220,25 @@ async function runDoctor(): Promise { try { const config = loadConfig(); - console.log(`Local MCP URL: http://${config.host}:${config.port}/mcp`); - console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`); + const localMcpUrl = `http://${config.host}:${config.port}/mcp`; + const publicMcpUrl = new URL("/mcp", config.publicBaseUrl).toString(); + const localHealthUrl = `http://${config.host}:${config.port}/healthz`; + const publicHealthUrl = new URL("/healthz", config.publicBaseUrl).toString(); + console.log(`Local MCP URL: ${localMcpUrl}`); + console.log(`Public MCP URL: ${publicMcpUrl}`); console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`); console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`); + + const localHealth = await checkEndpoint(localHealthUrl); + const publicHealth = await checkEndpoint(publicHealthUrl); + console.log(`Local server: ${formatEndpointHealth(localHealth)}`); + console.log(`Public tunnel: ${formatEndpointHealth(publicHealth)}`); + + if (!localHealth.ok) { + console.log("Action: start DevSpace with `devspace serve`."); + } else if (!publicHealth.ok) { + console.log("Action: start or repair the HTTPS tunnel, then update publicBaseUrl and reconnect the MCP client."); + } } catch (error) { console.log(`Config status: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/health.test.ts b/src/health.test.ts new file mode 100644 index 00000000..a0b2fa02 --- /dev/null +++ b/src/health.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { checkEndpoint } from "./health.js"; + +assert.deepEqual( + await checkEndpoint("http://127.0.0.1:7676/healthz", async () => new Response("ok", { status: 200 })), + { ok: true, status: 200 }, +); + +assert.deepEqual( + await checkEndpoint("https://stale.example.com/healthz", async () => { + throw new TypeError("fetch failed"); + }), + { ok: false, error: "fetch failed" }, +); + +assert.deepEqual( + await checkEndpoint("https://example.com/healthz", async () => new Response("no", { status: 502 })), + { ok: false, status: 502, error: "HTTP 502" }, +); diff --git a/src/health.ts b/src/health.ts new file mode 100644 index 00000000..85fd3cff --- /dev/null +++ b/src/health.ts @@ -0,0 +1,38 @@ +export interface EndpointHealth { + ok: boolean; + status?: number; + error?: string; +} + +type Fetch = (input: string, init?: RequestInit) => Promise; + +export async function checkEndpoint( + url: string, + fetcher: Fetch = fetch, +): Promise { + try { + const response = await fetcher(url, { + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) { + return { + ok: false, + status: response.status, + error: `HTTP ${response.status}`, + }; + } + + return { ok: true, status: response.status }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export function formatEndpointHealth(health: EndpointHealth): string { + return health.ok + ? `reachable (HTTP ${health.status})` + : `unreachable (${health.error ?? "unknown error"})`; +}