mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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())
|
||||
|
||||
|
||||
96
packages/cli/src/commands/doctor.ts
Normal file
96
packages/cli/src/commands/doctor.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
72
packages/server/src/server/doctor/checks/config-checks.ts
Normal file
72
packages/server/src/server/doctor/checks/config-checks.ts
Normal 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)];
|
||||
}
|
||||
87
packages/server/src/server/doctor/checks/provider-checks.ts
Normal file
87
packages/server/src/server/doctor/checks/provider-checks.ts
Normal 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;
|
||||
}
|
||||
43
packages/server/src/server/doctor/checks/runtime-checks.ts
Normal file
43
packages/server/src/server/doctor/checks/runtime-checks.ts
Normal 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)];
|
||||
}
|
||||
2
packages/server/src/server/doctor/index.ts
Normal file
2
packages/server/src/server/doctor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { runDoctorChecks } from "./run-doctor-checks.js";
|
||||
export type { CheckStatus, DoctorCheckResult, DoctorReport } from "./types.js";
|
||||
86
packages/server/src/server/doctor/run-doctor-checks.test.ts
Normal file
86
packages/server/src/server/doctor/run-doctor-checks.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
26
packages/server/src/server/doctor/run-doctor-checks.ts
Normal file
26
packages/server/src/server/doctor/run-doctor-checks.ts
Normal 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() };
|
||||
}
|
||||
14
packages/server/src/server/doctor/types.ts
Normal file
14
packages/server/src/server/doctor/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user