mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de7960ec60 | ||
|
|
90c37e2f95 | ||
|
|
59e0b2ec62 | ||
|
|
15d7763d0b | ||
|
|
3a4b463deb | ||
|
|
bb737dea52 |
@@ -0,0 +1,47 @@
|
||||
# Paseo Doctor / Health Check
|
||||
|
||||
## Problem
|
||||
|
||||
Users need a way to diagnose their Paseo setup: are agent binaries installed, what versions are running, is the config valid? Currently there's no unified diagnostic — errors only surface when you try to launch an agent and it fails.
|
||||
|
||||
## Decision: Shared library + HTTP endpoint
|
||||
|
||||
The check logic lives as a pure module in `packages/server` (no daemon dependency). It inspects the local filesystem and runs `which`/`--version` commands.
|
||||
|
||||
- **Daemon** exposes it via `GET /api/doctor` (stateless, no WS session needed).
|
||||
- **CLI** imports the module directly for offline use (`paseo doctor`), or calls the HTTP endpoint with `--remote`.
|
||||
- **App** calls the HTTP endpoint from the settings screen.
|
||||
|
||||
## Data Model
|
||||
|
||||
```typescript
|
||||
type CheckStatus = "ok" | "warn" | "error";
|
||||
|
||||
interface DoctorCheckResult {
|
||||
id: string; // e.g. "provider.claude.binary"
|
||||
label: string; // e.g. "Claude CLI"
|
||||
status: CheckStatus;
|
||||
detail: string; // e.g. "/usr/local/bin/claude (v1.0.42)"
|
||||
}
|
||||
|
||||
interface DoctorReport {
|
||||
checks: DoctorCheckResult[];
|
||||
summary: { ok: number; warn: number; error: number };
|
||||
timestamp: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Checks
|
||||
|
||||
| ID | What it checks | ok | warn | error |
|
||||
|---|---|---|---|---|
|
||||
| provider.claude.binary | which claude | Found + path | — | Not found |
|
||||
| provider.claude.version | claude --version | Version string | Parse failed | Binary missing |
|
||||
| provider.codex.binary | which codex | Found + path | — | Not found |
|
||||
| provider.codex.version | codex --version | Version string | Parse failed | Binary missing |
|
||||
| provider.opencode.binary | which opencode | Found + path | — | Not found |
|
||||
| provider.opencode.version | opencode --version | Version string | Parse failed | Binary missing |
|
||||
| config.valid | Parse config.json | Valid | — | Parse/schema error |
|
||||
| config.listen | Listen address format | Valid | — | Malformed |
|
||||
| runtime.node | Node.js version | Version | — | Not found |
|
||||
| runtime.paseo | Paseo daemon version | Version | — | Unknown |
|
||||
@@ -17,6 +17,8 @@ import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||
import type { DoctorReport } from "@server/server/doctor/types";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -980,6 +982,9 @@ function HostDetailModal({
|
||||
const activeConnection = runtimeSnapshot?.activeConnection ?? null;
|
||||
const lastError = runtimeSnapshot?.lastError ?? null;
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [doctorReport, setDoctorReport] = useState<DoctorReport | null>(null);
|
||||
const [doctorLoading, setDoctorLoading] = useState(false);
|
||||
const [doctorError, setDoctorError] = useState<string | null>(null);
|
||||
const isHostConnected = useCallback(() => {
|
||||
if (!host) {
|
||||
return false;
|
||||
@@ -1093,6 +1098,30 @@ function HostDetailModal({
|
||||
setDraftLabel(nextValue);
|
||||
}, []);
|
||||
|
||||
const runHealthCheck = useCallback(async () => {
|
||||
const endpoint =
|
||||
activeConnection?.endpoint ??
|
||||
host?.connections.find((c) => c.type === "directTcp")?.endpoint ??
|
||||
null;
|
||||
if (!endpoint) return;
|
||||
|
||||
setDoctorLoading(true);
|
||||
setDoctorError(null);
|
||||
try {
|
||||
const parsed = new URL(buildDaemonWebSocketUrl(endpoint));
|
||||
parsed.protocol = parsed.protocol === "wss:" ? "https:" : "http:";
|
||||
const baseUrl = parsed.toString().replace(/\/ws\/?$/, "");
|
||||
const res = await fetch(`${baseUrl}/api/doctor`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const report = (await res.json()) as DoctorReport;
|
||||
setDoctorReport(report);
|
||||
} catch (err) {
|
||||
setDoctorError(err instanceof Error ? err.message : "Health check failed");
|
||||
} finally {
|
||||
setDoctorLoading(false);
|
||||
}
|
||||
}, [activeConnection, host]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !host) return;
|
||||
// Initialize once per modal open / host switch; keep user edits fully local while typing.
|
||||
@@ -1103,6 +1132,9 @@ function HostDetailModal({
|
||||
if (!visible) {
|
||||
setIsRestarting(false);
|
||||
setDraftLabel("");
|
||||
setDoctorReport(null);
|
||||
setDoctorLoading(false);
|
||||
setDoctorError(null);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
@@ -1186,6 +1218,76 @@ function HostDetailModal({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Diagnostics */}
|
||||
{host ? (
|
||||
<View style={styles.formField}>
|
||||
<Text style={styles.label}>Diagnostics</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={() => { void runHealthCheck(); }}
|
||||
disabled={doctorLoading || !isConnected}
|
||||
>
|
||||
{doctorLoading ? "Running..." : doctorReport ? "Re-run" : "Run Health Check"}
|
||||
</Button>
|
||||
{doctorError ? (
|
||||
<Text style={{ color: theme.colors.destructive, fontSize: theme.fontSize.xs, marginTop: theme.spacing[2] }}>
|
||||
{doctorError}
|
||||
</Text>
|
||||
) : null}
|
||||
{doctorReport ? (
|
||||
<View style={{ marginTop: theme.spacing[2], gap: theme.spacing[3] }}>
|
||||
{/* Summary */}
|
||||
<Text style={{ fontSize: theme.fontSize.sm }}>
|
||||
{doctorReport.summary.ok > 0 ? (
|
||||
<Text style={{ color: theme.colors.palette.green[400] }}>{doctorReport.summary.ok} passed</Text>
|
||||
) : null}
|
||||
{doctorReport.summary.ok > 0 && (doctorReport.summary.warn > 0 || doctorReport.summary.error > 0) ? " · " : ""}
|
||||
{doctorReport.summary.warn > 0 ? (
|
||||
<Text style={{ color: theme.colors.palette.amber[500] }}>{doctorReport.summary.warn} warning{doctorReport.summary.warn !== 1 ? "s" : ""}</Text>
|
||||
) : null}
|
||||
{doctorReport.summary.warn > 0 && doctorReport.summary.error > 0 ? " · " : ""}
|
||||
{doctorReport.summary.error > 0 ? (
|
||||
<Text style={{ color: theme.colors.destructive }}>{doctorReport.summary.error} error{doctorReport.summary.error !== 1 ? "s" : ""}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
{/* Grouped checks */}
|
||||
{(["provider", "config", "runtime"] as const).map((prefix) => {
|
||||
const groupChecks = doctorReport.checks.filter((c) => c.id.startsWith(`${prefix}.`));
|
||||
if (groupChecks.length === 0) return null;
|
||||
const groupLabel = prefix === "provider" ? "Providers" : prefix === "config" ? "Config" : "Runtime";
|
||||
return (
|
||||
<View key={prefix} style={{ gap: theme.spacing[1] }}>
|
||||
<Text style={{ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, textTransform: "uppercase", marginBottom: theme.spacing[1] }}>
|
||||
{groupLabel}
|
||||
</Text>
|
||||
{groupChecks.map((check) => (
|
||||
<View key={check.id} style={{ flexDirection: "row", alignItems: "flex-start", gap: theme.spacing[2], paddingVertical: 3 }}>
|
||||
<Text style={{
|
||||
fontSize: theme.fontSize.sm,
|
||||
width: 16,
|
||||
color: check.status === "ok"
|
||||
? theme.colors.palette.green[400]
|
||||
: check.status === "warn"
|
||||
? theme.colors.palette.amber[500]
|
||||
: theme.colors.destructive,
|
||||
}}>
|
||||
{check.status === "ok" ? "✓" : check.status === "warn" ? "⚠" : "✗"}
|
||||
</Text>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: theme.fontSize.sm, color: theme.colors.foreground }}>{check.label}</Text>
|
||||
<Text style={{ fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted }} selectable>{check.detail}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Save/Cancel + Advanced */}
|
||||
<View style={{ borderTopWidth: 1, borderTopColor: theme.colors.border, marginTop: theme.spacing[2], paddingTop: theme.spacing[4] }}>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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) => {
|
||||
const v = typeof value === 'string' ? value : ''
|
||||
if (v.includes('ok')) return 'green'
|
||||
if (v.includes('warn')) return 'yellow'
|
||||
if (v.includes('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({ host: options.host as string | undefined })
|
||||
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
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { loadPersistedConfig, type PersistedConfig } 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(config: PersistedConfig | null, loadError: string | null): DoctorCheckResult {
|
||||
if (config) {
|
||||
return {
|
||||
id: "config.valid",
|
||||
label: "Config file",
|
||||
status: "ok",
|
||||
detail: "Valid",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "config.valid",
|
||||
label: "Config file",
|
||||
status: "error",
|
||||
detail: loadError ?? "Unknown error",
|
||||
};
|
||||
}
|
||||
|
||||
function checkListenAddress(config: PersistedConfig | null): DoctorCheckResult {
|
||||
if (!config) {
|
||||
return {
|
||||
id: "config.listen",
|
||||
label: "Listen address",
|
||||
status: "error",
|
||||
detail: "Cannot check (config failed to load)",
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runConfigChecks(paseoHome: string): Promise<DoctorCheckResult[]> {
|
||||
let config: PersistedConfig | null = null;
|
||||
let loadError: string | null = null;
|
||||
try {
|
||||
config = loadPersistedConfig(paseoHome);
|
||||
} catch (err) {
|
||||
loadError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
return [checkConfigValid(config, loadError), checkListenAddress(config)];
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import type { DoctorCheckResult } from "../types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
const EXEC_TIMEOUT_MS = 5000;
|
||||
|
||||
async function whichCommand(command: string): Promise<string | null> {
|
||||
const whichBin = process.platform === "win32" ? "where" : "which";
|
||||
try {
|
||||
const { stdout } = await execFileAsync(whichBin, [command], { encoding: "utf8", timeout: EXEC_TIMEOUT_MS });
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getVersion(binaryPath: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(binaryPath, ["--version"], { encoding: "utf8", timeout: EXEC_TIMEOUT_MS });
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function checkBinary(provider: ProviderDef, binaryPath: string | null): DoctorCheckResult {
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
async function checkVersion(provider: ProviderDef, binaryPath: string | null): Promise<DoctorCheckResult> {
|
||||
if (!binaryPath) {
|
||||
return {
|
||||
id: `provider.${provider.name}.version`,
|
||||
label: `${provider.label} version`,
|
||||
status: "error",
|
||||
detail: "Binary not found",
|
||||
};
|
||||
}
|
||||
|
||||
const version = await 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",
|
||||
};
|
||||
}
|
||||
|
||||
async function checkProvider(provider: ProviderDef): Promise<DoctorCheckResult[]> {
|
||||
const binaryPath = await whichCommand(provider.command);
|
||||
return [checkBinary(provider, binaryPath), await checkVersion(provider, binaryPath)];
|
||||
}
|
||||
|
||||
export async function runProviderChecks(): Promise<DoctorCheckResult[]> {
|
||||
const perProvider = await Promise.all(PROVIDERS.map(checkProvider));
|
||||
return perProvider.flat();
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { runDoctorChecks } from "./run-doctor-checks.js";
|
||||
export type { CheckStatus, DoctorCheckResult, DoctorReport } from "./types.js";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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