cc-haha/src/server/api/doctor.ts
程序员阿江(Relakkes) 7ae885a114 fix: add safe Doctor rescue path for desktop upgrades
Online upgrades can strand users on stale desktop UI state or malformed local persistence. This adds a deny-by-default Doctor path that resets only regenerable desktop UI state, reports protected local files with redacted metadata, and keeps protected repair as a dry-run no-op until a reviewed backup-first flow exists.

Constraint: Chat transcripts, model/provider config, Skills, MCP, IM bindings, adapter sessions, OAuth tokens, plugins, and team/session records are user-owned protected state.
Rejected: Automatically rewrite malformed protected JSON | unsafe without schema-specific migrations and backups.
Rejected: Continue relying only on startup migrations | users need an explicit recovery action after a white screen.
Confidence: high
Scope-risk: moderate
Directive: Keep Doctor repair deny-by-default; do not mutate protected state without an explicit reviewed backup-first manual repair flow.
Tested: bun test src/server/__tests__/doctor-service.test.ts
Tested: cd desktop && bun run test src/components/ErrorBoundary.test.tsx src/lib/doctorRepair.test.ts src/__tests__/diagnosticsSettings.test.tsx src/components/layout/StartupErrorView.test.tsx
Tested: cd desktop && bun run lint
Tested: bun run check:server
Tested: bun run verify (failed only existing agent-utils coverage baseline; changed-lines coverage 97.62%)
Not-tested: Packaged desktop manual Doctor click path.
2026-05-07 00:04:06 +08:00

58 lines
1.8 KiB
TypeScript

import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { DoctorService } from '../services/doctorService.js'
export async function handleDoctorApi(
req: Request,
url: URL,
segments: string[],
): Promise<Response> {
try {
const sub = segments[2]
if ((req.method === 'GET' && !sub) || (req.method === 'GET' && sub === 'report')) {
const cwd = url.searchParams.get('cwd') || undefined
const service = new DoctorService({ projectRoot: cwd })
return Response.json({ report: await service.getReport() })
}
if (req.method === 'POST' && sub === 'repair') {
const body = await parseJsonBody(req)
const cwd = typeof body.cwd === 'string' ? body.cwd : url.searchParams.get('cwd') || undefined
const targetIds = Array.isArray(body.targetIds)
? body.targetIds.filter((value): value is string => typeof value === 'string' && value.length > 0)
: undefined
const service = new DoctorService({ projectRoot: cwd })
return Response.json({ result: await service.repair(targetIds) })
}
if (!sub) {
throw methodNotAllowed(req.method, '/api/doctor')
}
throw ApiError.notFound(`Unknown doctor endpoint: ${sub}`)
} catch (error) {
return errorResponse(error)
}
}
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
if (
!req.headers.get('content-length') &&
!req.headers.get('transfer-encoding') &&
!req.headers.get('content-type')
) {
return {}
}
try {
const body = await req.json()
return body && typeof body === 'object' ? body as Record<string, unknown> : {}
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
}
function methodNotAllowed(method: string, route: string): ApiError {
return new ApiError(405, `Method ${method} not allowed on ${route}`, 'METHOD_NOT_ALLOWED')
}