feat: add paseo doctor health check (server + CLI)

Adds a unified diagnostic system for Paseo setup:
- Doctor module in packages/server with provider, config, and runtime checks
- GET /api/doctor HTTP endpoint on the daemon
- `paseo doctor` CLI command (local checks by default, --remote for daemon)
- Tests for the doctor report shape and summary correctness
This commit is contained in:
Zi Makki
2026-03-09 12:51:40 +01:00
parent bb737dea52
commit 3a4b463deb
11 changed files with 458 additions and 0 deletions

View File

@@ -19,6 +19,7 @@ import { runSendCommand } from './commands/agent/send.js'
import { runInspectCommand } from './commands/agent/inspect.js'
import { runWaitCommand } from './commands/agent/wait.js'
import { runAttachCommand } from './commands/agent/attach.js'
import { runDoctorCommand } from './commands/doctor.js'
import { withOutput } from './output/index.js'
import { onboardCommand } from './commands/onboard.js'
@@ -192,6 +193,14 @@ export function createCli(): Command {
)
.action(withOutput(runDaemonRestartCommand))
program
.command('doctor')
.description('Diagnose your Paseo setup (agents, config, runtime)')
.option('--remote', 'Fetch diagnostics from the running daemon instead of checking locally')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (used with --remote)')
.action(withOutput(runDoctorCommand))
// Advanced agent commands (less common operations)
program.addCommand(createAgentCommand())

View File

@@ -0,0 +1,96 @@
import type { Command } from 'commander'
import {
runDoctorChecks,
type DoctorCheckResult,
type DoctorReport,
} from '@getpaseo/server'
import { getDaemonHost, resolveDaemonTarget } from '../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema } from '../output/index.js'
interface DoctorRow {
check: string
status: string
detail: string
}
function statusIndicator(status: DoctorCheckResult['status']): string {
switch (status) {
case 'ok':
return 'ok'
case 'warn':
return 'warn'
case 'error':
return 'error'
}
}
function toDoctorRows(report: DoctorReport): DoctorRow[] {
return report.checks.map((c) => ({
check: c.label,
status: statusIndicator(c.status),
detail: c.detail,
}))
}
function createDoctorSchema(report: DoctorReport): OutputSchema<DoctorRow> {
return {
idField: 'check',
columns: [
{ header: 'CHECK', field: 'check' },
{
header: 'STATUS',
field: 'status',
color: (value) => {
if (value === 'ok') return 'green'
if (value === 'warn') return 'yellow'
if (value === 'error') return 'red'
return undefined
},
},
{ header: 'DETAIL', field: 'detail' },
],
serialize: () => report,
}
}
async function fetchRemoteReport(host: string): Promise<DoctorReport> {
const target = resolveDaemonTarget(host)
const baseUrl =
target.type === 'tcp'
? target.url.replace(/^ws:\/\//, 'http://').replace(/\/ws$/, '')
: null
if (!baseUrl) {
throw new Error('Remote doctor requires a TCP daemon target (not unix socket)')
}
const response = await fetch(`${baseUrl}/api/doctor`)
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`Doctor endpoint returned ${response.status}: ${text}`)
}
return (await response.json()) as DoctorReport
}
export type DoctorResult = ListResult<DoctorRow>
export async function runDoctorCommand(
options: CommandOptions,
_command: Command
): Promise<DoctorResult> {
const remote = Boolean(options.remote)
let report: DoctorReport
if (remote) {
const host = getDaemonHost(options)
report = await fetchRemoteReport(host)
} else {
report = await runDoctorChecks()
}
return {
type: 'list',
data: toDoctorRows(report),
schema: createDoctorSchema(report),
}
}

View File

@@ -71,6 +71,7 @@ import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
import { runDoctorChecks } from "./doctor/index.js";
import type {
AgentClient,
AgentProvider,
@@ -248,6 +249,20 @@ export async function createPaseoDaemon(
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Doctor diagnostic endpoint
app.get("/api/doctor", async (_req, res) => {
try {
const report = await runDoctorChecks({
paseoHome: config.paseoHome,
version: daemonVersion,
});
res.json(report);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
res.status(500).json({ error: message });
}
});
app.get("/api/files/download", async (req, res) => {
const token =
typeof req.query.token === "string" && req.query.token.trim().length > 0

View File

@@ -0,0 +1,72 @@
import { loadPersistedConfig } from "../../persisted-config.js";
import type { DoctorCheckResult } from "../types.js";
/**
* Validate that a listen string is parseable as a valid listen target.
* Inline check to avoid importing from bootstrap.ts (which has heavy transitive deps).
*/
function isValidListenString(listen: string): boolean {
// Named pipe
if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) return true;
// Unix socket
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) return true;
if (listen.startsWith("unix://")) return true;
// TCP host:port
if (listen.includes(":")) {
const port = parseInt(listen.split(":")[1]!, 10);
return Number.isFinite(port);
}
// Just a port
return Number.isFinite(parseInt(listen, 10));
}
function checkConfigValid(paseoHome: string): DoctorCheckResult {
try {
loadPersistedConfig(paseoHome);
return {
id: "config.valid",
label: "Config file",
status: "ok",
detail: "Valid",
};
} catch (err) {
return {
id: "config.valid",
label: "Config file",
status: "error",
detail: err instanceof Error ? err.message : String(err),
};
}
}
function checkListenAddress(paseoHome: string): DoctorCheckResult {
try {
const config = loadPersistedConfig(paseoHome);
const listen = config.daemon?.listen ?? "127.0.0.1:6767";
if (!isValidListenString(listen)) {
return {
id: "config.listen",
label: "Listen address",
status: "error",
detail: `Malformed listen address: ${listen}`,
};
}
return {
id: "config.listen",
label: "Listen address",
status: "ok",
detail: listen,
};
} catch (err) {
return {
id: "config.listen",
label: "Listen address",
status: "error",
detail: err instanceof Error ? err.message : String(err),
};
}
}
export async function runConfigChecks(paseoHome: string): Promise<DoctorCheckResult[]> {
return [checkConfigValid(paseoHome), checkListenAddress(paseoHome)];
}

View File

@@ -0,0 +1,87 @@
import { execFileSync } from "node:child_process";
import type { DoctorCheckResult } from "../types.js";
interface ProviderDef {
name: string;
command: string;
label: string;
}
const PROVIDERS: ProviderDef[] = [
{ name: "claude", command: "claude", label: "Claude CLI" },
{ name: "codex", command: "codex", label: "Codex CLI" },
{ name: "opencode", command: "opencode", label: "OpenCode CLI" },
];
function whichCommand(command: string): string | null {
try {
return execFileSync("which", [command], { encoding: "utf8" }).trim() || null;
} catch {
return null;
}
}
function getVersion(binaryPath: string): string | null {
try {
return execFileSync(binaryPath, ["--version"], { encoding: "utf8" }).trim() || null;
} catch {
return null;
}
}
function checkBinary(provider: ProviderDef): DoctorCheckResult {
const binaryPath = whichCommand(provider.command);
if (binaryPath) {
return {
id: `provider.${provider.name}.binary`,
label: provider.label,
status: "ok",
detail: binaryPath,
};
}
return {
id: `provider.${provider.name}.binary`,
label: provider.label,
status: "error",
detail: "Not found in PATH",
};
}
function checkVersion(provider: ProviderDef): DoctorCheckResult {
const binaryPath = whichCommand(provider.command);
if (!binaryPath) {
return {
id: `provider.${provider.name}.version`,
label: `${provider.label} version`,
status: "error",
detail: "Binary not found",
};
}
const version = getVersion(binaryPath);
if (version) {
return {
id: `provider.${provider.name}.version`,
label: `${provider.label} version`,
status: "ok",
detail: version,
};
}
return {
id: `provider.${provider.name}.version`,
label: `${provider.label} version`,
status: "warn",
detail: "Installed but version could not be parsed",
};
}
export async function runProviderChecks(): Promise<DoctorCheckResult[]> {
const results: DoctorCheckResult[] = [];
for (const provider of PROVIDERS) {
results.push(checkBinary(provider));
results.push(checkVersion(provider));
}
return results;
}

View File

@@ -0,0 +1,43 @@
import { resolveDaemonVersion } from "../../daemon-version.js";
import type { DoctorCheckResult } from "../types.js";
function checkNodeVersion(): DoctorCheckResult {
return {
id: "runtime.node",
label: "Node.js",
status: "ok",
detail: process.version,
};
}
function checkPaseoVersion(version?: string): DoctorCheckResult {
const resolved = version ?? tryResolveDaemonVersion();
if (resolved) {
return {
id: "runtime.paseo",
label: "Paseo daemon",
status: "ok",
detail: resolved,
};
}
return {
id: "runtime.paseo",
label: "Paseo daemon",
status: "error",
detail: "Version unknown",
};
}
function tryResolveDaemonVersion(): string | null {
try {
return resolveDaemonVersion();
} catch {
return null;
}
}
export async function runRuntimeChecks(options?: {
version?: string;
}): Promise<DoctorCheckResult[]> {
return [checkNodeVersion(), checkPaseoVersion(options?.version)];
}

View File

@@ -0,0 +1,2 @@
export { runDoctorChecks } from "./run-doctor-checks.js";
export type { CheckStatus, DoctorCheckResult, DoctorReport } from "./types.js";

View File

@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { runDoctorChecks } from "./run-doctor-checks.js";
import type { DoctorReport, DoctorCheckResult } from "./types.js";
describe("runDoctorChecks", () => {
it("returns a valid DoctorReport shape", async () => {
const report = await runDoctorChecks();
expect(report).toHaveProperty("checks");
expect(report).toHaveProperty("summary");
expect(report).toHaveProperty("timestamp");
expect(Array.isArray(report.checks)).toBe(true);
});
it("has summary counts matching checks array", async () => {
const report = await runDoctorChecks();
const okCount = report.checks.filter((c) => c.status === "ok").length;
const warnCount = report.checks.filter((c) => c.status === "warn").length;
const errorCount = report.checks.filter((c) => c.status === "error").length;
expect(report.summary.ok).toBe(okCount);
expect(report.summary.warn).toBe(warnCount);
expect(report.summary.error).toBe(errorCount);
expect(okCount + warnCount + errorCount).toBe(report.checks.length);
});
it("has a valid ISO timestamp", async () => {
const report = await runDoctorChecks();
const parsed = new Date(report.timestamp);
expect(parsed.toISOString()).toBe(report.timestamp);
});
it("each check has the expected shape", async () => {
const report = await runDoctorChecks();
for (const check of report.checks) {
expect(typeof check.id).toBe("string");
expect(check.id.length).toBeGreaterThan(0);
expect(typeof check.label).toBe("string");
expect(check.label.length).toBeGreaterThan(0);
expect(["ok", "warn", "error"]).toContain(check.status);
expect(typeof check.detail).toBe("string");
expect(check.detail.length).toBeGreaterThan(0);
}
});
it("includes expected check IDs", async () => {
const report = await runDoctorChecks();
const ids = report.checks.map((c) => c.id);
// Provider checks
expect(ids).toContain("provider.claude.binary");
expect(ids).toContain("provider.claude.version");
expect(ids).toContain("provider.codex.binary");
expect(ids).toContain("provider.codex.version");
expect(ids).toContain("provider.opencode.binary");
expect(ids).toContain("provider.opencode.version");
// Config checks
expect(ids).toContain("config.valid");
expect(ids).toContain("config.listen");
// Runtime checks
expect(ids).toContain("runtime.node");
expect(ids).toContain("runtime.paseo");
});
it("runtime.node reports the current Node version", async () => {
const report = await runDoctorChecks();
const nodeCheck = report.checks.find((c) => c.id === "runtime.node");
expect(nodeCheck).toBeDefined();
expect(nodeCheck!.status).toBe("ok");
expect(nodeCheck!.detail).toBe(process.version);
});
it("accepts a custom version option", async () => {
const report = await runDoctorChecks({ version: "1.2.3-test" });
const paseoCheck = report.checks.find((c) => c.id === "runtime.paseo");
expect(paseoCheck).toBeDefined();
expect(paseoCheck!.status).toBe("ok");
expect(paseoCheck!.detail).toBe("1.2.3-test");
});
});

View File

@@ -0,0 +1,26 @@
import { resolvePaseoHome } from "../paseo-home.js";
import { runProviderChecks } from "./checks/provider-checks.js";
import { runConfigChecks } from "./checks/config-checks.js";
import { runRuntimeChecks } from "./checks/runtime-checks.js";
import type { DoctorReport } from "./types.js";
export async function runDoctorChecks(options?: {
paseoHome?: string;
version?: string;
}): Promise<DoctorReport> {
const paseoHome = options?.paseoHome ?? resolvePaseoHome();
const checks = [
...(await runProviderChecks()),
...(await runConfigChecks(paseoHome)),
...(await runRuntimeChecks({ version: options?.version })),
];
const summary = {
ok: checks.filter((c) => c.status === "ok").length,
warn: checks.filter((c) => c.status === "warn").length,
error: checks.filter((c) => c.status === "error").length,
};
return { checks, summary, timestamp: new Date().toISOString() };
}

View File

@@ -0,0 +1,14 @@
export type CheckStatus = "ok" | "warn" | "error";
export interface DoctorCheckResult {
id: string;
label: string;
status: CheckStatus;
detail: string;
}
export interface DoctorReport {
checks: DoctorCheckResult[];
summary: { ok: number; warn: number; error: number };
timestamp: string;
}

View File

@@ -23,6 +23,14 @@ export {
type SherpaLoaderEnvResolution,
} from "./speech/providers/local/sherpa/sherpa-runtime-env.js";
// Doctor health check
export {
runDoctorChecks,
type CheckStatus,
type DoctorCheckResult,
type DoctorReport,
} from "./doctor/index.js";
// Agent SDK types for CLI commands
export type {
AgentMode,