diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2bb8e2095..9a6d55ae4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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 ', 'Daemon host target (used with --remote)') + .action(withOutput(runDoctorCommand)) + // Advanced agent commands (less common operations) program.addCommand(createAgentCommand()) diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 000000000..b65e7562f --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -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 { + 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 { + 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 + +export async function runDoctorCommand( + options: CommandOptions, + _command: Command +): Promise { + 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), + } +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index eddfc337e..83284ec88 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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 diff --git a/packages/server/src/server/doctor/checks/config-checks.ts b/packages/server/src/server/doctor/checks/config-checks.ts new file mode 100644 index 000000000..196ee4db8 --- /dev/null +++ b/packages/server/src/server/doctor/checks/config-checks.ts @@ -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 { + return [checkConfigValid(paseoHome), checkListenAddress(paseoHome)]; +} diff --git a/packages/server/src/server/doctor/checks/provider-checks.ts b/packages/server/src/server/doctor/checks/provider-checks.ts new file mode 100644 index 000000000..9fb9814fa --- /dev/null +++ b/packages/server/src/server/doctor/checks/provider-checks.ts @@ -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 { + const results: DoctorCheckResult[] = []; + for (const provider of PROVIDERS) { + results.push(checkBinary(provider)); + results.push(checkVersion(provider)); + } + return results; +} diff --git a/packages/server/src/server/doctor/checks/runtime-checks.ts b/packages/server/src/server/doctor/checks/runtime-checks.ts new file mode 100644 index 000000000..a82bdef89 --- /dev/null +++ b/packages/server/src/server/doctor/checks/runtime-checks.ts @@ -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 { + return [checkNodeVersion(), checkPaseoVersion(options?.version)]; +} diff --git a/packages/server/src/server/doctor/index.ts b/packages/server/src/server/doctor/index.ts new file mode 100644 index 000000000..25bc33474 --- /dev/null +++ b/packages/server/src/server/doctor/index.ts @@ -0,0 +1,2 @@ +export { runDoctorChecks } from "./run-doctor-checks.js"; +export type { CheckStatus, DoctorCheckResult, DoctorReport } from "./types.js"; diff --git a/packages/server/src/server/doctor/run-doctor-checks.test.ts b/packages/server/src/server/doctor/run-doctor-checks.test.ts new file mode 100644 index 000000000..3bfd6b567 --- /dev/null +++ b/packages/server/src/server/doctor/run-doctor-checks.test.ts @@ -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"); + }); +}); diff --git a/packages/server/src/server/doctor/run-doctor-checks.ts b/packages/server/src/server/doctor/run-doctor-checks.ts new file mode 100644 index 000000000..8b8e7a1a1 --- /dev/null +++ b/packages/server/src/server/doctor/run-doctor-checks.ts @@ -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 { + 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() }; +} diff --git a/packages/server/src/server/doctor/types.ts b/packages/server/src/server/doctor/types.ts new file mode 100644 index 000000000..86b5be4dd --- /dev/null +++ b/packages/server/src/server/doctor/types.ts @@ -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; +} diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index dee16d888..88dedef55 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -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,